diff --git a/.ci/packer_cache_for_branch.sh b/.ci/packer_cache_for_branch.sh index 5b4a94be50fa2..ab0ab845b2dc3 100755 --- a/.ci/packer_cache_for_branch.sh +++ b/.ci/packer_cache_for_branch.sh @@ -18,7 +18,7 @@ node scripts/es snapshot --download-only; node scripts/es snapshot --license=oss --download-only; # download reporting browsers -(cd "x-pack" && yarn gulp prepare); +(cd "x-pack" && yarn gulp downloadChromium); # cache the chromedriver archive chromedriverDistVersion="$(node -e "console.log(require('chromedriver').version)")" diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.applyglobalfilteractioncontext.embeddable.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.applyglobalfilteractioncontext.embeddable.md new file mode 100644 index 0000000000000..027ae4209b77f --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.applyglobalfilteractioncontext.embeddable.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [ApplyGlobalFilterActionContext](./kibana-plugin-plugins-data-public.applyglobalfilteractioncontext.md) > [embeddable](./kibana-plugin-plugins-data-public.applyglobalfilteractioncontext.embeddable.md) + +## ApplyGlobalFilterActionContext.embeddable property + +Signature: + +```typescript +embeddable?: IEmbeddable; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.applyglobalfilteractioncontext.filters.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.applyglobalfilteractioncontext.filters.md new file mode 100644 index 0000000000000..6d1d20580fb19 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.applyglobalfilteractioncontext.filters.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [ApplyGlobalFilterActionContext](./kibana-plugin-plugins-data-public.applyglobalfilteractioncontext.md) > [filters](./kibana-plugin-plugins-data-public.applyglobalfilteractioncontext.filters.md) + +## ApplyGlobalFilterActionContext.filters property + +Signature: + +```typescript +filters: Filter[]; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.applyglobalfilteractioncontext.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.applyglobalfilteractioncontext.md new file mode 100644 index 0000000000000..62817cd0a1e33 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.applyglobalfilteractioncontext.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [ApplyGlobalFilterActionContext](./kibana-plugin-plugins-data-public.applyglobalfilteractioncontext.md) + +## ApplyGlobalFilterActionContext interface + +Signature: + +```typescript +export interface ApplyGlobalFilterActionContext +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [embeddable](./kibana-plugin-plugins-data-public.applyglobalfilteractioncontext.embeddable.md) | IEmbeddable | | +| [filters](./kibana-plugin-plugins-data-public.applyglobalfilteractioncontext.filters.md) | Filter[] | | +| [timeFieldName](./kibana-plugin-plugins-data-public.applyglobalfilteractioncontext.timefieldname.md) | string | | + diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.applyglobalfilteractioncontext.timefieldname.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.applyglobalfilteractioncontext.timefieldname.md new file mode 100644 index 0000000000000..a5cf58018ec65 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.applyglobalfilteractioncontext.timefieldname.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [ApplyGlobalFilterActionContext](./kibana-plugin-plugins-data-public.applyglobalfilteractioncontext.md) > [timeFieldName](./kibana-plugin-plugins-data-public.applyglobalfilteractioncontext.timefieldname.md) + +## ApplyGlobalFilterActionContext.timeFieldName property + +Signature: + +```typescript +timeFieldName?: string; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md index 4852ad15781c7..db41936f35cca 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md @@ -48,6 +48,7 @@ | Interface | Description | | --- | --- | | [AggParamOption](./kibana-plugin-plugins-data-public.aggparamoption.md) | | +| [ApplyGlobalFilterActionContext](./kibana-plugin-plugins-data-public.applyglobalfilteractioncontext.md) | | | [DataPublicPluginSetup](./kibana-plugin-plugins-data-public.datapublicpluginsetup.md) | | | [DataPublicPluginStart](./kibana-plugin-plugins-data-public.datapublicpluginstart.md) | | | [EsQueryConfig](./kibana-plugin-plugins-data-public.esqueryconfig.md) | | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.plugin.setup.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.plugin.setup.md index 7bae595e75ad0..a0c9b38792825 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.plugin.setup.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.plugin.setup.md @@ -7,14 +7,14 @@ Signature: ```typescript -setup(core: CoreSetup, { expressions, uiActions, usageCollection }: DataSetupDependencies): DataPublicPluginSetup; +setup(core: CoreSetup, { expressions, uiActions, usageCollection }: DataSetupDependencies): DataPublicPluginSetup; ``` ## Parameters | Parameter | Type | Description | | --- | --- | --- | -| core | CoreSetup | | +| core | CoreSetup<DataStartDependencies, DataPublicPluginStart> | | | { expressions, uiActions, usageCollection } | DataSetupDependencies | | Returns: diff --git a/examples/ui_actions_explorer/public/actions/actions.tsx b/examples/ui_actions_explorer/public/actions/actions.tsx index 4ef8d5bf4d9c6..6d83362e998bc 100644 --- a/examples/ui_actions_explorer/public/actions/actions.tsx +++ b/examples/ui_actions_explorer/public/actions/actions.tsx @@ -31,7 +31,7 @@ export const ACTION_VIEW_IN_MAPS = 'ACTION_VIEW_IN_MAPS'; export const ACTION_TRAVEL_GUIDE = 'ACTION_TRAVEL_GUIDE'; export const ACTION_CALL_PHONE_NUMBER = 'ACTION_CALL_PHONE_NUMBER'; export const ACTION_EDIT_USER = 'ACTION_EDIT_USER'; -export const ACTION_PHONE_USER = 'ACTION_PHONE_USER'; +export const ACTION_TRIGGER_PHONE_USER = 'ACTION_TRIGGER_PHONE_USER'; export const ACTION_SHOWCASE_PLUGGABILITY = 'ACTION_SHOWCASE_PLUGGABILITY'; export const showcasePluggability = createAction({ @@ -120,19 +120,13 @@ export interface UserContext { update: (user: User) => void; } -export const createPhoneUserAction = (getUiActionsApi: () => Promise) => - createAction({ - type: ACTION_PHONE_USER, +export const createTriggerPhoneTriggerAction = (getUiActionsApi: () => Promise) => + createAction({ + type: ACTION_TRIGGER_PHONE_USER, getDisplayName: () => 'Call phone number', + shouldAutoExecute: async () => true, isCompatible: async ({ user }) => user.phone !== undefined, execute: async ({ user }) => { - // One option - execute the more specific action directly. - // makePhoneCallAction.execute({ phone: user.phone }); - - // Another option - emit the trigger and automatically get *all* the actions attached - // to the phone number trigger. - // TODO: we need to figure out the best way to handle these nested actions however, since - // we don't want multiple context menu's to pop up. if (user.phone !== undefined) { (await getUiActionsApi()).executeTriggerActions(PHONE_TRIGGER, { phone: user.phone }); } diff --git a/examples/ui_actions_explorer/public/plugin.tsx b/examples/ui_actions_explorer/public/plugin.tsx index 670138b43b9c4..b28e5e7a9f692 100644 --- a/examples/ui_actions_explorer/public/plugin.tsx +++ b/examples/ui_actions_explorer/public/plugin.tsx @@ -23,7 +23,6 @@ import { PHONE_TRIGGER, USER_TRIGGER, COUNTRY_TRIGGER, - createPhoneUserAction, lookUpWeatherAction, viewInMapsAction, createEditUserAction, @@ -37,7 +36,8 @@ import { ACTION_CALL_PHONE_NUMBER, ACTION_TRAVEL_GUIDE, ACTION_VIEW_IN_MAPS, - ACTION_PHONE_USER, + ACTION_TRIGGER_PHONE_USER, + createTriggerPhoneTriggerAction, } from './actions/actions'; import { DeveloperExamplesSetup } from '../../developer_examples/public'; import image from './ui_actions.png'; @@ -64,7 +64,7 @@ declare module '../../../src/plugins/ui_actions/public' { [ACTION_CALL_PHONE_NUMBER]: PhoneContext; [ACTION_TRAVEL_GUIDE]: CountryContext; [ACTION_VIEW_IN_MAPS]: CountryContext; - [ACTION_PHONE_USER]: UserContext; + [ACTION_TRIGGER_PHONE_USER]: UserContext; } } @@ -84,7 +84,7 @@ export class UiActionsExplorerPlugin implements Plugin (await startServices)[1].uiActions) + createTriggerPhoneTriggerAction(async () => (await startServices)[1].uiActions) ); deps.uiActions.addTriggerAction( USER_TRIGGER, diff --git a/package.json b/package.json index 190eb6d7d94b4..53aa6b25f190b 100644 --- a/package.json +++ b/package.json @@ -141,9 +141,9 @@ "@kbn/babel-preset": "1.0.0", "@kbn/config-schema": "1.0.0", "@kbn/i18n": "1.0.0", - "@kbn/telemetry-tools": "1.0.0", "@kbn/interpreter": "1.0.0", "@kbn/pm": "1.0.0", + "@kbn/telemetry-tools": "1.0.0", "@kbn/test-subj-selector": "0.2.1", "@kbn/ui-framework": "1.0.0", "@kbn/ui-shared-deps": "1.0.0", @@ -345,6 +345,7 @@ "@types/hapi-auth-cookie": "^9.1.0", "@types/has-ansi": "^3.0.0", "@types/history": "^4.7.3", + "@types/hjson": "^2.4.2", "@types/hoek": "^4.1.3", "@types/inert": "^5.1.2", "@types/jest": "^25.2.3", diff --git a/renovate.json5 b/renovate.json5 index 5a807b4b090c1..1ba6dc0ff7e1b 100644 --- a/renovate.json5 +++ b/renovate.json5 @@ -426,6 +426,14 @@ '@types/history', ], }, + { + groupSlug: 'hjson', + groupName: 'hjson related packages', + packageNames: [ + 'hjson', + '@types/hjson', + ], + }, { groupSlug: 'inquirer', groupName: 'inquirer related packages', diff --git a/src/core/server/logging/__snapshots__/logging_system.test.ts.snap b/src/core/server/logging/__snapshots__/logging_system.test.ts.snap index 2add00457b2ed..cbe0e352a0f3a 100644 --- a/src/core/server/logging/__snapshots__/logging_system.test.ts.snap +++ b/src/core/server/logging/__snapshots__/logging_system.test.ts.snap @@ -15,56 +15,72 @@ exports[`appends records via multiple appenders.: file logs 2`] = ` exports[`asLoggerFactory() only allows to create new loggers. 1`] = ` Object { "@timestamp": "2012-01-31T18:33:22.011-05:00", - "context": "test.context", - "level": "TRACE", + "log": Object { + "level": "TRACE", + "logger": "test.context", + }, "message": "buffered trace message", - "pid": Any, + "process": Object { + "pid": Any, + }, } `; exports[`asLoggerFactory() only allows to create new loggers. 2`] = ` Object { "@timestamp": "2012-01-31T13:33:22.011-05:00", - "context": "test.context", - "level": "INFO", + "log": Object { + "level": "INFO", + "logger": "test.context", + }, "message": "buffered info message", - "meta": Object { - "some": "value", + "process": Object { + "pid": Any, }, - "pid": Any, + "some": "value", } `; exports[`asLoggerFactory() only allows to create new loggers. 3`] = ` Object { "@timestamp": "2012-01-31T08:33:22.011-05:00", - "context": "test.context", - "level": "FATAL", + "log": Object { + "level": "FATAL", + "logger": "test.context", + }, "message": "buffered fatal message", - "pid": Any, + "process": Object { + "pid": Any, + }, } `; exports[`flushes memory buffer logger and switches to real logger once config is provided: buffered messages 1`] = ` Object { "@timestamp": "2012-02-01T09:33:22.011-05:00", - "context": "test.context", - "level": "INFO", + "log": Object { + "level": "INFO", + "logger": "test.context", + }, "message": "buffered info message", - "meta": Object { - "some": "value", + "process": Object { + "pid": Any, }, - "pid": Any, + "some": "value", } `; exports[`flushes memory buffer logger and switches to real logger once config is provided: new messages 1`] = ` Object { "@timestamp": "2012-01-31T23:33:22.011-05:00", - "context": "test.context", - "level": "INFO", + "log": Object { + "level": "INFO", + "logger": "test.context", + }, "message": "some new info message", - "pid": Any, + "process": Object { + "pid": Any, + }, } `; diff --git a/src/core/server/logging/integration_tests/logging.test.ts b/src/core/server/logging/integration_tests/logging.test.ts index a80939a25ae65..841c1ce15af47 100644 --- a/src/core/server/logging/integration_tests/logging.test.ts +++ b/src/core/server/logging/integration_tests/logging.test.ts @@ -198,13 +198,17 @@ describe('logging service', () => { JSON.parse(jsonString) ); expect(firstCall).toMatchObject({ - level: 'DEBUG', - context: 'plugins.myplugin.debug_json', + log: { + level: 'DEBUG', + logger: 'plugins.myplugin.debug_json', + }, message: 'log1', }); expect(secondCall).toMatchObject({ - level: 'INFO', - context: 'plugins.myplugin.debug_json', + log: { + level: 'INFO', + logger: 'plugins.myplugin.debug_json', + }, message: 'log2', }); }); @@ -217,8 +221,10 @@ describe('logging service', () => { expect(mockConsoleLog).toHaveBeenCalledTimes(1); expect(JSON.parse(mockConsoleLog.mock.calls[0][0])).toMatchObject({ - level: 'INFO', - context: 'plugins.myplugin.info_json', + log: { + level: 'INFO', + logger: 'plugins.myplugin.info_json', + }, message: 'log2', }); }); @@ -259,14 +265,18 @@ describe('logging service', () => { const logs = mockConsoleLog.mock.calls.map(([jsonString]) => jsonString); expect(JSON.parse(logs[0])).toMatchObject({ - level: 'DEBUG', - context: 'plugins.myplugin.all', + log: { + level: 'DEBUG', + logger: 'plugins.myplugin.all', + }, message: 'log1', }); expect(logs[1]).toEqual('CUSTOM - PATTERN [plugins.myplugin.all][DEBUG] log1'); expect(JSON.parse(logs[2])).toMatchObject({ - level: 'INFO', - context: 'plugins.myplugin.all', + log: { + level: 'INFO', + logger: 'plugins.myplugin.all', + }, message: 'log2', }); expect(logs[3]).toEqual('CUSTOM - PATTERN [plugins.myplugin.all][INFO ] log2'); diff --git a/src/core/server/logging/layouts/__snapshots__/json_layout.test.ts.snap b/src/core/server/logging/layouts/__snapshots__/json_layout.test.ts.snap index 14c071b40ad7a..0e7ce8d0b2f3c 100644 --- a/src/core/server/logging/layouts/__snapshots__/json_layout.test.ts.snap +++ b/src/core/server/logging/layouts/__snapshots__/json_layout.test.ts.snap @@ -1,13 +1,13 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`\`format()\` correctly formats record. 1`] = `"{\\"@timestamp\\":\\"2012-02-01T09:30:22.011-05:00\\",\\"context\\":\\"context-1\\",\\"error\\":{\\"message\\":\\"Some error message\\",\\"name\\":\\"Some error name\\",\\"stack\\":\\"Some error stack\\"},\\"level\\":\\"FATAL\\",\\"message\\":\\"message-1\\",\\"pid\\":5355}"`; +exports[`\`format()\` correctly formats record. 1`] = `"{\\"@timestamp\\":\\"2012-02-01T09:30:22.011-05:00\\",\\"message\\":\\"message-1\\",\\"error\\":{\\"message\\":\\"Some error message\\",\\"type\\":\\"Some error name\\",\\"stack_trace\\":\\"Some error stack\\"},\\"log\\":{\\"level\\":\\"FATAL\\",\\"logger\\":\\"context-1\\"},\\"process\\":{\\"pid\\":5355}}"`; -exports[`\`format()\` correctly formats record. 2`] = `"{\\"@timestamp\\":\\"2012-02-01T09:30:22.011-05:00\\",\\"context\\":\\"context-2\\",\\"level\\":\\"ERROR\\",\\"message\\":\\"message-2\\",\\"pid\\":5355}"`; +exports[`\`format()\` correctly formats record. 2`] = `"{\\"@timestamp\\":\\"2012-02-01T09:30:22.011-05:00\\",\\"message\\":\\"message-2\\",\\"log\\":{\\"level\\":\\"ERROR\\",\\"logger\\":\\"context-2\\"},\\"process\\":{\\"pid\\":5355}}"`; -exports[`\`format()\` correctly formats record. 3`] = `"{\\"@timestamp\\":\\"2012-02-01T09:30:22.011-05:00\\",\\"context\\":\\"context-3\\",\\"level\\":\\"WARN\\",\\"message\\":\\"message-3\\",\\"pid\\":5355}"`; +exports[`\`format()\` correctly formats record. 3`] = `"{\\"@timestamp\\":\\"2012-02-01T09:30:22.011-05:00\\",\\"message\\":\\"message-3\\",\\"log\\":{\\"level\\":\\"WARN\\",\\"logger\\":\\"context-3\\"},\\"process\\":{\\"pid\\":5355}}"`; -exports[`\`format()\` correctly formats record. 4`] = `"{\\"@timestamp\\":\\"2012-02-01T09:30:22.011-05:00\\",\\"context\\":\\"context-4\\",\\"level\\":\\"DEBUG\\",\\"message\\":\\"message-4\\",\\"pid\\":5355}"`; +exports[`\`format()\` correctly formats record. 4`] = `"{\\"@timestamp\\":\\"2012-02-01T09:30:22.011-05:00\\",\\"message\\":\\"message-4\\",\\"log\\":{\\"level\\":\\"DEBUG\\",\\"logger\\":\\"context-4\\"},\\"process\\":{\\"pid\\":5355}}"`; -exports[`\`format()\` correctly formats record. 5`] = `"{\\"@timestamp\\":\\"2012-02-01T09:30:22.011-05:00\\",\\"context\\":\\"context-5\\",\\"level\\":\\"INFO\\",\\"message\\":\\"message-5\\",\\"pid\\":5355}"`; +exports[`\`format()\` correctly formats record. 5`] = `"{\\"@timestamp\\":\\"2012-02-01T09:30:22.011-05:00\\",\\"message\\":\\"message-5\\",\\"log\\":{\\"level\\":\\"INFO\\",\\"logger\\":\\"context-5\\"},\\"process\\":{\\"pid\\":5355}}"`; -exports[`\`format()\` correctly formats record. 6`] = `"{\\"@timestamp\\":\\"2012-02-01T09:30:22.011-05:00\\",\\"context\\":\\"context-6\\",\\"level\\":\\"TRACE\\",\\"message\\":\\"message-6\\",\\"pid\\":5355}"`; +exports[`\`format()\` correctly formats record. 6`] = `"{\\"@timestamp\\":\\"2012-02-01T09:30:22.011-05:00\\",\\"message\\":\\"message-6\\",\\"log\\":{\\"level\\":\\"TRACE\\",\\"logger\\":\\"context-6\\"},\\"process\\":{\\"pid\\":5355}}"`; diff --git a/src/core/server/logging/layouts/json_layout.test.ts b/src/core/server/logging/layouts/json_layout.test.ts index 77e2876c143da..6cda1e4806aa8 100644 --- a/src/core/server/logging/layouts/json_layout.test.ts +++ b/src/core/server/logging/layouts/json_layout.test.ts @@ -98,21 +98,27 @@ test('`format()` correctly formats record with meta-data', () => { timestamp, pid: 5355, meta: { - from: 'v7', - to: 'v8', + version: { + from: 'v7', + to: 'v8', + }, }, }) ) ).toStrictEqual({ '@timestamp': '2012-02-01T09:30:22.011-05:00', - context: 'context-with-meta', - level: 'DEBUG', + log: { + level: 'DEBUG', + logger: 'context-with-meta', + }, message: 'message-with-meta', - meta: { + version: { from: 'v7', to: 'v8', }, - pid: 5355, + process: { + pid: 5355, + }, }); }); @@ -122,36 +128,131 @@ test('`format()` correctly formats error record with meta-data', () => { expect( JSON.parse( layout.format({ - context: 'error-with-meta', level: LogLevel.Debug, + context: 'error-with-meta', error: { message: 'Some error message', - name: 'Some error name', + name: 'Some error type', stack: 'Some error stack', }, message: 'Some error message', timestamp, pid: 5355, meta: { - from: 'v7', - to: 'v8', + version: { + from: 'v7', + to: 'v8', + }, }, }) ) ).toStrictEqual({ '@timestamp': '2012-02-01T09:30:22.011-05:00', - context: 'error-with-meta', - level: 'DEBUG', + log: { + level: 'DEBUG', + logger: 'error-with-meta', + }, error: { message: 'Some error message', - name: 'Some error name', - stack: 'Some error stack', + type: 'Some error type', + stack_trace: 'Some error stack', }, message: 'Some error message', - meta: { + version: { from: 'v7', to: 'v8', }, - pid: 5355, + process: { + pid: 5355, + }, + }); +}); + +test('format() meta can override @timestamp', () => { + const layout = new JsonLayout(); + expect( + JSON.parse( + layout.format({ + message: 'foo', + timestamp, + level: LogLevel.Debug, + context: 'bar', + pid: 3, + meta: { + '@timestamp': '2099-05-01T09:30:22.011-05:00', + }, + }) + ) + ).toStrictEqual({ + '@timestamp': '2099-05-01T09:30:22.011-05:00', + message: 'foo', + log: { + level: 'DEBUG', + logger: 'bar', + }, + process: { + pid: 3, + }, + }); +}); + +test('format() meta can merge override logs', () => { + const layout = new JsonLayout(); + expect( + JSON.parse( + layout.format({ + timestamp, + message: 'foo', + level: LogLevel.Error, + context: 'bar', + pid: 3, + meta: { + log: { + kbn_custom_field: 'hello', + }, + }, + }) + ) + ).toStrictEqual({ + '@timestamp': '2012-02-01T09:30:22.011-05:00', + message: 'foo', + log: { + level: 'ERROR', + logger: 'bar', + kbn_custom_field: 'hello', + }, + process: { + pid: 3, + }, + }); +}); + +test('format() meta can override log level objects', () => { + const layout = new JsonLayout(); + expect( + JSON.parse( + layout.format({ + timestamp, + context: '123', + message: 'foo', + level: LogLevel.Error, + pid: 3, + meta: { + log: { + level: 'FATAL', + }, + }, + }) + ) + ).toStrictEqual({ + '@timestamp': '2012-02-01T09:30:22.011-05:00', + message: 'foo', + log: { + level: 'FATAL', + logger: '123', + }, + process: { + pid: 3, + }, }); }); diff --git a/src/core/server/logging/layouts/json_layout.ts b/src/core/server/logging/layouts/json_layout.ts index ad8c33d7cb023..04416184a5957 100644 --- a/src/core/server/logging/layouts/json_layout.ts +++ b/src/core/server/logging/layouts/json_layout.ts @@ -18,6 +18,7 @@ */ import moment from 'moment-timezone'; +import { merge } from 'lodash'; import { schema, TypeOf } from '@kbn/config-schema'; import { LogRecord } from '../log_record'; @@ -46,20 +47,28 @@ export class JsonLayout implements Layout { return { message: error.message, - name: error.name, - stack: error.stack, + type: error.name, + stack_trace: error.stack, }; } public format(record: LogRecord): string { - return JSON.stringify({ - '@timestamp': moment(record.timestamp).format('YYYY-MM-DDTHH:mm:ss.SSSZ'), - context: record.context, - error: JsonLayout.errorToSerializableObject(record.error), - level: record.level.id.toUpperCase(), - message: record.message, - meta: record.meta, - pid: record.pid, - }); + return JSON.stringify( + merge( + { + '@timestamp': moment(record.timestamp).format('YYYY-MM-DDTHH:mm:ss.SSSZ'), + message: record.message, + error: JsonLayout.errorToSerializableObject(record.error), + log: { + level: record.level.id.toUpperCase(), + logger: record.context, + }, + process: { + pid: record.pid, + }, + }, + record.meta + ) + ); } } diff --git a/src/core/server/logging/logging_system.test.ts b/src/core/server/logging/logging_system.test.ts index ac52973081106..afe58ddff92aa 100644 --- a/src/core/server/logging/logging_system.test.ts +++ b/src/core/server/logging/logging_system.test.ts @@ -23,7 +23,7 @@ jest.mock('fs', () => ({ createWriteStream: jest.fn(() => ({ write: mockStreamWrite })), })); -const dynamicProps = { pid: expect.any(Number) }; +const dynamicProps = { process: { pid: expect.any(Number) } }; jest.mock('../../../legacy/server/logging/rotate', () => ({ setupLoggingRotate: jest.fn().mockImplementation(() => Promise.resolve({})), @@ -61,8 +61,10 @@ test('uses default memory buffer logger until config is provided', () => { anotherLogger.fatal('fatal message', { some: 'value' }); expect(bufferAppendSpy).toHaveBeenCalledTimes(2); - expect(bufferAppendSpy.mock.calls[0][0]).toMatchSnapshot(dynamicProps); - expect(bufferAppendSpy.mock.calls[1][0]).toMatchSnapshot(dynamicProps); + + // pid at args level, nested under process for ECS writes + expect(bufferAppendSpy.mock.calls[0][0]).toMatchSnapshot({ pid: expect.any(Number) }); + expect(bufferAppendSpy.mock.calls[1][0]).toMatchSnapshot({ pid: expect.any(Number) }); }); test('flushes memory buffer logger and switches to real logger once config is provided', () => { @@ -210,20 +212,26 @@ test('setContextConfig() updates config with relative contexts', () => { expect(mockConsoleLog).toHaveBeenCalledTimes(4); // Parent contexts are unaffected expect(JSON.parse(mockConsoleLog.mock.calls[0][0])).toMatchObject({ - context: 'tests', message: 'tests log to default!', - level: 'WARN', + log: { + level: 'WARN', + logger: 'tests', + }, }); expect(JSON.parse(mockConsoleLog.mock.calls[1][0])).toMatchObject({ - context: 'tests.child', message: 'tests.child log to default!', - level: 'ERROR', + log: { + level: 'ERROR', + logger: 'tests.child', + }, }); // Customized context is logged in both appender formats expect(JSON.parse(mockConsoleLog.mock.calls[2][0])).toMatchObject({ - context: 'tests.child.grandchild', message: 'tests.child.grandchild log to default and custom!', - level: 'DEBUG', + log: { + level: 'DEBUG', + logger: 'tests.child.grandchild', + }, }); expect(mockConsoleLog.mock.calls[3][0]).toMatchInlineSnapshot( `"[DEBUG][tests.child.grandchild] tests.child.grandchild log to default and custom!"` @@ -259,9 +267,11 @@ test('setContextConfig() updates config for a root context', () => { expect(mockConsoleLog).toHaveBeenCalledTimes(3); // Parent context is unaffected expect(JSON.parse(mockConsoleLog.mock.calls[0][0])).toMatchObject({ - context: 'tests', message: 'tests log to default!', - level: 'WARN', + log: { + level: 'WARN', + logger: 'tests', + }, }); // Customized contexts expect(mockConsoleLog.mock.calls[1][0]).toMatchInlineSnapshot( @@ -299,9 +309,11 @@ test('custom context configs are applied on subsequent calls to update()', () => // Customized context is logged in both appender formats still expect(mockConsoleLog).toHaveBeenCalledTimes(2); expect(JSON.parse(mockConsoleLog.mock.calls[0][0])).toMatchObject({ - context: 'tests.child.grandchild', message: 'tests.child.grandchild log to default and custom!', - level: 'DEBUG', + log: { + level: 'DEBUG', + logger: 'tests.child.grandchild', + }, }); expect(mockConsoleLog.mock.calls[1][0]).toMatchInlineSnapshot( `"[DEBUG][tests.child.grandchild] tests.child.grandchild log to default and custom!"` @@ -347,9 +359,11 @@ test('subsequent calls to setContextConfig() for the same context override the p // Only the warn log should have been logged expect(mockConsoleLog).toHaveBeenCalledTimes(2); expect(JSON.parse(mockConsoleLog.mock.calls[0][0])).toMatchObject({ - context: 'tests.child.grandchild', message: 'tests.child.grandchild log to default and custom!', - level: 'WARN', + log: { + level: 'WARN', + logger: 'tests.child.grandchild', + }, }); expect(mockConsoleLog.mock.calls[1][0]).toMatchInlineSnapshot( `"[WARN ][tests.child.grandchild] second pattern! tests.child.grandchild log to default and custom!"` @@ -384,8 +398,10 @@ test('subsequent calls to setContextConfig() for the same context can disable th // Only the warn log should have been logged once on the default appender expect(mockConsoleLog).toHaveBeenCalledTimes(1); expect(JSON.parse(mockConsoleLog.mock.calls[0][0])).toMatchObject({ - context: 'tests.child.grandchild', message: 'tests.child.grandchild log to default!', - level: 'WARN', + log: { + level: 'WARN', + logger: 'tests.child.grandchild', + }, }); }); diff --git a/src/plugins/data/public/actions/apply_filter_action.ts b/src/plugins/data/public/actions/apply_filter_action.ts index 7e8ed5ec8fb22..a2621e6ce8802 100644 --- a/src/plugins/data/public/actions/apply_filter_action.ts +++ b/src/plugins/data/public/actions/apply_filter_action.ts @@ -22,6 +22,7 @@ import { toMountPoint } from '../../../kibana_react/public'; import { ActionByType, createAction, IncompatibleActionError } from '../../../ui_actions/public'; import { getOverlays, getIndexPatterns } from '../services'; import { applyFiltersPopover } from '../ui/apply_filters'; +import type { IEmbeddable } from '../../../embeddable/public'; import { Filter, FilterManager, TimefilterContract, esFilters } from '..'; export const ACTION_GLOBAL_APPLY_FILTER = 'ACTION_GLOBAL_APPLY_FILTER'; @@ -29,6 +30,7 @@ export const ACTION_GLOBAL_APPLY_FILTER = 'ACTION_GLOBAL_APPLY_FILTER'; export interface ApplyGlobalFilterActionContext { filters: Filter[]; timeFieldName?: string; + embeddable?: IEmbeddable; } async function isCompatible(context: ApplyGlobalFilterActionContext) { diff --git a/src/plugins/data/public/actions/filters/create_filters_from_range_select.ts b/src/plugins/data/public/actions/filters/create_filters_from_range_select.ts index a0eb49d773f3d..d9aa1b8ec8048 100644 --- a/src/plugins/data/public/actions/filters/create_filters_from_range_select.ts +++ b/src/plugins/data/public/actions/filters/create_filters_from_range_select.ts @@ -22,7 +22,7 @@ import moment from 'moment'; import { esFilters, IFieldType, RangeFilterParams } from '../../../public'; import { getIndexPatterns } from '../../../public/services'; import { deserializeAggConfig } from '../../search/expressions/utils'; -import { RangeSelectContext } from '../../../../embeddable/public'; +import type { RangeSelectContext } from '../../../../embeddable/public'; export async function createFiltersFromRangeSelectAction(event: RangeSelectContext['data']) { const column: Record = event.table.columns[event.column]; diff --git a/src/plugins/data/public/actions/filters/create_filters_from_value_click.ts b/src/plugins/data/public/actions/filters/create_filters_from_value_click.ts index 1974b9f776748..9429df91f693c 100644 --- a/src/plugins/data/public/actions/filters/create_filters_from_value_click.ts +++ b/src/plugins/data/public/actions/filters/create_filters_from_value_click.ts @@ -21,7 +21,7 @@ import { KibanaDatatable } from '../../../../../plugins/expressions/public'; import { deserializeAggConfig } from '../../search/expressions'; import { esFilters, Filter } from '../../../public'; import { getIndexPatterns } from '../../../public/services'; -import { ValueClickContext } from '../../../../embeddable/public'; +import type { ValueClickContext } from '../../../../embeddable/public'; /** * For terms aggregations on `__other__` buckets, this assembles a list of applicable filter diff --git a/src/plugins/data/public/actions/index.ts b/src/plugins/data/public/actions/index.ts index ef9014aafe82d..692996cf6fd19 100644 --- a/src/plugins/data/public/actions/index.ts +++ b/src/plugins/data/public/actions/index.ts @@ -17,8 +17,12 @@ * under the License. */ -export { ACTION_GLOBAL_APPLY_FILTER, createFilterAction } from './apply_filter_action'; +export { + ACTION_GLOBAL_APPLY_FILTER, + createFilterAction, + ApplyGlobalFilterActionContext, +} from './apply_filter_action'; export { createFiltersFromValueClickAction } from './filters/create_filters_from_value_click'; export { createFiltersFromRangeSelectAction } from './filters/create_filters_from_range_select'; -export { selectRangeAction } from './select_range_action'; -export { valueClickAction } from './value_click_action'; +export * from './select_range_action'; +export * from './value_click_action'; diff --git a/src/plugins/data/public/actions/select_range_action.ts b/src/plugins/data/public/actions/select_range_action.ts index 49766143b5588..1781da980dc30 100644 --- a/src/plugins/data/public/actions/select_range_action.ts +++ b/src/plugins/data/public/actions/select_range_action.ts @@ -17,60 +17,39 @@ * under the License. */ -import { i18n } from '@kbn/i18n'; import { - createAction, - IncompatibleActionError, ActionByType, + APPLY_FILTER_TRIGGER, + createAction, + UiActionsStart, } from '../../../../plugins/ui_actions/public'; import { createFiltersFromRangeSelectAction } from './filters/create_filters_from_range_select'; -import { RangeSelectContext } from '../../../embeddable/public'; -import { FilterManager, TimefilterContract, esFilters } from '..'; - -export const ACTION_SELECT_RANGE = 'ACTION_SELECT_RANGE'; +import type { RangeSelectContext } from '../../../embeddable/public'; export type SelectRangeActionContext = RangeSelectContext; -async function isCompatible(context: SelectRangeActionContext) { - try { - return Boolean(await createFiltersFromRangeSelectAction(context.data)); - } catch { - return false; - } -} +export const ACTION_SELECT_RANGE = 'ACTION_SELECT_RANGE'; -export function selectRangeAction( - filterManager: FilterManager, - timeFilter: TimefilterContract +export function createSelectRangeAction( + getStartServices: () => { uiActions: UiActionsStart } ): ActionByType { return createAction({ type: ACTION_SELECT_RANGE, id: ACTION_SELECT_RANGE, - getIconType: () => 'filter', - getDisplayName: () => { - return i18n.translate('data.filter.applyFilterActionTitle', { - defaultMessage: 'Apply filter to current view', - }); - }, - isCompatible, - execute: async ({ data }: SelectRangeActionContext) => { - if (!(await isCompatible({ data }))) { - throw new IncompatibleActionError(); - } - - const selectedFilters = await createFiltersFromRangeSelectAction(data); - - if (data.timeFieldName) { - const { timeRangeFilter, restOfFilters } = esFilters.extractTimeFilter( - data.timeFieldName, - selectedFilters - ); - filterManager.addFilters(restOfFilters); - if (timeRangeFilter) { - esFilters.changeTimeFilter(timeFilter, timeRangeFilter); + shouldAutoExecute: async () => true, + execute: async (context: SelectRangeActionContext) => { + try { + const filters = await createFiltersFromRangeSelectAction(context.data); + if (filters.length > 0) { + await getStartServices().uiActions.getTrigger(APPLY_FILTER_TRIGGER).exec({ + filters, + embeddable: context.embeddable, + timeFieldName: context.data.timeFieldName, + }); } - } else { - filterManager.addFilters(selectedFilters); + } catch (e) { + // eslint-disable-next-line no-console + console.warn(`Error [ACTION_SELECT_RANGE]: can\'t extract filters from action context`); } }, }); diff --git a/src/plugins/data/public/actions/value_click_action.ts b/src/plugins/data/public/actions/value_click_action.ts index dd74a7ee507f3..81e62380eacfb 100644 --- a/src/plugins/data/public/actions/value_click_action.ts +++ b/src/plugins/data/public/actions/value_click_action.ts @@ -17,98 +17,41 @@ * under the License. */ -import { i18n } from '@kbn/i18n'; -import { toMountPoint } from '../../../../plugins/kibana_react/public'; import { ActionByType, + APPLY_FILTER_TRIGGER, createAction, - IncompatibleActionError, + UiActionsStart, } from '../../../../plugins/ui_actions/public'; -import { getOverlays, getIndexPatterns } from '../services'; -import { applyFiltersPopover } from '../ui/apply_filters'; import { createFiltersFromValueClickAction } from './filters/create_filters_from_value_click'; -import { ValueClickContext } from '../../../embeddable/public'; -import { Filter, FilterManager, TimefilterContract, esFilters } from '..'; - -export const ACTION_VALUE_CLICK = 'ACTION_VALUE_CLICK'; +import type { Filter } from '../../common/es_query/filters'; +import type { ValueClickContext } from '../../../embeddable/public'; export type ValueClickActionContext = ValueClickContext; +export const ACTION_VALUE_CLICK = 'ACTION_VALUE_CLICK'; -async function isCompatible(context: ValueClickActionContext) { - try { - const filters: Filter[] = await createFiltersFromValueClickAction(context.data); - return filters.length > 0; - } catch { - return false; - } -} - -export function valueClickAction( - filterManager: FilterManager, - timeFilter: TimefilterContract +export function createValueClickAction( + getStartServices: () => { uiActions: UiActionsStart } ): ActionByType { return createAction({ type: ACTION_VALUE_CLICK, id: ACTION_VALUE_CLICK, - getIconType: () => 'filter', - getDisplayName: () => { - return i18n.translate('data.filter.applyFilterActionTitle', { - defaultMessage: 'Apply filter to current view', - }); - }, - isCompatible, - execute: async ({ data }: ValueClickActionContext) => { - if (!(await isCompatible({ data }))) { - throw new IncompatibleActionError(); - } - - const filters: Filter[] = await createFiltersFromValueClickAction(data); - - let selectedFilters = filters; - - if (filters.length > 1) { - const indexPatterns = await Promise.all( - filters.map((filter) => { - return getIndexPatterns().get(filter.meta.index!); - }) - ); - - const filterSelectionPromise: Promise = new Promise((resolve) => { - const overlay = getOverlays().openModal( - toMountPoint( - applyFiltersPopover( - filters, - indexPatterns, - () => { - overlay.close(); - resolve([]); - }, - (filterSelection: Filter[]) => { - overlay.close(); - resolve(filterSelection); - } - ) - ), - { - 'data-test-subj': 'selectFilterOverlay', - } - ); - }); - - selectedFilters = await filterSelectionPromise; - } - - if (data.timeFieldName) { - const { timeRangeFilter, restOfFilters } = esFilters.extractTimeFilter( - data.timeFieldName, - selectedFilters - ); - filterManager.addFilters(restOfFilters); - if (timeRangeFilter) { - esFilters.changeTimeFilter(timeFilter, timeRangeFilter); + shouldAutoExecute: async () => true, + execute: async (context: ValueClickActionContext) => { + try { + const filters: Filter[] = await createFiltersFromValueClickAction(context.data); + if (filters.length > 0) { + await getStartServices().uiActions.getTrigger(APPLY_FILTER_TRIGGER).exec({ + filters, + embeddable: context.embeddable, + timeFieldName: context.data.timeFieldName, + }); } - } else { - filterManager.addFilters(selectedFilters); + } catch (e) { + // eslint-disable-next-line no-console + console.warn( + `Error [ACTION_EMIT_APPLY_FILTER_TRIGGER]: can\'t extract filters from action context` + ); } }, }); diff --git a/src/plugins/data/public/index.ts b/src/plugins/data/public/index.ts index 6328e694193c9..846471420327f 100644 --- a/src/plugins/data/public/index.ts +++ b/src/plugins/data/public/index.ts @@ -438,6 +438,8 @@ export { export { isTimeRange, isQuery, isFilter, isFilters } from '../common'; +export { ApplyGlobalFilterActionContext } from './actions'; + export * from '../common/field_mapping'; /* diff --git a/src/plugins/data/public/plugin.ts b/src/plugins/data/public/plugin.ts index 323a32ea362ac..68c0f506f121d 100644 --- a/src/plugins/data/public/plugin.ts +++ b/src/plugins/data/public/plugin.ts @@ -69,18 +69,15 @@ import { createFilterAction, createFiltersFromValueClickAction, createFiltersFromRangeSelectAction, -} from './actions'; -import { ApplyGlobalFilterActionContext } from './actions/apply_filter_action'; -import { - selectRangeAction, - SelectRangeActionContext, + ApplyGlobalFilterActionContext, ACTION_SELECT_RANGE, -} from './actions/select_range_action'; -import { - valueClickAction, ACTION_VALUE_CLICK, + SelectRangeActionContext, ValueClickActionContext, -} from './actions/value_click_action'; + createValueClickAction, + createSelectRangeAction, +} from './actions'; + import { SavedObjectsClientPublicToCommon } from './index_patterns'; import { indexPatternLoad } from './index_patterns/expressions/load_index_pattern'; @@ -92,7 +89,14 @@ declare module '../../ui_actions/public' { } } -export class DataPublicPlugin implements Plugin { +export class DataPublicPlugin + implements + Plugin< + DataPublicPluginSetup, + DataPublicPluginStart, + DataSetupDependencies, + DataStartDependencies + > { private readonly autocomplete: AutocompleteService; private readonly searchService: SearchService; private readonly fieldFormatsService: FieldFormatsService; @@ -110,13 +114,13 @@ export class DataPublicPlugin implements Plugin, { expressions, uiActions, usageCollection }: DataSetupDependencies ): DataPublicPluginSetup { const startServices = createStartServicesGetter(core.getStartServices); const getInternalStartServices = (): InternalStartServices => { - const { core: coreStart, self }: any = startServices(); + const { core: coreStart, self } = startServices(); return { fieldFormats: self.fieldFormats, notifications: coreStart.notifications, @@ -140,12 +144,16 @@ export class DataPublicPlugin implements Plugin ({ + uiActions: startServices().plugins.uiActions, + })) ); uiActions.addTriggerAction( VALUE_CLICK_TRIGGER, - valueClickAction(queryService.filterManager, queryService.timefilter.timefilter) + createValueClickAction(() => ({ + uiActions: startServices().plugins.uiActions, + })) ); return { diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index f8b8cb43b2297..38e0416233e25 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -250,6 +250,20 @@ export class AggParamType extends Ba makeAgg: (agg: TAggConfig, state?: AggConfigSerialized) => TAggConfig; } +// Warning: (ae-missing-release-tag) "ApplyGlobalFilterActionContext" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface ApplyGlobalFilterActionContext { + // Warning: (ae-forgotten-export) The symbol "IEmbeddable" needs to be exported by the entry point index.d.ts + // + // (undocumented) + embeddable?: IEmbeddable; + // (undocumented) + filters: Filter[]; + // (undocumented) + timeFieldName?: string; +} + // Warning: (ae-forgotten-export) The symbol "DateNanosFormat" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "DateFormat" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "baseFormattersPublic" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -1443,18 +1457,16 @@ export type PhrasesFilter = Filter & { meta: PhrasesFilterMeta; }; +// Warning: (ae-forgotten-export) The symbol "DataSetupDependencies" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "DataStartDependencies" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "DataPublicPlugin" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export class Plugin implements Plugin_2 { +export class Plugin implements Plugin_2 { // Warning: (ae-forgotten-export) The symbol "ConfigSchema" needs to be exported by the entry point index.d.ts constructor(initializerContext: PluginInitializerContext_2); - // Warning: (ae-forgotten-export) The symbol "DataSetupDependencies" needs to be exported by the entry point index.d.ts - // // (undocumented) - setup(core: CoreSetup, { expressions, uiActions, usageCollection }: DataSetupDependencies): DataPublicPluginSetup; - // Warning: (ae-forgotten-export) The symbol "DataStartDependencies" needs to be exported by the entry point index.d.ts - // + setup(core: CoreSetup, { expressions, uiActions, usageCollection }: DataSetupDependencies): DataPublicPluginSetup; // (undocumented) start(core: CoreStart_2, { uiActions }: DataStartDependencies): DataPublicPluginStart; // (undocumented) diff --git a/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx b/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx index 8cf2e015f88cf..cb02ffc470e95 100644 --- a/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx +++ b/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx @@ -311,8 +311,7 @@ export class EmbeddablePanel extends React.Component { const sortedActions = [...regularActions, ...extraActions].sort(sortByOrderField); return await buildContextMenuForActions({ - actions: sortedActions, - actionContext: { embeddable: this.props.embeddable }, + actions: sortedActions.map((action) => [action, { embeddable: this.props.embeddable }]), closeMenu: this.closeMyContextMenuPanel, }); }; diff --git a/src/plugins/es_ui_shared/public/forms/multi_content/multi_content_context.tsx b/src/plugins/es_ui_shared/public/forms/multi_content/multi_content_context.tsx index 210b0cedccd06..c5659745f229a 100644 --- a/src/plugins/es_ui_shared/public/forms/multi_content/multi_content_context.tsx +++ b/src/plugins/es_ui_shared/public/forms/multi_content/multi_content_context.tsx @@ -17,7 +17,7 @@ * under the License. */ -import React, { useEffect, useCallback, createContext, useContext } from 'react'; +import React, { useEffect, useCallback, createContext, useContext, useRef } from 'react'; import { useMultiContent, HookProps, Content, MultiContent } from './use_multi_content'; @@ -55,7 +55,14 @@ export function useMultiContentContext(contentId: K) { - const { updateContentAt, saveSnapshotAndRemoveContent, getData } = useMultiContentContext(); + const isMounted = useRef(false); + const defaultValue = useRef(undefined); + const { + updateContentAt, + saveSnapshotAndRemoveContent, + getData, + getSingleContentData, + } = useMultiContentContext(); const updateContent = useCallback( (content: Content) => { @@ -71,12 +78,22 @@ export function useContent(contentId: K) { }; }, [contentId, saveSnapshotAndRemoveContent]); - const data = getData(); - const defaultValue = data[contentId]; + useEffect(() => { + if (isMounted.current === false) { + isMounted.current = true; + } + }, []); + + if (isMounted.current === false) { + // Only read the default value once, on component mount to avoid re-rendering the + // consumer each time the multi-content validity ("isValid") changes. + defaultValue.current = getSingleContentData(contentId); + } return { - defaultValue, + defaultValue: defaultValue.current!, updateContent, getData, + getSingleContentData, }; } diff --git a/src/plugins/es_ui_shared/public/forms/multi_content/use_multi_content.ts b/src/plugins/es_ui_shared/public/forms/multi_content/use_multi_content.ts index adc68a39a4a5b..8d470f6454b0e 100644 --- a/src/plugins/es_ui_shared/public/forms/multi_content/use_multi_content.ts +++ b/src/plugins/es_ui_shared/public/forms/multi_content/use_multi_content.ts @@ -45,6 +45,7 @@ export interface MultiContent { updateContentAt: (id: keyof T, content: Content) => void; saveSnapshotAndRemoveContent: (id: keyof T) => void; getData: () => T; + getSingleContentData: (contentId: K) => T[K]; validate: () => Promise; validation: Validation; } @@ -109,9 +110,22 @@ export function useMultiContent({ }; }, [stateData, validation]); + /** + * Read a single content data. + */ + const getSingleContentData = useCallback( + (contentId: K): T[K] => { + if (contents.current[contentId]) { + return contents.current[contentId].getData(); + } + return stateData[contentId]; + }, + [stateData] + ); + const updateContentValidity = useCallback( (updatedData: { [key in keyof T]?: boolean | undefined }): boolean | undefined => { - let allContentValidity: boolean | undefined; + let isAllContentValid: boolean | undefined = validation.isValid; setValidation((prev) => { if ( @@ -120,7 +134,7 @@ export function useMultiContent({ ) ) { // No change in validation, nothing to update - allContentValidity = prev.isValid; + isAllContentValid = prev.isValid; return prev; } @@ -129,21 +143,21 @@ export function useMultiContent({ ...updatedData, }; - allContentValidity = Object.values(nextContentsValidityState).some( + isAllContentValid = Object.values(nextContentsValidityState).some( (_isValid) => _isValid === undefined ) ? undefined : Object.values(nextContentsValidityState).every(Boolean); return { - isValid: allContentValidity, + isValid: isAllContentValid, contents: nextContentsValidityState, }; }); - return allContentValidity; + return isAllContentValid; }, - [] + [validation.isValid] ); /** @@ -163,7 +177,7 @@ export function useMultiContent({ } return Boolean(updateContentValidity(updatedValidation)); - }, [updateContentValidity]); + }, [validation.isValid, updateContentValidity]); /** * Update a content. It replaces the content in our "contents" map and update @@ -186,7 +200,7 @@ export function useMultiContent({ }); } }, - [updateContentValidity, onChange] + [updateContentValidity, onChange, getData, validate] ); /** @@ -211,6 +225,7 @@ export function useMultiContent({ return { getData, + getSingleContentData, validate, validation, updateContentAt, diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/form_data_provider.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/form_data_provider.ts index 4c4a7f0642022..4c8e91b13b1b7 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/form_data_provider.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/form_data_provider.ts @@ -29,6 +29,7 @@ interface Props { export const FormDataProvider = React.memo(({ children, pathsToWatch }: Props) => { const form = useFormContext(); + const { subscribe } = form; const previousRawData = useRef(form.__getFormData$().value); const [formData, setFormData] = useState(previousRawData.current); @@ -54,9 +55,9 @@ export const FormDataProvider = React.memo(({ children, pathsToWatch }: Props) = ); useEffect(() => { - const subscription = form.subscribe(onFormData); + const subscription = subscribe(onFormData); return subscription.unsubscribe; - }, [form.subscribe, onFormData]); + }, [subscribe, onFormData]); return children(formData); }); diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_array.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_array.ts index 1605c09f575f6..3688421964d2e 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_array.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_array.ts @@ -17,7 +17,7 @@ * under the License. */ -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect, useRef, useCallback } from 'react'; import { useFormContext } from '../form_context'; @@ -83,14 +83,18 @@ export const UseArray = ({ const [items, setItems] = useState(initialState); - const updatePaths = (_rows: ArrayItem[]) => - _rows.map( - (row, index) => - ({ - ...row, - path: `${path}[${index}]`, - } as ArrayItem) - ); + const updatePaths = useCallback( + (_rows: ArrayItem[]) => { + return _rows.map( + (row, index) => + ({ + ...row, + path: `${path}[${index}]`, + } as ArrayItem) + ); + }, + [path] + ); const addItem = () => { setItems((previousItems) => { @@ -108,11 +112,13 @@ export const UseArray = ({ useEffect(() => { if (didMountRef.current) { - setItems(updatePaths(items)); + setItems((prev) => { + return updatePaths(prev); + }); } else { didMountRef.current = true; } - }, [path]); + }, [path, updatePaths]); return children({ items, addItem, removeItem }); }; diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_field.test.tsx b/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_field.test.tsx index 7ad32cb0bc3f0..f00beb470a9fc 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_field.test.tsx +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_field.test.tsx @@ -30,8 +30,9 @@ describe('', () => { const TestComp = ({ onData }: { onData: OnUpdateHandler }) => { const { form } = useForm(); + const { subscribe } = form; - useEffect(() => form.subscribe(onData).unsubscribe, [form]); + useEffect(() => subscribe(onData).unsubscribe, [subscribe, onData]); return (
diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_field.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_field.ts index b83006c6cec52..b2f00610a3d33 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_field.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_field.ts @@ -17,7 +17,7 @@ * under the License. */ -import { useState, useEffect, useRef, useMemo } from 'react'; +import { useMemo, useState, useEffect, useRef, useCallback } from 'react'; import { FormHook, FieldHook, FieldConfig, FieldValidateResponse, ValidationError } from '../types'; import { FIELD_TYPES, VALIDATION_TYPES } from '../constants'; @@ -34,21 +34,21 @@ export const useField = ( label = '', labelAppend = '', helpText = '', - validations = [], - formatters = [], - fieldsToValidateOnChange = [path], + validations, + formatters, + fieldsToValidateOnChange, errorDisplayDelay = form.__options.errorDisplayDelay, - serializer = (value: unknown) => value, - deserializer = (value: unknown) => value, + serializer, + deserializer, } = config; + const { getFormData, __removeField, __updateFormDataAt, __validateFields } = form; - const initialValue = useMemo( - () => - typeof defaultValue === 'function' - ? deserializer(defaultValue()) - : deserializer(defaultValue), - [defaultValue] - ) as T; + const initialValue = useMemo(() => { + if (typeof defaultValue === 'function') { + return deserializer ? deserializer(defaultValue()) : defaultValue(); + } + return deserializer ? deserializer(defaultValue) : defaultValue; + }, [defaultValue, deserializer]) as T; const [value, setStateValue] = useState(initialValue); const [errors, setErrors] = useState([]); @@ -64,6 +64,12 @@ export const useField = ( // -- HELPERS // ---------------------------------- + const serializeOutput: FieldHook['__serializeOutput'] = useCallback( + (rawValue = value) => { + return serializer ? serializer(rawValue) : rawValue; + }, + [serializer, value] + ); /** * Filter an array of errors with specific validation type on them @@ -84,19 +90,22 @@ export const useField = ( ); }; - const formatInputValue = (inputValue: unknown): T => { - const isEmptyString = typeof inputValue === 'string' && inputValue.trim() === ''; + const formatInputValue = useCallback( + (inputValue: unknown): T => { + const isEmptyString = typeof inputValue === 'string' && inputValue.trim() === ''; - if (isEmptyString) { - return inputValue as T; - } + if (isEmptyString || !formatters) { + return inputValue as T; + } - const formData = form.getFormData({ unflatten: false }); + const formData = getFormData({ unflatten: false }); - return formatters.reduce((output, formatter) => formatter(output, formData), inputValue) as T; - }; + return formatters.reduce((output, formatter) => formatter(output, formData), inputValue) as T; + }, + [formatters, getFormData] + ); - const onValueChange = async () => { + const onValueChange = useCallback(async () => { const changeIteration = ++changeCounter.current; const startTime = Date.now(); @@ -116,10 +125,10 @@ export const useField = ( } // Update the form data observable - form.__updateFormDataAt(path, newValue); + __updateFormDataAt(path, newValue); - // Validate field(s) and set form.isValid flag - await form.__validateFields(fieldsToValidateOnChange); + // Validate field(s) and update form.isValid state + await __validateFields(fieldsToValidateOnChange ?? [path]); if (isUnmounted.current) { return; @@ -142,9 +151,18 @@ export const useField = ( setIsChangingValue(false); } } - }; + }, [ + serializeOutput, + valueChangeListener, + errorDisplayDelay, + path, + value, + fieldsToValidateOnChange, + __updateFormDataAt, + __validateFields, + ]); - const cancelInflightValidation = () => { + const cancelInflightValidation = useCallback(() => { // Cancel any inflight validation (like an HTTP Request) if ( inflightValidation.current && @@ -153,209 +171,232 @@ export const useField = ( (inflightValidation.current as any).cancel(); inflightValidation.current = null; } - }; + }, []); - const runValidations = ({ - formData, - value: valueToValidate, - validationTypeToValidate, - }: { - formData: any; - value: unknown; - validationTypeToValidate?: string; - }): ValidationError[] | Promise => { - // By default, for fields that have an asynchronous validation - // we will clear the errors as soon as the field value changes. - clearErrors([VALIDATION_TYPES.FIELD, VALIDATION_TYPES.ASYNC]); - - cancelInflightValidation(); - - const runAsync = async () => { - const validationErrors: ValidationError[] = []; - - for (const validation of validations) { - inflightValidation.current = null; - - const { - validator, - exitOnFail = true, - type: validationType = VALIDATION_TYPES.FIELD, - } = validation; - - if ( - typeof validationTypeToValidate !== 'undefined' && - validationType !== validationTypeToValidate - ) { - continue; - } - - inflightValidation.current = validator({ - value: (valueToValidate as unknown) as string, - errors: validationErrors, - form, - formData, - path, - }) as Promise; - - const validationResult = await inflightValidation.current; - - if (!validationResult) { - continue; - } - - validationErrors.push({ - ...validationResult, - validationType: validationType || VALIDATION_TYPES.FIELD, - }); + const clearErrors: FieldHook['clearErrors'] = useCallback( + (validationType = VALIDATION_TYPES.FIELD) => { + setErrors((previousErrors) => filterErrors(previousErrors, validationType)); + }, + [] + ); - if (exitOnFail) { - break; - } + const runValidations = useCallback( + ({ + formData, + value: valueToValidate, + validationTypeToValidate, + }: { + formData: any; + value: unknown; + validationTypeToValidate?: string; + }): ValidationError[] | Promise => { + if (!validations) { + return []; } - return validationErrors; - }; - - const runSync = () => { - const validationErrors: ValidationError[] = []; - // Sequentially execute all the validations for the field - for (const validation of validations) { - const { - validator, - exitOnFail = true, - type: validationType = VALIDATION_TYPES.FIELD, - } = validation; - - if ( - typeof validationTypeToValidate !== 'undefined' && - validationType !== validationTypeToValidate - ) { - continue; - } - - const validationResult = validator({ - value: (valueToValidate as unknown) as string, - errors: validationErrors, - form, - formData, - path, - }); - - if (!validationResult) { - continue; + // By default, for fields that have an asynchronous validation + // we will clear the errors as soon as the field value changes. + clearErrors([VALIDATION_TYPES.FIELD, VALIDATION_TYPES.ASYNC]); + + cancelInflightValidation(); + + const runAsync = async () => { + const validationErrors: ValidationError[] = []; + + for (const validation of validations) { + inflightValidation.current = null; + + const { + validator, + exitOnFail = true, + type: validationType = VALIDATION_TYPES.FIELD, + } = validation; + + if ( + typeof validationTypeToValidate !== 'undefined' && + validationType !== validationTypeToValidate + ) { + continue; + } + + inflightValidation.current = validator({ + value: (valueToValidate as unknown) as string, + errors: validationErrors, + form, + formData, + path, + }) as Promise; + + const validationResult = await inflightValidation.current; + + if (!validationResult) { + continue; + } + + validationErrors.push({ + ...validationResult, + validationType: validationType || VALIDATION_TYPES.FIELD, + }); + + if (exitOnFail) { + break; + } } - if (!!validationResult.then) { - // The validator returned a Promise: abort and run the validations asynchronously - // We keep a reference to the onflith promise so we can cancel it. - - inflightValidation.current = validationResult as Promise; - cancelInflightValidation(); - - return runAsync(); - } - - validationErrors.push({ - ...(validationResult as ValidationError), - validationType: validationType || VALIDATION_TYPES.FIELD, - }); + return validationErrors; + }; - if (exitOnFail) { - break; + const runSync = () => { + const validationErrors: ValidationError[] = []; + // Sequentially execute all the validations for the field + for (const validation of validations) { + const { + validator, + exitOnFail = true, + type: validationType = VALIDATION_TYPES.FIELD, + } = validation; + + if ( + typeof validationTypeToValidate !== 'undefined' && + validationType !== validationTypeToValidate + ) { + continue; + } + + const validationResult = validator({ + value: (valueToValidate as unknown) as string, + errors: validationErrors, + form, + formData, + path, + }); + + if (!validationResult) { + continue; + } + + if (!!validationResult.then) { + // The validator returned a Promise: abort and run the validations asynchronously + // We keep a reference to the onflith promise so we can cancel it. + + inflightValidation.current = validationResult as Promise; + cancelInflightValidation(); + + return runAsync(); + } + + validationErrors.push({ + ...(validationResult as ValidationError), + validationType: validationType || VALIDATION_TYPES.FIELD, + }); + + if (exitOnFail) { + break; + } } - } - return validationErrors; - }; + return validationErrors; + }; - // We first try to run the validations synchronously - return runSync(); - }; + // We first try to run the validations synchronously + return runSync(); + }, + [clearErrors, cancelInflightValidation, validations, form, path] + ); // -- API // ---------------------------------- - const clearErrors: FieldHook['clearErrors'] = (validationType = VALIDATION_TYPES.FIELD) => { - setErrors((previousErrors) => filterErrors(previousErrors, validationType)); - }; /** * Validate a form field, running all its validations. * If a validationType is provided then only that validation will be executed, * skipping the other type of validation that might exist. */ - const validate: FieldHook['validate'] = (validationData = {}) => { - const { - formData = form.getFormData({ unflatten: false }), - value: valueToValidate = value, - validationType, - } = validationData; - - setIsValidated(true); - setValidating(true); - - // By the time our validate function has reached completion, it’s possible - // that validate() will have been called again. If this is the case, we need - // to ignore the results of this invocation and only use the results of - // the most recent invocation to update the error state for a field - const validateIteration = ++validateCounter.current; - - const onValidationErrors = (_validationErrors: ValidationError[]): FieldValidateResponse => { - if (validateIteration === validateCounter.current) { - // This is the most recent invocation - setValidating(false); - // Update the errors array - const filteredErrors = filterErrors(errors, validationType); - setErrors([...filteredErrors, ..._validationErrors]); - } + const validate: FieldHook['validate'] = useCallback( + (validationData = {}) => { + const { + formData = getFormData({ unflatten: false }), + value: valueToValidate = value, + validationType, + } = validationData; + + setIsValidated(true); + setValidating(true); + + // By the time our validate function has reached completion, it’s possible + // that validate() will have been called again. If this is the case, we need + // to ignore the results of this invocation and only use the results of + // the most recent invocation to update the error state for a field + const validateIteration = ++validateCounter.current; + + const onValidationErrors = (_validationErrors: ValidationError[]): FieldValidateResponse => { + if (validateIteration === validateCounter.current) { + // This is the most recent invocation + setValidating(false); + // Update the errors array + setErrors((prev) => { + const filteredErrors = filterErrors(prev, validationType); + return [...filteredErrors, ..._validationErrors]; + }); + } - return { - isValid: _validationErrors.length === 0, - errors: _validationErrors, + return { + isValid: _validationErrors.length === 0, + errors: _validationErrors, + }; }; - }; - const validationErrors = runValidations({ - formData, - value: valueToValidate, - validationTypeToValidate: validationType, - }); + const validationErrors = runValidations({ + formData, + value: valueToValidate, + validationTypeToValidate: validationType, + }); - if (Reflect.has(validationErrors, 'then')) { - return (validationErrors as Promise).then(onValidationErrors); - } - return onValidationErrors(validationErrors as ValidationError[]); - }; + if (Reflect.has(validationErrors, 'then')) { + return (validationErrors as Promise).then(onValidationErrors); + } + return onValidationErrors(validationErrors as ValidationError[]); + }, + [getFormData, value, runValidations] + ); /** * Handler to change the field value * * @param newValue The new value to assign to the field */ - const setValue: FieldHook['setValue'] = (newValue) => { - if (isPristine) { - setPristine(false); - } + const setValue: FieldHook['setValue'] = useCallback( + (newValue) => { + if (isPristine) { + setPristine(false); + } - const formattedValue = formatInputValue(newValue); - setStateValue(formattedValue); - }; + const formattedValue = formatInputValue(newValue); + setStateValue(formattedValue); + return formattedValue; + }, + [formatInputValue, isPristine] + ); - const _setErrors: FieldHook['setErrors'] = (_errors) => { + const _setErrors: FieldHook['setErrors'] = useCallback((_errors) => { setErrors(_errors.map((error) => ({ validationType: VALIDATION_TYPES.FIELD, ...error }))); - }; + }, []); /** * Form "onChange" event handler * * @param event Form input change event */ - const onChange: FieldHook['onChange'] = (event) => { - const newValue = {}.hasOwnProperty.call(event!.target, 'checked') - ? event.target.checked - : event.target.value; + const onChange: FieldHook['onChange'] = useCallback( + (event) => { + const newValue = {}.hasOwnProperty.call(event!.target, 'checked') + ? event.target.checked + : event.target.value; - setValue((newValue as unknown) as T); - }; + setValue((newValue as unknown) as T); + }, + [setValue] + ); /** * As we can have multiple validation types (FIELD, ASYNC, ARRAY_ITEM), this @@ -367,48 +408,50 @@ export const useField = ( * * @param validationType The validation type to return error messages from */ - const getErrorsMessages: FieldHook['getErrorsMessages'] = (args = {}) => { - const { errorCode, validationType = VALIDATION_TYPES.FIELD } = args; - const errorMessages = errors.reduce((messages, error) => { - const isSameErrorCode = errorCode && error.code === errorCode; - const isSamevalidationType = - error.validationType === validationType || - (validationType === VALIDATION_TYPES.FIELD && - !{}.hasOwnProperty.call(error, 'validationType')); - - if (isSameErrorCode || (typeof errorCode === 'undefined' && isSamevalidationType)) { - return messages ? `${messages}, ${error.message}` : (error.message as string); + const getErrorsMessages: FieldHook['getErrorsMessages'] = useCallback( + (args = {}) => { + const { errorCode, validationType = VALIDATION_TYPES.FIELD } = args; + const errorMessages = errors.reduce((messages, error) => { + const isSameErrorCode = errorCode && error.code === errorCode; + const isSamevalidationType = + error.validationType === validationType || + (validationType === VALIDATION_TYPES.FIELD && + !{}.hasOwnProperty.call(error, 'validationType')); + + if (isSameErrorCode || (typeof errorCode === 'undefined' && isSamevalidationType)) { + return messages ? `${messages}, ${error.message}` : (error.message as string); + } + return messages; + }, ''); + + return errorMessages ? errorMessages : null; + }, + [errors] + ); + + const reset: FieldHook['reset'] = useCallback( + (resetOptions = { resetValue: true }) => { + const { resetValue = true } = resetOptions; + + setPristine(true); + setValidating(false); + setIsChangingValue(false); + setIsValidated(false); + setErrors([]); + + if (resetValue) { + setValue(initialValue); + /** + * Having to call serializeOutput() is a current bug of the lib and will be fixed + * in a future PR. The serializer function should only be called when outputting + * the form data. If we need to continuously format the data while it changes, + * we need to use the field `formatter` config. + */ + return serializeOutput(initialValue); } - return messages; - }, ''); - - return errorMessages ? errorMessages : null; - }; - - const reset: FieldHook['reset'] = (resetOptions = { resetValue: true }) => { - const { resetValue = true } = resetOptions; - - setPristine(true); - setValidating(false); - setIsChangingValue(false); - setIsValidated(false); - setErrors([]); - - if (resetValue) { - setValue(initialValue); - /** - * Having to call serializeOutput() is a current bug of the lib and will be fixed - * in a future PR. The serializer function should only be called when outputting - * the form data. If we need to continuously format the data while it changes, - * we need to use the field `formatter` config. - */ - return serializeOutput(initialValue); - } - return value; - }; - - const serializeOutput: FieldHook['__serializeOutput'] = (rawValue = value) => - serializer(rawValue); + }, + [setValue, serializeOutput, initialValue] + ); // -- EFFECTS // ---------------------------------- @@ -425,54 +468,64 @@ export const useField = ( clearTimeout(debounceTimeout.current); } }; - }, [value]); - - const field: FieldHook = { + }, [isPristine, onValueChange]); + + const field: FieldHook = useMemo(() => { + return { + path, + type, + label, + labelAppend, + helpText, + value, + errors, + form, + isPristine, + isValid: errors.length === 0, + isValidating, + isValidated, + isChangingValue, + onChange, + getErrorsMessages, + setValue, + setErrors: _setErrors, + clearErrors, + validate, + reset, + __serializeOutput: serializeOutput, + }; + }, [ path, type, label, labelAppend, helpText, value, - errors, form, isPristine, - isValid: errors.length === 0, + errors, isValidating, isValidated, isChangingValue, onChange, getErrorsMessages, setValue, - setErrors: _setErrors, + _setErrors, clearErrors, validate, reset, - __serializeOutput: serializeOutput, - }; + serializeOutput, + ]); - form.__addField(field as FieldHook); // Executed first (1) + form.__addField(field as FieldHook); useEffect(() => { - /** - * NOTE: effect cleanup actually happens *after* the new component has been mounted, - * but before the next effect callback is run. - * Ref: https://kentcdodds.com/blog/understanding-reacts-key-prop - * - * This means that, the "form.__addField(field)" outside the effect will be called *before* - * the cleanup `form.__removeField(path);` creating a race condition. - * - * TODO: See how we could refactor "use_field" & "use_form" to avoid having the - * `form.__addField(field)` call outside the effect. - */ - form.__addField(field as FieldHook); // Executed third (3) - return () => { // Remove field from the form when it is unmounted or if its path changes. isUnmounted.current = true; - form.__removeField(path); // Executed second (2) + __removeField(path); }; - }, [path]); + }, [path, __removeField]); return field; }; diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.test.tsx b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.test.tsx index f332d2e6ea604..216c7974a9679 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.test.tsx +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.test.tsx @@ -135,12 +135,13 @@ describe('use_form() hook', () => { test('should allow subscribing to the form data changes and provide a handler to build the form data', async () => { const TestComp = ({ onData }: { onData: OnUpdateHandler }) => { const { form } = useForm(); + const { subscribe } = form; useEffect(() => { // Any time the form value changes, forward the data to the consumer - const subscription = form.subscribe(onData); + const subscription = subscribe(onData); return subscription.unsubscribe; - }, [form]); + }, [subscribe, onData]); return ( @@ -200,8 +201,9 @@ describe('use_form() hook', () => { const TestComp = ({ onData }: { onData: OnUpdateHandler }) => { const { form } = useForm({ defaultValue }); + const { subscribe } = form; - useEffect(() => form.subscribe(onData).unsubscribe, [form]); + useEffect(() => subscribe(onData).unsubscribe, [subscribe, onData]); return ( diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.ts index f9286d99cbf80..46b8958491e56 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.ts @@ -17,7 +17,7 @@ * under the License. */ -import { useState, useRef, useEffect, useMemo } from 'react'; +import { useState, useRef, useEffect, useMemo, useCallback } from 'react'; import { get } from 'lodash'; import { FormHook, FieldHook, FormData, FieldConfig, FieldsMap, FormConfig } from '../types'; @@ -34,28 +34,34 @@ interface UseFormReturn { } export function useForm( - formConfig: FormConfig | undefined = {} + formConfig?: FormConfig ): UseFormReturn { - const { - onSubmit, - schema, - serializer = (data: T): T => data, - deserializer = (data: T): T => data, - options = {}, - id = 'default', - } = formConfig; - - const formDefaultValue = - formConfig.defaultValue === undefined || Object.keys(formConfig.defaultValue).length === 0 - ? {} - : Object.entries(formConfig.defaultValue as object) - .filter(({ 1: value }) => value !== undefined) - .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}); - - const formOptions = { ...DEFAULT_OPTIONS, ...options }; - const defaultValueDeserialized = useMemo(() => deserializer(formDefaultValue), [ - formConfig.defaultValue, - ]); + const { onSubmit, schema, serializer, deserializer, options, id = 'default', defaultValue } = + formConfig ?? {}; + + const formDefaultValue = useMemo(() => { + if (defaultValue === undefined || Object.keys(defaultValue).length === 0) { + return {}; + } + + return Object.entries(defaultValue as object) + .filter(({ 1: value }) => value !== undefined) + .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}); + }, [defaultValue]); + + const { errorDisplayDelay, stripEmptyFields: doStripEmptyFields } = options ?? {}; + const formOptions = useMemo( + () => ({ + stripEmptyFields: doStripEmptyFields ?? DEFAULT_OPTIONS.stripEmptyFields, + errorDisplayDelay: errorDisplayDelay ?? DEFAULT_OPTIONS.errorDisplayDelay, + }), + [errorDisplayDelay, doStripEmptyFields] + ); + + const defaultValueDeserialized = useMemo( + () => (deserializer ? deserializer(formDefaultValue) : formDefaultValue), + [formDefaultValue, deserializer] + ); const [isSubmitted, setIsSubmitted] = useState(false); const [isSubmitting, setSubmitting] = useState(false); @@ -81,55 +87,68 @@ export function useForm( // -- HELPERS // ---------------------------------- - const getFormData$ = (): Subject => { + const getFormData$ = useCallback((): Subject => { if (formData$.current === null) { formData$.current = new Subject({} as T); } return formData$.current; - }; - const fieldsToArray = () => Object.values(fieldsRefs.current); + }, []); - const stripEmptyFields = (fields: FieldsMap): FieldsMap => { - if (formOptions.stripEmptyFields) { - return Object.entries(fields).reduce((acc, [key, field]) => { - if (typeof field.value !== 'string' || field.value.trim() !== '') { - acc[key] = field; - } - return acc; - }, {} as FieldsMap); - } - return fields; - }; + const fieldsToArray = useCallback(() => Object.values(fieldsRefs.current), []); + + const stripEmptyFields = useCallback( + (fields: FieldsMap): FieldsMap => { + if (formOptions.stripEmptyFields) { + return Object.entries(fields).reduce((acc, [key, field]) => { + if (typeof field.value !== 'string' || field.value.trim() !== '') { + acc[key] = field; + } + return acc; + }, {} as FieldsMap); + } + return fields; + }, + [formOptions] + ); + + const updateFormDataAt: FormHook['__updateFormDataAt'] = useCallback( + (path, value) => { + const _formData$ = getFormData$(); + const currentFormData = _formData$.value; + + if (currentFormData[path] !== value) { + _formData$.next({ ...currentFormData, [path]: value }); + } - const updateFormDataAt: FormHook['__updateFormDataAt'] = (path, value) => { - const _formData$ = getFormData$(); - const currentFormData = _formData$.value; - const nextValue = { ...currentFormData, [path]: value }; - _formData$.next(nextValue); - return _formData$.value; - }; + return _formData$.value; + }, + [getFormData$] + ); // -- API // ---------------------------------- - const getFormData: FormHook['getFormData'] = ( - getDataOptions: Parameters['getFormData']>[0] = { unflatten: true } - ) => { - if (getDataOptions.unflatten) { - const nonEmptyFields = stripEmptyFields(fieldsRefs.current); - const fieldsValue = mapFormFields(nonEmptyFields, (field) => field.__serializeOutput()); - return serializer(unflattenObject(fieldsValue)) as T; - } - - return Object.entries(fieldsRefs.current).reduce( - (acc, [key, field]) => ({ - ...acc, - [key]: field.__serializeOutput(), - }), - {} as T - ); - }; + const getFormData: FormHook['getFormData'] = useCallback( + (getDataOptions: Parameters['getFormData']>[0] = { unflatten: true }) => { + if (getDataOptions.unflatten) { + const nonEmptyFields = stripEmptyFields(fieldsRefs.current); + const fieldsValue = mapFormFields(nonEmptyFields, (field) => field.__serializeOutput()); + return serializer + ? (serializer(unflattenObject(fieldsValue)) as T) + : (unflattenObject(fieldsValue) as T); + } - const getErrors: FormHook['getErrors'] = () => { + return Object.entries(fieldsRefs.current).reduce( + (acc, [key, field]) => ({ + ...acc, + [key]: field.__serializeOutput(), + }), + {} as T + ); + }, + [stripEmptyFields, serializer] + ); + + const getErrors: FormHook['getErrors'] = useCallback(() => { if (isValid === true) { return []; } @@ -141,11 +160,15 @@ export function useForm( } return [...acc, fieldError]; }, [] as string[]); - }; + }, [isValid, fieldsToArray]); const isFieldValid = (field: FieldHook) => field.isValid && !field.isValidating; - const updateFormValidity = () => { + const updateFormValidity = useCallback(() => { + if (isUnmounted.current) { + return; + } + const fieldsArray = fieldsToArray(); const areAllFieldsValidated = fieldsArray.every((field) => field.isValidated); @@ -158,176 +181,220 @@ export function useForm( setIsValid(isFormValid); return isFormValid; - }; + }, [fieldsToArray]); - const validateFields: FormHook['__validateFields'] = async (fieldNames) => { - const fieldsToValidate = fieldNames - .map((name) => fieldsRefs.current[name]) - .filter((field) => field !== undefined); + const validateFields: FormHook['__validateFields'] = useCallback( + async (fieldNames) => { + const fieldsToValidate = fieldNames + .map((name) => fieldsRefs.current[name]) + .filter((field) => field !== undefined); - if (fieldsToValidate.length === 0) { - // Nothing to validate - return { areFieldsValid: true, isFormValid: true }; - } + if (fieldsToValidate.length === 0) { + // Nothing to validate + return { areFieldsValid: true, isFormValid: true }; + } - const formData = getFormData({ unflatten: false }); - await Promise.all(fieldsToValidate.map((field) => field.validate({ formData }))); + const formData = getFormData({ unflatten: false }); + await Promise.all(fieldsToValidate.map((field) => field.validate({ formData }))); - const isFormValid = updateFormValidity(); - const areFieldsValid = fieldsToValidate.every(isFieldValid); + const isFormValid = updateFormValidity(); + const areFieldsValid = fieldsToValidate.every(isFieldValid); - return { areFieldsValid, isFormValid }; - }; + return { areFieldsValid, isFormValid }; + }, + [getFormData, updateFormValidity] + ); - const validateAllFields = async (): Promise => { + const validateAllFields = useCallback(async (): Promise => { const fieldsArray = fieldsToArray(); const fieldsToValidate = fieldsArray.filter((field) => !field.isValidated); - let isFormValid: boolean | undefined = isValid; + let isFormValid: boolean | undefined; if (fieldsToValidate.length === 0) { - if (isFormValid === undefined) { - // We should never enter this condition as the form validity is updated each time - // a field is validated. But sometimes, during tests it does not happen and we need - // to wait the next tick (hooks lifecycle being tricky) to make sure the "isValid" state is updated. - // In order to avoid this unintentional behaviour, we add this if condition here. - isFormValid = fieldsArray.every(isFieldValid); - setIsValid(isFormValid); - } + // We should never enter this condition as the form validity is updated each time + // a field is validated. But sometimes, during tests or race conditions it does not happen and we need + // to wait the next tick (hooks lifecycle being tricky) to make sure the "isValid" state is updated. + // In order to avoid this unintentional behaviour, we add this if condition here. + + // TODO: Fix this when adding tests to the form lib. + isFormValid = fieldsArray.every(isFieldValid); + setIsValid(isFormValid); return isFormValid; } ({ isFormValid } = await validateFields(fieldsToValidate.map((field) => field.path))); return isFormValid!; - }; + }, [fieldsToArray, validateFields]); - const addField: FormHook['__addField'] = (field) => { - fieldsRefs.current[field.path] = field; + const addField: FormHook['__addField'] = useCallback( + (field) => { + fieldsRefs.current[field.path] = field; - if (!{}.hasOwnProperty.call(getFormData$().value, field.path)) { - const fieldValue = field.__serializeOutput(); - updateFormDataAt(field.path, fieldValue); - } - }; + if (!{}.hasOwnProperty.call(getFormData$().value, field.path)) { + const fieldValue = field.__serializeOutput(); + updateFormDataAt(field.path, fieldValue); + } + }, + [getFormData$, updateFormDataAt] + ); - const removeField: FormHook['__removeField'] = (_fieldNames) => { - const fieldNames = Array.isArray(_fieldNames) ? _fieldNames : [_fieldNames]; - const currentFormData = { ...getFormData$().value } as FormData; + const removeField: FormHook['__removeField'] = useCallback( + (_fieldNames) => { + const fieldNames = Array.isArray(_fieldNames) ? _fieldNames : [_fieldNames]; + const currentFormData = { ...getFormData$().value } as FormData; - fieldNames.forEach((name) => { - delete fieldsRefs.current[name]; - delete currentFormData[name]; - }); + fieldNames.forEach((name) => { + delete fieldsRefs.current[name]; + delete currentFormData[name]; + }); - getFormData$().next(currentFormData as T); + getFormData$().next(currentFormData as T); - /** - * After removing a field, the form validity might have changed - * (an invalid field might have been removed and now the form is valid) - */ - updateFormValidity(); - }; + /** + * After removing a field, the form validity might have changed + * (an invalid field might have been removed and now the form is valid) + */ + updateFormValidity(); + }, + [getFormData$, updateFormValidity] + ); - const setFieldValue: FormHook['setFieldValue'] = (fieldName, value) => { + const setFieldValue: FormHook['setFieldValue'] = useCallback((fieldName, value) => { if (fieldsRefs.current[fieldName] === undefined) { return; } fieldsRefs.current[fieldName].setValue(value); - }; + }, []); - const setFieldErrors: FormHook['setFieldErrors'] = (fieldName, errors) => { + const setFieldErrors: FormHook['setFieldErrors'] = useCallback((fieldName, errors) => { if (fieldsRefs.current[fieldName] === undefined) { return; } fieldsRefs.current[fieldName].setErrors(errors); - }; + }, []); - const getFields: FormHook['getFields'] = () => fieldsRefs.current; + const getFields: FormHook['getFields'] = useCallback(() => fieldsRefs.current, []); - const getFieldDefaultValue: FormHook['getFieldDefaultValue'] = (fieldName) => - get(defaultValueDeserialized, fieldName); + const getFieldDefaultValue: FormHook['getFieldDefaultValue'] = useCallback( + (fieldName) => get(defaultValueDeserialized, fieldName), + [defaultValueDeserialized] + ); - const readFieldConfigFromSchema: FormHook['__readFieldConfigFromSchema'] = (fieldName) => { - const config = (get(schema ? schema : {}, fieldName) as FieldConfig) || {}; + const readFieldConfigFromSchema: FormHook['__readFieldConfigFromSchema'] = useCallback( + (fieldName) => { + const config = (get(schema ?? {}, fieldName) as FieldConfig) || {}; - return config; - }; + return config; + }, + [schema] + ); - const submitForm: FormHook['submit'] = async (e) => { - if (e) { - e.preventDefault(); - } + const submitForm: FormHook['submit'] = useCallback( + async (e) => { + if (e) { + e.preventDefault(); + } - if (!isSubmitted) { setIsSubmitted(true); // User has attempted to submit the form at least once - } - setSubmitting(true); + setSubmitting(true); - const isFormValid = await validateAllFields(); - const formData = getFormData(); - - if (onSubmit) { - await onSubmit(formData, isFormValid!); - } - - if (isUnmounted.current === false) { - setSubmitting(false); - } + const isFormValid = await validateAllFields(); + const formData = getFormData(); - return { data: formData, isValid: isFormValid! }; - }; + if (onSubmit) { + await onSubmit(formData, isFormValid!); + } - const subscribe: FormHook['subscribe'] = (handler) => { - const subscription = getFormData$().subscribe((raw) => { - if (!isUnmounted.current) { - handler({ isValid, data: { raw, format: getFormData }, validate: validateAllFields }); + if (isUnmounted.current === false) { + setSubmitting(false); } - }); - formUpdateSubscribers.current.push(subscription); + return { data: formData, isValid: isFormValid! }; + }, + [validateAllFields, getFormData, onSubmit] + ); - return { - unsubscribe() { - formUpdateSubscribers.current = formUpdateSubscribers.current.filter( - (sub) => sub !== subscription - ); - return subscription.unsubscribe(); - }, - }; - }; + const subscribe: FormHook['subscribe'] = useCallback( + (handler) => { + const subscription = getFormData$().subscribe((raw) => { + if (!isUnmounted.current) { + handler({ isValid, data: { raw, format: getFormData }, validate: validateAllFields }); + } + }); + + formUpdateSubscribers.current.push(subscription); + + return { + unsubscribe() { + formUpdateSubscribers.current = formUpdateSubscribers.current.filter( + (sub) => sub !== subscription + ); + return subscription.unsubscribe(); + }, + }; + }, + [getFormData$, isValid, getFormData, validateAllFields] + ); /** * Reset all the fields of the form to their default values * and reset all the states to their original value. */ - const reset: FormHook['reset'] = (resetOptions = { resetValues: true }) => { - const { resetValues = true } = resetOptions; - const currentFormData = { ...getFormData$().value } as FormData; - Object.entries(fieldsRefs.current).forEach(([path, field]) => { - // By resetting the form, some field might be unmounted. In order - // to avoid a race condition, we check that the field still exists. - const isFieldMounted = fieldsRefs.current[path] !== undefined; - if (isFieldMounted) { - const fieldValue = field.reset({ resetValue: resetValues }); - currentFormData[path] = fieldValue; + const reset: FormHook['reset'] = useCallback( + (resetOptions = { resetValues: true }) => { + const { resetValues = true } = resetOptions; + const currentFormData = { ...getFormData$().value } as FormData; + Object.entries(fieldsRefs.current).forEach(([path, field]) => { + // By resetting the form, some field might be unmounted. In order + // to avoid a race condition, we check that the field still exists. + const isFieldMounted = fieldsRefs.current[path] !== undefined; + if (isFieldMounted) { + const fieldValue = field.reset({ resetValue: resetValues }) ?? currentFormData[path]; + currentFormData[path] = fieldValue; + } + }); + if (resetValues) { + getFormData$().next(currentFormData as T); } - }); - if (resetValues) { - getFormData$().next(currentFormData as T); - } - setIsSubmitted(false); - setSubmitting(false); - setIsValid(undefined); - }; + setIsSubmitted(false); + setSubmitting(false); + setIsValid(undefined); + }, + [getFormData$] + ); - const form: FormHook = { + const form = useMemo>(() => { + return { + isSubmitted, + isSubmitting, + isValid, + id, + submit: submitForm, + subscribe, + setFieldValue, + setFieldErrors, + getFields, + getFormData, + getErrors, + getFieldDefaultValue, + reset, + __options: formOptions, + __getFormData$: getFormData$, + __updateFormDataAt: updateFormDataAt, + __readFieldConfigFromSchema: readFieldConfigFromSchema, + __addField: addField, + __removeField: removeField, + __validateFields: validateFields, + }; + }, [ isSubmitted, isSubmitting, isValid, id, - submit: submitForm, + submitForm, subscribe, setFieldValue, setFieldErrors, @@ -336,14 +403,14 @@ export function useForm( getErrors, getFieldDefaultValue, reset, - __options: formOptions, - __getFormData$: getFormData$, - __updateFormDataAt: updateFormDataAt, - __readFieldConfigFromSchema: readFieldConfigFromSchema, - __addField: addField, - __removeField: removeField, - __validateFields: validateFields, - }; + formOptions, + getFormData$, + updateFormDataAt, + readFieldConfigFromSchema, + addField, + removeField, + validateFields, + ]); return { form, diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/types.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/types.ts index f11b61edaddf4..7e38a33f0c684 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/types.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/types.ts @@ -107,7 +107,7 @@ export interface FieldHook { errorCode?: string; }) => string | null; onChange: (event: ChangeEvent<{ name?: string; value: string; checked?: boolean }>) => void; - setValue: (value: T) => void; + setValue: (value: T) => T; setErrors: (errors: ValidationError[]) => void; clearErrors: (type?: string | string[]) => void; validate: (validateData?: { @@ -115,7 +115,7 @@ export interface FieldHook { value?: unknown; validationType?: string; }) => FieldValidateResponse | Promise; - reset: (options?: { resetValue: boolean }) => unknown; + reset: (options?: { resetValue: boolean }) => unknown | undefined; __serializeOutput: (rawValue?: unknown) => unknown; } diff --git a/src/plugins/maps_legacy/public/map/service_settings.d.ts b/src/plugins/maps_legacy/public/map/service_settings.d.ts index e265accaeb8fd..105836ff25f8b 100644 --- a/src/plugins/maps_legacy/public/map/service_settings.d.ts +++ b/src/plugins/maps_legacy/public/map/service_settings.d.ts @@ -48,4 +48,5 @@ export interface IServiceSettings { getEMSHotLink(layer: FileLayer): Promise; getTMSServices(): Promise; getFileLayers(): Promise; + getUrlForRegionLayer(layer: FileLayer): Promise; } diff --git a/src/plugins/ui_actions/kibana.json b/src/plugins/ui_actions/kibana.json index 7b24b3cc5c48b..337c5ddf0fd5c 100644 --- a/src/plugins/ui_actions/kibana.json +++ b/src/plugins/ui_actions/kibana.json @@ -7,6 +7,7 @@ "public/tests/test_samples" ], "requiredBundles": [ + "kibanaUtils", "kibanaReact" ] } diff --git a/src/plugins/ui_actions/public/actions/action.ts b/src/plugins/ui_actions/public/actions/action.ts index f5dbbc9f923ac..bc5f36acb8f0c 100644 --- a/src/plugins/ui_actions/public/actions/action.ts +++ b/src/plugins/ui_actions/public/actions/action.ts @@ -68,6 +68,13 @@ export interface Action * Executes the action. */ execute(context: Context): Promise; + + /** + * Determines if action should be executed automatically, + * without first showing up in context menu. + * false by default. + */ + shouldAutoExecute?(context: Context): Promise; } /** @@ -89,6 +96,13 @@ export interface ActionDefinition * Executes the action. */ execute(context: Context): Promise; + + /** + * Determines if action should be executed automatically, + * without first showing up in context menu. + * false by default. + */ + shouldAutoExecute?(context: Context): Promise; } export type ActionContext = A extends ActionDefinition ? Context : never; diff --git a/src/plugins/ui_actions/public/actions/action_internal.ts b/src/plugins/ui_actions/public/actions/action_internal.ts index 10eb760b13089..a22b3fa5b0367 100644 --- a/src/plugins/ui_actions/public/actions/action_internal.ts +++ b/src/plugins/ui_actions/public/actions/action_internal.ts @@ -65,4 +65,9 @@ export class ActionInternal if (!this.definition.getHref) return undefined; return await this.definition.getHref(context); } + + public async shouldAutoExecute(context: Context): Promise { + if (!this.definition.shouldAutoExecute) return false; + return this.definition.shouldAutoExecute(context); + } } diff --git a/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.tsx b/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.tsx index 74e9ef96b575b..7b87a5992a7f5 100644 --- a/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.tsx +++ b/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.tsx @@ -23,28 +23,28 @@ import _ from 'lodash'; import { i18n } from '@kbn/i18n'; import { uiToReactComponent } from '../../../kibana_react/public'; import { Action } from '../actions'; +import { BaseContext } from '../types'; export const defaultTitle = i18n.translate('uiActions.actionPanel.title', { defaultMessage: 'Options', }); +type ActionWithContext = [Action, Context]; + /** * Transforms an array of Actions to the shape EuiContextMenuPanel expects. */ -export async function buildContextMenuForActions({ +export async function buildContextMenuForActions({ actions, - actionContext, title = defaultTitle, closeMenu, }: { - actions: Array>; - actionContext: Context; + actions: ActionWithContext[]; title?: string; closeMenu: () => void; }): Promise { - const menuItems = await buildEuiContextMenuPanelItems({ + const menuItems = await buildEuiContextMenuPanelItems({ actions, - actionContext, closeMenu, }); @@ -58,17 +58,15 @@ export async function buildContextMenuForActions({ /** * Transform an array of Actions into the shape needed to build an EUIContextMenu */ -async function buildEuiContextMenuPanelItems({ +async function buildEuiContextMenuPanelItems({ actions, - actionContext, closeMenu, }: { - actions: Array>; - actionContext: Context; + actions: ActionWithContext[]; closeMenu: () => void; }) { const items: EuiContextMenuPanelItemDescriptor[] = new Array(actions.length); - const promises = actions.map(async (action, index) => { + const promises = actions.map(async ([action, actionContext], index) => { const isCompatible = await action.isCompatible(actionContext); if (!isCompatible) { return; diff --git a/src/plugins/ui_actions/public/service/ui_actions_execution_service.ts b/src/plugins/ui_actions/public/service/ui_actions_execution_service.ts new file mode 100644 index 0000000000000..7393989672e9d --- /dev/null +++ b/src/plugins/ui_actions/public/service/ui_actions_execution_service.ts @@ -0,0 +1,121 @@ +/* + * 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 { uniqBy } from 'lodash'; +import { Action } from '../actions'; +import { BaseContext } from '../types'; +import { defer as createDefer, Defer } from '../../../kibana_utils/public'; +import { buildContextMenuForActions, openContextMenu } from '../context_menu'; +import { Trigger } from '../triggers'; + +interface ExecuteActionTask { + action: Action; + context: BaseContext; + trigger: Trigger; + defer: Defer; +} + +export class UiActionsExecutionService { + private readonly batchingQueue: ExecuteActionTask[] = []; + private readonly pendingTasks = new Set(); + + constructor() {} + + async execute({ + action, + context, + trigger, + }: { + action: Action; + context: BaseContext; + trigger: Trigger; + }): Promise { + const shouldBatch = !(await action.shouldAutoExecute?.(context)) ?? false; + const task: ExecuteActionTask = { + action, + context, + trigger, + defer: createDefer(), + }; + + if (shouldBatch) { + this.batchingQueue.push(task); + } else { + this.pendingTasks.add(task); + try { + await action.execute(context); + this.pendingTasks.delete(task); + } catch (e) { + this.pendingTasks.delete(task); + throw new Error(e); + } + } + + this.scheduleFlush(); + + return task.defer.promise; + } + + private scheduleFlush() { + /** + * Have to delay at least until next macro task + * Otherwise chain: + * Trigger -> await action.execute() -> trigger -> action + * isn't batched + * + * This basically needed to support a chain of scheduled micro tasks (async/awaits) within uiActions code + */ + setTimeout(() => { + if (this.pendingTasks.size === 0) { + const tasks = uniqBy(this.batchingQueue, (t) => t.action.id); + if (tasks.length === 1) { + this.executeSingleTask(tasks[0]); + } + if (tasks.length > 1) { + this.executeMultipleActions(tasks); + } + + this.batchingQueue.splice(0, this.batchingQueue.length); + } + }, 0); + } + + private async executeSingleTask({ context, action, defer }: ExecuteActionTask) { + try { + await action.execute(context); + defer.resolve(); + } catch (e) { + defer.reject(e); + } + } + + private async executeMultipleActions(tasks: ExecuteActionTask[]) { + const panel = await buildContextMenuForActions({ + actions: tasks.map(({ action, context }) => [action, context]), + title: tasks[0].trigger.title, // title of context menu is title of trigger which originated the chain + closeMenu: () => { + tasks.forEach((t) => t.defer.resolve()); + session.close(); + }, + }); + const session = openContextMenu([panel], { + 'data-test-subj': 'multipleActionsContextMenu', + }); + } +} diff --git a/src/plugins/ui_actions/public/service/ui_actions_service.ts b/src/plugins/ui_actions/public/service/ui_actions_service.ts index 11f5769a94648..08efffbb6b5a8 100644 --- a/src/plugins/ui_actions/public/service/ui_actions_service.ts +++ b/src/plugins/ui_actions/public/service/ui_actions_service.ts @@ -28,6 +28,7 @@ import { ActionInternal, Action, ActionDefinition, ActionContext } from '../acti import { Trigger, TriggerContext } from '../triggers/trigger'; import { TriggerInternal } from '../triggers/trigger_internal'; import { TriggerContract } from '../triggers/trigger_contract'; +import { UiActionsExecutionService } from './ui_actions_execution_service'; export interface UiActionsServiceParams { readonly triggers?: TriggerRegistry; @@ -40,6 +41,7 @@ export interface UiActionsServiceParams { } export class UiActionsService { + public readonly executionService = new UiActionsExecutionService(); protected readonly triggers: TriggerRegistry; protected readonly actions: ActionRegistry; protected readonly triggerToActions: TriggerToActionsRegistry; diff --git a/src/plugins/ui_actions/public/tests/execute_trigger_actions.test.ts b/src/plugins/ui_actions/public/tests/execute_trigger_actions.test.ts index 983c6796eeb09..9af46f25b4fec 100644 --- a/src/plugins/ui_actions/public/tests/execute_trigger_actions.test.ts +++ b/src/plugins/ui_actions/public/tests/execute_trigger_actions.test.ts @@ -22,6 +22,7 @@ import { openContextMenu } from '../context_menu'; import { uiActionsPluginMock } from '../mocks'; import { Trigger } from '../triggers'; import { TriggerId, ActionType } from '../types'; +import { wait } from '@testing-library/dom'; jest.mock('../context_menu'); @@ -36,13 +37,15 @@ const TEST_ACTION_TYPE = 'TEST_ACTION_TYPE' as ActionType; function createTestAction( type: string, - checkCompatibility: (context: C) => boolean + checkCompatibility: (context: C) => boolean, + autoExecutable = false ): Action { return createAction({ type: type as ActionType, id: type, isCompatible: (context: C) => Promise.resolve(checkCompatibility(context)), execute: (context) => executeFn(context), + shouldAutoExecute: () => Promise.resolve(autoExecutable), }); } @@ -57,6 +60,7 @@ const reset = () => { executeFn.mockReset(); openContextMenuSpy.mockReset(); + jest.useFakeTimers(); }; beforeEach(reset); @@ -75,6 +79,8 @@ test('executes a single action mapped to a trigger', async () => { const start = doStart(); await start.executeTriggerActions('MY-TRIGGER' as TriggerId, context); + jest.runAllTimers(); + expect(executeFn).toBeCalledTimes(1); expect(executeFn).toBeCalledWith(context); }); @@ -117,6 +123,8 @@ test('does not execute an incompatible action', async () => { }; await start.executeTriggerActions('MY-TRIGGER' as TriggerId, context); + jest.runAllTimers(); + expect(executeFn).toBeCalledTimes(1); }); @@ -139,8 +147,12 @@ test('shows a context menu when more than one action is mapped to a trigger', as const context = {}; await start.executeTriggerActions('MY-TRIGGER' as TriggerId, context); - expect(executeFn).toBeCalledTimes(0); - expect(openContextMenu).toHaveBeenCalledTimes(1); + jest.runAllTimers(); + + await wait(() => { + expect(executeFn).toBeCalledTimes(0); + expect(openContextMenu).toHaveBeenCalledTimes(1); + }); }); test('passes whole action context to isCompatible()', async () => { @@ -161,4 +173,32 @@ test('passes whole action context to isCompatible()', async () => { const context = { foo: 'bar' }; await start.executeTriggerActions('MY-TRIGGER' as TriggerId, context); + jest.runAllTimers(); +}); + +test("doesn't show a context menu for auto executable actions", async () => { + const { setup, doStart } = uiActions; + const trigger: Trigger = { + id: 'MY-TRIGGER' as TriggerId, + title: 'My trigger', + }; + const action1 = createTestAction('test1', () => true, true); + const action2 = createTestAction('test2', () => true, false); + + setup.registerTrigger(trigger); + setup.addTriggerAction(trigger.id, action1); + setup.addTriggerAction(trigger.id, action2); + + expect(openContextMenu).toHaveBeenCalledTimes(0); + + const start = doStart(); + const context = {}; + await start.executeTriggerActions('MY-TRIGGER' as TriggerId, context); + + jest.runAllTimers(); + + await wait(() => { + expect(executeFn).toBeCalledTimes(2); + expect(openContextMenu).toHaveBeenCalledTimes(0); + }); }); diff --git a/src/plugins/ui_actions/public/triggers/trigger_internal.ts b/src/plugins/ui_actions/public/triggers/trigger_internal.ts index e499c404ae745..c91468d31add5 100644 --- a/src/plugins/ui_actions/public/triggers/trigger_internal.ts +++ b/src/plugins/ui_actions/public/triggers/trigger_internal.ts @@ -20,8 +20,6 @@ import { Trigger } from './trigger'; import { TriggerContract } from './trigger_contract'; import { UiActionsService } from '../service'; -import { Action } from '../actions'; -import { buildContextMenuForActions, openContextMenu } from '../context_menu'; import { TriggerId, TriggerContextMapping } from '../types'; /** @@ -43,33 +41,14 @@ export class TriggerInternal { ); } - if (actions.length === 1) { - await this.executeSingleAction(actions[0], context); - return; - } - - await this.executeMultipleActions(actions, context); - } - - private async executeSingleAction( - action: Action, - context: TriggerContextMapping[T] - ) { - await action.execute(context); - } - - private async executeMultipleActions( - actions: Array>, - context: TriggerContextMapping[T] - ) { - const panel = await buildContextMenuForActions({ - actions, - actionContext: context, - title: this.trigger.title, - closeMenu: () => session.close(), - }); - const session = openContextMenu([panel], { - 'data-test-subj': 'multipleActionsContextMenu', - }); + await Promise.all([ + actions.map((action) => + this.service.executionService.execute({ + action, + context, + trigger: this.trigger, + }) + ), + ]); } } diff --git a/src/plugins/ui_actions/public/types.ts b/src/plugins/ui_actions/public/types.ts index 9fcd8a32881df..5631441cf9a1b 100644 --- a/src/plugins/ui_actions/public/types.ts +++ b/src/plugins/ui_actions/public/types.ts @@ -19,10 +19,9 @@ import { ActionInternal } from './actions/action_internal'; import { TriggerInternal } from './triggers/trigger_internal'; -import { Filter } from '../../data/public'; import { SELECT_RANGE_TRIGGER, VALUE_CLICK_TRIGGER, APPLY_FILTER_TRIGGER } from './triggers'; -import { IEmbeddable } from '../../embeddable/public'; -import { RangeSelectContext, ValueClickContext } from '../../embeddable/public'; +import type { RangeSelectContext, ValueClickContext } from '../../embeddable/public'; +import type { ApplyGlobalFilterActionContext } from '../../data/public'; export type TriggerRegistry = Map>; export type ActionRegistry = Map; @@ -39,10 +38,7 @@ export interface TriggerContextMapping { [DEFAULT_TRIGGER]: TriggerContext; [SELECT_RANGE_TRIGGER]: RangeSelectContext; [VALUE_CLICK_TRIGGER]: ValueClickContext; - [APPLY_FILTER_TRIGGER]: { - embeddable: IEmbeddable; - filters: Filter[]; - }; + [APPLY_FILTER_TRIGGER]: ApplyGlobalFilterActionContext; } const DEFAULT_ACTION = ''; diff --git a/src/plugins/vis_type_vega/public/components/vega_vis_editor.tsx b/src/plugins/vis_type_vega/public/components/vega_vis_editor.tsx index 1da5e7544850a..5e770fcff556d 100644 --- a/src/plugins/vis_type_vega/public/components/vega_vis_editor.tsx +++ b/src/plugins/vis_type_vega/public/components/vega_vis_editor.tsx @@ -20,7 +20,6 @@ import React, { useCallback } from 'react'; import { EuiCodeEditor } from '@elastic/eui'; import compactStringify from 'json-stringify-pretty-compact'; -// @ts-ignore import hjson from 'hjson'; import 'brace/mode/hjson'; import { i18n } from '@kbn/i18n'; @@ -45,7 +44,11 @@ const hjsonStringifyOptions = { keepWsc: true, }; -function format(value: string, stringify: typeof compactStringify, options?: any) { +function format( + value: string, + stringify: typeof hjson.stringify | typeof compactStringify, + options?: any +) { try { const spec = hjson.parse(value, { legacyRoot: false, keepWsc: true }); return stringify(spec, options); diff --git a/src/plugins/vis_type_vega/public/data_model/ems_file_parser.js b/src/plugins/vis_type_vega/public/data_model/ems_file_parser.ts similarity index 86% rename from src/plugins/vis_type_vega/public/data_model/ems_file_parser.js rename to src/plugins/vis_type_vega/public/data_model/ems_file_parser.ts index ecdf6a43d5287..59256d47de97c 100644 --- a/src/plugins/vis_type_vega/public/data_model/ems_file_parser.js +++ b/src/plugins/vis_type_vega/public/data_model/ems_file_parser.ts @@ -18,14 +18,20 @@ */ import { i18n } from '@kbn/i18n'; +// @ts-ignore import { bypassExternalUrlCheck } from '../vega_view/vega_base_view'; +import { IServiceSettings, FileLayer } from '../../../maps_legacy/public'; +import { Data, UrlObject, Requests } from './types'; /** * This class processes all Vega spec customizations, * converting url object parameters into query results. */ export class EmsFileParser { - constructor(serviceSettings) { + _serviceSettings: IServiceSettings; + _fileLayersP?: Promise; + + constructor(serviceSettings: IServiceSettings) { this._serviceSettings = serviceSettings; } @@ -33,7 +39,7 @@ export class EmsFileParser { /** * Update request object, expanding any context-aware keywords */ - parseUrl(obj, url) { + parseUrl(obj: Data, url: UrlObject) { if (typeof url.name !== 'string') { throw new Error( i18n.translate('visTypeVega.emsFileParser.missingNameOfFileErrorMessage', { @@ -59,13 +65,13 @@ export class EmsFileParser { * @param {object[]} requests each object is generated by parseUrl() * @returns {Promise} */ - async populateData(requests) { + async populateData(requests: Requests[]) { if (requests.length === 0) return; const layers = await this._fileLayersP; for (const { obj, name } of requests) { - const foundLayer = layers.find((v) => v.name === name); + const foundLayer = layers?.find((v) => v.name === name); if (!foundLayer) { throw new Error( i18n.translate('visTypeVega.emsFileParser.emsFileNameDoesNotExistErrorMessage', { diff --git a/src/plugins/vis_type_vega/public/data_model/es_query_parser.js b/src/plugins/vis_type_vega/public/data_model/es_query_parser.ts similarity index 87% rename from src/plugins/vis_type_vega/public/data_model/es_query_parser.js rename to src/plugins/vis_type_vega/public/data_model/es_query_parser.ts index f7772ff888a61..4fdd68f9e9dbe 100644 --- a/src/plugins/vis_type_vega/public/data_model/es_query_parser.js +++ b/src/plugins/vis_type_vega/public/data_model/es_query_parser.ts @@ -19,24 +19,38 @@ import moment from 'moment'; import { i18n } from '@kbn/i18n'; -import { isPlainObject, cloneDeep } from 'lodash'; +import { cloneDeep, isPlainObject } from 'lodash'; +import { SearchParams } from 'elasticsearch'; +import { TimeCache } from './time_cache'; +import { SearchAPI } from './search_api'; +import { Opts, Type, Data, UrlObject, Bool, Requests, Query, ContextVarsObject } from './types'; -const TIMEFILTER = '%timefilter%'; -const AUTOINTERVAL = '%autointerval%'; -const MUST_CLAUSE = '%dashboard_context-must_clause%'; -const FILTER_CLAUSE = '%dashboard_context-filter_clause%'; -const MUST_NOT_CLAUSE = '%dashboard_context-must_not_clause%'; +const TIMEFILTER: string = '%timefilter%'; +const AUTOINTERVAL: string = '%autointerval%'; +const MUST_CLAUSE: string = '%dashboard_context-must_clause%'; +const MUST_NOT_CLAUSE: string = '%dashboard_context-must_not_clause%'; +const FILTER_CLAUSE: string = '%dashboard_context-filter_clause%'; // These values may appear in the 'url': { ... } object -const LEGACY_CONTEXT = '%context_query%'; -const CONTEXT = '%context%'; -const TIMEFIELD = '%timefield%'; +const LEGACY_CONTEXT: string = '%context_query%'; +const CONTEXT: string = '%context%'; +const TIMEFIELD: string = '%timefield%'; /** * This class parses ES requests specified in the data.url objects. */ export class EsQueryParser { - constructor(timeCache, searchAPI, filters, onWarning) { + _timeCache: TimeCache; + _searchAPI: SearchAPI; + _filters: Bool; + _onWarning: (...args: string[]) => void; + + constructor( + timeCache: TimeCache, + searchAPI: SearchAPI, + filters: Bool, + onWarning: (...args: string[]) => void + ) { this._timeCache = timeCache; this._searchAPI = searchAPI; this._filters = filters; @@ -47,7 +61,7 @@ export class EsQueryParser { /** * Update request object, expanding any context-aware keywords */ - parseUrl(dataObject, url) { + parseUrl(dataObject: Data, url: UrlObject) { let body = url.body; let context = url[CONTEXT]; delete url[CONTEXT]; @@ -167,13 +181,13 @@ export class EsQueryParser { // Use dashboard context const newQuery = cloneDeep(this._filters); if (timefield) { - newQuery.bool.must.push(body.query); + newQuery.bool!.must!.push(body.query); } body.query = newQuery; } } - this._injectContextVars(body.aggs, false); + this._injectContextVars(body.aggs!, false); return { dataObject, url }; } @@ -182,8 +196,8 @@ export class EsQueryParser { * @param {object[]} requests each object is generated by parseUrl() * @returns {Promise} */ - async populateData(requests) { - const esSearches = requests.map((r) => r.url); + async populateData(requests: Requests[]) { + const esSearches = requests.map((r: Requests) => r.url); const data$ = this._searchAPI.search(esSearches); const results = await data$.toPromise(); @@ -198,7 +212,7 @@ export class EsQueryParser { * @param {*} obj * @param {boolean} isQuery - if true, the `obj` belongs to the req's query portion */ - _injectContextVars(obj, isQuery) { + _injectContextVars(obj: Query | SearchParams['body']['aggs'], isQuery: boolean) { if (obj && typeof obj === 'object') { if (Array.isArray(obj)) { // For arrays, replace MUST_CLAUSE and MUST_NOT_CLAUSE string elements @@ -239,7 +253,7 @@ export class EsQueryParser { } } else { for (const prop of Object.keys(obj)) { - const subObj = obj[prop]; + const subObj = (obj as ContextVarsObject)[prop]; if (!subObj || typeof obj !== 'object') continue; // replace "interval": { "%autointerval%": true|integer } with @@ -260,7 +274,9 @@ export class EsQueryParser { ); } const bounds = this._timeCache.getTimeBounds(); - obj.interval = EsQueryParser._roundInterval((bounds.max - bounds.min) / size); + (obj as ContextVarsObject).interval = EsQueryParser._roundInterval( + (bounds.max - bounds.min) / size + ); continue; } @@ -269,7 +285,7 @@ export class EsQueryParser { case 'min': case 'max': // Replace {"%timefilter%": "min|max", ...} object with a timestamp - obj[prop] = this._getTimeBound(subObj, subObj[TIMEFILTER]); + (obj as ContextVarsObject)[prop] = this._getTimeBound(subObj, subObj[TIMEFILTER]); continue; case true: // Replace {"%timefilter%": true, ...} object with the "range" object @@ -302,7 +318,7 @@ export class EsQueryParser { * @param {object} obj * @return {object} */ - _createRangeFilter(obj) { + _createRangeFilter(obj: Opts) { obj.gte = moment(this._getTimeBound(obj, 'min')).toISOString(); obj.lte = moment(this._getTimeBound(obj, 'max')).toISOString(); obj.format = 'strict_date_optional_time'; @@ -320,9 +336,9 @@ export class EsQueryParser { * @param {'min'|'max'} type * @returns {*} */ - _getTimeBound(opts, type) { + _getTimeBound(opts: Opts, type: Type): number { const bounds = this._timeCache.getTimeBounds(); - let result = bounds[type]; + let result = bounds[type]?.valueOf() || 0; if (opts.shift) { const shift = opts.shift; @@ -380,7 +396,7 @@ export class EsQueryParser { * @param interval (ms) * @returns {string} */ - static _roundInterval(interval) { + static _roundInterval(interval: number): string { switch (true) { case interval <= 500: // <= 0.5s return '100ms'; diff --git a/src/plugins/vis_type_vega/public/data_model/time_cache.js b/src/plugins/vis_type_vega/public/data_model/time_cache.ts similarity index 79% rename from src/plugins/vis_type_vega/public/data_model/time_cache.js rename to src/plugins/vis_type_vega/public/data_model/time_cache.ts index cf241655592f3..27012d3cdc6c2 100644 --- a/src/plugins/vis_type_vega/public/data_model/time_cache.js +++ b/src/plugins/vis_type_vega/public/data_model/time_cache.ts @@ -17,26 +17,36 @@ * under the License. */ +import { TimefilterContract } from '../../../data/public'; +import { TimeRange } from '../../../data/common'; +import { CacheBounds } from './types'; + /** * Optimization caching - always return the same value if queried within this time * @type {number} */ -const AlwaysCacheMaxAge = 40; + +const AlwaysCacheMaxAge: number = 40; /** * This class caches timefilter's bounds to minimize number of server requests */ export class TimeCache { - constructor(timefilter, maxAge) { + _timefilter: TimefilterContract; + _maxAge: number; + _cachedBounds?: CacheBounds; + _cacheTS: number; + _timeRange?: TimeRange; + + constructor(timefilter: TimefilterContract, maxAge: number) { this._timefilter = timefilter; this._maxAge = maxAge; - this._cachedBounds = null; this._cacheTS = 0; } // Simplifies unit testing // noinspection JSMethodCanBeStatic - _now() { + _now(): number { return Date.now(); } @@ -44,10 +54,10 @@ export class TimeCache { * Get cached time range values * @returns {{min: number, max: number}} */ - getTimeBounds() { + getTimeBounds(): CacheBounds { const ts = this._now(); - let bounds; + let bounds: CacheBounds | null = null; if (this._cachedBounds) { const diff = ts - this._cacheTS; @@ -76,7 +86,7 @@ export class TimeCache { return this._cachedBounds; } - setTimeRange(timeRange) { + setTimeRange(timeRange: TimeRange): void { this._timeRange = timeRange; } @@ -85,11 +95,11 @@ export class TimeCache { * @returns {{min: number, max: number}} * @private */ - _getBounds() { - const bounds = this._timefilter.calculateBounds(this._timeRange); + _getBounds(): CacheBounds { + const bounds = this._timefilter.calculateBounds(this._timeRange!); return { - min: bounds.min.valueOf(), - max: bounds.max.valueOf(), + min: bounds.min!.valueOf(), + max: bounds.max!.valueOf(), }; } } diff --git a/src/plugins/vis_type_vega/public/data_model/types.ts b/src/plugins/vis_type_vega/public/data_model/types.ts new file mode 100644 index 0000000000000..9876faf0fc88f --- /dev/null +++ b/src/plugins/vis_type_vega/public/data_model/types.ts @@ -0,0 +1,246 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { SearchResponse, SearchParams } from 'elasticsearch'; +import { Filter } from 'src/plugins/data/public'; +import { DslQuery } from 'src/plugins/data/common'; +import { EsQueryParser } from './es_query_parser'; +import { EmsFileParser } from './ems_file_parser'; +import { UrlParser } from './url_parser'; + +interface Body { + aggs?: SearchParams['body']['aggs']; + query?: Query; + timeout?: string; +} + +interface Coordinate { + axis: { + title: string; + }; + field: string; +} + +interface Encoding { + x: Coordinate; + y: Coordinate; +} + +interface AutoSize { + type: string; + contains: string; +} + +interface Padding { + left: number; + right: number; + top: number; + bottom: number; +} + +interface Mark { + color?: string; + fill?: string; +} + +type Renderer = 'svg' | 'canvas'; + +interface VegaSpecConfig extends KibanaConfig { + kibana: KibanaConfig; + padding: Padding; + projection: Projection; + autosize: AutoSize; + tooltips: TooltipConfig; + mark: Mark; +} + +interface Projection { + name: string; +} + +interface RequestDataObject { + values: SearchResponse; +} + +interface RequestObject { + url: string; +} + +type ContextVarsObjectProps = + | string + | { + [CONSTANTS.AUTOINTERVAL]: number; + }; + +type ToolTipPositions = 'top' | 'right' | 'bottom' | 'left'; + +export interface KibanaConfig { + controlsLocation: ControlsLocation; + controlsDirection: ControlsDirection; + hideWarnings: boolean; + type: string; + renderer: Renderer; +} + +export interface VegaSpec { + [index: string]: any; + $schema: string; + data?: Data; + encoding?: Encoding; + mark?: string; + title?: string; + autosize: AutoSize; + projections: Projection[]; + width?: number; + height?: number; + padding?: number | Padding; + _hostConfig?: KibanaConfig; + config: VegaSpecConfig; +} + +export enum CONSTANTS { + TIMEFILTER = '%timefilter%', + CONTEXT = '%context%', + LEGACY_CONTEXT = '%context_query%', + TYPE = '%type%', + SYMBOL = 'Symbol(vega_id)', + AUTOINTERVAL = '%auautointerval%', +} + +export interface Opts { + [index: string]: any; + [CONSTANTS.TIMEFILTER]?: boolean; + gte?: string; + lte?: string; + format?: string; + shift?: number; + unit?: string; +} + +export type Type = 'min' | 'max'; + +export interface TimeBucket { + key_as_string: string; + key: number; + doc_count: number; + [CONSTANTS.SYMBOL]: number; +} + +export interface Bool { + [index: string]: any; + bool?: Bool; + must?: DslQuery[]; + filter?: Filter[]; + should?: never[]; + must_not?: Filter[]; +} + +export interface Query { + range?: { [x: number]: Opts }; + bool?: Bool; +} + +export interface UrlObject { + [index: string]: any; + [CONSTANTS.TIMEFILTER]?: string; + [CONSTANTS.CONTEXT]?: boolean; + [CONSTANTS.LEGACY_CONTEXT]?: string; + [CONSTANTS.TYPE]?: string; + name?: string; + index?: string; + body?: Body; + size?: number; + timeout?: string; +} + +export interface Data { + [index: string]: any; + url?: UrlObject; + values?: unknown; + source?: unknown; +} + +export interface CacheOptions { + max: number; + maxAge: number; +} + +export interface CacheBounds { + min: number; + max: number; +} + +export interface Requests extends RequestObject { + obj: RequestObject; + name: string; + dataObject: RequestDataObject; +} + +export interface ContextVarsObject { + [index: string]: any; + prop: ContextVarsObjectProps; + interval: string; +} + +export interface TooltipConfig { + position?: ToolTipPositions; + padding?: number | Padding; + centerOnMark?: boolean | number; +} + +export interface DstObj { + [index: string]: any; + type?: string; + latitude?: number; + longitude?: number; + zoom?: number; + mapStyle?: string | boolean; + minZoom?: number; + maxZoom?: number; + zoomControl?: boolean; + scrollWheelZoom?: boolean; + delayRepaint?: boolean; +} + +export type ControlsLocation = 'row' | 'column' | 'row-reverse' | 'column-reverse'; + +export type ControlsDirection = 'horizontal' | 'vertical'; + +export interface VegaConfig extends DstObj { + [index: string]: any; + maxBounds?: number; + tooltips?: TooltipConfig | boolean; + controlsLocation?: ControlsLocation; + controlsDirection?: ControlsDirection; +} + +export interface UrlParserConfig { + [index: string]: any; + elasticsearch: EsQueryParser; + emsfile: EmsFileParser; + url: UrlParser; +} + +export interface PendingType { + [index: string]: any; + dataObject?: Data; + obj?: Data; + url?: UrlObject; + name?: string; +} diff --git a/src/plugins/vis_type_vega/public/data_model/url_parser.js b/src/plugins/vis_type_vega/public/data_model/url_parser.ts similarity index 92% rename from src/plugins/vis_type_vega/public/data_model/url_parser.js rename to src/plugins/vis_type_vega/public/data_model/url_parser.ts index 9a30f12e08232..a27376bf25061 100644 --- a/src/plugins/vis_type_vega/public/data_model/url_parser.js +++ b/src/plugins/vis_type_vega/public/data_model/url_parser.ts @@ -19,13 +19,15 @@ import $ from 'jquery'; import { i18n } from '@kbn/i18n'; +import { UrlObject } from './types'; /** * This class processes all Vega spec customizations, * converting url object parameters into query results. */ export class UrlParser { - constructor(onWarning) { + _onWarning: (...args: string[]) => void; + constructor(onWarning: (...args: string[]) => void) { this._onWarning = onWarning; } @@ -33,7 +35,7 @@ export class UrlParser { /** * Update request object */ - parseUrl(obj, urlObj) { + parseUrl(obj: UrlObject, urlObj: UrlObject) { let url = urlObj.url; if (!url) { throw new Error( diff --git a/src/plugins/vis_type_vega/public/data_model/utils.js b/src/plugins/vis_type_vega/public/data_model/utils.ts similarity index 75% rename from src/plugins/vis_type_vega/public/data_model/utils.js rename to src/plugins/vis_type_vega/public/data_model/utils.ts index 9cf5e36b81294..4d24b1237daeb 100644 --- a/src/plugins/vis_type_vega/public/data_model/utils.js +++ b/src/plugins/vis_type_vega/public/data_model/utils.ts @@ -23,13 +23,14 @@ export class Utils { /** * If the 2nd array parameter in args exists, append it to the warning/error string value */ - static formatWarningToStr(value) { - if (arguments.length >= 2) { + static formatWarningToStr(...args: any[]) { + let value = args[0]; + if (args.length >= 2) { try { - if (typeof arguments[1] === 'string') { - value += `\n${arguments[1]}`; + if (typeof args[1] === 'string') { + value += `\n${args[1]}`; } else { - value += '\n' + compactStringify(arguments[1], { maxLength: 70 }); + value += '\n' + compactStringify(args[1], { maxLength: 70 }); } } catch (err) { // ignore @@ -38,12 +39,13 @@ export class Utils { return value; } - static formatErrorToStr(error) { + static formatErrorToStr(...args: any[]) { + let error: Error | string = args[0]; if (!error) { error = 'ERR'; } else if (error instanceof Error) { error = error.message; } - return Utils.formatWarningToStr(error, ...Array.from(arguments).slice(1)); + return Utils.formatWarningToStr(error, ...Array.from(args).slice(1)); } } diff --git a/src/plugins/vis_type_vega/public/data_model/vega_parser.js b/src/plugins/vis_type_vega/public/data_model/vega_parser.ts similarity index 74% rename from src/plugins/vis_type_vega/public/data_model/vega_parser.js rename to src/plugins/vis_type_vega/public/data_model/vega_parser.ts index 377567e47ced8..17166e1540755 100644 --- a/src/plugins/vis_type_vega/public/data_model/vega_parser.js +++ b/src/plugins/vis_type_vega/public/data_model/vega_parser.ts @@ -18,34 +18,78 @@ */ import _ from 'lodash'; -import { vega, vegaLite } from '../lib/vega'; import schemaParser from 'vega-schema-url-parser'; import versionCompare from 'compare-versions'; -import { EsQueryParser } from './es_query_parser'; import hjson from 'hjson'; +import { VISUALIZATION_COLORS } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +// @ts-ignore +import { vega, vegaLite } from '../lib/vega'; +import { EsQueryParser } from './es_query_parser'; import { Utils } from './utils'; import { EmsFileParser } from './ems_file_parser'; import { UrlParser } from './url_parser'; -import { VISUALIZATION_COLORS } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; +import { SearchAPI } from './search_api'; +import { TimeCache } from './time_cache'; +import { IServiceSettings } from '../../../maps_legacy/public'; +import { + Bool, + Data, + VegaSpec, + VegaConfig, + TooltipConfig, + DstObj, + UrlParserConfig, + PendingType, + ControlsLocation, + ControlsDirection, + KibanaConfig, +} from './types'; // Set default single color to match other Kibana visualizations -const defaultColor = VISUALIZATION_COLORS[0]; -const locToDirMap = { +const defaultColor: string = VISUALIZATION_COLORS[0]; + +const locToDirMap: Record = { left: 'row-reverse', right: 'row', top: 'column-reverse', bottom: 'column', }; -const DEFAULT_SCHEMA = 'https://vega.github.io/schema/vega/v5.json'; +const DEFAULT_SCHEMA: string = 'https://vega.github.io/schema/vega/v5.json'; // If there is no "%type%" parameter, use this parser -const DEFAULT_PARSER = 'elasticsearch'; +const DEFAULT_PARSER: string = 'elasticsearch'; export class VegaParser { - constructor(spec, searchAPI, timeCache, filters, serviceSettings) { - this.spec = spec; + spec: VegaSpec; + hideWarnings: boolean; + error?: string; + warnings: string[]; + _urlParsers: UrlParserConfig; + isVegaLite?: boolean; + useHover?: boolean; + _config?: VegaConfig; + useMap?: boolean; + renderer?: string; + tooltips?: boolean | TooltipConfig; + mapConfig?: object; + vlspec?: VegaSpec; + useResize?: boolean; + paddingWidth?: number; + paddingHeight?: number; + containerDir?: ControlsLocation | ControlsDirection; + controlsDir?: ControlsLocation; + + constructor( + spec: VegaSpec | string, + searchAPI: SearchAPI, + timeCache: TimeCache, + filters: Bool, + serviceSettings: IServiceSettings + ) { + this.spec = spec as VegaSpec; this.hideWarnings = false; + this.error = undefined; this.warnings = []; @@ -90,10 +134,10 @@ export class VegaParser { this.tooltips = this._parseTooltips(); this._setDefaultColors(); - this._parseControlPlacement(this._config); + this._parseControlPlacement(); if (this.useMap) { this.mapConfig = this._parseMapConfig(); - } else if (this.spec.autosize === undefined) { + } else if (this.spec && this.spec.autosize === undefined) { // Default autosize should be fit, unless it's a map (leaflet-vega handles that) this.spec.autosize = { type: 'fit', contains: 'padding' }; } @@ -123,6 +167,7 @@ export class VegaParser { // This way we let leaflet-vega library inject a different default projection for tile maps. // Also, VL injects default padding and autosize values, but neither should be set for vega-leaflet. if (this.useMap) { + if (!this.spec || !this.vlspec) return; const hasConfig = _.isPlainObject(this.vlspec.config); if (this.vlspec.config === undefined || (hasConfig && !this.vlspec.config.projection)) { // Assume VL generates spec.projections = an array of exactly one object named 'projection' @@ -168,49 +213,52 @@ export class VegaParser { */ _calcSizing() { this.useResize = false; - if (!this.useMap) { - // when useResize is true, vega's canvas size will be set based on the size of the container, - // and will be automatically updated on resize events. - // We delete width & height if the autosize is set to "fit" - // We also set useResize=true in case autosize=none, and width & height are not set - const autosize = this.spec.autosize.type || this.spec.autosize; - if (autosize === 'fit' || (autosize === 'none' && !this.spec.width && !this.spec.height)) { - this.useResize = true; - } - } // Padding is not included in the width/height by default this.paddingWidth = 0; this.paddingHeight = 0; - if (this.useResize && this.spec.padding && this.spec.autosize.contains !== 'padding') { - if (typeof this.spec.padding === 'object') { - this.paddingWidth += (+this.spec.padding.left || 0) + (+this.spec.padding.right || 0); - this.paddingHeight += (+this.spec.padding.top || 0) + (+this.spec.padding.bottom || 0); - } else { - this.paddingWidth += 2 * (+this.spec.padding || 0); - this.paddingHeight += 2 * (+this.spec.padding || 0); + if (this.spec) { + if (!this.useMap) { + // when useResize is true, vega's canvas size will be set based on the size of the container, + // and will be automatically updated on resize events. + // We delete width & height if the autosize is set to "fit" + // We also set useResize=true in case autosize=none, and width & height are not set + const autosize = this.spec.autosize.type || this.spec.autosize; + if (autosize === 'fit' || (autosize === 'none' && !this.spec.width && !this.spec.height)) { + this.useResize = true; + } } - } - if (this.useResize && (this.spec.width || this.spec.height)) { - if (this.isVegaLite) { - delete this.spec.width; - delete this.spec.height; - } else { - this._onWarning( - i18n.translate( - 'visTypeVega.vegaParser.widthAndHeightParamsAreIgnoredWithAutosizeFitWarningMessage', - { - defaultMessage: - 'The {widthParam} and {heightParam} params are ignored with {autosizeParam}', - values: { - autosizeParam: 'autosize=fit', - widthParam: '"width"', - heightParam: '"height"', - }, - } - ) - ); + if (this.useResize && this.spec.padding && this.spec.autosize.contains !== 'padding') { + if (typeof this.spec.padding === 'object') { + this.paddingWidth += (+this.spec.padding.left || 0) + (+this.spec.padding.right || 0); + this.paddingHeight += (+this.spec.padding.top || 0) + (+this.spec.padding.bottom || 0); + } else { + this.paddingWidth += 2 * (+this.spec.padding || 0); + this.paddingHeight += 2 * (+this.spec.padding || 0); + } + } + + if (this.useResize && (this.spec.width || this.spec.height)) { + if (this.isVegaLite) { + delete this.spec.width; + delete this.spec.height; + } else { + this._onWarning( + i18n.translate( + 'visTypeVega.vegaParser.widthAndHeightParamsAreIgnoredWithAutosizeFitWarningMessage', + { + defaultMessage: + 'The {widthParam} and {heightParam} params are ignored with {autosizeParam}', + values: { + autosizeParam: 'autosize=fit', + widthParam: '"width"', + heightParam: '"height"', + }, + } + ) + ); + } } } } @@ -220,9 +268,11 @@ export class VegaParser { * @private */ _parseControlPlacement() { - this.containerDir = locToDirMap[this._config.controlsLocation]; + this.containerDir = this._config?.controlsLocation + ? locToDirMap[this._config.controlsLocation] + : undefined; if (this.containerDir === undefined) { - if (this._config.controlsLocation === undefined) { + if (this._config && this._config.controlsLocation === undefined) { this.containerDir = 'column'; } else { throw new Error( @@ -230,14 +280,14 @@ export class VegaParser { defaultMessage: 'Unrecognized {controlsLocationParam} value. Expecting one of [{locToDirMap}]', values: { - locToDirMap: `"${locToDirMap.keys().join('", "')}"`, + locToDirMap: `"${Object.keys(locToDirMap).join('", "')}"`, controlsLocationParam: 'controlsLocation', }, }) ); } } - const dir = this._config.controlsDirection; + const dir = this._config?.controlsDirection; if (dir !== undefined && dir !== 'horizontal' && dir !== 'vertical') { throw new Error( i18n.translate('visTypeVega.vegaParser.unrecognizedDirValueErrorMessage', { @@ -254,51 +304,53 @@ export class VegaParser { * @returns {object} kibana config * @private */ - _parseConfig() { - let result; - if (this.spec._hostConfig !== undefined) { - result = this.spec._hostConfig; - delete this.spec._hostConfig; - if (!_.isPlainObject(result)) { - throw new Error( - i18n.translate('visTypeVega.vegaParser.hostConfigValueTypeErrorMessage', { - defaultMessage: 'If present, {configName} must be an object', - values: { configName: '"_hostConfig"' }, + _parseConfig(): KibanaConfig | {} { + let result: KibanaConfig | null = null; + if (this.spec) { + if (this.spec._hostConfig !== undefined) { + result = this.spec._hostConfig; + delete this.spec._hostConfig; + if (!_.isPlainObject(result)) { + throw new Error( + i18n.translate('visTypeVega.vegaParser.hostConfigValueTypeErrorMessage', { + defaultMessage: 'If present, {configName} must be an object', + values: { configName: '"_hostConfig"' }, + }) + ); + } + this._onWarning( + i18n.translate('visTypeVega.vegaParser.hostConfigIsDeprecatedWarningMessage', { + defaultMessage: + '{deprecatedConfigName} has been deprecated. Use {newConfigName} instead.', + values: { + deprecatedConfigName: '"_hostConfig"', + newConfigName: 'config.kibana', + }, }) ); } - this._onWarning( - i18n.translate('visTypeVega.vegaParser.hostConfigIsDeprecatedWarningMessage', { - defaultMessage: - '{deprecatedConfigName} has been deprecated. Use {newConfigName} instead.', - values: { - deprecatedConfigName: '"_hostConfig"', - newConfigName: 'config.kibana', - }, - }) - ); - } - if (_.isPlainObject(this.spec.config) && this.spec.config.kibana !== undefined) { - result = this.spec.config.kibana; - delete this.spec.config.kibana; - if (!_.isPlainObject(result)) { - throw new Error( - i18n.translate('visTypeVega.vegaParser.kibanaConfigValueTypeErrorMessage', { - defaultMessage: 'If present, {configName} must be an object', - values: { configName: 'config.kibana' }, - }) - ); + if (_.isPlainObject(this.spec.config) && this.spec.config.kibana !== undefined) { + result = this.spec.config.kibana; + delete this.spec.config.kibana; + if (!_.isPlainObject(result)) { + throw new Error( + i18n.translate('visTypeVega.vegaParser.kibanaConfigValueTypeErrorMessage', { + defaultMessage: 'If present, {configName} must be an object', + values: { configName: 'config.kibana' }, + }) + ); + } } } return result || {}; } _parseTooltips() { - if (this._config.tooltips === false) { + if (this._config && this._config.tooltips === false) { return false; } - const result = this._config.tooltips || {}; + const result: TooltipConfig = (this._config?.tooltips as TooltipConfig) || {}; if (result.position === undefined) { result.position = 'top'; @@ -352,12 +404,12 @@ export class VegaParser { * @private */ _parseMapConfig() { - const res = { - delayRepaint: this._config.delayRepaint === undefined ? true : this._config.delayRepaint, + const res: VegaConfig = { + delayRepaint: this._config?.delayRepaint === undefined ? true : this._config.delayRepaint, }; - const validate = (name, isZoom) => { - const val = this._config[name]; + const validate = (name: string, isZoom: boolean) => { + const val = this._config ? this._config[name] : undefined; if (val !== undefined) { const parsed = parseFloat(val); if (Number.isFinite(parsed) && (!isZoom || (parsed >= 0 && parsed <= 30))) { @@ -381,7 +433,7 @@ export class VegaParser { validate(`maxZoom`, true); // `false` is a valid value - res.mapStyle = this._config.mapStyle === undefined ? `default` : this._config.mapStyle; + res.mapStyle = this._config?.mapStyle === undefined ? `default` : this._config.mapStyle; if (res.mapStyle !== `default` && res.mapStyle !== false) { this._onWarning( i18n.translate('visTypeVega.vegaParser.mapStyleValueTypeWarningMessage', { @@ -400,7 +452,7 @@ export class VegaParser { this._parseBool('zoomControl', res, true); this._parseBool('scrollWheelZoom', res, false); - const maxBounds = this._config.maxBounds; + const maxBounds = this._config?.maxBounds; if (maxBounds !== undefined) { if ( !Array.isArray(maxBounds) || @@ -423,8 +475,8 @@ export class VegaParser { return res; } - _parseBool(paramName, dstObj, dflt) { - const val = this._config[paramName]; + _parseBool(paramName: string, dstObj: DstObj, dflt: boolean | string | number) { + const val = this._config ? this._config[paramName] : undefined; if (val === undefined) { dstObj[paramName] = dflt; } else if (typeof val !== 'boolean') { @@ -448,6 +500,7 @@ export class VegaParser { * @private */ _parseSchema() { + if (!this.spec) return false; if (!this.spec.$schema) { this._onWarning( i18n.translate('visTypeVega.vegaParser.inputSpecDoesNotSpecifySchemaWarningMessage', { @@ -486,13 +539,13 @@ export class VegaParser { * @private */ async _resolveDataUrls() { - const pending = {}; + const pending: PendingType = {}; - this._findObjectDataUrls(this.spec, (obj) => { + this._findObjectDataUrls(this.spec!, (obj: Data) => { const url = obj.url; delete obj.url; - let type = url['%type%']; - delete url['%type%']; + let type = url!['%type%']; + delete url!['%type%']; if (type === undefined) { type = DEFAULT_PARSER; } @@ -533,7 +586,8 @@ export class VegaParser { * @param {string} [key] field name of the current object * @private */ - _findObjectDataUrls(obj, onFind, key) { + + _findObjectDataUrls(obj: VegaSpec | Data, onFind: (data: Data) => void, key?: unknown) { if (Array.isArray(obj)) { for (const elem of obj) { this._findObjectDataUrls(elem, onFind, key); @@ -557,7 +611,7 @@ export class VegaParser { ) ); } - onFind(obj); + onFind(obj as Data); } else { for (const k of Object.keys(obj)) { this._findObjectDataUrls(obj[k], onFind, k); @@ -582,7 +636,7 @@ export class VegaParser { // https://github.com/vega/vega/issues/1083 // Don't set defaults if spec.config.mark.color or fill are set if ( - !this.spec.config.mark || + !this.spec?.config.mark || (this.spec.config.mark.color === undefined && this.spec.config.mark.fill === undefined) ) { this._setDefaultValue(defaultColor, 'config', 'arc', 'fill'); @@ -605,7 +659,7 @@ export class VegaParser { * @param {string} fields * @private */ - _setDefaultValue(value, ...fields) { + _setDefaultValue(value: unknown, ...fields: string[]) { let o = this.spec; for (let i = 0; i < fields.length - 1; i++) { const field = fields[i]; @@ -627,9 +681,10 @@ export class VegaParser { * Add a warning to the warnings array * @private */ - _onWarning() { + _onWarning(...args: any[]) { if (!this.hideWarnings) { - this.warnings.push(Utils.formatWarningToStr(...arguments)); + this.warnings.push(Utils.formatWarningToStr(args)); + return Utils.formatWarningToStr(args); } } } diff --git a/src/plugins/vis_type_vega/public/vega_fn.ts b/src/plugins/vis_type_vega/public/vega_fn.ts index 6b1af6044a2c4..d077aa7aee004 100644 --- a/src/plugins/vis_type_vega/public/vega_fn.ts +++ b/src/plugins/vis_type_vega/public/vega_fn.ts @@ -23,6 +23,7 @@ import { ExpressionFunctionDefinition, KibanaContext, Render } from '../../expre import { VegaVisualizationDependencies } from './plugin'; import { createVegaRequestHandler } from './vega_request_handler'; import { TimeRange, Query } from '../../data/public'; +import { VegaParser } from './data_model/vega_parser'; type Input = KibanaContext | null; type Output = Promise>; @@ -34,7 +35,7 @@ interface Arguments { export type VisParams = Required; interface RenderValue { - visData: Input; + visData: VegaParser; visType: 'vega'; visConfig: VisParams; } diff --git a/src/plugins/vis_type_vega/public/vega_request_handler.ts b/src/plugins/vis_type_vega/public/vega_request_handler.ts index ac28f0b3782b2..997b1982d749a 100644 --- a/src/plugins/vis_type_vega/public/vega_request_handler.ts +++ b/src/plugins/vis_type_vega/public/vega_request_handler.ts @@ -20,8 +20,6 @@ import { Filter, esQuery, TimeRange, Query } from '../../data/public'; import { SearchAPI } from './data_model/search_api'; - -// @ts-ignore import { TimeCache } from './data_model/time_cache'; import { VegaVisualizationDependencies } from './plugin'; @@ -64,7 +62,6 @@ export function createVegaRequestHandler( const esQueryConfigs = esQuery.getEsQueryConfig(uiSettings); const filtersDsl = esQuery.buildEsQuery(undefined, query, filters, esQueryConfigs); - // @ts-ignore const { VegaParser } = await import('./data_model/vega_parser'); const vp = new VegaParser(visParams.spec, searchAPI, timeCache, filtersDsl, serviceSettings); diff --git a/test/functional/page_objects/home_page.ts b/test/functional/page_objects/home_page.ts index 6a503f4f73b66..2d78de49a4f94 100644 --- a/test/functional/page_objects/home_page.ts +++ b/test/functional/page_objects/home_page.ts @@ -54,6 +54,10 @@ export function HomePageProvider({ getService, getPageObjects }: FtrProviderCont async removeSampleDataSet(id: string) { // looks like overkill but we're hitting flaky cases where we click but it doesn't remove await testSubjects.waitForEnabled(`removeSampleDataSet${id}`); + // https://github.com/elastic/kibana/issues/65949 + // Even after waiting for the "Remove" button to be enabled we still have failures + // where it appears the click just didn't work. + await PageObjects.common.sleep(1010); await testSubjects.click(`removeSampleDataSet${id}`); await this._waitForSampleDataLoadingAction(id); } diff --git a/x-pack/gulpfile.js b/x-pack/gulpfile.js index adccaccecd7da..7e5ab9b18f019 100644 --- a/x-pack/gulpfile.js +++ b/x-pack/gulpfile.js @@ -9,11 +9,13 @@ require('../src/setup_node_env'); const { buildTask } = require('./tasks/build'); const { devTask } = require('./tasks/dev'); const { testTask, testKarmaTask, testKarmaDebugTask } = require('./tasks/test'); +const { downloadChromium } = require('./tasks/download_chromium'); // export the tasks that are runnable from the CLI module.exports = { build: buildTask, dev: devTask, + downloadChromium, test: testTask, 'test:karma': testKarmaTask, 'test:karma:debug': testKarmaDebugTask, diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/steps/step_logistics.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/steps/step_logistics.tsx index c48a23226a371..032eb93f7f9f9 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/steps/step_logistics.tsx +++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/steps/step_logistics.tsx @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useCallback } from 'react'; import { EuiFlexGroup, EuiFlexItem, @@ -44,26 +44,28 @@ export const StepLogistics: React.FunctionComponent = React.memo( options: { stripEmptyFields: false }, }); + const { isValid: isFormValid, submit, getFormData, subscribe } = form; + const { documentation } = useComponentTemplatesContext(); const [isMetaVisible, setIsMetaVisible] = useState( Boolean(defaultValue._meta && Object.keys(defaultValue._meta).length) ); - const validate = async () => { - return (await form.submit()).isValid; - }; + const validate = useCallback(async () => { + return (await submit()).isValid; + }, [submit]); useEffect(() => { onChange({ - isValid: form.isValid, + isValid: isFormValid, validate, - getData: form.getFormData, + getData: getFormData, }); - }, [form.isValid, onChange]); // eslint-disable-line react-hooks/exhaustive-deps + }, [isFormValid, getFormData, validate, onChange]); useEffect(() => { - const subscription = form.subscribe(({ data, isValid }) => { + const subscription = subscribe(({ data, isValid }) => { onChange({ isValid, validate, @@ -71,7 +73,7 @@ export const StepLogistics: React.FunctionComponent = React.memo( }); }); return subscription.unsubscribe; - }, [onChange]); // eslint-disable-line react-hooks/exhaustive-deps + }, [subscribe, validate, onChange]); return ( diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/configuration_form.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/configuration_form.tsx index 098e530bddb3c..86bcc796a88eb 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/configuration_form.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/configuration_form.tsx @@ -94,22 +94,23 @@ export const ConfigurationForm = React.memo(({ value }: Props) => { id: 'configurationForm', }); const dispatch = useDispatch(); + const { subscribe, submit, reset, getFormData } = form; useEffect(() => { - const subscription = form.subscribe(({ data, isValid, validate }) => { + const subscription = subscribe(({ data, isValid, validate }) => { dispatch({ type: 'configuration.update', value: { data, isValid, validate, - submitForm: form.submit, + submitForm: submit, }, }); }); return subscription.unsubscribe; - }, [dispatch]); // eslint-disable-line react-hooks/exhaustive-deps + }, [dispatch, subscribe, submit]); useEffect(() => { if (isMounted.current === undefined) { @@ -125,18 +126,18 @@ export const ConfigurationForm = React.memo(({ value }: Props) => { // If the value has changed (it probably means that we have loaded a new JSON) // we need to reset the form to update the fields values. - form.reset({ resetValues: true }); - }, [value]); // eslint-disable-line react-hooks/exhaustive-deps + reset({ resetValues: true }); + }, [value, reset]); useEffect(() => { return () => { isMounted.current = false; // Save a snapshot of the form state so we can get back to it when navigating back to the tab - const configurationData = form.getFormData(); + const configurationData = getFormData(); dispatch({ type: 'configuration.save', value: configurationData }); }; - }, [dispatch]); // eslint-disable-line react-hooks/exhaustive-deps + }, [getFormData, dispatch]); return ( { - const subscription = form.subscribe((updatedFieldForm) => { + const subscription = subscribe((updatedFieldForm) => { dispatch({ type: 'fieldForm.update', value: updatedFieldForm }); }); return subscription.unsubscribe; - }, [dispatch]); // eslint-disable-line react-hooks/exhaustive-deps + }, [dispatch, subscribe]); const cancel = () => { dispatch({ type: 'documentField.changeStatus', value: 'idle' }); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field_container.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field_container.tsx index d543e49d23be9..5105a2a157a6d 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field_container.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field_container.tsx @@ -26,13 +26,15 @@ export const EditFieldContainer = React.memo(({ field, allFields }: Props) => { options: { stripEmptyFields: false }, }); + const { subscribe } = form; + useEffect(() => { - const subscription = form.subscribe((updatedFieldForm) => { + const subscription = subscribe((updatedFieldForm) => { dispatch({ type: 'fieldForm.update', value: updatedFieldForm }); }); return subscription.unsubscribe; - }, [dispatch]); // eslint-disable-line react-hooks/exhaustive-deps + }, [subscribe, dispatch]); const exitEdit = useCallback(() => { dispatch({ type: 'documentField.changeStatus', value: 'idle' }); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/templates_form/templates_form.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/templates_form/templates_form.tsx index 79685d46b6bdd..a95579a8a141e 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/templates_form/templates_form.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/templates_form/templates_form.tsx @@ -61,17 +61,18 @@ export const TemplatesForm = React.memo(({ value }: Props) => { deserializer: formDeserializer, defaultValue: value, }); + const { subscribe, getFormData, submit: submitForm, reset } = form; const dispatch = useDispatch(); useEffect(() => { - const subscription = form.subscribe(({ data, isValid, validate }) => { + const subscription = subscribe(({ data, isValid, validate }) => { dispatch({ type: 'templates.update', - value: { data, isValid, validate, submitForm: form.submit }, + value: { data, isValid, validate, submitForm }, }); }); return subscription.unsubscribe; - }, [dispatch]); // eslint-disable-line react-hooks/exhaustive-deps + }, [subscribe, dispatch, submitForm]); useEffect(() => { if (isMounted.current === undefined) { @@ -87,18 +88,18 @@ export const TemplatesForm = React.memo(({ value }: Props) => { // If the value has changed (it probably means that we have loaded a new JSON) // we need to reset the form to update the fields values. - form.reset({ resetValues: true }); - }, [value]); // eslint-disable-line react-hooks/exhaustive-deps + reset({ resetValues: true }); + }, [value, reset]); useEffect(() => { return () => { isMounted.current = false; // On unmount => save in the state a snapshot of the current form data. - const dynamicTemplatesData = form.getFormData(); + const dynamicTemplatesData = getFormData(); dispatch({ type: 'templates.save', value: dynamicTemplatesData }); }; - }, [dispatch]); // eslint-disable-line react-hooks/exhaustive-deps + }, [getFormData, dispatch]); return (
diff --git a/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_mappings_container.tsx b/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_mappings_container.tsx index 38c4a85bbe0ff..b0675c1412259 100644 --- a/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_mappings_container.tsx +++ b/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_mappings_container.tsx @@ -14,15 +14,16 @@ interface Props { } export const StepMappingsContainer: React.FunctionComponent = ({ esDocsBase }) => { - const { defaultValue, updateContent, getData } = Forms.useContent( + const { defaultValue, updateContent, getSingleContentData } = Forms.useContent< + CommonWizardSteps, 'mappings' - ); + >('mappings'); return ( ); diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_swimlane.tsx b/x-pack/plugins/ml/public/application/explorer/explorer_swimlane.tsx index 0f92278e90445..926f38ac8b552 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_swimlane.tsx +++ b/x-pack/plugins/ml/public/application/explorer/explorer_swimlane.tsx @@ -10,7 +10,7 @@ import React from 'react'; import './_explorer.scss'; -import _ from 'lodash'; +import _, { isEqual } from 'lodash'; import d3 from 'd3'; import moment from 'moment'; import DragSelect from 'dragselect'; @@ -60,11 +60,7 @@ export interface ExplorerSwimlaneProps { timeBuckets: InstanceType; swimlaneData: OverallSwimlaneData | ViewBySwimLaneData; swimlaneType: SwimlaneType; - selection?: { - lanes: any[]; - type: string; - times: number[]; - }; + selection?: AppStateSelectedCells; onCellsSelection: (payload?: AppStateSelectedCells) => void; tooltipService: ChartTooltipService; 'data-test-subj'?: string; @@ -82,6 +78,8 @@ export class ExplorerSwimlane extends React.Component { // and intentionally circumvent the component lifecycle when updating it. cellMouseoverActive = true; + selection: AppStateSelectedCells | undefined = undefined; + dragSelectSubscriber: Subscription | null = null; rootNode = React.createRef(); @@ -123,6 +121,8 @@ export class ExplorerSwimlane extends React.Component { onDragStart: (e) => { // make sure we don't trigger text selection on label e.preventDefault(); + // clear previous selection + this.clearSelection(); let target = e.target as HTMLElement; while (target && target !== document.body && !target.classList.contains('sl-cell')) { target = target.parentNode as HTMLElement; @@ -249,7 +249,7 @@ export class ExplorerSwimlane extends React.Component { } if (triggerNewSelection === false) { - this.swimlaneCellClick(); + this.swimLaneSelectionCompleted(); return; } @@ -259,17 +259,84 @@ export class ExplorerSwimlane extends React.Component { times: d3.extent(times), type: swimlaneType, }; - this.swimlaneCellClick(selectedCells); + this.swimLaneSelectionCompleted(selectedCells); } - highlightOverall(times: number[]) { - const overallSwimlane = d3.select('.ml-swimlane-overall'); - times.forEach((time) => { - const overallCell = overallSwimlane - .selectAll(`div[data-time="${time}"]`) - .selectAll('.sl-cell-inner,.sl-cell-inner-dragselect'); - overallCell.classed('sl-cell-inner-selected', true); + /** + * Highlights DOM elements of the swim lane cells + */ + highlightSwimLaneCells(selection: AppStateSelectedCells | undefined) { + const element = d3.select(this.rootNode.current!.parentNode!); + + const { swimlaneType, swimlaneData, filterActive, maskAll } = this.props; + + const { laneLabels: lanes, earliest: startTime, latest: endTime } = swimlaneData; + + // Check for selection and reselect the corresponding swimlane cell + // if the time range and lane label are still in view. + const selectionState = selection; + const selectedType = _.get(selectionState, 'type', undefined); + const selectionViewByFieldName = _.get(selectionState, 'viewByFieldName', ''); + + // If a selection was done in the other swimlane, add the "masked" classes + // to de-emphasize the swimlane cells. + if (swimlaneType !== selectedType && selectedType !== undefined) { + element.selectAll('.lane-label').classed('lane-label-masked', true); + element.selectAll('.sl-cell-inner').classed('sl-cell-inner-masked', true); + } + + const cellsToSelect: Node[] = []; + const selectedLanes = _.get(selectionState, 'lanes', []); + const selectedTimes = _.get(selectionState, 'times', []); + const selectedTimeExtent = d3.extent(selectedTimes); + + if ( + (swimlaneType !== selectedType || + (swimlaneData.fieldName !== undefined && + swimlaneData.fieldName !== selectionViewByFieldName)) && + filterActive === false + ) { + // Not this swimlane which was selected. + return; + } + + selectedLanes.forEach((selectedLane) => { + if ( + lanes.indexOf(selectedLane) > -1 && + selectedTimeExtent[0] >= startTime && + selectedTimeExtent[1] <= endTime + ) { + // Locate matching cell - look for exact time, otherwise closest before. + const laneCells = element.selectAll(`div[data-lane-label="${mlEscape(selectedLane)}"]`); + + laneCells.each(function (this: HTMLElement) { + const cell = d3.select(this); + const cellTime = parseInt(cell.attr('data-time'), 10); + if (cellTime >= selectedTimeExtent[0] && cellTime <= selectedTimeExtent[1]) { + cellsToSelect.push(cell.node()); + } + }); + } }); + + const selectedMaxBucketScore = cellsToSelect.reduce((maxBucketScore, cell) => { + return Math.max(maxBucketScore, +d3.select(cell).attr('data-bucket-score') || 0); + }, 0); + + const selectedCellTimes = cellsToSelect.map((e) => { + return (d3.select(e).node() as NodeWithData).__clickData__.time; + }); + + if (cellsToSelect.length > 1 || selectedMaxBucketScore > 0) { + this.highlightSelection(cellsToSelect, selectedLanes, selectedCellTimes); + } else if (filterActive === true) { + this.maskIrrelevantSwimlanes(Boolean(maskAll)); + } else { + this.clearSelection(); + } + + // cache selection to prevent rerenders + this.selection = selection; } highlightSelection(cellsToSelect: Node[], laneLabels: string[], times: number[]) { @@ -348,7 +415,6 @@ export class ExplorerSwimlane extends React.Component { const { chartWidth, filterActive, - maskAll, timeBuckets, swimlaneData, swimlaneType, @@ -478,7 +544,7 @@ export class ExplorerSwimlane extends React.Component { }) .on('click', () => { if (selection && typeof selection.lanes !== 'undefined') { - this.swimlaneCellClick(); + this.swimLaneSelectionCompleted(); } }) .each(function (this: HTMLElement) { @@ -618,86 +684,28 @@ export class ExplorerSwimlane extends React.Component { } }); - // Check for selection and reselect the corresponding swimlane cell - // if the time range and lane label are still in view. - const selectionState = selection; - const selectedType = _.get(selectionState, 'type', undefined); - const selectionViewByFieldName = _.get(selectionState, 'viewByFieldName', ''); - - // If a selection was done in the other swimlane, add the "masked" classes - // to de-emphasize the swimlane cells. - if (swimlaneType !== selectedType && selectedType !== undefined) { - element.selectAll('.lane-label').classed('lane-label-masked', true); - element.selectAll('.sl-cell-inner').classed('sl-cell-inner-masked', true); - } - this.swimlaneRenderDoneListener(); - if ( - (swimlaneType !== selectedType || - (swimlaneData.fieldName !== undefined && - swimlaneData.fieldName !== selectionViewByFieldName)) && - filterActive === false - ) { - // Not this swimlane which was selected. - return; - } - - const cellsToSelect: Node[] = []; - const selectedLanes = _.get(selectionState, 'lanes', []); - const selectedTimes = _.get(selectionState, 'times', []); - const selectedTimeExtent = d3.extent(selectedTimes); - - selectedLanes.forEach((selectedLane) => { - if ( - lanes.indexOf(selectedLane) > -1 && - selectedTimeExtent[0] >= startTime && - selectedTimeExtent[1] <= endTime - ) { - // Locate matching cell - look for exact time, otherwise closest before. - const laneCells = element.selectAll(`div[data-lane-label="${mlEscape(selectedLane)}"]`); - - laneCells.each(function (this: HTMLElement) { - const cell = d3.select(this); - const cellTime = parseInt(cell.attr('data-time'), 10); - if (cellTime >= selectedTimeExtent[0] && cellTime <= selectedTimeExtent[1]) { - cellsToSelect.push(cell.node()); - } - }); - } - }); - - const selectedMaxBucketScore = cellsToSelect.reduce((maxBucketScore, cell) => { - return Math.max(maxBucketScore, +d3.select(cell).attr('data-bucket-score') || 0); - }, 0); - - const selectedCellTimes = cellsToSelect.map((e) => { - return (d3.select(e).node() as NodeWithData).__clickData__.time; - }); - - if (cellsToSelect.length > 1 || selectedMaxBucketScore > 0) { - this.highlightSelection(cellsToSelect, selectedLanes, selectedCellTimes); - } else if (filterActive === true) { - if (selectedCellTimes.length > 0) { - this.highlightOverall(selectedCellTimes); - } - this.maskIrrelevantSwimlanes(Boolean(maskAll)); - } else { - this.clearSelection(); - } + this.highlightSwimLaneCells(selection); } - shouldComponentUpdate() { - return true; + shouldComponentUpdate(nextProps: ExplorerSwimlaneProps) { + return ( + this.props.chartWidth !== nextProps.chartWidth || + !isEqual(this.props.swimlaneData, nextProps.swimlaneData) || + !isEqual(nextProps.selection, this.selection) + ); } /** * Listener for click events in the swim lane and execute a prop callback. * @param selectedCellsUpdate */ - swimlaneCellClick(selectedCellsUpdate?: AppStateSelectedCells) { + swimLaneSelectionCompleted(selectedCellsUpdate?: AppStateSelectedCells) { // If selectedCells is an empty object we clear any existing selection, // otherwise we save the new selection in AppState and update the Explorer. + this.highlightSwimLaneCells(selectedCellsUpdate); + if (!selectedCellsUpdate) { this.props.onCellsSelection(); } else { diff --git a/x-pack/plugins/security_solution/public/cases/components/add_comment/index.tsx b/x-pack/plugins/security_solution/public/cases/components/add_comment/index.tsx index 980083e8e9d20..a54cf142c18b7 100644 --- a/x-pack/plugins/security_solution/public/cases/components/add_comment/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/add_comment/index.tsx @@ -47,7 +47,7 @@ export const AddComment = React.memo( options: { stripEmptyFields: false }, schema, }); - + const { getFormData, setFieldValue, reset, submit } = form; const { handleCursorChange, handleOnTimelineChange } = useInsertTimeline( form, 'comment' @@ -55,26 +55,23 @@ export const AddComment = React.memo( useEffect(() => { if (insertQuote !== null) { - const { comment } = form.getFormData(); - form.setFieldValue( - 'comment', - `${comment}${comment.length > 0 ? '\n\n' : ''}${insertQuote}` - ); + const { comment } = getFormData(); + setFieldValue('comment', `${comment}${comment.length > 0 ? '\n\n' : ''}${insertQuote}`); } - }, [form, insertQuote]); + }, [getFormData, insertQuote, setFieldValue]); const handleTimelineClick = useTimelineClick(); const onSubmit = useCallback(async () => { - const { isValid, data } = await form.submit(); + const { isValid, data } = await submit(); if (isValid) { if (onCommentSaving != null) { onCommentSaving(); } postComment(data, onCommentPosted); - form.reset(); + reset(); } - }, [form, onCommentPosted, onCommentSaving, postComment]); + }, [onCommentPosted, onCommentSaving, postComment, reset, submit]); return ( diff --git a/x-pack/plugins/security_solution/public/cases/components/create/index.tsx b/x-pack/plugins/security_solution/public/cases/components/create/index.tsx index 1a2697bb132b0..31e6da4269ead 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/index.tsx @@ -69,6 +69,7 @@ export const Create = React.memo(() => { options: { stripEmptyFields: false }, schema, }); + const { submit } = form; const { tags: tagOptions } = useGetTags(); const [options, setOptions] = useState( tagOptions.map((label) => ({ @@ -91,12 +92,12 @@ export const Create = React.memo(() => { const handleTimelineClick = useTimelineClick(); const onSubmit = useCallback(async () => { - const { isValid, data } = await form.submit(); + const { isValid, data } = await submit(); if (isValid) { // `postCase`'s type is incorrect, it actually returns a promise await postCase(data); } - }, [form, postCase]); + }, [submit, postCase]); const handleSetIsCancel = useCallback(() => { history.push('/'); diff --git a/x-pack/plugins/security_solution/public/cases/components/edit_connector/index.tsx b/x-pack/plugins/security_solution/public/cases/components/edit_connector/index.tsx index ba0b97b6088a8..11938a55181d3 100644 --- a/x-pack/plugins/security_solution/public/cases/components/edit_connector/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/edit_connector/index.tsx @@ -46,11 +46,13 @@ export const EditConnector = React.memo( onSubmit, selectedConnector, }: EditConnectorProps) => { + const initialState = { connectors }; const { form } = useForm({ - defaultValue: { connectors }, + defaultValue: initialState, options: { stripEmptyFields: false }, schema, }); + const { setFieldValue, submit } = form; const [connectorHasChanged, setConnectorHasChanged] = useState(false); const onChangeConnector = useCallback( (connectorId) => { @@ -60,17 +62,18 @@ export const EditConnector = React.memo( ); const onCancelConnector = useCallback(() => { - form.setFieldValue('connector', selectedConnector); + setFieldValue('connector', selectedConnector); setConnectorHasChanged(false); - }, [form, selectedConnector]); + }, [selectedConnector, setFieldValue]); const onSubmitConnector = useCallback(async () => { - const { isValid, data: newData } = await form.submit(); + const { isValid, data: newData } = await submit(); if (isValid && newData.connector) { onSubmit(newData.connector); setConnectorHasChanged(false); } - }, [form, onSubmit]); + }, [submit, onSubmit]); + return ( diff --git a/x-pack/plugins/security_solution/public/cases/components/tag_list/index.tsx b/x-pack/plugins/security_solution/public/cases/components/tag_list/index.tsx index 5f8404ca2dcc4..7bb10c743a418 100644 --- a/x-pack/plugins/security_solution/public/cases/components/tag_list/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/tag_list/index.tsx @@ -42,20 +42,23 @@ const MyFlexGroup = styled(EuiFlexGroup)` export const TagList = React.memo( ({ disabled = false, isLoading, onSubmit, tags }: TagListProps) => { + const initialState = { tags }; const { form } = useForm({ - defaultValue: { tags }, + defaultValue: initialState, options: { stripEmptyFields: false }, schema, }); + const { submit } = form; const [isEditTags, setIsEditTags] = useState(false); const onSubmitTags = useCallback(async () => { - const { isValid, data: newData } = await form.submit(); + const { isValid, data: newData } = await submit(); if (isValid && newData.tags) { onSubmit(newData.tags); setIsEditTags(false); } - }, [form, onSubmit]); + }, [onSubmit, submit]); + const { tags: tagOptions } = useGetTags(); const [options, setOptions] = useState( tagOptions.map((label) => ({ diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_markdown.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_markdown.tsx index 0a8167049266f..da081fea5eac0 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_markdown.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_markdown.tsx @@ -6,7 +6,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty, EuiButton } from '@elastic/eui'; import React, { useCallback } from 'react'; -import styled, { css } from 'styled-components'; +import styled from 'styled-components'; import * as i18n from '../case_view/translations'; import { Markdown } from '../../../common/components/markdown'; @@ -18,9 +18,7 @@ import { MarkdownEditorForm } from '../../../common/components//markdown_editor/ import { useTimelineClick } from '../utils/use_timeline_click'; const ContentWrapper = styled.div` - ${({ theme }) => css` - padding: ${theme.eui.euiSizeM} ${theme.eui.euiSizeL}; - `} + padding: ${({ theme }) => `${theme.eui.euiSizeM} ${theme.eui.euiSizeL}`}; `; interface UserActionMarkdownProps { @@ -37,11 +35,13 @@ export const UserActionMarkdown = ({ onChangeEditable, onSaveContent, }: UserActionMarkdownProps) => { + const initialState = { content }; const { form } = useForm({ - defaultValue: { content }, + defaultValue: initialState, options: { stripEmptyFields: false }, schema, }); + const { submit } = form; const { handleCursorChange, handleOnTimelineChange } = useInsertTimeline( form, 'content' @@ -53,45 +53,43 @@ export const UserActionMarkdown = ({ const handleTimelineClick = useTimelineClick(); const handleSaveAction = useCallback(async () => { - const { isValid, data } = await form.submit(); + const { isValid, data } = await submit(); if (isValid) { onSaveContent(data.content); } onChangeEditable(id); - }, [form, id, onChangeEditable, onSaveContent]); + }, [id, onChangeEditable, onSaveContent, submit]); const renderButtons = useCallback( - ({ cancelAction, saveAction }) => { - return ( - - - - {i18n.CANCEL} - - - - - {i18n.SAVE} - - - - ); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [handleCancelAction, handleSaveAction] + ({ cancelAction, saveAction }) => ( + + + + {i18n.CANCEL} + + + + + {i18n.SAVE} + + + + ), + [] ); + return isEditable ? ( = ({ setForm, setStepData, }) => { - const [myStepData, setMyStepData] = useState(stepAboutDefaultValue); + const initialState = defaultValues ?? stepAboutDefaultValue; + const [myStepData, setMyStepData] = useState(initialState); const [{ isLoading: indexPatternLoading, indexPatterns }] = useFetchIndexPatterns( defineRuleData?.index ?? [] ); const { form } = useForm({ - defaultValue: myStepData, + defaultValue: initialState, options: { stripEmptyFields: false }, schema, }); + const { getFields, submit } = form; const onSubmit = useCallback(async () => { if (setStepData) { setStepData(RuleStep.aboutRule, null, false); - const { isValid, data } = await form.submit(); + const { isValid, data } = await submit(); if (isValid) { setStepData(RuleStep.aboutRule, data, isValid); setMyStepData({ ...data, isNew: false } as AboutStepRule); } } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [form]); + }, [setStepData, submit]); useEffect(() => { - const { isNew, ...initDefaultValue } = myStepData; - if (defaultValues != null && !deepEqual(initDefaultValue, defaultValues)) { - const myDefaultValues = { - ...defaultValues, - isNew: false, - }; - setMyStepData(myDefaultValues); - setFieldValue(form, schema, myDefaultValues); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [defaultValues]); - - useEffect(() => { - if (setForm != null) { + if (setForm) { setForm(RuleStep.aboutRule, form); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [form]); + }, [setForm, form]); return isReadOnlyView && myStepData.name != null ? ( @@ -338,8 +323,8 @@ const StepAboutRuleComponent: FC = ({ {({ severity }) => { const newRiskScore = defaultRiskScoreBySeverity[severity as SeverityValue]; - const severityField = form.getFields().severity; - const riskScoreField = form.getFields().riskScore; + const severityField = getFields().severity; + const riskScoreField = getFields().riskScore; if ( severityField.value !== severity && newRiskScore != null && diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx index c7d70684b34cf..51e9291f31941 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx @@ -17,7 +17,6 @@ import { useFetchIndexPatterns } from '../../../containers/detection_engine/rule import { DEFAULT_TIMELINE_TITLE } from '../../../../timelines/components/timeline/translations'; import { useMlCapabilities } from '../../../../common/components/ml_popover/hooks/use_ml_capabilities'; import { useUiSetting$ } from '../../../../common/lib/kibana'; -import { setFieldValue } from '../../../pages/detection_engine/rules/helpers'; import { filterRuleFieldsForType, RuleFields, @@ -109,58 +108,46 @@ const StepDefineRuleComponent: FC = ({ const mlCapabilities = useMlCapabilities(); const [openTimelineSearch, setOpenTimelineSearch] = useState(false); const [indexModified, setIndexModified] = useState(false); - const [localRuleType, setLocalRuleType] = useState( - defaultValues?.ruleType || stepDefineDefaultValue.ruleType - ); const [indicesConfig] = useUiSetting$(DEFAULT_INDEX_KEY); - const [myStepData, setMyStepData] = useState({ + const initialState = defaultValues ?? { ...stepDefineDefaultValue, index: indicesConfig ?? [], - }); + }; + const [localRuleType, setLocalRuleType] = useState(initialState.ruleType); + const [myStepData, setMyStepData] = useState(initialState); const [ { browserFields, indexPatterns: indexPatternQueryBar, isLoading: indexPatternLoadingQueryBar }, ] = useFetchIndexPatterns(myStepData.index); const { form } = useForm({ - defaultValue: myStepData, + defaultValue: initialState, options: { stripEmptyFields: false }, schema, }); - const clearErrors = useCallback(() => form.reset({ resetValues: false }), [form]); + const { getFields, reset, submit } = form; + const clearErrors = useCallback(() => reset({ resetValues: false }), [reset]); const onSubmit = useCallback(async () => { if (setStepData) { setStepData(RuleStep.defineRule, null, false); - const { isValid, data } = await form.submit(); + const { isValid, data } = await submit(); if (isValid && setStepData) { setStepData(RuleStep.defineRule, data, isValid); setMyStepData({ ...data, isNew: false } as DefineStepRule); } } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [form]); - - useEffect(() => { - const { isNew, ...values } = myStepData; - if (defaultValues != null && !deepEqual(values, defaultValues)) { - const newValues = { ...values, ...defaultValues, isNew: false }; - setMyStepData(newValues); - setFieldValue(form, schema, newValues); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [defaultValues, setMyStepData, setFieldValue]); + }, [setStepData, submit]); useEffect(() => { - if (setForm != null) { + if (setForm) { setForm(RuleStep.defineRule, form); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [form]); + }, [form, setForm]); const handleResetIndices = useCallback(() => { - const indexField = form.getFields().index; + const indexField = getFields().index; indexField.setValue(indicesConfig); - }, [form, indicesConfig]); + }, [getFields, indicesConfig]); const handleOpenTimelineSearch = useCallback(() => { setOpenTimelineSearch(true); @@ -281,11 +268,11 @@ const StepDefineRuleComponent: FC = ({ fields={{ thresholdField: { path: 'threshold.field', - defaultValue: defaultValues?.threshold?.field, + defaultValue: initialState.threshold.field, }, thresholdValue: { path: 'threshold.value', - defaultValue: defaultValues?.threshold?.value, + defaultValue: initialState.threshold.value, }, }} > diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.tsx index 7005bfb25f4a6..7bf151adde5cc 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.tsx @@ -14,9 +14,7 @@ import { } from '@elastic/eui'; import { findIndex } from 'lodash/fp'; import React, { FC, memo, useCallback, useEffect, useMemo, useState } from 'react'; -import deepEqual from 'fast-deep-equal'; -import { setFieldValue } from '../../../pages/detection_engine/rules/helpers'; import { RuleStep, RuleStepProps, @@ -71,7 +69,8 @@ const StepRuleActionsComponent: FC = ({ setForm, actionMessageParams, }) => { - const [myStepData, setMyStepData] = useState(stepActionsDefaultValue); + const initialState = defaultValues ?? stepActionsDefaultValue; + const [myStepData, setMyStepData] = useState(initialState); const { services: { application, @@ -81,10 +80,11 @@ const StepRuleActionsComponent: FC = ({ const schema = useMemo(() => getSchema({ actionTypeRegistry }), [actionTypeRegistry]); const { form } = useForm({ - defaultValue: myStepData, + defaultValue: initialState, options: { stripEmptyFields: false }, schema, }); + const { submit } = form; // TO DO need to make sure that logic is still valid const kibanaAbsoluteUrl = useMemo(() => { @@ -101,36 +101,21 @@ const StepRuleActionsComponent: FC = ({ async (enabled: boolean) => { if (setStepData) { setStepData(RuleStep.ruleActions, null, false); - const { isValid: newIsValid, data } = await form.submit(); + const { isValid: newIsValid, data } = await submit(); if (newIsValid) { setStepData(RuleStep.ruleActions, { ...data, enabled }, newIsValid); setMyStepData({ ...data, isNew: false } as ActionsStepRule); } } }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [form] + [setStepData, submit] ); useEffect(() => { - const { isNew, ...initDefaultValue } = myStepData; - if (defaultValues != null && !deepEqual(initDefaultValue, defaultValues)) { - const myDefaultValues = { - ...defaultValues, - isNew: false, - }; - setMyStepData(myDefaultValues); - setFieldValue(form, schema, myDefaultValues); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [defaultValues]); - - useEffect(() => { - if (setForm != null) { + if (setForm) { setForm(RuleStep.ruleActions, form); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [form]); + }, [form, setForm]); const updateThrottle = useCallback((throttle) => setMyStepData({ ...myStepData, throttle }), [ myStepData, diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_schedule_rule/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_schedule_rule/index.tsx index fa0f4dbd3668c..52f04f8423bec 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_schedule_rule/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_schedule_rule/index.tsx @@ -5,9 +5,7 @@ */ import React, { FC, memo, useCallback, useEffect, useState } from 'react'; -import deepEqual from 'fast-deep-equal'; -import { setFieldValue } from '../../../pages/detection_engine/rules/helpers'; import { RuleStep, RuleStepProps, @@ -40,45 +38,32 @@ const StepScheduleRuleComponent: FC = ({ setStepData, setForm, }) => { - const [myStepData, setMyStepData] = useState(stepScheduleDefaultValue); + const initialState = defaultValues ?? stepScheduleDefaultValue; + const [myStepData, setMyStepData] = useState(initialState); const { form } = useForm({ - defaultValue: myStepData, + defaultValue: initialState, options: { stripEmptyFields: false }, schema, }); + const { submit } = form; const onSubmit = useCallback(async () => { if (setStepData) { setStepData(RuleStep.scheduleRule, null, false); - const { isValid: newIsValid, data } = await form.submit(); + const { isValid: newIsValid, data } = await submit(); if (newIsValid) { setStepData(RuleStep.scheduleRule, { ...data }, newIsValid); setMyStepData({ ...data, isNew: false } as ScheduleStepRule); } } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [form]); + }, [setStepData, submit]); useEffect(() => { - const { isNew, ...initDefaultValue } = myStepData; - if (defaultValues != null && !deepEqual(initDefaultValue, defaultValues)) { - const myDefaultValues = { - ...defaultValues, - isNew: false, - }; - setMyStepData(myDefaultValues); - setFieldValue(form, schema, myDefaultValues); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [defaultValues]); - - useEffect(() => { - if (setForm != null) { + if (setForm) { setForm(RuleStep.scheduleRule, form); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [form]); + }, [form, setForm]); return isReadOnlyView && myStepData != null ? ( diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.tsx index f6e13786e98d0..6ba65ceca8fe9 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.tsx @@ -109,10 +109,10 @@ const CreateRulePageComponent: React.FC = () => { [RuleStep.ruleActions]: null, }); const stepsData = useRef>({ - [RuleStep.defineRule]: { isValid: false, data: {} }, - [RuleStep.aboutRule]: { isValid: false, data: {} }, - [RuleStep.scheduleRule]: { isValid: false, data: {} }, - [RuleStep.ruleActions]: { isValid: false, data: {} }, + [RuleStep.defineRule]: { isValid: false, data: undefined }, + [RuleStep.aboutRule]: { isValid: false, data: undefined }, + [RuleStep.scheduleRule]: { isValid: false, data: undefined }, + [RuleStep.ruleActions]: { isValid: false, data: undefined }, }); const [isStepRuleInReadOnlyView, setIsStepRuleInEditView] = useState>({ [RuleStep.defineRule]: false, @@ -123,7 +123,7 @@ const CreateRulePageComponent: React.FC = () => { const [{ isLoading, isSaved }, setRule] = usePersistRule(); const actionMessageParams = useMemo( () => - getActionMessageParams((stepsData.current['define-rule'].data as DefineStepRule).ruleType), + getActionMessageParams((stepsData.current['define-rule'].data as DefineStepRule)?.ruleType), // eslint-disable-next-line react-hooks/exhaustive-deps [stepsData.current['define-rule'].data] ); @@ -335,9 +335,7 @@ const CreateRulePageComponent: React.FC = () => { { { , - schema: FormSchema, - defaultValues: unknown -) => - Object.keys(schema).forEach((key) => { - const val = get(key, defaultValues); - if (val != null) { - form.setFieldValue(key, val); - } - }); export const redirectToDetections = ( isSignalIndexExists: boolean | null, diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx index c5d47e87c3e1b..4c8d2c5a6df4e 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx @@ -377,7 +377,7 @@ export const HostList = () => { data-test-subj="hostPage" headerLeft={ <> - +

diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.tsx index 8dbfbeeb5d8d6..20b6534f7664e 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.tsx @@ -396,7 +396,7 @@ export const PolicyList = React.memo(() => { data-test-subj="policyListPage" headerLeft={ <> - +

diff --git a/x-pack/plugins/security_solution/server/usage/collector.ts b/x-pack/plugins/security_solution/server/usage/collector.ts index bb3583d50f8e5..9740f57450e80 100644 --- a/x-pack/plugins/security_solution/server/usage/collector.ts +++ b/x-pack/plugins/security_solution/server/usage/collector.ts @@ -66,8 +66,8 @@ export const registerCollector: RegisterCollector = ({ }, policies: { malware: { - success: { type: 'long' }, - warning: { type: 'long' }, + active: { type: 'long' }, + inactive: { type: 'long' }, failure: { type: 'long' }, }, }, diff --git a/x-pack/plugins/security_solution/server/usage/endpoints/endpoint.mocks.ts b/x-pack/plugins/security_solution/server/usage/endpoints/endpoint.mocks.ts index f41cfb773736d..1369a3d398265 100644 --- a/x-pack/plugins/security_solution/server/usage/endpoints/endpoint.mocks.ts +++ b/x-pack/plugins/security_solution/server/usage/endpoints/endpoint.mocks.ts @@ -76,6 +76,108 @@ export const mockFleetObjectsResponse = ( ], }); +const mockPolicyPayload = (malwareStatus: 'success' | 'warning' | 'failure') => + JSON.stringify({ + 'endpoint-security': { + Endpoint: { + configuration: { + inputs: [ + { + id: '0d466df0-c60f-11ea-a5c5-151665e785c4', + policy: { + linux: { + events: { + file: true, + network: true, + process: true, + }, + logging: { + file: 'info', + }, + }, + mac: { + events: { + file: true, + network: true, + process: true, + }, + logging: { + file: 'info', + }, + malware: { + mode: 'prevent', + }, + }, + windows: { + events: { + dll_and_driver_load: true, + dns: true, + file: true, + network: true, + process: true, + registry: true, + security: true, + }, + logging: { + file: 'info', + }, + malware: { + mode: 'prevent', + }, + }, + }, + }, + ], + }, + policy: { + applied: { + id: '0d466df0-c60f-11ea-a5c5-151665e785c4', + response: { + configurations: { + malware: { + concerned_actions: [ + 'load_config', + 'workflow', + 'download_global_artifacts', + 'download_user_artifacts', + 'configure_malware', + 'read_malware_config', + 'load_malware_model', + 'read_kernel_config', + 'configure_kernel', + 'detect_process_events', + 'detect_file_write_events', + 'connect_kernel', + 'detect_file_open_events', + 'detect_sync_image_load_events', + ], + status: `${malwareStatus}`, + }, + }, + }, + status: `${malwareStatus}`, + }, + }, + }, + agent: { + id: 'testAgentId', + version: '8.0.0-SNAPSHOT', + }, + host: { + architecture: 'x86_64', + id: 'a4148b63-1758-ab1f-a6d3-f95075cb1a9c', + os: { + Ext: { + variant: 'Windows 10 Pro', + }, + full: 'Windows 10 Pro 2004 (10.0.19041.329)', + name: 'Windows', + version: '2004 (10.0.19041.329)', + }, + }, + }, + }); + /** * * @param running - allows us to set whether the mocked endpoint is in an active or disabled/failed state @@ -102,6 +204,7 @@ export const mockFleetEventsObjectsResponse = ( message: `Application: endpoint-security--8.0.0[d8f7f6e8-9375-483c-b456-b479f1d7a4f2]: State changed to ${ running ? 'RUNNING' : 'FAILED' }: `, + payload: mockPolicyPayload(running ? 'success' : 'failure'), config_id: testConfigId, }, references: [], diff --git a/x-pack/plugins/security_solution/server/usage/endpoints/endpoint.test.ts b/x-pack/plugins/security_solution/server/usage/endpoints/endpoint.test.ts index 0b2f4e4ed9dbe..06755192bd818 100644 --- a/x-pack/plugins/security_solution/server/usage/endpoints/endpoint.test.ts +++ b/x-pack/plugins/security_solution/server/usage/endpoints/endpoint.test.ts @@ -20,12 +20,12 @@ import * as fleetSavedObjects from './fleet_saved_objects'; describe('test security solution endpoint telemetry', () => { let mockSavedObjectsRepository: jest.Mocked; let getFleetSavedObjectsMetadataSpy: jest.SpyInstance>>; - let getFleetEventsSavedObjectsSpy: jest.SpyInstance >>; beforeAll(() => { - getFleetEventsSavedObjectsSpy = jest.spyOn(fleetSavedObjects, 'getFleetEventsSavedObjects'); + getLatestFleetEndpointEventSpy = jest.spyOn(fleetSavedObjects, 'getLatestFleetEndpointEvent'); getFleetSavedObjectsMetadataSpy = jest.spyOn(fleetSavedObjects, 'getFleetSavedObjectsMetadata'); mockSavedObjectsRepository = savedObjectsRepositoryMock.create(); }); @@ -39,6 +39,13 @@ describe('test security solution endpoint telemetry', () => { Object { "active_within_last_24_hours": 0, "os": Array [], + "policies": Object { + "malware": Object { + "active": 0, + "failure": 0, + "inactive": 0, + }, + }, "total_installed": 0, } `); @@ -58,6 +65,13 @@ describe('test security solution endpoint telemetry', () => { total_installed: 0, active_within_last_24_hours: 0, os: [], + policies: { + malware: { + failure: 0, + active: 0, + inactive: 0, + }, + }, }); }); }); @@ -67,7 +81,7 @@ describe('test security solution endpoint telemetry', () => { getFleetSavedObjectsMetadataSpy.mockImplementation(() => Promise.resolve(mockFleetObjectsResponse()) ); - getFleetEventsSavedObjectsSpy.mockImplementation(() => + getLatestFleetEndpointEventSpy.mockImplementation(() => Promise.resolve(mockFleetEventsObjectsResponse()) ); @@ -85,6 +99,13 @@ describe('test security solution endpoint telemetry', () => { count: 1, }, ], + policies: { + malware: { + failure: 1, + active: 0, + inactive: 0, + }, + }, }); }); @@ -92,7 +113,7 @@ describe('test security solution endpoint telemetry', () => { getFleetSavedObjectsMetadataSpy.mockImplementation(() => Promise.resolve(mockFleetObjectsResponse()) ); - getFleetEventsSavedObjectsSpy.mockImplementation(() => + getLatestFleetEndpointEventSpy.mockImplementation(() => Promise.resolve(mockFleetEventsObjectsResponse(true)) ); @@ -110,6 +131,13 @@ describe('test security solution endpoint telemetry', () => { count: 1, }, ], + policies: { + malware: { + failure: 0, + active: 1, + inactive: 0, + }, + }, }); }); }); diff --git a/x-pack/plugins/security_solution/server/usage/endpoints/fleet_saved_objects.ts b/x-pack/plugins/security_solution/server/usage/endpoints/fleet_saved_objects.ts index 70657ed9f08f7..7e05fdec36169 100644 --- a/x-pack/plugins/security_solution/server/usage/endpoints/fleet_saved_objects.ts +++ b/x-pack/plugins/security_solution/server/usage/endpoints/fleet_saved_objects.ts @@ -19,17 +19,19 @@ export const getFleetSavedObjectsMetadata = async (savedObjectsClient: ISavedObj type: AGENT_SAVED_OBJECT_TYPE, fields: ['packages', 'last_checkin', 'local_metadata'], filter: `${AGENT_SAVED_OBJECT_TYPE}.attributes.packages: ${FLEET_ENDPOINT_PACKAGE_CONSTANT}`, + perPage: 10000, sortField: 'enrolled_at', sortOrder: 'desc', }); -export const getFleetEventsSavedObjects = async ( +export const getLatestFleetEndpointEvent = async ( savedObjectsClient: ISavedObjectsRepository, agentId: string ) => savedObjectsClient.find({ type: AGENT_EVENT_SAVED_OBJECT_TYPE, filter: `${AGENT_EVENT_SAVED_OBJECT_TYPE}.attributes.agent_id: ${agentId} and ${AGENT_EVENT_SAVED_OBJECT_TYPE}.attributes.message: "${FLEET_ENDPOINT_PACKAGE_CONSTANT}"`, + perPage: 1, // Get the most recent endpoint event. sortField: 'timestamp', sortOrder: 'desc', search: agentId, diff --git a/x-pack/plugins/security_solution/server/usage/endpoints/index.ts b/x-pack/plugins/security_solution/server/usage/endpoints/index.ts index 576d248613d1e..ab5669d503275 100644 --- a/x-pack/plugins/security_solution/server/usage/endpoints/index.ts +++ b/x-pack/plugins/security_solution/server/usage/endpoints/index.ts @@ -6,11 +6,7 @@ import { ISavedObjectsRepository } from 'src/core/server'; import { AgentMetadata } from '../../../../ingest_manager/common/types/models/agent'; -import { - getFleetSavedObjectsMetadata, - getFleetEventsSavedObjects, - FLEET_ENDPOINT_PACKAGE_CONSTANT, -} from './fleet_saved_objects'; +import { getFleetSavedObjectsMetadata, getLatestFleetEndpointEvent } from './fleet_saved_objects'; export interface AgentOSMetadataTelemetry { full_name: string; @@ -18,22 +14,25 @@ export interface AgentOSMetadataTelemetry { version: string; count: number; } +export interface PolicyTelemetry { + active: number; + inactive: number; + failure: number; +} export interface PoliciesTelemetry { - malware: { - success: number; - warning: number; - failure: number; - }; + malware: PolicyTelemetry; } export interface EndpointUsage { total_installed: number; active_within_last_24_hours: number; os: AgentOSMetadataTelemetry[]; - policies?: PoliciesTelemetry; // TODO: make required when able to enable policy information + policies: PoliciesTelemetry; } +type EndpointOSNames = 'Linux' | 'Windows' | 'macOs'; + export interface AgentLocalMetadata extends AgentMetadata { elastic: { agent: { @@ -51,7 +50,8 @@ export interface AgentLocalMetadata extends AgentMetadata { }; } -export type OSTracker = Record; +type OSTracker = Record; +type AgentDailyActiveTracker = Map; /** * @description returns an empty telemetry object to be incrmented and updated within the `getEndpointTelemetryFromFleet` fn */ @@ -59,8 +59,18 @@ export const getDefaultEndpointTelemetry = (): EndpointUsage => ({ total_installed: 0, active_within_last_24_hours: 0, os: [], + policies: { + malware: { + active: 0, + inactive: 0, + failure: 0, + }, + }, }); +/** + * @description this fun + */ export const trackEndpointOSTelemetry = ( os: AgentLocalMetadata['os'], osTracker: OSTracker @@ -82,6 +92,80 @@ export const trackEndpointOSTelemetry = ( return updatedOSTracker; }; +/** + * @description This iterates over all unique agents that currently track an endpoint package. It takes a list of agents who have checked in in the last 24 hours + * and then checks whether those agents have endpoints whose latest status is 'RUNNING' to determine an active_within_last_24_hours. Since the policy information is also tracked in these events + * we pull out the status of the current protection (malware) type. This must be done in a compound manner as the desired status is reflected in the config, and the successful application of that policy + * is tracked in the policy.applied.response.configurations[protectionsType].status. Using these two we can determine whether the policy is toggled on, off, or failed to turn on. + */ +export const addEndpointDailyActivityAndPolicyDetailsToTelemetry = async ( + agentDailyActiveTracker: AgentDailyActiveTracker, + savedObjectsClient: ISavedObjectsRepository, + endpointTelemetry: EndpointUsage +): Promise => { + const updatedEndpointTelemetry = { ...endpointTelemetry }; + + const policyHostTypeToPolicyType = { + Linux: 'linux', + macOs: 'mac', + Windows: 'windows', + }; + const enabledMalwarePolicyTypes = ['prevent', 'detect']; + + for (const agentId of agentDailyActiveTracker.keys()) { + const { saved_objects: agentEvents } = await getLatestFleetEndpointEvent( + savedObjectsClient, + agentId + ); + + const latestEndpointEvent = agentEvents[0]; + if (latestEndpointEvent) { + /* + We can assume that if the last status of the endpoint is RUNNING and the agent has checked in within the last 24 hours + then the endpoint has still been running within the last 24 hours. + */ + const { subtype, payload } = latestEndpointEvent.attributes; + const endpointIsActive = + subtype === 'RUNNING' && agentDailyActiveTracker.get(agentId) === true; + + if (endpointIsActive) { + updatedEndpointTelemetry.active_within_last_24_hours += 1; + } + + // The policy details are sent as a string on the 'payload' attribute of the agent event + const endpointPolicyDetails = payload ? JSON.parse(payload) : null; + if (endpointPolicyDetails) { + // We get the setting the user desired to enable (treating prevent and detect as 'active' states) and then see if it succeded or failed. + const hostType = + policyHostTypeToPolicyType[ + endpointPolicyDetails['endpoint-security']?.host?.os?.name as EndpointOSNames + ]; + const userDesiredMalwareState = + endpointPolicyDetails['endpoint-security'].Endpoint?.configuration?.inputs[0]?.policy[ + hostType + ]?.malware?.mode; + + const isAnActiveMalwareState = enabledMalwarePolicyTypes.includes(userDesiredMalwareState); + const malwareStatus = + endpointPolicyDetails['endpoint-security'].Endpoint?.policy?.applied?.response + ?.configurations?.malware?.status; + + if (isAnActiveMalwareState && malwareStatus !== 'failure') { + updatedEndpointTelemetry.policies.malware.active += 1; + } + if (!isAnActiveMalwareState) { + updatedEndpointTelemetry.policies.malware.inactive += 1; + } + if (isAnActiveMalwareState && malwareStatus === 'failure') { + updatedEndpointTelemetry.policies.malware.failure += 1; + } + } + } + } + + return updatedEndpointTelemetry; +}; + /** * @description This aggregates the telemetry details from the two fleet savedObject sources, `fleet-agents` and `fleet-agent-events` to populate * the telemetry details for endpoint. Since we cannot access our own indices due to `kibana_system` not having access, this is the best alternative. @@ -100,8 +184,8 @@ export const getEndpointTelemetryFromFleet = async ( // Use unique hosts to prevent any potential duplicates const uniqueHostIds: Set = new Set(); - // Need unique agents to get events data for those that have run in last 24 hours - const uniqueAgentIds: Set = new Set(); + // Need agents to get events data for those that have run in last 24 hours as well as policy details + const agentDailyActiveTracker: AgentDailyActiveTracker = new Map(); const aDayAgo = new Date(); aDayAgo.setDate(aDayAgo.getDate() - 1); @@ -110,17 +194,15 @@ export const getEndpointTelemetryFromFleet = async ( const endpointMetadataTelemetry = endpointAgents.reduce( (metadataTelemetry, { attributes: metadataAttributes }) => { const { last_checkin: lastCheckin, local_metadata: localMetadata } = metadataAttributes; - // The extended AgentMetadata is just an empty blob, so cast to account for our specific use case - const { host, os, elastic } = localMetadata as AgentLocalMetadata; + const { host, os, elastic } = localMetadata as AgentLocalMetadata; // AgentMetadata is just an empty blob, casting for our use case - if (lastCheckin && new Date(lastCheckin) > aDayAgo) { - // Get agents that have checked in within the last 24 hours to later see if their endpoints are running - uniqueAgentIds.add(elastic.agent.id); - } if (host && uniqueHostIds.has(host.id)) { + // use hosts since new agents could potentially be re-installed on existing hosts return metadataTelemetry; } else { uniqueHostIds.add(host.id); + const isActiveWithinLastDay = !!lastCheckin && new Date(lastCheckin) > aDayAgo; + agentDailyActiveTracker.set(elastic.agent.id, isActiveWithinLastDay); osTracker = trackEndpointOSTelemetry(os, osTracker); return metadataTelemetry; } @@ -128,32 +210,16 @@ export const getEndpointTelemetryFromFleet = async ( endpointTelemetry ); - // All unique agents with an endpoint installed. You can technically install a new agent on a host, so relying on most recently installed. + // All unique hosts with an endpoint installed. endpointTelemetry.total_installed = uniqueHostIds.size; - // Get the objects to populate our OS Telemetry endpointMetadataTelemetry.os = Object.values(osTracker); + // Populate endpoint telemetry with the finalized 24 hour count and policy details + const finalizedEndpointTelemetryData = await addEndpointDailyActivityAndPolicyDetailsToTelemetry( + agentDailyActiveTracker, + savedObjectsClient, + endpointMetadataTelemetry + ); - // Check for agents running in the last 24 hours whose endpoints are still active - for (const agentId of uniqueAgentIds) { - const { saved_objects: agentEvents } = await getFleetEventsSavedObjects( - savedObjectsClient, - agentId - ); - const lastEndpointStatus = agentEvents.find((agentEvent) => - agentEvent.attributes.message.includes(FLEET_ENDPOINT_PACKAGE_CONSTANT) - ); - - /* - We can assume that if the last status of the endpoint is RUNNING and the agent has checked in within the last 24 hours - then the endpoint has still been running within the last 24 hours. If / when we get the policy response, then we can use that - instead - */ - const endpointIsActive = lastEndpointStatus?.attributes.subtype === 'RUNNING'; - if (endpointIsActive) { - endpointMetadataTelemetry.active_within_last_24_hours += 1; - } - } - - return endpointMetadataTelemetry; + return finalizedEndpointTelemetryData; }; diff --git a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json index a7bc29f9efae2..fd21b70660bb6 100644 --- a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json +++ b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @@ -246,10 +246,10 @@ "properties": { "malware": { "properties": { - "success": { + "active": { "type": "long" }, - "warning": { + "inactive": { "type": "long" }, "failure": { diff --git a/x-pack/tasks/download_chromium.ts b/x-pack/tasks/download_chromium.ts new file mode 100644 index 0000000000000..1f7f8a92dfffb --- /dev/null +++ b/x-pack/tasks/download_chromium.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { LevelLogger } from '../plugins/reporting/server/lib'; +import { ensureBrowserDownloaded } from '../plugins/reporting/server/browsers/download'; + +export const downloadChromium = async () => { + // eslint-disable-next-line no-console + const consoleLogger = (tag: string) => (message: unknown) => console.log(tag, message); + const innerLogger = { + get: () => innerLogger, + debug: consoleLogger('debug'), + info: consoleLogger('info'), + warn: consoleLogger('warn'), + trace: consoleLogger('trace'), + error: consoleLogger('error'), + fatal: consoleLogger('fatal'), + log: consoleLogger('log'), + }; + + const levelLogger = new LevelLogger(innerLogger); + await ensureBrowserDownloaded(levelLogger); +}; diff --git a/yarn.lock b/yarn.lock index 0f144078ff46f..8e04560bd303e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5163,6 +5163,11 @@ resolved "https://registry.yarnpkg.com/@types/history/-/history-4.7.3.tgz#856c99cdc1551d22c22b18b5402719affec9839a" integrity sha512-cS5owqtwzLN5kY+l+KgKdRJ/Cee8tlmQoGQuIE9tWnSmS3JMKzmxo2HIAk2wODMifGwO20d62xZQLYz+RLfXmw== +"@types/hjson@^2.4.2": + version "2.4.2" + resolved "https://registry.yarnpkg.com/@types/hjson/-/hjson-2.4.2.tgz#fd0288a5b6778cda993c978e43cc978ddc8f22e9" + integrity sha512-MSKTfEyR8DbzJTOAY47BIJBD72ol4cu6BOw5inda0q1eEtEmurVHL4OmYB3Lxa4/DwXbWidkddvtoygbGQEDIw== + "@types/hoek@^4.1.3": version "4.1.3" resolved "https://registry.yarnpkg.com/@types/hoek/-/hoek-4.1.3.tgz#d1982d48fb0d2a0e5d7e9d91838264d8e428d337"