{
diff --git a/src/core/server/http/integration_tests/router.test.ts b/src/core/server/http/integration_tests/router.test.ts
index c3b9b20d84865..a1523781010d4 100644
--- a/src/core/server/http/integration_tests/router.test.ts
+++ b/src/core/server/http/integration_tests/router.test.ts
@@ -142,6 +142,61 @@ describe('Handler', () => {
statusCode: 400,
});
});
+
+ it('accept to receive an array payload', async () => {
+ const { server: innerServer, createRouter } = await server.setup(setupDeps);
+ const router = createRouter('/');
+
+ let body: any = null;
+ router.post(
+ {
+ path: '/',
+ validate: {
+ body: schema.arrayOf(schema.object({ foo: schema.string() })),
+ },
+ },
+ (context, req, res) => {
+ body = req.body;
+ return res.ok({ body: 'ok' });
+ }
+ );
+ await server.start();
+
+ await supertest(innerServer.listener)
+ .post('/')
+ .send([{ foo: 'bar' }, { foo: 'dolly' }])
+ .expect(200);
+
+ expect(body).toEqual([{ foo: 'bar' }, { foo: 'dolly' }]);
+ });
+
+ it('accept to receive a json primitive payload', async () => {
+ const { server: innerServer, createRouter } = await server.setup(setupDeps);
+ const router = createRouter('/');
+
+ let body: any = null;
+ router.post(
+ {
+ path: '/',
+ validate: {
+ body: schema.number(),
+ },
+ },
+ (context, req, res) => {
+ body = req.body;
+ return res.ok({ body: 'ok' });
+ }
+ );
+ await server.start();
+
+ await supertest(innerServer.listener)
+ .post('/')
+ .type('json')
+ .send('12')
+ .expect(200);
+
+ expect(body).toEqual(12);
+ });
});
describe('handleLegacyErrors', () => {
diff --git a/src/core/server/http/router/validator/validator.test.ts b/src/core/server/http/router/validator/validator.test.ts
index 729eb1b60c10a..e972e2075e705 100644
--- a/src/core/server/http/router/validator/validator.test.ts
+++ b/src/core/server/http/router/validator/validator.test.ts
@@ -132,4 +132,62 @@ describe('Router validator', () => {
'The validation rule provided in the handler is not valid'
);
});
+
+ it('should validate and infer type when data is an array', () => {
+ expect(
+ RouteValidator.from({
+ body: schema.arrayOf(schema.string()),
+ }).getBody(['foo', 'bar'])
+ ).toStrictEqual(['foo', 'bar']);
+ expect(
+ RouteValidator.from({
+ body: schema.arrayOf(schema.number()),
+ }).getBody([1, 2, 3])
+ ).toStrictEqual([1, 2, 3]);
+ expect(
+ RouteValidator.from({
+ body: schema.arrayOf(schema.object({ foo: schema.string() })),
+ }).getBody([{ foo: 'bar' }, { foo: 'dolly' }])
+ ).toStrictEqual([{ foo: 'bar' }, { foo: 'dolly' }]);
+
+ expect(() =>
+ RouteValidator.from({
+ body: schema.arrayOf(schema.number()),
+ }).getBody(['foo', 'bar', 'dolly'])
+ ).toThrowError('[0]: expected value of type [number] but got [string]');
+ expect(() =>
+ RouteValidator.from({
+ body: schema.arrayOf(schema.number()),
+ }).getBody({ foo: 'bar' })
+ ).toThrowError('expected value of type [array] but got [Object]');
+ });
+
+ it('should validate and infer type when data is a primitive', () => {
+ expect(
+ RouteValidator.from({
+ body: schema.string(),
+ }).getBody('foobar')
+ ).toStrictEqual('foobar');
+ expect(
+ RouteValidator.from({
+ body: schema.number(),
+ }).getBody(42)
+ ).toStrictEqual(42);
+ expect(
+ RouteValidator.from({
+ body: schema.boolean(),
+ }).getBody(true)
+ ).toStrictEqual(true);
+
+ expect(() =>
+ RouteValidator.from({
+ body: schema.string(),
+ }).getBody({ foo: 'bar' })
+ ).toThrowError('expected value of type [string] but got [Object]');
+ expect(() =>
+ RouteValidator.from({
+ body: schema.number(),
+ }).getBody('foobar')
+ ).toThrowError('expected value of type [number] but got [string]');
+ });
});
diff --git a/src/core/server/http/router/validator/validator.ts b/src/core/server/http/router/validator/validator.ts
index 65c0a934e6ef0..97dd2bc894f81 100644
--- a/src/core/server/http/router/validator/validator.ts
+++ b/src/core/server/http/router/validator/validator.ts
@@ -274,7 +274,7 @@ export class RouteValidator {
// if options.body.output === 'stream'
return schema.stream();
} else {
- return schema.maybe(schema.nullable(schema.object({}, { allowUnknowns: true })));
+ return schema.maybe(schema.nullable(schema.any({})));
}
}
}
diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md
index c4f3bf6caf5bd..bf7dc14c73265 100644
--- a/src/core/server/server.api.md
+++ b/src/core/server/server.api.md
@@ -450,11 +450,11 @@ export interface AuthToolkit {
export class BasePath {
// @internal
constructor(serverBasePath?: string);
- get: (request: KibanaRequest | LegacyRequest) => string;
+ get: (request: LegacyRequest | KibanaRequest) => string;
prepend: (path: string) => string;
remove: (path: string) => string;
readonly serverBasePath: string;
- set: (request: KibanaRequest | LegacyRequest, requestSpecificBasePath: string) => void;
+ set: (request: LegacyRequest | KibanaRequest, requestSpecificBasePath: string) => void;
}
// Warning: (ae-forgotten-export) The symbol "BootstrapArgs" needs to be exported by the entry point index.d.ts
diff --git a/src/core/server/server.ts b/src/core/server/server.ts
index eced24b84908c..611842e8a7de0 100644
--- a/src/core/server/server.ts
+++ b/src/core/server/server.ts
@@ -256,6 +256,10 @@ export class Server {
];
this.configService.addDeprecationProvider(rootConfigPath, coreDeprecationProvider);
+ this.configService.addDeprecationProvider(
+ elasticsearchConfig.path,
+ elasticsearchConfig.deprecations!
+ );
this.configService.addDeprecationProvider(
uiSettingsConfig.path,
uiSettingsConfig.deprecations!
diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover.js b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover.js
index abf025524522b..78ac99567d10e 100644
--- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover.js
+++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover.js
@@ -671,7 +671,9 @@ function discoverController(
$scope.$watch('state.query', (newQuery, oldQuery) => {
if (!_.isEqual(newQuery, oldQuery)) {
const query = migrateLegacyQuery(newQuery);
- $scope.updateQueryAndFetch({ query });
+ if (!_.isEqual(query, newQuery)) {
+ $scope.updateQueryAndFetch({ query });
+ }
}
});
@@ -817,6 +819,7 @@ function discoverController(
title: i18n.translate('kbn.discover.errorLoadingData', {
defaultMessage: 'Error loading data',
}),
+ toastMessage: error.shortMessage,
});
}
});
diff --git a/src/legacy/core_plugins/vis_type_markdown/public/markdown_options.tsx b/src/legacy/core_plugins/vis_type_markdown/public/markdown_options.tsx
index 53a7b1caef2a4..c70b6561c3101 100644
--- a/src/legacy/core_plugins/vis_type_markdown/public/markdown_options.tsx
+++ b/src/legacy/core_plugins/vis_type_markdown/public/markdown_options.tsx
@@ -36,7 +36,7 @@ import { MarkdownVisParams } from './types';
function MarkdownOptions({ stateParams, setValue }: VisOptionsProps) {
const onMarkdownUpdate = useCallback(
(value: MarkdownVisParams['markdown']) => setValue('markdown', value),
- []
+ [setValue]
);
return (
diff --git a/src/legacy/core_plugins/vis_type_table/public/agg_table/agg_table.js b/src/legacy/core_plugins/vis_type_table/public/agg_table/agg_table.js
index 3844f809a5257..83d7ca4084a20 100644
--- a/src/legacy/core_plugins/vis_type_table/public/agg_table/agg_table.js
+++ b/src/legacy/core_plugins/vis_type_table/public/agg_table/agg_table.js
@@ -74,7 +74,11 @@ export function KbnAggTable(config, RecursionHelper) {
// escape each cell in each row
const csvRows = rows.map(function(row) {
return Object.entries(row).map(([k, v]) => {
- return escape(formatted ? columns.find(c => c.id === k).formatter.convert(v) : v);
+ const column = columns.find(c => c.id === k);
+ if (formatted && column) {
+ return escape(column.formatter.convert(v));
+ }
+ return escape(v);
});
});
@@ -110,12 +114,16 @@ export function KbnAggTable(config, RecursionHelper) {
if (typeof $scope.dimensions === 'undefined') return;
- const { buckets, metrics } = $scope.dimensions;
+ const { buckets, metrics, splitColumn } = $scope.dimensions;
$scope.formattedColumns = table.columns
.map(function(col, i) {
const isBucket = buckets.find(bucket => bucket.accessor === i);
- const dimension = isBucket || metrics.find(metric => metric.accessor === i);
+ const isSplitColumn = splitColumn
+ ? splitColumn.find(splitColumn => splitColumn.accessor === i)
+ : undefined;
+ const dimension =
+ isBucket || isSplitColumn || metrics.find(metric => metric.accessor === i);
if (!dimension) return;
diff --git a/src/legacy/ui/ui_render/bootstrap/template.js.hbs b/src/legacy/ui/ui_render/bootstrap/template.js.hbs
index d8a55935b705a..85b6de26b9516 100644
--- a/src/legacy/ui/ui_render/bootstrap/template.js.hbs
+++ b/src/legacy/ui/ui_render/bootstrap/template.js.hbs
@@ -14,6 +14,7 @@ if (window.__kbnStrictCsp__ && window.__kbnCspNotEnforced__) {
window.onload = function () {
var files = [
'{{dllBundlePath}}/vendors.bundle.dll.js',
+ '{{regularBundlePath}}/kbn-ui-shared-deps/{{sharedDepsFilename}}',
'{{regularBundlePath}}/commons.bundle.js',
'{{regularBundlePath}}/{{appId}}.bundle.js'
];
diff --git a/src/legacy/ui/ui_render/ui_render_mixin.js b/src/legacy/ui/ui_render/ui_render_mixin.js
index 0b266b8b62726..a935270d23fce 100644
--- a/src/legacy/ui/ui_render/ui_render_mixin.js
+++ b/src/legacy/ui/ui_render/ui_render_mixin.js
@@ -21,6 +21,7 @@ import { createHash } from 'crypto';
import Boom from 'boom';
import { resolve } from 'path';
import { i18n } from '@kbn/i18n';
+import * as UiSharedDeps from '@kbn/ui-shared-deps';
import { AppBootstrap } from './bootstrap';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { fromRoot } from '../../../core/server/utils';
@@ -41,18 +42,10 @@ export function uiRenderMixin(kbnServer, server, config) {
// render all views from ./views
server.setupViews(resolve(__dirname, 'views'));
- server.exposeStaticDir(
- '/node_modules/@elastic/eui/dist/{path*}',
- fromRoot('node_modules/@elastic/eui/dist')
- );
server.exposeStaticDir(
'/node_modules/@kbn/ui-framework/dist/{path*}',
fromRoot('node_modules/@kbn/ui-framework/dist')
);
- server.exposeStaticDir(
- '/node_modules/@elastic/charts/dist/{path*}',
- fromRoot('node_modules/@elastic/charts/dist')
- );
const translationsCache = { translations: null, hash: null };
server.route({
@@ -114,14 +107,12 @@ export function uiRenderMixin(kbnServer, server, config) {
`${dllBundlePath}/vendors.style.dll.css`,
...(darkMode
? [
- `${basePath}/node_modules/@elastic/eui/dist/eui_theme_dark.css`,
+ `${basePath}/bundles/kbn-ui-shared-deps/${UiSharedDeps.darkCssDistFilename}`,
`${basePath}/node_modules/@kbn/ui-framework/dist/kui_dark.css`,
- `${basePath}/node_modules/@elastic/charts/dist/theme_only_dark.css`,
]
: [
- `${basePath}/node_modules/@elastic/eui/dist/eui_theme_light.css`,
+ `${basePath}/bundles/kbn-ui-shared-deps/${UiSharedDeps.lightCssDistFilename}`,
`${basePath}/node_modules/@kbn/ui-framework/dist/kui_light.css`,
- `${basePath}/node_modules/@elastic/charts/dist/theme_only_light.css`,
]),
`${regularBundlePath}/${darkMode ? 'dark' : 'light'}_theme.style.css`,
`${regularBundlePath}/commons.style.css`,
@@ -142,6 +133,7 @@ export function uiRenderMixin(kbnServer, server, config) {
regularBundlePath,
dllBundlePath,
styleSheetPaths,
+ sharedDepsFilename: UiSharedDeps.distFilename,
},
});
diff --git a/src/optimize/base_optimizer.js b/src/optimize/base_optimizer.js
index 9a21a4b1d5439..efff7f0aa2b46 100644
--- a/src/optimize/base_optimizer.js
+++ b/src/optimize/base_optimizer.js
@@ -19,6 +19,7 @@
import { writeFile } from 'fs';
import os from 'os';
+
import Boom from 'boom';
import MiniCssExtractPlugin from 'mini-css-extract-plugin';
import TerserPlugin from 'terser-webpack-plugin';
@@ -26,10 +27,10 @@ import webpack from 'webpack';
import Stats from 'webpack/lib/Stats';
import * as threadLoader from 'thread-loader';
import webpackMerge from 'webpack-merge';
-import { DynamicDllPlugin } from './dynamic_dll_plugin';
import WrapperPlugin from 'wrapper-webpack-plugin';
+import * as UiSharedDeps from '@kbn/ui-shared-deps';
-import { defaults } from 'lodash';
+import { DynamicDllPlugin } from './dynamic_dll_plugin';
import { IS_KIBANA_DISTRIBUTABLE } from '../legacy/utils';
import { fromRoot } from '../core/server/utils';
@@ -403,6 +404,10 @@ export default class BaseOptimizer {
// and not for the webpack compilations performance itself
hints: false,
},
+
+ externals: {
+ ...UiSharedDeps.externals,
+ },
};
// when running from the distributable define an environment variable we can use
@@ -417,17 +422,6 @@ export default class BaseOptimizer {
],
};
- // We need to add react-addons (and a few other bits) for enzyme to work.
- // https://github.com/airbnb/enzyme/blob/master/docs/guides/webpack.md
- const supportEnzymeConfig = {
- externals: {
- mocha: 'mocha',
- 'react/lib/ExecutionEnvironment': true,
- 'react/addons': true,
- 'react/lib/ReactContext': true,
- },
- };
-
const watchingConfig = {
plugins: [
new webpack.WatchIgnorePlugin([
@@ -482,9 +476,7 @@ export default class BaseOptimizer {
IS_CODE_COVERAGE ? coverageConfig : {},
commonConfig,
IS_KIBANA_DISTRIBUTABLE ? isDistributableConfig : {},
- this.uiBundles.isDevMode()
- ? webpackMerge(watchingConfig, supportEnzymeConfig)
- : productionConfig
+ this.uiBundles.isDevMode() ? watchingConfig : productionConfig
)
);
}
@@ -515,22 +507,19 @@ export default class BaseOptimizer {
}
failedStatsToError(stats) {
- const details = stats.toString(
- defaults(
- { colors: true, warningsFilter: STATS_WARNINGS_FILTER },
- Stats.presetToOptions('minimal')
- )
- );
+ const details = stats.toString({
+ ...Stats.presetToOptions('minimal'),
+ colors: true,
+ warningsFilter: STATS_WARNINGS_FILTER,
+ });
return Boom.internal(
`Optimizations failure.\n${details.split('\n').join('\n ')}\n`,
- stats.toJson(
- defaults({
- warningsFilter: STATS_WARNINGS_FILTER,
- ...Stats.presetToOptions('detailed'),
- maxModules: 1000,
- })
- )
+ stats.toJson({
+ warningsFilter: STATS_WARNINGS_FILTER,
+ ...Stats.presetToOptions('detailed'),
+ maxModules: 1000,
+ })
);
}
diff --git a/src/optimize/bundles_route/bundles_route.js b/src/optimize/bundles_route/bundles_route.js
index d3c08fae92264..f0261d44e0347 100644
--- a/src/optimize/bundles_route/bundles_route.js
+++ b/src/optimize/bundles_route/bundles_route.js
@@ -19,6 +19,7 @@
import { isAbsolute, extname } from 'path';
import LruCache from 'lru-cache';
+import * as UiSharedDeps from '@kbn/ui-shared-deps';
import { createDynamicAssetResponse } from './dynamic_asset_response';
/**
@@ -66,6 +67,12 @@ export function createBundlesRoute({
}
return [
+ buildRouteForBundles(
+ `${basePublicPath}/bundles/kbn-ui-shared-deps/`,
+ '/bundles/kbn-ui-shared-deps/',
+ UiSharedDeps.distDir,
+ fileHashCache
+ ),
buildRouteForBundles(
`${basePublicPath}/bundles/`,
'/bundles/',
diff --git a/src/optimize/dynamic_dll_plugin/dll_config_model.js b/src/optimize/dynamic_dll_plugin/dll_config_model.js
index 2a3d3dd659c67..ecf5def5aa6ca 100644
--- a/src/optimize/dynamic_dll_plugin/dll_config_model.js
+++ b/src/optimize/dynamic_dll_plugin/dll_config_model.js
@@ -23,6 +23,7 @@ import webpack from 'webpack';
import webpackMerge from 'webpack-merge';
import MiniCssExtractPlugin from 'mini-css-extract-plugin';
import TerserPlugin from 'terser-webpack-plugin';
+import * as UiSharedDeps from '@kbn/ui-shared-deps';
function generateDLL(config) {
const {
@@ -145,6 +146,9 @@ function generateDLL(config) {
// and not for the webpack compilations performance itself
hints: false,
},
+ externals: {
+ ...UiSharedDeps.externals,
+ },
};
}
diff --git a/src/optimize/watch/watch_cache.ts b/src/optimize/watch/watch_cache.ts
index ab11a8c5d2f11..15957210b3d43 100644
--- a/src/optimize/watch/watch_cache.ts
+++ b/src/optimize/watch/watch_cache.ts
@@ -18,17 +18,18 @@
*/
import { createHash } from 'crypto';
-import { readFile, writeFile } from 'fs';
+import { readFile, writeFile, readdir, unlink, rmdir } from 'fs';
import { resolve } from 'path';
import { promisify } from 'util';
-
+import path from 'path';
import del from 'del';
-import deleteEmpty from 'delete-empty';
-import globby from 'globby';
import normalizePosixPath from 'normalize-path';
const readAsync = promisify(readFile);
const writeAsync = promisify(writeFile);
+const readdirAsync = promisify(readdir);
+const unlinkAsync = promisify(unlink);
+const rmdirAsync = promisify(rmdir);
interface Params {
logWithMetadata: (tags: string[], message: string, metadata?: { [key: string]: any }) => void;
@@ -95,11 +96,7 @@ export class WatchCache {
await del(this.statePath, { force: true });
// delete everything in optimize/.cache directory
- await del(await globby([normalizePosixPath(this.cachePath)], { dot: true }));
-
- // delete some empty folder that could be left
- // from the previous cache path reset action
- await deleteEmpty(this.cachePath);
+ await recursiveDelete(normalizePosixPath(this.cachePath));
// delete dlls
await del(this.dllsPath);
@@ -167,3 +164,28 @@ export class WatchCache {
}
}
}
+
+/**
+ * Recursively deletes a folder. This is a workaround for a bug in `del` where
+ * very large folders (with 84K+ files) cause a stack overflow.
+ */
+async function recursiveDelete(directory: string) {
+ const entries = await readdirAsync(directory, { withFileTypes: true });
+ await Promise.all(
+ entries.map(entry => {
+ const absolutePath = path.join(directory, entry.name);
+ const result = entry.isDirectory()
+ ? recursiveDelete(absolutePath)
+ : unlinkAsync(absolutePath);
+
+ // Ignore errors, if the file or directory doesn't exist.
+ return result.catch(e => {
+ if (e.code !== 'ENOENT') {
+ throw e;
+ }
+ });
+ })
+ );
+
+ return rmdirAsync(directory);
+}
diff --git a/src/plugins/data/common/es_query/kuery/kuery_syntax_error.ts b/src/plugins/data/common/es_query/kuery/kuery_syntax_error.ts
index 7c90119fcc1bc..0d5cd6ea17f16 100644
--- a/src/plugins/data/common/es_query/kuery/kuery_syntax_error.ts
+++ b/src/plugins/data/common/es_query/kuery/kuery_syntax_error.ts
@@ -41,7 +41,7 @@ const grammarRuleTranslations: Record = {
interface KQLSyntaxErrorData extends Error {
found: string;
- expected: KQLSyntaxErrorExpected[];
+ expected: KQLSyntaxErrorExpected[] | null;
location: any;
}
@@ -53,19 +53,22 @@ export class KQLSyntaxError extends Error {
shortMessage: string;
constructor(error: KQLSyntaxErrorData, expression: any) {
- const translatedExpectations = error.expected.map(expected => {
- return grammarRuleTranslations[expected.description] || expected.description;
- });
+ let message = error.message;
+ if (error.expected) {
+ const translatedExpectations = error.expected.map(expected => {
+ return grammarRuleTranslations[expected.description] || expected.description;
+ });
- const translatedExpectationText = translatedExpectations.join(', ');
+ const translatedExpectationText = translatedExpectations.join(', ');
- const message = i18n.translate('data.common.esQuery.kql.errors.syntaxError', {
- defaultMessage: 'Expected {expectedList} but {foundInput} found.',
- values: {
- expectedList: translatedExpectationText,
- foundInput: error.found ? `"${error.found}"` : endOfInputText,
- },
- });
+ message = i18n.translate('data.common.esQuery.kql.errors.syntaxError', {
+ defaultMessage: 'Expected {expectedList} but {foundInput} found.',
+ values: {
+ expectedList: translatedExpectationText,
+ foundInput: error.found ? `"${error.found}"` : endOfInputText,
+ },
+ });
+ }
const fullMessage = [message, expression, repeat('-', error.location.start.offset) + '^'].join(
'\n'
diff --git a/src/plugins/kibana_react/public/index.ts b/src/plugins/kibana_react/public/index.ts
index 10b7dd2b4da44..cfe89f16e99dd 100644
--- a/src/plugins/kibana_react/public/index.ts
+++ b/src/plugins/kibana_react/public/index.ts
@@ -25,4 +25,5 @@ export * from './overlays';
export * from './ui_settings';
export * from './field_icon';
export * from './table_list_view';
+export { useUrlTracker } from './use_url_tracker';
export { toMountPoint } from './util';
diff --git a/webpackShims/moment.js b/src/plugins/kibana_react/public/use_url_tracker/index.ts
similarity index 90%
rename from webpackShims/moment.js
rename to src/plugins/kibana_react/public/use_url_tracker/index.ts
index 31476d18c9562..fdceaf34e04ee 100644
--- a/webpackShims/moment.js
+++ b/src/plugins/kibana_react/public/use_url_tracker/index.ts
@@ -17,4 +17,4 @@
* under the License.
*/
-module.exports = require('../node_modules/moment/min/moment-with-locales.min.js');
+export { useUrlTracker } from './use_url_tracker';
diff --git a/src/plugins/kibana_react/public/use_url_tracker/use_url_tracker.test.tsx b/src/plugins/kibana_react/public/use_url_tracker/use_url_tracker.test.tsx
new file mode 100644
index 0000000000000..d1425a09b2f9c
--- /dev/null
+++ b/src/plugins/kibana_react/public/use_url_tracker/use_url_tracker.test.tsx
@@ -0,0 +1,70 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { renderHook } from '@testing-library/react-hooks';
+import { useUrlTracker } from './use_url_tracker';
+import { StubBrowserStorage } from 'test_utils/stub_browser_storage';
+import { createMemoryHistory } from 'history';
+
+describe('useUrlTracker', () => {
+ const key = 'key';
+ let storage = new StubBrowserStorage();
+ let history = createMemoryHistory();
+ beforeEach(() => {
+ storage = new StubBrowserStorage();
+ history = createMemoryHistory();
+ });
+
+ it('should track history changes and save them to storage', () => {
+ expect(storage.getItem(key)).toBeNull();
+ const { unmount } = renderHook(() => {
+ useUrlTracker(key, history, () => false, storage);
+ });
+ expect(storage.getItem(key)).toBe('/');
+ history.push('/change');
+ expect(storage.getItem(key)).toBe('/change');
+ unmount();
+ history.push('/other-change');
+ expect(storage.getItem(key)).toBe('/change');
+ });
+
+ it('by default should restore initial url', () => {
+ storage.setItem(key, '/change');
+ renderHook(() => {
+ useUrlTracker(key, history, undefined, storage);
+ });
+ expect(history.location.pathname).toBe('/change');
+ });
+
+ it('should restore initial url if shouldRestoreUrl cb returns true', () => {
+ storage.setItem(key, '/change');
+ renderHook(() => {
+ useUrlTracker(key, history, () => true, storage);
+ });
+ expect(history.location.pathname).toBe('/change');
+ });
+
+ it('should not restore initial url if shouldRestoreUrl cb returns false', () => {
+ storage.setItem(key, '/change');
+ renderHook(() => {
+ useUrlTracker(key, history, () => false, storage);
+ });
+ expect(history.location.pathname).toBe('/');
+ });
+});
diff --git a/src/plugins/kibana_react/public/use_url_tracker/use_url_tracker.tsx b/src/plugins/kibana_react/public/use_url_tracker/use_url_tracker.tsx
new file mode 100644
index 0000000000000..97e69fe22a842
--- /dev/null
+++ b/src/plugins/kibana_react/public/use_url_tracker/use_url_tracker.tsx
@@ -0,0 +1,52 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { History } from 'history';
+import { useLayoutEffect } from 'react';
+import { createUrlTracker } from '../../../kibana_utils/public/';
+
+/**
+ * State management url_tracker in react hook form
+ *
+ * Replicates what src/legacy/ui/public/chrome/api/nav.ts did
+ * Persists the url in sessionStorage so it could be restored if navigated back to the app
+ *
+ * @param key - key to use in storage
+ * @param history - history instance to use
+ * @param shouldRestoreUrl - cb if url should be restored
+ * @param storage - storage to use. window.sessionStorage is default
+ */
+export function useUrlTracker(
+ key: string,
+ history: History,
+ shouldRestoreUrl: (urlToRestore: string) => boolean = () => true,
+ storage: Storage = sessionStorage
+) {
+ useLayoutEffect(() => {
+ const urlTracker = createUrlTracker(key, storage);
+ const urlToRestore = urlTracker.getTrackedUrl();
+ if (urlToRestore && shouldRestoreUrl(urlToRestore)) {
+ history.replace(urlToRestore);
+ }
+ const stopTrackingUrl = urlTracker.startTrackingUrl(history);
+ return () => {
+ stopTrackingUrl();
+ };
+ }, [key, history]);
+}
diff --git a/src/plugins/kibana_utils/common/distinct_until_changed_with_initial_value.test.ts b/src/plugins/kibana_utils/common/distinct_until_changed_with_initial_value.test.ts
new file mode 100644
index 0000000000000..24f8f13f21478
--- /dev/null
+++ b/src/plugins/kibana_utils/common/distinct_until_changed_with_initial_value.test.ts
@@ -0,0 +1,75 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Subject } from 'rxjs';
+import { distinctUntilChangedWithInitialValue } from './distinct_until_changed_with_initial_value';
+import { toArray } from 'rxjs/operators';
+import deepEqual from 'fast-deep-equal';
+
+describe('distinctUntilChangedWithInitialValue', () => {
+ it('should skip updates with the same value', async () => {
+ const subject = new Subject();
+ const result = subject.pipe(distinctUntilChangedWithInitialValue(1), toArray()).toPromise();
+
+ subject.next(2);
+ subject.next(3);
+ subject.next(3);
+ subject.next(3);
+ subject.complete();
+
+ expect(await result).toEqual([2, 3]);
+ });
+
+ it('should accept promise as initial value', async () => {
+ const subject = new Subject();
+ const result = subject
+ .pipe(
+ distinctUntilChangedWithInitialValue(
+ new Promise(resolve => {
+ resolve(1);
+ setTimeout(() => {
+ subject.next(2);
+ subject.next(3);
+ subject.next(3);
+ subject.next(3);
+ subject.complete();
+ });
+ })
+ ),
+ toArray()
+ )
+ .toPromise();
+ expect(await result).toEqual([2, 3]);
+ });
+
+ it('should accept custom comparator', async () => {
+ const subject = new Subject();
+ const result = subject
+ .pipe(distinctUntilChangedWithInitialValue({ test: 1 }, deepEqual), toArray())
+ .toPromise();
+
+ subject.next({ test: 1 });
+ subject.next({ test: 2 });
+ subject.next({ test: 2 });
+ subject.next({ test: 3 });
+ subject.complete();
+
+ expect(await result).toEqual([{ test: 2 }, { test: 3 }]);
+ });
+});
diff --git a/src/plugins/kibana_utils/common/distinct_until_changed_with_initial_value.ts b/src/plugins/kibana_utils/common/distinct_until_changed_with_initial_value.ts
new file mode 100644
index 0000000000000..6af9cc1e8ac3a
--- /dev/null
+++ b/src/plugins/kibana_utils/common/distinct_until_changed_with_initial_value.ts
@@ -0,0 +1,42 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { MonoTypeOperatorFunction, queueScheduler, scheduled, from } from 'rxjs';
+import { concatAll, distinctUntilChanged, skip } from 'rxjs/operators';
+
+export function distinctUntilChangedWithInitialValue(
+ initialValue: T | Promise,
+ compare?: (x: T, y: T) => boolean
+): MonoTypeOperatorFunction {
+ return input$ =>
+ scheduled(
+ [isPromise(initialValue) ? from(initialValue) : [initialValue], input$],
+ queueScheduler
+ ).pipe(concatAll(), distinctUntilChanged(compare), skip(1));
+}
+
+function isPromise(value: T | Promise): value is Promise {
+ return (
+ !!value &&
+ typeof value === 'object' &&
+ 'then' in value &&
+ typeof value.then === 'function' &&
+ !('subscribe' in value)
+ );
+}
diff --git a/src/plugins/kibana_utils/common/index.ts b/src/plugins/kibana_utils/common/index.ts
index d13a250cedf2e..eb3bb96c8e874 100644
--- a/src/plugins/kibana_utils/common/index.ts
+++ b/src/plugins/kibana_utils/common/index.ts
@@ -18,3 +18,4 @@
*/
export * from './defer';
+export { distinctUntilChangedWithInitialValue } from './distinct_until_changed_with_initial_value';
diff --git a/src/plugins/kibana_utils/demos/demos.test.ts b/src/plugins/kibana_utils/demos/demos.test.ts
index 4e792ceef117a..5c50e152ad46c 100644
--- a/src/plugins/kibana_utils/demos/demos.test.ts
+++ b/src/plugins/kibana_utils/demos/demos.test.ts
@@ -19,6 +19,7 @@
import { result as counterResult } from './state_containers/counter';
import { result as todomvcResult } from './state_containers/todomvc';
+import { result as urlSyncResult } from './state_sync/url';
describe('demos', () => {
describe('state containers', () => {
@@ -33,4 +34,12 @@ describe('demos', () => {
]);
});
});
+
+ describe('state sync', () => {
+ test('url sync demo works', async () => {
+ expect(await urlSyncResult).toMatchInlineSnapshot(
+ `"http://localhost/#?_s=!((completed:!f,id:0,text:'Learning%20state%20containers'),(completed:!f,id:2,text:test))"`
+ );
+ });
+ });
});
diff --git a/src/plugins/kibana_utils/demos/state_sync/url.ts b/src/plugins/kibana_utils/demos/state_sync/url.ts
new file mode 100644
index 0000000000000..657b64f55a776
--- /dev/null
+++ b/src/plugins/kibana_utils/demos/state_sync/url.ts
@@ -0,0 +1,70 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { defaultState, pureTransitions, TodoActions, TodoState } from '../state_containers/todomvc';
+import { BaseStateContainer, createStateContainer } from '../../public/state_containers';
+import {
+ createKbnUrlStateStorage,
+ syncState,
+ INullableBaseStateContainer,
+} from '../../public/state_sync';
+
+const tick = () => new Promise(resolve => setTimeout(resolve));
+
+const stateContainer = createStateContainer(defaultState, pureTransitions);
+const { start, stop } = syncState({
+ stateContainer: withDefaultState(stateContainer, defaultState),
+ storageKey: '_s',
+ stateStorage: createKbnUrlStateStorage(),
+});
+
+start();
+export const result = Promise.resolve()
+ .then(() => {
+ // http://localhost/#?_s=!((completed:!f,id:0,text:'Learning+state+containers')"
+
+ stateContainer.transitions.add({
+ id: 2,
+ text: 'test',
+ completed: false,
+ });
+
+ // http://localhost/#?_s=!((completed:!f,id:0,text:'Learning+state+containers'),(completed:!f,id:2,text:test))"
+
+ /* actual url updates happens async */
+ return tick();
+ })
+ .then(() => {
+ stop();
+ return window.location.href;
+ });
+
+function withDefaultState(
+ // eslint-disable-next-line no-shadow
+ stateContainer: BaseStateContainer,
+ // eslint-disable-next-line no-shadow
+ defaultState: State
+): INullableBaseStateContainer {
+ return {
+ ...stateContainer,
+ set: (state: State | null) => {
+ stateContainer.set(state || defaultState);
+ },
+ };
+}
diff --git a/src/plugins/kibana_utils/public/field_mapping/mapping_setup.ts b/src/plugins/kibana_utils/public/field_mapping/mapping_setup.ts
index 72f3716147efa..99b49b401a8b8 100644
--- a/src/plugins/kibana_utils/public/field_mapping/mapping_setup.ts
+++ b/src/plugins/kibana_utils/public/field_mapping/mapping_setup.ts
@@ -19,7 +19,9 @@
import { mapValues, isString } from 'lodash';
import { FieldMappingSpec, MappingObject } from './types';
-import { ES_FIELD_TYPES } from '../../../data/public';
+
+// import from ./common/types to prevent circular dependency of kibana_utils <-> data plugin
+import { ES_FIELD_TYPES } from '../../../data/common/types';
/** @private */
type ShorthandFieldMapObject = FieldMappingSpec | ES_FIELD_TYPES | 'json';
diff --git a/src/plugins/kibana_utils/public/index.ts b/src/plugins/kibana_utils/public/index.ts
index af2fc9e31b21b..0ba444c4e9395 100644
--- a/src/plugins/kibana_utils/public/index.ts
+++ b/src/plugins/kibana_utils/public/index.ts
@@ -27,6 +27,34 @@ export * from './render_complete';
export * from './resize_checker';
export * from './state_containers';
export * from './storage';
-export * from './storage/hashed_item_store';
-export * from './state_management/state_hash';
-export * from './state_management/url';
+export { hashedItemStore, HashedItemStore } from './storage/hashed_item_store';
+export {
+ createStateHash,
+ persistState,
+ retrieveState,
+ isStateHash,
+} from './state_management/state_hash';
+export {
+ hashQuery,
+ hashUrl,
+ unhashUrl,
+ unhashQuery,
+ createUrlTracker,
+ createKbnUrlControls,
+ getStateFromKbnUrl,
+ getStatesFromKbnUrl,
+ setStateToKbnUrl,
+} from './state_management/url';
+export {
+ syncState,
+ syncStates,
+ createKbnUrlStateStorage,
+ createSessionStorageStateStorage,
+ IStateSyncConfig,
+ ISyncStateRef,
+ IKbnUrlStateStorage,
+ INullableBaseStateContainer,
+ ISessionStorageStateStorage,
+ StartSyncStateFnType,
+ StopSyncStateFnType,
+} from './state_sync';
diff --git a/src/plugins/kibana_utils/public/state_containers/create_state_container.test.ts b/src/plugins/kibana_utils/public/state_containers/create_state_container.test.ts
index 9165181299a90..95f4c35f2ce01 100644
--- a/src/plugins/kibana_utils/public/state_containers/create_state_container.test.ts
+++ b/src/plugins/kibana_utils/public/state_containers/create_state_container.test.ts
@@ -113,6 +113,13 @@ test('multiple subscribers can subscribe', () => {
expect(spy2.mock.calls[1][0]).toEqual({ a: 2 });
});
+test('can create state container without transitions', () => {
+ const state = { foo: 'bar' };
+ const stateContainer = createStateContainer(state);
+ expect(stateContainer.transitions).toEqual({});
+ expect(stateContainer.get()).toEqual(state);
+});
+
test('creates impure mutators from pure mutators', () => {
const { mutators } = create(
{},
diff --git a/src/plugins/kibana_utils/public/state_containers/create_state_container.ts b/src/plugins/kibana_utils/public/state_containers/create_state_container.ts
index 1ef4a1c012817..b949a9daed0ae 100644
--- a/src/plugins/kibana_utils/public/state_containers/create_state_container.ts
+++ b/src/plugins/kibana_utils/public/state_containers/create_state_container.ts
@@ -41,11 +41,11 @@ const freeze: (value: T) => RecursiveReadonly =
export const createStateContainer = <
State,
- PureTransitions extends object,
+ PureTransitions extends object = {},
PureSelectors extends object = {}
>(
defaultState: State,
- pureTransitions: PureTransitions,
+ pureTransitions: PureTransitions = {} as PureTransitions,
pureSelectors: PureSelectors = {} as PureSelectors
): ReduxLikeStateContainer => {
const data$ = new BehaviorSubject>(freeze(defaultState));
diff --git a/src/plugins/kibana_utils/public/state_containers/create_state_container_react_helpers.test.tsx b/src/plugins/kibana_utils/public/state_containers/create_state_container_react_helpers.test.tsx
index 8f5810f3e147d..c1a35441b637b 100644
--- a/src/plugins/kibana_utils/public/state_containers/create_state_container_react_helpers.test.tsx
+++ b/src/plugins/kibana_utils/public/state_containers/create_state_container_react_helpers.test.tsx
@@ -193,12 +193,7 @@ describe('hooks', () => {
describe('useTransitions', () => {
test('useTransitions hook returns mutations that can update state', () => {
- const { store } = create<
- {
- cnt: number;
- },
- any
- >(
+ const { store } = create(
{
cnt: 0,
},
diff --git a/src/plugins/kibana_utils/public/state_containers/create_state_container_react_helpers.ts b/src/plugins/kibana_utils/public/state_containers/create_state_container_react_helpers.ts
index e94165cc48376..45b34b13251f4 100644
--- a/src/plugins/kibana_utils/public/state_containers/create_state_container_react_helpers.ts
+++ b/src/plugins/kibana_utils/public/state_containers/create_state_container_react_helpers.ts
@@ -35,7 +35,7 @@ export const createStateContainerReactHelpers = useContainer().transitions;
+ const useTransitions = (): Container['transitions'] => useContainer().transitions;
const useSelector = (
selector: (state: UnboxState) => Result,
diff --git a/src/plugins/kibana_utils/public/state_containers/types.ts b/src/plugins/kibana_utils/public/state_containers/types.ts
index e0a1a18972635..e120f60e72b8f 100644
--- a/src/plugins/kibana_utils/public/state_containers/types.ts
+++ b/src/plugins/kibana_utils/public/state_containers/types.ts
@@ -42,7 +42,7 @@ export interface BaseStateContainer {
export interface StateContainer<
State,
- PureTransitions extends object,
+ PureTransitions extends object = {},
PureSelectors extends object = {}
> extends BaseStateContainer {
transitions: Readonly>;
@@ -51,7 +51,7 @@ export interface StateContainer<
export interface ReduxLikeStateContainer<
State,
- PureTransitions extends object,
+ PureTransitions extends object = {},
PureSelectors extends object = {}
> extends StateContainer {
getState: () => RecursiveReadonly;
diff --git a/src/plugins/kibana_utils/public/state_management/state_encoder/encode_decode_state.ts b/src/plugins/kibana_utils/public/state_management/state_encoder/encode_decode_state.ts
new file mode 100644
index 0000000000000..c535e965aa772
--- /dev/null
+++ b/src/plugins/kibana_utils/public/state_management/state_encoder/encode_decode_state.ts
@@ -0,0 +1,61 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import rison, { RisonValue } from 'rison-node';
+import { isStateHash, retrieveState, persistState } from '../state_hash';
+
+// should be:
+// export function decodeState(expandedOrHashedState: string)
+// but this leads to the chain of types mismatches up to BaseStateContainer interfaces,
+// as in state containers we don't have any restrictions on state shape
+export function decodeState(expandedOrHashedState: string): State {
+ if (isStateHash(expandedOrHashedState)) {
+ return retrieveState(expandedOrHashedState);
+ } else {
+ return (rison.decode(expandedOrHashedState) as unknown) as State;
+ }
+}
+
+// should be:
+// export function encodeState(expandedOrHashedState: string)
+// but this leads to the chain of types mismatches up to BaseStateContainer interfaces,
+// as in state containers we don't have any restrictions on state shape
+export function encodeState(state: State, useHash: boolean): string {
+ if (useHash) {
+ return persistState(state);
+ } else {
+ return rison.encode((state as unknown) as RisonValue);
+ }
+}
+
+export function hashedStateToExpandedState(expandedOrHashedState: string): string {
+ if (isStateHash(expandedOrHashedState)) {
+ return encodeState(retrieveState(expandedOrHashedState), false);
+ }
+
+ return expandedOrHashedState;
+}
+
+export function expandedStateToHashedState(expandedOrHashedState: string): string {
+ if (isStateHash(expandedOrHashedState)) {
+ return expandedOrHashedState;
+ }
+
+ return persistState(decodeState(expandedOrHashedState));
+}
diff --git a/webpackShims/angular.js b/src/plugins/kibana_utils/public/state_management/state_encoder/index.ts
similarity index 85%
rename from webpackShims/angular.js
rename to src/plugins/kibana_utils/public/state_management/state_encoder/index.ts
index 4857f0f8975bc..da1382720faff 100644
--- a/webpackShims/angular.js
+++ b/src/plugins/kibana_utils/public/state_management/state_encoder/index.ts
@@ -17,6 +17,9 @@
* under the License.
*/
-require('jquery');
-require('../node_modules/angular/angular');
-module.exports = window.angular;
+export {
+ encodeState,
+ decodeState,
+ expandedStateToHashedState,
+ hashedStateToExpandedState,
+} from './encode_decode_state';
diff --git a/src/plugins/kibana_utils/public/state_management/state_hash/index.ts b/src/plugins/kibana_utils/public/state_management/state_hash/index.ts
index 0e52c4c55872d..24c3c57613477 100644
--- a/src/plugins/kibana_utils/public/state_management/state_hash/index.ts
+++ b/src/plugins/kibana_utils/public/state_management/state_hash/index.ts
@@ -17,4 +17,4 @@
* under the License.
*/
-export * from './state_hash';
+export { isStateHash, createStateHash, persistState, retrieveState } from './state_hash';
diff --git a/src/plugins/kibana_utils/public/state_management/state_hash/state_hash.ts b/src/plugins/kibana_utils/public/state_management/state_hash/state_hash.ts
index a3eb5272b112d..f56d71297c030 100644
--- a/src/plugins/kibana_utils/public/state_management/state_hash/state_hash.ts
+++ b/src/plugins/kibana_utils/public/state_management/state_hash/state_hash.ts
@@ -17,6 +17,7 @@
* under the License.
*/
+import { i18n } from '@kbn/i18n';
import { Sha256 } from '../../../../../core/public/utils';
import { hashedItemStore } from '../../storage/hashed_item_store';
@@ -52,3 +53,46 @@ export function createStateHash(
export function isStateHash(str: string) {
return String(str).indexOf(HASH_PREFIX) === 0;
}
+
+export function retrieveState(stateHash: string): State {
+ const json = hashedItemStore.getItem(stateHash);
+ const throwUnableToRestoreUrlError = () => {
+ throw new Error(
+ i18n.translate('kibana_utils.stateManagement.stateHash.unableToRestoreUrlErrorMessage', {
+ defaultMessage:
+ 'Unable to completely restore the URL, be sure to use the share functionality.',
+ })
+ );
+ };
+ if (json === null) {
+ return throwUnableToRestoreUrlError();
+ }
+ try {
+ return JSON.parse(json);
+ } catch (e) {
+ return throwUnableToRestoreUrlError();
+ }
+}
+
+export function persistState(state: State): string {
+ const json = JSON.stringify(state);
+ const hash = createStateHash(json);
+
+ const isItemSet = hashedItemStore.setItem(hash, json);
+ if (isItemSet) return hash;
+ // If we ran out of space trying to persist the state, notify the user.
+ const message = i18n.translate(
+ 'kibana_utils.stateManagement.stateHash.unableToStoreHistoryInSessionErrorMessage',
+ {
+ defaultMessage:
+ 'Kibana is unable to store history items in your session ' +
+ `because it is full and there don't seem to be items any items safe ` +
+ 'to delete.\n\n' +
+ 'This can usually be fixed by moving to a fresh tab, but could ' +
+ 'be caused by a larger issue. If you are seeing this message regularly, ' +
+ 'please file an issue at {gitHubIssuesUrl}.',
+ values: { gitHubIssuesUrl: 'https://github.com/elastic/kibana/issues' },
+ }
+ );
+ throw new Error(message);
+}
diff --git a/src/plugins/kibana_utils/public/state_management/url/format.test.ts b/src/plugins/kibana_utils/public/state_management/url/format.test.ts
new file mode 100644
index 0000000000000..728f069840c72
--- /dev/null
+++ b/src/plugins/kibana_utils/public/state_management/url/format.test.ts
@@ -0,0 +1,41 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { replaceUrlHashQuery } from './format';
+
+describe('format', () => {
+ describe('replaceUrlHashQuery', () => {
+ it('should add hash query to url without hash', () => {
+ const url = 'http://localhost:5601/oxf/app/kibana';
+ expect(replaceUrlHashQuery(url, () => ({ test: 'test' }))).toMatchInlineSnapshot(
+ `"http://localhost:5601/oxf/app/kibana#?test=test"`
+ );
+ });
+
+ it('should replace hash query', () => {
+ const url = 'http://localhost:5601/oxf/app/kibana#?test=test';
+ expect(
+ replaceUrlHashQuery(url, query => ({
+ ...query,
+ test1: 'test1',
+ }))
+ ).toMatchInlineSnapshot(`"http://localhost:5601/oxf/app/kibana#?test=test&test1=test1"`);
+ });
+ });
+});
diff --git a/src/plugins/kibana_utils/public/state_management/url/format.ts b/src/plugins/kibana_utils/public/state_management/url/format.ts
new file mode 100644
index 0000000000000..988ee08627382
--- /dev/null
+++ b/src/plugins/kibana_utils/public/state_management/url/format.ts
@@ -0,0 +1,41 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { format as formatUrl } from 'url';
+import { ParsedUrlQuery } from 'querystring';
+import { parseUrl, parseUrlHash } from './parse';
+import { stringifyQueryString } from './stringify_query_string';
+
+export function replaceUrlHashQuery(
+ rawUrl: string,
+ queryReplacer: (query: ParsedUrlQuery) => ParsedUrlQuery
+) {
+ const url = parseUrl(rawUrl);
+ const hash = parseUrlHash(rawUrl);
+ const newQuery = queryReplacer(hash?.query || {});
+ const searchQueryString = stringifyQueryString(newQuery);
+ if ((!hash || !hash.search) && !searchQueryString) return rawUrl; // nothing to change. return original url
+ return formatUrl({
+ ...url,
+ hash: formatUrl({
+ pathname: hash?.pathname || '',
+ search: searchQueryString,
+ }),
+ });
+}
diff --git a/src/plugins/kibana_utils/public/state_management/url/hash_unhash_url.test.ts b/src/plugins/kibana_utils/public/state_management/url/hash_unhash_url.test.ts
index a85158acddefd..ec87b8464ac2d 100644
--- a/src/plugins/kibana_utils/public/state_management/url/hash_unhash_url.test.ts
+++ b/src/plugins/kibana_utils/public/state_management/url/hash_unhash_url.test.ts
@@ -29,13 +29,6 @@ describe('hash unhash url', () => {
describe('hash url', () => {
describe('does nothing', () => {
- it('if missing input', () => {
- expect(() => {
- // @ts-ignore
- hashUrl();
- }).not.toThrowError();
- });
-
it('if url is empty', () => {
const url = '';
expect(hashUrl(url)).toBe(url);
diff --git a/src/plugins/kibana_utils/public/state_management/url/hash_unhash_url.ts b/src/plugins/kibana_utils/public/state_management/url/hash_unhash_url.ts
index 872e7953f938b..a29f8bb9ac635 100644
--- a/src/plugins/kibana_utils/public/state_management/url/hash_unhash_url.ts
+++ b/src/plugins/kibana_utils/public/state_management/url/hash_unhash_url.ts
@@ -17,13 +17,8 @@
* under the License.
*/
-import { i18n } from '@kbn/i18n';
-import rison, { RisonObject } from 'rison-node';
-import { stringify as stringifyQueryString } from 'querystring';
-import encodeUriQuery from 'encode-uri-query';
-import { format as formatUrl, parse as parseUrl } from 'url';
-import { hashedItemStore } from '../../storage/hashed_item_store';
-import { createStateHash, isStateHash } from '../state_hash';
+import { expandedStateToHashedState, hashedStateToExpandedState } from '../state_encoder';
+import { replaceUrlHashQuery } from './format';
export type IParsedUrlQuery = Record;
@@ -32,8 +27,8 @@ interface IUrlQueryMapperOptions {
}
export type IUrlQueryReplacerOptions = IUrlQueryMapperOptions;
-export const unhashQuery = createQueryMapper(stateHashToRisonState);
-export const hashQuery = createQueryMapper(risonStateToStateHash);
+export const unhashQuery = createQueryMapper(hashedStateToExpandedState);
+export const hashQuery = createQueryMapper(expandedStateToHashedState);
export const unhashUrl = createQueryReplacer(unhashQuery);
export const hashUrl = createQueryReplacer(hashQuery);
@@ -61,97 +56,5 @@ function createQueryReplacer(
queryMapper: (q: IParsedUrlQuery, options?: IUrlQueryMapperOptions) => IParsedUrlQuery,
options?: IUrlQueryReplacerOptions
) {
- return (url: string) => {
- if (!url) return url;
-
- const parsedUrl = parseUrl(url, true);
- if (!parsedUrl.hash) return url;
-
- const appUrl = parsedUrl.hash.slice(1); // trim the #
- if (!appUrl) return url;
-
- const appUrlParsed = parseUrl(appUrl, true);
- if (!appUrlParsed.query) return url;
-
- const changedAppQuery = queryMapper(appUrlParsed.query, options);
-
- // encodeUriQuery implements the less-aggressive encoding done naturally by
- // the browser. We use it to generate the same urls the browser would
- const changedAppQueryString = stringifyQueryString(changedAppQuery, undefined, undefined, {
- encodeURIComponent: encodeUriQuery,
- });
-
- return formatUrl({
- ...parsedUrl,
- hash: formatUrl({
- pathname: appUrlParsed.pathname,
- search: changedAppQueryString,
- }),
- });
- };
-}
-
-// TODO: this helper should be merged with or replaced by
-// src/legacy/ui/public/state_management/state_storage/hashed_item_store.ts
-// maybe to become simplified stateless version
-export function retrieveState(stateHash: string): RisonObject {
- const json = hashedItemStore.getItem(stateHash);
- const throwUnableToRestoreUrlError = () => {
- throw new Error(
- i18n.translate('kibana_utils.stateManagement.url.unableToRestoreUrlErrorMessage', {
- defaultMessage:
- 'Unable to completely restore the URL, be sure to use the share functionality.',
- })
- );
- };
- if (json === null) {
- return throwUnableToRestoreUrlError();
- }
- try {
- return JSON.parse(json);
- } catch (e) {
- return throwUnableToRestoreUrlError();
- }
-}
-
-// TODO: this helper should be merged with or replaced by
-// src/legacy/ui/public/state_management/state_storage/hashed_item_store.ts
-// maybe to become simplified stateless version
-export function persistState(state: RisonObject): string {
- const json = JSON.stringify(state);
- const hash = createStateHash(json);
-
- const isItemSet = hashedItemStore.setItem(hash, json);
- if (isItemSet) return hash;
- // If we ran out of space trying to persist the state, notify the user.
- const message = i18n.translate(
- 'kibana_utils.stateManagement.url.unableToStoreHistoryInSessionErrorMessage',
- {
- defaultMessage:
- 'Kibana is unable to store history items in your session ' +
- `because it is full and there don't seem to be items any items safe ` +
- 'to delete.\n\n' +
- 'This can usually be fixed by moving to a fresh tab, but could ' +
- 'be caused by a larger issue. If you are seeing this message regularly, ' +
- 'please file an issue at {gitHubIssuesUrl}.',
- values: { gitHubIssuesUrl: 'https://github.com/elastic/kibana/issues' },
- }
- );
- throw new Error(message);
-}
-
-function stateHashToRisonState(stateHashOrRison: string): string {
- if (isStateHash(stateHashOrRison)) {
- return rison.encode(retrieveState(stateHashOrRison));
- }
-
- return stateHashOrRison;
-}
-
-function risonStateToStateHash(stateHashOrRison: string): string | null {
- if (isStateHash(stateHashOrRison)) {
- return stateHashOrRison;
- }
-
- return persistState(rison.decode(stateHashOrRison) as RisonObject);
+ return (url: string) => replaceUrlHashQuery(url, query => queryMapper(query, options));
}
diff --git a/src/plugins/kibana_utils/public/state_management/url/index.ts b/src/plugins/kibana_utils/public/state_management/url/index.ts
index 30c5696233db7..40491bf7a274b 100644
--- a/src/plugins/kibana_utils/public/state_management/url/index.ts
+++ b/src/plugins/kibana_utils/public/state_management/url/index.ts
@@ -17,4 +17,12 @@
* under the License.
*/
-export * from './hash_unhash_url';
+export { hashUrl, hashQuery, unhashUrl, unhashQuery } from './hash_unhash_url';
+export {
+ createKbnUrlControls,
+ setStateToKbnUrl,
+ getStateFromKbnUrl,
+ getStatesFromKbnUrl,
+ IKbnUrlControls,
+} from './kbn_url_storage';
+export { createUrlTracker } from './url_tracker';
diff --git a/src/plugins/kibana_utils/public/state_management/url/kbn_url_storage.test.ts b/src/plugins/kibana_utils/public/state_management/url/kbn_url_storage.test.ts
new file mode 100644
index 0000000000000..f1c527d3d5309
--- /dev/null
+++ b/src/plugins/kibana_utils/public/state_management/url/kbn_url_storage.test.ts
@@ -0,0 +1,246 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import '../../storage/hashed_item_store/mock';
+import {
+ History,
+ createBrowserHistory,
+ createHashHistory,
+ createMemoryHistory,
+ createPath,
+} from 'history';
+import {
+ getRelativeToHistoryPath,
+ createKbnUrlControls,
+ IKbnUrlControls,
+ setStateToKbnUrl,
+ getStateFromKbnUrl,
+} from './kbn_url_storage';
+
+describe('kbn_url_storage', () => {
+ describe('getStateFromUrl & setStateToUrl', () => {
+ const url = 'http://localhost:5601/oxf/app/kibana#/management/kibana/index_patterns/id';
+ const state1 = {
+ testStr: '123',
+ testNumber: 0,
+ testObj: { test: '123' },
+ testNull: null,
+ testArray: [1, 2, {}],
+ };
+ const state2 = {
+ test: '123',
+ };
+
+ it('should set expanded state to url', () => {
+ let newUrl = setStateToKbnUrl('_s', state1, { useHash: false }, url);
+ expect(newUrl).toMatchInlineSnapshot(
+ `"http://localhost:5601/oxf/app/kibana#/management/kibana/index_patterns/id?_s=(testArray:!(1,2,()),testNull:!n,testNumber:0,testObj:(test:'123'),testStr:'123')"`
+ );
+ const retrievedState1 = getStateFromKbnUrl('_s', newUrl);
+ expect(retrievedState1).toEqual(state1);
+
+ newUrl = setStateToKbnUrl('_s', state2, { useHash: false }, newUrl);
+ expect(newUrl).toMatchInlineSnapshot(
+ `"http://localhost:5601/oxf/app/kibana#/management/kibana/index_patterns/id?_s=(test:'123')"`
+ );
+ const retrievedState2 = getStateFromKbnUrl('_s', newUrl);
+ expect(retrievedState2).toEqual(state2);
+ });
+
+ it('should set hashed state to url', () => {
+ let newUrl = setStateToKbnUrl('_s', state1, { useHash: true }, url);
+ expect(newUrl).toMatchInlineSnapshot(
+ `"http://localhost:5601/oxf/app/kibana#/management/kibana/index_patterns/id?_s=h@a897fac"`
+ );
+ const retrievedState1 = getStateFromKbnUrl('_s', newUrl);
+ expect(retrievedState1).toEqual(state1);
+
+ newUrl = setStateToKbnUrl('_s', state2, { useHash: true }, newUrl);
+ expect(newUrl).toMatchInlineSnapshot(
+ `"http://localhost:5601/oxf/app/kibana#/management/kibana/index_patterns/id?_s=h@40f94d5"`
+ );
+ const retrievedState2 = getStateFromKbnUrl('_s', newUrl);
+ expect(retrievedState2).toEqual(state2);
+ });
+ });
+
+ describe('urlControls', () => {
+ let history: History;
+ let urlControls: IKbnUrlControls;
+ beforeEach(() => {
+ history = createMemoryHistory();
+ urlControls = createKbnUrlControls(history);
+ });
+
+ const getCurrentUrl = () => createPath(history.location);
+ it('should update url', () => {
+ urlControls.update('/1', false);
+
+ expect(getCurrentUrl()).toBe('/1');
+ expect(history.length).toBe(2);
+
+ urlControls.update('/2', true);
+
+ expect(getCurrentUrl()).toBe('/2');
+ expect(history.length).toBe(2);
+ });
+
+ it('should update url async', async () => {
+ const pr1 = urlControls.updateAsync(() => '/1', false);
+ const pr2 = urlControls.updateAsync(() => '/2', false);
+ const pr3 = urlControls.updateAsync(() => '/3', false);
+ expect(getCurrentUrl()).toBe('/');
+ await Promise.all([pr1, pr2, pr3]);
+ expect(getCurrentUrl()).toBe('/3');
+ });
+
+ it('should push url state if at least 1 push in async chain', async () => {
+ const pr1 = urlControls.updateAsync(() => '/1', true);
+ const pr2 = urlControls.updateAsync(() => '/2', false);
+ const pr3 = urlControls.updateAsync(() => '/3', true);
+ expect(getCurrentUrl()).toBe('/');
+ await Promise.all([pr1, pr2, pr3]);
+ expect(getCurrentUrl()).toBe('/3');
+ expect(history.length).toBe(2);
+ });
+
+ it('should replace url state if all updates in async chain are replace', async () => {
+ const pr1 = urlControls.updateAsync(() => '/1', true);
+ const pr2 = urlControls.updateAsync(() => '/2', true);
+ const pr3 = urlControls.updateAsync(() => '/3', true);
+ expect(getCurrentUrl()).toBe('/');
+ await Promise.all([pr1, pr2, pr3]);
+ expect(getCurrentUrl()).toBe('/3');
+ expect(history.length).toBe(1);
+ });
+
+ it('should listen for url updates', async () => {
+ const cb = jest.fn();
+ urlControls.listen(cb);
+ const pr1 = urlControls.updateAsync(() => '/1', true);
+ const pr2 = urlControls.updateAsync(() => '/2', true);
+ const pr3 = urlControls.updateAsync(() => '/3', true);
+ await Promise.all([pr1, pr2, pr3]);
+
+ urlControls.update('/4', false);
+ urlControls.update('/5', true);
+
+ expect(cb).toHaveBeenCalledTimes(3);
+ });
+
+ it('should flush async url updates', async () => {
+ const pr1 = urlControls.updateAsync(() => '/1', false);
+ const pr2 = urlControls.updateAsync(() => '/2', false);
+ const pr3 = urlControls.updateAsync(() => '/3', false);
+ expect(getCurrentUrl()).toBe('/');
+ urlControls.flush();
+ expect(getCurrentUrl()).toBe('/3');
+ await Promise.all([pr1, pr2, pr3]);
+ expect(getCurrentUrl()).toBe('/3');
+ });
+
+ it('flush should take priority over regular replace behaviour', async () => {
+ const pr1 = urlControls.updateAsync(() => '/1', true);
+ const pr2 = urlControls.updateAsync(() => '/2', false);
+ const pr3 = urlControls.updateAsync(() => '/3', true);
+ urlControls.flush(false);
+ expect(getCurrentUrl()).toBe('/3');
+ await Promise.all([pr1, pr2, pr3]);
+ expect(getCurrentUrl()).toBe('/3');
+ expect(history.length).toBe(2);
+ });
+
+ it('should cancel async url updates', async () => {
+ const pr1 = urlControls.updateAsync(() => '/1', true);
+ const pr2 = urlControls.updateAsync(() => '/2', false);
+ const pr3 = urlControls.updateAsync(() => '/3', true);
+ urlControls.cancel();
+ expect(getCurrentUrl()).toBe('/');
+ await Promise.all([pr1, pr2, pr3]);
+ expect(getCurrentUrl()).toBe('/');
+ });
+ });
+
+ describe('getRelativeToHistoryPath', () => {
+ it('should extract path relative to browser history without basename', () => {
+ const history = createBrowserHistory();
+ const url =
+ "http://localhost:5601/oxf/app/kibana#/management/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'')";
+ const relativePath = getRelativeToHistoryPath(url, history);
+ expect(relativePath).toEqual(
+ "/oxf/app/kibana#/management/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'')"
+ );
+ });
+
+ it('should extract path relative to browser history with basename', () => {
+ const url =
+ "http://localhost:5601/oxf/app/kibana#/management/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'')";
+ const history1 = createBrowserHistory({ basename: '/oxf/app/' });
+ const relativePath1 = getRelativeToHistoryPath(url, history1);
+ expect(relativePath1).toEqual(
+ "/kibana#/management/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'')"
+ );
+
+ const history2 = createBrowserHistory({ basename: '/oxf/app/kibana/' });
+ const relativePath2 = getRelativeToHistoryPath(url, history2);
+ expect(relativePath2).toEqual(
+ "#/management/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'')"
+ );
+ });
+
+ it('should extract path relative to browser history with basename from relative url', () => {
+ const history = createBrowserHistory({ basename: '/oxf/app/' });
+ const url =
+ "/oxf/app/kibana#/management/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'')";
+ const relativePath = getRelativeToHistoryPath(url, history);
+ expect(relativePath).toEqual(
+ "/kibana#/management/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'')"
+ );
+ });
+
+ it('should extract path relative to hash history without basename', () => {
+ const history = createHashHistory();
+ const url =
+ "http://localhost:5601/oxf/app/kibana#/management/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'')";
+ const relativePath = getRelativeToHistoryPath(url, history);
+ expect(relativePath).toEqual(
+ "/management/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'')"
+ );
+ });
+
+ it('should extract path relative to hash history with basename', () => {
+ const history = createHashHistory({ basename: 'management' });
+ const url =
+ "http://localhost:5601/oxf/app/kibana#/management/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'')";
+ const relativePath = getRelativeToHistoryPath(url, history);
+ expect(relativePath).toEqual(
+ "/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'')"
+ );
+ });
+
+ it('should extract path relative to hash history with basename from relative url', () => {
+ const history = createHashHistory({ basename: 'management' });
+ const url =
+ "/oxf/app/kibana#/management/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'')";
+ const relativePath = getRelativeToHistoryPath(url, history);
+ expect(relativePath).toEqual(
+ "/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'')"
+ );
+ });
+ });
+});
diff --git a/src/plugins/kibana_utils/public/state_management/url/kbn_url_storage.ts b/src/plugins/kibana_utils/public/state_management/url/kbn_url_storage.ts
new file mode 100644
index 0000000000000..03c136ea3d092
--- /dev/null
+++ b/src/plugins/kibana_utils/public/state_management/url/kbn_url_storage.ts
@@ -0,0 +1,235 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { format as formatUrl } from 'url';
+import { createBrowserHistory, History } from 'history';
+import { decodeState, encodeState } from '../state_encoder';
+import { getCurrentUrl, parseUrl, parseUrlHash } from './parse';
+import { stringifyQueryString } from './stringify_query_string';
+import { replaceUrlHashQuery } from './format';
+
+/**
+ * Parses a kibana url and retrieves all the states encoded into url,
+ * Handles both expanded rison state and hashed state (where the actual state stored in sessionStorage)
+ * e.g.:
+ *
+ * given an url:
+ * http://localhost:5601/oxf/app/kibana#/management/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'')
+ * will return object:
+ * {_a: {tab: 'indexedFields'}, _b: {f: 'test', i: '', l: ''}};
+ */
+export function getStatesFromKbnUrl(
+ url: string = window.location.href,
+ keys?: string[]
+): Record {
+ const query = parseUrlHash(url)?.query;
+
+ if (!query) return {};
+ const decoded: Record = {};
+ Object.entries(query)
+ .filter(([key]) => (keys ? keys.includes(key) : true))
+ .forEach(([q, value]) => {
+ decoded[q] = decodeState(value as string);
+ });
+
+ return decoded;
+}
+
+/**
+ * Retrieves specific state from url by key
+ * e.g.:
+ *
+ * given an url:
+ * http://localhost:5601/oxf/app/kibana#/management/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'')
+ * and key '_a'
+ * will return object:
+ * {tab: 'indexedFields'}
+ */
+export function getStateFromKbnUrl(
+ key: string,
+ url: string = window.location.href
+): State | null {
+ return (getStatesFromKbnUrl(url, [key])[key] as State) || null;
+}
+
+/**
+ * Sets state to the url by key and returns a new url string.
+ * Doesn't actually updates history
+ *
+ * e.g.:
+ * given a url: http://localhost:5601/oxf/app/kibana#/management/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'')
+ * key: '_a'
+ * and state: {tab: 'other'}
+ *
+ * will return url:
+ * http://localhost:5601/oxf/app/kibana#/management/kibana/index_patterns/id?_a=(tab:other)&_b=(f:test,i:'',l:'')
+ */
+export function setStateToKbnUrl(
+ key: string,
+ state: State,
+ { useHash = false }: { useHash: boolean } = { useHash: false },
+ rawUrl = window.location.href
+): string {
+ return replaceUrlHashQuery(rawUrl, query => {
+ const encoded = encodeState(state, useHash);
+ return {
+ ...query,
+ [key]: encoded,
+ };
+ });
+}
+
+/**
+ * A tiny wrapper around history library to listen for url changes and update url
+ * History library handles a bunch of cross browser edge cases
+ */
+export interface IKbnUrlControls {
+ /**
+ * Listen for url changes
+ * @param cb - get's called when url has been changed
+ */
+ listen: (cb: () => void) => () => void;
+
+ /**
+ * Updates url synchronously
+ * @param url - url to update to
+ * @param replace - use replace instead of push
+ */
+ update: (url: string, replace: boolean) => string;
+
+ /**
+ * Schedules url update to next microtask,
+ * Useful to batch sync changes to url to cause only one browser history update
+ * @param updater - fn which receives current url and should return next url to update to
+ * @param replace - use replace instead of push
+ */
+ updateAsync: (updater: UrlUpdaterFnType, replace?: boolean) => Promise;
+
+ /**
+ * Synchronously flushes scheduled url updates
+ * @param replace - if replace passed in, then uses it instead of push. Otherwise push or replace is picked depending on updateQueue
+ */
+ flush: (replace?: boolean) => string;
+
+ /**
+ * Cancels any pending url updates
+ */
+ cancel: () => void;
+}
+export type UrlUpdaterFnType = (currentUrl: string) => string;
+
+export const createKbnUrlControls = (
+ history: History = createBrowserHistory()
+): IKbnUrlControls => {
+ const updateQueue: Array<(currentUrl: string) => string> = [];
+
+ // if we should replace or push with next async update,
+ // if any call in a queue asked to push, then we should push
+ let shouldReplace = true;
+
+ function updateUrl(newUrl: string, replace = false): string {
+ const currentUrl = getCurrentUrl();
+ if (newUrl === currentUrl) return currentUrl; // skip update
+
+ const historyPath = getRelativeToHistoryPath(newUrl, history);
+
+ if (replace) {
+ history.replace(historyPath);
+ } else {
+ history.push(historyPath);
+ }
+
+ return getCurrentUrl();
+ }
+
+ // queue clean up
+ function cleanUp() {
+ updateQueue.splice(0, updateQueue.length);
+ shouldReplace = true;
+ }
+
+ // runs scheduled url updates
+ function flush(replace = shouldReplace) {
+ if (updateQueue.length === 0) return getCurrentUrl();
+ const resultUrl = updateQueue.reduce((url, nextUpdate) => nextUpdate(url), getCurrentUrl());
+
+ cleanUp();
+
+ const newUrl = updateUrl(resultUrl, replace);
+ return newUrl;
+ }
+
+ return {
+ listen: (cb: () => void) =>
+ history.listen(() => {
+ cb();
+ }),
+ update: (newUrl: string, replace = false) => updateUrl(newUrl, replace),
+ updateAsync: (updater: (currentUrl: string) => string, replace = false) => {
+ updateQueue.push(updater);
+ if (shouldReplace) {
+ shouldReplace = replace;
+ }
+
+ // Schedule url update to the next microtask
+ // this allows to batch synchronous url changes
+ return Promise.resolve().then(() => {
+ return flush();
+ });
+ },
+ flush: (replace?: boolean) => {
+ return flush(replace);
+ },
+ cancel: () => {
+ cleanUp();
+ },
+ };
+};
+
+/**
+ * Depending on history configuration extracts relative path for history updates
+ * 4 possible cases (see tests):
+ * 1. Browser history with empty base path
+ * 2. Browser history with base path
+ * 3. Hash history with empty base path
+ * 4. Hash history with base path
+ */
+export function getRelativeToHistoryPath(absoluteUrl: string, history: History): History.Path {
+ function stripBasename(path: string = '') {
+ const stripLeadingHash = (_: string) => (_.charAt(0) === '#' ? _.substr(1) : _);
+ const stripTrailingSlash = (_: string) =>
+ _.charAt(_.length - 1) === '/' ? _.substr(0, _.length - 1) : _;
+ const baseName = stripLeadingHash(stripTrailingSlash(history.createHref({})));
+ return path.startsWith(baseName) ? path.substr(baseName.length) : path;
+ }
+ const isHashHistory = history.createHref({}).includes('#');
+ const parsedUrl = isHashHistory ? parseUrlHash(absoluteUrl)! : parseUrl(absoluteUrl);
+ const parsedHash = isHashHistory ? null : parseUrlHash(absoluteUrl);
+
+ return formatUrl({
+ pathname: stripBasename(parsedUrl.pathname),
+ search: stringifyQueryString(parsedUrl.query),
+ hash: parsedHash
+ ? formatUrl({
+ pathname: parsedHash.pathname,
+ search: stringifyQueryString(parsedHash.query),
+ })
+ : parsedUrl.hash,
+ });
+}
diff --git a/src/plugins/kibana_utils/public/state_management/url/parse.test.ts b/src/plugins/kibana_utils/public/state_management/url/parse.test.ts
new file mode 100644
index 0000000000000..774f18b734514
--- /dev/null
+++ b/src/plugins/kibana_utils/public/state_management/url/parse.test.ts
@@ -0,0 +1,35 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { parseUrlHash } from './parse';
+
+describe('parseUrlHash', () => {
+ it('should return null if no hash', () => {
+ expect(parseUrlHash('http://localhost:5601/oxf/app/kibana')).toBeNull();
+ });
+
+ it('should return parsed hash', () => {
+ expect(parseUrlHash('http://localhost:5601/oxf/app/kibana/#/path?test=test')).toMatchObject({
+ pathname: '/path',
+ query: {
+ test: 'test',
+ },
+ });
+ });
+});
diff --git a/src/plugins/kibana_utils/public/state_management/url/parse.ts b/src/plugins/kibana_utils/public/state_management/url/parse.ts
new file mode 100644
index 0000000000000..95041d0662f56
--- /dev/null
+++ b/src/plugins/kibana_utils/public/state_management/url/parse.ts
@@ -0,0 +1,29 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { parse as _parseUrl } from 'url';
+
+export const parseUrl = (url: string) => _parseUrl(url, true);
+export const parseUrlHash = (url: string) => {
+ const hash = parseUrl(url).hash;
+ return hash ? parseUrl(hash.slice(1)) : null;
+};
+export const getCurrentUrl = () => window.location.href;
+export const parseCurrentUrl = () => parseUrl(getCurrentUrl());
+export const parseCurrentUrlHash = () => parseUrlHash(getCurrentUrl());
diff --git a/src/plugins/kibana_utils/public/state_management/url/stringify_query_string.test.ts b/src/plugins/kibana_utils/public/state_management/url/stringify_query_string.test.ts
new file mode 100644
index 0000000000000..3ca6cb4214682
--- /dev/null
+++ b/src/plugins/kibana_utils/public/state_management/url/stringify_query_string.test.ts
@@ -0,0 +1,65 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { encodeUriQuery, stringifyQueryString } from './stringify_query_string';
+
+describe('stringifyQueryString', () => {
+ it('stringifyQueryString', () => {
+ expect(
+ stringifyQueryString({
+ a: 'asdf1234asdf',
+ b: "-_.!~*'() -_.!~*'()",
+ c: ':@$, :@$,',
+ d: "&;=+# &;=+#'",
+ f: ' ',
+ g: 'null',
+ })
+ ).toMatchInlineSnapshot(
+ `"a=asdf1234asdf&b=-_.!~*'()%20-_.!~*'()&c=:@$,%20:@$,&d=%26;%3D%2B%23%20%26;%3D%2B%23'&f=%20&g=null"`
+ );
+ });
+});
+
+describe('encodeUriQuery', function() {
+ it('should correctly encode uri query and not encode chars defined as pchar set in rfc3986', () => {
+ // don't encode alphanum
+ expect(encodeUriQuery('asdf1234asdf')).toBe('asdf1234asdf');
+
+ // don't encode unreserved
+ expect(encodeUriQuery("-_.!~*'() -_.!~*'()")).toBe("-_.!~*'()+-_.!~*'()");
+
+ // don't encode the rest of pchar
+ expect(encodeUriQuery(':@$, :@$,')).toBe(':@$,+:@$,');
+
+ // encode '&', ';', '=', '+', and '#'
+ expect(encodeUriQuery('&;=+# &;=+#')).toBe('%26;%3D%2B%23+%26;%3D%2B%23');
+
+ // encode ' ' as '+'
+ expect(encodeUriQuery(' ')).toBe('++');
+
+ // encode ' ' as '%20' when a flag is used
+ expect(encodeUriQuery(' ', true)).toBe('%20%20');
+
+ // do not encode `null` as '+' when flag is used
+ expect(encodeUriQuery('null', true)).toBe('null');
+
+ // do not encode `null` with no flag
+ expect(encodeUriQuery('null')).toBe('null');
+ });
+});
diff --git a/src/plugins/kibana_utils/public/state_management/url/stringify_query_string.ts b/src/plugins/kibana_utils/public/state_management/url/stringify_query_string.ts
new file mode 100644
index 0000000000000..e951dfac29c02
--- /dev/null
+++ b/src/plugins/kibana_utils/public/state_management/url/stringify_query_string.ts
@@ -0,0 +1,57 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { stringify, ParsedUrlQuery } from 'querystring';
+
+// encodeUriQuery implements the less-aggressive encoding done naturally by
+// the browser. We use it to generate the same urls the browser would
+export const stringifyQueryString = (query: ParsedUrlQuery) =>
+ stringify(query, undefined, undefined, {
+ // encode spaces with %20 is needed to produce the same queries as angular does
+ // https://github.com/angular/angular.js/blob/51c516e7d4f2d10b0aaa4487bd0b52772022207a/src/Angular.js#L1377
+ encodeURIComponent: (val: string) => encodeUriQuery(val, true),
+ });
+
+/**
+ * Extracted from angular.js
+ * repo: https://github.com/angular/angular.js
+ * license: MIT - https://github.com/angular/angular.js/blob/51c516e7d4f2d10b0aaa4487bd0b52772022207a/LICENSE
+ * source: https://github.com/angular/angular.js/blob/51c516e7d4f2d10b0aaa4487bd0b52772022207a/src/Angular.js#L1413-L1432
+ */
+
+/**
+ * This method is intended for encoding *key* or *value* parts of query component. We need a custom
+ * method because encodeURIComponent is too aggressive and encodes stuff that doesn't have to be
+ * encoded per http://tools.ietf.org/html/rfc3986:
+ * query = *( pchar / "/" / "?" )
+ * pchar = unreserved / pct-encoded / sub-delims / ":" / "@"
+ * unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~"
+ * pct-encoded = "%" HEXDIG HEXDIG
+ * sub-delims = "!" / "$" / "&" / "'" / "(" / ")"
+ * / "*" / "+" / "," / ";" / "="
+ */
+export function encodeUriQuery(val: string, pctEncodeSpaces: boolean = false) {
+ return encodeURIComponent(val)
+ .replace(/%40/gi, '@')
+ .replace(/%3A/gi, ':')
+ .replace(/%24/g, '$')
+ .replace(/%2C/gi, ',')
+ .replace(/%3B/gi, ';')
+ .replace(/%20/g, pctEncodeSpaces ? '%20' : '+');
+}
diff --git a/src/plugins/kibana_utils/public/state_management/url/url_tracker.test.ts b/src/plugins/kibana_utils/public/state_management/url/url_tracker.test.ts
new file mode 100644
index 0000000000000..d7e5f99ffb700
--- /dev/null
+++ b/src/plugins/kibana_utils/public/state_management/url/url_tracker.test.ts
@@ -0,0 +1,56 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { createUrlTracker, IUrlTracker } from './url_tracker';
+import { StubBrowserStorage } from 'test_utils/stub_browser_storage';
+import { createMemoryHistory, History } from 'history';
+
+describe('urlTracker', () => {
+ let storage: StubBrowserStorage;
+ let history: History;
+ let urlTracker: IUrlTracker;
+ beforeEach(() => {
+ storage = new StubBrowserStorage();
+ history = createMemoryHistory();
+ urlTracker = createUrlTracker('test', storage);
+ });
+
+ it('should return null if no tracked url', () => {
+ expect(urlTracker.getTrackedUrl()).toBeNull();
+ });
+
+ it('should return last tracked url', () => {
+ urlTracker.trackUrl('http://localhost:4200');
+ urlTracker.trackUrl('http://localhost:4201');
+ urlTracker.trackUrl('http://localhost:4202');
+ expect(urlTracker.getTrackedUrl()).toBe('http://localhost:4202');
+ });
+
+ it('should listen to history and track updates', () => {
+ const stop = urlTracker.startTrackingUrl(history);
+ expect(urlTracker.getTrackedUrl()).toBe('/');
+ history.push('/1');
+ history.replace('/2');
+ expect(urlTracker.getTrackedUrl()).toBe('/2');
+
+ stop();
+ history.replace('/3');
+ expect(urlTracker.getTrackedUrl()).toBe('/2');
+ });
+});
diff --git a/src/plugins/kibana_utils/public/state_management/url/url_tracker.ts b/src/plugins/kibana_utils/public/state_management/url/url_tracker.ts
new file mode 100644
index 0000000000000..89e72e94ba6b4
--- /dev/null
+++ b/src/plugins/kibana_utils/public/state_management/url/url_tracker.ts
@@ -0,0 +1,49 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { createBrowserHistory, History, Location } from 'history';
+import { getRelativeToHistoryPath } from './kbn_url_storage';
+
+export interface IUrlTracker {
+ startTrackingUrl: (history?: History) => () => void;
+ getTrackedUrl: () => string | null;
+ trackUrl: (url: string) => void;
+}
+/**
+ * Replicates what src/legacy/ui/public/chrome/api/nav.ts did
+ * Persists the url in sessionStorage so it could be restored if navigated back to the app
+ */
+export function createUrlTracker(key: string, storage: Storage = sessionStorage): IUrlTracker {
+ return {
+ startTrackingUrl(history: History = createBrowserHistory()) {
+ const track = (location: Location) => {
+ const url = getRelativeToHistoryPath(history.createHref(location), history);
+ storage.setItem(key, url);
+ };
+ track(history.location);
+ return history.listen(track);
+ },
+ getTrackedUrl() {
+ return storage.getItem(key);
+ },
+ trackUrl(url: string) {
+ storage.setItem(key, url);
+ },
+ };
+}
diff --git a/src/plugins/kibana_utils/public/state_sync/index.ts b/src/plugins/kibana_utils/public/state_sync/index.ts
new file mode 100644
index 0000000000000..1dfa998c5bb9d
--- /dev/null
+++ b/src/plugins/kibana_utils/public/state_sync/index.ts
@@ -0,0 +1,33 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+export {
+ createSessionStorageStateStorage,
+ createKbnUrlStateStorage,
+ IKbnUrlStateStorage,
+ ISessionStorageStateStorage,
+} from './state_sync_state_storage';
+export { IStateSyncConfig, INullableBaseStateContainer } from './types';
+export {
+ syncState,
+ syncStates,
+ StopSyncStateFnType,
+ StartSyncStateFnType,
+ ISyncStateRef,
+} from './state_sync';
diff --git a/src/plugins/kibana_utils/public/state_sync/state_sync.test.ts b/src/plugins/kibana_utils/public/state_sync/state_sync.test.ts
new file mode 100644
index 0000000000000..cc513bc674d0f
--- /dev/null
+++ b/src/plugins/kibana_utils/public/state_sync/state_sync.test.ts
@@ -0,0 +1,308 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { BaseStateContainer, createStateContainer } from '../state_containers';
+import {
+ defaultState,
+ pureTransitions,
+ TodoActions,
+ TodoState,
+} from '../../demos/state_containers/todomvc';
+import { syncState, syncStates } from './state_sync';
+import { IStateStorage } from './state_sync_state_storage/types';
+import { Observable, Subject } from 'rxjs';
+import {
+ createSessionStorageStateStorage,
+ createKbnUrlStateStorage,
+ IKbnUrlStateStorage,
+ ISessionStorageStateStorage,
+} from './state_sync_state_storage';
+import { StubBrowserStorage } from 'test_utils/stub_browser_storage';
+import { createBrowserHistory, History } from 'history';
+import { INullableBaseStateContainer } from './types';
+
+describe('state_sync', () => {
+ describe('basic', () => {
+ const container = createStateContainer(defaultState, pureTransitions);
+ beforeEach(() => {
+ container.set(defaultState);
+ });
+ const storageChange$ = new Subject();
+ let testStateStorage: IStateStorage;
+
+ beforeEach(() => {
+ testStateStorage = {
+ set: jest.fn(),
+ get: jest.fn(),
+ change$: (key: string) => storageChange$.asObservable() as Observable,
+ };
+ });
+
+ it('should sync state to storage', () => {
+ const key = '_s';
+ const { start, stop } = syncState({
+ stateContainer: withDefaultState(container, defaultState),
+ storageKey: key,
+ stateStorage: testStateStorage,
+ });
+ start();
+
+ // initial sync of state to storage is not happening
+ expect(testStateStorage.set).not.toBeCalled();
+
+ container.transitions.add({
+ id: 1,
+ text: 'Learning transitions...',
+ completed: false,
+ });
+ expect(testStateStorage.set).toBeCalledWith(key, container.getState());
+ stop();
+ });
+
+ it('should sync storage to state', () => {
+ const key = '_s';
+ const storageState1 = [{ id: 1, text: 'todo', completed: false }];
+ (testStateStorage.get as jest.Mock).mockImplementation(() => storageState1);
+ const { stop, start } = syncState({
+ stateContainer: withDefaultState(container, defaultState),
+ storageKey: key,
+ stateStorage: testStateStorage,
+ });
+ start();
+
+ // initial sync of storage to state is not happening
+ expect(container.getState()).toEqual(defaultState);
+
+ const storageState2 = [{ id: 1, text: 'todo', completed: true }];
+ (testStateStorage.get as jest.Mock).mockImplementation(() => storageState2);
+ storageChange$.next(storageState2);
+
+ expect(container.getState()).toEqual(storageState2);
+
+ stop();
+ });
+
+ it('should not update storage if no actual state change happened', () => {
+ const key = '_s';
+ const { stop, start } = syncState({
+ stateContainer: withDefaultState(container, defaultState),
+ storageKey: key,
+ stateStorage: testStateStorage,
+ });
+ start();
+ (testStateStorage.set as jest.Mock).mockClear();
+
+ container.set(defaultState);
+ expect(testStateStorage.set).not.toBeCalled();
+
+ stop();
+ });
+
+ it('should not update state container if no actual storage change happened', () => {
+ const key = '_s';
+ const { stop, start } = syncState({
+ stateContainer: withDefaultState(container, defaultState),
+ storageKey: key,
+ stateStorage: testStateStorage,
+ });
+ start();
+
+ const originalState = container.getState();
+ const storageState = [...originalState];
+ (testStateStorage.get as jest.Mock).mockImplementation(() => storageState);
+ storageChange$.next(storageState);
+
+ expect(container.getState()).toBe(originalState);
+
+ stop();
+ });
+
+ it('storage change to null should notify state', () => {
+ container.set([{ completed: false, id: 1, text: 'changed' }]);
+ const { stop, start } = syncStates([
+ {
+ stateContainer: withDefaultState(container, defaultState),
+ storageKey: '_s',
+ stateStorage: testStateStorage,
+ },
+ ]);
+ start();
+
+ (testStateStorage.get as jest.Mock).mockImplementation(() => null);
+ storageChange$.next(null);
+
+ expect(container.getState()).toEqual(defaultState);
+
+ stop();
+ });
+ });
+
+ describe('integration', () => {
+ const key = '_s';
+ const container = createStateContainer(defaultState, pureTransitions);
+
+ let sessionStorage: StubBrowserStorage;
+ let sessionStorageSyncStrategy: ISessionStorageStateStorage;
+ let history: History;
+ let urlSyncStrategy: IKbnUrlStateStorage;
+ const getCurrentUrl = () => history.createHref(history.location);
+ const tick = () => new Promise(resolve => setTimeout(resolve));
+
+ beforeEach(() => {
+ container.set(defaultState);
+
+ window.location.href = '/';
+ sessionStorage = new StubBrowserStorage();
+ sessionStorageSyncStrategy = createSessionStorageStateStorage(sessionStorage);
+ history = createBrowserHistory();
+ urlSyncStrategy = createKbnUrlStateStorage({ useHash: false, history });
+ });
+
+ it('change to one storage should also update other storage', () => {
+ const { stop, start } = syncStates([
+ {
+ stateContainer: withDefaultState(container, defaultState),
+ storageKey: key,
+ stateStorage: urlSyncStrategy,
+ },
+ {
+ stateContainer: withDefaultState(container, defaultState),
+ storageKey: key,
+ stateStorage: sessionStorageSyncStrategy,
+ },
+ ]);
+ start();
+
+ const newStateFromUrl = [{ completed: false, id: 1, text: 'changed' }];
+ history.replace('/#?_s=!((completed:!f,id:1,text:changed))');
+
+ expect(container.getState()).toEqual(newStateFromUrl);
+ expect(JSON.parse(sessionStorage.getItem(key)!)).toEqual(newStateFromUrl);
+
+ stop();
+ });
+
+ it('KbnUrlSyncStrategy applies url updates asynchronously to trigger single history change', async () => {
+ const { stop, start } = syncStates([
+ {
+ stateContainer: withDefaultState(container, defaultState),
+ storageKey: key,
+ stateStorage: urlSyncStrategy,
+ },
+ ]);
+ start();
+
+ const startHistoryLength = history.length;
+ container.transitions.add({ id: 2, text: '2', completed: false });
+ container.transitions.add({ id: 3, text: '3', completed: false });
+ container.transitions.completeAll();
+
+ expect(history.length).toBe(startHistoryLength);
+ expect(getCurrentUrl()).toMatchInlineSnapshot(`"/"`);
+
+ await tick();
+ expect(history.length).toBe(startHistoryLength + 1);
+
+ expect(getCurrentUrl()).toMatchInlineSnapshot(
+ `"/#?_s=!((completed:!t,id:0,text:'Learning%20state%20containers'),(completed:!t,id:2,text:'2'),(completed:!t,id:3,text:'3'))"`
+ );
+
+ stop();
+ });
+
+ it('KbnUrlSyncStrategy supports flushing url updates synchronously and triggers single history change', async () => {
+ const { stop, start } = syncStates([
+ {
+ stateContainer: withDefaultState(container, defaultState),
+ storageKey: key,
+ stateStorage: urlSyncStrategy,
+ },
+ ]);
+ start();
+
+ const startHistoryLength = history.length;
+ container.transitions.add({ id: 2, text: '2', completed: false });
+ container.transitions.add({ id: 3, text: '3', completed: false });
+ container.transitions.completeAll();
+
+ expect(history.length).toBe(startHistoryLength);
+ expect(getCurrentUrl()).toMatchInlineSnapshot(`"/"`);
+
+ urlSyncStrategy.flush();
+
+ expect(history.length).toBe(startHistoryLength + 1);
+ expect(getCurrentUrl()).toMatchInlineSnapshot(
+ `"/#?_s=!((completed:!t,id:0,text:'Learning%20state%20containers'),(completed:!t,id:2,text:'2'),(completed:!t,id:3,text:'3'))"`
+ );
+
+ await tick();
+
+ expect(history.length).toBe(startHistoryLength + 1);
+ expect(getCurrentUrl()).toMatchInlineSnapshot(
+ `"/#?_s=!((completed:!t,id:0,text:'Learning%20state%20containers'),(completed:!t,id:2,text:'2'),(completed:!t,id:3,text:'3'))"`
+ );
+
+ stop();
+ });
+
+ it('KbnUrlSyncStrategy supports cancellation of pending updates ', async () => {
+ const { stop, start } = syncStates([
+ {
+ stateContainer: withDefaultState(container, defaultState),
+ storageKey: key,
+ stateStorage: urlSyncStrategy,
+ },
+ ]);
+ start();
+
+ const startHistoryLength = history.length;
+ container.transitions.add({ id: 2, text: '2', completed: false });
+ container.transitions.add({ id: 3, text: '3', completed: false });
+ container.transitions.completeAll();
+
+ expect(history.length).toBe(startHistoryLength);
+ expect(getCurrentUrl()).toMatchInlineSnapshot(`"/"`);
+
+ urlSyncStrategy.cancel();
+
+ expect(history.length).toBe(startHistoryLength);
+ expect(getCurrentUrl()).toMatchInlineSnapshot(`"/"`);
+
+ await tick();
+
+ expect(history.length).toBe(startHistoryLength);
+ expect(getCurrentUrl()).toMatchInlineSnapshot(`"/"`);
+
+ stop();
+ });
+ });
+});
+
+function withDefaultState(
+ stateContainer: BaseStateContainer,
+ // eslint-disable-next-line no-shadow
+ defaultState: State
+): INullableBaseStateContainer {
+ return {
+ ...stateContainer,
+ set: (state: State | null) => {
+ stateContainer.set(state || defaultState);
+ },
+ };
+}
diff --git a/src/plugins/kibana_utils/public/state_sync/state_sync.ts b/src/plugins/kibana_utils/public/state_sync/state_sync.ts
new file mode 100644
index 0000000000000..f0ef1423dec71
--- /dev/null
+++ b/src/plugins/kibana_utils/public/state_sync/state_sync.ts
@@ -0,0 +1,171 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { EMPTY, Subscription } from 'rxjs';
+import { tap } from 'rxjs/operators';
+import defaultComparator from 'fast-deep-equal';
+import { IStateSyncConfig } from './types';
+import { IStateStorage } from './state_sync_state_storage';
+import { distinctUntilChangedWithInitialValue } from '../../common';
+
+/**
+ * Utility for syncing application state wrapped in state container
+ * with some kind of storage (e.g. URL)
+ *
+ * Examples:
+ *
+ * 1. the simplest use case
+ * const stateStorage = createKbnUrlStateStorage();
+ * syncState({
+ * storageKey: '_s',
+ * stateContainer,
+ * stateStorage
+ * });
+ *
+ * 2. conditionally configuring sync strategy
+ * const stateStorage = createKbnUrlStateStorage({useHash: config.get('state:stateContainerInSessionStorage')})
+ * syncState({
+ * storageKey: '_s',
+ * stateContainer,
+ * stateStorage
+ * });
+ *
+ * 3. implementing custom sync strategy
+ * const localStorageStateStorage = {
+ * set: (storageKey, state) => localStorage.setItem(storageKey, JSON.stringify(state)),
+ * get: (storageKey) => localStorage.getItem(storageKey) ? JSON.parse(localStorage.getItem(storageKey)) : null
+ * };
+ * syncState({
+ * storageKey: '_s',
+ * stateContainer,
+ * stateStorage: localStorageStateStorage
+ * });
+ *
+ * 4. Transform state before serialising
+ * Useful for:
+ * * Migration / backward compatibility
+ * * Syncing part of state
+ * * Providing default values
+ * const stateToStorage = (s) => ({ tab: s.tab });
+ * syncState({
+ * storageKey: '_s',
+ * stateContainer: {
+ * get: () => stateToStorage(stateContainer.get()),
+ * set: stateContainer.set(({ tab }) => ({ ...stateContainer.get(), tab }),
+ * state$: stateContainer.state$.pipe(map(stateToStorage))
+ * },
+ * stateStorage
+ * });
+ *
+ * Caveats:
+ *
+ * 1. It is responsibility of consumer to make sure that initial app state and storage are in sync before starting syncing
+ * No initial sync happens when syncState() is called
+ */
+export type StopSyncStateFnType = () => void;
+export type StartSyncStateFnType = () => void;
+export interface ISyncStateRef {
+ // stop syncing state with storage
+ stop: StopSyncStateFnType;
+ // start syncing state with storage
+ start: StartSyncStateFnType;
+}
+export function syncState({
+ storageKey,
+ stateStorage,
+ stateContainer,
+}: IStateSyncConfig): ISyncStateRef {
+ const subscriptions: Subscription[] = [];
+
+ const updateState = () => {
+ const newState = stateStorage.get(storageKey);
+ const oldState = stateContainer.get();
+ if (!defaultComparator(newState, oldState)) {
+ stateContainer.set(newState);
+ }
+ };
+
+ const updateStorage = () => {
+ const newStorageState = stateContainer.get();
+ const oldStorageState = stateStorage.get(storageKey);
+ if (!defaultComparator(newStorageState, oldStorageState)) {
+ stateStorage.set(storageKey, newStorageState);
+ }
+ };
+
+ const onStateChange$ = stateContainer.state$.pipe(
+ distinctUntilChangedWithInitialValue(stateContainer.get(), defaultComparator),
+ tap(() => updateStorage())
+ );
+
+ const onStorageChange$ = stateStorage.change$
+ ? stateStorage.change$(storageKey).pipe(
+ distinctUntilChangedWithInitialValue(stateStorage.get(storageKey), defaultComparator),
+ tap(() => {
+ updateState();
+ })
+ )
+ : EMPTY;
+
+ return {
+ stop: () => {
+ // if stateStorage has any cancellation logic, then run it
+ if (stateStorage.cancel) {
+ stateStorage.cancel();
+ }
+
+ subscriptions.forEach(s => s.unsubscribe());
+ subscriptions.splice(0, subscriptions.length);
+ },
+ start: () => {
+ if (subscriptions.length > 0) {
+ throw new Error("syncState: can't start syncing state, when syncing is in progress");
+ }
+ subscriptions.push(onStateChange$.subscribe(), onStorageChange$.subscribe());
+ },
+ };
+}
+
+/**
+ * multiple different sync configs
+ * syncStates([
+ * {
+ * storageKey: '_s1',
+ * stateStorage: stateStorage1,
+ * stateContainer: stateContainer1,
+ * },
+ * {
+ * storageKey: '_s2',
+ * stateStorage: stateStorage2,
+ * stateContainer: stateContainer2,
+ * },
+ * ]);
+ * @param stateSyncConfigs - Array of IStateSyncConfig to sync
+ */
+export function syncStates(stateSyncConfigs: Array>): ISyncStateRef {
+ const syncRefs = stateSyncConfigs.map(config => syncState(config));
+ return {
+ stop: () => {
+ syncRefs.forEach(s => s.stop());
+ },
+ start: () => {
+ syncRefs.forEach(s => s.start());
+ },
+ };
+}
diff --git a/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_kbn_url_state_storage.test.ts b/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_kbn_url_state_storage.test.ts
new file mode 100644
index 0000000000000..826122176e061
--- /dev/null
+++ b/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_kbn_url_state_storage.test.ts
@@ -0,0 +1,120 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import '../../storage/hashed_item_store/mock';
+import { createKbnUrlStateStorage, IKbnUrlStateStorage } from './create_kbn_url_state_storage';
+import { History, createBrowserHistory } from 'history';
+import { takeUntil, toArray } from 'rxjs/operators';
+import { Subject } from 'rxjs';
+
+describe('KbnUrlStateStorage', () => {
+ describe('useHash: false', () => {
+ let urlStateStorage: IKbnUrlStateStorage;
+ let history: History;
+ const getCurrentUrl = () => history.createHref(history.location);
+ beforeEach(() => {
+ history = createBrowserHistory();
+ history.push('/');
+ urlStateStorage = createKbnUrlStateStorage({ useHash: false, history });
+ });
+
+ it('should persist state to url', async () => {
+ const state = { test: 'test', ok: 1 };
+ const key = '_s';
+ await urlStateStorage.set(key, state);
+ expect(getCurrentUrl()).toMatchInlineSnapshot(`"/#?_s=(ok:1,test:test)"`);
+ expect(urlStateStorage.get(key)).toEqual(state);
+ });
+
+ it('should flush state to url', () => {
+ const state = { test: 'test', ok: 1 };
+ const key = '_s';
+ urlStateStorage.set(key, state);
+ expect(getCurrentUrl()).toMatchInlineSnapshot(`"/"`);
+ urlStateStorage.flush();
+ expect(getCurrentUrl()).toMatchInlineSnapshot(`"/#?_s=(ok:1,test:test)"`);
+ expect(urlStateStorage.get(key)).toEqual(state);
+ });
+
+ it('should cancel url updates', async () => {
+ const state = { test: 'test', ok: 1 };
+ const key = '_s';
+ const pr = urlStateStorage.set(key, state);
+ expect(getCurrentUrl()).toMatchInlineSnapshot(`"/"`);
+ urlStateStorage.cancel();
+ await pr;
+ expect(getCurrentUrl()).toMatchInlineSnapshot(`"/"`);
+ expect(urlStateStorage.get(key)).toEqual(null);
+ });
+
+ it('should notify about url changes', async () => {
+ expect(urlStateStorage.change$).toBeDefined();
+ const key = '_s';
+ const destroy$ = new Subject();
+ const result = urlStateStorage.change$!(key)
+ .pipe(takeUntil(destroy$), toArray())
+ .toPromise();
+
+ history.push(`/#?${key}=(ok:1,test:test)`);
+ history.push(`/?query=test#?${key}=(ok:2,test:test)&some=test`);
+ history.push(`/?query=test#?some=test`);
+
+ destroy$.next();
+ destroy$.complete();
+
+ expect(await result).toEqual([{ test: 'test', ok: 1 }, { test: 'test', ok: 2 }, null]);
+ });
+ });
+
+ describe('useHash: true', () => {
+ let urlStateStorage: IKbnUrlStateStorage;
+ let history: History;
+ const getCurrentUrl = () => history.createHref(history.location);
+ beforeEach(() => {
+ history = createBrowserHistory();
+ history.push('/');
+ urlStateStorage = createKbnUrlStateStorage({ useHash: true, history });
+ });
+
+ it('should persist state to url', async () => {
+ const state = { test: 'test', ok: 1 };
+ const key = '_s';
+ await urlStateStorage.set(key, state);
+ expect(getCurrentUrl()).toMatchInlineSnapshot(`"/#?_s=h@487e077"`);
+ expect(urlStateStorage.get(key)).toEqual(state);
+ });
+
+ it('should notify about url changes', async () => {
+ expect(urlStateStorage.change$).toBeDefined();
+ const key = '_s';
+ const destroy$ = new Subject();
+ const result = urlStateStorage.change$!(key)
+ .pipe(takeUntil(destroy$), toArray())
+ .toPromise();
+
+ history.push(`/#?${key}=(ok:1,test:test)`);
+ history.push(`/?query=test#?${key}=(ok:2,test:test)&some=test`);
+ history.push(`/?query=test#?some=test`);
+
+ destroy$.next();
+ destroy$.complete();
+
+ expect(await result).toEqual([{ test: 'test', ok: 1 }, { test: 'test', ok: 2 }, null]);
+ });
+ });
+});
diff --git a/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_kbn_url_state_storage.ts b/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_kbn_url_state_storage.ts
new file mode 100644
index 0000000000000..245006349ad55
--- /dev/null
+++ b/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_kbn_url_state_storage.ts
@@ -0,0 +1,84 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Observable } from 'rxjs';
+import { map, share } from 'rxjs/operators';
+import { History } from 'history';
+import { IStateStorage } from './types';
+import {
+ createKbnUrlControls,
+ getStateFromKbnUrl,
+ setStateToKbnUrl,
+} from '../../state_management/url';
+
+export interface IKbnUrlStateStorage extends IStateStorage {
+ set: (key: string, state: State, opts?: { replace: boolean }) => Promise;
+ get: (key: string) => State | null;
+ change$: (key: string) => Observable;
+
+ // cancels any pending url updates
+ cancel: () => void;
+
+ // synchronously runs any pending url updates
+ flush: (opts?: { replace?: boolean }) => void;
+}
+
+/**
+ * Implements syncing to/from url strategies.
+ * Replicates what was implemented in state (AppState, GlobalState)
+ * Both expanded and hashed use cases
+ */
+export const createKbnUrlStateStorage = (
+ { useHash = false, history }: { useHash: boolean; history?: History } = { useHash: false }
+): IKbnUrlStateStorage => {
+ const url = createKbnUrlControls(history);
+ return {
+ set: (
+ key: string,
+ state: State,
+ { replace = false }: { replace: boolean } = { replace: false }
+ ) => {
+ // syncState() utils doesn't wait for this promise
+ return url.updateAsync(
+ currentUrl => setStateToKbnUrl(key, state, { useHash }, currentUrl),
+ replace
+ );
+ },
+ get: key => getStateFromKbnUrl(key),
+ change$: (key: string) =>
+ new Observable(observer => {
+ const unlisten = url.listen(() => {
+ observer.next();
+ });
+
+ return () => {
+ unlisten();
+ };
+ }).pipe(
+ map(() => getStateFromKbnUrl(key)),
+ share()
+ ),
+ flush: ({ replace = false }: { replace?: boolean } = {}) => {
+ url.flush(replace);
+ },
+ cancel() {
+ url.cancel();
+ },
+ };
+};
diff --git a/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_session_storage_state_storage.test.ts b/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_session_storage_state_storage.test.ts
new file mode 100644
index 0000000000000..f69629e755008
--- /dev/null
+++ b/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_session_storage_state_storage.test.ts
@@ -0,0 +1,44 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import {
+ createSessionStorageStateStorage,
+ ISessionStorageStateStorage,
+} from './create_session_storage_state_storage';
+import { StubBrowserStorage } from 'test_utils/stub_browser_storage';
+
+describe('SessionStorageStateStorage', () => {
+ let browserStorage: StubBrowserStorage;
+ let stateStorage: ISessionStorageStateStorage;
+ beforeEach(() => {
+ browserStorage = new StubBrowserStorage();
+ stateStorage = createSessionStorageStateStorage(browserStorage);
+ });
+
+ it('should synchronously sync to storage', () => {
+ const state = { state: 'state' };
+ stateStorage.set('key', state);
+ expect(stateStorage.get('key')).toEqual(state);
+ expect(browserStorage.getItem('key')).not.toBeNull();
+ });
+
+ it('should not implement change$', () => {
+ expect(stateStorage.change$).not.toBeDefined();
+ });
+});
diff --git a/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_session_storage_state_storage.ts b/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_session_storage_state_storage.ts
new file mode 100644
index 0000000000000..00edfdfd1ed61
--- /dev/null
+++ b/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_session_storage_state_storage.ts
@@ -0,0 +1,34 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { IStateStorage } from './types';
+
+export interface ISessionStorageStateStorage extends IStateStorage {
+ set: (key: string, state: State) => void;
+ get: (key: string) => State | null;
+}
+
+export const createSessionStorageStateStorage = (
+ storage: Storage = window.sessionStorage
+): ISessionStorageStateStorage => {
+ return {
+ set: (key: string, state: State) => storage.setItem(key, JSON.stringify(state)),
+ get: (key: string) => JSON.parse(storage.getItem(key)!),
+ };
+};
diff --git a/test/typings/encode_uri_query.d.ts b/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/index.ts
similarity index 75%
rename from test/typings/encode_uri_query.d.ts
rename to src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/index.ts
index 4bfc554624446..fe04333e5ef15 100644
--- a/test/typings/encode_uri_query.d.ts
+++ b/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/index.ts
@@ -17,8 +17,9 @@
* under the License.
*/
-declare module 'encode-uri-query' {
- function encodeUriQuery(query: string, usePercentageSpace?: boolean): string;
- // eslint-disable-next-line import/no-default-export
- export default encodeUriQuery;
-}
+export { IStateStorage } from './types';
+export { createKbnUrlStateStorage, IKbnUrlStateStorage } from './create_kbn_url_state_storage';
+export {
+ createSessionStorageStateStorage,
+ ISessionStorageStateStorage,
+} from './create_session_storage_state_storage';
diff --git a/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/types.ts b/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/types.ts
new file mode 100644
index 0000000000000..add1dc259be45
--- /dev/null
+++ b/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/types.ts
@@ -0,0 +1,51 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Observable } from 'rxjs';
+
+/**
+ * Any StateStorage have to implement IStateStorage interface
+ * StateStorage is responsible for:
+ * * state serialisation / deserialization
+ * * persisting to and retrieving from storage
+ *
+ * For an example take a look at already implemented KbnUrl state storage
+ */
+export interface IStateStorage {
+ /**
+ * Take in a state object, should serialise and persist
+ */
+ set: (key: string, state: State) => any;
+
+ /**
+ * Should retrieve state from the storage and deserialize it
+ */
+ get: (key: string) => State | null;
+
+ /**
+ * Should notify when the stored state has changed
+ */
+ change$?: (key: string) => Observable;
+
+ /**
+ * Optional method to cancel any pending activity
+ * syncState() will call it, if it is provided by IStateStorage
+ */
+ cancel?: () => void;
+}
diff --git a/src/plugins/kibana_utils/public/state_sync/types.ts b/src/plugins/kibana_utils/public/state_sync/types.ts
new file mode 100644
index 0000000000000..0f7395ad0f0e5
--- /dev/null
+++ b/src/plugins/kibana_utils/public/state_sync/types.ts
@@ -0,0 +1,56 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { BaseStateContainer } from '../state_containers/types';
+import { IStateStorage } from './state_sync_state_storage';
+
+export interface INullableBaseStateContainer extends BaseStateContainer {
+ // State container for stateSync() have to accept "null"
+ // for example, set() implementation could handle null and fallback to some default state
+ // this is required to handle edge case, when state in storage becomes empty and syncing is in progress.
+ // state container will be notified about about storage becoming empty with null passed in
+ set: (state: State | null) => void;
+}
+
+export interface IStateSyncConfig<
+ State = unknown,
+ StateStorage extends IStateStorage = IStateStorage
+> {
+ /**
+ * Storage key to use for syncing,
+ * e.g. storageKey '_a' should sync state to ?_a query param
+ */
+ storageKey: string;
+ /**
+ * State container to keep in sync with storage, have to implement INullableBaseStateContainer interface
+ * The idea is that ./state_containers/ should be used as a state container,
+ * but it is also possible to implement own custom container for advanced use cases
+ */
+ stateContainer: INullableBaseStateContainer;
+ /**
+ * State storage to use,
+ * State storage is responsible for serialising / deserialising and persisting / retrieving stored state
+ *
+ * There are common strategies already implemented:
+ * './state_sync_state_storage/'
+ * which replicate what State (AppState, GlobalState) in legacy world did
+ *
+ */
+ stateStorage: StateStorage;
+}
diff --git a/tasks/config/karma.js b/tasks/config/karma.js
index c0d6074da61c5..0acd452530b30 100644
--- a/tasks/config/karma.js
+++ b/tasks/config/karma.js
@@ -20,6 +20,7 @@
import { dirname } from 'path';
import { times } from 'lodash';
import { makeJunitReportPath } from '@kbn/test';
+import * as UiSharedDeps from '@kbn/ui-shared-deps';
const TOTAL_CI_SHARDS = 4;
const ROOT = dirname(require.resolve('../../package.json'));
@@ -48,6 +49,25 @@ module.exports = function(grunt) {
return ['progress'];
}
+ function getKarmaFiles(shardNum) {
+ return [
+ 'http://localhost:5610/test_bundle/built_css.css',
+
+ `http://localhost:5610/bundles/kbn-ui-shared-deps/${UiSharedDeps.distFilename}`,
+ 'http://localhost:5610/built_assets/dlls/vendors.bundle.dll.js',
+
+ shardNum === undefined
+ ? `http://localhost:5610/bundles/tests.bundle.js`
+ : `http://localhost:5610/bundles/tests.bundle.js?shards=${TOTAL_CI_SHARDS}&shard_num=${shardNum}`,
+
+ // this causes tilemap tests to fail, probably because the eui styles haven't been
+ // included in the karma harness a long some time, if ever
+ // `http://localhost:5610/bundles/kbn-ui-shared-deps/${UiSharedDeps.lightCssDistFilename}`,
+ 'http://localhost:5610/built_assets/dlls/vendors.style.dll.css',
+ 'http://localhost:5610/bundles/tests.style.css',
+ ];
+ }
+
const config = {
options: {
// base path that will be used to resolve all patterns (eg. files, exclude)
@@ -90,15 +110,7 @@ module.exports = function(grunt) {
},
// list of files / patterns to load in the browser
- files: [
- 'http://localhost:5610/test_bundle/built_css.css',
-
- 'http://localhost:5610/built_assets/dlls/vendors.bundle.dll.js',
- 'http://localhost:5610/bundles/tests.bundle.js',
-
- 'http://localhost:5610/built_assets/dlls/vendors.style.dll.css',
- 'http://localhost:5610/bundles/tests.style.css',
- ],
+ files: getKarmaFiles(),
proxies: {
'/tests/': 'http://localhost:5610/tests/',
@@ -181,15 +193,7 @@ module.exports = function(grunt) {
config[`ciShard-${n}`] = {
singleRun: true,
options: {
- files: [
- 'http://localhost:5610/test_bundle/built_css.css',
-
- 'http://localhost:5610/built_assets/dlls/vendors.bundle.dll.js',
- `http://localhost:5610/bundles/tests.bundle.js?shards=${TOTAL_CI_SHARDS}&shard_num=${n}`,
-
- 'http://localhost:5610/built_assets/dlls/vendors.style.dll.css',
- 'http://localhost:5610/bundles/tests.style.css',
- ],
+ files: getKarmaFiles(n),
},
};
});
diff --git a/test/common/services/security/role_mappings.ts b/test/common/services/security/role_mappings.ts
new file mode 100644
index 0000000000000..cc2fa23825498
--- /dev/null
+++ b/test/common/services/security/role_mappings.ts
@@ -0,0 +1,66 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import axios, { AxiosInstance } from 'axios';
+import util from 'util';
+import { ToolingLog } from '@kbn/dev-utils';
+
+export class RoleMappings {
+ private log: ToolingLog;
+ private axios: AxiosInstance;
+
+ constructor(url: string, log: ToolingLog) {
+ this.log = log;
+ this.axios = axios.create({
+ headers: { 'kbn-xsrf': 'x-pack/ftr/services/security/role_mappings' },
+ baseURL: url,
+ maxRedirects: 0,
+ validateStatus: () => true, // we do our own validation below and throw better error messages
+ });
+ }
+
+ public async create(name: string, roleMapping: Record) {
+ this.log.debug(`creating role mapping ${name}`);
+ const { data, status, statusText } = await this.axios.post(
+ `/internal/security/role_mapping/${name}`,
+ roleMapping
+ );
+ if (status !== 200) {
+ throw new Error(
+ `Expected status code of 200, received ${status} ${statusText}: ${util.inspect(data)}`
+ );
+ }
+ this.log.debug(`created role mapping ${name}`);
+ }
+
+ public async delete(name: string) {
+ this.log.debug(`deleting role mapping ${name}`);
+ const { data, status, statusText } = await this.axios.delete(
+ `/internal/security/role_mapping/${name}`
+ );
+ if (status !== 200 && status !== 404) {
+ throw new Error(
+ `Expected status code of 200 or 404, received ${status} ${statusText}: ${util.inspect(
+ data
+ )}`
+ );
+ }
+ this.log.debug(`deleted role mapping ${name}`);
+ }
+}
diff --git a/test/common/services/security/security.ts b/test/common/services/security/security.ts
index 6649a765a9e50..4eebb7b6697e0 100644
--- a/test/common/services/security/security.ts
+++ b/test/common/services/security/security.ts
@@ -21,6 +21,7 @@ import { format as formatUrl } from 'url';
import { Role } from './role';
import { User } from './user';
+import { RoleMappings } from './role_mappings';
import { FtrProviderContext } from '../../ftr_provider_context';
export function SecurityServiceProvider({ getService }: FtrProviderContext) {
@@ -30,6 +31,7 @@ export function SecurityServiceProvider({ getService }: FtrProviderContext) {
return new (class SecurityService {
role = new Role(url, log);
+ roleMappings = new RoleMappings(url, log);
user = new User(url, log);
})();
}
diff --git a/test/functional/services/apps_menu.ts b/test/functional/services/apps_menu.ts
index a4cd98b2a06ec..fe17532f6a41a 100644
--- a/test/functional/services/apps_menu.ts
+++ b/test/functional/services/apps_menu.ts
@@ -25,7 +25,7 @@ export function AppsMenuProvider({ getService }: FtrProviderContext) {
return new (class AppsMenu {
/**
- * Get the text and href from each of the links in the apps menu
+ * Get the attributes from each of the links in the apps menu
*/
public async readLinks() {
const appMenu = await testSubjects.find('navDrawer');
@@ -37,12 +37,21 @@ export function AppsMenuProvider({ getService }: FtrProviderContext) {
return {
text: $(link).text(),
href: $(link).attr('href'),
+ disabled: $(link).attr('disabled') != null,
};
});
return links;
}
+ /**
+ * Get the attributes from the link with the given name.
+ * @param name
+ */
+ public async getLink(name: string) {
+ return (await this.readLinks()).find(nl => nl.text === name);
+ }
+
/**
* Determine if an app link with the given name exists
* @param name
diff --git a/test/plugin_functional/plugins/core_app_status/kibana.json b/test/plugin_functional/plugins/core_app_status/kibana.json
new file mode 100644
index 0000000000000..91d8e6fd8f9e1
--- /dev/null
+++ b/test/plugin_functional/plugins/core_app_status/kibana.json
@@ -0,0 +1,8 @@
+{
+ "id": "core_app_status",
+ "version": "0.0.1",
+ "kibanaVersion": "kibana",
+ "configPath": ["core_app_status"],
+ "server": false,
+ "ui": true
+}
diff --git a/test/plugin_functional/plugins/core_app_status/package.json b/test/plugin_functional/plugins/core_app_status/package.json
new file mode 100644
index 0000000000000..61655487c6acb
--- /dev/null
+++ b/test/plugin_functional/plugins/core_app_status/package.json
@@ -0,0 +1,17 @@
+{
+ "name": "core_app_status",
+ "version": "1.0.0",
+ "main": "target/test/plugin_functional/plugins/core_app_status",
+ "kibana": {
+ "version": "kibana",
+ "templateVersion": "1.0.0"
+ },
+ "license": "Apache-2.0",
+ "scripts": {
+ "kbn": "node ../../../../scripts/kbn.js",
+ "build": "rm -rf './target' && tsc"
+ },
+ "devDependencies": {
+ "typescript": "3.5.3"
+ }
+}
diff --git a/test/plugin_functional/plugins/core_app_status/public/application.tsx b/test/plugin_functional/plugins/core_app_status/public/application.tsx
new file mode 100644
index 0000000000000..323774392a6d7
--- /dev/null
+++ b/test/plugin_functional/plugins/core_app_status/public/application.tsx
@@ -0,0 +1,64 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import React from 'react';
+import { render, unmountComponentAtNode } from 'react-dom';
+import {
+ EuiPage,
+ EuiPageBody,
+ EuiPageContent,
+ EuiPageContentBody,
+ EuiPageContentHeader,
+ EuiPageContentHeaderSection,
+ EuiPageHeader,
+ EuiPageHeaderSection,
+ EuiTitle,
+} from '@elastic/eui';
+
+import { AppMountContext, AppMountParameters } from 'kibana/public';
+
+const AppStatusApp = () => (
+
+
+
+
+
+ Welcome to App Status Test App!
+
+
+
+
+
+
+
+ App Status Test App home page section title
+
+
+
+ App Status Test App content
+
+
+
+);
+
+export const renderApp = (context: AppMountContext, { element }: AppMountParameters) => {
+ render( , element);
+
+ return () => unmountComponentAtNode(element);
+};
diff --git a/typings/encode_uri_query.d.ts b/test/plugin_functional/plugins/core_app_status/public/index.ts
similarity index 74%
rename from typings/encode_uri_query.d.ts
rename to test/plugin_functional/plugins/core_app_status/public/index.ts
index 4bfc554624446..e0ad7c25a54b8 100644
--- a/typings/encode_uri_query.d.ts
+++ b/test/plugin_functional/plugins/core_app_status/public/index.ts
@@ -17,8 +17,8 @@
* under the License.
*/
-declare module 'encode-uri-query' {
- function encodeUriQuery(query: string, usePercentageSpace?: boolean): string;
- // eslint-disable-next-line import/no-default-export
- export default encodeUriQuery;
-}
+import { PluginInitializer } from 'kibana/public';
+import { CoreAppStatusPlugin, CoreAppStatusPluginSetup, CoreAppStatusPluginStart } from './plugin';
+
+export const plugin: PluginInitializer = () =>
+ new CoreAppStatusPlugin();
diff --git a/test/plugin_functional/plugins/core_app_status/public/plugin.tsx b/test/plugin_functional/plugins/core_app_status/public/plugin.tsx
new file mode 100644
index 0000000000000..85caaaf5f9090
--- /dev/null
+++ b/test/plugin_functional/plugins/core_app_status/public/plugin.tsx
@@ -0,0 +1,56 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Plugin, CoreSetup, AppUpdater, AppUpdatableFields, CoreStart } from 'kibana/public';
+import { BehaviorSubject } from 'rxjs';
+
+export class CoreAppStatusPlugin
+ implements Plugin {
+ private appUpdater = new BehaviorSubject(() => ({}));
+
+ public setup(core: CoreSetup, deps: {}) {
+ core.application.register({
+ id: 'app_status',
+ title: 'App Status',
+ euiIconType: 'snowflake',
+ updater$: this.appUpdater,
+ async mount(context, params) {
+ const { renderApp } = await import('./application');
+ return renderApp(context, params);
+ },
+ });
+
+ return {};
+ }
+
+ public start(core: CoreStart) {
+ return {
+ setAppStatus: (status: Partial) => {
+ this.appUpdater.next(() => status);
+ },
+ navigateToApp: async (appId: string) => {
+ return core.application.navigateToApp(appId);
+ },
+ };
+ }
+ public stop() {}
+}
+
+export type CoreAppStatusPluginSetup = ReturnType;
+export type CoreAppStatusPluginStart = ReturnType;
diff --git a/test/plugin_functional/plugins/core_app_status/tsconfig.json b/test/plugin_functional/plugins/core_app_status/tsconfig.json
new file mode 100644
index 0000000000000..5fcaeafbb0d85
--- /dev/null
+++ b/test/plugin_functional/plugins/core_app_status/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "extends": "../../../../tsconfig.json",
+ "compilerOptions": {
+ "outDir": "./target",
+ "skipLibCheck": true
+ },
+ "include": [
+ "index.ts",
+ "public/**/*.ts",
+ "public/**/*.tsx",
+ "../../../../typings/**/*",
+ ],
+ "exclude": []
+}
diff --git a/test/plugin_functional/test_suites/core_plugins/application_status.ts b/test/plugin_functional/test_suites/core_plugins/application_status.ts
new file mode 100644
index 0000000000000..703ae30533bae
--- /dev/null
+++ b/test/plugin_functional/test_suites/core_plugins/application_status.ts
@@ -0,0 +1,116 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import expect from '@kbn/expect';
+import {
+ AppNavLinkStatus,
+ AppStatus,
+ AppUpdatableFields,
+} from '../../../../src/core/public/application/types';
+import { PluginFunctionalProviderContext } from '../../services';
+import { CoreAppStatusPluginStart } from '../../plugins/core_app_status/public/plugin';
+import '../../plugins/core_provider_plugin/types';
+
+// eslint-disable-next-line import/no-default-export
+export default function({ getService, getPageObjects }: PluginFunctionalProviderContext) {
+ const PageObjects = getPageObjects(['common']);
+ const browser = getService('browser');
+ const appsMenu = getService('appsMenu');
+
+ const setAppStatus = async (s: Partial) => {
+ await browser.executeAsync(async (status: Partial, cb: Function) => {
+ const plugin = window.__coreProvider.start.plugins
+ .core_app_status as CoreAppStatusPluginStart;
+ plugin.setAppStatus(status);
+ cb();
+ }, s);
+ };
+
+ const navigateToApp = async (i: string): Promise<{ error?: string }> => {
+ return (await browser.executeAsync(async (appId, cb: Function) => {
+ // navigating in legacy mode performs a page refresh
+ // and webdriver seems to re-execute the script after the reload
+ // as it considers it didn't end on the previous session.
+ // however when testing navigation to NP app, __coreProvider is not accessible
+ // so we need to check for existence.
+ if (!window.__coreProvider) {
+ cb({});
+ }
+ const plugin = window.__coreProvider.start.plugins
+ .core_app_status as CoreAppStatusPluginStart;
+ try {
+ await plugin.navigateToApp(appId);
+ cb({});
+ } catch (e) {
+ cb({
+ error: e.message,
+ });
+ }
+ }, i)) as any;
+ };
+
+ describe('application status management', () => {
+ beforeEach(async () => {
+ await PageObjects.common.navigateToApp('settings');
+ });
+
+ it('can change the navLink status at runtime', async () => {
+ await setAppStatus({
+ navLinkStatus: AppNavLinkStatus.disabled,
+ });
+ let link = await appsMenu.getLink('App Status');
+ expect(link).not.to.eql(undefined);
+ expect(link!.disabled).to.eql(true);
+
+ await setAppStatus({
+ navLinkStatus: AppNavLinkStatus.hidden,
+ });
+ link = await appsMenu.getLink('App Status');
+ expect(link).to.eql(undefined);
+
+ await setAppStatus({
+ navLinkStatus: AppNavLinkStatus.visible,
+ tooltip: 'Some tooltip',
+ });
+ link = await appsMenu.getLink('Some tooltip'); // the tooltip replaces the name in the selector we use.
+ expect(link).not.to.eql(undefined);
+ expect(link!.disabled).to.eql(false);
+ });
+
+ it('shows an error when navigating to an inaccessible app', async () => {
+ await setAppStatus({
+ status: AppStatus.inaccessible,
+ });
+
+ const result = await navigateToApp('app_status');
+ expect(result.error).to.contain(
+ 'Trying to navigate to an inaccessible application: app_status'
+ );
+ });
+
+ it('allows to navigate to an accessible app', async () => {
+ await setAppStatus({
+ status: AppStatus.accessible,
+ });
+
+ const result = await navigateToApp('app_status');
+ expect(result.error).to.eql(undefined);
+ });
+ });
+}
diff --git a/test/plugin_functional/test_suites/core_plugins/index.ts b/test/plugin_functional/test_suites/core_plugins/index.ts
index 6c55245d10f03..d66e2e7dc5da7 100644
--- a/test/plugin_functional/test_suites/core_plugins/index.ts
+++ b/test/plugin_functional/test_suites/core_plugins/index.ts
@@ -28,5 +28,6 @@ export default function({ loadTestFile }: PluginFunctionalProviderContext) {
loadTestFile(require.resolve('./ui_settings'));
loadTestFile(require.resolve('./top_nav'));
loadTestFile(require.resolve('./application_leave_confirm'));
+ loadTestFile(require.resolve('./application_status'));
});
}
diff --git a/webpackShims/moment-timezone.js b/webpackShims/moment-timezone.js
deleted file mode 100644
index d5e032ff21eef..0000000000000
--- a/webpackShims/moment-timezone.js
+++ /dev/null
@@ -1,21 +0,0 @@
-/*
- * Licensed to Elasticsearch B.V. under one or more contributor
- * license agreements. See the NOTICE file distributed with
- * this work for additional information regarding copyright
- * ownership. Elasticsearch B.V. licenses this file to you under
- * the Apache License, Version 2.0 (the "License"); you may
- * not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-
-var moment = (module.exports = require('../node_modules/moment-timezone/moment-timezone'));
-moment.tz.load(require('../node_modules/moment-timezone/data/packed/latest.json'));
diff --git a/x-pack/dev-tools/jest/create_jest_config.js b/x-pack/dev-tools/jest/create_jest_config.js
index 02904cc48e030..f38181ce56a2f 100644
--- a/x-pack/dev-tools/jest/create_jest_config.js
+++ b/x-pack/dev-tools/jest/create_jest_config.js
@@ -28,6 +28,7 @@ export function createJestConfig({ kibanaDirectory, xPackKibanaDirectory }) {
'\\.(css|less|scss)$': `${kibanaDirectory}/src/dev/jest/mocks/style_mock.js`,
'^test_utils/enzyme_helpers': `${xPackKibanaDirectory}/test_utils/enzyme_helpers.tsx`,
'^test_utils/find_test_subject': `${xPackKibanaDirectory}/test_utils/find_test_subject.ts`,
+ '^test_utils/stub_web_worker': `${xPackKibanaDirectory}/test_utils/stub_web_worker.ts`,
},
coverageDirectory: '/../target/kibana-coverage/jest',
coverageReporters: !!process.env.CODE_COVERAGE ? ['json'] : ['html'],
diff --git a/x-pack/legacy/plugins/apm/public/context/LicenseContext/index.tsx b/x-pack/legacy/plugins/apm/public/context/LicenseContext/index.tsx
index 8cdb7f050027d..0bd3896782603 100644
--- a/x-pack/legacy/plugins/apm/public/context/LicenseContext/index.tsx
+++ b/x-pack/legacy/plugins/apm/public/context/LicenseContext/index.tsx
@@ -16,8 +16,8 @@ export const LicenseContext = React.createContext(
export function LicenseProvider({ children }: { children: React.ReactChild }) {
const { license$ } = useApmPluginContext().plugins.licensing;
- const license = useObservable(license$);
- const hasInvalidLicense = !license?.isActive;
+ const license = useObservable(license$, { isActive: true } as ILicense);
+ const hasInvalidLicense = !license.isActive;
// if license is invalid show an error message
if (hasInvalidLicense) {
diff --git a/x-pack/legacy/plugins/canvas/common/lib/__tests__/get_colors_from_palette.js b/x-pack/legacy/plugins/canvas/common/lib/__tests__/get_colors_from_palette.js
deleted file mode 100644
index e397bda763f1a..0000000000000
--- a/x-pack/legacy/plugins/canvas/common/lib/__tests__/get_colors_from_palette.js
+++ /dev/null
@@ -1,42 +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;
- * you may not use this file except in compliance with the Elastic License.
- */
-
-import expect from '@kbn/expect';
-import { getColorsFromPalette } from '../../lib/get_colors_from_palette';
-import {
- grayscalePalette,
- gradientPalette,
-} from '../../../canvas_plugin_src/functions/common/__tests__/fixtures/test_styles';
-
-describe('getColorsFromPalette', () => {
- it('returns the array of colors from a palette object when gradient is false', () => {
- expect(getColorsFromPalette(grayscalePalette, 20)).to.eql(grayscalePalette.colors);
- });
-
- it('returns an array of colors with equidistant colors with length equal to the number of series when gradient is true', () => {
- const result = getColorsFromPalette(gradientPalette, 16);
- expect(result)
- .to.have.length(16)
- .and.to.eql([
- '#ffffff',
- '#eeeeee',
- '#dddddd',
- '#cccccc',
- '#bbbbbb',
- '#aaaaaa',
- '#999999',
- '#888888',
- '#777777',
- '#666666',
- '#555555',
- '#444444',
- '#333333',
- '#222222',
- '#111111',
- '#000000',
- ]);
- });
-});
diff --git a/x-pack/legacy/plugins/canvas/common/lib/__tests__/get_legend_config.js b/x-pack/legacy/plugins/canvas/common/lib/__tests__/get_legend_config.js
deleted file mode 100644
index ba43db7a83677..0000000000000
--- a/x-pack/legacy/plugins/canvas/common/lib/__tests__/get_legend_config.js
+++ /dev/null
@@ -1,49 +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;
- * you may not use this file except in compliance with the Elastic License.
- */
-
-import expect from '@kbn/expect';
-import { getLegendConfig } from '../get_legend_config';
-
-describe('getLegendConfig', () => {
- describe('show', () => {
- it('hides the legend', () => {
- expect(getLegendConfig(false, 2))
- .to.only.have.key('show')
- .and.to.have.property('show', false);
- expect(getLegendConfig(false, 10))
- .to.only.have.key('show')
- .and.to.have.property('show', false);
- });
-
- it('hides the legend when there are less than 2 series', () => {
- expect(getLegendConfig(false, 1))
- .to.only.have.key('show')
- .and.to.have.property('show', false);
- expect(getLegendConfig(true, 1))
- .to.only.have.key('show')
- .and.to.have.property('show', false);
- });
-
- it('shows the legend when there are two or more series', () => {
- expect(getLegendConfig('sw', 2)).to.have.property('show', true);
- expect(getLegendConfig(true, 5)).to.have.property('show', true);
- });
- });
-
- describe('position', () => {
- it('sets the position of the legend', () => {
- expect(getLegendConfig('nw')).to.have.property('position', 'nw');
- expect(getLegendConfig('ne')).to.have.property('position', 'ne');
- expect(getLegendConfig('sw')).to.have.property('position', 'sw');
- expect(getLegendConfig('se')).to.have.property('position', 'se');
- });
-
- it("defaults to 'ne'", () => {
- expect(getLegendConfig(true)).to.have.property('position', 'ne');
- expect(getLegendConfig('foo')).to.have.property('position', 'ne');
- });
- });
-});
diff --git a/x-pack/legacy/plugins/canvas/common/lib/autocomplete.test.ts b/x-pack/legacy/plugins/canvas/common/lib/autocomplete.test.ts
index 616e45c86c4af..88bb32c846c24 100644
--- a/x-pack/legacy/plugins/canvas/common/lib/autocomplete.test.ts
+++ b/x-pack/legacy/plugins/canvas/common/lib/autocomplete.test.ts
@@ -7,184 +7,195 @@
jest.mock('ui/new_platform');
import { functionSpecs } from '../../__tests__/fixtures/function_specs';
-import { getAutocompleteSuggestions } from './autocomplete';
-
-describe('getAutocompleteSuggestions', () => {
- it('should suggest functions', () => {
- const suggestions = getAutocompleteSuggestions(functionSpecs, '', 0);
- expect(suggestions.length).toBe(functionSpecs.length);
- expect(suggestions[0].start).toBe(0);
- expect(suggestions[0].end).toBe(0);
- });
-
- it('should suggest functions filtered by text', () => {
- const expression = 'pl';
- const suggestions = getAutocompleteSuggestions(functionSpecs, expression, 0);
- const nonmatching = suggestions.map(s => s.text).filter(text => !text.includes(expression));
- expect(nonmatching.length).toBe(0);
- expect(suggestions[0].start).toBe(0);
- expect(suggestions[0].end).toBe(expression.length);
- });
-
- it('should suggest arguments', () => {
- const expression = 'plot ';
- const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length);
- const plotFn = functionSpecs.find(spec => spec.name === 'plot');
- expect(suggestions.length).toBe(Object.keys(plotFn.args).length);
- expect(suggestions[0].start).toBe(expression.length);
- expect(suggestions[0].end).toBe(expression.length);
- });
-
- it('should suggest arguments filtered by text', () => {
- const expression = 'plot axis';
- const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length);
- const plotFn = functionSpecs.find(spec => spec.name === 'plot');
- const matchingArgs = Object.keys(plotFn.args).filter(key => key.includes('axis'));
- expect(suggestions.length).toBe(matchingArgs.length);
- expect(suggestions[0].start).toBe('plot '.length);
- expect(suggestions[0].end).toBe('plot axis'.length);
- });
-
- it('should suggest values', () => {
- const expression = 'shape shape=';
- const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length);
- const shapeFn = functionSpecs.find(spec => spec.name === 'shape');
- expect(suggestions.length).toBe(shapeFn.args.shape.options.length);
- expect(suggestions[0].start).toBe(expression.length);
- expect(suggestions[0].end).toBe(expression.length);
- });
-
- it('should suggest values filtered by text', () => {
- const expression = 'shape shape=ar';
- const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length);
- const shapeFn = functionSpecs.find(spec => spec.name === 'shape');
- const matchingValues = shapeFn.args.shape.options.filter((key: string) => key.includes('ar'));
- expect(suggestions.length).toBe(matchingValues.length);
- expect(suggestions[0].start).toBe(expression.length - 'ar'.length);
- expect(suggestions[0].end).toBe(expression.length);
- });
-
- it('should suggest functions inside an expression', () => {
- const expression = 'if {}';
- const suggestions = getAutocompleteSuggestions(
- functionSpecs,
- expression,
- expression.length - 1
- );
- expect(suggestions.length).toBe(functionSpecs.length);
- expect(suggestions[0].start).toBe(expression.length - 1);
- expect(suggestions[0].end).toBe(expression.length - 1);
- });
-
- it('should suggest arguments inside an expression', () => {
- const expression = 'if {lt }';
- const suggestions = getAutocompleteSuggestions(
- functionSpecs,
- expression,
- expression.length - 1
- );
- const ltFn = functionSpecs.find(spec => spec.name === 'lt');
- expect(suggestions.length).toBe(Object.keys(ltFn.args).length);
- expect(suggestions[0].start).toBe(expression.length - 1);
- expect(suggestions[0].end).toBe(expression.length - 1);
- });
-
- it('should suggest values inside an expression', () => {
- const expression = 'if {shape shape=}';
- const suggestions = getAutocompleteSuggestions(
- functionSpecs,
- expression,
- expression.length - 1
- );
- const shapeFn = functionSpecs.find(spec => spec.name === 'shape');
- expect(suggestions.length).toBe(shapeFn.args.shape.options.length);
- expect(suggestions[0].start).toBe(expression.length - 1);
- expect(suggestions[0].end).toBe(expression.length - 1);
- });
-
- it('should suggest values inside quotes', () => {
- const expression = 'shape shape="ar"';
- const suggestions = getAutocompleteSuggestions(
- functionSpecs,
- expression,
- expression.length - 1
- );
- const shapeFn = functionSpecs.find(spec => spec.name === 'shape');
- const matchingValues = shapeFn.args.shape.options.filter((key: string) => key.includes('ar'));
- expect(suggestions.length).toBe(matchingValues.length);
- expect(suggestions[0].start).toBe(expression.length - '"ar"'.length);
- expect(suggestions[0].end).toBe(expression.length);
- });
-
- it('should prioritize functions that start with text', () => {
- const expression = 't';
- const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length);
- const tableIndex = suggestions.findIndex(suggestion => suggestion.text.includes('table'));
- const alterColumnIndex = suggestions.findIndex(suggestion =>
- suggestion.text.includes('alterColumn')
- );
- expect(tableIndex).toBeLessThan(alterColumnIndex);
- });
-
- it('should prioritize functions that match the previous function type', () => {
- const expression = 'plot | ';
- const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length);
- const renderIndex = suggestions.findIndex(suggestion => suggestion.text.includes('render'));
- const anyIndex = suggestions.findIndex(suggestion => suggestion.text.includes('any'));
- expect(renderIndex).toBeLessThan(anyIndex);
- });
-
- it('should alphabetize functions', () => {
- const expression = '';
- const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length);
- const metricIndex = suggestions.findIndex(suggestion => suggestion.text.includes('metric'));
- const anyIndex = suggestions.findIndex(suggestion => suggestion.text.includes('any'));
- expect(anyIndex).toBeLessThan(metricIndex);
- });
-
- it('should prioritize arguments that start with text', () => {
- const expression = 'plot y';
- const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length);
- const yaxisIndex = suggestions.findIndex(suggestion => suggestion.text.includes('yaxis'));
- const defaultStyleIndex = suggestions.findIndex(suggestion =>
- suggestion.text.includes('defaultStyle')
- );
- expect(yaxisIndex).toBeLessThan(defaultStyleIndex);
- });
-
- it('should prioritize unnamed arguments', () => {
- const expression = 'case ';
- const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length);
- const whenIndex = suggestions.findIndex(suggestion => suggestion.text.includes('when'));
- const thenIndex = suggestions.findIndex(suggestion => suggestion.text.includes('then'));
- expect(whenIndex).toBeLessThan(thenIndex);
- });
-
- it('should alphabetize arguments', () => {
- const expression = 'plot ';
- const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length);
- const yaxisIndex = suggestions.findIndex(suggestion => suggestion.text.includes('yaxis'));
- const defaultStyleIndex = suggestions.findIndex(suggestion =>
- suggestion.text.includes('defaultStyle')
- );
- expect(defaultStyleIndex).toBeLessThan(yaxisIndex);
- });
-
- it('should quote string values', () => {
- const expression = 'shape shape=';
- const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length);
- expect(suggestions[0].text.trim()).toMatch(/^".*"$/);
- });
-
- it('should not quote sub expression value suggestions', () => {
- const expression = 'plot font=';
- const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length);
- expect(suggestions[0].text.trim()).toBe('{font}');
- });
-
- it('should not quote booleans', () => {
- const expression = 'table paginate=true';
- const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length);
- expect(suggestions[0].text.trim()).toBe('true');
+import { getAutocompleteSuggestions, getFnArgDefAtPosition } from './autocomplete';
+
+describe('autocomplete', () => {
+ describe('getFnArgDefAtPosition', () => {
+ it('should return function definition for plot', () => {
+ const expression = 'plot ';
+ const def = getFnArgDefAtPosition(functionSpecs, expression, expression.length);
+ const plotFn = functionSpecs.find(spec => spec.name === 'plot');
+ expect(def.fnDef).toBe(plotFn);
+ });
+ });
+
+ describe('getAutocompleteSuggestions', () => {
+ it('should suggest functions', () => {
+ const suggestions = getAutocompleteSuggestions(functionSpecs, '', 0);
+ expect(suggestions.length).toBe(functionSpecs.length);
+ expect(suggestions[0].start).toBe(0);
+ expect(suggestions[0].end).toBe(0);
+ });
+
+ it('should suggest functions filtered by text', () => {
+ const expression = 'pl';
+ const suggestions = getAutocompleteSuggestions(functionSpecs, expression, 0);
+ const nonmatching = suggestions.map(s => s.text).filter(text => !text.includes(expression));
+ expect(nonmatching.length).toBe(0);
+ expect(suggestions[0].start).toBe(0);
+ expect(suggestions[0].end).toBe(expression.length);
+ });
+
+ it('should suggest arguments', () => {
+ const expression = 'plot ';
+ const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length);
+ const plotFn = functionSpecs.find(spec => spec.name === 'plot');
+ expect(suggestions.length).toBe(Object.keys(plotFn.args).length);
+ expect(suggestions[0].start).toBe(expression.length);
+ expect(suggestions[0].end).toBe(expression.length);
+ });
+
+ it('should suggest arguments filtered by text', () => {
+ const expression = 'plot axis';
+ const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length);
+ const plotFn = functionSpecs.find(spec => spec.name === 'plot');
+ const matchingArgs = Object.keys(plotFn.args).filter(key => key.includes('axis'));
+ expect(suggestions.length).toBe(matchingArgs.length);
+ expect(suggestions[0].start).toBe('plot '.length);
+ expect(suggestions[0].end).toBe('plot axis'.length);
+ });
+
+ it('should suggest values', () => {
+ const expression = 'shape shape=';
+ const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length);
+ const shapeFn = functionSpecs.find(spec => spec.name === 'shape');
+ expect(suggestions.length).toBe(shapeFn.args.shape.options.length);
+ expect(suggestions[0].start).toBe(expression.length);
+ expect(suggestions[0].end).toBe(expression.length);
+ });
+
+ it('should suggest values filtered by text', () => {
+ const expression = 'shape shape=ar';
+ const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length);
+ const shapeFn = functionSpecs.find(spec => spec.name === 'shape');
+ const matchingValues = shapeFn.args.shape.options.filter((key: string) => key.includes('ar'));
+ expect(suggestions.length).toBe(matchingValues.length);
+ expect(suggestions[0].start).toBe(expression.length - 'ar'.length);
+ expect(suggestions[0].end).toBe(expression.length);
+ });
+
+ it('should suggest functions inside an expression', () => {
+ const expression = 'if {}';
+ const suggestions = getAutocompleteSuggestions(
+ functionSpecs,
+ expression,
+ expression.length - 1
+ );
+ expect(suggestions.length).toBe(functionSpecs.length);
+ expect(suggestions[0].start).toBe(expression.length - 1);
+ expect(suggestions[0].end).toBe(expression.length - 1);
+ });
+
+ it('should suggest arguments inside an expression', () => {
+ const expression = 'if {lt }';
+ const suggestions = getAutocompleteSuggestions(
+ functionSpecs,
+ expression,
+ expression.length - 1
+ );
+ const ltFn = functionSpecs.find(spec => spec.name === 'lt');
+ expect(suggestions.length).toBe(Object.keys(ltFn.args).length);
+ expect(suggestions[0].start).toBe(expression.length - 1);
+ expect(suggestions[0].end).toBe(expression.length - 1);
+ });
+
+ it('should suggest values inside an expression', () => {
+ const expression = 'if {shape shape=}';
+ const suggestions = getAutocompleteSuggestions(
+ functionSpecs,
+ expression,
+ expression.length - 1
+ );
+ const shapeFn = functionSpecs.find(spec => spec.name === 'shape');
+ expect(suggestions.length).toBe(shapeFn.args.shape.options.length);
+ expect(suggestions[0].start).toBe(expression.length - 1);
+ expect(suggestions[0].end).toBe(expression.length - 1);
+ });
+
+ it('should suggest values inside quotes', () => {
+ const expression = 'shape shape="ar"';
+ const suggestions = getAutocompleteSuggestions(
+ functionSpecs,
+ expression,
+ expression.length - 1
+ );
+ const shapeFn = functionSpecs.find(spec => spec.name === 'shape');
+ const matchingValues = shapeFn.args.shape.options.filter((key: string) => key.includes('ar'));
+ expect(suggestions.length).toBe(matchingValues.length);
+ expect(suggestions[0].start).toBe(expression.length - '"ar"'.length);
+ expect(suggestions[0].end).toBe(expression.length);
+ });
+
+ it('should prioritize functions that start with text', () => {
+ const expression = 't';
+ const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length);
+ const tableIndex = suggestions.findIndex(suggestion => suggestion.text.includes('table'));
+ const alterColumnIndex = suggestions.findIndex(suggestion =>
+ suggestion.text.includes('alterColumn')
+ );
+ expect(tableIndex).toBeLessThan(alterColumnIndex);
+ });
+
+ it('should prioritize functions that match the previous function type', () => {
+ const expression = 'plot | ';
+ const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length);
+ const renderIndex = suggestions.findIndex(suggestion => suggestion.text.includes('render'));
+ const anyIndex = suggestions.findIndex(suggestion => suggestion.text.includes('any'));
+ expect(renderIndex).toBeLessThan(anyIndex);
+ });
+
+ it('should alphabetize functions', () => {
+ const expression = '';
+ const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length);
+ const metricIndex = suggestions.findIndex(suggestion => suggestion.text.includes('metric'));
+ const anyIndex = suggestions.findIndex(suggestion => suggestion.text.includes('any'));
+ expect(anyIndex).toBeLessThan(metricIndex);
+ });
+
+ it('should prioritize arguments that start with text', () => {
+ const expression = 'plot y';
+ const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length);
+ const yaxisIndex = suggestions.findIndex(suggestion => suggestion.text.includes('yaxis'));
+ const defaultStyleIndex = suggestions.findIndex(suggestion =>
+ suggestion.text.includes('defaultStyle')
+ );
+ expect(yaxisIndex).toBeLessThan(defaultStyleIndex);
+ });
+
+ it('should prioritize unnamed arguments', () => {
+ const expression = 'case ';
+ const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length);
+ const whenIndex = suggestions.findIndex(suggestion => suggestion.text.includes('when'));
+ const thenIndex = suggestions.findIndex(suggestion => suggestion.text.includes('then'));
+ expect(whenIndex).toBeLessThan(thenIndex);
+ });
+
+ it('should alphabetize arguments', () => {
+ const expression = 'plot ';
+ const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length);
+ const yaxisIndex = suggestions.findIndex(suggestion => suggestion.text.includes('yaxis'));
+ const defaultStyleIndex = suggestions.findIndex(suggestion =>
+ suggestion.text.includes('defaultStyle')
+ );
+ expect(defaultStyleIndex).toBeLessThan(yaxisIndex);
+ });
+
+ it('should quote string values', () => {
+ const expression = 'shape shape=';
+ const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length);
+ expect(suggestions[0].text.trim()).toMatch(/^".*"$/);
+ });
+
+ it('should not quote sub expression value suggestions', () => {
+ const expression = 'plot font=';
+ const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length);
+ expect(suggestions[0].text.trim()).toBe('{font}');
+ });
+
+ it('should not quote booleans', () => {
+ const expression = 'table paginate=true';
+ const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length);
+ expect(suggestions[0].text.trim()).toBe('true');
+ });
});
});
diff --git a/x-pack/legacy/plugins/canvas/common/lib/__tests__/dataurl.test.ts b/x-pack/legacy/plugins/canvas/common/lib/dataurl.test.ts
similarity index 77%
rename from x-pack/legacy/plugins/canvas/common/lib/__tests__/dataurl.test.ts
rename to x-pack/legacy/plugins/canvas/common/lib/dataurl.test.ts
index acd9e6d1821d6..8bfe723bc2ae0 100644
--- a/x-pack/legacy/plugins/canvas/common/lib/__tests__/dataurl.test.ts
+++ b/x-pack/legacy/plugins/canvas/common/lib/dataurl.test.ts
@@ -4,13 +4,15 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { isValidDataUrl, parseDataUrl } from '../dataurl';
+import { isValidDataUrl, parseDataUrl } from './dataurl';
const BASE64_TEXT = 'data:text/plain;charset=utf-8;base64,VGhpcyBpcyBhIHRlc3Q=';
const BASE64_SVG =
'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciLz4=';
const BASE64_PIXEL =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk8PxfDwADYgHJvQ16TAAAAABJRU5ErkJggg==';
+const INVALID_BASE64_PIXEL =
+ 'data:image/png;%89PNG%0D%0A%1A%0A%00%00%00%0DIHDR%00%00%00%01%00%00%00%01%08%06%00%00%00%1F%15%C4%89%0';
const RAW_TEXT = 'data:text/plain;charset=utf-8,This%20is%20a%20test';
const RAW_SVG =
@@ -20,10 +22,13 @@ const RAW_PIXEL =
describe('dataurl', () => {
describe('isValidDataUrl', () => {
- test('invalid data url', () => {
+ it('returns false for an invalid data url', () => {
expect(isValidDataUrl('somestring')).toBe(false);
});
- test('valid data urls', () => {
+ it('returns false for an empty string', () => {
+ expect(isValidDataUrl('')).toBe(false);
+ });
+ it('returns true for valid data urls', () => {
expect(isValidDataUrl(BASE64_TEXT)).toBe(true);
expect(isValidDataUrl(BASE64_SVG)).toBe(true);
expect(isValidDataUrl(BASE64_PIXEL)).toBe(true);
@@ -34,10 +39,13 @@ describe('dataurl', () => {
});
describe('dataurl.parseDataUrl', () => {
- test('invalid data url', () => {
+ it('returns null for an invalid data url', () => {
expect(parseDataUrl('somestring')).toBeNull();
});
- test('text data urls', () => {
+ it('returns null for an invalid base64 image', () => {
+ expect(parseDataUrl(INVALID_BASE64_PIXEL)).toBeNull();
+ });
+ it('returns correct values for text data urls', () => {
expect(parseDataUrl(BASE64_TEXT)).toEqual({
charset: 'utf-8',
data: null,
@@ -55,7 +63,7 @@ describe('dataurl', () => {
mimetype: 'text/plain',
});
});
- test('png data urls', () => {
+ it('returns correct values for png data urls', () => {
expect(parseDataUrl(RAW_PIXEL)).toBeNull();
expect(parseDataUrl(BASE64_PIXEL)).toEqual({
charset: undefined,
@@ -66,7 +74,7 @@ describe('dataurl', () => {
mimetype: 'image/png',
});
});
- test('svg data urls', () => {
+ it('returns correct values for svg data urls', () => {
expect(parseDataUrl(RAW_SVG)).toEqual({
charset: undefined,
data: null,
diff --git a/x-pack/legacy/plugins/canvas/common/lib/errors.test.js b/x-pack/legacy/plugins/canvas/common/lib/errors.test.js
new file mode 100644
index 0000000000000..a589fde5dadb6
--- /dev/null
+++ b/x-pack/legacy/plugins/canvas/common/lib/errors.test.js
@@ -0,0 +1,15 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { RenderError } from './errors';
+
+describe('errors', () => {
+ it('creates a test error', () => {
+ // eslint-disable-next-line new-cap
+ const throwTestError = () => RenderError();
+ expect(throwTestError.name).toBe('throwTestError');
+ });
+});
diff --git a/x-pack/legacy/plugins/canvas/common/lib/expression_form_handlers.test.js b/x-pack/legacy/plugins/canvas/common/lib/expression_form_handlers.test.js
new file mode 100644
index 0000000000000..ae46661b50cd2
--- /dev/null
+++ b/x-pack/legacy/plugins/canvas/common/lib/expression_form_handlers.test.js
@@ -0,0 +1,17 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { ExpressionFormHandlers } from './expression_form_handlers';
+
+describe('ExpressionFormHandlers', () => {
+ it('executes destroy function', () => {
+ const handler = new ExpressionFormHandlers();
+ handler.onDestroy(() => {
+ return 'DESTROYED!';
+ });
+ expect(handler.destroy()).toBe('DESTROYED!');
+ });
+});
diff --git a/x-pack/legacy/plugins/canvas/common/lib/fetch.test.ts b/x-pack/legacy/plugins/canvas/common/lib/fetch.test.ts
new file mode 100644
index 0000000000000..d06c2af4f062a
--- /dev/null
+++ b/x-pack/legacy/plugins/canvas/common/lib/fetch.test.ts
@@ -0,0 +1,24 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { fetch, arrayBufferFetch } from './fetch';
+
+describe('fetch', () => {
+ it('test fetch headers', () => {
+ expect(fetch.defaults.headers.Accept).toBe('application/json');
+ expect(fetch.defaults.headers['Content-Type']).toBe('application/json');
+ expect(fetch.defaults.headers['kbn-xsrf']).toBe('professionally-crafted-string-of-text');
+ });
+
+ it('test arrayBufferFetch headers', () => {
+ expect(arrayBufferFetch.defaults.headers.Accept).toBe('application/json');
+ expect(arrayBufferFetch.defaults.headers['Content-Type']).toBe('application/json');
+ expect(arrayBufferFetch.defaults.headers['kbn-xsrf']).toBe(
+ 'professionally-crafted-string-of-text'
+ );
+ expect(arrayBufferFetch.defaults.responseType).toBe('arraybuffer');
+ });
+});
diff --git a/x-pack/legacy/plugins/canvas/common/lib/get_colors_from_palette.test.js b/x-pack/legacy/plugins/canvas/common/lib/get_colors_from_palette.test.js
new file mode 100644
index 0000000000000..ebc72db1f67f0
--- /dev/null
+++ b/x-pack/legacy/plugins/canvas/common/lib/get_colors_from_palette.test.js
@@ -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;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import {
+ grayscalePalette,
+ gradientPalette,
+} from '../../canvas_plugin_src/functions/common/__tests__/fixtures/test_styles';
+import { getColorsFromPalette } from './get_colors_from_palette';
+
+describe('getColorsFromPalette', () => {
+ it('returns the array of colors from a palette object when gradient is false', () => {
+ expect(getColorsFromPalette(grayscalePalette, 20)).toBe(grayscalePalette.colors);
+ });
+
+ it('returns an array of colors with equidistant colors with length equal to the number of series when gradient is true', () => {
+ const result = getColorsFromPalette(gradientPalette, 16);
+ expect(result).toEqual([
+ '#ffffff',
+ '#eeeeee',
+ '#dddddd',
+ '#cccccc',
+ '#bbbbbb',
+ '#aaaaaa',
+ '#999999',
+ '#888888',
+ '#777777',
+ '#666666',
+ '#555555',
+ '#444444',
+ '#333333',
+ '#222222',
+ '#111111',
+ '#000000',
+ ]);
+ expect(result).toHaveLength(16);
+ });
+});
diff --git a/x-pack/legacy/plugins/canvas/common/lib/__tests__/get_field_type.test.ts b/x-pack/legacy/plugins/canvas/common/lib/get_field_type.test.ts
similarity index 87%
rename from x-pack/legacy/plugins/canvas/common/lib/__tests__/get_field_type.test.ts
rename to x-pack/legacy/plugins/canvas/common/lib/get_field_type.test.ts
index 34cfbb5a2befb..82e724c33ecc8 100644
--- a/x-pack/legacy/plugins/canvas/common/lib/__tests__/get_field_type.test.ts
+++ b/x-pack/legacy/plugins/canvas/common/lib/get_field_type.test.ts
@@ -4,11 +4,11 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { getFieldType } from '../get_field_type';
import {
emptyTable,
testTable,
-} from '../../../canvas_plugin_src/functions/common/__tests__/fixtures/test_tables';
+} from '../../canvas_plugin_src/functions/common/__tests__/fixtures/test_tables';
+import { getFieldType } from './get_field_type';
describe('getFieldType', () => {
it('returns type of a field in a datatable', () => {
diff --git a/x-pack/legacy/plugins/canvas/common/lib/get_legend_config.test.js b/x-pack/legacy/plugins/canvas/common/lib/get_legend_config.test.js
new file mode 100644
index 0000000000000..b9ab9ae6aba54
--- /dev/null
+++ b/x-pack/legacy/plugins/canvas/common/lib/get_legend_config.test.js
@@ -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;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { getLegendConfig } from './get_legend_config';
+
+describe('getLegendConfig', () => {
+ describe('show', () => {
+ it('hides the legend', () => {
+ expect(getLegendConfig(false, 2)).toHaveProperty('show', false);
+ expect(getLegendConfig(false, 10)).toHaveProperty('show', false);
+ });
+
+ it('hides the legend when there are less than 2 series', () => {
+ expect(getLegendConfig(false, 1)).toHaveProperty('show', false);
+ expect(getLegendConfig(true, 1)).toHaveProperty('show', false);
+ });
+
+ it('shows the legend when there are two or more series', () => {
+ expect(getLegendConfig('sw', 2)).toHaveProperty('show', true);
+ expect(getLegendConfig(true, 5)).toHaveProperty('show', true);
+ });
+ });
+
+ describe('position', () => {
+ it('sets the position of the legend', () => {
+ expect(getLegendConfig('nw')).toHaveProperty('position', 'nw');
+ expect(getLegendConfig('ne')).toHaveProperty('position', 'ne');
+ expect(getLegendConfig('sw')).toHaveProperty('position', 'sw');
+ expect(getLegendConfig('se')).toHaveProperty('position', 'se');
+ });
+
+ it("defaults to 'ne'", () => {
+ expect(getLegendConfig(true)).toHaveProperty('position', 'ne');
+ expect(getLegendConfig('foo')).toHaveProperty('position', 'ne');
+ });
+ });
+});
diff --git a/x-pack/legacy/plugins/canvas/common/lib/handlebars.test.js b/x-pack/legacy/plugins/canvas/common/lib/handlebars.test.js
new file mode 100644
index 0000000000000..5fcb2d42395fa
--- /dev/null
+++ b/x-pack/legacy/plugins/canvas/common/lib/handlebars.test.js
@@ -0,0 +1,19 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { testTable } from '../../canvas_plugin_src/functions/common/__tests__/fixtures/test_tables';
+import { Handlebars } from './handlebars';
+
+describe('handlebars', () => {
+ it('registers math function and returns argument error', () => {
+ const template = Handlebars.compile("test math: {{math rows 'mean(price * quantity)' 2}}");
+ expect(template()).toBe('test math: MATH ERROR: first argument must be an array');
+ });
+ it('evaluates math function successfully', () => {
+ const template = Handlebars.compile("test math: {{math rows 'mean(price * quantity)' 2}}");
+ expect(template(testTable)).toBe('test math: 82164.33');
+ });
+});
diff --git a/x-pack/legacy/plugins/canvas/common/lib/__tests__/hex_to_rgb.test.ts b/x-pack/legacy/plugins/canvas/common/lib/hex_to_rgb.test.ts
similarity index 80%
rename from x-pack/legacy/plugins/canvas/common/lib/__tests__/hex_to_rgb.test.ts
rename to x-pack/legacy/plugins/canvas/common/lib/hex_to_rgb.test.ts
index d9aa56314948e..00b4b40fa9839 100644
--- a/x-pack/legacy/plugins/canvas/common/lib/__tests__/hex_to_rgb.test.ts
+++ b/x-pack/legacy/plugins/canvas/common/lib/hex_to_rgb.test.ts
@@ -4,21 +4,21 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { hexToRgb } from '../hex_to_rgb';
+import { hexToRgb } from './hex_to_rgb';
describe('hexToRgb', () => {
- test('invalid hex', () => {
+ it('returns null for an invalid hex', () => {
expect(hexToRgb('hexadecimal')).toBeNull();
expect(hexToRgb('#00')).toBeNull();
expect(hexToRgb('#00000')).toBeNull();
});
- test('shorthand', () => {
+ it('returns correct value for shorthand hex codes', () => {
expect(hexToRgb('#000')).toEqual([0, 0, 0]);
expect(hexToRgb('#FFF')).toEqual([255, 255, 255]);
expect(hexToRgb('#fff')).toEqual([255, 255, 255]);
expect(hexToRgb('#fFf')).toEqual([255, 255, 255]);
});
- test('longhand', () => {
+ it('returns correct value for longhand hex codes', () => {
expect(hexToRgb('#000000')).toEqual([0, 0, 0]);
expect(hexToRgb('#ffffff')).toEqual([255, 255, 255]);
expect(hexToRgb('#fffFFF')).toEqual([255, 255, 255]);
diff --git a/x-pack/legacy/plugins/canvas/common/lib/__tests__/httpurl.test.ts b/x-pack/legacy/plugins/canvas/common/lib/httpurl.test.ts
similarity index 97%
rename from x-pack/legacy/plugins/canvas/common/lib/__tests__/httpurl.test.ts
rename to x-pack/legacy/plugins/canvas/common/lib/httpurl.test.ts
index 2a7cef7cf4236..65bc2469647aa 100644
--- a/x-pack/legacy/plugins/canvas/common/lib/__tests__/httpurl.test.ts
+++ b/x-pack/legacy/plugins/canvas/common/lib/httpurl.test.ts
@@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { isValidHttpUrl } from '../httpurl';
+import { isValidHttpUrl } from './httpurl';
describe('httpurl.isValidHttpUrl', () => {
it('matches HTTP URLs', () => {
diff --git a/x-pack/legacy/plugins/canvas/common/lib/__tests__/pivot_object_array.test.ts b/x-pack/legacy/plugins/canvas/common/lib/pivot_object_array.test.ts
similarity index 97%
rename from x-pack/legacy/plugins/canvas/common/lib/__tests__/pivot_object_array.test.ts
rename to x-pack/legacy/plugins/canvas/common/lib/pivot_object_array.test.ts
index 6f6d42e7129a9..faf319769cab0 100644
--- a/x-pack/legacy/plugins/canvas/common/lib/__tests__/pivot_object_array.test.ts
+++ b/x-pack/legacy/plugins/canvas/common/lib/pivot_object_array.test.ts
@@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { pivotObjectArray } from '../pivot_object_array';
+import { pivotObjectArray } from './pivot_object_array';
interface Car {
make: string;
diff --git a/x-pack/legacy/plugins/canvas/common/lib/resolve_dataurl.test.js b/x-pack/legacy/plugins/canvas/common/lib/resolve_dataurl.test.js
new file mode 100644
index 0000000000000..bbbd4f51d483f
--- /dev/null
+++ b/x-pack/legacy/plugins/canvas/common/lib/resolve_dataurl.test.js
@@ -0,0 +1,41 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { missingImage } from '../../common/lib/missing_asset';
+import { resolveFromArgs, resolveWithMissingImage } from './resolve_dataurl';
+
+describe('resolve_dataurl', () => {
+ describe('resolveFromArgs', () => {
+ it('finds and returns the dataurl from args successfully', () => {
+ const args = {
+ name: 'dataurl',
+ argType: 'imageUpload',
+ dataurl: [missingImage, 'test2'],
+ };
+ expect(resolveFromArgs(args)).toBe(missingImage);
+ });
+ it('finds and returns null for invalid dataurl', () => {
+ const args = {
+ name: 'dataurl',
+ argType: 'imageUpload',
+ dataurl: ['invalid url', 'test2'],
+ };
+ expect(resolveFromArgs(args)).toBe(null);
+ });
+ });
+
+ describe('resolveWithMissingImage', () => {
+ it('returns valid dataurl', () => {
+ expect(resolveWithMissingImage(missingImage)).toBe(missingImage);
+ });
+ it('returns missingImage for invalid dataurl', () => {
+ expect(resolveWithMissingImage('invalid dataurl')).toBe(missingImage);
+ });
+ it('returns null for null dataurl', () => {
+ expect(resolveWithMissingImage(null)).toBe(null);
+ });
+ });
+});
diff --git a/x-pack/legacy/plugins/canvas/common/lib/__tests__/unquote_string.test.ts b/x-pack/legacy/plugins/canvas/common/lib/unquote_string.test.ts
similarity index 94%
rename from x-pack/legacy/plugins/canvas/common/lib/__tests__/unquote_string.test.ts
rename to x-pack/legacy/plugins/canvas/common/lib/unquote_string.test.ts
index e67e46f9e5dac..d6eeb9392f40f 100644
--- a/x-pack/legacy/plugins/canvas/common/lib/__tests__/unquote_string.test.ts
+++ b/x-pack/legacy/plugins/canvas/common/lib/unquote_string.test.ts
@@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { unquoteString } from '../unquote_string';
+import { unquoteString } from './unquote_string';
describe('unquoteString', () => {
it('removes double quotes', () => {
diff --git a/x-pack/legacy/plugins/canvas/common/lib/url.test.js b/x-pack/legacy/plugins/canvas/common/lib/url.test.js
new file mode 100644
index 0000000000000..d49d12c6bf382
--- /dev/null
+++ b/x-pack/legacy/plugins/canvas/common/lib/url.test.js
@@ -0,0 +1,21 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { missingImage } from '../../common/lib/missing_asset';
+import { isValidUrl } from './url';
+
+describe('resolve_dataurl', () => {
+ it('returns valid dataurl', () => {
+ expect(isValidUrl(missingImage)).toBe(true);
+ });
+ it('returns valid http url', () => {
+ const httpurl = 'https://test.com/s/';
+ expect(isValidUrl(httpurl)).toBe(true);
+ });
+ it('returns false for invalid url', () => {
+ expect(isValidUrl('test')).toBe(false);
+ });
+});
diff --git a/x-pack/legacy/plugins/canvas/webpackShims/moment.js b/x-pack/legacy/plugins/canvas/webpackShims/moment.js
deleted file mode 100644
index 1261aa7f7bd0f..0000000000000
--- a/x-pack/legacy/plugins/canvas/webpackShims/moment.js
+++ /dev/null
@@ -1,2 +0,0 @@
-/* eslint-disable */
-module.exports = require('../../../node_modules/moment/min/moment.min.js');
diff --git a/x-pack/legacy/plugins/graph/public/components/field_manager/field_manager.test.tsx b/x-pack/legacy/plugins/graph/public/components/field_manager/field_manager.test.tsx
index 2f38f68b01f16..d7cbcf59213be 100644
--- a/x-pack/legacy/plugins/graph/public/components/field_manager/field_manager.test.tsx
+++ b/x-pack/legacy/plugins/graph/public/components/field_manager/field_manager.test.tsx
@@ -33,6 +33,7 @@ describe('field_manager', () => {
selected: true,
type: 'string',
hopSize: 5,
+ aggregatable: true,
},
{
name: 'field2',
@@ -42,6 +43,7 @@ describe('field_manager', () => {
type: 'string',
hopSize: 0,
lastValidHopSize: 5,
+ aggregatable: false,
},
{
name: 'field3',
@@ -50,6 +52,16 @@ describe('field_manager', () => {
selected: false,
type: 'string',
hopSize: 5,
+ aggregatable: true,
+ },
+ {
+ name: 'field4',
+ color: 'orange',
+ icon: getSuitableIcon('field4'),
+ selected: false,
+ type: 'string',
+ hopSize: 5,
+ aggregatable: false,
},
])
);
@@ -86,6 +98,17 @@ describe('field_manager', () => {
).toEqual('field2');
});
+ it('should show selected non-aggregatable fields in picker, but hide unselected ones', () => {
+ expect(
+ getInstance()
+ .find(FieldPicker)
+ .dive()
+ .find(EuiSelectable)
+ .prop('options')
+ .map((option: { label: string }) => option.label)
+ ).toEqual(['field1', 'field2', 'field3']);
+ });
+
it('should select fields from picker', () => {
expect(
getInstance()
@@ -130,6 +153,25 @@ describe('field_manager', () => {
expect(getInstance().find(FieldEditor).length).toEqual(1);
});
+ it('should show remove non-aggregatable fields from picker after deselection', () => {
+ act(() => {
+ getInstance()
+ .find(FieldEditor)
+ .at(1)
+ .dive()
+ .find(EuiContextMenu)
+ .prop('panels')![0].items![2].onClick!({} as any);
+ });
+ expect(
+ getInstance()
+ .find(FieldPicker)
+ .dive()
+ .find(EuiSelectable)
+ .prop('options')
+ .map((option: { label: string }) => option.label)
+ ).toEqual(['field1', 'field3']);
+ });
+
it('should disable field', () => {
const toggleItem = getInstance()
.find(FieldEditor)
diff --git a/x-pack/legacy/plugins/graph/public/components/field_manager/field_picker.tsx b/x-pack/legacy/plugins/graph/public/components/field_manager/field_picker.tsx
index 6ad792defb669..b38e3f8430980 100644
--- a/x-pack/legacy/plugins/graph/public/components/field_manager/field_picker.tsx
+++ b/x-pack/legacy/plugins/graph/public/components/field_manager/field_picker.tsx
@@ -114,9 +114,26 @@ export function FieldPicker({
function toOptions(
fields: WorkspaceField[]
): Array<{ label: string; checked?: 'on' | 'off'; prepend?: ReactNode }> {
- return fields.map(field => ({
- label: field.name,
- prepend: ,
- checked: field.selected ? 'on' : undefined,
- }));
+ return (
+ fields
+ // don't show non-aggregatable fields, except for the case when they are already selected.
+ // this is necessary to ensure backwards compatibility with existing workspaces that might
+ // contain non-aggregatable fields.
+ .filter(field => isExplorable(field) || field.selected)
+ .map(field => ({
+ label: field.name,
+ prepend: ,
+ checked: field.selected ? 'on' : undefined,
+ }))
+ );
+}
+
+const explorableTypes = ['string', 'number', 'date', 'ip', 'boolean'];
+
+function isExplorable(field: WorkspaceField) {
+ if (!field.aggregatable) {
+ return false;
+ }
+
+ return explorableTypes.includes(field.type);
}
diff --git a/x-pack/legacy/plugins/graph/public/components/settings/settings.test.tsx b/x-pack/legacy/plugins/graph/public/components/settings/settings.test.tsx
index a615901f40e25..0109e1f5a5ac7 100644
--- a/x-pack/legacy/plugins/graph/public/components/settings/settings.test.tsx
+++ b/x-pack/legacy/plugins/graph/public/components/settings/settings.test.tsx
@@ -112,6 +112,7 @@ describe('settings', () => {
code: '1',
label: 'test',
},
+ aggregatable: true,
},
{
selected: false,
@@ -123,6 +124,7 @@ describe('settings', () => {
code: '1',
label: 'test',
},
+ aggregatable: true,
},
])
);
diff --git a/x-pack/legacy/plugins/graph/public/services/fetch_top_nodes.test.ts b/x-pack/legacy/plugins/graph/public/services/fetch_top_nodes.test.ts
index 3bfc868fcb06e..79ff4debc7e82 100644
--- a/x-pack/legacy/plugins/graph/public/services/fetch_top_nodes.test.ts
+++ b/x-pack/legacy/plugins/graph/public/services/fetch_top_nodes.test.ts
@@ -13,8 +13,24 @@ describe('fetch_top_nodes', () => {
it('should build terms agg', async () => {
const postMock = jest.fn(() => Promise.resolve({ resp: {} }));
await fetchTopNodes(postMock as any, 'test', [
- { color: '', hopSize: 5, icon, name: 'field1', selected: false, type: 'string' },
- { color: '', hopSize: 5, icon, name: 'field2', selected: false, type: 'string' },
+ {
+ color: '',
+ hopSize: 5,
+ icon,
+ name: 'field1',
+ selected: false,
+ type: 'string',
+ aggregatable: true,
+ },
+ {
+ color: '',
+ hopSize: 5,
+ icon,
+ name: 'field2',
+ selected: false,
+ type: 'string',
+ aggregatable: true,
+ },
]);
expect(postMock).toHaveBeenCalledWith('../api/graph/searchProxy', {
body: JSON.stringify({
@@ -65,8 +81,24 @@ describe('fetch_top_nodes', () => {
})
);
const result = await fetchTopNodes(postMock as any, 'test', [
- { color: 'red', hopSize: 5, icon, name: 'field1', selected: false, type: 'string' },
- { color: 'blue', hopSize: 5, icon, name: 'field2', selected: false, type: 'string' },
+ {
+ color: 'red',
+ hopSize: 5,
+ icon,
+ name: 'field1',
+ selected: false,
+ type: 'string',
+ aggregatable: true,
+ },
+ {
+ color: 'blue',
+ hopSize: 5,
+ icon,
+ name: 'field2',
+ selected: false,
+ type: 'string',
+ aggregatable: true,
+ },
]);
expect(result.length).toEqual(4);
expect(result[0]).toEqual({
diff --git a/x-pack/legacy/plugins/graph/public/services/persistence/deserialize.test.ts b/x-pack/legacy/plugins/graph/public/services/persistence/deserialize.test.ts
index d38c950a5986f..1861479f85f18 100644
--- a/x-pack/legacy/plugins/graph/public/services/persistence/deserialize.test.ts
+++ b/x-pack/legacy/plugins/graph/public/services/persistence/deserialize.test.ts
@@ -119,9 +119,9 @@ describe('deserialize', () => {
savedWorkspace,
{
getNonScriptedFields: () => [
- { name: 'field1', type: 'string' },
- { name: 'field2', type: 'string' },
- { name: 'field3', type: 'string' },
+ { name: 'field1', type: 'string', aggregatable: true },
+ { name: 'field2', type: 'string', aggregatable: true },
+ { name: 'field3', type: 'string', aggregatable: true },
],
} as IndexPattern,
workspace
@@ -140,6 +140,7 @@ describe('deserialize', () => {
expect(allFields).toMatchInlineSnapshot(`
Array [
Object {
+ "aggregatable": true,
"color": "black",
"hopSize": undefined,
"icon": undefined,
@@ -149,6 +150,7 @@ describe('deserialize', () => {
"type": "string",
},
Object {
+ "aggregatable": true,
"color": "black",
"hopSize": undefined,
"icon": undefined,
@@ -158,6 +160,7 @@ describe('deserialize', () => {
"type": "string",
},
Object {
+ "aggregatable": true,
"color": "#CE0060",
"hopSize": 5,
"icon": Object {
diff --git a/x-pack/legacy/plugins/graph/public/services/persistence/deserialize.ts b/x-pack/legacy/plugins/graph/public/services/persistence/deserialize.ts
index af34b4f1a725b..43425077cc174 100644
--- a/x-pack/legacy/plugins/graph/public/services/persistence/deserialize.ts
+++ b/x-pack/legacy/plugins/graph/public/services/persistence/deserialize.ts
@@ -89,6 +89,7 @@ export function mapFields(indexPattern: IndexPattern): WorkspaceField[] {
color: colorChoices[index % colorChoices.length],
selected: false,
type: field.type,
+ aggregatable: Boolean(field.aggregatable),
}))
.sort((a, b) => {
if (a.name < b.name) {
diff --git a/x-pack/legacy/plugins/graph/public/services/persistence/serialize.test.ts b/x-pack/legacy/plugins/graph/public/services/persistence/serialize.test.ts
index 0e0c750383a71..a3942eccfdac3 100644
--- a/x-pack/legacy/plugins/graph/public/services/persistence/serialize.test.ts
+++ b/x-pack/legacy/plugins/graph/public/services/persistence/serialize.test.ts
@@ -41,6 +41,7 @@ describe('serialize', () => {
name: 'field1',
selected: true,
type: 'string',
+ aggregatable: true,
},
{
color: 'black',
@@ -48,6 +49,7 @@ describe('serialize', () => {
name: 'field2',
selected: true,
type: 'string',
+ aggregatable: true,
},
],
selectedIndex: {
diff --git a/x-pack/legacy/plugins/graph/public/types/app_state.ts b/x-pack/legacy/plugins/graph/public/types/app_state.ts
index eef8060f07f5c..876f2cf23b53a 100644
--- a/x-pack/legacy/plugins/graph/public/types/app_state.ts
+++ b/x-pack/legacy/plugins/graph/public/types/app_state.ts
@@ -25,6 +25,7 @@ export interface WorkspaceField {
icon: FontawesomeIcon;
selected: boolean;
type: string;
+ aggregatable: boolean;
}
export interface AdvancedSettings {
diff --git a/x-pack/legacy/plugins/graph/public/types/persistence.ts b/x-pack/legacy/plugins/graph/public/types/persistence.ts
index 7fc5e15d9ea72..adb07605b61c4 100644
--- a/x-pack/legacy/plugins/graph/public/types/persistence.ts
+++ b/x-pack/legacy/plugins/graph/public/types/persistence.ts
@@ -37,7 +37,7 @@ export interface SerializedUrlTemplate extends Omit {
+export interface SerializedField extends Omit {
iconClass: string;
}
diff --git a/x-pack/legacy/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.js b/x-pack/legacy/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.js
index 9463eccb93a02..2c0ea7fe699b8 100644
--- a/x-pack/legacy/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.js
+++ b/x-pack/legacy/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.js
@@ -20,8 +20,8 @@ import sinon from 'sinon';
import { findTestSubject } from '@elastic/eui/lib/test';
import {
positiveNumbersAboveZeroErrorMessage,
- numberRequiredMessage,
positiveNumberRequiredMessage,
+ numberRequiredMessage,
maximumAgeRequiredMessage,
maximumSizeRequiredMessage,
policyNameRequiredMessage,
@@ -243,17 +243,18 @@ describe('edit policy', () => {
noRollover(rendered);
setPolicyName(rendered, 'mypolicy');
activatePhase(rendered, 'warm');
+ setPhaseAfter(rendered, 'warm', '');
save(rendered);
expectedErrorMessages(rendered, [numberRequiredMessage]);
});
- test('should show positive number required above zero error when trying to save warm phase with 0 for after', () => {
+ test('should allow 0 for phase timing', () => {
const rendered = mountWithIntl(component);
noRollover(rendered);
setPolicyName(rendered, 'mypolicy');
activatePhase(rendered, 'warm');
setPhaseAfter(rendered, 'warm', 0);
save(rendered);
- expectedErrorMessages(rendered, [positiveNumbersAboveZeroErrorMessage]);
+ expectedErrorMessages(rendered, []);
});
test('should show positive number required error when trying to save warm phase with -1 for after', () => {
const rendered = mountWithIntl(component);
@@ -383,14 +384,14 @@ describe('edit policy', () => {
});
});
describe('cold phase', () => {
- test('should show positive number required error when trying to save cold phase with 0 for after', () => {
+ test('should allow 0 for phase timing', () => {
const rendered = mountWithIntl(component);
noRollover(rendered);
setPolicyName(rendered, 'mypolicy');
activatePhase(rendered, 'cold');
setPhaseAfter(rendered, 'cold', 0);
save(rendered);
- expectedErrorMessages(rendered, [positiveNumbersAboveZeroErrorMessage]);
+ expectedErrorMessages(rendered, []);
});
test('should show positive number required error when trying to save cold phase with -1 for after', () => {
const rendered = mountWithIntl(component);
@@ -464,14 +465,14 @@ describe('edit policy', () => {
});
});
describe('delete phase', () => {
- test('should show positive number required error when trying to save delete phase with 0 for after', () => {
+ test('should allow 0 for phase timing', () => {
const rendered = mountWithIntl(component);
noRollover(rendered);
setPolicyName(rendered, 'mypolicy');
activatePhase(rendered, 'delete');
setPhaseAfter(rendered, 'delete', 0);
save(rendered);
- expectedErrorMessages(rendered, [positiveNumbersAboveZeroErrorMessage]);
+ expectedErrorMessages(rendered, []);
});
test('should show positive number required error when trying to save delete phase with -1 for after', () => {
const rendered = mountWithIntl(component);
diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/sections/edit_policy/components/min_age_input.js b/x-pack/legacy/plugins/index_lifecycle_management/public/sections/edit_policy/components/min_age_input.js
index 0ed28bbaa905f..b4c9f4e958cd2 100644
--- a/x-pack/legacy/plugins/index_lifecycle_management/public/sections/edit_policy/components/min_age_input.js
+++ b/x-pack/legacy/plugins/index_lifecycle_management/public/sections/edit_policy/components/min_age_input.js
@@ -131,7 +131,7 @@ export const MinAgeInput = props => {
onChange={async e => {
setPhaseData(PHASE_ROLLOVER_MINIMUM_AGE, e.target.value);
}}
- min={1}
+ min={0}
/>
diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/store/defaults/cold_phase.js b/x-pack/legacy/plugins/index_lifecycle_management/public/store/defaults/cold_phase.js
index b0af0e6547803..a8f7fd3f4bdfa 100644
--- a/x-pack/legacy/plugins/index_lifecycle_management/public/store/defaults/cold_phase.js
+++ b/x-pack/legacy/plugins/index_lifecycle_management/public/store/defaults/cold_phase.js
@@ -17,7 +17,7 @@ import {
export const defaultColdPhase = {
[PHASE_ENABLED]: false,
[PHASE_ROLLOVER_ALIAS]: '',
- [PHASE_ROLLOVER_MINIMUM_AGE]: '',
+ [PHASE_ROLLOVER_MINIMUM_AGE]: 0,
[PHASE_ROLLOVER_MINIMUM_AGE_UNITS]: 'd',
[PHASE_NODE_ATTRS]: '',
[PHASE_REPLICA_COUNT]: '',
diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/store/defaults/delete_phase.js b/x-pack/legacy/plugins/index_lifecycle_management/public/store/defaults/delete_phase.js
index 5a44539ff90f8..b5296cd83fabd 100644
--- a/x-pack/legacy/plugins/index_lifecycle_management/public/store/defaults/delete_phase.js
+++ b/x-pack/legacy/plugins/index_lifecycle_management/public/store/defaults/delete_phase.js
@@ -15,7 +15,7 @@ export const defaultDeletePhase = {
[PHASE_ENABLED]: false,
[PHASE_ROLLOVER_ENABLED]: false,
[PHASE_ROLLOVER_ALIAS]: '',
- [PHASE_ROLLOVER_MINIMUM_AGE]: '',
+ [PHASE_ROLLOVER_MINIMUM_AGE]: 0,
[PHASE_ROLLOVER_MINIMUM_AGE_UNITS]: 'd',
};
export const defaultEmptyDeletePhase = defaultDeletePhase;
diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/store/defaults/warm_phase.js b/x-pack/legacy/plugins/index_lifecycle_management/public/store/defaults/warm_phase.js
index d3dc55178b253..f02ac2096675f 100644
--- a/x-pack/legacy/plugins/index_lifecycle_management/public/store/defaults/warm_phase.js
+++ b/x-pack/legacy/plugins/index_lifecycle_management/public/store/defaults/warm_phase.js
@@ -23,7 +23,7 @@ export const defaultWarmPhase = {
[PHASE_ROLLOVER_ALIAS]: '',
[PHASE_FORCE_MERGE_SEGMENTS]: '',
[PHASE_FORCE_MERGE_ENABLED]: false,
- [PHASE_ROLLOVER_MINIMUM_AGE]: '',
+ [PHASE_ROLLOVER_MINIMUM_AGE]: 0,
[PHASE_ROLLOVER_MINIMUM_AGE_UNITS]: 'd',
[PHASE_NODE_ATTRS]: '',
[PHASE_SHRINK_ENABLED]: false,
diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/store/selectors/lifecycle.js b/x-pack/legacy/plugins/index_lifecycle_management/public/store/selectors/lifecycle.js
index 026845c78ee66..750a7feb19c3d 100644
--- a/x-pack/legacy/plugins/index_lifecycle_management/public/store/selectors/lifecycle.js
+++ b/x-pack/legacy/plugins/index_lifecycle_management/public/store/selectors/lifecycle.js
@@ -120,12 +120,6 @@ export const validatePhase = (type, phase, errors) => {
phaseErrors[numberedAttribute] = [numberRequiredMessage];
} else if (phase[numberedAttribute] < 0) {
phaseErrors[numberedAttribute] = [positiveNumberRequiredMessage];
- } else if (
- (numberedAttribute === PHASE_ROLLOVER_MINIMUM_AGE ||
- numberedAttribute === PHASE_PRIMARY_SHARD_COUNT) &&
- phase[numberedAttribute] < 1
- ) {
- phaseErrors[numberedAttribute] = [positiveNumbersAboveZeroErrorMessage];
}
}
}
diff --git a/x-pack/legacy/plugins/maps/public/actions/map_actions.js b/x-pack/legacy/plugins/maps/public/actions/map_actions.js
index 053b389a59011..f05b7eba9e7e0 100644
--- a/x-pack/legacy/plugins/maps/public/actions/map_actions.js
+++ b/x-pack/legacy/plugins/maps/public/actions/map_actions.js
@@ -39,7 +39,7 @@ export const SET_LAYER_ERROR_STATUS = 'SET_LAYER_ERROR_STATUS';
export const ADD_WAITING_FOR_MAP_READY_LAYER = 'ADD_WAITING_FOR_MAP_READY_LAYER';
export const CLEAR_WAITING_FOR_MAP_READY_LAYER_LIST = 'CLEAR_WAITING_FOR_MAP_READY_LAYER_LIST';
export const REMOVE_LAYER = 'REMOVE_LAYER';
-export const TOGGLE_LAYER_VISIBLE = 'TOGGLE_LAYER_VISIBLE';
+export const SET_LAYER_VISIBILITY = 'SET_LAYER_VISIBILITY';
export const MAP_EXTENT_CHANGED = 'MAP_EXTENT_CHANGED';
export const MAP_READY = 'MAP_READY';
export const MAP_DESTROYED = 'MAP_DESTROYED';
@@ -72,6 +72,7 @@ export const DISABLE_TOOLTIP_CONTROL = 'DISABLE_TOOLTIP_CONTROL';
export const HIDE_TOOLBAR_OVERLAY = 'HIDE_TOOLBAR_OVERLAY';
export const HIDE_LAYER_CONTROL = 'HIDE_LAYER_CONTROL';
export const HIDE_VIEW_CONTROL = 'HIDE_VIEW_CONTROL';
+export const SET_WAITING_FOR_READY_HIDDEN_LAYERS = 'SET_WAITING_FOR_READY_HIDDEN_LAYERS';
function getLayerLoadingCallbacks(dispatch, layerId) {
return {
@@ -252,23 +253,25 @@ export function cleanTooltipStateForLayer(layerId, layerFeatures = []) {
};
}
-export function toggleLayerVisible(layerId) {
+export function setLayerVisibility(layerId, makeVisible) {
return async (dispatch, getState) => {
//if the current-state is invisible, we also want to sync data
//e.g. if a layer was invisible at start-up, it won't have any data loaded
const layer = getLayerById(layerId, getState());
- if (!layer) {
+
+ // If the layer visibility is already what we want it to be, do nothing
+ if (!layer || layer.isVisible() === makeVisible) {
return;
}
- const makeVisible = !layer.isVisible();
if (!makeVisible) {
dispatch(cleanTooltipStateForLayer(layerId));
}
await dispatch({
- type: TOGGLE_LAYER_VISIBLE,
+ type: SET_LAYER_VISIBILITY,
layerId,
+ visibility: makeVisible,
});
if (makeVisible) {
dispatch(syncDataForLayer(layerId));
@@ -276,6 +279,18 @@ export function toggleLayerVisible(layerId) {
};
}
+export function toggleLayerVisible(layerId) {
+ return async (dispatch, getState) => {
+ const layer = getLayerById(layerId, getState());
+ if (!layer) {
+ return;
+ }
+ const makeVisible = !layer.isVisible();
+
+ dispatch(setLayerVisibility(layerId, makeVisible));
+ };
+}
+
export function setSelectedLayer(layerId) {
return async (dispatch, getState) => {
const oldSelectedLayer = getSelectedLayerId(getState());
@@ -840,3 +855,17 @@ export function hideLayerControl() {
export function hideViewControl() {
return { type: HIDE_VIEW_CONTROL, hideViewControl: true };
}
+
+export function setHiddenLayers(hiddenLayerIds) {
+ return (dispatch, getState) => {
+ const isMapReady = getMapReady(getState());
+
+ if (!isMapReady) {
+ dispatch({ type: SET_WAITING_FOR_READY_HIDDEN_LAYERS, hiddenLayerIds });
+ } else {
+ getLayerListRaw(getState()).forEach(layer =>
+ dispatch(setLayerVisibility(layer.id, !hiddenLayerIds.includes(layer.id)))
+ );
+ }
+ };
+}
diff --git a/x-pack/legacy/plugins/maps/public/embeddable/README.md b/x-pack/legacy/plugins/maps/public/embeddable/README.md
index eb6571a96016c..1de327702fb87 100644
--- a/x-pack/legacy/plugins/maps/public/embeddable/README.md
+++ b/x-pack/legacy/plugins/maps/public/embeddable/README.md
@@ -9,6 +9,7 @@
- **hideToolbarOverlay:** (Boolean) Will disable toolbar, which can be used to navigate to coordinate by entering lat/long and zoom values.
- **hideLayerControl:** (Boolean) Will hide useful layer control, which can be used to hide/show a layer to get a refined view of the map.
- **hideViewControl:** (Boolean) Will hide view control at bottom right of the map, which shows lat/lon values based on mouse hover in the map, this is useful to get coordinate value from a particular point in map.
+- **hiddenLayers:** (Array of Strings) Array of layer ids that should be hidden. Any other layers will be set to visible regardless of their value in the layerList used to initialize the embeddable
### Creating a Map embeddable from saved object
```
diff --git a/x-pack/legacy/plugins/maps/public/embeddable/map_embeddable.js b/x-pack/legacy/plugins/maps/public/embeddable/map_embeddable.js
index 2ee766f91fbca..c723e996ee679 100644
--- a/x-pack/legacy/plugins/maps/public/embeddable/map_embeddable.js
+++ b/x-pack/legacy/plugins/maps/public/embeddable/map_embeddable.js
@@ -32,11 +32,12 @@ import {
hideToolbarOverlay,
hideLayerControl,
hideViewControl,
+ setHiddenLayers,
} from '../actions/map_actions';
import { setReadOnly, setIsLayerTOCOpen, setOpenTOCDetails } from '../actions/ui_actions';
import { getIsLayerTOCOpen, getOpenTOCDetails } from '../selectors/ui_selectors';
import { getInspectorAdapters, setEventHandlers } from '../reducers/non_serializable_instances';
-import { getMapCenter, getMapZoom } from '../selectors/map_selectors';
+import { getMapCenter, getMapZoom, getHiddenLayerIds } from '../selectors/map_selectors';
import { MAP_SAVED_OBJECT_TYPE } from '../../common/constants';
export class MapEmbeddable extends Embeddable {
@@ -153,6 +154,9 @@ export class MapEmbeddable extends Embeddable {
}
this._store.dispatch(replaceLayerList(this._layerList));
+ if (this.input.hiddenLayers) {
+ this._store.dispatch(setHiddenLayers(this.input.hiddenLayers));
+ }
this._dispatchSetQuery(this.input);
this._dispatchSetRefreshConfig(this.input);
@@ -244,5 +248,13 @@ export class MapEmbeddable extends Embeddable {
openTOCDetails,
});
}
+
+ const hiddenLayerIds = getHiddenLayerIds(this._store.getState());
+
+ if (!_.isEqual(this.input.hiddenLayers, hiddenLayerIds)) {
+ this.updateInput({
+ hiddenLayers: hiddenLayerIds,
+ });
+ }
}
}
diff --git a/x-pack/legacy/plugins/maps/public/reducers/map.js b/x-pack/legacy/plugins/maps/public/reducers/map.js
index 7dd60f013cefd..ac409c685c71a 100644
--- a/x-pack/legacy/plugins/maps/public/reducers/map.js
+++ b/x-pack/legacy/plugins/maps/public/reducers/map.js
@@ -16,7 +16,7 @@ import {
ADD_WAITING_FOR_MAP_READY_LAYER,
CLEAR_WAITING_FOR_MAP_READY_LAYER_LIST,
REMOVE_LAYER,
- TOGGLE_LAYER_VISIBLE,
+ SET_LAYER_VISIBILITY,
MAP_EXTENT_CHANGED,
MAP_READY,
MAP_DESTROYED,
@@ -46,6 +46,7 @@ import {
HIDE_TOOLBAR_OVERLAY,
HIDE_LAYER_CONTROL,
HIDE_VIEW_CONTROL,
+ SET_WAITING_FOR_READY_HIDDEN_LAYERS,
} from '../actions/map_actions';
import { copyPersistentState, TRACKED_LAYER_DESCRIPTOR } from './util';
@@ -307,8 +308,8 @@ export function map(state = INITIAL_STATE, action) {
...state,
waitingForMapReadyLayerList: [],
};
- case TOGGLE_LAYER_VISIBLE:
- return updateLayerInList(state, action.layerId, 'visible');
+ case SET_LAYER_VISIBILITY:
+ return updateLayerInList(state, action.layerId, 'visible', action.visibility);
case UPDATE_LAYER_STYLE:
const styleLayerId = action.layerId;
return updateLayerInList(state, styleLayerId, 'style', { ...action.style });
@@ -376,6 +377,14 @@ export function map(state = INITIAL_STATE, action) {
hideViewControl: action.hideViewControl,
},
};
+ case SET_WAITING_FOR_READY_HIDDEN_LAYERS:
+ return {
+ ...state,
+ waitingForMapReadyLayerList: state.waitingForMapReadyLayerList.map(layer => ({
+ ...layer,
+ visible: !action.hiddenLayerIds.includes(layer.id),
+ })),
+ };
default:
return state;
}
diff --git a/x-pack/legacy/plugins/maps/public/selectors/map_selectors.js b/x-pack/legacy/plugins/maps/public/selectors/map_selectors.js
index 3d8e6f97ef077..4b3d1355e4264 100644
--- a/x-pack/legacy/plugins/maps/public/selectors/map_selectors.js
+++ b/x-pack/legacy/plugins/maps/public/selectors/map_selectors.js
@@ -150,6 +150,10 @@ export const getLayerList = createSelector(
}
);
+export const getHiddenLayerIds = createSelector(getLayerListRaw, layers =>
+ layers.filter(layer => !layer.visible).map(layer => layer.id)
+);
+
export const getSelectedLayer = createSelector(
getSelectedLayerId,
getLayerList,
diff --git a/x-pack/legacy/plugins/ml/public/application/components/chart_tooltip/chart_tooltip.tsx b/x-pack/legacy/plugins/ml/public/application/components/chart_tooltip/chart_tooltip.tsx
index aa28831e8d807..a28dc41fa1790 100644
--- a/x-pack/legacy/plugins/ml/public/application/components/chart_tooltip/chart_tooltip.tsx
+++ b/x-pack/legacy/plugins/ml/public/application/components/chart_tooltip/chart_tooltip.tsx
@@ -37,8 +37,7 @@ function useRefWithCallback() {
if (left + tooltipWidth > contentWidth) {
// the tooltip is hanging off the side of the page,
// so move it to the other side of the target
- const markerWidthAdjustment = 25;
- left = left - (tooltipWidth + offset.x + markerWidthAdjustment);
+ left = left - (tooltipWidth + offset.x);
}
const top = targetPosition.top + offset.y - parentBounding.top;
diff --git a/x-pack/legacy/plugins/ml/public/application/components/validate_job/__snapshots__/validate_job_view.test.js.snap b/x-pack/legacy/plugins/ml/public/application/components/validate_job/__snapshots__/validate_job_view.test.js.snap
index fb6b9c7c9db66..f7752a1ae25a1 100644
--- a/x-pack/legacy/plugins/ml/public/application/components/validate_job/__snapshots__/validate_job_view.test.js.snap
+++ b/x-pack/legacy/plugins/ml/public/application/components/validate_job/__snapshots__/validate_job_view.test.js.snap
@@ -86,7 +86,7 @@ exports[`ValidateJob renders the button 1`] = `
iconSide="right"
iconType="questionInCircle"
isDisabled={false}
- isLoading={false}
+ isLoading={true}
onClick={[Function]}
size="s"
>
diff --git a/x-pack/legacy/plugins/ml/public/application/components/validate_job/validate_job_view.js b/x-pack/legacy/plugins/ml/public/application/components/validate_job/validate_job_view.js
index 9b92265c4034b..a5ed7c3753b2f 100644
--- a/x-pack/legacy/plugins/ml/public/application/components/validate_job/validate_job_view.js
+++ b/x-pack/legacy/plugins/ml/public/application/components/validate_job/validate_job_view.js
@@ -21,6 +21,9 @@ import {
EuiOverlayMask,
EuiSpacer,
EuiText,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiLoadingSpinner,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
@@ -38,7 +41,7 @@ const defaultIconType = 'questionInCircle';
const getDefaultState = () => ({
ui: {
iconType: defaultIconType,
- isLoading: false,
+ isLoading: true,
isModalVisible: false,
},
data: {
@@ -150,6 +153,14 @@ Callout.propTypes = {
}),
};
+const LoadingSpinner = () => (
+
+
+
+
+
+);
+
const Modal = ({ close, title, children }) => (
@@ -249,10 +260,11 @@ export class ValidateJob extends Component {
const isDisabled = this.props.isDisabled !== true ? false : true;
const embedded = this.props.embedded === true;
const idFilterList = this.props.idFilterList || [];
+ const isLoading = this.state.ui.isLoading;
return (
- {embedded === false && (
+ {embedded === false ? (
}
>
-
+ {isLoading ? (
+
+ ) : (
+
+ )}
)}
- )}
- {embedded === true && (
-
+ ) : (
+
+ {isLoading ? (
+
+ ) : (
+
+ )}
+
)}
);
diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_creator/util/general.ts b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_creator/util/general.ts
index 22b727452dd8d..6443539a9877d 100644
--- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_creator/util/general.ts
+++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_creator/util/general.ts
@@ -297,10 +297,10 @@ export function getJobCreatorTitle(jobCreator: JobCreatorType) {
return i18n.translate('xpack.ml.newJob.wizard.jobCreatorTitle.advanced', {
defaultMessage: 'Advanced',
});
- // case JOB_TYPE.CATEGORIZATION:
- // return i18n.translate('xpack.ml.newJob.wizard.jobCreatorTitle.categorization', {
- // defaultMessage: 'Categorization',
- // });
+ case JOB_TYPE.CATEGORIZATION:
+ return i18n.translate('xpack.ml.newJob.wizard.jobCreatorTitle.categorization', {
+ defaultMessage: 'Categorization',
+ });
default:
return '';
}
diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_field/categorization_field.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_field/categorization_field.tsx
index 015300debb156..7f7659d8bb6fd 100644
--- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_field/categorization_field.tsx
+++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_field/categorization_field.tsx
@@ -10,19 +10,20 @@ import { CategorizationFieldSelect } from './categorization_field_select';
import { JobCreatorContext } from '../../../job_creator_context';
import { newJobCapsService } from '../../../../../../../services/new_job_capabilities_service';
import {
- MultiMetricJobCreator,
- PopulationJobCreator,
AdvancedJobCreator,
+ CategorizationJobCreator,
+ isCategorizationJobCreator,
} from '../../../../../common/job_creator';
import { Description } from './description';
export const CategorizationField: FC = () => {
const { jobCreator: jc, jobCreatorUpdate, jobCreatorUpdated } = useContext(JobCreatorContext);
- const jobCreator = jc as MultiMetricJobCreator | PopulationJobCreator | AdvancedJobCreator;
+ const jobCreator = jc as AdvancedJobCreator | CategorizationJobCreator;
const { catFields } = newJobCapsService;
const [categorizationFieldName, setCategorizationFieldName] = useState(
jobCreator.categorizationFieldName
);
+ const isCategorizationJob = isCategorizationJobCreator(jobCreator);
useEffect(() => {
if (jobCreator.categorizationFieldName !== categorizationFieldName) {
@@ -36,7 +37,7 @@ export const CategorizationField: FC = () => {
}, [jobCreatorUpdated]);
return (
-
+
{
+interface Props {
+ isOptional: boolean;
+}
+export const Description: FC = memo(({ children, isOptional }) => {
const title = i18n.translate('xpack.ml.newJob.wizard.pickFieldsStep.categorizationField.title', {
defaultMessage: 'Categorization field',
});
@@ -18,10 +21,19 @@ export const Description: FC = memo(({ children }) => {
idAria="description"
title={{title} }
description={
-
+ <>
+ {isOptional ? (
+
+ ) : (
+
+ )}
+ >
}
>
diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/new_job/page.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/new_job/page.tsx
index 3a37934e6203a..ece43e00f2eb1 100644
--- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/new_job/page.tsx
+++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/new_job/page.tsx
@@ -133,6 +133,12 @@ export const Page: FC = ({ existingJobsAndGroups, jobType }) => {
// auto set the time range if creating a new advanced job
autoSetTimeRange = isAdvancedJobCreator(jobCreator);
initCategorizationSettings();
+ if (isCategorizationJobCreator(jobCreator)) {
+ const { catFields } = newJobCapsService;
+ if (catFields.length === 1) {
+ jobCreator.categorizationFieldName = catFields[0].name;
+ }
+ }
}
if (autoSetTimeRange && isAdvancedJobCreator(jobCreator)) {
diff --git a/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js b/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js
index 9db6f8f0a1c35..4d10d73bcc048 100644
--- a/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js
+++ b/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js
@@ -1523,12 +1523,12 @@ const TimeseriesChartIntl = injectI18n(
} else {
tooltipData.push({
name: intl.formatMessage({
- id: 'xpack.ml.timeSeriesExplorer.timeSeriesChart.modelPlotEnabled.valueLabel',
- defaultMessage: 'value',
+ id: 'xpack.ml.timeSeriesExplorer.timeSeriesChart.modelPlotEnabled.actualLabel',
+ defaultMessage: 'actual',
}),
- value: formatValue(marker.value, marker.function, fieldFormat),
+ value: formatValue(marker.actual, marker.function, fieldFormat),
seriesKey,
- yAccessor: 'value',
+ yAccessor: 'actual',
});
tooltipData.push({
name: intl.formatMessage({
diff --git a/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js b/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js
index 202448340f526..0ab10c4fe69cd 100644
--- a/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js
+++ b/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js
@@ -1394,156 +1394,158 @@ export class TimeSeriesExplorer extends React.Component {
jobs.length > 0 &&
(fullRefresh === false || loading === false) &&
hasResults === true && (
-
- {/* Make sure ChartTooltip is inside this plain wrapping element so positioning can be infered correctly. */}
+
+ {/* Make sure ChartTooltip is inside this plain wrapping element without padding so positioning can be infered correctly. */}
-
- {i18n.translate('xpack.ml.timeSeriesExplorer.singleTimeSeriesAnalysisTitle', {
- defaultMessage: 'Single time series analysis of {functionLabel}',
- values: { functionLabel: chartDetails.functionLabel },
- })}
-
-
- {chartDetails.entityData.count === 1 && (
-
- {chartDetails.entityData.entities.length > 0 && '('}
- {chartDetails.entityData.entities
- .map(entity => {
- return `${entity.fieldName}: ${entity.fieldValue}`;
- })
- .join(', ')}
- {chartDetails.entityData.entities.length > 0 && ')'}
-
- )}
- {chartDetails.entityData.count !== 1 && (
-
- {chartDetails.entityData.entities.map((countData, i) => {
- return (
-
- {i18n.translate(
- 'xpack.ml.timeSeriesExplorer.countDataInChartDetailsDescription',
- {
- defaultMessage:
- '{openBrace}{cardinalityValue} distinct {fieldName} {cardinality, plural, one {} other { values}}{closeBrace}',
- values: {
- openBrace: i === 0 ? '(' : '',
- closeBrace:
- i === chartDetails.entityData.entities.length - 1 ? ')' : '',
- cardinalityValue:
- countData.cardinality === 0
- ? allValuesLabel
- : countData.cardinality,
- cardinality: countData.cardinality,
- fieldName: countData.fieldName,
- },
- }
- )}
- {i !== chartDetails.entityData.entities.length - 1 ? ', ' : ''}
-
- );
+
+
+ {i18n.translate('xpack.ml.timeSeriesExplorer.singleTimeSeriesAnalysisTitle', {
+ defaultMessage: 'Single time series analysis of {functionLabel}',
+ values: { functionLabel: chartDetails.functionLabel },
})}
- )}
-
- {showModelBoundsCheckbox && (
-
-
+ {chartDetails.entityData.entities.length > 0 && '('}
+ {chartDetails.entityData.entities
+ .map(entity => {
+ return `${entity.fieldName}: ${entity.fieldValue}`;
+ })
+ .join(', ')}
+ {chartDetails.entityData.entities.length > 0 && ')'}
+
+ )}
+ {chartDetails.entityData.count !== 1 && (
+
+ {chartDetails.entityData.entities.map((countData, i) => {
+ return (
+
+ {i18n.translate(
+ 'xpack.ml.timeSeriesExplorer.countDataInChartDetailsDescription',
+ {
+ defaultMessage:
+ '{openBrace}{cardinalityValue} distinct {fieldName} {cardinality, plural, one {} other { values}}{closeBrace}',
+ values: {
+ openBrace: i === 0 ? '(' : '',
+ closeBrace:
+ i === chartDetails.entityData.entities.length - 1 ? ')' : '',
+ cardinalityValue:
+ countData.cardinality === 0
+ ? allValuesLabel
+ : countData.cardinality,
+ cardinality: countData.cardinality,
+ fieldName: countData.fieldName,
+ },
+ }
+ )}
+ {i !== chartDetails.entityData.entities.length - 1 ? ', ' : ''}
+
+ );
+ })}
+
+ )}
+
+ {showModelBoundsCheckbox && (
+
+
+
+ )}
+
+ {showAnnotationsCheckbox && (
+
+
+
+ )}
+
+ {showForecastCheckbox && (
+
+
+
+ )}
+
+
+
+
+ {showAnnotations && focusAnnotationData.length > 0 && (
+
+
+ {i18n.translate('xpack.ml.timeSeriesExplorer.annotationsTitle', {
+ defaultMessage: 'Annotations',
})}
- checked={showModelBounds}
- onChange={this.toggleShowModelBoundsHandler}
+
+
-
+
+
)}
-
- {showAnnotationsCheckbox && (
-
-
+
+ {i18n.translate('xpack.ml.timeSeriesExplorer.anomaliesTitle', {
+ defaultMessage: 'Anomalies',
+ })}
+
+
+
+
+ >
+
+
- )}
-
- {showForecastCheckbox && (
-
-
+
+ >
+
+
- )}
-
-
-
-
- {showAnnotations && focusAnnotationData.length > 0 && (
-
-
- {i18n.translate('xpack.ml.timeSeriesExplorer.annotationsTitle', {
- defaultMessage: 'Annotations',
- })}
-
-
-
-
- )}
-
-
- {i18n.translate('xpack.ml.timeSeriesExplorer.anomaliesTitle', {
- defaultMessage: 'Anomalies',
- })}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
)}
{arePartitioningFieldsProvided && jobs.length > 0 && (
diff --git a/x-pack/legacy/plugins/ml/public/application/util/custom_url_utils.test.ts b/x-pack/legacy/plugins/ml/public/application/util/custom_url_utils.test.ts
index a7afee237dba9..8cdaa192fcbc9 100644
--- a/x-pack/legacy/plugins/ml/public/application/util/custom_url_utils.test.ts
+++ b/x-pack/legacy/plugins/ml/public/application/util/custom_url_utils.test.ts
@@ -345,6 +345,98 @@ describe('ML - custom URL utils', () => {
);
});
+ test('returns expected URL for APM', () => {
+ const urlConfig = {
+ url_name: 'APM',
+ time_range: '2h',
+ url_value:
+ 'apm#/traces?rangeFrom=$earliest$&rangeTo=$latest$&kuery=trace.id:"$trace.id$" and transaction.name:"$transaction.name$"&_g=()',
+ };
+
+ const testRecords = {
+ job_id: 'abnormal_trace_durations_nodejs',
+ result_type: 'record',
+ probability: 0.025597710862701226,
+ multi_bucket_impact: 5,
+ record_score: 13.124152090331723,
+ initial_record_score: 13.124152090331723,
+ bucket_span: 900,
+ detector_index: 0,
+ is_interim: false,
+ timestamp: 1573339500000,
+ by_field_name: 'transaction.name',
+ by_field_value: 'GET /test-data',
+ function: 'high_mean',
+ function_description: 'mean',
+ typical: [802.0600710562369],
+ actual: [761.1531339031332],
+ field_name: 'transaction.duration.us',
+ influencers: [
+ {
+ influencer_field_name: 'transaction.name',
+ influencer_field_values: ['GET /test-data'],
+ },
+ {
+ influencer_field_name: 'trace.id',
+ influencer_field_values: [
+ '000a09d58a428f38550e7e87637733c1',
+ '0039c771d8bbadf6137767d3aeb89f96',
+ '01279ed5bb9f4249e3822d16dec7f2f2',
+ ],
+ },
+ {
+ influencer_field_name: 'service.name',
+ influencer_field_values: ['example-service'],
+ },
+ ],
+ 'trace.id': [
+ '000a09d58a428f38550e7e87637733c1',
+ '0039c771d8bbadf6137767d3aeb89f96',
+ '01279ed5bb9f4249e3822d16dec7f2f2',
+ ],
+ 'service.name': ['example-service'],
+ 'transaction.name': ['GET /test-data'],
+ earliest: '2019-11-09T20:45:00.000Z',
+ latest: '2019-11-10T01:00:00.000Z',
+ };
+
+ expect(getUrlForRecord(urlConfig, testRecords)).toBe(
+ 'apm#/traces?rangeFrom=2019-11-09T20:45:00.000Z&rangeTo=2019-11-10T01:00:00.000Z&kuery=(trace.id:"000a09d58a428f38550e7e87637733c1" OR trace.id:"0039c771d8bbadf6137767d3aeb89f96" OR trace.id:"01279ed5bb9f4249e3822d16dec7f2f2") AND transaction.name:"GET%20%2Ftest-data"&_g=()'
+ );
+ });
+
+ test('removes an empty path component with a trailing slash', () => {
+ const urlConfig = {
+ url_name: 'APM',
+ time_range: '2h',
+ url_value:
+ 'apm#/services/$service.name$/transactions?rangeFrom=$earliest$&rangeTo=$latest$&refreshPaused=true&refreshInterval=0&kuery=&transactionType=request',
+ };
+
+ const testRecords = {
+ job_id: 'decreased_throughput_jsbase',
+ result_type: 'record',
+ probability: 8.91350850732573e-9,
+ multi_bucket_impact: 5,
+ record_score: 93.63625728951217,
+ initial_record_score: 93.63625728951217,
+ bucket_span: 900,
+ detector_index: 0,
+ is_interim: false,
+ timestamp: 1573266600000,
+ function: 'low_count',
+ function_description: 'count',
+ typical: [100615.66506877479],
+ actual: [25251],
+ earliest: '2019-11-09T00:30:00.000Z',
+ latest: '2019-11-09T04:45:00.000Z',
+ };
+
+ expect(getUrlForRecord(urlConfig, testRecords)).toBe(
+ 'apm#/services/transactions?rangeFrom=2019-11-09T00:30:00.000Z&rangeTo=2019-11-09T04:45:00.000Z&refreshPaused=true&refreshInterval=0&kuery=&transactionType=request'
+ );
+ });
+
test('returns expected URL for other type URL', () => {
expect(getUrlForRecord(TEST_OTHER_URL, TEST_RECORD)).toBe(
'http://airlinecodes.info/airline-code-AAL'
diff --git a/x-pack/legacy/plugins/ml/public/application/util/custom_url_utils.ts b/x-pack/legacy/plugins/ml/public/application/util/custom_url_utils.ts
index e2f2dc0ad0fe8..7774f6dec0c95 100644
--- a/x-pack/legacy/plugins/ml/public/application/util/custom_url_utils.ts
+++ b/x-pack/legacy/plugins/ml/public/application/util/custom_url_utils.ts
@@ -97,7 +97,11 @@ export function openCustomUrlWindow(fullUrl: string, urlConfig: UrlConfig) {
// a Kibana Discover or Dashboard page running on the same server as this ML plugin.
function isKibanaUrl(urlConfig: UrlConfig) {
const urlValue = urlConfig.url_value;
- return urlValue.startsWith('kibana#/discover') || urlValue.startsWith('kibana#/dashboard');
+ return (
+ urlValue.startsWith('kibana#/discover') ||
+ urlValue.startsWith('kibana#/dashboard') ||
+ urlValue.startsWith('apm#/')
+ );
}
/**
@@ -136,13 +140,14 @@ function buildKibanaUrl(urlConfig: UrlConfig, record: CustomUrlAnomalyRecordDoc)
commonEscapeCallback
);
- return str.replace(/\$([^?&$\'"]+)\$/g, (match, name: string) => {
+ // Looking for a $token$ with an optional trailing slash
+ return str.replace(/\$([^?&$\'"]+)\$(\/)?/g, (match, name: string, slash: string = '') => {
// Use lodash get to allow nested JSON fields to be retrieved.
let tokenValue: string | string[] | undefined = get(record, name);
tokenValue = Array.isArray(tokenValue) ? tokenValue[0] : tokenValue;
- // If property not found string is not replaced.
- return tokenValue === undefined ? match : getResultTokenValue(tokenValue);
+ // If property not found token is replaced with an empty string.
+ return tokenValue === undefined ? '' : getResultTokenValue(tokenValue) + slash;
});
};
@@ -155,7 +160,7 @@ function buildKibanaUrl(urlConfig: UrlConfig, record: CustomUrlAnomalyRecordDoc)
commonEscapeCallback
);
return str.replace(
- /(.+query:')([^']*)('.+)/,
+ /(.+query:'|.+&kuery=)([^']*)(['&].+)/,
(fullMatch, prefix: string, queryString: string, postfix: string) => {
const [resultPrefix, resultPostfix] = [prefix, postfix].map(replaceSingleTokenValues);
@@ -170,28 +175,39 @@ function buildKibanaUrl(urlConfig: UrlConfig, record: CustomUrlAnomalyRecordDoc)
const queryParts: string[] = [];
const joinOperator = ' AND ';
- for (let i = 0; i < queryFields.length; i++) {
+ fieldsLoop: for (let i = 0; i < queryFields.length; i++) {
const field = queryFields[i];
// Use lodash get to allow nested JSON fields to be retrieved.
- const tokenValues: string[] | string | null = get(record, field) || null;
+ let tokenValues: string[] | string | null = get(record, field) || null;
if (tokenValues === null) {
continue;
}
+ tokenValues = Array.isArray(tokenValues) ? tokenValues : [tokenValues];
+
// Create a pair `influencerField:value`.
// In cases where there are multiple influencer field values for an anomaly
// combine values with OR operator e.g. `(influencerField:value or influencerField:another_value)`.
- let result = (Array.isArray(tokenValues) ? tokenValues : [tokenValues])
- .map(value => `${field}:"${getResultTokenValue(value)}"`)
- .join(' OR ');
- result = tokenValues.length > 1 ? `(${result})` : result;
-
- // Build up a URL string which is not longer than the allowed length and isn't corrupted by invalid query.
- availableCharactersLeft -= result.length - (i === 0 ? 0 : joinOperator.length);
-
- if (availableCharactersLeft <= 0) {
- break;
- } else {
- queryParts.push(result);
+ let result = '';
+ for (let j = 0; j < tokenValues.length; j++) {
+ const part = `${j > 0 ? ' OR ' : ''}${field}:"${getResultTokenValue(
+ tokenValues[j]
+ )}"`;
+
+ // Build up a URL string which is not longer than the allowed length and isn't corrupted by invalid query.
+ if (availableCharactersLeft < part.length) {
+ if (result.length > 0) {
+ queryParts.push(j > 0 ? `(${result})` : result);
+ }
+ break fieldsLoop;
+ }
+
+ result += part;
+
+ availableCharactersLeft -= result.length;
+ }
+
+ if (result.length > 0) {
+ queryParts.push(tokenValues.length > 1 ? `(${result})` : result);
}
}
diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/__tests__/data_recognizer.js b/x-pack/legacy/plugins/ml/server/models/data_recognizer/__tests__/data_recognizer.js
index cb268ffede7fa..9c5048daeee3f 100644
--- a/x-pack/legacy/plugins/ml/server/models/data_recognizer/__tests__/data_recognizer.js
+++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/__tests__/data_recognizer.js
@@ -12,6 +12,8 @@ describe('ML - data recognizer', () => {
const moduleIds = [
'apache_ecs',
+ 'apm_jsbase',
+ 'apm_nodejs',
'apm_transaction',
'auditbeat_process_docker_ecs',
'auditbeat_process_hosts_ecs',
diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/logo.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/logo.json
new file mode 100644
index 0000000000000..3905c809fbd7a
--- /dev/null
+++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/logo.json
@@ -0,0 +1,3 @@
+{
+ "icon": "apmApp"
+}
diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/manifest.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/manifest.json
new file mode 100644
index 0000000000000..e463b34be0fc2
--- /dev/null
+++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/manifest.json
@@ -0,0 +1,53 @@
+{
+ "id": "apm_jsbase",
+ "title": "APM: RUM Javascript",
+ "description": "Detect problematic spans and identify user agents that are potentially causing issues.",
+ "type": "APM data",
+ "logoFile": "logo.json",
+ "defaultIndexPattern": "apm-*",
+ "query": {
+ "bool": {
+ "filter": [{ "term": { "agent.name": "js-base" } }]
+ }
+ },
+ "jobs": [
+ {
+ "id": "abnormal_span_durations_jsbase",
+ "file": "abnormal_span_durations_jsbase.json"
+ },
+ {
+ "id": "anomalous_error_rate_for_user_agents_jsbase",
+ "file": "anomalous_error_rate_for_user_agents_jsbase.json"
+ },
+ {
+ "id": "decreased_throughput_jsbase",
+ "file": "decreased_throughput_jsbase.json"
+ },
+ {
+ "id": "high_count_by_user_agent_jsbase",
+ "file": "high_count_by_user_agent_jsbase.json"
+ }
+ ],
+ "datafeeds": [
+ {
+ "id": "datafeed-abnormal_span_durations_jsbase",
+ "file": "datafeed_abnormal_span_durations_jsbase.json",
+ "job_id": "abnormal_span_durations_jsbase"
+ },
+ {
+ "id": "datafeed-anomalous_error_rate_for_user_agents_jsbase",
+ "file": "datafeed_anomalous_error_rate_for_user_agents_jsbase.json",
+ "job_id": "anomalous_error_rate_for_user_agents_jsbase"
+ },
+ {
+ "id": "datafeed-decreased_throughput_jsbase",
+ "file": "datafeed_decreased_throughput_jsbase.json",
+ "job_id": "decreased_throughput_jsbase"
+ },
+ {
+ "id": "datafeed-high_count_by_user_agent_jsbase",
+ "file": "datafeed_high_count_by_user_agent_jsbase.json",
+ "job_id": "high_count_by_user_agent_jsbase"
+ }
+ ]
+}
diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/abnormal_span_durations_jsbase.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/abnormal_span_durations_jsbase.json
new file mode 100644
index 0000000000000..e0b51a4dcd05e
--- /dev/null
+++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/abnormal_span_durations_jsbase.json
@@ -0,0 +1,41 @@
+{
+ "job_type": "anomaly_detector",
+ "groups": [
+ "apm"
+ ],
+ "description": "APM JSBase: Looks for spans that are taking longer than usual to process.",
+ "analysis_config": {
+ "bucket_span": "15m",
+ "detectors": [
+ {
+ "detector_description": "increased span duration",
+ "function": "high_mean",
+ "field_name": "span.duration.us",
+ "partition_field_name": "span.type"
+ }
+ ],
+ "influencers": [
+ "span.type",
+ "trace.id",
+ "span.name",
+ "service.name"
+ ]
+ },
+ "allow_lazy_open": true,
+ "analysis_limits": {
+ "model_memory_limit": "128mb"
+ },
+ "data_description": {
+ "time_field": "@timestamp"
+ },
+ "custom_settings": {
+ "created_by": "ml-module-apm-jsbase",
+ "custom_urls": [
+ {
+ "url_name": "APM",
+ "time_range": "2h",
+ "url_value": "apm#/traces?rangeFrom=$earliest$&rangeTo=$latest$&kuery=trace.id:\"$trace.id$\"&_g=()"
+ }
+ ]
+ }
+}
diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/anomalous_error_rate_for_user_agents_jsbase.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/anomalous_error_rate_for_user_agents_jsbase.json
new file mode 100644
index 0000000000000..66fd9858c6885
--- /dev/null
+++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/anomalous_error_rate_for_user_agents_jsbase.json
@@ -0,0 +1,40 @@
+{
+ "job_type": "anomaly_detector",
+ "groups": [
+ "apm"
+ ],
+ "description": "APM JSBase: Detects user agents that are encountering errors at an above normal rate. This can help detect browser compatibility issues.",
+ "analysis_config": {
+ "bucket_span": "15m",
+ "detectors": [
+ {
+ "detector_description": "high error rate for user agent",
+ "function": "high_non_zero_count",
+ "partition_field_name": "user_agent.name"
+ }
+ ],
+ "influencers": [
+ "user_agent.name",
+ "error.exception.message.keyword",
+ "error.page.url",
+ "service.name"
+ ]
+ },
+ "allow_lazy_open": true,
+ "analysis_limits": {
+ "model_memory_limit": "32mb"
+ },
+ "data_description": {
+ "time_field": "@timestamp"
+ },
+ "custom_settings": {
+ "created_by": "ml-module-apm-jsbase",
+ "custom_urls": [
+ {
+ "url_name": "APM",
+ "time_range": "2h",
+ "url_value": "apm#/services/$service.name$/errors?rangeFrom=$earliest$&rangeTo=$latest$&refreshPaused=true&refreshInterval=0&kuery=user_agent.name:\"$user_agent.name$\"&_g=()"
+ }
+ ]
+ }
+}
diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/datafeed_abnormal_span_durations_jsbase.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/datafeed_abnormal_span_durations_jsbase.json
new file mode 100644
index 0000000000000..7ecbe2890b826
--- /dev/null
+++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/datafeed_abnormal_span_durations_jsbase.json
@@ -0,0 +1,15 @@
+{
+ "job_id": "JOB_ID",
+ "indices": [
+ "INDEX_PATTERN_NAME"
+ ],
+ "max_empty_searches": 10,
+ "query": {
+ "bool": {
+ "must": [
+ { "bool": { "filter": { "term": { "agent.name": "js-base" } } } },
+ { "bool": { "filter": { "term": { "processor.event": "span" } } } }
+ ]
+ }
+ }
+}
diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/datafeed_anomalous_error_rate_for_user_agents_jsbase.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/datafeed_anomalous_error_rate_for_user_agents_jsbase.json
new file mode 100644
index 0000000000000..fbfedcbf47561
--- /dev/null
+++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/datafeed_anomalous_error_rate_for_user_agents_jsbase.json
@@ -0,0 +1,15 @@
+{
+ "job_id": "JOB_ID",
+ "indices": [
+ "INDEX_PATTERN_NAME"
+ ],
+ "max_empty_searches": 10,
+ "query": {
+ "bool": {
+ "must": [
+ { "bool": { "filter": { "term": { "agent.name": "js-base" } } } },
+ { "exists": { "field": "user_agent.name" } }
+ ]
+ }
+ }
+}
diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/datafeed_decreased_throughput_jsbase.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/datafeed_decreased_throughput_jsbase.json
new file mode 100644
index 0000000000000..48cba1f157815
--- /dev/null
+++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/datafeed_decreased_throughput_jsbase.json
@@ -0,0 +1,27 @@
+{
+ "job_id": "JOB_ID",
+ "indices": [
+ "INDEX_PATTERN_NAME"
+ ],
+ "max_empty_searches": 10,
+ "query": {
+ "bool": {
+ "filter": { "term": { "agent.name": "js-base" } }
+ }
+ },
+ "aggregations": {
+ "buckets": {
+ "date_histogram": {
+ "field": "@timestamp",
+ "fixed_interval": "900000ms"
+ },
+ "aggregations": {
+ "@timestamp": {
+ "max": {
+ "field": "@timestamp"
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/datafeed_high_count_by_user_agent_jsbase.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/datafeed_high_count_by_user_agent_jsbase.json
new file mode 100644
index 0000000000000..18ca6b1389287
--- /dev/null
+++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/datafeed_high_count_by_user_agent_jsbase.json
@@ -0,0 +1,16 @@
+{
+ "job_id": "JOB_ID",
+ "indices": [
+ "INDEX_PATTERN_NAME"
+ ],
+ "max_empty_searches": 10,
+ "query": {
+ "bool": {
+ "must": [
+ { "bool": { "filter": { "term": { "agent.name": "js-base" } } } },
+ { "bool": { "filter": [{ "exists": { "field": "user_agent.name" } }] } },
+ { "bool": { "filter": { "term": { "processor.event": "transaction" } } } }
+ ]
+ }
+ }
+}
diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/decreased_throughput_jsbase.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/decreased_throughput_jsbase.json
new file mode 100644
index 0000000000000..4bc8757f19dc9
--- /dev/null
+++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/decreased_throughput_jsbase.json
@@ -0,0 +1,37 @@
+{
+ "job_type": "anomaly_detector",
+ "groups": [
+ "apm"
+ ],
+ "description": "APM JSBase: Identifies periods during which the application is processing fewer requests than normal.",
+ "analysis_config": {
+ "summary_count_field_name": "doc_count",
+ "bucket_span": "15m",
+ "detectors": [
+ {
+ "detector_description": "low throughput",
+ "function": "low_count"
+ }
+ ],
+ "influencers": [
+ "service.name"
+ ]
+ },
+ "allow_lazy_open": true,
+ "analysis_limits": {
+ "model_memory_limit": "10mb"
+ },
+ "data_description": {
+ "time_field": "@timestamp"
+ },
+ "custom_settings": {
+ "created_by": "ml-module-apm-jsbase",
+ "custom_urls": [
+ {
+ "url_name": "APM",
+ "time_range": "2h",
+ "url_value": "apm#/services?rangeFrom=$earliest$&rangeTo=$latest$&refreshPaused=true&refreshInterval=0&kuery=&transactionType=request"
+ }
+ ]
+ }
+}
diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/high_count_by_user_agent_jsbase.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/high_count_by_user_agent_jsbase.json
new file mode 100644
index 0000000000000..7e1316359eabb
--- /dev/null
+++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/high_count_by_user_agent_jsbase.json
@@ -0,0 +1,38 @@
+{
+ "job_type": "anomaly_detector",
+ "groups": [
+ "apm"
+ ],
+ "description": "APM JSBase: Detects user agents that are making requests at a suspiciously high rate. This is useful in identifying bots.",
+ "analysis_config": {
+ "bucket_span": "15m",
+ "detectors": [
+ {
+ "detector_description": "high request rate for user agent",
+ "function": "high_non_zero_count",
+ "partition_field_name": "user_agent.name"
+ }
+ ],
+ "influencers": [
+ "user_agent.name",
+ "service.name"
+ ]
+ },
+ "allow_lazy_open": true,
+ "analysis_limits": {
+ "model_memory_limit": "32mb"
+ },
+ "data_description": {
+ "time_field": "@timestamp"
+ },
+ "custom_settings": {
+ "created_by": "ml-module-apm-jsbase",
+ "custom_urls": [
+ {
+ "url_name": "APM",
+ "time_range": "2h",
+ "url_value": "apm#/services/$service.name$/transactions?rangeFrom=$earliest$&rangeTo=$latest$&refreshPaused=true&refreshInterval=0&kuery=user_agent.name:\"$user_agent.name$\"&_g=()"
+ }
+ ]
+ }
+}
diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/logo.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/logo.json
new file mode 100644
index 0000000000000..3905c809fbd7a
--- /dev/null
+++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/logo.json
@@ -0,0 +1,3 @@
+{
+ "icon": "apmApp"
+}
diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/manifest.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/manifest.json
new file mode 100644
index 0000000000000..1865a33a1d301
--- /dev/null
+++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/manifest.json
@@ -0,0 +1,42 @@
+{
+ "id": "apm_nodejs",
+ "title": "APM: NodeJS",
+ "description": "Detect abnormal traces, anomalous spans, and identify periods of decreased throughput.",
+ "type": "APM data",
+ "logoFile": "logo.json",
+ "defaultIndexPattern": "apm-*",
+ "query": {
+ "bool": { "filter": [{ "term": { "agent.name": "nodejs" } }] }
+ },
+ "jobs": [
+ {
+ "id": "abnormal_span_durations_nodejs",
+ "file": "abnormal_span_durations_nodejs.json"
+ },
+ {
+ "id": "abnormal_trace_durations_nodejs",
+ "file": "abnormal_trace_durations_nodejs.json"
+ },
+ {
+ "id": "decreased_throughput_nodejs",
+ "file": "decreased_throughput_nodejs.json"
+ }
+ ],
+ "datafeeds": [
+ {
+ "id": "datafeed-abnormal_span_durations_nodejs",
+ "file": "datafeed_abnormal_span_durations_nodejs.json",
+ "job_id": "abnormal_span_durations_nodejs"
+ },
+ {
+ "id": "datafeed-abnormal_trace_durations_nodejs",
+ "file": "datafeed_abnormal_trace_durations_nodejs.json",
+ "job_id": "abnormal_trace_durations_nodejs"
+ },
+ {
+ "id": "datafeed-decreased_throughput_nodejs",
+ "file": "datafeed_decreased_throughput_nodejs.json",
+ "job_id": "decreased_throughput_nodejs"
+ }
+ ]
+}
diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/abnormal_span_durations_nodejs.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/abnormal_span_durations_nodejs.json
new file mode 100644
index 0000000000000..1a8318437790e
--- /dev/null
+++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/abnormal_span_durations_nodejs.json
@@ -0,0 +1,41 @@
+{
+ "job_type": "anomaly_detector",
+ "groups": [
+ "apm"
+ ],
+ "description": "APM NodeJS: Looks for spans that are taking longer than usual to process.",
+ "analysis_config": {
+ "bucket_span": "15m",
+ "detectors": [
+ {
+ "detector_description": "increased span duration",
+ "function": "high_mean",
+ "field_name": "span.duration.us",
+ "partition_field_name": "span.type"
+ }
+ ],
+ "influencers": [
+ "span.type",
+ "trace.id",
+ "span.name",
+ "service.name"
+ ]
+ },
+ "allow_lazy_open": true,
+ "analysis_limits": {
+ "model_memory_limit": "128mb"
+ },
+ "data_description": {
+ "time_field": "@timestamp"
+ },
+ "custom_settings": {
+ "created_by": "ml-module-apm-nodejs",
+ "custom_urls": [
+ {
+ "url_name": "APM",
+ "time_range": "2h",
+ "url_value": "apm#/traces?rangeFrom=$earliest$&rangeTo=$latest$&kuery=trace.id:\"$trace.id$\"&_g=()"
+ }
+ ]
+ }
+}
diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/abnormal_trace_durations_nodejs.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/abnormal_trace_durations_nodejs.json
new file mode 100644
index 0000000000000..875b49e895a00
--- /dev/null
+++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/abnormal_trace_durations_nodejs.json
@@ -0,0 +1,40 @@
+{
+ "job_type": "anomaly_detector",
+ "groups": [
+ "apm"
+ ],
+ "description": "APM NodeJS: Identifies trace transactions that are processing more slowly than usual.",
+ "analysis_config": {
+ "bucket_span": "15m",
+ "detectors": [
+ {
+ "detector_description": "increased trace duration",
+ "function": "high_mean",
+ "field_name": "transaction.duration.us",
+ "by_field_name": "transaction.name"
+ }
+ ],
+ "influencers": [
+ "transaction.name",
+ "trace.id",
+ "service.name"
+ ]
+ },
+ "allow_lazy_open": true,
+ "analysis_limits": {
+ "model_memory_limit": "256mb"
+ },
+ "data_description": {
+ "time_field": "@timestamp"
+ },
+ "custom_settings": {
+ "created_by": "ml-module-apm-nodejs",
+ "custom_urls": [
+ {
+ "url_name": "APM",
+ "time_range": "2h",
+ "url_value": "apm#/traces?rangeFrom=$earliest$&rangeTo=$latest$&kuery=trace.id:\"$trace.id$\" and transaction.name:\"$transaction.name$\"&_g=()"
+ }
+ ]
+ }
+}
diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/datafeed_abnormal_span_durations_nodejs.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/datafeed_abnormal_span_durations_nodejs.json
new file mode 100644
index 0000000000000..3e4f4877bd042
--- /dev/null
+++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/datafeed_abnormal_span_durations_nodejs.json
@@ -0,0 +1,15 @@
+{
+ "job_id": "JOB_ID",
+ "indices": [
+ "INDEX_PATTERN_NAME"
+ ],
+ "max_empty_searches": 10,
+ "query": {
+ "bool": {
+ "must": [
+ { "bool": { "filter": { "term": { "agent.name": "nodejs" } } } },
+ { "bool": { "filter": { "term": { "processor.event": "span" } } } }
+ ]
+ }
+ }
+}
diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/datafeed_abnormal_trace_durations_nodejs.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/datafeed_abnormal_trace_durations_nodejs.json
new file mode 100644
index 0000000000000..d87f809a49940
--- /dev/null
+++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/datafeed_abnormal_trace_durations_nodejs.json
@@ -0,0 +1,13 @@
+{
+ "job_id": "JOB_ID",
+ "indices": [
+ "INDEX_PATTERN_NAME"
+ ],
+ "max_empty_searches": 10,
+ "query": {
+ "bool": {
+ "must_not": [{ "exists": { "field": "parent.id" } }],
+ "must": [{ "bool": { "filter": { "term": { "agent.name": "nodejs" } } } }]
+ }
+ }
+}
diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/datafeed_decreased_throughput_nodejs.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/datafeed_decreased_throughput_nodejs.json
new file mode 100644
index 0000000000000..451957c327dd0
--- /dev/null
+++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/datafeed_decreased_throughput_nodejs.json
@@ -0,0 +1,27 @@
+{
+ "job_id": "JOB_ID",
+ "indices": [
+ "INDEX_PATTERN_NAME"
+ ],
+ "max_empty_searches": 10,
+ "query": {
+ "bool": {
+ "filter": { "term": { "agent.name": "nodejs" } }
+ }
+ },
+ "aggregations": {
+ "buckets": {
+ "date_histogram": {
+ "field": "@timestamp",
+ "fixed_interval": "900000ms"
+ },
+ "aggregations": {
+ "@timestamp": {
+ "max": {
+ "field": "@timestamp"
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/decreased_throughput_nodejs.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/decreased_throughput_nodejs.json
new file mode 100644
index 0000000000000..f63c6289a5cd9
--- /dev/null
+++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/decreased_throughput_nodejs.json
@@ -0,0 +1,38 @@
+{
+ "job_type": "anomaly_detector",
+ "groups": [
+ "apm"
+ ],
+ "description": "APM NodeJS: Identifies periods during which the application is processing fewer requests than normal.",
+ "analysis_config": {
+ "summary_count_field_name": "doc_count",
+ "bucket_span": "15m",
+ "detectors": [
+ {
+ "detector_description": "low throughput",
+ "function": "low_count"
+ }
+ ],
+ "influencers": [
+ "transaction.name",
+ "service.name"
+ ]
+ },
+ "allow_lazy_open": true,
+ "analysis_limits": {
+ "model_memory_limit": "10mb"
+ },
+ "data_description": {
+ "time_field": "@timestamp"
+ },
+ "custom_settings": {
+ "created_by": "ml-module-apm-nodejs",
+ "custom_urls": [
+ {
+ "url_name": "APM",
+ "time_range": "2h",
+ "url_value": "apm#/services?rangeFrom=$earliest$&rangeTo=$latest$&refreshPaused=true&refreshInterval=0&kuery=&transactionType=request"
+ }
+ ]
+ }
+}
diff --git a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/__test__/get_paginated_nodes.test.js b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/__test__/get_paginated_nodes.test.js
index 57fdbd5cc6238..c08ae91769b9d 100644
--- a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/__test__/get_paginated_nodes.test.js
+++ b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/__test__/get_paginated_nodes.test.js
@@ -58,7 +58,7 @@ describe('getPaginatedNodes', () => {
},
},
};
- const shardStats = {
+ const nodesShardCount = {
nodes: {
1: {
shardCount: 10,
@@ -78,7 +78,7 @@ describe('getPaginatedNodes', () => {
pagination,
sort,
queryText,
- { clusterStats, shardStats }
+ { clusterStats, nodesShardCount }
);
expect(nodes).toEqual({
pageOfNodes: [
@@ -98,7 +98,7 @@ describe('getPaginatedNodes', () => {
pagination,
{ ...sort, field: 'foo', direction: 'desc' },
queryText,
- { clusterStats, shardStats }
+ { clusterStats, nodesShardCount }
);
expect(nodes).toEqual({
pageOfNodes: [
@@ -118,7 +118,7 @@ describe('getPaginatedNodes', () => {
pagination,
sort,
'tw',
- { clusterStats, shardStats }
+ { clusterStats, nodesShardCount }
);
expect(nodes).toEqual({
pageOfNodes: [{ name: 'two', uuid: 2, isOnline: false, shardCount: 5, foo: 12 }],
diff --git a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/get_nodes.js b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/get_nodes.js
index 4bfd0090fced0..7581a32590971 100644
--- a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/get_nodes.js
+++ b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/get_nodes.js
@@ -29,7 +29,7 @@ import { LISTING_METRICS_NAMES, LISTING_METRICS_PATHS } from './nodes_listing_me
* @param {Object} shardStats: per-node information about shards
* @return {Array} node info combined with metrics for each node from handle_response
*/
-export async function getNodes(req, esIndexPattern, pageOfNodes, clusterStats, shardStats) {
+export async function getNodes(req, esIndexPattern, pageOfNodes, clusterStats, nodesShardCount) {
checkParam(esIndexPattern, 'esIndexPattern in getNodes');
const start = moment.utc(req.payload.timeRange.min).valueOf();
@@ -104,5 +104,9 @@ export async function getNodes(req, esIndexPattern, pageOfNodes, clusterStats, s
const { callWithRequest } = req.server.plugins.elasticsearch.getCluster('monitoring');
const response = await callWithRequest(req, 'search', params);
- return handleResponse(response, clusterStats, shardStats, pageOfNodes, { min, max, bucketSize });
+ return handleResponse(response, clusterStats, nodesShardCount, pageOfNodes, {
+ min,
+ max,
+ bucketSize,
+ });
}
diff --git a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/get_paginated_nodes.js b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/get_paginated_nodes.js
index 15084d952b343..0023b9515ad1c 100644
--- a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/get_paginated_nodes.js
+++ b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/get_paginated_nodes.js
@@ -35,7 +35,7 @@ export async function getPaginatedNodes(
pagination,
sort,
queryText,
- { clusterStats, shardStats }
+ { clusterStats, nodesShardCount }
) {
const config = req.server.config();
const size = config.get('xpack.monitoring.max_bucket_size');
@@ -45,7 +45,7 @@ export async function getPaginatedNodes(
const clusterState = get(clusterStats, 'cluster_state', { nodes: {} });
for (const node of nodes) {
node.isOnline = !isUndefined(get(clusterState, ['nodes', node.uuid]));
- node.shardCount = get(shardStats, `nodes[${node.uuid}].shardCount`, 0);
+ node.shardCount = get(nodesShardCount, `nodes[${node.uuid}].shardCount`, 0);
}
// `metricSet` defines a list of metrics that are sortable in the UI
diff --git a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/handle_response.js b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/handle_response.js
index 55072a1086641..651fd20d77554 100644
--- a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/handle_response.js
+++ b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/handle_response.js
@@ -13,17 +13,23 @@ import { uncovertMetricNames } from '../../convert_metric_names';
* Process the response from the get_nodes query
* @param {Object} response: response data from get_nodes
* @param {Object} clusterStats: cluster stats from cluster state document
- * @param {Object} shardStats: per-node information about shards
+ * @param {Object} nodesShardCount: per-node information about shards
* @param {Object} timeOptions: min, max, and bucketSize needed for date histogram creation
* @return {Array} node info combined with metrics for each node
*/
-export function handleResponse(response, clusterStats, shardStats, pageOfNodes, timeOptions = {}) {
+export function handleResponse(
+ response,
+ clusterStats,
+ nodesShardCount,
+ pageOfNodes,
+ timeOptions = {}
+) {
if (!get(response, 'hits.hits')) {
return [];
}
const nodeHits = get(response, 'hits.hits', []);
- const nodesInfo = mapNodesInfo(nodeHits, clusterStats, shardStats);
+ const nodesInfo = mapNodesInfo(nodeHits, clusterStats, nodesShardCount);
/*
* Every node bucket is an object with a field for nodeId and fields for
diff --git a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/map_nodes_info.js b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/map_nodes_info.js
index 23ee614d48ec4..3c719c2ddfbf8 100644
--- a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/map_nodes_info.js
+++ b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/map_nodes_info.js
@@ -10,10 +10,10 @@ import { calculateNodeType, getNodeTypeClassLabel } from '../';
/**
* @param {Array} nodeHits: info about each node from the hits in the get_nodes query
* @param {Object} clusterStats: cluster stats from cluster state document
- * @param {Object} shardStats: per-node information about shards
+ * @param {Object} nodesShardCount: per-node information about shards
* @return {Object} summarized info about each node keyed by nodeId
*/
-export function mapNodesInfo(nodeHits, clusterStats, shardStats) {
+export function mapNodesInfo(nodeHits, clusterStats, nodesShardCount) {
const clusterState = get(clusterStats, 'cluster_state', { nodes: {} });
return nodeHits.reduce((prev, node) => {
@@ -35,7 +35,7 @@ export function mapNodesInfo(nodeHits, clusterStats, shardStats) {
isOnline,
nodeTypeLabel: nodeTypeLabel,
nodeTypeClass: nodeTypeClass,
- shardCount: get(shardStats, `nodes[${sourceNode.uuid}].shardCount`, 0),
+ shardCount: get(nodesShardCount, `nodes[${sourceNode.uuid}].shardCount`, 0),
},
};
}, {});
diff --git a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_indices_unassigned_shard_stats.js b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_indices_unassigned_shard_stats.js
new file mode 100644
index 0000000000000..e8d484e7021f4
--- /dev/null
+++ b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_indices_unassigned_shard_stats.js
@@ -0,0 +1,93 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { get } from 'lodash';
+import { checkParam } from '../../error_missing_required';
+import { createQuery } from '../../create_query';
+import { ElasticsearchMetric } from '../../metrics';
+import { calculateIndicesTotals } from './calculate_shard_stat_indices_totals';
+
+async function getUnassignedShardData(req, esIndexPattern, cluster) {
+ const config = req.server.config();
+ const maxBucketSize = config.get('xpack.monitoring.max_bucket_size');
+ const metric = ElasticsearchMetric.getMetricFields();
+
+ const params = {
+ index: esIndexPattern,
+ size: 0,
+ ignoreUnavailable: true,
+ body: {
+ sort: { timestamp: { order: 'desc' } },
+ query: createQuery({
+ type: 'shards',
+ clusterUuid: cluster.cluster_uuid,
+ metric,
+ filters: [{ term: { state_uuid: get(cluster, 'cluster_state.state_uuid') } }],
+ }),
+ aggs: {
+ indices: {
+ terms: {
+ field: 'shard.index',
+ size: maxBucketSize,
+ },
+ aggs: {
+ state: {
+ filter: {
+ terms: {
+ 'shard.state': ['UNASSIGNED', 'INITIALIZING'],
+ },
+ },
+ aggs: {
+ primary: {
+ terms: {
+ field: 'shard.primary',
+ size: 2,
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ };
+
+ const { callWithRequest } = req.server.plugins.elasticsearch.getCluster('monitoring');
+ return await callWithRequest(req, 'search', params);
+}
+
+export async function getIndicesUnassignedShardStats(req, esIndexPattern, cluster) {
+ checkParam(esIndexPattern, 'esIndexPattern in elasticsearch/getShardStats');
+
+ const response = await getUnassignedShardData(req, esIndexPattern, cluster);
+ const indices = get(response, 'aggregations.indices.buckets', []).reduce((accum, bucket) => {
+ const index = bucket.key;
+ const states = get(bucket, 'state.primary.buckets', []);
+ const unassignedReplica = states
+ .filter(state => state.key_as_string === 'false')
+ .reduce((total, state) => total + state.doc_count, 0);
+ const unassignedPrimary = states
+ .filter(state => state.key_as_string === 'true')
+ .reduce((total, state) => total + state.doc_count, 0);
+
+ let status = 'green';
+ if (unassignedReplica > 0) {
+ status = 'yellow';
+ }
+ if (unassignedPrimary > 0) {
+ status = 'red';
+ }
+
+ accum[index] = {
+ unassigned: { primary: unassignedPrimary, replica: unassignedReplica },
+ status,
+ };
+ return accum;
+ }, {});
+
+ const indicesTotals = calculateIndicesTotals(indices);
+ return { indices, indicesTotals };
+}
diff --git a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_indices_unassigned_shard_stats.test.js b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_indices_unassigned_shard_stats.test.js
new file mode 100644
index 0000000000000..a899b48cdd434
--- /dev/null
+++ b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_indices_unassigned_shard_stats.test.js
@@ -0,0 +1,59 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { getIndicesUnassignedShardStats } from './get_indices_unassigned_shard_stats';
+
+describe('getIndicesUnassignedShardStats', () => {
+ it('should return the unassigned shard stats for indices', async () => {
+ const indices = {
+ 12345: { status: 'red', unassigned: { primary: 1, replica: 0 } },
+ 6789: { status: 'yellow', unassigned: { primary: 0, replica: 1 } },
+ absdf82: { status: 'green', unassigned: { primary: 0, replica: 0 } },
+ };
+
+ const req = {
+ server: {
+ config: () => ({
+ get: () => {},
+ }),
+ plugins: {
+ elasticsearch: {
+ getCluster: () => ({
+ callWithRequest: () => ({
+ aggregations: {
+ indices: {
+ buckets: Object.keys(indices).map(id => ({
+ key: id,
+ state: {
+ primary: {
+ buckets:
+ indices[id].unassigned.primary || indices[id].unassigned.replica
+ ? [
+ {
+ key_as_string: indices[id].unassigned.primary
+ ? 'true'
+ : 'false',
+ doc_count: 1,
+ },
+ ]
+ : [],
+ },
+ },
+ })),
+ },
+ },
+ }),
+ }),
+ },
+ },
+ },
+ };
+ const esIndexPattern = '*';
+ const cluster = {};
+ const stats = await getIndicesUnassignedShardStats(req, esIndexPattern, cluster);
+ expect(stats.indices).toEqual(indices);
+ });
+});
diff --git a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_nodes_shard_count.js b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_nodes_shard_count.js
new file mode 100644
index 0000000000000..c11bd4aead693
--- /dev/null
+++ b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_nodes_shard_count.js
@@ -0,0 +1,53 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { get } from 'lodash';
+import { checkParam } from '../../error_missing_required';
+import { createQuery } from '../../create_query';
+import { ElasticsearchMetric } from '../../metrics';
+
+async function getShardCountPerNode(req, esIndexPattern, cluster) {
+ const config = req.server.config();
+ const maxBucketSize = config.get('xpack.monitoring.max_bucket_size');
+ const metric = ElasticsearchMetric.getMetricFields();
+
+ const params = {
+ index: esIndexPattern,
+ size: 0,
+ ignoreUnavailable: true,
+ body: {
+ sort: { timestamp: { order: 'desc' } },
+ query: createQuery({
+ type: 'shards',
+ clusterUuid: cluster.cluster_uuid,
+ metric,
+ filters: [{ term: { state_uuid: get(cluster, 'cluster_state.state_uuid') } }],
+ }),
+ aggs: {
+ nodes: {
+ terms: {
+ field: 'shard.node',
+ size: maxBucketSize,
+ },
+ },
+ },
+ },
+ };
+
+ const { callWithRequest } = req.server.plugins.elasticsearch.getCluster('monitoring');
+ return await callWithRequest(req, 'search', params);
+}
+
+export async function getNodesShardCount(req, esIndexPattern, cluster) {
+ checkParam(esIndexPattern, 'esIndexPattern in elasticsearch/getShardStats');
+
+ const response = await getShardCountPerNode(req, esIndexPattern, cluster);
+ const nodes = get(response, 'aggregations.nodes.buckets', []).reduce((accum, bucket) => {
+ accum[bucket.key] = { shardCount: bucket.doc_count };
+ return accum;
+ }, {});
+ return { nodes };
+}
diff --git a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_nodes_shard_count.test.js b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_nodes_shard_count.test.js
new file mode 100644
index 0000000000000..023f12db1bf46
--- /dev/null
+++ b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_nodes_shard_count.test.js
@@ -0,0 +1,45 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { getNodesShardCount } from './get_nodes_shard_count';
+
+describe('getNodeShardCount', () => {
+ it('should return the shard count per node', async () => {
+ const nodes = {
+ 12345: { shardCount: 10 },
+ 6789: { shardCount: 1 },
+ absdf82: { shardCount: 20 },
+ };
+
+ const req = {
+ server: {
+ config: () => ({
+ get: () => {},
+ }),
+ plugins: {
+ elasticsearch: {
+ getCluster: () => ({
+ callWithRequest: () => ({
+ aggregations: {
+ nodes: {
+ buckets: Object.keys(nodes).map(id => ({
+ key: id,
+ doc_count: nodes[id].shardCount,
+ })),
+ },
+ },
+ }),
+ }),
+ },
+ },
+ },
+ };
+ const esIndexPattern = '*';
+ const cluster = {};
+ const counts = await getNodesShardCount(req, esIndexPattern, cluster);
+ expect(counts.nodes).toEqual(nodes);
+ });
+});
diff --git a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_shard_stat_aggs.js b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_shard_stat_aggs.js
index a718ef8569dbf..eddd50612cdb1 100644
--- a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_shard_stat_aggs.js
+++ b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_shard_stat_aggs.js
@@ -8,7 +8,7 @@
* @param {Object} config - Kibana config service
* @param {Boolean} includeNodes - whether to add the aggs for node shards
*/
-export function getShardAggs(config, includeNodes) {
+export function getShardAggs(config, includeNodes, includeIndices) {
const maxBucketSize = config.get('xpack.monitoring.max_bucket_size');
const aggSize = 10;
const indicesAgg = {
@@ -40,7 +40,7 @@ export function getShardAggs(config, includeNodes) {
};
return {
- ...{ indices: indicesAgg },
+ ...{ indices: includeIndices ? indicesAgg : undefined },
...{ nodes: includeNodes ? nodesAgg : undefined },
};
}
diff --git a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_shard_stats.js b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_shard_stats.js
index c77e03673bb4c..132e9d6b01dbe 100644
--- a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_shard_stats.js
+++ b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_shard_stats.js
@@ -21,11 +21,11 @@ export function handleResponse(resp, includeNodes, includeIndices, cluster) {
if (buckets && buckets.length !== 0) {
indices = buckets.reduce(normalizeIndexShards, {});
indicesTotals = calculateIndicesTotals(indices);
+ }
- if (includeNodes) {
- const masterNode = get(cluster, 'cluster_state.master_node');
- nodes = resp.aggregations.nodes.buckets.reduce(normalizeNodeShards(masterNode), {});
- }
+ if (includeNodes) {
+ const masterNode = get(cluster, 'cluster_state.master_node');
+ nodes = resp.aggregations.nodes.buckets.reduce(normalizeNodeShards(masterNode), {});
}
return {
@@ -39,12 +39,19 @@ export function getShardStats(
req,
esIndexPattern,
cluster,
- { includeNodes = false, includeIndices = false } = {}
+ { includeNodes = false, includeIndices = false, indexName = null, nodeUuid = null } = {}
) {
checkParam(esIndexPattern, 'esIndexPattern in elasticsearch/getShardStats');
const config = req.server.config();
const metric = ElasticsearchMetric.getMetricFields();
+ const filters = [{ term: { state_uuid: get(cluster, 'cluster_state.state_uuid') } }];
+ if (indexName) {
+ filters.push({ term: { 'shard.index': indexName } });
+ }
+ if (nodeUuid) {
+ filters.push({ term: { 'shard.node': nodeUuid } });
+ }
const params = {
index: esIndexPattern,
size: 0,
@@ -55,10 +62,10 @@ export function getShardStats(
type: 'shards',
clusterUuid: cluster.cluster_uuid,
metric,
- filters: [{ term: { state_uuid: get(cluster, 'cluster_state.state_uuid') } }],
+ filters,
}),
aggs: {
- ...getShardAggs(config, includeNodes),
+ ...getShardAggs(config, includeNodes, includeIndices),
},
},
};
diff --git a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/index_detail.js b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/index_detail.js
index e6380a724590e..c32e25d9f20d1 100644
--- a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/index_detail.js
+++ b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/index_detail.js
@@ -60,6 +60,7 @@ export function esIndexRoute(server) {
const shardStats = await getShardStats(req, esIndexPattern, cluster, {
includeNodes: true,
includeIndices: true,
+ indexName: indexUuid,
});
const indexSummary = await getIndexSummary(req, esIndexPattern, shardStats, {
clusterUuid,
diff --git a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/indices.js b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/indices.js
index c8cf4bd29e26d..241b54fbf0c2a 100644
--- a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/indices.js
+++ b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/indices.js
@@ -8,10 +8,10 @@ import Joi from 'joi';
import { getClusterStats } from '../../../../lib/cluster/get_cluster_stats';
import { getClusterStatus } from '../../../../lib/cluster/get_cluster_status';
import { getIndices } from '../../../../lib/elasticsearch/indices';
-import { getShardStats } from '../../../../lib/elasticsearch/shards';
import { handleError } from '../../../../lib/errors/handle_error';
import { prefixIndexPattern } from '../../../../lib/ccs_utils';
import { INDEX_PATTERN_ELASTICSEARCH } from '../../../../../common/constants';
+import { getIndicesUnassignedShardStats } from '../../../../lib/elasticsearch/shards/get_indices_unassigned_shard_stats';
export function esIndicesRoute(server) {
server.route({
@@ -43,13 +43,20 @@ export function esIndicesRoute(server) {
try {
const clusterStats = await getClusterStats(req, esIndexPattern, clusterUuid);
- const shardStats = await getShardStats(req, esIndexPattern, clusterStats, {
- includeIndices: true,
- });
- const indices = await getIndices(req, esIndexPattern, showSystemIndices, shardStats);
+ const indicesUnassignedShardStats = await getIndicesUnassignedShardStats(
+ req,
+ esIndexPattern,
+ clusterStats
+ );
+ const indices = await getIndices(
+ req,
+ esIndexPattern,
+ showSystemIndices,
+ indicesUnassignedShardStats
+ );
return {
- clusterStatus: getClusterStatus(clusterStats, shardStats),
+ clusterStatus: getClusterStatus(clusterStats, indicesUnassignedShardStats),
indices,
};
} catch (err) {
diff --git a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/ml_jobs.js b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/ml_jobs.js
index 1876f751dd166..de3b9863d9141 100644
--- a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/ml_jobs.js
+++ b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/ml_jobs.js
@@ -8,10 +8,10 @@ import Joi from 'joi';
import { getClusterStats } from '../../../../lib/cluster/get_cluster_stats';
import { getClusterStatus } from '../../../../lib/cluster/get_cluster_status';
import { getMlJobs } from '../../../../lib/elasticsearch/get_ml_jobs';
-import { getShardStats } from '../../../../lib/elasticsearch/shards';
import { handleError } from '../../../../lib/errors/handle_error';
import { prefixIndexPattern } from '../../../../lib/ccs_utils';
import { INDEX_PATTERN_ELASTICSEARCH } from '../../../../../common/constants';
+import { getIndicesUnassignedShardStats } from '../../../../lib/elasticsearch/shards/get_indices_unassigned_shard_stats';
export function mlJobRoute(server) {
server.route({
@@ -39,11 +39,15 @@ export function mlJobRoute(server) {
try {
const clusterStats = await getClusterStats(req, esIndexPattern, clusterUuid);
- const shardStats = await getShardStats(req, esIndexPattern, clusterStats);
+ const indicesUnassignedShardStats = await getIndicesUnassignedShardStats(
+ req,
+ esIndexPattern,
+ clusterStats
+ );
const rows = await getMlJobs(req, esIndexPattern);
return {
- clusterStatus: getClusterStatus(clusterStats, shardStats),
+ clusterStatus: getClusterStatus(clusterStats, indicesUnassignedShardStats),
rows,
};
} catch (err) {
diff --git a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/node_detail.js b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/node_detail.js
index 5da2e7128e7e4..10226d74ed001 100644
--- a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/node_detail.js
+++ b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/node_detail.js
@@ -78,6 +78,7 @@ export function esNodeRoute(server) {
const shardStats = await getShardStats(req, esIndexPattern, cluster, {
includeIndices: true,
includeNodes: true,
+ nodeUuid,
});
const nodeSummary = await getNodeSummary(req, esIndexPattern, clusterState, shardStats, {
clusterUuid,
diff --git a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/nodes.js b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/nodes.js
index 88e65332603ad..fb2d04ecc041d 100644
--- a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/nodes.js
+++ b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/nodes.js
@@ -8,12 +8,13 @@ import Joi from 'joi';
import { getClusterStats } from '../../../../lib/cluster/get_cluster_stats';
import { getClusterStatus } from '../../../../lib/cluster/get_cluster_status';
import { getNodes } from '../../../../lib/elasticsearch/nodes';
-import { getShardStats } from '../../../../lib/elasticsearch/shards';
+import { getNodesShardCount } from '../../../../lib/elasticsearch/shards/get_nodes_shard_count';
import { handleError } from '../../../../lib/errors/handle_error';
import { prefixIndexPattern } from '../../../../lib/ccs_utils';
import { INDEX_PATTERN_ELASTICSEARCH } from '../../../../../common/constants';
import { getPaginatedNodes } from '../../../../lib/elasticsearch/nodes/get_nodes/get_paginated_nodes';
import { LISTING_METRICS_NAMES } from '../../../../lib/elasticsearch/nodes/get_nodes/nodes_listing_metrics';
+import { getIndicesUnassignedShardStats } from '../../../../lib/elasticsearch/shards/get_indices_unassigned_shard_stats';
export function esNodesRoute(server) {
server.route({
@@ -53,10 +54,13 @@ export function esNodesRoute(server) {
try {
const clusterStats = await getClusterStats(req, esIndexPattern, clusterUuid);
- const shardStats = await getShardStats(req, esIndexPattern, clusterStats, {
- includeNodes: true,
- });
- const clusterStatus = getClusterStatus(clusterStats, shardStats);
+ const nodesShardCount = await getNodesShardCount(req, esIndexPattern, clusterStats);
+ const indicesUnassignedShardStats = await getIndicesUnassignedShardStats(
+ req,
+ esIndexPattern,
+ clusterStats
+ );
+ const clusterStatus = getClusterStatus(clusterStats, indicesUnassignedShardStats);
const metricSet = LISTING_METRICS_NAMES;
const { pageOfNodes, totalNodeCount } = await getPaginatedNodes(
@@ -69,11 +73,17 @@ export function esNodesRoute(server) {
queryText,
{
clusterStats,
- shardStats,
+ nodesShardCount,
}
);
- const nodes = await getNodes(req, esIndexPattern, pageOfNodes, clusterStats, shardStats);
+ const nodes = await getNodes(
+ req,
+ esIndexPattern,
+ pageOfNodes,
+ clusterStats,
+ nodesShardCount
+ );
return { clusterStatus, nodes, totalNodeCount };
} catch (err) {
throw handleError(err, req);
diff --git a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/overview.js b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/overview.js
index 9022471dfb7f8..b0045502fa228 100644
--- a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/overview.js
+++ b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/overview.js
@@ -9,7 +9,6 @@ import { getClusterStats } from '../../../../lib/cluster/get_cluster_stats';
import { getClusterStatus } from '../../../../lib/cluster/get_cluster_status';
import { getLastRecovery } from '../../../../lib/elasticsearch/get_last_recovery';
import { getMetrics } from '../../../../lib/details/get_metrics';
-import { getShardStats } from '../../../../lib/elasticsearch/shards';
import { handleError } from '../../../../lib/errors/handle_error';
import { prefixIndexPattern } from '../../../../lib/ccs_utils';
import { metricSet } from './metric_set_overview';
@@ -18,6 +17,7 @@ import {
INDEX_PATTERN_FILEBEAT,
} from '../../../../../common/constants';
import { getLogs } from '../../../../lib/logs';
+import { getIndicesUnassignedShardStats } from '../../../../lib/elasticsearch/shards/get_indices_unassigned_shard_stats';
export function esOverviewRoute(server) {
server.route({
@@ -54,10 +54,14 @@ export function esOverviewRoute(server) {
getLastRecovery(req, esIndexPattern),
getLogs(config, req, filebeatIndexPattern, { clusterUuid, start, end }),
]);
- const shardStats = await getShardStats(req, esIndexPattern, clusterStats);
+ const indicesUnassignedShardStats = await getIndicesUnassignedShardStats(
+ req,
+ esIndexPattern,
+ clusterStats
+ );
return {
- clusterStatus: getClusterStatus(clusterStats, shardStats),
+ clusterStatus: getClusterStatus(clusterStats, indicesUnassignedShardStats),
metrics,
logs,
shardActivity,
diff --git a/x-pack/legacy/plugins/monitoring/server/telemetry_collection/__tests__/fixtures/beats_stats_results.json b/x-pack/legacy/plugins/monitoring/server/telemetry_collection/__tests__/fixtures/beats_stats_results.json
index 1f17a7b78a29e..584618057256a 100644
--- a/x-pack/legacy/plugins/monitoring/server/telemetry_collection/__tests__/fixtures/beats_stats_results.json
+++ b/x-pack/legacy/plugins/monitoring/server/telemetry_collection/__tests__/fixtures/beats_stats_results.json
@@ -2,6 +2,36 @@
{
"hits": {
"hits": [
+ {
+ "_source" : {
+ "cluster_uuid": "W7hppdX7R229Oy3KQbZrTw",
+ "type": "beats_state",
+ "beats_state" : {
+ "state" : {
+ "functionbeat" : {
+ "functions": {
+ "count": 1
+ }
+ }
+ }
+ }
+ }
+ },
+ {
+ "_source" : {
+ "cluster_uuid": "W7hppdX7R229Oy3KQbZrTw",
+ "type": "beats_state",
+ "beats_state" : {
+ "state" : {
+ "functionbeat" : {
+ "functions": {
+ "count": 3
+ }
+ }
+ }
+ }
+ }
+ },
{
"_source" : {
"cluster_uuid": "W7hppdX7R229Oy3KQbZrTw",
diff --git a/x-pack/legacy/plugins/monitoring/server/telemetry_collection/__tests__/get_beats_stats.js b/x-pack/legacy/plugins/monitoring/server/telemetry_collection/__tests__/get_beats_stats.js
index 7734441a302c3..522be71555fba 100644
--- a/x-pack/legacy/plugins/monitoring/server/telemetry_collection/__tests__/get_beats_stats.js
+++ b/x-pack/legacy/plugins/monitoring/server/telemetry_collection/__tests__/get_beats_stats.js
@@ -168,6 +168,11 @@ describe('Get Beats Stats', () => {
},
monitors: 3,
},
+ functionbeat: {
+ functions: {
+ count: 4,
+ },
+ },
},
FlV4ckTxQ0a78hmBkzzc9A: {
count: 405,
diff --git a/x-pack/legacy/plugins/monitoring/server/telemetry_collection/get_beats_stats.js b/x-pack/legacy/plugins/monitoring/server/telemetry_collection/get_beats_stats.js
index 94f710d51cc35..5722228b60207 100644
--- a/x-pack/legacy/plugins/monitoring/server/telemetry_collection/get_beats_stats.js
+++ b/x-pack/legacy/plugins/monitoring/server/telemetry_collection/get_beats_stats.js
@@ -138,6 +138,23 @@ export function processResults(
}
}
+ const functionbeatState = get(hit, '_source.beats_state.state.functionbeat');
+ if (functionbeatState !== undefined) {
+ if (!clusters[clusterUuid].hasOwnProperty('functionbeat')) {
+ clusters[clusterUuid].functionbeat = {
+ functions: {
+ count: 0,
+ },
+ };
+ }
+
+ clusters[clusterUuid].functionbeat.functions.count += get(
+ functionbeatState,
+ 'functions.count',
+ 0
+ );
+ }
+
const stateHost = get(hit, '_source.beats_state.state.host');
if (stateHost !== undefined) {
const hostMap = clusterArchitectureMaps[clusterUuid];
diff --git a/x-pack/legacy/plugins/security/common/model.ts b/x-pack/legacy/plugins/security/common/model.ts
index 90e6a5403dfe8..733e89f774db8 100644
--- a/x-pack/legacy/plugins/security/common/model.ts
+++ b/x-pack/legacy/plugins/security/common/model.ts
@@ -11,12 +11,17 @@ export {
BuiltinESPrivileges,
EditUser,
FeaturesPrivileges,
+ InlineRoleTemplate,
+ InvalidRoleTemplate,
KibanaPrivileges,
RawKibanaFeaturePrivileges,
RawKibanaPrivileges,
Role,
RoleIndexPrivilege,
RoleKibanaPrivilege,
+ RoleMapping,
+ RoleTemplate,
+ StoredRoleTemplate,
User,
canUserChangePassword,
getUserDisplayName,
diff --git a/x-pack/legacy/plugins/security/public/lib/role_mappings_api.ts b/x-pack/legacy/plugins/security/public/lib/role_mappings_api.ts
new file mode 100644
index 0000000000000..b8bcba91388b5
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/lib/role_mappings_api.ts
@@ -0,0 +1,58 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { CoreSetup } from 'src/core/public';
+import { RoleMapping } from '../../common/model';
+
+interface CheckRoleMappingFeaturesResponse {
+ canManageRoleMappings: boolean;
+ canUseInlineScripts: boolean;
+ canUseStoredScripts: boolean;
+ hasCompatibleRealms: boolean;
+}
+
+type DeleteRoleMappingsResponse = Array<{
+ name: string;
+ success: boolean;
+ error?: Error;
+}>;
+
+export class RoleMappingsAPI {
+ constructor(private readonly http: CoreSetup['http']) {}
+
+ public async checkRoleMappingFeatures(): Promise {
+ return this.http.get(`/internal/security/_check_role_mapping_features`);
+ }
+
+ public async getRoleMappings(): Promise {
+ return this.http.get(`/internal/security/role_mapping`);
+ }
+
+ public async getRoleMapping(name: string): Promise {
+ return this.http.get(`/internal/security/role_mapping/${encodeURIComponent(name)}`);
+ }
+
+ public async saveRoleMapping(roleMapping: RoleMapping) {
+ const payload = { ...roleMapping };
+ delete payload.name;
+
+ return this.http.post(
+ `/internal/security/role_mapping/${encodeURIComponent(roleMapping.name)}`,
+ { body: JSON.stringify(payload) }
+ );
+ }
+
+ public async deleteRoleMappings(names: string[]): Promise {
+ return Promise.all(
+ names.map(name =>
+ this.http
+ .delete(`/internal/security/role_mapping/${encodeURIComponent(name)}`)
+ .then(() => ({ success: true, name }))
+ .catch(error => ({ success: false, name, error }))
+ )
+ );
+ }
+}
diff --git a/x-pack/legacy/plugins/security/public/views/management/_index.scss b/x-pack/legacy/plugins/security/public/views/management/_index.scss
index 104fed5980543..78b53845071e4 100644
--- a/x-pack/legacy/plugins/security/public/views/management/_index.scss
+++ b/x-pack/legacy/plugins/security/public/views/management/_index.scss
@@ -1,3 +1,4 @@
@import './change_password_form/index';
@import './edit_role/index';
-@import './edit_user/index';
\ No newline at end of file
+@import './edit_user/index';
+@import './role_mappings/edit_role_mapping/index';
\ No newline at end of file
diff --git a/x-pack/legacy/plugins/security/public/views/management/breadcrumbs.ts b/x-pack/legacy/plugins/security/public/views/management/breadcrumbs.ts
index 7d345ac13dc41..4ab7e45e84849 100644
--- a/x-pack/legacy/plugins/security/public/views/management/breadcrumbs.ts
+++ b/x-pack/legacy/plugins/security/public/views/management/breadcrumbs.ts
@@ -86,3 +86,30 @@ export function getApiKeysBreadcrumbs() {
},
];
}
+
+export function getRoleMappingBreadcrumbs() {
+ return [
+ MANAGEMENT_BREADCRUMB,
+ {
+ text: i18n.translate('xpack.security.roleMapping.breadcrumb', {
+ defaultMessage: 'Role Mappings',
+ }),
+ href: '#/management/security/role_mappings',
+ },
+ ];
+}
+
+export function getEditRoleMappingBreadcrumbs($route: Record) {
+ const { name } = $route.current.params;
+ return [
+ ...getRoleMappingBreadcrumbs(),
+ {
+ text:
+ name ||
+ i18n.translate('xpack.security.roleMappings.createBreadcrumb', {
+ defaultMessage: 'Create',
+ }),
+ href: `#/management/security/role_mappings/edit/${name}`,
+ },
+ ];
+}
diff --git a/x-pack/legacy/plugins/security/public/views/management/management.js b/x-pack/legacy/plugins/security/public/views/management/management.js
index 59da63abbb83f..f0369f232aeba 100644
--- a/x-pack/legacy/plugins/security/public/views/management/management.js
+++ b/x-pack/legacy/plugins/security/public/views/management/management.js
@@ -11,9 +11,11 @@ import 'plugins/security/views/management/roles_grid/roles';
import 'plugins/security/views/management/api_keys_grid/api_keys';
import 'plugins/security/views/management/edit_user/edit_user';
import 'plugins/security/views/management/edit_role/index';
+import 'plugins/security/views/management/role_mappings/role_mappings_grid';
+import 'plugins/security/views/management/role_mappings/edit_role_mapping';
import routes from 'ui/routes';
import { xpackInfo } from 'plugins/xpack_main/services/xpack_info';
-import { ROLES_PATH, USERS_PATH, API_KEYS_PATH } from './management_urls';
+import { ROLES_PATH, USERS_PATH, API_KEYS_PATH, ROLE_MAPPINGS_PATH } from './management_urls';
import { management } from 'ui/management';
import { npSetup } from 'ui/new_platform';
@@ -38,11 +40,23 @@ routes
resolve: {
securityManagementSection: function() {
const showSecurityLinks = xpackInfo.get('features.security.showLinks');
+ const showRoleMappingsManagementLink = xpackInfo.get(
+ 'features.security.showRoleMappingsManagement'
+ );
function deregisterSecurity() {
management.deregister('security');
}
+ function deregisterRoleMappingsManagement() {
+ if (management.hasItem('security')) {
+ const security = management.getSection('security');
+ if (security.hasItem('roleMappings')) {
+ security.deregister('roleMappings');
+ }
+ }
+ }
+
function ensureSecurityRegistered() {
const registerSecurity = () =>
management.register('security', {
@@ -88,11 +102,26 @@ routes
url: `#${API_KEYS_PATH}`,
});
}
+
+ if (showRoleMappingsManagementLink && !security.hasItem('roleMappings')) {
+ security.register('roleMappings', {
+ name: 'securityRoleMappingLink',
+ order: 30,
+ display: i18n.translate('xpack.security.management.roleMappingsTitle', {
+ defaultMessage: 'Role Mappings',
+ }),
+ url: `#${ROLE_MAPPINGS_PATH}`,
+ });
+ }
}
if (!showSecurityLinks) {
deregisterSecurity();
} else {
+ if (!showRoleMappingsManagementLink) {
+ deregisterRoleMappingsManagement();
+ }
+
// getCurrentUser will reject if there is no authenticated user, so we prevent them from
// seeing the security management screens.
return npSetup.plugins.security.authc
diff --git a/x-pack/legacy/plugins/security/public/views/management/management_urls.ts b/x-pack/legacy/plugins/security/public/views/management/management_urls.ts
index ea0cba9f7f3a7..881740c0b2895 100644
--- a/x-pack/legacy/plugins/security/public/views/management/management_urls.ts
+++ b/x-pack/legacy/plugins/security/public/views/management/management_urls.ts
@@ -12,3 +12,13 @@ export const CLONE_ROLES_PATH = `${ROLES_PATH}/clone`;
export const USERS_PATH = `${SECURITY_PATH}/users`;
export const EDIT_USERS_PATH = `${USERS_PATH}/edit`;
export const API_KEYS_PATH = `${SECURITY_PATH}/api_keys`;
+export const ROLE_MAPPINGS_PATH = `${SECURITY_PATH}/role_mappings`;
+export const CREATE_ROLE_MAPPING_PATH = `${ROLE_MAPPINGS_PATH}/edit`;
+
+export const getEditRoleHref = (roleName: string) =>
+ `#${EDIT_ROLES_PATH}/${encodeURIComponent(roleName)}`;
+
+export const getCreateRoleMappingHref = () => `#${CREATE_ROLE_MAPPING_PATH}`;
+
+export const getEditRoleMappingHref = (roleMappingName: string) =>
+ `#${CREATE_ROLE_MAPPING_PATH}/${encodeURIComponent(roleMappingName)}`;
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/delete_provider/delete_provider.test.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/delete_provider/delete_provider.test.tsx
new file mode 100644
index 0000000000000..b826d68053e27
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/delete_provider/delete_provider.test.tsx
@@ -0,0 +1,301 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers';
+import { DeleteProvider } from '.';
+import { RoleMappingsAPI } from '../../../../../lib/role_mappings_api';
+import { RoleMapping } from '../../../../../../common/model';
+import { EuiConfirmModal } from '@elastic/eui';
+import { findTestSubject } from 'test_utils/find_test_subject';
+import { act } from '@testing-library/react';
+import { toastNotifications } from 'ui/notify';
+
+jest.mock('ui/notify', () => {
+ return {
+ toastNotifications: {
+ addError: jest.fn(),
+ addSuccess: jest.fn(),
+ addDanger: jest.fn(),
+ },
+ };
+});
+
+describe('DeleteProvider', () => {
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('allows a single role mapping to be deleted', async () => {
+ const props = {
+ roleMappingsAPI: ({
+ deleteRoleMappings: jest.fn().mockReturnValue(
+ Promise.resolve([
+ {
+ name: 'delete-me',
+ success: true,
+ },
+ ])
+ ),
+ } as unknown) as RoleMappingsAPI,
+ };
+
+ const roleMappingsToDelete = [
+ {
+ name: 'delete-me',
+ },
+ ] as RoleMapping[];
+
+ const onSuccess = jest.fn();
+
+ const wrapper = mountWithIntl(
+
+ {onDelete => (
+ act(() => onDelete(roleMappingsToDelete, onSuccess))}>
+ initiate delete
+
+ )}
+
+ );
+
+ await act(async () => {
+ wrapper.find('#invoker').simulate('click');
+ await nextTick();
+ wrapper.update();
+ });
+
+ const { title, confirmButtonText } = wrapper.find(EuiConfirmModal).props();
+ expect(title).toMatchInlineSnapshot(`"Delete role mapping 'delete-me'?"`);
+ expect(confirmButtonText).toMatchInlineSnapshot(`"Delete role mapping"`);
+
+ await act(async () => {
+ findTestSubject(wrapper, 'confirmModalConfirmButton').simulate('click');
+ await nextTick();
+ wrapper.update();
+ });
+
+ expect(props.roleMappingsAPI.deleteRoleMappings).toHaveBeenCalledWith(['delete-me']);
+
+ const notifications = toastNotifications as jest.Mocked;
+ expect(notifications.addError).toHaveBeenCalledTimes(0);
+ expect(notifications.addDanger).toHaveBeenCalledTimes(0);
+ expect(notifications.addSuccess).toHaveBeenCalledTimes(1);
+ expect(notifications.addSuccess.mock.calls[0]).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "data-test-subj": "deletedRoleMappingSuccessToast",
+ "title": "Deleted role mapping 'delete-me'",
+ },
+ ]
+ `);
+ });
+
+ it('allows multiple role mappings to be deleted', async () => {
+ const props = {
+ roleMappingsAPI: ({
+ deleteRoleMappings: jest.fn().mockReturnValue(
+ Promise.resolve([
+ {
+ name: 'delete-me',
+ success: true,
+ },
+ {
+ name: 'delete-me-too',
+ success: true,
+ },
+ ])
+ ),
+ } as unknown) as RoleMappingsAPI,
+ };
+
+ const roleMappingsToDelete = [
+ {
+ name: 'delete-me',
+ },
+ {
+ name: 'delete-me-too',
+ },
+ ] as RoleMapping[];
+
+ const onSuccess = jest.fn();
+
+ const wrapper = mountWithIntl(
+
+ {onDelete => (
+ act(() => onDelete(roleMappingsToDelete, onSuccess))}>
+ initiate delete
+
+ )}
+
+ );
+
+ await act(async () => {
+ wrapper.find('#invoker').simulate('click');
+ await nextTick();
+ wrapper.update();
+ });
+
+ const { title, confirmButtonText } = wrapper.find(EuiConfirmModal).props();
+ expect(title).toMatchInlineSnapshot(`"Delete 2 role mappings?"`);
+ expect(confirmButtonText).toMatchInlineSnapshot(`"Delete role mappings"`);
+
+ await act(async () => {
+ findTestSubject(wrapper, 'confirmModalConfirmButton').simulate('click');
+ await nextTick();
+ wrapper.update();
+ });
+
+ expect(props.roleMappingsAPI.deleteRoleMappings).toHaveBeenCalledWith([
+ 'delete-me',
+ 'delete-me-too',
+ ]);
+ const notifications = toastNotifications as jest.Mocked;
+ expect(notifications.addError).toHaveBeenCalledTimes(0);
+ expect(notifications.addDanger).toHaveBeenCalledTimes(0);
+ expect(notifications.addSuccess).toHaveBeenCalledTimes(1);
+ expect(notifications.addSuccess.mock.calls[0]).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "data-test-subj": "deletedRoleMappingSuccessToast",
+ "title": "Deleted 2 role mappings",
+ },
+ ]
+ `);
+ });
+
+ it('handles mixed success/failure conditions', async () => {
+ const props = {
+ roleMappingsAPI: ({
+ deleteRoleMappings: jest.fn().mockReturnValue(
+ Promise.resolve([
+ {
+ name: 'delete-me',
+ success: true,
+ },
+ {
+ name: 'i-wont-work',
+ success: false,
+ error: new Error('something went wrong. sad.'),
+ },
+ ])
+ ),
+ } as unknown) as RoleMappingsAPI,
+ };
+
+ const roleMappingsToDelete = [
+ {
+ name: 'delete-me',
+ },
+ {
+ name: 'i-wont-work',
+ },
+ ] as RoleMapping[];
+
+ const onSuccess = jest.fn();
+
+ const wrapper = mountWithIntl(
+
+ {onDelete => (
+ act(() => onDelete(roleMappingsToDelete, onSuccess))}>
+ initiate delete
+
+ )}
+
+ );
+
+ await act(async () => {
+ wrapper.find('#invoker').simulate('click');
+ await nextTick();
+ wrapper.update();
+ });
+
+ await act(async () => {
+ findTestSubject(wrapper, 'confirmModalConfirmButton').simulate('click');
+ await nextTick();
+ wrapper.update();
+ });
+
+ expect(props.roleMappingsAPI.deleteRoleMappings).toHaveBeenCalledWith([
+ 'delete-me',
+ 'i-wont-work',
+ ]);
+
+ const notifications = toastNotifications as jest.Mocked;
+ expect(notifications.addError).toHaveBeenCalledTimes(0);
+ expect(notifications.addSuccess).toHaveBeenCalledTimes(1);
+ expect(notifications.addSuccess.mock.calls[0]).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "data-test-subj": "deletedRoleMappingSuccessToast",
+ "title": "Deleted role mapping 'delete-me'",
+ },
+ ]
+ `);
+
+ expect(notifications.addDanger).toHaveBeenCalledTimes(1);
+ expect(notifications.addDanger.mock.calls[0]).toMatchInlineSnapshot(`
+ Array [
+ "Error deleting role mapping 'i-wont-work'",
+ ]
+ `);
+ });
+
+ it('handles errors calling the API', async () => {
+ const props = {
+ roleMappingsAPI: ({
+ deleteRoleMappings: jest.fn().mockImplementation(() => {
+ throw new Error('AHHHHH');
+ }),
+ } as unknown) as RoleMappingsAPI,
+ };
+
+ const roleMappingsToDelete = [
+ {
+ name: 'delete-me',
+ },
+ ] as RoleMapping[];
+
+ const onSuccess = jest.fn();
+
+ const wrapper = mountWithIntl(
+
+ {onDelete => (
+ act(() => onDelete(roleMappingsToDelete, onSuccess))}>
+ initiate delete
+
+ )}
+
+ );
+
+ await act(async () => {
+ wrapper.find('#invoker').simulate('click');
+ await nextTick();
+ wrapper.update();
+ });
+
+ await act(async () => {
+ findTestSubject(wrapper, 'confirmModalConfirmButton').simulate('click');
+ await nextTick();
+ wrapper.update();
+ });
+
+ expect(props.roleMappingsAPI.deleteRoleMappings).toHaveBeenCalledWith(['delete-me']);
+
+ const notifications = toastNotifications as jest.Mocked;
+ expect(notifications.addDanger).toHaveBeenCalledTimes(0);
+ expect(notifications.addSuccess).toHaveBeenCalledTimes(0);
+
+ expect(notifications.addError).toHaveBeenCalledTimes(1);
+ expect(notifications.addError.mock.calls[0]).toMatchInlineSnapshot(`
+ Array [
+ [Error: AHHHHH],
+ Object {
+ "title": "Error deleting role mappings",
+ },
+ ]
+ `);
+ });
+});
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/delete_provider/delete_provider.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/delete_provider/delete_provider.tsx
new file mode 100644
index 0000000000000..2072cedeab462
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/delete_provider/delete_provider.tsx
@@ -0,0 +1,198 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React, { Fragment, useRef, useState, ReactElement } from 'react';
+import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui';
+import { toastNotifications } from 'ui/notify';
+import { i18n } from '@kbn/i18n';
+import { RoleMapping } from '../../../../../../common/model';
+import { RoleMappingsAPI } from '../../../../../lib/role_mappings_api';
+
+interface Props {
+ roleMappingsAPI: RoleMappingsAPI;
+ children: (deleteMappings: DeleteRoleMappings) => ReactElement;
+}
+
+export type DeleteRoleMappings = (
+ roleMappings: RoleMapping[],
+ onSuccess?: OnSuccessCallback
+) => void;
+
+type OnSuccessCallback = (deletedRoleMappings: string[]) => void;
+
+export const DeleteProvider: React.FunctionComponent = ({ roleMappingsAPI, children }) => {
+ const [roleMappings, setRoleMappings] = useState([]);
+ const [isModalOpen, setIsModalOpen] = useState(false);
+ const [isDeleteInProgress, setIsDeleteInProgress] = useState(false);
+
+ const onSuccessCallback = useRef(null);
+
+ const deleteRoleMappingsPrompt: DeleteRoleMappings = (
+ roleMappingsToDelete,
+ onSuccess = () => undefined
+ ) => {
+ if (!roleMappingsToDelete || !roleMappingsToDelete.length) {
+ throw new Error('No Role Mappings specified for delete');
+ }
+ setIsModalOpen(true);
+ setRoleMappings(roleMappingsToDelete);
+ onSuccessCallback.current = onSuccess;
+ };
+
+ const closeModal = () => {
+ setIsModalOpen(false);
+ setRoleMappings([]);
+ };
+
+ const deleteRoleMappings = async () => {
+ let result;
+
+ setIsDeleteInProgress(true);
+
+ try {
+ result = await roleMappingsAPI.deleteRoleMappings(roleMappings.map(rm => rm.name));
+ } catch (e) {
+ toastNotifications.addError(e, {
+ title: i18n.translate(
+ 'xpack.security.management.roleMappings.deleteRoleMapping.unknownError',
+ {
+ defaultMessage: 'Error deleting role mappings',
+ }
+ ),
+ });
+ setIsDeleteInProgress(false);
+ return;
+ }
+
+ setIsDeleteInProgress(false);
+
+ closeModal();
+
+ const successfulDeletes = result.filter(res => res.success);
+ const erroredDeletes = result.filter(res => !res.success);
+
+ // Surface success notifications
+ if (successfulDeletes.length > 0) {
+ const hasMultipleSuccesses = successfulDeletes.length > 1;
+ const successMessage = hasMultipleSuccesses
+ ? i18n.translate(
+ 'xpack.security.management.roleMappings.deleteRoleMapping.successMultipleNotificationTitle',
+ {
+ defaultMessage: 'Deleted {count} role mappings',
+ values: { count: successfulDeletes.length },
+ }
+ )
+ : i18n.translate(
+ 'xpack.security.management.roleMappings.deleteRoleMapping.successSingleNotificationTitle',
+ {
+ defaultMessage: "Deleted role mapping '{name}'",
+ values: { name: successfulDeletes[0].name },
+ }
+ );
+ toastNotifications.addSuccess({
+ title: successMessage,
+ 'data-test-subj': 'deletedRoleMappingSuccessToast',
+ });
+ if (onSuccessCallback.current) {
+ onSuccessCallback.current(successfulDeletes.map(({ name }) => name));
+ }
+ }
+
+ // Surface error notifications
+ if (erroredDeletes.length > 0) {
+ const hasMultipleErrors = erroredDeletes.length > 1;
+ const errorMessage = hasMultipleErrors
+ ? i18n.translate(
+ 'xpack.security.management.roleMappings.deleteRoleMapping.errorMultipleNotificationTitle',
+ {
+ defaultMessage: 'Error deleting {count} role mappings',
+ values: {
+ count: erroredDeletes.length,
+ },
+ }
+ )
+ : i18n.translate(
+ 'xpack.security.management.roleMappings.deleteRoleMapping.errorSingleNotificationTitle',
+ {
+ defaultMessage: "Error deleting role mapping '{name}'",
+ values: { name: erroredDeletes[0].name },
+ }
+ );
+ toastNotifications.addDanger(errorMessage);
+ }
+ };
+
+ const renderModal = () => {
+ if (!isModalOpen) {
+ return null;
+ }
+
+ const isSingle = roleMappings.length === 1;
+
+ return (
+
+
+ {!isSingle ? (
+
+
+ {i18n.translate(
+ 'xpack.security.management.roleMappings.deleteRoleMapping.confirmModal.deleteMultipleListDescription',
+ { defaultMessage: 'You are about to delete these role mappings:' }
+ )}
+
+
+ {roleMappings.map(({ name }) => (
+ {name}
+ ))}
+
+
+ ) : null}
+
+
+ );
+ };
+
+ return (
+
+ {children(deleteRoleMappingsPrompt)}
+ {renderModal()}
+
+ );
+};
diff --git a/x-pack/typings/encode_uri_query.d.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/delete_provider/index.ts
similarity index 54%
rename from x-pack/typings/encode_uri_query.d.ts
rename to x-pack/legacy/plugins/security/public/views/management/role_mappings/components/delete_provider/index.ts
index e1ab5f4a70abf..7e8b5a99c3bf5 100644
--- a/x-pack/typings/encode_uri_query.d.ts
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/delete_provider/index.ts
@@ -4,8 +4,4 @@
* you may not use this file except in compliance with the Elastic License.
*/
-declare module 'encode-uri-query' {
- function encodeUriQuery(query: string, usePercentageSpace?: boolean): string;
- // eslint-disable-next-line import/no-default-export
- export default encodeUriQuery;
-}
+export { DeleteProvider } from './delete_provider';
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/index.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/index.ts
new file mode 100644
index 0000000000000..315c1f7ec2baf
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/index.ts
@@ -0,0 +1,10 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export * from './delete_provider';
+export * from './no_compatible_realms';
+export * from './permission_denied';
+export * from './section_loading';
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/no_compatible_realms/index.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/no_compatible_realms/index.ts
new file mode 100644
index 0000000000000..fb2e5b40c1941
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/no_compatible_realms/index.ts
@@ -0,0 +1,7 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export { NoCompatibleRealms } from './no_compatible_realms';
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/no_compatible_realms/no_compatible_realms.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/no_compatible_realms/no_compatible_realms.tsx
new file mode 100644
index 0000000000000..969832b3ecbae
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/no_compatible_realms/no_compatible_realms.tsx
@@ -0,0 +1,38 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+import { EuiCallOut, EuiLink } from '@elastic/eui';
+import { FormattedMessage } from '@kbn/i18n/react';
+import { documentationLinks } from '../../services/documentation_links';
+
+export const NoCompatibleRealms: React.FunctionComponent = () => (
+
+ }
+ color="warning"
+ iconType="alert"
+ >
+
+
+
+ ),
+ }}
+ />
+
+);
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/permission_denied/index.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/permission_denied/index.ts
new file mode 100644
index 0000000000000..8b0bc67f3f777
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/permission_denied/index.ts
@@ -0,0 +1,7 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export { PermissionDenied } from './permission_denied';
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/permission_denied/permission_denied.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/permission_denied/permission_denied.tsx
new file mode 100644
index 0000000000000..1a32645eaedb9
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/permission_denied/permission_denied.tsx
@@ -0,0 +1,34 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import { EuiEmptyPrompt, EuiFlexGroup, EuiPageContent } from '@elastic/eui';
+import { FormattedMessage } from '@kbn/i18n/react';
+import React from 'react';
+
+export const PermissionDenied = () => (
+
+
+
+
+
+ }
+ body={
+
+
+
+ }
+ />
+
+
+);
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/section_loading/index.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/section_loading/index.ts
new file mode 100644
index 0000000000000..f59aa7a22d7c2
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/section_loading/index.ts
@@ -0,0 +1,7 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export { SectionLoading } from './section_loading';
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/section_loading/section_loading.test.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/section_loading/section_loading.test.tsx
new file mode 100644
index 0000000000000..300f6ca0e1f72
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/section_loading/section_loading.test.tsx
@@ -0,0 +1,40 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+import { shallowWithIntl } from 'test_utils/enzyme_helpers';
+import { SectionLoading } from '.';
+
+describe('SectionLoading', () => {
+ it('renders the default loading message', () => {
+ const wrapper = shallowWithIntl( );
+ expect(wrapper.props().body).toMatchInlineSnapshot(`
+
+
+
+ `);
+ });
+
+ it('renders the custom message when provided', () => {
+ const custom = hold your horses
;
+ const wrapper = shallowWithIntl({custom} );
+ expect(wrapper.props().body).toMatchInlineSnapshot(`
+
+
+ hold your horses
+
+
+ `);
+ });
+});
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/section_loading/section_loading.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/section_loading/section_loading.tsx
new file mode 100644
index 0000000000000..8ae87127ed3b2
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/section_loading/section_loading.tsx
@@ -0,0 +1,31 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+import { EuiEmptyPrompt, EuiLoadingSpinner, EuiText } from '@elastic/eui';
+import { FormattedMessage } from '@kbn/i18n/react';
+
+interface Props {
+ children?: React.ReactChild;
+}
+export const SectionLoading = (props: Props) => {
+ return (
+ }
+ body={
+
+ {props.children || (
+
+ )}
+
+ }
+ data-test-subj="sectionLoading"
+ />
+ );
+};
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/_index.scss b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/_index.scss
new file mode 100644
index 0000000000000..80e08ebcf1226
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/_index.scss
@@ -0,0 +1 @@
+@import './components/rule_editor_panel/index';
\ No newline at end of file
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/edit_role_mapping_page.test.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/edit_role_mapping_page.test.tsx
new file mode 100644
index 0000000000000..375a8d9f374a8
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/edit_role_mapping_page.test.tsx
@@ -0,0 +1,341 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers';
+import { findTestSubject } from 'test_utils/find_test_subject';
+
+// brace/ace uses the Worker class, which is not currently provided by JSDOM.
+// This is not required for the tests to pass, but it rather suppresses lengthy
+// warnings in the console which adds unnecessary noise to the test output.
+import 'test_utils/stub_web_worker';
+
+import { RoleMappingsAPI } from '../../../../../lib/role_mappings_api';
+import { EditRoleMappingPage } from '.';
+import { NoCompatibleRealms, SectionLoading, PermissionDenied } from '../../components';
+import { VisualRuleEditor } from './rule_editor_panel/visual_rule_editor';
+import { JSONRuleEditor } from './rule_editor_panel/json_rule_editor';
+import { EuiComboBox } from '@elastic/eui';
+
+jest.mock('../../../../../lib/roles_api', () => {
+ return {
+ RolesApi: {
+ getRoles: () => Promise.resolve([{ name: 'foo_role' }, { name: 'bar role' }]),
+ },
+ };
+});
+
+describe('EditRoleMappingPage', () => {
+ it('allows a role mapping to be created', async () => {
+ const roleMappingsAPI = ({
+ saveRoleMapping: jest.fn().mockResolvedValue(null),
+ checkRoleMappingFeatures: jest.fn().mockResolvedValue({
+ canManageRoleMappings: true,
+ hasCompatibleRealms: true,
+ canUseInlineScripts: true,
+ canUseStoredScripts: true,
+ }),
+ } as unknown) as RoleMappingsAPI;
+
+ const wrapper = mountWithIntl( );
+
+ await nextTick();
+ wrapper.update();
+
+ findTestSubject(wrapper, 'roleMappingFormNameInput').simulate('change', {
+ target: { value: 'my-role-mapping' },
+ });
+
+ (wrapper
+ .find(EuiComboBox)
+ .filter('[data-test-subj="roleMappingFormRoleComboBox"]')
+ .props() as any).onChange([{ label: 'foo_role' }]);
+
+ findTestSubject(wrapper, 'roleMappingsAddRuleButton').simulate('click');
+
+ findTestSubject(wrapper, 'saveRoleMappingButton').simulate('click');
+
+ expect(roleMappingsAPI.saveRoleMapping).toHaveBeenCalledWith({
+ name: 'my-role-mapping',
+ enabled: true,
+ roles: ['foo_role'],
+ role_templates: [],
+ rules: {
+ all: [{ field: { username: '*' } }],
+ },
+ metadata: {},
+ });
+ });
+
+ it('allows a role mapping to be updated', async () => {
+ const roleMappingsAPI = ({
+ saveRoleMapping: jest.fn().mockResolvedValue(null),
+ getRoleMapping: jest.fn().mockResolvedValue({
+ name: 'foo',
+ role_templates: [
+ {
+ template: { id: 'foo' },
+ },
+ ],
+ enabled: true,
+ rules: {
+ any: [{ field: { 'metadata.someCustomOption': [false, true, 'asdf'] } }],
+ },
+ metadata: {
+ foo: 'bar',
+ bar: 'baz',
+ },
+ }),
+ checkRoleMappingFeatures: jest.fn().mockResolvedValue({
+ canManageRoleMappings: true,
+ hasCompatibleRealms: true,
+ canUseInlineScripts: true,
+ canUseStoredScripts: true,
+ }),
+ } as unknown) as RoleMappingsAPI;
+
+ const wrapper = mountWithIntl(
+
+ );
+
+ await nextTick();
+ wrapper.update();
+
+ findTestSubject(wrapper, 'switchToRolesButton').simulate('click');
+
+ (wrapper
+ .find(EuiComboBox)
+ .filter('[data-test-subj="roleMappingFormRoleComboBox"]')
+ .props() as any).onChange([{ label: 'foo_role' }]);
+
+ findTestSubject(wrapper, 'roleMappingsAddRuleButton').simulate('click');
+ wrapper.find('button[id="addRuleOption"]').simulate('click');
+
+ findTestSubject(wrapper, 'saveRoleMappingButton').simulate('click');
+
+ expect(roleMappingsAPI.saveRoleMapping).toHaveBeenCalledWith({
+ name: 'foo',
+ enabled: true,
+ roles: ['foo_role'],
+ role_templates: [],
+ rules: {
+ any: [
+ { field: { 'metadata.someCustomOption': [false, true, 'asdf'] } },
+ { field: { username: '*' } },
+ ],
+ },
+ metadata: {
+ foo: 'bar',
+ bar: 'baz',
+ },
+ });
+ });
+
+ it('renders a permission denied message when unauthorized to manage role mappings', async () => {
+ const roleMappingsAPI = ({
+ checkRoleMappingFeatures: jest.fn().mockResolvedValue({
+ canManageRoleMappings: false,
+ hasCompatibleRealms: true,
+ }),
+ } as unknown) as RoleMappingsAPI;
+
+ const wrapper = mountWithIntl( );
+ expect(wrapper.find(SectionLoading)).toHaveLength(1);
+ expect(wrapper.find(PermissionDenied)).toHaveLength(0);
+
+ await nextTick();
+ wrapper.update();
+
+ expect(wrapper.find(SectionLoading)).toHaveLength(0);
+ expect(wrapper.find(NoCompatibleRealms)).toHaveLength(0);
+ expect(wrapper.find(PermissionDenied)).toHaveLength(1);
+ });
+
+ it('renders a warning when there are no compatible realms enabled', async () => {
+ const roleMappingsAPI = ({
+ checkRoleMappingFeatures: jest.fn().mockResolvedValue({
+ canManageRoleMappings: true,
+ hasCompatibleRealms: false,
+ }),
+ } as unknown) as RoleMappingsAPI;
+
+ const wrapper = mountWithIntl( );
+ expect(wrapper.find(SectionLoading)).toHaveLength(1);
+ expect(wrapper.find(NoCompatibleRealms)).toHaveLength(0);
+
+ await nextTick();
+ wrapper.update();
+
+ expect(wrapper.find(SectionLoading)).toHaveLength(0);
+ expect(wrapper.find(NoCompatibleRealms)).toHaveLength(1);
+ });
+
+ it('renders a warning when editing a mapping with a stored role template, when stored scripts are disabled', async () => {
+ const roleMappingsAPI = ({
+ getRoleMapping: jest.fn().mockResolvedValue({
+ name: 'foo',
+ role_templates: [
+ {
+ template: { id: 'foo' },
+ },
+ ],
+ enabled: true,
+ rules: {
+ field: { username: '*' },
+ },
+ }),
+ checkRoleMappingFeatures: jest.fn().mockResolvedValue({
+ canManageRoleMappings: true,
+ hasCompatibleRealms: true,
+ canUseInlineScripts: true,
+ canUseStoredScripts: false,
+ }),
+ } as unknown) as RoleMappingsAPI;
+
+ const wrapper = mountWithIntl(
+
+ );
+
+ expect(findTestSubject(wrapper, 'roleMappingInlineScriptsDisabled')).toHaveLength(0);
+ expect(findTestSubject(wrapper, 'roleMappingStoredScriptsDisabled')).toHaveLength(0);
+
+ await nextTick();
+ wrapper.update();
+
+ expect(findTestSubject(wrapper, 'roleMappingInlineScriptsDisabled')).toHaveLength(0);
+ expect(findTestSubject(wrapper, 'roleMappingStoredScriptsDisabled')).toHaveLength(1);
+ });
+
+ it('renders a warning when editing a mapping with an inline role template, when inline scripts are disabled', async () => {
+ const roleMappingsAPI = ({
+ getRoleMapping: jest.fn().mockResolvedValue({
+ name: 'foo',
+ role_templates: [
+ {
+ template: { source: 'foo' },
+ },
+ ],
+ enabled: true,
+ rules: {
+ field: { username: '*' },
+ },
+ }),
+ checkRoleMappingFeatures: jest.fn().mockResolvedValue({
+ canManageRoleMappings: true,
+ hasCompatibleRealms: true,
+ canUseInlineScripts: false,
+ canUseStoredScripts: true,
+ }),
+ } as unknown) as RoleMappingsAPI;
+
+ const wrapper = mountWithIntl(
+
+ );
+
+ expect(findTestSubject(wrapper, 'roleMappingInlineScriptsDisabled')).toHaveLength(0);
+ expect(findTestSubject(wrapper, 'roleMappingStoredScriptsDisabled')).toHaveLength(0);
+
+ await nextTick();
+ wrapper.update();
+
+ expect(findTestSubject(wrapper, 'roleMappingInlineScriptsDisabled')).toHaveLength(1);
+ expect(findTestSubject(wrapper, 'roleMappingStoredScriptsDisabled')).toHaveLength(0);
+ });
+
+ it('renders the visual editor by default for simple rule sets', async () => {
+ const roleMappingsAPI = ({
+ getRoleMapping: jest.fn().mockResolvedValue({
+ name: 'foo',
+ roles: ['superuser'],
+ enabled: true,
+ rules: {
+ all: [
+ {
+ field: {
+ username: '*',
+ },
+ },
+ {
+ field: {
+ dn: null,
+ },
+ },
+ {
+ field: {
+ realm: ['ldap', 'pki', null, 12],
+ },
+ },
+ ],
+ },
+ }),
+ checkRoleMappingFeatures: jest.fn().mockResolvedValue({
+ canManageRoleMappings: true,
+ hasCompatibleRealms: true,
+ canUseInlineScripts: true,
+ canUseStoredScripts: true,
+ }),
+ } as unknown) as RoleMappingsAPI;
+
+ const wrapper = mountWithIntl(
+
+ );
+
+ await nextTick();
+ wrapper.update();
+
+ expect(wrapper.find(VisualRuleEditor)).toHaveLength(1);
+ expect(wrapper.find(JSONRuleEditor)).toHaveLength(0);
+ });
+
+ it('renders the JSON editor by default for complex rule sets', async () => {
+ const createRule = (depth: number): Record => {
+ if (depth > 0) {
+ const rule = {
+ all: [
+ {
+ field: {
+ username: '*',
+ },
+ },
+ ],
+ } as Record;
+
+ const subRule = createRule(depth - 1);
+ if (subRule) {
+ rule.all.push(subRule);
+ }
+
+ return rule;
+ }
+ return null as any;
+ };
+
+ const roleMappingsAPI = ({
+ getRoleMapping: jest.fn().mockResolvedValue({
+ name: 'foo',
+ roles: ['superuser'],
+ enabled: true,
+ rules: createRule(10),
+ }),
+ checkRoleMappingFeatures: jest.fn().mockResolvedValue({
+ canManageRoleMappings: true,
+ hasCompatibleRealms: true,
+ canUseInlineScripts: true,
+ canUseStoredScripts: true,
+ }),
+ } as unknown) as RoleMappingsAPI;
+
+ const wrapper = mountWithIntl(
+
+ );
+
+ await nextTick();
+ wrapper.update();
+
+ expect(wrapper.find(VisualRuleEditor)).toHaveLength(0);
+ expect(wrapper.find(JSONRuleEditor)).toHaveLength(1);
+ });
+});
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/edit_role_mapping_page.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/edit_role_mapping_page.tsx
new file mode 100644
index 0000000000000..b8a75a4ad9fdf
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/edit_role_mapping_page.tsx
@@ -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;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React, { Component, Fragment } from 'react';
+import {
+ EuiForm,
+ EuiPageContent,
+ EuiSpacer,
+ EuiText,
+ EuiTitle,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiButtonEmpty,
+ EuiButton,
+ EuiLink,
+} from '@elastic/eui';
+import { i18n } from '@kbn/i18n';
+import { FormattedMessage } from '@kbn/i18n/react';
+import { toastNotifications } from 'ui/notify';
+import { RoleMapping } from '../../../../../../common/model';
+import { RoleMappingsAPI } from '../../../../../lib/role_mappings_api';
+import { RuleEditorPanel } from './rule_editor_panel';
+import {
+ NoCompatibleRealms,
+ PermissionDenied,
+ DeleteProvider,
+ SectionLoading,
+} from '../../components';
+import { ROLE_MAPPINGS_PATH } from '../../../management_urls';
+import { validateRoleMappingForSave } from '../services/role_mapping_validation';
+import { MappingInfoPanel } from './mapping_info_panel';
+import { documentationLinks } from '../../services/documentation_links';
+
+interface State {
+ loadState: 'loading' | 'permissionDenied' | 'ready' | 'saveInProgress';
+ roleMapping: RoleMapping | null;
+ hasCompatibleRealms: boolean;
+ canUseStoredScripts: boolean;
+ canUseInlineScripts: boolean;
+ formError: {
+ isInvalid: boolean;
+ error?: string;
+ };
+ validateForm: boolean;
+ rulesValid: boolean;
+}
+
+interface Props {
+ name?: string;
+ roleMappingsAPI: RoleMappingsAPI;
+}
+
+export class EditRoleMappingPage extends Component {
+ constructor(props: any) {
+ super(props);
+ this.state = {
+ loadState: 'loading',
+ roleMapping: null,
+ hasCompatibleRealms: true,
+ canUseStoredScripts: true,
+ canUseInlineScripts: true,
+ rulesValid: true,
+ validateForm: false,
+ formError: {
+ isInvalid: false,
+ },
+ };
+ }
+
+ public componentDidMount() {
+ this.loadAppData();
+ }
+
+ public render() {
+ const { loadState } = this.state;
+
+ if (loadState === 'permissionDenied') {
+ return ;
+ }
+
+ if (loadState === 'loading') {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+ {this.getFormTitle()}
+
+ this.setState({ roleMapping })}
+ mode={this.editingExistingRoleMapping() ? 'edit' : 'create'}
+ validateForm={this.state.validateForm}
+ canUseInlineScripts={this.state.canUseInlineScripts}
+ canUseStoredScripts={this.state.canUseStoredScripts}
+ />
+
+
+ this.setState({
+ roleMapping: {
+ ...this.state.roleMapping!,
+ rules,
+ },
+ })
+ }
+ />
+
+ {this.getFormButtons()}
+
+
+ );
+ }
+
+ private getFormTitle = () => {
+ return (
+
+
+
+ {this.editingExistingRoleMapping() ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+ ),
+ }}
+ />
+
+
+ {!this.state.hasCompatibleRealms && (
+ <>
+
+
+ >
+ )}
+
+ );
+ };
+
+ private getFormButtons = () => {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ {this.editingExistingRoleMapping() && (
+
+
+ {deleteRoleMappingsPrompt => {
+ return (
+
+ deleteRoleMappingsPrompt([this.state.roleMapping!], () =>
+ this.backToRoleMappingsList()
+ )
+ }
+ color="danger"
+ >
+
+
+ );
+ }}
+
+
+ )}
+
+ );
+ };
+
+ private onRuleValidityChange = (rulesValid: boolean) => {
+ this.setState({
+ rulesValid,
+ });
+ };
+
+ private saveRoleMapping = () => {
+ if (!this.state.roleMapping) {
+ return;
+ }
+
+ const { isInvalid } = validateRoleMappingForSave(this.state.roleMapping);
+ if (isInvalid) {
+ this.setState({ validateForm: true });
+ return;
+ }
+
+ const roleMappingName = this.state.roleMapping.name;
+
+ this.setState({
+ loadState: 'saveInProgress',
+ });
+
+ this.props.roleMappingsAPI
+ .saveRoleMapping(this.state.roleMapping)
+ .then(() => {
+ toastNotifications.addSuccess({
+ title: i18n.translate('xpack.security.management.editRoleMapping.saveSuccess', {
+ defaultMessage: `Saved role mapping '{roleMappingName}'`,
+ values: {
+ roleMappingName,
+ },
+ }),
+ 'data-test-subj': 'savedRoleMappingSuccessToast',
+ });
+ this.backToRoleMappingsList();
+ })
+ .catch(e => {
+ toastNotifications.addError(e, {
+ title: i18n.translate('xpack.security.management.editRoleMapping.saveError', {
+ defaultMessage: `Error saving role mapping`,
+ }),
+ toastMessage: e?.body?.message,
+ });
+
+ this.setState({
+ loadState: 'saveInProgress',
+ });
+ });
+ };
+
+ private editingExistingRoleMapping = () => typeof this.props.name === 'string';
+
+ private async loadAppData() {
+ try {
+ const [features, roleMapping] = await Promise.all([
+ this.props.roleMappingsAPI.checkRoleMappingFeatures(),
+ this.editingExistingRoleMapping()
+ ? this.props.roleMappingsAPI.getRoleMapping(this.props.name!)
+ : Promise.resolve({
+ name: '',
+ enabled: true,
+ metadata: {},
+ role_templates: [],
+ roles: [],
+ rules: {},
+ }),
+ ]);
+
+ const {
+ canManageRoleMappings,
+ canUseStoredScripts,
+ canUseInlineScripts,
+ hasCompatibleRealms,
+ } = features;
+
+ const loadState: State['loadState'] = canManageRoleMappings ? 'ready' : 'permissionDenied';
+
+ this.setState({
+ loadState,
+ hasCompatibleRealms,
+ canUseStoredScripts,
+ canUseInlineScripts,
+ roleMapping,
+ });
+ } catch (e) {
+ toastNotifications.addDanger({
+ title: i18n.translate(
+ 'xpack.security.management.editRoleMapping.table.fetchingRoleMappingsErrorMessage',
+ {
+ defaultMessage: 'Error loading role mapping editor: {message}',
+ values: { message: e?.body?.message ?? '' },
+ }
+ ),
+ 'data-test-subj': 'errorLoadingRoleMappingEditorToast',
+ });
+ this.backToRoleMappingsList();
+ }
+ }
+
+ private backToRoleMappingsList = () => {
+ window.location.hash = ROLE_MAPPINGS_PATH;
+ };
+}
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/index.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/index.ts
new file mode 100644
index 0000000000000..6758033f92d98
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/index.ts
@@ -0,0 +1,7 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export { EditRoleMappingPage } from './edit_role_mapping_page';
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/mapping_info_panel/index.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/mapping_info_panel/index.ts
new file mode 100644
index 0000000000000..5042499bf00ac
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/mapping_info_panel/index.ts
@@ -0,0 +1,7 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export { MappingInfoPanel } from './mapping_info_panel';
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/mapping_info_panel/mapping_info_panel.test.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/mapping_info_panel/mapping_info_panel.test.tsx
new file mode 100644
index 0000000000000..d821b33ace6a7
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/mapping_info_panel/mapping_info_panel.test.tsx
@@ -0,0 +1,220 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+import { mountWithIntl } from 'test_utils/enzyme_helpers';
+import { MappingInfoPanel } from '.';
+import { RoleMapping } from '../../../../../../../common/model';
+import { findTestSubject } from 'test_utils/find_test_subject';
+import { RoleSelector } from '../role_selector';
+import { RoleTemplateEditor } from '../role_selector/role_template_editor';
+
+jest.mock('../../../../../../lib/roles_api', () => {
+ return {
+ RolesApi: {
+ getRoles: () => Promise.resolve([{ name: 'foo_role' }, { name: 'bar role' }]),
+ },
+ };
+});
+
+describe('MappingInfoPanel', () => {
+ it('renders when creating a role mapping, default to the "roles" view', () => {
+ const props = {
+ roleMapping: {
+ name: 'my role mapping',
+ enabled: true,
+ roles: [],
+ role_templates: [],
+ rules: {},
+ metadata: {},
+ } as RoleMapping,
+ mode: 'create',
+ } as MappingInfoPanel['props'];
+
+ const wrapper = mountWithIntl( );
+
+ // Name input validation
+ const { value: nameInputValue, readOnly: nameInputReadOnly } = findTestSubject(
+ wrapper,
+ 'roleMappingFormNameInput'
+ )
+ .find('input')
+ .props();
+
+ expect(nameInputValue).toEqual(props.roleMapping.name);
+ expect(nameInputReadOnly).toEqual(false);
+
+ // Enabled switch validation
+ const { checked: enabledInputValue } = wrapper
+ .find('EuiSwitch[data-test-subj="roleMappingsEnabledSwitch"]')
+ .props();
+
+ expect(enabledInputValue).toEqual(props.roleMapping.enabled);
+
+ // Verify "roles" mode
+ expect(wrapper.find(RoleSelector).props()).toMatchObject({
+ mode: 'roles',
+ });
+ });
+
+ it('renders the role templates view if templates are provided', () => {
+ const props = {
+ roleMapping: {
+ name: 'my role mapping',
+ enabled: true,
+ roles: [],
+ role_templates: [
+ {
+ template: {
+ source: '',
+ },
+ },
+ ],
+ rules: {},
+ metadata: {},
+ } as RoleMapping,
+ mode: 'edit',
+ } as MappingInfoPanel['props'];
+
+ const wrapper = mountWithIntl( );
+
+ expect(wrapper.find(RoleSelector).props()).toMatchObject({
+ mode: 'templates',
+ });
+ });
+
+ it('renders a blank inline template by default when switching from roles to role templates', () => {
+ const props = {
+ roleMapping: {
+ name: 'my role mapping',
+ enabled: true,
+ roles: ['foo_role'],
+ role_templates: [],
+ rules: {},
+ metadata: {},
+ } as RoleMapping,
+ mode: 'create' as any,
+ onChange: jest.fn(),
+ canUseInlineScripts: true,
+ canUseStoredScripts: false,
+ validateForm: false,
+ };
+
+ const wrapper = mountWithIntl( );
+
+ findTestSubject(wrapper, 'switchToRoleTemplatesButton').simulate('click');
+
+ expect(props.onChange).toHaveBeenCalledWith({
+ name: 'my role mapping',
+ enabled: true,
+ roles: [],
+ role_templates: [
+ {
+ template: { source: '' },
+ },
+ ],
+ rules: {},
+ metadata: {},
+ });
+
+ wrapper.setProps({ roleMapping: props.onChange.mock.calls[0][0] });
+
+ expect(wrapper.find(RoleTemplateEditor)).toHaveLength(1);
+ });
+
+ it('renders a blank stored template by default when switching from roles to role templates and inline scripts are disabled', () => {
+ const props = {
+ roleMapping: {
+ name: 'my role mapping',
+ enabled: true,
+ roles: ['foo_role'],
+ role_templates: [],
+ rules: {},
+ metadata: {},
+ } as RoleMapping,
+ mode: 'create' as any,
+ onChange: jest.fn(),
+ canUseInlineScripts: false,
+ canUseStoredScripts: true,
+ validateForm: false,
+ };
+
+ const wrapper = mountWithIntl( );
+
+ findTestSubject(wrapper, 'switchToRoleTemplatesButton').simulate('click');
+
+ expect(props.onChange).toHaveBeenCalledWith({
+ name: 'my role mapping',
+ enabled: true,
+ roles: [],
+ role_templates: [
+ {
+ template: { id: '' },
+ },
+ ],
+ rules: {},
+ metadata: {},
+ });
+
+ wrapper.setProps({ roleMapping: props.onChange.mock.calls[0][0] });
+
+ expect(wrapper.find(RoleTemplateEditor)).toHaveLength(1);
+ });
+
+ it('does not create a blank role template if no script types are enabled', () => {
+ const props = {
+ roleMapping: {
+ name: 'my role mapping',
+ enabled: true,
+ roles: ['foo_role'],
+ role_templates: [],
+ rules: {},
+ metadata: {},
+ } as RoleMapping,
+ mode: 'create' as any,
+ onChange: jest.fn(),
+ canUseInlineScripts: false,
+ canUseStoredScripts: false,
+ validateForm: false,
+ };
+
+ const wrapper = mountWithIntl( );
+
+ findTestSubject(wrapper, 'switchToRoleTemplatesButton').simulate('click');
+
+ wrapper.update();
+
+ expect(props.onChange).not.toHaveBeenCalled();
+ expect(wrapper.find(RoleTemplateEditor)).toHaveLength(0);
+ });
+
+ it('renders the name input as readonly when editing an existing role mapping', () => {
+ const props = {
+ roleMapping: {
+ name: 'my role mapping',
+ enabled: true,
+ roles: [],
+ role_templates: [],
+ rules: {},
+ metadata: {},
+ } as RoleMapping,
+ mode: 'edit',
+ } as MappingInfoPanel['props'];
+
+ const wrapper = mountWithIntl( );
+
+ // Name input validation
+ const { value: nameInputValue, readOnly: nameInputReadOnly } = findTestSubject(
+ wrapper,
+ 'roleMappingFormNameInput'
+ )
+ .find('input')
+ .props();
+
+ expect(nameInputValue).toEqual(props.roleMapping.name);
+ expect(nameInputReadOnly).toEqual(true);
+ });
+});
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/mapping_info_panel/mapping_info_panel.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/mapping_info_panel/mapping_info_panel.tsx
new file mode 100644
index 0000000000000..a02b4fc1709f0
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/mapping_info_panel/mapping_info_panel.tsx
@@ -0,0 +1,323 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React, { Component, ChangeEvent, Fragment } from 'react';
+import {
+ EuiPanel,
+ EuiTitle,
+ EuiText,
+ EuiSpacer,
+ EuiDescribedFormGroup,
+ EuiFormRow,
+ EuiFieldText,
+ EuiLink,
+ EuiIcon,
+ EuiSwitch,
+} from '@elastic/eui';
+import { FormattedMessage } from '@kbn/i18n/react';
+import { RoleMapping } from '../../../../../../../common/model';
+import {
+ validateRoleMappingName,
+ validateRoleMappingRoles,
+ validateRoleMappingRoleTemplates,
+} from '../../services/role_mapping_validation';
+import { RoleSelector } from '../role_selector';
+import { documentationLinks } from '../../../services/documentation_links';
+
+interface Props {
+ roleMapping: RoleMapping;
+ onChange: (roleMapping: RoleMapping) => void;
+ mode: 'create' | 'edit';
+ validateForm: boolean;
+ canUseInlineScripts: boolean;
+ canUseStoredScripts: boolean;
+}
+
+interface State {
+ rolesMode: 'roles' | 'templates';
+}
+
+export class MappingInfoPanel extends Component {
+ constructor(props: Props) {
+ super(props);
+ this.state = {
+ rolesMode:
+ props.roleMapping.role_templates && props.roleMapping.role_templates.length > 0
+ ? 'templates'
+ : 'roles',
+ };
+ }
+ public render() {
+ return (
+
+
+
+
+
+
+
+ {this.getRoleMappingName()}
+ {this.getEnabledSwitch()}
+ {this.getRolesOrRoleTemplatesSelector()}
+
+ );
+ }
+
+ private getRoleMappingName = () => {
+ return (
+
+
+
+ }
+ description={
+
+ }
+ fullWidth
+ >
+
+ }
+ fullWidth
+ {...(this.props.validateForm && validateRoleMappingName(this.props.roleMapping))}
+ >
+
+
+
+ );
+ };
+
+ private getRolesOrRoleTemplatesSelector = () => {
+ if (this.state.rolesMode === 'roles') {
+ return this.getRolesSelector();
+ }
+ return this.getRoleTemplatesSelector();
+ };
+
+ private getRolesSelector = () => {
+ const validationFunction = () => {
+ if (!this.props.validateForm) {
+ return {};
+ }
+ return validateRoleMappingRoles(this.props.roleMapping);
+ };
+ return (
+
+
+
+ }
+ description={
+
+
+
+
+
+ {
+ this.onRolesModeChange('templates');
+ }}
+ >
+
+ {' '}
+
+
+
+
+ }
+ fullWidth
+ >
+
+ this.props.onChange(roleMapping)}
+ />
+
+
+ );
+ };
+
+ private getRoleTemplatesSelector = () => {
+ const validationFunction = () => {
+ if (!this.props.validateForm) {
+ return {};
+ }
+ return validateRoleMappingRoleTemplates(this.props.roleMapping);
+ };
+ return (
+
+
+
+ }
+ description={
+
+
+ {' '}
+
+
+
+
+
+ {
+ this.onRolesModeChange('roles');
+ }}
+ data-test-subj="switchToRolesButton"
+ >
+
+ {' '}
+
+
+
+
+ }
+ fullWidth
+ >
+
+ this.props.onChange(roleMapping)}
+ />
+
+
+ );
+ };
+
+ private getEnabledSwitch = () => {
+ return (
+
+
+
+ }
+ description={
+
+ }
+ fullWidth
+ >
+
+ }
+ fullWidth
+ >
+
+ }
+ showLabel={false}
+ data-test-subj="roleMappingsEnabledSwitch"
+ checked={this.props.roleMapping.enabled}
+ onChange={e => {
+ this.props.onChange({
+ ...this.props.roleMapping,
+ enabled: e.target.checked,
+ });
+ }}
+ />
+
+
+ );
+ };
+
+ private onNameChange = (e: ChangeEvent) => {
+ const name = e.target.value;
+
+ this.props.onChange({
+ ...this.props.roleMapping,
+ name,
+ });
+ };
+
+ private onRolesModeChange = (rolesMode: State['rolesMode']) => {
+ const canUseTemplates = this.props.canUseInlineScripts || this.props.canUseStoredScripts;
+ if (rolesMode === 'templates' && canUseTemplates) {
+ // Create blank template as a starting point
+ const defaultTemplate = this.props.canUseInlineScripts
+ ? {
+ template: { source: '' },
+ }
+ : {
+ template: { id: '' },
+ };
+ this.props.onChange({
+ ...this.props.roleMapping,
+ roles: [],
+ role_templates: [defaultTemplate],
+ });
+ }
+ this.setState({ rolesMode });
+ };
+}
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/role_selector/add_role_template_button.test.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/role_selector/add_role_template_button.test.tsx
new file mode 100644
index 0000000000000..230664f6fc997
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/role_selector/add_role_template_button.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;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+import { shallowWithIntl, mountWithIntl } from 'test_utils/enzyme_helpers';
+import { AddRoleTemplateButton } from './add_role_template_button';
+
+describe('AddRoleTemplateButton', () => {
+ it('renders a warning instead of a button if all script types are disabled', () => {
+ const wrapper = shallowWithIntl(
+
+ );
+
+ expect(wrapper).toMatchInlineSnapshot(`
+
+ }
+ >
+
+
+
+
+ `);
+ });
+
+ it(`asks for an inline template to be created if both script types are enabled`, () => {
+ const onClickHandler = jest.fn();
+ const wrapper = mountWithIntl(
+
+ );
+ wrapper.simulate('click');
+ expect(onClickHandler).toHaveBeenCalledTimes(1);
+ expect(onClickHandler).toHaveBeenCalledWith('inline');
+ });
+
+ it(`asks for a stored template to be created if inline scripts are disabled`, () => {
+ const onClickHandler = jest.fn();
+ const wrapper = mountWithIntl(
+
+ );
+ wrapper.simulate('click');
+ expect(onClickHandler).toHaveBeenCalledTimes(1);
+ expect(onClickHandler).toHaveBeenCalledWith('stored');
+ });
+});
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/role_selector/add_role_template_button.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/role_selector/add_role_template_button.tsx
new file mode 100644
index 0000000000000..5a78e399bacc7
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/role_selector/add_role_template_button.tsx
@@ -0,0 +1,67 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+import { EuiButtonEmpty, EuiCallOut } from '@elastic/eui';
+import { FormattedMessage } from '@kbn/i18n/react';
+
+interface Props {
+ canUseStoredScripts: boolean;
+ canUseInlineScripts: boolean;
+ onClick: (templateType: 'inline' | 'stored') => void;
+}
+
+export const AddRoleTemplateButton = (props: Props) => {
+ if (!props.canUseStoredScripts && !props.canUseInlineScripts) {
+ return (
+
+ }
+ >
+
+
+
+
+ );
+ }
+
+ const addRoleTemplate = (
+
+ );
+ if (props.canUseInlineScripts) {
+ return (
+ props.onClick('inline')}
+ data-test-subj="addRoleTemplateButton"
+ >
+ {addRoleTemplate}
+
+ );
+ }
+
+ return (
+ props.onClick('stored')}
+ data-test-subj="addRoleTemplateButton"
+ >
+ {addRoleTemplate}
+
+ );
+};
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/role_selector/index.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/role_selector/index.tsx
new file mode 100644
index 0000000000000..0011f6ea77bc6
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/role_selector/index.tsx
@@ -0,0 +1,7 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export { RoleSelector } from './role_selector';
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/role_selector/role_selector.test.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/role_selector/role_selector.test.tsx
new file mode 100644
index 0000000000000..89815c50e5547
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/role_selector/role_selector.test.tsx
@@ -0,0 +1,136 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+import { mountWithIntl } from 'test_utils/enzyme_helpers';
+import { findTestSubject } from 'test_utils/find_test_subject';
+import { EuiComboBox } from '@elastic/eui';
+import { RoleSelector } from './role_selector';
+import { RoleMapping } from '../../../../../../../common/model';
+import { RoleTemplateEditor } from './role_template_editor';
+import { AddRoleTemplateButton } from './add_role_template_button';
+
+jest.mock('../../../../../../lib/roles_api', () => {
+ return {
+ RolesApi: {
+ getRoles: () => Promise.resolve([{ name: 'foo_role' }, { name: 'bar role' }]),
+ },
+ };
+});
+
+describe('RoleSelector', () => {
+ it('allows roles to be selected, removing any previously selected role templates', () => {
+ const props = {
+ roleMapping: {
+ roles: [] as string[],
+ role_templates: [
+ {
+ template: { source: '' },
+ },
+ ],
+ } as RoleMapping,
+ canUseStoredScripts: true,
+ canUseInlineScripts: true,
+ onChange: jest.fn(),
+ mode: 'roles',
+ } as RoleSelector['props'];
+
+ const wrapper = mountWithIntl( );
+ (wrapper.find(EuiComboBox).props() as any).onChange([{ label: 'foo_role' }]);
+
+ expect(props.onChange).toHaveBeenCalledWith({
+ roles: ['foo_role'],
+ role_templates: [],
+ });
+ });
+
+ it('allows role templates to be created, removing any previously selected roles', () => {
+ const props = {
+ roleMapping: {
+ roles: ['foo_role'],
+ role_templates: [] as any,
+ } as RoleMapping,
+ canUseStoredScripts: true,
+ canUseInlineScripts: true,
+ onChange: jest.fn(),
+ mode: 'templates',
+ } as RoleSelector['props'];
+
+ const wrapper = mountWithIntl( );
+
+ wrapper.find(AddRoleTemplateButton).simulate('click');
+
+ expect(props.onChange).toHaveBeenCalledWith({
+ roles: [],
+ role_templates: [
+ {
+ template: { source: '' },
+ },
+ ],
+ });
+ });
+
+ it('allows role templates to be edited', () => {
+ const props = {
+ roleMapping: {
+ roles: [] as string[],
+ role_templates: [
+ {
+ template: { source: 'foo_role' },
+ },
+ ],
+ } as RoleMapping,
+ canUseStoredScripts: true,
+ canUseInlineScripts: true,
+ onChange: jest.fn(),
+ mode: 'templates',
+ } as RoleSelector['props'];
+
+ const wrapper = mountWithIntl( );
+
+ wrapper
+ .find(RoleTemplateEditor)
+ .props()
+ .onChange({
+ template: { source: '{{username}}_role' },
+ });
+
+ expect(props.onChange).toHaveBeenCalledWith({
+ roles: [],
+ role_templates: [
+ {
+ template: { source: '{{username}}_role' },
+ },
+ ],
+ });
+ });
+
+ it('allows role templates to be deleted', () => {
+ const props = {
+ roleMapping: {
+ roles: [] as string[],
+ role_templates: [
+ {
+ template: { source: 'foo_role' },
+ },
+ ],
+ } as RoleMapping,
+ canUseStoredScripts: true,
+ canUseInlineScripts: true,
+ onChange: jest.fn(),
+ mode: 'templates',
+ } as RoleSelector['props'];
+
+ const wrapper = mountWithIntl( );
+
+ findTestSubject(wrapper, 'deleteRoleTemplateButton').simulate('click');
+
+ expect(props.onChange).toHaveBeenCalledWith({
+ roles: [],
+ role_templates: [],
+ });
+ });
+});
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/role_selector/role_selector.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/role_selector/role_selector.tsx
new file mode 100644
index 0000000000000..6b92d6b4907f1
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/role_selector/role_selector.tsx
@@ -0,0 +1,132 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React, { Fragment } from 'react';
+import { i18n } from '@kbn/i18n';
+import { EuiComboBox, EuiFormRow, EuiHorizontalRule } from '@elastic/eui';
+import { RoleMapping, Role } from '../../../../../../../common/model';
+import { RolesApi } from '../../../../../../lib/roles_api';
+import { AddRoleTemplateButton } from './add_role_template_button';
+import { RoleTemplateEditor } from './role_template_editor';
+
+interface Props {
+ roleMapping: RoleMapping;
+ canUseInlineScripts: boolean;
+ canUseStoredScripts: boolean;
+ mode: 'roles' | 'templates';
+ onChange: (roleMapping: RoleMapping) => void;
+}
+
+interface State {
+ roles: Role[];
+}
+
+export class RoleSelector extends React.Component {
+ constructor(props: Props) {
+ super(props);
+
+ this.state = { roles: [] };
+ }
+
+ public async componentDidMount() {
+ const roles = await RolesApi.getRoles();
+ this.setState({ roles });
+ }
+
+ public render() {
+ const { mode } = this.props;
+ return (
+
+ {mode === 'roles' ? this.getRoleComboBox() : this.getRoleTemplates()}
+
+ );
+ }
+
+ private getRoleComboBox = () => {
+ const { roles = [] } = this.props.roleMapping;
+ return (
+ ({ label: r.name }))}
+ selectedOptions={roles!.map(r => ({ label: r }))}
+ onChange={selectedOptions => {
+ this.props.onChange({
+ ...this.props.roleMapping,
+ roles: selectedOptions.map(so => so.label),
+ role_templates: [],
+ });
+ }}
+ />
+ );
+ };
+
+ private getRoleTemplates = () => {
+ const { role_templates: roleTemplates = [] } = this.props.roleMapping;
+ return (
+
+ {roleTemplates.map((rt, index) => (
+
+ {
+ const templates = [...(this.props.roleMapping.role_templates || [])];
+ templates.splice(index, 1, updatedTemplate);
+ this.props.onChange({
+ ...this.props.roleMapping,
+ role_templates: templates,
+ });
+ }}
+ onDelete={() => {
+ const templates = [...(this.props.roleMapping.role_templates || [])];
+ templates.splice(index, 1);
+ this.props.onChange({
+ ...this.props.roleMapping,
+ role_templates: templates,
+ });
+ }}
+ />
+
+
+ ))}
+
{
+ switch (type) {
+ case 'inline': {
+ const templates = this.props.roleMapping.role_templates || [];
+ this.props.onChange({
+ ...this.props.roleMapping,
+ roles: [],
+ role_templates: [...templates, { template: { source: '' } }],
+ });
+ break;
+ }
+ case 'stored': {
+ const templates = this.props.roleMapping.role_templates || [];
+ this.props.onChange({
+ ...this.props.roleMapping,
+ roles: [],
+ role_templates: [...templates, { template: { id: '' } }],
+ });
+ break;
+ }
+ default:
+ throw new Error(`Unsupported template type: ${type}`);
+ }
+ }}
+ />
+
+ );
+ };
+}
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/role_selector/role_template_editor.test.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/role_selector/role_template_editor.test.tsx
new file mode 100644
index 0000000000000..6d4af97e12def
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/role_selector/role_template_editor.test.tsx
@@ -0,0 +1,117 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+import { mountWithIntl } from 'test_utils/enzyme_helpers';
+import { RoleTemplateEditor } from './role_template_editor';
+import { findTestSubject } from 'test_utils/find_test_subject';
+
+describe('RoleTemplateEditor', () => {
+ it('allows inline templates to be edited', () => {
+ const props = {
+ roleTemplate: {
+ template: {
+ source: '{{username}}_foo',
+ },
+ },
+ onChange: jest.fn(),
+ onDelete: jest.fn(),
+ canUseStoredScripts: true,
+ canUseInlineScripts: true,
+ };
+
+ const wrapper = mountWithIntl( );
+ (wrapper
+ .find('EuiFieldText[data-test-subj="roleTemplateSourceEditor"]')
+ .props() as any).onChange({ target: { value: 'new_script' } });
+
+ expect(props.onChange).toHaveBeenCalledWith({
+ template: {
+ source: 'new_script',
+ },
+ });
+ });
+
+ it('warns when editing inline scripts when they are disabled', () => {
+ const props = {
+ roleTemplate: {
+ template: {
+ source: '{{username}}_foo',
+ },
+ },
+ onChange: jest.fn(),
+ onDelete: jest.fn(),
+ canUseStoredScripts: true,
+ canUseInlineScripts: false,
+ };
+
+ const wrapper = mountWithIntl( );
+ expect(findTestSubject(wrapper, 'roleMappingInlineScriptsDisabled')).toHaveLength(1);
+ expect(findTestSubject(wrapper, 'roleMappingStoredScriptsDisabled')).toHaveLength(0);
+ expect(findTestSubject(wrapper, 'roleMappingInvalidRoleTemplate')).toHaveLength(0);
+ });
+
+ it('warns when editing stored scripts when they are disabled', () => {
+ const props = {
+ roleTemplate: {
+ template: {
+ id: '{{username}}_foo',
+ },
+ },
+ onChange: jest.fn(),
+ onDelete: jest.fn(),
+ canUseStoredScripts: false,
+ canUseInlineScripts: true,
+ };
+
+ const wrapper = mountWithIntl( );
+ expect(findTestSubject(wrapper, 'roleMappingInlineScriptsDisabled')).toHaveLength(0);
+ expect(findTestSubject(wrapper, 'roleMappingStoredScriptsDisabled')).toHaveLength(1);
+ expect(findTestSubject(wrapper, 'roleMappingInvalidRoleTemplate')).toHaveLength(0);
+ });
+
+ it('allows template types to be changed', () => {
+ const props = {
+ roleTemplate: {
+ template: {
+ source: '{{username}}_foo',
+ },
+ },
+ onChange: jest.fn(),
+ onDelete: jest.fn(),
+ canUseStoredScripts: true,
+ canUseInlineScripts: true,
+ };
+
+ const wrapper = mountWithIntl( );
+ (wrapper
+ .find('EuiComboBox[data-test-subj="roleMappingsFormTemplateType"]')
+ .props() as any).onChange('stored');
+
+ expect(props.onChange).toHaveBeenCalledWith({
+ template: {
+ id: '',
+ },
+ });
+ });
+
+ it('warns when an invalid role template is specified', () => {
+ const props = {
+ roleTemplate: {
+ template: `This is a string instead of an object if the template was stored in an unparsable format in ES`,
+ },
+ onChange: jest.fn(),
+ onDelete: jest.fn(),
+ canUseStoredScripts: true,
+ canUseInlineScripts: true,
+ };
+
+ const wrapper = mountWithIntl( );
+ expect(findTestSubject(wrapper, 'roleMappingInvalidRoleTemplate')).toHaveLength(1);
+ expect(findTestSubject(wrapper, 'roleTemplateSourceEditor')).toHaveLength(0);
+ expect(findTestSubject(wrapper, 'roleTemplateScriptIdEditor')).toHaveLength(0);
+ });
+});
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/role_selector/role_template_editor.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/role_selector/role_template_editor.tsx
new file mode 100644
index 0000000000000..4b8d34d271996
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/role_selector/role_template_editor.tsx
@@ -0,0 +1,254 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React, { Fragment } from 'react';
+import { FormattedMessage } from '@kbn/i18n/react';
+import {
+ EuiButtonEmpty,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiFormRow,
+ EuiFieldText,
+ EuiCallOut,
+ EuiText,
+ EuiSwitch,
+ EuiSpacer,
+} from '@elastic/eui';
+import { i18n } from '@kbn/i18n';
+import { RoleTemplate } from '../../../../../../../common/model';
+import {
+ isInlineRoleTemplate,
+ isStoredRoleTemplate,
+ isInvalidRoleTemplate,
+} from '../../services/role_template_type';
+import { RoleTemplateTypeSelect } from './role_template_type_select';
+
+interface Props {
+ roleTemplate: RoleTemplate;
+ canUseInlineScripts: boolean;
+ canUseStoredScripts: boolean;
+ onChange: (roleTemplate: RoleTemplate) => void;
+ onDelete: (roleTemplate: RoleTemplate) => void;
+}
+
+export const RoleTemplateEditor = ({
+ roleTemplate,
+ onChange,
+ onDelete,
+ canUseInlineScripts,
+ canUseStoredScripts,
+}: Props) => {
+ return (
+
+ {getTemplateConfigurationFields()}
+ {getEditorForTemplate()}
+
+
+
+
+ onDelete(roleTemplate)}
+ data-test-subj="deleteRoleTemplateButton"
+ >
+
+
+
+
+
+
+ );
+
+ function getTemplateFormatSwitch() {
+ const returnsJsonLabel = i18n.translate(
+ 'xpack.security.management.editRoleMapping.roleTemplateReturnsJson',
+ {
+ defaultMessage: 'Returns JSON',
+ }
+ );
+
+ return (
+
+ {
+ onChange({
+ ...roleTemplate,
+ format: e.target.checked ? 'json' : 'string',
+ });
+ }}
+ />
+
+ );
+ }
+
+ function getTemplateConfigurationFields() {
+ const templateTypeComboBox = (
+
+
+ }
+ >
+
+
+
+ );
+
+ const templateFormatSwitch = {getTemplateFormatSwitch()} ;
+
+ return (
+
+
+ {templateTypeComboBox}
+ {templateFormatSwitch}
+
+
+ );
+ }
+
+ function getEditorForTemplate() {
+ if (isInlineRoleTemplate(roleTemplate)) {
+ const extraProps: Record = {};
+ if (!canUseInlineScripts) {
+ extraProps.isInvalid = true;
+ extraProps.error = (
+
+
+
+ );
+ }
+ const example = '{{username}}_role';
+ return (
+
+
+
+ }
+ helpText={
+
+ }
+ {...extraProps}
+ >
+ {
+ onChange({
+ ...roleTemplate,
+ template: {
+ source: e.target.value,
+ },
+ });
+ }}
+ />
+
+
+
+ );
+ }
+
+ if (isStoredRoleTemplate(roleTemplate)) {
+ const extraProps: Record = {};
+ if (!canUseStoredScripts) {
+ extraProps.isInvalid = true;
+ extraProps.error = (
+
+
+
+ );
+ }
+ return (
+
+
+
+ }
+ helpText={
+
+ }
+ {...extraProps}
+ >
+ {
+ onChange({
+ ...roleTemplate,
+ template: {
+ id: e.target.value,
+ },
+ });
+ }}
+ />
+
+
+
+ );
+ }
+
+ if (isInvalidRoleTemplate(roleTemplate)) {
+ return (
+
+
+ }
+ >
+
+
+
+ );
+ }
+
+ throw new Error(`Unable to determine role template type`);
+ }
+};
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/role_selector/role_template_type_select.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/role_selector/role_template_type_select.tsx
new file mode 100644
index 0000000000000..4a06af0fb436b
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/role_selector/role_template_type_select.tsx
@@ -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;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+import { i18n } from '@kbn/i18n';
+import { EuiComboBox } from '@elastic/eui';
+import { RoleTemplate } from '../../../../../../../common/model';
+import { isInlineRoleTemplate, isStoredRoleTemplate } from '../../services/role_template_type';
+
+const templateTypeOptions = [
+ {
+ id: 'inline',
+ label: i18n.translate(
+ 'xpack.security.management.editRoleMapping.roleTemplate.inlineTypeLabel',
+ { defaultMessage: 'Role template' }
+ ),
+ },
+ {
+ id: 'stored',
+ label: i18n.translate(
+ 'xpack.security.management.editRoleMapping.roleTemplate.storedTypeLabel',
+ { defaultMessage: 'Stored script' }
+ ),
+ },
+];
+
+interface Props {
+ roleTemplate: RoleTemplate;
+ onChange: (roleTempplate: RoleTemplate) => void;
+ canUseStoredScripts: boolean;
+ canUseInlineScripts: boolean;
+}
+
+export const RoleTemplateTypeSelect = (props: Props) => {
+ const availableOptions = templateTypeOptions.filter(
+ ({ id }) =>
+ (id === 'inline' && props.canUseInlineScripts) ||
+ (id === 'stored' && props.canUseStoredScripts)
+ );
+
+ const selectedOptions = templateTypeOptions.filter(
+ ({ id }) =>
+ (id === 'inline' && isInlineRoleTemplate(props.roleTemplate)) ||
+ (id === 'stored' && isStoredRoleTemplate(props.roleTemplate))
+ );
+
+ return (
+ {
+ const [{ id }] = selected;
+ if (id === 'inline') {
+ props.onChange({
+ ...props.roleTemplate,
+ template: {
+ source: '',
+ },
+ });
+ } else {
+ props.onChange({
+ ...props.roleTemplate,
+ template: {
+ id: '',
+ },
+ });
+ }
+ }}
+ isClearable={false}
+ />
+ );
+};
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/_index.scss b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/_index.scss
new file mode 100644
index 0000000000000..de64b80599720
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/_index.scss
@@ -0,0 +1,7 @@
+.secRoleMapping__ruleEditorGroup--even {
+ background-color: $euiColorLightestShade;
+}
+
+.secRoleMapping__ruleEditorGroup--odd {
+ background-color: $euiColorEmptyShade;
+}
\ No newline at end of file
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/add_rule_button.test.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/add_rule_button.test.tsx
new file mode 100644
index 0000000000000..917b822acef3f
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/add_rule_button.test.tsx
@@ -0,0 +1,55 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+import { AddRuleButton } from './add_rule_button';
+import { mountWithIntl } from 'test_utils/enzyme_helpers';
+import { findTestSubject } from 'test_utils/find_test_subject';
+import { FieldRule, AllRule } from '../../../model';
+
+describe('AddRuleButton', () => {
+ it('allows a field rule to be created', () => {
+ const props = {
+ onClick: jest.fn(),
+ };
+
+ const wrapper = mountWithIntl( );
+ findTestSubject(wrapper, 'roleMappingsAddRuleButton').simulate('click');
+ expect(findTestSubject(wrapper, 'addRuleContextMenu')).toHaveLength(1);
+
+ // EUI renders this ID twice, so we need to target the button itself
+ wrapper.find('button[id="addRuleOption"]').simulate('click');
+
+ expect(props.onClick).toHaveBeenCalledTimes(1);
+
+ const [newRule] = props.onClick.mock.calls[0];
+ expect(newRule).toBeInstanceOf(FieldRule);
+ expect(newRule.toRaw()).toEqual({
+ field: { username: '*' },
+ });
+ });
+
+ it('allows a rule group to be created', () => {
+ const props = {
+ onClick: jest.fn(),
+ };
+
+ const wrapper = mountWithIntl( );
+ findTestSubject(wrapper, 'roleMappingsAddRuleButton').simulate('click');
+ expect(findTestSubject(wrapper, 'addRuleContextMenu')).toHaveLength(1);
+
+ // EUI renders this ID twice, so we need to target the button itself
+ wrapper.find('button[id="addRuleGroupOption"]').simulate('click');
+
+ expect(props.onClick).toHaveBeenCalledTimes(1);
+
+ const [newRule] = props.onClick.mock.calls[0];
+ expect(newRule).toBeInstanceOf(AllRule);
+ expect(newRule.toRaw()).toEqual({
+ all: [{ field: { username: '*' } }],
+ });
+ });
+});
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/add_rule_button.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/add_rule_button.tsx
new file mode 100644
index 0000000000000..100c0dd3eeaee
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/add_rule_button.tsx
@@ -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;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React, { useState } from 'react';
+import { EuiButtonEmpty, EuiPopover, EuiContextMenuPanel, EuiContextMenuItem } from '@elastic/eui';
+import { FormattedMessage } from '@kbn/i18n/react';
+import { Rule, FieldRule, AllRule } from '../../../model';
+
+interface Props {
+ onClick: (newRule: Rule) => void;
+}
+
+export const AddRuleButton = (props: Props) => {
+ const [isMenuOpen, setIsMenuOpen] = useState(false);
+
+ const button = (
+ {
+ setIsMenuOpen(!isMenuOpen);
+ }}
+ >
+
+
+ );
+
+ const options = [
+ {
+ setIsMenuOpen(false);
+ props.onClick(new FieldRule('username', '*'));
+ }}
+ >
+
+ ,
+ {
+ setIsMenuOpen(false);
+ props.onClick(new AllRule([new FieldRule('username', '*')]));
+ }}
+ >
+
+ ,
+ ];
+
+ return (
+
+ );
+};
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/field_rule_editor.test.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/field_rule_editor.test.tsx
new file mode 100644
index 0000000000000..8d5d5c99ee99d
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/field_rule_editor.test.tsx
@@ -0,0 +1,230 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+import { FieldRuleEditor } from './field_rule_editor';
+import { mountWithIntl } from 'test_utils/enzyme_helpers';
+import { FieldRule } from '../../../model';
+import { findTestSubject } from 'test_utils/find_test_subject';
+import { ReactWrapper } from 'enzyme';
+
+function assertField(wrapper: ReactWrapper, index: number, field: string) {
+ const isFirst = index === 0;
+ if (isFirst) {
+ expect(
+ wrapper.find(`EuiComboBox[data-test-subj~="fieldRuleEditorField-${index}"]`).props()
+ ).toMatchObject({
+ selectedOptions: [{ label: field }],
+ });
+
+ expect(findTestSubject(wrapper, `fieldRuleEditorField-${index}-combo`)).toHaveLength(1);
+ expect(findTestSubject(wrapper, `fieldRuleEditorField-${index}-expression`)).toHaveLength(0);
+ } else {
+ expect(
+ wrapper.find(`EuiExpression[data-test-subj~="fieldRuleEditorField-${index}"]`).props()
+ ).toMatchObject({
+ value: field,
+ });
+
+ expect(findTestSubject(wrapper, `fieldRuleEditorField-${index}-combo`)).toHaveLength(0);
+ expect(findTestSubject(wrapper, `fieldRuleEditorField-${index}-expression`)).toHaveLength(1);
+ }
+}
+
+function assertValueType(wrapper: ReactWrapper, index: number, type: string) {
+ const valueTypeField = findTestSubject(wrapper, `fieldRuleEditorValueType-${index}`);
+ expect(valueTypeField.props()).toMatchObject({ value: type });
+}
+
+function assertValue(wrapper: ReactWrapper, index: number, value: any) {
+ const valueField = findTestSubject(wrapper, `fieldRuleEditorValue-${index}`);
+ expect(valueField.props()).toMatchObject({ value });
+}
+
+describe('FieldRuleEditor', () => {
+ it('can render a text-based field rule', () => {
+ const props = {
+ rule: new FieldRule('username', '*'),
+ onChange: jest.fn(),
+ onDelete: jest.fn(),
+ };
+
+ const wrapper = mountWithIntl( );
+ assertField(wrapper, 0, 'username');
+ assertValueType(wrapper, 0, 'text');
+ assertValue(wrapper, 0, '*');
+ });
+
+ it('can render a number-based field rule', () => {
+ const props = {
+ rule: new FieldRule('username', 12),
+ onChange: jest.fn(),
+ onDelete: jest.fn(),
+ };
+
+ const wrapper = mountWithIntl( );
+ assertField(wrapper, 0, 'username');
+ assertValueType(wrapper, 0, 'number');
+ assertValue(wrapper, 0, 12);
+ });
+
+ it('can render a null-based field rule', () => {
+ const props = {
+ rule: new FieldRule('username', null),
+ onChange: jest.fn(),
+ onDelete: jest.fn(),
+ };
+
+ const wrapper = mountWithIntl( );
+ assertField(wrapper, 0, 'username');
+ assertValueType(wrapper, 0, 'null');
+ assertValue(wrapper, 0, '-- null --');
+ });
+
+ it('can render a boolean-based field rule (true)', () => {
+ const props = {
+ rule: new FieldRule('username', true),
+ onChange: jest.fn(),
+ onDelete: jest.fn(),
+ };
+
+ const wrapper = mountWithIntl( );
+ assertField(wrapper, 0, 'username');
+ assertValueType(wrapper, 0, 'boolean');
+ assertValue(wrapper, 0, 'true');
+ });
+
+ it('can render a boolean-based field rule (false)', () => {
+ const props = {
+ rule: new FieldRule('username', false),
+ onChange: jest.fn(),
+ onDelete: jest.fn(),
+ };
+
+ const wrapper = mountWithIntl( );
+ assertField(wrapper, 0, 'username');
+ assertValueType(wrapper, 0, 'boolean');
+ assertValue(wrapper, 0, 'false');
+ });
+
+ it('can render with alternate values specified', () => {
+ const props = {
+ rule: new FieldRule('username', ['*', 12, null, true, false]),
+ onChange: jest.fn(),
+ onDelete: jest.fn(),
+ };
+
+ const wrapper = mountWithIntl( );
+ expect(findTestSubject(wrapper, 'addAlternateValueButton')).toHaveLength(1);
+
+ assertField(wrapper, 0, 'username');
+ assertValueType(wrapper, 0, 'text');
+ assertValue(wrapper, 0, '*');
+
+ assertField(wrapper, 1, 'username');
+ assertValueType(wrapper, 1, 'number');
+ assertValue(wrapper, 1, 12);
+
+ assertField(wrapper, 2, 'username');
+ assertValueType(wrapper, 2, 'null');
+ assertValue(wrapper, 2, '-- null --');
+
+ assertField(wrapper, 3, 'username');
+ assertValueType(wrapper, 3, 'boolean');
+ assertValue(wrapper, 3, 'true');
+
+ assertField(wrapper, 4, 'username');
+ assertValueType(wrapper, 4, 'boolean');
+ assertValue(wrapper, 4, 'false');
+ });
+
+ it('allows alternate values to be added when "allowAdd" is set to true', () => {
+ const props = {
+ rule: new FieldRule('username', null),
+ onChange: jest.fn(),
+ onDelete: jest.fn(),
+ };
+
+ const wrapper = mountWithIntl( );
+ findTestSubject(wrapper, 'addAlternateValueButton').simulate('click');
+ expect(props.onChange).toHaveBeenCalledTimes(1);
+ const [updatedRule] = props.onChange.mock.calls[0];
+ expect(updatedRule.toRaw()).toEqual({
+ field: {
+ username: [null, '*'],
+ },
+ });
+ });
+
+ it('allows values to be deleted; deleting all values invokes "onDelete"', () => {
+ const props = {
+ rule: new FieldRule('username', ['*', 12, null]),
+ onChange: jest.fn(),
+ onDelete: jest.fn(),
+ };
+
+ const wrapper = mountWithIntl( );
+
+ expect(findTestSubject(wrapper, `fieldRuleEditorDeleteValue`)).toHaveLength(3);
+ findTestSubject(wrapper, `fieldRuleEditorDeleteValue-0`).simulate('click');
+
+ expect(props.onChange).toHaveBeenCalledTimes(1);
+ const [updatedRule1] = props.onChange.mock.calls[0];
+ expect(updatedRule1.toRaw()).toEqual({
+ field: {
+ username: [12, null],
+ },
+ });
+
+ props.onChange.mockReset();
+
+ // simulate updated rule being fed back in
+ wrapper.setProps({ rule: updatedRule1 });
+
+ expect(findTestSubject(wrapper, `fieldRuleEditorDeleteValue`)).toHaveLength(2);
+ findTestSubject(wrapper, `fieldRuleEditorDeleteValue-1`).simulate('click');
+
+ expect(props.onChange).toHaveBeenCalledTimes(1);
+ const [updatedRule2] = props.onChange.mock.calls[0];
+ expect(updatedRule2.toRaw()).toEqual({
+ field: {
+ username: [12],
+ },
+ });
+
+ props.onChange.mockReset();
+
+ // simulate updated rule being fed back in
+ wrapper.setProps({ rule: updatedRule2 });
+
+ expect(findTestSubject(wrapper, `fieldRuleEditorDeleteValue`)).toHaveLength(1);
+ findTestSubject(wrapper, `fieldRuleEditorDeleteValue-0`).simulate('click');
+
+ expect(props.onChange).toHaveBeenCalledTimes(0);
+ expect(props.onDelete).toHaveBeenCalledTimes(1);
+ });
+
+ it('allows field data types to be changed', () => {
+ const props = {
+ rule: new FieldRule('username', '*'),
+ onChange: jest.fn(),
+ onDelete: jest.fn(),
+ };
+
+ const wrapper = mountWithIntl( );
+
+ const { onChange } = findTestSubject(wrapper, `fieldRuleEditorValueType-0`).props();
+ onChange!({ target: { value: 'number' } as any } as any);
+
+ expect(props.onChange).toHaveBeenCalledTimes(1);
+ const [updatedRule] = props.onChange.mock.calls[0];
+ expect(updatedRule.toRaw()).toEqual({
+ field: {
+ username: 0,
+ },
+ });
+ });
+});
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/field_rule_editor.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/field_rule_editor.tsx
new file mode 100644
index 0000000000000..52cf70dbd12bd
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/field_rule_editor.tsx
@@ -0,0 +1,380 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React, { Component, ChangeEvent } from 'react';
+import {
+ EuiButtonIcon,
+ EuiExpression,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiFormRow,
+ EuiFieldText,
+ EuiComboBox,
+ EuiSelect,
+ EuiFieldNumber,
+ EuiIcon,
+} from '@elastic/eui';
+import { i18n } from '@kbn/i18n';
+import { FieldRule, FieldRuleValue } from '../../../model';
+
+interface Props {
+ rule: FieldRule;
+ onChange: (rule: FieldRule) => void;
+ onDelete: () => void;
+}
+
+const userFields = [
+ {
+ name: 'username',
+ },
+ {
+ name: 'dn',
+ },
+ {
+ name: 'groups',
+ },
+ {
+ name: 'realm',
+ },
+];
+
+const fieldOptions = userFields.map(f => ({ label: f.name }));
+
+type ComparisonOption = 'text' | 'number' | 'null' | 'boolean';
+const comparisonOptions: Record<
+ ComparisonOption,
+ { id: ComparisonOption; defaultValue: FieldRuleValue }
+> = {
+ text: {
+ id: 'text',
+ defaultValue: '*',
+ },
+ number: {
+ id: 'number',
+ defaultValue: 0,
+ },
+ null: {
+ id: 'null',
+ defaultValue: null,
+ },
+ boolean: {
+ id: 'boolean',
+ defaultValue: true,
+ },
+};
+
+export class FieldRuleEditor extends Component {
+ public render() {
+ const { field, value } = this.props.rule;
+
+ const content = Array.isArray(value)
+ ? value.map((v, index) => this.renderFieldRow(field, value, index))
+ : [this.renderFieldRow(field, value, 0)];
+
+ return (
+
+ {content.map((row, index) => {
+ return {row} ;
+ })}
+
+ );
+ }
+
+ private renderFieldRow = (field: string, ruleValue: FieldRuleValue, valueIndex: number) => {
+ const isPrimaryRow = valueIndex === 0;
+
+ let renderAddValueButton = true;
+ let rowRuleValue: FieldRuleValue = ruleValue;
+ if (Array.isArray(ruleValue)) {
+ renderAddValueButton = ruleValue.length - 1 === valueIndex;
+ rowRuleValue = ruleValue[valueIndex];
+ }
+
+ const comparisonType = this.getComparisonType(rowRuleValue);
+
+ return (
+
+
+ {isPrimaryRow ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+ {this.renderFieldTypeInput(comparisonType.id, valueIndex)}
+
+
+ {this.renderFieldValueInput(comparisonType.id, rowRuleValue, valueIndex)}
+
+
+
+ {renderAddValueButton ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ this.onRemoveAlternateValue(valueIndex)}
+ />
+
+
+
+ );
+ };
+
+ private renderFieldTypeInput = (inputType: ComparisonOption, valueIndex: number) => {
+ return (
+
+
+ this.onComparisonTypeChange(valueIndex, e.target.value as ComparisonOption)
+ }
+ />
+
+ );
+ };
+
+ private renderFieldValueInput = (
+ fieldType: ComparisonOption,
+ rowRuleValue: FieldRuleValue,
+ valueIndex: number
+ ) => {
+ const inputField = this.getInputFieldForType(fieldType, rowRuleValue, valueIndex);
+
+ return (
+
+ {inputField}
+
+ );
+ };
+
+ private getInputFieldForType = (
+ fieldType: ComparisonOption,
+ rowRuleValue: FieldRuleValue,
+ valueIndex: number
+ ) => {
+ const isNullValue = rowRuleValue === null;
+
+ const commonProps = {
+ 'data-test-subj': `fieldRuleEditorValue-${valueIndex}`,
+ };
+
+ switch (fieldType) {
+ case 'boolean':
+ return (
+
+ );
+ case 'text':
+ case 'null':
+ return (
+
+ );
+ case 'number':
+ return (
+
+ );
+ default:
+ throw new Error(`Unsupported input field type: ${fieldType}`);
+ }
+ };
+
+ private onAddAlternateValue = () => {
+ const { field, value } = this.props.rule;
+ const nextValue = Array.isArray(value) ? [...value] : [value];
+ nextValue.push('*');
+ this.props.onChange(new FieldRule(field, nextValue));
+ };
+
+ private onRemoveAlternateValue = (index: number) => {
+ const { field, value } = this.props.rule;
+
+ if (!Array.isArray(value) || value.length === 1) {
+ // Only one value left. Delete entire rule instead.
+ this.props.onDelete();
+ return;
+ }
+ const nextValue = [...value];
+ nextValue.splice(index, 1);
+ this.props.onChange(new FieldRule(field, nextValue));
+ };
+
+ private onFieldChange = ([newField]: Array<{ label: string }>) => {
+ if (!newField) {
+ return;
+ }
+
+ const { value } = this.props.rule;
+ this.props.onChange(new FieldRule(newField.label, value));
+ };
+
+ private onAddField = (newField: string) => {
+ const { value } = this.props.rule;
+ this.props.onChange(new FieldRule(newField, value));
+ };
+
+ private onValueChange = (index: number) => (e: ChangeEvent) => {
+ const { field, value } = this.props.rule;
+ let nextValue;
+ if (Array.isArray(value)) {
+ nextValue = [...value];
+ nextValue.splice(index, 1, e.target.value);
+ } else {
+ nextValue = e.target.value;
+ }
+ this.props.onChange(new FieldRule(field, nextValue));
+ };
+
+ private onNumericValueChange = (index: number) => (e: ChangeEvent) => {
+ const { field, value } = this.props.rule;
+ let nextValue;
+ if (Array.isArray(value)) {
+ nextValue = [...value];
+ nextValue.splice(index, 1, parseFloat(e.target.value));
+ } else {
+ nextValue = parseFloat(e.target.value);
+ }
+ this.props.onChange(new FieldRule(field, nextValue));
+ };
+
+ private onBooleanValueChange = (index: number) => (e: ChangeEvent) => {
+ const boolValue = e.target.value === 'true';
+
+ const { field, value } = this.props.rule;
+ let nextValue;
+ if (Array.isArray(value)) {
+ nextValue = [...value];
+ nextValue.splice(index, 1, boolValue);
+ } else {
+ nextValue = boolValue;
+ }
+ this.props.onChange(new FieldRule(field, nextValue));
+ };
+
+ private onComparisonTypeChange = (index: number, newType: ComparisonOption) => {
+ const comparison = comparisonOptions[newType];
+ if (!comparison) {
+ throw new Error(`Unexpected comparison type: ${newType}`);
+ }
+ const { field, value } = this.props.rule;
+ let nextValue = value;
+ if (Array.isArray(value)) {
+ nextValue = [...value];
+ nextValue.splice(index, 1, comparison.defaultValue as any);
+ } else {
+ nextValue = comparison.defaultValue;
+ }
+ this.props.onChange(new FieldRule(field, nextValue));
+ };
+
+ private getComparisonType(ruleValue: FieldRuleValue) {
+ const valueType = typeof ruleValue;
+ if (valueType === 'string' || valueType === 'undefined') {
+ return comparisonOptions.text;
+ }
+ if (valueType === 'number') {
+ return comparisonOptions.number;
+ }
+ if (valueType === 'boolean') {
+ return comparisonOptions.boolean;
+ }
+ if (ruleValue === null) {
+ return comparisonOptions.null;
+ }
+ throw new Error(`Unable to detect comparison type for rule value [${ruleValue}]`);
+ }
+}
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/index.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/index.tsx
new file mode 100644
index 0000000000000..dc09cb1e591fa
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/index.tsx
@@ -0,0 +1,7 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export { RuleEditorPanel } from './rule_editor_panel';
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/json_rule_editor.test.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/json_rule_editor.test.tsx
new file mode 100644
index 0000000000000..8a9b37ab0f406
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/json_rule_editor.test.tsx
@@ -0,0 +1,164 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import 'brace';
+import 'brace/mode/json';
+
+// brace/ace uses the Worker class, which is not currently provided by JSDOM.
+// This is not required for the tests to pass, but it rather suppresses lengthy
+// warnings in the console which adds unnecessary noise to the test output.
+import 'test_utils/stub_web_worker';
+
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import { mountWithIntl } from 'test_utils/enzyme_helpers';
+import { JSONRuleEditor } from './json_rule_editor';
+import { EuiCodeEditor } from '@elastic/eui';
+import { AllRule, AnyRule, FieldRule, ExceptAnyRule, ExceptAllRule } from '../../../model';
+
+describe('JSONRuleEditor', () => {
+ it('renders an empty rule set', () => {
+ const props = {
+ rules: null,
+ onChange: jest.fn(),
+ onValidityChange: jest.fn(),
+ };
+ const wrapper = mountWithIntl( );
+
+ expect(props.onChange).not.toHaveBeenCalled();
+ expect(props.onValidityChange).not.toHaveBeenCalled();
+
+ expect(wrapper.find(EuiCodeEditor).props().value).toMatchInlineSnapshot(`"{}"`);
+ });
+
+ it('renders a rule set', () => {
+ const props = {
+ rules: new AllRule([
+ new AnyRule([new FieldRule('username', '*')]),
+ new ExceptAnyRule([
+ new FieldRule('metadata.foo.bar', '*'),
+ new AllRule([new FieldRule('realm', 'special-one')]),
+ ]),
+ new ExceptAllRule([new FieldRule('realm', '*')]),
+ ]),
+ onChange: jest.fn(),
+ onValidityChange: jest.fn(),
+ };
+ const wrapper = mountWithIntl( );
+
+ const { value } = wrapper.find(EuiCodeEditor).props();
+ expect(JSON.parse(value)).toEqual({
+ all: [
+ {
+ any: [{ field: { username: '*' } }],
+ },
+ {
+ except: {
+ any: [
+ { field: { 'metadata.foo.bar': '*' } },
+ {
+ all: [{ field: { realm: 'special-one' } }],
+ },
+ ],
+ },
+ },
+ {
+ except: {
+ all: [{ field: { realm: '*' } }],
+ },
+ },
+ ],
+ });
+ });
+
+ it('notifies when input contains invalid JSON', () => {
+ const props = {
+ rules: null,
+ onChange: jest.fn(),
+ onValidityChange: jest.fn(),
+ };
+ const wrapper = mountWithIntl( );
+
+ const allRule = JSON.stringify(new AllRule().toRaw());
+ act(() => {
+ wrapper
+ .find(EuiCodeEditor)
+ .props()
+ .onChange(allRule + ', this makes invalid JSON');
+ });
+
+ expect(props.onValidityChange).toHaveBeenCalledTimes(1);
+ expect(props.onValidityChange).toHaveBeenCalledWith(false);
+ expect(props.onChange).not.toHaveBeenCalled();
+ });
+
+ it('notifies when input contains an invalid rule set, even if it is valid JSON', () => {
+ const props = {
+ rules: null,
+ onChange: jest.fn(),
+ onValidityChange: jest.fn(),
+ };
+ const wrapper = mountWithIntl( );
+
+ const invalidRule = JSON.stringify({
+ all: [
+ {
+ field: {
+ foo: {},
+ },
+ },
+ ],
+ });
+
+ act(() => {
+ wrapper
+ .find(EuiCodeEditor)
+ .props()
+ .onChange(invalidRule);
+ });
+
+ expect(props.onValidityChange).toHaveBeenCalledTimes(1);
+ expect(props.onValidityChange).toHaveBeenCalledWith(false);
+ expect(props.onChange).not.toHaveBeenCalled();
+ });
+
+ it('fires onChange when a valid rule set is provided after being previously invalidated', () => {
+ const props = {
+ rules: null,
+ onChange: jest.fn(),
+ onValidityChange: jest.fn(),
+ };
+ const wrapper = mountWithIntl( );
+
+ const allRule = JSON.stringify(new AllRule().toRaw());
+ act(() => {
+ wrapper
+ .find(EuiCodeEditor)
+ .props()
+ .onChange(allRule + ', this makes invalid JSON');
+ });
+
+ expect(props.onValidityChange).toHaveBeenCalledTimes(1);
+ expect(props.onValidityChange).toHaveBeenCalledWith(false);
+ expect(props.onChange).not.toHaveBeenCalled();
+
+ props.onValidityChange.mockReset();
+
+ act(() => {
+ wrapper
+ .find(EuiCodeEditor)
+ .props()
+ .onChange(allRule);
+ });
+
+ expect(props.onValidityChange).toHaveBeenCalledTimes(1);
+ expect(props.onValidityChange).toHaveBeenCalledWith(true);
+
+ expect(props.onChange).toHaveBeenCalledTimes(1);
+ const [updatedRule] = props.onChange.mock.calls[0];
+ expect(JSON.stringify(updatedRule.toRaw())).toEqual(allRule);
+ });
+});
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/json_rule_editor.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/json_rule_editor.tsx
new file mode 100644
index 0000000000000..371fb59f7a5d1
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/json_rule_editor.tsx
@@ -0,0 +1,127 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React, { useState, Fragment } from 'react';
+
+import 'brace/mode/json';
+import 'brace/theme/github';
+import { EuiCodeEditor, EuiFormRow, EuiButton, EuiSpacer, EuiLink, EuiText } from '@elastic/eui';
+import { FormattedMessage } from '@kbn/i18n/react';
+import { i18n } from '@kbn/i18n';
+import { Rule, RuleBuilderError, generateRulesFromRaw } from '../../../model';
+import { documentationLinks } from '../../../services/documentation_links';
+
+interface Props {
+ rules: Rule | null;
+ onChange: (updatedRules: Rule | null) => void;
+ onValidityChange: (isValid: boolean) => void;
+}
+
+export const JSONRuleEditor = (props: Props) => {
+ const [rawRules, setRawRules] = useState(
+ JSON.stringify(props.rules ? props.rules.toRaw() : {}, null, 2)
+ );
+
+ const [ruleBuilderError, setRuleBuilderError] = useState(null);
+
+ function onRulesChange(updatedRules: string) {
+ setRawRules(updatedRules);
+ // Fire onChange only if rules are valid
+ try {
+ const ruleJSON = JSON.parse(updatedRules);
+ props.onChange(generateRulesFromRaw(ruleJSON).rules);
+ props.onValidityChange(true);
+ setRuleBuilderError(null);
+ } catch (e) {
+ if (e instanceof RuleBuilderError) {
+ setRuleBuilderError(e);
+ } else {
+ setRuleBuilderError(null);
+ }
+ props.onValidityChange(false);
+ }
+ }
+
+ function reformatRules() {
+ try {
+ const ruleJSON = JSON.parse(rawRules);
+ setRawRules(JSON.stringify(ruleJSON, null, 2));
+ } catch (ignore) {
+ // ignore
+ }
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ),
+ }}
+ />
+
+
+
+
+ );
+};
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/rule_editor_panel.test.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/rule_editor_panel.test.tsx
new file mode 100644
index 0000000000000..809264183d30c
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/rule_editor_panel.test.tsx
@@ -0,0 +1,114 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+import { mountWithIntl } from 'test_utils/enzyme_helpers';
+import { RuleEditorPanel } from '.';
+import { VisualRuleEditor } from './visual_rule_editor';
+import { JSONRuleEditor } from './json_rule_editor';
+import { findTestSubject } from 'test_utils/find_test_subject';
+
+// brace/ace uses the Worker class, which is not currently provided by JSDOM.
+// This is not required for the tests to pass, but it rather suppresses lengthy
+// warnings in the console which adds unnecessary noise to the test output.
+import 'test_utils/stub_web_worker';
+import { AllRule, FieldRule } from '../../../model';
+import { EuiErrorBoundary } from '@elastic/eui';
+
+describe('RuleEditorPanel', () => {
+ it('renders the visual editor when no rules are defined', () => {
+ const props = {
+ rawRules: {},
+ onChange: jest.fn(),
+ onValidityChange: jest.fn(),
+ validateForm: false,
+ };
+ const wrapper = mountWithIntl( );
+ expect(wrapper.find(VisualRuleEditor)).toHaveLength(1);
+ expect(wrapper.find(JSONRuleEditor)).toHaveLength(0);
+ });
+
+ it('allows switching to the JSON editor, carrying over rules', () => {
+ const props = {
+ rawRules: {
+ all: [
+ {
+ field: {
+ username: ['*'],
+ },
+ },
+ ],
+ },
+ onChange: jest.fn(),
+ onValidityChange: jest.fn(),
+ validateForm: false,
+ };
+ const wrapper = mountWithIntl( );
+ expect(wrapper.find(VisualRuleEditor)).toHaveLength(1);
+ expect(wrapper.find(JSONRuleEditor)).toHaveLength(0);
+
+ findTestSubject(wrapper, 'roleMappingsJSONRuleEditorButton').simulate('click');
+
+ expect(wrapper.find(VisualRuleEditor)).toHaveLength(0);
+
+ const jsonEditor = wrapper.find(JSONRuleEditor);
+ expect(jsonEditor).toHaveLength(1);
+ const { rules } = jsonEditor.props();
+ expect(rules!.toRaw()).toEqual(props.rawRules);
+ });
+
+ it('allows switching to the visual editor, carrying over rules', () => {
+ const props = {
+ rawRules: {
+ field: { username: '*' },
+ },
+ onChange: jest.fn(),
+ onValidityChange: jest.fn(),
+ validateForm: false,
+ };
+ const wrapper = mountWithIntl( );
+
+ findTestSubject(wrapper, 'roleMappingsJSONRuleEditorButton').simulate('click');
+
+ expect(wrapper.find(VisualRuleEditor)).toHaveLength(0);
+ expect(wrapper.find(JSONRuleEditor)).toHaveLength(1);
+
+ const jsonEditor = wrapper.find(JSONRuleEditor);
+ expect(jsonEditor).toHaveLength(1);
+ const { rules: initialRules, onChange } = jsonEditor.props();
+ expect(initialRules?.toRaw()).toEqual({
+ field: { username: '*' },
+ });
+
+ onChange(new AllRule([new FieldRule('otherRule', 12)]));
+
+ findTestSubject(wrapper, 'roleMappingsVisualRuleEditorButton').simulate('click');
+
+ expect(wrapper.find(VisualRuleEditor)).toHaveLength(1);
+ expect(wrapper.find(JSONRuleEditor)).toHaveLength(0);
+
+ expect(props.onChange).toHaveBeenCalledTimes(1);
+ const [rules] = props.onChange.mock.calls[0];
+ expect(rules).toEqual({
+ all: [{ field: { otherRule: 12 } }],
+ });
+ });
+
+ it('catches errors thrown by child components', () => {
+ const props = {
+ rawRules: {},
+ onChange: jest.fn(),
+ onValidityChange: jest.fn(),
+ validateForm: false,
+ };
+ const wrapper = mountWithIntl( );
+
+ wrapper.find(VisualRuleEditor).simulateError(new Error('Something awful happened here.'));
+
+ expect(wrapper.find(VisualRuleEditor)).toHaveLength(0);
+ expect(wrapper.find(EuiErrorBoundary)).toHaveLength(1);
+ });
+});
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/rule_editor_panel.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/rule_editor_panel.tsx
new file mode 100644
index 0000000000000..4aab49b2b2efc
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/rule_editor_panel.tsx
@@ -0,0 +1,298 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React, { Component, Fragment } from 'react';
+import {
+ EuiSpacer,
+ EuiConfirmModal,
+ EuiOverlayMask,
+ EuiCallOut,
+ EuiErrorBoundary,
+ EuiIcon,
+ EuiLink,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiText,
+ EuiFormRow,
+ EuiPanel,
+ EuiTitle,
+} from '@elastic/eui';
+import { FormattedMessage } from '@kbn/i18n/react';
+import { i18n } from '@kbn/i18n';
+import { RoleMapping } from '../../../../../../../common/model';
+import { VisualRuleEditor } from './visual_rule_editor';
+import { JSONRuleEditor } from './json_rule_editor';
+import { VISUAL_MAX_RULE_DEPTH } from '../../services/role_mapping_constants';
+import { Rule, generateRulesFromRaw } from '../../../model';
+import { validateRoleMappingRules } from '../../services/role_mapping_validation';
+import { documentationLinks } from '../../../services/documentation_links';
+
+interface Props {
+ rawRules: RoleMapping['rules'];
+ onChange: (rawRules: RoleMapping['rules']) => void;
+ onValidityChange: (isValid: boolean) => void;
+ validateForm: boolean;
+}
+
+interface State {
+ rules: Rule | null;
+ maxDepth: number;
+ isRuleValid: boolean;
+ showConfirmModeChange: boolean;
+ showVisualEditorDisabledAlert: boolean;
+ mode: 'visual' | 'json';
+}
+
+export class RuleEditorPanel extends Component {
+ constructor(props: Props) {
+ super(props);
+ this.state = {
+ ...this.initializeFromRawRules(props.rawRules),
+ isRuleValid: true,
+ showConfirmModeChange: false,
+ showVisualEditorDisabledAlert: false,
+ };
+ }
+
+ public render() {
+ const validationResult =
+ this.props.validateForm &&
+ validateRoleMappingRules({ rules: this.state.rules ? this.state.rules.toRaw() : {} });
+
+ let validationWarning = null;
+ if (validationResult && validationResult.error) {
+ validationWarning = (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ),
+ }}
+ />
+
+
+
+
+
+
+
+ {validationWarning}
+ {this.getEditor()}
+
+ {this.getModeToggle()}
+ {this.getConfirmModeChangePrompt()}
+
+
+
+
+
+
+ );
+ }
+
+ private initializeFromRawRules = (rawRules: Props['rawRules']) => {
+ const { rules, maxDepth } = generateRulesFromRaw(rawRules);
+ const mode: State['mode'] = maxDepth >= VISUAL_MAX_RULE_DEPTH ? 'json' : 'visual';
+ return {
+ rules,
+ mode,
+ maxDepth,
+ };
+ };
+
+ private getModeToggle() {
+ if (this.state.mode === 'json' && this.state.maxDepth > VISUAL_MAX_RULE_DEPTH) {
+ return (
+
+
+
+ );
+ }
+
+ // Don't offer swith if no rules are present yet
+ if (this.state.mode === 'visual' && this.state.rules === null) {
+ return null;
+ }
+
+ switch (this.state.mode) {
+ case 'visual':
+ return (
+ {
+ this.trySwitchEditorMode('json');
+ }}
+ >
+
+ {' '}
+
+
+
+ );
+ case 'json':
+ return (
+ {
+ this.trySwitchEditorMode('visual');
+ }}
+ >
+
+ {' '}
+
+
+
+ );
+ default:
+ throw new Error(`Unexpected rule editor mode: ${this.state.mode}`);
+ }
+ }
+
+ private getEditor() {
+ switch (this.state.mode) {
+ case 'visual':
+ return (
+ this.trySwitchEditorMode('json')}
+ />
+ );
+ case 'json':
+ return (
+
+ );
+ default:
+ throw new Error(`Unexpected rule editor mode: ${this.state.mode}`);
+ }
+ }
+
+ private getConfirmModeChangePrompt = () => {
+ if (!this.state.showConfirmModeChange) {
+ return null;
+ }
+ return (
+
+
+ }
+ onCancel={() => this.setState({ showConfirmModeChange: false })}
+ onConfirm={() => {
+ this.setState({ mode: 'visual', showConfirmModeChange: false });
+ this.onValidityChange(true);
+ }}
+ cancelButtonText={
+
+ }
+ confirmButtonText={
+
+ }
+ >
+
+
+
+
+
+ );
+ };
+
+ private onRuleChange = (updatedRule: Rule | null) => {
+ const raw = updatedRule ? updatedRule.toRaw() : {};
+ this.props.onChange(raw);
+ this.setState({
+ ...generateRulesFromRaw(raw),
+ });
+ };
+
+ private onValidityChange = (isRuleValid: boolean) => {
+ this.setState({ isRuleValid });
+ this.props.onValidityChange(isRuleValid);
+ };
+
+ private trySwitchEditorMode = (newMode: State['mode']) => {
+ switch (newMode) {
+ case 'visual': {
+ if (this.state.isRuleValid) {
+ this.setState({ mode: newMode });
+ this.onValidityChange(true);
+ } else {
+ this.setState({ showConfirmModeChange: true });
+ }
+ break;
+ }
+ case 'json':
+ this.setState({ mode: newMode });
+ this.onValidityChange(true);
+ break;
+ default:
+ throw new Error(`Unexpected rule editor mode: ${this.state.mode}`);
+ }
+ };
+}
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/rule_group_editor.test.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/rule_group_editor.test.tsx
new file mode 100644
index 0000000000000..3e0e0e386e98c
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/rule_group_editor.test.tsx
@@ -0,0 +1,149 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+import { RuleGroupEditor } from './rule_group_editor';
+import { shallowWithIntl, mountWithIntl, nextTick } from 'test_utils/enzyme_helpers';
+import { AllRule, FieldRule, AnyRule, ExceptAnyRule } from '../../../model';
+import { FieldRuleEditor } from './field_rule_editor';
+import { AddRuleButton } from './add_rule_button';
+import { EuiContextMenuItem } from '@elastic/eui';
+import { findTestSubject } from 'test_utils/find_test_subject';
+
+describe('RuleGroupEditor', () => {
+ it('renders an empty group', () => {
+ const props = {
+ rule: new AllRule([]),
+ allowAdd: true,
+ ruleDepth: 0,
+ onChange: jest.fn(),
+ onDelete: jest.fn(),
+ };
+ const wrapper = shallowWithIntl( );
+ expect(wrapper.find(RuleGroupEditor)).toHaveLength(0);
+ expect(wrapper.find(FieldRuleEditor)).toHaveLength(0);
+ expect(wrapper.find(AddRuleButton)).toHaveLength(1);
+ });
+
+ it('allows the group type to be changed, maintaining child rules', async () => {
+ const props = {
+ rule: new AllRule([new FieldRule('username', '*')]),
+ allowAdd: true,
+ ruleDepth: 0,
+ onChange: jest.fn(),
+ onDelete: jest.fn(),
+ };
+ const wrapper = mountWithIntl( );
+ expect(wrapper.find(RuleGroupEditor)).toHaveLength(1);
+ expect(wrapper.find(FieldRuleEditor)).toHaveLength(1);
+ expect(wrapper.find(AddRuleButton)).toHaveLength(1);
+ expect(findTestSubject(wrapper, 'deleteRuleGroupButton')).toHaveLength(1);
+
+ const anyRule = new AnyRule();
+
+ findTestSubject(wrapper, 'ruleGroupTitle').simulate('click');
+ await nextTick();
+ wrapper.update();
+
+ const anyRuleOption = wrapper.find(EuiContextMenuItem).filterWhere(menuItem => {
+ return menuItem.text() === anyRule.getDisplayTitle();
+ });
+
+ anyRuleOption.simulate('click');
+
+ expect(props.onChange).toHaveBeenCalledTimes(1);
+ const [newRule] = props.onChange.mock.calls[0];
+ expect(newRule).toBeInstanceOf(AnyRule);
+ expect(newRule.toRaw()).toEqual(new AnyRule([new FieldRule('username', '*')]).toRaw());
+ });
+
+ it('warns when changing group types which would invalidate child rules', async () => {
+ const props = {
+ rule: new AllRule([new ExceptAnyRule([new FieldRule('my_custom_field', 'foo*')])]),
+ allowAdd: true,
+ ruleDepth: 0,
+ onChange: jest.fn(),
+ onDelete: jest.fn(),
+ };
+ const wrapper = mountWithIntl( );
+ expect(wrapper.find(RuleGroupEditor)).toHaveLength(2);
+ expect(wrapper.find(FieldRuleEditor)).toHaveLength(1);
+ expect(wrapper.find(AddRuleButton)).toHaveLength(2);
+ expect(findTestSubject(wrapper, 'deleteRuleGroupButton')).toHaveLength(2);
+
+ const anyRule = new AnyRule();
+
+ findTestSubject(wrapper, 'ruleGroupTitle')
+ .first()
+ .simulate('click');
+ await nextTick();
+ wrapper.update();
+
+ const anyRuleOption = wrapper.find(EuiContextMenuItem).filterWhere(menuItem => {
+ return menuItem.text() === anyRule.getDisplayTitle();
+ });
+
+ anyRuleOption.simulate('click');
+
+ expect(props.onChange).toHaveBeenCalledTimes(0);
+ expect(findTestSubject(wrapper, 'confirmRuleChangeModal')).toHaveLength(1);
+ findTestSubject(wrapper, 'confirmModalConfirmButton').simulate('click');
+
+ expect(props.onChange).toHaveBeenCalledTimes(1);
+ const [newRule] = props.onChange.mock.calls[0];
+ expect(newRule).toBeInstanceOf(AnyRule);
+
+ // new rule should a defaulted field sub rule, as the existing rules are not valid for the new type
+ expect(newRule.toRaw()).toEqual(new AnyRule([new FieldRule('username', '*')]).toRaw());
+ });
+
+ it('does not change groups when canceling the confirmation', async () => {
+ const props = {
+ rule: new AllRule([new ExceptAnyRule([new FieldRule('username', '*')])]),
+ allowAdd: true,
+ ruleDepth: 0,
+ onChange: jest.fn(),
+ onDelete: jest.fn(),
+ };
+ const wrapper = mountWithIntl( );
+ expect(wrapper.find(RuleGroupEditor)).toHaveLength(2);
+ expect(wrapper.find(FieldRuleEditor)).toHaveLength(1);
+ expect(wrapper.find(AddRuleButton)).toHaveLength(2);
+ expect(findTestSubject(wrapper, 'deleteRuleGroupButton')).toHaveLength(2);
+
+ const anyRule = new AnyRule();
+
+ findTestSubject(wrapper, 'ruleGroupTitle')
+ .first()
+ .simulate('click');
+ await nextTick();
+ wrapper.update();
+
+ const anyRuleOption = wrapper.find(EuiContextMenuItem).filterWhere(menuItem => {
+ return menuItem.text() === anyRule.getDisplayTitle();
+ });
+
+ anyRuleOption.simulate('click');
+
+ expect(props.onChange).toHaveBeenCalledTimes(0);
+ expect(findTestSubject(wrapper, 'confirmRuleChangeModal')).toHaveLength(1);
+ findTestSubject(wrapper, 'confirmModalCancelButton').simulate('click');
+
+ expect(props.onChange).toHaveBeenCalledTimes(0);
+ });
+
+ it('hides the add rule button when instructed to', () => {
+ const props = {
+ rule: new AllRule([]),
+ allowAdd: false,
+ ruleDepth: 0,
+ onChange: jest.fn(),
+ onDelete: jest.fn(),
+ };
+ const wrapper = shallowWithIntl( );
+ expect(wrapper.find(AddRuleButton)).toHaveLength(0);
+ });
+});
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/rule_group_editor.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/rule_group_editor.tsx
new file mode 100644
index 0000000000000..6fb33db179e8a
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/rule_group_editor.tsx
@@ -0,0 +1,136 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React, { Component, Fragment } from 'react';
+import {
+ EuiPanel,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiHorizontalRule,
+ EuiButtonEmpty,
+} from '@elastic/eui';
+import { FormattedMessage } from '@kbn/i18n/react';
+import { AddRuleButton } from './add_rule_button';
+import { RuleGroupTitle } from './rule_group_title';
+import { FieldRuleEditor } from './field_rule_editor';
+import { RuleGroup, Rule, FieldRule } from '../../../model';
+import { isRuleGroup } from '../../services/is_rule_group';
+
+interface Props {
+ rule: RuleGroup;
+ allowAdd: boolean;
+ parentRule?: RuleGroup;
+ ruleDepth: number;
+ onChange: (rule: RuleGroup) => void;
+ onDelete: () => void;
+}
+export class RuleGroupEditor extends Component {
+ public render() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {this.renderSubRules()}
+ {this.props.allowAdd && (
+
+
+
+ )}
+
+
+ );
+ }
+
+ private renderSubRules = () => {
+ return this.props.rule.getRules().map((subRule, subRuleIndex, rules) => {
+ const isLastRule = subRuleIndex === rules.length - 1;
+ const divider = isLastRule ? null : (
+
+
+
+ );
+
+ if (isRuleGroup(subRule)) {
+ return (
+
+
+ {
+ const updatedRule = this.props.rule.clone() as RuleGroup;
+ updatedRule.replaceRule(subRuleIndex, updatedSubRule);
+ this.props.onChange(updatedRule);
+ }}
+ onDelete={() => {
+ const updatedRule = this.props.rule.clone() as RuleGroup;
+ updatedRule.removeRule(subRuleIndex);
+ this.props.onChange(updatedRule);
+ }}
+ />
+
+ {divider}
+
+ );
+ }
+
+ return (
+
+
+ {
+ const updatedRule = this.props.rule.clone() as RuleGroup;
+ updatedRule.replaceRule(subRuleIndex, updatedSubRule);
+ this.props.onChange(updatedRule);
+ }}
+ onDelete={() => {
+ const updatedRule = this.props.rule.clone() as RuleGroup;
+ updatedRule.removeRule(subRuleIndex);
+ this.props.onChange(updatedRule);
+ }}
+ />
+
+ {divider}
+
+ );
+ });
+ };
+
+ private onAddRuleClick = (newRule: Rule) => {
+ const updatedRule = this.props.rule.clone() as RuleGroup;
+ updatedRule.addRule(newRule);
+ this.props.onChange(updatedRule);
+ };
+}
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/rule_group_title.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/rule_group_title.tsx
new file mode 100644
index 0000000000000..e46893afd4d86
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/rule_group_title.tsx
@@ -0,0 +1,143 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React, { useState } from 'react';
+import {
+ EuiPopover,
+ EuiContextMenuPanel,
+ EuiContextMenuItem,
+ EuiLink,
+ EuiIcon,
+ EuiOverlayMask,
+ EuiConfirmModal,
+} from '@elastic/eui';
+import { FormattedMessage } from '@kbn/i18n/react';
+import {
+ RuleGroup,
+ AllRule,
+ AnyRule,
+ ExceptAllRule,
+ ExceptAnyRule,
+ FieldRule,
+} from '../../../model';
+
+interface Props {
+ rule: RuleGroup;
+ readonly?: boolean;
+ parentRule?: RuleGroup;
+ onChange: (rule: RuleGroup) => void;
+}
+
+const rules = [new AllRule(), new AnyRule()];
+const exceptRules = [new ExceptAllRule(), new ExceptAnyRule()];
+
+export const RuleGroupTitle = (props: Props) => {
+ const [isMenuOpen, setIsMenuOpen] = useState(false);
+
+ const [showConfirmChangeModal, setShowConfirmChangeModal] = useState(false);
+ const [pendingNewRule, setPendingNewRule] = useState(null);
+
+ const canUseExcept = props.parentRule && props.parentRule.canContainRules(exceptRules);
+
+ const availableRuleTypes = [...rules, ...(canUseExcept ? exceptRules : [])];
+
+ const onChange = (newRule: RuleGroup) => {
+ const currentSubRules = props.rule.getRules();
+ const areSubRulesValid = newRule.canContainRules(currentSubRules);
+ if (areSubRulesValid) {
+ const clone = newRule.clone() as RuleGroup;
+ currentSubRules.forEach(subRule => clone.addRule(subRule));
+
+ props.onChange(clone);
+ setIsMenuOpen(false);
+ } else {
+ setPendingNewRule(newRule);
+ setShowConfirmChangeModal(true);
+ }
+ };
+
+ const changeRuleDiscardingSubRules = (newRule: RuleGroup) => {
+ // Ensure a default sub rule is present when not carrying over the original sub rules
+ const newRuleInstance = newRule.clone() as RuleGroup;
+ if (newRuleInstance.getRules().length === 0) {
+ newRuleInstance.addRule(new FieldRule('username', '*'));
+ }
+
+ props.onChange(newRuleInstance);
+ setIsMenuOpen(false);
+ };
+
+ const ruleButton = (
+ setIsMenuOpen(!isMenuOpen)} data-test-subj="ruleGroupTitle">
+ {props.rule.getDisplayTitle()}
+
+ );
+
+ const ruleTypeSelector = (
+ setIsMenuOpen(false)}>
+ {
+ const isSelected = rt.getDisplayTitle() === props.rule.getDisplayTitle();
+ const icon = isSelected ? 'check' : 'empty';
+ return (
+ onChange(rt as RuleGroup)}>
+ {rt.getDisplayTitle()}
+
+ );
+ })}
+ />
+
+ );
+
+ const confirmChangeModal = showConfirmChangeModal ? (
+
+
+ }
+ onCancel={() => {
+ setShowConfirmChangeModal(false);
+ setPendingNewRule(null);
+ }}
+ onConfirm={() => {
+ setShowConfirmChangeModal(false);
+ changeRuleDiscardingSubRules(pendingNewRule!);
+ setPendingNewRule(null);
+ }}
+ cancelButtonText={
+
+ }
+ confirmButtonText={
+
+ }
+ >
+
+
+
+
+
+ ) : null;
+
+ return (
+
+ {ruleTypeSelector}
+ {confirmChangeModal}
+
+ );
+};
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/visual_rule_editor.test.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/visual_rule_editor.test.tsx
new file mode 100644
index 0000000000000..7c63613ee1cc9
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/visual_rule_editor.test.tsx
@@ -0,0 +1,126 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+import { mountWithIntl } from 'test_utils/enzyme_helpers';
+import { VisualRuleEditor } from './visual_rule_editor';
+import { findTestSubject } from 'test_utils/find_test_subject';
+import { AnyRule, AllRule, FieldRule, ExceptAnyRule, ExceptAllRule } from '../../../model';
+import { RuleGroupEditor } from './rule_group_editor';
+import { FieldRuleEditor } from './field_rule_editor';
+
+describe('VisualRuleEditor', () => {
+ it('renders an empty prompt when no rules are defined', () => {
+ const props = {
+ rules: null,
+ maxDepth: 0,
+ onSwitchEditorMode: jest.fn(),
+ onChange: jest.fn(),
+ };
+ const wrapper = mountWithIntl( );
+
+ findTestSubject(wrapper, 'roleMappingsAddRuleButton').simulate('click');
+ expect(props.onChange).toHaveBeenCalledTimes(1);
+ const [newRule] = props.onChange.mock.calls[0];
+ expect(newRule.toRaw()).toEqual({
+ all: [{ field: { username: '*' } }],
+ });
+ });
+
+ it('adds a rule group when the "Add rules" button is clicked', () => {
+ const props = {
+ rules: null,
+ maxDepth: 0,
+ onSwitchEditorMode: jest.fn(),
+ onChange: jest.fn(),
+ };
+ const wrapper = mountWithIntl( );
+ expect(findTestSubject(wrapper, 'roleMappingsNoRulesDefined')).toHaveLength(1);
+ expect(findTestSubject(wrapper, 'roleMappingsRulesTooComplex')).toHaveLength(0);
+ });
+
+ it('clicking the add button when no rules are defined populates an initial rule set', () => {
+ const props = {
+ rules: null,
+ maxDepth: 0,
+ onSwitchEditorMode: jest.fn(),
+ onChange: jest.fn(),
+ };
+ const wrapper = mountWithIntl( );
+ findTestSubject(wrapper, 'roleMappingsAddRuleButton').simulate('click');
+
+ expect(props.onChange).toHaveBeenCalledTimes(1);
+ const [newRule] = props.onChange.mock.calls[0];
+ expect(newRule).toBeInstanceOf(AllRule);
+ expect(newRule.toRaw()).toEqual({
+ all: [
+ {
+ field: {
+ username: '*',
+ },
+ },
+ ],
+ });
+ });
+
+ it('renders a nested rule set', () => {
+ const props = {
+ rules: new AllRule([
+ new AnyRule([new FieldRule('username', '*')]),
+ new ExceptAnyRule([
+ new FieldRule('metadata.foo.bar', '*'),
+ new AllRule([new FieldRule('realm', 'special-one')]),
+ ]),
+ new ExceptAllRule([new FieldRule('realm', '*')]),
+ ]),
+ maxDepth: 4,
+ onSwitchEditorMode: jest.fn(),
+ onChange: jest.fn(),
+ };
+ const wrapper = mountWithIntl( );
+
+ expect(wrapper.find(RuleGroupEditor)).toHaveLength(5);
+ expect(wrapper.find(FieldRuleEditor)).toHaveLength(4);
+ expect(findTestSubject(wrapper, 'roleMappingsRulesTooComplex')).toHaveLength(0);
+ });
+
+ it('warns when the rule set is too complex', () => {
+ const props = {
+ rules: new AllRule([
+ new AnyRule([
+ new AllRule([
+ new AnyRule([
+ new AllRule([
+ new AnyRule([
+ new AllRule([
+ new AnyRule([
+ new AllRule([
+ new AnyRule([
+ new AllRule([
+ new AnyRule([
+ new AnyRule([
+ new AllRule([new AnyRule([new FieldRule('username', '*')])]),
+ ]),
+ ]),
+ ]),
+ ]),
+ ]),
+ ]),
+ ]),
+ ]),
+ ]),
+ ]),
+ ]),
+ ]),
+ ]),
+ maxDepth: 11,
+ onSwitchEditorMode: jest.fn(),
+ onChange: jest.fn(),
+ };
+ const wrapper = mountWithIntl( );
+ expect(findTestSubject(wrapper, 'roleMappingsRulesTooComplex')).toHaveLength(1);
+ });
+});
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/visual_rule_editor.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/visual_rule_editor.tsx
new file mode 100644
index 0000000000000..214c583189fb8
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/visual_rule_editor.tsx
@@ -0,0 +1,143 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React, { Component, Fragment } from 'react';
+import { EuiEmptyPrompt, EuiCallOut, EuiSpacer, EuiButton } from '@elastic/eui';
+import { FormattedMessage } from '@kbn/i18n/react';
+import { FieldRuleEditor } from './field_rule_editor';
+import { RuleGroupEditor } from './rule_group_editor';
+import { VISUAL_MAX_RULE_DEPTH } from '../../services/role_mapping_constants';
+import { Rule, FieldRule, RuleGroup, AllRule } from '../../../model';
+import { isRuleGroup } from '../../services/is_rule_group';
+
+interface Props {
+ rules: Rule | null;
+ maxDepth: number;
+ onChange: (rules: Rule | null) => void;
+ onSwitchEditorMode: () => void;
+}
+
+export class VisualRuleEditor extends Component {
+ public render() {
+ if (this.props.rules) {
+ const rules = this.renderRule(this.props.rules, this.onRuleChange);
+ return (
+
+ {this.getRuleDepthWarning()}
+ {rules}
+
+ );
+ }
+
+ return (
+
+
+
+ }
+ titleSize="s"
+ body={
+
+
+
+ }
+ data-test-subj="roleMappingsNoRulesDefined"
+ actions={
+ {
+ this.props.onChange(new AllRule([new FieldRule('username', '*')]));
+ }}
+ >
+
+
+ }
+ />
+ );
+ }
+
+ private canUseVisualEditor = () => this.props.maxDepth < VISUAL_MAX_RULE_DEPTH;
+
+ private getRuleDepthWarning = () => {
+ if (this.canUseVisualEditor()) {
+ return null;
+ }
+ return (
+
+
+ }
+ data-test-subj="roleMappingsRulesTooComplex"
+ >
+
+
+
+
+
+
+
+
+
+
+ );
+ };
+
+ private onRuleChange = (updatedRule: Rule) => {
+ this.props.onChange(updatedRule);
+ };
+
+ private onRuleDelete = () => {
+ this.props.onChange(null);
+ };
+
+ private renderRule = (rule: Rule, onChange: (updatedRule: Rule) => void) => {
+ return this.getEditorForRuleType(rule, onChange);
+ };
+
+ private getEditorForRuleType(rule: Rule, onChange: (updatedRule: Rule) => void) {
+ if (isRuleGroup(rule)) {
+ return (
+ onChange(value)}
+ onDelete={this.onRuleDelete}
+ />
+ );
+ }
+ return (
+ onChange(value)}
+ onDelete={this.onRuleDelete}
+ />
+ );
+ }
+}
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/edit_role_mapping.html b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/edit_role_mapping.html
new file mode 100644
index 0000000000000..ca8ab9c35c49b
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/edit_role_mapping.html
@@ -0,0 +1,3 @@
+
+
+
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/index.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/index.tsx
new file mode 100644
index 0000000000000..b064a4dc50a22
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/index.tsx
@@ -0,0 +1,45 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import React from 'react';
+import { render, unmountComponentAtNode } from 'react-dom';
+import routes from 'ui/routes';
+import { I18nContext } from 'ui/i18n';
+import { npSetup } from 'ui/new_platform';
+import { RoleMappingsAPI } from '../../../../lib/role_mappings_api';
+// @ts-ignore
+import template from './edit_role_mapping.html';
+import { CREATE_ROLE_MAPPING_PATH } from '../../management_urls';
+import { getEditRoleMappingBreadcrumbs } from '../../breadcrumbs';
+import { EditRoleMappingPage } from './components';
+
+routes.when(`${CREATE_ROLE_MAPPING_PATH}/:name?`, {
+ template,
+ k7Breadcrumbs: getEditRoleMappingBreadcrumbs,
+ controller($scope, $route) {
+ $scope.$$postDigest(() => {
+ const domNode = document.getElementById('editRoleMappingReactRoot');
+
+ const { name } = $route.current.params;
+
+ render(
+
+
+ ,
+ domNode
+ );
+
+ // unmount react on controller destroy
+ $scope.$on('$destroy', () => {
+ if (domNode) {
+ unmountComponentAtNode(domNode);
+ }
+ });
+ });
+ },
+});
diff --git a/x-pack/test/typings/encode_uri_query.d.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/services/is_rule_group.ts
similarity index 54%
rename from x-pack/test/typings/encode_uri_query.d.ts
rename to x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/services/is_rule_group.ts
index e1ab5f4a70abf..60a879c6c29df 100644
--- a/x-pack/test/typings/encode_uri_query.d.ts
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/services/is_rule_group.ts
@@ -4,8 +4,8 @@
* you may not use this file except in compliance with the Elastic License.
*/
-declare module 'encode-uri-query' {
- function encodeUriQuery(query: string, usePercentageSpace?: boolean): string;
- // eslint-disable-next-line import/no-default-export
- export default encodeUriQuery;
+import { Rule, FieldRule } from '../../model';
+
+export function isRuleGroup(rule: Rule) {
+ return !(rule instanceof FieldRule);
}
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/services/role_mapping_constants.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/services/role_mapping_constants.ts
new file mode 100644
index 0000000000000..28010013c9f4f
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/services/role_mapping_constants.ts
@@ -0,0 +1,7 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export const VISUAL_MAX_RULE_DEPTH = 5;
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/services/role_mapping_validation.test.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/services/role_mapping_validation.test.ts
new file mode 100644
index 0000000000000..9614c4338b631
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/services/role_mapping_validation.test.ts
@@ -0,0 +1,151 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import {
+ validateRoleMappingName,
+ validateRoleMappingRoles,
+ validateRoleMappingRoleTemplates,
+ validateRoleMappingRules,
+ validateRoleMappingForSave,
+} from './role_mapping_validation';
+import { RoleMapping } from '../../../../../../common/model';
+
+describe('validateRoleMappingName', () => {
+ it('requires a value', () => {
+ expect(validateRoleMappingName({ name: '' } as RoleMapping)).toMatchInlineSnapshot(`
+ Object {
+ "error": "Name is required.",
+ "isInvalid": true,
+ }
+ `);
+ });
+});
+
+describe('validateRoleMappingRoles', () => {
+ it('requires a value', () => {
+ expect(validateRoleMappingRoles(({ roles: [] } as unknown) as RoleMapping))
+ .toMatchInlineSnapshot(`
+ Object {
+ "error": "At least one role is required.",
+ "isInvalid": true,
+ }
+ `);
+ });
+});
+
+describe('validateRoleMappingRoleTemplates', () => {
+ it('requires a value', () => {
+ expect(validateRoleMappingRoleTemplates(({ role_templates: [] } as unknown) as RoleMapping))
+ .toMatchInlineSnapshot(`
+ Object {
+ "error": "At least one role template is required.",
+ "isInvalid": true,
+ }
+ `);
+ });
+});
+
+describe('validateRoleMappingRules', () => {
+ it('requires at least one rule', () => {
+ expect(validateRoleMappingRules({ rules: {} } as RoleMapping)).toMatchInlineSnapshot(`
+ Object {
+ "error": "At least one rule is required.",
+ "isInvalid": true,
+ }
+ `);
+ });
+
+ // more exhaustive testing is done in other unit tests
+ it('requires rules to be valid', () => {
+ expect(validateRoleMappingRules(({ rules: { something: [] } } as unknown) as RoleMapping))
+ .toMatchInlineSnapshot(`
+ Object {
+ "error": "Unknown rule type: something.",
+ "isInvalid": true,
+ }
+ `);
+ });
+});
+
+describe('validateRoleMappingForSave', () => {
+ it('fails if the role mapping is missing a name', () => {
+ expect(
+ validateRoleMappingForSave(({
+ enabled: true,
+ roles: ['superuser'],
+ rules: { field: { username: '*' } },
+ } as unknown) as RoleMapping)
+ ).toMatchInlineSnapshot(`
+ Object {
+ "error": "Name is required.",
+ "isInvalid": true,
+ }
+ `);
+ });
+
+ it('fails if the role mapping is missing rules', () => {
+ expect(
+ validateRoleMappingForSave(({
+ name: 'foo',
+ enabled: true,
+ roles: ['superuser'],
+ rules: {},
+ } as unknown) as RoleMapping)
+ ).toMatchInlineSnapshot(`
+ Object {
+ "error": "At least one rule is required.",
+ "isInvalid": true,
+ }
+ `);
+ });
+
+ it('fails if the role mapping is missing both roles and templates', () => {
+ expect(
+ validateRoleMappingForSave(({
+ name: 'foo',
+ enabled: true,
+ roles: [],
+ role_templates: [],
+ rules: { field: { username: '*' } },
+ } as unknown) as RoleMapping)
+ ).toMatchInlineSnapshot(`
+ Object {
+ "error": "At least one role is required.",
+ "isInvalid": true,
+ }
+ `);
+ });
+
+ it('validates a correct role mapping using role templates', () => {
+ expect(
+ validateRoleMappingForSave(({
+ name: 'foo',
+ enabled: true,
+ roles: [],
+ role_templates: [{ template: { id: 'foo' } }],
+ rules: { field: { username: '*' } },
+ } as unknown) as RoleMapping)
+ ).toMatchInlineSnapshot(`
+ Object {
+ "isInvalid": false,
+ }
+ `);
+ });
+
+ it('validates a correct role mapping using roles', () => {
+ expect(
+ validateRoleMappingForSave(({
+ name: 'foo',
+ enabled: true,
+ roles: ['superuser'],
+ rules: { field: { username: '*' } },
+ } as unknown) as RoleMapping)
+ ).toMatchInlineSnapshot(`
+ Object {
+ "isInvalid": false,
+ }
+ `);
+ });
+});
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/services/role_mapping_validation.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/services/role_mapping_validation.ts
new file mode 100644
index 0000000000000..5916d6fd9e189
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/services/role_mapping_validation.ts
@@ -0,0 +1,93 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { i18n } from '@kbn/i18n';
+import { RoleMapping } from '../../../../../../common/model';
+import { generateRulesFromRaw } from '../../model';
+
+interface ValidationResult {
+ isInvalid: boolean;
+ error?: string;
+}
+
+export function validateRoleMappingName({ name }: RoleMapping): ValidationResult {
+ if (!name) {
+ return invalid(
+ i18n.translate('xpack.security.role_mappings.validation.invalidName', {
+ defaultMessage: 'Name is required.',
+ })
+ );
+ }
+ return valid();
+}
+
+export function validateRoleMappingRoles({ roles }: RoleMapping): ValidationResult {
+ if (roles && !roles.length) {
+ return invalid(
+ i18n.translate('xpack.security.role_mappings.validation.invalidRoles', {
+ defaultMessage: 'At least one role is required.',
+ })
+ );
+ }
+ return valid();
+}
+
+export function validateRoleMappingRoleTemplates({
+ role_templates: roleTemplates,
+}: RoleMapping): ValidationResult {
+ if (roleTemplates && !roleTemplates.length) {
+ return invalid(
+ i18n.translate('xpack.security.role_mappings.validation.invalidRoleTemplates', {
+ defaultMessage: 'At least one role template is required.',
+ })
+ );
+ }
+ return valid();
+}
+
+export function validateRoleMappingRules({ rules }: Pick): ValidationResult {
+ try {
+ const { rules: parsedRules } = generateRulesFromRaw(rules);
+ if (!parsedRules) {
+ return invalid(
+ i18n.translate('xpack.security.role_mappings.validation.invalidRoleRule', {
+ defaultMessage: 'At least one rule is required.',
+ })
+ );
+ }
+ } catch (e) {
+ return invalid(e.message);
+ }
+
+ return valid();
+}
+
+export function validateRoleMappingForSave(roleMapping: RoleMapping): ValidationResult {
+ const { isInvalid: isNameInvalid, error: nameError } = validateRoleMappingName(roleMapping);
+ const { isInvalid: areRolesInvalid, error: rolesError } = validateRoleMappingRoles(roleMapping);
+ const {
+ isInvalid: areRoleTemplatesInvalid,
+ error: roleTemplatesError,
+ } = validateRoleMappingRoleTemplates(roleMapping);
+
+ const { isInvalid: areRulesInvalid, error: rulesError } = validateRoleMappingRules(roleMapping);
+
+ const canSave =
+ !isNameInvalid && (!areRolesInvalid || !areRoleTemplatesInvalid) && !areRulesInvalid;
+
+ if (canSave) {
+ return valid();
+ }
+ return invalid(nameError || rulesError || rolesError || roleTemplatesError);
+}
+
+function valid() {
+ return { isInvalid: false };
+}
+
+function invalid(error?: string) {
+ return { isInvalid: true, error };
+}
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/services/role_template_type.test.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/services/role_template_type.test.ts
new file mode 100644
index 0000000000000..8e1f47a4157ae
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/services/role_template_type.test.ts
@@ -0,0 +1,39 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import {
+ isStoredRoleTemplate,
+ isInlineRoleTemplate,
+ isInvalidRoleTemplate,
+} from './role_template_type';
+import { RoleTemplate } from '../../../../../../common/model';
+
+describe('#isStoredRoleTemplate', () => {
+ it('returns true for stored templates, false otherwise', () => {
+ expect(isStoredRoleTemplate({ template: { id: '' } })).toEqual(true);
+ expect(isStoredRoleTemplate({ template: { source: '' } })).toEqual(false);
+ expect(isStoredRoleTemplate({ template: 'asdf' })).toEqual(false);
+ expect(isStoredRoleTemplate({} as RoleTemplate)).toEqual(false);
+ });
+});
+
+describe('#isInlineRoleTemplate', () => {
+ it('returns true for inline templates, false otherwise', () => {
+ expect(isInlineRoleTemplate({ template: { source: '' } })).toEqual(true);
+ expect(isInlineRoleTemplate({ template: { id: '' } })).toEqual(false);
+ expect(isInlineRoleTemplate({ template: 'asdf' })).toEqual(false);
+ expect(isInlineRoleTemplate({} as RoleTemplate)).toEqual(false);
+ });
+});
+
+describe('#isInvalidRoleTemplate', () => {
+ it('returns true for invalid templates, false otherwise', () => {
+ expect(isInvalidRoleTemplate({ template: 'asdf' })).toEqual(true);
+ expect(isInvalidRoleTemplate({} as RoleTemplate)).toEqual(true);
+ expect(isInvalidRoleTemplate({ template: { source: '' } })).toEqual(false);
+ expect(isInvalidRoleTemplate({ template: { id: '' } })).toEqual(false);
+ });
+});
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/services/role_template_type.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/services/role_template_type.ts
new file mode 100644
index 0000000000000..90d8d1a09e587
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/services/role_template_type.ts
@@ -0,0 +1,38 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import {
+ RoleTemplate,
+ StoredRoleTemplate,
+ InlineRoleTemplate,
+ InvalidRoleTemplate,
+} from '../../../../../../common/model';
+
+export function isStoredRoleTemplate(
+ roleMappingTemplate: RoleTemplate
+): roleMappingTemplate is StoredRoleTemplate {
+ return (
+ roleMappingTemplate.template != null &&
+ roleMappingTemplate.template.hasOwnProperty('id') &&
+ typeof ((roleMappingTemplate as unknown) as StoredRoleTemplate).template.id === 'string'
+ );
+}
+
+export function isInlineRoleTemplate(
+ roleMappingTemplate: RoleTemplate
+): roleMappingTemplate is InlineRoleTemplate {
+ return (
+ roleMappingTemplate.template != null &&
+ roleMappingTemplate.template.hasOwnProperty('source') &&
+ typeof ((roleMappingTemplate as unknown) as InlineRoleTemplate).template.source === 'string'
+ );
+}
+
+export function isInvalidRoleTemplate(
+ roleMappingTemplate: RoleTemplate
+): roleMappingTemplate is InvalidRoleTemplate {
+ return !isStoredRoleTemplate(roleMappingTemplate) && !isInlineRoleTemplate(roleMappingTemplate);
+}
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/__snapshots__/rule_builder.test.ts.snap b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/__snapshots__/rule_builder.test.ts.snap
new file mode 100644
index 0000000000000..1c61383b951ae
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/__snapshots__/rule_builder.test.ts.snap
@@ -0,0 +1,9 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`generateRulesFromRaw "field" does not support a value of () => null 1`] = `"Invalid value type for field. Expected one of null, string, number, or boolean, but found function ()."`;
+
+exports[`generateRulesFromRaw "field" does not support a value of [object Object] 1`] = `"Invalid value type for field. Expected one of null, string, number, or boolean, but found object ({})."`;
+
+exports[`generateRulesFromRaw "field" does not support a value of [object Object],,,() => null 1`] = `"Invalid value type for field. Expected one of null, string, number, or boolean, but found object ([{},null,[],null])."`;
+
+exports[`generateRulesFromRaw "field" does not support a value of undefined 1`] = `"Invalid value type for field. Expected one of null, string, number, or boolean, but found undefined ()."`;
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/all_rule.test.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/all_rule.test.ts
new file mode 100644
index 0000000000000..ddf3b4499f73b
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/all_rule.test.ts
@@ -0,0 +1,64 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { AllRule, AnyRule, FieldRule, ExceptAllRule, ExceptAnyRule, RuleGroup } from '.';
+
+describe('All rule', () => {
+ it('can be constructed without sub rules', () => {
+ const rule = new AllRule();
+ expect(rule.getRules()).toHaveLength(0);
+ });
+
+ it('can be constructed with sub rules', () => {
+ const rule = new AllRule([new AnyRule()]);
+ expect(rule.getRules()).toHaveLength(1);
+ });
+
+ it('can accept rules of any type', () => {
+ const subRules = [
+ new AllRule(),
+ new AnyRule(),
+ new FieldRule('username', '*'),
+ new ExceptAllRule(),
+ new ExceptAnyRule(),
+ ];
+
+ const rule = new AllRule() as RuleGroup;
+ expect(rule.canContainRules(subRules)).toEqual(true);
+ subRules.forEach(sr => rule.addRule(sr));
+ expect(rule.getRules()).toEqual([...subRules]);
+ });
+
+ it('can replace an existing rule', () => {
+ const rule = new AllRule([new AnyRule()]);
+ const newRule = new FieldRule('username', '*');
+ rule.replaceRule(0, newRule);
+ expect(rule.getRules()).toEqual([newRule]);
+ });
+
+ it('can remove an existing rule', () => {
+ const rule = new AllRule([new AnyRule()]);
+ rule.removeRule(0);
+ expect(rule.getRules()).toHaveLength(0);
+ });
+
+ it('can covert itself into a raw representation', () => {
+ const rule = new AllRule([new AnyRule()]);
+ expect(rule.toRaw()).toEqual({
+ all: [{ any: [] }],
+ });
+ });
+
+ it('can clone itself', () => {
+ const subRules = [new AnyRule()];
+ const rule = new AllRule(subRules);
+ const clone = rule.clone();
+
+ expect(clone.toRaw()).toEqual(rule.toRaw());
+ expect(clone.getRules()).toEqual(rule.getRules());
+ expect(clone.getRules()).not.toBe(rule.getRules());
+ });
+});
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/all_rule.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/all_rule.ts
new file mode 100644
index 0000000000000..ecea27a7fb87f
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/all_rule.ts
@@ -0,0 +1,62 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { i18n } from '@kbn/i18n';
+import { RuleGroup } from './rule_group';
+import { Rule } from './rule';
+
+/**
+ * Represents a group of rules which must all evaluate to true.
+ */
+export class AllRule extends RuleGroup {
+ constructor(private rules: Rule[] = []) {
+ super();
+ }
+
+ /** {@see RuleGroup.getRules} */
+ public getRules() {
+ return [...this.rules];
+ }
+
+ /** {@see RuleGroup.getDisplayTitle} */
+ public getDisplayTitle() {
+ return i18n.translate('xpack.security.management.editRoleMapping.allRule.displayTitle', {
+ defaultMessage: 'All are true',
+ });
+ }
+
+ /** {@see RuleGroup.replaceRule} */
+ public replaceRule(ruleIndex: number, rule: Rule) {
+ this.rules.splice(ruleIndex, 1, rule);
+ }
+
+ /** {@see RuleGroup.removeRule} */
+ public removeRule(ruleIndex: number) {
+ this.rules.splice(ruleIndex, 1);
+ }
+
+ /** {@see RuleGroup.addRule} */
+ public addRule(rule: Rule) {
+ this.rules.push(rule);
+ }
+
+ /** {@see RuleGroup.canContainRules} */
+ public canContainRules() {
+ return true;
+ }
+
+ /** {@see RuleGroup.clone} */
+ public clone() {
+ return new AllRule(this.rules.map(r => r.clone()));
+ }
+
+ /** {@see RuleGroup.toRaw} */
+ public toRaw() {
+ return {
+ all: [...this.rules.map(rule => rule.toRaw())],
+ };
+ }
+}
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/any_rule.test.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/any_rule.test.ts
new file mode 100644
index 0000000000000..767aa075755af
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/any_rule.test.ts
@@ -0,0 +1,65 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { AllRule, AnyRule, FieldRule, ExceptAllRule, ExceptAnyRule, RuleGroup } from '.';
+
+describe('Any rule', () => {
+ it('can be constructed without sub rules', () => {
+ const rule = new AnyRule();
+ expect(rule.getRules()).toHaveLength(0);
+ });
+
+ it('can be constructed with sub rules', () => {
+ const rule = new AnyRule([new AllRule()]);
+ expect(rule.getRules()).toHaveLength(1);
+ });
+
+ it('can accept non-except rules', () => {
+ const subRules = [new AllRule(), new AnyRule(), new FieldRule('username', '*')];
+
+ const rule = new AnyRule() as RuleGroup;
+ expect(rule.canContainRules(subRules)).toEqual(true);
+ subRules.forEach(sr => rule.addRule(sr));
+ expect(rule.getRules()).toEqual([...subRules]);
+ });
+
+ it('cannot accept except rules', () => {
+ const subRules = [new ExceptAllRule(), new ExceptAnyRule()];
+
+ const rule = new AnyRule() as RuleGroup;
+ expect(rule.canContainRules(subRules)).toEqual(false);
+ });
+
+ it('can replace an existing rule', () => {
+ const rule = new AnyRule([new AllRule()]);
+ const newRule = new FieldRule('username', '*');
+ rule.replaceRule(0, newRule);
+ expect(rule.getRules()).toEqual([newRule]);
+ });
+
+ it('can remove an existing rule', () => {
+ const rule = new AnyRule([new AllRule()]);
+ rule.removeRule(0);
+ expect(rule.getRules()).toHaveLength(0);
+ });
+
+ it('can covert itself into a raw representation', () => {
+ const rule = new AnyRule([new AllRule()]);
+ expect(rule.toRaw()).toEqual({
+ any: [{ all: [] }],
+ });
+ });
+
+ it('can clone itself', () => {
+ const subRules = [new AllRule()];
+ const rule = new AnyRule(subRules);
+ const clone = rule.clone();
+
+ expect(clone.toRaw()).toEqual(rule.toRaw());
+ expect(clone.getRules()).toEqual(rule.getRules());
+ expect(clone.getRules()).not.toBe(rule.getRules());
+ });
+});
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/any_rule.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/any_rule.ts
new file mode 100644
index 0000000000000..6a4f2eaf1b362
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/any_rule.ts
@@ -0,0 +1,67 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { i18n } from '@kbn/i18n';
+import { RuleGroup } from './rule_group';
+import { Rule } from './rule';
+import { ExceptAllRule } from './except_all_rule';
+import { ExceptAnyRule } from './except_any_rule';
+
+/**
+ * Represents a group of rules in which at least one must evaluate to true.
+ */
+export class AnyRule extends RuleGroup {
+ constructor(private rules: Rule[] = []) {
+ super();
+ }
+
+ /** {@see RuleGroup.getRules} */
+ public getRules() {
+ return [...this.rules];
+ }
+
+ /** {@see RuleGroup.getDisplayTitle} */
+ public getDisplayTitle() {
+ return i18n.translate('xpack.security.management.editRoleMapping.anyRule.displayTitle', {
+ defaultMessage: 'Any are true',
+ });
+ }
+
+ /** {@see RuleGroup.replaceRule} */
+ public replaceRule(ruleIndex: number, rule: Rule) {
+ this.rules.splice(ruleIndex, 1, rule);
+ }
+
+ /** {@see RuleGroup.removeRule} */
+ public removeRule(ruleIndex: number) {
+ this.rules.splice(ruleIndex, 1);
+ }
+
+ /** {@see RuleGroup.addRule} */
+ public addRule(rule: Rule) {
+ this.rules.push(rule);
+ }
+
+ /** {@see RuleGroup.canContainRules} */
+ public canContainRules(rules: Rule[]) {
+ const forbiddenRules = [ExceptAllRule, ExceptAnyRule];
+ return rules.every(
+ candidate => !forbiddenRules.some(forbidden => candidate instanceof forbidden)
+ );
+ }
+
+ /** {@see RuleGroup.clone} */
+ public clone() {
+ return new AnyRule(this.rules.map(r => r.clone()));
+ }
+
+ /** {@see RuleGroup.toRaw} */
+ public toRaw() {
+ return {
+ any: [...this.rules.map(rule => rule.toRaw())],
+ };
+ }
+}
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/except_all_rule.test.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/except_all_rule.test.ts
new file mode 100644
index 0000000000000..7a00e5b19638f
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/except_all_rule.test.ts
@@ -0,0 +1,64 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { AllRule, AnyRule, FieldRule, ExceptAllRule, ExceptAnyRule, RuleGroup } from '.';
+
+describe('Except All rule', () => {
+ it('can be constructed without sub rules', () => {
+ const rule = new ExceptAllRule();
+ expect(rule.getRules()).toHaveLength(0);
+ });
+
+ it('can be constructed with sub rules', () => {
+ const rule = new ExceptAllRule([new AnyRule()]);
+ expect(rule.getRules()).toHaveLength(1);
+ });
+
+ it('can accept rules of any type', () => {
+ const subRules = [
+ new AllRule(),
+ new AnyRule(),
+ new FieldRule('username', '*'),
+ new ExceptAllRule(),
+ new ExceptAnyRule(),
+ ];
+
+ const rule = new ExceptAllRule() as RuleGroup;
+ expect(rule.canContainRules(subRules)).toEqual(true);
+ subRules.forEach(sr => rule.addRule(sr));
+ expect(rule.getRules()).toEqual([...subRules]);
+ });
+
+ it('can replace an existing rule', () => {
+ const rule = new ExceptAllRule([new AnyRule()]);
+ const newRule = new FieldRule('username', '*');
+ rule.replaceRule(0, newRule);
+ expect(rule.getRules()).toEqual([newRule]);
+ });
+
+ it('can remove an existing rule', () => {
+ const rule = new ExceptAllRule([new AnyRule()]);
+ rule.removeRule(0);
+ expect(rule.getRules()).toHaveLength(0);
+ });
+
+ it('can covert itself into a raw representation', () => {
+ const rule = new ExceptAllRule([new AnyRule()]);
+ expect(rule.toRaw()).toEqual({
+ except: { all: [{ any: [] }] },
+ });
+ });
+
+ it('can clone itself', () => {
+ const subRules = [new AllRule()];
+ const rule = new ExceptAllRule(subRules);
+ const clone = rule.clone();
+
+ expect(clone.toRaw()).toEqual(rule.toRaw());
+ expect(clone.getRules()).toEqual(rule.getRules());
+ expect(clone.getRules()).not.toBe(rule.getRules());
+ });
+});
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/except_all_rule.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/except_all_rule.ts
new file mode 100644
index 0000000000000..a67c2622a2533
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/except_all_rule.ts
@@ -0,0 +1,66 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { i18n } from '@kbn/i18n';
+import { RuleGroup } from './rule_group';
+import { Rule } from './rule';
+
+/**
+ * Represents a group of rules in which at least one must evaluate to false.
+ */
+export class ExceptAllRule extends RuleGroup {
+ constructor(private rules: Rule[] = []) {
+ super();
+ }
+
+ /** {@see RuleGroup.getRules} */
+ public getRules() {
+ return [...this.rules];
+ }
+
+ /** {@see RuleGroup.getDisplayTitle} */
+ public getDisplayTitle() {
+ return i18n.translate('xpack.security.management.editRoleMapping.exceptAllRule.displayTitle', {
+ defaultMessage: 'Any are false',
+ });
+ }
+
+ /** {@see RuleGroup.replaceRule} */
+ public replaceRule(ruleIndex: number, rule: Rule) {
+ this.rules.splice(ruleIndex, 1, rule);
+ }
+
+ /** {@see RuleGroup.removeRule} */
+ public removeRule(ruleIndex: number) {
+ this.rules.splice(ruleIndex, 1);
+ }
+
+ /** {@see RuleGroup.addRule} */
+ public addRule(rule: Rule) {
+ this.rules.push(rule);
+ }
+
+ /** {@see RuleGroup.canContainRules} */
+ public canContainRules() {
+ return true;
+ }
+
+ /** {@see RuleGroup.clone} */
+ public clone() {
+ return new ExceptAllRule(this.rules.map(r => r.clone()));
+ }
+
+ /** {@see RuleGroup.toRaw} */
+ public toRaw() {
+ const rawRule = {
+ all: [...this.rules.map(rule => rule.toRaw())],
+ };
+
+ return {
+ except: rawRule,
+ };
+ }
+}
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/except_any_rule.test.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/except_any_rule.test.ts
new file mode 100644
index 0000000000000..e4e182ce88d8d
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/except_any_rule.test.ts
@@ -0,0 +1,65 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { AllRule, AnyRule, FieldRule, ExceptAllRule, ExceptAnyRule, RuleGroup } from '.';
+
+describe('Except Any rule', () => {
+ it('can be constructed without sub rules', () => {
+ const rule = new ExceptAnyRule();
+ expect(rule.getRules()).toHaveLength(0);
+ });
+
+ it('can be constructed with sub rules', () => {
+ const rule = new ExceptAnyRule([new AllRule()]);
+ expect(rule.getRules()).toHaveLength(1);
+ });
+
+ it('can accept non-except rules', () => {
+ const subRules = [new AllRule(), new AnyRule(), new FieldRule('username', '*')];
+
+ const rule = new ExceptAnyRule() as RuleGroup;
+ expect(rule.canContainRules(subRules)).toEqual(true);
+ subRules.forEach(sr => rule.addRule(sr));
+ expect(rule.getRules()).toEqual([...subRules]);
+ });
+
+ it('cannot accept except rules', () => {
+ const subRules = [new ExceptAllRule(), new ExceptAnyRule()];
+
+ const rule = new ExceptAnyRule() as RuleGroup;
+ expect(rule.canContainRules(subRules)).toEqual(false);
+ });
+
+ it('can replace an existing rule', () => {
+ const rule = new ExceptAnyRule([new AllRule()]);
+ const newRule = new FieldRule('username', '*');
+ rule.replaceRule(0, newRule);
+ expect(rule.getRules()).toEqual([newRule]);
+ });
+
+ it('can remove an existing rule', () => {
+ const rule = new ExceptAnyRule([new AllRule()]);
+ rule.removeRule(0);
+ expect(rule.getRules()).toHaveLength(0);
+ });
+
+ it('can covert itself into a raw representation', () => {
+ const rule = new ExceptAnyRule([new AllRule()]);
+ expect(rule.toRaw()).toEqual({
+ except: { any: [{ all: [] }] },
+ });
+ });
+
+ it('can clone itself', () => {
+ const subRules = [new AllRule()];
+ const rule = new ExceptAnyRule(subRules);
+ const clone = rule.clone();
+
+ expect(clone.toRaw()).toEqual(rule.toRaw());
+ expect(clone.getRules()).toEqual(rule.getRules());
+ expect(clone.getRules()).not.toBe(rule.getRules());
+ });
+});
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/except_any_rule.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/except_any_rule.ts
new file mode 100644
index 0000000000000..12ec3fe85b80d
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/except_any_rule.ts
@@ -0,0 +1,70 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { i18n } from '@kbn/i18n';
+import { RuleGroup } from './rule_group';
+import { Rule } from './rule';
+import { ExceptAllRule } from './except_all_rule';
+
+/**
+ * Represents a group of rules in which none can evaluate to true (all must evaluate to false).
+ */
+export class ExceptAnyRule extends RuleGroup {
+ constructor(private rules: Rule[] = []) {
+ super();
+ }
+
+ /** {@see RuleGroup.getRules} */
+ public getRules() {
+ return [...this.rules];
+ }
+
+ /** {@see RuleGroup.getDisplayTitle} */
+ public getDisplayTitle() {
+ return i18n.translate('xpack.security.management.editRoleMapping.exceptAnyRule.displayTitle', {
+ defaultMessage: 'All are false',
+ });
+ }
+
+ /** {@see RuleGroup.replaceRule} */
+ public replaceRule(ruleIndex: number, rule: Rule) {
+ this.rules.splice(ruleIndex, 1, rule);
+ }
+
+ /** {@see RuleGroup.removeRule} */
+ public removeRule(ruleIndex: number) {
+ this.rules.splice(ruleIndex, 1);
+ }
+
+ /** {@see RuleGroup.addRule} */
+ public addRule(rule: Rule) {
+ this.rules.push(rule);
+ }
+
+ /** {@see RuleGroup.canContainRules} */
+ public canContainRules(rules: Rule[]) {
+ const forbiddenRules = [ExceptAllRule, ExceptAnyRule];
+ return rules.every(
+ candidate => !forbiddenRules.some(forbidden => candidate instanceof forbidden)
+ );
+ }
+
+ /** {@see RuleGroup.clone} */
+ public clone() {
+ return new ExceptAnyRule(this.rules.map(r => r.clone()));
+ }
+
+ /** {@see RuleGroup.toRaw} */
+ public toRaw() {
+ const rawRule = {
+ any: [...this.rules.map(rule => rule.toRaw())],
+ };
+
+ return {
+ except: rawRule,
+ };
+ }
+}
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/field_rule.test.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/field_rule.test.ts
new file mode 100644
index 0000000000000..3447e81707002
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/field_rule.test.ts
@@ -0,0 +1,45 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { FieldRule } from '.';
+
+describe('FieldRule', () => {
+ ['*', 1, null, true, false].forEach(value => {
+ it(`can convert itself to raw form with a single value of ${value}`, () => {
+ const rule = new FieldRule('username', value);
+ expect(rule.toRaw()).toEqual({
+ field: {
+ username: value,
+ },
+ });
+ });
+ });
+
+ it('can convert itself to raw form with an array of values', () => {
+ const values = ['*', 1, null, true, false];
+ const rule = new FieldRule('username', values);
+ const raw = rule.toRaw();
+ expect(raw).toEqual({
+ field: {
+ username: ['*', 1, null, true, false],
+ },
+ });
+
+ // shoud not be the same array instance
+ expect(raw.field.username).not.toBe(values);
+ });
+
+ it('can clone itself', () => {
+ const values = ['*', 1, null];
+ const rule = new FieldRule('username', values);
+
+ const clone = rule.clone();
+ expect(clone.field).toEqual(rule.field);
+ expect(clone.value).toEqual(rule.value);
+ expect(clone.value).not.toBe(rule.value);
+ expect(clone.toRaw()).toEqual(rule.toRaw());
+ });
+});
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/field_rule.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/field_rule.ts
new file mode 100644
index 0000000000000..3e6a0e1e7ecb3
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/field_rule.ts
@@ -0,0 +1,47 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { i18n } from '@kbn/i18n';
+import { Rule } from './rule';
+
+/** The allowed types for field rule values */
+export type FieldRuleValue =
+ | string
+ | number
+ | null
+ | boolean
+ | Array;
+
+/**
+ * Represents a single field rule.
+ * Ex: "username = 'foo'"
+ */
+export class FieldRule extends Rule {
+ constructor(public readonly field: string, public readonly value: FieldRuleValue) {
+ super();
+ }
+
+ /** {@see Rule.getDisplayTitle} */
+ public getDisplayTitle() {
+ return i18n.translate('xpack.security.management.editRoleMapping.fieldRule.displayTitle', {
+ defaultMessage: 'The following is true',
+ });
+ }
+
+ /** {@see Rule.clone} */
+ public clone() {
+ return new FieldRule(this.field, Array.isArray(this.value) ? [...this.value] : this.value);
+ }
+
+ /** {@see Rule.toRaw} */
+ public toRaw() {
+ return {
+ field: {
+ [this.field]: Array.isArray(this.value) ? [...this.value] : this.value,
+ },
+ };
+ }
+}
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/index.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/index.ts
new file mode 100644
index 0000000000000..cbc970f02b03e
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/index.ts
@@ -0,0 +1,15 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export { AllRule } from './all_rule';
+export { AnyRule } from './any_rule';
+export { Rule } from './rule';
+export { RuleGroup } from './rule_group';
+export { ExceptAllRule } from './except_all_rule';
+export { ExceptAnyRule } from './except_any_rule';
+export { FieldRule, FieldRuleValue } from './field_rule';
+export { generateRulesFromRaw } from './rule_builder';
+export { RuleBuilderError } from './rule_builder_error';
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/rule.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/rule.ts
new file mode 100644
index 0000000000000..5cab2f1736e94
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/rule.ts
@@ -0,0 +1,25 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+/**
+ * Represents a Role Mapping rule.
+ */
+export abstract class Rule {
+ /**
+ * Converts this rule into a raw object for use in the persisted Role Mapping.
+ */
+ abstract toRaw(): Record;
+
+ /**
+ * The display title for this rule.
+ */
+ abstract getDisplayTitle(): string;
+
+ /**
+ * Returns a new instance of this rule.
+ */
+ abstract clone(): Rule;
+}
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/rule_builder.test.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/rule_builder.test.ts
new file mode 100644
index 0000000000000..ebd48f6d15d99
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/rule_builder.test.ts
@@ -0,0 +1,343 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { generateRulesFromRaw, FieldRule } from '.';
+import { RoleMapping } from '../../../../../common/model';
+import { RuleBuilderError } from './rule_builder_error';
+
+describe('generateRulesFromRaw', () => {
+ it('returns null for an empty rule set', () => {
+ expect(generateRulesFromRaw({})).toEqual({
+ rules: null,
+ maxDepth: 0,
+ });
+ });
+
+ it('returns a correctly parsed rule set', () => {
+ const rawRules: RoleMapping['rules'] = {
+ all: [
+ {
+ except: {
+ all: [
+ {
+ field: { username: '*' },
+ },
+ ],
+ },
+ },
+ {
+ any: [
+ {
+ field: { dn: '*' },
+ },
+ ],
+ },
+ ],
+ };
+
+ const { rules, maxDepth } = generateRulesFromRaw(rawRules);
+
+ expect(rules).toMatchInlineSnapshot(`
+ AllRule {
+ "rules": Array [
+ ExceptAllRule {
+ "rules": Array [
+ FieldRule {
+ "field": "username",
+ "value": "*",
+ },
+ ],
+ },
+ AnyRule {
+ "rules": Array [
+ FieldRule {
+ "field": "dn",
+ "value": "*",
+ },
+ ],
+ },
+ ],
+ }
+ `);
+ expect(maxDepth).toEqual(3);
+ });
+
+ it('does not support multiple rules at the root level', () => {
+ expect(() => {
+ generateRulesFromRaw({
+ all: [
+ {
+ field: { username: '*' },
+ },
+ ],
+ any: [
+ {
+ field: { username: '*' },
+ },
+ ],
+ });
+ }).toThrowError('Expected a single rule definition, but found 2.');
+ });
+
+ it('provides a rule trace describing the location of the error', () => {
+ try {
+ generateRulesFromRaw({
+ all: [
+ {
+ field: { username: '*' },
+ },
+ {
+ any: [
+ {
+ field: { username: '*' },
+ },
+ {
+ except: { field: { username: '*' } },
+ },
+ ],
+ },
+ ],
+ });
+ throw new Error(`Expected generateRulesFromRaw to throw error.`);
+ } catch (e) {
+ if (e instanceof RuleBuilderError) {
+ expect(e.message).toEqual(`"except" rule can only exist within an "all" rule.`);
+ expect(e.ruleTrace).toEqual(['all', '[1]', 'any', '[1]', 'except']);
+ } else {
+ throw e;
+ }
+ }
+ });
+
+ it('calculates the max depth of the rule tree', () => {
+ const rules = {
+ all: [
+ // depth = 1
+ {
+ // depth = 2
+ all: [
+ // depth = 3
+ {
+ any: [
+ // depth == 4
+ { field: { username: 'foo' } },
+ ],
+ },
+ { except: { field: { username: 'foo' } } },
+ ],
+ },
+ {
+ // depth = 2
+ any: [
+ {
+ // depth = 3
+ all: [
+ {
+ // depth = 4
+ any: [
+ {
+ // depth = 5
+ all: [
+ {
+ // depth = 6
+ all: [
+ // depth = 7
+ {
+ except: {
+ field: { username: 'foo' },
+ },
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ };
+
+ expect(generateRulesFromRaw(rules).maxDepth).toEqual(7);
+ });
+
+ describe('"any"', () => {
+ it('expects an array value', () => {
+ expect(() => {
+ generateRulesFromRaw({
+ any: {
+ field: { username: '*' },
+ } as any,
+ });
+ }).toThrowError('Expected an array of rules, but found object.');
+ });
+
+ it('expects each entry to be an object with a single property', () => {
+ expect(() => {
+ generateRulesFromRaw({
+ any: [
+ {
+ any: [{ field: { foo: 'bar' } }],
+ all: [{ field: { foo: 'bar' } }],
+ } as any,
+ ],
+ });
+ }).toThrowError('Expected a single rule definition, but found 2.');
+ });
+ });
+
+ describe('"all"', () => {
+ it('expects an array value', () => {
+ expect(() => {
+ generateRulesFromRaw({
+ all: {
+ field: { username: '*' },
+ } as any,
+ });
+ }).toThrowError('Expected an array of rules, but found object.');
+ });
+
+ it('expects each entry to be an object with a single property', () => {
+ expect(() => {
+ generateRulesFromRaw({
+ all: [
+ {
+ field: { username: '*' },
+ any: [{ field: { foo: 'bar' } }],
+ } as any,
+ ],
+ });
+ }).toThrowError('Expected a single rule definition, but found 2.');
+ });
+ });
+
+ describe('"field"', () => {
+ it(`expects an object value`, () => {
+ expect(() => {
+ generateRulesFromRaw({
+ field: [
+ {
+ username: '*',
+ },
+ ],
+ });
+ }).toThrowError('Expected an object, but found array.');
+ });
+
+ it(`expects an single property in its object value`, () => {
+ expect(() => {
+ generateRulesFromRaw({
+ field: {
+ username: '*',
+ dn: '*',
+ },
+ });
+ }).toThrowError('Expected a single field, but found 2.');
+ });
+
+ it('accepts an array of possible values', () => {
+ const { rules } = generateRulesFromRaw({
+ field: {
+ username: [0, '*', null, 'foo', true, false],
+ },
+ });
+
+ expect(rules).toBeInstanceOf(FieldRule);
+ expect((rules as FieldRule).field).toEqual('username');
+ expect((rules as FieldRule).value).toEqual([0, '*', null, 'foo', true, false]);
+ });
+
+ [{}, () => null, undefined, [{}, undefined, [], () => null]].forEach(invalidValue => {
+ it(`does not support a value of ${invalidValue}`, () => {
+ expect(() => {
+ generateRulesFromRaw({
+ field: {
+ username: invalidValue,
+ },
+ });
+ }).toThrowErrorMatchingSnapshot();
+ });
+ });
+ });
+
+ describe('"except"', () => {
+ it(`expects an object value`, () => {
+ expect(() => {
+ generateRulesFromRaw({
+ all: [
+ {
+ except: [
+ {
+ field: { username: '*' },
+ },
+ ],
+ },
+ ],
+ } as any);
+ }).toThrowError('Expected an object, but found array.');
+ });
+
+ it(`can only be nested inside an "all" clause`, () => {
+ expect(() => {
+ generateRulesFromRaw({
+ any: [
+ {
+ except: {
+ field: {
+ username: '*',
+ },
+ },
+ },
+ ],
+ });
+ }).toThrowError(`"except" rule can only exist within an "all" rule.`);
+
+ expect(() => {
+ generateRulesFromRaw({
+ except: {
+ field: {
+ username: '*',
+ },
+ },
+ });
+ }).toThrowError(`"except" rule can only exist within an "all" rule.`);
+ });
+
+ it('converts an "except field" rule into an equivilent "except all" rule', () => {
+ expect(
+ generateRulesFromRaw({
+ all: [
+ {
+ except: {
+ field: {
+ username: '*',
+ },
+ },
+ },
+ ],
+ })
+ ).toMatchInlineSnapshot(`
+ Object {
+ "maxDepth": 2,
+ "rules": AllRule {
+ "rules": Array [
+ ExceptAllRule {
+ "rules": Array [
+ FieldRule {
+ "field": "username",
+ "value": "*",
+ },
+ ],
+ },
+ ],
+ },
+ }
+ `);
+ });
+ });
+});
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/rule_builder.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/rule_builder.ts
new file mode 100644
index 0000000000000..fe344b2ae38dd
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/rule_builder.ts
@@ -0,0 +1,203 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { i18n } from '@kbn/i18n';
+import { RoleMapping } from '../../../../../common/model';
+import { FieldRule, FieldRuleValue } from './field_rule';
+import { AllRule } from './all_rule';
+import { AnyRule } from './any_rule';
+import { Rule } from './rule';
+import { ExceptAllRule } from './except_all_rule';
+import { ExceptAnyRule } from './except_any_rule';
+import { RuleBuilderError } from '.';
+
+interface RuleBuilderResult {
+ /** The maximum rule depth within the parsed rule set. */
+ maxDepth: number;
+
+ /** The parsed rule set. */
+ rules: Rule | null;
+}
+
+/**
+ * Given a set of raw rules, this constructs a class based tree for consumption by the Role Management UI.
+ * This also performs validation on the raw rule set, as it is possible to enter raw JSON in the JSONRuleEditor,
+ * so we have no guarantees that the rule set is valid ahead of time.
+ *
+ * @param rawRules the raw rules to translate.
+ */
+export function generateRulesFromRaw(rawRules: RoleMapping['rules'] = {}): RuleBuilderResult {
+ return parseRawRules(rawRules, null, [], 0);
+}
+
+function parseRawRules(
+ rawRules: RoleMapping['rules'],
+ parentRuleType: string | null,
+ ruleTrace: string[],
+ depth: number
+): RuleBuilderResult {
+ const entries = Object.entries(rawRules);
+ if (!entries.length) {
+ return {
+ rules: null,
+ maxDepth: 0,
+ };
+ }
+ if (entries.length > 1) {
+ throw new RuleBuilderError(
+ i18n.translate('xpack.security.management.editRoleMapping.ruleBuilder.expectSingleRule', {
+ defaultMessage: `Expected a single rule definition, but found {numberOfRules}.`,
+ values: { numberOfRules: entries.length },
+ }),
+ ruleTrace
+ );
+ }
+
+ const rule = entries[0];
+ const [ruleType, ruleDefinition] = rule;
+ return createRuleForType(ruleType, ruleDefinition, parentRuleType, ruleTrace, depth + 1);
+}
+
+function createRuleForType(
+ ruleType: string,
+ ruleDefinition: any,
+ parentRuleType: string | null,
+ ruleTrace: string[] = [],
+ depth: number
+): RuleBuilderResult {
+ const isRuleNegated = parentRuleType === 'except';
+
+ const currentRuleTrace = [...ruleTrace, ruleType];
+
+ switch (ruleType) {
+ case 'field': {
+ assertIsObject(ruleDefinition, currentRuleTrace);
+
+ const entries = Object.entries(ruleDefinition);
+ if (entries.length !== 1) {
+ throw new RuleBuilderError(
+ i18n.translate(
+ 'xpack.security.management.editRoleMapping.ruleBuilder.expectedSingleFieldRule',
+ {
+ defaultMessage: `Expected a single field, but found {count}.`,
+ values: { count: entries.length },
+ }
+ ),
+ currentRuleTrace
+ );
+ }
+
+ const [field, value] = entries[0] as [string, FieldRuleValue];
+ const values = Array.isArray(value) ? value : [value];
+ values.forEach(fieldValue => {
+ const valueType = typeof fieldValue;
+ if (fieldValue !== null && !['string', 'number', 'boolean'].includes(valueType)) {
+ throw new RuleBuilderError(
+ i18n.translate(
+ 'xpack.security.management.editRoleMapping.ruleBuilder.invalidFieldValueType',
+ {
+ defaultMessage: `Invalid value type for field. Expected one of null, string, number, or boolean, but found {valueType} ({value}).`,
+ values: { valueType, value: JSON.stringify(value) },
+ }
+ ),
+ currentRuleTrace
+ );
+ }
+ });
+
+ const fieldRule = new FieldRule(field, value);
+ return {
+ rules: isRuleNegated ? new ExceptAllRule([fieldRule]) : fieldRule,
+ maxDepth: depth,
+ };
+ }
+ case 'any': // intentional fall-through to 'all', as validation logic is identical
+ case 'all': {
+ if (ruleDefinition != null && !Array.isArray(ruleDefinition)) {
+ throw new RuleBuilderError(
+ i18n.translate(
+ 'xpack.security.management.editRoleMapping.ruleBuilder.expectedArrayForGroupRule',
+ {
+ defaultMessage: `Expected an array of rules, but found {type}.`,
+ values: { type: typeof ruleDefinition },
+ }
+ ),
+ currentRuleTrace
+ );
+ }
+
+ const subRulesResults = ((ruleDefinition as any[]) || []).map((definition: any, index) =>
+ parseRawRules(definition, ruleType, [...currentRuleTrace, `[${index}]`], depth)
+ ) as RuleBuilderResult[];
+
+ const { subRules, maxDepth } = subRulesResults.reduce(
+ (acc, result) => {
+ return {
+ subRules: [...acc.subRules, result.rules!],
+ maxDepth: Math.max(acc.maxDepth, result.maxDepth),
+ };
+ },
+ { subRules: [] as Rule[], maxDepth: 0 }
+ );
+
+ if (ruleType === 'all') {
+ return {
+ rules: isRuleNegated ? new ExceptAllRule(subRules) : new AllRule(subRules),
+ maxDepth,
+ };
+ } else {
+ return {
+ rules: isRuleNegated ? new ExceptAnyRule(subRules) : new AnyRule(subRules),
+ maxDepth,
+ };
+ }
+ }
+ case 'except': {
+ assertIsObject(ruleDefinition, currentRuleTrace);
+
+ if (parentRuleType !== 'all') {
+ throw new RuleBuilderError(
+ i18n.translate(
+ 'xpack.security.management.editRoleMapping.ruleBuilder.exceptOnlyInAllRule',
+ {
+ defaultMessage: `"except" rule can only exist within an "all" rule.`,
+ }
+ ),
+ currentRuleTrace
+ );
+ }
+ // subtracting 1 from depth because we don't currently count the "except" level itself as part of the depth calculation
+ // for the purpose of determining if the rule set is "too complex" for the visual rule editor.
+ // The "except" rule MUST be nested within an "all" rule type (see validation above), so the depth itself will always be a non-negative number.
+ return parseRawRules(ruleDefinition || {}, ruleType, currentRuleTrace, depth - 1);
+ }
+ default:
+ throw new RuleBuilderError(
+ i18n.translate('xpack.security.management.editRoleMapping.ruleBuilder.unknownRuleType', {
+ defaultMessage: `Unknown rule type: {ruleType}.`,
+ values: { ruleType },
+ }),
+ currentRuleTrace
+ );
+ }
+}
+
+function assertIsObject(ruleDefinition: any, ruleTrace: string[]) {
+ let fieldType: string = typeof ruleDefinition;
+ if (Array.isArray(ruleDefinition)) {
+ fieldType = 'array';
+ }
+
+ if (ruleDefinition && fieldType !== 'object') {
+ throw new RuleBuilderError(
+ i18n.translate('xpack.security.management.editRoleMapping.ruleBuilder.expectedObjectError', {
+ defaultMessage: `Expected an object, but found {type}.`,
+ values: { type: fieldType },
+ }),
+ ruleTrace
+ );
+ }
+}
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/rule_builder_error.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/rule_builder_error.ts
new file mode 100644
index 0000000000000..87d73cde00dd6
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/rule_builder_error.ts
@@ -0,0 +1,20 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+/**
+ * Describes an error during rule building.
+ * In addition to a user-"friendly" message, this also includes a rule trace,
+ * which is the "JSON path" where the error occurred.
+ */
+export class RuleBuilderError extends Error {
+ constructor(message: string, public readonly ruleTrace: string[]) {
+ super(message);
+
+ // Set the prototype explicitly, see:
+ // https://github.com/Microsoft/TypeScript/wiki/Breaking-Changes#extending-built-ins-like-error-array-and-map-may-no-longer-work
+ Object.setPrototypeOf(this, RuleBuilderError.prototype);
+ }
+}
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/rule_group.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/rule_group.ts
new file mode 100644
index 0000000000000..3e1e7fad9b36f
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/rule_group.ts
@@ -0,0 +1,42 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { Rule } from './rule';
+
+/**
+ * Represents a catagory of Role Mapping rules which are capable of containing other rules.
+ */
+export abstract class RuleGroup extends Rule {
+ /**
+ * Returns all immediate sub-rules within this group (non-recursive).
+ */
+ abstract getRules(): Rule[];
+
+ /**
+ * Replaces the rule at the indicated location.
+ * @param ruleIndex the location of the rule to replace.
+ * @param rule the new rule.
+ */
+ abstract replaceRule(ruleIndex: number, rule: Rule): void;
+
+ /**
+ * Removes the rule at the indicated location.
+ * @param ruleIndex the location of the rule to remove.
+ */
+ abstract removeRule(ruleIndex: number): void;
+
+ /**
+ * Adds a rule to this group.
+ * @param rule the rule to add.
+ */
+ abstract addRule(rule: Rule): void;
+
+ /**
+ * Determines if the provided rules are allowed to be contained within this group.
+ * @param rules the rules to test.
+ */
+ abstract canContainRules(rules: Rule[]): boolean;
+}
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/components/create_role_mapping_button/create_role_mapping_button.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/components/create_role_mapping_button/create_role_mapping_button.tsx
new file mode 100644
index 0000000000000..2342eeb97d03e
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/components/create_role_mapping_button/create_role_mapping_button.tsx
@@ -0,0 +1,21 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+import { EuiButton } from '@elastic/eui';
+import { FormattedMessage } from '@kbn/i18n/react';
+import { getCreateRoleMappingHref } from '../../../../management_urls';
+
+export const CreateRoleMappingButton = () => {
+ return (
+
+
+
+ );
+};
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/components/create_role_mapping_button/index.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/components/create_role_mapping_button/index.ts
new file mode 100644
index 0000000000000..417bf50d754e6
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/components/create_role_mapping_button/index.ts
@@ -0,0 +1,7 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export { CreateRoleMappingButton } from './create_role_mapping_button';
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/components/empty_prompt/empty_prompt.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/components/empty_prompt/empty_prompt.tsx
new file mode 100644
index 0000000000000..1919d3fbf4785
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/components/empty_prompt/empty_prompt.tsx
@@ -0,0 +1,36 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React, { Fragment } from 'react';
+import { EuiEmptyPrompt } from '@elastic/eui';
+import { FormattedMessage } from '@kbn/i18n/react';
+import { CreateRoleMappingButton } from '../create_role_mapping_button';
+
+export const EmptyPrompt: React.FunctionComponent<{}> = () => (
+
+
+
+ }
+ body={
+
+
+
+
+
+ }
+ actions={ }
+ data-test-subj="roleMappingsEmptyPrompt"
+ />
+);
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/components/empty_prompt/index.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/components/empty_prompt/index.ts
new file mode 100644
index 0000000000000..982e34a0ceed5
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/components/empty_prompt/index.ts
@@ -0,0 +1,7 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export { EmptyPrompt } from './empty_prompt';
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/components/index.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/components/index.ts
new file mode 100644
index 0000000000000..4bd5df71da446
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/components/index.ts
@@ -0,0 +1,7 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export { RoleMappingsGridPage } from './role_mappings_grid_page';
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/components/role_mappings_grid_page.test.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/components/role_mappings_grid_page.test.tsx
new file mode 100644
index 0000000000000..259cdc71e25a2
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/components/role_mappings_grid_page.test.tsx
@@ -0,0 +1,182 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers';
+import { RoleMappingsGridPage } from '.';
+import { SectionLoading, PermissionDenied, NoCompatibleRealms } from '../../components';
+import { EmptyPrompt } from './empty_prompt';
+import { findTestSubject } from 'test_utils/find_test_subject';
+import { EuiLink } from '@elastic/eui';
+import { RoleMappingsAPI } from '../../../../../lib/role_mappings_api';
+import { act } from '@testing-library/react';
+
+describe('RoleMappingsGridPage', () => {
+ it('renders an empty prompt when no role mappings exist', async () => {
+ const roleMappingsAPI = ({
+ getRoleMappings: jest.fn().mockResolvedValue([]),
+ checkRoleMappingFeatures: jest.fn().mockResolvedValue({
+ canManageRoleMappings: true,
+ hasCompatibleRealms: true,
+ }),
+ } as unknown) as RoleMappingsAPI;
+
+ const wrapper = mountWithIntl( );
+ expect(wrapper.find(SectionLoading)).toHaveLength(1);
+ expect(wrapper.find(EmptyPrompt)).toHaveLength(0);
+
+ await nextTick();
+ wrapper.update();
+
+ expect(wrapper.find(SectionLoading)).toHaveLength(0);
+ expect(wrapper.find(NoCompatibleRealms)).toHaveLength(0);
+ expect(wrapper.find(EmptyPrompt)).toHaveLength(1);
+ });
+
+ it('renders a permission denied message when unauthorized to manage role mappings', async () => {
+ const roleMappingsAPI = ({
+ checkRoleMappingFeatures: jest.fn().mockResolvedValue({
+ canManageRoleMappings: false,
+ hasCompatibleRealms: true,
+ }),
+ } as unknown) as RoleMappingsAPI;
+
+ const wrapper = mountWithIntl( );
+ expect(wrapper.find(SectionLoading)).toHaveLength(1);
+ expect(wrapper.find(PermissionDenied)).toHaveLength(0);
+
+ await nextTick();
+ wrapper.update();
+
+ expect(wrapper.find(SectionLoading)).toHaveLength(0);
+ expect(wrapper.find(NoCompatibleRealms)).toHaveLength(0);
+ expect(wrapper.find(PermissionDenied)).toHaveLength(1);
+ });
+
+ it('renders a warning when there are no compatible realms enabled', async () => {
+ const roleMappingsAPI = ({
+ getRoleMappings: jest.fn().mockResolvedValue([
+ {
+ name: 'some realm',
+ enabled: true,
+ roles: [],
+ rules: { field: { username: '*' } },
+ },
+ ]),
+ checkRoleMappingFeatures: jest.fn().mockResolvedValue({
+ canManageRoleMappings: true,
+ hasCompatibleRealms: false,
+ }),
+ } as unknown) as RoleMappingsAPI;
+
+ const wrapper = mountWithIntl( );
+ expect(wrapper.find(SectionLoading)).toHaveLength(1);
+ expect(wrapper.find(NoCompatibleRealms)).toHaveLength(0);
+
+ await nextTick();
+ wrapper.update();
+
+ expect(wrapper.find(SectionLoading)).toHaveLength(0);
+ expect(wrapper.find(NoCompatibleRealms)).toHaveLength(1);
+ });
+
+ it('renders links to mapped roles', async () => {
+ const roleMappingsAPI = ({
+ getRoleMappings: jest.fn().mockResolvedValue([
+ {
+ name: 'some realm',
+ enabled: true,
+ roles: ['superuser'],
+ rules: { field: { username: '*' } },
+ },
+ ]),
+ checkRoleMappingFeatures: jest.fn().mockResolvedValue({
+ canManageRoleMappings: true,
+ hasCompatibleRealms: true,
+ }),
+ } as unknown) as RoleMappingsAPI;
+
+ const wrapper = mountWithIntl( );
+ await nextTick();
+ wrapper.update();
+
+ const links = findTestSubject(wrapper, 'roleMappingRoles').find(EuiLink);
+ expect(links).toHaveLength(1);
+ expect(links.at(0).props()).toMatchObject({
+ href: '#/management/security/roles/edit/superuser',
+ });
+ });
+
+ it('describes the number of mapped role templates', async () => {
+ const roleMappingsAPI = ({
+ getRoleMappings: jest.fn().mockResolvedValue([
+ {
+ name: 'some realm',
+ enabled: true,
+ role_templates: [{}, {}],
+ rules: { field: { username: '*' } },
+ },
+ ]),
+ checkRoleMappingFeatures: jest.fn().mockResolvedValue({
+ canManageRoleMappings: true,
+ hasCompatibleRealms: true,
+ }),
+ } as unknown) as RoleMappingsAPI;
+
+ const wrapper = mountWithIntl( );
+ await nextTick();
+ wrapper.update();
+
+ const templates = findTestSubject(wrapper, 'roleMappingRoles');
+ expect(templates).toHaveLength(1);
+ expect(templates.text()).toEqual(`2 role templates defined`);
+ });
+
+ it('allows role mappings to be deleted, refreshing the grid after', async () => {
+ const roleMappingsAPI = ({
+ getRoleMappings: jest.fn().mockResolvedValue([
+ {
+ name: 'some-realm',
+ enabled: true,
+ roles: ['superuser'],
+ rules: { field: { username: '*' } },
+ },
+ ]),
+ checkRoleMappingFeatures: jest.fn().mockResolvedValue({
+ canManageRoleMappings: true,
+ hasCompatibleRealms: true,
+ }),
+ deleteRoleMappings: jest.fn().mockReturnValue(
+ Promise.resolve([
+ {
+ name: 'some-realm',
+ success: true,
+ },
+ ])
+ ),
+ } as unknown) as RoleMappingsAPI;
+
+ const wrapper = mountWithIntl( );
+ await nextTick();
+ wrapper.update();
+
+ expect(roleMappingsAPI.getRoleMappings).toHaveBeenCalledTimes(1);
+ expect(roleMappingsAPI.deleteRoleMappings).not.toHaveBeenCalled();
+
+ findTestSubject(wrapper, `deleteRoleMappingButton-some-realm`).simulate('click');
+ expect(findTestSubject(wrapper, 'deleteRoleMappingConfirmationModal')).toHaveLength(1);
+
+ await act(async () => {
+ findTestSubject(wrapper, 'confirmModalConfirmButton').simulate('click');
+ await nextTick();
+ wrapper.update();
+ });
+
+ expect(roleMappingsAPI.deleteRoleMappings).toHaveBeenCalledWith(['some-realm']);
+ // Expect an additional API call to refresh the grid
+ expect(roleMappingsAPI.getRoleMappings).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/components/role_mappings_grid_page.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/components/role_mappings_grid_page.tsx
new file mode 100644
index 0000000000000..7b23f2288d1ba
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/components/role_mappings_grid_page.tsx
@@ -0,0 +1,474 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React, { Component, Fragment } from 'react';
+import {
+ EuiBadge,
+ EuiButton,
+ EuiButtonIcon,
+ EuiCallOut,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiInMemoryTable,
+ EuiLink,
+ EuiPageContent,
+ EuiPageContentBody,
+ EuiPageContentHeader,
+ EuiPageContentHeaderSection,
+ EuiSpacer,
+ EuiText,
+ EuiTitle,
+ EuiToolTip,
+} from '@elastic/eui';
+import { i18n } from '@kbn/i18n';
+import { FormattedMessage } from '@kbn/i18n/react';
+import { RoleMapping } from '../../../../../../common/model';
+import { RoleMappingsAPI } from '../../../../../lib/role_mappings_api';
+import { EmptyPrompt } from './empty_prompt';
+import {
+ NoCompatibleRealms,
+ DeleteProvider,
+ PermissionDenied,
+ SectionLoading,
+} from '../../components';
+import { documentationLinks } from '../../services/documentation_links';
+import {
+ getCreateRoleMappingHref,
+ getEditRoleMappingHref,
+ getEditRoleHref,
+} from '../../../management_urls';
+
+interface Props {
+ roleMappingsAPI: RoleMappingsAPI;
+}
+
+interface State {
+ loadState: 'loadingApp' | 'loadingTable' | 'permissionDenied' | 'finished';
+ roleMappings: null | RoleMapping[];
+ selectedItems: RoleMapping[];
+ hasCompatibleRealms: boolean;
+ error: any;
+}
+
+export class RoleMappingsGridPage extends Component {
+ constructor(props: any) {
+ super(props);
+ this.state = {
+ loadState: 'loadingApp',
+ roleMappings: null,
+ hasCompatibleRealms: true,
+ selectedItems: [],
+ error: undefined,
+ };
+ }
+
+ public componentDidMount() {
+ this.checkPrivileges();
+ }
+
+ public render() {
+ const { loadState, error, roleMappings } = this.state;
+
+ if (loadState === 'permissionDenied') {
+ return ;
+ }
+
+ if (loadState === 'loadingApp') {
+ return (
+
+
+
+
+
+ );
+ }
+
+ if (error) {
+ const {
+ body: { error: errorTitle, message, statusCode },
+ } = error;
+
+ return (
+
+
+ }
+ color="danger"
+ iconType="alert"
+ >
+ {statusCode}: {errorTitle} - {message}
+
+
+ );
+ }
+
+ if (loadState === 'finished' && roleMappings && roleMappings.length === 0) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ),
+ }}
+ />
+
+
+
+
+
+
+
+
+
+
+
+ {!this.state.hasCompatibleRealms && (
+ <>
+
+
+ >
+ )}
+ {this.renderTable()}
+
+
+
+ );
+ }
+
+ private renderTable = () => {
+ const { roleMappings, selectedItems, loadState } = this.state;
+
+ const message =
+ loadState === 'loadingTable' ? (
+
+ ) : (
+ undefined
+ );
+
+ const sorting = {
+ sort: {
+ field: 'name',
+ direction: 'asc' as any,
+ },
+ };
+
+ const pagination = {
+ initialPageSize: 20,
+ pageSizeOptions: [10, 20, 50],
+ };
+
+ const selection = {
+ onSelectionChange: (newSelectedItems: RoleMapping[]) => {
+ this.setState({
+ selectedItems: newSelectedItems,
+ });
+ },
+ };
+
+ const search = {
+ toolsLeft: selectedItems.length ? (
+
+ {deleteRoleMappingsPrompt => {
+ return (
+ deleteRoleMappingsPrompt(selectedItems, this.onRoleMappingsDeleted)}
+ color="danger"
+ data-test-subj="bulkDeleteActionButton"
+ >
+
+
+ );
+ }}
+
+ ) : (
+ undefined
+ ),
+ toolsRight: (
+ this.reloadRoleMappings()}
+ data-test-subj="reloadButton"
+ >
+
+
+ ),
+ box: {
+ incremental: true,
+ },
+ filters: undefined,
+ };
+
+ return (
+ {
+ return {
+ 'data-test-subj': 'roleMappingRow',
+ };
+ }}
+ />
+ );
+ };
+
+ private getColumnConfig = () => {
+ const config = [
+ {
+ field: 'name',
+ name: i18n.translate('xpack.security.management.roleMappings.nameColumnName', {
+ defaultMessage: 'Name',
+ }),
+ sortable: true,
+ render: (roleMappingName: string) => {
+ return (
+
+ {roleMappingName}
+
+ );
+ },
+ },
+ {
+ field: 'roles',
+ name: i18n.translate('xpack.security.management.roleMappings.rolesColumnName', {
+ defaultMessage: 'Roles',
+ }),
+ sortable: true,
+ render: (entry: any, record: RoleMapping) => {
+ const { roles = [], role_templates: roleTemplates = [] } = record;
+ if (roleTemplates.length > 0) {
+ return (
+
+ {i18n.translate('xpack.security.management.roleMappings.roleTemplates', {
+ defaultMessage:
+ '{templateCount, plural, one{# role template} other {# role templates}} defined',
+ values: {
+ templateCount: roleTemplates.length,
+ },
+ })}
+
+ );
+ }
+ const roleLinks = roles.map((rolename, index) => {
+ return (
+
+ {rolename}
+ {index === roles.length - 1 ? null : ', '}
+
+ );
+ });
+ return {roleLinks}
;
+ },
+ },
+ {
+ field: 'enabled',
+ name: i18n.translate('xpack.security.management.roleMappings.enabledColumnName', {
+ defaultMessage: 'Enabled',
+ }),
+ sortable: true,
+ render: (enabled: boolean) => {
+ if (enabled) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ );
+ },
+ },
+ {
+ name: i18n.translate('xpack.security.management.roleMappings.actionsColumnName', {
+ defaultMessage: 'Actions',
+ }),
+ actions: [
+ {
+ render: (record: RoleMapping) => {
+ return (
+
+
+
+ );
+ },
+ },
+ {
+ render: (record: RoleMapping) => {
+ return (
+
+
+
+ {deleteRoleMappingPrompt => {
+ return (
+
+
+ deleteRoleMappingPrompt([record], this.onRoleMappingsDeleted)
+ }
+ />
+
+ );
+ }}
+
+
+
+ );
+ },
+ },
+ ],
+ },
+ ];
+ return config;
+ };
+
+ private onRoleMappingsDeleted = (roleMappings: string[]): void => {
+ if (roleMappings.length) {
+ this.reloadRoleMappings();
+ }
+ };
+
+ private async checkPrivileges() {
+ try {
+ const {
+ canManageRoleMappings,
+ hasCompatibleRealms,
+ } = await this.props.roleMappingsAPI.checkRoleMappingFeatures();
+
+ this.setState({
+ loadState: canManageRoleMappings ? this.state.loadState : 'permissionDenied',
+ hasCompatibleRealms,
+ });
+
+ if (canManageRoleMappings) {
+ this.loadRoleMappings();
+ }
+ } catch (e) {
+ this.setState({ error: e, loadState: 'finished' });
+ }
+ }
+
+ private reloadRoleMappings = () => {
+ this.setState({ roleMappings: [], loadState: 'loadingTable' });
+ this.loadRoleMappings();
+ };
+
+ private loadRoleMappings = async () => {
+ try {
+ const roleMappings = await this.props.roleMappingsAPI.getRoleMappings();
+ this.setState({ roleMappings });
+ } catch (e) {
+ this.setState({ error: e });
+ }
+
+ this.setState({ loadState: 'finished' });
+ };
+}
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/index.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/index.tsx
new file mode 100644
index 0000000000000..9e925d0fa6dc0
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/index.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;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import React from 'react';
+import { render, unmountComponentAtNode } from 'react-dom';
+import routes from 'ui/routes';
+import { I18nContext } from 'ui/i18n';
+import { npSetup } from 'ui/new_platform';
+import { RoleMappingsAPI } from '../../../../lib/role_mappings_api';
+// @ts-ignore
+import template from './role_mappings.html';
+import { ROLE_MAPPINGS_PATH } from '../../management_urls';
+import { getRoleMappingBreadcrumbs } from '../../breadcrumbs';
+import { RoleMappingsGridPage } from './components';
+
+routes.when(ROLE_MAPPINGS_PATH, {
+ template,
+ k7Breadcrumbs: getRoleMappingBreadcrumbs,
+ controller($scope) {
+ $scope.$$postDigest(() => {
+ const domNode = document.getElementById('roleMappingsGridReactRoot');
+
+ render(
+
+
+ ,
+ domNode
+ );
+
+ // unmount react on controller destroy
+ $scope.$on('$destroy', () => {
+ if (domNode) {
+ unmountComponentAtNode(domNode);
+ }
+ });
+ });
+ },
+});
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/role_mappings.html b/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/role_mappings.html
new file mode 100644
index 0000000000000..cff3b821d132c
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/role_mappings.html
@@ -0,0 +1,3 @@
+
+
+
diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/services/documentation_links.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/services/documentation_links.ts
new file mode 100644
index 0000000000000..36351f49890a1
--- /dev/null
+++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/services/documentation_links.ts
@@ -0,0 +1,29 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } from 'ui/documentation_links';
+
+class DocumentationLinksService {
+ private esDocBasePath = `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LINK_VERSION}`;
+
+ public getRoleMappingDocUrl() {
+ return `${this.esDocBasePath}/mapping-roles.html`;
+ }
+
+ public getRoleMappingAPIDocUrl() {
+ return `${this.esDocBasePath}/security-api-put-role-mapping.html`;
+ }
+
+ public getRoleMappingTemplateDocUrl() {
+ return `${this.esDocBasePath}/security-api-put-role-mapping.html#_role_templates`;
+ }
+
+ public getRoleMappingFieldRulesDocUrl() {
+ return `${this.esDocBasePath}/role-mapping-resources.html#mapping-roles-rule-field`;
+ }
+}
+
+export const documentationLinks = new DocumentationLinksService();
diff --git a/x-pack/legacy/plugins/siem/cypress/README.md b/x-pack/legacy/plugins/siem/cypress/README.md
index fb2b6cd2e3fd3..c9e0d4e18f78f 100644
--- a/x-pack/legacy/plugins/siem/cypress/README.md
+++ b/x-pack/legacy/plugins/siem/cypress/README.md
@@ -51,10 +51,23 @@ export const USERNAME = '[data-test-subj="loginUsername"]';
We prefer not to mock API responses in most of our smoke tests, but sometimes
it's necessary because a test must assert that a specific value is rendered,
and it's not possible to derive that value based on the data in the
-envrionment where tests are running.
+environment where tests are running.
Mocked responses API from the server are located in `siem/cypress/fixtures`.
+## Speeding up test execution time
+
+Loading the web page takes a big amount of time, in order to minimize that impact, the following points should be
+taken into consideration until another solution is implemented:
+
+- Don't refresh the page for every test to clean the state of it.
+- Instead, group the tests that are similar in different contexts.
+- For every context login only once, clean the state between tests if needed without re-loading the page.
+- All tests in a spec file must be order-independent.
+ - If you need to reload the page to make the tests order-independent, consider to create a new context.
+
+Remember that minimizing the number of times the web page is loaded, we minimize as well the execution time.
+
## Authentication
When running tests, there are two ways to specify the credentials used to
diff --git a/x-pack/legacy/plugins/siem/cypress/integration/lib/events_viewer/selectors.ts b/x-pack/legacy/plugins/siem/cypress/integration/lib/events_viewer/selectors.ts
index 0e3717feef7ad..6f7906d7fd791 100644
--- a/x-pack/legacy/plugins/siem/cypress/integration/lib/events_viewer/selectors.ts
+++ b/x-pack/legacy/plugins/siem/cypress/integration/lib/events_viewer/selectors.ts
@@ -19,6 +19,8 @@ export const HEADER_SUBTITLE = `${EVENTS_VIEWER_PANEL} [data-test-subj="header-p
/** The inspect query modal */
export const INSPECT_MODAL = '[data-test-subj="modal-inspect-euiModal"]';
+export const CLOSE_MODAL = '[data-test-subj="modal-inspect-close"]';
+
/** The inspect query button that launches the inspect query modal */
export const INSPECT_QUERY = `${EVENTS_VIEWER_PANEL} [data-test-subj="inspect-icon-button"]`;
diff --git a/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/events_viewer/events_viewer.spec.ts b/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/events_viewer/events_viewer.spec.ts
index 79169d3769a78..1450ee8dc8abf 100644
--- a/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/events_viewer/events_viewer.spec.ts
+++ b/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/events_viewer/events_viewer.spec.ts
@@ -18,6 +18,7 @@ import {
filterSearchBar,
} from '../../lib/events_viewer/helpers';
import {
+ CLOSE_MODAL,
EVENTS_VIEWER_PANEL,
HEADER_SUBTITLE,
INSPECT_MODAL,
@@ -39,162 +40,162 @@ const defaultHeadersInDefaultEcsCategory = [
];
describe('Events Viewer', () => {
- beforeEach(() => {
- loginAndWaitForPage(HOSTS_PAGE);
-
- clickEventsTab();
- });
-
- it('renders the fields browser with the expected title when the Events Viewer Fields Browser button is clicked', () => {
- openEventsViewerFieldsBrowser();
-
- cy.get(FIELDS_BROWSER_TITLE)
- .invoke('text')
- .should('eq', 'Customize Columns');
- });
+ context('Fields rendering', () => {
+ before(() => {
+ loginAndWaitForPage(HOSTS_PAGE);
+ clickEventsTab();
+ });
- it('closes the fields browser when the user clicks outside of it', () => {
- openEventsViewerFieldsBrowser();
+ beforeEach(() => {
+ openEventsViewerFieldsBrowser();
+ });
- clickOutsideFieldsBrowser();
+ afterEach(() => {
+ clickOutsideFieldsBrowser();
+ cy.get(FIELDS_BROWSER_CONTAINER).should('not.exist');
+ });
- cy.get(FIELDS_BROWSER_CONTAINER).should('not.exist');
- });
+ it('renders the fields browser with the expected title when the Events Viewer Fields Browser button is clicked', () => {
+ cy.get(FIELDS_BROWSER_TITLE)
+ .invoke('text')
+ .should('eq', 'Customize Columns');
+ });
- it('displays the `default ECS` category (by default)', () => {
- openEventsViewerFieldsBrowser();
+ it('displays the `default ECS` category (by default)', () => {
+ cy.get(FIELDS_BROWSER_SELECTED_CATEGORY_TITLE)
+ .invoke('text')
+ .should('eq', 'default ECS');
+ });
- cy.get(FIELDS_BROWSER_SELECTED_CATEGORY_TITLE)
- .invoke('text')
- .should('eq', 'default ECS');
+ it('displays a checked checkbox for all of the default events viewer columns that are also in the default ECS category', () => {
+ defaultHeadersInDefaultEcsCategory.forEach(header =>
+ cy.get(`[data-test-subj="field-${header.id}-checkbox"]`).should('be.checked')
+ );
+ });
});
- it('displays a checked checkbox for all of the default events viewer columns that are also in the default ECS category', () => {
- openEventsViewerFieldsBrowser();
-
- defaultHeadersInDefaultEcsCategory.forEach(header =>
- cy.get(`[data-test-subj="field-${header.id}-checkbox"]`).should('be.checked')
- );
- });
+ context('Events viewer query modal', () => {
+ before(() => {
+ loginAndWaitForPage(HOSTS_PAGE);
+ clickEventsTab();
+ });
- it('removes the message field from the timeline when the user un-checks the field', () => {
- const toggleField = 'message';
+ after(() => {
+ cy.get(CLOSE_MODAL).click();
+ cy.get(INSPECT_MODAL, { timeout: DEFAULT_TIMEOUT }).should('not.exist');
+ });
- cy.get(`${EVENTS_VIEWER_PANEL} [data-test-subj="header-text-${toggleField}"]`).should('exist');
+ it('launches the inspect query modal when the inspect button is clicked', () => {
+ // wait for data to load
+ cy.get(SERVER_SIDE_EVENT_COUNT, { timeout: DEFAULT_TIMEOUT })
+ .should('exist')
+ .invoke('text', { timeout: DEFAULT_TIMEOUT })
+ .should('not.equal', '0');
- openEventsViewerFieldsBrowser();
+ cy.get(INSPECT_QUERY, { timeout: DEFAULT_TIMEOUT })
+ .should('exist')
+ .trigger('mousemove', { force: true })
+ .click({ force: true });
- cy.get(`${EVENTS_VIEWER_PANEL} [data-test-subj="field-${toggleField}-checkbox"]`).uncheck({
- force: true,
+ cy.get(INSPECT_MODAL, { timeout: DEFAULT_TIMEOUT }).should('exist');
});
-
- clickOutsideFieldsBrowser();
-
- cy.get(`${EVENTS_VIEWER_PANEL} [data-test-subj="header-text-${toggleField}"]`).should(
- 'not.exist'
- );
});
- it('filters the events by applying filter criteria from the search bar at the top of the page', () => {
- const filterInput = '4bf34c1c-eaa9-46de-8921-67a4ccc49829'; // this will never match real data
-
- cy.get(HEADER_SUBTITLE)
- .invoke('text')
- .then(text1 => {
- cy.get(HEADER_SUBTITLE)
- .invoke('text', { timeout: DEFAULT_TIMEOUT })
- .should('not.equal', 'Showing: 0 events');
+ context('Events viewer fields behaviour', () => {
+ before(() => {
+ loginAndWaitForPage(HOSTS_PAGE);
+ clickEventsTab();
+ });
- filterSearchBar(filterInput);
+ beforeEach(() => {
+ openEventsViewerFieldsBrowser();
+ });
- cy.get(HEADER_SUBTITLE)
- .invoke('text')
- .should(text2 => {
- expect(text1).not.to.eq(text2);
- });
- });
- });
+ it('adds a field to the events viewer when the user clicks the checkbox', () => {
+ const filterInput = 'host.geo.c';
+ const toggleField = 'host.geo.city_name';
- it('adds a field to the events viewer when the user clicks the checkbox', () => {
- const filterInput = 'host.geo.c';
- const toggleField = 'host.geo.city_name';
+ filterFieldsBrowser(filterInput);
- openEventsViewerFieldsBrowser();
+ cy.get(`${EVENTS_VIEWER_PANEL} [data-test-subj="header-text-${toggleField}"]`).should(
+ 'not.exist'
+ );
- filterFieldsBrowser(filterInput);
+ cy.get(`${EVENTS_VIEWER_PANEL} [data-test-subj="field-${toggleField}-checkbox"]`).check({
+ force: true,
+ });
- cy.get(`${EVENTS_VIEWER_PANEL} [data-test-subj="header-text-${toggleField}"]`).should(
- 'not.exist'
- );
+ clickOutsideFieldsBrowser();
- cy.get(`${EVENTS_VIEWER_PANEL} [data-test-subj="field-${toggleField}-checkbox"]`).check({
- force: true,
+ cy.get(`${EVENTS_VIEWER_PANEL} [data-test-subj="header-text-${toggleField}"]`).should(
+ 'exist'
+ );
});
- clickOutsideFieldsBrowser();
+ it('resets all fields in the events viewer when `Reset Fields` is clicked', () => {
+ const filterInput = 'host.geo.c';
+ const toggleField = 'host.geo.country_name';
- cy.get(`${EVENTS_VIEWER_PANEL} [data-test-subj="header-text-${toggleField}"]`).should('exist');
- });
+ filterFieldsBrowser(filterInput);
- it('loads more events when the load more button is clicked', () => {
- cy.get(LOCAL_EVENTS_COUNT, { timeout: DEFAULT_TIMEOUT })
- .invoke('text')
- .then(text1 => {
- cy.get(LOCAL_EVENTS_COUNT)
- .invoke('text')
- .should('equal', '25');
-
- cy.get(LOAD_MORE).click({ force: true });
-
- cy.get(LOCAL_EVENTS_COUNT)
- .invoke('text')
- .should(text2 => {
- expect(text1).not.to.eq(text2);
- });
- });
- });
+ cy.get(`${EVENTS_VIEWER_PANEL} [data-test-subj="header-text-${toggleField}"]`).should(
+ 'not.exist'
+ );
- it('launches the inspect query modal when the inspect button is clicked', () => {
- // wait for data to load
- cy.get(SERVER_SIDE_EVENT_COUNT, { timeout: DEFAULT_TIMEOUT })
- .should('exist')
- .invoke('text', { timeout: DEFAULT_TIMEOUT })
- .should('not.equal', '0');
+ cy.get(`${EVENTS_VIEWER_PANEL} [data-test-subj="field-${toggleField}-checkbox"]`).check({
+ force: true,
+ });
- cy.get(INSPECT_QUERY, { timeout: DEFAULT_TIMEOUT })
- .should('exist')
- .trigger('mousemove', { force: true })
- .click({ force: true });
+ cy.get(`${EVENTS_VIEWER_PANEL} [data-test-subj="reset-fields"]`).click({ force: true });
- cy.get(INSPECT_MODAL, { timeout: DEFAULT_TIMEOUT }).should('exist');
+ cy.get(`${EVENTS_VIEWER_PANEL} [data-test-subj="header-text-${toggleField}"]`).should(
+ 'not.exist'
+ );
+ });
});
- it('resets all fields in the events viewer when `Reset Fields` is clicked', () => {
- const filterInput = 'host.geo.c';
- const toggleField = 'host.geo.city_name';
-
- openEventsViewerFieldsBrowser();
-
- filterFieldsBrowser(filterInput);
-
- cy.get(`${EVENTS_VIEWER_PANEL} [data-test-subj="header-text-${toggleField}"]`).should(
- 'not.exist'
- );
-
- cy.get(`${EVENTS_VIEWER_PANEL} [data-test-subj="field-${toggleField}-checkbox"]`).check({
- force: true,
+ context('Events behaviour', () => {
+ before(() => {
+ loginAndWaitForPage(HOSTS_PAGE);
+ clickEventsTab();
});
- clickOutsideFieldsBrowser();
+ it('filters the events by applying filter criteria from the search bar at the top of the page', () => {
+ const filterInput = '4bf34c1c-eaa9-46de-8921-67a4ccc49829'; // this will never match real data
- cy.get(`${EVENTS_VIEWER_PANEL} [data-test-subj="header-text-${toggleField}"]`).should('exist');
+ cy.get(HEADER_SUBTITLE)
+ .invoke('text')
+ .then(text1 => {
+ cy.get(HEADER_SUBTITLE)
+ .invoke('text', { timeout: DEFAULT_TIMEOUT })
+ .should('not.equal', 'Showing: 0 events');
- openEventsViewerFieldsBrowser();
+ filterSearchBar(filterInput);
- cy.get(`${EVENTS_VIEWER_PANEL} [data-test-subj="reset-fields"]`).click({ force: true });
+ cy.get(HEADER_SUBTITLE)
+ .invoke('text')
+ .should(text2 => {
+ expect(text1).not.to.eq(text2);
+ });
+ });
+ });
- cy.get(`${EVENTS_VIEWER_PANEL} [data-test-subj="header-text-${toggleField}"]`).should(
- 'not.exist'
- );
+ it('loads more events when the load more button is clicked', () => {
+ cy.get(LOCAL_EVENTS_COUNT, { timeout: DEFAULT_TIMEOUT })
+ .invoke('text')
+ .then(text1 => {
+ cy.get(LOCAL_EVENTS_COUNT)
+ .invoke('text')
+ .should('equal', '25');
+
+ cy.get(LOAD_MORE).click({ force: true });
+
+ cy.get(LOCAL_EVENTS_COUNT)
+ .invoke('text')
+ .should(text2 => {
+ expect(text1).not.to.eq(text2);
+ });
+ });
+ });
});
});
diff --git a/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/navigation/navigation.spec.ts b/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/navigation/navigation.spec.ts
index bb1a0379ce0ea..a549b5eec2e7c 100644
--- a/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/navigation/navigation.spec.ts
+++ b/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/navigation/navigation.spec.ts
@@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { OVERVIEW_PAGE, TIMELINES_PAGE } from '../../lib/urls';
+import { TIMELINES_PAGE } from '../../lib/urls';
import {
NAVIGATION_HOSTS,
NAVIGATION_NETWORK,
@@ -14,33 +14,27 @@ import {
import { loginAndWaitForPage } from '../../lib/util/helpers';
describe('top-level navigation common to all pages in the SIEM app', () => {
- it('navigates to the Overview page', () => {
+ before(() => {
loginAndWaitForPage(TIMELINES_PAGE);
-
+ });
+ it('navigates to the Overview page', () => {
cy.get(NAVIGATION_OVERVIEW).click({ force: true });
-
cy.url().should('include', '/siem#/overview');
});
it('navigates to the Hosts page', () => {
- loginAndWaitForPage(TIMELINES_PAGE);
-
cy.get(NAVIGATION_HOSTS).click({ force: true });
cy.url().should('include', '/siem#/hosts');
});
it('navigates to the Network page', () => {
- loginAndWaitForPage(TIMELINES_PAGE);
-
cy.get(NAVIGATION_NETWORK).click({ force: true });
cy.url().should('include', '/siem#/network');
});
it('navigates to the Timelines page', () => {
- loginAndWaitForPage(OVERVIEW_PAGE);
-
cy.get(NAVIGATION_TIMELINES).click({ force: true });
cy.url().should('include', '/siem#/timelines');
diff --git a/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/pagination/pagination.spec.ts b/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/pagination/pagination.spec.ts
index 73711f1434d5f..3853e703a7c07 100644
--- a/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/pagination/pagination.spec.ts
+++ b/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/pagination/pagination.spec.ts
@@ -18,9 +18,16 @@ import {
import { DEFAULT_TIMEOUT, loginAndWaitForPage, waitForTableLoad } from '../../lib/util/helpers';
describe('Pagination', () => {
- it('pagination updates results and page number', () => {
+ before(() => {
loginAndWaitForPage(HOSTS_PAGE_TAB_URLS.uncommonProcesses);
waitForTableLoad(UNCOMMON_PROCCESSES_TABLE);
+ });
+
+ afterEach(() => {
+ cy.get(getPageButtonSelector(0)).click({ force: true });
+ });
+
+ it('pagination updates results and page number', () => {
cy.get(getPageButtonSelector(0)).should('have.class', 'euiPaginationButton-isActive');
cy.get(getDraggableField('process.name'))
@@ -42,8 +49,6 @@ describe('Pagination', () => {
});
it('pagination keeps track of page results when tabs change', () => {
- loginAndWaitForPage(HOSTS_PAGE_TAB_URLS.uncommonProcesses);
- waitForTableLoad(UNCOMMON_PROCCESSES_TABLE);
cy.get(getPageButtonSelector(0)).should('have.class', 'euiPaginationButton-isActive');
let thirdPageResult: string;
cy.get(getPageButtonSelector(2)).click({ force: true });
@@ -78,7 +83,6 @@ describe('Pagination', () => {
* when we figure out a way to really mock the data, we should come back to it
*/
it('pagination resets results and page number to first page when refresh is clicked', () => {
- loginAndWaitForPage(HOSTS_PAGE_TAB_URLS.uncommonProcesses);
cy.get(NUMBERED_PAGINATION, { timeout: DEFAULT_TIMEOUT });
cy.get(getPageButtonSelector(0)).should('have.class', 'euiPaginationButton-isActive');
// let firstResult: string;
diff --git a/x-pack/legacy/plugins/siem/public/components/inspect/index.tsx b/x-pack/legacy/plugins/siem/public/components/inspect/index.tsx
index 04d6d94d6624d..a2a0ffdde34a5 100644
--- a/x-pack/legacy/plugins/siem/public/components/inspect/index.tsx
+++ b/x-pack/legacy/plugins/siem/public/components/inspect/index.tsx
@@ -20,7 +20,7 @@ import * as i18n from './translations';
const InspectContainer = styled.div<{ showInspect: boolean }>`
.euiButtonIcon {
- ${props => (props.showInspect ? 'opacity: 1;' : 'opacity: 0')}
+ ${props => (props.showInspect ? 'opacity: 1;' : 'opacity: 0;')}
transition: opacity ${props => getOr(250, 'theme.eui.euiAnimSpeedNormal', props)} ease;
}
`;
diff --git a/x-pack/legacy/plugins/siem/public/components/stat_items/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/stat_items/__snapshots__/index.test.tsx.snap
index 098f54640e4b2..5ed750b519cbf 100644
--- a/x-pack/legacy/plugins/siem/public/components/stat_items/__snapshots__/index.test.tsx.snap
+++ b/x-pack/legacy/plugins/siem/public/components/stat_items/__snapshots__/index.test.tsx.snap
@@ -105,7 +105,7 @@ exports[`Stat Items Component disable charts it renders the default widget 1`] =
showInspect={false}
>
`
- padding-left: ${({ selected }) => (selected ? '3px' : '0px')};
+const MyEuiFlexItem = styled(EuiFlexItem)`
+ display: inline-block;
+ max-width: 296px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
`;
-const MyEuiTextColor = styled(EuiTextColor)<{ selected: boolean }>`
- padding-left: ${({ selected }) => (selected ? '20px' : '0px')};
+const EuiSelectableContainer = styled.div`
+ .euiSelectable {
+ .euiFormControlLayout__childrenWrapper {
+ display: flex;
+ }
+ }
+`;
+
+const MyEuiFlexGroup = styled(EuiFlexGroup)`
+ padding 0px 4px;
`;
interface SearchTimelineSuperSelectProps {
@@ -83,6 +95,7 @@ const SearchTimelineSuperSelectComponent: React.FC(null);
const onSearchTimeline = useCallback(val => {
setSearchTimelineValue(val);
@@ -102,37 +115,57 @@ const SearchTimelineSuperSelectComponent: React.FC {
return (
- <>
- {option.checked === 'on' && }
-
- {isUntitled(option) ? i18nTimeline.UNTITLED_TIMELINE : option.title}
-
-
-
-
- {option.description != null && option.description.trim().length > 0
- ? option.description
- : getEmptyTagValue()}
-
-
- >
+
+
+
+
+
+
+
+
+ {isUntitled(option) ? i18nTimeline.UNTITLED_TIMELINE : option.title}
+
+
+
+
+
+ {option.description != null && option.description.trim().length > 0
+ ? option.description
+ : getEmptyTagValue()}
+
+
+
+
+
+
+
+
+
);
}, []);
- const handleTimelineChange = useCallback(options => {
- const selectedTimeline = options.filter(
- (option: { checked: string }) => option.checked === 'on'
- );
- if (selectedTimeline != null && selectedTimeline.length > 0 && onTimelineChange != null) {
- onTimelineChange(
- isEmpty(selectedTimeline[0].title)
- ? i18nTimeline.UNTITLED_TIMELINE
- : selectedTimeline[0].title,
- selectedTimeline[0].id
+ const handleTimelineChange = useCallback(
+ options => {
+ const selectedTimeline = options.filter(
+ (option: { checked: string }) => option.checked === 'on'
);
- }
- setIsPopoverOpen(false);
- }, []);
+ if (selectedTimeline != null && selectedTimeline.length > 0) {
+ onTimelineChange(
+ isEmpty(selectedTimeline[0].title)
+ ? i18nTimeline.UNTITLED_TIMELINE
+ : selectedTimeline[0].title,
+ selectedTimeline[0].id === '-1' ? null : selectedTimeline[0].id
+ );
+ }
+ setIsPopoverOpen(false);
+ },
+ [onTimelineChange]
+ );
const handleOnScroll = useCallback(
(
@@ -187,6 +220,29 @@ const SearchTimelineSuperSelectComponent: React.FC
+ searchRef != null ? (
+
+
+
+
+
+ {i18nTimeline.ONLY_FAVORITES}
+
+
+
+
+
+ ) : null,
+ [searchRef, onlyFavorites, handleOnToggleOnlyFavorites]
+ );
+
return (
{({ timelines, loading, totalCount }) => (
- <>
-
-
-
-
- {i18nTimeline.ONLY_FAVORITES}
-
-
-
-
-
+
{
+ setSearchRef(ref);
+ },
}}
singleSelection={true}
options={[
@@ -249,6 +293,7 @@ const SearchTimelineSuperSelectComponent: React.FC
({
description: t.description,
+ favorite: !isEmpty(t.favorite),
label: t.title,
id: t.savedObjectId,
key: `${t.title}-${index}`,
@@ -261,11 +306,12 @@ const SearchTimelineSuperSelectComponent: React.FC (
<>
{search}
+ {favoritePortal}
{list}
>
)}
- >
+
)}
diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts
index f9611995cdb04..b69a8de29e047 100644
--- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts
+++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts
@@ -15,9 +15,13 @@ import {
NewRule,
Rule,
FetchRuleProps,
+ BasicFetchProps,
} from './types';
import { throwIfNotOk } from '../../../hooks/api/api';
-import { DETECTION_ENGINE_RULES_URL } from '../../../../common/constants';
+import {
+ DETECTION_ENGINE_RULES_URL,
+ DETECTION_ENGINE_PREPACKAGED_URL,
+} from '../../../../common/constants';
/**
* Add provided Rule
@@ -199,3 +203,22 @@ export const duplicateRules = async ({ rules }: DuplicateRulesProps): Promise>(response => response.json())
);
};
+
+/**
+ * Create Prepackaged Rules
+ *
+ * @param signal AbortSignal for cancelling request
+ */
+export const createPrepackagedRules = async ({ signal }: BasicFetchProps): Promise => {
+ const response = await fetch(`${chrome.getBasePath()}${DETECTION_ENGINE_PREPACKAGED_URL}`, {
+ method: 'PUT',
+ credentials: 'same-origin',
+ headers: {
+ 'content-type': 'application/json',
+ 'kbn-xsrf': 'true',
+ },
+ signal,
+ });
+ await throwIfNotOk(response);
+ return true;
+};
diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts
index 655299c4a2a34..a329d96d444aa 100644
--- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts
+++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts
@@ -132,3 +132,7 @@ export interface DeleteRulesProps {
export interface DuplicateRulesProps {
rules: Rules;
}
+
+export interface BasicFetchProps {
+ signal: AbortSignal;
+}
diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/translations.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/translations.ts
index 5b5dc9e9699fe..2b8f54e5438df 100644
--- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/translations.ts
+++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/translations.ts
@@ -12,3 +12,24 @@ export const SIGNAL_FETCH_FAILURE = i18n.translate(
defaultMessage: 'Failed to query signals',
}
);
+
+export const PRIVILEGE_FETCH_FAILURE = i18n.translate(
+ 'xpack.siem.containers.detectionEngine.signals.errorFetchingSignalsDescription',
+ {
+ defaultMessage: 'Failed to query signals',
+ }
+);
+
+export const SIGNAL_GET_NAME_FAILURE = i18n.translate(
+ 'xpack.siem.containers.detectionEngine.signals.errorGetSignalDescription',
+ {
+ defaultMessage: 'Failed to get signal index name',
+ }
+);
+
+export const SIGNAL_POST_FAILURE = i18n.translate(
+ 'xpack.siem.containers.detectionEngine.signals.errorPostSignalDescription',
+ {
+ defaultMessage: 'Failed to create signal index',
+ }
+);
diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/use_privilege_user.tsx b/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/use_privilege_user.tsx
index aa66df53d9fd9..792ff29ad2488 100644
--- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/use_privilege_user.tsx
+++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/use_privilege_user.tsx
@@ -6,10 +6,18 @@
import { useEffect, useState } from 'react';
+import { errorToToaster } from '../../../components/ml/api/error_to_toaster';
+import { useStateToaster } from '../../../components/toasters';
import { getUserPrivilege } from './api';
+import * as i18n from './translations';
-type Return = [boolean, boolean | null, boolean | null];
-
+interface Return {
+ loading: boolean;
+ isAuthenticated: boolean | null;
+ hasIndexManage: boolean | null;
+ hasManageApiKey: boolean | null;
+ hasIndexWrite: boolean | null;
+}
/**
* Hook to get user privilege from
*
@@ -17,7 +25,10 @@ type Return = [boolean, boolean | null, boolean | null];
export const usePrivilegeUser = (): Return => {
const [loading, setLoading] = useState(true);
const [isAuthenticated, setAuthenticated] = useState(null);
- const [hasWrite, setHasWrite] = useState(null);
+ const [hasIndexManage, setHasIndexManage] = useState(null);
+ const [hasIndexWrite, setHasIndexWrite] = useState(null);
+ const [hasManageApiKey, setHasManageApiKey] = useState(null);
+ const [, dispatchToaster] = useStateToaster();
useEffect(() => {
let isSubscribed = true;
@@ -34,13 +45,21 @@ export const usePrivilegeUser = (): Return => {
setAuthenticated(privilege.isAuthenticated);
if (privilege.index != null && Object.keys(privilege.index).length > 0) {
const indexName = Object.keys(privilege.index)[0];
- setHasWrite(privilege.index[indexName].create_index);
+ setHasIndexManage(privilege.index[indexName].manage);
+ setHasIndexWrite(privilege.index[indexName].write);
+ setHasManageApiKey(
+ privilege.cluster.manage_security ||
+ privilege.cluster.manage_api_key ||
+ privilege.cluster.manage_own_api_key
+ );
}
}
} catch (error) {
if (isSubscribed) {
setAuthenticated(false);
- setHasWrite(false);
+ setHasIndexManage(false);
+ setHasIndexWrite(false);
+ errorToToaster({ title: i18n.PRIVILEGE_FETCH_FAILURE, error, dispatchToaster });
}
}
if (isSubscribed) {
@@ -55,5 +74,5 @@ export const usePrivilegeUser = (): Return => {
};
}, []);
- return [loading, isAuthenticated, hasWrite];
+ return { loading, isAuthenticated, hasIndexManage, hasManageApiKey, hasIndexWrite };
};
diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/use_signal_index.tsx b/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/use_signal_index.tsx
index 1ff4422cf6411..189d8a1bf3f75 100644
--- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/use_signal_index.tsx
+++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/use_signal_index.tsx
@@ -8,9 +8,10 @@ import { useEffect, useState, useRef } from 'react';
import { errorToToaster } from '../../../components/ml/api/error_to_toaster';
import { useStateToaster } from '../../../components/toasters';
+import { createPrepackagedRules } from '../rules';
import { createSignalIndex, getSignalIndex } from './api';
import * as i18n from './translations';
-import { PostSignalError } from './types';
+import { PostSignalError, SignalIndexError } from './types';
type Func = () => void;
@@ -40,11 +41,15 @@ export const useSignalIndex = (): Return => {
if (isSubscribed && signal != null) {
setSignalIndexName(signal.name);
setSignalIndexExists(true);
+ createPrepackagedRules({ signal: abortCtrl.signal });
}
} catch (error) {
if (isSubscribed) {
setSignalIndexName(null);
setSignalIndexExists(false);
+ if (error instanceof SignalIndexError && error.statusCode !== 404) {
+ errorToToaster({ title: i18n.SIGNAL_GET_NAME_FAILURE, error, dispatchToaster });
+ }
}
}
if (isSubscribed) {
@@ -69,7 +74,7 @@ export const useSignalIndex = (): Return => {
} else {
setSignalIndexName(null);
setSignalIndexExists(false);
- errorToToaster({ title: i18n.SIGNAL_FETCH_FAILURE, error, dispatchToaster });
+ errorToToaster({ title: i18n.SIGNAL_POST_FAILURE, error, dispatchToaster });
}
}
}
diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/no_write_signals_callout/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/no_write_signals_callout/index.tsx
new file mode 100644
index 0000000000000..1950531998450
--- /dev/null
+++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/no_write_signals_callout/index.tsx
@@ -0,0 +1,26 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { EuiCallOut, EuiButton } from '@elastic/eui';
+import React, { memo, useCallback, useState } from 'react';
+
+import * as i18n from './translations';
+
+const NoWriteSignalsCallOutComponent = () => {
+ const [showCallOut, setShowCallOut] = useState(true);
+ const handleCallOut = useCallback(() => setShowCallOut(false), [setShowCallOut]);
+
+ return showCallOut ? (
+
+ {i18n.NO_WRITE_SIGNALS_CALLOUT_MSG}
+
+ {i18n.DISMISS_CALLOUT}
+
+
+ ) : null;
+};
+
+export const NoWriteSignalsCallOut = memo(NoWriteSignalsCallOutComponent);
diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/no_write_signals_callout/translations.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/no_write_signals_callout/translations.ts
new file mode 100644
index 0000000000000..065d775e1dc6a
--- /dev/null
+++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/no_write_signals_callout/translations.ts
@@ -0,0 +1,29 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { i18n } from '@kbn/i18n';
+
+export const NO_WRITE_SIGNALS_CALLOUT_TITLE = i18n.translate(
+ 'xpack.siem.detectionEngine.noWriteSignalsCallOutTitle',
+ {
+ defaultMessage: 'Signals index permissions required',
+ }
+);
+
+export const NO_WRITE_SIGNALS_CALLOUT_MSG = i18n.translate(
+ 'xpack.siem.detectionEngine.noWriteSignalsCallOutMsg',
+ {
+ defaultMessage:
+ 'You are currently missing the required permissions to update signals. Please contact your administrator for further assistance.',
+ }
+);
+
+export const DISMISS_CALLOUT = i18n.translate(
+ 'xpack.siem.detectionEngine.dismissNoWriteSignalButton',
+ {
+ defaultMessage: 'Dismiss',
+ }
+);
diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/default_config.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/default_config.tsx
index 1a7ad5822a246..83b6ba690ec5b 100644
--- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/default_config.tsx
+++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/default_config.tsx
@@ -168,55 +168,66 @@ export const requiredFieldsForActions = [
];
export const getSignalsActions = ({
+ canUserCRUD,
+ hasIndexWrite,
setEventsLoading,
setEventsDeleted,
createTimeline,
status,
}: {
+ canUserCRUD: boolean;
+ hasIndexWrite: boolean;
setEventsLoading: ({ eventIds, isLoading }: SetEventsLoadingProps) => void;
setEventsDeleted: ({ eventIds, isDeleted }: SetEventsDeletedProps) => void;
createTimeline: CreateTimeline;
status: 'open' | 'closed';
-}): TimelineAction[] => [
- {
- getAction: ({ eventId, data }: TimelineActionProps): JSX.Element => (
-
- sendSignalsToTimelineAction({ createTimeline, data: [data] })}
- iconType="tableDensityNormal"
- aria-label="Next"
- />
-
- ),
- id: 'sendSignalToTimeline',
- width: 26,
- },
- {
- getAction: ({ eventId, data }: TimelineActionProps): JSX.Element => (
-
-
- updateSignalStatusAction({
- signalIds: [eventId],
- status,
- setEventsLoading,
- setEventsDeleted,
- })
- }
- iconType={status === FILTER_OPEN ? 'indexOpen' : 'indexClose'}
- aria-label="Next"
- />
-
- ),
- id: 'updateSignalStatus',
- width: 26,
- },
-];
+}): TimelineAction[] => {
+ const actions = [
+ {
+ getAction: ({ eventId, data }: TimelineActionProps): JSX.Element => (
+
+ sendSignalsToTimelineAction({ createTimeline, data: [data] })}
+ iconType="tableDensityNormal"
+ aria-label="Next"
+ />
+
+ ),
+ id: 'sendSignalToTimeline',
+ width: 26,
+ },
+ ];
+ return canUserCRUD && hasIndexWrite
+ ? [
+ ...actions,
+ {
+ getAction: ({ eventId, data }: TimelineActionProps): JSX.Element => (
+
+
+ updateSignalStatusAction({
+ signalIds: [eventId],
+ status,
+ setEventsLoading,
+ setEventsDeleted,
+ })
+ }
+ iconType={status === FILTER_OPEN ? 'indexOpen' : 'indexClose'}
+ aria-label="Next"
+ />
+
+ ),
+ id: 'updateSignalStatus',
+ width: 26,
+ },
+ ]
+ : actions;
+};
diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/index.tsx
index 47a78482cfb6e..d149eb700ad03 100644
--- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/index.tsx
+++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/index.tsx
@@ -4,6 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
+import { EuiPanel, EuiLoadingContent } from '@elastic/eui';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { connect } from 'react-redux';
import { ActionCreator } from 'typescript-fsa';
@@ -46,6 +47,8 @@ import { useFetchIndexPatterns } from '../../../../containers/detection_engine/r
import { InputsRange } from '../../../../store/inputs/model';
import { Query } from '../../../../../../../../../src/plugins/data/common/query';
+import { HeaderSection } from '../../../../components/header_section';
+
const SIGNALS_PAGE_TIMELINE_ID = 'signals-page';
interface ReduxProps {
@@ -88,8 +91,11 @@ interface DispatchProps {
}
interface OwnProps {
+ canUserCRUD: boolean;
defaultFilters?: esFilters.Filter[];
+ hasIndexWrite: boolean;
from: number;
+ loading: boolean;
signalsIndex: string;
to: number;
}
@@ -98,6 +104,7 @@ type SignalsTableComponentProps = OwnProps & ReduxProps & DispatchProps;
export const SignalsTableComponent = React.memo(
({
+ canUserCRUD,
createTimeline,
clearEventsDeleted,
clearEventsLoading,
@@ -106,7 +113,9 @@ export const SignalsTableComponent = React.memo(
from,
globalFilters,
globalQuery,
+ hasIndexWrite,
isSelectAllChecked,
+ loading,
loadingEventIds,
removeTimelineLinkTo,
selectedEventIds,
@@ -228,8 +237,10 @@ export const SignalsTableComponent = React.memo(
(totalCount: number) => {
return (
0}
clearSelection={clearSelectionCallback}
+ hasIndexWrite={hasIndexWrite}
isFilteredToOpen={filterGroup === FILTER_OPEN}
selectAll={selectAllCallback}
selectedEventIds={selectedEventIds}
@@ -241,6 +252,8 @@ export const SignalsTableComponent = React.memo(
);
},
[
+ canUserCRUD,
+ hasIndexWrite,
clearSelectionCallback,
filterGroup,
loadingEventIds.length,
@@ -254,12 +267,14 @@ export const SignalsTableComponent = React.memo(
const additionalActions = useMemo(
() =>
getSignalsActions({
+ canUserCRUD,
+ hasIndexWrite,
createTimeline: createTimelineCallback,
setEventsLoading: setEventsLoadingCallback,
setEventsDeleted: setEventsDeletedCallback,
status: filterGroup === FILTER_OPEN ? FILTER_CLOSED : FILTER_OPEN,
}),
- [createTimelineCallback, filterGroup]
+ [canUserCRUD, createTimelineCallback, filterGroup]
);
const defaultIndices = useMemo(() => [signalsIndex], [signalsIndex]);
@@ -279,11 +294,20 @@ export const SignalsTableComponent = React.memo(
queryFields: requiredFieldsForActions,
timelineActions: additionalActions,
title: i18n.SIGNALS_TABLE_TITLE,
- selectAll,
+ selectAll: canUserCRUD ? selectAll : false,
}),
- [additionalActions, selectAll]
+ [additionalActions, canUserCRUD, selectAll]
);
+ if (loading) {
+ return (
+
+
+
+
+ );
+ }
+
return (
>;
+ updateSignalsStatus: UpdateSignalsStatus;
+ sendSignalsToTimeline: SendSignalsToTimeline;
+ closePopover: () => void;
+ isFilteredToOpen: boolean;
+}
/**
* Returns ViewInTimeline / UpdateSignalStatus actions to be display within an EuiContextMenuPanel
*
@@ -22,15 +31,15 @@ import { FILTER_CLOSED, FILTER_OPEN } from '../signals_filter_group';
* @param closePopover
* @param isFilteredToOpen currently selected filter options
*/
-export const getBatchItems = (
- areEventsLoading: boolean,
- allEventsSelected: boolean,
- selectedEventIds: Readonly>,
- updateSignalsStatus: UpdateSignalsStatus,
- sendSignalsToTimeline: SendSignalsToTimeline,
- closePopover: () => void,
- isFilteredToOpen: boolean
-) => {
+export const getBatchItems = ({
+ areEventsLoading,
+ allEventsSelected,
+ selectedEventIds,
+ updateSignalsStatus,
+ sendSignalsToTimeline,
+ closePopover,
+ isFilteredToOpen,
+}: GetBatchItems) => {
const allDisabled = areEventsLoading || Object.keys(selectedEventIds).length === 0;
const sendToTimelineDisabled = allEventsSelected || uniqueRuleCount(selectedEventIds) > 1;
const filterString = isFilteredToOpen
diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/signals_utility_bar/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/signals_utility_bar/index.tsx
index f80de053b59bd..e28fb3e06870e 100644
--- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/signals_utility_bar/index.tsx
+++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/signals_utility_bar/index.tsx
@@ -22,6 +22,8 @@ import { TimelineNonEcsData } from '../../../../../graphql/types';
import { SendSignalsToTimeline, UpdateSignalsStatus } from '../types';
interface SignalsUtilityBarProps {
+ canUserCRUD: boolean;
+ hasIndexWrite: boolean;
areEventsLoading: boolean;
clearSelection: () => void;
isFilteredToOpen: boolean;
@@ -34,6 +36,8 @@ interface SignalsUtilityBarProps {
}
const SignalsUtilityBarComponent: React.FC = ({
+ canUserCRUD,
+ hasIndexWrite,
areEventsLoading,
clearSelection,
totalCount,
@@ -49,15 +53,15 @@ const SignalsUtilityBarComponent: React.FC