From b5b663e925bbfc023f8d7d06a9ab049a341ab0be Mon Sep 17 00:00:00 2001 From: Zacqary Adam Xeper Date: Wed, 2 Jun 2021 11:54:18 -0500 Subject: [PATCH 01/90] [Fleet] Add support for meta in fields.yml (#100931) * [Fleet] Add support for meta in fields.yml * Revert formatting changes to install.ts * Add mapping tests Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../elasticsearch/template/template.test.ts | 90 +++++++++++++++++++ .../epm/elasticsearch/template/template.ts | 18 ++++ .../fleet/server/services/epm/fields/field.ts | 4 + 3 files changed, 112 insertions(+) diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts index dcc685bb270b4..ae7bff618dba2 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts @@ -611,6 +611,96 @@ describe('EPM template', () => { expect(JSON.stringify(mappings)).toEqual(JSON.stringify(constantKeywordMapping)); }); + it('processes meta fields', () => { + const metaFieldLiteralYaml = ` +- name: fieldWithMetas + type: integer + unit: byte + metric_type: gauge + `; + const metaFieldMapping = { + properties: { + fieldWithMetas: { + type: 'long', + meta: { + metric_type: 'gauge', + unit: 'byte', + }, + }, + }, + }; + const fields: Field[] = safeLoad(metaFieldLiteralYaml); + const processedFields = processFields(fields); + const mappings = generateMappings(processedFields); + expect(JSON.stringify(mappings)).toEqual(JSON.stringify(metaFieldMapping)); + }); + + it('processes meta fields with only one meta value', () => { + const metaFieldLiteralYaml = ` +- name: fieldWithMetas + type: integer + metric_type: gauge + `; + const metaFieldMapping = { + properties: { + fieldWithMetas: { + type: 'long', + meta: { + metric_type: 'gauge', + }, + }, + }, + }; + const fields: Field[] = safeLoad(metaFieldLiteralYaml); + const processedFields = processFields(fields); + const mappings = generateMappings(processedFields); + expect(JSON.stringify(mappings)).toEqual(JSON.stringify(metaFieldMapping)); + }); + + it('processes grouped meta fields', () => { + const metaFieldLiteralYaml = ` +- name: groupWithMetas + type: group + unit: byte + metric_type: gauge + fields: + - name: fieldA + type: integer + unit: byte + metric_type: gauge + - name: fieldB + type: integer + unit: byte + metric_type: gauge + `; + const metaFieldMapping = { + properties: { + groupWithMetas: { + properties: { + fieldA: { + type: 'long', + meta: { + metric_type: 'gauge', + unit: 'byte', + }, + }, + fieldB: { + type: 'long', + meta: { + metric_type: 'gauge', + unit: 'byte', + }, + }, + }, + }, + }, + }; + const fields: Field[] = safeLoad(metaFieldLiteralYaml); + const processedFields = processFields(fields); + const mappings = generateMappings(processedFields); + expect(JSON.stringify(mappings)).toEqual(JSON.stringify(metaFieldMapping)); + }); + it('tests priority and index pattern for data stream without dataset_is_prefix', () => { const dataStreamDatasetIsPrefixUnset = { type: 'metrics', diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts index f6ca1dfc99f4e..64261226a7944 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts @@ -42,6 +42,8 @@ const DATASET_IS_PREFIX_TEMPLATE_PRIORITY = 150; const QUERY_DEFAULT_FIELD_TYPES = ['keyword', 'text']; const QUERY_DEFAULT_FIELD_LIMIT = 1024; +const META_PROP_KEYS = ['metric_type', 'unit']; + /** * getTemplate retrieves the default template but overwrites the index pattern with the given value. * @@ -162,6 +164,22 @@ export function generateMappings(fields: Field[]): IndexTemplateMappings { default: fieldProps.type = type; } + + const fieldHasMetaProps = META_PROP_KEYS.some((key) => key in field); + if (fieldHasMetaProps) { + switch (type) { + case 'group': + case 'group-nested': + break; + default: { + const meta = {}; + if ('metric_type' in field) Reflect.set(meta, 'metric_type', field.metric_type); + if ('unit' in field) Reflect.set(meta, 'unit', field.unit); + fieldProps.meta = meta; + } + } + } + props[field.name] = fieldProps; }); } diff --git a/x-pack/plugins/fleet/server/services/epm/fields/field.ts b/x-pack/plugins/fleet/server/services/epm/fields/field.ts index addcaf20cd146..b8839b88bb78c 100644 --- a/x-pack/plugins/fleet/server/services/epm/fields/field.ts +++ b/x-pack/plugins/fleet/server/services/epm/fields/field.ts @@ -34,6 +34,10 @@ export interface Field { include_in_parent?: boolean; include_in_root?: boolean; + // Meta fields + metric_type?: string; + unit?: string; + // Kibana specific analyzed?: boolean; count?: number; From d87e30e8c385eae4f599367db64f06a6b5172b34 Mon Sep 17 00:00:00 2001 From: DeDe Morton Date: Wed, 2 Jun 2021 10:02:26 -0700 Subject: [PATCH 02/90] Edit text strings in Heartbeat setup prompt (#100753) * Edit text strings in Heartbeat setup prompt * Update snapshot to fix test failure Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../__snapshots__/data_or_index_missing.test.tsx.snap | 4 ++-- .../overview/empty_state/data_or_index_missing.tsx | 6 +++--- .../public/components/overview/empty_state/empty_state.tsx | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/x-pack/plugins/uptime/public/components/overview/empty_state/__snapshots__/data_or_index_missing.test.tsx.snap b/x-pack/plugins/uptime/public/components/overview/empty_state/__snapshots__/data_or_index_missing.test.tsx.snap index 41e46259715ee..45e40f71c0fde 100644 --- a/x-pack/plugins/uptime/public/components/overview/empty_state/__snapshots__/data_or_index_missing.test.tsx.snap +++ b/x-pack/plugins/uptime/public/components/overview/empty_state/__snapshots__/data_or_index_missing.test.tsx.snap @@ -51,14 +51,14 @@ exports[`DataOrIndexMissing component renders headingMessage 1`] = `

diff --git a/x-pack/plugins/uptime/public/components/overview/empty_state/data_or_index_missing.tsx b/x-pack/plugins/uptime/public/components/overview/empty_state/data_or_index_missing.tsx index 77927b5750ff3..7f9839ff94dbe 100644 --- a/x-pack/plugins/uptime/public/components/overview/empty_state/data_or_index_missing.tsx +++ b/x-pack/plugins/uptime/public/components/overview/empty_state/data_or_index_missing.tsx @@ -43,14 +43,14 @@ export const DataOrIndexMissing = ({ headingMessage, settings }: DataMissingProp

diff --git a/x-pack/plugins/uptime/public/components/overview/empty_state/empty_state.tsx b/x-pack/plugins/uptime/public/components/overview/empty_state/empty_state.tsx index 5a28c7c2592d7..a6fd6579c49fa 100644 --- a/x-pack/plugins/uptime/public/components/overview/empty_state/empty_state.tsx +++ b/x-pack/plugins/uptime/public/components/overview/empty_state/empty_state.tsx @@ -38,7 +38,7 @@ export const EmptyStateComponent = ({ const noIndicesMessage = ( {settings?.heartbeatIndices} }} /> ); From e607b5859092317a9aa5a04a4f37c13dec53f0fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20C=C3=B4t=C3=A9?= Date: Wed, 2 Jun 2021 13:08:09 -0400 Subject: [PATCH 03/90] Fix alerting health API to consider rules in all spaces (#100879) * Initial commit * Expand tests Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../alerting/server/health/get_health.ts | 4 + .../alerting_api_integration/common/config.ts | 1 + .../tests/alerting/health.ts | 128 ++++++++++++++++++ .../tests/alerting/index.ts | 1 + 4 files changed, 134 insertions(+) create mode 100644 x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/health.ts diff --git a/x-pack/plugins/alerting/server/health/get_health.ts b/x-pack/plugins/alerting/server/health/get_health.ts index 4a0266c9b729f..6966c9b75ca43 100644 --- a/x-pack/plugins/alerting/server/health/get_health.ts +++ b/x-pack/plugins/alerting/server/health/get_health.ts @@ -34,6 +34,7 @@ export const getHealth = async ( sortOrder: 'desc', page: 1, perPage: 1, + namespaces: ['*'], }); if (decryptErrorData.length > 0) { @@ -51,6 +52,7 @@ export const getHealth = async ( sortOrder: 'desc', page: 1, perPage: 1, + namespaces: ['*'], }); if (executeErrorData.length > 0) { @@ -68,6 +70,7 @@ export const getHealth = async ( sortOrder: 'desc', page: 1, perPage: 1, + namespaces: ['*'], }); if (readErrorData.length > 0) { @@ -83,6 +86,7 @@ export const getHealth = async ( type: 'alert', sortField: 'executionStatus.lastExecutionDate', sortOrder: 'desc', + namespaces: ['*'], }); const lastExecutionDate = noErrorData.length > 0 diff --git a/x-pack/test/alerting_api_integration/common/config.ts b/x-pack/test/alerting_api_integration/common/config.ts index c56e8adfbe34f..548b4d0db1124 100644 --- a/x-pack/test/alerting_api_integration/common/config.ts +++ b/x-pack/test/alerting_api_integration/common/config.ts @@ -151,6 +151,7 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions) `--xpack.actions.allowedHosts=${JSON.stringify(['localhost', 'some.non.existent.com'])}`, '--xpack.encryptedSavedObjects.encryptionKey="wuGNaIhoMpk5sO4UBxgr3NyW1sFcLgIf"', '--xpack.alerting.invalidateApiKeysTask.interval="15s"', + '--xpack.alerting.healthCheck.interval="1s"', `--xpack.actions.enabledActionTypes=${JSON.stringify(enabledActionTypes)}`, `--xpack.actions.rejectUnauthorized=${rejectUnauthorized}`, `--xpack.actions.tls.verificationMode=${verificationMode}`, diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/health.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/health.ts new file mode 100644 index 0000000000000..668de3eb4fb9e --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/health.ts @@ -0,0 +1,128 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { UserAtSpaceScenarios } from '../../scenarios'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; +import { + getUrlPrefix, + getTestAlertData, + ObjectRemover, + AlertUtils, + ESTestIndexTool, + ES_TEST_INDEX_NAME, +} from '../../../common/lib'; + +// eslint-disable-next-line import/no-default-export +export default function createFindTests({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const es = getService('legacyEs'); + const retry = getService('retry'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const esTestIndexTool = new ESTestIndexTool(es, retry); + + describe('health', () => { + const objectRemover = new ObjectRemover(supertest); + + before(async () => { + await esTestIndexTool.destroy(); + await esTestIndexTool.setup(); + }); + + after(async () => { + await esTestIndexTool.destroy(); + }); + + for (const scenario of UserAtSpaceScenarios) { + const { user, space } = scenario; + + describe(scenario.id, () => { + let alertUtils: AlertUtils; + let indexRecordActionId: string; + + before(async () => { + const { body: createdAction } = await supertest + .post(`${getUrlPrefix(space.id)}/api/actions/connector`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'My action', + connector_type_id: 'test.index-record', + config: { + unencrypted: `This value shouldn't get encrypted`, + }, + secrets: { + encrypted: 'This value should be encrypted', + }, + }) + .expect(200); + indexRecordActionId = createdAction.id; + objectRemover.add(space.id, indexRecordActionId, 'connector', 'actions'); + + alertUtils = new AlertUtils({ + user, + space, + supertestWithoutAuth, + indexRecordActionId, + objectRemover, + }); + }); + + after(() => objectRemover.removeAll()); + + it('should return healthy status by default', async () => { + const { body: health } = await supertestWithoutAuth + .get(`${getUrlPrefix(space.id)}/api/alerting/_health`) + .auth(user.username, user.password); + expect(health.is_sufficiently_secure).to.eql(true); + expect(health.has_permanent_encryption_key).to.eql(true); + expect(health.alerting_framework_heath.decryption_health.status).to.eql('ok'); + expect(health.alerting_framework_heath.execution_health.status).to.eql('ok'); + expect(health.alerting_framework_heath.read_health.status).to.eql('ok'); + }); + + it('should return error when a rule in the default space is failing', async () => { + const reference = alertUtils.generateReference(); + const { body: createdRule } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send( + getTestAlertData({ + schedule: { + interval: '5m', + }, + rule_type_id: 'test.failing', + params: { + index: ES_TEST_INDEX_NAME, + reference, + }, + }) + ) + .expect(200); + objectRemover.add(space.id, createdRule.id, 'rule', 'alerting'); + + const ruleInErrorStatus = await retry.tryForTime(30000, async () => { + const { body: rule } = await supertest + .get(`${getUrlPrefix(space.id)}/api/alerting/rule/${createdRule.id}`) + .expect(200); + expect(rule.execution_status.status).to.eql('error'); + return rule; + }); + + await retry.tryForTime(30000, async () => { + const { body: health } = await supertestWithoutAuth + .get(`${getUrlPrefix(space.id)}/api/alerting/_health`) + .auth(user.username, user.password); + expect(health.alerting_framework_heath.execution_health.status).to.eql('warn'); + expect(health.alerting_framework_heath.execution_health.timestamp).to.eql( + ruleInErrorStatus.execution_status.last_execution_date + ); + }); + }); + }); + } + }); +} diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/index.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/index.ts index b1b52d89997cd..6ca68bd188124 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/index.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/index.ts @@ -51,6 +51,7 @@ export default function alertingTests({ loadTestFile, getService }: FtrProviderC loadTestFile(require.resolve('./alerts')); loadTestFile(require.resolve('./event_log')); loadTestFile(require.resolve('./mustache_templates')); + loadTestFile(require.resolve('./health')); }); }); } From 66553681c085e7a31d11a6f96139acaffa5d0e24 Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Wed, 2 Jun 2021 13:44:04 -0400 Subject: [PATCH 04/90] [Fleet] Fix host input with empty value (#101178) --- .../components/settings_flyout/hosts_input.test.tsx | 9 +++++++++ .../fleet/components/settings_flyout/hosts_input.tsx | 6 +++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout/hosts_input.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout/hosts_input.test.tsx index 27bf5af72fb61..f441cfd951ba9 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout/hosts_input.test.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout/hosts_input.test.tsx @@ -66,3 +66,12 @@ test('it should allow to update existing host with multiple hosts', async () => fireEvent.change(inputEl, { target: { value: 'http://newhost.com' } }); expect(mockOnChange).toHaveBeenCalledWith(['http://newhost.com', 'http://host2.com']); }); + +test('it should render an input if there is not hosts', async () => { + const { utils, mockOnChange } = renderInput([]); + + const inputEl = await utils.findByDisplayValue(''); + expect(inputEl).toBeDefined(); + fireEvent.change(inputEl, { target: { value: 'http://newhost.com' } }); + expect(mockOnChange).toHaveBeenCalledWith(['http://newhost.com']); +}); diff --git a/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout/hosts_input.tsx b/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout/hosts_input.tsx index 0e5f9a5e028b5..6c87a983f58a4 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout/hosts_input.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout/hosts_input.tsx @@ -132,7 +132,7 @@ const SortableTextField: FunctionComponent = React.memo( export const HostsInput: FunctionComponent = ({ id, - value, + value: valueFromProps, onChange, helpText, label, @@ -140,6 +140,10 @@ export const HostsInput: FunctionComponent = ({ errors, }) => { const [autoFocus, setAutoFocus] = useState(false); + const value = useMemo(() => { + return valueFromProps.length ? valueFromProps : ['']; + }, [valueFromProps]); + const rows = useMemo( () => value.map((host, idx) => ({ From dc5511f73bfb631b50e4ddf4ebe6a6b8c6bbdfc8 Mon Sep 17 00:00:00 2001 From: Tre Date: Wed, 2 Jun 2021 12:28:59 -0600 Subject: [PATCH 05/90] [QA] Bind the retry to fixup error in it repo tests (#100948) Verfiied via: https://internal-ci.elastic.co/view/All/job/elastic+integration-test+master/487/ --- .../apps/reporting/reporting_watcher.js | 2 +- .../apps/reporting/reporting_watcher_png.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/x-pack/test/stack_functional_integration/apps/reporting/reporting_watcher.js b/x-pack/test/stack_functional_integration/apps/reporting/reporting_watcher.js index 31eb6d65ce7ac..fb881162f51e8 100644 --- a/x-pack/test/stack_functional_integration/apps/reporting/reporting_watcher.js +++ b/x-pack/test/stack_functional_integration/apps/reporting/reporting_watcher.js @@ -85,7 +85,7 @@ export default function ({ getService, getPageObjects }) { await putWatcher(watch, id, body, client, log); }); it('should be successful and increment revision', async () => { - await getWatcher(watch, id, client, log, PageObjects.common, retry.tryForTime); + await getWatcher(watch, id, client, log, PageObjects.common, retry.tryForTime.bind(retry)); }); it('should delete watch and update revision', async () => { await deleteWatcher(watch, id, client, log); diff --git a/x-pack/test/stack_functional_integration/apps/reporting/reporting_watcher_png.js b/x-pack/test/stack_functional_integration/apps/reporting/reporting_watcher_png.js index 7e9ced57fdc0b..db913f563ebb0 100644 --- a/x-pack/test/stack_functional_integration/apps/reporting/reporting_watcher_png.js +++ b/x-pack/test/stack_functional_integration/apps/reporting/reporting_watcher_png.js @@ -79,7 +79,7 @@ export default ({ getService, getPageObjects }) => { await putWatcher(watch, id, body, client, log); }); it('should be successful and increment revision', async () => { - await getWatcher(watch, id, client, log, PageObjects.common, retry.tryForTime); + await getWatcher(watch, id, client, log, PageObjects.common, retry.tryForTime.bind(retry)); }); it('should delete watch and update revision', async () => { await deleteWatcher(watch, id, client, log); From 6724a474dee0d2590996200c99ba2bffcae09560 Mon Sep 17 00:00:00 2001 From: Jen Huang Date: Wed, 2 Jun 2021 11:39:18 -0700 Subject: [PATCH 06/90] Convert $json to json in package README code blocks (#101187) --- .../epm/screens/detail/overview/markdown_renderers.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/overview/markdown_renderers.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/overview/markdown_renderers.tsx index 47c327b17c241..cbc2f7b5f7888 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/overview/markdown_renderers.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/overview/markdown_renderers.tsx @@ -60,8 +60,10 @@ export const markdownRenderers = { ), code: ({ language, value }: { language: string; value: string }) => { + // Old packages are using `$json`, which is not valid any more with the move to prism.js + const parsedLang = language === '$json' ? 'json' : language; return ( - + {value} ); From dbac313d406f4ae44351b134859fb7ecdd8962bd Mon Sep 17 00:00:00 2001 From: gchaps <33642766+gchaps@users.noreply.github.com> Date: Wed, 2 Jun 2021 11:42:26 -0700 Subject: [PATCH 07/90] [DOCS] Updates homebrew content to use latest version (#101199) --- docs/setup/install/brew.asciidoc | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/docs/setup/install/brew.asciidoc b/docs/setup/install/brew.asciidoc index e3085f6e225aa..eeba869a259d4 100644 --- a/docs/setup/install/brew.asciidoc +++ b/docs/setup/install/brew.asciidoc @@ -14,15 +14,13 @@ brew tap elastic/tap ------------------------- Once you've tapped the Elastic Homebrew repo, you can use `brew install` to -install the default distribution of {kib}: +install the **latest version** of {kib}: [source,sh] ------------------------- brew install elastic/tap/kibana-full ------------------------- -This installs the most recently released distribution of {kib}. - [[brew-layout]] ==== Directory layout for Homebrew installs From 8e48d48f86633011b3e7eb411e8cfe672fa54c08 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Wed, 2 Jun 2021 20:20:26 +0100 Subject: [PATCH 08/90] docs(NA): update developer getting started guide to build on windows within Bazel (#101181) --- docs/developer/getting-started/index.asciidoc | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/developer/getting-started/index.asciidoc b/docs/developer/getting-started/index.asciidoc index ac8eff132fcfe..a28a95605bc6a 100644 --- a/docs/developer/getting-started/index.asciidoc +++ b/docs/developer/getting-started/index.asciidoc @@ -14,9 +14,14 @@ In order to support Windows development we currently require you to use one of t As well as installing https://www.microsoft.com/en-us/download/details.aspx?id=48145[Visual C++ Redistributable for Visual Studio 2015]. +In addition we also require you to do the following: + +- Install https://www.microsoft.com/en-us/download/details.aspx?id=48145[Visual C++ Redistributable for Visual Studio 2015] +- Enable the https://docs.microsoft.com/en-us/windows/apps/get-started/enable-your-device-for-development[Windows Developer Mode] +- Enable https://docs.microsoft.com/en-us/windows-server/administration/windows-commands/fsutil-8dot3name[8.3 filename support] by running the following command in a windows command prompt with admin rights `fsutil 8dot3name set 0` + Before running the steps listed below, please make sure you have installed everything -that we require and listed above and that you are running the mentioned commands -through Git bash or WSL. +that we require and listed above and that you are running all the commands from now on through Git bash or WSL. [discrete] [[get-kibana-code]] From 98527ad232a823ae72731062435f707d79644732 Mon Sep 17 00:00:00 2001 From: spalger Date: Wed, 2 Jun 2021 14:14:54 -0700 Subject: [PATCH 09/90] skip suite failing es promotion (#101219) --- .../apis/management/index_lifecycle_management/policies.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/api_integration/apis/management/index_lifecycle_management/policies.js b/x-pack/test/api_integration/apis/management/index_lifecycle_management/policies.js index 55b4da8b11ec2..8f40f5826c537 100644 --- a/x-pack/test/api_integration/apis/management/index_lifecycle_management/policies.js +++ b/x-pack/test/api_integration/apis/management/index_lifecycle_management/policies.js @@ -32,7 +32,8 @@ export default function ({ getService }) { const { addPolicyToIndex } = registerIndexHelpers({ supertest }); - describe('policies', () => { + // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/101219 + describe.skip('policies', () => { after(() => Promise.all([cleanUpEsResources(), cleanUpPolicies()])); describe('list', () => { From 71b4c38c4a579b0b4871b9f437d6d855f5076511 Mon Sep 17 00:00:00 2001 From: Steph Milovic Date: Wed, 2 Jun 2021 17:37:11 -0600 Subject: [PATCH 10/90] [Security Solution] [Bug Fix] Fix flakey cypress tests (#101231) --- .../cypress/support/commands.js | 57 ++----------------- 1 file changed, 6 insertions(+), 51 deletions(-) diff --git a/x-pack/plugins/security_solution/cypress/support/commands.js b/x-pack/plugins/security_solution/cypress/support/commands.js index d9de00be0ea9e..90eb9a38d7509 100644 --- a/x-pack/plugins/security_solution/cypress/support/commands.js +++ b/x-pack/plugins/security_solution/cypress/support/commands.js @@ -31,62 +31,17 @@ // -- This is will overwrite an existing command -- // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) -import { findIndex } from 'lodash/fp'; - -const getFindRequestConfig = (searchStrategyName, factoryQueryType) => { - if (!factoryQueryType) { - return { - options: { strategy: searchStrategyName }, - }; - } - - return { - options: { strategy: searchStrategyName }, - request: { factoryQueryType }, - }; -}; - Cypress.Commands.add( 'stubSearchStrategyApi', function (stubObject, factoryQueryType, searchStrategyName = 'securitySolutionSearchStrategy') { cy.intercept('POST', '/internal/bsearch', (req) => { - const findRequestConfig = getFindRequestConfig(searchStrategyName, factoryQueryType); - const requestIndex = findIndex(findRequestConfig, req.body.batch); - - if (requestIndex > -1) { - return req.reply((res) => { - const responseObjectsArray = res.body.split('\n').map((responseString) => { - try { - return JSON.parse(responseString); - } catch { - return responseString; - } - }); - const responseIndex = findIndex({ id: requestIndex }, responseObjectsArray); - - const stubbedResponseObjectsArray = [...responseObjectsArray]; - stubbedResponseObjectsArray[responseIndex] = { - ...stubbedResponseObjectsArray[responseIndex], - result: { - ...stubbedResponseObjectsArray[responseIndex].result, - ...stubObject, - }, - }; - - const stubbedResponse = stubbedResponseObjectsArray - .map((object) => { - try { - return JSON.stringify(object); - } catch { - return object; - } - }) - .join('\n'); - - res.send(stubbedResponse); - }); + if (searchStrategyName === 'securitySolutionIndexFields') { + req.reply(stubObject.rawResponse); + } else if (factoryQueryType === 'overviewHost') { + req.reply(stubObject.overviewHost); + } else if (factoryQueryType === 'overviewNetwork') { + req.reply(stubObject.overviewNetwork); } - req.reply(); }); } From 45ae6cc39b09e6ee4132ed32f74df290e532ed40 Mon Sep 17 00:00:00 2001 From: Yuliia Naumenko Date: Wed, 2 Jun 2021 22:33:43 -0700 Subject: [PATCH 11/90] [Alerting UI] Reduced triggersActionsUi bundle size by making all action types UI validation messages translations asynchronous. (#100525) * [Alerting UI] Reduced triggersActionsUi bundle size by making all connectors validation messages translations asyncronus. * changed validation logic to be async * fixed action form * fixed tests * fixed tests * fixed validation usage in security * fixed due to comments * fixed due to comments * added spinner for the validation awaiting * fixed typechecks Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../components/connectors/case/index.ts | 4 +- .../public/alerts/alert_form.test.tsx | 8 +- .../rules/step_rule_actions/index.tsx | 1 + .../rules/step_rule_actions/schema.test.tsx | 24 ++--- .../rules/step_rule_actions/schema.tsx | 28 ++--- .../rules/step_rule_actions/utils.test.ts | 16 +-- .../rules/step_rule_actions/utils.ts | 6 +- x-pack/plugins/triggers_actions_ui/README.md | 32 +++--- .../builtin_action_types/email/email.test.tsx | 28 ++--- .../builtin_action_types/email/email.tsx | 100 ++++-------------- .../email/email_connector.tsx | 31 ++++-- .../email/email_params.tsx | 25 +++-- .../email/translations.ts | 78 ++++++++++++++ .../es_index/es_index.test.tsx | 24 ++--- .../es_index/es_index.tsx | 37 ++----- .../es_index/es_index_connector.tsx | 6 +- .../es_index/es_index_params.tsx | 6 +- .../es_index/translations.ts | 29 +++++ .../builtin_action_types/jira/jira.test.tsx | 20 ++-- .../builtin_action_types/jira/jira.tsx | 46 +++++--- .../jira/jira_connectors.tsx | 12 ++- .../builtin_action_types/jira/jira_params.tsx | 2 + .../builtin_action_types/jira/translations.ts | 14 --- .../pagerduty/pagerduty.test.tsx | 14 +-- .../pagerduty/pagerduty.tsx | 39 ++----- .../pagerduty/pagerduty_connectors.tsx | 7 +- .../pagerduty/pagerduty_params.tsx | 14 ++- .../pagerduty/translations.ts | 29 +++++ .../resilient/resilient.test.tsx | 16 +-- .../resilient/resilient.tsx | 47 +++++--- .../resilient/resilient_connectors.tsx | 13 ++- .../resilient/resilient_params.tsx | 6 +- .../resilient/translations.ts | 14 --- .../server_log/server_log.test.tsx | 12 +-- .../server_log/server_log.tsx | 8 +- .../servicenow/servicenow.test.tsx | 16 +-- .../servicenow/servicenow.tsx | 67 +++++++++--- .../servicenow/servicenow_connectors.tsx | 9 +- .../servicenow/servicenow_itsm_params.tsx | 1 + .../servicenow/servicenow_sir_params.tsx | 1 + .../servicenow/translations.ts | 28 ----- .../builtin_action_types/slack/slack.test.tsx | 24 ++--- .../builtin_action_types/slack/slack.tsx | 46 ++------ .../slack/slack_connectors.tsx | 6 +- .../slack/slack_params.tsx | 2 +- .../slack/translations.ts | 36 +++++++ .../builtin_action_types/teams/teams.test.tsx | 24 ++--- .../builtin_action_types/teams/teams.tsx | 46 ++------ .../teams/teams_connectors.tsx | 7 +- .../teams/teams_params.tsx | 2 +- .../teams/translations.ts | 36 +++++++ .../webhook/translations.ts | 64 +++++++++++ .../webhook/webhook.test.tsx | 24 ++--- .../builtin_action_types/webhook/webhook.tsx | 85 +++------------ .../webhook/webhook_connectors.tsx | 25 +++-- .../action_connector_form.test.tsx | 8 +- .../action_connector_form.tsx | 11 +- .../action_form.test.tsx | 40 +++---- .../action_connector_form/action_form.tsx | 81 +++++++------- .../action_type_form.test.tsx | 17 ++- .../action_type_form.tsx | 17 ++- .../action_type_menu.test.tsx | 24 ++--- .../connector_add_flyout.test.tsx | 8 +- .../connector_add_flyout.tsx | 76 +++++++++---- .../connector_add_modal.test.tsx | 8 +- .../connector_add_modal.tsx | 79 ++++++++++---- .../connector_edit_flyout.test.tsx | 16 +-- .../connector_edit_flyout.tsx | 97 ++++++++++------- .../test_connector_form.test.tsx | 8 +- .../test_connector_form.tsx | 13 ++- .../actions_connectors_list.test.tsx | 8 +- .../sections/alert_form/alert_add.test.tsx | 8 +- .../sections/alert_form/alert_add.tsx | 21 +++- .../sections/alert_form/alert_add_footer.tsx | 18 +++- .../sections/alert_form/alert_edit.test.tsx | 8 +- .../sections/alert_form/alert_edit.tsx | 35 ++++-- .../sections/alert_form/alert_form.test.tsx | 10 +- .../sections/alert_form/alert_form.tsx | 24 +++-- .../public/application/type_registry.test.ts | 8 +- .../triggers_actions_ui/public/types.ts | 4 +- 80 files changed, 1149 insertions(+), 843 deletions(-) create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/translations.ts create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/translations.ts create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/translations.ts create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/translations.ts create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/translations.ts create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/translations.ts diff --git a/x-pack/plugins/cases/public/components/connectors/case/index.ts b/x-pack/plugins/cases/public/components/connectors/case/index.ts index c2cf4980da7ec..8e6680cd65387 100644 --- a/x-pack/plugins/cases/public/components/connectors/case/index.ts +++ b/x-pack/plugins/cases/public/components/connectors/case/index.ts @@ -25,7 +25,7 @@ const validateParams = (actionParams: CaseActionParams) => { validationResult.errors.caseId.push(i18n.CASE_CONNECTOR_CASE_REQUIRED); } - return validationResult; + return Promise.resolve(validationResult); }; export function getActionType(): ActionTypeModel { @@ -34,7 +34,7 @@ export function getActionType(): ActionTypeModel { iconClass: 'securityAnalyticsApp', selectMessage: i18n.CASE_CONNECTOR_DESC, actionTypeTitle: i18n.CASE_CONNECTOR_TITLE, - validateConnector: () => ({ config: { errors: {} }, secrets: { errors: {} } }), + validateConnector: () => Promise.resolve({ config: { errors: {} }, secrets: { errors: {} } }), validateParams, actionConnectorFields: null, actionParamsFields: lazy(() => import('./alert_fields')), diff --git a/x-pack/plugins/monitoring/public/alerts/alert_form.test.tsx b/x-pack/plugins/monitoring/public/alerts/alert_form.test.tsx index 11642f4083d39..3eda13f5bcb38 100644 --- a/x-pack/plugins/monitoring/public/alerts/alert_form.test.tsx +++ b/x-pack/plugins/monitoring/public/alerts/alert_form.test.tsx @@ -94,12 +94,12 @@ describe('alert_form', () => { id: 'alert-action-type', iconClass: '', selectMessage: '', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, actionParamsFields: mockedActionParamsFields, 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 a31371c31cbbb..8a85d35d77fac 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 @@ -89,6 +89,7 @@ const StepRuleActionsComponent: FC = ({ ...(defaultValues ?? stepActionsDefaultValue), kibanaSiemAppUrl: kibanaAbsoluteUrl, }; + const schema = useMemo(() => getSchema({ actionTypeRegistry }), [actionTypeRegistry]); const { form } = useForm({ defaultValue: initialState, diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/schema.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/schema.test.tsx index 992f30e795bbf..3266d6f61eeed 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/schema.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/schema.test.tsx @@ -15,13 +15,13 @@ describe('stepRuleActions schema', () => { const actionTypeRegistry = actionTypeRegistryMock.create(); describe('validateSingleAction', () => { - it('should validate single action', () => { + it('should validate single action', async () => { (isUuid as jest.Mock).mockReturnValue(true); (validateActionParams as jest.Mock).mockReturnValue([]); (validateMustache as jest.Mock).mockReturnValue([]); expect( - validateSingleAction( + await validateSingleAction( { id: '817b8bca-91d1-4729-8ee1-3a83aaafd9d4', group: 'default', @@ -33,12 +33,12 @@ describe('stepRuleActions schema', () => { ).toHaveLength(0); }); - it('should validate single action with invalid mustache template', () => { + it('should validate single action with invalid mustache template', async () => { (isUuid as jest.Mock).mockReturnValue(true); (validateActionParams as jest.Mock).mockReturnValue([]); (validateMustache as jest.Mock).mockReturnValue(['Message is not valid mustache template']); - const errors = validateSingleAction( + const errors = await validateSingleAction( { id: '817b8bca-91d1-4729-8ee1-3a83aaafd9d4', group: 'default', @@ -54,12 +54,12 @@ describe('stepRuleActions schema', () => { expect(errors[0]).toEqual('Message is not valid mustache template'); }); - it('should validate single action with incorrect id', () => { + it('should validate single action with incorrect id', async () => { (isUuid as jest.Mock).mockReturnValue(false); (validateMustache as jest.Mock).mockReturnValue([]); (validateActionParams as jest.Mock).mockReturnValue([]); - const errors = validateSingleAction( + const errors = await validateSingleAction( { id: '823d4', group: 'default', @@ -74,10 +74,10 @@ describe('stepRuleActions schema', () => { }); describe('validateRuleActionsField', () => { - it('should validate rule actions field', () => { + it('should validate rule actions field', async () => { const validator = validateRuleActionsField(actionTypeRegistry); - const result = validator({ + const result = await validator({ path: '', value: [], form: {} as FormHook, @@ -88,11 +88,11 @@ describe('stepRuleActions schema', () => { expect(result).toEqual(undefined); }); - it('should validate incorrect rule actions field', () => { + it('should validate incorrect rule actions field', async () => { (getActionTypeName as jest.Mock).mockReturnValue('Slack'); const validator = validateRuleActionsField(actionTypeRegistry); - const result = validator({ + const result = await validator({ path: '', value: [ { @@ -117,7 +117,7 @@ describe('stepRuleActions schema', () => { }); }); - it('should validate multiple incorrect rule actions field', () => { + it('should validate multiple incorrect rule actions field', async () => { (isUuid as jest.Mock).mockReturnValueOnce(false); (getActionTypeName as jest.Mock).mockReturnValueOnce('Slack'); (isUuid as jest.Mock).mockReturnValueOnce(true); @@ -126,7 +126,7 @@ describe('stepRuleActions schema', () => { (validateMustache as jest.Mock).mockReturnValue(['Component is not valid mustache template']); const validator = validateRuleActionsField(actionTypeRegistry); - const result = validator({ + const result = await validator({ path: '', value: [ { diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/schema.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/schema.tsx index bc32bdc387cd2..a697d922eda97 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/schema.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/schema.tsx @@ -13,42 +13,46 @@ import { AlertAction, ActionTypeRegistryContract, } from '../../../../../../triggers_actions_ui/public'; -import { FormSchema, ValidationFunc, ERROR_CODE } from '../../../../shared_imports'; +import { + FormSchema, + ValidationFunc, + ERROR_CODE, + ValidationError, +} from '../../../../shared_imports'; import { ActionsStepRule } from '../../../pages/detection_engine/rules/types'; import * as I18n from './translations'; import { isUuid, getActionTypeName, validateMustache, validateActionParams } from './utils'; -export const validateSingleAction = ( +export const validateSingleAction = async ( actionItem: AlertAction, actionTypeRegistry: ActionTypeRegistryContract -): string[] => { +): Promise => { if (!isUuid(actionItem.id)) { return [I18n.NO_CONNECTOR_SELECTED]; } - const actionParamsErrors = validateActionParams(actionItem, actionTypeRegistry); + const actionParamsErrors = await validateActionParams(actionItem, actionTypeRegistry); const mustacheErrors = validateMustache(actionItem.params); return [...actionParamsErrors, ...mustacheErrors]; }; -export const validateRuleActionsField = (actionTypeRegistry: ActionTypeRegistryContract) => ( +export const validateRuleActionsField = (actionTypeRegistry: ActionTypeRegistryContract) => async ( ...data: Parameters -): ReturnType> | undefined => { +): Promise | void | undefined> => { const [{ value, path }] = data as [{ value: AlertAction[]; path: string }]; - const errors = value.reduce((acc, actionItem) => { - const errorsArray = validateSingleAction(actionItem, actionTypeRegistry); + const errors = []; + for (const actionItem of value) { + const errorsArray = await validateSingleAction(actionItem, actionTypeRegistry); if (errorsArray.length) { const actionTypeName = getActionTypeName(actionItem.actionTypeId); const errorsListItems = errorsArray.map((error) => `* ${error}\n`); - return [...acc, `\n**${actionTypeName}:**\n${errorsListItems.join('')}`]; + errors.push(`\n**${actionTypeName}:**\n${errorsListItems.join('')}`); } - - return acc; - }, [] as string[]); + } if (errors.length) { return { diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/utils.test.ts b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/utils.test.ts index 3d7299c1673b1..7c4ea71c983c8 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/utils.test.ts +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/utils.test.ts @@ -61,11 +61,11 @@ describe('stepRuleActions utils', () => { actionTypeRegistry.get.mockReturnValue(actionMock); }); - it('should validate action params', () => { + it('should validate action params', async () => { validateParamsMock.mockReturnValue({ errors: [] }); expect( - validateActionParams( + await validateActionParams( { id: '817b8bca-91d1-4729-8ee1-3a83aaafd9d4', group: 'default', @@ -79,13 +79,13 @@ describe('stepRuleActions utils', () => { ).toHaveLength(0); }); - it('should validate incorrect action params', () => { + it('should validate incorrect action params', async () => { validateParamsMock.mockReturnValue({ errors: ['Message is required'], }); expect( - validateActionParams( + await validateActionParams( { id: '817b8bca-91d1-4729-8ee1-3a83aaafd9d4', group: 'default', @@ -97,7 +97,7 @@ describe('stepRuleActions utils', () => { ).toHaveLength(1); }); - it('should validate incorrect action params and filter error objects', () => { + it('should validate incorrect action params and filter error objects', async () => { validateParamsMock.mockReturnValue({ errors: [ { @@ -107,7 +107,7 @@ describe('stepRuleActions utils', () => { }); expect( - validateActionParams( + await validateActionParams( { id: '817b8bca-91d1-4729-8ee1-3a83aaafd9d4', group: 'default', @@ -119,13 +119,13 @@ describe('stepRuleActions utils', () => { ).toHaveLength(0); }); - it('should validate incorrect action params and filter duplicated errors', () => { + it('should validate incorrect action params and filter duplicated errors', async () => { validateParamsMock.mockReturnValue({ errors: ['Message is required', 'Message is required', 'Message is required'], }); expect( - validateActionParams( + await validateActionParams( { id: '817b8bca-91d1-4729-8ee1-3a83aaafd9d4', group: 'default', diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/utils.ts b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/utils.ts index d241d4283fc77..22363df5164a6 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/utils.ts +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/utils.ts @@ -41,11 +41,11 @@ export const validateMustache = (params: AlertAction['params']) => { return errors; }; -export const validateActionParams = ( +export const validateActionParams = async ( actionItem: AlertAction, actionTypeRegistry: ActionTypeRegistryContract -): string[] => { - const actionErrors = actionTypeRegistry +): Promise => { + const actionErrors = await actionTypeRegistry .get(actionItem.actionTypeId) ?.validateParams(actionItem.params); diff --git a/x-pack/plugins/triggers_actions_ui/README.md b/x-pack/plugins/triggers_actions_ui/README.md index 7d736218af2d9..cd83be0138faf 100644 --- a/x-pack/plugins/triggers_actions_ui/README.md +++ b/x-pack/plugins/triggers_actions_ui/README.md @@ -888,10 +888,10 @@ export function getActionType(): ActionTypeModel { defaultMessage: 'Send to Server log', } ), - validateConnector: (): ValidationResult => { + validateConnector: (): Promise => { return { errors: {} }; }, - validateParams: (actionParams: ServerLogActionParams): ValidationResult => { + validateParams: (actionParams: ServerLogActionParams): Promise => { // validation of action params implementation }, actionConnectorFields: null, @@ -929,10 +929,10 @@ export function getActionType(): ActionTypeModel { defaultMessage: 'Send to email', } ), - validateConnector: (action: EmailActionConnector): ValidationResult => { + validateConnector: (action: EmailActionConnector): Promise => { // validation of connector properties implementation }, - validateParams: (actionParams: EmailActionParams): ValidationResult => { + validateParams: (actionParams: EmailActionParams): Promise => { // validation of action params implementation }, actionConnectorFields: EmailActionConnectorFields, @@ -967,10 +967,10 @@ export function getActionType(): ActionTypeModel { defaultMessage: 'Send to Slack', } ), - validateConnector: (action: SlackActionConnector): ValidationResult => { + validateConnector: (action: SlackActionConnector): Promise => { // validation of connector properties implementation }, - validateParams: (actionParams: SlackActionParams): ValidationResult => { + validateParams: (actionParams: SlackActionParams): Promise => { // validation of action params implementation }, actionConnectorFields: SlackActionFields, @@ -1000,12 +1000,12 @@ export function getActionType(): ActionTypeModel { defaultMessage: 'Index data into Elasticsearch.', } ), - validateConnector: (): ValidationResult => { + validateConnector: (): Promise => { return { errors: {} }; }, actionConnectorFields: IndexActionConnectorFields, actionParamsFields: IndexParamsFields, - validateParams: (): ValidationResult => { + validateParams: (): Promise => { return { errors: {} }; }, }; @@ -1046,10 +1046,10 @@ export function getActionType(): ActionTypeModel { defaultMessage: 'Send a request to a web service.', } ), - validateConnector: (action: WebhookActionConnector): ValidationResult => { + validateConnector: (action: WebhookActionConnector): Promise => { // validation of connector properties implementation }, - validateParams: (actionParams: WebhookActionParams): ValidationResult => { + validateParams: (actionParams: WebhookActionParams): Promise => { // validation of action params implementation }, actionConnectorFields: WebhookActionConnectorFields, @@ -1086,10 +1086,10 @@ export function getActionType(): ActionTypeModel { defaultMessage: 'Send to PagerDuty', } ), - validateConnector: (action: PagerDutyActionConnector): ValidationResult => { + validateConnector: (action: PagerDutyActionConnector): Promise => { // validation of connector properties implementation }, - validateParams: (actionParams: PagerDutyActionParams): ValidationResult => { + validateParams: (actionParams: PagerDutyActionParams): Promise => { // validation of action params implementation }, actionConnectorFields: PagerDutyActionConnectorFields, @@ -1113,8 +1113,8 @@ Each action type should be defined as an `ActionTypeModel` object with the follo iconClass: IconType; selectMessage: string; actionTypeTitle?: string; - validateConnector: (connector: any) => ValidationResult; - validateParams: (actionParams: any) => ValidationResult; + validateConnector: (connector: any) => Promise; + validateParams: (actionParams: any) => Promise; actionConnectorFields: React.FunctionComponent | null; actionParamsFields: React.LazyExoticComponent>>; ``` @@ -1186,7 +1186,7 @@ export function getActionType(): ActionTypeModel { defaultMessage: 'Example Action', } ), - validateConnector: (action: ExampleActionConnector): ValidationResult => { + validateConnector: (action: ExampleActionConnector): Promise => { const validationResult = { errors: {} }; const errors = { someConnectorField: new Array(), @@ -1204,7 +1204,7 @@ export function getActionType(): ActionTypeModel { } return validationResult; }, - validateParams: (actionParams: ExampleActionParams): ValidationResult => { + validateParams: (actionParams: ExampleActionParams): Promise => { const validationResult = { errors: {} }; const errors = { message: new Array(), diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email.test.tsx index bebddba0c1110..4d669ab4c76a1 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email.test.tsx @@ -30,7 +30,7 @@ describe('actionTypeRegistry.get() works', () => { }); describe('connector validation', () => { - test('connector validation succeeds when connector config is valid', () => { + test('connector validation succeeds when connector config is valid', async () => { const actionConnector = { secrets: { user: 'user', @@ -49,7 +49,7 @@ describe('connector validation', () => { }, } as EmailActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: { from: [], @@ -66,7 +66,7 @@ describe('connector validation', () => { }); }); - test('connector validation succeeds when connector config is valid with empty user/password', () => { + test('connector validation succeeds when connector config is valid with empty user/password', async () => { const actionConnector = { secrets: { user: null, @@ -85,7 +85,7 @@ describe('connector validation', () => { }, } as EmailActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: { from: [], @@ -101,7 +101,7 @@ describe('connector validation', () => { }, }); }); - test('connector validation fails when connector config is not valid', () => { + test('connector validation fails when connector config is not valid', async () => { const actionConnector = { secrets: { user: 'user', @@ -116,7 +116,7 @@ describe('connector validation', () => { }, } as EmailActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: { from: [], @@ -132,7 +132,7 @@ describe('connector validation', () => { }, }); }); - test('connector validation fails when user specified but not password', () => { + test('connector validation fails when user specified but not password', async () => { const actionConnector = { secrets: { user: 'user', @@ -151,7 +151,7 @@ describe('connector validation', () => { }, } as EmailActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: { from: [], @@ -167,7 +167,7 @@ describe('connector validation', () => { }, }); }); - test('connector validation fails when password specified but not user', () => { + test('connector validation fails when password specified but not user', async () => { const actionConnector = { secrets: { user: null, @@ -186,7 +186,7 @@ describe('connector validation', () => { }, } as EmailActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: { from: [], @@ -205,7 +205,7 @@ describe('connector validation', () => { }); describe('action params validation', () => { - test('action params validation succeeds when action params is valid', () => { + test('action params validation succeeds when action params is valid', async () => { const actionParams = { to: [], cc: ['test1@test.com'], @@ -213,7 +213,7 @@ describe('action params validation', () => { subject: 'test', }; - expect(actionTypeModel.validateParams(actionParams)).toEqual({ + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ errors: { to: [], cc: [], @@ -224,13 +224,13 @@ describe('action params validation', () => { }); }); - test('action params validation fails when action params is not valid', () => { + test('action params validation fails when action params is not valid', async () => { const actionParams = { to: ['test@test.com'], subject: 'test', }; - expect(actionTypeModel.validateParams(actionParams)).toEqual({ + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ errors: { to: [], cc: [], diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email.tsx index 81eadda4fc278..5e23754621430 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email.tsx @@ -31,9 +31,12 @@ export function getActionType(): ActionTypeModel, EmailSecrets> => { + ): Promise< + ConnectorValidationResult, EmailSecrets> + > => { + const translations = await import('./translations'); const configErrors = { from: new Array(), port: new Array(), @@ -49,74 +52,25 @@ export function getActionType(): ActionTypeModel => { + ): Promise> => { + const translations = await import('./translations'); const errors = { to: new Array(), cc: new Array(), @@ -146,35 +101,16 @@ export function getActionType(): ActionTypeModel 0; + const isHostInvalid: boolean = + host !== undefined && errors.host !== undefined && errors.host.length > 0; + const isPortInvalid: boolean = + port !== undefined && errors.port !== undefined && errors.port.length > 0; + + const isPasswordInvalid: boolean = + password !== undefined && errors.password !== undefined && errors.password.length > 0; + const isUserInvalid: boolean = + user !== undefined && errors.user !== undefined && errors.user.length > 0; return ( <> @@ -46,7 +57,7 @@ export const EmailActionConnectorFields: React.FunctionComponent< id="from" fullWidth error={errors.from} - isInvalid={errors.from.length > 0 && from !== undefined} + isInvalid={isFromInvalid} label={i18n.translate( 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.fromTextFieldLabel', { @@ -65,7 +76,7 @@ export const EmailActionConnectorFields: React.FunctionComponent< 0 && from !== undefined} + isInvalid={isFromInvalid} name="from" value={from || ''} data-test-subj="emailFromInput" @@ -87,7 +98,7 @@ export const EmailActionConnectorFields: React.FunctionComponent< id="emailHost" fullWidth error={errors.host} - isInvalid={errors.host.length > 0 && host !== undefined} + isInvalid={isHostInvalid} label={i18n.translate( 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.hostTextFieldLabel', { @@ -98,7 +109,7 @@ export const EmailActionConnectorFields: React.FunctionComponent< 0 && host !== undefined} + isInvalid={isHostInvalid} name="host" value={host || ''} data-test-subj="emailHostInput" @@ -121,7 +132,7 @@ export const EmailActionConnectorFields: React.FunctionComponent< fullWidth placeholder="587" error={errors.port} - isInvalid={errors.port.length > 0 && port !== undefined} + isInvalid={isPortInvalid} label={i18n.translate( 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.portTextFieldLabel', { @@ -131,7 +142,7 @@ export const EmailActionConnectorFields: React.FunctionComponent< > 0 && port !== undefined} + isInvalid={isPortInvalid} fullWidth readOnly={readOnly} name="port" @@ -221,7 +232,7 @@ export const EmailActionConnectorFields: React.FunctionComponent< id="emailUser" fullWidth error={errors.user} - isInvalid={errors.user.length > 0 && user !== undefined} + isInvalid={isUserInvalid} label={i18n.translate( 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.userTextFieldLabel', { @@ -231,7 +242,7 @@ export const EmailActionConnectorFields: React.FunctionComponent< > 0 && user !== undefined} + isInvalid={isUserInvalid} name="user" readOnly={readOnly} value={user || ''} @@ -252,7 +263,7 @@ export const EmailActionConnectorFields: React.FunctionComponent< id="emailPassword" fullWidth error={errors.password} - isInvalid={errors.password.length > 0 && password !== undefined} + isInvalid={isPasswordInvalid} label={i18n.translate( 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.passwordFieldLabel', { @@ -263,7 +274,7 @@ export const EmailActionConnectorFields: React.FunctionComponent< 0 && password !== undefined} + isInvalid={isPasswordInvalid} name="password" value={password || ''} data-test-subj="emailPasswordInput" diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_params.tsx index e2d6237af85da..5d19a1958c1c6 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_params.tsx @@ -44,13 +44,18 @@ export const EmailParamsFields = ({ } // eslint-disable-next-line react-hooks/exhaustive-deps }, [defaultMessage]); - + const isToInvalid: boolean = to !== undefined && errors.to !== undefined && errors.to.length > 0; + const isSubjectInvalid: boolean = + subject !== undefined && errors.subject !== undefined && errors.subject.length > 0; + const isCCInvalid: boolean = errors.cc !== undefined && errors.cc.length > 0 && cc !== undefined; + const isBCCInvalid: boolean = + errors.bcc !== undefined && errors.bcc.length > 0 && bcc !== undefined; return ( <> 0 && to !== undefined} + isInvalid={isToInvalid} label={i18n.translate( 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.recipientTextFieldLabel', { @@ -82,7 +87,7 @@ export const EmailParamsFields = ({ > 0 && to !== undefined} + isInvalid={isToInvalid} fullWidth data-test-subj="toEmailAddressInput" selectedOptions={toOptions} @@ -112,7 +117,7 @@ export const EmailParamsFields = ({ 0 && cc !== undefined} + isInvalid={isCCInvalid} label={i18n.translate( 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.recipientCopyTextFieldLabel', { @@ -122,7 +127,7 @@ export const EmailParamsFields = ({ > 0 && cc !== undefined} + isInvalid={isCCInvalid} fullWidth data-test-subj="ccEmailAddressInput" selectedOptions={ccOptions} @@ -153,7 +158,7 @@ export const EmailParamsFields = ({ 0 && bcc !== undefined} + isInvalid={isBCCInvalid} label={i18n.translate( 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.recipientBccTextFieldLabel', { @@ -163,7 +168,7 @@ export const EmailParamsFields = ({ > 0 && bcc !== undefined} + isInvalid={isBCCInvalid} fullWidth data-test-subj="bccEmailAddressInput" selectedOptions={bccOptions} @@ -193,7 +198,7 @@ export const EmailParamsFields = ({ 0 && subject !== undefined} + isInvalid={isSubjectInvalid} label={i18n.translate( 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.subjectTextFieldLabel', { @@ -207,7 +212,7 @@ export const EmailParamsFields = ({ messageVariables={messageVariables} paramsProperty={'subject'} inputTargetValue={subject} - errors={errors.subject as string[]} + errors={(errors.subject ?? []) as string[]} /> ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/translations.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/translations.ts new file mode 100644 index 0000000000000..5da9145ecec0b --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/translations.ts @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const SENDER_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredFromText', + { + defaultMessage: 'Sender is required.', + } +); + +export const SENDER_NOT_VALID = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.formatFromText', + { + defaultMessage: 'Sender is not a valid email address.', + } +); + +export const PORT_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredPortText', + { + defaultMessage: 'Port is required.', + } +); + +export const HOST_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredHostText', + { + defaultMessage: 'Host is required.', + } +); + +export const USERNAME_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredAuthUserNameText', + { + defaultMessage: 'Username is required.', + } +); + +export const PASSWORD_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredAuthPasswordText', + { + defaultMessage: 'Password is required.', + } +); + +export const PASSWORD_REQUIRED_FOR_USER_USED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredPasswordText', + { + defaultMessage: 'Password is required when username is used.', + } +); + +export const TO_CC_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredEntryText', + { + defaultMessage: 'No To, Cc, or Bcc entry. At least one entry is required.', + } +); + +export const MESSAGE_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredMessageText', + { + defaultMessage: 'Message is required.', + } +); + +export const SUBJECT_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredSubjectText', + { + defaultMessage: 'Subject is required.', + } +); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index.test.tsx index 9757653043175..f43d883be7add 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index.test.tsx @@ -30,7 +30,7 @@ describe('actionTypeRegistry.get() works', () => { }); describe('index connector validation', () => { - test('connector validation succeeds when connector config is valid', () => { + test('connector validation succeeds when connector config is valid', async () => { const actionConnector = { secrets: {}, id: 'test', @@ -43,7 +43,7 @@ describe('index connector validation', () => { }, } as EsIndexActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: { index: [], @@ -57,7 +57,7 @@ describe('index connector validation', () => { }); describe('index connector validation with minimal config', () => { - test('connector validation succeeds when connector config is valid', () => { + test('connector validation succeeds when connector config is valid', async () => { const actionConnector = { secrets: {}, id: 'test', @@ -68,7 +68,7 @@ describe('index connector validation with minimal config', () => { }, } as EsIndexActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: { index: [], @@ -82,9 +82,9 @@ describe('index connector validation with minimal config', () => { }); describe('action params validation', () => { - test('action params validation succeeds when action params are valid', () => { + test('action params validation succeeds when action params are valid', async () => { expect( - actionTypeModel.validateParams({ + await actionTypeModel.validateParams({ documents: [{ test: 1234 }], }) ).toEqual({ @@ -95,7 +95,7 @@ describe('action params validation', () => { }); expect( - actionTypeModel.validateParams({ + await actionTypeModel.validateParams({ documents: [{ test: 1234 }], indexOverride: 'kibana-alert-history-anything', }) @@ -107,8 +107,8 @@ describe('action params validation', () => { }); }); - test('action params validation fails when action params are invalid', () => { - expect(actionTypeModel.validateParams({})).toEqual({ + test('action params validation fails when action params are invalid', async () => { + expect(await actionTypeModel.validateParams({})).toEqual({ errors: { documents: ['Document is required and should be a valid JSON object.'], indexOverride: [], @@ -116,7 +116,7 @@ describe('action params validation', () => { }); expect( - actionTypeModel.validateParams({ + await actionTypeModel.validateParams({ documents: [{}], }) ).toEqual({ @@ -127,7 +127,7 @@ describe('action params validation', () => { }); expect( - actionTypeModel.validateParams({ + await actionTypeModel.validateParams({ documents: [{}], indexOverride: 'kibana-alert-history-', }) @@ -139,7 +139,7 @@ describe('action params validation', () => { }); expect( - actionTypeModel.validateParams({ + await actionTypeModel.validateParams({ documents: [{}], indexOverride: 'this.is-a_string', }) diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index.tsx index f4b8284c8cfa6..80d38bda22ab3 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index.tsx @@ -31,44 +31,32 @@ export function getActionType(): ActionTypeModel, unknown> => { + ): Promise, unknown>> => { + const translations = await import('./translations'); const configErrors = { index: new Array(), }; const validationResult = { config: { errors: configErrors }, secrets: { errors: {} } }; if (!action.config.index) { - configErrors.index.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.indexAction.error.requiredIndexText', - { - defaultMessage: 'Index is required.', - } - ) - ); + configErrors.index.push(translations.INDEX_REQUIRED); } return validationResult; }, actionConnectorFields: lazy(() => import('./es_index_connector')), actionParamsFields: lazy(() => import('./es_index_params')), - validateParams: ( + validateParams: async ( actionParams: IndexActionParams - ): GenericValidationResult => { + ): Promise> => { + const translations = await import('./translations'); const errors = { documents: new Array(), indexOverride: new Array(), }; const validationResult = { errors }; if (!actionParams.documents?.length || Object.keys(actionParams.documents[0]).length === 0) { - errors.documents.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredDocumentJson', - { - defaultMessage: 'Document is required and should be a valid JSON object.', - } - ) - ); + errors.documents.push(translations.DOCUMENT_NOT_VALID); } if (actionParams.indexOverride) { if (!actionParams.indexOverride.startsWith(ALERT_HISTORY_PREFIX)) { @@ -85,14 +73,7 @@ export function getActionType(): ActionTypeModel 0 && index !== undefined; return ( <> @@ -95,7 +97,7 @@ const IndexActionConnectorFields: React.FunctionComponent< defaultMessage="Index" /> } - isInvalid={errors.index.length > 0 && index !== undefined} + isInvalid={isIndexInvalid} error={errors.index} helpText={ <> @@ -118,7 +120,7 @@ const IndexActionConnectorFields: React.FunctionComponent< singleSelection={{ asPlainText: true }} async isLoading={isIndiciesLoading} - isInvalid={errors.index.length > 0 && index !== undefined} + isInvalid={isIndexInvalid} noSuggestions={!indexOptions.length} options={indexOptions} data-test-subj="connectorIndexesComboBox" diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_params.tsx index 6973cdcc7a088..b5985cf724e09 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_params.tsx @@ -117,7 +117,11 @@ export const IndexParamsFields = ({ 0} + isInvalid={ + errors.indexOverride !== undefined && + (errors.indexOverride as string[]) && + errors.indexOverride.length > 0 + } label={i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.indexAction.preconfiguredIndex', { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/translations.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/translations.ts new file mode 100644 index 0000000000000..b7dd6ac749909 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/translations.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const INDEX_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.indexAction.error.requiredIndexText', + { + defaultMessage: 'Index is required.', + } +); + +export const DOCUMENT_NOT_VALID = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredDocumentJson', + { + defaultMessage: 'Document is required and should be a valid JSON object.', + } +); + +export const HISTORY_NOT_VALID = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.badIndexOverrideSuffix', + { + defaultMessage: 'Alert history index must contain valid suffix.', + } +); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira.test.tsx index ea1bcf82c314c..857582fa7cdaf 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira.test.tsx @@ -29,7 +29,7 @@ describe('actionTypeRegistry.get() works', () => { }); describe('jira connector validation', () => { - test('connector validation succeeds when connector config is valid', () => { + test('connector validation succeeds when connector config is valid', async () => { const actionConnector = { secrets: { email: 'email', @@ -45,7 +45,7 @@ describe('jira connector validation', () => { }, } as JiraActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: { apiUrl: [], @@ -61,7 +61,7 @@ describe('jira connector validation', () => { }); }); - test('connector validation fails when connector config is not valid', () => { + test('connector validation fails when connector config is not valid', async () => { const actionConnector = ({ secrets: { email: 'user', @@ -72,7 +72,7 @@ describe('jira connector validation', () => { config: {}, } as unknown) as JiraActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: { apiUrl: ['URL is required.'], @@ -90,22 +90,22 @@ describe('jira connector validation', () => { }); describe('jira action params validation', () => { - test('action params validation succeeds when action params is valid', () => { + test('action params validation succeeds when action params is valid', async () => { const actionParams = { subActionParams: { incident: { summary: 'some title {{test}}' }, comments: [] }, }; - expect(actionTypeModel.validateParams(actionParams)).toEqual({ + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ errors: { 'subActionParams.incident.summary': [], 'subActionParams.incident.labels': [] }, }); }); - test('params validation fails when body is not valid', () => { + test('params validation fails when body is not valid', async () => { const actionParams = { subActionParams: { incident: { summary: '' }, comments: [] }, }; - expect(actionTypeModel.validateParams(actionParams)).toEqual({ + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ errors: { 'subActionParams.incident.summary': ['Summary is required.'], 'subActionParams.incident.labels': [], @@ -113,7 +113,7 @@ describe('jira action params validation', () => { }); }); - test('params validation fails when labels contain spaces', () => { + test('params validation fails when labels contain spaces', async () => { const actionParams = { subActionParams: { incident: { summary: 'some title', labels: ['label with spaces'] }, @@ -121,7 +121,7 @@ describe('jira action params validation', () => { }, }; - expect(actionTypeModel.validateParams(actionParams)).toEqual({ + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ errors: { 'subActionParams.incident.summary': [], 'subActionParams.incident.labels': ['Labels cannot contain spaces.'], diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira.tsx index ff7fd026f8e31..8e3424a16c295 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira.tsx @@ -6,18 +6,19 @@ */ import { lazy } from 'react'; +import { i18n } from '@kbn/i18n'; import { GenericValidationResult, ActionTypeModel, ConnectorValidationResult, } from '../../../../types'; import { JiraActionConnector, JiraConfig, JiraSecrets, JiraActionParams } from './types'; -import * as i18n from './translations'; import { isValidUrl } from '../../../lib/value_validators'; -const validateConnector = ( +const validateConnector = async ( action: JiraActionConnector -): ConnectorValidationResult => { +): Promise> => { + const translations = await import('./translations'); const configErrors = { apiUrl: new Array(), projectKey: new Array(), @@ -33,41 +34,58 @@ const validateConnector = ( }; if (!action.config.apiUrl) { - configErrors.apiUrl = [...configErrors.apiUrl, i18n.API_URL_REQUIRED]; + configErrors.apiUrl = [...configErrors.apiUrl, translations.API_URL_REQUIRED]; } if (action.config.apiUrl) { if (!isValidUrl(action.config.apiUrl)) { - configErrors.apiUrl = [...configErrors.apiUrl, i18n.API_URL_INVALID]; + configErrors.apiUrl = [...configErrors.apiUrl, translations.API_URL_INVALID]; } else if (!isValidUrl(action.config.apiUrl, 'https:')) { - configErrors.apiUrl = [...configErrors.apiUrl, i18n.API_URL_REQUIRE_HTTPS]; + configErrors.apiUrl = [...configErrors.apiUrl, translations.API_URL_REQUIRE_HTTPS]; } } if (!action.config.projectKey) { - configErrors.projectKey = [...configErrors.projectKey, i18n.JIRA_PROJECT_KEY_REQUIRED]; + configErrors.projectKey = [...configErrors.projectKey, translations.JIRA_PROJECT_KEY_REQUIRED]; } if (!action.secrets.email) { - secretsErrors.email = [...secretsErrors.email, i18n.JIRA_EMAIL_REQUIRED]; + secretsErrors.email = [...secretsErrors.email, translations.JIRA_EMAIL_REQUIRED]; } if (!action.secrets.apiToken) { - secretsErrors.apiToken = [...secretsErrors.apiToken, i18n.JIRA_API_TOKEN_REQUIRED]; + secretsErrors.apiToken = [...secretsErrors.apiToken, translations.JIRA_API_TOKEN_REQUIRED]; } return validationResult; }; +export const JIRA_DESC = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.jira.selectMessageText', + { + defaultMessage: 'Create an incident in Jira.', + } +); + +export const JIRA_TITLE = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.jira.actionTypeTitle', + { + defaultMessage: 'Jira', + } +); + export function getActionType(): ActionTypeModel { return { id: '.jira', iconClass: lazy(() => import('./logo')), - selectMessage: i18n.JIRA_DESC, - actionTypeTitle: i18n.JIRA_TITLE, + selectMessage: JIRA_DESC, + actionTypeTitle: JIRA_TITLE, validateConnector, actionConnectorFields: lazy(() => import('./jira_connectors')), - validateParams: (actionParams: JiraActionParams): GenericValidationResult => { + validateParams: async ( + actionParams: JiraActionParams + ): Promise> => { + const translations = await import('./translations'); const errors = { 'subActionParams.incident.summary': new Array(), 'subActionParams.incident.labels': new Array(), @@ -80,13 +98,13 @@ export function getActionType(): ActionTypeModel label.match(/\s/g))) - errors['subActionParams.incident.labels'].push(i18n.LABELS_WHITE_SPACES); + errors['subActionParams.incident.labels'].push(translations.LABELS_WHITE_SPACES); } return validationResult; }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_connectors.tsx index f2753310d73ae..7aec0a405d0d5 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_connectors.tsx @@ -32,13 +32,17 @@ const JiraConnectorFields: React.FC { const { apiUrl, projectKey } = action.config; - const isApiUrlInvalid: boolean = errors.apiUrl.length > 0 && apiUrl !== undefined; + const isApiUrlInvalid: boolean = + apiUrl !== undefined && errors.apiUrl !== undefined && errors.apiUrl.length > 0; const { email, apiToken } = action.secrets; - const isProjectKeyInvalid: boolean = errors.projectKey.length > 0 && projectKey !== undefined; - const isEmailInvalid: boolean = errors.email.length > 0 && email !== undefined; - const isApiTokenInvalid: boolean = errors.apiToken.length > 0 && apiToken !== undefined; + const isProjectKeyInvalid: boolean = + projectKey !== undefined && errors.projectKey !== undefined && errors.projectKey.length > 0; + const isEmailInvalid: boolean = + email !== undefined && errors.email !== undefined && errors.email.length > 0; + const isApiTokenInvalid: boolean = + apiToken !== undefined && errors.apiToken !== undefined && errors.apiToken.length > 0; const handleOnChangeActionConfig = useCallback( (key: string, value: string) => editActionConfig(key, value), diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.tsx index 11123a81440bb..5897de46f94df 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.tsx @@ -186,6 +186,7 @@ const JiraParamsFields: React.FunctionComponent 0 && incident.labels !== undefined; @@ -277,6 +278,7 @@ const JiraParamsFields: React.FunctionComponent 0 && incident.summary !== undefined } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/translations.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/translations.ts index 4577e55260d9d..5904eb05c31b6 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/translations.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/translations.ts @@ -7,20 +7,6 @@ import { i18n } from '@kbn/i18n'; -export const JIRA_DESC = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.jira.selectMessageText', - { - defaultMessage: 'Create an incident in Jira.', - } -); - -export const JIRA_TITLE = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.jira.actionTypeTitle', - { - defaultMessage: 'Jira', - } -); - export const API_URL_LABEL = i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.jira.apiUrlTextFieldLabel', { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty.test.tsx index eae8690dbdd98..d96ca76aea3be 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty.test.tsx @@ -30,7 +30,7 @@ describe('actionTypeRegistry.get() works', () => { }); describe('pagerduty connector validation', () => { - test('connector validation succeeds when connector config is valid', () => { + test('connector validation succeeds when connector config is valid', async () => { const actionConnector = { secrets: { routingKey: 'test', @@ -43,7 +43,7 @@ describe('pagerduty connector validation', () => { }, } as PagerDutyActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ secrets: { errors: { routingKey: [], @@ -53,7 +53,7 @@ describe('pagerduty connector validation', () => { delete actionConnector.config.apiUrl; actionConnector.secrets.routingKey = 'test1'; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ secrets: { errors: { routingKey: [], @@ -62,7 +62,7 @@ describe('pagerduty connector validation', () => { }); }); - test('connector validation fails when connector config is not valid', () => { + test('connector validation fails when connector config is not valid', async () => { const actionConnector = { secrets: {}, id: 'test', @@ -73,7 +73,7 @@ describe('pagerduty connector validation', () => { }, } as PagerDutyActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ secrets: { errors: { routingKey: ['An integration key / routing key is required.'], @@ -84,7 +84,7 @@ describe('pagerduty connector validation', () => { }); describe('pagerduty action params validation', () => { - test('action params validation succeeds when action params is valid', () => { + test('action params validation succeeds when action params is valid', async () => { const actionParams = { eventAction: 'trigger', dedupKey: 'test', @@ -97,7 +97,7 @@ describe('pagerduty action params validation', () => { class: 'test class', }; - expect(actionTypeModel.validateParams(actionParams)).toEqual({ + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ errors: { dedupKey: [], summary: [], diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty.tsx index 310c5cae24566..80dd360d620b2 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty.tsx @@ -42,9 +42,10 @@ export function getActionType(): ActionTypeModel< defaultMessage: 'Send to PagerDuty', } ), - validateConnector: ( + validateConnector: async ( action: PagerDutyActionConnector - ): ConnectorValidationResult => { + ): Promise> => { + const translations = await import('./translations'); const secretsErrors = { routingKey: new Array(), }; @@ -53,22 +54,16 @@ export function getActionType(): ActionTypeModel< }; if (!action.secrets.routingKey) { - secretsErrors.routingKey.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.error.requiredRoutingKeyText', - { - defaultMessage: 'An integration key / routing key is required.', - } - ) - ); + secretsErrors.routingKey.push(translations.INTEGRATION_KEY_REQUIRED); } return validationResult; }, - validateParams: ( + validateParams: async ( actionParams: PagerDutyActionParams - ): GenericValidationResult< - Pick + ): Promise< + GenericValidationResult> > => { + const translations = await import('./translations'); const errors = { summary: new Array(), timestamp: new Array(), @@ -79,27 +74,13 @@ export function getActionType(): ActionTypeModel< !actionParams.dedupKey?.length && (actionParams.eventAction === 'resolve' || actionParams.eventAction === 'acknowledge') ) { - errors.dedupKey.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.error.requiredDedupKeyText', - { - defaultMessage: 'DedupKey is required when resolving or acknowledging an incident.', - } - ) - ); + errors.dedupKey.push(translations.DEDUP_KEY_REQUIRED); } if ( actionParams.eventAction === EventActionOptions.TRIGGER && !actionParams.summary?.length ) { - errors.summary.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.error.requiredSummaryText', - { - defaultMessage: 'Summary is required.', - } - ) - ); + errors.summary.push(translations.SUMMARY_REQUIRED); } if (actionParams.timestamp && !hasMustacheTokens(actionParams.timestamp)) { if (isNaN(Date.parse(actionParams.timestamp))) { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_connectors.tsx index 7e9a5770c2158..3ac7832d0462e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_connectors.tsx @@ -20,6 +20,9 @@ const PagerDutyActionConnectorFields: React.FunctionComponent< const { docLinks } = useKibana().services; const { apiUrl } = action.config; const { routingKey } = action.secrets; + const isRoutingKeyInvalid: boolean = + routingKey !== undefined && errors.routingKey !== undefined && errors.routingKey.length > 0; + return ( <> } error={errors.routingKey} - isInvalid={errors.routingKey.length > 0 && routingKey !== undefined} + isInvalid={isRoutingKeyInvalid} label={i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.routingKeyTextFieldLabel', { @@ -80,7 +83,7 @@ const PagerDutyActionConnectorFields: React.FunctionComponent< )} 0 && routingKey !== undefined} + isInvalid={isRoutingKeyInvalid} name="routingKey" readOnly={readOnly} value={routingKey || ''} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_params.tsx index 4961a27fd0ac1..8605832b92ea5 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_params.tsx @@ -101,6 +101,12 @@ const PagerDutyParamsFields: React.FunctionComponent 0; + const isSummaryInvalid: boolean = + errors.summary !== undefined && errors.summary.length > 0 && summary !== undefined; + const isTimestampInvalid: boolean = + errors.timestamp !== undefined && errors.timestamp.length > 0 && timestamp !== undefined; + return ( <> @@ -132,7 +138,7 @@ const PagerDutyParamsFields: React.FunctionComponent 0} + isInvalid={isDedupKeyInvalid} label={ isDedupeKeyRequired ? i18n.translate( @@ -166,7 +172,7 @@ const PagerDutyParamsFields: React.FunctionComponent 0 && summary !== undefined} + isInvalid={isSummaryInvalid} label={i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.summaryFieldLabel', { @@ -180,7 +186,7 @@ const PagerDutyParamsFields: React.FunctionComponent @@ -211,7 +217,7 @@ const PagerDutyParamsFields: React.FunctionComponent 0 && timestamp !== undefined} + isInvalid={isTimestampInvalid} label={i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.timestampTextFieldLabel', { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/translations.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/translations.ts new file mode 100644 index 0000000000000..a907b19a1d733 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/translations.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const SUMMARY_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.error.requiredSummaryText', + { + defaultMessage: 'Summary is required.', + } +); + +export const DEDUP_KEY_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.error.requiredDedupKeyText', + { + defaultMessage: 'DedupKey is required when resolving or acknowledging an incident.', + } +); + +export const INTEGRATION_KEY_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.error.requiredRoutingKeyText', + { + defaultMessage: 'An integration key / routing key is required.', + } +); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient.test.tsx index 892ab97b8627f..93fb419f509bc 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient.test.tsx @@ -29,7 +29,7 @@ describe('actionTypeRegistry.get() works', () => { }); describe('resilient connector validation', () => { - test('connector validation succeeds when connector config is valid', () => { + test('connector validation succeeds when connector config is valid', async () => { const actionConnector = { secrets: { apiKeyId: 'email', @@ -45,7 +45,7 @@ describe('resilient connector validation', () => { }, } as ResilientActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: { apiUrl: [], @@ -61,7 +61,7 @@ describe('resilient connector validation', () => { }); }); - test('connector validation fails when connector config is not valid', () => { + test('connector validation fails when connector config is not valid', async () => { const actionConnector = ({ secrets: { apiKeyId: 'user', @@ -72,7 +72,7 @@ describe('resilient connector validation', () => { config: {}, } as unknown) as ResilientActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: { apiUrl: ['URL is required.'], @@ -90,22 +90,22 @@ describe('resilient connector validation', () => { }); describe('resilient action params validation', () => { - test('action params validation succeeds when action params is valid', () => { + test('action params validation succeeds when action params is valid', async () => { const actionParams = { subActionParams: { incident: { name: 'some title {{test}}' }, comments: [] }, }; - expect(actionTypeModel.validateParams(actionParams)).toEqual({ + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ errors: { 'subActionParams.incident.name': [] }, }); }); - test('params validation fails when body is not valid', () => { + test('params validation fails when body is not valid', async () => { const actionParams = { subActionParams: { incident: { name: '' }, comments: [] }, }; - expect(actionTypeModel.validateParams(actionParams)).toEqual({ + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ errors: { 'subActionParams.incident.name': ['Name is required.'], }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient.tsx index e7074b7506e7a..f20204af17697 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient.tsx @@ -6,6 +6,7 @@ */ import { lazy } from 'react'; +import { i18n } from '@kbn/i18n'; import { GenericValidationResult, ActionTypeModel, @@ -17,12 +18,12 @@ import { ResilientSecrets, ResilientActionParams, } from './types'; -import * as i18n from './translations'; import { isValidUrl } from '../../../lib/value_validators'; -const validateConnector = ( +const validateConnector = async ( action: ResilientActionConnector -): ConnectorValidationResult => { +): Promise> => { + const translations = await import('./translations'); const configErrors = { apiUrl: new Array(), orgId: new Array(), @@ -38,32 +39,49 @@ const validateConnector = ( }; if (!action.config.apiUrl) { - configErrors.apiUrl = [...configErrors.apiUrl, i18n.API_URL_REQUIRED]; + configErrors.apiUrl = [...configErrors.apiUrl, translations.API_URL_REQUIRED]; } if (action.config.apiUrl) { if (!isValidUrl(action.config.apiUrl)) { - configErrors.apiUrl = [...configErrors.apiUrl, i18n.API_URL_INVALID]; + configErrors.apiUrl = [...configErrors.apiUrl, translations.API_URL_INVALID]; } else if (!isValidUrl(action.config.apiUrl, 'https:')) { - configErrors.apiUrl = [...configErrors.apiUrl, i18n.API_URL_REQUIRE_HTTPS]; + configErrors.apiUrl = [...configErrors.apiUrl, translations.API_URL_REQUIRE_HTTPS]; } } if (!action.config.orgId) { - configErrors.orgId = [...configErrors.orgId, i18n.ORG_ID_REQUIRED]; + configErrors.orgId = [...configErrors.orgId, translations.ORG_ID_REQUIRED]; } if (!action.secrets.apiKeyId) { - secretsErrors.apiKeyId = [...secretsErrors.apiKeyId, i18n.API_KEY_ID_REQUIRED]; + secretsErrors.apiKeyId = [...secretsErrors.apiKeyId, translations.API_KEY_ID_REQUIRED]; } if (!action.secrets.apiKeySecret) { - secretsErrors.apiKeySecret = [...secretsErrors.apiKeySecret, i18n.API_KEY_SECRET_REQUIRED]; + secretsErrors.apiKeySecret = [ + ...secretsErrors.apiKeySecret, + translations.API_KEY_SECRET_REQUIRED, + ]; } return validationResult; }; +export const DESC = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.resilient.selectMessageText', + { + defaultMessage: 'Create an incident in IBM Resilient.', + } +); + +export const TITLE = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.resilient.actionTypeTitle', + { + defaultMessage: 'Resilient', + } +); + export function getActionType(): ActionTypeModel< ResilientConfig, ResilientSecrets, @@ -72,11 +90,14 @@ export function getActionType(): ActionTypeModel< return { id: '.resilient', iconClass: lazy(() => import('./logo')), - selectMessage: i18n.DESC, - actionTypeTitle: i18n.TITLE, + selectMessage: DESC, + actionTypeTitle: TITLE, validateConnector, actionConnectorFields: lazy(() => import('./resilient_connectors')), - validateParams: (actionParams: ResilientActionParams): GenericValidationResult => { + validateParams: async ( + actionParams: ResilientActionParams + ): Promise> => { + const translations = await import('./translations'); const errors = { 'subActionParams.incident.name': new Array(), }; @@ -88,7 +109,7 @@ export function getActionType(): ActionTypeModel< actionParams.subActionParams.incident && !actionParams.subActionParams.incident.name?.length ) { - errors['subActionParams.incident.name'].push(i18n.NAME_REQUIRED); + errors['subActionParams.incident.name'].push(translations.NAME_REQUIRED); } return validationResult; }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_connectors.tsx index 6996062899c39..1270f19820f4c 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_connectors.tsx @@ -30,14 +30,19 @@ const ResilientConnectorFields: React.FC { const { apiUrl, orgId } = action.config; - const isApiUrlInvalid: boolean = errors.apiUrl.length > 0 && apiUrl !== undefined; + const isApiUrlInvalid: boolean = + apiUrl !== undefined && errors.apiUrl !== undefined && errors.apiUrl.length > 0; const { apiKeyId, apiKeySecret } = action.secrets; - const isOrgIdInvalid: boolean = errors.orgId.length > 0 && orgId !== undefined; - const isApiKeyInvalid: boolean = errors.apiKeyId.length > 0 && apiKeyId !== undefined; + const isOrgIdInvalid: boolean = + orgId !== undefined && errors.orgId !== undefined && errors.orgId.length > 0; + const isApiKeyInvalid: boolean = + apiKeyId !== undefined && errors.apiKeyId !== undefined && errors.apiKeyId.length > 0; const isApiKeySecretInvalid: boolean = - errors.apiKeySecret.length > 0 && apiKeySecret !== undefined; + apiKeySecret !== undefined && + errors.apiKeySecret !== undefined && + errors.apiKeySecret.length > 0; const handleOnChangeActionConfig = useCallback( (key: string, value: string) => editActionConfig(key, value), diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_params.tsx index 4642226d40222..54a138a2bc7cf 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_params.tsx @@ -213,7 +213,9 @@ const ResilientParamsFields: React.FunctionComponent 0 && incident.name !== undefined + errors['subActionParams.incident.name'] !== undefined && + errors['subActionParams.incident.name'].length > 0 && + incident.name !== undefined } label={i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.resilient.nameFieldLabel', @@ -226,7 +228,7 @@ const ResilientParamsFields: React.FunctionComponent { }); describe('server-log connector validation', () => { - test('connector validation succeeds when connector config is valid', () => { + test('connector validation succeeds when connector config is valid', async () => { const actionConnector: UserConfiguredActionConnector<{}, {}> = { secrets: {}, id: 'test', @@ -39,7 +39,7 @@ describe('server-log connector validation', () => { isPreconfigured: false, }; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: {}, }, @@ -51,23 +51,23 @@ describe('server-log connector validation', () => { }); describe('action params validation', () => { - test('action params validation succeeds when action params is valid', () => { + test('action params validation succeeds when action params is valid', async () => { const actionParams = { message: 'test message', level: 'trace', }; - expect(actionTypeModel.validateParams(actionParams)).toEqual({ + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ errors: { message: [] }, }); }); - test('params validation fails when message is not valid', () => { + test('params validation fails when message is not valid', async () => { const actionParams = { message: '', }; - expect(actionTypeModel.validateParams(actionParams)).toEqual({ + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ errors: { message: ['Message is required.'], }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log/server_log.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log/server_log.tsx index 4550d2d65b9df..066c5c0a2f385 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log/server_log.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log/server_log.tsx @@ -30,12 +30,12 @@ export function getActionType(): ActionTypeModel => { - return { config: { errors: {} }, secrets: { errors: {} } }; + validateConnector: (): Promise> => { + return Promise.resolve({ config: { errors: {} }, secrets: { errors: {} } }); }, validateParams: ( actionParams: ServerLogActionParams - ): GenericValidationResult> => { + ): Promise>> => { const errors = { message: new Array(), }; @@ -50,7 +50,7 @@ export function getActionType(): ActionTypeModel import('./server_log_params')), diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.test.tsx index 02ecab47ae49a..e25e8120b1650 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.test.tsx @@ -30,7 +30,7 @@ describe('actionTypeRegistry.get() works', () => { describe('servicenow connector validation', () => { [SERVICENOW_ITSM_ACTION_TYPE_ID, SERVICENOW_SIR_ACTION_TYPE_ID].forEach((id) => { - test(`${id}: connector validation succeeds when connector config is valid`, () => { + test(`${id}: connector validation succeeds when connector config is valid`, async () => { const actionTypeModel = actionTypeRegistry.get(id); const actionConnector = { secrets: { @@ -46,7 +46,7 @@ describe('servicenow connector validation', () => { }, } as ServiceNowActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: { apiUrl: [], @@ -61,7 +61,7 @@ describe('servicenow connector validation', () => { }); }); - test(`${id}: connector validation fails when connector config is not valid`, () => { + test(`${id}: connector validation fails when connector config is not valid`, async () => { const actionTypeModel = actionTypeRegistry.get(id); const actionConnector = ({ secrets: { @@ -73,7 +73,7 @@ describe('servicenow connector validation', () => { config: {}, } as unknown) as ServiceNowActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: { apiUrl: ['URL is required.'], @@ -92,24 +92,24 @@ describe('servicenow connector validation', () => { describe('servicenow action params validation', () => { [SERVICENOW_ITSM_ACTION_TYPE_ID, SERVICENOW_SIR_ACTION_TYPE_ID].forEach((id) => { - test(`${id}: action params validation succeeds when action params is valid`, () => { + test(`${id}: action params validation succeeds when action params is valid`, async () => { const actionTypeModel = actionTypeRegistry.get(id); const actionParams = { subActionParams: { incident: { short_description: 'some title {{test}}' }, comments: [] }, }; - expect(actionTypeModel.validateParams(actionParams)).toEqual({ + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ errors: { ['subActionParams.incident.short_description']: [] }, }); }); - test(`${id}: params validation fails when body is not valid`, () => { + test(`${id}: params validation fails when body is not valid`, async () => { const actionTypeModel = actionTypeRegistry.get(id); const actionParams = { subActionParams: { incident: { short_description: '' }, comments: [] }, }; - expect(actionTypeModel.validateParams(actionParams)).toEqual({ + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ errors: { ['subActionParams.incident.short_description']: ['Short description is required.'], }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.tsx index a6cc116d3d7b4..24e2a87d42357 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.tsx @@ -6,6 +6,7 @@ */ import { lazy } from 'react'; +import { i18n } from '@kbn/i18n'; import { GenericValidationResult, ActionTypeModel, @@ -18,12 +19,12 @@ import { ServiceNowITSMActionParams, ServiceNowSIRActionParams, } from './types'; -import * as i18n from './translations'; import { isValidUrl } from '../../../lib/value_validators'; -const validateConnector = ( +const validateConnector = async ( action: ServiceNowActionConnector -): ConnectorValidationResult => { +): Promise> => { + const translations = await import('./translations'); const configErrors = { apiUrl: new Array(), }; @@ -38,28 +39,56 @@ const validateConnector = ( }; if (!action.config.apiUrl) { - configErrors.apiUrl = [...configErrors.apiUrl, i18n.API_URL_REQUIRED]; + configErrors.apiUrl = [...configErrors.apiUrl, translations.API_URL_REQUIRED]; } if (action.config.apiUrl) { if (!isValidUrl(action.config.apiUrl)) { - configErrors.apiUrl = [...configErrors.apiUrl, i18n.API_URL_INVALID]; + configErrors.apiUrl = [...configErrors.apiUrl, translations.API_URL_INVALID]; } else if (!isValidUrl(action.config.apiUrl, 'https:')) { - configErrors.apiUrl = [...configErrors.apiUrl, i18n.API_URL_REQUIRE_HTTPS]; + configErrors.apiUrl = [...configErrors.apiUrl, translations.API_URL_REQUIRE_HTTPS]; } } if (!action.secrets.username) { - secretsErrors.username = [...secretsErrors.username, i18n.USERNAME_REQUIRED]; + secretsErrors.username = [...secretsErrors.username, translations.USERNAME_REQUIRED]; } if (!action.secrets.password) { - secretsErrors.password = [...secretsErrors.password, i18n.PASSWORD_REQUIRED]; + secretsErrors.password = [...secretsErrors.password, translations.PASSWORD_REQUIRED]; } return validationResult; }; +export const SERVICENOW_ITSM_DESC = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNowITSM.selectMessageText', + { + defaultMessage: 'Create an incident in ServiceNow ITSM.', + } +); + +export const SERVICENOW_SIR_DESC = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNowSIR.selectMessageText', + { + defaultMessage: 'Create an incident in ServiceNow SecOps.', + } +); + +export const SERVICENOW_ITSM_TITLE = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNowITSM.actionTypeTitle', + { + defaultMessage: 'ServiceNow ITSM', + } +); + +export const SERVICENOW_SIR_TITLE = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNowSIR.actionTypeTitle', + { + defaultMessage: 'ServiceNow SecOps', + } +); + export function getServiceNowITSMActionType(): ActionTypeModel< ServiceNowConfig, ServiceNowSecrets, @@ -68,13 +97,14 @@ export function getServiceNowITSMActionType(): ActionTypeModel< return { id: '.servicenow', iconClass: lazy(() => import('./logo')), - selectMessage: i18n.SERVICENOW_ITSM_DESC, - actionTypeTitle: i18n.SERVICENOW_ITSM_TITLE, + selectMessage: SERVICENOW_ITSM_DESC, + actionTypeTitle: SERVICENOW_ITSM_TITLE, validateConnector, actionConnectorFields: lazy(() => import('./servicenow_connectors')), - validateParams: ( + validateParams: async ( actionParams: ServiceNowITSMActionParams - ): GenericValidationResult => { + ): Promise> => { + const translations = await import('./translations'); const errors = { // eslint-disable-next-line @typescript-eslint/naming-convention 'subActionParams.incident.short_description': new Array(), @@ -87,7 +117,7 @@ export function getServiceNowITSMActionType(): ActionTypeModel< actionParams.subActionParams.incident && !actionParams.subActionParams.incident.short_description?.length ) { - errors['subActionParams.incident.short_description'].push(i18n.TITLE_REQUIRED); + errors['subActionParams.incident.short_description'].push(translations.TITLE_REQUIRED); } return validationResult; }, @@ -103,11 +133,14 @@ export function getServiceNowSIRActionType(): ActionTypeModel< return { id: '.servicenow-sir', iconClass: lazy(() => import('./logo')), - selectMessage: i18n.SERVICENOW_SIR_DESC, - actionTypeTitle: i18n.SERVICENOW_SIR_TITLE, + selectMessage: SERVICENOW_SIR_DESC, + actionTypeTitle: SERVICENOW_SIR_TITLE, validateConnector, actionConnectorFields: lazy(() => import('./servicenow_connectors')), - validateParams: (actionParams: ServiceNowSIRActionParams): GenericValidationResult => { + validateParams: async ( + actionParams: ServiceNowSIRActionParams + ): Promise> => { + const translations = await import('./translations'); const errors = { // eslint-disable-next-line @typescript-eslint/naming-convention 'subActionParams.incident.short_description': new Array(), @@ -120,7 +153,7 @@ export function getServiceNowSIRActionType(): ActionTypeModel< actionParams.subActionParams.incident && !actionParams.subActionParams.incident.short_description?.length ) { - errors['subActionParams.incident.short_description'].push(i18n.TITLE_REQUIRED); + errors['subActionParams.incident.short_description'].push(translations.TITLE_REQUIRED); } return validationResult; }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx index e7b2c4bac5914..c9aafc58f3ede 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx @@ -32,12 +32,15 @@ const ServiceNowConnectorFields: React.FC< const { docLinks } = useKibana().services; const { apiUrl } = action.config; - const isApiUrlInvalid: boolean = errors.apiUrl.length > 0 && apiUrl !== undefined; + const isApiUrlInvalid: boolean = + errors.apiUrl !== undefined && errors.apiUrl.length > 0 && apiUrl !== undefined; const { username, password } = action.secrets; - const isUsernameInvalid: boolean = errors.username.length > 0 && username !== undefined; - const isPasswordInvalid: boolean = errors.password.length > 0 && password !== undefined; + const isUsernameInvalid: boolean = + errors.username !== undefined && errors.username.length > 0 && username !== undefined; + const isPasswordInvalid: boolean = + errors.password !== undefined && errors.password.length > 0 && password !== undefined; const handleOnChangeActionConfig = useCallback( (key: string, value: string) => editActionConfig(key, value), diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.tsx index dbd6fec3dad19..f0fc5ed42d24c 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.tsx @@ -240,6 +240,7 @@ const ServiceNowParamsFields: React.FunctionComponent< fullWidth error={errors['subActionParams.incident.short_description']} isInvalid={ + errors['subActionParams.incident.short_description'] !== undefined && errors['subActionParams.incident.short_description'].length > 0 && incident.short_description !== undefined } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_sir_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_sir_params.tsx index be6756b1c1049..a991ee29c85f8 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_sir_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_sir_params.tsx @@ -151,6 +151,7 @@ const ServiceNowSIRParamsFields: React.FunctionComponent< fullWidth error={errors['subActionParams.incident.short_description']} isInvalid={ + errors['subActionParams.incident.short_description'] !== undefined && errors['subActionParams.incident.short_description'].length > 0 && incident.short_description !== undefined } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts index 288b6e629112d..ea646b896f5e9 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts @@ -7,34 +7,6 @@ import { i18n } from '@kbn/i18n'; -export const SERVICENOW_ITSM_DESC = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNowITSM.selectMessageText', - { - defaultMessage: 'Create an incident in ServiceNow ITSM.', - } -); - -export const SERVICENOW_SIR_DESC = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNowSIR.selectMessageText', - { - defaultMessage: 'Create an incident in ServiceNow SecOps.', - } -); - -export const SERVICENOW_ITSM_TITLE = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNowITSM.actionTypeTitle', - { - defaultMessage: 'ServiceNow ITSM', - } -); - -export const SERVICENOW_SIR_TITLE = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNowSIR.actionTypeTitle', - { - defaultMessage: 'ServiceNow SecOps', - } -); - export const API_URL_LABEL = i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.apiUrlTextFieldLabel', { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack.test.tsx index eabb63567ea86..dbdc123e0098f 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack.test.tsx @@ -30,7 +30,7 @@ describe('actionTypeRegistry.get() works', () => { }); describe('slack connector validation', () => { - test('connector validation succeeds when connector config is valid', () => { + test('connector validation succeeds when connector config is valid', async () => { const actionConnector = { secrets: { webhookUrl: 'https:\\test', @@ -41,7 +41,7 @@ describe('slack connector validation', () => { config: {}, } as SlackActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: {}, }, @@ -53,7 +53,7 @@ describe('slack connector validation', () => { }); }); - test('connector validation fails when connector config is not valid - no webhook url', () => { + test('connector validation fails when connector config is not valid - no webhook url', async () => { const actionConnector = { secrets: {}, id: 'test', @@ -62,7 +62,7 @@ describe('slack connector validation', () => { config: {}, } as SlackActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: {}, }, @@ -74,7 +74,7 @@ describe('slack connector validation', () => { }); }); - test('connector validation fails when connector config is not valid - invalid webhook protocol', () => { + test('connector validation fails when connector config is not valid - invalid webhook protocol', async () => { const actionConnector = { secrets: { webhookUrl: 'http:\\test', @@ -85,7 +85,7 @@ describe('slack connector validation', () => { config: {}, } as SlackActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: {}, }, @@ -97,7 +97,7 @@ describe('slack connector validation', () => { }); }); - test('connector validation fails when connector config is not valid - invalid webhook url', () => { + test('connector validation fails when connector config is not valid - invalid webhook url', async () => { const actionConnector = { secrets: { webhookUrl: 'h', @@ -108,7 +108,7 @@ describe('slack connector validation', () => { config: {}, } as SlackActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: {}, }, @@ -122,22 +122,22 @@ describe('slack connector validation', () => { }); describe('slack action params validation', () => { - test('if action params validation succeeds when action params is valid', () => { + test('if action params validation succeeds when action params is valid', async () => { const actionParams = { message: 'message {test}', }; - expect(actionTypeModel.validateParams(actionParams)).toEqual({ + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ errors: { message: [] }, }); }); - test('params validation fails when message is not valid', () => { + test('params validation fails when message is not valid', async () => { const actionParams = { message: '', }; - expect(actionTypeModel.validateParams(actionParams)).toEqual({ + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ errors: { message: ['Message is required.'], }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack.tsx index 30e60a6ac0156..d3df034a90bf2 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack.tsx @@ -31,61 +31,35 @@ export function getActionType(): ActionTypeModel => { + ): Promise> => { + const translations = await import('./translations'); const secretsErrors = { webhookUrl: new Array(), }; const validationResult = { config: { errors: {} }, secrets: { errors: secretsErrors } }; if (!action.secrets.webhookUrl) { - secretsErrors.webhookUrl.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.error.requiredWebhookUrlText', - { - defaultMessage: 'Webhook URL is required.', - } - ) - ); + secretsErrors.webhookUrl.push(translations.WEBHOOK_URL_REQUIRED); } else if (action.secrets.webhookUrl) { if (!isValidUrl(action.secrets.webhookUrl)) { - secretsErrors.webhookUrl.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.error.invalidWebhookUrlText', - { - defaultMessage: 'Webhook URL is invalid.', - } - ) - ); + secretsErrors.webhookUrl.push(translations.WEBHOOK_URL_INVALID); } else if (!isValidUrl(action.secrets.webhookUrl, 'https:')) { - secretsErrors.webhookUrl.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.error.requireHttpsWebhookUrlText', - { - defaultMessage: 'Webhook URL must start with https://.', - } - ) - ); + secretsErrors.webhookUrl.push(translations.WEBHOOK_URL_HTTP_INVALID); } } return validationResult; }, - validateParams: ( + validateParams: async ( actionParams: SlackActionParams - ): GenericValidationResult => { + ): Promise> => { + const translations = await import('./translations'); const errors = { message: new Array(), }; const validationResult = { errors }; if (!actionParams.message?.length) { - errors.message.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredSlackMessageText', - { - defaultMessage: 'Message is required.', - } - ) - ); + errors.message.push(translations.MESSAGE_REQUIRED); } return validationResult; }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.tsx index ce6cda1294adc..e87b00dca9343 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.tsx @@ -19,6 +19,8 @@ const SlackActionFields: React.FunctionComponent< > = ({ action, editActionSecrets, errors, readOnly }) => { const { docLinks } = useKibana().services; const { webhookUrl } = action.secrets; + const isWebhookUrlInvalid: boolean = + errors.webhookUrl !== undefined && errors.webhookUrl.length > 0 && webhookUrl !== undefined; return ( <> @@ -34,7 +36,7 @@ const SlackActionFields: React.FunctionComponent< } error={errors.webhookUrl} - isInvalid={errors.webhookUrl.length > 0 && webhookUrl !== undefined} + isInvalid={isWebhookUrlInvalid} label={i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.webhookUrlTextFieldLabel', { @@ -54,7 +56,7 @@ const SlackActionFields: React.FunctionComponent< )} 0 && webhookUrl !== undefined} + isInvalid={isWebhookUrlInvalid} name="webhookUrl" readOnly={readOnly} value={webhookUrl || ''} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_params.tsx index 3aa7fd8227496..59e10277cfe08 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_params.tsx @@ -49,7 +49,7 @@ const SlackParamsFields: React.FunctionComponent ); }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/translations.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/translations.ts new file mode 100644 index 0000000000000..bd1fd8ea194f6 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/translations.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const WEBHOOK_URL_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.error.requiredWebhookUrlText', + { + defaultMessage: 'Webhook URL is required.', + } +); + +export const WEBHOOK_URL_INVALID = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.error.invalidWebhookUrlText', + { + defaultMessage: 'Webhook URL is invalid.', + } +); + +export const WEBHOOK_URL_HTTP_INVALID = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.error.requireHttpsWebhookUrlText', + { + defaultMessage: 'Webhook URL must start with https://.', + } +); + +export const MESSAGE_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredSlackMessageText', + { + defaultMessage: 'Message is required.', + } +); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams.test.tsx index 62be20a9bad90..641c46af6bfc1 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams.test.tsx @@ -29,7 +29,7 @@ describe('actionTypeRegistry.get() works', () => { }); describe('teams connector validation', () => { - test('connector validation succeeds when connector config is valid', () => { + test('connector validation succeeds when connector config is valid', async () => { const actionConnector = { secrets: { webhookUrl: 'https:\\test', @@ -40,7 +40,7 @@ describe('teams connector validation', () => { config: {}, } as TeamsActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: {}, }, @@ -52,7 +52,7 @@ describe('teams connector validation', () => { }); }); - test('connector validation fails when connector config is not valid - empty webhook url', () => { + test('connector validation fails when connector config is not valid - empty webhook url', async () => { const actionConnector = { secrets: {}, id: 'test', @@ -61,7 +61,7 @@ describe('teams connector validation', () => { config: {}, } as TeamsActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: {}, }, @@ -73,7 +73,7 @@ describe('teams connector validation', () => { }); }); - test('connector validation fails when connector config is not valid - invalid webhook url', () => { + test('connector validation fails when connector config is not valid - invalid webhook url', async () => { const actionConnector = { secrets: { webhookUrl: 'h', @@ -84,7 +84,7 @@ describe('teams connector validation', () => { config: {}, } as TeamsActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: {}, }, @@ -96,7 +96,7 @@ describe('teams connector validation', () => { }); }); - test('connector validation fails when connector config is not valid - invalid webhook url protocol', () => { + test('connector validation fails when connector config is not valid - invalid webhook url protocol', async () => { const actionConnector = { secrets: { webhookUrl: 'http://insecure', @@ -107,7 +107,7 @@ describe('teams connector validation', () => { config: {}, } as TeamsActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: {}, }, @@ -121,22 +121,22 @@ describe('teams connector validation', () => { }); describe('teams action params validation', () => { - test('if action params validation succeeds when action params is valid', () => { + test('if action params validation succeeds when action params is valid', async () => { const actionParams = { message: 'message {test}', }; - expect(actionTypeModel.validateParams(actionParams)).toEqual({ + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ errors: { message: [] }, }); }); - test('params validation fails when message is not valid', () => { + test('params validation fails when message is not valid', async () => { const actionParams = { message: '', }; - expect(actionTypeModel.validateParams(actionParams)).toEqual({ + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ errors: { message: ['Message is required.'], }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams.tsx index e8c7be7311c1c..c48b4f950855d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams.tsx @@ -31,61 +31,35 @@ export function getActionType(): ActionTypeModel => { + ): Promise> => { + const translations = await import('./translations'); const secretsErrors = { webhookUrl: new Array(), }; const validationResult = { config: { errors: {} }, secrets: { errors: secretsErrors } }; if (!action.secrets.webhookUrl) { - secretsErrors.webhookUrl.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.teamsAction.error.requiredWebhookUrlText', - { - defaultMessage: 'Webhook URL is required.', - } - ) - ); + secretsErrors.webhookUrl.push(translations.WEBHOOK_URL_REQUIRED); } else if (action.secrets.webhookUrl) { if (!isValidUrl(action.secrets.webhookUrl)) { - secretsErrors.webhookUrl.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.teamsAction.error.invalidWebhookUrlText', - { - defaultMessage: 'Webhook URL is invalid.', - } - ) - ); + secretsErrors.webhookUrl.push(translations.WEBHOOK_URL_INVALID); } else if (!isValidUrl(action.secrets.webhookUrl, 'https:')) { - secretsErrors.webhookUrl.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.teamsAction.error.requireHttpsWebhookUrlText', - { - defaultMessage: 'Webhook URL must start with https://.', - } - ) - ); + secretsErrors.webhookUrl.push(translations.WEBHOOK_URL_HTTP_INVALID); } } return validationResult; }, - validateParams: ( + validateParams: async ( actionParams: TeamsActionParams - ): GenericValidationResult => { + ): Promise> => { + const translations = await import('./translations'); const errors = { message: new Array(), }; const validationResult = { errors }; if (!actionParams.message?.length) { - errors.message.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.teamsAction.error.requiredMessageText', - { - defaultMessage: 'Message is required.', - } - ) - ); + errors.message.push(translations.MESSAGE_REQUIRED); } return validationResult; }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_connectors.tsx index 454b938692225..8de1c68926f14 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_connectors.tsx @@ -20,6 +20,9 @@ const TeamsActionFields: React.FunctionComponent< const { webhookUrl } = action.secrets; const { docLinks } = useKibana().services; + const isWebhookUrlInvalid: boolean = + errors.webhookUrl !== undefined && errors.webhookUrl.length > 0 && webhookUrl !== undefined; + return ( <> } error={errors.webhookUrl} - isInvalid={errors.webhookUrl.length > 0 && webhookUrl !== undefined} + isInvalid={isWebhookUrlInvalid} label={i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.teamsAction.webhookUrlTextFieldLabel', { @@ -54,7 +57,7 @@ const TeamsActionFields: React.FunctionComponent< )} 0 && webhookUrl !== undefined} + isInvalid={isWebhookUrlInvalid} name="webhookUrl" readOnly={readOnly} value={webhookUrl || ''} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_params.tsx index c0a20e214b4e1..0aea576c10b31 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_params.tsx @@ -40,7 +40,7 @@ const TeamsParamsFields: React.FunctionComponent ); }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/translations.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/translations.ts new file mode 100644 index 0000000000000..790a3b3bac32f --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/translations.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const WEBHOOK_URL_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.teamsAction.error.requiredWebhookUrlText', + { + defaultMessage: 'Webhook URL is required.', + } +); + +export const WEBHOOK_URL_INVALID = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.teamsAction.error.invalidWebhookUrlText', + { + defaultMessage: 'Webhook URL is invalid.', + } +); + +export const WEBHOOK_URL_HTTP_INVALID = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.teamsAction.error.requireHttpsWebhookUrlText', + { + defaultMessage: 'Webhook URL must start with https://.', + } +); + +export const MESSAGE_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.teamsAction.error.requiredMessageText', + { + defaultMessage: 'Message is required.', + } +); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/translations.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/translations.ts new file mode 100644 index 0000000000000..3550121e81694 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/translations.ts @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const URL_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.error.requiredUrlText', + { + defaultMessage: 'URL is required.', + } +); + +export const URL_INVALID = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.error.invalidUrlTextField', + { + defaultMessage: 'URL is invalid.', + } +); + +export const METHOD_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredMethodText', + { + defaultMessage: 'Method is required.', + } +); + +export const USERNAME_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredAuthUserNameText', + { + defaultMessage: 'Username is required.', + } +); + +export const PASSWORD_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredAuthPasswordText', + { + defaultMessage: 'Password is required.', + } +); + +export const PASSWORD_REQUIRED_FOR_USER = i18n.translate( + 'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredPasswordText', + { + defaultMessage: 'Password is required when username is used.', + } +); + +export const USERNAME_REQUIRED_FOR_PASSWORD = i18n.translate( + 'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredUserText', + { + defaultMessage: 'Username is required when password is used.', + } +); + +export const BODY_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredWebhookBodyText', + { + defaultMessage: 'Body is required.', + } +); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook.test.tsx index 8399316044f33..3e42e7965c5bd 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook.test.tsx @@ -30,7 +30,7 @@ describe('actionTypeRegistry.get() works', () => { }); describe('webhook connector validation', () => { - test('connector validation succeeds when hasAuth is true and connector config is valid', () => { + test('connector validation succeeds when hasAuth is true and connector config is valid', async () => { const actionConnector = { secrets: { user: 'user', @@ -48,7 +48,7 @@ describe('webhook connector validation', () => { }, } as WebhookActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: { url: [], @@ -64,7 +64,7 @@ describe('webhook connector validation', () => { }); }); - test('connector validation succeeds when hasAuth is false and connector config is valid', () => { + test('connector validation succeeds when hasAuth is false and connector config is valid', async () => { const actionConnector = { secrets: { user: '', @@ -82,7 +82,7 @@ describe('webhook connector validation', () => { }, } as WebhookActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: { url: [], @@ -98,7 +98,7 @@ describe('webhook connector validation', () => { }); }); - test('connector validation fails when connector config is not valid', () => { + test('connector validation fails when connector config is not valid', async () => { const actionConnector = { secrets: { user: 'user', @@ -112,7 +112,7 @@ describe('webhook connector validation', () => { }, } as WebhookActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: { url: ['URL is required.'], @@ -128,7 +128,7 @@ describe('webhook connector validation', () => { }); }); - test('connector validation fails when url in config is not valid', () => { + test('connector validation fails when url in config is not valid', async () => { const actionConnector = { secrets: { user: 'user', @@ -144,7 +144,7 @@ describe('webhook connector validation', () => { }, } as WebhookActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: { url: ['URL is invalid.'], @@ -162,22 +162,22 @@ describe('webhook connector validation', () => { }); describe('webhook action params validation', () => { - test('action params validation succeeds when action params is valid', () => { + test('action params validation succeeds when action params is valid', async () => { const actionParams = { body: 'message {test}', }; - expect(actionTypeModel.validateParams(actionParams)).toEqual({ + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ errors: { body: [] }, }); }); - test('params validation fails when body is not valid', () => { + test('params validation fails when body is not valid', async () => { const actionParams = { body: '', }; - expect(actionTypeModel.validateParams(actionParams)).toEqual({ + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ errors: { body: ['Body is required.'], }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook.tsx index 3ba801b83c46c..a668f531a6d4c 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook.tsx @@ -40,9 +40,12 @@ export function getActionType(): ActionTypeModel< defaultMessage: 'Webhook data', } ), - validateConnector: ( + validateConnector: async ( action: WebhookActionConnector - ): ConnectorValidationResult, WebhookSecrets> => { + ): Promise< + ConnectorValidationResult, WebhookSecrets> + > => { + const translations = await import('./translations'); const configErrors = { url: new Array(), method: new Array(), @@ -56,95 +59,39 @@ export function getActionType(): ActionTypeModel< secrets: { errors: secretsErrors }, }; if (!action.config.url) { - configErrors.url.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.error.requiredUrlText', - { - defaultMessage: 'URL is required.', - } - ) - ); + configErrors.url.push(translations.URL_REQUIRED); } if (action.config.url && !isValidUrl(action.config.url)) { - configErrors.url = [ - ...configErrors.url, - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.error.invalidUrlTextField', - { - defaultMessage: 'URL is invalid.', - } - ), - ]; + configErrors.url = [...configErrors.url, translations.URL_INVALID]; } if (!action.config.method) { - configErrors.method.push( - i18n.translate( - 'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredMethodText', - { - defaultMessage: 'Method is required.', - } - ) - ); + configErrors.method.push(translations.METHOD_REQUIRED); } if (action.config.hasAuth && !action.secrets.user && !action.secrets.password) { - secretsErrors.user.push( - i18n.translate( - 'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredAuthUserNameText', - { - defaultMessage: 'Username is required.', - } - ) - ); + secretsErrors.user.push(translations.USERNAME_REQUIRED); } if (action.config.hasAuth && !action.secrets.user && !action.secrets.password) { - secretsErrors.password.push( - i18n.translate( - 'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredAuthPasswordText', - { - defaultMessage: 'Password is required.', - } - ) - ); + secretsErrors.password.push(translations.PASSWORD_REQUIRED); } if (action.secrets.user && !action.secrets.password) { - secretsErrors.password.push( - i18n.translate( - 'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredPasswordText', - { - defaultMessage: 'Password is required when username is used.', - } - ) - ); + secretsErrors.password.push(translations.PASSWORD_REQUIRED_FOR_USER); } if (!action.secrets.user && action.secrets.password) { - secretsErrors.user.push( - i18n.translate( - 'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredUserText', - { - defaultMessage: 'Username is required when password is used.', - } - ) - ); + secretsErrors.user.push(translations.USERNAME_REQUIRED_FOR_PASSWORD); } return validationResult; }, - validateParams: ( + validateParams: async ( actionParams: WebhookActionParams - ): GenericValidationResult => { + ): Promise> => { + const translations = await import('./translations'); const errors = { body: new Array(), }; const validationResult = { errors }; validationResult.errors = errors; if (!actionParams.body?.length) { - errors.body.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredWebhookBodyText', - { - defaultMessage: 'Body is required.', - } - ) - ); + errors.body.push(translations.BODY_REQUIRED); } return validationResult; }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_connectors.tsx index d3231f52b4d7b..ba0e7016caa76 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_connectors.tsx @@ -76,7 +76,11 @@ const WebhookActionConnectorFields: React.FunctionComponent< ) ); } - const hasHeaderErrors = headerErrors.keyHeader.length > 0 || headerErrors.valueHeader.length > 0; + const hasHeaderErrors: boolean = + (headerErrors.keyHeader !== undefined && + headerErrors.valueHeader !== undefined && + headerErrors.keyHeader.length > 0) || + headerErrors.valueHeader.length > 0; function addHeader() { if (headers && !!Object.keys(headers).find((key) => key === httpHeaderKey)) { @@ -219,6 +223,13 @@ const WebhookActionConnectorFields: React.FunctionComponent< ); }); + const isUrlInvalid: boolean = + errors.url !== undefined && errors.url.length > 0 && url !== undefined; + const isPasswordInvalid: boolean = + password !== undefined && errors.password !== undefined && errors.password.length > 0; + const isUserInvalid: boolean = + user !== undefined && errors.user !== undefined && errors.user.length > 0; + return ( <> @@ -248,7 +259,7 @@ const WebhookActionConnectorFields: React.FunctionComponent< id="url" fullWidth error={errors.url} - isInvalid={errors.url.length > 0 && url !== undefined} + isInvalid={isUrlInvalid} label={i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.urlTextFieldLabel', { @@ -258,7 +269,7 @@ const WebhookActionConnectorFields: React.FunctionComponent< > 0 && url !== undefined} + isInvalid={isUrlInvalid} fullWidth readOnly={readOnly} value={url || ''} @@ -326,7 +337,7 @@ const WebhookActionConnectorFields: React.FunctionComponent< id="webhookUser" fullWidth error={errors.user} - isInvalid={errors.user.length > 0 && user !== undefined} + isInvalid={isUserInvalid} label={i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.userTextFieldLabel', { @@ -336,7 +347,7 @@ const WebhookActionConnectorFields: React.FunctionComponent< > 0 && user !== undefined} + isInvalid={isUserInvalid} name="user" readOnly={readOnly} value={user || ''} @@ -357,7 +368,7 @@ const WebhookActionConnectorFields: React.FunctionComponent< id="webhookPassword" fullWidth error={errors.password} - isInvalid={errors.password.length > 0 && password !== undefined} + isInvalid={isPasswordInvalid} label={i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.passwordTextFieldLabel', { @@ -369,7 +380,7 @@ const WebhookActionConnectorFields: React.FunctionComponent< fullWidth name="password" readOnly={readOnly} - isInvalid={errors.password.length > 0 && password !== undefined} + isInvalid={isPasswordInvalid} value={password || ''} data-test-subj="webhookPasswordInput" onChange={(e) => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.test.tsx index 964f538d54971..091ea1e305e35 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.test.tsx @@ -23,12 +23,12 @@ describe('action_connector_form', () => { id: 'my-action-type', iconClass: 'test', selectMessage: 'test', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, }); actionTypeRegistry.get.mockReturnValue(actionType); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.tsx index 0790dce9ca3d4..29232940da5c3 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.tsx @@ -51,11 +51,11 @@ export function validateBaseProperties( return validationResult; } -export function getConnectorErrors( +export async function getConnectorErrors( connector: UserConfiguredActionConnector, actionTypeModel: ActionTypeModel ) { - const connectorValidationResult = actionTypeModel?.validateConnector(connector); + const connectorValidationResult = await actionTypeModel?.validateConnector(connector); const configErrors = (connectorValidationResult.config ? connectorValidationResult.config.errors : {}) as IErrorObject; @@ -173,7 +173,8 @@ export const ActionConnectorForm = ({ ); const FieldsComponent = actionTypeRegistered.actionConnectorFields; - + const isNameInvalid: boolean = + connector.name !== undefined && errors.name !== undefined && errors.name.length > 0; return ( } - isInvalid={errors.name.length > 0 && connector.name !== undefined} + isInvalid={isNameInvalid} error={errors.name} > 0 && connector.name !== undefined} + isInvalid={isNameInvalid} name="name" placeholder="Untitled" data-test-subj="nameInput" diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx index ad727be58280f..bedde696e51c0 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx @@ -53,12 +53,12 @@ describe('action_form', () => { id: 'my-action-type', iconClass: 'test', selectMessage: 'test', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, actionParamsFields: mockedActionParamsFields, @@ -68,12 +68,12 @@ describe('action_form', () => { id: 'disabled-by-config', iconClass: 'test', selectMessage: 'test', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, actionParamsFields: mockedActionParamsFields, @@ -83,12 +83,12 @@ describe('action_form', () => { id: '.jira', iconClass: 'test', selectMessage: 'test', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): ValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, actionParamsFields: mockedActionParamsFields, @@ -98,12 +98,12 @@ describe('action_form', () => { id: 'disabled-by-license', iconClass: 'test', selectMessage: 'test', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, actionParamsFields: mockedActionParamsFields, @@ -113,12 +113,12 @@ describe('action_form', () => { id: 'preconfigured', iconClass: 'test', selectMessage: 'test', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, actionParamsFields: mockedActionParamsFields, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx index e9f79633ef520..f12ce25abc492 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { Fragment, useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { @@ -30,7 +30,7 @@ import { ActionTypeRegistryContract, } from '../../../types'; import { SectionLoading } from '../../components/section_loading'; -import { ActionTypeForm, ActionTypeFormProps } from './action_type_form'; +import { ActionTypeForm } from './action_type_form'; import { AddConnectorInline } from './connector_add_inline'; import { actionTypeCompare } from '../../lib/action_type_compare'; import { checkActionFormActionTypeEnabled } from '../../lib/check_action_type_enabled'; @@ -357,49 +357,42 @@ export const ActionForm = ({ ); } - const actionParamsErrors: ActionTypeFormProps['actionParamsErrors'] = actionTypeRegistry - .get(actionItem.actionTypeId) - ?.validateParams(actionItem.params); - return ( - - { - setActiveActionItem({ actionTypeId: actionItem.actionTypeId, indices: [index] }); - setAddModalVisibility(true); - }} - onConnectorSelected={(id: string) => { - setActionIdByIndex(id, index); - }} - actionTypeRegistry={actionTypeRegistry} - onDeleteAction={() => { - const updatedActions = actions.filter( - (_item: AlertAction, i: number) => i !== index - ); - setActions(updatedActions); - setIsAddActionPanelOpen( - updatedActions.filter((item: AlertAction) => item.id !== actionItem.id) - .length === 0 - ); - setActiveActionItem(undefined); - }} - /> - - + { + setActiveActionItem({ actionTypeId: actionItem.actionTypeId, indices: [index] }); + setAddModalVisibility(true); + }} + onConnectorSelected={(id: string) => { + setActionIdByIndex(id, index); + }} + actionTypeRegistry={actionTypeRegistry} + onDeleteAction={() => { + const updatedActions = actions.filter( + (_item: AlertAction, i: number) => i !== index + ); + setActions(updatedActions); + setIsAddActionPanelOpen( + updatedActions.filter((item: AlertAction) => item.id !== actionItem.id).length === + 0 + ); + setActiveActionItem(undefined); + }} + /> ); })} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.test.tsx index 38f1e8f52254c..e8590595b9d61 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.test.tsx @@ -43,12 +43,12 @@ describe('action_type_form', () => { id: '.pagerduty', iconClass: 'test', selectMessage: 'test', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, actionParamsFields: mockedActionParamsFields, @@ -92,12 +92,12 @@ describe('action_type_form', () => { id: '.pagerduty', iconClass: 'test', selectMessage: 'test', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, actionParamsFields: mockedActionParamsFields, @@ -220,7 +220,6 @@ function getActionTypeForm( onAddConnector={onAddConnector ?? jest.fn()} onDeleteAction={onDeleteAction ?? jest.fn()} onConnectorSelected={onConnectorSelected ?? jest.fn()} - actionParamsErrors={{ errors: { summary: [], timestamp: [], dedupKey: [] } }} defaultActionGroupId={defaultActionGroupId ?? 'default'} setActionParamsProperty={jest.fn()} index={index ?? 1} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx index 2690aeaffad32..526d899b7efb1 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx @@ -47,9 +47,6 @@ import { DefaultActionParams } from '../../lib/get_defaults_for_action_params'; export type ActionTypeFormProps = { actionItem: AlertAction; actionConnector: ActionConnector; - actionParamsErrors: { - errors: IErrorObject; - }; index: number; onAddConnector: () => void; onConnectorSelected: (id: string) => void; @@ -80,7 +77,6 @@ const preconfiguredMessage = i18n.translate( export const ActionTypeForm = ({ actionItem, actionConnector, - actionParamsErrors, index, onAddConnector, onConnectorSelected, @@ -106,6 +102,9 @@ export const ActionTypeForm = ({ const selectedActionGroup = actionGroups?.find(({ id }) => id === actionItem.group) ?? defaultActionGroup; const [actionGroup, setActionGroup] = useState(); + const [actionParamsErrors, setActionParamsErrors] = useState<{ errors: IErrorObject }>({ + errors: {}, + }); useEffect(() => { setAvailableActionVariables( @@ -130,6 +129,16 @@ export const ActionTypeForm = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [actionGroup]); + useEffect(() => { + (async () => { + const res: { errors: IErrorObject } = await actionTypeRegistry + .get(actionItem.actionTypeId) + ?.validateParams(actionItem.params); + setActionParamsErrors(res); + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [actionItem]); + const canSave = hasSaveActionsCapability(capabilities); const getSelectedOptions = (actionItemId: string) => { const selectedConnector = connectors.find((connector) => connector.id === actionItemId); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.test.tsx index 9a011823612c4..e15916138af71 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.test.tsx @@ -40,12 +40,12 @@ describe('connector_add_flyout', () => { id: 'my-action-type', iconClass: 'test', selectMessage: 'test', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, }); @@ -77,12 +77,12 @@ describe('connector_add_flyout', () => { id: 'my-action-type', iconClass: 'test', selectMessage: 'test', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, }); @@ -114,12 +114,12 @@ describe('connector_add_flyout', () => { id: 'my-action-type', iconClass: 'test', selectMessage: 'test', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.test.tsx index fedb2ed382994..8dbe5f105a0f7 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.test.tsx @@ -198,12 +198,12 @@ function createActionType() { id: `my-action-type-${++count}`, iconClass: 'test', selectMessage: 'test', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.tsx index d3a6d662720ca..1a3a186d891cc 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useCallback, useState, useReducer } from 'react'; +import React, { useCallback, useState, useReducer, useEffect } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiTitle, @@ -30,7 +30,9 @@ import { ActionType, ActionConnector, UserConfiguredActionConnector, + IErrorObject, ConnectorAddFlyoutProps, + ActionTypeModel, } from '../../../types'; import { hasSaveActionsCapability } from '../../lib/capabilities'; import { createActionConnector } from '../../lib/action_connector_api'; @@ -38,6 +40,7 @@ import { VIEW_LICENSE_OPTIONS_LINK } from '../../../common/constants'; import { useKibana } from '../../../common/lib/kibana'; import { createConnectorReducer, InitialConnector, ConnectorReducer } from './connector_reducer'; import { getConnectorWithInvalidatedFields } from '../../lib/value_validators'; +import { CenterJustifiedSpinner } from '../../components/center_justified_spinner'; const ConnectorAddFlyout: React.FunctionComponent = ({ onClose, @@ -47,7 +50,9 @@ const ConnectorAddFlyout: React.FunctionComponent = ({ consumer, actionTypeRegistry, }) => { - let hasErrors = false; + const [hasErrors, setHasErrors] = useState(true); + let actionTypeModel: ActionTypeModel | undefined; + const { http, notifications: { toasts }, @@ -55,7 +60,17 @@ const ConnectorAddFlyout: React.FunctionComponent = ({ } = useKibana().services; const [actionType, setActionType] = useState(undefined); const [hasActionsUpgradeableByTrial, setHasActionsUpgradeableByTrial] = useState(false); - + const [errors, setErrors] = useState<{ + configErrors: IErrorObject; + connectorBaseErrors: IErrorObject; + connectorErrors: IErrorObject; + secretsErrors: IErrorObject; + }>({ + configErrors: {}, + connectorBaseErrors: {}, + connectorErrors: {}, + secretsErrors: {}, + }); // hooks const initialConnector: InitialConnector, Record> = { actionTypeId: actionType?.id ?? '', @@ -73,6 +88,24 @@ const ConnectorAddFlyout: React.FunctionComponent = ({ Record >, }); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + (async () => { + if (actionTypeModel) { + setIsLoading(true); + const res = await getConnectorErrors(connector, actionTypeModel); + setHasErrors( + !!Object.keys(res.connectorErrors).find( + (errorKey) => (res.connectorErrors as IErrorObject)[errorKey].length >= 1 + ) + ); + setIsLoading(false); + setErrors({ ...res }); + } + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [connector, actionType]); const setActionProperty = ( key: Key, @@ -101,7 +134,6 @@ const ConnectorAddFlyout: React.FunctionComponent = ({ } let currentForm; - let actionTypeModel; let saveButton; if (!actionType) { currentForm = ( @@ -115,22 +147,12 @@ const ConnectorAddFlyout: React.FunctionComponent = ({ } else { actionTypeModel = actionTypeRegistry.get(actionType.id); - const { - configErrors, - connectorBaseErrors, - connectorErrors, - secretsErrors, - } = getConnectorErrors(connector, actionTypeModel); - hasErrors = !!Object.keys(connectorErrors).find( - (errorKey) => connectorErrors[errorKey].length >= 1 - ); - currentForm = ( @@ -170,9 +192,9 @@ const ConnectorAddFlyout: React.FunctionComponent = ({ setConnector( getConnectorWithInvalidatedFields( connector, - configErrors, - secretsErrors, - connectorBaseErrors + errors.configErrors, + errors.secretsErrors, + errors.connectorBaseErrors ) ); return; @@ -235,13 +257,13 @@ const ConnectorAddFlyout: React.FunctionComponent = ({ - {actionTypeModel && actionTypeModel.iconClass ? ( + {!!actionTypeModel && actionTypeModel.iconClass ? ( ) : null} - {actionTypeModel && actionType ? ( + {!!actionTypeModel && actionType ? ( <>

@@ -280,7 +302,17 @@ const ConnectorAddFlyout: React.FunctionComponent = ({ ) } > - {currentForm} + <> + {currentForm} + {isLoading ? ( + <> + + {' '} + + ) : ( + <> + )} + @@ -314,7 +346,7 @@ const ConnectorAddFlyout: React.FunctionComponent = ({ - {canSave && actionTypeModel && actionType ? saveButton : null} + {canSave && !!actionTypeModel && actionType ? saveButton : null} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.test.tsx index c18f6955d1217..1ae37cf96cd3e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.test.tsx @@ -39,12 +39,12 @@ describe('connector_add_modal', () => { id: 'my-action-type', iconClass: 'test', selectMessage: 'test', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx index d01ee08df2394..1e9669d1995dd 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useCallback, useMemo, useReducer, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useReducer, useState } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiModal, @@ -19,6 +19,7 @@ import { EuiFlexItem, EuiIcon, EuiFlexGroup, + EuiSpacer, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { ActionConnectorForm, getConnectorErrors } from './action_connector_form'; @@ -31,9 +32,11 @@ import { ActionConnector, ActionTypeRegistryContract, UserConfiguredActionConnector, + IErrorObject, } from '../../../types'; import { useKibana } from '../../../common/lib/kibana'; import { getConnectorWithInvalidatedFields } from '../../lib/value_validators'; +import { CenterJustifiedSpinner } from '../../components/center_justified_spinner'; // eslint-disable-next-line @typescript-eslint/consistent-type-definitions type ConnectorAddModalProps = { @@ -56,7 +59,7 @@ const ConnectorAddModal = ({ notifications: { toasts }, application: { capabilities }, } = useKibana().services; - let hasErrors = false; + const [hasErrors, setHasErrors] = useState(true); const initialConnector: InitialConnector< Record, Record @@ -69,6 +72,7 @@ const ConnectorAddModal = ({ [actionType.id] ); const [isSaving, setIsSaving] = useState(false); + const [isLoading, setIsLoading] = useState(false); const canSave = hasSaveActionsCapability(capabilities); const reducer: ConnectorReducer< @@ -81,6 +85,34 @@ const ConnectorAddModal = ({ Record >, }); + const [errors, setErrors] = useState<{ + configErrors: IErrorObject; + connectorBaseErrors: IErrorObject; + connectorErrors: IErrorObject; + secretsErrors: IErrorObject; + }>({ + configErrors: {}, + connectorBaseErrors: {}, + connectorErrors: {}, + secretsErrors: {}, + }); + + const actionTypeModel = actionTypeRegistry.get(actionType.id); + + useEffect(() => { + (async () => { + setIsLoading(true); + const res = await getConnectorErrors(connector, actionTypeModel); + setHasErrors( + !!Object.keys(res.connectorErrors).find( + (errorKey) => (res.connectorErrors as IErrorObject)[errorKey].length >= 1 + ) + ); + setIsLoading(false); + setErrors({ ...res }); + })(); + }, [connector, actionTypeModel]); + const setConnector = (value: any) => { dispatch({ command: { type: 'setConnector' }, payload: { key: 'connector', value } }); }; @@ -97,15 +129,6 @@ const ConnectorAddModal = ({ onClose(); }, [initialConnector, onClose]); - const actionTypeModel = actionTypeRegistry.get(actionType.id); - const { configErrors, connectorBaseErrors, connectorErrors, secretsErrors } = getConnectorErrors( - connector, - actionTypeModel - ); - hasErrors = !!Object.keys(connectorErrors).find( - (errorKey) => connectorErrors[errorKey].length >= 1 - ); - const onActionConnectorSave = async (): Promise => await createActionConnector({ http, connector }) .then((savedConnector) => { @@ -157,15 +180,25 @@ const ConnectorAddModal = ({ - + <> + + {isLoading ? ( + <> + + {' '} + + ) : ( + <> + )} + @@ -189,9 +222,9 @@ const ConnectorAddModal = ({ setConnector( getConnectorWithInvalidatedFields( connector, - configErrors, - secretsErrors, - connectorBaseErrors + errors.configErrors, + errors.secretsErrors, + errors.connectorBaseErrors ) ); return; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.test.tsx index 56bf57cb45095..e6d3c0bde8113 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.test.tsx @@ -51,12 +51,12 @@ describe('connector_edit_flyout', () => { id: 'test-action-type-id', iconClass: 'test', selectMessage: 'test', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, }); @@ -95,12 +95,12 @@ describe('connector_edit_flyout', () => { id: 'test-action-type-id', iconClass: 'test', selectMessage: 'test', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx index 66a4dcc452c51..ca729f9a61662 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useCallback, useReducer, useState } from 'react'; +import React, { useCallback, useReducer, useState, useEffect } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiTitle, @@ -23,6 +23,7 @@ import { EuiLink, EuiTabs, EuiTab, + EuiSpacer, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { Option, none, some } from 'fp-ts/lib/Option'; @@ -31,6 +32,7 @@ import { TestConnectorForm } from './test_connector_form'; import { ActionConnector, ConnectorEditFlyoutProps, + IErrorObject, EditConectorTabs, UserConfiguredActionConnector, } from '../../../types'; @@ -44,6 +46,7 @@ import { import './connector_edit_flyout.scss'; import { useKibana } from '../../../common/lib/kibana'; import { getConnectorWithInvalidatedFields } from '../../lib/value_validators'; +import { CenterJustifiedSpinner } from '../../components/center_justified_spinner'; const ConnectorEditFlyout = ({ initialConnector, @@ -53,12 +56,14 @@ const ConnectorEditFlyout = ({ consumer, actionTypeRegistry, }: ConnectorEditFlyoutProps) => { + const [hasErrors, setHasErrors] = useState(true); const { http, notifications: { toasts }, docLinks, application: { capabilities }, } = useKibana().services; + const getConnectorWithoutSecrets = () => ({ ...(initialConnector as UserConfiguredActionConnector< Record, @@ -75,6 +80,35 @@ const ConnectorEditFlyout = ({ const [{ connector }, dispatch] = useReducer(reducer, { connector: getConnectorWithoutSecrets(), }); + const [isLoading, setIsLoading] = useState(false); + const [errors, setErrors] = useState<{ + configErrors: IErrorObject; + connectorBaseErrors: IErrorObject; + connectorErrors: IErrorObject; + secretsErrors: IErrorObject; + }>({ + configErrors: {}, + connectorBaseErrors: {}, + connectorErrors: {}, + secretsErrors: {}, + }); + + const actionTypeModel = actionTypeRegistry.get(connector.actionTypeId); + + useEffect(() => { + (async () => { + setIsLoading(true); + const res = await getConnectorErrors(connector, actionTypeModel); + setHasErrors( + !!Object.keys(res.connectorErrors).find( + (errorKey) => (res.connectorErrors as IErrorObject)[errorKey].length >= 1 + ) + ); + setIsLoading(false); + setErrors({ ...res }); + })(); + }, [connector, actionTypeModel]); + const [isSaving, setIsSaving] = useState(false); const [selectedTab, setTab] = useState(tab); @@ -113,25 +147,6 @@ const ConnectorEditFlyout = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [onClose]); - const actionTypeModel = actionTypeRegistry.get(connector.actionTypeId); - const { - configErrors, - connectorBaseErrors, - connectorErrors, - secretsErrors, - } = !connector.isPreconfigured - ? getConnectorErrors(connector, actionTypeModel) - : { - configErrors: {}, - connectorBaseErrors: {}, - connectorErrors: {}, - secretsErrors: {}, - }; - - const hasErrors = !!Object.keys(connectorErrors).find( - (errorKey) => connectorErrors[errorKey].length >= 1 - ); - const onActionConnectorSave = async (): Promise => await updateActionConnector({ http, connector, id: connector.id }) .then((savedConnector) => { @@ -227,9 +242,9 @@ const ConnectorEditFlyout = ({ setConnector( getConnectorWithInvalidatedFields( connector, - configErrors, - secretsErrors, - connectorBaseErrors + errors.configErrors, + errors.secretsErrors, + errors.connectorBaseErrors ) ); return; @@ -286,19 +301,29 @@ const ConnectorEditFlyout = ({ {selectedTab === EditConectorTabs.Configuration ? ( !connector.isPreconfigured ? ( - { - setHasChanges(true); - // if the user changes the connector, "forget" the last execution - // so the user comes back to a clean form ready to run a fresh test - setTestExecutionResult(none); - dispatch(changes); - }} - actionTypeRegistry={actionTypeRegistry} - consumer={consumer} - /> + <> + { + setHasChanges(true); + // if the user changes the connector, "forget" the last execution + // so the user comes back to a clean form ready to run a fresh test + setTestExecutionResult(none); + dispatch(changes); + }} + actionTypeRegistry={actionTypeRegistry} + consumer={consumer} + /> + {isLoading ? ( + <> + + {' '} + + ) : ( + <> + )} + ) : ( <> diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/test_connector_form.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/test_connector_form.test.tsx index 5cdc15ab0375d..ae15670ce8ab9 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/test_connector_form.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/test_connector_form.test.tsx @@ -53,12 +53,12 @@ const actionType = { id: 'my-action-type', iconClass: 'test', selectMessage: 'test', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, actionParamsFields: mockedActionParamsFields, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/test_connector_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/test_connector_form.tsx index 92a17a2e4cfae..242c1c33d8d79 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/test_connector_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/test_connector_form.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { Suspense } from 'react'; +import React, { Suspense, useEffect, useState } from 'react'; import { EuiFlexGroup, EuiFlexItem, @@ -46,11 +46,18 @@ export const TestConnectorForm = ({ isExecutingAction, actionTypeRegistry, }: ConnectorAddFlyoutProps) => { + const [actionErrors, setActionErrors] = useState({}); + const [hasErrors, setHasErrors] = useState(false); const actionTypeModel = actionTypeRegistry.get(connector.actionTypeId); const ParamsFieldsComponent = actionTypeModel.actionParamsFields; - const actionErrors = actionTypeModel?.validateParams(actionParams).errors as IErrorObject; - const hasErrors = !!Object.values(actionErrors).find((errors) => errors.length > 0); + useEffect(() => { + (async () => { + const res = (await actionTypeModel?.validateParams(actionParams)).errors as IErrorObject; + setActionErrors({ ...res }); + setHasErrors(!!Object.values(res).find((errors) => errors.length > 0)); + })(); + }, [actionTypeModel, actionParams]); const steps = [ { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.test.tsx index 7b6453e705ec3..90eadaf5f9b8b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.test.tsx @@ -162,12 +162,12 @@ describe('actions_connectors_list component with items', () => { id: 'test', iconClass: 'test', selectMessage: 'test', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, actionParamsFields: mockedActionParamsFields, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.test.tsx index cb43c168aa999..b40b7cbc1a387 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.test.tsx @@ -135,12 +135,12 @@ describe('alert_add', () => { id: 'my-action-type', iconClass: 'test', selectMessage: 'test', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.tsx index a40f77998d6ee..2d111d5405230 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.tsx @@ -15,9 +15,10 @@ import { AlertTypeParams, AlertUpdates, AlertFlyoutCloseReason, + IErrorObject, AlertAddProps, } from '../../../types'; -import { AlertForm, getAlertErrors, isValidAlert } from './alert_form'; +import { AlertForm, getAlertActionErrors, getAlertErrors, isValidAlert } from './alert_form'; import { alertReducer, InitialAlert, InitialAlertReducer } from './alert_reducer'; import { createAlert } from '../../lib/alert_api'; import { HealthCheck } from '../../components/health_check'; @@ -102,6 +103,18 @@ const AlertAdd = ({ } }, [alert.params, initialAlertParams, setInitialAlertParams]); + const [alertActionsErrors, setAlertActionsErrors] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + (async () => { + setIsLoading(true); + const res = await getAlertActionErrors(alert as Alert, actionTypeRegistry); + setIsLoading(false); + setAlertActionsErrors([...res]); + })(); + }, [alert, actionTypeRegistry]); + const checkForChangesAndCloseFlyout = () => { if ( hasAlertChanged(alert, initialAlert, false) || @@ -125,9 +138,8 @@ const AlertAdd = ({ }; const alertType = alert.alertTypeId ? alertTypeRegistry.get(alert.alertTypeId) : null; - const { alertActionsErrors, alertBaseErrors, alertErrors, alertParamsErrors } = getAlertErrors( + const { alertBaseErrors, alertErrors, alertParamsErrors } = getAlertErrors( alert as Alert, - actionTypeRegistry, alertType ); @@ -195,9 +207,10 @@ const AlertAdd = ({ { setIsSaving(true); - if (!isValidAlert(alert, alertErrors, alertActionsErrors)) { + if (isLoading || !isValidAlert(alert, alertErrors, alertActionsErrors)) { setAlert( getAlertWithInvalidatedFields( alert as Alert, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add_footer.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add_footer.tsx index fe4b9d066429d..ee36257dedf0b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add_footer.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add_footer.tsx @@ -13,17 +13,25 @@ import { EuiFlexItem, EuiButtonEmpty, EuiButton, + EuiLoadingSpinner, + EuiSpacer, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { useHealthContext } from '../../context/health_context'; interface AlertAddFooterProps { isSaving: boolean; + isFormLoading: boolean; onSave: () => void; onCancel: () => void; } -export const AlertAddFooter = ({ isSaving, onSave, onCancel }: AlertAddFooterProps) => { +export const AlertAddFooter = ({ + isSaving, + onSave, + onCancel, + isFormLoading, +}: AlertAddFooterProps) => { const { loadingHealthCheck } = useHealthContext(); return ( @@ -36,6 +44,14 @@ export const AlertAddFooter = ({ isSaving, onSave, onCancel }: AlertAddFooterPro })} + {isFormLoading ? ( + + + + + ) : ( + <> + )} { id: 'my-action-type', iconClass: 'test', selectMessage: 'test', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.tsx index f6569f32088ee..bf6f0ef43b820 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useReducer, useState } from 'react'; +import React, { useReducer, useState, useEffect } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiTitle, @@ -20,11 +20,12 @@ import { EuiPortal, EuiCallOut, EuiSpacer, + EuiLoadingSpinner, } from '@elastic/eui'; import { cloneDeep } from 'lodash'; import { i18n } from '@kbn/i18n'; -import { Alert, AlertEditProps, AlertFlyoutCloseReason } from '../../../types'; -import { AlertForm, getAlertErrors, isValidAlert } from './alert_form'; +import { Alert, AlertFlyoutCloseReason, AlertEditProps, IErrorObject } from '../../../types'; +import { AlertForm, getAlertActionErrors, getAlertErrors, isValidAlert } from './alert_form'; import { alertReducer, ConcreteAlertReducer } from './alert_reducer'; import { updateAlert } from '../../lib/alert_api'; import { HealthCheck } from '../../components/health_check'; @@ -53,6 +54,8 @@ export const AlertEdit = ({ false ); const [isConfirmAlertCloseModalOpen, setIsConfirmAlertCloseModalOpen] = useState(false); + const [alertActionsErrors, setAlertActionsErrors] = useState([]); + const [isLoading, setIsLoading] = useState(false); const { http, @@ -64,9 +67,17 @@ export const AlertEdit = ({ const alertType = alertTypeRegistry.get(alert.alertTypeId); - const { alertActionsErrors, alertBaseErrors, alertErrors, alertParamsErrors } = getAlertErrors( + useEffect(() => { + (async () => { + setIsLoading(true); + const res = await getAlertActionErrors(alert as Alert, actionTypeRegistry); + setAlertActionsErrors([...res]); + setIsLoading(false); + })(); + }, [alert, actionTypeRegistry]); + + const { alertBaseErrors, alertErrors, alertParamsErrors } = getAlertErrors( alert as Alert, - actionTypeRegistry, alertType ); @@ -80,7 +91,11 @@ export const AlertEdit = ({ async function onSaveAlert(): Promise { try { - if (isValidAlert(alert, alertErrors, alertActionsErrors) && !hasActionsWithBrokenConnector) { + if ( + !isLoading && + isValidAlert(alert, alertErrors, alertActionsErrors) && + !hasActionsWithBrokenConnector + ) { const newAlert = await updateAlert({ http, alert, id: alert.id }); toasts.addSuccess( i18n.translate('xpack.triggersActionsUI.sections.alertEdit.saveSuccessNotificationText', { @@ -177,6 +192,14 @@ export const AlertEdit = ({ )} + {isLoading ? ( + + + + + ) : ( + <> + )} { id: 'my-action-type', iconClass: 'test', selectMessage: 'test', - validateConnector: (): ConnectorValidationResult => { - return { + validateConnector: (): Promise> => { + return Promise.resolve({ config: { errors: {}, }, secrets: { errors: {}, }, - }; + }); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx index b4b6477fd5947..16878abc362d0 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx @@ -121,11 +121,7 @@ export function validateBaseProperties(alertObject: InitialAlert): ValidationRes return validationResult; } -export function getAlertErrors( - alert: Alert, - actionTypeRegistry: ActionTypeRegistryContract, - alertTypeModel: AlertTypeModel | null -) { +export function getAlertErrors(alert: Alert, alertTypeModel: AlertTypeModel | null) { const alertParamsErrors: IErrorObject = alertTypeModel ? alertTypeModel.validate(alert.params).errors : []; @@ -135,18 +131,26 @@ export function getAlertErrors( ...alertBaseErrors, } as IErrorObject; - const alertActionsErrors = alert.actions.map((alertAction: AlertAction) => { - return actionTypeRegistry.get(alertAction.actionTypeId)?.validateParams(alertAction.params) - .errors; - }); return { alertParamsErrors, alertBaseErrors, - alertActionsErrors, alertErrors, }; } +export async function getAlertActionErrors( + alert: Alert, + actionTypeRegistry: ActionTypeRegistryContract +): Promise { + return await Promise.all( + alert.actions.map( + async (alertAction: AlertAction) => + (await actionTypeRegistry.get(alertAction.actionTypeId)?.validateParams(alertAction.params)) + .errors + ) + ); +} + export const hasObjectErrors: (errors: IErrorObject) => boolean = (errors) => !!Object.values(errors).find((errorList) => { if (isObject(errorList)) return hasObjectErrors(errorList as IErrorObject); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/type_registry.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/type_registry.test.ts index 8c7876c3f7255..ee561a65069e5 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/type_registry.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/type_registry.test.ts @@ -42,12 +42,12 @@ const getTestActionType = ( id: id || 'my-action-type', iconClass: iconClass || 'test', selectMessage: selectedMessage || 'test', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, }); diff --git a/x-pack/plugins/triggers_actions_ui/public/types.ts b/x-pack/plugins/triggers_actions_ui/public/types.ts index 0f2b961b1f2da..5ddddcb73a843 100644 --- a/x-pack/plugins/triggers_actions_ui/public/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/types.ts @@ -109,10 +109,10 @@ export interface ActionTypeModel - ) => ConnectorValidationResult, Partial>; + ) => Promise, Partial>>; validateParams: ( actionParams: ActionParams - ) => GenericValidationResult | unknown>; + ) => Promise | unknown>>; actionConnectorFields: React.LazyExoticComponent< ComponentType< ActionConnectorFieldsProps> From f367deca48a95ca017e98bbf9dba68c646187fc9 Mon Sep 17 00:00:00 2001 From: Shahzad Date: Thu, 3 Jun 2021 08:59:22 +0200 Subject: [PATCH 12/90] [Exploratory View] Refactor series storage (#100571) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../exploratory_view/configurations/utils.ts | 2 +- .../exploratory_view.test.tsx | 22 ++-- .../exploratory_view/exploratory_view.tsx | 19 ++- .../exploratory_view/header/header.test.tsx | 13 +- .../shared/exploratory_view/header/header.tsx | 6 +- .../hooks/use_lens_attributes.ts | 6 +- .../hooks/use_series_filters.ts | 6 +- ...url_storage.tsx => use_series_storage.tsx} | 121 +++++++++++------- .../shared/exploratory_view/index.tsx | 26 ++-- .../shared/exploratory_view/rtl_helpers.tsx | 58 ++++----- .../columns/chart_types.test.tsx | 12 +- .../series_builder/columns/chart_types.tsx | 6 +- .../columns/data_types_col.test.tsx | 16 +-- .../series_builder/columns/data_types_col.tsx | 5 +- .../columns/operation_type_select.test.tsx | 26 ++-- .../columns/operation_type_select.tsx | 6 +- .../columns/report_breakdowns.test.tsx | 17 +-- .../columns/report_definition_col.test.tsx | 22 ++-- .../columns/report_definition_col.tsx | 8 +- .../columns/report_definition_field.tsx | 6 +- .../columns/report_filters.test.tsx | 5 +- .../columns/report_types_col.test.tsx | 22 ++-- .../columns/report_types_col.tsx | 9 +- .../series_builder/custom_report_field.tsx | 6 +- .../series_builder/series_builder.tsx | 11 +- .../series_date_picker/index.tsx | 6 +- .../series_date_picker.test.tsx | 41 +++--- .../series_editor/columns/breakdowns.test.tsx | 13 +- .../series_editor/columns/breakdowns.tsx | 6 +- .../columns/filter_expanded.test.tsx | 22 ++-- .../series_editor/columns/filter_expanded.tsx | 6 +- .../columns/filter_value_btn.test.tsx | 3 +- .../columns/filter_value_btn.tsx | 6 +- .../series_editor/columns/remove_series.tsx | 4 +- .../series_editor/columns/series_actions.tsx | 5 +- .../series_editor/columns/series_filter.tsx | 5 +- .../series_editor/selected_filters.test.tsx | 8 +- .../series_editor/selected_filters.tsx | 6 +- .../series_editor/series_editor.tsx | 4 +- 39 files changed, 332 insertions(+), 259 deletions(-) rename x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/{use_url_storage.tsx => use_series_storage.tsx} (51%) diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/utils.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/utils.ts index 0d79f76be341c..fc60800bc4403 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/utils.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/utils.ts @@ -5,7 +5,7 @@ * 2.0. */ import rison, { RisonValue } from 'rison-node'; -import type { AllSeries, AllShortSeries } from '../hooks/use_url_storage'; +import type { AllSeries, AllShortSeries } from '../hooks/use_series_storage'; import type { SeriesUrl } from '../types'; import { IIndexPattern } from '../../../../../../../../src/plugins/data/common/index_patterns'; import { esFilters } from '../../../../../../../../src/plugins/data/public'; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.test.tsx index cf51c4614e543..fc0062694e0a3 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { screen, waitFor } from '@testing-library/dom'; -import { render, mockUrlStorage, mockCore, mockAppIndexPattern } from './rtl_helpers'; +import { render, mockCore, mockAppIndexPattern } from './rtl_helpers'; import { ExploratoryView } from './exploratory_view'; import { getStubIndexPattern } from '../../../../../../../src/plugins/data/public/test_utils'; import * as obsvInd from './utils/observability_index_patterns'; @@ -41,26 +41,26 @@ describe('ExploratoryView', () => { it('renders exploratory view', async () => { render(); - await waitFor(() => { - screen.getByText(/open in lens/i); - screen.getByRole('heading', { name: /analyze data/i }); - }); + expect(await screen.findByText(/open in lens/i)).toBeInTheDocument(); + expect( + await screen.findByRole('heading', { name: /Performance Distribution/i }) + ).toBeInTheDocument(); }); it('renders lens component when there is series', async () => { - mockUrlStorage({ + const initSeries = { data: { 'ux-series': { - dataType: 'ux', - reportType: 'pld', - breakdown: 'user_agent.name', + dataType: 'ux' as const, + reportType: 'pld' as const, + breakdown: 'user_agent .name', reportDefinitions: { 'service.name': ['elastic-co'] }, time: { from: 'now-15m', to: 'now' }, }, }, - }); + }; - render(); + render(, { initSeries }); expect(await screen.findByText(/open in lens/i)).toBeInTheDocument(); expect(await screen.findByText('Performance Distribution')).toBeInTheDocument(); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.tsx index 19136cda6387c..7958dca6e396e 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.tsx @@ -11,7 +11,7 @@ import styled from 'styled-components'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { ObservabilityPublicPluginsStart } from '../../../plugin'; import { ExploratoryViewHeader } from './header/header'; -import { useUrlStorage } from './hooks/use_url_storage'; +import { useSeriesStorage } from './hooks/use_series_storage'; import { useLensAttributes } from './hooks/use_lens_attributes'; import { EmptyView } from './components/empty_view'; import { TypedLensByValueInput } from '../../../../../lens/public'; @@ -19,7 +19,11 @@ import { useAppIndexPatternContext } from './hooks/use_app_index_pattern'; import { ReportToDataTypeMap } from './configurations/constants'; import { SeriesBuilder } from './series_builder/series_builder'; -export function ExploratoryView() { +export function ExploratoryView({ + saveAttributes, +}: { + saveAttributes?: (attr: TypedLensByValueInput['attributes'] | null) => void; +}) { const { services: { lens, notifications }, } = useKibana(); @@ -28,6 +32,7 @@ export function ExploratoryView() { const wrapperRef = useRef(null); const [height, setHeight] = useState('100vh'); + const [seriesId, setSeriesId] = useState(''); const [lensAttributes, setLensAttributes] = useState( null @@ -37,7 +42,11 @@ export function ExploratoryView() { const LensComponent = lens?.EmbeddableComponent; - const { firstSeriesId: seriesId, firstSeries: series, setSeries } = useUrlStorage(); + const { firstSeriesId, firstSeries: series, setSeries, allSeries } = useSeriesStorage(); + + useEffect(() => { + setSeriesId(firstSeriesId); + }, [allSeries, firstSeriesId]); const lensAttributesT = useLensAttributes({ seriesId, @@ -59,6 +68,10 @@ export function ExploratoryView() { useEffect(() => { setLensAttributes(lensAttributesT); + if (saveAttributes) { + saveAttributes(lensAttributesT); + } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [JSON.stringify(lensAttributesT ?? {}), series?.reportType, series?.time?.from]); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.test.tsx index dec69dc0a7b33..ca9f2c9e73eb8 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.test.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { mockUrlStorage, render } from '../rtl_helpers'; +import { render } from '../rtl_helpers'; import { ExploratoryViewHeader } from './header'; import { fireEvent } from '@testing-library/dom'; @@ -22,22 +22,23 @@ describe('ExploratoryViewHeader', function () { }); it('should be able to click open in lens', function () { - mockUrlStorage({ + const initSeries = { data: { 'uptime-pings-histogram': { - dataType: 'synthetics', - reportType: 'upp', + dataType: 'synthetics' as const, + reportType: 'upp' as const, breakdown: 'monitor.status', time: { from: 'now-15m', to: 'now' }, }, }, - }); + }; const { getByText, core } = render( + />, + { initSeries } ); fireEvent.click(getByText('Open in Lens')); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.tsx index 8f2f30185d37f..3265287a7f915 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.tsx @@ -12,7 +12,7 @@ import { TypedLensByValueInput } from '../../../../../../lens/public'; import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; import { ObservabilityPublicPluginsStart } from '../../../../plugin'; import { DataViewLabels } from '../configurations/constants'; -import { useUrlStorage } from '../hooks/use_url_storage'; +import { useSeriesStorage } from '../hooks/use_series_storage'; interface Props { seriesId: string; @@ -24,7 +24,9 @@ export function ExploratoryViewHeader({ seriesId, lensAttributes }: Props) { services: { lens }, } = useKibana(); - const { series } = useUrlStorage(seriesId); + const { getSeries } = useSeriesStorage(); + + const series = getSeries(seriesId); return ( diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_lens_attributes.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_lens_attributes.ts index ea6f435460401..4e9c360745b6b 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_lens_attributes.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_lens_attributes.ts @@ -9,7 +9,7 @@ import { useMemo } from 'react'; import { isEmpty } from 'lodash'; import { TypedLensByValueInput } from '../../../../../../lens/public'; import { LensAttributes } from '../configurations/lens_attributes'; -import { useUrlStorage } from './use_url_storage'; +import { useSeriesStorage } from './use_series_storage'; import { getDefaultConfigs } from '../configurations/default_configs'; import { DataSeries, SeriesUrl, UrlFilter } from '../types'; @@ -40,8 +40,8 @@ export const getFiltersFromDefs = ( export const useLensAttributes = ({ seriesId, }: Props): TypedLensByValueInput['attributes'] | null => { - const { series } = useUrlStorage(seriesId); - + const { getSeries } = useSeriesStorage(); + const series = getSeries(seriesId); const { breakdown, seriesType, operationType, reportType, reportDefinitions = {} } = series ?? {}; const { indexPattern } = useAppIndexPatternContext(); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_series_filters.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_series_filters.ts index 2605818ed7846..2d2618bc46152 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_series_filters.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_series_filters.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { useUrlStorage } from './use_url_storage'; +import { useSeriesStorage } from './use_series_storage'; import { UrlFilter } from '../types'; export interface UpdateFilter { @@ -15,7 +15,9 @@ export interface UpdateFilter { } export const useSeriesFilters = ({ seriesId }: { seriesId: string }) => { - const { series, setSeries } = useUrlStorage(seriesId); + const { getSeries, setSeries } = useSeriesStorage(); + + const series = getSeries(seriesId); const filters = series.filters ?? []; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_url_storage.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_series_storage.tsx similarity index 51% rename from x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_url_storage.tsx rename to x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_series_storage.tsx index 498886cc94410..fac75f910a93f 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_url_storage.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_series_storage.tsx @@ -5,8 +5,11 @@ * 2.0. */ -import React, { createContext, useContext, Context } from 'react'; -import { IKbnUrlStateStorage } from '../../../../../../../../src/plugins/kibana_utils/public'; +import React, { createContext, useContext, useState, useEffect, useCallback } from 'react'; +import { + IKbnUrlStateStorage, + ISessionStorageStateStorage, +} from '../../../../../../../../src/plugins/kibana_utils/public'; import type { AppDataType, ReportViewTypeId, @@ -18,17 +21,81 @@ import { convertToShortUrl } from '../configurations/utils'; import { OperationType, SeriesType } from '../../../../../../lens/public'; import { URL_KEYS } from '../configurations/constants/url_constants'; -export const UrlStorageContext = createContext(null); +export interface SeriesContextValue { + firstSeries: SeriesUrl; + firstSeriesId: string; + allSeriesIds: string[]; + allSeries: AllSeries; + setSeries: (seriesIdN: string, newValue: SeriesUrl) => void; + getSeries: (seriesId: string) => SeriesUrl; + removeSeries: (seriesId: string) => void; +} +export const UrlStorageContext = createContext({} as SeriesContextValue); interface ProviderProps { - storage: IKbnUrlStateStorage; + storage: IKbnUrlStateStorage | ISessionStorageStateStorage; } export function UrlStorageContextProvider({ children, storage, }: ProviderProps & { children: JSX.Element }) { - return {children}; + const allSeriesKey = 'sr'; + + const [allShortSeries, setAllShortSeries] = useState( + () => storage.get(allSeriesKey) ?? {} + ); + const [allSeries, setAllSeries] = useState({}); + const [firstSeriesId, setFirstSeriesId] = useState(''); + + useEffect(() => { + const allSeriesIds = Object.keys(allShortSeries); + const allSeriesN: AllSeries = {}; + allSeriesIds.forEach((seriesKey) => { + allSeriesN[seriesKey] = convertFromShortUrl(allShortSeries[seriesKey]); + }); + + setAllSeries(allSeriesN); + setFirstSeriesId(allSeriesIds?.[0]); + (storage as IKbnUrlStateStorage).set(allSeriesKey, allShortSeries); + }, [allShortSeries, storage]); + + const setSeries = (seriesIdN: string, newValue: SeriesUrl) => { + setAllShortSeries((prevState) => { + prevState[seriesIdN] = convertToShortUrl(newValue); + return { ...prevState }; + }); + }; + + const removeSeries = (seriesIdN: string) => { + delete allShortSeries[seriesIdN]; + delete allSeries[seriesIdN]; + }; + + const allSeriesIds = Object.keys(allShortSeries); + + const getSeries = useCallback( + (seriesId?: string) => { + return seriesId ? allSeries?.[seriesId] ?? {} : ({} as SeriesUrl); + }, + [allSeries] + ); + + const value = { + storage, + getSeries, + setSeries, + removeSeries, + firstSeriesId, + allSeries, + allSeriesIds, + firstSeries: allSeries?.[firstSeriesId], + }; + return {children}; +} + +export function useSeriesStorage() { + return useContext(UrlStorageContext); } function convertFromShortUrl(newValue: ShortUrlSeries): SeriesUrl { @@ -64,47 +131,3 @@ export type AllShortSeries = Record; export type AllSeries = Record; export const NEW_SERIES_KEY = 'new-series-key'; - -export function useUrlStorage(seriesId?: string) { - const allSeriesKey = 'sr'; - const storage = useContext((UrlStorageContext as unknown) as Context); - let series: SeriesUrl = {} as SeriesUrl; - const allShortSeries = storage.get(allSeriesKey) ?? {}; - - const allSeriesIds = Object.keys(allShortSeries); - - const allSeries: AllSeries = {}; - - allSeriesIds.forEach((seriesKey) => { - allSeries[seriesKey] = convertFromShortUrl(allShortSeries[seriesKey]); - }); - - if (seriesId) { - series = allSeries?.[seriesId] ?? ({} as SeriesUrl); - } - - const setSeries = async (seriesIdN: string, newValue: SeriesUrl) => { - allShortSeries[seriesIdN] = convertToShortUrl(newValue); - allSeries[seriesIdN] = newValue; - return storage.set(allSeriesKey, allShortSeries); - }; - - const removeSeries = (seriesIdN: string) => { - delete allShortSeries[seriesIdN]; - delete allSeries[seriesIdN]; - storage.set(allSeriesKey, allShortSeries); - }; - - const firstSeriesId = allSeriesIds?.[0]; - - return { - storage, - setSeries, - removeSeries, - series, - firstSeriesId, - allSeries, - allSeriesIds, - firstSeries: allSeries?.[firstSeriesId], - }; -} diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/index.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/index.tsx index 80b6b29f88303..3de29b02853e8 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/index.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/index.tsx @@ -17,11 +17,19 @@ import { IndexPatternContextProvider } from './hooks/use_app_index_pattern'; import { createKbnUrlStateStorage, withNotifyOnErrors, + createSessionStorageStateStorage, } from '../../../../../../../src/plugins/kibana_utils/public/'; -import { UrlStorageContextProvider } from './hooks/use_url_storage'; +import { UrlStorageContextProvider } from './hooks/use_series_storage'; import { useTrackPageview } from '../../..'; +import { TypedLensByValueInput } from '../../../../../lens/public'; -export function ExploratoryViewPage() { +export function ExploratoryViewPage({ + saveAttributes, + useSessionStorage = false, +}: { + useSessionStorage?: boolean; + saveAttributes?: (attr: TypedLensByValueInput['attributes'] | null) => void; +}) { useTrackPageview({ app: 'observability-overview', path: 'exploratory-view' }); useTrackPageview({ app: 'observability-overview', path: 'exploratory-view', delay: 15000 }); @@ -39,17 +47,19 @@ export function ExploratoryViewPage() { const history = useHistory(); - const kbnUrlStateStorage = createKbnUrlStateStorage({ - history, - useHash: uiSettings!.get('state:storeInSessionStorage'), - ...withNotifyOnErrors(notifications!.toasts), - }); + const kbnUrlStateStorage = useSessionStorage + ? createSessionStorageStateStorage() + : createKbnUrlStateStorage({ + history, + useHash: uiSettings!.get('state:storeInSessionStorage'), + ...withNotifyOnErrors(notifications!.toasts), + }); return ( - + diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/rtl_helpers.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/rtl_helpers.tsx index beb1daafbd55f..9118e49a42dfb 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/rtl_helpers.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/rtl_helpers.tsx @@ -16,29 +16,23 @@ import { CoreStart } from 'kibana/public'; import { I18nProvider } from '@kbn/i18n/react'; import { coreMock } from 'src/core/public/mocks'; import { - KibanaServices, KibanaContextProvider, + KibanaServices, } from '../../../../../../../src/plugins/kibana_react/public'; import { ObservabilityPublicPluginsStart } from '../../../plugin'; import { EuiThemeProvider } from '../../../../../../../src/plugins/kibana_react/common'; import { lensPluginMock } from '../../../../../lens/public/mocks'; +import * as useAppIndexPatternHook from './hooks/use_app_index_pattern'; import { IndexPatternContextProvider } from './hooks/use_app_index_pattern'; -import { AllSeries, UrlStorageContextProvider } from './hooks/use_url_storage'; -import { - withNotifyOnErrors, - createKbnUrlStateStorage, -} from '../../../../../../../src/plugins/kibana_utils/public'; +import { AllSeries, UrlStorageContext } from './hooks/use_series_storage'; + import * as fetcherHook from '../../../hooks/use_fetcher'; -import * as useUrlHook from './hooks/use_url_storage'; import * as useSeriesFilterHook from './hooks/use_series_filters'; import * as useHasDataHook from '../../../hooks/use_has_data'; import * as useValuesListHook from '../../../hooks/use_values_list'; -import * as useAppIndexPatternHook from './hooks/use_app_index_pattern'; - // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { getStubIndexPattern } from '../../../../../../../src/plugins/data/public/index_patterns/index_pattern.stub'; import indexPatternData from './configurations/test_data/test_index_pattern.json'; - // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { setIndexPatterns } from '../../../../../../../src/plugins/data/public/services'; import { IndexPatternsContract } from '../../../../../../../src/plugins/data/common/index_patterns/index_patterns'; @@ -73,6 +67,11 @@ interface RenderRouterOptions extends KibanaProviderOptions; url?: Url; + initSeries?: { + data?: AllSeries; + filters?: UrlFilter[]; + breakdown?: string; + }; } function getSetting(key: string): T { @@ -127,17 +126,8 @@ export const mockCore: () => Partial>({ children, core, - history, kibanaProps, }: MockKibanaProviderProps) { - const { notifications } = core!; - - const kbnUrlStateStorage = createKbnUrlStateStorage({ - history, - useHash: false, - ...withNotifyOnErrors(notifications!.toasts), - }); - const indexPattern = mockIndexPattern; setIndexPatterns(({ @@ -149,11 +139,7 @@ export function MockKibanaProvider>({ - - - {children} - - + {children} @@ -184,6 +170,7 @@ export function render( kibanaProps, renderOptions, url, + initSeries = {}, }: RenderRouterOptions = {} ) { if (url) { @@ -195,15 +182,20 @@ export function render( ...customCore, }; + const seriesContextValue = mockSeriesStorageContext(initSeries); + return { ...reactTestLibRender( - {ui} + + {ui} + , renderOptions ), history, core, + ...seriesContextValue, }; } @@ -256,7 +248,7 @@ export const mockUseValuesList = (values?: string[]) => { return { spy, onRefreshTimeRange }; }; -export const mockUrlStorage = ({ +function mockSeriesStorageContext({ data, filters, breakdown, @@ -264,7 +256,7 @@ export const mockUrlStorage = ({ data?: AllSeries; filters?: UrlFilter[]; breakdown?: string; -}) => { +}) { const mockDataSeries = data || { 'performance-distribution': { reportType: 'pld', @@ -282,18 +274,18 @@ export const mockUrlStorage = ({ const removeSeries = jest.fn(); const setSeries = jest.fn(); - const spy = jest.spyOn(useUrlHook, 'useUrlStorage').mockReturnValue({ + const getSeries = jest.fn().mockReturnValue(series); + + return { firstSeriesId, allSeriesIds, removeSeries, setSeries, - series, + getSeries, firstSeries: mockDataSeries[firstSeriesId], allSeries: mockDataSeries, - } as any); - - return { spy, removeSeries, setSeries }; -}; + }; +} export function mockUseSeriesFilter() { const removeFilter = jest.fn(); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/chart_types.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/chart_types.test.tsx index bac935dbecbe7..c054853d9c877 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/chart_types.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/chart_types.test.tsx @@ -7,13 +7,11 @@ import React from 'react'; import { fireEvent, screen, waitFor } from '@testing-library/react'; -import { mockUrlStorage, render } from '../../rtl_helpers'; +import { render } from '../../rtl_helpers'; import { SeriesChartTypesSelect, XYChartTypesSelect } from './chart_types'; describe.skip('SeriesChartTypesSelect', function () { it('should render properly', async function () { - mockUrlStorage({}); - render(); await waitFor(() => { @@ -22,9 +20,9 @@ describe.skip('SeriesChartTypesSelect', function () { }); it('should call set series on change', async function () { - const { setSeries } = mockUrlStorage({}); - - render(); + const { setSeries } = render( + + ); await waitFor(() => { screen.getByText(/chart type/i); @@ -44,8 +42,6 @@ describe.skip('SeriesChartTypesSelect', function () { describe('XYChartTypesSelect', function () { it('should render properly', async function () { - mockUrlStorage({}); - render(); await waitFor(() => { diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/chart_types.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/chart_types.tsx index a296d2520db34..9ae8b68bf3e8c 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/chart_types.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/chart_types.tsx @@ -11,7 +11,7 @@ import { i18n } from '@kbn/i18n'; import { useKibana } from '../../../../../../../../../src/plugins/kibana_react/public'; import { ObservabilityPublicPluginsStart } from '../../../../../plugin'; import { useFetcher } from '../../../../..'; -import { useUrlStorage } from '../../hooks/use_url_storage'; +import { useSeriesStorage } from '../../hooks/use_series_storage'; import { SeriesType } from '../../../../../../../lens/public'; const CHART_TYPE_LABEL = i18n.translate('xpack.observability.expView.chartTypes.label', { @@ -27,7 +27,9 @@ export function SeriesChartTypesSelect({ seriesTypes?: SeriesType[]; defaultChartType: SeriesType; }) { - const { series, setSeries, allSeries } = useUrlStorage(seriesId); + const { getSeries, setSeries, allSeries } = useSeriesStorage(); + + const series = getSeries(seriesId); const seriesType = series?.seriesType ?? defaultChartType; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/data_types_col.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/data_types_col.test.tsx index 9348fcbe15f6c..51529a3b1ac17 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/data_types_col.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/data_types_col.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { fireEvent, screen } from '@testing-library/react'; -import { mockAppIndexPattern, mockUrlStorage, render } from '../../rtl_helpers'; +import { mockAppIndexPattern, render } from '../../rtl_helpers'; import { dataTypes, DataTypesCol } from './data_types_col'; describe('DataTypesCol', function () { @@ -24,9 +24,7 @@ describe('DataTypesCol', function () { }); it('should set series on change', function () { - const { setSeries } = mockUrlStorage({}); - - render(); + const { setSeries } = render(); fireEvent.click(screen.getByText(/user experience \(rum\)/i)); @@ -35,18 +33,18 @@ describe('DataTypesCol', function () { }); it('should set series on change on already selected', function () { - mockUrlStorage({ + const initSeries = { data: { [seriesId]: { - dataType: 'synthetics', - reportType: 'upp', + dataType: 'synthetics' as const, + reportType: 'upp' as const, breakdown: 'monitor.status', time: { from: 'now-15m', to: 'now' }, }, }, - }); + }; - render(); + render(, { initSeries }); const button = screen.getByRole('button', { name: /Synthetic Monitoring/i, diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/data_types_col.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/data_types_col.tsx index b64fad51e9778..08e7f4ddcd3d0 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/data_types_col.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/data_types_col.tsx @@ -10,7 +10,7 @@ import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import styled from 'styled-components'; import { AppDataType } from '../../types'; import { useAppIndexPatternContext } from '../../hooks/use_app_index_pattern'; -import { useUrlStorage } from '../../hooks/use_url_storage'; +import { useSeriesStorage } from '../../hooks/use_series_storage'; import { ReportToDataTypeMap } from '../../configurations/constants'; export const dataTypes: Array<{ id: AppDataType; label: string }> = [ @@ -22,8 +22,9 @@ export const dataTypes: Array<{ id: AppDataType; label: string }> = [ ]; export function DataTypesCol({ seriesId }: { seriesId: string }) { - const { series, setSeries, removeSeries } = useUrlStorage(seriesId); + const { getSeries, setSeries, removeSeries } = useSeriesStorage(); + const series = getSeries(seriesId); const { loading } = useAppIndexPatternContext(); const onDataTypeChange = (dataType?: AppDataType) => { diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/operation_type_select.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/operation_type_select.test.tsx index 9550b8e98103b..c262a94f968be 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/operation_type_select.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/operation_type_select.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { fireEvent, screen } from '@testing-library/react'; -import { mockUrlStorage, render } from '../../rtl_helpers'; +import { render } from '../../rtl_helpers'; import { OperationTypeSelect } from './operation_type_select'; describe('OperationTypeSelect', function () { @@ -18,35 +18,35 @@ describe('OperationTypeSelect', function () { }); it('should display selected value', function () { - mockUrlStorage({ + const initSeries = { data: { 'performance-distribution': { - dataType: 'ux', - reportType: 'kpi', - operationType: 'median', + dataType: 'ux' as const, + reportType: 'kpi' as const, + operationType: 'median' as const, time: { from: 'now-15m', to: 'now' }, }, }, - }); + }; - render(); + render(, { initSeries }); screen.getByText('Median'); }); it('should call set series on change', function () { - const { setSeries } = mockUrlStorage({ + const initSeries = { data: { 'series-id': { - dataType: 'ux', - reportType: 'kpi', - operationType: 'median', + dataType: 'ux' as const, + reportType: 'kpi' as const, + operationType: 'median' as const, time: { from: 'now-15m', to: 'now' }, }, }, - }); + }; - render(); + const { setSeries } = render(, { initSeries }); fireEvent.click(screen.getByTestId('operationTypeSelect')); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/operation_type_select.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/operation_type_select.tsx index 75203d7bae3a0..fa273f6180935 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/operation_type_select.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/operation_type_select.tsx @@ -9,7 +9,7 @@ import React, { useEffect } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiSuperSelect } from '@elastic/eui'; -import { useUrlStorage } from '../../hooks/use_url_storage'; +import { useSeriesStorage } from '../../hooks/use_series_storage'; import { OperationType } from '../../../../../../../lens/public'; export function OperationTypeSelect({ @@ -19,7 +19,9 @@ export function OperationTypeSelect({ seriesId: string; defaultOperationType?: OperationType; }) { - const { series, setSeries } = useUrlStorage(seriesId); + const { getSeries, setSeries } = useSeriesStorage(); + + const series = getSeries(seriesId); const operationType = series?.operationType; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_breakdowns.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_breakdowns.test.tsx index 3363d17d81eab..f576862f18e76 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_breakdowns.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_breakdowns.test.tsx @@ -7,9 +7,8 @@ import React from 'react'; import { fireEvent, screen } from '@testing-library/react'; -import { render } from '../../../../../utils/test_helper'; import { getDefaultConfigs } from '../../configurations/default_configs'; -import { mockIndexPattern, mockUrlStorage } from '../../rtl_helpers'; +import { mockIndexPattern, render } from '../../rtl_helpers'; import { ReportBreakdowns } from './report_breakdowns'; import { USER_AGENT_OS } from '../../configurations/constants/elasticsearch_fieldnames'; @@ -22,8 +21,6 @@ describe('Series Builder ReportBreakdowns', function () { }); it('should render properly', function () { - mockUrlStorage({}); - render(); screen.getByText('Select an option: , is selected'); @@ -31,9 +28,9 @@ describe('Series Builder ReportBreakdowns', function () { }); it('should set new series breakdown on change', function () { - const { setSeries } = mockUrlStorage({}); - - render(); + const { setSeries } = render( + + ); const btn = screen.getByRole('button', { name: /select an option: Browser family , is selected/i, @@ -53,9 +50,9 @@ describe('Series Builder ReportBreakdowns', function () { }); }); it('should set undefined on new series on no select breakdown', function () { - const { setSeries } = mockUrlStorage({}); - - render(); + const { setSeries } = render( + + ); const btn = screen.getByRole('button', { name: /select an option: Browser family , is selected/i, diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_col.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_col.test.tsx index 27adcf4682c02..fdf6633c0ddb5 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_col.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_col.test.tsx @@ -11,7 +11,6 @@ import { getDefaultConfigs } from '../../configurations/default_configs'; import { mockAppIndexPattern, mockIndexPattern, - mockUrlStorage, mockUseValuesList, render, } from '../../rtl_helpers'; @@ -28,21 +27,23 @@ describe('Series Builder ReportDefinitionCol', function () { indexPattern: mockIndexPattern, }); - const { setSeries } = mockUrlStorage({ + const initSeries = { data: { [seriesId]: { - dataType: 'ux', - reportType: 'pld', + dataType: 'ux' as const, + reportType: 'pld' as const, time: { from: 'now-30d', to: 'now' }, reportDefinitions: { [SERVICE_NAME]: ['elastic-co'] }, }, }, - }); + }; mockUseValuesList(['elastic-co']); it('should render properly', async function () { - render(); + render(, { + initSeries, + }); screen.getByText('Web Application'); screen.getByText('Environment'); @@ -51,7 +52,9 @@ describe('Series Builder ReportDefinitionCol', function () { }); it('should render selected report definitions', async function () { - render(); + render(, { + initSeries, + }); expect(await screen.findByText('elastic-co')).toBeInTheDocument(); @@ -59,7 +62,10 @@ describe('Series Builder ReportDefinitionCol', function () { }); it('should be able to remove selected definition', async function () { - render(); + const { setSeries } = render( + , + { initSeries } + ); expect( await screen.findByLabelText('Remove elastic-co from selection in this group') diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_col.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_col.tsx index ff8b0f7aa578b..338f5d52c26fa 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_col.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_col.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiHorizontalRule } from '@elastic/eui'; import styled from 'styled-components'; import { useAppIndexPatternContext } from '../../hooks/use_app_index_pattern'; -import { useUrlStorage } from '../../hooks/use_url_storage'; +import { useSeriesStorage } from '../../hooks/use_series_storage'; import { CustomReportField } from '../custom_report_field'; import { DataSeries, URLReportDefinition } from '../../types'; import { SeriesChartTypesSelect } from './chart_types'; @@ -38,9 +38,11 @@ export function ReportDefinitionCol({ }) { const { indexPattern } = useAppIndexPatternContext(); - const { series, setSeries } = useUrlStorage(seriesId); + const { getSeries, setSeries } = useSeriesStorage(); - const { reportDefinitions: selectedReportDefinitions = {} } = series; + const series = getSeries(seriesId); + + const { reportDefinitions: selectedReportDefinitions = {} } = series ?? {}; const { reportDefinitions, defaultSeriesType, hasOperationType, yAxisColumns } = dataViewSeries; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_field.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_field.tsx index 9f92bec4d1f9c..1a6d2af8f4d40 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_field.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_field.tsx @@ -9,7 +9,7 @@ import React, { useMemo } from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { isEmpty } from 'lodash'; import FieldValueSuggestions from '../../../field_value_suggestions'; -import { useUrlStorage } from '../../hooks/use_url_storage'; +import { useSeriesStorage } from '../../hooks/use_series_storage'; import { useAppIndexPatternContext } from '../../hooks/use_app_index_pattern'; import { ESFilter } from '../../../../../../../../../typings/elasticsearch'; import { PersistableFilter } from '../../../../../../../lens/common'; @@ -25,7 +25,9 @@ interface Props { } export function ReportDefinitionField({ seriesId, field, dataSeries, onChange }: Props) { - const { series } = useUrlStorage(seriesId); + const { getSeries } = useSeriesStorage(); + + const series = getSeries(seriesId); const { indexPattern } = useAppIndexPatternContext(); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_filters.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_filters.test.tsx index 1467cb54d648a..dc2dc629cc121 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_filters.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_filters.test.tsx @@ -7,10 +7,9 @@ import React from 'react'; import { screen } from '@testing-library/react'; -import { render } from '../../../../../utils/test_helper'; import { ReportFilters } from './report_filters'; import { getDefaultConfigs } from '../../configurations/default_configs'; -import { mockIndexPattern, mockUrlStorage } from '../../rtl_helpers'; +import { mockIndexPattern, render } from '../../rtl_helpers'; describe('Series Builder ReportFilters', function () { const seriesId = 'test-series-id'; @@ -20,7 +19,7 @@ describe('Series Builder ReportFilters', function () { reportType: 'pld', indexPattern: mockIndexPattern, }); - mockUrlStorage({}); + it('should render properly', function () { render(); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.test.tsx index 20c4ea98d482d..c721a2fa2fe77 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.test.tsx @@ -7,11 +7,11 @@ import React from 'react'; import { fireEvent, screen } from '@testing-library/react'; -import { mockAppIndexPattern, mockUrlStorage, render } from '../../rtl_helpers'; +import { mockAppIndexPattern, render } from '../../rtl_helpers'; import { ReportTypesCol, SELECTED_DATA_TYPE_FOR_REPORT } from './report_types_col'; import { ReportTypes } from '../series_builder'; import { DEFAULT_TIME } from '../../configurations/constants'; -import { NEW_SERIES_KEY } from '../../hooks/use_url_storage'; +import { NEW_SERIES_KEY } from '../../hooks/use_series_storage'; describe('ReportTypesCol', function () { const seriesId = 'test-series-id'; @@ -30,8 +30,9 @@ describe('ReportTypesCol', function () { }); it('should set series on change', function () { - const { setSeries } = mockUrlStorage({}); - render(); + const { setSeries } = render( + + ); fireEvent.click(screen.getByText(/monitor duration/i)); @@ -46,18 +47,21 @@ describe('ReportTypesCol', function () { }); it('should set selected as filled', function () { - const { setSeries } = mockUrlStorage({ + const initSeries = { data: { [NEW_SERIES_KEY]: { - dataType: 'synthetics', - reportType: 'upp', + dataType: 'synthetics' as const, + reportType: 'upp' as const, breakdown: 'monitor.status', time: { from: 'now-15m', to: 'now' }, }, }, - }); + }; - render(); + const { setSeries } = render( + , + { initSeries } + ); const button = screen.getByRole('button', { name: /pings histogram/i, diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.tsx index bd82d1d1bd500..9fff8dae14a47 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.tsx @@ -11,7 +11,7 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; import styled from 'styled-components'; import { ReportViewTypeId, SeriesUrl } from '../../types'; -import { useUrlStorage } from '../../hooks/use_url_storage'; +import { useSeriesStorage } from '../../hooks/use_series_storage'; import { DEFAULT_TIME } from '../../configurations/constants'; import { useAppIndexPatternContext } from '../../hooks/use_app_index_pattern'; @@ -21,10 +21,9 @@ interface Props { } export function ReportTypesCol({ seriesId, reportTypes }: Props) { - const { - series: { reportType: selectedReportType, ...restSeries }, - setSeries, - } = useUrlStorage(seriesId); + const { setSeries, getSeries } = useSeriesStorage(); + + const { reportType: selectedReportType, ...restSeries } = getSeries(seriesId); const { loading, hasData, selectedApp } = useAppIndexPatternContext(); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/custom_report_field.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/custom_report_field.tsx index b41f3a603e5da..201df9628e135 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/custom_report_field.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/custom_report_field.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { EuiSuperSelect } from '@elastic/eui'; -import { useUrlStorage } from '../hooks/use_url_storage'; +import { useSeriesStorage } from '../hooks/use_series_storage'; import { ReportDefinition } from '../types'; interface Props { @@ -18,7 +18,9 @@ interface Props { } export function CustomReportField({ field, seriesId, options: opts }: Props) { - const { series, setSeries } = useUrlStorage(seriesId); + const { getSeries, setSeries } = useSeriesStorage(); + + const series = getSeries(seriesId); const { reportDefinitions: rtd = {} } = series; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/series_builder.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/series_builder.tsx index 1944bb281598b..32f1fb7f7c43b 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/series_builder.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/series_builder.tsx @@ -15,7 +15,7 @@ import { ReportTypesCol } from './columns/report_types_col'; import { ReportDefinitionCol } from './columns/report_definition_col'; import { ReportFilters } from './columns/report_filters'; import { ReportBreakdowns } from './columns/report_breakdowns'; -import { NEW_SERIES_KEY, useUrlStorage } from '../hooks/use_url_storage'; +import { NEW_SERIES_KEY, useSeriesStorage } from '../hooks/use_series_storage'; import { useAppIndexPatternContext } from '../hooks/use_app_index_pattern'; import { getDefaultConfigs } from '../configurations/default_configs'; @@ -53,7 +53,9 @@ export function SeriesBuilder({ seriesId: string; seriesBuilderRef: RefObject; }) { - const { series, setSeries, removeSeries } = useUrlStorage(seriesId); + const { getSeries, setSeries, removeSeries } = useSeriesStorage(); + + const series = getSeries(seriesId); const { dataType, @@ -156,9 +158,8 @@ export function SeriesBuilder({ reportDefinitions, }; - setSeries(newSeriesId, newSeriesN).then(() => { - removeSeries(NEW_SERIES_KEY); - }); + setSeries(newSeriesId, newSeriesN); + removeSeries(NEW_SERIES_KEY); } }; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_date_picker/index.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_date_picker/index.tsx index 960c2978287bc..d6a70532f4257 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_date_picker/index.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_date_picker/index.tsx @@ -8,7 +8,7 @@ import { EuiSuperDatePicker } from '@elastic/eui'; import React, { useEffect } from 'react'; import { useHasData } from '../../../../hooks/use_has_data'; -import { useUrlStorage } from '../hooks/use_url_storage'; +import { useSeriesStorage } from '../hooks/use_series_storage'; import { useQuickTimeRanges } from '../../../../hooks/use_quick_time_ranges'; import { DEFAULT_TIME } from '../configurations/constants'; @@ -30,7 +30,9 @@ export function SeriesDatePicker({ seriesId }: Props) { const commonlyUsedRanges = useQuickTimeRanges(); - const { series, setSeries } = useUrlStorage(seriesId); + const { getSeries, setSeries } = useSeriesStorage(); + + const series = getSeries(seriesId); function onTimeChange({ start, end }: { start: string; end: string }) { onRefreshTimeRange(); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_date_picker/series_date_picker.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_date_picker/series_date_picker.test.tsx index e99b701f091fe..0edc4330ef97a 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_date_picker/series_date_picker.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_date_picker/series_date_picker.test.tsx @@ -6,62 +6,67 @@ */ import React from 'react'; -import { mockUrlStorage, mockUseHasData, render } from '../rtl_helpers'; +import { mockUseHasData, render } from '../rtl_helpers'; import { fireEvent, waitFor } from '@testing-library/react'; import { SeriesDatePicker } from './index'; import { DEFAULT_TIME } from '../configurations/constants'; describe('SeriesDatePicker', function () { it('should render properly', function () { - mockUrlStorage({ + const initSeries = { data: { 'uptime-pings-histogram': { - dataType: 'synthetics', - reportType: 'upp', + dataType: 'synthetics' as const, + reportType: 'upp' as const, breakdown: 'monitor.status', time: { from: 'now-30m', to: 'now' }, }, }, - }); - const { getByText } = render(); + }; + const { getByText } = render(, { initSeries }); getByText('Last 30 minutes'); }); it('should set defaults', async function () { - const { setSeries: setSeries1 } = mockUrlStorage({ + const initSeries = { data: { 'uptime-pings-histogram': { - reportType: 'upp', - dataType: 'synthetics', + reportType: 'upp' as const, + dataType: 'synthetics' as const, breakdown: 'monitor.status', }, }, - } as any); - render(); + }; + const { setSeries: setSeries1 } = render( + , + { initSeries: initSeries as any } + ); expect(setSeries1).toHaveBeenCalledTimes(1); expect(setSeries1).toHaveBeenCalledWith('uptime-pings-histogram', { breakdown: 'monitor.status', - dataType: 'synthetics', - reportType: 'upp', + dataType: 'synthetics' as const, + reportType: 'upp' as const, time: DEFAULT_TIME, }); }); it('should set series data', async function () { - const { setSeries } = mockUrlStorage({ + const initSeries = { data: { 'uptime-pings-histogram': { - dataType: 'synthetics', - reportType: 'upp', + dataType: 'synthetics' as const, + reportType: 'upp' as const, breakdown: 'monitor.status', time: { from: 'now-30m', to: 'now' }, }, }, - }); + }; const { onRefreshTimeRange } = mockUseHasData(); - const { getByTestId } = render(); + const { getByTestId, setSeries } = render(, { + initSeries, + }); await waitFor(function () { fireEvent.click(getByTestId('superDatePickerToggleQuickMenuButton')); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/breakdowns.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/breakdowns.test.tsx index 9d26ec79c31ad..0ce9db73f92b1 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/breakdowns.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/breakdowns.test.tsx @@ -8,8 +8,8 @@ import React from 'react'; import { fireEvent, screen } from '@testing-library/react'; import { Breakdowns } from './breakdowns'; -import { mockIndexPattern, mockUrlStorage, render } from '../../rtl_helpers'; -import { NEW_SERIES_KEY } from '../../hooks/use_url_storage'; +import { mockIndexPattern, render } from '../../rtl_helpers'; +import { NEW_SERIES_KEY } from '../../hooks/use_series_storage'; import { getDefaultConfigs } from '../../configurations/default_configs'; import { USER_AGENT_OS } from '../../configurations/constants/elasticsearch_fieldnames'; @@ -21,8 +21,6 @@ describe('Breakdowns', function () { }); it('should render properly', async function () { - mockUrlStorage({}); - render( + />, + { initSeries } ); screen.getAllByText('Operating system'); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/breakdowns.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/breakdowns.tsx index 5cf6ac47aa8c7..cf24cb31951b1 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/breakdowns.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/breakdowns.tsx @@ -8,8 +8,8 @@ import React from 'react'; import { EuiSuperSelect } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { useSeriesStorage } from '../../hooks/use_series_storage'; import { USE_BREAK_DOWN_COLUMN } from '../../configurations/constants'; -import { useUrlStorage } from '../../hooks/use_url_storage'; import { DataSeries } from '../../types'; interface Props { @@ -19,7 +19,9 @@ interface Props { } export function Breakdowns({ reportViewConfig, seriesId, breakdowns = [] }: Props) { - const { setSeries, series } = useUrlStorage(seriesId); + const { setSeries, getSeries } = useSeriesStorage(); + + const series = getSeries(seriesId); const selectedBreakdown = series.breakdown; const NO_BREAKDOWN = 'no_breakdown'; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.test.tsx index 8d3060792857e..1a8c5b335bc4f 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.test.tsx @@ -8,12 +8,12 @@ import React from 'react'; import { fireEvent, screen } from '@testing-library/react'; import { FilterExpanded } from './filter_expanded'; -import { mockAppIndexPattern, mockUrlStorage, mockUseValuesList, render } from '../../rtl_helpers'; +import { mockAppIndexPattern, mockUseValuesList, render } from '../../rtl_helpers'; import { USER_AGENT_NAME } from '../../configurations/constants/elasticsearch_fieldnames'; describe('FilterExpanded', function () { it('should render properly', async function () { - mockUrlStorage({ filters: [{ field: USER_AGENT_NAME, values: ['Chrome'] }] }); + const initSeries = { filters: [{ field: USER_AGENT_NAME, values: ['Chrome'] }] }; mockAppIndexPattern(); render( @@ -22,13 +22,14 @@ describe('FilterExpanded', function () { label={'Browser Family'} field={USER_AGENT_NAME} goBack={jest.fn()} - /> + />, + { initSeries } ); screen.getByText('Browser Family'); }); it('should call go back on click', async function () { - mockUrlStorage({ filters: [{ field: USER_AGENT_NAME, values: ['Chrome'] }] }); + const initSeries = { filters: [{ field: USER_AGENT_NAME, values: ['Chrome'] }] }; const goBack = jest.fn(); render( @@ -37,7 +38,8 @@ describe('FilterExpanded', function () { label={'Browser Family'} field={USER_AGENT_NAME} goBack={goBack} - /> + />, + { initSeries } ); fireEvent.click(screen.getByText('Browser Family')); @@ -47,7 +49,7 @@ describe('FilterExpanded', function () { }); it('should call useValuesList on load', async function () { - mockUrlStorage({ filters: [{ field: USER_AGENT_NAME, values: ['Chrome'] }] }); + const initSeries = { filters: [{ field: USER_AGENT_NAME, values: ['Chrome'] }] }; const { spy } = mockUseValuesList(['Chrome', 'Firefox']); @@ -59,7 +61,8 @@ describe('FilterExpanded', function () { label={'Browser Family'} field={USER_AGENT_NAME} goBack={goBack} - /> + />, + { initSeries } ); expect(spy).toHaveBeenCalledTimes(1); @@ -71,7 +74,7 @@ describe('FilterExpanded', function () { ); }); it('should filter display values', async function () { - mockUrlStorage({ filters: [{ field: USER_AGENT_NAME, values: ['Chrome'] }] }); + const initSeries = { filters: [{ field: USER_AGENT_NAME, values: ['Chrome'] }] }; mockUseValuesList(['Chrome', 'Firefox']); @@ -81,7 +84,8 @@ describe('FilterExpanded', function () { label={'Browser Family'} field={USER_AGENT_NAME} goBack={jest.fn()} - /> + />, + { initSeries } ); expect(screen.queryByText('Firefox')).toBeTruthy(); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.tsx index 7a646c9035968..cc1769cfa8c95 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.tsx @@ -11,7 +11,7 @@ import styled from 'styled-components'; import { rgba } from 'polished'; import { i18n } from '@kbn/i18n'; import { useAppIndexPatternContext } from '../../hooks/use_app_index_pattern'; -import { useUrlStorage } from '../../hooks/use_url_storage'; +import { useSeriesStorage } from '../../hooks/use_series_storage'; import { UrlFilter } from '../../types'; import { FilterValueButton } from './filter_value_btn'; import { useValuesList } from '../../../../../hooks/use_values_list'; @@ -33,7 +33,9 @@ export function FilterExpanded({ seriesId, field, label, goBack, nestedField, is const [isOpen, setIsOpen] = useState({ value: '', negate: false }); - const { series } = useUrlStorage(seriesId); + const { getSeries } = useSeriesStorage(); + + const series = getSeries(seriesId); const { values, loading } = useValuesList({ query: value, diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_value_btn.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_value_btn.test.tsx index befbb3b74d6d7..79eb858b7624b 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_value_btn.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_value_btn.test.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { fireEvent, screen } from '@testing-library/react'; import { FilterValueButton } from './filter_value_btn'; -import { mockUrlStorage, mockUseSeriesFilter, mockUseValuesList, render } from '../../rtl_helpers'; +import { mockUseSeriesFilter, mockUseValuesList, render } from '../../rtl_helpers'; import { USER_AGENT_NAME, USER_AGENT_VERSION, @@ -75,7 +75,6 @@ describe('FilterValueButton', function () { }); }); it('should remove filter on click if already selected', async function () { - mockUrlStorage({}); const { removeFilter } = mockUseSeriesFilter(); render( diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_value_btn.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_value_btn.tsx index ccb9c90a884bb..ea84ec6b6c212 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_value_btn.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_value_btn.tsx @@ -8,7 +8,7 @@ import { i18n } from '@kbn/i18n'; import React, { useMemo } from 'react'; import { EuiFilterButton, hexToRgb } from '@elastic/eui'; import { useAppIndexPatternContext } from '../../hooks/use_app_index_pattern'; -import { useUrlStorage } from '../../hooks/use_url_storage'; +import { useSeriesStorage } from '../../hooks/use_series_storage'; import { useSeriesFilters } from '../../hooks/use_series_filters'; import { euiStyled } from '../../../../../../../../../src/plugins/kibana_react/common'; import FieldValueSuggestions from '../../../field_value_suggestions'; @@ -37,7 +37,9 @@ export function FilterValueButton({ nestedField, allSelectedValues, }: Props) { - const { series } = useUrlStorage(seriesId); + const { getSeries } = useSeriesStorage(); + + const series = getSeries(seriesId); const { indexPattern } = useAppIndexPatternContext(); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/remove_series.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/remove_series.tsx index ba2cdc545fbef..dc84352ff3b3d 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/remove_series.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/remove_series.tsx @@ -8,14 +8,14 @@ import { i18n } from '@kbn/i18n'; import React from 'react'; import { EuiButtonIcon } from '@elastic/eui'; -import { useUrlStorage } from '../../hooks/use_url_storage'; +import { useSeriesStorage } from '../../hooks/use_series_storage'; interface Props { seriesId: string; } export function RemoveSeries({ seriesId }: Props) { - const { removeSeries } = useUrlStorage(); + const { removeSeries } = useSeriesStorage(); const onClick = () => { removeSeries(seriesId); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_actions.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_actions.tsx index cdc20e2d9ab6c..5374fc33093a1 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_actions.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_actions.tsx @@ -9,14 +9,15 @@ import React from 'react'; import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { RemoveSeries } from './remove_series'; -import { NEW_SERIES_KEY, useUrlStorage } from '../../hooks/use_url_storage'; +import { NEW_SERIES_KEY, useSeriesStorage } from '../../hooks/use_series_storage'; import { ReportToDataTypeMap } from '../../configurations/constants'; interface Props { seriesId: string; } export function SeriesActions({ seriesId }: Props) { - const { series, removeSeries, setSeries } = useUrlStorage(seriesId); + const { getSeries, removeSeries, setSeries } = useSeriesStorage(); + const series = getSeries(seriesId); const onEdit = () => { removeSeries(seriesId); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_filter.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_filter.tsx index 926852fda5cbc..9e5770c2de8f9 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_filter.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_filter.tsx @@ -19,7 +19,7 @@ import { FilterExpanded } from './filter_expanded'; import { DataSeries } from '../../types'; import { FieldLabels } from '../../configurations/constants/constants'; import { SelectedFilters } from '../selected_filters'; -import { useUrlStorage } from '../../hooks/use_url_storage'; +import { useSeriesStorage } from '../../hooks/use_series_storage'; interface Props { seriesId: string; @@ -53,7 +53,8 @@ export function SeriesFilter({ series, isNew, seriesId, defaultFilters = [] }: P }; }); - const { setSeries, series: urlSeries } = useUrlStorage(seriesId); + const { setSeries, getSeries } = useSeriesStorage(); + const urlSeries = getSeries(seriesId); const button = ( ); + render(, { initSeries }); await waitFor(() => { screen.getByText('Chrome'); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/selected_filters.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/selected_filters.tsx index aabb39f88507f..63abb581c9c72 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/selected_filters.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/selected_filters.tsx @@ -7,7 +7,7 @@ import React, { Fragment } from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { useUrlStorage } from '../hooks/use_url_storage'; +import { useSeriesStorage } from '../hooks/use_series_storage'; import { FilterLabel } from '../components/filter_label'; import { DataSeries, UrlFilter } from '../types'; import { useAppIndexPatternContext } from '../hooks/use_app_index_pattern'; @@ -20,7 +20,9 @@ interface Props { isNew?: boolean; } export function SelectedFilters({ seriesId, isNew, series: dataSeries }: Props) { - const { series } = useUrlStorage(seriesId); + const { getSeries } = useSeriesStorage(); + + const series = getSeries(seriesId); const { reportDefinitions = {} } = series; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/series_editor.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/series_editor.tsx index d883b854c88cb..6e513fcd2fec9 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/series_editor.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/series_editor.tsx @@ -11,7 +11,7 @@ import { EuiBasicTable, EuiIcon, EuiSpacer, EuiText } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { SeriesFilter } from './columns/series_filter'; import { DataSeries } from '../types'; -import { NEW_SERIES_KEY, useUrlStorage } from '../hooks/use_url_storage'; +import { NEW_SERIES_KEY, useSeriesStorage } from '../hooks/use_series_storage'; import { getDefaultConfigs } from '../configurations/default_configs'; import { DatePickerCol } from './columns/date_picker_col'; import { useAppIndexPatternContext } from '../hooks/use_app_index_pattern'; @@ -19,7 +19,7 @@ import { SeriesActions } from './columns/series_actions'; import { ChartEditOptions } from './chart_edit_options'; export function SeriesEditor() { - const { allSeries, firstSeriesId } = useUrlStorage(); + const { allSeries, firstSeriesId } = useSeriesStorage(); const columns = [ { From d4ecee6ba0ceb92fcf965b776139821abfb85975 Mon Sep 17 00:00:00 2001 From: Ashokaditya Date: Thu, 3 Jun 2021 09:22:49 +0200 Subject: [PATCH 13/90] [Security Solution] [Endpoint] Add endpoint details activity log (#99795) * WIP add tabs for endpoint details * fetch activity log for endpoint this is work in progress with dummy data * refactor to hold host details and activity log within endpointDetails * api for fetching actions log * add a selector for getting selected agent id * use the new api to show actions log * review changes * move util function to common/utils in order to use it in endpoint_hosts as well as in trusted _apps review suggestion * use util function to get API path review suggestion * sync url params with details active tab review suggestion * fix types due to merge commit refs 3722552f739f74d3c457e8ed6cf80444aa6dfd06 * use AsyncResourseState type review suggestions * sort entries chronologically with recent at the top * adjust icon sizes within entries to match mocks * remove endpoint list paging stuff (not for now) * fix import after sync with master * make the search bar work (sort of) this needs to be fleshed out in a later PR * add tests to middleware for now * use snake case for naming routes review changes * rename and use own relative time function review change * use euiTheme tokens review change * add a comment review changes * log errors to kibana log and unwind stack review changes * use FleetActionGenerator for mocking data review changes Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../common/endpoint/constants.ts | 3 + .../common/endpoint/schema/actions.ts | 8 ++ .../endpoint/formatted_date_time.tsx | 8 +- .../containers/detection_engine/alerts/api.ts | 2 +- .../public/management/common/utils.test.ts | 37 +++++- .../public/management/common/utils.ts | 5 + .../pages/endpoint_hosts/store/action.ts | 10 +- .../pages/endpoint_hosts/store/builders.ts | 53 ++++++++ .../pages/endpoint_hosts/store/index.test.ts | 13 +- .../endpoint_hosts/store/middleware.test.ts | 66 +++++++++- .../pages/endpoint_hosts/store/middleware.ts | 29 ++++- .../pages/endpoint_hosts/store/reducer.ts | 114 ++++++++++------- .../pages/endpoint_hosts/store/selectors.ts | 55 ++++++++- .../management/pages/endpoint_hosts/types.ts | 20 +-- .../components/endpoint_details_tabs.tsx | 78 ++++++++++++ .../view/details/components/log_entry.tsx | 57 +++++++++ .../view/details/endpoint_activity_log.tsx | 45 +++++++ .../view/details/endpoint_details.tsx | 1 + .../view/details/endpoints.stories.tsx | 111 +++++++++++++++++ .../endpoint_hosts/view/details/index.tsx | 115 +++++++++++++++--- .../pages/endpoint_hosts/view/translations.ts | 23 ++++ .../pages/trusted_apps/service/index.ts | 2 +- .../pages/trusted_apps/service/utils.test.ts | 45 ------- .../pages/trusted_apps/service/utils.ts | 11 -- .../view/trusted_apps_page.test.tsx | 2 +- .../public/management/store/reducer.ts | 8 +- .../endpoint/routes/actions/audit_log.ts | 30 +++++ .../routes/actions/audit_log_handler.ts | 59 +++++++++ .../server/endpoint/routes/actions/index.ts | 1 + .../security_solution/server/plugin.ts | 6 +- 30 files changed, 868 insertions(+), 149 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/builders.ts create mode 100644 x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/endpoint_details_tabs.tsx create mode 100644 x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/log_entry.tsx create mode 100644 x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_activity_log.tsx create mode 100644 x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoints.stories.tsx create mode 100644 x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/translations.ts delete mode 100644 x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/utils.test.ts delete mode 100644 x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/utils.ts create mode 100644 x-pack/plugins/security_solution/server/endpoint/routes/actions/audit_log.ts create mode 100644 x-pack/plugins/security_solution/server/endpoint/routes/actions/audit_log_handler.ts diff --git a/x-pack/plugins/security_solution/common/endpoint/constants.ts b/x-pack/plugins/security_solution/common/endpoint/constants.ts index 1c0b09a4648e5..c85778f2f38fa 100644 --- a/x-pack/plugins/security_solution/common/endpoint/constants.ts +++ b/x-pack/plugins/security_solution/common/endpoint/constants.ts @@ -32,3 +32,6 @@ export const AGENT_POLICY_SUMMARY_ROUTE = `${BASE_POLICY_ROUTE}/summaries`; /** Host Isolation Routes */ export const ISOLATE_HOST_ROUTE = `/api/endpoint/isolate`; export const UNISOLATE_HOST_ROUTE = `/api/endpoint/unisolate`; + +/** Endpoint Actions Log Routes */ +export const ENDPOINT_ACTION_LOG_ROUTE = `/api/endpoint/action_log/{agent_id}`; diff --git a/x-pack/plugins/security_solution/common/endpoint/schema/actions.ts b/x-pack/plugins/security_solution/common/endpoint/schema/actions.ts index e8997158cdfad..32affddf46294 100644 --- a/x-pack/plugins/security_solution/common/endpoint/schema/actions.ts +++ b/x-pack/plugins/security_solution/common/endpoint/schema/actions.ts @@ -20,3 +20,11 @@ export const HostIsolationRequestSchema = { comment: schema.maybe(schema.string()), }), }; + +export const EndpointActionLogRequestSchema = { + // TODO improve when using pagination with query params + query: schema.object({}), + params: schema.object({ + agent_id: schema.string(), + }), +}; diff --git a/x-pack/plugins/security_solution/public/common/components/endpoint/formatted_date_time.tsx b/x-pack/plugins/security_solution/public/common/components/endpoint/formatted_date_time.tsx index 2fdb7e99d860e..740437646f61a 100644 --- a/x-pack/plugins/security_solution/public/common/components/endpoint/formatted_date_time.tsx +++ b/x-pack/plugins/security_solution/public/common/components/endpoint/formatted_date_time.tsx @@ -8,10 +8,14 @@ import React from 'react'; import { FormattedDate, FormattedTime, FormattedRelative } from '@kbn/i18n/react'; -export const FormattedDateAndTime: React.FC<{ date: Date }> = ({ date }) => { +export const FormattedDateAndTime: React.FC<{ date: Date; showRelativeTime?: boolean }> = ({ + date, + showRelativeTime = false, +}) => { // If date is greater than or equal to 1h (ago), then show it as a date + // and if showRelativeTime is false // else, show it as relative to "now" - return Date.now() - date.getTime() >= 3.6e6 ? ( + return Date.now() - date.getTime() >= 3.6e6 && !showRelativeTime ? ( <> {' @'} diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.ts index 65185b4d05135..a7bd42c6af5ee 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.ts @@ -25,7 +25,7 @@ import { UpdateAlertStatusProps, CasesFromAlertsResponse, } from './types'; -import { resolvePathVariables } from '../../../../management/pages/trusted_apps/service/utils'; +import { resolvePathVariables } from '../../../../management/common/utils'; import { isolateHost, unIsolateHost } from '../../../../common/lib/host_isolation'; /** diff --git a/x-pack/plugins/security_solution/public/management/common/utils.test.ts b/x-pack/plugins/security_solution/public/management/common/utils.test.ts index 59455ccd6bb04..8918261b6a436 100644 --- a/x-pack/plugins/security_solution/public/management/common/utils.test.ts +++ b/x-pack/plugins/security_solution/public/management/common/utils.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { parseQueryFilterToKQL } from './utils'; +import { parseQueryFilterToKQL, resolvePathVariables } from './utils'; describe('utils', () => { const searchableFields = [`name`, `description`, `entries.value`, `entries.entries.value`]; @@ -39,4 +39,39 @@ describe('utils', () => { ); }); }); + + describe('resolvePathVariables', () => { + it('should resolve defined variables', () => { + expect(resolvePathVariables('/segment1/{var1}/segment2', { var1: 'value1' })).toBe( + '/segment1/value1/segment2' + ); + }); + + it('should not resolve undefined variables', () => { + expect(resolvePathVariables('/segment1/{var1}/segment2', {})).toBe( + '/segment1/{var1}/segment2' + ); + }); + + it('should ignore unused variables', () => { + expect(resolvePathVariables('/segment1/{var1}/segment2', { var2: 'value2' })).toBe( + '/segment1/{var1}/segment2' + ); + }); + + it('should replace multiple variable occurences', () => { + expect(resolvePathVariables('/{var1}/segment1/{var1}', { var1: 'value1' })).toBe( + '/value1/segment1/value1' + ); + }); + + it('should replace multiple variables', () => { + const path = resolvePathVariables('/{var1}/segment1/{var2}', { + var1: 'value1', + var2: 'value2', + }); + + expect(path).toBe('/value1/segment1/value2'); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/management/common/utils.ts b/x-pack/plugins/security_solution/public/management/common/utils.ts index c8cf761ccaf86..78a95eb4d6f81 100644 --- a/x-pack/plugins/security_solution/public/management/common/utils.ts +++ b/x-pack/plugins/security_solution/public/management/common/utils.ts @@ -19,3 +19,8 @@ export const parseQueryFilterToKQL = (filter: string, fields: Readonly return kuery; }; + +export const resolvePathVariables = (path: string, variables: { [K: string]: string | number }) => + Object.keys(variables).reduce((acc, paramName) => { + return acc.replace(new RegExp(`\{${paramName}\}`, 'g'), String(variables[paramName])); + }, path); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/action.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/action.ts index d80a7d03903ac..25f2631ef46ff 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/action.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/action.ts @@ -37,7 +37,6 @@ export interface ServerFailedToReturnEndpointDetails { type: 'serverFailedToReturnEndpointDetails'; payload: ServerApiError; } - export interface ServerReturnedEndpointPolicyResponse { type: 'serverReturnedEndpointPolicyResponse'; payload: GetHostPolicyResponse; @@ -137,19 +136,24 @@ export interface ServerFailedToReturnEndpointsTotal { payload: ServerApiError; } -type EndpointIsolationRequest = Action<'endpointIsolationRequest'> & { +export type EndpointIsolationRequest = Action<'endpointIsolationRequest'> & { payload: HostIsolationRequestBody; }; -type EndpointIsolationRequestStateChange = Action<'endpointIsolationRequestStateChange'> & { +export type EndpointIsolationRequestStateChange = Action<'endpointIsolationRequestStateChange'> & { payload: EndpointState['isolationRequestState']; }; +export type EndpointDetailsActivityLogChanged = Action<'endpointDetailsActivityLogChanged'> & { + payload: EndpointState['endpointDetails']['activityLog']; +}; + export type EndpointAction = | ServerReturnedEndpointList | ServerFailedToReturnEndpointList | ServerReturnedEndpointDetails | ServerFailedToReturnEndpointDetails + | EndpointDetailsActivityLogChanged | ServerReturnedEndpointPolicyResponse | ServerFailedToReturnEndpointPolicyResponse | ServerReturnedPoliciesForOnboarding diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/builders.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/builders.ts new file mode 100644 index 0000000000000..d5416d9f8ec96 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/builders.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Immutable } from '../../../../../common/endpoint/types'; +import { DEFAULT_POLL_INTERVAL } from '../../../common/constants'; +import { createUninitialisedResourceState } from '../../../state'; +import { EndpointState } from '../types'; + +export const initialEndpointPageState = (): Immutable => { + return { + hosts: [], + pageSize: 10, + pageIndex: 0, + total: 0, + loading: false, + error: undefined, + endpointDetails: { + activityLog: createUninitialisedResourceState(), + hostDetails: { + details: undefined, + detailsLoading: false, + detailsError: undefined, + }, + }, + policyResponse: undefined, + policyResponseLoading: false, + policyResponseError: undefined, + location: undefined, + policyItems: [], + selectedPolicyId: undefined, + policyItemsLoading: false, + endpointPackageInfo: undefined, + nonExistingPolicies: {}, + agentPolicies: {}, + endpointsExist: true, + patterns: [], + patternsError: undefined, + isAutoRefreshEnabled: true, + autoRefreshInterval: DEFAULT_POLL_INTERVAL, + agentsWithEndpointsTotal: 0, + agentsWithEndpointsTotalError: undefined, + endpointsTotal: 0, + endpointsTotalError: undefined, + queryStrategyVersion: undefined, + policyVersionInfo: undefined, + hostStatus: undefined, + isolationRequestState: createUninitialisedResourceState(), + }; +}; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/index.test.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/index.test.ts index 79f0c5af9bbe3..5be67a3581c9e 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/index.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/index.test.ts @@ -41,9 +41,16 @@ describe('EndpointList store concerns', () => { total: 0, loading: false, error: undefined, - details: undefined, - detailsLoading: false, - detailsError: undefined, + endpointDetails: { + activityLog: { + type: 'UninitialisedResourceState', + }, + hostDetails: { + details: undefined, + detailsLoading: false, + detailsError: undefined, + }, + }, policyResponse: undefined, policyResponseLoading: false, policyResponseError: undefined, diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.test.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.test.ts index c52d922001887..04a04bc38996b 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.test.ts @@ -18,6 +18,7 @@ import { Immutable, HostResultList, HostIsolationResponse, + EndpointAction, } from '../../../../../common/endpoint/types'; import { AppAction } from '../../../../common/store/actions'; import { mockEndpointResultList } from './mock_endpoint_result_list'; @@ -25,8 +26,9 @@ import { listData } from './selectors'; import { EndpointState } from '../types'; import { endpointListReducer } from './reducer'; import { endpointMiddlewareFactory } from './middleware'; -import { getEndpointListPath } from '../../../common/routing'; +import { getEndpointListPath, getEndpointDetailsPath } from '../../../common/routing'; import { + createLoadedResourceState, FailedResourceState, isFailedResourceState, isLoadedResourceState, @@ -39,6 +41,7 @@ import { hostIsolationRequestBodyMock, hostIsolationResponseMock, } from '../../../../common/lib/host_isolation/mocks'; +import { FleetActionGenerator } from '../../../../../common/endpoint/data_generators/fleet_action_generator'; jest.mock('../../policy/store/services/ingest', () => ({ sendGetAgentConfigList: () => Promise.resolve({ items: [] }), @@ -192,4 +195,65 @@ describe('endpoint list middleware', () => { expect(failedAction.error).toBe(apiError); }); }); + + describe('handle ActivityLog State Change actions', () => { + const endpointList = getEndpointListApiResponse(); + const search = getEndpointDetailsPath({ + name: 'endpointDetails', + selected_endpoint: endpointList.hosts[0].metadata.agent.id, + }); + const dispatchUserChangedUrl = () => { + dispatch({ + type: 'userChangedUrl', + payload: { + ...history.location, + pathname: '/endpoints', + search: `?${search.split('?').pop()}`, + }, + }); + }; + const fleetActionGenerator = new FleetActionGenerator(Math.random().toString()); + const activityLog = [ + fleetActionGenerator.generate({ + agents: [endpointList.hosts[0].metadata.agent.id], + }), + ]; + const dispatchGetActivityLog = () => { + dispatch({ + type: 'endpointDetailsActivityLogChanged', + payload: createLoadedResourceState(activityLog), + }); + }; + + it('should set ActivityLog state to loading', async () => { + dispatchUserChangedUrl(); + + const loadingDispatched = waitForAction('endpointDetailsActivityLogChanged', { + validate(action) { + return isLoadingResourceState(action.payload); + }, + }); + + const loadingDispatchedResponse = await loadingDispatched; + expect(loadingDispatchedResponse.payload.type).toEqual('LoadingResourceState'); + }); + + it('should set ActivityLog state to loaded when fetching activity log is successful', async () => { + dispatchUserChangedUrl(); + + const loadedDispatched = waitForAction('endpointDetailsActivityLogChanged', { + validate(action) { + return isLoadedResourceState(action.payload); + }, + }); + + dispatchGetActivityLog(); + const loadedDispatchedResponse = await loadedDispatched; + const activityLogData = (loadedDispatchedResponse.payload as LoadedResourceState< + EndpointAction[] + >).data; + + expect(activityLogData).toEqual(activityLog); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts index 9db9932dd4387..90427d5003384 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts @@ -7,6 +7,7 @@ import { HttpStart } from 'kibana/public'; import { + EndpointAction, HostInfo, HostIsolationRequestBody, HostIsolationResponse, @@ -18,6 +19,7 @@ import { ImmutableMiddlewareAPI, ImmutableMiddlewareFactory } from '../../../../ import { isOnEndpointPage, hasSelectedEndpoint, + selectedAgent, uiQueryParams, listData, endpointPackageInfo, @@ -27,6 +29,7 @@ import { isTransformEnabled, getIsIsolationRequestPending, getCurrentIsolationRequestState, + getActivityLogData, } from './selectors'; import { EndpointState, PolicyIds } from '../types'; import { @@ -37,12 +40,13 @@ import { } from '../../policy/store/services/ingest'; import { AGENT_POLICY_SAVED_OBJECT_TYPE } from '../../../../../../fleet/common'; import { + ENDPOINT_ACTION_LOG_ROUTE, HOST_METADATA_GET_API, HOST_METADATA_LIST_API, metadataCurrentIndexPattern, } from '../../../../../common/endpoint/constants'; import { IIndexPattern, Query } from '../../../../../../../../src/plugins/data/public'; -import { resolvePathVariables } from '../../trusted_apps/service/utils'; +import { resolvePathVariables } from '../../../common/utils'; import { createFailedResourceState, createLoadedResourceState, @@ -336,6 +340,29 @@ export const endpointMiddlewareFactory: ImmutableMiddlewareFactory(getActivityLogData(getState())), + }); + + try { + const activityLog = await coreStart.http.get( + resolvePathVariables(ENDPOINT_ACTION_LOG_ROUTE, { agent_id: selectedAgent(getState()) }) + ); + dispatch({ + type: 'endpointDetailsActivityLogChanged', + payload: createLoadedResourceState(activityLog), + }); + } catch (error) { + dispatch({ + type: 'endpointDetailsActivityLogChanged', + payload: createFailedResourceState(error.body ?? error), + }); + } + // call the policy response api try { const policyResponse = await coreStart.http.get(`/api/endpoint/policy_response`, { diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/reducer.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/reducer.ts index b2b46e6de9842..19235b792b270 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/reducer.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/reducer.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { EndpointDetailsActivityLogChanged } from './action'; import { isOnEndpointPage, hasSelectedEndpoint, @@ -12,52 +13,33 @@ import { getCurrentIsolationRequestState, } from './selectors'; import { EndpointState } from '../types'; +import { initialEndpointPageState } from './builders'; import { AppAction } from '../../../../common/store/actions'; import { ImmutableReducer } from '../../../../common/store'; import { Immutable } from '../../../../../common/endpoint/types'; -import { DEFAULT_POLL_INTERVAL } from '../../../common/constants'; import { createUninitialisedResourceState, isUninitialisedResourceState } from '../../../state'; -export const initialEndpointListState: Immutable = { - hosts: [], - pageSize: 10, - pageIndex: 0, - total: 0, - loading: false, - error: undefined, - details: undefined, - detailsLoading: false, - detailsError: undefined, - policyResponse: undefined, - policyResponseLoading: false, - policyResponseError: undefined, - location: undefined, - policyItems: [], - selectedPolicyId: undefined, - policyItemsLoading: false, - endpointPackageInfo: undefined, - nonExistingPolicies: {}, - agentPolicies: {}, - endpointsExist: true, - patterns: [], - patternsError: undefined, - isAutoRefreshEnabled: true, - autoRefreshInterval: DEFAULT_POLL_INTERVAL, - agentsWithEndpointsTotal: 0, - agentsWithEndpointsTotalError: undefined, - endpointsTotal: 0, - endpointsTotalError: undefined, - queryStrategyVersion: undefined, - policyVersionInfo: undefined, - hostStatus: undefined, - isolationRequestState: createUninitialisedResourceState(), -}; +type StateReducer = ImmutableReducer; +type CaseReducer = ( + state: Immutable, + action: Immutable +) => Immutable; -/* eslint-disable-next-line complexity */ -export const endpointListReducer: ImmutableReducer = ( - state = initialEndpointListState, +const handleEndpointDetailsActivityLogChanged: CaseReducer = ( + state, action ) => { + return { + ...state!, + endpointDetails: { + ...state.endpointDetails!, + activityLog: action.payload, + }, + }; +}; + +/* eslint-disable-next-line complexity */ +export const endpointListReducer: StateReducer = (state = initialEndpointPageState(), action) => { if (action.type === 'serverReturnedEndpointList') { const { hosts, @@ -115,18 +97,32 @@ export const endpointListReducer: ImmutableReducer = ( } else if (action.type === 'serverReturnedEndpointDetails') { return { ...state, - details: action.payload.metadata, + endpointDetails: { + ...state.endpointDetails, + hostDetails: { + ...state.endpointDetails.hostDetails, + details: action.payload.metadata, + detailsLoading: false, + detailsError: undefined, + }, + }, policyVersionInfo: action.payload.policy_info, hostStatus: action.payload.host_status, - detailsLoading: false, - detailsError: undefined, }; } else if (action.type === 'serverFailedToReturnEndpointDetails') { return { ...state, - detailsError: action.payload, - detailsLoading: false, + endpointDetails: { + ...state.endpointDetails, + hostDetails: { + ...state.endpointDetails.hostDetails, + detailsError: action.payload, + detailsLoading: false, + }, + }, }; + } else if (action.type === 'endpointDetailsActivityLogChanged') { + return handleEndpointDetailsActivityLogChanged(state, action); } else if (action.type === 'serverReturnedPoliciesForOnboarding') { return { ...state, @@ -221,7 +217,6 @@ export const endpointListReducer: ImmutableReducer = ( const stateUpdates: Partial = { location: action.payload, error: undefined, - detailsError: undefined, policyResponseError: undefined, }; @@ -239,6 +234,13 @@ export const endpointListReducer: ImmutableReducer = ( return { ...state, ...stateUpdates, + endpointDetails: { + ...state.endpointDetails, + hostDetails: { + ...state.endpointDetails.hostDetails, + detailsError: undefined, + }, + }, loading: true, policyItemsLoading: true, }; @@ -249,6 +251,14 @@ export const endpointListReducer: ImmutableReducer = ( return { ...state, ...stateUpdates, + endpointDetails: { + ...state.endpointDetails, + hostDetails: { + ...state.endpointDetails.hostDetails, + detailsLoading: true, + detailsError: undefined, + }, + }, detailsLoading: true, policyResponseLoading: true, }; @@ -257,8 +267,15 @@ export const endpointListReducer: ImmutableReducer = ( return { ...state, ...stateUpdates, + endpointDetails: { + ...state.endpointDetails, + hostDetails: { + ...state.endpointDetails.hostDetails, + detailsLoading: true, + detailsError: undefined, + }, + }, loading: true, - detailsLoading: true, policyResponseLoading: true, policyItemsLoading: true, }; @@ -268,6 +285,13 @@ export const endpointListReducer: ImmutableReducer = ( return { ...state, ...stateUpdates, + endpointDetails: { + ...state.endpointDetails, + hostDetails: { + ...state.endpointDetails.hostDetails, + detailsError: undefined, + }, + }, endpointsExist: true, }; } diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts index af95d89fdc10b..8b6599611ffc4 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts @@ -45,11 +45,16 @@ export const listLoading = (state: Immutable): boolean => state.l export const listError = (state: Immutable) => state.error; -export const detailsData = (state: Immutable) => state.details; +export const detailsData = (state: Immutable) => + state.endpointDetails.hostDetails.details; -export const detailsLoading = (state: Immutable): boolean => state.detailsLoading; +export const detailsLoading = (state: Immutable): boolean => + state.endpointDetails.hostDetails.detailsLoading; -export const detailsError = (state: Immutable) => state.detailsError; +export const detailsError = ( + state: Immutable +): EndpointState['endpointDetails']['hostDetails']['detailsError'] => + state.endpointDetails.hostDetails.detailsError; export const policyItems = (state: Immutable) => state.policyItems; @@ -209,7 +214,12 @@ export const uiQueryParams: ( if (value !== undefined) { if (key === 'show') { - if (value === 'policy_response' || value === 'details' || value === 'isolate') { + if ( + value === 'policy_response' || + value === 'details' || + value === 'activity_log' || + value === 'isolate' + ) { data[key] = value; } } else { @@ -240,6 +250,19 @@ export const showView: ( return searchParams.show ?? 'details'; }); +/** + * Returns the selected endpoint's elastic agent Id + * used for fetching endpoint actions log + */ +export const selectedAgent = (state: Immutable): string => { + const hostList = state.hosts; + const { selected_endpoint: selectedEndpoint } = uiQueryParams(state); + return ( + hostList.find((host) => host.metadata.agent.id === selectedEndpoint)?.metadata.elastic.agent + .id || '' + ); +}; + /** * Returns the Host Status which is connected the fleet agent */ @@ -331,3 +354,27 @@ export const getIsolationRequestError: ( return isolateHost.error; } }); + +export const getActivityLogData = ( + state: Immutable +): Immutable => state.endpointDetails.activityLog; + +export const getActivityLogRequestLoading: ( + state: Immutable +) => boolean = createSelector(getActivityLogData, (activityLog) => + isLoadingResourceState(activityLog) +); + +export const getActivityLogRequestLoaded: ( + state: Immutable +) => boolean = createSelector(getActivityLogData, (activityLog) => + isLoadedResourceState(activityLog) +); + +export const getActivityLogError: ( + state: Immutable +) => ServerApiError | undefined = createSelector(getActivityLogData, (activityLog) => { + if (isFailedResourceState(activityLog)) { + return activityLog.error; + } +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts index 74eee0602722b..ac06f98004f59 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts @@ -14,6 +14,7 @@ import { PolicyData, MetadataQueryStrategyVersions, HostStatus, + EndpointAction, HostIsolationResponse, } from '../../../../common/endpoint/types'; import { ServerApiError } from '../../../common/types'; @@ -34,12 +35,17 @@ export interface EndpointState { loading: boolean; /** api error from retrieving host list */ error?: ServerApiError; - /** details data for a specific host */ - details?: Immutable; - /** details page is retrieving data */ - detailsLoading: boolean; - /** api error from retrieving host details */ - detailsError?: ServerApiError; + endpointDetails: { + activityLog: AsyncResourceState; + hostDetails: { + /** details data for a specific host */ + details?: Immutable; + /** details page is retrieving data */ + detailsLoading: boolean; + /** api error from retrieving host details */ + detailsError?: ServerApiError; + }; + }; /** Holds the Policy Response for the Host currently being displayed in the details */ policyResponse?: HostPolicyResponse; /** policyResponse is being retrieved */ @@ -108,7 +114,7 @@ export interface EndpointIndexUIQueryParams { /** Which page to show */ page_index?: string; /** show the policy response or host details */ - show?: 'policy_response' | 'details' | 'isolate'; + show?: 'policy_response' | 'activity_log' | 'details' | 'isolate'; /** Query text from search bar*/ admin_query?: string; } diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/endpoint_details_tabs.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/endpoint_details_tabs.tsx new file mode 100644 index 0000000000000..3e228be4565b1 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/endpoint_details_tabs.tsx @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useCallback, useMemo, useState } from 'react'; +import styled from 'styled-components'; +import { EuiTabbedContent, EuiTabbedContentTab } from '@elastic/eui'; +import { EndpointIndexUIQueryParams } from '../../../types'; +export enum EndpointDetailsTabsTypes { + overview = 'overview', + activityLog = 'activity_log', +} + +export type EndpointDetailsTabsId = + | EndpointDetailsTabsTypes.overview + | EndpointDetailsTabsTypes.activityLog; + +interface EndpointDetailsTabs { + id: string; + name: string; + content: JSX.Element; +} + +const StyledEuiTabbedContent = styled(EuiTabbedContent)` + overflow: hidden; + padding-bottom: ${(props) => props.theme.eui.paddingSizes.xl}; + + > [role='tabpanel'] { + height: 100%; + padding-right: 12px; + overflow: hidden; + overflow-y: auto; + ::-webkit-scrollbar { + -webkit-appearance: none; + width: 4px; + } + ::-webkit-scrollbar-thumb { + border-radius: 2px; + background-color: rgba(0, 0, 0, 0.5); + -webkit-box-shadow: 0 0 1px rgba(255, 255, 255, 0.5); + } + } +`; + +export const EndpointDetailsFlyoutTabs = memo( + ({ show, tabs }: { show: EndpointIndexUIQueryParams['show']; tabs: EndpointDetailsTabs[] }) => { + const [selectedTabId, setSelectedTabId] = useState(() => { + return show === 'details' + ? EndpointDetailsTabsTypes.overview + : EndpointDetailsTabsTypes.activityLog; + }); + + const handleTabClick = useCallback( + (tab: EuiTabbedContentTab) => setSelectedTabId(tab.id as EndpointDetailsTabsId), + [setSelectedTabId] + ); + + const selectedTab = useMemo(() => tabs.find((tab) => tab.id === selectedTabId), [ + tabs, + selectedTabId, + ]); + + return ( + + ); + } +); + +EndpointDetailsFlyoutTabs.displayName = 'EndpointDetailsFlyoutTabs'; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/log_entry.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/log_entry.tsx new file mode 100644 index 0000000000000..de6d2ecf36ecc --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/log_entry.tsx @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo } from 'react'; + +import { EuiAvatar, EuiComment, EuiText } from '@elastic/eui'; +import { Immutable, EndpointAction } from '../../../../../../../common/endpoint/types'; +import { FormattedDateAndTime } from '../../../../../../common/components/endpoint/formatted_date_time'; +import { useEuiTheme } from '../../../../../../common/lib/theme/use_eui_theme'; + +export const LogEntry = memo( + ({ endpointAction }: { endpointAction: Immutable }) => { + const euiTheme = useEuiTheme(); + const isIsolated = endpointAction?.data.command === 'isolate'; + + // do this better when we can distinguish between endpoint events vs user events + const iconType = endpointAction.user_id === 'sys' ? 'dot' : isIsolated ? 'lock' : 'lockOpen'; + const commentType = endpointAction.user_id === 'sys' ? 'update' : 'regular'; + const timelineIcon = ( + + ); + const event = `${isIsolated ? 'isolated' : 'unisolated'} host`; + const hasComment = !!endpointAction.data.comment; + + return ( + + {hasComment ? ( + +

{endpointAction.data.comment}

+
+ ) : undefined} +
+ ); + } +); + +LogEntry.displayName = 'LogEntry'; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_activity_log.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_activity_log.tsx new file mode 100644 index 0000000000000..50c91730e332c --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_activity_log.tsx @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useCallback } from 'react'; + +import { EuiEmptyPrompt, EuiSpacer } from '@elastic/eui'; +import { LogEntry } from './components/log_entry'; +import * as i18 from '../translations'; +import { SearchBar } from '../../../../components/search_bar'; +import { Immutable, EndpointAction } from '../../../../../../common/endpoint/types'; +import { AsyncResourceState } from '../../../../state'; + +export const EndpointActivityLog = memo( + ({ endpointActions }: { endpointActions: AsyncResourceState> }) => { + // TODO + const onSearch = useCallback(() => {}, []); + return ( + <> + + {endpointActions.type !== 'LoadedResourceState' || !endpointActions.data.length ? ( + {'No logged actions'}

} + body={

{'No actions have been logged for this endpoint.'}

} + /> + ) : ( + <> + + + {endpointActions.data.map((endpointAction) => ( + + ))} + + )} + + ); + } +); + +EndpointActivityLog.displayName = 'EndpointActivityLog'; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_details.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_details.tsx index c9db78f425afa..16cae79d42c0f 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_details.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_details.tsx @@ -258,6 +258,7 @@ export const EndpointDetails = memo( return ( <> + > => ({ + type: 'LoadedResourceState', + data: [ + { + action_id: '1', + '@timestamp': moment().subtract(1, 'hours').fromNow().toString(), + expiration: moment().add(3, 'day').fromNow().toString(), + type: 'INPUT_ACTION', + input_type: 'endpoint', + agents: [`${selectedEndpoint}`], + user_id: 'sys', + data: { + command: 'isolate', + }, + }, + { + action_id: '2', + '@timestamp': moment().subtract(2, 'hours').fromNow().toString(), + expiration: moment().add(1, 'day').fromNow().toString(), + type: 'INPUT_ACTION', + input_type: 'endpoint', + agents: [`${selectedEndpoint}`], + user_id: 'ash', + data: { + command: 'isolate', + comment: 'Sem et tortor consequat id porta nibh venenatis cras sed.', + }, + }, + { + action_id: '3', + '@timestamp': moment().subtract(4, 'hours').fromNow().toString(), + expiration: moment().add(1, 'day').fromNow().toString(), + type: 'INPUT_ACTION', + input_type: 'endpoint', + agents: [`${selectedEndpoint}`], + user_id: 'someone', + data: { + command: 'unisolate', + comment: 'Turpis egestas pretium aenean pharetra.', + }, + }, + { + action_id: '4', + '@timestamp': moment().subtract(1, 'day').fromNow().toString(), + expiration: moment().add(3, 'day').fromNow().toString(), + type: 'INPUT_ACTION', + input_type: 'endpoint', + agents: [`${selectedEndpoint}`], + user_id: 'ash', + data: { + command: 'isolate', + comment: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, \ + sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', + }, + }, + ], +}); + +export default { + title: 'Endpoints/Endpoint Details', + component: EndpointDetailsFlyout, + decorators: [ + (Story: ComponentType) => ( + + + + ), + ], +}; + +export const Tabs = () => ( + {'Endpoint Details'}, + }, + { + id: 'activity_log', + name: 'Activity Log', + content: ActivityLog(), + }, + ]} + /> +); + +export const ActivityLog = () => ( + +); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/index.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/index.tsx index 09b1bbceef21d..8d985f3a4cfe2 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/index.tsx @@ -5,7 +5,8 @@ * 2.0. */ -import React, { useCallback, useEffect, memo } from 'react'; +import React, { useCallback, useEffect, useMemo, memo } from 'react'; +import styled from 'styled-components'; import { EuiFlyout, EuiFlyoutBody, @@ -16,6 +17,8 @@ import { EuiSpacer, EuiEmptyPrompt, EuiToolTip, + EuiFlexGroup, + EuiFlexItem, } from '@elastic/eui'; import { useHistory } from 'react-router-dom'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -26,8 +29,11 @@ import { uiQueryParams, detailsData, detailsError, - showView, detailsLoading, + getActivityLogData, + getActivityLogError, + getActivityLogRequestLoading, + showView, policyResponseConfigurations, policyResponseActions, policyResponseFailedOrWarningActionCount, @@ -39,14 +45,36 @@ import { policyResponseAppliedRevision, } from '../../store/selectors'; import { EndpointDetails } from './endpoint_details'; +import { EndpointActivityLog } from './endpoint_activity_log'; import { PolicyResponse } from './policy_response'; +import * as i18 from '../translations'; import { HostMetadata } from '../../../../../../common/endpoint/types'; +import { + EndpointDetailsFlyoutTabs, + EndpointDetailsTabsTypes, +} from './components/endpoint_details_tabs'; + import { PreferenceFormattedDateFromPrimitive } from '../../../../../common/components/formatted_date'; import { EndpointIsolateFlyoutPanel } from './components/endpoint_isolate_flyout_panel'; import { BackToEndpointDetailsFlyoutSubHeader } from './components/back_to_endpoint_details_flyout_subheader'; import { FlyoutBodyNoTopPadding } from './components/flyout_body_no_top_padding'; import { getEndpointListPath } from '../../../../common/routing'; +const DetailsFlyoutBody = styled(EuiFlyoutBody)` + overflow-y: hidden; + flex: 1; + + .euiFlyoutBody__overflow { + overflow: hidden; + mask-image: none; + } + + .euiFlyoutBody__overflowContent { + height: 100%; + display: flex; + } +`; + export const EndpointDetailsFlyout = memo(() => { const history = useHistory(); const toasts = useToasts(); @@ -55,13 +83,51 @@ export const EndpointDetailsFlyout = memo(() => { selected_endpoint: selectedEndpoint, ...queryParamsWithoutSelectedEndpoint } = queryParams; - const details = useEndpointSelector(detailsData); + + const activityLog = useEndpointSelector(getActivityLogData); + const activityLoading = useEndpointSelector(getActivityLogRequestLoading); + const activityError = useEndpointSelector(getActivityLogError); + const hostDetails = useEndpointSelector(detailsData); + const hostDetailsLoading = useEndpointSelector(detailsLoading); + const hostDetailsError = useEndpointSelector(detailsError); + const policyInfo = useEndpointSelector(policyVersionInfo); const hostStatus = useEndpointSelector(hostStatusInfo); - const loading = useEndpointSelector(detailsLoading); - const error = useEndpointSelector(detailsError); const show = useEndpointSelector(showView); + const ContentLoadingMarkup = useMemo( + () => ( + <> + + + + + ), + [] + ); + + const tabs = [ + { + id: EndpointDetailsTabsTypes.overview, + name: i18.OVERVIEW, + content: + hostDetails === undefined ? ( + ContentLoadingMarkup + ) : ( + + ), + }, + { + id: EndpointDetailsTabsTypes.activityLog, + name: i18.ACTIVITY_LOG, + content: activityLoading ? ( + ContentLoadingMarkup + ) : ( + + ), + }, + ]; + const handleFlyoutClose = useCallback(() => { const { show: _show, ...urlSearchParams } = queryParamsWithoutSelectedEndpoint; history.push( @@ -73,7 +139,7 @@ export const EndpointDetailsFlyout = memo(() => { }, [history, queryParamsWithoutSelectedEndpoint]); useEffect(() => { - if (error !== undefined) { + if (hostDetailsError !== undefined) { toasts.addDanger({ title: i18n.translate('xpack.securitySolution.endpoint.details.errorTitle', { defaultMessage: 'Could not find host', @@ -83,7 +149,17 @@ export const EndpointDetailsFlyout = memo(() => { }), }); } - }, [error, toasts]); + if (activityError !== undefined) { + toasts.addDanger({ + title: i18n.translate('xpack.securitySolution.endpoint.activityLog.errorTitle', { + defaultMessage: 'Could not find activity log for host', + }), + text: i18n.translate('xpack.securitySolution.endpoint.activityLog.errorBody', { + defaultMessage: 'Please exit the flyout and select another host with actions.', + }), + }); + } + }, [hostDetailsError, activityError, toasts]); return ( { style={{ zIndex: 4001 }} data-test-subj="endpointDetailsFlyout" size="m" + paddingSize="m" > - {loading ? ( + {hostDetailsLoading || activityLoading ? ( ) : ( - +

- {details?.host?.hostname} + {hostDetails?.host?.hostname}

)}
- {details === undefined ? ( + {hostDetails === undefined ? ( ) : ( <> - {show === 'details' && ( - - - + {(show === 'details' || show === 'activity_log') && ( + + + + + + + )} - {show === 'policy_response' && } + {show === 'policy_response' && } - {show === 'isolate' && } + {show === 'isolate' && } )}
diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/translations.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/translations.ts new file mode 100644 index 0000000000000..fd2806713183b --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/translations.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const OVERVIEW = i18n.translate('xpack.securitySolution.endpointDetails.overview', { + defaultMessage: 'Overview', +}); + +export const ACTIVITY_LOG = i18n.translate('xpack.securitySolution.endpointDetails.activityLog', { + defaultMessage: 'Activity Log', +}); + +export const SEARCH_ACTIVITY_LOG = i18n.translate( + 'xpack.securitySolution.endpointDetails.activityLog.search', + { + defaultMessage: 'Search activity log', + } +); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/index.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/index.ts index 5f572251daeda..01bccc81b5063 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/index.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/index.ts @@ -30,7 +30,7 @@ import { GetOneTrustedAppResponse, } from '../../../../../common/endpoint/types/trusted_apps'; -import { resolvePathVariables } from './utils'; +import { resolvePathVariables } from '../../../common/utils'; import { sendGetEndpointSpecificPackagePolicies } from '../../policy/store/services/ingest'; export interface TrustedAppsService { diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/utils.test.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/utils.test.ts deleted file mode 100644 index c2067f9d0848f..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/utils.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { resolvePathVariables } from './utils'; - -describe('utils', () => { - describe('resolvePathVariables', () => { - it('should resolve defined variables', () => { - expect(resolvePathVariables('/segment1/{var1}/segment2', { var1: 'value1' })).toBe( - '/segment1/value1/segment2' - ); - }); - - it('should not resolve undefined variables', () => { - expect(resolvePathVariables('/segment1/{var1}/segment2', {})).toBe( - '/segment1/{var1}/segment2' - ); - }); - - it('should ignore unused variables', () => { - expect(resolvePathVariables('/segment1/{var1}/segment2', { var2: 'value2' })).toBe( - '/segment1/{var1}/segment2' - ); - }); - - it('should replace multiple variable occurences', () => { - expect(resolvePathVariables('/{var1}/segment1/{var1}', { var1: 'value1' })).toBe( - '/value1/segment1/value1' - ); - }); - - it('should replace multiple variables', () => { - const path = resolvePathVariables('/{var1}/segment1/{var2}', { - var1: 'value1', - var2: 'value2', - }); - - expect(path).toBe('/value1/segment1/value2'); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/utils.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/utils.ts deleted file mode 100644 index 89067e575665d..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/utils.ts +++ /dev/null @@ -1,11 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export const resolvePathVariables = (path: string, variables: { [K: string]: string | number }) => - Object.keys(variables).reduce((acc, paramName) => { - return acc.replace(new RegExp(`\{${paramName}\}`, 'g'), String(variables[paramName])); - }, path); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx index 3f02d505daea1..dc0032243312f 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx @@ -31,7 +31,7 @@ import { import { EndpointDocGenerator } from '../../../../../common/endpoint/generate_data'; import { isFailedResourceState, isLoadedResourceState } from '../state'; import { forceHTMLElementOffsetWidth } from './components/effected_policy_select/test_utils'; -import { resolvePathVariables } from '../service/utils'; +import { resolvePathVariables } from '../../../common/utils'; import { toUpdateTrustedApp } from '../../../../../common/endpoint/service/trusted_apps/to_update_trusted_app'; import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; diff --git a/x-pack/plugins/security_solution/public/management/store/reducer.ts b/x-pack/plugins/security_solution/public/management/store/reducer.ts index bf8cd416a3e39..25c7c87c6f5c9 100644 --- a/x-pack/plugins/security_solution/public/management/store/reducer.ts +++ b/x-pack/plugins/security_solution/public/management/store/reducer.ts @@ -19,14 +19,12 @@ import { import { ImmutableCombineReducers } from '../../common/store'; import { Immutable } from '../../../common/endpoint/types'; import { ManagementState } from '../types'; -import { - endpointListReducer, - initialEndpointListState, -} from '../pages/endpoint_hosts/store/reducer'; +import { endpointListReducer } from '../pages/endpoint_hosts/store/reducer'; import { initialTrustedAppsPageState } from '../pages/trusted_apps/store/builders'; import { trustedAppsPageReducer } from '../pages/trusted_apps/store/reducer'; import { initialEventFiltersPageState } from '../pages/event_filters/store/builders'; import { eventFiltersPageReducer } from '../pages/event_filters/store/reducer'; +import { initialEndpointPageState } from '../pages/endpoint_hosts/store/builders'; const immutableCombineReducers: ImmutableCombineReducers = combineReducers; @@ -35,7 +33,7 @@ const immutableCombineReducers: ImmutableCombineReducers = combineReducers; */ export const mockManagementState: Immutable = { [MANAGEMENT_STORE_POLICY_DETAILS_NAMESPACE]: initialPolicyDetailsState(), - [MANAGEMENT_STORE_ENDPOINTS_NAMESPACE]: initialEndpointListState, + [MANAGEMENT_STORE_ENDPOINTS_NAMESPACE]: initialEndpointPageState(), [MANAGEMENT_STORE_TRUSTED_APPS_NAMESPACE]: initialTrustedAppsPageState(), [MANAGEMENT_STORE_EVENT_FILTERS_NAMESPACE]: initialEventFiltersPageState(), }; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/audit_log.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/audit_log.ts new file mode 100644 index 0000000000000..487ee16558fec --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/audit_log.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ENDPOINT_ACTION_LOG_ROUTE } from '../../../../common/endpoint/constants'; +import { EndpointActionLogRequestSchema } from '../../../../common/endpoint/schema/actions'; +import { actionsLogRequestHandler } from './audit_log_handler'; + +import { SecuritySolutionPluginRouter } from '../../../types'; +import { EndpointAppContext } from '../../types'; + +/** + * Registers the endpoint activity_log route + */ +export function registerActionAuditLogRoutes( + router: SecuritySolutionPluginRouter, + endpointContext: EndpointAppContext +) { + router.get( + { + path: ENDPOINT_ACTION_LOG_ROUTE, + validate: EndpointActionLogRequestSchema, + options: { authRequired: true, tags: ['access:securitySolution'] }, + }, + actionsLogRequestHandler(endpointContext) + ); +} diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/audit_log_handler.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/audit_log_handler.ts new file mode 100644 index 0000000000000..fdbb9608463e9 --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/audit_log_handler.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { TypeOf } from '@kbn/config-schema'; +import { RequestHandler } from 'kibana/server'; +import { AGENT_ACTIONS_INDEX } from '../../../../../fleet/common'; +import { EndpointActionLogRequestSchema } from '../../../../common/endpoint/schema/actions'; + +import { SecuritySolutionRequestHandlerContext } from '../../../types'; +import { EndpointAppContext } from '../../types'; + +export const actionsLogRequestHandler = ( + endpointContext: EndpointAppContext +): RequestHandler< + TypeOf, + unknown, + unknown, + SecuritySolutionRequestHandlerContext +> => { + const logger = endpointContext.logFactory.get('audit_log'); + return async (context, req, res) => { + const esClient = context.core.elasticsearch.client.asCurrentUser; + let result; + try { + result = await esClient.search({ + index: AGENT_ACTIONS_INDEX, + body: { + query: { + match: { + agents: req.params.agent_id, + }, + }, + sort: [ + { + '@timestamp': { + order: 'desc', + }, + }, + ], + }, + }); + } catch (error) { + logger.error(error); + throw error; + } + if (result?.statusCode !== 200) { + logger.error(`Error fetching actions log for agent_id ${req.params.agent_id}`); + throw new Error(`Error fetching actions log for agent_id ${req.params.agent_id}`); + } + + return res.ok({ + body: result.body.hits.hits.map((e) => e._source), + }); + }; +}; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/index.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/index.ts index 9dec4fb2cbb79..e95a33253034d 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/index.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/index.ts @@ -6,3 +6,4 @@ */ export * from './isolation'; +export * from './audit_log'; diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index 2507475592e88..732ae48223421 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -75,7 +75,10 @@ import { registerEndpointRoutes } from './endpoint/routes/metadata'; import { registerLimitedConcurrencyRoutes } from './endpoint/routes/limited_concurrency'; import { registerResolverRoutes } from './endpoint/routes/resolver'; import { registerPolicyRoutes } from './endpoint/routes/policy'; -import { registerHostIsolationRoutes } from './endpoint/routes/actions'; +import { + registerHostIsolationRoutes, + registerActionAuditLogRoutes, +} from './endpoint/routes/actions'; import { EndpointArtifactClient, ManifestManager } from './endpoint/services'; import { EndpointAppContextService } from './endpoint/endpoint_app_context_services'; import { EndpointAppContext } from './endpoint/types'; @@ -291,6 +294,7 @@ export class Plugin implements IPlugin Date: Thu, 3 Jun 2021 10:03:08 +0200 Subject: [PATCH 14/90] Link and formatting fix (#101243) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: ✏️ make "Risk Matrix" link absolute The page can be rendered in different environments, so we should have this link absolute so that it always works, for example, it would not work in PRs themselves. * docs: ✏️ remove formatting breaks In PRs Markdown formatting breaks are rendered incorrectly, so we remove them. --- .github/PULL_REQUEST_TEMPLATE.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 336f7e5165d07..726e4257a5aac 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -21,18 +21,16 @@ Delete any items that are not applicable to this PR. Delete this section if it is not applicable to this PR. -Before closing this PR, invite QA, stakeholders, and other developers to -identify risks that should be tested prior to the change/feature release. +Before closing this PR, invite QA, stakeholders, and other developers to identify risks that should be tested prior to the change/feature release. -When forming the risk matrix, consider some of the following examples and how -they may potentially impact the change: +When forming the risk matrix, consider some of the following examples and how they may potentially impact the change: | Risk | Probability | Severity | Mitigation/Notes | |---------------------------|-------------|----------|-------------------------| | Multiple Spaces—unexpected behavior in non-default Kibana Space. | Low | High | Integration tests will verify that all features are still supported in non-default Kibana Space and when user switches between spaces. | | Multiple nodes—Elasticsearch polling might have race conditions when multiple Kibana nodes are polling for the same tasks. | High | Low | Tasks are idempotent, so executing them multiple times will not result in logical error, but will degrade performance. To test for this case we add plenty of unit tests around this logic and document manual testing procedure. | | Code should gracefully handle cases when feature X or plugin Y are disabled. | Medium | High | Unit tests will verify that any feature flag or plugin combination still results in our service operational. | -| [See more potential risk examples](../RISK_MATRIX.mdx) | +| [See more potential risk examples](https://github.com/elastic/kibana/blob/master/RISK_MATRIX.mdx) | ### For maintainers From 6f106e468747f03ba8f5b94e615dc65342251b49 Mon Sep 17 00:00:00 2001 From: Uladzislau Lasitsa Date: Thu, 3 Jun 2021 11:49:00 +0300 Subject: [PATCH 15/90] Gauge/goal: Tooltip always includes "_all" (#101064) * Don't show _all for goal and gauge in tooltip * add unit test --- .../tooltip/_pointseries_tooltip_formatter.js | 4 +++- .../_pointseries_tooltip_formatter.test.js | 23 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/plugins/vis_type_vislib/public/vislib/components/tooltip/_pointseries_tooltip_formatter.js b/src/plugins/vis_type_vislib/public/vislib/components/tooltip/_pointseries_tooltip_formatter.js index cb8a8f72c5172..5e1f0bfbb4464 100644 --- a/src/plugins/vis_type_vislib/public/vislib/components/tooltip/_pointseries_tooltip_formatter.js +++ b/src/plugins/vis_type_vislib/public/vislib/components/tooltip/_pointseries_tooltip_formatter.js @@ -31,6 +31,7 @@ export function pointSeriesTooltipFormatter() { const details = []; const isGauge = config.get('gauge', false); + const chartType = config.get('type', undefined); const isPercentageMode = config.get(isGauge ? 'gauge.percentageMode' : 'percentageMode', false); const isSetColorRange = config.get('setColorRange', false); @@ -44,7 +45,8 @@ export function pointSeriesTooltipFormatter() { }); } - if (datum.x !== null && datum.x !== undefined) { + // For goal and gauge we have only one value for x - '_all'. It doesn't have sense to show it + if (datum.x !== null && datum.x !== undefined && !['goal', 'gauge'].includes(chartType)) { addDetail(data.xAxisLabel, data.xAxisFormatter(datum.x)); } diff --git a/src/plugins/vis_type_vislib/public/vislib/components/tooltip/_pointseries_tooltip_formatter.test.js b/src/plugins/vis_type_vislib/public/vislib/components/tooltip/_pointseries_tooltip_formatter.test.js index 5c0548ea399b7..a207b1f4360b6 100644 --- a/src/plugins/vis_type_vislib/public/vislib/components/tooltip/_pointseries_tooltip_formatter.test.js +++ b/src/plugins/vis_type_vislib/public/vislib/components/tooltip/_pointseries_tooltip_formatter.test.js @@ -96,4 +96,27 @@ describe('tooltipFormatter', function () { const $rows = $el.find('tr'); expect($rows.length).toBe(3); }); + + it('renders correctly for gauge/goal visualizations', function () { + const event = _.cloneDeep(baseEvent); + let type = 'gauge'; + event.config.get = (name) => { + const config = { + setColorRange: false, + gauge: false, + percentageMode: false, + type, + }; + return config[name]; + }; + + let $el = $(tooltipFormatter(event, uiSettings)); + let $rows = $el.find('tr'); + expect($rows.length).toBe(2); + + type = 'goal'; + $el = $(tooltipFormatter(event, uiSettings)); + $rows = $el.find('tr'); + expect($rows.length).toBe(2); + }); }); From 8097f3646586dbd49028b75f0ffa31ffe557726a Mon Sep 17 00:00:00 2001 From: Liza Katz Date: Thu, 3 Jun 2021 12:28:09 +0300 Subject: [PATCH 16/90] attempt at tree shaking (#101147) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- packages/kbn-ui-shared-deps/entry.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/kbn-ui-shared-deps/entry.js b/packages/kbn-ui-shared-deps/entry.js index d3755ed7c5f29..b8d21a473c65f 100644 --- a/packages/kbn-ui-shared-deps/entry.js +++ b/packages/kbn-ui-shared-deps/entry.js @@ -44,7 +44,8 @@ export const Theme = require('./theme.ts'); export const Lodash = require('lodash'); export const LodashFp = require('lodash/fp'); -export const Fflate = require('fflate/esm/browser'); +import { unzlibSync, strFromU8 } from 'fflate'; +export const Fflate = { unzlibSync, strFromU8 }; // runtime deps which don't need to be copied across all bundles export const TsLib = require('tslib'); From 83b47c1bc81e9dd5f49b2f093a902f49455a6134 Mon Sep 17 00:00:00 2001 From: Alexey Antonov Date: Thu, 3 Jun 2021 12:37:45 +0300 Subject: [PATCH 17/90] [TSVB] Math params._interval is incorrect when using entire timerange mode (#100775) * [TSVB] Math params._interval is incorrect when using entire timerange mode Closes: #100615 * fix jest * rename get -> overwrite * apply fix for "bucket script" * Update date_histogram.js Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../lib/vis_data/helpers/bucket_transform.js | 19 ++++++--- .../series/date_histogram.js | 35 +++++++++------- .../series/date_histogram.test.js | 42 +++++++++++++++---- .../series/metric_buckets.js | 21 ++-------- .../series/sibling_buckets.js | 23 +++------- .../table/date_histogram.js | 25 ++++++----- .../table/metric_buckets.js | 12 ++---- .../table/sibling_buckets.js | 14 +++---- .../response_processors/series/math.js | 6 ++- .../response_processors/series/math.test.js | 21 +++++++++- .../series/build_request_body.test.ts | 1 - 11 files changed, 124 insertions(+), 95 deletions(-) diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/bucket_transform.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/bucket_transform.js index 2877373ffba9a..16e7b9d6072cb 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/bucket_transform.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/bucket_transform.js @@ -7,11 +7,11 @@ */ import { getBucketsPath } from './get_buckets_path'; -import { parseInterval } from './parse_interval'; import { set } from '@elastic/safer-lodash-set'; import { isEmpty } from 'lodash'; import { i18n } from '@kbn/i18n'; import { MODEL_SCRIPTS } from './moving_fn_scripts'; +import { convertIntervalToUnit } from './unit_to_seconds'; function checkMetric(metric, fields) { fields.forEach((field) => { @@ -161,19 +161,24 @@ export const bucketTransform = { }; }, - derivative: (bucket, metrics, bucketSize) => { + derivative: (bucket, metrics, intervalString) => { checkMetric(bucket, ['type', 'field']); + const body = { derivative: { buckets_path: getBucketsPath(bucket.field, metrics), gap_policy: 'skip', // seems sane - unit: bucketSize, + unit: intervalString, }, }; + if (bucket.gap_policy) body.derivative.gap_policy = bucket.gap_policy; if (bucket.unit) { - body.derivative.unit = /^([\d]+)([shmdwMy]|ms)$/.test(bucket.unit) ? bucket.unit : bucketSize; + body.derivative.unit = /^([\d]+)([shmdwMy]|ms)$/.test(bucket.unit) + ? bucket.unit + : intervalString; } + return body; }, @@ -214,8 +219,10 @@ export const bucketTransform = { }; }, - calculation: (bucket, metrics, bucketSize) => { + calculation: (bucket, metrics, intervalString) => { checkMetric(bucket, ['variables', 'script']); + const inMsInterval = convertIntervalToUnit(intervalString, 'ms'); + const body = { bucket_script: { buckets_path: bucket.variables.reduce((acc, row) => { @@ -226,7 +233,7 @@ export const bucketTransform = { source: bucket.script, lang: 'painless', params: { - _interval: parseInterval(bucketSize).asMilliseconds(), + _interval: inMsInterval?.value, }, }, gap_policy: 'skip', // seems sane diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.js index f82f332df19fd..253612c0274ad 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.js @@ -29,17 +29,20 @@ export function dateHistogram( const barTargetUiSettings = await uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET); const { timeField, interval, maxBars } = getIntervalAndTimefield(panel, series, seriesIndex); - const { bucketSize, intervalString } = getBucketSize( - req, - interval, - capabilities, - maxBars ? Math.min(maxBarsUiSettings, maxBars) : barTargetUiSettings - ); + const { from, to } = offsetTime(req, series.offset_time); - const getDateHistogramForLastBucketMode = () => { - const { from, to } = offsetTime(req, series.offset_time); + let bucketInterval; + + const overwriteDateHistogramForLastBucketMode = () => { const { timezone } = capabilities; + const { intervalString } = getBucketSize( + req, + interval, + capabilities, + maxBars ? Math.min(maxBarsUiSettings, maxBars) : barTargetUiSettings + ); + overwrite(doc, `aggs.${series.id}.aggs.timeseries.date_histogram`, { field: timeField, min_doc_count: 0, @@ -50,25 +53,29 @@ export function dateHistogram( }, ...dateHistogramInterval(intervalString), }); + + bucketInterval = intervalString; }; - const getDateHistogramForEntireTimerangeMode = () => + const overwriteDateHistogramForEntireTimerangeMode = () => { overwrite(doc, `aggs.${series.id}.aggs.timeseries.auto_date_histogram`, { field: timeField, buckets: 1, }); + bucketInterval = `${to.valueOf() - from.valueOf()}ms`; + }; + isLastValueTimerangeMode(panel, series) - ? getDateHistogramForLastBucketMode() - : getDateHistogramForEntireTimerangeMode(); + ? overwriteDateHistogramForLastBucketMode() + : overwriteDateHistogramForEntireTimerangeMode(); overwrite(doc, `aggs.${series.id}.meta`, { timeField, - intervalString, - bucketSize, + panelId: panel.id, seriesId: series.id, + intervalString: bucketInterval, index: panel.use_kibana_indexes ? seriesIndex.indexPattern?.id : undefined, - panelId: panel.id, }); return next(doc); diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.test.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.test.js index 741eb93267f4c..2cd7a213b273e 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.test.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.test.js @@ -86,7 +86,6 @@ describe('dateHistogram(req, panel, series)', () => { }, }, meta: { - bucketSize: 10, intervalString: '10s', timeField: '@timestamp', seriesId: 'test', @@ -128,7 +127,6 @@ describe('dateHistogram(req, panel, series)', () => { }, }, meta: { - bucketSize: 10, intervalString: '10s', timeField: '@timestamp', seriesId: 'test', @@ -173,7 +171,6 @@ describe('dateHistogram(req, panel, series)', () => { }, }, meta: { - bucketSize: 20, intervalString: '20s', timeField: 'timestamp', seriesId: 'test', @@ -185,8 +182,11 @@ describe('dateHistogram(req, panel, series)', () => { }); describe('dateHistogram for entire time range mode', () => { - test('should ignore entire range mode for timeseries', async () => { + beforeEach(() => { panel.time_range_mode = 'entire_time_range'; + }); + + test('should ignore entire range mode for timeseries', async () => { panel.type = 'timeseries'; const next = (doc) => doc; @@ -204,9 +204,36 @@ describe('dateHistogram(req, panel, series)', () => { expect(doc.aggs.test.aggs.timeseries.date_histogram).toBeDefined(); }); - test('should returns valid date histogram for entire range mode', async () => { - panel.time_range_mode = 'entire_time_range'; + test('should set meta values', async () => { + // set 15 minutes (=== 900000ms) interval; + req.body.timerange = { + min: '2021-01-01T00:00:00Z', + max: '2021-01-01T00:15:00Z', + }; + + const next = (doc) => doc; + const doc = await dateHistogram( + req, + panel, + series, + config, + indexPattern, + capabilities, + uiSettings + )(next)({}); + expect(doc.aggs.test.meta).toMatchInlineSnapshot(` + Object { + "index": undefined, + "intervalString": "900000ms", + "panelId": "panelId", + "seriesId": "test", + "timeField": "@timestamp", + } + `); + }); + + test('should returns valid date histogram for entire range mode', async () => { const next = (doc) => doc; const doc = await dateHistogram( req, @@ -232,8 +259,7 @@ describe('dateHistogram(req, panel, series)', () => { meta: { timeField: '@timestamp', seriesId: 'test', - bucketSize: 10, - intervalString: '10s', + intervalString: '3600000ms', panelId: 'panelId', }, }, diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/metric_buckets.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/metric_buckets.js index 29a11bf163e0b..33c6622f73941 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/metric_buckets.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/metric_buckets.js @@ -7,30 +7,17 @@ */ import { overwrite } from '../../helpers'; -import { getBucketSize } from '../../helpers/get_bucket_size'; import { bucketTransform } from '../../helpers/bucket_transform'; -import { getIntervalAndTimefield } from '../../get_interval_and_timefield'; -import { UI_SETTINGS } from '../../../../../../data/common'; +import { get } from 'lodash'; -export function metricBuckets( - req, - panel, - series, - esQueryConfig, - seriesIndex, - capabilities, - uiSettings -) { +export function metricBuckets(req, panel, series) { return (next) => async (doc) => { - const barTargetUiSettings = await uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET); - - const { interval } = getIntervalAndTimefield(panel, series, seriesIndex); - const { intervalString } = getBucketSize(req, interval, capabilities, barTargetUiSettings); - series.metrics .filter((row) => !/_bucket$/.test(row.type) && !/^series/.test(row.type)) .forEach((metric) => { const fn = bucketTransform[metric.type]; + const intervalString = get(doc, `aggs.${series.id}.meta.intervalString`); + if (fn) { try { const bucket = fn(metric, series.metrics, intervalString); diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/sibling_buckets.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/sibling_buckets.js index dbeb3b1393bd5..c3075dd6dcac0 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/sibling_buckets.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/sibling_buckets.js @@ -6,39 +6,28 @@ * Side Public License, v 1. */ +import { get } from 'lodash'; import { overwrite } from '../../helpers'; -import { getBucketSize } from '../../helpers/get_bucket_size'; import { bucketTransform } from '../../helpers/bucket_transform'; -import { getIntervalAndTimefield } from '../../get_interval_and_timefield'; -import { UI_SETTINGS } from '../../../../../../data/common'; -export function siblingBuckets( - req, - panel, - series, - esQueryConfig, - seriesIndex, - capabilities, - uiSettings -) { +export function siblingBuckets(req, panel, series) { return (next) => async (doc) => { - const barTargetUiSettings = await uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET); - const { interval } = getIntervalAndTimefield(panel, series, seriesIndex); - const { bucketSize } = getBucketSize(req, interval, capabilities, barTargetUiSettings); - series.metrics .filter((row) => /_bucket$/.test(row.type)) .forEach((metric) => { const fn = bucketTransform[metric.type]; + const intervalString = get(doc, `aggs.${series.id}.meta.intervalString`); + if (fn) { try { - const bucket = fn(metric, series.metrics, bucketSize); + const bucket = fn(metric, series.metrics, intervalString); overwrite(doc, `aggs.${series.id}.aggs.${metric.id}`, bucket); } catch (e) { // meh } } }); + return next(doc); }; } diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/date_histogram.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/date_histogram.js index 3e883abc9e5e0..92ac4078a3835 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/date_histogram.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/date_histogram.js @@ -20,6 +20,7 @@ export function dateHistogram(req, panel, esQueryConfig, seriesIndex, capabiliti return (next) => async (doc) => { const barTargetUiSettings = await uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET); const { timeField, interval } = getIntervalAndTimefield(panel, {}, seriesIndex); + const { from, to } = getTimerange(req); const meta = { timeField, @@ -27,14 +28,8 @@ export function dateHistogram(req, panel, esQueryConfig, seriesIndex, capabiliti panelId: panel.id, }; - const getDateHistogramForLastBucketMode = () => { - const { bucketSize, intervalString } = getBucketSize( - req, - interval, - capabilities, - barTargetUiSettings - ); - const { from, to } = getTimerange(req); + const overwriteDateHistogramForLastBucketMode = () => { + const { intervalString } = getBucketSize(req, interval, capabilities, barTargetUiSettings); const { timezone } = capabilities; panel.series.forEach((column) => { @@ -54,12 +49,13 @@ export function dateHistogram(req, panel, esQueryConfig, seriesIndex, capabiliti overwrite(doc, aggRoot.replace(/\.aggs$/, '.meta'), { ...meta, intervalString, - bucketSize, }); }); }; - const getDateHistogramForEntireTimerangeMode = () => { + const overwriteDateHistogramForEntireTimerangeMode = () => { + const intervalString = `${to.valueOf() - from.valueOf()}ms`; + panel.series.forEach((column) => { const aggRoot = calculateAggRoot(doc, column); @@ -68,13 +64,16 @@ export function dateHistogram(req, panel, esQueryConfig, seriesIndex, capabiliti buckets: 1, }); - overwrite(doc, aggRoot.replace(/\.aggs$/, '.meta'), meta); + overwrite(doc, aggRoot.replace(/\.aggs$/, '.meta'), { + ...meta, + intervalString, + }); }); }; isLastValueTimerangeMode(panel) - ? getDateHistogramForLastBucketMode() - : getDateHistogramForEntireTimerangeMode(); + ? overwriteDateHistogramForLastBucketMode() + : overwriteDateHistogramForEntireTimerangeMode(); return next(doc); }; diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/metric_buckets.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/metric_buckets.js index 421f9d2d75f0c..8e0d0060225ff 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/metric_buckets.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/metric_buckets.js @@ -6,19 +6,13 @@ * Side Public License, v 1. */ +import { get } from 'lodash'; import { overwrite } from '../../helpers'; -import { getBucketSize } from '../../helpers/get_bucket_size'; import { bucketTransform } from '../../helpers/bucket_transform'; -import { getIntervalAndTimefield } from '../../get_interval_and_timefield'; import { calculateAggRoot } from './calculate_agg_root'; -import { UI_SETTINGS } from '../../../../../../data/common'; -export function metricBuckets(req, panel, esQueryConfig, seriesIndex, capabilities, uiSettings) { +export function metricBuckets(req, panel) { return (next) => async (doc) => { - const barTargetUiSettings = await uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET); - const { interval } = getIntervalAndTimefield(panel, {}, seriesIndex); - const { intervalString } = getBucketSize(req, interval, capabilities, barTargetUiSettings); - panel.series.forEach((column) => { const aggRoot = calculateAggRoot(doc, column); column.metrics @@ -27,7 +21,9 @@ export function metricBuckets(req, panel, esQueryConfig, seriesIndex, capabiliti const fn = bucketTransform[metric.type]; if (fn) { try { + const intervalString = get(doc, aggRoot.replace(/\.aggs$/, '.meta.intervalString')); const bucket = fn(metric, column.metrics, intervalString); + overwrite(doc, `${aggRoot}.timeseries.aggs.${metric.id}`, bucket); } catch (e) { // meh diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/sibling_buckets.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/sibling_buckets.js index 9b4b0f244fc2c..6ce956c490900 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/sibling_buckets.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/sibling_buckets.js @@ -7,18 +7,12 @@ */ import { overwrite } from '../../helpers'; -import { getBucketSize } from '../../helpers/get_bucket_size'; import { bucketTransform } from '../../helpers/bucket_transform'; -import { getIntervalAndTimefield } from '../../get_interval_and_timefield'; import { calculateAggRoot } from './calculate_agg_root'; -import { UI_SETTINGS } from '../../../../../../data/common'; +import { get } from 'lodash'; -export function siblingBuckets(req, panel, esQueryConfig, seriesIndex, capabilities, uiSettings) { +export function siblingBuckets(req, panel) { return (next) => async (doc) => { - const barTargetUiSettings = await uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET); - const { interval } = getIntervalAndTimefield(panel, {}, seriesIndex); - const { bucketSize } = getBucketSize(req, interval, capabilities, barTargetUiSettings); - panel.series.forEach((column) => { const aggRoot = calculateAggRoot(doc, column); column.metrics @@ -27,7 +21,9 @@ export function siblingBuckets(req, panel, esQueryConfig, seriesIndex, capabilit const fn = bucketTransform[metric.type]; if (fn) { try { - const bucket = fn(metric, column.metrics, bucketSize); + const intervalString = get(doc, aggRoot.replace(/\.aggs$/, '.meta.intervalString')); + const bucket = fn(metric, column.metrics, intervalString); + overwrite(doc, `${aggRoot}.${metric.id}`, bucket); } catch (e) { // meh diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/math.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/math.js index 403b486cc4d09..d3cff76524ee3 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/math.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/math.js @@ -6,6 +6,8 @@ * Side Public License, v 1. */ +import { convertIntervalToUnit } from '../../helpers/unit_to_seconds'; + const percentileValueMatch = /\[([0-9\.]+)\]$/; import { startsWith, flatten, values, first, last } from 'lodash'; import { getDefaultDecoration } from '../../helpers/get_default_decoration'; @@ -82,13 +84,15 @@ export function mathAgg(resp, panel, series, meta, extractFields) { if (someNull) return [ts, null]; try { // calculate the result based on the user's script and return the value + const inMsInterval = convertIntervalToUnit(split.meta?.intervalString || 0, 'ms'); + const result = evaluate(mathMetric.script, { params: { ...params, _index: index, _timestamp: ts, _all: all, - _interval: split.meta.bucketSize * 1000, + _interval: inMsInterval?.value, }, }); // if the result is an object (usually when the user is working with maps and functions) flatten the results and return the last value. diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/math.test.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/math.test.js index 1e30720d6e5b2..7b5eb1e029069 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/math.test.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/math.test.js @@ -54,7 +54,7 @@ describe('math(resp, panel, series)', () => { aggregations: { test: { meta: { - bucketSize: 5, + intervalString: '5s', }, buckets: [ { @@ -124,6 +124,25 @@ describe('math(resp, panel, series)', () => { ); }); + test('should works with predefined variables (params._interval)', async () => { + const expectedInterval = 5000; + + series.metrics[2].script = 'params._interval'; + + const next = await mathAgg(resp, panel, series)((results) => results); + const results = await stdMetric(resp, panel, series)(next)([]); + + expect(results).toHaveLength(1); + expect(results[0]).toEqual( + expect.objectContaining({ + data: [ + [1, expectedInterval], + [2, expectedInterval], + ], + }) + ); + }); + test('throws on actual tinymath expression errors #1', async () => { series.metrics[2].script = 'notExistingFn(params.a)'; diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/series/build_request_body.test.ts b/src/plugins/vis_type_timeseries/server/lib/vis_data/series/build_request_body.test.ts index 5b865d451003a..46acbb27e15e1 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/series/build_request_body.test.ts +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/series/build_request_body.test.ts @@ -153,7 +153,6 @@ describe('buildRequestBody(req)', () => { time_zone: 'UTC', }, meta: { - bucketSize: 10, intervalString: '10s', seriesId: 'c9b5f9c0-e403-11e6-be91-6f7688e9fac7', timeField: '@timestamp', From 4e5652d05a0f2b3c0589276bb8ad79ebc7f5ccc4 Mon Sep 17 00:00:00 2001 From: Marta Bondyra Date: Thu, 3 Jun 2021 12:54:43 +0200 Subject: [PATCH 18/90] [Lens] setFocusTrap after animation is ended and not with timeout (#101148) --- .../config_panel/dimension_container.tsx | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.tsx index a8d610f2740de..b14d391c2c969 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.tsx @@ -44,15 +44,6 @@ export function DimensionContainer({ setFocusTrapIsEnabled(false); }, [handleClose]); - useEffect(() => { - if (isOpen) { - // without setTimeout here the flyout pushes content when animating - setTimeout(() => { - setFocusTrapIsEnabled(true); - }, 255); - } - }, [isOpen]); - const closeOnEscape = useCallback( (event: KeyboardEvent) => { if (event.key === keys.ESCAPE) { @@ -83,6 +74,13 @@ export function DimensionContainer({ role="dialog" aria-labelledby="lnsDimensionContainerTitle" className="lnsDimensionContainer euiFlyout" + onAnimationEnd={() => { + if (isOpen) { + // EuiFocusTrap interferes with animating elements with absolute position: + // running this onAnimationEnd, otherwise the flyout pushes content when animating + setFocusTrapIsEnabled(true); + } + }} > Date: Thu, 3 Jun 2021 14:06:57 +0200 Subject: [PATCH 19/90] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20connect=20dasdhboa?= =?UTF-8?q?rd=20telemetry=20to=20persistable=20state=20(#99498)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 🎸 connect dasdhboard telemetry to persistable state * fix: 🐛 do not mutate .telemetry() stats objects * feat: 🎸 populate stats object with embeddable telemetry * feat: 🎸 embeddable telemetry schema * feat: 🎸 update telemetry schema * feat: 🎸 add descriptions to dashboard collector * chore: 🤖 update telemetry schema Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../server/usage/dashboard_telemetry.ts | 22 +++++++++++++++++++ .../server/usage/register_collector.ts | 17 ++++++++++++++ src/plugins/embeddable/public/plugin.tsx | 2 +- src/plugins/embeddable/server/plugin.ts | 4 ++-- src/plugins/telemetry/schema/oss_plugins.json | 20 +++++++++++++++-- .../public/dynamic_actions/action_factory.ts | 2 +- .../ui_actions_enhanced/server/plugin.ts | 2 +- .../telemetry/dynamic_actions_collector.ts | 3 ++- 8 files changed, 64 insertions(+), 8 deletions(-) diff --git a/src/plugins/dashboard/server/usage/dashboard_telemetry.ts b/src/plugins/dashboard/server/usage/dashboard_telemetry.ts index 02d492de4fe66..912dc04d16d09 100644 --- a/src/plugins/dashboard/server/usage/dashboard_telemetry.ts +++ b/src/plugins/dashboard/server/usage/dashboard_telemetry.ts @@ -41,6 +41,9 @@ export interface DashboardCollectorData { visualizationByValue: { [key: string]: number; }; + embeddable: { + [key: string]: number; + }; } export const getEmptyTelemetryData = (): DashboardCollectorData => ({ @@ -48,6 +51,7 @@ export const getEmptyTelemetryData = (): DashboardCollectorData => ({ panelsByValue: 0, lensByValue: {}, visualizationByValue: {}, + embeddable: {}, }); type DashboardCollectorFunction = ( @@ -115,6 +119,23 @@ export const collectForPanels: DashboardCollectorFunction = (panels, collectorDa collectByValueLensInfo(panels, collectorData); }; +export const collectEmbeddableData = ( + panels: SavedDashboardPanel730ToLatest[], + collectorData: DashboardCollectorData, + embeddableService: EmbeddablePersistableStateService +) => { + for (const panel of panels) { + collectorData.embeddable = embeddableService.telemetry( + { + ...panel.embeddableConfig, + id: panel.id || '', + type: panel.type, + }, + collectorData.embeddable + ); + } +}; + export async function collectDashboardTelemetry( savedObjectClient: Pick, embeddableService: EmbeddablePersistableStateService @@ -134,6 +155,7 @@ export async function collectDashboardTelemetry( ) as unknown) as SavedDashboardPanel730ToLatest[]; collectForPanels(panels, collectorData); + collectEmbeddableData(panels, collectorData, embeddableService); } return collectorData; diff --git a/src/plugins/dashboard/server/usage/register_collector.ts b/src/plugins/dashboard/server/usage/register_collector.ts index 780dd716c0f78..a911fc9b81666 100644 --- a/src/plugins/dashboard/server/usage/register_collector.ts +++ b/src/plugins/dashboard/server/usage/register_collector.ts @@ -27,11 +27,28 @@ export function registerDashboardUsageCollector( lensByValue: { DYNAMIC_KEY: { type: 'long', + _meta: { + description: + 'Collection of telemetry metrics for Lens visualizations, which are added to dashboard by "value".', + }, }, }, visualizationByValue: { DYNAMIC_KEY: { type: 'long', + _meta: { + description: + 'Collection of telemetry metrics for visualizations, which are added to dashboard by "value".', + }, + }, + }, + embeddable: { + DYNAMIC_KEY: { + type: 'long', + _meta: { + description: + 'Collection of telemetry metrics that embeddable service reports. Embeddable service internally calls each embeddable, which in turn calls its dynamic actions, which calls each drill down attached to that embeddable.', + }, }, }, }, diff --git a/src/plugins/embeddable/public/plugin.tsx b/src/plugins/embeddable/public/plugin.tsx index 3e1a19711d0ff..4ddef89727ef1 100644 --- a/src/plugins/embeddable/public/plugin.tsx +++ b/src/plugins/embeddable/public/plugin.tsx @@ -236,7 +236,7 @@ export class EmbeddablePublicPlugin implements Plugin ({}), + telemetry: (state, stats) => stats, inject: identity, extract: (state: SerializableState) => { return { state, references: [] }; diff --git a/src/plugins/embeddable/server/plugin.ts b/src/plugins/embeddable/server/plugin.ts index f4728bf575a06..788f51adc327b 100644 --- a/src/plugins/embeddable/server/plugin.ts +++ b/src/plugins/embeddable/server/plugin.ts @@ -90,7 +90,7 @@ export class EmbeddableServerPlugin implements Plugin ({}), + telemetry: (state, stats) => stats, inject: identity, extract: (state: SerializableState) => { return { state, references: [] }; @@ -119,7 +119,7 @@ export class EmbeddableServerPlugin implements Plugin ({}), + telemetry: (state, stats) => stats, inject: (state: EmbeddableStateWithType) => state, extract: (state: EmbeddableStateWithType) => { return { state, references: [] }; diff --git a/src/plugins/telemetry/schema/oss_plugins.json b/src/plugins/telemetry/schema/oss_plugins.json index 0ca1b863f91a7..1d37c25f52fd4 100644 --- a/src/plugins/telemetry/schema/oss_plugins.json +++ b/src/plugins/telemetry/schema/oss_plugins.json @@ -11,14 +11,30 @@ "lensByValue": { "properties": { "DYNAMIC_KEY": { - "type": "long" + "type": "long", + "_meta": { + "description": "Collection of telemetry metrics for Lens visualizations, which are added to dashboard by \"value\"." + } } } }, "visualizationByValue": { "properties": { "DYNAMIC_KEY": { - "type": "long" + "type": "long", + "_meta": { + "description": "Collection of telemetry metrics for visualizations, which are added to dashboard by \"value\"." + } + } + } + }, + "embeddable": { + "properties": { + "DYNAMIC_KEY": { + "type": "long", + "_meta": { + "description": "Collection of telemetry metrics that embeddable service reports. Embeddable service internally calls each embeddable, which in turn calls its dynamic actions, which calls each drill down attached to that embeddable." + } } } } diff --git a/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/action_factory.ts b/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/action_factory.ts index bd5dc5794cb59..93c1b33268bf4 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/action_factory.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/action_factory.ts @@ -123,7 +123,7 @@ export class ActionFactory< } public telemetry(state: SerializedEvent, telemetryData: Record) { - return this.def.telemetry ? this.def.telemetry(state, telemetryData) : {}; + return this.def.telemetry ? this.def.telemetry(state, telemetryData) : telemetryData; } public extract(state: SerializedEvent) { diff --git a/x-pack/plugins/ui_actions_enhanced/server/plugin.ts b/x-pack/plugins/ui_actions_enhanced/server/plugin.ts index 245892e854df2..3faa5ce6aa3ef 100644 --- a/x-pack/plugins/ui_actions_enhanced/server/plugin.ts +++ b/x-pack/plugins/ui_actions_enhanced/server/plugin.ts @@ -52,7 +52,7 @@ export class AdvancedUiActionsServerPlugin this.actionFactories.set(definition.id, { id: definition.id, - telemetry: definition.telemetry || (() => ({})), + telemetry: definition.telemetry || ((state, stats) => stats), inject: definition.inject || identity, extract: definition.extract || diff --git a/x-pack/plugins/ui_actions_enhanced/server/telemetry/dynamic_actions_collector.ts b/x-pack/plugins/ui_actions_enhanced/server/telemetry/dynamic_actions_collector.ts index 15cb40ee62068..c89d93f5f5e28 100644 --- a/x-pack/plugins/ui_actions_enhanced/server/telemetry/dynamic_actions_collector.ts +++ b/x-pack/plugins/ui_actions_enhanced/server/telemetry/dynamic_actions_collector.ts @@ -10,8 +10,9 @@ import { getMetricKey } from './get_metric_key'; export const dynamicActionsCollector = ( state: DynamicActionsState, - stats: Record + currentStats: Record ): Record => { + const stats: Record = { ...currentStats }; const countMetricKey = getMetricKey('count'); stats[countMetricKey] = state.events.length + (stats[countMetricKey] || 0); From c30dc1e08f43299f0d516fb0cbd321abad2743dd Mon Sep 17 00:00:00 2001 From: Liza Katz Date: Thu, 3 Jun 2021 15:15:29 +0300 Subject: [PATCH 20/90] use fake timers to avoid flakiness (#101254) --- .../create_streaming_batched_function.test.ts | 78 ++++++++++--------- 1 file changed, 40 insertions(+), 38 deletions(-) diff --git a/src/plugins/bfetch/public/batching/create_streaming_batched_function.test.ts b/src/plugins/bfetch/public/batching/create_streaming_batched_function.test.ts index 458b691573e56..719bddc4080d0 100644 --- a/src/plugins/bfetch/public/batching/create_streaming_batched_function.test.ts +++ b/src/plugins/bfetch/public/batching/create_streaming_batched_function.test.ts @@ -48,8 +48,14 @@ const setup = () => { }; }; -// FLAKY: https://github.com/elastic/kibana/issues/101126 -describe.skip('createStreamingBatchedFunction()', () => { +describe('createStreamingBatchedFunction()', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); test('returns a function', () => { const { fetchStreaming } = setup(); const fn = createStreamingBatchedFunction({ @@ -87,8 +93,8 @@ describe.skip('createStreamingBatchedFunction()', () => { expect(fetchStreaming).toHaveBeenCalledTimes(0); fn({ baz: 'quix' }); expect(fetchStreaming).toHaveBeenCalledTimes(0); + jest.advanceTimersByTime(6); - await new Promise((r) => setTimeout(r, 6)); expect(fetchStreaming).toHaveBeenCalledTimes(1); }); @@ -103,7 +109,7 @@ describe.skip('createStreamingBatchedFunction()', () => { }); expect(fetchStreaming).toHaveBeenCalledTimes(0); - await new Promise((r) => setTimeout(r, 6)); + jest.advanceTimersByTime(6); expect(fetchStreaming).toHaveBeenCalledTimes(0); }); @@ -118,7 +124,7 @@ describe.skip('createStreamingBatchedFunction()', () => { }); fn({ foo: 'bar' }); - await new Promise((r) => setTimeout(r, 6)); + jest.advanceTimersByTime(6); expect(fetchStreaming.mock.calls[0][0]).toMatchObject({ url: '/test', @@ -139,7 +145,7 @@ describe.skip('createStreamingBatchedFunction()', () => { fn({ foo: 'bar' }); fn({ baz: 'quix' }); - await new Promise((r) => setTimeout(r, 6)); + jest.advanceTimersByTime(6); const { body } = fetchStreaming.mock.calls[0][0]; expect(JSON.parse(body)).toEqual({ batch: [{ foo: 'bar' }, { baz: 'quix' }], @@ -160,13 +166,10 @@ describe.skip('createStreamingBatchedFunction()', () => { expect(fetchStreaming).toHaveBeenCalledTimes(0); fn({ foo: 'bar' }); - await flushPromises(); expect(fetchStreaming).toHaveBeenCalledTimes(0); fn({ baz: 'quix' }); - await flushPromises(); expect(fetchStreaming).toHaveBeenCalledTimes(0); fn({ full: 'yep' }); - await flushPromises(); expect(fetchStreaming).toHaveBeenCalledTimes(1); }); @@ -186,7 +189,7 @@ describe.skip('createStreamingBatchedFunction()', () => { of(fn({ foo: 'bar' }, abortController.signal)); fn({ baz: 'quix' }); - await new Promise((r) => setTimeout(r, 6)); + jest.advanceTimersByTime(6); const { body } = fetchStreaming.mock.calls[0][0]; expect(JSON.parse(body)).toEqual({ batch: [{ baz: 'quix' }], @@ -206,7 +209,6 @@ describe.skip('createStreamingBatchedFunction()', () => { fn({ a: '1' }); fn({ b: '2' }); fn({ c: '3' }); - await flushPromises(); expect(fetchStreaming.mock.calls[0][0]).toMatchObject({ url: '/test', @@ -231,11 +233,9 @@ describe.skip('createStreamingBatchedFunction()', () => { fn({ a: '1' }); fn({ b: '2' }); fn({ c: '3' }); - await flushPromises(); expect(fetchStreaming).toHaveBeenCalledTimes(1); fn({ d: '4' }); - await flushPromises(); - await new Promise((r) => setTimeout(r, 6)); + jest.advanceTimersByTime(6); expect(fetchStreaming).toHaveBeenCalledTimes(2); }); }); @@ -253,7 +253,7 @@ describe.skip('createStreamingBatchedFunction()', () => { const promise1 = fn({ a: '1' }); const promise2 = fn({ b: '2' }); - await new Promise((r) => setTimeout(r, 6)); + jest.advanceTimersByTime(6); expect(await isPending(promise1)).toBe(true); expect(await isPending(promise2)).toBe(true); @@ -274,7 +274,7 @@ describe.skip('createStreamingBatchedFunction()', () => { const promise1 = fn({ a: '1' }); const promise2 = fn({ b: '2' }); const promise3 = fn({ c: '3' }); - await new Promise((r) => setTimeout(r, 6)); + jest.advanceTimersByTime(6); expect(await isPending(promise1)).toBe(true); expect(await isPending(promise2)).toBe(true); @@ -316,7 +316,7 @@ describe.skip('createStreamingBatchedFunction()', () => { const promise1 = fn({ a: '1' }); const promise2 = fn({ b: '2' }); const promise3 = fn({ c: '3' }); - await new Promise((r) => setTimeout(r, 6)); + jest.advanceTimersByTime(6); stream.next( JSON.stringify({ @@ -365,7 +365,7 @@ describe.skip('createStreamingBatchedFunction()', () => { const promise1 = fn({ a: '1' }); const promise2 = fn({ b: '2' }); const promise3 = fn({ c: '3' }); - await new Promise((r) => setTimeout(r, 6)); + jest.advanceTimersByTime(6); stream.next( JSON.stringify({ @@ -405,7 +405,7 @@ describe.skip('createStreamingBatchedFunction()', () => { }); const promise = fn({ a: '1' }); - await new Promise((r) => setTimeout(r, 6)); + jest.advanceTimersByTime(6); expect(await isPending(promise)).toBe(true); @@ -437,7 +437,7 @@ describe.skip('createStreamingBatchedFunction()', () => { const promise2 = of(fn({ a: '2' })); const promise3 = of(fn({ a: '3' })); - await new Promise((r) => setTimeout(r, 6)); + jest.advanceTimersByTime(6); stream.next( JSON.stringify({ @@ -446,7 +446,7 @@ describe.skip('createStreamingBatchedFunction()', () => { }) + '\n' ); - await new Promise((r) => setTimeout(r, 1)); + jest.advanceTimersByTime(1); stream.next( JSON.stringify({ @@ -455,7 +455,7 @@ describe.skip('createStreamingBatchedFunction()', () => { }) + '\n' ); - await new Promise((r) => setTimeout(r, 1)); + jest.advanceTimersByTime(1); stream.next( JSON.stringify({ @@ -464,7 +464,7 @@ describe.skip('createStreamingBatchedFunction()', () => { }) + '\n' ); - await new Promise((r) => setTimeout(r, 1)); + jest.advanceTimersByTime(1); const [result1] = await promise1; const [, error2] = await promise2; @@ -489,13 +489,14 @@ describe.skip('createStreamingBatchedFunction()', () => { const abortController = new AbortController(); const promise = fn({ a: '1' }, abortController.signal); const promise2 = fn({ a: '2' }, abortController.signal); - await new Promise((r) => setTimeout(r, 6)); + jest.advanceTimersByTime(6); expect(await isPending(promise)).toBe(true); expect(await isPending(promise2)).toBe(true); abortController.abort(); - await new Promise((r) => setTimeout(r, 6)); + jest.advanceTimersByTime(6); + await flushPromises(); expect(await isPending(promise)).toBe(false); expect(await isPending(promise2)).toBe(false); @@ -519,12 +520,13 @@ describe.skip('createStreamingBatchedFunction()', () => { const abortController = new AbortController(); const promise = fn({ a: '1' }, abortController.signal); const promise2 = fn({ a: '2' }); - await new Promise((r) => setTimeout(r, 6)); + jest.advanceTimersByTime(6); expect(await isPending(promise)).toBe(true); abortController.abort(); - await new Promise((r) => setTimeout(r, 6)); + jest.advanceTimersByTime(6); + await flushPromises(); expect(await isPending(promise)).toBe(false); const [, error] = await of(promise); @@ -537,7 +539,7 @@ describe.skip('createStreamingBatchedFunction()', () => { }) + '\n' ); - await new Promise((r) => setTimeout(r, 1)); + jest.advanceTimersByTime(1); const [result2] = await of(promise2); expect(result2).toEqual({ b: '2' }); @@ -558,11 +560,11 @@ describe.skip('createStreamingBatchedFunction()', () => { const promise1 = of(fn({ a: '1' })); const promise2 = of(fn({ a: '2' })); - await new Promise((r) => setTimeout(r, 6)); + jest.advanceTimersByTime(6); stream.complete(); - await new Promise((r) => setTimeout(r, 1)); + jest.advanceTimersByTime(1); const [, error1] = await promise1; const [, error2] = await promise2; @@ -589,7 +591,7 @@ describe.skip('createStreamingBatchedFunction()', () => { const promise1 = of(fn({ a: '1' })); const promise2 = of(fn({ a: '2' })); - await new Promise((r) => setTimeout(r, 6)); + jest.advanceTimersByTime(6); stream.next( JSON.stringify({ @@ -599,7 +601,7 @@ describe.skip('createStreamingBatchedFunction()', () => { ); stream.complete(); - await new Promise((r) => setTimeout(r, 1)); + jest.advanceTimersByTime(1); const [, error1] = await promise1; const [result1] = await promise2; @@ -627,13 +629,13 @@ describe.skip('createStreamingBatchedFunction()', () => { const promise1 = of(fn({ a: '1' })); const promise2 = of(fn({ a: '2' })); - await new Promise((r) => setTimeout(r, 6)); + jest.advanceTimersByTime(6); stream.error({ message: 'something went wrong', }); - await new Promise((r) => setTimeout(r, 1)); + jest.advanceTimersByTime(1); const [, error1] = await promise1; const [, error2] = await promise2; @@ -660,7 +662,7 @@ describe.skip('createStreamingBatchedFunction()', () => { const promise1 = of(fn({ a: '1' })); const promise2 = of(fn({ a: '2' })); - await new Promise((r) => setTimeout(r, 6)); + jest.advanceTimersByTime(6); stream.next( JSON.stringify({ @@ -670,7 +672,7 @@ describe.skip('createStreamingBatchedFunction()', () => { ); stream.error('oops'); - await new Promise((r) => setTimeout(r, 1)); + jest.advanceTimersByTime(1); const [, error1] = await promise1; const [result1] = await promise2; @@ -698,7 +700,7 @@ describe.skip('createStreamingBatchedFunction()', () => { const promise1 = of(fn({ a: '1' })); const promise2 = of(fn({ a: '2' })); - await new Promise((r) => setTimeout(r, 6)); + jest.advanceTimersByTime(6); stream.next( JSON.stringify({ @@ -709,7 +711,7 @@ describe.skip('createStreamingBatchedFunction()', () => { stream.next('Not a JSON\n'); - await new Promise((r) => setTimeout(r, 1)); + jest.advanceTimersByTime(1); const [, error1] = await promise1; const [result1] = await promise2; From 2ea4d5713c3ed6324779788dae1a470dcbcc403d Mon Sep 17 00:00:00 2001 From: Shahzad Date: Thu, 3 Jun 2021 15:19:35 +0200 Subject: [PATCH 21/90] [Uptime] Move uptime to new solution nav (#100905) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Expose options to customize the route matching * Add more comments * move uptime to new solution nav * push * update test * add an extra breadcrumb Co-authored-by: Felix Stürmer --- x-pack/plugins/uptime/kibana.json | 6 +- x-pack/plugins/uptime/public/apps/plugin.ts | 62 ++++++-- .../plugins/uptime/public/apps/uptime_app.tsx | 1 + .../certificates/certificate_title.tsx | 25 +++ .../header/action_menu_content.test.tsx | 2 +- .../common/header/page_header.test.tsx | 69 --------- .../components/common/header/page_header.tsx | 64 -------- .../components/monitor/monitor_title.test.tsx | 65 -------- .../components/monitor/monitor_title.tsx | 36 +++-- .../synthetics/step_detail/step_detail.tsx | 144 ------------------ .../step_detail/step_detail_container.tsx | 59 ++++--- .../synthetics/step_detail/step_page_nav.tsx | 71 +++++++++ .../step_detail/step_page_title.tsx | 69 +++++++++ .../use_monitor_breadcrumbs.test.tsx | 8 + .../public/hooks/use_breadcrumbs.test.tsx | 16 +- .../uptime/public/hooks/use_breadcrumbs.ts | 40 +++-- .../uptime/public/lib/helper/rtl_helpers.tsx | 8 +- .../uptime/public/pages/certificates.tsx | 25 +-- .../plugins/uptime/public/pages/overview.tsx | 3 +- .../plugins/uptime/public/pages/settings.tsx | 134 ++++++++-------- .../pages/synthetics/synthetics_checks.tsx | 30 ++-- x-pack/plugins/uptime/public/routes.tsx | 82 +++++----- .../services/uptime/certificates.ts | 3 +- .../functional/services/uptime/navigation.ts | 5 +- 24 files changed, 471 insertions(+), 556 deletions(-) create mode 100644 x-pack/plugins/uptime/public/components/certificates/certificate_title.tsx delete mode 100644 x-pack/plugins/uptime/public/components/common/header/page_header.test.tsx delete mode 100644 x-pack/plugins/uptime/public/components/common/header/page_header.tsx delete mode 100644 x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_detail.tsx create mode 100644 x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_page_nav.tsx create mode 100644 x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_page_title.tsx diff --git a/x-pack/plugins/uptime/kibana.json b/x-pack/plugins/uptime/kibana.json index 0d2346f59b0a1..4d5ab531af7c4 100644 --- a/x-pack/plugins/uptime/kibana.json +++ b/x-pack/plugins/uptime/kibana.json @@ -8,7 +8,6 @@ "optionalPlugins": [ "data", "home", - "observability", "ml", "fleet" ], @@ -18,7 +17,8 @@ "features", "licensing", "triggersActionsUi", - "usageCollection" + "usageCollection", + "observability" ], "server": true, "ui": true, @@ -31,4 +31,4 @@ "data", "ml" ] -} \ No newline at end of file +} diff --git a/x-pack/plugins/uptime/public/apps/plugin.ts b/x-pack/plugins/uptime/public/apps/plugin.ts index 80a131676951e..e02cf44b0856e 100644 --- a/x-pack/plugins/uptime/public/apps/plugin.ts +++ b/x-pack/plugins/uptime/public/apps/plugin.ts @@ -12,6 +12,8 @@ import { PluginInitializerContext, AppMountParameters, } from 'kibana/public'; +import { of } from 'rxjs'; +import { i18n } from '@kbn/i18n'; import { DEFAULT_APP_CATEGORIES } from '../../../../../src/core/public'; import { FeatureCatalogueCategory, @@ -28,7 +30,11 @@ import { } from '../../../../../src/plugins/data/public'; import { alertTypeInitializers } from '../lib/alert_types'; import { FleetStart } from '../../../fleet/public'; -import { FetchDataParams, ObservabilityPublicSetup } from '../../../observability/public'; +import { + FetchDataParams, + ObservabilityPublicSetup, + ObservabilityPublicStart, +} from '../../../observability/public'; import { PLUGIN } from '../../common/constants/plugin'; import { IStorageWrapper } from '../../../../../src/plugins/kibana_utils/public'; import { @@ -48,6 +54,7 @@ export interface ClientPluginsStart { data: DataPublicPluginStart; triggersActionsUi: TriggersAndActionsUIPublicPluginStart; fleet?: FleetStart; + observability: ObservabilityPublicStart; } export interface UptimePluginServices extends Partial { @@ -83,21 +90,46 @@ export class UptimePlugin return UptimeDataHelper(coreStart); }; - if (plugins.observability) { - plugins.observability.dashboard.register({ - appName: 'synthetics', - hasData: async () => { - const dataHelper = await getUptimeDataHelper(); - const status = await dataHelper.indexStatus(); - return { hasData: status.docCount > 0, indices: status.indices }; - }, - fetchData: async (params: FetchDataParams) => { - const dataHelper = await getUptimeDataHelper(); - return await dataHelper.overviewData(params); - }, - }); - } + plugins.observability.dashboard.register({ + appName: 'synthetics', + hasData: async () => { + const dataHelper = await getUptimeDataHelper(); + const status = await dataHelper.indexStatus(); + return { hasData: status.docCount > 0, indices: status.indices }; + }, + fetchData: async (params: FetchDataParams) => { + const dataHelper = await getUptimeDataHelper(); + return await dataHelper.overviewData(params); + }, + }); + plugins.observability.navigation.registerSections( + of([ + { + label: 'Uptime', + sortKey: 200, + entries: [ + { + label: i18n.translate('xpack.uptime.overview.heading', { + defaultMessage: 'Monitoring overview', + }), + app: 'uptime', + path: '/', + matchFullPath: true, + ignoreTrailingSlash: true, + }, + { + label: i18n.translate('xpack.uptime.certificatesPage.heading', { + defaultMessage: 'TLS Certificates', + }), + app: 'uptime', + path: '/certificates', + matchFullPath: true, + }, + ], + }, + ]) + ); core.application.register({ id: PLUGIN.ID, euiIconType: 'logoObservability', diff --git a/x-pack/plugins/uptime/public/apps/uptime_app.tsx b/x-pack/plugins/uptime/public/apps/uptime_app.tsx index 758d40a95a86a..4d99e877291b5 100644 --- a/x-pack/plugins/uptime/public/apps/uptime_app.tsx +++ b/x-pack/plugins/uptime/public/apps/uptime_app.tsx @@ -122,6 +122,7 @@ const Application = (props: UptimeAppProps) => { storage, data: startPlugins.data, triggersActionsUi: startPlugins.triggersActionsUi, + observability: startPlugins.observability, }} > diff --git a/x-pack/plugins/uptime/public/components/certificates/certificate_title.tsx b/x-pack/plugins/uptime/public/components/certificates/certificate_title.tsx new file mode 100644 index 0000000000000..5056a3d1c1957 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/certificates/certificate_title.tsx @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { useSelector } from 'react-redux'; +import { certificatesSelector } from '../../state/certificates/certificates'; + +export const CertificateTitle = () => { + const { data: certificates } = useSelector(certificatesSelector); + + return ( + {certificates?.total ?? 0}, + }} + /> + ); +}; diff --git a/x-pack/plugins/uptime/public/components/common/header/action_menu_content.test.tsx b/x-pack/plugins/uptime/public/components/common/header/action_menu_content.test.tsx index bc5eab6f92111..89d8f38b1e3b3 100644 --- a/x-pack/plugins/uptime/public/components/common/header/action_menu_content.test.tsx +++ b/x-pack/plugins/uptime/public/components/common/header/action_menu_content.test.tsx @@ -49,7 +49,7 @@ describe('ActionMenuContent', () => { // this href value is mocked, so it doesn't correspond to the real link // that Kibana core services will provide - expect(addDataAnchor.getAttribute('href')).toBe('/app/uptime'); + expect(addDataAnchor.getAttribute('href')).toBe('/home#/tutorial/uptimeMonitors'); expect(getByText('Add data')); }); }); diff --git a/x-pack/plugins/uptime/public/components/common/header/page_header.test.tsx b/x-pack/plugins/uptime/public/components/common/header/page_header.test.tsx deleted file mode 100644 index 6e04648a817f0..0000000000000 --- a/x-pack/plugins/uptime/public/components/common/header/page_header.test.tsx +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import moment from 'moment'; -import { PageHeader } from './page_header'; -import { Ping } from '../../../../common/runtime_types'; -import { renderWithRouter } from '../../../lib'; -import { mockReduxHooks } from '../../../lib/helper/test_helpers'; - -describe('PageHeader', () => { - const monitorName = 'sample monitor'; - const defaultMonitorId = 'always-down'; - - const defaultMonitorStatus: Ping = { - docId: 'few213kl', - timestamp: moment(new Date()).subtract(15, 'm').toString(), - monitor: { - duration: { - us: 1234567, - }, - id: defaultMonitorId, - status: 'up', - type: 'http', - name: monitorName, - }, - url: { - full: 'https://www.elastic.co/', - }, - }; - - beforeEach(() => { - mockReduxHooks(defaultMonitorStatus); - }); - - it('does not render dynamic elements by default', () => { - const component = renderWithRouter(); - - expect(component.find('[data-test-subj="superDatePickerShowDatesButton"]').length).toBe(0); - expect(component.find('[data-test-subj="certificatesRefreshButton"]').length).toBe(0); - expect(component.find('[data-test-subj="monitorTitle"]').length).toBe(0); - expect(component.find('[data-test-subj="uptimeTabs"]').length).toBe(0); - }); - - it('shallow renders with the date picker', () => { - const component = renderWithRouter(); - expect(component.find('[data-test-subj="superDatePickerShowDatesButton"]').length).toBe(1); - }); - - it('shallow renders with certificate refresh button', () => { - const component = renderWithRouter(); - expect(component.find('[data-test-subj="certificatesRefreshButton"]').length).toBe(1); - }); - - it('renders monitor title when showMonitorTitle', () => { - const component = renderWithRouter(); - expect(component.find('[data-test-subj="monitorTitle"]').length).toBe(1); - expect(component.find('h1').text()).toBe(monitorName); - }); - - it('renders tabs when showTabs is true', () => { - const component = renderWithRouter(); - expect(component.find('[data-test-subj="uptimeTabs"]').length).toBe(1); - }); -}); diff --git a/x-pack/plugins/uptime/public/components/common/header/page_header.tsx b/x-pack/plugins/uptime/public/components/common/header/page_header.tsx deleted file mode 100644 index 28a133698ae8b..0000000000000 --- a/x-pack/plugins/uptime/public/components/common/header/page_header.tsx +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; -import styled from 'styled-components'; -import { UptimeDatePicker } from '../uptime_date_picker'; -import { SyntheticsCallout } from '../../overview/synthetics_callout'; -import { PageTabs } from './page_tabs'; -import { CertRefreshBtn } from '../../certificates/cert_refresh_btn'; -import { MonitorPageTitle } from '../../monitor/monitor_title'; - -export interface Props { - showCertificateRefreshBtn?: boolean; - showDatePicker?: boolean; - showMonitorTitle?: boolean; - showTabs?: boolean; -} - -const StyledPicker = styled(EuiFlexItem)` - &&& { - @media only screen and (max-width: 1024px) and (min-width: 868px) { - .euiSuperDatePicker__flexWrapper { - width: 500px; - } - } - @media only screen and (max-width: 880px) { - flex-grow: 1; - .euiSuperDatePicker__flexWrapper { - width: calc(100% + 8px); - } - } - } -`; - -export const PageHeader = ({ - showCertificateRefreshBtn = false, - showDatePicker = false, - showMonitorTitle = false, - showTabs = false, -}: Props) => { - return ( - <> - - - - {showMonitorTitle && } - {showTabs && } - - {showCertificateRefreshBtn && } - {showDatePicker && ( - - - - )} - - - - ); -}; diff --git a/x-pack/plugins/uptime/public/components/monitor/monitor_title.test.tsx b/x-pack/plugins/uptime/public/components/monitor/monitor_title.test.tsx index 5e77e68720c52..4fd6335c3d3ca 100644 --- a/x-pack/plugins/uptime/public/components/monitor/monitor_title.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/monitor_title.test.tsx @@ -48,38 +48,6 @@ describe('MonitorTitle component', () => { }, }; - const defaultTCPMonitorStatus: Ping = { - docId: 'few213kl', - timestamp: moment(new Date()).subtract(15, 'm').toString(), - monitor: { - duration: { - us: 1234567, - }, - id: 'tcp', - status: 'up', - type: 'tcp', - }, - url: { - full: 'https://www.elastic.co/', - }, - }; - - const defaultICMPMonitorStatus: Ping = { - docId: 'few213kl', - timestamp: moment(new Date()).subtract(15, 'm').toString(), - monitor: { - duration: { - us: 1234567, - }, - id: 'icmp', - status: 'up', - type: 'icmp', - }, - url: { - full: 'https://www.elastic.co/', - }, - }; - const defaultBrowserMonitorStatus: Ping = { docId: 'few213kl', timestamp: moment(new Date()).subtract(15, 'm').toString(), @@ -145,37 +113,4 @@ describe('MonitorTitle component', () => { expect(betaLink.href).toBe('https://www.elastic.co/what-is/synthetic-monitoring'); expect(screen.getByText('Browser (BETA)')).toBeInTheDocument(); }); - - it('does not render beta disclaimer for http', () => { - render(, { - state: { monitorStatus: { status: defaultMonitorStatus, loading: false } }, - }); - expect(screen.getByText('HTTP ping')).toBeInTheDocument(); - expect(screen.queryByText(/BETA/)).not.toBeInTheDocument(); - expect( - screen.queryByRole('link', { name: 'See more External link (opens in a new tab or window)' }) - ).not.toBeInTheDocument(); - }); - - it('does not render beta disclaimer for tcp', () => { - render(, { - state: { monitorStatus: { status: defaultTCPMonitorStatus, loading: false } }, - }); - expect(screen.getByText('TCP ping')).toBeInTheDocument(); - expect(screen.queryByText(/BETA/)).not.toBeInTheDocument(); - expect( - screen.queryByRole('link', { name: 'See more External link (opens in a new tab or window)' }) - ).not.toBeInTheDocument(); - }); - - it('renders badge and does not render beta disclaimer for icmp', () => { - render(, { - state: { monitorStatus: { status: defaultICMPMonitorStatus, loading: false } }, - }); - expect(screen.getByText('ICMP ping')).toBeInTheDocument(); - expect(screen.queryByText(/BETA/)).not.toBeInTheDocument(); - expect( - screen.queryByRole('link', { name: 'See more External link (opens in a new tab or window)' }) - ).not.toBeInTheDocument(); - }); }); diff --git a/x-pack/plugins/uptime/public/components/monitor/monitor_title.tsx b/x-pack/plugins/uptime/public/components/monitor/monitor_title.tsx index eebd3d8aeb14d..8cb1c49cbd974 100644 --- a/x-pack/plugins/uptime/public/components/monitor/monitor_title.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/monitor_title.tsx @@ -5,7 +5,15 @@ * 2.0. */ -import { EuiBadge, EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiTitle, EuiLink } from '@elastic/eui'; +import { + EuiBadge, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiTitle, + EuiLink, + EuiText, +} from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import React from 'react'; import { useSelector } from 'react-redux'; @@ -95,26 +103,26 @@ export const MonitorPageTitle: React.FC = () => { - {type && ( + {isBrowser && type && ( {renderMonitorType(type)}{' '} - {isBrowser && ( - - )} + )} {isBrowser && ( - - - + + + + + )} diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_detail.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_detail.tsx deleted file mode 100644 index befe53219a449..0000000000000 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_detail.tsx +++ /dev/null @@ -1,144 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { - EuiFlexGroup, - EuiFlexItem, - EuiSpacer, - EuiTitle, - EuiButtonEmpty, - EuiText, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; -import React from 'react'; -import moment from 'moment'; -import { WaterfallChartContainer } from './waterfall/waterfall_chart_container'; - -export const PREVIOUS_CHECK_BUTTON_TEXT = i18n.translate( - 'xpack.uptime.synthetics.stepDetail.previousCheckButtonText', - { - defaultMessage: 'Previous check', - } -); - -export const NEXT_CHECK_BUTTON_TEXT = i18n.translate( - 'xpack.uptime.synthetics.stepDetail.nextCheckButtonText', - { - defaultMessage: 'Next check', - } -); - -interface Props { - checkGroup: string; - stepName?: string; - stepIndex: number; - totalSteps: number; - hasPreviousStep: boolean; - hasNextStep: boolean; - handlePreviousStep: () => void; - handleNextStep: () => void; - handleNextRun: () => void; - handlePreviousRun: () => void; - previousCheckGroup?: string; - nextCheckGroup?: string; - checkTimestamp?: string; - dateFormat: string; -} - -export const StepDetail: React.FC = ({ - dateFormat, - stepName, - checkGroup, - stepIndex, - totalSteps, - hasPreviousStep, - hasNextStep, - handlePreviousStep, - handleNextStep, - handlePreviousRun, - handleNextRun, - previousCheckGroup, - nextCheckGroup, - checkTimestamp, -}) => { - return ( - <> - - - - - -

{stepName}

-
-
- - - - - - - - - - - - - - - -
-
- - - - - {PREVIOUS_CHECK_BUTTON_TEXT} - - - - {moment(checkTimestamp).format(dateFormat)} - - - - {NEXT_CHECK_BUTTON_TEXT} - - - - -
- - - - ); -}; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_detail_container.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_detail_container.tsx index ef0d001ac905e..df8f5dff59dc2 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_detail_container.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_detail_container.tsx @@ -13,8 +13,12 @@ import { useHistory } from 'react-router-dom'; import { getJourneySteps } from '../../../../state/actions/journey'; import { journeySelector } from '../../../../state/selectors'; import { useUiSetting$ } from '../../../../../../../../src/plugins/kibana_react/public'; -import { StepDetail } from './step_detail'; import { useMonitorBreadcrumb } from './use_monitor_breadcrumb'; +import { ClientPluginsStart } from '../../../../apps/plugin'; +import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; +import { StepPageTitle } from './step_page_title'; +import { StepPageNavigation } from './step_page_nav'; +import { WaterfallChartContainer } from './waterfall/waterfall_chart_container'; export const NO_STEP_DATA = i18n.translate('xpack.uptime.synthetics.stepDetail.noData', { defaultMessage: 'No data could be found for this step', @@ -66,8 +70,40 @@ export const StepDetailContainer: React.FC = ({ checkGroup, stepIndex }) history.push(`/journey/${journey?.details?.previous?.checkGroup}/step/1`); }, [history, journey?.details?.previous?.checkGroup]); + const { + services: { observability }, + } = useKibana(); + const PageTemplateComponent = observability.navigation.PageTemplate; + return ( - <> + + ) : null, + rightSideItems: journey + ? [ + , + ] + : [], + }} + > {(!journey || journey.loading) && ( @@ -86,24 +122,9 @@ export const StepDetailContainer: React.FC = ({ checkGroup, stepIndex }) )} {journey && activeStep && !journey.loading && ( - + )} - + ); }; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_page_nav.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_page_nav.tsx new file mode 100644 index 0000000000000..81c72b74c18e8 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_page_nav.tsx @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; +import moment from 'moment'; +import { i18n } from '@kbn/i18n'; + +export const PREVIOUS_CHECK_BUTTON_TEXT = i18n.translate( + 'xpack.uptime.synthetics.stepDetail.previousCheckButtonText', + { + defaultMessage: 'Previous check', + } +); + +export const NEXT_CHECK_BUTTON_TEXT = i18n.translate( + 'xpack.uptime.synthetics.stepDetail.nextCheckButtonText', + { + defaultMessage: 'Next check', + } +); + +interface Props { + previousCheckGroup?: string; + dateFormat: string; + checkTimestamp?: string; + nextCheckGroup?: string; + handlePreviousRun: () => void; + handleNextRun: () => void; +} +export const StepPageNavigation = ({ + previousCheckGroup, + dateFormat, + handleNextRun, + handlePreviousRun, + checkTimestamp, + nextCheckGroup, +}: Props) => { + return ( + + + + {PREVIOUS_CHECK_BUTTON_TEXT} + + + + {moment(checkTimestamp).format(dateFormat)} + + + + {NEXT_CHECK_BUTTON_TEXT} + + + + ); +}; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_page_title.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_page_title.tsx new file mode 100644 index 0000000000000..083f2f1533e2e --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_page_title.tsx @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiText, EuiTitle } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + +interface Props { + stepName?: string; + stepIndex: number; + totalSteps: number; + hasPreviousStep: boolean; + hasNextStep: boolean; + handlePreviousStep: () => void; + handleNextStep: () => void; +} +export const StepPageTitle = ({ + stepName, + stepIndex, + totalSteps, + handleNextStep, + handlePreviousStep, + hasNextStep, + hasPreviousStep, +}: Props) => { + return ( + + + +

{stepName}

+
+
+ + + + + + + + + + + + + + + +
+ ); +}; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/use_monitor_breadcrumbs.test.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/use_monitor_breadcrumbs.test.tsx index 4aed073424788..4521d9f82f92e 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/use_monitor_breadcrumbs.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/use_monitor_breadcrumbs.test.tsx @@ -63,6 +63,10 @@ describe('useMonitorBreadcrumbs', () => { expect(getBreadcrumbs()).toMatchInlineSnapshot(` Array [ + Object { + "href": "", + "text": "Observability", + }, Object { "href": "/app/uptime", "onClick": [Function], @@ -129,6 +133,10 @@ describe('useMonitorBreadcrumbs', () => { expect(getBreadcrumbs()).toMatchInlineSnapshot(` Array [ + Object { + "href": "", + "text": "Observability", + }, Object { "href": "/app/uptime", "onClick": [Function], diff --git a/x-pack/plugins/uptime/public/hooks/use_breadcrumbs.test.tsx b/x-pack/plugins/uptime/public/hooks/use_breadcrumbs.test.tsx index 6fc98fbaf1f5b..9d7318a45f76e 100644 --- a/x-pack/plugins/uptime/public/hooks/use_breadcrumbs.test.tsx +++ b/x-pack/plugins/uptime/public/hooks/use_breadcrumbs.test.tsx @@ -19,14 +19,8 @@ describe('useBreadcrumbs', () => { const [getBreadcrumbs, core] = mockCore(); const expectedCrumbs: ChromeBreadcrumb[] = [ - { - text: 'Crumb: ', - href: 'http://href.example.net', - }, - { - text: 'Crumb II: Son of Crumb', - href: 'http://href2.example.net', - }, + { text: 'Crumb: ', href: 'http://href.example.net' }, + { text: 'Crumb II: Son of Crumb', href: 'http://href2.example.net' }, ]; const Component = () => { @@ -46,7 +40,9 @@ describe('useBreadcrumbs', () => { const urlParams: UptimeUrlParams = getSupportedUrlParams({}); expect(JSON.stringify(getBreadcrumbs())).toEqual( - JSON.stringify([makeBaseBreadcrumb('/app/uptime', urlParams)].concat(expectedCrumbs)) + JSON.stringify( + makeBaseBreadcrumb('/app/uptime', '/app/observability', urlParams).concat(expectedCrumbs) + ) ); }); }); @@ -58,7 +54,7 @@ const mockCore: () => [() => ChromeBreadcrumb[], any] = () => { }; const core = { application: { - getUrlForApp: () => '/app/uptime', + getUrlForApp: (app: string) => (app === 'uptime' ? '/app/uptime' : '/app/observability'), navigateToUrl: jest.fn(), }, chrome: { diff --git a/x-pack/plugins/uptime/public/hooks/use_breadcrumbs.ts b/x-pack/plugins/uptime/public/hooks/use_breadcrumbs.ts index f2ec25b50332b..5ea81e579ff92 100644 --- a/x-pack/plugins/uptime/public/hooks/use_breadcrumbs.ts +++ b/x-pack/plugins/uptime/public/hooks/use_breadcrumbs.ts @@ -36,34 +36,52 @@ function handleBreadcrumbClick( })); } -export const makeBaseBreadcrumb = (href: string, params?: UptimeUrlParams): EuiBreadcrumb => { +export const makeBaseBreadcrumb = ( + uptimePath: string, + observabilityPath: string, + params?: UptimeUrlParams +): [EuiBreadcrumb, EuiBreadcrumb] => { if (params) { const crumbParams: Partial = { ...params }; delete crumbParams.statusFilter; const query = stringifyUrlParams(crumbParams, true); - href += query === EMPTY_QUERY ? '' : query; + uptimePath += query === EMPTY_QUERY ? '' : query; } - return { - text: i18n.translate('xpack.uptime.breadcrumbs.overviewBreadcrumbText', { - defaultMessage: 'Uptime', - }), - href, - }; + + return [ + { + text: i18n.translate('xpack.uptime.breadcrumbs.observabilityText', { + defaultMessage: 'Observability', + }), + href: observabilityPath, + }, + { + text: i18n.translate('xpack.uptime.breadcrumbs.overviewBreadcrumbText', { + defaultMessage: 'Uptime', + }), + href: uptimePath, + }, + ]; }; export const useBreadcrumbs = (extraCrumbs: ChromeBreadcrumb[]) => { const params = useUrlParams()[0](); const kibana = useKibana(); const setBreadcrumbs = kibana.services.chrome?.setBreadcrumbs; - const appPath = kibana.services.application?.getUrlForApp(PLUGIN.ID) ?? ''; + const uptimePath = kibana.services.application?.getUrlForApp(PLUGIN.ID) ?? ''; + const observabilityPath = + kibana.services.application?.getUrlForApp('observability-overview') ?? ''; const navigate = kibana.services.application?.navigateToUrl; useEffect(() => { if (setBreadcrumbs) { setBreadcrumbs( - handleBreadcrumbClick([makeBaseBreadcrumb(appPath, params)].concat(extraCrumbs), navigate) + handleBreadcrumbClick( + makeBaseBreadcrumb(uptimePath, observabilityPath, params).concat(extraCrumbs), + navigate + ) ); } - }, [appPath, extraCrumbs, navigate, params, setBreadcrumbs]); + }, [uptimePath, observabilityPath, extraCrumbs, navigate, params, setBreadcrumbs]); }; diff --git a/x-pack/plugins/uptime/public/lib/helper/rtl_helpers.tsx b/x-pack/plugins/uptime/public/lib/helper/rtl_helpers.tsx index a84209a23449a..0c2e31589bb10 100644 --- a/x-pack/plugins/uptime/public/lib/helper/rtl_helpers.tsx +++ b/x-pack/plugins/uptime/public/lib/helper/rtl_helpers.tsx @@ -79,6 +79,12 @@ const createMockStore = () => { }; }; +const mockAppUrls: Record = { + uptime: '/app/uptime', + observability: '/app/observability', + '/home#/tutorial/uptimeMonitors': '/home#/tutorial/uptimeMonitors', +}; + /* default mock core */ const defaultCore = coreMock.createStart(); const mockCore: () => Partial = () => { @@ -86,7 +92,7 @@ const mockCore: () => Partial = () => { ...defaultCore, application: { ...defaultCore.application, - getUrlForApp: () => '/app/uptime', + getUrlForApp: (app: string) => mockAppUrls[app], navigateToUrl: jest.fn(), capabilities: { ...defaultCore.application.capabilities, diff --git a/x-pack/plugins/uptime/public/pages/certificates.tsx b/x-pack/plugins/uptime/public/pages/certificates.tsx index 7c21493dbde06..4b8617d594547 100644 --- a/x-pack/plugins/uptime/public/pages/certificates.tsx +++ b/x-pack/plugins/uptime/public/pages/certificates.tsx @@ -5,15 +5,14 @@ * 2.0. */ -import { useDispatch, useSelector } from 'react-redux'; -import { EuiPanel, EuiSpacer, EuiTitle } from '@elastic/eui'; +import { useDispatch } from 'react-redux'; +import { EuiSpacer } from '@elastic/eui'; import React, { useContext, useEffect, useState } from 'react'; -import { FormattedMessage } from '@kbn/i18n/react'; import { useTrackPageview } from '../../../observability/public'; import { useBreadcrumbs } from '../hooks/use_breadcrumbs'; import { getDynamicSettings } from '../state/actions/dynamic_settings'; import { UptimeRefreshContext } from '../contexts'; -import { certificatesSelector, getCertificatesAction } from '../state/certificates/certificates'; +import { getCertificatesAction } from '../state/certificates/certificates'; import { CertificateList, CertificateSearch, CertSort } from '../components/certificates'; const DEFAULT_PAGE_SIZE = 10; @@ -58,22 +57,8 @@ export const CertificatesPage: React.FC = () => { ); }, [dispatch, page, search, sort.direction, sort.field, lastRefresh]); - const { data: certificates } = useSelector(certificatesSelector); - return ( - - -

- {certificates?.total ?? 0}, - }} - /> -

-
- + <> @@ -86,6 +71,6 @@ export const CertificatesPage: React.FC = () => { }} sort={sort} /> -
+ ); }; diff --git a/x-pack/plugins/uptime/public/pages/overview.tsx b/x-pack/plugins/uptime/public/pages/overview.tsx index 846698bc390db..626e797bd9fd1 100644 --- a/x-pack/plugins/uptime/public/pages/overview.tsx +++ b/x-pack/plugins/uptime/public/pages/overview.tsx @@ -15,6 +15,7 @@ import { MonitorList } from '../components/overview/monitor_list/monitor_list_co import { EmptyState, FilterGroup } from '../components/overview'; import { StatusPanel } from '../components/overview/status_panel'; import { QueryBar } from '../components/overview/query_bar/query_bar'; +import { MONITORING_OVERVIEW_LABEL } from '../routes'; const EuiFlexItemStyled = styled(EuiFlexItem)` && { @@ -32,7 +33,7 @@ export const OverviewPageComponent = () => { useTrackPageview({ app: 'uptime', path: 'overview' }); useTrackPageview({ app: 'uptime', path: 'overview', delay: 15000 }); - useBreadcrumbs([]); // No extra breadcrumbs on overview + useBreadcrumbs([{ text: MONITORING_OVERVIEW_LABEL }]); // No extra breadcrumbs on overview return ( diff --git a/x-pack/plugins/uptime/public/pages/settings.tsx b/x-pack/plugins/uptime/public/pages/settings.tsx index f806ebbd09cc3..5f2699240425a 100644 --- a/x-pack/plugins/uptime/public/pages/settings.tsx +++ b/x-pack/plugins/uptime/public/pages/settings.tsx @@ -148,73 +148,71 @@ export const SettingsPage: React.FC = () => { ); return ( - <> - - - {cannotEditNotice} - - - -
- - - - - - - - - { - resetForm(); - }} - > - - - - - - - - - - -
-
-
-
- + + + {cannotEditNotice} + + + +
+ + + + + + + + + { + resetForm(); + }} + > + + + + + + + + + + +
+
+
+
); }; diff --git a/x-pack/plugins/uptime/public/pages/synthetics/synthetics_checks.tsx b/x-pack/plugins/uptime/public/pages/synthetics/synthetics_checks.tsx index edfd7ae24f91b..fe41e72fa4c48 100644 --- a/x-pack/plugins/uptime/public/pages/synthetics/synthetics_checks.tsx +++ b/x-pack/plugins/uptime/public/pages/synthetics/synthetics_checks.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiTitle } from '@elastic/eui'; +import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; import { useTrackPageview } from '../../../../observability/public'; import { useInitApp } from '../../hooks/use_init_app'; import { StepsList } from '../../components/synthetics/check_steps/steps_list'; @@ -14,6 +14,7 @@ import { useCheckSteps } from '../../components/synthetics/check_steps/use_check import { ChecksNavigation } from './checks_navigation'; import { useMonitorBreadcrumb } from '../../components/monitor/synthetics/step_detail/use_monitor_breadcrumb'; import { EmptyJourney } from '../../components/synthetics/empty_journey'; +import { ClientPluginsStart } from '../../apps/plugin'; export const SyntheticsCheckSteps: React.FC = () => { useInitApp(); @@ -24,21 +25,22 @@ export const SyntheticsCheckSteps: React.FC = () => { useMonitorBreadcrumb({ details, activeStep: details?.journey }); + const { + services: { observability }, + } = useKibana(); + const PageTemplateComponent = observability.navigation.PageTemplate; + return ( - <> - - - -

{details?.journey?.monitor.name || details?.journey?.monitor.id}

-
-
- - {details && } - -
- + : null, + ], + }} + > {(!steps || steps.length === 0) && !loading && } - + ); }; diff --git a/x-pack/plugins/uptime/public/routes.tsx b/x-pack/plugins/uptime/public/routes.tsx index 1c025edd0a73d..192b5552fea40 100644 --- a/x-pack/plugins/uptime/public/routes.tsx +++ b/x-pack/plugins/uptime/public/routes.tsx @@ -6,8 +6,9 @@ */ import React, { FC, useEffect } from 'react'; -import { Route, RouteComponentProps, Switch } from 'react-router-dom'; -import { Props as PageHeaderProps, PageHeader } from './components/common/header/page_header'; +import { Route, Switch } from 'react-router-dom'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; import { CERTIFICATES_ROUTE, MONITOR_ROUTE, @@ -21,6 +22,13 @@ import { CertificatesPage } from './pages/certificates'; import { UptimePage, useUptimeTelemetry } from './hooks'; import { OverviewPageComponent } from './pages/overview'; import { SyntheticsCheckSteps } from './pages/synthetics/synthetics_checks'; +import { ClientPluginsStart } from './apps/plugin'; +import { MonitorPageTitle } from './components/monitor/monitor_title'; +import { UptimeDatePicker } from './components/common/uptime_date_picker'; +import { useKibana } from '../../../../src/plugins/kibana_react/public'; +import { CertRefreshBtn } from './components/certificates/cert_refresh_btn'; +import { CertificateTitle } from './components/certificates/certificate_title'; +import { SyntheticsCallout } from './components/overview/synthetics_callout'; interface RouteProps { path: string; @@ -28,11 +36,15 @@ interface RouteProps { dataTestSubj: string; title: string; telemetryId: UptimePage; - headerProps?: PageHeaderProps; + pageHeader?: { pageTitle: string | JSX.Element; rightSideItems?: JSX.Element[] }; } const baseTitle = 'Uptime - Kibana'; +export const MONITORING_OVERVIEW_LABEL = i18n.translate('xpack.uptime.overview.heading', { + defaultMessage: 'Monitoring overview', +}); + const Routes: RouteProps[] = [ { title: `Monitor | ${baseTitle}`, @@ -40,9 +52,9 @@ const Routes: RouteProps[] = [ component: MonitorPage, dataTestSubj: 'uptimeMonitorPage', telemetryId: UptimePage.Monitor, - headerProps: { - showDatePicker: true, - showMonitorTitle: true, + pageHeader: { + pageTitle: , + rightSideItems: [], }, }, { @@ -51,8 +63,10 @@ const Routes: RouteProps[] = [ component: SettingsPage, dataTestSubj: 'uptimeSettingsPage', telemetryId: UptimePage.Settings, - headerProps: { - showTabs: true, + pageHeader: { + pageTitle: ( + + ), }, }, { @@ -61,9 +75,9 @@ const Routes: RouteProps[] = [ component: CertificatesPage, dataTestSubj: 'uptimeCertificatesPage', telemetryId: UptimePage.Certificates, - headerProps: { - showCertificateRefreshBtn: true, - showTabs: true, + pageHeader: { + pageTitle: , + rightSideItems: [], }, }, { @@ -86,9 +100,9 @@ const Routes: RouteProps[] = [ component: OverviewPageComponent, dataTestSubj: 'uptimeOverviewPage', telemetryId: UptimePage.Overview, - headerProps: { - showDatePicker: true, - showTabs: true, + pageHeader: { + pageTitle: MONITORING_OVERVIEW_LABEL, + rightSideItems: [], }, }, ]; @@ -106,31 +120,31 @@ const RouteInit: React.FC> = }; export const PageRouter: FC = () => { + const { + services: { observability }, + } = useKibana(); + const PageTemplateComponent = observability.navigation.PageTemplate; + return ( - <> - {/* Independent page header route that matches all paths and passes appropriate header props */} - {/* Prevents the header from being remounted on route changes */} - route.path)]} - exact={true} - render={({ match }: RouteComponentProps) => { - const routeProps: RouteProps | undefined = Routes.find( - (route: RouteProps) => route?.path === match?.path - ); - return routeProps?.headerProps && ; - }} - /> - - {Routes.map(({ title, path, component: RouteComponent, dataTestSubj, telemetryId }) => ( + + {Routes.map( + ({ title, path, component: RouteComponent, dataTestSubj, telemetryId, pageHeader }) => (
+ - + {pageHeader ? ( + + + + ) : ( + + )}
- ))} - -
- + ) + )} + +
); }; diff --git a/x-pack/test/functional/services/uptime/certificates.ts b/x-pack/test/functional/services/uptime/certificates.ts index 498e18de8e281..3a560acee52d8 100644 --- a/x-pack/test/functional/services/uptime/certificates.ts +++ b/x-pack/test/functional/services/uptime/certificates.ts @@ -11,6 +11,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export function UptimeCertProvider({ getService, getPageObjects }: FtrProviderContext) { const testSubjects = getService('testSubjects'); const retry = getService('retry'); + const find = getService('find'); const PageObjects = getPageObjects(['common', 'timePicker', 'header']); @@ -27,7 +28,7 @@ export function UptimeCertProvider({ getService, getPageObjects }: FtrProviderCo return { async hasViewCertButton() { return retry.tryForTime(15000, async () => { - await testSubjects.existOrFail('uptimeCertificatesLink'); + await find.existsByCssSelector('[href="/app/uptime/certificates"]'); }); }, async certificateExists(cert: { certId: string; monitorId: string }) { diff --git a/x-pack/test/functional/services/uptime/navigation.ts b/x-pack/test/functional/services/uptime/navigation.ts index b76d68e1eb454..51806d1006ab4 100644 --- a/x-pack/test/functional/services/uptime/navigation.ts +++ b/x-pack/test/functional/services/uptime/navigation.ts @@ -10,6 +10,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export function UptimeNavigationProvider({ getService, getPageObjects }: FtrProviderContext) { const retry = getService('retry'); const testSubjects = getService('testSubjects'); + const find = getService('find'); const PageObjects = getPageObjects(['common', 'timePicker', 'header']); const goToUptimeRoot = async () => { @@ -70,8 +71,8 @@ export function UptimeNavigationProvider({ getService, getPageObjects }: FtrProv goToCertificates: async () => { if (!(await testSubjects.exists('uptimeCertificatesPage', { timeout: 0 }))) { return retry.try(async () => { - if (await testSubjects.exists('uptimeCertificatesLink', { timeout: 0 })) { - await testSubjects.click('uptimeCertificatesLink', 10000); + if (await find.existsByCssSelector('[href="/app/uptime/certificates"]', 0)) { + await find.clickByCssSelector('[href="/app/uptime/certificates"]'); } await testSubjects.existOrFail('uptimeCertificatesPage'); }); From f5df40a5a1fa4b744ee35c2a0ae44e2ffa6ebb16 Mon Sep 17 00:00:00 2001 From: spalger Date: Wed, 19 May 2021 09:34:14 -0700 Subject: [PATCH 22/90] skip flaky suite (#99581) --- x-pack/test/functional/apps/spaces/spaces_selection.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/functional/apps/spaces/spaces_selection.ts b/x-pack/test/functional/apps/spaces/spaces_selection.ts index 99efdf29eecb9..f3d3665bf9f61 100644 --- a/x-pack/test/functional/apps/spaces/spaces_selection.ts +++ b/x-pack/test/functional/apps/spaces/spaces_selection.ts @@ -22,7 +22,8 @@ export default function spaceSelectorFunctionalTests({ 'spaceSelector', ]); - describe('Spaces', function () { + // FLAKY: https://github.com/elastic/kibana/issues/99581 + describe.skip('Spaces', function () { this.tags('includeFirefox'); describe('Space Selector', () => { before(async () => { From b8c127c18fbf70ba0c6b73f9f86bcef5d712beee Mon Sep 17 00:00:00 2001 From: ymao1 Date: Thu, 3 Jun 2021 09:35:27 -0400 Subject: [PATCH 23/90] Fixing pagerduty server side functionality (#101091) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../builtin_action_types/pagerduty.test.ts | 206 +++++++++++++++++- .../server/builtin_action_types/pagerduty.ts | 17 +- 2 files changed, 213 insertions(+), 10 deletions(-) diff --git a/x-pack/plugins/actions/server/builtin_action_types/pagerduty.test.ts b/x-pack/plugins/actions/server/builtin_action_types/pagerduty.test.ts index 93c5dd4a44db0..7540785714bcd 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/pagerduty.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/pagerduty.test.ts @@ -152,14 +152,15 @@ describe('validateParams()', () => { `); }); - test('should validate and throw error when timestamp has spaces', () => { + test('should validate and pass when valid timestamp has spaces', () => { const randoDate = new Date('1963-09-23T01:23:45Z').toISOString(); const timestamp = ` ${randoDate}`; - expect(() => { - validateParams(actionType, { - timestamp, - }); - }).toThrowError(`error validating action params: error parsing timestamp "${timestamp}"`); + expect(validateParams(actionType, { timestamp })).toEqual({ timestamp }); + }); + + test('should validate and pass when timestamp is empty string', () => { + const timestamp = ''; + expect(validateParams(actionType, { timestamp })).toEqual({ timestamp }); }); test('should validate and throw error when timestamp is invalid', () => { @@ -409,7 +410,7 @@ describe('execute()', () => { `); }); - test('should fail when sendPagerdury throws', async () => { + test('should fail when sendPagerduty throws', async () => { const secrets = { routingKey: 'super-secret' }; const config = { apiUrl: null }; const params = {}; @@ -576,4 +577,195 @@ describe('execute()', () => { } `); }); + + test('should succeed when timestamp contains valid date and extraneous spaces', async () => { + const randoDate = new Date('1963-09-23T01:23:45Z').toISOString(); + const secrets = { + routingKey: 'super-secret', + }; + const config = { + apiUrl: 'the-api-url', + }; + const params: ActionParamsType = { + eventAction: 'trigger', + dedupKey: 'a-dedup-key', + summary: 'the summary', + source: 'the-source', + severity: 'critical', + timestamp: ` ${randoDate} `, + component: 'the-component', + group: 'the-group', + class: 'the-class', + }; + + postPagerdutyMock.mockImplementation(() => { + return { status: 202, data: 'data-here' }; + }); + + const actionId = 'some-action-id'; + const executorOptions: PagerDutyActionTypeExecutorOptions = { + actionId, + config, + params, + secrets, + services, + }; + const actionResponse = await actionType.executor(executorOptions); + const { apiUrl, data, headers } = postPagerdutyMock.mock.calls[0][0]; + expect({ apiUrl, data, headers }).toMatchInlineSnapshot(` + Object { + "apiUrl": "the-api-url", + "data": Object { + "dedup_key": "a-dedup-key", + "event_action": "trigger", + "payload": Object { + "class": "the-class", + "component": "the-component", + "group": "the-group", + "severity": "critical", + "source": "the-source", + "summary": "the summary", + "timestamp": "1963-09-23T01:23:45.000Z", + }, + }, + "headers": Object { + "Content-Type": "application/json", + "X-Routing-Key": "super-secret", + }, + } + `); + expect(actionResponse).toMatchInlineSnapshot(` + Object { + "actionId": "some-action-id", + "data": "data-here", + "status": "ok", + } + `); + }); + + test('should not pass timestamp field when timestamp is empty string', async () => { + const secrets = { + routingKey: 'super-secret', + }; + const config = { + apiUrl: 'the-api-url', + }; + const params: ActionParamsType = { + eventAction: 'trigger', + dedupKey: 'a-dedup-key', + summary: 'the summary', + source: 'the-source', + severity: 'critical', + timestamp: '', + component: 'the-component', + group: 'the-group', + class: 'the-class', + }; + + postPagerdutyMock.mockImplementation(() => { + return { status: 202, data: 'data-here' }; + }); + + const actionId = 'some-action-id'; + const executorOptions: PagerDutyActionTypeExecutorOptions = { + actionId, + config, + params, + secrets, + services, + }; + const actionResponse = await actionType.executor(executorOptions); + const { apiUrl, data, headers } = postPagerdutyMock.mock.calls[0][0]; + expect({ apiUrl, data, headers }).toMatchInlineSnapshot(` + Object { + "apiUrl": "the-api-url", + "data": Object { + "dedup_key": "a-dedup-key", + "event_action": "trigger", + "payload": Object { + "class": "the-class", + "component": "the-component", + "group": "the-group", + "severity": "critical", + "source": "the-source", + "summary": "the summary", + }, + }, + "headers": Object { + "Content-Type": "application/json", + "X-Routing-Key": "super-secret", + }, + } + `); + expect(actionResponse).toMatchInlineSnapshot(` + Object { + "actionId": "some-action-id", + "data": "data-here", + "status": "ok", + } + `); + }); + + test('should not pass timestamp field when timestamp is string of spaces', async () => { + const secrets = { + routingKey: 'super-secret', + }; + const config = { + apiUrl: 'the-api-url', + }; + const params: ActionParamsType = { + eventAction: 'trigger', + dedupKey: 'a-dedup-key', + summary: 'the summary', + source: 'the-source', + severity: 'critical', + timestamp: ' ', + component: 'the-component', + group: 'the-group', + class: 'the-class', + }; + + postPagerdutyMock.mockImplementation(() => { + return { status: 202, data: 'data-here' }; + }); + + const actionId = 'some-action-id'; + const executorOptions: PagerDutyActionTypeExecutorOptions = { + actionId, + config, + params, + secrets, + services, + }; + const actionResponse = await actionType.executor(executorOptions); + const { apiUrl, data, headers } = postPagerdutyMock.mock.calls[0][0]; + expect({ apiUrl, data, headers }).toMatchInlineSnapshot(` + Object { + "apiUrl": "the-api-url", + "data": Object { + "dedup_key": "a-dedup-key", + "event_action": "trigger", + "payload": Object { + "class": "the-class", + "component": "the-component", + "group": "the-group", + "severity": "critical", + "source": "the-source", + "summary": "the summary", + }, + }, + "headers": Object { + "Content-Type": "application/json", + "X-Routing-Key": "super-secret", + }, + } + `); + expect(actionResponse).toMatchInlineSnapshot(` + Object { + "actionId": "some-action-id", + "data": "data-here", + "status": "ok", + } + `); + }); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/pagerduty.ts b/x-pack/plugins/actions/server/builtin_action_types/pagerduty.ts index b64cf6ec346d5..5d83b658111e4 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/pagerduty.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/pagerduty.ts @@ -85,11 +85,19 @@ const ParamsSchema = schema.object( { validate: validateParams } ); +function validateTimestamp(timestamp?: string): string | null { + if (timestamp) { + return timestamp.trim().length > 0 ? timestamp.trim() : null; + } + return null; +} + function validateParams(paramsObject: unknown): string | void { const { timestamp, eventAction, dedupKey } = paramsObject as ActionParamsType; - if (timestamp != null) { + const validatedTimestamp = validateTimestamp(timestamp); + if (validatedTimestamp != null) { try { - const date = Date.parse(timestamp); + const date = Date.parse(validatedTimestamp); if (isNaN(date)) { return i18n.translate('xpack.actions.builtin.pagerduty.invalidTimestampErrorMessage', { defaultMessage: `error parsing timestamp "{timestamp}"`, @@ -279,11 +287,14 @@ function getBodyForEventAction(actionId: string, params: ActionParamsType): Page return data; } + const validatedTimestamp = validateTimestamp(params.timestamp); + data.payload = { summary: params.summary || 'No summary provided.', source: params.source || `Kibana Action ${actionId}`, severity: params.severity || 'info', - ...omitBy(pick(params, ['timestamp', 'component', 'group', 'class']), isUndefined), + ...(validatedTimestamp ? { timestamp: validatedTimestamp } : {}), + ...omitBy(pick(params, ['component', 'group', 'class']), isUndefined), }; return data; From de85036fc75a5dcab3d4074f7db65e88e91aee9c Mon Sep 17 00:00:00 2001 From: Ahmad Bamieh Date: Thu, 3 Jun 2021 17:47:54 +0300 Subject: [PATCH 24/90] [Usage] Fix flaky UI Counters test (#100979) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../apis/ui_counters/ui_counters.ts | 87 ++++++++++--------- 1 file changed, 44 insertions(+), 43 deletions(-) diff --git a/test/api_integration/apis/ui_counters/ui_counters.ts b/test/api_integration/apis/ui_counters/ui_counters.ts index ab3ca2e8dd3a7..2be6ea4341fb0 100644 --- a/test/api_integration/apis/ui_counters/ui_counters.ts +++ b/test/api_integration/apis/ui_counters/ui_counters.ts @@ -15,6 +15,7 @@ import { UsageCountersSavedObject } from '../../../../src/plugins/usage_collecti export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const esArchiver = getService('esArchiver'); + const retry = getService('retry'); const createUiCounterEvent = (eventName: string, type: UiCounterMetricType, count = 1) => ({ eventName, @@ -23,16 +24,24 @@ export default function ({ getService }: FtrProviderContext) { count, }); - const sendReport = async (report: Report) => { + const fetchUsageCountersObjects = async (): Promise => { + const { + body: { saved_objects: savedObjects }, + } = await supertest + .get('/api/saved_objects/_find?type=usage-counters') + .set('kbn-xsrf', 'kibana') + .expect(200); + + return savedObjects; + }; + + const sendReport = async (report: Report): Promise => { await supertest .post('/api/ui_counters/_report') .set('kbn-xsrf', 'kibana') .set('content-type', 'application/json') .send({ report }) .expect(200); - - // wait for SO to index data into ES - await new Promise((res) => setTimeout(res, 5 * 1000)); }; const getCounterById = ( @@ -47,8 +56,7 @@ export default function ({ getService }: FtrProviderContext) { return savedObject; }; - // FLAKY: https://github.com/elastic/kibana/issues/98240 - describe.skip('UI Counters API', () => { + describe('UI Counters API', () => { const dayDate = moment().format('DDMMYYYY'); before(async () => await esArchiver.emptyKibanaIndex()); @@ -61,18 +69,15 @@ export default function ({ getService }: FtrProviderContext) { await sendReport(report); - const { - body: { saved_objects: savedObjects }, - } = await supertest - .get('/api/saved_objects/_find?type=usage-counters') - .set('kbn-xsrf', 'kibana') - .expect(200); - - const countTypeEvent = getCounterById( - savedObjects, - `uiCounter:${dayDate}:${METRIC_TYPE.COUNT}:myApp:my_event` - ); - expect(countTypeEvent.attributes.count).to.eql(1); + await retry.waitForWithTimeout('reported events to be stored into ES', 8000, async () => { + const savedObjects = await fetchUsageCountersObjects(); + const countTypeEvent = getCounterById( + savedObjects, + `uiCounter:${dayDate}:${METRIC_TYPE.COUNT}:myApp:my_event` + ); + expect(countTypeEvent.attributes.count).to.eql(1); + return true; + }); }); it('supports multiple events', async () => { @@ -87,31 +92,27 @@ export default function ({ getService }: FtrProviderContext) { ]); await sendReport(report); - - const { - body: { saved_objects: savedObjects }, - } = await supertest - .get('/api/saved_objects/_find?type=usage-counters&fields=count') - .set('kbn-xsrf', 'kibana') - .expect(200); - - const countTypeEvent = getCounterById( - savedObjects, - `uiCounter:${dayDate}:${METRIC_TYPE.COUNT}:myApp:${uniqueEventName}` - ); - expect(countTypeEvent.attributes.count).to.eql(1); - - const clickTypeEvent = getCounterById( - savedObjects, - `uiCounter:${dayDate}:${METRIC_TYPE.CLICK}:myApp:${uniqueEventName}` - ); - expect(clickTypeEvent.attributes.count).to.eql(2); - - const secondEvent = getCounterById( - savedObjects, - `uiCounter:${dayDate}:${METRIC_TYPE.COUNT}:myApp:${uniqueEventName}_2` - ); - expect(secondEvent.attributes.count).to.eql(1); + await retry.waitForWithTimeout('reported events to be stored into ES', 8000, async () => { + const savedObjects = await fetchUsageCountersObjects(); + const countTypeEvent = getCounterById( + savedObjects, + `uiCounter:${dayDate}:${METRIC_TYPE.COUNT}:myApp:${uniqueEventName}` + ); + expect(countTypeEvent.attributes.count).to.eql(1); + + const clickTypeEvent = getCounterById( + savedObjects, + `uiCounter:${dayDate}:${METRIC_TYPE.CLICK}:myApp:${uniqueEventName}` + ); + expect(clickTypeEvent.attributes.count).to.eql(2); + + const secondEvent = getCounterById( + savedObjects, + `uiCounter:${dayDate}:${METRIC_TYPE.COUNT}:myApp:${uniqueEventName}_2` + ); + expect(secondEvent.attributes.count).to.eql(1); + return true; + }); }); }); } From 58b1416f845cdcdfa615533d8c09bbc30a4624a1 Mon Sep 17 00:00:00 2001 From: Oleksiy Kovyrin Date: Thu, 3 Jun 2021 10:58:11 -0400 Subject: [PATCH 25/90] Enterpise Search SSL Settings Support (#100946) Introduce a new set of SSL configuration settings for Enterprise Search plugin, allowing users to configure a set of custom certificate authorities and to control TLS validation mode used for all requests to Enterprise Search. Co-authored-by: Byron Hulcher Co-authored-by: Constance Chen --- .../server/__mocks__/http_agent.mock.ts | 14 +++ .../server/__mocks__/index.ts | 2 + .../__mocks__/routerDependencies.mock.ts | 1 + .../plugins/enterprise_search/server/index.ts | 9 ++ .../lib/enterprise_search_config_api.test.ts | 1 + .../lib/enterprise_search_config_api.ts | 9 +- .../lib/enterprise_search_http_agent.test.ts | 118 ++++++++++++++++++ .../lib/enterprise_search_http_agent.ts | 85 +++++++++++++ .../enterprise_search_request_handler.test.ts | 3 +- .../lib/enterprise_search_request_handler.ts | 15 ++- .../enterprise_search/server/plugin.ts | 6 + 11 files changed, 255 insertions(+), 8 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/server/__mocks__/http_agent.mock.ts create mode 100644 x-pack/plugins/enterprise_search/server/lib/enterprise_search_http_agent.test.ts create mode 100644 x-pack/plugins/enterprise_search/server/lib/enterprise_search_http_agent.ts diff --git a/x-pack/plugins/enterprise_search/server/__mocks__/http_agent.mock.ts b/x-pack/plugins/enterprise_search/server/__mocks__/http_agent.mock.ts new file mode 100644 index 0000000000000..1e9b04674b582 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/__mocks__/http_agent.mock.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const mockHttpAgent = jest.fn(); + +jest.mock('../lib/enterprise_search_http_agent', () => ({ + entSearchHttpAgent: { + getHttpAgent: () => mockHttpAgent, + }, +})); diff --git a/x-pack/plugins/enterprise_search/server/__mocks__/index.ts b/x-pack/plugins/enterprise_search/server/__mocks__/index.ts index c36acd2b57647..c59a5a8f67e32 100644 --- a/x-pack/plugins/enterprise_search/server/__mocks__/index.ts +++ b/x-pack/plugins/enterprise_search/server/__mocks__/index.ts @@ -12,3 +12,5 @@ export { mockRequestHandler, mockDependencies, } from './routerDependencies.mock'; + +export { mockHttpAgent } from './http_agent.mock'; diff --git a/x-pack/plugins/enterprise_search/server/__mocks__/routerDependencies.mock.ts b/x-pack/plugins/enterprise_search/server/__mocks__/routerDependencies.mock.ts index 50ff082858fc8..08be1a134ae02 100644 --- a/x-pack/plugins/enterprise_search/server/__mocks__/routerDependencies.mock.ts +++ b/x-pack/plugins/enterprise_search/server/__mocks__/routerDependencies.mock.ts @@ -23,6 +23,7 @@ export const mockConfig = { host: 'http://localhost:3002', accessCheckTimeout: 5000, accessCheckTimeoutWarning: 300, + ssl: {}, } as ConfigType; /** diff --git a/x-pack/plugins/enterprise_search/server/index.ts b/x-pack/plugins/enterprise_search/server/index.ts index c4552b9134eae..ecd068c8bdbd9 100644 --- a/x-pack/plugins/enterprise_search/server/index.ts +++ b/x-pack/plugins/enterprise_search/server/index.ts @@ -19,6 +19,15 @@ export const configSchema = schema.object({ enabled: schema.boolean({ defaultValue: true }), accessCheckTimeout: schema.number({ defaultValue: 5000 }), accessCheckTimeoutWarning: schema.number({ defaultValue: 300 }), + ssl: schema.object({ + certificateAuthorities: schema.maybe( + schema.oneOf([schema.arrayOf(schema.string(), { minSize: 1 }), schema.string()]) + ), + verificationMode: schema.oneOf( + [schema.literal('none'), schema.literal('certificate'), schema.literal('full')], + { defaultValue: 'full' } + ), + }), }); export type ConfigType = TypeOf; diff --git a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.test.ts b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.test.ts index 66f2bf78e0c9c..50bac793ee696 100644 --- a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.test.ts +++ b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.test.ts @@ -6,6 +6,7 @@ */ import { DEFAULT_INITIAL_APP_DATA } from '../../common/__mocks__'; +import '../__mocks__/http_agent.mock.ts'; jest.mock('node-fetch'); import fetch from 'node-fetch'; diff --git a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.ts b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.ts index 0f2faf1fd8a3a..8cce01d1932ee 100644 --- a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.ts +++ b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.ts @@ -16,6 +16,8 @@ import { stripTrailingSlash } from '../../common/strip_slashes'; import { InitialAppData } from '../../common/types'; import { ConfigType } from '../index'; +import { entSearchHttpAgent } from './enterprise_search_http_agent'; + interface Params { request: KibanaRequest; config: ConfigType; @@ -54,10 +56,13 @@ export const callEnterpriseSearchConfigAPI = async ({ try { const enterpriseSearchUrl = encodeURI(`${config.host}${ENDPOINT}`); - const response = await fetch(enterpriseSearchUrl, { + const options = { headers: { Authorization: request.headers.authorization as string }, signal: controller.signal, - }); + agent: entSearchHttpAgent.getHttpAgent(), + }; + + const response = await fetch(enterpriseSearchUrl, options); const data = await response.json(); warnMismatchedVersions(data?.version?.number, log); diff --git a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_http_agent.test.ts b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_http_agent.test.ts new file mode 100644 index 0000000000000..f4bdfd8d2cb0f --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_http_agent.test.ts @@ -0,0 +1,118 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +jest.mock('fs', () => ({ readFileSync: jest.fn() })); +import { readFileSync } from 'fs'; + +import http from 'http'; +import https from 'https'; + +import { ConfigType } from '../'; + +import { entSearchHttpAgent } from './enterprise_search_http_agent'; + +describe('entSearchHttpAgent', () => { + describe('initializeHttpAgent', () => { + it('creates an https.Agent when host URL is using HTTPS', () => { + entSearchHttpAgent.initializeHttpAgent({ + host: 'https://example.org', + ssl: {}, + } as ConfigType); + expect(entSearchHttpAgent.getHttpAgent()).toBeInstanceOf(https.Agent); + }); + + it('creates an http.Agent when host URL is using HTTP', () => { + entSearchHttpAgent.initializeHttpAgent({ + host: 'http://example.org', + ssl: {}, + } as ConfigType); + expect(entSearchHttpAgent.getHttpAgent()).toBeInstanceOf(http.Agent); + }); + + describe('fallbacks', () => { + it('initializes a http.Agent when host URL is invalid', () => { + entSearchHttpAgent.initializeHttpAgent({ + host: '##!notarealurl#$', + ssl: {}, + } as ConfigType); + expect(entSearchHttpAgent.getHttpAgent()).toBeInstanceOf(http.Agent); + }); + + it('should be an http.Agent when host URL is empty', () => { + entSearchHttpAgent.initializeHttpAgent({ + host: undefined, + ssl: {}, + } as ConfigType); + expect(entSearchHttpAgent.getHttpAgent()).toBeInstanceOf(http.Agent); + }); + }); + }); + + describe('loadCertificateAuthorities', () => { + describe('happy path', () => { + beforeEach(() => { + jest.clearAllMocks(); + (readFileSync as jest.Mock).mockImplementation((path: string) => `content-of-${path}`); + }); + + it('reads certificate authorities when ssl.certificateAuthorities is a string', () => { + const certs = entSearchHttpAgent.loadCertificateAuthorities('some-path'); + expect(readFileSync).toHaveBeenCalledTimes(1); + expect(certs).toEqual(['content-of-some-path']); + }); + + it('reads certificate authorities when ssl.certificateAuthorities is an array', () => { + const certs = entSearchHttpAgent.loadCertificateAuthorities(['some-path', 'another-path']); + expect(readFileSync).toHaveBeenCalledTimes(2); + expect(certs).toEqual(['content-of-some-path', 'content-of-another-path']); + }); + + it('does not read anything when ssl.certificateAuthorities is empty', () => { + const certs = entSearchHttpAgent.loadCertificateAuthorities(undefined); + expect(readFileSync).toHaveBeenCalledTimes(0); + expect(certs).toEqual([]); + }); + }); + + describe('error handling', () => { + beforeEach(() => { + const realFs = jest.requireActual('fs'); + (readFileSync as jest.Mock).mockImplementation((path: string) => realFs.readFileSync(path)); + }); + + it('throws if certificateAuthorities is invalid', () => { + expect(() => entSearchHttpAgent.loadCertificateAuthorities('/invalid/ca')).toThrow( + "ENOENT: no such file or directory, open '/invalid/ca'" + ); + }); + }); + }); + + describe('getAgentOptions', () => { + it('verificationMode: none', () => { + expect(entSearchHttpAgent.getAgentOptions('none')).toEqual({ + rejectUnauthorized: false, + }); + }); + + it('verificationMode: certificate', () => { + expect(entSearchHttpAgent.getAgentOptions('certificate')).toEqual({ + rejectUnauthorized: true, + checkServerIdentity: expect.any(Function), + }); + + const { checkServerIdentity } = entSearchHttpAgent.getAgentOptions('certificate') as any; + expect(checkServerIdentity()).toEqual(undefined); + }); + + it('verificationMode: full', () => { + expect(entSearchHttpAgent.getAgentOptions('full')).toEqual({ + rejectUnauthorized: true, + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_http_agent.ts b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_http_agent.ts new file mode 100644 index 0000000000000..89210def248b3 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_http_agent.ts @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { readFileSync } from 'fs'; +import http from 'http'; +import https from 'https'; +import { PeerCertificate } from 'tls'; + +import { ConfigType } from '../'; + +export type HttpAgent = http.Agent | https.Agent; +interface AgentOptions { + rejectUnauthorized?: boolean; + checkServerIdentity?: ((host: string, cert: PeerCertificate) => Error | undefined) | undefined; +} + +/* + * Returns an HTTP agent to be used for requests to Enterprise Search APIs + */ +class EnterpriseSearchHttpAgent { + public httpAgent: HttpAgent = new http.Agent(); + + getHttpAgent() { + return this.httpAgent; + } + + initializeHttpAgent(config: ConfigType) { + if (!config.host) return; + + try { + const parsedHost = new URL(config.host); + if (parsedHost.protocol === 'https:') { + this.httpAgent = new https.Agent({ + ca: this.loadCertificateAuthorities(config.ssl.certificateAuthorities), + ...this.getAgentOptions(config.ssl.verificationMode), + }); + } + } catch { + // Ignore URL parsing errors and fall back to the HTTP agent + } + } + + /* + * Loads custom CA certificate files and returns all certificates as an array + * This is a potentially expensive operation & why this helper is a class + * initialized once on plugin init + */ + loadCertificateAuthorities(certificates: string | string[] | undefined): string[] { + if (!certificates) return []; + + const paths = Array.isArray(certificates) ? certificates : [certificates]; + return paths.map((path) => readFileSync(path, 'utf8')); + } + + /* + * Convert verificationMode to rejectUnauthorized for more consistent config settings + * with the rest of Kibana + * @see https://github.com/elastic/kibana/blob/master/x-pack/plugins/actions/server/builtin_action_types/lib/get_node_tls_options.ts + */ + getAgentOptions(verificationMode: 'full' | 'certificate' | 'none') { + const agentOptions: AgentOptions = {}; + + switch (verificationMode) { + case 'none': + agentOptions.rejectUnauthorized = false; + break; + case 'certificate': + agentOptions.rejectUnauthorized = true; + agentOptions.checkServerIdentity = () => undefined; + break; + case 'full': + default: + agentOptions.rejectUnauthorized = true; + break; + } + + return agentOptions; + } +} + +export const entSearchHttpAgent = new EnterpriseSearchHttpAgent(); diff --git a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.test.ts b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.test.ts index 3223471e4fc1a..6ebf46abd39d3 100644 --- a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.test.ts +++ b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { mockConfig, mockLogger } from '../__mocks__'; +import { mockConfig, mockLogger, mockHttpAgent } from '../__mocks__'; import { ENTERPRISE_SEARCH_KIBANA_COOKIE, @@ -476,6 +476,7 @@ const EnterpriseSearchAPI = { headers: { Authorization: 'Basic 123', ...JSON_HEADER }, method: 'GET', body: undefined, + agent: mockHttpAgent, ...expectedParams, }); }, diff --git a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.ts b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.ts index 2fc0a13f2ff72..597f7524808e9 100644 --- a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.ts +++ b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.ts @@ -16,13 +16,15 @@ import { Logger, } from 'src/core/server'; +import { ConfigType } from '../'; + import { ENTERPRISE_SEARCH_KIBANA_COOKIE, JSON_HEADER, READ_ONLY_MODE_HEADER, } from '../../common/constants'; -import { ConfigType } from '../index'; +import { entSearchHttpAgent } from './enterprise_search_http_agent'; interface ConstructorDependencies { config: ConfigType; @@ -77,12 +79,15 @@ export class EnterpriseSearchRequestHandler { const url = encodeURI(this.enterpriseSearchUrl) + encodedPath + queryString; // Set up API options - const { method } = request.route; - const headers = { Authorization: request.headers.authorization as string, ...JSON_HEADER }; - const body = this.getBodyAsString(request.body as object | Buffer); + const options = { + method: request.route.method as string, + headers: { Authorization: request.headers.authorization as string, ...JSON_HEADER }, + body: this.getBodyAsString(request.body as object | Buffer), + agent: entSearchHttpAgent.getHttpAgent(), + }; // Call the Enterprise Search API - const apiResponse = await fetch(url, { method, headers, body }); + const apiResponse = await fetch(url, options); // Handle response headers this.setResponseHeaders(apiResponse); diff --git a/x-pack/plugins/enterprise_search/server/plugin.ts b/x-pack/plugins/enterprise_search/server/plugin.ts index 1b9659899097d..04bd304ee679f 100644 --- a/x-pack/plugins/enterprise_search/server/plugin.ts +++ b/x-pack/plugins/enterprise_search/server/plugin.ts @@ -31,6 +31,7 @@ import { registerTelemetryUsageCollector as registerESTelemetryUsageCollector } import { registerTelemetryUsageCollector as registerWSTelemetryUsageCollector } from './collectors/workplace_search/telemetry'; import { checkAccess } from './lib/check_access'; +import { entSearchHttpAgent } from './lib/enterprise_search_http_agent'; import { EnterpriseSearchRequestHandler, IEnterpriseSearchRequestHandler, @@ -81,6 +82,11 @@ export class EnterpriseSearchPlugin implements Plugin { const config = this.config; const log = this.logger; + /* + * Initialize config.ssl.certificateAuthorities file(s) - required for all API calls (+ access checks) + */ + entSearchHttpAgent.initializeHttpAgent(config); + /** * Register space/feature control */ From 9618fd7dfedf7c1b8ee869e8eb7f00fd4c2875bf Mon Sep 17 00:00:00 2001 From: Jason Stoltzfus Date: Thu, 3 Jun 2021 11:00:12 -0400 Subject: [PATCH 26/90] [App Search] Added a persistent query tester flyout (#101071) --- .../results/add_result_flyout.test.tsx | 4 +- .../curation/results/add_result_flyout.tsx | 9 +- .../curation/results/add_result_logic.test.ts | 80 +------------ .../curation/results/add_result_logic.ts | 50 -------- .../layout/kibana_header_actions.test.tsx | 6 +- .../layout/kibana_header_actions.tsx | 10 +- .../components/query_tester/i18n.ts | 15 +++ .../components/query_tester/index.ts | 9 ++ .../query_tester/query_tester.test.tsx | 66 +++++++++++ .../components/query_tester/query_tester.tsx | 66 +++++++++++ .../query_tester/query_tester_button.test.tsx | 35 ++++++ .../query_tester/query_tester_button.tsx | 30 +++++ .../query_tester/query_tester_flyout.test.tsx | 25 ++++ .../query_tester/query_tester_flyout.tsx | 32 ++++++ .../app_search/components/search/index.ts | 8 ++ .../components/search/search_logic.test.ts | 108 ++++++++++++++++++ .../components/search/search_logic.ts | 73 ++++++++++++ .../routes/app_search/curations.test.ts | 35 ------ .../server/routes/app_search/index.ts | 2 + .../server/routes/app_search/search.test.ts | 35 ++++++ .../server/routes/app_search/search.ts | 39 +++++++ 21 files changed, 558 insertions(+), 179 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/i18n.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/query_tester.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/query_tester.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/query_tester_button.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/query_tester_button.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/query_tester_flyout.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/query_tester_flyout.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/search/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/search/search_logic.test.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/search/search_logic.ts create mode 100644 x-pack/plugins/enterprise_search/server/routes/app_search/search.test.ts create mode 100644 x-pack/plugins/enterprise_search/server/routes/app_search/search.ts diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_flyout.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_flyout.test.tsx index e12267d0eb136..a0f178aca32b2 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_flyout.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_flyout.test.tsx @@ -17,7 +17,7 @@ import { CurationResult, AddResultFlyout } from './'; describe('AddResultFlyout', () => { const values = { - dataLoading: false, + searchDataLoading: false, searchQuery: '', searchResults: [], promotedIds: [], @@ -48,7 +48,7 @@ describe('AddResultFlyout', () => { describe('search input', () => { it('renders isLoading state correctly', () => { - setMockValues({ ...values, dataLoading: true }); + setMockValues({ ...values, searchDataLoading: true }); const wrapper = shallow(); expect(wrapper.find(EuiFieldSearch).prop('isLoading')).toEqual(true); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_flyout.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_flyout.tsx index 6363919e32cc9..a20e4e137f899 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_flyout.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_flyout.tsx @@ -24,6 +24,7 @@ import { i18n } from '@kbn/i18n'; import { FlashMessages } from '../../../../../shared/flash_messages'; +import { SearchLogic } from '../../../search'; import { RESULT_ACTIONS_DIRECTIONS, PROMOTE_DOCUMENT_ACTION, @@ -36,8 +37,10 @@ import { CurationLogic } from '../curation_logic'; import { AddResultLogic, CurationResult } from './'; export const AddResultFlyout: React.FC = () => { - const { searchQuery, searchResults, dataLoading } = useValues(AddResultLogic); - const { search, closeFlyout } = useActions(AddResultLogic); + const searchLogic = SearchLogic({ id: 'add-results-flyout' }); + const { searchQuery, searchResults, searchDataLoading } = useValues(searchLogic); + const { closeFlyout } = useActions(AddResultLogic); + const { search } = useActions(searchLogic); const { promotedIds, hiddenIds } = useValues(CurationLogic); const { addPromotedId, removePromotedId, addHiddenId, removeHiddenId } = useActions( @@ -63,7 +66,7 @@ export const AddResultFlyout: React.FC = () => { search(e.target.value)} - isLoading={dataLoading} + isLoading={searchDataLoading} placeholder={i18n.translate( 'xpack.enterpriseSearch.appSearch.engine.curations.addResult.searchPlaceholder', { defaultMessage: 'Search engine documents' } diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_logic.test.ts index a722ab96fc574..e7007cdc093cb 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_logic.test.ts @@ -5,31 +5,16 @@ * 2.0. */ -import { LogicMounter, mockHttpValues, mockFlashMessageHelpers } from '../../../../../__mocks__'; +import { LogicMounter } from '../../../../../__mocks__'; import '../../../../__mocks__/engine_logic.mock'; -import { nextTick } from '@kbn/test/jest'; - import { AddResultLogic } from './'; describe('AddResultLogic', () => { const { mount } = new LogicMounter(AddResultLogic); - const { http } = mockHttpValues; - const { flashAPIErrors } = mockFlashMessageHelpers; - - const MOCK_SEARCH_RESPONSE = { - results: [ - { id: { raw: 'document-1' }, _meta: { id: 'document-1', engine: 'some-engine' } }, - { id: { raw: 'document-2' }, _meta: { id: 'document-2', engine: 'some-engine' } }, - { id: { raw: 'document-3' }, _meta: { id: 'document-3', engine: 'some-engine' } }, - ], - }; const DEFAULT_VALUES = { isFlyoutOpen: false, - dataLoading: false, - searchQuery: '', - searchResults: [], }; beforeEach(() => { @@ -51,7 +36,6 @@ describe('AddResultLogic', () => { expect(AddResultLogic.values).toEqual({ ...DEFAULT_VALUES, isFlyoutOpen: true, - searchQuery: '', }); }); }); @@ -68,67 +52,5 @@ describe('AddResultLogic', () => { }); }); }); - - describe('search', () => { - it('sets searchQuery & dataLoading to true', () => { - mount({ searchQuery: '', dataLoading: false }); - - AddResultLogic.actions.search('hello world'); - - expect(AddResultLogic.values).toEqual({ - ...DEFAULT_VALUES, - searchQuery: 'hello world', - dataLoading: true, - }); - }); - }); - - describe('onSearch', () => { - it('sets searchResults & dataLoading to false', () => { - mount({ searchResults: [], dataLoading: true }); - - AddResultLogic.actions.onSearch(MOCK_SEARCH_RESPONSE); - - expect(AddResultLogic.values).toEqual({ - ...DEFAULT_VALUES, - searchResults: MOCK_SEARCH_RESPONSE.results, - dataLoading: false, - }); - }); - }); - }); - - describe('listeners', () => { - describe('search', () => { - beforeAll(() => jest.useFakeTimers()); - afterAll(() => jest.useRealTimers()); - - it('should make a GET API call with a search query', async () => { - http.get.mockReturnValueOnce(Promise.resolve(MOCK_SEARCH_RESPONSE)); - mount(); - jest.spyOn(AddResultLogic.actions, 'onSearch'); - - AddResultLogic.actions.search('hello world'); - jest.runAllTimers(); - await nextTick(); - - expect(http.get).toHaveBeenCalledWith( - '/api/app_search/engines/some-engine/curation_search', - { query: { query: 'hello world' } } - ); - expect(AddResultLogic.actions.onSearch).toHaveBeenCalledWith(MOCK_SEARCH_RESPONSE); - }); - - it('handles errors', async () => { - http.get.mockReturnValueOnce(Promise.reject('error')); - mount(); - - AddResultLogic.actions.search('test'); - jest.runAllTimers(); - await nextTick(); - - expect(flashAPIErrors).toHaveBeenCalledWith('error'); - }); - }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_logic.ts index 808f4c86971ee..bcf18aa9625d7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_logic.ts @@ -7,24 +7,13 @@ import { kea, MakeLogicType } from 'kea'; -import { flashAPIErrors } from '../../../../../shared/flash_messages'; -import { HttpLogic } from '../../../../../shared/http'; - -import { EngineLogic } from '../../../engine'; -import { Result } from '../../../result/types'; - interface AddResultValues { isFlyoutOpen: boolean; - dataLoading: boolean; - searchQuery: string; - searchResults: Result[]; } interface AddResultActions { openFlyout(): void; closeFlyout(): void; - search(query: string): { query: string }; - onSearch({ results }: { results: Result[] }): { results: Result[] }; } export const AddResultLogic = kea>({ @@ -32,8 +21,6 @@ export const AddResultLogic = kea ({ openFlyout: true, closeFlyout: true, - search: (query) => ({ query }), - onSearch: ({ results }) => ({ results }), }), reducers: () => ({ isFlyoutOpen: [ @@ -43,42 +30,5 @@ export const AddResultLogic = kea false, }, ], - dataLoading: [ - false, - { - search: () => true, - onSearch: () => false, - }, - ], - searchQuery: [ - '', - { - search: (_, { query }) => query, - openFlyout: () => '', - }, - ], - searchResults: [ - [], - { - onSearch: (_, { results }) => results, - }, - ], - }), - listeners: ({ actions }) => ({ - search: async ({ query }, breakpoint) => { - await breakpoint(250); - - const { http } = HttpLogic.values; - const { engineName } = EngineLogic.values; - - try { - const response = await http.get(`/api/app_search/engines/${engineName}/curation_search`, { - query: { query }, - }); - actions.onSearch(response); - } catch (e) { - flashAPIErrors(e); - } - }, }), }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/kibana_header_actions.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/kibana_header_actions.test.tsx index 21fc2b235d83c..096d858cd1191 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/kibana_header_actions.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/kibana_header_actions.test.tsx @@ -11,7 +11,7 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { EuiButtonEmpty } from '@elastic/eui'; +import { QueryTesterButton } from '../query_tester'; import { KibanaHeaderActions } from './kibana_header_actions'; @@ -27,7 +27,7 @@ describe('KibanaHeaderActions', () => { it('renders', () => { const wrapper = shallow(); - expect(wrapper.find(EuiButtonEmpty).exists()).toBe(true); + expect(wrapper.find(QueryTesterButton).exists()).toBe(true); }); it('does not render a "Query Tester" button if there is no engine available', () => { @@ -35,6 +35,6 @@ describe('KibanaHeaderActions', () => { engineName: '', }); const wrapper = shallow(); - expect(wrapper.find(EuiButtonEmpty).exists()).toBe(false); + expect(wrapper.find(QueryTesterButton).exists()).toBe(false); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/kibana_header_actions.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/kibana_header_actions.tsx index b2e810962df02..e23c8ff8f0f0c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/kibana_header_actions.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/kibana_header_actions.tsx @@ -9,10 +9,10 @@ import React from 'react'; import { useValues } from 'kea'; -import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { EngineLogic } from '../engine'; +import { QueryTesterButton } from '../query_tester'; export const KibanaHeaderActions: React.FC = () => { const { engineName } = useValues(EngineLogic); @@ -21,11 +21,7 @@ export const KibanaHeaderActions: React.FC = () => { {engineName && ( - - {i18n.translate('xpack.enterpriseSearch.appSearch.engine.queryTesterButtonLabel', { - defaultMessage: 'Query tester', - })} - + )} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/i18n.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/i18n.ts new file mode 100644 index 0000000000000..a1b1f6769beaf --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/i18n.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const QUERY_TESTER_TITLE = i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.queryTesterTitle', + { + defaultMessage: 'Query tester', + } +); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/index.ts new file mode 100644 index 0000000000000..b2b8ad0dd1255 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { QueryTesterFlyout } from './query_tester_flyout'; +export { QueryTesterButton } from './query_tester_button'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/query_tester.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/query_tester.test.tsx new file mode 100644 index 0000000000000..160be70cbbfc9 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/query_tester.test.tsx @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { setMockValues, setMockActions } from '../../../__mocks__/kea.mock'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiEmptyPrompt, EuiFieldSearch } from '@elastic/eui'; + +import { SchemaType } from '../../../shared/schema/types'; +import { Result } from '../result'; + +import { QueryTester } from './query_tester'; + +describe('QueryTester', () => { + const values = { + searchQuery: 'foo', + searchResults: [{ id: { raw: '1' } }, { id: { raw: '2' } }, { id: { raw: '3' } }], + searchDataLoading: false, + engine: { + schema: { + foo: SchemaType.Text, + }, + }, + }; + + const actions = { + search: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + setMockValues(values); + setMockActions(actions); + }); + + it('renders with a search box and results', () => { + const wrapper = shallow(); + expect(wrapper.find(EuiFieldSearch).prop('value')).toBe('foo'); + expect(wrapper.find(EuiFieldSearch).prop('isLoading')).toBe(false); + expect(wrapper.find(Result)).toHaveLength(3); + }); + + it('will update the search term in state when the user updates the search box', () => { + const wrapper = shallow(); + wrapper.find(EuiFieldSearch).simulate('change', { target: { value: 'bar' } }); + expect(actions.search).toHaveBeenCalledWith('bar'); + }); + + it('will render an empty prompt when there are no results', () => { + setMockValues({ + ...values, + searchResults: [], + }); + const wrapper = shallow(); + wrapper.find(EuiFieldSearch).simulate('change', { target: { value: 'bar' } }); + expect(wrapper.find(Result)).toHaveLength(0); + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/query_tester.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/query_tester.tsx new file mode 100644 index 0000000000000..374b6bd1a77b3 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/query_tester.tsx @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { useActions, useValues } from 'kea'; + +import { EuiEmptyPrompt, EuiFieldSearch, EuiSpacer } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { EngineLogic } from '../engine'; +import { Result } from '../result'; +import { SearchLogic } from '../search'; + +export const QueryTester: React.FC = () => { + const logic = SearchLogic({ id: 'query-tester' }); + const { searchQuery, searchResults, searchDataLoading } = useValues(logic); + const { search } = useActions(logic); + const { engine } = useValues(EngineLogic); + + return ( + <> + search(e.target.value)} + isLoading={searchDataLoading} + placeholder={i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.queryTester.searchPlaceholder', + { defaultMessage: 'Search engine documents' } + )} + fullWidth + autoFocus + /> + + {searchResults.length > 0 ? ( + searchResults.map((result) => { + const id = result.id.raw; + + return ( + + + + + ); + }) + ) : ( + + )} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/query_tester_button.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/query_tester_button.test.tsx new file mode 100644 index 0000000000000..4d2c3286ff516 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/query_tester_button.test.tsx @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiButtonEmpty } from '@elastic/eui'; + +import { QueryTesterFlyout, QueryTesterButton } from '.'; + +describe('QueryTesterButton', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders', () => { + const wrapper = shallow(); + expect(wrapper.find(EuiButtonEmpty).exists()).toBe(true); + expect(wrapper.find(QueryTesterFlyout).exists()).toBe(false); + }); + + it('will render a QueryTesterFlyout when pressed and close on QueryTesterFlyout close', () => { + const wrapper = shallow(); + wrapper.find(EuiButtonEmpty).simulate('click'); + expect(wrapper.find(QueryTesterFlyout).exists()).toBe(true); + + wrapper.find(QueryTesterFlyout).simulate('close'); + expect(wrapper.find(QueryTesterFlyout).exists()).toBe(false); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/query_tester_button.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/query_tester_button.tsx new file mode 100644 index 0000000000000..89381914b6db6 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/query_tester_button.tsx @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState } from 'react'; + +import { EuiButtonEmpty } from '@elastic/eui'; + +import { QUERY_TESTER_TITLE } from './i18n'; + +import { QueryTesterFlyout } from '.'; + +export const QueryTesterButton: React.FC = () => { + const [isQueryTesterOpen, setIsQueryTesterOpen] = useState(false); + return ( + <> + setIsQueryTesterOpen(!isQueryTesterOpen)} + > + {QUERY_TESTER_TITLE} + + {isQueryTesterOpen && setIsQueryTesterOpen(false)} />} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/query_tester_flyout.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/query_tester_flyout.test.tsx new file mode 100644 index 0000000000000..8c25589f04639 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/query_tester_flyout.test.tsx @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiFlyout } from '@elastic/eui'; + +import { QueryTester } from './query_tester'; +import { QueryTesterFlyout } from './query_tester_flyout'; + +describe('QueryTesterFlyout', () => { + const onClose = jest.fn(); + + it('renders', () => { + const wrapper = shallow(); + expect(wrapper.find(QueryTester).exists()).toBe(true); + expect(wrapper.find(EuiFlyout).prop('onClose')).toEqual(onClose); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/query_tester_flyout.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/query_tester_flyout.tsx new file mode 100644 index 0000000000000..d419bef472de3 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/query_tester_flyout.tsx @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { EuiFlyout, EuiFlyoutBody, EuiFlyoutHeader, EuiTitle } from '@elastic/eui'; + +import { QUERY_TESTER_TITLE } from './i18n'; +import { QueryTester } from './query_tester'; + +interface Props { + onClose: () => void; +} + +export const QueryTesterFlyout: React.FC = ({ onClose }) => { + return ( + + + +

{QUERY_TESTER_TITLE}

+
+
+ + + +
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/search/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search/index.ts new file mode 100644 index 0000000000000..68cad7b0a0c77 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { SearchLogic } from './search_logic'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/search/search_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search/search_logic.test.ts new file mode 100644 index 0000000000000..784ebd0aad0cb --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search/search_logic.test.ts @@ -0,0 +1,108 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import '../../__mocks__/engine_logic.mock'; + +import { LogicMounter, mockHttpValues, mockFlashMessageHelpers } from '../../../__mocks__'; + +import { nextTick } from '@kbn/test/jest'; + +import { SearchLogic } from './search_logic'; + +describe('SearchLogic', () => { + const { mount } = new LogicMounter(SearchLogic); + const { http } = mockHttpValues; + const { flashAPIErrors } = mockFlashMessageHelpers; + + const MOCK_SEARCH_RESPONSE = { + results: [ + { id: { raw: 'document-1' }, _meta: { id: 'document-1', engine: 'some-engine' } }, + { id: { raw: 'document-2' }, _meta: { id: 'document-2', engine: 'some-engine' } }, + { id: { raw: 'document-3' }, _meta: { id: 'document-3', engine: 'some-engine' } }, + ], + }; + + const DEFAULT_VALUES = { + searchDataLoading: false, + searchQuery: '', + searchResults: [], + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + const mountLogic = (values: object = {}) => mount(values, { id: '1' }); + + it('has expected default values', () => { + const logic = mountLogic(); + expect(logic.values).toEqual(DEFAULT_VALUES); + }); + + describe('actions', () => { + describe('search', () => { + it('sets searchQuery & searchDataLoading to true', () => { + const logic = mountLogic({ searchQuery: '', searchDataLoading: false }); + + logic.actions.search('hello world'); + + expect(logic.values).toEqual({ + ...DEFAULT_VALUES, + searchQuery: 'hello world', + searchDataLoading: true, + }); + }); + }); + + describe('onSearch', () => { + it('sets searchResults & searchDataLoading to false', () => { + const logic = mountLogic({ searchResults: [], searchDataLoading: true }); + + logic.actions.onSearch(MOCK_SEARCH_RESPONSE); + + expect(logic.values).toEqual({ + ...DEFAULT_VALUES, + searchResults: MOCK_SEARCH_RESPONSE.results, + searchDataLoading: false, + }); + }); + }); + }); + + describe('listeners', () => { + describe('search', () => { + beforeAll(() => jest.useFakeTimers()); + afterAll(() => jest.useRealTimers()); + + it('should make a GET API call with a search query', async () => { + http.get.mockReturnValueOnce(Promise.resolve(MOCK_SEARCH_RESPONSE)); + const logic = mountLogic(); + jest.spyOn(logic.actions, 'onSearch'); + + logic.actions.search('hello world'); + jest.runAllTimers(); + await nextTick(); + + expect(http.get).toHaveBeenCalledWith('/api/app_search/engines/some-engine/search', { + query: { query: 'hello world' }, + }); + expect(logic.actions.onSearch).toHaveBeenCalledWith(MOCK_SEARCH_RESPONSE); + }); + + it('handles errors', async () => { + http.get.mockReturnValueOnce(Promise.reject('error')); + const logic = mountLogic(); + + logic.actions.search('test'); + jest.runAllTimers(); + await nextTick(); + + expect(flashAPIErrors).toHaveBeenCalledWith('error'); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/search/search_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search/search_logic.ts new file mode 100644 index 0000000000000..d9b7d575ae0e1 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search/search_logic.ts @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { kea, MakeLogicType } from 'kea'; + +import { flashAPIErrors } from '../../../shared/flash_messages'; + +import { HttpLogic } from '../../../shared/http'; +import { EngineLogic } from '../engine'; + +import { Result } from '../result/types'; + +interface SearchValues { + searchDataLoading: boolean; + searchQuery: string; + searchResults: Result[]; +} + +interface SearchActions { + search(query: string): { query: string }; + onSearch({ results }: { results: Result[] }): { results: Result[] }; +} + +export const SearchLogic = kea>({ + key: (props) => props.id, + path: (key: string) => ['enterprise_search', 'app_search', 'search_logic', key], + actions: () => ({ + search: (query) => ({ query }), + onSearch: ({ results }) => ({ results }), + }), + reducers: () => ({ + searchDataLoading: [ + false, + { + search: () => true, + onSearch: () => false, + }, + ], + searchQuery: [ + '', + { + search: (_, { query }) => query, + }, + ], + searchResults: [ + [], + { + onSearch: (_, { results }) => results, + }, + ], + }), + listeners: ({ actions }) => ({ + search: async ({ query }, breakpoint) => { + await breakpoint(250); + + const { http } = HttpLogic.values; + const { engineName } = EngineLogic.values; + + try { + const response = await http.get(`/api/app_search/engines/${engineName}/search`, { + query: { query }, + }); + actions.onSearch(response); + } catch (e) { + flashAPIErrors(e); + } + }, + }), +}); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/curations.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/curations.test.ts index 045d3d12e8bcf..08e123a98cd31 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/curations.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/curations.test.ts @@ -229,39 +229,4 @@ describe('curations routes', () => { }); }); }); - - describe('GET /api/app_search/engines/{engineName}/curation_search', () => { - let mockRouter: MockRouter; - - beforeEach(() => { - jest.clearAllMocks(); - mockRouter = new MockRouter({ - method: 'get', - path: '/api/app_search/engines/{engineName}/curation_search', - }); - - registerCurationsRoutes({ - ...mockDependencies, - router: mockRouter.router, - }); - }); - - it('creates a request handler', () => { - expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ - path: '/api/as/v1/engines/:engineName/search.json', - }); - }); - - describe('validates', () => { - it('required query param', () => { - const request = { query: { query: 'some query' } }; - mockRouter.shouldValidate(request); - }); - - it('missing query', () => { - const request = { query: {} }; - mockRouter.shouldThrow(request); - }); - }); - }); }); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts index 18de4580318a2..6ccdce0935d93 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts @@ -17,6 +17,7 @@ import { registerOnboardingRoutes } from './onboarding'; import { registerResultSettingsRoutes } from './result_settings'; import { registerRoleMappingsRoutes } from './role_mappings'; import { registerSchemaRoutes } from './schema'; +import { registerSearchRoutes } from './search'; import { registerSearchSettingsRoutes } from './search_settings'; import { registerSearchUIRoutes } from './search_ui'; import { registerSettingsRoutes } from './settings'; @@ -31,6 +32,7 @@ export const registerAppSearchRoutes = (dependencies: RouteDependencies) => { registerDocumentsRoutes(dependencies); registerDocumentRoutes(dependencies); registerSchemaRoutes(dependencies); + registerSearchRoutes(dependencies); registerSourceEnginesRoutes(dependencies); registerCurationsRoutes(dependencies); registerSynonymsRoutes(dependencies); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/search.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/search.test.ts new file mode 100644 index 0000000000000..9262dd9e574ad --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/search.test.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { MockRouter, mockRequestHandler, mockDependencies } from '../../__mocks__'; + +import { registerSearchRoutes } from './search'; + +describe('search routes', () => { + describe('GET /api/app_search/engines/{engineName}/search', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + mockRouter = new MockRouter({ + method: 'get', + path: '/api/app_search/engines/{engineName}/schema', + }); + + registerSearchRoutes({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('creates a request handler', () => { + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/api/as/v1/engines/:engineName/search.json', + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/search.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/search.ts new file mode 100644 index 0000000000000..016f71e7e65b8 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/search.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; + +import { RouteDependencies } from '../../plugin'; + +export function registerSearchRoutes({ + router, + enterpriseSearchRequestHandler, +}: RouteDependencies) { + router.get( + { + path: '/api/app_search/engines/{engineName}/search', + validate: { + params: schema.object({ + engineName: schema.string(), + }), + query: schema.object({ + query: schema.string(), + }), + }, + }, + enterpriseSearchRequestHandler.createRequest({ + path: '/api/as/v1/engines/:engineName/search.json', + }) + ); +} From c260407640865a60035102b6a913de016b8f1dec Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Thu, 3 Jun 2021 17:16:42 +0200 Subject: [PATCH 27/90] added screenshot_mode to app services ownership (#101257) --- .github/CODEOWNERS | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index b071e06f1bc54..9ccf660946dd5 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -54,6 +54,7 @@ /src/plugins/share/ @elastic/kibana-app-services /src/plugins/ui_actions/ @elastic/kibana-app-services /src/plugins/index_pattern_field_editor @elastic/kibana-app-services +/src/plugins/screenshot_mode @elastic/kibana-app-services /x-pack/examples/ui_actions_enhanced_examples/ @elastic/kibana-app-services /x-pack/plugins/data_enhanced/ @elastic/kibana-app-services /x-pack/plugins/embeddable_enhanced/ @elastic/kibana-app-services From a9a90131208604af79e5476ecc10e73433af934a Mon Sep 17 00:00:00 2001 From: Stratoula Kalafateli Date: Thu, 3 Jun 2021 18:17:14 +0300 Subject: [PATCH 28/90] [Pie] New implementation of the vislib pie chart with es-charts (#83929) * es lint fix * Add formatter on the buckets labels * Config the new plugin, toggle tooltip * Aff filtering on slice click * minor fixes * fix eslint error * use legacy palette for now * Add color picker to legend colors * Fix ts error * Add legend actions * Fix bug on Color Picker and remove local state as it is unecessary * Fix some bugs on colorPicker * Add setting for the user to select between the legacy palette or the eui ones * small enhancements, treat empty labels with (empty) * Fix color picker bugs with multiple layers * fixes on internationalization * Create migration script for pie chart and legacy palette * Add unit tests (wip) and a small refactoring * Add unit tests and move some things to utils, useMemo and useCallback where it should * Add jest config file * Fix jest test * fix api integration failure * Fix to_ast_esaggs for new pie plugin * Close legendColorPicker popover when user clicks outside * Fix warning * Remove getter/setters and refactor * Remove kibanaUtils from pie plugin as it is not needed * Add new values to the migration script * Fix bug on not changing color for expty string * remove from migration script as they don't need it * Fix editor settings for old and new implementation * fix uistate type * Disable split chart for the new plugin for now * Remove temp folder * Move translations to the pie plugin * Fix CI failures * Add unit test for the editor config * Types cleanup * Fix types vol2 * Minor improvements * Display data on the inspector * Cleanup translations * Add telemetry for new editor pie options * Fix missing translation * Use Eui component to detect click outside the color picker popover * Retrieve color picker from editor and syncColors on dashboard * Lazy load palette service * Add the new plugin to ts references, fix tests, refactor * Fix ci failure * Move charts library switch to vislib plugin * Remove cyclic dependencies * Modify license headers * Move charts library switch to visualizations plugin * Fix i18n on the switch moved to visualizations plugin * Update license * Fix tests * Fix bugs created by new charts version * Fix the i18n switch problem * Update the migration script * Identify if colorIsOverwritten or not * Small multiples, missing the click event * Fixes the UX for small multiples part1 * Distinct colors per slice implementation * Fix ts references problem * Fix some small multiples bugs * Add unit tests * Fix ts ref problem * Fix TS problems caused by es-charts new version * Update the sample pie visualizations with the new eui palette * Allows filtering by the small multiples value * Apply sortPredicate on partition layers * Fix vilib test * Enable functional tests for new plugin * Fix some functional tests * Minor fix * Fix functional tests * Fix dashboard tests * Fix all dashboard tests * Apply some improvements * Explicit params instead of visConfig Json * Fix i18n failure * Add top level setting * Minor fix * Fix jest tests * Address PR comments * Fix i18n error * fix functional test * Add an icon tip on the distinct colors per slice switch * Fix some of the PR comments * Address more PR comments * Small fix * Functional test * address some PR comments * Add padding to the pie container * Add a max width to the container * Improve dashboard functional test * Move the labels expression function to the pie plugin * Fix i18n * Fix functional test * Apply PR comments * Do not forget to also add the migration to them embeddable too :D * Fix distinct colors for IP range layer * Remove console errors * Fix small mulitples colors with multiple layers * Fix lint problem * Fix problems created from merging with master * Address PR comments * Change the config in order the pie chart to not appear so huge on the editor * Address PR comments * Change the max percentage digits to 4 * Change the max size to 1000 Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .github/CODEOWNERS | 1 + .i18nrc.json | 1 + docs/developer/plugin-list.asciidoc | 4 + packages/kbn-optimizer/limits.yml | 1 + src/plugins/charts/public/index.ts | 1 + .../public/static/components/color_picker.tsx | 31 +- .../data_sets/ecommerce/saved_objects.ts | 2 +- .../data_sets/flights/saved_objects.ts | 2 +- .../data_sets/logs/saved_objects.ts | 2 +- src/plugins/vis_type_pie/README.md | 1 + .../server => vis_type_pie/common}/index.ts | 4 +- src/plugins/vis_type_pie/jest.config.js | 13 + src/plugins/vis_type_pie/kibana.json | 8 + .../public/__snapshots__/pie_fn.test.ts.snap | 73 + .../public/__snapshots__/to_ast.test.ts.snap | 122 ++ src/plugins/vis_type_pie/public/chart.scss | 18 + .../public/components/chart_split.tsx | 67 + .../vis_type_pie/public/editor/collections.ts | 40 + .../public/editor/components/index.tsx | 26 + .../public/editor/components/pie.test.tsx | 124 ++ .../public/editor/components/pie.tsx | 287 ++++ .../components/truncate_labels.test.tsx | 51 + .../editor/components/truncate_labels.tsx | 43 + .../vis_type_pie/public/editor/positions.ts | 37 + .../public/expression_functions/pie_labels.ts | 113 ++ src/plugins/vis_type_pie/public/index.ts | 14 + src/plugins/vis_type_pie/public/mocks.ts | 328 ++++ .../public/pie_component.test.tsx | 123 ++ .../vis_type_pie/public/pie_component.tsx | 355 +++++ .../vis_type_pie/public/pie_fn.test.ts | 53 + src/plugins/vis_type_pie/public/pie_fn.ts | 153 ++ .../vis_type_pie/public/pie_renderer.tsx | 63 + src/plugins/vis_type_pie/public/plugin.ts | 73 + .../public/sample_vis.test.mocks.ts | 1332 +++++++++++++++++ .../vis_type_pie/public/to_ast.test.ts | 31 + src/plugins/vis_type_pie/public/to_ast.ts | 71 + .../vis_type_pie/public/to_ast_esaggs.ts | 33 + .../vis_type_pie/public/types/index.ts | 9 + .../vis_type_pie/public/types/types.ts | 96 ++ .../public/utils/filter_helpers.test.ts | 98 ++ .../public/utils/filter_helpers.ts | 89 ++ .../public/utils/get_color_picker.test.tsx | 116 ++ .../public/utils/get_color_picker.tsx | 121 ++ .../public/utils/get_columns.test.ts | 222 +++ .../vis_type_pie/public/utils/get_columns.ts | 43 + .../vis_type_pie/public/utils/get_config.ts | 76 + .../public/utils/get_distinct_series.test.ts | 30 + .../public/utils/get_distinct_series.ts | 31 + .../public/utils/get_layers.test.ts | 114 ++ .../vis_type_pie/public/utils/get_layers.ts | 186 +++ .../public/utils/get_legend_actions.tsx | 117 ++ .../utils/get_split_dimension_accessor.ts | 31 + .../vis_type_pie/public/utils/index.ts | 16 + .../vis_type_pie/public/vis_type/index.ts | 14 + .../vis_type_pie/public/vis_type/pie.ts | 98 ++ src/plugins/vis_type_pie/tsconfig.json | 24 + src/plugins/vis_type_vislib/kibana.json | 2 +- .../public/editor/components/index.tsx | 6 - .../public/editor/components/pie.tsx | 97 -- src/plugins/vis_type_vislib/public/pie.ts | 75 +- src/plugins/vis_type_vislib/public/plugin.ts | 5 +- .../vis_type_vislib/public/to_ast_pie.test.ts | 2 +- .../build_hierarchical_data.test.ts | 4 +- .../hierarchical/build_hierarchical_data.ts | 17 +- src/plugins/vis_type_vislib/tsconfig.json | 1 + src/plugins/vis_type_xy/common/index.ts | 2 - src/plugins/vis_type_xy/kibana.json | 1 - src/plugins/vis_type_xy/public/plugin.ts | 2 +- .../public/sample_vis.test.mocks.ts | 1319 ---------------- src/plugins/vis_type_xy/server/plugin.ts | 46 - .../visualizations/common/constants.ts | 1 + src/plugins/visualizations/kibana.json | 3 +- .../visualize_embeddable_factory.ts | 10 +- .../visualization_common_migrations.ts | 23 + ...ualization_saved_object_migrations.test.ts | 48 + .../visualization_saved_object_migrations.ts | 26 +- src/plugins/visualizations/server/plugin.ts | 23 +- test/examples/embeddables/dashboard.ts | 6 +- .../apps/dashboard/dashboard_state.ts | 16 +- test/functional/apps/visualize/_pie_chart.ts | 31 +- test/functional/apps/visualize/index.ts | 1 + .../page_objects/visualize_chart_page.ts | 122 +- .../page_objects/visualize_editor_page.ts | 8 + .../services/visualizations/pie_chart.ts | 91 +- tsconfig.json | 1 + tsconfig.refs.json | 1 + .../translations/translations/ja-JP.json | 26 +- .../translations/translations/zh-CN.json | 26 +- .../dashboard_to_dashboard_drilldown.ts | 6 +- 89 files changed, 5602 insertions(+), 1678 deletions(-) create mode 100644 src/plugins/vis_type_pie/README.md rename src/plugins/{vis_type_xy/server => vis_type_pie/common}/index.ts (76%) create mode 100644 src/plugins/vis_type_pie/jest.config.js create mode 100644 src/plugins/vis_type_pie/kibana.json create mode 100644 src/plugins/vis_type_pie/public/__snapshots__/pie_fn.test.ts.snap create mode 100644 src/plugins/vis_type_pie/public/__snapshots__/to_ast.test.ts.snap create mode 100644 src/plugins/vis_type_pie/public/chart.scss create mode 100644 src/plugins/vis_type_pie/public/components/chart_split.tsx create mode 100644 src/plugins/vis_type_pie/public/editor/collections.ts create mode 100644 src/plugins/vis_type_pie/public/editor/components/index.tsx create mode 100644 src/plugins/vis_type_pie/public/editor/components/pie.test.tsx create mode 100644 src/plugins/vis_type_pie/public/editor/components/pie.tsx create mode 100644 src/plugins/vis_type_pie/public/editor/components/truncate_labels.test.tsx create mode 100644 src/plugins/vis_type_pie/public/editor/components/truncate_labels.tsx create mode 100644 src/plugins/vis_type_pie/public/editor/positions.ts create mode 100644 src/plugins/vis_type_pie/public/expression_functions/pie_labels.ts create mode 100644 src/plugins/vis_type_pie/public/index.ts create mode 100644 src/plugins/vis_type_pie/public/mocks.ts create mode 100644 src/plugins/vis_type_pie/public/pie_component.test.tsx create mode 100644 src/plugins/vis_type_pie/public/pie_component.tsx create mode 100644 src/plugins/vis_type_pie/public/pie_fn.test.ts create mode 100644 src/plugins/vis_type_pie/public/pie_fn.ts create mode 100644 src/plugins/vis_type_pie/public/pie_renderer.tsx create mode 100644 src/plugins/vis_type_pie/public/plugin.ts create mode 100644 src/plugins/vis_type_pie/public/sample_vis.test.mocks.ts create mode 100644 src/plugins/vis_type_pie/public/to_ast.test.ts create mode 100644 src/plugins/vis_type_pie/public/to_ast.ts create mode 100644 src/plugins/vis_type_pie/public/to_ast_esaggs.ts create mode 100644 src/plugins/vis_type_pie/public/types/index.ts create mode 100644 src/plugins/vis_type_pie/public/types/types.ts create mode 100644 src/plugins/vis_type_pie/public/utils/filter_helpers.test.ts create mode 100644 src/plugins/vis_type_pie/public/utils/filter_helpers.ts create mode 100644 src/plugins/vis_type_pie/public/utils/get_color_picker.test.tsx create mode 100644 src/plugins/vis_type_pie/public/utils/get_color_picker.tsx create mode 100644 src/plugins/vis_type_pie/public/utils/get_columns.test.ts create mode 100644 src/plugins/vis_type_pie/public/utils/get_columns.ts create mode 100644 src/plugins/vis_type_pie/public/utils/get_config.ts create mode 100644 src/plugins/vis_type_pie/public/utils/get_distinct_series.test.ts create mode 100644 src/plugins/vis_type_pie/public/utils/get_distinct_series.ts create mode 100644 src/plugins/vis_type_pie/public/utils/get_layers.test.ts create mode 100644 src/plugins/vis_type_pie/public/utils/get_layers.ts create mode 100644 src/plugins/vis_type_pie/public/utils/get_legend_actions.tsx create mode 100644 src/plugins/vis_type_pie/public/utils/get_split_dimension_accessor.ts create mode 100644 src/plugins/vis_type_pie/public/utils/index.ts create mode 100644 src/plugins/vis_type_pie/public/vis_type/index.ts create mode 100644 src/plugins/vis_type_pie/public/vis_type/pie.ts create mode 100644 src/plugins/vis_type_pie/tsconfig.json delete mode 100644 src/plugins/vis_type_vislib/public/editor/components/pie.tsx delete mode 100644 src/plugins/vis_type_xy/server/plugin.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 9ccf660946dd5..68fadd4958cba 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -24,6 +24,7 @@ /src/plugins/vis_type_vega/ @elastic/kibana-app /src/plugins/vis_type_vislib/ @elastic/kibana-app /src/plugins/vis_type_xy/ @elastic/kibana-app +/src/plugins/vis_type_pie/ @elastic/kibana-app /src/plugins/visualize/ @elastic/kibana-app /src/plugins/visualizations/ @elastic/kibana-app /packages/kbn-tinymath/ @elastic/kibana-app diff --git a/.i18nrc.json b/.i18nrc.json index 57dffa4147e52..ad91042a2172d 100644 --- a/.i18nrc.json +++ b/.i18nrc.json @@ -56,6 +56,7 @@ "visTypeVega": "src/plugins/vis_type_vega", "visTypeVislib": "src/plugins/vis_type_vislib", "visTypeXy": "src/plugins/vis_type_xy", + "visTypePie": "src/plugins/vis_type_pie", "visualizations": "src/plugins/visualizations", "visualize": "src/plugins/visualize", "apmOss": "src/plugins/apm_oss", diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index 087626240ff33..7d06562547f70 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -265,6 +265,10 @@ The plugin exposes the static DefaultEditorController class to consume. |Contains the metric visualization. +|{kib-repo}blob/{branch}/src/plugins/vis_type_pie/README.md[visTypePie] +|Contains the pie chart implementation using the elastic-charts library. The goal is to eventually deprecate the old implementation and keep only this. Until then, the library used is defined by the Legacy charts library advanced setting. + + |{kib-repo}blob/{branch}/src/plugins/vis_type_table/README.md[visTypeTable] |Contains the data table visualization, that allows presenting data in a simple table format. diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 6ccf6269751b1..3427eee4b5c0b 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -87,6 +87,7 @@ pageLoadAssetSize: visDefaultEditor: 50178 visTypeMarkdown: 30896 visTypeMetric: 42790 + visTypePie: 34051 visTypeTable: 94934 visTypeTagcloud: 37575 visTypeTimelion: 68883 diff --git a/src/plugins/charts/public/index.ts b/src/plugins/charts/public/index.ts index b42407bb10365..cc1a54c2e25b0 100644 --- a/src/plugins/charts/public/index.ts +++ b/src/plugins/charts/public/index.ts @@ -14,6 +14,7 @@ export { ChartsPluginSetup, ChartsPluginStart } from './plugin'; export * from './static'; export * from './services/palettes/types'; +export { lightenColor } from './services/palettes/lighten_color'; export { PaletteOutput, CustomPaletteArguments, diff --git a/src/plugins/charts/public/static/components/color_picker.tsx b/src/plugins/charts/public/static/components/color_picker.tsx index 4974400a3767a..813748accd8fd 100644 --- a/src/plugins/charts/public/static/components/color_picker.tsx +++ b/src/plugins/charts/public/static/components/color_picker.tsx @@ -18,7 +18,7 @@ import { EuiFlexGroup, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; - +import { lightenColor } from '../../services/palettes/lighten_color'; import './color_picker.scss'; export const legacyColors: string[] = [ @@ -105,6 +105,14 @@ interface ColorPickerProps { * Callback for onKeyPress event */ onKeyDown?: (e: React.KeyboardEvent) => void; + /** + * Optional define the series maxDepth + */ + maxDepth?: number; + /** + * Optional define the layer index + */ + layerIndex?: number; } const euiColors = euiPaletteColorBlind({ rotations: 4, order: 'group' }); @@ -115,6 +123,8 @@ export const ColorPicker = ({ useLegacyColors = true, colorIsOverwritten = true, onKeyDown, + maxDepth, + layerIndex, }: ColorPickerProps) => { const legendColors = useLegacyColors ? legacyColors : euiColors; @@ -159,13 +169,18 @@ export const ColorPicker = ({ ))}
- {legendColors.some((c) => c === selectedColor) && colorIsOverwritten && ( - - onChange(null, e)}> - - - - )} + {legendColors.some( + (c) => + c === selectedColor || + (layerIndex && maxDepth && lightenColor(c, layerIndex, maxDepth) === selectedColor) + ) && + colorIsOverwritten && ( + + onChange(null, e)}> + + + + )} ); }; diff --git a/src/plugins/home/server/services/sample_data/data_sets/ecommerce/saved_objects.ts b/src/plugins/home/server/services/sample_data/data_sets/ecommerce/saved_objects.ts index dc5831aa00a0b..a12a2ff195211 100644 --- a/src/plugins/home/server/services/sample_data/data_sets/ecommerce/saved_objects.ts +++ b/src/plugins/home/server/services/sample_data/data_sets/ecommerce/saved_objects.ts @@ -45,7 +45,7 @@ export const getSavedObjects = (): SavedObject[] => [ defaultMessage: '[eCommerce] Sales by Gender', }), visState: - '{"title":"[eCommerce] Sales by Gender","type":"pie","params":{"type":"pie","addTooltip":true,"addLegend":true,"legendPosition":"right","isDonut":true,"labels":{"show":true,"values":true,"last_level":true,"truncate":100}},"aggs":[{"id":"1","enabled":true,"type":"count","schema":"metric","params":{}},{"id":"2","enabled":true,"type":"terms","schema":"segment","params":{"field":"customer_gender","size":5,"order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","missingBucket":false,"missingBucketLabel":"Missing"}}]}', + '{"title":"[eCommerce] Sales by Gender","type":"pie","params":{"type":"pie","addTooltip":true,"addLegend":true,"legendPosition":"right","isDonut":true,"labels":{"show":true,"values":true,"last_level":true,"truncate":100},"palette":{"type":"palette","name":"default"}},"aggs":[{"id":"1","enabled":true,"type":"count","schema":"metric","params":{}},{"id":"2","enabled":true,"type":"terms","schema":"segment","params":{"field":"customer_gender","size":5,"order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","missingBucket":false,"missingBucketLabel":"Missing"}}]}', uiStateJSON: '{}', description: '', version: 1, diff --git a/src/plugins/home/server/services/sample_data/data_sets/flights/saved_objects.ts b/src/plugins/home/server/services/sample_data/data_sets/flights/saved_objects.ts index 1fa19189b8c84..05a3d012d707c 100644 --- a/src/plugins/home/server/services/sample_data/data_sets/flights/saved_objects.ts +++ b/src/plugins/home/server/services/sample_data/data_sets/flights/saved_objects.ts @@ -100,7 +100,7 @@ export const getSavedObjects = (): SavedObject[] => [ defaultMessage: '[Flights] Airline Carrier', }), visState: - '{"title":"[Flights] Airline Carrier","type":"pie","params":{"type":"pie","addTooltip":true,"addLegend":true,"legendPosition":"right","isDonut":true,"labels":{"show":true,"values":true,"last_level":true,"truncate":100}},"aggs":[{"id":"1","enabled":true,"type":"count","schema":"metric","params":{}},{"id":"2","enabled":true,"type":"terms","schema":"segment","params":{"field":"Carrier","size":5,"order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","missingBucket":false,"missingBucketLabel":"Missing"}}]}', + '{"title":"[Flights] Airline Carrier","type":"pie","params":{"type":"pie","addTooltip":true,"addLegend":true,"legendPosition":"right","isDonut":true,"labels":{"show":true,"values":true,"last_level":true,"truncate":100},"palette":{"type":"palette","name":"default"}},"aggs":[{"id":"1","enabled":true,"type":"count","schema":"metric","params":{}},{"id":"2","enabled":true,"type":"terms","schema":"segment","params":{"field":"Carrier","size":5,"order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","missingBucket":false,"missingBucketLabel":"Missing"}}]}', uiStateJSON: '{"vis":{"legendOpen":false}}', description: '', version: 1, diff --git a/src/plugins/home/server/services/sample_data/data_sets/logs/saved_objects.ts b/src/plugins/home/server/services/sample_data/data_sets/logs/saved_objects.ts index 4a17f96bf89ba..661e6ca0ce50f 100644 --- a/src/plugins/home/server/services/sample_data/data_sets/logs/saved_objects.ts +++ b/src/plugins/home/server/services/sample_data/data_sets/logs/saved_objects.ts @@ -234,7 +234,7 @@ export const getSavedObjects = (): SavedObject[] => [ defaultMessage: '[Logs] Visitors by OS', }), visState: - '{"title":"[Logs] Visitors by OS","type":"pie","params":{"type":"pie","addTooltip":true,"addLegend":true,"legendPosition":"right","isDonut":true,"labels":{"show":true,"values":true,"last_level":true,"truncate":100}},"aggs":[{"id":"1","enabled":true,"type":"count","schema":"metric","params":{}},{"id":"2","enabled":true,"type":"terms","schema":"segment","params":{"field":"machine.os.keyword","otherBucket":true,"otherBucketLabel":"Other","missingBucket":false,"missingBucketLabel":"Missing","size":10,"order":"desc","orderBy":"1"}}]}', + '{"title":"[Logs] Visitors by OS","type":"pie","params":{"type":"pie","addTooltip":true,"addLegend":true,"legendPosition":"right","isDonut":true,"labels":{"show":true,"values":true,"last_level":true,"truncate":100},"palette":{"type":"palette","name":"default"}},"aggs":[{"id":"1","enabled":true,"type":"count","schema":"metric","params":{}},{"id":"2","enabled":true,"type":"terms","schema":"segment","params":{"field":"machine.os.keyword","otherBucket":true,"otherBucketLabel":"Other","missingBucket":false,"missingBucketLabel":"Missing","size":10,"order":"desc","orderBy":"1"}}]}', uiStateJSON: '{}', description: '', version: 1, diff --git a/src/plugins/vis_type_pie/README.md b/src/plugins/vis_type_pie/README.md new file mode 100644 index 0000000000000..41b8131a5381d --- /dev/null +++ b/src/plugins/vis_type_pie/README.md @@ -0,0 +1 @@ +Contains the pie chart implementation using the elastic-charts library. The goal is to eventually deprecate the old implementation and keep only this. Until then, the library used is defined by the Legacy charts library advanced setting. \ No newline at end of file diff --git a/src/plugins/vis_type_xy/server/index.ts b/src/plugins/vis_type_pie/common/index.ts similarity index 76% rename from src/plugins/vis_type_xy/server/index.ts rename to src/plugins/vis_type_pie/common/index.ts index bfd8b7d28a98d..1aa1680530b32 100644 --- a/src/plugins/vis_type_xy/server/index.ts +++ b/src/plugins/vis_type_pie/common/index.ts @@ -6,6 +6,4 @@ * Side Public License, v 1. */ -import { VisTypeXyServerPlugin } from './plugin'; - -export const plugin = () => new VisTypeXyServerPlugin(); +export const DEFAULT_PERCENT_DECIMALS = 2; diff --git a/src/plugins/vis_type_pie/jest.config.js b/src/plugins/vis_type_pie/jest.config.js new file mode 100644 index 0000000000000..e4900ef4a35c8 --- /dev/null +++ b/src/plugins/vis_type_pie/jest.config.js @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../..', + roots: ['/src/plugins/vis_type_pie'], +}; diff --git a/src/plugins/vis_type_pie/kibana.json b/src/plugins/vis_type_pie/kibana.json new file mode 100644 index 0000000000000..c2d51fba8260d --- /dev/null +++ b/src/plugins/vis_type_pie/kibana.json @@ -0,0 +1,8 @@ +{ + "id": "visTypePie", + "version": "kibana", + "ui": true, + "requiredPlugins": ["charts", "data", "expressions", "visualizations", "usageCollection"], + "requiredBundles": ["visDefaultEditor"] + } + \ No newline at end of file diff --git a/src/plugins/vis_type_pie/public/__snapshots__/pie_fn.test.ts.snap b/src/plugins/vis_type_pie/public/__snapshots__/pie_fn.test.ts.snap new file mode 100644 index 0000000000000..dc83d9fdf48ac --- /dev/null +++ b/src/plugins/vis_type_pie/public/__snapshots__/pie_fn.test.ts.snap @@ -0,0 +1,73 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`interpreter/functions#pie returns an object with the correct structure 1`] = ` +Object { + "as": "pie_vis", + "type": "render", + "value": Object { + "params": Object { + "listenOnChange": true, + }, + "syncColors": false, + "visConfig": Object { + "addLegend": true, + "addTooltip": true, + "buckets": undefined, + "dimensions": Object { + "buckets": undefined, + "metric": Object { + "accessor": 0, + "aggType": "count", + "format": Object { + "id": "number", + }, + "params": Object {}, + }, + "splitColumn": undefined, + "splitRow": undefined, + }, + "distinctColors": false, + "isDonut": true, + "labels": Object { + "percentDecimals": 2, + "position": "default", + "show": false, + "truncate": 100, + "values": true, + "valuesFormat": "percent", + }, + "legendPosition": "right", + "metric": Object { + "accessor": 0, + "aggType": "count", + "format": Object { + "id": "number", + }, + "params": Object {}, + }, + "nestedLegend": true, + "palette": Object { + "name": "kibana_palette", + "type": "palette", + }, + "splitColumn": undefined, + "splitRow": undefined, + }, + "visData": Object { + "columns": Array [ + Object { + "id": "col-0-1", + "name": "Count", + }, + ], + "rows": Array [ + Object { + "col-0-1": 0, + }, + ], + "type": "datatable", + }, + "visType": "pie", + }, +} +`; diff --git a/src/plugins/vis_type_pie/public/__snapshots__/to_ast.test.ts.snap b/src/plugins/vis_type_pie/public/__snapshots__/to_ast.test.ts.snap new file mode 100644 index 0000000000000..0c8398a142027 --- /dev/null +++ b/src/plugins/vis_type_pie/public/__snapshots__/to_ast.test.ts.snap @@ -0,0 +1,122 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`vis type pie vis toExpressionAst function should match basic snapshot 1`] = ` +Object { + "chain": Array [ + Object { + "arguments": Object { + "aggs": Array [], + "index": Array [ + Object { + "chain": Array [ + Object { + "arguments": Object { + "id": Array [ + "123", + ], + }, + "function": "indexPatternLoad", + "type": "function", + }, + ], + "type": "expression", + }, + ], + "metricsAtAllLevels": Array [ + true, + ], + "partialRows": Array [ + false, + ], + }, + "function": "esaggs", + "type": "function", + }, + Object { + "arguments": Object { + "addLegend": Array [ + true, + ], + "addTooltip": Array [ + true, + ], + "buckets": Array [ + Object { + "chain": Array [ + Object { + "arguments": Object { + "accessor": Array [ + 1, + ], + "format": Array [ + "terms", + ], + "formatParams": Array [ + "{\\"id\\":\\"string\\",\\"otherBucketLabel\\":\\"Other\\",\\"missingBucketLabel\\":\\"Missing\\",\\"parsedUrl\\":{\\"origin\\":\\"http://localhost:5801\\",\\"pathname\\":\\"/app/visualize\\",\\"basePath\\":\\"\\"}}", + ], + }, + "function": "visdimension", + "type": "function", + }, + ], + "type": "expression", + }, + ], + "isDonut": Array [ + true, + ], + "labels": Array [ + Object { + "chain": Array [ + Object { + "arguments": Object { + "lastLevel": Array [ + true, + ], + "show": Array [ + true, + ], + "truncate": Array [ + 100, + ], + "values": Array [ + true, + ], + }, + "function": "pielabels", + "type": "function", + }, + ], + "type": "expression", + }, + ], + "legendPosition": Array [ + "right", + ], + "metric": Array [ + Object { + "chain": Array [ + Object { + "arguments": Object { + "accessor": Array [ + 0, + ], + "format": Array [ + "number", + ], + }, + "function": "visdimension", + "type": "function", + }, + ], + "type": "expression", + }, + ], + }, + "function": "pie_vis", + "type": "function", + }, + ], + "type": "expression", +} +`; diff --git a/src/plugins/vis_type_pie/public/chart.scss b/src/plugins/vis_type_pie/public/chart.scss new file mode 100644 index 0000000000000..8c098b13581f5 --- /dev/null +++ b/src/plugins/vis_type_pie/public/chart.scss @@ -0,0 +1,18 @@ +.pieChart__wrapper, +.pieChart__container { + display: flex; + flex: 1 1 auto; + min-height: 0; + min-width: 0; +} + +.pieChart__container { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + padding: $euiSizeS; + margin-left: auto; + margin-right: auto; +} diff --git a/src/plugins/vis_type_pie/public/components/chart_split.tsx b/src/plugins/vis_type_pie/public/components/chart_split.tsx new file mode 100644 index 0000000000000..46f841113c03d --- /dev/null +++ b/src/plugins/vis_type_pie/public/components/chart_split.tsx @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { Accessor, AccessorFn, GroupBy, GroupBySort, SmallMultiples } from '@elastic/charts'; +import { DatatableColumn } from '../../../expressions/public'; +import { SplitDimensionParams } from '../types'; + +interface ChartSplitProps { + splitColumnAccessor?: Accessor | AccessorFn; + splitRowAccessor?: Accessor | AccessorFn; + splitDimension?: DatatableColumn; +} + +const CHART_SPLIT_ID = '__pie_chart_split__'; +export const SMALL_MULTIPLES_ID = '__pie_chart_sm__'; + +export const ChartSplit = ({ + splitColumnAccessor, + splitRowAccessor, + splitDimension, +}: ChartSplitProps) => { + if (!splitColumnAccessor && !splitRowAccessor) return null; + let sort: GroupBySort = 'alphaDesc'; + if (splitDimension?.meta?.params?.id === 'terms') { + const params = splitDimension?.meta?.sourceParams?.params as SplitDimensionParams; + sort = params?.order === 'asc' ? 'alphaAsc' : 'alphaDesc'; + } + + return ( + <> + { + const splitTypeAccessor = splitColumnAccessor || splitRowAccessor; + if (splitTypeAccessor) { + return typeof splitTypeAccessor === 'function' + ? splitTypeAccessor(datum) + : datum[splitTypeAccessor]; + } + return spec.id; + }} + sort={sort} + /> + + + ); +}; diff --git a/src/plugins/vis_type_pie/public/editor/collections.ts b/src/plugins/vis_type_pie/public/editor/collections.ts new file mode 100644 index 0000000000000..d65e933a8835c --- /dev/null +++ b/src/plugins/vis_type_pie/public/editor/collections.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; +import { LabelPositions, ValueFormats } from '../types'; + +export const getLabelPositions = [ + { + text: i18n.translate('visTypePie.labelPositions.insideText', { + defaultMessage: 'Inside', + }), + value: LabelPositions.INSIDE, + }, + { + text: i18n.translate('visTypePie.labelPositions.insideOrOutsideText', { + defaultMessage: 'Inside or outside', + }), + value: LabelPositions.DEFAULT, + }, +]; + +export const getValuesFormats = [ + { + text: i18n.translate('visTypePie.valuesFormats.percent', { + defaultMessage: 'Show percent', + }), + value: ValueFormats.PERCENT, + }, + { + text: i18n.translate('visTypePie.valuesFormats.value', { + defaultMessage: 'Show value', + }), + value: ValueFormats.VALUE, + }, +]; diff --git a/src/plugins/vis_type_pie/public/editor/components/index.tsx b/src/plugins/vis_type_pie/public/editor/components/index.tsx new file mode 100644 index 0000000000000..6bc31208fbdb0 --- /dev/null +++ b/src/plugins/vis_type_pie/public/editor/components/index.tsx @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { lazy } from 'react'; +import { VisEditorOptionsProps } from '../../../../visualizations/public'; +import { PieVisParams, PieTypeProps } from '../../types'; + +const PieOptionsLazy = lazy(() => import('./pie')); + +export const getPieOptions = ({ + showElasticChartsOptions, + palettes, + trackUiMetric, +}: PieTypeProps) => (props: VisEditorOptionsProps) => ( + +); diff --git a/src/plugins/vis_type_pie/public/editor/components/pie.test.tsx b/src/plugins/vis_type_pie/public/editor/components/pie.test.tsx new file mode 100644 index 0000000000000..524986524fd7e --- /dev/null +++ b/src/plugins/vis_type_pie/public/editor/components/pie.test.tsx @@ -0,0 +1,124 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { mountWithIntl } from '@kbn/test/jest'; +import { ReactWrapper } from 'enzyme'; +import PieOptions, { PieOptionsProps } from './pie'; +import { chartPluginMock } from '../../../../charts/public/mocks'; +import { findTestSubject } from '@elastic/eui/lib/test'; +import { act } from 'react-dom/test-utils'; + +describe('PalettePicker', function () { + let props: PieOptionsProps; + let component: ReactWrapper; + + beforeAll(() => { + props = ({ + palettes: chartPluginMock.createSetupContract().palettes, + showElasticChartsOptions: true, + vis: { + type: { + editorConfig: { + collections: { + legendPositions: [ + { + text: 'Top', + value: 'top', + }, + { + text: 'Left', + value: 'left', + }, + { + text: 'Right', + value: 'right', + }, + { + text: 'Bottom', + value: 'bottom', + }, + ], + }, + }, + }, + }, + stateParams: { + isDonut: true, + legendPosition: 'left', + labels: { + show: true, + }, + }, + setValue: jest.fn(), + } as unknown) as PieOptionsProps; + }); + + it('renders the nested legend switch for the elastic charts implementation', async () => { + component = mountWithIntl(); + await act(async () => { + expect(findTestSubject(component, 'visTypePieNestedLegendSwitch').length).toBe(1); + }); + }); + + it('not renders the nested legend switch for the vislib implementation', async () => { + component = mountWithIntl(); + await act(async () => { + expect(findTestSubject(component, 'visTypePieNestedLegendSwitch').length).toBe(0); + }); + }); + + it('renders the label position dropdown for the elastic charts implementation', async () => { + component = mountWithIntl(); + await act(async () => { + expect(findTestSubject(component, 'visTypePieLabelPositionSelect').length).toBe(1); + }); + }); + + it('not renders the label position dropdown for the vislib implementation', async () => { + component = mountWithIntl(); + await act(async () => { + expect(findTestSubject(component, 'visTypePieLabelPositionSelect').length).toBe(0); + }); + }); + + it('renders the top level switch for the elastic charts implementation', async () => { + component = mountWithIntl(); + await act(async () => { + expect(findTestSubject(component, 'visTypePieTopLevelSwitch').length).toBe(1); + }); + }); + + it('renders the top level switch for the vislib implementation', async () => { + component = mountWithIntl(); + await act(async () => { + expect(findTestSubject(component, 'visTypePieTopLevelSwitch').length).toBe(1); + }); + }); + + it('renders the value format dropdown for the elastic charts implementation', async () => { + component = mountWithIntl(); + await act(async () => { + expect(findTestSubject(component, 'visTypePieValueFormatsSelect').length).toBe(1); + }); + }); + + it('not renders the value format dropdown for the vislib implementation', async () => { + component = mountWithIntl(); + await act(async () => { + expect(findTestSubject(component, 'visTypePieValueFormatsSelect').length).toBe(0); + }); + }); + + it('renders the percent slider for the elastic charts implementation', async () => { + component = mountWithIntl(); + await act(async () => { + expect(findTestSubject(component, 'visTypePieValueDecimals').length).toBe(1); + }); + }); +}); diff --git a/src/plugins/vis_type_pie/public/editor/components/pie.tsx b/src/plugins/vis_type_pie/public/editor/components/pie.tsx new file mode 100644 index 0000000000000..8ce4f4defbaed --- /dev/null +++ b/src/plugins/vis_type_pie/public/editor/components/pie.tsx @@ -0,0 +1,287 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useState, useEffect } from 'react'; +import { METRIC_TYPE } from '@kbn/analytics'; +import { + EuiPanel, + EuiTitle, + EuiSpacer, + EuiRange, + EuiFormRow, + EuiIconTip, + EuiFlexItem, + EuiFlexGroup, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { + BasicOptions, + SwitchOption, + SelectOption, + PalettePicker, +} from '../../../../vis_default_editor/public'; +import { VisEditorOptionsProps } from '../../../../visualizations/public'; +import { TruncateLabelsOption } from './truncate_labels'; +import { PaletteRegistry } from '../../../../charts/public'; +import { DEFAULT_PERCENT_DECIMALS } from '../../../common'; +import { PieVisParams, LabelPositions, ValueFormats, PieTypeProps } from '../../types'; +import { getLabelPositions, getValuesFormats } from '../collections'; +import { getLegendPositions } from '../positions'; + +export interface PieOptionsProps extends VisEditorOptionsProps, PieTypeProps {} + +function DecimalSlider({ + paramName, + value, + setValue, +}: { + value: number; + paramName: ParamName; + setValue: (paramName: ParamName, value: number) => void; +}) { + return ( + + { + setValue(paramName, Number(e.currentTarget.value)); + }} + /> + + ); +} + +const PieOptions = (props: PieOptionsProps) => { + const { stateParams, setValue, aggs } = props; + const setLabels = ( + paramName: T, + value: PieVisParams['labels'][T] + ) => setValue('labels', { ...stateParams.labels, [paramName]: value }); + const legendUiStateValue = props.uiState?.get('vis.legendOpen'); + const [palettesRegistry, setPalettesRegistry] = useState(undefined); + const [legendVisibility, setLegendVisibility] = useState(() => { + const bwcLegendStateDefault = stateParams.addLegend == null ? false : stateParams.addLegend; + return props.uiState?.get('vis.legendOpen', bwcLegendStateDefault) as boolean; + }); + const hasSplitChart = Boolean(aggs?.aggs?.find((agg) => agg.schema === 'split' && agg.enabled)); + const segments = aggs?.aggs?.filter((agg) => agg.schema === 'segment' && agg.enabled) ?? []; + + useEffect(() => { + setLegendVisibility(legendUiStateValue); + }, [legendUiStateValue]); + + useEffect(() => { + const fetchPalettes = async () => { + const palettes = await props.palettes?.getPalettes(); + setPalettesRegistry(palettes); + }; + fetchPalettes(); + }, [props.palettes]); + + return ( + <> + + +

+ +

+
+ + + + {props.showElasticChartsOptions && ( + <> + + + + + + + + + + + { + setLegendVisibility(value); + setValue(paramName, value); + }} + data-test-subj="visTypePieAddLegendSwitch" + /> + { + if (props.trackUiMetric) { + props.trackUiMetric(METRIC_TYPE.CLICK, 'nested_legend_switched'); + } + setValue(paramName, value); + }} + data-test-subj="visTypePieNestedLegendSwitch" + /> + + )} + {props.showElasticChartsOptions && palettesRegistry && ( + { + if (props.trackUiMetric) { + props.trackUiMetric(METRIC_TYPE.CLICK, 'palette_selected'); + } + setValue(paramName, value); + }} + /> + )} +
+ + + + + +

+ +

+
+ + + {props.showElasticChartsOptions && ( + { + if (props.trackUiMetric) { + props.trackUiMetric(METRIC_TYPE.CLICK, 'label_position_selected'); + } + setLabels(paramName, value); + }} + data-test-subj="visTypePieLabelPositionSelect" + /> + )} + + + {props.showElasticChartsOptions && ( + <> + { + if (props.trackUiMetric) { + props.trackUiMetric(METRIC_TYPE.CLICK, 'values_format_selected'); + } + setLabels(paramName, value); + }} + data-test-subj="visTypePieValueFormatsSelect" + /> + + + )} + +
+ + ); +}; + +// default export required for React.Lazy +// eslint-disable-next-line import/no-default-export +export { PieOptions as default }; diff --git a/src/plugins/vis_type_pie/public/editor/components/truncate_labels.test.tsx b/src/plugins/vis_type_pie/public/editor/components/truncate_labels.test.tsx new file mode 100644 index 0000000000000..1d4bb238dcb50 --- /dev/null +++ b/src/plugins/vis_type_pie/public/editor/components/truncate_labels.test.tsx @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { mountWithIntl } from '@kbn/test/jest'; +import { ReactWrapper } from 'enzyme'; +import { TruncateLabelsOption, TruncateLabelsOptionProps } from './truncate_labels'; +import { findTestSubject } from '@elastic/eui/lib/test'; + +describe('TruncateLabelsOption', function () { + let props: TruncateLabelsOptionProps; + let component: ReactWrapper; + + beforeAll(() => { + props = { + disabled: false, + value: 20, + setValue: jest.fn(), + }; + }); + + it('renders an input type number', () => { + component = mountWithIntl(); + expect(findTestSubject(component, 'pieLabelTruncateInput').length).toBe(1); + }); + + it('renders the value on the input number', function () { + component = mountWithIntl(); + const input = findTestSubject(component, 'pieLabelTruncateInput'); + expect(input.props().value).toBe(20); + }); + + it('disables the input if disabled prop is given', function () { + const newProps = { ...props, disabled: true }; + component = mountWithIntl(); + const input = findTestSubject(component, 'pieLabelTruncateInput'); + expect(input.props().disabled).toBe(true); + }); + + it('should set the new value', function () { + component = mountWithIntl(); + const input = findTestSubject(component, 'pieLabelTruncateInput'); + input.simulate('change', { target: { value: 100 } }); + expect(props.setValue).toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/vis_type_pie/public/editor/components/truncate_labels.tsx b/src/plugins/vis_type_pie/public/editor/components/truncate_labels.tsx new file mode 100644 index 0000000000000..e6eb56725753c --- /dev/null +++ b/src/plugins/vis_type_pie/public/editor/components/truncate_labels.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { ChangeEvent } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiFormRow, EuiFieldNumber } from '@elastic/eui'; + +export interface TruncateLabelsOptionProps { + disabled?: boolean; + value?: number | null; + setValue: (paramName: 'truncate', value: null | number) => void; +} + +function TruncateLabelsOption({ disabled, value = null, setValue }: TruncateLabelsOptionProps) { + const onChange = (ev: ChangeEvent) => + setValue('truncate', ev.target.value === '' ? null : parseFloat(ev.target.value)); + + return ( + + + + ); +} + +export { TruncateLabelsOption }; diff --git a/src/plugins/vis_type_pie/public/editor/positions.ts b/src/plugins/vis_type_pie/public/editor/positions.ts new file mode 100644 index 0000000000000..ea099a23cf9b4 --- /dev/null +++ b/src/plugins/vis_type_pie/public/editor/positions.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; +import { Position } from '@elastic/charts'; + +export const getLegendPositions = [ + { + text: i18n.translate('visTypePie.legendPositions.topText', { + defaultMessage: 'Top', + }), + value: Position.Top, + }, + { + text: i18n.translate('visTypePie.legendPositions.leftText', { + defaultMessage: 'Left', + }), + value: Position.Left, + }, + { + text: i18n.translate('visTypePie.legendPositions.rightText', { + defaultMessage: 'Right', + }), + value: Position.Right, + }, + { + text: i18n.translate('visTypePie.legendPositions.bottomText', { + defaultMessage: 'Bottom', + }), + value: Position.Bottom, + }, +]; diff --git a/src/plugins/vis_type_pie/public/expression_functions/pie_labels.ts b/src/plugins/vis_type_pie/public/expression_functions/pie_labels.ts new file mode 100644 index 0000000000000..269d5d5f779d6 --- /dev/null +++ b/src/plugins/vis_type_pie/public/expression_functions/pie_labels.ts @@ -0,0 +1,113 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; +import { + ExpressionFunctionDefinition, + Datatable, + ExpressionValueBoxed, +} from '../../../expressions/public'; + +interface Arguments { + show: boolean; + position: string; + values: boolean; + truncate: number | null; + valuesFormat: string; + lastLevel: boolean; + percentDecimals: number; +} + +export type ExpressionValuePieLabels = ExpressionValueBoxed< + 'pie_labels', + { + show: boolean; + position: string; + values: boolean; + truncate: number | null; + valuesFormat: string; + last_level: boolean; + percentDecimals: number; + } +>; + +export const pieLabels = (): ExpressionFunctionDefinition< + 'pielabels', + Datatable | null, + Arguments, + ExpressionValuePieLabels +> => ({ + name: 'pielabels', + help: i18n.translate('visTypePie.function.pieLabels.help', { + defaultMessage: 'Generates the pie labels object', + }), + type: 'pie_labels', + args: { + show: { + types: ['boolean'], + help: i18n.translate('visTypePie.function.pieLabels.show.help', { + defaultMessage: 'Displays the pie labels', + }), + required: true, + }, + position: { + types: ['string'], + default: 'default', + help: i18n.translate('visTypePie.function.pieLabels.position.help', { + defaultMessage: 'Defines the label position', + }), + }, + values: { + types: ['boolean'], + help: i18n.translate('visTypePie.function.pieLabels.values.help', { + defaultMessage: 'Displays the values inside the slices', + }), + default: true, + }, + percentDecimals: { + types: ['number'], + help: i18n.translate('visTypePie.function.pieLabels.percentDecimals.help', { + defaultMessage: 'Defines the number of decimals that will appear on the values as percent', + }), + default: 2, + }, + lastLevel: { + types: ['boolean'], + help: i18n.translate('visTypePie.function.pieLabels.lastLevel.help', { + defaultMessage: 'Show top level labels only', + }), + default: true, + }, + truncate: { + types: ['number', 'null'], + help: i18n.translate('visTypePie.function.pieLabels.truncate.help', { + defaultMessage: 'Defines the number of characters that the slice value will display', + }), + default: null, + }, + valuesFormat: { + types: ['string'], + default: 'percent', + help: i18n.translate('visTypePie.function.pieLabels.valuesFormat.help', { + defaultMessage: 'Defines the format of the values', + }), + }, + }, + fn: (context, args) => { + return { + type: 'pie_labels', + show: args.show, + position: args.position, + percentDecimals: args.percentDecimals, + values: args.values, + truncate: args.truncate, + valuesFormat: args.valuesFormat, + last_level: args.lastLevel, + }; + }, +}); diff --git a/src/plugins/vis_type_pie/public/index.ts b/src/plugins/vis_type_pie/public/index.ts new file mode 100644 index 0000000000000..adf8b2d073f39 --- /dev/null +++ b/src/plugins/vis_type_pie/public/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { VisTypePiePlugin } from './plugin'; + +export { pieVisType } from './vis_type'; +export { Dimensions, Dimension } from './types'; + +export const plugin = () => new VisTypePiePlugin(); diff --git a/src/plugins/vis_type_pie/public/mocks.ts b/src/plugins/vis_type_pie/public/mocks.ts new file mode 100644 index 0000000000000..53579422e44eb --- /dev/null +++ b/src/plugins/vis_type_pie/public/mocks.ts @@ -0,0 +1,328 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { Datatable } from '../../expressions/public'; +import { BucketColumns, PieVisParams, LabelPositions, ValueFormats } from './types'; + +export const createMockBucketColumns = (): BucketColumns[] => { + return [ + { + id: 'col-0-2', + name: 'Carrier: Descending', + meta: { + type: 'string', + field: 'Carrier', + index: 'kibana_sample_data_flights', + params: { + id: 'terms', + params: { + id: 'string', + otherBucketLabel: 'Other', + missingBucketLabel: 'Missing', + }, + }, + source: 'esaggs', + sourceParams: { + indexPatternId: 'd3d7af60-4c81-11e8-b3d7-01146121b73d', + id: '2', + enabled: true, + type: 'terms', + params: { + field: 'Carrier', + orderBy: '1', + order: 'desc', + size: 5, + otherBucket: false, + otherBucketLabel: 'Other', + missingBucket: false, + missingBucketLabel: 'Missing', + }, + schema: 'segment', + }, + }, + format: { + id: 'terms', + params: { + id: 'string', + }, + }, + }, + { + id: 'col-2-3', + name: 'Cancelled: Descending', + meta: { + type: 'boolean', + field: 'Cancelled', + index: 'kibana_sample_data_flights', + params: { + id: 'terms', + params: { + id: 'boolean', + otherBucketLabel: 'Other', + missingBucketLabel: 'Missing', + }, + }, + source: 'esaggs', + sourceParams: { + indexPatternId: 'd3d7af60-4c81-11e8-b3d7-01146121b73d', + id: '3', + enabled: true, + type: 'terms', + params: { + field: 'Cancelled', + orderBy: '1', + order: 'desc', + size: 5, + otherBucket: false, + otherBucketLabel: 'Other', + missingBucket: false, + missingBucketLabel: 'Missing', + }, + schema: 'segment', + }, + }, + format: { + id: 'terms', + params: { + id: 'boolean', + }, + }, + }, + ]; +}; + +export const createMockVisData = (): Datatable => { + return { + type: 'datatable', + rows: [ + { + 'col-0-2': 'Logstash Airways', + 'col-2-3': 0, + 'col-1-1': 797, + 'col-3-1': 689, + }, + { + 'col-0-2': 'Logstash Airways', + 'col-2-3': 1, + 'col-1-1': 797, + 'col-3-1': 108, + }, + { + 'col-0-2': 'JetBeats', + 'col-2-3': 0, + 'col-1-1': 766, + 'col-3-1': 654, + }, + { + 'col-0-2': 'JetBeats', + 'col-2-3': 1, + 'col-1-1': 766, + 'col-3-1': 112, + }, + { + 'col-0-2': 'ES-Air', + 'col-2-3': 0, + 'col-1-1': 744, + 'col-3-1': 665, + }, + { + 'col-0-2': 'ES-Air', + 'col-2-3': 1, + 'col-1-1': 744, + 'col-3-1': 79, + }, + { + 'col-0-2': 'Kibana Airlines', + 'col-2-3': 0, + 'col-1-1': 731, + 'col-3-1': 655, + }, + { + 'col-0-2': 'Kibana Airlines', + 'col-2-3': 1, + 'col-1-1': 731, + 'col-3-1': 76, + }, + ], + columns: [ + { + id: 'col-0-2', + name: 'Carrier: Descending', + meta: { + type: 'string', + field: 'Carrier', + index: 'kibana_sample_data_flights', + params: { + id: 'terms', + params: { + id: 'string', + otherBucketLabel: 'Other', + missingBucketLabel: 'Missing', + }, + }, + source: 'esaggs', + sourceParams: { + indexPatternId: 'd3d7af60-4c81-11e8-b3d7-01146121b73d', + id: '2', + enabled: true, + type: 'terms', + params: { + field: 'Carrier', + orderBy: '1', + order: 'desc', + size: 5, + otherBucket: false, + otherBucketLabel: 'Other', + missingBucket: false, + missingBucketLabel: 'Missing', + }, + schema: 'segment', + }, + }, + }, + { + id: 'col-1-1', + name: 'Count', + meta: { + type: 'number', + index: 'kibana_sample_data_flights', + params: { + id: 'number', + }, + source: 'esaggs', + sourceParams: { + indexPatternId: 'd3d7af60-4c81-11e8-b3d7-01146121b73d', + id: '1', + enabled: true, + type: 'count', + params: {}, + schema: 'metric', + }, + }, + }, + { + id: 'col-2-3', + name: 'Cancelled: Descending', + meta: { + type: 'boolean', + field: 'Cancelled', + index: 'kibana_sample_data_flights', + params: { + id: 'terms', + params: { + id: 'boolean', + otherBucketLabel: 'Other', + missingBucketLabel: 'Missing', + }, + }, + source: 'esaggs', + sourceParams: { + indexPatternId: 'd3d7af60-4c81-11e8-b3d7-01146121b73d', + id: '3', + enabled: true, + type: 'terms', + params: { + field: 'Cancelled', + orderBy: '1', + order: 'desc', + size: 5, + otherBucket: false, + otherBucketLabel: 'Other', + missingBucket: false, + missingBucketLabel: 'Missing', + }, + schema: 'segment', + }, + }, + }, + { + id: 'col-3-1', + name: 'Count', + meta: { + type: 'number', + index: 'kibana_sample_data_flights', + params: { + id: 'number', + }, + source: 'esaggs', + sourceParams: { + indexPatternId: 'd3d7af60-4c81-11e8-b3d7-01146121b73d', + id: '1', + enabled: true, + type: 'count', + params: {}, + schema: 'metric', + }, + }, + }, + ], + }; +}; + +export const createMockPieParams = (): PieVisParams => { + return ({ + addLegend: true, + addTooltip: true, + isDonut: true, + labels: { + position: LabelPositions.DEFAULT, + show: true, + truncate: 100, + values: true, + valuesFormat: ValueFormats.PERCENT, + percentDecimals: 2, + }, + legendPosition: 'right', + nestedLegend: false, + distinctColors: false, + palette: { + name: 'default', + type: 'palette', + }, + type: 'pie', + dimensions: { + metric: { + accessor: 1, + format: { + id: 'number', + }, + params: {}, + label: 'Count', + aggType: 'count', + }, + buckets: [ + { + accessor: 0, + format: { + id: 'terms', + params: { + id: 'string', + otherBucketLabel: 'Other', + missingBucketLabel: 'Missing', + }, + }, + label: 'Carrier: Descending', + aggType: 'terms', + }, + { + accessor: 2, + format: { + id: 'terms', + params: { + id: 'boolean', + otherBucketLabel: 'Other', + missingBucketLabel: 'Missing', + }, + }, + label: 'Cancelled: Descending', + aggType: 'terms', + }, + ], + }, + } as unknown) as PieVisParams; +}; diff --git a/src/plugins/vis_type_pie/public/pie_component.test.tsx b/src/plugins/vis_type_pie/public/pie_component.test.tsx new file mode 100644 index 0000000000000..177396f25adb6 --- /dev/null +++ b/src/plugins/vis_type_pie/public/pie_component.test.tsx @@ -0,0 +1,123 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { Settings, TooltipType, SeriesIdentifier } from '@elastic/charts'; +import { chartPluginMock } from '../../charts/public/mocks'; +import { dataPluginMock } from '../../data/public/mocks'; +import { shallow, mount } from 'enzyme'; +import { findTestSubject } from '@elastic/eui/lib/test'; +import { act } from 'react-dom/test-utils'; +import PieComponent, { PieComponentProps } from './pie_component'; +import { createMockPieParams, createMockVisData } from './mocks'; + +jest.mock('@elastic/charts', () => { + const original = jest.requireActual('@elastic/charts'); + + return { + ...original, + getSpecId: jest.fn(() => {}), + }; +}); + +const chartsThemeService = chartPluginMock.createSetupContract().theme; +const palettesRegistry = chartPluginMock.createPaletteRegistry(); +const visParams = createMockPieParams(); +const visData = createMockVisData(); + +const mockState = new Map(); +const uiState = { + get: jest + .fn() + .mockImplementation((key, fallback) => (mockState.has(key) ? mockState.get(key) : fallback)), + set: jest.fn().mockImplementation((key, value) => mockState.set(key, value)), + emit: jest.fn(), + setSilent: jest.fn(), +} as any; + +describe('PieComponent', function () { + let wrapperProps: PieComponentProps; + + beforeAll(() => { + wrapperProps = { + chartsThemeService, + palettesRegistry, + visParams, + visData, + uiState, + syncColors: false, + fireEvent: jest.fn(), + renderComplete: jest.fn(), + services: dataPluginMock.createStartContract(), + }; + }); + + it('renders the legend on the correct position', () => { + const component = shallow(); + expect(component.find(Settings).prop('legendPosition')).toEqual('right'); + }); + + it('renders the legend toggle component', async () => { + const component = mount(); + await act(async () => { + expect(findTestSubject(component, 'vislibToggleLegend').length).toBe(1); + }); + }); + + it('hides the legend if the legend toggle is clicked', async () => { + const component = mount(); + findTestSubject(component, 'vislibToggleLegend').simulate('click'); + await act(async () => { + expect(component.find(Settings).prop('showLegend')).toEqual(false); + }); + }); + + it('defaults on showing the legend for the inner cicle', () => { + const component = shallow(); + expect(component.find(Settings).prop('legendMaxDepth')).toBe(1); + }); + + it('shows the nested legend when the user requests it', () => { + const newParams = { ...visParams, nestedLegend: true }; + const newProps = { ...wrapperProps, visParams: newParams }; + const component = shallow(); + expect(component.find(Settings).prop('legendMaxDepth')).toBeUndefined(); + }); + + it('defaults on displaying the tooltip', () => { + const component = shallow(); + expect(component.find(Settings).prop('tooltip')).toStrictEqual({ type: TooltipType.Follow }); + }); + + it('doesnt show the tooltip when the user requests it', () => { + const newParams = { ...visParams, addTooltip: false }; + const newProps = { ...wrapperProps, visParams: newParams }; + const component = shallow(); + expect(component.find(Settings).prop('tooltip')).toStrictEqual({ type: TooltipType.None }); + }); + + it('calls filter callback', () => { + const component = shallow(); + component.find(Settings).first().prop('onElementClick')!([ + [ + [ + { + groupByRollup: 6, + value: 6, + depth: 1, + path: [], + sortIndex: 1, + smAccessorValue: 'Logstash Airways', + }, + ], + {} as SeriesIdentifier, + ], + ]); + expect(wrapperProps.fireEvent).toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/vis_type_pie/public/pie_component.tsx b/src/plugins/vis_type_pie/public/pie_component.tsx new file mode 100644 index 0000000000000..b79eed2087a16 --- /dev/null +++ b/src/plugins/vis_type_pie/public/pie_component.tsx @@ -0,0 +1,355 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { memo, useCallback, useMemo, useState, useEffect, useRef } from 'react'; + +import { + Chart, + Datum, + LayerValue, + Partition, + Position, + Settings, + RenderChangeListener, + TooltipProps, + TooltipType, + SeriesIdentifier, +} from '@elastic/charts'; +import { + LegendToggle, + ClickTriggerEvent, + ChartsPluginSetup, + PaletteRegistry, +} from '../../charts/public'; +import { DataPublicPluginStart, FieldFormat } from '../../data/public'; +import type { PersistedState } from '../../visualizations/public'; +import { Datatable, DatatableColumn, IInterpreterRenderHandlers } from '../../expressions/public'; +import { DEFAULT_PERCENT_DECIMALS } from '../common'; +import { PieVisParams, BucketColumns, ValueFormats, PieContainerDimensions } from './types'; +import { + getColorPicker, + getLayers, + getLegendActions, + canFilter, + getFilterClickData, + getFilterEventData, + getConfig, + getColumns, + getSplitDimensionAccessor, +} from './utils'; +import { ChartSplit, SMALL_MULTIPLES_ID } from './components/chart_split'; + +import './chart.scss'; + +declare global { + interface Window { + /** + * Flag used to enable debugState on elastic charts + */ + _echDebugStateFlag?: boolean; + } +} + +export interface PieComponentProps { + visParams: PieVisParams; + visData: Datatable; + uiState: PersistedState; + fireEvent: IInterpreterRenderHandlers['event']; + renderComplete: IInterpreterRenderHandlers['done']; + chartsThemeService: ChartsPluginSetup['theme']; + palettesRegistry: PaletteRegistry; + services: DataPublicPluginStart; + syncColors: boolean; +} + +const PieComponent = (props: PieComponentProps) => { + const chartTheme = props.chartsThemeService.useChartsTheme(); + const chartBaseTheme = props.chartsThemeService.useChartsBaseTheme(); + const [showLegend, setShowLegend] = useState(() => { + const bwcLegendStateDefault = + props.visParams.addLegend == null ? false : props.visParams.addLegend; + return props.uiState?.get('vis.legendOpen', bwcLegendStateDefault) as boolean; + }); + const [dimensions, setDimensions] = useState(); + + const parentRef = useRef(null); + + useEffect(() => { + if (parentRef && parentRef.current) { + const parentHeight = parentRef.current!.getBoundingClientRect().height; + const parentWidth = parentRef.current!.getBoundingClientRect().width; + setDimensions({ width: parentWidth, height: parentHeight }); + } + }, [parentRef]); + + const onRenderChange = useCallback( + (isRendered) => { + if (isRendered) { + props.renderComplete(); + } + }, + [props] + ); + + // handles slice click event + const handleSliceClick = useCallback( + ( + clickedLayers: LayerValue[], + bucketColumns: Array>, + visData: Datatable, + splitChartDimension?: DatatableColumn, + splitChartFormatter?: FieldFormat + ): void => { + const data = getFilterClickData( + clickedLayers, + bucketColumns, + visData, + splitChartDimension, + splitChartFormatter + ); + const event = { + name: 'filterBucket', + data: { data }, + }; + props.fireEvent(event); + }, + [props] + ); + + // handles legend action event data + const getLegendActionEventData = useCallback( + (visData: Datatable) => (series: SeriesIdentifier): ClickTriggerEvent | null => { + const data = getFilterEventData(visData, series); + + return { + name: 'filterBucket', + data: { + negate: false, + data, + }, + }; + }, + [] + ); + + const handleLegendAction = useCallback( + (event: ClickTriggerEvent, negate = false) => { + props.fireEvent({ + ...event, + data: { + ...event.data, + negate, + }, + }); + }, + [props] + ); + + const toggleLegend = useCallback(() => { + setShowLegend((value) => { + const newValue = !value; + props.uiState?.set('vis.legendOpen', newValue); + return newValue; + }); + }, [props.uiState]); + + useEffect(() => { + setShowLegend(props.visParams.addLegend); + props.uiState?.set('vis.legendOpen', props.visParams.addLegend); + }, [props.uiState, props.visParams.addLegend]); + + const setColor = useCallback( + (newColor: string | null, seriesLabel: string | number) => { + const colors = props.uiState?.get('vis.colors') || {}; + if (colors[seriesLabel] === newColor || !newColor) { + delete colors[seriesLabel]; + } else { + colors[seriesLabel] = newColor; + } + props.uiState?.setSilent('vis.colors', null); + props.uiState?.set('vis.colors', colors); + props.uiState?.emit('reload'); + }, + [props.uiState] + ); + + const { visData, visParams, services, syncColors } = props; + + function getSliceValue(d: Datum, metricColumn: DatatableColumn) { + if (typeof d[metricColumn.id] === 'number' && d[metricColumn.id] !== 0) { + return d[metricColumn.id]; + } + return Number.EPSILON; + } + + // formatters + const metricFieldFormatter = services.fieldFormats.deserialize( + visParams.dimensions.metric.format + ); + const splitChartFormatter = visParams.dimensions.splitColumn + ? services.fieldFormats.deserialize(visParams.dimensions.splitColumn[0].format) + : visParams.dimensions.splitRow + ? services.fieldFormats.deserialize(visParams.dimensions.splitRow[0].format) + : undefined; + const percentFormatter = services.fieldFormats.deserialize({ + id: 'percent', + params: { + pattern: `0,0.[${'0'.repeat(visParams.labels.percentDecimals ?? DEFAULT_PERCENT_DECIMALS)}]%`, + }, + }); + + const { bucketColumns, metricColumn } = useMemo(() => getColumns(visParams, visData), [ + visData, + visParams, + ]); + + const layers = useMemo( + () => + getLayers( + bucketColumns, + visParams, + props.uiState?.get('vis.colors', {}), + visData.rows, + props.palettesRegistry, + services.fieldFormats, + syncColors + ), + [ + bucketColumns, + visParams, + props.uiState, + props.palettesRegistry, + visData.rows, + services.fieldFormats, + syncColors, + ] + ); + const config = useMemo(() => getConfig(visParams, chartTheme, dimensions), [ + chartTheme, + visParams, + dimensions, + ]); + const tooltip: TooltipProps = { + type: visParams.addTooltip ? TooltipType.Follow : TooltipType.None, + }; + const legendPosition = visParams.legendPosition ?? Position.Right; + + const legendColorPicker = useMemo( + () => + getColorPicker( + legendPosition, + setColor, + bucketColumns, + visParams.palette.name, + visData.rows, + props.uiState, + visParams.distinctColors + ), + [ + legendPosition, + setColor, + bucketColumns, + visParams.palette.name, + visParams.distinctColors, + visData.rows, + props.uiState, + ] + ); + + const splitChartColumnAccessor = visParams.dimensions.splitColumn + ? getSplitDimensionAccessor( + services.fieldFormats, + visData.columns + )(visParams.dimensions.splitColumn[0]) + : undefined; + const splitChartRowAccessor = visParams.dimensions.splitRow + ? getSplitDimensionAccessor( + services.fieldFormats, + visData.columns + )(visParams.dimensions.splitRow[0]) + : undefined; + + const splitChartDimension = visParams.dimensions.splitColumn + ? visData.columns[visParams.dimensions.splitColumn[0].accessor] + : visParams.dimensions.splitRow + ? visData.columns[visParams.dimensions.splitRow[0].accessor] + : undefined; + + return ( +
+
+ + + + { + handleSliceClick( + args[0][0] as LayerValue[], + bucketColumns, + visData, + splitChartDimension, + splitChartFormatter + ); + }} + legendAction={getLegendActions( + canFilter, + getLegendActionEventData(visData), + handleLegendAction, + visParams, + services.actions, + services.fieldFormats + )} + theme={chartTheme} + baseTheme={chartBaseTheme} + onRenderChange={onRenderChange} + /> + getSliceValue(d, metricColumn)} + percentFormatter={(d: number) => percentFormatter.convert(d / 100)} + valueGetter={ + !visParams.labels.show || + visParams.labels.valuesFormat === ValueFormats.VALUE || + !visParams.labels.values + ? undefined + : 'percent' + } + valueFormatter={(d: number) => + !visParams.labels.show || !visParams.labels.values + ? '' + : metricFieldFormatter.convert(d) + } + layers={layers} + config={config} + topGroove={!visParams.labels.show ? 0 : undefined} + /> + +
+
+ ); +}; + +// eslint-disable-next-line import/no-default-export +export default memo(PieComponent); diff --git a/src/plugins/vis_type_pie/public/pie_fn.test.ts b/src/plugins/vis_type_pie/public/pie_fn.test.ts new file mode 100644 index 0000000000000..d387d4035e8ab --- /dev/null +++ b/src/plugins/vis_type_pie/public/pie_fn.test.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { functionWrapper } from '../../expressions/common/expression_functions/specs/tests/utils'; +import { createPieVisFn } from './pie_fn'; + +describe('interpreter/functions#pie', () => { + const fn = functionWrapper(createPieVisFn()); + const context = { + type: 'datatable', + rows: [{ 'col-0-1': 0 }], + columns: [{ id: 'col-0-1', name: 'Count' }], + }; + const visConfig = { + addTooltip: true, + addLegend: true, + legendPosition: 'right', + isDonut: true, + nestedLegend: true, + distinctColors: false, + palette: 'kibana_palette', + labels: { + show: false, + values: true, + position: 'default', + valuesFormat: 'percent', + percentDecimals: 2, + truncate: 100, + }, + metric: { + accessor: 0, + format: { + id: 'number', + }, + params: {}, + aggType: 'count', + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns an object with the correct structure', async () => { + const actual = await fn(context, visConfig); + expect(actual).toMatchSnapshot(); + }); +}); diff --git a/src/plugins/vis_type_pie/public/pie_fn.ts b/src/plugins/vis_type_pie/public/pie_fn.ts new file mode 100644 index 0000000000000..1b5b8574f9311 --- /dev/null +++ b/src/plugins/vis_type_pie/public/pie_fn.ts @@ -0,0 +1,153 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; +import { ExpressionFunctionDefinition, Datatable, Render } from '../../expressions/public'; +import { PieVisParams, PieVisConfig } from './types'; + +export const vislibPieName = 'pie_vis'; + +export interface RenderValue { + visData: Datatable; + visType: string; + visConfig: PieVisParams; + syncColors: boolean; +} + +export type VisTypePieExpressionFunctionDefinition = ExpressionFunctionDefinition< + typeof vislibPieName, + Datatable, + PieVisConfig, + Render +>; + +export const createPieVisFn = (): VisTypePieExpressionFunctionDefinition => ({ + name: vislibPieName, + type: 'render', + inputTypes: ['datatable'], + help: i18n.translate('visTypePie.functions.help', { + defaultMessage: 'Pie visualization', + }), + args: { + metric: { + types: ['vis_dimension'], + help: i18n.translate('visTypePie.function.args.metricHelpText', { + defaultMessage: 'Metric dimensions config', + }), + required: true, + }, + buckets: { + types: ['vis_dimension'], + help: i18n.translate('visTypePie.function.args.bucketsHelpText', { + defaultMessage: 'Buckets dimensions config', + }), + multi: true, + }, + splitColumn: { + types: ['vis_dimension'], + help: i18n.translate('visTypePie.function.args.splitColumnHelpText', { + defaultMessage: 'Split by column dimension config', + }), + multi: true, + }, + splitRow: { + types: ['vis_dimension'], + help: i18n.translate('visTypePie.function.args.splitRowHelpText', { + defaultMessage: 'Split by row dimension config', + }), + multi: true, + }, + addTooltip: { + types: ['boolean'], + help: i18n.translate('visTypePie.function.args.addTooltipHelpText', { + defaultMessage: 'Show tooltip on slice hover', + }), + default: true, + }, + addLegend: { + types: ['boolean'], + help: i18n.translate('visTypePie.function.args.addLegendHelpText', { + defaultMessage: 'Show legend chart legend', + }), + }, + legendPosition: { + types: ['string'], + help: i18n.translate('visTypePie.function.args.legendPositionHelpText', { + defaultMessage: 'Position the legend on top, bottom, left, right of the chart', + }), + }, + nestedLegend: { + types: ['boolean'], + help: i18n.translate('visTypePie.function.args.nestedLegendHelpText', { + defaultMessage: 'Show a more detailed legend', + }), + default: false, + }, + distinctColors: { + types: ['boolean'], + help: i18n.translate('visTypePie.function.args.distinctColorsHelpText', { + defaultMessage: + 'Maps different color per slice. Slices with the same value have the same color', + }), + default: false, + }, + isDonut: { + types: ['boolean'], + help: i18n.translate('visTypePie.function.args.isDonutHelpText', { + defaultMessage: 'Displays the pie chart as donut', + }), + default: false, + }, + palette: { + types: ['string'], + help: i18n.translate('visTypePie.function.args.paletteHelpText', { + defaultMessage: 'Defines the chart palette name', + }), + default: 'default', + }, + labels: { + types: ['pie_labels'], + help: i18n.translate('visTypePie.function.args.labelsHelpText', { + defaultMessage: 'Pie labels config', + }), + }, + }, + fn(context, args, handlers) { + const visConfig = { + ...args, + palette: { + type: 'palette', + name: args.palette, + }, + dimensions: { + metric: args.metric, + buckets: args.buckets, + splitColumn: args.splitColumn, + splitRow: args.splitRow, + }, + } as PieVisParams; + + if (handlers?.inspectorAdapters?.tables) { + handlers.inspectorAdapters.tables.logDatatable('default', context); + } + + return { + type: 'render', + as: vislibPieName, + value: { + visData: context, + visConfig, + syncColors: handlers?.isSyncColorsEnabled?.() ?? false, + visType: 'pie', + params: { + listenOnChange: true, + }, + }, + }; + }, +}); diff --git a/src/plugins/vis_type_pie/public/pie_renderer.tsx b/src/plugins/vis_type_pie/public/pie_renderer.tsx new file mode 100644 index 0000000000000..bcd4cad4efa66 --- /dev/null +++ b/src/plugins/vis_type_pie/public/pie_renderer.tsx @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { lazy } from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; +import { I18nProvider } from '@kbn/i18n/react'; +import { ExpressionRenderDefinition } from '../../expressions/public'; +import { VisualizationContainer } from '../../visualizations/public'; +import type { PersistedState } from '../../visualizations/public'; +import { VisTypePieDependencies } from './plugin'; + +import { RenderValue, vislibPieName } from './pie_fn'; + +const PieComponent = lazy(() => import('./pie_component')); + +function shouldShowNoResultsMessage(visData: any): boolean { + const rows: object[] | undefined = visData?.rows; + const isZeroHits = visData?.hits === 0 || (rows && !rows.length); + + return Boolean(isZeroHits); +} + +export const getPieVisRenderer: ( + deps: VisTypePieDependencies +) => ExpressionRenderDefinition = ({ theme, palettes, getStartDeps }) => ({ + name: vislibPieName, + displayName: 'Pie visualization', + reuseDomNode: true, + render: async (domNode, { visConfig, visData, syncColors }, handlers) => { + const showNoResult = shouldShowNoResultsMessage(visData); + + handlers.onDestroy(() => { + unmountComponentAtNode(domNode); + }); + + const services = await getStartDeps(); + const palettesRegistry = await palettes.getPalettes(); + + render( + + + + + , + domNode + ); + }, +}); diff --git a/src/plugins/vis_type_pie/public/plugin.ts b/src/plugins/vis_type_pie/public/plugin.ts new file mode 100644 index 0000000000000..440a3a75a2eb1 --- /dev/null +++ b/src/plugins/vis_type_pie/public/plugin.ts @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { CoreSetup, DocLinksStart } from 'src/core/public'; +import { VisualizationsSetup } from '../../visualizations/public'; +import { Plugin as ExpressionsPublicPlugin } from '../../expressions/public'; +import { ChartsPluginSetup } from '../../charts/public'; +import { UsageCollectionSetup } from '../../usage_collection/public'; +import { DataPublicPluginStart } from '../../data/public'; +import { LEGACY_CHARTS_LIBRARY } from '../../visualizations/common/constants'; +import { pieLabels as pieLabelsExpressionFunction } from './expression_functions/pie_labels'; +import { createPieVisFn } from './pie_fn'; +import { getPieVisRenderer } from './pie_renderer'; +import { pieVisType } from './vis_type'; + +/** @internal */ +export interface VisTypePieSetupDependencies { + visualizations: VisualizationsSetup; + expressions: ReturnType; + charts: ChartsPluginSetup; + usageCollection: UsageCollectionSetup; +} + +/** @internal */ +export interface VisTypePiePluginStartDependencies { + data: DataPublicPluginStart; +} + +/** @internal */ +export interface VisTypePieDependencies { + theme: ChartsPluginSetup['theme']; + palettes: ChartsPluginSetup['palettes']; + getStartDeps: () => Promise<{ data: DataPublicPluginStart; docLinks: DocLinksStart }>; +} + +export class VisTypePiePlugin { + setup( + core: CoreSetup, + { expressions, visualizations, charts, usageCollection }: VisTypePieSetupDependencies + ) { + if (!core.uiSettings.get(LEGACY_CHARTS_LIBRARY, false)) { + const getStartDeps = async () => { + const [coreStart, deps] = await core.getStartServices(); + return { + data: deps.data, + docLinks: coreStart.docLinks, + }; + }; + const trackUiMetric = usageCollection?.reportUiCounter.bind(usageCollection, 'vis_type_pie'); + + expressions.registerFunction(createPieVisFn); + expressions.registerRenderer( + getPieVisRenderer({ theme: charts.theme, palettes: charts.palettes, getStartDeps }) + ); + expressions.registerFunction(pieLabelsExpressionFunction); + visualizations.createBaseVisualization( + pieVisType({ + showElasticChartsOptions: true, + palettes: charts.palettes, + trackUiMetric, + }) + ); + } + return {}; + } + + start() {} +} diff --git a/src/plugins/vis_type_pie/public/sample_vis.test.mocks.ts b/src/plugins/vis_type_pie/public/sample_vis.test.mocks.ts new file mode 100644 index 0000000000000..3b07743e79f45 --- /dev/null +++ b/src/plugins/vis_type_pie/public/sample_vis.test.mocks.ts @@ -0,0 +1,1332 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export const samplePieVis = { + type: { + name: 'pie', + title: 'Pie', + description: 'Compare parts of a whole', + icon: 'visPie', + stage: 'production', + options: { + showTimePicker: true, + showQueryBar: true, + showFilterBar: true, + showIndexSelection: true, + hierarchicalData: false, + }, + visConfig: { + defaults: { + type: 'pie', + addTooltip: true, + addLegend: true, + legendPosition: 'right', + isDonut: true, + nestedLegend: true, + distinctColors: false, + palette: 'kibana_palette', + labels: { + show: false, + values: true, + last_level: true, + valuesFormat: 'percent', + percentDecimals: 2, + truncate: 100, + }, + }, + }, + editorConfig: { + collections: { + legendPositions: [ + { + text: 'Top', + value: 'top', + }, + { + text: 'Left', + value: 'left', + }, + { + text: 'Right', + value: 'right', + }, + { + text: 'Bottom', + value: 'bottom', + }, + ], + }, + schemas: { + all: [ + { + group: 'metrics', + name: 'metric', + title: 'Slice size', + min: 1, + max: 1, + aggFilter: ['sum', 'count', 'cardinality', 'top_hits'], + defaults: [ + { + schema: 'metric', + type: 'count', + }, + ], + editor: false, + params: [], + }, + { + group: 'buckets', + name: 'segment', + title: 'Split slices', + min: 0, + max: null, + aggFilter: ['!geohash_grid', '!geotile_grid', '!filter'], + editor: false, + params: [], + }, + { + group: 'buckets', + name: 'split', + title: 'Split chart', + mustBeFirst: true, + min: 0, + max: 1, + aggFilter: ['!geohash_grid', '!geotile_grid', '!filter'], + params: [ + { + name: 'row', + default: true, + }, + ], + editor: false, + }, + ], + buckets: [null, null], + metrics: [null], + }, + }, + hidden: false, + hierarchicalData: true, + }, + title: '[Flights] Airline Carrier', + description: '', + params: { + type: 'pie', + addTooltip: true, + addLegend: true, + legendPosition: 'right', + isDonut: true, + labels: { + show: true, + values: true, + last_level: true, + truncate: 100, + }, + }, + data: { + indexPattern: { id: '123' }, + searchSource: { + id: 'data_source1', + requestStartHandlers: [], + inheritOptions: {}, + history: [], + fields: { + filter: [], + query: { + query: '', + language: 'kuery', + }, + index: { + id: 'd3d7af60-4c81-11e8-b3d7-01146121b73d', + title: 'kibana_sample_data_flights', + fieldFormatMap: { + AvgTicketPrice: { + id: 'number', + params: { + parsedUrl: { + origin: 'http://localhost:5801', + pathname: '/app/visualize', + basePath: '', + }, + pattern: '$0,0.[00]', + }, + }, + hour_of_day: { + id: 'number', + params: { + pattern: '00', + }, + }, + }, + fields: [ + { + count: 0, + name: 'AvgTicketPrice', + type: 'number', + esTypes: ['float'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'Cancelled', + type: 'boolean', + esTypes: ['boolean'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'Carrier', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'Dest', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'DestAirportID', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'DestCityName', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'DestCountry', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'DestLocation', + type: 'geo_point', + esTypes: ['geo_point'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'DestRegion', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'DestWeather', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'DistanceKilometers', + type: 'number', + esTypes: ['float'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'DistanceMiles', + type: 'number', + esTypes: ['float'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'FlightDelay', + type: 'boolean', + esTypes: ['boolean'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'FlightDelayMin', + type: 'number', + esTypes: ['integer'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'FlightDelayType', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'FlightNum', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'FlightTimeHour', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'FlightTimeMin', + type: 'number', + esTypes: ['float'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'Origin', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'OriginAirportID', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'OriginCityName', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'OriginCountry', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'OriginLocation', + type: 'geo_point', + esTypes: ['geo_point'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'OriginRegion', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'OriginWeather', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: '_id', + type: 'string', + esTypes: ['_id'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: false, + }, + { + count: 0, + name: '_index', + type: 'string', + esTypes: ['_index'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: false, + }, + { + count: 0, + name: '_score', + type: 'number', + scripted: false, + searchable: false, + aggregatable: false, + readFromDocValues: false, + }, + { + count: 0, + name: '_source', + type: '_source', + esTypes: ['_source'], + scripted: false, + searchable: false, + aggregatable: false, + readFromDocValues: false, + }, + { + count: 0, + name: '_type', + type: 'string', + esTypes: ['_type'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: false, + }, + { + count: 0, + name: 'dayOfWeek', + type: 'number', + esTypes: ['integer'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'timestamp', + type: 'date', + esTypes: ['date'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + script: "doc['timestamp'].value.hourOfDay", + lang: 'painless', + name: 'hour_of_day', + type: 'number', + scripted: true, + searchable: true, + aggregatable: true, + readFromDocValues: false, + }, + ], + timeFieldName: 'timestamp', + metaFields: ['_source', '_id', '_type', '_index', '_score'], + version: 'WzM1LDFd', + originalSavedObjectBody: { + title: 'kibana_sample_data_flights', + timeFieldName: 'timestamp', + fields: + '[{"count":0,"name":"AvgTicketPrice","type":"number","esTypes":["float"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"Cancelled","type":"boolean","esTypes":["boolean"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"Carrier","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"Dest","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"DestAirportID","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"DestCityName","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"DestCountry","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"DestLocation","type":"geo_point","esTypes":["geo_point"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"DestRegion","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"DestWeather","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"DistanceKilometers","type":"number","esTypes":["float"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"DistanceMiles","type":"number","esTypes":["float"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"FlightDelay","type":"boolean","esTypes":["boolean"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"FlightDelayMin","type":"number","esTypes":["integer"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"FlightDelayType","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"FlightNum","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"FlightTimeHour","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"FlightTimeMin","type":"number","esTypes":["float"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"Origin","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"OriginAirportID","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"OriginCityName","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"OriginCountry","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"OriginLocation","type":"geo_point","esTypes":["geo_point"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"OriginRegion","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"OriginWeather","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"_id","type":"string","esTypes":["_id"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":false},{"count":0,"name":"_index","type":"string","esTypes":["_index"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":false},{"count":0,"name":"_score","type":"number","scripted":false,"searchable":false,"aggregatable":false,"readFromDocValues":false},{"count":0,"name":"_source","type":"_source","esTypes":["_source"],"scripted":false,"searchable":false,"aggregatable":false,"readFromDocValues":false},{"count":0,"name":"_type","type":"string","esTypes":["_type"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":false},{"count":0,"name":"dayOfWeek","type":"number","esTypes":["integer"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"timestamp","type":"date","esTypes":["date"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"script":"doc[\'timestamp\'].value.hourOfDay","lang":"painless","name":"hour_of_day","type":"number","scripted":true,"searchable":true,"aggregatable":true,"readFromDocValues":false}]', + fieldFormatMap: + '{"AvgTicketPrice":{"id":"number","params":{"parsedUrl":{"origin":"http://localhost:5801","pathname":"/app/visualize","basePath":""},"pattern":"$0,0.[00]"}},"hour_of_day":{"id":"number","params":{"parsedUrl":{"origin":"http://localhost:5801","pathname":"/app/visualize","basePath":""},"pattern":"00"}}}', + }, + shortDotsEnable: false, + fieldFormats: { + fieldFormats: {}, + defaultMap: { + ip: { + id: 'ip', + params: {}, + }, + date: { + id: 'date', + params: {}, + }, + date_nanos: { + id: 'date_nanos', + params: {}, + es: true, + }, + number: { + id: 'number', + params: {}, + }, + boolean: { + id: 'boolean', + params: {}, + }, + _source: { + id: '_source', + params: {}, + }, + _default_: { + id: 'string', + params: {}, + }, + }, + metaParamsOptions: {}, + }, + }, + }, + dependencies: { + legacy: { + loadingCount$: { + _isScalar: false, + observers: [ + { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: null, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: false, + destination: { + closed: false, + _parentOrParents: null, + _subscriptions: null, + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + destination: { + closed: true, + }, + _context: {}, + }, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: false, + count: 1, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: false, + hasPrev: true, + prev: 0, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + parent: { + closed: true, + _parentOrParents: null, + _subscriptions: null, + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: true, + concurrent: 1, + hasCompleted: true, + buffer: [], + active: 1, + index: 2, + }, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + parent: { + closed: true, + _parentOrParents: null, + _subscriptions: null, + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: true, + concurrent: 1, + hasCompleted: true, + buffer: [ + { + _isScalar: false, + }, + ], + active: 1, + index: 1, + }, + }, + _subscriptions: [ + { + closed: false, + _subscriptions: [ + { + closed: false, + _subscriptions: null, + subject: { + _isScalar: false, + observers: [ + { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: null, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: false, + destination: { + closed: false, + _parentOrParents: null, + _subscriptions: null, + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + _context: {}, + }, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: false, + count: 13, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: false, + hasPrev: true, + prev: 0, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + parent: { + closed: true, + _parentOrParents: null, + _subscriptions: null, + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: true, + concurrent: 1, + hasCompleted: true, + buffer: [], + active: 1, + index: 2, + }, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + parent: { + closed: true, + _parentOrParents: null, + _subscriptions: null, + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: true, + concurrent: 1, + hasCompleted: true, + buffer: [ + { + _isScalar: false, + }, + ], + active: 1, + index: 1, + }, + }, + _subscriptions: [ + null, + { + closed: false, + _subscriptions: [ + { + closed: false, + _subscriptions: [ + { + closed: false, + _subscriptions: null, + subject: { + _isScalar: false, + observers: [null], + closed: false, + isStopped: false, + hasError: false, + thrownError: null, + _value: 0, + }, + }, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + hasKey: true, + key: 0, + }, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + }, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + seenValue: false, + }, + _subscriptions: [ + { + closed: false, + _subscriptions: null, + }, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + }, + { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: null, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: false, + destination: { + closed: false, + _parentOrParents: null, + _subscriptions: null, + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + _context: {}, + }, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: false, + count: 1, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: false, + hasPrev: true, + prev: 0, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + parent: { + closed: true, + _parentOrParents: null, + _subscriptions: null, + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: true, + concurrent: 1, + hasCompleted: true, + buffer: [], + active: 1, + index: 2, + }, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + parent: { + closed: true, + _parentOrParents: null, + _subscriptions: null, + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: true, + concurrent: 1, + hasCompleted: true, + buffer: [ + { + _isScalar: false, + }, + ], + active: 1, + index: 1, + }, + }, + _subscriptions: [ + null, + { + closed: false, + _subscriptions: [ + { + closed: false, + _subscriptions: [ + { + closed: false, + _subscriptions: null, + subject: { + _isScalar: false, + observers: [null], + closed: false, + isStopped: false, + hasError: false, + thrownError: null, + _value: 0, + }, + }, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + hasKey: true, + key: 0, + }, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + }, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + seenValue: false, + }, + _subscriptions: [ + { + closed: false, + _subscriptions: null, + }, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + }, + { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: null, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: false, + destination: { + closed: false, + _parentOrParents: null, + _subscriptions: null, + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + _context: {}, + }, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: false, + count: 1, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: false, + hasPrev: true, + prev: 0, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + parent: { + closed: true, + _parentOrParents: null, + _subscriptions: null, + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: true, + concurrent: 1, + hasCompleted: true, + buffer: [], + active: 1, + index: 2, + }, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + parent: { + closed: true, + _parentOrParents: null, + _subscriptions: null, + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: true, + concurrent: 1, + hasCompleted: true, + buffer: [ + { + _isScalar: false, + }, + ], + active: 1, + index: 1, + }, + }, + _subscriptions: [ + null, + { + closed: false, + _subscriptions: [ + { + closed: false, + _subscriptions: [ + { + closed: false, + _subscriptions: null, + subject: { + _isScalar: false, + observers: [null], + closed: false, + isStopped: false, + hasError: false, + thrownError: null, + _value: 0, + }, + }, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + hasKey: true, + key: 0, + }, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + }, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + seenValue: false, + }, + _subscriptions: [ + { + closed: false, + _subscriptions: null, + }, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + }, + { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: null, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: false, + destination: { + closed: false, + _parentOrParents: null, + _subscriptions: null, + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + _context: {}, + }, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: false, + count: 3, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: false, + hasPrev: true, + prev: 0, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + parent: { + closed: true, + _parentOrParents: null, + _subscriptions: null, + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: true, + concurrent: 1, + hasCompleted: true, + buffer: [], + active: 1, + index: 2, + }, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + parent: { + closed: true, + _parentOrParents: null, + _subscriptions: null, + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: true, + concurrent: 1, + hasCompleted: true, + buffer: [ + { + _isScalar: false, + }, + ], + active: 1, + index: 1, + }, + }, + _subscriptions: [ + null, + { + closed: false, + _subscriptions: [ + { + closed: false, + _subscriptions: [ + { + closed: false, + _subscriptions: null, + subject: { + _isScalar: false, + observers: [null], + closed: false, + isStopped: false, + hasError: false, + thrownError: null, + _value: 0, + }, + }, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + hasKey: true, + key: 0, + }, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + }, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + seenValue: false, + }, + _subscriptions: [ + { + closed: false, + _subscriptions: null, + }, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + }, + null, + ], + closed: false, + isStopped: false, + hasError: false, + thrownError: null, + }, + }, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + }, + null, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + seenValue: false, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + }, + _subscriptions: [ + { + closed: false, + _subscriptions: null, + }, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + hasKey: true, + key: 0, + }, + ], + closed: false, + isStopped: false, + hasError: false, + thrownError: null, + _value: 0, + }, + }, + }, + }, + aggs: { + typesRegistry: {}, + getResponseAggs: () => [ + { + id: '1', + enabled: true, + type: 'count', + params: {}, + schema: 'metric', + toSerializedFieldFormat: () => ({ + id: 'number', + }), + }, + { + id: '2', + enabled: true, + type: 'terms', + params: { + field: 'Carrier', + orderBy: '1', + order: 'desc', + size: 5, + otherBucket: false, + otherBucketLabel: 'Other', + missingBucket: false, + missingBucketLabel: 'Missing', + }, + schema: 'segment', + toSerializedFieldFormat: () => ({ + id: 'terms', + params: { + id: 'string', + otherBucketLabel: 'Other', + missingBucketLabel: 'Missing', + parsedUrl: { + origin: 'http://localhost:5801', + pathname: '/app/visualize', + basePath: '', + }, + }, + }), + }, + ], + aggs: [], + }, + }, + isHierarchical: () => true, + uiState: { + vis: { + legendOpen: false, + }, + }, +}; diff --git a/src/plugins/vis_type_pie/public/to_ast.test.ts b/src/plugins/vis_type_pie/public/to_ast.test.ts new file mode 100644 index 0000000000000..019c6e2176710 --- /dev/null +++ b/src/plugins/vis_type_pie/public/to_ast.test.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { Vis } from '../../visualizations/public'; + +import { PieVisParams } from './types'; +import { samplePieVis } from './sample_vis.test.mocks'; +import { toExpressionAst } from './to_ast'; + +describe('vis type pie vis toExpressionAst function', () => { + let vis: Vis; + const params = { + timefilter: {}, + timeRange: {}, + abortSignal: {}, + } as any; + + beforeEach(() => { + vis = samplePieVis as any; + }); + + it('should match basic snapshot', async () => { + const actual = await toExpressionAst(vis, params); + expect(actual).toMatchSnapshot(); + }); +}); diff --git a/src/plugins/vis_type_pie/public/to_ast.ts b/src/plugins/vis_type_pie/public/to_ast.ts new file mode 100644 index 0000000000000..e8c9f301b4366 --- /dev/null +++ b/src/plugins/vis_type_pie/public/to_ast.ts @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { getVisSchemas, VisToExpressionAst, SchemaConfig } from '../../visualizations/public'; +import { buildExpression, buildExpressionFunction } from '../../expressions/public'; +import { PieVisParams, LabelsParams } from './types'; +import { vislibPieName, VisTypePieExpressionFunctionDefinition } from './pie_fn'; +import { getEsaggsFn } from './to_ast_esaggs'; + +const prepareDimension = (params: SchemaConfig) => { + const visdimension = buildExpressionFunction('visdimension', { accessor: params.accessor }); + + if (params.format) { + visdimension.addArgument('format', params.format.id); + visdimension.addArgument('formatParams', JSON.stringify(params.format.params)); + } + + return buildExpression([visdimension]); +}; + +const prepareLabels = (params: LabelsParams) => { + const pieLabels = buildExpressionFunction('pielabels', { + show: params.show, + lastLevel: params.last_level, + values: params.values, + truncate: params.truncate, + }); + if (params.position) { + pieLabels.addArgument('position', params.position); + } + if (params.valuesFormat) { + pieLabels.addArgument('valuesFormat', params.valuesFormat); + } + if (params.percentDecimals != null) { + pieLabels.addArgument('percentDecimals', params.percentDecimals); + } + return buildExpression([pieLabels]); +}; + +export const toExpressionAst: VisToExpressionAst = async (vis, params) => { + const schemas = getVisSchemas(vis, params); + const args = { + // explicitly pass each param to prevent extra values trapping + addTooltip: vis.params.addTooltip, + addLegend: vis.params.addLegend, + legendPosition: vis.params.legendPosition, + nestedLegend: vis.params?.nestedLegend, + distinctColors: vis.params?.distinctColors, + isDonut: vis.params.isDonut, + palette: vis.params?.palette?.name, + labels: prepareLabels(vis.params.labels), + metric: schemas.metric.map(prepareDimension), + buckets: schemas.segment?.map(prepareDimension), + splitColumn: schemas.split_column?.map(prepareDimension), + splitRow: schemas.split_row?.map(prepareDimension), + }; + + const visTypePie = buildExpressionFunction( + vislibPieName, + args + ); + + const ast = buildExpression([getEsaggsFn(vis), visTypePie]); + + return ast.toAst(); +}; diff --git a/src/plugins/vis_type_pie/public/to_ast_esaggs.ts b/src/plugins/vis_type_pie/public/to_ast_esaggs.ts new file mode 100644 index 0000000000000..9b760bd4bebcc --- /dev/null +++ b/src/plugins/vis_type_pie/public/to_ast_esaggs.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { Vis } from '../../visualizations/public'; +import { buildExpression, buildExpressionFunction } from '../../expressions/public'; +import { + EsaggsExpressionFunctionDefinition, + IndexPatternLoadExpressionFunctionDefinition, +} from '../../data/public'; + +import { PieVisParams } from './types'; + +/** + * Get esaggs expressions function + * @param vis + */ +export function getEsaggsFn(vis: Vis) { + return buildExpressionFunction('esaggs', { + index: buildExpression([ + buildExpressionFunction('indexPatternLoad', { + id: vis.data.indexPattern!.id!, + }), + ]), + metricsAtAllLevels: vis.isHierarchical(), + partialRows: false, + aggs: vis.data.aggs!.aggs.map((agg) => buildExpression(agg.toExpressionAst())), + }); +} diff --git a/src/plugins/vis_type_pie/public/types/index.ts b/src/plugins/vis_type_pie/public/types/index.ts new file mode 100644 index 0000000000000..12594660136d8 --- /dev/null +++ b/src/plugins/vis_type_pie/public/types/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './types'; diff --git a/src/plugins/vis_type_pie/public/types/types.ts b/src/plugins/vis_type_pie/public/types/types.ts new file mode 100644 index 0000000000000..4f3365545d062 --- /dev/null +++ b/src/plugins/vis_type_pie/public/types/types.ts @@ -0,0 +1,96 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { Position } from '@elastic/charts'; +import { UiCounterMetricType } from '@kbn/analytics'; +import { DatatableColumn, SerializedFieldFormat } from '../../../expressions/public'; +import { ExpressionValueVisDimension } from '../../../visualizations/public'; +import { ExpressionValuePieLabels } from '../expression_functions/pie_labels'; +import { PaletteOutput, ChartsPluginSetup } from '../../../charts/public'; + +export interface Dimension { + accessor: number; + format: { + id?: string; + params?: SerializedFieldFormat; + }; +} + +export interface Dimensions { + metric: Dimension; + buckets?: Dimension[]; + splitRow?: Dimension[]; + splitColumn?: Dimension[]; +} + +interface PieCommonParams { + addTooltip: boolean; + addLegend: boolean; + legendPosition: Position; + nestedLegend: boolean; + distinctColors: boolean; + isDonut: boolean; +} + +export interface LabelsParams { + show: boolean; + last_level: boolean; + position: LabelPositions; + values: boolean; + truncate: number | null; + valuesFormat: ValueFormats; + percentDecimals: number; +} + +export interface PieVisParams extends PieCommonParams { + dimensions: Dimensions; + labels: LabelsParams; + palette: PaletteOutput; +} + +export interface PieVisConfig extends PieCommonParams { + buckets?: ExpressionValueVisDimension[]; + metric: ExpressionValueVisDimension; + splitColumn?: ExpressionValueVisDimension[]; + splitRow?: ExpressionValueVisDimension[]; + labels: ExpressionValuePieLabels; + palette: string; +} + +export interface BucketColumns extends DatatableColumn { + format?: { + id?: string; + params?: SerializedFieldFormat; + }; +} + +export enum LabelPositions { + INSIDE = 'inside', + DEFAULT = 'default', +} + +export enum ValueFormats { + PERCENT = 'percent', + VALUE = 'value', +} + +export interface PieTypeProps { + showElasticChartsOptions?: boolean; + palettes?: ChartsPluginSetup['palettes']; + trackUiMetric?: (metricType: UiCounterMetricType, eventName: string | string[]) => void; +} + +export interface SplitDimensionParams { + order?: string; + orderBy?: string; +} + +export interface PieContainerDimensions { + width: number; + height: number; +} diff --git a/src/plugins/vis_type_pie/public/utils/filter_helpers.test.ts b/src/plugins/vis_type_pie/public/utils/filter_helpers.test.ts new file mode 100644 index 0000000000000..3f532cf4c384f --- /dev/null +++ b/src/plugins/vis_type_pie/public/utils/filter_helpers.test.ts @@ -0,0 +1,98 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { DatatableColumn } from '../../../expressions/public'; +import { getFilterClickData, getFilterEventData } from './filter_helpers'; +import { createMockBucketColumns, createMockVisData } from '../mocks'; + +const bucketColumns = createMockBucketColumns(); +const visData = createMockVisData(); + +describe('getFilterClickData', () => { + it('returns the correct filter data for the specific layer', () => { + const clickedLayers = [ + { + groupByRollup: 'Logstash Airways', + value: 729, + depth: 1, + path: [], + sortIndex: 1, + smAccessorValue: '', + }, + ]; + const data = getFilterClickData(clickedLayers, bucketColumns, visData); + expect(data.length).toEqual(clickedLayers.length); + expect(data[0].value).toEqual('Logstash Airways'); + expect(data[0].row).toEqual(0); + expect(data[0].column).toEqual(0); + }); + + it('changes the filter if the user clicks on another layer', () => { + const clickedLayers = [ + { + groupByRollup: 'ES-Air', + value: 572, + depth: 1, + path: [], + sortIndex: 1, + smAccessorValue: '', + }, + ]; + const data = getFilterClickData(clickedLayers, bucketColumns, visData); + expect(data.length).toEqual(clickedLayers.length); + expect(data[0].value).toEqual('ES-Air'); + expect(data[0].row).toEqual(4); + expect(data[0].column).toEqual(0); + }); + + it('returns the correct filters for small multiples', () => { + const clickedLayers = [ + { + groupByRollup: 'ES-Air', + value: 572, + depth: 1, + path: [], + sortIndex: 1, + smAccessorValue: 1, + }, + ]; + const splitDimension = { + id: 'col-2-3', + name: 'Cancelled: Descending', + } as DatatableColumn; + const data = getFilterClickData(clickedLayers, bucketColumns, visData, splitDimension); + expect(data.length).toEqual(2); + expect(data[0].value).toEqual('ES-Air'); + expect(data[0].row).toEqual(5); + expect(data[0].column).toEqual(0); + expect(data[1].value).toEqual(1); + }); +}); + +describe('getFilterEventData', () => { + it('returns the correct filter data for the specific series', () => { + const series = { + key: 'Kibana Airlines', + specId: 'pie', + }; + const data = getFilterEventData(visData, series); + expect(data[0].value).toEqual('Kibana Airlines'); + expect(data[0].row).toEqual(6); + expect(data[0].column).toEqual(0); + }); + + it('changes the filter if the user clicks on another series', () => { + const series = { + key: 'JetBeats', + specId: 'pie', + }; + const data = getFilterEventData(visData, series); + expect(data[0].value).toEqual('JetBeats'); + expect(data[0].row).toEqual(2); + expect(data[0].column).toEqual(0); + }); +}); diff --git a/src/plugins/vis_type_pie/public/utils/filter_helpers.ts b/src/plugins/vis_type_pie/public/utils/filter_helpers.ts new file mode 100644 index 0000000000000..251ff8acc698e --- /dev/null +++ b/src/plugins/vis_type_pie/public/utils/filter_helpers.ts @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { LayerValue, SeriesIdentifier } from '@elastic/charts'; +import { Datatable, DatatableColumn } from '../../../expressions/public'; +import { DataPublicPluginStart, FieldFormat } from '../../../data/public'; +import { ClickTriggerEvent } from '../../../charts/public'; +import { ValueClickContext } from '../../../embeddable/public'; +import { BucketColumns } from '../types'; + +export const canFilter = async ( + event: ClickTriggerEvent | null, + actions: DataPublicPluginStart['actions'] +): Promise => { + if (!event) { + return false; + } + const filters = await actions.createFiltersFromValueClickAction(event.data); + return Boolean(filters.length); +}; + +export const getFilterClickData = ( + clickedLayers: LayerValue[], + bucketColumns: Array>, + visData: Datatable, + splitChartDimension?: DatatableColumn, + splitChartFormatter?: FieldFormat +): ValueClickContext['data']['data'] => { + const data: ValueClickContext['data']['data'] = []; + const matchingIndex = visData.rows.findIndex((row) => + clickedLayers.every((layer, index) => { + const columnId = bucketColumns[index].id; + if (!columnId) return; + const isCurrentLayer = row[columnId] === layer.groupByRollup; + if (!splitChartDimension) { + return isCurrentLayer; + } + const value = + splitChartFormatter?.convert(row[splitChartDimension.id]) || row[splitChartDimension.id]; + return isCurrentLayer && value === layer.smAccessorValue; + }) + ); + + data.push( + ...clickedLayers.map((clickedLayer, index) => ({ + column: visData.columns.findIndex((col) => col.id === bucketColumns[index].id), + row: matchingIndex, + value: clickedLayer.groupByRollup, + table: visData, + })) + ); + + // Allows filtering with the small multiples value + if (splitChartDimension) { + data.push({ + column: visData.columns.findIndex((col) => col.id === splitChartDimension.id), + row: matchingIndex, + table: visData, + value: clickedLayers[0].smAccessorValue, + }); + } + + return data; +}; + +export const getFilterEventData = ( + visData: Datatable, + series: SeriesIdentifier +): ValueClickContext['data']['data'] => { + return visData.columns.reduce((acc, { id }, column) => { + const value = series.key; + const row = visData.rows.findIndex((r) => r[id] === value); + if (row > -1) { + acc.push({ + table: visData, + column, + row, + value, + }); + } + + return acc; + }, []); +}; diff --git a/src/plugins/vis_type_pie/public/utils/get_color_picker.test.tsx b/src/plugins/vis_type_pie/public/utils/get_color_picker.test.tsx new file mode 100644 index 0000000000000..5e9087947b95e --- /dev/null +++ b/src/plugins/vis_type_pie/public/utils/get_color_picker.test.tsx @@ -0,0 +1,116 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { LegendColorPickerProps } from '@elastic/charts'; +import { EuiPopover } from '@elastic/eui'; +import { mountWithIntl } from '@kbn/test/jest'; +import { ComponentType, ReactWrapper } from 'enzyme'; +import { getColorPicker } from './get_color_picker'; +import { ColorPicker } from '../../../charts/public'; +import type { PersistedState } from '../../../visualizations/public'; +import { createMockBucketColumns, createMockVisData } from '../mocks'; + +const bucketColumns = createMockBucketColumns(); +const visData = createMockVisData(); + +jest.mock('@elastic/charts', () => { + const original = jest.requireActual('@elastic/charts'); + + return { + ...original, + getSpecId: jest.fn(() => {}), + }; +}); + +describe('getColorPicker', function () { + const mockState = new Map(); + const uiState = ({ + get: jest + .fn() + .mockImplementation((key, fallback) => (mockState.has(key) ? mockState.get(key) : fallback)), + set: jest.fn().mockImplementation((key, value) => mockState.set(key, value)), + emit: jest.fn(), + setSilent: jest.fn(), + } as unknown) as PersistedState; + + let wrapperProps: LegendColorPickerProps; + const Component: ComponentType = getColorPicker( + 'left', + jest.fn(), + bucketColumns, + 'default', + visData.rows, + uiState, + false + ); + let wrapper: ReactWrapper; + + beforeAll(() => { + wrapperProps = { + color: 'rgb(109, 204, 177)', + onClose: jest.fn(), + onChange: jest.fn(), + anchor: document.createElement('div'), + seriesIdentifiers: [ + { + key: 'Logstash Airways', + specId: 'pie', + }, + ], + }; + }); + + it('renders the color picker for default palette and inner layer', () => { + wrapper = mountWithIntl(); + expect(wrapper.find(ColorPicker).length).toBe(1); + }); + + it('renders the picker on the correct position', () => { + wrapper = mountWithIntl(); + expect(wrapper.find(EuiPopover).prop('anchorPosition')).toEqual('rightCenter'); + }); + + it('converts the color to the right hex and passes it to the color picker', () => { + wrapper = mountWithIntl(); + expect(wrapper.find(ColorPicker).prop('color')).toEqual('#6dccb1'); + }); + + it('doesnt render the picker for default palette and not inner layer', () => { + const newProps = { ...wrapperProps, seriesIdentifier: { key: '1', specId: 'pie' } }; + wrapper = mountWithIntl(); + expect(wrapper).toEqual({}); + }); + + it('renders the color picker with the colorIsOverwritten prop set to false if color is not overwritten for the specific series', () => { + wrapper = mountWithIntl(); + expect(wrapper.find(ColorPicker).prop('colorIsOverwritten')).toBe(false); + }); + + it('renders the color picker with the colorIsOverwritten prop set to true if color is overwritten for the specific series', () => { + uiState.set('vis.colors', { 'Logstash Airways': '#6092c0' }); + wrapper = mountWithIntl(); + expect(wrapper.find(ColorPicker).prop('colorIsOverwritten')).toBe(true); + }); + + it('renders the picker for kibana palette and not distinctColors', () => { + const LegacyPaletteComponent: ComponentType = getColorPicker( + 'left', + jest.fn(), + bucketColumns, + 'kibana_palette', + visData.rows, + uiState, + true + ); + const newProps = { ...wrapperProps, seriesIdentifier: { key: '1', specId: 'pie' } }; + wrapper = mountWithIntl(); + expect(wrapper.find(ColorPicker).length).toBe(1); + expect(wrapper.find(ColorPicker).prop('useLegacyColors')).toBe(true); + }); +}); diff --git a/src/plugins/vis_type_pie/public/utils/get_color_picker.tsx b/src/plugins/vis_type_pie/public/utils/get_color_picker.tsx new file mode 100644 index 0000000000000..436ce81d3ce3c --- /dev/null +++ b/src/plugins/vis_type_pie/public/utils/get_color_picker.tsx @@ -0,0 +1,121 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useCallback } from 'react'; +import Color from 'color'; +import { LegendColorPicker, Position } from '@elastic/charts'; +import { PopoverAnchorPosition, EuiPopover, EuiOutsideClickDetector } from '@elastic/eui'; +import { DatatableRow } from '../../../expressions/public'; +import type { PersistedState } from '../../../visualizations/public'; +import { ColorPicker } from '../../../charts/public'; +import { BucketColumns } from '../types'; + +const KEY_CODE_ENTER = 13; + +function getAnchorPosition(legendPosition: Position): PopoverAnchorPosition { + switch (legendPosition) { + case Position.Bottom: + return 'upCenter'; + case Position.Top: + return 'downCenter'; + case Position.Left: + return 'rightCenter'; + default: + return 'leftCenter'; + } +} + +function getLayerIndex( + seriesKey: string, + data: DatatableRow[], + layers: Array> +): number { + const row = data.find((d) => Object.keys(d).find((key) => d[key] === seriesKey)); + const bucketId = row && Object.keys(row).find((key) => row[key] === seriesKey); + return layers.findIndex((layer) => layer.id === bucketId) + 1; +} + +function isOnInnerLayer( + firstBucket: Partial, + data: DatatableRow[], + seriesKey: string +): DatatableRow | undefined { + return data.find((d) => firstBucket.id && d[firstBucket.id] === seriesKey); +} + +export const getColorPicker = ( + legendPosition: Position, + setColor: (newColor: string | null, seriesKey: string | number) => void, + bucketColumns: Array>, + palette: string, + data: DatatableRow[], + uiState: PersistedState, + distinctColors: boolean +): LegendColorPicker => ({ + anchor, + color, + onClose, + onChange, + seriesIdentifiers: [seriesIdentifier], +}) => { + const seriesName = seriesIdentifier.key; + const overwriteColors: Record = uiState?.get('vis.colors', {}) ?? {}; + const colorIsOverwritten = Object.keys(overwriteColors).includes(seriesName.toString()); + let keyDownEventOn = false; + const handleChange = (newColor: string | null) => { + if (newColor) { + onChange(newColor); + } + setColor(newColor, seriesName); + // close the popover if no color is applied or the user has clicked a color + if (!newColor || !keyDownEventOn) { + onClose(); + } + }; + + const onKeyDown = (e: React.KeyboardEvent) => { + if (e.keyCode === KEY_CODE_ENTER) { + onClose?.(); + } + keyDownEventOn = true; + }; + + const handleOutsideClick = useCallback(() => { + onClose?.(); + }, [onClose]); + + if (!distinctColors) { + const enablePicker = isOnInnerLayer(bucketColumns[0], data, seriesName) || !bucketColumns[0].id; + if (!enablePicker) return null; + } + const hexColor = new Color(color).hex(); + return ( + + + + + + ); +}; diff --git a/src/plugins/vis_type_pie/public/utils/get_columns.test.ts b/src/plugins/vis_type_pie/public/utils/get_columns.test.ts new file mode 100644 index 0000000000000..3170628ec2e12 --- /dev/null +++ b/src/plugins/vis_type_pie/public/utils/get_columns.test.ts @@ -0,0 +1,222 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { getColumns } from './get_columns'; +import { PieVisParams } from '../types'; +import { createMockPieParams, createMockVisData } from '../mocks'; + +const visParams = createMockPieParams(); +const visData = createMockVisData(); + +describe('getColumns', () => { + it('should return the correct bucket columns if visParams returns dimensions', () => { + const { bucketColumns } = getColumns(visParams, visData); + expect(bucketColumns.length).toEqual(visParams.dimensions.buckets?.length); + expect(bucketColumns).toEqual([ + { + format: { + id: 'terms', + params: { + id: 'string', + missingBucketLabel: 'Missing', + otherBucketLabel: 'Other', + }, + }, + id: 'col-0-2', + meta: { + field: 'Carrier', + index: 'kibana_sample_data_flights', + params: { + id: 'terms', + params: { + id: 'string', + missingBucketLabel: 'Missing', + otherBucketLabel: 'Other', + }, + }, + source: 'esaggs', + sourceParams: { + enabled: true, + id: '2', + indexPatternId: 'd3d7af60-4c81-11e8-b3d7-01146121b73d', + params: { + field: 'Carrier', + missingBucket: false, + missingBucketLabel: 'Missing', + order: 'desc', + orderBy: '1', + otherBucket: false, + otherBucketLabel: 'Other', + size: 5, + }, + schema: 'segment', + type: 'terms', + }, + type: 'string', + }, + name: 'Carrier: Descending', + }, + { + format: { + id: 'terms', + params: { + id: 'boolean', + missingBucketLabel: 'Missing', + otherBucketLabel: 'Other', + }, + }, + id: 'col-2-3', + meta: { + field: 'Cancelled', + index: 'kibana_sample_data_flights', + params: { + id: 'terms', + params: { + id: 'boolean', + missingBucketLabel: 'Missing', + otherBucketLabel: 'Other', + }, + }, + source: 'esaggs', + sourceParams: { + enabled: true, + id: '3', + indexPatternId: 'd3d7af60-4c81-11e8-b3d7-01146121b73d', + params: { + field: 'Cancelled', + missingBucket: false, + missingBucketLabel: 'Missing', + order: 'desc', + orderBy: '1', + otherBucket: false, + otherBucketLabel: 'Other', + size: 5, + }, + schema: 'segment', + type: 'terms', + }, + type: 'boolean', + }, + name: 'Cancelled: Descending', + }, + ]); + }); + + it('should return the correct metric column if visParams returns dimensions', () => { + const { metricColumn } = getColumns(visParams, visData); + expect(metricColumn).toEqual({ + id: 'col-3-1', + meta: { + index: 'kibana_sample_data_flights', + params: { id: 'number' }, + source: 'esaggs', + sourceParams: { + enabled: true, + id: '1', + indexPatternId: 'd3d7af60-4c81-11e8-b3d7-01146121b73d', + params: {}, + schema: 'metric', + type: 'count', + }, + type: 'number', + }, + name: 'Count', + }); + }); + + it('should return the first data column if no buckets specified', () => { + const visParamsOnlyMetric = ({ + addLegend: true, + addTooltip: true, + isDonut: true, + labels: { + position: 'default', + show: true, + truncate: 100, + values: true, + valuesFormat: 'percent', + percentDecimals: 2, + }, + legendPosition: 'right', + nestedLegend: false, + palette: { + name: 'default', + type: 'palette', + }, + type: 'pie', + dimensions: { + metric: { + accessor: 1, + format: { + id: 'number', + }, + params: {}, + label: 'Count', + aggType: 'count', + }, + }, + } as unknown) as PieVisParams; + const { metricColumn } = getColumns(visParamsOnlyMetric, visData); + expect(metricColumn).toEqual({ + id: 'col-1-1', + meta: { + index: 'kibana_sample_data_flights', + params: { + id: 'number', + }, + source: 'esaggs', + sourceParams: { + enabled: true, + id: '1', + indexPatternId: 'd3d7af60-4c81-11e8-b3d7-01146121b73d', + params: {}, + schema: 'metric', + type: 'count', + }, + type: 'number', + }, + name: 'Count', + }); + }); + + it('should return an object with the name of the metric if no buckets specified', () => { + const visParamsOnlyMetric = ({ + addLegend: true, + addTooltip: true, + isDonut: true, + labels: { + position: 'default', + show: true, + truncate: 100, + values: true, + valuesFormat: 'percent', + percentDecimals: 2, + }, + legendPosition: 'right', + nestedLegend: false, + palette: { + name: 'default', + type: 'palette', + }, + type: 'pie', + dimensions: { + metric: { + accessor: 1, + format: { + id: 'number', + }, + params: {}, + label: 'Count', + aggType: 'count', + }, + }, + } as unknown) as PieVisParams; + const { bucketColumns, metricColumn } = getColumns(visParamsOnlyMetric, visData); + expect(bucketColumns).toEqual([{ name: metricColumn.name }]); + }); +}); diff --git a/src/plugins/vis_type_pie/public/utils/get_columns.ts b/src/plugins/vis_type_pie/public/utils/get_columns.ts new file mode 100644 index 0000000000000..4a32466d808da --- /dev/null +++ b/src/plugins/vis_type_pie/public/utils/get_columns.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { DatatableColumn, Datatable } from '../../../expressions/public'; +import { BucketColumns, PieVisParams } from '../types'; + +export const getColumns = ( + visParams: PieVisParams, + visData: Datatable +): { + metricColumn: DatatableColumn; + bucketColumns: Array>; +} => { + if (visParams.dimensions.buckets && visParams.dimensions.buckets.length > 0) { + const bucketColumns: Array> = visParams.dimensions.buckets.map( + ({ accessor, format }) => ({ + ...visData.columns[accessor], + format, + }) + ); + const lastBucketId = bucketColumns[bucketColumns.length - 1].id; + const matchingIndex = visData.columns.findIndex((col) => col.id === lastBucketId); + return { + bucketColumns, + metricColumn: visData.columns[matchingIndex + 1], + }; + } + const metricAccessor = visParams?.dimensions?.metric.accessor ?? 0; + const metricColumn = visData.columns[metricAccessor]; + return { + metricColumn, + bucketColumns: [ + { + name: metricColumn.name, + }, + ], + }; +}; diff --git a/src/plugins/vis_type_pie/public/utils/get_config.ts b/src/plugins/vis_type_pie/public/utils/get_config.ts new file mode 100644 index 0000000000000..a8a4edb01cd9c --- /dev/null +++ b/src/plugins/vis_type_pie/public/utils/get_config.ts @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { PartitionConfig, PartitionLayout, RecursivePartial, Theme } from '@elastic/charts'; +import { LabelPositions, PieVisParams, PieContainerDimensions } from '../types'; +const MAX_SIZE = 1000; + +export const getConfig = ( + visParams: PieVisParams, + chartTheme: RecursivePartial, + dimensions?: PieContainerDimensions +): RecursivePartial => { + // On small multiples we want the labels to only appear inside + const isSplitChart = Boolean(visParams.dimensions.splitColumn || visParams.dimensions.splitRow); + const usingMargin = + dimensions && !isSplitChart + ? { + margin: { + top: (1 - Math.min(1, MAX_SIZE / dimensions?.height)) / 2, + bottom: (1 - Math.min(1, MAX_SIZE / dimensions?.height)) / 2, + left: (1 - Math.min(1, MAX_SIZE / dimensions?.width)) / 2, + right: (1 - Math.min(1, MAX_SIZE / dimensions?.width)) / 2, + }, + } + : null; + + const usingOuterSizeRatio = + dimensions && !isSplitChart + ? { + outerSizeRatio: MAX_SIZE / Math.min(dimensions?.width, dimensions?.height), + } + : null; + const config: RecursivePartial = { + partitionLayout: PartitionLayout.sunburst, + fontFamily: chartTheme.barSeriesStyle?.displayValue?.fontFamily, + ...usingOuterSizeRatio, + specialFirstInnermostSector: false, + minFontSize: 10, + maxFontSize: 16, + linkLabel: { + maxCount: 5, + fontSize: 11, + textColor: chartTheme.axes?.axisTitle?.fill, + maxTextLength: visParams.labels.truncate ?? undefined, + }, + sectorLineStroke: chartTheme.lineSeriesStyle?.point?.fill, + sectorLineWidth: 1.5, + circlePadding: 4, + emptySizeRatio: visParams.isDonut ? 0.3 : 0, + ...usingMargin, + }; + if (!visParams.labels.show) { + // Force all labels to be linked, then prevent links from showing + config.linkLabel = { maxCount: 0, maximumSection: Number.POSITIVE_INFINITY }; + } + + if (visParams.labels.last_level && visParams.labels.show) { + config.linkLabel = { + maxCount: Number.POSITIVE_INFINITY, + maximumSection: Number.POSITIVE_INFINITY, + }; + } + + if ( + (visParams.labels.position === LabelPositions.INSIDE || isSplitChart) && + visParams.labels.show + ) { + config.linkLabel = { maxCount: 0 }; + } + return config; +}; diff --git a/src/plugins/vis_type_pie/public/utils/get_distinct_series.test.ts b/src/plugins/vis_type_pie/public/utils/get_distinct_series.test.ts new file mode 100644 index 0000000000000..3d700614a07ed --- /dev/null +++ b/src/plugins/vis_type_pie/public/utils/get_distinct_series.test.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { getDistinctSeries } from './get_distinct_series'; +import { createMockVisData, createMockBucketColumns } from '../mocks'; + +const visData = createMockVisData(); +const buckets = createMockBucketColumns(); + +describe('getDistinctSeries', () => { + it('should return the distinct values for all buckets', () => { + const { allSeries } = getDistinctSeries(visData.rows, buckets); + expect(allSeries).toEqual(['Logstash Airways', 'JetBeats', 'ES-Air', 'Kibana Airlines', 0, 1]); + }); + + it('should return only the distinct values for the parent bucket', () => { + const { parentSeries } = getDistinctSeries(visData.rows, buckets); + expect(parentSeries).toEqual(['Logstash Airways', 'JetBeats', 'ES-Air', 'Kibana Airlines']); + }); + + it('should return empty array for empty buckets', () => { + const { parentSeries } = getDistinctSeries(visData.rows, [{ name: 'Count' }]); + expect(parentSeries.length).toEqual(0); + }); +}); diff --git a/src/plugins/vis_type_pie/public/utils/get_distinct_series.ts b/src/plugins/vis_type_pie/public/utils/get_distinct_series.ts new file mode 100644 index 0000000000000..ba5042dfc210c --- /dev/null +++ b/src/plugins/vis_type_pie/public/utils/get_distinct_series.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { DatatableRow } from '../../../expressions/public'; +import { BucketColumns } from '../types'; + +export const getDistinctSeries = (rows: DatatableRow[], buckets: Array>) => { + const parentBucketId = buckets[0].id; + const parentSeries: string[] = []; + const allSeries: string[] = []; + buckets.forEach(({ id }) => { + if (!id) return; + rows.forEach((row) => { + const name = row[id]; + if (!allSeries.includes(name)) { + allSeries.push(name); + } + if (id === parentBucketId && !parentSeries.includes(row[parentBucketId])) { + parentSeries.push(row[parentBucketId]); + } + }); + }); + return { + allSeries, + parentSeries, + }; +}; diff --git a/src/plugins/vis_type_pie/public/utils/get_layers.test.ts b/src/plugins/vis_type_pie/public/utils/get_layers.test.ts new file mode 100644 index 0000000000000..e0658eaa295f9 --- /dev/null +++ b/src/plugins/vis_type_pie/public/utils/get_layers.test.ts @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { ShapeTreeNode } from '@elastic/charts'; +import { PaletteDefinition, SeriesLayer } from '../../../charts/public'; +import { computeColor } from './get_layers'; +import { createMockVisData, createMockBucketColumns, createMockPieParams } from '../mocks'; + +const visData = createMockVisData(); +const buckets = createMockBucketColumns(); +const visParams = createMockPieParams(); +const colors = ['color1', 'color2', 'color3', 'color4']; +export const getPaletteRegistry = () => { + const mockPalette1: jest.Mocked = { + id: 'default', + title: 'My Palette', + getCategoricalColor: jest.fn((layer: SeriesLayer[]) => colors[layer[0].rankAtDepth]), + getCategoricalColors: jest.fn((num: number) => colors), + toExpression: jest.fn(() => ({ + type: 'expression', + chain: [ + { + type: 'function', + function: 'system_palette', + arguments: { + name: ['default'], + }, + }, + ], + })), + }; + + return { + get: () => mockPalette1, + getAll: () => [mockPalette1], + }; +}; + +describe('computeColor', () => { + it('should return the correct color based on the parent sortIndex', () => { + const d = ({ + dataName: 'ES-Air', + depth: 1, + sortIndex: 0, + parent: { + children: [['ES-Air'], ['Kibana Airlines']], + depth: 0, + sortIndex: 0, + }, + } as unknown) as ShapeTreeNode; + const color = computeColor( + d, + false, + {}, + buckets, + visData.rows, + visParams, + getPaletteRegistry(), + false + ); + expect(color).toEqual(colors[0]); + }); + + it('slices with the same label should have the same color for small multiples', () => { + const d = ({ + dataName: 'ES-Air', + depth: 1, + sortIndex: 0, + parent: { + children: [['ES-Air'], ['Kibana Airlines']], + depth: 0, + sortIndex: 0, + }, + } as unknown) as ShapeTreeNode; + const color = computeColor( + d, + true, + {}, + buckets, + visData.rows, + visParams, + getPaletteRegistry(), + false + ); + expect(color).toEqual('color3'); + }); + it('returns the overwriteColor if exists', () => { + const d = ({ + dataName: 'ES-Air', + depth: 1, + sortIndex: 0, + parent: { + children: [['ES-Air'], ['Kibana Airlines']], + depth: 0, + sortIndex: 0, + }, + } as unknown) as ShapeTreeNode; + const color = computeColor( + d, + true, + { 'ES-Air': '#000028' }, + buckets, + visData.rows, + visParams, + getPaletteRegistry(), + false + ); + expect(color).toEqual('#000028'); + }); +}); diff --git a/src/plugins/vis_type_pie/public/utils/get_layers.ts b/src/plugins/vis_type_pie/public/utils/get_layers.ts new file mode 100644 index 0000000000000..27dcf2d379811 --- /dev/null +++ b/src/plugins/vis_type_pie/public/utils/get_layers.ts @@ -0,0 +1,186 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; +import { + Datum, + PartitionFillLabel, + PartitionLayer, + ShapeTreeNode, + ArrayEntry, +} from '@elastic/charts'; +import { isEqual } from 'lodash'; +import { SeriesLayer, PaletteRegistry, lightenColor } from '../../../charts/public'; +import { DataPublicPluginStart } from '../../../data/public'; +import { DatatableRow } from '../../../expressions/public'; +import { BucketColumns, PieVisParams, SplitDimensionParams } from '../types'; +import { getDistinctSeries } from './get_distinct_series'; + +const EMPTY_SLICE = Symbol('empty_slice'); + +export const computeColor = ( + d: ShapeTreeNode, + isSplitChart: boolean, + overwriteColors: { [key: string]: string }, + columns: Array>, + rows: DatatableRow[], + visParams: PieVisParams, + palettes: PaletteRegistry | null, + syncColors: boolean +) => { + const { parentSeries, allSeries } = getDistinctSeries(rows, columns); + + if (visParams.distinctColors) { + const dataName = d.dataName; + if (Object.keys(overwriteColors).includes(dataName.toString())) { + return overwriteColors[dataName]; + } + + const index = allSeries.findIndex((name) => isEqual(name, dataName)); + const isSplitParentLayer = isSplitChart && parentSeries.includes(dataName); + return palettes?.get(visParams.palette.name).getCategoricalColor( + [ + { + name: dataName, + rankAtDepth: isSplitParentLayer + ? parentSeries.findIndex((name) => name === dataName) + : index > -1 + ? index + : 0, + totalSeriesAtDepth: isSplitParentLayer ? parentSeries.length : allSeries.length || 1, + }, + ], + { + maxDepth: 1, + totalSeries: allSeries.length || 1, + behindText: visParams.labels.show, + syncColors, + } + ); + } + const seriesLayers: SeriesLayer[] = []; + let tempParent: typeof d | typeof d['parent'] = d; + while (tempParent.parent && tempParent.depth > 0) { + const seriesName = String(tempParent.parent.children[tempParent.sortIndex][0]); + const isSplitParentLayer = isSplitChart && parentSeries.includes(seriesName); + seriesLayers.unshift({ + name: seriesName, + rankAtDepth: isSplitParentLayer + ? parentSeries.findIndex((name) => name === seriesName) + : tempParent.sortIndex, + totalSeriesAtDepth: isSplitParentLayer + ? parentSeries.length + : tempParent.parent.children.length, + }); + tempParent = tempParent.parent; + } + + let overwriteColor; + seriesLayers.forEach((layer) => { + if (Object.keys(overwriteColors).includes(layer.name)) { + overwriteColor = overwriteColors[layer.name]; + } + }); + + if (overwriteColor) { + return lightenColor(overwriteColor, seriesLayers.length, columns.length); + } + return palettes?.get(visParams.palette.name).getCategoricalColor(seriesLayers, { + behindText: visParams.labels.show, + maxDepth: columns.length, + totalSeries: rows.length, + syncColors, + }); +}; + +export const getLayers = ( + columns: Array>, + visParams: PieVisParams, + overwriteColors: { [key: string]: string }, + rows: DatatableRow[], + palettes: PaletteRegistry | null, + formatter: DataPublicPluginStart['fieldFormats'], + syncColors: boolean +): PartitionLayer[] => { + const fillLabel: Partial = { + textInvertible: true, + valueFont: { + fontWeight: 700, + }, + }; + + if (!visParams.labels.values) { + fillLabel.valueFormatter = () => ''; + } + const isSplitChart = Boolean(visParams.dimensions.splitColumn || visParams.dimensions.splitRow); + return columns.map((col) => { + return { + groupByRollup: (d: Datum) => { + return col.id ? d[col.id] : col.name; + }, + showAccessor: (d: Datum) => d !== EMPTY_SLICE, + nodeLabel: (d: unknown) => { + if (d === '') { + return i18n.translate('visTypePie.emptyLabelValue', { + defaultMessage: '(empty)', + }); + } + if (col.format) { + const formattedLabel = formatter.deserialize(col.format).convert(d) ?? ''; + if (visParams.labels.truncate && formattedLabel.length <= visParams.labels.truncate) { + return formattedLabel; + } else { + return `${formattedLabel.slice(0, Number(visParams.labels.truncate))}\u2026`; + } + } + return String(d); + }, + sortPredicate: ([name1, node1]: ArrayEntry, [name2, node2]: ArrayEntry) => { + const params = col.meta?.sourceParams?.params as SplitDimensionParams | undefined; + const sort: string | undefined = params?.orderBy; + // unconditionally put "Other" to the end (as the "Other" slice may be larger than a regular slice, yet should be at the end) + if (name1 === '__other__' && name2 !== '__other__') return 1; + if (name2 === '__other__' && name1 !== '__other__') return -1; + // metric sorting + if (sort !== '_key') { + if (params?.order === 'desc') { + return node2.value - node1.value; + } else { + return node1.value - node2.value; + } + // alphabetical sorting + } else { + if (name1 > name2) { + return params?.order === 'desc' ? -1 : 1; + } + if (name2 > name1) { + return params?.order === 'desc' ? 1 : -1; + } + } + return 0; + }, + fillLabel, + shape: { + fillColor: (d) => { + const outputColor = computeColor( + d, + isSplitChart, + overwriteColors, + columns, + rows, + visParams, + palettes, + syncColors + ); + + return outputColor || 'rgba(0,0,0,0)'; + }, + }, + }; + }); +}; diff --git a/src/plugins/vis_type_pie/public/utils/get_legend_actions.tsx b/src/plugins/vis_type_pie/public/utils/get_legend_actions.tsx new file mode 100644 index 0000000000000..9f1d5e0db4583 --- /dev/null +++ b/src/plugins/vis_type_pie/public/utils/get_legend_actions.tsx @@ -0,0 +1,117 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useState, useEffect } from 'react'; + +import { i18n } from '@kbn/i18n'; +import { EuiContextMenuPanelDescriptor, EuiIcon, EuiPopover, EuiContextMenu } from '@elastic/eui'; +import { LegendAction, SeriesIdentifier } from '@elastic/charts'; +import { DataPublicPluginStart } from '../../../data/public'; +import { PieVisParams } from '../types'; +import { ClickTriggerEvent } from '../../../charts/public'; + +export const getLegendActions = ( + canFilter: ( + data: ClickTriggerEvent | null, + actions: DataPublicPluginStart['actions'] + ) => Promise, + getFilterEventData: (series: SeriesIdentifier) => ClickTriggerEvent | null, + onFilter: (data: ClickTriggerEvent, negate?: any) => void, + visParams: PieVisParams, + actions: DataPublicPluginStart['actions'], + formatter: DataPublicPluginStart['fieldFormats'] +): LegendAction => { + return ({ series: [pieSeries] }) => { + const [popoverOpen, setPopoverOpen] = useState(false); + const [isfilterable, setIsfilterable] = useState(true); + const filterData = getFilterEventData(pieSeries); + + useEffect(() => { + (async () => setIsfilterable(await canFilter(filterData, actions)))(); + }, [filterData]); + + if (!isfilterable || !filterData) { + return null; + } + + let formattedTitle = ''; + if (visParams.dimensions.buckets) { + const column = visParams.dimensions.buckets.find( + (bucket) => bucket.accessor === filterData.data.data[0].column + ); + formattedTitle = formatter.deserialize(column?.format).convert(pieSeries.key) ?? ''; + } + + const title = formattedTitle || pieSeries.key; + const panels: EuiContextMenuPanelDescriptor[] = [ + { + id: 'main', + title: `${title}`, + items: [ + { + name: i18n.translate('visTypePie.legend.filterForValueButtonAriaLabel', { + defaultMessage: 'Filter for value', + }), + 'data-test-subj': `legend-${title}-filterIn`, + icon: , + onClick: () => { + setPopoverOpen(false); + onFilter(filterData); + }, + }, + { + name: i18n.translate('visTypePie.legend.filterOutValueButtonAriaLabel', { + defaultMessage: 'Filter out value', + }), + 'data-test-subj': `legend-${title}-filterOut`, + icon: , + onClick: () => { + setPopoverOpen(false); + onFilter(filterData, true); + }, + }, + ], + }, + ]; + + const Button = ( +
undefined} + onClick={() => setPopoverOpen(!popoverOpen)} + > + +
+ ); + + return ( + setPopoverOpen(false)} + panelPaddingSize="none" + anchorPosition="upLeft" + title={i18n.translate('visTypePie.legend.filterOptionsLegend', { + defaultMessage: '{legendDataLabel}, filter options', + values: { legendDataLabel: title }, + })} + > + + + ); + }; +}; diff --git a/src/plugins/vis_type_pie/public/utils/get_split_dimension_accessor.ts b/src/plugins/vis_type_pie/public/utils/get_split_dimension_accessor.ts new file mode 100644 index 0000000000000..e1029b11a7b75 --- /dev/null +++ b/src/plugins/vis_type_pie/public/utils/get_split_dimension_accessor.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { AccessorFn } from '@elastic/charts'; +import { FieldFormatsStart } from '../../../data/public'; +import { DatatableColumn } from '../../../expressions/public'; +import { Dimension } from '../types'; + +export const getSplitDimensionAccessor = ( + fieldFormats: FieldFormatsStart, + columns: DatatableColumn[] +) => (splitDimension: Dimension): AccessorFn => { + const formatter = fieldFormats.deserialize(splitDimension.format); + const splitChartColumn = columns[splitDimension.accessor]; + const accessor = splitChartColumn.id; + + const fn: AccessorFn = (d) => { + const v = d[accessor]; + if (v === undefined) { + return; + } + const f = formatter.convert(v); + return f; + }; + + return fn; +}; diff --git a/src/plugins/vis_type_pie/public/utils/index.ts b/src/plugins/vis_type_pie/public/utils/index.ts new file mode 100644 index 0000000000000..0cf4292ad565a --- /dev/null +++ b/src/plugins/vis_type_pie/public/utils/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { getLayers } from './get_layers'; +export { getColorPicker } from './get_color_picker'; +export { getLegendActions } from './get_legend_actions'; +export { canFilter, getFilterClickData, getFilterEventData } from './filter_helpers'; +export { getConfig } from './get_config'; +export { getColumns } from './get_columns'; +export { getSplitDimensionAccessor } from './get_split_dimension_accessor'; +export { getDistinctSeries } from './get_distinct_series'; diff --git a/src/plugins/vis_type_pie/public/vis_type/index.ts b/src/plugins/vis_type_pie/public/vis_type/index.ts new file mode 100644 index 0000000000000..e02e802028a35 --- /dev/null +++ b/src/plugins/vis_type_pie/public/vis_type/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { getPieVisTypeDefinition } from './pie'; +import type { PieTypeProps } from '../types'; + +export const pieVisType = (props: PieTypeProps) => { + return getPieVisTypeDefinition(props); +}; diff --git a/src/plugins/vis_type_pie/public/vis_type/pie.ts b/src/plugins/vis_type_pie/public/vis_type/pie.ts new file mode 100644 index 0000000000000..9d1556ac33ad7 --- /dev/null +++ b/src/plugins/vis_type_pie/public/vis_type/pie.ts @@ -0,0 +1,98 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; +import { Position } from '@elastic/charts'; +import { AggGroupNames } from '../../../data/public'; +import { VIS_EVENT_TO_TRIGGER, VisTypeDefinition } from '../../../visualizations/public'; +import { DEFAULT_PERCENT_DECIMALS } from '../../common'; +import { PieVisParams, LabelPositions, ValueFormats, PieTypeProps } from '../types'; +import { toExpressionAst } from '../to_ast'; +import { getPieOptions } from '../editor/components'; + +export const getPieVisTypeDefinition = ({ + showElasticChartsOptions = false, + palettes, + trackUiMetric, +}: PieTypeProps): VisTypeDefinition => ({ + name: 'pie', + title: i18n.translate('visTypePie.pie.pieTitle', { defaultMessage: 'Pie' }), + icon: 'visPie', + description: i18n.translate('visTypePie.pie.pieDescription', { + defaultMessage: 'Compare data in proportion to a whole.', + }), + toExpressionAst, + getSupportedTriggers: () => [VIS_EVENT_TO_TRIGGER.filter], + visConfig: { + defaults: { + type: 'pie', + addTooltip: true, + addLegend: !showElasticChartsOptions, + legendPosition: Position.Right, + nestedLegend: false, + distinctColors: false, + isDonut: true, + palette: { + type: 'palette', + name: 'default', + }, + labels: { + show: true, + last_level: !showElasticChartsOptions, + values: true, + valuesFormat: ValueFormats.PERCENT, + percentDecimals: DEFAULT_PERCENT_DECIMALS, + truncate: 100, + position: LabelPositions.DEFAULT, + }, + }, + }, + editorConfig: { + optionsTemplate: getPieOptions({ + showElasticChartsOptions, + palettes, + trackUiMetric, + }), + schemas: [ + { + group: AggGroupNames.Metrics, + name: 'metric', + title: i18n.translate('visTypePie.pie.metricTitle', { + defaultMessage: 'Slice size', + }), + min: 1, + max: 1, + aggFilter: ['sum', 'count', 'cardinality', 'top_hits'], + defaults: [{ schema: 'metric', type: 'count' }], + }, + { + group: AggGroupNames.Buckets, + name: 'segment', + title: i18n.translate('visTypePie.pie.segmentTitle', { + defaultMessage: 'Split slices', + }), + min: 0, + max: Infinity, + aggFilter: ['!geohash_grid', '!geotile_grid', '!filter'], + }, + { + group: AggGroupNames.Buckets, + name: 'split', + title: i18n.translate('visTypePie.pie.splitTitle', { + defaultMessage: 'Split chart', + }), + mustBeFirst: true, + min: 0, + max: 1, + aggFilter: ['!geohash_grid', '!geotile_grid', '!filter'], + }, + ], + }, + hierarchicalData: true, + requiresSearch: true, +}); diff --git a/src/plugins/vis_type_pie/tsconfig.json b/src/plugins/vis_type_pie/tsconfig.json new file mode 100644 index 0000000000000..f12db316f1972 --- /dev/null +++ b/src/plugins/vis_type_pie/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": [ + "common/**/*", + "public/**/*", + "server/**/*" + ], + "references": [ + { "path": "../../core/tsconfig.json" }, + { "path": "../charts/tsconfig.json" }, + { "path": "../data/tsconfig.json" }, + { "path": "../expressions/tsconfig.json" }, + { "path": "../visualizations/tsconfig.json" }, + { "path": "../usage_collection/tsconfig.json" }, + { "path": "../vis_default_editor/tsconfig.json" }, + ] + } \ No newline at end of file diff --git a/src/plugins/vis_type_vislib/kibana.json b/src/plugins/vis_type_vislib/kibana.json index 175c21f47c182..56dfba0aca59c 100644 --- a/src/plugins/vis_type_vislib/kibana.json +++ b/src/plugins/vis_type_vislib/kibana.json @@ -4,5 +4,5 @@ "server": true, "ui": true, "requiredPlugins": ["charts", "data", "expressions", "visualizations", "kibanaLegacy"], - "requiredBundles": ["kibanaUtils", "visDefaultEditor", "visTypeXy"] + "requiredBundles": ["kibanaUtils", "visDefaultEditor", "visTypeXy", "visTypePie"] } diff --git a/src/plugins/vis_type_vislib/public/editor/components/index.tsx b/src/plugins/vis_type_vislib/public/editor/components/index.tsx index a90aaeab58503..34547dc7115e2 100644 --- a/src/plugins/vis_type_vislib/public/editor/components/index.tsx +++ b/src/plugins/vis_type_vislib/public/editor/components/index.tsx @@ -10,21 +10,15 @@ import React, { lazy } from 'react'; import { VisEditorOptionsProps } from 'src/plugins/visualizations/public'; import { GaugeVisParams } from '../../gauge'; -import { PieVisParams } from '../../pie'; import { HeatmapVisParams } from '../../heatmap'; const GaugeOptionsLazy = lazy(() => import('./gauge')); -const PieOptionsLazy = lazy(() => import('./pie')); const HeatmapOptionsLazy = lazy(() => import('./heatmap')); export const GaugeOptions = (props: VisEditorOptionsProps) => ( ); -export const PieOptions = (props: VisEditorOptionsProps) => ( - -); - export const HeatmapOptions = (props: VisEditorOptionsProps) => ( ); diff --git a/src/plugins/vis_type_vislib/public/editor/components/pie.tsx b/src/plugins/vis_type_vislib/public/editor/components/pie.tsx deleted file mode 100644 index 6c84bc744676a..0000000000000 --- a/src/plugins/vis_type_vislib/public/editor/components/pie.tsx +++ /dev/null @@ -1,97 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import React from 'react'; - -import { EuiPanel, EuiTitle, EuiSpacer } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; - -import { VisEditorOptionsProps } from 'src/plugins/visualizations/public'; -import { BasicOptions, SwitchOption } from '../../../../vis_default_editor/public'; -import { TruncateLabelsOption, getPositions } from '../../../../vis_type_xy/public'; - -import { PieVisParams } from '../../pie'; - -const legendPositions = getPositions(); - -function PieOptions(props: VisEditorOptionsProps) { - const { stateParams, setValue } = props; - const setLabels = ( - paramName: T, - value: PieVisParams['labels'][T] - ) => setValue('labels', { ...stateParams.labels, [paramName]: value }); - - return ( - <> - - -

- -

-
- - - -
- - - - - -

- -

-
- - - - - -
- - ); -} - -// default export required for React.Lazy -// eslint-disable-next-line import/no-default-export -export { PieOptions as default }; diff --git a/src/plugins/vis_type_vislib/public/pie.ts b/src/plugins/vis_type_vislib/public/pie.ts index d1d8d2a5279fe..4f6eb7e536509 100644 --- a/src/plugins/vis_type_vislib/public/pie.ts +++ b/src/plugins/vis_type_vislib/public/pie.ts @@ -6,14 +6,9 @@ * Side Public License, v 1. */ -import { i18n } from '@kbn/i18n'; -import { Position } from '@elastic/charts'; - -import { AggGroupNames } from '../../data/public'; -import { VisTypeDefinition, VIS_EVENT_TO_TRIGGER } from '../../visualizations/public'; - +import { pieVisType } from '../../vis_type_pie/public'; +import { VisTypeDefinition } from '../../visualizations/public'; import { CommonVislibParams } from './types'; -import { PieOptions } from './editor'; import { toExpressionAst } from './to_ast_pie'; export interface PieVisParams extends CommonVislibParams { @@ -27,67 +22,7 @@ export interface PieVisParams extends CommonVislibParams { }; } -export const pieVisTypeDefinition: VisTypeDefinition = { - name: 'pie', - title: i18n.translate('visTypeVislib.pie.pieTitle', { defaultMessage: 'Pie' }), - icon: 'visPie', - description: i18n.translate('visTypeVislib.pie.pieDescription', { - defaultMessage: 'Compare data in proportion to a whole.', - }), - getSupportedTriggers: () => [VIS_EVENT_TO_TRIGGER.filter], +export const pieVisTypeDefinition = { + ...pieVisType({}), toExpressionAst, - visConfig: { - defaults: { - type: 'pie', - addTooltip: true, - addLegend: true, - legendPosition: Position.Right, - isDonut: true, - labels: { - show: false, - values: true, - last_level: true, - truncate: 100, - }, - }, - }, - editorConfig: { - optionsTemplate: PieOptions, - schemas: [ - { - group: AggGroupNames.Metrics, - name: 'metric', - title: i18n.translate('visTypeVislib.pie.metricTitle', { - defaultMessage: 'Slice size', - }), - min: 1, - max: 1, - aggFilter: ['sum', 'count', 'cardinality', 'top_hits'], - defaults: [{ schema: 'metric', type: 'count' }], - }, - { - group: AggGroupNames.Buckets, - name: 'segment', - title: i18n.translate('visTypeVislib.pie.segmentTitle', { - defaultMessage: 'Split slices', - }), - min: 0, - max: Infinity, - aggFilter: ['!geohash_grid', '!geotile_grid', '!filter'], - }, - { - group: AggGroupNames.Buckets, - name: 'split', - title: i18n.translate('visTypeVislib.pie.splitTitle', { - defaultMessage: 'Split chart', - }), - mustBeFirst: true, - min: 0, - max: 1, - aggFilter: ['!geohash_grid', '!geotile_grid', '!filter'], - }, - ], - }, - hierarchicalData: true, - requiresSearch: true, -}; +} as VisTypeDefinition; diff --git a/src/plugins/vis_type_vislib/public/plugin.ts b/src/plugins/vis_type_vislib/public/plugin.ts index 9d329c92bede0..52faf8a74778c 100644 --- a/src/plugins/vis_type_vislib/public/plugin.ts +++ b/src/plugins/vis_type_vislib/public/plugin.ts @@ -13,7 +13,7 @@ import { VisualizationsSetup } from '../../visualizations/public'; import { ChartsPluginSetup } from '../../charts/public'; import { DataPublicPluginStart } from '../../data/public'; import { KibanaLegacyStart } from '../../kibana_legacy/public'; -import { LEGACY_CHARTS_LIBRARY } from '../../vis_type_xy/public'; +import { LEGACY_CHARTS_LIBRARY } from '../../visualizations/common/constants'; import { createVisTypeVislibVisFn } from './vis_type_vislib_vis_fn'; import { createPieVisFn } from './pie_fn'; @@ -53,9 +53,8 @@ export class VisTypeVislibPlugin if (!core.uiSettings.get(LEGACY_CHARTS_LIBRARY, false)) { // Register only non-replaced vis types convertedTypeDefinitions.forEach(visualizations.createBaseVisualization); - visualizations.createBaseVisualization(pieVisTypeDefinition); expressions.registerRenderer(getVislibVisRenderer(core, charts)); - [createVisTypeVislibVisFn(), createPieVisFn()].forEach(expressions.registerFunction); + expressions.registerFunction(createVisTypeVislibVisFn()); } else { // Register all vis types visLibVisTypeDefinitions.forEach(visualizations.createBaseVisualization); diff --git a/src/plugins/vis_type_vislib/public/to_ast_pie.test.ts b/src/plugins/vis_type_vislib/public/to_ast_pie.test.ts index 3ca52f27e3fa1..3178c23ee8fa0 100644 --- a/src/plugins/vis_type_vislib/public/to_ast_pie.test.ts +++ b/src/plugins/vis_type_vislib/public/to_ast_pie.test.ts @@ -10,7 +10,7 @@ import { Vis } from '../../visualizations/public'; import { buildExpression } from '../../expressions/public'; import { PieVisParams } from './pie'; -import { samplePieVis } from '../../vis_type_xy/public/sample_vis.test.mocks'; +import { samplePieVis } from '../../vis_type_pie/public/sample_vis.test.mocks'; import { toExpressionAst } from './to_ast_pie'; jest.mock('../../expressions/public', () => ({ diff --git a/src/plugins/vis_type_vislib/public/vislib/helpers/hierarchical/build_hierarchical_data.test.ts b/src/plugins/vis_type_vislib/public/vislib/helpers/hierarchical/build_hierarchical_data.test.ts index 71f692b80b531..de91053b6dc4d 100644 --- a/src/plugins/vis_type_vislib/public/vislib/helpers/hierarchical/build_hierarchical_data.test.ts +++ b/src/plugins/vis_type_vislib/public/vislib/helpers/hierarchical/build_hierarchical_data.test.ts @@ -5,8 +5,8 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ - -import { buildHierarchicalData, Dimensions, Dimension } from './build_hierarchical_data'; +import type { Dimensions, Dimension } from '../../../../../vis_type_pie/public'; +import { buildHierarchicalData } from './build_hierarchical_data'; import { Table, TableParent } from '../../types'; function tableVisResponseHandler(table: Table, dimensions: Dimensions) { diff --git a/src/plugins/vis_type_vislib/public/vislib/helpers/hierarchical/build_hierarchical_data.ts b/src/plugins/vis_type_vislib/public/vislib/helpers/hierarchical/build_hierarchical_data.ts index b235d3936ae0f..da10edf9591fb 100644 --- a/src/plugins/vis_type_vislib/public/vislib/helpers/hierarchical/build_hierarchical_data.ts +++ b/src/plugins/vis_type_vislib/public/vislib/helpers/hierarchical/build_hierarchical_data.ts @@ -7,24 +7,9 @@ */ import { toArray } from 'lodash'; -import { SerializedFieldFormat } from '../../../../../expressions/common/types'; import { getFormatService } from '../../../services'; import { Table } from '../../types'; - -export interface Dimension { - accessor: number; - format: { - id?: string; - params?: SerializedFieldFormat; - }; -} - -export interface Dimensions { - metric: Dimension; - buckets?: Dimension[]; - splitRow?: Dimension[]; - splitColumn?: Dimension[]; -} +import type { Dimensions } from '../../../../../vis_type_pie/public'; interface Slice { name: string; diff --git a/src/plugins/vis_type_vislib/tsconfig.json b/src/plugins/vis_type_vislib/tsconfig.json index 74bc1440d9dbc..5bf1af9ba75fe 100644 --- a/src/plugins/vis_type_vislib/tsconfig.json +++ b/src/plugins/vis_type_vislib/tsconfig.json @@ -22,5 +22,6 @@ { "path": "../kibana_utils/tsconfig.json" }, { "path": "../vis_default_editor/tsconfig.json" }, { "path": "../vis_type_xy/tsconfig.json" }, + { "path": "../vis_type_pie/tsconfig.json" }, ] } diff --git a/src/plugins/vis_type_xy/common/index.ts b/src/plugins/vis_type_xy/common/index.ts index a80946f7c62fa..f17bc8476d9a6 100644 --- a/src/plugins/vis_type_xy/common/index.ts +++ b/src/plugins/vis_type_xy/common/index.ts @@ -19,5 +19,3 @@ export enum ChartType { * Type of xy visualizations */ export type XyVisType = ChartType | 'horizontal_bar'; - -export const LEGACY_CHARTS_LIBRARY = 'visualization:visualize:legacyChartsLibrary'; diff --git a/src/plugins/vis_type_xy/kibana.json b/src/plugins/vis_type_xy/kibana.json index 619fa8e71c0dd..a32b1e4d1d8b5 100644 --- a/src/plugins/vis_type_xy/kibana.json +++ b/src/plugins/vis_type_xy/kibana.json @@ -1,7 +1,6 @@ { "id": "visTypeXy", "version": "kibana", - "server": true, "ui": true, "requiredPlugins": ["charts", "data", "expressions", "visualizations", "usageCollection"], "requiredBundles": ["kibanaUtils", "visDefaultEditor"] diff --git a/src/plugins/vis_type_xy/public/plugin.ts b/src/plugins/vis_type_xy/public/plugin.ts index 7bdb4f78bc631..e8d53127765b4 100644 --- a/src/plugins/vis_type_xy/public/plugin.ts +++ b/src/plugins/vis_type_xy/public/plugin.ts @@ -23,7 +23,7 @@ import { } from './services'; import { visTypesDefinitions } from './vis_types'; -import { LEGACY_CHARTS_LIBRARY } from '../common'; +import { LEGACY_CHARTS_LIBRARY } from '../../visualizations/common/constants'; import { xyVisRenderer } from './vis_renderer'; import * as expressionFunctions from './expression_functions'; diff --git a/src/plugins/vis_type_xy/public/sample_vis.test.mocks.ts b/src/plugins/vis_type_xy/public/sample_vis.test.mocks.ts index 39370d941b52a..8fafd4c723055 100644 --- a/src/plugins/vis_type_xy/public/sample_vis.test.mocks.ts +++ b/src/plugins/vis_type_xy/public/sample_vis.test.mocks.ts @@ -5,1325 +5,6 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ - -export const samplePieVis = { - type: { - name: 'pie', - title: 'Pie', - description: 'Compare parts of a whole', - icon: 'visPie', - stage: 'production', - options: { - showTimePicker: true, - showQueryBar: true, - showFilterBar: true, - showIndexSelection: true, - hierarchicalData: false, - }, - visConfig: { - defaults: { - type: 'pie', - addTooltip: true, - addLegend: true, - legendPosition: 'right', - isDonut: true, - labels: { - show: false, - values: true, - last_level: true, - truncate: 100, - }, - }, - }, - editorConfig: { - collections: { - legendPositions: [ - { - text: 'Top', - value: 'top', - }, - { - text: 'Left', - value: 'left', - }, - { - text: 'Right', - value: 'right', - }, - { - text: 'Bottom', - value: 'bottom', - }, - ], - }, - schemas: { - all: [ - { - group: 'metrics', - name: 'metric', - title: 'Slice size', - min: 1, - max: 1, - aggFilter: ['sum', 'count', 'cardinality', 'top_hits'], - defaults: [ - { - schema: 'metric', - type: 'count', - }, - ], - editor: false, - params: [], - }, - { - group: 'buckets', - name: 'segment', - title: 'Split slices', - min: 0, - max: null, - aggFilter: ['!geohash_grid', '!geotile_grid', '!filter'], - editor: false, - params: [], - }, - { - group: 'buckets', - name: 'split', - title: 'Split chart', - mustBeFirst: true, - min: 0, - max: 1, - aggFilter: ['!geohash_grid', '!geotile_grid', '!filter'], - params: [ - { - name: 'row', - default: true, - }, - ], - editor: false, - }, - ], - buckets: [null, null], - metrics: [null], - }, - }, - hidden: false, - hierarchicalData: true, - }, - title: '[Flights] Airline Carrier', - description: '', - params: { - type: 'pie', - addTooltip: true, - addLegend: true, - legendPosition: 'right', - isDonut: true, - labels: { - show: true, - values: true, - last_level: true, - truncate: 100, - }, - }, - data: { - searchSource: { - id: 'data_source1', - requestStartHandlers: [], - inheritOptions: {}, - history: [], - fields: { - filter: [], - query: { - query: '', - language: 'kuery', - }, - index: { - id: 'd3d7af60-4c81-11e8-b3d7-01146121b73d', - title: 'kibana_sample_data_flights', - fieldFormatMap: { - AvgTicketPrice: { - id: 'number', - params: { - parsedUrl: { - origin: 'http://localhost:5801', - pathname: '/app/visualize', - basePath: '', - }, - pattern: '$0,0.[00]', - }, - }, - hour_of_day: { - id: 'number', - params: { - pattern: '00', - }, - }, - }, - fields: [ - { - count: 0, - name: 'AvgTicketPrice', - type: 'number', - esTypes: ['float'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'Cancelled', - type: 'boolean', - esTypes: ['boolean'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'Carrier', - type: 'string', - esTypes: ['keyword'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'Dest', - type: 'string', - esTypes: ['keyword'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'DestAirportID', - type: 'string', - esTypes: ['keyword'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'DestCityName', - type: 'string', - esTypes: ['keyword'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'DestCountry', - type: 'string', - esTypes: ['keyword'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'DestLocation', - type: 'geo_point', - esTypes: ['geo_point'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'DestRegion', - type: 'string', - esTypes: ['keyword'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'DestWeather', - type: 'string', - esTypes: ['keyword'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'DistanceKilometers', - type: 'number', - esTypes: ['float'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'DistanceMiles', - type: 'number', - esTypes: ['float'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'FlightDelay', - type: 'boolean', - esTypes: ['boolean'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'FlightDelayMin', - type: 'number', - esTypes: ['integer'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'FlightDelayType', - type: 'string', - esTypes: ['keyword'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'FlightNum', - type: 'string', - esTypes: ['keyword'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'FlightTimeHour', - type: 'string', - esTypes: ['keyword'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'FlightTimeMin', - type: 'number', - esTypes: ['float'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'Origin', - type: 'string', - esTypes: ['keyword'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'OriginAirportID', - type: 'string', - esTypes: ['keyword'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'OriginCityName', - type: 'string', - esTypes: ['keyword'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'OriginCountry', - type: 'string', - esTypes: ['keyword'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'OriginLocation', - type: 'geo_point', - esTypes: ['geo_point'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'OriginRegion', - type: 'string', - esTypes: ['keyword'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'OriginWeather', - type: 'string', - esTypes: ['keyword'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: '_id', - type: 'string', - esTypes: ['_id'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: false, - }, - { - count: 0, - name: '_index', - type: 'string', - esTypes: ['_index'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: false, - }, - { - count: 0, - name: '_score', - type: 'number', - scripted: false, - searchable: false, - aggregatable: false, - readFromDocValues: false, - }, - { - count: 0, - name: '_source', - type: '_source', - esTypes: ['_source'], - scripted: false, - searchable: false, - aggregatable: false, - readFromDocValues: false, - }, - { - count: 0, - name: '_type', - type: 'string', - esTypes: ['_type'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: false, - }, - { - count: 0, - name: 'dayOfWeek', - type: 'number', - esTypes: ['integer'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'timestamp', - type: 'date', - esTypes: ['date'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - script: "doc['timestamp'].value.hourOfDay", - lang: 'painless', - name: 'hour_of_day', - type: 'number', - scripted: true, - searchable: true, - aggregatable: true, - readFromDocValues: false, - }, - ], - timeFieldName: 'timestamp', - metaFields: ['_source', '_id', '_type', '_index', '_score'], - version: 'WzM1LDFd', - originalSavedObjectBody: { - title: 'kibana_sample_data_flights', - timeFieldName: 'timestamp', - fields: - '[{"count":0,"name":"AvgTicketPrice","type":"number","esTypes":["float"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"Cancelled","type":"boolean","esTypes":["boolean"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"Carrier","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"Dest","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"DestAirportID","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"DestCityName","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"DestCountry","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"DestLocation","type":"geo_point","esTypes":["geo_point"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"DestRegion","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"DestWeather","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"DistanceKilometers","type":"number","esTypes":["float"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"DistanceMiles","type":"number","esTypes":["float"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"FlightDelay","type":"boolean","esTypes":["boolean"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"FlightDelayMin","type":"number","esTypes":["integer"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"FlightDelayType","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"FlightNum","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"FlightTimeHour","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"FlightTimeMin","type":"number","esTypes":["float"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"Origin","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"OriginAirportID","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"OriginCityName","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"OriginCountry","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"OriginLocation","type":"geo_point","esTypes":["geo_point"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"OriginRegion","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"OriginWeather","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"_id","type":"string","esTypes":["_id"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":false},{"count":0,"name":"_index","type":"string","esTypes":["_index"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":false},{"count":0,"name":"_score","type":"number","scripted":false,"searchable":false,"aggregatable":false,"readFromDocValues":false},{"count":0,"name":"_source","type":"_source","esTypes":["_source"],"scripted":false,"searchable":false,"aggregatable":false,"readFromDocValues":false},{"count":0,"name":"_type","type":"string","esTypes":["_type"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":false},{"count":0,"name":"dayOfWeek","type":"number","esTypes":["integer"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"timestamp","type":"date","esTypes":["date"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"script":"doc[\'timestamp\'].value.hourOfDay","lang":"painless","name":"hour_of_day","type":"number","scripted":true,"searchable":true,"aggregatable":true,"readFromDocValues":false}]', - fieldFormatMap: - '{"AvgTicketPrice":{"id":"number","params":{"parsedUrl":{"origin":"http://localhost:5801","pathname":"/app/visualize","basePath":""},"pattern":"$0,0.[00]"}},"hour_of_day":{"id":"number","params":{"parsedUrl":{"origin":"http://localhost:5801","pathname":"/app/visualize","basePath":""},"pattern":"00"}}}', - }, - shortDotsEnable: false, - fieldFormats: { - fieldFormats: {}, - defaultMap: { - ip: { - id: 'ip', - params: {}, - }, - date: { - id: 'date', - params: {}, - }, - date_nanos: { - id: 'date_nanos', - params: {}, - es: true, - }, - number: { - id: 'number', - params: {}, - }, - boolean: { - id: 'boolean', - params: {}, - }, - _source: { - id: '_source', - params: {}, - }, - _default_: { - id: 'string', - params: {}, - }, - }, - metaParamsOptions: {}, - }, - }, - }, - dependencies: { - legacy: { - loadingCount$: { - _isScalar: false, - observers: [ - { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: null, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: true, - isStopped: false, - destination: { - closed: false, - _parentOrParents: null, - _subscriptions: null, - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - destination: { - closed: true, - }, - _context: {}, - }, - }, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: true, - isStopped: false, - count: 1, - }, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: true, - isStopped: false, - hasPrev: true, - prev: 0, - }, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - parent: { - closed: true, - _parentOrParents: null, - _subscriptions: null, - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: true, - isStopped: true, - concurrent: 1, - hasCompleted: true, - buffer: [], - active: 1, - index: 2, - }, - }, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - parent: { - closed: true, - _parentOrParents: null, - _subscriptions: null, - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: true, - concurrent: 1, - hasCompleted: true, - buffer: [ - { - _isScalar: false, - }, - ], - active: 1, - index: 1, - }, - }, - _subscriptions: [ - { - closed: false, - _subscriptions: [ - { - closed: false, - _subscriptions: null, - subject: { - _isScalar: false, - observers: [ - { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: null, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: true, - isStopped: false, - destination: { - closed: false, - _parentOrParents: null, - _subscriptions: null, - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - _context: {}, - }, - }, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: true, - isStopped: false, - count: 13, - }, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: true, - isStopped: false, - hasPrev: true, - prev: 0, - }, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - parent: { - closed: true, - _parentOrParents: null, - _subscriptions: null, - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: true, - isStopped: true, - concurrent: 1, - hasCompleted: true, - buffer: [], - active: 1, - index: 2, - }, - }, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - parent: { - closed: true, - _parentOrParents: null, - _subscriptions: null, - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: true, - concurrent: 1, - hasCompleted: true, - buffer: [ - { - _isScalar: false, - }, - ], - active: 1, - index: 1, - }, - }, - _subscriptions: [ - null, - { - closed: false, - _subscriptions: [ - { - closed: false, - _subscriptions: [ - { - closed: false, - _subscriptions: null, - subject: { - _isScalar: false, - observers: [null], - closed: false, - isStopped: false, - hasError: false, - thrownError: null, - _value: 0, - }, - }, - ], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - hasKey: true, - key: 0, - }, - ], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - }, - ], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - seenValue: false, - }, - _subscriptions: [ - { - closed: false, - _subscriptions: null, - }, - ], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - }, - { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: null, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: true, - isStopped: false, - destination: { - closed: false, - _parentOrParents: null, - _subscriptions: null, - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - _context: {}, - }, - }, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: true, - isStopped: false, - count: 1, - }, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: true, - isStopped: false, - hasPrev: true, - prev: 0, - }, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - parent: { - closed: true, - _parentOrParents: null, - _subscriptions: null, - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: true, - isStopped: true, - concurrent: 1, - hasCompleted: true, - buffer: [], - active: 1, - index: 2, - }, - }, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - parent: { - closed: true, - _parentOrParents: null, - _subscriptions: null, - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: true, - concurrent: 1, - hasCompleted: true, - buffer: [ - { - _isScalar: false, - }, - ], - active: 1, - index: 1, - }, - }, - _subscriptions: [ - null, - { - closed: false, - _subscriptions: [ - { - closed: false, - _subscriptions: [ - { - closed: false, - _subscriptions: null, - subject: { - _isScalar: false, - observers: [null], - closed: false, - isStopped: false, - hasError: false, - thrownError: null, - _value: 0, - }, - }, - ], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - hasKey: true, - key: 0, - }, - ], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - }, - ], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - seenValue: false, - }, - _subscriptions: [ - { - closed: false, - _subscriptions: null, - }, - ], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - }, - { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: null, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: true, - isStopped: false, - destination: { - closed: false, - _parentOrParents: null, - _subscriptions: null, - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - _context: {}, - }, - }, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: true, - isStopped: false, - count: 1, - }, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: true, - isStopped: false, - hasPrev: true, - prev: 0, - }, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - parent: { - closed: true, - _parentOrParents: null, - _subscriptions: null, - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: true, - isStopped: true, - concurrent: 1, - hasCompleted: true, - buffer: [], - active: 1, - index: 2, - }, - }, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - parent: { - closed: true, - _parentOrParents: null, - _subscriptions: null, - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: true, - concurrent: 1, - hasCompleted: true, - buffer: [ - { - _isScalar: false, - }, - ], - active: 1, - index: 1, - }, - }, - _subscriptions: [ - null, - { - closed: false, - _subscriptions: [ - { - closed: false, - _subscriptions: [ - { - closed: false, - _subscriptions: null, - subject: { - _isScalar: false, - observers: [null], - closed: false, - isStopped: false, - hasError: false, - thrownError: null, - _value: 0, - }, - }, - ], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - hasKey: true, - key: 0, - }, - ], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - }, - ], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - seenValue: false, - }, - _subscriptions: [ - { - closed: false, - _subscriptions: null, - }, - ], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - }, - { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: null, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: true, - isStopped: false, - destination: { - closed: false, - _parentOrParents: null, - _subscriptions: null, - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - _context: {}, - }, - }, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: true, - isStopped: false, - count: 3, - }, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: true, - isStopped: false, - hasPrev: true, - prev: 0, - }, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - parent: { - closed: true, - _parentOrParents: null, - _subscriptions: null, - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: true, - isStopped: true, - concurrent: 1, - hasCompleted: true, - buffer: [], - active: 1, - index: 2, - }, - }, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - parent: { - closed: true, - _parentOrParents: null, - _subscriptions: null, - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: true, - concurrent: 1, - hasCompleted: true, - buffer: [ - { - _isScalar: false, - }, - ], - active: 1, - index: 1, - }, - }, - _subscriptions: [ - null, - { - closed: false, - _subscriptions: [ - { - closed: false, - _subscriptions: [ - { - closed: false, - _subscriptions: null, - subject: { - _isScalar: false, - observers: [null], - closed: false, - isStopped: false, - hasError: false, - thrownError: null, - _value: 0, - }, - }, - ], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - hasKey: true, - key: 0, - }, - ], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - }, - ], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - seenValue: false, - }, - _subscriptions: [ - { - closed: false, - _subscriptions: null, - }, - ], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - }, - null, - ], - closed: false, - isStopped: false, - hasError: false, - thrownError: null, - }, - }, - ], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - }, - null, - ], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - seenValue: false, - }, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - }, - _subscriptions: [ - { - closed: false, - _subscriptions: null, - }, - ], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - hasKey: true, - key: 0, - }, - ], - closed: false, - isStopped: false, - hasError: false, - thrownError: null, - _value: 0, - }, - }, - }, - }, - aggs: { - typesRegistry: {}, - getResponseAggs: () => [ - { - id: '1', - enabled: true, - type: 'count', - params: {}, - schema: 'metric', - toSerializedFieldFormat: () => ({ - id: 'number', - }), - }, - { - id: '2', - enabled: true, - type: 'terms', - params: { - field: 'Carrier', - orderBy: '1', - order: 'desc', - size: 5, - otherBucket: false, - otherBucketLabel: 'Other', - missingBucket: false, - missingBucketLabel: 'Missing', - }, - schema: 'segment', - toSerializedFieldFormat: () => ({ - id: 'terms', - params: { - id: 'string', - otherBucketLabel: 'Other', - missingBucketLabel: 'Missing', - parsedUrl: { - origin: 'http://localhost:5801', - pathname: '/app/visualize', - basePath: '', - }, - }, - }), - }, - ], - }, - }, - isHierarchical: () => true, - uiState: { - vis: { - legendOpen: false, - }, - }, -}; - export const sampleAreaVis = { type: { name: 'area', diff --git a/src/plugins/vis_type_xy/server/plugin.ts b/src/plugins/vis_type_xy/server/plugin.ts deleted file mode 100644 index 08aefdeb836b0..0000000000000 --- a/src/plugins/vis_type_xy/server/plugin.ts +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { i18n } from '@kbn/i18n'; -import { schema } from '@kbn/config-schema'; - -import { CoreSetup, Plugin, UiSettingsParams } from 'kibana/server'; - -import { LEGACY_CHARTS_LIBRARY } from '../common'; - -export const getUiSettingsConfig: () => Record> = () => ({ - // TODO: Remove this when vis_type_vislib is removed - // https://github.com/elastic/kibana/issues/56143 - [LEGACY_CHARTS_LIBRARY]: { - name: i18n.translate('visTypeXy.advancedSettings.visualization.legacyChartsLibrary.name', { - defaultMessage: 'Legacy charts library', - }), - requiresPageReload: true, - value: false, - description: i18n.translate( - 'visTypeXy.advancedSettings.visualization.legacyChartsLibrary.description', - { - defaultMessage: 'Enables legacy charts library for area, line and bar charts in visualize.', - } - ), - category: ['visualization'], - schema: schema.boolean(), - }, -}); - -export class VisTypeXyServerPlugin implements Plugin { - public setup(core: CoreSetup) { - core.uiSettings.register(getUiSettingsConfig()); - - return {}; - } - - public start() { - return {}; - } -} diff --git a/src/plugins/visualizations/common/constants.ts b/src/plugins/visualizations/common/constants.ts index a8a0963ac8948..a33e74b498a2c 100644 --- a/src/plugins/visualizations/common/constants.ts +++ b/src/plugins/visualizations/common/constants.ts @@ -7,3 +7,4 @@ */ export const VISUALIZE_ENABLE_LABS_SETTING = 'visualize:enableLabs'; +export const LEGACY_CHARTS_LIBRARY = 'visualization:visualize:legacyChartsLibrary'; diff --git a/src/plugins/visualizations/kibana.json b/src/plugins/visualizations/kibana.json index 0ced74e2733d3..939b331414166 100644 --- a/src/plugins/visualizations/kibana.json +++ b/src/plugins/visualizations/kibana.json @@ -12,5 +12,6 @@ "savedObjects" ], "optionalPlugins": ["usageCollection"], - "requiredBundles": ["kibanaUtils", "discover"] + "requiredBundles": ["kibanaUtils", "discover"], + "extraPublicDirs": ["common/constants"] } diff --git a/src/plugins/visualizations/server/embeddable/visualize_embeddable_factory.ts b/src/plugins/visualizations/server/embeddable/visualize_embeddable_factory.ts index 212c033a65c26..edfd05b84dfc8 100644 --- a/src/plugins/visualizations/server/embeddable/visualize_embeddable_factory.ts +++ b/src/plugins/visualizations/server/embeddable/visualize_embeddable_factory.ts @@ -13,6 +13,7 @@ import { commonAddSupportOfDualIndexSelectionModeInTSVB, commonHideTSVBLastValueIndicator, commonRemoveDefaultIndexPatternAndTimeFieldFromTSVBModel, + commonMigrateVislibPie, commonAddEmptyValueColorRule, } from '../migrations/visualization_common_migrations'; @@ -44,6 +45,13 @@ const byValueAddEmptyValueColorRule = (state: SerializableState) => { }; }; +const byValueMigrateVislibPie = (state: SerializableState) => { + return { + ...state, + savedVis: commonMigrateVislibPie(state.savedVis), + }; +}; + export const visualizeEmbeddableFactory = (): EmbeddableRegistryDefinition => { return { id: 'visualization', @@ -55,7 +63,7 @@ export const visualizeEmbeddableFactory = (): EmbeddableRegistryDefinition => { byValueHideTSVBLastValueIndicator, byValueRemoveDefaultIndexPatternAndTimeFieldFromTSVBModel )(state), - '7.14.0': (state) => flow(byValueAddEmptyValueColorRule)(state), + '7.14.0': (state) => flow(byValueAddEmptyValueColorRule, byValueMigrateVislibPie)(state), }, }; }; diff --git a/src/plugins/visualizations/server/migrations/visualization_common_migrations.ts b/src/plugins/visualizations/server/migrations/visualization_common_migrations.ts index 13b8d8c4a0f98..f5afeee0ff35e 100644 --- a/src/plugins/visualizations/server/migrations/visualization_common_migrations.ts +++ b/src/plugins/visualizations/server/migrations/visualization_common_migrations.ts @@ -91,3 +91,26 @@ export const commonAddEmptyValueColorRule = (visState: any) => { return visState; }; + +export const commonMigrateVislibPie = (visState: any) => { + if (visState && visState.type === 'pie') { + const { params } = visState; + const hasPalette = params?.palette; + + return { + ...visState, + params: { + ...visState.params, + ...(!hasPalette && { + palette: { + type: 'palette', + name: 'kibana_palette', + }, + }), + distinctColors: true, + }, + }; + } + + return visState; +}; diff --git a/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.test.ts b/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.test.ts index 36e1635ad4730..7ee43f36c864e 100644 --- a/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.test.ts +++ b/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.test.ts @@ -2114,4 +2114,52 @@ describe('migration visualization', () => { checkRuleIsNotAddedToArray('gauge_color_rules', params, migratedParams, rule4); }); }); + + describe('7.14.0 update pie visualization defaults', () => { + const migrate = (doc: any) => + visualizationSavedObjectTypeMigrations['7.14.0']( + doc as Parameters[0], + savedObjectMigrationContext + ); + const getTestDoc = (hasPalette = false) => ({ + attributes: { + title: 'My Vis', + description: 'This is my super cool vis.', + visState: JSON.stringify({ + type: 'pie', + title: '[Flights] Delay Type', + params: { + type: 'pie', + ...(hasPalette && { + palette: { + type: 'palette', + name: 'default', + }, + }), + }, + }), + }, + }); + + it('should decorate existing docs with the kibana legacy palette if the palette is not defined - pie', () => { + const migratedTestDoc = migrate(getTestDoc()); + const { palette } = JSON.parse(migratedTestDoc.attributes.visState).params; + + expect(palette.name).toEqual('kibana_palette'); + }); + + it('should not overwrite the palette with the legacy one if the palette already exists in the saved object', () => { + const migratedTestDoc = migrate(getTestDoc(true)); + const { palette } = JSON.parse(migratedTestDoc.attributes.visState).params; + + expect(palette.name).toEqual('default'); + }); + + it('should default the distinct colors per slice setting to true', () => { + const migratedTestDoc = migrate(getTestDoc()); + const { distinctColors } = JSON.parse(migratedTestDoc.attributes.visState).params; + + expect(distinctColors).toBe(true); + }); + }); }); diff --git a/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.ts b/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.ts index c5050b4a6940b..f386d9eb12091 100644 --- a/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.ts +++ b/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.ts @@ -15,6 +15,7 @@ import { commonAddSupportOfDualIndexSelectionModeInTSVB, commonHideTSVBLastValueIndicator, commonRemoveDefaultIndexPatternAndTimeFieldFromTSVBModel, + commonMigrateVislibPie, commonAddEmptyValueColorRule, } from './visualization_common_migrations'; @@ -990,6 +991,29 @@ const addEmptyValueColorRule: SavedObjectMigrationFn = (doc) => { return doc; }; +// [Pie Chart] Migrate vislib pie chart to use the new plugin vis_type_pie +const migrateVislibPie: SavedObjectMigrationFn = (doc) => { + const visStateJSON = get(doc, 'attributes.visState'); + let visState; + + if (visStateJSON) { + try { + visState = JSON.parse(visStateJSON); + } catch (e) { + // Let it go, the data is invalid and we'll leave it as is + } + const newVisState = commonMigrateVislibPie(visState); + return { + ...doc, + attributes: { + ...doc.attributes, + visState: JSON.stringify(newVisState), + }, + }; + } + return doc; +}; + export const visualizationSavedObjectTypeMigrations = { /** * We need to have this migration twice, once with a version prior to 7.0.0 once with a version @@ -1036,5 +1060,5 @@ export const visualizationSavedObjectTypeMigrations = { hideTSVBLastValueIndicator, removeDefaultIndexPatternAndTimeFieldFromTSVBModel ), - '7.14.0': flow(addEmptyValueColorRule), + '7.14.0': flow(addEmptyValueColorRule, migrateVislibPie), }; diff --git a/src/plugins/visualizations/server/plugin.ts b/src/plugins/visualizations/server/plugin.ts index 5a5a80b2689d6..1fec63f2bb45a 100644 --- a/src/plugins/visualizations/server/plugin.ts +++ b/src/plugins/visualizations/server/plugin.ts @@ -18,7 +18,7 @@ import { Logger, } from '../../../core/server'; -import { VISUALIZE_ENABLE_LABS_SETTING } from '../common/constants'; +import { VISUALIZE_ENABLE_LABS_SETTING, LEGACY_CHARTS_LIBRARY } from '../common/constants'; import { visualizationSavedObjectType } from './saved_objects'; @@ -58,6 +58,27 @@ export class VisualizationsPlugin category: ['visualization'], schema: schema.boolean(), }, + // TODO: Remove this when vis_type_vislib is removed + // https://github.com/elastic/kibana/issues/56143 + [LEGACY_CHARTS_LIBRARY]: { + name: i18n.translate( + 'visualizations.advancedSettings.visualization.legacyChartsLibrary.name', + { + defaultMessage: 'Legacy charts library', + } + ), + requiresPageReload: true, + value: false, + description: i18n.translate( + 'visualizations.advancedSettings.visualization.legacyChartsLibrary.description', + { + defaultMessage: + 'Enables legacy charts library for area, line, bar, pie charts in visualize.', + } + ), + category: ['visualization'], + schema: schema.boolean(), + }, }); if (plugins.usageCollection) { diff --git a/test/examples/embeddables/dashboard.ts b/test/examples/embeddables/dashboard.ts index 597846ab6a43d..69788ebad2af2 100644 --- a/test/examples/embeddables/dashboard.ts +++ b/test/examples/embeddables/dashboard.ts @@ -97,7 +97,8 @@ export default function ({ getService, getPageObjects }: PluginFunctionalProvide const pieChart = getService('pieChart'); const browser = getService('browser'); const dashboardExpect = getService('dashboardExpect'); - const PageObjects = getPageObjects(['common']); + const elasticChart = getService('elasticChart'); + const PageObjects = getPageObjects(['common', 'visChart']); describe('dashboard container', () => { before(async () => { @@ -109,6 +110,9 @@ export default function ({ getService, getPageObjects }: PluginFunctionalProvide }); it('pie charts', async () => { + if (await PageObjects.visChart.isNewChartsLibraryEnabled()) { + await elasticChart.setNewChartUiDebugFlag(); + } await pieChart.expectPieSliceCount(5); }); diff --git a/test/functional/apps/dashboard/dashboard_state.ts b/test/functional/apps/dashboard/dashboard_state.ts index acb2bd869819d..0f7722925293b 100644 --- a/test/functional/apps/dashboard/dashboard_state.ts +++ b/test/functional/apps/dashboard/dashboard_state.ts @@ -256,8 +256,11 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); describe('for embeddable config color parameters on a visualization', () => { + let originalPieSliceStyle = ''; it('updates a pie slice color on a soft refresh', async function () { await dashboardAddPanel.addVisualization(PIE_CHART_VIS_NAME); + + originalPieSliceStyle = await pieChart.getPieSliceStyle(`80,000`); await PageObjects.visChart.openLegendOptionColors( '80,000', `[data-title="${PIE_CHART_VIS_NAME}"]` @@ -272,7 +275,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const allPieSlicesColor = await pieChart.getAllPieSliceStyles('80,000'); let whitePieSliceCounts = 0; allPieSlicesColor.forEach((style) => { - if (style.indexOf('rgb(255, 255, 255)') > 0) { + if (style.indexOf('rgb(255, 255, 255)') > -1) { whitePieSliceCounts++; } }); @@ -290,14 +293,17 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('resets a pie slice color to the original when removed', async function () { const currentUrl = await getUrlFromShare(); - const newUrl = currentUrl.replace(`vis:(colors:('80,000':%23FFFFFF))`, ''); + const newUrl = isNewChartsLibraryEnabled + ? currentUrl.replace(`'80000':%23FFFFFF`, '') + : currentUrl.replace(`vis:(colors:('80,000':%23FFFFFF))`, ''); await browser.get(newUrl.toString(), false); await PageObjects.header.waitUntilLoadingHasFinished(); await retry.try(async () => { - const pieSliceStyle = await pieChart.getPieSliceStyle(`80,000`); - // The default green color that was stored with the visualization before any dashboard overrides. - expect(pieSliceStyle.indexOf('rgb(87, 193, 123)')).to.be.greaterThan(0); + const pieSliceStyle = await pieChart.getPieSliceStyle('80,000'); + + // After removing all overrides, pie slice style should match original. + expect(pieSliceStyle).to.be(originalPieSliceStyle); }); }); diff --git a/test/functional/apps/visualize/_pie_chart.ts b/test/functional/apps/visualize/_pie_chart.ts index dd58ca6514c36..8f76e2765e42c 100644 --- a/test/functional/apps/visualize/_pie_chart.ts +++ b/test/functional/apps/visualize/_pie_chart.ts @@ -15,6 +15,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const filterBar = getService('filterBar'); const pieChart = getService('pieChart'); const inspector = getService('inspector'); + const browser = getService('browser'); + const kibanaServer = getService('kibanaServer'); + const PageObjects = getPageObjects([ 'common', 'visualize', @@ -25,9 +28,18 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { ]); describe('pie chart', function () { + // Used to track flag before and after reset + let isNewChartsLibraryEnabled = false; const vizName1 = 'Visualization PieChart'; before(async function () { + isNewChartsLibraryEnabled = await PageObjects.visChart.isNewChartsLibraryEnabled(); await PageObjects.visualize.initTests(); + if (isNewChartsLibraryEnabled) { + await kibanaServer.uiSettings.update({ + 'visualization:visualize:legacyChartsLibrary': false, + }); + await browser.refresh(); + } log.debug('navigateToApp visualize'); await PageObjects.visualize.navigateToNewAggBasedVisualization(); log.debug('clickPieChart'); @@ -84,7 +96,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('other bucket', () => { it('should show other and missing bucket', async function () { - const expectedTableData = ['win 8', 'win xp', 'win 7', 'ios', 'Missing', 'Other']; + const expectedTableData = ['Missing', 'Other', 'ios', 'win 7', 'win 8', 'win xp']; await PageObjects.visualize.navigateToNewAggBasedVisualization(); log.debug('clickPieChart'); @@ -168,7 +180,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { 'ID', 'BR', 'Other', - ]; + ].sort(); await PageObjects.visEditor.toggleOpenEditor(2, 'false'); await PageObjects.visEditor.clickBucket('Split slices'); @@ -190,7 +202,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('should show correct result with one agg disabled', async () => { - const expectedTableData = ['win 8', 'win xp', 'win 7', 'ios', 'osx']; + const expectedTableData = ['ios', 'osx', 'win 7', 'win 8', 'win xp']; await PageObjects.visEditor.clickBucket('Split slices'); await PageObjects.visEditor.selectAggregation('Terms'); @@ -207,7 +219,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.visualize.loadSavedVisualization(vizName1); await PageObjects.visChart.waitForRenderingCount(); - const expectedTableData = ['win 8', 'win xp', 'win 7', 'ios', 'osx']; + const expectedTableData = ['ios', 'osx', 'win 7', 'win 8', 'win xp']; await pieChart.expectPieChartLabels(expectedTableData); }); @@ -276,7 +288,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { 'ios', 'win 8', 'osx', - ]; + ].sort(); await pieChart.expectPieChartLabels(expectedTableData); }); @@ -426,7 +438,14 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { 'CN', '360,000', 'CN', - ]; + ].sort(); + if (await PageObjects.visChart.isNewLibraryChart('visTypePieChart')) { + await PageObjects.visEditor.clickOptionsTab(); + await PageObjects.visEditor.togglePieLegend(); + await PageObjects.visEditor.togglePieNestedLegend(); + await PageObjects.visEditor.clickDataTab(); + await PageObjects.visEditor.clickGo(); + } await PageObjects.visChart.filterLegend('CN'); await PageObjects.visChart.waitForVisualization(); await pieChart.expectPieChartLabels(expectedTableData); diff --git a/test/functional/apps/visualize/index.ts b/test/functional/apps/visualize/index.ts index b87184bab3c0d..1e0e12a7d31bb 100644 --- a/test/functional/apps/visualize/index.ts +++ b/test/functional/apps/visualize/index.ts @@ -49,6 +49,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./_point_series_options')); loadTestFile(require.resolve('./_vertical_bar_chart')); loadTestFile(require.resolve('./_vertical_bar_chart_nontimeindex')); + loadTestFile(require.resolve('./_pie_chart')); }); describe('visualize ciGroup9', function () { diff --git a/test/functional/page_objects/visualize_chart_page.ts b/test/functional/page_objects/visualize_chart_page.ts index 7b69101b92475..7ecf800b4be7c 100644 --- a/test/functional/page_objects/visualize_chart_page.ts +++ b/test/functional/page_objects/visualize_chart_page.ts @@ -7,10 +7,12 @@ */ import { Position } from '@elastic/charts'; +import Color from 'color'; import { FtrProviderContext } from '../ftr_provider_context'; -const elasticChartSelector = 'visTypeXyChart'; +const xyChartSelector = 'visTypeXyChart'; +const pieChartSelector = 'visTypePieChart'; export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrProviderContext) { const testSubjects = getService('testSubjects'); @@ -25,8 +27,8 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr const { common } = getPageObjects(['common']); class VisualizeChart { - private async getDebugState() { - return await elasticChart.getChartDebugData(elasticChartSelector); + public async getEsChartDebugState(chartSelector: string) { + return await elasticChart.getChartDebugData(chartSelector); } /** @@ -45,32 +47,32 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr /** * Is new charts library enabled and an area, line or histogram chart exists */ - private async isVisTypeXYChart(): Promise { + public async isNewLibraryChart(chartSelector: string): Promise { const enabled = await this.isNewChartsLibraryEnabled(); if (!enabled) { - log.debug(`-- isVisTypeXYChart = false`); + log.debug(`-- isNewLibraryChart = false`); return false; } - // check if enabled but not a line, area or histogram chart + // check if enabled but not a line, area, histogram or pie chart if (await find.existsByCssSelector('.visLib__chart', 1)) { const chart = await find.byCssSelector('.visLib__chart'); const chartType = await chart.getAttribute('data-vislib-chart-type'); - if (!['line', 'area', 'histogram'].includes(chartType)) { - log.debug(`-- isVisTypeXYChart = false`); + if (!['line', 'area', 'histogram', 'pie'].includes(chartType)) { + log.debug(`-- isNewLibraryChart = false`); return false; } } - if (!(await elasticChart.hasChart(elasticChartSelector, 1))) { + if (!(await elasticChart.hasChart(chartSelector, 1))) { // not be a vislib chart type - log.debug(`-- isVisTypeXYChart = false`); + log.debug(`-- isNewLibraryChart = false`); return false; } - log.debug(`-- isVisTypeXYChart = true`); + log.debug(`-- isNewLibraryChart = true`); return true; } @@ -81,7 +83,7 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr * @param elasticChartsValue value expected for `@elastic/charts` chart */ public async getExpectedValue(vislibValue: T, elasticChartsValue: T): Promise { - if (await this.isVisTypeXYChart()) { + if (await this.isNewLibraryChart(xyChartSelector)) { return elasticChartsValue; } @@ -89,8 +91,8 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr } public async getYAxisTitle() { - if (await this.isVisTypeXYChart()) { - const xAxis = (await this.getDebugState())?.axes?.y ?? []; + if (await this.isNewLibraryChart(xyChartSelector)) { + const xAxis = (await this.getEsChartDebugState(xyChartSelector))?.axes?.y ?? []; return xAxis[0]?.title; } @@ -99,8 +101,8 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr } public async getXAxisLabels() { - if (await this.isVisTypeXYChart()) { - const [xAxis] = (await this.getDebugState())?.axes?.x ?? []; + if (await this.isNewLibraryChart(xyChartSelector)) { + const [xAxis] = (await this.getEsChartDebugState(xyChartSelector))?.axes?.x ?? []; return xAxis?.labels; } @@ -112,8 +114,8 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr } public async getYAxisLabels() { - if (await this.isVisTypeXYChart()) { - const [yAxis] = (await this.getDebugState())?.axes?.y ?? []; + if (await this.isNewLibraryChart(xyChartSelector)) { + const [yAxis] = (await this.getEsChartDebugState(xyChartSelector))?.axes?.y ?? []; return yAxis?.labels; } @@ -125,8 +127,8 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr } public async getYAxisLabelsAsNumbers() { - if (await this.isVisTypeXYChart()) { - const [yAxis] = (await this.getDebugState())?.axes?.y ?? []; + if (await this.isNewLibraryChart(xyChartSelector)) { + const [yAxis] = (await this.getEsChartDebugState(xyChartSelector))?.axes?.y ?? []; return yAxis?.values; } @@ -141,8 +143,8 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr * Returns an array of height values */ public async getAreaChartData(dataLabel: string, axis = 'ValueAxis-1') { - if (await this.isVisTypeXYChart()) { - const areas = (await this.getDebugState())?.areas ?? []; + if (await this.isNewLibraryChart(xyChartSelector)) { + const areas = (await this.getEsChartDebugState(xyChartSelector))?.areas ?? []; const points = areas.find(({ name }) => name === dataLabel)?.lines.y1.points ?? []; return points.map(({ y }) => y); } @@ -183,8 +185,8 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr * @param dataLabel data-label value */ public async getAreaChartPaths(dataLabel: string) { - if (await this.isVisTypeXYChart()) { - const areas = (await this.getDebugState())?.areas ?? []; + if (await this.isNewLibraryChart(xyChartSelector)) { + const areas = (await this.getEsChartDebugState(xyChartSelector))?.areas ?? []; const path = areas.find(({ name }) => name === dataLabel)?.path ?? ''; return path.split('L'); } @@ -208,9 +210,9 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr * @param axis axis value, 'ValueAxis-1' by default */ public async getLineChartData(dataLabel = 'Count', axis = 'ValueAxis-1') { - if (await this.isVisTypeXYChart()) { + if (await this.isNewLibraryChart(xyChartSelector)) { // For now lines are rendered as areas to enable stacking - const areas = (await this.getDebugState())?.areas ?? []; + const areas = (await this.getEsChartDebugState(xyChartSelector))?.areas ?? []; const lines = areas.map(({ lines: { y1 }, name, color }) => ({ ...y1, name, color })); const points = lines.find(({ name }) => name === dataLabel)?.points ?? []; return points.map(({ y }) => y); @@ -248,8 +250,8 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr * @param axis axis value, 'ValueAxis-1' by default */ public async getBarChartData(dataLabel = 'Count', axis = 'ValueAxis-1') { - if (await this.isVisTypeXYChart()) { - const bars = (await this.getDebugState())?.bars ?? []; + if (await this.isNewLibraryChart(xyChartSelector)) { + const bars = (await this.getEsChartDebugState(xyChartSelector))?.bars ?? []; const values = bars.find(({ name }) => name === dataLabel)?.bars ?? []; return values.map(({ y }) => y); } @@ -293,8 +295,9 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr } public async toggleLegend(show = true) { - const isVisTypeXYChart = await this.isVisTypeXYChart(); - const legendSelector = isVisTypeXYChart ? '.echLegend' : '.visLegend'; + const isVisTypeXYChart = await this.isNewLibraryChart(xyChartSelector); + const isVisTypePieChart = await this.isNewLibraryChart(pieChartSelector); + const legendSelector = isVisTypeXYChart || isVisTypePieChart ? '.echLegend' : '.visLegend'; await retry.try(async () => { const isVisible = await find.existsByCssSelector(legendSelector); @@ -321,16 +324,25 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr } public async doesSelectedLegendColorExist(color: string) { - if (await this.isVisTypeXYChart()) { - const items = (await this.getDebugState())?.legend?.items ?? []; + if (await this.isNewLibraryChart(xyChartSelector)) { + const items = (await this.getEsChartDebugState(xyChartSelector))?.legend?.items ?? []; return items.some(({ color: c }) => c === color); } + if (await this.isNewLibraryChart(pieChartSelector)) { + const slices = + (await this.getEsChartDebugState(pieChartSelector))?.partition?.[0]?.partitions ?? []; + return slices.some(({ color: c }) => { + const rgbColor = new Color(color).rgb().toString(); + return c === rgbColor; + }); + } + return await testSubjects.exists(`legendSelectedColor-${color}`); } public async expectError() { - if (!this.isVisTypeXYChart()) { + if (!this.isNewLibraryChart(xyChartSelector)) { await testSubjects.existOrFail('vislibVisualizeError'); } } @@ -371,17 +383,25 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr public async waitForVisualization() { await this.waitForVisualizationRenderingStabilized(); - if (!(await this.isVisTypeXYChart())) { + if (!(await this.isNewLibraryChart(xyChartSelector))) { await find.byCssSelector('.visualization'); } } public async getLegendEntries() { - if (await this.isVisTypeXYChart()) { - const items = (await this.getDebugState())?.legend?.items ?? []; + const isVisTypeXYChart = await this.isNewLibraryChart(xyChartSelector); + const isVisTypePieChart = await this.isNewLibraryChart(pieChartSelector); + if (isVisTypeXYChart) { + const items = (await this.getEsChartDebugState(xyChartSelector))?.legend?.items ?? []; return items.map(({ name }) => name); } + if (isVisTypePieChart) { + const slices = + (await this.getEsChartDebugState(pieChartSelector))?.partition?.[0]?.partitions ?? []; + return slices.map(({ name }) => name); + } + const legendEntries = await find.allByCssSelector( '.visLegend__button', defaultFindTimeout * 2 @@ -391,10 +411,13 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr ); } - public async openLegendOptionColors(name: string, chartSelector = elasticChartSelector) { + public async openLegendOptionColors(name: string, chartSelector: string) { await this.waitForVisualizationRenderingStabilized(); await retry.try(async () => { - if (await this.isVisTypeXYChart()) { + if ( + (await this.isNewLibraryChart(xyChartSelector)) || + (await this.isNewLibraryChart(pieChartSelector)) + ) { const chart = await find.byCssSelector(chartSelector); const legendItemColor = await chart.findByCssSelector( `[data-ech-series-name="${name}"] .echLegendItem__color` @@ -408,7 +431,9 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr await this.waitForVisualizationRenderingStabilized(); // arbitrary color chosen, any available would do - const arbitraryColor = (await this.isVisTypeXYChart()) ? '#d36086' : '#EF843C'; + const arbitraryColor = (await this.isNewLibraryChart(xyChartSelector)) + ? '#d36086' + : '#EF843C'; const isOpen = await this.doesLegendColorChoiceExist(arbitraryColor); if (!isOpen) { throw new Error('legend color selector not open'); @@ -524,8 +549,8 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr } public async getRightValueAxesCount() { - if (await this.isVisTypeXYChart()) { - const yAxes = (await this.getDebugState())?.axes?.y ?? []; + if (await this.isNewLibraryChart(xyChartSelector)) { + const yAxes = (await this.getEsChartDebugState(xyChartSelector))?.axes?.y ?? []; return yAxes.filter(({ position }) => position === Position.Right).length; } const axes = await find.allByCssSelector('.visAxis__column--right g.axis'); @@ -544,8 +569,8 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr } public async getHistogramSeriesCount() { - if (await this.isVisTypeXYChart()) { - const bars = (await this.getDebugState())?.bars ?? []; + if (await this.isNewLibraryChart(xyChartSelector)) { + const bars = (await this.getEsChartDebugState(xyChartSelector))?.bars ?? []; return bars.filter(({ visible }) => visible).length; } @@ -554,8 +579,11 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr } public async getGridLines(): Promise> { - if (await this.isVisTypeXYChart()) { - const { x, y } = (await this.getDebugState())?.axes ?? { x: [], y: [] }; + if (await this.isNewLibraryChart(xyChartSelector)) { + const { x, y } = (await this.getEsChartDebugState(xyChartSelector))?.axes ?? { + x: [], + y: [], + }; return [...x, ...y].flatMap(({ gridlines }) => gridlines); } @@ -574,8 +602,8 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr } public async getChartValues() { - if (await this.isVisTypeXYChart()) { - const barSeries = (await this.getDebugState())?.bars ?? []; + if (await this.isNewLibraryChart(xyChartSelector)) { + const barSeries = (await this.getEsChartDebugState(xyChartSelector))?.bars ?? []; return barSeries.filter(({ visible }) => visible).flatMap((bars) => bars.labels); } diff --git a/test/functional/page_objects/visualize_editor_page.ts b/test/functional/page_objects/visualize_editor_page.ts index 59e93bd1f5700..47cbc8c5e3ea3 100644 --- a/test/functional/page_objects/visualize_editor_page.ts +++ b/test/functional/page_objects/visualize_editor_page.ts @@ -327,6 +327,14 @@ export function VisualizeEditorPageProvider({ getService, getPageObjects }: FtrP await testSubjects.click('visualizeEditorAutoButton'); } + public async togglePieLegend() { + await testSubjects.click('visTypePieAddLegendSwitch'); + } + + public async togglePieNestedLegend() { + await testSubjects.click('visTypePieNestedLegendSwitch'); + } + public async isApplyEnabled() { const applyButton = await testSubjects.find('visualizeEditorRenderButton'); return await applyButton.isEnabled(); diff --git a/test/functional/services/visualizations/pie_chart.ts b/test/functional/services/visualizations/pie_chart.ts index cac4e8fe64c5e..f51492d29b450 100644 --- a/test/functional/services/visualizations/pie_chart.ts +++ b/test/functional/services/visualizations/pie_chart.ts @@ -9,6 +9,8 @@ import expect from '@kbn/expect'; import { FtrService } from '../../ftr_provider_context'; +const pieChartSelector = 'visTypePieChart'; + export class PieChartService extends FtrService { private readonly log = this.ctx.getService('log'); private readonly retry = this.ctx.getService('retry'); @@ -18,20 +20,42 @@ export class PieChartService extends FtrService { private readonly find = this.ctx.getService('find'); private readonly panelActions = this.ctx.getService('dashboardPanelActions'); private readonly defaultFindTimeout = this.config.get('timeouts.find'); + private readonly pageObjects = this.ctx.getPageObjects(['visChart']); private readonly filterActionText = 'Apply filter to current view'; async clickOnPieSlice(name?: string) { this.log.debug(`PieChart.clickOnPieSlice(${name})`); - if (name) { - await this.testSubjects.click(`pieSlice-${name.split(' ').join('-')}`); + if (await this.pageObjects.visChart.isNewLibraryChart(pieChartSelector)) { + const slices = + (await this.pageObjects.visChart.getEsChartDebugState(pieChartSelector))?.partition?.[0] + ?.partitions ?? []; + let sliceLabel = name || slices[0].name; + if (name === 'Other') { + sliceLabel = '__other__'; + } + const pieSlice = slices.find((slice) => slice.name === sliceLabel); + const pie = await this.testSubjects.find(pieChartSelector); + if (pieSlice) { + const pieSize = await pie.getSize(); + const pieHeight = pieSize.height; + const pieWidth = pieSize.width; + await pie.clickMouseButton({ + xOffset: pieSlice.coords[0] - Math.floor(pieWidth / 2), + yOffset: Math.floor(pieHeight / 2) - pieSlice.coords[1], + }); + } } else { - // If no pie slice has been provided, find the first one available. - await this.retry.try(async () => { - const slices = await this.find.allByCssSelector('svg > g > g.arcs > path.slice'); - this.log.debug('Slices found:' + slices.length); - return slices[0].click(); - }); + if (name) { + await this.testSubjects.click(`pieSlice-${name.split(' ').join('-')}`); + } else { + // If no pie slice has been provided, find the first one available. + await this.retry.try(async () => { + const slices = await this.find.allByCssSelector('svg > g > g.arcs > path.slice'); + this.log.debug('Slices found:' + slices.length); + return slices[0].click(); + }); + } } } @@ -63,12 +87,30 @@ export class PieChartService extends FtrService { async getPieSliceStyle(name: string) { this.log.debug(`VisualizePage.getPieSliceStyle(${name})`); + if (await this.pageObjects.visChart.isNewLibraryChart(pieChartSelector)) { + const slices = + (await this.pageObjects.visChart.getEsChartDebugState(pieChartSelector))?.partition?.[0] + ?.partitions ?? []; + const selectedSlice = slices.filter((slice) => { + return slice.name.toString() === name.replace(',', ''); + }); + return selectedSlice[0].color; + } const pieSlice = await this.getPieSlice(name); return await pieSlice.getAttribute('style'); } async getAllPieSliceStyles(name: string) { this.log.debug(`VisualizePage.getAllPieSliceStyles(${name})`); + if (await this.pageObjects.visChart.isNewLibraryChart(pieChartSelector)) { + const slices = + (await this.pageObjects.visChart.getEsChartDebugState(pieChartSelector))?.partition?.[0] + ?.partitions ?? []; + const selectedSlice = slices.filter((slice) => { + return slice.name.toString() === name.replace(',', ''); + }); + return selectedSlice.map((slice) => slice.color); + } const pieSlices = await this.getAllPieSlices(name); return await Promise.all( pieSlices.map(async (pieSlice) => await pieSlice.getAttribute('style')) @@ -87,6 +129,24 @@ export class PieChartService extends FtrService { } async getPieChartLabels() { + if (await this.pageObjects.visChart.isNewLibraryChart(pieChartSelector)) { + const slices = + (await this.pageObjects.visChart.getEsChartDebugState(pieChartSelector))?.partition?.[0] + ?.partitions ?? []; + return slices.map((slice) => { + if (slice.name === '__missing__') { + return 'Missing'; + } else if (slice.name === '__other__') { + return 'Other'; + } else if (typeof slice.name === 'number') { + // debugState of escharts returns the numbers without comma + const val = slice.name as number; + return val.toString().replace(/\B(? await chart.getAttribute('data-label')) @@ -95,10 +155,23 @@ export class PieChartService extends FtrService { async getPieSliceCount() { this.log.debug('PieChart.getPieSliceCount'); + if (await this.pageObjects.visChart.isNewLibraryChart(pieChartSelector)) { + const slices = + (await this.pageObjects.visChart.getEsChartDebugState(pieChartSelector))?.partition?.[0] + ?.partitions ?? []; + return slices?.length; + } const slices = await this.find.allByCssSelector('svg > g > g.arcs > path.slice'); return slices.length; } + async expectPieSliceCountEsCharts(expectedCount: number) { + const slices = + (await this.pageObjects.visChart.getEsChartDebugState(pieChartSelector))?.partition?.[0] + ?.partitions ?? []; + expect(slices.length).to.be(expectedCount); + } + async expectPieSliceCount(expectedCount: number) { this.log.debug(`PieChart.expectPieSliceCount(${expectedCount})`); await this.retry.try(async () => { @@ -111,7 +184,7 @@ export class PieChartService extends FtrService { this.log.debug(`PieChart.expectPieChartLabels(${expectedLabels.join(',')})`); await this.retry.try(async () => { const pieData = await this.getPieChartLabels(); - expect(pieData).to.eql(expectedLabels); + expect(pieData.sort()).to.eql(expectedLabels); }); } } diff --git a/tsconfig.json b/tsconfig.json index 37fc9ee05a29b..c91f7b768a5c4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -65,6 +65,7 @@ { "path": "./src/plugins/vis_type_vislib/tsconfig.json" }, { "path": "./src/plugins/vis_type_vega/tsconfig.json" }, { "path": "./src/plugins/vis_type_xy/tsconfig.json" }, + { "path": "./src/plugins/vis_type_pie/tsconfig.json" }, { "path": "./src/plugins/visualizations/tsconfig.json" }, { "path": "./src/plugins/visualize/tsconfig.json" }, { "path": "./src/plugins/index_pattern_management/tsconfig.json" }, diff --git a/tsconfig.refs.json b/tsconfig.refs.json index 1b8a76d601e38..9aa41cb9bc755 100644 --- a/tsconfig.refs.json +++ b/tsconfig.refs.json @@ -52,6 +52,7 @@ { "path": "./src/plugins/vis_type_vislib/tsconfig.json" }, { "path": "./src/plugins/vis_type_vega/tsconfig.json" }, { "path": "./src/plugins/vis_type_xy/tsconfig.json" }, + { "path": "./src/plugins/vis_type_pie/tsconfig.json" }, { "path": "./src/plugins/visualizations/tsconfig.json" }, { "path": "./src/plugins/visualize/tsconfig.json" }, { "path": "./src/plugins/index_pattern_management/tsconfig.json" }, diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index d1e6835b486b2..c2cad05ff9e30 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -4902,12 +4902,6 @@ "visTypeVislib.editors.heatmap.heatmapSettingsTitle": "ヒートマップ設定", "visTypeVislib.editors.heatmap.highlightLabel": "ハイライト範囲", "visTypeVislib.editors.heatmap.highlightLabelTooltip": "チャートのカーソルを当てた部分と凡例の対応するラベルをハイライトします。", - "visTypeVislib.editors.pie.donutLabel": "ドーナッツ", - "visTypeVislib.editors.pie.labelsSettingsTitle": "ラベル設定", - "visTypeVislib.editors.pie.pieSettingsTitle": "パイ設定", - "visTypeVislib.editors.pie.showLabelsLabel": "ラベルを表示", - "visTypeVislib.editors.pie.showTopLevelOnlyLabel": "トップレベルのみ表示", - "visTypeVislib.editors.pie.showValuesLabel": "値を表示", "visTypeVislib.functions.pie.help": "パイビジュアライゼーション", "visTypeVislib.functions.vislib.help": "Vislib ビジュアライゼーション", "visTypeVislib.gauge.alignmentAutomaticTitle": "自動", @@ -4929,11 +4923,17 @@ "visTypeVislib.heatmap.metricTitle": "値", "visTypeVislib.heatmap.segmentTitle": "X 軸", "visTypeVislib.heatmap.splitTitle": "チャートを分割", - "visTypeVislib.pie.metricTitle": "スライスサイズ", - "visTypeVislib.pie.pieDescription": "全体に対する比率でデータを比較します。", - "visTypeVislib.pie.pieTitle": "円", - "visTypeVislib.pie.segmentTitle": "スライスの分割", - "visTypeVislib.pie.splitTitle": "チャートを分割", + "visTypePie.pie.metricTitle": "スライスサイズ", + "visTypePie.pie.pieDescription": "全体に対する比率でデータを比較します。", + "visTypePie.pie.pieTitle": "円", + "visTypePie.pie.segmentTitle": "スライスの分割", + "visTypePie.pie.splitTitle": "チャートを分割", + "visTypePie.editors.pie.donutLabel": "ドーナッツ", + "visTypePie.editors.pie.labelsSettingsTitle": "ラベル設定", + "visTypePie.editors.pie.pieSettingsTitle": "パイ設定", + "visTypePie.editors.pie.showLabelsLabel": "ラベルを表示", + "visTypePie.editors.pie.showTopLevelOnlyLabel": "トップレベルのみ表示", + "visTypePie.editors.pie.showValuesLabel": "値を表示", "visTypeVislib.vislib.errors.noResultsFoundTitle": "結果が見つかりませんでした", "visTypeVislib.vislib.heatmap.maxBucketsText": "定義された数列が多すぎます ({nr}) 。構成されている最大値は {max} です。", "visTypeVislib.vislib.legend.filterForValueButtonAriaLabel": "値 {legendDataLabel} でフィルタリング", @@ -4945,8 +4945,8 @@ "visTypeVislib.vislib.legend.toggleOptionsButtonAriaLabel": "{legendDataLabel}、トグルオプション", "visTypeVislib.vislib.tooltip.fieldLabel": "フィールド", "visTypeVislib.vislib.tooltip.valueLabel": "値", - "visTypeXy.advancedSettings.visualization.legacyChartsLibrary.description": "Visualizeでエリア、折れ線、棒グラフのレガシーグラフライブラリを有効にします。", - "visTypeXy.advancedSettings.visualization.legacyChartsLibrary.name": "レガシーグラフライブラリ", + "visualizations.advancedSettings.visualization.legacyChartsLibrary.description": "Visualizeでエリア、折れ線、棒グラフのレガシーグラフライブラリを有効にします。", + "visualizations.advancedSettings.visualization.legacyChartsLibrary.name": "レガシーグラフライブラリ", "visTypeXy.aggResponse.allDocsTitle": "すべてのドキュメント", "visTypeXy.area.areaDescription": "軸と線の間のデータを強調します。", "visTypeXy.area.areaTitle": "エリア", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 97f3ebdb73396..d3a3d9ae30c37 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -4929,12 +4929,6 @@ "visTypeVislib.editors.heatmap.heatmapSettingsTitle": "热图设置", "visTypeVislib.editors.heatmap.highlightLabel": "高亮范围", "visTypeVislib.editors.heatmap.highlightLabelTooltip": "高亮显示图表中鼠标悬停的范围以及图例中对应的标签。", - "visTypeVislib.editors.pie.donutLabel": "圆环图", - "visTypeVislib.editors.pie.labelsSettingsTitle": "标签设置", - "visTypeVislib.editors.pie.pieSettingsTitle": "饼图设置", - "visTypeVislib.editors.pie.showLabelsLabel": "显示标签", - "visTypeVislib.editors.pie.showTopLevelOnlyLabel": "仅显示顶级", - "visTypeVislib.editors.pie.showValuesLabel": "显示值", "visTypeVislib.functions.pie.help": "饼图可视化", "visTypeVislib.functions.vislib.help": "Vislib 可视化", "visTypeVislib.gauge.alignmentAutomaticTitle": "自动", @@ -4956,11 +4950,17 @@ "visTypeVislib.heatmap.metricTitle": "值", "visTypeVislib.heatmap.segmentTitle": "X 轴", "visTypeVislib.heatmap.splitTitle": "拆分图表", - "visTypeVislib.pie.metricTitle": "切片大小", - "visTypeVislib.pie.pieDescription": "以整体的比例比较数据。", - "visTypeVislib.pie.pieTitle": "饼图", - "visTypeVislib.pie.segmentTitle": "拆分切片", - "visTypeVislib.pie.splitTitle": "拆分图表", + "visTypePie.pie.metricTitle": "切片大小", + "visTypePie.pie.pieDescription": "以整体的比例比较数据。", + "visTypePie.pie.pieTitle": "饼图", + "visTypePie.pie.segmentTitle": "拆分切片", + "visTypePie.pie.splitTitle": "拆分图表", + "visTypePie.editors.pie.donutLabel": "圆环图", + "visTypePie.editors.pie.labelsSettingsTitle": "标签设置", + "visTypePie.editors.pie.pieSettingsTitle": "饼图设置", + "visTypePie.editors.pie.showLabelsLabel": "显示标签", + "visTypePie.editors.pie.showTopLevelOnlyLabel": "仅显示顶级", + "visTypePie.editors.pie.showValuesLabel": "显示值", "visTypeVislib.vislib.errors.noResultsFoundTitle": "找不到结果", "visTypeVislib.vislib.heatmap.maxBucketsText": "定义了过多的序列 ({nr})。配置的最大值为 {max}。", "visTypeVislib.vislib.legend.filterForValueButtonAriaLabel": "筛留值 {legendDataLabel}", @@ -4972,8 +4972,8 @@ "visTypeVislib.vislib.legend.toggleOptionsButtonAriaLabel": "{legendDataLabel}, 切换选项", "visTypeVislib.vislib.tooltip.fieldLabel": "字段", "visTypeVislib.vislib.tooltip.valueLabel": "值", - "visTypeXy.advancedSettings.visualization.legacyChartsLibrary.description": "在 Visualize 中启用面积图、折线图和条形图的旧版图表库。", - "visTypeXy.advancedSettings.visualization.legacyChartsLibrary.name": "旧版图表库", + "visualizations.advancedSettings.visualization.legacyChartsLibrary.description": "在 Visualize 中启用面积图、折线图和条形图的旧版图表库。", + "visualizations.advancedSettings.visualization.legacyChartsLibrary.name": "旧版图表库", "visTypeXy.aggResponse.allDocsTitle": "所有文档", "visTypeXy.area.areaDescription": "突出轴与线之间的数据。", "visTypeXy.area.areaTitle": "面积图", diff --git a/x-pack/test/functional/apps/dashboard/drilldowns/dashboard_to_dashboard_drilldown.ts b/x-pack/test/functional/apps/dashboard/drilldowns/dashboard_to_dashboard_drilldown.ts index b891d3cce3ba0..1660bbff10d37 100644 --- a/x-pack/test/functional/apps/dashboard/drilldowns/dashboard_to_dashboard_drilldown.ts +++ b/x-pack/test/functional/apps/dashboard/drilldowns/dashboard_to_dashboard_drilldown.ts @@ -24,6 +24,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { 'settings', 'copySavedObjectsToSpace', ]); + const queryBar = getService('queryBar'); const pieChart = getService('pieChart'); const log = getService('log'); const browser = getService('browser'); @@ -31,6 +32,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const filterBar = getService('filterBar'); const security = getService('security'); const spaces = getService('spaces'); + const elasticChart = getService('elasticChart'); describe('Dashboard to dashboard drilldown', function () { describe('Create & use drilldowns', () => { @@ -211,7 +213,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await navigateWithinDashboard(async () => { await dashboardDrilldownPanelActions.clickActionByText(DRILLDOWN_TO_PIE_CHART_NAME); }); - await pieChart.expectPieSliceCount(10); + await elasticChart.setNewChartUiDebugFlag(); + await queryBar.submitQuery(); + await pieChart.expectPieSliceCountEsCharts(10); }); }); }); From a0c20ac7aaf5b5667b4bb78d270825e039995431 Mon Sep 17 00:00:00 2001 From: Devon Thomson Date: Thu, 3 Jun 2021 11:58:25 -0400 Subject: [PATCH 29/90] [Dashboard] Fix Copy To Permission & Unskip RBAC tests (#100616) * slightly better typing for dashboard permissions. Fixed typo, unskipped functional tests --- src/plugins/dashboard/common/types.ts | 8 ++++++ .../application/dashboard_app_functions.ts | 4 +-- .../embeddable/dashboard_container.tsx | 6 ++--- .../dashboard_empty_screen.test.tsx.snap | 4 +++ .../empty_screen/dashboard_empty_screen.tsx | 6 ++++- .../hooks/use_dashboard_container.test.tsx | 4 +-- .../listing/dashboard_listing.test.tsx | 4 +-- .../application/top_nav/show_share_modal.tsx | 6 ++--- .../dashboard/public/application/types.ts | 4 +-- src/plugins/dashboard/public/plugin.tsx | 8 ++++-- .../dashboard/server/capabilities_provider.ts | 6 ++++- .../feature_controls/dashboard_security.ts | 26 +++++++++++-------- .../time_to_visualize_security.ts | 3 +-- 13 files changed, 58 insertions(+), 31 deletions(-) diff --git a/src/plugins/dashboard/common/types.ts b/src/plugins/dashboard/common/types.ts index 9a6d185ef2ac1..5851ffa045bc7 100644 --- a/src/plugins/dashboard/common/types.ts +++ b/src/plugins/dashboard/common/types.ts @@ -32,6 +32,14 @@ export interface DashboardPanelState< panelRefName?: string; } +export interface DashboardCapabilities { + showWriteControls: boolean; + saveQuery: boolean; + createNew: boolean; + show: boolean; + [key: string]: boolean; +} + /** * This should always represent the latest dashboard panel shape, after all possible migrations. */ diff --git a/src/plugins/dashboard/public/application/dashboard_app_functions.ts b/src/plugins/dashboard/public/application/dashboard_app_functions.ts index 6d51422d4bd23..895a56242bf96 100644 --- a/src/plugins/dashboard/public/application/dashboard_app_functions.ts +++ b/src/plugins/dashboard/public/application/dashboard_app_functions.ts @@ -21,7 +21,7 @@ import { switchMap, } from 'rxjs/operators'; -import { DashboardCapabilities } from './types'; +import { DashboardAppCapabilities } from './types'; import { DashboardConstants } from '../dashboard_constants'; import { DashboardStateManager } from './dashboard_state_manager'; import { convertSavedDashboardPanelToPanelState } from '../../common/embeddable/embeddable_saved_object_converters'; @@ -103,7 +103,7 @@ export const getDashboardContainerInput = ({ dashboardStateManager, dashboardCapabilities, }: { - dashboardCapabilities: DashboardCapabilities; + dashboardCapabilities: DashboardAppCapabilities; dashboardStateManager: DashboardStateManager; incomingEmbeddable?: EmbeddablePackageState; lastReloadRequestTime?: number; diff --git a/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx b/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx index 92b0727d2458c..847a190a6e083 100644 --- a/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx +++ b/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx @@ -37,11 +37,11 @@ import { } from '../../services/kibana_react'; import { PLACEHOLDER_EMBEDDABLE } from './placeholder'; import { PanelPlacementMethod, IPanelPlacementArgs } from './panel/dashboard_panel_placement'; -import { DashboardCapabilities } from '../types'; +import { DashboardAppCapabilities } from '../types'; import { PresentationUtilPluginStart } from '../../services/presentation_util'; export interface DashboardContainerInput extends ContainerInput { - dashboardCapabilities?: DashboardCapabilities; + dashboardCapabilities?: DashboardAppCapabilities; refreshConfig?: RefreshInterval; isEmbeddedExternally?: boolean; isFullScreenMode: boolean; @@ -91,7 +91,7 @@ export interface InheritedChildInput extends IndexSignature { export type DashboardReactContextValue = KibanaReactContextValue; export type DashboardReactContext = KibanaReactContext; -const defaultCapabilities: DashboardCapabilities = { +const defaultCapabilities: DashboardAppCapabilities = { show: false, createNew: false, saveQuery: false, diff --git a/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap b/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap index 44beed5e4a89b..ae8943e9f6b3e 100644 --- a/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap +++ b/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap @@ -590,10 +590,12 @@ exports[`DashboardEmptyScreen renders correctly with readonly mode 1`] = `
{ return ( - + toasts: coreMock.createStart().notifications.toasts, }); -const defaultCapabilities: DashboardCapabilities = { +const defaultCapabilities: DashboardAppCapabilities = { show: false, createNew: false, saveQuery: false, diff --git a/src/plugins/dashboard/public/application/listing/dashboard_listing.test.tsx b/src/plugins/dashboard/public/application/listing/dashboard_listing.test.tsx index 022c830b180b6..febb03d58d934 100644 --- a/src/plugins/dashboard/public/application/listing/dashboard_listing.test.tsx +++ b/src/plugins/dashboard/public/application/listing/dashboard_listing.test.tsx @@ -24,7 +24,7 @@ import { savedObjectsPluginMock } from '../../../../saved_objects/public/mocks'; import { DashboardListing, DashboardListingProps } from './dashboard_listing'; import { embeddablePluginMock } from '../../../../embeddable/public/mocks'; import { visualizationsPluginMock } from '../../../../visualizations/public/mocks'; -import { DashboardAppServices, DashboardCapabilities } from '../types'; +import { DashboardAppServices, DashboardAppCapabilities } from '../types'; import { dataPluginMock } from '../../../../data/public/mocks'; import { chromeServiceMock, coreMock } from '../../../../../core/public/mocks'; import { I18nProvider } from '@kbn/i18n/react'; @@ -59,7 +59,7 @@ function makeDefaultServices(): DashboardAppServices { return { savedObjects: savedObjectsPluginMock.createStartContract(), embeddable: embeddablePluginMock.createInstance().doStart(), - dashboardCapabilities: {} as DashboardCapabilities, + dashboardCapabilities: {} as DashboardAppCapabilities, initializerContext: {} as PluginInitializerContext, chrome: chromeServiceMock.createStartContract(), navigation: {} as NavigationPublicPluginStart, diff --git a/src/plugins/dashboard/public/application/top_nav/show_share_modal.tsx b/src/plugins/dashboard/public/application/top_nav/show_share_modal.tsx index 56823adf6bc14..a96b1ebd4f1ff 100644 --- a/src/plugins/dashboard/public/application/top_nav/show_share_modal.tsx +++ b/src/plugins/dashboard/public/application/top_nav/show_share_modal.tsx @@ -16,7 +16,7 @@ import { SharePluginStart } from '../../services/share'; import { dashboardUrlParams } from '../dashboard_router'; import { DashboardStateManager } from '../dashboard_state_manager'; import { shareModalStrings } from '../../dashboard_strings'; -import { DashboardCapabilities } from '../types'; +import { DashboardAppCapabilities } from '../types'; const showFilterBarId = 'showFilterBar'; @@ -24,14 +24,14 @@ interface ShowShareModalProps { share: SharePluginStart; anchorElement: HTMLElement; savedDashboard: DashboardSavedObject; - dashboardCapabilities: DashboardCapabilities; + dashboardCapabilities: DashboardAppCapabilities; dashboardStateManager: DashboardStateManager; } export const showPublicUrlSwitch = (anonymousUserCapabilities: Capabilities) => { if (!anonymousUserCapabilities.dashboard) return false; - const dashboard = (anonymousUserCapabilities.dashboard as unknown) as DashboardCapabilities; + const dashboard = (anonymousUserCapabilities.dashboard as unknown) as DashboardAppCapabilities; return !!dashboard.show; }; diff --git a/src/plugins/dashboard/public/application/types.ts b/src/plugins/dashboard/public/application/types.ts index dd291291ce9d6..aae8a1f6eca54 100644 --- a/src/plugins/dashboard/public/application/types.ts +++ b/src/plugins/dashboard/public/application/types.ts @@ -49,7 +49,7 @@ export interface DashboardSaveOptions { isTitleDuplicateConfirmed: boolean; } -export interface DashboardCapabilities { +export interface DashboardAppCapabilities { visualizeCapabilities: { save: boolean }; mapsCapabilities: { save: boolean }; hideWriteControls: boolean; @@ -77,7 +77,7 @@ export interface DashboardAppServices { usageCollection?: UsageCollectionSetup; navigation: NavigationPublicPluginStart; dashboardPanelStorage: DashboardPanelStorage; - dashboardCapabilities: DashboardCapabilities; + dashboardCapabilities: DashboardAppCapabilities; initializerContext: PluginInitializerContext; onAppLeave: AppMountParameters['onAppLeave']; savedObjectsTagging?: SavedObjectsTaggingApi; diff --git a/src/plugins/dashboard/public/plugin.tsx b/src/plugins/dashboard/public/plugin.tsx index 230918399d88f..b73fe5f2ba410 100644 --- a/src/plugins/dashboard/public/plugin.tsx +++ b/src/plugins/dashboard/public/plugin.tsx @@ -65,6 +65,7 @@ import { AddToLibraryAction, LibraryNotificationAction, CopyToDashboardAction, + DashboardCapabilities, } from './application'; import { createDashboardUrlGenerator, @@ -351,6 +352,9 @@ export class DashboardPlugin const { notifications, overlays, application } = core; const { uiActions, data, share, presentationUtil, embeddable } = plugins; + const dashboardCapabilities: Readonly = application.capabilities + .dashboard as DashboardCapabilities; + const SavedObjectFinder = getSavedObjectFinder(core.savedObjects, core.uiSettings); const expandPanelAction = new ExpandPanelAction(); @@ -395,8 +399,8 @@ export class DashboardPlugin overlays, embeddable.getStateTransfer(), { - canCreateNew: Boolean(application.capabilities.dashboard.createNew), - canEditExisting: !Boolean(application.capabilities.dashboard.hideWriteControls), + canCreateNew: Boolean(dashboardCapabilities.createNew), + canEditExisting: Boolean(dashboardCapabilities.showWriteControls), }, presentationUtil.ContextProvider ); diff --git a/src/plugins/dashboard/server/capabilities_provider.ts b/src/plugins/dashboard/server/capabilities_provider.ts index 25457c1a487d9..c5b740c581294 100644 --- a/src/plugins/dashboard/server/capabilities_provider.ts +++ b/src/plugins/dashboard/server/capabilities_provider.ts @@ -6,7 +6,11 @@ * Side Public License, v 1. */ -export const capabilitiesProvider = () => ({ +import { DashboardCapabilities } from '../common/types'; + +export const capabilitiesProvider = (): { + dashboard: DashboardCapabilities; +} => ({ dashboard: { createNew: true, show: true, diff --git a/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_security.ts b/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_security.ts index bdbfb5050a32f..94a0eedd07c54 100644 --- a/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_security.ts +++ b/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_security.ts @@ -31,8 +31,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const queryBar = getService('queryBar'); const savedQueryManagementComponent = getService('savedQueryManagementComponent'); - // FLAKY: https://github.com/elastic/kibana/issues/86950 - describe.skip('dashboard feature controls security', () => { + describe('dashboard feature controls security', () => { before(async () => { await esArchiver.load('dashboard/feature_controls/security'); await esArchiver.loadIfNeeded('logstash_functional'); @@ -86,7 +85,11 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { it('only shows the dashboard navlink', async () => { const navLinks = await appsMenu.readLinks(); - expect(navLinks.map((link) => link.text)).to.eql(['Overview', 'Dashboard']); + expect(navLinks.map((link) => link.text)).to.eql([ + 'Overview', + 'Dashboard', + 'Stack Management', + ]); }); it(`landing page shows "Create new Dashboard" button`, async () => { @@ -108,8 +111,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await globalNav.badgeMissingOrFail(); }); - // Can't figure out how to get this test to pass - it.skip(`create new dashboard shows addNew button`, async () => { + it(`create new dashboard shows addNew button`, async () => { await PageObjects.common.navigateToActualUrl( 'dashboard', DashboardConstants.CREATE_NEW_DASHBOARD_URL, @@ -320,8 +322,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await globalNav.badgeExistsOrFail('Read only'); }); - // Has this behavior changed? - it.skip(`create new dashboard redirects to the home page`, async () => { + it(`create new dashboard shows the read only warning`, async () => { await PageObjects.common.navigateToActualUrl( 'dashboard', DashboardConstants.CREATE_NEW_DASHBOARD_URL, @@ -330,7 +331,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { shouldLoginIfPrompted: false, } ); - await testSubjects.existOrFail('homeApp', { timeout: 20000 }); + await testSubjects.existOrFail('dashboardEmptyReadOnly', { timeout: 20000 }); }); it(`can view existing Dashboard`, async () => { @@ -347,6 +348,10 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }); }); + it('does not allow copy to dashboard behaviour', async () => { + await panelActions.expectMissingPanelAction('embeddablePanelAction-copyToDashboard'); + }); + it(`Permalinks doesn't show create short-url button`, async () => { await PageObjects.share.openShareMenuItem('Permalinks'); await PageObjects.share.createShortUrlMissingOrFail(); @@ -438,8 +443,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await globalNav.badgeExistsOrFail('Read only'); }); - // Has this behavior changed? - it.skip(`create new dashboard redirects to the home page`, async () => { + it(`create new dashboard shows the read only warning`, async () => { await PageObjects.common.navigateToActualUrl( 'dashboard', DashboardConstants.CREATE_NEW_DASHBOARD_URL, @@ -448,7 +452,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { shouldLoginIfPrompted: false, } ); - await testSubjects.existOrFail('homeApp', { timeout: 20000 }); + await testSubjects.existOrFail('dashboardEmptyReadOnly', { timeout: 20000 }); }); it(`can view existing Dashboard`, async () => { diff --git a/x-pack/test/functional/apps/dashboard/feature_controls/time_to_visualize_security.ts b/x-pack/test/functional/apps/dashboard/feature_controls/time_to_visualize_security.ts index 2c151235518e0..730c00a8d5e4f 100644 --- a/x-pack/test/functional/apps/dashboard/feature_controls/time_to_visualize_security.ts +++ b/x-pack/test/functional/apps/dashboard/feature_controls/time_to_visualize_security.ts @@ -29,8 +29,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const security = getService('security'); const find = getService('find'); - // flaky https://github.com/elastic/kibana/issues/98249 - describe.skip('dashboard time to visualize security', () => { + describe('dashboard time to visualize security', () => { before(async () => { await esArchiver.load('dashboard/feature_controls/security'); await esArchiver.loadIfNeeded('logstash_functional'); From 0312839e34ba3e8519abd1fab7d7f94cfef98ba1 Mon Sep 17 00:00:00 2001 From: Candace Park <56409205+parkiino@users.noreply.github.com> Date: Thu, 3 Jun 2021 12:27:06 -0400 Subject: [PATCH 30/90] [Security Solution][Endpoint][Host Isolation] Unisolate host minor refactors (#100889) --- .../common/endpoint/constants.ts | 25 ++++++----- .../host_isolation/isolate_success.tsx | 35 +++++++-------- .../utils/resolve_path_variables.test.ts | 45 +++++++++++++++++++ .../common/utils/resolve_path_variables.ts | 11 +++++ .../components/host_isolation/index.tsx | 8 ---- .../components/host_isolation/isolate.tsx | 13 +----- .../components/host_isolation/translations.ts | 3 +- .../components/host_isolation/unisolate.tsx | 13 +----- .../containers/detection_engine/alerts/api.ts | 6 +-- .../public/management/common/utils.test.ts | 37 +-------------- .../public/management/common/utils.ts | 5 --- .../pages/endpoint_hosts/store/middleware.ts | 14 +++--- .../pages/trusted_apps/service/index.ts | 2 +- .../view/trusted_apps_page.test.tsx | 2 +- .../server/endpoint/routes/metadata/index.ts | 12 ++--- .../endpoint/routes/metadata/metadata.test.ts | 27 ++++++----- .../apis/metadata.ts | 30 ++++++------- 17 files changed, 141 insertions(+), 147 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/common/utils/resolve_path_variables.test.ts create mode 100644 x-pack/plugins/security_solution/public/common/utils/resolve_path_variables.ts diff --git a/x-pack/plugins/security_solution/common/endpoint/constants.ts b/x-pack/plugins/security_solution/common/endpoint/constants.ts index c85778f2f38fa..cdfc34c2e9cda 100644 --- a/x-pack/plugins/security_solution/common/endpoint/constants.ts +++ b/x-pack/plugins/security_solution/common/endpoint/constants.ts @@ -15,23 +15,24 @@ export const telemetryIndexPattern = 'metrics-endpoint.telemetry-*'; export const LIMITED_CONCURRENCY_ENDPOINT_ROUTE_TAG = 'endpoint:limited-concurrency'; export const LIMITED_CONCURRENCY_ENDPOINT_COUNT = 100; -export const HOST_METADATA_LIST_API = '/api/endpoint/metadata'; -export const HOST_METADATA_GET_API = '/api/endpoint/metadata/{id}'; +export const BASE_ENDPOINT_ROUTE = '/api/endpoint'; +export const HOST_METADATA_LIST_ROUTE = `${BASE_ENDPOINT_ROUTE}/metadata`; +export const HOST_METADATA_GET_ROUTE = `${BASE_ENDPOINT_ROUTE}/metadata/{id}`; -export const TRUSTED_APPS_GET_API = '/api/endpoint/trusted_apps/{id}'; -export const TRUSTED_APPS_LIST_API = '/api/endpoint/trusted_apps'; -export const TRUSTED_APPS_CREATE_API = '/api/endpoint/trusted_apps'; -export const TRUSTED_APPS_UPDATE_API = '/api/endpoint/trusted_apps/{id}'; -export const TRUSTED_APPS_DELETE_API = '/api/endpoint/trusted_apps/{id}'; -export const TRUSTED_APPS_SUMMARY_API = '/api/endpoint/trusted_apps/summary'; +export const TRUSTED_APPS_GET_API = `${BASE_ENDPOINT_ROUTE}/trusted_apps/{id}`; +export const TRUSTED_APPS_LIST_API = `${BASE_ENDPOINT_ROUTE}/trusted_apps`; +export const TRUSTED_APPS_CREATE_API = `${BASE_ENDPOINT_ROUTE}/trusted_apps`; +export const TRUSTED_APPS_UPDATE_API = `${BASE_ENDPOINT_ROUTE}/trusted_apps/{id}`; +export const TRUSTED_APPS_DELETE_API = `${BASE_ENDPOINT_ROUTE}/trusted_apps/{id}`; +export const TRUSTED_APPS_SUMMARY_API = `${BASE_ENDPOINT_ROUTE}/trusted_apps/summary`; -export const BASE_POLICY_RESPONSE_ROUTE = `/api/endpoint/policy_response`; -export const BASE_POLICY_ROUTE = `/api/endpoint/policy`; +export const BASE_POLICY_RESPONSE_ROUTE = `${BASE_ENDPOINT_ROUTE}/policy_response`; +export const BASE_POLICY_ROUTE = `${BASE_ENDPOINT_ROUTE}/policy`; export const AGENT_POLICY_SUMMARY_ROUTE = `${BASE_POLICY_ROUTE}/summaries`; /** Host Isolation Routes */ -export const ISOLATE_HOST_ROUTE = `/api/endpoint/isolate`; -export const UNISOLATE_HOST_ROUTE = `/api/endpoint/unisolate`; +export const ISOLATE_HOST_ROUTE = `${BASE_ENDPOINT_ROUTE}/isolate`; +export const UNISOLATE_HOST_ROUTE = `${BASE_ENDPOINT_ROUTE}/unisolate`; /** Endpoint Actions Log Routes */ export const ENDPOINT_ACTION_LOG_ROUTE = `/api/endpoint/action_log/{agent_id}`; diff --git a/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/isolate_success.tsx b/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/isolate_success.tsx index f822b3c287a02..3459da068b282 100644 --- a/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/isolate_success.tsx +++ b/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/isolate_success.tsx @@ -27,25 +27,22 @@ export const EndpointIsolateSuccess = memo( }) => { return ( <> - {isolateAction === 'isolateHost' ? ( - - {additionalInfo} - - ) : ( - - {additionalInfo} - - )} - + + {additionalInfo} + { + describe('resolvePathVariables', () => { + it('should resolve defined variables', () => { + expect(resolvePathVariables('/segment1/{var1}/segment2', { var1: 'value1' })).toBe( + '/segment1/value1/segment2' + ); + }); + + it('should not resolve undefined variables', () => { + expect(resolvePathVariables('/segment1/{var1}/segment2', {})).toBe( + '/segment1/{var1}/segment2' + ); + }); + + it('should ignore unused variables', () => { + expect(resolvePathVariables('/segment1/{var1}/segment2', { var2: 'value2' })).toBe( + '/segment1/{var1}/segment2' + ); + }); + + it('should replace multiple variable occurences', () => { + expect(resolvePathVariables('/{var1}/segment1/{var1}', { var1: 'value1' })).toBe( + '/value1/segment1/value1' + ); + }); + + it('should replace multiple variables', () => { + const path = resolvePathVariables('/{var1}/segment1/{var2}', { + var1: 'value1', + var2: 'value2', + }); + + expect(path).toBe('/value1/segment1/value2'); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/utils/resolve_path_variables.ts b/x-pack/plugins/security_solution/public/common/utils/resolve_path_variables.ts new file mode 100644 index 0000000000000..89067e575665d --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/utils/resolve_path_variables.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const resolvePathVariables = (path: string, variables: { [K: string]: string | number }) => + Object.keys(variables).reduce((acc, paramName) => { + return acc.replace(new RegExp(`\{${paramName}\}`, 'g'), String(variables[paramName])); + }, path); diff --git a/x-pack/plugins/security_solution/public/detections/components/host_isolation/index.tsx b/x-pack/plugins/security_solution/public/detections/components/host_isolation/index.tsx index bb1585b5392bd..2ca8416841497 100644 --- a/x-pack/plugins/security_solution/public/detections/components/host_isolation/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/host_isolation/index.tsx @@ -36,12 +36,6 @@ export const HostIsolationPanel = React.memo( return findHostName ? findHostName[0] : ''; }, [details]); - const alertRule = useMemo(() => { - const findAlertRule = find({ category: 'signal', field: 'signal.rule.name' }, details) - ?.values; - return findAlertRule ? findAlertRule[0] : ''; - }, [details]); - const alertId = useMemo(() => { const findAlertId = find({ category: '_id', field: '_id' }, details)?.values; return findAlertId ? findAlertId[0] : ''; @@ -95,7 +89,6 @@ export const HostIsolationPanel = React.memo( void; @@ -80,15 +78,9 @@ export const IsolateHost = React.memo( messageAppend={ - {caseCount} - {CASES_ASSOCIATED_WITH_ALERT(caseCount)} - {alertRule} - - ), + cases: {CASES_ASSOCIATED_WITH_ALERT(caseCount)}, }} /> } @@ -103,7 +95,6 @@ export const IsolateHost = React.memo( comment, loading, caseCount, - alertRule, ]); return isIsolated ? hostIsolatedSuccess : hostNotIsolated; diff --git a/x-pack/plugins/security_solution/public/detections/components/host_isolation/translations.ts b/x-pack/plugins/security_solution/public/detections/components/host_isolation/translations.ts index 449a09b932cd3..98b74817cabb6 100644 --- a/x-pack/plugins/security_solution/public/detections/components/host_isolation/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/components/host_isolation/translations.ts @@ -25,7 +25,8 @@ export const CASES_ASSOCIATED_WITH_ALERT = (caseCount: number): string => i18n.translate( 'xpack.securitySolution.endpoint.hostIsolation.isolateHost.casesAssociatedWithAlert', { - defaultMessage: ' {caseCount, plural, one {case} other {cases}} associated with the rule ', + defaultMessage: + '{caseCount} {caseCount, plural, one {case} other {cases}} associated with this host', values: { caseCount }, } ); diff --git a/x-pack/plugins/security_solution/public/detections/components/host_isolation/unisolate.tsx b/x-pack/plugins/security_solution/public/detections/components/host_isolation/unisolate.tsx index 74149f2a692d3..e72a0d2de61bc 100644 --- a/x-pack/plugins/security_solution/public/detections/components/host_isolation/unisolate.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/host_isolation/unisolate.tsx @@ -20,14 +20,12 @@ export const UnisolateHost = React.memo( ({ agentId, hostName, - alertRule, cases, caseIds, cancelCallback, }: { agentId: string; hostName: string; - alertRule: string; cases: ReactNode; caseIds: string[]; cancelCallback: () => void; @@ -80,15 +78,9 @@ export const UnisolateHost = React.memo( messageAppend={ - {caseCount} - {CASES_ASSOCIATED_WITH_ALERT(caseCount)} - {alertRule} - - ), + cases: {CASES_ASSOCIATED_WITH_ALERT(caseCount)}, }} /> } @@ -103,7 +95,6 @@ export const UnisolateHost = React.memo( comment, loading, caseCount, - alertRule, ]); return isUnIsolated ? hostUnisolatedSuccess : hostNotUnisolated; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.ts index a7bd42c6af5ee..cd596ef76ce0a 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.ts @@ -14,7 +14,7 @@ import { DETECTION_ENGINE_INDEX_URL, DETECTION_ENGINE_PRIVILEGES_URL, } from '../../../../../common/constants'; -import { HOST_METADATA_GET_API } from '../../../../../common/endpoint/constants'; +import { HOST_METADATA_GET_ROUTE } from '../../../../../common/endpoint/constants'; import { KibanaServices } from '../../../../common/lib/kibana'; import { BasicSignals, @@ -25,8 +25,8 @@ import { UpdateAlertStatusProps, CasesFromAlertsResponse, } from './types'; -import { resolvePathVariables } from '../../../../management/common/utils'; import { isolateHost, unIsolateHost } from '../../../../common/lib/host_isolation'; +import { resolvePathVariables } from '../../../../common/utils/resolve_path_variables'; /** * Fetch Alerts by providing a query @@ -181,6 +181,6 @@ export const getHostMetadata = async ({ agentId: string; }): Promise => KibanaServices.get().http.fetch( - resolvePathVariables(HOST_METADATA_GET_API, { id: agentId }), + resolvePathVariables(HOST_METADATA_GET_ROUTE, { id: agentId }), { method: 'get' } ); diff --git a/x-pack/plugins/security_solution/public/management/common/utils.test.ts b/x-pack/plugins/security_solution/public/management/common/utils.test.ts index 8918261b6a436..59455ccd6bb04 100644 --- a/x-pack/plugins/security_solution/public/management/common/utils.test.ts +++ b/x-pack/plugins/security_solution/public/management/common/utils.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { parseQueryFilterToKQL, resolvePathVariables } from './utils'; +import { parseQueryFilterToKQL } from './utils'; describe('utils', () => { const searchableFields = [`name`, `description`, `entries.value`, `entries.entries.value`]; @@ -39,39 +39,4 @@ describe('utils', () => { ); }); }); - - describe('resolvePathVariables', () => { - it('should resolve defined variables', () => { - expect(resolvePathVariables('/segment1/{var1}/segment2', { var1: 'value1' })).toBe( - '/segment1/value1/segment2' - ); - }); - - it('should not resolve undefined variables', () => { - expect(resolvePathVariables('/segment1/{var1}/segment2', {})).toBe( - '/segment1/{var1}/segment2' - ); - }); - - it('should ignore unused variables', () => { - expect(resolvePathVariables('/segment1/{var1}/segment2', { var2: 'value2' })).toBe( - '/segment1/{var1}/segment2' - ); - }); - - it('should replace multiple variable occurences', () => { - expect(resolvePathVariables('/{var1}/segment1/{var1}', { var1: 'value1' })).toBe( - '/value1/segment1/value1' - ); - }); - - it('should replace multiple variables', () => { - const path = resolvePathVariables('/{var1}/segment1/{var2}', { - var1: 'value1', - var2: 'value2', - }); - - expect(path).toBe('/value1/segment1/value2'); - }); - }); }); diff --git a/x-pack/plugins/security_solution/public/management/common/utils.ts b/x-pack/plugins/security_solution/public/management/common/utils.ts index 78a95eb4d6f81..c8cf761ccaf86 100644 --- a/x-pack/plugins/security_solution/public/management/common/utils.ts +++ b/x-pack/plugins/security_solution/public/management/common/utils.ts @@ -19,8 +19,3 @@ export const parseQueryFilterToKQL = (filter: string, fields: Readonly return kuery; }; - -export const resolvePathVariables = (path: string, variables: { [K: string]: string | number }) => - Object.keys(variables).reduce((acc, paramName) => { - return acc.replace(new RegExp(`\{${paramName}\}`, 'g'), String(variables[paramName])); - }, path); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts index 90427d5003384..911a902bd2029 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts @@ -41,12 +41,11 @@ import { import { AGENT_POLICY_SAVED_OBJECT_TYPE } from '../../../../../../fleet/common'; import { ENDPOINT_ACTION_LOG_ROUTE, - HOST_METADATA_GET_API, - HOST_METADATA_LIST_API, + HOST_METADATA_GET_ROUTE, + HOST_METADATA_LIST_ROUTE, metadataCurrentIndexPattern, } from '../../../../../common/endpoint/constants'; import { IIndexPattern, Query } from '../../../../../../../../src/plugins/data/public'; -import { resolvePathVariables } from '../../../common/utils'; import { createFailedResourceState, createLoadedResourceState, @@ -54,6 +53,7 @@ import { } from '../../../state'; import { isolateHost } from '../../../../common/lib/host_isolation'; import { AppAction } from '../../../../common/store/actions'; +import { resolvePathVariables } from '../../../../common/utils/resolve_path_variables'; type EndpointPageStore = ImmutableMiddlewareAPI; @@ -104,7 +104,7 @@ export const endpointMiddlewareFactory: ImmutableMiddlewareFactory(HOST_METADATA_LIST_API, { + endpointResponse = await coreStart.http.post(HOST_METADATA_LIST_ROUTE, { body: JSON.stringify({ paging_properties: [{ page_index: pageIndex }, { page_size: pageSize }], filters: { kql: decodedQuery.query }, @@ -253,7 +253,7 @@ export const endpointMiddlewareFactory: ImmutableMiddlewareFactory( - resolvePathVariables(HOST_METADATA_GET_API, { id: selectedEndpoint as string }) + resolvePathVariables(HOST_METADATA_GET_ROUTE, { id: selectedEndpoint as string }) ); dispatch({ type: 'serverReturnedEndpointDetails', @@ -458,7 +458,7 @@ const getAgentAndPoliciesForEndpointsList = async ( const endpointsTotal = async (http: HttpStart): Promise => { try { return ( - await http.post(HOST_METADATA_LIST_API, { + await http.post(HOST_METADATA_LIST_ROUTE, { body: JSON.stringify({ paging_properties: [{ page_index: 0 }, { page_size: 1 }], }), diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/index.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/index.ts index 01bccc81b5063..9d39ecd05ad8a 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/index.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/index.ts @@ -29,8 +29,8 @@ import { GetOneTrustedAppRequestParams, GetOneTrustedAppResponse, } from '../../../../../common/endpoint/types/trusted_apps'; +import { resolvePathVariables } from '../../../../common/utils/resolve_path_variables'; -import { resolvePathVariables } from '../../../common/utils'; import { sendGetEndpointSpecificPackagePolicies } from '../../policy/store/services/ingest'; export interface TrustedAppsService { diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx index dc0032243312f..fac9fb1e5bf6e 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx @@ -31,9 +31,9 @@ import { import { EndpointDocGenerator } from '../../../../../common/endpoint/generate_data'; import { isFailedResourceState, isLoadedResourceState } from '../state'; import { forceHTMLElementOffsetWidth } from './components/effected_policy_select/test_utils'; -import { resolvePathVariables } from '../../../common/utils'; import { toUpdateTrustedApp } from '../../../../../common/endpoint/service/trusted_apps/to_update_trusted_app'; import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; +import { resolvePathVariables } from '../../../../common/utils/resolve_path_variables'; jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({ htmlIdGenerator: () => () => 'mockId', diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/index.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/index.ts index 44db86f85cf5f..b4784c1ff5ed4 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/index.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/index.ts @@ -11,12 +11,14 @@ import { HostStatus, MetadataQueryStrategyVersions } from '../../../../common/en import { EndpointAppContext } from '../../types'; import { getLogger, getMetadataListRequestHandler, getMetadataRequestHandler } from './handlers'; import type { SecuritySolutionPluginRouter } from '../../../types'; +import { + BASE_ENDPOINT_ROUTE, + HOST_METADATA_GET_ROUTE, + HOST_METADATA_LIST_ROUTE, +} from '../../../../common/endpoint/constants'; -export const BASE_ENDPOINT_ROUTE = '/api/endpoint'; export const METADATA_REQUEST_V1_ROUTE = `${BASE_ENDPOINT_ROUTE}/v1/metadata`; export const GET_METADATA_REQUEST_V1_ROUTE = `${METADATA_REQUEST_V1_ROUTE}/{id}`; -export const METADATA_REQUEST_ROUTE = `${BASE_ENDPOINT_ROUTE}/metadata`; -export const GET_METADATA_REQUEST_ROUTE = `${METADATA_REQUEST_ROUTE}/{id}`; /* Filters that can be applied to the endpoint fetch route */ export const endpointFilters = schema.object({ @@ -82,7 +84,7 @@ export function registerEndpointRoutes( router.post( { - path: `${METADATA_REQUEST_ROUTE}`, + path: `${HOST_METADATA_LIST_ROUTE}`, validate: GetMetadataListRequestSchema, options: { authRequired: true, tags: ['access:securitySolution'] }, }, @@ -100,7 +102,7 @@ export function registerEndpointRoutes( router.get( { - path: `${GET_METADATA_REQUEST_ROUTE}`, + path: `${HOST_METADATA_GET_ROUTE}`, validate: GetMetadataRequestSchema, options: { authRequired: true, tags: ['access:securitySolution'] }, }, diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata.test.ts index b916ec19da17f..e6d6879ba1845 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata.test.ts @@ -26,7 +26,7 @@ import { MetadataQueryStrategyVersions, } from '../../../../common/endpoint/types'; import { parseExperimentalConfigValue } from '../../../../common/experimental_features'; -import { registerEndpointRoutes, METADATA_REQUEST_ROUTE } from './index'; +import { registerEndpointRoutes } from './index'; import { createMockEndpointAppContextServiceStartContract, createMockPackageService, @@ -45,7 +45,10 @@ import { } from '../../../../../fleet/common/types/models'; import { createV1SearchResponse, createV2SearchResponse } from './support/test_support'; import { PackageService } from '../../../../../fleet/server/services'; -import { metadataTransformPrefix } from '../../../../common/endpoint/constants'; +import { + HOST_METADATA_LIST_ROUTE, + metadataTransformPrefix, +} from '../../../../common/endpoint/constants'; import type { SecuritySolutionPluginRouter } from '../../../types'; import { PackagePolicyServiceInterface } from '../../../../../fleet/server'; import { @@ -126,7 +129,7 @@ describe('test endpoint route', () => { Promise.resolve({ body: response }) ); [routeConfig, routeHandler] = routerMock.post.mock.calls.find(([{ path }]) => - path.startsWith(`${METADATA_REQUEST_ROUTE}`) + path.startsWith(`${HOST_METADATA_LIST_ROUTE}`) )!; mockAgentService.getAgentStatusById = jest.fn().mockReturnValue('error'); mockAgentService.listAgents = jest.fn().mockReturnValue(noUnenrolledAgent); @@ -167,7 +170,7 @@ describe('test endpoint route', () => { ); [routeConfig, routeHandler] = routerMock.get.mock.calls.find(([{ path }]) => - path.startsWith(`${METADATA_REQUEST_ROUTE}`) + path.startsWith(`${HOST_METADATA_LIST_ROUTE}`) )!; await routeHandler( @@ -225,7 +228,7 @@ describe('test endpoint route', () => { Promise.resolve({ body: response }) ); [routeConfig, routeHandler] = routerMock.post.mock.calls.find(([{ path }]) => - path.startsWith(`${METADATA_REQUEST_ROUTE}`) + path.startsWith(`${HOST_METADATA_LIST_ROUTE}`) )!; mockAgentService.getAgentStatusById = jest.fn().mockReturnValue('error'); mockAgentService.listAgents = jest.fn().mockReturnValue(noUnenrolledAgent); @@ -273,7 +276,7 @@ describe('test endpoint route', () => { }) ); [routeConfig, routeHandler] = routerMock.post.mock.calls.find(([{ path }]) => - path.startsWith(`${METADATA_REQUEST_ROUTE}`) + path.startsWith(`${HOST_METADATA_LIST_ROUTE}`) )!; await routeHandler( @@ -332,7 +335,7 @@ describe('test endpoint route', () => { }) ); [routeConfig, routeHandler] = routerMock.post.mock.calls.find(([{ path }]) => - path.startsWith(`${METADATA_REQUEST_ROUTE}`) + path.startsWith(`${HOST_METADATA_LIST_ROUTE}`) )!; await routeHandler( @@ -416,7 +419,7 @@ describe('test endpoint route', () => { } as unknown) as Agent); [routeConfig, routeHandler] = routerMock.get.mock.calls.find(([{ path }]) => - path.startsWith(`${METADATA_REQUEST_ROUTE}`) + path.startsWith(`${HOST_METADATA_LIST_ROUTE}`) )!; await routeHandler( createRouteHandlerContext(mockScopedClient, mockSavedObjectClient), @@ -449,7 +452,7 @@ describe('test endpoint route', () => { ); [routeConfig, routeHandler] = routerMock.get.mock.calls.find(([{ path }]) => - path.startsWith(`${METADATA_REQUEST_ROUTE}`) + path.startsWith(`${HOST_METADATA_LIST_ROUTE}`) )!; await routeHandler( @@ -490,7 +493,7 @@ describe('test endpoint route', () => { ); [routeConfig, routeHandler] = routerMock.get.mock.calls.find(([{ path }]) => - path.startsWith(`${METADATA_REQUEST_ROUTE}`) + path.startsWith(`${HOST_METADATA_LIST_ROUTE}`) )!; await routeHandler( @@ -525,7 +528,7 @@ describe('test endpoint route', () => { ); [routeConfig, routeHandler] = routerMock.get.mock.calls.find(([{ path }]) => - path.startsWith(`${METADATA_REQUEST_ROUTE}`) + path.startsWith(`${HOST_METADATA_LIST_ROUTE}`) )!; await routeHandler( @@ -558,7 +561,7 @@ describe('test endpoint route', () => { } as unknown) as Agent); [routeConfig, routeHandler] = routerMock.get.mock.calls.find(([{ path }]) => - path.startsWith(`${METADATA_REQUEST_ROUTE}`) + path.startsWith(`${HOST_METADATA_LIST_ROUTE}`) )!; await routeHandler( diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts b/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts index 8dd5adba43edb..13a19e55ab588 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts @@ -12,8 +12,8 @@ import { deleteAllDocsFromMetadataIndex, deleteMetadataStream, } from './data_stream_helper'; -import { METADATA_REQUEST_ROUTE } from '../../../plugins/security_solution/server/endpoint/routes/metadata'; import { MetadataQueryStrategyVersions } from '../../../plugins/security_solution/common/endpoint/types'; +import { HOST_METADATA_LIST_ROUTE } from '../../../plugins/security_solution/common/endpoint/constants'; /** * The number of host documents in the es archive. @@ -25,13 +25,13 @@ export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); describe('test metadata api', () => { - describe(`POST ${METADATA_REQUEST_ROUTE} when index is empty`, () => { + describe(`POST ${HOST_METADATA_LIST_ROUTE} when index is empty`, () => { it('metadata api should return empty result when index is empty', async () => { await deleteMetadataStream(getService); await deleteAllDocsFromMetadataIndex(getService); await deleteAllDocsFromMetadataCurrentIndex(getService); const { body } = await supertest - .post(`${METADATA_REQUEST_ROUTE}`) + .post(`${HOST_METADATA_LIST_ROUTE}`) .set('kbn-xsrf', 'xxx') .send() .expect(200); @@ -42,7 +42,7 @@ export default function ({ getService }: FtrProviderContext) { }); }); - describe(`POST ${METADATA_REQUEST_ROUTE} when index is not empty`, () => { + describe(`POST ${HOST_METADATA_LIST_ROUTE} when index is not empty`, () => { before(async () => { await esArchiver.load('endpoint/metadata/api_feature', { useCreate: true }); // wait for transform @@ -57,7 +57,7 @@ export default function ({ getService }: FtrProviderContext) { }); it('metadata api should return one entry for each host with default paging', async () => { const { body } = await supertest - .post(`${METADATA_REQUEST_ROUTE}`) + .post(`${HOST_METADATA_LIST_ROUTE}`) .set('kbn-xsrf', 'xxx') .send() .expect(200); @@ -69,7 +69,7 @@ export default function ({ getService }: FtrProviderContext) { it('metadata api should return page based on paging properties passed.', async () => { const { body } = await supertest - .post(`${METADATA_REQUEST_ROUTE}`) + .post(`${HOST_METADATA_LIST_ROUTE}`) .set('kbn-xsrf', 'xxx') .send({ paging_properties: [ @@ -94,7 +94,7 @@ export default function ({ getService }: FtrProviderContext) { */ it('metadata api should return accurate total metadata if page index produces no result', async () => { const { body } = await supertest - .post(`${METADATA_REQUEST_ROUTE}`) + .post(`${HOST_METADATA_LIST_ROUTE}`) .set('kbn-xsrf', 'xxx') .send({ paging_properties: [ @@ -116,7 +116,7 @@ export default function ({ getService }: FtrProviderContext) { it('metadata api should return 400 when pagingProperties is below boundaries.', async () => { const { body } = await supertest - .post(`${METADATA_REQUEST_ROUTE}`) + .post(`${HOST_METADATA_LIST_ROUTE}`) .set('kbn-xsrf', 'xxx') .send({ paging_properties: [ @@ -134,7 +134,7 @@ export default function ({ getService }: FtrProviderContext) { it('metadata api should return page based on filters passed.', async () => { const { body } = await supertest - .post(`${METADATA_REQUEST_ROUTE}`) + .post(`${HOST_METADATA_LIST_ROUTE}`) .set('kbn-xsrf', 'xxx') .send({ filters: { @@ -152,7 +152,7 @@ export default function ({ getService }: FtrProviderContext) { it('metadata api should return page based on filters and paging passed.', async () => { const notIncludedIp = '10.46.229.234'; const { body } = await supertest - .post(`${METADATA_REQUEST_ROUTE}`) + .post(`${HOST_METADATA_LIST_ROUTE}`) .set('kbn-xsrf', 'xxx') .send({ paging_properties: [ @@ -190,7 +190,7 @@ export default function ({ getService }: FtrProviderContext) { it('metadata api should return page based on host.os.Ext.variant filter.', async () => { const variantValue = 'Windows Pro'; const { body } = await supertest - .post(`${METADATA_REQUEST_ROUTE}`) + .post(`${HOST_METADATA_LIST_ROUTE}`) .set('kbn-xsrf', 'xxx') .send({ filters: { @@ -212,7 +212,7 @@ export default function ({ getService }: FtrProviderContext) { it('metadata api should return the latest event for all the events for an endpoint', async () => { const targetEndpointIp = '10.46.229.234'; const { body } = await supertest - .post(`${METADATA_REQUEST_ROUTE}`) + .post(`${HOST_METADATA_LIST_ROUTE}`) .set('kbn-xsrf', 'xxx') .send({ filters: { @@ -234,7 +234,7 @@ export default function ({ getService }: FtrProviderContext) { it('metadata api should return the latest event for all the events where policy status is not success', async () => { const { body } = await supertest - .post(`${METADATA_REQUEST_ROUTE}`) + .post(`${HOST_METADATA_LIST_ROUTE}`) .set('kbn-xsrf', 'xxx') .send({ filters: { @@ -255,7 +255,7 @@ export default function ({ getService }: FtrProviderContext) { const targetEndpointId = 'fc0ff548-feba-41b6-8367-65e8790d0eaf'; const targetElasticAgentId = '023fa40c-411d-4188-a941-4147bfadd095'; const { body } = await supertest - .post(`${METADATA_REQUEST_ROUTE}`) + .post(`${HOST_METADATA_LIST_ROUTE}`) .set('kbn-xsrf', 'xxx') .send({ filters: { @@ -278,7 +278,7 @@ export default function ({ getService }: FtrProviderContext) { it('metadata api should return all hosts when filter is empty string', async () => { const { body } = await supertest - .post(`${METADATA_REQUEST_ROUTE}`) + .post(`${HOST_METADATA_LIST_ROUTE}`) .set('kbn-xsrf', 'xxx') .send({ filters: { From 3b1e8b03f162244c88452e5da4df00eb73741f4a Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Thu, 3 Jun 2021 12:27:29 -0400 Subject: [PATCH 31/90] [Fleet] Install final pipeline (#100973) --- .../ingest_pipeline/final_pipeline.ts | 107 ++++++++++ .../elasticsearch/ingest_pipeline/install.ts | 23 +++ .../__snapshots__/template.test.ts.snap | 9 +- .../epm/elasticsearch/template/template.ts | 6 + x-pack/plugins/fleet/server/services/setup.ts | 3 + .../apis/epm/final_pipeline.ts | 187 ++++++++++++++++++ .../fleet_api_integration/apis/epm/index.js | 1 + 7 files changed, 333 insertions(+), 3 deletions(-) create mode 100644 x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/final_pipeline.ts create mode 100644 x-pack/test/fleet_api_integration/apis/epm/final_pipeline.ts diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/final_pipeline.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/final_pipeline.ts new file mode 100644 index 0000000000000..4c0484c058abf --- /dev/null +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/final_pipeline.ts @@ -0,0 +1,107 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const FINAL_PIPELINE_ID = '.fleet_final_pipeline'; + +export const FINAL_PIPELINE = `--- +description: > + Final pipeline for processing all incoming Fleet Agent documents. +processors: + - set: + description: Add time when event was ingested. + field: event.ingested + value: '{{{_ingest.timestamp}}}' + - remove: + description: Remove any pre-existing untrusted values. + field: + - event.agent_id_status + - _security + ignore_missing: true + - set_security_user: + field: _security + properties: + - authentication_type + - username + - realm + - api_key + - script: + description: > + Add event.agent_id_status based on the API key metadata and the + agent.id contained in the event. + tag: agent-id-status + source: |- + boolean is_user_trusted(def ctx, def users) { + if (ctx?._security?.username == null) { + return false; + } + + def user = null; + for (def item : users) { + if (item?.username == ctx._security.username) { + user = item; + break; + } + } + + if (user == null || user?.realm == null || ctx?._security?.realm?.name == null) { + return false; + } + + if (ctx._security.realm.name != user.realm) { + return false; + } + + return true; + } + + String verified(def ctx, def params) { + // Agents only use API keys. + if (ctx?._security?.authentication_type == null || ctx._security.authentication_type != 'API_KEY') { + return "no_api_key"; + } + + // Verify the API key owner before trusting any metadata it contains. + if (!is_user_trusted(ctx, params.trusted_users)) { + return "untrusted_user"; + } + + // API keys created by Fleet include metadata about the agent they were issued to. + if (ctx?._security?.api_key?.metadata?.agent_id == null || ctx?.agent?.id == null) { + return "missing_metadata"; + } + + // The API key can only be used represent the agent.id it was issued to. + if (ctx._security.api_key.metadata.agent_id != ctx.agent.id) { + // Potential masquerade attempt. + return "agent_id_mismatch"; + } + + return "verified"; + } + + if (ctx?.event == null) { + ctx.event = [:]; + } + + ctx.event.agent_id_status = verified(ctx, params); + params: + # List of users responsible for creating Fleet output API keys. + trusted_users: + - username: elastic + realm: reserved + - remove: + field: _security + ignore_missing: true +on_failure: + - remove: + field: _security + ignore_missing: true + ignore_failure: true + - append: + field: error.message + value: + - 'failed in Fleet agent final_pipeline: {{ _ingest.on_failure_message }}'`; diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/install.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/install.ts index ac5aca7ab1c14..1d212f188120f 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/install.ts @@ -16,6 +16,7 @@ import { saveInstalledEsRefs } from '../../packages/install'; import { getInstallationObject } from '../../packages'; import { deletePipelineRefs } from './remove'; +import { FINAL_PIPELINE, FINAL_PIPELINE_ID } from './final_pipeline'; interface RewriteSubstitution { source: string; @@ -185,6 +186,28 @@ async function installPipeline({ return { id: pipeline.nameForInstallation, type: ElasticsearchAssetType.ingestPipeline }; } +export async function ensureFleetFinalPipelineIsInstalled(esClient: ElasticsearchClient) { + const esClientRequestOptions: TransportRequestOptions = { + ignore: [404], + }; + const res = await esClient.ingest.getPipeline({ id: FINAL_PIPELINE_ID }, esClientRequestOptions); + + if (res.statusCode === 404) { + await esClient.ingest.putPipeline( + // @ts-ignore pipeline is define in yaml + { id: FINAL_PIPELINE_ID, body: FINAL_PIPELINE }, + { + headers: { + // pipeline is YAML + 'Content-Type': 'application/yaml', + // but we want JSON responses (to extract error messages, status code, or other metadata) + Accept: 'application/json', + }, + } + ); + } +} + const isDirectory = ({ path }: ArchiveEntry) => path.endsWith('/'); const isDataStreamPipeline = (path: string, dataStreamDataset: string) => { diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/__snapshots__/template.test.ts.snap b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/__snapshots__/template.test.ts.snap index 65eec939d5850..acf8ae742bf8f 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/__snapshots__/template.test.ts.snap +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/__snapshots__/template.test.ts.snap @@ -25,7 +25,8 @@ exports[`EPM template tests loading base.yml: base.yml 1`] = ` "default_field": [ "long.nested.foo" ] - } + }, + "final_pipeline": ".fleet_final_pipeline" } }, "mappings": { @@ -139,7 +140,8 @@ exports[`EPM template tests loading coredns.logs.yml: coredns.logs.yml 1`] = ` "coredns.response.code", "coredns.response.flags" ] - } + }, + "final_pipeline": ".fleet_final_pipeline" } }, "mappings": { @@ -281,7 +283,8 @@ exports[`EPM template tests loading system.yml: system.yml 1`] = ` "system.users.scope", "system.users.remote_host" ] - } + }, + "final_pipeline": ".fleet_final_pipeline" } }, "mappings": { diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts index 64261226a7944..5dd2755390ecb 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts @@ -16,6 +16,7 @@ import type { } from '../../../../types'; import { appContextService } from '../../../'; import { getRegistryDataStreamAssetBaseName } from '../index'; +import { FINAL_PIPELINE_ID } from '../ingest_pipeline/final_pipeline'; interface Properties { [key: string]: any; @@ -86,6 +87,11 @@ export function getTemplate({ if (pipelineName) { template.template.settings.index.default_pipeline = pipelineName; } + if (template.template.settings.index.final_pipeline) { + throw new Error(`Error template for ${templateIndexPattern} contains a final_pipeline`); + } + template.template.settings.index.final_pipeline = FINAL_PIPELINE_ID; + return template; } diff --git a/x-pack/plugins/fleet/server/services/setup.ts b/x-pack/plugins/fleet/server/services/setup.ts index 28deec8a89028..7f4219799e511 100644 --- a/x-pack/plugins/fleet/server/services/setup.ts +++ b/x-pack/plugins/fleet/server/services/setup.ts @@ -20,6 +20,7 @@ import { settingsService } from '.'; import { awaitIfPending } from './setup_utils'; import { ensureAgentActionPolicyChangeExists } from './agents'; import { awaitIfFleetServerSetupPending } from './fleet_server'; +import { ensureFleetFinalPipelineIsInstalled } from './epm/elasticsearch/ingest_pipeline/install'; export interface SetupStatus { isInitialized: boolean; @@ -42,6 +43,8 @@ async function createSetupSideEffects( settingsService.settingsSetup(soClient), ]); + await ensureFleetFinalPipelineIsInstalled(esClient); + await awaitIfFleetServerSetupPending(); const { agentPolicies: policiesOrUndefined, packages: packagesOrUndefined } = diff --git a/x-pack/test/fleet_api_integration/apis/epm/final_pipeline.ts b/x-pack/test/fleet_api_integration/apis/epm/final_pipeline.ts new file mode 100644 index 0000000000000..1ab7b00da5d76 --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/epm/final_pipeline.ts @@ -0,0 +1,187 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../api_integration/ftr_provider_context'; +import { setupFleetAndAgents } from '../agents/services'; +import { skipIfNoDockerRegistry } from '../../helpers'; + +const TEST_INDEX = 'logs-log.log-test'; + +const FINAL_PIPELINE_ID = '.fleet_final_pipeline'; + +let pkgKey: string; + +export default function (providerContext: FtrProviderContext) { + const { getService } = providerContext; + const supertest = getService('supertest'); + const es = getService('es'); + const esArchiver = getService('esArchiver'); + + function indexUsingApiKey(body: any, apiKey: string): Promise<{ body: Record }> { + const supertestWithoutAuth = getService('esSupertestWithoutAuth'); + return supertestWithoutAuth + .post(`/${TEST_INDEX}/_doc`) + .set('Authorization', `ApiKey ${apiKey}`) + .send(body) + .expect(201); + } + + describe('fleet_final_pipeline', () => { + skipIfNoDockerRegistry(providerContext); + before(async () => { + await esArchiver.load('fleet/empty_fleet_server'); + }); + setupFleetAndAgents(providerContext); + + // Use the custom log package to test the fleet final pipeline + before(async () => { + const { body: getPackagesRes } = await supertest.get( + `/api/fleet/epm/packages?experimental=true` + ); + + const logPackage = getPackagesRes.response.find((p: any) => p.name === 'log'); + if (!logPackage) { + throw new Error('No log package'); + } + + pkgKey = `log-${logPackage.version}`; + + await supertest + .post(`/api/fleet/epm/packages/${pkgKey}`) + .set('kbn-xsrf', 'xxxx') + .send({ force: true }) + .expect(200); + }); + after(async () => { + await supertest + .delete(`/api/fleet/epm/packages/${pkgKey}`) + .set('kbn-xsrf', 'xxxx') + .send({ force: true }) + .expect(200); + }); + + after(async () => { + await esArchiver.unload('fleet/empty_fleet_server'); + }); + + after(async () => { + const res = await es.search({ + index: TEST_INDEX, + }); + + for (const hit of res.body.hits.hits) { + await es.delete({ + id: hit._id, + index: hit._index, + }); + } + }); + + it('should correctly setup the final pipeline and apply to fleet managed index template', async () => { + const pipelineRes = await es.ingest.getPipeline({ id: FINAL_PIPELINE_ID }); + expect(pipelineRes.body).to.have.property(FINAL_PIPELINE_ID); + + const res = await es.indices.getIndexTemplate({ name: 'logs-log.log' }); + expect(res.body.index_templates.length).to.be(1); + expect( + res.body.index_templates[0]?.index_template?.template?.settings?.index?.final_pipeline + ).to.be(FINAL_PIPELINE_ID); + }); + + it('For a doc written without api key should write the correct api key status', async () => { + const res = await es.index({ + index: 'logs-log.log-test', + body: { + message: 'message-test-1', + '@timestamp': '2020-01-01T09:09:00', + agent: { + id: 'agent1', + }, + }, + }); + + const { body: doc } = await es.get({ + id: res.body._id, + index: res.body._index, + }); + // @ts-expect-error + const event = doc._source.event; + + expect(event.agent_id_status).to.be('no_api_key'); + expect(event).to.have.property('ingested'); + }); + + const scenarios = [ + { + name: 'API key without metadata', + expectedStatus: 'missing_metadata', + event: { agent: { id: 'agent1' } }, + }, + { + name: 'API key with agent id metadata', + expectedStatus: 'verified', + apiKey: { + metadata: { + agent_id: 'agent1', + }, + }, + event: { agent: { id: 'agent1' } }, + }, + { + name: 'API key with agent id metadata and no agent id in event', + expectedStatus: 'missing_metadata', + apiKey: { + metadata: { + agent_id: 'agent1', + }, + }, + }, + { + name: 'API key with agent id metadata and tampered agent id in event', + expectedStatus: 'agent_id_mismatch', + apiKey: { + metadata: { + agent_id: 'agent2', + }, + }, + event: { agent: { id: 'agent1' } }, + }, + ]; + + for (const scenario of scenarios) { + it(`Should write the correct event.agent_id_status for ${scenario.name}`, async () => { + // Create an API key + const { body: apiKeyRes } = await es.security.createApiKey({ + body: { + name: `test api key`, + ...(scenario.apiKey || {}), + }, + }); + + const res = await indexUsingApiKey( + { + message: 'message-test-1', + '@timestamp': '2020-01-01T09:09:00', + ...(scenario.event || {}), + }, + Buffer.from(`${apiKeyRes.id}:${apiKeyRes.api_key}`).toString('base64') + ); + + const { body: doc } = await es.get({ + id: res.body._id as string, + index: res.body._index as string, + }); + // @ts-expect-error + const event = doc._source.event; + + expect(event.agent_id_status).to.be(scenario.expectedStatus); + expect(event).to.have.property('ingested'); + }); + } + }); +} diff --git a/x-pack/test/fleet_api_integration/apis/epm/index.js b/x-pack/test/fleet_api_integration/apis/epm/index.js index 445d9706bb9a9..b6a1fd5d7346d 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/index.js +++ b/x-pack/test/fleet_api_integration/apis/epm/index.js @@ -25,5 +25,6 @@ export default function loadTests({ loadTestFile }) { loadTestFile(require.resolve('./data_stream')); loadTestFile(require.resolve('./package_install_complete')); loadTestFile(require.resolve('./install_error_rollback')); + loadTestFile(require.resolve('./final_pipeline')); }); } From 07ce6374ef4996452eabad56ffa1022c8fcc7471 Mon Sep 17 00:00:00 2001 From: Thom Heymann <190132+thomheymann@users.noreply.github.com> Date: Thu, 3 Jun 2021 19:29:57 +0300 Subject: [PATCH 32/90] Bump packages (#101167) * Bump mini-css-extract-plugin * Removed unused dependency * Remove unecessary types * Don't match _meta field --- package.json | 4 +- .../index_lifecycle_management/policies.js | 3 + yarn.lock | 95 ++----------------- 3 files changed, 11 insertions(+), 91 deletions(-) diff --git a/package.json b/package.json index e0bebcaacd7ea..fd81b86c7da6e 100644 --- a/package.json +++ b/package.json @@ -241,7 +241,6 @@ "get-port": "^5.0.0", "getopts": "^2.2.5", "getos": "^3.1.0", - "git-url-parse": "11.1.2", "github-markdown-css": "^2.10.0", "glob": "^7.1.2", "glob-all": "^3.2.1", @@ -294,7 +293,7 @@ "memoize-one": "^5.0.0", "mime": "^2.4.4", "mime-types": "^2.1.27", - "mini-css-extract-plugin": "0.8.0", + "mini-css-extract-plugin": "1.1.0", "minimatch": "^3.0.4", "moment": "^2.24.0", "moment-duration-format": "^2.3.2", @@ -533,7 +532,6 @@ "@types/geojson": "7946.0.7", "@types/getopts": "^2.0.1", "@types/getos": "^3.0.0", - "@types/git-url-parse": "^9.0.0", "@types/glob": "^7.1.2", "@types/gulp": "^4.0.6", "@types/gulp-zip": "^4.0.1", diff --git a/x-pack/test/api_integration/apis/management/index_lifecycle_management/policies.js b/x-pack/test/api_integration/apis/management/index_lifecycle_management/policies.js index 8f40f5826c537..a07b966668545 100644 --- a/x-pack/test/api_integration/apis/management/index_lifecycle_management/policies.js +++ b/x-pack/test/api_integration/apis/management/index_lifecycle_management/policies.js @@ -45,6 +45,9 @@ export default function ({ getService }) { const modifiedDate = '2019-04-30T14:30:00.000Z'; policy.modified_date = modifiedDate; + // We don't want to match `_meta` field since it can change between Elasticsearch versions + delete policy.policy._meta; + expect(policy).to.eql({ version: 1, modified_date: modifiedDate, diff --git a/yarn.lock b/yarn.lock index 032c5255130d9..8b47284516978 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4953,11 +4953,6 @@ resolved "https://registry.yarnpkg.com/@types/getos/-/getos-3.0.0.tgz#582c758e99e9d634f31f471faf7ce59cf1c39a71" integrity sha512-g5O9kykBPMaK5USwU+zM5AyXaztqbvHjSQ7HaBjqgO3f5lKGChkRhLP58Z/Nrr4RBGNNPrBcJkWZwnmbmi9YjQ== -"@types/git-url-parse@^9.0.0": - version "9.0.0" - resolved "https://registry.yarnpkg.com/@types/git-url-parse/-/git-url-parse-9.0.0.tgz#aac1315a44fa4ed5a52c3820f6c3c2fb79cbd12d" - integrity sha512-kA2RxBT/r/ZuDDKwMl+vFWn1Z0lfm1/Ik6Qb91wnSzyzCDa/fkM8gIOq6ruB7xfr37n6Mj5dyivileUVKsidlg== - "@types/glob-base@^0.3.0": version "0.3.0" resolved "https://registry.yarnpkg.com/@types/glob-base/-/glob-base-0.3.0.tgz#a581d688347e10e50dd7c17d6f2880a10354319d" @@ -14372,21 +14367,6 @@ gifwrap@^0.9.2: image-q "^1.1.1" omggif "^1.0.10" -git-up@^4.0.0: - version "4.0.1" - resolved "https://registry.yarnpkg.com/git-up/-/git-up-4.0.1.tgz#cb2ef086653640e721d2042fe3104857d89007c0" - integrity sha512-LFTZZrBlrCrGCG07/dm1aCjjpL1z9L3+5aEeI9SBhAqSc+kiA9Or1bgZhQFNppJX6h/f5McrvJt1mQXTFm6Qrw== - dependencies: - is-ssh "^1.3.0" - parse-url "^5.0.0" - -git-url-parse@11.1.2: - version "11.1.2" - resolved "https://registry.yarnpkg.com/git-url-parse/-/git-url-parse-11.1.2.tgz#aff1a897c36cc93699270587bea3dbcbbb95de67" - integrity sha512-gZeLVGY8QVKMIkckncX+iCq2/L8PlwncvDFKiWkBn9EtCfYDbliRTTp6qzyQ1VMdITUfq7293zDzfpjdiGASSQ== - dependencies: - git-up "^4.0.0" - github-markdown-css@^2.10.0: version "2.10.0" resolved "https://registry.yarnpkg.com/github-markdown-css/-/github-markdown-css-2.10.0.tgz#0612fed22816b33b282f37ef8def7a4ecabfe993" @@ -16546,13 +16526,6 @@ is-set@^2.0.1: resolved "https://registry.yarnpkg.com/is-set/-/is-set-2.0.1.tgz#d1604afdab1724986d30091575f54945da7e5f43" integrity sha512-eJEzOtVyenDs1TMzSQ3kU3K+E0GUS9sno+F0OBT97xsgcJsF9nXMBtkT9/kut5JEpM7oL7X/0qxR17K3mcwIAA== -is-ssh@^1.3.0: - version "1.3.1" - resolved "https://registry.yarnpkg.com/is-ssh/-/is-ssh-1.3.1.tgz#f349a8cadd24e65298037a522cf7520f2e81a0f3" - integrity sha512-0eRIASHZt1E68/ixClI8bp2YK2wmBPVWEismTs6M+M099jKgrzl/3E976zIbImSIob48N2/XGe9y7ZiYdImSlg== - dependencies: - protocols "^1.1.0" - is-stream@^1.0.0, is-stream@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" @@ -19306,14 +19279,13 @@ mini-create-react-context@^0.4.0: "@babel/runtime" "^7.5.5" tiny-warning "^1.0.3" -mini-css-extract-plugin@0.8.0: - version "0.8.0" - resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-0.8.0.tgz#81d41ec4fe58c713a96ad7c723cdb2d0bd4d70e1" - integrity sha512-MNpRGbNA52q6U92i0qbVpQNsgk7LExy41MdAlG84FeytfDOtRIf/mCHdEgG8rpTKOaNKiqUnZdlptF469hxqOw== +mini-css-extract-plugin@1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-1.1.0.tgz#dcc2f0bfbec660c0bd1200ff7c8f82deec2cc8a6" + integrity sha512-0bTS+Fg2tGe3dFAgfiN7+YRO37oyQM7/vjFvZF1nXSCJ/sy0tGpeme8MbT4BCpUuUphKwTh9LH/uuTcWRr9DPA== dependencies: - loader-utils "^1.1.0" - normalize-url "1.9.1" - schema-utils "^1.0.0" + loader-utils "^2.0.0" + schema-utils "^3.0.0" webpack-sources "^1.1.0" minimalistic-assert@^1.0.0, minimalistic-assert@^1.0.1: @@ -20260,17 +20232,7 @@ normalize-selector@^0.2.0: resolved "https://registry.yarnpkg.com/normalize-selector/-/normalize-selector-0.2.0.tgz#d0b145eb691189c63a78d201dc4fdb1293ef0c03" integrity sha1-0LFF62kRicY6eNIB3E/bEpPvDAM= -normalize-url@1.9.1: - version "1.9.1" - resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-1.9.1.tgz#2cc0d66b31ea23036458436e3620d85954c66c3c" - integrity sha1-LMDWazHqIwNkWENuNiDYWVTGbDw= - dependencies: - object-assign "^4.0.1" - prepend-http "^1.0.0" - query-string "^4.1.0" - sort-keys "^1.0.0" - -normalize-url@^3.0.0, normalize-url@^3.3.0: +normalize-url@^3.0.0: version "3.3.0" resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-3.3.0.tgz#b2e1c4dc4f7c6d57743df733a4f5978d18650559" integrity sha512-U+JJi7duF1o+u2pynbp2zXDW2/PADgC30f0GsHZtRh+HOcXHnw137TrNlyxxRvWW5fjKd3bcLHPxofWuCjaeZg== @@ -21117,24 +21079,6 @@ parse-passwd@^1.0.0: resolved "https://registry.yarnpkg.com/parse-passwd/-/parse-passwd-1.0.0.tgz#6d5b934a456993b23d37f40a382d6f1666a8e5c6" integrity sha1-bVuTSkVpk7I9N/QKOC1vFmao5cY= -parse-path@^4.0.0: - version "4.0.1" - resolved "https://registry.yarnpkg.com/parse-path/-/parse-path-4.0.1.tgz#0ec769704949778cb3b8eda5e994c32073a1adff" - integrity sha512-d7yhga0Oc+PwNXDvQ0Jv1BuWkLVPXcAoQ/WREgd6vNNoKYaW52KI+RdOFjI63wjkmps9yUE8VS4veP+AgpQ/hA== - dependencies: - is-ssh "^1.3.0" - protocols "^1.4.0" - -parse-url@^5.0.0: - version "5.0.1" - resolved "https://registry.yarnpkg.com/parse-url/-/parse-url-5.0.1.tgz#99c4084fc11be14141efa41b3d117a96fcb9527f" - integrity sha512-flNUPP27r3vJpROi0/R3/2efgKkyXqnXwyP1KQ2U0SfFRgdizOdWfvrrvJg1LuOoxs7GQhmxJlq23IpQ/BkByg== - dependencies: - is-ssh "^1.3.0" - normalize-url "^3.3.0" - parse-path "^4.0.0" - protocols "^1.4.0" - parse5@5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/parse5/-/parse5-5.1.0.tgz#c59341c9723f414c452975564c7c00a68d58acd2" @@ -22277,11 +22221,6 @@ protocol-buffers-schema@^3.3.1: resolved "https://registry.yarnpkg.com/protocol-buffers-schema/-/protocol-buffers-schema-3.3.2.tgz#00434f608b4e8df54c59e070efeefc37fb4bb859" integrity sha512-Xdayp8sB/mU+sUV4G7ws8xtYMGdQnxbeIfLjyO9TZZRJdztBGhlmbI5x1qcY4TG5hBkIKGnc28i7nXxaugu88w== -protocols@^1.1.0, protocols@^1.4.0: - version "1.4.7" - resolved "https://registry.yarnpkg.com/protocols/-/protocols-1.4.7.tgz#95f788a4f0e979b291ffefcf5636ad113d037d32" - integrity sha512-Fx65lf9/YDn3hUX08XUc0J8rSux36rEsyiv21ZGUC1mOyeM3lTRpZLcrm8aAolzS4itwVfm7TAPyxC2E5zd6xg== - proxy-addr@~2.0.5: version "2.0.5" resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.5.tgz#34cbd64a2d81f4b1fd21e76f9f06c8a45299ee34" @@ -22455,14 +22394,6 @@ qs@~6.5.2: resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36" integrity sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA== -query-string@^4.1.0: - version "4.3.4" - resolved "https://registry.yarnpkg.com/query-string/-/query-string-4.3.4.tgz#bbb693b9ca915c232515b228b1a02b609043dbeb" - integrity sha1-u7aTucqRXCMlFbIosaArYJBD2+s= - dependencies: - object-assign "^4.1.0" - strict-uri-encode "^1.0.0" - query-string@^6.13.2: version "6.13.2" resolved "https://registry.yarnpkg.com/query-string/-/query-string-6.13.2.tgz#3585aa9412c957cbd358fd5eaca7466f05586dda" @@ -25242,13 +25173,6 @@ sonic-boom@^1.0.2: atomic-sleep "^1.0.0" flatstr "^1.0.12" -sort-keys@^1.0.0: - version "1.1.2" - resolved "https://registry.yarnpkg.com/sort-keys/-/sort-keys-1.1.2.tgz#441b6d4d346798f1b4e49e8920adfba0e543f9ad" - integrity sha1-RBttTTRnmPG05J6JIK37oOVD+a0= - dependencies: - is-plain-obj "^1.0.0" - sort-keys@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/sort-keys/-/sort-keys-2.0.0.tgz#658535584861ec97d730d6cf41822e1f56684128" @@ -25793,11 +25717,6 @@ stream-to-async-iterator@^0.2.0: resolved "https://registry.yarnpkg.com/stream-to-async-iterator/-/stream-to-async-iterator-0.2.0.tgz#bef5c885e9524f98b2fa5effecc357bd58483780" integrity sha1-vvXIhelST5iy+l7/7MNXvVhIN4A= -strict-uri-encode@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz#279b225df1d582b1f54e65addd4352e18faa0713" - integrity sha1-J5siXfHVgrH1TmWt3UNS4Y+qBxM= - strict-uri-encode@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz#b9c7330c7042862f6b142dc274bbcc5866ce3546" From f69d63e8be8052d8400021fa91c440bf79c05925 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Thu, 3 Jun 2021 17:53:39 +0100 Subject: [PATCH 33/90] fix(NA): windows ts_project outside sandbox compilation (#100947) * fix(NA): windows ts_project outside sandbox compilation adding tsconfig paths for packages * chore(NA): missing @kbn paths for node_modules so types can work * chore(NA): missing @kbn paths for node_modules so types can work * chore(NA): organizing deps on non ts_project packages * chore(NA): change order to find @kbn packages on node_modules first * chore(NA): add @kbn/expect typings setting on package.json * chore(NA): fix typechecking * chore(NA): add missing change on tsconfig file * chore(NA): unblock windows build by not depending on the pkg_npm rule symlink in the package.json * chore(NA): add missing depedencies on BUILD.bazel file for io-ts-list-types * chore(NA): remove rootDirs configs * chore(NA): change kbn/monaco targets order * chore(NA): update kbn-monaco build Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- package.json | 82 +++++++++---------- packages/kbn-babel-code-parser/BUILD.bazel | 4 +- packages/kbn-es/BUILD.bazel | 4 +- packages/kbn-expect/BUILD.bazel | 2 +- .../{expect.js.d.ts => expect.d.ts} | 0 packages/kbn-expect/package.json | 1 + packages/kbn-expect/tsconfig.json | 2 +- packages/kbn-monaco/BUILD.bazel | 4 +- packages/kbn-monaco/webpack.config.js | 1 - packages/kbn-pm/dist/index.js | 4 +- packages/kbn-pm/src/utils/package_json.ts | 2 +- packages/kbn-pm/src/utils/project.ts | 2 +- .../BUILD.bazel | 3 +- test/functional/page_objects/error_page.ts | 2 +- .../page_objects/visualize_editor_page.ts | 2 +- tsconfig.base.json | 7 ++ .../spaces_only/tests/alerting/update.ts | 2 +- .../apis/lists/create_exception_list_item.ts | 2 +- .../api_integration/apis/security/api_keys.ts | 2 +- .../apis/security/builtin_es_privileges.ts | 2 +- .../apis/security/index_fields.ts | 2 +- .../apis/security/license_downgrade.ts | 2 +- .../apis/security/privileges.ts | 2 +- .../apis/spaces/saved_objects.ts | 2 +- .../apis/agents/upgrade.ts | 2 +- .../page_objects/infra_home_page.ts | 2 +- .../functional/services/uptime/monitor.ts | 2 +- .../event_log/public_api_integration.ts | 2 +- .../event_log/service_api_integration.ts | 2 +- .../common/suites/delete.ts | 2 +- .../tests/session_idle/extension.ts | 2 +- .../apis/metadata.ts | 2 +- .../apis/metadata_v1.ts | 2 +- .../apis/policy.ts | 2 +- yarn.lock | 82 +++++++++---------- 35 files changed, 124 insertions(+), 116 deletions(-) rename packages/kbn-expect/{expect.js.d.ts => expect.d.ts} (100%) diff --git a/package.json b/package.json index fd81b86c7da6e..f0803b3b44056 100644 --- a/package.json +++ b/package.json @@ -100,7 +100,7 @@ "@elastic/apm-rum": "^5.6.1", "@elastic/apm-rum-react": "^1.2.5", "@elastic/charts": "29.2.0", - "@elastic/datemath": "link:bazel-bin/packages/elastic-datemath/npm_module", + "@elastic/datemath": "link:bazel-bin/packages/elastic-datemath", "@elastic/elasticsearch": "npm:@elastic/elasticsearch-canary@^8.0.0-canary.4", "@elastic/ems-client": "7.13.0", "@elastic/eui": "33.0.0", @@ -111,7 +111,7 @@ "@elastic/numeral": "^2.5.1", "@elastic/react-search-ui": "^1.5.1", "@elastic/request-crypto": "1.1.4", - "@elastic/safer-lodash-set": "link:bazel-bin/packages/elastic-safer-lodash-set/npm_module", + "@elastic/safer-lodash-set": "link:bazel-bin/packages/elastic-safer-lodash-set", "@elastic/search-ui-app-search-connector": "^1.5.0", "@elastic/ui-ace": "0.2.3", "@hapi/accept": "^5.0.2", @@ -124,39 +124,39 @@ "@hapi/inert": "^6.0.3", "@hapi/podium": "^4.1.1", "@hapi/wreck": "^17.1.0", - "@kbn/ace": "link:bazel-bin/packages/kbn-ace/npm_module", - "@kbn/analytics": "link:bazel-bin/packages/kbn-analytics/npm_module", - "@kbn/apm-config-loader": "link:bazel-bin/packages/kbn-apm-config-loader/npm_module", - "@kbn/apm-utils": "link:bazel-bin/packages/kbn-apm-utils/npm_module", - "@kbn/config": "link:bazel-bin/packages/kbn-config/npm_module", - "@kbn/config-schema": "link:bazel-bin/packages/kbn-config-schema/npm_module", - "@kbn/crypto": "link:bazel-bin/packages/kbn-crypto/npm_module", - "@kbn/i18n": "link:bazel-bin/packages/kbn-i18n/npm_module", + "@kbn/ace": "link:bazel-bin/packages/kbn-ace", + "@kbn/analytics": "link:bazel-bin/packages/kbn-analytics", + "@kbn/apm-config-loader": "link:bazel-bin/packages/kbn-apm-config-loader", + "@kbn/apm-utils": "link:bazel-bin/packages/kbn-apm-utils", + "@kbn/config": "link:bazel-bin/packages/kbn-config", + "@kbn/config-schema": "link:bazel-bin/packages/kbn-config-schema", + "@kbn/crypto": "link:bazel-bin/packages/kbn-crypto", + "@kbn/mapbox-gl": "link:bazel-bin/packages/kbn-mapbox-gl", + "@kbn/i18n": "link:bazel-bin/packages/kbn-i18n", "@kbn/interpreter": "link:packages/kbn-interpreter", - "@kbn/io-ts-utils": "link:bazel-bin/packages/kbn-io-ts-utils/npm_module", - "@kbn/legacy-logging": "link:bazel-bin/packages/kbn-legacy-logging/npm_module", - "@kbn/logging": "link:bazel-bin/packages/kbn-logging/npm_module", - "@kbn/mapbox-gl": "link:bazel-bin/packages/kbn-mapbox-gl/npm_module", - "@kbn/monaco": "link:bazel-bin/packages/kbn-monaco/npm_module", + "@kbn/io-ts-utils": "link:bazel-bin/packages/kbn-io-ts-utils", + "@kbn/legacy-logging": "link:bazel-bin/packages/kbn-legacy-logging", + "@kbn/logging": "link:bazel-bin/packages/kbn-logging", + "@kbn/monaco": "link:bazel-bin/packages/kbn-monaco", "@kbn/rule-data-utils": "link:packages/kbn-rule-data-utils", - "@kbn/securitysolution-es-utils": "link:bazel-bin/packages/kbn-securitysolution-es-utils/npm_module", - "@kbn/securitysolution-io-ts-alerting-types": "link:bazel-bin/packages/kbn-securitysolution-io-ts-alerting-types/npm_module", - "@kbn/securitysolution-io-ts-list-types": "link:bazel-bin/packages/kbn-securitysolution-io-ts-list-types/npm_module", - "@kbn/securitysolution-io-ts-types": "link:bazel-bin/packages/kbn-securitysolution-io-ts-types/npm_module", - "@kbn/securitysolution-io-ts-utils": "link:bazel-bin/packages/kbn-securitysolution-io-ts-utils/npm_module", - "@kbn/securitysolution-list-api": "link:bazel-bin/packages/kbn-securitysolution-list-api/npm_module", - "@kbn/securitysolution-list-constants": "link:bazel-bin/packages/kbn-securitysolution-list-constants/npm_module", - "@kbn/securitysolution-list-hooks": "link:bazel-bin/packages/kbn-securitysolution-list-hooks/npm_module", - "@kbn/securitysolution-list-utils": "link:bazel-bin/packages/kbn-securitysolution-list-utils/npm_module", - "@kbn/securitysolution-utils": "link:bazel-bin/packages/kbn-securitysolution-utils/npm_module", - "@kbn/server-http-tools": "link:bazel-bin/packages/kbn-server-http-tools/npm_module", + "@kbn/securitysolution-list-constants": "link:bazel-bin/packages/kbn-securitysolution-list-constants", + "@kbn/securitysolution-es-utils": "link:bazel-bin/packages/kbn-securitysolution-es-utils", + "@kbn/securitysolution-io-ts-types": "link:bazel-bin/packages/kbn-securitysolution-io-ts-types", + "@kbn/securitysolution-io-ts-alerting-types": "link:bazel-bin/packages/kbn-securitysolution-io-ts-alerting-types", + "@kbn/securitysolution-io-ts-list-types": "link:bazel-bin/packages/kbn-securitysolution-io-ts-list-types", + "@kbn/securitysolution-io-ts-utils": "link:bazel-bin/packages/kbn-securitysolution-io-ts-utils", + "@kbn/securitysolution-list-api": "link:bazel-bin/packages/kbn-securitysolution-list-api", + "@kbn/securitysolution-list-hooks": "link:bazel-bin/packages/kbn-securitysolution-list-hooks", + "@kbn/securitysolution-list-utils": "link:bazel-bin/packages/kbn-securitysolution-list-utils", + "@kbn/securitysolution-utils": "link:bazel-bin/packages/kbn-securitysolution-utils", + "@kbn/server-http-tools": "link:bazel-bin/packages/kbn-server-http-tools", "@kbn/server-route-repository": "link:packages/kbn-server-route-repository", - "@kbn/std": "link:bazel-bin/packages/kbn-std/npm_module", - "@kbn/tinymath": "link:bazel-bin/packages/kbn-tinymath/npm_module", + "@kbn/std": "link:bazel-bin/packages/kbn-std", + "@kbn/tinymath": "link:bazel-bin/packages/kbn-tinymath", "@kbn/ui-framework": "link:packages/kbn-ui-framework", "@kbn/ui-shared-deps": "link:packages/kbn-ui-shared-deps", - "@kbn/utility-types": "link:bazel-bin/packages/kbn-utility-types/npm_module", - "@kbn/utils": "link:bazel-bin/packages/kbn-utils/npm_module", + "@kbn/utility-types": "link:bazel-bin/packages/kbn-utility-types", + "@kbn/utils": "link:bazel-bin/packages/kbn-utils", "@loaders.gl/core": "^2.3.1", "@loaders.gl/json": "^2.3.1", "@mapbox/geojson-rewind": "^0.5.0", @@ -446,28 +446,28 @@ "@cypress/webpack-preprocessor": "^5.6.0", "@elastic/apm-rum": "^5.6.1", "@elastic/apm-rum-react": "^1.2.5", - "@elastic/eslint-config-kibana": "link:bazel-bin/packages/elastic-eslint-config-kibana/npm_module", + "@elastic/eslint-config-kibana": "link:bazel-bin/packages/elastic-eslint-config-kibana", "@elastic/eslint-plugin-eui": "0.0.2", "@elastic/github-checks-reporter": "0.0.20b3", "@elastic/makelogs": "^6.0.0", "@istanbuljs/schema": "^0.1.2", "@jest/reporters": "^26.6.2", - "@kbn/babel-code-parser": "link:bazel-bin/packages/kbn-babel-code-parser/npm_module", - "@kbn/babel-preset": "link:bazel-bin/packages/kbn-babel-preset/npm_module", + "@kbn/babel-code-parser": "link:bazel-bin/packages/kbn-babel-code-parser", + "@kbn/babel-preset": "link:bazel-bin/packages/kbn-babel-preset", "@kbn/cli-dev-mode": "link:packages/kbn-cli-dev-mode", - "@kbn/dev-utils": "link:bazel-bin/packages/kbn-dev-utils/npm_module", - "@kbn/docs-utils": "link:bazel-bin/packages/kbn-docs-utils/npm_module", - "@kbn/es": "link:bazel-bin/packages/kbn-es/npm_module", + "@kbn/dev-utils": "link:bazel-bin/packages/kbn-dev-utils", + "@kbn/docs-utils": "link:bazel-bin/packages/kbn-docs-utils", + "@kbn/es": "link:bazel-bin/packages/kbn-es", "@kbn/es-archiver": "link:packages/kbn-es-archiver", - "@kbn/eslint-import-resolver-kibana": "link:bazel-bin/packages/kbn-eslint-import-resolver-kibana/npm_module", - "@kbn/eslint-plugin-eslint": "link:bazel-bin/packages/kbn-eslint-plugin-eslint/npm_module", - "@kbn/expect": "link:bazel-bin/packages/kbn-expect/npm_module", + "@kbn/eslint-import-resolver-kibana": "link:bazel-bin/packages/kbn-eslint-import-resolver-kibana", + "@kbn/eslint-plugin-eslint": "link:bazel-bin/packages/kbn-eslint-plugin-eslint", + "@kbn/expect": "link:bazel-bin/packages/kbn-expect", "@kbn/optimizer": "link:packages/kbn-optimizer", - "@kbn/plugin-generator": "link:bazel-bin/packages/kbn-plugin-generator/npm_module", + "@kbn/plugin-generator": "link:bazel-bin/packages/kbn-plugin-generator", "@kbn/plugin-helpers": "link:packages/kbn-plugin-helpers", "@kbn/pm": "link:packages/kbn-pm", "@kbn/storybook": "link:packages/kbn-storybook", - "@kbn/telemetry-tools": "link:bazel-bin/packages/kbn-telemetry-tools/npm_module", + "@kbn/telemetry-tools": "link:bazel-bin/packages/kbn-telemetry-tools", "@kbn/test": "link:packages/kbn-test", "@kbn/test-subj-selector": "link:packages/kbn-test-subj-selector", "@loaders.gl/polyfills": "^2.3.5", diff --git a/packages/kbn-babel-code-parser/BUILD.bazel b/packages/kbn-babel-code-parser/BUILD.bazel index c1576ce59aa5c..dcdc042d7b802 100644 --- a/packages/kbn-babel-code-parser/BUILD.bazel +++ b/packages/kbn-babel-code-parser/BUILD.bazel @@ -34,10 +34,10 @@ DEPS = [ babel( name = "target", - data = [ + data = DEPS + [ ":srcs", ".babelrc", - ] + DEPS, + ], output_dir = True, # the following arg paths includes $(execpath) as babel runs on the sandbox root args = [ diff --git a/packages/kbn-es/BUILD.bazel b/packages/kbn-es/BUILD.bazel index 6c845996ce5e5..48f0fb58e983f 100644 --- a/packages/kbn-es/BUILD.bazel +++ b/packages/kbn-es/BUILD.bazel @@ -47,10 +47,10 @@ DEPS = [ babel( name = "target", - data = [ + data = DEPS + [ ":srcs", ".babelrc", - ] + DEPS, + ], output_dir = True, # the following arg paths includes $(execpath) as babel runs on the sandbox root args = [ diff --git a/packages/kbn-expect/BUILD.bazel b/packages/kbn-expect/BUILD.bazel index 82e6200e9688a..b7eb91a451b9a 100644 --- a/packages/kbn-expect/BUILD.bazel +++ b/packages/kbn-expect/BUILD.bazel @@ -5,7 +5,7 @@ PKG_REQUIRE_NAME = "@kbn/expect" SOURCE_FILES = glob([ "expect.js", - "expect.js.d.ts", + "expect.d.ts", ]) SRCS = SOURCE_FILES diff --git a/packages/kbn-expect/expect.js.d.ts b/packages/kbn-expect/expect.d.ts similarity index 100% rename from packages/kbn-expect/expect.js.d.ts rename to packages/kbn-expect/expect.d.ts diff --git a/packages/kbn-expect/package.json b/packages/kbn-expect/package.json index 8ca37c7c88673..2040683c539e2 100644 --- a/packages/kbn-expect/package.json +++ b/packages/kbn-expect/package.json @@ -1,6 +1,7 @@ { "name": "@kbn/expect", "main": "./expect.js", + "typings": "./expect.d.ts", "version": "1.0.0", "license": "MIT", "private": true, diff --git a/packages/kbn-expect/tsconfig.json b/packages/kbn-expect/tsconfig.json index 7baae093bc3a9..8c0d9f1e34bd0 100644 --- a/packages/kbn-expect/tsconfig.json +++ b/packages/kbn-expect/tsconfig.json @@ -4,6 +4,6 @@ "incremental": false, }, "include": [ - "expect.js.d.ts" + "expect.d.ts" ] } diff --git a/packages/kbn-monaco/BUILD.bazel b/packages/kbn-monaco/BUILD.bazel index 3a25568dfd811..325187cdebc3a 100644 --- a/packages/kbn-monaco/BUILD.bazel +++ b/packages/kbn-monaco/BUILD.bazel @@ -48,7 +48,7 @@ webpack( name = "target_web", data = DEPS + [ ":src", - ":webpack.config.js", + "webpack.config.js", ], output_dir = True, args = [ @@ -87,7 +87,7 @@ ts_project( js_library( name = PKG_BASE_NAME, srcs = NPM_MODULE_EXTRA_FILES, - deps = DEPS + [":target_web", ":tsc"], + deps = DEPS + [":tsc", ":target_web"], package_name = PKG_REQUIRE_NAME, visibility = ["//visibility:public"], ) diff --git a/packages/kbn-monaco/webpack.config.js b/packages/kbn-monaco/webpack.config.js index d035134565463..ef482cd55159b 100644 --- a/packages/kbn-monaco/webpack.config.js +++ b/packages/kbn-monaco/webpack.config.js @@ -22,7 +22,6 @@ const createLangWorkerConfig = (lang) => { filename: `${lang}.editor.worker.js`, }, resolve: { - modules: ['node_modules'], extensions: ['.js', '.ts', '.tsx'], }, stats: 'errors-only', diff --git a/packages/kbn-pm/dist/index.js b/packages/kbn-pm/dist/index.js index 29c0457c316f0..4c4c0259f066b 100644 --- a/packages/kbn-pm/dist/index.js +++ b/packages/kbn-pm/dist/index.js @@ -23045,7 +23045,7 @@ class Project { ensureValidProjectDependency(project) { const relativePathToProject = normalizePath(path__WEBPACK_IMPORTED_MODULE_1___default.a.relative(this.path, project.path)); - const relativePathToProjectIfBazelPkg = normalizePath(path__WEBPACK_IMPORTED_MODULE_1___default.a.relative(this.path, `${__dirname}/../../../bazel-bin/packages/${path__WEBPACK_IMPORTED_MODULE_1___default.a.basename(project.path)}/npm_module`)); + const relativePathToProjectIfBazelPkg = normalizePath(path__WEBPACK_IMPORTED_MODULE_1___default.a.relative(this.path, `${__dirname}/../../../bazel-bin/packages/${path__WEBPACK_IMPORTED_MODULE_1___default.a.basename(project.path)}`)); const versionInPackageJson = this.allDependencies[project.name]; const expectedVersionInPackageJson = `link:${relativePathToProject}`; const expectedVersionInPackageJsonIfBazelPkg = `link:${relativePathToProjectIfBazelPkg}`; // TODO: after introduce bazel to build all the packages and completely remove the support for kbn packages @@ -23234,7 +23234,7 @@ function transformDependencies(dependencies = {}) { } if (isBazelPackageDependency(depVersion)) { - newDeps[name] = depVersion.replace('link:bazel-bin/', 'file:').replace('/npm_module', ''); + newDeps[name] = depVersion.replace('link:bazel-bin/', 'file:'); continue; } diff --git a/packages/kbn-pm/src/utils/package_json.ts b/packages/kbn-pm/src/utils/package_json.ts index e635c2566e65a..a50d8994b5720 100644 --- a/packages/kbn-pm/src/utils/package_json.ts +++ b/packages/kbn-pm/src/utils/package_json.ts @@ -61,7 +61,7 @@ export function transformDependencies(dependencies: IPackageDependencies = {}) { } if (isBazelPackageDependency(depVersion)) { - newDeps[name] = depVersion.replace('link:bazel-bin/', 'file:').replace('/npm_module', ''); + newDeps[name] = depVersion.replace('link:bazel-bin/', 'file:'); continue; } diff --git a/packages/kbn-pm/src/utils/project.ts b/packages/kbn-pm/src/utils/project.ts index 5d2a0547b2577..8e86b111c6a18 100644 --- a/packages/kbn-pm/src/utils/project.ts +++ b/packages/kbn-pm/src/utils/project.ts @@ -94,7 +94,7 @@ export class Project { const relativePathToProjectIfBazelPkg = normalizePath( Path.relative( this.path, - `${__dirname}/../../../bazel-bin/packages/${Path.basename(project.path)}/npm_module` + `${__dirname}/../../../bazel-bin/packages/${Path.basename(project.path)}` ) ); diff --git a/packages/kbn-securitysolution-io-ts-list-types/BUILD.bazel b/packages/kbn-securitysolution-io-ts-list-types/BUILD.bazel index 91e4667c16b4e..99df07c3d8ea8 100644 --- a/packages/kbn-securitysolution-io-ts-list-types/BUILD.bazel +++ b/packages/kbn-securitysolution-io-ts-list-types/BUILD.bazel @@ -27,9 +27,10 @@ NPM_MODULE_EXTRA_FILES = [ ] SRC_DEPS = [ + "//packages/elastic-datemath", "//packages/kbn-securitysolution-io-ts-types", "//packages/kbn-securitysolution-io-ts-utils", - "//packages/elastic-datemath", + "//packages/kbn-securitysolution-list-constants", "@npm//fp-ts", "@npm//io-ts", "@npm//lodash", diff --git a/test/functional/page_objects/error_page.ts b/test/functional/page_objects/error_page.ts index e3d5a7fdf57c2..98096f3179d02 100644 --- a/test/functional/page_objects/error_page.ts +++ b/test/functional/page_objects/error_page.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect/expect'; import { FtrProviderContext } from '../ftr_provider_context'; export function ErrorPageProvider({ getPageObjects }: FtrProviderContext) { diff --git a/test/functional/page_objects/visualize_editor_page.ts b/test/functional/page_objects/visualize_editor_page.ts index 47cbc8c5e3ea3..9ba1ab6f85081 100644 --- a/test/functional/page_objects/visualize_editor_page.ts +++ b/test/functional/page_objects/visualize_editor_page.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect/expect'; import { FtrProviderContext } from '../ftr_provider_context'; export function VisualizeEditorPageProvider({ getService, getPageObjects }: FtrProviderContext) { diff --git a/tsconfig.base.json b/tsconfig.base.json index eca78e492ff5e..cc8b66848a394 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -2,6 +2,13 @@ "compilerOptions": { "baseUrl": ".", "paths": { + // Setup @kbn paths for Bazel compilations + "@kbn/*": [ + "node_modules/@kbn/*", + "bazel-out/darwin-fastbuild/bin/packages/kbn-*", + "bazel-out/k8-fastbuild/bin/packages/kbn-*", + "bazel-out/x64_windows-fastbuild/bin/packages/kbn-*", + ], // Allows for importing from `kibana` package for the exported types. "kibana": ["./kibana"], "kibana/public": ["src/core/public"], diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/update.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/update.ts index 318da3e114097..3d98e428fd9ee 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/update.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/update.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect/expect'; import { Spaces } from '../../scenarios'; import { checkAAD, getUrlPrefix, getTestAlertData, ObjectRemover } from '../../../common/lib'; import { FtrProviderContext } from '../../../common/ftr_provider_context'; diff --git a/x-pack/test/api_integration/apis/lists/create_exception_list_item.ts b/x-pack/test/api_integration/apis/lists/create_exception_list_item.ts index db3cdd17a89dc..fb80d81dd242a 100644 --- a/x-pack/test/api_integration/apis/lists/create_exception_list_item.ts +++ b/x-pack/test/api_integration/apis/lists/create_exception_list_item.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect/expect'; import { ENDPOINT_LIST_ID } from '@kbn/securitysolution-list-constants'; import { FtrProviderContext } from '../../ftr_provider_context'; diff --git a/x-pack/test/api_integration/apis/security/api_keys.ts b/x-pack/test/api_integration/apis/security/api_keys.ts index c79c4f3eaa88e..d2614abc9e5f7 100644 --- a/x-pack/test/api_integration/apis/security/api_keys.ts +++ b/x-pack/test/api_integration/apis/security/api_keys.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { diff --git a/x-pack/test/api_integration/apis/security/builtin_es_privileges.ts b/x-pack/test/api_integration/apis/security/builtin_es_privileges.ts index 9e4f5c8af8b05..c927d095b8889 100644 --- a/x-pack/test/api_integration/apis/security/builtin_es_privileges.ts +++ b/x-pack/test/api_integration/apis/security/builtin_es_privileges.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { diff --git a/x-pack/test/api_integration/apis/security/index_fields.ts b/x-pack/test/api_integration/apis/security/index_fields.ts index 442740c7666df..3f036bcd7f7ea 100644 --- a/x-pack/test/api_integration/apis/security/index_fields.ts +++ b/x-pack/test/api_integration/apis/security/index_fields.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { diff --git a/x-pack/test/api_integration/apis/security/license_downgrade.ts b/x-pack/test/api_integration/apis/security/license_downgrade.ts index 583df6ea5ed07..7a5ad1ce64a62 100644 --- a/x-pack/test/api_integration/apis/security/license_downgrade.ts +++ b/x-pack/test/api_integration/apis/security/license_downgrade.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { diff --git a/x-pack/test/api_integration/apis/security/privileges.ts b/x-pack/test/api_integration/apis/security/privileges.ts index f08712e015656..d6ad5f6cd387b 100644 --- a/x-pack/test/api_integration/apis/security/privileges.ts +++ b/x-pack/test/api_integration/apis/security/privileges.ts @@ -7,7 +7,7 @@ import util from 'util'; import { isEqual, isEqualWith } from 'lodash'; -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect/expect'; import { RawKibanaPrivileges } from '../../../../plugins/security/common/model'; import { FtrProviderContext } from '../../ftr_provider_context'; diff --git a/x-pack/test/api_integration/apis/spaces/saved_objects.ts b/x-pack/test/api_integration/apis/spaces/saved_objects.ts index b520c374d4f90..20fc3428bb2b1 100644 --- a/x-pack/test/api_integration/apis/spaces/saved_objects.ts +++ b/x-pack/test/api_integration/apis/spaces/saved_objects.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { diff --git a/x-pack/test/fleet_api_integration/apis/agents/upgrade.ts b/x-pack/test/fleet_api_integration/apis/agents/upgrade.ts index b692699182cac..0722edbcb45b3 100644 --- a/x-pack/test/fleet_api_integration/apis/agents/upgrade.ts +++ b/x-pack/test/fleet_api_integration/apis/agents/upgrade.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect/expect'; import semver from 'semver'; import { FtrProviderContext } from '../../../api_integration/ftr_provider_context'; import { setupFleetAndAgents } from './services'; diff --git a/x-pack/test/functional/page_objects/infra_home_page.ts b/x-pack/test/functional/page_objects/infra_home_page.ts index 2f4575d45cc20..a5388aa829d01 100644 --- a/x-pack/test/functional/page_objects/infra_home_page.ts +++ b/x-pack/test/functional/page_objects/infra_home_page.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect/expect'; import testSubjSelector from '@kbn/test-subj-selector'; import { FtrProviderContext } from '../ftr_provider_context'; diff --git a/x-pack/test/functional/services/uptime/monitor.ts b/x-pack/test/functional/services/uptime/monitor.ts index 417c9bb20f9b7..3b22a5f7f6630 100644 --- a/x-pack/test/functional/services/uptime/monitor.ts +++ b/x-pack/test/functional/services/uptime/monitor.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; export function UptimeMonitorProvider({ getService, getPageObjects }: FtrProviderContext) { diff --git a/x-pack/test/plugin_api_integration/test_suites/event_log/public_api_integration.ts b/x-pack/test/plugin_api_integration/test_suites/event_log/public_api_integration.ts index 58bac6fe45417..f2497041094f7 100644 --- a/x-pack/test/plugin_api_integration/test_suites/event_log/public_api_integration.ts +++ b/x-pack/test/plugin_api_integration/test_suites/event_log/public_api_integration.ts @@ -7,7 +7,7 @@ import { merge, omit, chunk, isEmpty } from 'lodash'; import uuid from 'uuid'; -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect/expect'; import moment from 'moment'; import { FtrProviderContext } from '../../ftr_provider_context'; import { IEvent } from '../../../../plugins/event_log/server'; diff --git a/x-pack/test/plugin_api_integration/test_suites/event_log/service_api_integration.ts b/x-pack/test/plugin_api_integration/test_suites/event_log/service_api_integration.ts index f9f518091847d..170b01a01edf9 100644 --- a/x-pack/test/plugin_api_integration/test_suites/event_log/service_api_integration.ts +++ b/x-pack/test/plugin_api_integration/test_suites/event_log/service_api_integration.ts @@ -7,7 +7,7 @@ import _ from 'lodash'; import uuid from 'uuid'; -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect/expect'; import { IEvent } from '../../../../plugins/event_log/server'; import { FtrProviderContext } from '../../ftr_provider_context'; diff --git a/x-pack/test/saved_object_api_integration/common/suites/delete.ts b/x-pack/test/saved_object_api_integration/common/suites/delete.ts index 37aff5a8cdadd..1ba8ea32b9922 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/delete.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/delete.ts @@ -6,7 +6,7 @@ */ import { SuperTest } from 'supertest'; -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect/expect'; import { SAVED_OBJECT_TEST_CASES as CASES } from '../lib/saved_object_test_cases'; import { SPACES } from '../lib/spaces'; import { expectResponses, getUrlPrefix, getTestTitle } from '../lib/saved_object_test_utils'; diff --git a/x-pack/test/security_api_integration/tests/session_idle/extension.ts b/x-pack/test/security_api_integration/tests/session_idle/extension.ts index 71621f4e3db8a..b8fef972f05d6 100644 --- a/x-pack/test/security_api_integration/tests/session_idle/extension.ts +++ b/x-pack/test/security_api_integration/tests/session_idle/extension.ts @@ -6,7 +6,7 @@ */ import { Cookie, cookie } from 'request'; -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts b/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts index 13a19e55ab588..da339f54d41f4 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect/expect'; import { FtrProviderContext } from '../ftr_provider_context'; import { deleteAllDocsFromMetadataCurrentIndex, diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/metadata_v1.ts b/x-pack/test/security_solution_endpoint_api_int/apis/metadata_v1.ts index f3f86d4610d2b..1e1322944153b 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/metadata_v1.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/metadata_v1.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect/expect'; import { FtrProviderContext } from '../ftr_provider_context'; import { deleteMetadataStream } from './data_stream_helper'; import { METADATA_REQUEST_V1_ROUTE } from '../../../plugins/security_solution/server/endpoint/routes/metadata'; diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/policy.ts b/x-pack/test/security_solution_endpoint_api_int/apis/policy.ts index 79ea93da8a5b0..318e857bdcad0 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/policy.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/policy.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect/expect'; import { FtrProviderContext } from '../ftr_provider_context'; import { deletePolicyStream } from './data_stream_helper'; diff --git a/yarn.lock b/yarn.lock index 8b47284516978..7f2b44b5d0c3f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1392,7 +1392,7 @@ utility-types "^3.10.0" uuid "^3.3.2" -"@elastic/datemath@link:bazel-bin/packages/elastic-datemath/npm_module": +"@elastic/datemath@link:bazel-bin/packages/elastic-datemath": version "0.0.0" uid "" @@ -1435,7 +1435,7 @@ semver "7.3.2" topojson-client "^3.1.0" -"@elastic/eslint-config-kibana@link:bazel-bin/packages/elastic-eslint-config-kibana/npm_module": +"@elastic/eslint-config-kibana@link:bazel-bin/packages/elastic-eslint-config-kibana": version "0.0.0" uid "" @@ -1572,7 +1572,7 @@ "@types/node-jose" "1.1.0" node-jose "1.1.0" -"@elastic/safer-lodash-set@link:bazel-bin/packages/elastic-safer-lodash-set/npm_module": +"@elastic/safer-lodash-set@link:bazel-bin/packages/elastic-safer-lodash-set": version "0.0.0" uid "" @@ -2591,27 +2591,27 @@ "@babel/runtime" "^7.7.2" regenerator-runtime "^0.13.3" -"@kbn/ace@link:bazel-bin/packages/kbn-ace/npm_module": +"@kbn/ace@link:bazel-bin/packages/kbn-ace": version "0.0.0" uid "" -"@kbn/analytics@link:bazel-bin/packages/kbn-analytics/npm_module": +"@kbn/analytics@link:bazel-bin/packages/kbn-analytics": version "0.0.0" uid "" -"@kbn/apm-config-loader@link:bazel-bin/packages/kbn-apm-config-loader/npm_module": +"@kbn/apm-config-loader@link:bazel-bin/packages/kbn-apm-config-loader": version "0.0.0" uid "" -"@kbn/apm-utils@link:bazel-bin/packages/kbn-apm-utils/npm_module": +"@kbn/apm-utils@link:bazel-bin/packages/kbn-apm-utils": version "0.0.0" uid "" -"@kbn/babel-code-parser@link:bazel-bin/packages/kbn-babel-code-parser/npm_module": +"@kbn/babel-code-parser@link:bazel-bin/packages/kbn-babel-code-parser": version "0.0.0" uid "" -"@kbn/babel-preset@link:bazel-bin/packages/kbn-babel-preset/npm_module": +"@kbn/babel-preset@link:bazel-bin/packages/kbn-babel-preset": version "0.0.0" uid "" @@ -2619,23 +2619,23 @@ version "0.0.0" uid "" -"@kbn/config-schema@link:bazel-bin/packages/kbn-config-schema/npm_module": +"@kbn/config-schema@link:bazel-bin/packages/kbn-config-schema": version "0.0.0" uid "" -"@kbn/config@link:bazel-bin/packages/kbn-config/npm_module": +"@kbn/config@link:bazel-bin/packages/kbn-config": version "0.0.0" uid "" -"@kbn/crypto@link:bazel-bin/packages/kbn-crypto/npm_module": +"@kbn/crypto@link:bazel-bin/packages/kbn-crypto": version "0.0.0" uid "" -"@kbn/dev-utils@link:bazel-bin/packages/kbn-dev-utils/npm_module": +"@kbn/dev-utils@link:bazel-bin/packages/kbn-dev-utils": version "0.0.0" uid "" -"@kbn/docs-utils@link:bazel-bin/packages/kbn-docs-utils/npm_module": +"@kbn/docs-utils@link:bazel-bin/packages/kbn-docs-utils": version "0.0.0" uid "" @@ -2643,23 +2643,23 @@ version "0.0.0" uid "" -"@kbn/es@link:bazel-bin/packages/kbn-es/npm_module": +"@kbn/es@link:bazel-bin/packages/kbn-es": version "0.0.0" uid "" -"@kbn/eslint-import-resolver-kibana@link:bazel-bin/packages/kbn-eslint-import-resolver-kibana/npm_module": +"@kbn/eslint-import-resolver-kibana@link:bazel-bin/packages/kbn-eslint-import-resolver-kibana": version "0.0.0" uid "" -"@kbn/eslint-plugin-eslint@link:bazel-bin/packages/kbn-eslint-plugin-eslint/npm_module": +"@kbn/eslint-plugin-eslint@link:bazel-bin/packages/kbn-eslint-plugin-eslint": version "0.0.0" uid "" -"@kbn/expect@link:bazel-bin/packages/kbn-expect/npm_module": +"@kbn/expect@link:bazel-bin/packages/kbn-expect": version "0.0.0" uid "" -"@kbn/i18n@link:bazel-bin/packages/kbn-i18n/npm_module": +"@kbn/i18n@link:bazel-bin/packages/kbn-i18n": version "0.0.0" uid "" @@ -2667,23 +2667,23 @@ version "0.0.0" uid "" -"@kbn/io-ts-utils@link:bazel-bin/packages/kbn-io-ts-utils/npm_module": +"@kbn/io-ts-utils@link:bazel-bin/packages/kbn-io-ts-utils": version "0.0.0" uid "" -"@kbn/legacy-logging@link:bazel-bin/packages/kbn-legacy-logging/npm_module": +"@kbn/legacy-logging@link:bazel-bin/packages/kbn-legacy-logging": version "0.0.0" uid "" -"@kbn/logging@link:bazel-bin/packages/kbn-logging/npm_module": +"@kbn/logging@link:bazel-bin/packages/kbn-logging": version "0.0.0" uid "" -"@kbn/mapbox-gl@link:bazel-bin/packages/kbn-mapbox-gl/npm_module": +"@kbn/mapbox-gl@link:bazel-bin/packages/kbn-mapbox-gl": version "0.0.0" uid "" -"@kbn/monaco@link:bazel-bin/packages/kbn-monaco/npm_module": +"@kbn/monaco@link:bazel-bin/packages/kbn-monaco": version "0.0.0" uid "" @@ -2691,7 +2691,7 @@ version "0.0.0" uid "" -"@kbn/plugin-generator@link:bazel-bin/packages/kbn-plugin-generator/npm_module": +"@kbn/plugin-generator@link:bazel-bin/packages/kbn-plugin-generator": version "0.0.0" uid "" @@ -2707,47 +2707,47 @@ version "0.0.0" uid "" -"@kbn/securitysolution-es-utils@link:bazel-bin/packages/kbn-securitysolution-es-utils/npm_module": +"@kbn/securitysolution-es-utils@link:bazel-bin/packages/kbn-securitysolution-es-utils": version "0.0.0" uid "" -"@kbn/securitysolution-io-ts-alerting-types@link:bazel-bin/packages/kbn-securitysolution-io-ts-alerting-types/npm_module": +"@kbn/securitysolution-io-ts-alerting-types@link:bazel-bin/packages/kbn-securitysolution-io-ts-alerting-types": version "0.0.0" uid "" -"@kbn/securitysolution-io-ts-list-types@link:bazel-bin/packages/kbn-securitysolution-io-ts-list-types/npm_module": +"@kbn/securitysolution-io-ts-list-types@link:bazel-bin/packages/kbn-securitysolution-io-ts-list-types": version "0.0.0" uid "" -"@kbn/securitysolution-io-ts-types@link:bazel-bin/packages/kbn-securitysolution-io-ts-types/npm_module": +"@kbn/securitysolution-io-ts-types@link:bazel-bin/packages/kbn-securitysolution-io-ts-types": version "0.0.0" uid "" -"@kbn/securitysolution-io-ts-utils@link:bazel-bin/packages/kbn-securitysolution-io-ts-utils/npm_module": +"@kbn/securitysolution-io-ts-utils@link:bazel-bin/packages/kbn-securitysolution-io-ts-utils": version "0.0.0" uid "" -"@kbn/securitysolution-list-api@link:bazel-bin/packages/kbn-securitysolution-list-api/npm_module": +"@kbn/securitysolution-list-api@link:bazel-bin/packages/kbn-securitysolution-list-api": version "0.0.0" uid "" -"@kbn/securitysolution-list-constants@link:bazel-bin/packages/kbn-securitysolution-list-constants/npm_module": +"@kbn/securitysolution-list-constants@link:bazel-bin/packages/kbn-securitysolution-list-constants": version "0.0.0" uid "" -"@kbn/securitysolution-list-hooks@link:bazel-bin/packages/kbn-securitysolution-list-hooks/npm_module": +"@kbn/securitysolution-list-hooks@link:bazel-bin/packages/kbn-securitysolution-list-hooks": version "0.0.0" uid "" -"@kbn/securitysolution-list-utils@link:bazel-bin/packages/kbn-securitysolution-list-utils/npm_module": +"@kbn/securitysolution-list-utils@link:bazel-bin/packages/kbn-securitysolution-list-utils": version "0.0.0" uid "" -"@kbn/securitysolution-utils@link:bazel-bin/packages/kbn-securitysolution-utils/npm_module": +"@kbn/securitysolution-utils@link:bazel-bin/packages/kbn-securitysolution-utils": version "0.0.0" uid "" -"@kbn/server-http-tools@link:bazel-bin/packages/kbn-server-http-tools/npm_module": +"@kbn/server-http-tools@link:bazel-bin/packages/kbn-server-http-tools": version "0.0.0" uid "" @@ -2755,7 +2755,7 @@ version "0.0.0" uid "" -"@kbn/std@link:bazel-bin/packages/kbn-std/npm_module": +"@kbn/std@link:bazel-bin/packages/kbn-std": version "0.0.0" uid "" @@ -2763,7 +2763,7 @@ version "0.0.0" uid "" -"@kbn/telemetry-tools@link:bazel-bin/packages/kbn-telemetry-tools/npm_module": +"@kbn/telemetry-tools@link:bazel-bin/packages/kbn-telemetry-tools": version "0.0.0" uid "" @@ -2775,7 +2775,7 @@ version "0.0.0" uid "" -"@kbn/tinymath@link:bazel-bin/packages/kbn-tinymath/npm_module": +"@kbn/tinymath@link:bazel-bin/packages/kbn-tinymath": version "0.0.0" uid "" @@ -2787,11 +2787,11 @@ version "0.0.0" uid "" -"@kbn/utility-types@link:bazel-bin/packages/kbn-utility-types/npm_module": +"@kbn/utility-types@link:bazel-bin/packages/kbn-utility-types": version "0.0.0" uid "" -"@kbn/utils@link:bazel-bin/packages/kbn-utils/npm_module": +"@kbn/utils@link:bazel-bin/packages/kbn-utils": version "0.0.0" uid "" From 843a81ea5182ae51824c7fb0420040248612ac30 Mon Sep 17 00:00:00 2001 From: "Christiane (Tina) Heiligers" Date: Thu, 3 Jun 2021 09:54:37 -0700 Subject: [PATCH 34/90] Splits migrationsv2 actions and unit tests into separate files (#101200) * Splits migrationsv2 actions and unit tests into separate files * Moves actions integration tests --- ...lk_overwrite_transformed_documents.test.ts | 46 + .../bulk_overwrite_transformed_documents.ts | 84 ++ .../migrationsv2/actions/clone_index.test.ts | 60 + .../migrationsv2/actions/clone_index.ts | 141 ++ .../migrationsv2/actions/close_pit.test.ts | 40 + .../migrationsv2/actions/close_pit.ts | 41 + .../migrationsv2/actions/constants.ts | 20 + .../migrationsv2/actions/create_index.test.ts | 59 + .../migrationsv2/actions/create_index.ts | 145 ++ .../actions/fetch_indices.test.ts | 37 + .../migrationsv2/actions/fetch_indices.ts | 49 + .../migrationsv2/actions/index.test.ts | 346 ----- .../migrationsv2/actions/index.ts | 1267 ++--------------- .../integration_tests/actions.test.ts | 14 +- .../migrationsv2/actions/open_pit.test.ts | 40 + .../migrationsv2/actions/open_pit.ts | 43 + .../actions/pickup_updated_mappings.test.ts | 39 + .../actions/pickup_updated_mappings.ts | 57 + .../actions/read_with_pit.test.ts | 45 + .../migrationsv2/actions/read_with_pit.ts | 92 ++ .../actions/refresh_index.test.ts | 42 + .../migrationsv2/actions/refresh_index.ts | 40 + .../migrationsv2/actions/reindex.test.ts | 48 + .../migrationsv2/actions/reindex.ts | 90 ++ .../actions/remove_write_block.test.ts | 53 + .../actions/remove_write_block.ts | 60 + .../search_for_outdated_documents.test.ts | 69 + .../actions/search_for_outdated_documents.ts | 77 + .../actions/set_write_block.test.ts | 52 + .../migrationsv2/actions/set_write_block.ts | 73 + .../migrationsv2/actions/transform_docs.ts | 30 + .../actions/update_aliases.test.ts | 55 + .../migrationsv2/actions/update_aliases.ts | 98 ++ .../update_and_pickup_mappings.test.ts | 45 + .../actions/update_and_pickup_mappings.ts | 80 ++ .../migrationsv2/actions/verify_reindex.ts | 52 + .../wait_for_index_status_yellow.test.ts | 44 + .../actions/wait_for_index_status_yellow.ts | 45 + ...t_for_pickup_updated_mappings_task.test.ts | 59 + .../wait_for_pickup_updated_mappings_task.ts | 43 + .../actions/wait_for_reindex_task.test.ts | 56 + .../actions/wait_for_reindex_task.ts | 65 + .../actions/wait_for_task.test.ts | 47 + .../migrationsv2/actions/wait_for_task.ts | 95 ++ 44 files changed, 2544 insertions(+), 1539 deletions(-) create mode 100644 src/core/server/saved_objects/migrationsv2/actions/bulk_overwrite_transformed_documents.test.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/bulk_overwrite_transformed_documents.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/clone_index.test.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/clone_index.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/close_pit.test.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/close_pit.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/constants.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/create_index.test.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/create_index.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/fetch_indices.test.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/fetch_indices.ts delete mode 100644 src/core/server/saved_objects/migrationsv2/actions/index.test.ts rename src/core/server/saved_objects/migrationsv2/{ => actions}/integration_tests/actions.test.ts (99%) create mode 100644 src/core/server/saved_objects/migrationsv2/actions/open_pit.test.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/open_pit.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/pickup_updated_mappings.test.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/pickup_updated_mappings.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/read_with_pit.test.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/read_with_pit.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/refresh_index.test.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/refresh_index.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/reindex.test.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/reindex.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/remove_write_block.test.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/remove_write_block.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/search_for_outdated_documents.test.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/search_for_outdated_documents.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/set_write_block.test.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/set_write_block.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/transform_docs.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/update_aliases.test.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/update_aliases.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/update_and_pickup_mappings.test.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/update_and_pickup_mappings.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/verify_reindex.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/wait_for_index_status_yellow.test.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/wait_for_index_status_yellow.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/wait_for_pickup_updated_mappings_task.test.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/wait_for_pickup_updated_mappings_task.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/wait_for_reindex_task.test.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/wait_for_reindex_task.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/wait_for_task.test.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/wait_for_task.ts diff --git a/src/core/server/saved_objects/migrationsv2/actions/bulk_overwrite_transformed_documents.test.ts b/src/core/server/saved_objects/migrationsv2/actions/bulk_overwrite_transformed_documents.test.ts new file mode 100644 index 0000000000000..8ff9591798fd4 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/bulk_overwrite_transformed_documents.test.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +import { errors as EsErrors } from '@elastic/elasticsearch'; +jest.mock('./catch_retryable_es_client_errors'); +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { bulkOverwriteTransformedDocuments } from './bulk_overwrite_transformed_documents'; + +describe('bulkOverwriteTransformedDocuments', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Create a mock client that rejects all methods with a 503 status code + // response. + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = bulkOverwriteTransformedDocuments({ + client, + index: 'new_index', + transformedDocs: [], + refresh: 'wait_for', + }); + try { + await task(); + } catch (e) { + /** ignore */ + } + + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/bulk_overwrite_transformed_documents.ts b/src/core/server/saved_objects/migrationsv2/actions/bulk_overwrite_transformed_documents.ts new file mode 100644 index 0000000000000..830a8efccc7eb --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/bulk_overwrite_transformed_documents.ts @@ -0,0 +1,84 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as Either from 'fp-ts/lib/Either'; +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import type { estypes } from '@elastic/elasticsearch'; +import { ElasticsearchClient } from '../../../elasticsearch'; +import type { SavedObjectsRawDoc } from '../../serialization'; +import { + catchRetryableEsClientErrors, + RetryableEsClientError, +} from './catch_retryable_es_client_errors'; +import { WAIT_FOR_ALL_SHARDS_TO_BE_ACTIVE } from './constants'; + +/** @internal */ +export interface BulkOverwriteTransformedDocumentsParams { + client: ElasticsearchClient; + index: string; + transformedDocs: SavedObjectsRawDoc[]; + refresh?: estypes.Refresh; +} +/** + * Write the up-to-date transformed documents to the index, overwriting any + * documents that are still on their outdated version. + */ +export const bulkOverwriteTransformedDocuments = ({ + client, + index, + transformedDocs, + refresh = false, +}: BulkOverwriteTransformedDocumentsParams): TaskEither.TaskEither< + RetryableEsClientError, + 'bulk_index_succeeded' +> => () => { + return client + .bulk({ + // Because we only add aliases in the MARK_VERSION_INDEX_READY step we + // can't bulkIndex to an alias with require_alias=true. This means if + // users tamper during this operation (delete indices or restore a + // snapshot), we could end up auto-creating an index without the correct + // mappings. Such tampering could lead to many other problems and is + // probably unlikely so for now we'll accept this risk and wait till + // system indices puts in place a hard control. + require_alias: false, + wait_for_active_shards: WAIT_FOR_ALL_SHARDS_TO_BE_ACTIVE, + refresh, + filter_path: ['items.*.error'], + body: transformedDocs.flatMap((doc) => { + return [ + { + index: { + _index: index, + _id: doc._id, + // overwrite existing documents + op_type: 'index', + // use optimistic concurrency control to ensure that outdated + // documents are only overwritten once with the latest version + if_seq_no: doc._seq_no, + if_primary_term: doc._primary_term, + }, + }, + doc._source, + ]; + }), + }) + .then((res) => { + // Filter out version_conflict_engine_exception since these just mean + // that another instance already updated these documents + const errors = (res.body.items ?? []).filter( + (item) => item.index?.error?.type !== 'version_conflict_engine_exception' + ); + if (errors.length === 0) { + return Either.right('bulk_index_succeeded' as const); + } else { + throw new Error(JSON.stringify(errors)); + } + }) + .catch(catchRetryableEsClientErrors); +}; diff --git a/src/core/server/saved_objects/migrationsv2/actions/clone_index.test.ts b/src/core/server/saved_objects/migrationsv2/actions/clone_index.test.ts new file mode 100644 index 0000000000000..84b4b00bc7e7f --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/clone_index.test.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { errors as EsErrors } from '@elastic/elasticsearch'; +import { cloneIndex } from './clone_index'; +import { setWriteBlock } from './set_write_block'; +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +jest.mock('./catch_retryable_es_client_errors'); + +describe('cloneIndex', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Create a mock client that rejects all methods with a 503 status code + // response. + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + + const nonRetryableError = new Error('crash'); + const clientWithNonRetryableError = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(nonRetryableError) + ); + + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = cloneIndex({ + client, + source: 'my_source_index', + target: 'my_target_index', + }); + try { + await task(); + } catch (e) { + /** ignore */ + } + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); + + it('re-throws non retry-able errors', async () => { + const task = setWriteBlock({ + client: clientWithNonRetryableError, + index: 'my_index', + }); + await task(); + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(nonRetryableError); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/clone_index.ts b/src/core/server/saved_objects/migrationsv2/actions/clone_index.ts new file mode 100644 index 0000000000000..5674535c80328 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/clone_index.ts @@ -0,0 +1,141 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as Either from 'fp-ts/lib/Either'; +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import { errors as EsErrors } from '@elastic/elasticsearch'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { ElasticsearchClient } from '../../../elasticsearch'; +import { + catchRetryableEsClientErrors, + RetryableEsClientError, +} from './catch_retryable_es_client_errors'; +import type { IndexNotFound, AcknowledgeResponse } from './'; +import { waitForIndexStatusYellow } from './wait_for_index_status_yellow'; +import { + DEFAULT_TIMEOUT, + INDEX_AUTO_EXPAND_REPLICAS, + INDEX_NUMBER_OF_SHARDS, + WAIT_FOR_ALL_SHARDS_TO_BE_ACTIVE, +} from './constants'; +export type CloneIndexResponse = AcknowledgeResponse; + +/** @internal */ +export interface CloneIndexParams { + client: ElasticsearchClient; + source: string; + target: string; + /** only used for testing */ + timeout?: string; +} +/** + * Makes a clone of the source index into the target. + * + * @remarks + * This method adds some additional logic to the ES clone index API: + * - it is idempotent, if it gets called multiple times subsequent calls will + * wait for the first clone operation to complete (up to 60s) + * - the first call will wait up to 120s for the cluster state and all shards + * to be updated. + */ +export const cloneIndex = ({ + client, + source, + target, + timeout = DEFAULT_TIMEOUT, +}: CloneIndexParams): TaskEither.TaskEither< + RetryableEsClientError | IndexNotFound, + CloneIndexResponse +> => { + const cloneTask: TaskEither.TaskEither< + RetryableEsClientError | IndexNotFound, + AcknowledgeResponse + > = () => { + return client.indices + .clone( + { + index: source, + target, + wait_for_active_shards: WAIT_FOR_ALL_SHARDS_TO_BE_ACTIVE, + body: { + settings: { + index: { + // The source we're cloning from will have a write block set, so + // we need to remove it to allow writes to our newly cloned index + 'blocks.write': false, + number_of_shards: INDEX_NUMBER_OF_SHARDS, + auto_expand_replicas: INDEX_AUTO_EXPAND_REPLICAS, + // Set an explicit refresh interval so that we don't inherit the + // value from incorrectly configured index templates (not required + // after we adopt system indices) + refresh_interval: '1s', + // Bump priority so that recovery happens before newer indices + priority: 10, + }, + }, + }, + timeout, + }, + { maxRetries: 0 /** handle retry ourselves for now */ } + ) + .then((res) => { + /** + * - acknowledged=false, we timed out before the cluster state was + * updated with the newly created index, but it probably will be + * created sometime soon. + * - shards_acknowledged=false, we timed out before all shards were + * started + * - acknowledged=true, shards_acknowledged=true, cloning complete + */ + return Either.right({ + acknowledged: res.body.acknowledged, + shardsAcknowledged: res.body.shards_acknowledged, + }); + }) + .catch((error: EsErrors.ResponseError) => { + if (error?.body?.error?.type === 'index_not_found_exception') { + return Either.left({ + type: 'index_not_found_exception' as const, + index: error.body.error.index, + }); + } else if (error?.body?.error?.type === 'resource_already_exists_exception') { + /** + * If the target index already exists it means a previous clone + * operation had already been started. However, we can't be sure + * that all shards were started so return shardsAcknowledged: false + */ + return Either.right({ + acknowledged: true, + shardsAcknowledged: false, + }); + } else { + throw error; + } + }) + .catch(catchRetryableEsClientErrors); + }; + + return pipe( + cloneTask, + TaskEither.chain((res) => { + if (res.acknowledged && res.shardsAcknowledged) { + // If the cluster state was updated and all shards ackd we're done + return TaskEither.right(res); + } else { + // Otherwise, wait until the target index has a 'green' status. + return pipe( + waitForIndexStatusYellow({ client, index: target, timeout }), + TaskEither.map((value) => { + /** When the index status is 'green' we know that all shards were started */ + return { acknowledged: true, shardsAcknowledged: true }; + }) + ); + } + }) + ); +}; diff --git a/src/core/server/saved_objects/migrationsv2/actions/close_pit.test.ts b/src/core/server/saved_objects/migrationsv2/actions/close_pit.test.ts new file mode 100644 index 0000000000000..5d9696239a61e --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/close_pit.test.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +jest.mock('./catch_retryable_es_client_errors'); +import { errors as EsErrors } from '@elastic/elasticsearch'; +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { closePit } from './close_pit'; + +describe('closePit', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Create a mock client that rejects all methods with a 503 status code + // response. + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = closePit({ client, pitId: 'pitId' }); + try { + await task(); + } catch (e) { + /** ignore */ + } + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/close_pit.ts b/src/core/server/saved_objects/migrationsv2/actions/close_pit.ts new file mode 100644 index 0000000000000..d421950c839e2 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/close_pit.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as Either from 'fp-ts/lib/Either'; +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import { ElasticsearchClient } from '../../../elasticsearch'; +import { + catchRetryableEsClientErrors, + RetryableEsClientError, +} from './catch_retryable_es_client_errors'; + +/** @internal */ +export interface ClosePitParams { + client: ElasticsearchClient; + pitId: string; +} +/* + * Closes PIT. + * See https://www.elastic.co/guide/en/elasticsearch/reference/current/point-in-time-api.html + * */ +export const closePit = ({ + client, + pitId, +}: ClosePitParams): TaskEither.TaskEither => () => { + return client + .closePointInTime({ + body: { id: pitId }, + }) + .then((response) => { + if (!response.body.succeeded) { + throw new Error(`Failed to close PointInTime with id: ${pitId}`); + } + return Either.right({}); + }) + .catch(catchRetryableEsClientErrors); +}; diff --git a/src/core/server/saved_objects/migrationsv2/actions/constants.ts b/src/core/server/saved_objects/migrationsv2/actions/constants.ts new file mode 100644 index 0000000000000..5d0d2ffe5d695 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/constants.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +/** + * Batch size for updateByQuery and reindex operations. + * Uses the default value of 1000 for Elasticsearch reindex operation. + */ +export const BATCH_SIZE = 1_000; +export const DEFAULT_TIMEOUT = '60s'; +/** Allocate 1 replica if there are enough data nodes, otherwise continue with 0 */ +export const INDEX_AUTO_EXPAND_REPLICAS = '0-1'; +/** ES rule of thumb: shards should be several GB to 10's of GB, so Kibana is unlikely to cross that limit */ +export const INDEX_NUMBER_OF_SHARDS = 1; +/** Wait for all shards to be active before starting an operation */ +export const WAIT_FOR_ALL_SHARDS_TO_BE_ACTIVE = 'all'; diff --git a/src/core/server/saved_objects/migrationsv2/actions/create_index.test.ts b/src/core/server/saved_objects/migrationsv2/actions/create_index.test.ts new file mode 100644 index 0000000000000..d5d906898943c --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/create_index.test.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +import { errors as EsErrors } from '@elastic/elasticsearch'; +jest.mock('./catch_retryable_es_client_errors'); +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { createIndex } from './create_index'; +import { setWriteBlock } from './set_write_block'; + +describe('createIndex', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Create a mock client that rejects all methods with a 503 status code + // response. + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + + const nonRetryableError = new Error('crash'); + const clientWithNonRetryableError = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(nonRetryableError) + ); + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = createIndex({ + client, + indexName: 'new_index', + mappings: { properties: {} }, + }); + try { + await task(); + } catch (e) { + /** ignore */ + } + + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); + it('re-throws non retry-able errors', async () => { + const task = setWriteBlock({ + client: clientWithNonRetryableError, + index: 'my_index', + }); + await task(); + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(nonRetryableError); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/create_index.ts b/src/core/server/saved_objects/migrationsv2/actions/create_index.ts new file mode 100644 index 0000000000000..47ee44e762db7 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/create_index.ts @@ -0,0 +1,145 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as Either from 'fp-ts/lib/Either'; +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import { pipe } from 'fp-ts/lib/pipeable'; +import type { estypes } from '@elastic/elasticsearch'; +import { AcknowledgeResponse } from './index'; +import { ElasticsearchClient } from '../../../elasticsearch'; +import { IndexMapping } from '../../mappings'; +import { + catchRetryableEsClientErrors, + RetryableEsClientError, +} from './catch_retryable_es_client_errors'; +import { + DEFAULT_TIMEOUT, + INDEX_AUTO_EXPAND_REPLICAS, + WAIT_FOR_ALL_SHARDS_TO_BE_ACTIVE, +} from './constants'; +import { waitForIndexStatusYellow } from './wait_for_index_status_yellow'; + +function aliasArrayToRecord(aliases: string[]): Record { + const result: Record = {}; + for (const alias of aliases) { + result[alias] = {}; + } + return result; +} + +/** @internal */ +export interface CreateIndexParams { + client: ElasticsearchClient; + indexName: string; + mappings: IndexMapping; + aliases?: string[]; +} +/** + * Creates an index with the given mappings + * + * @remarks + * This method adds some additional logic to the ES create index API: + * - it is idempotent, if it gets called multiple times subsequent calls will + * wait for the first create operation to complete (up to 60s) + * - the first call will wait up to 120s for the cluster state and all shards + * to be updated. + */ +export const createIndex = ({ + client, + indexName, + mappings, + aliases = [], +}: CreateIndexParams): TaskEither.TaskEither => { + const createIndexTask: TaskEither.TaskEither< + RetryableEsClientError, + AcknowledgeResponse + > = () => { + const aliasesObject = aliasArrayToRecord(aliases); + + return client.indices + .create( + { + index: indexName, + // wait until all shards are available before creating the index + // (since number_of_shards=1 this does not have any effect atm) + wait_for_active_shards: WAIT_FOR_ALL_SHARDS_TO_BE_ACTIVE, + // Wait up to 60s for the cluster state to update and all shards to be + // started + timeout: DEFAULT_TIMEOUT, + body: { + mappings, + aliases: aliasesObject, + settings: { + index: { + // ES rule of thumb: shards should be several GB to 10's of GB, so + // Kibana is unlikely to cross that limit. + number_of_shards: 1, + auto_expand_replicas: INDEX_AUTO_EXPAND_REPLICAS, + // Set an explicit refresh interval so that we don't inherit the + // value from incorrectly configured index templates (not required + // after we adopt system indices) + refresh_interval: '1s', + // Bump priority so that recovery happens before newer indices + priority: 10, + }, + }, + }, + }, + { maxRetries: 0 /** handle retry ourselves for now */ } + ) + .then((res) => { + /** + * - acknowledged=false, we timed out before the cluster state was + * updated on all nodes with the newly created index, but it + * probably will be created sometime soon. + * - shards_acknowledged=false, we timed out before all shards were + * started + * - acknowledged=true, shards_acknowledged=true, index creation complete + */ + return Either.right({ + acknowledged: res.body.acknowledged, + shardsAcknowledged: res.body.shards_acknowledged, + }); + }) + .catch((error) => { + if (error?.body?.error?.type === 'resource_already_exists_exception') { + /** + * If the target index already exists it means a previous create + * operation had already been started. However, we can't be sure + * that all shards were started so return shardsAcknowledged: false + */ + return Either.right({ + acknowledged: true, + shardsAcknowledged: false, + }); + } else { + throw error; + } + }) + .catch(catchRetryableEsClientErrors); + }; + + return pipe( + createIndexTask, + TaskEither.chain((res) => { + if (res.acknowledged && res.shardsAcknowledged) { + // If the cluster state was updated and all shards ackd we're done + return TaskEither.right('create_index_succeeded'); + } else { + // Otherwise, wait until the target index has a 'yellow' status. + return pipe( + waitForIndexStatusYellow({ client, index: indexName, timeout: DEFAULT_TIMEOUT }), + TaskEither.map(() => { + /** When the index status is 'yellow' we know that all shards were started */ + return 'create_index_succeeded'; + }) + ); + } + }) + ); +}; diff --git a/src/core/server/saved_objects/migrationsv2/actions/fetch_indices.test.ts b/src/core/server/saved_objects/migrationsv2/actions/fetch_indices.test.ts new file mode 100644 index 0000000000000..0dab1728b6ef2 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/fetch_indices.test.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +import { errors as EsErrors } from '@elastic/elasticsearch'; +jest.mock('./catch_retryable_es_client_errors'); +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { fetchIndices } from './fetch_indices'; + +describe('fetchIndices', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = fetchIndices({ client, indices: ['my_index'] }); + try { + await task(); + } catch (e) { + /** ignore */ + } + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/fetch_indices.ts b/src/core/server/saved_objects/migrationsv2/actions/fetch_indices.ts new file mode 100644 index 0000000000000..3847252eb6db1 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/fetch_indices.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import * as Either from 'fp-ts/lib/Either'; +import { IndexMapping } from '../../mappings'; +import { ElasticsearchClient } from '../../../elasticsearch'; +import { + catchRetryableEsClientErrors, + RetryableEsClientError, +} from './catch_retryable_es_client_errors'; +export type FetchIndexResponse = Record< + string, + { aliases: Record; mappings: IndexMapping; settings: unknown } +>; + +/** @internal */ +export interface FetchIndicesParams { + client: ElasticsearchClient; + indices: string[]; +} + +/** + * Fetches information about the given indices including aliases, mappings and + * settings. + */ +export const fetchIndices = ({ + client, + indices, +}: FetchIndicesParams): TaskEither.TaskEither => + // @ts-expect-error @elastic/elasticsearch IndexState.alias and IndexState.mappings should be required + () => { + return client.indices + .get( + { + index: indices, + ignore_unavailable: true, // Don't return an error for missing indices. Note this *will* include closed indices, the docs are misleading https://github.com/elastic/elasticsearch/issues/63607 + }, + { ignore: [404], maxRetries: 0 } + ) + .then(({ body }) => { + return Either.right(body); + }) + .catch(catchRetryableEsClientErrors); + }; diff --git a/src/core/server/saved_objects/migrationsv2/actions/index.test.ts b/src/core/server/saved_objects/migrationsv2/actions/index.test.ts deleted file mode 100644 index 05da335d70884..0000000000000 --- a/src/core/server/saved_objects/migrationsv2/actions/index.test.ts +++ /dev/null @@ -1,346 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import * as Actions from './'; -import { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; -import { errors as EsErrors } from '@elastic/elasticsearch'; -jest.mock('./catch_retryable_es_client_errors'); -import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; -import * as Option from 'fp-ts/lib/Option'; - -describe('actions', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - // Create a mock client that rejects all methods with a 503 status code - // response. - const retryableError = new EsErrors.ResponseError( - elasticsearchClientMock.createApiResponse({ - statusCode: 503, - body: { error: { type: 'es_type', reason: 'es_reason' } }, - }) - ); - const client = elasticsearchClientMock.createInternalClient( - elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) - ); - - const nonRetryableError = new Error('crash'); - const clientWithNonRetryableError = elasticsearchClientMock.createInternalClient( - elasticsearchClientMock.createErrorTransportRequestPromise(nonRetryableError) - ); - - describe('fetchIndices', () => { - it('calls catchRetryableEsClientErrors when the promise rejects', async () => { - const task = Actions.fetchIndices({ client, indices: ['my_index'] }); - try { - await task(); - } catch (e) { - /** ignore */ - } - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); - }); - }); - - describe('setWriteBlock', () => { - it('calls catchRetryableEsClientErrors when the promise rejects', async () => { - const task = Actions.setWriteBlock({ client, index: 'my_index' }); - try { - await task(); - } catch (e) { - /** ignore */ - } - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); - }); - it('re-throws non retry-able errors', async () => { - const task = Actions.setWriteBlock({ - client: clientWithNonRetryableError, - index: 'my_index', - }); - await task(); - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(nonRetryableError); - }); - }); - - describe('cloneIndex', () => { - it('calls catchRetryableEsClientErrors when the promise rejects', async () => { - const task = Actions.cloneIndex({ - client, - source: 'my_source_index', - target: 'my_target_index', - }); - try { - await task(); - } catch (e) { - /** ignore */ - } - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); - }); - it('re-throws non retry-able errors', async () => { - const task = Actions.setWriteBlock({ - client: clientWithNonRetryableError, - index: 'my_index', - }); - await task(); - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(nonRetryableError); - }); - }); - - describe('pickupUpdatedMappings', () => { - it('calls catchRetryableEsClientErrors when the promise rejects', async () => { - const task = Actions.pickupUpdatedMappings(client, 'my_index'); - try { - await task(); - } catch (e) { - /** ignore */ - } - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); - }); - }); - - describe('openPit', () => { - it('calls catchRetryableEsClientErrors when the promise rejects', async () => { - const task = Actions.openPit({ client, index: 'my_index' }); - try { - await task(); - } catch (e) { - /** ignore */ - } - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); - }); - }); - - describe('readWithPit', () => { - it('calls catchRetryableEsClientErrors when the promise rejects', async () => { - const task = Actions.readWithPit({ - client, - pitId: 'pitId', - query: { match_all: {} }, - batchSize: 10_000, - }); - try { - await task(); - } catch (e) { - /** ignore */ - } - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); - }); - }); - - describe('closePit', () => { - it('calls catchRetryableEsClientErrors when the promise rejects', async () => { - const task = Actions.closePit({ client, pitId: 'pitId' }); - try { - await task(); - } catch (e) { - /** ignore */ - } - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); - }); - }); - - describe('reindex', () => { - it('calls catchRetryableEsClientErrors when the promise rejects', async () => { - const task = Actions.reindex({ - client, - sourceIndex: 'my_source_index', - targetIndex: 'my_target_index', - reindexScript: Option.none, - requireAlias: false, - unusedTypesQuery: {}, - }); - try { - await task(); - } catch (e) { - /** ignore */ - } - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); - }); - }); - - describe('waitForReindexTask', () => { - it('calls catchRetryableEsClientErrors when the promise rejects', async () => { - const task = Actions.waitForReindexTask({ client, taskId: 'my task id', timeout: '60s' }); - try { - await task(); - } catch (e) { - /** ignore */ - } - - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); - }); - it('re-throws non retry-able errors', async () => { - const task = Actions.setWriteBlock({ - client: clientWithNonRetryableError, - index: 'my_index', - }); - await task(); - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(nonRetryableError); - }); - }); - - describe('waitForPickupUpdatedMappingsTask', () => { - it('calls catchRetryableEsClientErrors when the promise rejects', async () => { - const task = Actions.waitForPickupUpdatedMappingsTask({ - client, - taskId: 'my task id', - timeout: '60s', - }); - try { - await task(); - } catch (e) { - /** ignore */ - } - - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); - }); - it('re-throws non retry-able errors', async () => { - const task = Actions.setWriteBlock({ - client: clientWithNonRetryableError, - index: 'my_index', - }); - await task(); - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(nonRetryableError); - }); - }); - - describe('updateAliases', () => { - it('calls catchRetryableEsClientErrors when the promise rejects', async () => { - const task = Actions.updateAliases({ client, aliasActions: [] }); - try { - await task(); - } catch (e) { - /** ignore */ - } - - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); - }); - it('re-throws non retry-able errors', async () => { - const task = Actions.setWriteBlock({ - client: clientWithNonRetryableError, - index: 'my_index', - }); - await task(); - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(nonRetryableError); - }); - }); - - describe('createIndex', () => { - it('calls catchRetryableEsClientErrors when the promise rejects', async () => { - const task = Actions.createIndex({ - client, - indexName: 'new_index', - mappings: { properties: {} }, - }); - try { - await task(); - } catch (e) { - /** ignore */ - } - - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); - }); - it('re-throws non retry-able errors', async () => { - const task = Actions.setWriteBlock({ - client: clientWithNonRetryableError, - index: 'my_index', - }); - await task(); - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(nonRetryableError); - }); - }); - - describe('updateAndPickupMappings', () => { - it('calls catchRetryableEsClientErrors when the promise rejects', async () => { - const task = Actions.updateAndPickupMappings({ - client, - index: 'new_index', - mappings: { properties: {} }, - }); - try { - await task(); - } catch (e) { - /** ignore */ - } - - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); - }); - }); - - describe('searchForOutdatedDocuments', () => { - it('calls catchRetryableEsClientErrors when the promise rejects', async () => { - const task = Actions.searchForOutdatedDocuments(client, { - batchSize: 1000, - targetIndex: 'new_index', - outdatedDocumentsQuery: {}, - }); - - try { - await task(); - } catch (e) { - /** ignore */ - } - - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); - }); - - it('configures request according to given parameters', async () => { - const esClient = elasticsearchClientMock.createInternalClient(); - const query = {}; - const targetIndex = 'new_index'; - const batchSize = 1000; - const task = Actions.searchForOutdatedDocuments(esClient, { - batchSize, - targetIndex, - outdatedDocumentsQuery: query, - }); - - await task(); - - expect(esClient.search).toHaveBeenCalledTimes(1); - expect(esClient.search).toHaveBeenCalledWith( - expect.objectContaining({ - index: targetIndex, - size: batchSize, - body: expect.objectContaining({ query }), - }) - ); - }); - }); - - describe('bulkOverwriteTransformedDocuments', () => { - it('calls catchRetryableEsClientErrors when the promise rejects', async () => { - const task = Actions.bulkOverwriteTransformedDocuments({ - client, - index: 'new_index', - transformedDocs: [], - refresh: 'wait_for', - }); - try { - await task(); - } catch (e) { - /** ignore */ - } - - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); - }); - }); - - describe('refreshIndex', () => { - it('calls catchRetryableEsClientErrors when the promise rejects', async () => { - const task = Actions.refreshIndex({ client, targetIndex: 'target_index' }); - try { - await task(); - } catch (e) { - /** ignore */ - } - - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); - }); - }); -}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/index.ts b/src/core/server/saved_objects/migrationsv2/actions/index.ts index 905d64947298e..98d7167ffc31a 100644 --- a/src/core/server/saved_objects/migrationsv2/actions/index.ts +++ b/src/core/server/saved_objects/migrationsv2/actions/index.ts @@ -6,1231 +6,126 @@ * Side Public License, v 1. */ -import * as Either from 'fp-ts/lib/Either'; -import * as TaskEither from 'fp-ts/lib/TaskEither'; -import * as Option from 'fp-ts/lib/Option'; -import type { estypes } from '@elastic/elasticsearch'; -import { errors as EsErrors } from '@elastic/elasticsearch'; -import type { ElasticsearchClientError, ResponseError } from '@elastic/elasticsearch/lib/errors'; -import { pipe } from 'fp-ts/lib/pipeable'; -import { flow } from 'fp-ts/lib/function'; -import { ElasticsearchClient } from '../../../elasticsearch'; -import { IndexMapping } from '../../mappings'; -import type { SavedObjectsRawDoc, SavedObjectsRawDocSource } from '../../serialization'; -import type { TransformRawDocs } from '../types'; -import { - catchRetryableEsClientErrors, - RetryableEsClientError, -} from './catch_retryable_es_client_errors'; -import { - DocumentsTransformFailed, - DocumentsTransformSuccess, -} from '../../migrations/core/migrate_raw_docs'; -export type { RetryableEsClientError }; - -/** - * Batch size for updateByQuery and reindex operations. - * Uses the default value of 1000 for Elasticsearch reindex operation. - */ -const BATCH_SIZE = 1_000; -const DEFAULT_TIMEOUT = '60s'; -/** Allocate 1 replica if there are enough data nodes, otherwise continue with 0 */ -const INDEX_AUTO_EXPAND_REPLICAS = '0-1'; -/** ES rule of thumb: shards should be several GB to 10's of GB, so Kibana is unlikely to cross that limit */ -const INDEX_NUMBER_OF_SHARDS = 1; -/** Wait for all shards to be active before starting an operation */ -const WAIT_FOR_ALL_SHARDS_TO_BE_ACTIVE = 'all'; - -// Map of left response 'type' string -> response interface -export interface ActionErrorTypeMap { - wait_for_task_completion_timeout: WaitForTaskCompletionTimeout; - retryable_es_client_error: RetryableEsClientError; - index_not_found_exception: IndexNotFound; - target_index_had_write_block: TargetIndexHadWriteBlock; - incompatible_mapping_exception: IncompatibleMappingException; - alias_not_found_exception: AliasNotFound; - remove_index_not_a_concrete_index: RemoveIndexNotAConcreteIndex; - documents_transform_failed: DocumentsTransformFailed; -} - -/** - * Type guard for narrowing the type of a left - */ -export function isLeftTypeof( - res: any, - typeString: T -): res is ActionErrorTypeMap[T] { - return res.type === typeString; -} - -export type FetchIndexResponse = Record< - string, - { aliases: Record; mappings: IndexMapping; settings: unknown } ->; +import { RetryableEsClientError } from './catch_retryable_es_client_errors'; +import { DocumentsTransformFailed } from '../../migrations/core/migrate_raw_docs'; -/** @internal */ -export interface FetchIndicesParams { - client: ElasticsearchClient; - indices: string[]; -} - -/** - * Fetches information about the given indices including aliases, mappings and - * settings. - */ -export const fetchIndices = ({ - client, - indices, -}: FetchIndicesParams): TaskEither.TaskEither => - // @ts-expect-error @elastic/elasticsearch IndexState.alias and IndexState.mappings should be required - () => { - return client.indices - .get( - { - index: indices, - ignore_unavailable: true, // Don't return an error for missing indices. Note this *will* include closed indices, the docs are misleading https://github.com/elastic/elasticsearch/issues/63607 - }, - { ignore: [404], maxRetries: 0 } - ) - .then(({ body }) => { - return Either.right(body); - }) - .catch(catchRetryableEsClientErrors); - }; +export { + BATCH_SIZE, + DEFAULT_TIMEOUT, + INDEX_AUTO_EXPAND_REPLICAS, + INDEX_NUMBER_OF_SHARDS, + WAIT_FOR_ALL_SHARDS_TO_BE_ACTIVE, +} from './constants'; -export interface IndexNotFound { - type: 'index_not_found_exception'; - index: string; -} +export type { RetryableEsClientError }; -/** @internal */ -export interface SetWriteBlockParams { - client: ElasticsearchClient; - index: string; -} -/** - * Sets a write block in place for the given index. If the response includes - * `acknowledged: true` all in-progress writes have drained and no further - * writes to this index will be possible. - * - * The first time the write block is added to an index the response will - * include `shards_acknowledged: true` but once the block is in place, - * subsequent calls return `shards_acknowledged: false` - */ -export const setWriteBlock = ({ - client, - index, -}: SetWriteBlockParams): TaskEither.TaskEither< - IndexNotFound | RetryableEsClientError, - 'set_write_block_succeeded' -> => () => { - return ( - client.indices - .addBlock<{ - acknowledged: boolean; - shards_acknowledged: boolean; - }>( - { - index, - block: 'write', - }, - { maxRetries: 0 /** handle retry ourselves for now */ } - ) - // not typed yet - .then((res: any) => { - return res.body.acknowledged === true - ? Either.right('set_write_block_succeeded' as const) - : Either.left({ - type: 'retryable_es_client_error' as const, - message: 'set_write_block_failed', - }); - }) - .catch((e: ElasticsearchClientError) => { - if (e instanceof EsErrors.ResponseError) { - if (e.body?.error?.type === 'index_not_found_exception') { - return Either.left({ type: 'index_not_found_exception' as const, index }); - } - } - throw e; - }) - .catch(catchRetryableEsClientErrors) - ); -}; +// actions/* imports +export type { FetchIndexResponse, FetchIndicesParams } from './fetch_indices'; +export { fetchIndices } from './fetch_indices'; -/** @internal */ -export interface RemoveWriteBlockParams { - client: ElasticsearchClient; - index: string; -} -/** - * Removes a write block from an index - */ -export const removeWriteBlock = ({ - client, - index, -}: RemoveWriteBlockParams): TaskEither.TaskEither< - RetryableEsClientError, - 'remove_write_block_succeeded' -> => () => { - return client.indices - .putSettings<{ - acknowledged: boolean; - shards_acknowledged: boolean; - }>( - { - index, - // Don't change any existing settings - preserve_existing: true, - body: { - index: { - blocks: { - write: false, - }, - }, - }, - }, - { maxRetries: 0 /** handle retry ourselves for now */ } - ) - .then((res) => { - return res.body.acknowledged === true - ? Either.right('remove_write_block_succeeded' as const) - : Either.left({ - type: 'retryable_es_client_error' as const, - message: 'remove_write_block_failed', - }); - }) - .catch(catchRetryableEsClientErrors); -}; +export type { SetWriteBlockParams } from './set_write_block'; +export { setWriteBlock } from './set_write_block'; -/** @internal */ -export interface WaitForIndexStatusYellowParams { - client: ElasticsearchClient; - index: string; - timeout?: string; -} -/** - * A yellow index status means the index's primary shard is allocated and the - * index is ready for searching/indexing documents, but ES wasn't able to - * allocate the replicas. When migrations proceed with a yellow index it means - * we don't have as much data-redundancy as we could have, but waiting for - * replicas would mean that v2 migrations fail where v1 migrations would have - * succeeded. It doesn't feel like it's Kibana's job to force users to keep - * their clusters green and even if it's green when we migrate it can turn - * yellow at any point in the future. So ultimately data-redundancy is up to - * users to maintain. - */ -export const waitForIndexStatusYellow = ({ - client, - index, - timeout = DEFAULT_TIMEOUT, -}: WaitForIndexStatusYellowParams): TaskEither.TaskEither => () => { - return client.cluster - .health({ index, wait_for_status: 'yellow', timeout }) - .then(() => { - return Either.right({}); - }) - .catch(catchRetryableEsClientErrors); -}; +export type { RemoveWriteBlockParams } from './remove_write_block'; +export { removeWriteBlock } from './remove_write_block'; -export type CloneIndexResponse = AcknowledgeResponse; +export type { CloneIndexResponse, CloneIndexParams } from './clone_index'; +export { cloneIndex } from './clone_index'; -/** @internal */ -export interface CloneIndexParams { - client: ElasticsearchClient; - source: string; - target: string; - /** only used for testing */ - timeout?: string; -} -/** - * Makes a clone of the source index into the target. - * - * @remarks - * This method adds some additional logic to the ES clone index API: - * - it is idempotent, if it gets called multiple times subsequent calls will - * wait for the first clone operation to complete (up to 60s) - * - the first call will wait up to 120s for the cluster state and all shards - * to be updated. - */ -export const cloneIndex = ({ - client, - source, - target, - timeout = DEFAULT_TIMEOUT, -}: CloneIndexParams): TaskEither.TaskEither< - RetryableEsClientError | IndexNotFound, - CloneIndexResponse -> => { - const cloneTask: TaskEither.TaskEither< - RetryableEsClientError | IndexNotFound, - AcknowledgeResponse - > = () => { - return client.indices - .clone( - { - index: source, - target, - wait_for_active_shards: WAIT_FOR_ALL_SHARDS_TO_BE_ACTIVE, - body: { - settings: { - index: { - // The source we're cloning from will have a write block set, so - // we need to remove it to allow writes to our newly cloned index - 'blocks.write': false, - number_of_shards: INDEX_NUMBER_OF_SHARDS, - auto_expand_replicas: INDEX_AUTO_EXPAND_REPLICAS, - // Set an explicit refresh interval so that we don't inherit the - // value from incorrectly configured index templates (not required - // after we adopt system indices) - refresh_interval: '1s', - // Bump priority so that recovery happens before newer indices - priority: 10, - }, - }, - }, - timeout, - }, - { maxRetries: 0 /** handle retry ourselves for now */ } - ) - .then((res) => { - /** - * - acknowledged=false, we timed out before the cluster state was - * updated with the newly created index, but it probably will be - * created sometime soon. - * - shards_acknowledged=false, we timed out before all shards were - * started - * - acknowledged=true, shards_acknowledged=true, cloning complete - */ - return Either.right({ - acknowledged: res.body.acknowledged, - shardsAcknowledged: res.body.shards_acknowledged, - }); - }) - .catch((error: EsErrors.ResponseError) => { - if (error?.body?.error?.type === 'index_not_found_exception') { - return Either.left({ - type: 'index_not_found_exception' as const, - index: error.body.error.index, - }); - } else if (error?.body?.error?.type === 'resource_already_exists_exception') { - /** - * If the target index already exists it means a previous clone - * operation had already been started. However, we can't be sure - * that all shards were started so return shardsAcknowledged: false - */ - return Either.right({ - acknowledged: true, - shardsAcknowledged: false, - }); - } else { - throw error; - } - }) - .catch(catchRetryableEsClientErrors); - }; +export type { WaitForIndexStatusYellowParams } from './wait_for_index_status_yellow'; +import { waitForIndexStatusYellow } from './wait_for_index_status_yellow'; - return pipe( - cloneTask, - TaskEither.chain((res) => { - if (res.acknowledged && res.shardsAcknowledged) { - // If the cluster state was updated and all shards ackd we're done - return TaskEither.right(res); - } else { - // Otherwise, wait until the target index has a 'green' status. - return pipe( - waitForIndexStatusYellow({ client, index: target, timeout }), - TaskEither.map((value) => { - /** When the index status is 'green' we know that all shards were started */ - return { acknowledged: true, shardsAcknowledged: true }; - }) - ); - } - }) - ); -}; +export type { WaitForTaskResponse, WaitForTaskCompletionTimeout } from './wait_for_task'; +import { waitForTask, WaitForTaskCompletionTimeout } from './wait_for_task'; -interface WaitForTaskResponse { - error: Option.Option<{ type: string; reason: string; index: string }>; - completed: boolean; - failures: Option.Option; - description?: string; -} +export type { UpdateByQueryResponse } from './pickup_updated_mappings'; +import { pickupUpdatedMappings } from './pickup_updated_mappings'; -/** - * After waiting for the specificed timeout, the task has not yet completed. - * - * When querying the tasks API we use `wait_for_completion=true` to block the - * request until the task completes. If after the `timeout`, the task still has - * not completed we return this error. This does not mean that the task itelf - * has reached a timeout, Elasticsearch will continue to run the task. - */ -export interface WaitForTaskCompletionTimeout { - /** After waiting for the specificed timeout, the task has not yet completed. */ - readonly type: 'wait_for_task_completion_timeout'; - readonly message: string; - readonly error?: Error; -} +export type { OpenPitResponse, OpenPitParams } from './open_pit'; +export { openPit, pitKeepAlive } from './open_pit'; -const catchWaitForTaskCompletionTimeout = ( - e: ResponseError -): Either.Either => { - if ( - e.body?.error?.type === 'timeout_exception' || - e.body?.error?.type === 'receive_timeout_transport_exception' - ) { - return Either.left({ - type: 'wait_for_task_completion_timeout' as const, - message: `[${e.body.error.type}] ${e.body.error.reason}`, - error: e, - }); - } else { - throw e; - } -}; +export type { ReadWithPit, ReadWithPitParams } from './read_with_pit'; +export { readWithPit } from './read_with_pit'; -/** @internal */ -export interface WaitForTaskParams { - client: ElasticsearchClient; - taskId: string; - timeout: string; -} -/** - * Blocks for up to 60s or until a task completes. - * - * TODO: delete completed tasks - */ -const waitForTask = ({ - client, - taskId, - timeout, -}: WaitForTaskParams): TaskEither.TaskEither< - RetryableEsClientError | WaitForTaskCompletionTimeout, - WaitForTaskResponse -> => () => { - return client.tasks - .get({ - task_id: taskId, - wait_for_completion: true, - timeout, - }) - .then((res) => { - const body = res.body; - const failures = body.response?.failures ?? []; - return Either.right({ - completed: body.completed, - // @ts-expect-error @elastic/elasticsearch GetTaskResponse doesn't declare `error` property - error: Option.fromNullable(body.error), - failures: failures.length > 0 ? Option.some(failures) : Option.none, - description: body.task.description, - }); - }) - .catch(catchWaitForTaskCompletionTimeout) - .catch(catchRetryableEsClientErrors); -}; +export type { ClosePitParams } from './close_pit'; +export { closePit } from './close_pit'; -export interface UpdateByQueryResponse { - taskId: string; -} +export type { TransformDocsParams } from './transform_docs'; +export { transformDocs } from './transform_docs'; -/** - * Pickup updated mappings by performing an update by query operation on all - * documents in the index. Returns a task ID which can be - * tracked for progress. - * - * @remarks When mappings are updated to add a field which previously wasn't - * mapped Elasticsearch won't automatically add existing documents to it's - * internal search indices. So search results on this field won't return any - * existing documents. By running an update by query we essentially refresh - * these the internal search indices for all existing documents. - * This action uses `conflicts: 'proceed'` allowing several Kibana instances - * to run this in parallel. - */ -export const pickupUpdatedMappings = ( - client: ElasticsearchClient, - index: string -): TaskEither.TaskEither => () => { - return client - .updateByQuery({ - // Ignore version conflicts that can occur from parallel update by query operations - conflicts: 'proceed', - // Return an error when targeting missing or closed indices - allow_no_indices: false, - index, - // How many documents to update per batch - scroll_size: BATCH_SIZE, - // force a refresh so that we can query the updated index immediately - // after the operation completes - refresh: true, - // Create a task and return task id instead of blocking until complete - wait_for_completion: false, - }) - .then(({ body: { task: taskId } }) => { - return Either.right({ taskId: String(taskId!) }); - }) - .catch(catchRetryableEsClientErrors); -}; +export type { RefreshIndexParams } from './refresh_index'; +export { refreshIndex } from './refresh_index'; -/** @internal */ -export interface OpenPitResponse { - pitId: string; -} +export type { ReindexResponse, ReindexParams } from './reindex'; +export { reindex } from './reindex'; -/** @internal */ -export interface OpenPitParams { - client: ElasticsearchClient; - index: string; -} -// how long ES should keep PIT alive -const pitKeepAlive = '10m'; -/* - * Creates a lightweight view of data when the request has been initiated. - * See https://www.elastic.co/guide/en/elasticsearch/reference/current/point-in-time-api.html - * */ -export const openPit = ({ - client, - index, -}: OpenPitParams): TaskEither.TaskEither => () => { - return client - .openPointInTime({ - index, - keep_alive: pitKeepAlive, - }) - .then((response) => Either.right({ pitId: response.body.id })) - .catch(catchRetryableEsClientErrors); -}; +import type { IncompatibleMappingException } from './wait_for_reindex_task'; +export { waitForReindexTask } from './wait_for_reindex_task'; -/** @internal */ -export interface ReadWithPit { - outdatedDocuments: SavedObjectsRawDoc[]; - readonly lastHitSortValue: number[] | undefined; - readonly totalHits: number | undefined; -} +export type { VerifyReindexParams } from './verify_reindex'; +export { verifyReindex } from './verify_reindex'; -/** @internal */ +import type { AliasNotFound, RemoveIndexNotAConcreteIndex } from './update_aliases'; +export type { AliasAction, UpdateAliasesParams } from './update_aliases'; +export { updateAliases } from './update_aliases'; -export interface ReadWithPitParams { - client: ElasticsearchClient; - pitId: string; - query: estypes.QueryContainer; - batchSize: number; - searchAfter?: number[]; - seqNoPrimaryTerm?: boolean; -} +export type { CreateIndexParams } from './create_index'; +export { createIndex } from './create_index'; -/* - * Requests documents from the index using PIT mechanism. - * */ -export const readWithPit = ({ - client, - pitId, - query, - batchSize, - searchAfter, - seqNoPrimaryTerm, -}: ReadWithPitParams): TaskEither.TaskEither => () => { - return client - .search({ - seq_no_primary_term: seqNoPrimaryTerm, - body: { - // Sort fields are required to use searchAfter - sort: { - // the most efficient option as order is not important for the migration - _shard_doc: { order: 'asc' }, - }, - pit: { id: pitId, keep_alive: pitKeepAlive }, - size: batchSize, - search_after: searchAfter, - /** - * We want to know how many documents we need to process so we can log the progress. - * But we also want to increase the performance of these requests, - * so we ask ES to report the total count only on the first request (when searchAfter does not exist) - */ - track_total_hits: typeof searchAfter === 'undefined', - query, - }, - }) - .then((response) => { - const totalHits = - typeof response.body.hits.total === 'number' - ? response.body.hits.total // This format is to be removed in 8.0 - : response.body.hits.total?.value; - const hits = response.body.hits.hits; +export type { + UpdateAndPickupMappingsResponse, + UpdateAndPickupMappingsParams, +} from './update_and_pickup_mappings'; +export { updateAndPickupMappings } from './update_and_pickup_mappings'; - if (hits.length > 0) { - return Either.right({ - // @ts-expect-error @elastic/elasticsearch _source is optional - outdatedDocuments: hits as SavedObjectsRawDoc[], - lastHitSortValue: hits[hits.length - 1].sort as number[], - totalHits, - }); - } +export { waitForPickupUpdatedMappingsTask } from './wait_for_pickup_updated_mappings_task'; - return Either.right({ - outdatedDocuments: [], - lastHitSortValue: undefined, - totalHits, - }); - }) - .catch(catchRetryableEsClientErrors); -}; +export type { + SearchResponse, + SearchForOutdatedDocumentsOptions, +} from './search_for_outdated_documents'; +export { searchForOutdatedDocuments } from './search_for_outdated_documents'; -/** @internal */ -export interface ClosePitParams { - client: ElasticsearchClient; - pitId: string; -} -/* - * Closes PIT. - * See https://www.elastic.co/guide/en/elasticsearch/reference/current/point-in-time-api.html - * */ -export const closePit = ({ - client, - pitId, -}: ClosePitParams): TaskEither.TaskEither => () => { - return client - .closePointInTime({ - body: { id: pitId }, - }) - .then((response) => { - if (!response.body.succeeded) { - throw new Error(`Failed to close PointInTime with id: ${pitId}`); - } - return Either.right({}); - }) - .catch(catchRetryableEsClientErrors); -}; +export type { BulkOverwriteTransformedDocumentsParams } from './bulk_overwrite_transformed_documents'; +export { bulkOverwriteTransformedDocuments } from './bulk_overwrite_transformed_documents'; -/** @internal */ -export interface TransformDocsParams { - transformRawDocs: TransformRawDocs; - outdatedDocuments: SavedObjectsRawDoc[]; -} -/* - * Transform outdated docs - * */ -export const transformDocs = ({ - transformRawDocs, - outdatedDocuments, -}: TransformDocsParams): TaskEither.TaskEither< - DocumentsTransformFailed, - DocumentsTransformSuccess -> => transformRawDocs(outdatedDocuments); +export { pickupUpdatedMappings, waitForTask, waitForIndexStatusYellow }; +export type { AliasNotFound, RemoveIndexNotAConcreteIndex }; -/** @internal */ -export interface ReindexResponse { - taskId: string; -} - -/** @internal */ -export interface RefreshIndexParams { - client: ElasticsearchClient; - targetIndex: string; -} -/** - * Wait for Elasticsearch to reindex all the changes. - */ -export const refreshIndex = ({ - client, - targetIndex, -}: RefreshIndexParams): TaskEither.TaskEither< - RetryableEsClientError, - { refreshed: boolean } -> => () => { - return client.indices - .refresh({ - index: targetIndex, - }) - .then(() => { - return Either.right({ refreshed: true }); - }) - .catch(catchRetryableEsClientErrors); -}; -/** @internal */ -export interface ReindexParams { - client: ElasticsearchClient; - sourceIndex: string; - targetIndex: string; - reindexScript: Option.Option; - requireAlias: boolean; - /* When reindexing we use a source query to exclude saved objects types which - * are no longer used. These saved objects will still be kept in the outdated - * index for backup purposes, but won't be available in the upgraded index. - */ - unusedTypesQuery: estypes.QueryContainer; +export interface IndexNotFound { + type: 'index_not_found_exception'; + index: string; } -/** - * Reindex documents from the `sourceIndex` into the `targetIndex`. Returns a - * task ID which can be tracked for progress. - * - * @remarks This action is idempotent allowing several Kibana instances to run - * this in parallel. By using `op_type: 'create', conflicts: 'proceed'` there - * will be only one write per reindexed document. - */ -export const reindex = ({ - client, - sourceIndex, - targetIndex, - reindexScript, - requireAlias, - unusedTypesQuery, -}: ReindexParams): TaskEither.TaskEither => () => { - return client - .reindex({ - // Require targetIndex to be an alias. Prevents a new index from being - // created if targetIndex doesn't exist. - require_alias: requireAlias, - body: { - // Ignore version conflicts from existing documents - conflicts: 'proceed', - source: { - index: sourceIndex, - // Set reindex batch size - size: BATCH_SIZE, - // Exclude saved object types - query: unusedTypesQuery, - }, - dest: { - index: targetIndex, - // Don't override existing documents, only create if missing - op_type: 'create', - }, - script: Option.fold( - () => undefined, - (script) => ({ - source: script, - lang: 'painless', - }) - )(reindexScript), - }, - // force a refresh so that we can query the target index - refresh: true, - // Create a task and return task id instead of blocking until complete - wait_for_completion: false, - }) - .then(({ body: { task: taskId } }) => { - return Either.right({ taskId: String(taskId) }); - }) - .catch(catchRetryableEsClientErrors); -}; - -interface WaitForReindexTaskFailure { +export interface WaitForReindexTaskFailure { readonly cause: { type: string; reason: string }; } - -/** @internal */ export interface TargetIndexHadWriteBlock { type: 'target_index_had_write_block'; } -/** @internal */ -export interface IncompatibleMappingException { - type: 'incompatible_mapping_exception'; -} - -export const waitForReindexTask = flow( - waitForTask, - TaskEither.chain( - ( - res - ): TaskEither.TaskEither< - | IndexNotFound - | TargetIndexHadWriteBlock - | IncompatibleMappingException - | RetryableEsClientError - | WaitForTaskCompletionTimeout, - 'reindex_succeeded' - > => { - const failureIsAWriteBlock = ({ cause: { type, reason } }: WaitForReindexTaskFailure) => - type === 'cluster_block_exception' && - reason.match(/index \[.+] blocked by: \[FORBIDDEN\/8\/index write \(api\)\]/); - - const failureIsIncompatibleMappingException = ({ - cause: { type, reason }, - }: WaitForReindexTaskFailure) => - type === 'strict_dynamic_mapping_exception' || type === 'mapper_parsing_exception'; - - if (Option.isSome(res.error)) { - if (res.error.value.type === 'index_not_found_exception') { - return TaskEither.left({ - type: 'index_not_found_exception' as const, - index: res.error.value.index, - }); - } else { - throw new Error('Reindex failed with the following error:\n' + JSON.stringify(res.error)); - } - } else if (Option.isSome(res.failures)) { - if (res.failures.value.every(failureIsAWriteBlock)) { - return TaskEither.left({ type: 'target_index_had_write_block' as const }); - } else if (res.failures.value.every(failureIsIncompatibleMappingException)) { - return TaskEither.left({ type: 'incompatible_mapping_exception' as const }); - } else { - throw new Error( - 'Reindex failed with the following failures:\n' + JSON.stringify(res.failures.value) - ); - } - } else { - return TaskEither.right('reindex_succeeded' as const); - } - } - ) -); - -/** @internal */ -export interface VerifyReindexParams { - client: ElasticsearchClient; - sourceIndex: string; - targetIndex: string; -} - -export const verifyReindex = ({ - client, - sourceIndex, - targetIndex, -}: VerifyReindexParams): TaskEither.TaskEither< - RetryableEsClientError | { type: 'verify_reindex_failed' }, - 'verify_reindex_succeeded' -> => () => { - const count = (index: string) => - client - .count<{ count: number }>({ - index, - // Return an error when targeting missing or closed indices - allow_no_indices: false, - }) - .then((res) => { - return res.body.count; - }); - - return Promise.all([count(sourceIndex), count(targetIndex)]) - .then(([sourceCount, targetCount]) => { - if (targetCount >= sourceCount) { - return Either.right('verify_reindex_succeeded' as const); - } else { - return Either.left({ type: 'verify_reindex_failed' as const }); - } - }) - .catch(catchRetryableEsClientErrors); -}; - -export const waitForPickupUpdatedMappingsTask = flow( - waitForTask, - TaskEither.chain( - ( - res - ): TaskEither.TaskEither< - RetryableEsClientError | WaitForTaskCompletionTimeout, - 'pickup_updated_mappings_succeeded' - > => { - // We don't catch or type failures/errors because they should never - // occur in our migration algorithm and we don't have any business logic - // for dealing with it. If something happens we'll just crash and try - // again. - if (Option.isSome(res.failures)) { - throw new Error( - 'pickupUpdatedMappings task failed with the following failures:\n' + - JSON.stringify(res.failures.value) - ); - } else if (Option.isSome(res.error)) { - throw new Error( - 'pickupUpdatedMappings task failed with the following error:\n' + - JSON.stringify(res.error.value) - ); - } else { - return TaskEither.right('pickup_updated_mappings_succeeded' as const); - } - } - ) -); -export interface AliasNotFound { - type: 'alias_not_found_exception'; -} - -/** @internal */ -export interface RemoveIndexNotAConcreteIndex { - type: 'remove_index_not_a_concrete_index'; -} - -/** @internal */ -export type AliasAction = - | { remove_index: { index: string } } - | { remove: { index: string; alias: string; must_exist: boolean } } - | { add: { index: string; alias: string } }; - -/** @internal */ -export interface UpdateAliasesParams { - client: ElasticsearchClient; - aliasActions: AliasAction[]; -} -/** - * Calls the Update index alias API `_alias` with the provided alias actions. - */ -export const updateAliases = ({ - client, - aliasActions, -}: UpdateAliasesParams): TaskEither.TaskEither< - IndexNotFound | AliasNotFound | RemoveIndexNotAConcreteIndex | RetryableEsClientError, - 'update_aliases_succeeded' -> => () => { - return client.indices - .updateAliases( - { - body: { - actions: aliasActions, - }, - }, - { maxRetries: 0 } - ) - .then(() => { - // Ignore `acknowledged: false`. When the coordinating node accepts - // the new cluster state update but not all nodes have applied the - // update within the timeout `acknowledged` will be false. However, - // retrying this update will always immediately result in `acknowledged: - // true` even if there are still nodes which are falling behind with - // cluster state updates. - // The only impact for using `updateAliases` to mark the version index - // as ready is that it could take longer for other Kibana instances to - // see that the version index is ready so they are more likely to - // perform unecessary duplicate work. - return Either.right('update_aliases_succeeded' as const); - }) - .catch((err: EsErrors.ElasticsearchClientError) => { - if (err instanceof EsErrors.ResponseError) { - if (err?.body?.error?.type === 'index_not_found_exception') { - return Either.left({ - type: 'index_not_found_exception' as const, - index: err.body.error.index, - }); - } else if ( - err?.body?.error?.type === 'illegal_argument_exception' && - err?.body?.error?.reason?.match( - /The provided expression \[.+\] matches an alias, specify the corresponding concrete indices instead./ - ) - ) { - return Either.left({ type: 'remove_index_not_a_concrete_index' as const }); - } else if ( - err?.body?.error?.type === 'aliases_not_found_exception' || - (err?.body?.error?.type === 'resource_not_found_exception' && - err?.body?.error?.reason?.match(/required alias \[.+\] does not exist/)) - ) { - return Either.left({ - type: 'alias_not_found_exception' as const, - }); - } - } - throw err; - }) - .catch(catchRetryableEsClientErrors); -}; - /** @internal */ export interface AcknowledgeResponse { acknowledged: boolean; shardsAcknowledged: boolean; } - -function aliasArrayToRecord(aliases: string[]): Record { - const result: Record = {}; - for (const alias of aliases) { - result[alias] = {}; - } - return result; -} - -/** @internal */ -export interface CreateIndexParams { - client: ElasticsearchClient; - indexName: string; - mappings: IndexMapping; - aliases?: string[]; -} -/** - * Creates an index with the given mappings - * - * @remarks - * This method adds some additional logic to the ES create index API: - * - it is idempotent, if it gets called multiple times subsequent calls will - * wait for the first create operation to complete (up to 60s) - * - the first call will wait up to 120s for the cluster state and all shards - * to be updated. - */ -export const createIndex = ({ - client, - indexName, - mappings, - aliases = [], -}: CreateIndexParams): TaskEither.TaskEither => { - const createIndexTask: TaskEither.TaskEither< - RetryableEsClientError, - AcknowledgeResponse - > = () => { - const aliasesObject = aliasArrayToRecord(aliases); - - return client.indices - .create( - { - index: indexName, - // wait until all shards are available before creating the index - // (since number_of_shards=1 this does not have any effect atm) - wait_for_active_shards: WAIT_FOR_ALL_SHARDS_TO_BE_ACTIVE, - // Wait up to 60s for the cluster state to update and all shards to be - // started - timeout: DEFAULT_TIMEOUT, - body: { - mappings, - aliases: aliasesObject, - settings: { - index: { - // ES rule of thumb: shards should be several GB to 10's of GB, so - // Kibana is unlikely to cross that limit. - number_of_shards: 1, - auto_expand_replicas: INDEX_AUTO_EXPAND_REPLICAS, - // Set an explicit refresh interval so that we don't inherit the - // value from incorrectly configured index templates (not required - // after we adopt system indices) - refresh_interval: '1s', - // Bump priority so that recovery happens before newer indices - priority: 10, - }, - }, - }, - }, - { maxRetries: 0 /** handle retry ourselves for now */ } - ) - .then((res) => { - /** - * - acknowledged=false, we timed out before the cluster state was - * updated on all nodes with the newly created index, but it - * probably will be created sometime soon. - * - shards_acknowledged=false, we timed out before all shards were - * started - * - acknowledged=true, shards_acknowledged=true, index creation complete - */ - return Either.right({ - acknowledged: res.body.acknowledged, - shardsAcknowledged: res.body.shards_acknowledged, - }); - }) - .catch((error) => { - if (error?.body?.error?.type === 'resource_already_exists_exception') { - /** - * If the target index already exists it means a previous create - * operation had already been started. However, we can't be sure - * that all shards were started so return shardsAcknowledged: false - */ - return Either.right({ - acknowledged: true, - shardsAcknowledged: false, - }); - } else { - throw error; - } - }) - .catch(catchRetryableEsClientErrors); - }; - - return pipe( - createIndexTask, - TaskEither.chain((res) => { - if (res.acknowledged && res.shardsAcknowledged) { - // If the cluster state was updated and all shards ackd we're done - return TaskEither.right('create_index_succeeded'); - } else { - // Otherwise, wait until the target index has a 'yellow' status. - return pipe( - waitForIndexStatusYellow({ client, index: indexName, timeout: DEFAULT_TIMEOUT }), - TaskEither.map(() => { - /** When the index status is 'yellow' we know that all shards were started */ - return 'create_index_succeeded'; - }) - ); - } - }) - ); -}; - -/** @internal */ -export interface UpdateAndPickupMappingsResponse { - taskId: string; -} - -/** @internal */ -export interface UpdateAndPickupMappingsParams { - client: ElasticsearchClient; - index: string; - mappings: IndexMapping; -} -/** - * Updates an index's mappings and runs an pickupUpdatedMappings task so that the mapping - * changes are "picked up". Returns a taskId to track progress. - */ -export const updateAndPickupMappings = ({ - client, - index, - mappings, -}: UpdateAndPickupMappingsParams): TaskEither.TaskEither< - RetryableEsClientError, - UpdateAndPickupMappingsResponse -> => { - const putMappingTask: TaskEither.TaskEither< - RetryableEsClientError, - 'update_mappings_succeeded' - > = () => { - return client.indices - .putMapping({ - index, - timeout: DEFAULT_TIMEOUT, - body: mappings, - }) - .then((res) => { - // Ignore `acknowledged: false`. When the coordinating node accepts - // the new cluster state update but not all nodes have applied the - // update within the timeout `acknowledged` will be false. However, - // retrying this update will always immediately result in `acknowledged: - // true` even if there are still nodes which are falling behind with - // cluster state updates. - // For updateAndPickupMappings this means that there is the potential - // that some existing document's fields won't be picked up if the node - // on which the Kibana shard is running has fallen behind with cluster - // state updates and the mapping update wasn't applied before we run - // `pickupUpdatedMappings`. ES tries to limit this risk by blocking - // index operations (including update_by_query used by - // updateAndPickupMappings) if there are pending mappings changes. But - // not all mapping changes will prevent this. - return Either.right('update_mappings_succeeded' as const); - }) - .catch(catchRetryableEsClientErrors); - }; - - return pipe( - putMappingTask, - TaskEither.chain((res) => { - return pickupUpdatedMappings(client, index); - }) - ); -}; - -/** @internal */ -export interface SearchResponse { - outdatedDocuments: SavedObjectsRawDoc[]; -} - -interface SearchForOutdatedDocumentsOptions { - batchSize: number; - targetIndex: string; - outdatedDocumentsQuery?: estypes.QueryContainer; +// Map of left response 'type' string -> response interface +export interface ActionErrorTypeMap { + wait_for_task_completion_timeout: WaitForTaskCompletionTimeout; + retryable_es_client_error: RetryableEsClientError; + index_not_found_exception: IndexNotFound; + target_index_had_write_block: TargetIndexHadWriteBlock; + incompatible_mapping_exception: IncompatibleMappingException; + alias_not_found_exception: AliasNotFound; + remove_index_not_a_concrete_index: RemoveIndexNotAConcreteIndex; + documents_transform_failed: DocumentsTransformFailed; } /** - * Search for outdated saved object documents with the provided query. Will - * return one batch of documents. Searching should be repeated until no more - * outdated documents can be found. - * - * Used for testing only + * Type guard for narrowing the type of a left */ -export const searchForOutdatedDocuments = ( - client: ElasticsearchClient, - options: SearchForOutdatedDocumentsOptions -): TaskEither.TaskEither => () => { - return client - .search({ - index: options.targetIndex, - // Return the _seq_no and _primary_term so we can use optimistic - // concurrency control for updates - seq_no_primary_term: true, - size: options.batchSize, - body: { - query: options.outdatedDocumentsQuery, - // Optimize search performance by sorting by the "natural" index order - sort: ['_doc'], - }, - // Return an error when targeting missing or closed indices - allow_no_indices: false, - // Don't return partial results if timeouts or shard failures are - // encountered. This is important because 0 search hits is interpreted as - // there being no more outdated documents left that require - // transformation. Although the default is `false`, we set this - // explicitly to avoid users overriding the - // search.default_allow_partial_results cluster setting to true. - allow_partial_search_results: false, - // Improve performance by not calculating the total number of hits - // matching the query. - track_total_hits: false, - // Reduce the response payload size by only returning the data we care about - filter_path: [ - 'hits.hits._id', - 'hits.hits._source', - 'hits.hits._seq_no', - 'hits.hits._primary_term', - ], - }) - .then((res) => - Either.right({ outdatedDocuments: (res.body.hits?.hits as SavedObjectsRawDoc[]) ?? [] }) - ) - .catch(catchRetryableEsClientErrors); -}; - -/** @internal */ -export interface BulkOverwriteTransformedDocumentsParams { - client: ElasticsearchClient; - index: string; - transformedDocs: SavedObjectsRawDoc[]; - refresh?: estypes.Refresh; +export function isLeftTypeof( + res: any, + typeString: T +): res is ActionErrorTypeMap[T] { + return res.type === typeString; } -/** - * Write the up-to-date transformed documents to the index, overwriting any - * documents that are still on their outdated version. - */ -export const bulkOverwriteTransformedDocuments = ({ - client, - index, - transformedDocs, - refresh = false, -}: BulkOverwriteTransformedDocumentsParams): TaskEither.TaskEither< - RetryableEsClientError, - 'bulk_index_succeeded' -> => () => { - return client - .bulk({ - // Because we only add aliases in the MARK_VERSION_INDEX_READY step we - // can't bulkIndex to an alias with require_alias=true. This means if - // users tamper during this operation (delete indices or restore a - // snapshot), we could end up auto-creating an index without the correct - // mappings. Such tampering could lead to many other problems and is - // probably unlikely so for now we'll accept this risk and wait till - // system indices puts in place a hard control. - require_alias: false, - wait_for_active_shards: WAIT_FOR_ALL_SHARDS_TO_BE_ACTIVE, - refresh, - filter_path: ['items.*.error'], - body: transformedDocs.flatMap((doc) => { - return [ - { - index: { - _index: index, - _id: doc._id, - // overwrite existing documents - op_type: 'index', - // use optimistic concurrency control to ensure that outdated - // documents are only overwritten once with the latest version - if_seq_no: doc._seq_no, - if_primary_term: doc._primary_term, - }, - }, - doc._source, - ]; - }), - }) - .then((res) => { - // Filter out version_conflict_engine_exception since these just mean - // that another instance already updated these documents - const errors = (res.body.items ?? []).filter( - (item) => item.index?.error?.type !== 'version_conflict_engine_exception' - ); - if (errors.length === 0) { - return Either.right('bulk_index_succeeded' as const); - } else { - throw new Error(JSON.stringify(errors)); - } - }) - .catch(catchRetryableEsClientErrors); -}; diff --git a/src/core/server/saved_objects/migrationsv2/integration_tests/actions.test.ts b/src/core/server/saved_objects/migrationsv2/actions/integration_tests/actions.test.ts similarity index 99% rename from src/core/server/saved_objects/migrationsv2/integration_tests/actions.test.ts rename to src/core/server/saved_objects/migrationsv2/actions/integration_tests/actions.test.ts index 67a2685caf3d6..b508a6198bfb3 100644 --- a/src/core/server/saved_objects/migrationsv2/integration_tests/actions.test.ts +++ b/src/core/server/saved_objects/migrationsv2/actions/integration_tests/actions.test.ts @@ -6,11 +6,11 @@ * Side Public License, v 1. */ -import { ElasticsearchClient } from '../../../'; -import { InternalCoreStart } from '../../../internal_types'; -import * as kbnTestServer from '../../../../test_helpers/kbn_server'; -import { Root } from '../../../root'; -import { SavedObjectsRawDoc } from '../../serialization'; +import { ElasticsearchClient } from '../../../../'; +import { InternalCoreStart } from '../../../../internal_types'; +import * as kbnTestServer from '../../../../../test_helpers/kbn_server'; +import { Root } from '../../../../root'; +import { SavedObjectsRawDoc } from '../../../serialization'; import { bulkOverwriteTransformedDocuments, cloneIndex, @@ -37,11 +37,11 @@ import { removeWriteBlock, transformDocs, waitForIndexStatusYellow, -} from '../actions'; +} from '../../actions'; import * as Either from 'fp-ts/lib/Either'; import * as Option from 'fp-ts/lib/Option'; import { ResponseError } from '@elastic/elasticsearch/lib/errors'; -import { DocumentsTransformFailed, DocumentsTransformSuccess } from '../../migrations/core'; +import { DocumentsTransformFailed, DocumentsTransformSuccess } from '../../../migrations/core'; import { TaskEither } from 'fp-ts/lib/TaskEither'; const { startES } = kbnTestServer.createTestServers({ diff --git a/src/core/server/saved_objects/migrationsv2/actions/open_pit.test.ts b/src/core/server/saved_objects/migrationsv2/actions/open_pit.test.ts new file mode 100644 index 0000000000000..c8fc29d06f42f --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/open_pit.test.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +import { errors as EsErrors } from '@elastic/elasticsearch'; +jest.mock('./catch_retryable_es_client_errors'); +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { openPit } from './open_pit'; + +describe('openPit', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Create a mock client that rejects all methods with a 503 status code + // response. + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = openPit({ client, index: 'my_index' }); + try { + await task(); + } catch (e) { + /** ignore */ + } + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/open_pit.ts b/src/core/server/saved_objects/migrationsv2/actions/open_pit.ts new file mode 100644 index 0000000000000..e740dc00ac27e --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/open_pit.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as Either from 'fp-ts/lib/Either'; +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import { ElasticsearchClient } from '../../../elasticsearch'; +import { + catchRetryableEsClientErrors, + RetryableEsClientError, +} from './catch_retryable_es_client_errors'; +/** @internal */ +export interface OpenPitResponse { + pitId: string; +} + +/** @internal */ +export interface OpenPitParams { + client: ElasticsearchClient; + index: string; +} +// how long ES should keep PIT alive +export const pitKeepAlive = '10m'; +/* + * Creates a lightweight view of data when the request has been initiated. + * See https://www.elastic.co/guide/en/elasticsearch/reference/current/point-in-time-api.html + * */ +export const openPit = ({ + client, + index, +}: OpenPitParams): TaskEither.TaskEither => () => { + return client + .openPointInTime({ + index, + keep_alive: pitKeepAlive, + }) + .then((response) => Either.right({ pitId: response.body.id })) + .catch(catchRetryableEsClientErrors); +}; diff --git a/src/core/server/saved_objects/migrationsv2/actions/pickup_updated_mappings.test.ts b/src/core/server/saved_objects/migrationsv2/actions/pickup_updated_mappings.test.ts new file mode 100644 index 0000000000000..e319d4149dd1a --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/pickup_updated_mappings.test.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { errors as EsErrors } from '@elastic/elasticsearch'; +import { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +jest.mock('./catch_retryable_es_client_errors'); +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { pickupUpdatedMappings } from './pickup_updated_mappings'; + +describe('pickupUpdatedMappings', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Create a mock client that rejects all methods with a 503 status code + // response. + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = pickupUpdatedMappings(client, 'my_index'); + try { + await task(); + } catch (e) { + /** ignore */ + } + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/pickup_updated_mappings.ts b/src/core/server/saved_objects/migrationsv2/actions/pickup_updated_mappings.ts new file mode 100644 index 0000000000000..8cc609e5277bc --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/pickup_updated_mappings.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as Either from 'fp-ts/lib/Either'; +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import { ElasticsearchClient } from '../../../elasticsearch'; +import { + catchRetryableEsClientErrors, + RetryableEsClientError, +} from './catch_retryable_es_client_errors'; +import { BATCH_SIZE } from './constants'; +export interface UpdateByQueryResponse { + taskId: string; +} + +/** + * Pickup updated mappings by performing an update by query operation on all + * documents in the index. Returns a task ID which can be + * tracked for progress. + * + * @remarks When mappings are updated to add a field which previously wasn't + * mapped Elasticsearch won't automatically add existing documents to it's + * internal search indices. So search results on this field won't return any + * existing documents. By running an update by query we essentially refresh + * these the internal search indices for all existing documents. + * This action uses `conflicts: 'proceed'` allowing several Kibana instances + * to run this in parallel. + */ +export const pickupUpdatedMappings = ( + client: ElasticsearchClient, + index: string +): TaskEither.TaskEither => () => { + return client + .updateByQuery({ + // Ignore version conflicts that can occur from parallel update by query operations + conflicts: 'proceed', + // Return an error when targeting missing or closed indices + allow_no_indices: false, + index, + // How many documents to update per batch + scroll_size: BATCH_SIZE, + // force a refresh so that we can query the updated index immediately + // after the operation completes + refresh: true, + // Create a task and return task id instead of blocking until complete + wait_for_completion: false, + }) + .then(({ body: { task: taskId } }) => { + return Either.right({ taskId: String(taskId!) }); + }) + .catch(catchRetryableEsClientErrors); +}; diff --git a/src/core/server/saved_objects/migrationsv2/actions/read_with_pit.test.ts b/src/core/server/saved_objects/migrationsv2/actions/read_with_pit.test.ts new file mode 100644 index 0000000000000..0d8d76b45a57b --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/read_with_pit.test.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +import { errors as EsErrors } from '@elastic/elasticsearch'; +jest.mock('./catch_retryable_es_client_errors'); +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { readWithPit } from './read_with_pit'; + +describe('readWithPit', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Create a mock client that rejects all methods with a 503 status code + // response. + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = readWithPit({ + client, + pitId: 'pitId', + query: { match_all: {} }, + batchSize: 10_000, + }); + try { + await task(); + } catch (e) { + /** ignore */ + } + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/read_with_pit.ts b/src/core/server/saved_objects/migrationsv2/actions/read_with_pit.ts new file mode 100644 index 0000000000000..16f1df05f26b3 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/read_with_pit.ts @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as Either from 'fp-ts/lib/Either'; +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import type { estypes } from '@elastic/elasticsearch'; +import { ElasticsearchClient } from '../../../elasticsearch'; +import type { SavedObjectsRawDoc } from '../../serialization'; +import { + catchRetryableEsClientErrors, + RetryableEsClientError, +} from './catch_retryable_es_client_errors'; +import { pitKeepAlive } from './open_pit'; + +/** @internal */ +export interface ReadWithPit { + outdatedDocuments: SavedObjectsRawDoc[]; + readonly lastHitSortValue: number[] | undefined; + readonly totalHits: number | undefined; +} + +/** @internal */ +export interface ReadWithPitParams { + client: ElasticsearchClient; + pitId: string; + query: estypes.QueryContainer; + batchSize: number; + searchAfter?: number[]; + seqNoPrimaryTerm?: boolean; +} + +/* + * Requests documents from the index using PIT mechanism. + * */ +export const readWithPit = ({ + client, + pitId, + query, + batchSize, + searchAfter, + seqNoPrimaryTerm, +}: ReadWithPitParams): TaskEither.TaskEither => () => { + return client + .search({ + seq_no_primary_term: seqNoPrimaryTerm, + body: { + // Sort fields are required to use searchAfter + sort: { + // the most efficient option as order is not important for the migration + _shard_doc: { order: 'asc' }, + }, + pit: { id: pitId, keep_alive: pitKeepAlive }, + size: batchSize, + search_after: searchAfter, + /** + * We want to know how many documents we need to process so we can log the progress. + * But we also want to increase the performance of these requests, + * so we ask ES to report the total count only on the first request (when searchAfter does not exist) + */ + track_total_hits: typeof searchAfter === 'undefined', + query, + }, + }) + .then((response) => { + const totalHits = + typeof response.body.hits.total === 'number' + ? response.body.hits.total // This format is to be removed in 8.0 + : response.body.hits.total?.value; + const hits = response.body.hits.hits; + + if (hits.length > 0) { + return Either.right({ + // @ts-expect-error @elastic/elasticsearch _source is optional + outdatedDocuments: hits as SavedObjectsRawDoc[], + lastHitSortValue: hits[hits.length - 1].sort as number[], + totalHits, + }); + } + + return Either.right({ + outdatedDocuments: [], + lastHitSortValue: undefined, + totalHits, + }); + }) + .catch(catchRetryableEsClientErrors); +}; diff --git a/src/core/server/saved_objects/migrationsv2/actions/refresh_index.test.ts b/src/core/server/saved_objects/migrationsv2/actions/refresh_index.test.ts new file mode 100644 index 0000000000000..0ebdb2b2b1851 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/refresh_index.test.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +import { errors as EsErrors } from '@elastic/elasticsearch'; +jest.mock('./catch_retryable_es_client_errors'); +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { refreshIndex } from './refresh_index'; + +describe('refreshIndex', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Create a mock client that rejects all methods with a 503 status code + // response. + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = refreshIndex({ client, targetIndex: 'target_index' }); + try { + await task(); + } catch (e) { + /** ignore */ + } + + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/refresh_index.ts b/src/core/server/saved_objects/migrationsv2/actions/refresh_index.ts new file mode 100644 index 0000000000000..e7bcbfb7d2d53 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/refresh_index.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import * as Either from 'fp-ts/lib/Either'; +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import { ElasticsearchClient } from '../../../elasticsearch'; + +import { + catchRetryableEsClientErrors, + RetryableEsClientError, +} from './catch_retryable_es_client_errors'; + +/** @internal */ +export interface RefreshIndexParams { + client: ElasticsearchClient; + targetIndex: string; +} +/** + * Wait for Elasticsearch to reindex all the changes. + */ +export const refreshIndex = ({ + client, + targetIndex, +}: RefreshIndexParams): TaskEither.TaskEither< + RetryableEsClientError, + { refreshed: boolean } +> => () => { + return client.indices + .refresh({ + index: targetIndex, + }) + .then(() => { + return Either.right({ refreshed: true }); + }) + .catch(catchRetryableEsClientErrors); +}; diff --git a/src/core/server/saved_objects/migrationsv2/actions/reindex.test.ts b/src/core/server/saved_objects/migrationsv2/actions/reindex.test.ts new file mode 100644 index 0000000000000..f53368bd9321b --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/reindex.test.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import * as Option from 'fp-ts/lib/Option'; +import { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +import { errors as EsErrors } from '@elastic/elasticsearch'; +jest.mock('./catch_retryable_es_client_errors'); +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { reindex } from './reindex'; + +describe('reindex', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Create a mock client that rejects all methods with a 503 status code + // response. + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = reindex({ + client, + sourceIndex: 'my_source_index', + targetIndex: 'my_target_index', + reindexScript: Option.none, + requireAlias: false, + unusedTypesQuery: {}, + }); + try { + await task(); + } catch (e) { + /** ignore */ + } + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/reindex.ts b/src/core/server/saved_objects/migrationsv2/actions/reindex.ts new file mode 100644 index 0000000000000..ca8d3b594703c --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/reindex.ts @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as Either from 'fp-ts/lib/Either'; +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import * as Option from 'fp-ts/lib/Option'; +import type { estypes } from '@elastic/elasticsearch'; +import { ElasticsearchClient } from '../../../elasticsearch'; +import { + catchRetryableEsClientErrors, + RetryableEsClientError, +} from './catch_retryable_es_client_errors'; +import { BATCH_SIZE } from './constants'; + +/** @internal */ +export interface ReindexResponse { + taskId: string; +} +/** @internal */ +export interface ReindexParams { + client: ElasticsearchClient; + sourceIndex: string; + targetIndex: string; + reindexScript: Option.Option; + requireAlias: boolean; + /* When reindexing we use a source query to exclude saved objects types which + * are no longer used. These saved objects will still be kept in the outdated + * index for backup purposes, but won't be available in the upgraded index. + */ + unusedTypesQuery: estypes.QueryContainer; +} +/** + * Reindex documents from the `sourceIndex` into the `targetIndex`. Returns a + * task ID which can be tracked for progress. + * + * @remarks This action is idempotent allowing several Kibana instances to run + * this in parallel. By using `op_type: 'create', conflicts: 'proceed'` there + * will be only one write per reindexed document. + */ +export const reindex = ({ + client, + sourceIndex, + targetIndex, + reindexScript, + requireAlias, + unusedTypesQuery, +}: ReindexParams): TaskEither.TaskEither => () => { + return client + .reindex({ + // Require targetIndex to be an alias. Prevents a new index from being + // created if targetIndex doesn't exist. + require_alias: requireAlias, + body: { + // Ignore version conflicts from existing documents + conflicts: 'proceed', + source: { + index: sourceIndex, + // Set reindex batch size + size: BATCH_SIZE, + // Exclude saved object types + query: unusedTypesQuery, + }, + dest: { + index: targetIndex, + // Don't override existing documents, only create if missing + op_type: 'create', + }, + script: Option.fold( + () => undefined, + (script) => ({ + source: script, + lang: 'painless', + }) + )(reindexScript), + }, + // force a refresh so that we can query the target index + refresh: true, + // Create a task and return task id instead of blocking until complete + wait_for_completion: false, + }) + .then(({ body: { task: taskId } }) => { + return Either.right({ taskId: String(taskId) }); + }) + .catch(catchRetryableEsClientErrors); +}; diff --git a/src/core/server/saved_objects/migrationsv2/actions/remove_write_block.test.ts b/src/core/server/saved_objects/migrationsv2/actions/remove_write_block.test.ts new file mode 100644 index 0000000000000..497211cb693ab --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/remove_write_block.test.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { removeWriteBlock } from './remove_write_block'; +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +import { errors as EsErrors } from '@elastic/elasticsearch'; +jest.mock('./catch_retryable_es_client_errors'); + +describe('removeWriteBlock', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Create a mock client that rejects all methods with a 503 status code + // response. + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + + const nonRetryableError = new Error('crash'); + const clientWithNonRetryableError = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(nonRetryableError) + ); + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = removeWriteBlock({ client, index: 'my_index' }); + try { + await task(); + } catch (e) { + /** ignore */ + } + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); + it('re-throws non retry-able errors', async () => { + const task = removeWriteBlock({ + client: clientWithNonRetryableError, + index: 'my_index', + }); + await task(); + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(nonRetryableError); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/remove_write_block.ts b/src/core/server/saved_objects/migrationsv2/actions/remove_write_block.ts new file mode 100644 index 0000000000000..c55e4a235fbf1 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/remove_write_block.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as Either from 'fp-ts/lib/Either'; +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import { ElasticsearchClient } from '../../../elasticsearch'; +import { + catchRetryableEsClientErrors, + RetryableEsClientError, +} from './catch_retryable_es_client_errors'; + +/** @internal */ +export interface RemoveWriteBlockParams { + client: ElasticsearchClient; + index: string; +} +/** + * Removes a write block from an index + */ +export const removeWriteBlock = ({ + client, + index, +}: RemoveWriteBlockParams): TaskEither.TaskEither< + RetryableEsClientError, + 'remove_write_block_succeeded' +> => () => { + return client.indices + .putSettings<{ + acknowledged: boolean; + shards_acknowledged: boolean; + }>( + { + index, + // Don't change any existing settings + preserve_existing: true, + body: { + index: { + blocks: { + write: false, + }, + }, + }, + }, + { maxRetries: 0 /** handle retry ourselves for now */ } + ) + .then((res) => { + return res.body.acknowledged === true + ? Either.right('remove_write_block_succeeded' as const) + : Either.left({ + type: 'retryable_es_client_error' as const, + message: 'remove_write_block_failed', + }); + }) + .catch(catchRetryableEsClientErrors); +}; diff --git a/src/core/server/saved_objects/migrationsv2/actions/search_for_outdated_documents.test.ts b/src/core/server/saved_objects/migrationsv2/actions/search_for_outdated_documents.test.ts new file mode 100644 index 0000000000000..ab133e9a564be --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/search_for_outdated_documents.test.ts @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +import { errors as EsErrors } from '@elastic/elasticsearch'; +jest.mock('./catch_retryable_es_client_errors'); +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { searchForOutdatedDocuments } from './search_for_outdated_documents'; + +describe('searchForOutdatedDocuments', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Create a mock client that rejects all methods with a 503 status code + // response. + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = searchForOutdatedDocuments(client, { + batchSize: 1000, + targetIndex: 'new_index', + outdatedDocumentsQuery: {}, + }); + + try { + await task(); + } catch (e) { + /** ignore */ + } + + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); + + it('configures request according to given parameters', async () => { + const esClient = elasticsearchClientMock.createInternalClient(); + const query = {}; + const targetIndex = 'new_index'; + const batchSize = 1000; + const task = searchForOutdatedDocuments(esClient, { + batchSize, + targetIndex, + outdatedDocumentsQuery: query, + }); + + await task(); + + expect(esClient.search).toHaveBeenCalledTimes(1); + expect(esClient.search).toHaveBeenCalledWith( + expect.objectContaining({ + index: targetIndex, + size: batchSize, + body: expect.objectContaining({ query }), + }) + ); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/search_for_outdated_documents.ts b/src/core/server/saved_objects/migrationsv2/actions/search_for_outdated_documents.ts new file mode 100644 index 0000000000000..7406cd35b1593 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/search_for_outdated_documents.ts @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as Either from 'fp-ts/lib/Either'; +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import type { estypes } from '@elastic/elasticsearch'; +import { ElasticsearchClient } from '../../../elasticsearch'; +import type { SavedObjectsRawDoc, SavedObjectsRawDocSource } from '../../serialization'; +import { + catchRetryableEsClientErrors, + RetryableEsClientError, +} from './catch_retryable_es_client_errors'; + +/** @internal */ +export interface SearchResponse { + outdatedDocuments: SavedObjectsRawDoc[]; +} + +export interface SearchForOutdatedDocumentsOptions { + batchSize: number; + targetIndex: string; + outdatedDocumentsQuery?: estypes.QueryContainer; +} + +/** + * Search for outdated saved object documents with the provided query. Will + * return one batch of documents. Searching should be repeated until no more + * outdated documents can be found. + * + * Used for testing only + */ +export const searchForOutdatedDocuments = ( + client: ElasticsearchClient, + options: SearchForOutdatedDocumentsOptions +): TaskEither.TaskEither => () => { + return client + .search({ + index: options.targetIndex, + // Return the _seq_no and _primary_term so we can use optimistic + // concurrency control for updates + seq_no_primary_term: true, + size: options.batchSize, + body: { + query: options.outdatedDocumentsQuery, + // Optimize search performance by sorting by the "natural" index order + sort: ['_doc'], + }, + // Return an error when targeting missing or closed indices + allow_no_indices: false, + // Don't return partial results if timeouts or shard failures are + // encountered. This is important because 0 search hits is interpreted as + // there being no more outdated documents left that require + // transformation. Although the default is `false`, we set this + // explicitly to avoid users overriding the + // search.default_allow_partial_results cluster setting to true. + allow_partial_search_results: false, + // Improve performance by not calculating the total number of hits + // matching the query. + track_total_hits: false, + // Reduce the response payload size by only returning the data we care about + filter_path: [ + 'hits.hits._id', + 'hits.hits._source', + 'hits.hits._seq_no', + 'hits.hits._primary_term', + ], + }) + .then((res) => + Either.right({ outdatedDocuments: (res.body.hits?.hits as SavedObjectsRawDoc[]) ?? [] }) + ) + .catch(catchRetryableEsClientErrors); +}; diff --git a/src/core/server/saved_objects/migrationsv2/actions/set_write_block.test.ts b/src/core/server/saved_objects/migrationsv2/actions/set_write_block.test.ts new file mode 100644 index 0000000000000..cf7b3091f38ff --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/set_write_block.test.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { setWriteBlock } from './set_write_block'; +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +import { errors as EsErrors } from '@elastic/elasticsearch'; +jest.mock('./catch_retryable_es_client_errors'); + +describe('setWriteBlock', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Create a mock client that rejects all methods with a 503 status code + // response. + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + + const nonRetryableError = new Error('crash'); + const clientWithNonRetryableError = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(nonRetryableError) + ); + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = setWriteBlock({ client, index: 'my_index' }); + try { + await task(); + } catch (e) { + /** ignore */ + } + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); + it('re-throws non retry-able errors', async () => { + const task = setWriteBlock({ + client: clientWithNonRetryableError, + index: 'my_index', + }); + await task(); + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(nonRetryableError); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/set_write_block.ts b/src/core/server/saved_objects/migrationsv2/actions/set_write_block.ts new file mode 100644 index 0000000000000..5aed316306cf9 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/set_write_block.ts @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { ElasticsearchClientError } from '@elastic/elasticsearch/lib/errors'; +import * as Either from 'fp-ts/lib/Either'; +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import { errors as EsErrors } from '@elastic/elasticsearch'; +import { ElasticsearchClient } from '../../../elasticsearch'; +import { + catchRetryableEsClientErrors, + RetryableEsClientError, +} from './catch_retryable_es_client_errors'; +import type { IndexNotFound } from './'; + +/** @internal */ +export interface SetWriteBlockParams { + client: ElasticsearchClient; + index: string; +} +/** + * Sets a write block in place for the given index. If the response includes + * `acknowledged: true` all in-progress writes have drained and no further + * writes to this index will be possible. + * + * The first time the write block is added to an index the response will + * include `shards_acknowledged: true` but once the block is in place, + * subsequent calls return `shards_acknowledged: false` + */ +export const setWriteBlock = ({ + client, + index, +}: SetWriteBlockParams): TaskEither.TaskEither< + IndexNotFound | RetryableEsClientError, + 'set_write_block_succeeded' +> => () => { + return ( + client.indices + .addBlock<{ + acknowledged: boolean; + shards_acknowledged: boolean; + }>( + { + index, + block: 'write', + }, + { maxRetries: 0 /** handle retry ourselves for now */ } + ) + // not typed yet + .then((res: any) => { + return res.body.acknowledged === true + ? Either.right('set_write_block_succeeded' as const) + : Either.left({ + type: 'retryable_es_client_error' as const, + message: 'set_write_block_failed', + }); + }) + .catch((e: ElasticsearchClientError) => { + if (e instanceof EsErrors.ResponseError) { + if (e.body?.error?.type === 'index_not_found_exception') { + return Either.left({ type: 'index_not_found_exception' as const, index }); + } + } + throw e; + }) + .catch(catchRetryableEsClientErrors) + ); +}; +// diff --git a/src/core/server/saved_objects/migrationsv2/actions/transform_docs.ts b/src/core/server/saved_objects/migrationsv2/actions/transform_docs.ts new file mode 100644 index 0000000000000..4c712afcff3a4 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/transform_docs.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import type { TransformRawDocs } from '../types'; +import type { SavedObjectsRawDoc } from '../../serialization'; +import { + DocumentsTransformFailed, + DocumentsTransformSuccess, +} from '../../migrations/core/migrate_raw_docs'; + +/** @internal */ +export interface TransformDocsParams { + transformRawDocs: TransformRawDocs; + outdatedDocuments: SavedObjectsRawDoc[]; +} +/* + * Transform outdated docs + * */ +export const transformDocs = ({ + transformRawDocs, + outdatedDocuments, +}: TransformDocsParams): TaskEither.TaskEither< + DocumentsTransformFailed, + DocumentsTransformSuccess +> => transformRawDocs(outdatedDocuments); diff --git a/src/core/server/saved_objects/migrationsv2/actions/update_aliases.test.ts b/src/core/server/saved_objects/migrationsv2/actions/update_aliases.test.ts new file mode 100644 index 0000000000000..e2ea07d40281b --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/update_aliases.test.ts @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +import { errors as EsErrors } from '@elastic/elasticsearch'; +jest.mock('./catch_retryable_es_client_errors'); +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { updateAliases } from './update_aliases'; +import { setWriteBlock } from './set_write_block'; + +describe('updateAliases', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Create a mock client that rejects all methods with a 503 status code + // response. + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + + const nonRetryableError = new Error('crash'); + const clientWithNonRetryableError = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(nonRetryableError) + ); + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = updateAliases({ client, aliasActions: [] }); + try { + await task(); + } catch (e) { + /** ignore */ + } + + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); + it('re-throws non retry-able errors', async () => { + const task = setWriteBlock({ + client: clientWithNonRetryableError, + index: 'my_index', + }); + await task(); + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(nonRetryableError); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/update_aliases.ts b/src/core/server/saved_objects/migrationsv2/actions/update_aliases.ts new file mode 100644 index 0000000000000..ffb8002f09212 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/update_aliases.ts @@ -0,0 +1,98 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as Either from 'fp-ts/lib/Either'; +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import { errors as EsErrors } from '@elastic/elasticsearch'; +import { ElasticsearchClient } from '../../../elasticsearch'; +import { + catchRetryableEsClientErrors, + RetryableEsClientError, +} from './catch_retryable_es_client_errors'; +import { IndexNotFound } from './index'; + +export interface AliasNotFound { + type: 'alias_not_found_exception'; +} + +/** @internal */ +export interface RemoveIndexNotAConcreteIndex { + type: 'remove_index_not_a_concrete_index'; +} + +/** @internal */ +export type AliasAction = + | { remove_index: { index: string } } + | { remove: { index: string; alias: string; must_exist: boolean } } + | { add: { index: string; alias: string } }; + +/** @internal */ +export interface UpdateAliasesParams { + client: ElasticsearchClient; + aliasActions: AliasAction[]; +} +/** + * Calls the Update index alias API `_alias` with the provided alias actions. + */ +export const updateAliases = ({ + client, + aliasActions, +}: UpdateAliasesParams): TaskEither.TaskEither< + IndexNotFound | AliasNotFound | RemoveIndexNotAConcreteIndex | RetryableEsClientError, + 'update_aliases_succeeded' +> => () => { + return client.indices + .updateAliases( + { + body: { + actions: aliasActions, + }, + }, + { maxRetries: 0 } + ) + .then(() => { + // Ignore `acknowledged: false`. When the coordinating node accepts + // the new cluster state update but not all nodes have applied the + // update within the timeout `acknowledged` will be false. However, + // retrying this update will always immediately result in `acknowledged: + // true` even if there are still nodes which are falling behind with + // cluster state updates. + // The only impact for using `updateAliases` to mark the version index + // as ready is that it could take longer for other Kibana instances to + // see that the version index is ready so they are more likely to + // perform unecessary duplicate work. + return Either.right('update_aliases_succeeded' as const); + }) + .catch((err: EsErrors.ElasticsearchClientError) => { + if (err instanceof EsErrors.ResponseError) { + if (err?.body?.error?.type === 'index_not_found_exception') { + return Either.left({ + type: 'index_not_found_exception' as const, + index: err.body.error.index, + }); + } else if ( + err?.body?.error?.type === 'illegal_argument_exception' && + err?.body?.error?.reason?.match( + /The provided expression \[.+\] matches an alias, specify the corresponding concrete indices instead./ + ) + ) { + return Either.left({ type: 'remove_index_not_a_concrete_index' as const }); + } else if ( + err?.body?.error?.type === 'aliases_not_found_exception' || + (err?.body?.error?.type === 'resource_not_found_exception' && + err?.body?.error?.reason?.match(/required alias \[.+\] does not exist/)) + ) { + return Either.left({ + type: 'alias_not_found_exception' as const, + }); + } + } + throw err; + }) + .catch(catchRetryableEsClientErrors); +}; diff --git a/src/core/server/saved_objects/migrationsv2/actions/update_and_pickup_mappings.test.ts b/src/core/server/saved_objects/migrationsv2/actions/update_and_pickup_mappings.test.ts new file mode 100644 index 0000000000000..3ecb990cd9e82 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/update_and_pickup_mappings.test.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +import { errors as EsErrors } from '@elastic/elasticsearch'; +jest.mock('./catch_retryable_es_client_errors'); +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { updateAndPickupMappings } from './update_and_pickup_mappings'; + +describe('updateAndPickupMappings', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Create a mock client that rejects all methods with a 503 status code + // response. + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = updateAndPickupMappings({ + client, + index: 'new_index', + mappings: { properties: {} }, + }); + try { + await task(); + } catch (e) { + /** ignore */ + } + + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/update_and_pickup_mappings.ts b/src/core/server/saved_objects/migrationsv2/actions/update_and_pickup_mappings.ts new file mode 100644 index 0000000000000..8c742005a01ce --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/update_and_pickup_mappings.ts @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as Either from 'fp-ts/lib/Either'; +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { ElasticsearchClient } from '../../../elasticsearch'; +import { IndexMapping } from '../../mappings'; +import { + catchRetryableEsClientErrors, + RetryableEsClientError, +} from './catch_retryable_es_client_errors'; +import { pickupUpdatedMappings } from './pickup_updated_mappings'; +import { DEFAULT_TIMEOUT } from './constants'; + +/** @internal */ +export interface UpdateAndPickupMappingsResponse { + taskId: string; +} + +/** @internal */ +export interface UpdateAndPickupMappingsParams { + client: ElasticsearchClient; + index: string; + mappings: IndexMapping; +} +/** + * Updates an index's mappings and runs an pickupUpdatedMappings task so that the mapping + * changes are "picked up". Returns a taskId to track progress. + */ +export const updateAndPickupMappings = ({ + client, + index, + mappings, +}: UpdateAndPickupMappingsParams): TaskEither.TaskEither< + RetryableEsClientError, + UpdateAndPickupMappingsResponse +> => { + const putMappingTask: TaskEither.TaskEither< + RetryableEsClientError, + 'update_mappings_succeeded' + > = () => { + return client.indices + .putMapping({ + index, + timeout: DEFAULT_TIMEOUT, + body: mappings, + }) + .then((res) => { + // Ignore `acknowledged: false`. When the coordinating node accepts + // the new cluster state update but not all nodes have applied the + // update within the timeout `acknowledged` will be false. However, + // retrying this update will always immediately result in `acknowledged: + // true` even if there are still nodes which are falling behind with + // cluster state updates. + // For updateAndPickupMappings this means that there is the potential + // that some existing document's fields won't be picked up if the node + // on which the Kibana shard is running has fallen behind with cluster + // state updates and the mapping update wasn't applied before we run + // `pickupUpdatedMappings`. ES tries to limit this risk by blocking + // index operations (including update_by_query used by + // updateAndPickupMappings) if there are pending mappings changes. But + // not all mapping changes will prevent this. + return Either.right('update_mappings_succeeded' as const); + }) + .catch(catchRetryableEsClientErrors); + }; + + return pipe( + putMappingTask, + TaskEither.chain((res) => { + return pickupUpdatedMappings(client, index); + }) + ); +}; diff --git a/src/core/server/saved_objects/migrationsv2/actions/verify_reindex.ts b/src/core/server/saved_objects/migrationsv2/actions/verify_reindex.ts new file mode 100644 index 0000000000000..4db599d8fbadf --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/verify_reindex.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as Either from 'fp-ts/lib/Either'; +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import { ElasticsearchClient } from '../../../elasticsearch'; +import { + catchRetryableEsClientErrors, + RetryableEsClientError, +} from './catch_retryable_es_client_errors'; + +/** @internal */ +export interface VerifyReindexParams { + client: ElasticsearchClient; + sourceIndex: string; + targetIndex: string; +} + +export const verifyReindex = ({ + client, + sourceIndex, + targetIndex, +}: VerifyReindexParams): TaskEither.TaskEither< + RetryableEsClientError | { type: 'verify_reindex_failed' }, + 'verify_reindex_succeeded' +> => () => { + const count = (index: string) => + client + .count<{ count: number }>({ + index, + // Return an error when targeting missing or closed indices + allow_no_indices: false, + }) + .then((res) => { + return res.body.count; + }); + + return Promise.all([count(sourceIndex), count(targetIndex)]) + .then(([sourceCount, targetCount]) => { + if (targetCount >= sourceCount) { + return Either.right('verify_reindex_succeeded' as const); + } else { + return Either.left({ type: 'verify_reindex_failed' as const }); + } + }) + .catch(catchRetryableEsClientErrors); +}; diff --git a/src/core/server/saved_objects/migrationsv2/actions/wait_for_index_status_yellow.test.ts b/src/core/server/saved_objects/migrationsv2/actions/wait_for_index_status_yellow.test.ts new file mode 100644 index 0000000000000..8cea34b80ffad --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/wait_for_index_status_yellow.test.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { errors as EsErrors } from '@elastic/elasticsearch'; +import { waitForIndexStatusYellow } from './wait_for_index_status_yellow'; +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +jest.mock('./catch_retryable_es_client_errors'); + +describe('waitForIndexStatusYellow', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Create a mock client that rejects all methods with a 503 status code + // response. + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = waitForIndexStatusYellow({ + client, + index: 'my_index', + }); + try { + await task(); + } catch (e) { + /** ignore */ + } + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/wait_for_index_status_yellow.ts b/src/core/server/saved_objects/migrationsv2/actions/wait_for_index_status_yellow.ts new file mode 100644 index 0000000000000..307c77ee5b89c --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/wait_for_index_status_yellow.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import * as Either from 'fp-ts/lib/Either'; +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import { ElasticsearchClient } from '../../../elasticsearch'; +import { + catchRetryableEsClientErrors, + RetryableEsClientError, +} from './catch_retryable_es_client_errors'; +import { DEFAULT_TIMEOUT } from './constants'; + +/** @internal */ +export interface WaitForIndexStatusYellowParams { + client: ElasticsearchClient; + index: string; + timeout?: string; +} +/** + * A yellow index status means the index's primary shard is allocated and the + * index is ready for searching/indexing documents, but ES wasn't able to + * allocate the replicas. When migrations proceed with a yellow index it means + * we don't have as much data-redundancy as we could have, but waiting for + * replicas would mean that v2 migrations fail where v1 migrations would have + * succeeded. It doesn't feel like it's Kibana's job to force users to keep + * their clusters green and even if it's green when we migrate it can turn + * yellow at any point in the future. So ultimately data-redundancy is up to + * users to maintain. + */ +export const waitForIndexStatusYellow = ({ + client, + index, + timeout = DEFAULT_TIMEOUT, +}: WaitForIndexStatusYellowParams): TaskEither.TaskEither => () => { + return client.cluster + .health({ index, wait_for_status: 'yellow', timeout }) + .then(() => { + return Either.right({}); + }) + .catch(catchRetryableEsClientErrors); +}; diff --git a/src/core/server/saved_objects/migrationsv2/actions/wait_for_pickup_updated_mappings_task.test.ts b/src/core/server/saved_objects/migrationsv2/actions/wait_for_pickup_updated_mappings_task.test.ts new file mode 100644 index 0000000000000..f7c380be9427c --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/wait_for_pickup_updated_mappings_task.test.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +import { errors as EsErrors } from '@elastic/elasticsearch'; +jest.mock('./catch_retryable_es_client_errors'); +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { waitForPickupUpdatedMappingsTask } from './wait_for_pickup_updated_mappings_task'; +import { setWriteBlock } from './set_write_block'; + +describe('waitForPickupUpdatedMappingsTask', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Create a mock client that rejects all methods with a 503 status code + // response. + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + + const nonRetryableError = new Error('crash'); + const clientWithNonRetryableError = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(nonRetryableError) + ); + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = waitForPickupUpdatedMappingsTask({ + client, + taskId: 'my task id', + timeout: '60s', + }); + try { + await task(); + } catch (e) { + /** ignore */ + } + + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); + it('re-throws non retry-able errors', async () => { + const task = setWriteBlock({ + client: clientWithNonRetryableError, + index: 'my_index', + }); + await task(); + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(nonRetryableError); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/wait_for_pickup_updated_mappings_task.ts b/src/core/server/saved_objects/migrationsv2/actions/wait_for_pickup_updated_mappings_task.ts new file mode 100644 index 0000000000000..02f7c3455cec9 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/wait_for_pickup_updated_mappings_task.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import * as Option from 'fp-ts/lib/Option'; +import { flow } from 'fp-ts/lib/function'; +import { waitForTask, WaitForTaskCompletionTimeout } from './wait_for_task'; +import { RetryableEsClientError } from './catch_retryable_es_client_errors'; + +export const waitForPickupUpdatedMappingsTask = flow( + waitForTask, + TaskEither.chain( + ( + res + ): TaskEither.TaskEither< + RetryableEsClientError | WaitForTaskCompletionTimeout, + 'pickup_updated_mappings_succeeded' + > => { + // We don't catch or type failures/errors because they should never + // occur in our migration algorithm and we don't have any business logic + // for dealing with it. If something happens we'll just crash and try + // again. + if (Option.isSome(res.failures)) { + throw new Error( + 'pickupUpdatedMappings task failed with the following failures:\n' + + JSON.stringify(res.failures.value) + ); + } else if (Option.isSome(res.error)) { + throw new Error( + 'pickupUpdatedMappings task failed with the following error:\n' + + JSON.stringify(res.error.value) + ); + } else { + return TaskEither.right('pickup_updated_mappings_succeeded' as const); + } + } + ) +); diff --git a/src/core/server/saved_objects/migrationsv2/actions/wait_for_reindex_task.test.ts b/src/core/server/saved_objects/migrationsv2/actions/wait_for_reindex_task.test.ts new file mode 100644 index 0000000000000..f6a236aab5c85 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/wait_for_reindex_task.test.ts @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +import { errors as EsErrors } from '@elastic/elasticsearch'; +jest.mock('./catch_retryable_es_client_errors'); +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { waitForReindexTask } from './wait_for_reindex_task'; +import { setWriteBlock } from './set_write_block'; + +describe('waitForReindexTask', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Create a mock client that rejects all methods with a 503 status code + // response. + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + + const nonRetryableError = new Error('crash'); + const clientWithNonRetryableError = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(nonRetryableError) + ); + + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = waitForReindexTask({ client, taskId: 'my task id', timeout: '60s' }); + try { + await task(); + } catch (e) { + /** ignore */ + } + + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); + it('re-throws non retry-able errors', async () => { + const task = setWriteBlock({ + client: clientWithNonRetryableError, + index: 'my_index', + }); + await task(); + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(nonRetryableError); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/wait_for_reindex_task.ts b/src/core/server/saved_objects/migrationsv2/actions/wait_for_reindex_task.ts new file mode 100644 index 0000000000000..fcadb5e80298a --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/wait_for_reindex_task.ts @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import * as Option from 'fp-ts/lib/Option'; +import { flow } from 'fp-ts/lib/function'; +import { RetryableEsClientError } from './catch_retryable_es_client_errors'; +import type { IndexNotFound, WaitForReindexTaskFailure, TargetIndexHadWriteBlock } from './index'; +import { waitForTask, WaitForTaskCompletionTimeout } from './wait_for_task'; + +export interface IncompatibleMappingException { + type: 'incompatible_mapping_exception'; +} +export const waitForReindexTask = flow( + waitForTask, + TaskEither.chain( + ( + res + ): TaskEither.TaskEither< + | IndexNotFound + | TargetIndexHadWriteBlock + | IncompatibleMappingException + | RetryableEsClientError + | WaitForTaskCompletionTimeout, + 'reindex_succeeded' + > => { + const failureIsAWriteBlock = ({ cause: { type, reason } }: WaitForReindexTaskFailure) => + type === 'cluster_block_exception' && + reason.match(/index \[.+] blocked by: \[FORBIDDEN\/8\/index write \(api\)\]/); + + const failureIsIncompatibleMappingException = ({ + cause: { type, reason }, + }: WaitForReindexTaskFailure) => + type === 'strict_dynamic_mapping_exception' || type === 'mapper_parsing_exception'; + + if (Option.isSome(res.error)) { + if (res.error.value.type === 'index_not_found_exception') { + return TaskEither.left({ + type: 'index_not_found_exception' as const, + index: res.error.value.index, + }); + } else { + throw new Error('Reindex failed with the following error:\n' + JSON.stringify(res.error)); + } + } else if (Option.isSome(res.failures)) { + if (res.failures.value.every(failureIsAWriteBlock)) { + return TaskEither.left({ type: 'target_index_had_write_block' as const }); + } else if (res.failures.value.every(failureIsIncompatibleMappingException)) { + return TaskEither.left({ type: 'incompatible_mapping_exception' as const }); + } else { + throw new Error( + 'Reindex failed with the following failures:\n' + JSON.stringify(res.failures.value) + ); + } + } else { + return TaskEither.right('reindex_succeeded' as const); + } + } + ) +); diff --git a/src/core/server/saved_objects/migrationsv2/actions/wait_for_task.test.ts b/src/core/server/saved_objects/migrationsv2/actions/wait_for_task.test.ts new file mode 100644 index 0000000000000..c7ca9bf36a2c6 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/wait_for_task.test.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { errors as EsErrors } from '@elastic/elasticsearch'; +import { waitForTask } from './wait_for_task'; +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +jest.mock('./catch_retryable_es_client_errors'); + +describe('waitForTask', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Create a mock client that rejects all methods with a 503 status code + // response. + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + + describe('waitForPickupUpdatedMappingsTask', () => { + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = waitForTask({ + client, + taskId: 'my task id', + timeout: '60s', + }); + try { + await task(); + } catch (e) { + /** ignore */ + } + + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/wait_for_task.ts b/src/core/server/saved_objects/migrationsv2/actions/wait_for_task.ts new file mode 100644 index 0000000000000..4e3631797e34b --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/wait_for_task.ts @@ -0,0 +1,95 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import * as Either from 'fp-ts/lib/Either'; +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import * as Option from 'fp-ts/lib/Option'; +import { errors as EsErrors } from '@elastic/elasticsearch'; +import { ElasticsearchClient } from '../../../elasticsearch'; +import { + catchRetryableEsClientErrors, + RetryableEsClientError, +} from './catch_retryable_es_client_errors'; +/** @internal */ +export interface WaitForTaskResponse { + error: Option.Option<{ type: string; reason: string; index: string }>; + completed: boolean; + failures: Option.Option; + description?: string; +} + +/** + * After waiting for the specificed timeout, the task has not yet completed. + * + * When querying the tasks API we use `wait_for_completion=true` to block the + * request until the task completes. If after the `timeout`, the task still has + * not completed we return this error. This does not mean that the task itelf + * has reached a timeout, Elasticsearch will continue to run the task. + */ +export interface WaitForTaskCompletionTimeout { + /** After waiting for the specificed timeout, the task has not yet completed. */ + readonly type: 'wait_for_task_completion_timeout'; + readonly message: string; + readonly error?: Error; +} + +const catchWaitForTaskCompletionTimeout = ( + e: EsErrors.ResponseError +): Either.Either => { + if ( + e.body?.error?.type === 'timeout_exception' || + e.body?.error?.type === 'receive_timeout_transport_exception' + ) { + return Either.left({ + type: 'wait_for_task_completion_timeout' as const, + message: `[${e.body.error.type}] ${e.body.error.reason}`, + error: e, + }); + } else { + throw e; + } +}; + +/** @internal */ +export interface WaitForTaskParams { + client: ElasticsearchClient; + taskId: string; + timeout: string; +} +/** + * Blocks for up to 60s or until a task completes. + * + * TODO: delete completed tasks + */ +export const waitForTask = ({ + client, + taskId, + timeout, +}: WaitForTaskParams): TaskEither.TaskEither< + RetryableEsClientError | WaitForTaskCompletionTimeout, + WaitForTaskResponse +> => () => { + return client.tasks + .get({ + task_id: taskId, + wait_for_completion: true, + timeout, + }) + .then((res) => { + const body = res.body; + const failures = body.response?.failures ?? []; + return Either.right({ + completed: body.completed, + // @ts-expect-error @elastic/elasticsearch GetTaskResponse doesn't declare `error` property + error: Option.fromNullable(body.error), + failures: failures.length > 0 ? Option.some(failures) : Option.none, + description: body.task.description, + }); + }) + .catch(catchWaitForTaskCompletionTimeout) + .catch(catchRetryableEsClientErrors); +}; From e4f74471ecdbf2723581f38b7a5088360b353338 Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Thu, 3 Jun 2021 12:57:21 -0400 Subject: [PATCH 35/90] [Fleet] Rename config value agents.elasticsearch.host => agents.elasticsearch.hosts (#101162) --- docs/settings/fleet-settings.asciidoc | 4 +- x-pack/plugins/fleet/common/types/index.ts | 2 +- .../fleet/mock/plugin_configuration.ts | 2 +- x-pack/plugins/fleet/server/index.ts | 19 ++++- .../fleet/server/services/output.test.ts | 85 +++++++++++++++++++ .../plugins/fleet/server/services/output.ts | 24 ++++-- 6 files changed, 124 insertions(+), 12 deletions(-) create mode 100644 x-pack/plugins/fleet/server/services/output.test.ts diff --git a/docs/settings/fleet-settings.asciidoc b/docs/settings/fleet-settings.asciidoc index 9c054fbc00222..134d9de3f49d8 100644 --- a/docs/settings/fleet-settings.asciidoc +++ b/docs/settings/fleet-settings.asciidoc @@ -39,8 +39,8 @@ See the {fleet-guide}/index.html[{fleet}] docs for more information. |=== | `xpack.fleet.agents.fleet_server.hosts` | Hostnames used by {agent} for accessing {fleet-server}. -| `xpack.fleet.agents.elasticsearch.host` - | The hostname used by {agent} for accessing {es}. +| `xpack.fleet.agents.elasticsearch.hosts` + | Hostnames used by {agent} for accessing {es}. |=== [NOTE] diff --git a/x-pack/plugins/fleet/common/types/index.ts b/x-pack/plugins/fleet/common/types/index.ts index 7117973baa139..95f91165aaf94 100644 --- a/x-pack/plugins/fleet/common/types/index.ts +++ b/x-pack/plugins/fleet/common/types/index.ts @@ -16,7 +16,7 @@ export interface FleetConfigType { agents: { enabled: boolean; elasticsearch: { - host?: string; + hosts?: string[]; ca_sha256?: string; }; fleet_server?: { diff --git a/x-pack/plugins/fleet/public/applications/fleet/mock/plugin_configuration.ts b/x-pack/plugins/fleet/public/applications/fleet/mock/plugin_configuration.ts index 7f0b71de779dc..a9ad6b1bd8794 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/mock/plugin_configuration.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/mock/plugin_configuration.ts @@ -15,7 +15,7 @@ export const createConfigurationMock = (): FleetConfigType => { agents: { enabled: true, elasticsearch: { - host: '', + hosts: [''], ca_sha256: '', }, }, diff --git a/x-pack/plugins/fleet/server/index.ts b/x-pack/plugins/fleet/server/index.ts index e83617413b744..0a886ffedbd6c 100644 --- a/x-pack/plugins/fleet/server/index.ts +++ b/x-pack/plugins/fleet/server/index.ts @@ -41,6 +41,23 @@ export const config: PluginConfigDescriptor = { unused('agents.pollingRequestTimeout'), unused('agents.tlsCheckDisabled'), unused('agents.fleetServerEnabled'), + (fullConfig, fromPath, addDeprecation) => { + const oldValue = fullConfig?.xpack?.fleet?.agents?.elasticsearch?.host; + if (oldValue) { + delete fullConfig.xpack.fleet.agents.elasticsearch.host; + fullConfig.xpack.fleet.agents.elasticsearch.hosts = [oldValue]; + addDeprecation({ + message: `Config key [xpack.fleet.agents.elasticsearch.host] is deprecated and replaced by [xpack.fleet.agents.elasticsearch.hosts]`, + correctiveActions: { + manualSteps: [ + `Use [xpack.fleet.agents.elasticsearch.hosts] with an array of host instead.`, + ], + }, + }); + } + + return fullConfig; + }, ], schema: schema.object({ enabled: schema.boolean({ defaultValue: true }), @@ -49,7 +66,7 @@ export const config: PluginConfigDescriptor = { agents: schema.object({ enabled: schema.boolean({ defaultValue: true }), elasticsearch: schema.object({ - host: schema.maybe(schema.string()), + hosts: schema.maybe(schema.arrayOf(schema.uri({ scheme: ['http', 'https'] }))), ca_sha256: schema.maybe(schema.string()), }), fleet_server: schema.maybe( diff --git a/x-pack/plugins/fleet/server/services/output.test.ts b/x-pack/plugins/fleet/server/services/output.test.ts new file mode 100644 index 0000000000000..26e3955607ada --- /dev/null +++ b/x-pack/plugins/fleet/server/services/output.test.ts @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { outputService } from './output'; + +import { appContextService } from './app_context'; + +jest.mock('./app_context'); + +const mockedAppContextService = appContextService as jest.Mocked; + +const CLOUD_ID = + 'dXMtZWFzdC0xLmF3cy5mb3VuZC5pbyRjZWM2ZjI2MWE3NGJmMjRjZTMzYmI4ODExYjg0Mjk0ZiRjNmMyY2E2ZDA0MjI0OWFmMGNjN2Q3YTllOTYyNTc0Mw=='; + +const CONFIG_WITH_ES_HOSTS = { + enabled: true, + agents: { + enabled: true, + elasticsearch: { + hosts: ['http://host1.com'], + }, + }, +}; + +const CONFIG_WITHOUT_ES_HOSTS = { + enabled: true, + agents: { + enabled: true, + elasticsearch: {}, + }, +}; + +describe('Output Service', () => { + describe('getDefaultESHosts', () => { + afterEach(() => { + mockedAppContextService.getConfig.mockReset(); + mockedAppContextService.getConfig.mockReset(); + }); + it('Should use cloud ID as the source of truth for ES hosts', () => { + // @ts-expect-error + mockedAppContextService.getCloud.mockReturnValue({ + isCloudEnabled: true, + cloudId: CLOUD_ID, + }); + + mockedAppContextService.getConfig.mockReturnValue(CONFIG_WITH_ES_HOSTS); + + const hosts = outputService.getDefaultESHosts(); + + expect(hosts).toEqual([ + 'https://cec6f261a74bf24ce33bb8811b84294f.us-east-1.aws.found.io:443', + ]); + }); + + it('Should use the value from the config if not in cloud', () => { + // @ts-expect-error + mockedAppContextService.getCloud.mockReturnValue({ + isCloudEnabled: false, + }); + + mockedAppContextService.getConfig.mockReturnValue(CONFIG_WITH_ES_HOSTS); + + const hosts = outputService.getDefaultESHosts(); + + expect(hosts).toEqual(['http://host1.com']); + }); + + it('Should use the default value if there is no config', () => { + // @ts-expect-error + mockedAppContextService.getCloud.mockReturnValue({ + isCloudEnabled: false, + }); + + mockedAppContextService.getConfig.mockReturnValue(CONFIG_WITHOUT_ES_HOSTS); + + const hosts = outputService.getDefaultESHosts(); + + expect(hosts).toEqual(['http://localhost:9200']); + }); + }); +}); diff --git a/x-pack/plugins/fleet/server/services/output.ts b/x-pack/plugins/fleet/server/services/output.ts index b3857ba5c0ef3..0c7b086f78fdf 100644 --- a/x-pack/plugins/fleet/server/services/output.ts +++ b/x-pack/plugins/fleet/server/services/output.ts @@ -16,6 +16,8 @@ import { normalizeHostsForAgents } from './hosts_utils'; const SAVED_OBJECT_TYPE = OUTPUT_SAVED_OBJECT_TYPE; +const DEFAULT_ES_HOSTS = ['http://localhost:9200']; + class OutputService { public async getDefaultOutput(soClient: SavedObjectsClientContract) { return await soClient.find({ @@ -27,17 +29,11 @@ class OutputService { public async ensureDefaultOutput(soClient: SavedObjectsClientContract) { const outputs = await this.getDefaultOutput(soClient); - const cloud = appContextService.getCloud(); - const cloudId = cloud?.isCloudEnabled && cloud.cloudId; - const cloudUrl = cloudId && decodeCloudId(cloudId)?.elasticsearchUrl; - const flagsUrl = appContextService.getConfig()!.agents.elasticsearch.host; - const defaultUrl = 'http://localhost:9200'; - const defaultOutputUrl = cloudUrl || flagsUrl || defaultUrl; if (!outputs.saved_objects.length) { const newDefaultOutput = { ...DEFAULT_OUTPUT, - hosts: [defaultOutputUrl], + hosts: this.getDefaultESHosts(), ca_sha256: appContextService.getConfig()!.agents.elasticsearch.ca_sha256, } as NewOutput; @@ -50,6 +46,20 @@ class OutputService { }; } + public getDefaultESHosts(): string[] { + const cloud = appContextService.getCloud(); + const cloudId = cloud?.isCloudEnabled && cloud.cloudId; + const cloudUrl = cloudId && decodeCloudId(cloudId)?.elasticsearchUrl; + const cloudHosts = cloudUrl ? [cloudUrl] : undefined; + const flagHosts = + appContextService.getConfig()!.agents?.elasticsearch?.hosts && + appContextService.getConfig()!.agents.elasticsearch.hosts?.length + ? appContextService.getConfig()!.agents.elasticsearch.hosts + : undefined; + + return cloudHosts || flagHosts || DEFAULT_ES_HOSTS; + } + public async getDefaultOutputId(soClient: SavedObjectsClientContract) { const outputs = await this.getDefaultOutput(soClient); From 747b80b58ff054ca2a0a00824e7b3911a9268f55 Mon Sep 17 00:00:00 2001 From: Dan Panzarella Date: Thu, 3 Jun 2021 14:51:45 -0400 Subject: [PATCH 36/90] [Security Solution] [OLM] Endpoint pending actions API (#101269) --- .../common/endpoint/constants.ts | 1 + .../common/endpoint/schema/actions.ts | 9 + .../common/endpoint/types/actions.ts | 24 +- .../server/endpoint/routes/actions/index.ts | 18 +- .../endpoint/routes/actions/isolation.test.ts | 1 - .../endpoint/routes/actions/isolation.ts | 8 +- .../server/endpoint/routes/actions/mocks.ts | 140 +++++++++ .../endpoint/routes/actions/status.test.ts | 276 ++++++++++++++++++ .../server/endpoint/routes/actions/status.ts | 128 ++++++++ .../security_solution/server/plugin.ts | 8 +- 10 files changed, 592 insertions(+), 21 deletions(-) create mode 100644 x-pack/plugins/security_solution/server/endpoint/routes/actions/mocks.ts create mode 100644 x-pack/plugins/security_solution/server/endpoint/routes/actions/status.test.ts create mode 100644 x-pack/plugins/security_solution/server/endpoint/routes/actions/status.ts diff --git a/x-pack/plugins/security_solution/common/endpoint/constants.ts b/x-pack/plugins/security_solution/common/endpoint/constants.ts index cdfc34c2e9cda..f4cf85e025237 100644 --- a/x-pack/plugins/security_solution/common/endpoint/constants.ts +++ b/x-pack/plugins/security_solution/common/endpoint/constants.ts @@ -36,3 +36,4 @@ export const UNISOLATE_HOST_ROUTE = `${BASE_ENDPOINT_ROUTE}/unisolate`; /** Endpoint Actions Log Routes */ export const ENDPOINT_ACTION_LOG_ROUTE = `/api/endpoint/action_log/{agent_id}`; +export const ACTION_STATUS_ROUTE = `/api/endpoint/action_status`; diff --git a/x-pack/plugins/security_solution/common/endpoint/schema/actions.ts b/x-pack/plugins/security_solution/common/endpoint/schema/actions.ts index 32affddf46294..09776b57ed8ea 100644 --- a/x-pack/plugins/security_solution/common/endpoint/schema/actions.ts +++ b/x-pack/plugins/security_solution/common/endpoint/schema/actions.ts @@ -28,3 +28,12 @@ export const EndpointActionLogRequestSchema = { agent_id: schema.string(), }), }; + +export const ActionStatusRequestSchema = { + query: schema.object({ + agent_ids: schema.oneOf([ + schema.arrayOf(schema.string({ minLength: 1 }), { minSize: 1, maxSize: 50 }), + schema.string({ minLength: 1 }), + ]), + }), +}; diff --git a/x-pack/plugins/security_solution/common/endpoint/types/actions.ts b/x-pack/plugins/security_solution/common/endpoint/types/actions.ts index fcfda9c9a30d9..3c9be9a823c49 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/actions.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/actions.ts @@ -10,6 +10,11 @@ import { HostIsolationRequestSchema } from '../schema/actions'; export type ISOLATION_ACTIONS = 'isolate' | 'unisolate'; +export interface EndpointActionData { + command: ISOLATION_ACTIONS; + comment?: string; +} + export interface EndpointAction { action_id: string; '@timestamp': string; @@ -18,10 +23,7 @@ export interface EndpointAction { input_type: 'endpoint'; agents: string[]; user_id: string; - data: { - command: ISOLATION_ACTIONS; - comment?: string; - }; + data: EndpointActionData; } export interface EndpointActionResponse { @@ -32,11 +34,8 @@ export interface EndpointActionResponse { agent_id: string; started_at: string; completed_at: string; - error: string; - action_data: { - command: ISOLATION_ACTIONS; - comment?: string; - }; + error?: string; + action_data: EndpointActionData; } export type HostIsolationRequestBody = TypeOf; @@ -44,3 +43,10 @@ export type HostIsolationRequestBody = TypeOf { }, ]) ); - endpointAppContextService.start({ ...startContract, packageService: mockPackageService }); licenseEmitter = new Subject(); licenseService = new LicenseService(); licenseService.start(licenseEmitter); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts index 6842041128465..9dacc9767b88b 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts @@ -117,7 +117,7 @@ export const isolationRequestHandler = function ( const actionID = uuid.v4(); let result; try { - result = await esClient.index({ + result = await esClient.index({ index: AGENT_ACTIONS_INDEX, body: { action_id: actionID, @@ -126,12 +126,12 @@ export const isolationRequestHandler = function ( type: 'INPUT_ACTION', input_type: 'endpoint', agents: agentIDs, - user_id: user?.username, + user_id: user!.username, data: { command: isolate ? 'isolate' : 'unisolate', - comment: req.body.comment, + comment: req.body.comment ?? undefined, }, - } as EndpointAction, + }, }); } catch (e) { return res.customError({ diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/mocks.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/mocks.ts new file mode 100644 index 0000000000000..34f7d140a78de --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/mocks.ts @@ -0,0 +1,140 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable max-classes-per-file */ +/* eslint-disable @typescript-eslint/no-useless-constructor */ + +import { ApiResponse } from '@elastic/elasticsearch'; +import moment from 'moment'; +import uuid from 'uuid'; +import { + EndpointAction, + EndpointActionResponse, + ISOLATION_ACTIONS, +} from '../../../../common/endpoint/types'; + +export const mockSearchResult = (results: any = []): ApiResponse => { + return { + body: { + hits: { + hits: results.map((a: any) => ({ + _source: a, + })), + }, + }, + statusCode: 200, + headers: {}, + warnings: [], + meta: {} as any, + }; +}; + +export class MockAction { + private actionID: string = uuid.v4(); + private ts: moment.Moment = moment(); + private user: string = ''; + private agents: string[] = []; + private command: ISOLATION_ACTIONS = 'isolate'; + private comment?: string; + + constructor() {} + + public build(): EndpointAction { + return { + action_id: this.actionID, + '@timestamp': this.ts.toISOString(), + expiration: this.ts.add(2, 'weeks').toISOString(), + type: 'INPUT_ACTION', + input_type: 'endpoint', + agents: this.agents, + user_id: this.user, + data: { + command: this.command, + comment: this.comment, + }, + }; + } + + public fromUser(u: string) { + this.user = u; + return this; + } + public withAgents(a: string[]) { + this.agents = a; + return this; + } + public withAgent(a: string) { + this.agents = [a]; + return this; + } + public withComment(c: string) { + this.comment = c; + return this; + } + public withAction(a: ISOLATION_ACTIONS) { + this.command = a; + return this; + } + public atTime(m: moment.Moment | Date) { + if (m instanceof Date) { + this.ts = moment(m); + } else { + this.ts = m; + } + return this; + } + public withID(id: string) { + this.actionID = id; + return this; + } +} + +export const aMockAction = (): MockAction => { + return new MockAction(); +}; + +export class MockResponse { + private actionID: string = uuid.v4(); + private ts: moment.Moment = moment(); + private started: moment.Moment = moment(); + private completed: moment.Moment = moment(); + private agent: string = ''; + private command: ISOLATION_ACTIONS = 'isolate'; + private comment?: string; + private error?: string; + + constructor() {} + + public build(): EndpointActionResponse { + return { + '@timestamp': this.ts.toISOString(), + action_id: this.actionID, + agent_id: this.agent, + started_at: this.started.toISOString(), + completed_at: this.completed.toISOString(), + error: this.error, + action_data: { + command: this.command, + comment: this.comment, + }, + }; + } + + public forAction(id: string) { + this.actionID = id; + return this; + } + public forAgent(id: string) { + this.agent = id; + return this; + } +} + +export const aMockResponse = (actionID: string, agentID: string): MockResponse => { + return new MockResponse().forAction(actionID).forAgent(agentID); +}; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/status.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/status.test.ts new file mode 100644 index 0000000000000..62e138ead7f81 --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/status.test.ts @@ -0,0 +1,276 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { KibanaResponseFactory, RequestHandler, RouteConfig } from 'kibana/server'; +import { + elasticsearchServiceMock, + httpServerMock, + httpServiceMock, + loggingSystemMock, + savedObjectsClientMock, +} from 'src/core/server/mocks'; +import { ActionStatusRequestSchema } from '../../../../common/endpoint/schema/actions'; +import { ACTION_STATUS_ROUTE } from '../../../../common/endpoint/constants'; +import { parseExperimentalConfigValue } from '../../../../common/experimental_features'; +import { createMockConfig } from '../../../lib/detection_engine/routes/__mocks__'; +import { EndpointAppContextService } from '../../endpoint_app_context_services'; +import { + createMockEndpointAppContextServiceStartContract, + createRouteHandlerContext, +} from '../../mocks'; +import { registerActionStatusRoutes } from './status'; +import uuid from 'uuid'; +import { aMockAction, aMockResponse, MockAction, mockSearchResult, MockResponse } from './mocks'; + +describe('Endpoint Action Status', () => { + describe('schema', () => { + it('should require at least 1 agent ID', () => { + expect(() => { + ActionStatusRequestSchema.query.validate({}); // no agent_ids provided + }).toThrow(); + }); + + it('should accept a single agent ID', () => { + expect(() => { + ActionStatusRequestSchema.query.validate({ agent_ids: uuid.v4() }); + }).not.toThrow(); + }); + + it('should accept multiple agent IDs', () => { + expect(() => { + ActionStatusRequestSchema.query.validate({ agent_ids: [uuid.v4(), uuid.v4()] }); + }).not.toThrow(); + }); + it('should limit the maximum number of agent IDs', () => { + const tooManyCooks = new Array(200).fill(uuid.v4()); // all the same ID string + expect(() => { + ActionStatusRequestSchema.query.validate({ agent_ids: tooManyCooks }); + }).toThrow(); + }); + }); + + describe('response', () => { + let endpointAppContextService: EndpointAppContextService; + + // convenience for calling the route and handler for action status + let getPendingStatus: (reqParams?: any) => Promise>; + // convenience for injecting mock responses for actions index and responses + let havingActionsAndResponses: (actions: MockAction[], responses: any[]) => void; + + beforeEach(() => { + const esClientMock = elasticsearchServiceMock.createScopedClusterClient(); + const routerMock = httpServiceMock.createRouter(); + endpointAppContextService = new EndpointAppContextService(); + endpointAppContextService.start(createMockEndpointAppContextServiceStartContract()); + + registerActionStatusRoutes(routerMock, { + logFactory: loggingSystemMock.create(), + service: endpointAppContextService, + config: () => Promise.resolve(createMockConfig()), + experimentalFeatures: parseExperimentalConfigValue(createMockConfig().enableExperimental), + }); + + getPendingStatus = async (reqParams?: any): Promise> => { + const req = httpServerMock.createKibanaRequest(reqParams); + const mockResponse = httpServerMock.createResponseFactory(); + const [, routeHandler]: [ + RouteConfig, + RequestHandler + ] = routerMock.get.mock.calls.find(([{ path }]) => path.startsWith(ACTION_STATUS_ROUTE))!; + await routeHandler( + createRouteHandlerContext(esClientMock, savedObjectsClientMock.create()), + req, + mockResponse + ); + + return mockResponse; + }; + + havingActionsAndResponses = (actions: MockAction[], responses: MockResponse[]) => { + esClientMock.asCurrentUser.search = jest + .fn() + .mockImplementationOnce(() => + Promise.resolve(mockSearchResult(actions.map((a) => a.build()))) + ) + .mockImplementationOnce(() => + Promise.resolve(mockSearchResult(responses.map((r) => r.build()))) + ); + }; + }); + + afterEach(() => { + endpointAppContextService.stop(); + }); + + it('should include agent IDs in the output, even if they have no actions', async () => { + const mockID = 'XYZABC-000'; + havingActionsAndResponses([], []); + const response = await getPendingStatus({ + query: { + agent_ids: [mockID], + }, + }); + expect(response.ok).toBeCalled(); + expect((response.ok.mock.calls[0][0]?.body as any)?.data).toHaveLength(1); + expect((response.ok.mock.calls[0][0]?.body as any)?.data[0].agent_id).toEqual(mockID); + }); + + it('should respond with a valid pending action', async () => { + const mockID = 'XYZABC-000'; + havingActionsAndResponses([aMockAction().withAgent(mockID)], []); + const response = await getPendingStatus({ + query: { + agent_ids: [mockID], + }, + }); + expect(response.ok).toBeCalled(); + expect((response.ok.mock.calls[0][0]?.body as any)?.data).toHaveLength(1); + expect((response.ok.mock.calls[0][0]?.body as any)?.data[0].agent_id).toEqual(mockID); + }); + it('should include a total count of a pending action', async () => { + const mockID = 'XYZABC-000'; + havingActionsAndResponses( + [ + aMockAction().withAgent(mockID).withAction('isolate'), + aMockAction().withAgent(mockID).withAction('isolate'), + ], + [] + ); + const response = await getPendingStatus({ + query: { + agent_ids: [mockID], + }, + }); + expect(response.ok).toBeCalled(); + expect((response.ok.mock.calls[0][0]?.body as any)?.data).toHaveLength(1); + expect((response.ok.mock.calls[0][0]?.body as any)?.data[0].agent_id).toEqual(mockID); + expect((response.ok.mock.calls[0][0]?.body as any)?.data[0].pending_actions.isolate).toEqual( + 2 + ); + }); + it('should show multiple pending actions, and their counts', async () => { + const mockID = 'XYZABC-000'; + havingActionsAndResponses( + [ + aMockAction().withAgent(mockID).withAction('isolate'), + aMockAction().withAgent(mockID).withAction('isolate'), + aMockAction().withAgent(mockID).withAction('isolate'), + aMockAction().withAgent(mockID).withAction('unisolate'), + aMockAction().withAgent(mockID).withAction('unisolate'), + ], + [] + ); + const response = await getPendingStatus({ + query: { + agent_ids: [mockID], + }, + }); + expect(response.ok).toBeCalled(); + expect((response.ok.mock.calls[0][0]?.body as any)?.data).toHaveLength(1); + expect((response.ok.mock.calls[0][0]?.body as any)?.data[0].agent_id).toEqual(mockID); + expect((response.ok.mock.calls[0][0]?.body as any)?.data[0].pending_actions.isolate).toEqual( + 3 + ); + expect( + (response.ok.mock.calls[0][0]?.body as any)?.data[0].pending_actions.unisolate + ).toEqual(2); + }); + it('should calculate correct pending counts from grouped/bulked actions', async () => { + const mockID = 'XYZABC-000'; + havingActionsAndResponses( + [ + aMockAction() + .withAgents([mockID, 'IRRELEVANT-OTHER-AGENT', 'ANOTHER-POSSIBLE-AGENT']) + .withAction('isolate'), + aMockAction().withAgents([mockID, 'YET-ANOTHER-AGENT-ID']).withAction('isolate'), + aMockAction().withAgents(['YET-ANOTHER-AGENT-ID']).withAction('isolate'), // one WITHOUT our agent-under-test + ], + [] + ); + const response = await getPendingStatus({ + query: { + agent_ids: [mockID], + }, + }); + expect(response.ok).toBeCalled(); + expect((response.ok.mock.calls[0][0]?.body as any)?.data).toHaveLength(1); + expect((response.ok.mock.calls[0][0]?.body as any)?.data[0].agent_id).toEqual(mockID); + expect((response.ok.mock.calls[0][0]?.body as any)?.data[0].pending_actions.isolate).toEqual( + 2 + ); + }); + + it('should exclude actions that have responses from the pending count', async () => { + const mockAgentID = 'XYZABC-000'; + const actionID = 'some-known-actionid'; + havingActionsAndResponses( + [ + aMockAction().withAgent(mockAgentID).withAction('isolate'), + aMockAction().withAgent(mockAgentID).withAction('isolate').withID(actionID), + ], + [aMockResponse(actionID, mockAgentID)] + ); + const response = await getPendingStatus({ + query: { + agent_ids: [mockAgentID], + }, + }); + expect(response.ok).toBeCalled(); + expect((response.ok.mock.calls[0][0]?.body as any)?.data).toHaveLength(1); + expect((response.ok.mock.calls[0][0]?.body as any)?.data[0].agent_id).toEqual(mockAgentID); + expect((response.ok.mock.calls[0][0]?.body as any)?.data[0].pending_actions.isolate).toEqual( + 1 + ); + }); + + it('should have accurate counts for multiple agents, bulk actions, and responses', async () => { + const agentOne = 'XYZABC-000'; + const agentTwo = 'DEADBEEF'; + const agentThree = 'IDIDIDID'; + + const actionTwoID = 'ID-TWO'; + havingActionsAndResponses( + [ + aMockAction().withAgents([agentOne, agentTwo, agentThree]).withAction('isolate'), + aMockAction() + .withAgents([agentTwo, agentThree]) + .withAction('isolate') + .withID(actionTwoID), + aMockAction().withAgents([agentThree]).withAction('isolate'), + ], + [aMockResponse(actionTwoID, agentThree)] + ); + const response = await getPendingStatus({ + query: { + agent_ids: [agentOne, agentTwo, agentThree], + }, + }); + expect(response.ok).toBeCalled(); + expect((response.ok.mock.calls[0][0]?.body as any)?.data).toHaveLength(3); + expect((response.ok.mock.calls[0][0]?.body as any)?.data).toContainEqual({ + agent_id: agentOne, + pending_actions: { + isolate: 1, + }, + }); + expect((response.ok.mock.calls[0][0]?.body as any)?.data).toContainEqual({ + agent_id: agentTwo, + pending_actions: { + isolate: 2, + }, + }); + expect((response.ok.mock.calls[0][0]?.body as any)?.data).toContainEqual({ + agent_id: agentThree, + pending_actions: { + isolate: 2, // present in all three actions, but second one has a response, therefore not pending + }, + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/status.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/status.ts new file mode 100644 index 0000000000000..faaf41962a96c --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/status.ts @@ -0,0 +1,128 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { RequestHandler } from 'kibana/server'; +import { TypeOf } from '@kbn/config-schema'; +import { + EndpointAction, + EndpointActionResponse, + PendingActionsResponse, +} from '../../../../common/endpoint/types'; +import { AGENT_ACTIONS_INDEX } from '../../../../../fleet/common'; +import { ActionStatusRequestSchema } from '../../../../common/endpoint/schema/actions'; +import { ACTION_STATUS_ROUTE } from '../../../../common/endpoint/constants'; +import { + SecuritySolutionPluginRouter, + SecuritySolutionRequestHandlerContext, +} from '../../../types'; +import { EndpointAppContext } from '../../types'; + +/** + * Registers routes for checking status of endpoints based on pending actions + */ +export function registerActionStatusRoutes( + router: SecuritySolutionPluginRouter, + endpointContext: EndpointAppContext +) { + router.get( + { + path: ACTION_STATUS_ROUTE, + validate: ActionStatusRequestSchema, + options: { authRequired: true, tags: ['access:securitySolution'] }, + }, + actionStatusRequestHandler(endpointContext) + ); +} + +export const actionStatusRequestHandler = function ( + endpointContext: EndpointAppContext +): RequestHandler< + unknown, + TypeOf, + unknown, + SecuritySolutionRequestHandlerContext +> { + return async (context, req, res) => { + const esClient = context.core.elasticsearch.client.asCurrentUser; + const agentIDs: string[] = Array.isArray(req.query.agent_ids) + ? [...new Set(req.query.agent_ids)] + : [req.query.agent_ids]; + + // retrieve the unexpired actions for the given hosts + const recentActionResults = await esClient.search( + { + index: AGENT_ACTIONS_INDEX, + body: { + query: { + bool: { + filter: [ + { term: { type: 'INPUT_ACTION' } }, // actions that are directed at agent children + { term: { input_type: 'endpoint' } }, // filter for agent->endpoint actions + { range: { expiration: { gte: 'now' } } }, // that have not expired yet + { terms: { agents: agentIDs } }, // for the requested agent IDs + ], + }, + }, + }, + }, + { + ignore: [404], + } + ); + const pendingActions = + recentActionResults.body?.hits?.hits?.map((a): EndpointAction => a._source!) || []; + + // retrieve any responses to those action IDs from these agents + const actionIDs = pendingActions.map((a) => a.action_id); + const responseResults = await esClient.search( + { + index: '.fleet-actions-results', + body: { + query: { + bool: { + filter: [ + { terms: { action_id: actionIDs } }, // get results for these actions + { terms: { agent_id: agentIDs } }, // ignoring responses from agents we're not looking for + ], + }, + }, + }, + }, + { + ignore: [404], + } + ); + const actionResponses = responseResults.body?.hits?.hits?.map((a) => a._source!) || []; + + // respond with action-count per agent + const response = agentIDs.map((aid) => { + const responseIDsFromAgent = actionResponses + .filter((r) => r.agent_id === aid) + .map((r) => r.action_id); + return { + agent_id: aid, + pending_actions: pendingActions + .filter((a) => a.agents.includes(aid) && !responseIDsFromAgent.includes(a.action_id)) + .map((a) => a.data.command) + .reduce((acc, cur) => { + if (cur in acc) { + acc[cur] += 1; + } else { + acc[cur] = 1; + } + return acc; + }, {} as PendingActionsResponse['pending_actions']), + } as PendingActionsResponse; + }); + + return res.ok({ + body: { + data: response, + }, + }); + }; +}; diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index 732ae48223421..5aa298d6789be 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -75,10 +75,7 @@ import { registerEndpointRoutes } from './endpoint/routes/metadata'; import { registerLimitedConcurrencyRoutes } from './endpoint/routes/limited_concurrency'; import { registerResolverRoutes } from './endpoint/routes/resolver'; import { registerPolicyRoutes } from './endpoint/routes/policy'; -import { - registerHostIsolationRoutes, - registerActionAuditLogRoutes, -} from './endpoint/routes/actions'; +import { registerActionRoutes } from './endpoint/routes/actions'; import { EndpointArtifactClient, ManifestManager } from './endpoint/services'; import { EndpointAppContextService } from './endpoint/endpoint_app_context_services'; import { EndpointAppContext } from './endpoint/types'; @@ -293,8 +290,7 @@ export class Plugin implements IPlugin Date: Thu, 3 Jun 2021 13:05:03 -0700 Subject: [PATCH 37/90] [DOCS] Updates videos in Maps and Discover (#101312) --- docs/maps/index.asciidoc | 2 +- docs/user/introduction.asciidoc | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/maps/index.asciidoc b/docs/maps/index.asciidoc index 45d24bfb5a7e4..e4f72b344b844 100644 --- a/docs/maps/index.asciidoc +++ b/docs/maps/index.asciidoc @@ -24,7 +24,7 @@ Create beautiful maps from your geographical data. With **Maps**, you can: Date: Thu, 3 Jun 2021 15:15:15 -0500 Subject: [PATCH 38/90] [DOCS] Adds the updated Lens video (#101327) --- docs/user/dashboard/lens.asciidoc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/user/dashboard/lens.asciidoc b/docs/user/dashboard/lens.asciidoc index c5718b2a089bf..3b3a7a9ee527d 100644 --- a/docs/user/dashboard/lens.asciidoc +++ b/docs/user/dashboard/lens.asciidoc @@ -10,8 +10,8 @@ src="https://play.vidyard.com/embed/v4.js"> From 78d8272afebe3fe89fd121f559a217e3969ba4f2 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Thu, 3 Jun 2021 21:26:17 +0100 Subject: [PATCH 39/90] chore(NA): moving @kbn/rule-data-utils into bazel (#101290) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../monorepo-packages.asciidoc | 1 + package.json | 2 +- packages/BUILD.bazel | 1 + packages/kbn-rule-data-utils/BUILD.bazel | 79 +++++++++++++++++++ packages/kbn-rule-data-utils/tsconfig.json | 3 +- yarn.lock | 2 +- 6 files changed, 85 insertions(+), 3 deletions(-) create mode 100644 packages/kbn-rule-data-utils/BUILD.bazel diff --git a/docs/developer/getting-started/monorepo-packages.asciidoc b/docs/developer/getting-started/monorepo-packages.asciidoc index dbfbe90ec9263..029ee9ea4faf6 100644 --- a/docs/developer/getting-started/monorepo-packages.asciidoc +++ b/docs/developer/getting-started/monorepo-packages.asciidoc @@ -85,6 +85,7 @@ yarn kbn watch-bazel - @kbn/logging - @kbn/mapbox-gl - @kbn/monaco +- @kbn/rule-data-utils - @kbn/securitysolution-es-utils - @kbn/securitysolution-io-ts-alerting-types - @kbn/securitysolution-io-ts-list-types diff --git a/package.json b/package.json index f0803b3b44056..a2499d85247d7 100644 --- a/package.json +++ b/package.json @@ -138,7 +138,7 @@ "@kbn/legacy-logging": "link:bazel-bin/packages/kbn-legacy-logging", "@kbn/logging": "link:bazel-bin/packages/kbn-logging", "@kbn/monaco": "link:bazel-bin/packages/kbn-monaco", - "@kbn/rule-data-utils": "link:packages/kbn-rule-data-utils", + "@kbn/rule-data-utils": "link:bazel-bin/packages/kbn-rule-data-utils", "@kbn/securitysolution-list-constants": "link:bazel-bin/packages/kbn-securitysolution-list-constants", "@kbn/securitysolution-es-utils": "link:bazel-bin/packages/kbn-securitysolution-es-utils", "@kbn/securitysolution-io-ts-types": "link:bazel-bin/packages/kbn-securitysolution-io-ts-types", diff --git a/packages/BUILD.bazel b/packages/BUILD.bazel index de3498da1a697..083ae90a031f5 100644 --- a/packages/BUILD.bazel +++ b/packages/BUILD.bazel @@ -28,6 +28,7 @@ filegroup( "//packages/kbn-mapbox-gl:build", "//packages/kbn-monaco:build", "//packages/kbn-plugin-generator:build", + "//packages/kbn-rule-data-utils:build", "//packages/kbn-securitysolution-list-constants:build", "//packages/kbn-securitysolution-io-ts-types:build", "//packages/kbn-securitysolution-io-ts-alerting-types:build", diff --git a/packages/kbn-rule-data-utils/BUILD.bazel b/packages/kbn-rule-data-utils/BUILD.bazel new file mode 100644 index 0000000000000..ccd1793feb161 --- /dev/null +++ b/packages/kbn-rule-data-utils/BUILD.bazel @@ -0,0 +1,79 @@ +load("@npm//@bazel/typescript:index.bzl", "ts_config", "ts_project") +load("@build_bazel_rules_nodejs//:index.bzl", "js_library", "pkg_npm") + +PKG_BASE_NAME = "kbn-rule-data-utils" +PKG_REQUIRE_NAME = "@kbn/rule-data-utils" + +SOURCE_FILES = glob( + [ + "src/**/*.ts", + ], +) + +SRCS = SOURCE_FILES + +filegroup( + name = "srcs", + srcs = SRCS, +) + +NPM_MODULE_EXTRA_FILES = [ + "package.json", +] + +SRC_DEPS = [ + "@npm//tslib", + "@npm//utility-types", +] + +TYPES_DEPS = [ + "@npm//@types/jest", + "@npm//@types/node", +] + +DEPS = SRC_DEPS + TYPES_DEPS + +ts_config( + name = "tsconfig", + src = "tsconfig.json", + deps = [ + "//:tsconfig.base.json", + ], +) + +ts_project( + name = "tsc", + args = ['--pretty'], + srcs = SRCS, + deps = DEPS, + declaration = True, + declaration_map = True, + incremental = True, + out_dir = "target", + source_map = True, + root_dir = "src", + tsconfig = ":tsconfig", +) + +js_library( + name = PKG_BASE_NAME, + srcs = NPM_MODULE_EXTRA_FILES, + deps = DEPS + [":tsc"], + package_name = PKG_REQUIRE_NAME, + visibility = ["//visibility:public"], +) + +pkg_npm( + name = "npm_module", + deps = [ + ":%s" % PKG_BASE_NAME, + ] +) + +filegroup( + name = "build", + srcs = [ + ":npm_module", + ], + visibility = ["//visibility:public"], +) diff --git a/packages/kbn-rule-data-utils/tsconfig.json b/packages/kbn-rule-data-utils/tsconfig.json index 4b1262d11f3af..852393f01e594 100644 --- a/packages/kbn-rule-data-utils/tsconfig.json +++ b/packages/kbn-rule-data-utils/tsconfig.json @@ -1,11 +1,12 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "incremental": false, + "incremental": true, "outDir": "./target", "stripInternal": false, "declaration": true, "declarationMap": true, + "rootDir": "./src", "sourceMap": true, "sourceRoot": "../../../../packages/kbn-rule-data-utils/src", "types": [ diff --git a/yarn.lock b/yarn.lock index 7f2b44b5d0c3f..c5255bc4d0d30 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2703,7 +2703,7 @@ version "0.0.0" uid "" -"@kbn/rule-data-utils@link:packages/kbn-rule-data-utils": +"@kbn/rule-data-utils@link:bazel-bin/packages/kbn-rule-data-utils": version "0.0.0" uid "" From b1f2b1f9f5b48fd9008024923c7eeda30a124d16 Mon Sep 17 00:00:00 2001 From: gchaps <33642766+gchaps@users.noreply.github.com> Date: Thu, 3 Jun 2021 13:36:45 -0700 Subject: [PATCH 40/90] [DOCS] Updates video in Intor & Maps take 2 (#101330) --- docs/maps/index.asciidoc | 2 +- docs/user/introduction.asciidoc | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/maps/index.asciidoc b/docs/maps/index.asciidoc index e4f72b344b844..20320c5a938c9 100644 --- a/docs/maps/index.asciidoc +++ b/docs/maps/index.asciidoc @@ -25,7 +25,7 @@ Create beautiful maps from your geographical data. With **Maps**, you can: style="width: 100%; margin: auto; display: block;" class="vidyard-player-embed" src="https://play.vidyard.com/mBuWenQ2uSLY9YjEkPtzJC.jpg" -data-uuid="BYzRDtH4u7RSD8wKhuEW1b" +data-uuid="mBuWenQ2uSLY9YjEkPtzJC" data-v="4" data-type="inline" /> diff --git a/docs/user/introduction.asciidoc b/docs/user/introduction.asciidoc index d2ea5aaa555ef..25780d303eec4 100644 --- a/docs/user/introduction.asciidoc +++ b/docs/user/introduction.asciidoc @@ -27,7 +27,7 @@ which features. style="width: 100%; margin: auto; display: block;" class="vidyard-player-embed" src="https://play.vidyard.com/iyqMwJcvi8r4YfjeoPMjyH.jpg" -data-uuid="jW5wP2dmegbs5ThRZ451Gj" +data-uuid="iyqMwJcvi8r4YfjeoPMjyH" data-v="4" data-type="inline" /> From be9fcad655e6aa7abd0411b4d4f138e2936fe17d Mon Sep 17 00:00:00 2001 From: Spencer Date: Thu, 3 Jun 2021 15:13:11 -0700 Subject: [PATCH 41/90] [fix] import from the root of `@kbn/expect` (#101321) Co-authored-by: spalger --- test/functional/page_objects/error_page.ts | 2 +- test/functional/page_objects/visualize_editor_page.ts | 2 +- .../spaces_only/tests/alerting/update.ts | 2 +- .../api_integration/apis/lists/create_exception_list_item.ts | 2 +- x-pack/test/api_integration/apis/security/api_keys.ts | 2 +- .../test/api_integration/apis/security/builtin_es_privileges.ts | 2 +- x-pack/test/api_integration/apis/security/index_fields.ts | 2 +- x-pack/test/api_integration/apis/security/license_downgrade.ts | 2 +- x-pack/test/api_integration/apis/security/privileges.ts | 2 +- x-pack/test/api_integration/apis/spaces/saved_objects.ts | 2 +- x-pack/test/fleet_api_integration/apis/agents/upgrade.ts | 2 +- x-pack/test/functional/page_objects/infra_home_page.ts | 2 +- x-pack/test/functional/services/uptime/monitor.ts | 2 +- .../test_suites/event_log/public_api_integration.ts | 2 +- .../test_suites/event_log/service_api_integration.ts | 2 +- .../test/saved_object_api_integration/common/suites/delete.ts | 2 +- .../security_api_integration/tests/session_idle/extension.ts | 2 +- x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts | 2 +- .../test/security_solution_endpoint_api_int/apis/metadata_v1.ts | 2 +- x-pack/test/security_solution_endpoint_api_int/apis/policy.ts | 2 +- 20 files changed, 20 insertions(+), 20 deletions(-) diff --git a/test/functional/page_objects/error_page.ts b/test/functional/page_objects/error_page.ts index 98096f3179d02..99c17c632720a 100644 --- a/test/functional/page_objects/error_page.ts +++ b/test/functional/page_objects/error_page.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import expect from '@kbn/expect/expect'; +import expect from '@kbn/expect'; import { FtrProviderContext } from '../ftr_provider_context'; export function ErrorPageProvider({ getPageObjects }: FtrProviderContext) { diff --git a/test/functional/page_objects/visualize_editor_page.ts b/test/functional/page_objects/visualize_editor_page.ts index 9ba1ab6f85081..d311f752fd490 100644 --- a/test/functional/page_objects/visualize_editor_page.ts +++ b/test/functional/page_objects/visualize_editor_page.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import expect from '@kbn/expect/expect'; +import expect from '@kbn/expect'; import { FtrProviderContext } from '../ftr_provider_context'; export function VisualizeEditorPageProvider({ getService, getPageObjects }: FtrProviderContext) { diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/update.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/update.ts index 3d98e428fd9ee..326fb0bfac465 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/update.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/update.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect'; +import expect from '@kbn/expect'; import { Spaces } from '../../scenarios'; import { checkAAD, getUrlPrefix, getTestAlertData, ObjectRemover } from '../../../common/lib'; import { FtrProviderContext } from '../../../common/ftr_provider_context'; diff --git a/x-pack/test/api_integration/apis/lists/create_exception_list_item.ts b/x-pack/test/api_integration/apis/lists/create_exception_list_item.ts index fb80d81dd242a..e18f805bf174e 100644 --- a/x-pack/test/api_integration/apis/lists/create_exception_list_item.ts +++ b/x-pack/test/api_integration/apis/lists/create_exception_list_item.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect'; +import expect from '@kbn/expect'; import { ENDPOINT_LIST_ID } from '@kbn/securitysolution-list-constants'; import { FtrProviderContext } from '../../ftr_provider_context'; diff --git a/x-pack/test/api_integration/apis/security/api_keys.ts b/x-pack/test/api_integration/apis/security/api_keys.ts index d2614abc9e5f7..98ef83c437863 100644 --- a/x-pack/test/api_integration/apis/security/api_keys.ts +++ b/x-pack/test/api_integration/apis/security/api_keys.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect'; +import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { diff --git a/x-pack/test/api_integration/apis/security/builtin_es_privileges.ts b/x-pack/test/api_integration/apis/security/builtin_es_privileges.ts index c927d095b8889..89587fe259683 100644 --- a/x-pack/test/api_integration/apis/security/builtin_es_privileges.ts +++ b/x-pack/test/api_integration/apis/security/builtin_es_privileges.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect'; +import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { diff --git a/x-pack/test/api_integration/apis/security/index_fields.ts b/x-pack/test/api_integration/apis/security/index_fields.ts index 3f036bcd7f7ea..c4dc288b0e060 100644 --- a/x-pack/test/api_integration/apis/security/index_fields.ts +++ b/x-pack/test/api_integration/apis/security/index_fields.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect'; +import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { diff --git a/x-pack/test/api_integration/apis/security/license_downgrade.ts b/x-pack/test/api_integration/apis/security/license_downgrade.ts index 7a5ad1ce64a62..dcdcc039bc9d6 100644 --- a/x-pack/test/api_integration/apis/security/license_downgrade.ts +++ b/x-pack/test/api_integration/apis/security/license_downgrade.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect'; +import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { diff --git a/x-pack/test/api_integration/apis/security/privileges.ts b/x-pack/test/api_integration/apis/security/privileges.ts index d6ad5f6cd387b..5830fc2d1017f 100644 --- a/x-pack/test/api_integration/apis/security/privileges.ts +++ b/x-pack/test/api_integration/apis/security/privileges.ts @@ -7,7 +7,7 @@ import util from 'util'; import { isEqual, isEqualWith } from 'lodash'; -import expect from '@kbn/expect/expect'; +import expect from '@kbn/expect'; import { RawKibanaPrivileges } from '../../../../plugins/security/common/model'; import { FtrProviderContext } from '../../ftr_provider_context'; diff --git a/x-pack/test/api_integration/apis/spaces/saved_objects.ts b/x-pack/test/api_integration/apis/spaces/saved_objects.ts index 20fc3428bb2b1..806929e67ebbc 100644 --- a/x-pack/test/api_integration/apis/spaces/saved_objects.ts +++ b/x-pack/test/api_integration/apis/spaces/saved_objects.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect'; +import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { diff --git a/x-pack/test/fleet_api_integration/apis/agents/upgrade.ts b/x-pack/test/fleet_api_integration/apis/agents/upgrade.ts index 0722edbcb45b3..143dc123bc722 100644 --- a/x-pack/test/fleet_api_integration/apis/agents/upgrade.ts +++ b/x-pack/test/fleet_api_integration/apis/agents/upgrade.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect'; +import expect from '@kbn/expect'; import semver from 'semver'; import { FtrProviderContext } from '../../../api_integration/ftr_provider_context'; import { setupFleetAndAgents } from './services'; diff --git a/x-pack/test/functional/page_objects/infra_home_page.ts b/x-pack/test/functional/page_objects/infra_home_page.ts index a5388aa829d01..8dfef36c3a1c1 100644 --- a/x-pack/test/functional/page_objects/infra_home_page.ts +++ b/x-pack/test/functional/page_objects/infra_home_page.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect'; +import expect from '@kbn/expect'; import testSubjSelector from '@kbn/test-subj-selector'; import { FtrProviderContext } from '../ftr_provider_context'; diff --git a/x-pack/test/functional/services/uptime/monitor.ts b/x-pack/test/functional/services/uptime/monitor.ts index 3b22a5f7f6630..583a37d7f4ef9 100644 --- a/x-pack/test/functional/services/uptime/monitor.ts +++ b/x-pack/test/functional/services/uptime/monitor.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect'; +import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; export function UptimeMonitorProvider({ getService, getPageObjects }: FtrProviderContext) { diff --git a/x-pack/test/plugin_api_integration/test_suites/event_log/public_api_integration.ts b/x-pack/test/plugin_api_integration/test_suites/event_log/public_api_integration.ts index f2497041094f7..d41dab2741cd5 100644 --- a/x-pack/test/plugin_api_integration/test_suites/event_log/public_api_integration.ts +++ b/x-pack/test/plugin_api_integration/test_suites/event_log/public_api_integration.ts @@ -7,7 +7,7 @@ import { merge, omit, chunk, isEmpty } from 'lodash'; import uuid from 'uuid'; -import expect from '@kbn/expect/expect'; +import expect from '@kbn/expect'; import moment from 'moment'; import { FtrProviderContext } from '../../ftr_provider_context'; import { IEvent } from '../../../../plugins/event_log/server'; diff --git a/x-pack/test/plugin_api_integration/test_suites/event_log/service_api_integration.ts b/x-pack/test/plugin_api_integration/test_suites/event_log/service_api_integration.ts index 170b01a01edf9..960deb692ac8d 100644 --- a/x-pack/test/plugin_api_integration/test_suites/event_log/service_api_integration.ts +++ b/x-pack/test/plugin_api_integration/test_suites/event_log/service_api_integration.ts @@ -7,7 +7,7 @@ import _ from 'lodash'; import uuid from 'uuid'; -import expect from '@kbn/expect/expect'; +import expect from '@kbn/expect'; import { IEvent } from '../../../../plugins/event_log/server'; import { FtrProviderContext } from '../../ftr_provider_context'; diff --git a/x-pack/test/saved_object_api_integration/common/suites/delete.ts b/x-pack/test/saved_object_api_integration/common/suites/delete.ts index 1ba8ea32b9922..9726c47a9bc0a 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/delete.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/delete.ts @@ -6,7 +6,7 @@ */ import { SuperTest } from 'supertest'; -import expect from '@kbn/expect/expect'; +import expect from '@kbn/expect'; import { SAVED_OBJECT_TEST_CASES as CASES } from '../lib/saved_object_test_cases'; import { SPACES } from '../lib/spaces'; import { expectResponses, getUrlPrefix, getTestTitle } from '../lib/saved_object_test_utils'; diff --git a/x-pack/test/security_api_integration/tests/session_idle/extension.ts b/x-pack/test/security_api_integration/tests/session_idle/extension.ts index b8fef972f05d6..84ab8ce42c13e 100644 --- a/x-pack/test/security_api_integration/tests/session_idle/extension.ts +++ b/x-pack/test/security_api_integration/tests/session_idle/extension.ts @@ -6,7 +6,7 @@ */ import { Cookie, cookie } from 'request'; -import expect from '@kbn/expect/expect'; +import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts b/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts index da339f54d41f4..a1be11c4f696d 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect'; +import expect from '@kbn/expect'; import { FtrProviderContext } from '../ftr_provider_context'; import { deleteAllDocsFromMetadataCurrentIndex, diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/metadata_v1.ts b/x-pack/test/security_solution_endpoint_api_int/apis/metadata_v1.ts index 1e1322944153b..6879184b9bc13 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/metadata_v1.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/metadata_v1.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect'; +import expect from '@kbn/expect'; import { FtrProviderContext } from '../ftr_provider_context'; import { deleteMetadataStream } from './data_stream_helper'; import { METADATA_REQUEST_V1_ROUTE } from '../../../plugins/security_solution/server/endpoint/routes/metadata'; diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/policy.ts b/x-pack/test/security_solution_endpoint_api_int/apis/policy.ts index 318e857bdcad0..73687784d15ea 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/policy.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/policy.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect'; +import expect from '@kbn/expect'; import { FtrProviderContext } from '../ftr_provider_context'; import { deletePolicyStream } from './data_stream_helper'; From caa4bd111d2d2f9c318cd6f492990713a5d463fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Louv-Jansen?= Date: Fri, 4 Jun 2021 02:59:30 +0200 Subject: [PATCH 42/90] [APM] Add Obs side nav and refactor APM templates (#101044) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../apm/common/environment_filter_values.ts | 12 + .../step_definitions/csm/csm_dashboard.ts | 16 +- .../public/application/application.test.tsx | 23 +- .../plugins/apm/public/application/csmApp.tsx | 4 +- .../plugins/apm/public/application/index.tsx | 85 +--- .../alerting/alerting_flyout/index.tsx | 15 +- .../DetailView/index.test.tsx | 39 +- .../ErrorGroupDetails/DetailView/index.tsx | 4 +- .../app/ErrorGroupDetails/index.tsx | 163 +++--- .../public/components/app/Home/Home.test.tsx | 33 -- .../app/Home/__snapshots__/Home.test.tsx.snap | 189 ------- .../apm/public/components/app/Home/index.tsx | 80 --- .../app/Main/route_config/index.tsx | 369 -------------- .../Settings/AgentConfigurations/index.tsx | 6 +- .../app/Settings/ApmIndices/index.test.tsx | 6 +- .../app/Settings/ApmIndices/index.tsx | 6 +- .../app/Settings/CustomizeUI/index.tsx | 6 +- .../app/Settings/anomaly_detection/index.tsx | 6 +- .../public/components/app/Settings/index.tsx | 166 +++--- .../app/error_group_overview/index.tsx | 63 ++- .../components/app/service_details/index.tsx | 39 -- .../service_details/service_detail_tabs.tsx | 202 -------- .../app/service_inventory/index.tsx | 46 +- .../Controls.test.tsx | 0 .../{ServiceMap => service_map}/Controls.tsx | 0 .../{ServiceMap => service_map}/Cytoscape.tsx | 0 .../EmptyBanner.tsx | 0 .../Popover/AnomalyDetection.tsx | 0 .../Popover/Buttons.test.tsx | 0 .../Popover/Buttons.tsx | 0 .../Popover/Contents.tsx | 0 .../Popover/Info.tsx | 0 .../Popover/Popover.stories.tsx | 4 +- .../Popover/ServiceStatsFetcher.tsx | 0 .../Popover/ServiceStatsList.tsx | 0 .../Popover/index.tsx | 0 .../Popover/service_stats_list.stories.tsx | 2 +- .../__stories__/Cytoscape.stories.tsx | 2 +- .../__stories__/centerer.tsx | 0 .../cytoscape_example_data.stories.tsx | 4 +- .../example_grouped_connections.json | 0 .../example_response_hipster_store.json | 0 .../example_response_opbeans_beats.json | 0 .../__stories__/example_response_todo.json | 0 .../generate_service_map_elements.ts | 0 .../cytoscape_options.ts | 0 .../empty_banner.test.tsx | 0 .../empty_prompt.tsx | 0 .../app/{ServiceMap => service_map}/icons.ts | 0 .../index.test.tsx | 2 +- .../app/{ServiceMap => service_map}/index.tsx | 24 +- .../timeout_prompt.tsx | 0 .../useRefDimensions.ts | 0 .../use_cytoscape_event_handlers.test.tsx | 0 .../use_cytoscape_event_handlers.ts | 0 .../components/app/service_metrics/index.tsx | 61 +-- .../app/service_node_metrics/index.test.tsx | 11 +- .../app/service_node_metrics/index.tsx | 66 +-- .../app/service_node_overview/index.tsx | 37 +- .../components/app/service_overview/index.tsx | 143 +++--- .../get_columns.tsx | 1 + .../intance_details.tsx | 5 +- .../app/service_profiling/index.tsx | 82 ++- .../components/app/trace_overview/index.tsx | 19 +- .../WaterfallWithSummmary/TransactionTabs.tsx | 11 +- .../Waterfall/WaterfallFlyout.tsx | 16 +- .../Waterfall/accordion_waterfall.tsx | 4 - .../WaterfallContainer/Waterfall/index.tsx | 18 +- .../WaterfallContainer.stories.tsx | 5 - .../WaterfallContainer/index.tsx | 4 - .../WaterfallWithSummmary/index.tsx | 4 - .../app/transaction_details/index.tsx | 99 ++-- .../app/transaction_overview/index.tsx | 106 ++-- .../transaction_overview.test.tsx | 42 +- .../components/routing/apm_route_config.tsx | 478 ++++++++++++++++++ .../public/components/routing/app_root.tsx | 110 ++++ .../public/components/routing/redirect_to.tsx | 38 ++ .../route_config.test.tsx | 4 +- .../route_handlers/agent_configuration.tsx | 8 +- .../routing/templates/apm_main_template.tsx | 43 ++ .../templates/apm_service_template.tsx | 216 ++++++++ .../shared/ApmHeader/apm_header.stories.tsx | 53 -- .../components/shared/ApmHeader/index.tsx | 36 -- .../shared/EnvironmentFilter/index.tsx | 9 +- .../alerting_popover_flyout.tsx | 6 +- .../anomaly_detection_setup_link.test.tsx | 4 +- .../anomaly_detection_setup_link.tsx | 18 +- .../shared/apm_header_action_menu}/index.tsx | 8 +- .../public/components/shared/main_tabs.tsx | 24 - .../components/shared/search_bar.test.tsx | 120 +++++ .../public/components/shared/search_bar.tsx | 32 +- .../service_icons/alert_details.tsx | 12 +- .../service_icons/cloud_details.tsx | 2 +- .../service_icons/container_details.tsx | 4 +- .../service_icons/icon_popover.tsx | 4 +- .../service_icons/index.test.tsx | 10 +- .../service_icons/index.tsx | 12 +- .../service_icons/service_details.tsx | 2 +- .../apm/public/hooks/use_breadcrumbs.test.tsx | 6 +- x-pack/plugins/apm/public/plugin.ts | 28 +- .../public/utils/getRangeFromTimeSeries.ts | 22 - .../translations/translations/ja-JP.json | 18 - .../translations/translations/zh-CN.json | 18 - 103 files changed, 1640 insertions(+), 2055 deletions(-) delete mode 100644 x-pack/plugins/apm/public/components/app/Home/Home.test.tsx delete mode 100644 x-pack/plugins/apm/public/components/app/Home/__snapshots__/Home.test.tsx.snap delete mode 100644 x-pack/plugins/apm/public/components/app/Home/index.tsx delete mode 100644 x-pack/plugins/apm/public/components/app/Main/route_config/index.tsx delete mode 100644 x-pack/plugins/apm/public/components/app/service_details/index.tsx delete mode 100644 x-pack/plugins/apm/public/components/app/service_details/service_detail_tabs.tsx rename x-pack/plugins/apm/public/components/app/{ServiceMap => service_map}/Controls.test.tsx (100%) rename x-pack/plugins/apm/public/components/app/{ServiceMap => service_map}/Controls.tsx (100%) rename x-pack/plugins/apm/public/components/app/{ServiceMap => service_map}/Cytoscape.tsx (100%) rename x-pack/plugins/apm/public/components/app/{ServiceMap => service_map}/EmptyBanner.tsx (100%) rename x-pack/plugins/apm/public/components/app/{ServiceMap => service_map}/Popover/AnomalyDetection.tsx (100%) rename x-pack/plugins/apm/public/components/app/{ServiceMap => service_map}/Popover/Buttons.test.tsx (100%) rename x-pack/plugins/apm/public/components/app/{ServiceMap => service_map}/Popover/Buttons.tsx (100%) rename x-pack/plugins/apm/public/components/app/{ServiceMap => service_map}/Popover/Contents.tsx (100%) rename x-pack/plugins/apm/public/components/app/{ServiceMap => service_map}/Popover/Info.tsx (100%) rename x-pack/plugins/apm/public/components/app/{ServiceMap => service_map}/Popover/Popover.stories.tsx (97%) rename x-pack/plugins/apm/public/components/app/{ServiceMap => service_map}/Popover/ServiceStatsFetcher.tsx (100%) rename x-pack/plugins/apm/public/components/app/{ServiceMap => service_map}/Popover/ServiceStatsList.tsx (100%) rename x-pack/plugins/apm/public/components/app/{ServiceMap => service_map}/Popover/index.tsx (100%) rename x-pack/plugins/apm/public/components/app/{ServiceMap => service_map}/Popover/service_stats_list.stories.tsx (96%) rename x-pack/plugins/apm/public/components/app/{ServiceMap => service_map}/__stories__/Cytoscape.stories.tsx (99%) rename x-pack/plugins/apm/public/components/app/{ServiceMap => service_map}/__stories__/centerer.tsx (100%) rename x-pack/plugins/apm/public/components/app/{ServiceMap => service_map}/__stories__/cytoscape_example_data.stories.tsx (98%) rename x-pack/plugins/apm/public/components/app/{ServiceMap => service_map}/__stories__/example_grouped_connections.json (100%) rename x-pack/plugins/apm/public/components/app/{ServiceMap => service_map}/__stories__/example_response_hipster_store.json (100%) rename x-pack/plugins/apm/public/components/app/{ServiceMap => service_map}/__stories__/example_response_opbeans_beats.json (100%) rename x-pack/plugins/apm/public/components/app/{ServiceMap => service_map}/__stories__/example_response_todo.json (100%) rename x-pack/plugins/apm/public/components/app/{ServiceMap => service_map}/__stories__/generate_service_map_elements.ts (100%) rename x-pack/plugins/apm/public/components/app/{ServiceMap => service_map}/cytoscape_options.ts (100%) rename x-pack/plugins/apm/public/components/app/{ServiceMap => service_map}/empty_banner.test.tsx (100%) rename x-pack/plugins/apm/public/components/app/{ServiceMap => service_map}/empty_prompt.tsx (100%) rename x-pack/plugins/apm/public/components/app/{ServiceMap => service_map}/icons.ts (100%) rename x-pack/plugins/apm/public/components/app/{ServiceMap => service_map}/index.test.tsx (99%) rename x-pack/plugins/apm/public/components/app/{ServiceMap => service_map}/index.tsx (87%) rename x-pack/plugins/apm/public/components/app/{ServiceMap => service_map}/timeout_prompt.tsx (100%) rename x-pack/plugins/apm/public/components/app/{ServiceMap => service_map}/useRefDimensions.ts (100%) rename x-pack/plugins/apm/public/components/app/{ServiceMap => service_map}/use_cytoscape_event_handlers.test.tsx (100%) rename x-pack/plugins/apm/public/components/app/{ServiceMap => service_map}/use_cytoscape_event_handlers.ts (100%) create mode 100644 x-pack/plugins/apm/public/components/routing/apm_route_config.tsx create mode 100644 x-pack/plugins/apm/public/components/routing/app_root.tsx create mode 100644 x-pack/plugins/apm/public/components/routing/redirect_to.tsx rename x-pack/plugins/apm/public/components/{app/Main/route_config => routing}/route_config.test.tsx (93%) rename x-pack/plugins/apm/public/components/{app/Main/route_config => routing}/route_handlers/agent_configuration.tsx (86%) create mode 100644 x-pack/plugins/apm/public/components/routing/templates/apm_main_template.tsx create mode 100644 x-pack/plugins/apm/public/components/routing/templates/apm_service_template.tsx delete mode 100644 x-pack/plugins/apm/public/components/shared/ApmHeader/apm_header.stories.tsx delete mode 100644 x-pack/plugins/apm/public/components/shared/ApmHeader/index.tsx rename x-pack/plugins/apm/public/{application/action_menu => components/shared/apm_header_action_menu}/alerting_popover_flyout.tsx (96%) rename x-pack/plugins/apm/public/{application/action_menu => components/shared/apm_header_action_menu}/anomaly_detection_setup_link.test.tsx (95%) rename x-pack/plugins/apm/public/{application/action_menu => components/shared/apm_header_action_menu}/anomaly_detection_setup_link.tsx (83%) rename x-pack/plugins/apm/public/{application/action_menu => components/shared/apm_header_action_menu}/index.tsx (88%) delete mode 100644 x-pack/plugins/apm/public/components/shared/main_tabs.tsx create mode 100644 x-pack/plugins/apm/public/components/shared/search_bar.test.tsx rename x-pack/plugins/apm/public/components/{app/service_details => shared}/service_icons/alert_details.tsx (84%) rename x-pack/plugins/apm/public/components/{app/service_details => shared}/service_icons/cloud_details.tsx (97%) rename x-pack/plugins/apm/public/components/{app/service_details => shared}/service_icons/container_details.tsx (94%) rename x-pack/plugins/apm/public/components/{app/service_details => shared}/service_icons/icon_popover.tsx (93%) rename x-pack/plugins/apm/public/components/{app/service_details => shared}/service_icons/index.test.tsx (95%) rename x-pack/plugins/apm/public/components/{app/service_details => shared}/service_icons/index.tsx (91%) rename x-pack/plugins/apm/public/components/{app/service_details => shared}/service_icons/service_details.tsx (96%) delete mode 100644 x-pack/plugins/apm/public/utils/getRangeFromTimeSeries.ts diff --git a/x-pack/plugins/apm/common/environment_filter_values.ts b/x-pack/plugins/apm/common/environment_filter_values.ts index c80541ee1ba6b..e4f0b40607679 100644 --- a/x-pack/plugins/apm/common/environment_filter_values.ts +++ b/x-pack/plugins/apm/common/environment_filter_values.ts @@ -37,6 +37,18 @@ export function getEnvironmentLabel(environment: string) { return environmentLabels[environment] || environment; } +export function omitEsFieldValue({ + esFieldValue, + value, + text, +}: { + esFieldValue?: string; + value: string; + text: string; +}) { + return { value, text }; +} + export function parseEnvironmentUrlParam(environment: string) { if (environment === ENVIRONMENT_ALL_VALUE) { return ENVIRONMENT_ALL; diff --git a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/csm_dashboard.ts b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/csm_dashboard.ts index 47154ee214dc4..cbcb48796a6d4 100644 --- a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/csm_dashboard.ts +++ b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/csm_dashboard.ts @@ -51,16 +51,16 @@ Then(`should display percentile for page load chart`, () => { cy.get(pMarkers).eq(3).should('have.text', '95th'); }); -Then(`should display chart legend`, () => { - const chartLegend = 'button.echLegendItem__label'; +// Then(`should display chart legend`, () => { +// const chartLegend = 'button.echLegendItem__label'; - waitForLoadingToFinish(); - cy.get('.euiLoadingChart').should('not.exist'); +// waitForLoadingToFinish(); +// cy.get('.euiLoadingChart').should('not.exist'); - cy.get('[data-cy=pageLoadDist]').within(() => { - cy.get(chartLegend, DEFAULT_TIMEOUT).eq(0).should('have.text', 'Overall'); - }); -}); +// cy.get('[data-cy=pageLoadDist]').within(() => { +// cy.get(chartLegend, DEFAULT_TIMEOUT).eq(0).should('have.text', 'Overall'); +// }); +// }); Then(`should display tooltip on hover`, () => { cy.get('.euiLoadingChart').should('not.exist'); diff --git a/x-pack/plugins/apm/public/application/application.test.tsx b/x-pack/plugins/apm/public/application/application.test.tsx index 4ec654a6c0bfd..57285649677dc 100644 --- a/x-pack/plugins/apm/public/application/application.test.tsx +++ b/x-pack/plugins/apm/public/application/application.test.tsx @@ -5,6 +5,7 @@ * 2.0. */ +import React from 'react'; import { act } from '@testing-library/react'; import { createMemoryHistory } from 'history'; import { Observable } from 'rxjs'; @@ -15,6 +16,7 @@ import { renderApp } from './'; import { disableConsoleWarning } from '../utils/testHelpers'; import { dataPluginMock } from 'src/plugins/data/public/mocks'; import { embeddablePluginMock } from 'src/plugins/embeddable/public/mocks'; +import { ApmPluginStartDeps } from '../plugin'; jest.mock('../services/rest/index_pattern', () => ({ createStaticIndexPattern: () => Promise.resolve(undefined), @@ -44,6 +46,7 @@ describe('renderApp', () => { config, observabilityRuleTypeRegistry, } = mockApmPluginContextValue; + const plugins = { licensing: { license$: new Observable() }, triggersActionsUi: { actionTypeRegistry: {}, alertTypeRegistry: {} }, @@ -56,7 +59,7 @@ describe('renderApp', () => { }, }, }; - const params = { + const appMountParameters = { element: document.createElement('div'), history: createMemoryHistory(), setHeaderActionMenu: () => {}, @@ -64,7 +67,16 @@ describe('renderApp', () => { const data = dataPluginMock.createStartContract(); const embeddable = embeddablePluginMock.createStartContract(); - const startDeps = { + + const pluginsStart = ({ + observability: { + navigation: { + registerSections: () => jest.fn(), + PageTemplate: ({ children }: { children: React.ReactNode }) => ( +
hello worlds {children}
+ ), + }, + }, triggersActionsUi: { actionTypeRegistry: {}, alertTypeRegistry: {}, @@ -73,7 +85,8 @@ describe('renderApp', () => { }, data, embeddable, - }; + } as unknown) as ApmPluginStartDeps; + jest.spyOn(window, 'scrollTo').mockReturnValueOnce(undefined); createCallApmApi((core as unknown) as CoreStart); @@ -93,8 +106,8 @@ describe('renderApp', () => { unmount = renderApp({ coreStart: core as any, pluginsSetup: plugins as any, - appMountParameters: params as any, - pluginsStart: startDeps as any, + appMountParameters: appMountParameters as any, + pluginsStart, config, observabilityRuleTypeRegistry, }); diff --git a/x-pack/plugins/apm/public/application/csmApp.tsx b/x-pack/plugins/apm/public/application/csmApp.tsx index 11a2777f47f6a..ca4f4856894f9 100644 --- a/x-pack/plugins/apm/public/application/csmApp.tsx +++ b/x-pack/plugins/apm/public/application/csmApp.tsx @@ -20,7 +20,6 @@ import { useUiSetting$, } from '../../../../../src/plugins/kibana_react/public'; import { APMRouteDefinition } from '../application/routes'; -import { renderAsRedirectTo } from '../components/app/Main/route_config'; import { ScrollToTopOnPathChange } from '../components/app/Main/ScrollToTopOnPathChange'; import { RumHome, UX_LABEL } from '../components/app/RumDashboard/RumHome'; import { ApmPluginContext } from '../context/apm_plugin/apm_plugin_context'; @@ -32,6 +31,7 @@ import { createCallApmApi } from '../services/rest/createCallApmApi'; import { px, units } from '../style/variables'; import { createStaticIndexPattern } from '../services/rest/index_pattern'; import { UXActionMenu } from '../components/app/RumDashboard/ActionMenu'; +import { redirectTo } from '../components/routing/redirect_to'; const CsmMainContainer = euiStyled.div` padding: ${px(units.plus)}; @@ -42,7 +42,7 @@ export const rumRoutes: APMRouteDefinition[] = [ { exact: true, path: '/', - render: renderAsRedirectTo('/ux'), + render: redirectTo('/ux'), breadcrumb: UX_LABEL, }, ]; diff --git a/x-pack/plugins/apm/public/application/index.tsx b/x-pack/plugins/apm/public/application/index.tsx index e2a0bdb6b48b1..9b8d3c7822d3d 100644 --- a/x-pack/plugins/apm/public/application/index.tsx +++ b/x-pack/plugins/apm/public/application/index.tsx @@ -5,99 +5,18 @@ * 2.0. */ -import { ApmRoute } from '@elastic/apm-rum-react'; -import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; -import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import React from 'react'; import ReactDOM from 'react-dom'; -import { Route, Router, Switch } from 'react-router-dom'; import 'react-vis/dist/style.css'; -import { DefaultTheme, ThemeProvider } from 'styled-components'; import type { ObservabilityRuleTypeRegistry } from '../../../observability/public'; -import { euiStyled } from '../../../../../src/plugins/kibana_react/common'; import { ConfigSchema } from '../'; import { AppMountParameters, CoreStart } from '../../../../../src/core/public'; -import { - KibanaContextProvider, - RedirectAppLinks, - useUiSetting$, -} from '../../../../../src/plugins/kibana_react/public'; -import { routes } from '../components/app/Main/route_config'; -import { ScrollToTopOnPathChange } from '../components/app/Main/ScrollToTopOnPathChange'; -import { - ApmPluginContext, - ApmPluginContextValue, -} from '../context/apm_plugin/apm_plugin_context'; -import { LicenseProvider } from '../context/license/license_context'; -import { UrlParamsProvider } from '../context/url_params_context/url_params_context'; -import { useBreadcrumbs } from '../hooks/use_breadcrumbs'; import { ApmPluginSetupDeps, ApmPluginStartDeps } from '../plugin'; import { createCallApmApi } from '../services/rest/createCallApmApi'; import { createStaticIndexPattern } from '../services/rest/index_pattern'; import { setHelpExtension } from '../setHelpExtension'; import { setReadonlyBadge } from '../updateBadge'; -import { AnomalyDetectionJobsContextProvider } from '../context/anomaly_detection_jobs/anomaly_detection_jobs_context'; - -const MainContainer = euiStyled.div` - height: 100%; -`; - -function App() { - const [darkMode] = useUiSetting$('theme:darkMode'); - - useBreadcrumbs(routes); - - return ( - ({ - ...outerTheme, - eui: darkMode ? euiDarkVars : euiLightVars, - darkMode, - })} - > - - - - {routes.map((route, i) => ( - - ))} - - - - ); -} - -export function ApmAppRoot({ - apmPluginContextValue, - startDeps, -}: { - apmPluginContextValue: ApmPluginContextValue; - startDeps: ApmPluginStartDeps; -}) { - const { appMountParameters, core } = apmPluginContextValue; - const { history } = appMountParameters; - const i18nCore = core.i18n; - - return ( - - - - - - - - - - - - - - - - - - ); -} +import { ApmAppRoot } from '../components/routing/app_root'; /** * This module is rendered asynchronously in the Kibana platform. @@ -141,7 +60,7 @@ export const renderApp = ({ ReactDOM.render( , element ); diff --git a/x-pack/plugins/apm/public/components/alerting/alerting_flyout/index.tsx b/x-pack/plugins/apm/public/components/alerting/alerting_flyout/index.tsx index 50788c28999b5..35863d8099394 100644 --- a/x-pack/plugins/apm/public/components/alerting/alerting_flyout/index.tsx +++ b/x-pack/plugins/apm/public/components/alerting/alerting_flyout/index.tsx @@ -10,24 +10,17 @@ import { useParams } from 'react-router-dom'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { AlertType } from '../../../../common/alert_types'; import { getInitialAlertValues } from '../get_initial_alert_values'; -import { TriggersAndActionsUIPublicPluginStart } from '../../../../../triggers_actions_ui/public'; +import { ApmPluginStartDeps } from '../../../plugin'; interface Props { addFlyoutVisible: boolean; setAddFlyoutVisibility: React.Dispatch>; alertType: AlertType | null; } -interface KibanaDeps { - triggersActionsUi: TriggersAndActionsUIPublicPluginStart; -} - export function AlertingFlyout(props: Props) { const { addFlyoutVisible, setAddFlyoutVisibility, alertType } = props; const { serviceName } = useParams<{ serviceName?: string }>(); - const { - services: { triggersActionsUi }, - } = useKibana(); - + const { services } = useKibana(); const initialValues = getInitialAlertValues(alertType, serviceName); const onCloseAddFlyout = useCallback(() => setAddFlyoutVisibility(false), [ @@ -37,7 +30,7 @@ export function AlertingFlyout(props: Props) { const addAlertFlyout = useMemo( () => alertType && - triggersActionsUi.getAddAlertFlyout({ + services.triggersActionsUi.getAddAlertFlyout({ consumer: 'apm', onClose: onCloseAddFlyout, alertTypeId: alertType, @@ -45,7 +38,7 @@ export function AlertingFlyout(props: Props) { initialValues, }), /* eslint-disable-next-line react-hooks/exhaustive-deps */ - [alertType, onCloseAddFlyout, triggersActionsUi] + [alertType, onCloseAddFlyout, services.triggersActionsUi] ); return <>{addFlyoutVisible && addAlertFlyout}; } diff --git a/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/DetailView/index.test.tsx b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/DetailView/index.test.tsx index 5671f0bfcf085..9cb5a57b090f3 100644 --- a/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/DetailView/index.test.tsx +++ b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/DetailView/index.test.tsx @@ -6,7 +6,6 @@ */ import { shallow } from 'enzyme'; -import { Location } from 'history'; import React from 'react'; import { mockMoment } from '../../../../utils/testHelpers'; import { DetailView } from './index'; @@ -19,11 +18,7 @@ describe('DetailView', () => { it('should render empty state', () => { const wrapper = shallow( - + ); expect(wrapper.isEmptyRender()).toBe(true); }); @@ -46,11 +41,7 @@ describe('DetailView', () => { }; const wrapper = shallow( - + ).find('DiscoverErrorLink'); expect(wrapper.exists()).toBe(true); @@ -69,11 +60,7 @@ describe('DetailView', () => { transaction: undefined, }; const wrapper = shallow( - + ).find('Summary'); expect(wrapper.exists()).toBe(true); @@ -93,11 +80,7 @@ describe('DetailView', () => { } as any, }; const wrapper = shallow( - + ).find('EuiTabs'); expect(wrapper.exists()).toBe(true); @@ -117,11 +100,7 @@ describe('DetailView', () => { } as any, }; const wrapper = shallow( - + ).find('TabContent'); expect(wrapper.exists()).toBe(true); @@ -145,13 +124,7 @@ describe('DetailView', () => { } as any, }; expect(() => - shallow( - - ) + shallow() ).not.toThrowError(); }); }); diff --git a/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/DetailView/index.tsx b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/DetailView/index.tsx index cd893c1736988..da55f274bd77c 100644 --- a/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/DetailView/index.tsx +++ b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/DetailView/index.tsx @@ -16,7 +16,6 @@ import { EuiToolTip, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { Location } from 'history'; import { first } from 'lodash'; import React from 'react'; import { useHistory } from 'react-router-dom'; @@ -58,7 +57,6 @@ const TransactionLinkName = euiStyled.div` interface Props { errorGroup: APIReturnType<'GET /api/apm/services/{serviceName}/errors/{groupId}'>; urlParams: IUrlParams; - location: Location; } // TODO: Move query-string-based tabs into a re-usable component? @@ -70,7 +68,7 @@ function getCurrentTab( return selectedTab ? selectedTab : first(tabs) || {}; } -export function DetailView({ errorGroup, urlParams, location }: Props) { +export function DetailView({ errorGroup, urlParams }: Props) { const history = useHistory(); const { transaction, error, occurrencesCount } = errorGroup; diff --git a/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/index.tsx b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/index.tsx index 5fcd2914f2225..0f2180721afe3 100644 --- a/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/index.tsx +++ b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/index.tsx @@ -9,24 +9,19 @@ import { EuiBadge, EuiFlexGroup, EuiFlexItem, - EuiPage, - EuiPageBody, EuiPanel, EuiSpacer, EuiText, EuiTitle, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import React, { Fragment } from 'react'; -import { RouteComponentProps } from 'react-router-dom'; +import React from 'react'; import { euiStyled } from '../../../../../../../src/plugins/kibana_react/common'; import { useTrackPageview } from '../../../../../observability/public'; import { NOT_AVAILABLE_LABEL } from '../../../../common/i18n'; import { useFetcher } from '../../../hooks/use_fetcher'; import { useUrlParams } from '../../../context/url_params_context/use_url_params'; import { fontFamilyCode, fontSizes, px, units } from '../../../style/variables'; -import { ApmHeader } from '../../shared/ApmHeader'; -import { SearchBar } from '../../shared/search_bar'; import { DetailView } from './DetailView'; import { ErrorDistribution } from './Distribution'; import { useErrorGroupDistributionFetcher } from '../../../hooks/use_error_group_distribution_fetcher'; @@ -68,44 +63,42 @@ function ErrorGroupHeader({ isUnhandled?: boolean; }) { return ( - <> - - - - -

- {i18n.translate('xpack.apm.errorGroupDetails.errorGroupTitle', { - defaultMessage: 'Error group {errorGroupId}', - values: { - errorGroupId: getShortGroupId(groupId), - }, - })} -

-
-
- {isUnhandled && ( - - - {i18n.translate('xpack.apm.errorGroupDetails.unhandledLabel', { - defaultMessage: 'Unhandled', - })} - - - )} -
-
- - + + + +

+ {i18n.translate('xpack.apm.errorGroupDetails.errorGroupTitle', { + defaultMessage: 'Error group {errorGroupId}', + values: { + errorGroupId: getShortGroupId(groupId), + }, + })} +

+
+
+ + {isUnhandled && ( + + + {i18n.translate('xpack.apm.errorGroupDetails.unhandledLabel', { + defaultMessage: 'Unhandled', + })} + + + )} +
); } -type ErrorGroupDetailsProps = RouteComponentProps<{ +interface ErrorGroupDetailsProps { groupId: string; serviceName: string; -}>; +} -export function ErrorGroupDetails({ location, match }: ErrorGroupDetailsProps) { - const { serviceName, groupId } = match.params; +export function ErrorGroupDetails({ + serviceName, + groupId, +}: ErrorGroupDetailsProps) { const { urlParams } = useUrlParams(); const { environment, kuery, start, end } = urlParams; const { data: errorGroupData } = useFetcher( @@ -154,66 +147,56 @@ export function ErrorGroupDetails({ location, match }: ErrorGroupDetailsProps) { return ( <> - - - - {showDetails && ( - - - {logMessage && ( - - - {logMessage} - - )} - - {excMessage || NOT_AVAILABLE_LABEL} + + + {showDetails && ( + + + {logMessage && ( + <> - {culprit || NOT_AVAILABLE_LABEL} - - - )} - {logMessage} + )} - /> - - - {showDetails && ( - + + {excMessage || NOT_AVAILABLE_LABEL} + + {culprit || NOT_AVAILABLE_LABEL} + + + )} + - + /> + + + {showDetails && ( + + )} ); } diff --git a/x-pack/plugins/apm/public/components/app/Home/Home.test.tsx b/x-pack/plugins/apm/public/components/app/Home/Home.test.tsx deleted file mode 100644 index ab3b76848c248..0000000000000 --- a/x-pack/plugins/apm/public/components/app/Home/Home.test.tsx +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { shallow } from 'enzyme'; -import React from 'react'; -import { Home } from '../Home'; -import { MockApmPluginContextWrapper } from '../../../context/apm_plugin/mock_apm_plugin_context'; - -describe('Home component', () => { - it('should render services', () => { - expect( - shallow( - - - - ) - ).toMatchSnapshot(); - }); - - it('should render traces', () => { - expect( - shallow( - - - - ) - ).toMatchSnapshot(); - }); -}); diff --git a/x-pack/plugins/apm/public/components/app/Home/__snapshots__/Home.test.tsx.snap b/x-pack/plugins/apm/public/components/app/Home/__snapshots__/Home.test.tsx.snap deleted file mode 100644 index f13cce3fd9b40..0000000000000 --- a/x-pack/plugins/apm/public/components/app/Home/__snapshots__/Home.test.tsx.snap +++ /dev/null @@ -1,189 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Home component should render services 1`] = ` - - - -`; - -exports[`Home component should render traces 1`] = ` - - - -`; diff --git a/x-pack/plugins/apm/public/components/app/Home/index.tsx b/x-pack/plugins/apm/public/components/app/Home/index.tsx deleted file mode 100644 index 834c2d5c40bce..0000000000000 --- a/x-pack/plugins/apm/public/components/app/Home/index.tsx +++ /dev/null @@ -1,80 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { EuiTab, EuiTitle } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import React, { ComponentType } from 'react'; -import { $ElementType } from 'utility-types'; -import { ApmHeader } from '../../shared/ApmHeader'; -import { useServiceMapHref } from '../../shared/Links/apm/ServiceMapLink'; -import { useServiceInventoryHref } from '../../shared/Links/apm/service_inventory_link'; -import { useTraceOverviewHref } from '../../shared/Links/apm/TraceOverviewLink'; -import { MainTabs } from '../../shared/main_tabs'; -import { ServiceMap } from '../ServiceMap'; -import { ServiceInventory } from '../service_inventory'; -import { TraceOverview } from '../trace_overview'; - -interface Tab { - key: string; - href: string; - text: string; - Component: ComponentType; -} - -interface Props { - tab: 'traces' | 'services' | 'service-map'; -} - -export function Home({ tab }: Props) { - const homeTabs: Tab[] = [ - { - key: 'services', - href: useServiceInventoryHref(), - text: i18n.translate('xpack.apm.home.servicesTabLabel', { - defaultMessage: 'Services', - }), - Component: ServiceInventory, - }, - { - key: 'traces', - href: useTraceOverviewHref(), - text: i18n.translate('xpack.apm.home.tracesTabLabel', { - defaultMessage: 'Traces', - }), - Component: TraceOverview, - }, - { - key: 'service-map', - href: useServiceMapHref(), - text: i18n.translate('xpack.apm.home.serviceMapTabLabel', { - defaultMessage: 'Service Map', - }), - Component: ServiceMap, - }, - ]; - const selectedTab = homeTabs.find( - (homeTab) => homeTab.key === tab - ) as $ElementType; - - return ( - <> - - -

APM

-
-
- - {homeTabs.map(({ href, key, text }) => ( - - {text} - - ))} - - - - ); -} diff --git a/x-pack/plugins/apm/public/components/app/Main/route_config/index.tsx b/x-pack/plugins/apm/public/components/app/Main/route_config/index.tsx deleted file mode 100644 index 89b8db5f386dc..0000000000000 --- a/x-pack/plugins/apm/public/components/app/Main/route_config/index.tsx +++ /dev/null @@ -1,369 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { i18n } from '@kbn/i18n'; -import React from 'react'; -import { Redirect, RouteComponentProps } from 'react-router-dom'; -import { ApmServiceContextProvider } from '../../../../context/apm_service/apm_service_context'; -import { getServiceNodeName } from '../../../../../common/service_nodes'; -import { APMRouteDefinition } from '../../../../application/routes'; -import { toQuery } from '../../../shared/Links/url_helpers'; -import { ErrorGroupDetails } from '../../ErrorGroupDetails'; -import { Home } from '../../Home'; -import { ServiceDetails } from '../../service_details'; -import { ServiceNodeMetrics } from '../../service_node_metrics'; -import { Settings } from '../../Settings'; -import { AgentConfigurations } from '../../Settings/AgentConfigurations'; -import { AnomalyDetection } from '../../Settings/anomaly_detection'; -import { ApmIndices } from '../../Settings/ApmIndices'; -import { CustomizeUI } from '../../Settings/CustomizeUI'; -import { TraceLink } from '../../TraceLink'; -import { TransactionDetails } from '../../transaction_details'; -import { - CreateAgentConfigurationRouteHandler, - EditAgentConfigurationRouteHandler, -} from './route_handlers/agent_configuration'; -import { enableServiceOverview } from '../../../../../common/ui_settings_keys'; -import { useApmPluginContext } from '../../../../context/apm_plugin/use_apm_plugin_context'; - -/** - * Given a path, redirect to that location, preserving the search and maintaining - * backward-compatibilty with legacy (pre-7.9) hash-based URLs. - */ -export function renderAsRedirectTo(to: string) { - return ({ location }: RouteComponentProps<{}>) => { - let resolvedUrl: URL | undefined; - - // Redirect root URLs with a hash to support backward compatibility with URLs - // from before we switched to the non-hash platform history. - if (location.pathname === '' && location.hash.length > 0) { - // We just want the search and pathname so the host doesn't matter - resolvedUrl = new URL(location.hash.slice(1), 'http://localhost'); - to = resolvedUrl.pathname; - } - - return ( - - ); - }; -} - -// These component function definitions are used below with the `component` -// property of the route definitions. -// -// If you provide an inline function to the component prop, you would create a -// new component every render. This results in the existing component unmounting -// and the new component mounting instead of just updating the existing component. -function HomeServices() { - return ; -} - -function HomeServiceMap() { - return ; -} - -function HomeTraces() { - return ; -} - -function ServiceDetailsErrors( - props: RouteComponentProps<{ serviceName: string }> -) { - return ; -} - -function ServiceDetailsMetrics( - props: RouteComponentProps<{ serviceName: string }> -) { - return ; -} - -function ServiceDetailsNodes( - props: RouteComponentProps<{ serviceName: string }> -) { - return ; -} - -function ServiceDetailsOverview( - props: RouteComponentProps<{ serviceName: string }> -) { - return ; -} - -function ServiceDetailsServiceMap( - props: RouteComponentProps<{ serviceName: string }> -) { - return ; -} - -function ServiceDetailsTransactions( - props: RouteComponentProps<{ serviceName: string }> -) { - return ; -} - -function ServiceDetailsProfiling( - props: RouteComponentProps<{ serviceName: string }> -) { - return ; -} - -function SettingsAgentConfiguration(props: RouteComponentProps<{}>) { - return ( - - - - ); -} - -function SettingsAnomalyDetection(props: RouteComponentProps<{}>) { - return ( - - - - ); -} - -function SettingsApmIndices(props: RouteComponentProps<{}>) { - return ( - - - - ); -} - -function SettingsCustomizeUI(props: RouteComponentProps<{}>) { - return ( - - - - ); -} - -function DefaultServicePageRouteHandler( - props: RouteComponentProps<{ serviceName: string }> -) { - const { uiSettings } = useApmPluginContext().core; - const { serviceName } = props.match.params; - if (uiSettings.get(enableServiceOverview)) { - return renderAsRedirectTo(`/services/${serviceName}/overview`)(props); - } - return renderAsRedirectTo(`/services/${serviceName}/transactions`)(props); -} - -/** - * The array of route definitions to be used when the application - * creates the routes. - */ -export const routes: APMRouteDefinition[] = [ - { - exact: true, - path: '/', - render: renderAsRedirectTo('/services'), - breadcrumb: 'APM', - }, - // !! Need to be kept in sync with the deepLinks in x-pack/plugins/apm/public/plugin.ts - { - exact: true, - path: '/services', - component: HomeServices, - breadcrumb: i18n.translate('xpack.apm.breadcrumb.servicesTitle', { - defaultMessage: 'Services', - }), - }, - // !! Need to be kept in sync with the deepLinks in x-pack/plugins/apm/public/plugin.ts - { - exact: true, - path: '/traces', - component: HomeTraces, - breadcrumb: i18n.translate('xpack.apm.breadcrumb.tracesTitle', { - defaultMessage: 'Traces', - }), - }, - { - exact: true, - path: '/settings', - render: renderAsRedirectTo('/settings/agent-configuration'), - breadcrumb: i18n.translate('xpack.apm.breadcrumb.listSettingsTitle', { - defaultMessage: 'Settings', - }), - }, - { - exact: true, - path: '/settings/apm-indices', - component: SettingsApmIndices, - breadcrumb: i18n.translate('xpack.apm.breadcrumb.settings.indicesTitle', { - defaultMessage: 'Indices', - }), - }, - { - exact: true, - path: '/settings/agent-configuration', - component: SettingsAgentConfiguration, - breadcrumb: i18n.translate( - 'xpack.apm.breadcrumb.settings.agentConfigurationTitle', - { defaultMessage: 'Agent Configuration' } - ), - }, - { - exact: true, - path: '/settings/agent-configuration/create', - breadcrumb: i18n.translate( - 'xpack.apm.breadcrumb.settings.createAgentConfigurationTitle', - { defaultMessage: 'Create Agent Configuration' } - ), - component: CreateAgentConfigurationRouteHandler, - }, - { - exact: true, - path: '/settings/agent-configuration/edit', - breadcrumb: i18n.translate( - 'xpack.apm.breadcrumb.settings.editAgentConfigurationTitle', - { defaultMessage: 'Edit Agent Configuration' } - ), - component: EditAgentConfigurationRouteHandler, - }, - { - exact: true, - path: '/services/:serviceName', - breadcrumb: ({ match }) => match.params.serviceName, - component: DefaultServicePageRouteHandler, - } as APMRouteDefinition<{ serviceName: string }>, - { - exact: true, - path: '/services/:serviceName/overview', - breadcrumb: i18n.translate('xpack.apm.breadcrumb.overviewTitle', { - defaultMessage: 'Overview', - }), - component: withApmServiceContext(ServiceDetailsOverview), - } as APMRouteDefinition<{ serviceName: string }>, - // errors - { - exact: true, - path: '/services/:serviceName/errors/:groupId', - component: withApmServiceContext(ErrorGroupDetails), - breadcrumb: ({ match }) => match.params.groupId, - } as APMRouteDefinition<{ groupId: string; serviceName: string }>, - { - exact: true, - path: '/services/:serviceName/errors', - component: withApmServiceContext(ServiceDetailsErrors), - breadcrumb: i18n.translate('xpack.apm.breadcrumb.errorsTitle', { - defaultMessage: 'Errors', - }), - }, - // transactions - { - exact: true, - path: '/services/:serviceName/transactions', - component: withApmServiceContext(ServiceDetailsTransactions), - breadcrumb: i18n.translate('xpack.apm.breadcrumb.transactionsTitle', { - defaultMessage: 'Transactions', - }), - }, - // metrics - { - exact: true, - path: '/services/:serviceName/metrics', - component: withApmServiceContext(ServiceDetailsMetrics), - breadcrumb: i18n.translate('xpack.apm.breadcrumb.metricsTitle', { - defaultMessage: 'Metrics', - }), - }, - // service nodes, only enabled for java agents for now - { - exact: true, - path: '/services/:serviceName/nodes', - component: withApmServiceContext(ServiceDetailsNodes), - breadcrumb: i18n.translate('xpack.apm.breadcrumb.nodesTitle', { - defaultMessage: 'JVMs', - }), - }, - // node metrics - { - exact: true, - path: '/services/:serviceName/nodes/:serviceNodeName/metrics', - component: withApmServiceContext(ServiceNodeMetrics), - breadcrumb: ({ match }) => getServiceNodeName(match.params.serviceNodeName), - }, - { - exact: true, - path: '/services/:serviceName/transactions/view', - component: withApmServiceContext(TransactionDetails), - breadcrumb: ({ location }) => { - const query = toQuery(location.search); - return query.transactionName as string; - }, - }, - { - exact: true, - path: '/services/:serviceName/profiling', - component: withApmServiceContext(ServiceDetailsProfiling), - breadcrumb: i18n.translate('xpack.apm.breadcrumb.serviceProfilingTitle', { - defaultMessage: 'Profiling', - }), - }, - { - exact: true, - path: '/services/:serviceName/service-map', - component: withApmServiceContext(ServiceDetailsServiceMap), - breadcrumb: i18n.translate('xpack.apm.breadcrumb.serviceMapTitle', { - defaultMessage: 'Service Map', - }), - }, - { - exact: true, - path: '/link-to/trace/:traceId', - component: TraceLink, - breadcrumb: null, - }, - // !! Need to be kept in sync with the deepLinks in x-pack/plugins/apm/public/plugin.ts - { - exact: true, - path: '/service-map', - component: HomeServiceMap, - breadcrumb: i18n.translate('xpack.apm.breadcrumb.serviceMapTitle', { - defaultMessage: 'Service Map', - }), - }, - { - exact: true, - path: '/settings/customize-ui', - component: SettingsCustomizeUI, - breadcrumb: i18n.translate('xpack.apm.breadcrumb.settings.customizeUI', { - defaultMessage: 'Customize UI', - }), - }, - { - exact: true, - path: '/settings/anomaly-detection', - component: SettingsAnomalyDetection, - breadcrumb: i18n.translate( - 'xpack.apm.breadcrumb.settings.anomalyDetection', - { - defaultMessage: 'Anomaly detection', - } - ), - }, -]; - -function withApmServiceContext(WrappedComponent: React.ComponentType) { - return (props: any) => { - return ( - - - - ); - }; -} diff --git a/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/index.tsx b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/index.tsx index 3225951fd6c70..b781a6569cc35 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/index.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/index.tsx @@ -42,12 +42,12 @@ export function AgentConfigurations() { return ( <> - -

+ +

{i18n.translate('xpack.apm.agentConfig.titleText', { defaultMessage: 'Agent central configuration', })} -

+

diff --git a/x-pack/plugins/apm/public/components/app/Settings/ApmIndices/index.test.tsx b/x-pack/plugins/apm/public/components/app/Settings/ApmIndices/index.test.tsx index 70672df85b649..28cb4ebd51cdd 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/ApmIndices/index.test.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/ApmIndices/index.test.tsx @@ -25,11 +25,11 @@ describe('ApmIndices', () => { ); expect(getByText('Indices')).toMatchInlineSnapshot(` -

Indices -

+ `); expect(spy).toHaveBeenCalledTimes(2); diff --git a/x-pack/plugins/apm/public/components/app/Settings/ApmIndices/index.tsx b/x-pack/plugins/apm/public/components/app/Settings/ApmIndices/index.tsx index 9d2b4bba22afb..44a3c4655417c 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/ApmIndices/index.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/ApmIndices/index.tsx @@ -176,12 +176,12 @@ export function ApmIndices() { return ( <> - -

+ +

{i18n.translate('xpack.apm.settings.apmIndices.title', { defaultMessage: 'Indices', })} -

+

diff --git a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/index.tsx b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/index.tsx index fabd70cec6647..c4b3c39248ffb 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/index.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/index.tsx @@ -13,12 +13,12 @@ import { CustomLinkOverview } from './CustomLink'; export function CustomizeUI() { return ( <> - -

+ +

{i18n.translate('xpack.apm.settings.customizeApp.title', { defaultMessage: 'Customize app', })} -

+

diff --git a/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/index.tsx b/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/index.tsx index 62b39664cf63d..38b9970f64d32 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/index.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/index.tsx @@ -66,12 +66,12 @@ export function AnomalyDetection() { return ( <> - -

+ +

{i18n.translate('xpack.apm.settings.anomalyDetection.titleText', { defaultMessage: 'Anomaly detection', })} -

+

diff --git a/x-pack/plugins/apm/public/components/app/Settings/index.tsx b/x-pack/plugins/apm/public/components/app/Settings/index.tsx index 36c36e3957e96..b4cba2afc2550 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/index.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/index.tsx @@ -5,32 +5,19 @@ * 2.0. */ -import { - EuiButtonEmpty, - EuiPage, - EuiPageBody, - EuiPageSideBar, - EuiSideNav, - EuiSpacer, -} from '@elastic/eui'; +import { EuiPage, EuiPageBody, EuiPageSideBar, EuiSideNav } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { ReactNode, useState } from 'react'; -import { RouteComponentProps } from 'react-router-dom'; -import { HeaderMenuPortal } from '../../../../../observability/public'; -import { ActionMenu } from '../../../application/action_menu'; +import { useHistory } from 'react-router-dom'; import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context'; import { getAPMHref } from '../../shared/Links/apm/APMLink'; -import { HomeLink } from '../../shared/Links/apm/HomeLink'; -interface SettingsProps extends RouteComponentProps<{}> { - children: ReactNode; -} - -export function Settings({ children, location }: SettingsProps) { - const { appMountParameters, core } = useApmPluginContext(); +export function Settings({ children }: { children: ReactNode }) { + const { core } = useApmPluginContext(); + const history = useHistory(); const { basePath } = core.http; const canAccessML = !!core.application.capabilities.ml?.canAccessML; - const { search, pathname } = location; + const { search, pathname } = history.location; const [isSideNavOpenOnMobile, setisSideNavOpenOnMobile] = useState(false); @@ -43,86 +30,65 @@ export function Settings({ children, location }: SettingsProps) { } return ( - <> - - - - - - - - {i18n.translate('xpack.apm.settings.returnLinkLabel', { - defaultMessage: 'Return to inventory', - })} - - - - toggleOpenOnMobile()} - isOpenOnMobile={isSideNavOpenOnMobile} - items={[ - { - name: i18n.translate('xpack.apm.settings.pageTitle', { - defaultMessage: 'Settings', - }), - id: 0, - items: [ - { - name: i18n.translate('xpack.apm.settings.agentConfig', { - defaultMessage: 'Agent Configuration', - }), - id: '1', - href: getSettingsHref('/agent-configuration'), - isSelected: pathname.startsWith( - '/settings/agent-configuration' - ), - }, - ...(canAccessML - ? [ - { - name: i18n.translate( - 'xpack.apm.settings.anomalyDetection', - { - defaultMessage: 'Anomaly detection', - } - ), - id: '4', - href: getSettingsHref('/anomaly-detection'), - isSelected: - pathname === '/settings/anomaly-detection', - }, - ] - : []), - { - name: i18n.translate('xpack.apm.settings.customizeApp', { - defaultMessage: 'Customize app', - }), - id: '3', - href: getSettingsHref('/customize-ui'), - isSelected: pathname === '/settings/customize-ui', - }, - { - name: i18n.translate('xpack.apm.settings.indices', { - defaultMessage: 'Indices', - }), - id: '2', - href: getSettingsHref('/apm-indices'), - isSelected: pathname === '/settings/apm-indices', - }, - ], - }, - ]} - /> - - {children} - - + + + toggleOpenOnMobile()} + isOpenOnMobile={isSideNavOpenOnMobile} + items={[ + { + name: i18n.translate('xpack.apm.settings.pageTitle', { + defaultMessage: 'Settings', + }), + id: 0, + items: [ + { + name: i18n.translate('xpack.apm.settings.agentConfig', { + defaultMessage: 'Agent Configuration', + }), + id: '1', + href: getSettingsHref('/agent-configuration'), + isSelected: pathname.startsWith( + '/settings/agent-configuration' + ), + }, + ...(canAccessML + ? [ + { + name: i18n.translate( + 'xpack.apm.settings.anomalyDetection', + { + defaultMessage: 'Anomaly detection', + } + ), + id: '4', + href: getSettingsHref('/anomaly-detection'), + isSelected: pathname === '/settings/anomaly-detection', + }, + ] + : []), + { + name: i18n.translate('xpack.apm.settings.customizeApp', { + defaultMessage: 'Customize app', + }), + id: '3', + href: getSettingsHref('/customize-ui'), + isSelected: pathname === '/settings/customize-ui', + }, + { + name: i18n.translate('xpack.apm.settings.indices', { + defaultMessage: 'Indices', + }), + id: '2', + href: getSettingsHref('/apm-indices'), + isSelected: pathname === '/settings/apm-indices', + }, + ], + }, + ]} + /> + + {children} + ); } diff --git a/x-pack/plugins/apm/public/components/app/error_group_overview/index.tsx b/x-pack/plugins/apm/public/components/app/error_group_overview/index.tsx index 6f7a8228db298..95ec80b1a51bc 100644 --- a/x-pack/plugins/apm/public/components/app/error_group_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/error_group_overview/index.tsx @@ -7,7 +7,7 @@ import { EuiFlexGroup, - EuiPage, + EuiFlexItem, EuiPanel, EuiSpacer, EuiTitle, @@ -18,7 +18,6 @@ import { useTrackPageview } from '../../../../../observability/public'; import { useUrlParams } from '../../../context/url_params_context/use_url_params'; import { useErrorGroupDistributionFetcher } from '../../../hooks/use_error_group_distribution_fetcher'; import { useFetcher } from '../../../hooks/use_fetcher'; -import { SearchBar } from '../../shared/search_bar'; import { ErrorDistribution } from '../ErrorGroupDetails/Distribution'; import { ErrorGroupList } from './List'; @@ -68,41 +67,41 @@ export function ErrorGroupOverview({ serviceName }: ErrorGroupOverviewProps) { useTrackPageview({ app: 'apm', path: 'error_group_overview', delay: 15000 }); if (!errorDistributionData || !errorGroupListData) { - return ; + return null; } return ( - <> - - - - - - + + + + + + + + + +

+ {i18n.translate( + 'xpack.apm.serviceDetails.metrics.errorsList.title', + { defaultMessage: 'Errors' } + )} +

+
- - -

Errors

-
- - - -
-
-
- + + +
+
); } diff --git a/x-pack/plugins/apm/public/components/app/service_details/index.tsx b/x-pack/plugins/apm/public/components/app/service_details/index.tsx deleted file mode 100644 index 29bb1c04ab945..0000000000000 --- a/x-pack/plugins/apm/public/components/app/service_details/index.tsx +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { EuiFlexGroup, EuiFlexItem, EuiTitle } from '@elastic/eui'; -import React from 'react'; -import { RouteComponentProps } from 'react-router-dom'; -import { ApmHeader } from '../../shared/ApmHeader'; -import { ServiceIcons } from './service_icons'; -import { ServiceDetailTabs } from './service_detail_tabs'; - -interface Props extends RouteComponentProps<{ serviceName: string }> { - tab: React.ComponentProps['tab']; -} - -export function ServiceDetails({ match, tab }: Props) { - const { serviceName } = match.params; - - return ( -
- - - - -

{serviceName}

-
-
- - - -
-
- -
- ); -} diff --git a/x-pack/plugins/apm/public/components/app/service_details/service_detail_tabs.tsx b/x-pack/plugins/apm/public/components/app/service_details/service_detail_tabs.tsx deleted file mode 100644 index d360b186aba16..0000000000000 --- a/x-pack/plugins/apm/public/components/app/service_details/service_detail_tabs.tsx +++ /dev/null @@ -1,202 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { EuiTab } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import React, { ReactNode } from 'react'; -import { EuiBetaBadge } from '@elastic/eui'; -import { EuiFlexItem } from '@elastic/eui'; -import { EuiFlexGroup } from '@elastic/eui'; -import { isJavaAgentName, isRumAgentName } from '../../../../common/agent_name'; -import { enableServiceOverview } from '../../../../common/ui_settings_keys'; -import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context'; -import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; -import { useUrlParams } from '../../../context/url_params_context/use_url_params'; -import { useErrorOverviewHref } from '../../shared/Links/apm/ErrorOverviewLink'; -import { useMetricOverviewHref } from '../../shared/Links/apm/MetricOverviewLink'; -import { useServiceMapHref } from '../../shared/Links/apm/ServiceMapLink'; -import { useServiceNodeOverviewHref } from '../../shared/Links/apm/ServiceNodeOverviewLink'; -import { useServiceOverviewHref } from '../../shared/Links/apm/service_overview_link'; -import { useTransactionsOverviewHref } from '../../shared/Links/apm/transaction_overview_link'; -import { useServiceProfilingHref } from '../../shared/Links/apm/service_profiling_link'; -import { MainTabs } from '../../shared/main_tabs'; -import { ErrorGroupOverview } from '../error_group_overview'; -import { ServiceMap } from '../ServiceMap'; -import { ServiceNodeOverview } from '../service_node_overview'; -import { ServiceMetrics } from '../service_metrics'; -import { ServiceOverview } from '../service_overview'; -import { TransactionOverview } from '../transaction_overview'; -import { ServiceProfiling } from '../service_profiling'; -import { Correlations } from '../correlations'; - -interface Tab { - key: string; - href: string; - text: ReactNode; - hidden?: boolean; - render: () => ReactNode; -} - -interface Props { - serviceName: string; - tab: - | 'errors' - | 'metrics' - | 'nodes' - | 'overview' - | 'service-map' - | 'profiling' - | 'transactions'; -} - -export function ServiceDetailTabs({ serviceName, tab }: Props) { - const { agentName, transactionType } = useApmServiceContext(); - const { - core: { uiSettings }, - config, - } = useApmPluginContext(); - const { - urlParams: { latencyAggregationType }, - } = useUrlParams(); - - const overviewTab = { - key: 'overview', - href: useServiceOverviewHref({ serviceName, transactionType }), - text: i18n.translate('xpack.apm.serviceDetails.overviewTabLabel', { - defaultMessage: 'Overview', - }), - render: () => ( - - ), - }; - - const transactionsTab = { - key: 'transactions', - href: useTransactionsOverviewHref({ - serviceName, - latencyAggregationType, - transactionType, - }), - text: i18n.translate('xpack.apm.serviceDetails.transactionsTabLabel', { - defaultMessage: 'Transactions', - }), - render: () => , - }; - - const errorsTab = { - key: 'errors', - href: useErrorOverviewHref(serviceName), - text: i18n.translate('xpack.apm.serviceDetails.errorsTabLabel', { - defaultMessage: 'Errors', - }), - render: () => { - return ; - }, - }; - - const serviceMapTab = { - key: 'service-map', - href: useServiceMapHref(serviceName), - text: i18n.translate('xpack.apm.home.serviceMapTabLabel', { - defaultMessage: 'Service Map', - }), - render: () => , - }; - - const nodesListTab = { - key: 'nodes', - href: useServiceNodeOverviewHref(serviceName), - text: i18n.translate('xpack.apm.serviceDetails.nodesTabLabel', { - defaultMessage: 'JVMs', - }), - render: () => , - }; - - const metricsTab = { - key: 'metrics', - href: useMetricOverviewHref(serviceName), - text: i18n.translate('xpack.apm.serviceDetails.metricsTabLabel', { - defaultMessage: 'Metrics', - }), - render: () => - agentName ? ( - - ) : null, - }; - - const profilingTab = { - key: 'profiling', - href: useServiceProfilingHref({ serviceName }), - hidden: !config.profilingEnabled, - text: ( - - - {i18n.translate('xpack.apm.serviceDetails.profilingTabLabel', { - defaultMessage: 'Profiling', - })} - - - - - - ), - render: () => , - }; - - const tabs: Tab[] = [transactionsTab, errorsTab]; - - if (uiSettings.get(enableServiceOverview)) { - tabs.unshift(overviewTab); - } - - if (isJavaAgentName(agentName)) { - tabs.push(nodesListTab); - } else if (agentName && !isRumAgentName(agentName)) { - tabs.push(metricsTab); - } - - tabs.push(serviceMapTab, profilingTab); - - const selectedTab = tabs.find((serviceTab) => serviceTab.key === tab); - - return ( - <> - - {tabs - .filter((t) => !t.hidden) - .map(({ href, key, text }) => ( - - {text} - - ))} -
- -
-
- {selectedTab ? selectedTab.render() : null} - - ); -} diff --git a/x-pack/plugins/apm/public/components/app/service_inventory/index.tsx b/x-pack/plugins/apm/public/components/app/service_inventory/index.tsx index 9c4728488d96a..78f02c5a66701 100644 --- a/x-pack/plugins/apm/public/components/app/service_inventory/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_inventory/index.tsx @@ -5,13 +5,7 @@ * 2.0. */ -import { - EuiFlexGroup, - EuiFlexItem, - EuiLink, - EuiPage, - EuiPanel, -} from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiLink, EuiPanel } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { useEffect } from 'react'; import { toMountPoint } from '../../../../../../../src/plugins/kibana_react/public'; @@ -126,28 +120,26 @@ export function ServiceInventory() { return ( <> - - - {displayMlCallout ? ( - - setUserHasDismissedCallout(true)} /> - - ) : null} + + {displayMlCallout && ( - - - } - /> - + setUserHasDismissedCallout(true)} /> - - + )} + + + + } + /> + + + ); } diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Controls.test.tsx b/x-pack/plugins/apm/public/components/app/service_map/Controls.test.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/Controls.test.tsx rename to x-pack/plugins/apm/public/components/app/service_map/Controls.test.tsx diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Controls.tsx b/x-pack/plugins/apm/public/components/app/service_map/Controls.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/Controls.tsx rename to x-pack/plugins/apm/public/components/app/service_map/Controls.tsx diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx b/x-pack/plugins/apm/public/components/app/service_map/Cytoscape.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx rename to x-pack/plugins/apm/public/components/app/service_map/Cytoscape.tsx diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/EmptyBanner.tsx b/x-pack/plugins/apm/public/components/app/service_map/EmptyBanner.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/EmptyBanner.tsx rename to x-pack/plugins/apm/public/components/app/service_map/EmptyBanner.tsx diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/AnomalyDetection.tsx b/x-pack/plugins/apm/public/components/app/service_map/Popover/AnomalyDetection.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/Popover/AnomalyDetection.tsx rename to x-pack/plugins/apm/public/components/app/service_map/Popover/AnomalyDetection.tsx diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Buttons.test.tsx b/x-pack/plugins/apm/public/components/app/service_map/Popover/Buttons.test.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Buttons.test.tsx rename to x-pack/plugins/apm/public/components/app/service_map/Popover/Buttons.test.tsx diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Buttons.tsx b/x-pack/plugins/apm/public/components/app/service_map/Popover/Buttons.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Buttons.tsx rename to x-pack/plugins/apm/public/components/app/service_map/Popover/Buttons.tsx diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Contents.tsx b/x-pack/plugins/apm/public/components/app/service_map/Popover/Contents.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Contents.tsx rename to x-pack/plugins/apm/public/components/app/service_map/Popover/Contents.tsx diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Info.tsx b/x-pack/plugins/apm/public/components/app/service_map/Popover/Info.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Info.tsx rename to x-pack/plugins/apm/public/components/app/service_map/Popover/Info.tsx diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Popover.stories.tsx b/x-pack/plugins/apm/public/components/app/service_map/Popover/Popover.stories.tsx similarity index 97% rename from x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Popover.stories.tsx rename to x-pack/plugins/apm/public/components/app/service_map/Popover/Popover.stories.tsx index ac1846155569a..fe3922060533a 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Popover.stories.tsx +++ b/x-pack/plugins/apm/public/components/app/service_map/Popover/Popover.stories.tsx @@ -13,11 +13,11 @@ import { MockApmPluginContextWrapper } from '../../../../context/apm_plugin/mock import { MockUrlParamsContextProvider } from '../../../../context/url_params_context/mock_url_params_context_provider'; import { createCallApmApi } from '../../../../services/rest/createCallApmApi'; import { CytoscapeContext } from '../Cytoscape'; -import { Popover } from './'; +import { Popover } from '.'; import exampleGroupedConnectionsData from '../__stories__/example_grouped_connections.json'; export default { - title: 'app/ServiceMap/Popover', + title: 'app/service_map/Popover', component: Popover, decorators: [ (Story: ComponentType) => { diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/ServiceStatsFetcher.tsx b/x-pack/plugins/apm/public/components/app/service_map/Popover/ServiceStatsFetcher.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/Popover/ServiceStatsFetcher.tsx rename to x-pack/plugins/apm/public/components/app/service_map/Popover/ServiceStatsFetcher.tsx diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/ServiceStatsList.tsx b/x-pack/plugins/apm/public/components/app/service_map/Popover/ServiceStatsList.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/Popover/ServiceStatsList.tsx rename to x-pack/plugins/apm/public/components/app/service_map/Popover/ServiceStatsList.tsx diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/index.tsx b/x-pack/plugins/apm/public/components/app/service_map/Popover/index.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/Popover/index.tsx rename to x-pack/plugins/apm/public/components/app/service_map/Popover/index.tsx diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/service_stats_list.stories.tsx b/x-pack/plugins/apm/public/components/app/service_map/Popover/service_stats_list.stories.tsx similarity index 96% rename from x-pack/plugins/apm/public/components/app/ServiceMap/Popover/service_stats_list.stories.tsx rename to x-pack/plugins/apm/public/components/app/service_map/Popover/service_stats_list.stories.tsx index a8f004a7295d9..83f0a3ea7e4b9 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/service_stats_list.stories.tsx +++ b/x-pack/plugins/apm/public/components/app/service_map/Popover/service_stats_list.stories.tsx @@ -10,7 +10,7 @@ import { EuiThemeProvider } from '../../../../../../../../src/plugins/kibana_rea import { ServiceStatsList } from './ServiceStatsList'; export default { - title: 'app/ServiceMap/Popover/ServiceStatsList', + title: 'app/service_map/Popover/ServiceStatsList', component: ServiceStatsList, decorators: [ (Story: ComponentType) => ( diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/Cytoscape.stories.tsx b/x-pack/plugins/apm/public/components/app/service_map/__stories__/Cytoscape.stories.tsx similarity index 99% rename from x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/Cytoscape.stories.tsx rename to x-pack/plugins/apm/public/components/app/service_map/__stories__/Cytoscape.stories.tsx index 37644c084815e..c3f3c09e10e4f 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/Cytoscape.stories.tsx +++ b/x-pack/plugins/apm/public/components/app/service_map/__stories__/Cytoscape.stories.tsx @@ -14,7 +14,7 @@ import { iconForNode } from '../icons'; import { Centerer } from './centerer'; export default { - title: 'app/ServiceMap/Cytoscape', + title: 'app/service_map/Cytoscape', component: Cytoscape, decorators: [ (Story: ComponentType) => ( diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/centerer.tsx b/x-pack/plugins/apm/public/components/app/service_map/__stories__/centerer.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/centerer.tsx rename to x-pack/plugins/apm/public/components/app/service_map/__stories__/centerer.tsx diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/cytoscape_example_data.stories.tsx b/x-pack/plugins/apm/public/components/app/service_map/__stories__/cytoscape_example_data.stories.tsx similarity index 98% rename from x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/cytoscape_example_data.stories.tsx rename to x-pack/plugins/apm/public/components/app/service_map/__stories__/cytoscape_example_data.stories.tsx index 41eecb9181a2c..84351d5716edb 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/cytoscape_example_data.stories.tsx +++ b/x-pack/plugins/apm/public/components/app/service_map/__stories__/cytoscape_example_data.stories.tsx @@ -25,7 +25,7 @@ import exampleResponseOpbeansBeats from './example_response_opbeans_beats.json'; import exampleResponseTodo from './example_response_todo.json'; import { generateServiceMapElements } from './generate_service_map_elements'; -const STORYBOOK_PATH = 'app/ServiceMap/Cytoscape/Example data'; +const STORYBOOK_PATH = 'app/service_map/Cytoscape/Example data'; const SESSION_STORAGE_KEY = `${STORYBOOK_PATH}/pre-loaded map`; function getSessionJson() { @@ -40,7 +40,7 @@ function getHeight() { } export default { - title: 'app/ServiceMap/Cytoscape/Example data', + title: 'app/service_map/Cytoscape/Example data', component: Cytoscape, decorators: [ (Story: ComponentType) => ( diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/example_grouped_connections.json b/x-pack/plugins/apm/public/components/app/service_map/__stories__/example_grouped_connections.json similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/example_grouped_connections.json rename to x-pack/plugins/apm/public/components/app/service_map/__stories__/example_grouped_connections.json diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/example_response_hipster_store.json b/x-pack/plugins/apm/public/components/app/service_map/__stories__/example_response_hipster_store.json similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/example_response_hipster_store.json rename to x-pack/plugins/apm/public/components/app/service_map/__stories__/example_response_hipster_store.json diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/example_response_opbeans_beats.json b/x-pack/plugins/apm/public/components/app/service_map/__stories__/example_response_opbeans_beats.json similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/example_response_opbeans_beats.json rename to x-pack/plugins/apm/public/components/app/service_map/__stories__/example_response_opbeans_beats.json diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/example_response_todo.json b/x-pack/plugins/apm/public/components/app/service_map/__stories__/example_response_todo.json similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/example_response_todo.json rename to x-pack/plugins/apm/public/components/app/service_map/__stories__/example_response_todo.json diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/generate_service_map_elements.ts b/x-pack/plugins/apm/public/components/app/service_map/__stories__/generate_service_map_elements.ts similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/generate_service_map_elements.ts rename to x-pack/plugins/apm/public/components/app/service_map/__stories__/generate_service_map_elements.ts diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/cytoscape_options.ts b/x-pack/plugins/apm/public/components/app/service_map/cytoscape_options.ts similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/cytoscape_options.ts rename to x-pack/plugins/apm/public/components/app/service_map/cytoscape_options.ts diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/empty_banner.test.tsx b/x-pack/plugins/apm/public/components/app/service_map/empty_banner.test.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/empty_banner.test.tsx rename to x-pack/plugins/apm/public/components/app/service_map/empty_banner.test.tsx diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/empty_prompt.tsx b/x-pack/plugins/apm/public/components/app/service_map/empty_prompt.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/empty_prompt.tsx rename to x-pack/plugins/apm/public/components/app/service_map/empty_prompt.tsx diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/icons.ts b/x-pack/plugins/apm/public/components/app/service_map/icons.ts similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/icons.ts rename to x-pack/plugins/apm/public/components/app/service_map/icons.ts diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/index.test.tsx b/x-pack/plugins/apm/public/components/app/service_map/index.test.tsx similarity index 99% rename from x-pack/plugins/apm/public/components/app/ServiceMap/index.test.tsx rename to x-pack/plugins/apm/public/components/app/service_map/index.test.tsx index e8384de1d15ba..f68d8e46f66e3 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/index.test.tsx +++ b/x-pack/plugins/apm/public/components/app/service_map/index.test.tsx @@ -15,7 +15,7 @@ import { EuiThemeProvider } from '../../../../../../../src/plugins/kibana_react/ import { MockApmPluginContextWrapper } from '../../../context/apm_plugin/mock_apm_plugin_context'; import { LicenseContext } from '../../../context/license/license_context'; import * as useFetcherModule from '../../../hooks/use_fetcher'; -import { ServiceMap } from './'; +import { ServiceMap } from '.'; import { UrlParamsProvider } from '../../../context/url_params_context/url_params_context'; import { Router } from 'react-router-dom'; diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/index.tsx b/x-pack/plugins/apm/public/components/app/service_map/index.tsx similarity index 87% rename from x-pack/plugins/apm/public/components/app/ServiceMap/index.tsx rename to x-pack/plugins/apm/public/components/app/service_map/index.tsx index b338d1e4ab03d..714228d58f962 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_map/index.tsx @@ -7,7 +7,6 @@ import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; import React, { PropsWithChildren, ReactNode } from 'react'; -import { euiStyled } from '../../../../../../../src/plugins/kibana_react/common'; import { isActivePlatinumLicense } from '../../../../common/license_check'; import { useTrackPageview } from '../../../../../observability/public'; import { @@ -18,7 +17,6 @@ import { FETCH_STATUS, useFetcher } from '../../../hooks/use_fetcher'; import { useLicenseContext } from '../../../context/license/use_license_context'; import { useTheme } from '../../../hooks/use_theme'; import { useUrlParams } from '../../../context/url_params_context/use_url_params'; -import { DatePicker } from '../../shared/DatePicker'; import { LicensePrompt } from '../../shared/license_prompt'; import { Controls } from './Controls'; import { Cytoscape } from './Cytoscape'; @@ -28,31 +26,16 @@ import { EmptyPrompt } from './empty_prompt'; import { Popover } from './Popover'; import { TimeoutPrompt } from './timeout_prompt'; import { useRefDimensions } from './useRefDimensions'; +import { SearchBar } from '../../shared/search_bar'; interface ServiceMapProps { serviceName?: string; } -const ServiceMapDatePickerFlexGroup = euiStyled(EuiFlexGroup)` - padding: ${({ theme }) => theme.eui.euiSizeM}; - border-bottom: ${({ theme }) => theme.eui.euiBorderThin}; - margin: 0; -`; - -function DatePickerSection() { - return ( - - - - - - ); -} - function PromptContainer({ children }: { children: ReactNode }) { return ( <> - + - + +
- - - - - - {data.charts.map((chart) => ( - - - - - - ))} - - - - - - + + + {data.charts.map((chart) => ( + + + + + + ))} + + + ); } diff --git a/x-pack/plugins/apm/public/components/app/service_node_metrics/index.test.tsx b/x-pack/plugins/apm/public/components/app/service_node_metrics/index.test.tsx index d9cd003042a45..8711366fdd185 100644 --- a/x-pack/plugins/apm/public/components/app/service_node_metrics/index.test.tsx +++ b/x-pack/plugins/apm/public/components/app/service_node_metrics/index.test.tsx @@ -9,20 +9,17 @@ import React from 'react'; import { shallow } from 'enzyme'; import { ServiceNodeMetrics } from '.'; import { MockApmPluginContextWrapper } from '../../../context/apm_plugin/mock_apm_plugin_context'; -import { RouteComponentProps } from 'react-router-dom'; describe('ServiceNodeMetrics', () => { describe('render', () => { it('renders', () => { - const props = ({} as unknown) as RouteComponentProps<{ - serviceName: string; - serviceNodeName: string; - }>; - expect(() => shallow( - + ) ).not.toThrowError(); diff --git a/x-pack/plugins/apm/public/components/app/service_node_metrics/index.tsx b/x-pack/plugins/apm/public/components/app/service_node_metrics/index.tsx index 186c148fa6918..20b78b90e0378 100644 --- a/x-pack/plugins/apm/public/components/app/service_node_metrics/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_node_metrics/index.tsx @@ -10,17 +10,14 @@ import { EuiFlexGrid, EuiFlexGroup, EuiFlexItem, - EuiPage, EuiPanel, EuiSpacer, EuiStat, - EuiTitle, EuiToolTip, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import React from 'react'; -import { RouteComponentProps } from 'react-router-dom'; import { euiStyled } from '../../../../../../../src/plugins/kibana_react/common'; import { SERVICE_NODE_NAME_MISSING } from '../../../../common/service_nodes'; import { ChartPointerEventContextProvider } from '../../../context/chart_pointer_event/chart_pointer_event_context'; @@ -29,10 +26,8 @@ import { useServiceMetricChartsFetcher } from '../../../hooks/use_service_metric import { useUrlParams } from '../../../context/url_params_context/use_url_params'; import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; import { px, truncate, unit } from '../../../style/variables'; -import { ApmHeader } from '../../shared/ApmHeader'; import { MetricsChart } from '../../shared/charts/metrics_chart'; import { ElasticDocsLink } from '../../shared/Links/ElasticDocsLink'; -import { SearchBar } from '../../shared/search_bar'; const INITIAL_DATA = { host: '', @@ -51,16 +46,18 @@ const MetadataFlexGroup = euiStyled(EuiFlexGroup)` `${theme.eui.paddingSizes.m} 0 0 ${theme.eui.paddingSizes.m}`}; `; -type ServiceNodeMetricsProps = RouteComponentProps<{ +interface ServiceNodeMetricsProps { serviceName: string; serviceNodeName: string; -}>; +} -export function ServiceNodeMetrics({ match }: ServiceNodeMetricsProps) { +export function ServiceNodeMetrics({ + serviceName, + serviceNodeName, +}: ServiceNodeMetricsProps) { const { urlParams: { kuery, start, end }, } = useUrlParams(); - const { serviceName, serviceNodeName } = match.params; const { agentName } = useApmServiceContext(); const { data } = useServiceMetricChartsFetcher({ serviceNodeName }); @@ -89,15 +86,6 @@ export function ServiceNodeMetrics({ match }: ServiceNodeMetricsProps) { return ( <> - - - - -

{serviceName}

-
-
-
-
{isAggregatedData ? ( )} - - - {agentName && ( - - - {data.charts.map((chart) => ( - - - - - - ))} - - - - )} - + + {agentName && ( + + + {data.charts.map((chart) => ( + + + + + + ))} + + + + )} ); } diff --git a/x-pack/plugins/apm/public/components/app/service_node_overview/index.tsx b/x-pack/plugins/apm/public/components/app/service_node_overview/index.tsx index 3d284de621ea3..69e5ea5a78ea1 100644 --- a/x-pack/plugins/apm/public/components/app/service_node_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_node_overview/index.tsx @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { EuiFlexGroup, EuiPage, EuiPanel, EuiToolTip } from '@elastic/eui'; +import { EuiPanel, EuiToolTip } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; import { euiStyled } from '../../../../../../../src/plugins/kibana_react/common'; @@ -22,7 +22,6 @@ import { useFetcher } from '../../../hooks/use_fetcher'; import { px, truncate, unit } from '../../../style/variables'; import { ServiceNodeMetricOverviewLink } from '../../shared/Links/apm/ServiceNodeMetricOverviewLink'; import { ITableColumn, ManagedTable } from '../../shared/ManagedTable'; -import { SearchBar } from '../../shared/search_bar'; const INITIAL_PAGE_SIZE = 25; const INITIAL_SORT_FIELD = 'cpu'; @@ -143,28 +142,18 @@ function ServiceNodeOverview({ serviceName }: ServiceNodeOverviewProps) { ]; return ( - <> - - - - - - - - - + + + ); } diff --git a/x-pack/plugins/apm/public/components/app/service_overview/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/index.tsx index cd1ced1830123..f7046d9e40138 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/index.tsx @@ -5,17 +5,17 @@ * 2.0. */ -import { EuiFlexGroup, EuiFlexItem, EuiPage, EuiPanel } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui'; import React from 'react'; import { useTrackPageview } from '../../../../../observability/public'; import { isRumAgentName } from '../../../../common/agent_name'; import { AnnotationsContextProvider } from '../../../context/annotations/annotations_context'; +import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; import { ChartPointerEventContextProvider } from '../../../context/chart_pointer_event/chart_pointer_event_context'; import { useBreakPoints } from '../../../hooks/use_break_points'; import { LatencyChart } from '../../shared/charts/latency_chart'; import { TransactionBreakdownChart } from '../../shared/charts/transaction_breakdown_chart'; import { TransactionErrorRateChart } from '../../shared/charts/transaction_error_rate_chart'; -import { SearchBar } from '../../shared/search_bar'; import { ServiceOverviewDependenciesTable } from './service_overview_dependencies_table'; import { ServiceOverviewErrorsTable } from './service_overview_errors_table'; import { ServiceOverviewInstancesChartAndTable } from './service_overview_instances_chart_and_table'; @@ -29,14 +29,12 @@ import { ServiceOverviewTransactionsTable } from './service_overview_transaction export const chartHeight = 288; interface ServiceOverviewProps { - agentName?: string; serviceName: string; } -export function ServiceOverview({ - agentName, - serviceName, -}: ServiceOverviewProps) { +export function ServiceOverview({ serviceName }: ServiceOverviewProps) { + const { agentName } = useApmServiceContext(); + useTrackPageview({ app: 'apm', path: 'service_overview' }); useTrackPageview({ app: 'apm', path: 'service_overview', delay: 15000 }); @@ -49,89 +47,84 @@ export function ServiceOverview({ return ( - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + {!isRumAgent && ( - + + )} + + + + + + + + + + + + + {!isRumAgent && ( - - - + )} + + + {!isRumAgent && ( - {!isRumAgent && ( - - - - )} - - - - - + - - - - - - {!isRumAgent && ( - - - - - - )} - - - {!isRumAgent && ( - - - - - - )} - - + )} + ); diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/get_columns.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/get_columns.tsx index 46747e18c44af..a92efff103910 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/get_columns.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/get_columns.tsx @@ -234,6 +234,7 @@ export function getColumns({ anchorPosition="leftCenter" button={ diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/intance_details.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/intance_details.tsx index ba1da7e6dd6eb..5c2bbd9e20c59 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/intance_details.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/intance_details.tsx @@ -32,10 +32,7 @@ import { pct } from '../../../../style/variables'; import { getAgentIcon } from '../../../shared/AgentIcon/get_agent_icon'; import { KeyValueFilterList } from '../../../shared/key_value_filter_list'; import { pushNewItemToKueryBar } from '../../../shared/KueryBar/utils'; -import { - getCloudIcon, - getContainerIcon, -} from '../../service_details/service_icons'; +import { getCloudIcon, getContainerIcon } from '../../../shared/service_icons'; import { useInstanceDetailsFetcher } from './use_instance_details_fetcher'; type ServiceInstanceDetails = APIReturnType<'GET /api/apm/services/{serviceName}/service_overview_instances/details/{serviceNodeName}'>; diff --git a/x-pack/plugins/apm/public/components/app/service_profiling/index.tsx b/x-pack/plugins/apm/public/components/app/service_profiling/index.tsx index 94391b5b2fb06..c6e1f575298c6 100644 --- a/x-pack/plugins/apm/public/components/app/service_profiling/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_profiling/index.tsx @@ -4,14 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { - EuiFlexGroup, - EuiFlexItem, - EuiPage, - EuiPanel, - EuiTitle, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; +import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiTitle } from '@elastic/eui'; import React, { useEffect, useState } from 'react'; import { getValueTypeConfig, @@ -20,7 +13,6 @@ import { import { useUrlParams } from '../../../context/url_params_context/use_url_params'; import { useFetcher } from '../../../hooks/use_fetcher'; import { APIReturnType } from '../../../services/rest/createCallApmApi'; -import { SearchBar } from '../../shared/search_bar'; import { ServiceProfilingFlamegraph } from './service_profiling_flamegraph'; import { ServiceProfilingTimeline } from './service_profiling_timeline'; @@ -90,54 +82,38 @@ export function ServiceProfiling({ return ( <> - - - - - -

- {i18n.translate('xpack.apm.profilingOverviewTitle', { - defaultMessage: 'Profiling', - })} -

-
+ + + + { + setValueType(type); + }} + selectedValueType={valueType} + /> + {valueType ? ( + + +

{getValueTypeConfig(valueType).label}

+
+
+ ) : null} - - - - { - setValueType(type); - }} - selectedValueType={valueType} - /> - - {valueType ? ( - - -

{getValueTypeConfig(valueType).label}

-
-
- ) : null} - - - -
-
+
-
+ ); } diff --git a/x-pack/plugins/apm/public/components/app/trace_overview/index.tsx b/x-pack/plugins/apm/public/components/app/trace_overview/index.tsx index 364266d277482..0938456193dc0 100644 --- a/x-pack/plugins/apm/public/components/app/trace_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/trace_overview/index.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { EuiFlexGroup, EuiPage, EuiPanel } from '@elastic/eui'; +import { EuiPanel } from '@elastic/eui'; import React from 'react'; import { useTrackPageview } from '../../../../../observability/public'; import { useUrlParams } from '../../../context/url_params_context/use_url_params'; @@ -50,16 +50,13 @@ export function TraceOverview() { return ( <> - - - - - - - + + + + ); } diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/TransactionTabs.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/TransactionTabs.tsx index 7f8ffb62d9e72..ae58e6f60cf09 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/TransactionTabs.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/TransactionTabs.tsx @@ -7,7 +7,6 @@ import { EuiSpacer, EuiTab, EuiTabs } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { Location } from 'history'; import React from 'react'; import { useHistory } from 'react-router-dom'; import { LogStream } from '../../../../../../infra/public'; @@ -19,7 +18,6 @@ import { WaterfallContainer } from './WaterfallContainer'; import { IWaterfall } from './WaterfallContainer/Waterfall/waterfall_helpers/waterfall_helpers'; interface Props { - location: Location; transaction: Transaction; urlParams: IUrlParams; waterfall: IWaterfall; @@ -27,7 +25,6 @@ interface Props { } export function TransactionTabs({ - location, transaction, urlParams, waterfall, @@ -47,9 +44,9 @@ export function TransactionTabs({ { history.replace({ - ...location, + ...history.location, search: fromQuery({ - ...toQuery(location.search), + ...toQuery(history.location.search), detailTab: key, }), }); @@ -66,7 +63,6 @@ export function TransactionTabs({ ; urlParams: IUrlParams; waterfall: IWaterfall; exceedsMax: boolean; }) { return ( void; + toggleFlyout: ({ history }: { history: History }) => void; } export function WaterfallFlyout({ waterfallItemId, waterfall, - location, toggleFlyout, }: Props) { const history = useHistory(); @@ -52,14 +44,14 @@ export function WaterfallFlyout({ totalDuration={waterfall.duration} span={currentItem.doc} parentTransaction={parentTransaction} - onClose={() => toggleFlyout({ history, location })} + onClose={() => toggleFlyout({ history })} /> ); case 'transaction': return ( toggleFlyout({ history, location })} + onClose={() => toggleFlyout({ history })} rootTransactionDuration={ waterfall.rootTransaction?.transaction.duration.us } diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/accordion_waterfall.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/accordion_waterfall.tsx index baced34ad3e56..b0721791081fa 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/accordion_waterfall.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/accordion_waterfall.tsx @@ -6,7 +6,6 @@ */ import { EuiAccordion, EuiAccordionProps } from '@elastic/eui'; -import { Location } from 'history'; import { isEmpty } from 'lodash'; import React, { useState } from 'react'; import { euiStyled } from '../../../../../../../../../../src/plugins/kibana_react/common'; @@ -23,7 +22,6 @@ interface AccordionWaterfallProps { level: number; duration: IWaterfall['duration']; waterfallItemId?: string; - location: Location; errorsPerTransaction: IWaterfall['errorsPerTransaction']; childrenByParentId: Record; onToggleEntryTransaction?: () => void; @@ -100,7 +98,6 @@ export function AccordionWaterfall(props: AccordionWaterfallProps) { duration, childrenByParentId, waterfallItemId, - location, errorsPerTransaction, timelineMargins, onClickWaterfallItem, @@ -160,7 +157,6 @@ export function AccordionWaterfall(props: AccordionWaterfallProps) { item={child} level={nextLevel} waterfallItemId={waterfallItemId} - location={location} errorsPerTransaction={errorsPerTransaction} duration={duration} childrenByParentId={childrenByParentId} diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/index.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/index.tsx index 4be595ac16c6c..d7613699221b4 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/index.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/index.tsx @@ -7,7 +7,7 @@ import { EuiButtonEmpty, EuiCallOut } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { History, Location } from 'history'; +import { History } from 'history'; import React, { useState } from 'react'; import { useHistory } from 'react-router-dom'; import { euiStyled } from '../../../../../../../../../../src/plugins/kibana_react/common'; @@ -40,14 +40,12 @@ const TIMELINE_MARGINS = { const toggleFlyout = ({ history, item, - location, }: { history: History; item?: IWaterfallItem; - location: Location; }) => { history.replace({ - ...location, + ...history.location, search: fromQuery({ ...toQuery(location.search), flyoutDetailTab: undefined, @@ -63,15 +61,9 @@ const WaterfallItemsContainer = euiStyled.div` interface Props { waterfallItemId?: string; waterfall: IWaterfall; - location: Location; exceedsMax: boolean; } -export function Waterfall({ - waterfall, - exceedsMax, - waterfallItemId, - location, -}: Props) { +export function Waterfall({ waterfall, exceedsMax, waterfallItemId }: Props) { const history = useHistory(); const [isAccordionOpen, setIsAccordionOpen] = useState(true); const itemContainerHeight = 58; // TODO: This is a nasty way to calculate the height of the svg element. A better approach should be found @@ -97,13 +89,12 @@ export function Waterfall({ item={entryWaterfallTransaction} level={0} waterfallItemId={waterfallItemId} - location={location} errorsPerTransaction={waterfall.errorsPerTransaction} duration={duration} childrenByParentId={childrenByParentId} timelineMargins={TIMELINE_MARGINS} onClickWaterfallItem={(item: IWaterfallItem) => - toggleFlyout({ history, item, location }) + toggleFlyout({ history, item }) } onToggleEntryTransaction={() => setIsAccordionOpen((isOpen) => !isOpen)} /> @@ -148,7 +139,6 @@ export function Waterfall({ diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/WaterfallContainer.stories.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/WaterfallContainer.stories.tsx index 57743590ea566..5ea2fca2dfa32 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/WaterfallContainer.stories.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/WaterfallContainer.stories.tsx @@ -15,7 +15,6 @@ import { WaterfallContainer } from './index'; import { getWaterfall } from './Waterfall/waterfall_helpers/waterfall_helpers'; import { inferredSpans, - location, simpleTrace, traceChildStartBeforeParent, traceWithErrors, @@ -45,7 +44,6 @@ export function Example() { ); return ( ; - -export function TransactionDetails({ - location, - match, -}: TransactionDetailsProps) { +export function TransactionDetails() { const { urlParams } = useUrlParams(); const history = useHistory(); const { @@ -90,48 +76,43 @@ export function TransactionDetails({ return ( <> - - -

{transactionName}

-
-
- - - - - - - - - - - { - if (!isEmpty(bucket.samples)) { - selectSampleFromBucketClick(bucket.samples[0]); - } - }} - /> - - - - - - - - - + +

{transactionName}

+
+ + + + + + + + + + + { + if (!isEmpty(bucket.samples)) { + selectSampleFromBucketClick(bucket.samples[0]); + } + }} + /> + + + + + + + ); } diff --git a/x-pack/plugins/apm/public/components/app/transaction_overview/index.tsx b/x-pack/plugins/apm/public/components/app/transaction_overview/index.tsx index 9e2743d7b5986..38066b4ecd3f7 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_overview/index.tsx @@ -8,8 +8,6 @@ import { EuiCallOut, EuiCode, - EuiFlexGroup, - EuiPage, EuiPanel, EuiSpacer, EuiTitle, @@ -26,7 +24,6 @@ import { useUrlParams } from '../../../context/url_params_context/use_url_params import { TransactionCharts } from '../../shared/charts/transaction_charts'; import { ElasticDocsLink } from '../../shared/Links/ElasticDocsLink'; import { fromQuery, toQuery } from '../../shared/Links/url_helpers'; -import { SearchBar } from '../../shared/search_bar'; import { TransactionList } from './TransactionList'; import { useRedirect } from './useRedirect'; import { useTransactionListFetcher } from './use_transaction_list'; @@ -80,62 +77,55 @@ export function TransactionOverview({ serviceName }: TransactionOverviewProps) { return ( <> - - - - - - - -

Transactions

-
- - {!transactionListData.isAggregationAccurate && ( - -

- - xpack.apm.ui.transactionGroupBucketSize - - ), - }} - /> - - - {i18n.translate( - 'xpack.apm.transactionCardinalityWarning.docsLink', - { defaultMessage: 'Learn more in the docs' } - )} - -

-
+ + + + +

Transactions

+
+ + {!transactionListData.isAggregationAccurate && ( + - -
-
-
+ color="danger" + iconType="alert" + > +

+ xpack.apm.ui.transactionGroupBucketSize + ), + }} + /> + + + {i18n.translate( + 'xpack.apm.transactionCardinalityWarning.docsLink', + { defaultMessage: 'Learn more in the docs' } + )} + +

+
+ )} + + + ); } diff --git a/x-pack/plugins/apm/public/components/app/transaction_overview/transaction_overview.test.tsx b/x-pack/plugins/apm/public/components/app/transaction_overview/transaction_overview.test.tsx index e4fbd07566060..9c4c2aa11a858 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_overview/transaction_overview.test.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_overview/transaction_overview.test.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { fireEvent, getByText, queryByLabelText } from '@testing-library/react'; +import { queryByLabelText } from '@testing-library/react'; import { createMemoryHistory } from 'history'; import { CoreStart } from 'kibana/public'; import React from 'react'; @@ -107,46 +107,6 @@ describe('TransactionOverview', () => { const FILTER_BY_TYPE_LABEL = 'Transaction type'; - describe('when transactionType is selected and multiple transaction types are given', () => { - it('renders a radio group with transaction types', () => { - const { container } = setup({ - serviceTransactionTypes: ['firstType', 'secondType'], - urlParams: { - transactionType: 'secondType', - }, - }); - - expect(getByText(container, 'firstType')).toBeInTheDocument(); - expect(getByText(container, 'secondType')).toBeInTheDocument(); - - expect(getByText(container, 'firstType')).not.toBeNull(); - }); - - it('should update the URL when a transaction type is selected', () => { - const { container } = setup({ - serviceTransactionTypes: ['firstType', 'secondType'], - urlParams: { - transactionType: 'secondType', - }, - }); - - expect(history.location.search).toEqual( - '?transactionType=secondType&rangeFrom=now-15m&rangeTo=now' - ); - expect(getByText(container, 'firstType')).toBeInTheDocument(); - expect(getByText(container, 'secondType')).toBeInTheDocument(); - - fireEvent.change(getByText(container, 'firstType').parentElement!, { - target: { value: 'firstType' }, - }); - - expect(history.push).toHaveBeenCalled(); - expect(history.location.search).toEqual( - '?transactionType=firstType&rangeFrom=now-15m&rangeTo=now' - ); - }); - }); - describe('when a transaction type is selected, and there are no other transaction types', () => { it('does not render a radio group with transaction types', () => { const { container } = setup({ diff --git a/x-pack/plugins/apm/public/components/routing/apm_route_config.tsx b/x-pack/plugins/apm/public/components/routing/apm_route_config.tsx new file mode 100644 index 0000000000000..af62f4f235af7 --- /dev/null +++ b/x-pack/plugins/apm/public/components/routing/apm_route_config.tsx @@ -0,0 +1,478 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { RouteComponentProps } from 'react-router-dom'; +import { getServiceNodeName } from '../../../common/service_nodes'; +import { APMRouteDefinition } from '../../application/routes'; +import { toQuery } from '../shared/Links/url_helpers'; +import { ErrorGroupDetails } from '../app/ErrorGroupDetails'; +import { useApmPluginContext } from '../../context/apm_plugin/use_apm_plugin_context'; +import { ServiceNodeMetrics } from '../app/service_node_metrics'; +import { Settings } from '../app/Settings'; +import { AgentConfigurations } from '../app/Settings/AgentConfigurations'; +import { AnomalyDetection } from '../app/Settings/anomaly_detection'; +import { ApmIndices } from '../app/Settings/ApmIndices'; +import { CustomizeUI } from '../app/Settings/CustomizeUI'; +import { TraceLink } from '../app/TraceLink'; +import { TransactionDetails } from '../app/transaction_details'; +import { + CreateAgentConfigurationRouteHandler, + EditAgentConfigurationRouteHandler, +} from './route_handlers/agent_configuration'; +import { enableServiceOverview } from '../../../common/ui_settings_keys'; +import { redirectTo } from './redirect_to'; +import { ApmMainTemplate } from './templates/apm_main_template'; +import { ApmServiceTemplate } from './templates/apm_service_template'; +import { ServiceProfiling } from '../app/service_profiling'; +import { ErrorGroupOverview } from '../app/error_group_overview'; +import { ServiceMap } from '../app/service_map'; +import { ServiceNodeOverview } from '../app/service_node_overview'; +import { ServiceMetrics } from '../app/service_metrics'; +import { ServiceOverview } from '../app/service_overview'; +import { TransactionOverview } from '../app/transaction_overview'; +import { ServiceInventory } from '../app/service_inventory'; +import { TraceOverview } from '../app/trace_overview'; + +// These component function definitions are used below with the `component` +// property of the route definitions. +// +// If you provide an inline function to the component prop, you would create a +// new component every render. This results in the existing component unmounting +// and the new component mounting instead of just updating the existing component. + +const ServiceInventoryTitle = i18n.translate( + 'xpack.apm.views.serviceInventory.title', + { defaultMessage: 'Services' } +); + +function ServiceInventoryView() { + return ( + + + + ); +} + +const TraceOverviewTitle = i18n.translate( + 'xpack.apm.views.traceOverview.title', + { + defaultMessage: 'Traces', + } +); + +function TraceOverviewView() { + return ( + + + + ); +} + +const ServiceMapTitle = i18n.translate('xpack.apm.views.serviceMap.title', { + defaultMessage: 'Service Map', +}); + +function ServiceMapView() { + return ( + + + + ); +} + +function ServiceDetailsErrorsRouteView( + props: RouteComponentProps<{ serviceName: string }> +) { + const { serviceName } = props.match.params; + return ( + + + + ); +} + +function ErrorGroupDetailsRouteView( + props: RouteComponentProps<{ serviceName: string; groupId: string }> +) { + const { serviceName, groupId } = props.match.params; + return ( + + + + ); +} + +function ServiceDetailsMetricsRouteView( + props: RouteComponentProps<{ serviceName: string }> +) { + const { serviceName } = props.match.params; + return ( + + + + ); +} + +function ServiceDetailsNodesRouteView( + props: RouteComponentProps<{ serviceName: string }> +) { + const { serviceName } = props.match.params; + return ( + + + + ); +} + +function ServiceDetailsOverviewRouteView( + props: RouteComponentProps<{ serviceName: string }> +) { + const { serviceName } = props.match.params; + return ( + + + + ); +} + +function ServiceDetailsServiceMapRouteView( + props: RouteComponentProps<{ serviceName: string }> +) { + const { serviceName } = props.match.params; + return ( + + + + ); +} + +function ServiceDetailsTransactionsRouteView( + props: RouteComponentProps<{ serviceName: string }> +) { + const { serviceName } = props.match.params; + return ( + + + + ); +} + +function ServiceDetailsProfilingRouteView( + props: RouteComponentProps<{ serviceName: string }> +) { + const { serviceName } = props.match.params; + return ( + + + + ); +} + +function ServiceNodeMetricsRouteView( + props: RouteComponentProps<{ + serviceName: string; + serviceNodeName: string; + }> +) { + const { serviceName, serviceNodeName } = props.match.params; + return ( + + + + ); +} + +function TransactionDetailsRouteView( + props: RouteComponentProps<{ serviceName: string }> +) { + const { serviceName } = props.match.params; + return ( + + + + ); +} + +function SettingsAgentConfigurationRouteView() { + return ( + + + + + + ); +} + +function SettingsAnomalyDetectionRouteView() { + return ( + + + + + + ); +} + +function SettingsApmIndicesRouteView() { + return ( + + + + + + ); +} + +function SettingsCustomizeUI() { + return ( + + + + + + ); +} + +const SettingsApmIndicesTitle = i18n.translate( + 'xpack.apm.views.settings.indices.title', + { defaultMessage: 'Indices' } +); + +const SettingsAgentConfigurationTitle = i18n.translate( + 'xpack.apm.views.settings.agentConfiguration.title', + { defaultMessage: 'Agent Configuration' } +); +const CreateAgentConfigurationTitle = i18n.translate( + 'xpack.apm.views.settings.createAgentConfiguration.title', + { defaultMessage: 'Create Agent Configuration' } +); +const EditAgentConfigurationTitle = i18n.translate( + 'xpack.apm.views.settings.editAgentConfiguration.title', + { defaultMessage: 'Edit Agent Configuration' } +); +const SettingsCustomizeUITitle = i18n.translate( + 'xpack.apm.views.settings.customizeUI.title', + { defaultMessage: 'Customize app' } +); +const SettingsAnomalyDetectionTitle = i18n.translate( + 'xpack.apm.views.settings.anomalyDetection.title', + { defaultMessage: 'Anomaly detection' } +); +const SettingsTitle = i18n.translate('xpack.apm.views.listSettings.title', { + defaultMessage: 'Settings', +}); + +/** + * The array of route definitions to be used when the application + * creates the routes. + */ +export const apmRouteConfig: APMRouteDefinition[] = [ + /* + * Home routes + */ + { + exact: true, + path: '/', + render: redirectTo('/services'), + breadcrumb: 'APM', + }, + { + exact: true, + path: '/services', // !! Need to be kept in sync with the deepLinks in x-pack/plugins/apm/public/plugin.ts + component: ServiceInventoryView, + breadcrumb: ServiceInventoryTitle, + }, + { + exact: true, + path: '/traces', // !! Need to be kept in sync with the deepLinks in x-pack/plugins/apm/public/plugin.ts + component: TraceOverviewView, + breadcrumb: TraceOverviewTitle, + }, + { + exact: true, + path: '/service-map', // !! Need to be kept in sync with the deepLinks in x-pack/plugins/apm/public/plugin.ts + component: ServiceMapView, + breadcrumb: ServiceMapTitle, + }, + + /* + * Settings routes + */ + { + exact: true, + path: '/settings', + render: redirectTo('/settings/agent-configuration'), + breadcrumb: SettingsTitle, + }, + { + exact: true, + path: '/settings/agent-configuration', + component: SettingsAgentConfigurationRouteView, + breadcrumb: SettingsAgentConfigurationTitle, + }, + { + exact: true, + path: '/settings/agent-configuration/create', + component: CreateAgentConfigurationRouteHandler, + breadcrumb: CreateAgentConfigurationTitle, + }, + { + exact: true, + path: '/settings/agent-configuration/edit', + breadcrumb: EditAgentConfigurationTitle, + component: EditAgentConfigurationRouteHandler, + }, + { + exact: true, + path: '/settings/apm-indices', + component: SettingsApmIndicesRouteView, + breadcrumb: SettingsApmIndicesTitle, + }, + { + exact: true, + path: '/settings/customize-ui', + component: SettingsCustomizeUI, + breadcrumb: SettingsCustomizeUITitle, + }, + { + exact: true, + path: '/settings/anomaly-detection', + component: SettingsAnomalyDetectionRouteView, + breadcrumb: SettingsAnomalyDetectionTitle, + }, + + /* + * Services routes (with APM Service context) + */ + { + exact: true, + path: '/services/:serviceName', + breadcrumb: ({ match }) => match.params.serviceName, + component: RedirectToDefaultServiceRouteView, + }, + { + exact: true, + path: '/services/:serviceName/overview', + breadcrumb: i18n.translate('xpack.apm.views.overview.title', { + defaultMessage: 'Overview', + }), + component: ServiceDetailsOverviewRouteView, + }, + { + exact: true, + path: '/services/:serviceName/transactions', + component: ServiceDetailsTransactionsRouteView, + breadcrumb: i18n.translate('xpack.apm.views.transactions.title', { + defaultMessage: 'Transactions', + }), + }, + { + exact: true, + path: '/services/:serviceName/errors/:groupId', + component: ErrorGroupDetailsRouteView, + breadcrumb: ({ match }) => match.params.groupId, + }, + { + exact: true, + path: '/services/:serviceName/errors', + component: ServiceDetailsErrorsRouteView, + breadcrumb: i18n.translate('xpack.apm.views.errors.title', { + defaultMessage: 'Errors', + }), + }, + { + exact: true, + path: '/services/:serviceName/metrics', + component: ServiceDetailsMetricsRouteView, + breadcrumb: i18n.translate('xpack.apm.views.metrics.title', { + defaultMessage: 'Metrics', + }), + }, + // service nodes, only enabled for java agents for now + { + exact: true, + path: '/services/:serviceName/nodes', + component: ServiceDetailsNodesRouteView, + breadcrumb: i18n.translate('xpack.apm.views.nodes.title', { + defaultMessage: 'JVMs', + }), + }, + // node metrics + { + exact: true, + path: '/services/:serviceName/nodes/:serviceNodeName/metrics', + component: ServiceNodeMetricsRouteView, + breadcrumb: ({ match }) => getServiceNodeName(match.params.serviceNodeName), + }, + { + exact: true, + path: '/services/:serviceName/transactions/view', + component: TransactionDetailsRouteView, + breadcrumb: ({ location }) => { + const query = toQuery(location.search); + return query.transactionName as string; + }, + }, + { + exact: true, + path: '/services/:serviceName/profiling', + component: ServiceDetailsProfilingRouteView, + breadcrumb: i18n.translate('xpack.apm.views.serviceProfiling.title', { + defaultMessage: 'Profiling', + }), + }, + { + exact: true, + path: '/services/:serviceName/service-map', + component: ServiceDetailsServiceMapRouteView, + breadcrumb: i18n.translate('xpack.apm.views.serviceMap.title', { + defaultMessage: 'Service Map', + }), + }, + /* + * Utilility routes + */ + { + exact: true, + path: '/link-to/trace/:traceId', + component: TraceLink, + breadcrumb: null, + }, +]; + +function RedirectToDefaultServiceRouteView( + props: RouteComponentProps<{ serviceName: string }> +) { + const { uiSettings } = useApmPluginContext().core; + const { serviceName } = props.match.params; + if (uiSettings.get(enableServiceOverview)) { + return redirectTo(`/services/${serviceName}/overview`)(props); + } + return redirectTo(`/services/${serviceName}/transactions`)(props); +} diff --git a/x-pack/plugins/apm/public/components/routing/app_root.tsx b/x-pack/plugins/apm/public/components/routing/app_root.tsx new file mode 100644 index 0000000000000..9529a67210748 --- /dev/null +++ b/x-pack/plugins/apm/public/components/routing/app_root.tsx @@ -0,0 +1,110 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ApmRoute } from '@elastic/apm-rum-react'; +import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; +import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; +import React from 'react'; +import { Route, Router, Switch } from 'react-router-dom'; +import { DefaultTheme, ThemeProvider } from 'styled-components'; +import { euiStyled } from '../../../../../../src/plugins/kibana_react/common'; +import { + KibanaContextProvider, + RedirectAppLinks, + useUiSetting$, +} from '../../../../../../src/plugins/kibana_react/public'; +import { ScrollToTopOnPathChange } from '../../components/app/Main/ScrollToTopOnPathChange'; +import { + ApmPluginContext, + ApmPluginContextValue, +} from '../../context/apm_plugin/apm_plugin_context'; +import { LicenseProvider } from '../../context/license/license_context'; +import { UrlParamsProvider } from '../../context/url_params_context/url_params_context'; +import { useBreadcrumbs } from '../../hooks/use_breadcrumbs'; +import { ApmPluginStartDeps } from '../../plugin'; +import { HeaderMenuPortal } from '../../../../observability/public'; +import { ApmHeaderActionMenu } from '../shared/apm_header_action_menu'; +import { useApmPluginContext } from '../../context/apm_plugin/use_apm_plugin_context'; +import { AnomalyDetectionJobsContextProvider } from '../../context/anomaly_detection_jobs/anomaly_detection_jobs_context'; +import { apmRouteConfig } from './apm_route_config'; + +const MainContainer = euiStyled.div` + height: 100%; +`; + +export function ApmAppRoot({ + apmPluginContextValue, + pluginsStart, +}: { + apmPluginContextValue: ApmPluginContextValue; + pluginsStart: ApmPluginStartDeps; +}) { + const { appMountParameters, core } = apmPluginContextValue; + const { history } = appMountParameters; + const i18nCore = core.i18n; + + return ( + + + + + + + + + + + + + + + {apmRouteConfig.map((route, i) => ( + + ))} + + + + + + + + + + + + ); +} + +function MountApmHeaderActionMenu() { + useBreadcrumbs(apmRouteConfig); + const { setHeaderActionMenu } = useApmPluginContext().appMountParameters; + + return ( + + + + ); +} + +function ApmThemeProvider({ children }: { children: React.ReactNode }) { + const [darkMode] = useUiSetting$('theme:darkMode'); + + return ( + ({ + ...outerTheme, + eui: darkMode ? euiDarkVars : euiLightVars, + darkMode, + })} + > + {children} + + ); +} diff --git a/x-pack/plugins/apm/public/components/routing/redirect_to.tsx b/x-pack/plugins/apm/public/components/routing/redirect_to.tsx new file mode 100644 index 0000000000000..68ff2fce77f13 --- /dev/null +++ b/x-pack/plugins/apm/public/components/routing/redirect_to.tsx @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { Redirect, RouteComponentProps } from 'react-router-dom'; + +/** + * Given a path, redirect to that location, preserving the search and maintaining + * backward-compatibilty with legacy (pre-7.9) hash-based URLs. + */ +export function redirectTo(to: string) { + return ({ location }: RouteComponentProps<{}>) => { + let resolvedUrl: URL | undefined; + + // Redirect root URLs with a hash to support backward compatibility with URLs + // from before we switched to the non-hash platform history. + if (location.pathname === '' && location.hash.length > 0) { + // We just want the search and pathname so the host doesn't matter + resolvedUrl = new URL(location.hash.slice(1), 'http://localhost'); + to = resolvedUrl.pathname; + } + + return ( + + ); + }; +} diff --git a/x-pack/plugins/apm/public/components/app/Main/route_config/route_config.test.tsx b/x-pack/plugins/apm/public/components/routing/route_config.test.tsx similarity index 93% rename from x-pack/plugins/apm/public/components/app/Main/route_config/route_config.test.tsx rename to x-pack/plugins/apm/public/components/routing/route_config.test.tsx index 62202d9489d51..b1d5c1a83b43b 100644 --- a/x-pack/plugins/apm/public/components/app/Main/route_config/route_config.test.tsx +++ b/x-pack/plugins/apm/public/components/routing/route_config.test.tsx @@ -5,11 +5,11 @@ * 2.0. */ -import { routes } from './'; +import { apmRouteConfig } from './apm_route_config'; describe('routes', () => { describe('/', () => { - const route = routes.find((r) => r.path === '/'); + const route = apmRouteConfig.find((r) => r.path === '/'); describe('with no hash path', () => { it('redirects to /services', () => { diff --git a/x-pack/plugins/apm/public/components/app/Main/route_config/route_handlers/agent_configuration.tsx b/x-pack/plugins/apm/public/components/routing/route_handlers/agent_configuration.tsx similarity index 86% rename from x-pack/plugins/apm/public/components/app/Main/route_config/route_handlers/agent_configuration.tsx rename to x-pack/plugins/apm/public/components/routing/route_handlers/agent_configuration.tsx index e5d238e6aa89c..8e0a08603bc76 100644 --- a/x-pack/plugins/apm/public/components/app/Main/route_config/route_handlers/agent_configuration.tsx +++ b/x-pack/plugins/apm/public/components/routing/route_handlers/agent_configuration.tsx @@ -7,10 +7,10 @@ import React from 'react'; import { RouteComponentProps } from 'react-router-dom'; -import { useFetcher } from '../../../../../hooks/use_fetcher'; -import { toQuery } from '../../../../shared/Links/url_helpers'; -import { Settings } from '../../../Settings'; -import { AgentConfigurationCreateEdit } from '../../../Settings/AgentConfigurations/AgentConfigurationCreateEdit'; +import { useFetcher } from '../../../hooks/use_fetcher'; +import { toQuery } from '../../shared/Links/url_helpers'; +import { Settings } from '../../app/Settings'; +import { AgentConfigurationCreateEdit } from '../../app/Settings/AgentConfigurations/AgentConfigurationCreateEdit'; type EditAgentConfigurationRouteHandler = RouteComponentProps<{}>; diff --git a/x-pack/plugins/apm/public/components/routing/templates/apm_main_template.tsx b/x-pack/plugins/apm/public/components/routing/templates/apm_main_template.tsx new file mode 100644 index 0000000000000..0473e88c23d12 --- /dev/null +++ b/x-pack/plugins/apm/public/components/routing/templates/apm_main_template.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; +import { ApmPluginStartDeps } from '../../../plugin'; +import { EnvironmentFilter } from '../../shared/EnvironmentFilter'; + +/* + * This template contains: + * - The Shared Observability Nav (https://github.com/elastic/kibana/blob/f7698bd8aa8787d683c728300ba4ca52b202369c/x-pack/plugins/observability/public/components/shared/page_template/README.md) + * - The APM Header Action Menu + * - Page title + * + * Optionally: + * - EnvironmentFilter + */ +export function ApmMainTemplate({ + pageTitle, + children, +}: { + pageTitle: React.ReactNode; + children: React.ReactNode; +}) { + const { services } = useKibana(); + const ObservabilityPageTemplate = + services.observability.navigation.PageTemplate; + + return ( + ], + }} + > + {children} + + ); +} diff --git a/x-pack/plugins/apm/public/components/routing/templates/apm_service_template.tsx b/x-pack/plugins/apm/public/components/routing/templates/apm_service_template.tsx new file mode 100644 index 0000000000000..526d9eb3551d0 --- /dev/null +++ b/x-pack/plugins/apm/public/components/routing/templates/apm_service_template.tsx @@ -0,0 +1,216 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiTitle, + EuiTabs, + EuiTab, + EuiBetaBadge, +} from '@elastic/eui'; +import { ApmMainTemplate } from './apm_main_template'; +import { ApmServiceContextProvider } from '../../../context/apm_service/apm_service_context'; +import { enableServiceOverview } from '../../../../common/ui_settings_keys'; +import { isJavaAgentName, isRumAgentName } from '../../../../common/agent_name'; +import { ServiceIcons } from '../../shared/service_icons'; +import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context'; +import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; +import { useErrorOverviewHref } from '../../shared/Links/apm/ErrorOverviewLink'; +import { useMetricOverviewHref } from '../../shared/Links/apm/MetricOverviewLink'; +import { useServiceMapHref } from '../../shared/Links/apm/ServiceMapLink'; +import { useServiceNodeOverviewHref } from '../../shared/Links/apm/ServiceNodeOverviewLink'; +import { useServiceOverviewHref } from '../../shared/Links/apm/service_overview_link'; +import { useServiceProfilingHref } from '../../shared/Links/apm/service_profiling_link'; +import { useTransactionsOverviewHref } from '../../shared/Links/apm/transaction_overview_link'; +import { useUrlParams } from '../../../context/url_params_context/use_url_params'; +import { Correlations } from '../../app/correlations'; +import { SearchBar } from '../../shared/search_bar'; + +interface Tab { + key: TabKey; + href: string; + text: React.ReactNode; + hidden?: boolean; +} + +type TabKey = + | 'errors' + | 'metrics' + | 'nodes' + | 'overview' + | 'service-map' + | 'profiling' + | 'transactions'; + +export function ApmServiceTemplate({ + children, + serviceName, + selectedTab, + searchBarOptions, +}: { + children: React.ReactNode; + serviceName: string; + selectedTab: TabKey; + searchBarOptions?: { + hidden?: boolean; + showTransactionTypeSelector?: boolean; + showTimeComparison?: boolean; + }; +}) { + return ( + + + + +

{serviceName}

+
+
+ + + +
+ + } + > + + + + + + + + + + + + + {children} + +
+ ); +} + +function TabNavigation({ + serviceName, + selectedTab, +}: { + serviceName: string; + selectedTab: TabKey; +}) { + const { agentName, transactionType } = useApmServiceContext(); + const { core, config } = useApmPluginContext(); + const { urlParams } = useUrlParams(); + + const tabs: Tab[] = [ + { + key: 'overview', + href: useServiceOverviewHref({ serviceName, transactionType }), + text: i18n.translate('xpack.apm.serviceDetails.overviewTabLabel', { + defaultMessage: 'Overview', + }), + hidden: !core.uiSettings.get(enableServiceOverview), + }, + { + key: 'transactions', + href: useTransactionsOverviewHref({ + serviceName, + latencyAggregationType: urlParams.latencyAggregationType, + transactionType, + }), + text: i18n.translate('xpack.apm.serviceDetails.transactionsTabLabel', { + defaultMessage: 'Transactions', + }), + }, + { + key: 'errors', + href: useErrorOverviewHref(serviceName), + text: i18n.translate('xpack.apm.serviceDetails.errorsTabLabel', { + defaultMessage: 'Errors', + }), + }, + { + key: 'nodes', + href: useServiceNodeOverviewHref(serviceName), + text: i18n.translate('xpack.apm.serviceDetails.nodesTabLabel', { + defaultMessage: 'JVMs', + }), + hidden: !isJavaAgentName(agentName), + }, + { + key: 'metrics', + href: useMetricOverviewHref(serviceName), + text: i18n.translate('xpack.apm.serviceDetails.metricsTabLabel', { + defaultMessage: 'Metrics', + }), + hidden: + !agentName || isRumAgentName(agentName) || isJavaAgentName(agentName), + }, + { + key: 'service-map', + href: useServiceMapHref(serviceName), + text: i18n.translate('xpack.apm.home.serviceMapTabLabel', { + defaultMessage: 'Service Map', + }), + }, + { + key: 'profiling', + href: useServiceProfilingHref({ serviceName }), + hidden: !config.profilingEnabled, + text: ( + + + {i18n.translate('xpack.apm.serviceDetails.profilingTabLabel', { + defaultMessage: 'Profiling', + })} + + + + + + ), + }, + ]; + + return ( + + {tabs + .filter((t) => !t.hidden) + .map(({ href, key, text }) => ( + + {text} + + ))} + + ); +} diff --git a/x-pack/plugins/apm/public/components/shared/ApmHeader/apm_header.stories.tsx b/x-pack/plugins/apm/public/components/shared/ApmHeader/apm_header.stories.tsx deleted file mode 100644 index 4bc9764b704b0..0000000000000 --- a/x-pack/plugins/apm/public/components/shared/ApmHeader/apm_header.stories.tsx +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { EuiTitle } from '@elastic/eui'; -import React, { ComponentType } from 'react'; -import { MemoryRouter } from 'react-router-dom'; -import { CoreStart } from '../../../../../../../src/core/public'; -import { EuiThemeProvider } from '../../../../../../../src/plugins/kibana_react/common'; -import { MockApmPluginContextWrapper } from '../../../context/apm_plugin/mock_apm_plugin_context'; -import { MockUrlParamsContextProvider } from '../../../context/url_params_context/mock_url_params_context_provider'; -import { createCallApmApi } from '../../../services/rest/createCallApmApi'; -import { ApmHeader } from './'; - -export default { - title: 'shared/ApmHeader', - component: ApmHeader, - decorators: [ - (Story: ComponentType) => { - createCallApmApi(({} as unknown) as CoreStart); - - return ( - - - - - - - - - - ); - }, - ], -}; - -export function Example() { - return ( - - -

- GET - /api/v1/regions/azure-eastus2/clusters/elasticsearch/xc18de071deb4262be54baebf5f6a1ce/proxy/_snapshot/found-snapshots/_all -

-
-
- ); -} diff --git a/x-pack/plugins/apm/public/components/shared/ApmHeader/index.tsx b/x-pack/plugins/apm/public/components/shared/ApmHeader/index.tsx deleted file mode 100644 index f94bba84526a7..0000000000000 --- a/x-pack/plugins/apm/public/components/shared/ApmHeader/index.tsx +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import React, { ReactNode } from 'react'; -import { euiStyled } from '../../../../../../../src/plugins/kibana_react/common'; -import { HeaderMenuPortal } from '../../../../../observability/public'; -import { ActionMenu } from '../../../application/action_menu'; -import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context'; -import { EnvironmentFilter } from '../EnvironmentFilter'; - -const HeaderFlexGroup = euiStyled(EuiFlexGroup)` - padding: ${({ theme }) => theme.eui.gutterTypes.gutterMedium}; - background: ${({ theme }) => theme.eui.euiColorEmptyShade}; - border-bottom: ${({ theme }) => theme.eui.euiBorderThin}; -`; - -export function ApmHeader({ children }: { children: ReactNode }) { - const { setHeaderActionMenu } = useApmPluginContext().appMountParameters; - - return ( - - - - - {children} - - - - - ); -} diff --git a/x-pack/plugins/apm/public/components/shared/EnvironmentFilter/index.tsx b/x-pack/plugins/apm/public/components/shared/EnvironmentFilter/index.tsx index 59c99463144cb..c1bef7ac407ff 100644 --- a/x-pack/plugins/apm/public/components/shared/EnvironmentFilter/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/EnvironmentFilter/index.tsx @@ -13,6 +13,7 @@ import { useHistory, useLocation, useParams } from 'react-router-dom'; import { ENVIRONMENT_ALL, ENVIRONMENT_NOT_DEFINED, + omitEsFieldValue, } from '../../../../common/environment_filter_values'; import { useEnvironmentsFetcher } from '../../../hooks/use_environments_fetcher'; import { useUrlParams } from '../../../context/url_params_context/use_url_params'; @@ -51,9 +52,9 @@ function getOptions(environments: string[]) { })); return [ - ENVIRONMENT_ALL, + omitEsFieldValue(ENVIRONMENT_ALL), ...(environments.includes(ENVIRONMENT_NOT_DEFINED.value) - ? [ENVIRONMENT_NOT_DEFINED] + ? [omitEsFieldValue(ENVIRONMENT_NOT_DEFINED)] : []), ...(environmentOptions.length > 0 ? [SEPARATOR_OPTION] : []), ...environmentOptions, @@ -78,12 +79,14 @@ export function EnvironmentFilter() { // the contents. const minWidth = 200; + const options = getOptions(environments); + return ( { updateEnvironmentUrl(history, location, event.target.value); diff --git a/x-pack/plugins/apm/public/application/action_menu/alerting_popover_flyout.tsx b/x-pack/plugins/apm/public/components/shared/apm_header_action_menu/alerting_popover_flyout.tsx similarity index 96% rename from x-pack/plugins/apm/public/application/action_menu/alerting_popover_flyout.tsx rename to x-pack/plugins/apm/public/components/shared/apm_header_action_menu/alerting_popover_flyout.tsx index 2ff3756855d14..95acc55196c54 100644 --- a/x-pack/plugins/apm/public/application/action_menu/alerting_popover_flyout.tsx +++ b/x-pack/plugins/apm/public/components/shared/apm_header_action_menu/alerting_popover_flyout.tsx @@ -13,9 +13,9 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { useState } from 'react'; -import { IBasePath } from '../../../../../../src/core/public'; -import { AlertType } from '../../../common/alert_types'; -import { AlertingFlyout } from '../../components/alerting/alerting_flyout'; +import { IBasePath } from '../../../../../../../src/core/public'; +import { AlertType } from '../../../../common/alert_types'; +import { AlertingFlyout } from '../../alerting/alerting_flyout'; const alertLabel = i18n.translate('xpack.apm.home.alertsMenu.alerts', { defaultMessage: 'Alerts', diff --git a/x-pack/plugins/apm/public/application/action_menu/anomaly_detection_setup_link.test.tsx b/x-pack/plugins/apm/public/components/shared/apm_header_action_menu/anomaly_detection_setup_link.test.tsx similarity index 95% rename from x-pack/plugins/apm/public/application/action_menu/anomaly_detection_setup_link.test.tsx rename to x-pack/plugins/apm/public/components/shared/apm_header_action_menu/anomaly_detection_setup_link.test.tsx index 044abbb2ec792..6a6ba3f9529ff 100644 --- a/x-pack/plugins/apm/public/application/action_menu/anomaly_detection_setup_link.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/apm_header_action_menu/anomaly_detection_setup_link.test.tsx @@ -8,8 +8,8 @@ import React from 'react'; import { render, fireEvent, waitFor } from '@testing-library/react'; import { MissingJobsAlert } from './anomaly_detection_setup_link'; -import * as hooks from '../../context/anomaly_detection_jobs/use_anomaly_detection_jobs_context'; -import { FETCH_STATUS } from '../../hooks/use_fetcher'; +import * as hooks from '../../../context/anomaly_detection_jobs/use_anomaly_detection_jobs_context'; +import { FETCH_STATUS } from '../../../hooks/use_fetcher'; async function renderTooltipAnchor({ jobs, diff --git a/x-pack/plugins/apm/public/application/action_menu/anomaly_detection_setup_link.tsx b/x-pack/plugins/apm/public/components/shared/apm_header_action_menu/anomaly_detection_setup_link.tsx similarity index 83% rename from x-pack/plugins/apm/public/application/action_menu/anomaly_detection_setup_link.tsx rename to x-pack/plugins/apm/public/components/shared/apm_header_action_menu/anomaly_detection_setup_link.tsx index 296e55fdff82b..ade49bc7e3aa4 100644 --- a/x-pack/plugins/apm/public/application/action_menu/anomaly_detection_setup_link.tsx +++ b/x-pack/plugins/apm/public/components/shared/apm_header_action_menu/anomaly_detection_setup_link.tsx @@ -16,15 +16,15 @@ import React from 'react'; import { ENVIRONMENT_ALL, getEnvironmentLabel, -} from '../../../common/environment_filter_values'; -import { getAPMHref } from '../../components/shared/Links/apm/APMLink'; -import { useAnomalyDetectionJobsContext } from '../../context/anomaly_detection_jobs/use_anomaly_detection_jobs_context'; -import { useApmPluginContext } from '../../context/apm_plugin/use_apm_plugin_context'; -import { useLicenseContext } from '../../context/license/use_license_context'; -import { useUrlParams } from '../../context/url_params_context/use_url_params'; -import { FETCH_STATUS } from '../../hooks/use_fetcher'; -import { APIReturnType } from '../../services/rest/createCallApmApi'; -import { units } from '../../style/variables'; +} from '../../../../common/environment_filter_values'; +import { getAPMHref } from '../Links/apm/APMLink'; +import { useAnomalyDetectionJobsContext } from '../../../context/anomaly_detection_jobs/use_anomaly_detection_jobs_context'; +import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context'; +import { useLicenseContext } from '../../../context/license/use_license_context'; +import { useUrlParams } from '../../../context/url_params_context/use_url_params'; +import { FETCH_STATUS } from '../../../hooks/use_fetcher'; +import { APIReturnType } from '../../../services/rest/createCallApmApi'; +import { units } from '../../../style/variables'; export type AnomalyDetectionApiResponse = APIReturnType<'GET /api/apm/settings/anomaly-detection/jobs'>; diff --git a/x-pack/plugins/apm/public/application/action_menu/index.tsx b/x-pack/plugins/apm/public/components/shared/apm_header_action_menu/index.tsx similarity index 88% rename from x-pack/plugins/apm/public/application/action_menu/index.tsx rename to x-pack/plugins/apm/public/components/shared/apm_header_action_menu/index.tsx index 2d9b619a3176d..134941990a0f4 100644 --- a/x-pack/plugins/apm/public/application/action_menu/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/apm_header_action_menu/index.tsx @@ -9,13 +9,13 @@ import { EuiHeaderLink, EuiHeaderLinks } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; import { useParams } from 'react-router-dom'; -import { getAlertingCapabilities } from '../../components/alerting/get_alerting_capabilities'; -import { getAPMHref } from '../../components/shared/Links/apm/APMLink'; -import { useApmPluginContext } from '../../context/apm_plugin/use_apm_plugin_context'; +import { getAlertingCapabilities } from '../../alerting/get_alerting_capabilities'; +import { getAPMHref } from '../Links/apm/APMLink'; +import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context'; import { AlertingPopoverAndFlyout } from './alerting_popover_flyout'; import { AnomalyDetectionSetupLink } from './anomaly_detection_setup_link'; -export function ActionMenu() { +export function ApmHeaderActionMenu() { const { core, plugins } = useApmPluginContext(); const { serviceName } = useParams<{ serviceName?: string }>(); const { search } = window.location; diff --git a/x-pack/plugins/apm/public/components/shared/main_tabs.tsx b/x-pack/plugins/apm/public/components/shared/main_tabs.tsx deleted file mode 100644 index f60da7c308711..0000000000000 --- a/x-pack/plugins/apm/public/components/shared/main_tabs.tsx +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { EuiTabs } from '@elastic/eui'; -import React, { ReactNode } from 'react'; -import { euiStyled } from '../../../../../../src/plugins/kibana_react/common'; - -// Since our `EuiTab` components have `APMLink`s inside of them and not just -// `href`s, we need to override the color of the links inside or they will all -// be the primary color. -const StyledTabs = euiStyled(EuiTabs)` - padding: ${({ theme }) => `${theme.eui.gutterTypes.gutterMedium}`}; - border-bottom: ${({ theme }) => theme.eui.euiBorderThin}; - border-top: ${({ theme }) => theme.eui.euiBorderThin}; - background: ${({ theme }) => theme.eui.euiColorEmptyShade}; -`; - -export function MainTabs({ children }: { children: ReactNode }) { - return {children}; -} diff --git a/x-pack/plugins/apm/public/components/shared/search_bar.test.tsx b/x-pack/plugins/apm/public/components/shared/search_bar.test.tsx new file mode 100644 index 0000000000000..105bdb008042e --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/search_bar.test.tsx @@ -0,0 +1,120 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getByTestId, fireEvent, getByText } from '@testing-library/react'; +import { createMemoryHistory, MemoryHistory } from 'history'; +import React from 'react'; +import { Router } from 'react-router-dom'; +import { createKibanaReactContext } from 'src/plugins/kibana_react/public'; +import { MockApmPluginContextWrapper } from '../../context/apm_plugin/mock_apm_plugin_context'; +import { ApmServiceContextProvider } from '../../context/apm_service/apm_service_context'; +import { UrlParamsProvider } from '../../context/url_params_context/url_params_context'; +import { IUrlParams } from '../../context/url_params_context/types'; +import * as useFetcherHook from '../../hooks/use_fetcher'; +import * as useServiceTransactionTypesHook from '../../context/apm_service/use_service_transaction_types_fetcher'; +import { renderWithTheme } from '../../utils/testHelpers'; +import { fromQuery } from './Links/url_helpers'; +import { CoreStart } from 'kibana/public'; +import { SearchBar } from './search_bar'; + +function setup({ + urlParams, + serviceTransactionTypes, + history, +}: { + urlParams: IUrlParams; + serviceTransactionTypes: string[]; + history: MemoryHistory; +}) { + history.replace({ + pathname: '/services/foo/transactions', + search: fromQuery(urlParams), + }); + + const KibanaReactContext = createKibanaReactContext({ + usageCollection: { reportUiCounter: () => {} }, + } as Partial); + + // mock transaction types + jest + .spyOn(useServiceTransactionTypesHook, 'useServiceTransactionTypesFetcher') + .mockReturnValue(serviceTransactionTypes); + + jest.spyOn(useFetcherHook, 'useFetcher').mockReturnValue({} as any); + + return renderWithTheme( + + + + + + + + + + + + ); +} + +describe('when transactionType is selected and multiple transaction types are given', () => { + let history: MemoryHistory; + beforeEach(() => { + history = createMemoryHistory(); + jest.spyOn(history, 'push'); + jest.spyOn(history, 'replace'); + }); + + it('renders a radio group with transaction types', () => { + const { container } = setup({ + history, + serviceTransactionTypes: ['firstType', 'secondType'], + urlParams: { + transactionType: 'secondType', + }, + }); + + // transaction type selector + const dropdown = getByTestId(container, 'headerFilterTransactionType'); + + // both options should be listed + expect(getByText(dropdown, 'firstType')).toBeInTheDocument(); + expect(getByText(dropdown, 'secondType')).toBeInTheDocument(); + + // second option should be selected + expect(dropdown).toHaveValue('secondType'); + }); + + it('should update the URL when a transaction type is selected', () => { + const { container } = setup({ + history, + serviceTransactionTypes: ['firstType', 'secondType'], + urlParams: { + transactionType: 'secondType', + }, + }); + + expect(history.location.search).toEqual( + '?transactionType=secondType&rangeFrom=now-15m&rangeTo=now' + ); + + // transaction type selector + const dropdown = getByTestId(container, 'headerFilterTransactionType'); + expect(getByText(dropdown, 'firstType')).toBeInTheDocument(); + expect(getByText(dropdown, 'secondType')).toBeInTheDocument(); + + // change dropdown value + fireEvent.change(dropdown, { target: { value: 'firstType' } }); + + // assert that value was changed + expect(dropdown).toHaveValue('firstType'); + expect(history.push).toHaveBeenCalled(); + expect(history.location.search).toEqual( + '?transactionType=firstType&rangeFrom=now-15m&rangeTo=now' + ); + }); +}); diff --git a/x-pack/plugins/apm/public/components/shared/search_bar.tsx b/x-pack/plugins/apm/public/components/shared/search_bar.tsx index f0fc18cf266b9..17497e1fb4b30 100644 --- a/x-pack/plugins/apm/public/components/shared/search_bar.tsx +++ b/x-pack/plugins/apm/public/components/shared/search_bar.tsx @@ -15,7 +15,6 @@ import { import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import React from 'react'; -import { euiStyled } from '../../../../../../src/plugins/kibana_react/common'; import { enableInspectEsQueries } from '../../../../observability/public'; import { useApmPluginContext } from '../../context/apm_plugin/use_apm_plugin_context'; import { useKibanaUrl } from '../../hooks/useKibanaUrl'; @@ -26,12 +25,9 @@ import { KueryBar } from './KueryBar'; import { TimeComparison } from './time_comparison'; import { TransactionTypeSelect } from './transaction_type_select'; -const EuiFlexGroupSpaced = euiStyled(EuiFlexGroup)` - margin: ${({ theme }) => - `${theme.eui.euiSizeS} ${theme.eui.euiSizeS} -${theme.eui.gutterTypes.gutterMedium} ${theme.eui.euiSizeS}`}; -`; - interface Props { + hidden?: boolean; + showKueryBar?: boolean; showTimeComparison?: boolean; showTransactionTypeSelector?: boolean; } @@ -49,7 +45,7 @@ function DebugQueryCallout() { } return ( - + - + ); } export function SearchBar({ + hidden = false, + showKueryBar = true, showTimeComparison = false, showTransactionTypeSelector = false, }: Props) { const { isSmall, isMedium, isLarge, isXl, isXXL } = useBreakPoints(); + + if (hidden) { + return null; + } + return ( <> - )} - - - + + {showKueryBar && ( + + + + )} @@ -128,7 +134,7 @@ export function SearchBar({ - + ); diff --git a/x-pack/plugins/apm/public/components/app/service_details/service_icons/alert_details.tsx b/x-pack/plugins/apm/public/components/shared/service_icons/alert_details.tsx similarity index 84% rename from x-pack/plugins/apm/public/components/app/service_details/service_icons/alert_details.tsx rename to x-pack/plugins/apm/public/components/shared/service_icons/alert_details.tsx index 0066480230c6b..9f6378ccb4497 100644 --- a/x-pack/plugins/apm/public/components/app/service_details/service_icons/alert_details.tsx +++ b/x-pack/plugins/apm/public/components/shared/service_icons/alert_details.tsx @@ -14,12 +14,12 @@ import { RULE_ID, RULE_NAME, } from '@kbn/rule-data-utils/target/technical_field_names'; -import { parseTechnicalFields } from '../../../../../../rule_registry/common'; -import { useUrlParams } from '../../../../context/url_params_context/use_url_params'; -import { useApmPluginContext } from '../../../../context/apm_plugin/use_apm_plugin_context'; -import { APIReturnType } from '../../../../services/rest/createCallApmApi'; -import { asPercent, asDuration } from '../../../../../common/utils/formatters'; -import { TimestampTooltip } from '../../../shared/TimestampTooltip'; +import { parseTechnicalFields } from '../../../../../rule_registry/common'; +import { useUrlParams } from '../../../context/url_params_context/use_url_params'; +import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context'; +import { APIReturnType } from '../../../services/rest/createCallApmApi'; +import { asPercent, asDuration } from '../../../../common/utils/formatters'; +import { TimestampTooltip } from '../TimestampTooltip'; interface AlertDetailProps { alerts: APIReturnType<'GET /api/apm/services/{serviceName}/alerts'>['alerts']; diff --git a/x-pack/plugins/apm/public/components/app/service_details/service_icons/cloud_details.tsx b/x-pack/plugins/apm/public/components/shared/service_icons/cloud_details.tsx similarity index 97% rename from x-pack/plugins/apm/public/components/app/service_details/service_icons/cloud_details.tsx rename to x-pack/plugins/apm/public/components/shared/service_icons/cloud_details.tsx index 2e19bc684d681..2e8fcfa1df672 100644 --- a/x-pack/plugins/apm/public/components/app/service_details/service_icons/cloud_details.tsx +++ b/x-pack/plugins/apm/public/components/shared/service_icons/cloud_details.tsx @@ -9,7 +9,7 @@ import { EuiBadge, EuiDescriptionList } from '@elastic/eui'; import { EuiDescriptionListProps } from '@elastic/eui/src/components/description_list/description_list'; import { i18n } from '@kbn/i18n'; import React from 'react'; -import { APIReturnType } from '../../../../services/rest/createCallApmApi'; +import { APIReturnType } from '../../../services/rest/createCallApmApi'; type ServiceDetailsReturnType = APIReturnType<'GET /api/apm/services/{serviceName}/metadata/details'>; diff --git a/x-pack/plugins/apm/public/components/app/service_details/service_icons/container_details.tsx b/x-pack/plugins/apm/public/components/shared/service_icons/container_details.tsx similarity index 94% rename from x-pack/plugins/apm/public/components/app/service_details/service_icons/container_details.tsx rename to x-pack/plugins/apm/public/components/shared/service_icons/container_details.tsx index efc9a46526cf8..b590a67409d9e 100644 --- a/x-pack/plugins/apm/public/components/app/service_details/service_icons/container_details.tsx +++ b/x-pack/plugins/apm/public/components/shared/service_icons/container_details.tsx @@ -9,8 +9,8 @@ import { EuiDescriptionList } from '@elastic/eui'; import { EuiDescriptionListProps } from '@elastic/eui/src/components/description_list/description_list'; import { i18n } from '@kbn/i18n'; import React from 'react'; -import { asInteger } from '../../../../../common/utils/formatters'; -import { APIReturnType } from '../../../../services/rest/createCallApmApi'; +import { asInteger } from '../../../../common/utils/formatters'; +import { APIReturnType } from '../../../services/rest/createCallApmApi'; type ServiceDetailsReturnType = APIReturnType<'GET /api/apm/services/{serviceName}/metadata/details'>; diff --git a/x-pack/plugins/apm/public/components/app/service_details/service_icons/icon_popover.tsx b/x-pack/plugins/apm/public/components/shared/service_icons/icon_popover.tsx similarity index 93% rename from x-pack/plugins/apm/public/components/app/service_details/service_icons/icon_popover.tsx rename to x-pack/plugins/apm/public/components/shared/service_icons/icon_popover.tsx index 79f93ea76ee51..05305558564f1 100644 --- a/x-pack/plugins/apm/public/components/app/service_details/service_icons/icon_popover.tsx +++ b/x-pack/plugins/apm/public/components/shared/service_icons/icon_popover.tsx @@ -13,8 +13,8 @@ import { EuiPopoverTitle, } from '@elastic/eui'; import React from 'react'; -import { FETCH_STATUS } from '../../../../hooks/use_fetcher'; -import { px } from '../../../../style/variables'; +import { FETCH_STATUS } from '../../../hooks/use_fetcher'; +import { px } from '../../../style/variables'; interface IconPopoverProps { title: string; diff --git a/x-pack/plugins/apm/public/components/app/service_details/service_icons/index.test.tsx b/x-pack/plugins/apm/public/components/shared/service_icons/index.test.tsx similarity index 95% rename from x-pack/plugins/apm/public/components/app/service_details/service_icons/index.test.tsx rename to x-pack/plugins/apm/public/components/shared/service_icons/index.test.tsx index 6027e8b1d07c5..d66625f613cdc 100644 --- a/x-pack/plugins/apm/public/components/app/service_details/service_icons/index.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/service_icons/index.test.tsx @@ -11,14 +11,14 @@ import { merge } from 'lodash'; // import { renderWithTheme } from '../../../../utils/testHelpers'; import React, { ReactNode } from 'react'; import { createKibanaReactContext } from 'src/plugins/kibana_react/public'; -import { MockUrlParamsContextProvider } from '../../../../context/url_params_context/mock_url_params_context_provider'; -import { ApmPluginContextValue } from '../../../../context/apm_plugin/apm_plugin_context'; +import { MockUrlParamsContextProvider } from '../../../context/url_params_context/mock_url_params_context_provider'; +import { ApmPluginContextValue } from '../../../context/apm_plugin/apm_plugin_context'; import { mockApmPluginContextValue, MockApmPluginContextWrapper, -} from '../../../../context/apm_plugin/mock_apm_plugin_context'; -import * as fetcherHook from '../../../../hooks/use_fetcher'; -import { ServiceIcons } from './'; +} from '../../../context/apm_plugin/mock_apm_plugin_context'; +import * as fetcherHook from '../../../hooks/use_fetcher'; +import { ServiceIcons } from '.'; import { EuiThemeProvider } from 'src/plugins/kibana_react/common'; const KibanaReactContext = createKibanaReactContext({ diff --git a/x-pack/plugins/apm/public/components/app/service_details/service_icons/index.tsx b/x-pack/plugins/apm/public/components/shared/service_icons/index.tsx similarity index 91% rename from x-pack/plugins/apm/public/components/app/service_details/service_icons/index.tsx rename to x-pack/plugins/apm/public/components/shared/service_icons/index.tsx index f7bed4e09a696..d64605da2bc3f 100644 --- a/x-pack/plugins/apm/public/components/app/service_details/service_icons/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/service_icons/index.tsx @@ -8,12 +8,12 @@ import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { ReactChild, useState } from 'react'; -import { useApmServiceContext } from '../../../../context/apm_service/use_apm_service_context'; -import { useTheme } from '../../../../hooks/use_theme'; -import { ContainerType } from '../../../../../common/service_metadata'; -import { useUrlParams } from '../../../../context/url_params_context/use_url_params'; -import { FETCH_STATUS, useFetcher } from '../../../../hooks/use_fetcher'; -import { getAgentIcon } from '../../../shared/AgentIcon/get_agent_icon'; +import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; +import { useTheme } from '../../../hooks/use_theme'; +import { ContainerType } from '../../../../common/service_metadata'; +import { useUrlParams } from '../../../context/url_params_context/use_url_params'; +import { FETCH_STATUS, useFetcher } from '../../../hooks/use_fetcher'; +import { getAgentIcon } from '../AgentIcon/get_agent_icon'; import { CloudDetails } from './cloud_details'; import { ContainerDetails } from './container_details'; import { IconPopover } from './icon_popover'; diff --git a/x-pack/plugins/apm/public/components/app/service_details/service_icons/service_details.tsx b/x-pack/plugins/apm/public/components/shared/service_icons/service_details.tsx similarity index 96% rename from x-pack/plugins/apm/public/components/app/service_details/service_icons/service_details.tsx rename to x-pack/plugins/apm/public/components/shared/service_icons/service_details.tsx index ed503a5cb34a0..1828465fff450 100644 --- a/x-pack/plugins/apm/public/components/app/service_details/service_icons/service_details.tsx +++ b/x-pack/plugins/apm/public/components/shared/service_icons/service_details.tsx @@ -9,7 +9,7 @@ import { EuiDescriptionList } from '@elastic/eui'; import { EuiDescriptionListProps } from '@elastic/eui/src/components/description_list/description_list'; import { i18n } from '@kbn/i18n'; import React from 'react'; -import { APIReturnType } from '../../../../services/rest/createCallApmApi'; +import { APIReturnType } from '../../../services/rest/createCallApmApi'; type ServiceDetailsReturnType = APIReturnType<'GET /api/apm/services/{serviceName}/metadata/details'>; diff --git a/x-pack/plugins/apm/public/hooks/use_breadcrumbs.test.tsx b/x-pack/plugins/apm/public/hooks/use_breadcrumbs.test.tsx index 13a2fa3b227da..64990651b52bb 100644 --- a/x-pack/plugins/apm/public/hooks/use_breadcrumbs.test.tsx +++ b/x-pack/plugins/apm/public/hooks/use_breadcrumbs.test.tsx @@ -9,7 +9,7 @@ import { renderHook } from '@testing-library/react-hooks'; import produce from 'immer'; import React, { ReactNode } from 'react'; import { MemoryRouter } from 'react-router-dom'; -import { routes } from '../components/app/Main/route_config'; +import { apmRouteConfig } from '../components/routing/apm_route_config'; import { ApmPluginContextValue } from '../context/apm_plugin/apm_plugin_context'; import { mockApmPluginContextValue, @@ -36,7 +36,9 @@ function createWrapper(path: string) { } function mountBreadcrumb(path: string) { - renderHook(() => useBreadcrumbs(routes), { wrapper: createWrapper(path) }); + renderHook(() => useBreadcrumbs(apmRouteConfig), { + wrapper: createWrapper(path), + }); } const changeTitle = jest.fn(); diff --git a/x-pack/plugins/apm/public/plugin.ts b/x-pack/plugins/apm/public/plugin.ts index 10af1837dab42..845b18b707f93 100644 --- a/x-pack/plugins/apm/public/plugin.ts +++ b/x-pack/plugins/apm/public/plugin.ts @@ -6,6 +6,7 @@ */ import { i18n } from '@kbn/i18n'; +import { of } from 'rxjs'; import type { ConfigSchema } from '.'; import { AppMountParameters, @@ -34,6 +35,7 @@ import type { FetchDataParams, HasDataParams, ObservabilityPublicSetup, + ObservabilityPublicStart, } from '../../observability/public'; import type { TriggersAndActionsUIPublicPluginSetup, @@ -48,24 +50,25 @@ export type ApmPluginStart = void; export interface ApmPluginSetupDeps { alerting?: AlertingPluginPublicSetup; - ml?: MlPluginSetup; data: DataPublicPluginSetup; features: FeaturesPluginSetup; home?: HomePublicPluginSetup; licensing: LicensingPluginSetup; - triggersActionsUi: TriggersAndActionsUIPublicPluginSetup; + ml?: MlPluginSetup; observability: ObservabilityPublicSetup; + triggersActionsUi: TriggersAndActionsUIPublicPluginSetup; } export interface ApmPluginStartDeps { alerting?: AlertingPluginPublicStart; - ml?: MlPluginStart; data: DataPublicPluginStart; + embeddable: EmbeddableStart; home: void; licensing: void; - triggersActionsUi: TriggersAndActionsUIPublicPluginStart; - embeddable: EmbeddableStart; maps?: MapsStartApi; + ml?: MlPluginStart; + observability: ObservabilityPublicStart; + triggersActionsUi: TriggersAndActionsUIPublicPluginStart; } export class ApmPlugin implements Plugin { @@ -83,6 +86,21 @@ export class ApmPlugin implements Plugin { pluginSetupDeps.home.featureCatalogue.register(featureCatalogueEntry); } + // register observability nav + plugins.observability.navigation.registerSections( + of([ + { + label: 'APM', + sortKey: 200, + entries: [ + { label: 'Services', app: 'apm', path: '/services' }, + { label: 'Traces', app: 'apm', path: '/traces' }, + { label: 'Service Map', app: 'apm', path: '/service-map' }, + ], + }, + ]) + ); + const getApmDataHelper = async () => { const { fetchObservabilityOverviewPageData, diff --git a/x-pack/plugins/apm/public/utils/getRangeFromTimeSeries.ts b/x-pack/plugins/apm/public/utils/getRangeFromTimeSeries.ts deleted file mode 100644 index 7b4a07dc7bbc5..0000000000000 --- a/x-pack/plugins/apm/public/utils/getRangeFromTimeSeries.ts +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { flatten } from 'lodash'; -import { TimeSeries } from '../../typings/timeseries'; - -export function getRangeFromTimeSeries(timeseries: TimeSeries[]) { - const dataPoints = flatten(timeseries.map((series) => series.data)); - - if (dataPoints.length) { - return { - start: dataPoints[0].x, - end: dataPoints[dataPoints.length - 1].x, - }; - } - - return null; -} diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index c2cad05ff9e30..4c927d5094ca4 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -5400,22 +5400,9 @@ "xpack.apm.apply.label": "適用", "xpack.apm.applyFilter": "{title} フィルターを適用", "xpack.apm.applyOptions": "オプションを適用", - "xpack.apm.breadcrumb.errorsTitle": "エラー", - "xpack.apm.breadcrumb.listSettingsTitle": "設定", - "xpack.apm.breadcrumb.metricsTitle": "メトリック", - "xpack.apm.breadcrumb.nodesTitle": "JVM", - "xpack.apm.breadcrumb.overviewTitle": "概要", "xpack.apm.breadcrumb.serviceMapTitle": "サービスマップ", - "xpack.apm.breadcrumb.serviceProfilingTitle": "プロファイリング", "xpack.apm.breadcrumb.servicesTitle": "サービス", - "xpack.apm.breadcrumb.settings.agentConfigurationTitle": "エージェントの編集", - "xpack.apm.breadcrumb.settings.anomalyDetection": "異常検知", - "xpack.apm.breadcrumb.settings.createAgentConfigurationTitle": "エージェント構成の作成", - "xpack.apm.breadcrumb.settings.customizeUI": "UI をカスタマイズ", - "xpack.apm.breadcrumb.settings.editAgentConfigurationTitle": "エージェント構成の編集", - "xpack.apm.breadcrumb.settings.indicesTitle": "インデックス", "xpack.apm.breadcrumb.tracesTitle": "トレース", - "xpack.apm.breadcrumb.transactionsTitle": "トランザクション", "xpack.apm.chart.annotation.version": "バージョン", "xpack.apm.chart.cpuSeries.processAverageLabel": "プロセス平均", "xpack.apm.chart.cpuSeries.processMaxLabel": "プロセス最大", @@ -5524,8 +5511,6 @@ "xpack.apm.home.alertsMenu.transactionErrorRate": "トランザクションエラー率", "xpack.apm.home.alertsMenu.viewActiveAlerts": "アクティブアラートを表示", "xpack.apm.home.serviceMapTabLabel": "サービスマップ", - "xpack.apm.home.servicesTabLabel": "サービス", - "xpack.apm.home.tracesTabLabel": "トレース", "xpack.apm.instancesLatencyDistributionChartLegend": "インスタンス", "xpack.apm.instancesLatencyDistributionChartLegend.previousPeriod": "前の期間", "xpack.apm.instancesLatencyDistributionChartTitle": "インスタンスのレイテンシ分布", @@ -5589,7 +5574,6 @@ "xpack.apm.profiling.highlightFrames": "検索", "xpack.apm.profiling.table.name": "名前", "xpack.apm.profiling.table.value": "自己", - "xpack.apm.profilingOverviewTitle": "プロファイリング", "xpack.apm.propertiesTable.agentFeature.noDataAvailableLabel": "利用可能なデータがありません", "xpack.apm.propertiesTable.agentFeature.noResultFound": "\"{value}\"に対する結果が見つかりませんでした。", "xpack.apm.propertiesTable.tabs.exceptionStacktraceLabel": "例外のスタックトレース", @@ -5654,7 +5638,6 @@ "xpack.apm.selectPlaceholder": "オプションを選択:", "xpack.apm.serviceDetails.errorsTabLabel": "エラー", "xpack.apm.serviceDetails.metrics.cpuUsageChartTitle": "CPU 使用状況", - "xpack.apm.serviceDetails.metrics.errorOccurrencesChartTitle": "エラーのオカレンス", "xpack.apm.serviceDetails.metrics.memoryUsageChartTitle": "システムメモリー使用状況", "xpack.apm.serviceDetails.metricsTabLabel": "メトリック", "xpack.apm.serviceDetails.nodesTabLabel": "JVM", @@ -5891,7 +5874,6 @@ "xpack.apm.settings.customizeUI.customLink.table.url": "URL", "xpack.apm.settings.indices": "インデックス", "xpack.apm.settings.pageTitle": "設定", - "xpack.apm.settings.returnLinkLabel": "インベントリに戻る", "xpack.apm.settingsLinkLabel": "設定", "xpack.apm.setupInstructionsButtonLabel": "セットアップの手順", "xpack.apm.significanTerms.license.text": "相関関係APIを使用するには、Elastic Platinumライセンスのサブスクリプションが必要です。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index d3a3d9ae30c37..57a1b6a8751fd 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -5429,22 +5429,9 @@ "xpack.apm.apply.label": "应用", "xpack.apm.applyFilter": "应用 {title} 筛选", "xpack.apm.applyOptions": "应用选项", - "xpack.apm.breadcrumb.errorsTitle": "错误", - "xpack.apm.breadcrumb.listSettingsTitle": "设置", - "xpack.apm.breadcrumb.metricsTitle": "指标", - "xpack.apm.breadcrumb.nodesTitle": "JVM", - "xpack.apm.breadcrumb.overviewTitle": "概览", "xpack.apm.breadcrumb.serviceMapTitle": "服务地图", - "xpack.apm.breadcrumb.serviceProfilingTitle": "分析", "xpack.apm.breadcrumb.servicesTitle": "服务", - "xpack.apm.breadcrumb.settings.agentConfigurationTitle": "代理配置", - "xpack.apm.breadcrumb.settings.anomalyDetection": "异常检测", - "xpack.apm.breadcrumb.settings.createAgentConfigurationTitle": "创建代理配置", - "xpack.apm.breadcrumb.settings.customizeUI": "定制 UI", - "xpack.apm.breadcrumb.settings.editAgentConfigurationTitle": "编辑代理配置", - "xpack.apm.breadcrumb.settings.indicesTitle": "索引", "xpack.apm.breadcrumb.tracesTitle": "追溯", - "xpack.apm.breadcrumb.transactionsTitle": "事务", "xpack.apm.chart.annotation.version": "版本", "xpack.apm.chart.cpuSeries.processAverageLabel": "进程平均值", "xpack.apm.chart.cpuSeries.processMaxLabel": "进程最大值", @@ -5554,8 +5541,6 @@ "xpack.apm.home.alertsMenu.transactionErrorRate": "事务错误率", "xpack.apm.home.alertsMenu.viewActiveAlerts": "查看活动告警", "xpack.apm.home.serviceMapTabLabel": "服务地图", - "xpack.apm.home.servicesTabLabel": "服务", - "xpack.apm.home.tracesTabLabel": "追溯", "xpack.apm.instancesLatencyDistributionChartLegend": "实例", "xpack.apm.instancesLatencyDistributionChartLegend.previousPeriod": "上一时段", "xpack.apm.instancesLatencyDistributionChartTitle": "实例延迟分布", @@ -5622,7 +5607,6 @@ "xpack.apm.profiling.highlightFrames": "搜索", "xpack.apm.profiling.table.name": "名称", "xpack.apm.profiling.table.value": "自我", - "xpack.apm.profilingOverviewTitle": "分析", "xpack.apm.propertiesTable.agentFeature.noDataAvailableLabel": "没有可用数据", "xpack.apm.propertiesTable.agentFeature.noResultFound": "没有“{value}”的结果。", "xpack.apm.propertiesTable.tabs.exceptionStacktraceLabel": "异常堆栈跟踪", @@ -5687,7 +5671,6 @@ "xpack.apm.selectPlaceholder": "选择选项:", "xpack.apm.serviceDetails.errorsTabLabel": "错误", "xpack.apm.serviceDetails.metrics.cpuUsageChartTitle": "CPU 使用", - "xpack.apm.serviceDetails.metrics.errorOccurrencesChartTitle": "错误发生次数", "xpack.apm.serviceDetails.metrics.memoryUsageChartTitle": "系统内存使用", "xpack.apm.serviceDetails.metricsTabLabel": "指标", "xpack.apm.serviceDetails.nodesTabLabel": "JVM", @@ -5925,7 +5908,6 @@ "xpack.apm.settings.customizeUI.customLink.table.url": "URL", "xpack.apm.settings.indices": "索引", "xpack.apm.settings.pageTitle": "设置", - "xpack.apm.settings.returnLinkLabel": "返回库存", "xpack.apm.settingsLinkLabel": "设置", "xpack.apm.setupInstructionsButtonLabel": "设置说明", "xpack.apm.significanTerms.license.text": "要使用相关性 API,必须订阅 Elastic 白金级许可证。", From d6164aeecc3eab4df4bc91606099479776a2206c Mon Sep 17 00:00:00 2001 From: Ignacio Rivas Date: Fri, 4 Jun 2021 10:12:04 +0200 Subject: [PATCH 43/90] [Ingest pipelines] add media_type to set processor (#101035) * start working on conditionally showing the field * add tests and document regex matcher * add tests for set processor * fix broken tests * move path below componentProps * Add little comment about whitespaces handling * template snippets can also contain strings other Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../__jest__/processors/processor.helpers.tsx | 4 + .../__jest__/processors/set.test.tsx | 146 ++++++++++++++++++ .../processor_form/processors/set.tsx | 61 +++++++- .../components/pipeline_editor/utils.test.ts | 18 ++- .../components/pipeline_editor/utils.ts | 17 ++ 5 files changed, 243 insertions(+), 3 deletions(-) create mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/set.test.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/processor.helpers.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/processor.helpers.tsx index 9dd0d6cc72de1..c00f09b2d2b06 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/processor.helpers.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/processor.helpers.tsx @@ -154,6 +154,10 @@ type TestSubject = | 'separatorValueField.input' | 'quoteValueField.input' | 'emptyValueField.input' + | 'valueFieldInput' + | 'mediaTypeSelectorField' + | 'ignoreEmptyField.input' + | 'overrideField.input' | 'fieldsValueField.input' | 'saltValueField.input' | 'methodsValueField' diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/set.test.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/set.test.tsx new file mode 100644 index 0000000000000..d7351c9dbf65f --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/set.test.tsx @@ -0,0 +1,146 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act } from 'react-dom/test-utils'; +import { setup, SetupResult, getProcessorValue } from './processor.helpers'; + +// Default parameter values automatically added to the set processor when saved +const defaultSetParameters = { + value: '', + if: undefined, + tag: undefined, + override: undefined, + media_type: undefined, + description: undefined, + ignore_missing: undefined, + ignore_failure: undefined, + ignore_empty_value: undefined, +}; + +const SET_TYPE = 'set'; + +describe('Processor: Set', () => { + let onUpdate: jest.Mock; + let testBed: SetupResult; + + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + beforeEach(async () => { + onUpdate = jest.fn(); + + await act(async () => { + testBed = await setup({ + value: { + processors: [], + }, + onFlyoutOpen: jest.fn(), + onUpdate, + }); + }); + + testBed.component.update(); + + // Open flyout to add new processor + testBed.actions.addProcessor(); + // Add type (the other fields are not visible until a type is selected) + await testBed.actions.addProcessorType(SET_TYPE); + }); + + test('prevents form submission if required fields are not provided', async () => { + const { + actions: { saveNewProcessor }, + form, + } = testBed; + + // Click submit button with only the type defined + await saveNewProcessor(); + + // Expect form error as "field" is required parameter + expect(form.getErrorsMessages()).toEqual(['A field value is required.']); + }); + + test('saves with default parameter value', async () => { + const { + actions: { saveNewProcessor }, + form, + } = testBed; + + // Add "field" value (required) + form.setInputValue('fieldNameField.input', 'field_1'); + // Save the field + await saveNewProcessor(); + + const processors = getProcessorValue(onUpdate, SET_TYPE); + expect(processors[0][SET_TYPE]).toEqual({ + ...defaultSetParameters, + field: 'field_1', + }); + }); + + test('should allow to set mediaType when value is a template snippet', async () => { + const { + actions: { saveNewProcessor }, + form, + exists, + } = testBed; + + // Add "field" value (required) + form.setInputValue('fieldNameField.input', 'field_1'); + + // Shouldnt be able to set mediaType if value is not a template string + form.setInputValue('valueFieldInput', 'hello'); + expect(exists('mediaTypeSelectorField')).toBe(false); + + // Set value to a template snippet and media_type to a non-default value + form.setInputValue('valueFieldInput', '{{{hello}}}'); + form.setSelectValue('mediaTypeSelectorField', 'text/plain'); + + // Save the field with new changes + await saveNewProcessor(); + + const processors = getProcessorValue(onUpdate, SET_TYPE); + expect(processors[0][SET_TYPE]).toEqual({ + ...defaultSetParameters, + field: 'field_1', + value: '{{{hello}}}', + media_type: 'text/plain', + }); + }); + + test('allows optional parameters to be set', async () => { + const { + actions: { saveNewProcessor }, + form, + } = testBed; + + // Add "field" value (required) + form.setInputValue('fieldNameField.input', 'field_1'); + + // Set optional parameteres + form.setInputValue('valueFieldInput', '{{{hello}}}'); + form.toggleEuiSwitch('overrideField.input'); + form.toggleEuiSwitch('ignoreEmptyField.input'); + + // Save the field with new changes + await saveNewProcessor(); + + const processors = getProcessorValue(onUpdate, SET_TYPE); + expect(processors[0][SET_TYPE]).toEqual({ + ...defaultSetParameters, + field: 'field_1', + value: '{{{hello}}}', + ignore_empty_value: true, + override: false, + }); + }); +}); diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/set.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/set.tsx index 89ca373b9e653..fda34f8700b33 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/set.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/set.tsx @@ -10,7 +10,15 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiCode } from '@elastic/eui'; -import { FIELD_TYPES, ToggleField, UseField, Field } from '../../../../../../shared_imports'; +import { + FIELD_TYPES, + useFormData, + SelectField, + ToggleField, + UseField, + Field, +} from '../../../../../../shared_imports'; +import { hasTemplateSnippet } from '../../../utils'; import { FieldsConfig, to, from } from './shared'; @@ -35,6 +43,20 @@ const fieldsConfig: FieldsConfig = { /> ), }, + mediaType: { + type: FIELD_TYPES.SELECT, + defaultValue: 'application/json', + serializer: from.undefinedIfValue('application/json'), + label: i18n.translate('xpack.ingestPipelines.pipelineEditor.setForm.mediaTypeFieldLabel', { + defaultMessage: 'Media Type', + }), + helpText: ( + + ), + }, /* Optional fields config */ override: { type: FIELD_TYPES.TOGGLE, @@ -83,6 +105,8 @@ const fieldsConfig: FieldsConfig = { * Disambiguate name from the Set data structure */ export const SetProcessor: FunctionComponent = () => { + const [{ fields }] = useFormData({ watch: 'fields.value' }); + return ( <> { path="fields.value" /> - + {hasTemplateSnippet(fields?.value) && ( + + )} + + ); diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/utils.test.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/utils.test.ts index 6f21285398e1f..6e367a83bf8d4 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/utils.test.ts +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/utils.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { getValue, setValue } from './utils'; +import { getValue, setValue, hasTemplateSnippet } from './utils'; describe('get and set values', () => { const testObject = Object.freeze([{ onFailure: [{ onFailure: 1 }] }]); @@ -35,3 +35,19 @@ describe('get and set values', () => { }); }); }); + +describe('template snippets', () => { + it('knows when a string contains an invalid template snippet', () => { + expect(hasTemplateSnippet('')).toBe(false); + expect(hasTemplateSnippet('{}')).toBe(false); + expect(hasTemplateSnippet('{{{}}}')).toBe(false); + expect(hasTemplateSnippet('{{hello}}')).toBe(false); + }); + + it('knows when a string contains a valid template snippet', () => { + expect(hasTemplateSnippet('{{{hello}}}')).toBe(true); + expect(hasTemplateSnippet('hello{{{world}}}')).toBe(true); + expect(hasTemplateSnippet('{{{hello}}}world')).toBe(true); + expect(hasTemplateSnippet('{{{hello.world}}}')).toBe(true); + }); +}); diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/utils.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/utils.ts index e07b9eba90622..1259dbd5a9b91 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/utils.ts +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/utils.ts @@ -102,3 +102,20 @@ export const checkIfSamePath = (pathA: ProcessorSelector, pathB: ProcessorSelect if (pathA.length !== pathB.length) return false; return pathA.join('.') === pathB.join('.'); }; + +/* + * Given a string it checks if it contains a valid mustache template snippet. + * + * Note: This allows strings with spaces such as: {{{hello world}}}. I figured we + * should use .+ instead of \S (disallow all whitespaces) because the backend seems + * to allow spaces inside the template snippet anyway. + * + * See: https://www.elastic.co/guide/en/elasticsearch/reference/master/ingest.html#template-snippets + */ +export const hasTemplateSnippet = (str: string = '') => { + // Matches when: + // * contains a {{{ + // * Followed by all strings of length >= 1 + // * And followed by }}} + return /{{{.+}}}/.test(str); +}; From e3198bcb57359f8492b3388a073a19aa1de3121b Mon Sep 17 00:00:00 2001 From: Marco Liberati Date: Fri, 4 Jun 2021 10:19:04 +0200 Subject: [PATCH 44/90] [Lens] rewrite warning messages with i18n (#101314) --- .../public/pie_visualization/visualization.tsx | 15 ++++++++++----- .../xy_visualization/visualization.test.ts | 17 +++++++++++------ .../public/xy_visualization/visualization.tsx | 15 ++++++++++----- 3 files changed, 31 insertions(+), 16 deletions(-) diff --git a/x-pack/plugins/lens/public/pie_visualization/visualization.tsx b/x-pack/plugins/lens/public/pie_visualization/visualization.tsx index f413b122d913c..6e04d1a4ff958 100644 --- a/x-pack/plugins/lens/public/pie_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/visualization.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { render } from 'react-dom'; import { i18n } from '@kbn/i18n'; -import { I18nProvider } from '@kbn/i18n/react'; +import { FormattedMessage, I18nProvider } from '@kbn/i18n/react'; import { PaletteRegistry } from 'src/plugins/charts/public'; import { Visualization, OperationMetadata, AccessorConfig } from '../types'; import { toExpression, toPreviewExpression } from './to_expression'; @@ -265,10 +265,15 @@ export const getPieVisualization = ({ } } return metricColumnsWithArrayValues.map((label) => ( - <> - {label} contains array values. Your visualization may not render as - expected. - + {label}, + }} + /> )); }, diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts b/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts index 8fbc8e8b2ef7a..c1041e1fefcfd 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts @@ -929,12 +929,17 @@ describe('xy_visualization', () => { ); expect(warningMessages).toHaveLength(1); expect(warningMessages && warningMessages[0]).toMatchInlineSnapshot(` - - - Label B - - contains array values. Your visualization may not render as expected. - + + Label B + , + } + } + /> `); }); }); diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx index fa9d46be11d68..ad2c9fd713985 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { uniq } from 'lodash'; import { render } from 'react-dom'; import { Position } from '@elastic/charts'; -import { I18nProvider } from '@kbn/i18n/react'; +import { FormattedMessage, I18nProvider } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { PaletteRegistry } from 'src/plugins/charts/public'; import { DataPublicPluginStart } from 'src/plugins/data/public'; @@ -439,10 +439,15 @@ export const getXyVisualization = ({ } } return accessorsWithArrayValues.map((label) => ( - <> - {label} contains array values. Your visualization may not render as - expected. - + {label}, + }} + /> )); }, }); From 6927d6cf1ce6d8dc2962cfabe13adcf208cedef6 Mon Sep 17 00:00:00 2001 From: Stratoula Kalafateli Date: Fri, 4 Jun 2021 11:42:51 +0300 Subject: [PATCH 45/90] [Visualize] Adds a unit test to compare the by value and by ref migrations (#101247) * [Visualize] Add unti test to compare the by value and by ref migrations * Fix file name Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../visualize_embeddable_factory.test.ts | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 src/plugins/visualizations/server/embeddable/visualize_embeddable_factory.test.ts diff --git a/src/plugins/visualizations/server/embeddable/visualize_embeddable_factory.test.ts b/src/plugins/visualizations/server/embeddable/visualize_embeddable_factory.test.ts new file mode 100644 index 0000000000000..fe0f1a766e8ac --- /dev/null +++ b/src/plugins/visualizations/server/embeddable/visualize_embeddable_factory.test.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import semverGte from 'semver/functions/gte'; +import { visualizeEmbeddableFactory } from './visualize_embeddable_factory'; +import { visualizationSavedObjectTypeMigrations } from '../migrations/visualization_saved_object_migrations'; + +describe('saved object migrations and embeddable migrations', () => { + test('should have same versions registered (>7.13.0)', () => { + const savedObjectMigrationVersions = Object.keys(visualizationSavedObjectTypeMigrations).filter( + (version) => { + return semverGte(version, '7.13.1'); + } + ); + const embeddableMigrationVersions = visualizeEmbeddableFactory()?.migrations; + if (embeddableMigrationVersions) { + expect(savedObjectMigrationVersions.sort()).toEqual( + Object.keys(embeddableMigrationVersions).sort() + ); + } + }); +}); From 71adda0366c638c6e755e070869ebcf610e58efa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Louv-Jansen?= Date: Fri, 4 Jun 2021 11:27:11 +0200 Subject: [PATCH 46/90] [APM] Update ESLint and tsc commands in APM readme (#101207) * [APM] Change typescript command in readme * Update eslint command --- .../plugins/apm/e2e/cypress/integration/csm_dashboard.feature | 1 - x-pack/plugins/apm/readme.md | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/apm/e2e/cypress/integration/csm_dashboard.feature b/x-pack/plugins/apm/e2e/cypress/integration/csm_dashboard.feature index 4c598d8d168a4..2b95216bc3719 100644 --- a/x-pack/plugins/apm/e2e/cypress/integration/csm_dashboard.feature +++ b/x-pack/plugins/apm/e2e/cypress/integration/csm_dashboard.feature @@ -29,7 +29,6 @@ Feature: CSM Dashboard When a user browses the APM UI application for RUM Data Then should display percentile for page load chart And should display tooltip on hover - And should display chart legend Scenario: Breakdown filter Given a user clicks the page load breakdown filter diff --git a/x-pack/plugins/apm/readme.md b/x-pack/plugins/apm/readme.md index ef2675f4f6c65..9cfb6210e2541 100644 --- a/x-pack/plugins/apm/readme.md +++ b/x-pack/plugins/apm/readme.md @@ -124,7 +124,7 @@ _Note: Run the following commands from `kibana/`._ ### Typescript ``` -yarn tsc --noEmit --emitDeclarationOnly false --project x-pack/plugins/apm/tsconfig.json --skipLibCheck +node scripts/type_check.js --project x-pack/plugins/apm/tsconfig.json ``` ### Prettier @@ -136,7 +136,7 @@ yarn prettier "./x-pack/plugins/apm/**/*.{tsx,ts,js}" --write ### ESLint ``` -yarn eslint ./x-pack/plugins/apm --fix +node scripts/eslint.js x-pack/legacy/plugins/apm ``` ## Setup default APM users From d62bb452dd527ab4f7cbd4a4f1d7a2ba9fac05e4 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Fri, 4 Jun 2021 13:16:31 +0200 Subject: [PATCH 47/90] make sure migrations stay in sync (#101362) --- .../lens_embeddable_factory.test.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 x-pack/plugins/lens/server/embeddable/lens_embeddable_factory.test.ts diff --git a/x-pack/plugins/lens/server/embeddable/lens_embeddable_factory.test.ts b/x-pack/plugins/lens/server/embeddable/lens_embeddable_factory.test.ts new file mode 100644 index 0000000000000..9ce405804bde1 --- /dev/null +++ b/x-pack/plugins/lens/server/embeddable/lens_embeddable_factory.test.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import semverGte from 'semver/functions/gte'; +import { lensEmbeddableFactory } from './lens_embeddable_factory'; +import { migrations } from '../migrations/saved_object_migrations'; + +describe('saved object migrations and embeddable migrations', () => { + test('should have same versions registered (>7.13.0)', () => { + const savedObjectMigrationVersions = Object.keys(migrations).filter((version) => { + return semverGte(version, '7.13.1'); + }); + const embeddableMigrationVersions = lensEmbeddableFactory()?.migrations; + if (embeddableMigrationVersions) { + expect(savedObjectMigrationVersions.sort()).toEqual( + Object.keys(embeddableMigrationVersions).sort() + ); + } + }); +}); From aa8aa7f23dc0ac69e375234a57f1aeef20fabbdd Mon Sep 17 00:00:00 2001 From: Pierre Gayvallet Date: Fri, 4 Jun 2021 14:46:05 +0200 Subject: [PATCH 48/90] Saved object export: apply export hooks to referenced / nested objects (#100769) * execute export transform for nested references * fix sort * fix duplicate references * add FTR test --- .../collect_exported_objects.test.mocks.ts | 12 + .../export/collect_exported_objects.test.ts | 528 +++++++++++++++ .../export/collect_exported_objects.ts | 128 ++++ .../export/fetch_nested_dependencies.test.ts | 606 ------------------ .../export/fetch_nested_dependencies.ts | 50 -- .../export/saved_objects_exporter.ts | 32 +- .../nested_export_transform/data.json | 87 +++ .../nested_export_transform/mappings.json | 499 ++++++++++++++ .../export_transform.ts | 261 ++++---- 9 files changed, 1420 insertions(+), 783 deletions(-) create mode 100644 src/core/server/saved_objects/export/collect_exported_objects.test.mocks.ts create mode 100644 src/core/server/saved_objects/export/collect_exported_objects.test.ts create mode 100644 src/core/server/saved_objects/export/collect_exported_objects.ts delete mode 100644 src/core/server/saved_objects/export/fetch_nested_dependencies.test.ts delete mode 100644 src/core/server/saved_objects/export/fetch_nested_dependencies.ts create mode 100644 test/functional/fixtures/es_archiver/saved_objects_management/nested_export_transform/data.json create mode 100644 test/functional/fixtures/es_archiver/saved_objects_management/nested_export_transform/mappings.json diff --git a/src/core/server/saved_objects/export/collect_exported_objects.test.mocks.ts b/src/core/server/saved_objects/export/collect_exported_objects.test.mocks.ts new file mode 100644 index 0000000000000..1f61788e55650 --- /dev/null +++ b/src/core/server/saved_objects/export/collect_exported_objects.test.mocks.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export const applyExportTransformsMock = jest.fn(); +jest.doMock('./apply_export_transforms', () => ({ + applyExportTransforms: applyExportTransformsMock, +})); diff --git a/src/core/server/saved_objects/export/collect_exported_objects.test.ts b/src/core/server/saved_objects/export/collect_exported_objects.test.ts new file mode 100644 index 0000000000000..0929ff0d40910 --- /dev/null +++ b/src/core/server/saved_objects/export/collect_exported_objects.test.ts @@ -0,0 +1,528 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { applyExportTransformsMock } from './collect_exported_objects.test.mocks'; +import { savedObjectsClientMock } from '../../mocks'; +import { httpServerMock } from '../../http/http_server.mocks'; +import { SavedObject, SavedObjectError } from '../../../types'; +import type { SavedObjectsExportTransform } from './types'; +import { collectExportedObjects } from './collect_exported_objects'; + +const createObject = (parts: Partial): SavedObject => ({ + id: 'id', + type: 'type', + references: [], + attributes: {}, + ...parts, +}); + +const createError = (parts: Partial = {}): SavedObjectError => ({ + error: 'error', + message: 'message', + statusCode: 404, + ...parts, +}); + +const toIdTuple = (obj: SavedObject) => ({ type: obj.type, id: obj.id }); + +describe('collectExportedObjects', () => { + let savedObjectsClient: ReturnType; + let request: ReturnType; + + beforeEach(() => { + savedObjectsClient = savedObjectsClientMock.create(); + request = httpServerMock.createKibanaRequest(); + applyExportTransformsMock.mockImplementation(({ objects }) => objects); + savedObjectsClient.bulkGet.mockResolvedValue({ saved_objects: [] }); + }); + + afterEach(() => { + applyExportTransformsMock.mockReset(); + savedObjectsClient.bulkGet.mockReset(); + }); + + describe('when `includeReferences` is `true`', () => { + it('calls `applyExportTransforms` with the correct parameters', async () => { + const obj1 = createObject({ + type: 'foo', + id: '1', + }); + const obj2 = createObject({ + type: 'foo', + id: '2', + }); + + const fooTransform: SavedObjectsExportTransform = jest.fn(); + + await collectExportedObjects({ + objects: [obj1, obj2], + savedObjectsClient, + request, + exportTransforms: { foo: fooTransform }, + includeReferences: true, + }); + + expect(applyExportTransformsMock).toHaveBeenCalledTimes(1); + expect(applyExportTransformsMock).toHaveBeenCalledWith({ + objects: [obj1, obj2], + transforms: { foo: fooTransform }, + request, + }); + }); + + it('returns the collected objects', async () => { + const foo1 = createObject({ + type: 'foo', + id: '1', + references: [ + { + type: 'bar', + id: '2', + name: 'bar-2', + }, + ], + }); + const bar2 = createObject({ + type: 'bar', + id: '2', + }); + const dolly3 = createObject({ + type: 'dolly', + id: '3', + }); + + applyExportTransformsMock.mockImplementationOnce(({ objects }) => [...objects, dolly3]); + savedObjectsClient.bulkGet.mockResolvedValueOnce({ + saved_objects: [bar2], + }); + + const { objects, missingRefs } = await collectExportedObjects({ + objects: [foo1], + savedObjectsClient, + request, + exportTransforms: {}, + includeReferences: true, + }); + + expect(missingRefs).toHaveLength(0); + expect(objects.map(toIdTuple)).toEqual([foo1, dolly3, bar2].map(toIdTuple)); + }); + + it('returns the missing references', async () => { + const foo1 = createObject({ + type: 'foo', + id: '1', + references: [ + { + type: 'bar', + id: '2', + name: 'bar-2', + }, + { + type: 'missing', + id: '1', + name: 'missing-1', + }, + ], + }); + const bar2 = createObject({ + type: 'bar', + id: '2', + references: [ + { + type: 'missing', + id: '2', + name: 'missing-2', + }, + ], + }); + const missing1 = createObject({ + type: 'missing', + id: '1', + error: createError(), + }); + const missing2 = createObject({ + type: 'missing', + id: '2', + error: createError(), + }); + + savedObjectsClient.bulkGet.mockResolvedValueOnce({ + saved_objects: [bar2, missing1], + }); + savedObjectsClient.bulkGet.mockResolvedValueOnce({ + saved_objects: [missing2], + }); + + const { objects, missingRefs } = await collectExportedObjects({ + objects: [foo1], + savedObjectsClient, + request, + exportTransforms: {}, + includeReferences: true, + }); + + expect(missingRefs).toEqual([missing1, missing2].map(toIdTuple)); + expect(objects.map(toIdTuple)).toEqual([foo1, bar2].map(toIdTuple)); + }); + + it('does not call `client.bulkGet` when no objects have references', async () => { + const obj1 = createObject({ + type: 'foo', + id: '1', + }); + const obj2 = createObject({ + type: 'foo', + id: '2', + }); + + const { objects, missingRefs } = await collectExportedObjects({ + objects: [obj1, obj2], + savedObjectsClient, + request, + exportTransforms: {}, + includeReferences: true, + }); + + expect(missingRefs).toHaveLength(0); + expect(objects.map(toIdTuple)).toEqual([ + { + type: 'foo', + id: '1', + }, + { + type: 'foo', + id: '2', + }, + ]); + + expect(savedObjectsClient.bulkGet).not.toHaveBeenCalled(); + }); + + it('calls `applyExportTransforms` for each iteration', async () => { + const foo1 = createObject({ + type: 'foo', + id: '1', + references: [ + { + type: 'bar', + id: '2', + name: 'bar-2', + }, + ], + }); + const bar2 = createObject({ + type: 'bar', + id: '2', + }); + savedObjectsClient.bulkGet.mockResolvedValueOnce({ + saved_objects: [bar2], + }); + + await collectExportedObjects({ + objects: [foo1], + savedObjectsClient, + request, + exportTransforms: {}, + includeReferences: true, + }); + + expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.bulkGet).toHaveBeenCalledWith( + [toIdTuple(bar2)], + expect.any(Object) + ); + + expect(applyExportTransformsMock).toHaveBeenCalledTimes(2); + expect(applyExportTransformsMock).toHaveBeenCalledWith({ + objects: [foo1], + transforms: {}, + request, + }); + expect(applyExportTransformsMock).toHaveBeenCalledWith({ + objects: [bar2], + transforms: {}, + request, + }); + }); + + it('ignores references that are already included in the export', async () => { + const foo1 = createObject({ + type: 'foo', + id: '1', + references: [ + { + type: 'bar', + id: '2', + name: 'bar-2', + }, + ], + }); + const bar2 = createObject({ + type: 'bar', + id: '2', + references: [ + { + type: 'foo', + id: '1', + name: 'foo-1', + }, + { + type: 'dolly', + id: '3', + name: 'dolly-3', + }, + ], + }); + const dolly3 = createObject({ + type: 'dolly', + id: '3', + references: [ + { + type: 'foo', + id: '1', + name: 'foo-1', + }, + ], + }); + + savedObjectsClient.bulkGet.mockResolvedValueOnce({ + saved_objects: [bar2], + }); + savedObjectsClient.bulkGet.mockResolvedValueOnce({ + saved_objects: [foo1, dolly3], + }); + + const { objects } = await collectExportedObjects({ + objects: [foo1], + savedObjectsClient, + request, + exportTransforms: {}, + includeReferences: true, + }); + + expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(2); + expect(savedObjectsClient.bulkGet).toHaveBeenNthCalledWith( + 1, + [toIdTuple(bar2)], + expect.any(Object) + ); + expect(savedObjectsClient.bulkGet).toHaveBeenNthCalledWith( + 2, + [toIdTuple(dolly3)], + expect.any(Object) + ); + + expect(objects.map(toIdTuple)).toEqual([foo1, bar2, dolly3].map(toIdTuple)); + }); + + it('does not fetch duplicates of references', async () => { + const foo1 = createObject({ + type: 'foo', + id: '1', + references: [ + { + type: 'dolly', + id: '3', + name: 'dolly-3', + }, + { + type: 'baz', + id: '4', + name: 'baz-4', + }, + ], + }); + const bar2 = createObject({ + type: 'bar', + id: '2', + references: [ + { + type: 'dolly', + id: '3', + name: 'dolly-3', + }, + ], + }); + const dolly3 = createObject({ + type: 'dolly', + id: '3', + }); + const baz4 = createObject({ + type: 'baz', + id: '4', + }); + + savedObjectsClient.bulkGet.mockResolvedValueOnce({ + saved_objects: [dolly3, baz4], + }); + + await collectExportedObjects({ + objects: [foo1, bar2], + savedObjectsClient, + request, + exportTransforms: {}, + includeReferences: true, + }); + + expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.bulkGet).toHaveBeenCalledWith( + [dolly3, baz4].map(toIdTuple), + expect.any(Object) + ); + }); + + it('fetch references for additional objects returned by the export transform', async () => { + const foo1 = createObject({ + type: 'foo', + id: '1', + references: [ + { + type: 'baz', + id: '4', + name: 'baz-4', + }, + ], + }); + const bar2 = createObject({ + type: 'bar', + id: '2', + references: [ + { + type: 'dolly', + id: '3', + name: 'dolly-3', + }, + ], + }); + + applyExportTransformsMock.mockImplementationOnce(({ objects }) => [...objects, bar2]); + + savedObjectsClient.bulkGet.mockResolvedValueOnce({ + saved_objects: [], + }); + + await collectExportedObjects({ + objects: [foo1], + savedObjectsClient, + request, + exportTransforms: {}, + includeReferences: true, + }); + + expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.bulkGet).toHaveBeenCalledWith( + [ + { type: 'baz', id: '4' }, + { type: 'dolly', id: '3' }, + ], + expect.any(Object) + ); + }); + + it('fetch references for additional objects returned by the export transform of nested references', async () => { + const foo1 = createObject({ + type: 'foo', + id: '1', + references: [ + { + type: 'bar', + id: '2', + name: 'bar-2', + }, + ], + }); + const bar2 = createObject({ + type: 'bar', + id: '2', + references: [], + }); + const dolly3 = createObject({ + type: 'dolly', + id: '3', + references: [ + { + type: 'baz', + id: '4', + name: 'baz-4', + }, + ], + }); + const baz4 = createObject({ + type: 'baz', + id: '4', + }); + + // first call for foo-1 + applyExportTransformsMock.mockImplementationOnce(({ objects }) => [...objects]); + // second call for bar-2 + applyExportTransformsMock.mockImplementationOnce(({ objects }) => [...objects, dolly3]); + + savedObjectsClient.bulkGet.mockResolvedValueOnce({ + saved_objects: [bar2], + }); + savedObjectsClient.bulkGet.mockResolvedValueOnce({ + saved_objects: [baz4], + }); + + await collectExportedObjects({ + objects: [foo1], + savedObjectsClient, + request, + exportTransforms: {}, + includeReferences: true, + }); + + expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(2); + expect(savedObjectsClient.bulkGet).toHaveBeenNthCalledWith( + 1, + [toIdTuple(bar2)], + expect.any(Object) + ); + expect(savedObjectsClient.bulkGet).toHaveBeenNthCalledWith( + 2, + [toIdTuple(baz4)], + expect.any(Object) + ); + }); + }); + + describe('when `includeReferences` is `false`', () => { + it('does not fetch the object references', async () => { + const obj1 = createObject({ + type: 'foo', + id: '1', + references: [ + { + id: '2', + type: 'bar', + name: 'bar-2', + }, + ], + }); + + const { objects, missingRefs } = await collectExportedObjects({ + objects: [obj1], + savedObjectsClient, + request, + exportTransforms: {}, + includeReferences: false, + }); + + expect(missingRefs).toHaveLength(0); + expect(objects.map(toIdTuple)).toEqual([ + { + type: 'foo', + id: '1', + }, + ]); + + expect(savedObjectsClient.bulkGet).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/core/server/saved_objects/export/collect_exported_objects.ts b/src/core/server/saved_objects/export/collect_exported_objects.ts new file mode 100644 index 0000000000000..d45782a83c284 --- /dev/null +++ b/src/core/server/saved_objects/export/collect_exported_objects.ts @@ -0,0 +1,128 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { SavedObject } from '../../../types'; +import type { KibanaRequest } from '../../http'; +import { SavedObjectsClientContract } from '../types'; +import type { SavedObjectsExportTransform } from './types'; +import { applyExportTransforms } from './apply_export_transforms'; + +interface CollectExportedObjectOptions { + savedObjectsClient: SavedObjectsClientContract; + objects: SavedObject[]; + /** flag to also include all related saved objects in the export stream. */ + includeReferences?: boolean; + /** optional namespace to override the namespace used by the savedObjectsClient. */ + namespace?: string; + /** The http request initiating the export. */ + request: KibanaRequest; + /** export transform per type */ + exportTransforms: Record; +} + +interface CollectExportedObjectResult { + objects: SavedObject[]; + missingRefs: CollectedReference[]; +} + +export const collectExportedObjects = async ({ + objects, + includeReferences = true, + namespace, + request, + exportTransforms, + savedObjectsClient, +}: CollectExportedObjectOptions): Promise => { + const collectedObjects: SavedObject[] = []; + const collectedMissingRefs: CollectedReference[] = []; + const alreadyProcessed: Set = new Set(); + + let currentObjects = objects; + do { + const transformed = ( + await applyExportTransforms({ + request, + objects: currentObjects, + transforms: exportTransforms, + }) + ).filter((object) => !alreadyProcessed.has(objKey(object))); + + transformed.forEach((obj) => alreadyProcessed.add(objKey(obj))); + collectedObjects.push(...transformed); + + if (includeReferences) { + const references = collectReferences(transformed, alreadyProcessed); + if (references.length) { + const { objects: fetchedObjects, missingRefs } = await fetchReferences({ + references, + namespace, + client: savedObjectsClient, + }); + collectedMissingRefs.push(...missingRefs); + currentObjects = fetchedObjects; + } else { + currentObjects = []; + } + } else { + currentObjects = []; + } + } while (includeReferences && currentObjects.length); + + return { + objects: collectedObjects, + missingRefs: collectedMissingRefs, + }; +}; + +const objKey = (obj: { type: string; id: string }) => `${obj.type}:${obj.id}`; + +type ObjectKey = string; + +interface CollectedReference { + id: string; + type: string; +} + +const collectReferences = ( + objects: SavedObject[], + alreadyProcessed: Set +): CollectedReference[] => { + const references: Map = new Map(); + objects.forEach((obj) => { + obj.references?.forEach((ref) => { + const refKey = objKey(ref); + if (!alreadyProcessed.has(refKey)) { + references.set(refKey, { type: ref.type, id: ref.id }); + } + }); + }); + return [...references.values()]; +}; + +interface FetchReferencesResult { + objects: SavedObject[]; + missingRefs: CollectedReference[]; +} + +const fetchReferences = async ({ + references, + client, + namespace, +}: { + references: CollectedReference[]; + client: SavedObjectsClientContract; + namespace?: string; +}): Promise => { + const { saved_objects: savedObjects } = await client.bulkGet(references, { namespace }); + return { + objects: savedObjects.filter((obj) => !obj.error), + missingRefs: savedObjects + .filter((obj) => obj.error) + .map((obj) => ({ type: obj.type, id: obj.id })), + }; +}; diff --git a/src/core/server/saved_objects/export/fetch_nested_dependencies.test.ts b/src/core/server/saved_objects/export/fetch_nested_dependencies.test.ts deleted file mode 100644 index a47c629f9066b..0000000000000 --- a/src/core/server/saved_objects/export/fetch_nested_dependencies.test.ts +++ /dev/null @@ -1,606 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { SavedObject } from '../types'; -import { savedObjectsClientMock } from '../../mocks'; -import { getObjectReferencesToFetch, fetchNestedDependencies } from './fetch_nested_dependencies'; -import { SavedObjectsErrorHelpers } from '..'; - -describe('getObjectReferencesToFetch()', () => { - test('works with no saved objects', () => { - const map = new Map(); - const result = getObjectReferencesToFetch(map); - expect(result).toEqual([]); - }); - - test('excludes already fetched objects', () => { - const map = new Map(); - map.set('index-pattern:1', { - id: '1', - type: 'index-pattern', - attributes: {}, - references: [], - }); - map.set('visualization:2', { - id: '2', - type: 'visualization', - attributes: {}, - references: [ - { - name: 'ref_0', - type: 'index-pattern', - id: '1', - }, - ], - }); - const result = getObjectReferencesToFetch(map); - expect(result).toEqual([]); - }); - - test('returns objects that are missing', () => { - const map = new Map(); - map.set('visualization:2', { - id: '2', - type: 'visualization', - attributes: {}, - references: [ - { - name: 'ref_0', - type: 'index-pattern', - id: '1', - }, - ], - }); - const result = getObjectReferencesToFetch(map); - expect(result).toMatchInlineSnapshot(` - Array [ - Object { - "id": "1", - "type": "index-pattern", - }, - ] - `); - }); - - test('does not fail on circular dependencies', () => { - const map = new Map(); - map.set('index-pattern:1', { - id: '1', - type: 'index-pattern', - attributes: {}, - references: [ - { - name: 'ref_0', - type: 'visualization', - id: '2', - }, - ], - }); - map.set('visualization:2', { - id: '2', - type: 'visualization', - attributes: {}, - references: [ - { - name: 'ref_0', - type: 'index-pattern', - id: '1', - }, - ], - }); - const result = getObjectReferencesToFetch(map); - expect(result).toEqual([]); - }); -}); - -describe('injectNestedDependencies', () => { - const savedObjectsClient = savedObjectsClientMock.create(); - - afterEach(() => { - jest.resetAllMocks(); - }); - - test(`doesn't fetch when no dependencies are missing`, async () => { - const savedObjects = [ - { - id: '1', - type: 'index-pattern', - attributes: {}, - references: [], - }, - ]; - const result = await fetchNestedDependencies(savedObjects, savedObjectsClient); - expect(result).toMatchInlineSnapshot(` - Object { - "missingRefs": Array [], - "objects": Array [ - Object { - "attributes": Object {}, - "id": "1", - "references": Array [], - "type": "index-pattern", - }, - ], - } - `); - }); - - test(`doesn't fetch references that are already fetched`, async () => { - const savedObjects = [ - { - id: '1', - type: 'index-pattern', - attributes: {}, - references: [], - }, - { - id: '2', - type: 'search', - attributes: {}, - references: [ - { - name: 'ref_0', - type: 'index-pattern', - id: '1', - }, - ], - }, - ]; - const result = await fetchNestedDependencies(savedObjects, savedObjectsClient); - expect(result).toMatchInlineSnapshot(` - Object { - "missingRefs": Array [], - "objects": Array [ - Object { - "attributes": Object {}, - "id": "1", - "references": Array [], - "type": "index-pattern", - }, - Object { - "attributes": Object {}, - "id": "2", - "references": Array [ - Object { - "id": "1", - "name": "ref_0", - "type": "index-pattern", - }, - ], - "type": "search", - }, - ], - } - `); - }); - - test('fetches dependencies at least one level deep', async () => { - const savedObjects = [ - { - id: '2', - type: 'search', - attributes: {}, - references: [ - { - name: 'ref_0', - type: 'index-pattern', - id: '1', - }, - ], - }, - ]; - savedObjectsClient.bulkGet.mockResolvedValueOnce({ - saved_objects: [ - { - id: '1', - type: 'index-pattern', - attributes: {}, - references: [], - }, - ], - }); - const result = await fetchNestedDependencies(savedObjects, savedObjectsClient); - expect(result).toMatchInlineSnapshot(` - Object { - "missingRefs": Array [], - "objects": Array [ - Object { - "attributes": Object {}, - "id": "2", - "references": Array [ - Object { - "id": "1", - "name": "ref_0", - "type": "index-pattern", - }, - ], - "type": "search", - }, - Object { - "attributes": Object {}, - "id": "1", - "references": Array [], - "type": "index-pattern", - }, - ], - } - `); - expect(savedObjectsClient.bulkGet).toMatchInlineSnapshot(` - [MockFunction] { - "calls": Array [ - Array [ - Array [ - Object { - "id": "1", - "type": "index-pattern", - }, - ], - Object { - "namespace": undefined, - }, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, - ], - } - `); - }); - - test('fetches dependencies multiple levels deep', async () => { - const savedObjects = [ - { - id: '5', - type: 'dashboard', - attributes: {}, - references: [ - { - name: 'panel_0', - type: 'visualization', - id: '4', - }, - { - name: 'panel_1', - type: 'visualization', - id: '3', - }, - ], - }, - ]; - savedObjectsClient.bulkGet.mockResolvedValueOnce({ - saved_objects: [ - { - id: '4', - type: 'visualization', - attributes: {}, - references: [ - { - name: 'ref_0', - type: 'search', - id: '2', - }, - ], - }, - { - id: '3', - type: 'visualization', - attributes: {}, - references: [ - { - name: 'ref_0', - type: 'index-pattern', - id: '1', - }, - ], - }, - ], - }); - savedObjectsClient.bulkGet.mockResolvedValueOnce({ - saved_objects: [ - { - id: '2', - type: 'search', - attributes: {}, - references: [ - { - name: 'ref_0', - type: 'index-pattern', - id: '1', - }, - ], - }, - { - id: '1', - type: 'index-pattern', - attributes: {}, - references: [], - }, - ], - }); - const result = await fetchNestedDependencies(savedObjects, savedObjectsClient); - expect(result).toMatchInlineSnapshot(` - Object { - "missingRefs": Array [], - "objects": Array [ - Object { - "attributes": Object {}, - "id": "5", - "references": Array [ - Object { - "id": "4", - "name": "panel_0", - "type": "visualization", - }, - Object { - "id": "3", - "name": "panel_1", - "type": "visualization", - }, - ], - "type": "dashboard", - }, - Object { - "attributes": Object {}, - "id": "4", - "references": Array [ - Object { - "id": "2", - "name": "ref_0", - "type": "search", - }, - ], - "type": "visualization", - }, - Object { - "attributes": Object {}, - "id": "3", - "references": Array [ - Object { - "id": "1", - "name": "ref_0", - "type": "index-pattern", - }, - ], - "type": "visualization", - }, - Object { - "attributes": Object {}, - "id": "2", - "references": Array [ - Object { - "id": "1", - "name": "ref_0", - "type": "index-pattern", - }, - ], - "type": "search", - }, - Object { - "attributes": Object {}, - "id": "1", - "references": Array [], - "type": "index-pattern", - }, - ], - } - `); - expect(savedObjectsClient.bulkGet).toMatchInlineSnapshot(` - [MockFunction] { - "calls": Array [ - Array [ - Array [ - Object { - "id": "4", - "type": "visualization", - }, - Object { - "id": "3", - "type": "visualization", - }, - ], - Object { - "namespace": undefined, - }, - ], - Array [ - Array [ - Object { - "id": "2", - "type": "search", - }, - Object { - "id": "1", - "type": "index-pattern", - }, - ], - Object { - "namespace": undefined, - }, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, - Object { - "type": "return", - "value": Promise {}, - }, - ], - } - `); - }); - - test('returns list of missing references', async () => { - const savedObjects = [ - { - id: '1', - type: 'search', - attributes: {}, - references: [ - { - name: 'ref_0', - type: 'index-pattern', - id: '1', - }, - { - name: 'ref_1', - type: 'index-pattern', - id: '2', - }, - ], - }, - ]; - savedObjectsClient.bulkGet.mockResolvedValueOnce({ - saved_objects: [ - { - id: '1', - type: 'index-pattern', - error: SavedObjectsErrorHelpers.createGenericNotFoundError('index-pattern', '1').output - .payload, - attributes: {}, - references: [], - }, - { - id: '2', - type: 'index-pattern', - attributes: {}, - references: [], - }, - ], - }); - const result = await fetchNestedDependencies(savedObjects, savedObjectsClient); - expect(result).toMatchInlineSnapshot(` - Object { - "missingRefs": Array [ - Object { - "id": "1", - "type": "index-pattern", - }, - ], - "objects": Array [ - Object { - "attributes": Object {}, - "id": "1", - "references": Array [ - Object { - "id": "1", - "name": "ref_0", - "type": "index-pattern", - }, - Object { - "id": "2", - "name": "ref_1", - "type": "index-pattern", - }, - ], - "type": "search", - }, - Object { - "attributes": Object {}, - "id": "2", - "references": Array [], - "type": "index-pattern", - }, - ], - } - `); - }); - - test('does not fail on circular dependencies', async () => { - const savedObjects = [ - { - id: '2', - type: 'search', - attributes: {}, - references: [ - { - name: 'ref_0', - type: 'index-pattern', - id: '1', - }, - ], - }, - ]; - savedObjectsClient.bulkGet.mockResolvedValueOnce({ - saved_objects: [ - { - id: '1', - type: 'index-pattern', - attributes: {}, - references: [ - { - name: 'ref_0', - type: 'search', - id: '2', - }, - ], - }, - ], - }); - const result = await fetchNestedDependencies(savedObjects, savedObjectsClient); - expect(result).toMatchInlineSnapshot(` - Object { - "missingRefs": Array [], - "objects": Array [ - Object { - "attributes": Object {}, - "id": "2", - "references": Array [ - Object { - "id": "1", - "name": "ref_0", - "type": "index-pattern", - }, - ], - "type": "search", - }, - Object { - "attributes": Object {}, - "id": "1", - "references": Array [ - Object { - "id": "2", - "name": "ref_0", - "type": "search", - }, - ], - "type": "index-pattern", - }, - ], - } - `); - expect(savedObjectsClient.bulkGet).toMatchInlineSnapshot(` - [MockFunction] { - "calls": Array [ - Array [ - Array [ - Object { - "id": "1", - "type": "index-pattern", - }, - ], - Object { - "namespace": undefined, - }, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, - ], - } - `); - }); -}); diff --git a/src/core/server/saved_objects/export/fetch_nested_dependencies.ts b/src/core/server/saved_objects/export/fetch_nested_dependencies.ts deleted file mode 100644 index 778c01804b893..0000000000000 --- a/src/core/server/saved_objects/export/fetch_nested_dependencies.ts +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { SavedObject, SavedObjectsClientContract } from '../types'; - -export function getObjectReferencesToFetch(savedObjectsMap: Map) { - const objectsToFetch = new Map(); - for (const savedObject of savedObjectsMap.values()) { - for (const ref of savedObject.references || []) { - if (!savedObjectsMap.has(objKey(ref))) { - objectsToFetch.set(objKey(ref), { type: ref.type, id: ref.id }); - } - } - } - return [...objectsToFetch.values()]; -} - -export async function fetchNestedDependencies( - savedObjects: SavedObject[], - savedObjectsClient: SavedObjectsClientContract, - namespace?: string -) { - const savedObjectsMap = new Map(); - for (const savedObject of savedObjects) { - savedObjectsMap.set(objKey(savedObject), savedObject); - } - let objectsToFetch = getObjectReferencesToFetch(savedObjectsMap); - while (objectsToFetch.length > 0) { - const bulkGetResponse = await savedObjectsClient.bulkGet(objectsToFetch, { namespace }); - // Push to array result - for (const savedObject of bulkGetResponse.saved_objects) { - savedObjectsMap.set(objKey(savedObject), savedObject); - } - objectsToFetch = getObjectReferencesToFetch(savedObjectsMap); - } - const allObjects = [...savedObjectsMap.values()]; - return { - objects: allObjects.filter((obj) => !obj.error), - missingRefs: allObjects - .filter((obj) => !!obj.error) - .map((obj) => ({ type: obj.type, id: obj.id })), - }; -} - -const objKey = (obj: { type: string; id: string }) => `${obj.type}:${obj.id}`; diff --git a/src/core/server/saved_objects/export/saved_objects_exporter.ts b/src/core/server/saved_objects/export/saved_objects_exporter.ts index 8cd6934bf1af9..9d56bb4872a6d 100644 --- a/src/core/server/saved_objects/export/saved_objects_exporter.ts +++ b/src/core/server/saved_objects/export/saved_objects_exporter.ts @@ -12,7 +12,6 @@ import { Logger } from '../../logging'; import { SavedObject, SavedObjectsClientContract } from '../types'; import { SavedObjectsFindResult } from '../service'; import { ISavedObjectTypeRegistry } from '../saved_objects_type_registry'; -import { fetchNestedDependencies } from './fetch_nested_dependencies'; import { sortObjects } from './sort_objects'; import { SavedObjectsExportResultDetails, @@ -22,7 +21,7 @@ import { SavedObjectsExportTransform, } from './types'; import { SavedObjectsExportError } from './errors'; -import { applyExportTransforms } from './apply_export_transforms'; +import { collectExportedObjects } from './collect_exported_objects'; import { byIdAscComparator, getPreservedOrderComparator, SavedObjectComparator } from './utils'; /** @@ -118,28 +117,21 @@ export class SavedObjectsExporter { }: SavedObjectExportBaseOptions ) { this.#log.debug(`Processing [${savedObjects.length}] saved objects.`); - let exportedObjects: Array>; - let missingReferences: SavedObjectsExportResultDetails['missingReferences'] = []; - savedObjects = await applyExportTransforms({ - request, + const { + objects: collectedObjects, + missingRefs: missingReferences, + } = await collectExportedObjects({ objects: savedObjects, - transforms: this.#exportTransforms, - sortFunction, + includeReferences: includeReferencesDeep, + namespace, + request, + exportTransforms: this.#exportTransforms, + savedObjectsClient: this.#savedObjectsClient, }); - if (includeReferencesDeep) { - this.#log.debug(`Fetching saved objects references.`); - const fetchResult = await fetchNestedDependencies( - savedObjects, - this.#savedObjectsClient, - namespace - ); - exportedObjects = sortObjects(fetchResult.objects); - missingReferences = fetchResult.missingRefs; - } else { - exportedObjects = sortObjects(savedObjects); - } + // sort with the provided sort function then with the default export sorting + const exportedObjects = sortObjects(collectedObjects.sort(sortFunction)); // redact attributes that should not be exported const redactedObjects = includeNamespaces diff --git a/test/functional/fixtures/es_archiver/saved_objects_management/nested_export_transform/data.json b/test/functional/fixtures/es_archiver/saved_objects_management/nested_export_transform/data.json new file mode 100644 index 0000000000000..caac89461b9ef --- /dev/null +++ b/test/functional/fixtures/es_archiver/saved_objects_management/nested_export_transform/data.json @@ -0,0 +1,87 @@ +{ + "type": "doc", + "value": { + "index": ".kibana", + "type": "doc", + "id": "test-export-transform:type_1-obj_1", + "source": { + "test-export-transform": { + "title": "test_1-obj_1", + "enabled": true + }, + "type": "test-export-transform", + "migrationVersion": {}, + "updated_at": "2018-12-21T00:43:07.096Z", + "references": [ + { + "type": "test-export-transform", + "id": "type_1-obj_2", + "name": "ref-1" + }, + { + "type": "test-export-add", + "id": "type_2-obj_1", + "name": "ref-2" + } + ] + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "type": "doc", + "id": "test-export-transform:type_1-obj_2", + "source": { + "test-export-transform": { + "title": "test_1-obj_2", + "enabled": true + }, + "type": "test-export-transform", + "migrationVersion": {}, + "updated_at": "2018-12-21T00:43:07.096Z" + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "type": "doc", + "id": "test-export-add:type_2-obj_1", + "source": { + "test-export-add": { + "title": "test_2-obj_1" + }, + "type": "test-export-add", + "migrationVersion": {}, + "updated_at": "2018-12-21T00:43:07.096Z" + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "type": "doc", + "id": "test-export-add-dep:type_dep-obj_1", + "source": { + "test-export-add-dep": { + "title": "type_dep-obj_1" + }, + "type": "test-export-add-dep", + "migrationVersion": {}, + "updated_at": "2018-12-21T00:43:07.096Z", + "references": [ + { + "type": "test-export-add", + "id": "type_2-obj_1" + } + ] + } + } +} diff --git a/test/functional/fixtures/es_archiver/saved_objects_management/nested_export_transform/mappings.json b/test/functional/fixtures/es_archiver/saved_objects_management/nested_export_transform/mappings.json new file mode 100644 index 0000000000000..43b851e817fa8 --- /dev/null +++ b/test/functional/fixtures/es_archiver/saved_objects_management/nested_export_transform/mappings.json @@ -0,0 +1,499 @@ +{ + "type": "index", + "value": { + "index": ".kibana", + "settings": { + "index": { + "number_of_shards": "1", + "auto_expand_replicas": "0-1", + "number_of_replicas": "0" + } + }, + "mappings": { + "dynamic": "strict", + "properties": { + "test-export-transform": { + "properties": { + "title": { "type": "text" }, + "enabled": { "type": "boolean" } + } + }, + "test-export-add": { + "properties": { + "title": { "type": "text" } + } + }, + "test-export-add-dep": { + "properties": { + "title": { "type": "text" } + } + }, + "test-export-transform-error": { + "properties": { + "title": { "type": "text" } + } + }, + "test-export-invalid-transform": { + "properties": { + "title": { "type": "text" } + } + }, + "apm-telemetry": { + "properties": { + "has_any_services": { + "type": "boolean" + }, + "services_per_agent": { + "properties": { + "go": { + "type": "long", + "null_value": 0 + }, + "java": { + "type": "long", + "null_value": 0 + }, + "js-base": { + "type": "long", + "null_value": 0 + }, + "nodejs": { + "type": "long", + "null_value": 0 + }, + "python": { + "type": "long", + "null_value": 0 + }, + "ruby": { + "type": "long", + "null_value": 0 + } + } + } + } + }, + "canvas-workpad": { + "dynamic": "false", + "properties": { + "@created": { + "type": "date" + }, + "@timestamp": { + "type": "date" + }, + "id": { + "type": "text", + "index": false + }, + "name": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword" + } + } + } + } + }, + "config": { + "dynamic": "true", + "properties": { + "accessibility:disableAnimations": { + "type": "boolean" + }, + "buildNum": { + "type": "keyword" + }, + "dateFormat:tz": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "defaultIndex": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "telemetry:optIn": { + "type": "boolean" + } + } + }, + "dashboard": { + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "optionsJSON": { + "type": "text" + }, + "panelsJSON": { + "type": "text" + }, + "refreshInterval": { + "properties": { + "display": { + "type": "keyword" + }, + "pause": { + "type": "boolean" + }, + "section": { + "type": "integer" + }, + "value": { + "type": "integer" + } + } + }, + "timeFrom": { + "type": "keyword" + }, + "timeRestore": { + "type": "boolean" + }, + "timeTo": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "map": { + "properties": { + "bounds": { + "dynamic": false, + "properties": {} + }, + "description": { + "type": "text" + }, + "layerListJSON": { + "type": "text" + }, + "mapStateJSON": { + "type": "text" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "graph-workspace": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "numLinks": { + "type": "integer" + }, + "numVertices": { + "type": "integer" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + }, + "wsState": { + "type": "text" + } + } + }, + "index-pattern": { + "properties": { + "fieldFormatMap": { + "type": "text" + }, + "fields": { + "type": "text" + }, + "intervalName": { + "type": "keyword" + }, + "notExpandable": { + "type": "boolean" + }, + "sourceFilters": { + "type": "text" + }, + "timeFieldName": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "type": { + "type": "keyword" + }, + "typeMeta": { + "type": "keyword" + } + } + }, + "kql-telemetry": { + "properties": { + "optInCount": { + "type": "long" + }, + "optOutCount": { + "type": "long" + } + } + }, + "migrationVersion": { + "dynamic": "true", + "properties": { + "index-pattern": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "space": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + } + } + }, + "namespace": { + "type": "keyword" + }, + "search": { + "properties": { + "columns": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "sort": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "server": { + "properties": { + "uuid": { + "type": "keyword" + } + } + }, + "space": { + "properties": { + "_reserved": { + "type": "boolean" + }, + "color": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "disabledFeatures": { + "type": "keyword" + }, + "initials": { + "type": "keyword" + }, + "name": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 2048 + } + } + } + } + }, + "spaceId": { + "type": "keyword" + }, + "telemetry": { + "properties": { + "enabled": { + "type": "boolean" + } + } + }, + "timelion-sheet": { + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "timelion_chart_height": { + "type": "integer" + }, + "timelion_columns": { + "type": "integer" + }, + "timelion_interval": { + "type": "keyword" + }, + "timelion_other_interval": { + "type": "keyword" + }, + "timelion_rows": { + "type": "integer" + }, + "timelion_sheet": { + "type": "text" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "type": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + }, + "url": { + "properties": { + "accessCount": { + "type": "long" + }, + "accessDate": { + "type": "date" + }, + "createDate": { + "type": "date" + }, + "url": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 2048 + } + } + } + } + }, + "visualization": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "savedSearchId": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + }, + "visState": { + "type": "text" + } + } + }, + "references": { + "properties": { + "id": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + }, + "type": "nested" + } + } + } + } +} diff --git a/test/plugin_functional/test_suites/saved_objects_management/export_transform.ts b/test/plugin_functional/test_suites/saved_objects_management/export_transform.ts index 87bf5e0584a7d..2b845cb6327b8 100644 --- a/test/plugin_functional/test_suites/saved_objects_management/export_transform.ts +++ b/test/plugin_functional/test_suites/saved_objects_management/export_transform.ts @@ -19,122 +19,169 @@ export default function ({ getService }: PluginFunctionalProviderContext) { const esArchiver = getService('esArchiver'); describe('export transforms', () => { - before(async () => { - await esArchiver.load( - '../functional/fixtures/es_archiver/saved_objects_management/export_transform' - ); - }); + describe('root objects export transforms', () => { + before(async () => { + await esArchiver.load( + '../functional/fixtures/es_archiver/saved_objects_management/export_transform' + ); + }); - after(async () => { - await esArchiver.unload( - '../functional/fixtures/es_archiver/saved_objects_management/export_transform' - ); - }); + after(async () => { + await esArchiver.unload( + '../functional/fixtures/es_archiver/saved_objects_management/export_transform' + ); + }); - it('allows to mutate the objects during an export', async () => { - await supertest - .post('/api/saved_objects/_export') - .set('kbn-xsrf', 'true') - .send({ - type: ['test-export-transform'], - excludeExportDetails: true, - }) - .expect(200) - .then((resp) => { - const objects = parseNdJson(resp.text); - expect(objects.map((obj) => ({ id: obj.id, enabled: obj.attributes.enabled }))).to.eql([ - { - id: 'type_1-obj_1', - enabled: false, - }, - { - id: 'type_1-obj_2', - enabled: false, - }, - ]); - }); - }); + it('allows to mutate the objects during an export', async () => { + await supertest + .post('/api/saved_objects/_export') + .set('kbn-xsrf', 'true') + .send({ + type: ['test-export-transform'], + excludeExportDetails: true, + }) + .expect(200) + .then((resp) => { + const objects = parseNdJson(resp.text); + expect(objects.map((obj) => ({ id: obj.id, enabled: obj.attributes.enabled }))).to.eql([ + { + id: 'type_1-obj_1', + enabled: false, + }, + { + id: 'type_1-obj_2', + enabled: false, + }, + ]); + }); + }); - it('allows to add additional objects to an export', async () => { - await supertest - .post('/api/saved_objects/_export') - .set('kbn-xsrf', 'true') - .send({ - objects: [ - { - type: 'test-export-add', - id: 'type_2-obj_1', - }, - ], - excludeExportDetails: true, - }) - .expect(200) - .then((resp) => { - const objects = parseNdJson(resp.text); - expect(objects.map((obj) => obj.id)).to.eql(['type_2-obj_1', 'type_dep-obj_1']); - }); - }); + it('allows to add additional objects to an export', async () => { + await supertest + .post('/api/saved_objects/_export') + .set('kbn-xsrf', 'true') + .send({ + objects: [ + { + type: 'test-export-add', + id: 'type_2-obj_1', + }, + ], + excludeExportDetails: true, + }) + .expect(200) + .then((resp) => { + const objects = parseNdJson(resp.text); + expect(objects.map((obj) => obj.id)).to.eql(['type_2-obj_1', 'type_dep-obj_1']); + }); + }); - it('allows to add additional objects to an export when exporting by type', async () => { - await supertest - .post('/api/saved_objects/_export') - .set('kbn-xsrf', 'true') - .send({ - type: ['test-export-add'], - excludeExportDetails: true, - }) - .expect(200) - .then((resp) => { - const objects = parseNdJson(resp.text); - expect(objects.map((obj) => obj.id)).to.eql([ - 'type_2-obj_1', - 'type_2-obj_2', - 'type_dep-obj_1', - 'type_dep-obj_2', - ]); - }); - }); + it('allows to add additional objects to an export when exporting by type', async () => { + await supertest + .post('/api/saved_objects/_export') + .set('kbn-xsrf', 'true') + .send({ + type: ['test-export-add'], + excludeExportDetails: true, + }) + .expect(200) + .then((resp) => { + const objects = parseNdJson(resp.text); + expect(objects.map((obj) => obj.id)).to.eql([ + 'type_2-obj_1', + 'type_2-obj_2', + 'type_dep-obj_1', + 'type_dep-obj_2', + ]); + }); + }); + + it('returns a 400 when the type causes a transform error', async () => { + await supertest + .post('/api/saved_objects/_export') + .set('kbn-xsrf', 'true') + .send({ + type: ['test-export-transform-error'], + excludeExportDetails: true, + }) + .expect(400) + .then((resp) => { + const { attributes, ...error } = resp.body; + expect(error).to.eql({ + error: 'Bad Request', + message: 'Error transforming objects to export', + statusCode: 400, + }); + expect(attributes.cause).to.eql('Error during transform'); + expect(attributes.objects.map((obj: any) => obj.id)).to.eql(['type_4-obj_1']); + }); + }); - it('returns a 400 when the type causes a transform error', async () => { - await supertest - .post('/api/saved_objects/_export') - .set('kbn-xsrf', 'true') - .send({ - type: ['test-export-transform-error'], - excludeExportDetails: true, - }) - .expect(400) - .then((resp) => { - const { attributes, ...error } = resp.body; - expect(error).to.eql({ - error: 'Bad Request', - message: 'Error transforming objects to export', - statusCode: 400, + it('returns a 400 when the type causes an invalid transform', async () => { + await supertest + .post('/api/saved_objects/_export') + .set('kbn-xsrf', 'true') + .send({ + type: ['test-export-invalid-transform'], + excludeExportDetails: true, + }) + .expect(400) + .then((resp) => { + expect(resp.body).to.eql({ + error: 'Bad Request', + message: 'Invalid transform performed on objects to export', + statusCode: 400, + attributes: { + objectKeys: ['test-export-invalid-transform|type_3-obj_1'], + }, + }); }); - expect(attributes.cause).to.eql('Error during transform'); - expect(attributes.objects.map((obj: any) => obj.id)).to.eql(['type_4-obj_1']); - }); + }); }); - it('returns a 400 when the type causes an invalid transform', async () => { - await supertest - .post('/api/saved_objects/_export') - .set('kbn-xsrf', 'true') - .send({ - type: ['test-export-invalid-transform'], - excludeExportDetails: true, - }) - .expect(400) - .then((resp) => { - expect(resp.body).to.eql({ - error: 'Bad Request', - message: 'Invalid transform performed on objects to export', - statusCode: 400, - attributes: { - objectKeys: ['test-export-invalid-transform|type_3-obj_1'], - }, + describe('FOO nested export transforms', () => { + before(async () => { + await esArchiver.load( + '../functional/fixtures/es_archiver/saved_objects_management/nested_export_transform' + ); + }); + + after(async () => { + await esArchiver.unload( + '../functional/fixtures/es_archiver/saved_objects_management/nested_export_transform' + ); + }); + + it('execute export transforms for reference objects', async () => { + await supertest + .post('/api/saved_objects/_export') + .set('kbn-xsrf', 'true') + .send({ + objects: [ + { + type: 'test-export-transform', + id: 'type_1-obj_1', + }, + ], + includeReferencesDeep: true, + excludeExportDetails: true, + }) + .expect(200) + .then((resp) => { + const objects = parseNdJson(resp.text).sort((obj1, obj2) => + obj1.id.localeCompare(obj2.id) + ); + expect(objects.map((obj) => obj.id)).to.eql([ + 'type_1-obj_1', + 'type_1-obj_2', + 'type_2-obj_1', + 'type_dep-obj_1', + ]); + + expect(objects[0].attributes.enabled).to.eql(false); + expect(objects[1].attributes.enabled).to.eql(false); }); - }); + }); }); }); } From 8f83090d74d0254a870ce3540999a7e40ecfea91 Mon Sep 17 00:00:00 2001 From: Justin Kambic Date: Fri, 4 Jun 2021 08:48:35 -0400 Subject: [PATCH 49/90] [Uptime] Show URL and metrics on sidebar and waterfall item tooltips (#99985) * Add URL to metrics tooltip. * Add screenreader label for URL container. * Add metrics to URL sidebar tooltip. * Rename vars. * Delete unnecessary code. * Undo rename. * Extract component to dedicated file, add tests. * Fix error in test. * Add offset index to heading of waterfall chart tooltip. * Format the waterfall tool tip header. * Add horizontal rule and bold text for waterfall tooltip. * Extract inline helper function to module-level for reuse. * Reuse waterfall tooltip style. * Style reusable tooltip content. * Adapt existing chart tooltip to use tooltip content component for better consistency. * Delete test code. * Style EUI tooltip arrow. * Revert whitespace change. * Delete obsolete test. * Implement and use common tooltip heading formatter function. * Add tests for new formatter function. * Fix a typo. * Add a comment explaining a style hack. * Add optional chaining to avoid breaking a test. * Revert previous change, use RTL wrapper, rename describe block. Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../waterfall/data_formatting.test.ts | 11 +++ .../step_detail/waterfall/data_formatting.ts | 3 + .../components/middle_truncated_text.tsx | 8 +- .../synthetics/waterfall/components/styles.ts | 3 + .../components/waterfall_bar_chart.tsx | 46 +++++----- .../waterfall_tooltip_content.test.tsx | 84 +++++++++++++++++++ .../components/waterfall_tooltip_content.tsx | 46 ++++++++++ 7 files changed, 180 insertions(+), 21 deletions(-) create mode 100644 x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_tooltip_content.test.tsx create mode 100644 x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_tooltip_content.tsx diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/data_formatting.test.ts b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/data_formatting.test.ts index ebb6eb6bdc989..229933c2f0642 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/data_formatting.test.ts +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/data_formatting.test.ts @@ -7,6 +7,7 @@ import moment from 'moment'; import { colourPalette, + formatTooltipHeading, getConnectingTime, getSeriesAndDomain, getSidebarItems, @@ -729,3 +730,13 @@ describe('getSidebarItems', () => { expect(actual[0].offsetIndex).toBe(1); }); }); + +describe('formatTooltipHeading', () => { + it('puts index and URL text together', () => { + expect(formatTooltipHeading(1, 'http://www.elastic.co/')).toEqual('1. http://www.elastic.co/'); + }); + + it('returns only the text if `index` is NaN', () => { + expect(formatTooltipHeading(NaN, 'http://www.elastic.co/')).toEqual('http://www.elastic.co/'); + }); +}); diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/data_formatting.ts b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/data_formatting.ts index f497bf1ea7b35..0f0ce01d25099 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/data_formatting.ts +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/data_formatting.ts @@ -450,3 +450,6 @@ const MIME_TYPE_PALETTE = buildMimeTypePalette(); type ColourPalette = TimingColourPalette & MimeTypeColourPalette; export const colourPalette: ColourPalette = { ...TIMING_PALETTE, ...MIME_TYPE_PALETTE }; + +export const formatTooltipHeading = (index: number, fullText: string): string => + isNaN(index) ? fullText : `${index}. ${fullText}`; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/middle_truncated_text.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/middle_truncated_text.tsx index a661d60400f97..956f4b19c6626 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/middle_truncated_text.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/middle_truncated_text.tsx @@ -8,17 +8,19 @@ import React, { useMemo } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { + EuiButtonEmpty, EuiScreenReaderOnly, EuiToolTip, - EuiButtonEmpty, EuiLink, EuiText, EuiIcon, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { WaterfallTooltipContent } from './waterfall_tooltip_content'; import { WaterfallTooltipResponsiveMaxWidth } from './styles'; import { FIXED_AXIS_HEIGHT } from './constants'; import { euiStyled } from '../../../../../../../../../src/plugins/kibana_react/common'; +import { formatTooltipHeading } from '../../step_detail/waterfall/data_formatting'; interface Props { index: number; @@ -116,7 +118,9 @@ export const MiddleTruncatedText = ({ + } data-test-subj="middleTruncatedTextToolTip" delay="long" position="top" diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/styles.ts b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/styles.ts index 8e3033037766a..f8de61f9d8690 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/styles.ts +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/styles.ts @@ -153,6 +153,9 @@ export const WaterfallChartTooltip = euiStyled(WaterfallTooltipResponsiveMaxWidt border-radius: ${(props) => props.theme.eui.euiBorderRadius}; color: ${(props) => props.theme.eui.euiColorLightestShade}; padding: ${(props) => props.theme.eui.paddingSizes.s}; + .euiToolTip__arrow { + background-color: ${(props) => props.theme.eui.euiColorDarkestShade}; + } `; export const NetworkRequestsTotalStyle = euiStyled(EuiText)` diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_bar_chart.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_bar_chart.tsx index 19a828aa097b6..8723dd744132a 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_bar_chart.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_bar_chart.tsx @@ -18,11 +18,12 @@ import { TickFormatter, TooltipInfo, } from '@elastic/charts'; -import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { BAR_HEIGHT } from './constants'; import { useChartTheme } from '../../../../../hooks/use_chart_theme'; import { WaterfallChartChartContainer, WaterfallChartTooltip } from './styles'; import { useWaterfallContext, WaterfallData } from '..'; +import { WaterfallTooltipContent } from './waterfall_tooltip_content'; +import { formatTooltipHeading } from '../../step_detail/waterfall/data_formatting'; const getChartHeight = (data: WaterfallData): number => { // We get the last item x(number of bars) and adds 1 to cater for 0 index @@ -32,23 +33,25 @@ const getChartHeight = (data: WaterfallData): number => { }; const Tooltip = (tooltipInfo: TooltipInfo) => { - const { data, renderTooltipItem } = useWaterfallContext(); - const relevantItems = data.filter((item) => { - return ( - item.x === tooltipInfo.header?.value && item.config.showTooltip && item.config.tooltipProps - ); - }); - return relevantItems.length ? ( - - - {relevantItems.map((item, index) => { - return ( - {renderTooltipItem(item.config.tooltipProps)} - ); - })} - - - ) : null; + const { data, sidebarItems } = useWaterfallContext(); + return useMemo(() => { + const sidebarItem = sidebarItems?.find((item) => item.index === tooltipInfo.header?.value); + const relevantItems = data.filter((item) => { + return ( + item.x === tooltipInfo.header?.value && item.config.showTooltip && item.config.tooltipProps + ); + }); + return relevantItems.length ? ( + + {sidebarItem && ( + + )} + + ) : null; + }, [data, sidebarItems, tooltipInfo.header?.value]); }; interface Props { @@ -82,7 +85,12 @@ export const WaterfallBarChart = ({ ({ + useWaterfallContext: jest.fn().mockReturnValue({ + data: [ + { + x: 0, + config: { + url: 'https://www.elastic.co', + tooltipProps: { + colour: '#000000', + value: 'test-val', + }, + showTooltip: true, + }, + }, + { + x: 0, + config: { + url: 'https://www.elastic.co/with/missing/tooltip.props', + showTooltip: true, + }, + }, + { + x: 1, + config: { + url: 'https://www.elastic.co/someresource.path', + tooltipProps: { + colour: '#010000', + value: 'test-val-missing', + }, + showTooltip: true, + }, + }, + ], + renderTooltipItem: (props: any) => ( +
+
{props.colour}
+
{props.value}
+
+ ), + sidebarItems: [ + { + isHighlighted: true, + index: 0, + offsetIndex: 1, + url: 'https://www.elastic.co', + status: 200, + method: 'GET', + }, + ], + }), +})); + +describe('WaterfallTooltipContent', () => { + it('renders tooltip', () => { + const { getByText, queryByText } = render( + + ); + expect(getByText('#000000')).toBeInTheDocument(); + expect(getByText('test-val')).toBeInTheDocument(); + expect(getByText('1. https://www.elastic.co')).toBeInTheDocument(); + expect(queryByText('#010000')).toBeNull(); + expect(queryByText('test-val-missing')).toBeNull(); + }); + + it(`doesn't render metric if tooltip props missing`, () => { + const { getAllByLabelText, getByText } = render( + + ); + const metricElements = getAllByLabelText('tooltip item'); + expect(metricElements).toHaveLength(1); + expect(getByText('test-val')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_tooltip_content.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_tooltip_content.tsx new file mode 100644 index 0000000000000..21b3bf72d2217 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_tooltip_content.tsx @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiHorizontalRule, EuiText } from '@elastic/eui'; +import { useWaterfallContext } from '../context/waterfall_chart'; +import { euiStyled } from '../../../../../../../../../src/plugins/kibana_react/common'; + +interface Props { + text: string; + url: string; +} + +const StyledText = euiStyled(EuiText)` + font-weight: bold; +`; + +const StyledHorizontalRule = euiStyled(EuiHorizontalRule)` + background-color: ${(props) => props.theme.eui.euiColorDarkShade}; +`; + +export const WaterfallTooltipContent: React.FC = ({ text, url }) => { + const { data, renderTooltipItem, sidebarItems } = useWaterfallContext(); + + const tooltipMetrics = data.filter( + (datum) => + datum.x === sidebarItems?.find((sidebarItem) => sidebarItem.url === url)?.index && + datum.config.tooltipProps && + datum.config.showTooltip + ); + return ( + <> + {text} + + + {tooltipMetrics.map((item, idx) => ( + {renderTooltipItem(item.config.tooltipProps)} + ))} + + + ); +}; From 93df9a32a49d13ebbdc53de7ec9e99e3c2c7c1da Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Fri, 4 Jun 2021 08:00:41 -0600 Subject: [PATCH 50/90] [Maps] embeddable migrations (#101070) * [Maps] embeddable migrations * review feedback Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../maps/server/embeddable_migrations.test.ts | 21 ++++ .../maps/server/embeddable_migrations.ts | 26 ++++ x-pack/plugins/maps/server/plugin.ts | 8 ++ .../plugins/maps/server/saved_objects/map.ts | 4 +- .../maps/server/saved_objects/migrations.js | 107 ----------------- .../saved_objects/saved_object_migrations.js | 112 ++++++++++++++++++ .../api_integration/apis/maps/migrations.js | 94 ++++++++++----- .../es_archives/maps/kibana/data.json | 28 +++++ 8 files changed, 259 insertions(+), 141 deletions(-) create mode 100644 x-pack/plugins/maps/server/embeddable_migrations.test.ts create mode 100644 x-pack/plugins/maps/server/embeddable_migrations.ts delete mode 100644 x-pack/plugins/maps/server/saved_objects/migrations.js create mode 100644 x-pack/plugins/maps/server/saved_objects/saved_object_migrations.js diff --git a/x-pack/plugins/maps/server/embeddable_migrations.test.ts b/x-pack/plugins/maps/server/embeddable_migrations.test.ts new file mode 100644 index 0000000000000..306f716d5171d --- /dev/null +++ b/x-pack/plugins/maps/server/embeddable_migrations.test.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import semverGte from 'semver/functions/gte'; +import { embeddableMigrations } from './embeddable_migrations'; +// @ts-ignore +import { savedObjectMigrations } from './saved_objects/saved_object_migrations'; + +describe('saved object migrations and embeddable migrations', () => { + test('should have same versions registered (>7.12)', () => { + const savedObjectMigrationVersions = Object.keys(savedObjectMigrations).filter((version) => { + return semverGte(version, '7.13.0'); + }); + const embeddableMigrationVersions = Object.keys(embeddableMigrations); + expect(savedObjectMigrationVersions.sort()).toEqual(embeddableMigrationVersions.sort()); + }); +}); diff --git a/x-pack/plugins/maps/server/embeddable_migrations.ts b/x-pack/plugins/maps/server/embeddable_migrations.ts new file mode 100644 index 0000000000000..4bf39dc1f999c --- /dev/null +++ b/x-pack/plugins/maps/server/embeddable_migrations.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SerializableState } from '../../../../src/plugins/kibana_utils/common'; +import { MapSavedObjectAttributes } from '../common/map_saved_object_type'; +import { moveAttribution } from '../common/migrations/move_attribution'; + +/* + * Embeddables such as Maps, Lens, and Visualize can be embedded by value or by reference on a dashboard. + * To ensure that any migrations (>7.12) are run correctly in both cases, + * the migration function must be registered as both a saved object migration and an embeddable migration + + * This is the embeddable migration registry. + */ +export const embeddableMigrations = { + '7.14.0': (state: SerializableState) => { + return { + ...state, + attributes: moveAttribution(state as { attributes: MapSavedObjectAttributes }), + } as SerializableState; + }, +}; diff --git a/x-pack/plugins/maps/server/plugin.ts b/x-pack/plugins/maps/server/plugin.ts index f0c8a051f8f79..c753297932037 100644 --- a/x-pack/plugins/maps/server/plugin.ts +++ b/x-pack/plugins/maps/server/plugin.ts @@ -37,6 +37,8 @@ import { HomeServerPluginSetup } from '../../../../src/plugins/home/server'; import { MapsEmsPluginSetup } from '../../../../src/plugins/maps_ems/server'; import { EMSSettings } from '../common/ems_settings'; import { PluginStart as DataPluginStart } from '../../../../src/plugins/data/server'; +import { EmbeddableSetup } from '../../../../src/plugins/embeddable/server'; +import { embeddableMigrations } from './embeddable_migrations'; interface SetupDeps { features: FeaturesPluginSetupContract; @@ -44,6 +46,7 @@ interface SetupDeps { home: HomeServerPluginSetup; licensing: LicensingPluginSetup; mapsEms: MapsEmsPluginSetup; + embeddable: EmbeddableSetup; } export interface StartDeps { @@ -214,6 +217,11 @@ export class MapsPlugin implements Plugin { core.savedObjects.registerType(mapSavedObjects); registerMapsUsageCollector(usageCollection, currentConfig); + plugins.embeddable.registerEmbeddableFactory({ + id: MAP_SAVED_OBJECT_TYPE, + migrations: embeddableMigrations, + }); + return { config: config$, }; diff --git a/x-pack/plugins/maps/server/saved_objects/map.ts b/x-pack/plugins/maps/server/saved_objects/map.ts index f4091db66d3da..78f70e27b2b7b 100644 --- a/x-pack/plugins/maps/server/saved_objects/map.ts +++ b/x-pack/plugins/maps/server/saved_objects/map.ts @@ -8,7 +8,7 @@ import { SavedObjectsType } from 'src/core/server'; import { APP_ICON, getExistingMapPath } from '../../common/constants'; // @ts-ignore -import { migrations } from './migrations'; +import { savedObjectMigrations } from './saved_object_migrations'; export const mapSavedObjects: SavedObjectsType = { name: 'map', @@ -39,5 +39,5 @@ export const mapSavedObjects: SavedObjectsType = { }; }, }, - migrations: migrations.map, + migrations: savedObjectMigrations, }; diff --git a/x-pack/plugins/maps/server/saved_objects/migrations.js b/x-pack/plugins/maps/server/saved_objects/migrations.js deleted file mode 100644 index d10e22722970a..0000000000000 --- a/x-pack/plugins/maps/server/saved_objects/migrations.js +++ /dev/null @@ -1,107 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { extractReferences } from '../../common/migrations/references'; -import { emsRasterTileToEmsVectorTile } from '../../common/migrations/ems_raster_tile_to_ems_vector_tile'; -import { topHitsTimeToSort } from '../../common/migrations/top_hits_time_to_sort'; -import { moveApplyGlobalQueryToSources } from '../../common/migrations/move_apply_global_query'; -import { addFieldMetaOptions } from '../../common/migrations/add_field_meta_options'; -import { migrateSymbolStyleDescriptor } from '../../common/migrations/migrate_symbol_style_descriptor'; -import { migrateUseTopHitsToScalingType } from '../../common/migrations/scaling_type'; -import { migrateJoinAggKey } from '../../common/migrations/join_agg_key'; -import { removeBoundsFromSavedObject } from '../../common/migrations/remove_bounds'; -import { setDefaultAutoFitToBounds } from '../../common/migrations/set_default_auto_fit_to_bounds'; -import { addTypeToTermJoin } from '../../common/migrations/add_type_to_termjoin'; -import { moveAttribution } from '../../common/migrations/move_attribution'; - -export const migrations = { - map: { - '7.2.0': (doc) => { - const { attributes, references } = extractReferences(doc); - - return { - ...doc, - attributes, - references, - }; - }, - '7.4.0': (doc) => { - const attributes = emsRasterTileToEmsVectorTile(doc); - - return { - ...doc, - attributes, - }; - }, - '7.5.0': (doc) => { - const attributes = topHitsTimeToSort(doc); - - return { - ...doc, - attributes, - }; - }, - '7.6.0': (doc) => { - const attributesPhase1 = moveApplyGlobalQueryToSources(doc); - const attributesPhase2 = addFieldMetaOptions({ attributes: attributesPhase1 }); - - return { - ...doc, - attributes: attributesPhase2, - }; - }, - '7.7.0': (doc) => { - const attributesPhase1 = migrateSymbolStyleDescriptor(doc); - const attributesPhase2 = migrateUseTopHitsToScalingType({ attributes: attributesPhase1 }); - - return { - ...doc, - attributes: attributesPhase2, - }; - }, - '7.8.0': (doc) => { - const attributes = migrateJoinAggKey(doc); - - return { - ...doc, - attributes, - }; - }, - '7.9.0': (doc) => { - const attributes = removeBoundsFromSavedObject(doc); - - return { - ...doc, - attributes, - }; - }, - '7.10.0': (doc) => { - const attributes = setDefaultAutoFitToBounds(doc); - - return { - ...doc, - attributes, - }; - }, - '7.12.0': (doc) => { - const attributes = addTypeToTermJoin(doc); - - return { - ...doc, - attributes, - }; - }, - '7.14.0': (doc) => { - const attributes = moveAttribution(doc); - - return { - ...doc, - attributes, - }; - }, - }, -}; diff --git a/x-pack/plugins/maps/server/saved_objects/saved_object_migrations.js b/x-pack/plugins/maps/server/saved_objects/saved_object_migrations.js new file mode 100644 index 0000000000000..8866ebb6b3de3 --- /dev/null +++ b/x-pack/plugins/maps/server/saved_objects/saved_object_migrations.js @@ -0,0 +1,112 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { extractReferences } from '../../common/migrations/references'; +import { emsRasterTileToEmsVectorTile } from '../../common/migrations/ems_raster_tile_to_ems_vector_tile'; +import { topHitsTimeToSort } from '../../common/migrations/top_hits_time_to_sort'; +import { moveApplyGlobalQueryToSources } from '../../common/migrations/move_apply_global_query'; +import { addFieldMetaOptions } from '../../common/migrations/add_field_meta_options'; +import { migrateSymbolStyleDescriptor } from '../../common/migrations/migrate_symbol_style_descriptor'; +import { migrateUseTopHitsToScalingType } from '../../common/migrations/scaling_type'; +import { migrateJoinAggKey } from '../../common/migrations/join_agg_key'; +import { removeBoundsFromSavedObject } from '../../common/migrations/remove_bounds'; +import { setDefaultAutoFitToBounds } from '../../common/migrations/set_default_auto_fit_to_bounds'; +import { addTypeToTermJoin } from '../../common/migrations/add_type_to_termjoin'; +import { moveAttribution } from '../../common/migrations/move_attribution'; + +/* + * Embeddables such as Maps, Lens, and Visualize can be embedded by value or by reference on a dashboard. + * To ensure that any migrations (>7.12) are run correctly in both cases, + * the migration function must be registered as both a saved object migration and an embeddable migration + + * This is the saved object migration registry. + */ +export const savedObjectMigrations = { + '7.2.0': (doc) => { + const { attributes, references } = extractReferences(doc); + + return { + ...doc, + attributes, + references, + }; + }, + '7.4.0': (doc) => { + const attributes = emsRasterTileToEmsVectorTile(doc); + + return { + ...doc, + attributes, + }; + }, + '7.5.0': (doc) => { + const attributes = topHitsTimeToSort(doc); + + return { + ...doc, + attributes, + }; + }, + '7.6.0': (doc) => { + const attributesPhase1 = moveApplyGlobalQueryToSources(doc); + const attributesPhase2 = addFieldMetaOptions({ attributes: attributesPhase1 }); + + return { + ...doc, + attributes: attributesPhase2, + }; + }, + '7.7.0': (doc) => { + const attributesPhase1 = migrateSymbolStyleDescriptor(doc); + const attributesPhase2 = migrateUseTopHitsToScalingType({ attributes: attributesPhase1 }); + + return { + ...doc, + attributes: attributesPhase2, + }; + }, + '7.8.0': (doc) => { + const attributes = migrateJoinAggKey(doc); + + return { + ...doc, + attributes, + }; + }, + '7.9.0': (doc) => { + const attributes = removeBoundsFromSavedObject(doc); + + return { + ...doc, + attributes, + }; + }, + '7.10.0': (doc) => { + const attributes = setDefaultAutoFitToBounds(doc); + + return { + ...doc, + attributes, + }; + }, + '7.12.0': (doc) => { + const attributes = addTypeToTermJoin(doc); + + return { + ...doc, + attributes, + }; + }, + '7.14.0': (doc) => { + const attributes = moveAttribution(doc); + + return { + ...doc, + attributes, + }; + }, +}; diff --git a/x-pack/test/api_integration/apis/maps/migrations.js b/x-pack/test/api_integration/apis/maps/migrations.js index 109579e867cb0..fe6e1c70356b0 100644 --- a/x-pack/test/api_integration/apis/maps/migrations.js +++ b/x-pack/test/api_integration/apis/maps/migrations.js @@ -9,41 +9,71 @@ import expect from '@kbn/expect'; export default function ({ getService }) { const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); describe('migrations', () => { - it('should apply saved object reference migration when importing map saved objects prior to 7.2.0', async () => { - const resp = await supertest - .post(`/api/saved_objects/map`) - .set('kbn-xsrf', 'kibana') - .send({ - attributes: { - title: '[Logs] Total Requests and Bytes', - layerListJSON: - '[{"id":"edh66","label":"Total Requests by Country","minZoom":0,"maxZoom":24,"alpha":0.5,"sourceDescriptor":{"type":"EMS_FILE","id":"world_countries"},"visible":true,"style":{"type":"VECTOR","properties":{"fillColor":{"type":"DYNAMIC","options":{"field":{"label":"count of kibana_sample_data_logs:geo.src","name":"__kbnjoin__count_groupby_kibana_sample_data_logs.geo.src","origin":"join"},"color":"Greys"}},"lineColor":{"type":"STATIC","options":{"color":"#FFFFFF"}},"lineWidth":{"type":"STATIC","options":{"size":1}},"iconSize":{"type":"STATIC","options":{"size":10}}}},"type":"VECTOR","joins":[{"leftField":"iso2","right":{"id":"673ff994-fc75-4c67-909b-69fcb0e1060e","indexPatternId":"90943e30-9a47-11e8-b64d-95841ca0b247","indexPatternTitle":"kibana_sample_data_logs","term":"geo.src"}}]},{"id":"gaxya","label":"Actual Requests","minZoom":9,"maxZoom":24,"alpha":1,"sourceDescriptor":{"id":"b7486535-171b-4d3b-bb2e-33c1a0a2854c","type":"ES_SEARCH","indexPatternId":"90943e30-9a47-11e8-b64d-95841ca0b247","geoField":"geo.coordinates","limit":2048,"filterByMapBounds":true,"tooltipProperties":["clientip","timestamp","host","request","response","machine.os","agent","bytes"]},"visible":true,"style":{"type":"VECTOR","properties":{"fillColor":{"type":"STATIC","options":{"color":"#2200ff"}},"lineColor":{"type":"STATIC","options":{"color":"#FFFFFF"}},"lineWidth":{"type":"STATIC","options":{"size":2}},"iconSize":{"type":"DYNAMIC","options":{"field":{"label":"bytes","name":"bytes","origin":"source"},"minSize":1,"maxSize":23}}}},"type":"VECTOR"},{"id":"tfi3f","label":"Total Requests and Bytes","minZoom":0,"maxZoom":9,"alpha":1,"sourceDescriptor":{"type":"ES_GEO_GRID","resolution":"COARSE","id":"8aaa65b5-a4e9-448b-9560-c98cb1c5ac5b","indexPatternId":"90943e30-9a47-11e8-b64d-95841ca0b247","geoField":"geo.coordinates","requestType":"point","metrics":[{"type":"count"},{"type":"sum","field":"bytes"}]},"visible":true,"style":{"type":"VECTOR","properties":{"fillColor":{"type":"DYNAMIC","options":{"field":{"label":"Count","name":"doc_count","origin":"source"},"color":"Blues"}},"lineColor":{"type":"STATIC","options":{"color":"#cccccc"}},"lineWidth":{"type":"STATIC","options":{"size":1}},"iconSize":{"type":"DYNAMIC","options":{"field":{"label":"sum of bytes","name":"sum_of_bytes","origin":"source"},"minSize":1,"maxSize":25}}}},"type":"VECTOR"}]', + describe('saved object migrations', () => { + it('should apply saved object reference migration when importing map saved objects prior to 7.2.0', async () => { + const resp = await supertest + .post(`/api/saved_objects/map`) + .set('kbn-xsrf', 'kibana') + .send({ + attributes: { + title: '[Logs] Total Requests and Bytes', + layerListJSON: + '[{"id":"edh66","label":"Total Requests by Country","minZoom":0,"maxZoom":24,"alpha":0.5,"sourceDescriptor":{"type":"EMS_FILE","id":"world_countries"},"visible":true,"style":{"type":"VECTOR","properties":{"fillColor":{"type":"DYNAMIC","options":{"field":{"label":"count of kibana_sample_data_logs:geo.src","name":"__kbnjoin__count_groupby_kibana_sample_data_logs.geo.src","origin":"join"},"color":"Greys"}},"lineColor":{"type":"STATIC","options":{"color":"#FFFFFF"}},"lineWidth":{"type":"STATIC","options":{"size":1}},"iconSize":{"type":"STATIC","options":{"size":10}}}},"type":"VECTOR","joins":[{"leftField":"iso2","right":{"id":"673ff994-fc75-4c67-909b-69fcb0e1060e","indexPatternId":"90943e30-9a47-11e8-b64d-95841ca0b247","indexPatternTitle":"kibana_sample_data_logs","term":"geo.src"}}]},{"id":"gaxya","label":"Actual Requests","minZoom":9,"maxZoom":24,"alpha":1,"sourceDescriptor":{"id":"b7486535-171b-4d3b-bb2e-33c1a0a2854c","type":"ES_SEARCH","indexPatternId":"90943e30-9a47-11e8-b64d-95841ca0b247","geoField":"geo.coordinates","limit":2048,"filterByMapBounds":true,"tooltipProperties":["clientip","timestamp","host","request","response","machine.os","agent","bytes"]},"visible":true,"style":{"type":"VECTOR","properties":{"fillColor":{"type":"STATIC","options":{"color":"#2200ff"}},"lineColor":{"type":"STATIC","options":{"color":"#FFFFFF"}},"lineWidth":{"type":"STATIC","options":{"size":2}},"iconSize":{"type":"DYNAMIC","options":{"field":{"label":"bytes","name":"bytes","origin":"source"},"minSize":1,"maxSize":23}}}},"type":"VECTOR"},{"id":"tfi3f","label":"Total Requests and Bytes","minZoom":0,"maxZoom":9,"alpha":1,"sourceDescriptor":{"type":"ES_GEO_GRID","resolution":"COARSE","id":"8aaa65b5-a4e9-448b-9560-c98cb1c5ac5b","indexPatternId":"90943e30-9a47-11e8-b64d-95841ca0b247","geoField":"geo.coordinates","requestType":"point","metrics":[{"type":"count"},{"type":"sum","field":"bytes"}]},"visible":true,"style":{"type":"VECTOR","properties":{"fillColor":{"type":"DYNAMIC","options":{"field":{"label":"Count","name":"doc_count","origin":"source"},"color":"Blues"}},"lineColor":{"type":"STATIC","options":{"color":"#cccccc"}},"lineWidth":{"type":"STATIC","options":{"size":1}},"iconSize":{"type":"DYNAMIC","options":{"field":{"label":"sum of bytes","name":"sum_of_bytes","origin":"source"},"minSize":1,"maxSize":25}}}},"type":"VECTOR"}]', + }, + migrationVersion: {}, + }) + .expect(200); + + expect(resp.body.references).to.eql([ + { + id: '90943e30-9a47-11e8-b64d-95841ca0b247', + name: 'layer_0_join_0_index_pattern', + type: 'index-pattern', + }, + { + id: '90943e30-9a47-11e8-b64d-95841ca0b247', + name: 'layer_1_source_index_pattern', + type: 'index-pattern', }, - migrationVersion: {}, - }) - .expect(200); - - expect(resp.body.references).to.eql([ - { - id: '90943e30-9a47-11e8-b64d-95841ca0b247', - name: 'layer_0_join_0_index_pattern', - type: 'index-pattern', - }, - { - id: '90943e30-9a47-11e8-b64d-95841ca0b247', - name: 'layer_1_source_index_pattern', - type: 'index-pattern', - }, - { - id: '90943e30-9a47-11e8-b64d-95841ca0b247', - name: 'layer_2_source_index_pattern', - type: 'index-pattern', - }, - ]); - expect(resp.body.migrationVersion).to.eql({ map: '7.14.0' }); - expect(resp.body.attributes.layerListJSON.includes('indexPatternRefName')).to.be(true); + { + id: '90943e30-9a47-11e8-b64d-95841ca0b247', + name: 'layer_2_source_index_pattern', + type: 'index-pattern', + }, + ]); + expect(resp.body.migrationVersion).to.eql({ map: '7.14.0' }); + expect(resp.body.attributes.layerListJSON.includes('indexPatternRefName')).to.be(true); + }); + }); + + describe('embeddable migrations', () => { + before(async () => { + await esArchiver.loadIfNeeded('maps/kibana'); + }); + + after(async () => { + await esArchiver.unload('maps/kibana'); + }); + + it('should apply embeddable migrations', async () => { + const resp = await supertest + .get(`/api/saved_objects/dashboard/4beb0d80-c2ef-11eb-b0cb-bd162d969e6b`) + .set('kbn-xsrf', 'kibana') + .expect(200); + + let panels; + try { + panels = JSON.parse(resp.body.attributes.panelsJSON); + } catch (error) { + throw 'Unable to parse panelsJSON from dashboard saved object'; + } + expect(panels.length).to.be(1); + expect(panels[0].type).to.be('map'); + expect(panels[0].version).to.be('7.14.0'); + }); }); }); } diff --git a/x-pack/test/functional/es_archives/maps/kibana/data.json b/x-pack/test/functional/es_archives/maps/kibana/data.json index 4a879c20f19ab..d0c4559d0a0a9 100644 --- a/x-pack/test/functional/es_archives/maps/kibana/data.json +++ b/x-pack/test/functional/es_archives/maps/kibana/data.json @@ -1199,6 +1199,34 @@ } } +{ + "type": "doc", + "value": { + "id": "dashboard:4beb0d80-c2ef-11eb-b0cb-bd162d969e6b", + "index": ".kibana", + "source": { + "dashboard": { + "title" : "by value map", + "hits" : 0, + "description" : "", + "panelsJSON" : "[{\"version\":\"7.12.1-SNAPSHOT\",\"type\":\"map\",\"gridData\":{\"x\":0,\"y\":0,\"w\":24,\"h\":15,\"i\":\"cb82a9e3-2eb0-487f-9ade-0ffb921eb536\"},\"panelIndex\":\"cb82a9e3-2eb0-487f-9ade-0ffb921eb536\",\"embeddableConfig\":{\"attributes\":{\"title\":\"\",\"description\":\"\",\"layerListJSON\":\"[]\",\"mapStateJSON\":\"{\\\"zoom\\\":1.75,\\\"center\\\":{\\\"lon\\\":0,\\\"lat\\\":19.94277},\\\"timeFilters\\\":{\\\"from\\\":\\\"now-15m\\\",\\\"to\\\":\\\"now\\\"},\\\"refreshConfig\\\":{\\\"isPaused\\\":true,\\\"interval\\\":0},\\\"query\\\":{\\\"query\\\":\\\"\\\",\\\"language\\\":\\\"kuery\\\"},\\\"filters\\\":[],\\\"settings\\\":{\\\"autoFitToDataBounds\\\":false,\\\"backgroundColor\\\":\\\"#ffffff\\\",\\\"disableInteractive\\\":false,\\\"disableTooltipControl\\\":false,\\\"hideToolbarOverlay\\\":false,\\\"hideLayerControl\\\":false,\\\"hideViewControl\\\":false,\\\"initialLocation\\\":\\\"LAST_SAVED_LOCATION\\\",\\\"fixedLocation\\\":{\\\"lat\\\":0,\\\"lon\\\":0,\\\"zoom\\\":2},\\\"browserLocation\\\":{\\\"zoom\\\":2},\\\"maxZoom\\\":24,\\\"minZoom\\\":0,\\\"showScaleControl\\\":false,\\\"showSpatialFilters\\\":true,\\\"spatialFiltersAlpa\\\":0.3,\\\"spatialFiltersFillColor\\\":\\\"#DA8B45\\\",\\\"spatialFiltersLineColor\\\":\\\"#DA8B45\\\"}}\",\"uiStateJSON\":\"{\\\"isLayerTOCOpen\\\":true,\\\"openTOCDetails\\\":[]}\"},\"mapCenter\":{\"lat\":19.94277,\"lon\":0,\"zoom\":1.75},\"mapBuffer\":{\"minLon\":-211.13072,\"minLat\":-55.27145,\"maxLon\":211.13072,\"maxLat\":87.44135},\"isLayerTOCOpen\":true,\"openTOCDetails\":[],\"hiddenLayers\":[],\"enhancements\":{}}}]", + "optionsJSON" : "{\"hidePanelTitles\":false,\"useMargins\":true}", + "version" : 1, + "timeRestore" : false, + "kibanaSavedObjectMeta" : { + "searchSourceJSON" : "{\"query\":{\"language\":\"kuery\",\"query\":\"\"},\"filter\":[]}" + } + }, + "type" : "dashboard", + "references" : [ ], + "migrationVersion" : { + "dashboard" : "7.11.0" + }, + "updated_at" : "2021-06-01T15:37:39.198Z" + } + } +} + { "type": "doc", "value": { From 9810a72720c63a72ef5c5cc43c7af9d09ff165db Mon Sep 17 00:00:00 2001 From: Dima Arnautov Date: Fri, 4 Jun 2021 16:04:53 +0200 Subject: [PATCH 51/90] [Transform] Support for the `top_metrics` aggregation (#101152) * [ML] init top_metrics agg * [ML] support sort * [ML] support _score sorting * [ML] support sort mode * [ML] support numeric type sorting * [ML] update field label, hide additional sorting controls * [ML] preserve advanced config * [ML] update agg fields after runtime fields edit * [ML] fix TS issue with EuiButtonGroup * [ML] fix Field label * [ML] refactor setUiConfig * [ML] update unit tests * [ML] wrap advanced sorting settings with accordion * [ML] config validation with tests * [ML] fix preserving of the unsupported config * [ML] update translation message * [ML] fix level of the custom config * [ML] preserve unsupported config for sorting --- .../transform/common/types/pivot_aggs.ts | 1 + .../public/app/common/pivot_aggs.test.ts | 11 +- .../transform/public/app/common/pivot_aggs.ts | 70 ++++++- .../advanced_runtime_mappings_settings.tsx | 22 +- .../aggregation_list/popover_form.tsx | 71 +++++-- .../step_define/common/common.test.ts | 3 + .../step_define/common/get_agg_form_config.ts | 3 + .../common/get_default_aggregation_config.ts | 3 + .../common/get_pivot_dropdown_options.ts | 1 + .../components/top_metrics_agg_form.tsx | 195 +++++++++++++++++ .../common/top_metrics_agg/config.test.ts | 196 ++++++++++++++++++ .../common/top_metrics_agg/config.ts | 118 +++++++++++ .../common/top_metrics_agg/types.ts | 24 +++ .../step_define/hooks/use_pivot_config.ts | 4 +- 14 files changed, 693 insertions(+), 29 deletions(-) create mode 100644 x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/top_metrics_agg/components/top_metrics_agg_form.tsx create mode 100644 x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/top_metrics_agg/config.test.ts create mode 100644 x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/top_metrics_agg/config.ts create mode 100644 x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/top_metrics_agg/types.ts diff --git a/x-pack/plugins/transform/common/types/pivot_aggs.ts b/x-pack/plugins/transform/common/types/pivot_aggs.ts index c50852e53254a..ced4d0a9bce0c 100644 --- a/x-pack/plugins/transform/common/types/pivot_aggs.ts +++ b/x-pack/plugins/transform/common/types/pivot_aggs.ts @@ -17,6 +17,7 @@ export const PIVOT_SUPPORTED_AGGS = { SUM: 'sum', VALUE_COUNT: 'value_count', FILTER: 'filter', + TOP_METRICS: 'top_metrics', } as const; export type PivotSupportedAggs = typeof PIVOT_SUPPORTED_AGGS[keyof typeof PIVOT_SUPPORTED_AGGS]; diff --git a/x-pack/plugins/transform/public/app/common/pivot_aggs.test.ts b/x-pack/plugins/transform/public/app/common/pivot_aggs.test.ts index dba9fa5dd83ba..f92bf1cdf59d9 100644 --- a/x-pack/plugins/transform/public/app/common/pivot_aggs.test.ts +++ b/x-pack/plugins/transform/public/app/common/pivot_aggs.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { getAggConfigFromEsAgg } from './pivot_aggs'; +import { getAggConfigFromEsAgg, isSpecialSortField } from './pivot_aggs'; import { FilterAggForm, FilterTermForm, @@ -67,3 +67,12 @@ describe('getAggConfigFromEsAgg', () => { }); }); }); + +describe('isSpecialSortField', () => { + test('detects special sort field', () => { + expect(isSpecialSortField('_score')).toBe(true); + }); + test('rejects special fields that not supported yet', () => { + expect(isSpecialSortField('_doc')).toBe(false); + }); +}); diff --git a/x-pack/plugins/transform/public/app/common/pivot_aggs.ts b/x-pack/plugins/transform/public/app/common/pivot_aggs.ts index 03e06d36f9319..97685096a5d22 100644 --- a/x-pack/plugins/transform/public/app/common/pivot_aggs.ts +++ b/x-pack/plugins/transform/public/app/common/pivot_aggs.ts @@ -7,7 +7,7 @@ import { FC } from 'react'; -import { KBN_FIELD_TYPES } from '../../../../../../src/plugins/data/common'; +import { ES_FIELD_TYPES, KBN_FIELD_TYPES } from '../../../../../../src/plugins/data/common'; import type { AggName } from '../../../common/types/aggregations'; import type { Dictionary } from '../../../common/types/common'; @@ -43,6 +43,7 @@ export const pivotAggsFieldSupport = { PIVOT_SUPPORTED_AGGS.CARDINALITY, PIVOT_SUPPORTED_AGGS.VALUE_COUNT, PIVOT_SUPPORTED_AGGS.FILTER, + PIVOT_SUPPORTED_AGGS.TOP_METRICS, ], [KBN_FIELD_TYPES.MURMUR3]: [PIVOT_SUPPORTED_AGGS.VALUE_COUNT, PIVOT_SUPPORTED_AGGS.FILTER], [KBN_FIELD_TYPES.NUMBER]: [ @@ -54,17 +55,78 @@ export const pivotAggsFieldSupport = { PIVOT_SUPPORTED_AGGS.SUM, PIVOT_SUPPORTED_AGGS.VALUE_COUNT, PIVOT_SUPPORTED_AGGS.FILTER, + PIVOT_SUPPORTED_AGGS.TOP_METRICS, ], [KBN_FIELD_TYPES.STRING]: [ PIVOT_SUPPORTED_AGGS.CARDINALITY, PIVOT_SUPPORTED_AGGS.VALUE_COUNT, PIVOT_SUPPORTED_AGGS.FILTER, + PIVOT_SUPPORTED_AGGS.TOP_METRICS, ], [KBN_FIELD_TYPES._SOURCE]: [PIVOT_SUPPORTED_AGGS.VALUE_COUNT, PIVOT_SUPPORTED_AGGS.FILTER], [KBN_FIELD_TYPES.UNKNOWN]: [PIVOT_SUPPORTED_AGGS.VALUE_COUNT, PIVOT_SUPPORTED_AGGS.FILTER], [KBN_FIELD_TYPES.CONFLICT]: [PIVOT_SUPPORTED_AGGS.VALUE_COUNT, PIVOT_SUPPORTED_AGGS.FILTER], }; +export const TOP_METRICS_SORT_FIELD_TYPES = [ + KBN_FIELD_TYPES.NUMBER, + KBN_FIELD_TYPES.DATE, + KBN_FIELD_TYPES.GEO_POINT, +]; + +export const SORT_DIRECTION = { + ASC: 'asc', + DESC: 'desc', +} as const; + +export type SortDirection = typeof SORT_DIRECTION[keyof typeof SORT_DIRECTION]; + +export const SORT_MODE = { + MIN: 'min', + MAX: 'max', + AVG: 'avg', + SUM: 'sum', + MEDIAN: 'median', +} as const; + +export const NUMERIC_TYPES_OPTIONS = { + [KBN_FIELD_TYPES.NUMBER]: [ES_FIELD_TYPES.DOUBLE, ES_FIELD_TYPES.LONG], + [KBN_FIELD_TYPES.DATE]: [ES_FIELD_TYPES.DATE, ES_FIELD_TYPES.DATE_NANOS], +}; + +export type KbnNumericType = typeof KBN_FIELD_TYPES.NUMBER | typeof KBN_FIELD_TYPES.DATE; + +const SORT_NUMERIC_FIELD_TYPES = [ + ES_FIELD_TYPES.DOUBLE, + ES_FIELD_TYPES.LONG, + ES_FIELD_TYPES.DATE, + ES_FIELD_TYPES.DATE_NANOS, +] as const; + +export type SortNumericFieldType = typeof SORT_NUMERIC_FIELD_TYPES[number]; + +export type SortMode = typeof SORT_MODE[keyof typeof SORT_MODE]; + +export const TOP_METRICS_SPECIAL_SORT_FIELDS = { + _SCORE: '_score', +} as const; + +export const isSpecialSortField = (sortField: unknown) => { + return Object.values(TOP_METRICS_SPECIAL_SORT_FIELDS).some((v) => v === sortField); +}; + +export const isValidSortDirection = (arg: unknown): arg is SortDirection => { + return Object.values(SORT_DIRECTION).some((v) => v === arg); +}; + +export const isValidSortMode = (arg: unknown): arg is SortMode => { + return Object.values(SORT_MODE).some((v) => v === arg); +}; + +export const isValidSortNumericType = (arg: unknown): arg is SortNumericFieldType => { + return SORT_NUMERIC_FIELD_TYPES.some((v) => v === arg); +}; + /** * The maximum level of sub-aggregations */ @@ -75,6 +137,10 @@ export interface PivotAggsConfigBase { agg: PivotSupportedAggs; aggName: AggName; dropDownName: string; + /** + * Indicates if aggregation supports multiple fields + */ + isMultiField?: boolean; /** Indicates if aggregation supports sub-aggregations */ isSubAggsSupported?: boolean; /** Dictionary of the sub-aggregations */ @@ -130,7 +196,7 @@ export function getAggConfigFromEsAgg( } export interface PivotAggsConfigWithUiBase extends PivotAggsConfigBase { - field: EsFieldName; + field: EsFieldName | EsFieldName[]; } export interface PivotAggsConfigWithExtra extends PivotAggsConfigWithUiBase { diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/advanced_runtime_mappings_settings/advanced_runtime_mappings_settings.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/advanced_runtime_mappings_settings/advanced_runtime_mappings_settings.tsx index 29e341fdaeaea..4e70b7d7fe9b7 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/advanced_runtime_mappings_settings/advanced_runtime_mappings_settings.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/advanced_runtime_mappings_settings/advanced_runtime_mappings_settings.tsx @@ -46,7 +46,7 @@ export const AdvancedRuntimeMappingsSettings: FC = (props) = }, } = props.runtimeMappingsEditor; const { - actions: { deleteAggregation, deleteGroupBy }, + actions: { deleteAggregation, deleteGroupBy, updateAggregation }, state: { groupByList, aggList }, } = props.pivotConfig; @@ -55,6 +55,9 @@ export const AdvancedRuntimeMappingsSettings: FC = (props) = advancedRuntimeMappingsConfig === '' ? {} : JSON.parse(advancedRuntimeMappingsConfig); const previousConfig = runtimeMappings; + const isFieldDeleted = (field: string) => + previousConfig?.hasOwnProperty(field) && !nextConfig.hasOwnProperty(field); + applyRuntimeMappingsEditorChanges(); // If the user updates the name of the runtime mapping fields @@ -71,13 +74,16 @@ export const AdvancedRuntimeMappingsSettings: FC = (props) = }); Object.keys(aggList).forEach((aggName) => { const agg = aggList[aggName] as PivotAggsConfigWithUiSupport; - if ( - isPivotAggConfigWithUiSupport(agg) && - agg.field !== undefined && - previousConfig?.hasOwnProperty(agg.field) && - !nextConfig.hasOwnProperty(agg.field) - ) { - deleteAggregation(aggName); + + if (isPivotAggConfigWithUiSupport(agg)) { + if (Array.isArray(agg.field)) { + const newFields = agg.field.filter((f) => !isFieldDeleted(f)); + updateAggregation(aggName, { ...agg, field: newFields }); + } else { + if (agg.field !== undefined && isFieldDeleted(agg.field)) { + deleteAggregation(aggName); + } + } } }); }; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/popover_form.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/popover_form.tsx index 553581f58d55e..fd11255374a51 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/popover_form.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/popover_form.tsx @@ -12,6 +12,7 @@ import { i18n } from '@kbn/i18n'; import { EuiButton, EuiCodeEditor, + EuiComboBox, EuiFieldText, EuiForm, EuiFormRow, @@ -79,7 +80,7 @@ export const PopoverForm: React.FC = ({ defaultData, otherAggNames, onCha const [aggName, setAggName] = useState(defaultData.aggName); const [agg, setAgg] = useState(defaultData.agg); - const [field, setField] = useState( + const [field, setField] = useState( isPivotAggsConfigWithUiSupport(defaultData) ? defaultData.field : '' ); @@ -148,13 +149,21 @@ export const PopoverForm: React.FC = ({ defaultData, otherAggNames, onCha if (!isUnsupportedAgg) { const optionsArr = dictionaryToArray(options); + optionsArr .filter((o) => o.agg === defaultData.agg) .forEach((o) => { availableFields.push({ text: o.field }); }); + optionsArr - .filter((o) => isPivotAggsConfigWithUiSupport(defaultData) && o.field === defaultData.field) + .filter( + (o) => + isPivotAggsConfigWithUiSupport(defaultData) && + (Array.isArray(defaultData.field) + ? defaultData.field.includes(o.field as string) + : o.field === defaultData.field) + ) .forEach((o) => { availableAggs.push({ text: o.agg }); }); @@ -217,20 +226,48 @@ export const PopoverForm: React.FC = ({ defaultData, otherAggNames, onCha data-test-subj="transformAggName" /> - {availableFields.length > 0 && ( - - setField(e.target.value)} - data-test-subj="transformAggField" - /> - - )} + {availableFields.length > 0 ? ( + aggConfigDef.isMultiField ? ( + + { + return { + value: v.text, + label: v.text as string, + }; + })} + selectedOptions={(typeof field === 'string' ? [field] : field).map((v) => ({ + value: v, + label: v, + }))} + onChange={(e) => { + const res = e.map((v) => v.value as string); + setField(res); + }} + isClearable={false} + data-test-subj="transformAggFields" + /> + + ) : ( + + setField(e.target.value)} + data-test-subj="transformAggField" + /> + + ) + ) : null} {availableAggs.length > 0 && ( = ({ defaultData, otherAggNames, onCha {isPivotAggsWithExtendedForm(aggConfigDef) && ( { setAggConfigDef({ ...aggConfigDef, diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/common.test.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/common.test.ts index fcdbac8c7ff39..5891e8b330b94 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/common.test.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/common.test.ts @@ -44,6 +44,7 @@ describe('Transform: Define Pivot Common', () => { { label: 'sum( the-f[i]e>ld )' }, { label: 'value_count( the-f[i]e>ld )' }, { label: 'filter( the-f[i]e>ld )' }, + { label: 'top_metrics( the-f[i]e>ld )' }, ], }, ], @@ -133,6 +134,7 @@ describe('Transform: Define Pivot Common', () => { { label: 'sum( the-f[i]e>ld )' }, { label: 'value_count( the-f[i]e>ld )' }, { label: 'filter( the-f[i]e>ld )' }, + { label: 'top_metrics( the-f[i]e>ld )' }, ], }, { @@ -146,6 +148,7 @@ describe('Transform: Define Pivot Common', () => { { label: 'sum(rt_bytes_bigger)' }, { label: 'value_count(rt_bytes_bigger)' }, { label: 'filter(rt_bytes_bigger)' }, + { label: 'top_metrics(rt_bytes_bigger)' }, ], }, ], diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_agg_form_config.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_agg_form_config.ts index ab69d22b1f3d7..5d8d7cb967b65 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_agg_form_config.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_agg_form_config.ts @@ -12,6 +12,7 @@ import { import { PivotAggsConfigBase, PivotAggsConfigWithUiBase } from '../../../../../common/pivot_aggs'; import { getFilterAggConfig } from './filter_agg/config'; +import { getTopMetricsAggConfig } from './top_metrics_agg/config'; /** * Gets form configuration for provided aggregation type. @@ -23,6 +24,8 @@ export function getAggFormConfig( switch (agg) { case PIVOT_SUPPORTED_AGGS.FILTER: return getFilterAggConfig(commonConfig); + case PIVOT_SUPPORTED_AGGS.TOP_METRICS: + return getTopMetricsAggConfig(commonConfig); default: return commonConfig; } diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_default_aggregation_config.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_default_aggregation_config.ts index 53350f0238bf0..39594dcbff9ae 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_default_aggregation_config.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_default_aggregation_config.ts @@ -15,6 +15,7 @@ import { PivotAggsConfigWithUiSupport, } from '../../../../../common'; import { getFilterAggConfig } from './filter_agg/config'; +import { getTopMetricsAggConfig } from './top_metrics_agg/config'; /** * Provides a configuration based on the aggregation type. @@ -41,6 +42,8 @@ export function getDefaultAggregationConfig( }; case PIVOT_SUPPORTED_AGGS.FILTER: return getFilterAggConfig(commonConfig); + case PIVOT_SUPPORTED_AGGS.TOP_METRICS: + return getTopMetricsAggConfig(commonConfig); default: return commonConfig; } diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_pivot_dropdown_options.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_pivot_dropdown_options.ts index 300626e0570ae..b17f30d115f4a 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_pivot_dropdown_options.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_pivot_dropdown_options.ts @@ -141,6 +141,7 @@ export function getPivotDropdownOptions( }); return { + fields: combinedFields, groupByOptions, groupByOptionsData, aggOptions, diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/top_metrics_agg/components/top_metrics_agg_form.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/top_metrics_agg/components/top_metrics_agg_form.tsx new file mode 100644 index 0000000000000..0ec66a3d59a11 --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/top_metrics_agg/components/top_metrics_agg_form.tsx @@ -0,0 +1,195 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useContext } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiFormRow, EuiSelect, EuiButtonGroup, EuiAccordion, EuiSpacer } from '@elastic/eui'; +import { PivotAggsConfigTopMetrics, TopMetricsAggConfig } from '../types'; +import { PivotConfigurationContext } from '../../../../pivot_configuration/pivot_configuration'; +import { + isSpecialSortField, + KbnNumericType, + NUMERIC_TYPES_OPTIONS, + SORT_DIRECTION, + SORT_MODE, + SortDirection, + SortMode, + SortNumericFieldType, + TOP_METRICS_SORT_FIELD_TYPES, + TOP_METRICS_SPECIAL_SORT_FIELDS, +} from '../../../../../../../common/pivot_aggs'; + +export const TopMetricsAggForm: PivotAggsConfigTopMetrics['AggFormComponent'] = ({ + onChange, + aggConfig, +}) => { + const { + state: { fields }, + } = useContext(PivotConfigurationContext)!; + + const sortFieldOptions = fields + .filter((v) => TOP_METRICS_SORT_FIELD_TYPES.includes(v.type)) + .map(({ name }) => ({ text: name, value: name })); + + Object.values(TOP_METRICS_SPECIAL_SORT_FIELDS).forEach((v) => { + sortFieldOptions.unshift({ text: v, value: v }); + }); + sortFieldOptions.unshift({ text: '', value: '' }); + + const isSpecialFieldSelected = isSpecialSortField(aggConfig.sortField); + + const sortDirectionOptions = Object.values(SORT_DIRECTION).map((v) => ({ + id: v, + label: v, + })); + + const sortModeOptions = Object.values(SORT_MODE).map((v) => ({ + id: v, + label: v, + })); + + const sortFieldType = fields.find((f) => f.name === aggConfig.sortField)?.type; + + const sortSettings = aggConfig.sortSettings ?? {}; + + const updateSortSettings = useCallback( + (update: Partial) => { + onChange({ + ...aggConfig, + sortSettings: { + ...(aggConfig.sortSettings ?? {}), + ...update, + }, + }); + }, + [aggConfig, onChange] + ); + + return ( + <> + + } + > + { + onChange({ ...aggConfig, sortField: e.target.value }); + }} + data-test-subj="transformSortFieldTopMetricsLabel" + /> + + + {aggConfig.sortField ? ( + <> + {isSpecialFieldSelected ? null : ( + <> + + } + > + { + updateSortSettings({ order: id as SortDirection }); + }} + color="text" + /> + + + + + + } + > + + } + helpText={ + + } + > + { + updateSortSettings({ mode: id as SortMode }); + }} + color="text" + /> + + + {sortFieldType && NUMERIC_TYPES_OPTIONS.hasOwnProperty(sortFieldType) ? ( + + } + > + ({ + text: v, + name: v, + }))} + value={sortSettings.numericType} + onChange={(e) => { + updateSortSettings({ + numericType: e.target.value as SortNumericFieldType, + }); + }} + data-test-subj="transformSortNumericTypeTopMetricsLabel" + /> + + ) : null} + + + )} + + ) : null} + + ); +}; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/top_metrics_agg/config.test.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/top_metrics_agg/config.test.ts new file mode 100644 index 0000000000000..ef57e6d1295c1 --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/top_metrics_agg/config.test.ts @@ -0,0 +1,196 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getTopMetricsAggConfig } from './config'; +import { PivotAggsConfigTopMetrics } from './types'; + +describe('top metrics agg config', () => { + let config: PivotAggsConfigTopMetrics; + + beforeEach(() => { + config = getTopMetricsAggConfig({ + agg: 'top_metrics', + aggName: 'test-agg', + field: ['test-field'], + dropDownName: 'test-agg', + }); + }); + + describe('#setUiConfigFromEs', () => { + test('sets config with a special field', () => { + // act + config.setUiConfigFromEs({ + metrics: { + field: 'test-field-01', + }, + sort: '_score', + }); + + // assert + expect(config.field).toEqual(['test-field-01']); + expect(config.aggConfig).toEqual({ + sortField: '_score', + }); + }); + + test('sets config with a simple sort direction definition', () => { + // act + config.setUiConfigFromEs({ + metrics: [ + { + field: 'test-field-01', + }, + { + field: 'test-field-02', + }, + ], + sort: { + 'sort-field': 'asc', + }, + }); + + // assert + expect(config.field).toEqual(['test-field-01', 'test-field-02']); + expect(config.aggConfig).toEqual({ + sortField: 'sort-field', + sortSettings: { + order: 'asc', + }, + }); + }); + + test('sets config with a sort definition params not supported by the UI', () => { + // act + config.setUiConfigFromEs({ + metrics: [ + { + field: 'test-field-01', + }, + ], + sort: { + 'offer.price': { + order: 'desc', + mode: 'avg', + nested: { + path: 'offer', + filter: { + term: { 'offer.color': 'blue' }, + }, + }, + }, + }, + }); + + // assert + expect(config.field).toEqual(['test-field-01']); + expect(config.aggConfig).toEqual({ + sortField: 'offer.price', + sortSettings: { + order: 'desc', + mode: 'avg', + nested: { + path: 'offer', + filter: { + term: { 'offer.color': 'blue' }, + }, + }, + }, + }); + }); + }); + + describe('#getEsAggConfig', () => { + test('rejects invalid config', () => { + // arrange + config.field = ['field-01', 'field-02']; + config.aggConfig = { + sortSettings: { + order: 'asc', + }, + }; + + // act and assert + expect(config.getEsAggConfig()).toEqual(null); + }); + + test('rejects invalid config with missing sort direction', () => { + // arrange + config.field = ['field-01', 'field-02']; + config.aggConfig = { + sortField: 'sort-field', + }; + + // act and assert + expect(config.getEsAggConfig()).toEqual(null); + }); + + test('converts valid config', () => { + // arrange + config.field = ['field-01', 'field-02']; + config.aggConfig = { + sortField: 'sort-field', + sortSettings: { + order: 'asc', + }, + }; + + // act and assert + expect(config.getEsAggConfig()).toEqual({ + metrics: [{ field: 'field-01' }, { field: 'field-02' }], + sort: { + 'sort-field': 'asc', + }, + }); + }); + + test('preserves unsupported config', () => { + // arrange + config.field = ['field-01', 'field-02']; + + config.aggConfig = { + sortField: 'sort-field', + sortSettings: { + order: 'asc', + // @ts-ignore + nested: { + path: 'order', + }, + }, + // @ts-ignore + size: 2, + }; + + // act and assert + expect(config.getEsAggConfig()).toEqual({ + metrics: [{ field: 'field-01' }, { field: 'field-02' }], + sort: { + 'sort-field': { + order: 'asc', + nested: { + path: 'order', + }, + }, + }, + size: 2, + }); + }); + + test('converts configs with a special sorting field', () => { + // arrange + config.field = ['field-01', 'field-02']; + config.aggConfig = { + sortField: '_score', + }; + + // act and assert + expect(config.getEsAggConfig()).toEqual({ + metrics: [{ field: 'field-01' }, { field: 'field-02' }], + sort: '_score', + }); + }); + }); +}); diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/top_metrics_agg/config.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/top_metrics_agg/config.ts new file mode 100644 index 0000000000000..56d17e7973e16 --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/top_metrics_agg/config.ts @@ -0,0 +1,118 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + isPivotAggsConfigWithUiSupport, + isSpecialSortField, + isValidSortDirection, + isValidSortMode, + isValidSortNumericType, + PivotAggsConfigBase, + PivotAggsConfigWithUiBase, +} from '../../../../../../common/pivot_aggs'; +import { PivotAggsConfigTopMetrics } from './types'; +import { TopMetricsAggForm } from './components/top_metrics_agg_form'; +import { isPopulatedObject } from '../../../../../../../../common/shared_imports'; + +/** + * Gets initial basic configuration of the top_metrics aggregation. + */ +export function getTopMetricsAggConfig( + commonConfig: PivotAggsConfigWithUiBase | PivotAggsConfigBase +): PivotAggsConfigTopMetrics { + return { + ...commonConfig, + isSubAggsSupported: false, + isMultiField: true, + field: isPivotAggsConfigWithUiSupport(commonConfig) ? commonConfig.field : '', + AggFormComponent: TopMetricsAggForm, + aggConfig: {}, + getEsAggConfig() { + // ensure the configuration has been completed + if (!this.isValid()) { + return null; + } + + const { sortField, sortSettings = {}, ...unsupportedConfig } = this.aggConfig; + + let sort = null; + + if (isSpecialSortField(sortField)) { + sort = sortField; + } else { + const { mode, numericType, order, ...rest } = sortSettings; + + if (mode || numericType || isPopulatedObject(rest)) { + sort = { + [sortField!]: { + ...rest, + order, + ...(mode ? { mode } : {}), + ...(numericType ? { numeric_type: numericType } : {}), + }, + }; + } else { + sort = { [sortField!]: sortSettings.order }; + } + } + + return { + metrics: (Array.isArray(this.field) ? this.field : [this.field]).map((f) => ({ field: f })), + sort, + ...(unsupportedConfig ?? {}), + }; + }, + setUiConfigFromEs(esAggDefinition) { + const { metrics, sort, ...unsupportedConfig } = esAggDefinition; + + this.field = (Array.isArray(metrics) ? metrics : [metrics]).map((v) => v.field); + + if (isSpecialSortField(sort)) { + this.aggConfig.sortField = sort; + return; + } + + const sortField = Object.keys(sort)[0]; + + this.aggConfig.sortField = sortField; + + const sortDefinition = sort[sortField]; + + this.aggConfig.sortSettings = this.aggConfig.sortSettings ?? {}; + + if (isValidSortDirection(sortDefinition)) { + this.aggConfig.sortSettings.order = sortDefinition; + } + + if (isPopulatedObject(sortDefinition)) { + const { order, mode, numeric_type: numType, ...rest } = sortDefinition; + this.aggConfig.sortSettings = rest; + + if (isValidSortDirection(order)) { + this.aggConfig.sortSettings.order = order; + } + if (isValidSortMode(mode)) { + this.aggConfig.sortSettings.mode = mode; + } + if (isValidSortNumericType(numType)) { + this.aggConfig.sortSettings.numericType = numType; + } + } + + this.aggConfig = { + ...this.aggConfig, + ...(unsupportedConfig ?? {}), + }; + }, + isValid() { + return ( + !!this.aggConfig.sortField && + (isSpecialSortField(this.aggConfig.sortField) ? true : !!this.aggConfig.sortSettings?.order) + ); + }, + }; +} diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/top_metrics_agg/types.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/top_metrics_agg/types.ts new file mode 100644 index 0000000000000..a90ee5307a18e --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/top_metrics_agg/types.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { + PivotAggsConfigWithExtra, + SortDirection, + SortMode, + SortNumericFieldType, +} from '../../../../../../common/pivot_aggs'; + +export interface TopMetricsAggConfig { + sortField: string; + sortSettings?: { + order?: SortDirection; + mode?: SortMode; + numericType?: SortNumericFieldType; + }; +} + +export type PivotAggsConfigTopMetrics = PivotAggsConfigWithExtra; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_pivot_config.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_pivot_config.ts index 4bd8f5cea6092..0c31b4fe2da81 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_pivot_config.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_pivot_config.ts @@ -97,7 +97,7 @@ export const usePivotConfig = ( ) => { const toastNotifications = useToastNotifications(); - const { aggOptions, aggOptionsData, groupByOptions, groupByOptionsData } = useMemo( + const { aggOptions, aggOptionsData, groupByOptions, groupByOptionsData, fields } = useMemo( () => getPivotDropdownOptions(indexPattern, defaults.runtimeMappings), [defaults.runtimeMappings, indexPattern] ); @@ -347,6 +347,7 @@ export const usePivotConfig = ( pivotGroupByArr, validationStatus, requestPayload, + fields, }, }; }, [ @@ -361,6 +362,7 @@ export const usePivotConfig = ( pivotGroupByArr, validationStatus, requestPayload, + fields, ]); }; From 690e81aa6098093bde58d16283703c5d16bf6642 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Fri, 4 Jun 2021 15:52:25 +0100 Subject: [PATCH 52/90] chore(NA): include missing dependency on @kbn/legacy-logging (#101331) --- packages/kbn-legacy-logging/BUILD.bazel | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/kbn-legacy-logging/BUILD.bazel b/packages/kbn-legacy-logging/BUILD.bazel index 21cb8c338f89f..1fd04604dbd24 100644 --- a/packages/kbn-legacy-logging/BUILD.bazel +++ b/packages/kbn-legacy-logging/BUILD.bazel @@ -25,6 +25,7 @@ NPM_MODULE_EXTRA_FILES = [ SRC_DEPS = [ "//packages/kbn-config-schema", + "//packages/kbn-utils", "@npm//@elastic/numeral", "@npm//@hapi/hapi", "@npm//chokidar", From 36996634c3eb4f48eb3dcc904f7ffffae1c4f499 Mon Sep 17 00:00:00 2001 From: Paul Tavares <56442535+paul-tavares@users.noreply.github.com> Date: Fri, 4 Jun 2021 10:59:53 -0400 Subject: [PATCH 53/90] [Security Solution][Endpoint] Add ability to isolate the Host from the Endpoint Details flyout (#100482) * Add un-isolate form to the endpoint flyout * Add Endpoint details flyout footer and action button * Refactor hooks into a directory * Refactor endpoint list actions into reusable list + add it to Take action on details * Refactor Endpoint list row actions to use new common hook for items * generate different values for isolation in endpoint generator * move `isEndpointHostIsolated()` utility to a common folder * refactor detections to also use common `isEndpointHostIsolated()` * httpHandlerMockFactory can now handle API paths with params (`{id}`) * Initial set of re-usable http mocks for endpoint hosts set of pages * fix bug in `composeHttpHandlerMocks()` * small improvements to test utilities * Show API errors for isolate in Form standard place --- .../common/endpoint/types/index.ts | 9 + .../mock/endpoint/app_context_render.tsx | 3 + .../endpoint/http_handler_mock_factory.ts | 43 ++++- .../public/common/store/test_utils.ts | 7 +- .../public/common/utils/validators/index.ts | 2 + .../is_endpoint_host_isolated.test.ts | 36 ++++ .../validators/is_endpoint_host_isolated.ts | 17 ++ .../alerts/use_host_isolation_status.tsx | 3 +- .../public/management/common/routing.ts | 5 +- .../management/pages/endpoint_hosts/mocks.ts | 128 +++++++++++++++ .../pages/endpoint_hosts/store/action.ts | 6 +- .../endpoint_hosts/store/middleware.test.ts | 27 ++- .../pages/endpoint_hosts/store/middleware.ts | 10 +- .../pages/endpoint_hosts/store/selectors.ts | 22 ++- .../management/pages/endpoint_hosts/types.ts | 2 +- .../context_menu_item_nav_by_rotuer.tsx | 36 ++++ .../view/components/table_row_actions.tsx | 48 ++---- .../details/components/actions_menu.test.tsx | 113 +++++++++++++ .../view/details/components/actions_menu.tsx | 63 +++++++ .../endpoint_isolate_flyout_panel.tsx | 55 ++++--- .../endpoint_hosts/view/details/index.tsx | 17 +- .../endpoint_hosts/view/{ => hooks}/hooks.ts | 10 +- .../pages/endpoint_hosts/view/hooks/index.ts | 9 + .../view/hooks/use_endpoint_action_items.tsx | 155 ++++++++++++++++++ .../pages/endpoint_hosts/view/index.test.tsx | 48 ++++-- .../pages/endpoint_hosts/view/index.tsx | 102 +----------- .../translations/translations/ja-JP.json | 3 - .../translations/translations/zh-CN.json | 3 - .../metadata/destination_index/data.json | 24 ++- 29 files changed, 799 insertions(+), 207 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/common/utils/validators/is_endpoint_host_isolated.test.ts create mode 100644 x-pack/plugins/security_solution/public/common/utils/validators/is_endpoint_host_isolated.ts create mode 100644 x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/mocks.ts create mode 100644 x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/components/context_menu_item_nav_by_rotuer.tsx create mode 100644 x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/actions_menu.test.tsx create mode 100644 x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/actions_menu.tsx rename x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/{ => hooks}/hooks.ts (89%) create mode 100644 x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks/index.ts create mode 100644 x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks/use_endpoint_action_items.tsx diff --git a/x-pack/plugins/security_solution/common/endpoint/types/index.ts b/x-pack/plugins/security_solution/common/endpoint/types/index.ts index c084dd8ca7668..4367c0d90af79 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/index.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/index.ts @@ -468,14 +468,23 @@ export type HostMetadata = Immutable<{ id: string; status: HostPolicyResponseActionStatus; name: string; + /** The endpoint integration policy revision number in kibana */ endpoint_policy_version: number; version: number; }; }; configuration: { + /** + * Shows whether the endpoint is set up to be isolated. (e.g. a user has isolated a host, + * and the endpoint successfully received that action and applied the setting) + */ isolation?: boolean; }; state: { + /** + * Shows what the current state of the host is. This could differ from `Endpoint.configuration.isolation` + * in some cases, but normally they will match + */ isolation?: boolean; }; }; diff --git a/x-pack/plugins/security_solution/public/common/mock/endpoint/app_context_render.tsx b/x-pack/plugins/security_solution/public/common/mock/endpoint/app_context_render.tsx index ae2cc59de6abf..d96929ec183d8 100644 --- a/x-pack/plugins/security_solution/public/common/mock/endpoint/app_context_render.tsx +++ b/x-pack/plugins/security_solution/public/common/mock/endpoint/app_context_render.tsx @@ -23,6 +23,7 @@ import { ExperimentalFeatures } from '../../../../common/experimental_features'; import { PLUGIN_ID } from '../../../../../fleet/common'; import { APP_ID } from '../../../../common/constants'; import { KibanaContextProvider } from '../../lib/kibana'; +import { MANAGEMENT_APP_ID } from '../../../management/common/constants'; type UiRender = (ui: React.ReactElement, options?: RenderOptions) => RenderResult; @@ -156,6 +157,8 @@ const createCoreStartMock = (): ReturnType => { return '/app/fleet'; case APP_ID: return '/app/security'; + case MANAGEMENT_APP_ID: + return '/app/security/administration'; default: return `${appId} not mocked!`; } diff --git a/x-pack/plugins/security_solution/public/common/mock/endpoint/http_handler_mock_factory.ts b/x-pack/plugins/security_solution/public/common/mock/endpoint/http_handler_mock_factory.ts index 9d12efca19aed..2df16fc1e21b0 100644 --- a/x-pack/plugins/security_solution/public/common/mock/endpoint/http_handler_mock_factory.ts +++ b/x-pack/plugins/security_solution/public/common/mock/endpoint/http_handler_mock_factory.ts @@ -13,7 +13,7 @@ import type { HttpHandler, HttpStart, } from 'kibana/public'; -import { extend } from 'lodash'; +import { merge } from 'lodash'; import { act } from '@testing-library/react'; class ApiRouteNotMocked extends Error {} @@ -159,6 +159,11 @@ export const httpHandlerMockFactory = ['responseProvider'] = mocks.reduce( (providers, routeMock) => { // FIXME: find a way to remove the ignore below. May need to limit the calling signature of `RouteMock['handler']` @@ -195,7 +200,7 @@ export const httpHandlerMockFactory = { const path = isHttpFetchOptionsWithPath(args[0]) ? args[0].path : args[0]; - const routeMock = methodMocks.find((handler) => handler.path === path); + const routeMock = methodMocks.find((handler) => pathMatchesPattern(handler.path, path)); if (routeMock) { markApiCallAsHandled(responseProvider[routeMock.id].mockDelay); @@ -211,6 +216,9 @@ export const httpHandlerMockFactory = { + // No path params - pattern is single path + if (pathPattern === path) { + return true; + } + + // If pathPattern has params (`{value}`), then see if `path` matches it + if (/{.*?}/.test(pathPattern)) { + const pathParts = path.split(/\//); + const patternParts = pathPattern.split(/\//); + + if (pathParts.length !== patternParts.length) { + return false; + } + + return pathParts.every((part, index) => { + return part === patternParts[index] || /{.*?}/.test(patternParts[index]); + }); + } + + return false; +}; + const isHttpFetchOptionsWithPath = ( opt: string | HttpFetchOptions | HttpFetchOptionsWithPath ): opt is HttpFetchOptionsWithPath => { @@ -235,12 +266,14 @@ const isHttpFetchOptionsWithPath = ( * @example * import { composeApiHandlerMocks } from './http_handler_mock_factory'; * import { + * FleetSetupApiMockInterface, * fleetSetupApiMock, + * AgentsSetupApiMockInterface, * agentsSetupApiMock, * } from './setup'; * - * // Create the new interface as an intersection of all other Api Handler Mocks - * type ComposedApiHandlerMocks = ReturnType & ReturnType + * // Create the new interface as an intersection of all other Api Handler Mock's interfaces + * type ComposedApiHandlerMocks = AgentsSetupApiMockInterface & FleetSetupApiMockInterface * * const newComposedHandlerMock = composeApiHandlerMocks< * ComposedApiHandlerMocks @@ -267,7 +300,7 @@ export const composeHttpHandlerMocks = < handlerMocks.forEach((handlerMock) => { const { waitForApi, ...otherInterfaceProps } = handlerMock(http); - extend(mockedApiInterfaces, otherInterfaceProps); + merge(mockedApiInterfaces, otherInterfaceProps); }); return mockedApiInterfaces; diff --git a/x-pack/plugins/security_solution/public/common/store/test_utils.ts b/x-pack/plugins/security_solution/public/common/store/test_utils.ts index 7616dfccddaff..21c8e6c15f826 100644 --- a/x-pack/plugins/security_solution/public/common/store/test_utils.ts +++ b/x-pack/plugins/security_solution/public/common/store/test_utils.ts @@ -89,7 +89,9 @@ export const createSpyMiddleware = < type ResolvedAction = A extends { type: typeof actionType } ? A : never; // Error is defined here so that we get a better stack trace that points to the test from where it was used - const err = new Error(`action '${actionType}' was not dispatched within the allocated time`); + const err = new Error( + `Timeout! Action '${actionType}' was not dispatched within the allocated time` + ); return new Promise((resolve, reject) => { const watch: ActionWatcher = (action) => { @@ -108,7 +110,10 @@ export const createSpyMiddleware = < const timeout = setTimeout(() => { watchers.delete(watch); reject(err); + // TODO: is there a way we can grab the current timeout value from jest? + // For now, this is using the default value (5000ms) - 500. }, 4500); + watchers.add(watch); }); }, diff --git a/x-pack/plugins/security_solution/public/common/utils/validators/index.ts b/x-pack/plugins/security_solution/public/common/utils/validators/index.ts index 7f470c199d550..178ae3b0f716e 100644 --- a/x-pack/plugins/security_solution/public/common/utils/validators/index.ts +++ b/x-pack/plugins/security_solution/public/common/utils/validators/index.ts @@ -7,6 +7,8 @@ import { isEmpty } from 'lodash/fp'; +export * from './is_endpoint_host_isolated'; + const urlExpression = /(https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})/gi; export const isUrlInvalid = (url: string | null | undefined) => { diff --git a/x-pack/plugins/security_solution/public/common/utils/validators/is_endpoint_host_isolated.test.ts b/x-pack/plugins/security_solution/public/common/utils/validators/is_endpoint_host_isolated.test.ts new file mode 100644 index 0000000000000..2e96d56c3625f --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/utils/validators/is_endpoint_host_isolated.test.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EndpointDocGenerator } from '../../../../common/endpoint/generate_data'; +import { HostMetadata } from '../../../../common/endpoint/types'; +import { isEndpointHostIsolated } from './is_endpoint_host_isolated'; + +describe('When using isEndpointHostIsolated()', () => { + const generator = new EndpointDocGenerator(); + + const generateMetadataDoc = (isolation: boolean = true) => { + const metadataDoc = generator.generateHostMetadata() as HostMetadata; + return { + ...metadataDoc, + Endpoint: { + ...metadataDoc.Endpoint, + state: { + ...metadataDoc.Endpoint.state, + isolation, + }, + }, + }; + }; + + it('Returns `true` when endpoint is isolated', () => { + expect(isEndpointHostIsolated(generateMetadataDoc())).toBe(true); + }); + + it('Returns `false` when endpoint is isolated', () => { + expect(isEndpointHostIsolated(generateMetadataDoc(false))).toBe(false); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/utils/validators/is_endpoint_host_isolated.ts b/x-pack/plugins/security_solution/public/common/utils/validators/is_endpoint_host_isolated.ts new file mode 100644 index 0000000000000..6ca187c52475e --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/utils/validators/is_endpoint_host_isolated.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { HostMetadata } from '../../../../common/endpoint/types'; + +/** + * Given an endpoint host metadata record (`HostMetadata`), this utility will validate if + * that host is isolated + * @param endpointMetadata + */ +export const isEndpointHostIsolated = (endpointMetadata: HostMetadata): boolean => { + return Boolean(endpointMetadata.Endpoint.state.isolation); +}; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_host_isolation_status.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_host_isolation_status.tsx index adc6d3a6b054b..f7894d4764275 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_host_isolation_status.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_host_isolation_status.tsx @@ -11,6 +11,7 @@ import { Maybe } from '../../../../../../observability/common/typings'; import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; import { getHostMetadata } from './api'; import { ISOLATION_STATUS_FAILURE } from './translations'; +import { isEndpointHostIsolated } from '../../../../common/utils/validators'; interface HostIsolationStatusResponse { loading: boolean; @@ -36,7 +37,7 @@ export const useHostIsolationStatus = ({ try { const metadataResponse = await getHostMetadata({ agentId }); if (isMounted) { - setIsIsolated(Boolean(metadataResponse.metadata.Endpoint.state.isolation)); + setIsIsolated(isEndpointHostIsolated(metadataResponse.metadata)); } } catch (error) { addError(error.message, { title: ISOLATION_STATUS_FAILURE }); diff --git a/x-pack/plugins/security_solution/public/management/common/routing.ts b/x-pack/plugins/security_solution/public/management/common/routing.ts index 5bafecb8c4ff5..93d0642c6b3b6 100644 --- a/x-pack/plugins/security_solution/public/management/common/routing.ts +++ b/x-pack/plugins/security_solution/public/management/common/routing.ts @@ -66,7 +66,7 @@ export const getEndpointListPath = ( export const getEndpointDetailsPath = ( props: { - name: 'endpointDetails' | 'endpointPolicyResponse' | 'endpointIsolate'; + name: 'endpointDetails' | 'endpointPolicyResponse' | 'endpointIsolate' | 'endpointUnIsolate'; } & EndpointIndexUIQueryParams & EndpointDetailsUrlProps, search?: string @@ -79,6 +79,9 @@ export const getEndpointDetailsPath = ( case 'endpointIsolate': queryParams.show = 'isolate'; break; + case 'endpointUnIsolate': + queryParams.show = 'unisolate'; + break; case 'endpointPolicyResponse': queryParams.show = 'policy_response'; break; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/mocks.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/mocks.ts new file mode 100644 index 0000000000000..3a3ad47f9f575 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/mocks.ts @@ -0,0 +1,128 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + composeHttpHandlerMocks, + httpHandlerMockFactory, + ResponseProvidersInterface, +} from '../../../common/mock/endpoint/http_handler_mock_factory'; +import { + HostInfo, + HostPolicyResponse, + HostResultList, + HostStatus, + MetadataQueryStrategyVersions, +} from '../../../../common/endpoint/types'; +import { EndpointDocGenerator } from '../../../../common/endpoint/generate_data'; +import { + BASE_POLICY_RESPONSE_ROUTE, + HOST_METADATA_GET_ROUTE, + HOST_METADATA_LIST_ROUTE, +} from '../../../../common/endpoint/constants'; +import { AGENT_POLICY_API_ROUTES, GetAgentPoliciesResponse } from '../../../../../fleet/common'; + +type EndpointMetadataHttpMocksInterface = ResponseProvidersInterface<{ + metadataList: () => HostResultList; + metadataDetails: () => HostInfo; +}>; +export const endpointMetadataHttpMocks = httpHandlerMockFactory( + [ + { + id: 'metadataList', + path: HOST_METADATA_LIST_ROUTE, + method: 'post', + handler: () => { + const generator = new EndpointDocGenerator('seed'); + + return { + hosts: Array.from({ length: 10 }, () => { + return { + metadata: generator.generateHostMetadata(), + host_status: HostStatus.UNHEALTHY, + query_strategy_version: MetadataQueryStrategyVersions.VERSION_2, + }; + }), + total: 10, + request_page_size: 10, + request_page_index: 0, + query_strategy_version: MetadataQueryStrategyVersions.VERSION_2, + }; + }, + }, + { + id: 'metadataDetails', + path: HOST_METADATA_GET_ROUTE, + method: 'get', + handler: () => { + const generator = new EndpointDocGenerator('seed'); + + return { + metadata: generator.generateHostMetadata(), + host_status: HostStatus.UNHEALTHY, + query_strategy_version: MetadataQueryStrategyVersions.VERSION_2, + }; + }, + }, + ] +); + +type EndpointPolicyResponseHttpMockInterface = ResponseProvidersInterface<{ + policyResponse: () => HostPolicyResponse; +}>; +export const endpointPolicyResponseHttpMock = httpHandlerMockFactory( + [ + { + id: 'policyResponse', + path: BASE_POLICY_RESPONSE_ROUTE, + method: 'get', + handler: () => { + return new EndpointDocGenerator('seed').generatePolicyResponse(); + }, + }, + ] +); + +type FleetApisHttpMockInterface = ResponseProvidersInterface<{ + agentPolicy: () => GetAgentPoliciesResponse; +}>; +export const fleetApisHttpMock = httpHandlerMockFactory([ + { + id: 'agentPolicy', + path: AGENT_POLICY_API_ROUTES.LIST_PATTERN, + method: 'get', + handler: () => { + const generator = new EndpointDocGenerator('seed'); + const endpointMetadata = generator.generateHostMetadata(); + const agentPolicy = generator.generateAgentPolicy(); + + // Make sure that the Agent policy returned from the API has the Integration Policy ID that + // the endpoint metadata is using. This is needed especially when testing the Endpoint Details + // flyout where certain actions might be disabled if we know the endpoint integration policy no + // longer exists. + (agentPolicy.package_policies as string[]).push(endpointMetadata.Endpoint.policy.applied.id); + + return { + items: [agentPolicy], + perPage: 10, + total: 1, + page: 1, + }; + }, + }, +]); + +type EndpointPageHttpMockInterface = EndpointMetadataHttpMocksInterface & + EndpointPolicyResponseHttpMockInterface & + FleetApisHttpMockInterface; +/** + * HTTP Mocks that support the Endpoint List and Details page + */ +export const endpointPageHttpMock = composeHttpHandlerMocks([ + endpointMetadataHttpMocks, + endpointPolicyResponseHttpMock, + fleetApisHttpMock, +]); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/action.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/action.ts index 25f2631ef46ff..178f27caa1085 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/action.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/action.ts @@ -11,6 +11,7 @@ import { HostInfo, GetHostPolicyResponse, HostIsolationRequestBody, + ISOLATION_ACTIONS, } from '../../../../../common/endpoint/types'; import { ServerApiError } from '../../../../common/types'; import { GetPolicyListResponse } from '../../policy/types'; @@ -137,7 +138,10 @@ export interface ServerFailedToReturnEndpointsTotal { } export type EndpointIsolationRequest = Action<'endpointIsolationRequest'> & { - payload: HostIsolationRequestBody; + payload: { + type: ISOLATION_ACTIONS; + data: HostIsolationRequestBody; + }; }; export type EndpointIsolationRequestStateChange = Action<'endpointIsolationRequestStateChange'> & { diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.test.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.test.ts index 04a04bc38996b..6548d8a10ce97 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.test.ts @@ -19,6 +19,7 @@ import { HostResultList, HostIsolationResponse, EndpointAction, + ISOLATION_ACTIONS, } from '../../../../../common/endpoint/types'; import { AppAction } from '../../../../common/store/actions'; import { mockEndpointResultList } from './mock_endpoint_result_list'; @@ -135,10 +136,13 @@ describe('endpoint list middleware', () => { describe('handling of IsolateEndpointHost action', () => { const getKibanaServicesMock = KibanaServices.get as jest.Mock; - const dispatchIsolateEndpointHost = () => { + const dispatchIsolateEndpointHost = (action: ISOLATION_ACTIONS = 'isolate') => { dispatch({ type: 'endpointIsolationRequest', - payload: hostIsolationRequestBodyMock(), + payload: { + type: action, + data: hostIsolationRequestBodyMock(), + }, }); }; let isolateApiResponseHandlers: ReturnType; @@ -161,7 +165,24 @@ describe('endpoint list middleware', () => { it('should call isolate api', async () => { dispatchIsolateEndpointHost(); - expect(fakeHttpServices.post).toHaveBeenCalled(); + await waitForAction('endpointIsolationRequestStateChange', { + validate(action) { + return isLoadedResourceState(action.payload); + }, + }); + + expect(isolateApiResponseHandlers.responseProvider.isolateHost).toHaveBeenCalled(); + }); + + it('should call unisolate api', async () => { + dispatchIsolateEndpointHost('unisolate'); + await waitForAction('endpointIsolationRequestStateChange', { + validate(action) { + return isLoadedResourceState(action.payload); + }, + }); + + expect(isolateApiResponseHandlers.responseProvider.unIsolateHost).toHaveBeenCalled(); }); it('should set Isolation state to loaded if api is successful', async () => { diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts index 911a902bd2029..b62663bd78750 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts @@ -51,7 +51,7 @@ import { createLoadedResourceState, createLoadingResourceState, } from '../../../state'; -import { isolateHost } from '../../../../common/lib/host_isolation'; +import { isolateHost, unIsolateHost } from '../../../../common/lib/host_isolation'; import { AppAction } from '../../../../common/store/actions'; import { resolvePathVariables } from '../../../../common/utils/resolve_path_variables'; @@ -504,7 +504,13 @@ const handleIsolateEndpointHost = async ( try { // Cast needed below due to the value of payload being `Immutable<>` - const response = await isolateHost(action.payload as HostIsolationRequestBody); + let response: HostIsolationResponse; + + if (action.payload.type === 'unisolate') { + response = await unIsolateHost(action.payload.data as HostIsolationRequestBody); + } else { + response = await isolateHost(action.payload.data as HostIsolationRequestBody); + } dispatch({ type: 'endpointIsolationRequestStateChange', diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts index 8b6599611ffc4..f3848557567ec 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts @@ -32,6 +32,7 @@ import { isLoadingResourceState, } from '../../../state'; import { ServerApiError } from '../../../../common/types'; +import { isEndpointHostIsolated } from '../../../../common/utils/validators'; export const listData = (state: Immutable) => state.hosts; @@ -204,6 +205,14 @@ export const uiQueryParams: ( 'admin_query', ]; + const allowedShowValues: Array = [ + 'policy_response', + 'details', + 'isolate', + 'unisolate', + 'activity_log', + ]; + for (const key of keys) { const value: string | undefined = typeof query[key] === 'string' @@ -214,13 +223,8 @@ export const uiQueryParams: ( if (value !== undefined) { if (key === 'show') { - if ( - value === 'policy_response' || - value === 'details' || - value === 'activity_log' || - value === 'isolate' - ) { - data[key] = value; + if (allowedShowValues.includes(value as EndpointIndexUIQueryParams['show'])) { + data[key] = value as EndpointIndexUIQueryParams['show']; } } else { data[key] = value; @@ -378,3 +382,7 @@ export const getActivityLogError: ( return activityLog.error; } }); + +export const getIsEndpointHostIsolated = createSelector(detailsData, (details) => { + return (details && isEndpointHostIsolated(details)) || false; +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts index ac06f98004f59..53ddfaee7aa05 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts @@ -114,7 +114,7 @@ export interface EndpointIndexUIQueryParams { /** Which page to show */ page_index?: string; /** show the policy response or host details */ - show?: 'policy_response' | 'activity_log' | 'details' | 'isolate'; + show?: 'policy_response' | 'activity_log' | 'details' | 'isolate' | 'unisolate'; /** Query text from search bar*/ admin_query?: string; } diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/components/context_menu_item_nav_by_rotuer.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/components/context_menu_item_nav_by_rotuer.tsx new file mode 100644 index 0000000000000..ac1b83bdc493b --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/components/context_menu_item_nav_by_rotuer.tsx @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo } from 'react'; +import { EuiContextMenuItem, EuiContextMenuItemProps } from '@elastic/eui'; +import { NavigateToAppOptions } from 'kibana/public'; +import { useNavigateToAppEventHandler } from '../../../../../common/hooks/endpoint/use_navigate_to_app_event_handler'; + +export interface ContextMenuItemNavByRouterProps extends EuiContextMenuItemProps { + navigateAppId: string; + navigateOptions: NavigateToAppOptions; + children: React.ReactNode; +} + +/** + * Just like `EuiContextMenuItem`, but allows for additional props to be defined which will + * allow navigation to a URL path via React Router + */ +export const ContextMenuItemNavByRouter = memo( + ({ navigateAppId, navigateOptions, onClick, children, ...otherMenuItemProps }) => { + const handleOnClick = useNavigateToAppEventHandler(navigateAppId, { + ...navigateOptions, + onClick, + }); + + return ( + + {children} + + ); + } +); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/components/table_row_actions.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/components/table_row_actions.tsx index 8110c5f16a892..94303c43cd4da 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/components/table_row_actions.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/components/table_row_actions.tsx @@ -9,37 +9,31 @@ import React, { memo, useCallback, useMemo, useState } from 'react'; import { EuiButtonIcon, EuiContextMenuPanel, - EuiPopover, - EuiContextMenuItemProps, EuiContextMenuPanelProps, - EuiContextMenuItem, + EuiPopover, EuiPopoverProps, } from '@elastic/eui'; -import { NavigateToAppOptions } from 'kibana/public'; import { i18n } from '@kbn/i18n'; -import { useNavigateToAppEventHandler } from '../../../../../common/hooks/endpoint/use_navigate_to_app_event_handler'; +import { ContextMenuItemNavByRouter } from './context_menu_item_nav_by_rotuer'; +import { HostMetadata } from '../../../../../../common/endpoint/types'; +import { useEndpointActionItems } from '../hooks'; export interface TableRowActionProps { - items: Array< - Omit & { - navigateAppId: string; - navigateOptions: NavigateToAppOptions; - children: React.ReactNode; - key: string; - } - >; + endpointMetadata: HostMetadata; } -export const TableRowActions = memo(({ items }) => { +export const TableRowActions = memo(({ endpointMetadata }) => { const [isOpen, setIsOpen] = useState(false); + const endpointActions = useEndpointActionItems(endpointMetadata); + const handleCloseMenu = useCallback(() => setIsOpen(false), [setIsOpen]); const handleToggleMenu = useCallback(() => setIsOpen(!isOpen), [isOpen]); const menuItems: EuiContextMenuPanelProps['items'] = useMemo(() => { - return items.map((itemProps) => { - return ; + return endpointActions.map((itemProps) => { + return ; }); - }, [handleCloseMenu, items]); + }, [handleCloseMenu, endpointActions]); const panelProps: EuiPopoverProps['panelProps'] = useMemo(() => { return { 'data-test-subj': 'tableRowActionsMenuPanel' }; @@ -69,22 +63,4 @@ export const TableRowActions = memo(({ items }) => { }); TableRowActions.displayName = 'EndpointTableRowActions'; -const EuiContextMenuItemNavByRouter = memo< - EuiContextMenuItemProps & { - navigateAppId: string; - navigateOptions: NavigateToAppOptions; - children: React.ReactNode; - } ->(({ navigateAppId, navigateOptions, onClick, children, ...otherMenuItemProps }) => { - const handleOnClick = useNavigateToAppEventHandler(navigateAppId, { - ...navigateOptions, - onClick, - }); - - return ( - - {children} - - ); -}); -EuiContextMenuItemNavByRouter.displayName = 'EuiContextMenuItemNavByRouter'; +ContextMenuItemNavByRouter.displayName = 'EuiContextMenuItemNavByRouter'; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/actions_menu.test.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/actions_menu.test.tsx new file mode 100644 index 0000000000000..7ecbad54dbbec --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/actions_menu.test.tsx @@ -0,0 +1,113 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + AppContextTestRender, + createAppRootMockRenderer, +} from '../../../../../../common/mock/endpoint'; +import { useKibana } from '../../../../../../common/lib/kibana'; +import { ActionsMenu } from './actions_menu'; +import React from 'react'; +import { act } from '@testing-library/react'; +import { endpointPageHttpMock } from '../../../mocks'; +import { fireEvent } from '@testing-library/dom'; + +jest.mock('../../../../../../common/lib/kibana'); + +describe('When using the Endpoint Details Actions Menu', () => { + let render: () => Promise>; + let coreStart: AppContextTestRender['coreStart']; + let waitForAction: AppContextTestRender['middlewareSpy']['waitForAction']; + let renderResult: ReturnType; + let httpMocks: ReturnType; + + const setEndpointMetadataResponse = (isolation: boolean = false) => { + const endpointHost = httpMocks.responseProvider.metadataDetails(); + // Safe to mutate this mocked data + // @ts-ignore + endpointHost.metadata.Endpoint.state.isolation = isolation; + httpMocks.responseProvider.metadataDetails.mockReturnValue(endpointHost); + }; + + beforeEach(() => { + const mockedContext = createAppRootMockRenderer(); + + (useKibana as jest.Mock).mockReturnValue({ services: mockedContext.startServices }); + coreStart = mockedContext.coreStart; + waitForAction = mockedContext.middlewareSpy.waitForAction; + httpMocks = endpointPageHttpMock(mockedContext.coreStart.http); + + act(() => { + mockedContext.history.push( + '/endpoints?selected_endpoint=5fe11314-678c-413e-87a2-b4a3461878ee' + ); + }); + + render = async () => { + renderResult = mockedContext.render(); + + await act(async () => { + await waitForAction('serverReturnedEndpointDetails'); + }); + + act(() => { + fireEvent.click(renderResult.getByTestId('endpointDetailsActionsButton')); + }); + + return renderResult; + }; + }); + + describe('and endpoint host is NOT isolated', () => { + beforeEach(() => setEndpointMetadataResponse()); + + it.each([ + ['Isolate host', 'isolateLink'], + ['View host details', 'hostLink'], + ['View agent policy', 'agentPolicyLink'], + ['View agent details', 'agentDetailsLink'], + ])('should display %s action', async (_, dataTestSubj) => { + await render(); + expect(renderResult.getByTestId(dataTestSubj)).not.toBeNull(); + }); + + it.each([ + ['Isolate host', 'isolateLink'], + ['View host details', 'hostLink'], + ['View agent policy', 'agentPolicyLink'], + ['View agent details', 'agentDetailsLink'], + ])( + 'should navigate via kibana `navigateToApp()` when %s is clicked', + async (_, dataTestSubj) => { + await render(); + act(() => { + fireEvent.click(renderResult.getByTestId(dataTestSubj)); + }); + + expect(coreStart.application.navigateToApp).toHaveBeenCalled(); + } + ); + }); + + describe('and endpoint host is isolated', () => { + beforeEach(() => setEndpointMetadataResponse(true)); + + it('should display Unisolate action', async () => { + await render(); + expect(renderResult.getByTestId('unIsolateLink')).not.toBeNull(); + }); + + it('should navigate via router when unisolate is clicked', async () => { + await render(); + act(() => { + fireEvent.click(renderResult.getByTestId('unIsolateLink')); + }); + + expect(coreStart.application.navigateToApp).toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/actions_menu.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/actions_menu.tsx new file mode 100644 index 0000000000000..c778f4f2a08ec --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/actions_menu.tsx @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState, useCallback, useMemo } from 'react'; +import { EuiContextMenuPanel, EuiButton, EuiPopover } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { useEndpointActionItems, useEndpointSelector } from '../../hooks'; +import { detailsData } from '../../../store/selectors'; +import { ContextMenuItemNavByRouter } from '../../components/context_menu_item_nav_by_rotuer'; + +export const ActionsMenu = React.memo<{}>(() => { + const endpointDetails = useEndpointSelector(detailsData); + const menuOptions = useEndpointActionItems(endpointDetails); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + const closePopoverHandler = useCallback(() => { + setIsPopoverOpen(false); + }, []); + + const takeActionItems = useMemo(() => { + return menuOptions.map((item) => { + return ; + }); + }, [closePopoverHandler, menuOptions]); + + const takeActionButton = useMemo(() => { + return ( + { + setIsPopoverOpen(!isPopoverOpen); + }} + > + + + ); + }, [isPopoverOpen]); + + return ( + + + + ); +}); + +ActionsMenu.displayName = 'ActionMenu'; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/endpoint_isolate_flyout_panel.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/endpoint_isolate_flyout_panel.tsx index e299a7ec5f973..289c1efeab041 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/endpoint_isolate_flyout_panel.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/endpoint_isolate_flyout_panel.tsx @@ -5,17 +5,19 @@ * 2.0. */ -import React, { memo, useCallback, useEffect, useState } from 'react'; +import React, { memo, useCallback, useState } from 'react'; import { useHistory } from 'react-router-dom'; import { useDispatch } from 'react-redux'; import { Dispatch } from 'redux'; import { i18n } from '@kbn/i18n'; +import { EuiForm } from '@elastic/eui'; import { HostMetadata } from '../../../../../../../common/endpoint/types'; import { BackToEndpointDetailsFlyoutSubHeader } from './back_to_endpoint_details_flyout_subheader'; import { EndpointIsolatedFormProps, EndpointIsolateForm, EndpointIsolateSuccess, + EndpointUnisolateForm, } from '../../../../../../common/components/endpoint/host_isolation'; import { FlyoutBodyNoTopPadding } from './flyout_body_no_top_padding'; import { getEndpointDetailsPath } from '../../../../../common/routing'; @@ -25,18 +27,21 @@ import { getIsIsolationRequestPending, getWasIsolationRequestSuccessful, uiQueryParams, + getIsEndpointHostIsolated, } from '../../../store/selectors'; import { AppAction } from '../../../../../../common/store/actions'; -import { useToasts } from '../../../../../../common/lib/kibana'; -export const EndpointIsolateFlyoutPanel = memo<{ +/** + * Component handles both isolate and un-isolate for a given endpoint + */ +export const EndpointIsolationFlyoutPanel = memo<{ hostMeta: HostMetadata; }>(({ hostMeta }) => { const history = useHistory(); const dispatch = useDispatch>(); - const toast = useToasts(); const { show, ...queryParams } = useEndpointSelector(uiQueryParams); + const isCurrentlyIsolated = useEndpointSelector(getIsEndpointHostIsolated); const isPending = useEndpointSelector(getIsIsolationRequestPending); const wasSuccessful = useEndpointSelector(getWasIsolationRequestSuccessful); const isolateError = useEndpointSelector(getIsolationRequestError); @@ -45,6 +50,8 @@ export const EndpointIsolateFlyoutPanel = memo<{ Parameters[0] >({ comment: '' }); + const IsolationForm = isCurrentlyIsolated ? EndpointUnisolateForm : EndpointIsolateForm; + const handleCancel: EndpointIsolatedFormProps['onCancel'] = useCallback(() => { history.push( getEndpointDetailsPath({ @@ -59,11 +66,14 @@ export const EndpointIsolateFlyoutPanel = memo<{ dispatch({ type: 'endpointIsolationRequest', payload: { - endpoint_ids: [hostMeta.agent.id], - comment: formValues.comment, + type: isCurrentlyIsolated ? 'unisolate' : 'isolate', + data: { + endpoint_ids: [hostMeta.agent.id], + comment: formValues.comment, + }, }, }); - }, [dispatch, formValues.comment, hostMeta.agent.id]); + }, [dispatch, formValues.comment, hostMeta.agent.id, isCurrentlyIsolated]); const handleChange: EndpointIsolatedFormProps['onChange'] = useCallback((changes) => { setFormValues((prevState) => { @@ -74,12 +84,6 @@ export const EndpointIsolateFlyoutPanel = memo<{ }); }, []); - useEffect(() => { - if (isolateError) { - toast.addDanger(isolateError.message); - } - }, [isolateError, toast]); - return ( <> @@ -88,6 +92,7 @@ export const EndpointIsolateFlyoutPanel = memo<{ {wasSuccessful ? ( ) : ( - + + + )} ); }); -EndpointIsolateFlyoutPanel.displayName = 'EndpointIsolateFlyoutPanel'; +EndpointIsolationFlyoutPanel.displayName = 'EndpointIsolateFlyoutPanel'; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/index.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/index.tsx index 8d985f3a4cfe2..89c0e3e6a3e06 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/index.tsx @@ -11,6 +11,7 @@ import { EuiFlyout, EuiFlyoutBody, EuiFlyoutHeader, + EuiFlyoutFooter, EuiLoadingContent, EuiTitle, EuiText, @@ -55,10 +56,11 @@ import { } from './components/endpoint_details_tabs'; import { PreferenceFormattedDateFromPrimitive } from '../../../../../common/components/formatted_date'; -import { EndpointIsolateFlyoutPanel } from './components/endpoint_isolate_flyout_panel'; +import { EndpointIsolationFlyoutPanel } from './components/endpoint_isolate_flyout_panel'; import { BackToEndpointDetailsFlyoutSubHeader } from './components/back_to_endpoint_details_flyout_subheader'; import { FlyoutBodyNoTopPadding } from './components/flyout_body_no_top_padding'; import { getEndpointListPath } from '../../../../common/routing'; +import { ActionsMenu } from './components/actions_menu'; const DetailsFlyoutBody = styled(EuiFlyoutBody)` overflow-y: hidden; @@ -128,6 +130,9 @@ export const EndpointDetailsFlyout = memo(() => { }, ]; + const showFlyoutFooter = + show === 'details' || show === 'policy_response' || show === 'activity_log'; + const handleFlyoutClose = useCallback(() => { const { show: _show, ...urlSearchParams } = queryParamsWithoutSelectedEndpoint; history.push( @@ -203,7 +208,15 @@ export const EndpointDetailsFlyout = memo(() => { {show === 'policy_response' && } - {show === 'isolate' && } + {(show === 'isolate' || show === 'unisolate') && ( + + )} + + {showFlyoutFooter && ( + + + + )} )} diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks/hooks.ts similarity index 89% rename from x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks.ts rename to x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks/hooks.ts index 5c8de0c4e0f3b..4c00c00e50dbc 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks/hooks.ts @@ -7,13 +7,14 @@ import { useSelector } from 'react-redux'; import { useMemo } from 'react'; -import { useKibana } from '../../../../common/lib/kibana'; -import { EndpointState } from '../types'; +import { EndpointState } from '../../types'; +import { State } from '../../../../../common/store'; import { MANAGEMENT_STORE_ENDPOINTS_NAMESPACE, MANAGEMENT_STORE_GLOBAL_NAMESPACE, -} from '../../../common/constants'; -import { State } from '../../../../common/store'; +} from '../../../../common/constants'; +import { useKibana } from '../../../../../common/lib/kibana'; + export function useEndpointSelector(selector: (state: EndpointState) => TSelected) { return useSelector(function (state: State) { return selector( @@ -38,7 +39,6 @@ export const useIngestUrl = (subpath: string): { url: string; appId: string; app }; }, [services.application, subpath]); }; - /** * Returns an object that contains Fleet app and URL information */ diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks/index.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks/index.ts new file mode 100644 index 0000000000000..a5a22b43e63d1 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './hooks'; +export * from './use_endpoint_action_items'; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks/use_endpoint_action_items.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks/use_endpoint_action_items.tsx new file mode 100644 index 0000000000000..dd498ffbbcacc --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks/use_endpoint_action_items.tsx @@ -0,0 +1,155 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { MANAGEMENT_APP_ID } from '../../../../common/constants'; +import { APP_ID, SecurityPageName } from '../../../../../../common/constants'; +import { pagePathGetters } from '../../../../../../../fleet/public'; +import { getEndpointDetailsPath } from '../../../../common/routing'; +import { HostMetadata, MaybeImmutable } from '../../../../../../common/endpoint/types'; +import { useFormatUrl } from '../../../../../common/components/link_to'; +import { useEndpointSelector } from './hooks'; +import { agentPolicies, uiQueryParams } from '../../store/selectors'; +import { useKibana } from '../../../../../common/lib/kibana'; +import { ContextMenuItemNavByRouterProps } from '../components/context_menu_item_nav_by_rotuer'; +import { isEndpointHostIsolated } from '../../../../../common/utils/validators/is_endpoint_host_isolated'; + +/** + * Returns a list (array) of actions for an individual endpoint + * @param endpointMetadata + */ +export const useEndpointActionItems = ( + endpointMetadata: MaybeImmutable | undefined +): ContextMenuItemNavByRouterProps[] => { + const { formatUrl } = useFormatUrl(SecurityPageName.administration); + const fleetAgentPolicies = useEndpointSelector(agentPolicies); + const allCurrentUrlParams = useEndpointSelector(uiQueryParams); + const { + services: { + application: { getUrlForApp }, + }, + } = useKibana(); + + return useMemo(() => { + if (endpointMetadata) { + const isIsolated = isEndpointHostIsolated(endpointMetadata); + const endpointId = endpointMetadata.agent.id; + const endpointPolicyId = endpointMetadata.Endpoint.policy.applied.id; + const endpointHostName = endpointMetadata.host.hostname; + const fleetAgentId = endpointMetadata.elastic.agent.id; + const { + show, + selected_endpoint: _selectedEndpoint, + ...currentUrlParams + } = allCurrentUrlParams; + const endpointIsolatePath = getEndpointDetailsPath({ + name: 'endpointIsolate', + ...currentUrlParams, + selected_endpoint: endpointId, + }); + const endpointUnIsolatePath = getEndpointDetailsPath({ + name: 'endpointUnIsolate', + ...currentUrlParams, + selected_endpoint: endpointId, + }); + + return [ + isIsolated + ? { + 'data-test-subj': 'unIsolateLink', + icon: 'logoSecurity', + key: 'unIsolateHost', + navigateAppId: MANAGEMENT_APP_ID, + navigateOptions: { + path: endpointUnIsolatePath, + }, + href: formatUrl(endpointUnIsolatePath), + children: ( + + ), + } + : { + 'data-test-subj': 'isolateLink', + icon: 'logoSecurity', + key: 'isolateHost', + navigateAppId: MANAGEMENT_APP_ID, + navigateOptions: { + path: endpointIsolatePath, + }, + href: formatUrl(endpointIsolatePath), + children: ( + + ), + }, + { + 'data-test-subj': 'hostLink', + icon: 'logoSecurity', + key: 'hostDetailsLink', + navigateAppId: APP_ID, + navigateOptions: { path: `hosts/${endpointHostName}` }, + href: `${getUrlForApp('securitySolution')}/hosts/${endpointHostName}`, + children: ( + + ), + }, + { + icon: 'gear', + key: 'agentConfigLink', + 'data-test-subj': 'agentPolicyLink', + navigateAppId: 'fleet', + navigateOptions: { + path: `#${pagePathGetters.policy_details({ + policyId: fleetAgentPolicies[endpointPolicyId], + })}`, + }, + href: `${getUrlForApp('fleet')}#${pagePathGetters.policy_details({ + policyId: fleetAgentPolicies[endpointPolicyId], + })}`, + disabled: fleetAgentPolicies[endpointPolicyId] === undefined, + children: ( + + ), + }, + { + icon: 'gear', + key: 'agentDetailsLink', + 'data-test-subj': 'agentDetailsLink', + navigateAppId: 'fleet', + navigateOptions: { + path: `#${pagePathGetters.fleet_agent_details({ + agentId: fleetAgentId, + })}`, + }, + href: `${getUrlForApp('fleet')}#${pagePathGetters.fleet_agent_details({ + agentId: fleetAgentId, + })}`, + children: ( + + ), + }, + ]; + } + + return []; + }, [allCurrentUrlParams, endpointMetadata, fleetAgentPolicies, formatUrl, getUrlForApp]); +}; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx index d963682ff005d..509bb7b4cf711 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx @@ -522,7 +522,7 @@ describe('when on the endpoint list page', () => { const { // eslint-disable-next-line @typescript-eslint/naming-convention host_status, - metadata: { agent, ...details }, + metadata: { agent, Endpoint, ...details }, // eslint-disable-next-line @typescript-eslint/naming-convention query_strategy_version, } = mockEndpointDetailsApiResult(); @@ -531,6 +531,13 @@ describe('when on the endpoint list page', () => { host_status, metadata: { ...details, + Endpoint: { + ...Endpoint, + state: { + ...Endpoint.state, + isolation: false, + }, + }, agent: { ...agent, id: '1', @@ -633,11 +640,10 @@ describe('when on the endpoint list page', () => { jest.clearAllMocks(); }); - it('should show the flyout', async () => { + it('should show the flyout and footer', async () => { const renderResult = await renderAndWaitForData(); - return renderResult.findByTestId('endpointDetailsFlyout').then((flyout) => { - expect(flyout).not.toBeNull(); - }); + await expect(renderResult.findByTestId('endpointDetailsFlyout')).not.toBeNull(); + await expect(renderResult.queryByTestId('endpointDetailsFlyoutFooter')).not.toBeNull(); }); it('should display policy name value as a link', async () => { @@ -743,6 +749,11 @@ describe('when on the endpoint list page', () => { ); }); + it('should show the Take Action button', async () => { + const renderResult = await renderAndWaitForData(); + expect(renderResult.getByTestId('endpointDetailsActionsButton')).not.toBeNull(); + }); + describe('when link to reassignment in Ingest is clicked', () => { beforeEach(async () => { coreStart.application.getUrlForApp.mockReturnValue('/app/fleet'); @@ -993,18 +1004,13 @@ describe('when on the endpoint list page', () => { }); }); - it('should show error toast if isolate fails', async () => { + it('should show error if isolate fails', async () => { isolateApiMock.responseProvider.isolateHost.mockImplementation(() => { throw new Error('oh oh. something went wrong'); }); - - // coreStart.http.post.mockReset(); - // coreStart.http.post.mockRejectedValue(new Error('oh oh. something went wrong')); await confirmIsolateAndWaitForApiResponse('failure'); - expect(coreStart.notifications.toasts.addDanger).toHaveBeenCalledWith( - 'oh oh. something went wrong' - ); + expect(renderResult.getByText('oh oh. something went wrong')).not.toBeNull(); }); it('should reset isolation state and show form again', async () => { @@ -1031,6 +1037,10 @@ describe('when on the endpoint list page', () => { ) ).toBe(true); }); + + it('should NOT show the flyout footer', async () => { + await expect(renderResult.queryByTestId('endpointDetailsFlyoutFooter')).toBeNull(); + }); }); }); @@ -1045,9 +1055,19 @@ describe('when on the endpoint list page', () => { const { hosts, query_strategy_version: queryStrategyVersion } = mockEndpointResultList(); hostInfo = { host_status: hosts[0].host_status, - metadata: hosts[0].metadata, + metadata: { + ...hosts[0].metadata, + Endpoint: { + ...hosts[0].metadata.Endpoint, + state: { + ...hosts[0].metadata.Endpoint.state, + isolation: false, + }, + }, + }, query_strategy_version: queryStrategyVersion, }; + const packagePolicy = docGenerator.generatePolicyPackagePolicy(); packagePolicy.id = hosts[0].metadata.Endpoint.policy.applied.id; const agentPolicy = generator.generateAgentPolicy(); @@ -1098,6 +1118,8 @@ describe('when on the endpoint list page', () => { expect(isolateLink.getAttribute('href')).toEqual( getEndpointDetailsPath({ name: 'endpointIsolate', + page_index: '0', + page_size: '10', selected_endpoint: hostInfo.metadata.agent.id, }) ); 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 7e5658f7b0cba..cef6acff4e344 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 @@ -39,11 +39,7 @@ import { import { useNavigateByRouterEventHandler } from '../../../../common/hooks/endpoint/use_navigate_by_router_event_handler'; import { CreateStructuredSelector } from '../../../../common/store'; import { Immutable, HostInfo } from '../../../../../common/endpoint/types'; -import { - DEFAULT_POLL_INTERVAL, - MANAGEMENT_APP_ID, - MANAGEMENT_PAGE_SIZE_OPTIONS, -} from '../../../common/constants'; +import { DEFAULT_POLL_INTERVAL, MANAGEMENT_PAGE_SIZE_OPTIONS } from '../../../common/constants'; import { PolicyEmptyState, HostsEmptyState } from '../../../components/management_empty_state'; import { FormattedDate } from '../../../../common/components/formatted_date'; import { useNavigateToAppEventHandler } from '../../../../common/hooks/endpoint/use_navigate_to_app_event_handler'; @@ -61,7 +57,6 @@ import { OutOfDate } from './components/out_of_date'; import { AdminSearchBar } from './components/search_bar'; import { AdministrationListPage } from '../../../components/administration_list_page'; import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; -import { APP_ID } from '../../../../../common/constants'; import { LinkToApp } from '../../../../common/components/endpoint/link_to_app'; import { TableRowActions } from './components/table_row_actions'; @@ -120,7 +115,6 @@ export const EndpointList = () => { policyItemsLoading, endpointPackageVersion, endpointsExist, - agentPolicies, autoRefreshInterval, isAutoRefreshEnabled, patternsError, @@ -130,7 +124,6 @@ export const EndpointList = () => { isTransformEnabled, } = useEndpointSelector(selector); const { formatUrl, search } = useFormatUrl(SecurityPageName.administration); - const dispatch = useDispatch<(a: EndpointAction) => void>(); // cap ability to page at 10k records. (max_result_window) const maxPageCount = totalItemCount > MAX_PAGINATED_ITEM ? MAX_PAGINATED_ITEM : totalItemCount; @@ -427,102 +420,15 @@ export const EndpointList = () => { }), actions: [ { + // eslint-disable-next-line react/display-name render: (item: HostInfo) => { - const endpointIsolatePath = getEndpointDetailsPath({ - name: 'endpointIsolate', - selected_endpoint: item.metadata.agent.id, - }); - - return ( - - ), - }, - { - 'data-test-subj': 'hostLink', - icon: 'logoSecurity', - key: 'hostDetailsLink', - navigateAppId: APP_ID, - navigateOptions: { path: `hosts/${item.metadata.host.hostname}` }, - href: `${services?.application?.getUrlForApp('securitySolution')}/hosts/${ - item.metadata.host.hostname - }`, - children: ( - - ), - }, - { - icon: 'logoObservability', - key: 'agentConfigLink', - 'data-test-subj': 'agentPolicyLink', - navigateAppId: 'fleet', - navigateOptions: { - path: `#${pagePathGetters.policy_details({ - policyId: agentPolicies[item.metadata.Endpoint.policy.applied.id], - })}`, - }, - href: `${services?.application?.getUrlForApp( - 'fleet' - )}#${pagePathGetters.policy_details({ - policyId: agentPolicies[item.metadata.Endpoint.policy.applied.id], - })}`, - disabled: - agentPolicies[item.metadata.Endpoint.policy.applied.id] === undefined, - children: ( - - ), - }, - { - icon: 'logoObservability', - key: 'agentDetailsLink', - 'data-test-subj': 'agentDetailsLink', - navigateAppId: 'fleet', - navigateOptions: { - path: `#${pagePathGetters.fleet_agent_details({ - agentId: item.metadata.elastic.agent.id, - })}`, - }, - href: `${services?.application?.getUrlForApp( - 'fleet' - )}#${pagePathGetters.fleet_agent_details({ - agentId: item.metadata.elastic.agent.id, - })}`, - children: ( - - ), - }, - ]} - /> - ); + return ; }, }, ], }, ]; - }, [queryParams, search, formatUrl, PAD_LEFT, services?.application, agentPolicies]); + }, [queryParams, search, formatUrl, PAD_LEFT]); const renderTableOrEmptyState = useMemo(() => { if (endpointsExist || areEndpointsEnrolling) { diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 4c927d5094ca4..982cf768db078 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -20313,9 +20313,6 @@ "xpack.securitySolution.endpoint.ingestToastTitle": "アプリを初期化できませんでした", "xpack.securitySolution.endpoint.list.actionmenu": "開く", "xpack.securitySolution.endpoint.list.actions": "アクション", - "xpack.securitySolution.endpoint.list.actions.agentDetails": "エージェント詳細を表示", - "xpack.securitySolution.endpoint.list.actions.agentPolicy": "エージェントポリシーを表示", - "xpack.securitySolution.endpoint.list.actions.hostDetails": "ホスト詳細を表示", "xpack.securitySolution.endpoint.list.endpointsEnrolling": "エンドポイントを登録しています。進行状況を追跡するには、{agentsLink}してください。", "xpack.securitySolution.endpoint.list.endpointsEnrolling.viewAgentsLink": "エージェントを表示", "xpack.securitySolution.endpoint.list.endpointVersion": "バージョン", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 57a1b6a8751fd..46f08cbed6c8e 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -20613,9 +20613,6 @@ "xpack.securitySolution.endpoint.ingestToastTitle": "应用无法初始化", "xpack.securitySolution.endpoint.list.actionmenu": "未结", "xpack.securitySolution.endpoint.list.actions": "操作", - "xpack.securitySolution.endpoint.list.actions.agentDetails": "查看代理详情", - "xpack.securitySolution.endpoint.list.actions.agentPolicy": "查看代理策略", - "xpack.securitySolution.endpoint.list.actions.hostDetails": "查看主机详情", "xpack.securitySolution.endpoint.list.endpointsEnrolling": "正在注册终端。{agentsLink}以跟踪进度。", "xpack.securitySolution.endpoint.list.endpointsEnrolling.viewAgentsLink": "查看代理", "xpack.securitySolution.endpoint.list.endpointVersion": "版本", diff --git a/x-pack/test/functional/es_archives/endpoint/metadata/destination_index/data.json b/x-pack/test/functional/es_archives/endpoint/metadata/destination_index/data.json index b70a9d5df0eb8..22f4afcf99d4d 100644 --- a/x-pack/test/functional/es_archives/endpoint/metadata/destination_index/data.json +++ b/x-pack/test/functional/es_archives/endpoint/metadata/destination_index/data.json @@ -13,7 +13,13 @@ "status": "failure" } }, - "status": "enrolled" + "status": "enrolled", + "configuration": { + "isolation": false + }, + "state": { + "isolation": false + } }, "agent": { "id": "3838df35-a095-4af4-8fce-0b6d78793f2e", @@ -81,7 +87,13 @@ "status": "failure" } }, - "status": "enrolled" + "status": "enrolled", + "configuration": { + "isolation": false + }, + "state": { + "isolation": false + } }, "agent": { "id": "963b081e-60d1-482c-befd-a5815fa8290f", @@ -152,7 +164,13 @@ "status": "success" } }, - "status": "enrolled" + "status": "enrolled", + "configuration": { + "isolation": false + }, + "state": { + "isolation": false + } }, "agent": { "id": "b3412d6f-b022-4448-8fee-21cc936ea86b", From b130edfdb49506b694e2c4b00674fe5869921cc6 Mon Sep 17 00:00:00 2001 From: Marta Bondyra Date: Fri, 4 Jun 2021 17:29:45 +0200 Subject: [PATCH 54/90] [Lens] fix suggestions for filters and filter by (#101372) * [Lens] fix suggestions for filters and filter by * Update advanced_options.tsx revert another PR changes --- .../dimension_panel/filtering.tsx | 2 +- .../filters/filter_popover.test.tsx | 23 +++++++++++++++---- .../definitions/filters/filter_popover.tsx | 2 +- .../indexpattern_datasource/query_input.tsx | 7 +++--- 4 files changed, 23 insertions(+), 11 deletions(-) diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/filtering.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/filtering.tsx index 65bc23b4eb1ca..68705ebf2d157 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/filtering.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/filtering.tsx @@ -114,7 +114,7 @@ export function Filtering({ } > { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.test.tsx index 6d1cc3254ca7e..1c2e64735ca16 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.test.tsx @@ -13,6 +13,7 @@ import { createMockedIndexPattern } from '../../../mocks'; import { FilterPopover } from './filter_popover'; import { LabelInput } from '../shared_components'; import { QueryInput } from '../../../query_input'; +import { QueryStringInput } from '../../../../../../../../src/plugins/data/public'; jest.mock('.', () => ({ isQueryValid: () => true, @@ -32,13 +33,25 @@ const defaultProps = { ), initiallyOpen: true, }; +jest.mock('../../../../../../../../src/plugins/data/public', () => ({ + QueryStringInput: () => { + return 'QueryStringInput'; + }, +})); describe('filter popover', () => { - jest.mock('../../../../../../../../src/plugins/data/public', () => ({ - QueryStringInput: () => { - return 'QueryStringInput'; - }, - })); + it('passes correct props to QueryStringInput', () => { + const instance = mount(); + instance.update(); + expect(instance.find(QueryStringInput).props()).toEqual( + expect.objectContaining({ + dataTestSubj: 'indexPattern-filters-queryStringInput', + indexPatterns: ['my-fake-index-pattern'], + isInvalid: false, + query: { language: 'kuery', query: 'bytes >= 1' }, + }) + ); + }); it('should be open if is open by creation', () => { const instance = mount(); instance.update(); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.tsx index f5428bf24348f..bfb0cffece57c 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.tsx @@ -75,7 +75,7 @@ export const FilterPopover = ({ { if (inputRef.current) inputRef.current.focus(); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/query_input.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/query_input.tsx index 6c2b62f96eaec..a67199a9d3432 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/query_input.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/query_input.tsx @@ -7,21 +7,20 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; -import { IndexPattern } from './types'; import { QueryStringInput, Query } from '../../../../../src/plugins/data/public'; import { useDebouncedValue } from '../shared_components'; export const QueryInput = ({ value, onChange, - indexPattern, + indexPatternTitle, isInvalid, onSubmit, disableAutoFocus, }: { value: Query; onChange: (input: Query) => void; - indexPattern: IndexPattern; + indexPatternTitle: string; isInvalid: boolean; onSubmit: () => void; disableAutoFocus?: boolean; @@ -35,7 +34,7 @@ export const QueryInput = ({ disableAutoFocus={disableAutoFocus} isInvalid={isInvalid} bubbleSubmitEvent={false} - indexPatterns={[indexPattern]} + indexPatterns={[indexPatternTitle]} query={inputValue} onChange={handleInputChange} onSubmit={() => { From 03bc6bfe311735334f62fa4fe4053469e8e2c1f2 Mon Sep 17 00:00:00 2001 From: Alison Goryachev Date: Fri, 4 Jun 2021 11:45:06 -0400 Subject: [PATCH 55/90] [Upgrade Assistant] Use config for readonly mode (#101296) --- .../client_integration/helpers/setup_environment.tsx | 4 ++-- x-pack/plugins/upgrade_assistant/common/config.ts | 6 ++++++ x-pack/plugins/upgrade_assistant/common/constants.ts | 7 ------- .../public/application/mount_management_section.ts | 6 +++--- x-pack/plugins/upgrade_assistant/public/plugin.ts | 5 +++-- x-pack/plugins/upgrade_assistant/server/index.ts | 5 +++-- 6 files changed, 17 insertions(+), 16 deletions(-) diff --git a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/setup_environment.tsx b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/setup_environment.tsx index 31189428fda18..faeb0e4a40abd 100644 --- a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/setup_environment.tsx +++ b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/setup_environment.tsx @@ -17,7 +17,7 @@ import { } from 'src/core/public/mocks'; import { HttpSetup } from 'src/core/public'; -import { mockKibanaSemverVersion, UA_READONLY_MODE } from '../../../common/constants'; +import { mockKibanaSemverVersion } from '../../../common/constants'; import { AppContextProvider } from '../../../public/application/app_context'; import { apiService } from '../../../public/application/lib/api'; import { breadcrumbService } from '../../../public/application/lib/breadcrumbs'; @@ -40,7 +40,7 @@ export const WithAppDependencies = (Comp: any, overrides: Record; diff --git a/x-pack/plugins/upgrade_assistant/common/constants.ts b/x-pack/plugins/upgrade_assistant/common/constants.ts index 29253d3c373d6..bab3d8c3fda86 100644 --- a/x-pack/plugins/upgrade_assistant/common/constants.ts +++ b/x-pack/plugins/upgrade_assistant/common/constants.ts @@ -14,13 +14,6 @@ import SemVer from 'semver/classes/semver'; export const mockKibanaVersion = '8.0.0'; export const mockKibanaSemverVersion = new SemVer(mockKibanaVersion); -/* - * This will be set to true up until the last minor before the next major. - * In readonly mode, the user will not be able to perform any actions in the UI - * and will be presented with a message indicating as such. - */ -export const UA_READONLY_MODE = true; - /* * Map of 7.0 --> 8.0 index setting deprecation log messages and associated settings * We currently only support one setting deprecation (translog retention), but the code is written diff --git a/x-pack/plugins/upgrade_assistant/public/application/mount_management_section.ts b/x-pack/plugins/upgrade_assistant/public/application/mount_management_section.ts index b17c1301f83f3..73e5d33e6c968 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/mount_management_section.ts +++ b/x-pack/plugins/upgrade_assistant/public/application/mount_management_section.ts @@ -7,7 +7,6 @@ import { CoreSetup } from 'src/core/public'; import { ManagementAppMountParams } from '../../../../../src/plugins/management/public'; -import { UA_READONLY_MODE } from '../../common/constants'; import { renderApp } from './render_app'; import { KibanaVersionContext } from './app_context'; import { apiService } from './lib/api'; @@ -17,7 +16,8 @@ export async function mountManagementSection( coreSetup: CoreSetup, isCloudEnabled: boolean, params: ManagementAppMountParams, - kibanaVersionInfo: KibanaVersionContext + kibanaVersionInfo: KibanaVersionContext, + readonly: boolean ) { const [ { i18n, docLinks, notifications, application, deprecations }, @@ -37,7 +37,7 @@ export async function mountManagementSection( docLinks, kibanaVersionInfo, notifications, - isReadOnlyMode: UA_READONLY_MODE, + isReadOnlyMode: readonly, history, api: apiService, breadcrumbs: breadcrumbService, diff --git a/x-pack/plugins/upgrade_assistant/public/plugin.ts b/x-pack/plugins/upgrade_assistant/public/plugin.ts index e33f146dd47fc..4f5429201f304 100644 --- a/x-pack/plugins/upgrade_assistant/public/plugin.ts +++ b/x-pack/plugins/upgrade_assistant/public/plugin.ts @@ -22,7 +22,7 @@ interface Dependencies { export class UpgradeAssistantUIPlugin implements Plugin { constructor(private ctx: PluginInitializerContext) {} setup(coreSetup: CoreSetup, { cloud, management }: Dependencies) { - const { enabled } = this.ctx.config.get(); + const { enabled, readonly } = this.ctx.config.get(); if (!enabled) { return; @@ -61,7 +61,8 @@ export class UpgradeAssistantUIPlugin implements Plugin { coreSetup, isCloudEnabled, params, - kibanaVersionInfo + kibanaVersionInfo, + readonly ); return () => { diff --git a/x-pack/plugins/upgrade_assistant/server/index.ts b/x-pack/plugins/upgrade_assistant/server/index.ts index 15e1435672407..035a6515de152 100644 --- a/x-pack/plugins/upgrade_assistant/server/index.ts +++ b/x-pack/plugins/upgrade_assistant/server/index.ts @@ -7,15 +7,16 @@ import { PluginInitializerContext, PluginConfigDescriptor } from 'src/core/server'; import { UpgradeAssistantServerPlugin } from './plugin'; -import { configSchema } from '../common/config'; +import { configSchema, Config } from '../common/config'; export const plugin = (ctx: PluginInitializerContext) => { return new UpgradeAssistantServerPlugin(ctx); }; -export const config: PluginConfigDescriptor = { +export const config: PluginConfigDescriptor = { schema: configSchema, exposeToBrowser: { enabled: true, + readonly: true, }, }; From bc363181cf15a4e9462552d784bbfa131a1f3580 Mon Sep 17 00:00:00 2001 From: igoristic Date: Fri, 4 Jun 2021 11:52:27 -0400 Subject: [PATCH 56/90] Allow . system indices in regex (#100831) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../common/es_glob_patterns.test.ts | 120 ++++++++++++++++++ .../server/alerts/large_shard_size_alert.ts | 2 +- .../lib/alerts/fetch_index_shard_size.ts | 6 +- 3 files changed, 122 insertions(+), 6 deletions(-) create mode 100644 x-pack/plugins/monitoring/common/es_glob_patterns.test.ts diff --git a/x-pack/plugins/monitoring/common/es_glob_patterns.test.ts b/x-pack/plugins/monitoring/common/es_glob_patterns.test.ts new file mode 100644 index 0000000000000..64250d0b3c5ae --- /dev/null +++ b/x-pack/plugins/monitoring/common/es_glob_patterns.test.ts @@ -0,0 +1,120 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ESGlobPatterns } from './es_glob_patterns'; + +const testIndices = [ + '.kibana_task_manager_inifc_1', + '.kibana_shahzad_4', + '.kibana_shahzad_3', + '.kibana_shahzad_2', + '.kibana_shahzad_1', + '.kibana_task_manager_cmarcondes-24_8.0.0_001', + '.kibana_task_manager_custom_kbn-pr93025_8.0.0_001', + '.kibana_task_manager_spong_8.0.0_001', + '.ds-metrics-system.process.summary-default-2021.05.25-00000', + '.kibana_shahzad_9', + '.kibana-felix-log-stream_8.0.0_001', + '.kibana_smith_alerts-observability-apm-000001', + '.ds-logs-endpoint.events.process-default-2021.05.26-000001', + '.kibana_dominiqueclarke54_8.0.0_001', + '.kibana-cmarcondes-19_8.0.0_001', + '.kibana_task_manager_cmarcondes-17_8.0.0_001', + '.kibana_task_manager_jrhodes_8.0.0_001', + '.kibana_task_manager_dominiqueclarke7_8', + 'data_prod_0', + 'data_prod_1', + 'data_prod_2', + 'data_prod_3', + 'filebeat-8.0.0-2021.04.13-000001', + '.kibana_dominiqueclarke55-alerts-8.0.0-000001', + '.ds-metrics-system.socket_summary-default-2021.05.12-000001', + '.kibana_task_manager_dominiqueclarke24_8.0.0_001', + '.kibana_custom_kbn-pr94906_8.0.0_001', + '.kibana_task_manager_cmarcondes-22_8.0.0_001', + '.kibana_dominiqueclarke49-event-log-8.0.0-000001', + 'data_stage_2', + 'data_stage_3', +].sort(); + +const noSystemIndices = [ + 'data_prod_0', + 'data_prod_1', + 'data_prod_2', + 'data_prod_3', + 'filebeat-8.0.0-2021.04.13-000001', + 'data_stage_2', + 'data_stage_3', +].sort(); + +const onlySystemIndices = [ + '.kibana_task_manager_inifc_1', + '.kibana_shahzad_4', + '.kibana_shahzad_3', + '.kibana_shahzad_2', + '.kibana_shahzad_1', + '.kibana_task_manager_cmarcondes-24_8.0.0_001', + '.kibana_task_manager_custom_kbn-pr93025_8.0.0_001', + '.kibana_task_manager_spong_8.0.0_001', + '.ds-metrics-system.process.summary-default-2021.05.25-00000', + '.kibana_shahzad_9', + '.kibana-felix-log-stream_8.0.0_001', + '.kibana_smith_alerts-observability-apm-000001', + '.ds-logs-endpoint.events.process-default-2021.05.26-000001', + '.kibana_dominiqueclarke54_8.0.0_001', + '.kibana-cmarcondes-19_8.0.0_001', + '.kibana_task_manager_cmarcondes-17_8.0.0_001', + '.kibana_task_manager_jrhodes_8.0.0_001', + '.kibana_task_manager_dominiqueclarke7_8', + '.kibana_dominiqueclarke55-alerts-8.0.0-000001', + '.ds-metrics-system.socket_summary-default-2021.05.12-000001', + '.kibana_task_manager_dominiqueclarke24_8.0.0_001', + '.kibana_custom_kbn-pr94906_8.0.0_001', + '.kibana_task_manager_cmarcondes-22_8.0.0_001', + '.kibana_dominiqueclarke49-event-log-8.0.0-000001', +].sort(); + +const kibanaNoTaskIndices = [ + '.kibana_shahzad_4', + '.kibana_shahzad_3', + '.kibana_shahzad_2', + '.kibana_shahzad_1', + '.kibana_shahzad_9', + '.kibana-felix-log-stream_8.0.0_001', + '.kibana_smith_alerts-observability-apm-000001', + '.kibana_dominiqueclarke54_8.0.0_001', + '.kibana-cmarcondes-19_8.0.0_001', + '.kibana_dominiqueclarke55-alerts-8.0.0-000001', + '.kibana_custom_kbn-pr94906_8.0.0_001', + '.kibana_dominiqueclarke49-event-log-8.0.0-000001', +].sort(); + +describe('ES glob index patterns', () => { + it('should exclude system/internal indices', () => { + const validIndexPatterns = ESGlobPatterns.createRegExPatterns('-.*'); + const validIndices = testIndices.filter((index) => + ESGlobPatterns.isValid(index, validIndexPatterns) + ); + expect(validIndices.sort()).toEqual(noSystemIndices); + }); + + it('should only show ".index" system indices', () => { + const validIndexPatterns = ESGlobPatterns.createRegExPatterns('.*'); + const validIndices = testIndices.filter((index) => + ESGlobPatterns.isValid(index, validIndexPatterns) + ); + expect(validIndices.sort()).toEqual(onlySystemIndices); + }); + + it('should only show ".kibana*" indices without _task_', () => { + const validIndexPatterns = ESGlobPatterns.createRegExPatterns('.kibana*,-*_task_*'); + const validIndices = testIndices.filter((index) => + ESGlobPatterns.isValid(index, validIndexPatterns) + ); + expect(validIndices.sort()).toEqual(kibanaNoTaskIndices); + }); +}); diff --git a/x-pack/plugins/monitoring/server/alerts/large_shard_size_alert.ts b/x-pack/plugins/monitoring/server/alerts/large_shard_size_alert.ts index db318d7962beb..a6a101bc42afa 100644 --- a/x-pack/plugins/monitoring/server/alerts/large_shard_size_alert.ts +++ b/x-pack/plugins/monitoring/server/alerts/large_shard_size_alert.ts @@ -42,7 +42,7 @@ export class LargeShardSizeAlert extends BaseAlert { id: ALERT_LARGE_SHARD_SIZE, name: ALERT_DETAILS[ALERT_LARGE_SHARD_SIZE].label, throttle: '12h', - defaultParams: { indexPattern: '*', threshold: 55 }, + defaultParams: { indexPattern: '-.*', threshold: 55 }, actionVariables: [ { name: 'shardIndices', diff --git a/x-pack/plugins/monitoring/server/lib/alerts/fetch_index_shard_size.ts b/x-pack/plugins/monitoring/server/lib/alerts/fetch_index_shard_size.ts index e1da45ab7d991..aab3f0101ef83 100644 --- a/x-pack/plugins/monitoring/server/lib/alerts/fetch_index_shard_size.ts +++ b/x-pack/plugins/monitoring/server/lib/alerts/fetch_index_shard_size.ts @@ -120,11 +120,7 @@ export async function fetchIndexShardSize( for (const indexBucket of indexBuckets) { const shardIndex = indexBucket.key; const topHit = indexBucket.hits?.hits?.hits[0] as TopHitType; - if ( - !topHit || - shardIndex.charAt() === '.' || - !ESGlobPatterns.isValid(shardIndex, validIndexPatterns) - ) { + if (!topHit || !ESGlobPatterns.isValid(shardIndex, validIndexPatterns)) { continue; } const { From 76105cc4d02032ad66490d1a8fd044fd6e84c82e Mon Sep 17 00:00:00 2001 From: Shahzad Date: Fri, 4 Jun 2021 18:04:40 +0200 Subject: [PATCH 57/90] [User Experience] Move ux app to new nav (#101005) --- .github/CODEOWNERS | 2 +- .../plugins/apm/public/application/index.tsx | 1 + .../application/{csmApp.tsx => uxApp.tsx} | 40 ++++++++------- .../RumDashboard/CsmSharedContext/index.tsx | 2 +- .../app/RumDashboard/Panels/MainFilters.tsx | 26 ++-------- .../components/app/RumDashboard/RumHome.tsx | 51 ++++++++++++++----- .../components/app/RumDashboard/index.tsx | 25 ++++----- .../context/apm_plugin/apm_plugin_context.tsx | 2 + x-pack/plugins/apm/public/plugin.ts | 24 ++++++++- .../public/hooks/use_kibana_ui_settings.tsx | 8 +-- x-pack/plugins/observability/public/index.ts | 1 + .../plugins/uptime/public/apps/uptime_app.tsx | 19 ++----- 12 files changed, 112 insertions(+), 89 deletions(-) rename x-pack/plugins/apm/public/application/{csmApp.tsx => uxApp.tsx} (85%) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 68fadd4958cba..725708e8a8af2 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -89,7 +89,7 @@ # Client Side Monitoring / Uptime (lives in APM directories but owned by Uptime) /x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm @elastic/uptime /x-pack/plugins/apm/e2e/cypress/integration/csm_dashboard.feature @elastic/uptime -/x-pack/plugins/apm/public/application/csmApp.tsx @elastic/uptime +/x-pack/plugins/apm/public/application/uxApp.tsx @elastic/uptime /x-pack/plugins/apm/public/components/app/RumDashboard @elastic/uptime /x-pack/plugins/apm/server/lib/rum_client @elastic/uptime /x-pack/plugins/apm/server/routes/rum_client.ts @elastic/uptime diff --git a/x-pack/plugins/apm/public/application/index.tsx b/x-pack/plugins/apm/public/application/index.tsx index 9b8d3c7822d3d..d5d77eea8c9c0 100644 --- a/x-pack/plugins/apm/public/application/index.tsx +++ b/x-pack/plugins/apm/public/application/index.tsx @@ -43,6 +43,7 @@ export const renderApp = ({ config, core: coreStart, plugins: pluginsSetup, + observability: pluginsStart.observability, observabilityRuleTypeRegistry, }; diff --git a/x-pack/plugins/apm/public/application/csmApp.tsx b/x-pack/plugins/apm/public/application/uxApp.tsx similarity index 85% rename from x-pack/plugins/apm/public/application/csmApp.tsx rename to x-pack/plugins/apm/public/application/uxApp.tsx index ca4f4856894f9..947ff404a1437 100644 --- a/x-pack/plugins/apm/public/application/csmApp.tsx +++ b/x-pack/plugins/apm/public/application/uxApp.tsx @@ -12,8 +12,8 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { Route, Router } from 'react-router-dom'; import { DefaultTheme, ThemeProvider } from 'styled-components'; +import { i18n } from '@kbn/i18n'; import type { ObservabilityRuleTypeRegistry } from '../../../observability/public'; -import { euiStyled } from '../../../../../src/plugins/kibana_react/common'; import { KibanaContextProvider, RedirectAppLinks, @@ -24,21 +24,16 @@ import { ScrollToTopOnPathChange } from '../components/app/Main/ScrollToTopOnPat import { RumHome, UX_LABEL } from '../components/app/RumDashboard/RumHome'; import { ApmPluginContext } from '../context/apm_plugin/apm_plugin_context'; import { UrlParamsProvider } from '../context/url_params_context/url_params_context'; -import { useBreadcrumbs } from '../hooks/use_breadcrumbs'; import { ConfigSchema } from '../index'; import { ApmPluginSetupDeps, ApmPluginStartDeps } from '../plugin'; import { createCallApmApi } from '../services/rest/createCallApmApi'; -import { px, units } from '../style/variables'; import { createStaticIndexPattern } from '../services/rest/index_pattern'; import { UXActionMenu } from '../components/app/RumDashboard/ActionMenu'; import { redirectTo } from '../components/routing/redirect_to'; +import { useBreadcrumbs } from '../../../observability/public'; +import { useApmPluginContext } from '../context/apm_plugin/use_apm_plugin_context'; -const CsmMainContainer = euiStyled.div` - padding: ${px(units.plus)}; - height: 100%; -`; - -export const rumRoutes: APMRouteDefinition[] = [ +export const uxRoutes: APMRouteDefinition[] = [ { exact: true, path: '/', @@ -47,10 +42,20 @@ export const rumRoutes: APMRouteDefinition[] = [ }, ]; -function CsmApp() { +function UxApp() { const [darkMode] = useUiSetting$('theme:darkMode'); - useBreadcrumbs(rumRoutes); + const { core } = useApmPluginContext(); + const basePath = core.http.basePath.get(); + + useBreadcrumbs([ + { text: UX_LABEL, href: basePath + '/app/ux' }, + { + text: i18n.translate('xpack.apm.ux.overview', { + defaultMessage: 'Overview', + }), + }, + ]); return ( - +
- +
); } -export function CsmAppRoot({ +export function UXAppRoot({ appMountParameters, core, deps, config, - corePlugins: { embeddable, maps }, + corePlugins: { embeddable, maps, observability }, observabilityRuleTypeRegistry, }: { appMountParameters: AppMountParameters; @@ -91,6 +96,7 @@ export function CsmAppRoot({ config, core, plugins, + observability, observabilityRuleTypeRegistry, }; @@ -101,7 +107,7 @@ export function CsmAppRoot({ - + @@ -142,7 +148,7 @@ export const renderApp = ({ }); ReactDOM.render( - ({ totalPageViews: 0 }); diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/Panels/MainFilters.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/Panels/MainFilters.tsx index d04bcb79a53e1..4b31ee63eb7ad 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/Panels/MainFilters.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/Panels/MainFilters.tsx @@ -6,14 +6,10 @@ */ import React from 'react'; -import { EuiFlexItem } from '@elastic/eui'; -import { EnvironmentFilter } from '../../../shared/EnvironmentFilter'; import { ServiceNameFilter } from '../URLFilter/ServiceNameFilter'; import { useFetcher } from '../../../../hooks/use_fetcher'; import { RUM_AGENT_NAMES } from '../../../../../common/agent_name'; import { useUrlParams } from '../../../../context/url_params_context/use_url_params'; -import { UserPercentile } from '../UserPercentile'; -import { useBreakPoints } from '../../../../hooks/use_break_points'; export function MainFilters() { const { @@ -39,25 +35,11 @@ export function MainFilters() { ); const rumServiceNames = data?.rumServices ?? []; - const { isSmall } = useBreakPoints(); - - // on mobile we want it to take full width - const envStyle = isSmall ? {} : { maxWidth: 200 }; return ( - <> - - - - - - - - - - + ); } diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/RumHome.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/RumHome.tsx index a0b3781a30b20..40f091ad1a9fc 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/RumHome.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/RumHome.tsx @@ -5,33 +5,58 @@ * 2.0. */ -import { EuiFlexGroup, EuiFlexItem, EuiTitle } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import React from 'react'; import { i18n } from '@kbn/i18n'; import { RumOverview } from '../RumDashboard'; import { CsmSharedContextProvider } from './CsmSharedContext'; import { MainFilters } from './Panels/MainFilters'; import { DatePicker } from '../../shared/DatePicker'; +import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context'; +import { EnvironmentFilter } from '../../shared/EnvironmentFilter'; +import { UserPercentile } from './UserPercentile'; +import { useBreakPoints } from '../../../hooks/use_break_points'; export const UX_LABEL = i18n.translate('xpack.apm.ux.title', { defaultMessage: 'User Experience', }); export function RumHome() { + const { observability } = useApmPluginContext(); + const PageTemplateComponent = observability.navigation.PageTemplate; + + const { isSmall } = useBreakPoints(); + + const envStyle = isSmall ? {} : { maxWidth: 200 }; + return ( - - - -

{UX_LABEL}

-
-
- - - - -
- + , +
+ +
, + , + , + ], + }} + > + +
); } + +export function UxHomeHeaderItems() { + return ( + + + + + + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/index.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/index.tsx index 9bdad14eb8a18..e42cb5b2989b6 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/index.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/index.tsx @@ -25,19 +25,16 @@ export function RumOverview() { }, []); return ( - <> - - - - - - - - - - - - - + + + + + + + + + + + ); } diff --git a/x-pack/plugins/apm/public/context/apm_plugin/apm_plugin_context.tsx b/x-pack/plugins/apm/public/context/apm_plugin/apm_plugin_context.tsx index ec42a11783273..b332c491f6e55 100644 --- a/x-pack/plugins/apm/public/context/apm_plugin/apm_plugin_context.tsx +++ b/x-pack/plugins/apm/public/context/apm_plugin/apm_plugin_context.tsx @@ -11,6 +11,7 @@ import type { ObservabilityRuleTypeRegistry } from '../../../../observability/pu import { ConfigSchema } from '../..'; import { ApmPluginSetupDeps } from '../../plugin'; import { MapsStartApi } from '../../../../maps/public'; +import { ObservabilityPublicStart } from '../../../../observability/public'; export interface ApmPluginContextValue { appMountParameters: AppMountParameters; @@ -18,6 +19,7 @@ export interface ApmPluginContextValue { core: CoreStart; plugins: ApmPluginSetupDeps & { maps?: MapsStartApi }; observabilityRuleTypeRegistry: ObservabilityRuleTypeRegistry; + observability: ObservabilityPublicStart; } export const ApmPluginContext = createContext({} as ApmPluginContextValue); diff --git a/x-pack/plugins/apm/public/plugin.ts b/x-pack/plugins/apm/public/plugin.ts index 845b18b707f93..24db9e0cd8504 100644 --- a/x-pack/plugins/apm/public/plugin.ts +++ b/x-pack/plugins/apm/public/plugin.ts @@ -67,8 +67,8 @@ export interface ApmPluginStartDeps { licensing: void; maps?: MapsStartApi; ml?: MlPluginStart; - observability: ObservabilityPublicStart; triggersActionsUi: TriggersAndActionsUIPublicPluginStart; + observability: ObservabilityPublicStart; } export class ApmPlugin implements Plugin { @@ -150,6 +150,26 @@ export class ApmPlugin implements Plugin { }, }); + plugins.observability.navigation.registerSections( + of([ + { + label: 'User Experience', + sortKey: 201, + entries: [ + { + label: i18n.translate('xpack.apm.ux.overview.heading', { + defaultMessage: 'Overview', + }), + app: 'ux', + path: '/', + matchFullPath: true, + ignoreTrailingSlash: true, + }, + ], + }, + ]) + ); + core.application.register({ id: 'apm', title: 'APM', @@ -231,7 +251,7 @@ export class ApmPlugin implements Plugin { async mount(appMountParameters: AppMountParameters) { // Load application bundle and Get start service const [{ renderApp }, [coreStart, corePlugins]] = await Promise.all([ - import('./application/csmApp'), + import('./application/uxApp'), core.getStartServices(), ]); diff --git a/x-pack/plugins/observability/public/hooks/use_kibana_ui_settings.tsx b/x-pack/plugins/observability/public/hooks/use_kibana_ui_settings.tsx index 14c4e0a7cb9af..d16fbf6f7cd14 100644 --- a/x-pack/plugins/observability/public/hooks/use_kibana_ui_settings.tsx +++ b/x-pack/plugins/observability/public/hooks/use_kibana_ui_settings.tsx @@ -5,8 +5,8 @@ * 2.0. */ -import { usePluginContext } from './use_plugin_context'; import { UI_SETTINGS } from '../../../../../src/plugins/data/public'; +import { useKibana } from '../../../../../src/plugins/kibana_react/public'; export { UI_SETTINGS }; @@ -14,6 +14,8 @@ type SettingKeys = keyof typeof UI_SETTINGS; type SettingValues = typeof UI_SETTINGS[SettingKeys]; export function useKibanaUISettings(key: SettingValues): T { - const { core } = usePluginContext(); - return core.uiSettings.get(key); + const { + services: { uiSettings }, + } = useKibana(); + return uiSettings!.get(key); } diff --git a/x-pack/plugins/observability/public/index.ts b/x-pack/plugins/observability/public/index.ts index a49d3461529c2..030046ce7bed9 100644 --- a/x-pack/plugins/observability/public/index.ts +++ b/x-pack/plugins/observability/public/index.ts @@ -57,6 +57,7 @@ export { useFetcher, FETCH_STATUS } from './hooks/use_fetcher'; export * from './typings'; export { useChartTheme } from './hooks/use_chart_theme'; +export { useBreadcrumbs } from './hooks/use_breadcrumbs'; export { useTheme } from './hooks/use_theme'; export { getApmTraceUrl } from './utils/get_apm_trace_url'; export { createExploratoryViewUrl } from './components/shared/exploratory_view/configurations/utils'; diff --git a/x-pack/plugins/uptime/public/apps/uptime_app.tsx b/x-pack/plugins/uptime/public/apps/uptime_app.tsx index 4d99e877291b5..60717db8af27d 100644 --- a/x-pack/plugins/uptime/public/apps/uptime_app.tsx +++ b/x-pack/plugins/uptime/public/apps/uptime_app.tsx @@ -7,8 +7,7 @@ import React, { useEffect } from 'react'; import { Provider as ReduxProvider } from 'react-redux'; import { Router } from 'react-router-dom'; -import styled from 'styled-components'; -import { EuiPage, EuiErrorBoundary } from '@elastic/eui'; +import { EuiErrorBoundary } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { I18nStart, ChromeBreadcrumb, CoreStart, AppMountParameters } from 'kibana/public'; import { @@ -62,18 +61,6 @@ export interface UptimeAppProps { appMountParameters: AppMountParameters; } -const StyledPage = styled(EuiPage)` - display: flex; - flex-grow: 1; - flex-shrink: 0; - flex-basis: auto; - flex-direction: column; - - > * { - flex-shrink: 0; - } -`; - const Application = (props: UptimeAppProps) => { const { basePath, @@ -131,7 +118,7 @@ const Application = (props: UptimeAppProps) => { - +
@@ -139,7 +126,7 @@ const Application = (props: UptimeAppProps) => {
- +
From 7b4b7132375fed65a5ef3f0fccfa361371bf2f20 Mon Sep 17 00:00:00 2001 From: CJ Cenizal Date: Fri, 4 Jun 2021 09:22:40 -0700 Subject: [PATCH 58/90] Update CODEOWNERS to ping Stack Management team. (#101350) --- .github/CODEOWNERS | 48 +++++++++++++++++++++++----------------------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 725708e8a8af2..0cf5fc4e0dfd0 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -128,7 +128,7 @@ /x-pack/test/functional_basic/apps/ml/ @elastic/ml-ui /x-pack/test/functional_with_es_ssl/apps/ml/ @elastic/ml-ui -# ML team owns and maintains the transform plugin despite it living in the Elasticsearch management section. +# ML team owns and maintains the transform plugin despite it living in the Data management section. /x-pack/plugins/transform/ @elastic/ml-ui /x-pack/test/accessibility/apps/transform.ts @elastic/ml-ui /x-pack/test/api_integration/apis/transform/ @elastic/ml-ui @@ -305,29 +305,29 @@ /x-pack/plugins/enterprise_search/server/collectors/workplace_search/ @elastic/workplace-search-frontend /x-pack/plugins/enterprise_search/server/saved_objects/workplace_search/ @elastic/workplace-search-frontend -# Elasticsearch UI -/src/plugins/dev_tools/ @elastic/es-ui -/src/plugins/console/ @elastic/es-ui -/src/plugins/es_ui_shared/ @elastic/es-ui -/x-pack/plugins/cross_cluster_replication/ @elastic/es-ui -/x-pack/plugins/index_lifecycle_management/ @elastic/es-ui -/x-pack/plugins/console_extensions/ @elastic/es-ui -/x-pack/plugins/grokdebugger/ @elastic/es-ui -/x-pack/plugins/index_management/ @elastic/es-ui -/x-pack/plugins/license_api_guard/ @elastic/es-ui -/x-pack/plugins/license_management/ @elastic/es-ui -/x-pack/plugins/painless_lab/ @elastic/es-ui -/x-pack/plugins/remote_clusters/ @elastic/es-ui -/x-pack/plugins/rollup/ @elastic/es-ui -/x-pack/plugins/searchprofiler/ @elastic/es-ui -/x-pack/plugins/snapshot_restore/ @elastic/es-ui -/x-pack/plugins/upgrade_assistant/ @elastic/es-ui -/x-pack/plugins/watcher/ @elastic/es-ui -/x-pack/plugins/ingest_pipelines/ @elastic/es-ui -/packages/kbn-ace/ @elastic/es-ui -/packages/kbn-monaco/ @elastic/es-ui -#CC# /x-pack/plugins/console_extensions/ @elastic/es-ui -#CC# /x-pack/plugins/cross_cluster_replication/ @elastic/es-ui +# Stack Management +/src/plugins/dev_tools/ @elastic/kibana-stack-management +/src/plugins/console/ @elastic/kibana-stack-management +/src/plugins/es_ui_shared/ @elastic/kibana-stack-management +/x-pack/plugins/cross_cluster_replication/ @elastic/kibana-stack-management +/x-pack/plugins/index_lifecycle_management/ @elastic/kibana-stack-management +/x-pack/plugins/console_extensions/ @elastic/kibana-stack-management +/x-pack/plugins/grokdebugger/ @elastic/kibana-stack-management +/x-pack/plugins/index_management/ @elastic/kibana-stack-management +/x-pack/plugins/license_api_guard/ @elastic/kibana-stack-management +/x-pack/plugins/license_management/ @elastic/kibana-stack-management +/x-pack/plugins/painless_lab/ @elastic/kibana-stack-management +/x-pack/plugins/remote_clusters/ @elastic/kibana-stack-management +/x-pack/plugins/rollup/ @elastic/kibana-stack-management +/x-pack/plugins/searchprofiler/ @elastic/kibana-stack-management +/x-pack/plugins/snapshot_restore/ @elastic/kibana-stack-management +/x-pack/plugins/upgrade_assistant/ @elastic/kibana-stack-management +/x-pack/plugins/watcher/ @elastic/kibana-stack-management +/x-pack/plugins/ingest_pipelines/ @elastic/kibana-stack-management +/packages/kbn-ace/ @elastic/kibana-stack-management +/packages/kbn-monaco/ @elastic/kibana-stack-management +#CC# /x-pack/plugins/console_extensions/ @elastic/kibana-stack-management +#CC# /x-pack/plugins/cross_cluster_replication/ @elastic/kibana-stack-management # Security Solution /x-pack/test/endpoint_api_integration_no_ingest/ @elastic/security-solution From 081cf98f618bbca9e729bffb02bd305feea6af13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20St=C3=BCrmer?= Date: Fri, 4 Jun 2021 19:13:35 +0200 Subject: [PATCH 59/90] [Logs UI] Fix the LogStream story to work with KIPs (#100862) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../log_stream/log_stream.stories.mdx | 50 ++++++++++++++++- .../hooks/use_kibana_index_patterns.mock.tsx | 55 ++++++++++++------- 2 files changed, 84 insertions(+), 21 deletions(-) diff --git a/x-pack/plugins/infra/public/components/log_stream/log_stream.stories.mdx b/x-pack/plugins/infra/public/components/log_stream/log_stream.stories.mdx index 7f1636b00d24e..87419a9bfbe78 100644 --- a/x-pack/plugins/infra/public/components/log_stream/log_stream.stories.mdx +++ b/x-pack/plugins/infra/public/components/log_stream/log_stream.stories.mdx @@ -3,10 +3,11 @@ import { defer, of, Subject } from 'rxjs'; import { delay } from 'rxjs/operators'; import { I18nProvider } from '@kbn/i18n/react'; +import { KBN_FIELD_TYPES } from '../../../../../../src/plugins/data/public'; import { EuiThemeProvider } from '../../../../../../src/plugins/kibana_react/common'; import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public'; - import { LOG_ENTRIES_SEARCH_STRATEGY } from '../../../common/search_strategies/log_entries/log_entries'; +import { createIndexPatternMock, createIndexPatternsMock } from '../../hooks/use_kibana_index_patterns.mock'; import { DEFAULT_SOURCE_CONFIGURATION } from '../../test_utils/source_configuration'; import { generateFakeEntries, ENTRIES_EMPTY } from '../../test_utils/entries'; @@ -18,6 +19,45 @@ export const startTimestamp = 1595145600000; export const endTimestamp = startTimestamp + 15 * 60 * 1000; export const dataMock = { + indexPatterns: createIndexPatternsMock(500, [ + createIndexPatternMock({ + id: 'some-test-id', + title: 'mock-index-pattern-*', + timeFieldName: '@timestamp', + fields: [ + { + name: '@timestamp', + type: KBN_FIELD_TYPES.DATE, + searchable: true, + aggregatable: true, + }, + { + name: 'event.dataset', + type: KBN_FIELD_TYPES.STRING, + searchable: true, + aggregatable: true, + }, + { + name: 'host.name', + type: KBN_FIELD_TYPES.STRING, + searchable: true, + aggregatable: true, + }, + { + name: 'log.level', + type: KBN_FIELD_TYPES.STRING, + searchable: true, + aggregatable: true, + }, + { + name: 'message', + type: KBN_FIELD_TYPES.STRING, + searchable: true, + aggregatable: true, + }, + ], + }) + ]), search: { search: ({ params }, options) => { return defer(() => { @@ -68,10 +108,16 @@ export const dataMock = { }; -export const fetch = function (url, params) { +export const fetch = async function (url, params) { switch (url) { case '/api/infra/log_source_configurations/default': return DEFAULT_SOURCE_CONFIGURATION; + case '/api/infra/log_source_configurations/default/status': + return { + data: { + logIndexStatus: 'available', + } + }; default: return {}; } diff --git a/x-pack/plugins/infra/public/hooks/use_kibana_index_patterns.mock.tsx b/x-pack/plugins/infra/public/hooks/use_kibana_index_patterns.mock.tsx index dbf032415cb99..9d3a611cff88d 100644 --- a/x-pack/plugins/infra/public/hooks/use_kibana_index_patterns.mock.tsx +++ b/x-pack/plugins/infra/public/hooks/use_kibana_index_patterns.mock.tsx @@ -21,7 +21,7 @@ import { Pick2 } from '../../common/utility_types'; type MockIndexPattern = Pick< IndexPattern, - 'id' | 'title' | 'type' | 'getTimeField' | 'isTimeBased' | 'getFieldByName' + 'id' | 'title' | 'type' | 'getTimeField' | 'isTimeBased' | 'getFieldByName' | 'getComputedFields' >; export type MockIndexPatternSpec = Pick< IIndexPattern, @@ -35,23 +35,7 @@ export const MockIndexPatternsKibanaContextProvider: React.FC<{ mockIndexPatterns: MockIndexPatternSpec[]; }> = ({ asyncDelay, children, mockIndexPatterns }) => { const indexPatterns = useMemo( - () => - createIndexPatternsMock( - asyncDelay, - mockIndexPatterns.map(({ id, title, type = undefined, fields, timeFieldName }) => { - const indexPatternFields = fields.map((fieldSpec) => new IndexPatternField(fieldSpec)); - - return { - id, - title, - type, - getTimeField: () => indexPatternFields.find(({ name }) => name === timeFieldName), - isTimeBased: () => timeFieldName != null, - getFieldByName: (fieldName) => - indexPatternFields.find(({ name }) => name === fieldName), - }; - }) - ), + () => createIndexPatternsMock(asyncDelay, mockIndexPatterns.map(createIndexPatternMock)), [asyncDelay, mockIndexPatterns] ); @@ -71,7 +55,7 @@ export const MockIndexPatternsKibanaContextProvider: React.FC<{ ); }; -const createIndexPatternsMock = ( +export const createIndexPatternsMock = ( asyncDelay: number, indexPatterns: MockIndexPattern[] ): { @@ -93,3 +77,36 @@ const createIndexPatternsMock = ( }, }; }; + +export const createIndexPatternMock = ({ + id, + title, + type = undefined, + fields, + timeFieldName, +}: MockIndexPatternSpec): MockIndexPattern => { + const indexPatternFields = fields.map((fieldSpec) => new IndexPatternField(fieldSpec)); + + return { + id, + title, + type, + getTimeField: () => indexPatternFields.find(({ name }) => name === timeFieldName), + isTimeBased: () => timeFieldName != null, + getFieldByName: (fieldName) => indexPatternFields.find(({ name }) => name === fieldName), + getComputedFields: () => ({ + docvalueFields: [], + runtimeFields: indexPatternFields.reduce((accumulatedRuntimeFields, field) => { + if (field.runtimeField != null) { + return { + ...accumulatedRuntimeFields, + [field.name]: field.runtimeField, + }; + } + return accumulatedRuntimeFields; + }, {}), + scriptFields: {}, + storedFields: [], + }), + }; +}; From 090d0abd11c126b4f4fe1099bd62311b6554ee9e Mon Sep 17 00:00:00 2001 From: Spencer Date: Fri, 4 Jun 2021 10:17:00 -0700 Subject: [PATCH 60/90] [ts] migrate root test dir to project refs (#99148) Co-authored-by: spalger --- .../functional_test_runner/public_types.ts | 6 + ...r_context.d.ts => ftr_provider_context.ts} | 3 +- test/accessibility/services/a11y/a11y.ts | 136 +- test/accessibility/services/a11y/index.ts | 2 +- test/accessibility/services/index.ts | 4 +- test/functional/page_objects/common_page.ts | 809 ++++++----- test/functional/page_objects/console_page.ts | 150 +- test/functional/page_objects/context_page.ts | 144 +- .../functional/page_objects/dashboard_page.ts | 1091 ++++++++------- test/functional/page_objects/discover_page.ts | 829 ++++++----- test/functional/page_objects/error_page.ts | 36 +- test/functional/page_objects/header_page.ts | 144 +- test/functional/page_objects/home_page.ts | 214 ++- test/functional/page_objects/index.ts | 96 +- .../page_objects/legacy/data_table_vis.ts | 133 +- test/functional/page_objects/login_page.ts | 98 +- .../management/saved_objects_page.ts | 559 ++++---- test/functional/page_objects/newsfeed_page.ts | 86 +- test/functional/page_objects/settings_page.ts | 1235 ++++++++--------- test/functional/page_objects/share_page.ts | 114 +- .../functional/page_objects/tag_cloud_page.ts | 47 +- test/functional/page_objects/tile_map_page.ts | 146 +- test/functional/page_objects/time_picker.ts | 457 +++--- .../page_objects/time_to_visualize_page.ts | 183 ++- test/functional/page_objects/timelion_page.ts | 118 +- .../page_objects/vega_chart_page.ts | 153 +- .../page_objects/visual_builder_page.ts | 1101 ++++++++------- .../page_objects/visualize_chart_page.ts | 1040 +++++++------- .../page_objects/visualize_editor_page.ts | 870 ++++++------ .../functional/page_objects/visualize_page.ts | 740 +++++----- test/functional/services/combo_box.ts | 4 +- .../services/dashboard/add_panel.ts | 15 +- .../services/dashboard/expectations.ts | 11 +- .../services/dashboard/panel_actions.ts | 10 +- .../services/dashboard/visualizations.ts | 60 +- test/functional/services/data_grid.ts | 8 +- test/functional/services/doc_table.ts | 10 +- test/functional/services/embedding.ts | 4 +- test/functional/services/filter_bar.ts | 17 +- test/functional/services/index.ts | 4 +- test/functional/services/listing_table.ts | 4 +- test/functional/services/monaco_editor.ts | 34 +- test/functional/services/query_bar.ts | 11 +- .../services/remote/prevent_parallel_calls.ts | 59 +- .../saved_query_management_component.ts | 4 +- .../services/visualizations/pie_chart.ts | 36 +- .../plugins/core_app_status/tsconfig.json | 10 +- .../core_provider_plugin/tsconfig.json | 9 +- test/tsconfig.json | 13 +- ...r_context.d.ts => ftr_provider_context.ts} | 3 +- test/visual_regression/services/index.ts | 4 +- .../services/visual_testing/index.ts | 2 +- .../services/visual_testing/visual_testing.ts | 136 +- tsconfig.refs.json | 1 + 54 files changed, 5581 insertions(+), 5632 deletions(-) rename test/accessibility/{ftr_provider_context.d.ts => ftr_provider_context.ts} (78%) rename test/visual_regression/{ftr_provider_context.d.ts => ftr_provider_context.ts} (78%) diff --git a/packages/kbn-test/src/functional_test_runner/public_types.ts b/packages/kbn-test/src/functional_test_runner/public_types.ts index 4a30744c09b51..d94f61e23b8b8 100644 --- a/packages/kbn-test/src/functional_test_runner/public_types.ts +++ b/packages/kbn-test/src/functional_test_runner/public_types.ts @@ -74,6 +74,12 @@ export interface GenericFtrProviderContext< getService(serviceName: 'failureMetadata'): FailureMetadata; getService(serviceName: T): ServiceMap[T]; + /** + * Get the instance of a page object + * @param pageObjectName + */ + getPageObject(pageObjectName: K): PageObjectMap[K]; + /** * Get a map of PageObjects * @param pageObjects diff --git a/test/accessibility/ftr_provider_context.d.ts b/test/accessibility/ftr_provider_context.ts similarity index 78% rename from test/accessibility/ftr_provider_context.d.ts rename to test/accessibility/ftr_provider_context.ts index 4c827393e1ef3..a1a29f50b7761 100644 --- a/test/accessibility/ftr_provider_context.d.ts +++ b/test/accessibility/ftr_provider_context.ts @@ -6,9 +6,10 @@ * Side Public License, v 1. */ -import { GenericFtrProviderContext } from '@kbn/test'; +import { GenericFtrProviderContext, GenericFtrService } from '@kbn/test'; import { pageObjects } from './page_objects'; import { services } from './services'; export type FtrProviderContext = GenericFtrProviderContext; +export class FtrService extends GenericFtrService {} diff --git a/test/accessibility/services/a11y/a11y.ts b/test/accessibility/services/a11y/a11y.ts index ea205e8121eba..4b01b0dd3b953 100644 --- a/test/accessibility/services/a11y/a11y.ts +++ b/test/accessibility/services/a11y/a11y.ts @@ -9,7 +9,7 @@ import chalk from 'chalk'; import testSubjectToCss from '@kbn/test-subj-selector'; -import { FtrProviderContext } from '../../ftr_provider_context'; +import { FtrService } from '../../ftr_provider_context'; import { AxeReport, printResult } from './axe_report'; // @ts-ignore JS that is run in browser as is import { analyzeWithAxe, analyzeWithAxeWithClient } from './analyze_with_axe'; @@ -33,86 +33,84 @@ export const normalizeResult = (report: any) => { return report.result as false | AxeReport; }; -export function A11yProvider({ getService }: FtrProviderContext) { - const browser = getService('browser'); - const Wd = getService('__webdriver__'); - - /** - * Accessibility testing service using the Axe (https://www.deque.com/axe/) - * toolset to validate a11y rules similar to ESLint. In order to test against - * the rules we must load up the UI and feed a full HTML snapshot into Axe. - */ - return new (class Accessibility { - public async testAppSnapshot(options: TestOptions = {}) { - const context = this.getAxeContext(true, options.excludeTestSubj); - const report = await this.captureAxeReport(context); - await this.testAxeReport(report); - } +/** + * Accessibility testing service using the Axe (https://www.deque.com/axe/) + * toolset to validate a11y rules similar to ESLint. In order to test against + * the rules we must load up the UI and feed a full HTML snapshot into Axe. + */ +export class AccessibilityService extends FtrService { + private readonly browser = this.ctx.getService('browser'); + private readonly Wd = this.ctx.getService('__webdriver__'); + + public async testAppSnapshot(options: TestOptions = {}) { + const context = this.getAxeContext(true, options.excludeTestSubj); + const report = await this.captureAxeReport(context); + this.assertValidAxeReport(report); + } - public async testGlobalSnapshot(options: TestOptions = {}) { - const context = this.getAxeContext(false, options.excludeTestSubj); - const report = await this.captureAxeReport(context); - await this.testAxeReport(report); - } + public async testGlobalSnapshot(options: TestOptions = {}) { + const context = this.getAxeContext(false, options.excludeTestSubj); + const report = await this.captureAxeReport(context); + this.assertValidAxeReport(report); + } - private getAxeContext(global: boolean, excludeTestSubj?: string | string[]): AxeContext { - return { - include: global ? undefined : [testSubjectToCss('appA11yRoot')], - exclude: ([] as string[]) - .concat(excludeTestSubj || []) - .map((ts) => [testSubjectToCss(ts)]) - .concat([['[role="graphics-document"][aria-roledescription="visualization"]']]), - }; - } + private getAxeContext(global: boolean, excludeTestSubj?: string | string[]): AxeContext { + return { + include: global ? undefined : [testSubjectToCss('appA11yRoot')], + exclude: ([] as string[]) + .concat(excludeTestSubj || []) + .map((ts) => [testSubjectToCss(ts)]) + .concat([['[role="graphics-document"][aria-roledescription="visualization"]']]), + }; + } - private testAxeReport(report: AxeReport) { - const errorMsgs = []; + private assertValidAxeReport(report: AxeReport) { + const errorMsgs = []; - for (const result of report.violations) { - errorMsgs.push(printResult(chalk.red('VIOLATION'), result)); - } + for (const result of report.violations) { + errorMsgs.push(printResult(chalk.red('VIOLATION'), result)); + } - if (errorMsgs.length) { - throw new Error(`a11y report:\n${errorMsgs.join('\n')}`); - } + if (errorMsgs.length) { + throw new Error(`a11y report:\n${errorMsgs.join('\n')}`); } + } - private async captureAxeReport(context: AxeContext): Promise { - const axeOptions = { - reporter: 'v2', - runOnly: ['wcag2a', 'wcag2aa'], - rules: { - 'color-contrast': { - enabled: false, // disabled because we have too many failures - }, - bypass: { - enabled: false, // disabled because it's too flaky - }, + private async captureAxeReport(context: AxeContext): Promise { + const axeOptions = { + reporter: 'v2', + runOnly: ['wcag2a', 'wcag2aa'], + rules: { + 'color-contrast': { + enabled: false, // disabled because we have too many failures }, - }; - - await (Wd.driver.manage() as any).setTimeouts({ - ...(await (Wd.driver.manage() as any).getTimeouts()), - script: 600000, - }); + bypass: { + enabled: false, // disabled because it's too flaky + }, + }, + }; - const report = normalizeResult( - await browser.executeAsync(analyzeWithAxe, context, axeOptions) - ); + await this.Wd.driver.manage().setTimeouts({ + ...(await this.Wd.driver.manage().getTimeouts()), + script: 600000, + }); - if (report !== false) { - return report; - } + const report = normalizeResult( + await this.browser.executeAsync(analyzeWithAxe, context, axeOptions) + ); - const withClientReport = normalizeResult( - await browser.executeAsync(analyzeWithAxeWithClient, context, axeOptions) - ); + if (report !== false) { + return report; + } - if (withClientReport === false) { - throw new Error('Attempted to analyze with axe but failed to load axe client'); - } + const withClientReport = normalizeResult( + await this.browser.executeAsync(analyzeWithAxeWithClient, context, axeOptions) + ); - return withClientReport; + if (withClientReport === false) { + throw new Error('Attempted to analyze with axe but failed to load axe client'); } - })(); + + return withClientReport; + } } diff --git a/test/accessibility/services/a11y/index.ts b/test/accessibility/services/a11y/index.ts index 79912dd99d326..642b170c4e077 100644 --- a/test/accessibility/services/a11y/index.ts +++ b/test/accessibility/services/a11y/index.ts @@ -6,4 +6,4 @@ * Side Public License, v 1. */ -export { A11yProvider } from './a11y'; +export * from './a11y'; diff --git a/test/accessibility/services/index.ts b/test/accessibility/services/index.ts index ec3bf534590b3..ef5674d4011fb 100644 --- a/test/accessibility/services/index.ts +++ b/test/accessibility/services/index.ts @@ -7,9 +7,9 @@ */ import { services as kibanaFunctionalServices } from '../../functional/services'; -import { A11yProvider } from './a11y'; +import { AccessibilityService } from './a11y'; export const services = { ...kibanaFunctionalServices, - a11y: A11yProvider, + a11y: AccessibilityService, }; diff --git a/test/functional/page_objects/common_page.ts b/test/functional/page_objects/common_page.ts index bc60b8ce5f19c..49d56d6f43784 100644 --- a/test/functional/page_objects/common_page.ts +++ b/test/functional/page_objects/common_page.ts @@ -11,470 +11,465 @@ import expect from '@kbn/expect'; // @ts-ignore import fetch from 'node-fetch'; import { getUrl } from '@kbn/test'; -import { FtrProviderContext } from '../ftr_provider_context'; - -export function CommonPageProvider({ getService, getPageObjects }: FtrProviderContext) { - const log = getService('log'); - const config = getService('config'); - const browser = getService('browser'); - const retry = getService('retry'); - const find = getService('find'); - const globalNav = getService('globalNav'); - const testSubjects = getService('testSubjects'); - const PageObjects = getPageObjects(['login']); - - const defaultTryTimeout = config.get('timeouts.try'); - const defaultFindTimeout = config.get('timeouts.find'); - - interface NavigateProps { - appConfig: {}; - ensureCurrentUrl: boolean; - shouldLoginIfPrompted: boolean; - useActualUrl: boolean; - insertTimestamp: boolean; - } - - class CommonPage { - /** - * Logins to Kibana as default user and navigates to provided app - * @param appUrl Kibana URL - */ - private async loginIfPrompted(appUrl: string, insertTimestamp: boolean) { - // Disable the welcome screen. This is relevant for environments - // which don't allow to use the yml setting, e.g. cloud production. - // It is done here so it applies to logins but also to a login re-use. - await browser.setLocalStorageItem('home:welcome:show', 'false'); - - let currentUrl = await browser.getCurrentUrl(); - log.debug(`currentUrl = ${currentUrl}\n appUrl = ${appUrl}`); - await testSubjects.find('kibanaChrome', 6 * defaultFindTimeout); // 60 sec waiting - const loginPage = currentUrl.includes('/login'); - const wantedLoginPage = appUrl.includes('/login') || appUrl.includes('/logout'); - - if (loginPage && !wantedLoginPage) { - log.debug('Found login page'); - if (config.get('security.disableTestUser')) { - await PageObjects.login.login( - config.get('servers.kibana.username'), - config.get('servers.kibana.password') - ); - } else { - await PageObjects.login.login('test_user', 'changeme'); - } - - await find.byCssSelector( - '[data-test-subj="kibanaChrome"] nav:not(.ng-hide)', - 6 * defaultFindTimeout +import { FtrService } from '../ftr_provider_context'; + +interface NavigateProps { + appConfig: {}; + ensureCurrentUrl: boolean; + shouldLoginIfPrompted: boolean; + useActualUrl: boolean; + insertTimestamp: boolean; +} +export class CommonPageObject extends FtrService { + private readonly log = this.ctx.getService('log'); + private readonly config = this.ctx.getService('config'); + private readonly browser = this.ctx.getService('browser'); + private readonly retry = this.ctx.getService('retry'); + private readonly find = this.ctx.getService('find'); + private readonly globalNav = this.ctx.getService('globalNav'); + private readonly testSubjects = this.ctx.getService('testSubjects'); + private readonly loginPage = this.ctx.getPageObject('login'); + + private readonly defaultTryTimeout = this.config.get('timeouts.try'); + private readonly defaultFindTimeout = this.config.get('timeouts.find'); + + /** + * Logins to Kibana as default user and navigates to provided app + * @param appUrl Kibana URL + */ + private async loginIfPrompted(appUrl: string, insertTimestamp: boolean) { + // Disable the welcome screen. This is relevant for environments + // which don't allow to use the yml setting, e.g. cloud production. + // It is done here so it applies to logins but also to a login re-use. + await this.browser.setLocalStorageItem('home:welcome:show', 'false'); + + let currentUrl = await this.browser.getCurrentUrl(); + this.log.debug(`currentUrl = ${currentUrl}\n appUrl = ${appUrl}`); + await this.testSubjects.find('kibanaChrome', 6 * this.defaultFindTimeout); // 60 sec waiting + const loginPage = currentUrl.includes('/login'); + const wantedLoginPage = appUrl.includes('/login') || appUrl.includes('/logout'); + + if (loginPage && !wantedLoginPage) { + this.log.debug('Found login page'); + if (this.config.get('security.disableTestUser')) { + await this.loginPage.login( + this.config.get('servers.kibana.username'), + this.config.get('servers.kibana.password') ); - await browser.get(appUrl, insertTimestamp); - currentUrl = await browser.getCurrentUrl(); - log.debug(`Finished login process currentUrl = ${currentUrl}`); + } else { + await this.loginPage.login('test_user', 'changeme'); } - return currentUrl; - } - private async navigate(navigateProps: NavigateProps) { - const { - appConfig, - ensureCurrentUrl, - shouldLoginIfPrompted, - useActualUrl, - insertTimestamp, - } = navigateProps; - const appUrl = getUrl.noAuth(config.get('servers.kibana'), appConfig); - - await retry.try(async () => { - if (useActualUrl) { - log.debug(`navigateToActualUrl ${appUrl}`); - await browser.get(appUrl); - } else { - log.debug(`navigateToUrl ${appUrl}`); - await browser.get(appUrl, insertTimestamp); - } + await this.find.byCssSelector( + '[data-test-subj="kibanaChrome"] nav:not(.ng-hide)', + 6 * this.defaultFindTimeout + ); + await this.browser.get(appUrl, insertTimestamp); + currentUrl = await this.browser.getCurrentUrl(); + this.log.debug(`Finished login process currentUrl = ${currentUrl}`); + } + return currentUrl; + } - // accept alert if it pops up - const alert = await browser.getAlert(); - await alert?.accept(); + private async navigate(navigateProps: NavigateProps) { + const { + appConfig, + ensureCurrentUrl, + shouldLoginIfPrompted, + useActualUrl, + insertTimestamp, + } = navigateProps; + const appUrl = getUrl.noAuth(this.config.get('servers.kibana'), appConfig); + + await this.retry.try(async () => { + if (useActualUrl) { + this.log.debug(`navigateToActualUrl ${appUrl}`); + await this.browser.get(appUrl); + } else { + this.log.debug(`navigateToUrl ${appUrl}`); + await this.browser.get(appUrl, insertTimestamp); + } - const currentUrl = shouldLoginIfPrompted - ? await this.loginIfPrompted(appUrl, insertTimestamp) - : await browser.getCurrentUrl(); + // accept alert if it pops up + const alert = await this.browser.getAlert(); + await alert?.accept(); - if (ensureCurrentUrl && !currentUrl.includes(appUrl)) { - throw new Error(`expected ${currentUrl}.includes(${appUrl})`); - } - }); - } + const currentUrl = shouldLoginIfPrompted + ? await this.loginIfPrompted(appUrl, insertTimestamp) + : await this.browser.getCurrentUrl(); - /** - * Navigates browser using the pathname from the appConfig and subUrl as the hash - * @param appName As defined in the apps config, e.g. 'home' - * @param subUrl The route after the hash (#), e.g. '/tutorial_directory/sampleData' - * @param args additional arguments - */ - public async navigateToUrl( - appName: string, - subUrl?: string, - { - basePath = '', - ensureCurrentUrl = true, - shouldLoginIfPrompted = true, - useActualUrl = false, - insertTimestamp = true, - shouldUseHashForSubUrl = true, - } = {} - ) { - const appConfig: { pathname: string; hash?: string } = { - pathname: `${basePath}${config.get(['apps', appName]).pathname}`, - }; - - if (shouldUseHashForSubUrl) { - appConfig.hash = useActualUrl ? subUrl : `/${appName}/${subUrl}`; - } else { - appConfig.pathname += `/${subUrl}`; + if (ensureCurrentUrl && !currentUrl.includes(appUrl)) { + throw new Error(`expected ${currentUrl}.includes(${appUrl})`); } + }); + } - await this.navigate({ - appConfig, - ensureCurrentUrl, - shouldLoginIfPrompted, - useActualUrl, - insertTimestamp, - }); + /** + * Navigates browser using the pathname from the appConfig and subUrl as the hash + * @param appName As defined in the apps config, e.g. 'home' + * @param subUrl The route after the hash (#), e.g. '/tutorial_directory/sampleData' + * @param args additional arguments + */ + public async navigateToUrl( + appName: string, + subUrl?: string, + { + basePath = '', + ensureCurrentUrl = true, + shouldLoginIfPrompted = true, + useActualUrl = false, + insertTimestamp = true, + shouldUseHashForSubUrl = true, + } = {} + ) { + const appConfig: { pathname: string; hash?: string } = { + pathname: `${basePath}${this.config.get(['apps', appName]).pathname}`, + }; + + if (shouldUseHashForSubUrl) { + appConfig.hash = useActualUrl ? subUrl : `/${appName}/${subUrl}`; + } else { + appConfig.pathname += `/${subUrl}`; } - /** - * Navigates browser using the pathname from the appConfig and subUrl as the extended path. - * This was added to be able to test an application that uses browser history over hash history. - * @param appName As defined in the apps config, e.g. 'home' - * @param subUrl The route after the appUrl, e.g. '/tutorial_directory/sampleData' - * @param args additional arguments - */ - public async navigateToUrlWithBrowserHistory( - appName: string, - subUrl?: string, - search?: string, - { - basePath = '', - ensureCurrentUrl = true, - shouldLoginIfPrompted = true, - useActualUrl = true, - insertTimestamp = true, - } = {} - ) { - const appConfig = { - // subUrl following the basePath, assumes no hashes. Ex: 'app/endpoint/management' - pathname: `${basePath}${config.get(['apps', appName]).pathname}${subUrl}`, - search, - }; - - await this.navigate({ - appConfig, - ensureCurrentUrl, - shouldLoginIfPrompted, - useActualUrl, - insertTimestamp, - }); - } + await this.navigate({ + appConfig, + ensureCurrentUrl, + shouldLoginIfPrompted, + useActualUrl, + insertTimestamp, + }); + } - /** - * Navigates browser using only the pathname from the appConfig - * @param appName As defined in the apps config, e.g. 'kibana' - * @param hash The route after the hash (#), e.g. 'management/kibana/settings' - * @param args additional arguments - */ - async navigateToActualUrl( - appName: string, - hash?: string, - { basePath = '', ensureCurrentUrl = true, shouldLoginIfPrompted = true } = {} - ) { - await this.navigateToUrl(appName, hash, { - basePath, - ensureCurrentUrl, - shouldLoginIfPrompted, - useActualUrl: true, - }); - } + /** + * Navigates browser using the pathname from the appConfig and subUrl as the extended path. + * This was added to be able to test an application that uses browser history over hash history. + * @param appName As defined in the apps config, e.g. 'home' + * @param subUrl The route after the appUrl, e.g. '/tutorial_directory/sampleData' + * @param args additional arguments + */ + public async navigateToUrlWithBrowserHistory( + appName: string, + subUrl?: string, + search?: string, + { + basePath = '', + ensureCurrentUrl = true, + shouldLoginIfPrompted = true, + useActualUrl = true, + insertTimestamp = true, + } = {} + ) { + const appConfig = { + // subUrl following the basePath, assumes no hashes. Ex: 'app/endpoint/management' + pathname: `${basePath}${this.config.get(['apps', appName]).pathname}${subUrl}`, + search, + }; + + await this.navigate({ + appConfig, + ensureCurrentUrl, + shouldLoginIfPrompted, + useActualUrl, + insertTimestamp, + }); + } - async sleep(sleepMilliseconds: number) { - log.debug(`... sleep(${sleepMilliseconds}) start`); - await delay(sleepMilliseconds); - log.debug(`... sleep(${sleepMilliseconds}) end`); - } + /** + * Navigates browser using only the pathname from the appConfig + * @param appName As defined in the apps config, e.g. 'kibana' + * @param hash The route after the hash (#), e.g. 'management/kibana/settings' + * @param args additional arguments + */ + async navigateToActualUrl( + appName: string, + hash?: string, + { basePath = '', ensureCurrentUrl = true, shouldLoginIfPrompted = true } = {} + ) { + await this.navigateToUrl(appName, hash, { + basePath, + ensureCurrentUrl, + shouldLoginIfPrompted, + useActualUrl: true, + }); + } - async navigateToApp( - appName: string, - { basePath = '', shouldLoginIfPrompted = true, hash = '', insertTimestamp = true } = {} - ) { - let appUrl: string; - if (config.has(['apps', appName])) { - // Legacy applications - const appConfig = config.get(['apps', appName]); - appUrl = getUrl.noAuth(config.get('servers.kibana'), { - pathname: `${basePath}${appConfig.pathname}`, - hash: hash || appConfig.hash, - }); - } else { - appUrl = getUrl.noAuth(config.get('servers.kibana'), { - pathname: `${basePath}/app/${appName}`, - hash, - }); - } + async sleep(sleepMilliseconds: number) { + this.log.debug(`... sleep(${sleepMilliseconds}) start`); + await delay(sleepMilliseconds); + this.log.debug(`... sleep(${sleepMilliseconds}) end`); + } - log.debug('navigating to ' + appName + ' url: ' + appUrl); - - await retry.tryForTime(defaultTryTimeout * 2, async () => { - let lastUrl = await retry.try(async () => { - // since we're using hash URLs, always reload first to force re-render - log.debug('navigate to: ' + appUrl); - await browser.get(appUrl, insertTimestamp); - // accept alert if it pops up - const alert = await browser.getAlert(); - await alert?.accept(); - await this.sleep(700); - log.debug('returned from get, calling refresh'); - await browser.refresh(); - let currentUrl = shouldLoginIfPrompted - ? await this.loginIfPrompted(appUrl, insertTimestamp) - : await browser.getCurrentUrl(); - - if (currentUrl.includes('app/kibana')) { - await testSubjects.find('kibanaChrome'); - } - - currentUrl = (await browser.getCurrentUrl()).replace(/\/\/\w+:\w+@/, '//'); - - const navSuccessful = currentUrl - .replace(':80/', '/') - .replace(':443/', '/') - .startsWith(appUrl); - - if (!navSuccessful) { - const msg = `App failed to load: ${appName} in ${defaultFindTimeout}ms appUrl=${appUrl} currentUrl=${currentUrl}`; - log.debug(msg); - throw new Error(msg); - } - return currentUrl; - }); - - await retry.tryForTime(defaultFindTimeout, async () => { - await this.sleep(501); - const currentUrl = await browser.getCurrentUrl(); - log.debug('in navigateTo url = ' + currentUrl); - if (lastUrl !== currentUrl) { - lastUrl = currentUrl; - throw new Error('URL changed, waiting for it to settle'); - } - }); + async navigateToApp( + appName: string, + { basePath = '', shouldLoginIfPrompted = true, hash = '', insertTimestamp = true } = {} + ) { + let appUrl: string; + if (this.config.has(['apps', appName])) { + // Legacy applications + const appConfig = this.config.get(['apps', appName]); + appUrl = getUrl.noAuth(this.config.get('servers.kibana'), { + pathname: `${basePath}${appConfig.pathname}`, + hash: hash || appConfig.hash, + }); + } else { + appUrl = getUrl.noAuth(this.config.get('servers.kibana'), { + pathname: `${basePath}/app/${appName}`, + hash, }); } - async waitUntilUrlIncludes(path: string) { - await retry.try(async () => { - const url = await browser.getCurrentUrl(); - if (!url.includes(path)) { - throw new Error('Url not found'); + this.log.debug('navigating to ' + appName + ' url: ' + appUrl); + + await this.retry.tryForTime(this.defaultTryTimeout * 2, async () => { + let lastUrl = await this.retry.try(async () => { + // since we're using hash URLs, always reload first to force re-render + this.log.debug('navigate to: ' + appUrl); + await this.browser.get(appUrl, insertTimestamp); + // accept alert if it pops up + const alert = await this.browser.getAlert(); + await alert?.accept(); + await this.sleep(700); + this.log.debug('returned from get, calling refresh'); + await this.browser.refresh(); + let currentUrl = shouldLoginIfPrompted + ? await this.loginIfPrompted(appUrl, insertTimestamp) + : await this.browser.getCurrentUrl(); + + if (currentUrl.includes('app/kibana')) { + await this.testSubjects.find('kibanaChrome'); } - }); - } - async getSharedItemTitleAndDescription() { - const cssSelector = '[data-shared-item][data-title][data-description]'; - const element = await find.byCssSelector(cssSelector); + currentUrl = (await this.browser.getCurrentUrl()).replace(/\/\/\w+:\w+@/, '//'); - return { - title: await element.getAttribute('data-title'), - description: await element.getAttribute('data-description'), - }; - } + const navSuccessful = currentUrl + .replace(':80/', '/') + .replace(':443/', '/') + .startsWith(appUrl); - async getSharedItemContainers() { - const cssSelector = '[data-shared-items-container]'; - return find.allByCssSelector(cssSelector); - } + if (!navSuccessful) { + const msg = `App failed to load: ${appName} in ${this.defaultFindTimeout}ms appUrl=${appUrl} currentUrl=${currentUrl}`; + this.log.debug(msg); + throw new Error(msg); + } + return currentUrl; + }); - async ensureModalOverlayHidden() { - return retry.try(async () => { - const shown = await testSubjects.exists('confirmModalTitleText'); - if (shown) { - throw new Error('Modal overlay is showing'); + await this.retry.tryForTime(this.defaultFindTimeout, async () => { + await this.sleep(501); + const currentUrl = await this.browser.getCurrentUrl(); + this.log.debug('in navigateTo url = ' + currentUrl); + if (lastUrl !== currentUrl) { + lastUrl = currentUrl; + throw new Error('URL changed, waiting for it to settle'); } }); - } + }); + } - async clickConfirmOnModal(ensureHidden = true) { - log.debug('Clicking modal confirm'); - // make sure this data-test-subj 'confirmModalTitleText' exists because we're going to wait for it to be gone later - await testSubjects.exists('confirmModalTitleText'); - await testSubjects.click('confirmModalConfirmButton'); - if (ensureHidden) { - await this.ensureModalOverlayHidden(); + async waitUntilUrlIncludes(path: string) { + await this.retry.try(async () => { + const url = await this.browser.getCurrentUrl(); + if (!url.includes(path)) { + throw new Error('Url not found'); } - } + }); + } - async pressEnterKey() { - await browser.pressKeys(browser.keys.ENTER); - } + async getSharedItemTitleAndDescription() { + const cssSelector = '[data-shared-item][data-title][data-description]'; + const element = await this.find.byCssSelector(cssSelector); - async pressTabKey() { - await browser.pressKeys(browser.keys.TAB); - } + return { + title: await element.getAttribute('data-title'), + description: await element.getAttribute('data-description'), + }; + } - // Pause the browser at a certain place for debugging - // Not meant for usage in CI, only for dev-usage - async pause() { - return browser.pause(); - } + async getSharedItemContainers() { + const cssSelector = '[data-shared-items-container]'; + return this.find.allByCssSelector(cssSelector); + } - /** - * Clicks cancel button on modal - * @param overlayWillStay pass in true if your test will show multiple modals in succession - */ - async clickCancelOnModal(overlayWillStay = true) { - log.debug('Clicking modal cancel'); - await testSubjects.click('confirmModalCancelButton'); - if (!overlayWillStay) { - await this.ensureModalOverlayHidden(); + async ensureModalOverlayHidden() { + return this.retry.try(async () => { + const shown = await this.testSubjects.exists('confirmModalTitleText'); + if (shown) { + throw new Error('Modal overlay is showing'); } - } + }); + } - async expectConfirmModalOpenState(state: boolean) { - log.debug(`expectConfirmModalOpenState(${state})`); - // we use retry here instead of a simple .exists() check because the modal - // fades in/out, which takes time, and we really only care that at some point - // the modal is either open or closed - await retry.try(async () => { - const actualState = await testSubjects.exists('confirmModalCancelButton'); - expect(actualState).to.equal( - state, - state ? 'Confirm modal should be present' : 'Confirm modal should be hidden' - ); - }); + async clickConfirmOnModal(ensureHidden = true) { + this.log.debug('Clicking modal confirm'); + // make sure this data-test-subj 'confirmModalTitleText' exists because we're going to wait for it to be gone later + await this.testSubjects.exists('confirmModalTitleText'); + await this.testSubjects.click('confirmModalConfirmButton'); + if (ensureHidden) { + await this.ensureModalOverlayHidden(); } + } - async isChromeVisible() { - const globalNavShown = await globalNav.exists(); - return globalNavShown; - } + async pressEnterKey() { + await this.browser.pressKeys(this.browser.keys.ENTER); + } - async isChromeHidden() { - const globalNavShown = await globalNav.exists(); - return !globalNavShown; - } + async pressTabKey() { + await this.browser.pressKeys(this.browser.keys.TAB); + } - async waitForTopNavToBeVisible() { - await retry.try(async () => { - const isNavVisible = await testSubjects.exists('top-nav'); - if (!isNavVisible) { - throw new Error('Local nav not visible yet'); - } - }); + // Pause the browser at a certain place for debugging + // Not meant for usage in CI, only for dev-usage + async pause() { + return this.browser.pause(); + } + + /** + * Clicks cancel button on modal + * @param overlayWillStay pass in true if your test will show multiple modals in succession + */ + async clickCancelOnModal(overlayWillStay = true) { + this.log.debug('Clicking modal cancel'); + await this.testSubjects.click('confirmModalCancelButton'); + if (!overlayWillStay) { + await this.ensureModalOverlayHidden(); } + } - async closeToast() { - const toast = await find.byCssSelector('.euiToast', 6 * defaultFindTimeout); - await toast.moveMouseTo(); - const title = await (await find.byCssSelector('.euiToastHeader__title')).getVisibleText(); + async expectConfirmModalOpenState(state: boolean) { + this.log.debug(`expectConfirmModalOpenState(${state})`); + // we use retry here instead of a simple .exists() check because the modal + // fades in/out, which takes time, and we really only care that at some point + // the modal is either open or closed + await this.retry.try(async () => { + const actualState = await this.testSubjects.exists('confirmModalCancelButton'); + expect(actualState).to.equal( + state, + state ? 'Confirm modal should be present' : 'Confirm modal should be hidden' + ); + }); + } - await find.clickByCssSelector('.euiToast__closeButton'); - return title; - } + async isChromeVisible() { + const globalNavShown = await this.globalNav.exists(); + return globalNavShown; + } - async closeToastIfExists() { - const toastShown = await find.existsByCssSelector('.euiToast'); - if (toastShown) { - try { - await find.clickByCssSelector('.euiToast__closeButton'); - } catch (err) { - // ignore errors, toast clear themselves after timeout - } - } - } + async isChromeHidden() { + const globalNavShown = await this.globalNav.exists(); + return !globalNavShown; + } - async clearAllToasts() { - const toasts = await find.allByCssSelector('.euiToast'); - for (const toastElement of toasts) { - try { - await toastElement.moveMouseTo(); - const closeBtn = await toastElement.findByCssSelector('.euiToast__closeButton'); - await closeBtn.click(); - } catch (err) { - // ignore errors, toast clear themselves after timeout - } + async waitForTopNavToBeVisible() { + await this.retry.try(async () => { + const isNavVisible = await this.testSubjects.exists('top-nav'); + if (!isNavVisible) { + throw new Error('Local nav not visible yet'); } - } + }); + } - async getJsonBodyText() { - if (await find.existsByCssSelector('a[id=rawdata-tab]', defaultFindTimeout)) { - // Firefox has 3 tabs and requires navigation to see Raw output - await find.clickByCssSelector('a[id=rawdata-tab]'); - } - const msgElements = await find.allByCssSelector('body pre'); - if (msgElements.length > 0) { - return await msgElements[0].getVisibleText(); - } else { - // Sometimes Firefox renders Timelion page without tabs and with div#json - const jsonElement = await find.byCssSelector('body div#json'); - return await jsonElement.getVisibleText(); + async closeToast() { + const toast = await this.find.byCssSelector('.euiToast', 6 * this.defaultFindTimeout); + await toast.moveMouseTo(); + const title = await (await this.find.byCssSelector('.euiToastHeader__title')).getVisibleText(); + + await this.find.clickByCssSelector('.euiToast__closeButton'); + return title; + } + + async closeToastIfExists() { + const toastShown = await this.find.existsByCssSelector('.euiToast'); + if (toastShown) { + try { + await this.find.clickByCssSelector('.euiToast__closeButton'); + } catch (err) { + // ignore errors, toast clear themselves after timeout } } + } - async getBodyText() { - const body = await find.byCssSelector('body'); - return await body.getVisibleText(); + async clearAllToasts() { + const toasts = await this.find.allByCssSelector('.euiToast'); + for (const toastElement of toasts) { + try { + await toastElement.moveMouseTo(); + const closeBtn = await toastElement.findByCssSelector('.euiToast__closeButton'); + await closeBtn.click(); + } catch (err) { + // ignore errors, toast clear themselves after timeout + } } + } - async waitForSaveModalToClose() { - log.debug('Waiting for save modal to close'); - await retry.try(async () => { - if (await testSubjects.exists('savedObjectSaveModal')) { - throw new Error('save modal still open'); - } - }); + async getJsonBodyText() { + if (await this.find.existsByCssSelector('a[id=rawdata-tab]', this.defaultFindTimeout)) { + // Firefox has 3 tabs and requires navigation to see Raw output + await this.find.clickByCssSelector('a[id=rawdata-tab]'); } - - async setFileInputPath(path: string) { - log.debug(`Setting the path '${path}' on the file input`); - const input = await find.byCssSelector('.euiFilePicker__input'); - await input.type(path); + const msgElements = await this.find.allByCssSelector('body pre'); + if (msgElements.length > 0) { + return await msgElements[0].getVisibleText(); + } else { + // Sometimes Firefox renders Timelion page without tabs and with div#json + const jsonElement = await this.find.byCssSelector('body div#json'); + return await jsonElement.getVisibleText(); } + } - async scrollKibanaBodyTop() { - await browser.setScrollToById('kibana-body', 0, 0); - } + async getBodyText() { + const body = await this.find.byCssSelector('body'); + return await body.getVisibleText(); + } - /** - * Dismiss Banner if available. - */ - async dismissBanner() { - if (await testSubjects.exists('global-banner-item')) { - const button = await find.byButtonText('Dismiss'); - await button.click(); + async waitForSaveModalToClose() { + this.log.debug('Waiting for save modal to close'); + await this.retry.try(async () => { + if (await this.testSubjects.exists('savedObjectSaveModal')) { + throw new Error('save modal still open'); } - } + }); + } - /** - * Get visible text of the Welcome Banner - */ - async getWelcomeText() { - return await testSubjects.getVisibleText('global-banner-item'); - } + async setFileInputPath(path: string) { + this.log.debug(`Setting the path '${path}' on the file input`); + const input = await this.find.byCssSelector('.euiFilePicker__input'); + await input.type(path); + } + + async scrollKibanaBodyTop() { + await this.browser.setScrollToById('kibana-body', 0, 0); + } - /** - * Clicks on an element, and validates that the desired effect has taken place - * by confirming the existence of a validator - */ - async clickAndValidate( - clickTarget: string, - validator: string, - isValidatorCssString: boolean = false, - topOffset?: number - ) { - await testSubjects.click(clickTarget, undefined, topOffset); - const validate = isValidatorCssString ? find.byCssSelector : testSubjects.exists; - await validate(validator); + /** + * Dismiss Banner if available. + */ + async dismissBanner() { + if (await this.testSubjects.exists('global-banner-item')) { + const button = await this.find.byButtonText('Dismiss'); + await button.click(); } } - return new CommonPage(); + /** + * Get visible text of the Welcome Banner + */ + async getWelcomeText() { + return await this.testSubjects.getVisibleText('global-banner-item'); + } + + /** + * Clicks on an element, and validates that the desired effect has taken place + * by confirming the existence of a validator + */ + async clickAndValidate( + clickTarget: string, + validator: string, + isValidatorCssString: boolean = false, + topOffset?: number + ) { + await this.testSubjects.click(clickTarget, undefined, topOffset); + const validate = isValidatorCssString ? this.find.byCssSelector : this.testSubjects.exists; + await validate(validator); + } } diff --git a/test/functional/page_objects/console_page.ts b/test/functional/page_objects/console_page.ts index 6fb554e6d34a0..77c87f6066e85 100644 --- a/test/functional/page_objects/console_page.ts +++ b/test/functional/page_objects/console_page.ts @@ -7,102 +7,98 @@ */ import { Key } from 'selenium-webdriver'; -import { FtrProviderContext } from '../ftr_provider_context'; +import { FtrService } from '../ftr_provider_context'; import { WebElementWrapper } from '../services/lib/web_element_wrapper'; -export function ConsolePageProvider({ getService }: FtrProviderContext) { - const testSubjects = getService('testSubjects'); - const retry = getService('retry'); - const find = getService('find'); +export class ConsolePageObject extends FtrService { + private readonly testSubjects = this.ctx.getService('testSubjects'); + private readonly retry = this.ctx.getService('retry'); + private readonly find = this.ctx.getService('find'); - class ConsolePage { - public async getVisibleTextFromAceEditor(editor: WebElementWrapper) { - const lines = await editor.findAllByClassName('ace_line_group'); - const linesText = await Promise.all(lines.map(async (line) => await line.getVisibleText())); - return linesText.join('\n'); - } + public async getVisibleTextFromAceEditor(editor: WebElementWrapper) { + const lines = await editor.findAllByClassName('ace_line_group'); + const linesText = await Promise.all(lines.map(async (line) => await line.getVisibleText())); + return linesText.join('\n'); + } - public async getRequestEditor() { - return await testSubjects.find('request-editor'); - } + public async getRequestEditor() { + return await this.testSubjects.find('request-editor'); + } - public async getRequest() { - const requestEditor = await this.getRequestEditor(); - return await this.getVisibleTextFromAceEditor(requestEditor); - } + public async getRequest() { + const requestEditor = await this.getRequestEditor(); + return await this.getVisibleTextFromAceEditor(requestEditor); + } - public async getResponse() { - const responseEditor = await testSubjects.find('response-editor'); - return await this.getVisibleTextFromAceEditor(responseEditor); - } + public async getResponse() { + const responseEditor = await this.testSubjects.find('response-editor'); + return await this.getVisibleTextFromAceEditor(responseEditor); + } - public async clickPlay() { - await testSubjects.click('sendRequestButton'); - } + public async clickPlay() { + await this.testSubjects.click('sendRequestButton'); + } - public async collapseHelp() { - await testSubjects.click('help-close-button'); - } + public async collapseHelp() { + await this.testSubjects.click('help-close-button'); + } - public async openSettings() { - await testSubjects.click('consoleSettingsButton'); - } + public async openSettings() { + await this.testSubjects.click('consoleSettingsButton'); + } - public async setFontSizeSetting(newSize: number) { - await this.openSettings(); + public async setFontSizeSetting(newSize: number) { + await this.openSettings(); - // while the settings form opens/loads this may fail, so retry for a while - await retry.try(async () => { - const fontSizeInput = await testSubjects.find('setting-font-size-input'); - await fontSizeInput.clearValue({ withJS: true }); - await fontSizeInput.click(); - await fontSizeInput.type(String(newSize)); - }); + // while the settings form opens/loads this may fail, so retry for a while + await this.retry.try(async () => { + const fontSizeInput = await this.testSubjects.find('setting-font-size-input'); + await fontSizeInput.clearValue({ withJS: true }); + await fontSizeInput.click(); + await fontSizeInput.type(String(newSize)); + }); - await testSubjects.click('settings-save-button'); - } + await this.testSubjects.click('settings-save-button'); + } - public async getFontSize(editor: WebElementWrapper) { - const aceLine = await editor.findByClassName('ace_line'); - return await aceLine.getComputedStyle('font-size'); - } + public async getFontSize(editor: WebElementWrapper) { + const aceLine = await editor.findByClassName('ace_line'); + return await aceLine.getComputedStyle('font-size'); + } - public async getRequestFontSize() { - return await this.getFontSize(await this.getRequestEditor()); - } + public async getRequestFontSize() { + return await this.getFontSize(await this.getRequestEditor()); + } - public async getEditor() { - return testSubjects.find('console-application'); - } + public async getEditor() { + return this.testSubjects.find('console-application'); + } - public async dismissTutorial() { - try { - const closeButton = await testSubjects.find('help-close-button'); - await closeButton.click(); - } catch (e) { - // Ignore because it is probably not there. - } + public async dismissTutorial() { + try { + const closeButton = await this.testSubjects.find('help-close-button'); + await closeButton.click(); + } catch (e) { + // Ignore because it is probably not there. } + } - public async promptAutocomplete() { - // This focusses the cursor on the bottom of the text area - const editor = await this.getEditor(); - const content = await editor.findByCssSelector('.ace_content'); - await content.click(); - const textArea = await testSubjects.find('console-textarea'); - // There should be autocomplete for this on all license levels - await textArea.pressKeys('\nGET s'); - await textArea.pressKeys([Key.CONTROL, Key.SPACE]); - } + public async promptAutocomplete() { + // This focusses the cursor on the bottom of the text area + const editor = await this.getEditor(); + const content = await editor.findByCssSelector('.ace_content'); + await content.click(); + const textArea = await this.testSubjects.find('console-textarea'); + // There should be autocomplete for this on all license levels + await textArea.pressKeys('\nGET s'); + await textArea.pressKeys([Key.CONTROL, Key.SPACE]); + } - public async hasAutocompleter(): Promise { - try { - return Boolean(await find.byCssSelector('.ace_autocomplete')); - } catch (e) { - return false; - } + public async hasAutocompleter(): Promise { + try { + return Boolean(await this.find.byCssSelector('.ace_autocomplete')); + } catch (e) { + return false; } } - - return new ConsolePage(); } diff --git a/test/functional/page_objects/context_page.ts b/test/functional/page_objects/context_page.ts index b758423d9346d..05ea89cb65b3d 100644 --- a/test/functional/page_objects/context_page.ts +++ b/test/functional/page_objects/context_page.ts @@ -8,93 +8,91 @@ import rison from 'rison-node'; import { getUrl } from '@kbn/test'; -import { FtrProviderContext } from '../ftr_provider_context'; +import { FtrService } from '../ftr_provider_context'; const DEFAULT_INITIAL_STATE = { columns: ['@message'], }; -export function ContextPageProvider({ getService, getPageObjects }: FtrProviderContext) { - const browser = getService('browser'); - const config = getService('config'); - const retry = getService('retry'); - const testSubjects = getService('testSubjects'); - const PageObjects = getPageObjects(['header', 'common']); - const log = getService('log'); +export class ContextPageObject extends FtrService { + private readonly browser = this.ctx.getService('browser'); + private readonly config = this.ctx.getService('config'); + private readonly retry = this.ctx.getService('retry'); + private readonly testSubjects = this.ctx.getService('testSubjects'); + private readonly header = this.ctx.getPageObject('header'); + private readonly common = this.ctx.getPageObject('common'); + private readonly log = this.ctx.getService('log'); - class ContextPage { - public async navigateTo(indexPattern: string, anchorId: string, overrideInitialState = {}) { - const initialState = rison.encode({ - ...DEFAULT_INITIAL_STATE, - ...overrideInitialState, - }); - const appUrl = getUrl.noAuth(config.get('servers.kibana'), { - ...config.get('apps.context'), - hash: `${config.get('apps.context.hash')}/${indexPattern}/${anchorId}?_a=${initialState}`, - }); + public async navigateTo(indexPattern: string, anchorId: string, overrideInitialState = {}) { + const initialState = rison.encode({ + ...DEFAULT_INITIAL_STATE, + ...overrideInitialState, + }); + const contextHash = this.config.get('apps.context.hash'); + const appUrl = getUrl.noAuth(this.config.get('servers.kibana'), { + ...this.config.get('apps.context'), + hash: `${contextHash}/${indexPattern}/${anchorId}?_a=${initialState}`, + }); - log.debug(`browser.get(${appUrl})`); + this.log.debug(`browser.get(${appUrl})`); - await browser.get(appUrl); - await PageObjects.header.awaitGlobalLoadingIndicatorHidden(); - await this.waitUntilContextLoadingHasFinished(); - // For lack of a better way, using a sleep to ensure page is loaded before proceeding - await PageObjects.common.sleep(1000); - } - - public async getPredecessorCountPicker() { - return await testSubjects.find('predecessorsCountPicker'); - } + await this.browser.get(appUrl); + await this.header.awaitGlobalLoadingIndicatorHidden(); + await this.waitUntilContextLoadingHasFinished(); + // For lack of a better way, using a sleep to ensure page is loaded before proceeding + await this.common.sleep(1000); + } - public async getSuccessorCountPicker() { - return await testSubjects.find('successorsCountPicker'); - } + public async getPredecessorCountPicker() { + return await this.testSubjects.find('predecessorsCountPicker'); + } - public async getPredecessorLoadMoreButton() { - return await testSubjects.find('predecessorsLoadMoreButton'); - } + public async getSuccessorCountPicker() { + return await this.testSubjects.find('successorsCountPicker'); + } - public async getSuccessorLoadMoreButton() { - return await testSubjects.find('successorsLoadMoreButton'); - } + public async getPredecessorLoadMoreButton() { + return await this.testSubjects.find('predecessorsLoadMoreButton'); + } - public async clickPredecessorLoadMoreButton() { - log.debug('Click Predecessor Load More Button'); - await retry.try(async () => { - const predecessorButton = await this.getPredecessorLoadMoreButton(); - await predecessorButton.click(); - }); - await this.waitUntilContextLoadingHasFinished(); - await PageObjects.header.waitUntilLoadingHasFinished(); - } + public async getSuccessorLoadMoreButton() { + return await this.testSubjects.find('successorsLoadMoreButton'); + } - public async clickSuccessorLoadMoreButton() { - log.debug('Click Successor Load More Button'); - await retry.try(async () => { - const sucessorButton = await this.getSuccessorLoadMoreButton(); - await sucessorButton.click(); - }); - await this.waitUntilContextLoadingHasFinished(); - await PageObjects.header.waitUntilLoadingHasFinished(); - } + public async clickPredecessorLoadMoreButton() { + this.log.debug('Click Predecessor Load More Button'); + await this.retry.try(async () => { + const predecessorButton = await this.getPredecessorLoadMoreButton(); + await predecessorButton.click(); + }); + await this.waitUntilContextLoadingHasFinished(); + await this.header.waitUntilLoadingHasFinished(); + } - public async waitUntilContextLoadingHasFinished() { - return await retry.try(async () => { - const successorLoadMoreButton = await this.getSuccessorLoadMoreButton(); - const predecessorLoadMoreButton = await this.getPredecessorLoadMoreButton(); - if ( - !( - (await successorLoadMoreButton.isEnabled()) && - (await successorLoadMoreButton.isDisplayed()) && - (await predecessorLoadMoreButton.isEnabled()) && - (await predecessorLoadMoreButton.isDisplayed()) - ) - ) { - throw new Error('loading context rows'); - } - }); - } + public async clickSuccessorLoadMoreButton() { + this.log.debug('Click Successor Load More Button'); + await this.retry.try(async () => { + const sucessorButton = await this.getSuccessorLoadMoreButton(); + await sucessorButton.click(); + }); + await this.waitUntilContextLoadingHasFinished(); + await this.header.waitUntilLoadingHasFinished(); } - return new ContextPage(); + public async waitUntilContextLoadingHasFinished() { + return await this.retry.try(async () => { + const successorLoadMoreButton = await this.getSuccessorLoadMoreButton(); + const predecessorLoadMoreButton = await this.getPredecessorLoadMoreButton(); + if ( + !( + (await successorLoadMoreButton.isEnabled()) && + (await successorLoadMoreButton.isDisplayed()) && + (await predecessorLoadMoreButton.isEnabled()) && + (await predecessorLoadMoreButton.isDisplayed()) + ) + ) { + throw new Error('loading context rows'); + } + }); + } } diff --git a/test/functional/page_objects/dashboard_page.ts b/test/functional/page_objects/dashboard_page.ts index ba75ab75cc6e8..194f0936274e5 100644 --- a/test/functional/page_objects/dashboard_page.ts +++ b/test/functional/page_objects/dashboard_page.ts @@ -9,668 +9,667 @@ export const PIE_CHART_VIS_NAME = 'Visualization PieChart'; export const AREA_CHART_VIS_NAME = 'Visualization漢字 AreaChart'; export const LINE_CHART_VIS_NAME = 'Visualization漢字 LineChart'; -import { FtrProviderContext } from '../ftr_provider_context'; - -export function DashboardPageProvider({ getService, getPageObjects }: FtrProviderContext) { - const log = getService('log'); - const find = getService('find'); - const retry = getService('retry'); - const browser = getService('browser'); - const globalNav = getService('globalNav'); - const esArchiver = getService('esArchiver'); - const kibanaServer = getService('kibanaServer'); - const testSubjects = getService('testSubjects'); - const dashboardAddPanel = getService('dashboardAddPanel'); - const renderable = getService('renderable'); - const listingTable = getService('listingTable'); - const elasticChart = getService('elasticChart'); - const PageObjects = getPageObjects(['common', 'header', 'visualize', 'discover']); - - interface SaveDashboardOptions { - /** - * @default true - */ - waitDialogIsClosed?: boolean; - exitFromEditMode?: boolean; - needsConfirm?: boolean; - storeTimeWithDashboard?: boolean; - saveAsNew?: boolean; - tags?: string[]; - } - - class DashboardPage { - async initTests({ kibanaIndex = 'dashboard/legacy', defaultIndex = 'logstash-*' } = {}) { - log.debug('load kibana index with visualizations and log data'); - await esArchiver.load(kibanaIndex); - await kibanaServer.uiSettings.replace({ defaultIndex }); - await PageObjects.common.navigateToApp('dashboard'); - } - - public async preserveCrossAppState() { - const url = await browser.getCurrentUrl(); - await browser.get(url, false); - await PageObjects.header.waitUntilLoadingHasFinished(); - } +import { FtrService } from '../ftr_provider_context'; + +interface SaveDashboardOptions { + /** + * @default true + */ + waitDialogIsClosed?: boolean; + exitFromEditMode?: boolean; + needsConfirm?: boolean; + storeTimeWithDashboard?: boolean; + saveAsNew?: boolean; + tags?: string[]; +} - public async clickFullScreenMode() { - log.debug(`clickFullScreenMode`); - await testSubjects.click('dashboardFullScreenMode'); - await testSubjects.exists('exitFullScreenModeLogo'); - await this.waitForRenderComplete(); - } +export class DashboardPageObject extends FtrService { + private readonly log = this.ctx.getService('log'); + private readonly find = this.ctx.getService('find'); + private readonly retry = this.ctx.getService('retry'); + private readonly browser = this.ctx.getService('browser'); + private readonly globalNav = this.ctx.getService('globalNav'); + private readonly esArchiver = this.ctx.getService('esArchiver'); + private readonly kibanaServer = this.ctx.getService('kibanaServer'); + private readonly testSubjects = this.ctx.getService('testSubjects'); + private readonly dashboardAddPanel = this.ctx.getService('dashboardAddPanel'); + private readonly renderable = this.ctx.getService('renderable'); + private readonly listingTable = this.ctx.getService('listingTable'); + private readonly elasticChart = this.ctx.getService('elasticChart'); + private readonly common = this.ctx.getPageObject('common'); + private readonly header = this.ctx.getPageObject('header'); + private readonly visualize = this.ctx.getPageObject('visualize'); + private readonly discover = this.ctx.getPageObject('discover'); + + async initTests({ kibanaIndex = 'dashboard/legacy', defaultIndex = 'logstash-*' } = {}) { + this.log.debug('load kibana index with visualizations and log data'); + await this.esArchiver.load(kibanaIndex); + await this.kibanaServer.uiSettings.replace({ defaultIndex }); + await this.common.navigateToApp('dashboard'); + } - public async exitFullScreenMode() { - log.debug(`exitFullScreenMode`); - const logoButton = await this.getExitFullScreenLogoButton(); - await logoButton.moveMouseTo(); - await this.clickExitFullScreenTextButton(); - } + public async preserveCrossAppState() { + const url = await this.browser.getCurrentUrl(); + await this.browser.get(url, false); + await this.header.waitUntilLoadingHasFinished(); + } - public async fullScreenModeMenuItemExists() { - return await testSubjects.exists('dashboardFullScreenMode'); - } + public async clickFullScreenMode() { + this.log.debug(`clickFullScreenMode`); + await this.testSubjects.click('dashboardFullScreenMode'); + await this.testSubjects.exists('exitFullScreenModeLogo'); + await this.waitForRenderComplete(); + } - public async exitFullScreenTextButtonExists() { - return await testSubjects.exists('exitFullScreenModeText'); - } + public async exitFullScreenMode() { + this.log.debug(`exitFullScreenMode`); + const logoButton = await this.getExitFullScreenLogoButton(); + await logoButton.moveMouseTo(); + await this.clickExitFullScreenTextButton(); + } - public async getExitFullScreenTextButton() { - return await testSubjects.find('exitFullScreenModeText'); - } + public async fullScreenModeMenuItemExists() { + return await this.testSubjects.exists('dashboardFullScreenMode'); + } - public async exitFullScreenLogoButtonExists() { - return await testSubjects.exists('exitFullScreenModeLogo'); - } + public async exitFullScreenTextButtonExists() { + return await this.testSubjects.exists('exitFullScreenModeText'); + } - public async getExitFullScreenLogoButton() { - return await testSubjects.find('exitFullScreenModeLogo'); - } + public async getExitFullScreenTextButton() { + return await this.testSubjects.find('exitFullScreenModeText'); + } - public async clickExitFullScreenLogoButton() { - await testSubjects.click('exitFullScreenModeLogo'); - await this.waitForRenderComplete(); - } + public async exitFullScreenLogoButtonExists() { + return await this.testSubjects.exists('exitFullScreenModeLogo'); + } - public async clickExitFullScreenTextButton() { - await testSubjects.click('exitFullScreenModeText'); - await this.waitForRenderComplete(); - } + public async getExitFullScreenLogoButton() { + return await this.testSubjects.find('exitFullScreenModeLogo'); + } - public async getDashboardIdFromCurrentUrl() { - const currentUrl = await browser.getCurrentUrl(); - const id = this.getDashboardIdFromUrl(currentUrl); + public async clickExitFullScreenLogoButton() { + await this.testSubjects.click('exitFullScreenModeLogo'); + await this.waitForRenderComplete(); + } - log.debug(`Dashboard id extracted from ${currentUrl} is ${id}`); + public async clickExitFullScreenTextButton() { + await this.testSubjects.click('exitFullScreenModeText'); + await this.waitForRenderComplete(); + } - return id; - } + public async getDashboardIdFromCurrentUrl() { + const currentUrl = await this.browser.getCurrentUrl(); + const id = this.getDashboardIdFromUrl(currentUrl); - public getDashboardIdFromUrl(url: string) { - const urlSubstring = '#/view/'; - const startOfIdIndex = url.indexOf(urlSubstring) + urlSubstring.length; - const endIndex = url.indexOf('?'); - const id = url.substring(startOfIdIndex, endIndex < 0 ? url.length : endIndex); - return id; - } + this.log.debug(`Dashboard id extracted from ${currentUrl} is ${id}`); - public async expectUnsavedChangesListingExists(title: string) { - log.debug(`Expect Unsaved Changes Listing Exists for `, title); - await testSubjects.existOrFail(`edit-unsaved-${title.split(' ').join('-')}`); - } + return id; + } - public async expectUnsavedChangesDoesNotExist(title: string) { - log.debug(`Expect Unsaved Changes Listing Does Not Exist for `, title); - await testSubjects.missingOrFail(`edit-unsaved-${title.split(' ').join('-')}`); - } + public getDashboardIdFromUrl(url: string) { + const urlSubstring = '#/view/'; + const startOfIdIndex = url.indexOf(urlSubstring) + urlSubstring.length; + const endIndex = url.indexOf('?'); + const id = url.substring(startOfIdIndex, endIndex < 0 ? url.length : endIndex); + return id; + } - public async clickUnsavedChangesContinueEditing(title: string) { - log.debug(`Click Unsaved Changes Continue Editing `, title); - await testSubjects.existOrFail(`edit-unsaved-${title.split(' ').join('-')}`); - await testSubjects.click(`edit-unsaved-${title.split(' ').join('-')}`); - } + public async expectUnsavedChangesListingExists(title: string) { + this.log.debug(`Expect Unsaved Changes Listing Exists for `, title); + await this.testSubjects.existOrFail(`edit-unsaved-${title.split(' ').join('-')}`); + } - public async clickUnsavedChangesDiscard(title: string, confirmDiscard = true) { - log.debug(`Click Unsaved Changes Discard for `, title); - await testSubjects.existOrFail(`discard-unsaved-${title.split(' ').join('-')}`); - await testSubjects.click(`discard-unsaved-${title.split(' ').join('-')}`); - if (confirmDiscard) { - await PageObjects.common.clickConfirmOnModal(); - } else { - await PageObjects.common.clickCancelOnModal(); - } - } + public async expectUnsavedChangesDoesNotExist(title: string) { + this.log.debug(`Expect Unsaved Changes Listing Does Not Exist for `, title); + await this.testSubjects.missingOrFail(`edit-unsaved-${title.split(' ').join('-')}`); + } - /** - * Returns true if already on the dashboard landing page (that page doesn't have a link to itself). - * @returns {Promise} - */ - public async onDashboardLandingPage() { - log.debug(`onDashboardLandingPage`); - return await listingTable.onListingPage('dashboard'); - } + public async clickUnsavedChangesContinueEditing(title: string) { + this.log.debug(`Click Unsaved Changes Continue Editing `, title); + await this.testSubjects.existOrFail(`edit-unsaved-${title.split(' ').join('-')}`); + await this.testSubjects.click(`edit-unsaved-${title.split(' ').join('-')}`); + } - public async expectExistsDashboardLandingPage() { - log.debug(`expectExistsDashboardLandingPage`); - await testSubjects.existOrFail('dashboardLandingPage'); + public async clickUnsavedChangesDiscard(title: string, confirmDiscard = true) { + this.log.debug(`Click Unsaved Changes Discard for `, title); + await this.testSubjects.existOrFail(`discard-unsaved-${title.split(' ').join('-')}`); + await this.testSubjects.click(`discard-unsaved-${title.split(' ').join('-')}`); + if (confirmDiscard) { + await this.common.clickConfirmOnModal(); + } else { + await this.common.clickCancelOnModal(); } + } - public async clickDashboardBreadcrumbLink() { - log.debug('clickDashboardBreadcrumbLink'); - await testSubjects.click('breadcrumb dashboardListingBreadcrumb first'); - } + /** + * Returns true if already on the dashboard landing page (that page doesn't have a link to itself). + * @returns {Promise} + */ + public async onDashboardLandingPage() { + this.log.debug(`onDashboardLandingPage`); + return await this.listingTable.onListingPage('dashboard'); + } - public async expectOnDashboard(dashboardTitle: string) { - await retry.waitFor( - 'last breadcrumb to have dashboard title', - async () => (await globalNav.getLastBreadcrumb()) === dashboardTitle - ); - } + public async expectExistsDashboardLandingPage() { + this.log.debug(`expectExistsDashboardLandingPage`); + await this.testSubjects.existOrFail('dashboardLandingPage'); + } - public async gotoDashboardLandingPage(ignorePageLeaveWarning = true) { - log.debug('gotoDashboardLandingPage'); - const onPage = await this.onDashboardLandingPage(); - if (!onPage) { - await this.clickDashboardBreadcrumbLink(); - await retry.try(async () => { - const warning = await testSubjects.exists('confirmModalTitleText'); - if (warning) { - await testSubjects.click( - ignorePageLeaveWarning ? 'confirmModalConfirmButton' : 'confirmModalCancelButton' - ); - } - }); - await this.expectExistsDashboardLandingPage(); - } - } + public async clickDashboardBreadcrumbLink() { + this.log.debug('clickDashboardBreadcrumbLink'); + await this.testSubjects.click('breadcrumb dashboardListingBreadcrumb first'); + } - public async clickClone() { - log.debug('Clicking clone'); - await testSubjects.click('dashboardClone'); - } + public async expectOnDashboard(dashboardTitle: string) { + await this.retry.waitFor( + 'last breadcrumb to have dashboard title', + async () => (await this.globalNav.getLastBreadcrumb()) === dashboardTitle + ); + } - public async getCloneTitle() { - return await testSubjects.getAttribute('clonedDashboardTitle', 'value'); + public async gotoDashboardLandingPage(ignorePageLeaveWarning = true) { + this.log.debug('gotoDashboardLandingPage'); + const onPage = await this.onDashboardLandingPage(); + if (!onPage) { + await this.clickDashboardBreadcrumbLink(); + await this.retry.try(async () => { + const warning = await this.testSubjects.exists('confirmModalTitleText'); + if (warning) { + await this.testSubjects.click( + ignorePageLeaveWarning ? 'confirmModalConfirmButton' : 'confirmModalCancelButton' + ); + } + }); + await this.expectExistsDashboardLandingPage(); } + } - public async confirmClone() { - log.debug('Confirming clone'); - await testSubjects.click('cloneConfirmButton'); - } + public async clickClone() { + this.log.debug('Clicking clone'); + await this.testSubjects.click('dashboardClone'); + } - public async cancelClone() { - log.debug('Canceling clone'); - await testSubjects.click('cloneCancelButton'); - } + public async getCloneTitle() { + return await this.testSubjects.getAttribute('clonedDashboardTitle', 'value'); + } - public async setClonedDashboardTitle(title: string) { - await testSubjects.setValue('clonedDashboardTitle', title); - } + public async confirmClone() { + this.log.debug('Confirming clone'); + await this.testSubjects.click('cloneConfirmButton'); + } - /** - * Asserts that the duplicate title warning is either displayed or not displayed. - * @param { displayed: boolean } - */ - public async expectDuplicateTitleWarningDisplayed({ displayed = true }) { - if (displayed) { - await testSubjects.existOrFail('titleDupicateWarnMsg'); - } else { - await testSubjects.missingOrFail('titleDupicateWarnMsg'); - } - } + public async cancelClone() { + this.log.debug('Canceling clone'); + await this.testSubjects.click('cloneCancelButton'); + } - /** - * Asserts that the toolbar pagination (count and arrows) is either displayed or not displayed. + public async setClonedDashboardTitle(title: string) { + await this.testSubjects.setValue('clonedDashboardTitle', title); + } - */ - public async expectToolbarPaginationDisplayed() { - const isLegacyDefault = PageObjects.discover.useLegacyTable(); - if (isLegacyDefault) { - const subjects = ['btnPrevPage', 'btnNextPage', 'toolBarPagerText']; - await Promise.all(subjects.map(async (subj) => await testSubjects.existOrFail(subj))); - } else { - const subjects = ['pagination-button-previous', 'pagination-button-next']; + /** + * Asserts that the duplicate title warning is either displayed or not displayed. + * @param { displayed: boolean } + */ + public async expectDuplicateTitleWarningDisplayed({ displayed = true }) { + if (displayed) { + await this.testSubjects.existOrFail('titleDupicateWarnMsg'); + } else { + await this.testSubjects.missingOrFail('titleDupicateWarnMsg'); + } + } - await Promise.all(subjects.map(async (subj) => await testSubjects.existOrFail(subj))); - const paginationListExists = await find.existsByCssSelector('.euiPagination__list'); - if (!paginationListExists) { - throw new Error(`expected discover data grid pagination list to exist`); - } + /** + * Asserts that the toolbar pagination (count and arrows) is either displayed or not displayed. + + */ + public async expectToolbarPaginationDisplayed() { + const isLegacyDefault = this.discover.useLegacyTable(); + if (isLegacyDefault) { + const subjects = ['btnPrevPage', 'btnNextPage', 'toolBarPagerText']; + await Promise.all(subjects.map(async (subj) => await this.testSubjects.existOrFail(subj))); + } else { + const subjects = ['pagination-button-previous', 'pagination-button-next']; + + await Promise.all(subjects.map(async (subj) => await this.testSubjects.existOrFail(subj))); + const paginationListExists = await this.find.existsByCssSelector('.euiPagination__list'); + if (!paginationListExists) { + throw new Error(`expected discover data grid pagination list to exist`); } } + } - public async switchToEditMode() { - log.debug('Switching to edit mode'); - await testSubjects.click('dashboardEditMode'); - // wait until the count of dashboard panels equals the count of toggle menu icons - await retry.waitFor('in edit mode', async () => { - const panels = await testSubjects.findAll('embeddablePanel', 2500); - const menuIcons = await testSubjects.findAll('embeddablePanelToggleMenuIcon', 2500); - return panels.length === menuIcons.length; - }); - } + public async switchToEditMode() { + this.log.debug('Switching to edit mode'); + await this.testSubjects.click('dashboardEditMode'); + // wait until the count of dashboard panels equals the count of toggle menu icons + await this.retry.waitFor('in edit mode', async () => { + const panels = await this.testSubjects.findAll('embeddablePanel', 2500); + const menuIcons = await this.testSubjects.findAll('embeddablePanelToggleMenuIcon', 2500); + return panels.length === menuIcons.length; + }); + } - public async getIsInViewMode() { - log.debug('getIsInViewMode'); - return await testSubjects.exists('dashboardEditMode'); - } + public async getIsInViewMode() { + this.log.debug('getIsInViewMode'); + return await this.testSubjects.exists('dashboardEditMode'); + } - public async clickCancelOutOfEditMode(accept = true) { - log.debug('clickCancelOutOfEditMode'); - await testSubjects.click('dashboardViewOnlyMode'); - if (accept) { - const confirmation = await testSubjects.exists('dashboardDiscardConfirmKeep'); - if (confirmation) { - await testSubjects.click('dashboardDiscardConfirmKeep'); - } + public async clickCancelOutOfEditMode(accept = true) { + this.log.debug('clickCancelOutOfEditMode'); + await this.testSubjects.click('dashboardViewOnlyMode'); + if (accept) { + const confirmation = await this.testSubjects.exists('dashboardDiscardConfirmKeep'); + if (confirmation) { + await this.testSubjects.click('dashboardDiscardConfirmKeep'); } } + } - public async clickDiscardChanges(accept = true) { - log.debug('clickDiscardChanges'); - await testSubjects.click('dashboardViewOnlyMode'); - if (accept) { - const confirmation = await testSubjects.exists('dashboardDiscardConfirmDiscard'); - if (confirmation) { - await testSubjects.click('dashboardDiscardConfirmDiscard'); - } + public async clickDiscardChanges(accept = true) { + this.log.debug('clickDiscardChanges'); + await this.testSubjects.click('dashboardViewOnlyMode'); + if (accept) { + const confirmation = await this.testSubjects.exists('dashboardDiscardConfirmDiscard'); + if (confirmation) { + await this.testSubjects.click('dashboardDiscardConfirmDiscard'); } } + } - public async clickQuickSave() { - await this.expectQuickSaveButtonEnabled(); - log.debug('clickQuickSave'); - await testSubjects.click('dashboardQuickSaveMenuItem'); - } - - public async clickNewDashboard(continueEditing = false) { - await listingTable.clickNewButton('createDashboardPromptButton'); - if (await testSubjects.exists('dashboardCreateConfirm')) { - if (continueEditing) { - await testSubjects.click('dashboardCreateConfirmContinue'); - } else { - await testSubjects.click('dashboardCreateConfirmStartOver'); - } - } - // make sure the dashboard page is shown - await this.waitForRenderComplete(); - } + public async clickQuickSave() { + await this.expectQuickSaveButtonEnabled(); + this.log.debug('clickQuickSave'); + await this.testSubjects.click('dashboardQuickSaveMenuItem'); + } - public async clickNewDashboardExpectWarning(continueEditing = false) { - await listingTable.clickNewButton('createDashboardPromptButton'); - await testSubjects.existOrFail('dashboardCreateConfirm'); + public async clickNewDashboard(continueEditing = false) { + await this.listingTable.clickNewButton('createDashboardPromptButton'); + if (await this.testSubjects.exists('dashboardCreateConfirm')) { if (continueEditing) { - await testSubjects.click('dashboardCreateConfirmContinue'); + await this.testSubjects.click('dashboardCreateConfirmContinue'); } else { - await testSubjects.click('dashboardCreateConfirmStartOver'); + await this.testSubjects.click('dashboardCreateConfirmStartOver'); } - // make sure the dashboard page is shown - await this.waitForRenderComplete(); - } - - public async clickCreateDashboardPrompt() { - await testSubjects.click('createDashboardPromptButton'); } + // make sure the dashboard page is shown + await this.waitForRenderComplete(); + } - public async getCreateDashboardPromptExists() { - return await testSubjects.exists('createDashboardPromptButton'); + public async clickNewDashboardExpectWarning(continueEditing = false) { + await this.listingTable.clickNewButton('createDashboardPromptButton'); + await this.testSubjects.existOrFail('dashboardCreateConfirm'); + if (continueEditing) { + await this.testSubjects.click('dashboardCreateConfirmContinue'); + } else { + await this.testSubjects.click('dashboardCreateConfirmStartOver'); } + // make sure the dashboard page is shown + await this.waitForRenderComplete(); + } - public async isOptionsOpen() { - log.debug('isOptionsOpen'); - return await testSubjects.exists('dashboardOptionsMenu'); - } + public async clickCreateDashboardPrompt() { + await this.testSubjects.click('createDashboardPromptButton'); + } - public async openOptions() { - log.debug('openOptions'); - const isOpen = await this.isOptionsOpen(); - if (!isOpen) { - return await testSubjects.click('dashboardOptionsButton'); - } - } + public async getCreateDashboardPromptExists() { + return await this.testSubjects.exists('createDashboardPromptButton'); + } - // avoids any 'Object with id x not found' errors when switching tests. - public async clearSavedObjectsFromAppLinks() { - await PageObjects.header.clickVisualize(); - await PageObjects.visualize.gotoLandingPage(); - await PageObjects.header.clickDashboard(); - await this.gotoDashboardLandingPage(); - } + public async isOptionsOpen() { + this.log.debug('isOptionsOpen'); + return await this.testSubjects.exists('dashboardOptionsMenu'); + } - public async isMarginsOn() { - log.debug('isMarginsOn'); - await this.openOptions(); - return await testSubjects.getAttribute('dashboardMarginsCheckbox', 'checked'); + public async openOptions() { + this.log.debug('openOptions'); + const isOpen = await this.isOptionsOpen(); + if (!isOpen) { + return await this.testSubjects.click('dashboardOptionsButton'); } + } - public async useMargins(on = true) { - await this.openOptions(); - const isMarginsOn = await this.isMarginsOn(); - if (isMarginsOn !== 'on') { - return await testSubjects.click('dashboardMarginsCheckbox'); - } - } + // avoids any 'Object with id x not found' errors when switching tests. + public async clearSavedObjectsFromAppLinks() { + await this.header.clickVisualize(); + await this.visualize.gotoLandingPage(); + await this.header.clickDashboard(); + await this.gotoDashboardLandingPage(); + } - public async isColorSyncOn() { - log.debug('isColorSyncOn'); - await this.openOptions(); - return await testSubjects.getAttribute('dashboardSyncColorsCheckbox', 'checked'); - } + public async isMarginsOn() { + this.log.debug('isMarginsOn'); + await this.openOptions(); + return await this.testSubjects.getAttribute('dashboardMarginsCheckbox', 'checked'); + } - public async useColorSync(on = true) { - await this.openOptions(); - const isColorSyncOn = await this.isColorSyncOn(); - if (isColorSyncOn !== 'on') { - return await testSubjects.click('dashboardSyncColorsCheckbox'); - } + public async useMargins(on = true) { + await this.openOptions(); + const isMarginsOn = await this.isMarginsOn(); + if (isMarginsOn !== 'on') { + return await this.testSubjects.click('dashboardMarginsCheckbox'); } + } - public async gotoDashboardEditMode(dashboardName: string) { - await this.loadSavedDashboard(dashboardName); - await this.switchToEditMode(); - } + public async isColorSyncOn() { + this.log.debug('isColorSyncOn'); + await this.openOptions(); + return await this.testSubjects.getAttribute('dashboardSyncColorsCheckbox', 'checked'); + } - public async renameDashboard(dashboardName: string) { - log.debug(`Naming dashboard ` + dashboardName); - await testSubjects.click('dashboardRenameButton'); - await testSubjects.setValue('savedObjectTitle', dashboardName); + public async useColorSync(on = true) { + await this.openOptions(); + const isColorSyncOn = await this.isColorSyncOn(); + if (isColorSyncOn !== 'on') { + return await this.testSubjects.click('dashboardSyncColorsCheckbox'); } + } - /** - * Save the current dashboard with the specified name and options and - * verify that the save was successful, close the toast and return the - * toast message - * - * @param dashboardName {String} - * @param saveOptions {{storeTimeWithDashboard: boolean, saveAsNew: boolean, needsConfirm: false, waitDialogIsClosed: boolean }} - */ - public async saveDashboard( - dashboardName: string, - saveOptions: SaveDashboardOptions = { waitDialogIsClosed: true, exitFromEditMode: true } - ) { - await retry.try(async () => { - await this.enterDashboardTitleAndClickSave(dashboardName, saveOptions); - - if (saveOptions.needsConfirm) { - await this.ensureDuplicateTitleCallout(); - await this.clickSave(); - } + public async gotoDashboardEditMode(dashboardName: string) { + await this.loadSavedDashboard(dashboardName); + await this.switchToEditMode(); + } - // Confirm that the Dashboard has actually been saved - await testSubjects.existOrFail('saveDashboardSuccess'); - }); - const message = await PageObjects.common.closeToast(); - await PageObjects.header.waitUntilLoadingHasFinished(); - await PageObjects.common.waitForSaveModalToClose(); + public async renameDashboard(dashboardName: string) { + this.log.debug(`Naming dashboard ` + dashboardName); + await this.testSubjects.click('dashboardRenameButton'); + await this.testSubjects.setValue('savedObjectTitle', dashboardName); + } - const isInViewMode = await testSubjects.exists('dashboardEditMode'); - if (saveOptions.exitFromEditMode && !isInViewMode) { - await this.clickCancelOutOfEditMode(); + /** + * Save the current dashboard with the specified name and options and + * verify that the save was successful, close the toast and return the + * toast message + * + * @param dashboardName {String} + * @param saveOptions {{storeTimeWithDashboard: boolean, saveAsNew: boolean, needsConfirm: false, waitDialogIsClosed: boolean }} + */ + public async saveDashboard( + dashboardName: string, + saveOptions: SaveDashboardOptions = { waitDialogIsClosed: true, exitFromEditMode: true } + ) { + await this.retry.try(async () => { + await this.enterDashboardTitleAndClickSave(dashboardName, saveOptions); + + if (saveOptions.needsConfirm) { + await this.ensureDuplicateTitleCallout(); + await this.clickSave(); } - await PageObjects.header.waitUntilLoadingHasFinished(); - return message; - } + // Confirm that the Dashboard has actually been saved + await this.testSubjects.existOrFail('saveDashboardSuccess'); + }); + const message = await this.common.closeToast(); + await this.header.waitUntilLoadingHasFinished(); + await this.common.waitForSaveModalToClose(); - public async cancelSave() { - log.debug('Canceling save'); - await testSubjects.click('saveCancelButton'); + const isInViewMode = await this.testSubjects.exists('dashboardEditMode'); + if (saveOptions.exitFromEditMode && !isInViewMode) { + await this.clickCancelOutOfEditMode(); } + await this.header.waitUntilLoadingHasFinished(); - public async clickSave() { - log.debug('DashboardPage.clickSave'); - await testSubjects.click('confirmSaveSavedObjectButton'); - } + return message; + } - /** - * - * @param dashboardTitle {String} - * @param saveOptions {{storeTimeWithDashboard: boolean, saveAsNew: boolean, waitDialogIsClosed: boolean}} - */ - public async enterDashboardTitleAndClickSave( - dashboardTitle: string, - saveOptions: SaveDashboardOptions = { waitDialogIsClosed: true } - ) { - await testSubjects.click('dashboardSaveMenuItem'); - const modalDialog = await testSubjects.find('savedObjectSaveModal'); - - log.debug('entering new title'); - await testSubjects.setValue('savedObjectTitle', dashboardTitle); - - if (saveOptions.storeTimeWithDashboard !== undefined) { - await this.setStoreTimeWithDashboard(saveOptions.storeTimeWithDashboard); - } + public async cancelSave() { + this.log.debug('Canceling save'); + await this.testSubjects.click('saveCancelButton'); + } - const saveAsNewCheckboxExists = await testSubjects.exists('saveAsNewCheckbox'); - if (saveAsNewCheckboxExists) { - await this.setSaveAsNewCheckBox(Boolean(saveOptions.saveAsNew)); - } + public async clickSave() { + this.log.debug('DashboardPage.clickSave'); + await this.testSubjects.click('confirmSaveSavedObjectButton'); + } - if (saveOptions.tags) { - await this.selectDashboardTags(saveOptions.tags); - } + /** + * + * @param dashboardTitle {String} + * @param saveOptions {{storeTimeWithDashboard: boolean, saveAsNew: boolean, waitDialogIsClosed: boolean}} + */ + public async enterDashboardTitleAndClickSave( + dashboardTitle: string, + saveOptions: SaveDashboardOptions = { waitDialogIsClosed: true } + ) { + await this.testSubjects.click('dashboardSaveMenuItem'); + const modalDialog = await this.testSubjects.find('savedObjectSaveModal'); - await this.clickSave(); - if (saveOptions.waitDialogIsClosed) { - await testSubjects.waitForDeleted(modalDialog); - } + this.log.debug('entering new title'); + await this.testSubjects.setValue('savedObjectTitle', dashboardTitle); + + if (saveOptions.storeTimeWithDashboard !== undefined) { + await this.setStoreTimeWithDashboard(saveOptions.storeTimeWithDashboard); } - public async ensureDuplicateTitleCallout() { - await testSubjects.existOrFail('titleDupicateWarnMsg'); + const saveAsNewCheckboxExists = await this.testSubjects.exists('saveAsNewCheckbox'); + if (saveAsNewCheckboxExists) { + await this.setSaveAsNewCheckBox(Boolean(saveOptions.saveAsNew)); } - public async selectDashboardTags(tagNames: string[]) { - await testSubjects.click('savedObjectTagSelector'); - for (const tagName of tagNames) { - await testSubjects.click(`tagSelectorOption-${tagName.replace(' ', '_')}`); - } - await testSubjects.click('savedObjectTitle'); + if (saveOptions.tags) { + await this.selectDashboardTags(saveOptions.tags); } - /** - * @param dashboardTitle {String} - */ - public async enterDashboardTitleAndPressEnter(dashboardTitle: string) { - await testSubjects.click('dashboardSaveMenuItem'); - const modalDialog = await testSubjects.find('savedObjectSaveModal'); + await this.clickSave(); + if (saveOptions.waitDialogIsClosed) { + await this.testSubjects.waitForDeleted(modalDialog); + } + } - log.debug('entering new title'); - await testSubjects.setValue('savedObjectTitle', dashboardTitle); + public async ensureDuplicateTitleCallout() { + await this.testSubjects.existOrFail('titleDupicateWarnMsg'); + } - await PageObjects.common.pressEnterKey(); - await testSubjects.waitForDeleted(modalDialog); + public async selectDashboardTags(tagNames: string[]) { + await this.testSubjects.click('savedObjectTagSelector'); + for (const tagName of tagNames) { + await this.testSubjects.click(`tagSelectorOption-${tagName.replace(' ', '_')}`); } + await this.testSubjects.click('savedObjectTitle'); + } - // use the search filter box to narrow the results down to a single - // entry, or at least to a single page of results - public async loadSavedDashboard(dashboardName: string) { - log.debug(`Load Saved Dashboard ${dashboardName}`); + /** + * @param dashboardTitle {String} + */ + public async enterDashboardTitleAndPressEnter(dashboardTitle: string) { + await this.testSubjects.click('dashboardSaveMenuItem'); + const modalDialog = await this.testSubjects.find('savedObjectSaveModal'); - await this.gotoDashboardLandingPage(); + this.log.debug('entering new title'); + await this.testSubjects.setValue('savedObjectTitle', dashboardTitle); - await listingTable.searchForItemWithName(dashboardName); - await retry.try(async () => { - await listingTable.clickItemLink('dashboard', dashboardName); - await PageObjects.header.waitUntilLoadingHasFinished(); - // check Dashboard landing page is not present - await testSubjects.missingOrFail('dashboardLandingPage', { timeout: 10000 }); - }); - } + await this.common.pressEnterKey(); + await this.testSubjects.waitForDeleted(modalDialog); + } - public async getPanelTitles() { - log.debug('in getPanelTitles'); - const titleObjects = await testSubjects.findAll('dashboardPanelTitle'); - return await Promise.all(titleObjects.map(async (title) => await title.getVisibleText())); - } + // use the search filter box to narrow the results down to a single + // entry, or at least to a single page of results + public async loadSavedDashboard(dashboardName: string) { + this.log.debug(`Load Saved Dashboard ${dashboardName}`); - public async getPanelDimensions() { - const panels = await find.allByCssSelector('.react-grid-item'); // These are gridster-defined elements and classes - return await Promise.all( - panels.map(async (panel) => { - const size = await panel.getSize(); - return { - width: size.width, - height: size.height, - }; - }) - ); - } + await this.gotoDashboardLandingPage(); - public async getPanelCount() { - log.debug('getPanelCount'); - const panels = await testSubjects.findAll('embeddablePanel'); - return panels.length; - } + await this.listingTable.searchForItemWithName(dashboardName); + await this.retry.try(async () => { + await this.listingTable.clickItemLink('dashboard', dashboardName); + await this.header.waitUntilLoadingHasFinished(); + // check Dashboard landing page is not present + await this.testSubjects.missingOrFail('dashboardLandingPage', { timeout: 10000 }); + }); + } - public getTestVisualizations() { - return [ - { name: PIE_CHART_VIS_NAME, description: 'PieChart' }, - { name: 'Visualization☺ VerticalBarChart', description: 'VerticalBarChart' }, - { name: AREA_CHART_VIS_NAME, description: 'AreaChart' }, - { name: 'Visualization☺漢字 DataTable', description: 'DataTable' }, - { name: LINE_CHART_VIS_NAME, description: 'LineChart' }, - { name: 'Visualization TileMap', description: 'TileMap' }, - { name: 'Visualization MetricChart', description: 'MetricChart' }, - ]; - } + public async getPanelTitles() { + this.log.debug('in getPanelTitles'); + const titleObjects = await this.testSubjects.findAll('dashboardPanelTitle'); + return await Promise.all(titleObjects.map(async (title) => await title.getVisibleText())); + } - public getTestVisualizationNames() { - return this.getTestVisualizations().map((visualization) => visualization.name); - } + public async getPanelDimensions() { + const panels = await this.find.allByCssSelector('.react-grid-item'); // These are gridster-defined elements and classes + return await Promise.all( + panels.map(async (panel) => { + const size = await panel.getSize(); + return { + width: size.width, + height: size.height, + }; + }) + ); + } - public getTestVisualizationDescriptions() { - return this.getTestVisualizations().map((visualization) => visualization.description); - } + public async getPanelCount() { + this.log.debug('getPanelCount'); + const panels = await this.testSubjects.findAll('embeddablePanel'); + return panels.length; + } - public async getDashboardPanels() { - return await testSubjects.findAll('embeddablePanel'); - } + public getTestVisualizations() { + return [ + { name: PIE_CHART_VIS_NAME, description: 'PieChart' }, + { name: 'Visualization☺ VerticalBarChart', description: 'VerticalBarChart' }, + { name: AREA_CHART_VIS_NAME, description: 'AreaChart' }, + { name: 'Visualization☺漢字 DataTable', description: 'DataTable' }, + { name: LINE_CHART_VIS_NAME, description: 'LineChart' }, + { name: 'Visualization TileMap', description: 'TileMap' }, + { name: 'Visualization MetricChart', description: 'MetricChart' }, + ]; + } - public async addVisualizations(visualizations: string[]) { - await dashboardAddPanel.addVisualizations(visualizations); - } + public getTestVisualizationNames() { + return this.getTestVisualizations().map((visualization) => visualization.name); + } - public async setSaveAsNewCheckBox(checked: boolean) { - log.debug('saveAsNewCheckbox: ' + checked); - let saveAsNewCheckbox = await testSubjects.find('saveAsNewCheckbox'); - const isAlreadyChecked = (await saveAsNewCheckbox.getAttribute('aria-checked')) === 'true'; - if (isAlreadyChecked !== checked) { - log.debug('Flipping save as new checkbox'); - saveAsNewCheckbox = await testSubjects.find('saveAsNewCheckbox'); - await retry.try(() => saveAsNewCheckbox.click()); - } - } + public getTestVisualizationDescriptions() { + return this.getTestVisualizations().map((visualization) => visualization.description); + } - public async setStoreTimeWithDashboard(checked: boolean) { - log.debug('Storing time with dashboard: ' + checked); - let storeTimeCheckbox = await testSubjects.find('storeTimeWithDashboard'); - const isAlreadyChecked = (await storeTimeCheckbox.getAttribute('aria-checked')) === 'true'; - if (isAlreadyChecked !== checked) { - log.debug('Flipping store time checkbox'); - storeTimeCheckbox = await testSubjects.find('storeTimeWithDashboard'); - await retry.try(() => storeTimeCheckbox.click()); - } - } + public async getDashboardPanels() { + return await this.testSubjects.findAll('embeddablePanel'); + } - public async getSharedItemsCount() { - log.debug('in getSharedItemsCount'); - const attributeName = 'data-shared-items-count'; - const element = await find.byCssSelector(`[${attributeName}]`); - if (element) { - return await element.getAttribute(attributeName); - } + public async addVisualizations(visualizations: string[]) { + await this.dashboardAddPanel.addVisualizations(visualizations); + } - throw new Error('no element'); + public async setSaveAsNewCheckBox(checked: boolean) { + this.log.debug('saveAsNewCheckbox: ' + checked); + let saveAsNewCheckbox = await this.testSubjects.find('saveAsNewCheckbox'); + const isAlreadyChecked = (await saveAsNewCheckbox.getAttribute('aria-checked')) === 'true'; + if (isAlreadyChecked !== checked) { + this.log.debug('Flipping save as new checkbox'); + saveAsNewCheckbox = await this.testSubjects.find('saveAsNewCheckbox'); + await this.retry.try(() => saveAsNewCheckbox.click()); } + } - public async waitForRenderComplete() { - log.debug('waitForRenderComplete'); - const count = await this.getSharedItemsCount(); - // eslint-disable-next-line radix - await renderable.waitForRender(parseInt(count)); + public async setStoreTimeWithDashboard(checked: boolean) { + this.log.debug('Storing time with dashboard: ' + checked); + let storeTimeCheckbox = await this.testSubjects.find('storeTimeWithDashboard'); + const isAlreadyChecked = (await storeTimeCheckbox.getAttribute('aria-checked')) === 'true'; + if (isAlreadyChecked !== checked) { + this.log.debug('Flipping store time checkbox'); + storeTimeCheckbox = await this.testSubjects.find('storeTimeWithDashboard'); + await this.retry.try(() => storeTimeCheckbox.click()); } + } - public async getSharedContainerData() { - log.debug('getSharedContainerData'); - const sharedContainer = await find.byCssSelector('[data-shared-items-container]'); - return { - title: await sharedContainer.getAttribute('data-title'), - description: await sharedContainer.getAttribute('data-description'), - count: await sharedContainer.getAttribute('data-shared-items-count'), - }; + public async getSharedItemsCount() { + this.log.debug('in getSharedItemsCount'); + const attributeName = 'data-shared-items-count'; + const element = await this.find.byCssSelector(`[${attributeName}]`); + if (element) { + return await element.getAttribute(attributeName); } - public async getPanelSharedItemData() { - log.debug('in getPanelSharedItemData'); - const sharedItemscontainer = await find.byCssSelector('[data-shared-items-count]'); - const $ = await sharedItemscontainer.parseDomContent(); - return $('[data-shared-item]') - .toArray() - .map((item) => { - return { - title: $(item).attr('data-title'), - description: $(item).attr('data-description'), - }; - }); - } + throw new Error('no element'); + } - public async checkHideTitle() { - log.debug('ensure that you can click on hide title checkbox'); - await this.openOptions(); - return await testSubjects.click('dashboardPanelTitlesCheckbox'); - } + public async waitForRenderComplete() { + this.log.debug('waitForRenderComplete'); + const count = await this.getSharedItemsCount(); + // eslint-disable-next-line radix + await this.renderable.waitForRender(parseInt(count)); + } - public async expectMissingSaveOption() { - await testSubjects.missingOrFail('dashboardSaveMenuItem'); - } + public async getSharedContainerData() { + this.log.debug('getSharedContainerData'); + const sharedContainer = await this.find.byCssSelector('[data-shared-items-container]'); + return { + title: await sharedContainer.getAttribute('data-title'), + description: await sharedContainer.getAttribute('data-description'), + count: await sharedContainer.getAttribute('data-shared-items-count'), + }; + } - public async expectMissingQuickSaveOption() { - await testSubjects.missingOrFail('dashboardQuickSaveMenuItem'); - } - public async expectExistsQuickSaveOption() { - await testSubjects.existOrFail('dashboardQuickSaveMenuItem'); - } + public async getPanelSharedItemData() { + this.log.debug('in getPanelSharedItemData'); + const sharedItemscontainer = await this.find.byCssSelector('[data-shared-items-count]'); + const $ = await sharedItemscontainer.parseDomContent(); + return $('[data-shared-item]') + .toArray() + .map((item) => { + return { + title: $(item).attr('data-title'), + description: $(item).attr('data-description'), + }; + }); + } - public async expectQuickSaveButtonEnabled() { - log.debug('expectQuickSaveButtonEnabled'); - const quickSaveButton = await testSubjects.find('dashboardQuickSaveMenuItem'); - const isDisabled = await quickSaveButton.getAttribute('disabled'); - if (isDisabled) { - throw new Error('Quick save button disabled'); - } - } + public async checkHideTitle() { + this.log.debug('ensure that you can click on hide title checkbox'); + await this.openOptions(); + return await this.testSubjects.click('dashboardPanelTitlesCheckbox'); + } - public async getNotLoadedVisualizations(vizList: string[]) { - const checkList = []; - for (const name of vizList) { - const isPresent = await testSubjects.exists( - `embeddablePanelHeading-${name.replace(/\s+/g, '')}`, - { timeout: 10000 } - ); - checkList.push({ name, isPresent }); - } + public async expectMissingSaveOption() { + await this.testSubjects.missingOrFail('dashboardSaveMenuItem'); + } + + public async expectMissingQuickSaveOption() { + await this.testSubjects.missingOrFail('dashboardQuickSaveMenuItem'); + } + public async expectExistsQuickSaveOption() { + await this.testSubjects.existOrFail('dashboardQuickSaveMenuItem'); + } - return checkList.filter((viz) => viz.isPresent === false).map((viz) => viz.name); + public async expectQuickSaveButtonEnabled() { + this.log.debug('expectQuickSaveButtonEnabled'); + const quickSaveButton = await this.testSubjects.find('dashboardQuickSaveMenuItem'); + const isDisabled = await quickSaveButton.getAttribute('disabled'); + if (isDisabled) { + throw new Error('Quick save button disabled'); } + } - public async getPanelDrilldownCount(panelIndex = 0): Promise { - log.debug('getPanelDrilldownCount'); - const panel = (await this.getDashboardPanels())[panelIndex]; - try { - const count = await panel.findByTestSubject( - 'embeddablePanelNotification-ACTION_PANEL_NOTIFICATIONS' - ); - return Number.parseInt(await count.getVisibleText(), 10); - } catch (e) { - // if not found then this is 0 (we don't show badge with 0) - return 0; - } + public async getNotLoadedVisualizations(vizList: string[]) { + const checkList = []; + for (const name of vizList) { + const isPresent = await this.testSubjects.exists( + `embeddablePanelHeading-${name.replace(/\s+/g, '')}`, + { timeout: 10000 } + ); + checkList.push({ name, isPresent }); } - public async getPanelChartDebugState(panelIndex: number) { - return await elasticChart.getChartDebugData(undefined, panelIndex); + return checkList.filter((viz) => viz.isPresent === false).map((viz) => viz.name); + } + + public async getPanelDrilldownCount(panelIndex = 0): Promise { + this.log.debug('getPanelDrilldownCount'); + const panel = (await this.getDashboardPanels())[panelIndex]; + try { + const count = await panel.findByTestSubject( + 'embeddablePanelNotification-ACTION_PANEL_NOTIFICATIONS' + ); + return Number.parseInt(await count.getVisibleText(), 10); + } catch (e) { + // if not found then this is 0 (we don't show badge with 0) + return 0; } } - return new DashboardPage(); + public async getPanelChartDebugState(panelIndex: number) { + return await this.elasticChart.getChartDebugData(undefined, panelIndex); + } } diff --git a/test/functional/page_objects/discover_page.ts b/test/functional/page_objects/discover_page.ts index 436d22d659aec..41c4441a1c95d 100644 --- a/test/functional/page_objects/discover_page.ts +++ b/test/functional/page_objects/discover_page.ts @@ -6,511 +6,510 @@ * Side Public License, v 1. */ -import { FtrProviderContext } from '../ftr_provider_context'; - -export function DiscoverPageProvider({ getService, getPageObjects }: FtrProviderContext) { - const retry = getService('retry'); - const testSubjects = getService('testSubjects'); - const find = getService('find'); - const flyout = getService('flyout'); - const { header } = getPageObjects(['header']); - const browser = getService('browser'); - const globalNav = getService('globalNav'); - const elasticChart = getService('elasticChart'); - const docTable = getService('docTable'); - const config = getService('config'); - const defaultFindTimeout = config.get('timeouts.find'); - const dataGrid = getService('dataGrid'); - const kibanaServer = getService('kibanaServer'); - - class DiscoverPage { - public async getChartTimespan() { - const el = await find.byCssSelector('[data-test-subj="discoverIntervalDateRange"]'); - return await el.getVisibleText(); - } +import { FtrService } from '../ftr_provider_context'; + +export class DiscoverPageObject extends FtrService { + private readonly retry = this.ctx.getService('retry'); + private readonly testSubjects = this.ctx.getService('testSubjects'); + private readonly find = this.ctx.getService('find'); + private readonly flyout = this.ctx.getService('flyout'); + private readonly header = this.ctx.getPageObject('header'); + private readonly browser = this.ctx.getService('browser'); + private readonly globalNav = this.ctx.getService('globalNav'); + private readonly elasticChart = this.ctx.getService('elasticChart'); + private readonly docTable = this.ctx.getService('docTable'); + private readonly config = this.ctx.getService('config'); + private readonly dataGrid = this.ctx.getService('dataGrid'); + private readonly kibanaServer = this.ctx.getService('kibanaServer'); + + private readonly defaultFindTimeout = this.config.get('timeouts.find'); + + public async getChartTimespan() { + const el = await this.find.byCssSelector('[data-test-subj="discoverIntervalDateRange"]'); + return await el.getVisibleText(); + } - public async getDocTable() { - const isLegacyDefault = await this.useLegacyTable(); - if (isLegacyDefault) { - return docTable; - } else { - return dataGrid; - } + public async getDocTable() { + const isLegacyDefault = await this.useLegacyTable(); + if (isLegacyDefault) { + return this.docTable; + } else { + return this.dataGrid; } + } - public async findFieldByName(name: string) { - const fieldSearch = await testSubjects.find('fieldFilterSearchInput'); - await fieldSearch.type(name); - } + public async findFieldByName(name: string) { + const fieldSearch = await this.testSubjects.find('fieldFilterSearchInput'); + await fieldSearch.type(name); + } - public async clearFieldSearchInput() { - const fieldSearch = await testSubjects.find('fieldFilterSearchInput'); - await fieldSearch.clearValue(); - } + public async clearFieldSearchInput() { + const fieldSearch = await this.testSubjects.find('fieldFilterSearchInput'); + await fieldSearch.clearValue(); + } - public async saveSearch(searchName: string) { - await this.clickSaveSearchButton(); - // preventing an occasional flakiness when the saved object wasn't set and the form can't be submitted - await retry.waitFor( - `saved search title is set to ${searchName} and save button is clickable`, - async () => { - const saveButton = await testSubjects.find('confirmSaveSavedObjectButton'); - await testSubjects.setValue('savedObjectTitle', searchName); - return (await saveButton.getAttribute('disabled')) !== 'true'; - } - ); - await testSubjects.click('confirmSaveSavedObjectButton'); - await header.waitUntilLoadingHasFinished(); - // LeeDr - this additional checking for the saved search name was an attempt - // to cause this method to wait for the reloading of the page to complete so - // that the next action wouldn't have to retry. But it doesn't really solve - // that issue. But it does typically take about 3 retries to - // complete with the expected searchName. - await retry.waitFor(`saved search was persisted with name ${searchName}`, async () => { - return (await this.getCurrentQueryName()) === searchName; - }); - } + public async saveSearch(searchName: string) { + await this.clickSaveSearchButton(); + // preventing an occasional flakiness when the saved object wasn't set and the form can't be submitted + await this.retry.waitFor( + `saved search title is set to ${searchName} and save button is clickable`, + async () => { + const saveButton = await this.testSubjects.find('confirmSaveSavedObjectButton'); + await this.testSubjects.setValue('savedObjectTitle', searchName); + return (await saveButton.getAttribute('disabled')) !== 'true'; + } + ); + await this.testSubjects.click('confirmSaveSavedObjectButton'); + await this.header.waitUntilLoadingHasFinished(); + // LeeDr - this additional checking for the saved search name was an attempt + // to cause this method to wait for the reloading of the page to complete so + // that the next action wouldn't have to retry. But it doesn't really solve + // that issue. But it does typically take about 3 retries to + // complete with the expected searchName. + await this.retry.waitFor(`saved search was persisted with name ${searchName}`, async () => { + return (await this.getCurrentQueryName()) === searchName; + }); + } - public async inputSavedSearchTitle(searchName: string) { - await testSubjects.setValue('savedObjectTitle', searchName); - } + public async inputSavedSearchTitle(searchName: string) { + await this.testSubjects.setValue('savedObjectTitle', searchName); + } - public async clickConfirmSavedSearch() { - await testSubjects.click('confirmSaveSavedObjectButton'); - } + public async clickConfirmSavedSearch() { + await this.testSubjects.click('confirmSaveSavedObjectButton'); + } - public async openAddFilterPanel() { - await testSubjects.click('addFilter'); - } + public async openAddFilterPanel() { + await this.testSubjects.click('addFilter'); + } - public async waitUntilSearchingHasFinished() { - await testSubjects.missingOrFail('loadingSpinner', { timeout: defaultFindTimeout * 10 }); + public async waitUntilSearchingHasFinished() { + await this.testSubjects.missingOrFail('loadingSpinner', { + timeout: this.defaultFindTimeout * 10, + }); + } + + public async getColumnHeaders() { + const isLegacy = await this.useLegacyTable(); + if (isLegacy) { + return await this.docTable.getHeaderFields('embeddedSavedSearchDocTable'); } + const table = await this.getDocTable(); + return await table.getHeaderFields(); + } - public async getColumnHeaders() { - const isLegacy = await this.useLegacyTable(); - if (isLegacy) { - return await docTable.getHeaderFields('embeddedSavedSearchDocTable'); - } - const table = await this.getDocTable(); - return await table.getHeaderFields(); + public async openLoadSavedSearchPanel() { + let isOpen = await this.testSubjects.exists('loadSearchForm'); + if (isOpen) { + return; } - public async openLoadSavedSearchPanel() { - let isOpen = await testSubjects.exists('loadSearchForm'); - if (isOpen) { - return; - } + // We need this try loop here because previous actions in Discover like + // saving a search cause reloading of the page and the "Open" menu item goes stale. + await this.retry.waitFor('saved search panel is opened', async () => { + await this.clickLoadSavedSearchButton(); + await this.header.waitUntilLoadingHasFinished(); + isOpen = await this.testSubjects.exists('loadSearchForm'); + return isOpen === true; + }); + } - // We need this try loop here because previous actions in Discover like - // saving a search cause reloading of the page and the "Open" menu item goes stale. - await retry.waitFor('saved search panel is opened', async () => { - await this.clickLoadSavedSearchButton(); - await header.waitUntilLoadingHasFinished(); - isOpen = await testSubjects.exists('loadSearchForm'); - return isOpen === true; - }); - } + public async closeLoadSaveSearchPanel() { + await this.flyout.ensureClosed('loadSearchForm'); + } - public async closeLoadSaveSearchPanel() { - await flyout.ensureClosed('loadSearchForm'); - } + public async hasSavedSearch(searchName: string) { + const searchLink = await this.find.byButtonText(searchName); + return await searchLink.isDisplayed(); + } - public async hasSavedSearch(searchName: string) { - const searchLink = await find.byButtonText(searchName); - return await searchLink.isDisplayed(); - } + public async loadSavedSearch(searchName: string) { + await this.openLoadSavedSearchPanel(); + await this.testSubjects.click(`savedObjectTitle${searchName.split(' ').join('-')}`); + await this.header.waitUntilLoadingHasFinished(); + } - public async loadSavedSearch(searchName: string) { - await this.openLoadSavedSearchPanel(); - await testSubjects.click(`savedObjectTitle${searchName.split(' ').join('-')}`); - await header.waitUntilLoadingHasFinished(); - } + public async clickNewSearchButton() { + await this.testSubjects.click('discoverNewButton'); + await this.header.waitUntilLoadingHasFinished(); + } - public async clickNewSearchButton() { - await testSubjects.click('discoverNewButton'); - await header.waitUntilLoadingHasFinished(); - } + public async clickSaveSearchButton() { + await this.testSubjects.click('discoverSaveButton'); + } - public async clickSaveSearchButton() { - await testSubjects.click('discoverSaveButton'); - } + public async clickLoadSavedSearchButton() { + await this.testSubjects.moveMouseTo('discoverOpenButton'); + await this.testSubjects.click('discoverOpenButton'); + } - public async clickLoadSavedSearchButton() { - await testSubjects.moveMouseTo('discoverOpenButton'); - await testSubjects.click('discoverOpenButton'); - } + public async clickResetSavedSearchButton() { + await this.testSubjects.moveMouseTo('resetSavedSearch'); + await this.testSubjects.click('resetSavedSearch'); + await this.header.waitUntilLoadingHasFinished(); + } - public async clickResetSavedSearchButton() { - await testSubjects.moveMouseTo('resetSavedSearch'); - await testSubjects.click('resetSavedSearch'); - await header.waitUntilLoadingHasFinished(); - } + public async closeLoadSavedSearchPanel() { + await this.testSubjects.click('euiFlyoutCloseButton'); + } - public async closeLoadSavedSearchPanel() { - await testSubjects.click('euiFlyoutCloseButton'); - } + public async clickHistogramBar() { + await this.elasticChart.waitForRenderComplete(); + const el = await this.elasticChart.getCanvas(); - public async clickHistogramBar() { - await elasticChart.waitForRenderComplete(); - const el = await elasticChart.getCanvas(); + await this.browser.getActions().move({ x: 0, y: 0, origin: el._webElement }).click().perform(); + } - await browser.getActions().move({ x: 0, y: 0, origin: el._webElement }).click().perform(); - } + public async brushHistogram() { + await this.elasticChart.waitForRenderComplete(); + const el = await this.elasticChart.getCanvas(); - public async brushHistogram() { - await elasticChart.waitForRenderComplete(); - const el = await elasticChart.getCanvas(); + await this.browser.dragAndDrop( + { location: el, offset: { x: -300, y: 20 } }, + { location: el, offset: { x: -100, y: 30 } } + ); + } - await browser.dragAndDrop( - { location: el, offset: { x: -300, y: 20 } }, - { location: el, offset: { x: -100, y: 30 } } - ); - } + public async getCurrentQueryName() { + return await this.globalNav.getLastBreadcrumb(); + } - public async getCurrentQueryName() { - return await globalNav.getLastBreadcrumb(); - } + public async getChartInterval() { + const selectedValue = await this.testSubjects.getAttribute('discoverIntervalSelect', 'value'); + const selectedOption = await this.find.byCssSelector(`option[value="${selectedValue}"]`); + return selectedOption.getVisibleText(); + } - public async getChartInterval() { - const selectedValue = await testSubjects.getAttribute('discoverIntervalSelect', 'value'); - const selectedOption = await find.byCssSelector(`option[value="${selectedValue}"]`); - return selectedOption.getVisibleText(); - } + public async getChartIntervalWarningIcon() { + await this.header.waitUntilLoadingHasFinished(); + return await this.find.existsByCssSelector('.euiToolTipAnchor'); + } - public async getChartIntervalWarningIcon() { - await header.waitUntilLoadingHasFinished(); - return await find.existsByCssSelector('.euiToolTipAnchor'); - } + public async setChartInterval(interval: string) { + const optionElement = await this.find.byCssSelector(`option[label="${interval}"]`, 5000); + await optionElement.click(); + return await this.header.waitUntilLoadingHasFinished(); + } - public async setChartInterval(interval: string) { - const optionElement = await find.byCssSelector(`option[label="${interval}"]`, 5000); - await optionElement.click(); - return await header.waitUntilLoadingHasFinished(); - } + public async getHitCount() { + await this.header.waitUntilLoadingHasFinished(); + return await this.testSubjects.getVisibleText('discoverQueryHits'); + } - public async getHitCount() { - await header.waitUntilLoadingHasFinished(); - return await testSubjects.getVisibleText('discoverQueryHits'); - } + public async getDocHeader() { + const table = await this.getDocTable(); + const docHeader = await table.getHeaders(); + return docHeader.join(); + } - public async getDocHeader() { - const table = await this.getDocTable(); - const docHeader = await table.getHeaders(); - return docHeader.join(); - } + public async getDocTableRows() { + await this.header.waitUntilLoadingHasFinished(); + const table = await this.getDocTable(); + return await table.getBodyRows(); + } - public async getDocTableRows() { - await header.waitUntilLoadingHasFinished(); - const table = await this.getDocTable(); - return await table.getBodyRows(); - } + public async useLegacyTable() { + return (await this.kibanaServer.uiSettings.get('doc_table:legacy')) !== false; + } - public async useLegacyTable() { - return (await kibanaServer.uiSettings.get('doc_table:legacy')) !== false; + public async getDocTableIndex(index: number) { + const isLegacyDefault = await this.useLegacyTable(); + if (isLegacyDefault) { + const row = await this.find.byCssSelector(`tr.kbnDocTable__row:nth-child(${index})`); + return await row.getVisibleText(); } - public async getDocTableIndex(index: number) { - const isLegacyDefault = await this.useLegacyTable(); - if (isLegacyDefault) { - const row = await find.byCssSelector(`tr.kbnDocTable__row:nth-child(${index})`); - return await row.getVisibleText(); - } + const row = await this.dataGrid.getRow({ rowIndex: index - 1 }); + const result = await Promise.all(row.map(async (cell) => await cell.getVisibleText())); + // Remove control columns + return result.slice(2).join(' '); + } - const row = await dataGrid.getRow({ rowIndex: index - 1 }); - const result = await Promise.all(row.map(async (cell) => await cell.getVisibleText())); - // Remove control columns - return result.slice(2).join(' '); - } + public async getDocTableIndexLegacy(index: number) { + const row = await this.find.byCssSelector(`tr.kbnDocTable__row:nth-child(${index})`); + return await row.getVisibleText(); + } - public async getDocTableIndexLegacy(index: number) { - const row = await find.byCssSelector(`tr.kbnDocTable__row:nth-child(${index})`); - return await row.getVisibleText(); + public async getDocTableField(index: number, cellIdx: number = -1) { + const isLegacyDefault = await this.useLegacyTable(); + const usedDefaultCellIdx = isLegacyDefault ? 0 : 2; + const usedCellIdx = cellIdx === -1 ? usedDefaultCellIdx : cellIdx; + if (isLegacyDefault) { + const fields = await this.find.allByCssSelector( + `tr.kbnDocTable__row:nth-child(${index}) [data-test-subj='docTableField']` + ); + return await fields[usedCellIdx].getVisibleText(); } + const row = await this.dataGrid.getRow({ rowIndex: index - 1 }); + const result = await Promise.all(row.map(async (cell) => await cell.getVisibleText())); + return result[usedCellIdx]; + } - public async getDocTableField(index: number, cellIdx: number = -1) { - const isLegacyDefault = await this.useLegacyTable(); - const usedDefaultCellIdx = isLegacyDefault ? 0 : 2; - const usedCellIdx = cellIdx === -1 ? usedDefaultCellIdx : cellIdx; - if (isLegacyDefault) { - const fields = await find.allByCssSelector( - `tr.kbnDocTable__row:nth-child(${index}) [data-test-subj='docTableField']` - ); - return await fields[usedCellIdx].getVisibleText(); - } - const row = await dataGrid.getRow({ rowIndex: index - 1 }); - const result = await Promise.all(row.map(async (cell) => await cell.getVisibleText())); - return result[usedCellIdx]; - } + public async skipToEndOfDocTable() { + // add the focus to the button to make it appear + const skipButton = await this.testSubjects.find('discoverSkipTableButton'); + // force focus on it, to make it interactable + skipButton.focus(); + // now click it! + return skipButton.click(); + } - public async skipToEndOfDocTable() { - // add the focus to the button to make it appear - const skipButton = await testSubjects.find('discoverSkipTableButton'); - // force focus on it, to make it interactable - skipButton.focus(); - // now click it! - return skipButton.click(); - } + /** + * When scrolling down the legacy table there's a link to scroll up + * So this is done by this function + */ + public async backToTop() { + const skipButton = await this.testSubjects.find('discoverBackToTop'); + return skipButton.click(); + } - /** - * When scrolling down the legacy table there's a link to scroll up - * So this is done by this function - */ - public async backToTop() { - const skipButton = await testSubjects.find('discoverBackToTop'); - return skipButton.click(); - } + public async getDocTableFooter() { + return await this.testSubjects.find('discoverDocTableFooter'); + } - public async getDocTableFooter() { - return await testSubjects.find('discoverDocTableFooter'); + public async clickDocSortDown() { + const isLegacyDefault = await this.useLegacyTable(); + if (isLegacyDefault) { + await this.find.clickByCssSelector('.fa-sort-down'); + } else { + await this.dataGrid.clickDocSortAsc(); } + } - public async clickDocSortDown() { - const isLegacyDefault = await this.useLegacyTable(); - if (isLegacyDefault) { - await find.clickByCssSelector('.fa-sort-down'); - } else { - await dataGrid.clickDocSortAsc(); - } + public async clickDocSortUp() { + const isLegacyDefault = await this.useLegacyTable(); + if (isLegacyDefault) { + await this.find.clickByCssSelector('.fa-sort-up'); + } else { + await this.dataGrid.clickDocSortDesc(); } + } - public async clickDocSortUp() { - const isLegacyDefault = await this.useLegacyTable(); - if (isLegacyDefault) { - await find.clickByCssSelector('.fa-sort-up'); - } else { - await dataGrid.clickDocSortDesc(); - } - } + public async isShowingDocViewer() { + return await this.testSubjects.exists('kbnDocViewer'); + } - public async isShowingDocViewer() { - return await testSubjects.exists('kbnDocViewer'); - } + public async getMarks() { + const table = await this.docTable.getTable(); + const marks = await table.findAllByTagName('mark'); + return await Promise.all(marks.map((mark) => mark.getVisibleText())); + } - public async getMarks() { - const table = await docTable.getTable(); - const marks = await table.findAllByTagName('mark'); - return await Promise.all(marks.map((mark) => mark.getVisibleText())); - } + public async toggleSidebarCollapse() { + return await this.testSubjects.click('collapseSideBarButton'); + } - public async toggleSidebarCollapse() { - return await testSubjects.click('collapseSideBarButton'); - } + public async getAllFieldNames() { + const sidebar = await this.testSubjects.find('discover-sidebar'); + const $ = await sidebar.parseDomContent(); + return $('.dscSidebarField__name') + .toArray() + .map((field) => $(field).text()); + } - public async getAllFieldNames() { - const sidebar = await testSubjects.find('discover-sidebar'); - const $ = await sidebar.parseDomContent(); - return $('.dscSidebarField__name') - .toArray() - .map((field) => $(field).text()); - } + public async editField(field: string) { + await this.retry.try(async () => { + await this.testSubjects.click(`field-${field}`); + await this.testSubjects.click(`discoverFieldListPanelEdit-${field}`); + await this.find.byClassName('indexPatternFieldEditor__form'); + }); + } - public async editField(field: string) { - await retry.try(async () => { - await testSubjects.click(`field-${field}`); - await testSubjects.click(`discoverFieldListPanelEdit-${field}`); - await find.byClassName('indexPatternFieldEditor__form'); - }); - } + public async removeField(field: string) { + await this.testSubjects.click(`field-${field}`); + await this.testSubjects.click(`discoverFieldListPanelDelete-${field}`); + await this.testSubjects.existOrFail('runtimeFieldDeleteConfirmModal'); + } - public async removeField(field: string) { - await testSubjects.click(`field-${field}`); - await testSubjects.click(`discoverFieldListPanelDelete-${field}`); - await testSubjects.existOrFail('runtimeFieldDeleteConfirmModal'); - } + public async clickIndexPatternActions() { + await this.retry.try(async () => { + await this.testSubjects.click('discoverIndexPatternActions'); + await this.testSubjects.existOrFail('discover-addRuntimeField-popover'); + }); + } - public async clickIndexPatternActions() { - await retry.try(async () => { - await testSubjects.click('discoverIndexPatternActions'); - await testSubjects.existOrFail('discover-addRuntimeField-popover'); - }); - } + public async clickAddNewField() { + await this.retry.try(async () => { + await this.testSubjects.click('indexPattern-add-field'); + await this.find.byClassName('indexPatternFieldEditor__form'); + }); + } - public async clickAddNewField() { - await retry.try(async () => { - await testSubjects.click('indexPattern-add-field'); - await find.byClassName('indexPatternFieldEditor__form'); - }); - } + public async hasNoResults() { + return await this.testSubjects.exists('discoverNoResults'); + } - public async hasNoResults() { - return await testSubjects.exists('discoverNoResults'); - } + public async hasNoResultsTimepicker() { + return await this.testSubjects.exists('discoverNoResultsTimefilter'); + } - public async hasNoResultsTimepicker() { - return await testSubjects.exists('discoverNoResultsTimefilter'); - } + public async clickFieldListItem(field: string) { + return await this.testSubjects.click(`field-${field}`); + } - public async clickFieldListItem(field: string) { - return await testSubjects.click(`field-${field}`); + public async clickFieldSort(field: string, text = 'Sort New-Old') { + const isLegacyDefault = await this.useLegacyTable(); + if (isLegacyDefault) { + return await this.testSubjects.click(`docTableHeaderFieldSort_${field}`); } + return await this.dataGrid.clickDocSortAsc(field, text); + } - public async clickFieldSort(field: string, text = 'Sort New-Old') { - const isLegacyDefault = await this.useLegacyTable(); - if (isLegacyDefault) { - return await testSubjects.click(`docTableHeaderFieldSort_${field}`); - } - return await dataGrid.clickDocSortAsc(field, text); - } + public async clickFieldListItemToggle(field: string) { + await this.testSubjects.moveMouseTo(`field-${field}`); + await this.testSubjects.click(`fieldToggle-${field}`); + } - public async clickFieldListItemToggle(field: string) { - await testSubjects.moveMouseTo(`field-${field}`); - await testSubjects.click(`fieldToggle-${field}`); - } + public async clickFieldListItemAdd(field: string) { + // a filter check may make sense here, but it should be properly handled to make + // it work with the _score and _source fields as well + await this.clickFieldListItemToggle(field); + } - public async clickFieldListItemAdd(field: string) { - // a filter check may make sense here, but it should be properly handled to make - // it work with the _score and _source fields as well - await this.clickFieldListItemToggle(field); + public async clickFieldListItemRemove(field: string) { + if (!(await this.testSubjects.exists('fieldList-selected'))) { + return; } - - public async clickFieldListItemRemove(field: string) { - if (!(await testSubjects.exists('fieldList-selected'))) { - return; - } - const selectedList = await testSubjects.find('fieldList-selected'); - if (await testSubjects.descendantExists(`field-${field}`, selectedList)) { - await this.clickFieldListItemToggle(field); - } + const selectedList = await this.testSubjects.find('fieldList-selected'); + if (await this.testSubjects.descendantExists(`field-${field}`, selectedList)) { + await this.clickFieldListItemToggle(field); } + } - public async clickFieldListItemVisualize(fieldName: string) { - const field = await testSubjects.find(`field-${fieldName}-showDetails`); - const isActive = await field.elementHasClass('dscSidebarItem--active'); + public async clickFieldListItemVisualize(fieldName: string) { + const field = await this.testSubjects.find(`field-${fieldName}-showDetails`); + const isActive = await field.elementHasClass('dscSidebarItem--active'); - if (!isActive) { - // expand the field to show the "Visualize" button - await field.click(); - } - - await testSubjects.click(`fieldVisualize-${fieldName}`); + if (!isActive) { + // expand the field to show the "Visualize" button + await field.click(); } - public async expectFieldListItemVisualize(field: string) { - await testSubjects.existOrFail(`fieldVisualize-${field}`); - } + await this.testSubjects.click(`fieldVisualize-${fieldName}`); + } - public async expectMissingFieldListItemVisualize(field: string) { - await testSubjects.missingOrFail(`fieldVisualize-${field}`); - } + public async expectFieldListItemVisualize(field: string) { + await this.testSubjects.existOrFail(`fieldVisualize-${field}`); + } - public async clickFieldListPlusFilter(field: string, value: string) { - const plusFilterTestSubj = `plus-${field}-${value}`; - if (!(await testSubjects.exists(plusFilterTestSubj))) { - // field has to be open - await this.clickFieldListItem(field); - } - // testSubjects.find doesn't handle spaces in the data-test-subj value - await testSubjects.click(plusFilterTestSubj); - await header.waitUntilLoadingHasFinished(); - } + public async expectMissingFieldListItemVisualize(field: string) { + await this.testSubjects.missingOrFail(`fieldVisualize-${field}`); + } - public async clickFieldListMinusFilter(field: string, value: string) { - // this method requires the field details to be open from clickFieldListItem() - // testSubjects.find doesn't handle spaces in the data-test-subj value - await testSubjects.click(`minus-${field}-${value}`); - await header.waitUntilLoadingHasFinished(); + public async clickFieldListPlusFilter(field: string, value: string) { + const plusFilterTestSubj = `plus-${field}-${value}`; + if (!(await this.testSubjects.exists(plusFilterTestSubj))) { + // field has to be open + await this.clickFieldListItem(field); } + // this.testSubjects.find doesn't handle spaces in the data-test-subj value + await this.testSubjects.click(plusFilterTestSubj); + await this.header.waitUntilLoadingHasFinished(); + } - public async selectIndexPattern(indexPattern: string) { - await testSubjects.click('indexPattern-switch-link'); - await find.setValue('[data-test-subj="indexPattern-switcher"] input', indexPattern); - await find.clickByCssSelector( - `[data-test-subj="indexPattern-switcher"] [title="${indexPattern}"]` - ); - await header.waitUntilLoadingHasFinished(); - } + public async clickFieldListMinusFilter(field: string, value: string) { + // this method requires the field details to be open from clickFieldListItem() + // this.testSubjects.find doesn't handle spaces in the data-test-subj value + await this.testSubjects.click(`minus-${field}-${value}`); + await this.header.waitUntilLoadingHasFinished(); + } - public async removeHeaderColumn(name: string) { - const isLegacyDefault = await this.useLegacyTable(); - if (isLegacyDefault) { - await testSubjects.moveMouseTo(`docTableHeader-${name}`); - await testSubjects.click(`docTableRemoveHeader-${name}`); - } else { - await dataGrid.clickRemoveColumn(name); - } - } + public async selectIndexPattern(indexPattern: string) { + await this.testSubjects.click('indexPattern-switch-link'); + await this.find.setValue('[data-test-subj="indexPattern-switcher"] input', indexPattern); + await this.find.clickByCssSelector( + `[data-test-subj="indexPattern-switcher"] [title="${indexPattern}"]` + ); + await this.header.waitUntilLoadingHasFinished(); + } - public async openSidebarFieldFilter() { - await testSubjects.click('toggleFieldFilterButton'); - await testSubjects.existOrFail('filterSelectionPanel'); + public async removeHeaderColumn(name: string) { + const isLegacyDefault = await this.useLegacyTable(); + if (isLegacyDefault) { + await this.testSubjects.moveMouseTo(`docTableHeader-${name}`); + await this.testSubjects.click(`docTableRemoveHeader-${name}`); + } else { + await this.dataGrid.clickRemoveColumn(name); } + } - public async closeSidebarFieldFilter() { - await testSubjects.click('toggleFieldFilterButton'); - await testSubjects.missingOrFail('filterSelectionPanel'); - } + public async openSidebarFieldFilter() { + await this.testSubjects.click('toggleFieldFilterButton'); + await this.testSubjects.existOrFail('filterSelectionPanel'); + } - public async waitForChartLoadingComplete(renderCount: number) { - await elasticChart.waitForRenderingCount(renderCount, 'discoverChart'); - } + public async closeSidebarFieldFilter() { + await this.testSubjects.click('toggleFieldFilterButton'); + await this.testSubjects.missingOrFail('filterSelectionPanel'); + } - public async waitForDocTableLoadingComplete() { - await testSubjects.waitForAttributeToChange( - 'discoverDocTable', - 'data-render-complete', - 'true' - ); - } - public async getNrOfFetches() { - const el = await find.byCssSelector('[data-fetch-counter]'); - const nr = await el.getAttribute('data-fetch-counter'); - return Number(nr); - } + public async waitForChartLoadingComplete(renderCount: number) { + await this.elasticChart.waitForRenderingCount(renderCount, 'discoverChart'); + } - /** - * Check if Discover app is currently rendered on the screen. - */ - public async isDiscoverAppOnScreen(): Promise { - const result = await find.allByCssSelector('discover-app'); - return result.length === 1; - } + public async waitForDocTableLoadingComplete() { + await this.testSubjects.waitForAttributeToChange( + 'discoverDocTable', + 'data-render-complete', + 'true' + ); + } + public async getNrOfFetches() { + const el = await this.find.byCssSelector('[data-fetch-counter]'); + const nr = await el.getAttribute('data-fetch-counter'); + return Number(nr); + } - /** - * Wait until Discover app is rendered on the screen. - */ - public async waitForDiscoverAppOnScreen() { - await retry.waitFor('Discover app on screen', async () => { - return await this.isDiscoverAppOnScreen(); - }); - } + /** + * Check if Discover app is currently rendered on the screen. + */ + public async isDiscoverAppOnScreen(): Promise { + const result = await this.find.allByCssSelector('discover-app'); + return result.length === 1; + } - public async showAllFilterActions() { - await testSubjects.click('showFilterActions'); - } + /** + * Wait until Discover app is rendered on the screen. + */ + public async waitForDiscoverAppOnScreen() { + await this.retry.waitFor('Discover app on screen', async () => { + return await this.isDiscoverAppOnScreen(); + }); + } - public async clickSavedQueriesPopOver() { - await testSubjects.click('saved-query-management-popover-button'); - } + public async showAllFilterActions() { + await this.testSubjects.click('showFilterActions'); + } - public async clickCurrentSavedQuery() { - await testSubjects.click('saved-query-management-save-button'); - } + public async clickSavedQueriesPopOver() { + await this.testSubjects.click('saved-query-management-popover-button'); + } - public async setSaveQueryFormTitle(savedQueryName: string) { - await testSubjects.setValue('saveQueryFormTitle', savedQueryName); - } + public async clickCurrentSavedQuery() { + await this.testSubjects.click('saved-query-management-save-button'); + } - public async toggleIncludeFilters() { - await testSubjects.click('saveQueryFormIncludeFiltersOption'); - } + public async setSaveQueryFormTitle(savedQueryName: string) { + await this.testSubjects.setValue('saveQueryFormTitle', savedQueryName); + } - public async saveCurrentSavedQuery() { - await testSubjects.click('savedQueryFormSaveButton'); - } + public async toggleIncludeFilters() { + await this.testSubjects.click('saveQueryFormIncludeFiltersOption'); + } - public async deleteSavedQuery() { - await testSubjects.click('delete-saved-query-TEST-button'); - } + public async saveCurrentSavedQuery() { + await this.testSubjects.click('savedQueryFormSaveButton'); + } - public async confirmDeletionOfSavedQuery() { - await testSubjects.click('confirmModalConfirmButton'); - } + public async deleteSavedQuery() { + await this.testSubjects.click('delete-saved-query-TEST-button'); + } - public async clearSavedQuery() { - await testSubjects.click('saved-query-management-clear-button'); - } + public async confirmDeletionOfSavedQuery() { + await this.testSubjects.click('confirmModalConfirmButton'); } - return new DiscoverPage(); + public async clearSavedQuery() { + await this.testSubjects.click('saved-query-management-clear-button'); + } } diff --git a/test/functional/page_objects/error_page.ts b/test/functional/page_objects/error_page.ts index 99c17c632720a..b0166e3753dd5 100644 --- a/test/functional/page_objects/error_page.ts +++ b/test/functional/page_objects/error_page.ts @@ -7,28 +7,24 @@ */ import expect from '@kbn/expect'; -import { FtrProviderContext } from '../ftr_provider_context'; +import { FtrService } from '../ftr_provider_context'; -export function ErrorPageProvider({ getPageObjects }: FtrProviderContext) { - const { common } = getPageObjects(['common']); +export class ErrorPageObject extends FtrService { + private readonly common = this.ctx.getPageObject('common'); - class ErrorPage { - public async expectForbidden() { - const messageText = await common.getBodyText(); - expect(messageText).to.contain('You do not have permission to access the requested page'); - } - - public async expectNotFound() { - const messageText = await common.getJsonBodyText(); - expect(messageText).to.eql( - JSON.stringify({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', - }) - ); - } + public async expectForbidden() { + const messageText = await this.common.getBodyText(); + expect(messageText).to.contain('You do not have permission to access the requested page'); } - return new ErrorPage(); + public async expectNotFound() { + const messageText = await this.common.getJsonBodyText(); + expect(messageText).to.eql( + JSON.stringify({ + statusCode: 404, + error: 'Not Found', + message: 'Not Found', + }) + ); + } } diff --git a/test/functional/page_objects/header_page.ts b/test/functional/page_objects/header_page.ts index c5a796a1eb13b..8597a4b4ee2fb 100644 --- a/test/functional/page_objects/header_page.ts +++ b/test/functional/page_objects/header_page.ts @@ -6,92 +6,88 @@ * Side Public License, v 1. */ -import { FtrProviderContext } from '../ftr_provider_context'; +import { FtrService } from '../ftr_provider_context'; -export function HeaderPageProvider({ getService, getPageObjects }: FtrProviderContext) { - const config = getService('config'); - const log = getService('log'); - const retry = getService('retry'); - const testSubjects = getService('testSubjects'); - const appsMenu = getService('appsMenu'); - const PageObjects = getPageObjects(['common']); +export class HeaderPageObject extends FtrService { + private readonly config = this.ctx.getService('config'); + private readonly log = this.ctx.getService('log'); + private readonly retry = this.ctx.getService('retry'); + private readonly testSubjects = this.ctx.getService('testSubjects'); + private readonly appsMenu = this.ctx.getService('appsMenu'); + private readonly common = this.ctx.getPageObject('common'); - const defaultFindTimeout = config.get('timeouts.find'); + private readonly defaultFindTimeout = this.config.get('timeouts.find'); - class HeaderPage { - public async clickDiscover(ignoreAppLeaveWarning = false) { - await appsMenu.clickLink('Discover', { category: 'kibana' }); - await this.onAppLeaveWarning(ignoreAppLeaveWarning); - await PageObjects.common.waitForTopNavToBeVisible(); - await this.awaitGlobalLoadingIndicatorHidden(); - } + public async clickDiscover(ignoreAppLeaveWarning = false) { + await this.appsMenu.clickLink('Discover', { category: 'kibana' }); + await this.onAppLeaveWarning(ignoreAppLeaveWarning); + await this.common.waitForTopNavToBeVisible(); + await this.awaitGlobalLoadingIndicatorHidden(); + } - public async clickVisualize(ignoreAppLeaveWarning = false) { - await appsMenu.clickLink('Visualize Library', { category: 'kibana' }); - await this.onAppLeaveWarning(ignoreAppLeaveWarning); - await this.awaitGlobalLoadingIndicatorHidden(); - await retry.waitFor('Visualize app to be loaded', async () => { - const isNavVisible = await testSubjects.exists('top-nav'); - return isNavVisible; - }); - } + public async clickVisualize(ignoreAppLeaveWarning = false) { + await this.appsMenu.clickLink('Visualize Library', { category: 'kibana' }); + await this.onAppLeaveWarning(ignoreAppLeaveWarning); + await this.awaitGlobalLoadingIndicatorHidden(); + await this.retry.waitFor('Visualize app to be loaded', async () => { + const isNavVisible = await this.testSubjects.exists('top-nav'); + return isNavVisible; + }); + } - public async clickDashboard() { - await appsMenu.clickLink('Dashboard', { category: 'kibana' }); - await retry.waitFor('dashboard app to be loaded', async () => { - const isNavVisible = await testSubjects.exists('top-nav'); - const isLandingPageVisible = await testSubjects.exists('dashboardLandingPage'); - return isNavVisible || isLandingPageVisible; - }); - await this.awaitGlobalLoadingIndicatorHidden(); - } + public async clickDashboard() { + await this.appsMenu.clickLink('Dashboard', { category: 'kibana' }); + await this.retry.waitFor('dashboard app to be loaded', async () => { + const isNavVisible = await this.testSubjects.exists('top-nav'); + const isLandingPageVisible = await this.testSubjects.exists('dashboardLandingPage'); + return isNavVisible || isLandingPageVisible; + }); + await this.awaitGlobalLoadingIndicatorHidden(); + } - public async clickStackManagement() { - await appsMenu.clickLink('Stack Management', { category: 'management' }); - await this.awaitGlobalLoadingIndicatorHidden(); - } + public async clickStackManagement() { + await this.appsMenu.clickLink('Stack Management', { category: 'management' }); + await this.awaitGlobalLoadingIndicatorHidden(); + } - public async waitUntilLoadingHasFinished() { - try { - await this.isGlobalLoadingIndicatorVisible(); - } catch (exception) { - if (exception.name === 'ElementNotVisible') { - // selenium might just have been too slow to catch it - } else { - throw exception; - } + public async waitUntilLoadingHasFinished() { + try { + await this.isGlobalLoadingIndicatorVisible(); + } catch (exception) { + if (exception.name === 'ElementNotVisible') { + // selenium might just have been too slow to catch it + } else { + throw exception; } - await this.awaitGlobalLoadingIndicatorHidden(); - } - - public async isGlobalLoadingIndicatorVisible() { - log.debug('isGlobalLoadingIndicatorVisible'); - return await testSubjects.exists('globalLoadingIndicator', { timeout: 1500 }); } + await this.awaitGlobalLoadingIndicatorHidden(); + } - public async awaitGlobalLoadingIndicatorHidden() { - await testSubjects.existOrFail('globalLoadingIndicator-hidden', { - allowHidden: true, - timeout: defaultFindTimeout * 10, - }); - } + public async isGlobalLoadingIndicatorVisible() { + this.log.debug('isGlobalLoadingIndicatorVisible'); + return await this.testSubjects.exists('globalLoadingIndicator', { timeout: 1500 }); + } - public async awaitKibanaChrome() { - log.debug('awaitKibanaChrome'); - await testSubjects.find('kibanaChrome', defaultFindTimeout * 10); - } + public async awaitGlobalLoadingIndicatorHidden() { + await this.testSubjects.existOrFail('globalLoadingIndicator-hidden', { + allowHidden: true, + timeout: this.defaultFindTimeout * 10, + }); + } - public async onAppLeaveWarning(ignoreWarning = false) { - await retry.try(async () => { - const warning = await testSubjects.exists('confirmModalTitleText'); - if (warning) { - await testSubjects.click( - ignoreWarning ? 'confirmModalConfirmButton' : 'confirmModalCancelButton' - ); - } - }); - } + public async awaitKibanaChrome() { + this.log.debug('awaitKibanaChrome'); + await this.testSubjects.find('kibanaChrome', this.defaultFindTimeout * 10); } - return new HeaderPage(); + public async onAppLeaveWarning(ignoreWarning = false) { + await this.retry.try(async () => { + const warning = await this.testSubjects.exists('confirmModalTitleText'); + if (warning) { + await this.testSubjects.click( + ignoreWarning ? 'confirmModalConfirmButton' : 'confirmModalCancelButton' + ); + } + }); + } } diff --git a/test/functional/page_objects/home_page.ts b/test/functional/page_objects/home_page.ts index f03f74ef8c61d..33de6a33c50f5 100644 --- a/test/functional/page_objects/home_page.ts +++ b/test/functional/page_objects/home_page.ts @@ -6,138 +6,134 @@ * Side Public License, v 1. */ -import { FtrProviderContext } from '../ftr_provider_context'; +import { FtrService } from '../ftr_provider_context'; -export function HomePageProvider({ getService, getPageObjects }: FtrProviderContext) { - const testSubjects = getService('testSubjects'); - const retry = getService('retry'); - const find = getService('find'); - const PageObjects = getPageObjects(['common']); +export class HomePageObject extends FtrService { + private readonly testSubjects = this.ctx.getService('testSubjects'); + private readonly retry = this.ctx.getService('retry'); + private readonly find = this.ctx.getService('find'); + private readonly common = this.ctx.getPageObject('common'); - class HomePage { - async clickSynopsis(title: string) { - await testSubjects.click(`homeSynopsisLink${title}`); - } - - async doesSynopsisExist(title: string) { - return await testSubjects.exists(`homeSynopsisLink${title}`); - } + async clickSynopsis(title: string) { + await this.testSubjects.click(`homeSynopsisLink${title}`); + } - async doesSampleDataSetExist(id: string) { - return await testSubjects.exists(`sampleDataSetCard${id}`); - } + async doesSynopsisExist(title: string) { + return await this.testSubjects.exists(`homeSynopsisLink${title}`); + } - async isSampleDataSetInstalled(id: string) { - return !(await testSubjects.exists(`addSampleDataSet${id}`)); - } + async doesSampleDataSetExist(id: string) { + return await this.testSubjects.exists(`sampleDataSetCard${id}`); + } - async getVisibileSolutions() { - const solutionPanels = await testSubjects.findAll('~homSolutionPanel', 2000); - const panelAttributes = await Promise.all( - solutionPanels.map((panel) => panel.getAttribute('data-test-subj')) - ); - return panelAttributes.map((attributeValue) => attributeValue.split('homSolutionPanel_')[1]); - } + async isSampleDataSetInstalled(id: string) { + return !(await this.testSubjects.exists(`addSampleDataSet${id}`)); + } - async addSampleDataSet(id: string) { - const isInstalled = await this.isSampleDataSetInstalled(id); - if (!isInstalled) { - await testSubjects.click(`addSampleDataSet${id}`); - await this._waitForSampleDataLoadingAction(id); - } - } + async getVisibileSolutions() { + const solutionPanels = await this.testSubjects.findAll('~homSolutionPanel', 2000); + const panelAttributes = await Promise.all( + solutionPanels.map((panel) => panel.getAttribute('data-test-subj')) + ); + return panelAttributes.map((attributeValue) => attributeValue.split('homSolutionPanel_')[1]); + } - 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}`); + async addSampleDataSet(id: string) { + const isInstalled = await this.isSampleDataSetInstalled(id); + if (!isInstalled) { + await this.testSubjects.click(`addSampleDataSet${id}`); await this._waitForSampleDataLoadingAction(id); } + } - // loading action is either uninstall and install - async _waitForSampleDataLoadingAction(id: string) { - const sampleDataCard = await testSubjects.find(`sampleDataSetCard${id}`); - await retry.try(async () => { - // waitForDeletedByCssSelector needs to be inside retry because it will timeout at least once - // before action is complete - await sampleDataCard.waitForDeletedByCssSelector('.euiLoadingSpinner'); - }); - } + async removeSampleDataSet(id: string) { + // looks like overkill but we're hitting flaky cases where we click but it doesn't remove + await this.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 this.common.sleep(1010); + await this.testSubjects.click(`removeSampleDataSet${id}`); + await this._waitForSampleDataLoadingAction(id); + } - async launchSampleDashboard(id: string) { - await this.launchSampleDataSet(id); - await find.clickByLinkText('Dashboard'); - } + // loading action is either uninstall and install + async _waitForSampleDataLoadingAction(id: string) { + const sampleDataCard = await this.testSubjects.find(`sampleDataSetCard${id}`); + await this.retry.try(async () => { + // waitForDeletedByCssSelector needs to be inside retry because it will timeout at least once + // before action is complete + await sampleDataCard.waitForDeletedByCssSelector('.euiLoadingSpinner'); + }); + } - async launchSampleDataSet(id: string) { - await this.addSampleDataSet(id); - await testSubjects.click(`launchSampleDataSet${id}`); - } + async launchSampleDashboard(id: string) { + await this.launchSampleDataSet(id); + await this.find.clickByLinkText('Dashboard'); + } - async clickAllKibanaPlugins() { - await testSubjects.click('allPlugins'); - } + async launchSampleDataSet(id: string) { + await this.addSampleDataSet(id); + await this.testSubjects.click(`launchSampleDataSet${id}`); + } - async clickVisualizeExplorePlugins() { - await testSubjects.click('tab-data'); - } + async clickAllKibanaPlugins() { + await this.testSubjects.click('allPlugins'); + } - async clickAdminPlugin() { - await testSubjects.click('tab-admin'); - } + async clickVisualizeExplorePlugins() { + await this.testSubjects.click('tab-data'); + } - async clickOnConsole() { - await this.clickSynopsis('console'); - } - async clickOnLogo() { - await testSubjects.click('logo'); - } + async clickAdminPlugin() { + await this.testSubjects.click('tab-admin'); + } - async clickOnAddData() { - await this.clickSynopsis('home_tutorial_directory'); - } + async clickOnConsole() { + await this.clickSynopsis('console'); + } + async clickOnLogo() { + await this.testSubjects.click('logo'); + } - // clicks on Active MQ logs - async clickOnLogsTutorial() { - await this.clickSynopsis('activemqlogs'); - } + async clickOnAddData() { + await this.clickSynopsis('home_tutorial_directory'); + } - // clicks on cloud tutorial link - async clickOnCloudTutorial() { - await testSubjects.click('onCloudTutorial'); - } + // clicks on Active MQ logs + async clickOnLogsTutorial() { + await this.clickSynopsis('activemqlogs'); + } - // click on side nav toggle button to see all of side nav - async clickOnToggleNavButton() { - await testSubjects.click('toggleNavButton'); - } + // clicks on cloud tutorial link + async clickOnCloudTutorial() { + await this.testSubjects.click('onCloudTutorial'); + } - // collapse the observability side nav details - async collapseObservabibilitySideNav() { - await testSubjects.click('collapsibleNavGroup-observability'); - } + // click on side nav toggle button to see all of side nav + async clickOnToggleNavButton() { + await this.testSubjects.click('toggleNavButton'); + } - // dock the side nav - async dockTheSideNav() { - await testSubjects.click('collapsible-nav-lock'); - } + // collapse the observability side nav details + async collapseObservabibilitySideNav() { + await this.testSubjects.click('collapsibleNavGroup-observability'); + } - async loadSavedObjects() { - await retry.try(async () => { - await testSubjects.click('loadSavedObjects'); - const successMsgExists = await testSubjects.exists('loadSavedObjects_success', { - timeout: 5000, - }); - if (!successMsgExists) { - throw new Error('Failed to load saved objects'); - } - }); - } + // dock the side nav + async dockTheSideNav() { + await this.testSubjects.click('collapsible-nav-lock'); } - return new HomePage(); + async loadSavedObjects() { + await this.retry.try(async () => { + await this.testSubjects.click('loadSavedObjects'); + const successMsgExists = await this.testSubjects.exists('loadSavedObjects_success', { + timeout: 5000, + }); + if (!successMsgExists) { + throw new Error('Failed to load saved objects'); + } + }); + } } diff --git a/test/functional/page_objects/index.ts b/test/functional/page_objects/index.ts index 413e0aef1444b..7c06344c1a1ad 100644 --- a/test/functional/page_objects/index.ts +++ b/test/functional/page_objects/index.ts @@ -6,54 +6,54 @@ * Side Public License, v 1. */ -import { CommonPageProvider } from './common_page'; -import { ConsolePageProvider } from './console_page'; -import { ContextPageProvider } from './context_page'; -import { DashboardPageProvider } from './dashboard_page'; -import { DiscoverPageProvider } from './discover_page'; -import { ErrorPageProvider } from './error_page'; -import { HeaderPageProvider } from './header_page'; -import { HomePageProvider } from './home_page'; -import { NewsfeedPageProvider } from './newsfeed_page'; -import { SettingsPageProvider } from './settings_page'; -import { SharePageProvider } from './share_page'; -import { LoginPageProvider } from './login_page'; -import { TimePickerProvider } from './time_picker'; -import { TimelionPageProvider } from './timelion_page'; -import { VisualBuilderPageProvider } from './visual_builder_page'; -import { VisualizePageProvider } from './visualize_page'; -import { VisualizeEditorPageProvider } from './visualize_editor_page'; -import { VisualizeChartPageProvider } from './visualize_chart_page'; -import { TileMapPageProvider } from './tile_map_page'; -import { TimeToVisualizePageProvider } from './time_to_visualize_page'; -import { TagCloudPageProvider } from './tag_cloud_page'; -import { VegaChartPageProvider } from './vega_chart_page'; -import { SavedObjectsPageProvider } from './management/saved_objects_page'; -import { LegacyDataTableVisProvider } from './legacy/data_table_vis'; +import { CommonPageObject } from './common_page'; +import { ConsolePageObject } from './console_page'; +import { ContextPageObject } from './context_page'; +import { DashboardPageObject } from './dashboard_page'; +import { DiscoverPageObject } from './discover_page'; +import { ErrorPageObject } from './error_page'; +import { HeaderPageObject } from './header_page'; +import { HomePageObject } from './home_page'; +import { NewsfeedPageObject } from './newsfeed_page'; +import { SettingsPageObject } from './settings_page'; +import { SharePageObject } from './share_page'; +import { LoginPageObject } from './login_page'; +import { TimePickerPageObject } from './time_picker'; +import { TimelionPageObject } from './timelion_page'; +import { VisualBuilderPageObject } from './visual_builder_page'; +import { VisualizePageObject } from './visualize_page'; +import { VisualizeEditorPageObject } from './visualize_editor_page'; +import { VisualizeChartPageObject } from './visualize_chart_page'; +import { TileMapPageObject } from './tile_map_page'; +import { TimeToVisualizePageObject } from './time_to_visualize_page'; +import { TagCloudPageObject } from './tag_cloud_page'; +import { VegaChartPageObject } from './vega_chart_page'; +import { SavedObjectsPageObject } from './management/saved_objects_page'; +import { LegacyDataTableVisPageObject } from './legacy/data_table_vis'; export const pageObjects = { - common: CommonPageProvider, - console: ConsolePageProvider, - context: ContextPageProvider, - dashboard: DashboardPageProvider, - discover: DiscoverPageProvider, - error: ErrorPageProvider, - header: HeaderPageProvider, - home: HomePageProvider, - newsfeed: NewsfeedPageProvider, - settings: SettingsPageProvider, - share: SharePageProvider, - legacyDataTableVis: LegacyDataTableVisProvider, - login: LoginPageProvider, - timelion: TimelionPageProvider, - timePicker: TimePickerProvider, - visualBuilder: VisualBuilderPageProvider, - visualize: VisualizePageProvider, - visEditor: VisualizeEditorPageProvider, - visChart: VisualizeChartPageProvider, - tileMap: TileMapPageProvider, - timeToVisualize: TimeToVisualizePageProvider, - tagCloud: TagCloudPageProvider, - vegaChart: VegaChartPageProvider, - savedObjects: SavedObjectsPageProvider, + common: CommonPageObject, + console: ConsolePageObject, + context: ContextPageObject, + dashboard: DashboardPageObject, + discover: DiscoverPageObject, + error: ErrorPageObject, + header: HeaderPageObject, + home: HomePageObject, + newsfeed: NewsfeedPageObject, + settings: SettingsPageObject, + share: SharePageObject, + legacyDataTableVis: LegacyDataTableVisPageObject, + login: LoginPageObject, + timelion: TimelionPageObject, + timePicker: TimePickerPageObject, + visualBuilder: VisualBuilderPageObject, + visualize: VisualizePageObject, + visEditor: VisualizeEditorPageObject, + visChart: VisualizeChartPageObject, + tileMap: TileMapPageObject, + timeToVisualize: TimeToVisualizePageObject, + tagCloud: TagCloudPageObject, + vegaChart: VegaChartPageObject, + savedObjects: SavedObjectsPageObject, }; diff --git a/test/functional/page_objects/legacy/data_table_vis.ts b/test/functional/page_objects/legacy/data_table_vis.ts index ef787263f2ab9..122409f28de90 100644 --- a/test/functional/page_objects/legacy/data_table_vis.ts +++ b/test/functional/page_objects/legacy/data_table_vis.ts @@ -6,80 +6,79 @@ * Side Public License, v 1. */ -import { FtrProviderContext } from 'test/functional/ftr_provider_context'; -import { WebElementWrapper } from 'test/functional/services/lib/web_element_wrapper'; +import { FtrService } from '../../ftr_provider_context'; +import { WebElementWrapper } from '../../services/lib/web_element_wrapper'; -export function LegacyDataTableVisProvider({ getService, getPageObjects }: FtrProviderContext) { - const testSubjects = getService('testSubjects'); - const retry = getService('retry'); +export class LegacyDataTableVisPageObject extends FtrService { + private readonly testSubjects = this.ctx.getService('testSubjects'); + private readonly retry = this.ctx.getService('retry'); - class LegacyDataTableVis { - /** - * Converts the table data into nested array - * [ [cell1_in_row1, cell2_in_row1], [cell1_in_row2, cell2_in_row2] ] - * @param element table - */ - private async getDataFromElement(element: WebElementWrapper): Promise { - const $ = await element.parseDomContent(); - return $('tr') - .toArray() - .map((row) => - $(row) - .find('td') - .toArray() - .map((cell) => - $(cell) - .text() - .replace(/ /g, '') - .trim() - ) - ); - } - - public async getTableVisContent({ stripEmptyRows = true } = {}) { - return await retry.try(async () => { - const container = await testSubjects.find('tableVis'); - const allTables = await testSubjects.findAllDescendant('paginated-table-body', container); + /** + * Converts the table data into nested array + * [ [cell1_in_row1, cell2_in_row1], [cell1_in_row2, cell2_in_row2] ] + * @param element table + */ + private async getDataFromElement(element: WebElementWrapper): Promise { + const $ = await element.parseDomContent(); + return $('tr') + .toArray() + .map((row) => + $(row) + .find('td') + .toArray() + .map((cell) => + $(cell) + .text() + .replace(/ /g, '') + .trim() + ) + ); + } - if (allTables.length === 0) { - return []; - } + public async getTableVisContent({ stripEmptyRows = true } = {}) { + return await this.retry.try(async () => { + const container = await this.testSubjects.find('tableVis'); + const allTables = await this.testSubjects.findAllDescendant( + 'paginated-table-body', + container + ); - const allData = await Promise.all( - allTables.map(async (t) => { - let data = await this.getDataFromElement(t); - if (stripEmptyRows) { - data = data.filter( - (row) => row.length > 0 && row.some((cell) => cell.trim().length > 0) - ); - } - return data; - }) - ); + if (allTables.length === 0) { + return []; + } - if (allTables.length === 1) { - // If there was only one table we return only the data for that table - // This prevents an unnecessary array around that single table, which - // is the case we have in most tests. - return allData[0]; - } + const allData = await Promise.all( + allTables.map(async (t) => { + let data = await this.getDataFromElement(t); + if (stripEmptyRows) { + data = data.filter( + (row) => row.length > 0 && row.some((cell) => cell.trim().length > 0) + ); + } + return data; + }) + ); - return allData; - }); - } + if (allTables.length === 1) { + // If there was only one table we return only the data for that table + // This prevents an unnecessary array around that single table, which + // is the case we have in most tests. + return allData[0]; + } - public async filterOnTableCell(columnIndex: number, rowIndex: number) { - await retry.try(async () => { - const tableVis = await testSubjects.find('tableVis'); - const cell = await tableVis.findByCssSelector( - `tbody tr:nth-child(${rowIndex}) td:nth-child(${columnIndex})` - ); - await cell.moveMouseTo(); - const filterBtn = await testSubjects.findDescendant('filterForCellValue', cell); - await filterBtn.click(); - }); - } + return allData; + }); } - return new LegacyDataTableVis(); + public async filterOnTableCell(columnIndex: number, rowIndex: number) { + await this.retry.try(async () => { + const tableVis = await this.testSubjects.find('tableVis'); + const cell = await tableVis.findByCssSelector( + `tbody tr:nth-child(${rowIndex}) td:nth-child(${columnIndex})` + ); + await cell.moveMouseTo(); + const filterBtn = await this.testSubjects.findDescendant('filterForCellValue', cell); + await filterBtn.click(); + }); + } } diff --git a/test/functional/page_objects/login_page.ts b/test/functional/page_objects/login_page.ts index 606ddf4643c40..5318a2b2d0c15 100644 --- a/test/functional/page_objects/login_page.ts +++ b/test/functional/page_objects/login_page.ts @@ -7,65 +7,61 @@ */ import { delay } from 'bluebird'; -import { FtrProviderContext } from '../ftr_provider_context'; +import { FtrService } from '../ftr_provider_context'; -export function LoginPageProvider({ getService }: FtrProviderContext) { - const testSubjects = getService('testSubjects'); - const log = getService('log'); - const find = getService('find'); +export class LoginPageObject extends FtrService { + private readonly testSubjects = this.ctx.getService('testSubjects'); + private readonly log = this.ctx.getService('log'); + private readonly find = this.ctx.getService('find'); - const regularLogin = async (user: string, pwd: string) => { - await testSubjects.setValue('loginUsername', user); - await testSubjects.setValue('loginPassword', pwd); - await testSubjects.click('loginSubmit'); - await find.waitForDeletedByCssSelector('.kibanaWelcomeLogo'); - await find.byCssSelector('[data-test-subj="kibanaChrome"]', 60000); // 60 sec waiting - }; - - const samlLogin = async (user: string, pwd: string) => { - try { - await find.clickByButtonText('Login using SAML'); - await find.setValue('input[name="email"]', user); - await find.setValue('input[type="password"]', pwd); - await find.clickByCssSelector('.auth0-label-submit'); - await find.byCssSelector('[data-test-subj="kibanaChrome"]', 60000); // 60 sec waiting - } catch (err) { - log.debug(`${err} \nFailed to find Auth0 login page, trying the Auth0 last login page`); - await find.clickByCssSelector('.auth0-lock-social-button'); + async login(user: string, pwd: string) { + const loginType = process.env.VM || ''; + if (loginType.includes('oidc') || loginType.includes('saml')) { + await this.samlLogin(user, pwd); + return; } - }; - class LoginPage { - async login(user: string, pwd: string) { - const loginType = process.env.VM || ''; - if (loginType.includes('oidc') || loginType.includes('saml')) { - await samlLogin(user, pwd); - return; - } + await this.regularLogin(user, pwd); + } - await regularLogin(user, pwd); - } + async logoutLogin(user: string, pwd: string) { + await this.logout(); + await this.sleep(3002); + await this.login(user, pwd); + } - async logoutLogin(user: string, pwd: string) { - await this.logout(); - await this.sleep(3002); - await this.login(user, pwd); - } + async logout() { + await this.testSubjects.click('userMenuButton'); + await this.sleep(500); + await this.testSubjects.click('logoutLink'); + this.log.debug('### found and clicked log out--------------------------'); + await this.sleep(8002); + } - async logout() { - await testSubjects.click('userMenuButton'); - await this.sleep(500); - await testSubjects.click('logoutLink'); - log.debug('### found and clicked log out--------------------------'); - await this.sleep(8002); - } + async sleep(sleepMilliseconds: number) { + this.log.debug(`... sleep(${sleepMilliseconds}) start`); + await delay(sleepMilliseconds); + this.log.debug(`... sleep(${sleepMilliseconds}) end`); + } - async sleep(sleepMilliseconds: number) { - log.debug(`... sleep(${sleepMilliseconds}) start`); - await delay(sleepMilliseconds); - log.debug(`... sleep(${sleepMilliseconds}) end`); - } + private async regularLogin(user: string, pwd: string) { + await this.testSubjects.setValue('loginUsername', user); + await this.testSubjects.setValue('loginPassword', pwd); + await this.testSubjects.click('loginSubmit'); + await this.find.waitForDeletedByCssSelector('.kibanaWelcomeLogo'); + await this.find.byCssSelector('[data-test-subj="kibanaChrome"]', 60000); // 60 sec waiting } - return new LoginPage(); + private async samlLogin(user: string, pwd: string) { + try { + await this.find.clickByButtonText('Login using SAML'); + await this.find.setValue('input[name="email"]', user); + await this.find.setValue('input[type="password"]', pwd); + await this.find.clickByCssSelector('.auth0-label-submit'); + await this.find.byCssSelector('[data-test-subj="kibanaChrome"]', 60000); // 60 sec waiting + } catch (err) { + this.log.debug(`${err} \nFailed to find Auth0 login page, trying the Auth0 last login page`); + await this.find.clickByCssSelector('.auth0-lock-social-button'); + } + } } diff --git a/test/functional/page_objects/management/saved_objects_page.ts b/test/functional/page_objects/management/saved_objects_page.ts index fc4de6ed7f82f..9f48a6f57c8d8 100644 --- a/test/functional/page_objects/management/saved_objects_page.ts +++ b/test/functional/page_objects/management/saved_objects_page.ts @@ -8,328 +8,325 @@ import { keyBy } from 'lodash'; import { map as mapAsync } from 'bluebird'; -import { FtrProviderContext } from '../../ftr_provider_context'; - -export function SavedObjectsPageProvider({ getService, getPageObjects }: FtrProviderContext) { - const log = getService('log'); - const retry = getService('retry'); - const browser = getService('browser'); - const find = getService('find'); - const testSubjects = getService('testSubjects'); - const PageObjects = getPageObjects(['header', 'common']); - - class SavedObjectsPage { - async searchForObject(objectName: string) { - const searchBox = await testSubjects.find('savedObjectSearchBar'); - await searchBox.clearValue(); - await searchBox.type(objectName); - await searchBox.pressKeys(browser.keys.ENTER); - await PageObjects.header.waitUntilLoadingHasFinished(); - await this.waitTableIsLoaded(); - } - - async getCurrentSearchValue() { - const searchBox = await testSubjects.find('savedObjectSearchBar'); - return await searchBox.getAttribute('value'); - } +import { FtrService } from '../../ftr_provider_context'; + +export class SavedObjectsPageObject extends FtrService { + private readonly log = this.ctx.getService('log'); + private readonly retry = this.ctx.getService('retry'); + private readonly browser = this.ctx.getService('browser'); + private readonly find = this.ctx.getService('find'); + private readonly testSubjects = this.ctx.getService('testSubjects'); + private readonly common = this.ctx.getPageObject('common'); + private readonly header = this.ctx.getPageObject('header'); + + async searchForObject(objectName: string) { + const searchBox = await this.testSubjects.find('savedObjectSearchBar'); + await searchBox.clearValue(); + await searchBox.type(objectName); + await searchBox.pressKeys(this.browser.keys.ENTER); + await this.header.waitUntilLoadingHasFinished(); + await this.waitTableIsLoaded(); + } - async importFile(path: string, overwriteAll = true) { - log.debug(`importFile(${path})`); + async getCurrentSearchValue() { + const searchBox = await this.testSubjects.find('savedObjectSearchBar'); + return await searchBox.getAttribute('value'); + } - log.debug(`Clicking importObjects`); - await testSubjects.click('importObjects'); - await PageObjects.common.setFileInputPath(path); + async importFile(path: string, overwriteAll = true) { + this.log.debug(`importFile(${path})`); - if (!overwriteAll) { - log.debug(`Toggling overwriteAll`); - const radio = await testSubjects.find( - 'savedObjectsManagement-importModeControl-overwriteRadioGroup' - ); - // a radio button consists of a div tag that contains an input, a div, and a label - // we can't click the input directly, need to go up one level and click the parent div - const div = await radio.findByXpath("//div[input[@id='overwriteDisabled']]"); - await div.click(); - } else { - log.debug(`Leaving overwriteAll alone`); - } - await testSubjects.click('importSavedObjectsImportBtn'); - log.debug(`done importing the file`); + this.log.debug(`Clicking importObjects`); + await this.testSubjects.click('importObjects'); + await this.common.setFileInputPath(path); - // Wait for all the saves to happen - await PageObjects.header.waitUntilLoadingHasFinished(); + if (!overwriteAll) { + this.log.debug(`Toggling overwriteAll`); + const radio = await this.testSubjects.find( + 'savedObjectsManagement-importModeControl-overwriteRadioGroup' + ); + // a radio button consists of a div tag that contains an input, a div, and a label + // we can't click the input directly, need to go up one level and click the parent div + const div = await radio.findByXpath("//div[input[@id='overwriteDisabled']]"); + await div.click(); + } else { + this.log.debug(`Leaving overwriteAll alone`); } + await this.testSubjects.click('importSavedObjectsImportBtn'); + this.log.debug(`done importing the file`); - async checkImportSucceeded() { - await testSubjects.existOrFail('importSavedObjectsSuccess', { timeout: 20000 }); - } + // Wait for all the saves to happen + await this.header.waitUntilLoadingHasFinished(); + } - async checkNoneImported() { - await testSubjects.existOrFail('importSavedObjectsSuccessNoneImported', { timeout: 20000 }); - } + async checkImportSucceeded() { + await this.testSubjects.existOrFail('importSavedObjectsSuccess', { timeout: 20000 }); + } - async checkImportConflictsWarning() { - await testSubjects.existOrFail('importSavedObjectsConflictsWarning', { timeout: 20000 }); - } + async checkNoneImported() { + await this.testSubjects.existOrFail('importSavedObjectsSuccessNoneImported', { + timeout: 20000, + }); + } - async checkImportLegacyWarning() { - await testSubjects.existOrFail('importSavedObjectsLegacyWarning', { timeout: 20000 }); - } + async checkImportConflictsWarning() { + await this.testSubjects.existOrFail('importSavedObjectsConflictsWarning', { timeout: 20000 }); + } - async checkImportFailedWarning() { - await testSubjects.existOrFail('importSavedObjectsFailedWarning', { timeout: 20000 }); - } + async checkImportLegacyWarning() { + await this.testSubjects.existOrFail('importSavedObjectsLegacyWarning', { timeout: 20000 }); + } - async checkImportError() { - await testSubjects.existOrFail('importSavedObjectsErrorText', { timeout: 20000 }); - } + async checkImportFailedWarning() { + await this.testSubjects.existOrFail('importSavedObjectsFailedWarning', { timeout: 20000 }); + } - async getImportErrorText() { - return await testSubjects.getVisibleText('importSavedObjectsErrorText'); - } + async checkImportError() { + await this.testSubjects.existOrFail('importSavedObjectsErrorText', { timeout: 20000 }); + } - async clickImportDone() { - await testSubjects.click('importSavedObjectsDoneBtn'); - await this.waitTableIsLoaded(); - } + async getImportErrorText() { + return await this.testSubjects.getVisibleText('importSavedObjectsErrorText'); + } - async clickConfirmChanges() { - await testSubjects.click('importSavedObjectsConfirmBtn'); - } + async clickImportDone() { + await this.testSubjects.click('importSavedObjectsDoneBtn'); + await this.waitTableIsLoaded(); + } - async waitTableIsLoaded() { - return retry.try(async () => { - const isLoaded = await find.existsByDisplayedByCssSelector( - '*[data-test-subj="savedObjectsTable"] :not(.euiBasicTable-loading)' - ); + async clickConfirmChanges() { + await this.testSubjects.click('importSavedObjectsConfirmBtn'); + } - if (isLoaded) { - return true; - } else { - throw new Error('Waiting'); - } - }); - } + async waitTableIsLoaded() { + return this.retry.try(async () => { + const isLoaded = await this.find.existsByDisplayedByCssSelector( + '*[data-test-subj="savedObjectsTable"] :not(.euiBasicTable-loading)' + ); - async clickRelationshipsByTitle(title: string) { - const table = keyBy(await this.getElementsInTable(), 'title'); - // should we check if table size > 0 and log error if not? - if (table[title].menuElement) { - log.debug(`we found a context menu element for (${title}) so click it`); - await table[title].menuElement?.click(); - // Wait for context menu to render - const menuPanel = await find.byCssSelector('.euiContextMenuPanel'); - await (await menuPanel.findByTestSubject('savedObjectsTableAction-relationships')).click(); + if (isLoaded) { + return true; } else { - log.debug( - `we didn't find a menu element so should be a relastionships element for (${title}) to click` - ); - // or the action elements are on the row without the menu - await table[title].relationshipsElement?.click(); + throw new Error('Waiting'); } - } + }); + } - async setOverriddenIndexPatternValue(oldName: string, newName: string) { - const select = await testSubjects.find(`managementChangeIndexSelection-${oldName}`); - const option = await testSubjects.findDescendant(`indexPatternOption-${newName}`, select); - await option.click(); + async clickRelationshipsByTitle(title: string) { + const table = keyBy(await this.getElementsInTable(), 'title'); + // should we check if table size > 0 and log error if not? + if (table[title].menuElement) { + this.log.debug(`we found a context menu element for (${title}) so click it`); + await table[title].menuElement?.click(); + // Wait for context menu to render + const menuPanel = await this.find.byCssSelector('.euiContextMenuPanel'); + await (await menuPanel.findByTestSubject('savedObjectsTableAction-relationships')).click(); + } else { + this.log.debug( + `we didn't find a menu element so should be a relastionships element for (${title}) to click` + ); + // or the action elements are on the row without the menu + await table[title].relationshipsElement?.click(); } + } - async clickCopyToSpaceByTitle(title: string) { - const table = keyBy(await this.getElementsInTable(), 'title'); - // should we check if table size > 0 and log error if not? - if (table[title].menuElement) { - log.debug(`we found a context menu element for (${title}) so click it`); - await table[title].menuElement?.click(); - // Wait for context menu to render - const menuPanel = await find.byCssSelector('.euiContextMenuPanel'); - await ( - await menuPanel.findByTestSubject('savedObjectsTableAction-copy_saved_objects_to_space') - ).click(); - } else { - log.debug( - `we didn't find a menu element so should be a "copy to space" element for (${title}) to click` - ); - // or the action elements are on the row without the menu - await table[title].copySaveObjectsElement?.click(); - } - } + async setOverriddenIndexPatternValue(oldName: string, newName: string) { + const select = await this.testSubjects.find(`managementChangeIndexSelection-${oldName}`); + const option = await this.testSubjects.findDescendant(`indexPatternOption-${newName}`, select); + await option.click(); + } - async clickInspectByTitle(title: string) { - const table = keyBy(await this.getElementsInTable(), 'title'); - if (table[title].menuElement) { - await table[title].menuElement?.click(); - // Wait for context menu to render - const menuPanel = await find.byCssSelector('.euiContextMenuPanel'); - const panelButton = await menuPanel.findByTestSubject('savedObjectsTableAction-inspect'); - await panelButton.click(); - } else { - // or the action elements are on the row without the menu - await table[title].copySaveObjectsElement?.click(); - } + async clickCopyToSpaceByTitle(title: string) { + const table = keyBy(await this.getElementsInTable(), 'title'); + // should we check if table size > 0 and log error if not? + if (table[title].menuElement) { + this.log.debug(`we found a context menu element for (${title}) so click it`); + await table[title].menuElement?.click(); + // Wait for context menu to render + const menuPanel = await this.find.byCssSelector('.euiContextMenuPanel'); + await ( + await menuPanel.findByTestSubject('savedObjectsTableAction-copy_saved_objects_to_space') + ).click(); + } else { + this.log.debug( + `we didn't find a menu element so should be a "copy to space" element for (${title}) to click` + ); + // or the action elements are on the row without the menu + await table[title].copySaveObjectsElement?.click(); } + } - async clickCheckboxByTitle(title: string) { - const table = keyBy(await this.getElementsInTable(), 'title'); - // should we check if table size > 0 and log error if not? - await table[title].checkbox.click(); + async clickInspectByTitle(title: string) { + const table = keyBy(await this.getElementsInTable(), 'title'); + if (table[title].menuElement) { + await table[title].menuElement?.click(); + // Wait for context menu to render + const menuPanel = await this.find.byCssSelector('.euiContextMenuPanel'); + const panelButton = await menuPanel.findByTestSubject('savedObjectsTableAction-inspect'); + await panelButton.click(); + } else { + // or the action elements are on the row without the menu + await table[title].copySaveObjectsElement?.click(); } + } - async getObjectTypeByTitle(title: string) { - const table = keyBy(await this.getElementsInTable(), 'title'); - // should we check if table size > 0 and log error if not? - return table[title].objectType; - } + async clickCheckboxByTitle(title: string) { + const table = keyBy(await this.getElementsInTable(), 'title'); + // should we check if table size > 0 and log error if not? + await table[title].checkbox.click(); + } - async getElementsInTable() { - const rows = await testSubjects.findAll('~savedObjectsTableRow'); - return mapAsync(rows, async (row) => { - const checkbox = await row.findByCssSelector('[data-test-subj*="checkboxSelectRow"]'); - // return the object type aria-label="index patterns" - const objectType = await row.findByTestSubject('objectType'); - const titleElement = await row.findByTestSubject('savedObjectsTableRowTitle'); - // not all rows have inspect button - Advanced Settings objects don't - // Advanced Settings has 2 actions, - // data-test-subj="savedObjectsTableAction-relationships" - // data-test-subj="savedObjectsTableAction-copy_saved_objects_to_space" - // Some other objects have the ... - // data-test-subj="euiCollapsedItemActionsButton" - // Maybe some objects still have the inspect element visible? - // !!! Also note that since we don't have spaces on OSS, the actions for the same object can be different depending on OSS or not - let menuElement = null; - let inspectElement = null; - let relationshipsElement = null; - let copySaveObjectsElement = null; - const actions = await row.findByClassName('euiTableRowCell--hasActions'); - // getting the innerHTML and checking if it 'includes' a string is faster than a timeout looking for each element - const actionsHTML = await actions.getAttribute('innerHTML'); - if (actionsHTML.includes('euiCollapsedItemActionsButton')) { - menuElement = await row.findByTestSubject('euiCollapsedItemActionsButton'); - } - if (actionsHTML.includes('savedObjectsTableAction-inspect')) { - inspectElement = await row.findByTestSubject('savedObjectsTableAction-inspect'); - } - if (actionsHTML.includes('savedObjectsTableAction-relationships')) { - relationshipsElement = await row.findByTestSubject( - 'savedObjectsTableAction-relationships' - ); - } - if (actionsHTML.includes('savedObjectsTableAction-copy_saved_objects_to_space')) { - copySaveObjectsElement = await row.findByTestSubject( - 'savedObjectsTableAction-copy_saved_objects_to_space' - ); - } - return { - checkbox, - objectType: await objectType.getAttribute('aria-label'), - titleElement, - title: await titleElement.getVisibleText(), - menuElement, - inspectElement, - relationshipsElement, - copySaveObjectsElement, - }; - }); - } + async getObjectTypeByTitle(title: string) { + const table = keyBy(await this.getElementsInTable(), 'title'); + // should we check if table size > 0 and log error if not? + return table[title].objectType; + } - async getRowTitles() { - await this.waitTableIsLoaded(); - const table = await testSubjects.find('savedObjectsTable'); - const $ = await table.parseDomContent(); - return $.findTestSubjects('savedObjectsTableRowTitle') - .toArray() - .map((cell) => $(cell).find('.euiTableCellContent').text()); - } + async getElementsInTable() { + const rows = await this.testSubjects.findAll('~savedObjectsTableRow'); + return mapAsync(rows, async (row) => { + const checkbox = await row.findByCssSelector('[data-test-subj*="checkboxSelectRow"]'); + // return the object type aria-label="index patterns" + const objectType = await row.findByTestSubject('objectType'); + const titleElement = await row.findByTestSubject('savedObjectsTableRowTitle'); + // not all rows have inspect button - Advanced Settings objects don't + // Advanced Settings has 2 actions, + // data-test-subj="savedObjectsTableAction-relationships" + // data-test-subj="savedObjectsTableAction-copy_saved_objects_to_space" + // Some other objects have the ... + // data-test-subj="euiCollapsedItemActionsButton" + // Maybe some objects still have the inspect element visible? + // !!! Also note that since we don't have spaces on OSS, the actions for the same object can be different depending on OSS or not + let menuElement = null; + let inspectElement = null; + let relationshipsElement = null; + let copySaveObjectsElement = null; + const actions = await row.findByClassName('euiTableRowCell--hasActions'); + // getting the innerHTML and checking if it 'includes' a string is faster than a timeout looking for each element + const actionsHTML = await actions.getAttribute('innerHTML'); + if (actionsHTML.includes('euiCollapsedItemActionsButton')) { + menuElement = await row.findByTestSubject('euiCollapsedItemActionsButton'); + } + if (actionsHTML.includes('savedObjectsTableAction-inspect')) { + inspectElement = await row.findByTestSubject('savedObjectsTableAction-inspect'); + } + if (actionsHTML.includes('savedObjectsTableAction-relationships')) { + relationshipsElement = await row.findByTestSubject('savedObjectsTableAction-relationships'); + } + if (actionsHTML.includes('savedObjectsTableAction-copy_saved_objects_to_space')) { + copySaveObjectsElement = await row.findByTestSubject( + 'savedObjectsTableAction-copy_saved_objects_to_space' + ); + } + return { + checkbox, + objectType: await objectType.getAttribute('aria-label'), + titleElement, + title: await titleElement.getVisibleText(), + menuElement, + inspectElement, + relationshipsElement, + copySaveObjectsElement, + }; + }); + } - async getRelationshipFlyout() { - const rows = await testSubjects.findAll('relationshipsTableRow'); - return mapAsync(rows, async (row) => { - const objectType = await row.findByTestSubject('relationshipsObjectType'); - const relationship = await row.findByTestSubject('directRelationship'); - const titleElement = await row.findByTestSubject('relationshipsTitle'); - const inspectElement = await row.findByTestSubject('relationshipsTableAction-inspect'); - return { - objectType: await objectType.getAttribute('aria-label'), - relationship: await relationship.getVisibleText(), - titleElement, - title: await titleElement.getVisibleText(), - inspectElement, - }; - }); - } + async getRowTitles() { + await this.waitTableIsLoaded(); + const table = await this.testSubjects.find('savedObjectsTable'); + const $ = await table.parseDomContent(); + return $.findTestSubjects('savedObjectsTableRowTitle') + .toArray() + .map((cell) => $(cell).find('.euiTableCellContent').text()); + } - async getInvalidRelations() { - const rows = await testSubjects.findAll('invalidRelationshipsTableRow'); - return mapAsync(rows, async (row) => { - const objectType = await row.findByTestSubject('relationshipsObjectType'); - const objectId = await row.findByTestSubject('relationshipsObjectId'); - const relationship = await row.findByTestSubject('directRelationship'); - const error = await row.findByTestSubject('relationshipsError'); + async getRelationshipFlyout() { + const rows = await this.testSubjects.findAll('relationshipsTableRow'); + return mapAsync(rows, async (row) => { + const objectType = await row.findByTestSubject('relationshipsObjectType'); + const relationship = await row.findByTestSubject('directRelationship'); + const titleElement = await row.findByTestSubject('relationshipsTitle'); + const inspectElement = await row.findByTestSubject('relationshipsTableAction-inspect'); + return { + objectType: await objectType.getAttribute('aria-label'), + relationship: await relationship.getVisibleText(), + titleElement, + title: await titleElement.getVisibleText(), + inspectElement, + }; + }); + } + + async getInvalidRelations() { + const rows = await this.testSubjects.findAll('invalidRelationshipsTableRow'); + return mapAsync(rows, async (row) => { + const objectType = await row.findByTestSubject('relationshipsObjectType'); + const objectId = await row.findByTestSubject('relationshipsObjectId'); + const relationship = await row.findByTestSubject('directRelationship'); + const error = await row.findByTestSubject('relationshipsError'); + return { + type: await objectType.getVisibleText(), + id: await objectId.getVisibleText(), + relationship: await relationship.getVisibleText(), + error: await error.getVisibleText(), + }; + }); + } + + async getTableSummary() { + const table = await this.testSubjects.find('savedObjectsTable'); + const $ = await table.parseDomContent(); + return $('tbody tr') + .toArray() + .map((row) => { return { - type: await objectType.getVisibleText(), - id: await objectId.getVisibleText(), - relationship: await relationship.getVisibleText(), - error: await error.getVisibleText(), + title: $(row).find('td:nth-child(3) .euiTableCellContent').text(), + canViewInApp: Boolean($(row).find('td:nth-child(3) a').length), }; }); - } + } - async getTableSummary() { - const table = await testSubjects.find('savedObjectsTable'); - const $ = await table.parseDomContent(); - return $('tbody tr') - .toArray() - .map((row) => { - return { - title: $(row).find('td:nth-child(3) .euiTableCellContent').text(), - canViewInApp: Boolean($(row).find('td:nth-child(3) a').length), - }; - }); - } + async clickTableSelectAll() { + await this.testSubjects.click('checkboxSelectAll'); + } - async clickTableSelectAll() { - await testSubjects.click('checkboxSelectAll'); - } + async canBeDeleted() { + return await this.testSubjects.isEnabled('savedObjectsManagementDelete'); + } - async canBeDeleted() { - return await testSubjects.isEnabled('savedObjectsManagementDelete'); + async clickDelete({ confirmDelete = true }: { confirmDelete?: boolean } = {}) { + await this.testSubjects.click('savedObjectsManagementDelete'); + if (confirmDelete) { + await this.testSubjects.click('confirmModalConfirmButton'); + await this.waitTableIsLoaded(); } + } - async clickDelete({ confirmDelete = true }: { confirmDelete?: boolean } = {}) { - await testSubjects.click('savedObjectsManagementDelete'); - if (confirmDelete) { - await testSubjects.click('confirmModalConfirmButton'); - await this.waitTableIsLoaded(); - } - } + async getImportWarnings() { + const elements = await this.testSubjects.findAll('importSavedObjectsWarning'); + return Promise.all( + elements.map(async (element) => { + const message = await element + .findByClassName('euiCallOutHeader__title') + .then((titleEl) => titleEl.getVisibleText()); + const buttons = await element.findAllByClassName('euiButton'); + return { + message, + type: buttons.length ? 'action_required' : 'simple', + }; + }) + ); + } - async getImportWarnings() { - const elements = await testSubjects.findAll('importSavedObjectsWarning'); - return Promise.all( - elements.map(async (element) => { - const message = await element - .findByClassName('euiCallOutHeader__title') - .then((titleEl) => titleEl.getVisibleText()); - const buttons = await element.findAllByClassName('euiButton'); - return { - message, - type: buttons.length ? 'action_required' : 'simple', - }; - }) - ); + async getImportErrorsCount() { + this.log.debug(`Toggling overwriteAll`); + const errorCountNode = await this.testSubjects.find('importSavedObjectsErrorsCount'); + const errorCountText = await errorCountNode.getVisibleText(); + const match = errorCountText.match(/(\d)+/); + if (!match) { + throw Error(`unable to parse error count from text ${errorCountText}`); } - async getImportErrorsCount() { - log.debug(`Toggling overwriteAll`); - const errorCountNode = await testSubjects.find('importSavedObjectsErrorsCount'); - const errorCountText = await errorCountNode.getVisibleText(); - const match = errorCountText.match(/(\d)+/); - if (!match) { - throw Error(`unable to parse error count from text ${errorCountText}`); - } - - return +match[1]; - } + return +match[1]; } - - return new SavedObjectsPage(); } diff --git a/test/functional/page_objects/newsfeed_page.ts b/test/functional/page_objects/newsfeed_page.ts index 1fa9bb5b90002..3a4bbee924552 100644 --- a/test/functional/page_objects/newsfeed_page.ts +++ b/test/functional/page_objects/newsfeed_page.ts @@ -6,58 +6,54 @@ * Side Public License, v 1. */ -import { FtrProviderContext } from '../ftr_provider_context'; - -export function NewsfeedPageProvider({ getService, getPageObjects }: FtrProviderContext) { - const log = getService('log'); - const find = getService('find'); - const retry = getService('retry'); - const flyout = getService('flyout'); - const testSubjects = getService('testSubjects'); - const PageObjects = getPageObjects(['common']); - - class NewsfeedPage { - async resetPage() { - await PageObjects.common.navigateToUrl('home', '', { useActualUrl: true }); - } - - async closeNewsfeedPanel() { - await flyout.ensureClosed('NewsfeedFlyout'); - log.debug('clickNewsfeed icon'); - await retry.waitFor('newsfeed flyout', async () => { - if (await testSubjects.exists('NewsfeedFlyout')) { - await testSubjects.click('NewsfeedFlyout > euiFlyoutCloseButton'); - return false; - } - return true; - }); - } +import { FtrService } from '../ftr_provider_context'; + +export class NewsfeedPageObject extends FtrService { + private readonly log = this.ctx.getService('log'); + private readonly find = this.ctx.getService('find'); + private readonly retry = this.ctx.getService('retry'); + private readonly flyout = this.ctx.getService('flyout'); + private readonly testSubjects = this.ctx.getService('testSubjects'); + private readonly common = this.ctx.getPageObject('common'); + + async resetPage() { + await this.common.navigateToUrl('home', '', { useActualUrl: true }); + } - async openNewsfeedPanel() { - log.debug('clickNewsfeed icon'); - return await testSubjects.exists('NewsfeedFlyout'); - } + async closeNewsfeedPanel() { + await this.flyout.ensureClosed('NewsfeedFlyout'); + this.log.debug('clickNewsfeed icon'); + await this.retry.waitFor('newsfeed flyout', async () => { + if (await this.testSubjects.exists('NewsfeedFlyout')) { + await this.testSubjects.click('NewsfeedFlyout > euiFlyoutCloseButton'); + return false; + } + return true; + }); + } - async getRedButtonSign() { - return await find.existsByCssSelector('.euiHeaderSectionItemButton__notification--dot'); - } + async openNewsfeedPanel() { + this.log.debug('clickNewsfeed icon'); + return await this.testSubjects.exists('NewsfeedFlyout'); + } - async getNewsfeedList() { - const list = await testSubjects.find('NewsfeedFlyout'); - const cells = await list.findAllByTestSubject('newsHeadAlert'); + async getRedButtonSign() { + return await this.find.existsByCssSelector('.euiHeaderSectionItemButton__notification--dot'); + } - const objects = []; - for (const cell of cells) { - objects.push(await cell.getVisibleText()); - } + async getNewsfeedList() { + const list = await this.testSubjects.find('NewsfeedFlyout'); + const cells = await list.findAllByTestSubject('newsHeadAlert'); - return objects; + const objects = []; + for (const cell of cells) { + objects.push(await cell.getVisibleText()); } - async openNewsfeedEmptyPanel() { - return await testSubjects.exists('emptyNewsfeed'); - } + return objects; } - return new NewsfeedPage(); + async openNewsfeedEmptyPanel() { + return await this.testSubjects.exists('emptyNewsfeed'); + } } diff --git a/test/functional/page_objects/settings_page.ts b/test/functional/page_objects/settings_page.ts index 699165a51ca8c..7d7da79b4a397 100644 --- a/test/functional/page_objects/settings_page.ts +++ b/test/functional/page_objects/settings_page.ts @@ -8,731 +8,730 @@ import { map as mapAsync } from 'bluebird'; import expect from '@kbn/expect'; -import { FtrProviderContext } from '../ftr_provider_context'; - -export function SettingsPageProvider({ getService, getPageObjects }: FtrProviderContext) { - const log = getService('log'); - const retry = getService('retry'); - const browser = getService('browser'); - const find = getService('find'); - const flyout = getService('flyout'); - const testSubjects = getService('testSubjects'); - const comboBox = getService('comboBox'); - const PageObjects = getPageObjects(['header', 'common', 'savedObjects']); - - class SettingsPage { - async clickNavigation() { - await find.clickDisplayedByCssSelector('.app-link:nth-child(5) a'); - } +import { FtrService } from '../ftr_provider_context'; + +export class SettingsPageObject extends FtrService { + private readonly log = this.ctx.getService('log'); + private readonly retry = this.ctx.getService('retry'); + private readonly browser = this.ctx.getService('browser'); + private readonly find = this.ctx.getService('find'); + private readonly flyout = this.ctx.getService('flyout'); + private readonly testSubjects = this.ctx.getService('testSubjects'); + private readonly comboBox = this.ctx.getService('comboBox'); + private readonly header = this.ctx.getPageObject('header'); + private readonly common = this.ctx.getPageObject('common'); + private readonly savedObjects = this.ctx.getPageObject('savedObjects'); + + async clickNavigation() { + await this.find.clickDisplayedByCssSelector('.app-link:nth-child(5) a'); + } - async clickLinkText(text: string) { - await find.clickByDisplayedLinkText(text); - } + async clickLinkText(text: string) { + await this.find.clickByDisplayedLinkText(text); + } - async clickKibanaSettings() { - await testSubjects.click('settings'); - await PageObjects.header.waitUntilLoadingHasFinished(); - await testSubjects.existOrFail('managementSettingsTitle'); - } + async clickKibanaSettings() { + await this.testSubjects.click('settings'); + await this.header.waitUntilLoadingHasFinished(); + await this.testSubjects.existOrFail('managementSettingsTitle'); + } - async clickKibanaSavedObjects() { - await testSubjects.click('objects'); - await PageObjects.savedObjects.waitTableIsLoaded(); - } + async clickKibanaSavedObjects() { + await this.testSubjects.click('objects'); + await this.savedObjects.waitTableIsLoaded(); + } - async clickKibanaIndexPatterns() { - log.debug('clickKibanaIndexPatterns link'); - await testSubjects.click('indexPatterns'); + async clickKibanaIndexPatterns() { + this.log.debug('clickKibanaIndexPatterns link'); + await this.testSubjects.click('indexPatterns'); - await PageObjects.header.waitUntilLoadingHasFinished(); - } + await this.header.waitUntilLoadingHasFinished(); + } - async getAdvancedSettings(propertyName: string) { - log.debug('in getAdvancedSettings'); - return await testSubjects.getAttribute(`advancedSetting-editField-${propertyName}`, 'value'); - } + async getAdvancedSettings(propertyName: string) { + this.log.debug('in getAdvancedSettings'); + return await this.testSubjects.getAttribute( + `advancedSetting-editField-${propertyName}`, + 'value' + ); + } - async expectDisabledAdvancedSetting(propertyName: string) { - expect( - await testSubjects.getAttribute(`advancedSetting-editField-${propertyName}`, 'disabled') - ).to.eql('true'); - } + async expectDisabledAdvancedSetting(propertyName: string) { + expect( + await this.testSubjects.getAttribute(`advancedSetting-editField-${propertyName}`, 'disabled') + ).to.eql('true'); + } - async getAdvancedSettingCheckbox(propertyName: string) { - log.debug('in getAdvancedSettingCheckbox'); - return await testSubjects.getAttribute( - `advancedSetting-editField-${propertyName}`, - 'checked' - ); - } + async getAdvancedSettingCheckbox(propertyName: string) { + this.log.debug('in getAdvancedSettingCheckbox'); + return await this.testSubjects.getAttribute( + `advancedSetting-editField-${propertyName}`, + 'checked' + ); + } - async clearAdvancedSettings(propertyName: string) { - await testSubjects.click(`advancedSetting-resetField-${propertyName}`); - await PageObjects.header.waitUntilLoadingHasFinished(); - await testSubjects.click(`advancedSetting-saveButton`); - await PageObjects.header.waitUntilLoadingHasFinished(); - } + async clearAdvancedSettings(propertyName: string) { + await this.testSubjects.click(`advancedSetting-resetField-${propertyName}`); + await this.header.waitUntilLoadingHasFinished(); + await this.testSubjects.click(`advancedSetting-saveButton`); + await this.header.waitUntilLoadingHasFinished(); + } - async setAdvancedSettingsSelect(propertyName: string, propertyValue: string) { - await find.clickByCssSelector( - `[data-test-subj="advancedSetting-editField-${propertyName}"] option[value="${propertyValue}"]` - ); - await PageObjects.header.waitUntilLoadingHasFinished(); - await testSubjects.click(`advancedSetting-saveButton`); - await PageObjects.header.waitUntilLoadingHasFinished(); - } + async setAdvancedSettingsSelect(propertyName: string, propertyValue: string) { + await this.find.clickByCssSelector( + `[data-test-subj="advancedSetting-editField-${propertyName}"] option[value="${propertyValue}"]` + ); + await this.header.waitUntilLoadingHasFinished(); + await this.testSubjects.click(`advancedSetting-saveButton`); + await this.header.waitUntilLoadingHasFinished(); + } - async setAdvancedSettingsInput(propertyName: string, propertyValue: string) { - const input = await testSubjects.find(`advancedSetting-editField-${propertyName}`); - await input.clearValue(); - await input.type(propertyValue); - await testSubjects.click(`advancedSetting-saveButton`); - await PageObjects.header.waitUntilLoadingHasFinished(); - } + async setAdvancedSettingsInput(propertyName: string, propertyValue: string) { + const input = await this.testSubjects.find(`advancedSetting-editField-${propertyName}`); + await input.clearValue(); + await input.type(propertyValue); + await this.testSubjects.click(`advancedSetting-saveButton`); + await this.header.waitUntilLoadingHasFinished(); + } - async setAdvancedSettingsTextArea(propertyName: string, propertyValue: string) { - const wrapper = await testSubjects.find(`advancedSetting-editField-${propertyName}`); - const textarea = await wrapper.findByTagName('textarea'); - await textarea.focus(); - // only way to properly replace the value of the ace editor is via the JS api - await browser.execute( - (editor: string, value: string) => { - return (window as any).ace.edit(editor).setValue(value); - }, - `advancedSetting-editField-${propertyName}-editor`, - propertyValue - ); - await testSubjects.click(`advancedSetting-saveButton`); - await PageObjects.header.waitUntilLoadingHasFinished(); - } + async setAdvancedSettingsTextArea(propertyName: string, propertyValue: string) { + const wrapper = await this.testSubjects.find(`advancedSetting-editField-${propertyName}`); + const textarea = await wrapper.findByTagName('textarea'); + await textarea.focus(); + // only way to properly replace the value of the ace editor is via the JS api + await this.browser.execute( + (editor: string, value: string) => { + return (window as any).ace.edit(editor).setValue(value); + }, + `advancedSetting-editField-${propertyName}-editor`, + propertyValue + ); + await this.testSubjects.click(`advancedSetting-saveButton`); + await this.header.waitUntilLoadingHasFinished(); + } - async toggleAdvancedSettingCheckbox(propertyName: string) { - await testSubjects.click(`advancedSetting-editField-${propertyName}`); - await PageObjects.header.waitUntilLoadingHasFinished(); - await testSubjects.click(`advancedSetting-saveButton`); - await PageObjects.header.waitUntilLoadingHasFinished(); - } + async toggleAdvancedSettingCheckbox(propertyName: string) { + await this.testSubjects.click(`advancedSetting-editField-${propertyName}`); + await this.header.waitUntilLoadingHasFinished(); + await this.testSubjects.click(`advancedSetting-saveButton`); + await this.header.waitUntilLoadingHasFinished(); + } - async navigateTo() { - await PageObjects.common.navigateToApp('settings'); - } + async navigateTo() { + await this.common.navigateToApp('settings'); + } - async getIndexPatternField() { - return await testSubjects.find('createIndexPatternNameInput'); - } + async getIndexPatternField() { + return await this.testSubjects.find('createIndexPatternNameInput'); + } - async clickTimeFieldNameField() { - return await testSubjects.click('createIndexPatternTimeFieldSelect'); - } + async clickTimeFieldNameField() { + return await this.testSubjects.click('createIndexPatternTimeFieldSelect'); + } - async getTimeFieldNameField() { - return await testSubjects.find('createIndexPatternTimeFieldSelect'); - } + async getTimeFieldNameField() { + return await this.testSubjects.find('createIndexPatternTimeFieldSelect'); + } - async selectTimeFieldOption(selection: string) { - // open dropdown - await this.clickTimeFieldNameField(); - // close dropdown, keep focus - await this.clickTimeFieldNameField(); - await PageObjects.header.waitUntilLoadingHasFinished(); - return await retry.try(async () => { - log.debug(`selectTimeFieldOption(${selection})`); - const timeFieldOption = await this.getTimeFieldOption(selection); - await timeFieldOption.click(); - const selected = await timeFieldOption.isSelected(); - if (!selected) throw new Error('option not selected: ' + selected); - }); - } + async selectTimeFieldOption(selection: string) { + // open dropdown + await this.clickTimeFieldNameField(); + // close dropdown, keep focus + await this.clickTimeFieldNameField(); + await this.header.waitUntilLoadingHasFinished(); + return await this.retry.try(async () => { + this.log.debug(`selectTimeFieldOption(${selection})`); + const timeFieldOption = await this.getTimeFieldOption(selection); + await timeFieldOption.click(); + const selected = await timeFieldOption.isSelected(); + if (!selected) throw new Error('option not selected: ' + selected); + }); + } - async getTimeFieldOption(selection: string) { - return await find.displayedByCssSelector('option[value="' + selection + '"]'); - } + async getTimeFieldOption(selection: string) { + return await this.find.displayedByCssSelector('option[value="' + selection + '"]'); + } - async getCreateIndexPatternButton() { - return await testSubjects.find('createIndexPatternButton'); - } + async getCreateIndexPatternButton() { + return await this.testSubjects.find('createIndexPatternButton'); + } - async getCreateButton() { - return await find.displayedByCssSelector('[type="submit"]'); - } + async getCreateButton() { + return await this.find.displayedByCssSelector('[type="submit"]'); + } - async clickDefaultIndexButton() { - await testSubjects.click('setDefaultIndexPatternButton'); - await PageObjects.header.waitUntilLoadingHasFinished(); - } + async clickDefaultIndexButton() { + await this.testSubjects.click('setDefaultIndexPatternButton'); + await this.header.waitUntilLoadingHasFinished(); + } - async clickDeletePattern() { - await testSubjects.click('deleteIndexPatternButton'); - } + async clickDeletePattern() { + await this.testSubjects.click('deleteIndexPatternButton'); + } - async getIndexPageHeading() { - return await testSubjects.getVisibleText('indexPatternTitle'); - } + async getIndexPageHeading() { + return await this.testSubjects.getVisibleText('indexPatternTitle'); + } - async getConfigureHeader() { - return await find.byCssSelector('h1'); - } + async getConfigureHeader() { + return await this.find.byCssSelector('h1'); + } - async getTableHeader() { - return await find.allByCssSelector('table.euiTable thead tr th'); - } + async getTableHeader() { + return await this.find.allByCssSelector('table.euiTable thead tr th'); + } - async sortBy(columnName: string) { - const chartTypes = await find.allByCssSelector('table.euiTable thead tr th button'); + async sortBy(columnName: string) { + const chartTypes = await this.find.allByCssSelector('table.euiTable thead tr th button'); - async function getChartType(chart: Record) { - const chartString = await chart.getVisibleText(); - if (chartString === columnName) { - await chart.click(); - await PageObjects.header.waitUntilLoadingHasFinished(); - } + const getChartType = async (chart: Record) => { + const chartString = await chart.getVisibleText(); + if (chartString === columnName) { + await chart.click(); + await this.header.waitUntilLoadingHasFinished(); } + }; - const getChartTypesPromises = chartTypes.map(getChartType); - return Promise.all(getChartTypesPromises); - } + const getChartTypesPromises = chartTypes.map(getChartType); + return Promise.all(getChartTypesPromises); + } - async getTableRow(rowNumber: number, colNumber: number) { - // passing in zero-based index, but adding 1 for css 1-based indexes - return await find.byCssSelector( - 'table.euiTable tbody tr:nth-child(' + - (rowNumber + 1) + - ') td.euiTableRowCell:nth-child(' + - (colNumber + 1) + - ')' - ); - } + async getTableRow(rowNumber: number, colNumber: number) { + // passing in zero-based index, but adding 1 for css 1-based indexes + return await this.find.byCssSelector( + 'table.euiTable tbody tr:nth-child(' + + (rowNumber + 1) + + ') td.euiTableRowCell:nth-child(' + + (colNumber + 1) + + ')' + ); + } - async getFieldsTabCount() { - return retry.try(async () => { - const text = await testSubjects.getVisibleText('tab-indexedFields'); - return text.split(' ')[1].replace(/\((.*)\)/, '$1'); - }); - } + async getFieldsTabCount() { + return this.retry.try(async () => { + const text = await this.testSubjects.getVisibleText('tab-indexedFields'); + return text.split(' ')[1].replace(/\((.*)\)/, '$1'); + }); + } - async getScriptedFieldsTabCount() { - return await retry.try(async () => { - const text = await testSubjects.getVisibleText('tab-scriptedFields'); - return text.split(' ')[2].replace(/\((.*)\)/, '$1'); - }); - } + async getScriptedFieldsTabCount() { + return await this.retry.try(async () => { + const text = await this.testSubjects.getVisibleText('tab-scriptedFields'); + return text.split(' ')[2].replace(/\((.*)\)/, '$1'); + }); + } - async getFieldNames() { - const fieldNameCells = await testSubjects.findAll('editIndexPattern > indexedFieldName'); - return await mapAsync(fieldNameCells, async (cell) => { - return (await cell.getVisibleText()).trim(); - }); - } + async getFieldNames() { + const fieldNameCells = await this.testSubjects.findAll('editIndexPattern > indexedFieldName'); + return await mapAsync(fieldNameCells, async (cell) => { + return (await cell.getVisibleText()).trim(); + }); + } - async getFieldTypes() { - const fieldNameCells = await testSubjects.findAll('editIndexPattern > indexedFieldType'); - return await mapAsync(fieldNameCells, async (cell) => { - return (await cell.getVisibleText()).trim(); - }); - } + async getFieldTypes() { + const fieldNameCells = await this.testSubjects.findAll('editIndexPattern > indexedFieldType'); + return await mapAsync(fieldNameCells, async (cell) => { + return (await cell.getVisibleText()).trim(); + }); + } - async getScriptedFieldLangs() { - const fieldNameCells = await testSubjects.findAll('editIndexPattern > scriptedFieldLang'); - return await mapAsync(fieldNameCells, async (cell) => { - return (await cell.getVisibleText()).trim(); - }); - } + async getScriptedFieldLangs() { + const fieldNameCells = await this.testSubjects.findAll('editIndexPattern > scriptedFieldLang'); + return await mapAsync(fieldNameCells, async (cell) => { + return (await cell.getVisibleText()).trim(); + }); + } - async setFieldTypeFilter(type: string) { - await find.clickByCssSelector( - 'select[data-test-subj="indexedFieldTypeFilterDropdown"] > option[value="' + type + '"]' - ); - } + async setFieldTypeFilter(type: string) { + await this.find.clickByCssSelector( + 'select[data-test-subj="indexedFieldTypeFilterDropdown"] > option[value="' + type + '"]' + ); + } - async setScriptedFieldLanguageFilter(language: string) { - await find.clickByCssSelector( - 'select[data-test-subj="scriptedFieldLanguageFilterDropdown"] > option[value="' + - language + - '"]' - ); - } + async setScriptedFieldLanguageFilter(language: string) { + await this.find.clickByCssSelector( + 'select[data-test-subj="scriptedFieldLanguageFilterDropdown"] > option[value="' + + language + + '"]' + ); + } - async filterField(name: string) { - const input = await testSubjects.find('indexPatternFieldFilter'); - await input.clearValue(); - await input.type(name); - } + async filterField(name: string) { + const input = await this.testSubjects.find('indexPatternFieldFilter'); + await input.clearValue(); + await input.type(name); + } - async openControlsByName(name: string) { - await this.filterField(name); - const tableFields = await ( - await find.byCssSelector( - 'table.euiTable tbody tr.euiTableRow td.euiTableRowCell:first-child' - ) - ).getVisibleText(); - - await find.clickByCssSelector( - `table.euiTable tbody tr.euiTableRow:nth-child(${tableFields.indexOf(name) + 1}) - td:nth-last-child(2) button` - ); - } + async openControlsByName(name: string) { + await this.filterField(name); + const tableFields = await ( + await this.find.byCssSelector( + 'table.euiTable tbody tr.euiTableRow td.euiTableRowCell:first-child' + ) + ).getVisibleText(); + + await this.find.clickByCssSelector( + `table.euiTable tbody tr.euiTableRow:nth-child(${tableFields.indexOf(name) + 1}) + td:nth-last-child(2) button` + ); + } - async increasePopularity() { - await testSubjects.setValue('editorFieldCount', '1', { clearWithKeyboard: true }); - } + async increasePopularity() { + await this.testSubjects.setValue('editorFieldCount', '1', { clearWithKeyboard: true }); + } - async getPopularity() { - return await testSubjects.getAttribute('editorFieldCount', 'value'); - } + async getPopularity() { + return await this.testSubjects.getAttribute('editorFieldCount', 'value'); + } - async controlChangeCancel() { - await testSubjects.click('fieldCancelButton'); - await PageObjects.header.waitUntilLoadingHasFinished(); - } + async controlChangeCancel() { + await this.testSubjects.click('fieldCancelButton'); + await this.header.waitUntilLoadingHasFinished(); + } - async controlChangeSave() { - await testSubjects.click('fieldSaveButton'); - await PageObjects.header.waitUntilLoadingHasFinished(); - } + async controlChangeSave() { + await this.testSubjects.click('fieldSaveButton'); + await this.header.waitUntilLoadingHasFinished(); + } - async hasIndexPattern(name: string) { - return await find.existsByLinkText(name); - } + async hasIndexPattern(name: string) { + return await this.find.existsByLinkText(name); + } - async clickIndexPatternByName(name: string) { - const indexLink = await find.byXPath(`//a[descendant::*[text()='${name}']]`); - await indexLink.click(); - } + async clickIndexPatternByName(name: string) { + const indexLink = await this.find.byXPath(`//a[descendant::*[text()='${name}']]`); + await indexLink.click(); + } - async clickIndexPatternLogstash() { - await this.clickIndexPatternByName('logstash-*'); - } + async clickIndexPatternLogstash() { + await this.clickIndexPatternByName('logstash-*'); + } - async getIndexPatternList() { - await testSubjects.existOrFail('indexPatternTable', { timeout: 5000 }); - return await find.allByCssSelector( - '[data-test-subj="indexPatternTable"] .euiTable .euiTableRow' - ); - } + async getIndexPatternList() { + await this.testSubjects.existOrFail('indexPatternTable', { timeout: 5000 }); + return await this.find.allByCssSelector( + '[data-test-subj="indexPatternTable"] .euiTable .euiTableRow' + ); + } - async getAllIndexPatternNames() { - const indexPatterns = await this.getIndexPatternList(); - return await mapAsync(indexPatterns, async (index) => { - return await index.getVisibleText(); - }); - } + async getAllIndexPatternNames() { + const indexPatterns = await this.getIndexPatternList(); + return await mapAsync(indexPatterns, async (index) => { + return await index.getVisibleText(); + }); + } - async isIndexPatternListEmpty() { - return !(await testSubjects.exists('indexPatternTable', { timeout: 5000 })); - } + async isIndexPatternListEmpty() { + return !(await this.testSubjects.exists('indexPatternTable', { timeout: 5000 })); + } - async removeLogstashIndexPatternIfExist() { - if (!(await this.isIndexPatternListEmpty())) { - await this.clickIndexPatternLogstash(); - await this.removeIndexPattern(); - } + async removeLogstashIndexPatternIfExist() { + if (!(await this.isIndexPatternListEmpty())) { + await this.clickIndexPatternLogstash(); + await this.removeIndexPattern(); } + } - async createIndexPattern( - indexPatternName: string, - // null to bypass default value - timefield: string | null = '@timestamp', - isStandardIndexPattern = true - ) { - await retry.try(async () => { - await PageObjects.header.waitUntilLoadingHasFinished(); - await this.clickKibanaIndexPatterns(); - const exists = await this.hasIndexPattern(indexPatternName); - - if (exists) { - await this.clickIndexPatternByName(indexPatternName); - return; - } + async createIndexPattern( + indexPatternName: string, + // null to bypass default value + timefield: string | null = '@timestamp', + isStandardIndexPattern = true + ) { + await this.retry.try(async () => { + await this.header.waitUntilLoadingHasFinished(); + await this.clickKibanaIndexPatterns(); + const exists = await this.hasIndexPattern(indexPatternName); + + if (exists) { + await this.clickIndexPatternByName(indexPatternName); + return; + } - await PageObjects.header.waitUntilLoadingHasFinished(); - await this.clickAddNewIndexPatternButton(); - if (!isStandardIndexPattern) { - await this.clickCreateNewRollupButton(); - } - await PageObjects.header.waitUntilLoadingHasFinished(); - await retry.try(async () => { - await this.setIndexPatternField(indexPatternName); - }); - - const btn = await this.getCreateIndexPatternGoToStep2Button(); - await retry.waitFor(`index pattern Go To Step 2 button to be enabled`, async () => { - return await btn.isEnabled(); - }); - await btn.click(); - - await PageObjects.common.sleep(2000); - if (timefield) { - await this.selectTimeFieldOption(timefield); - } - await (await this.getCreateIndexPatternButton()).click(); + await this.header.waitUntilLoadingHasFinished(); + await this.clickAddNewIndexPatternButton(); + if (!isStandardIndexPattern) { + await this.clickCreateNewRollupButton(); + } + await this.header.waitUntilLoadingHasFinished(); + await this.retry.try(async () => { + await this.setIndexPatternField(indexPatternName); }); - await PageObjects.header.waitUntilLoadingHasFinished(); - await retry.try(async () => { - const currentUrl = await browser.getCurrentUrl(); - log.info('currentUrl', currentUrl); - if (!currentUrl.match(/indexPatterns\/.+\?/)) { - throw new Error('Index pattern not created'); - } else { - log.debug('Index pattern created: ' + currentUrl); - } + + const btn = await this.getCreateIndexPatternGoToStep2Button(); + await this.retry.waitFor(`index pattern Go To Step 2 button to be enabled`, async () => { + return await btn.isEnabled(); }); + await btn.click(); - return await this.getIndexPatternIdFromUrl(); - } + await this.common.sleep(2000); + if (timefield) { + await this.selectTimeFieldOption(timefield); + } + await (await this.getCreateIndexPatternButton()).click(); + }); + await this.header.waitUntilLoadingHasFinished(); + await this.retry.try(async () => { + const currentUrl = await this.browser.getCurrentUrl(); + this.log.info('currentUrl', currentUrl); + if (!currentUrl.match(/indexPatterns\/.+\?/)) { + throw new Error('Index pattern not created'); + } else { + this.log.debug('Index pattern created: ' + currentUrl); + } + }); - async clickAddNewIndexPatternButton() { - await PageObjects.common.scrollKibanaBodyTop(); - await testSubjects.click('createIndexPatternButton'); - } + return await this.getIndexPatternIdFromUrl(); + } - async clickCreateNewRollupButton() { - await testSubjects.click('createRollupIndexPatternButton'); - } + async clickAddNewIndexPatternButton() { + await this.common.scrollKibanaBodyTop(); + await this.testSubjects.click('createIndexPatternButton'); + } - async getIndexPatternIdFromUrl() { - const currentUrl = await browser.getCurrentUrl(); - const indexPatternId = currentUrl.match(/.*\/(.*)/)![1]; + async clickCreateNewRollupButton() { + await this.testSubjects.click('createRollupIndexPatternButton'); + } - log.debug('index pattern ID: ', indexPatternId); + async getIndexPatternIdFromUrl() { + const currentUrl = await this.browser.getCurrentUrl(); + const indexPatternId = currentUrl.match(/.*\/(.*)/)![1]; - return indexPatternId; - } + this.log.debug('index pattern ID: ', indexPatternId); - async setIndexPatternField(indexPatternName = 'logstash-*') { - log.debug(`setIndexPatternField(${indexPatternName})`); - const field = await this.getIndexPatternField(); - await field.clearValue(); - if ( - indexPatternName.charAt(0) === '*' && - indexPatternName.charAt(indexPatternName.length - 1) === '*' - ) { - // this is a special case when the index pattern name starts with '*' - // like '*:makelogs-*' where the UI will not append * - await field.type(indexPatternName, { charByChar: true }); - } else if (indexPatternName.charAt(indexPatternName.length - 1) === '*') { - // the common case where the UI will append '*' automatically so we won't type it - const tempName = indexPatternName.slice(0, -1); - await field.type(tempName, { charByChar: true }); - } else { - // case where we don't want the * appended so we'll remove it if it was added - await field.type(indexPatternName, { charByChar: true }); - const tempName = await field.getAttribute('value'); - if (tempName.length > indexPatternName.length) { - await field.type(browser.keys.DELETE, { charByChar: true }); - } + return indexPatternId; + } + + async setIndexPatternField(indexPatternName = 'logstash-*') { + this.log.debug(`setIndexPatternField(${indexPatternName})`); + const field = await this.getIndexPatternField(); + await field.clearValue(); + if ( + indexPatternName.charAt(0) === '*' && + indexPatternName.charAt(indexPatternName.length - 1) === '*' + ) { + // this is a special case when the index pattern name starts with '*' + // like '*:makelogs-*' where the UI will not append * + await field.type(indexPatternName, { charByChar: true }); + } else if (indexPatternName.charAt(indexPatternName.length - 1) === '*') { + // the common case where the UI will append '*' automatically so we won't type it + const tempName = indexPatternName.slice(0, -1); + await field.type(tempName, { charByChar: true }); + } else { + // case where we don't want the * appended so we'll remove it if it was added + await field.type(indexPatternName, { charByChar: true }); + const tempName = await field.getAttribute('value'); + if (tempName.length > indexPatternName.length) { + await field.type(this.browser.keys.DELETE, { charByChar: true }); } - const currentName = await field.getAttribute('value'); - log.debug(`setIndexPatternField set to ${currentName}`); - expect(currentName).to.eql(indexPatternName); } + const currentName = await field.getAttribute('value'); + this.log.debug(`setIndexPatternField set to ${currentName}`); + expect(currentName).to.eql(indexPatternName); + } - async getCreateIndexPatternGoToStep2Button() { - return await testSubjects.find('createIndexPatternGoToStep2Button'); - } + async getCreateIndexPatternGoToStep2Button() { + return await this.testSubjects.find('createIndexPatternGoToStep2Button'); + } - async removeIndexPattern() { - let alertText; - await retry.try(async () => { - log.debug('click delete index pattern button'); - await this.clickDeletePattern(); - }); - await retry.try(async () => { - log.debug('getAlertText'); - alertText = await testSubjects.getVisibleText('confirmModalTitleText'); - }); - await retry.try(async () => { - log.debug('acceptConfirmation'); - await testSubjects.click('confirmModalConfirmButton'); - }); - await retry.try(async () => { - const currentUrl = await browser.getCurrentUrl(); - if (currentUrl.match(/index_patterns\/.+\?/)) { - throw new Error('Index pattern not removed'); - } - }); - return alertText; - } + async removeIndexPattern() { + let alertText; + await this.retry.try(async () => { + this.log.debug('click delete index pattern button'); + await this.clickDeletePattern(); + }); + await this.retry.try(async () => { + this.log.debug('getAlertText'); + alertText = await this.testSubjects.getVisibleText('confirmModalTitleText'); + }); + await this.retry.try(async () => { + this.log.debug('acceptConfirmation'); + await this.testSubjects.click('confirmModalConfirmButton'); + }); + await this.retry.try(async () => { + const currentUrl = await this.browser.getCurrentUrl(); + if (currentUrl.match(/index_patterns\/.+\?/)) { + throw new Error('Index pattern not removed'); + } + }); + return alertText; + } - async clickFieldsTab() { - log.debug('click Fields tab'); - await testSubjects.click('tab-indexedFields'); - } + async clickFieldsTab() { + this.log.debug('click Fields tab'); + await this.testSubjects.click('tab-indexedFields'); + } - async clickScriptedFieldsTab() { - log.debug('click Scripted Fields tab'); - await testSubjects.click('tab-scriptedFields'); - } + async clickScriptedFieldsTab() { + this.log.debug('click Scripted Fields tab'); + await this.testSubjects.click('tab-scriptedFields'); + } - async clickSourceFiltersTab() { - log.debug('click Source Filters tab'); - await testSubjects.click('tab-sourceFilters'); - } + async clickSourceFiltersTab() { + this.log.debug('click Source Filters tab'); + await this.testSubjects.click('tab-sourceFilters'); + } - async editScriptedField(name: string) { - await this.filterField(name); - await find.clickByCssSelector('.euiTableRowCell--hasActions button:first-child'); - } + async editScriptedField(name: string) { + await this.filterField(name); + await this.find.clickByCssSelector('.euiTableRowCell--hasActions button:first-child'); + } - async addScriptedField( - name: string, - language: string, - type: string, - format: Record, - popularity: string, - script: string - ) { - await this.clickAddScriptedField(); - await this.setScriptedFieldName(name); - if (language) await this.setScriptedFieldLanguage(language); - if (type) await this.setScriptedFieldType(type); - if (format) { - await this.setFieldFormat(format.format); - // null means leave - default - which has no other settings - // Url adds Type, Url Template, and Label Template - // Date adds moment.js format pattern (Default: "MMMM Do YYYY, HH:mm:ss.SSS") - // String adds Transform - switch (format.format) { - case 'url': - await this.setScriptedFieldUrlType(format.type); - await this.setScriptedFieldUrlTemplate(format.template); - await this.setScriptedFieldUrlLabelTemplate(format.labelTemplate); - break; - case 'date': - await this.setScriptedFieldDatePattern(format.datePattern); - break; - case 'string': - await this.setScriptedFieldStringTransform(format.stringTransform); - break; - } + async addScriptedField( + name: string, + language: string, + type: string, + format: Record, + popularity: string, + script: string + ) { + await this.clickAddScriptedField(); + await this.setScriptedFieldName(name); + if (language) await this.setScriptedFieldLanguage(language); + if (type) await this.setScriptedFieldType(type); + if (format) { + await this.setFieldFormat(format.format); + // null means leave - default - which has no other settings + // Url adds Type, Url Template, and Label Template + // Date adds moment.js format pattern (Default: "MMMM Do YYYY, HH:mm:ss.SSS") + // String adds Transform + switch (format.format) { + case 'url': + await this.setScriptedFieldUrlType(format.type); + await this.setScriptedFieldUrlTemplate(format.template); + await this.setScriptedFieldUrlLabelTemplate(format.labelTemplate); + break; + case 'date': + await this.setScriptedFieldDatePattern(format.datePattern); + break; + case 'string': + await this.setScriptedFieldStringTransform(format.stringTransform); + break; } - if (popularity) await this.setScriptedFieldPopularity(popularity); - await this.setScriptedFieldScript(script); - await this.clickSaveScriptedField(); } + if (popularity) await this.setScriptedFieldPopularity(popularity); + await this.setScriptedFieldScript(script); + await this.clickSaveScriptedField(); + } - async addRuntimeField(name: string, type: string, script: string) { - await this.clickAddField(); - await this.setFieldName(name); - await this.setFieldType(type); - if (script) { - await this.setFieldScript(script); - } - await this.clickSaveField(); - await this.closeIndexPatternFieldEditor(); + async addRuntimeField(name: string, type: string, script: string) { + await this.clickAddField(); + await this.setFieldName(name); + await this.setFieldType(type); + if (script) { + await this.setFieldScript(script); } + await this.clickSaveField(); + await this.closeIndexPatternFieldEditor(); + } - public async confirmSave() { - await testSubjects.setValue('saveModalConfirmText', 'change'); - await testSubjects.click('confirmModalConfirmButton'); - } + public async confirmSave() { + await this.testSubjects.setValue('saveModalConfirmText', 'change'); + await this.testSubjects.click('confirmModalConfirmButton'); + } - public async confirmDelete() { - await testSubjects.setValue('deleteModalConfirmText', 'remove'); - await testSubjects.click('confirmModalConfirmButton'); - } + public async confirmDelete() { + await this.testSubjects.setValue('deleteModalConfirmText', 'remove'); + await this.testSubjects.click('confirmModalConfirmButton'); + } - async closeIndexPatternFieldEditor() { - await retry.waitFor('field editor flyout to close', async () => { - return !(await testSubjects.exists('euiFlyoutCloseButton')); - }); - } + async closeIndexPatternFieldEditor() { + await this.retry.waitFor('field editor flyout to close', async () => { + return !(await this.testSubjects.exists('euiFlyoutCloseButton')); + }); + } - async clickAddField() { - log.debug('click Add Field'); - await testSubjects.click('addField'); - } + async clickAddField() { + this.log.debug('click Add Field'); + await this.testSubjects.click('addField'); + } - async clickSaveField() { - log.debug('click Save'); - await testSubjects.click('fieldSaveButton'); - } + async clickSaveField() { + this.log.debug('click Save'); + await this.testSubjects.click('fieldSaveButton'); + } - async setFieldName(name: string) { - log.debug('set field name = ' + name); - await testSubjects.setValue('nameField', name); - } + async setFieldName(name: string) { + this.log.debug('set field name = ' + name); + await this.testSubjects.setValue('nameField', name); + } - async setFieldType(type: string) { - log.debug('set type = ' + type); - await testSubjects.setValue('typeField', type); - } + async setFieldType(type: string) { + this.log.debug('set type = ' + type); + await this.testSubjects.setValue('typeField', type); + } - async setFieldScript(script: string) { - log.debug('set script = ' + script); - const formatRow = await testSubjects.find('valueRow'); - const formatRowToggle = ( - await formatRow.findAllByCssSelector('[data-test-subj="toggle"]') - )[0]; - - await formatRowToggle.click(); - const getMonacoTextArea = async () => (await formatRow.findAllByCssSelector('textarea'))[0]; - retry.waitFor('monaco editor is ready', async () => !!(await getMonacoTextArea())); - const monacoTextArea = await getMonacoTextArea(); - await monacoTextArea.focus(); - browser.pressKeys(script); - } + async setFieldScript(script: string) { + this.log.debug('set script = ' + script); + const formatRow = await this.testSubjects.find('valueRow'); + const formatRowToggle = (await formatRow.findAllByCssSelector('[data-test-subj="toggle"]'))[0]; + + await formatRowToggle.click(); + const getMonacoTextArea = async () => (await formatRow.findAllByCssSelector('textarea'))[0]; + this.retry.waitFor('monaco editor is ready', async () => !!(await getMonacoTextArea())); + const monacoTextArea = await getMonacoTextArea(); + await monacoTextArea.focus(); + this.browser.pressKeys(script); + } - async changeFieldScript(script: string) { - log.debug('set script = ' + script); - const formatRow = await testSubjects.find('valueRow'); - const getMonacoTextArea = async () => (await formatRow.findAllByCssSelector('textarea'))[0]; - retry.waitFor('monaco editor is ready', async () => !!(await getMonacoTextArea())); - const monacoTextArea = await getMonacoTextArea(); - await monacoTextArea.focus(); - browser.pressKeys(browser.keys.DELETE.repeat(30)); - browser.pressKeys(script); - } + async changeFieldScript(script: string) { + this.log.debug('set script = ' + script); + const formatRow = await this.testSubjects.find('valueRow'); + const getMonacoTextArea = async () => (await formatRow.findAllByCssSelector('textarea'))[0]; + this.retry.waitFor('monaco editor is ready', async () => !!(await getMonacoTextArea())); + const monacoTextArea = await getMonacoTextArea(); + await monacoTextArea.focus(); + this.browser.pressKeys(this.browser.keys.DELETE.repeat(30)); + this.browser.pressKeys(script); + } - async clickAddScriptedField() { - log.debug('click Add Scripted Field'); - await testSubjects.click('addScriptedFieldLink'); - } + async clickAddScriptedField() { + this.log.debug('click Add Scripted Field'); + await this.testSubjects.click('addScriptedFieldLink'); + } - async clickSaveScriptedField() { - log.debug('click Save Scripted Field'); - await testSubjects.click('fieldSaveButton'); - await PageObjects.header.waitUntilLoadingHasFinished(); - } + async clickSaveScriptedField() { + this.log.debug('click Save Scripted Field'); + await this.testSubjects.click('fieldSaveButton'); + await this.header.waitUntilLoadingHasFinished(); + } - async setScriptedFieldName(name: string) { - log.debug('set scripted field name = ' + name); - await testSubjects.setValue('editorFieldName', name); - } + async setScriptedFieldName(name: string) { + this.log.debug('set scripted field name = ' + name); + await this.testSubjects.setValue('editorFieldName', name); + } - async setScriptedFieldLanguage(language: string) { - log.debug('set scripted field language = ' + language); - await find.clickByCssSelector( - 'select[data-test-subj="editorFieldLang"] > option[value="' + language + '"]' - ); - } + async setScriptedFieldLanguage(language: string) { + this.log.debug('set scripted field language = ' + language); + await this.find.clickByCssSelector( + 'select[data-test-subj="editorFieldLang"] > option[value="' + language + '"]' + ); + } - async setScriptedFieldType(type: string) { - log.debug('set scripted field type = ' + type); - await find.clickByCssSelector( - 'select[data-test-subj="editorFieldType"] > option[value="' + type + '"]' - ); - } + async setScriptedFieldType(type: string) { + this.log.debug('set scripted field type = ' + type); + await this.find.clickByCssSelector( + 'select[data-test-subj="editorFieldType"] > option[value="' + type + '"]' + ); + } - async setFieldFormat(format: string) { - log.debug('set scripted field format = ' + format); - await find.clickByCssSelector( - 'select[data-test-subj="editorSelectedFormatId"] > option[value="' + format + '"]' - ); - } + async setFieldFormat(format: string) { + this.log.debug('set scripted field format = ' + format); + await this.find.clickByCssSelector( + 'select[data-test-subj="editorSelectedFormatId"] > option[value="' + format + '"]' + ); + } - async setScriptedFieldUrlType(type: string) { - log.debug('set scripted field Url type = ' + type); - await find.clickByCssSelector( - 'select[data-test-subj="urlEditorType"] > option[value="' + type + '"]' - ); - } + async setScriptedFieldUrlType(type: string) { + this.log.debug('set scripted field Url type = ' + type); + await this.find.clickByCssSelector( + 'select[data-test-subj="urlEditorType"] > option[value="' + type + '"]' + ); + } - async setScriptedFieldUrlTemplate(template: string) { - log.debug('set scripted field Url Template = ' + template); - const urlTemplateField = await find.byCssSelector( - 'input[data-test-subj="urlEditorUrlTemplate"]' - ); - await urlTemplateField.type(template); - } + async setScriptedFieldUrlTemplate(template: string) { + this.log.debug('set scripted field Url Template = ' + template); + const urlTemplateField = await this.find.byCssSelector( + 'input[data-test-subj="urlEditorUrlTemplate"]' + ); + await urlTemplateField.type(template); + } - async setScriptedFieldUrlLabelTemplate(labelTemplate: string) { - log.debug('set scripted field Url Label Template = ' + labelTemplate); - const urlEditorLabelTemplate = await find.byCssSelector( - 'input[data-test-subj="urlEditorLabelTemplate"]' - ); - await urlEditorLabelTemplate.type(labelTemplate); - } + async setScriptedFieldUrlLabelTemplate(labelTemplate: string) { + this.log.debug('set scripted field Url Label Template = ' + labelTemplate); + const urlEditorLabelTemplate = await this.find.byCssSelector( + 'input[data-test-subj="urlEditorLabelTemplate"]' + ); + await urlEditorLabelTemplate.type(labelTemplate); + } - async setScriptedFieldDatePattern(datePattern: string) { - log.debug('set scripted field Date Pattern = ' + datePattern); - const datePatternField = await find.byCssSelector( - 'input[data-test-subj="dateEditorPattern"]' - ); - // clearValue does not work here - // Send Backspace event for each char in value string to clear field - await datePatternField.clearValueWithKeyboard({ charByChar: true }); - await datePatternField.type(datePattern); - } + async setScriptedFieldDatePattern(datePattern: string) { + this.log.debug('set scripted field Date Pattern = ' + datePattern); + const datePatternField = await this.find.byCssSelector( + 'input[data-test-subj="dateEditorPattern"]' + ); + // clearValue does not work here + // Send Backspace event for each char in value string to clear field + await datePatternField.clearValueWithKeyboard({ charByChar: true }); + await datePatternField.type(datePattern); + } - async setScriptedFieldStringTransform(stringTransform: string) { - log.debug('set scripted field string Transform = ' + stringTransform); - await find.clickByCssSelector( - 'select[data-test-subj="stringEditorTransform"] > option[value="' + stringTransform + '"]' - ); - } + async setScriptedFieldStringTransform(stringTransform: string) { + this.log.debug('set scripted field string Transform = ' + stringTransform); + await this.find.clickByCssSelector( + 'select[data-test-subj="stringEditorTransform"] > option[value="' + stringTransform + '"]' + ); + } - async setScriptedFieldPopularity(popularity: string) { - log.debug('set scripted field popularity = ' + popularity); - await testSubjects.setValue('editorFieldCount', popularity); - } + async setScriptedFieldPopularity(popularity: string) { + this.log.debug('set scripted field popularity = ' + popularity); + await this.testSubjects.setValue('editorFieldCount', popularity); + } - async setScriptedFieldScript(script: string) { - log.debug('set scripted field script = ' + script); - const aceEditorCssSelector = '[data-test-subj="editorFieldScript"] .ace_editor'; - const editor = await find.byCssSelector(aceEditorCssSelector); - await editor.click(); - const existingText = await editor.getVisibleText(); - for (let i = 0; i < existingText.length; i++) { - await browser.pressKeys(browser.keys.BACK_SPACE); - } - await browser.pressKeys(...script.split('')); + async setScriptedFieldScript(script: string) { + this.log.debug('set scripted field script = ' + script); + const aceEditorCssSelector = '[data-test-subj="editorFieldScript"] .ace_editor'; + const editor = await this.find.byCssSelector(aceEditorCssSelector); + await editor.click(); + const existingText = await editor.getVisibleText(); + for (let i = 0; i < existingText.length; i++) { + await this.browser.pressKeys(this.browser.keys.BACK_SPACE); } + await this.browser.pressKeys(...script.split('')); + } - async openScriptedFieldHelp(activeTab: string) { - log.debug('open Scripted Fields help'); - let isOpen = await testSubjects.exists('scriptedFieldsHelpFlyout'); - if (!isOpen) { - await retry.try(async () => { - await testSubjects.click('scriptedFieldsHelpLink'); - isOpen = await testSubjects.exists('scriptedFieldsHelpFlyout'); - if (!isOpen) { - throw new Error('Failed to open scripted fields help'); - } - }); - } - - if (activeTab) { - await testSubjects.click(activeTab); - } + async openScriptedFieldHelp(activeTab: string) { + this.log.debug('open Scripted Fields help'); + let isOpen = await this.testSubjects.exists('scriptedFieldsHelpFlyout'); + if (!isOpen) { + await this.retry.try(async () => { + await this.testSubjects.click('scriptedFieldsHelpLink'); + isOpen = await this.testSubjects.exists('scriptedFieldsHelpFlyout'); + if (!isOpen) { + throw new Error('Failed to open scripted fields help'); + } + }); } - async closeScriptedFieldHelp() { - await flyout.ensureClosed('scriptedFieldsHelpFlyout'); + if (activeTab) { + await this.testSubjects.click(activeTab); } + } - async executeScriptedField(script: string, additionalField: string) { - log.debug('execute Scripted Fields help'); - await this.closeScriptedFieldHelp(); // ensure script help is closed so script input is not blocked - await this.setScriptedFieldScript(script); - await this.openScriptedFieldHelp('testTab'); - if (additionalField) { - await comboBox.set('additionalFieldsSelect', additionalField); - await testSubjects.find('scriptedFieldPreview'); - await testSubjects.click('runScriptButton'); - await testSubjects.waitForDeleted('.euiLoadingSpinner'); - } - let scriptResults; - await retry.try(async () => { - scriptResults = await testSubjects.getVisibleText('scriptedFieldPreview'); - }); - return scriptResults; - } + async closeScriptedFieldHelp() { + await this.flyout.ensureClosed('scriptedFieldsHelpFlyout'); + } - async clickEditFieldFormat() { - await testSubjects.click('editFieldFormat'); - } + async executeScriptedField(script: string, additionalField: string) { + this.log.debug('execute Scripted Fields help'); + await this.closeScriptedFieldHelp(); // ensure script help is closed so script input is not blocked + await this.setScriptedFieldScript(script); + await this.openScriptedFieldHelp('testTab'); + if (additionalField) { + await this.comboBox.set('additionalFieldsSelect', additionalField); + await this.testSubjects.find('scriptedFieldPreview'); + await this.testSubjects.click('runScriptButton'); + await this.testSubjects.waitForDeleted('.euiLoadingSpinner'); + } + let scriptResults; + await this.retry.try(async () => { + scriptResults = await this.testSubjects.getVisibleText('scriptedFieldPreview'); + }); + return scriptResults; + } - async associateIndexPattern(oldIndexPatternId: string, newIndexPatternTitle: string) { - await find.clickByCssSelector( - `select[data-test-subj="managementChangeIndexSelection-${oldIndexPatternId}"] > - [data-test-subj="indexPatternOption-${newIndexPatternTitle}"]` - ); - } + async clickEditFieldFormat() { + await this.testSubjects.click('editFieldFormat'); + } - async clickChangeIndexConfirmButton() { - await testSubjects.click('changeIndexConfirmButton'); - } + async associateIndexPattern(oldIndexPatternId: string, newIndexPatternTitle: string) { + await this.find.clickByCssSelector( + `select[data-test-subj="managementChangeIndexSelection-${oldIndexPatternId}"] > + [data-test-subj="indexPatternOption-${newIndexPatternTitle}"]` + ); } - return new SettingsPage(); + async clickChangeIndexConfirmButton() { + await this.testSubjects.click('changeIndexConfirmButton'); + } } diff --git a/test/functional/page_objects/share_page.ts b/test/functional/page_objects/share_page.ts index aa58341600599..ce1dc4c45e21f 100644 --- a/test/functional/page_objects/share_page.ts +++ b/test/functional/page_objects/share_page.ts @@ -6,76 +6,72 @@ * Side Public License, v 1. */ -import { FtrProviderContext } from '../ftr_provider_context'; +import { FtrService } from '../ftr_provider_context'; -export function SharePageProvider({ getService }: FtrProviderContext) { - const testSubjects = getService('testSubjects'); - const find = getService('find'); - const log = getService('log'); +export class SharePageObject extends FtrService { + private readonly testSubjects = this.ctx.getService('testSubjects'); + private readonly find = this.ctx.getService('find'); + private readonly log = this.ctx.getService('log'); - class SharePage { - async isShareMenuOpen() { - return await testSubjects.exists('shareContextMenu'); - } - - async clickShareTopNavButton() { - return testSubjects.click('shareTopNavButton'); - } + async isShareMenuOpen() { + return await this.testSubjects.exists('shareContextMenu'); + } - async openShareMenuItem(itemTitle: string) { - log.debug(`openShareMenuItem title:${itemTitle}`); - const isShareMenuOpen = await this.isShareMenuOpen(); - if (!isShareMenuOpen) { - await this.clickShareTopNavButton(); - } else { - // there is no easy way to ensure the menu is at the top level - // so just close the existing menu - await this.clickShareTopNavButton(); - // and then re-open the menu - await this.clickShareTopNavButton(); - } - const menuPanel = await find.byCssSelector('div.euiContextMenuPanel'); - await testSubjects.click(`sharePanel-${itemTitle.replace(' ', '')}`); - await testSubjects.waitForDeleted(menuPanel); - } + async clickShareTopNavButton() { + return this.testSubjects.click('shareTopNavButton'); + } - /** - * if there are more entries in the share menu, the permalinks entry has to be clicked first - * else the selection isn't displayed. this happens if you're testing against an instance - * with xpack features enabled, where there's also a csv sharing option - * in a pure OSS environment, the permalinks sharing panel is displayed initially - */ - async openPermaLinks() { - if (await testSubjects.exists('sharePanel-Permalinks')) { - await testSubjects.click(`sharePanel-Permalinks`); - } + async openShareMenuItem(itemTitle: string) { + this.log.debug(`openShareMenuItem title:${itemTitle}`); + const isShareMenuOpen = await this.isShareMenuOpen(); + if (!isShareMenuOpen) { + await this.clickShareTopNavButton(); + } else { + // there is no easy way to ensure the menu is at the top level + // so just close the existing menu + await this.clickShareTopNavButton(); + // and then re-open the menu + await this.clickShareTopNavButton(); } + const menuPanel = await this.find.byCssSelector('div.euiContextMenuPanel'); + await this.testSubjects.click(`sharePanel-${itemTitle.replace(' ', '')}`); + await this.testSubjects.waitForDeleted(menuPanel); + } - async getSharedUrl() { - await this.openPermaLinks(); - return await testSubjects.getAttribute('copyShareUrlButton', 'data-share-url'); + /** + * if there are more entries in the share menu, the permalinks entry has to be clicked first + * else the selection isn't displayed. this happens if you're testing against an instance + * with xpack features enabled, where there's also a csv sharing option + * in a pure OSS environment, the permalinks sharing panel is displayed initially + */ + async openPermaLinks() { + if (await this.testSubjects.exists('sharePanel-Permalinks')) { + await this.testSubjects.click(`sharePanel-Permalinks`); } + } - async createShortUrlExistOrFail() { - await testSubjects.existOrFail('createShortUrl'); - } + async getSharedUrl() { + await this.openPermaLinks(); + return await this.testSubjects.getAttribute('copyShareUrlButton', 'data-share-url'); + } - async createShortUrlMissingOrFail() { - await testSubjects.missingOrFail('createShortUrl'); - } + async createShortUrlExistOrFail() { + await this.testSubjects.existOrFail('createShortUrl'); + } - async checkShortenUrl() { - await this.openPermaLinks(); - const shareForm = await testSubjects.find('shareUrlForm'); - await testSubjects.setCheckbox('useShortUrl', 'check'); - await shareForm.waitForDeletedByCssSelector('.euiLoadingSpinner'); - } + async createShortUrlMissingOrFail() { + await this.testSubjects.missingOrFail('createShortUrl'); + } - async exportAsSavedObject() { - await this.openPermaLinks(); - return await testSubjects.click('exportAsSavedObject'); - } + async checkShortenUrl() { + await this.openPermaLinks(); + const shareForm = await this.testSubjects.find('shareUrlForm'); + await this.testSubjects.setCheckbox('useShortUrl', 'check'); + await shareForm.waitForDeletedByCssSelector('.euiLoadingSpinner'); } - return new SharePage(); + async exportAsSavedObject() { + await this.openPermaLinks(); + return await this.testSubjects.click('exportAsSavedObject'); + } } diff --git a/test/functional/page_objects/tag_cloud_page.ts b/test/functional/page_objects/tag_cloud_page.ts index fa977618e64d7..61e844c813df8 100644 --- a/test/functional/page_objects/tag_cloud_page.ts +++ b/test/functional/page_objects/tag_cloud_page.ts @@ -6,36 +6,33 @@ * Side Public License, v 1. */ -import { FtrProviderContext } from '../ftr_provider_context'; +import { FtrService } from '../ftr_provider_context'; import { WebElementWrapper } from '../services/lib/web_element_wrapper'; -export function TagCloudPageProvider({ getService, getPageObjects }: FtrProviderContext) { - const find = getService('find'); - const testSubjects = getService('testSubjects'); - const { header, visChart } = getPageObjects(['header', 'visChart']); +export class TagCloudPageObject extends FtrService { + private readonly find = this.ctx.getService('find'); + private readonly testSubjects = this.ctx.getService('testSubjects'); + private readonly header = this.ctx.getPageObject('header'); + private readonly visChart = this.ctx.getPageObject('visChart'); - class TagCloudPage { - public async selectTagCloudTag(tagDisplayText: string) { - await testSubjects.click(tagDisplayText); - await header.waitUntilLoadingHasFinished(); - } + public async selectTagCloudTag(tagDisplayText: string) { + await this.testSubjects.click(tagDisplayText); + await this.header.waitUntilLoadingHasFinished(); + } - public async getTextTag() { - await visChart.waitForVisualization(); - const elements = await find.allByCssSelector('text'); - return await Promise.all(elements.map(async (element) => await element.getVisibleText())); - } + public async getTextTag() { + await this.visChart.waitForVisualization(); + const elements = await this.find.allByCssSelector('text'); + return await Promise.all(elements.map(async (element) => await element.getVisibleText())); + } - public async getTextSizes() { - const tags = await find.allByCssSelector('text'); - async function returnTagSize(tag: WebElementWrapper) { - const style = await tag.getAttribute('style'); - const fontSize = style.match(/font-size: ([^;]*);/); - return fontSize ? fontSize[1] : ''; - } - return await Promise.all(tags.map(returnTagSize)); + public async getTextSizes() { + const tags = await this.find.allByCssSelector('text'); + async function returnTagSize(tag: WebElementWrapper) { + const style = await tag.getAttribute('style'); + const fontSize = style.match(/font-size: ([^;]*);/); + return fontSize ? fontSize[1] : ''; } + return await Promise.all(tags.map(returnTagSize)); } - - return new TagCloudPage(); } diff --git a/test/functional/page_objects/tile_map_page.ts b/test/functional/page_objects/tile_map_page.ts index 6008d7434bf1d..079ca919543e2 100644 --- a/test/functional/page_objects/tile_map_page.ts +++ b/test/functional/page_objects/tile_map_page.ts @@ -6,92 +6,88 @@ * Side Public License, v 1. */ -import { FtrProviderContext } from '../ftr_provider_context'; - -export function TileMapPageProvider({ getService, getPageObjects }: FtrProviderContext) { - const find = getService('find'); - const testSubjects = getService('testSubjects'); - const retry = getService('retry'); - const log = getService('log'); - const inspector = getService('inspector'); - const monacoEditor = getService('monacoEditor'); - const { header } = getPageObjects(['header']); - - class TileMapPage { - public async getZoomSelectors(zoomSelector: string) { - return await find.allByCssSelector(zoomSelector); - } - - public async clickMapButton(zoomSelector: string, waitForLoading?: boolean) { - await retry.try(async () => { - const zooms = await this.getZoomSelectors(zoomSelector); - for (let i = 0; i < zooms.length; i++) { - await zooms[i].click(); - } - if (waitForLoading) { - await header.waitUntilLoadingHasFinished(); - } - }); - } +import { FtrService } from '../ftr_provider_context'; + +export class TileMapPageObject extends FtrService { + private readonly find = this.ctx.getService('find'); + private readonly testSubjects = this.ctx.getService('testSubjects'); + private readonly retry = this.ctx.getService('retry'); + private readonly log = this.ctx.getService('log'); + private readonly inspector = this.ctx.getService('inspector'); + private readonly monacoEditor = this.ctx.getService('monacoEditor'); + private readonly header = this.ctx.getPageObject('header'); + + public async getZoomSelectors(zoomSelector: string) { + return await this.find.allByCssSelector(zoomSelector); + } - public async getVisualizationRequest() { - log.debug('getVisualizationRequest'); - await inspector.open(); - await testSubjects.click('inspectorViewChooser'); - await testSubjects.click('inspectorViewChooserRequests'); - await testSubjects.click('inspectorRequestDetailRequest'); - await find.byCssSelector('.react-monaco-editor-container'); + public async clickMapButton(zoomSelector: string, waitForLoading?: boolean) { + await this.retry.try(async () => { + const zooms = await this.getZoomSelectors(zoomSelector); + for (let i = 0; i < zooms.length; i++) { + await zooms[i].click(); + } + if (waitForLoading) { + await this.header.waitUntilLoadingHasFinished(); + } + }); + } - return await monacoEditor.getCodeEditorValue(1); - } + public async getVisualizationRequest() { + this.log.debug('getVisualizationRequest'); + await this.inspector.open(); + await this.testSubjects.click('inspectorViewChooser'); + await this.testSubjects.click('inspectorViewChooserRequests'); + await this.testSubjects.click('inspectorRequestDetailRequest'); + await this.find.byCssSelector('.react-monaco-editor-container'); - public async getMapBounds(): Promise { - const request = await this.getVisualizationRequest(); - const requestObject = JSON.parse(request); + return await this.monacoEditor.getCodeEditorValue(1); + } - return requestObject.aggs.filter_agg.filter.geo_bounding_box['geo.coordinates']; - } + public async getMapBounds(): Promise { + const request = await this.getVisualizationRequest(); + const requestObject = JSON.parse(request); - public async clickMapZoomIn(waitForLoading = true) { - await this.clickMapButton('a.leaflet-control-zoom-in', waitForLoading); - } + return requestObject.aggs.filter_agg.filter.geo_bounding_box['geo.coordinates']; + } - public async clickMapZoomOut(waitForLoading = true) { - await this.clickMapButton('a.leaflet-control-zoom-out', waitForLoading); - } + public async clickMapZoomIn(waitForLoading = true) { + await this.clickMapButton('a.leaflet-control-zoom-in', waitForLoading); + } - public async getMapZoomEnabled(zoomSelector: string): Promise { - const zooms = await this.getZoomSelectors(zoomSelector); - const classAttributes = await Promise.all( - zooms.map(async (zoom) => await zoom.getAttribute('class')) - ); - return !classAttributes.join('').includes('leaflet-disabled'); - } + public async clickMapZoomOut(waitForLoading = true) { + await this.clickMapButton('a.leaflet-control-zoom-out', waitForLoading); + } - public async zoomAllTheWayOut(): Promise { - // we can tell we're at level 1 because zoom out is disabled - return await retry.try(async () => { - await this.clickMapZoomOut(); - const enabled = await this.getMapZoomOutEnabled(); - // should be able to zoom more as current config has 0 as min level. - if (enabled) { - throw new Error('Not fully zoomed out yet'); - } - }); - } + public async getMapZoomEnabled(zoomSelector: string): Promise { + const zooms = await this.getZoomSelectors(zoomSelector); + const classAttributes = await Promise.all( + zooms.map(async (zoom) => await zoom.getAttribute('class')) + ); + return !classAttributes.join('').includes('leaflet-disabled'); + } - public async getMapZoomInEnabled() { - return await this.getMapZoomEnabled('a.leaflet-control-zoom-in'); - } + public async zoomAllTheWayOut(): Promise { + // we can tell we're at level 1 because zoom out is disabled + return await this.retry.try(async () => { + await this.clickMapZoomOut(); + const enabled = await this.getMapZoomOutEnabled(); + // should be able to zoom more as current config has 0 as min level. + if (enabled) { + throw new Error('Not fully zoomed out yet'); + } + }); + } - public async getMapZoomOutEnabled() { - return await this.getMapZoomEnabled('a.leaflet-control-zoom-out'); - } + public async getMapZoomInEnabled() { + return await this.getMapZoomEnabled('a.leaflet-control-zoom-in'); + } - public async clickMapFitDataBounds() { - return await this.clickMapButton('a.fa-crop'); - } + public async getMapZoomOutEnabled() { + return await this.getMapZoomEnabled('a.leaflet-control-zoom-out'); } - return new TileMapPage(); + public async clickMapFitDataBounds() { + return await this.clickMapButton('a.fa-crop'); + } } diff --git a/test/functional/page_objects/time_picker.ts b/test/functional/page_objects/time_picker.ts index 4d0930c3ff932..e8f6afc365f5d 100644 --- a/test/functional/page_objects/time_picker.ts +++ b/test/functional/page_objects/time_picker.ts @@ -7,7 +7,7 @@ */ import moment from 'moment'; -import { FtrProviderContext } from '../ftr_provider_context'; +import { FtrService } from '../ftr_provider_context'; import { WebElementWrapper } from '../services/lib/web_element_wrapper'; export type CommonlyUsed = @@ -22,275 +22,270 @@ export type CommonlyUsed = | 'Last_90 days' | 'Last_1 year'; -export function TimePickerProvider({ getService, getPageObjects }: FtrProviderContext) { - const log = getService('log'); - const find = getService('find'); - const browser = getService('browser'); - const retry = getService('retry'); - const testSubjects = getService('testSubjects'); - const { header } = getPageObjects(['header']); - const kibanaServer = getService('kibanaServer'); - const menuToggle = getService('menuToggle'); - - const quickSelectTimeMenuToggle = menuToggle.create({ +export class TimePickerPageObject extends FtrService { + private readonly log = this.ctx.getService('log'); + private readonly find = this.ctx.getService('find'); + private readonly browser = this.ctx.getService('browser'); + private readonly retry = this.ctx.getService('retry'); + private readonly testSubjects = this.ctx.getService('testSubjects'); + private readonly header = this.ctx.getPageObject('header'); + private readonly kibanaServer = this.ctx.getService('kibanaServer'); + + private readonly quickSelectTimeMenuToggle = this.ctx.getService('menuToggle').create({ name: 'QuickSelectTime Menu', menuTestSubject: 'superDatePickerQuickMenu', toggleButtonTestSubject: 'superDatePickerToggleQuickMenuButton', }); - class TimePicker { - defaultStartTime = 'Sep 19, 2015 @ 06:31:44.000'; - defaultEndTime = 'Sep 23, 2015 @ 18:31:44.000'; - defaultStartTimeUTC = '2015-09-18T06:31:44.000Z'; - defaultEndTimeUTC = '2015-09-23T18:31:44.000Z'; - - async setDefaultAbsoluteRange() { - await this.setAbsoluteRange(this.defaultStartTime, this.defaultEndTime); - } + public readonly defaultStartTime = 'Sep 19, 2015 @ 06:31:44.000'; + public readonly defaultEndTime = 'Sep 23, 2015 @ 18:31:44.000'; + public readonly defaultStartTimeUTC = '2015-09-18T06:31:44.000Z'; + public readonly defaultEndTimeUTC = '2015-09-23T18:31:44.000Z'; - async ensureHiddenNoDataPopover() { - const isVisible = await testSubjects.exists('noDataPopoverDismissButton'); - if (isVisible) { - await testSubjects.click('noDataPopoverDismissButton'); - } - } + async setDefaultAbsoluteRange() { + await this.setAbsoluteRange(this.defaultStartTime, this.defaultEndTime); + } - /** - * the provides a quicker way to set the timepicker to the default range, saves a few seconds - */ - async setDefaultAbsoluteRangeViaUiSettings() { - await kibanaServer.uiSettings.update({ - 'timepicker:timeDefaults': `{ "from": "${this.defaultStartTimeUTC}", "to": "${this.defaultEndTimeUTC}"}`, - }); + async ensureHiddenNoDataPopover() { + const isVisible = await this.testSubjects.exists('noDataPopoverDismissButton'); + if (isVisible) { + await this.testSubjects.click('noDataPopoverDismissButton'); } + } - async resetDefaultAbsoluteRangeViaUiSettings() { - await kibanaServer.uiSettings.replace({}); - } + /** + * the provides a quicker way to set the timepicker to the default range, saves a few seconds + */ + async setDefaultAbsoluteRangeViaUiSettings() { + await this.kibanaServer.uiSettings.update({ + 'timepicker:timeDefaults': `{ "from": "${this.defaultStartTimeUTC}", "to": "${this.defaultEndTimeUTC}"}`, + }); + } - private async getTimePickerPanel() { - return await retry.try(async () => { - return await find.byCssSelector('div.euiPopover__panel-isOpen'); - }); - } + async resetDefaultAbsoluteRangeViaUiSettings() { + await this.kibanaServer.uiSettings.replace({}); + } - private async waitPanelIsGone(panelElement: WebElementWrapper) { - await find.waitForElementStale(panelElement); - } + private async getTimePickerPanel() { + return await this.retry.try(async () => { + return await this.find.byCssSelector('div.euiPopover__panel-isOpen'); + }); + } - public async timePickerExists() { - return await testSubjects.exists('superDatePickerToggleQuickMenuButton'); - } + private async waitPanelIsGone(panelElement: WebElementWrapper) { + await this.find.waitForElementStale(panelElement); + } - /** - * Sets commonly used time - * @param option 'Today' | 'This_week' | 'Last_15 minutes' | 'Last_24 hours' ... - */ - async setCommonlyUsedTime(option: CommonlyUsed | string) { - await testSubjects.click('superDatePickerToggleQuickMenuButton'); - await testSubjects.click(`superDatePickerCommonlyUsed_${option}`); - } + public async timePickerExists() { + return await this.testSubjects.exists('superDatePickerToggleQuickMenuButton'); + } - public async inputValue(dataTestSubj: string, value: string) { - if (browser.isFirefox) { - const input = await testSubjects.find(dataTestSubj); - await input.clearValue(); - await input.type(value); - } else { - await testSubjects.setValue(dataTestSubj, value); - } - } + /** + * Sets commonly used time + * @param option 'Today' | 'This_week' | 'Last_15 minutes' | 'Last_24 hours' ... + */ + async setCommonlyUsedTime(option: CommonlyUsed | string) { + await this.testSubjects.click('superDatePickerToggleQuickMenuButton'); + await this.testSubjects.click(`superDatePickerCommonlyUsed_${option}`); + } - private async showStartEndTimes() { - // This first await makes sure the superDatePicker has loaded before we check for the ShowDatesButton - await testSubjects.exists('superDatePickerToggleQuickMenuButton', { timeout: 20000 }); - const isShowDatesButton = await testSubjects.exists('superDatePickerShowDatesButton'); - if (isShowDatesButton) { - await testSubjects.click('superDatePickerShowDatesButton'); - } - await testSubjects.exists('superDatePickerstartDatePopoverButton'); + public async inputValue(dataTestSubj: string, value: string) { + if (this.browser.isFirefox) { + const input = await this.testSubjects.find(dataTestSubj); + await input.clearValue(); + await input.type(value); + } else { + await this.testSubjects.setValue(dataTestSubj, value); } + } - /** - * @param {String} fromTime MMM D, YYYY @ HH:mm:ss.SSS - * @param {String} toTime MMM D, YYYY @ HH:mm:ss.SSS - */ - public async setAbsoluteRange(fromTime: string, toTime: string) { - log.debug(`Setting absolute range to ${fromTime} to ${toTime}`); - await this.showStartEndTimes(); - - // set to time - await testSubjects.click('superDatePickerendDatePopoverButton'); - let panel = await this.getTimePickerPanel(); - await testSubjects.click('superDatePickerAbsoluteTab'); - await testSubjects.click('superDatePickerAbsoluteDateInput'); - await this.inputValue('superDatePickerAbsoluteDateInput', toTime); - await browser.pressKeys(browser.keys.ESCAPE); // close popover because sometimes browser can't find start input - - // set from time - await testSubjects.click('superDatePickerstartDatePopoverButton'); - await this.waitPanelIsGone(panel); - panel = await this.getTimePickerPanel(); - await testSubjects.click('superDatePickerAbsoluteTab'); - await testSubjects.click('superDatePickerAbsoluteDateInput'); - await this.inputValue('superDatePickerAbsoluteDateInput', fromTime); - - const superDatePickerApplyButtonExists = await testSubjects.exists( - 'superDatePickerApplyTimeButton' - ); - if (superDatePickerApplyButtonExists) { - // Timepicker is in top nav - // Click super date picker apply button to apply time range - await testSubjects.click('superDatePickerApplyTimeButton'); - } else { - // Timepicker is embedded in query bar - // click query bar submit button to apply time range - await testSubjects.click('querySubmitButton'); - } - - await this.waitPanelIsGone(panel); - await header.awaitGlobalLoadingIndicatorHidden(); + private async showStartEndTimes() { + // This first await makes sure the superDatePicker has loaded before we check for the ShowDatesButton + await this.testSubjects.exists('superDatePickerToggleQuickMenuButton', { timeout: 20000 }); + const isShowDatesButton = await this.testSubjects.exists('superDatePickerShowDatesButton'); + if (isShowDatesButton) { + await this.testSubjects.click('superDatePickerShowDatesButton'); } + await this.testSubjects.exists('superDatePickerstartDatePopoverButton'); + } - public async isOff() { - return await find.existsByCssSelector('.euiDatePickerRange--readOnly'); + /** + * @param {String} fromTime MMM D, YYYY @ HH:mm:ss.SSS + * @param {String} toTime MMM D, YYYY @ HH:mm:ss.SSS + */ + public async setAbsoluteRange(fromTime: string, toTime: string) { + this.log.debug(`Setting absolute range to ${fromTime} to ${toTime}`); + await this.showStartEndTimes(); + + // set to time + await this.testSubjects.click('superDatePickerendDatePopoverButton'); + let panel = await this.getTimePickerPanel(); + await this.testSubjects.click('superDatePickerAbsoluteTab'); + await this.testSubjects.click('superDatePickerAbsoluteDateInput'); + await this.inputValue('superDatePickerAbsoluteDateInput', toTime); + await this.browser.pressKeys(this.browser.keys.ESCAPE); // close popover because sometimes browser can't find start input + + // set from time + await this.testSubjects.click('superDatePickerstartDatePopoverButton'); + await this.waitPanelIsGone(panel); + panel = await this.getTimePickerPanel(); + await this.testSubjects.click('superDatePickerAbsoluteTab'); + await this.testSubjects.click('superDatePickerAbsoluteDateInput'); + await this.inputValue('superDatePickerAbsoluteDateInput', fromTime); + + const superDatePickerApplyButtonExists = await this.testSubjects.exists( + 'superDatePickerApplyTimeButton' + ); + if (superDatePickerApplyButtonExists) { + // Timepicker is in top nav + // Click super date picker apply button to apply time range + await this.testSubjects.click('superDatePickerApplyTimeButton'); + } else { + // Timepicker is embedded in query bar + // click query bar submit button to apply time range + await this.testSubjects.click('querySubmitButton'); } - public async getRefreshConfig(keepQuickSelectOpen = false) { - await quickSelectTimeMenuToggle.open(); - const interval = await testSubjects.getAttribute( - 'superDatePickerRefreshIntervalInput', - 'value' - ); - - let selectedUnit; - const select = await testSubjects.find('superDatePickerRefreshIntervalUnitsSelect'); - const options = await find.allDescendantDisplayedByCssSelector('option', select); - await Promise.all( - options.map(async (optionElement) => { - const isSelected = await optionElement.isSelected(); - if (isSelected) { - selectedUnit = await optionElement.getVisibleText(); - } - }) - ); - - const toggleButtonText = await testSubjects.getVisibleText( - 'superDatePickerToggleRefreshButton' - ); - if (!keepQuickSelectOpen) { - await quickSelectTimeMenuToggle.close(); - } - - return { - interval, - units: selectedUnit, - isPaused: toggleButtonText === 'Start' ? true : false, - }; - } + await this.waitPanelIsGone(panel); + await this.header.awaitGlobalLoadingIndicatorHidden(); + } - public async getTimeConfig() { - await this.showStartEndTimes(); - const start = await testSubjects.getVisibleText('superDatePickerstartDatePopoverButton'); - const end = await testSubjects.getVisibleText('superDatePickerendDatePopoverButton'); - return { - start, - end, - }; - } + public async isOff() { + return await this.find.existsByCssSelector('.euiDatePickerRange--readOnly'); + } - public async getShowDatesButtonText() { - const button = await testSubjects.find('superDatePickerShowDatesButton'); - const text = await button.getVisibleText(); - return text; + public async getRefreshConfig(keepQuickSelectOpen = false) { + await this.quickSelectTimeMenuToggle.open(); + const interval = await this.testSubjects.getAttribute( + 'superDatePickerRefreshIntervalInput', + 'value' + ); + + let selectedUnit; + const select = await this.testSubjects.find('superDatePickerRefreshIntervalUnitsSelect'); + const options = await this.find.allDescendantDisplayedByCssSelector('option', select); + await Promise.all( + options.map(async (optionElement) => { + const isSelected = await optionElement.isSelected(); + if (isSelected) { + selectedUnit = await optionElement.getVisibleText(); + } + }) + ); + + const toggleButtonText = await this.testSubjects.getVisibleText( + 'superDatePickerToggleRefreshButton' + ); + if (!keepQuickSelectOpen) { + await this.quickSelectTimeMenuToggle.close(); } - public async getTimeDurationForSharing() { - return await testSubjects.getAttribute( - 'dataSharedTimefilterDuration', - 'data-shared-timefilter-duration' - ); - } + return { + interval, + units: selectedUnit, + isPaused: toggleButtonText === 'Start' ? true : false, + }; + } - public async getTimeConfigAsAbsoluteTimes() { - await this.showStartEndTimes(); - - // get to time - await testSubjects.click('superDatePickerendDatePopoverButton'); - const panel = await this.getTimePickerPanel(); - await testSubjects.click('superDatePickerAbsoluteTab'); - const end = await testSubjects.getAttribute('superDatePickerAbsoluteDateInput', 'value'); - - // get from time - await testSubjects.click('superDatePickerstartDatePopoverButton'); - await this.waitPanelIsGone(panel); - await testSubjects.click('superDatePickerAbsoluteTab'); - const start = await testSubjects.getAttribute('superDatePickerAbsoluteDateInput', 'value'); - - return { - start, - end, - }; - } + public async getTimeConfig() { + await this.showStartEndTimes(); + const start = await this.testSubjects.getVisibleText('superDatePickerstartDatePopoverButton'); + const end = await this.testSubjects.getVisibleText('superDatePickerendDatePopoverButton'); + return { + start, + end, + }; + } - public async getTimeDurationInHours() { - const DEFAULT_DATE_FORMAT = 'MMM D, YYYY @ HH:mm:ss.SSS'; - const { start, end } = await this.getTimeConfigAsAbsoluteTimes(); - const startMoment = moment(start, DEFAULT_DATE_FORMAT); - const endMoment = moment(end, DEFAULT_DATE_FORMAT); - return moment.duration(endMoment.diff(startMoment)).asHours(); - } + public async getShowDatesButtonText() { + const button = await this.testSubjects.find('superDatePickerShowDatesButton'); + const text = await button.getVisibleText(); + return text; + } - public async startAutoRefresh(intervalS = 3) { - await quickSelectTimeMenuToggle.open(); - await this.inputValue('superDatePickerRefreshIntervalInput', intervalS.toString()); - const refreshConfig = await this.getRefreshConfig(true); - if (refreshConfig.isPaused) { - log.debug('start auto refresh'); - await testSubjects.click('superDatePickerToggleRefreshButton'); - } - await quickSelectTimeMenuToggle.close(); - } + public async getTimeDurationForSharing() { + return await this.testSubjects.getAttribute( + 'dataSharedTimefilterDuration', + 'data-shared-timefilter-duration' + ); + } - public async pauseAutoRefresh() { - log.debug('pauseAutoRefresh'); - const refreshConfig = await this.getRefreshConfig(true); + public async getTimeConfigAsAbsoluteTimes() { + await this.showStartEndTimes(); + + // get to time + await this.testSubjects.click('superDatePickerendDatePopoverButton'); + const panel = await this.getTimePickerPanel(); + await this.testSubjects.click('superDatePickerAbsoluteTab'); + const end = await this.testSubjects.getAttribute('superDatePickerAbsoluteDateInput', 'value'); + + // get from time + await this.testSubjects.click('superDatePickerstartDatePopoverButton'); + await this.waitPanelIsGone(panel); + await this.testSubjects.click('superDatePickerAbsoluteTab'); + const start = await this.testSubjects.getAttribute('superDatePickerAbsoluteDateInput', 'value'); + + return { + start, + end, + }; + } - if (!refreshConfig.isPaused) { - log.debug('pause auto refresh'); - await testSubjects.click('superDatePickerToggleRefreshButton'); - } + public async getTimeDurationInHours() { + const DEFAULT_DATE_FORMAT = 'MMM D, YYYY @ HH:mm:ss.SSS'; + const { start, end } = await this.getTimeConfigAsAbsoluteTimes(); + const startMoment = moment(start, DEFAULT_DATE_FORMAT); + const endMoment = moment(end, DEFAULT_DATE_FORMAT); + return moment.duration(endMoment.diff(startMoment)).asHours(); + } - await quickSelectTimeMenuToggle.close(); + public async startAutoRefresh(intervalS = 3) { + await this.quickSelectTimeMenuToggle.open(); + await this.inputValue('superDatePickerRefreshIntervalInput', intervalS.toString()); + const refreshConfig = await this.getRefreshConfig(true); + if (refreshConfig.isPaused) { + this.log.debug('start auto refresh'); + await this.testSubjects.click('superDatePickerToggleRefreshButton'); } + await this.quickSelectTimeMenuToggle.close(); + } - public async resumeAutoRefresh() { - log.debug('resumeAutoRefresh'); - const refreshConfig = await this.getRefreshConfig(true); - if (refreshConfig.isPaused) { - log.debug('resume auto refresh'); - await testSubjects.click('superDatePickerToggleRefreshButton'); - } + public async pauseAutoRefresh() { + this.log.debug('pauseAutoRefresh'); + const refreshConfig = await this.getRefreshConfig(true); - await quickSelectTimeMenuToggle.close(); + if (!refreshConfig.isPaused) { + this.log.debug('pause auto refresh'); + await this.testSubjects.click('superDatePickerToggleRefreshButton'); } - public async setHistoricalDataRange() { - await this.setDefaultAbsoluteRange(); - } + await this.quickSelectTimeMenuToggle.close(); + } - public async setDefaultDataRange() { - const fromTime = 'Jan 1, 2018 @ 00:00:00.000'; - const toTime = 'Apr 13, 2018 @ 00:00:00.000'; - await this.setAbsoluteRange(fromTime, toTime); + public async resumeAutoRefresh() { + this.log.debug('resumeAutoRefresh'); + const refreshConfig = await this.getRefreshConfig(true); + if (refreshConfig.isPaused) { + this.log.debug('resume auto refresh'); + await this.testSubjects.click('superDatePickerToggleRefreshButton'); } - public async setLogstashDataRange() { - const fromTime = 'Apr 9, 2018 @ 00:00:00.000'; - const toTime = 'Apr 13, 2018 @ 00:00:00.000'; - await this.setAbsoluteRange(fromTime, toTime); - } + await this.quickSelectTimeMenuToggle.close(); } - return new TimePicker(); + public async setHistoricalDataRange() { + await this.setDefaultAbsoluteRange(); + } + + public async setDefaultDataRange() { + const fromTime = 'Jan 1, 2018 @ 00:00:00.000'; + const toTime = 'Apr 13, 2018 @ 00:00:00.000'; + await this.setAbsoluteRange(fromTime, toTime); + } + + public async setLogstashDataRange() { + const fromTime = 'Apr 9, 2018 @ 00:00:00.000'; + const toTime = 'Apr 13, 2018 @ 00:00:00.000'; + await this.setAbsoluteRange(fromTime, toTime); + } } diff --git a/test/functional/page_objects/time_to_visualize_page.ts b/test/functional/page_objects/time_to_visualize_page.ts index 458b4dd3e60a1..287b03ec60d88 100644 --- a/test/functional/page_objects/time_to_visualize_page.ts +++ b/test/functional/page_objects/time_to_visualize_page.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { FtrProviderContext } from '../ftr_provider_context'; +import { FtrService } from '../ftr_provider_context'; interface SaveModalArgs { addToDashboard?: 'new' | 'existing' | null; @@ -21,117 +21,108 @@ type DashboardPickerOption = | 'existing-dashboard-option' | 'new-dashboard-option'; -export function TimeToVisualizePageProvider({ getService, getPageObjects }: FtrProviderContext) { - const testSubjects = getService('testSubjects'); - const log = getService('log'); - const find = getService('find'); - const { common, dashboard } = getPageObjects(['common', 'dashboard']); +export class TimeToVisualizePageObject extends FtrService { + private readonly testSubjects = this.ctx.getService('testSubjects'); + private readonly log = this.ctx.getService('log'); + private readonly find = this.ctx.getService('find'); + private readonly common = this.ctx.getPageObject('common'); + private readonly dashboard = this.ctx.getPageObject('dashboard'); - class TimeToVisualizePage { - public async ensureSaveModalIsOpen() { - await testSubjects.exists('savedObjectSaveModal', { timeout: 5000 }); - } + public async ensureSaveModalIsOpen() { + await this.testSubjects.exists('savedObjectSaveModal', { timeout: 5000 }); + } - public async ensureDashboardOptionsAreDisabled() { - const dashboardSelector = await testSubjects.find('add-to-dashboard-options'); - await dashboardSelector.findByCssSelector(`input[id="new-dashboard-option"]:disabled`); - await dashboardSelector.findByCssSelector(`input[id="existing-dashboard-option"]:disabled`); + public async ensureDashboardOptionsAreDisabled() { + const dashboardSelector = await this.testSubjects.find('add-to-dashboard-options'); + await dashboardSelector.findByCssSelector(`input[id="new-dashboard-option"]:disabled`); + await dashboardSelector.findByCssSelector(`input[id="existing-dashboard-option"]:disabled`); - const librarySelector = await testSubjects.find('add-to-library-checkbox'); - await librarySelector.findByCssSelector(`input[id="add-to-library-checkbox"]:disabled`); - } - - public async resetNewDashboard() { - await common.navigateToApp('dashboard'); - await dashboard.gotoDashboardLandingPage(true); - await dashboard.clickNewDashboard(false); - } + const librarySelector = await this.testSubjects.find('add-to-library-checkbox'); + await librarySelector.findByCssSelector(`input[id="add-to-library-checkbox"]:disabled`); + } - public async setSaveModalValues( - vizName: string, - { - saveAsNew, - redirectToOrigin, - addToDashboard, - dashboardId, - saveToLibrary, - }: SaveModalArgs = {} - ) { - await testSubjects.setValue('savedObjectTitle', vizName); - - const hasSaveAsNew = await testSubjects.exists('saveAsNewCheckbox'); - if (hasSaveAsNew && saveAsNew !== undefined) { - const state = saveAsNew ? 'check' : 'uncheck'; - log.debug('save as new checkbox exists. Setting its state to', state); - await testSubjects.setEuiSwitch('saveAsNewCheckbox', state); - } + public async resetNewDashboard() { + await this.common.navigateToApp('dashboard'); + await this.dashboard.gotoDashboardLandingPage(true); + await this.dashboard.clickNewDashboard(false); + } - const hasDashboardSelector = await testSubjects.exists('add-to-dashboard-options'); - if (hasDashboardSelector && addToDashboard !== undefined) { - let option: DashboardPickerOption = 'add-to-library-option'; - if (addToDashboard) { - option = dashboardId ? 'existing-dashboard-option' : 'new-dashboard-option'; - } - log.debug('save modal dashboard selector, choosing option:', option); - const dashboardSelector = await testSubjects.find('add-to-dashboard-options'); - const label = await dashboardSelector.findByCssSelector(`label[for="${option}"]`); - await label.click(); + public async setSaveModalValues( + vizName: string, + { saveAsNew, redirectToOrigin, addToDashboard, dashboardId, saveToLibrary }: SaveModalArgs = {} + ) { + await this.testSubjects.setValue('savedObjectTitle', vizName); + + const hasSaveAsNew = await this.testSubjects.exists('saveAsNewCheckbox'); + if (hasSaveAsNew && saveAsNew !== undefined) { + const state = saveAsNew ? 'check' : 'uncheck'; + this.log.debug('save as new checkbox exists. Setting its state to', state); + await this.testSubjects.setEuiSwitch('saveAsNewCheckbox', state); + } - if (dashboardId) { - await testSubjects.setValue('dashboardPickerInput', dashboardId); - await find.clickByButtonText(dashboardId); - } + const hasDashboardSelector = await this.testSubjects.exists('add-to-dashboard-options'); + if (hasDashboardSelector && addToDashboard !== undefined) { + let option: DashboardPickerOption = 'add-to-library-option'; + if (addToDashboard) { + option = dashboardId ? 'existing-dashboard-option' : 'new-dashboard-option'; } - - const hasSaveToLibrary = await testSubjects.exists('add-to-library-checkbox'); - if (hasSaveToLibrary && saveToLibrary !== undefined) { - const libraryCheckbox = await find.byCssSelector('#add-to-library-checkbox'); - const isChecked = await libraryCheckbox.isSelected(); - const needsClick = isChecked !== saveToLibrary; - const state = saveToLibrary ? 'check' : 'uncheck'; - - log.debug('save to library checkbox exists. Setting its state to', state); - if (needsClick) { - const selector = await testSubjects.find('add-to-library-checkbox'); - const label = await selector.findByCssSelector(`label[for="add-to-library-checkbox"]`); - await label.click(); - } + this.log.debug('save modal dashboard selector, choosing option:', option); + const dashboardSelector = await this.testSubjects.find('add-to-dashboard-options'); + const label = await dashboardSelector.findByCssSelector(`label[for="${option}"]`); + await label.click(); + + if (dashboardId) { + await this.testSubjects.setValue('dashboardPickerInput', dashboardId); + await this.find.clickByButtonText(dashboardId); } + } - const hasRedirectToOrigin = await testSubjects.exists('returnToOriginModeSwitch'); - if (hasRedirectToOrigin && redirectToOrigin !== undefined) { - const state = redirectToOrigin ? 'check' : 'uncheck'; - log.debug('redirect to origin checkbox exists. Setting its state to', state); - await testSubjects.setEuiSwitch('returnToOriginModeSwitch', state); + const hasSaveToLibrary = await this.testSubjects.exists('add-to-library-checkbox'); + if (hasSaveToLibrary && saveToLibrary !== undefined) { + const libraryCheckbox = await this.find.byCssSelector('#add-to-library-checkbox'); + const isChecked = await libraryCheckbox.isSelected(); + const needsClick = isChecked !== saveToLibrary; + const state = saveToLibrary ? 'check' : 'uncheck'; + + this.log.debug('save to library checkbox exists. Setting its state to', state); + if (needsClick) { + const selector = await this.testSubjects.find('add-to-library-checkbox'); + const label = await selector.findByCssSelector(`label[for="add-to-library-checkbox"]`); + await label.click(); } } - public async libraryNotificationExists(panelTitle: string) { - log.debug('searching for library modal on panel:', panelTitle); - const panel = await testSubjects.find( - `embeddablePanelHeading-${panelTitle.replace(/ /g, '')}` - ); - const libraryActionExists = await testSubjects.descendantExists( - 'embeddablePanelNotification-ACTION_LIBRARY_NOTIFICATION', - panel - ); - return libraryActionExists; + const hasRedirectToOrigin = await this.testSubjects.exists('returnToOriginModeSwitch'); + if (hasRedirectToOrigin && redirectToOrigin !== undefined) { + const state = redirectToOrigin ? 'check' : 'uncheck'; + this.log.debug('redirect to origin checkbox exists. Setting its state to', state); + await this.testSubjects.setEuiSwitch('returnToOriginModeSwitch', state); } + } - public async saveFromModal( - vizName: string, - saveModalArgs: SaveModalArgs = { addToDashboard: null } - ) { - await this.ensureSaveModalIsOpen(); + public async libraryNotificationExists(panelTitle: string) { + this.log.debug('searching for library modal on panel:', panelTitle); + const panel = await this.testSubjects.find( + `embeddablePanelHeading-${panelTitle.replace(/ /g, '')}` + ); + const libraryActionExists = await this.testSubjects.descendantExists( + 'embeddablePanelNotification-ACTION_LIBRARY_NOTIFICATION', + panel + ); + return libraryActionExists; + } - await this.setSaveModalValues(vizName, saveModalArgs); - log.debug('Click Save Visualization button'); + public async saveFromModal( + vizName: string, + saveModalArgs: SaveModalArgs = { addToDashboard: null } + ) { + await this.ensureSaveModalIsOpen(); - await testSubjects.click('confirmSaveSavedObjectButton'); + await this.setSaveModalValues(vizName, saveModalArgs); + this.log.debug('Click Save Visualization button'); - await common.waitForSaveModalToClose(); - } - } + await this.testSubjects.click('confirmSaveSavedObjectButton'); - return new TimeToVisualizePage(); + await this.common.waitForSaveModalToClose(); + } } diff --git a/test/functional/page_objects/timelion_page.ts b/test/functional/page_objects/timelion_page.ts index 7f4c4eb125c8e..57913f8e2413d 100644 --- a/test/functional/page_objects/timelion_page.ts +++ b/test/functional/page_objects/timelion_page.ts @@ -6,79 +6,75 @@ * Side Public License, v 1. */ -import { FtrProviderContext } from '../ftr_provider_context'; +import { FtrService } from '../ftr_provider_context'; -export function TimelionPageProvider({ getService, getPageObjects }: FtrProviderContext) { - const testSubjects = getService('testSubjects'); - const log = getService('log'); - const PageObjects = getPageObjects(['common', 'header']); - const esArchiver = getService('esArchiver'); - const kibanaServer = getService('kibanaServer'); +export class TimelionPageObject extends FtrService { + private readonly testSubjects = this.ctx.getService('testSubjects'); + private readonly log = this.ctx.getService('log'); + private readonly common = this.ctx.getPageObject('common'); + private readonly esArchiver = this.ctx.getService('esArchiver'); + private readonly kibanaServer = this.ctx.getService('kibanaServer'); - class TimelionPage { - public async initTests() { - await kibanaServer.uiSettings.replace({ - defaultIndex: 'logstash-*', - }); + public async initTests() { + await this.kibanaServer.uiSettings.replace({ + defaultIndex: 'logstash-*', + }); - log.debug('load kibana index'); - await esArchiver.load('timelion'); + this.log.debug('load kibana index'); + await this.esArchiver.load('timelion'); - await PageObjects.common.navigateToApp('timelion'); - } - - public async setExpression(expression: string) { - const input = await testSubjects.find('timelionExpressionTextArea'); - await input.clearValue(); - await input.type(expression); - } + await this.common.navigateToApp('timelion'); + } - public async updateExpression(updates: string) { - const input = await testSubjects.find('timelionExpressionTextArea'); - await input.type(updates); - await PageObjects.common.sleep(1000); - } + public async setExpression(expression: string) { + const input = await this.testSubjects.find('timelionExpressionTextArea'); + await input.clearValue(); + await input.type(expression); + } - public async getExpression() { - const input = await testSubjects.find('timelionExpressionTextArea'); - return input.getVisibleText(); - } + public async updateExpression(updates: string) { + const input = await this.testSubjects.find('timelionExpressionTextArea'); + await input.type(updates); + await this.common.sleep(1000); + } - public async getSuggestionItemsText() { - const elements = await testSubjects.findAll('timelionSuggestionListItem'); - return await Promise.all(elements.map(async (element) => await element.getVisibleText())); - } + public async getExpression() { + const input = await this.testSubjects.find('timelionExpressionTextArea'); + return input.getVisibleText(); + } - public async clickSuggestion(suggestionIndex = 0, waitTime = 1000) { - const elements = await testSubjects.findAll('timelionSuggestionListItem'); - if (suggestionIndex > elements.length) { - throw new Error( - `Unable to select suggestion ${suggestionIndex}, only ${elements.length} suggestions available.` - ); - } - await elements[suggestionIndex].click(); - // Wait for timelion expression to be updated after clicking suggestions - await PageObjects.common.sleep(waitTime); - } + public async getSuggestionItemsText() { + const elements = await this.testSubjects.findAll('timelionSuggestionListItem'); + return await Promise.all(elements.map(async (element) => await element.getVisibleText())); + } - public async saveTimelionSheet() { - await testSubjects.click('timelionSaveButton'); - await testSubjects.click('timelionSaveAsSheetButton'); - await testSubjects.click('timelionFinishSaveButton'); - await testSubjects.existOrFail('timelionSaveSuccessToast'); - await testSubjects.waitForDeleted('timelionSaveSuccessToast'); + public async clickSuggestion(suggestionIndex = 0, waitTime = 1000) { + const elements = await this.testSubjects.findAll('timelionSuggestionListItem'); + if (suggestionIndex > elements.length) { + throw new Error( + `Unable to select suggestion ${suggestionIndex}, only ${elements.length} suggestions available.` + ); } + await elements[suggestionIndex].click(); + // Wait for timelion expression to be updated after clicking suggestions + await this.common.sleep(waitTime); + } - public async expectWriteControls() { - await testSubjects.existOrFail('timelionSaveButton'); - await testSubjects.existOrFail('timelionDeleteButton'); - } + public async saveTimelionSheet() { + await this.testSubjects.click('timelionSaveButton'); + await this.testSubjects.click('timelionSaveAsSheetButton'); + await this.testSubjects.click('timelionFinishSaveButton'); + await this.testSubjects.existOrFail('timelionSaveSuccessToast'); + await this.testSubjects.waitForDeleted('timelionSaveSuccessToast'); + } - public async expectMissingWriteControls() { - await testSubjects.missingOrFail('timelionSaveButton'); - await testSubjects.missingOrFail('timelionDeleteButton'); - } + public async expectWriteControls() { + await this.testSubjects.existOrFail('timelionSaveButton'); + await this.testSubjects.existOrFail('timelionDeleteButton'); } - return new TimelionPage(); + public async expectMissingWriteControls() { + await this.testSubjects.missingOrFail('timelionSaveButton'); + await this.testSubjects.missingOrFail('timelionDeleteButton'); + } } diff --git a/test/functional/page_objects/vega_chart_page.ts b/test/functional/page_objects/vega_chart_page.ts index 3e165b3434f8a..f83c5e193034e 100644 --- a/test/functional/page_objects/vega_chart_page.ts +++ b/test/functional/page_objects/vega_chart_page.ts @@ -7,110 +7,103 @@ */ import expect from '@kbn/expect'; -import { FtrProviderContext } from '../ftr_provider_context'; +import { FtrService } from '../ftr_provider_context'; const compareSpecs = (first: string, second: string) => { const normalizeSpec = (spec: string) => spec.replace(/[\n ]/g, ''); return normalizeSpec(first) === normalizeSpec(second); }; -export function VegaChartPageProvider({ - getService, - getPageObjects, -}: FtrProviderContext & { updateBaselines: boolean }) { - const find = getService('find'); - const testSubjects = getService('testSubjects'); - const browser = getService('browser'); - const retry = getService('retry'); - - class VegaChartPage { - public getEditor() { - return testSubjects.find('vega-editor'); - } +export class VegaChartPageObject extends FtrService { + private readonly find = this.ctx.getService('find'); + private readonly testSubjects = this.ctx.getService('testSubjects'); + private readonly browser = this.ctx.getService('browser'); + private readonly retry = this.ctx.getService('retry'); - public getViewContainer() { - return find.byCssSelector('div.vgaVis__view'); - } + public getEditor() { + return this.testSubjects.find('vega-editor'); + } - public getControlContainer() { - return find.byCssSelector('div.vgaVis__controls'); - } + public getViewContainer() { + return this.find.byCssSelector('div.vgaVis__view'); + } - public getYAxisContainer() { - return find.byCssSelector('[aria-label^="Y-axis"]'); - } + public getControlContainer() { + return this.find.byCssSelector('div.vgaVis__controls'); + } - public async getAceGutterContainer() { - const editor = await this.getEditor(); - return editor.findByClassName('ace_gutter'); - } + public getYAxisContainer() { + return this.find.byCssSelector('[aria-label^="Y-axis"]'); + } - public async getRawSpec() { - // Adapted from console_page.js:getVisibleTextFromAceEditor(). Is there a common utilities file? - const editor = await this.getEditor(); - const lines = await editor.findAllByClassName('ace_line_group'); + public async getAceGutterContainer() { + const editor = await this.getEditor(); + return editor.findByClassName('ace_gutter'); + } - return await Promise.all( - lines.map(async (line) => { - return await line.getVisibleText(); - }) - ); - } + public async getRawSpec() { + // Adapted from console_page.js:getVisibleTextFromAceEditor(). Is there a common utilities file? + const editor = await this.getEditor(); + const lines = await editor.findAllByClassName('ace_line_group'); - public async getSpec() { - return (await this.getRawSpec()).join('\n'); - } + return await Promise.all( + lines.map(async (line) => { + return await line.getVisibleText(); + }) + ); + } - public async focusEditor() { - const editor = await this.getEditor(); - const textarea = await editor.findByClassName('ace_content'); + public async getSpec() { + return (await this.getRawSpec()).join('\n'); + } - await textarea.click(); - } + public async focusEditor() { + const editor = await this.getEditor(); + const textarea = await editor.findByClassName('ace_content'); - public async fillSpec(newSpec: string) { - await retry.try(async () => { - await this.cleanSpec(); - await this.focusEditor(); - await browser.pressKeys(newSpec); + await textarea.click(); + } - expect(compareSpecs(await this.getSpec(), newSpec)).to.be(true); - }); - } + public async fillSpec(newSpec: string) { + await this.retry.try(async () => { + await this.cleanSpec(); + await this.focusEditor(); + await this.browser.pressKeys(newSpec); - public async typeInSpec(text: string) { - const aceGutter = await this.getAceGutterContainer(); + expect(compareSpecs(await this.getSpec(), newSpec)).to.be(true); + }); + } - await aceGutter.doubleClick(); - await browser.pressKeys(browser.keys.RIGHT); - await browser.pressKeys(browser.keys.LEFT); - await browser.pressKeys(browser.keys.LEFT); - await browser.pressKeys(text); - } + public async typeInSpec(text: string) { + const aceGutter = await this.getAceGutterContainer(); - public async cleanSpec() { - const aceGutter = await this.getAceGutterContainer(); + await aceGutter.doubleClick(); + await this.browser.pressKeys(this.browser.keys.RIGHT); + await this.browser.pressKeys(this.browser.keys.LEFT); + await this.browser.pressKeys(this.browser.keys.LEFT); + await this.browser.pressKeys(text); + } - await retry.try(async () => { - await aceGutter.doubleClick(); - await browser.pressKeys(browser.keys.BACK_SPACE); + public async cleanSpec() { + const aceGutter = await this.getAceGutterContainer(); - expect(await this.getSpec()).to.be(''); - }); - } + await this.retry.try(async () => { + await aceGutter.doubleClick(); + await this.browser.pressKeys(this.browser.keys.BACK_SPACE); - public async getYAxisLabels() { - const yAxis = await this.getYAxisContainer(); - const tickGroup = await yAxis.findByClassName('role-axis-label'); - const labels = await tickGroup.findAllByCssSelector('text'); - const labelTexts: string[] = []; + expect(await this.getSpec()).to.be(''); + }); + } + + public async getYAxisLabels() { + const yAxis = await this.getYAxisContainer(); + const tickGroup = await yAxis.findByClassName('role-axis-label'); + const labels = await tickGroup.findAllByCssSelector('text'); + const labelTexts: string[] = []; - for (const label of labels) { - labelTexts.push(await label.getVisibleText()); - } - return labelTexts; + for (const label of labels) { + labelTexts.push(await label.getVisibleText()); } + return labelTexts; } - - return new VegaChartPage(); } diff --git a/test/functional/page_objects/visual_builder_page.ts b/test/functional/page_objects/visual_builder_page.ts index 997a1127005ee..d796067372fa8 100644 --- a/test/functional/page_objects/visual_builder_page.ts +++ b/test/functional/page_objects/visual_builder_page.ts @@ -6,654 +6,651 @@ * Side Public License, v 1. */ -import { FtrProviderContext } from '../ftr_provider_context'; +import { FtrService } from '../ftr_provider_context'; import { WebElementWrapper } from '../services/lib/web_element_wrapper'; -export function VisualBuilderPageProvider({ getService, getPageObjects }: FtrProviderContext) { - const find = getService('find'); - const log = getService('log'); - const retry = getService('retry'); - const testSubjects = getService('testSubjects'); - const comboBox = getService('comboBox'); - const PageObjects = getPageObjects(['common', 'header', 'visualize', 'timePicker', 'visChart']); - - type Duration = - | 'Milliseconds' - | 'Seconds' - | 'Minutes' - | 'Hours' - | 'Days' - | 'Weeks' - | 'Months' - | 'Years'; - - type FromDuration = Duration | 'Picoseconds' | 'Nanoseconds' | 'Microseconds'; - type ToDuration = Duration | 'Human readable'; - - class VisualBuilderPage { - public async resetPage( - fromTime = 'Sep 19, 2015 @ 06:31:44.000', - toTime = 'Sep 22, 2015 @ 18:31:44.000' - ) { - await PageObjects.common.navigateToUrl('visualize', 'create?type=metrics', { - useActualUrl: true, - }); - log.debug('Wait for initializing TSVB editor'); - await this.checkVisualBuilderIsPresent(); - log.debug('Set absolute time range from "' + fromTime + '" to "' + toTime + '"'); - await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); - // 2 sec sleep until https://github.com/elastic/kibana/issues/46353 is fixed - await PageObjects.common.sleep(2000); - } +type Duration = + | 'Milliseconds' + | 'Seconds' + | 'Minutes' + | 'Hours' + | 'Days' + | 'Weeks' + | 'Months' + | 'Years'; + +type FromDuration = Duration | 'Picoseconds' | 'Nanoseconds' | 'Microseconds'; +type ToDuration = Duration | 'Human readable'; + +export class VisualBuilderPageObject extends FtrService { + private readonly find = this.ctx.getService('find'); + private readonly log = this.ctx.getService('log'); + private readonly retry = this.ctx.getService('retry'); + private readonly testSubjects = this.ctx.getService('testSubjects'); + private readonly comboBox = this.ctx.getService('comboBox'); + private readonly common = this.ctx.getPageObject('common'); + private readonly header = this.ctx.getPageObject('header'); + private readonly timePicker = this.ctx.getPageObject('timePicker'); + private readonly visChart = this.ctx.getPageObject('visChart'); + + public async resetPage( + fromTime = 'Sep 19, 2015 @ 06:31:44.000', + toTime = 'Sep 22, 2015 @ 18:31:44.000' + ) { + await this.common.navigateToUrl('visualize', 'create?type=metrics', { + useActualUrl: true, + }); + this.log.debug('Wait for initializing TSVB editor'); + await this.checkVisualBuilderIsPresent(); + this.log.debug('Set absolute time range from "' + fromTime + '" to "' + toTime + '"'); + await this.timePicker.setAbsoluteRange(fromTime, toTime); + // 2 sec sleep until https://github.com/elastic/kibana/issues/46353 is fixed + await this.common.sleep(2000); + } - public async checkTabIsLoaded(testSubj: string, name: string) { - let isPresent = false; - await retry.try(async () => { - isPresent = await testSubjects.exists(testSubj, { timeout: 20000 }); - if (!isPresent) { - isPresent = await testSubjects.exists('visNoResult', { timeout: 1000 }); - } - }); + public async checkTabIsLoaded(testSubj: string, name: string) { + let isPresent = false; + await this.retry.try(async () => { + isPresent = await this.testSubjects.exists(testSubj, { timeout: 20000 }); if (!isPresent) { - throw new Error(`TSVB ${name} tab is not loaded`); + isPresent = await this.testSubjects.exists('visNoResult', { timeout: 1000 }); } + }); + if (!isPresent) { + throw new Error(`TSVB ${name} tab is not loaded`); } + } - public async checkTabIsSelected(chartType: string) { - const chartTypeBtn = await testSubjects.find(`${chartType}TsvbTypeBtn`); - const isSelected = await chartTypeBtn.getAttribute('aria-selected'); + public async checkTabIsSelected(chartType: string) { + const chartTypeBtn = await this.testSubjects.find(`${chartType}TsvbTypeBtn`); + const isSelected = await chartTypeBtn.getAttribute('aria-selected'); - if (isSelected !== 'true') { - throw new Error(`TSVB ${chartType} tab is not selected`); - } + if (isSelected !== 'true') { + throw new Error(`TSVB ${chartType} tab is not selected`); } + } - public async checkPanelConfigIsPresent(chartType: string) { - await testSubjects.existOrFail(`tvbPanelConfig__${chartType}`); - } + public async checkPanelConfigIsPresent(chartType: string) { + await this.testSubjects.existOrFail(`tvbPanelConfig__${chartType}`); + } - public async checkVisualBuilderIsPresent() { - await this.checkTabIsLoaded('tvbVisEditor', 'Time Series'); - } + public async checkVisualBuilderIsPresent() { + await this.checkTabIsLoaded('tvbVisEditor', 'Time Series'); + } - public async checkTimeSeriesChartIsPresent() { - const isPresent = await find.existsByCssSelector('.tvbVisTimeSeries'); - if (!isPresent) { - throw new Error(`TimeSeries chart is not loaded`); - } + public async checkTimeSeriesChartIsPresent() { + const isPresent = await this.find.existsByCssSelector('.tvbVisTimeSeries'); + if (!isPresent) { + throw new Error(`TimeSeries chart is not loaded`); } + } - public async checkTimeSeriesIsLight() { - return await find.existsByCssSelector('.tvbVisTimeSeriesLight'); - } + public async checkTimeSeriesIsLight() { + return await this.find.existsByCssSelector('.tvbVisTimeSeriesLight'); + } - public async checkTimeSeriesLegendIsPresent() { - const isPresent = await find.existsByCssSelector('.echLegend'); - if (!isPresent) { - throw new Error(`TimeSeries legend is not loaded`); - } + public async checkTimeSeriesLegendIsPresent() { + const isPresent = await this.find.existsByCssSelector('.echLegend'); + if (!isPresent) { + throw new Error(`TimeSeries legend is not loaded`); } + } - public async checkMetricTabIsPresent() { - await this.checkTabIsLoaded('tsvbMetricValue', 'Metric'); - } + public async checkMetricTabIsPresent() { + await this.checkTabIsLoaded('tsvbMetricValue', 'Metric'); + } - public async checkGaugeTabIsPresent() { - await this.checkTabIsLoaded('tvbVisGaugeContainer', 'Gauge'); - } + public async checkGaugeTabIsPresent() { + await this.checkTabIsLoaded('tvbVisGaugeContainer', 'Gauge'); + } - public async checkTopNTabIsPresent() { - await this.checkTabIsLoaded('tvbVisTopNTable', 'TopN'); - } + public async checkTopNTabIsPresent() { + await this.checkTabIsLoaded('tvbVisTopNTable', 'TopN'); + } - public async clickMetric() { - const button = await testSubjects.find('metricTsvbTypeBtn'); - await button.click(); - } + public async clickMetric() { + const button = await this.testSubjects.find('metricTsvbTypeBtn'); + await button.click(); + } - public async clickMarkdown() { - const button = await testSubjects.find('markdownTsvbTypeBtn'); - await button.click(); - } + public async clickMarkdown() { + const button = await this.testSubjects.find('markdownTsvbTypeBtn'); + await button.click(); + } - public async getMetricValue() { - await PageObjects.visChart.waitForVisualizationRenderingStabilized(); - const metricValue = await find.byCssSelector('.tvbVisMetric__value--primary'); - return metricValue.getVisibleText(); - } + public async getMetricValue() { + await this.visChart.waitForVisualizationRenderingStabilized(); + const metricValue = await this.find.byCssSelector('.tvbVisMetric__value--primary'); + return metricValue.getVisibleText(); + } - public async enterMarkdown(markdown: string) { - await this.clearMarkdown(); - const input = await find.byCssSelector('.tvbMarkdownEditor__editor textarea'); - await input.type(markdown); - await PageObjects.common.sleep(3000); - } + public async enterMarkdown(markdown: string) { + await this.clearMarkdown(); + const input = await this.find.byCssSelector('.tvbMarkdownEditor__editor textarea'); + await input.type(markdown); + await this.common.sleep(3000); + } - public async clearMarkdown() { - // Since we use ACE editor and that isn't really storing its value inside - // a textarea we must really select all text and remove it, and cannot use - // clearValue(). - await retry.waitForWithTimeout('text area is cleared', 20000, async () => { - const editor = await testSubjects.find('codeEditorContainer'); - const $ = await editor.parseDomContent(); - const value = $('.ace_line').text(); - if (value.length > 0) { - log.debug('Clearing text area input'); - this.waitForMarkdownTextAreaCleaned(); - } - - return value.length === 0; - }); - } + public async clearMarkdown() { + // Since we use ACE editor and that isn't really storing its value inside + // a textarea we must really select all text and remove it, and cannot use + // clearValue(). + await this.retry.waitForWithTimeout('text area is cleared', 20000, async () => { + const editor = await this.testSubjects.find('codeEditorContainer'); + const $ = await editor.parseDomContent(); + const value = $('.ace_line').text(); + if (value.length > 0) { + this.log.debug('Clearing text area input'); + this.waitForMarkdownTextAreaCleaned(); + } - public async waitForMarkdownTextAreaCleaned() { - const input = await find.byCssSelector('.tvbMarkdownEditor__editor textarea'); - await input.clearValueWithKeyboard(); - const text = await this.getMarkdownText(); - return text.length === 0; - } + return value.length === 0; + }); + } - public async getMarkdownText(): Promise { - const el = await find.byCssSelector('.tvbVis'); - const text = await el.getVisibleText(); - return text; - } + public async waitForMarkdownTextAreaCleaned() { + const input = await this.find.byCssSelector('.tvbMarkdownEditor__editor textarea'); + await input.clearValueWithKeyboard(); + const text = await this.getMarkdownText(); + return text.length === 0; + } - /** - * - * getting all markdown variables list which located on `table` section - * - * **Note**: if `table` not have variables, use `getMarkdownTableNoVariables` method instead - * @see {getMarkdownTableNoVariables} - * @returns {Promise>} - * @memberof VisualBuilderPage - */ - public async getMarkdownTableVariables(): Promise< - Array<{ key: string; value: string; selector: WebElementWrapper }> - > { - const testTableVariables = await testSubjects.find('tsvbMarkdownVariablesTable'); - const variablesSelector = 'tbody tr'; - const exists = await find.existsByCssSelector(variablesSelector); - if (!exists) { - log.debug('variable list is empty'); - return []; - } - const variables = await testTableVariables.findAllByCssSelector(variablesSelector); - - const variablesKeyValueSelectorMap = await Promise.all( - variables.map(async (variable) => { - const subVars = await variable.findAllByCssSelector('td'); - const selector = await subVars[0].findByTagName('a'); - const key = await selector.getVisibleText(); - const value = await subVars[1].getVisibleText(); - log.debug(`markdown table variables table is: ${key} ${value}`); - return { key, value, selector }; - }) - ); - return variablesKeyValueSelectorMap; - } + public async getMarkdownText(): Promise { + const el = await this.find.byCssSelector('.tvbVis'); + const text = await el.getVisibleText(); + return text; + } - /** - * return variable table message, if `table` is empty it will be fail - * - * **Note:** if `table` have variables, use `getMarkdownTableVariables` method instead - * @see {@link VisualBuilderPage#getMarkdownTableVariables} - * @returns - * @memberof VisualBuilderPage - */ - public async getMarkdownTableNoVariables() { - return await testSubjects.getVisibleText('tvbMarkdownEditor__noVariables'); - } + /** + * + * getting all markdown variables list which located on `table` section + * + * **Note**: if `table` not have variables, use `getMarkdownTableNoVariables` method instead + * @see {getMarkdownTableNoVariables} + * @returns {Promise>} + * @memberof VisualBuilderPage + */ + public async getMarkdownTableVariables(): Promise< + Array<{ key: string; value: string; selector: WebElementWrapper }> + > { + const testTableVariables = await this.testSubjects.find('tsvbMarkdownVariablesTable'); + const variablesSelector = 'tbody tr'; + const exists = await this.find.existsByCssSelector(variablesSelector); + if (!exists) { + this.log.debug('variable list is empty'); + return []; + } + const variables = await testTableVariables.findAllByCssSelector(variablesSelector); + + const variablesKeyValueSelectorMap = await Promise.all( + variables.map(async (variable) => { + const subVars = await variable.findAllByCssSelector('td'); + const selector = await subVars[0].findByTagName('a'); + const key = await selector.getVisibleText(); + const value = await subVars[1].getVisibleText(); + this.log.debug(`markdown table variables table is: ${key} ${value}`); + return { key, value, selector }; + }) + ); + return variablesKeyValueSelectorMap; + } - /** - * get all sub-tabs count for `time series`, `metric`, `top n`, `gauge`, `markdown` or `table` tab. - * - * @returns {Promise} - * @memberof VisualBuilderPage - */ - public async getSubTabs(): Promise { - return await find.allByCssSelector('[data-test-subj$="-subtab"]'); - } + /** + * return variable table message, if `table` is empty it will be fail + * + * **Note:** if `table` have variables, use `getMarkdownTableVariables` method instead + * @see {@link VisualBuilderPage#getMarkdownTableVariables} + * @returns + * @memberof VisualBuilderPage + */ + public async getMarkdownTableNoVariables() { + return await this.testSubjects.getVisibleText('tvbMarkdownEditor__noVariables'); + } - /** - * switch markdown sub-tab for visualization - * - * @param {'data' | 'options'| 'markdown'} subTab - * @memberof VisualBuilderPage - */ - public async markdownSwitchSubTab(subTab: 'data' | 'options' | 'markdown') { - const tab = await testSubjects.find(`${subTab}-subtab`); - const isSelected = await tab.getAttribute('aria-selected'); - if (isSelected !== 'true') { - await tab.click(); - } - } + /** + * get all sub-tabs count for `time series`, `metric`, `top n`, `gauge`, `markdown` or `table` tab. + * + * @returns {Promise} + * @memberof VisualBuilderPage + */ + public async getSubTabs(): Promise { + return await this.find.allByCssSelector('[data-test-subj$="-subtab"]'); + } - /** - * setting label for markdown visualization - * - * @param {string} variableName - * @param type - * @memberof VisualBuilderPage - */ - public async setMarkdownDataVariable(variableName: string, type: 'variable' | 'label') { - const SELECTOR = type === 'label' ? '[placeholder="Label"]' : '[placeholder="Variable name"]'; - if (variableName) { - await find.setValue(SELECTOR, variableName); - } else { - const input = await find.byCssSelector(SELECTOR); - await input.clearValueWithKeyboard({ charByChar: true }); - } + /** + * switch markdown sub-tab for visualization + * + * @param {'data' | 'options'| 'markdown'} subTab + * @memberof VisualBuilderPage + */ + public async markdownSwitchSubTab(subTab: 'data' | 'options' | 'markdown') { + const tab = await this.testSubjects.find(`${subTab}-subtab`); + const isSelected = await tab.getAttribute('aria-selected'); + if (isSelected !== 'true') { + await tab.click(); } + } - public async clickSeriesOption(nth = 0) { - const el = await testSubjects.findAll('seriesOptions'); - await el[nth].click(); + /** + * setting label for markdown visualization + * + * @param {string} variableName + * @param type + * @memberof VisualBuilderPage + */ + public async setMarkdownDataVariable(variableName: string, type: 'variable' | 'label') { + const SELECTOR = type === 'label' ? '[placeholder="Label"]' : '[placeholder="Variable name"]'; + if (variableName) { + await this.find.setValue(SELECTOR, variableName); + } else { + const input = await this.find.byCssSelector(SELECTOR); + await input.clearValueWithKeyboard({ charByChar: true }); } + } - public async clearOffsetSeries() { - const el = await testSubjects.find('offsetTimeSeries'); - await el.clearValue(); - } + public async clickSeriesOption(nth = 0) { + const el = await this.testSubjects.findAll('seriesOptions'); + await el[nth].click(); + } - public async toggleAutoApplyChanges() { - await find.clickByCssSelector('#tsvbAutoApplyInput'); - } + public async clearOffsetSeries() { + const el = await this.testSubjects.find('offsetTimeSeries'); + await el.clearValue(); + } - public async applyChanges() { - await testSubjects.clickWhenNotDisabled('applyBtn'); - } + public async toggleAutoApplyChanges() { + await this.find.clickByCssSelector('#tsvbAutoApplyInput'); + } - /** - * change the data formatter for template in an `options` label tab - * - * @param formatter - typeof formatter which you can use for presenting data. By default kibana show `Number` formatter - */ - public async changeDataFormatter( - formatter: 'Bytes' | 'Number' | 'Percent' | 'Duration' | 'Custom' - ) { - const formatterEl = await testSubjects.find('tsvbDataFormatPicker'); - await comboBox.setElement(formatterEl, formatter, { clickWithMouse: true }); - } + public async applyChanges() { + await this.testSubjects.clickWhenNotDisabled('applyBtn'); + } - /** - * set duration formatter additional settings - * - * @param from start format - * @param to end format - * @param decimalPlaces decimals count - */ - public async setDurationFormatterSettings({ - from, - to, - decimalPlaces, - }: { - from?: FromDuration; - to?: ToDuration; - decimalPlaces?: string; - }) { - if (from) { - await retry.try(async () => { - const fromCombobox = await find.byCssSelector('[id$="from-row"] .euiComboBox'); - await comboBox.setElement(fromCombobox, from, { clickWithMouse: true }); - }); - } - if (to) { - const toCombobox = await find.byCssSelector('[id$="to-row"] .euiComboBox'); - await comboBox.setElement(toCombobox, to, { clickWithMouse: true }); - } - if (decimalPlaces) { - const decimalPlacesInput = await find.byCssSelector('[id$="decimal"]'); - await decimalPlacesInput.type(decimalPlaces); - } - } + /** + * change the data formatter for template in an `options` label tab + * + * @param formatter - typeof formatter which you can use for presenting data. By default kibana show `Number` formatter + */ + public async changeDataFormatter( + formatter: 'Bytes' | 'Number' | 'Percent' | 'Duration' | 'Custom' + ) { + const formatterEl = await this.testSubjects.find('tsvbDataFormatPicker'); + await this.comboBox.setElement(formatterEl, formatter, { clickWithMouse: true }); + } - /** - * write template for aggregation row in the `option` tab - * - * @param template always should contain `{{value}}` - * @example - * await visualBuilder.enterSeriesTemplate('$ {{value}}') // add `$` symbol for value - */ - public async enterSeriesTemplate(template: string) { - const el = await testSubjects.find('tsvb_series_value'); - await el.clearValueWithKeyboard(); - await el.type(template); + /** + * set duration formatter additional settings + * + * @param from start format + * @param to end format + * @param decimalPlaces decimals count + */ + public async setDurationFormatterSettings({ + from, + to, + decimalPlaces, + }: { + from?: FromDuration; + to?: ToDuration; + decimalPlaces?: string; + }) { + if (from) { + await this.retry.try(async () => { + const fromCombobox = await this.find.byCssSelector('[id$="from-row"] .euiComboBox'); + await this.comboBox.setElement(fromCombobox, from, { clickWithMouse: true }); + }); } - - public async enterOffsetSeries(value: string) { - const el = await testSubjects.find('offsetTimeSeries'); - await el.clearValue(); - await el.type(value); + if (to) { + const toCombobox = await this.find.byCssSelector('[id$="to-row"] .euiComboBox'); + await this.comboBox.setElement(toCombobox, to, { clickWithMouse: true }); } - - public async getRhythmChartLegendValue(nth = 0) { - await PageObjects.visChart.waitForVisualizationRenderingStabilized(); - const metricValue = ( - await find.allByCssSelector(`.echLegendItem .echLegendItem__extra`, 20000) - )[nth]; - await metricValue.moveMouseTo(); - return await metricValue.getVisibleText(); + if (decimalPlaces) { + const decimalPlacesInput = await this.find.byCssSelector('[id$="decimal"]'); + await decimalPlacesInput.type(decimalPlaces); } + } - public async clickGauge() { - await testSubjects.click('gaugeTsvbTypeBtn'); - } + /** + * write template for aggregation row in the `option` tab + * + * @param template always should contain `{{value}}` + * @example + * await visualBuilder.enterSeriesTemplate('$ {{value}}') // add `$` symbol for value + */ + public async enterSeriesTemplate(template: string) { + const el = await this.testSubjects.find('tsvb_series_value'); + await el.clearValueWithKeyboard(); + await el.type(template); + } - public async getGaugeLabel() { - const gaugeLabel = await find.byCssSelector('.tvbVisGauge__label'); - return await gaugeLabel.getVisibleText(); - } + public async enterOffsetSeries(value: string) { + const el = await this.testSubjects.find('offsetTimeSeries'); + await el.clearValue(); + await el.type(value); + } - public async getGaugeCount() { - const gaugeCount = await find.byCssSelector('.tvbVisGauge__value'); - return await gaugeCount.getVisibleText(); - } + public async getRhythmChartLegendValue(nth = 0) { + await this.visChart.waitForVisualizationRenderingStabilized(); + const metricValue = ( + await this.find.allByCssSelector(`.echLegendItem .echLegendItem__extra`, 20000) + )[nth]; + await metricValue.moveMouseTo(); + return await metricValue.getVisibleText(); + } - public async clickTopN() { - await testSubjects.click('top_nTsvbTypeBtn'); - } + public async clickGauge() { + await this.testSubjects.click('gaugeTsvbTypeBtn'); + } - public async getTopNLabel() { - const topNLabel = await find.byCssSelector('.tvbVisTopN__label'); - return await topNLabel.getVisibleText(); - } + public async getGaugeLabel() { + const gaugeLabel = await this.find.byCssSelector('.tvbVisGauge__label'); + return await gaugeLabel.getVisibleText(); + } - public async getTopNCount() { - const gaugeCount = await find.byCssSelector('.tvbVisTopN__value'); - return await gaugeCount.getVisibleText(); - } + public async getGaugeCount() { + const gaugeCount = await this.find.byCssSelector('.tvbVisGauge__value'); + return await gaugeCount.getVisibleText(); + } - public async clickTable() { - await testSubjects.click('tableTsvbTypeBtn'); - } + public async clickTopN() { + await this.testSubjects.click('top_nTsvbTypeBtn'); + } - public async createNewAgg(nth = 0) { - const prevAggs = await testSubjects.findAll('aggSelector'); - const elements = await testSubjects.findAll('addMetricAddBtn'); - await elements[nth].click(); - await PageObjects.visChart.waitForVisualizationRenderingStabilized(); - await retry.waitFor('new agg is added', async () => { - const currentAggs = await testSubjects.findAll('aggSelector'); - return currentAggs.length > prevAggs.length; - }); - } + public async getTopNLabel() { + const topNLabel = await this.find.byCssSelector('.tvbVisTopN__label'); + return await topNLabel.getVisibleText(); + } - public async selectAggType(value: string, nth = 0) { - const elements = await testSubjects.findAll('aggSelector'); - await comboBox.setElement(elements[nth], value); - return await PageObjects.header.waitUntilLoadingHasFinished(); - } + public async getTopNCount() { + const gaugeCount = await this.find.byCssSelector('.tvbVisTopN__value'); + return await gaugeCount.getVisibleText(); + } - public async fillInExpression(expression: string, nth = 0) { - const expressions = await testSubjects.findAll('mathExpression'); - await expressions[nth].type(expression); - return await PageObjects.header.waitUntilLoadingHasFinished(); - } + public async clickTable() { + await this.testSubjects.click('tableTsvbTypeBtn'); + } - public async fillInVariable(name = 'test', metric = 'Count', nth = 0) { - const elements = await testSubjects.findAll('varRow'); - const varNameInput = await elements[nth].findByCssSelector('.tvbAggs__varName'); - await varNameInput.type(name); - const metricSelectWrapper = await elements[nth].findByCssSelector( - '.tvbAggs__varMetricWrapper' - ); - await comboBox.setElement(metricSelectWrapper, metric); - return await PageObjects.header.waitUntilLoadingHasFinished(); - } + public async createNewAgg(nth = 0) { + const prevAggs = await this.testSubjects.findAll('aggSelector'); + const elements = await this.testSubjects.findAll('addMetricAddBtn'); + await elements[nth].click(); + await this.visChart.waitForVisualizationRenderingStabilized(); + await this.retry.waitFor('new agg is added', async () => { + const currentAggs = await this.testSubjects.findAll('aggSelector'); + return currentAggs.length > prevAggs.length; + }); + } - public async selectGroupByField(fieldName: string) { - await comboBox.set('groupByField', fieldName); - } + public async selectAggType(value: string, nth = 0) { + const elements = await this.testSubjects.findAll('aggSelector'); + await this.comboBox.setElement(elements[nth], value); + return await this.header.waitUntilLoadingHasFinished(); + } - public async setColumnLabelValue(value: string) { - const el = await testSubjects.find('columnLabelName'); - await el.clearValue(); - await el.type(value); - await PageObjects.header.waitUntilLoadingHasFinished(); - } + public async fillInExpression(expression: string, nth = 0) { + const expressions = await this.testSubjects.findAll('mathExpression'); + await expressions[nth].type(expression); + return await this.header.waitUntilLoadingHasFinished(); + } - /** - * get values for rendered table - * - * **Note:** this work only for table visualization - * - * @returns {Promise} - * @memberof VisualBuilderPage - */ - public async getViewTable(): Promise { - const tableView = await testSubjects.find('tableView', 20000); - return await tableView.getVisibleText(); - } + public async fillInVariable(name = 'test', metric = 'Count', nth = 0) { + const elements = await this.testSubjects.findAll('varRow'); + const varNameInput = await elements[nth].findByCssSelector('.tvbAggs__varName'); + await varNameInput.type(name); + const metricSelectWrapper = await elements[nth].findByCssSelector('.tvbAggs__varMetricWrapper'); + await this.comboBox.setElement(metricSelectWrapper, metric); + return await this.header.waitUntilLoadingHasFinished(); + } - public async clickPanelOptions(tabName: string) { - await testSubjects.click(`${tabName}EditorPanelOptionsBtn`); - await PageObjects.header.waitUntilLoadingHasFinished(); - } + public async selectGroupByField(fieldName: string) { + await this.comboBox.set('groupByField', fieldName); + } - public async clickDataTab(tabName: string) { - await testSubjects.click(`${tabName}EditorDataBtn`); - await PageObjects.header.waitUntilLoadingHasFinished(); - } + public async setColumnLabelValue(value: string) { + const el = await this.testSubjects.find('columnLabelName'); + await el.clearValue(); + await el.type(value); + await this.header.waitUntilLoadingHasFinished(); + } - public async switchIndexPatternSelectionMode(useKibanaIndices: boolean) { - await testSubjects.click('switchIndexPatternSelectionModePopover'); - await testSubjects.setEuiSwitch( - 'switchIndexPatternSelectionMode', - useKibanaIndices ? 'check' : 'uncheck' - ); - } + /** + * get values for rendered table + * + * **Note:** this work only for table visualization + * + * @returns {Promise} + * @memberof VisualBuilderPage + */ + public async getViewTable(): Promise { + const tableView = await this.testSubjects.find('tableView', 20000); + return await tableView.getVisibleText(); + } - public async setIndexPatternValue(value: string, useKibanaIndices?: boolean) { - const metricsIndexPatternInput = 'metricsIndexPatternInput'; + public async clickPanelOptions(tabName: string) { + await this.testSubjects.click(`${tabName}EditorPanelOptionsBtn`); + await this.header.waitUntilLoadingHasFinished(); + } - if (useKibanaIndices !== undefined) { - await this.switchIndexPatternSelectionMode(useKibanaIndices); - } + public async clickDataTab(tabName: string) { + await this.testSubjects.click(`${tabName}EditorDataBtn`); + await this.header.waitUntilLoadingHasFinished(); + } - if (useKibanaIndices === false) { - const el = await testSubjects.find(metricsIndexPatternInput); - await el.clearValue(); - if (value) { - await el.type(value, { charByChar: true }); - } - } else { - await comboBox.clearInputField(metricsIndexPatternInput); - if (value) { - await comboBox.setCustom(metricsIndexPatternInput, value); - } - } + public async switchIndexPatternSelectionMode(useKibanaIndices: boolean) { + await this.testSubjects.click('switchIndexPatternSelectionModePopover'); + await this.testSubjects.setEuiSwitch( + 'switchIndexPatternSelectionMode', + useKibanaIndices ? 'check' : 'uncheck' + ); + } + + public async setIndexPatternValue(value: string, useKibanaIndices?: boolean) { + const metricsIndexPatternInput = 'metricsIndexPatternInput'; - await PageObjects.header.waitUntilLoadingHasFinished(); + if (useKibanaIndices !== undefined) { + await this.switchIndexPatternSelectionMode(useKibanaIndices); } - public async setIntervalValue(value: string) { - const el = await testSubjects.find('metricsIndexPatternInterval'); + if (useKibanaIndices === false) { + const el = await this.testSubjects.find(metricsIndexPatternInput); await el.clearValue(); - await el.type(value); - await PageObjects.header.waitUntilLoadingHasFinished(); + if (value) { + await el.type(value, { charByChar: true }); + } + } else { + await this.comboBox.clearInputField(metricsIndexPatternInput); + if (value) { + await this.comboBox.setCustom(metricsIndexPatternInput, value); + } } - public async setDropLastBucket(value: boolean) { - const option = await testSubjects.find(`metricsDropLastBucket-${value ? 'yes' : 'no'}`); - (await option.findByCssSelector('label')).click(); - await PageObjects.header.waitUntilLoadingHasFinished(); - } + await this.header.waitUntilLoadingHasFinished(); + } - public async waitForIndexPatternTimeFieldOptionsLoaded() { - await retry.waitFor('combobox options loaded', async () => { - const options = await comboBox.getOptions('metricsIndexPatternFieldsSelect'); - log.debug(`-- optionsCount=${options.length}`); - return options.length > 0; - }); - } + public async setIntervalValue(value: string) { + const el = await this.testSubjects.find('metricsIndexPatternInterval'); + await el.clearValue(); + await el.type(value); + await this.header.waitUntilLoadingHasFinished(); + } - public async selectIndexPatternTimeField(timeField: string) { - await retry.try(async () => { - await comboBox.clearInputField('metricsIndexPatternFieldsSelect'); - await comboBox.set('metricsIndexPatternFieldsSelect', timeField); - }); - } + public async setDropLastBucket(value: boolean) { + const option = await this.testSubjects.find(`metricsDropLastBucket-${value ? 'yes' : 'no'}`); + (await option.findByCssSelector('label')).click(); + await this.header.waitUntilLoadingHasFinished(); + } - /** - * check that table visualization is visible and ready for interact - * - * @returns {Promise} - * @memberof VisualBuilderPage - */ - public async checkTableTabIsPresent(): Promise { - await testSubjects.existOrFail('visualizationLoader'); - const isDataExists = await testSubjects.exists('tableView'); - log.debug(`data is already rendered: ${isDataExists}`); - if (!isDataExists) { - await this.checkPreviewIsDisabled(); - } - } + public async waitForIndexPatternTimeFieldOptionsLoaded() { + await this.retry.waitFor('combobox options loaded', async () => { + const options = await this.comboBox.getOptions('metricsIndexPatternFieldsSelect'); + this.log.debug(`-- optionsCount=${options.length}`); + return options.length > 0; + }); + } - /** - * set label name for aggregation - * - * @param {string} labelName - * @param {number} [nth=0] - * @memberof VisualBuilderPage - */ - public async setLabel(labelName: string, nth: number = 0): Promise { - const input = (await find.allByCssSelector('[placeholder="Label"]'))[nth]; - await input.type(labelName); - } + public async selectIndexPatternTimeField(timeField: string) { + await this.retry.try(async () => { + await this.comboBox.clearInputField('metricsIndexPatternFieldsSelect'); + await this.comboBox.set('metricsIndexPatternFieldsSelect', timeField); + }); + } - /** - * set field for type of aggregation - * - * @param {string} field name of field - * @param {number} [aggNth=0] number of aggregation. Start by zero - * @default 0 - * @memberof VisualBuilderPage - */ - public async setFieldForAggregation(field: string, aggNth: number = 0): Promise { - const fieldEl = await this.getFieldForAggregation(aggNth); - - await comboBox.setElement(fieldEl, field); + /** + * check that table visualization is visible and ready for interact + * + * @returns {Promise} + * @memberof VisualBuilderPage + */ + public async checkTableTabIsPresent(): Promise { + await this.testSubjects.existOrFail('visualizationLoader'); + const isDataExists = await this.testSubjects.exists('tableView'); + this.log.debug(`data is already rendered: ${isDataExists}`); + if (!isDataExists) { + await this.checkPreviewIsDisabled(); } + } - public async checkFieldForAggregationValidity(aggNth: number = 0): Promise { - const fieldEl = await this.getFieldForAggregation(aggNth); + /** + * set label name for aggregation + * + * @param {string} labelName + * @param {number} [nth=0] + * @memberof VisualBuilderPage + */ + public async setLabel(labelName: string, nth: number = 0): Promise { + const input = (await this.find.allByCssSelector('[placeholder="Label"]'))[nth]; + await input.type(labelName); + } - return await comboBox.checkValidity(fieldEl); - } + /** + * set field for type of aggregation + * + * @param {string} field name of field + * @param {number} [aggNth=0] number of aggregation. Start by zero + * @default 0 + * @memberof VisualBuilderPage + */ + public async setFieldForAggregation(field: string, aggNth: number = 0): Promise { + const fieldEl = await this.getFieldForAggregation(aggNth); + + await this.comboBox.setElement(fieldEl, field); + } - public async getFieldForAggregation(aggNth: number = 0): Promise { - const labels = await testSubjects.findAll('aggRow'); - const label = labels[aggNth]; + public async checkFieldForAggregationValidity(aggNth: number = 0): Promise { + const fieldEl = await this.getFieldForAggregation(aggNth); - return (await label.findAllByTestSubject('comboBoxInput'))[1]; - } + return await this.comboBox.checkValidity(fieldEl); + } - public async clickColorPicker(): Promise { - const picker = await find.byCssSelector('.tvbColorPicker button'); - await picker.clickMouseButton(); - } + public async getFieldForAggregation(aggNth: number = 0): Promise { + const labels = await this.testSubjects.findAll('aggRow'); + const label = labels[aggNth]; - public async setBackgroundColor(colorHex: string): Promise { - await this.clickColorPicker(); - await this.checkColorPickerPopUpIsPresent(); - await find.setValue('.euiColorPicker input', colorHex); - await this.clickColorPicker(); - await PageObjects.visChart.waitForVisualizationRenderingStabilized(); - } + return (await label.findAllByTestSubject('comboBoxInput'))[1]; + } - public async checkColorPickerPopUpIsPresent(): Promise { - log.debug(`Check color picker popup is present`); - await testSubjects.existOrFail('colorPickerPopover', { timeout: 5000 }); - } + public async clickColorPicker(): Promise { + const picker = await this.find.byCssSelector('.tvbColorPicker button'); + await picker.clickMouseButton(); + } - public async changePanelPreview(nth: number = 0): Promise { - const prevRenderingCount = await PageObjects.visChart.getVisualizationRenderingCount(); - const changePreviewBtnArray = await testSubjects.findAll('AddActivatePanelBtn'); - await changePreviewBtnArray[nth].click(); - await PageObjects.visChart.waitForRenderingCount(prevRenderingCount + 1); - } + public async setBackgroundColor(colorHex: string): Promise { + await this.clickColorPicker(); + await this.checkColorPickerPopUpIsPresent(); + await this.find.setValue('.euiColorPicker input', colorHex); + await this.clickColorPicker(); + await this.visChart.waitForVisualizationRenderingStabilized(); + } - public async checkPreviewIsDisabled(): Promise { - log.debug(`Check no data message is present`); - await testSubjects.existOrFail('timeseriesVis > visNoResult', { timeout: 5000 }); - } + public async checkColorPickerPopUpIsPresent(): Promise { + this.log.debug(`Check color picker popup is present`); + await this.testSubjects.existOrFail('colorPickerPopover', { timeout: 5000 }); + } - public async cloneSeries(nth: number = 0): Promise { - const cloneBtnArray = await testSubjects.findAll('AddCloneBtn'); - await cloneBtnArray[nth].click(); - await PageObjects.visChart.waitForVisualizationRenderingStabilized(); - } + public async changePanelPreview(nth: number = 0): Promise { + const prevRenderingCount = await this.visChart.getVisualizationRenderingCount(); + const changePreviewBtnArray = await this.testSubjects.findAll('AddActivatePanelBtn'); + await changePreviewBtnArray[nth].click(); + await this.visChart.waitForRenderingCount(prevRenderingCount + 1); + } - /** - * Get aggregation count for the current series - * - * @param {number} [nth=0] series - * @returns {Promise} - * @memberof VisualBuilderPage - */ - public async getAggregationCount(nth: number = 0): Promise { - const series = await this.getSeries(); - const aggregation = await series[nth].findAllByTestSubject('draggable'); - return aggregation.length; - } + public async checkPreviewIsDisabled(): Promise { + this.log.debug(`Check no data message is present`); + await this.testSubjects.existOrFail('timeseriesVis > visNoResult', { timeout: 5000 }); + } - public async deleteSeries(nth: number = 0): Promise { - const prevRenderingCount = await PageObjects.visChart.getVisualizationRenderingCount(); - const cloneBtnArray = await testSubjects.findAll('AddDeleteBtn'); - await cloneBtnArray[nth].click(); - await PageObjects.visChart.waitForRenderingCount(prevRenderingCount + 1); - } + public async cloneSeries(nth: number = 0): Promise { + const cloneBtnArray = await this.testSubjects.findAll('AddCloneBtn'); + await cloneBtnArray[nth].click(); + await this.visChart.waitForVisualizationRenderingStabilized(); + } - public async getLegendItems(): Promise { - return await find.allByCssSelector('.echLegendItem'); - } + /** + * Get aggregation count for the current series + * + * @param {number} [nth=0] series + * @returns {Promise} + * @memberof VisualBuilderPage + */ + public async getAggregationCount(nth: number = 0): Promise { + const series = await this.getSeries(); + const aggregation = await series[nth].findAllByTestSubject('draggable'); + return aggregation.length; + } - public async getLegendItemsContent(): Promise { - const legendList = await find.byCssSelector('.echLegendList'); - const $ = await legendList.parseDomContent(); + public async deleteSeries(nth: number = 0): Promise { + const prevRenderingCount = await this.visChart.getVisualizationRenderingCount(); + const cloneBtnArray = await this.testSubjects.findAll('AddDeleteBtn'); + await cloneBtnArray[nth].click(); + await this.visChart.waitForRenderingCount(prevRenderingCount + 1); + } - return $('li') - .toArray() - .map((li) => { - const label = $(li).find('.echLegendItem__label').text(); - const value = $(li).find('.echLegendItem__extra').text(); + public async getLegendItems(): Promise { + return await this.find.allByCssSelector('.echLegendItem'); + } - return `${label}: ${value}`; - }); - } + public async getLegendItemsContent(): Promise { + const legendList = await this.find.byCssSelector('.echLegendList'); + const $ = await legendList.parseDomContent(); - public async getSeries(): Promise { - return await find.allByCssSelector('.tvbSeriesEditor'); - } + return $('li') + .toArray() + .map((li) => { + const label = $(li).find('.echLegendItem__label').text(); + const value = $(li).find('.echLegendItem__extra').text(); - public async setMetricsGroupByTerms(field: string) { - const groupBy = await find.byCssSelector( - '.tvbAggRow--split [data-test-subj="comboBoxInput"]' - ); - await comboBox.setElement(groupBy, 'Terms', { clickWithMouse: true }); - await PageObjects.common.sleep(1000); - const byField = await testSubjects.find('groupByField'); - await comboBox.setElement(byField, field); - } + return `${label}: ${value}`; + }); + } - public async checkSelectedMetricsGroupByValue(value: string) { - const groupBy = await find.byCssSelector( - '.tvbAggRow--split [data-test-subj="comboBoxInput"]' - ); - return await comboBox.isOptionSelected(groupBy, value); - } + public async getSeries(): Promise { + return await this.find.allByCssSelector('.tvbSeriesEditor'); + } - public async setMetricsDataTimerangeMode(value: string) { - const dataTimeRangeMode = await testSubjects.find('dataTimeRangeMode'); - return await comboBox.setElement(dataTimeRangeMode, value); - } + public async setMetricsGroupByTerms(field: string) { + const groupBy = await this.find.byCssSelector( + '.tvbAggRow--split [data-test-subj="comboBoxInput"]' + ); + await this.comboBox.setElement(groupBy, 'Terms', { clickWithMouse: true }); + await this.common.sleep(1000); + const byField = await this.testSubjects.find('groupByField'); + await this.comboBox.setElement(byField, field); + } - public async checkSelectedDataTimerangeMode(value: string) { - const dataTimeRangeMode = await testSubjects.find('dataTimeRangeMode'); - return await comboBox.isOptionSelected(dataTimeRangeMode, value); - } + public async checkSelectedMetricsGroupByValue(value: string) { + const groupBy = await this.find.byCssSelector( + '.tvbAggRow--split [data-test-subj="comboBoxInput"]' + ); + return await this.comboBox.isOptionSelected(groupBy, value); } - return new VisualBuilderPage(); + public async setMetricsDataTimerangeMode(value: string) { + const dataTimeRangeMode = await this.testSubjects.find('dataTimeRangeMode'); + return await this.comboBox.setElement(dataTimeRangeMode, value); + } + + public async checkSelectedDataTimerangeMode(value: string) { + const dataTimeRangeMode = await this.testSubjects.find('dataTimeRangeMode'); + return await this.comboBox.isOptionSelected(dataTimeRangeMode, value); + } } diff --git a/test/functional/page_objects/visualize_chart_page.ts b/test/functional/page_objects/visualize_chart_page.ts index 7ecf800b4be7c..c8587f4ffd346 100644 --- a/test/functional/page_objects/visualize_chart_page.ts +++ b/test/functional/page_objects/visualize_chart_page.ts @@ -9,614 +9,618 @@ import { Position } from '@elastic/charts'; import Color from 'color'; -import { FtrProviderContext } from '../ftr_provider_context'; +import { FtrService } from '../ftr_provider_context'; const xyChartSelector = 'visTypeXyChart'; const pieChartSelector = 'visTypePieChart'; -export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrProviderContext) { - const testSubjects = getService('testSubjects'); - const config = getService('config'); - const find = getService('find'); - const log = getService('log'); - const retry = getService('retry'); - const kibanaServer = getService('kibanaServer'); - const elasticChart = getService('elasticChart'); - const dataGrid = getService('dataGrid'); - const defaultFindTimeout = config.get('timeouts.find'); - const { common } = getPageObjects(['common']); - - class VisualizeChart { - public async getEsChartDebugState(chartSelector: string) { - return await elasticChart.getChartDebugData(chartSelector); - } +export class VisualizeChartPageObject extends FtrService { + private readonly testSubjects = this.ctx.getService('testSubjects'); + private readonly config = this.ctx.getService('config'); + private readonly find = this.ctx.getService('find'); + private readonly log = this.ctx.getService('log'); + private readonly retry = this.ctx.getService('retry'); + private readonly kibanaServer = this.ctx.getService('kibanaServer'); + private readonly elasticChart = this.ctx.getService('elasticChart'); + private readonly dataGrid = this.ctx.getService('dataGrid'); + private readonly common = this.ctx.getPageObject('common'); + + private readonly defaultFindTimeout = this.config.get('timeouts.find'); + + public async getEsChartDebugState(chartSelector: string) { + return await this.elasticChart.getChartDebugData(chartSelector); + } - /** - * Is new charts library advanced setting enabled - */ - public async isNewChartsLibraryEnabled(): Promise { - const legacyChartsLibrary = - Boolean(await kibanaServer.uiSettings.get('visualization:visualize:legacyChartsLibrary')) ?? - true; - const enabled = !legacyChartsLibrary; - log.debug(`-- isNewChartsLibraryEnabled = ${enabled}`); - - return enabled; - } + /** + * Is new charts library advanced setting enabled + */ + public async isNewChartsLibraryEnabled(): Promise { + const legacyChartsLibrary = + Boolean( + await this.kibanaServer.uiSettings.get('visualization:visualize:legacyChartsLibrary') + ) ?? true; + const enabled = !legacyChartsLibrary; + this.log.debug(`-- isNewChartsLibraryEnabled = ${enabled}`); + + return enabled; + } - /** - * Is new charts library enabled and an area, line or histogram chart exists - */ - public async isNewLibraryChart(chartSelector: string): Promise { - const enabled = await this.isNewChartsLibraryEnabled(); + /** + * Is new charts library enabled and an area, line or histogram chart exists + */ + public async isNewLibraryChart(chartSelector: string): Promise { + const enabled = await this.isNewChartsLibraryEnabled(); - if (!enabled) { - log.debug(`-- isNewLibraryChart = false`); - return false; - } - - // check if enabled but not a line, area, histogram or pie chart - if (await find.existsByCssSelector('.visLib__chart', 1)) { - const chart = await find.byCssSelector('.visLib__chart'); - const chartType = await chart.getAttribute('data-vislib-chart-type'); + if (!enabled) { + this.log.debug(`-- isNewLibraryChart = false`); + return false; + } - if (!['line', 'area', 'histogram', 'pie'].includes(chartType)) { - log.debug(`-- isNewLibraryChart = false`); - return false; - } - } + // check if enabled but not a line, area, histogram or pie chart + if (await this.find.existsByCssSelector('.visLib__chart', 1)) { + const chart = await this.find.byCssSelector('.visLib__chart'); + const chartType = await chart.getAttribute('data-vislib-chart-type'); - if (!(await elasticChart.hasChart(chartSelector, 1))) { - // not be a vislib chart type - log.debug(`-- isNewLibraryChart = false`); + if (!['line', 'area', 'histogram', 'pie'].includes(chartType)) { + this.log.debug(`-- isNewLibraryChart = false`); return false; } + } - log.debug(`-- isNewLibraryChart = true`); - return true; + if (!(await this.elasticChart.hasChart(chartSelector, 1))) { + // not be a vislib chart type + this.log.debug(`-- isNewLibraryChart = false`); + return false; } - /** - * Helper method to get expected values that are slightly different - * between vislib and elastic-chart inplementations - * @param vislibValue value expected for vislib chart - * @param elasticChartsValue value expected for `@elastic/charts` chart - */ - public async getExpectedValue(vislibValue: T, elasticChartsValue: T): Promise { - if (await this.isNewLibraryChart(xyChartSelector)) { - return elasticChartsValue; - } + this.log.debug(`-- isNewLibraryChart = true`); + return true; + } - return vislibValue; + /** + * Helper method to get expected values that are slightly different + * between vislib and elastic-chart inplementations + * @param vislibValue value expected for vislib chart + * @param elasticChartsValue value expected for `@elastic/charts` chart + */ + public async getExpectedValue(vislibValue: T, elasticChartsValue: T): Promise { + if (await this.isNewLibraryChart(xyChartSelector)) { + return elasticChartsValue; } - public async getYAxisTitle() { - if (await this.isNewLibraryChart(xyChartSelector)) { - const xAxis = (await this.getEsChartDebugState(xyChartSelector))?.axes?.y ?? []; - return xAxis[0]?.title; - } + return vislibValue; + } - const title = await find.byCssSelector('.y-axis-div .y-axis-title text'); - return await title.getVisibleText(); + public async getYAxisTitle() { + if (await this.isNewLibraryChart(xyChartSelector)) { + const xAxis = (await this.getEsChartDebugState(xyChartSelector))?.axes?.y ?? []; + return xAxis[0]?.title; } - public async getXAxisLabels() { - if (await this.isNewLibraryChart(xyChartSelector)) { - const [xAxis] = (await this.getEsChartDebugState(xyChartSelector))?.axes?.x ?? []; - return xAxis?.labels; - } + const title = await this.find.byCssSelector('.y-axis-div .y-axis-title text'); + return await title.getVisibleText(); + } - const xAxis = await find.byCssSelector('.visAxis--x.visAxis__column--bottom'); - const $ = await xAxis.parseDomContent(); - return $('.x > g > text') - .toArray() - .map((tick) => $(tick).text().trim()); + public async getXAxisLabels() { + if (await this.isNewLibraryChart(xyChartSelector)) { + const [xAxis] = (await this.getEsChartDebugState(xyChartSelector))?.axes?.x ?? []; + return xAxis?.labels; } - public async getYAxisLabels() { - if (await this.isNewLibraryChart(xyChartSelector)) { - const [yAxis] = (await this.getEsChartDebugState(xyChartSelector))?.axes?.y ?? []; - return yAxis?.labels; - } + const xAxis = await this.find.byCssSelector('.visAxis--x.visAxis__column--bottom'); + const $ = await xAxis.parseDomContent(); + return $('.x > g > text') + .toArray() + .map((tick) => $(tick).text().trim()); + } - const yAxis = await find.byCssSelector('.visAxis__column--y.visAxis__column--left'); - const $ = await yAxis.parseDomContent(); - return $('.y > g > text') - .toArray() - .map((tick) => $(tick).text().trim()); + public async getYAxisLabels() { + if (await this.isNewLibraryChart(xyChartSelector)) { + const [yAxis] = (await this.getEsChartDebugState(xyChartSelector))?.axes?.y ?? []; + return yAxis?.labels; } - public async getYAxisLabelsAsNumbers() { - if (await this.isNewLibraryChart(xyChartSelector)) { - const [yAxis] = (await this.getEsChartDebugState(xyChartSelector))?.axes?.y ?? []; - return yAxis?.values; - } + const yAxis = await this.find.byCssSelector('.visAxis__column--y.visAxis__column--left'); + const $ = await yAxis.parseDomContent(); + return $('.y > g > text') + .toArray() + .map((tick) => $(tick).text().trim()); + } - return (await this.getYAxisLabels()).map((label) => Number(label.replace(',', ''))); + public async getYAxisLabelsAsNumbers() { + if (await this.isNewLibraryChart(xyChartSelector)) { + const [yAxis] = (await this.getEsChartDebugState(xyChartSelector))?.axes?.y ?? []; + return yAxis?.values; } - /** - * Gets the chart data and scales it based on chart height and label. - * @param dataLabel data-label value - * @param axis axis value, 'ValueAxis-1' by default - * - * Returns an array of height values - */ - public async getAreaChartData(dataLabel: string, axis = 'ValueAxis-1') { - if (await this.isNewLibraryChart(xyChartSelector)) { - const areas = (await this.getEsChartDebugState(xyChartSelector))?.areas ?? []; - const points = areas.find(({ name }) => name === dataLabel)?.lines.y1.points ?? []; - return points.map(({ y }) => y); - } - - const yAxisRatio = await this.getChartYAxisRatio(axis); + return (await this.getYAxisLabels()).map((label) => Number(label.replace(',', ''))); + } - const rectangle = await find.byCssSelector('rect.background'); - const yAxisHeight = Number(await rectangle.getAttribute('height')); - log.debug(`height --------- ${yAxisHeight}`); + /** + * Gets the chart data and scales it based on chart height and label. + * @param dataLabel data-label value + * @param axis axis value, 'ValueAxis-1' by default + * + * Returns an array of height values + */ + public async getAreaChartData(dataLabel: string, axis = 'ValueAxis-1') { + if (await this.isNewLibraryChart(xyChartSelector)) { + const areas = (await this.getEsChartDebugState(xyChartSelector))?.areas ?? []; + const points = areas.find(({ name }) => name === dataLabel)?.lines.y1.points ?? []; + return points.map(({ y }) => y); + } + + const yAxisRatio = await this.getChartYAxisRatio(axis); + + const rectangle = await this.find.byCssSelector('rect.background'); + const yAxisHeight = Number(await rectangle.getAttribute('height')); + this.log.debug(`height --------- ${yAxisHeight}`); + + const path = await this.retry.try( + async () => + await this.find.byCssSelector( + `path[data-label="${dataLabel}"]`, + this.defaultFindTimeout * 2 + ) + ); + const data = await path.getAttribute('d'); + this.log.debug(data); + // This area chart data starts with a 'M'ove to a x,y location, followed + // by a bunch of 'L'ines from that point to the next. Those points are + // the values we're going to use to calculate the data values we're testing. + // So git rid of the one 'M' and split the rest on the 'L's. + const tempArray = data + .replace('M ', '') + .replace('M', '') + .replace(/ L /g, 'L') + .replace(/ /g, ',') + .split('L'); + const chartSections = tempArray.length / 2; + const chartData = []; + for (let i = 0; i < chartSections; i++) { + chartData[i] = Math.round((yAxisHeight - Number(tempArray[i].split(',')[1])) * yAxisRatio); + this.log.debug('chartData[i] =' + chartData[i]); + } + return chartData; + } - const path = await retry.try( - async () => - await find.byCssSelector(`path[data-label="${dataLabel}"]`, defaultFindTimeout * 2) - ); - const data = await path.getAttribute('d'); - log.debug(data); - // This area chart data starts with a 'M'ove to a x,y location, followed - // by a bunch of 'L'ines from that point to the next. Those points are - // the values we're going to use to calculate the data values we're testing. - // So git rid of the one 'M' and split the rest on the 'L's. - const tempArray = data - .replace('M ', '') - .replace('M', '') - .replace(/ L /g, 'L') - .replace(/ /g, ',') - .split('L'); - const chartSections = tempArray.length / 2; - const chartData = []; - for (let i = 0; i < chartSections; i++) { - chartData[i] = Math.round((yAxisHeight - Number(tempArray[i].split(',')[1])) * yAxisRatio); - log.debug('chartData[i] =' + chartData[i]); - } - return chartData; - } + /** + * Returns the paths that compose an area chart. + * @param dataLabel data-label value + */ + public async getAreaChartPaths(dataLabel: string) { + if (await this.isNewLibraryChart(xyChartSelector)) { + const areas = (await this.getEsChartDebugState(xyChartSelector))?.areas ?? []; + const path = areas.find(({ name }) => name === dataLabel)?.path ?? ''; + return path.split('L'); + } + + const path = await this.retry.try( + async () => + await this.find.byCssSelector( + `path[data-label="${dataLabel}"]`, + this.defaultFindTimeout * 2 + ) + ); + const data = await path.getAttribute('d'); + this.log.debug(data); + // This area chart data starts with a 'M'ove to a x,y location, followed + // by a bunch of 'L'ines from that point to the next. Those points are + // the values we're going to use to calculate the data values we're testing. + // So git rid of the one 'M' and split the rest on the 'L's. + return data.split('L'); + } - /** - * Returns the paths that compose an area chart. - * @param dataLabel data-label value - */ - public async getAreaChartPaths(dataLabel: string) { - if (await this.isNewLibraryChart(xyChartSelector)) { - const areas = (await this.getEsChartDebugState(xyChartSelector))?.areas ?? []; - const path = areas.find(({ name }) => name === dataLabel)?.path ?? ''; - return path.split('L'); - } + /** + * Gets the dots and normalizes their height. + * @param dataLabel data-label value + * @param axis axis value, 'ValueAxis-1' by default + */ + public async getLineChartData(dataLabel = 'Count', axis = 'ValueAxis-1') { + if (await this.isNewLibraryChart(xyChartSelector)) { + // For now lines are rendered as areas to enable stacking + const areas = (await this.getEsChartDebugState(xyChartSelector))?.areas ?? []; + const lines = areas.map(({ lines: { y1 }, name, color }) => ({ ...y1, name, color })); + const points = lines.find(({ name }) => name === dataLabel)?.points ?? []; + return points.map(({ y }) => y); + } + + // 1). get the range/pixel ratio + const yAxisRatio = await this.getChartYAxisRatio(axis); + // 2). find and save the y-axis pixel size (the chart height) + const rectangle = await this.find.byCssSelector('clipPath rect'); + const yAxisHeight = Number(await rectangle.getAttribute('height')); + // 3). get the visWrapper__chart elements + const chartTypes = await this.retry.try( + async () => + await this.find.allByCssSelector( + `.visWrapper__chart circle[data-label="${dataLabel}"][fill-opacity="1"]`, + this.defaultFindTimeout * 2 + ) + ); + // 4). for each chart element, find the green circle, then the cy position + const chartData = await Promise.all( + chartTypes.map(async (chart) => { + const cy = Number(await chart.getAttribute('cy')); + // the point_series_options test has data in the billions range and + // getting 11 digits of precision with these calculations is very hard + return Math.round(Number(((yAxisHeight - cy) * yAxisRatio).toPrecision(6))); + }) + ); + + return chartData; + } - const path = await retry.try( - async () => - await find.byCssSelector(`path[data-label="${dataLabel}"]`, defaultFindTimeout * 2) - ); - const data = await path.getAttribute('d'); - log.debug(data); - // This area chart data starts with a 'M'ove to a x,y location, followed - // by a bunch of 'L'ines from that point to the next. Those points are - // the values we're going to use to calculate the data values we're testing. - // So git rid of the one 'M' and split the rest on the 'L's. - return data.split('L'); - } + /** + * Returns bar chart data in pixels + * @param dataLabel data-label value + * @param axis axis value, 'ValueAxis-1' by default + */ + public async getBarChartData(dataLabel = 'Count', axis = 'ValueAxis-1') { + if (await this.isNewLibraryChart(xyChartSelector)) { + const bars = (await this.getEsChartDebugState(xyChartSelector))?.bars ?? []; + const values = bars.find(({ name }) => name === dataLabel)?.bars ?? []; + return values.map(({ y }) => y); + } + + const yAxisRatio = await this.getChartYAxisRatio(axis); + const svg = await this.find.byCssSelector('div.chart'); + const $ = await svg.parseDomContent(); + const chartData = $(`g > g.series > rect[data-label="${dataLabel}"]`) + .toArray() + .map((chart) => { + const barHeight = Number($(chart).attr('height')); + return Math.round(barHeight * yAxisRatio); + }); - /** - * Gets the dots and normalizes their height. - * @param dataLabel data-label value - * @param axis axis value, 'ValueAxis-1' by default - */ - public async getLineChartData(dataLabel = 'Count', axis = 'ValueAxis-1') { - if (await this.isNewLibraryChart(xyChartSelector)) { - // For now lines are rendered as areas to enable stacking - const areas = (await this.getEsChartDebugState(xyChartSelector))?.areas ?? []; - const lines = areas.map(({ lines: { y1 }, name, color }) => ({ ...y1, name, color })); - const points = lines.find(({ name }) => name === dataLabel)?.points ?? []; - return points.map(({ y }) => y); - } + return chartData; + } - // 1). get the range/pixel ratio - const yAxisRatio = await this.getChartYAxisRatio(axis); - // 2). find and save the y-axis pixel size (the chart height) - const rectangle = await find.byCssSelector('clipPath rect'); - const yAxisHeight = Number(await rectangle.getAttribute('height')); - // 3). get the visWrapper__chart elements - const chartTypes = await retry.try( - async () => - await find.allByCssSelector( - `.visWrapper__chart circle[data-label="${dataLabel}"][fill-opacity="1"]`, - defaultFindTimeout * 2 - ) - ); - // 4). for each chart element, find the green circle, then the cy position - const chartData = await Promise.all( - chartTypes.map(async (chart) => { - const cy = Number(await chart.getAttribute('cy')); - // the point_series_options test has data in the billions range and - // getting 11 digits of precision with these calculations is very hard - return Math.round(Number(((yAxisHeight - cy) * yAxisRatio).toPrecision(6))); - }) - ); + /** + * Returns the range/pixel ratio + * @param axis axis value, 'ValueAxis-1' by default + */ + private async getChartYAxisRatio(axis = 'ValueAxis-1') { + // 1). get the maximum chart Y-Axis marker value and Y position + const maxYAxisChartMarker = await this.retry.try( + async () => + await this.find.byCssSelector( + `div.visAxis__splitAxes--y > div > svg > g.${axis} > g:last-of-type.tick` + ) + ); + const maxYLabel = (await maxYAxisChartMarker.getVisibleText()).replace(/,/g, ''); + const maxYLabelYPosition = (await maxYAxisChartMarker.getPosition()).y; + this.log.debug(`maxYLabel = ${maxYLabel}, maxYLabelYPosition = ${maxYLabelYPosition}`); + + // 2). get the minimum chart Y-Axis marker value and Y position + const minYAxisChartMarker = await this.find.byCssSelector( + 'div.visAxis__column--y.visAxis__column--left > div > div > svg:nth-child(2) > g > g:nth-child(1).tick' + ); + const minYLabel = (await minYAxisChartMarker.getVisibleText()).replace(',', ''); + const minYLabelYPosition = (await minYAxisChartMarker.getPosition()).y; + return (Number(maxYLabel) - Number(minYLabel)) / (minYLabelYPosition - maxYLabelYPosition); + } - return chartData; - } + public async toggleLegend(show = true) { + const isVisTypeXYChart = await this.isNewLibraryChart(xyChartSelector); + const isVisTypePieChart = await this.isNewLibraryChart(pieChartSelector); + const legendSelector = isVisTypeXYChart || isVisTypePieChart ? '.echLegend' : '.visLegend'; - /** - * Returns bar chart data in pixels - * @param dataLabel data-label value - * @param axis axis value, 'ValueAxis-1' by default - */ - public async getBarChartData(dataLabel = 'Count', axis = 'ValueAxis-1') { - if (await this.isNewLibraryChart(xyChartSelector)) { - const bars = (await this.getEsChartDebugState(xyChartSelector))?.bars ?? []; - const values = bars.find(({ name }) => name === dataLabel)?.bars ?? []; - return values.map(({ y }) => y); + await this.retry.try(async () => { + const isVisible = await this.find.existsByCssSelector(legendSelector); + if ((show && !isVisible) || (!show && isVisible)) { + await this.testSubjects.click('vislibToggleLegend'); } + }); + } - const yAxisRatio = await this.getChartYAxisRatio(axis); - const svg = await find.byCssSelector('div.chart'); - const $ = await svg.parseDomContent(); - const chartData = $(`g > g.series > rect[data-label="${dataLabel}"]`) - .toArray() - .map((chart) => { - const barHeight = Number($(chart).attr('height')); - return Math.round(barHeight * yAxisRatio); - }); - - return chartData; - } - - /** - * Returns the range/pixel ratio - * @param axis axis value, 'ValueAxis-1' by default - */ - private async getChartYAxisRatio(axis = 'ValueAxis-1') { - // 1). get the maximum chart Y-Axis marker value and Y position - const maxYAxisChartMarker = await retry.try( - async () => - await find.byCssSelector( - `div.visAxis__splitAxes--y > div > svg > g.${axis} > g:last-of-type.tick` - ) - ); - const maxYLabel = (await maxYAxisChartMarker.getVisibleText()).replace(/,/g, ''); - const maxYLabelYPosition = (await maxYAxisChartMarker.getPosition()).y; - log.debug(`maxYLabel = ${maxYLabel}, maxYLabelYPosition = ${maxYLabelYPosition}`); + public async filterLegend(name: string) { + await this.toggleLegend(); + await this.testSubjects.click(`legend-${name}`); + const filterIn = await this.testSubjects.find(`legend-${name}-filterIn`); + await filterIn.click(); + await this.waitForVisualizationRenderingStabilized(); + } - // 2). get the minimum chart Y-Axis marker value and Y position - const minYAxisChartMarker = await find.byCssSelector( - 'div.visAxis__column--y.visAxis__column--left > div > div > svg:nth-child(2) > g > g:nth-child(1).tick' - ); - const minYLabel = (await minYAxisChartMarker.getVisibleText()).replace(',', ''); - const minYLabelYPosition = (await minYAxisChartMarker.getPosition()).y; - return (Number(maxYLabel) - Number(minYLabel)) / (minYLabelYPosition - maxYLabelYPosition); - } + public async doesLegendColorChoiceExist(color: string) { + return await this.testSubjects.exists(`visColorPickerColor-${color}`); + } - public async toggleLegend(show = true) { - const isVisTypeXYChart = await this.isNewLibraryChart(xyChartSelector); - const isVisTypePieChart = await this.isNewLibraryChart(pieChartSelector); - const legendSelector = isVisTypeXYChart || isVisTypePieChart ? '.echLegend' : '.visLegend'; + public async selectNewLegendColorChoice(color: string) { + await this.testSubjects.click(`visColorPickerColor-${color}`); + } - await retry.try(async () => { - const isVisible = await find.existsByCssSelector(legendSelector); - if ((show && !isVisible) || (!show && isVisible)) { - await testSubjects.click('vislibToggleLegend'); - } - }); + public async doesSelectedLegendColorExist(color: string) { + if (await this.isNewLibraryChart(xyChartSelector)) { + const items = (await this.getEsChartDebugState(xyChartSelector))?.legend?.items ?? []; + return items.some(({ color: c }) => c === color); } - public async filterLegend(name: string) { - await this.toggleLegend(); - await testSubjects.click(`legend-${name}`); - const filterIn = await testSubjects.find(`legend-${name}-filterIn`); - await filterIn.click(); - await this.waitForVisualizationRenderingStabilized(); + if (await this.isNewLibraryChart(pieChartSelector)) { + const slices = + (await this.getEsChartDebugState(pieChartSelector))?.partition?.[0]?.partitions ?? []; + return slices.some(({ color: c }) => { + const rgbColor = new Color(color).rgb().toString(); + return c === rgbColor; + }); } - public async doesLegendColorChoiceExist(color: string) { - return await testSubjects.exists(`visColorPickerColor-${color}`); - } + return await this.testSubjects.exists(`legendSelectedColor-${color}`); + } - public async selectNewLegendColorChoice(color: string) { - await testSubjects.click(`visColorPickerColor-${color}`); + public async expectError() { + if (!this.isNewLibraryChart(xyChartSelector)) { + await this.testSubjects.existOrFail('vislibVisualizeError'); } + } - public async doesSelectedLegendColorExist(color: string) { - if (await this.isNewLibraryChart(xyChartSelector)) { - const items = (await this.getEsChartDebugState(xyChartSelector))?.legend?.items ?? []; - return items.some(({ color: c }) => c === color); - } - - if (await this.isNewLibraryChart(pieChartSelector)) { - const slices = - (await this.getEsChartDebugState(pieChartSelector))?.partition?.[0]?.partitions ?? []; - return slices.some(({ color: c }) => { - const rgbColor = new Color(color).rgb().toString(); - return c === rgbColor; - }); - } - - return await testSubjects.exists(`legendSelectedColor-${color}`); - } + public async getVisualizationRenderingCount() { + const visualizationLoader = await this.testSubjects.find('visualizationLoader'); + const renderingCount = await visualizationLoader.getAttribute('data-rendering-count'); + return Number(renderingCount); + } - public async expectError() { - if (!this.isNewLibraryChart(xyChartSelector)) { - await testSubjects.existOrFail('vislibVisualizeError'); + public async waitForRenderingCount(minimumCount = 1) { + await this.retry.waitFor( + `rendering count to be greater than or equal to [${minimumCount}]`, + async () => { + const currentRenderingCount = await this.getVisualizationRenderingCount(); + this.log.debug(`-- currentRenderingCount=${currentRenderingCount}`); + this.log.debug(`-- expectedCount=${minimumCount}`); + return currentRenderingCount >= minimumCount; } - } + ); + } - public async getVisualizationRenderingCount() { - const visualizationLoader = await testSubjects.find('visualizationLoader'); - const renderingCount = await visualizationLoader.getAttribute('data-rendering-count'); - return Number(renderingCount); - } + public async waitForVisualizationRenderingStabilized() { + // assuming rendering is done when data-rendering-count is constant within 1000 ms + await this.retry.waitFor('rendering count to stabilize', async () => { + const firstCount = await this.getVisualizationRenderingCount(); + this.log.debug(`-- firstCount=${firstCount}`); - public async waitForRenderingCount(minimumCount = 1) { - await retry.waitFor( - `rendering count to be greater than or equal to [${minimumCount}]`, - async () => { - const currentRenderingCount = await this.getVisualizationRenderingCount(); - log.debug(`-- currentRenderingCount=${currentRenderingCount}`); - log.debug(`-- expectedCount=${minimumCount}`); - return currentRenderingCount >= minimumCount; - } - ); - } + await this.common.sleep(2000); - public async waitForVisualizationRenderingStabilized() { - // assuming rendering is done when data-rendering-count is constant within 1000 ms - await retry.waitFor('rendering count to stabilize', async () => { - const firstCount = await this.getVisualizationRenderingCount(); - log.debug(`-- firstCount=${firstCount}`); + const secondCount = await this.getVisualizationRenderingCount(); + this.log.debug(`-- secondCount=${secondCount}`); - await common.sleep(2000); + return firstCount === secondCount; + }); + } - const secondCount = await this.getVisualizationRenderingCount(); - log.debug(`-- secondCount=${secondCount}`); + public async waitForVisualization() { + await this.waitForVisualizationRenderingStabilized(); - return firstCount === secondCount; - }); + if (!(await this.isNewLibraryChart(xyChartSelector))) { + await this.find.byCssSelector('.visualization'); } + } - public async waitForVisualization() { - await this.waitForVisualizationRenderingStabilized(); - - if (!(await this.isNewLibraryChart(xyChartSelector))) { - await find.byCssSelector('.visualization'); - } + public async getLegendEntries() { + const isVisTypeXYChart = await this.isNewLibraryChart(xyChartSelector); + const isVisTypePieChart = await this.isNewLibraryChart(pieChartSelector); + if (isVisTypeXYChart) { + const items = (await this.getEsChartDebugState(xyChartSelector))?.legend?.items ?? []; + return items.map(({ name }) => name); } - public async getLegendEntries() { - const isVisTypeXYChart = await this.isNewLibraryChart(xyChartSelector); - const isVisTypePieChart = await this.isNewLibraryChart(pieChartSelector); - if (isVisTypeXYChart) { - const items = (await this.getEsChartDebugState(xyChartSelector))?.legend?.items ?? []; - return items.map(({ name }) => name); - } - - if (isVisTypePieChart) { - const slices = - (await this.getEsChartDebugState(pieChartSelector))?.partition?.[0]?.partitions ?? []; - return slices.map(({ name }) => name); - } - - const legendEntries = await find.allByCssSelector( - '.visLegend__button', - defaultFindTimeout * 2 - ); - return await Promise.all( - legendEntries.map(async (chart) => await chart.getAttribute('data-label')) - ); + if (isVisTypePieChart) { + const slices = + (await this.getEsChartDebugState(pieChartSelector))?.partition?.[0]?.partitions ?? []; + return slices.map(({ name }) => name); } - public async openLegendOptionColors(name: string, chartSelector: string) { - await this.waitForVisualizationRenderingStabilized(); - await retry.try(async () => { - if ( - (await this.isNewLibraryChart(xyChartSelector)) || - (await this.isNewLibraryChart(pieChartSelector)) - ) { - const chart = await find.byCssSelector(chartSelector); - const legendItemColor = await chart.findByCssSelector( - `[data-ech-series-name="${name}"] .echLegendItem__color` - ); - legendItemColor.click(); - } else { - // This click has been flaky in opening the legend, hence the retry. See - // https://github.com/elastic/kibana/issues/17468 - await testSubjects.click(`legend-${name}`); - } - - await this.waitForVisualizationRenderingStabilized(); - // arbitrary color chosen, any available would do - const arbitraryColor = (await this.isNewLibraryChart(xyChartSelector)) - ? '#d36086' - : '#EF843C'; - const isOpen = await this.doesLegendColorChoiceExist(arbitraryColor); - if (!isOpen) { - throw new Error('legend color selector not open'); - } - }); - } + const legendEntries = await this.find.allByCssSelector( + '.visLegend__button', + this.defaultFindTimeout * 2 + ); + return await Promise.all( + legendEntries.map(async (chart) => await chart.getAttribute('data-label')) + ); + } - public async filterOnTableCell(columnIndex: number, rowIndex: number) { - await retry.try(async () => { - const cell = await dataGrid.getCellElement(rowIndex, columnIndex); - await cell.click(); - const filterBtn = await testSubjects.findDescendant( - 'tbvChartCell__filterForCellValue', - cell + public async openLegendOptionColors(name: string, chartSelector: string) { + await this.waitForVisualizationRenderingStabilized(); + await this.retry.try(async () => { + if ( + (await this.isNewLibraryChart(xyChartSelector)) || + (await this.isNewLibraryChart(pieChartSelector)) + ) { + const chart = await this.find.byCssSelector(chartSelector); + const legendItemColor = await chart.findByCssSelector( + `[data-ech-series-name="${name}"] .echLegendItem__color` ); - await common.sleep(2000); - filterBtn.click(); - }); - } + legendItemColor.click(); + } else { + // This click has been flaky in opening the legend, hence the this.retry. See + // https://github.com/elastic/kibana/issues/17468 + await this.testSubjects.click(`legend-${name}`); + } - public async getMarkdownText() { - const markdownContainer = await testSubjects.find('markdownBody'); - return markdownContainer.getVisibleText(); - } + await this.waitForVisualizationRenderingStabilized(); + // arbitrary color chosen, any available would do + const arbitraryColor = (await this.isNewLibraryChart(xyChartSelector)) + ? '#d36086' + : '#EF843C'; + const isOpen = await this.doesLegendColorChoiceExist(arbitraryColor); + if (!isOpen) { + throw new Error('legend color selector not open'); + } + }); + } - public async getMarkdownBodyDescendentText(selector: string) { - const markdownContainer = await testSubjects.find('markdownBody'); - const element = await find.descendantDisplayedByCssSelector(selector, markdownContainer); - return element.getVisibleText(); - } + public async filterOnTableCell(columnIndex: number, rowIndex: number) { + await this.retry.try(async () => { + const cell = await this.dataGrid.getCellElement(rowIndex, columnIndex); + await cell.click(); + const filterBtn = await this.testSubjects.findDescendant( + 'tbvChartCell__filterForCellValue', + cell + ); + await this.common.sleep(2000); + filterBtn.click(); + }); + } - // Table visualization + public async getMarkdownText() { + const markdownContainer = await this.testSubjects.find('markdownBody'); + return markdownContainer.getVisibleText(); + } - public async getTableVisNoResult() { - return await testSubjects.find('tbvChartContainer>visNoResult'); - } + public async getMarkdownBodyDescendentText(selector: string) { + const markdownContainer = await this.testSubjects.find('markdownBody'); + const element = await this.find.descendantDisplayedByCssSelector(selector, markdownContainer); + return element.getVisibleText(); + } - /** - * This function returns the text displayed in the Table Vis header - */ - public async getTableVisHeader() { - return await testSubjects.getVisibleText('dataGridHeader'); - } + // Table visualization - public async getFieldLinkInVisTable(fieldName: string, rowIndex: number = 1) { - const headers = await dataGrid.getHeaders(); - const fieldColumnIndex = headers.indexOf(fieldName); - const cell = await dataGrid.getCellElement(rowIndex, fieldColumnIndex + 1); - return await cell.findByTagName('a'); - } + public async getTableVisNoResult() { + return await this.testSubjects.find('tbvChartContainer>visNoResult'); + } - /** - * Function to retrieve data from within a table visualization. - */ - public async getTableVisContent({ stripEmptyRows = true } = {}) { - return await retry.try(async () => { - const container = await testSubjects.find('tbvChart'); - const allTables = await testSubjects.findAllDescendant('dataGridWrapper', container); - - if (allTables.length === 0) { - return []; - } - - const allData = await Promise.all( - allTables.map(async (t) => { - let data = await dataGrid.getDataFromElement(t, 'tbvChartCellContent'); - if (stripEmptyRows) { - data = data.filter( - (row) => row.length > 0 && row.some((cell) => cell.trim().length > 0) - ); - } - return data; - }) - ); + /** + * This function returns the text displayed in the Table Vis header + */ + public async getTableVisHeader() { + return await this.testSubjects.getVisibleText('dataGridHeader'); + } - if (allTables.length === 1) { - // If there was only one table we return only the data for that table - // This prevents an unnecessary array around that single table, which - // is the case we have in most tests. - return allData[0]; - } + public async getFieldLinkInVisTable(fieldName: string, rowIndex: number = 1) { + const headers = await this.dataGrid.getHeaders(); + const fieldColumnIndex = headers.indexOf(fieldName); + const cell = await this.dataGrid.getCellElement(rowIndex, fieldColumnIndex + 1); + return await cell.findByTagName('a'); + } - return allData; - }); - } + /** + * Function to retrieve data from within a table visualization. + */ + public async getTableVisContent({ stripEmptyRows = true } = {}) { + return await this.retry.try(async () => { + const container = await this.testSubjects.find('tbvChart'); + const allTables = await this.testSubjects.findAllDescendant('dataGridWrapper', container); - public async getMetric() { - const elements = await find.allByCssSelector( - '[data-test-subj="visualizationLoader"] .mtrVis__container' - ); - const values = await Promise.all( - elements.map(async (element) => { - const text = await element.getVisibleText(); - return text; - }) - ); - return values - .filter((item) => item.length > 0) - .reduce((arr: string[], item) => arr.concat(item.split('\n')), []); - } + if (allTables.length === 0) { + return []; + } - public async getGaugeValue() { - const elements = await find.allByCssSelector( - '[data-test-subj="visualizationLoader"] .chart svg text' - ); - const values = await Promise.all( - elements.map(async (element) => { - const text = await element.getVisibleText(); - return text; + const allData = await Promise.all( + allTables.map(async (t) => { + let data = await this.dataGrid.getDataFromElement(t, 'tbvChartCellContent'); + if (stripEmptyRows) { + data = data.filter( + (row) => row.length > 0 && row.some((cell) => cell.trim().length > 0) + ); + } + return data; }) ); - return values.filter((item) => item.length > 0); - } - public async getRightValueAxesCount() { - if (await this.isNewLibraryChart(xyChartSelector)) { - const yAxes = (await this.getEsChartDebugState(xyChartSelector))?.axes?.y ?? []; - return yAxes.filter(({ position }) => position === Position.Right).length; + if (allTables.length === 1) { + // If there was only one table we return only the data for that table + // This prevents an unnecessary array around that single table, which + // is the case we have in most tests. + return allData[0]; } - const axes = await find.allByCssSelector('.visAxis__column--right g.axis'); - return axes.length; - } - public async clickOnGaugeByLabel(label: string) { - const gauge = await testSubjects.find(`visGauge__meter--${label}`); - const gaugeSize = await gauge.getSize(); - const gaugeHeight = gaugeSize.height; - // To click at Gauge arc instead of the center of SVG element - // the offset for a click is calculated as half arc height without 1 pixel - const yOffset = 1 - Math.floor(gaugeHeight / 2); + return allData; + }); + } - await gauge.clickMouseButton({ xOffset: 0, yOffset }); - } + public async getMetric() { + const elements = await this.find.allByCssSelector( + '[data-test-subj="visualizationLoader"] .mtrVis__container' + ); + const values = await Promise.all( + elements.map(async (element) => { + const text = await element.getVisibleText(); + return text; + }) + ); + return values + .filter((item) => item.length > 0) + .reduce((arr: string[], item) => arr.concat(item.split('\n')), []); + } - public async getHistogramSeriesCount() { - if (await this.isNewLibraryChart(xyChartSelector)) { - const bars = (await this.getEsChartDebugState(xyChartSelector))?.bars ?? []; - return bars.filter(({ visible }) => visible).length; - } + public async getGaugeValue() { + const elements = await this.find.allByCssSelector( + '[data-test-subj="visualizationLoader"] .chart svg text' + ); + const values = await Promise.all( + elements.map(async (element) => { + const text = await element.getVisibleText(); + return text; + }) + ); + return values.filter((item) => item.length > 0); + } - const series = await find.allByCssSelector('.series.histogram'); - return series.length; + public async getRightValueAxesCount() { + if (await this.isNewLibraryChart(xyChartSelector)) { + const yAxes = (await this.getEsChartDebugState(xyChartSelector))?.axes?.y ?? []; + return yAxes.filter(({ position }) => position === Position.Right).length; } + const axes = await this.find.allByCssSelector('.visAxis__column--right g.axis'); + return axes.length; + } - public async getGridLines(): Promise> { - if (await this.isNewLibraryChart(xyChartSelector)) { - const { x, y } = (await this.getEsChartDebugState(xyChartSelector))?.axes ?? { - x: [], - y: [], - }; - return [...x, ...y].flatMap(({ gridlines }) => gridlines); - } + public async clickOnGaugeByLabel(label: string) { + const gauge = await this.testSubjects.find(`visGauge__meter--${label}`); + const gaugeSize = await gauge.getSize(); + const gaugeHeight = gaugeSize.height; + // To click at Gauge arc instead of the center of SVG element + // the offset for a click is calculated as half arc height without 1 pixel + const yOffset = 1 - Math.floor(gaugeHeight / 2); - const grid = await find.byCssSelector('g.grid'); - const $ = await grid.parseDomContent(); - return $('path') - .toArray() - .map((line) => { - const dAttribute = $(line).attr('d'); - const firstPoint = dAttribute.split('L')[0].replace('M', '').split(','); - return { - x: parseFloat(firstPoint[0]), - y: parseFloat(firstPoint[1]), - }; - }); + await gauge.clickMouseButton({ xOffset: 0, yOffset }); + } + + public async getHistogramSeriesCount() { + if (await this.isNewLibraryChart(xyChartSelector)) { + const bars = (await this.getEsChartDebugState(xyChartSelector))?.bars ?? []; + return bars.filter(({ visible }) => visible).length; } - public async getChartValues() { - if (await this.isNewLibraryChart(xyChartSelector)) { - const barSeries = (await this.getEsChartDebugState(xyChartSelector))?.bars ?? []; - return barSeries.filter(({ visible }) => visible).flatMap((bars) => bars.labels); - } + const series = await this.find.allByCssSelector('.series.histogram'); + return series.length; + } - const elements = await find.allByCssSelector('.series.histogram text'); - const values = await Promise.all( - elements.map(async (element) => { - const text = await element.getVisibleText(); - return text; - }) - ); - return values; - } + public async getGridLines(): Promise> { + if (await this.isNewLibraryChart(xyChartSelector)) { + const { x, y } = (await this.getEsChartDebugState(xyChartSelector))?.axes ?? { + x: [], + y: [], + }; + return [...x, ...y].flatMap(({ gridlines }) => gridlines); + } + + const grid = await this.find.byCssSelector('g.grid'); + const $ = await grid.parseDomContent(); + return $('path') + .toArray() + .map((line) => { + const dAttribute = $(line).attr('d'); + const firstPoint = dAttribute.split('L')[0].replace('M', '').split(','); + return { + x: parseFloat(firstPoint[0]), + y: parseFloat(firstPoint[1]), + }; + }); } - return new VisualizeChart(); + public async getChartValues() { + if (await this.isNewLibraryChart(xyChartSelector)) { + const barSeries = (await this.getEsChartDebugState(xyChartSelector))?.bars ?? []; + return barSeries.filter(({ visible }) => visible).flatMap((bars) => bars.labels); + } + + const elements = await this.find.allByCssSelector('.series.histogram text'); + const values = await Promise.all( + elements.map(async (element) => { + const text = await element.getVisibleText(); + return text; + }) + ); + return values; + } } diff --git a/test/functional/page_objects/visualize_editor_page.ts b/test/functional/page_objects/visualize_editor_page.ts index d311f752fd490..ab458c2c0fdc1 100644 --- a/test/functional/page_objects/visualize_editor_page.ts +++ b/test/functional/page_objects/visualize_editor_page.ts @@ -7,535 +7,529 @@ */ import expect from '@kbn/expect'; -import { FtrProviderContext } from '../ftr_provider_context'; - -export function VisualizeEditorPageProvider({ getService, getPageObjects }: FtrProviderContext) { - const find = getService('find'); - const log = getService('log'); - const retry = getService('retry'); - const browser = getService('browser'); - const testSubjects = getService('testSubjects'); - const comboBox = getService('comboBox'); - const elasticChart = getService('elasticChart'); - const { common, header, visChart } = getPageObjects(['common', 'header', 'visChart']); - - interface IntervalOptions { - type?: 'default' | 'numeric' | 'custom'; - aggNth?: number; - append?: boolean; - } - - class VisualizeEditorPage { - public async clickDataTab() { - await testSubjects.click('visEditorTab__data'); - } +import { FtrService } from '../ftr_provider_context'; - public async clickOptionsTab() { - await testSubjects.click('visEditorTab__options'); - } +interface IntervalOptions { + type?: 'default' | 'numeric' | 'custom'; + aggNth?: number; + append?: boolean; +} - public async clickMetricsAndAxes() { - await testSubjects.click('visEditorTab__advanced'); - } +export class VisualizeEditorPageObject extends FtrService { + private readonly find = this.ctx.getService('find'); + private readonly log = this.ctx.getService('log'); + private readonly retry = this.ctx.getService('retry'); + private readonly browser = this.ctx.getService('browser'); + private readonly testSubjects = this.ctx.getService('testSubjects'); + private readonly comboBox = this.ctx.getService('comboBox'); + private readonly elasticChart = this.ctx.getService('elasticChart'); + private readonly common = this.ctx.getPageObject('common'); + private readonly header = this.ctx.getPageObject('header'); + private readonly visChart = this.ctx.getPageObject('visChart'); + + public async clickDataTab() { + await this.testSubjects.click('visEditorTab__data'); + } - public async clickVisEditorTab(tabName: string) { - await testSubjects.click(`visEditorTab__${tabName}`); - await header.waitUntilLoadingHasFinished(); - } + public async clickOptionsTab() { + await this.testSubjects.click('visEditorTab__options'); + } - public async addInputControl(type?: string) { - if (type) { - const selectInput = await testSubjects.find('selectControlType'); - await selectInput.type(type); - } - await testSubjects.click('inputControlEditorAddBtn'); - await header.waitUntilLoadingHasFinished(); - } + public async clickMetricsAndAxes() { + await this.testSubjects.click('visEditorTab__advanced'); + } - public async inputControlClear() { - await testSubjects.click('inputControlClearBtn'); - await header.waitUntilLoadingHasFinished(); - } + public async clickVisEditorTab(tabName: string) { + await this.testSubjects.click(`visEditorTab__${tabName}`); + await this.header.waitUntilLoadingHasFinished(); + } - public async inputControlSubmit() { - await testSubjects.clickWhenNotDisabled('inputControlSubmitBtn'); - await visChart.waitForVisualizationRenderingStabilized(); + public async addInputControl(type?: string) { + if (type) { + const selectInput = await this.testSubjects.find('selectControlType'); + await selectInput.type(type); } + await this.testSubjects.click('inputControlEditorAddBtn'); + await this.header.waitUntilLoadingHasFinished(); + } - public async clickGo() { - if (await visChart.isNewChartsLibraryEnabled()) { - await elasticChart.setNewChartUiDebugFlag(); - } + public async inputControlClear() { + await this.testSubjects.click('inputControlClearBtn'); + await this.header.waitUntilLoadingHasFinished(); + } - const prevRenderingCount = await visChart.getVisualizationRenderingCount(); - log.debug(`Before Rendering count ${prevRenderingCount}`); - await testSubjects.clickWhenNotDisabled('visualizeEditorRenderButton'); - await visChart.waitForRenderingCount(prevRenderingCount + 1); - } + public async inputControlSubmit() { + await this.testSubjects.clickWhenNotDisabled('inputControlSubmitBtn'); + await this.visChart.waitForVisualizationRenderingStabilized(); + } - public async removeDimension(aggNth: number) { - await testSubjects.click(`visEditorAggAccordion${aggNth} > removeDimensionBtn`); + public async clickGo() { + if (await this.visChart.isNewChartsLibraryEnabled()) { + await this.elasticChart.setNewChartUiDebugFlag(); } - public async setFilterParams(aggNth: number, indexPattern: string, field: string) { - await comboBox.set(`indexPatternSelect-${aggNth}`, indexPattern); - await comboBox.set(`fieldSelect-${aggNth}`, field); - } + const prevRenderingCount = await this.visChart.getVisualizationRenderingCount(); + this.log.debug(`Before Rendering count ${prevRenderingCount}`); + await this.testSubjects.clickWhenNotDisabled('visualizeEditorRenderButton'); + await this.visChart.waitForRenderingCount(prevRenderingCount + 1); + } - public async setFilterRange(aggNth: number, min: string, max: string) { - const control = await testSubjects.find(`inputControl${aggNth}`); - const inputMin = await control.findByCssSelector('[name$="minValue"]'); - await inputMin.type(min); - const inputMax = await control.findByCssSelector('[name$="maxValue"]'); - await inputMax.type(max); - } + public async removeDimension(aggNth: number) { + await this.testSubjects.click(`visEditorAggAccordion${aggNth} > removeDimensionBtn`); + } - public async clickSplitDirection(direction: string) { - const radioBtn = await find.byCssSelector(`[data-test-subj="visEditorSplitBy-${direction}"]`); - await radioBtn.click(); - } + public async setFilterParams(aggNth: number, indexPattern: string, field: string) { + await this.comboBox.set(`indexPatternSelect-${aggNth}`, indexPattern); + await this.comboBox.set(`fieldSelect-${aggNth}`, field); + } - public async clickAddDateRange() { - await testSubjects.click(`visEditorAddDateRange`); - } + public async setFilterRange(aggNth: number, min: string, max: string) { + const control = await this.testSubjects.find(`inputControl${aggNth}`); + const inputMin = await control.findByCssSelector('[name$="minValue"]'); + await inputMin.type(min); + const inputMax = await control.findByCssSelector('[name$="maxValue"]'); + await inputMax.type(max); + } - public async setDateRangeByIndex(index: string, from: string, to: string) { - await testSubjects.setValue(`visEditorDateRange${index}__from`, from); - await testSubjects.setValue(`visEditorDateRange${index}__to`, to); - } + public async clickSplitDirection(direction: string) { + const radioBtn = await this.find.byCssSelector( + `[data-test-subj="visEditorSplitBy-${direction}"]` + ); + await radioBtn.click(); + } - /** - * Adds new bucket - * @param bucketName bucket name, like 'X-axis', 'Split rows', 'Split series' - * @param type aggregation type, like 'buckets', 'metrics' - */ - public async clickBucket(bucketName: string, type = 'buckets') { - await testSubjects.click(`visEditorAdd_${type}`); - await testSubjects.click(`visEditorAdd_${type}_${bucketName}`); - } + public async clickAddDateRange() { + await this.testSubjects.click(`visEditorAddDateRange`); + } - public async clickEnableCustomRanges() { - await testSubjects.click('heatmapUseCustomRanges'); - } + public async setDateRangeByIndex(index: string, from: string, to: string) { + await this.testSubjects.setValue(`visEditorDateRange${index}__from`, from); + await this.testSubjects.setValue(`visEditorDateRange${index}__to`, to); + } - public async clickAddRange() { - await testSubjects.click(`heatmapColorRange__addRangeButton`); - } + /** + * Adds new bucket + * @param bucketName bucket name, like 'X-axis', 'Split rows', 'Split series' + * @param type aggregation type, like 'buckets', 'metrics' + */ + public async clickBucket(bucketName: string, type = 'buckets') { + await this.testSubjects.click(`visEditorAdd_${type}`); + await this.testSubjects.click(`visEditorAdd_${type}_${bucketName}`); + } - public async setCustomRangeByIndex(index: string | number, from: string, to: string) { - await testSubjects.setValue(`heatmapColorRange${index}__from`, from); - await testSubjects.setValue(`heatmapColorRange${index}__to`, to); - } + public async clickEnableCustomRanges() { + await this.testSubjects.click('heatmapUseCustomRanges'); + } - public async changeHeatmapColorNumbers(value = 6) { - await testSubjects.setValue('heatmapColorsNumber', `${value}`); - } + public async clickAddRange() { + await this.testSubjects.click(`heatmapColorRange__addRangeButton`); + } - public async getBucketErrorMessage() { - const error = await find.byCssSelector( - '[data-test-subj="bucketsAggGroup"] [data-test-subj="defaultEditorAggSelect"] + .euiFormErrorText' - ); - const errorMessage = await error.getAttribute('innerText'); - log.debug(errorMessage); - return errorMessage; - } + public async setCustomRangeByIndex(index: string | number, from: string, to: string) { + await this.testSubjects.setValue(`heatmapColorRange${index}__from`, from); + await this.testSubjects.setValue(`heatmapColorRange${index}__to`, to); + } - public async addNewFilterAggregation() { - await testSubjects.click('visEditorAddFilterButton'); - } + public async changeHeatmapColorNumbers(value = 6) { + await this.testSubjects.setValue('heatmapColorsNumber', `${value}`); + } - public async selectField( - fieldValue: string, - groupName = 'buckets', - isChildAggregation = false - ) { - log.debug(`selectField ${fieldValue}`); - const selector = ` - [data-test-subj="${groupName}AggGroup"] - [data-test-subj^="visEditorAggAccordion"].euiAccordion-isOpen - [data-test-subj="visAggEditorParams"] - ${isChildAggregation ? '.visEditorAgg__subAgg' : ''} - [data-test-subj="visDefaultEditorField"] - `; - const fieldEl = await find.byCssSelector(selector); - await comboBox.setElement(fieldEl, fieldValue); - } + public async getBucketErrorMessage() { + const error = await this.find.byCssSelector( + '[data-test-subj="bucketsAggGroup"] [data-test-subj="defaultEditorAggSelect"] + .euiFormErrorText' + ); + const errorMessage = await error.getAttribute('innerText'); + this.log.debug(errorMessage); + return errorMessage; + } - public async selectOrderByMetric(aggNth: number, metric: string) { - const sortSelect = await testSubjects.find(`visEditorOrderBy${aggNth}`); - const sortMetric = await sortSelect.findByCssSelector(`option[value="${metric}"]`); - await sortMetric.click(); - } + public async addNewFilterAggregation() { + await this.testSubjects.click('visEditorAddFilterButton'); + } - public async selectCustomSortMetric(aggNth: number, metric: string, field: string) { - await this.selectOrderByMetric(aggNth, 'custom'); - await this.selectAggregation(metric, 'buckets', true); - await this.selectField(field, 'buckets', true); - } + public async selectField(fieldValue: string, groupName = 'buckets', isChildAggregation = false) { + this.log.debug(`selectField ${fieldValue}`); + const selector = ` + [data-test-subj="${groupName}AggGroup"] + [data-test-subj^="visEditorAggAccordion"].euiAccordion-isOpen + [data-test-subj="visAggEditorParams"] + ${isChildAggregation ? '.visEditorAgg__subAgg' : ''} + [data-test-subj="visDefaultEditorField"] + `; + const fieldEl = await this.find.byCssSelector(selector); + await this.comboBox.setElement(fieldEl, fieldValue); + } - public async selectAggregation( - aggValue: string, - groupName = 'buckets', - isChildAggregation = false - ) { - const comboBoxElement = await find.byCssSelector(` - [data-test-subj="${groupName}AggGroup"] - [data-test-subj^="visEditorAggAccordion"].euiAccordion-isOpen - ${isChildAggregation ? '.visEditorAgg__subAgg' : ''} - [data-test-subj="defaultEditorAggSelect"] - `); - - await comboBox.setElement(comboBoxElement, aggValue); - await common.sleep(500); - } + public async selectOrderByMetric(aggNth: number, metric: string) { + const sortSelect = await this.testSubjects.find(`visEditorOrderBy${aggNth}`); + const sortMetric = await sortSelect.findByCssSelector(`option[value="${metric}"]`); + await sortMetric.click(); + } - /** - * Set the test for a filter aggregation. - * @param {*} filterValue the string value of the filter - * @param {*} filterIndex used when multiple filters are configured on the same aggregation - * @param {*} aggregationId the ID if the aggregation. On Tests, it start at from 2 - */ - public async setFilterAggregationValue( - filterValue: string, - filterIndex = 0, - aggregationId = 2 - ) { - await testSubjects.setValue( - `visEditorFilterInput_${aggregationId}_${filterIndex}`, - filterValue - ); - } + public async selectCustomSortMetric(aggNth: number, metric: string, field: string) { + await this.selectOrderByMetric(aggNth, 'custom'); + await this.selectAggregation(metric, 'buckets', true); + await this.selectField(field, 'buckets', true); + } - public async setValue(newValue: string) { - const input = await find.byCssSelector('[data-test-subj="visEditorPercentileRanks"] input'); - await input.clearValue(); - await input.type(newValue); - } + public async selectAggregation( + aggValue: string, + groupName = 'buckets', + isChildAggregation = false + ) { + const comboBoxElement = await this.find.byCssSelector(` + [data-test-subj="${groupName}AggGroup"] + [data-test-subj^="visEditorAggAccordion"].euiAccordion-isOpen + ${isChildAggregation ? '.visEditorAgg__subAgg' : ''} + [data-test-subj="defaultEditorAggSelect"] + `); + + await this.comboBox.setElement(comboBoxElement, aggValue); + await this.common.sleep(500); + } - public async clickEditorSidebarCollapse() { - await testSubjects.click('collapseSideBarButton'); - } + /** + * Set the test for a filter aggregation. + * @param {*} filterValue the string value of the filter + * @param {*} filterIndex used when multiple filters are configured on the same aggregation + * @param {*} aggregationId the ID if the aggregation. On Tests, it start at from 2 + */ + public async setFilterAggregationValue(filterValue: string, filterIndex = 0, aggregationId = 2) { + await this.testSubjects.setValue( + `visEditorFilterInput_${aggregationId}_${filterIndex}`, + filterValue + ); + } - public async clickDropPartialBuckets() { - await testSubjects.click('dropPartialBucketsCheckbox'); - } + public async setValue(newValue: string) { + const input = await this.find.byCssSelector( + '[data-test-subj="visEditorPercentileRanks"] input' + ); + await input.clearValue(); + await input.type(newValue); + } - public async expectMarkdownTextArea() { - await testSubjects.existOrFail('markdownTextarea'); - } + public async clickEditorSidebarCollapse() { + await this.testSubjects.click('collapseSideBarButton'); + } - public async setMarkdownTxt(markdownTxt: string) { - const input = await testSubjects.find('markdownTextarea'); - await input.clearValue(); - await input.type(markdownTxt); - } + public async clickDropPartialBuckets() { + await this.testSubjects.click('dropPartialBucketsCheckbox'); + } - public async isSwitchChecked(selector: string) { - const checkbox = await testSubjects.find(selector); - const isChecked = await checkbox.getAttribute('aria-checked'); - return isChecked === 'true'; - } + public async expectMarkdownTextArea() { + await this.testSubjects.existOrFail('markdownTextarea'); + } - public async checkSwitch(selector: string) { - const isChecked = await this.isSwitchChecked(selector); - if (!isChecked) { - log.debug(`checking switch ${selector}`); - await testSubjects.click(selector); - } - } + public async setMarkdownTxt(markdownTxt: string) { + const input = await this.testSubjects.find('markdownTextarea'); + await input.clearValue(); + await input.type(markdownTxt); + } - public async uncheckSwitch(selector: string) { - const isChecked = await this.isSwitchChecked(selector); - if (isChecked) { - log.debug(`unchecking switch ${selector}`); - await testSubjects.click(selector); - } - } + public async isSwitchChecked(selector: string) { + const checkbox = await this.testSubjects.find(selector); + const isChecked = await checkbox.getAttribute('aria-checked'); + return isChecked === 'true'; + } - public async setIsFilteredByCollarCheckbox(value = true) { - await retry.try(async () => { - const isChecked = await this.isSwitchChecked('isFilteredByCollarCheckbox'); - if (isChecked !== value) { - await testSubjects.click('isFilteredByCollarCheckbox'); - throw new Error('isFilteredByCollar not set correctly'); - } - }); + public async checkSwitch(selector: string) { + const isChecked = await this.isSwitchChecked(selector); + if (!isChecked) { + this.log.debug(`checking switch ${selector}`); + await this.testSubjects.click(selector); } + } - public async setCustomLabel(label: string, index: number | string = 1) { - const customLabel = await testSubjects.find(`visEditorStringInput${index}customLabel`); - customLabel.type(label); + public async uncheckSwitch(selector: string) { + const isChecked = await this.isSwitchChecked(selector); + if (isChecked) { + this.log.debug(`unchecking switch ${selector}`); + await this.testSubjects.click(selector); } + } - public async selectYAxisAggregation(agg: string, field: string, label: string, index = 1) { - // index starts on the first "count" metric at 1 - // Each new metric or aggregation added to a visualization gets the next index. - // So to modify a metric or aggregation tests need to keep track of the - // order they are added. - await this.toggleOpenEditor(index); + public async setIsFilteredByCollarCheckbox(value = true) { + await this.retry.try(async () => { + const isChecked = await this.isSwitchChecked('isFilteredByCollarCheckbox'); + if (isChecked !== value) { + await this.testSubjects.click('isFilteredByCollarCheckbox'); + throw new Error('isFilteredByCollar not set correctly'); + } + }); + } - // select our agg - const aggSelect = await find.byCssSelector( - `#visEditorAggAccordion${index} [data-test-subj="defaultEditorAggSelect"]` - ); - await comboBox.setElement(aggSelect, agg); + public async setCustomLabel(label: string, index: number | string = 1) { + const customLabel = await this.testSubjects.find(`visEditorStringInput${index}customLabel`); + customLabel.type(label); + } - const fieldSelect = await find.byCssSelector( - `#visEditorAggAccordion${index} [data-test-subj="visDefaultEditorField"]` - ); - // select our field - await comboBox.setElement(fieldSelect, field); - // enter custom label - await this.setCustomLabel(label, index); - } + public async selectYAxisAggregation(agg: string, field: string, label: string, index = 1) { + // index starts on the first "count" metric at 1 + // Each new metric or aggregation added to a visualization gets the next index. + // So to modify a metric or aggregation tests need to keep track of the + // order they are added. + await this.toggleOpenEditor(index); + + // select our agg + const aggSelect = await this.find.byCssSelector( + `#visEditorAggAccordion${index} [data-test-subj="defaultEditorAggSelect"]` + ); + await this.comboBox.setElement(aggSelect, agg); + + const fieldSelect = await this.find.byCssSelector( + `#visEditorAggAccordion${index} [data-test-subj="visDefaultEditorField"]` + ); + // select our field + await this.comboBox.setElement(fieldSelect, field); + // enter custom label + await this.setCustomLabel(label, index); + } - public async getField() { - return await comboBox.getComboBoxSelectedOptions('visDefaultEditorField'); - } + public async getField() { + return await this.comboBox.getComboBoxSelectedOptions('visDefaultEditorField'); + } - public async sizeUpEditor() { - const resizerPanel = await testSubjects.find('euiResizableButton'); - // Drag panel 100 px left - await browser.dragAndDrop({ location: resizerPanel }, { location: { x: -100, y: 0 } }); - } + public async sizeUpEditor() { + const resizerPanel = await this.testSubjects.find('euiResizableButton'); + // Drag panel 100 px left + await this.browser.dragAndDrop({ location: resizerPanel }, { location: { x: -100, y: 0 } }); + } - public async toggleDisabledAgg(agg: string | number) { - await testSubjects.click(`visEditorAggAccordion${agg} > ~toggleDisableAggregationBtn`); - await header.waitUntilLoadingHasFinished(); - } + public async toggleDisabledAgg(agg: string | number) { + await this.testSubjects.click(`visEditorAggAccordion${agg} > ~toggleDisableAggregationBtn`); + await this.header.waitUntilLoadingHasFinished(); + } - public async toggleAggregationEditor(agg: string | number) { - await find.clickByCssSelector( - `[data-test-subj="visEditorAggAccordion${agg}"] .euiAccordion__button` - ); - await header.waitUntilLoadingHasFinished(); - } + public async toggleAggregationEditor(agg: string | number) { + await this.find.clickByCssSelector( + `[data-test-subj="visEditorAggAccordion${agg}"] .euiAccordion__button` + ); + await this.header.waitUntilLoadingHasFinished(); + } - public async toggleOtherBucket(agg: string | number = 2) { - await testSubjects.click(`visEditorAggAccordion${agg} > otherBucketSwitch`); - } + public async toggleOtherBucket(agg: string | number = 2) { + await this.testSubjects.click(`visEditorAggAccordion${agg} > otherBucketSwitch`); + } - public async toggleMissingBucket(agg: string | number = 2) { - await testSubjects.click(`visEditorAggAccordion${agg} > missingBucketSwitch`); - } + public async toggleMissingBucket(agg: string | number = 2) { + await this.testSubjects.click(`visEditorAggAccordion${agg} > missingBucketSwitch`); + } - public async toggleScaleMetrics() { - await testSubjects.click('scaleMetricsSwitch'); - } + public async toggleScaleMetrics() { + await this.testSubjects.click('scaleMetricsSwitch'); + } - public async toggleAutoMode() { - await testSubjects.click('visualizeEditorAutoButton'); - } + public async toggleAutoMode() { + await this.testSubjects.click('visualizeEditorAutoButton'); + } - public async togglePieLegend() { - await testSubjects.click('visTypePieAddLegendSwitch'); - } + public async togglePieLegend() { + await this.testSubjects.click('visTypePieAddLegendSwitch'); + } - public async togglePieNestedLegend() { - await testSubjects.click('visTypePieNestedLegendSwitch'); - } + public async togglePieNestedLegend() { + await this.testSubjects.click('visTypePieNestedLegendSwitch'); + } - public async isApplyEnabled() { - const applyButton = await testSubjects.find('visualizeEditorRenderButton'); - return await applyButton.isEnabled(); - } + public async isApplyEnabled() { + const applyButton = await this.testSubjects.find('visualizeEditorRenderButton'); + return await applyButton.isEnabled(); + } - public async toggleAccordion(id: string, toState = 'true') { - const toggle = await find.byCssSelector(`button[aria-controls="${id}"]`); - const toggleOpen = await toggle.getAttribute('aria-expanded'); - log.debug(`toggle ${id} expand = ${toggleOpen}`); - if (toggleOpen !== toState) { - log.debug(`toggle ${id} click()`); - await toggle.click(); - } + public async toggleAccordion(id: string, toState = 'true') { + const toggle = await this.find.byCssSelector(`button[aria-controls="${id}"]`); + const toggleOpen = await toggle.getAttribute('aria-expanded'); + this.log.debug(`toggle ${id} expand = ${toggleOpen}`); + if (toggleOpen !== toState) { + this.log.debug(`toggle ${id} click()`); + await toggle.click(); } + } - public async toggleOpenEditor(index: number, toState = 'true') { - // index, see selectYAxisAggregation - await this.toggleAccordion(`visEditorAggAccordion${index}`, toState); - } + public async toggleOpenEditor(index: number, toState = 'true') { + // index, see selectYAxisAggregation + await this.toggleAccordion(`visEditorAggAccordion${index}`, toState); + } - public async toggleAdvancedParams(aggId: string) { - const accordion = await testSubjects.find(`advancedParams-${aggId}`); - const accordionButton = await find.descendantDisplayedByCssSelector('button', accordion); - await accordionButton.click(); - } + public async toggleAdvancedParams(aggId: string) { + const accordion = await this.testSubjects.find(`advancedParams-${aggId}`); + const accordionButton = await this.find.descendantDisplayedByCssSelector('button', accordion); + await accordionButton.click(); + } - public async inputValueInCodeEditor(value: string) { - const codeEditor = await find.byCssSelector('.react-monaco-editor-container'); - const textarea = await codeEditor.findByClassName('monaco-mouse-cursor-text'); + public async inputValueInCodeEditor(value: string) { + const codeEditor = await this.find.byCssSelector('.react-monaco-editor-container'); + const textarea = await codeEditor.findByClassName('monaco-mouse-cursor-text'); - await textarea.click(); - await browser.pressKeys(value); - } + await textarea.click(); + await this.browser.pressKeys(value); + } - public async clickReset() { - await testSubjects.click('visualizeEditorResetButton'); - await visChart.waitForVisualization(); - } + public async clickReset() { + await this.testSubjects.click('visualizeEditorResetButton'); + await this.visChart.waitForVisualization(); + } - public async clickYAxisOptions(axisId: string) { - await testSubjects.click(`toggleYAxisOptions-${axisId}`); - } + public async clickYAxisOptions(axisId: string) { + await this.testSubjects.click(`toggleYAxisOptions-${axisId}`); + } - public async changeYAxisShowCheckbox(axisId: string, enabled: boolean) { - const selector = `valueAxisShow-${axisId}`; - const button = await testSubjects.find(selector); - const isEnabled = (await button.getAttribute('aria-checked')) === 'true'; - if (enabled !== isEnabled) { - await button.click(); - } + public async changeYAxisShowCheckbox(axisId: string, enabled: boolean) { + const selector = `valueAxisShow-${axisId}`; + const button = await this.testSubjects.find(selector); + const isEnabled = (await button.getAttribute('aria-checked')) === 'true'; + if (enabled !== isEnabled) { + await button.click(); } + } - public async changeYAxisFilterLabelsCheckbox(axisId: string, enabled: boolean) { - const selector = `yAxisFilterLabelsCheckbox-${axisId}`; - const button = await testSubjects.find(selector); - const isEnabled = (await button.getAttribute('aria-checked')) === 'true'; - if (enabled !== isEnabled) { - await button.click(); - } + public async changeYAxisFilterLabelsCheckbox(axisId: string, enabled: boolean) { + const selector = `yAxisFilterLabelsCheckbox-${axisId}`; + const button = await this.testSubjects.find(selector); + const isEnabled = (await button.getAttribute('aria-checked')) === 'true'; + if (enabled !== isEnabled) { + await button.click(); } + } - public async setSize(newValue: number, aggId?: number) { - const dataTestSubj = aggId - ? `visEditorAggAccordion${aggId} > sizeParamEditor` - : 'sizeParamEditor'; - await testSubjects.setValue(dataTestSubj, String(newValue)); - } + public async setSize(newValue: number, aggId?: number) { + const dataTestSubj = aggId + ? `visEditorAggAccordion${aggId} > sizeParamEditor` + : 'sizeParamEditor'; + await this.testSubjects.setValue(dataTestSubj, String(newValue)); + } - public async selectChartMode(mode: string) { - const selector = await find.byCssSelector(`#seriesMode0 > option[value="${mode}"]`); - await selector.click(); - } + public async selectChartMode(mode: string) { + const selector = await this.find.byCssSelector(`#seriesMode0 > option[value="${mode}"]`); + await selector.click(); + } - public async selectYAxisScaleType(axisId: string, scaleType: string) { - const selector = await find.byCssSelector( - `#scaleSelectYAxis-${axisId} > option[value="${scaleType}"]` - ); - await selector.click(); - } + public async selectYAxisScaleType(axisId: string, scaleType: string) { + const selector = await this.find.byCssSelector( + `#scaleSelectYAxis-${axisId} > option[value="${scaleType}"]` + ); + await selector.click(); + } - public async selectXAxisPosition(position: string) { - const option = await (await testSubjects.find('categoryAxisPosition')).findByCssSelector( - `option[value="${position}"]` - ); - await option.click(); - } + public async selectXAxisPosition(position: string) { + const option = await (await this.testSubjects.find('categoryAxisPosition')).findByCssSelector( + `option[value="${position}"]` + ); + await option.click(); + } - public async selectYAxisMode(mode: string) { - const selector = await find.byCssSelector(`#valueAxisMode0 > option[value="${mode}"]`); - await selector.click(); - } + public async selectYAxisMode(mode: string) { + const selector = await this.find.byCssSelector(`#valueAxisMode0 > option[value="${mode}"]`); + await selector.click(); + } - public async setAxisExtents(min: string, max: string, axisId = 'ValueAxis-1') { - await this.toggleAccordion(`yAxisAccordion${axisId}`); - await this.toggleAccordion(`yAxisOptionsAccordion${axisId}`); + public async setAxisExtents(min: string, max: string, axisId = 'ValueAxis-1') { + await this.toggleAccordion(`yAxisAccordion${axisId}`); + await this.toggleAccordion(`yAxisOptionsAccordion${axisId}`); - await testSubjects.click('yAxisSetYExtents'); - await testSubjects.setValue('yAxisYExtentsMax', max); - await testSubjects.setValue('yAxisYExtentsMin', min); - } + await this.testSubjects.click('yAxisSetYExtents'); + await this.testSubjects.setValue('yAxisYExtentsMax', max); + await this.testSubjects.setValue('yAxisYExtentsMin', min); + } - public async selectAggregateWith(fieldValue: string) { - await testSubjects.selectValue('visDefaultEditorAggregateWith', fieldValue); - } + public async selectAggregateWith(fieldValue: string) { + await this.testSubjects.selectValue('visDefaultEditorAggregateWith', fieldValue); + } - public async setInterval(newValue: string | number, options: IntervalOptions = {}) { - const newValueString = `${newValue}`; - const { type = 'default', aggNth = 2, append = false } = options; - log.debug(`visEditor.setInterval(${newValueString}, {${type}, ${aggNth}, ${append}})`); - if (type === 'default') { - await comboBox.set('visEditorInterval', newValueString); - } else if (type === 'custom') { - await comboBox.setCustom('visEditorInterval', newValueString); - } else { - if (type === 'numeric') { - const autoMode = await testSubjects.getAttribute( - `visEditorIntervalSwitch${aggNth}`, - 'aria-checked' - ); - if (autoMode === 'true') { - await testSubjects.click(`visEditorIntervalSwitch${aggNth}`); - } - } - if (append) { - await testSubjects.append(`visEditorInterval${aggNth}`, String(newValueString)); - } else { - await testSubjects.setValue(`visEditorInterval${aggNth}`, String(newValueString)); + public async setInterval(newValue: string | number, options: IntervalOptions = {}) { + const newValueString = `${newValue}`; + const { type = 'default', aggNth = 2, append = false } = options; + this.log.debug(`visEditor.setInterval(${newValueString}, {${type}, ${aggNth}, ${append}})`); + if (type === 'default') { + await this.comboBox.set('visEditorInterval', newValueString); + } else if (type === 'custom') { + await this.comboBox.setCustom('visEditorInterval', newValueString); + } else { + if (type === 'numeric') { + const autoMode = await this.testSubjects.getAttribute( + `visEditorIntervalSwitch${aggNth}`, + 'aria-checked' + ); + if (autoMode === 'true') { + await this.testSubjects.click(`visEditorIntervalSwitch${aggNth}`); } } + if (append) { + await this.testSubjects.append(`visEditorInterval${aggNth}`, String(newValueString)); + } else { + await this.testSubjects.setValue(`visEditorInterval${aggNth}`, String(newValueString)); + } } + } - public async getInterval() { - return await comboBox.getComboBoxSelectedOptions('visEditorInterval'); - } + public async getInterval() { + return await this.comboBox.getComboBoxSelectedOptions('visEditorInterval'); + } - public async getNumericInterval(aggNth = 2) { - return await testSubjects.getAttribute(`visEditorInterval${aggNth}`, 'value'); - } + public async getNumericInterval(aggNth = 2) { + return await this.testSubjects.getAttribute(`visEditorInterval${aggNth}`, 'value'); + } - public async clickMetricEditor() { - await find.clickByCssSelector('[data-test-subj="metricsAggGroup"] .euiAccordion__button'); - } + public async clickMetricEditor() { + await this.find.clickByCssSelector('[data-test-subj="metricsAggGroup"] .euiAccordion__button'); + } - public async clickMetricByIndex(index: number) { - const metrics = await find.allByCssSelector( - '[data-test-subj="visualizationLoader"] .mtrVis .mtrVis__container' - ); - expect(metrics.length).greaterThan(index); - await metrics[index].click(); - } + public async clickMetricByIndex(index: number) { + const metrics = await this.find.allByCssSelector( + '[data-test-subj="visualizationLoader"] .mtrVis .mtrVis__container' + ); + expect(metrics.length).greaterThan(index); + await metrics[index].click(); + } - public async setSelectByOptionText(selectId: string, optionText: string) { - const selectField = await find.byCssSelector(`#${selectId}`); - const options = await find.allByCssSelector(`#${selectId} > option`); - const $ = await selectField.parseDomContent(); - const optionsText = $('option') - .toArray() - .map((option) => $(option).text()); - const optionIndex = optionsText.indexOf(optionText); - - if (optionIndex === -1) { - throw new Error( - `Unable to find option '${optionText}' in select ${selectId}. Available options: ${optionsText.join( - ',' - )}` - ); - } - await options[optionIndex].click(); + public async setSelectByOptionText(selectId: string, optionText: string) { + const selectField = await this.find.byCssSelector(`#${selectId}`); + const options = await this.find.allByCssSelector(`#${selectId} > option`); + const $ = await selectField.parseDomContent(); + const optionsText = $('option') + .toArray() + .map((option) => $(option).text()); + const optionIndex = optionsText.indexOf(optionText); + + if (optionIndex === -1) { + throw new Error( + `Unable to find option '${optionText}' in select ${selectId}. Available options: ${optionsText.join( + ',' + )}` + ); } + await options[optionIndex].click(); + } - // point series - - async clickAddAxis() { - return await testSubjects.click('visualizeAddYAxisButton'); - } + // point series - async setAxisTitle(title: string, aggNth = 0) { - return await testSubjects.setValue(`valueAxisTitle${aggNth}`, title); - } + async clickAddAxis() { + return await this.testSubjects.click('visualizeAddYAxisButton'); + } - public async toggleGridCategoryLines() { - return await testSubjects.click('showCategoryLines'); - } + async setAxisTitle(title: string, aggNth = 0) { + return await this.testSubjects.setValue(`valueAxisTitle${aggNth}`, title); + } - public async toggleValuesOnChart() { - return await testSubjects.click('showValuesOnChart'); - } + public async toggleGridCategoryLines() { + return await this.testSubjects.click('showCategoryLines'); + } - public async setGridValueAxis(axis: string) { - log.debug(`setGridValueAxis(${axis})`); - await find.selectValue('select#gridAxis', axis); - } + public async toggleValuesOnChart() { + return await this.testSubjects.click('showValuesOnChart'); + } - public async setSeriesAxis(seriesNth: number, axis: string) { - await find.selectValue(`select#seriesValueAxis${seriesNth}`, axis); - } + public async setGridValueAxis(axis: string) { + this.log.debug(`setGridValueAxis(${axis})`); + await this.find.selectValue('select#gridAxis', axis); + } - public async setSeriesType(seriesNth: number, type: string) { - await find.selectValue(`select#seriesType${seriesNth}`, type); - } + public async setSeriesAxis(seriesNth: number, axis: string) { + await this.find.selectValue(`select#seriesValueAxis${seriesNth}`, axis); } - return new VisualizeEditorPage(); + public async setSeriesType(seriesNth: number, type: string) { + await this.find.selectValue(`select#seriesType${seriesNth}`, type); + } } diff --git a/test/functional/page_objects/visualize_page.ts b/test/functional/page_objects/visualize_page.ts index 78a963867b8c2..efd4834652429 100644 --- a/test/functional/page_objects/visualize_page.ts +++ b/test/functional/page_objects/visualize_page.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { FtrProviderContext } from '../ftr_provider_context'; +import { FtrService } from '../ftr_provider_context'; import { VisualizeConstants } from '../../../src/plugins/visualize/public/application/visualize_constants'; import { UI_SETTINGS } from '../../../src/plugins/data/common'; @@ -23,455 +23,451 @@ type DashboardPickerOption = | 'existing-dashboard-option' | 'new-dashboard-option'; -export function VisualizePageProvider({ getService, getPageObjects }: FtrProviderContext) { - const kibanaServer = getService('kibanaServer'); - const testSubjects = getService('testSubjects'); - const retry = getService('retry'); - const find = getService('find'); - const log = getService('log'); - const globalNav = getService('globalNav'); - const listingTable = getService('listingTable'); - const queryBar = getService('queryBar'); - const elasticChart = getService('elasticChart'); - const { common, header, visEditor, visChart } = getPageObjects([ - 'common', - 'header', - 'visEditor', - 'visChart', - ]); - - /** - * This page object contains the visualization type selection, the landing page, - * and the open/save dialog functions - */ - class VisualizePage { - index = { - LOGSTASH_TIME_BASED: 'logstash-*', - LOGSTASH_NON_TIME_BASED: 'logstash*', - }; - - public async initTests() { - await kibanaServer.savedObjects.clean({ types: ['visualization'] }); - await kibanaServer.importExport.load('visualize'); - - await kibanaServer.uiSettings.replace({ - defaultIndex: 'logstash-*', - [UI_SETTINGS.FORMAT_BYTES_DEFAULT_PATTERN]: '0,0.[000]b', - }); - } - - public async gotoVisualizationLandingPage() { - await common.navigateToApp('visualize'); - } +/** + * This page object contains the visualization type selection, the landing page, + * and the open/save dialog functions + */ +export class VisualizePageObject extends FtrService { + private readonly kibanaServer = this.ctx.getService('kibanaServer'); + private readonly testSubjects = this.ctx.getService('testSubjects'); + private readonly retry = this.ctx.getService('retry'); + private readonly find = this.ctx.getService('find'); + private readonly log = this.ctx.getService('log'); + private readonly globalNav = this.ctx.getService('globalNav'); + private readonly listingTable = this.ctx.getService('listingTable'); + private readonly queryBar = this.ctx.getService('queryBar'); + private readonly elasticChart = this.ctx.getService('elasticChart'); + private readonly common = this.ctx.getPageObject('common'); + private readonly header = this.ctx.getPageObject('header'); + private readonly visEditor = this.ctx.getPageObject('visEditor'); + private readonly visChart = this.ctx.getPageObject('visChart'); + + index = { + LOGSTASH_TIME_BASED: 'logstash-*', + LOGSTASH_NON_TIME_BASED: 'logstash*', + }; + + public async initTests() { + await this.kibanaServer.savedObjects.clean({ types: ['visualization'] }); + await this.kibanaServer.importExport.load('visualize'); + + await this.kibanaServer.uiSettings.replace({ + defaultIndex: 'logstash-*', + [UI_SETTINGS.FORMAT_BYTES_DEFAULT_PATTERN]: '0,0.[000]b', + }); + } - public async clickNewVisualization() { - await listingTable.clickNewButton('createVisualizationPromptButton'); - } + public async gotoVisualizationLandingPage() { + await this.common.navigateToApp('visualize'); + } - public async clickAggBasedVisualizations() { - await testSubjects.click('visGroupAggBasedExploreLink'); - } + public async clickNewVisualization() { + await this.listingTable.clickNewButton('createVisualizationPromptButton'); + } - public async goBackToGroups() { - await testSubjects.click('goBackLink'); - } + public async clickAggBasedVisualizations() { + await this.testSubjects.click('visGroupAggBasedExploreLink'); + } - public async createVisualizationPromptButton() { - await testSubjects.click('createVisualizationPromptButton'); - } + public async goBackToGroups() { + await this.testSubjects.click('goBackLink'); + } - public async getChartTypes() { - const chartTypeField = await testSubjects.find('visNewDialogTypes'); - const $ = await chartTypeField.parseDomContent(); - return $('button') - .toArray() - .map((chart) => $(chart).findTestSubject('visTypeTitle').text().trim()); - } + public async createVisualizationPromptButton() { + await this.testSubjects.click('createVisualizationPromptButton'); + } - public async getPromotedVisTypes() { - const chartTypeField = await testSubjects.find('visNewDialogGroups'); - const $ = await chartTypeField.parseDomContent(); - const promotedVisTypes: string[] = []; - $('button') - .toArray() - .forEach((chart) => { - const title = $(chart).findTestSubject('visTypeTitle').text().trim(); - if (title) { - promotedVisTypes.push(title); - } - }); - return promotedVisTypes; - } + public async getChartTypes() { + const chartTypeField = await this.testSubjects.find('visNewDialogTypes'); + const $ = await chartTypeField.parseDomContent(); + return $('button') + .toArray() + .map((chart) => $(chart).findTestSubject('visTypeTitle').text().trim()); + } - public async waitForVisualizationSelectPage() { - await retry.try(async () => { - const visualizeSelectTypePage = await testSubjects.find('visNewDialogTypes'); - if (!(await visualizeSelectTypePage.isDisplayed())) { - throw new Error('wait for visualization select page'); + public async getPromotedVisTypes() { + const chartTypeField = await this.testSubjects.find('visNewDialogGroups'); + const $ = await chartTypeField.parseDomContent(); + const promotedVisTypes: string[] = []; + $('button') + .toArray() + .forEach((chart) => { + const title = $(chart).findTestSubject('visTypeTitle').text().trim(); + if (title) { + promotedVisTypes.push(title); } }); - } + return promotedVisTypes; + } - public async clickRefresh() { - if (await visChart.isNewChartsLibraryEnabled()) { - await elasticChart.setNewChartUiDebugFlag(); + public async waitForVisualizationSelectPage() { + await this.retry.try(async () => { + const visualizeSelectTypePage = await this.testSubjects.find('visNewDialogTypes'); + if (!(await visualizeSelectTypePage.isDisplayed())) { + throw new Error('wait for visualization select page'); } - await queryBar.clickQuerySubmitButton(); - } - - public async waitForGroupsSelectPage() { - await retry.try(async () => { - const visualizeSelectGroupStep = await testSubjects.find('visNewDialogGroups'); - if (!(await visualizeSelectGroupStep.isDisplayed())) { - throw new Error('wait for vis groups select step'); - } - }); - } - - public async navigateToNewVisualization() { - await this.gotoVisualizationLandingPage(); - await header.waitUntilLoadingHasFinished(); - await this.clickNewVisualization(); - await this.waitForGroupsSelectPage(); - } + }); + } - public async navigateToNewAggBasedVisualization() { - await this.gotoVisualizationLandingPage(); - await header.waitUntilLoadingHasFinished(); - await this.clickNewVisualization(); - await this.clickAggBasedVisualizations(); - await this.waitForVisualizationSelectPage(); + public async clickRefresh() { + if (await this.visChart.isNewChartsLibraryEnabled()) { + await this.elasticChart.setNewChartUiDebugFlag(); } + await this.queryBar.clickQuerySubmitButton(); + } - public async hasVisType(type: string) { - return await testSubjects.exists(`visType-${type}`); - } + public async waitForGroupsSelectPage() { + await this.retry.try(async () => { + const visualizeSelectGroupStep = await this.testSubjects.find('visNewDialogGroups'); + if (!(await visualizeSelectGroupStep.isDisplayed())) { + throw new Error('wait for vis groups select step'); + } + }); + } - public async clickVisType(type: string) { - await testSubjects.click(`visType-${type}`); - await header.waitUntilLoadingHasFinished(); - } + public async navigateToNewVisualization() { + await this.gotoVisualizationLandingPage(); + await this.header.waitUntilLoadingHasFinished(); + await this.clickNewVisualization(); + await this.waitForGroupsSelectPage(); + } - public async clickAreaChart() { - await this.clickVisType('area'); - } + public async navigateToNewAggBasedVisualization() { + await this.gotoVisualizationLandingPage(); + await this.header.waitUntilLoadingHasFinished(); + await this.clickNewVisualization(); + await this.clickAggBasedVisualizations(); + await this.waitForVisualizationSelectPage(); + } - public async clickDataTable() { - await this.clickVisType('table'); - } + public async hasVisType(type: string) { + return await this.testSubjects.exists(`visType-${type}`); + } - public async clickLineChart() { - await this.clickVisType('line'); - } + public async clickVisType(type: string) { + await this.testSubjects.click(`visType-${type}`); + await this.header.waitUntilLoadingHasFinished(); + } - public async clickRegionMap() { - await this.clickVisType('region_map'); - } + public async clickAreaChart() { + await this.clickVisType('area'); + } - public async hasRegionMap() { - return await this.hasVisType('region_map'); - } + public async clickDataTable() { + await this.clickVisType('table'); + } - public async clickMarkdownWidget() { - await this.clickVisType('markdown'); - } + public async clickLineChart() { + await this.clickVisType('line'); + } - public async clickMetric() { - await this.clickVisType('metric'); - } + public async clickRegionMap() { + await this.clickVisType('region_map'); + } - public async clickGauge() { - await this.clickVisType('gauge'); - } + public async hasRegionMap() { + return await this.hasVisType('region_map'); + } - public async clickPieChart() { - await this.clickVisType('pie'); - } + public async clickMarkdownWidget() { + await this.clickVisType('markdown'); + } - public async clickTileMap() { - await this.clickVisType('tile_map'); - } + public async clickMetric() { + await this.clickVisType('metric'); + } - public async hasTileMap() { - return await this.hasVisType('tile_map'); - } + public async clickGauge() { + await this.clickVisType('gauge'); + } - public async clickTagCloud() { - await this.clickVisType('tagcloud'); - } + public async clickPieChart() { + await this.clickVisType('pie'); + } - public async clickVega() { - await this.clickVisType('vega'); - } + public async clickTileMap() { + await this.clickVisType('tile_map'); + } - public async clickVisualBuilder() { - await this.clickVisType('metrics'); - } + public async hasTileMap() { + return await this.hasVisType('tile_map'); + } - public async clickVerticalBarChart() { - await this.clickVisType('histogram'); - } + public async clickTagCloud() { + await this.clickVisType('tagcloud'); + } - public async clickHeatmapChart() { - await this.clickVisType('heatmap'); - } + public async clickVega() { + await this.clickVisType('vega'); + } - public async clickInputControlVis() { - await this.clickVisType('input_control_vis'); - } + public async clickVisualBuilder() { + await this.clickVisType('metrics'); + } - public async clickLensWidget() { - await this.clickVisType('lens'); - } + public async clickVerticalBarChart() { + await this.clickVisType('histogram'); + } - public async clickMapsApp() { - await this.clickVisType('maps'); - } + public async clickHeatmapChart() { + await this.clickVisType('heatmap'); + } - public async hasMapsApp() { - return await this.hasVisType('maps'); - } + public async clickInputControlVis() { + await this.clickVisType('input_control_vis'); + } - public async createSimpleMarkdownViz(vizName: string) { - await this.gotoVisualizationLandingPage(); - await this.navigateToNewVisualization(); - await this.clickMarkdownWidget(); - await visEditor.setMarkdownTxt(vizName); - await visEditor.clickGo(); - await this.saveVisualization(vizName); - } + public async clickLensWidget() { + await this.clickVisType('lens'); + } - public async clickNewSearch(indexPattern = this.index.LOGSTASH_TIME_BASED) { - await testSubjects.click(`savedObjectTitle${indexPattern.split(' ').join('-')}`); - await header.waitUntilLoadingHasFinished(); - } + public async clickMapsApp() { + await this.clickVisType('maps'); + } - public async selectVisSourceIfRequired() { - log.debug('selectVisSourceIfRequired'); - const selectPage = await testSubjects.findAll('visualizeSelectSearch'); - if (selectPage.length) { - log.debug('a search is required for this visualization'); - await this.clickNewSearch(); - } - } + public async hasMapsApp() { + return await this.hasVisType('maps'); + } - /** - * Deletes all existing visualizations - */ - public async deleteAllVisualizations() { - await retry.try(async () => { - await listingTable.checkListingSelectAllCheckbox(); - await listingTable.clickDeleteSelected(); - await common.clickConfirmOnModal(); - await testSubjects.find('createVisualizationPromptButton'); - }); - } + public async createSimpleMarkdownViz(vizName: string) { + await this.gotoVisualizationLandingPage(); + await this.navigateToNewVisualization(); + await this.clickMarkdownWidget(); + await this.visEditor.setMarkdownTxt(vizName); + await this.visEditor.clickGo(); + await this.saveVisualization(vizName); + } - public async isBetaInfoShown() { - return await testSubjects.exists('betaVisInfo'); - } + public async clickNewSearch(indexPattern = this.index.LOGSTASH_TIME_BASED) { + await this.testSubjects.click(`savedObjectTitle${indexPattern.split(' ').join('-')}`); + await this.header.waitUntilLoadingHasFinished(); + } - public async getBetaTypeLinks() { - return await find.allByCssSelector('[data-vis-stage="beta"]'); + public async selectVisSourceIfRequired() { + this.log.debug('selectVisSourceIfRequired'); + const selectPage = await this.testSubjects.findAll('visualizeSelectSearch'); + if (selectPage.length) { + this.log.debug('a search is required for this visualization'); + await this.clickNewSearch(); } + } - public async getExperimentalTypeLinks() { - return await find.allByCssSelector('[data-vis-stage="experimental"]'); - } + /** + * Deletes all existing visualizations + */ + public async deleteAllVisualizations() { + await this.retry.try(async () => { + await this.listingTable.checkListingSelectAllCheckbox(); + await this.listingTable.clickDeleteSelected(); + await this.common.clickConfirmOnModal(); + await this.testSubjects.find('createVisualizationPromptButton'); + }); + } - public async isExperimentalInfoShown() { - return await testSubjects.exists('experimentalVisInfo'); - } + public async isBetaInfoShown() { + return await this.testSubjects.exists('betaVisInfo'); + } - public async getExperimentalInfo() { - return await testSubjects.find('experimentalVisInfo'); - } + public async getBetaTypeLinks() { + return await this.find.allByCssSelector('[data-vis-stage="beta"]'); + } - public async getSideEditorExists() { - return await find.existsByCssSelector('.visEditor__collapsibleSidebar'); - } + public async getExperimentalTypeLinks() { + return await this.find.allByCssSelector('[data-vis-stage="experimental"]'); + } - public async clickSavedSearch(savedSearchName: string) { - await testSubjects.click(`savedObjectTitle${savedSearchName.split(' ').join('-')}`); - await header.waitUntilLoadingHasFinished(); - } + public async isExperimentalInfoShown() { + return await this.testSubjects.exists('experimentalVisInfo'); + } - public async clickUnlinkSavedSearch() { - await testSubjects.click('showUnlinkSavedSearchPopover'); - await testSubjects.click('unlinkSavedSearch'); - await header.waitUntilLoadingHasFinished(); - } + public async getExperimentalInfo() { + return await this.testSubjects.find('experimentalVisInfo'); + } - public async ensureSavePanelOpen() { - log.debug('ensureSavePanelOpen'); - await header.waitUntilLoadingHasFinished(); - const isOpen = await testSubjects.exists('savedObjectSaveModal', { timeout: 5000 }); - if (!isOpen) { - await testSubjects.click('visualizeSaveButton'); - } - } + public async getSideEditorExists() { + return await this.find.existsByCssSelector('.visEditor__collapsibleSidebar'); + } - public async clickLoadSavedVisButton() { - // TODO: Use a test subject selector once we rewrite breadcrumbs to accept each breadcrumb - // element as a child instead of building the breadcrumbs dynamically. - await find.clickByCssSelector('[href="#/"]'); - } + public async clickSavedSearch(savedSearchName: string) { + await this.testSubjects.click(`savedObjectTitle${savedSearchName.split(' ').join('-')}`); + await this.header.waitUntilLoadingHasFinished(); + } - public async loadSavedVisualization(vizName: string, { navigateToVisualize = true } = {}) { - if (navigateToVisualize) { - await this.clickLoadSavedVisButton(); - } - await this.openSavedVisualization(vizName); - } + public async clickUnlinkSavedSearch() { + await this.testSubjects.click('showUnlinkSavedSearchPopover'); + await this.testSubjects.click('unlinkSavedSearch'); + await this.header.waitUntilLoadingHasFinished(); + } - public async openSavedVisualization(vizName: string) { - const dataTestSubj = `visListingTitleLink-${vizName.split(' ').join('-')}`; - await testSubjects.click(dataTestSubj, 20000); - await header.waitUntilLoadingHasFinished(); + public async ensureSavePanelOpen() { + this.log.debug('ensureSavePanelOpen'); + await this.header.waitUntilLoadingHasFinished(); + const isOpen = await this.testSubjects.exists('savedObjectSaveModal', { timeout: 5000 }); + if (!isOpen) { + await this.testSubjects.click('visualizeSaveButton'); } + } - public async waitForVisualizationSavedToastGone() { - await testSubjects.waitForDeleted('saveVisualizationSuccess'); - } + public async clickLoadSavedVisButton() { + // TODO: Use a test subject selector once we rewrite breadcrumbs to accept each breadcrumb + // element as a child instead of building the breadcrumbs dynamically. + await this.find.clickByCssSelector('[href="#/"]'); + } - public async clickLandingPageBreadcrumbLink() { - log.debug('clickLandingPageBreadcrumbLink'); - await find.clickByCssSelector(`a[href="#${VisualizeConstants.LANDING_PAGE_PATH}"]`); + public async loadSavedVisualization(vizName: string, { navigateToVisualize = true } = {}) { + if (navigateToVisualize) { + await this.clickLoadSavedVisButton(); } + await this.openSavedVisualization(vizName); + } - /** - * Returns true if already on the landing page (that page doesn't have a link to itself). - * @returns {Promise} - */ - public async onLandingPage() { - log.debug(`VisualizePage.onLandingPage`); - return await testSubjects.exists('visualizationLandingPage'); - } + public async openSavedVisualization(vizName: string) { + const dataTestSubj = `visListingTitleLink-${vizName.split(' ').join('-')}`; + await this.testSubjects.click(dataTestSubj, 20000); + await this.header.waitUntilLoadingHasFinished(); + } - public async gotoLandingPage() { - log.debug('VisualizePage.gotoLandingPage'); - const onPage = await this.onLandingPage(); - if (!onPage) { - await retry.try(async () => { - await this.clickLandingPageBreadcrumbLink(); - const onLandingPage = await this.onLandingPage(); - if (!onLandingPage) throw new Error('Not on the landing page.'); - }); - } - } + public async waitForVisualizationSavedToastGone() { + await this.testSubjects.waitForDeleted('saveVisualizationSuccess'); + } - public async saveVisualization(vizName: string, saveModalArgs: VisualizeSaveModalArgs = {}) { - await this.ensureSavePanelOpen(); + public async clickLandingPageBreadcrumbLink() { + this.log.debug('clickLandingPageBreadcrumbLink'); + await this.find.clickByCssSelector(`a[href="#${VisualizeConstants.LANDING_PAGE_PATH}"]`); + } - await this.setSaveModalValues(vizName, saveModalArgs); - log.debug('Click Save Visualization button'); + /** + * Returns true if already on the landing page (that page doesn't have a link to itself). + * @returns {Promise} + */ + public async onLandingPage() { + this.log.debug(`VisualizePage.onLandingPage`); + return await this.testSubjects.exists('visualizationLandingPage'); + } - await testSubjects.click('confirmSaveSavedObjectButton'); + public async gotoLandingPage() { + this.log.debug('VisualizePage.gotoLandingPage'); + const onPage = await this.onLandingPage(); + if (!onPage) { + await this.retry.try(async () => { + await this.clickLandingPageBreadcrumbLink(); + const onLandingPage = await this.onLandingPage(); + if (!onLandingPage) throw new Error('Not on the landing page.'); + }); + } + } - // Confirm that the Visualization has actually been saved - await testSubjects.existOrFail('saveVisualizationSuccess'); - const message = await common.closeToast(); - await header.waitUntilLoadingHasFinished(); - await common.waitForSaveModalToClose(); + public async saveVisualization(vizName: string, saveModalArgs: VisualizeSaveModalArgs = {}) { + await this.ensureSavePanelOpen(); - return message; - } + await this.setSaveModalValues(vizName, saveModalArgs); + this.log.debug('Click Save Visualization button'); - public async setSaveModalValues( - vizName: string, - { saveAsNew, redirectToOrigin, addToDashboard, dashboardId }: VisualizeSaveModalArgs = {} - ) { - await testSubjects.setValue('savedObjectTitle', vizName); - - const saveAsNewCheckboxExists = await testSubjects.exists('saveAsNewCheckbox'); - if (saveAsNewCheckboxExists) { - const state = saveAsNew ? 'check' : 'uncheck'; - log.debug('save as new checkbox exists. Setting its state to', state); - await testSubjects.setEuiSwitch('saveAsNewCheckbox', state); - } + await this.testSubjects.click('confirmSaveSavedObjectButton'); - const redirectToOriginCheckboxExists = await testSubjects.exists('returnToOriginModeSwitch'); - if (redirectToOriginCheckboxExists) { - const state = redirectToOrigin ? 'check' : 'uncheck'; - log.debug('redirect to origin checkbox exists. Setting its state to', state); - await testSubjects.setEuiSwitch('returnToOriginModeSwitch', state); - } + // Confirm that the Visualization has actually been saved + await this.testSubjects.existOrFail('saveVisualizationSuccess'); + const message = await this.common.closeToast(); + await this.header.waitUntilLoadingHasFinished(); + await this.common.waitForSaveModalToClose(); - const dashboardSelectorExists = await testSubjects.exists('add-to-dashboard-options'); - if (dashboardSelectorExists) { - let option: DashboardPickerOption = 'add-to-library-option'; - if (addToDashboard) { - option = dashboardId ? 'existing-dashboard-option' : 'new-dashboard-option'; - } - log.debug('save modal dashboard selector, choosing option:', option); - const dashboardSelector = await testSubjects.find('add-to-dashboard-options'); - const label = await dashboardSelector.findByCssSelector(`label[for="${option}"]`); - await label.click(); + return message; + } - if (dashboardId) { - // TODO - selecting an existing dashboard - } + public async setSaveModalValues( + vizName: string, + { saveAsNew, redirectToOrigin, addToDashboard, dashboardId }: VisualizeSaveModalArgs = {} + ) { + await this.testSubjects.setValue('savedObjectTitle', vizName); + + const saveAsNewCheckboxExists = await this.testSubjects.exists('saveAsNewCheckbox'); + if (saveAsNewCheckboxExists) { + const state = saveAsNew ? 'check' : 'uncheck'; + this.log.debug('save as new checkbox exists. Setting its state to', state); + await this.testSubjects.setEuiSwitch('saveAsNewCheckbox', state); + } + + const redirectToOriginCheckboxExists = await this.testSubjects.exists( + 'returnToOriginModeSwitch' + ); + if (redirectToOriginCheckboxExists) { + const state = redirectToOrigin ? 'check' : 'uncheck'; + this.log.debug('redirect to origin checkbox exists. Setting its state to', state); + await this.testSubjects.setEuiSwitch('returnToOriginModeSwitch', state); + } + + const dashboardSelectorExists = await this.testSubjects.exists('add-to-dashboard-options'); + if (dashboardSelectorExists) { + let option: DashboardPickerOption = 'add-to-library-option'; + if (addToDashboard) { + option = dashboardId ? 'existing-dashboard-option' : 'new-dashboard-option'; } - } + this.log.debug('save modal dashboard selector, choosing option:', option); + const dashboardSelector = await this.testSubjects.find('add-to-dashboard-options'); + const label = await dashboardSelector.findByCssSelector(`label[for="${option}"]`); + await label.click(); - public async saveVisualizationExpectSuccess( - vizName: string, - { saveAsNew, redirectToOrigin, addToDashboard, dashboardId }: VisualizeSaveModalArgs = {} - ) { - const saveMessage = await this.saveVisualization(vizName, { - saveAsNew, - redirectToOrigin, - addToDashboard, - dashboardId, - }); - if (!saveMessage) { - throw new Error( - `Expected saveVisualization to respond with the saveMessage from the toast, got ${saveMessage}` - ); + if (dashboardId) { + // TODO - selecting an existing dashboard } } + } - public async saveVisualizationExpectSuccessAndBreadcrumb( - vizName: string, - { saveAsNew = false, redirectToOrigin = false } = {} - ) { - await this.saveVisualizationExpectSuccess(vizName, { saveAsNew, redirectToOrigin }); - await retry.waitFor( - 'last breadcrumb to have new vis name', - async () => (await globalNav.getLastBreadcrumb()) === vizName + public async saveVisualizationExpectSuccess( + vizName: string, + { saveAsNew, redirectToOrigin, addToDashboard, dashboardId }: VisualizeSaveModalArgs = {} + ) { + const saveMessage = await this.saveVisualization(vizName, { + saveAsNew, + redirectToOrigin, + addToDashboard, + dashboardId, + }); + if (!saveMessage) { + throw new Error( + `Expected saveVisualization to respond with the saveMessage from the toast, got ${saveMessage}` ); } + } - public async saveVisualizationAndReturn() { - await header.waitUntilLoadingHasFinished(); - await testSubjects.existOrFail('visualizesaveAndReturnButton'); - await testSubjects.click('visualizesaveAndReturnButton'); - } + public async saveVisualizationExpectSuccessAndBreadcrumb( + vizName: string, + { saveAsNew = false, redirectToOrigin = false } = {} + ) { + await this.saveVisualizationExpectSuccess(vizName, { saveAsNew, redirectToOrigin }); + await this.retry.waitFor( + 'last breadcrumb to have new vis name', + async () => (await this.globalNav.getLastBreadcrumb()) === vizName + ); + } - public async linkedToOriginatingApp() { - await header.waitUntilLoadingHasFinished(); - await testSubjects.existOrFail('visualizesaveAndReturnButton'); - } + public async saveVisualizationAndReturn() { + await this.header.waitUntilLoadingHasFinished(); + await this.testSubjects.existOrFail('visualizesaveAndReturnButton'); + await this.testSubjects.click('visualizesaveAndReturnButton'); + } - public async notLinkedToOriginatingApp() { - await header.waitUntilLoadingHasFinished(); - await testSubjects.missingOrFail('visualizesaveAndReturnButton'); - } + public async linkedToOriginatingApp() { + await this.header.waitUntilLoadingHasFinished(); + await this.testSubjects.existOrFail('visualizesaveAndReturnButton'); + } - public async cancelAndReturn(showConfirmModal: boolean) { - await header.waitUntilLoadingHasFinished(); - await testSubjects.existOrFail('visualizeCancelAndReturnButton'); - await testSubjects.click('visualizeCancelAndReturnButton'); - if (showConfirmModal) { - await retry.waitFor( - 'confirm modal to show', - async () => await testSubjects.exists('appLeaveConfirmModal') - ); - await testSubjects.exists('confirmModalConfirmButton'); - await testSubjects.click('confirmModalConfirmButton'); - } - } + public async notLinkedToOriginatingApp() { + await this.header.waitUntilLoadingHasFinished(); + await this.testSubjects.missingOrFail('visualizesaveAndReturnButton'); } - return new VisualizePage(); + public async cancelAndReturn(showConfirmModal: boolean) { + await this.header.waitUntilLoadingHasFinished(); + await this.testSubjects.existOrFail('visualizeCancelAndReturnButton'); + await this.testSubjects.click('visualizeCancelAndReturnButton'); + if (showConfirmModal) { + await this.retry.waitFor( + 'confirm modal to show', + async () => await this.testSubjects.exists('appLeaveConfirmModal') + ); + await this.testSubjects.exists('confirmModalConfirmButton'); + await this.testSubjects.click('confirmModalConfirmButton'); + } + } } diff --git a/test/functional/services/combo_box.ts b/test/functional/services/combo_box.ts index a198aec1d1696..6706db82ce708 100644 --- a/test/functional/services/combo_box.ts +++ b/test/functional/services/combo_box.ts @@ -21,7 +21,7 @@ export class ComboBoxService extends FtrService { private readonly log = this.ctx.getService('log'); private readonly retry = this.ctx.getService('retry'); private readonly browser = this.ctx.getService('browser'); - private readonly PageObjects = this.ctx.getPageObjects(['common']); + private readonly common = this.ctx.getPageObject('common'); private readonly WAIT_FOR_EXISTS_TIME: number = this.config.get('timeouts.waitForExists'); @@ -113,7 +113,7 @@ export class ComboBoxService extends FtrService { this.log.debug(`comboBox.setCustom, comboBoxSelector: ${comboBoxSelector}, value: ${value}`); const comboBoxElement = await this.testSubjects.find(comboBoxSelector); await this.setFilterValue(comboBoxElement, value); - await this.PageObjects.common.pressEnterKey(); + await this.common.pressEnterKey(); await this.closeOptionsList(comboBoxElement); } diff --git a/test/functional/services/dashboard/add_panel.ts b/test/functional/services/dashboard/add_panel.ts index 98e947541b52d..43ab1f966bc9a 100644 --- a/test/functional/services/dashboard/add_panel.ts +++ b/test/functional/services/dashboard/add_panel.ts @@ -13,20 +13,21 @@ export class DashboardAddPanelService extends FtrService { private readonly retry = this.ctx.getService('retry'); private readonly testSubjects = this.ctx.getService('testSubjects'); private readonly flyout = this.ctx.getService('flyout'); - private readonly PageObjects = this.ctx.getPageObjects(['header', 'common']); + private readonly common = this.ctx.getPageObject('common'); + private readonly header = this.ctx.getPageObject('header'); async clickOpenAddPanel() { this.log.debug('DashboardAddPanel.clickOpenAddPanel'); await this.testSubjects.click('dashboardAddPanelButton'); // Give some time for the animation to complete - await this.PageObjects.common.sleep(500); + await this.common.sleep(500); } async clickCreateNewLink() { this.log.debug('DashboardAddPanel.clickAddNewPanelButton'); await this.testSubjects.click('dashboardAddNewPanelButton'); // Give some time for the animation to complete - await this.PageObjects.common.sleep(500); + await this.common.sleep(500); } async clickQuickButton(visType: string) { @@ -94,7 +95,7 @@ export class DashboardAddPanelService extends FtrService { } await embeddableRows[i].click(); - await this.PageObjects.common.closeToast(); + await this.common.closeToast(); embeddableList.push(name); } }); @@ -104,7 +105,7 @@ export class DashboardAddPanelService extends FtrService { async clickPagerNextButton() { // Clear all toasts that could hide pagination controls - await this.PageObjects.common.clearAllToasts(); + await this.common.clearAllToasts(); const isNext = await this.testSubjects.exists('pagination-button-next'); if (!isNext) { @@ -118,9 +119,9 @@ export class DashboardAddPanelService extends FtrService { return false; } - await this.PageObjects.header.waitUntilLoadingHasFinished(); + await this.header.waitUntilLoadingHasFinished(); await pagerNextButton.click(); - await this.PageObjects.header.waitUntilLoadingHasFinished(); + await this.header.waitUntilLoadingHasFinished(); return true; } diff --git a/test/functional/services/dashboard/expectations.ts b/test/functional/services/dashboard/expectations.ts index 34a4a9de7899a..c22eddb032cf9 100644 --- a/test/functional/services/dashboard/expectations.ts +++ b/test/functional/services/dashboard/expectations.ts @@ -16,20 +16,21 @@ export class DashboardExpectService extends FtrService { private readonly testSubjects = this.ctx.getService('testSubjects'); private readonly find = this.ctx.getService('find'); private readonly filterBar = this.ctx.getService('filterBar'); - private readonly PageObjects = this.ctx.getPageObjects(['dashboard', 'visualize', 'visChart']); + private readonly dashboard = this.ctx.getPageObject('dashboard'); + private readonly visChart = this.ctx.getPageObject('visChart'); private readonly findTimeout = 2500; async panelCount(expectedCount: number) { this.log.debug(`DashboardExpect.panelCount(${expectedCount})`); await this.retry.try(async () => { - const panelCount = await this.PageObjects.dashboard.getPanelCount(); + const panelCount = await this.dashboard.getPanelCount(); expect(panelCount).to.be(expectedCount); }); } async visualizationsArePresent(vizList: string[]) { this.log.debug('Checking all visualisations are present on dashsboard'); - let notLoaded = await this.PageObjects.dashboard.getNotLoadedVisualizations(vizList); + let notLoaded = await this.dashboard.getNotLoadedVisualizations(vizList); // TODO: Determine issue occasionally preventing 'geo map' from loading notLoaded = notLoaded.filter((x) => x !== 'Rendering Test: geo map'); expect(notLoaded).to.be.empty(); @@ -231,7 +232,7 @@ export class DashboardExpectService extends FtrService { async dataTableRowCount(expectedCount: number) { this.log.debug(`DashboardExpect.dataTableRowCount(${expectedCount})`); await this.retry.try(async () => { - const dataTableRows = await this.PageObjects.visChart.getTableVisContent(); + const dataTableRows = await this.visChart.getTableVisContent(); expect(dataTableRows.length).to.be(expectedCount); }); } @@ -239,7 +240,7 @@ export class DashboardExpectService extends FtrService { async dataTableNoResult() { this.log.debug(`DashboardExpect.dataTableNoResult`); await this.retry.try(async () => { - await this.PageObjects.visChart.getTableVisNoResult(); + await this.visChart.getTableVisNoResult(); }); } diff --git a/test/functional/services/dashboard/panel_actions.ts b/test/functional/services/dashboard/panel_actions.ts index e7c028acc0e1b..9aca790b0b437 100644 --- a/test/functional/services/dashboard/panel_actions.ts +++ b/test/functional/services/dashboard/panel_actions.ts @@ -25,7 +25,9 @@ export class DashboardPanelActionsService extends FtrService { private readonly log = this.ctx.getService('log'); private readonly testSubjects = this.ctx.getService('testSubjects'); private readonly inspector = this.ctx.getService('inspector'); - private readonly PageObjects = this.ctx.getPageObjects(['header', 'common', 'dashboard']); + private readonly header = this.ctx.getPageObject('header'); + private readonly common = this.ctx.getPageObject('common'); + private readonly dashboard = this.ctx.getPageObject('dashboard'); async findContextMenu(parent?: WebElementWrapper) { return parent @@ -78,8 +80,8 @@ export class DashboardPanelActionsService extends FtrService { const isActionVisible = await this.testSubjects.exists(EDIT_PANEL_DATA_TEST_SUBJ); if (!isActionVisible) await this.clickContextMenuMoreItem(); await this.testSubjects.clickWhenNotDisabled(EDIT_PANEL_DATA_TEST_SUBJ); - await this.PageObjects.header.waitUntilLoadingHasFinished(); - await this.PageObjects.common.waitForTopNavToBeVisible(); + await this.header.waitUntilLoadingHasFinished(); + await this.common.waitForTopNavToBeVisible(); } async editPanelByTitle(title?: string) { @@ -146,7 +148,7 @@ export class DashboardPanelActionsService extends FtrService { await this.openContextMenu(); } await this.testSubjects.click(CLONE_PANEL_DATA_TEST_SUBJ); - await this.PageObjects.dashboard.waitForRenderComplete(); + await this.dashboard.waitForRenderComplete(); } async openCopyToModalByTitle(title?: string) { diff --git a/test/functional/services/dashboard/visualizations.ts b/test/functional/services/dashboard/visualizations.ts index a6b88802d7b81..8688d375f7a7b 100644 --- a/test/functional/services/dashboard/visualizations.ts +++ b/test/functional/services/dashboard/visualizations.ts @@ -13,25 +13,23 @@ export class DashboardVisualizationsService extends FtrService { private readonly queryBar = this.ctx.getService('queryBar'); private readonly testSubjects = this.ctx.getService('testSubjects'); private readonly dashboardAddPanel = this.ctx.getService('dashboardAddPanel'); - private readonly PageObjects = this.ctx.getPageObjects([ - 'dashboard', - 'visualize', - 'visEditor', - 'header', - 'discover', - 'timePicker', - ]); + private readonly dashboard = this.ctx.getPageObject('dashboard'); + private readonly visualize = this.ctx.getPageObject('visualize'); + private readonly visEditor = this.ctx.getPageObject('visEditor'); + private readonly header = this.ctx.getPageObject('header'); + private readonly discover = this.ctx.getPageObject('discover'); + private readonly timePicker = this.ctx.getPageObject('timePicker'); async createAndAddTSVBVisualization(name: string) { this.log.debug(`createAndAddTSVBVisualization(${name})`); - const inViewMode = await this.PageObjects.dashboard.getIsInViewMode(); + const inViewMode = await this.dashboard.getIsInViewMode(); if (inViewMode) { - await this.PageObjects.dashboard.switchToEditMode(); + await this.dashboard.switchToEditMode(); } await this.dashboardAddPanel.clickEditorMenuButton(); await this.dashboardAddPanel.clickAddNewEmbeddableLink('metrics'); - await this.PageObjects.visualize.clickVisualBuilder(); - await this.PageObjects.visualize.saveVisualizationExpectSuccess(name); + await this.visualize.clickVisualBuilder(); + await this.visualize.saveVisualizationExpectSuccess(name); } async createSavedSearch({ @@ -44,8 +42,8 @@ export class DashboardVisualizationsService extends FtrService { fields?: string[]; }) { this.log.debug(`createSavedSearch(${name})`); - await this.PageObjects.header.clickDiscover(true); - await this.PageObjects.timePicker.setHistoricalDataRange(); + await this.header.clickDiscover(true); + await this.timePicker.setHistoricalDataRange(); if (query) { await this.queryBar.setQuery(query); @@ -54,12 +52,12 @@ export class DashboardVisualizationsService extends FtrService { if (fields) { for (let i = 0; i < fields.length; i++) { - await this.PageObjects.discover.clickFieldListItemAdd(fields[i]); + await this.discover.clickFieldListItemAdd(fields[i]); } } - await this.PageObjects.discover.saveSearch(name); - await this.PageObjects.header.waitUntilLoadingHasFinished(); + await this.discover.saveSearch(name); + await this.header.waitUntilLoadingHasFinished(); await this.testSubjects.exists('saveSearchSuccess'); } @@ -75,25 +73,25 @@ export class DashboardVisualizationsService extends FtrService { this.log.debug(`createAndAddSavedSearch(${name})`); await this.createSavedSearch({ name, query, fields }); - await this.PageObjects.header.clickDashboard(); + await this.header.clickDashboard(); - const inViewMode = await this.PageObjects.dashboard.getIsInViewMode(); + const inViewMode = await this.dashboard.getIsInViewMode(); if (inViewMode) { - await this.PageObjects.dashboard.switchToEditMode(); + await this.dashboard.switchToEditMode(); } await this.dashboardAddPanel.addSavedSearch(name); } async createAndAddMarkdown({ name, markdown }: { name: string; markdown: string }) { this.log.debug(`createAndAddMarkdown(${markdown})`); - const inViewMode = await this.PageObjects.dashboard.getIsInViewMode(); + const inViewMode = await this.dashboard.getIsInViewMode(); if (inViewMode) { - await this.PageObjects.dashboard.switchToEditMode(); + await this.dashboard.switchToEditMode(); } await this.dashboardAddPanel.clickMarkdownQuickButton(); - await this.PageObjects.visEditor.setMarkdownTxt(markdown); - await this.PageObjects.visEditor.clickGo(); - await this.PageObjects.visualize.saveVisualizationExpectSuccess(name, { + await this.visEditor.setMarkdownTxt(markdown); + await this.visEditor.clickGo(); + await this.visualize.saveVisualizationExpectSuccess(name, { saveAsNew: false, redirectToOrigin: true, }); @@ -101,9 +99,9 @@ export class DashboardVisualizationsService extends FtrService { async createAndEmbedMetric(name: string) { this.log.debug(`createAndEmbedMetric(${name})`); - const inViewMode = await this.PageObjects.dashboard.getIsInViewMode(); + const inViewMode = await this.dashboard.getIsInViewMode(); if (inViewMode) { - await this.PageObjects.dashboard.switchToEditMode(); + await this.dashboard.switchToEditMode(); } await this.dashboardAddPanel.clickEditorMenuButton(); await this.dashboardAddPanel.clickAggBasedVisualizations(); @@ -115,13 +113,13 @@ export class DashboardVisualizationsService extends FtrService { async createAndEmbedMarkdown({ name, markdown }: { name: string; markdown: string }) { this.log.debug(`createAndEmbedMarkdown(${markdown})`); - const inViewMode = await this.PageObjects.dashboard.getIsInViewMode(); + const inViewMode = await this.dashboard.getIsInViewMode(); if (inViewMode) { - await this.PageObjects.dashboard.switchToEditMode(); + await this.dashboard.switchToEditMode(); } await this.dashboardAddPanel.clickMarkdownQuickButton(); - await this.PageObjects.visEditor.setMarkdownTxt(markdown); - await this.PageObjects.visEditor.clickGo(); + await this.visEditor.setMarkdownTxt(markdown); + await this.visEditor.clickGo(); await this.testSubjects.click('visualizesaveAndReturnButton'); } } diff --git a/test/functional/services/data_grid.ts b/test/functional/services/data_grid.ts index f2079c02ef5b5..f54e7b65a46e2 100644 --- a/test/functional/services/data_grid.ts +++ b/test/functional/services/data_grid.ts @@ -10,7 +10,7 @@ import { chunk } from 'lodash'; import { FtrService } from '../ftr_provider_context'; import { WebElementWrapper } from './lib/web_element_wrapper'; -interface TabbedGridData { +export interface TabbedGridData { columns: string[]; rows: string[][]; } @@ -22,7 +22,7 @@ interface SelectOptions { export class DataGridService extends FtrService { private readonly find = this.ctx.getService('find'); private readonly testSubjects = this.ctx.getService('testSubjects'); - private readonly PageObjects = this.ctx.getPageObjects(['common', 'header']); + private readonly header = this.ctx.getPageObject('header'); private readonly retry = this.ctx.getService('retry'); async getDataGridTableData(): Promise { @@ -234,7 +234,7 @@ export class DataGridService extends FtrService { const tableDocViewRow = await this.getTableDocViewRow(detailsRow, fieldName); const addInclusiveFilterButton = await this.getAddInclusiveFilterButton(tableDocViewRow); await addInclusiveFilterButton.click(); - await this.PageObjects.header.awaitGlobalLoadingIndicatorHidden(); + await this.header.awaitGlobalLoadingIndicatorHidden(); } public async getAddInclusiveFilterButton( @@ -263,7 +263,7 @@ export class DataGridService extends FtrService { const tableDocViewRow = await this.getTableDocViewRow(detailsRow, fieldName); const addInclusiveFilterButton = await this.getRemoveInclusiveFilterButton(tableDocViewRow); await addInclusiveFilterButton.click(); - await this.PageObjects.header.awaitGlobalLoadingIndicatorHidden(); + await this.header.awaitGlobalLoadingIndicatorHidden(); } public async hasNoResults() { diff --git a/test/functional/services/doc_table.ts b/test/functional/services/doc_table.ts index 6c73faec16b1a..685f1748d56b2 100644 --- a/test/functional/services/doc_table.ts +++ b/test/functional/services/doc_table.ts @@ -17,7 +17,7 @@ interface SelectOptions { export class DocTableService extends FtrService { private readonly testSubjects = this.ctx.getService('testSubjects'); private readonly retry = this.ctx.getService('retry'); - private readonly PageObjects = this.ctx.getPageObjects(['common', 'header']); + private readonly header = this.ctx.getPageObject('header'); public async getTable(selector?: string) { return await this.testSubjects.find(selector ? selector : 'docTable'); @@ -126,7 +126,7 @@ export class DocTableService extends FtrService { const tableDocViewRow = await this.getTableDocViewRow(detailsRow, fieldName); const addInclusiveFilterButton = await this.getAddInclusiveFilterButton(tableDocViewRow); await addInclusiveFilterButton.click(); - await this.PageObjects.header.awaitGlobalLoadingIndicatorHidden(); + await this.header.awaitGlobalLoadingIndicatorHidden(); } public async getRemoveInclusiveFilterButton( @@ -142,7 +142,7 @@ export class DocTableService extends FtrService { const tableDocViewRow = await this.getTableDocViewRow(detailsRow, fieldName); const addInclusiveFilterButton = await this.getRemoveInclusiveFilterButton(tableDocViewRow); await addInclusiveFilterButton.click(); - await this.PageObjects.header.awaitGlobalLoadingIndicatorHidden(); + await this.header.awaitGlobalLoadingIndicatorHidden(); } public async getAddExistsFilterButton( @@ -155,7 +155,7 @@ export class DocTableService extends FtrService { const tableDocViewRow = await this.getTableDocViewRow(detailsRow, fieldName); const addInclusiveFilterButton = await this.getAddExistsFilterButton(tableDocViewRow); await addInclusiveFilterButton.click(); - await this.PageObjects.header.awaitGlobalLoadingIndicatorHidden(); + await this.header.awaitGlobalLoadingIndicatorHidden(); } public async toggleRowExpanded({ @@ -163,7 +163,7 @@ export class DocTableService extends FtrService { rowIndex = 0, }: SelectOptions = {}): Promise { await this.clickRowToggle({ isAnchorRow, rowIndex }); - await this.PageObjects.header.awaitGlobalLoadingIndicatorHidden(); + await this.header.awaitGlobalLoadingIndicatorHidden(); return await this.retry.try(async () => { const row = isAnchorRow ? await this.getAnchorRow() : (await this.getBodyRows())[rowIndex]; const detailsRow = await row.findByXpath( diff --git a/test/functional/services/embedding.ts b/test/functional/services/embedding.ts index e394aff19ab8b..6d168b00c5447 100644 --- a/test/functional/services/embedding.ts +++ b/test/functional/services/embedding.ts @@ -11,7 +11,7 @@ import { FtrService } from '../ftr_provider_context'; export class EmbeddingService extends FtrService { private readonly browser = this.ctx.getService('browser'); private readonly log = this.ctx.getService('log'); - private readonly PageObjects = this.ctx.getPageObjects(['header']); + private readonly header = this.ctx.getPageObject('header'); /** * Opens current page in embeded mode @@ -20,6 +20,6 @@ export class EmbeddingService extends FtrService { const currentUrl = await this.browser.getCurrentUrl(); this.log.debug(`Opening in embedded mode: ${currentUrl}`); await this.browser.get(`${currentUrl}&embed=true`); - await this.PageObjects.header.waitUntilLoadingHasFinished(); + await this.header.waitUntilLoadingHasFinished(); } } diff --git a/test/functional/services/filter_bar.ts b/test/functional/services/filter_bar.ts index 5f20d3d4f8b7b..1d0b85eed3a9c 100644 --- a/test/functional/services/filter_bar.ts +++ b/test/functional/services/filter_bar.ts @@ -12,7 +12,8 @@ import { FtrService } from '../ftr_provider_context'; export class FilterBarService extends FtrService { private readonly comboBox = this.ctx.getService('comboBox'); private readonly testSubjects = this.ctx.getService('testSubjects'); - private readonly PageObjects = this.ctx.getPageObjects(['common', 'header']); + private readonly common = this.ctx.getPageObject('common'); + private readonly header = this.ctx.getPageObject('header'); /** * Checks if specified filter exists @@ -56,7 +57,7 @@ export class FilterBarService extends FtrService { public async removeFilter(key: string): Promise { await this.testSubjects.click(`~filter & ~filter-key-${key}`); await this.testSubjects.click(`deleteFilter`); - await this.PageObjects.header.awaitGlobalLoadingIndicatorHidden(); + await this.header.awaitGlobalLoadingIndicatorHidden(); } /** @@ -65,8 +66,8 @@ export class FilterBarService extends FtrService { public async removeAllFilters(): Promise { await this.testSubjects.click('showFilterActions'); await this.testSubjects.click('removeAllFilters'); - await this.PageObjects.header.waitUntilLoadingHasFinished(); - await this.PageObjects.common.waitUntilUrlIncludes('filters:!()'); + await this.header.waitUntilLoadingHasFinished(); + await this.common.waitUntilUrlIncludes('filters:!()'); } /** @@ -77,13 +78,13 @@ export class FilterBarService extends FtrService { public async toggleFilterEnabled(key: string): Promise { await this.testSubjects.click(`~filter & ~filter-key-${key}`); await this.testSubjects.click(`disableFilter`); - await this.PageObjects.header.awaitGlobalLoadingIndicatorHidden(); + await this.header.awaitGlobalLoadingIndicatorHidden(); } public async toggleFilterPinned(key: string): Promise { await this.testSubjects.click(`~filter & ~filter-key-${key}`); await this.testSubjects.click(`pinFilter`); - await this.PageObjects.header.awaitGlobalLoadingIndicatorHidden(); + await this.header.awaitGlobalLoadingIndicatorHidden(); } public async isFilterPinned(key: string): Promise { @@ -141,7 +142,7 @@ export class FilterBarService extends FtrService { } } await this.testSubjects.click('saveFilter'); - await this.PageObjects.header.awaitGlobalLoadingIndicatorHidden(); + await this.header.awaitGlobalLoadingIndicatorHidden(); } /** @@ -152,7 +153,7 @@ export class FilterBarService extends FtrService { public async clickEditFilter(key: string, value: string): Promise { await this.testSubjects.click(`~filter & ~filter-key-${key} & ~filter-value-${value}`); await this.testSubjects.click(`editFilter`); - await this.PageObjects.header.awaitGlobalLoadingIndicatorHidden(); + await this.header.awaitGlobalLoadingIndicatorHidden(); } /** diff --git a/test/functional/services/index.ts b/test/functional/services/index.ts index a509141390f67..26f562799b297 100644 --- a/test/functional/services/index.ts +++ b/test/functional/services/index.ts @@ -47,7 +47,7 @@ import { ListingTableService } from './listing_table'; import { SavedQueryManagementComponentService } from './saved_query_management_component'; import { KibanaSupertestProvider } from './supertest'; import { MenuToggleService } from './menu_toggle'; -import { MonacoEditorProvider } from './monaco_editor'; +import { MonacoEditorService } from './monaco_editor'; export const services = { ...commonServiceProviders, @@ -84,6 +84,6 @@ export const services = { elasticChart: ElasticChartService, supertest: KibanaSupertestProvider, managementMenu: ManagementMenuService, - monacoEditor: MonacoEditorProvider, + monacoEditor: MonacoEditorService, menuToggle: MenuToggleService, }; diff --git a/test/functional/services/listing_table.ts b/test/functional/services/listing_table.ts index 79678cf7a812b..1cd4249df5050 100644 --- a/test/functional/services/listing_table.ts +++ b/test/functional/services/listing_table.ts @@ -17,8 +17,8 @@ export class ListingTableService extends FtrService { private readonly find = this.ctx.getService('find'); private readonly log = this.ctx.getService('log'); private readonly retry = this.ctx.getService('retry'); - private readonly common = this.ctx.getPageObjects(['common']).common; - private readonly header = this.ctx.getPageObjects(['header']).header; + private readonly common = this.ctx.getPageObject('common'); + private readonly header = this.ctx.getPageObject('header'); private async getSearchFilter() { return await this.testSubjects.find('tableListSearchBox'); diff --git a/test/functional/services/monaco_editor.ts b/test/functional/services/monaco_editor.ts index 4e791e54c4b09..572606f896454 100644 --- a/test/functional/services/monaco_editor.ts +++ b/test/functional/services/monaco_editor.ts @@ -6,26 +6,24 @@ * Side Public License, v 1. */ -import { FtrProviderContext } from '../ftr_provider_context'; +import { FtrService } from '../ftr_provider_context'; -export function MonacoEditorProvider({ getService }: FtrProviderContext) { - const retry = getService('retry'); - const browser = getService('browser'); +export class MonacoEditorService extends FtrService { + private readonly retry = this.ctx.getService('retry'); + private readonly browser = this.ctx.getService('browser'); - return new (class MonacoEditor { - public async getCodeEditorValue(nthIndex: number = 0) { - let values: string[] = []; + public async getCodeEditorValue(nthIndex: number = 0) { + let values: string[] = []; - await retry.try(async () => { - values = await browser.execute( - () => - (window as any).MonacoEnvironment.monaco.editor - .getModels() - .map((model: any) => model.getValue()) as string[] - ); - }); + await this.retry.try(async () => { + values = await this.browser.execute( + () => + (window as any).MonacoEnvironment.monaco.editor + .getModels() + .map((model: any) => model.getValue()) as string[] + ); + }); - return values[nthIndex] as string; - } - })(); + return values[nthIndex] as string; + } } diff --git a/test/functional/services/query_bar.ts b/test/functional/services/query_bar.ts index 31586d92d92a9..f0728f2b022e3 100644 --- a/test/functional/services/query_bar.ts +++ b/test/functional/services/query_bar.ts @@ -13,7 +13,8 @@ export class QueryBarService extends FtrService { private readonly testSubjects = this.ctx.getService('testSubjects'); private readonly retry = this.ctx.getService('retry'); private readonly log = this.ctx.getService('log'); - private readonly PageObjects = this.ctx.getPageObjects(['header', 'common']); + private readonly common = this.ctx.getPageObject('common'); + private readonly header = this.ctx.getPageObject('header'); private readonly find = this.ctx.getService('find'); private readonly browser = this.ctx.getService('browser'); @@ -42,15 +43,15 @@ export class QueryBarService extends FtrService { public async clearQuery(): Promise { await this.setQuery(''); - await this.PageObjects.common.pressTabKey(); // move outside of input into language switcher - await this.PageObjects.common.pressTabKey(); // move outside of language switcher so time picker appears + await this.common.pressTabKey(); // move outside of input into language switcher + await this.common.pressTabKey(); // move outside of language switcher so time picker appears } public async submitQuery(): Promise { this.log.debug('QueryBar.submitQuery'); await this.testSubjects.click('queryInput'); - await this.PageObjects.common.pressEnterKey(); - await this.PageObjects.header.waitUntilLoadingHasFinished(); + await this.common.pressEnterKey(); + await this.header.waitUntilLoadingHasFinished(); } public async clickQuerySubmitButton(): Promise { diff --git a/test/functional/services/remote/prevent_parallel_calls.ts b/test/functional/services/remote/prevent_parallel_calls.ts index d21abc9d26867..338bfbd427873 100644 --- a/test/functional/services/remote/prevent_parallel_calls.ts +++ b/test/functional/services/remote/prevent_parallel_calls.ts @@ -6,44 +6,49 @@ * Side Public License, v 1. */ -export function preventParallelCalls( - fn: (this: C, arg: A) => Promise, - filter: (arg: A) => boolean -) { - const execQueue: Task[] = []; +class Task { + public promise: Promise; + private resolve!: (result: R) => void; + private reject!: (error: Error) => void; - class Task { - public promise: Promise; - private resolve!: (result: R) => void; - private reject!: (error: Error) => void; - - constructor(private readonly context: C, private readonly arg: A) { - this.promise = new Promise((resolve, reject) => { - this.resolve = resolve; - this.reject = reject; - }); - } + constructor( + private readonly execQueue: Array>, + private readonly fn: (this: C, arg: A) => Promise, + private readonly context: C, + private readonly arg: A + ) { + this.promise = new Promise((resolve, reject) => { + this.resolve = resolve; + this.reject = reject; + }); + } - public async exec() { - try { - this.resolve(await fn.call(this.context, this.arg)); - } catch (error) { - this.reject(error); - } finally { - execQueue.shift(); - if (execQueue.length) { - execQueue[0].exec(); - } + public async exec() { + try { + this.resolve(await this.fn.call(this.context, this.arg)); + } catch (error) { + this.reject(error); + } finally { + this.execQueue.shift(); + if (this.execQueue.length) { + this.execQueue[0].exec(); } } } +} + +export function preventParallelCalls( + fn: (this: C, arg: A) => Promise, + filter: (arg: A) => boolean +) { + const execQueue: Array> = []; return async function (this: C, arg: A) { if (filter(arg)) { return await fn.call(this, arg); } - const task = new Task(this, arg); + const task = new Task(execQueue, fn, this, arg); if (execQueue.push(task) === 1) { // only item in the queue, kick it off task.exec(); diff --git a/test/functional/services/saved_query_management_component.ts b/test/functional/services/saved_query_management_component.ts index aabe8c0aebb0c..decf1618c7879 100644 --- a/test/functional/services/saved_query_management_component.ts +++ b/test/functional/services/saved_query_management_component.ts @@ -14,7 +14,7 @@ export class SavedQueryManagementComponentService extends FtrService { private readonly queryBar = this.ctx.getService('queryBar'); private readonly retry = this.ctx.getService('retry'); private readonly config = this.ctx.getService('config'); - private readonly PageObjects = this.ctx.getPageObjects(['common']); + private readonly common = this.ctx.getPageObject('common'); public async getCurrentlyLoadedQueryID() { await this.openSavedQueryManagementComponent(); @@ -93,7 +93,7 @@ export class SavedQueryManagementComponentService extends FtrService { public async deleteSavedQuery(title: string) { await this.openSavedQueryManagementComponent(); await this.testSubjects.click(`~delete-saved-query-${title}-button`); - await this.PageObjects.common.clickConfirmOnModal(); + await this.common.clickConfirmOnModal(); } async clearCurrentlyLoadedQuery() { diff --git a/test/functional/services/visualizations/pie_chart.ts b/test/functional/services/visualizations/pie_chart.ts index f51492d29b450..99e0bb6ac4c4c 100644 --- a/test/functional/services/visualizations/pie_chart.ts +++ b/test/functional/services/visualizations/pie_chart.ts @@ -20,16 +20,16 @@ export class PieChartService extends FtrService { private readonly find = this.ctx.getService('find'); private readonly panelActions = this.ctx.getService('dashboardPanelActions'); private readonly defaultFindTimeout = this.config.get('timeouts.find'); - private readonly pageObjects = this.ctx.getPageObjects(['visChart']); + private readonly visChart = this.ctx.getPageObject('visChart'); private readonly filterActionText = 'Apply filter to current view'; async clickOnPieSlice(name?: string) { this.log.debug(`PieChart.clickOnPieSlice(${name})`); - if (await this.pageObjects.visChart.isNewLibraryChart(pieChartSelector)) { + if (await this.visChart.isNewLibraryChart(pieChartSelector)) { const slices = - (await this.pageObjects.visChart.getEsChartDebugState(pieChartSelector))?.partition?.[0] - ?.partitions ?? []; + (await this.visChart.getEsChartDebugState(pieChartSelector))?.partition?.[0]?.partitions ?? + []; let sliceLabel = name || slices[0].name; if (name === 'Other') { sliceLabel = '__other__'; @@ -87,10 +87,10 @@ export class PieChartService extends FtrService { async getPieSliceStyle(name: string) { this.log.debug(`VisualizePage.getPieSliceStyle(${name})`); - if (await this.pageObjects.visChart.isNewLibraryChart(pieChartSelector)) { + if (await this.visChart.isNewLibraryChart(pieChartSelector)) { const slices = - (await this.pageObjects.visChart.getEsChartDebugState(pieChartSelector))?.partition?.[0] - ?.partitions ?? []; + (await this.visChart.getEsChartDebugState(pieChartSelector))?.partition?.[0]?.partitions ?? + []; const selectedSlice = slices.filter((slice) => { return slice.name.toString() === name.replace(',', ''); }); @@ -102,10 +102,10 @@ export class PieChartService extends FtrService { async getAllPieSliceStyles(name: string) { this.log.debug(`VisualizePage.getAllPieSliceStyles(${name})`); - if (await this.pageObjects.visChart.isNewLibraryChart(pieChartSelector)) { + if (await this.visChart.isNewLibraryChart(pieChartSelector)) { const slices = - (await this.pageObjects.visChart.getEsChartDebugState(pieChartSelector))?.partition?.[0] - ?.partitions ?? []; + (await this.visChart.getEsChartDebugState(pieChartSelector))?.partition?.[0]?.partitions ?? + []; const selectedSlice = slices.filter((slice) => { return slice.name.toString() === name.replace(',', ''); }); @@ -129,10 +129,10 @@ export class PieChartService extends FtrService { } async getPieChartLabels() { - if (await this.pageObjects.visChart.isNewLibraryChart(pieChartSelector)) { + if (await this.visChart.isNewLibraryChart(pieChartSelector)) { const slices = - (await this.pageObjects.visChart.getEsChartDebugState(pieChartSelector))?.partition?.[0] - ?.partitions ?? []; + (await this.visChart.getEsChartDebugState(pieChartSelector))?.partition?.[0]?.partitions ?? + []; return slices.map((slice) => { if (slice.name === '__missing__') { return 'Missing'; @@ -155,10 +155,10 @@ export class PieChartService extends FtrService { async getPieSliceCount() { this.log.debug('PieChart.getPieSliceCount'); - if (await this.pageObjects.visChart.isNewLibraryChart(pieChartSelector)) { + if (await this.visChart.isNewLibraryChart(pieChartSelector)) { const slices = - (await this.pageObjects.visChart.getEsChartDebugState(pieChartSelector))?.partition?.[0] - ?.partitions ?? []; + (await this.visChart.getEsChartDebugState(pieChartSelector))?.partition?.[0]?.partitions ?? + []; return slices?.length; } const slices = await this.find.allByCssSelector('svg > g > g.arcs > path.slice'); @@ -167,8 +167,8 @@ export class PieChartService extends FtrService { async expectPieSliceCountEsCharts(expectedCount: number) { const slices = - (await this.pageObjects.visChart.getEsChartDebugState(pieChartSelector))?.partition?.[0] - ?.partitions ?? []; + (await this.visChart.getEsChartDebugState(pieChartSelector))?.partition?.[0]?.partitions ?? + []; expect(slices.length).to.be(expectedCount); } diff --git a/test/plugin_functional/plugins/core_app_status/tsconfig.json b/test/plugin_functional/plugins/core_app_status/tsconfig.json index 4d979fbf7f15f..0f0e7caf2b486 100644 --- a/test/plugin_functional/plugins/core_app_status/tsconfig.json +++ b/test/plugin_functional/plugins/core_app_status/tsconfig.json @@ -1,8 +1,11 @@ { "extends": "../../../../tsconfig.base.json", "compilerOptions": { + "composite": true, "outDir": "./target", - "skipLibCheck": true + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true }, "include": [ "index.ts", @@ -10,5 +13,8 @@ "public/**/*.tsx", "../../../../typings/**/*", ], - "exclude": [] + "exclude": [], + "references": [ + { "path": "../../../../src/core/tsconfig.json" }, + ], } diff --git a/test/plugin_functional/plugins/core_provider_plugin/tsconfig.json b/test/plugin_functional/plugins/core_provider_plugin/tsconfig.json index eacd2f5e9aee3..d0d1f2d99295a 100644 --- a/test/plugin_functional/plugins/core_provider_plugin/tsconfig.json +++ b/test/plugin_functional/plugins/core_provider_plugin/tsconfig.json @@ -1,8 +1,11 @@ { "extends": "../../../../tsconfig.base.json", "compilerOptions": { + "composite": true, "outDir": "./target", - "skipLibCheck": true + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true }, "include": [ "index.ts", @@ -12,6 +15,6 @@ ], "exclude": [], "references": [ - { "path": "../../../../src/core/tsconfig.json" } - ] + { "path": "../../../../src/core/tsconfig.json" }, + ], } diff --git a/test/tsconfig.json b/test/tsconfig.json index 86b97da699ae1..2524755d3f291 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -1,7 +1,11 @@ { "extends": "../tsconfig.base.json", "compilerOptions": { - "incremental": false, + "composite": true, + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true, "types": ["node", "resize-observer-polyfill"] }, "include": [ @@ -9,7 +13,7 @@ "../typings/**/*", "../packages/kbn-test/types/ftr_globals/**/*" ], - "exclude": ["plugin_functional/plugins/**/*", "interpreter_functional/plugins/**/*"], + "exclude": ["target/**/*", "plugin_functional/plugins/**/*", "interpreter_functional/plugins/**/*"], "references": [ { "path": "../src/core/tsconfig.json" }, { "path": "../src/plugins/telemetry_management_section/tsconfig.json" }, @@ -40,6 +44,9 @@ { "path": "../src/plugins/url_forwarding/tsconfig.json" }, { "path": "../src/plugins/usage_collection/tsconfig.json" }, { "path": "../src/plugins/index_pattern_management/tsconfig.json" }, - { "path": "../src/plugins/legacy_export/tsconfig.json" } + { "path": "../src/plugins/legacy_export/tsconfig.json" }, + { "path": "../src/plugins/visualize/tsconfig.json" }, + { "path": "plugin_functional/plugins/core_app_status/tsconfig.json" }, + { "path": "plugin_functional/plugins/core_provider_plugin/tsconfig.json" }, ] } diff --git a/test/visual_regression/ftr_provider_context.d.ts b/test/visual_regression/ftr_provider_context.ts similarity index 78% rename from test/visual_regression/ftr_provider_context.d.ts rename to test/visual_regression/ftr_provider_context.ts index ba3eb370048b8..28bedd1ca6bc3 100644 --- a/test/visual_regression/ftr_provider_context.d.ts +++ b/test/visual_regression/ftr_provider_context.ts @@ -6,9 +6,10 @@ * Side Public License, v 1. */ -import { GenericFtrProviderContext } from '@kbn/test'; +import { GenericFtrProviderContext, GenericFtrService } from '@kbn/test'; import { pageObjects } from '../functional/page_objects'; import { services } from './services'; export type FtrProviderContext = GenericFtrProviderContext; +export class FtrService extends GenericFtrService {} diff --git a/test/visual_regression/services/index.ts b/test/visual_regression/services/index.ts index a948e4ef5d94e..9aefe1f8de780 100644 --- a/test/visual_regression/services/index.ts +++ b/test/visual_regression/services/index.ts @@ -7,9 +7,9 @@ */ import { services as functionalServices } from '../../functional/services'; -import { VisualTestingProvider } from './visual_testing'; +import { VisualTestingService } from './visual_testing'; export const services = { ...functionalServices, - visualTesting: VisualTestingProvider, + visualTesting: VisualTestingService, }; diff --git a/test/visual_regression/services/visual_testing/index.ts b/test/visual_regression/services/visual_testing/index.ts index 9add3a7f6fd33..156e3814d8a1d 100644 --- a/test/visual_regression/services/visual_testing/index.ts +++ b/test/visual_regression/services/visual_testing/index.ts @@ -6,4 +6,4 @@ * Side Public License, v 1. */ -export { VisualTestingProvider } from './visual_testing'; +export * from './visual_testing'; diff --git a/test/visual_regression/services/visual_testing/visual_testing.ts b/test/visual_regression/services/visual_testing/visual_testing.ts index a0d9afa90f3fe..59c601e6a2b6e 100644 --- a/test/visual_regression/services/visual_testing/visual_testing.ts +++ b/test/visual_regression/services/visual_testing/visual_testing.ts @@ -10,7 +10,7 @@ import { postSnapshot } from '@percy/agent/dist/utils/sdk-utils'; import testSubjSelector from '@kbn/test-subj-selector'; import { Test } from '@kbn/test'; import { kibanaPackageJson as pkg } from '@kbn/utils'; -import { FtrProviderContext } from '../../ftr_provider_context'; +import { FtrService, FtrProviderContext } from '../../ftr_provider_context'; // @ts-ignore internal js that is passed to the browser as is import { takePercySnapshot, takePercySnapshotWithAgent } from './take_percy_snapshot'; @@ -34,79 +34,81 @@ export interface SnapshotOptions { hide?: string[]; } -export async function VisualTestingProvider({ getService }: FtrProviderContext) { - const browser = getService('browser'); - const log = getService('log'); - const lifecycle = getService('lifecycle'); +const statsCache = new WeakMap(); - let currentTest: Test | undefined; - lifecycle.beforeEachTest.add((test) => { - currentTest = test; - }); +function getStats(test: Test) { + if (!statsCache.has(test)) { + statsCache.set(test, { + snapshotCount: 0, + }); + } + + return statsCache.get(test)!; +} - const statsCache = new WeakMap(); +export class VisualTestingService extends FtrService { + private readonly browser = this.ctx.getService('browser'); + private readonly log = this.ctx.getService('log'); - function getStats(test: Test) { - if (!statsCache.has(test)) { - statsCache.set(test, { - snapshotCount: 0, - }); - } + private currentTest: Test | undefined; - return statsCache.get(test)!; + constructor(ctx: FtrProviderContext) { + super(ctx); + + this.ctx.getService('lifecycle').beforeEachTest.add((test) => { + this.currentTest = test; + }); } - return new (class VisualTesting { - public async snapshot(options: SnapshotOptions = {}) { - if (process.env.DISABLE_VISUAL_TESTING) { - log.warning( - 'Capturing of percy snapshots disabled, would normally capture a snapshot here!' - ); - return; - } - - log.debug('Capturing percy snapshot'); - - if (!currentTest) { - throw new Error('unable to determine current test'); - } - - const [domSnapshot, url] = await Promise.all([ - this.getSnapshot(options.show, options.hide), - browser.getCurrentUrl(), - ]); - const stats = getStats(currentTest); - stats.snapshotCount += 1; - - const { name } = options; - const success = await postSnapshot({ - name: `${currentTest.fullTitle()} [${name ? name : stats.snapshotCount}]`, - url, - domSnapshot, - clientInfo: `kibana-ftr:${pkg.version}`, - ...DEFAULT_OPTIONS, - }); - - if (!success) { - throw new Error('Percy snapshot failed'); - } + public async snapshot(options: SnapshotOptions = {}) { + if (process.env.DISABLE_VISUAL_TESTING) { + this.log.warning( + 'Capturing of percy snapshots disabled, would normally capture a snapshot here!' + ); + return; } - private async getSnapshot(show: string[] = [], hide: string[] = []) { - const showSelectors = show.map(testSubjSelector); - const hideSelectors = hide.map(testSubjSelector); - const snapshot = await browser.execute<[string[], string[]], string | false>( - takePercySnapshot, - showSelectors, - hideSelectors - ); - return snapshot !== false - ? snapshot - : await browser.execute<[string[], string[]], string>( - takePercySnapshotWithAgent, - showSelectors, - hideSelectors - ); + this.log.debug('Capturing percy snapshot'); + + if (!this.currentTest) { + throw new Error('unable to determine current test'); + } + + const [domSnapshot, url] = await Promise.all([ + this.getSnapshot(options.show, options.hide), + this.browser.getCurrentUrl(), + ]); + const stats = getStats(this.currentTest); + stats.snapshotCount += 1; + + const { name } = options; + const success = await postSnapshot({ + name: `${this.currentTest.fullTitle()} [${name ? name : stats.snapshotCount}]`, + url, + domSnapshot, + clientInfo: `kibana-ftr:${pkg.version}`, + ...DEFAULT_OPTIONS, + }); + + if (!success) { + throw new Error('Percy snapshot failed'); } - })(); + } + + private async getSnapshot(show: string[] = [], hide: string[] = []) { + const showSelectors = show.map(testSubjSelector); + const hideSelectors = hide.map(testSubjSelector); + const snapshot = await this.browser.execute<[string[], string[]], string | false>( + takePercySnapshot, + showSelectors, + hideSelectors + ); + return snapshot !== false + ? snapshot + : await this.browser.execute<[string[], string[]], string>( + takePercySnapshotWithAgent, + showSelectors, + hideSelectors + ); + } } diff --git a/tsconfig.refs.json b/tsconfig.refs.json index 9aa41cb9bc755..a2c1ee43a92c4 100644 --- a/tsconfig.refs.json +++ b/tsconfig.refs.json @@ -56,6 +56,7 @@ { "path": "./src/plugins/visualizations/tsconfig.json" }, { "path": "./src/plugins/visualize/tsconfig.json" }, { "path": "./src/plugins/index_pattern_management/tsconfig.json" }, + { "path": "./test/tsconfig.json" }, { "path": "./x-pack/plugins/actions/tsconfig.json" }, { "path": "./x-pack/plugins/alerting/tsconfig.json" }, { "path": "./x-pack/plugins/apm/tsconfig.json" }, From 9a275de0f99cb616048cdb1409eb1018daf4b196 Mon Sep 17 00:00:00 2001 From: Byron Hulcher Date: Fri, 4 Jun 2021 14:28:11 -0400 Subject: [PATCH 61/90] [App Search] Initial logic for Crawler Overview (#101176) * New CrawlerOverview component * CrawlerRouter should use CrawlerOverview in dev mode * New CrawlerOverviewLogic * New crawler route * Display domains data for CrawlerOverview in EuiCode * Update types * Clean up tests for Crawler utils * Better todo commenting for CrawlerOverview tests * Remove unused div from CrawlerOverview * Rename CrawlerOverviewLogic.actios.setCrawlerData to onFetchCrawlerData * Cleaning up CrawlerOverviewLogic * Cleaning up CrawlerOverviewLogic tests * Fix CrawlerPolicies capitalization * Add Loading UX * Cleaning up afterEachs across Crawler tests --- .../crawler/crawler_landing.test.tsx | 5 +- .../crawler/crawler_overview.test.tsx | 66 ++++++++++ .../components/crawler/crawler_overview.tsx | 41 ++++++ .../crawler/crawler_overview_logic.test.ts | 121 ++++++++++++++++++ .../crawler/crawler_overview_logic.ts | 64 +++++++++ .../crawler/crawler_router.test.tsx | 15 ++- .../components/crawler/crawler_router.tsx | 3 +- .../app_search/components/crawler/types.ts | 67 ++++++++++ .../components/crawler/utils.test.ts | 93 ++++++++++++++ .../app_search/components/crawler/utils.ts | 55 ++++++++ .../server/routes/app_search/crawler.test.ts | 35 +++++ .../server/routes/app_search/crawler.ts | 29 +++++ .../server/routes/app_search/index.ts | 2 + 13 files changed, 589 insertions(+), 7 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview_logic.test.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview_logic.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/types.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/utils.test.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/utils.ts create mode 100644 x-pack/plugins/enterprise_search/server/routes/app_search/crawler.test.ts create mode 100644 x-pack/plugins/enterprise_search/server/routes/app_search/crawler.ts diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_landing.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_landing.test.tsx index 9591b82773b9f..132579bad8bdc 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_landing.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_landing.test.tsx @@ -19,14 +19,11 @@ describe('CrawlerLanding', () => { let wrapper: ShallowWrapper; beforeEach(() => { + jest.clearAllMocks(); setMockValues({ ...mockEngineValues }); wrapper = shallow(); }); - afterEach(() => { - jest.clearAllMocks(); - }); - it('contains an external documentation link', () => { const externalDocumentationLink = wrapper.find('[data-test-subj="CrawlerDocumentationLink"]'); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview.test.tsx new file mode 100644 index 0000000000000..eb30ae867b4b6 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview.test.tsx @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { rerender, setMockActions, setMockValues } from '../../../__mocks__'; +import '../../../__mocks__/shallow_useeffect.mock'; + +import React from 'react'; + +import { shallow, ShallowWrapper } from 'enzyme'; + +import { EuiCode } from '@elastic/eui'; + +import { Loading } from '../../../shared/loading'; + +import { CrawlerOverview } from './crawler_overview'; + +const actions = { + fetchCrawlerData: jest.fn(), +}; + +const values = { + dataLoading: false, + domains: [], +}; + +describe('CrawlerOverview', () => { + let wrapper: ShallowWrapper; + + beforeEach(() => { + jest.clearAllMocks(); + setMockValues(values); + setMockActions(actions); + wrapper = shallow(); + }); + + it('renders', () => { + expect(wrapper.find(EuiCode)).toHaveLength(1); + }); + + it('calls fetchCrawlerData on page load', () => { + expect(actions.fetchCrawlerData).toHaveBeenCalledTimes(1); + }); + + // TODO after DomainsTable is built in a future PR + // it('contains a DomainsTable', () => {}) + + // TODO after CrawlRequestsTable is built in a future PR + // it('containss a CrawlRequestsTable,() => {}) + + // TODO after AddDomainForm is built in a future PR + // it('contains an AddDomainForm' () => {}) + + // TODO after empty state is added in a future PR + // it('has an empty state', () => {} ) + + it('shows an empty state when data is loading', () => { + setMockValues({ dataLoading: true }); + rerender(wrapper); + + expect(wrapper.find(Loading)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview.tsx new file mode 100644 index 0000000000000..5eeaaaef69605 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview.tsx @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useEffect } from 'react'; + +import { useActions, useValues } from 'kea'; + +import { EuiCode, EuiPageHeader } from '@elastic/eui'; + +import { FlashMessages } from '../../../shared/flash_messages'; + +import { Loading } from '../../../shared/loading'; + +import { CRAWLER_TITLE } from './constants'; +import { CrawlerOverviewLogic } from './crawler_overview_logic'; + +export const CrawlerOverview: React.FC = () => { + const { dataLoading, domains } = useValues(CrawlerOverviewLogic); + + const { fetchCrawlerData } = useActions(CrawlerOverviewLogic); + + useEffect(() => { + fetchCrawlerData(); + }, []); + + if (dataLoading) { + return ; + } + + return ( + <> + + + {JSON.stringify(domains, null, 2)} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview_logic.test.ts new file mode 100644 index 0000000000000..766f5dcfa02dc --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview_logic.test.ts @@ -0,0 +1,121 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { LogicMounter, mockHttpValues, mockFlashMessageHelpers } from '../../../__mocks__'; +import '../../__mocks__/engine_logic.mock'; + +import { nextTick } from '@kbn/test/jest'; + +import { CrawlerOverviewLogic } from './crawler_overview_logic'; +import { CrawlerPolicies, CrawlerRules, CrawlRule } from './types'; + +const DEFAULT_VALUES = { + dataLoading: true, + domains: [], +}; + +const DEFAULT_CRAWL_RULE: CrawlRule = { + id: '-', + policy: CrawlerPolicies.allow, + rule: CrawlerRules.regex, + pattern: '.*', +}; + +describe('CrawlerOverviewLogic', () => { + const { mount } = new LogicMounter(CrawlerOverviewLogic); + const { http } = mockHttpValues; + const { flashAPIErrors } = mockFlashMessageHelpers; + + beforeEach(() => { + jest.clearAllMocks(); + mount(); + }); + + it('has expected default values', () => { + expect(CrawlerOverviewLogic.values).toEqual(DEFAULT_VALUES); + }); + + describe('actions', () => { + describe('onFetchCrawlerData', () => { + const crawlerData = { + domains: [ + { + id: '507f1f77bcf86cd799439011', + createdOn: 'Mon, 31 Aug 2020 17:00:00 +0000', + url: 'moviedatabase.com', + documentCount: 13, + sitemaps: [], + entryPoints: [], + crawlRules: [], + defaultCrawlRule: DEFAULT_CRAWL_RULE, + }, + ], + }; + + beforeEach(() => { + CrawlerOverviewLogic.actions.onFetchCrawlerData(crawlerData); + }); + + it('should set all received data as top-level values', () => { + expect(CrawlerOverviewLogic.values.domains).toEqual(crawlerData.domains); + }); + + it('should set dataLoading to false', () => { + expect(CrawlerOverviewLogic.values.dataLoading).toEqual(false); + }); + }); + }); + + describe('listeners', () => { + describe('fetchCrawlerData', () => { + it('calls onFetchCrawlerData with retrieved data that has been converted from server to client', async () => { + jest.spyOn(CrawlerOverviewLogic.actions, 'onFetchCrawlerData'); + + http.get.mockReturnValue( + Promise.resolve({ + domains: [ + { + id: '507f1f77bcf86cd799439011', + name: 'moviedatabase.com', + created_on: 'Mon, 31 Aug 2020 17:00:00 +0000', + document_count: 13, + sitemaps: [], + entry_points: [], + crawl_rules: [], + }, + ], + }) + ); + CrawlerOverviewLogic.actions.fetchCrawlerData(); + await nextTick(); + + expect(http.get).toHaveBeenCalledWith('/api/app_search/engines/some-engine/crawler'); + expect(CrawlerOverviewLogic.actions.onFetchCrawlerData).toHaveBeenCalledWith({ + domains: [ + { + id: '507f1f77bcf86cd799439011', + createdOn: 'Mon, 31 Aug 2020 17:00:00 +0000', + url: 'moviedatabase.com', + documentCount: 13, + sitemaps: [], + entryPoints: [], + crawlRules: [], + }, + ], + }); + }); + + it('calls flashApiErrors when there is an error', async () => { + http.get.mockReturnValue(Promise.reject('error')); + CrawlerOverviewLogic.actions.fetchCrawlerData(); + await nextTick(); + + expect(flashAPIErrors).toHaveBeenCalledWith('error'); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview_logic.ts new file mode 100644 index 0000000000000..6f04ade5962eb --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview_logic.ts @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { kea, MakeLogicType } from 'kea'; + +import { flashAPIErrors } from '../../../shared/flash_messages'; + +import { HttpLogic } from '../../../shared/http'; +import { EngineLogic } from '../engine'; + +import { CrawlerData, CrawlerDataFromServer, CrawlerDomain } from './types'; +import { crawlerDataServerToClient } from './utils'; + +interface CrawlerOverviewValues { + dataLoading: boolean; + domains: CrawlerDomain[]; +} + +interface CrawlerOverviewActions { + fetchCrawlerData(): void; + onFetchCrawlerData(data: CrawlerData): { data: CrawlerData }; +} + +export const CrawlerOverviewLogic = kea< + MakeLogicType +>({ + path: ['enterprise_search', 'app_search', 'crawler', 'crawler_overview'], + actions: { + fetchCrawlerData: true, + onFetchCrawlerData: (data) => ({ data }), + }, + reducers: { + dataLoading: [ + true, + { + onFetchCrawlerData: () => false, + }, + ], + domains: [ + [], + { + onFetchCrawlerData: (_, { data: { domains } }) => domains, + }, + ], + }, + listeners: ({ actions }) => ({ + fetchCrawlerData: async () => { + const { http } = HttpLogic.values; + const { engineName } = EngineLogic.values; + + try { + const response = await http.get(`/api/app_search/engines/${engineName}/crawler`); + const crawlerData = crawlerDataServerToClient(response as CrawlerDataFromServer); + actions.onFetchCrawlerData(crawlerData); + } catch (e) { + flashAPIErrors(e); + } + }, + }), +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_router.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_router.test.tsx index 6aa9ca8c4feb1..351f547447803 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_router.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_router.test.tsx @@ -14,21 +14,32 @@ import { Switch } from 'react-router-dom'; import { shallow } from 'enzyme'; import { CrawlerLanding } from './crawler_landing'; +import { CrawlerOverview } from './crawler_overview'; import { CrawlerRouter } from './crawler_router'; describe('CrawlerRouter', () => { + const OLD_ENV = process.env; + beforeEach(() => { + jest.clearAllMocks(); setMockValues({ ...mockEngineValues }); }); afterEach(() => { - jest.clearAllMocks(); + process.env = OLD_ENV; }); - it('renders a landing page', () => { + it('renders a landing page by default', () => { const wrapper = shallow(); expect(wrapper.find(Switch)).toHaveLength(1); expect(wrapper.find(CrawlerLanding)).toHaveLength(1); }); + + it('renders a crawler overview in dev', () => { + process.env.NODE_ENV = 'development'; + const wrapper = shallow(); + + expect(wrapper.find(CrawlerOverview)).toHaveLength(1); + }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_router.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_router.tsx index fcc949de7d8b4..926c45b437937 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_router.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_router.tsx @@ -14,13 +14,14 @@ import { getEngineBreadcrumbs } from '../engine'; import { CRAWLER_TITLE } from './constants'; import { CrawlerLanding } from './crawler_landing'; +import { CrawlerOverview } from './crawler_overview'; export const CrawlerRouter: React.FC = () => { return ( - + {process.env.NODE_ENV === 'development' ? : } ); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/types.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/types.ts new file mode 100644 index 0000000000000..f895e8f01e399 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/types.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export enum CrawlerPolicies { + allow = 'allow', + deny = 'deny', +} + +export enum CrawlerRules { + beginsWith = 'begins', + endsWith = 'ends', + contains = 'contains', + regex = 'regex', +} + +export interface CrawlRule { + id: string; + policy: CrawlerPolicies; + rule: CrawlerRules; + pattern: string; +} + +export interface EntryPoint { + id: string; + value: string; +} + +export interface Sitemap { + id: string; + url: string; +} + +export interface CrawlerDomain { + createdOn: string; + documentCount: number; + id: string; + lastCrawl?: string; + url: string; + crawlRules: CrawlRule[]; + defaultCrawlRule?: CrawlRule; + entryPoints: EntryPoint[]; + sitemaps: Sitemap[]; +} + +export interface CrawlerDomainFromServer { + id: string; + name: string; + created_on: string; + last_visited_at?: string; + document_count: number; + crawl_rules: CrawlRule[]; + default_crawl_rule?: CrawlRule; + entry_points: EntryPoint[]; + sitemaps: Sitemap[]; +} + +export interface CrawlerData { + domains: CrawlerDomain[]; +} + +export interface CrawlerDataFromServer { + domains: CrawlerDomainFromServer[]; +} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/utils.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/utils.test.ts new file mode 100644 index 0000000000000..6e2dd7c826b70 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/utils.test.ts @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CrawlerPolicies, CrawlerRules, CrawlRule, CrawlerDomainFromServer } from './types'; + +import { crawlerDomainServerToClient, crawlerDataServerToClient } from './utils'; + +const DEFAULT_CRAWL_RULE: CrawlRule = { + id: '-', + policy: CrawlerPolicies.allow, + rule: CrawlerRules.regex, + pattern: '.*', +}; + +describe('crawlerDomainServerToClient', () => { + it('converts the API payload into properties matching our code style', () => { + const id = '507f1f77bcf86cd799439011'; + const name = 'moviedatabase.com'; + + const defaultServerPayload = { + id, + name, + created_on: 'Mon, 31 Aug 2020 17:00:00 +0000', + document_count: 13, + sitemaps: [], + entry_points: [], + crawl_rules: [], + }; + + const defaultClientPayload = { + id, + createdOn: 'Mon, 31 Aug 2020 17:00:00 +0000', + url: name, + documentCount: 13, + sitemaps: [], + entryPoints: [], + crawlRules: [], + }; + + expect(crawlerDomainServerToClient(defaultServerPayload)).toStrictEqual(defaultClientPayload); + expect( + crawlerDomainServerToClient({ + ...defaultServerPayload, + last_visited_at: 'Mon, 31 Aug 2020 17:00:00 +0000', + }) + ).toStrictEqual({ ...defaultClientPayload, lastCrawl: 'Mon, 31 Aug 2020 17:00:00 +0000' }); + expect( + crawlerDomainServerToClient({ + ...defaultServerPayload, + default_crawl_rule: DEFAULT_CRAWL_RULE, + }) + ).toStrictEqual({ ...defaultClientPayload, defaultCrawlRule: DEFAULT_CRAWL_RULE }); + }); +}); + +describe('crawlerDataServerToClient', () => { + it('converts all domains from the server form to their client form', () => { + const domains: CrawlerDomainFromServer[] = [ + { + id: 'x', + name: 'moviedatabase.com', + document_count: 13, + created_on: 'Mon, 31 Aug 2020 17:00:00 +0000', + sitemaps: [], + entry_points: [], + crawl_rules: [], + default_crawl_rule: DEFAULT_CRAWL_RULE, + }, + { + id: 'y', + name: 'swiftype.com', + last_visited_at: 'Mon, 31 Aug 2020 17:00:00 +0000', + document_count: 40, + created_on: 'Mon, 31 Aug 2020 17:00:00 +0000', + sitemaps: [], + entry_points: [], + crawl_rules: [], + }, + ]; + + const output = crawlerDataServerToClient({ + domains, + }); + + expect(output.domains).toHaveLength(2); + expect(output.domains[0]).toEqual(crawlerDomainServerToClient(domains[0])); + expect(output.domains[1]).toEqual(crawlerDomainServerToClient(domains[1])); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/utils.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/utils.ts new file mode 100644 index 0000000000000..e89c549261fca --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/utils.ts @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + CrawlerDomain, + CrawlerDomainFromServer, + CrawlerData, + CrawlerDataFromServer, +} from './types'; + +export function crawlerDomainServerToClient(payload: CrawlerDomainFromServer): CrawlerDomain { + const { + id, + name, + sitemaps, + created_on: createdOn, + last_visited_at: lastCrawl, + document_count: documentCount, + crawl_rules: crawlRules, + default_crawl_rule: defaultCrawlRule, + entry_points: entryPoints, + } = payload; + + const clientPayload: CrawlerDomain = { + id, + url: name, + documentCount, + createdOn, + crawlRules, + sitemaps, + entryPoints, + }; + + if (lastCrawl) { + clientPayload.lastCrawl = lastCrawl; + } + + if (defaultCrawlRule) { + clientPayload.defaultCrawlRule = defaultCrawlRule; + } + + return clientPayload; +} + +export function crawlerDataServerToClient(payload: CrawlerDataFromServer): CrawlerData { + const { domains } = payload; + + return { + domains: domains.map((domain) => crawlerDomainServerToClient(domain)), + }; +} diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/crawler.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/crawler.test.ts new file mode 100644 index 0000000000000..626a107b6942b --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/crawler.test.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { mockDependencies, mockRequestHandler, MockRouter } from '../../__mocks__'; + +import { registerCrawlerRoutes } from './crawler'; + +describe('crawler routes', () => { + describe('GET /api/app_search/engines/{name}/crawler', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + mockRouter = new MockRouter({ + method: 'get', + path: '/api/app_search/engines/{name}/crawler', + }); + + registerCrawlerRoutes({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('creates a request to enterprise search', () => { + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/api/as/v0/engines/:name/crawler', + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/crawler.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/crawler.ts new file mode 100644 index 0000000000000..15b8340b07d4e --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/crawler.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; + +import { RouteDependencies } from '../../plugin'; + +export function registerCrawlerRoutes({ + router, + enterpriseSearchRequestHandler, +}: RouteDependencies) { + router.get( + { + path: '/api/app_search/engines/{name}/crawler', + validate: { + params: schema.object({ + name: schema.string(), + }), + }, + }, + enterpriseSearchRequestHandler.createRequest({ + path: '/api/as/v0/engines/:name/crawler', + }) + ); +} diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts index 6ccdce0935d93..2442b61c632c1 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts @@ -9,6 +9,7 @@ import { RouteDependencies } from '../../plugin'; import { registerAnalyticsRoutes } from './analytics'; import { registerApiLogsRoutes } from './api_logs'; +import { registerCrawlerRoutes } from './crawler'; import { registerCredentialsRoutes } from './credentials'; import { registerCurationsRoutes } from './curations'; import { registerDocumentsRoutes, registerDocumentRoutes } from './documents'; @@ -42,4 +43,5 @@ export const registerAppSearchRoutes = (dependencies: RouteDependencies) => { registerResultSettingsRoutes(dependencies); registerApiLogsRoutes(dependencies); registerOnboardingRoutes(dependencies); + registerCrawlerRoutes(dependencies); }; From 77533da2be7470238474faf7efe4ce351a014a4e Mon Sep 17 00:00:00 2001 From: Jason Stoltzfus Date: Fri, 4 Jun 2021 14:32:17 -0400 Subject: [PATCH 62/90] [App Search] 100% code coverage plus fix console error (#101407) --- .../__mocks__/flash_messages_logic.mock.ts | 1 + .../app_search/components/library/library.tsx | 2 ++ .../applications/app_search/index.test.tsx | 23 +++++++++++++++++++ 3 files changed, 26 insertions(+) diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/flash_messages_logic.mock.ts b/x-pack/plugins/enterprise_search/public/applications/__mocks__/flash_messages_logic.mock.ts index 17e22e6f23daf..6c31927cd75b0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/__mocks__/flash_messages_logic.mock.ts +++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/flash_messages_logic.mock.ts @@ -15,6 +15,7 @@ export const mockFlashMessagesActions = { clearFlashMessages: jest.fn(), setQueuedMessages: jest.fn(), clearQueuedMessages: jest.fn(), + dismissToastMessage: jest.fn(), }; export const mockFlashMessageHelpers = { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/library/library.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/library/library.tsx index 5d61929770299..b9d3dbd9ee412 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/library/library.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/library/library.tsx @@ -5,6 +5,8 @@ * 2.0. */ +/* istanbul ignore file */ + import React, { useState } from 'react'; import { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx index 287d46c2dec75..8d33bd2d130ec 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx @@ -26,6 +26,7 @@ import { EngineRouter, EngineNav } from './components/engine'; import { EngineCreation } from './components/engine_creation'; import { EnginesOverview } from './components/engines'; import { ErrorConnecting } from './components/error_connecting'; +import { Library } from './components/library'; import { MetaEngineCreation } from './components/meta_engine_creation'; import { RoleMappingsRouter } from './components/role_mappings'; import { SetupGuide } from './components/setup_guide'; @@ -147,6 +148,28 @@ describe('AppSearchConfigured', () => { }); }); }); + + describe('library', () => { + it('renders a library page in development', () => { + const OLD_ENV = process.env.NODE_ENV; + process.env.NODE_ENV = 'development'; + + rerender(wrapper); + + expect(wrapper.find(Library)).toHaveLength(1); + process.env.NODE_ENV = OLD_ENV; + }); + + it("doesn't in production", () => { + const OLD_ENV = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + + rerender(wrapper); + + expect(wrapper.find(Library)).toHaveLength(0); + process.env.NODE_ENV = OLD_ENV; + }); + }); }); describe('AppSearchNav', () => { From e565b22ab372760bb13350768c9f624ce26dc981 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Fri, 4 Jun 2021 19:56:52 +0100 Subject: [PATCH 63/90] chore(NA): upgrade bazel rules nodejs to v3.5.1 (#101412) --- WORKSPACE.bazel | 6 +++--- package.json | 2 +- yarn.lock | 8 ++++---- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/WORKSPACE.bazel b/WORKSPACE.bazel index d80ad948cbb55..acb62043a15ca 100644 --- a/WORKSPACE.bazel +++ b/WORKSPACE.bazel @@ -10,15 +10,15 @@ load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive") # Fetch Node.js rules http_archive( name = "build_bazel_rules_nodejs", - sha256 = "10f534e1c80f795cffe1f2822becd4897754d18564612510c59b3c73544ae7c6", - urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/3.5.0/rules_nodejs-3.5.0.tar.gz"], + sha256 = "4a5d654a4ccd4a4c24eca5d319d85a88a650edf119601550c95bf400c8cc897e", + urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/3.5.1/rules_nodejs-3.5.1.tar.gz"], ) # Now that we have the rules let's import from them to complete the work load("@build_bazel_rules_nodejs//:index.bzl", "check_rules_nodejs_version", "node_repositories", "yarn_install") # Assure we have at least a given rules_nodejs version -check_rules_nodejs_version(minimum_version_string = "3.5.0") +check_rules_nodejs_version(minimum_version_string = "3.5.1") # Setup the Node.js toolchain for the architectures we want to support # diff --git a/package.json b/package.json index a2499d85247d7..65cb1e51866df 100644 --- a/package.json +++ b/package.json @@ -441,7 +441,7 @@ "@babel/traverse": "^7.12.12", "@babel/types": "^7.12.12", "@bazel/ibazel": "^0.15.10", - "@bazel/typescript": "^3.5.0", + "@bazel/typescript": "^3.5.1", "@cypress/snapshot": "^2.1.7", "@cypress/webpack-preprocessor": "^5.6.0", "@elastic/apm-rum": "^5.6.1", diff --git a/yarn.lock b/yarn.lock index c5255bc4d0d30..83ab15d1f68d1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1204,10 +1204,10 @@ resolved "https://registry.yarnpkg.com/@bazel/ibazel/-/ibazel-0.15.10.tgz#cf0cff1aec6d8e7bb23e1fc618d09fbd39b7a13f" integrity sha512-0v+OwCQ6fsGFa50r6MXWbUkSGuWOoZ22K4pMSdtWiL5LKFIE4kfmMmtQS+M7/ICNwk2EIYob+NRreyi/DGUz5A== -"@bazel/typescript@^3.5.0": - version "3.5.0" - resolved "https://registry.yarnpkg.com/@bazel/typescript/-/typescript-3.5.0.tgz#605493f4f0a5297df8a7fcccb86a1a80ea2090bb" - integrity sha512-BtGFp4nYFkQTmnONCzomk7dkmOwaINBL3piq+lykBlcc6UxLe9iCAnZpOyPypB1ReN3k3SRNAa53x6oGScQxMg== +"@bazel/typescript@^3.5.1": + version "3.5.1" + resolved "https://registry.yarnpkg.com/@bazel/typescript/-/typescript-3.5.1.tgz#c6027d683adeefa2c3cebfa3ed5efa17c405a63b" + integrity sha512-dU5sGgaGdFWV1dJ1B+9iFbttgcKtmob+BvlM8mY7Nxq4j7/wVbgPjiVLOBeOD7kpzYep8JHXfhAokHt486IG+Q== dependencies: protobufjs "6.8.8" semver "5.6.0" From 137778d1240d56ca6b5aab0eea82f97ef5936d44 Mon Sep 17 00:00:00 2001 From: John Schulz Date: Fri, 4 Jun 2021 15:21:54 -0400 Subject: [PATCH 64/90] [Fleet] Show callout & CTA in add agent flyout if no enrollment keys (#100599) ## Summary Fixes https://github.com/elastic/kibana/issues/91454 ### If there are no enrollment tokens for a policy, show help text & button to create one. https://user-images.githubusercontent.com/57655/119555390-ce95b480-bd6b-11eb-8333-ce7b50c9fccd.mov ### Clicking "Create enrollment token" button in Agents list view now opens a modal instead of a flyout https://user-images.githubusercontent.com/57655/119556503-1e28b000-bd6d-11eb-8952-1da8e80e976e.mov ### Checklist - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/master/packages/kbn-i18n/README.md) - [ ] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --- .../hooks/use_request/enrollment_api_keys.ts | 10 ++ .../agent_policy_selection.tsx | 120 +++++++++++++---- .../managed_instructions.tsx | 14 +- .../agent_enrollment_flyout/steps.tsx | 8 +- ...lyout.tsx => new_enrollment_key_modal.tsx} | 125 +++++++----------- .../enrollment_token_list_page/index.tsx | 14 +- .../public/applications/fleet/types/index.ts | 2 + .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - 9 files changed, 180 insertions(+), 115 deletions(-) rename x-pack/plugins/fleet/public/applications/fleet/sections/agents/enrollment_token_list_page/components/{new_enrollment_key_flyout.tsx => new_enrollment_key_modal.tsx} (50%) diff --git a/x-pack/plugins/fleet/public/applications/fleet/hooks/use_request/enrollment_api_keys.ts b/x-pack/plugins/fleet/public/applications/fleet/hooks/use_request/enrollment_api_keys.ts index 601d54ec56c46..7b3ddaada8001 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/hooks/use_request/enrollment_api_keys.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/hooks/use_request/enrollment_api_keys.ts @@ -11,6 +11,8 @@ import type { GetOneEnrollmentAPIKeyResponse, GetEnrollmentAPIKeysResponse, GetEnrollmentAPIKeysRequest, + PostEnrollmentAPIKeyRequest, + PostEnrollmentAPIKeyResponse, } from '../../types'; import { useRequest, sendRequest, useConditionalRequest } from './use_request'; @@ -65,3 +67,11 @@ export function useGetEnrollmentAPIKeys( ...options, }); } + +export function sendCreateEnrollmentAPIKey(body: PostEnrollmentAPIKeyRequest['body']) { + return sendRequest({ + method: 'post', + path: enrollmentAPIKeyRouteService.getCreatePath(), + body, + }); +} diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/agent_policy_selection.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/agent_policy_selection.tsx index bcedb23b32d5d..4edc1121b1091 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/agent_policy_selection.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/agent_policy_selection.tsx @@ -8,21 +8,25 @@ import React, { useState, useEffect } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiSelect, EuiSpacer, EuiText, EuiButtonEmpty } from '@elastic/eui'; +import { EuiButtonEmpty, EuiButton, EuiCallOut, EuiSelect, EuiSpacer, EuiText } from '@elastic/eui'; import { SO_SEARCH_LIMIT } from '../../../../constants'; import type { AgentPolicy, GetEnrollmentAPIKeysResponse } from '../../../../types'; -import { sendGetEnrollmentAPIKeys, useStartServices } from '../../../../hooks'; +import { + sendGetEnrollmentAPIKeys, + useStartServices, + sendCreateEnrollmentAPIKey, +} from '../../../../hooks'; import { AgentPolicyPackageBadges } from '../agent_policy_package_badges'; type Props = { agentPolicies?: AgentPolicy[]; - onAgentPolicyChange?: (key: string) => void; + onAgentPolicyChange?: (key?: string) => void; excludeFleetServer?: boolean; } & ( | { withKeySelection: true; - onKeyChange?: (key: string) => void; + onKeyChange?: (key?: string) => void; } | { withKeySelection: false; @@ -38,6 +42,8 @@ export const EnrollmentStepAgentPolicy: React.FC = (props) => { const [enrollmentAPIKeys, setEnrollmentAPIKeys] = useState( [] ); + const [isLoadingEnrollmentKey, setIsLoadingEnrollmentKey] = useState(false); + const [selectedState, setSelectedState] = useState<{ agentPolicyId?: string; enrollmentAPIKeyId?: string; @@ -45,7 +51,7 @@ export const EnrollmentStepAgentPolicy: React.FC = (props) => { useEffect( function triggerOnAgentPolicyChangeEffect() { - if (onAgentPolicyChange && selectedState.agentPolicyId) { + if (onAgentPolicyChange) { onAgentPolicyChange(selectedState.agentPolicyId); } }, @@ -58,7 +64,7 @@ export const EnrollmentStepAgentPolicy: React.FC = (props) => { return; } - if (selectedState.enrollmentAPIKeyId) { + if (onKeyChange) { onKeyChange(selectedState.enrollmentAPIKeyId); } }, @@ -94,6 +100,7 @@ export const EnrollmentStepAgentPolicy: React.FC = (props) => { return; } if (!selectedState.agentPolicyId) { + setIsAuthenticationSettingsOpen(true); setEnrollmentAPIKeys([]); return; } @@ -204,28 +211,89 @@ export const EnrollmentStepAgentPolicy: React.FC = (props) => { {isAuthenticationSettingsOpen && ( <> - ({ - value: key.id, - text: key.name, - }))} - value={selectedState.enrollmentAPIKeyId || undefined} - prepend={ - + {enrollmentAPIKeys.length && selectedState.enrollmentAPIKeyId ? ( + ({ + value: key.id, + text: key.name, + }))} + value={selectedState.enrollmentAPIKeyId || undefined} + prepend={ + + + + } + onChange={(e) => { + setSelectedState({ + ...selectedState, + enrollmentAPIKeyId: e.target.value, + }); + }} + /> + ) : ( + +
+ +
+ + { + setIsLoadingEnrollmentKey(true); + if (selectedState.agentPolicyId) { + sendCreateEnrollmentAPIKey({ policy_id: selectedState.agentPolicyId }) + .then((res) => { + if (res.error) { + throw res.error; + } + setIsLoadingEnrollmentKey(false); + if (res.data?.item) { + setEnrollmentAPIKeys([res.data.item]); + setSelectedState({ + ...selectedState, + enrollmentAPIKeyId: res.data.item.id, + }); + notifications.toasts.addSuccess( + i18n.translate('xpack.fleet.newEnrollmentKey.keyCreatedToasts', { + defaultMessage: 'Enrollment token created', + }) + ); + } + }) + .catch((error) => { + setIsLoadingEnrollmentKey(false); + notifications.toasts.addError(error, { + title: 'Error', + }); + }); + } + }} + > -
- } - onChange={(e) => { - setSelectedState({ - ...selectedState, - enrollmentAPIKeyId: e.target.value, - }); - }} - /> + + + )} )} diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/managed_instructions.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/managed_instructions.tsx index 0158af2d78470..df1630abfab47 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/managed_instructions.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/managed_instructions.tsx @@ -18,6 +18,8 @@ import { useLink, useFleetStatus, } from '../../../../hooks'; +import { NewEnrollmentTokenModal } from '../../enrollment_token_list_page/components/new_enrollment_key_modal'; + import { ManualInstructions } from '../../../../components/enrollment_instructions'; import { FleetServerRequirementPage, @@ -99,7 +101,7 @@ export const ManagedInstructions = React.memo(({ agentPolicies }) => { title: i18n.translate('xpack.fleet.agentEnrollment.stepEnrollAndRunAgentTitle', { defaultMessage: 'Enroll and start the Elastic Agent', }), - children: apiKey.data && ( + children: selectedAPIKeyId && apiKey.data && ( ), }); @@ -107,12 +109,18 @@ export const ManagedInstructions = React.memo(({ agentPolicies }) => { return baseSteps; }, [ agentPolicies, + selectedAPIKeyId, apiKey.data, isFleetServerPolicySelected, settings.data?.item?.fleet_server_hosts, fleetServerInstructions, ]); + const [isModalOpen, setModalOpen] = useState(false); + const closeModal = () => { + setModalOpen(false); + }; + return ( <> {fleetStatus.isReady ? ( @@ -125,6 +133,10 @@ export const ManagedInstructions = React.memo(({ agentPolicies }) => { + + {isModalOpen && ( + + )} ) : fleetStatus.missingRequirements?.length === 1 && fleetStatus.missingRequirements[0] === 'fleet_server' ? ( diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/steps.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/steps.tsx index 6a446e888a19f..8ba0098b3d277 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/steps.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/steps.tsx @@ -54,8 +54,8 @@ export const AgentPolicySelectionStep = ({ excludeFleetServer, }: { agentPolicies?: AgentPolicy[]; - setSelectedAPIKeyId?: (key: string) => void; - setSelectedPolicyId?: (policyId: string) => void; + setSelectedAPIKeyId?: (key?: string) => void; + setSelectedPolicyId?: (policyId?: string) => void; setIsFleetServerPolicySelected?: (selected: boolean) => void; excludeFleetServer?: boolean; }) => { @@ -67,11 +67,11 @@ export const AgentPolicySelectionStep = ({ : []; const onAgentPolicyChange = useCallback( - async (policyId: string) => { + async (policyId?: string) => { if (setSelectedPolicyId) { setSelectedPolicyId(policyId); } - if (setIsFleetServerPolicySelected) { + if (policyId && setIsFleetServerPolicySelected) { const agentPolicyRequest = await sendGetOneAgentPolicy(policyId); if ( agentPolicyRequest.data?.item && diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/enrollment_token_list_page/components/new_enrollment_key_flyout.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/enrollment_token_list_page/components/new_enrollment_key_modal.tsx similarity index 50% rename from x-pack/plugins/fleet/public/applications/fleet/sections/agents/enrollment_token_list_page/components/new_enrollment_key_flyout.tsx rename to x-pack/plugins/fleet/public/applications/fleet/sections/agents/enrollment_token_list_page/components/new_enrollment_key_modal.tsx index 7fae295d0d5b4..29e130f5583ab 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/enrollment_token_list_page/components/new_enrollment_key_flyout.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/enrollment_token_list_page/components/new_enrollment_key_modal.tsx @@ -7,32 +7,16 @@ import React, { useState } from 'react'; import { i18n } from '@kbn/i18n'; -import { - EuiFlyout, - EuiFlyoutBody, - EuiFlyoutHeader, - EuiTitle, - EuiFlexGroup, - EuiFlexItem, - EuiButtonEmpty, - EuiButton, - EuiFlyoutFooter, - EuiForm, - EuiFormRow, - EuiFieldText, - EuiSelect, -} from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiConfirmModal, EuiForm, EuiFormRow, EuiFieldText, EuiSelect } from '@elastic/eui'; -import type { AgentPolicy } from '../../../../types'; -import { useInput, useStartServices, sendRequest } from '../../../../hooks'; -import { enrollmentAPIKeyRouteService } from '../../../../services'; +import type { AgentPolicy, EnrollmentAPIKey } from '../../../../types'; +import { useInput, useStartServices, sendCreateEnrollmentAPIKey } from '../../../../hooks'; function useCreateApiKeyForm( policyIdDefaultValue: string | undefined, - onSuccess: (keyId: string) => void + onSuccess: (key: EnrollmentAPIKey) => void, + onError: (error: Error) => void ) { - const { notifications } = useStartServices(); const [isLoading, setIsLoading] = useState(false); const apiKeyNameInput = useInput(''); const policyIdInput = useInput(policyIdDefaultValue); @@ -41,31 +25,23 @@ function useCreateApiKeyForm( event.preventDefault(); setIsLoading(true); try { - const res = await sendRequest({ - method: 'post', - path: enrollmentAPIKeyRouteService.getCreatePath(), - body: JSON.stringify({ - name: apiKeyNameInput.value, - policy_id: policyIdInput.value, - }), + const res = await sendCreateEnrollmentAPIKey({ + name: apiKeyNameInput.value, + policy_id: policyIdInput.value, }); + if (res.error) { throw res.error; } policyIdInput.clear(); apiKeyNameInput.clear(); setIsLoading(false); - onSuccess(res.data.item.id); - notifications.toasts.addSuccess( - i18n.translate('xpack.fleet.newEnrollmentKey.keyCreatedToasts', { - defaultMessage: 'Enrollment token created.', - }) - ); - } catch (err) { - notifications.toasts.addError(err as Error, { - title: 'Error', - }); + if (res.data?.item) { + onSuccess(res.data.item); + } + } catch (error) { setIsLoading(false); + onError(error); } }; @@ -78,18 +54,32 @@ function useCreateApiKeyForm( } interface Props { - onClose: () => void; - agentPolicies: AgentPolicy[]; + onClose: (key?: EnrollmentAPIKey) => void; + agentPolicies?: AgentPolicy[]; } -export const NewEnrollmentTokenFlyout: React.FunctionComponent = ({ +export const NewEnrollmentTokenModal: React.FunctionComponent = ({ onClose, agentPolicies = [], }) => { + const { notifications } = useStartServices(); const policyIdDefaultValue = agentPolicies.find((agentPolicy) => agentPolicy.is_default)?.id; - const form = useCreateApiKeyForm(policyIdDefaultValue, () => { - onClose(); - }); + const form = useCreateApiKeyForm( + policyIdDefaultValue, + (key: EnrollmentAPIKey) => { + onClose(key); + notifications.toasts.addSuccess( + i18n.translate('xpack.fleet.newEnrollmentKey.keyCreatedToasts', { + defaultMessage: 'Enrollment token created', + }) + ); + }, + (error: Error) => { + notifications.toasts.addError(error, { + title: 'Error', + }); + } + ); const body = ( @@ -124,41 +114,26 @@ export const NewEnrollmentTokenFlyout: React.FunctionComponent = ({ }))} /> - - - ); return ( - - - -

- -

-
-
- {body} - - - - - - - - - -
+ onClose()} + cancelButtonText={i18n.translate('xpack.fleet.newEnrollmentKey.cancelButtonLabel', { + defaultMessage: 'Cancel', + })} + onConfirm={form.onSubmit} + confirmButtonText={i18n.translate('xpack.fleet.newEnrollmentKey.submitButton', { + defaultMessage: 'Create enrollment token', + })} + > + {body} + ); }; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/enrollment_token_list_page/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/enrollment_token_list_page/index.tsx index 6d141b0c9ebf1..66e0c338dbbbc 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/enrollment_token_list_page/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/enrollment_token_list_page/index.tsx @@ -34,7 +34,7 @@ import { import type { EnrollmentAPIKey, GetAgentPoliciesResponseItem } from '../../../types'; import { SearchBar } from '../../../components/search_bar'; -import { NewEnrollmentTokenFlyout } from './components/new_enrollment_key_flyout'; +import { NewEnrollmentTokenModal } from './components/new_enrollment_key_modal'; import { ConfirmEnrollmentTokenDelete } from './components/confirm_delete_modal'; const ApiKeyField: React.FunctionComponent<{ apiKeyId: string }> = ({ apiKeyId }) => { @@ -156,7 +156,7 @@ const DeleteButton: React.FunctionComponent<{ apiKey: EnrollmentAPIKey; refresh: export const EnrollmentTokenListPage: React.FunctionComponent<{}> = () => { useBreadcrumbs('fleet_enrollment_tokens'); - const [flyoutOpen, setFlyoutOpen] = useState(false); + const [isModalOpen, setModalOpen] = useState(false); const [search, setSearch] = useState(''); const { pagination, setPagination, pageSizeOptions } = usePagination(); @@ -270,11 +270,11 @@ export const EnrollmentTokenListPage: React.FunctionComponent<{}> = () => { return ( <> - {flyoutOpen && ( - { - setFlyoutOpen(false); + onClose={(key?: EnrollmentAPIKey) => { + setModalOpen(false); enrollmentAPIKeysRequest.resendRequest(); }} /> @@ -301,7 +301,7 @@ export const EnrollmentTokenListPage: React.FunctionComponent<{}> = () => { /> - setFlyoutOpen(true)}> + setModalOpen(true)}> Date: Fri, 4 Jun 2021 14:22:31 -0500 Subject: [PATCH 65/90] [Enterprise Search] Convert Role mappings for both apps to use flyouts (#101198) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add RoleOptionLabel component * Refactor RoleSelector to use EuiRadioGroup Previously, we used individual radio buttons in a map in the component. However the new designs have a shared label and work best in the EuiRadioGroup component. * Add reducer and actions to logic file for flyout visibility * Remove redirects in favor of refreshing lists With the existing multi-page view, we redirect after creating, editing or deleting a mapping. We now simply refresh the list after the action. Also as a part of this commit, we show a hard-coded error message if a user tries to navigate to a non-existant mapping, instead of redirecting to a 404 page * Add RoleMappingFlyout component * Refactor AttributeSelector No longer uses a panel or has the need for flex groups - Also added a test for 100% coverage * Refactor RoleMappingsTable - Use EuiButtonIcons instead of Link - Manage button now triggers flyout instead of linking to route * Remove AddRoleMappingButton We can just use an EuiButton to trigger the flyout * Convert to use RoleSelector syntax - Passes the entire array to the component instead of mapping. - Uses ‘id’ instead of ‘type’ to match EUI component - For App Search, as per design and PM direction, dropping labels for advanced and standard roles and showing them all in the same list. - Removed unused constant and i18ns * Move constants to shared Will do a lot more of this in a future PR * Remove DeleteMappingCallout - This now an action in the row - Also added tests for correct titles for 100% test coverage * Remove routers and routes - SPA FTW * No longer pass isNew as prop - Determine based on existence of Role Mapping instead * No longer need to initialze role mapping in the component This will become a flyout and the intialization will be triggered when the button in the table is clicked. * Remove flash messages This will be handled globally in the main component. * Wrap components with flyout Also add to main RoleMappings views * Add form row validation for App Search * Remove unnecessary layout components - Don’t need the panel, headings, spacer, and Flex components - Also removed constants and i18n from unused headings * Wire up handleDeleteMapping to take ID param The method now passes the ID directly from the table row action item * Add EuiPortal wrapper for flyout Without this, the flyout was was under the overlay. Hide whitespace changes on this commit * Add spacer to better match design * Update constants for new copy from design * Replace all engines/groups radio and group/engine selectors - The designs call for a radio group and a combo box, instead of separate radios and a list of checkboxes - Also added a spacer to each layout * Remove util that is no longer needed - This was used for generating routes that are no longer there - Also removed unused test file from a component deleted in an earlier PR - Fix test since spacer was added * Add missing i18n constant * Add back missing scoped engine check * Rename roleId -> roleMappingId * Use shared constant for “Cancel” Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../components/role_mappings/constants.ts | 60 ++-- .../components/role_mappings/index.ts | 2 +- .../role_mappings/role_mapping.test.tsx | 51 +-- .../components/role_mappings/role_mapping.tsx | 291 ++++++------------ .../role_mappings/role_mappings.test.tsx | 22 +- .../role_mappings/role_mappings.tsx | 33 +- .../role_mappings/role_mappings_logic.test.ts | 88 ++++-- .../role_mappings/role_mappings_logic.ts | 106 ++++--- .../role_mappings_router.test.tsx | 26 -- .../role_mappings/role_mappings_router.tsx | 29 -- .../components/role_mappings/utils.test.ts | 16 - .../components/role_mappings/utils.ts | 12 - .../applications/app_search/index.test.tsx | 6 +- .../public/applications/app_search/index.tsx | 4 +- .../public/applications/app_search/routes.ts | 2 - .../app_search/utils/role/types.ts | 2 +- .../add_role_mapping_button.test.tsx | 22 -- .../role_mapping/add_role_mapping_button.tsx | 22 -- .../role_mapping/attribute_selector.test.tsx | 8 + .../role_mapping/attribute_selector.tsx | 140 ++++----- .../shared/role_mapping/constants.ts | 59 ++++ .../delete_mapping_callout.test.tsx | 31 -- .../role_mapping/delete_mapping_callout.tsx | 29 -- .../applications/shared/role_mapping/index.ts | 4 +- .../role_mapping/role_mapping_flyout.test.tsx | 64 ++++ .../role_mapping/role_mapping_flyout.tsx | 90 ++++++ .../role_mapping/role_mappings_table.test.tsx | 20 +- .../role_mapping/role_mappings_table.tsx | 29 +- .../role_mapping/role_option_label.test.tsx | 24 ++ .../shared/role_mapping/role_option_label.tsx | 26 ++ .../role_mapping/role_selector.test.tsx | 30 +- .../shared/role_mapping/role_selector.tsx | 68 ++-- .../applications/workplace_search/index.tsx | 4 +- .../workplace_search/routes.test.tsx | 8 - .../applications/workplace_search/routes.ts | 3 - .../views/role_mappings/constants.ts | 42 ++- .../views/role_mappings/index.ts | 2 +- .../views/role_mappings/role_mapping.test.tsx | 51 ++- .../views/role_mappings/role_mapping.tsx | 226 +++++--------- .../role_mappings/role_mappings.test.tsx | 22 +- .../views/role_mappings/role_mappings.tsx | 29 +- .../role_mappings/role_mappings_logic.test.ts | 79 +++-- .../role_mappings/role_mappings_logic.ts | 93 ++++-- .../role_mappings_router.test.tsx | 26 -- .../role_mappings/role_mappings_router.tsx | 34 -- .../translations/translations/ja-JP.json | 14 +- .../translations/translations/zh-CN.json | 14 +- 47 files changed, 1043 insertions(+), 1020 deletions(-) delete mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_router.test.tsx delete mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_router.tsx delete mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/utils.test.ts delete mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/utils.ts delete mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/add_role_mapping_button.test.tsx delete mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/add_role_mapping_button.tsx delete mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/delete_mapping_callout.test.tsx delete mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/delete_mapping_callout.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mapping_flyout.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mapping_flyout.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_option_label.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_option_label.tsx delete mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_router.test.tsx delete mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_router.tsx diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/constants.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/constants.ts index 2f9ff707f9631..59010cb9ab8b6 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/constants.ts @@ -9,15 +9,6 @@ import { i18n } from '@kbn/i18n'; import { AdvanceRoleType } from '../../types'; -export const SAVE_ROLE_MAPPING = i18n.translate( - 'xpack.enterpriseSearch.appSearch.roleMapping.saveRoleMappingButtonLabel', - { defaultMessage: 'Save role mapping' } -); -export const UPDATE_ROLE_MAPPING = i18n.translate( - 'xpack.enterpriseSearch.appSearch.roleMapping.updateRoleMappingButtonLabel', - { defaultMessage: 'Update role mapping' } -); - export const EMPTY_ROLE_MAPPINGS_BODY = i18n.translate( 'xpack.enterpriseSearch.appSearch.roleMapping.emptyRoleMappingsBody', { @@ -126,74 +117,71 @@ export const ADMIN_ROLE_TYPE_DESCRIPTION = i18n.translate( } ); -export const ADVANCED_ROLE_SELECTORS_TITLE = i18n.translate( - 'xpack.enterpriseSearch.appSearch.advancedRoleSelectorsTitle', +export const ENGINE_REQUIRED_ERROR = i18n.translate( + 'xpack.enterpriseSearch.appSearch.engineRequiredError', { - defaultMessage: 'Full or limited engine access', + defaultMessage: 'At least one assigned engine is required.', } ); -export const ROLE_TITLE = i18n.translate('xpack.enterpriseSearch.appSearch.roleTitle', { - defaultMessage: 'Role', -}); - -export const FULL_ENGINE_ACCESS_TITLE = i18n.translate( - 'xpack.enterpriseSearch.appSearch.fullEngineAccessTitle', +export const ALL_ENGINES_LABEL = i18n.translate( + 'xpack.enterpriseSearch.appSearch.allEnginesLabel', { - defaultMessage: 'Full engine access', + defaultMessage: 'Assign to all engines', } ); -export const FULL_ENGINE_ACCESS_DESCRIPTION = i18n.translate( - 'xpack.enterpriseSearch.appSearch.fullEngineAccessDescription', +export const ALL_ENGINES_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.appSearch.allEnginesDescription', { - defaultMessage: 'Access to all current and future engines.', + defaultMessage: + 'Assigning to all engines includes all current and future engines as created and administered at a later date.', } ); -export const LIMITED_ENGINE_ACCESS_TITLE = i18n.translate( - 'xpack.enterpriseSearch.appSearch.limitedEngineAccessTitle', +export const SPECIFIC_ENGINES_LABEL = i18n.translate( + 'xpack.enterpriseSearch.appSearch.specificEnginesLabel', { - defaultMessage: 'Limited engine access', + defaultMessage: 'Assign to specific engines', } ); -export const LIMITED_ENGINE_ACCESS_DESCRIPTION = i18n.translate( - 'xpack.enterpriseSearch.appSearch.limitedEngineAccessDescription', +export const SPECIFIC_ENGINES_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.appSearch.specificEnginesDescription', { - defaultMessage: 'Limit user access to specific engines:', + defaultMessage: 'Assign to a select set of engines statically.', } ); -export const ENGINE_ACCESS_TITLE = i18n.translate( - 'xpack.enterpriseSearch.appSearch.engineAccessTitle', +export const ENGINE_ASSIGNMENT_LABEL = i18n.translate( + 'xpack.enterpriseSearch.appSearch.engineAssignmentLabel', { - defaultMessage: 'Engine access', + defaultMessage: 'Engine assignment', } ); export const ADVANCED_ROLE_TYPES = [ { - type: 'dev', + id: 'dev', description: DEV_ROLE_TYPE_DESCRIPTION, }, { - type: 'editor', + id: 'editor', description: EDITOR_ROLE_TYPE_DESCRIPTION, }, { - type: 'analyst', + id: 'analyst', description: ANALYST_ROLE_TYPE_DESCRIPTION, }, ] as AdvanceRoleType[]; export const STANDARD_ROLE_TYPES = [ { - type: 'owner', + id: 'owner', description: OWNER_ROLE_TYPE_DESCRIPTION, }, { - type: 'admin', + id: 'admin', description: ADMIN_ROLE_TYPE_DESCRIPTION, }, ] as AdvanceRoleType[]; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/index.ts index ce4b1de6e399d..19062cf44c17a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export { RoleMappingsRouter } from './role_mappings_router'; +export { RoleMappings } from './role_mappings'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mapping.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mapping.test.tsx index f50fc21d5ba58..2e179dc2b6ab3 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mapping.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mapping.test.tsx @@ -12,18 +12,16 @@ import { engines } from '../../__mocks__/engines.mock'; import React from 'react'; +import { waitFor } from '@testing-library/dom'; import { shallow } from 'enzyme'; -import { EuiCheckbox } from '@elastic/eui'; +import { EuiComboBox, EuiComboBoxOptionOption, EuiRadioGroup } from '@elastic/eui'; -import { Loading } from '../../../shared/loading'; -import { - AttributeSelector, - DeleteMappingCallout, - RoleSelector, -} from '../../../shared/role_mapping'; +import { AttributeSelector, RoleSelector } from '../../../shared/role_mapping'; import { asRoleMapping } from '../../../shared/role_mapping/__mocks__/roles'; +import { STANDARD_ROLE_TYPES } from './constants'; + import { RoleMapping } from './role_mapping'; describe('RoleMapping', () => { @@ -68,39 +66,44 @@ describe('RoleMapping', () => { }); it('renders', () => { + setMockValues({ ...mockValues, roleMapping: asRoleMapping }); const wrapper = shallow(); expect(wrapper.find(AttributeSelector)).toHaveLength(1); - expect(wrapper.find(RoleSelector)).toHaveLength(5); + expect(wrapper.find(RoleSelector)).toHaveLength(1); }); - it('returns Loading when loading', () => { - setMockValues({ ...mockValues, dataLoading: true }); + it('only passes standard role options for non-advanced roles', () => { + setMockValues({ ...mockValues, hasAdvancedRoles: false }); const wrapper = shallow(); - expect(wrapper.find(Loading)).toHaveLength(1); + expect(wrapper.find(RoleSelector).prop('roleOptions')).toHaveLength(STANDARD_ROLE_TYPES.length); }); - it('renders DeleteMappingCallout for existing mapping', () => { - setMockValues({ ...mockValues, roleMapping: asRoleMapping }); + it('sets initial selected state when accessAllEngines is true', () => { + setMockValues({ ...mockValues, accessAllEngines: true }); const wrapper = shallow(); - expect(wrapper.find(DeleteMappingCallout)).toHaveLength(1); + expect(wrapper.find(EuiRadioGroup).prop('idSelected')).toBe('all'); }); - it('hides DeleteMappingCallout for new mapping', () => { - const wrapper = shallow(); + it('handles all/specific engines radio change', () => { + const wrapper = shallow(); + const radio = wrapper.find(EuiRadioGroup); + radio.simulate('change', { target: { checked: false } }); - expect(wrapper.find(DeleteMappingCallout)).toHaveLength(0); + expect(actions.handleAccessAllEnginesChange).toHaveBeenCalledWith(false); }); - it('handles engine checkbox click', () => { + it('handles engine checkbox click', async () => { const wrapper = shallow(); - wrapper - .find(EuiCheckbox) - .first() - .simulate('change', { target: { checked: true } }); - - expect(actions.handleEngineSelectionChange).toHaveBeenCalledWith(engines[0].name, true); + await waitFor(() => + ((wrapper.find(EuiComboBox).props() as unknown) as { + onChange: (a: EuiComboBoxOptionOption[]) => void; + }).onChange([{ label: engines[0].name, value: engines[0].name }]) + ); + wrapper.update(); + + expect(actions.handleEngineSelectionChange).toHaveBeenCalledWith([engines[0].name]); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mapping.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mapping.tsx index 610ceae8856f2..0f201889b2f05 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mapping.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mapping.tsx @@ -5,65 +5,36 @@ * 2.0. */ -import React, { useEffect } from 'react'; - -import { useParams } from 'react-router-dom'; +import React from 'react'; import { useActions, useValues } from 'kea'; -import { - EuiButton, - EuiCheckbox, - EuiFlexGroup, - EuiFlexItem, - EuiFormRow, - EuiPageContentBody, - EuiPageHeader, - EuiPanel, - EuiRadio, - EuiSpacer, - EuiTitle, -} from '@elastic/eui'; - -import { FlashMessages } from '../../../shared/flash_messages'; -import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; -import { Loading } from '../../../shared/loading'; +import { EuiComboBox, EuiFormRow, EuiHorizontalRule, EuiRadioGroup, EuiSpacer } from '@elastic/eui'; + import { AttributeSelector, - DeleteMappingCallout, RoleSelector, + RoleOptionLabel, + RoleMappingFlyout, } from '../../../shared/role_mapping'; -import { - ROLE_MAPPINGS_TITLE, - ADD_ROLE_MAPPING_TITLE, - MANAGE_ROLE_MAPPING_TITLE, -} from '../../../shared/role_mapping/constants'; import { AppLogic } from '../../app_logic'; +import { AdvanceRoleType } from '../../types'; import { roleHasScopedEngines } from '../../utils/role/has_scoped_engines'; -import { Engine } from '../engine/types'; import { - SAVE_ROLE_MAPPING, - UPDATE_ROLE_MAPPING, ADVANCED_ROLE_TYPES, STANDARD_ROLE_TYPES, - ADVANCED_ROLE_SELECTORS_TITLE, - ROLE_TITLE, - FULL_ENGINE_ACCESS_TITLE, - FULL_ENGINE_ACCESS_DESCRIPTION, - LIMITED_ENGINE_ACCESS_TITLE, - LIMITED_ENGINE_ACCESS_DESCRIPTION, - ENGINE_ACCESS_TITLE, + ENGINE_REQUIRED_ERROR, + ALL_ENGINES_LABEL, + ALL_ENGINES_DESCRIPTION, + SPECIFIC_ENGINES_LABEL, + SPECIFIC_ENGINES_DESCRIPTION, + ENGINE_ASSIGNMENT_LABEL, } from './constants'; import { RoleMappingsLogic } from './role_mappings_logic'; -interface RoleMappingProps { - isNew?: boolean; -} - -export const RoleMapping: React.FC = ({ isNew }) => { - const { roleId } = useParams() as { roleId: string }; +export const RoleMapping: React.FC = () => { const { myRole } = useValues(AppLogic); const { @@ -71,12 +42,10 @@ export const RoleMapping: React.FC = ({ isNew }) => { handleAttributeSelectorChange, handleAttributeValueChange, handleAuthProviderChange, - handleDeleteMapping, handleEngineSelectionChange, handleRoleChange, handleSaveMapping, - initializeRoleMapping, - resetState, + closeRoleMappingFlyout, } = useActions(RoleMappingsLogic); const { @@ -86,7 +55,6 @@ export const RoleMapping: React.FC = ({ isNew }) => { attributes, availableAuthProviders, availableEngines, - dataLoading, elasticsearchRoles, hasAdvancedRoles, multipleAuthProvidersConfig, @@ -94,154 +62,97 @@ export const RoleMapping: React.FC = ({ isNew }) => { roleType, selectedEngines, selectedAuthProviders, + selectedOptions, } = useValues(RoleMappingsLogic); - useEffect(() => { - initializeRoleMapping(roleId); - return resetState; - }, []); - - if (dataLoading) return ; - - const SAVE_ROLE_MAPPING_LABEL = isNew ? SAVE_ROLE_MAPPING : UPDATE_ROLE_MAPPING; - const TITLE = isNew ? ADD_ROLE_MAPPING_TITLE : MANAGE_ROLE_MAPPING_TITLE; - - const saveRoleMappingButton = ( - - {SAVE_ROLE_MAPPING_LABEL} - - ); - - const engineSelector = (engine: Engine) => ( - { - handleEngineSelectionChange(engine.name, e.target.checked); - }} - label={engine.name} - /> - ); - - const advancedRoleSelectors = ( - <> - - -

{ADVANCED_ROLE_SELECTORS_TITLE}

-
- - {ADVANCED_ROLE_TYPES.map(({ type, description }) => ( - 0 || accessAllEngines; + + const mapRoleOptions = ({ id, description }: AdvanceRoleType) => ({ + id, + description, + disabled: !myRole.availableRoleTypes.includes(id), + }); + + const standardRoleOptions = STANDARD_ROLE_TYPES.map(mapRoleOptions); + const advancedRoleOptions = ADVANCED_ROLE_TYPES.map(mapRoleOptions); + + const roleOptions = hasAdvancedRoles + ? [...standardRoleOptions, ...advancedRoleOptions] + : standardRoleOptions; + + const engineOptions = [ + { + id: 'all', + label: , + }, + { + id: 'specific', + label: ( + - ))} - - ); + ), + }, + ]; return ( - <> - - - - - - - - - - - -

{ROLE_TITLE}

-
- - -

{FULL_ENGINE_ACCESS_TITLE}

-
- - {STANDARD_ROLE_TYPES.map(({ type, description }) => ( - - ))} - {hasAdvancedRoles && advancedRoleSelectors} -
-
- {hasAdvancedRoles && ( - - - -

{ENGINE_ACCESS_TITLE}

-
- - - - -

{FULL_ENGINE_ACCESS_TITLE}

-
-

{FULL_ENGINE_ACCESS_DESCRIPTION}

- - } - /> -
- - <> - - -

{LIMITED_ENGINE_ACCESS_TITLE}

-
-

{LIMITED_ENGINE_ACCESS_DESCRIPTION}

- - } - /> - {!accessAllEngines && ( -
- {availableEngines.map((engine) => engineSelector(engine))} -
- )} - -
-
-
- )} -
- - {roleMapping && } -
- + + + + + + {hasAdvancedRoles && ( + <> + + + handleAccessAllEnginesChange(id === 'all')} + legend={{ + children: {ENGINE_ASSIGNMENT_LABEL}, + }} + /> + + + ({ label: name, value: name }))} + onChange={(options) => { + handleEngineSelectionChange(options.map(({ value }) => value as string)); + }} + fullWidth + isDisabled={accessAllEngines || !roleHasScopedEngines(roleType)} + /> + + + )} + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings.test.tsx index c6da903e20912..4ccb1fec0f034 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings.test.tsx @@ -12,16 +12,19 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { EuiEmptyPrompt } from '@elastic/eui'; +import { EuiButton, EuiEmptyPrompt } from '@elastic/eui'; import { Loading } from '../../../shared/loading'; import { RoleMappingsTable } from '../../../shared/role_mapping'; import { wsRoleMapping } from '../../../shared/role_mapping/__mocks__/roles'; +import { RoleMapping } from './role_mapping'; import { RoleMappings } from './role_mappings'; describe('RoleMappings', () => { const initializeRoleMappings = jest.fn(); + const initializeRoleMapping = jest.fn(); + const handleDeleteMapping = jest.fn(); const mockValues = { roleMappings: [wsRoleMapping], dataLoading: false, @@ -31,6 +34,8 @@ describe('RoleMappings', () => { beforeEach(() => { setMockActions({ initializeRoleMappings, + initializeRoleMapping, + handleDeleteMapping, }); setMockValues(mockValues); }); @@ -54,4 +59,19 @@ describe('RoleMappings', () => { expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); }); + + it('renders RoleMapping flyout', () => { + setMockValues({ ...mockValues, roleMappingFlyoutOpen: true }); + const wrapper = shallow(); + + expect(wrapper.find(RoleMapping)).toHaveLength(1); + }); + + it('handles button click', () => { + setMockValues({ ...mockValues, roleMappings: [] }); + const wrapper = shallow(); + wrapper.find(EuiEmptyPrompt).dive().find(EuiButton).simulate('click'); + + expect(initializeRoleMapping).toHaveBeenCalled(); + }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings.tsx index 86e2e51d29a7d..61ed70f515f6f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings.tsx @@ -10,6 +10,7 @@ import React, { useEffect } from 'react'; import { useActions, useValues } from 'kea'; import { + EuiButton, EuiEmptyPrompt, EuiPageContent, EuiPageContentBody, @@ -20,22 +21,31 @@ import { import { FlashMessages } from '../../../shared/flash_messages'; import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; import { Loading } from '../../../shared/loading'; -import { AddRoleMappingButton, RoleMappingsTable } from '../../../shared/role_mapping'; +import { RoleMappingsTable } from '../../../shared/role_mapping'; import { EMPTY_ROLE_MAPPINGS_TITLE, + ROLE_MAPPING_ADD_BUTTON, ROLE_MAPPINGS_TITLE, ROLE_MAPPINGS_DESCRIPTION, } from '../../../shared/role_mapping/constants'; -import { ROLE_MAPPING_NEW_PATH } from '../../routes'; - import { ROLE_MAPPINGS_ENGINE_ACCESS_HEADING, EMPTY_ROLE_MAPPINGS_BODY } from './constants'; +import { RoleMapping } from './role_mapping'; import { RoleMappingsLogic } from './role_mappings_logic'; -import { generateRoleMappingPath } from './utils'; export const RoleMappings: React.FC = () => { - const { initializeRoleMappings, resetState } = useActions(RoleMappingsLogic); - const { roleMappings, multipleAuthProvidersConfig, dataLoading } = useValues(RoleMappingsLogic); + const { + initializeRoleMappings, + initializeRoleMapping, + handleDeleteMapping, + resetState, + } = useActions(RoleMappingsLogic); + const { + roleMappings, + multipleAuthProvidersConfig, + dataLoading, + roleMappingFlyoutOpen, + } = useValues(RoleMappingsLogic); useEffect(() => { initializeRoleMappings(); @@ -44,7 +54,11 @@ export const RoleMappings: React.FC = () => { if (dataLoading) return ; - const addMappingButton = ; + const addMappingButton = ( + initializeRoleMapping()}> + {ROLE_MAPPING_ADD_BUTTON} + + ); const roleMappingEmptyState = ( @@ -63,8 +77,9 @@ export const RoleMappings: React.FC = () => { accessItemKey="engines" accessHeader={ROLE_MAPPINGS_ENGINE_ACCESS_HEADING} addMappingButton={addMappingButton} - getRoleMappingPath={generateRoleMappingPath} + initializeRoleMapping={initializeRoleMapping} shouldShowAuthProvider={multipleAuthProvidersConfig} + handleDeleteMapping={handleDeleteMapping} /> ); @@ -72,6 +87,8 @@ export const RoleMappings: React.FC = () => { <> + + {roleMappingFlyoutOpen && } 0}> diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_logic.test.ts index ada17fc9a732a..d0534a2a0be59 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_logic.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { mockFlashMessageHelpers, mockHttpValues, mockKibanaValues } from '../../../__mocks__'; +import { mockFlashMessageHelpers, mockHttpValues } from '../../../__mocks__'; import { LogicMounter } from '../../../__mocks__/kea.mock'; import { engines } from '../../__mocks__/engines.mock'; @@ -13,20 +13,25 @@ import { engines } from '../../__mocks__/engines.mock'; import { nextTick } from '@kbn/test/jest'; import { asRoleMapping } from '../../../shared/role_mapping/__mocks__/roles'; -import { ANY_AUTH_PROVIDER } from '../../../shared/role_mapping/constants'; +import { ANY_AUTH_PROVIDER, ROLE_MAPPING_NOT_FOUND } from '../../../shared/role_mapping/constants'; import { RoleMappingsLogic } from './role_mappings_logic'; describe('RoleMappingsLogic', () => { const { http } = mockHttpValues; - const { navigateToUrl } = mockKibanaValues; - const { clearFlashMessages, flashAPIErrors, setSuccessMessage } = mockFlashMessageHelpers; + const { + clearFlashMessages, + flashAPIErrors, + setSuccessMessage, + setErrorMessage, + } = mockFlashMessageHelpers; const { mount } = new LogicMounter(RoleMappingsLogic); const DEFAULT_VALUES = { attributes: [], availableAuthProviders: [], elasticsearchRoles: [], roleMapping: null, + roleMappingFlyoutOpen: false, roleMappings: [], roleType: 'owner', attributeValue: '', @@ -38,6 +43,7 @@ describe('RoleMappingsLogic', () => { selectedEngines: new Set(), accessAllEngines: true, selectedAuthProviders: [ANY_AUTH_PROVIDER], + selectedOptions: [], }; const mappingsServerProps = { multipleAuthProvidersConfig: true, roleMappings: [asRoleMapping] }; @@ -87,6 +93,10 @@ describe('RoleMappingsLogic', () => { attributeValue: 'superuser', elasticsearchRoles: mappingServerProps.elasticsearchRoles, selectedEngines: new Set(engines.map((e) => e.name)), + selectedOptions: [ + { label: engines[0].name, value: engines[0].name }, + { label: engines[1].name, value: engines[1].name }, + ], }); }); @@ -134,21 +144,21 @@ describe('RoleMappingsLogic', () => { }); it('handles adding an engine to selected engines', () => { - RoleMappingsLogic.actions.handleEngineSelectionChange(otherEngine.name, true); + RoleMappingsLogic.actions.handleEngineSelectionChange([engine.name, otherEngine.name]); expect(RoleMappingsLogic.values.selectedEngines).toEqual( new Set([engine.name, otherEngine.name]) ); }); it('handles removing an engine from selected engines', () => { - RoleMappingsLogic.actions.handleEngineSelectionChange(otherEngine.name, false); + RoleMappingsLogic.actions.handleEngineSelectionChange([engine.name]); expect(RoleMappingsLogic.values.selectedEngines).toEqual(new Set([engine.name])); }); }); it('handleAccessAllEnginesChange', () => { - RoleMappingsLogic.actions.handleAccessAllEnginesChange(); + RoleMappingsLogic.actions.handleAccessAllEnginesChange(false); expect(RoleMappingsLogic.values).toEqual({ ...DEFAULT_VALUES, @@ -250,6 +260,25 @@ describe('RoleMappingsLogic', () => { expect(RoleMappingsLogic.values).toEqual(DEFAULT_VALUES); expect(clearFlashMessages).toHaveBeenCalled(); }); + + it('openRoleMappingFlyout', () => { + mount(mappingServerProps); + RoleMappingsLogic.actions.openRoleMappingFlyout(); + + expect(RoleMappingsLogic.values.roleMappingFlyoutOpen).toEqual(true); + expect(clearFlashMessages).toHaveBeenCalled(); + }); + + it('closeRoleMappingFlyout', () => { + mount({ + ...mappingServerProps, + roleMappingFlyoutOpen: true, + }); + RoleMappingsLogic.actions.closeRoleMappingFlyout(); + + expect(RoleMappingsLogic.values.roleMappingFlyoutOpen).toEqual(false); + expect(clearFlashMessages).toHaveBeenCalled(); + }); }); describe('listeners', () => { @@ -302,12 +331,12 @@ describe('RoleMappingsLogic', () => { expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); - it('redirects when there is a 404 status', async () => { + it('shows error when there is a 404 status', async () => { http.get.mockReturnValue(Promise.reject({ status: 404 })); RoleMappingsLogic.actions.initializeRoleMapping(); await nextTick(); - expect(navigateToUrl).toHaveBeenCalled(); + expect(setErrorMessage).toHaveBeenCalledWith(ROLE_MAPPING_NOT_FOUND); }); }); @@ -322,8 +351,12 @@ describe('RoleMappingsLogic', () => { engines: [], }; - it('calls API and navigates when new mapping', async () => { + it('calls API and refreshes list when new mapping', async () => { mount(mappingsServerProps); + const initializeRoleMappingsSpy = jest.spyOn( + RoleMappingsLogic.actions, + 'initializeRoleMappings' + ); http.post.mockReturnValue(Promise.resolve(mappingServerProps)); RoleMappingsLogic.actions.handleSaveMapping(); @@ -333,11 +366,15 @@ describe('RoleMappingsLogic', () => { }); await nextTick(); - expect(navigateToUrl).toHaveBeenCalled(); + expect(initializeRoleMappingsSpy).toHaveBeenCalled(); }); - it('calls API and navigates when existing mapping', async () => { + it('calls API and refreshes list when existing mapping', async () => { mount(mappingServerProps); + const initializeRoleMappingsSpy = jest.spyOn( + RoleMappingsLogic.actions, + 'initializeRoleMappings' + ); http.put.mockReturnValue(Promise.resolve(mappingServerProps)); RoleMappingsLogic.actions.handleSaveMapping(); @@ -347,7 +384,7 @@ describe('RoleMappingsLogic', () => { }); await nextTick(); - expect(navigateToUrl).toHaveBeenCalled(); + expect(initializeRoleMappingsSpy).toHaveBeenCalled(); expect(setSuccessMessage).toHaveBeenCalled(); }); @@ -383,6 +420,7 @@ describe('RoleMappingsLogic', () => { describe('handleDeleteMapping', () => { let confirmSpy: any; + const roleMappingId = 'r1'; beforeEach(() => { confirmSpy = jest.spyOn(window, 'confirm'); @@ -393,30 +431,26 @@ describe('RoleMappingsLogic', () => { confirmSpy.mockRestore(); }); - it('returns when no mapping', () => { - RoleMappingsLogic.actions.handleDeleteMapping(); - - expect(http.delete).not.toHaveBeenCalled(); - }); - - it('calls API and navigates', async () => { + it('calls API and refreshes list', async () => { mount(mappingServerProps); + const initializeRoleMappingsSpy = jest.spyOn( + RoleMappingsLogic.actions, + 'initializeRoleMappings' + ); http.delete.mockReturnValue(Promise.resolve({})); - RoleMappingsLogic.actions.handleDeleteMapping(); + RoleMappingsLogic.actions.handleDeleteMapping(roleMappingId); - expect(http.delete).toHaveBeenCalledWith( - `/api/app_search/role_mappings/${asRoleMapping.id}` - ); + expect(http.delete).toHaveBeenCalledWith(`/api/app_search/role_mappings/${roleMappingId}`); await nextTick(); - expect(navigateToUrl).toHaveBeenCalled(); + expect(initializeRoleMappingsSpy).toHaveBeenCalled(); expect(setSuccessMessage).toHaveBeenCalled(); }); it('handles error', async () => { mount(mappingServerProps); http.delete.mockReturnValue(Promise.reject('this is an error')); - RoleMappingsLogic.actions.handleDeleteMapping(); + RoleMappingsLogic.actions.handleDeleteMapping(roleMappingId); await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); @@ -425,7 +459,7 @@ describe('RoleMappingsLogic', () => { it('will do nothing if not confirmed', () => { mount(mappingServerProps); jest.spyOn(window, 'confirm').mockReturnValueOnce(false); - RoleMappingsLogic.actions.handleDeleteMapping(); + RoleMappingsLogic.actions.handleDeleteMapping(roleMappingId); expect(http.delete).not.toHaveBeenCalled(); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_logic.ts index 00b944d91cbcb..6981f48159a4e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_logic.ts @@ -7,16 +7,17 @@ import { kea, MakeLogicType } from 'kea'; +import { EuiComboBoxOptionOption } from '@elastic/eui'; + import { clearFlashMessages, flashAPIErrors, setSuccessMessage, + setErrorMessage, } from '../../../shared/flash_messages'; import { HttpLogic } from '../../../shared/http'; -import { KibanaLogic } from '../../../shared/kibana'; -import { ANY_AUTH_PROVIDER } from '../../../shared/role_mapping/constants'; +import { ANY_AUTH_PROVIDER, ROLE_MAPPING_NOT_FOUND } from '../../../shared/role_mapping/constants'; import { AttributeName } from '../../../shared/types'; -import { ROLE_MAPPINGS_PATH } from '../../routes'; import { ASRoleMapping, RoleTypes } from '../../types'; import { roleHasScopedEngines } from '../../utils/role/has_scoped_engines'; import { Engine } from '../engine/types'; @@ -49,28 +50,24 @@ const getFirstAttributeValue = (roleMapping: ASRoleMapping) => Object.entries(roleMapping.rules)[0][1] as AttributeName; interface RoleMappingsActions { - handleAccessAllEnginesChange(): void; + handleAccessAllEnginesChange(selected: boolean): { selected: boolean }; handleAuthProviderChange(value: string[]): { value: string[] }; handleAttributeSelectorChange( value: AttributeName, firstElasticsearchRole: string ): { value: AttributeName; firstElasticsearchRole: string }; handleAttributeValueChange(value: string): { value: string }; - handleDeleteMapping(): void; - handleEngineSelectionChange( - engineName: string, - selected: boolean - ): { - engineName: string; - selected: boolean; - }; + handleDeleteMapping(roleMappingId: string): { roleMappingId: string }; + handleEngineSelectionChange(engineNames: string[]): { engineNames: string[] }; handleRoleChange(roleType: RoleTypes): { roleType: RoleTypes }; handleSaveMapping(): void; - initializeRoleMapping(roleId?: string): { roleId?: string }; + initializeRoleMapping(roleMappingId?: string): { roleMappingId?: string }; initializeRoleMappings(): void; resetState(): void; setRoleMappingData(data: RoleMappingServerDetails): RoleMappingServerDetails; setRoleMappingsData(data: RoleMappingsServerDetails): RoleMappingsServerDetails; + openRoleMappingFlyout(): void; + closeRoleMappingFlyout(): void; } interface RoleMappingsValues { @@ -89,6 +86,8 @@ interface RoleMappingsValues { roleType: RoleTypes; selectedAuthProviders: string[]; selectedEngines: Set; + roleMappingFlyoutOpen: boolean; + selectedOptions: EuiComboBoxOptionOption[]; } export const RoleMappingsLogic = kea>({ @@ -98,21 +97,20 @@ export const RoleMappingsLogic = kea data, handleAuthProviderChange: (value: string) => ({ value }), handleRoleChange: (roleType: RoleTypes) => ({ roleType }), - handleEngineSelectionChange: (engineName: string, selected: boolean) => ({ - engineName, - selected, - }), + handleEngineSelectionChange: (engineNames: string[]) => ({ engineNames }), handleAttributeSelectorChange: (value: string, firstElasticsearchRole: string) => ({ value, firstElasticsearchRole, }), handleAttributeValueChange: (value: string) => ({ value }), - handleAccessAllEnginesChange: true, + handleAccessAllEnginesChange: (selected: boolean) => ({ selected }), resetState: true, initializeRoleMappings: true, - initializeRoleMapping: (roleId) => ({ roleId }), - handleDeleteMapping: true, + initializeRoleMapping: (roleMappingId) => ({ roleMappingId }), + handleDeleteMapping: (roleMappingId: string) => ({ roleMappingId }), handleSaveMapping: true, + openRoleMappingFlyout: true, + closeRoleMappingFlyout: false, }, reducers: { dataLoading: [ @@ -169,6 +167,7 @@ export const RoleMappingsLogic = kea roleMapping || null, resetState: () => null, + closeRoleMappingFlyout: () => null, }, ], roleType: [ @@ -185,7 +184,7 @@ export const RoleMappingsLogic = kea roleMapping ? roleMapping.accessAllEngines : true, handleRoleChange: (_, { roleType }) => !roleHasScopedEngines(roleType), - handleAccessAllEnginesChange: (accessAllEngines) => !accessAllEngines, + handleAccessAllEnginesChange: (_, { selected }) => selected, }, ], attributeValue: [ @@ -197,6 +196,7 @@ export const RoleMappingsLogic = kea value, resetState: () => '', + closeRoleMappingFlyout: () => '', }, ], attributeName: [ @@ -206,6 +206,7 @@ export const RoleMappingsLogic = kea value, resetState: () => 'username', + closeRoleMappingFlyout: () => 'username', }, ], selectedEngines: [ @@ -214,13 +215,9 @@ export const RoleMappingsLogic = kea roleMapping ? new Set(roleMapping.engines.map((engine) => engine.name)) : new Set(), handleAccessAllEnginesChange: () => new Set(), - handleEngineSelectionChange: (engines, { engineName, selected }) => { - const newSelectedEngineNames = new Set(engines as Set); - if (selected) { - newSelectedEngineNames.add(engineName); - } else { - newSelectedEngineNames.delete(engineName); - } + handleEngineSelectionChange: (_, { engineNames }) => { + const newSelectedEngineNames = new Set() as Set; + engineNames.forEach((engineName) => newSelectedEngineNames.add(engineName)); return newSelectedEngineNames; }, @@ -250,7 +247,27 @@ export const RoleMappingsLogic = kea true, + closeRoleMappingFlyout: () => false, + initializeRoleMappings: () => false, + initializeRoleMapping: () => true, + }, + ], }, + selectors: ({ selectors }) => ({ + selectedOptions: [ + () => [selectors.selectedEngines, selectors.availableEngines], + (selectedEngines, availableEngines) => { + const selectedNames = Array.from(selectedEngines.values()); + return availableEngines + .filter(({ name }: { name: string }) => selectedNames.includes(name)) + .map(({ name }: { name: string }) => ({ label: name, value: name })); + }, + ], + }), listeners: ({ actions, values }) => ({ initializeRoleMappings: async () => { const { http } = HttpLogic.values; @@ -263,33 +280,31 @@ export const RoleMappingsLogic = kea { + initializeRoleMapping: async ({ roleMappingId }) => { const { http } = HttpLogic.values; - const { navigateToUrl } = KibanaLogic.values; - const route = roleId - ? `/api/app_search/role_mappings/${roleId}` + const route = roleMappingId + ? `/api/app_search/role_mappings/${roleMappingId}` : '/api/app_search/role_mappings/new'; try { const response = await http.get(route); actions.setRoleMappingData(response); } catch (e) { - navigateToUrl(ROLE_MAPPINGS_PATH); - flashAPIErrors(e); + if (e.status === 404) { + setErrorMessage(ROLE_MAPPING_NOT_FOUND); + } else { + flashAPIErrors(e); + } } }, - handleDeleteMapping: async () => { - const { roleMapping } = values; - if (!roleMapping) return; - + handleDeleteMapping: async ({ roleMappingId }) => { const { http } = HttpLogic.values; - const { navigateToUrl } = KibanaLogic.values; - const route = `/api/app_search/role_mappings/${roleMapping.id}`; + const route = `/api/app_search/role_mappings/${roleMappingId}`; if (window.confirm(DELETE_ROLE_MAPPING_MESSAGE)) { try { await http.delete(route); - navigateToUrl(ROLE_MAPPINGS_PATH); + actions.initializeRoleMappings(); setSuccessMessage(ROLE_MAPPING_DELETED_MESSAGE); } catch (e) { flashAPIErrors(e); @@ -298,7 +313,6 @@ export const RoleMappingsLogic = kea { const { http } = HttpLogic.values; - const { navigateToUrl } = KibanaLogic.values; const { attributeName, @@ -330,7 +344,7 @@ export const RoleMappingsLogic = kea { clearFlashMessages(); }, + closeRoleMappingFlyout: () => { + clearFlashMessages(); + }, + openRoleMappingFlyout: () => { + clearFlashMessages(); + }, }), }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_router.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_router.test.tsx deleted file mode 100644 index e9fc40ba1dbb4..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_router.test.tsx +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { Route, Switch } from 'react-router-dom'; - -import { shallow } from 'enzyme'; - -import { RoleMapping } from './role_mapping'; -import { RoleMappings } from './role_mappings'; -import { RoleMappingsRouter } from './role_mappings_router'; - -describe('RoleMappingsRouter', () => { - it('renders', () => { - const wrapper = shallow(); - - expect(wrapper.find(Switch)).toHaveLength(1); - expect(wrapper.find(Route)).toHaveLength(3); - expect(wrapper.find(RoleMapping)).toHaveLength(2); - expect(wrapper.find(RoleMappings)).toHaveLength(1); - }); -}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_router.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_router.tsx deleted file mode 100644 index 7aa8b4067d9e5..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_router.tsx +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; - -import { Route, Switch } from 'react-router-dom'; - -import { ROLE_MAPPING_NEW_PATH, ROLE_MAPPING_PATH, ROLE_MAPPINGS_PATH } from '../../routes'; - -import { RoleMapping } from './role_mapping'; -import { RoleMappings } from './role_mappings'; - -export const RoleMappingsRouter: React.FC = () => ( - - - - - - - - - - - -); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/utils.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/utils.test.ts deleted file mode 100644 index e72f2b90758ac..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/utils.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { generateRoleMappingPath } from './utils'; - -describe('generateRoleMappingPath', () => { - it('generates paths with roleId filled', () => { - const roleId = 'role123'; - - expect(generateRoleMappingPath(roleId)).toEqual(`/role_mappings/${roleId}`); - }); -}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/utils.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/utils.ts deleted file mode 100644 index 109d3de1b86db..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/utils.ts +++ /dev/null @@ -1,12 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { ROLE_MAPPING_PATH } from '../../routes'; -import { generateEncodedPath } from '../../utils/encode_path_params'; - -export const generateRoleMappingPath = (roleId: string) => - generateEncodedPath(ROLE_MAPPING_PATH, { roleId }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx index 8d33bd2d130ec..08aab7af164e3 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx @@ -28,7 +28,7 @@ import { EnginesOverview } from './components/engines'; import { ErrorConnecting } from './components/error_connecting'; import { Library } from './components/library'; import { MetaEngineCreation } from './components/meta_engine_creation'; -import { RoleMappingsRouter } from './components/role_mappings'; +import { RoleMappings } from './components/role_mappings'; import { SetupGuide } from './components/setup_guide'; import { AppSearch, AppSearchUnconfigured, AppSearchConfigured, AppSearchNav } from './'; @@ -106,13 +106,13 @@ describe('AppSearchConfigured', () => { it('renders RoleMappings when canViewRoleMappings is true', () => { setMockValues({ myRole: { canViewRoleMappings: true } }); rerender(wrapper); - expect(wrapper.find(RoleMappingsRouter)).toHaveLength(1); + expect(wrapper.find(RoleMappings)).toHaveLength(1); }); it('does not render RoleMappings when user canViewRoleMappings is false', () => { setMockValues({ myRole: { canManageEngines: false } }); rerender(wrapper); - expect(wrapper.find(RoleMappingsRouter)).toHaveLength(0); + expect(wrapper.find(RoleMappings)).toHaveLength(0); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx index 9b59e0e19a5da..a491efcb234dc 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx @@ -28,7 +28,7 @@ import { ErrorConnecting } from './components/error_connecting'; import { KibanaHeaderActions } from './components/layout/kibana_header_actions'; import { Library } from './components/library'; import { MetaEngineCreation } from './components/meta_engine_creation'; -import { RoleMappingsRouter } from './components/role_mappings'; +import { RoleMappings } from './components/role_mappings'; import { Settings, SETTINGS_TITLE } from './components/settings'; import { SetupGuide } from './components/setup_guide'; import { @@ -112,7 +112,7 @@ export const AppSearchConfigured: React.FC> = (props) = {canViewRoleMappings && ( - + )} {canManageEngines && ( diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts index 872db3e149b60..c8fb009fb31da 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts @@ -16,8 +16,6 @@ export const SETTINGS_PATH = '/settings'; export const CREDENTIALS_PATH = '/credentials'; export const ROLE_MAPPINGS_PATH = '/role_mappings'; -export const ROLE_MAPPING_PATH = `${ROLE_MAPPINGS_PATH}/:roleId`; -export const ROLE_MAPPING_NEW_PATH = `${ROLE_MAPPINGS_PATH}/new`; export const ENGINES_PATH = '/engines'; export const ENGINE_CREATION_PATH = '/engine_creation'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/utils/role/types.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/utils/role/types.ts index 8aa58d08b96dd..f125a9dd13aa5 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/utils/role/types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/utils/role/types.ts @@ -52,6 +52,6 @@ export interface ASRoleMapping extends RoleMapping { } export interface AdvanceRoleType { - type: RoleTypes; + id: RoleTypes; description: string; } diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/add_role_mapping_button.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/add_role_mapping_button.test.tsx deleted file mode 100644 index a02f6c43225c0..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/add_role_mapping_button.test.tsx +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; - -import { shallow } from 'enzyme'; - -import { EuiButtonTo } from '../react_router_helpers'; - -import { AddRoleMappingButton } from './add_role_mapping_button'; - -describe('AddRoleMappingButton', () => { - it('renders', () => { - const wrapper = shallow(); - - expect(wrapper.find(EuiButtonTo)).toHaveLength(1); - }); -}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/add_role_mapping_button.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/add_role_mapping_button.tsx deleted file mode 100644 index 097302e0aa5f1..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/add_role_mapping_button.tsx +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; - -import { EuiButtonTo } from '../react_router_helpers'; - -import { ADD_ROLE_MAPPING_BUTTON } from './constants'; - -interface Props { - path: string; -} - -export const AddRoleMappingButton: React.FC = ({ path }) => ( - - {ADD_ROLE_MAPPING_BUTTON} - -); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/attribute_selector.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/attribute_selector.test.tsx index 504acf9ae1c6a..2258496464ef5 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/attribute_selector.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/attribute_selector.test.tsx @@ -114,6 +114,14 @@ describe('AttributeSelector', () => { expect(handleAuthProviderChange).toHaveBeenCalledWith(['kbn_saml']); }); + it('should call the "handleAuthProviderChange" prop with fallback when a value not present', () => { + const wrapper = shallow(); + const select = findAuthProvidersSelect(wrapper); + select.simulate('change', [{ label: 'kbn_saml' }]); + + expect(handleAuthProviderChange).toHaveBeenCalledWith(['']); + }); + it('should call the "handleAttributeSelectorChange" prop when a value is selected', () => { const wrapper = shallow(); const select = wrapper.find('[data-test-subj="ExternalAttributeSelect"]'); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/attribute_selector.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/attribute_selector.tsx index 0ee093ed934c9..bb8bf4ab1abf9 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/attribute_selector.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/attribute_selector.tsx @@ -11,13 +11,8 @@ import { EuiComboBox, EuiComboBoxOptionOption, EuiFieldText, - EuiFlexGroup, - EuiFlexItem, EuiFormRow, - EuiPanel, EuiSelect, - EuiSpacer, - EuiTitle, } from '@elastic/eui'; import { AttributeName, AttributeExamples } from '../types'; @@ -27,10 +22,6 @@ import { ANY_AUTH_PROVIDER_OPTION_LABEL, AUTH_ANY_PROVIDER_LABEL, AUTH_INDIVIDUAL_PROVIDER_LABEL, - ATTRIBUTE_SELECTOR_TITLE, - AUTH_PROVIDER_LABEL, - EXTERNAL_ATTRIBUTE_LABEL, - ATTRIBUTE_VALUE_LABEL, } from './constants'; interface Props { @@ -100,80 +91,65 @@ export const AttributeSelector: React.FC = ({ handleAuthProviderChange = () => null, }) => { return ( - - -

{ATTRIBUTE_SELECTOR_TITLE}

-
- +
{availableAuthProviders && multipleAuthProvidersConfig && ( - - - - { - handleAuthProviderChange(options.map((o) => (o as ChildOption).value)); - }} - fullWidth - isDisabled={disabled} - /> - - - - + + { + handleAuthProviderChange(options.map((o) => o.value || '')); + }} + fullWidth + isDisabled={disabled} + /> + )} - - - - ({ value: attribute, text: attribute }))} - onChange={(e) => { - handleAttributeSelectorChange(e.target.value, elasticsearchRoles[0]); - }} - fullWidth - disabled={disabled} - /> - - - - - {attributeName === 'role' ? ( - ({ - value: elasticsearchRole, - text: elasticsearchRole, - }))} - onChange={(e) => { - handleAttributeValueChange(e.target.value); - }} - fullWidth - disabled={disabled} - /> - ) : ( - { - handleAttributeValueChange(e.target.value); - }} - fullWidth - disabled={disabled} - /> - )} - - - - + + ({ value: attribute, text: attribute }))} + onChange={(e) => { + handleAttributeSelectorChange(e.target.value, elasticsearchRoles[0]); + }} + fullWidth + disabled={disabled} + /> + + + {attributeName === 'role' ? ( + ({ + value: elasticsearchRole, + text: elasticsearchRole, + }))} + onChange={(e) => { + handleAttributeValueChange(e.target.value); + }} + fullWidth + disabled={disabled} + /> + ) : ( + { + handleAttributeValueChange(e.target.value); + }} + fullWidth + disabled={disabled} + /> + )} + +
); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/constants.ts b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/constants.ts index a172fbae18d8f..7c53e37437e84 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/constants.ts @@ -132,3 +132,62 @@ export const ROLE_MAPPINGS_DESCRIPTION = i18n.translate( 'Define role mappings for elasticsearch-native and elasticsearch-saml authentication.', } ); + +export const ROLE_MAPPING_NOT_FOUND = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.notFoundMessage', + { + defaultMessage: 'No matching Role mapping found.', + } +); + +export const ROLE_MAPPING_FLYOUT_CREATE_TITLE = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.flyoutCreateTitle', + { + defaultMessage: 'Create a role mapping', + } +); + +export const ROLE_MAPPING_FLYOUT_UPDATE_TITLE = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.flyoutUpdateTitle', + { + defaultMessage: 'Update role mapping', + } +); + +export const ROLE_MAPPING_FLYOUT_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.flyoutDescription', + { + defaultMessage: 'Assign roles and permissions based on user attributes', + } +); + +export const ROLE_MAPPING_ADD_BUTTON = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.roleMappingAddButton', + { + defaultMessage: 'Add mapping', + } +); + +export const ROLE_MAPPING_FLYOUT_CREATE_BUTTON = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.roleMappingFlyoutCreateButton', + { + defaultMessage: 'Create mapping', + } +); + +export const ROLE_MAPPING_FLYOUT_UPDATE_BUTTON = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.roleMappingFlyoutUpdateButton', + { + defaultMessage: 'Update mapping', + } +); + +export const SAVE_ROLE_MAPPING = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.saveRoleMappingButtonLabel', + { defaultMessage: 'Save role mapping' } +); + +export const UPDATE_ROLE_MAPPING = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.updateRoleMappingButtonLabel', + { defaultMessage: 'Update role mapping' } +); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/delete_mapping_callout.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/delete_mapping_callout.test.tsx deleted file mode 100644 index c7556ee20e26a..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/delete_mapping_callout.test.tsx +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; - -import { shallow } from 'enzyme'; - -import { EuiButton, EuiCallOut } from '@elastic/eui'; - -import { DeleteMappingCallout } from './delete_mapping_callout'; - -describe('DeleteMappingCallout', () => { - const handleDeleteMapping = jest.fn(); - it('renders', () => { - const wrapper = shallow(); - - expect(wrapper.find(EuiCallOut)).toHaveLength(1); - expect(wrapper.find(EuiButton).prop('onClick')).toEqual(handleDeleteMapping); - }); - - it('handles button click', () => { - const wrapper = shallow(); - wrapper.find(EuiButton).simulate('click'); - - expect(handleDeleteMapping).toHaveBeenCalled(); - }); -}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/delete_mapping_callout.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/delete_mapping_callout.tsx deleted file mode 100644 index cb3c27038c566..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/delete_mapping_callout.tsx +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; - -import { EuiButton, EuiCallOut } from '@elastic/eui'; - -import { - DELETE_ROLE_MAPPING_TITLE, - DELETE_ROLE_MAPPING_DESCRIPTION, - DELETE_ROLE_MAPPING_BUTTON, -} from './constants'; - -interface Props { - handleDeleteMapping(): void; -} - -export const DeleteMappingCallout: React.FC = ({ handleDeleteMapping }) => ( - -

{DELETE_ROLE_MAPPING_DESCRIPTION}

- - {DELETE_ROLE_MAPPING_BUTTON} - -
-); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/index.ts index e6320dbb7feef..6f67bc682f333 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/index.ts @@ -5,8 +5,8 @@ * 2.0. */ -export { AddRoleMappingButton } from './add_role_mapping_button'; export { AttributeSelector } from './attribute_selector'; -export { DeleteMappingCallout } from './delete_mapping_callout'; export { RoleMappingsTable } from './role_mappings_table'; +export { RoleOptionLabel } from './role_option_label'; export { RoleSelector } from './role_selector'; +export { RoleMappingFlyout } from './role_mapping_flyout'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mapping_flyout.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mapping_flyout.test.tsx new file mode 100644 index 0000000000000..c0973bb2c9504 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mapping_flyout.test.tsx @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiFlyout } from '@elastic/eui'; + +import { + ROLE_MAPPING_FLYOUT_CREATE_TITLE, + ROLE_MAPPING_FLYOUT_UPDATE_TITLE, + ROLE_MAPPING_FLYOUT_CREATE_BUTTON, + ROLE_MAPPING_FLYOUT_UPDATE_BUTTON, +} from './constants'; +import { RoleMappingFlyout } from './role_mapping_flyout'; + +describe('RoleMappingFlyout', () => { + const closeRoleMappingFlyout = jest.fn(); + const handleSaveMapping = jest.fn(); + + const props = { + isNew: true, + disabled: false, + closeRoleMappingFlyout, + handleSaveMapping, + }; + + it('renders for new mapping', () => { + const wrapper = shallow( + +
+ + ); + + expect(wrapper.find(EuiFlyout)).toHaveLength(1); + expect(wrapper.find('[data-test-subj="FlyoutTitle"]').prop('children')).toEqual( + ROLE_MAPPING_FLYOUT_CREATE_TITLE + ); + expect(wrapper.find('[data-test-subj="FlyoutButton"]').prop('children')).toEqual( + ROLE_MAPPING_FLYOUT_CREATE_BUTTON + ); + }); + + it('renders for existing mapping', () => { + const wrapper = shallow( + +
+ + ); + + expect(wrapper.find(EuiFlyout)).toHaveLength(1); + expect(wrapper.find('[data-test-subj="FlyoutTitle"]').prop('children')).toEqual( + ROLE_MAPPING_FLYOUT_UPDATE_TITLE + ); + expect(wrapper.find('[data-test-subj="FlyoutButton"]').prop('children')).toEqual( + ROLE_MAPPING_FLYOUT_UPDATE_BUTTON + ); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mapping_flyout.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mapping_flyout.tsx new file mode 100644 index 0000000000000..bae991fef3655 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mapping_flyout.tsx @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiPortal, + EuiText, + EuiTitle, + EuiSpacer, +} from '@elastic/eui'; + +import { CANCEL_BUTTON_LABEL } from '../../shared/constants/actions'; + +import { + ROLE_MAPPING_FLYOUT_CREATE_TITLE, + ROLE_MAPPING_FLYOUT_UPDATE_TITLE, + ROLE_MAPPING_FLYOUT_DESCRIPTION, + ROLE_MAPPING_FLYOUT_CREATE_BUTTON, + ROLE_MAPPING_FLYOUT_UPDATE_BUTTON, +} from './constants'; + +interface Props { + children: React.ReactNode; + isNew: boolean; + disabled: boolean; + closeRoleMappingFlyout(): void; + handleSaveMapping(): void; +} + +export const RoleMappingFlyout: React.FC = ({ + children, + isNew, + disabled, + closeRoleMappingFlyout, + handleSaveMapping, +}) => ( + + + + +

+ {isNew ? ROLE_MAPPING_FLYOUT_CREATE_TITLE : ROLE_MAPPING_FLYOUT_UPDATE_TITLE} +

+
+ +

{ROLE_MAPPING_FLYOUT_DESCRIPTION}

+
+
+ + {children} + + + + + + {CANCEL_BUTTON_LABEL} + + + + {isNew ? ROLE_MAPPING_FLYOUT_CREATE_BUTTON : ROLE_MAPPING_FLYOUT_UPDATE_BUTTON} + + + + +
+
+); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.test.tsx index e1c43dca581fe..5ec84db478bc3 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.test.tsx @@ -18,7 +18,8 @@ import { ALL_LABEL, ANY_AUTH_PROVIDER_OPTION_LABEL } from './constants'; import { RoleMappingsTable } from './role_mappings_table'; describe('RoleMappingsTable', () => { - const getRoleMappingPath = jest.fn(); + const initializeRoleMapping = jest.fn(); + const handleDeleteMapping = jest.fn(); const roleMappings = [ { ...wsRoleMapping, @@ -36,7 +37,8 @@ describe('RoleMappingsTable', () => { roleMappings, addMappingButton: