(settings, 'server.xsrf.whitelist').length > 0
+ ) {
+ log(
+ 'It is not recommended to disable xsrf protections for API endpoints via [server.xsrf.whitelist]. ' +
+ 'It will be removed in 8.0 release. Instead, supply the "kbn-xsrf" header.'
+ );
+ }
+ return settings;
+};
+
const rewriteBasePathDeprecation: ConfigDeprecation = (settings, fromPath, log) => {
if (has(settings, 'server.basePath') && !has(settings, 'server.rewriteBasePath')) {
log(
@@ -177,4 +190,5 @@ export const coreDeprecationProvider: ConfigDeprecationProvider = ({
rewriteBasePathDeprecation,
cspRulesDeprecation,
mapManifestServiceUrlDeprecation,
+ xsrfDeprecation,
];
diff --git a/src/core/server/http/http_server.mocks.ts b/src/core/server/http/http_server.mocks.ts
index 0a9541393284e..741c723ca9365 100644
--- a/src/core/server/http/http_server.mocks.ts
+++ b/src/core/server/http/http_server.mocks.ts
@@ -29,6 +29,7 @@ import {
RouteMethod,
KibanaResponseFactory,
RouteValidationSpec,
+ KibanaRouteState,
} from './router';
import { OnPreResponseToolkit } from './lifecycle/on_pre_response';
import { OnPostAuthToolkit } from './lifecycle/on_post_auth';
@@ -43,6 +44,7 @@ interface RequestFixtureOptions {
method?: RouteMethod;
socket?: Socket;
routeTags?: string[];
+ kibanaRouteState?: KibanaRouteState;
routeAuthRequired?: false;
validation?: {
params?: RouteValidationSpec
;
@@ -62,6 +64,7 @@ function createKibanaRequestMock
({
routeTags,
routeAuthRequired,
validation = {},
+ kibanaRouteState = { xsrfRequired: true },
}: RequestFixtureOptions
= {}) {
const queryString = stringify(query, { sort: false });
@@ -80,7 +83,7 @@ function createKibanaRequestMock
({
search: queryString ? `?${queryString}` : queryString,
},
route: {
- settings: { tags: routeTags, auth: routeAuthRequired },
+ settings: { tags: routeTags, auth: routeAuthRequired, app: kibanaRouteState },
},
raw: {
req: { socket },
@@ -109,6 +112,7 @@ function createRawRequestMock(customization: DeepPartial = {}) {
return merge(
{},
{
+ app: { xsrfRequired: true } as any,
headers: {},
path: '/',
route: { settings: {} },
diff --git a/src/core/server/http/http_server.test.ts b/src/core/server/http/http_server.test.ts
index a9fc80c86d878..27db79bb94d25 100644
--- a/src/core/server/http/http_server.test.ts
+++ b/src/core/server/http/http_server.test.ts
@@ -811,6 +811,7 @@ test('exposes route details of incoming request to a route handler', async () =>
path: '/',
options: {
authRequired: true,
+ xsrfRequired: false,
tags: [],
},
});
@@ -923,6 +924,7 @@ test('exposes route details of incoming request to a route handler (POST + paylo
path: '/',
options: {
authRequired: true,
+ xsrfRequired: true,
tags: [],
body: {
parse: true, // hapi populates the default
diff --git a/src/core/server/http/http_server.ts b/src/core/server/http/http_server.ts
index 025ab2bf56ac2..cffdffab0d0cf 100644
--- a/src/core/server/http/http_server.ts
+++ b/src/core/server/http/http_server.ts
@@ -27,7 +27,7 @@ import { adoptToHapiOnPostAuthFormat, OnPostAuthHandler } from './lifecycle/on_p
import { adoptToHapiOnPreAuthFormat, OnPreAuthHandler } from './lifecycle/on_pre_auth';
import { adoptToHapiOnPreResponseFormat, OnPreResponseHandler } from './lifecycle/on_pre_response';
-import { IRouter } from './router';
+import { IRouter, KibanaRouteState, isSafeMethod } from './router';
import {
SessionStorageCookieOptions,
createCookieSessionStorageFactory,
@@ -147,9 +147,14 @@ export class HttpServer {
for (const route of router.getRoutes()) {
this.log.debug(`registering route handler for [${route.path}]`);
// Hapi does not allow payload validation to be specified for 'head' or 'get' requests
- const validate = ['head', 'get'].includes(route.method) ? undefined : { payload: true };
+ const validate = isSafeMethod(route.method) ? undefined : { payload: true };
const { authRequired = true, tags, body = {} } = route.options;
const { accepts: allow, maxBytes, output, parse } = body;
+
+ const kibanaRouteState: KibanaRouteState = {
+ xsrfRequired: route.options.xsrfRequired ?? !isSafeMethod(route.method),
+ };
+
this.server.route({
handler: route.handler,
method: route.method,
@@ -157,6 +162,7 @@ export class HttpServer {
options: {
// Enforcing the comparison with true because plugins could overwrite the auth strategy by doing `options: { authRequired: authStrategy as any }`
auth: authRequired === true ? undefined : false,
+ app: kibanaRouteState,
tags: tags ? Array.from(tags) : undefined,
// TODO: This 'validate' section can be removed once the legacy platform is completely removed.
// We are telling Hapi that NP routes can accept any payload, so that it can bypass the default
diff --git a/src/core/server/http/index.ts b/src/core/server/http/index.ts
index d31afe1670e41..8f4c02680f8a3 100644
--- a/src/core/server/http/index.ts
+++ b/src/core/server/http/index.ts
@@ -58,6 +58,8 @@ export {
RouteValidationError,
RouteValidatorFullConfig,
RouteValidationResultFactory,
+ DestructiveRouteMethod,
+ SafeRouteMethod,
} from './router';
export { BasePathProxyServer } from './base_path_proxy_server';
export { OnPreAuthHandler, OnPreAuthToolkit } from './lifecycle/on_pre_auth';
diff --git a/src/core/server/http/integration_tests/lifecycle_handlers.test.ts b/src/core/server/http/integration_tests/lifecycle_handlers.test.ts
index f4c5f16870c7e..b5364c616f17c 100644
--- a/src/core/server/http/integration_tests/lifecycle_handlers.test.ts
+++ b/src/core/server/http/integration_tests/lifecycle_handlers.test.ts
@@ -36,6 +36,7 @@ const versionHeader = 'kbn-version';
const xsrfHeader = 'kbn-xsrf';
const nameHeader = 'kbn-name';
const whitelistedTestPath = '/xsrf/test/route/whitelisted';
+const xsrfDisabledTestPath = '/xsrf/test/route/disabled';
const kibanaName = 'my-kibana-name';
const setupDeps = {
context: contextServiceMock.createSetupContract(),
@@ -188,6 +189,12 @@ describe('core lifecycle handlers', () => {
return res.ok({ body: 'ok' });
}
);
+ ((router as any)[method.toLowerCase()] as RouteRegistrar)(
+ { path: xsrfDisabledTestPath, validate: false, options: { xsrfRequired: false } },
+ (context, req, res) => {
+ return res.ok({ body: 'ok' });
+ }
+ );
});
await server.start();
@@ -235,6 +242,10 @@ describe('core lifecycle handlers', () => {
it('accepts whitelisted requests without either an xsrf or version header', async () => {
await getSupertest(method.toLowerCase(), whitelistedTestPath).expect(200, 'ok');
});
+
+ it('accepts requests on a route with disabled xsrf protection', async () => {
+ await getSupertest(method.toLowerCase(), xsrfDisabledTestPath).expect(200, 'ok');
+ });
});
});
});
diff --git a/src/core/server/http/lifecycle_handlers.test.ts b/src/core/server/http/lifecycle_handlers.test.ts
index 48a6973b741ba..a80e432e0d4cb 100644
--- a/src/core/server/http/lifecycle_handlers.test.ts
+++ b/src/core/server/http/lifecycle_handlers.test.ts
@@ -24,7 +24,7 @@ import {
} from './lifecycle_handlers';
import { httpServerMock } from './http_server.mocks';
import { HttpConfig } from './http_config';
-import { KibanaRequest, RouteMethod } from './router';
+import { KibanaRequest, RouteMethod, KibanaRouteState } from './router';
const createConfig = (partial: Partial): HttpConfig => partial as HttpConfig;
@@ -32,12 +32,14 @@ const forgeRequest = ({
headers = {},
path = '/',
method = 'get',
+ kibanaRouteState,
}: Partial<{
headers: Record;
path: string;
method: RouteMethod;
+ kibanaRouteState: KibanaRouteState;
}>): KibanaRequest => {
- return httpServerMock.createKibanaRequest({ headers, path, method });
+ return httpServerMock.createKibanaRequest({ headers, path, method, kibanaRouteState });
};
describe('xsrf post-auth handler', () => {
@@ -142,6 +144,29 @@ describe('xsrf post-auth handler', () => {
expect(toolkit.next).toHaveBeenCalledTimes(1);
expect(result).toEqual('next');
});
+
+ it('accepts requests if xsrf protection on a route is disabled', () => {
+ const config = createConfig({
+ xsrf: { whitelist: [], disableProtection: false },
+ });
+ const handler = createXsrfPostAuthHandler(config);
+ const request = forgeRequest({
+ method: 'post',
+ headers: {},
+ path: '/some-path',
+ kibanaRouteState: {
+ xsrfRequired: false,
+ },
+ });
+
+ toolkit.next.mockReturnValue('next' as any);
+
+ const result = handler(request, responseFactory, toolkit);
+
+ expect(responseFactory.badRequest).not.toHaveBeenCalled();
+ expect(toolkit.next).toHaveBeenCalledTimes(1);
+ expect(result).toEqual('next');
+ });
});
});
diff --git a/src/core/server/http/lifecycle_handlers.ts b/src/core/server/http/lifecycle_handlers.ts
index ee877ee031a2b..7ef7e86326039 100644
--- a/src/core/server/http/lifecycle_handlers.ts
+++ b/src/core/server/http/lifecycle_handlers.ts
@@ -20,6 +20,7 @@
import { OnPostAuthHandler } from './lifecycle/on_post_auth';
import { OnPreResponseHandler } from './lifecycle/on_pre_response';
import { HttpConfig } from './http_config';
+import { isSafeMethod } from './router';
import { Env } from '../config';
import { LifecycleRegistrar } from './http_server';
@@ -31,15 +32,18 @@ export const createXsrfPostAuthHandler = (config: HttpConfig): OnPostAuthHandler
const { whitelist, disableProtection } = config.xsrf;
return (request, response, toolkit) => {
- if (disableProtection || whitelist.includes(request.route.path)) {
+ if (
+ disableProtection ||
+ whitelist.includes(request.route.path) ||
+ request.route.options.xsrfRequired === false
+ ) {
return toolkit.next();
}
- const isSafeMethod = request.route.method === 'get' || request.route.method === 'head';
const hasVersionHeader = VERSION_HEADER in request.headers;
const hasXsrfHeader = XSRF_HEADER in request.headers;
- if (!isSafeMethod && !hasVersionHeader && !hasXsrfHeader) {
+ if (!isSafeMethod(request.route.method) && !hasVersionHeader && !hasXsrfHeader) {
return response.badRequest({ body: `Request must contain a ${XSRF_HEADER} header.` });
}
diff --git a/src/core/server/http/router/index.ts b/src/core/server/http/router/index.ts
index 32663d1513f36..d254f391ca5e4 100644
--- a/src/core/server/http/router/index.ts
+++ b/src/core/server/http/router/index.ts
@@ -24,16 +24,20 @@ export {
KibanaRequestEvents,
KibanaRequestRoute,
KibanaRequestRouteOptions,
+ KibanaRouteState,
isRealRequest,
LegacyRequest,
ensureRawRequest,
} from './request';
export {
+ DestructiveRouteMethod,
+ isSafeMethod,
RouteMethod,
RouteConfig,
RouteConfigOptions,
RouteContentType,
RouteConfigOptionsBody,
+ SafeRouteMethod,
validBodyOutput,
} from './route';
export { HapiResponseAdapter } from './response_adapter';
diff --git a/src/core/server/http/router/request.ts b/src/core/server/http/router/request.ts
index 703571ba53c0a..bb2db6367f701 100644
--- a/src/core/server/http/router/request.ts
+++ b/src/core/server/http/router/request.ts
@@ -18,18 +18,24 @@
*/
import { Url } from 'url';
-import { Request } from 'hapi';
+import { Request, ApplicationState } from 'hapi';
import { Observable, fromEvent, merge } from 'rxjs';
import { shareReplay, first, takeUntil } from 'rxjs/operators';
import { deepFreeze, RecursiveReadonly } from '../../../utils';
import { Headers } from './headers';
-import { RouteMethod, RouteConfigOptions, validBodyOutput } from './route';
+import { RouteMethod, RouteConfigOptions, validBodyOutput, isSafeMethod } from './route';
import { KibanaSocket, IKibanaSocket } from './socket';
import { RouteValidator, RouteValidatorFullConfig } from './validator';
const requestSymbol = Symbol('request');
+/**
+ * @internal
+ */
+export interface KibanaRouteState extends ApplicationState {
+ xsrfRequired: boolean;
+}
/**
* Route options: If 'GET' or 'OPTIONS' method, body options won't be returned.
* @public
@@ -184,8 +190,10 @@ export class KibanaRequest<
const options = ({
authRequired: request.route.settings.auth !== false,
+ // some places in LP call KibanaRequest.from(request) manually. remove fallback to true before v8
+ xsrfRequired: (request.route.settings.app as KibanaRouteState)?.xsrfRequired ?? true,
tags: request.route.settings.tags || [],
- body: ['get', 'options'].includes(method)
+ body: isSafeMethod(method)
? undefined
: {
parse,
diff --git a/src/core/server/http/router/route.ts b/src/core/server/http/router/route.ts
index 4439a80b1eac7..d1458ef4ad063 100644
--- a/src/core/server/http/router/route.ts
+++ b/src/core/server/http/router/route.ts
@@ -19,11 +19,27 @@
import { RouteValidatorFullConfig } from './validator';
+export function isSafeMethod(method: RouteMethod): method is SafeRouteMethod {
+ return method === 'get' || method === 'options';
+}
+
+/**
+ * Set of HTTP methods changing the state of the server.
+ * @public
+ */
+export type DestructiveRouteMethod = 'post' | 'put' | 'delete' | 'patch';
+
+/**
+ * Set of HTTP methods not changing the state of the server.
+ * @public
+ */
+export type SafeRouteMethod = 'get' | 'options';
+
/**
* The set of common HTTP methods supported by Kibana routing.
* @public
*/
-export type RouteMethod = 'get' | 'post' | 'put' | 'delete' | 'patch' | 'options';
+export type RouteMethod = SafeRouteMethod | DestructiveRouteMethod;
/**
* The set of valid body.output
@@ -108,6 +124,15 @@ export interface RouteConfigOptions {
*/
authRequired?: boolean;
+ /**
+ * Defines xsrf protection requirements for a route:
+ * - true. Requires an incoming POST/PUT/DELETE request to contain `kbn-xsrf` header.
+ * - false. Disables xsrf protection.
+ *
+ * Set to true by default
+ */
+ xsrfRequired?: Method extends 'get' ? never : boolean;
+
/**
* Additional metadata tag strings to attach to the route.
*/
diff --git a/src/core/server/index.ts b/src/core/server/index.ts
index de6cdb2d7acd7..0c112e3cfb5b2 100644
--- a/src/core/server/index.ts
+++ b/src/core/server/index.ts
@@ -159,6 +159,8 @@ export {
SessionStorageCookieOptions,
SessionCookieValidationResult,
SessionStorageFactory,
+ DestructiveRouteMethod,
+ SafeRouteMethod,
} from './http';
export { RenderingServiceSetup, IRenderOptions } from './rendering';
export { Logger, LoggerFactory, LogMeta, LogRecord, LogLevel } from './logging';
diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md
index 445ed16ec7829..8c5e84446a0d3 100644
--- a/src/core/server/server.api.md
+++ b/src/core/server/server.api.md
@@ -685,6 +685,9 @@ export interface DeprecationSettings {
message: string;
}
+// @public
+export type DestructiveRouteMethod = 'post' | 'put' | 'delete' | 'patch';
+
// @public
export interface DiscoveredPlugin {
readonly configPath: ConfigPath;
@@ -1459,6 +1462,7 @@ export interface RouteConfigOptions {
authRequired?: boolean;
body?: Method extends 'get' | 'options' ? undefined : RouteConfigOptionsBody;
tags?: readonly string[];
+ xsrfRequired?: Method extends 'get' ? never : boolean;
}
// @public
@@ -1473,7 +1477,7 @@ export interface RouteConfigOptionsBody {
export type RouteContentType = 'application/json' | 'application/*+json' | 'application/octet-stream' | 'application/x-www-form-urlencoded' | 'multipart/form-data' | 'text/*';
// @public
-export type RouteMethod = 'get' | 'post' | 'put' | 'delete' | 'patch' | 'options';
+export type RouteMethod = SafeRouteMethod | DestructiveRouteMethod;
// @public
export type RouteRegistrar = (route: RouteConfig
, handler: RequestHandler
) => void;
@@ -1526,6 +1530,9 @@ export interface RouteValidatorOptions {
};
}
+// @public
+export type SafeRouteMethod = 'get' | 'options';
+
// @public (undocumented)
export interface SavedObject {
attributes: T;
From 0d0973869d07f7d1e88292dce0c38e4302db9d54 Mon Sep 17 00:00:00 2001
From: Spencer
Date: Tue, 3 Mar 2020 08:15:32 -0700
Subject: [PATCH 05/96] =?UTF-8?q?[kbn/optimizer]=20add=20test=20to=20verif?=
=?UTF-8?q?y=20that=20dynamic=20imports=20keep=20w=E2=80=A6=20(#59065)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* [kbn/optimizer] add test to verify that dynamic imports keep working
* sort files before adding to cache
* oops, committed tmp repo
---
.../plugins/foo/public/async_import.ts | 20 +++++++++++++++++++
.../mock_repo/plugins/foo/public/index.ts | 4 ++++
.../basic_optimization.test.ts.snap | 6 ++++--
.../basic_optimization.test.ts | 12 ++++++++---
.../kbn-optimizer/src/worker/run_compilers.ts | 4 ++--
5 files changed, 39 insertions(+), 7 deletions(-)
create mode 100644 packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/foo/public/async_import.ts
diff --git a/packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/foo/public/async_import.ts b/packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/foo/public/async_import.ts
new file mode 100644
index 0000000000000..9a51937cbac1e
--- /dev/null
+++ b/packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/foo/public/async_import.ts
@@ -0,0 +1,20 @@
+/*
+ * 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 function foo() {}
diff --git a/packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/foo/public/index.ts b/packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/foo/public/index.ts
index 9d3871df24739..1ba0b69681152 100644
--- a/packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/foo/public/index.ts
+++ b/packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/foo/public/index.ts
@@ -19,3 +19,7 @@
export * from './lib';
export * from './ext';
+
+export async function getFoo() {
+ return await import('./async_import');
+}
diff --git a/packages/kbn-optimizer/src/integration_tests/__snapshots__/basic_optimization.test.ts.snap b/packages/kbn-optimizer/src/integration_tests/__snapshots__/basic_optimization.test.ts.snap
index 1a974d3e81092..d52d89eebe2f1 100644
--- a/packages/kbn-optimizer/src/integration_tests/__snapshots__/basic_optimization.test.ts.snap
+++ b/packages/kbn-optimizer/src/integration_tests/__snapshots__/basic_optimization.test.ts.snap
@@ -1,5 +1,7 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
+exports[`builds expected bundles, saves bundle counts to metadata: 1 async bundle 1`] = `"(window[\\"foo_bundle_jsonpfunction\\"]=window[\\"foo_bundle_jsonpfunction\\"]||[]).push([[1],{3:function(module,exports,__webpack_require__){\\"use strict\\";Object.defineProperty(exports,\\"__esModule\\",{value:true});exports.foo=foo;function foo(){}}}]);"`;
+
exports[`builds expected bundles, saves bundle counts to metadata: OptimizerConfig 1`] = `
OptimizerConfig {
"bundles": Array [
@@ -55,6 +57,6 @@ OptimizerConfig {
}
`;
-exports[`builds expected bundles, saves bundle counts to metadata: bar bundle 1`] = `"var __kbnBundles__=typeof __kbnBundles__===\\"object\\"?__kbnBundles__:{};__kbnBundles__[\\"plugin/bar\\"]=function(modules){var installedModules={};function __webpack_require__(moduleId){if(installedModules[moduleId]){return installedModules[moduleId].exports}var module=installedModules[moduleId]={i:moduleId,l:false,exports:{}};modules[moduleId].call(module.exports,module,module.exports,__webpack_require__);module.l=true;return module.exports}__webpack_require__.m=modules;__webpack_require__.c=installedModules;__webpack_require__.d=function(exports,name,getter){if(!__webpack_require__.o(exports,name)){Object.defineProperty(exports,name,{enumerable:true,get:getter})}};__webpack_require__.r=function(exports){if(typeof Symbol!==\\"undefined\\"&&Symbol.toStringTag){Object.defineProperty(exports,Symbol.toStringTag,{value:\\"Module\\"})}Object.defineProperty(exports,\\"__esModule\\",{value:true})};__webpack_require__.t=function(value,mode){if(mode&1)value=__webpack_require__(value);if(mode&8)return value;if(mode&4&&typeof value===\\"object\\"&&value&&value.__esModule)return value;var ns=Object.create(null);__webpack_require__.r(ns);Object.defineProperty(ns,\\"default\\",{enumerable:true,value:value});if(mode&2&&typeof value!=\\"string\\")for(var key in value)__webpack_require__.d(ns,key,function(key){return value[key]}.bind(null,key));return ns};__webpack_require__.n=function(module){var getter=module&&module.__esModule?function getDefault(){return module[\\"default\\"]}:function getModuleExports(){return module};__webpack_require__.d(getter,\\"a\\",getter);return getter};__webpack_require__.o=function(object,property){return Object.prototype.hasOwnProperty.call(object,property)};__webpack_require__.p=\\"__REPLACE_WITH_PUBLIC_PATH__\\";return __webpack_require__(__webpack_require__.s=4)}([function(module,exports,__webpack_require__){\\"use strict\\";var isOldIE=function isOldIE(){var memo;return function memorize(){if(typeof memo===\\"undefined\\"){memo=Boolean(window&&document&&document.all&&!window.atob)}return memo}}();var getTarget=function getTarget(){var memo={};return function memorize(target){if(typeof memo[target]===\\"undefined\\"){var styleTarget=document.querySelector(target);if(window.HTMLIFrameElement&&styleTarget instanceof window.HTMLIFrameElement){try{styleTarget=styleTarget.contentDocument.head}catch(e){styleTarget=null}}memo[target]=styleTarget}return memo[target]}}();var stylesInDom=[];function getIndexByIdentifier(identifier){var result=-1;for(var i=0;i {
Fs.readFileSync(Path.resolve(MOCK_REPO_DIR, 'plugins/foo/target/public/foo.plugin.js'), 'utf8')
).toMatchSnapshot('foo bundle');
+ expect(
+ Fs.readFileSync(Path.resolve(MOCK_REPO_DIR, 'plugins/foo/target/public/1.plugin.js'), 'utf8')
+ ).toMatchSnapshot('1 async bundle');
+
expect(
Fs.readFileSync(Path.resolve(MOCK_REPO_DIR, 'plugins/bar/target/public/bar.plugin.js'), 'utf8')
).toMatchSnapshot('bar bundle');
@@ -135,9 +139,10 @@ it('builds expected bundles, saves bundle counts to metadata', async () => {
const foo = config.bundles.find(b => b.id === 'foo')!;
expect(foo).toBeTruthy();
foo.cache.refresh();
- expect(foo.cache.getModuleCount()).toBe(3);
+ expect(foo.cache.getModuleCount()).toBe(4);
expect(foo.cache.getReferencedFiles()).toMatchInlineSnapshot(`
Array [
+ /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/public/async_import.ts,
/packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/public/ext.ts,
/packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/public/index.ts,
/packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/public/lib.ts,
@@ -148,8 +153,8 @@ it('builds expected bundles, saves bundle counts to metadata', async () => {
expect(bar).toBeTruthy();
bar.cache.refresh();
expect(bar.cache.getModuleCount()).toBe(
- // code + styles + style/css-loader runtime
- 14
+ // code + styles + style/css-loader runtimes
+ 15
);
expect(bar.cache.getReferencedFiles()).toMatchInlineSnapshot(`
@@ -159,6 +164,7 @@ it('builds expected bundles, saves bundle counts to metadata', async () => {
/packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/bar/public/index.ts,
/packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/bar/public/legacy/styles.scss,
/packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/bar/public/lib.ts,
+ /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/public/async_import.ts,
/packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/public/ext.ts,
/packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/public/index.ts,
/packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/public/lib.ts,
diff --git a/packages/kbn-optimizer/src/worker/run_compilers.ts b/packages/kbn-optimizer/src/worker/run_compilers.ts
index 7a8097fd2b2c7..e87ddc7d0185c 100644
--- a/packages/kbn-optimizer/src/worker/run_compilers.ts
+++ b/packages/kbn-optimizer/src/worker/run_compilers.ts
@@ -127,7 +127,7 @@ const observeCompiler = (
);
}
- const files = Array.from(referencedFiles);
+ const files = Array.from(referencedFiles).sort(ascending(p => p));
const mtimes = new Map(
files.map((path): [string, number | undefined] => {
try {
@@ -146,7 +146,7 @@ const observeCompiler = (
optimizerCacheKey: workerConfig.optimizerCacheKey,
cacheKey: bundle.createCacheKey(files, mtimes),
moduleCount: normalModules.length,
- files: files.sort(ascending(f => f)),
+ files,
});
return compilerMsgs.compilerSuccess({
From 6cacfd0c1505a8107c24d6c222b67b724ab6ff25 Mon Sep 17 00:00:00 2001
From: Spencer
Date: Tue, 3 Mar 2020 08:58:50 -0700
Subject: [PATCH 06/96] =?UTF-8?q?[failed-test-report]=20if=20one=20test=20?=
=?UTF-8?q?fails=20twice=20don't=20create=20two=E2=80=A6=20(#58778)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* [failed-test-report] if one test fails twice don't create two issues
* fix type check error
Co-authored-by: Elastic Machine
---
.../src/failed_tests_reporter/github_api.ts | 15 +++++++++--
.../report_failure.test.ts | 2 --
.../failed_tests_reporter/report_failure.ts | 4 +--
.../run_failed_tests_reporter_cli.ts | 26 +++++++++++++++----
4 files changed, 36 insertions(+), 11 deletions(-)
diff --git a/packages/kbn-test/src/failed_tests_reporter/github_api.ts b/packages/kbn-test/src/failed_tests_reporter/github_api.ts
index d8a952bee42e5..7da79b5b67e63 100644
--- a/packages/kbn-test/src/failed_tests_reporter/github_api.ts
+++ b/packages/kbn-test/src/failed_tests_reporter/github_api.ts
@@ -33,6 +33,15 @@ export interface GithubIssue {
body: string;
}
+/**
+ * Minimal GithubIssue type that can be easily replicated by dry-run helpers
+ */
+export interface GithubIssueMini {
+ number: GithubIssue['number'];
+ body: GithubIssue['body'];
+ html_url: GithubIssue['html_url'];
+}
+
type RequestOptions = AxiosRequestConfig & {
safeForDryRun?: boolean;
maxAttempts?: number;
@@ -162,7 +171,7 @@ export class GithubApi {
}
async createIssue(title: string, body: string, labels?: string[]) {
- const resp = await this.request(
+ const resp = await this.request(
{
method: 'POST',
url: Url.resolve(BASE_URL, 'issues'),
@@ -173,11 +182,13 @@ export class GithubApi {
},
},
{
+ body,
+ number: 999,
html_url: 'https://dryrun',
}
);
- return resp.data.html_url;
+ return resp.data;
}
private async request(
diff --git a/packages/kbn-test/src/failed_tests_reporter/report_failure.test.ts b/packages/kbn-test/src/failed_tests_reporter/report_failure.test.ts
index ef6ab3c51ab19..5bbc72fe04e86 100644
--- a/packages/kbn-test/src/failed_tests_reporter/report_failure.test.ts
+++ b/packages/kbn-test/src/failed_tests_reporter/report_failure.test.ts
@@ -78,9 +78,7 @@ describe('updateFailureIssue()', () => {
'https://build-url',
{
html_url: 'https://github.com/issues/1234',
- labels: ['some-label'],
number: 1234,
- title: 'issue title',
body: dedent`
# existing issue body
diff --git a/packages/kbn-test/src/failed_tests_reporter/report_failure.ts b/packages/kbn-test/src/failed_tests_reporter/report_failure.ts
index 97e9d517576fc..1413d05498459 100644
--- a/packages/kbn-test/src/failed_tests_reporter/report_failure.ts
+++ b/packages/kbn-test/src/failed_tests_reporter/report_failure.ts
@@ -18,7 +18,7 @@
*/
import { TestFailure } from './get_failures';
-import { GithubIssue, GithubApi } from './github_api';
+import { GithubIssueMini, GithubApi } from './github_api';
import { getIssueMetadata, updateIssueMetadata } from './issue_metadata';
export async function createFailureIssue(buildUrl: string, failure: TestFailure, api: GithubApi) {
@@ -44,7 +44,7 @@ export async function createFailureIssue(buildUrl: string, failure: TestFailure,
return await api.createIssue(title, body, ['failed-test']);
}
-export async function updateFailureIssue(buildUrl: string, issue: GithubIssue, api: GithubApi) {
+export async function updateFailureIssue(buildUrl: string, issue: GithubIssueMini, api: GithubApi) {
// Increment failCount
const newCount = getIssueMetadata(issue.body, 'test.failCount', 0) + 1;
const newBody = updateIssueMetadata(issue.body, {
diff --git a/packages/kbn-test/src/failed_tests_reporter/run_failed_tests_reporter_cli.ts b/packages/kbn-test/src/failed_tests_reporter/run_failed_tests_reporter_cli.ts
index fc52fa6cbf9e7..9324f9eb42aa5 100644
--- a/packages/kbn-test/src/failed_tests_reporter/run_failed_tests_reporter_cli.ts
+++ b/packages/kbn-test/src/failed_tests_reporter/run_failed_tests_reporter_cli.ts
@@ -20,8 +20,8 @@
import { REPO_ROOT, run, createFailError, createFlagError } from '@kbn/dev-utils';
import globby from 'globby';
-import { getFailures } from './get_failures';
-import { GithubApi } from './github_api';
+import { getFailures, TestFailure } from './get_failures';
+import { GithubApi, GithubIssueMini } from './github_api';
import { updateFailureIssue, createFailureIssue } from './report_failure';
import { getIssueMetadata } from './issue_metadata';
import { readTestReport } from './test_report';
@@ -73,6 +73,11 @@ export function runFailedTestsReporterCli() {
absolute: true,
});
+ const newlyCreatedIssues: Array<{
+ failure: TestFailure;
+ newIssue: GithubIssueMini;
+ }> = [];
+
for (const reportPath of reportPaths) {
const report = await readTestReport(reportPath);
const messages = Array.from(getReportMessageIter(report));
@@ -94,12 +99,22 @@ export function runFailedTestsReporterCli() {
continue;
}
- const existingIssue = await githubApi.findFailedTestIssue(
+ let existingIssue: GithubIssueMini | undefined = await githubApi.findFailedTestIssue(
i =>
getIssueMetadata(i.body, 'test.class') === failure.classname &&
getIssueMetadata(i.body, 'test.name') === failure.name
);
+ if (!existingIssue) {
+ const newlyCreated = newlyCreatedIssues.find(
+ ({ failure: f }) => f.classname === failure.classname && f.name === failure.name
+ );
+
+ if (newlyCreated) {
+ existingIssue = newlyCreated.newIssue;
+ }
+ }
+
if (existingIssue) {
const newFailureCount = await updateFailureIssue(buildUrl, existingIssue, githubApi);
const url = existingIssue.html_url;
@@ -110,11 +125,12 @@ export function runFailedTestsReporterCli() {
continue;
}
- const newIssueUrl = await createFailureIssue(buildUrl, failure, githubApi);
+ const newIssue = await createFailureIssue(buildUrl, failure, githubApi);
pushMessage('Test has not failed recently on tracked branches');
if (updateGithub) {
- pushMessage(`Created new issue: ${newIssueUrl}`);
+ pushMessage(`Created new issue: ${newIssue.html_url}`);
}
+ newlyCreatedIssues.push({ failure, newIssue });
}
// mutates report to include messages and writes updated report to disk
From 417f7966938b6a4d1a7401a9ed055432618b9c91 Mon Sep 17 00:00:00 2001
From: Justin Juno <50022106+justinjunodev@users.noreply.github.com>
Date: Tue, 3 Mar 2020 10:25:59 -0600
Subject: [PATCH 07/96] [Rollups] Fix broken link in Rollup Jobs (#58802)
(#58929)
---
.../rollup/public/crud_app/services/documentation_links.js | 4 +---
1 file changed, 1 insertion(+), 3 deletions(-)
diff --git a/x-pack/legacy/plugins/rollup/public/crud_app/services/documentation_links.js b/x-pack/legacy/plugins/rollup/public/crud_app/services/documentation_links.js
index ce42b26cc3e86..bc9cb15e1c5e0 100644
--- a/x-pack/legacy/plugins/rollup/public/crud_app/services/documentation_links.js
+++ b/x-pack/legacy/plugins/rollup/public/crud_app/services/documentation_links.js
@@ -5,11 +5,9 @@
*/
let esBase = '';
-let xPackBase = '';
export function setEsBaseAndXPackBase(elasticWebsiteUrl, docLinksVersion) {
esBase = `${elasticWebsiteUrl}guide/en/elasticsearch/reference/${docLinksVersion}`;
- xPackBase = `${elasticWebsiteUrl}guide/en/x-pack/${docLinksVersion}`;
}
export const getLogisticalDetailsUrl = () => `${esBase}/rollup-job-config.html#_logistical_details`;
@@ -21,4 +19,4 @@ export const getMetricsDetailsUrl = () => `${esBase}/rollup-job-config.html#roll
export const getDateHistogramAggregationUrl = () =>
`${esBase}/search-aggregations-bucket-datehistogram-aggregation.html`;
-export const getCronUrl = () => `${xPackBase}/trigger-schedule.html#_cron_expressions`;
+export const getCronUrl = () => `${esBase}/trigger-schedule.html#_cron_expressions`;
From 63cb9ff7be925d9f140c096c90e1eabe9cca9d75 Mon Sep 17 00:00:00 2001
From: Walter Rafelsberger
Date: Tue, 3 Mar 2020 17:43:38 +0100
Subject: [PATCH 08/96] [ML] Use EuiDataGrid for transform wizard. (#52510)
Replaces the custom EuiInMemoryTable component with EuiDataGrid for the transforms wizard.
---
.../transform/public/app/common/data_grid.ts | 23 +
.../transform/public/app/common/fields.ts | 32 +-
.../transform/public/app/common/index.ts | 2 +-
.../source_index_preview.tsx | 435 ++++++------------
.../use_source_index_data.test.tsx | 3 +-
.../use_source_index_data.ts | 81 +---
.../step_create/step_create_form.tsx | 54 ++-
.../components/step_define/common.test.ts | 56 ++-
.../components/step_define/common.ts | 49 +-
.../components/step_define/pivot_preview.tsx | 234 +++++-----
.../step_define/step_define_form.tsx | 16 +-
.../components/wizard/_wizard.scss | 22 +-
.../translations/translations/ja-JP.json | 9 -
.../translations/translations/zh-CN.json | 9 -
.../apps/transform/creation_index_pattern.ts | 2 +-
.../apps/transform/creation_saved_search.ts | 2 +-
.../services/transform_ui/wizard.ts | 68 +--
17 files changed, 515 insertions(+), 582 deletions(-)
create mode 100644 x-pack/legacy/plugins/transform/public/app/common/data_grid.ts
diff --git a/x-pack/legacy/plugins/transform/public/app/common/data_grid.ts b/x-pack/legacy/plugins/transform/public/app/common/data_grid.ts
new file mode 100644
index 0000000000000..0783839afee83
--- /dev/null
+++ b/x-pack/legacy/plugins/transform/public/app/common/data_grid.ts
@@ -0,0 +1,23 @@
+/*
+ * 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 { EuiDataGridStyle } from '@elastic/eui';
+
+export const euiDataGridStyle: EuiDataGridStyle = {
+ border: 'all',
+ fontSize: 's',
+ cellPadding: 's',
+ stripes: false,
+ rowHover: 'highlight',
+ header: 'shade',
+};
+
+export const euiDataGridToolbarSettings = {
+ showColumnSelector: true,
+ showStyleSelector: false,
+ showSortSelector: true,
+ showFullScreenSelector: false,
+};
diff --git a/x-pack/legacy/plugins/transform/public/app/common/fields.ts b/x-pack/legacy/plugins/transform/public/app/common/fields.ts
index f2181654286db..108f45ce67e37 100644
--- a/x-pack/legacy/plugins/transform/public/app/common/fields.ts
+++ b/x-pack/legacy/plugins/transform/public/app/common/fields.ts
@@ -15,8 +15,6 @@ export interface EsDoc extends Dictionary {
_source: EsDocSource;
}
-export const MAX_COLUMNS = 5;
-
export function getFlattenedFields(obj: EsDocSource): EsFieldName[] {
const flatDocFields: EsFieldName[] = [];
const newDocFields = Object.keys(obj);
@@ -33,35 +31,33 @@ export function getFlattenedFields(obj: EsDocSource): EsFieldName[] {
return flatDocFields;
}
-export const getSelectableFields = (docs: EsDoc[]): EsFieldName[] => {
+export const getSelectableFields = (docs: EsDocSource[]): EsFieldName[] => {
if (docs.length === 0) {
return [];
}
- const newDocFields = getFlattenedFields(docs[0]._source);
+ const newDocFields = getFlattenedFields(docs[0]);
newDocFields.sort();
return newDocFields;
};
-export const getDefaultSelectableFields = (docs: EsDoc[]): EsFieldName[] => {
+export const getDefaultSelectableFields = (docs: EsDocSource[]): EsFieldName[] => {
if (docs.length === 0) {
return [];
}
- const newDocFields = getFlattenedFields(docs[0]._source);
+ const newDocFields = getFlattenedFields(docs[0]);
newDocFields.sort();
- return newDocFields
- .filter(k => {
- let value = false;
- docs.forEach(row => {
- const source = row._source;
- if (source[k] !== null) {
- value = true;
- }
- });
- return value;
- })
- .slice(0, MAX_COLUMNS);
+ return newDocFields.filter(k => {
+ let value = false;
+ docs.forEach(row => {
+ const source = row;
+ if (source[k] !== null) {
+ value = true;
+ }
+ });
+ return value;
+ });
};
export const toggleSelectedField = (
diff --git a/x-pack/legacy/plugins/transform/public/app/common/index.ts b/x-pack/legacy/plugins/transform/public/app/common/index.ts
index 3f515db389b45..52a6884367bc5 100644
--- a/x-pack/legacy/plugins/transform/public/app/common/index.ts
+++ b/x-pack/legacy/plugins/transform/public/app/common/index.ts
@@ -5,6 +5,7 @@
*/
export { AggName, isAggName } from './aggregations';
+export { euiDataGridStyle, euiDataGridToolbarSettings } from './data_grid';
export {
getDefaultSelectableFields,
getFlattenedFields,
@@ -13,7 +14,6 @@ export {
EsDoc,
EsDocSource,
EsFieldName,
- MAX_COLUMNS,
} from './fields';
export { DropDownLabel, DropDownOption, Label } from './dropdown';
export {
diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/source_index_preview.tsx b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/source_index_preview.tsx
index 2b7d36cada3c6..0c9dcfb9b1c04 100644
--- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/source_index_preview.tsx
+++ b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/source_index_preview.tsx
@@ -4,60 +4,36 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import React, { useState } from 'react';
-import moment from 'moment-timezone';
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { i18n } from '@kbn/i18n';
import {
- EuiBadge,
- EuiButtonEmpty,
EuiButtonIcon,
EuiCallOut,
- EuiCheckbox,
EuiCodeBlock,
EuiCopy,
+ EuiDataGrid,
EuiFlexGroup,
EuiFlexItem,
- EuiPanel,
- EuiPopover,
- EuiPopoverTitle,
EuiProgress,
- EuiText,
EuiTitle,
- EuiToolTip,
- RIGHT_ALIGNMENT,
} from '@elastic/eui';
-import {
- ColumnType,
- mlInMemoryTableBasicFactory,
- SortingPropType,
- SORT_DIRECTION,
-} from '../../../../../shared_imports';
-
-import { KBN_FIELD_TYPES } from '../../../../../../../../../../src/plugins/data/public';
-import { Dictionary } from '../../../../../../common/types/common';
-import { formatHumanReadableDateTimeSeconds } from '../../../../../../common/utils/date_utils';
+import { getNestedProperty } from '../../../../../../common/utils/object_utils';
import { useCurrentIndexPattern } from '../../../../lib/kibana';
import {
- toggleSelectedField,
- EsDoc,
+ euiDataGridStyle,
+ euiDataGridToolbarSettings,
EsFieldName,
- MAX_COLUMNS,
PivotQuery,
} from '../../../../common';
import { getSourceIndexDevConsoleStatement } from './common';
-import { ExpandedRow } from './expanded_row';
import { SOURCE_INDEX_STATUS, useSourceIndexData } from './use_source_index_data';
-type ItemIdToExpandedRowMap = Dictionary;
-
-const CELL_CLICK_ENABLED = false;
-
interface SourceIndexPreviewTitle {
indexPatternTitle: string;
}
@@ -74,67 +50,112 @@ const SourceIndexPreviewTitle: React.FC = ({ indexPatte
interface Props {
query: PivotQuery;
- cellClick?(search: string): void;
}
-export const SourceIndexPreview: React.FC = React.memo(({ cellClick, query }) => {
- const [clearTable, setClearTable] = useState(false);
+const defaultPagination = { pageIndex: 0, pageSize: 5 };
+export const SourceIndexPreview: React.FC = React.memo(({ query }) => {
const indexPattern = useCurrentIndexPattern();
+ const allFields = indexPattern.fields.map(f => f.name);
+ const indexPatternFields: string[] = allFields.filter(f => {
+ if (indexPattern.metaFields.includes(f)) {
+ return false;
+ }
- const [selectedFields, setSelectedFields] = useState([] as EsFieldName[]);
- const [isColumnsPopoverVisible, setColumnsPopoverVisible] = useState(false);
-
- // EuiInMemoryTable has an issue with dynamic sortable columns
- // and will trigger a full page Kibana error in such a case.
- // The following is a workaround until this is solved upstream:
- // - If the sortable/columns config changes,
- // the table will be unmounted/not rendered.
- // This is what setClearTable(true) in toggleColumn() does.
- // - After that on next render it gets re-enabled. To make sure React
- // doesn't consolidate the state updates, setTimeout is used.
- if (clearTable) {
- setTimeout(() => setClearTable(false), 0);
- }
+ const fieldParts = f.split('.');
+ const lastPart = fieldParts.pop();
+ if (lastPart === 'keyword' && allFields.includes(fieldParts.join('.'))) {
+ return false;
+ }
- function toggleColumnsPopover() {
- setColumnsPopoverVisible(!isColumnsPopoverVisible);
- }
+ return true;
+ });
- function closeColumnsPopover() {
- setColumnsPopoverVisible(false);
- }
+ // Column visibility
+ const [visibleColumns, setVisibleColumns] = useState(indexPatternFields);
- function toggleColumn(column: EsFieldName) {
- // spread to a new array otherwise the component wouldn't re-render
- setClearTable(true);
- setSelectedFields([...toggleSelectedField(selectedFields, column)]);
- }
+ const [pagination, setPagination] = useState(defaultPagination);
+
+ useEffect(() => {
+ setPagination(defaultPagination);
+ }, [query]);
- const [itemIdToExpandedRowMap, setItemIdToExpandedRowMap] = useState(
- {} as ItemIdToExpandedRowMap
+ const { errorMessage, status, rowCount, tableItems: data } = useSourceIndexData(
+ indexPattern,
+ query,
+ pagination
);
- function toggleDetails(item: EsDoc) {
- if (itemIdToExpandedRowMap[item._id]) {
- delete itemIdToExpandedRowMap[item._id];
- } else {
- itemIdToExpandedRowMap[item._id] = ;
+ // EuiDataGrid State
+ const dataGridColumns = indexPatternFields.map(id => {
+ const field = indexPattern.fields.getByName(id);
+
+ let schema = 'string';
+
+ switch (field?.type) {
+ case 'date':
+ schema = 'datetime';
+ break;
+ case 'geo_point':
+ schema = 'json';
+ break;
+ case 'number':
+ schema = 'numeric';
+ break;
}
- // spread to a new object otherwise the component wouldn't re-render
- setItemIdToExpandedRowMap({ ...itemIdToExpandedRowMap });
- }
- const { errorMessage, status, tableItems } = useSourceIndexData(
- indexPattern,
- query,
- selectedFields,
- setSelectedFields
+ return { id, schema };
+ });
+
+ const onChangeItemsPerPage = useCallback(
+ pageSize => {
+ setPagination(p => {
+ const pageIndex = Math.floor((p.pageSize * p.pageIndex) / pageSize);
+ return { pageIndex, pageSize };
+ });
+ },
+ [setPagination]
);
+ const onChangePage = useCallback(pageIndex => setPagination(p => ({ ...p, pageIndex })), [
+ setPagination,
+ ]);
+
+ // ** Sorting config
+ const [sortingColumns, setSortingColumns] = useState([]);
+ const onSort = useCallback(sc => setSortingColumns(sc), [setSortingColumns]);
+
+ const renderCellValue = useMemo(() => {
+ return ({
+ rowIndex,
+ columnId,
+ setCellProps,
+ }: {
+ rowIndex: number;
+ columnId: string;
+ setCellProps: any;
+ }) => {
+ const adjustedRowIndex = rowIndex - pagination.pageIndex * pagination.pageSize;
+
+ const cellValue = data.hasOwnProperty(adjustedRowIndex)
+ ? getNestedProperty(data[adjustedRowIndex], columnId, null)
+ : null;
+
+ if (typeof cellValue === 'object' && cellValue !== null) {
+ return JSON.stringify(cellValue);
+ }
+
+ if (cellValue === undefined) {
+ return null;
+ }
+
+ return cellValue;
+ };
+ }, [data, pagination.pageIndex, pagination.pageSize]);
+
if (status === SOURCE_INDEX_STATUS.ERROR) {
return (
-
+
= React.memo(({ cellClick, quer
{errorMessage}
-
+
);
}
- if (status === SOURCE_INDEX_STATUS.LOADED && tableItems.length === 0) {
+ if (status === SOURCE_INDEX_STATUS.LOADED && data.length === 0) {
return (
-
+
= React.memo(({ cellClick, quer
})}
-
+
);
}
- let docFields: EsFieldName[] = [];
- let docFieldsCount = 0;
- if (tableItems.length > 0) {
- docFields = Object.keys(tableItems[0]._source);
- docFields.sort();
- docFieldsCount = docFields.length;
- }
-
- const columns: Array> = selectedFields.map(k => {
- const column: ColumnType = {
- field: `_source["${k}"]`,
- name: k,
- sortable: true,
- truncateText: true,
- };
-
- const field = indexPattern.fields.find(f => f.name === k);
-
- const formatField = (d: string) => {
- return field !== undefined && field.type === KBN_FIELD_TYPES.DATE
- ? formatHumanReadableDateTimeSeconds(moment(d).unix() * 1000)
- : d;
- };
-
- const render = (d: any) => {
- if (Array.isArray(d) && d.every(item => typeof item === 'string')) {
- // If the cells data is an array of strings, return as a comma separated list.
- // The list will get limited to 5 items with `…` at the end if there's more in the original array.
- return `${d
- .map(item => formatField(item))
- .slice(0, 5)
- .join(', ')}${d.length > 5 ? ', …' : ''}`;
- } else if (Array.isArray(d)) {
- // If the cells data is an array of e.g. objects, display a 'array' badge with a
- // tooltip that explains that this type of field is not supported in this table.
- return (
-
-
- {i18n.translate('xpack.transform.sourceIndexPreview.SourceIndexArrayBadgeContent', {
- defaultMessage: 'array',
- })}
-
-
- );
- } else if (typeof d === 'object' && d !== null) {
- // If the cells data is an object, display a 'object' badge with a
- // tooltip that explains that this type of field is not supported in this table.
- return (
-
-
- {i18n.translate('xpack.transform.sourceIndexPreview.SourceIndexObjectBadgeContent', {
- defaultMessage: 'object',
- })}
-
-
- );
- }
-
- return formatField(d);
- };
-
- if (typeof field !== 'undefined') {
- switch (field.type) {
- case KBN_FIELD_TYPES.BOOLEAN:
- column.dataType = 'boolean';
- break;
- case KBN_FIELD_TYPES.DATE:
- column.align = 'right';
- column.render = (d: any) => formatHumanReadableDateTimeSeconds(moment(d).unix() * 1000);
- break;
- case KBN_FIELD_TYPES.NUMBER:
- column.dataType = 'number';
- break;
- default:
- column.render = render;
- break;
- }
- } else {
- column.render = render;
- }
-
- if (CELL_CLICK_ENABLED && cellClick) {
- column.render = (d: string) => (
- cellClick(`${k}:(${d})`)}>
- {render(d)}
-
- );
- }
-
- return column;
- });
-
- let sorting: SortingPropType = false;
-
- if (columns.length > 0) {
- sorting = {
- sort: {
- field: `_source["${selectedFields[0]}"]`,
- direction: SORT_DIRECTION.ASC,
- },
- };
- }
-
- columns.unshift({
- align: RIGHT_ALIGNMENT,
- width: '40px',
- isExpander: true,
- render: (item: EsDoc) => (
- toggleDetails(item)}
- aria-label={
- itemIdToExpandedRowMap[item._id]
- ? i18n.translate('xpack.transform.sourceIndexPreview.rowCollapse', {
- defaultMessage: 'Collapse',
- })
- : i18n.translate('xpack.transform.sourceIndexPreview.rowExpand', {
- defaultMessage: 'Expand',
- })
- }
- iconType={itemIdToExpandedRowMap[item._id] ? 'arrowUp' : 'arrowDown'}
- />
- ),
- });
-
const euiCopyText = i18n.translate('xpack.transform.sourceIndexPreview.copyClipboardTooltip', {
defaultMessage: 'Copy Dev Console statement of the source index preview to the clipboard.',
});
- const MlInMemoryTableBasic = mlInMemoryTableBasicFactory();
-
return (
-
+
-
+
-
-
-
- {docFieldsCount > MAX_COLUMNS && (
-
- {i18n.translate('xpack.transform.sourceIndexPreview.fieldSelection', {
- defaultMessage:
- '{selectedFieldsLength, number} of {docFieldsCount, number} {docFieldsCount, plural, one {field} other {fields}} selected',
- values: { selectedFieldsLength: selectedFields.length, docFieldsCount },
- })}
-
- )}
-
-
-
-
- }
- isOpen={isColumnsPopoverVisible}
- closePopover={closeColumnsPopover}
- ownFocus
- >
-
- {i18n.translate('xpack.transform.sourceIndexPreview.selectFieldsPopoverTitle', {
- defaultMessage: 'Select fields',
- })}
-
-
- {docFields.map(d => (
- toggleColumn(d)}
- disabled={selectedFields.includes(d) && selectedFields.length === 1}
- />
- ))}
-
-
-
-
-
-
- {(copy: () => void) => (
-
- )}
-
-
-
+
+
+ {(copy: () => void) => (
+
+ )}
+
- {status === SOURCE_INDEX_STATUS.LOADING &&
}
- {status !== SOURCE_INDEX_STATUS.LOADING && (
-
- )}
- {clearTable === false && columns.length > 0 && sorting !== false && (
-
+ {status === SOURCE_INDEX_STATUS.LOADING && }
+ {status !== SOURCE_INDEX_STATUS.LOADING && (
+
+ )}
+
+ {dataGridColumns.length > 0 && data.length > 0 && (
+ ({
- 'data-test-subj': `transformSourceIndexPreviewRow row-${item._id}`,
- })}
- sorting={sorting}
/>
)}
-
+
);
});
diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/use_source_index_data.test.tsx b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/use_source_index_data.test.tsx
index fb0a71baea321..715573e3a6f67 100644
--- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/use_source_index_data.test.tsx
+++ b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/use_source_index_data.test.tsx
@@ -51,8 +51,7 @@ describe('useSourceIndexData', () => {
sourceIndexObj = useSourceIndexData(
{ id: 'the-id', title: 'the-title', fields: [] },
query,
- [],
- () => {}
+ { pageIndex: 0, pageSize: 10 }
);
});
});
diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/use_source_index_data.ts b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/use_source_index_data.ts
index e5c6783db1022..ae5bd9040baca 100644
--- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/use_source_index_data.ts
+++ b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/use_source_index_data.ts
@@ -4,27 +4,15 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import React, { useEffect, useState } from 'react';
+import { useEffect, useState } from 'react';
import { SearchResponse } from 'elasticsearch';
import { IIndexPattern } from 'src/plugins/data/public';
import { useApi } from '../../../../hooks/use_api';
-import { getNestedProperty } from '../../../../../../common/utils/object_utils';
-import {
- getDefaultSelectableFields,
- getFlattenedFields,
- isDefaultQuery,
- matchAllQuery,
- EsDoc,
- EsDocSource,
- EsFieldName,
- PivotQuery,
-} from '../../../../common';
-
-const SEARCH_SIZE = 1000;
+import { isDefaultQuery, matchAllQuery, EsDocSource, PivotQuery } from '../../../../common';
export enum SOURCE_INDEX_STATUS {
UNUSED,
@@ -48,23 +36,34 @@ const isErrorResponse = (arg: any): arg is ErrorResponse => {
return arg.error !== undefined;
};
-type SourceIndexSearchResponse = ErrorResponse | SearchResponse;
+// The types specified in `@types/elasticsearch` are out of date and still have `total: number`.
+interface SearchResponse7 extends SearchResponse {
+ hits: SearchResponse['hits'] & {
+ total: {
+ value: number;
+ relation: string;
+ };
+ };
+}
+
+type SourceIndexSearchResponse = ErrorResponse | SearchResponse7;
export interface UseSourceIndexDataReturnType {
errorMessage: string;
status: SOURCE_INDEX_STATUS;
- tableItems: EsDoc[];
+ rowCount: number;
+ tableItems: EsDocSource[];
}
export const useSourceIndexData = (
indexPattern: IIndexPattern,
query: PivotQuery,
- selectedFields: EsFieldName[],
- setSelectedFields: React.Dispatch>
+ pagination: { pageIndex: number; pageSize: number }
): UseSourceIndexDataReturnType => {
const [errorMessage, setErrorMessage] = useState('');
const [status, setStatus] = useState(SOURCE_INDEX_STATUS.UNUSED);
- const [tableItems, setTableItems] = useState([]);
+ const [rowCount, setRowCount] = useState(0);
+ const [tableItems, setTableItems] = useState([]);
const api = useApi();
const getSourceIndexData = async function() {
@@ -74,7 +73,8 @@ export const useSourceIndexData = (
try {
const resp: SourceIndexSearchResponse = await api.esSearch({
index: indexPattern.title,
- size: SEARCH_SIZE,
+ from: pagination.pageIndex * pagination.pageSize,
+ size: pagination.pageSize,
// Instead of using the default query (`*`), fall back to a more efficient `match_all` query.
body: { query: isDefaultQuery(query) ? matchAllQuery : query },
});
@@ -83,41 +83,10 @@ export const useSourceIndexData = (
throw resp.error;
}
- const docs = resp.hits.hits;
-
- if (docs.length === 0) {
- setTableItems([]);
- setStatus(SOURCE_INDEX_STATUS.LOADED);
- return;
- }
-
- if (selectedFields.length === 0) {
- const newSelectedFields = getDefaultSelectableFields(docs);
- setSelectedFields(newSelectedFields);
- }
-
- // Create a version of the doc's source with flattened field names.
- // This avoids confusion later on if a field name has dots in its name
- // or is a nested fields when displaying it via EuiInMemoryTable.
- const flattenedFields = getFlattenedFields(docs[0]._source);
- const transformedTableItems = docs.map(doc => {
- const item: EsDocSource = {};
- flattenedFields.forEach(ff => {
- item[ff] = getNestedProperty(doc._source, ff);
- if (item[ff] === undefined) {
- // If the attribute is undefined, it means it was not a nested property
- // but had dots in its actual name. This selects the property by its
- // full name and assigns it to `item[ff]`.
- item[ff] = doc._source[`"${ff}"`];
- }
- });
- return {
- ...doc,
- _source: item,
- };
- });
+ const docs = resp.hits.hits.map(d => d._source);
- setTableItems(transformedTableItems);
+ setRowCount(resp.hits.total.value);
+ setTableItems(docs);
setStatus(SOURCE_INDEX_STATUS.LOADED);
} catch (e) {
if (e.message !== undefined) {
@@ -134,6 +103,6 @@ export const useSourceIndexData = (
getSourceIndexData();
// custom comparison
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [indexPattern.title, JSON.stringify(query)]);
- return { errorMessage, status, tableItems };
+ }, [indexPattern.title, JSON.stringify(query), JSON.stringify(pagination)]);
+ return { errorMessage, status, rowCount, tableItems };
};
diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.tsx b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.tsx
index 312d8a30dab77..bbeb97b6b8113 100644
--- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.tsx
+++ b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.tsx
@@ -67,6 +67,7 @@ export const StepCreateForm: FC = React.memo(
const [redirectToTransformManagement, setRedirectToTransformManagement] = useState(false);
+ const [loading, setLoading] = useState(false);
const [created, setCreated] = useState(defaults.created);
const [started, setStarted] = useState(defaults.started);
const [indexPatternId, setIndexPatternId] = useState(defaults.indexPatternId);
@@ -87,7 +88,7 @@ export const StepCreateForm: FC = React.memo(
const api = useApi();
async function createTransform() {
- setCreated(true);
+ setLoading(true);
try {
const resp = await api.createTransform(transformId, transformConfig);
@@ -107,8 +108,9 @@ export const StepCreateForm: FC = React.memo(
values: { transformId },
})
);
+ setCreated(true);
+ setLoading(false);
} catch (e) {
- setCreated(false);
toastNotifications.addDanger({
title: i18n.translate('xpack.transform.stepCreateForm.createTransformErrorMessage', {
defaultMessage: 'An error occurred creating the transform {transformId}:',
@@ -116,6 +118,8 @@ export const StepCreateForm: FC = React.memo(
}),
text: toMountPoint(),
});
+ setCreated(false);
+ setLoading(false);
return false;
}
@@ -127,18 +131,27 @@ export const StepCreateForm: FC = React.memo(
}
async function startTransform() {
- setStarted(true);
+ setLoading(true);
try {
- await api.startTransforms([{ id: transformId }]);
- toastNotifications.addSuccess(
- i18n.translate('xpack.transform.stepCreateForm.startTransformSuccessMessage', {
- defaultMessage: 'Request to start transform {transformId} acknowledged.',
- values: { transformId },
- })
- );
+ const resp = await api.startTransforms([{ id: transformId }]);
+ if (typeof resp === 'object' && resp !== null && resp[transformId]?.success === true) {
+ toastNotifications.addSuccess(
+ i18n.translate('xpack.transform.stepCreateForm.startTransformSuccessMessage', {
+ defaultMessage: 'Request to start transform {transformId} acknowledged.',
+ values: { transformId },
+ })
+ );
+ setStarted(true);
+ setLoading(false);
+ } else {
+ const errorMessage =
+ typeof resp === 'object' && resp !== null && resp[transformId]?.success === false
+ ? resp[transformId].error
+ : resp;
+ throw new Error(errorMessage);
+ }
} catch (e) {
- setStarted(false);
toastNotifications.addDanger({
title: i18n.translate('xpack.transform.stepCreateForm.startTransformErrorMessage', {
defaultMessage: 'An error occurred starting the transform {transformId}:',
@@ -146,6 +159,8 @@ export const StepCreateForm: FC = React.memo(
}),
text: toMountPoint(),
});
+ setStarted(false);
+ setLoading(false);
}
}
@@ -157,6 +172,7 @@ export const StepCreateForm: FC = React.memo(
}
const createKibanaIndexPattern = async () => {
+ setLoading(true);
const indexPatternName = transformConfig.dest.index;
try {
@@ -178,6 +194,7 @@ export const StepCreateForm: FC = React.memo(
values: { indexPatternName },
})
);
+ setLoading(false);
return;
}
@@ -195,6 +212,7 @@ export const StepCreateForm: FC = React.memo(
);
setIndexPatternId(id);
+ setLoading(false);
return true;
} catch (e) {
toastNotifications.addDanger({
@@ -205,13 +223,19 @@ export const StepCreateForm: FC = React.memo(
}),
text: toMountPoint(),
});
+ setLoading(false);
return false;
}
};
const isBatchTransform = typeof transformConfig.sync === 'undefined';
- if (started === true && progressPercentComplete === undefined && isBatchTransform) {
+ if (
+ loading === false &&
+ started === true &&
+ progressPercentComplete === undefined &&
+ isBatchTransform
+ ) {
// wrapping in function so we can keep the interval id in local scope
function startProgressBar() {
const interval = setInterval(async () => {
@@ -266,7 +290,7 @@ export const StepCreateForm: FC = React.memo(
@@ -293,7 +317,7 @@ export const StepCreateForm: FC = React.memo(
@@ -315,7 +339,7 @@ export const StepCreateForm: FC = React.memo(
diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/common.test.ts b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/common.test.ts
index 78ad217a69e3d..88e009c63339a 100644
--- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/common.test.ts
+++ b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/common.test.ts
@@ -4,6 +4,8 @@
* you may not use this file except in compliance with the Elastic License.
*/
+import { EuiDataGridSorting } from '@elastic/eui';
+
import {
getPreviewRequestBody,
PivotAggsConfig,
@@ -13,10 +15,62 @@ import {
SimpleQuery,
} from '../../../../common';
-import { getPivotPreviewDevConsoleStatement, getPivotDropdownOptions } from './common';
+import {
+ multiColumnSortFactory,
+ getPivotPreviewDevConsoleStatement,
+ getPivotDropdownOptions,
+} from './common';
import { IndexPattern } from '../../../../../../../../../../src/plugins/data/public';
describe('Transform: Define Pivot Common', () => {
+ test('customSortFactory()', () => {
+ const data = [
+ { s: 'a', n: 1 },
+ { s: 'a', n: 2 },
+ { s: 'b', n: 3 },
+ { s: 'b', n: 4 },
+ ];
+
+ const sortingColumns1: EuiDataGridSorting['columns'] = [{ id: 's', direction: 'desc' }];
+ const multiColumnSort1 = multiColumnSortFactory(sortingColumns1);
+ data.sort(multiColumnSort1);
+
+ expect(data).toStrictEqual([
+ { s: 'b', n: 3 },
+ { s: 'b', n: 4 },
+ { s: 'a', n: 1 },
+ { s: 'a', n: 2 },
+ ]);
+
+ const sortingColumns2: EuiDataGridSorting['columns'] = [
+ { id: 's', direction: 'asc' },
+ { id: 'n', direction: 'desc' },
+ ];
+ const multiColumnSort2 = multiColumnSortFactory(sortingColumns2);
+ data.sort(multiColumnSort2);
+
+ expect(data).toStrictEqual([
+ { s: 'a', n: 2 },
+ { s: 'a', n: 1 },
+ { s: 'b', n: 4 },
+ { s: 'b', n: 3 },
+ ]);
+
+ const sortingColumns3: EuiDataGridSorting['columns'] = [
+ { id: 'n', direction: 'desc' },
+ { id: 's', direction: 'desc' },
+ ];
+ const multiColumnSort3 = multiColumnSortFactory(sortingColumns3);
+ data.sort(multiColumnSort3);
+
+ expect(data).toStrictEqual([
+ { s: 'b', n: 4 },
+ { s: 'b', n: 3 },
+ { s: 'a', n: 2 },
+ { s: 'a', n: 1 },
+ ]);
+ });
+
test('getPivotDropdownOptions()', () => {
// The field name includes the characters []> as well as a leading and ending space charcter
// which cannot be used for aggregation names. The test results verifies that the characters
diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/common.ts b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/common.ts
index b4b03c1f0d571..7b78d4ffccfa1 100644
--- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/common.ts
+++ b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/common.ts
@@ -4,12 +4,14 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { get } from 'lodash';
-import { EuiComboBoxOptionProps } from '@elastic/eui';
+import { EuiComboBoxOptionProps, EuiDataGridSorting } from '@elastic/eui';
import {
IndexPattern,
KBN_FIELD_TYPES,
} from '../../../../../../../../../../src/plugins/data/public';
+import { getNestedProperty } from '../../../../../../common/utils/object_utils';
+
import {
PreviewRequestBody,
DropDownLabel,
@@ -28,6 +30,51 @@ export interface Field {
type: KBN_FIELD_TYPES;
}
+/**
+ * Helper to sort an array of objects based on an EuiDataGrid sorting configuration.
+ * `sortFn()` is recursive to support sorting on multiple columns.
+ *
+ * @param sortingColumns - The EUI data grid sorting configuration
+ * @returns The sorting function which can be used with an array's sort() function.
+ */
+export const multiColumnSortFactory = (sortingColumns: EuiDataGridSorting['columns']) => {
+ const isString = (arg: any): arg is string => {
+ return typeof arg === 'string';
+ };
+
+ const sortFn = (a: any, b: any, sortingColumnIndex = 0): number => {
+ const sort = sortingColumns[sortingColumnIndex];
+ const aValue = getNestedProperty(a, sort.id, null);
+ const bValue = getNestedProperty(b, sort.id, null);
+
+ if (typeof aValue === 'number' && typeof bValue === 'number') {
+ if (aValue < bValue) {
+ return sort.direction === 'asc' ? -1 : 1;
+ }
+ if (aValue > bValue) {
+ return sort.direction === 'asc' ? 1 : -1;
+ }
+ }
+
+ if (isString(aValue) && isString(bValue)) {
+ if (aValue.localeCompare(bValue) === -1) {
+ return sort.direction === 'asc' ? -1 : 1;
+ }
+ if (aValue.localeCompare(bValue) === 1) {
+ return sort.direction === 'asc' ? 1 : -1;
+ }
+ }
+
+ if (sortingColumnIndex + 1 < sortingColumns.length) {
+ return sortFn(a, b, sortingColumnIndex + 1);
+ }
+
+ return 0;
+ };
+
+ return sortFn;
+};
+
function getDefaultGroupByConfig(
aggName: string,
dropDownName: string,
diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/pivot_preview.tsx b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/pivot_preview.tsx
index 241f65614cea2..b755956eae24e 100644
--- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/pivot_preview.tsx
+++ b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/pivot_preview.tsx
@@ -4,8 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import React, { FC, useEffect, useRef, useState } from 'react';
-import moment from 'moment-timezone';
+import React, { FC, useCallback, useEffect, useMemo, useState } from 'react';
import { i18n } from '@kbn/i18n';
@@ -14,26 +13,23 @@ import {
EuiCallOut,
EuiCodeBlock,
EuiCopy,
+ EuiDataGrid,
+ EuiDataGridSorting,
EuiFlexGroup,
EuiFlexItem,
- EuiPanel,
EuiProgress,
EuiTitle,
} from '@elastic/eui';
-import {
- ColumnType,
- mlInMemoryTableBasicFactory,
- SORT_DIRECTION,
-} from '../../../../../shared_imports';
import { dictionaryToArray } from '../../../../../../common/types/common';
-import { ES_FIELD_TYPES } from '../../../../../../../../../../src/plugins/data/public';
-import { formatHumanReadableDateTimeSeconds } from '../../../../../../common/utils/date_utils';
+import { getNestedProperty } from '../../../../../../common/utils/object_utils';
import { useCurrentIndexPattern } from '../../../../lib/kibana';
import {
- getFlattenedFields,
+ euiDataGridStyle,
+ euiDataGridToolbarSettings,
+ EsFieldName,
PreviewRequestBody,
PivotAggsConfigDict,
PivotGroupByConfig,
@@ -41,8 +37,8 @@ import {
PivotQuery,
} from '../../../../common';
-import { getPivotPreviewDevConsoleStatement } from './common';
-import { PreviewItem, PIVOT_PREVIEW_STATUS, usePivotPreviewData } from './use_pivot_preview_data';
+import { getPivotPreviewDevConsoleStatement, multiColumnSortFactory } from './common';
+import { PIVOT_PREVIEW_STATUS, usePivotPreviewData } from './use_pivot_preview_data';
function sortColumns(groupByArr: PivotGroupByConfig[]) {
return (a: string, b: string) => {
@@ -60,14 +56,6 @@ function sortColumns(groupByArr: PivotGroupByConfig[]) {
};
}
-function usePrevious(value: any) {
- const ref = useRef(null);
- useEffect(() => {
- ref.current = value;
- });
- return ref.current;
-}
-
interface PreviewTitleProps {
previewRequest: PreviewRequestBody;
}
@@ -118,51 +106,103 @@ interface PivotPreviewProps {
query: PivotQuery;
}
-export const PivotPreview: FC = React.memo(({ aggs, groupBy, query }) => {
- const [clearTable, setClearTable] = useState(false);
+const defaultPagination = { pageIndex: 0, pageSize: 5 };
+export const PivotPreview: FC = React.memo(({ aggs, groupBy, query }) => {
const indexPattern = useCurrentIndexPattern();
const {
- previewData,
+ previewData: data,
previewMappings,
errorMessage,
previewRequest,
status,
} = usePivotPreviewData(indexPattern, query, aggs, groupBy);
-
const groupByArr = dictionaryToArray(groupBy);
- // EuiInMemoryTable has an issue with dynamic sortable columns
- // and will trigger a full page Kibana error in such a case.
- // The following is a workaround until this is solved upstream:
- // - If the sortable/columns config changes,
- // the table will be unmounted/not rendered.
- // This is what the useEffect() part does.
- // - After that the table gets re-enabled. To make sure React
- // doesn't consolidate the state updates, setTimeout is used.
- const firstColumnName =
- previewData.length > 0
- ? Object.keys(previewData[0]).sort(sortColumns(groupByArr))[0]
- : undefined;
-
- const firstColumnNameChanged = usePrevious(firstColumnName) !== firstColumnName;
+ // Filters mapping properties of type `object`, which get returned for nested field parents.
+ const columnKeys = Object.keys(previewMappings.properties).filter(
+ key => previewMappings.properties[key].type !== 'object'
+ );
+ columnKeys.sort(sortColumns(groupByArr));
+
+ // Column visibility
+ const [visibleColumns, setVisibleColumns] = useState(columnKeys);
+
useEffect(() => {
- if (firstColumnNameChanged) {
- setClearTable(true);
- }
- if (clearTable) {
- setTimeout(() => setClearTable(false), 0);
- }
- }, [firstColumnNameChanged, clearTable]);
+ setVisibleColumns(columnKeys);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [JSON.stringify(columnKeys)]);
- if (firstColumnNameChanged) {
- return null;
+ const [pagination, setPagination] = useState(defaultPagination);
+
+ // Reset pagination if data changes. This is to avoid ending up with an empty table
+ // when for example the user selected a page that is not available with the updated data.
+ useEffect(() => {
+ setPagination(defaultPagination);
+ }, [data.length]);
+
+ // EuiDataGrid State
+ const dataGridColumns = columnKeys.map(id => ({ id }));
+
+ const onChangeItemsPerPage = useCallback(
+ pageSize => {
+ setPagination(p => {
+ const pageIndex = Math.floor((p.pageSize * p.pageIndex) / pageSize);
+ return { pageIndex, pageSize };
+ });
+ },
+ [setPagination]
+ );
+
+ const onChangePage = useCallback(pageIndex => setPagination(p => ({ ...p, pageIndex })), [
+ setPagination,
+ ]);
+
+ // Sorting config
+ const [sortingColumns, setSortingColumns] = useState([]);
+ const onSort = useCallback(sc => setSortingColumns(sc), [setSortingColumns]);
+
+ if (sortingColumns.length > 0) {
+ data.sort(multiColumnSortFactory(sortingColumns));
}
+ const pageData = data.slice(
+ pagination.pageIndex * pagination.pageSize,
+ (pagination.pageIndex + 1) * pagination.pageSize
+ );
+
+ const renderCellValue = useMemo(() => {
+ return ({
+ rowIndex,
+ columnId,
+ setCellProps,
+ }: {
+ rowIndex: number;
+ columnId: string;
+ setCellProps: any;
+ }) => {
+ const adjustedRowIndex = rowIndex - pagination.pageIndex * pagination.pageSize;
+
+ const cellValue = pageData.hasOwnProperty(adjustedRowIndex)
+ ? getNestedProperty(pageData[adjustedRowIndex], columnId, null)
+ : null;
+
+ if (typeof cellValue === 'object' && cellValue !== null) {
+ return JSON.stringify(cellValue);
+ }
+
+ if (cellValue === undefined) {
+ return null;
+ }
+
+ return cellValue;
+ };
+ }, [pageData, pagination.pageIndex, pagination.pageSize]);
+
if (status === PIVOT_PREVIEW_STATUS.ERROR) {
return (
-
+
= React.memo(({ aggs, groupBy,
>
-
+
);
}
- if (previewData.length === 0) {
+ if (data.length === 0) {
let noDataMessage = i18n.translate(
'xpack.transform.pivotPreview.PivotPreviewNoDataCalloutBody',
{
@@ -196,7 +236,7 @@ export const PivotPreview: FC = React.memo(({ aggs, groupBy,
);
}
return (
-
+
= React.memo(({ aggs, groupBy,
>
{noDataMessage}
-
+
);
}
- const columnKeys = getFlattenedFields(previewData[0]);
- columnKeys.sort(sortColumns(groupByArr));
-
- const columns = columnKeys.map(k => {
- const column: ColumnType = {
- field: k,
- name: k,
- sortable: true,
- truncateText: true,
- };
- if (typeof previewMappings.properties[k] !== 'undefined') {
- const esFieldType = previewMappings.properties[k].type;
- switch (esFieldType) {
- case ES_FIELD_TYPES.BOOLEAN:
- column.dataType = 'boolean';
- break;
- case ES_FIELD_TYPES.DATE:
- column.align = 'right';
- column.render = (d: any) => formatHumanReadableDateTimeSeconds(moment(d).unix() * 1000);
- break;
- case ES_FIELD_TYPES.BYTE:
- case ES_FIELD_TYPES.DOUBLE:
- case ES_FIELD_TYPES.FLOAT:
- case ES_FIELD_TYPES.HALF_FLOAT:
- case ES_FIELD_TYPES.INTEGER:
- case ES_FIELD_TYPES.LONG:
- case ES_FIELD_TYPES.SCALED_FLOAT:
- case ES_FIELD_TYPES.SHORT:
- column.dataType = 'number';
- break;
- case ES_FIELD_TYPES.KEYWORD:
- case ES_FIELD_TYPES.TEXT:
- column.dataType = 'string';
- break;
- }
- }
- return column;
- });
-
- if (columns.length === 0) {
+ if (columnKeys.length === 0) {
return null;
}
- const sorting = {
- sort: {
- field: columns[0].field as string,
- direction: SORT_DIRECTION.ASC,
- },
- };
-
- const MlInMemoryTableBasic = mlInMemoryTableBasicFactory();
-
return (
-
+
- {status === PIVOT_PREVIEW_STATUS.LOADING &&
}
- {status !== PIVOT_PREVIEW_STATUS.LOADING && (
-
- )}
- {previewData.length > 0 && clearTable === false && columns.length > 0 && (
-
+ {status === PIVOT_PREVIEW_STATUS.LOADING && }
+ {status !== PIVOT_PREVIEW_STATUS.LOADING && (
+
+ )}
+
+ {dataGridColumns.length > 0 && data.length > 0 && (
+ ({
- 'data-test-subj': 'transformPivotPreviewRow',
- })}
- sorting={sorting}
/>
)}
-
+
);
});
diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.tsx b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.tsx
index bde832894632c..9b96e4b1ee758 100644
--- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.tsx
+++ b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.tsx
@@ -17,6 +17,7 @@ import {
EuiForm,
EuiFormHelpText,
EuiFormRow,
+ EuiHorizontalRule,
EuiLink,
EuiPanel,
// @ts-ignore
@@ -255,11 +256,6 @@ export const StepDefineForm: FC = React.memo(({ overrides = {}, onChange
const [searchQuery, setSearchQuery] = useState(defaults.searchQuery);
const [useKQL] = useState(true);
- const addToSearch = (newSearch: string) => {
- const currentDisplaySearch = searchString === defaultSearch ? emptySearch : searchString;
- setSearchString(`${currentDisplaySearch} ${newSearch}`.trim());
- };
-
const searchHandler = (d: Record) => {
const { filterQuery, queryString } = d;
const newSearch = queryString === emptySearch ? defaultSearch : queryString;
@@ -568,8 +564,8 @@ export const StepDefineForm: FC = React.memo(({ overrides = {}, onChange
const disabledQuery = numIndexFields > maxIndexFields;
return (
-
-
+
+
{kibanaContext.currentSavedSearch === undefined && typeof searchString === 'string' && (
@@ -906,9 +902,9 @@ export const StepDefineForm: FC = React.memo(({ overrides = {}, onChange
-
-
-
+
+
+
diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/wizard/_wizard.scss b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/wizard/_wizard.scss
index 7c43e34d5f1b4..b235e9ebf7c21 100644
--- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/wizard/_wizard.scss
+++ b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/wizard/_wizard.scss
@@ -1,13 +1,29 @@
+.transform__progress progress[value]::-webkit-progress-bar {
+ background-color: $euiColorGhost;
+}
+
.transform__steps {
.euiStep__content {
padding-right: 0px;
}
}
-/* This is an override to replicate the previous full-page-width of the transforms creation wizard
+.transform__stepDefineForm {
+ align-items: flex-start;
+}
+
+.transform__stepDefineFormLeftColumn {
+ min-width: 420px;
+ border-right: 1px solid $euiColorLightShade;
+}
+
+/*
+This is an override to replicate the previous full-page-width of the transforms creation wizard
when it was in use within the ML plugin. The Kibana management section limits a max-width to 1200px
which is a bit narrow for the two column layout of the transform wizard. We might revisit this for
-future versions to blend in more with the overall design of the Kibana management section. */
+future versions to blend in more with the overall design of the Kibana management section.
+The management section's navigation width is 192px + 24px right margin
+*/
.mgtPage__body--transformWizard {
- max-width: 100%;
+ max-width: calc(100% - 216px);
}
diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json
index 75db834a969d0..0504343e4dcc3 100644
--- a/x-pack/plugins/translations/translations/ja-JP.json
+++ b/x-pack/plugins/translations/translations/ja-JP.json
@@ -12386,17 +12386,8 @@
"xpack.transform.progress": "進捗",
"xpack.transform.sourceIndex": "ソースインデックス",
"xpack.transform.sourceIndexPreview.copyClipboardTooltip": "ソースインデックスプレビューの開発コンソールステートメントをクリップボードにコピーします。",
- "xpack.transform.sourceIndexPreview.fieldSelection": "{docFieldsCount, number} 件中 {selectedFieldsLength, number} 件の {docFieldsCount, plural, one {フィールド} other {フィールド}}を選択済み",
- "xpack.transform.sourceIndexPreview.rowCollapse": "縮小",
- "xpack.transform.sourceIndexPreview.rowExpand": "拡張",
- "xpack.transform.sourceIndexPreview.selectColumnsAriaLabel": "列を選択",
- "xpack.transform.sourceIndexPreview.selectFieldsPopoverTitle": "フィールドを選択",
- "xpack.transform.sourceIndexPreview.SourceIndexArrayBadgeContent": "配列",
- "xpack.transform.sourceIndexPreview.SourceIndexArrayToolTipContent": "この配列に基づく列の完全な内容は、展開された行に表示されます。",
"xpack.transform.sourceIndexPreview.SourceIndexNoDataCalloutBody": "ソースインデックスのクエリが結果を返しませんでした。インデックスにドキュメントが含まれていて、クエリ要件が妥当であることを確認してください。",
"xpack.transform.sourceIndexPreview.SourceIndexNoDataCalloutTitle": "ソースインデックスクエリの結果がありません",
- "xpack.transform.sourceIndexPreview.SourceIndexObjectBadgeContent": "オブジェクト",
- "xpack.transform.sourceIndexPreview.SourceIndexObjectToolTipContent": "このオブジェクトベースの列の完全な内容は、展開された行に表示されます。",
"xpack.transform.sourceIndexPreview.sourceIndexPatternError": "ソースインデックスデータの読み込み中にエラーが発生しました。",
"xpack.transform.sourceIndexPreview.sourceIndexPatternTitle": "ソースインデックス {indexPatternTitle}",
"xpack.transform.statsBar.batchTransformsLabel": "一斉",
diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json
index d67d2054a2da6..156b1d3d24153 100644
--- a/x-pack/plugins/translations/translations/zh-CN.json
+++ b/x-pack/plugins/translations/translations/zh-CN.json
@@ -12386,17 +12386,8 @@
"xpack.transform.progress": "进度",
"xpack.transform.sourceIndex": "源索引",
"xpack.transform.sourceIndexPreview.copyClipboardTooltip": "将源索引预览的开发控制台语句复制到剪贴板。",
- "xpack.transform.sourceIndexPreview.fieldSelection": "已选择 {selectedFieldsLength, number} 个{docFieldsCount, plural, one {字段} other {字段}},共 {docFieldsCount, number} 个",
- "xpack.transform.sourceIndexPreview.rowCollapse": "折叠",
- "xpack.transform.sourceIndexPreview.rowExpand": "展开",
- "xpack.transform.sourceIndexPreview.selectColumnsAriaLabel": "选择列",
- "xpack.transform.sourceIndexPreview.selectFieldsPopoverTitle": "选择字段",
- "xpack.transform.sourceIndexPreview.SourceIndexArrayBadgeContent": "数组",
- "xpack.transform.sourceIndexPreview.SourceIndexArrayToolTipContent": "此基于数组的列的完整内容在展开的行中。",
"xpack.transform.sourceIndexPreview.SourceIndexNoDataCalloutBody": "源索引的查询未返回结果。请确保索引包含文档且您的查询限制不过于严格。",
"xpack.transform.sourceIndexPreview.SourceIndexNoDataCalloutTitle": "源索引查询结果为空。",
- "xpack.transform.sourceIndexPreview.SourceIndexObjectBadgeContent": "对象",
- "xpack.transform.sourceIndexPreview.SourceIndexObjectToolTipContent": "此基于对象的列的完整内容在展开的行中。",
"xpack.transform.sourceIndexPreview.sourceIndexPatternError": "加载源索引数据时出错。",
"xpack.transform.sourceIndexPreview.sourceIndexPatternTitle": "源索引 {indexPatternTitle}",
"xpack.transform.statsBar.batchTransformsLabel": "批量",
diff --git a/x-pack/test/functional/apps/transform/creation_index_pattern.ts b/x-pack/test/functional/apps/transform/creation_index_pattern.ts
index 5b54bfdafdbdb..4d1300ffaad06 100644
--- a/x-pack/test/functional/apps/transform/creation_index_pattern.ts
+++ b/x-pack/test/functional/apps/transform/creation_index_pattern.ts
@@ -89,7 +89,7 @@ export default function({ getService }: FtrProviderContext) {
progress: '100',
},
sourcePreview: {
- columns: 6,
+ columns: 45,
rows: 5,
},
},
diff --git a/x-pack/test/functional/apps/transform/creation_saved_search.ts b/x-pack/test/functional/apps/transform/creation_saved_search.ts
index 2f5f60e1573c8..bf501c65bc79b 100644
--- a/x-pack/test/functional/apps/transform/creation_saved_search.ts
+++ b/x-pack/test/functional/apps/transform/creation_saved_search.ts
@@ -63,7 +63,7 @@ export default function({ getService }: FtrProviderContext) {
},
sourceIndex: 'farequote',
sourcePreview: {
- column: 3,
+ column: 2,
values: ['ASA'],
},
},
diff --git a/x-pack/test/functional/services/transform_ui/wizard.ts b/x-pack/test/functional/services/transform_ui/wizard.ts
index aca08f7083aa8..2d20f3617cf06 100644
--- a/x-pack/test/functional/services/transform_ui/wizard.ts
+++ b/x-pack/test/functional/services/transform_ui/wizard.ts
@@ -76,17 +76,17 @@ export function TransformWizardProvider({ getService }: FtrProviderContext) {
await testSubjects.existOrFail(selector);
},
- async parseEuiInMemoryTable(tableSubj: string) {
+ async parseEuiDataGrid(tableSubj: string) {
const table = await testSubjects.find(`~${tableSubj}`);
const $ = await table.parseDomContent();
const rows = [];
// For each row, get the content of each cell and
// add its values as an array to each row.
- for (const tr of $.findTestSubjects(`~${tableSubj}Row`).toArray()) {
+ for (const tr of $.findTestSubjects(`~dataGridRow`).toArray()) {
rows.push(
$(tr)
- .find('.euiTableCellContent')
+ .find('.euiDataGridRowCell__truncate')
.toArray()
.map(cell =>
$(cell)
@@ -99,14 +99,14 @@ export function TransformWizardProvider({ getService }: FtrProviderContext) {
return rows;
},
- async assertEuiInMemoryTableColumnValues(
+ async assertEuiDataGridColumnValues(
tableSubj: string,
column: number,
expectedColumnValues: string[]
) {
await retry.tryForTime(2000, async () => {
// get a 2D array of rows and cell values
- const rows = await this.parseEuiInMemoryTable(tableSubj);
+ const rows = await this.parseEuiDataGrid(tableSubj);
// reduce the rows data to an array of unique values in the specified column
const uniqueColumnValues = rows
@@ -119,7 +119,7 @@ export function TransformWizardProvider({ getService }: FtrProviderContext) {
// check if the returned unique value matches the supplied filter value
expect(uniqueColumnValues).to.eql(
expectedColumnValues,
- `Unique EuiInMemoryTable column values should be '${expectedColumnValues.join()}' (got ${uniqueColumnValues.join()})`
+ `Unique EuiDataGrid column values should be '${expectedColumnValues.join()}' (got ${uniqueColumnValues.join()})`
);
});
},
@@ -127,28 +127,28 @@ export function TransformWizardProvider({ getService }: FtrProviderContext) {
async assertSourceIndexPreview(columns: number, rows: number) {
await retry.tryForTime(2000, async () => {
// get a 2D array of rows and cell values
- const rowsData = await this.parseEuiInMemoryTable('transformSourceIndexPreview');
+ const rowsData = await this.parseEuiDataGrid('transformSourceIndexPreview');
expect(rowsData).to.length(
rows,
- `EuiInMemoryTable rows should be ${rows} (got ${rowsData.length})`
+ `EuiDataGrid rows should be ${rows} (got ${rowsData.length})`
);
rowsData.map((r, i) =>
expect(r).to.length(
columns,
- `EuiInMemoryTable row #${i + 1} column count should be ${columns} (got ${r.length})`
+ `EuiDataGrid row #${i + 1} column count should be ${columns} (got ${r.length})`
)
);
});
},
async assertSourceIndexPreviewColumnValues(column: number, values: string[]) {
- await this.assertEuiInMemoryTableColumnValues('transformSourceIndexPreview', column, values);
+ await this.assertEuiDataGridColumnValues('transformSourceIndexPreview', column, values);
},
async assertPivotPreviewColumnValues(column: number, values: string[]) {
- await this.assertEuiInMemoryTableColumnValues('transformPivotPreview', column, values);
+ await this.assertEuiDataGridColumnValues('transformPivotPreview', column, values);
},
async assertPivotPreviewLoaded() {
@@ -445,21 +445,25 @@ export function TransformWizardProvider({ getService }: FtrProviderContext) {
},
async assertStartButtonExists() {
- await testSubjects.existOrFail('transformWizardStartButton');
- expect(await testSubjects.isDisplayed('transformWizardStartButton')).to.eql(
- true,
- `Expected 'Start' button to be displayed`
- );
+ await retry.tryForTime(5000, async () => {
+ await testSubjects.existOrFail('transformWizardStartButton');
+ expect(await testSubjects.isDisplayed('transformWizardStartButton')).to.eql(
+ true,
+ `Expected 'Start' button to be displayed`
+ );
+ });
},
async assertStartButtonEnabled(expectedValue: boolean) {
- const isEnabled = await testSubjects.isEnabled('transformWizardStartButton');
- expect(isEnabled).to.eql(
- expectedValue,
- `Expected 'Start' button to be '${expectedValue ? 'enabled' : 'disabled'}' (got ${
- isEnabled ? 'enabled' : 'disabled'
- }')`
- );
+ await retry.tryForTime(5000, async () => {
+ const isEnabled = await testSubjects.isEnabled('transformWizardStartButton');
+ expect(isEnabled).to.eql(
+ expectedValue,
+ `Expected 'Start' button to be '${expectedValue ? 'enabled' : 'disabled'}' (got ${
+ isEnabled ? 'enabled' : 'disabled'
+ }')`
+ );
+ });
},
async assertManagementCardExists() {
@@ -492,17 +496,21 @@ export function TransformWizardProvider({ getService }: FtrProviderContext) {
async createTransform() {
await testSubjects.click('transformWizardCreateButton');
- await this.assertStartButtonExists();
- await this.assertStartButtonEnabled(true);
- await this.assertManagementCardExists();
- await this.assertCreateButtonEnabled(false);
+ await retry.tryForTime(5000, async () => {
+ await this.assertStartButtonExists();
+ await this.assertStartButtonEnabled(true);
+ await this.assertManagementCardExists();
+ await this.assertCreateButtonEnabled(false);
+ });
},
async startTransform() {
await testSubjects.click('transformWizardStartButton');
- await this.assertDiscoverCardExists();
- await this.assertStartButtonEnabled(false);
- await this.assertProgressbarExists();
+ await retry.tryForTime(5000, async () => {
+ await this.assertDiscoverCardExists();
+ await this.assertStartButtonEnabled(false);
+ await this.assertProgressbarExists();
+ });
},
};
}
From 39a431a24715d1cbdf0513fc8d3a04a5a96c4075 Mon Sep 17 00:00:00 2001
From: Marta Bondyra
Date: Tue, 3 Mar 2020 17:48:54 +0100
Subject: [PATCH 09/96] =?UTF-8?q?[Lens]=20Display=20field=20name=20instead?=
=?UTF-8?q?=20of=20'dragging'=20text=20when=20a=20fi=E2=80=A6=20(#59053)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../lens/public/drag_drop/drag_drop.test.tsx | 8 +++--
.../lens/public/drag_drop/drag_drop.tsx | 36 +++++++++++++++----
.../indexpattern_datasource/field_item.tsx | 1 +
3 files changed, 36 insertions(+), 9 deletions(-)
diff --git a/x-pack/legacy/plugins/lens/public/drag_drop/drag_drop.test.tsx b/x-pack/legacy/plugins/lens/public/drag_drop/drag_drop.test.tsx
index 17a6afa9b70ff..6bf629912f53c 100644
--- a/x-pack/legacy/plugins/lens/public/drag_drop/drag_drop.test.tsx
+++ b/x-pack/legacy/plugins/lens/public/drag_drop/drag_drop.test.tsx
@@ -14,7 +14,7 @@ jest.useFakeTimers();
describe('DragDrop', () => {
test('renders if nothing is being dragged', () => {
const component = render(
-
+
Hello!
);
@@ -50,7 +50,9 @@ describe('DragDrop', () => {
const component = mount(
- Hello!
+
+ Hello!
+
);
@@ -58,7 +60,7 @@ describe('DragDrop', () => {
jest.runAllTimers();
- expect(dataTransfer.setData).toBeCalledWith('text', 'dragging');
+ expect(dataTransfer.setData).toBeCalledWith('text', 'drag label');
expect(setDragging).toBeCalledWith(value);
});
diff --git a/x-pack/legacy/plugins/lens/public/drag_drop/drag_drop.tsx b/x-pack/legacy/plugins/lens/public/drag_drop/drag_drop.tsx
index 834e0a727ba48..72b0d58122405 100644
--- a/x-pack/legacy/plugins/lens/public/drag_drop/drag_drop.tsx
+++ b/x-pack/legacy/plugins/lens/public/drag_drop/drag_drop.tsx
@@ -17,9 +17,9 @@ type DroppableEvent = React.DragEvent;
export type DropHandler = (item: unknown) => void;
/**
- * The argument to the DragDrop component.
+ * The base props to the DragDrop component.
*/
-interface Props {
+interface BaseProps {
/**
* The CSS class(es) for the root element.
*/
@@ -49,17 +49,39 @@ interface Props {
*/
droppable?: boolean;
+ /**
+ * The optional test subject associated with this DOM element.
+ */
+ 'data-test-subj'?: string;
+}
+
+/**
+ * The props for a draggable instance of that component.
+ */
+interface DraggableProps extends BaseProps {
/**
* Indicates whether or not this component is draggable.
*/
- draggable?: boolean;
+ draggable: true;
+ /**
+ * The label, which should be attached to the drag event, and which will e.g.
+ * be used if the element will be dropped into a text field.
+ */
+ label: string;
+}
+/**
+ * The props for a non-draggable instance of that component.
+ */
+interface NonDraggableProps extends BaseProps {
/**
- * The optional test subject associated with this DOM element.
+ * Indicates whether or not this component is draggable.
*/
- 'data-test-subj'?: string;
+ draggable?: false;
}
+type Props = DraggableProps | NonDraggableProps;
+
/**
* A draggable / droppable item. Items can be both draggable and droppable at
* the same time.
@@ -86,7 +108,9 @@ export function DragDrop(props: Props) {
return;
}
- e.dataTransfer.setData('text', 'dragging');
+ // We only can reach the dragStart method if the element is draggable,
+ // so we know we have DraggableProps if we reach this code.
+ e.dataTransfer.setData('text', (props as DraggableProps).label);
// Chrome causes issues if you try to render from within a
// dragStart event, so we drop a setTimeout to avoid that.
diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/field_item.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_datasource/field_item.tsx
index 94d644e6590e1..b98f589bc5b98 100644
--- a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/field_item.tsx
+++ b/x-pack/legacy/plugins/lens/public/indexpattern_datasource/field_item.tsx
@@ -177,6 +177,7 @@ export function FieldItem(props: FieldItemProps) {
container={document.querySelector('.application') || undefined}
button={
Date: Tue, 3 Mar 2020 17:50:01 +0100
Subject: [PATCH 10/96] [Console] Fixes for console error handling and loading
of autocomplete (#58587)
* Fix console error handling when offline
In cases when the client cannot connect to server
the UI would get stuck in a loading state. We need to handle
that case explicitly to stop the progress spinner and report
the error correctly.
* Fix editor request cycle. Request should always complete
The bug was that the request could error in such a way that the
requestFail dispatch was not being called. Leaving the loading spinner
running and an unhelpful error message would appear.
Also partly fixed the loading of autocomplete data and cleaned up
a legacy import.
* Fixed loading of mappings in as they were updated from
settings modal.
* Fix the mappings update logic
TODO, this function needs to be revisited, but for now
it is convenient to have the Settings service passed in every
time so that the poller can be updated.
* Fix poll interval
* Address PR feedback
Rename variable (instance -> editorRegistry) and remove unused
file
Co-authored-by: Elastic Machine
---
.../legacy/console_editor/editor.test.tsx | 18 +--
.../editor/legacy/console_editor/editor.tsx | 6 +-
.../legacy/console_editor/editor_output.tsx | 5 +-
.../application/containers/settings.tsx | 19 +--
.../editor_context/editor_registry.ts | 2 +-
.../contexts/services_context.mock.ts | 43 +++++++
.../application/contexts/services_context.tsx | 2 +-
.../use_send_current_request_to_es.test.tsx | 108 ++++++++++++++++++
.../use_send_current_request_to_es.ts | 8 +-
.../public/application/stores/request.ts | 2 +-
.../console/public/lib/mappings/mappings.js | 27 ++---
.../console/public/services/history.mock.ts | 31 +++++
.../console/public/services/settings.mock.ts | 35 ++++++
.../console/public/services/storage.mock.ts | 32 ++++++
14 files changed, 292 insertions(+), 46 deletions(-)
create mode 100644 src/plugins/console/public/application/contexts/services_context.mock.ts
create mode 100644 src/plugins/console/public/application/hooks/use_send_current_request_to_es/use_send_current_request_to_es.test.tsx
create mode 100644 src/plugins/console/public/services/history.mock.ts
create mode 100644 src/plugins/console/public/services/settings.mock.ts
create mode 100644 src/plugins/console/public/services/storage.mock.ts
diff --git a/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor.test.tsx b/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor.test.tsx
index 306cdd396f4f8..3e188ce591e9a 100644
--- a/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor.test.tsx
+++ b/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor.test.tsx
@@ -25,7 +25,7 @@ import { I18nProvider } from '@kbn/i18n/react';
import { act } from 'react-dom/test-utils';
import * as sinon from 'sinon';
-import { notificationServiceMock } from '../../../../../../../../core/public/mocks';
+import { serviceContextMock } from '../../../../contexts/services_context.mock';
import { nextTick } from 'test_utils/enzyme_helpers';
import {
@@ -61,21 +61,7 @@ describe('Legacy (Ace) Console Editor Component Smoke Test', () => {
beforeEach(() => {
document.queryCommandSupported = sinon.fake(() => true);
- mockedAppContextValue = {
- elasticsearchUrl: 'test',
- services: {
- trackUiMetric: { count: () => {}, load: () => {} },
- settings: {} as any,
- storage: {} as any,
- history: {
- getSavedEditorState: () => ({} as any),
- updateCurrentState: jest.fn(),
- } as any,
- notifications: notificationServiceMock.createSetupContract(),
- objectStorageClient: {} as any,
- },
- docLinkVersion: 'NA',
- };
+ mockedAppContextValue = serviceContextMock.create();
});
afterEach(() => {
diff --git a/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor.tsx b/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor.tsx
index daf88e28c6440..170024c192e7f 100644
--- a/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor.tsx
+++ b/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor.tsx
@@ -65,7 +65,7 @@ const inputId = 'ConAppInputTextarea';
function EditorUI({ initialTextValue }: EditorProps) {
const {
- services: { history, notifications },
+ services: { history, notifications, settings: settingsService },
docLinkVersion,
elasticsearchUrl,
} = useServicesContext();
@@ -172,7 +172,7 @@ function EditorUI({ initialTextValue }: EditorProps) {
setInputEditor(editor);
setTextArea(editorRef.current!.querySelector('textarea'));
- mappings.retrieveAutoCompleteInfo();
+ mappings.retrieveAutoCompleteInfo(settingsService, settingsService.getAutocomplete());
const unsubscribeResizer = subscribeResizeChecker(editorRef.current!, editor);
setupAutosave();
@@ -182,7 +182,7 @@ function EditorUI({ initialTextValue }: EditorProps) {
mappings.clearSubscriptions();
window.removeEventListener('hashchange', onHashChange);
};
- }, [saveCurrentTextObject, initialTextValue, history, setInputEditor]);
+ }, [saveCurrentTextObject, initialTextValue, history, setInputEditor, settingsService]);
useEffect(() => {
const { current: editor } = editorInstanceRef;
diff --git a/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor_output.tsx b/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor_output.tsx
index a8f456d22e726..36d90bb6bff1a 100644
--- a/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor_output.tsx
+++ b/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor_output.tsx
@@ -30,7 +30,10 @@ import { createReadOnlyAceEditor, CustomAceEditor } from '../../../../models/leg
import { subscribeResizeChecker } from '../subscribe_console_resize_checker';
import { applyCurrentSettings } from './apply_editor_settings';
-function modeForContentType(contentType: string) {
+function modeForContentType(contentType?: string) {
+ if (!contentType) {
+ return 'ace/mode/text';
+ }
if (contentType.indexOf('application/json') >= 0) {
return 'ace/mode/json';
} else if (contentType.indexOf('application/yaml') >= 0) {
diff --git a/src/plugins/console/public/application/containers/settings.tsx b/src/plugins/console/public/application/containers/settings.tsx
index 795103a5c95bb..e34cfcac8096b 100644
--- a/src/plugins/console/public/application/containers/settings.tsx
+++ b/src/plugins/console/public/application/containers/settings.tsx
@@ -23,7 +23,7 @@ import { AutocompleteOptions, DevToolsSettingsModal } from '../components';
// @ts-ignore
import mappings from '../../lib/mappings/mappings';
import { useServicesContext, useEditorActionContext } from '../contexts';
-import { DevToolsSettings } from '../../services';
+import { DevToolsSettings, Settings as SettingsService } from '../../services';
const getAutocompleteDiff = (newSettings: DevToolsSettings, prevSettings: DevToolsSettings) => {
return Object.keys(newSettings.autocomplete).filter(key => {
@@ -32,11 +32,12 @@ const getAutocompleteDiff = (newSettings: DevToolsSettings, prevSettings: DevToo
});
};
-const refreshAutocompleteSettings = (selectedSettings: any) => {
- mappings.retrieveAutoCompleteInfo(selectedSettings);
+const refreshAutocompleteSettings = (settings: SettingsService, selectedSettings: any) => {
+ mappings.retrieveAutoCompleteInfo(settings, selectedSettings);
};
const fetchAutocompleteSettingsIfNeeded = (
+ settings: SettingsService,
newSettings: DevToolsSettings,
prevSettings: DevToolsSettings
) => {
@@ -60,10 +61,10 @@ const fetchAutocompleteSettingsIfNeeded = (
},
{}
);
- mappings.retrieveAutoCompleteInfo(changedSettings.autocomplete);
- } else if (isPollingChanged) {
+ mappings.retrieveAutoCompleteInfo(settings, changedSettings);
+ } else if (isPollingChanged && newSettings.polling) {
// If the user has turned polling on, then we'll fetch all selected autocomplete settings.
- mappings.retrieveAutoCompleteInfo();
+ mappings.retrieveAutoCompleteInfo(settings, settings.getAutocomplete());
}
}
};
@@ -81,7 +82,7 @@ export function Settings({ onClose }: Props) {
const onSaveSettings = (newSettings: DevToolsSettings) => {
const prevSettings = settings.toJSON();
- fetchAutocompleteSettingsIfNeeded(newSettings, prevSettings);
+ fetchAutocompleteSettingsIfNeeded(settings, newSettings, prevSettings);
// Update the new settings in localStorage
settings.updateSettings(newSettings);
@@ -98,7 +99,9 @@ export function Settings({ onClose }: Props) {
+ refreshAutocompleteSettings(settings, selectedSettings)
+ }
settings={settings.toJSON()}
/>
);
diff --git a/src/plugins/console/public/application/contexts/editor_context/editor_registry.ts b/src/plugins/console/public/application/contexts/editor_context/editor_registry.ts
index 64b0cddb4189b..9efd388ef0b9c 100644
--- a/src/plugins/console/public/application/contexts/editor_context/editor_registry.ts
+++ b/src/plugins/console/public/application/contexts/editor_context/editor_registry.ts
@@ -20,7 +20,7 @@
import { SenseEditor } from '../../models/sense_editor';
export class EditorRegistry {
- inputEditor: SenseEditor | undefined;
+ private inputEditor: SenseEditor | undefined;
setInputEditor(inputEditor: SenseEditor) {
this.inputEditor = inputEditor;
diff --git a/src/plugins/console/public/application/contexts/services_context.mock.ts b/src/plugins/console/public/application/contexts/services_context.mock.ts
new file mode 100644
index 0000000000000..ae8d15a890782
--- /dev/null
+++ b/src/plugins/console/public/application/contexts/services_context.mock.ts
@@ -0,0 +1,43 @@
+/*
+ * 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 { notificationServiceMock } from '../../../../../core/public/mocks';
+import { HistoryMock } from '../../services/history.mock';
+import { SettingsMock } from '../../services/settings.mock';
+import { StorageMock } from '../../services/storage.mock';
+
+import { ContextValue } from './services_context';
+
+export const serviceContextMock = {
+ create: (): ContextValue => {
+ const storage = new StorageMock({} as any, 'test');
+ (storage.keys as jest.Mock).mockImplementation(() => []);
+ return {
+ elasticsearchUrl: 'test',
+ services: {
+ trackUiMetric: { count: () => {}, load: () => {} },
+ storage,
+ settings: new SettingsMock(storage),
+ history: new HistoryMock(storage),
+ notifications: notificationServiceMock.createSetupContract(),
+ objectStorageClient: {} as any,
+ },
+ docLinkVersion: 'NA',
+ };
+ },
+};
diff --git a/src/plugins/console/public/application/contexts/services_context.tsx b/src/plugins/console/public/application/contexts/services_context.tsx
index 4393cab4adbc5..3d4ac3291c5ac 100644
--- a/src/plugins/console/public/application/contexts/services_context.tsx
+++ b/src/plugins/console/public/application/contexts/services_context.tsx
@@ -50,7 +50,7 @@ export function ServicesContextProvider({ children, value }: ContextProps) {
export const useServicesContext = () => {
const context = useContext(ServicesContext);
if (context === undefined) {
- throw new Error('useAppContext must be used inside the AppContextProvider.');
+ throw new Error('useServicesContext must be used inside the ServicesContextProvider.');
}
return context;
};
diff --git a/src/plugins/console/public/application/hooks/use_send_current_request_to_es/use_send_current_request_to_es.test.tsx b/src/plugins/console/public/application/hooks/use_send_current_request_to_es/use_send_current_request_to_es.test.tsx
new file mode 100644
index 0000000000000..8b5312ee84cd5
--- /dev/null
+++ b/src/plugins/console/public/application/hooks/use_send_current_request_to_es/use_send_current_request_to_es.test.tsx
@@ -0,0 +1,108 @@
+/*
+ * 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.
+ */
+
+jest.mock('./send_request_to_es', () => ({ sendRequestToES: jest.fn() }));
+jest.mock('../../contexts/editor_context/editor_registry', () => ({
+ instance: { getInputEditor: jest.fn() },
+}));
+jest.mock('./track', () => ({ track: jest.fn() }));
+jest.mock('../../contexts/request_context', () => ({ useRequestActionContext: jest.fn() }));
+
+import React from 'react';
+import { renderHook, act } from '@testing-library/react-hooks';
+
+import { ContextValue, ServicesContextProvider } from '../../contexts';
+import { serviceContextMock } from '../../contexts/services_context.mock';
+import { useRequestActionContext } from '../../contexts/request_context';
+import { instance as editorRegistry } from '../../contexts/editor_context/editor_registry';
+
+import { sendRequestToES } from './send_request_to_es';
+import { useSendCurrentRequestToES } from './use_send_current_request_to_es';
+
+describe('useSendCurrentRequestToES', () => {
+ let mockContextValue: ContextValue;
+ let dispatch: (...args: any[]) => void;
+ const contexts = ({ children }: { children?: any }) => (
+ {children}
+ );
+
+ beforeEach(() => {
+ mockContextValue = serviceContextMock.create();
+ dispatch = jest.fn();
+ (useRequestActionContext as jest.Mock).mockReturnValue(dispatch);
+ });
+
+ afterEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('calls send request to ES', async () => {
+ // Set up mocks
+ (mockContextValue.services.settings.toJSON as jest.Mock).mockReturnValue({});
+ // This request should succeed
+ (sendRequestToES as jest.Mock).mockResolvedValue([]);
+ (editorRegistry.getInputEditor as jest.Mock).mockImplementation(() => ({
+ getRequestsInRange: () => ['test'],
+ }));
+
+ const { result } = renderHook(() => useSendCurrentRequestToES(), { wrapper: contexts });
+ await act(() => result.current());
+ expect(sendRequestToES).toHaveBeenCalledWith({ requests: ['test'] });
+
+ // Second call should be the request success
+ const [, [requestSucceededCall]] = (dispatch as jest.Mock).mock.calls;
+ expect(requestSucceededCall).toEqual({ type: 'requestSuccess', payload: { data: [] } });
+ });
+
+ it('handles known errors', async () => {
+ // Set up mocks
+ (sendRequestToES as jest.Mock).mockRejectedValue({ response: 'nada' });
+ (editorRegistry.getInputEditor as jest.Mock).mockImplementation(() => ({
+ getRequestsInRange: () => ['test'],
+ }));
+
+ const { result } = renderHook(() => useSendCurrentRequestToES(), { wrapper: contexts });
+ await act(() => result.current());
+ // Second call should be the request failure
+ const [, [requestFailedCall]] = (dispatch as jest.Mock).mock.calls;
+
+ // The request must have concluded
+ expect(requestFailedCall).toEqual({ type: 'requestFail', payload: { response: 'nada' } });
+ });
+
+ it('handles unknown errors', async () => {
+ // Set up mocks
+ (sendRequestToES as jest.Mock).mockRejectedValue(NaN /* unexpected error value */);
+ (editorRegistry.getInputEditor as jest.Mock).mockImplementation(() => ({
+ getRequestsInRange: () => ['test'],
+ }));
+
+ const { result } = renderHook(() => useSendCurrentRequestToES(), { wrapper: contexts });
+ await act(() => result.current());
+ // Second call should be the request failure
+ const [, [requestFailedCall]] = (dispatch as jest.Mock).mock.calls;
+
+ // The request must have concluded
+ expect(requestFailedCall).toEqual({ type: 'requestFail', payload: undefined });
+ // It also notified the user
+ expect(mockContextValue.services.notifications.toasts.addError).toHaveBeenCalledWith(NaN, {
+ title: 'Unknown Request Error',
+ });
+ });
+});
diff --git a/src/plugins/console/public/application/hooks/use_send_current_request_to_es/use_send_current_request_to_es.ts b/src/plugins/console/public/application/hooks/use_send_current_request_to_es/use_send_current_request_to_es.ts
index ef5f63b39c0a7..548366c63aa05 100644
--- a/src/plugins/console/public/application/hooks/use_send_current_request_to_es/use_send_current_request_to_es.ts
+++ b/src/plugins/console/public/application/hooks/use_send_current_request_to_es/use_send_current_request_to_es.ts
@@ -64,7 +64,7 @@ export const useSendCurrentRequestToES = () => {
// or templates may have changed, so we'll need to update this data. Assume that if
// the user disables polling they're trying to optimize performance or otherwise
// preserve resources, so they won't want this request sent either.
- mappings.retrieveAutoCompleteInfo();
+ mappings.retrieveAutoCompleteInfo(settings, settings.getAutocomplete());
}
dispatch({
@@ -74,12 +74,16 @@ export const useSendCurrentRequestToES = () => {
},
});
} catch (e) {
- if (e.response?.contentType) {
+ if (e?.response) {
dispatch({
type: 'requestFail',
payload: e,
});
} else {
+ dispatch({
+ type: 'requestFail',
+ payload: undefined,
+ });
notifications.toasts.addError(e, {
title: i18n.translate('console.notification.unknownRequestErrorTitle', {
defaultMessage: 'Unknown Request Error',
diff --git a/src/plugins/console/public/application/stores/request.ts b/src/plugins/console/public/application/stores/request.ts
index fe43d9f0b74d4..f711330df3911 100644
--- a/src/plugins/console/public/application/stores/request.ts
+++ b/src/plugins/console/public/application/stores/request.ts
@@ -26,7 +26,7 @@ import { ESRequestResult } from '../hooks/use_send_current_request_to_es/send_re
export type Actions =
| { type: 'sendRequest'; payload: undefined }
| { type: 'requestSuccess'; payload: { data: ESRequestResult[] } }
- | { type: 'requestFail'; payload: ESRequestResult };
+ | { type: 'requestFail'; payload: ESRequestResult | undefined };
export interface Store {
requestInFlight: boolean;
diff --git a/src/plugins/console/public/lib/mappings/mappings.js b/src/plugins/console/public/lib/mappings/mappings.js
index e0db361f9b422..330147118d42c 100644
--- a/src/plugins/console/public/lib/mappings/mappings.js
+++ b/src/plugins/console/public/lib/mappings/mappings.js
@@ -17,8 +17,6 @@
* under the License.
*/
-import { legacyBackDoorToSettings } from '../../application';
-
const $ = require('jquery');
const _ = require('lodash');
const es = require('../es/es');
@@ -255,7 +253,6 @@ function clear() {
}
function retrieveSettings(settingsKey, settingsToRetrieve) {
- const currentSettings = legacyBackDoorToSettings().getAutocomplete();
const settingKeyToPathMap = {
fields: '_mapping',
indices: '_aliases',
@@ -263,16 +260,17 @@ function retrieveSettings(settingsKey, settingsToRetrieve) {
};
// Fetch autocomplete info if setting is set to true, and if user has made changes.
- if (currentSettings[settingsKey] && settingsToRetrieve[settingsKey]) {
+ if (settingsToRetrieve[settingsKey] === true) {
return es.send('GET', settingKeyToPathMap[settingsKey], null);
} else {
const settingsPromise = new $.Deferred();
- // If a user has saved settings, but a field remains checked and unchanged, no need to make changes
- if (currentSettings[settingsKey]) {
+ if (settingsToRetrieve[settingsKey] === false) {
+ // If the user doesn't want autocomplete suggestions, then clear any that exist
+ return settingsPromise.resolveWith(this, [[JSON.stringify({})]]);
+ } else {
+ // If the user doesn't want autocomplete suggestions, then clear any that exist
return settingsPromise.resolve();
}
- // If the user doesn't want autocomplete suggestions, then clear any that exist
- return settingsPromise.resolveWith(this, [[JSON.stringify({})]]);
}
}
@@ -293,9 +291,12 @@ function clearSubscriptions() {
}
}
-function retrieveAutoCompleteInfo(
- settingsToRetrieve = legacyBackDoorToSettings().getAutocomplete()
-) {
+/**
+ *
+ * @param settings Settings A way to retrieve the current settings
+ * @param settingsToRetrieve any
+ */
+function retrieveAutoCompleteInfo(settings, settingsToRetrieve) {
clearSubscriptions();
const mappingPromise = retrieveSettings('fields', settingsToRetrieve);
@@ -334,8 +335,8 @@ function retrieveAutoCompleteInfo(
pollTimeoutId = setTimeout(() => {
// This looks strange/inefficient, but it ensures correct behavior because we don't want to send
// a scheduled request if the user turns off polling.
- if (legacyBackDoorToSettings().getPolling()) {
- retrieveAutoCompleteInfo();
+ if (settings.getPolling()) {
+ retrieveAutoCompleteInfo(settings, settings.getAutocomplete());
}
}, POLL_INTERVAL);
});
diff --git a/src/plugins/console/public/services/history.mock.ts b/src/plugins/console/public/services/history.mock.ts
new file mode 100644
index 0000000000000..97937a121ebdc
--- /dev/null
+++ b/src/plugins/console/public/services/history.mock.ts
@@ -0,0 +1,31 @@
+/*
+ * 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';
+
+export class HistoryMock extends History {
+ addToHistory = jest.fn();
+ change = jest.fn();
+ clearHistory = jest.fn();
+ deleteLegacySavedEditorState = jest.fn();
+ getHistory = jest.fn();
+ getHistoryKeys = jest.fn();
+ getLegacySavedEditorState = jest.fn();
+ updateCurrentState = jest.fn();
+}
diff --git a/src/plugins/console/public/services/settings.mock.ts b/src/plugins/console/public/services/settings.mock.ts
new file mode 100644
index 0000000000000..bec26c1129619
--- /dev/null
+++ b/src/plugins/console/public/services/settings.mock.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 { Settings } from './settings';
+
+export class SettingsMock extends Settings {
+ getAutocomplete = jest.fn();
+ getFontSize = jest.fn();
+ getPolling = jest.fn();
+ getTripleQuotes = jest.fn();
+ getWrapMode = jest.fn();
+ setAutocomplete = jest.fn();
+ setFontSize = jest.fn();
+ setPolling = jest.fn();
+ setTripleQuotes = jest.fn();
+ setWrapMode = jest.fn();
+ toJSON = jest.fn();
+ updateSettings = jest.fn();
+}
diff --git a/src/plugins/console/public/services/storage.mock.ts b/src/plugins/console/public/services/storage.mock.ts
new file mode 100644
index 0000000000000..fd7cdcce93466
--- /dev/null
+++ b/src/plugins/console/public/services/storage.mock.ts
@@ -0,0 +1,32 @@
+/*
+ * 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 } from './storage';
+
+export class StorageMock extends Storage {
+ delete = jest.fn();
+ decode = jest.fn();
+ decodeKey = jest.fn();
+ encodeKey = jest.fn();
+ encode = jest.fn();
+ has = jest.fn();
+ keys = jest.fn();
+ get = jest.fn();
+ set = jest.fn();
+}
From 02f89f04cc7d1e757443288c611aed834107c197 Mon Sep 17 00:00:00 2001
From: MadameSheema
Date: Tue, 3 Mar 2020 17:51:35 +0100
Subject: [PATCH 11/96] [SIEM] Cypress preparation for Jenkins (#59013)
* updates events viewer test
* updates login tasks
* updates ml conditional links
* updates url state
* updates timeline screen
* updates timeline tasks
* updates test files
* adds jenkins needed files
* ignoring isAttached lines due to a known error in Cypress (https://github.com/cypress-io/cypress/issues/4408)
* updates loop script
* updates readme with new cypress command explanation
* removes skip
Co-authored-by: Elastic Machine
---
test/scripts/jenkins_siem_cypress.sh | 21 +
x-pack/legacy/plugins/siem/cypress/README.md | 24 +
.../cypress/integration/events_viewer.spec.ts | 2 +-
.../integration/ml_conditional_links.spec.ts | 44 +-
.../cypress/integration/url_state.spec.ts | 58 +-
.../plugins/siem/cypress/screens/timeline.ts | 7 +-
.../plugins/siem/cypress/tasks/common.ts | 8 +-
.../plugins/siem/cypress/tasks/date_picker.ts | 23 +-
.../plugins/siem/cypress/tasks/login.ts | 12 +-
.../plugins/siem/cypress/tasks/timeline.ts | 26 +-
x-pack/legacy/plugins/siem/package.json | 3 +-
.../siem/scripts/loop_cypress_tests.js | 83 +
x-pack/test/siem_cypress/config.ts | 47 +
.../es_archives/auditbeat/data.json.gz | Bin 0 -> 5130292 bytes
.../es_archives/auditbeat/mappings.json | 3577 +++++++++++++++++
.../es_archives/empty_kibana/data.json.gz | Bin 0 -> 225 bytes
.../es_archives/empty_kibana/mappings.json | 284 ++
.../siem_cypress/ftr_provider_context.d.ts | 11 +
x-pack/test/siem_cypress/runner.ts | 37 +
x-pack/test/siem_cypress/services.ts | 7 +
20 files changed, 4210 insertions(+), 64 deletions(-)
create mode 100644 test/scripts/jenkins_siem_cypress.sh
create mode 100644 x-pack/legacy/plugins/siem/scripts/loop_cypress_tests.js
create mode 100644 x-pack/test/siem_cypress/config.ts
create mode 100644 x-pack/test/siem_cypress/es_archives/auditbeat/data.json.gz
create mode 100644 x-pack/test/siem_cypress/es_archives/auditbeat/mappings.json
create mode 100644 x-pack/test/siem_cypress/es_archives/empty_kibana/data.json.gz
create mode 100644 x-pack/test/siem_cypress/es_archives/empty_kibana/mappings.json
create mode 100644 x-pack/test/siem_cypress/ftr_provider_context.d.ts
create mode 100644 x-pack/test/siem_cypress/runner.ts
create mode 100644 x-pack/test/siem_cypress/services.ts
diff --git a/test/scripts/jenkins_siem_cypress.sh b/test/scripts/jenkins_siem_cypress.sh
new file mode 100644
index 0000000000000..c7157e97b36cc
--- /dev/null
+++ b/test/scripts/jenkins_siem_cypress.sh
@@ -0,0 +1,21 @@
+#!/usr/bin/env bash
+
+source test/scripts/jenkins_test_setup.sh
+
+installDir="$PARENT_DIR/install/kibana"
+destDir="${installDir}-${CI_WORKER_NUMBER}"
+cp -R "$installDir" "$destDir"
+
+export KIBANA_INSTALL_DIR="$destDir"
+
+echo " -> Running SIEM cypress tests"
+cd "$XPACK_DIR"
+
+checks-reporter-with-killswitch "SIEM Cypress Tests" \
+ node scripts/functional_tests \
+ --debug --bail \
+ --kibana-install-dir "$KIBANA_INSTALL_DIR" \
+ --config test/siem_cypress/config.ts
+
+echo ""
+echo ""
diff --git a/x-pack/legacy/plugins/siem/cypress/README.md b/x-pack/legacy/plugins/siem/cypress/README.md
index a57fe0d361b8f..41137ce6d8a9d 100644
--- a/x-pack/legacy/plugins/siem/cypress/README.md
+++ b/x-pack/legacy/plugins/siem/cypress/README.md
@@ -210,6 +210,30 @@ cd x-pack/legacy/plugins/siem
CYPRESS_baseUrl=http://localhost:5601 CYPRESS_ELASTICSEARCH_USERNAME=elastic CYPRESS_ELASTICSEARCH_PASSWORD= yarn cypress:run
```
+## Running (Headless) Tests on the Command Line as a Jenkins execution
+
+To run (headless) tests as a Jenkins execution.
+
+1. First bootstrap kibana changes from the Kibana root directory:
+
+```sh
+yarn kbn bootstrap
+```
+
+2. Launch Cypress command line test runner:
+
+```sh
+cd x-pack/legacy/plugins/siem
+yarn cypress:run-as-ci
+```
+
+Note that with this type of execution you don't need to have running a kibana and elasticsearch instance. This is because
+ the command, as it would happen in the CI, will launch the instances. The elasticsearch instance will be fed with the data
+ placed in: `x-pack/test/siem_cypress/es_archives`.
+
+As in this case we want to mimic a CI execution we want to execute the tests with the same set of data, this is why
+in this case does not make sense to override Cypress environment variables.
+
## Reporting
When Cypress tests are run on the command line via `yarn cypress:run`,
diff --git a/x-pack/legacy/plugins/siem/cypress/integration/events_viewer.spec.ts b/x-pack/legacy/plugins/siem/cypress/integration/events_viewer.spec.ts
index 446db89ec09dc..aa463b01fd190 100644
--- a/x-pack/legacy/plugins/siem/cypress/integration/events_viewer.spec.ts
+++ b/x-pack/legacy/plugins/siem/cypress/integration/events_viewer.spec.ts
@@ -134,7 +134,7 @@ describe('Events Viewer', () => {
});
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
+ const filterInput = 'aa7ca589f1b8220002f2fc61c64cfbf1'; // this will never match real data
cy.get(HEADER_SUBTITLE)
.invoke('text')
.then(initialNumberOfEvents => {
diff --git a/x-pack/legacy/plugins/siem/cypress/integration/ml_conditional_links.spec.ts b/x-pack/legacy/plugins/siem/cypress/integration/ml_conditional_links.spec.ts
index b02ed1a5e4c94..328ff7582efd6 100644
--- a/x-pack/legacy/plugins/siem/cypress/integration/ml_conditional_links.spec.ts
+++ b/x-pack/legacy/plugins/siem/cypress/integration/ml_conditional_links.spec.ts
@@ -6,7 +6,7 @@
import { KQL_INPUT } from '../screens/siem_header';
-import { loginAndWaitForPage } from '../tasks/login';
+import { loginAndWaitForPageWithoutDateRange } from '../tasks/login';
import {
mlHostMultiHostKqlQuery,
@@ -26,7 +26,7 @@ import {
describe('ml conditional links', () => {
it('sets the KQL from a single IP with a value for the query', () => {
- loginAndWaitForPage(mlNetworkSingleIpKqlQuery);
+ loginAndWaitForPageWithoutDateRange(mlNetworkSingleIpKqlQuery);
cy.get(KQL_INPUT).should(
'have.attr',
'value',
@@ -35,7 +35,7 @@ describe('ml conditional links', () => {
});
it('sets the KQL from a multiple IPs with a null for the query', () => {
- loginAndWaitForPage(mlNetworkMultipleIpNullKqlQuery);
+ loginAndWaitForPageWithoutDateRange(mlNetworkMultipleIpNullKqlQuery);
cy.get(KQL_INPUT).should(
'have.attr',
'value',
@@ -44,7 +44,7 @@ describe('ml conditional links', () => {
});
it('sets the KQL from a multiple IPs with a value for the query', () => {
- loginAndWaitForPage(mlNetworkMultipleIpKqlQuery);
+ loginAndWaitForPageWithoutDateRange(mlNetworkMultipleIpKqlQuery);
cy.get(KQL_INPUT).should(
'have.attr',
'value',
@@ -53,7 +53,7 @@ describe('ml conditional links', () => {
});
it('sets the KQL from a $ip$ with a value for the query', () => {
- loginAndWaitForPage(mlNetworkKqlQuery);
+ loginAndWaitForPageWithoutDateRange(mlNetworkKqlQuery);
cy.get(KQL_INPUT).should(
'have.attr',
'value',
@@ -62,7 +62,7 @@ describe('ml conditional links', () => {
});
it('sets the KQL from a single host name with a value for query', () => {
- loginAndWaitForPage(mlHostSingleHostKqlQuery);
+ loginAndWaitForPageWithoutDateRange(mlHostSingleHostKqlQuery);
cy.get(KQL_INPUT).should(
'have.attr',
'value',
@@ -71,7 +71,7 @@ describe('ml conditional links', () => {
});
it('sets the KQL from a multiple host names with null for query', () => {
- loginAndWaitForPage(mlHostMultiHostNullKqlQuery);
+ loginAndWaitForPageWithoutDateRange(mlHostMultiHostNullKqlQuery);
cy.get(KQL_INPUT).should(
'have.attr',
'value',
@@ -80,7 +80,7 @@ describe('ml conditional links', () => {
});
it('sets the KQL from a multiple host names with a value for query', () => {
- loginAndWaitForPage(mlHostMultiHostKqlQuery);
+ loginAndWaitForPageWithoutDateRange(mlHostMultiHostKqlQuery);
cy.get(KQL_INPUT).should(
'have.attr',
'value',
@@ -89,7 +89,7 @@ describe('ml conditional links', () => {
});
it('sets the KQL from a undefined/null host name but with a value for query', () => {
- loginAndWaitForPage(mlHostVariableHostKqlQuery);
+ loginAndWaitForPageWithoutDateRange(mlHostVariableHostKqlQuery);
cy.get(KQL_INPUT).should(
'have.attr',
'value',
@@ -98,7 +98,7 @@ describe('ml conditional links', () => {
});
it('redirects from a single IP with a null for the query', () => {
- loginAndWaitForPage(mlNetworkSingleIpNullKqlQuery);
+ loginAndWaitForPageWithoutDateRange(mlNetworkSingleIpNullKqlQuery);
cy.url().should(
'include',
'/app/siem#/network/ip/127.0.0.1/source?timerange=(global:(linkTo:!(timeline),timerange:(from:1566990000000,kind:absolute,to:1567000799999)),timeline:(linkTo:!(global),timerange:(from:1566990000000,kind:absolute,to:1567000799999)))'
@@ -106,7 +106,7 @@ describe('ml conditional links', () => {
});
it('redirects from a single IP with a value for the query', () => {
- loginAndWaitForPage(mlNetworkSingleIpKqlQuery);
+ loginAndWaitForPageWithoutDateRange(mlNetworkSingleIpKqlQuery);
cy.url().should(
'include',
"/app/siem#/network/ip/127.0.0.1/source?query=(language:kuery,query:'(process.name:%20%22conhost.exe%22%20or%20process.name:%20%22sc.exe%22)')&timerange=(global:(linkTo:!(timeline),timerange:(from:1566990000000,kind:absolute,to:1567000799999)),timeline:(linkTo:!(global),timerange:(from:1566990000000,kind:absolute,to:1567000799999)))"
@@ -114,7 +114,7 @@ describe('ml conditional links', () => {
});
it('redirects from a multiple IPs with a null for the query', () => {
- loginAndWaitForPage(mlNetworkMultipleIpNullKqlQuery);
+ loginAndWaitForPageWithoutDateRange(mlNetworkMultipleIpNullKqlQuery);
cy.url().should(
'include',
"app/siem#/network/flows?query=(language:kuery,query:'((source.ip:%20%22127.0.0.1%22%20or%20destination.ip:%20%22127.0.0.1%22)%20or%20(source.ip:%20%22127.0.0.2%22%20or%20destination.ip:%20%22127.0.0.2%22))')&timerange=(global:(linkTo:!(timeline),timerange:(from:1566990000000,kind:absolute,to:1567000799999)),timeline:(linkTo:!(global),timerange:(from:1566990000000,kind:absolute,to:1567000799999))"
@@ -122,7 +122,7 @@ describe('ml conditional links', () => {
});
it('redirects from a multiple IPs with a value for the query', () => {
- loginAndWaitForPage(mlNetworkMultipleIpKqlQuery);
+ loginAndWaitForPageWithoutDateRange(mlNetworkMultipleIpKqlQuery);
cy.url().should(
'include',
"/app/siem#/network/flows?query=(language:kuery,query:'((source.ip:%20%22127.0.0.1%22%20or%20destination.ip:%20%22127.0.0.1%22)%20or%20(source.ip:%20%22127.0.0.2%22%20or%20destination.ip:%20%22127.0.0.2%22))%20and%20((process.name:%20%22conhost.exe%22%20or%20process.name:%20%22sc.exe%22))')&timerange=(global:(linkTo:!(timeline),timerange:(from:1566990000000,kind:absolute,to:1567000799999)),timeline:(linkTo:!(global),timerange:(from:1566990000000,kind:absolute,to:1567000799999)))"
@@ -130,7 +130,7 @@ describe('ml conditional links', () => {
});
it('redirects from a $ip$ with a null query', () => {
- loginAndWaitForPage(mlNetworkNullKqlQuery);
+ loginAndWaitForPageWithoutDateRange(mlNetworkNullKqlQuery);
cy.url().should(
'include',
'/app/siem#/network/flows?timerange=(global:(linkTo:!(timeline),timerange:(from:1566990000000,kind:absolute,to:1567000799999)),timeline:(linkTo:!(global),timerange:(from:1566990000000,kind:absolute,to:1567000799999)))'
@@ -138,7 +138,7 @@ describe('ml conditional links', () => {
});
it('redirects from a $ip$ with a value for the query', () => {
- loginAndWaitForPage(mlNetworkKqlQuery);
+ loginAndWaitForPageWithoutDateRange(mlNetworkKqlQuery);
cy.url().should(
'include',
"/app/siem#/network/flows?query=(language:kuery,query:'(process.name:%20%22conhost.exe%22%20or%20process.name:%20%22sc.exe%22)')&timerange=(global:(linkTo:!(timeline),timerange:(from:1566990000000,kind:absolute,to:1567000799999)),timeline:(linkTo:!(global),timerange:(from:1566990000000,kind:absolute,to:1567000799999)))"
@@ -146,7 +146,7 @@ describe('ml conditional links', () => {
});
it('redirects from a single host name with a null for the query', () => {
- loginAndWaitForPage(mlHostSingleHostNullKqlQuery);
+ loginAndWaitForPageWithoutDateRange(mlHostSingleHostNullKqlQuery);
cy.url().should(
'include',
'/app/siem#/hosts/siem-windows/anomalies?timerange=(global:(linkTo:!(timeline),timerange:(from:1559800800000,kind:absolute,to:1559887199999)),timeline:(linkTo:!(global),timerange:(from:1559800800000,kind:absolute,to:1559887199999)))'
@@ -154,7 +154,7 @@ describe('ml conditional links', () => {
});
it('redirects from a host name with a variable in the query', () => {
- loginAndWaitForPage(mlHostSingleHostKqlQueryVariable);
+ loginAndWaitForPageWithoutDateRange(mlHostSingleHostKqlQueryVariable);
cy.url().should(
'include',
'/app/siem#/hosts/siem-windows/anomalies?timerange=(global:(linkTo:!(timeline),timerange:(from:1559800800000,kind:absolute,to:1559887199999)),timeline:(linkTo:!(global),timerange:(from:1559800800000,kind:absolute,to:1559887199999)))'
@@ -162,7 +162,7 @@ describe('ml conditional links', () => {
});
it('redirects from a single host name with a value for query', () => {
- loginAndWaitForPage(mlHostSingleHostKqlQuery);
+ loginAndWaitForPageWithoutDateRange(mlHostSingleHostKqlQuery);
cy.url().should(
'include',
"/app/siem#/hosts/siem-windows/anomalies?query=(language:kuery,query:'(process.name:%20%22conhost.exe%22%20or%20process.name:%20%22sc.exe%22)')&timerange=(global:(linkTo:!(timeline),timerange:(from:1559800800000,kind:absolute,to:1559887199999)),timeline:(linkTo:!(global),timerange:(from:1559800800000,kind:absolute,to:1559887199999)))"
@@ -170,7 +170,7 @@ describe('ml conditional links', () => {
});
it('redirects from a multiple host names with null for query', () => {
- loginAndWaitForPage(mlHostMultiHostNullKqlQuery);
+ loginAndWaitForPageWithoutDateRange(mlHostMultiHostNullKqlQuery);
cy.url().should(
'include',
"/app/siem#/hosts/anomalies?query=(language:kuery,query:'(host.name:%20%22siem-windows%22%20or%20host.name:%20%22siem-suricata%22)')&timerange=(global:(linkTo:!(timeline),timerange:(from:1559800800000,kind:absolute,to:1559887199999)),timeline:(linkTo:!(global),timerange:(from:1559800800000,kind:absolute,to:1559887199999)))"
@@ -178,7 +178,7 @@ describe('ml conditional links', () => {
});
it('redirects from a multiple host names with a value for query', () => {
- loginAndWaitForPage(mlHostMultiHostKqlQuery);
+ loginAndWaitForPageWithoutDateRange(mlHostMultiHostKqlQuery);
cy.url().should(
'include',
"/app/siem#/hosts/anomalies?query=(language:kuery,query:'(host.name:%20%22siem-windows%22%20or%20host.name:%20%22siem-suricata%22)%20and%20((process.name:%20%22conhost.exe%22%20or%20process.name:%20%22sc.exe%22))')&timerange=(global:(linkTo:!(timeline),timerange:(from:1559800800000,kind:absolute,to:1559887199999)),timeline:(linkTo:!(global),timerange:(from:1559800800000,kind:absolute,to:1559887199999)))"
@@ -186,7 +186,7 @@ describe('ml conditional links', () => {
});
it('redirects from a undefined/null host name with a null for the KQL', () => {
- loginAndWaitForPage(mlHostVariableHostNullKqlQuery);
+ loginAndWaitForPageWithoutDateRange(mlHostVariableHostNullKqlQuery);
cy.url().should(
'include',
'/app/siem#/hosts/anomalies?timerange=(global:(linkTo:!(timeline),timerange:(from:1559800800000,kind:absolute,to:1559887199999)),timeline:(linkTo:!(global),timerange:(from:1559800800000,kind:absolute,to:1559887199999)))'
@@ -194,7 +194,7 @@ describe('ml conditional links', () => {
});
it('redirects from a undefined/null host name but with a value for query', () => {
- loginAndWaitForPage(mlHostVariableHostKqlQuery);
+ loginAndWaitForPageWithoutDateRange(mlHostVariableHostKqlQuery);
cy.url().should(
'include',
"/app/siem#/hosts/anomalies?query=(language:kuery,query:'(process.name:%20%22conhost.exe%22%20or%20process.name:%20%22sc.exe%22)')&timerange=(global:(linkTo:!(timeline),timerange:(from:1559800800000,kind:absolute,to:1559887199999)),timeline:(linkTo:!(global),timerange:(from:1559800800000,kind:absolute,to:1559887199999)))"
diff --git a/x-pack/legacy/plugins/siem/cypress/integration/url_state.spec.ts b/x-pack/legacy/plugins/siem/cypress/integration/url_state.spec.ts
index 11c0562eb3638..25e50194f543d 100644
--- a/x-pack/legacy/plugins/siem/cypress/integration/url_state.spec.ts
+++ b/x-pack/legacy/plugins/siem/cypress/integration/url_state.spec.ts
@@ -5,6 +5,7 @@
*/
import {
+ DATE_PICKER_APPLY_BUTTON_TIMELINE,
DATE_PICKER_END_DATE_POPOVER_BUTTON,
DATE_PICKER_END_DATE_POPOVER_BUTTON_TIMELINE,
DATE_PICKER_START_DATE_POPOVER_BUTTON,
@@ -15,7 +16,7 @@ import { ANOMALIES_TAB } from '../screens/hosts/main';
import { BREADCRUMBS, HOSTS, KQL_INPUT, NETWORK } from '../screens/siem_header';
import { SERVER_SIDE_EVENT_COUNT, TIMELINE_TITLE } from '../screens/timeline';
-import { loginAndWaitForPage } from '../tasks/login';
+import { loginAndWaitForPage, loginAndWaitForPageWithoutDateRange } from '../tasks/login';
import {
setStartDate,
setEndDate,
@@ -30,7 +31,12 @@ import { openAllHosts } from '../tasks/hosts/main';
import { waitForIpsTableToBeLoaded } from '../tasks/network/flows';
import { clearSearchBar, kqlSearch, navigateFromHeaderTo } from '../tasks/siem_header';
import { openTimeline } from '../tasks/siem_main';
-import { addNameToTimeline, executeTimelineKQL } from '../tasks/timeline';
+import {
+ addDescriptionToTimeline,
+ addNameToTimeline,
+ closeTimeline,
+ executeTimelineKQL,
+} from '../tasks/timeline';
import { HOSTS_PAGE } from '../urls/navigation';
import { ABSOLUTE_DATE_RANGE } from '../urls/state';
@@ -58,7 +64,7 @@ const ABSOLUTE_DATE = {
describe('url state', () => {
it('sets the global start and end dates from the url', () => {
- loginAndWaitForPage(ABSOLUTE_DATE_RANGE.url);
+ loginAndWaitForPageWithoutDateRange(ABSOLUTE_DATE_RANGE.url);
cy.get(DATE_PICKER_START_DATE_POPOVER_BUTTON).should(
'have.attr',
'title',
@@ -72,7 +78,7 @@ describe('url state', () => {
});
it('sets the url state when start and end date are set', () => {
- loginAndWaitForPage(ABSOLUTE_DATE_RANGE.url);
+ loginAndWaitForPageWithoutDateRange(ABSOLUTE_DATE_RANGE.url);
setStartDate(ABSOLUTE_DATE.newStartTimeTyped);
updateDates();
waitForIpsTableToBeLoaded();
@@ -88,7 +94,7 @@ describe('url state', () => {
});
it('sets the timeline start and end dates from the url when locked to global time', () => {
- loginAndWaitForPage(ABSOLUTE_DATE_RANGE.url);
+ loginAndWaitForPageWithoutDateRange(ABSOLUTE_DATE_RANGE.url);
openTimeline();
cy.get(DATE_PICKER_START_DATE_POPOVER_BUTTON_TIMELINE).should(
@@ -104,8 +110,7 @@ describe('url state', () => {
});
it('sets the timeline start and end dates independently of the global start and end dates when times are unlocked', () => {
- loginAndWaitForPage(ABSOLUTE_DATE_RANGE.urlUnlinked);
-
+ loginAndWaitForPageWithoutDateRange(ABSOLUTE_DATE_RANGE.urlUnlinked);
cy.get(DATE_PICKER_START_DATE_POPOVER_BUTTON).should(
'have.attr',
'title',
@@ -132,7 +137,7 @@ describe('url state', () => {
});
it('sets the url state when timeline/global date pickers are unlinked and timeline start and end date are set', () => {
- loginAndWaitForPage(ABSOLUTE_DATE_RANGE.urlUnlinked);
+ loginAndWaitForPageWithoutDateRange(ABSOLUTE_DATE_RANGE.urlUnlinked);
openTimeline();
setTimelineStartDate(ABSOLUTE_DATE.newStartTimeTyped);
updateTimelineDates();
@@ -148,24 +153,24 @@ describe('url state', () => {
});
it('sets kql on network page', () => {
- loginAndWaitForPage(ABSOLUTE_DATE_RANGE.urlKqlNetworkNetwork);
+ loginAndWaitForPageWithoutDateRange(ABSOLUTE_DATE_RANGE.urlKqlNetworkNetwork);
cy.get(KQL_INPUT).should('have.attr', 'value', 'source.ip: "10.142.0.9"');
});
it('sets kql on hosts page', () => {
- loginAndWaitForPage(ABSOLUTE_DATE_RANGE.urlKqlHostsHosts);
+ loginAndWaitForPageWithoutDateRange(ABSOLUTE_DATE_RANGE.urlKqlHostsHosts);
cy.get(KQL_INPUT).should('have.attr', 'value', 'source.ip: "10.142.0.9"');
});
it('sets the url state when kql is set', () => {
- loginAndWaitForPage(ABSOLUTE_DATE_RANGE.url);
+ loginAndWaitForPageWithoutDateRange(ABSOLUTE_DATE_RANGE.url);
kqlSearch('source.ip: "10.142.0.9" {enter}');
cy.url().should('include', `query=(language:kuery,query:'source.ip:%20%2210.142.0.9%22%20')`);
});
it('sets the url state when kql is set and check if href reflect this change', () => {
- loginAndWaitForPage(ABSOLUTE_DATE_RANGE.url);
+ loginAndWaitForPageWithoutDateRange(ABSOLUTE_DATE_RANGE.url);
kqlSearch('source.ip: "10.142.0.9" {enter}');
navigateFromHeaderTo(HOSTS);
@@ -177,7 +182,7 @@ describe('url state', () => {
});
it('sets KQL in host page and detail page and check if href match on breadcrumb, tabs and subTabs', () => {
- loginAndWaitForPage(ABSOLUTE_DATE_RANGE.urlHostNew);
+ loginAndWaitForPageWithoutDateRange(ABSOLUTE_DATE_RANGE.urlHostNew);
kqlSearch('host.name: "siem-kibana" {enter}');
openAllHosts();
waitForAllHostsToBeLoaded();
@@ -223,7 +228,7 @@ describe('url state', () => {
});
it('Do not clears kql when navigating to a new page', () => {
- loginAndWaitForPage(ABSOLUTE_DATE_RANGE.urlKqlHostsHosts);
+ loginAndWaitForPageWithoutDateRange(ABSOLUTE_DATE_RANGE.urlKqlHostsHosts);
navigateFromHeaderTo(NETWORK);
cy.get(KQL_INPUT).should('have.attr', 'value', 'source.ip: "10.142.0.9"');
@@ -241,12 +246,23 @@ describe('url state', () => {
cy.wrap(intCount).should('be.above', 0);
});
- const bestTimelineName = 'The Best Timeline';
- addNameToTimeline(bestTimelineName);
-
- cy.url().should('include', 'timeline=');
- cy.visit(
- `/app/siem#/timelines?timerange=(global:(linkTo:!(),timerange:(from:1565274377369,kind:absolute,to:1565360777369)),timeline:(linkTo:!(),timerange:(from:1565274377369,kind:absolute,to:1565360777369)))`
- ).then(() => cy.get(TIMELINE_TITLE).should('have.attr', 'value', bestTimelineName));
+ const timelineName = 'SIEM';
+ addNameToTimeline(timelineName);
+ addDescriptionToTimeline('This is the best timeline of the world');
+
+ cy.url({ timeout: 30000 }).should('match', /\w*-\w*-\w*-\w*-\w*/);
+ cy.url().then(url => {
+ const matched = url.match(/\w*-\w*-\w*-\w*-\w*/);
+ const newTimelineId = matched && matched.length > 0 ? matched[0] : 'null';
+ expect(matched).to.have.lengthOf(1);
+ closeTimeline();
+ cy.visit('/app/kibana');
+ cy.visit(`/app/siem#/overview?timeline\=(id:'${newTimelineId}',isOpen:!t)`);
+ cy.contains('a', 'SIEM');
+ cy.get(DATE_PICKER_APPLY_BUTTON_TIMELINE)
+ .invoke('text')
+ .should('not.equal', 'Updating');
+ cy.get(TIMELINE_TITLE).should('have.attr', 'value', timelineName);
+ });
});
});
diff --git a/x-pack/legacy/plugins/siem/cypress/screens/timeline.ts b/x-pack/legacy/plugins/siem/cypress/screens/timeline.ts
index 1640647b45427..5638b8d23e83a 100644
--- a/x-pack/legacy/plugins/siem/cypress/screens/timeline.ts
+++ b/x-pack/legacy/plugins/siem/cypress/screens/timeline.ts
@@ -24,8 +24,9 @@ export const TIMELINE_DATA_PROVIDERS = '[data-test-subj="dataProviders"]';
export const TIMELINE_DATA_PROVIDERS_EMPTY =
'[data-test-subj="dataProviders"] [data-test-subj="empty"]';
-export const TIMELINE_DROPPED_DATA_PROVIDERS =
- '[data-test-subj="dataProviders"] [data-test-subj="providerContainer"]';
+export const TIMELINE_DESCRIPTION = '[data-test-subj="timeline-description"]';
+
+export const TIMELINE_DROPPED_DATA_PROVIDERS = '[data-test-subj="providerContainer"]';
export const TIMELINE_FIELDS_BUTTON =
'[data-test-subj="timeline"] [data-test-subj="show-field-browser"]';
@@ -43,8 +44,6 @@ export const TIMELINE_TITLE = '[data-test-subj="timeline-title"]';
export const TIMESTAMP_HEADER_FIELD = '[data-test-subj="header-text-@timestamp"]';
-export const TIMELINE_TOGGLE_BUTTON = '[data-test-subj="flyoutOverlay"]';
-
export const TIMESTAMP_TOGGLE_FIELD = '[data-test-subj="toggle-field-@timestamp"]';
export const TOGGLE_TIMELINE_EXPAND_EVENT = '[data-test-subj="expand-event"]';
diff --git a/x-pack/legacy/plugins/siem/cypress/tasks/common.ts b/x-pack/legacy/plugins/siem/cypress/tasks/common.ts
index e02d3506b33bc..a99471d92828e 100644
--- a/x-pack/legacy/plugins/siem/cypress/tasks/common.ts
+++ b/x-pack/legacy/plugins/siem/cypress/tasks/common.ts
@@ -23,14 +23,14 @@ export const drag = (subject: JQuery) => {
clientY: subjectLocation.top,
force: true,
})
- .wait(1)
+ .wait(5)
.trigger('mousemove', {
button: primaryButton,
clientX: subjectLocation.left + dndSloppyClickDetectionThreshold,
clientY: subjectLocation.top,
force: true,
})
- .wait(1);
+ .wait(5);
};
/** Drags the subject being dragged on the specified drop target, but does not drop it */
@@ -44,7 +44,7 @@ export const dragWithoutDrop = (dropTarget: JQuery) => {
export const drop = (dropTarget: JQuery) => {
cy.wrap(dropTarget)
.trigger('mousemove', { button: primaryButton, force: true })
- .wait(1)
+ .wait(5)
.trigger('mouseup', { force: true })
- .wait(1);
+ .wait(5);
};
diff --git a/x-pack/legacy/plugins/siem/cypress/tasks/date_picker.ts b/x-pack/legacy/plugins/siem/cypress/tasks/date_picker.ts
index 9d79b73a52b08..0d778b737380b 100644
--- a/x-pack/legacy/plugins/siem/cypress/tasks/date_picker.ts
+++ b/x-pack/legacy/plugins/siem/cypress/tasks/date_picker.ts
@@ -46,7 +46,14 @@ export const setTimelineEndDate = (date: string) => {
.first()
.click({ force: true });
- cy.get(DATE_PICKER_ABSOLUTE_INPUT).type(`{selectall}{backspace}${date}{enter}`);
+ cy.get(DATE_PICKER_ABSOLUTE_INPUT).click({ force: true });
+ cy.get(DATE_PICKER_ABSOLUTE_INPUT).then($el => {
+ // @ts-ignore
+ if (Cypress.dom.isAttached($el)) {
+ cy.wrap($el).click({ force: true });
+ }
+ cy.wrap($el).type(`{selectall}{backspace}${date}{enter}`);
+ });
};
export const setTimelineStartDate = (date: string) => {
@@ -58,7 +65,14 @@ export const setTimelineStartDate = (date: string) => {
.first()
.click({ force: true });
- cy.get(DATE_PICKER_ABSOLUTE_INPUT).type(`{selectall}{backspace}${date}{enter}`);
+ cy.get(DATE_PICKER_ABSOLUTE_INPUT).click({ force: true });
+ cy.get(DATE_PICKER_ABSOLUTE_INPUT).then($el => {
+ // @ts-ignore
+ if (Cypress.dom.isAttached($el)) {
+ cy.wrap($el).click({ force: true });
+ }
+ cy.wrap($el).type(`{selectall}{backspace}${date}{enter}`);
+ });
};
export const updateDates = () => {
@@ -69,5 +83,8 @@ export const updateDates = () => {
};
export const updateTimelineDates = () => {
- cy.get(DATE_PICKER_APPLY_BUTTON_TIMELINE).click({ force: true });
+ cy.get(DATE_PICKER_APPLY_BUTTON_TIMELINE)
+ .click({ force: true })
+ .invoke('text')
+ .should('not.equal', 'Updating');
};
diff --git a/x-pack/legacy/plugins/siem/cypress/tasks/login.ts b/x-pack/legacy/plugins/siem/cypress/tasks/login.ts
index 1b982d56d79a4..883bdb2a4820a 100644
--- a/x-pack/legacy/plugins/siem/cypress/tasks/login.ts
+++ b/x-pack/legacy/plugins/siem/cypress/tasks/login.ts
@@ -120,10 +120,16 @@ const loginViaConfig = () => {
*/
export const loginAndWaitForPage = (url: string) => {
login();
-
- cy.visit(`${Cypress.config().baseUrl}${url}`);
-
cy.viewport('macbook-15');
+ cy.visit(
+ `${url}?timerange=(global:(linkTo:!(timeline),timerange:(from:1547914976217,fromStr:'2019-01-19T16:22:56.217Z',kind:relative,to:1579537385745,toStr:now)),timeline:(linkTo:!(global),timerange:(from:1547914976217,fromStr:'2019-01-19T16:22:56.217Z',kind:relative,to:1579537385745,toStr:now)))`
+ );
+ cy.contains('a', 'SIEM');
+};
+export const loginAndWaitForPageWithoutDateRange = (url: string) => {
+ login();
+ cy.viewport('macbook-15');
+ cy.visit(url);
cy.contains('a', 'SIEM');
};
diff --git a/x-pack/legacy/plugins/siem/cypress/tasks/timeline.ts b/x-pack/legacy/plugins/siem/cypress/tasks/timeline.ts
index c218d5153356b..7873a76bf99f1 100644
--- a/x-pack/legacy/plugins/siem/cypress/tasks/timeline.ts
+++ b/x-pack/legacy/plugins/siem/cypress/tasks/timeline.ts
@@ -4,6 +4,8 @@
* you may not use this file except in compliance with the Elastic License.
*/
+import { DATE_PICKER_APPLY_BUTTON_TIMELINE } from '../screens/date_picker';
+
import {
CLOSE_TIMELINE_BTN,
CREATE_NEW_TIMELINE,
@@ -12,6 +14,7 @@ import {
ID_TOGGLE_FIELD,
SEARCH_OR_FILTER_CONTAINER,
SERVER_SIDE_EVENT_COUNT,
+ TIMELINE_DESCRIPTION,
TIMELINE_FIELDS_BUTTON,
TIMELINE_INSPECT_BUTTON,
TIMELINE_SETTINGS_ICON,
@@ -24,14 +27,31 @@ import { drag, drop } from '../tasks/common';
export const hostExistsQuery = 'host.name: *';
+export const addDescriptionToTimeline = (description: string) => {
+ cy.get(TIMELINE_DESCRIPTION).type(`${description}{enter}`);
+ cy.get(DATE_PICKER_APPLY_BUTTON_TIMELINE)
+ .click()
+ .invoke('text')
+ .should('not.equal', 'Updating');
+};
+
+export const addNameToTimeline = (name: string) => {
+ cy.get(TIMELINE_TITLE).type(`${name}{enter}`);
+ cy.get(TIMELINE_TITLE).should('have.attr', 'value', name);
+};
+
export const checkIdToggleField = () => {
- cy.get(ID_TOGGLE_FIELD).should('not.exist');
+ cy.get(ID_HEADER_FIELD).should('not.exist');
cy.get(ID_TOGGLE_FIELD).check({
force: true,
});
};
+export const closeTimeline = () => {
+ cy.get(CLOSE_TIMELINE_BTN).click({ force: true });
+};
+
export const createNewTimeline = () => {
cy.get(TIMELINE_SETTINGS_ICON).click({ force: true });
cy.get(CREATE_NEW_TIMELINE).click();
@@ -86,7 +106,3 @@ export const dragAndDropIdToggleFieldToTimeline = () => {
drop(headersDropArea)
);
};
-
-export const addNameToTimeline = (name: string) => {
- cy.get(TIMELINE_TITLE).type(name);
-};
diff --git a/x-pack/legacy/plugins/siem/package.json b/x-pack/legacy/plugins/siem/package.json
index 558ac013e5963..ad4a6e86ffc88 100644
--- a/x-pack/legacy/plugins/siem/package.json
+++ b/x-pack/legacy/plugins/siem/package.json
@@ -8,7 +8,8 @@
"extract-mitre-attacks": "node scripts/extract_tactics_techniques_mitre.js & node ../../../../scripts/eslint ./public/pages/detection_engine/mitre/mitre_tactics_techniques.ts --fix",
"build-graphql-types": "node scripts/generate_types_from_graphql.js",
"cypress:open": "../../../node_modules/.bin/cypress open",
- "cypress:run": "../../../node_modules/.bin/cypress run --spec ./cypress/integration/**/*.spec.ts --reporter ../../../node_modules/cypress-multi-reporters --reporter-options configFile=./reporter_config.json; status=$?; ../../../node_modules/.bin/mochawesome-merge --reportDir ../../../../target/kibana-siem/cypress/results > ../../../../target/kibana-siem/cypress/results/output.json; ../../../../node_modules/.bin/marge ../../../../target/kibana-siem/cypress/results/output.json --reportDir ../../../../target/kibana-siem/cypress/results; mkdir -p ../../../../target/junit && cp ../../../../target/kibana-siem/cypress/results/*.xml ../../../../target/junit/ && exit $status;"
+ "cypress:run": "../../../node_modules/.bin/cypress run --spec ./cypress/integration/**/*.spec.ts --reporter ../../../node_modules/cypress-multi-reporters --reporter-options configFile=./reporter_config.json; status=$?; ../../../node_modules/.bin/mochawesome-merge --reportDir ../../../../target/kibana-siem/cypress/results > ../../../../target/kibana-siem/cypress/results/output.json; ../../../../node_modules/.bin/marge ../../../../target/kibana-siem/cypress/results/output.json --reportDir ../../../../target/kibana-siem/cypress/results; mkdir -p ../../../../target/junit && cp ../../../../target/kibana-siem/cypress/results/*.xml ../../../../target/junit/ && exit $status;",
+ "cypress:run-as-ci": "node ../../../../scripts/functional_tests --config ../../../test/siem_cypress/config.ts"
},
"devDependencies": {
"@types/lodash": "^4.14.110",
diff --git a/x-pack/legacy/plugins/siem/scripts/loop_cypress_tests.js b/x-pack/legacy/plugins/siem/scripts/loop_cypress_tests.js
new file mode 100644
index 0000000000000..3e2891df27907
--- /dev/null
+++ b/x-pack/legacy/plugins/siem/scripts/loop_cypress_tests.js
@@ -0,0 +1,83 @@
+/*
+ * 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.
+ */
+
+const fs = require('fs');
+const os = require('os');
+const process = require('process');
+const spawn = require('child_process').spawn;
+/* eslint-disable no-process-exit */
+const MUST_RUN_FROM_DIR = 'kibana';
+const OUTPUT_DIR = 'target';
+const OUTPUT_FILE = `${OUTPUT_DIR}/loop-cypress-tests.txt`;
+const createOutputDir = () => {
+ fs.mkdir(OUTPUT_DIR, { recursive: true }, err => {
+ if (err) throw err;
+ });
+};
+const showUsage = () => {
+ const scriptName = process.argv[1].slice(process.argv[1].lastIndexOf('/') + 1);
+ console.log(`\nUsage: ${scriptName} `, `\nExample: ${scriptName} 5`);
+};
+
+const exitIfIncorrectWorkingDir = () => {
+ if (!process.cwd().endsWith(`/${MUST_RUN_FROM_DIR}`)) {
+ console.error(
+ `\nERROR: This script must be run from the '${MUST_RUN_FROM_DIR}' directory, but it was ran from '${process.cwd()}' instead.`
+ );
+ showUsage();
+ process.exit(1);
+ }
+};
+const exitIfTimesToRunIsInvalid = timesToRun => {
+ if (!timesToRun > 0) {
+ console.error(
+ '\nERROR: You must specify a valid number of times to run the SIEM Cypress tests.'
+ );
+ showUsage();
+ process.exit(1);
+ }
+};
+const spawnChild = async () => {
+ const child = spawn('node', [
+ 'scripts/functional_tests',
+ '--config',
+ 'x-pack/test/siem_cypress/config.ts',
+ ]);
+ for await (const chunk of child.stdout) {
+ console.log(chunk.toString());
+ fs.appendFileSync(OUTPUT_FILE, chunk.toString());
+ }
+ for await (const chunk of child.stderr) {
+ console.log(chunk.toString());
+ fs.appendFileSync(OUTPUT_FILE, chunk.toString());
+ }
+ const exitCode = await new Promise(resolve => {
+ child.on('close', resolve);
+ });
+ return exitCode;
+};
+
+const runNTimes = async timesToRun => {
+ for (let i = 0; i < timesToRun; i++) {
+ const startingRun = `\n\n*** Starting test run ${i +
+ 1} of ${timesToRun} on host ${os.hostname()} at ${new Date()} ***\n\n`;
+ console.log(startingRun);
+ fs.appendFileSync(OUTPUT_FILE, startingRun);
+ const exitCode = await spawnChild();
+ const testRunCompleted = `\n\n*** Test run ${i +
+ 1} of ${timesToRun} on host ${os.hostname()} exited with code ${exitCode} at ${new Date()} ***`;
+ console.log(testRunCompleted);
+ fs.appendFileSync(OUTPUT_FILE, testRunCompleted);
+ }
+};
+
+const timesToRun = Number(process.argv[2]) || 0;
+exitIfIncorrectWorkingDir();
+exitIfTimesToRunIsInvalid(timesToRun);
+console.log(`\nCypress tests will be run ${timesToRun} times`);
+console.log(`\nTest output will be appended to '${OUTPUT_FILE}'`);
+createOutputDir();
+runNTimes(timesToRun);
diff --git a/x-pack/test/siem_cypress/config.ts b/x-pack/test/siem_cypress/config.ts
new file mode 100644
index 0000000000000..05c1e471e74a9
--- /dev/null
+++ b/x-pack/test/siem_cypress/config.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 { resolve } from 'path';
+
+import { FtrConfigProviderContext } from '@kbn/test/types/ftr';
+
+import { SiemCypressTestRunner } from './runner';
+
+export default async function({ readConfigFile }: FtrConfigProviderContext) {
+ const kibanaCommonTestsConfig = await readConfigFile(
+ require.resolve('../../../test/common/config.js')
+ );
+ const xpackFunctionalTestsConfig = await readConfigFile(
+ require.resolve('../functional/config.js')
+ );
+
+ return {
+ ...kibanaCommonTestsConfig.getAll(),
+
+ testRunner: SiemCypressTestRunner,
+
+ esArchiver: {
+ directory: resolve(__dirname, 'es_archives'),
+ },
+
+ esTestCluster: {
+ ...xpackFunctionalTestsConfig.get('esTestCluster'),
+ serverArgs: [
+ ...xpackFunctionalTestsConfig.get('esTestCluster.serverArgs'),
+ // define custom es server here
+ ],
+ },
+
+ kbnTestServer: {
+ ...xpackFunctionalTestsConfig.get('kbnTestServer'),
+ serverArgs: [
+ ...xpackFunctionalTestsConfig.get('kbnTestServer.serverArgs'),
+ '--csp.strict=false',
+ // define custom kibana server args here
+ ],
+ },
+ };
+}
diff --git a/x-pack/test/siem_cypress/es_archives/auditbeat/data.json.gz b/x-pack/test/siem_cypress/es_archives/auditbeat/data.json.gz
new file mode 100644
index 0000000000000000000000000000000000000000..f1a4baefa5adf3b84ba95f913a1dbee3bd19ecb0
GIT binary patch
literal 5130292
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