From 932108ee95e776acc1255f8ef942c87974ddb971 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20St=C3=BCrmer?= Date: Wed, 30 Jun 2021 21:44:30 +0200 Subject: [PATCH 01/51] [RAC] Fix rule registry write flag and turn it off by default (#103646) --- .../apm/server/lib/alerts/test_utils/index.ts | 1 + x-pack/plugins/apm/server/plugin.ts | 24 +++++++++---------- x-pack/plugins/observability/server/plugin.ts | 13 ++++------ x-pack/plugins/rule_registry/server/config.ts | 2 +- .../create_rule_data_client_mock.ts | 1 + .../server/rule_data_client/index.ts | 11 +++++++++ .../server/rule_data_client/types.ts | 1 + .../server/rule_data_plugin_service/errors.ts | 14 +++++++++++ .../server/rule_data_plugin_service/index.ts | 17 ++++++++++--- .../utils/create_lifecycle_rule_type.test.ts | 19 +++++++++++++++ .../create_lifecycle_rule_type_factory.ts | 24 ++++++++++--------- .../create_persistence_rule_type_factory.ts | 2 +- .../reference_rules/__mocks__/rule_type.ts | 1 + .../security_solution/server/plugin.ts | 18 ++++++-------- .../test/apm_api_integration/configs/index.ts | 1 + 15 files changed, 100 insertions(+), 49 deletions(-) create mode 100644 x-pack/plugins/rule_registry/server/rule_data_plugin_service/errors.ts diff --git a/x-pack/plugins/apm/server/lib/alerts/test_utils/index.ts b/x-pack/plugins/apm/server/lib/alerts/test_utils/index.ts index ce1466bff01a9..9dc22844bb629 100644 --- a/x-pack/plugins/apm/server/lib/alerts/test_utils/index.ts +++ b/x-pack/plugins/apm/server/lib/alerts/test_utils/index.ts @@ -59,6 +59,7 @@ export const createRuleTypeMocks = () => { bulk: jest.fn(), }; }, + isWriteEnabled: jest.fn(() => true), } as unknown) as RuleDataClient, }, services, diff --git a/x-pack/plugins/apm/server/plugin.ts b/x-pack/plugins/apm/server/plugin.ts index 638880c9f3e4a..e617ed0510a8d 100644 --- a/x-pack/plugins/apm/server/plugin.ts +++ b/x-pack/plugins/apm/server/plugin.ts @@ -18,7 +18,6 @@ import { import { mapValues, once } from 'lodash'; import { TECHNICAL_COMPONENT_TEMPLATE_NAME } from '../../rule_registry/common/assets'; import { mappingFromFieldMap } from '../../rule_registry/common/mapping_from_field_map'; -import { RuleDataClient } from '../../rule_registry/server'; import { APMConfig, APMXPackConfig } from '.'; import { mergeConfigs } from './index'; import { UI_SETTINGS } from '../../../../src/plugins/data/common'; @@ -128,7 +127,7 @@ export class APMPlugin const getCoreStart = () => core.getStartServices().then(([coreStart]) => coreStart); - const ready = once(async () => { + const initializeRuleDataTemplates = once(async () => { const componentTemplateName = ruleDataService.getFullAssetName( 'apm-mappings' ); @@ -176,18 +175,17 @@ export class APMPlugin }); }); - ready().catch((err) => { - this.logger!.error(err); - }); + // initialize eagerly + const initializeRuleDataTemplatesPromise = initializeRuleDataTemplates().catch( + (err) => { + this.logger!.error(err); + } + ); - const ruleDataClient = new RuleDataClient({ - alias: ruleDataService.getFullAssetName('observability-apm'), - getClusterClient: async () => { - const coreStart = await getCoreStart(); - return coreStart.elasticsearch.client.asInternalUser; - }, - ready, - }); + const ruleDataClient = ruleDataService.getRuleDataClient( + ruleDataService.getFullAssetName('observability-apm'), + () => initializeRuleDataTemplatesPromise + ); const resourcePlugins = mapValues(plugins, (value, key) => { return { diff --git a/x-pack/plugins/observability/server/plugin.ts b/x-pack/plugins/observability/server/plugin.ts index 2006ce50a74cb..d820a6c0a6f76 100644 --- a/x-pack/plugins/observability/server/plugin.ts +++ b/x-pack/plugins/observability/server/plugin.ts @@ -12,7 +12,6 @@ import { CoreSetup, DEFAULT_APP_CATEGORIES, } from '../../../../src/core/server'; -import { RuleDataClient } from '../../rule_registry/server'; import { ObservabilityConfig } from '.'; import { bootstrapAnnotations, @@ -99,14 +98,10 @@ export class ObservabilityPlugin implements Plugin { const start = () => core.getStartServices().then(([coreStart]) => coreStart); - const ruleDataClient = new RuleDataClient({ - getClusterClient: async () => { - const coreStart = await start(); - return coreStart.elasticsearch.client.asInternalUser; - }, - ready: () => Promise.resolve(), - alias: plugins.ruleRegistry.ruleDataService.getFullAssetName(), - }); + const ruleDataClient = plugins.ruleRegistry.ruleDataService.getRuleDataClient( + plugins.ruleRegistry.ruleDataService.getFullAssetName(), + () => Promise.resolve() + ); registerRoutes({ core: { diff --git a/x-pack/plugins/rule_registry/server/config.ts b/x-pack/plugins/rule_registry/server/config.ts index 498b6d16a6fda..ce1d44cdb94ee 100644 --- a/x-pack/plugins/rule_registry/server/config.ts +++ b/x-pack/plugins/rule_registry/server/config.ts @@ -11,7 +11,7 @@ export const config = { schema: schema.object({ enabled: schema.boolean({ defaultValue: true }), write: schema.object({ - enabled: schema.boolean({ defaultValue: true }), + enabled: schema.boolean({ defaultValue: false }), }), index: schema.string({ defaultValue: '.alerts' }), }), diff --git a/x-pack/plugins/rule_registry/server/rule_data_client/create_rule_data_client_mock.ts b/x-pack/plugins/rule_registry/server/rule_data_client/create_rule_data_client_mock.ts index 18f3c21fafc15..59f740e0afb73 100644 --- a/x-pack/plugins/rule_registry/server/rule_data_client/create_rule_data_client_mock.ts +++ b/x-pack/plugins/rule_registry/server/rule_data_client/create_rule_data_client_mock.ts @@ -28,6 +28,7 @@ export function createRuleDataClientMock() { getWriter: jest.fn(() => ({ bulk, })), + isWriteEnabled: jest.fn(() => true), } as unknown) as Assign< RuleDataClient & Omit, 'options' | 'getClusterClient'>, { diff --git a/x-pack/plugins/rule_registry/server/rule_data_client/index.ts b/x-pack/plugins/rule_registry/server/rule_data_client/index.ts index cb336580ca354..ffc926fc74b56 100644 --- a/x-pack/plugins/rule_registry/server/rule_data_client/index.ts +++ b/x-pack/plugins/rule_registry/server/rule_data_client/index.ts @@ -9,6 +9,7 @@ import { isEmpty } from 'lodash'; import type { estypes } from '@elastic/elasticsearch'; import { ResponseError } from '@elastic/elasticsearch/lib/errors'; import { IndexPatternsFetcher } from '../../../../../src/plugins/data/server'; +import { RuleDataWriteDisabledError } from '../rule_data_plugin_service/errors'; import { IRuleDataClient, RuleDataClientConstructorOptions, @@ -28,6 +29,10 @@ export class RuleDataClient implements IRuleDataClient { return await this.options.getClusterClient(); } + isWriteEnabled(): boolean { + return this.options.isWriteEnabled; + } + getReader(options: { namespace?: string } = {}): RuleDataReader { const index = `${[this.options.alias, options.namespace].filter(Boolean).join('-')}*`; @@ -72,9 +77,15 @@ export class RuleDataClient implements IRuleDataClient { getWriter(options: { namespace?: string } = {}): RuleDataWriter { const { namespace } = options; + const isWriteEnabled = this.isWriteEnabled(); const alias = getNamespacedAlias({ alias: this.options.alias, namespace }); + return { bulk: async (request) => { + if (!isWriteEnabled) { + throw new RuleDataWriteDisabledError(); + } + const clusterClient = await this.getClusterClient(); const requestWithDefaultParameters = { diff --git a/x-pack/plugins/rule_registry/server/rule_data_client/types.ts b/x-pack/plugins/rule_registry/server/rule_data_client/types.ts index d5ce022781b0d..46a37abcd1ffc 100644 --- a/x-pack/plugins/rule_registry/server/rule_data_client/types.ts +++ b/x-pack/plugins/rule_registry/server/rule_data_client/types.ts @@ -39,6 +39,7 @@ export interface IRuleDataClient { export interface RuleDataClientConstructorOptions { getClusterClient: () => Promise; + isWriteEnabled: boolean; ready: () => Promise; alias: string; } diff --git a/x-pack/plugins/rule_registry/server/rule_data_plugin_service/errors.ts b/x-pack/plugins/rule_registry/server/rule_data_plugin_service/errors.ts new file mode 100644 index 0000000000000..cb5dcf8e8ae76 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/rule_data_plugin_service/errors.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 class RuleDataWriteDisabledError extends Error { + constructor(message?: string) { + super(message); + Object.setPrototypeOf(this, new.target.prototype); + this.name = 'RuleDataWriteDisabledError'; + } +} diff --git a/x-pack/plugins/rule_registry/server/rule_data_plugin_service/index.ts b/x-pack/plugins/rule_registry/server/rule_data_plugin_service/index.ts index 22435ef8c0203..abb56f3102a4a 100644 --- a/x-pack/plugins/rule_registry/server/rule_data_plugin_service/index.ts +++ b/x-pack/plugins/rule_registry/server/rule_data_plugin_service/index.ts @@ -16,6 +16,8 @@ import { import { ecsComponentTemplate } from '../../common/assets/component_templates/ecs_component_template'; import { defaultLifecyclePolicy } from '../../common/assets/lifecycle_policies/default_lifecycle_policy'; import { ClusterPutComponentTemplateBody, PutIndexTemplateRequest } from '../../common/types'; +import { RuleDataClient } from '../rule_data_client'; +import { RuleDataWriteDisabledError } from './errors'; const BOOTSTRAP_TIMEOUT = 60000; @@ -54,8 +56,8 @@ export class RuleDataPluginService { constructor(private readonly options: RuleDataPluginServiceConstructorOptions) {} private assertWriteEnabled() { - if (!this.isWriteEnabled) { - throw new Error('Write operations are disabled'); + if (!this.isWriteEnabled()) { + throw new RuleDataWriteDisabledError(); } } @@ -64,7 +66,7 @@ export class RuleDataPluginService { } async init() { - if (!this.isWriteEnabled) { + if (!this.isWriteEnabled()) { this.options.logger.info('Write is disabled, not installing assets'); this.signal.complete(); return; @@ -155,4 +157,13 @@ export class RuleDataPluginService { getFullAssetName(assetName?: string) { return [this.options.index, assetName].filter(Boolean).join('-'); } + + getRuleDataClient(alias: string, initialize: () => Promise) { + return new RuleDataClient({ + alias, + getClusterClient: () => this.getClusterClient(), + isWriteEnabled: this.isWriteEnabled(), + ready: initialize, + }); + } } diff --git a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type.test.ts b/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type.test.ts index a362dcccc2f0f..38ddbd3f1876b 100644 --- a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type.test.ts +++ b/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type.test.ts @@ -126,6 +126,25 @@ describe('createLifecycleRuleTypeFactory', () => { helpers = createRule(); }); + describe('when writing is disabled', () => { + beforeEach(() => { + helpers.ruleDataClientMock.isWriteEnabled.mockReturnValue(false); + }); + + it("doesn't persist anything", async () => { + await helpers.alertWithLifecycle([ + { + id: 'opbeans-java', + fields: { + 'service.name': 'opbeans-java', + }, + }, + ]); + + expect(helpers.ruleDataClientMock.getWriter().bulk).toHaveBeenCalledTimes(0); + }); + }); + describe('when alerts are new', () => { beforeEach(async () => { await helpers.alertWithLifecycle([ diff --git a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type_factory.ts b/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type_factory.ts index c2e0ae7c151ca..005af59892b8a 100644 --- a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type_factory.ts +++ b/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type_factory.ts @@ -235,16 +235,18 @@ export const createLifecycleRuleTypeFactory: CreateLifecycleRuleTypeFactory = ({ }); } - await ruleDataClient.getWriter().bulk({ - body: eventsToIndex - .flatMap((event) => [{ index: {} }, event]) - .concat( - Array.from(alertEvents.values()).flatMap((event) => [ - { index: { _id: event[ALERT_UUID]! } }, - event, - ]) - ), - }); + if (ruleDataClient.isWriteEnabled()) { + await ruleDataClient.getWriter().bulk({ + body: eventsToIndex + .flatMap((event) => [{ index: {} }, event]) + .concat( + Array.from(alertEvents.values()).flatMap((event) => [ + { index: { _id: event[ALERT_UUID]! } }, + event, + ]) + ), + }); + } } const nextTrackedAlerts = Object.fromEntries( @@ -260,7 +262,7 @@ export const createLifecycleRuleTypeFactory: CreateLifecycleRuleTypeFactory = ({ return { wrapped: nextWrappedState ?? {}, - trackedAlerts: nextTrackedAlerts, + trackedAlerts: ruleDataClient.isWriteEnabled() ? nextTrackedAlerts : {}, }; }, }; diff --git a/x-pack/plugins/rule_registry/server/utils/create_persistence_rule_type_factory.ts b/x-pack/plugins/rule_registry/server/utils/create_persistence_rule_type_factory.ts index 3f50b78151e74..9f4a6ce2e022c 100644 --- a/x-pack/plugins/rule_registry/server/utils/create_persistence_rule_type_factory.ts +++ b/x-pack/plugins/rule_registry/server/utils/create_persistence_rule_type_factory.ts @@ -100,7 +100,7 @@ export const createPersistenceRuleTypeFactory: CreatePersistenceRuleTypeFactory const numAlerts = currentAlerts.length; logger.debug(`Found ${numAlerts} alerts.`); - if (ruleDataClient && numAlerts) { + if (ruleDataClient.isWriteEnabled() && numAlerts) { await ruleDataClient.getWriter().bulk({ body: currentAlerts.flatMap((event) => [{ index: {} }, event]), }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/reference_rules/__mocks__/rule_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/reference_rules/__mocks__/rule_type.ts index f7e0dd9eb3620..6c0670d1dbb2c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/reference_rules/__mocks__/rule_type.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/reference_rules/__mocks__/rule_type.ts @@ -60,6 +60,7 @@ export const createRuleTypeMocks = () => { bulk: jest.fn(), }; }, + isWriteEnabled: jest.fn(() => true), } as unknown) as RuleDataClient, }, services, diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index cd923a4b0619f..2f523d9d9969d 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -196,9 +196,8 @@ export class Plugin implements IPlugin core.getStartServices().then(([coreStart]) => coreStart); - const ready = once(async () => { + const initializeRuleDataTemplates = once(async () => { const componentTemplateName = ruleDataService.getFullAssetName( 'security-solution-mappings' ); @@ -232,18 +231,15 @@ export class Plugin implements IPlugin { + // initialize eagerly + const initializeRuleDataTemplatesPromise = initializeRuleDataTemplates().catch((err) => { this.logger!.error(err); }); - ruleDataClient = new RuleDataClient({ - alias: plugins.ruleRegistry.ruleDataService.getFullAssetName('security-solution'), - getClusterClient: async () => { - const coreStart = await start(); - return coreStart.elasticsearch.client.asInternalUser; - }, - ready, - }); + ruleDataClient = ruleDataService.getRuleDataClient( + ruleDataService.getFullAssetName('security-solution'), + () => initializeRuleDataTemplatesPromise + ); // sec diff --git a/x-pack/test/apm_api_integration/configs/index.ts b/x-pack/test/apm_api_integration/configs/index.ts index 3393580153215..da1e06f7f2ea6 100644 --- a/x-pack/test/apm_api_integration/configs/index.ts +++ b/x-pack/test/apm_api_integration/configs/index.ts @@ -19,6 +19,7 @@ const apmFtrConfigs = { license: 'trial' as const, kibanaConfig: { 'xpack.ruleRegistry.index': '.kibana-alerts', + 'xpack.ruleRegistry.write.enabled': 'true', }, }, }; From e72f82db248e8c1859d8733311f9b2ea0bbc01b3 Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Wed, 30 Jun 2021 15:55:47 -0400 Subject: [PATCH 02/51] [Fleet] Fix action menu to close on click (#103958) --- .../agent_policy/components/actions_menu.tsx | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/actions_menu.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/actions_menu.tsx index ecc538bd95e2a..d8f13da64257b 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/actions_menu.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/actions_menu.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { memo, useState, useMemo } from 'react'; +import React, { memo, useState, useMemo, useCallback } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiContextMenuItem, EuiPortal } from '@elastic/eui'; @@ -36,6 +36,15 @@ export const AgentPolicyActionMenu = memo<{ enrollmentFlyoutOpenByDefault ); + const [isContextMenuOpen, setIsContextMenuOpen] = useState(false); + + const onContextMenuChange = useCallback( + (open: boolean) => { + setIsContextMenuOpen(open); + }, + [setIsContextMenuOpen] + ); + const onClose = useMemo(() => { if (onCancelEnrollment) { return onCancelEnrollment; @@ -50,7 +59,10 @@ export const AgentPolicyActionMenu = memo<{ const viewPolicyItem = ( setIsYamlFlyoutOpen(!isYamlFlyoutOpen)} + onClick={() => { + setIsContextMenuOpen(false); + setIsYamlFlyoutOpen(!isYamlFlyoutOpen); + }} key="viewPolicy" > setIsEnrollmentFlyoutOpen(true)} + onClick={() => { + setIsContextMenuOpen(false); + setIsEnrollmentFlyoutOpen(true); + }} key="enrollAgents" > { + setIsContextMenuOpen(false); copyAgentPolicyPrompt(agentPolicy, onCopySuccess); }} key="copyPolicy" @@ -105,6 +121,8 @@ export const AgentPolicyActionMenu = memo<{ )} Date: Wed, 30 Jun 2021 16:05:18 -0400 Subject: [PATCH 03/51] [Security Solution][Detection Rules] Fixes rule table sort not working for certain fields (#103960) --- .../detection_engine/rules/api.test.ts | 26 +++++++++++++++++++ .../containers/detection_engine/rules/api.ts | 4 ++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.test.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.test.ts index 3d1b3a422ff64..ec9ee47bcb087 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.test.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.test.ts @@ -215,6 +215,32 @@ describe('Detections Rules API', () => { }); }); + test('check parameter url, passed sort field is snake case', async () => { + await fetchRules({ + filterOptions: { + filter: '', + sortField: 'updated_at', + sortOrder: 'desc', + showCustomRules: false, + showElasticRules: false, + tags: ['hello', 'world'], + }, + signal: abortCtrl.signal, + }); + + expect(fetchMock).toHaveBeenCalledWith('/api/detection_engine/rules/_find', { + method: 'GET', + query: { + filter: 'alert.attributes.tags: "hello" AND alert.attributes.tags: "world"', + page: 1, + per_page: 20, + sort_field: 'updatedAt', + sort_order: 'desc', + }, + signal: abortCtrl.signal, + }); + }); + test('query with tags KQL parses without errors when tags contain characters such as left parenthesis (', async () => { await fetchRules({ filterOptions: { diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.ts index 7de91a07a68a0..85f6c88765158 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { camelCase } from 'lodash'; import { FullResponseSchema } from '../../../../../common/detection_engine/schemas/request'; import { HttpStart } from '../../../../../../../../src/core/public'; import { @@ -117,8 +118,9 @@ export const fetchRules = async ({ }: FetchRulesProps): Promise => { const filterString = convertRulesFilterToKQL(filterOptions); + // Sort field is camel cased because we use that in our mapping, but display snake case on the front end const getFieldNameForSortField = (field: string) => { - return field === 'name' ? `${field}.keyword` : field; + return field === 'name' ? `${field}.keyword` : camelCase(field); }; const query = { From 2f315ebbc77e94380d2735a9c8f965996c3b41de Mon Sep 17 00:00:00 2001 From: debadair Date: Wed, 30 Jun 2021 13:14:16 -0700 Subject: [PATCH 04/51] Fix links to time & byte units conventions (#103963) --- src/core/public/doc_links/doc_links_service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/public/doc_links/doc_links_service.ts b/src/core/public/doc_links/doc_links_service.ts index 1efe1e560bce1..9ab5480b809bc 100644 --- a/src/core/public/doc_links/doc_links_service.ts +++ b/src/core/public/doc_links/doc_links_service.ts @@ -328,7 +328,7 @@ export class DocLinksService { }, apis: { bulkIndexAlias: `${ELASTICSEARCH_DOCS}indices-aliases.html`, - byteSizeUnits: `${ELASTICSEARCH_DOCS}common-options.html#byte-units`, + byteSizeUnits: `${ELASTICSEARCH_DOCS}api-conventions.html#byte-units`, createAutoFollowPattern: `${ELASTICSEARCH_DOCS}ccr-put-auto-follow-pattern.html`, createFollower: `${ELASTICSEARCH_DOCS}ccr-put-follow.html`, createIndex: `${ELASTICSEARCH_DOCS}indices-create-index.html`, @@ -352,7 +352,7 @@ export class DocLinksService { putSnapshotLifecyclePolicy: `${ELASTICSEARCH_DOCS}slm-api-put-policy.html`, putWatch: `${ELASTICSEARCH_DOCS}watcher-api-put-watch.html`, simulatePipeline: `${ELASTICSEARCH_DOCS}simulate-pipeline-api.html`, - timeUnits: `${ELASTICSEARCH_DOCS}common-options.html#time-units`, + timeUnits: `${ELASTICSEARCH_DOCS}api-conventions.html#time-units`, updateTransform: `${ELASTICSEARCH_DOCS}update-transform.html`, }, plugins: { From 5fa6cdf1b21b0e8897931069d3c381b3797d7b90 Mon Sep 17 00:00:00 2001 From: Kate Farrar Date: Wed, 30 Jun 2021 14:40:33 -0600 Subject: [PATCH 05/51] [Metrics UI] [Logs UI] Updating alerts language in headers and Metrics (#103589) * updating language in metrics ui to indicate rule type and updating language in header menu to Alerts and Rules --- .../common/components/metrics_alert_dropdown.tsx | 11 +++++++---- .../log_threshold/components/alert_dropdown.tsx | 5 ++++- .../components/node_details/overlay.tsx | 2 +- .../components/waffle/node_context_menu.tsx | 4 ++-- .../components/chart_context_menu.tsx | 4 ++-- x-pack/plugins/translations/translations/ja-JP.json | 2 -- x-pack/plugins/translations/translations/zh-CN.json | 2 -- 7 files changed, 16 insertions(+), 14 deletions(-) diff --git a/x-pack/plugins/infra/public/alerting/common/components/metrics_alert_dropdown.tsx b/x-pack/plugins/infra/public/alerting/common/components/metrics_alert_dropdown.tsx index c3327dc3fe85d..cf84ea40d64cc 100644 --- a/x-pack/plugins/infra/public/alerting/common/components/metrics_alert_dropdown.tsx +++ b/x-pack/plugins/infra/public/alerting/common/components/metrics_alert_dropdown.tsx @@ -40,7 +40,7 @@ export const MetricsAlertDropdown = () => { }), items: [ { - name: i18n.translate('xpack.infra.alerting.createInventoryAlertButton', { + name: i18n.translate('xpack.infra.alerting.createInventoryRuleButton', { defaultMessage: 'Create inventory rule', }), onClick: () => setVisibleFlyoutType('inventory'), @@ -58,7 +58,7 @@ export const MetricsAlertDropdown = () => { }), items: [ { - name: i18n.translate('xpack.infra.alerting.createThresholdAlertButton', { + name: i18n.translate('xpack.infra.alerting.createThresholdRuleButton', { defaultMessage: 'Create threshold rule', }), onClick: () => setVisibleFlyoutType('threshold'), @@ -75,7 +75,7 @@ export const MetricsAlertDropdown = () => { const manageAlertsMenuItem = useMemo( () => ({ - name: i18n.translate('xpack.infra.alerting.manageAlerts', { + name: i18n.translate('xpack.infra.alerting.manageRules', { defaultMessage: 'Manage rules', }), icon: 'tableOfContents', @@ -141,7 +141,10 @@ export const MetricsAlertDropdown = () => { iconType={'arrowDown'} onClick={openPopover} > - + } isOpen={popoverOpen} diff --git a/x-pack/plugins/infra/public/alerting/log_threshold/components/alert_dropdown.tsx b/x-pack/plugins/infra/public/alerting/log_threshold/components/alert_dropdown.tsx index c1733d4af0589..f3481cab73360 100644 --- a/x-pack/plugins/infra/public/alerting/log_threshold/components/alert_dropdown.tsx +++ b/x-pack/plugins/infra/public/alerting/log_threshold/components/alert_dropdown.tsx @@ -90,7 +90,10 @@ export const AlertDropdown = () => { iconType={'arrowDown'} onClick={openPopover} > - + } isOpen={popoverOpen} diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/overlay.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/overlay.tsx index eb5a3f3f9fee1..7f6d592fce6e6 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/overlay.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/overlay.tsx @@ -119,7 +119,7 @@ export const NodeContextPopover = ({ > diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/node_context_menu.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/node_context_menu.tsx index ea80bd13e8a4d..46534f278fd45 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/node_context_menu.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/node_context_menu.tsx @@ -138,8 +138,8 @@ export const NodeContextMenu: React.FC = withTheme }; const createAlertMenuItem: SectionLinkProps = { - label: i18n.translate('xpack.infra.nodeContextMenu.createAlertLink', { - defaultMessage: 'Create alert', + label: i18n.translate('xpack.infra.nodeContextMenu.createRuleLink', { + defaultMessage: 'Create inventory rule', }), onClick: () => { setFlyoutVisible(true); diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/chart_context_menu.tsx b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/chart_context_menu.tsx index 8f281bda0229d..005dd5cc8c078 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/chart_context_menu.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/chart_context_menu.tsx @@ -155,8 +155,8 @@ export const MetricsExplorerChartContextMenu: React.FC = ({ const createAlert = uiCapabilities?.infrastructure?.save ? [ { - name: i18n.translate('xpack.infra.metricsExplorer.alerts.createAlertButton', { - defaultMessage: 'Create alert', + name: i18n.translate('xpack.infra.metricsExplorer.alerts.createRuleButton', { + defaultMessage: 'Create threshold rule', }), icon: 'bell', onClick() { diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index fb1a5026b7e01..40f7ae933a262 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -11479,7 +11479,6 @@ "xpack.infra.metricsExplorer.aggregationLables.rate": "レート", "xpack.infra.metricsExplorer.aggregationLables.sum": "合計", "xpack.infra.metricsExplorer.aggregationSelectLabel": "集約を選択してください", - "xpack.infra.metricsExplorer.alerts.createAlertButton": "アラートの作成", "xpack.infra.metricsExplorer.andLabel": "\"および\"", "xpack.infra.metricsExplorer.chartOptions.areaLabel": "エリア", "xpack.infra.metricsExplorer.chartOptions.autoLabel": "自動 (最低 ~ 最高) ", @@ -11570,7 +11569,6 @@ "xpack.infra.ml.steps.setupProcess.when.timePicker.label": "開始日", "xpack.infra.ml.steps.setupProcess.when.title": "いつモデルを開始しますか?", "xpack.infra.node.ariaLabel": "{nodeName}、クリックしてメニューを開きます", - "xpack.infra.nodeContextMenu.createAlertLink": "アラートの作成", "xpack.infra.nodeContextMenu.description": "{label} {value} の詳細を表示", "xpack.infra.nodeContextMenu.title": "{inventoryName} の詳細", "xpack.infra.nodeContextMenu.viewAPMTraces": "{inventoryName} APM トレース", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index d33212d8a2696..9545ec1729557 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -11637,7 +11637,6 @@ "xpack.infra.metricsExplorer.aggregationLables.rate": "比率", "xpack.infra.metricsExplorer.aggregationLables.sum": "求和", "xpack.infra.metricsExplorer.aggregationSelectLabel": "选择聚合", - "xpack.infra.metricsExplorer.alerts.createAlertButton": "创建告警", "xpack.infra.metricsExplorer.andLabel": "\" 且 \"", "xpack.infra.metricsExplorer.chartOptions.areaLabel": "面积图", "xpack.infra.metricsExplorer.chartOptions.autoLabel": "自动 (最小值到最大值) ", @@ -11728,7 +11727,6 @@ "xpack.infra.ml.steps.setupProcess.when.timePicker.label": "开始日期", "xpack.infra.ml.steps.setupProcess.when.title": "您的模型何时开始?", "xpack.infra.node.ariaLabel": "{nodeName},单击打开菜单", - "xpack.infra.nodeContextMenu.createAlertLink": "创建告警", "xpack.infra.nodeContextMenu.description": "查看 {label} {value} 的详情", "xpack.infra.nodeContextMenu.title": "{inventoryName} 详情", "xpack.infra.nodeContextMenu.viewAPMTraces": "{inventoryName} APM 跟踪", From bbda3a7cf1405f49e90b33bdf6366dcc97aa74f5 Mon Sep 17 00:00:00 2001 From: Zacqary Adam Xeper Date: Wed, 30 Jun 2021 15:48:38 -0500 Subject: [PATCH 06/51] [Fleet] Add autoSubmit to Fleet search bar (#103974) --- .../fleet/public/applications/fleet/components/search_bar.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/x-pack/plugins/fleet/public/applications/fleet/components/search_bar.tsx b/x-pack/plugins/fleet/public/applications/fleet/components/search_bar.tsx index f064cf1e72f18..1dbbe937c23cf 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/components/search_bar.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/components/search_bar.tsx @@ -98,6 +98,7 @@ export const SearchBar: React.FunctionComponent = ({ }} submitOnBlur isClearable + autoSubmit /> ); }; From 94142625588146329532e803eca450bb929766a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Casper=20H=C3=BCbertz?= Date: Wed, 30 Jun 2021 22:58:56 +0200 Subject: [PATCH 07/51] [APM] Update copy in modal (#103976) --- .../components/app/Settings/schema/confirm_switch_modal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/apm/public/components/app/Settings/schema/confirm_switch_modal.tsx b/x-pack/plugins/apm/public/components/app/Settings/schema/confirm_switch_modal.tsx index 47e83fa079e63..9900093253d2a 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/schema/confirm_switch_modal.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/schema/confirm_switch_modal.tsx @@ -57,7 +57,7 @@ export function ConfirmSwitchModal({

{i18n.translate('xpack.apm.settings.schema.confirm.descriptionText', { defaultMessage: - 'If you have custom dashboards, machine learning jobs, or source maps that use classic APM indices, you must reconfigure them for data streams. Stack monitoring is not currently supported with Fleet-managed APM.', + 'Please note Stack monitoring is not currently supported with Fleet-managed APM.', })}

{!hasUnsupportedConfigs && ( From 305df3ab37a278dcae878767f32c8cae9cc1fe08 Mon Sep 17 00:00:00 2001 From: Steph Milovic Date: Wed, 30 Jun 2021 15:00:56 -0600 Subject: [PATCH 08/51] [RAC] [Cases] Fix responsiveness in Cases UI (#103766) --- .../public/components/all_cases/count.tsx | 2 +- .../public/components/all_cases/header.tsx | 23 +++--- .../components/all_cases/nav_buttons.tsx | 17 +++- .../components/all_cases/status_filter.tsx | 6 +- .../components/case_action_bar/index.tsx | 39 +++++++--- .../components/case_header_page/index.tsx | 14 ---- .../public/components/case_view/index.tsx | 13 +--- .../components/edit_connector/index.tsx | 12 ++- .../editable_title.test.tsx.snap | 1 + .../components/header_page/editable_title.tsx | 2 +- .../components/property_actions/index.tsx | 77 +++++++++---------- .../public/components/tag_list/index.tsx | 31 ++++++-- .../cases/public/components/tag_list/tags.tsx | 9 ++- .../components/user_action_tree/helpers.tsx | 32 +++++--- .../user_action_content_toolbar.tsx | 6 +- .../user_action_tree/user_action_markdown.tsx | 2 +- .../public/components/user_list/index.tsx | 8 +- .../public/components/wrappers/index.tsx | 21 ++++- .../flyout/header/active_timelines.tsx | 64 ++++++++------- .../components/flyout/header/index.tsx | 8 +- 20 files changed, 226 insertions(+), 161 deletions(-) delete mode 100644 x-pack/plugins/cases/public/components/case_header_page/index.tsx diff --git a/x-pack/plugins/cases/public/components/all_cases/count.tsx b/x-pack/plugins/cases/public/components/all_cases/count.tsx index e42e52cfdc934..eb33cf1069a9b 100644 --- a/x-pack/plugins/cases/public/components/all_cases/count.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/count.tsx @@ -28,7 +28,7 @@ export const Count: FunctionComponent = ({ refresh }) => { } }, [fetchCasesStatus, refresh]); return ( - + = ({ showTitle = true, userCanCrud, }) => ( - - + + {userCanCrud ? ( <> - + - + = ({ )} - + ); diff --git a/x-pack/plugins/cases/public/components/all_cases/nav_buttons.tsx b/x-pack/plugins/cases/public/components/all_cases/nav_buttons.tsx index ec83604987180..0e55abd00a706 100644 --- a/x-pack/plugins/cases/public/components/all_cases/nav_buttons.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/nav_buttons.tsx @@ -8,11 +8,22 @@ import React, { FunctionComponent } from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { isEmpty } from 'lodash/fp'; +import styled, { css } from 'styled-components'; import { ConfigureCaseButton } from '../configure_cases/button'; import * as i18n from './translations'; import { CasesNavigation, LinkButton } from '../links'; import { ErrorMessage } from '../use_push_to_service/callout/types'; +const ButtonFlexGroup = styled(EuiFlexGroup)` + ${({ theme }) => css` + & { + @media only screen and (max-width: ${theme.eui.euiBreakpoints.s}) { + flex-direction: column; + } + } + `} +`; + interface OwnProps { actionsErrors: ErrorMessage[]; configureCasesNavigation: CasesNavigation; @@ -26,7 +37,7 @@ export const NavButtons: FunctionComponent = ({ configureCasesNavigation, createCaseNavigation, }) => ( - + = ({ titleTooltip={!isEmpty(actionsErrors) ? actionsErrors[0].title : ''} /> - + = ({ {i18n.CREATE_TITLE} - + ); diff --git a/x-pack/plugins/cases/public/components/all_cases/status_filter.tsx b/x-pack/plugins/cases/public/components/all_cases/status_filter.tsx index 7d02bf2c441d3..bb54fbe410951 100644 --- a/x-pack/plugins/cases/public/components/all_cases/status_filter.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/status_filter.tsx @@ -29,9 +29,11 @@ const StatusFilterComponent: React.FC = ({ .map((status) => ({ value: status, inputDisplay: ( - + - + + + {status !== StatusAll && {` (${stats[status]})`}} diff --git a/x-pack/plugins/cases/public/components/case_action_bar/index.tsx b/x-pack/plugins/cases/public/components/case_action_bar/index.tsx index 3448d112dadd1..af17ea0dca895 100644 --- a/x-pack/plugins/cases/public/components/case_action_bar/index.tsx +++ b/x-pack/plugins/cases/public/components/case_action_bar/index.tsx @@ -32,6 +32,10 @@ const MyDescriptionList = styled(EuiDescriptionList)` & { padding-right: ${theme.eui.euiSizeL}; border-right: ${theme.eui.euiBorderThin}; + @media only screen and (max-width: ${theme.eui.euiBreakpoints.m}) { + padding-right: 0; + border-right: 0; + } } `} `; @@ -80,9 +84,9 @@ const CaseActionBarComponent: React.FC = ({ - + {caseData.type !== CaseType.collection && ( - + {i18n.STATUS} = ({ - + {userCanCrud && !disableAlerting && ( - + - + {i18n.SYNC_ALERTS} @@ -129,10 +143,17 @@ const CaseActionBarComponent: React.FC = ({ )} - - - {i18n.CASE_REFRESH} - + + + + {i18n.CASE_REFRESH} + + {userCanCrud && ( diff --git a/x-pack/plugins/cases/public/components/case_header_page/index.tsx b/x-pack/plugins/cases/public/components/case_header_page/index.tsx deleted file mode 100644 index 7e60db1030587..0000000000000 --- a/x-pack/plugins/cases/public/components/case_header_page/index.tsx +++ /dev/null @@ -1,14 +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 { HeaderPage, HeaderPageProps } from '../header_page'; - -const CaseHeaderPageComponent: React.FC = (props) => ; - -export const CaseHeaderPage = React.memo(CaseHeaderPageComponent); diff --git a/x-pack/plugins/cases/public/components/case_view/index.tsx b/x-pack/plugins/cases/public/components/case_view/index.tsx index ac7c9ebe08b5a..a44c2cb22010e 100644 --- a/x-pack/plugins/cases/public/components/case_view/index.tsx +++ b/x-pack/plugins/cases/public/components/case_view/index.tsx @@ -26,7 +26,7 @@ import { UserActionTree } from '../user_action_tree'; import { UserList } from '../user_list'; import { useUpdateCase } from '../../containers/use_update_case'; import { getTypedPayload } from '../../containers/utils'; -import { WhitePageWrapper, HeaderWrapper } from '../wrappers'; +import { ContentWrapper, WhitePageWrapper, HeaderWrapper } from '../wrappers'; import { CaseActionBar } from '../case_action_bar'; import { useGetCaseUserActions } from '../../containers/use_get_case_user_actions'; import { EditConnector } from '../edit_connector'; @@ -41,8 +41,6 @@ import { OwnerProvider } from '../owner_context'; import { getConnectorById } from '../utils'; import { DoesNotExist } from './does_not_exist'; -const gutterTimeline = '70px'; // seems to be a timeline reference from the original file - export interface CaseViewComponentProps { allCasesNavigation: CasesNavigation; caseDetailsNavigation: CasesNavigation; @@ -75,11 +73,6 @@ export interface OnUpdateFields { onError?: () => void; } -const MyWrapper = styled.div` - padding: ${({ theme }) => - `${theme.eui.paddingSizes.l} ${theme.eui.paddingSizes.l} ${gutterTimeline} ${theme.eui.paddingSizes.l}`}; -`; - const MyEuiFlexGroup = styled(EuiFlexGroup)` height: 100%; `; @@ -404,7 +397,7 @@ export const CaseComponent = React.memo( - + {initLoadingData && ( @@ -491,7 +484,7 @@ export const CaseComponent = React.memo( /> - + {timelineUi?.renderTimelineDetailsPanel ? timelineUi.renderTimelineDetailsPanel() : null} diff --git a/x-pack/plugins/cases/public/components/edit_connector/index.tsx b/x-pack/plugins/cases/public/components/edit_connector/index.tsx index 0a20d2f5c8303..df7855fb9ce33 100644 --- a/x-pack/plugins/cases/public/components/edit_connector/index.tsx +++ b/x-pack/plugins/cases/public/components/edit_connector/index.tsx @@ -68,6 +68,9 @@ const DisappearingFlexItem = styled(EuiFlexItem)` $isHidden && ` margin: 0 !important; + & .euiFlexItem { + margin: 0 !important; + } `} `; @@ -244,7 +247,12 @@ export const EditConnector = React.memo( return ( - +

{i18n.CONNECTORS}

@@ -304,7 +312,7 @@ export const EditConnector = React.memo(
{editConnector && ( - + = ({ ) : ( - + </EuiFlexItem> diff --git a/x-pack/plugins/cases/public/components/property_actions/index.tsx b/x-pack/plugins/cases/public/components/property_actions/index.tsx index 170af5fd3b28c..9fa874344864b 100644 --- a/x-pack/plugins/cases/public/components/property_actions/index.tsx +++ b/x-pack/plugins/cases/public/components/property_actions/index.tsx @@ -22,9 +22,9 @@ const ComponentId = 'property-actions'; const PropertyActionButton = React.memo<PropertyActionButtonProps>( ({ disabled = false, onClick, iconType, label }) => ( <EuiButtonEmpty - data-test-subj={`${ComponentId}-${iconType}`} aria-label={label} color="text" + data-test-subj={`${ComponentId}-${iconType}`} iconSide="left" iconType={iconType} isDisabled={disabled} @@ -56,44 +56,43 @@ export const PropertyActions = React.memo<PropertyActionsProps>(({ propertyActio }, []); return ( - <EuiFlexGroup alignItems="flexStart" data-test-subj={ComponentId} gutterSize="none"> - <EuiFlexItem grow={false}> - <EuiPopover - anchorPosition="downRight" - ownFocus - button={ - <EuiButtonIcon - data-test-subj={`${ComponentId}-ellipses`} - aria-label={i18n.ACTIONS_ARIA} - iconType="boxesHorizontal" - onClick={onButtonClick} - /> - } - id="settingsPopover" - isOpen={showActions} - closePopover={onClosePopover} - repositionOnScroll - > - <EuiFlexGroup - alignItems="flexStart" - data-test-subj={`${ComponentId}-group`} - direction="column" - gutterSize="none" - > - {propertyActions.map((action, key) => ( - <EuiFlexItem grow={false} key={`${action.label}${key}`}> - <PropertyActionButton - disabled={action.disabled} - iconType={action.iconType} - label={action.label} - onClick={() => onClosePopover(action.onClick)} - /> - </EuiFlexItem> - ))} - </EuiFlexGroup> - </EuiPopover> - </EuiFlexItem> - </EuiFlexGroup> + <EuiPopover + anchorPosition="downRight" + data-test-subj={ComponentId} + ownFocus + button={ + <EuiButtonIcon + data-test-subj={`${ComponentId}-ellipses`} + aria-label={i18n.ACTIONS_ARIA} + iconType="boxesHorizontal" + onClick={onButtonClick} + /> + } + id="settingsPopover" + isOpen={showActions} + closePopover={onClosePopover} + repositionOnScroll + > + <EuiFlexGroup + alignItems="flexStart" + data-test-subj={`${ComponentId}-group`} + direction="column" + gutterSize="none" + > + {propertyActions.map((action, key) => ( + <EuiFlexItem grow={false} key={`${action.label}${key}`}> + <span> + <PropertyActionButton + disabled={action.disabled} + iconType={action.iconType} + label={action.label} + onClick={() => onClosePopover(action.onClick)} + /> + </span> + </EuiFlexItem> + ))} + </EuiFlexGroup> + </EuiPopover> ); }); diff --git a/x-pack/plugins/cases/public/components/tag_list/index.tsx b/x-pack/plugins/cases/public/components/tag_list/index.tsx index 4e8946a6589a3..925e198e4a478 100644 --- a/x-pack/plugins/cases/public/components/tag_list/index.tsx +++ b/x-pack/plugins/cases/public/components/tag_list/index.tsx @@ -36,6 +36,7 @@ export interface TagListProps { const MyFlexGroup = styled(EuiFlexGroup)` ${({ theme }) => css` + width: 100%; margin-top: ${theme.eui.euiSizeM}; p { font-size: ${theme.eui.euiSizeM}; @@ -43,6 +44,17 @@ const MyFlexGroup = styled(EuiFlexGroup)` `} `; +const ColumnFlexGroup = styled(EuiFlexGroup)` + ${({ theme }) => css` + & { + max-width: 100%; + @media only screen and (max-width: ${theme.eui.euiBreakpoints.m}) { + flex-direction: row; + } + } + `} +`; + export const TagList = React.memo( ({ userCanCrud = true, isLoading, onSubmit, tags }: TagListProps) => { const initialState = { tags }; @@ -80,7 +92,12 @@ export const TagList = React.memo( ); return ( <EuiText> - <EuiFlexGroup alignItems="center" gutterSize="xs" justifyContent="spaceBetween"> + <EuiFlexGroup + alignItems="center" + gutterSize="xs" + justifyContent="spaceBetween" + responsive={false} + > <EuiFlexItem grow={false}> <h4>{i18n.TAGS}</h4> </EuiFlexItem> @@ -99,9 +116,13 @@ export const TagList = React.memo( <EuiHorizontalRule margin="xs" /> <MyFlexGroup gutterSize="none" data-test-subj="case-tags"> {tags.length === 0 && !isEditTags && <p data-test-subj="no-tags">{i18n.NO_TAGS}</p>} - {!isEditTags && <Tags tags={tags} color="hollow" />} + {!isEditTags && ( + <EuiFlexItem> + <Tags tags={tags} color="hollow" /> + </EuiFlexItem> + )} {isEditTags && ( - <EuiFlexGroup data-test-subj="edit-tags" direction="column"> + <ColumnFlexGroup data-test-subj="edit-tags" direction="column"> <EuiFlexItem> <Form form={form}> <CommonUseField @@ -139,7 +160,7 @@ export const TagList = React.memo( </Form> </EuiFlexItem> <EuiFlexItem> - <EuiFlexGroup gutterSize="s" alignItems="center"> + <EuiFlexGroup gutterSize="s" alignItems="center" responsive={false}> <EuiFlexItem grow={false}> <EuiButton color="secondary" @@ -164,7 +185,7 @@ export const TagList = React.memo( </EuiFlexItem> </EuiFlexGroup> </EuiFlexItem> - </EuiFlexGroup> + </ColumnFlexGroup> )} </MyFlexGroup> </EuiText> diff --git a/x-pack/plugins/cases/public/components/tag_list/tags.tsx b/x-pack/plugins/cases/public/components/tag_list/tags.tsx index c91953c3077ca..f3b05972a24a9 100644 --- a/x-pack/plugins/cases/public/components/tag_list/tags.tsx +++ b/x-pack/plugins/cases/public/components/tag_list/tags.tsx @@ -7,21 +7,24 @@ import React, { memo } from 'react'; import { EuiBadgeGroup, EuiBadge, EuiBadgeGroupProps } from '@elastic/eui'; +import styled from 'styled-components'; interface TagsProps { tags: string[]; color?: string; gutterSize?: EuiBadgeGroupProps['gutterSize']; } - +const MyEuiBadge = styled(EuiBadge)` + max-width: 200px; +`; const TagsComponent: React.FC<TagsProps> = ({ tags, color = 'default', gutterSize }) => ( <> {tags.length > 0 && ( <EuiBadgeGroup gutterSize={gutterSize}> {tags.map((tag) => ( - <EuiBadge data-test-subj={`tag-${tag}`} color={color} key={tag}> + <MyEuiBadge data-test-subj={`tag-${tag}`} color={color} key={tag}> {tag} - </EuiBadge> + </MyEuiBadge> ))} </EuiBadgeGroup> )} diff --git a/x-pack/plugins/cases/public/components/user_action_tree/helpers.tsx b/x-pack/plugins/cases/public/components/user_action_tree/helpers.tsx index 5d234296dd503..338b8577458e3 100644 --- a/x-pack/plugins/cases/public/components/user_action_tree/helpers.tsx +++ b/x-pack/plugins/cases/public/components/user_action_tree/helpers.tsx @@ -44,8 +44,9 @@ export type ActionsNavigation = CasesNavigation<string, 'configurable'>; const getStatusTitle = (id: string, status: CaseStatuses) => ( <EuiFlexGroup gutterSize="s" - alignItems={'center'} + alignItems="center" data-test-subj={`${id}-user-action-status-title`} + responsive={false} > <EuiFlexItem grow={false}>{i18n.MARKED_CASE_AS}</EuiFlexItem> <EuiFlexItem grow={false}> @@ -110,7 +111,7 @@ const getTagsLabelTitle = (action: CaseUserActions) => { const tags = action.newValue != null ? action.newValue.split(',') : []; return ( - <EuiFlexGroup alignItems="baseline" gutterSize="xs" component="span"> + <EuiFlexGroup alignItems="baseline" gutterSize="xs" component="span" responsive={false}> <EuiFlexItem data-test-subj="ua-tags-label" grow={false}> {action.action === 'add' && i18n.ADDED_FIELD} {action.action === 'delete' && i18n.REMOVED_FIELD} {i18n.TAGS.toLowerCase()} @@ -125,7 +126,12 @@ const getTagsLabelTitle = (action: CaseUserActions) => { export const getPushedServiceLabelTitle = (action: CaseUserActions, firstPush: boolean) => { const pushedVal = JSON.parse(action.newValue ?? '') as CaseFullExternalService; return ( - <EuiFlexGroup alignItems="baseline" gutterSize="xs" data-test-subj="pushed-service-label-title"> + <EuiFlexGroup + alignItems="baseline" + gutterSize="xs" + data-test-subj="pushed-service-label-title" + responsive={false} + > <EuiFlexItem data-test-subj="pushed-label"> {`${firstPush ? i18n.PUSHED_NEW_INCIDENT : i18n.UPDATE_INCIDENT} ${ pushedVal?.connector_name @@ -190,15 +196,15 @@ export const getUpdateAction = ({ timestamp: <UserActionTimestamp createdAt={action.actionAt} />, timelineIcon: getUpdateActionIcon(action.actionField[0]), actions: ( - <EuiFlexGroup> - <EuiFlexItem> + <EuiFlexGroup responsive={false}> + <EuiFlexItem grow={false}> <UserActionCopyLink getCaseDetailHrefWithCommentId={getCaseDetailHrefWithCommentId} id={action.actionId} /> </EuiFlexItem> {action.action === 'update' && action.commentId != null && ( - <EuiFlexItem> + <EuiFlexItem grow={false}> <UserActionMoveToReference id={action.commentId} outlineComment={handleOutlineComment} /> </EuiFlexItem> )} @@ -252,14 +258,14 @@ export const getAlertAttachment = ({ timestamp: <UserActionTimestamp createdAt={action.actionAt} />, timelineIcon: 'bell', actions: ( - <EuiFlexGroup> - <EuiFlexItem> + <EuiFlexGroup responsive={false}> + <EuiFlexItem grow={false}> <UserActionCopyLink id={action.actionId} getCaseDetailHrefWithCommentId={getCaseDetailHrefWithCommentId} /> </EuiFlexItem> - <EuiFlexItem> + <EuiFlexItem grow={false}> <UserActionShowAlert id={action.actionId} alertId={alertId} @@ -343,15 +349,17 @@ export const getGeneratedAlertsAttachment = ({ timestamp: <UserActionTimestamp createdAt={action.actionAt} />, timelineIcon: 'bell', actions: ( - <EuiFlexGroup> - <EuiFlexItem> + <EuiFlexGroup responsive={false}> + <EuiFlexItem grow={false}> <UserActionCopyLink getCaseDetailHrefWithCommentId={getCaseDetailHrefWithCommentId} id={action.actionId} /> </EuiFlexItem> {renderInvestigateInTimelineActionComponent ? ( - <EuiFlexItem>{renderInvestigateInTimelineActionComponent(alertIds)}</EuiFlexItem> + <EuiFlexItem grow={false}> + {renderInvestigateInTimelineActionComponent(alertIds)} + </EuiFlexItem> ) : null} </EuiFlexGroup> ), diff --git a/x-pack/plugins/cases/public/components/user_action_tree/user_action_content_toolbar.tsx b/x-pack/plugins/cases/public/components/user_action_tree/user_action_content_toolbar.tsx index 5fa12b8cfa434..d19ed697f97fe 100644 --- a/x-pack/plugins/cases/public/components/user_action_tree/user_action_content_toolbar.tsx +++ b/x-pack/plugins/cases/public/components/user_action_tree/user_action_content_toolbar.tsx @@ -32,12 +32,12 @@ const UserActionContentToolbarComponent = ({ onQuote, userCanCrud, }: UserActionContentToolbarProps) => ( - <EuiFlexGroup> - <EuiFlexItem> + <EuiFlexGroup responsive={false} alignItems="center"> + <EuiFlexItem grow={false}> <UserActionCopyLink id={id} getCaseDetailHrefWithCommentId={getCaseDetailHrefWithCommentId} /> </EuiFlexItem> {userCanCrud && ( - <EuiFlexItem> + <EuiFlexItem grow={false}> <UserActionPropertyActions id={id} editLabel={editLabel} diff --git a/x-pack/plugins/cases/public/components/user_action_tree/user_action_markdown.tsx b/x-pack/plugins/cases/public/components/user_action_tree/user_action_markdown.tsx index 19cc804786af1..1522527468fc4 100644 --- a/x-pack/plugins/cases/public/components/user_action_tree/user_action_markdown.tsx +++ b/x-pack/plugins/cases/public/components/user_action_tree/user_action_markdown.tsx @@ -56,7 +56,7 @@ export const UserActionMarkdown = ({ const renderButtons = useCallback( ({ cancelAction, saveAction }) => ( - <EuiFlexGroup gutterSize="s" alignItems="center"> + <EuiFlexGroup gutterSize="s" alignItems="center" responsive={false}> <EuiFlexItem grow={false}> <EuiButtonEmpty data-test-subj="user-action-cancel-markdown" diff --git a/x-pack/plugins/cases/public/components/user_list/index.tsx b/x-pack/plugins/cases/public/components/user_list/index.tsx index d4d5d56ccc0d5..6a252cfaea20d 100644 --- a/x-pack/plugins/cases/public/components/user_list/index.tsx +++ b/x-pack/plugins/cases/public/components/user_list/index.tsx @@ -49,13 +49,13 @@ const renderUsers = ( handleSendEmail: (emailAddress: string | undefined | null) => void ) => users.map(({ fullName, username, email }, key) => ( - <MyFlexGroup key={key} justifyContent="spaceBetween"> + <MyFlexGroup key={key} justifyContent="spaceBetween" responsive={false}> <EuiFlexItem grow={false}> - <EuiFlexGroup gutterSize="xs"> - <EuiFlexItem> + <EuiFlexGroup gutterSize="xs" responsive={false}> + <EuiFlexItem grow={false}> <MyAvatar name={fullName ? fullName : username ?? ''} /> </EuiFlexItem> - <EuiFlexItem> + <EuiFlexItem grow={false}> <EuiToolTip position="top" content={<p>{fullName ? fullName : username ?? ''}</p>}> <p> <strong> diff --git a/x-pack/plugins/cases/public/components/wrappers/index.tsx b/x-pack/plugins/cases/public/components/wrappers/index.tsx index 3b33e9304da83..4c8a3a681f024 100644 --- a/x-pack/plugins/cases/public/components/wrappers/index.tsx +++ b/x-pack/plugins/cases/public/components/wrappers/index.tsx @@ -21,6 +21,23 @@ export const SectionWrapper = styled.div` `; export const HeaderWrapper = styled.div` - padding: ${({ theme }) => - `${theme.eui.paddingSizes.l} ${theme.eui.paddingSizes.l} 0 ${theme.eui.paddingSizes.l}`}; + ${({ theme }) => + ` + padding: ${theme.eui.paddingSizes.l} ${theme.eui.paddingSizes.l} 0 ${theme.eui.paddingSizes.l}; + @media only screen and (max-width: ${theme.eui.euiBreakpoints.s}) { + padding: ${theme.eui.paddingSizes.s} ${theme.eui.paddingSizes.s} 0 + ${theme.eui.paddingSizes.s}; + } + `}; +`; +const gutterTimeline = '70px'; // seems to be a timeline reference from the original file +export const ContentWrapper = styled.div` + ${({ theme }) => + ` + padding: ${theme.eui.paddingSizes.l} ${theme.eui.paddingSizes.l} ${gutterTimeline} ${theme.eui.paddingSizes.l}; + @media only screen and (max-width: ${theme.eui.euiBreakpoints.s}) { + padding: ${theme.eui.paddingSizes.s} ${theme.eui.paddingSizes.s} ${gutterTimeline} + ${theme.eui.paddingSizes.s}; + } + `}; `; diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/active_timelines.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/active_timelines.tsx index 636bbf4044cb7..64832bf7f039d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/active_timelines.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/active_timelines.tsx @@ -22,11 +22,6 @@ import { UNTITLED_TIMELINE, UNTITLED_TEMPLATE } from '../../timeline/properties/ import { timelineActions } from '../../../store/timeline'; import * as i18n from './translations'; -const ButtonWrapper = styled(EuiFlexItem)` - flex-direction: row; - align-items: center; -`; - const EuiHealthStyled = styled(EuiHealth)` display: block; `; @@ -83,35 +78,36 @@ const ActiveTimelinesComponent: React.FC<ActiveTimelinesProps> = ({ }, [timelineStatus, updated]); return ( - <EuiFlexGroup gutterSize="none"> - <ButtonWrapper grow={false}> - <StyledEuiButtonEmpty - aria-label={i18n.TIMELINE_TOGGLE_BUTTON_ARIA_LABEL({ isOpen, title })} - className={ACTIVE_TIMELINE_BUTTON_CLASS_NAME} - flush="both" - data-test-subj="flyoutOverlay" - size="s" - isSelected={isOpen} - onClick={handleToggleOpen} - > - <EuiFlexGroup gutterSize="none" alignItems="center" justifyContent="flexStart"> - <EuiFlexItem grow={false}> - <EuiToolTip position="top" content={tooltipContent}> - <EuiHealthStyled - color={timelineStatus === TimelineStatus.draft ? 'warning' : 'success'} - /> - </EuiToolTip> - </EuiFlexItem> - <EuiFlexItem grow={false}>{title}</EuiFlexItem> - {!isOpen && ( - <EuiFlexItem grow={false}> - <TimelineEventsCountBadge /> - </EuiFlexItem> - )} - </EuiFlexGroup> - </StyledEuiButtonEmpty> - </ButtonWrapper> - </EuiFlexGroup> + <StyledEuiButtonEmpty + aria-label={i18n.TIMELINE_TOGGLE_BUTTON_ARIA_LABEL({ isOpen, title })} + className={ACTIVE_TIMELINE_BUTTON_CLASS_NAME} + flush="both" + data-test-subj="flyoutOverlay" + size="s" + isSelected={isOpen} + onClick={handleToggleOpen} + > + <EuiFlexGroup + gutterSize="none" + alignItems="center" + justifyContent="flexStart" + responsive={false} + > + <EuiFlexItem grow={false}> + <EuiToolTip position="top" content={tooltipContent}> + <EuiHealthStyled + color={timelineStatus === TimelineStatus.draft ? 'warning' : 'success'} + /> + </EuiToolTip> + </EuiFlexItem> + <EuiFlexItem grow={false}>{title}</EuiFlexItem> + {!isOpen && ( + <EuiFlexItem grow={false}> + <TimelineEventsCountBadge /> + </EuiFlexItem> + )} + </EuiFlexGroup> + </StyledEuiButtonEmpty> ); }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx index e54da13ea6056..f0c21b6bc1565 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx @@ -143,9 +143,9 @@ const FlyoutHeaderPanelComponent: React.FC<FlyoutHeaderPanelProps> = ({ timeline hasShadow={false} data-test-subj="timeline-flyout-header-panel" > - <EuiFlexGroup alignItems="center" gutterSize="s"> + <EuiFlexGroup alignItems="center" gutterSize="s" responsive={false}> <AddTimelineButton timelineId={timelineId} /> - <EuiFlexItem grow> + <EuiFlexItem grow={false}> <ActiveTimelines timelineId={timelineId} timelineType={timelineType} @@ -156,8 +156,8 @@ const FlyoutHeaderPanelComponent: React.FC<FlyoutHeaderPanelProps> = ({ timeline /> </EuiFlexItem> {show && ( - <EuiFlexItem grow={false}> - <EuiFlexGroup gutterSize="s"> + <EuiFlexItem> + <EuiFlexGroup justifyContent="flexEnd" gutterSize="s" responsive={false}> {(activeTab === TimelineTabs.query || activeTab === TimelineTabs.eql) && ( <EuiFlexItem grow={false}> <InspectButton From 8afddc9fe6a1f65a0218017add7b9b600abf0c7a Mon Sep 17 00:00:00 2001 From: Angela Chuang <6295984+angorayc@users.noreply.github.com> Date: Wed, 30 Jun 2021 22:05:32 +0100 Subject: [PATCH 09/51] [SecuritySolution] Handle not found routes for detections routes (#103659) * add not found page * fix url state * fix url state * revert cypress test case * add tests for new links * rename detections to alerts * move function to helper * add cypress tests * clean up routes * clean up routes * styling for not found page * clean up rules routes * rm unused i18n * add cypress tests * add cypress tests * rm unused i18n Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../integration/header/navigation.spec.ts | 18 ++++- .../integration/urls/compatibility.spec.ts | 16 ++++ .../integration/urls/not_found.spec.ts | 78 +++++++++++++++++++ .../cypress/screens/common/page.ts | 2 + .../cypress/screens/security_header.ts | 6 +- .../cypress/urls/navigation.ts | 7 +- .../security_solution/public/app/404.tsx | 20 ++++- .../common/components/url_state/helpers.ts | 5 ++ .../url_state/initialize_redux_by_url.tsx | 5 +- .../components/url_state/use_url_state.tsx | 4 +- .../public/detections/routes.tsx | 20 +++-- .../public/exceptions/routes.tsx | 15 +++- .../security_solution/public/rules/routes.tsx | 33 ++++---- .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - 15 files changed, 193 insertions(+), 38 deletions(-) create mode 100644 x-pack/plugins/security_solution/cypress/integration/urls/not_found.spec.ts diff --git a/x-pack/plugins/security_solution/cypress/integration/header/navigation.spec.ts b/x-pack/plugins/security_solution/cypress/integration/header/navigation.spec.ts index 2079e8e47d479..15982f1674351 100644 --- a/x-pack/plugins/security_solution/cypress/integration/header/navigation.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/header/navigation.spec.ts @@ -7,7 +7,7 @@ import { CASES, - DETECTIONS, + ALERTS, HOSTS, ENDPOINTS, TRUSTED_APPS, @@ -15,6 +15,8 @@ import { NETWORK, OVERVIEW, TIMELINES, + RULES, + EXCEPTIONS, } from '../../screens/security_header'; import { loginAndWaitForPage } from '../../tasks/login'; @@ -31,6 +33,8 @@ import { NETWORK_URL, OVERVIEW_URL, TIMELINES_URL, + EXCEPTIONS_URL, + DETECTIONS_RULE_MANAGEMENT_URL, } from '../../urls/navigation'; import { openKibanaNavigation, @@ -59,7 +63,7 @@ describe('top-level navigation common to all pages in the Security app', () => { }); it('navigates to the Alerts page', () => { - navigateFromHeaderTo(DETECTIONS); + navigateFromHeaderTo(ALERTS); cy.url().should('include', ALERTS_URL); }); @@ -73,6 +77,16 @@ describe('top-level navigation common to all pages in the Security app', () => { cy.url().should('include', NETWORK_URL); }); + it('navigates to the Rules page', () => { + navigateFromHeaderTo(RULES); + cy.url().should('include', DETECTIONS_RULE_MANAGEMENT_URL); + }); + + it('navigates to the Exceptions page', () => { + navigateFromHeaderTo(EXCEPTIONS); + cy.url().should('include', EXCEPTIONS_URL); + }); + it('navigates to the Timelines page', () => { navigateFromHeaderTo(TIMELINES); cy.url().should('include', TIMELINES_URL); diff --git a/x-pack/plugins/security_solution/cypress/integration/urls/compatibility.spec.ts b/x-pack/plugins/security_solution/cypress/integration/urls/compatibility.spec.ts index bbbd6037d3862..fa4a5ee40d126 100644 --- a/x-pack/plugins/security_solution/cypress/integration/urls/compatibility.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/urls/compatibility.spec.ts @@ -9,8 +9,12 @@ import { loginAndWaitForPage, loginAndWaitForPageWithoutDateRange } from '../../ import { ALERTS_URL, + detectionRuleEditUrl, DETECTIONS, + detectionsRuleDetailsUrl, DETECTIONS_RULE_MANAGEMENT_URL, + ruleDetailsUrl, + ruleEditUrl, RULE_CREATION, SECURITY_DETECTIONS_RULES_CREATION_URL, SECURITY_DETECTIONS_RULES_URL, @@ -28,6 +32,8 @@ const ABSOLUTE_DATE = { startTime: '2019-08-01T20:03:29.186Z', }; +const RULE_ID = '5a4a0460-d822-11eb-8962-bfd4aff0a9b3'; + describe('URL compatibility', () => { before(() => { cleanKibana(); @@ -53,6 +59,16 @@ describe('URL compatibility', () => { cy.url().should('include', RULE_CREATION); }); + it('Redirects to rule details from old Detections rule details URL', () => { + loginAndWaitForPage(detectionsRuleDetailsUrl(RULE_ID)); + cy.url().should('include', ruleDetailsUrl(RULE_ID)); + }); + + it('Redirects to rule edit from old Detections rule edit URL', () => { + loginAndWaitForPage(detectionRuleEditUrl(RULE_ID)); + cy.url().should('include', ruleEditUrl(RULE_ID)); + }); + it('sets the global start and end dates from the url with timestamps', () => { loginAndWaitForPageWithoutDateRange(ABSOLUTE_DATE_RANGE.urlWithTimestamps); cy.get(DATE_PICKER_START_DATE_POPOVER_BUTTON).should( diff --git a/x-pack/plugins/security_solution/cypress/integration/urls/not_found.spec.ts b/x-pack/plugins/security_solution/cypress/integration/urls/not_found.spec.ts new file mode 100644 index 0000000000000..3b1df67bec29c --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/integration/urls/not_found.spec.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 { loginAndWaitForPage } from '../../tasks/login'; + +import { + ALERTS_URL, + ENDPOINTS_URL, + TRUSTED_APPS_URL, + EVENT_FILTERS_URL, + TIMELINES_URL, + EXCEPTIONS_URL, + DETECTIONS_RULE_MANAGEMENT_URL, + RULE_CREATION, + ruleEditUrl, + ruleDetailsUrl, +} from '../../urls/navigation'; + +import { cleanKibana } from '../../tasks/common'; +import { NOT_FOUND } from '../../screens/common/page'; + +const mockRuleId = '5a4a0460-d822-11eb-8962-bfd4aff0a9b3'; + +describe('Display not found page', () => { + before(() => { + cleanKibana(); + loginAndWaitForPage(TIMELINES_URL); + }); + + it('navigates to the alerts page with incorrect link', () => { + loginAndWaitForPage(`${ALERTS_URL}/randomUrl`); + cy.get(NOT_FOUND).should('exist'); + }); + + it('navigates to the exceptions page with incorrect link', () => { + loginAndWaitForPage(`${EXCEPTIONS_URL}/randomUrl`); + cy.get(NOT_FOUND).should('exist'); + }); + + it('navigates to the rules page with incorrect link', () => { + loginAndWaitForPage(`${DETECTIONS_RULE_MANAGEMENT_URL}/randomUrl`); + cy.get(NOT_FOUND).should('exist'); + }); + + it('navigates to the rules creation page with incorrect link', () => { + loginAndWaitForPage(`${RULE_CREATION}/randomUrl`); + cy.get(NOT_FOUND).should('exist'); + }); + + it('navigates to the rules details page with incorrect link', () => { + loginAndWaitForPage(`${ruleDetailsUrl(mockRuleId)}/randomUrl`); + cy.get(NOT_FOUND).should('exist'); + }); + + it('navigates to the edit rules page with incorrect link', () => { + loginAndWaitForPage(`${ruleEditUrl(mockRuleId)}/randomUrl`); + cy.get(NOT_FOUND).should('exist'); + }); + + it('navigates to the endpoints page with incorrect link', () => { + loginAndWaitForPage(`${ENDPOINTS_URL}/randomUrl`); + cy.get(NOT_FOUND).should('exist'); + }); + + it('navigates to the trusted applications page with incorrect link', () => { + loginAndWaitForPage(`${TRUSTED_APPS_URL}/randomUrl`); + cy.get(NOT_FOUND).should('exist'); + }); + + it('navigates to the trusted applications page with incorrect link', () => { + loginAndWaitForPage(`${EVENT_FILTERS_URL}/randomUrl`); + cy.get(NOT_FOUND).should('exist'); + }); +}); diff --git a/x-pack/plugins/security_solution/cypress/screens/common/page.ts b/x-pack/plugins/security_solution/cypress/screens/common/page.ts index df3890e30746c..3f6a130ca3314 100644 --- a/x-pack/plugins/security_solution/cypress/screens/common/page.ts +++ b/x-pack/plugins/security_solution/cypress/screens/common/page.ts @@ -6,3 +6,5 @@ */ export const PAGE_TITLE = '[data-test-subj="header-page-title"]'; + +export const NOT_FOUND = '[data-test-subj="notFoundPage"]'; diff --git a/x-pack/plugins/security_solution/cypress/screens/security_header.ts b/x-pack/plugins/security_solution/cypress/screens/security_header.ts index 3573d78bfcf8a..d4589745f9757 100644 --- a/x-pack/plugins/security_solution/cypress/screens/security_header.ts +++ b/x-pack/plugins/security_solution/cypress/screens/security_header.ts @@ -5,7 +5,7 @@ * 2.0. */ -export const DETECTIONS = '[data-test-subj="navigation-alerts"]'; +export const ALERTS = '[data-test-subj="navigation-alerts"]'; export const BREADCRUMBS = '[data-test-subj="breadcrumbs"] a'; @@ -23,6 +23,10 @@ export const EVENT_FILTERS = '[data-test-subj="navigation-event_filters"]'; export const NETWORK = '[data-test-subj="navigation-network"]'; +export const RULES = '[data-test-subj="navigation-rules"]'; + +export const EXCEPTIONS = '[data-test-subj="navigation-exceptions"]'; + export const OVERVIEW = '[data-test-subj="navigation-overview"]'; export const REFRESH_BUTTON = '[data-test-subj="querySubmitButton"]'; diff --git a/x-pack/plugins/security_solution/cypress/urls/navigation.ts b/x-pack/plugins/security_solution/cypress/urls/navigation.ts index 879f16f0b7e0e..304db7e93e2cb 100644 --- a/x-pack/plugins/security_solution/cypress/urls/navigation.ts +++ b/x-pack/plugins/security_solution/cypress/urls/navigation.ts @@ -7,7 +7,12 @@ export const ALERTS_URL = 'app/security/alerts'; export const DETECTIONS_RULE_MANAGEMENT_URL = 'app/security/rules'; -export const detectionsRuleDetailsUrl = (ruleId: string) => `app/security/rules/id/${ruleId}`; +export const ruleDetailsUrl = (ruleId: string) => `app/security/rules/id/${ruleId}`; +export const detectionsRuleDetailsUrl = (ruleId: string) => + `app/security/detections/rules/id/${ruleId}`; + +export const ruleEditUrl = (ruleId: string) => `${ruleDetailsUrl(ruleId)}/edit`; +export const detectionRuleEditUrl = (ruleId: string) => `${detectionsRuleDetailsUrl(ruleId)}/edit`; export const CASES_URL = '/app/security/cases'; export const DETECTIONS = '/app/siem#/detections'; diff --git a/x-pack/plugins/security_solution/public/app/404.tsx b/x-pack/plugins/security_solution/public/app/404.tsx index 2634ffd47bff1..72cae59867081 100644 --- a/x-pack/plugins/security_solution/public/app/404.tsx +++ b/x-pack/plugins/security_solution/public/app/404.tsx @@ -8,14 +8,26 @@ import React from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiEmptyPrompt, EuiPageTemplate } from '@elastic/eui'; import { SecuritySolutionPageWrapper } from '../common/components/page_wrapper'; export const NotFoundPage = React.memo(() => ( <SecuritySolutionPageWrapper> - <FormattedMessage - id="xpack.securitySolution.pages.fourohfour.noContentFoundDescription" - defaultMessage="No content found" - /> + <EuiPageTemplate template="centeredContent"> + <EuiEmptyPrompt + data-test-subj="notFoundPage" + iconColor="default" + iconType="logoElastic" + title={ + <p> + <FormattedMessage + id="xpack.securitySolution.pages.fourohfour.pageNotFoundDescription" + defaultMessage="Page not found" + /> + </p> + } + /> + </EuiPageTemplate> </SecuritySolutionPageWrapper> )); diff --git a/x-pack/plugins/security_solution/public/common/components/url_state/helpers.ts b/x-pack/plugins/security_solution/public/common/components/url_state/helpers.ts index 8908b83fc9b56..035e1314f1557 100644 --- a/x-pack/plugins/security_solution/public/common/components/url_state/helpers.ts +++ b/x-pack/plugins/security_solution/public/common/components/url_state/helpers.ts @@ -26,6 +26,11 @@ import { ReplaceStateInLocation, UpdateUrlStateString } from './types'; import { sourcererSelectors } from '../../store/sourcerer'; import { SourcererScopeName, SourcererScopePatterns } from '../../store/sourcerer/model'; +export const isDetectionsPages = (pageName: string) => + pageName === SecurityPageName.alerts || + pageName === SecurityPageName.rules || + pageName === SecurityPageName.exceptions; + export const decodeRisonUrlState = <T>(value: string | undefined): T | null => { try { return value ? ((decode(value) as unknown) as T) : null; diff --git a/x-pack/plugins/security_solution/public/common/components/url_state/initialize_redux_by_url.tsx b/x-pack/plugins/security_solution/public/common/components/url_state/initialize_redux_by_url.tsx index 9fc2e24221bcb..4df5e093ec07c 100644 --- a/x-pack/plugins/security_solution/public/common/components/url_state/initialize_redux_by_url.tsx +++ b/x-pack/plugins/security_solution/public/common/components/url_state/initialize_redux_by_url.tsx @@ -19,12 +19,11 @@ import { } from '../../store/inputs/model'; import { TimelineUrl } from '../../../timelines/store/timeline/model'; import { CONSTANTS } from './constants'; -import { decodeRisonUrlState } from './helpers'; +import { decodeRisonUrlState, isDetectionsPages } from './helpers'; import { normalizeTimeRange } from './normalize_time_range'; import { DispatchSetInitialStateFromUrl, SetInitialStateFromUrl } from './types'; import { queryTimelineById } from '../../../timelines/components/open_timeline/helpers'; import { SourcererScopeName, SourcererScopePatterns } from '../../store/sourcerer/model'; -import { SecurityPageName } from '../../../../common/constants'; export const dispatchSetInitialStateFromUrl = ( dispatch: Dispatch @@ -45,7 +44,7 @@ export const dispatchSetInitialStateFromUrl = ( const sourcererState = decodeRisonUrlState<SourcererScopePatterns>(newUrlStateString); if (sourcererState != null) { const activeScopes: SourcererScopeName[] = Object.keys(sourcererState).filter( - (key) => !(key === SourcererScopeName.default && pageName === SecurityPageName.alerts) + (key) => !(key === SourcererScopeName.default && isDetectionsPages(pageName)) ) as SourcererScopeName[]; activeScopes.forEach((scope) => dispatch( diff --git a/x-pack/plugins/security_solution/public/common/components/url_state/use_url_state.tsx b/x-pack/plugins/security_solution/public/common/components/url_state/use_url_state.tsx index 10d586c2d7441..487463dfd9d7d 100644 --- a/x-pack/plugins/security_solution/public/common/components/url_state/use_url_state.tsx +++ b/x-pack/plugins/security_solution/public/common/components/url_state/use_url_state.tsx @@ -19,6 +19,7 @@ import { replaceStateInLocation, updateUrlStateString, decodeRisonUrlState, + isDetectionsPages, } from './helpers'; import { UrlStateContainerPropTypes, @@ -29,7 +30,6 @@ import { UrlStateToRedux, UrlState, } from './types'; -import { SecurityPageName } from '../../../app/types'; import { TimelineUrl } from '../../../timelines/store/timeline/model'; function usePrevious(value: PreviousLocationUrlState) { @@ -221,7 +221,7 @@ export const useUrlStateHooks = ({ } }); } else if (pathName !== prevProps.pathName) { - handleInitialize(type, pageName === SecurityPageName.alerts); + handleInitialize(type, isDetectionsPages(pageName)); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [isInitializing, history, pathName, pageName, prevProps, urlState]); diff --git a/x-pack/plugins/security_solution/public/detections/routes.tsx b/x-pack/plugins/security_solution/public/detections/routes.tsx index 329e1c939c201..f0128577cb268 100644 --- a/x-pack/plugins/security_solution/public/detections/routes.tsx +++ b/x-pack/plugins/security_solution/public/detections/routes.tsx @@ -6,21 +6,29 @@ */ import React from 'react'; -import { Redirect, RouteProps, RouteComponentProps } from 'react-router-dom'; +import { Redirect, RouteProps, RouteComponentProps, Route, Switch } from 'react-router-dom'; import { TrackApplicationView } from '../../../../../src/plugins/usage_collection/public'; import { ALERTS_PATH, DETECTIONS_PATH, SecurityPageName } from '../../common/constants'; +import { NotFoundPage } from '../app/404'; import { SpyRoute } from '../common/utils/route/spy_routes'; import { DetectionEnginePage } from './pages/detection_engine/detection_engine'; -const renderAlertsRoutes = () => ( +const AlertsRoute = () => ( <TrackApplicationView viewId={SecurityPageName.alerts}> <DetectionEnginePage /> <SpyRoute pageName={SecurityPageName.alerts} /> </TrackApplicationView> ); +const renderAlertsRoutes = () => ( + <Switch> + <Route path={ALERTS_PATH} exact component={AlertsRoute} /> + <Route component={NotFoundPage} /> + </Switch> +); + const DetectionsRedirects = ({ location }: RouteComponentProps) => location.pathname === DETECTIONS_PATH ? ( <Redirect to={{ ...location, pathname: ALERTS_PATH }} /> @@ -30,11 +38,11 @@ const DetectionsRedirects = ({ location }: RouteComponentProps) => export const routes: RouteProps[] = [ { - path: ALERTS_PATH, - render: renderAlertsRoutes, + path: DETECTIONS_PATH, + render: DetectionsRedirects, }, { - path: DETECTIONS_PATH, - component: DetectionsRedirects, + path: ALERTS_PATH, + render: renderAlertsRoutes, }, ]; diff --git a/x-pack/plugins/security_solution/public/exceptions/routes.tsx b/x-pack/plugins/security_solution/public/exceptions/routes.tsx index 0afc152ed3870..a5b95ffa64d4d 100644 --- a/x-pack/plugins/security_solution/public/exceptions/routes.tsx +++ b/x-pack/plugins/security_solution/public/exceptions/routes.tsx @@ -5,13 +5,15 @@ * 2.0. */ import React from 'react'; +import { Route, Switch } from 'react-router-dom'; import { TrackApplicationView } from '../../../../../src/plugins/usage_collection/public'; import { EXCEPTIONS_PATH, SecurityPageName } from '../../common/constants'; import { ExceptionListsTable } from '../detections/pages/detection_engine/rules/all/exceptions/exceptions_table'; import { SpyRoute } from '../common/utils/route/spy_routes'; +import { NotFoundPage } from '../app/404'; -export const ExceptionsRoutes = () => { +const ExceptionsRoutes = () => { return ( <TrackApplicationView viewId={SecurityPageName.exceptions}> <ExceptionListsTable /> @@ -20,9 +22,18 @@ export const ExceptionsRoutes = () => { ); }; +const renderExceptionsRoutes = () => { + return ( + <Switch> + <Route path={EXCEPTIONS_PATH} exact component={ExceptionsRoutes} /> + <Route component={NotFoundPage} /> + </Switch> + ); +}; + export const routes = [ { path: EXCEPTIONS_PATH, - render: ExceptionsRoutes, + render: renderExceptionsRoutes, }, ]; diff --git a/x-pack/plugins/security_solution/public/rules/routes.tsx b/x-pack/plugins/security_solution/public/rules/routes.tsx index 39b882ad76f8c..fcb434ae760ed 100644 --- a/x-pack/plugins/security_solution/public/rules/routes.tsx +++ b/x-pack/plugins/security_solution/public/rules/routes.tsx @@ -9,6 +9,7 @@ import { Route, Switch } from 'react-router-dom'; import { TrackApplicationView } from '../../../../../src/plugins/usage_collection/public'; import { RULES_PATH, SecurityPageName } from '../../common/constants'; +import { NotFoundPage } from '../app/404'; import { RulesPage } from '../detections/pages/detection_engine/rules'; import { CreateRulePage } from '../detections/pages/detection_engine/rules/create'; import { RuleDetailsPage } from '../detections/pages/detection_engine/rules/details'; @@ -18,39 +19,41 @@ const RulesSubRoutes = [ { path: '/rules/id/:detailName/edit', main: EditRulePage, + exact: true, }, { path: '/rules/id/:detailName', main: RuleDetailsPage, + exact: true, }, { path: '/rules/create', main: CreateRulePage, + exact: true, }, { path: '/rules', - exact: true, main: RulesPage, + exact: true, }, ]; -export const RulesRoutes = () => { - return ( - <TrackApplicationView viewId={SecurityPageName.rules}> - <Switch> - {RulesSubRoutes.map((route, index) => ( - <Route key={`rules-route-${route.path}`} path={route.path} exact={route?.exact ?? false}> - <route.main /> - </Route> - ))} - </Switch> - </TrackApplicationView> - ); -}; +const renderRulesRoutes = () => ( + <TrackApplicationView viewId={SecurityPageName.rules}> + <Switch> + {RulesSubRoutes.map((route, index) => ( + <Route key={`rules-route-${route.path}`} path={route.path} exact={route?.exact ?? false}> + <route.main /> + </Route> + ))} + <Route component={NotFoundPage} /> + </Switch> + </TrackApplicationView> +); export const routes = [ { path: RULES_PATH, - render: RulesRoutes, + render: renderRulesRoutes, }, ]; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 40f7ae933a262..69553fd53ffc5 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -20734,7 +20734,6 @@ "xpack.securitySolution.pages.common.emptyActionEndpointDescription": "脅威防御、検出、深いセキュリティデータの可視化を実現し、ホストを保護します。", "xpack.securitySolution.pages.common.emptyActionSecondary": "入門ガイドを表示します。", "xpack.securitySolution.pages.common.emptyTitle": "Elastic Securityへようこそ。始めましょう。", - "xpack.securitySolution.pages.fourohfour.noContentFoundDescription": "コンテンツがありません", "xpack.securitySolution.paginatedTable.rowsButtonLabel": "ページごとの行数", "xpack.securitySolution.paginatedTable.showingSubtitle": "表示中", "xpack.securitySolution.paginatedTable.tooManyResultsToastText": "クエリ範囲を縮めて結果をさらにフィルタリングしてください", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 9545ec1729557..261f68c2e629a 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -21066,7 +21066,6 @@ "xpack.securitySolution.pages.common.emptyTitle": "欢迎使用 Elastic Security。让我们帮您如何入门。", "xpack.securitySolution.pages.common.updateAlertStatusFailed": "无法更新{ conflicts } 个{conflicts, plural, other {告警}}。", "xpack.securitySolution.pages.common.updateAlertStatusFailedDetailed": "{ updated } 个{updated, plural, other {告警}}已成功更新,但是 { conflicts } 个无法更新,\n 因为{ conflicts, plural, other {其}}已被修改。", - "xpack.securitySolution.pages.fourohfour.noContentFoundDescription": "未找到任何内容", "xpack.securitySolution.paginatedTable.rowsButtonLabel": "每页行数", "xpack.securitySolution.paginatedTable.showingSubtitle": "正在显示", "xpack.securitySolution.paginatedTable.tooManyResultsToastText": "缩减您的查询范围,以更好地筛选结果", From fdbf88de5b972c19ed62ab0b532dc0d4cbf23061 Mon Sep 17 00:00:00 2001 From: Kevin Logan <56395104+kevinlog@users.noreply.github.com> Date: Wed, 30 Jun 2021 17:06:29 -0400 Subject: [PATCH 10/51] [Security Solution] Use semver for Host Isolation version check (#103975) --- .../service/host_isolation/utils.test.ts | 2 ++ .../endpoint/service/host_isolation/utils.ts | 16 ++++++---------- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/x-pack/plugins/security_solution/common/endpoint/service/host_isolation/utils.test.ts b/x-pack/plugins/security_solution/common/endpoint/service/host_isolation/utils.test.ts index 7d3810bed8f44..8983f1a99b0cd 100644 --- a/x-pack/plugins/security_solution/common/endpoint/service/host_isolation/utils.test.ts +++ b/x-pack/plugins/security_solution/common/endpoint/service/host_isolation/utils.test.ts @@ -20,6 +20,8 @@ describe('Host Isolation utils isVersionSupported', () => { ${'7.14.0-SNAPSHOT'} | ${'7.14.0'} | ${true} ${'7.14.0-SNAPSHOT-beta'} | ${'7.14.0'} | ${true} ${'7.14.0-alpha'} | ${'7.14.0'} | ${true} + ${'8.0.0-SNAPSHOT'} | ${'7.14.0'} | ${true} + ${'8.0.0'} | ${'7.14.0'} | ${true} `('should validate that version $a is compatible($expected) to $b', ({ a, b, expected }) => { expect( isVersionSupported({ diff --git a/x-pack/plugins/security_solution/common/endpoint/service/host_isolation/utils.ts b/x-pack/plugins/security_solution/common/endpoint/service/host_isolation/utils.ts index c5e57179bcb8d..fd0180b9146e7 100644 --- a/x-pack/plugins/security_solution/common/endpoint/service/host_isolation/utils.ts +++ b/x-pack/plugins/security_solution/common/endpoint/service/host_isolation/utils.ts @@ -4,6 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import semverLt from 'semver/functions/lt'; export const isVersionSupported = ({ currentVersion, @@ -12,19 +13,14 @@ export const isVersionSupported = ({ currentVersion: string; minVersionRequired: string; }) => { - const parsedCurrentVersion = currentVersion.includes('-SNAPSHOT') + const parsedCurrentVersion = currentVersion.includes('-') ? currentVersion.substring(0, currentVersion.indexOf('-')) : currentVersion; - const tokenizedCurrent = parsedCurrentVersion - .split('.') - .map((token: string) => parseInt(token, 10)); - const tokenizedMin = minVersionRequired.split('.').map((token: string) => parseInt(token, 10)); - const versionNotSupported = tokenizedCurrent.some((token: number, index: number) => { - return token < tokenizedMin[index]; - }); - - return !versionNotSupported; + return ( + parsedCurrentVersion === minVersionRequired || + semverLt(minVersionRequired, parsedCurrentVersion) + ); }; export const isOsSupported = ({ From 49c66b807e4a336c0ad467c5b810b59ac66d11c5 Mon Sep 17 00:00:00 2001 From: Clint Andrew Hall <clint.hall@elastic.co> Date: Wed, 30 Jun 2021 17:08:13 -0400 Subject: [PATCH 11/51] [canvas] Relocate Legacy Services; create Workpad Service (#103386) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- src/plugins/presentation_util/public/index.ts | 10 + x-pack/plugins/canvas/jest.config.js | 3 + x-pack/plugins/canvas/public/application.tsx | 57 +- .../home/__snapshots__/home.stories.storyshot | 133 +++ .../public/components/home/home.stories.tsx | 23 +- .../empty_prompt.stories.storyshot | 65 ++ .../my_workpads.stories.storyshot | 24 + .../workpad_table.stories.storyshot | 974 ++++++++++++++++++ .../home/my_workpads/empty_prompt.stories.tsx | 5 +- .../home/my_workpads/my_workpads.stories.tsx | 59 +- .../my_workpads/workpad_table.stories.tsx | 79 +- .../workpad_templates.stories.storyshot | 24 + .../workpad_templates.stories.tsx | 62 +- .../__stories__/share_menu.stories.tsx | 4 +- x-pack/plugins/canvas/public/plugin.tsx | 36 +- .../routes/workpad/hooks/use_workpad.test.tsx | 6 +- .../routes/workpad/hooks/use_workpad.ts | 8 +- .../plugins/canvas/public/services/index.ts | 127 +-- .../canvas/public/services/kibana/index.ts | 31 + .../canvas/public/services/kibana/workpad.ts | 97 ++ .../public/services/{ => legacy}/context.tsx | 5 +- .../services/{ => legacy}/embeddables.ts | 2 +- .../services/{ => legacy}/expressions.ts | 4 +- .../canvas/public/services/legacy/index.ts | 129 +++ .../public/services/{ => legacy}/labs.ts | 4 +- .../public/services/{ => legacy}/nav_link.ts | 4 +- .../public/services/{ => legacy}/notify.ts | 4 +- .../public/services/{ => legacy}/platform.ts | 2 +- .../public/services/{ => legacy}/reporting.ts | 2 +- .../public/services/{ => legacy}/search.ts | 0 .../{ => legacy}/stubs/embeddables.ts | 0 .../{ => legacy}/stubs/expressions.ts | 6 +- .../public/services/legacy/stubs/index.ts | 35 + .../services/{ => legacy}/stubs/labs.ts | 2 +- .../services/{ => legacy}/stubs/nav_link.ts | 0 .../services/{ => legacy}/stubs/notify.ts | 0 .../services/{ => legacy}/stubs/platform.ts | 0 .../services/{ => legacy}/stubs/reporting.ts | 0 .../services/{ => legacy}/stubs/search.ts | 0 .../canvas/public/services/storybook/index.ts | 60 ++ .../public/services/storybook/workpad.ts | 100 ++ .../canvas/public/services/stubs/index.ts | 44 +- .../canvas/public/services/stubs/workpad.ts | 72 +- .../plugins/canvas/public/services/workpad.ts | 86 +- x-pack/plugins/canvas/public/store.ts | 7 +- .../canvas/storybook/decorators/index.ts | 5 +- .../decorators/services_decorator.tsx | 64 +- x-pack/plugins/canvas/storybook/index.ts | 2 + x-pack/plugins/canvas/storybook/preview.ts | 4 + .../canvas/storybook/storyshots.test.tsx | 2 +- 50 files changed, 1930 insertions(+), 542 deletions(-) create mode 100644 x-pack/plugins/canvas/public/components/home/__snapshots__/home.stories.storyshot create mode 100644 x-pack/plugins/canvas/public/components/home/my_workpads/__snapshots__/empty_prompt.stories.storyshot create mode 100644 x-pack/plugins/canvas/public/components/home/my_workpads/__snapshots__/my_workpads.stories.storyshot create mode 100644 x-pack/plugins/canvas/public/components/home/my_workpads/__snapshots__/workpad_table.stories.storyshot create mode 100644 x-pack/plugins/canvas/public/components/home/workpad_templates/__snapshots__/workpad_templates.stories.storyshot create mode 100644 x-pack/plugins/canvas/public/services/kibana/index.ts create mode 100644 x-pack/plugins/canvas/public/services/kibana/workpad.ts rename x-pack/plugins/canvas/public/services/{ => legacy}/context.tsx (92%) rename x-pack/plugins/canvas/public/services/{ => legacy}/embeddables.ts (88%) rename x-pack/plugins/canvas/public/services/{ => legacy}/expressions.ts (93%) create mode 100644 x-pack/plugins/canvas/public/services/legacy/index.ts rename x-pack/plugins/canvas/public/services/{ => legacy}/labs.ts (87%) rename x-pack/plugins/canvas/public/services/{ => legacy}/nav_link.ts (85%) rename x-pack/plugins/canvas/public/services/{ => legacy}/notify.ts (92%) rename x-pack/plugins/canvas/public/services/{ => legacy}/platform.ts (98%) rename x-pack/plugins/canvas/public/services/{ => legacy}/reporting.ts (94%) rename x-pack/plugins/canvas/public/services/{ => legacy}/search.ts (100%) rename x-pack/plugins/canvas/public/services/{ => legacy}/stubs/embeddables.ts (100%) rename x-pack/plugins/canvas/public/services/{ => legacy}/stubs/expressions.ts (75%) create mode 100644 x-pack/plugins/canvas/public/services/legacy/stubs/index.ts rename x-pack/plugins/canvas/public/services/{ => legacy}/stubs/labs.ts (86%) rename x-pack/plugins/canvas/public/services/{ => legacy}/stubs/nav_link.ts (100%) rename x-pack/plugins/canvas/public/services/{ => legacy}/stubs/notify.ts (100%) rename x-pack/plugins/canvas/public/services/{ => legacy}/stubs/platform.ts (100%) rename x-pack/plugins/canvas/public/services/{ => legacy}/stubs/reporting.ts (100%) rename x-pack/plugins/canvas/public/services/{ => legacy}/stubs/search.ts (100%) create mode 100644 x-pack/plugins/canvas/public/services/storybook/index.ts create mode 100644 x-pack/plugins/canvas/public/services/storybook/workpad.ts diff --git a/src/plugins/presentation_util/public/index.ts b/src/plugins/presentation_util/public/index.ts index 5ad81c7e759bc..9f17133c5b35a 100644 --- a/src/plugins/presentation_util/public/index.ts +++ b/src/plugins/presentation_util/public/index.ts @@ -15,6 +15,16 @@ export { getStubPluginServices, } from './services'; +export { + KibanaPluginServiceFactory, + PluginServiceFactory, + PluginServices, + PluginServiceProviders, + PluginServiceProvider, + PluginServiceRegistry, + KibanaPluginServiceParams, +} from './services/create'; + export { PresentationUtilPluginSetup, PresentationUtilPluginStart } from './types'; export { SaveModalDashboardProps } from './components/types'; export { projectIDs, ProjectID, Project } from '../common/labs'; diff --git a/x-pack/plugins/canvas/jest.config.js b/x-pack/plugins/canvas/jest.config.js index 5d40aa984e480..7524e06159a41 100644 --- a/x-pack/plugins/canvas/jest.config.js +++ b/x-pack/plugins/canvas/jest.config.js @@ -9,4 +9,7 @@ module.exports = { preset: '@kbn/test', rootDir: '../../..', roots: ['<rootDir>/x-pack/plugins/canvas'], + transform: { + '^.+\\.stories\\.tsx?$': '@storybook/addon-storyshots/injectFileName', + }, }; diff --git a/x-pack/plugins/canvas/public/application.tsx b/x-pack/plugins/canvas/public/application.tsx index 4163cb88d5fef..b7910c8293d80 100644 --- a/x-pack/plugins/canvas/public/application.tsx +++ b/x-pack/plugins/canvas/public/application.tsx @@ -17,9 +17,11 @@ import { includes, remove } from 'lodash'; import { AppMountParameters, CoreStart, CoreSetup, AppUpdater } from 'kibana/public'; +import { KibanaContextProvider } from '../../../../src/plugins/kibana_react/public'; +import { PluginServices } from '../../../../src/plugins/presentation_util/public'; + import { CanvasStartDeps, CanvasSetupDeps } from './plugin'; import { App } from './components/app'; -import { KibanaContextProvider } from '../../../../src/plugins/kibana_react/public'; import { registerLanguage } from './lib/monaco_language_def'; import { SetupRegistries } from './plugin_api'; import { initRegistries, populateRegistries, destroyRegistries } from './registries'; @@ -30,7 +32,7 @@ import { init as initStatsReporter } from './lib/ui_metric'; import { CapabilitiesStrings } from '../i18n'; -import { startServices, services, ServicesProvider } from './services'; +import { startServices, services, LegacyServicesProvider, CanvasPluginServices } from './services'; import { initFunctions } from './functions'; // @ts-expect-error untyped local import { appUnload } from './state/actions/app'; @@ -44,27 +46,38 @@ import './style/index.scss'; const { ReadOnlyBadge: strings } = CapabilitiesStrings; -export const renderApp = ( - coreStart: CoreStart, - plugins: CanvasStartDeps, - { element }: AppMountParameters, - canvasStore: Store -) => { - const { presentationUtil } = plugins; +export const renderApp = ({ + coreStart, + startPlugins, + params, + canvasStore, + pluginServices, +}: { + coreStart: CoreStart; + startPlugins: CanvasStartDeps; + params: AppMountParameters; + canvasStore: Store; + pluginServices: PluginServices<CanvasPluginServices>; +}) => { + const { presentationUtil } = startPlugins; + const { element } = params; element.classList.add('canvas'); element.classList.add('canvasContainerWrapper'); + const ServicesContextProvider = pluginServices.getContextProvider(); ReactDOM.render( - <KibanaContextProvider services={{ ...plugins, ...coreStart }}> - <ServicesProvider providers={services}> - <presentationUtil.ContextProvider> - <I18nProvider> - <Provider store={canvasStore}> - <App /> - </Provider> - </I18nProvider> - </presentationUtil.ContextProvider> - </ServicesProvider> + <KibanaContextProvider services={{ ...startPlugins, ...coreStart }}> + <ServicesContextProvider> + <LegacyServicesProvider providers={services}> + <presentationUtil.ContextProvider> + <I18nProvider> + <Provider store={canvasStore}> + <App /> + </Provider> + </I18nProvider> + </presentationUtil.ContextProvider> + </LegacyServicesProvider> + </ServicesContextProvider> </KibanaContextProvider>, element ); @@ -89,7 +102,7 @@ export const initializeCanvas = async ( // of our bundle entry point. Moving them here pushes that load to when canvas is actually loaded. const canvasFunctions = initFunctions({ timefilter: setupPlugins.data.query.timefilter.timefilter, - prependBasePath: coreSetup.http.basePath.prepend, + prependBasePath: coreStart.http.basePath.prepend, types: setupPlugins.expressions.getTypes(), paletteService: await setupPlugins.charts.palettes.getPalettes(), }); @@ -99,7 +112,7 @@ export const initializeCanvas = async ( } // Create Store - const canvasStore = await createStore(coreSetup, setupPlugins); + const canvasStore = await createStore(coreSetup); registerLanguage(Object.values(services.expressions.getService().getFunctions())); @@ -147,7 +160,7 @@ export const initializeCanvas = async ( return canvasStore; }; -export const teardownCanvas = (coreStart: CoreStart, startPlugins: CanvasStartDeps) => { +export const teardownCanvas = (coreStart: CoreStart) => { destroyRegistries(); // Canvas pollutes the jQuery plot plugins collection with custom plugins that only work in Canvas. diff --git a/x-pack/plugins/canvas/public/components/home/__snapshots__/home.stories.storyshot b/x-pack/plugins/canvas/public/components/home/__snapshots__/home.stories.storyshot new file mode 100644 index 0000000000000..770e94ec4b174 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/__snapshots__/home.stories.storyshot @@ -0,0 +1,133 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Storyshots Home Home Page 1`] = ` +<div + className="euiPage euiPage--grow euiPageTemplate" + style={ + Object { + "minHeight": 460, + } + } +> + <div + className="euiPageBody euiPageBody--borderRadiusNone" + > + <header + className="euiPageHeader euiPageHeader--paddingLarge euiPageHeader--responsive euiPageHeader--tabsAtBottom euiPage--restrictWidth-default euiPageHeader--center" + > + <div + className="euiPageHeaderContent" + > + <div + className="euiFlexGroup euiFlexGroup--gutterLarge euiFlexGroup--alignItemsFlexStart euiFlexGroup--directionRow euiFlexGroup--responsive euiPageHeaderContent__top" + > + <div + className="euiFlexItem" + > + <h1 + className="euiTitle euiTitle--large" + > + Canvas + </h1> + </div> + <div + className="euiFlexItem euiFlexItem--flexGrowZero" + > + <div + className="euiFlexGroup euiFlexGroup--gutterLarge euiFlexGroup--directionRow euiFlexGroup--wrap euiPageHeaderContent__rightSideItems" + > + <div + className="euiFlexItem euiFlexItem--flexGrowZero" + > + <button + className="euiButton euiButton--primary euiButton--fill" + data-test-subj="create-workpad-button" + disabled={false} + onClick={[Function]} + style={ + Object { + "minWidth": undefined, + } + } + type="button" + > + <span + className="euiButtonContent euiButton__content" + > + <span + className="euiButtonContent__icon" + color="inherit" + data-euiicon-type="plusInCircleFilled" + size="m" + /> + <span + className="euiButton__text" + > + Create workpad + </span> + </span> + </button> + </div> + </div> + </div> + </div> + <div + className="euiPageHeaderContent__bottom" + > + <div + className="euiSpacer euiSpacer--l" + /> + <div + className="euiTabs euiTabs--condensed euiTabs--large" + role="tablist" + > + <button + aria-selected={true} + className="euiTab euiTab-isSelected" + disabled={false} + id="myWorkpads" + onClick={[Function]} + role="tab" + type="button" + > + <span + className="euiTab__content" + > + My workpads + </span> + </button> + <button + aria-selected={false} + className="euiTab" + data-test-subj="workpadTemplates" + disabled={false} + id="workpadTemplates" + onClick={[Function]} + role="tab" + type="button" + > + <span + className="euiTab__content" + > + Templates + </span> + </button> + </div> + </div> + </div> + </header> + <div + className="euiPanel euiPanel--borderRadiusNone euiPanel--plain euiPanel--noShadow euiPageContent euiPageContent--borderRadiusNone" + role="main" + > + <div + className="euiPageContentBody euiPage--paddingLarge euiPage--restrictWidth-default" + > + <span + className="euiLoadingSpinner euiLoadingSpinner--medium" + /> + </div> + </div> + </div> +</div> +`; diff --git a/x-pack/plugins/canvas/public/components/home/home.stories.tsx b/x-pack/plugins/canvas/public/components/home/home.stories.tsx index 186b916afa003..0130f9f3f894b 100644 --- a/x-pack/plugins/canvas/public/components/home/home.stories.tsx +++ b/x-pack/plugins/canvas/public/components/home/home.stories.tsx @@ -7,24 +7,17 @@ import React from 'react'; -import { - reduxDecorator, - getAddonPanelParameters, - servicesContextDecorator, - getDisableStoryshotsParameter, -} from '../../../storybook'; +import { reduxDecorator } from '../../../storybook'; +import { argTypes } from '../../services/storybook'; -import { Home } from './home.component'; +import { Home } from './home'; export default { - title: 'Home/Home Page', - argTypes: {}, + title: 'Home', + component: Home, + argTypes, decorators: [reduxDecorator()], - parameters: { ...getAddonPanelParameters(), ...getDisableStoryshotsParameter() }, + parameters: {}, }; -export const NoContent = () => <Home />; -export const HasContent = () => <Home />; - -NoContent.decorators = [servicesContextDecorator()]; -HasContent.decorators = [servicesContextDecorator({ findWorkpads: 5, findTemplates: true })]; +export const HomePage = () => <Home />; diff --git a/x-pack/plugins/canvas/public/components/home/my_workpads/__snapshots__/empty_prompt.stories.storyshot b/x-pack/plugins/canvas/public/components/home/my_workpads/__snapshots__/empty_prompt.stories.storyshot new file mode 100644 index 0000000000000..c6468cf5a6f0a --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/my_workpads/__snapshots__/empty_prompt.stories.storyshot @@ -0,0 +1,65 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Storyshots Home/Components/Empty Prompt Empty Prompt 1`] = ` +<div + className="euiFlexGroup euiFlexGroup--gutterLarge euiFlexGroup--alignItemsCenter euiFlexGroup--justifyContentSpaceAround euiFlexGroup--directionRow euiFlexGroup--responsive" + style={ + Object { + "minHeight": 600, + } + } +> + <div + className="euiFlexItem euiFlexItem--flexGrowZero" + > + <div + className="euiPanel euiPanel--paddingMedium euiPanel--borderRadiusNone euiPanel--subdued euiPanel--noShadow euiPanel--noBorder" + > + <div + className="euiEmptyPrompt" + color="subdued" + > + <span + color="subdued" + data-euiicon-type="importAction" + size="xxl" + /> + <div + className="euiSpacer euiSpacer--m" + /> + <h2 + className="euiTitle euiTitle--medium" + > + Add your first workpad + </h2> + <span + className="euiTextColor euiTextColor--subdued" + > + <div + className="euiSpacer euiSpacer--m" + /> + <div + className="euiText euiText--medium" + > + <p> + Create a new workpad, start from a template, or import a workpad JSON file by dropping it here. + </p> + <p> + New to Canvas? + + <a + className="euiLink euiLink--primary" + href="home#/tutorial_directory/sampleData" + rel="noreferrer" + > + Add your first workpad + </a> + . + </p> + </div> + </span> + </div> + </div> + </div> +</div> +`; diff --git a/x-pack/plugins/canvas/public/components/home/my_workpads/__snapshots__/my_workpads.stories.storyshot b/x-pack/plugins/canvas/public/components/home/my_workpads/__snapshots__/my_workpads.stories.storyshot new file mode 100644 index 0000000000000..d081dffd219b0 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/my_workpads/__snapshots__/my_workpads.stories.storyshot @@ -0,0 +1,24 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Storyshots Home/Tabs/My Workpads My Workpads 1`] = ` +<div + className="euiPanel euiPanel--paddingMedium euiPanel--borderRadiusMedium euiPanel--plain euiPanel--hasShadow" +> + <div + className="euiFlexGroup euiFlexGroup--gutterLarge euiFlexGroup--alignItemsCenter euiFlexGroup--justifyContentSpaceAround euiFlexGroup--directionRow euiFlexGroup--responsive" + style={ + Object { + "minHeight": 600, + } + } + > + <div + className="euiFlexItem euiFlexItem--flexGrowZero" + > + <span + className="euiLoadingSpinner euiLoadingSpinner--xLarge" + /> + </div> + </div> +</div> +`; diff --git a/x-pack/plugins/canvas/public/components/home/my_workpads/__snapshots__/workpad_table.stories.storyshot b/x-pack/plugins/canvas/public/components/home/my_workpads/__snapshots__/workpad_table.stories.storyshot new file mode 100644 index 0000000000000..44c9160cdf544 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/my_workpads/__snapshots__/workpad_table.stories.storyshot @@ -0,0 +1,974 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Storyshots Home/Components/Workpad Table Workpad Table 1`] = ` +<div + className="euiPanel euiPanel--paddingMedium euiPanel--borderRadiusMedium euiPanel--plain euiPanel--hasShadow" +> + <div> + <div + className="euiFlexGroup euiFlexGroup--gutterMedium euiFlexGroup--alignItemsCenter euiFlexGroup--directionRow euiFlexGroup--responsive euiFlexGroup--wrap" + > + <div + className="euiFlexItem euiSearchBar__searchHolder" + > + <div + className="euiFormControlLayout euiFormControlLayout--fullWidth" + > + <div + className="euiFormControlLayout__childrenWrapper" + > + <input + aria-label="This is a search bar. As you type, the results lower in the page will automatically filter." + className="euiFieldSearch euiFieldSearch--fullWidth" + data-test-subj="tableListSearchBox" + defaultValue="" + onKeyUp={[Function]} + placeholder="Find workpad" + type="search" + /> + <div + className="euiFormControlLayoutIcons" + > + <span + className="euiFormControlLayoutCustomIcon" + > + <span + aria-hidden="true" + className="euiFormControlLayoutCustomIcon__icon" + data-euiicon-type="search" + size="m" + /> + </span> + </div> + </div> + </div> + </div> + <div + className="euiFlexItem euiFlexItem--flexGrowZero" + > + <div + className="euiFilePicker canvasWorkpad__upload--compressed" + > + <div + className="euiFilePicker__wrap" + > + <input + accept="application/json" + aria-describedby="generated-id" + aria-label="Import workpad JSON file" + className="euiFilePicker__input" + disabled={false} + onChange={[Function]} + onDragLeave={[Function]} + onDragOver={[Function]} + onDrop={[Function]} + type="file" + /> + <div + className="euiFilePicker__prompt" + id="generated-id" + > + <span + aria-hidden="true" + className="euiFilePicker__icon" + data-euiicon-type="importAction" + size="m" + /> + <div + className="euiFilePicker__promptText" + > + Import workpad JSON file + </div> + </div> + </div> + </div> + </div> + </div> + <div + className="euiSpacer euiSpacer--l" + /> + <div + className="euiBasicTable" + data-test-subj="canvasWorkpadTable" + > + <div> + <div + className="euiTableHeaderMobile" + > + <div + className="euiFlexGroup euiFlexGroup--gutterLarge euiFlexGroup--alignItemsBaseline euiFlexGroup--justifyContentSpaceBetween euiFlexGroup--directionRow" + > + <div + className="euiFlexItem euiFlexItem--flexGrowZero" + > + <div + className="euiCheckbox" + > + <input + aria-label="Select all rows" + checked={false} + className="euiCheckbox__input" + disabled={false} + id="_selection_column-checkbox_generated-id" + onChange={[Function]} + type="checkbox" + /> + <div + className="euiCheckbox__square" + /> + <label + className="euiCheckbox__label" + htmlFor="_selection_column-checkbox_generated-id" + > + Select all rows + </label> + </div> + </div> + <div + className="euiFlexItem euiFlexItem--flexGrowZero" + > + <div + className="euiTableSortMobile" + > + <div + className="euiPopover euiPopover--anchorDownRight" + > + <div + className="euiPopover__anchor" + > + <button + className="euiButtonEmpty euiButtonEmpty--primary euiButtonEmpty--xSmall euiButtonEmpty--flushRight" + disabled={false} + onClick={[Function]} + type="button" + > + <span + className="euiButtonContent euiButtonContent--iconRight euiButtonEmpty__content" + > + <span + className="euiButtonContent__icon" + color="inherit" + data-euiicon-type="arrowDown" + size="s" + /> + <span + className="euiButtonEmpty__text" + > + Sorting + </span> + </span> + </button> + </div> + </div> + </div> + </div> + </div> + </div> + <table + className="euiTable euiTable--responsive" + id="generated-id" + tabIndex={-1} + > + <caption + className="euiScreenReaderOnly euiTableCaption" + /> + <thead> + <tr> + <th + className="euiTableHeaderCellCheckbox" + scope="col" + style={ + Object { + "width": undefined, + } + } + > + <div + className="euiTableCellContent" + > + <div + className="euiCheckbox euiCheckbox--inList euiCheckbox--noLabel" + > + <input + aria-label="Select all rows" + checked={false} + className="euiCheckbox__input" + data-test-subj="checkboxSelectAll" + disabled={false} + id="_selection_column-checkbox_generated-id" + onChange={[Function]} + type="checkbox" + /> + <div + className="euiCheckbox__square" + /> + </div> + </div> + </th> + <th + aria-live="polite" + aria-sort="none" + className="euiTableHeaderCell" + data-test-subj="tableHeaderCell_name_0" + role="columnheader" + scope="col" + style={ + Object { + "width": undefined, + } + } + > + <button + className="euiTableHeaderButton" + data-test-subj="tableHeaderSortButton" + onClick={[Function]} + type="button" + > + <span + className="euiTableCellContent" + > + <span + className="euiTableCellContent__text" + > + Workpad name + </span> + </span> + </button> + </th> + <th + aria-live="polite" + aria-sort="none" + className="euiTableHeaderCell" + data-test-subj="tableHeaderCell_@created_1" + role="columnheader" + scope="col" + style={ + Object { + "width": "20%", + } + } + > + <button + className="euiTableHeaderButton" + data-test-subj="tableHeaderSortButton" + onClick={[Function]} + type="button" + > + <span + className="euiTableCellContent" + > + <span + className="euiTableCellContent__text" + > + Created + </span> + </span> + </button> + </th> + <th + aria-live="polite" + aria-sort="descending" + className="euiTableHeaderCell" + data-test-subj="tableHeaderCell_@timestamp_2" + role="columnheader" + scope="col" + style={ + Object { + "width": "20%", + } + } + > + <button + className="euiTableHeaderButton euiTableHeaderButton-isSorted" + data-test-subj="tableHeaderSortButton" + onClick={[Function]} + type="button" + > + <span + className="euiTableCellContent" + > + <span + className="euiTableCellContent__text" + > + Updated + </span> + <span + className="euiTableSortIcon" + data-euiicon-type="sortDown" + size="m" + /> + </span> + </button> + </th> + <th + className="euiTableHeaderCell" + role="columnheader" + scope="col" + style={ + Object { + "width": "100px", + } + } + > + <span + className="euiTableCellContent euiTableCellContent--alignRight" + > + <span + className="euiTableCellContent__text" + > + Actions + </span> + </span> + </th> + </tr> + </thead> + <tbody> + <tr + className="euiTableRow euiTableRow-isSelectable euiTableRow-hasActions" + > + <td + className="euiTableRowCellCheckbox" + > + <div + className="euiTableCellContent" + > + <div + className="euiCheckbox euiCheckbox--inList euiCheckbox--noLabel" + > + <input + aria-label="Select this row" + checked={false} + className="euiCheckbox__input" + data-test-subj="checkboxSelectRow-workpad-2" + disabled={false} + id="_selection_column_workpad-2-checkbox" + onChange={[Function]} + title="Select this row" + type="checkbox" + /> + <div + className="euiCheckbox__square" + /> + </div> + </div> + </td> + <td + className="euiTableRowCell" + style={ + Object { + "width": undefined, + } + } + > + <div + className="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop" + > + Workpad name + </div> + <div + className="euiTableCellContent euiTableCellContent--overflowingContent" + > + <a + aria-label="Load workpad 'Workpad 2'" + className="euiLink euiLink--primary" + data-test-subj="canvasWorkpadTableWorkpad" + href="/workpad/workpad-2" + rel="noreferrer" + > + <span> + Workpad 2 + </span> + </a> + </div> + </td> + <td + className="euiTableRowCell" + style={ + Object { + "width": "20%", + } + } + > + <div + className="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop" + > + Created + </div> + <div + className="euiTableCellContent euiTableCellContent--overflowingContent" + > + Jan 3, 2000 @ 00:00:00.000 + </div> + </td> + <td + className="euiTableRowCell" + style={ + Object { + "width": "20%", + } + } + > + <div + className="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop" + > + Updated + </div> + <div + className="euiTableCellContent euiTableCellContent--overflowingContent" + > + Jan 4, 2000 @ 00:00:00.000 + </div> + </td> + <td + className="euiTableRowCell euiTableRowCell--hasActions" + style={ + Object { + "width": undefined, + } + } + > + <div + className="euiTableCellContent euiTableCellContent--alignRight euiTableCellContent--showOnHover euiTableCellContent--overflowingContent" + > + <div + className="euiTableCellContent__hoverItem" + > + <div + className="euiFlexGroup euiFlexGroup--gutterExtraSmall euiFlexGroup--alignItemsCenter euiFlexGroup--directionRow euiFlexGroup--responsive" + onBlur={[Function]} + onFocus={[Function]} + > + <div + className="euiFlexItem euiFlexItem--flexGrowZero" + > + <span + className="euiToolTipAnchor" + onKeyUp={[Function]} + onMouseOut={[Function]} + onMouseOver={[Function]} + > + <button + aria-label="Export workpad" + className="euiButtonIcon euiButtonIcon--primary euiButtonIcon--empty euiButtonIcon--xSmall" + disabled={false} + onBlur={[Function]} + onClick={[Function]} + onFocus={[Function]} + type="button" + > + <span + aria-hidden="true" + className="euiButtonIcon__icon" + color="inherit" + data-euiicon-type="exportAction" + size="m" + /> + </button> + </span> + </div> + <div + className="euiFlexItem euiFlexItem--flexGrowZero" + > + <span + className="euiToolTipAnchor" + onKeyUp={[Function]} + onMouseOut={[Function]} + onMouseOver={[Function]} + > + <button + aria-label="Clone workpad" + className="euiButtonIcon euiButtonIcon--primary euiButtonIcon--empty euiButtonIcon--xSmall" + disabled={false} + onBlur={[Function]} + onClick={[Function]} + onFocus={[Function]} + type="button" + > + <span + aria-hidden="true" + className="euiButtonIcon__icon" + color="inherit" + data-euiicon-type="copy" + size="m" + /> + </button> + </span> + </div> + </div> + </div> + </div> + </td> + </tr> + <tr + className="euiTableRow euiTableRow-isSelectable euiTableRow-hasActions" + > + <td + className="euiTableRowCellCheckbox" + > + <div + className="euiTableCellContent" + > + <div + className="euiCheckbox euiCheckbox--inList euiCheckbox--noLabel" + > + <input + aria-label="Select this row" + checked={false} + className="euiCheckbox__input" + data-test-subj="checkboxSelectRow-workpad-1" + disabled={false} + id="_selection_column_workpad-1-checkbox" + onChange={[Function]} + title="Select this row" + type="checkbox" + /> + <div + className="euiCheckbox__square" + /> + </div> + </div> + </td> + <td + className="euiTableRowCell" + style={ + Object { + "width": undefined, + } + } + > + <div + className="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop" + > + Workpad name + </div> + <div + className="euiTableCellContent euiTableCellContent--overflowingContent" + > + <a + aria-label="Load workpad 'Workpad 1'" + className="euiLink euiLink--primary" + data-test-subj="canvasWorkpadTableWorkpad" + href="/workpad/workpad-1" + rel="noreferrer" + > + <span> + Workpad 1 + </span> + </a> + </div> + </td> + <td + className="euiTableRowCell" + style={ + Object { + "width": "20%", + } + } + > + <div + className="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop" + > + Created + </div> + <div + className="euiTableCellContent euiTableCellContent--overflowingContent" + > + Jan 2, 2000 @ 00:00:00.000 + </div> + </td> + <td + className="euiTableRowCell" + style={ + Object { + "width": "20%", + } + } + > + <div + className="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop" + > + Updated + </div> + <div + className="euiTableCellContent euiTableCellContent--overflowingContent" + > + Jan 3, 2000 @ 00:00:00.000 + </div> + </td> + <td + className="euiTableRowCell euiTableRowCell--hasActions" + style={ + Object { + "width": undefined, + } + } + > + <div + className="euiTableCellContent euiTableCellContent--alignRight euiTableCellContent--showOnHover euiTableCellContent--overflowingContent" + > + <div + className="euiTableCellContent__hoverItem" + > + <div + className="euiFlexGroup euiFlexGroup--gutterExtraSmall euiFlexGroup--alignItemsCenter euiFlexGroup--directionRow euiFlexGroup--responsive" + onBlur={[Function]} + onFocus={[Function]} + > + <div + className="euiFlexItem euiFlexItem--flexGrowZero" + > + <span + className="euiToolTipAnchor" + onKeyUp={[Function]} + onMouseOut={[Function]} + onMouseOver={[Function]} + > + <button + aria-label="Export workpad" + className="euiButtonIcon euiButtonIcon--primary euiButtonIcon--empty euiButtonIcon--xSmall" + disabled={false} + onBlur={[Function]} + onClick={[Function]} + onFocus={[Function]} + type="button" + > + <span + aria-hidden="true" + className="euiButtonIcon__icon" + color="inherit" + data-euiicon-type="exportAction" + size="m" + /> + </button> + </span> + </div> + <div + className="euiFlexItem euiFlexItem--flexGrowZero" + > + <span + className="euiToolTipAnchor" + onKeyUp={[Function]} + onMouseOut={[Function]} + onMouseOver={[Function]} + > + <button + aria-label="Clone workpad" + className="euiButtonIcon euiButtonIcon--primary euiButtonIcon--empty euiButtonIcon--xSmall" + disabled={false} + onBlur={[Function]} + onClick={[Function]} + onFocus={[Function]} + type="button" + > + <span + aria-hidden="true" + className="euiButtonIcon__icon" + color="inherit" + data-euiicon-type="copy" + size="m" + /> + </button> + </span> + </div> + </div> + </div> + </div> + </td> + </tr> + <tr + className="euiTableRow euiTableRow-isSelectable euiTableRow-hasActions" + > + <td + className="euiTableRowCellCheckbox" + > + <div + className="euiTableCellContent" + > + <div + className="euiCheckbox euiCheckbox--inList euiCheckbox--noLabel" + > + <input + aria-label="Select this row" + checked={false} + className="euiCheckbox__input" + data-test-subj="checkboxSelectRow-workpad-0" + disabled={false} + id="_selection_column_workpad-0-checkbox" + onChange={[Function]} + title="Select this row" + type="checkbox" + /> + <div + className="euiCheckbox__square" + /> + </div> + </div> + </td> + <td + className="euiTableRowCell" + style={ + Object { + "width": undefined, + } + } + > + <div + className="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop" + > + Workpad name + </div> + <div + className="euiTableCellContent euiTableCellContent--overflowingContent" + > + <a + aria-label="Load workpad 'Workpad 0'" + className="euiLink euiLink--primary" + data-test-subj="canvasWorkpadTableWorkpad" + href="/workpad/workpad-0" + rel="noreferrer" + > + <span> + Workpad 0 + </span> + </a> + </div> + </td> + <td + className="euiTableRowCell" + style={ + Object { + "width": "20%", + } + } + > + <div + className="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop" + > + Created + </div> + <div + className="euiTableCellContent euiTableCellContent--overflowingContent" + > + Jan 1, 2000 @ 00:00:00.000 + </div> + </td> + <td + className="euiTableRowCell" + style={ + Object { + "width": "20%", + } + } + > + <div + className="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop" + > + Updated + </div> + <div + className="euiTableCellContent euiTableCellContent--overflowingContent" + > + Jan 2, 2000 @ 00:00:00.000 + </div> + </td> + <td + className="euiTableRowCell euiTableRowCell--hasActions" + style={ + Object { + "width": undefined, + } + } + > + <div + className="euiTableCellContent euiTableCellContent--alignRight euiTableCellContent--showOnHover euiTableCellContent--overflowingContent" + > + <div + className="euiTableCellContent__hoverItem" + > + <div + className="euiFlexGroup euiFlexGroup--gutterExtraSmall euiFlexGroup--alignItemsCenter euiFlexGroup--directionRow euiFlexGroup--responsive" + onBlur={[Function]} + onFocus={[Function]} + > + <div + className="euiFlexItem euiFlexItem--flexGrowZero" + > + <span + className="euiToolTipAnchor" + onKeyUp={[Function]} + onMouseOut={[Function]} + onMouseOver={[Function]} + > + <button + aria-label="Export workpad" + className="euiButtonIcon euiButtonIcon--primary euiButtonIcon--empty euiButtonIcon--xSmall" + disabled={false} + onBlur={[Function]} + onClick={[Function]} + onFocus={[Function]} + type="button" + > + <span + aria-hidden="true" + className="euiButtonIcon__icon" + color="inherit" + data-euiicon-type="exportAction" + size="m" + /> + </button> + </span> + </div> + <div + className="euiFlexItem euiFlexItem--flexGrowZero" + > + <span + className="euiToolTipAnchor" + onKeyUp={[Function]} + onMouseOut={[Function]} + onMouseOver={[Function]} + > + <button + aria-label="Clone workpad" + className="euiButtonIcon euiButtonIcon--primary euiButtonIcon--empty euiButtonIcon--xSmall" + disabled={false} + onBlur={[Function]} + onClick={[Function]} + onFocus={[Function]} + type="button" + > + <span + aria-hidden="true" + className="euiButtonIcon__icon" + color="inherit" + data-euiicon-type="copy" + size="m" + /> + </button> + </span> + </div> + </div> + </div> + </div> + </td> + </tr> + </tbody> + </table> + </div> + <div> + <div + className="euiSpacer euiSpacer--m" + /> + <div + className="euiFlexGroup euiFlexGroup--gutterLarge euiFlexGroup--alignItemsCenter euiFlexGroup--justifyContentSpaceBetween euiFlexGroup--directionRow" + > + <div + className="euiFlexItem euiFlexItem--flexGrowZero" + > + <div + className="euiPopover euiPopover--anchorUpRight" + > + <div + className="euiPopover__anchor" + > + <button + className="euiButtonEmpty euiButtonEmpty--text euiButtonEmpty--xSmall" + data-test-subj="tablePaginationPopoverButton" + disabled={false} + onClick={[Function]} + type="button" + > + <span + className="euiButtonContent euiButtonContent--iconRight euiButtonEmpty__content" + > + <span + className="euiButtonContent__icon" + color="inherit" + data-euiicon-type="arrowDown" + size="s" + /> + <span + className="euiButtonEmpty__text" + > + Rows per page + : + 10 + </span> + </span> + </button> + </div> + </div> + </div> + <div + className="euiFlexItem euiFlexItem--flexGrowZero" + > + <nav + className="euiPagination" + > + <button + aria-label="Previous page" + className="euiButtonIcon euiButtonIcon--text euiButtonIcon--empty euiButtonIcon--xSmall" + data-test-subj="pagination-button-previous" + disabled={true} + onClick={[Function]} + type="button" + > + <span + aria-hidden="true" + className="euiButtonIcon__icon" + color="inherit" + data-euiicon-type="arrowLeft" + size="m" + /> + </button> + <ul + className="euiPagination__list" + > + <li + className="euiPagination__item" + > + <button + aria-controls="generated-id" + aria-current={true} + aria-label="Page 1 of 1" + className="euiButtonEmpty euiButtonEmpty--text euiButtonEmpty--small euiButtonEmpty-isDisabled euiPaginationButton euiPaginationButton-isActive euiPaginationButton--hideOnMobile" + data-test-subj="pagination-button-0" + disabled={true} + onClick={[Function]} + type="button" + > + <span + className="euiButtonContent euiButtonEmpty__content" + > + <span + className="euiButtonEmpty__text" + > + 1 + </span> + </span> + </button> + </li> + </ul> + <button + aria-label="Next page" + className="euiButtonIcon euiButtonIcon--text euiButtonIcon--empty euiButtonIcon--xSmall" + data-test-subj="pagination-button-next" + disabled={true} + onClick={[Function]} + type="button" + > + <span + aria-hidden="true" + className="euiButtonIcon__icon" + color="inherit" + data-euiicon-type="arrowRight" + size="m" + /> + </button> + </nav> + </div> + </div> + </div> + </div> + </div> +</div> +`; diff --git a/x-pack/plugins/canvas/public/components/home/my_workpads/empty_prompt.stories.tsx b/x-pack/plugins/canvas/public/components/home/my_workpads/empty_prompt.stories.tsx index aef1b0625b585..2d457b913c79f 100644 --- a/x-pack/plugins/canvas/public/components/home/my_workpads/empty_prompt.stories.tsx +++ b/x-pack/plugins/canvas/public/components/home/my_workpads/empty_prompt.stories.tsx @@ -8,12 +8,11 @@ import React from 'react'; import { HomeEmptyPrompt } from './empty_prompt'; -import { getDisableStoryshotsParameter } from '../../../../storybook'; export default { - title: 'Home/Empty Prompt', + title: 'Home/Components/Empty Prompt', argTypes: {}, - parameters: { ...getDisableStoryshotsParameter() }, + parameters: {}, }; export const EmptyPrompt = () => <HomeEmptyPrompt />; diff --git a/x-pack/plugins/canvas/public/components/home/my_workpads/my_workpads.stories.tsx b/x-pack/plugins/canvas/public/components/home/my_workpads/my_workpads.stories.tsx index 0d5d6ca16f614..52afd552bbc49 100644 --- a/x-pack/plugins/canvas/public/components/home/my_workpads/my_workpads.stories.tsx +++ b/x-pack/plugins/canvas/public/components/home/my_workpads/my_workpads.stories.tsx @@ -5,52 +5,29 @@ * 2.0. */ -import React, { useState } from 'react'; +import React from 'react'; import { EuiPanel } from '@elastic/eui'; -import { - reduxDecorator, - getAddonPanelParameters, - servicesContextDecorator, - getDisableStoryshotsParameter, -} from '../../../../storybook'; -import { getSomeWorkpads } from '../../../services/stubs/workpad'; +import { reduxDecorator } from '../../../../storybook'; +import { argTypes } from '../../../services/storybook'; -import { MyWorkpads, WorkpadsContext } from './my_workpads'; -import { MyWorkpads as MyWorkpadsComponent } from './my_workpads.component'; +import { MyWorkpads as Component } from './my_workpads'; + +const { workpadCount, useStaticData } = argTypes; export default { - title: 'Home/My Workpads', - argTypes: {}, + title: 'Home/Tabs/My Workpads', + component: Component, + argTypes: { + workpadCount, + useStaticData, + }, decorators: [reduxDecorator()], - parameters: { ...getAddonPanelParameters(), ...getDisableStoryshotsParameter() }, -}; - -export const NoWorkpads = () => { - return <MyWorkpads />; -}; - -export const HasWorkpads = () => { - return ( - <EuiPanel> - <MyWorkpads /> - </EuiPanel> - ); -}; - -NoWorkpads.decorators = [servicesContextDecorator()]; -HasWorkpads.decorators = [servicesContextDecorator({ findWorkpads: 5 })]; - -export const Component = ({ workpadCount }: { workpadCount: number }) => { - const [workpads, setWorkpads] = useState(getSomeWorkpads(workpadCount)); - - return ( - <WorkpadsContext.Provider value={{ workpads, setWorkpads }}> - <EuiPanel> - <MyWorkpadsComponent {...{ workpads }} /> - </EuiPanel> - </WorkpadsContext.Provider> - ); + parameters: {}, }; -Component.args = { workpadCount: 5 }; +export const MyWorkpads = () => ( + <EuiPanel> + <Component /> + </EuiPanel> +); diff --git a/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table.stories.tsx b/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table.stories.tsx index 501a0a76a8589..6675dea238cc4 100644 --- a/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table.stories.tsx +++ b/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table.stories.tsx @@ -8,76 +8,35 @@ import React, { useState, useEffect } from 'react'; import { EuiPanel } from '@elastic/eui'; -import { action } from '@storybook/addon-actions'; -import { - reduxDecorator, - getAddonPanelParameters, - getDisableStoryshotsParameter, -} from '../../../../storybook'; -import { getSomeWorkpads } from '../../../services/stubs/workpad'; +import { reduxDecorator } from '../../../../storybook'; -import { WorkpadTable } from './workpad_table'; -import { WorkpadTable as WorkpadTableComponent } from './workpad_table.component'; +import { argTypes } from '../../../services/storybook'; +import { getSomeWorkpads } from '../../../services/stubs/workpad'; +import { WorkpadTable as Component } from './workpad_table'; import { WorkpadsContext } from './my_workpads'; +const { workpadCount } = argTypes; + export default { - title: 'Home/Workpad Table', - argTypes: {}, + title: 'Home/Components/Workpad Table', + argTypes: { workpadCount }, decorators: [reduxDecorator()], - parameters: { ...getAddonPanelParameters(), ...getDisableStoryshotsParameter() }, -}; - -export const NoWorkpads = () => { - const [workpads, setWorkpads] = useState(getSomeWorkpads(0)); - - return ( - <WorkpadsContext.Provider value={{ workpads, setWorkpads }}> - <EuiPanel> - <WorkpadTable /> - </EuiPanel> - </WorkpadsContext.Provider> - ); + parameters: {}, }; -export const HasWorkpads = () => { - const [workpads, setWorkpads] = useState(getSomeWorkpads(5)); - - return ( - <WorkpadsContext.Provider value={{ workpads, setWorkpads }}> - <EuiPanel> - <WorkpadTable /> - </EuiPanel> - </WorkpadsContext.Provider> - ); -}; - -export const Component = ({ - workpadCount, - canUserWrite, - dateFormat, -}: { - workpadCount: number; - canUserWrite: boolean; - dateFormat: string; -}) => { - const [workpads, setWorkpads] = useState(getSomeWorkpads(workpadCount)); +export const WorkpadTable = (args: { findWorkpads: number }) => { + const { findWorkpads } = args; + const [workpads, setWorkpads] = useState(getSomeWorkpads(findWorkpads)); useEffect(() => { - setWorkpads(getSomeWorkpads(workpadCount)); - }, [workpadCount]); + setWorkpads(getSomeWorkpads(findWorkpads)); + }, [findWorkpads]); return ( - <WorkpadsContext.Provider value={{ workpads, setWorkpads }}> - <EuiPanel> - <WorkpadTableComponent - {...{ workpads, canUserWrite, dateFormat }} - onCloneWorkpad={action('onCloneWorkpad')} - onExportWorkpad={action('onExportWorkpad')} - /> - </EuiPanel> - </WorkpadsContext.Provider> + <EuiPanel> + <WorkpadsContext.Provider value={{ workpads, setWorkpads }}> + <Component /> + </WorkpadsContext.Provider> + </EuiPanel> ); }; - -Component.args = { workpadCount: 5, canUserWrite: true, dateFormat: 'MMM D, YYYY @ HH:mm:ss.SSS' }; -Component.argTypes = {}; diff --git a/x-pack/plugins/canvas/public/components/home/workpad_templates/__snapshots__/workpad_templates.stories.storyshot b/x-pack/plugins/canvas/public/components/home/workpad_templates/__snapshots__/workpad_templates.stories.storyshot new file mode 100644 index 0000000000000..7226726354834 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/workpad_templates/__snapshots__/workpad_templates.stories.storyshot @@ -0,0 +1,24 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Storyshots Home/Tabs/Workpad Templates Workpad Templates 1`] = ` +<div + className="euiPanel euiPanel--paddingMedium euiPanel--borderRadiusMedium euiPanel--plain euiPanel--hasShadow" +> + <div + className="euiFlexGroup euiFlexGroup--gutterLarge euiFlexGroup--alignItemsCenter euiFlexGroup--justifyContentSpaceAround euiFlexGroup--directionRow euiFlexGroup--responsive" + style={ + Object { + "minHeight": 600, + } + } + > + <div + className="euiFlexItem euiFlexItem--flexGrowZero" + > + <span + className="euiLoadingSpinner euiLoadingSpinner--xLarge" + /> + </div> + </div> +</div> +`; diff --git a/x-pack/plugins/canvas/public/components/home/workpad_templates/workpad_templates.stories.tsx b/x-pack/plugins/canvas/public/components/home/workpad_templates/workpad_templates.stories.tsx index cb2b872ea15f9..92583ca845aa8 100644 --- a/x-pack/plugins/canvas/public/components/home/workpad_templates/workpad_templates.stories.tsx +++ b/x-pack/plugins/canvas/public/components/home/workpad_templates/workpad_templates.stories.tsx @@ -6,57 +6,27 @@ */ import { EuiPanel } from '@elastic/eui'; -import { action } from '@storybook/addon-actions'; import React from 'react'; -import { - reduxDecorator, - getAddonPanelParameters, - servicesContextDecorator, - getDisableStoryshotsParameter, -} from '../../../../storybook'; -import { getSomeTemplates } from '../../../services/stubs/workpad'; +import { reduxDecorator } from '../../../../storybook'; +import { argTypes } from '../../../services/storybook'; -import { WorkpadTemplates } from './workpad_templates'; -import { WorkpadTemplates as WorkpadTemplatesComponent } from './workpad_templates.component'; +import { WorkpadTemplates as Component } from './workpad_templates'; + +const { hasTemplates } = argTypes; export default { - title: 'Home/Workpad Templates', - argTypes: {}, + title: 'Home/Tabs/Workpad Templates', + component: Component, + argTypes: { + hasTemplates, + }, decorators: [reduxDecorator()], - parameters: { ...getAddonPanelParameters(), ...getDisableStoryshotsParameter() }, -}; - -export const NoTemplates = () => { - return ( - <EuiPanel> - <WorkpadTemplates /> - </EuiPanel> - ); -}; - -export const HasTemplates = () => { - return ( - <EuiPanel> - <WorkpadTemplates /> - </EuiPanel> - ); + parameters: {}, }; -NoTemplates.decorators = [servicesContextDecorator()]; -HasTemplates.decorators = [servicesContextDecorator({ findTemplates: true })]; - -export const Component = ({ hasTemplates }: { hasTemplates: boolean }) => { - return ( - <EuiPanel> - <WorkpadTemplatesComponent - onCreateWorkpad={action('onCreateWorkpad')} - templates={hasTemplates ? getSomeTemplates().templates : []} - /> - </EuiPanel> - ); -}; - -Component.args = { - hasTemplates: true, -}; +export const WorkpadTemplates = () => ( + <EuiPanel> + <Component /> + </EuiPanel> +); diff --git a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/__stories__/share_menu.stories.tsx b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/__stories__/share_menu.stories.tsx index 20e52b40bc702..59a7f263fea08 100644 --- a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/__stories__/share_menu.stories.tsx +++ b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/__stories__/share_menu.stories.tsx @@ -8,8 +8,8 @@ import { action } from '@storybook/addon-actions'; import { storiesOf } from '@storybook/react'; import React from 'react'; -import { platformService } from '../../../../services/stubs/platform'; -import { reportingService } from '../../../../services/stubs/reporting'; +import { platformService } from '../../../../services/legacy/stubs/platform'; +import { reportingService } from '../../../../services/legacy/stubs/reporting'; import { ShareMenu } from '../share_menu.component'; storiesOf('components/WorkpadHeader/ShareMenu', module).add('minimal', () => ( diff --git a/x-pack/plugins/canvas/public/plugin.tsx b/x-pack/plugins/canvas/public/plugin.tsx index d31a5a18cecc1..543c159bae145 100644 --- a/x-pack/plugins/canvas/public/plugin.tsx +++ b/x-pack/plugins/canvas/public/plugin.tsx @@ -31,6 +31,9 @@ import { BfetchPublicSetup } from '../../../../src/plugins/bfetch/public'; import { PresentationUtilPluginStart } from '../../../../src/plugins/presentation_util/public'; import { getPluginApi, CanvasApi } from './plugin_api'; import { CanvasSrcPlugin } from '../canvas_plugin_src/plugin'; +import { pluginServices } from './services'; +import { pluginServiceRegistry } from './services/kibana'; + export { CoreStart, CoreSetup }; /** @@ -75,14 +78,14 @@ export class CanvasPlugin // TODO: Do we want to completely move canvas_plugin_src into it's own plugin? private srcPlugin = new CanvasSrcPlugin(); - public setup(core: CoreSetup<CanvasStartDeps>, plugins: CanvasSetupDeps) { - const { api: canvasApi, registries } = getPluginApi(plugins.expressions); + public setup(coreSetup: CoreSetup<CanvasStartDeps>, setupPlugins: CanvasSetupDeps) { + const { api: canvasApi, registries } = getPluginApi(setupPlugins.expressions); - this.srcPlugin.setup(core, { canvas: canvasApi }); + this.srcPlugin.setup(coreSetup, { canvas: canvasApi }); // Set the nav link to the last saved url if we have one in storage const lastPath = getSessionStorage().get( - `${SESSIONSTORAGE_LASTPATH}:${core.http.basePath.get()}` + `${SESSIONSTORAGE_LASTPATH}:${coreSetup.http.basePath.get()}` ); if (lastPath) { this.appUpdater.next(() => ({ @@ -90,7 +93,7 @@ export class CanvasPlugin })); } - core.application.register({ + coreSetup.application.register({ category: DEFAULT_APP_CATEGORIES.kibana, id: 'canvas', title: 'Canvas', @@ -102,28 +105,28 @@ export class CanvasPlugin const { renderApp, initializeCanvas, teardownCanvas } = await import('./application'); // Get start services - const [coreStart, depsStart] = await core.getStartServices(); + const [coreStart, startPlugins] = await coreSetup.getStartServices(); const canvasStore = await initializeCanvas( - core, + coreSetup, coreStart, - plugins, - depsStart, + setupPlugins, + startPlugins, registries, this.appUpdater ); - const unmount = renderApp(coreStart, depsStart, params, canvasStore); + const unmount = renderApp({ coreStart, startPlugins, params, canvasStore, pluginServices }); return () => { unmount(); - teardownCanvas(coreStart, depsStart); + teardownCanvas(coreStart); }; }, }); - if (plugins.home) { - plugins.home.featureCatalogue.register(featureCatalogueEntry); + if (setupPlugins.home) { + setupPlugins.home.featureCatalogue.register(featureCatalogueEntry); } canvasApi.addArgumentUIs(async () => { @@ -141,8 +144,9 @@ export class CanvasPlugin }; } - public start(core: CoreStart, plugins: CanvasStartDeps) { - this.srcPlugin.start(core, plugins); - initLoadingIndicator(core.http.addLoadingCountSource); + public start(coreStart: CoreStart, startPlugins: CanvasStartDeps) { + this.srcPlugin.start(coreStart, startPlugins); + pluginServices.setRegistry(pluginServiceRegistry.start({ coreStart, startPlugins })); + initLoadingIndicator(coreStart.http.addLoadingCountSource); } } diff --git a/x-pack/plugins/canvas/public/routes/workpad/hooks/use_workpad.test.tsx b/x-pack/plugins/canvas/public/routes/workpad/hooks/use_workpad.test.tsx index e77b878359d11..0fd4d3d2401f7 100644 --- a/x-pack/plugins/canvas/public/routes/workpad/hooks/use_workpad.test.tsx +++ b/x-pack/plugins/canvas/public/routes/workpad/hooks/use_workpad.test.tsx @@ -32,10 +32,8 @@ jest.mock('react-redux', () => ({ })); jest.mock('../../../services', () => ({ - useServices: () => ({ - workpad: { - get: mockGetWorkpad, - }, + useWorkpadService: () => ({ + get: mockGetWorkpad, }), })); diff --git a/x-pack/plugins/canvas/public/routes/workpad/hooks/use_workpad.ts b/x-pack/plugins/canvas/public/routes/workpad/hooks/use_workpad.ts index 29b869b46e416..983622dad264d 100644 --- a/x-pack/plugins/canvas/public/routes/workpad/hooks/use_workpad.ts +++ b/x-pack/plugins/canvas/public/routes/workpad/hooks/use_workpad.ts @@ -7,7 +7,7 @@ import { useEffect, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import { useServices } from '../../../services'; +import { useWorkpadService } from '../../../services'; import { getWorkpad } from '../../../state/selectors/workpad'; import { setWorkpad } from '../../../state/actions/workpad'; // @ts-expect-error @@ -20,7 +20,7 @@ export const useWorkpad = ( workpadId: string, loadPages: boolean = true ): [CanvasWorkpad | undefined, string | Error | undefined] => { - const services = useServices(); + const workpadService = useWorkpadService(); const dispatch = useDispatch(); const storedWorkpad = useSelector(getWorkpad); const [error, setError] = useState<string | Error | undefined>(undefined); @@ -28,7 +28,7 @@ export const useWorkpad = ( useEffect(() => { (async () => { try { - const { assets, ...workpad } = await services.workpad.get(workpadId); + const { assets, ...workpad } = await workpadService.get(workpadId); dispatch(setAssets(assets)); dispatch(setWorkpad(workpad, { loadPages })); dispatch(setZoomScale(1)); @@ -36,7 +36,7 @@ export const useWorkpad = ( setError(e); } })(); - }, [workpadId, services.workpad, dispatch, setError, loadPages]); + }, [workpadId, dispatch, setError, loadPages, workpadService]); return [storedWorkpad.id === workpadId ? storedWorkpad : undefined, error]; }; diff --git a/x-pack/plugins/canvas/public/services/index.ts b/x-pack/plugins/canvas/public/services/index.ts index 3f8f58367171a..49408fcec1ec4 100644 --- a/x-pack/plugins/canvas/public/services/index.ts +++ b/x-pack/plugins/canvas/public/services/index.ts @@ -5,128 +5,15 @@ * 2.0. */ -import { BehaviorSubject } from 'rxjs'; -import { CoreSetup, CoreStart, AppUpdater } from '../../../../../src/core/public'; -import { CanvasSetupDeps, CanvasStartDeps } from '../plugin'; -import { notifyServiceFactory } from './notify'; -import { platformServiceFactory } from './platform'; -import { navLinkServiceFactory } from './nav_link'; -import { embeddablesServiceFactory } from './embeddables'; -import { expressionsServiceFactory } from './expressions'; -import { searchServiceFactory } from './search'; -import { labsServiceFactory } from './labs'; -import { reportingServiceFactory } from './reporting'; -import { workpadServiceFactory } from './workpad'; +export * from './legacy'; -export { NotifyService } from './notify'; -export { SearchService } from './search'; -export { PlatformService } from './platform'; -export { NavLinkService } from './nav_link'; -export { EmbeddablesService } from './embeddables'; -export { ExpressionsService } from '../../../../../src/plugins/expressions/common'; -export * from './context'; +import { PluginServices } from '../../../../../src/plugins/presentation_util/public'; +import { CanvasWorkpadService } from './workpad'; -export type CanvasServiceFactory<Service> = ( - coreSetup: CoreSetup, - coreStart: CoreStart, - canvasSetupPlugins: CanvasSetupDeps, - canvasStartPlugins: CanvasStartDeps, - appUpdater: BehaviorSubject<AppUpdater> -) => Service | Promise<Service>; - -export class CanvasServiceProvider<Service> { - private factory: CanvasServiceFactory<Service>; - private service: Service | undefined; - - constructor(factory: CanvasServiceFactory<Service>) { - this.factory = factory; - } - - setService(service: Service) { - this.service = service; - } - - async start( - coreSetup: CoreSetup, - coreStart: CoreStart, - canvasSetupPlugins: CanvasSetupDeps, - canvasStartPlugins: CanvasStartDeps, - appUpdater: BehaviorSubject<AppUpdater> - ) { - this.service = await this.factory( - coreSetup, - coreStart, - canvasSetupPlugins, - canvasStartPlugins, - appUpdater - ); - } - - getService(): Service { - if (!this.service) { - throw new Error('Service not ready'); - } - - return this.service; - } - - stop() { - this.service = undefined; - } +export interface CanvasPluginServices { + workpad: CanvasWorkpadService; } -export type ServiceFromProvider<P> = P extends CanvasServiceProvider<infer T> ? T : never; - -export const services = { - embeddables: new CanvasServiceProvider(embeddablesServiceFactory), - expressions: new CanvasServiceProvider(expressionsServiceFactory), - notify: new CanvasServiceProvider(notifyServiceFactory), - platform: new CanvasServiceProvider(platformServiceFactory), - navLink: new CanvasServiceProvider(navLinkServiceFactory), - search: new CanvasServiceProvider(searchServiceFactory), - reporting: new CanvasServiceProvider(reportingServiceFactory), - labs: new CanvasServiceProvider(labsServiceFactory), - workpad: new CanvasServiceProvider(workpadServiceFactory), -}; - -export type CanvasServiceProviders = typeof services; - -export interface CanvasServices { - embeddables: ServiceFromProvider<typeof services.embeddables>; - expressions: ServiceFromProvider<typeof services.expressions>; - notify: ServiceFromProvider<typeof services.notify>; - platform: ServiceFromProvider<typeof services.platform>; - navLink: ServiceFromProvider<typeof services.navLink>; - search: ServiceFromProvider<typeof services.search>; - reporting: ServiceFromProvider<typeof services.reporting>; - labs: ServiceFromProvider<typeof services.labs>; - workpad: ServiceFromProvider<typeof services.workpad>; -} - -export const startServices = async ( - coreSetup: CoreSetup, - coreStart: CoreStart, - canvasSetupPlugins: CanvasSetupDeps, - canvasStartPlugins: CanvasStartDeps, - appUpdater: BehaviorSubject<AppUpdater> -) => { - const startPromises = Object.values(services).map((provider) => - provider.start(coreSetup, coreStart, canvasSetupPlugins, canvasStartPlugins, appUpdater) - ); - - await Promise.all(startPromises); -}; - -export const stopServices = () => { - Object.values(services).forEach((provider) => provider.stop()); -}; +export const pluginServices = new PluginServices<CanvasPluginServices>(); -export const { - embeddables: embeddableService, - notify: notifyService, - platform: platformService, - navLink: navLinkService, - expressions: expressionsService, - search: searchService, - reporting: reportingService, -} = services; +export const useWorkpadService = () => (() => pluginServices.getHooks().workpad.useService())(); diff --git a/x-pack/plugins/canvas/public/services/kibana/index.ts b/x-pack/plugins/canvas/public/services/kibana/index.ts new file mode 100644 index 0000000000000..99012003b3a15 --- /dev/null +++ b/x-pack/plugins/canvas/public/services/kibana/index.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + PluginServiceProviders, + PluginServiceProvider, + PluginServiceRegistry, + KibanaPluginServiceParams, +} from '../../../../../../src/plugins/presentation_util/public'; + +import { workpadServiceFactory } from './workpad'; +import { CanvasPluginServices } from '..'; +import { CanvasStartDeps } from '../../plugin'; + +export { workpadServiceFactory } from './workpad'; + +export const pluginServiceProviders: PluginServiceProviders< + CanvasPluginServices, + KibanaPluginServiceParams<CanvasStartDeps> +> = { + workpad: new PluginServiceProvider(workpadServiceFactory), +}; + +export const pluginServiceRegistry = new PluginServiceRegistry< + CanvasPluginServices, + KibanaPluginServiceParams<CanvasStartDeps> +>(pluginServiceProviders); diff --git a/x-pack/plugins/canvas/public/services/kibana/workpad.ts b/x-pack/plugins/canvas/public/services/kibana/workpad.ts new file mode 100644 index 0000000000000..36ad1c568f9e6 --- /dev/null +++ b/x-pack/plugins/canvas/public/services/kibana/workpad.ts @@ -0,0 +1,97 @@ +/* + * 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 { KibanaPluginServiceFactory } from '../../../../../../src/plugins/presentation_util/public'; + +import { CanvasStartDeps } from '../../plugin'; +import { CanvasWorkpadService } from '../workpad'; + +import { + API_ROUTE_WORKPAD, + DEFAULT_WORKPAD_CSS, + API_ROUTE_TEMPLATES, +} from '../../../common/lib/constants'; +import { CanvasWorkpad } from '../../../types'; + +export type CanvasWorkpadServiceFactory = KibanaPluginServiceFactory< + CanvasWorkpadService, + CanvasStartDeps +>; + +/* + Remove any top level keys from the workpad which will be rejected by validation +*/ +const validKeys = [ + '@created', + '@timestamp', + 'assets', + 'colors', + 'css', + 'variables', + 'height', + 'id', + 'isWriteable', + 'name', + 'page', + 'pages', + 'width', +]; + +const sanitizeWorkpad = function (workpad: CanvasWorkpad) { + const workpadKeys = Object.keys(workpad); + + for (const key of workpadKeys) { + if (!validKeys.includes(key)) { + delete (workpad as { [key: string]: any })[key]; + } + } + + return workpad; +}; + +export const workpadServiceFactory: CanvasWorkpadServiceFactory = ({ coreStart, startPlugins }) => { + const getApiPath = function () { + return `${API_ROUTE_WORKPAD}`; + }; + + return { + get: async (id: string) => { + const workpad = await coreStart.http.get(`${getApiPath()}/${id}`); + + return { css: DEFAULT_WORKPAD_CSS, variables: [], ...workpad }; + }, + create: (workpad: CanvasWorkpad) => { + return coreStart.http.post(getApiPath(), { + body: JSON.stringify({ + ...sanitizeWorkpad({ ...workpad }), + assets: workpad.assets || {}, + variables: workpad.variables || [], + }), + }); + }, + createFromTemplate: (templateId: string) => { + return coreStart.http.post(getApiPath(), { + body: JSON.stringify({ templateId }), + }); + }, + findTemplates: async () => coreStart.http.get(API_ROUTE_TEMPLATES), + find: (searchTerm: string) => { + // TODO: this shouldn't be necessary. Check for usage. + const validSearchTerm = typeof searchTerm === 'string' && searchTerm.length > 0; + + return coreStart.http.get(`${getApiPath()}/find`, { + query: { + perPage: 10000, + name: validSearchTerm ? searchTerm : '', + }, + }); + }, + remove: (id: string) => { + return coreStart.http.delete(`${getApiPath()}/${id}`); + }, + }; +}; diff --git a/x-pack/plugins/canvas/public/services/context.tsx b/x-pack/plugins/canvas/public/services/legacy/context.tsx similarity index 92% rename from x-pack/plugins/canvas/public/services/context.tsx rename to x-pack/plugins/canvas/public/services/legacy/context.tsx index 3a78e314b9635..7a90c6870df4a 100644 --- a/x-pack/plugins/canvas/public/services/context.tsx +++ b/x-pack/plugins/canvas/public/services/legacy/context.tsx @@ -26,7 +26,6 @@ const defaultContextValue = { platform: {}, navLink: {}, search: {}, - workpad: {}, }; const context = createContext<CanvasServices>(defaultContextValue as CanvasServices); @@ -38,7 +37,6 @@ export const useExpressionsService = () => useServices().expressions; export const useNotifyService = () => useServices().notify; export const useNavLinkService = () => useServices().navLink; export const useLabsService = () => useServices().labs; -export const useWorkpadService = () => useServices().workpad; export const withServices = <Props extends WithServicesProps>(type: ComponentType<Props>) => { const EnhancedType: FC<Props> = (props) => @@ -46,7 +44,7 @@ export const withServices = <Props extends WithServicesProps>(type: ComponentTyp return EnhancedType; }; -export const ServicesProvider: FC<{ +export const LegacyServicesProvider: FC<{ providers?: Partial<CanvasServiceProviders>; children: ReactElement<any>; }> = ({ providers = {}, children }) => { @@ -60,7 +58,6 @@ export const ServicesProvider: FC<{ search: specifiedProviders.search.getService(), reporting: specifiedProviders.reporting.getService(), labs: specifiedProviders.labs.getService(), - workpad: specifiedProviders.workpad.getService(), }; return <context.Provider value={value}>{children}</context.Provider>; }; diff --git a/x-pack/plugins/canvas/public/services/embeddables.ts b/x-pack/plugins/canvas/public/services/legacy/embeddables.ts similarity index 88% rename from x-pack/plugins/canvas/public/services/embeddables.ts rename to x-pack/plugins/canvas/public/services/legacy/embeddables.ts index 1281c60f31782..05a4205c23f9e 100644 --- a/x-pack/plugins/canvas/public/services/embeddables.ts +++ b/x-pack/plugins/canvas/public/services/legacy/embeddables.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { EmbeddableFactory } from '../../../../../src/plugins/embeddable/public'; +import { EmbeddableFactory } from '../../../../../../src/plugins/embeddable/public'; import { CanvasServiceFactory } from '.'; export interface EmbeddablesService { diff --git a/x-pack/plugins/canvas/public/services/expressions.ts b/x-pack/plugins/canvas/public/services/legacy/expressions.ts similarity index 93% rename from x-pack/plugins/canvas/public/services/expressions.ts rename to x-pack/plugins/canvas/public/services/legacy/expressions.ts index 219edb667efc6..99915cad745e3 100644 --- a/x-pack/plugins/canvas/public/services/expressions.ts +++ b/x-pack/plugins/canvas/public/services/legacy/expressions.ts @@ -9,8 +9,8 @@ import { CanvasServiceFactory } from '.'; import { ExpressionsService, serializeProvider, -} from '../../../../../src/plugins/expressions/common'; -import { API_ROUTE_FUNCTIONS } from '../../common/lib/constants'; +} from '../../../../../../src/plugins/expressions/common'; +import { API_ROUTE_FUNCTIONS } from '../../../common/lib/constants'; export const expressionsServiceFactory: CanvasServiceFactory<ExpressionsService> = async ( coreSetup, diff --git a/x-pack/plugins/canvas/public/services/legacy/index.ts b/x-pack/plugins/canvas/public/services/legacy/index.ts new file mode 100644 index 0000000000000..e23057daa7359 --- /dev/null +++ b/x-pack/plugins/canvas/public/services/legacy/index.ts @@ -0,0 +1,129 @@ +/* + * 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 { BehaviorSubject } from 'rxjs'; +import { CoreSetup, CoreStart, AppUpdater } from '../../../../../../src/core/public'; +import { CanvasSetupDeps, CanvasStartDeps } from '../../plugin'; +import { notifyServiceFactory } from './notify'; +import { platformServiceFactory } from './platform'; +import { navLinkServiceFactory } from './nav_link'; +import { embeddablesServiceFactory } from './embeddables'; +import { expressionsServiceFactory } from './expressions'; +import { searchServiceFactory } from './search'; +import { labsServiceFactory } from './labs'; +import { reportingServiceFactory } from './reporting'; + +export { NotifyService } from './notify'; +export { SearchService } from './search'; +export { PlatformService } from './platform'; +export { NavLinkService } from './nav_link'; +export { EmbeddablesService } from './embeddables'; +export { ExpressionsService } from '../../../../../../src/plugins/expressions/common'; +export * from './context'; + +export type CanvasServiceFactory<Service> = ( + coreSetup: CoreSetup, + coreStart: CoreStart, + canvasSetupPlugins: CanvasSetupDeps, + canvasStartPlugins: CanvasStartDeps, + appUpdater: BehaviorSubject<AppUpdater> +) => Service | Promise<Service>; + +export class CanvasServiceProvider<Service> { + private factory: CanvasServiceFactory<Service>; + private service: Service | undefined; + + constructor(factory: CanvasServiceFactory<Service>) { + this.factory = factory; + } + + setService(service: Service) { + this.service = service; + } + + async start( + coreSetup: CoreSetup, + coreStart: CoreStart, + canvasSetupPlugins: CanvasSetupDeps, + canvasStartPlugins: CanvasStartDeps, + appUpdater: BehaviorSubject<AppUpdater> + ) { + this.service = await this.factory( + coreSetup, + coreStart, + canvasSetupPlugins, + canvasStartPlugins, + appUpdater + ); + } + + getService(): Service { + if (!this.service) { + throw new Error('Service not ready'); + } + + return this.service; + } + + stop() { + this.service = undefined; + } +} + +export type ServiceFromProvider<P> = P extends CanvasServiceProvider<infer T> ? T : never; + +export const services = { + embeddables: new CanvasServiceProvider(embeddablesServiceFactory), + expressions: new CanvasServiceProvider(expressionsServiceFactory), + notify: new CanvasServiceProvider(notifyServiceFactory), + platform: new CanvasServiceProvider(platformServiceFactory), + navLink: new CanvasServiceProvider(navLinkServiceFactory), + search: new CanvasServiceProvider(searchServiceFactory), + reporting: new CanvasServiceProvider(reportingServiceFactory), + labs: new CanvasServiceProvider(labsServiceFactory), +}; + +export type CanvasServiceProviders = typeof services; + +export interface CanvasServices { + embeddables: ServiceFromProvider<typeof services.embeddables>; + expressions: ServiceFromProvider<typeof services.expressions>; + notify: ServiceFromProvider<typeof services.notify>; + platform: ServiceFromProvider<typeof services.platform>; + navLink: ServiceFromProvider<typeof services.navLink>; + search: ServiceFromProvider<typeof services.search>; + reporting: ServiceFromProvider<typeof services.reporting>; + labs: ServiceFromProvider<typeof services.labs>; +} + +export const startServices = async ( + coreSetup: CoreSetup, + coreStart: CoreStart, + canvasSetupPlugins: CanvasSetupDeps, + canvasStartPlugins: CanvasStartDeps, + appUpdater: BehaviorSubject<AppUpdater> +) => { + const startPromises = Object.values(services).map((provider) => + provider.start(coreSetup, coreStart, canvasSetupPlugins, canvasStartPlugins, appUpdater) + ); + + await Promise.all(startPromises); +}; + +export const stopServices = () => { + Object.values(services).forEach((provider) => provider.stop()); +}; + +export const { + embeddables: embeddableService, + notify: notifyService, + platform: platformService, + navLink: navLinkService, + expressions: expressionsService, + search: searchService, + reporting: reportingService, +} = services; diff --git a/x-pack/plugins/canvas/public/services/labs.ts b/x-pack/plugins/canvas/public/services/legacy/labs.ts similarity index 87% rename from x-pack/plugins/canvas/public/services/labs.ts rename to x-pack/plugins/canvas/public/services/legacy/labs.ts index 7f5de8d1e6570..2a506d813bde9 100644 --- a/x-pack/plugins/canvas/public/services/labs.ts +++ b/x-pack/plugins/canvas/public/services/legacy/labs.ts @@ -8,10 +8,10 @@ import { projectIDs, PresentationLabsService, -} from '../../../../../src/plugins/presentation_util/public'; +} from '../../../../../../src/plugins/presentation_util/public'; import { CanvasServiceFactory } from '.'; -import { UI_SETTINGS } from '../../common'; +import { UI_SETTINGS } from '../../../common'; export interface CanvasLabsService extends PresentationLabsService { projectIDs: typeof projectIDs; isLabsEnabled: () => boolean; diff --git a/x-pack/plugins/canvas/public/services/nav_link.ts b/x-pack/plugins/canvas/public/services/legacy/nav_link.ts similarity index 85% rename from x-pack/plugins/canvas/public/services/nav_link.ts rename to x-pack/plugins/canvas/public/services/legacy/nav_link.ts index 068874b745d9d..49088c08a8a71 100644 --- a/x-pack/plugins/canvas/public/services/nav_link.ts +++ b/x-pack/plugins/canvas/public/services/legacy/nav_link.ts @@ -6,8 +6,8 @@ */ import { CanvasServiceFactory } from '.'; -import { SESSIONSTORAGE_LASTPATH } from '../../common/lib/constants'; -import { getSessionStorage } from '../lib/storage'; +import { SESSIONSTORAGE_LASTPATH } from '../../../common/lib/constants'; +import { getSessionStorage } from '../../lib/storage'; export interface NavLinkService { updatePath: (path: string) => void; diff --git a/x-pack/plugins/canvas/public/services/notify.ts b/x-pack/plugins/canvas/public/services/legacy/notify.ts similarity index 92% rename from x-pack/plugins/canvas/public/services/notify.ts rename to x-pack/plugins/canvas/public/services/legacy/notify.ts index 6ee5eec6291ab..22dcfa671d0b5 100644 --- a/x-pack/plugins/canvas/public/services/notify.ts +++ b/x-pack/plugins/canvas/public/services/legacy/notify.ts @@ -7,8 +7,8 @@ import { get } from 'lodash'; import { CanvasServiceFactory } from '.'; -import { formatMsg } from '../../../../../src/plugins/kibana_legacy/public'; -import { ToastInputFields } from '../../../../../src/core/public'; +import { formatMsg } from '../../../../../../src/plugins/kibana_legacy/public'; +import { ToastInputFields } from '../../../../../../src/core/public'; const getToast = (err: Error | string, opts: ToastInputFields = {}) => { const errData = (get(err, 'response') || err) as Error | string; diff --git a/x-pack/plugins/canvas/public/services/platform.ts b/x-pack/plugins/canvas/public/services/legacy/platform.ts similarity index 98% rename from x-pack/plugins/canvas/public/services/platform.ts rename to x-pack/plugins/canvas/public/services/legacy/platform.ts index c4be5097a18f0..b867622f5d302 100644 --- a/x-pack/plugins/canvas/public/services/platform.ts +++ b/x-pack/plugins/canvas/public/services/legacy/platform.ts @@ -12,7 +12,7 @@ import { ChromeBreadcrumb, IBasePath, ChromeStart, -} from '../../../../../src/core/public'; +} from '../../../../../../src/core/public'; import { CanvasServiceFactory } from '.'; export interface PlatformService { diff --git a/x-pack/plugins/canvas/public/services/reporting.ts b/x-pack/plugins/canvas/public/services/legacy/reporting.ts similarity index 94% rename from x-pack/plugins/canvas/public/services/reporting.ts rename to x-pack/plugins/canvas/public/services/legacy/reporting.ts index 4fa40401472c6..e594475360dff 100644 --- a/x-pack/plugins/canvas/public/services/reporting.ts +++ b/x-pack/plugins/canvas/public/services/legacy/reporting.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { ReportingStart } from '../../../reporting/public'; +import { ReportingStart } from '../../../../reporting/public'; import { CanvasServiceFactory } from './'; export interface ReportingService { diff --git a/x-pack/plugins/canvas/public/services/search.ts b/x-pack/plugins/canvas/public/services/legacy/search.ts similarity index 100% rename from x-pack/plugins/canvas/public/services/search.ts rename to x-pack/plugins/canvas/public/services/legacy/search.ts diff --git a/x-pack/plugins/canvas/public/services/stubs/embeddables.ts b/x-pack/plugins/canvas/public/services/legacy/stubs/embeddables.ts similarity index 100% rename from x-pack/plugins/canvas/public/services/stubs/embeddables.ts rename to x-pack/plugins/canvas/public/services/legacy/stubs/embeddables.ts diff --git a/x-pack/plugins/canvas/public/services/stubs/expressions.ts b/x-pack/plugins/canvas/public/services/legacy/stubs/expressions.ts similarity index 75% rename from x-pack/plugins/canvas/public/services/stubs/expressions.ts rename to x-pack/plugins/canvas/public/services/legacy/stubs/expressions.ts index 497ec9b162045..bd1076ab0bf80 100644 --- a/x-pack/plugins/canvas/public/services/stubs/expressions.ts +++ b/x-pack/plugins/canvas/public/services/legacy/stubs/expressions.ts @@ -7,9 +7,9 @@ import { AnyExpressionRenderDefinition } from 'src/plugins/expressions'; import { ExpressionsService } from '../'; -import { plugin } from '../../../../../../src/plugins/expressions/public'; -import { functions as functionDefinitions } from '../../../canvas_plugin_src/functions/common'; -import { renderFunctions } from '../../../canvas_plugin_src/renderers/core'; +import { plugin } from '../../../../../../../src/plugins/expressions/public'; +import { functions as functionDefinitions } from '../../../../canvas_plugin_src/functions/common'; +import { renderFunctions } from '../../../../canvas_plugin_src/renderers/core'; const placeholder = {} as any; const expressionsPlugin = plugin(placeholder); diff --git a/x-pack/plugins/canvas/public/services/legacy/stubs/index.ts b/x-pack/plugins/canvas/public/services/legacy/stubs/index.ts new file mode 100644 index 0000000000000..7246a34d7f491 --- /dev/null +++ b/x-pack/plugins/canvas/public/services/legacy/stubs/index.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 { CanvasServices, services } from '../'; +import { embeddablesService } from './embeddables'; +import { expressionsService } from './expressions'; +import { reportingService } from './reporting'; +import { navLinkService } from './nav_link'; +import { notifyService } from './notify'; +import { labsService } from './labs'; +import { platformService } from './platform'; +import { searchService } from './search'; + +export const stubs: CanvasServices = { + embeddables: embeddablesService, + expressions: expressionsService, + reporting: reportingService, + navLink: navLinkService, + notify: notifyService, + platform: platformService, + search: searchService, + labs: labsService, +}; + +export const startServices = async (providedServices: Partial<CanvasServices> = {}) => { + Object.entries(services).forEach(([key, provider]) => { + // @ts-expect-error Object.entries isn't strongly typed + const stub = providedServices[key] || stubs[key]; + provider.setService(stub); + }); +}; diff --git a/x-pack/plugins/canvas/public/services/stubs/labs.ts b/x-pack/plugins/canvas/public/services/legacy/stubs/labs.ts similarity index 86% rename from x-pack/plugins/canvas/public/services/stubs/labs.ts rename to x-pack/plugins/canvas/public/services/legacy/stubs/labs.ts index db89c5c35d5fb..fc65d45d2dd34 100644 --- a/x-pack/plugins/canvas/public/services/stubs/labs.ts +++ b/x-pack/plugins/canvas/public/services/legacy/stubs/labs.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { projectIDs } from '../../../../../../src/plugins/presentation_util/public'; +import { projectIDs } from '../../../../../../../src/plugins/presentation_util/public'; import { CanvasLabsService } from '../labs'; const noop = (..._args: any[]): any => {}; diff --git a/x-pack/plugins/canvas/public/services/stubs/nav_link.ts b/x-pack/plugins/canvas/public/services/legacy/stubs/nav_link.ts similarity index 100% rename from x-pack/plugins/canvas/public/services/stubs/nav_link.ts rename to x-pack/plugins/canvas/public/services/legacy/stubs/nav_link.ts diff --git a/x-pack/plugins/canvas/public/services/stubs/notify.ts b/x-pack/plugins/canvas/public/services/legacy/stubs/notify.ts similarity index 100% rename from x-pack/plugins/canvas/public/services/stubs/notify.ts rename to x-pack/plugins/canvas/public/services/legacy/stubs/notify.ts diff --git a/x-pack/plugins/canvas/public/services/stubs/platform.ts b/x-pack/plugins/canvas/public/services/legacy/stubs/platform.ts similarity index 100% rename from x-pack/plugins/canvas/public/services/stubs/platform.ts rename to x-pack/plugins/canvas/public/services/legacy/stubs/platform.ts diff --git a/x-pack/plugins/canvas/public/services/stubs/reporting.ts b/x-pack/plugins/canvas/public/services/legacy/stubs/reporting.ts similarity index 100% rename from x-pack/plugins/canvas/public/services/stubs/reporting.ts rename to x-pack/plugins/canvas/public/services/legacy/stubs/reporting.ts diff --git a/x-pack/plugins/canvas/public/services/stubs/search.ts b/x-pack/plugins/canvas/public/services/legacy/stubs/search.ts similarity index 100% rename from x-pack/plugins/canvas/public/services/stubs/search.ts rename to x-pack/plugins/canvas/public/services/legacy/stubs/search.ts diff --git a/x-pack/plugins/canvas/public/services/storybook/index.ts b/x-pack/plugins/canvas/public/services/storybook/index.ts new file mode 100644 index 0000000000000..de231f730faf5 --- /dev/null +++ b/x-pack/plugins/canvas/public/services/storybook/index.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + PluginServiceProviders, + PluginServiceProvider, +} from '../../../../../../src/plugins/presentation_util/public'; + +import { CanvasPluginServices } from '..'; +import { pluginServiceProviders as stubProviders } from '../stubs'; +import { workpadServiceFactory } from './workpad'; + +export interface StorybookParams { + hasTemplates?: boolean; + useStaticData?: boolean; + workpadCount?: number; +} + +export const pluginServiceProviders: PluginServiceProviders< + CanvasPluginServices, + StorybookParams +> = { + ...stubProviders, + workpad: new PluginServiceProvider(workpadServiceFactory), +}; + +export const argTypes = { + hasTemplates: { + name: 'Has templates?', + type: { + name: 'boolean', + }, + defaultValue: true, + control: { + type: 'boolean', + }, + }, + useStaticData: { + name: 'Use static data?', + type: { + name: 'boolean', + }, + defaultValue: false, + control: { + type: 'boolean', + }, + }, + workpadCount: { + name: 'Number of workpads', + type: { name: 'number' }, + defaultValue: 5, + control: { + type: 'range', + }, + }, +}; diff --git a/x-pack/plugins/canvas/public/services/storybook/workpad.ts b/x-pack/plugins/canvas/public/services/storybook/workpad.ts new file mode 100644 index 0000000000000..a494f634141bc --- /dev/null +++ b/x-pack/plugins/canvas/public/services/storybook/workpad.ts @@ -0,0 +1,100 @@ +/* + * 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 moment from 'moment'; + +import { action } from '@storybook/addon-actions'; +import { PluginServiceFactory } from '../../../../../../src/plugins/presentation_util/public'; + +import { getId } from '../../lib/get_id'; +// @ts-expect-error +import { getDefaultWorkpad } from '../../state/defaults'; + +import { StorybookParams } from '.'; +import { CanvasWorkpadService } from '../workpad'; + +import * as stubs from '../stubs/workpad'; + +export { + findNoTemplates, + findNoWorkpads, + findSomeTemplates, + getNoTemplates, + getSomeTemplates, +} from '../stubs/workpad'; + +type CanvasWorkpadServiceFactory = PluginServiceFactory<CanvasWorkpadService, StorybookParams>; + +const TIMEOUT = 500; +const promiseTimeout = (time: number) => () => new Promise((resolve) => setTimeout(resolve, time)); + +const { findNoTemplates, findNoWorkpads, findSomeTemplates } = stubs; + +const getRandomName = () => { + const lorem = 'Lorem ipsum dolor sit amet consectetur adipiscing elit Fusce lobortis aliquet arcu ut turpis duis'.split( + ' ' + ); + return [1, 2, 3].map(() => lorem[Math.floor(Math.random() * lorem.length)]).join(' '); +}; + +const getRandomDate = ( + start: Date = moment().toDate(), + end: Date = moment().subtract(7, 'days').toDate() +) => new Date(start.getTime() + Math.random() * (end.getTime() - start.getTime())).toISOString(); + +export const getSomeWorkpads = (count = 3) => + Array.from({ length: count }, () => ({ + '@created': getRandomDate( + moment().subtract(3, 'days').toDate(), + moment().subtract(10, 'days').toDate() + ), + '@timestamp': getRandomDate(), + id: getId('workpad'), + name: getRandomName(), + })); + +export const findSomeWorkpads = (count = 3, useStaticData = false, timeout = TIMEOUT) => ( + _term: string +) => { + return Promise.resolve() + .then(promiseTimeout(timeout)) + .then(() => ({ + total: count, + workpads: useStaticData ? stubs.getSomeWorkpads(count) : getSomeWorkpads(count), + })); +}; + +export const workpadServiceFactory: CanvasWorkpadServiceFactory = ({ + workpadCount, + hasTemplates, + useStaticData, +}) => ({ + get: (id: string) => { + action('workpadService.get')(id); + return Promise.resolve({ ...getDefaultWorkpad(), id }); + }, + findTemplates: () => { + action('workpadService.findTemplates')(); + return (hasTemplates ? findSomeTemplates() : findNoTemplates())(); + }, + create: (workpad) => { + action('workpadService.create')(workpad); + return Promise.resolve(workpad); + }, + createFromTemplate: (templateId: string) => { + action('workpadService.createFromTemplate')(templateId); + return Promise.resolve(getDefaultWorkpad()); + }, + find: (term: string) => { + action('workpadService.find')(term); + return (workpadCount ? findSomeWorkpads(workpadCount, useStaticData) : findNoWorkpads())(term); + }, + remove: (id: string) => { + action('workpadService.remove')(id); + return Promise.resolve(); + }, +}); diff --git a/x-pack/plugins/canvas/public/services/stubs/index.ts b/x-pack/plugins/canvas/public/services/stubs/index.ts index 3b00e0e6195f3..586007201db81 100644 --- a/x-pack/plugins/canvas/public/services/stubs/index.ts +++ b/x-pack/plugins/canvas/public/services/stubs/index.ts @@ -5,33 +5,23 @@ * 2.0. */ -import { CanvasServices, services } from '../'; -import { embeddablesService } from './embeddables'; -import { expressionsService } from './expressions'; -import { reportingService } from './reporting'; -import { navLinkService } from './nav_link'; -import { notifyService } from './notify'; -import { labsService } from './labs'; -import { platformService } from './platform'; -import { searchService } from './search'; -import { workpadService } from './workpad'; +export * from '../legacy/stubs'; -export const stubs: CanvasServices = { - embeddables: embeddablesService, - expressions: expressionsService, - reporting: reportingService, - navLink: navLinkService, - notify: notifyService, - platform: platformService, - search: searchService, - labs: labsService, - workpad: workpadService, -}; +import { + PluginServiceProviders, + PluginServiceProvider, + PluginServiceRegistry, +} from '../../../../../../src/plugins/presentation_util/public'; + +import { CanvasPluginServices } from '..'; +import { workpadServiceFactory } from './workpad'; -export const startServices = async (providedServices: Partial<CanvasServices> = {}) => { - Object.entries(services).forEach(([key, provider]) => { - // @ts-expect-error Object.entries isn't strongly typed - const stub = providedServices[key] || stubs[key]; - provider.setService(stub); - }); +export { workpadServiceFactory } from './workpad'; + +export const pluginServiceProviders: PluginServiceProviders<CanvasPluginServices> = { + workpad: new PluginServiceProvider(workpadServiceFactory), }; + +export const pluginServiceRegistry = new PluginServiceRegistry<CanvasPluginServices>( + pluginServiceProviders +); diff --git a/x-pack/plugins/canvas/public/services/stubs/workpad.ts b/x-pack/plugins/canvas/public/services/stubs/workpad.ts index 4e3612feb67c8..eef7508e7c1eb 100644 --- a/x-pack/plugins/canvas/public/services/stubs/workpad.ts +++ b/x-pack/plugins/canvas/public/services/stubs/workpad.ts @@ -7,26 +7,46 @@ import moment from 'moment'; +import { PluginServiceFactory } from '../../../../../../src/plugins/presentation_util/public'; + // @ts-expect-error import { getDefaultWorkpad } from '../../state/defaults'; -import { WorkpadService } from '../workpad'; -import { getId } from '../../lib/get_id'; +import { CanvasWorkpadService } from '../workpad'; import { CanvasTemplate } from '../../../types'; -const TIMEOUT = 500; +type CanvasWorkpadServiceFactory = PluginServiceFactory<CanvasWorkpadService>; + +export const TIMEOUT = 500; +export const promiseTimeout = (time: number) => () => + new Promise((resolve) => setTimeout(resolve, time)); + +const DAY = 86400000; +const JAN_1_2000 = 946684800000; -const promiseTimeout = (time: number) => () => new Promise((resolve) => setTimeout(resolve, time)); -const getName = () => { - const lorem = 'Lorem ipsum dolor sit amet consectetur adipiscing elit Fusce lobortis aliquet arcu ut turpis duis'.split( - ' ' - ); - return [1, 2, 3].map(() => lorem[Math.floor(Math.random() * lorem.length)]).join(' '); +const getWorkpads = (count = 3) => { + const workpads = []; + for (let i = 0; i < count; i++) { + workpads[i] = { + ...getDefaultWorkpad(), + name: `Workpad ${i}`, + id: `workpad-${i}`, + '@created': moment(JAN_1_2000 + DAY * i).toDate(), + '@timestamp': moment(JAN_1_2000 + DAY * (i + 1)).toDate(), + }; + } + return workpads; }; -const randomDate = ( - start: Date = moment().toDate(), - end: Date = moment().subtract(7, 'days').toDate() -) => new Date(start.getTime() + Math.random() * (end.getTime() - start.getTime())).toISOString(); +export const getSomeWorkpads = (count = 3) => getWorkpads(count); + +export const findSomeWorkpads = (count = 3, timeout = TIMEOUT) => (_term: string) => { + return Promise.resolve() + .then(promiseTimeout(timeout)) + .then(() => ({ + total: count, + workpads: getSomeWorkpads(count), + })); +}; const templates: CanvasTemplate[] = [ { @@ -45,26 +65,6 @@ const templates: CanvasTemplate[] = [ }, ]; -export const getSomeWorkpads = (count = 3) => - Array.from({ length: count }, () => ({ - '@created': randomDate( - moment().subtract(3, 'days').toDate(), - moment().subtract(10, 'days').toDate() - ), - '@timestamp': randomDate(), - id: getId('workpad'), - name: getName(), - })); - -export const findSomeWorkpads = (count = 3, timeout = TIMEOUT) => (_term: string) => { - return Promise.resolve() - .then(promiseTimeout(timeout)) - .then(() => ({ - total: count, - workpads: getSomeWorkpads(count), - })); -}; - export const findNoWorkpads = (timeout = TIMEOUT) => (_term: string) => { return Promise.resolve() .then(promiseTimeout(timeout)) @@ -89,11 +89,11 @@ export const findNoTemplates = (timeout = TIMEOUT) => () => { export const getNoTemplates = () => ({ templates: [] }); export const getSomeTemplates = () => ({ templates }); -export const workpadService: WorkpadService = { +export const workpadServiceFactory: CanvasWorkpadServiceFactory = () => ({ get: (id: string) => Promise.resolve({ ...getDefaultWorkpad(), id }), findTemplates: findNoTemplates(), create: (workpad) => Promise.resolve(workpad), createFromTemplate: (_templateId: string) => Promise.resolve(getDefaultWorkpad()), find: findNoWorkpads(), - remove: (id: string) => Promise.resolve(), -}; + remove: (_id: string) => Promise.resolve(), +}); diff --git a/x-pack/plugins/canvas/public/services/workpad.ts b/x-pack/plugins/canvas/public/services/workpad.ts index 7d2f1550a312f..37664244b2d55 100644 --- a/x-pack/plugins/canvas/public/services/workpad.ts +++ b/x-pack/plugins/canvas/public/services/workpad.ts @@ -5,44 +5,7 @@ * 2.0. */ -import { - API_ROUTE_WORKPAD, - DEFAULT_WORKPAD_CSS, - API_ROUTE_TEMPLATES, -} from '../../common/lib/constants'; import { CanvasWorkpad, CanvasTemplate } from '../../types'; -import { CanvasServiceFactory } from './'; - -/* - Remove any top level keys from the workpad which will be rejected by validation -*/ -const validKeys = [ - '@created', - '@timestamp', - 'assets', - 'colors', - 'css', - 'variables', - 'height', - 'id', - 'isWriteable', - 'name', - 'page', - 'pages', - 'width', -]; - -const sanitizeWorkpad = function (workpad: CanvasWorkpad) { - const workpadKeys = Object.keys(workpad); - - for (const key of workpadKeys) { - if (!validKeys.includes(key)) { - delete (workpad as { [key: string]: any })[key]; - } - } - - return workpad; -}; export type FoundWorkpads = Array<Pick<CanvasWorkpad, 'name' | 'id' | '@timestamp' | '@created'>>; export type FoundWorkpad = FoundWorkpads[number]; @@ -55,7 +18,7 @@ export interface TemplateFindResponse { templates: CanvasTemplate[]; } -export interface WorkpadService { +export interface CanvasWorkpadService { get: (id: string) => Promise<CanvasWorkpad>; create: (workpad: CanvasWorkpad) => Promise<CanvasWorkpad>; createFromTemplate: (templateId: string) => Promise<CanvasWorkpad>; @@ -63,50 +26,3 @@ export interface WorkpadService { remove: (id: string) => Promise<void>; findTemplates: () => Promise<TemplateFindResponse>; } - -export const workpadServiceFactory: CanvasServiceFactory<WorkpadService> = ( - _coreSetup, - coreStart, - _setupPlugins, - startPlugins -): WorkpadService => { - const getApiPath = function () { - return `${API_ROUTE_WORKPAD}`; - }; - return { - get: async (id: string) => { - const workpad = await coreStart.http.get(`${getApiPath()}/${id}`); - - return { css: DEFAULT_WORKPAD_CSS, variables: [], ...workpad }; - }, - create: (workpad: CanvasWorkpad) => { - return coreStart.http.post(getApiPath(), { - body: JSON.stringify({ - ...sanitizeWorkpad({ ...workpad }), - assets: workpad.assets || {}, - variables: workpad.variables || [], - }), - }); - }, - createFromTemplate: (templateId: string) => { - return coreStart.http.post(getApiPath(), { - body: JSON.stringify({ templateId }), - }); - }, - findTemplates: async () => coreStart.http.get(API_ROUTE_TEMPLATES), - find: (searchTerm: string) => { - // TODO: this shouldn't be necessary. Check for usage. - const validSearchTerm = typeof searchTerm === 'string' && searchTerm.length > 0; - - return coreStart.http.get(`${getApiPath()}/find`, { - query: { - perPage: 10000, - name: validSearchTerm ? searchTerm : '', - }, - }); - }, - remove: (id: string) => { - return coreStart.http.delete(`${getApiPath()}/${id}`); - }, - }; -}; diff --git a/x-pack/plugins/canvas/public/store.ts b/x-pack/plugins/canvas/public/store.ts index a199599d8c0ff..e8821bafbb052 100644 --- a/x-pack/plugins/canvas/public/store.ts +++ b/x-pack/plugins/canvas/public/store.ts @@ -17,17 +17,16 @@ import { getInitialState } from './state/initial_state'; import { CoreSetup } from '../../../../src/core/public'; import { API_ROUTE_FUNCTIONS } from '../common/lib/constants'; -import { CanvasSetupDeps } from './plugin'; -export async function createStore(core: CoreSetup, plugins: CanvasSetupDeps) { +export async function createStore(core: CoreSetup) { if (getStore()) { return cloneStore(); } - return createFreshStore(core, plugins); + return createFreshStore(core); } -export async function createFreshStore(core: CoreSetup, plugins: CanvasSetupDeps) { +export async function createFreshStore(core: CoreSetup) { const initialState = getInitialState(); const basePath = core.http.basePath.get(); diff --git a/x-pack/plugins/canvas/storybook/decorators/index.ts b/x-pack/plugins/canvas/storybook/decorators/index.ts index 598a2333be554..a4ea3226b7760 100644 --- a/x-pack/plugins/canvas/storybook/decorators/index.ts +++ b/x-pack/plugins/canvas/storybook/decorators/index.ts @@ -8,7 +8,7 @@ import { addDecorator } from '@storybook/react'; import { routerContextDecorator } from './router_decorator'; import { kibanaContextDecorator } from './kibana_decorator'; -import { servicesContextDecorator } from './services_decorator'; +import { servicesContextDecorator, legacyContextDecorator } from './services_decorator'; export { reduxDecorator } from './redux_decorator'; export { servicesContextDecorator } from './services_decorator'; @@ -21,5 +21,6 @@ export const addDecorators = () => { addDecorator(kibanaContextDecorator); addDecorator(routerContextDecorator); - addDecorator(servicesContextDecorator()); + addDecorator(servicesContextDecorator); + addDecorator(legacyContextDecorator()); }; diff --git a/x-pack/plugins/canvas/storybook/decorators/services_decorator.tsx b/x-pack/plugins/canvas/storybook/decorators/services_decorator.tsx index def5a5681a8c4..fbc3f140bffcc 100644 --- a/x-pack/plugins/canvas/storybook/decorators/services_decorator.tsx +++ b/x-pack/plugins/canvas/storybook/decorators/services_decorator.tsx @@ -7,40 +7,34 @@ import React from 'react'; -import { - CanvasServiceFactory, - CanvasServiceProvider, - ServicesProvider, -} from '../../public/services'; -import { - findNoWorkpads, - findSomeWorkpads, - workpadService, - findSomeTemplates, - findNoTemplates, -} from '../../public/services/stubs/workpad'; -import { WorkpadService } from '../../public/services/workpad'; - -interface Params { - findWorkpads?: number; - findTemplates?: boolean; -} - -export const servicesContextDecorator = ({ - findWorkpads = 0, - findTemplates: findTemplatesOption = false, -}: Params = {}) => { - const workpadServiceFactory: CanvasServiceFactory<WorkpadService> = (): WorkpadService => ({ - ...workpadService, - find: findWorkpads > 0 ? findSomeWorkpads(findWorkpads) : findNoWorkpads(), - findTemplates: findTemplatesOption ? findSomeTemplates() : findNoTemplates(), - }); - - const workpad = new CanvasServiceProvider(workpadServiceFactory); - // @ts-expect-error This is a hack at the moment, until we can get Canvas moved over to the new services architecture. - workpad.start(); - - return (story: Function) => ( - <ServicesProvider providers={{ workpad }}>{story()}</ServicesProvider> +import { DecoratorFn } from '@storybook/react'; +import { I18nProvider } from '@kbn/i18n/react'; + +import { PluginServiceRegistry } from '../../../../../src/plugins/presentation_util/public'; +import { pluginServices, LegacyServicesProvider } from '../../public/services'; +import { CanvasPluginServices } from '../../public/services'; +import { pluginServiceProviders, StorybookParams } from '../../public/services/storybook'; + +export const servicesContextDecorator: DecoratorFn = (story: Function, storybook) => { + if (process.env.JEST_WORKER_ID !== undefined) { + storybook.args.useStaticData = true; + } + + const pluginServiceRegistry = new PluginServiceRegistry<CanvasPluginServices, StorybookParams>( + pluginServiceProviders + ); + + pluginServices.setRegistry(pluginServiceRegistry.start(storybook.args)); + + const ContextProvider = pluginServices.getContextProvider(); + + return ( + <I18nProvider> + <ContextProvider>{story()}</ContextProvider> + </I18nProvider> ); }; + +export const legacyContextDecorator = () => (story: Function) => ( + <LegacyServicesProvider>{story()}</LegacyServicesProvider> +); diff --git a/x-pack/plugins/canvas/storybook/index.ts b/x-pack/plugins/canvas/storybook/index.ts index ff60b84c88a69..01dda057dac81 100644 --- a/x-pack/plugins/canvas/storybook/index.ts +++ b/x-pack/plugins/canvas/storybook/index.ts @@ -9,7 +9,9 @@ import { ACTIONS_PANEL_ID } from './addon/src/constants'; export * from './decorators'; export { ACTIONS_PANEL_ID } from './addon/src/constants'; + export const getAddonPanelParameters = () => ({ options: { selectedPanel: ACTIONS_PANEL_ID } }); + export const getDisableStoryshotsParameter = () => ({ storyshots: { disable: true, diff --git a/x-pack/plugins/canvas/storybook/preview.ts b/x-pack/plugins/canvas/storybook/preview.ts index f885a654cdab8..266ff767c566a 100644 --- a/x-pack/plugins/canvas/storybook/preview.ts +++ b/x-pack/plugins/canvas/storybook/preview.ts @@ -6,6 +6,7 @@ */ import { action } from '@storybook/addon-actions'; +import { addParameters } from '@storybook/react'; import { startServices } from '../public/services/stubs'; import { addDecorators } from './decorators'; @@ -23,3 +24,6 @@ startServices({ }); addDecorators(); +addParameters({ + controls: { hideNoControlsWarning: true }, +}); diff --git a/x-pack/plugins/canvas/storybook/storyshots.test.tsx b/x-pack/plugins/canvas/storybook/storyshots.test.tsx index 7f0ea077c7569..84ac1a26281e0 100644 --- a/x-pack/plugins/canvas/storybook/storyshots.test.tsx +++ b/x-pack/plugins/canvas/storybook/storyshots.test.tsx @@ -118,7 +118,7 @@ addSerializer(styleSheetSerializer); initStoryshots({ configPath: path.resolve(__dirname), framework: 'react', - test: multiSnapshotWithOptions({}), + test: multiSnapshotWithOptions(), // Don't snapshot tests that start with 'redux' storyNameRegex: /^((?!.*?redux).)*$/, }); From 54dae304ccfd04d4814655f6bfe51a15ae915831 Mon Sep 17 00:00:00 2001 From: Brandon Kobel <brandon.kobel@elastic.co> Date: Wed, 30 Jun 2021 14:11:59 -0700 Subject: [PATCH 12/51] Update docs to explicitly state supported upgrade version (#103774) * Update docs to explicitly state supported upgrade version * Update docs/setup/upgrade.asciidoc Co-authored-by: Kaarina Tungseth <kaarina.tungseth@elastic.co> Co-authored-by: Kaarina Tungseth <kaarina.tungseth@elastic.co> --- docs/setup/upgrade.asciidoc | 50 +++++++++++++++++++++++++++++++++++-- 1 file changed, 48 insertions(+), 2 deletions(-) diff --git a/docs/setup/upgrade.asciidoc b/docs/setup/upgrade.asciidoc index 92cd6e9ead5a1..bd93517a7a82f 100644 --- a/docs/setup/upgrade.asciidoc +++ b/docs/setup/upgrade.asciidoc @@ -1,8 +1,54 @@ [[upgrade]] == Upgrade {kib} -Depending on the {kib} version you're upgrading from, the upgrade process to 7.0 -varies. +Depending on the {kib} version you're upgrading from, the upgrade process to {version} +varies. The following upgrades are supported: + +* Between minor versions +* From 5.6 to 6.8 +* From 6.8 to {prev-major-version} +* From {prev-major-version} to {version} +ifeval::[ "{version}" != "{minor-version}.0" ] +* From any version since {minor-version}.0 to {version} +endif::[] + +The following table shows the recommended upgrade paths to {version}. + +[cols="<1,3",options="header",] +|==== +|Upgrade from +|Recommended upgrade path to {version} + +ifeval::[ "{version}" != "{minor-version}.0" ] +|A previous {minor-version} version (e.g., {minor-version}.0) +|Upgrade to {version} +endif::[] + +|{prev-major-version} +|Upgrade to {version} + +|7.0–7.7 +a| +. Upgrade to {prev-major-version} +. Upgrade to {version} + +|6.8 +a| +. Upgrade to {prev-major-version} +. Upgrade to {version} + +|6.0–6.7 +a| + +. Upgrade to 6.8 +. Upgrade to {prev-major-version} +. Upgrade to {version} +|==== + +[WARNING] +==== +The upgrade path from 6.8 to 7.0 is *not* supported. +==== [float] [[upgrade-before-you-begin]] From f65eaa2c49a1c098df171eb210c9569bbe939b55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Casper=20H=C3=BCbertz?= <casper@elastic.co> Date: Wed, 30 Jun 2021 23:22:14 +0200 Subject: [PATCH 13/51] [APM] Fix prepend form label background (#103983) --- .../apm/public/components/shared/time_comparison/index.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/apm/public/components/shared/time_comparison/index.tsx b/x-pack/plugins/apm/public/components/shared/time_comparison/index.tsx index cbe7b81486a64..ed9d1a15cdbca 100644 --- a/x-pack/plugins/apm/public/components/shared/time_comparison/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/time_comparison/index.tsx @@ -26,7 +26,8 @@ const PrependContainer = euiStyled.div` display: flex; justify-content: center; align-items: center; - background-color: ${({ theme }) => theme.eui.euiGradientMiddle}; + background-color: ${({ theme }) => + theme.eui.euiFormInputGroupLabelBackground}; padding: 0 ${px(unit)}; `; From 12e7fe50bb4367893e8fef9b94b3bb28a92bd75a Mon Sep 17 00:00:00 2001 From: Frank Hassanabad <frank.hassanabad@elastic.co> Date: Wed, 30 Jun 2021 15:50:05 -0600 Subject: [PATCH 14/51] [Security Solutions][Detection Engine] Adds a merge strategy key to kibana.yml and updates docker to have missing keys from security solutions (#103800) ## Summary This is a follow up considered critical addition to: https://github.com/elastic/kibana/pull/102280 This adds a key of `xpack.securitySolution.alertMergeStrategy` to `kibana.yml` which allows users to change their merge strategy between their raw events and the signals/alerts that are generated. This also adds additional security keys to the docker container that were overlooked in the past from security solutions. The values you can use and add to to `xpack.securitySolution.alertMergeStrategy` are: * missingFields (The default) * allFields * noFields ## missingFields The default merge strategy we are using starting with 7.14 which will merge any primitive data types from the [fields API](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-fields.html#search-fields-param) into the resulting signal/alert. This will copy over fields such as `constant_keyword`, `copy_to`, `runtime fields`, `field aliases` which previously were not copied over as long as they are primitive data types such as `keyword`, `text`, `numeric` and are not found in your original `_source` document. This will not copy copy `geo points`, `nested objects`, and in some cases if your `_source` contains arrays or top level objects or conflicts/ambiguities it will not merge them. This will _not_ merge existing values between `_source` and `fields` for `runtime fields` as well. It only merges missing primitive data types. ## allFields A very aggressive merge strategy which should be considered experimental. It will do everything `missingFields` does but in addition to that it will merge existing values between `_source` and `fields` which means if you change values or override values with `runtime fields` this strategy will attempt to merge those values. This will also merge in most instances your nested fields but it will not merge `geo` data types due to ambiguities. If you have multi-fields this will choose your default field and merge that into `_source`. This can change a lot your data between your original `_source` and `fields` when the data is copied into an alert/signal which is why it is considered an aggressive merge strategy. Both these strategies attempts to unbox single array elements when it makes sense and assumes you only want values in an array when it sees them in `_source` or if it sees multiple elements within an array. ## noFields The behavior before https://github.com/elastic/kibana/pull/102280 was introduced and is a do nothing strategy. This should only be used if you are seeing problems with alerts/signals being inserted due to conflicts and/or bugs for some reason with `missingFields`. We are not anticipating this, but if you are setting `noFields` please reach out to our [forums](https://discuss.elastic.co/c/security/83) and let us know we have a bug so we can fix it. If you are encountering undesired merge behaviors or have other strategies you want us to implement let us know on the forums as well. The missing keys added for docker are: * xpack.securitySolution.alertMergeStrategy * xpack.securitySolution.alertResultListDefaultDateRange * xpack.securitySolution.endpointResultListDefaultFirstPageIndex * xpack.securitySolution.endpointResultListDefaultPageSize * xpack.securitySolution.maxRuleImportExportSize * xpack.securitySolution.maxRuleImportPayloadBytes * xpack.securitySolution.maxTimelineImportExportSize * xpack.securitySolution.maxTimelineImportPayloadBytes * xpack.securitySolution.packagerTaskInterval * xpack.securitySolution.validateArtifactDownloads I intentionally skipped adding the other `kibana.yml` keys which are considered either experimental flags or are for internal developers and are not documented and not supported in production by us. ## Manual testing of the different strategies First add this mapping and document in the dev tools for basic tests ```json # Mapping with two constant_keywords and a runtime field DELETE frank-test-delme-17 PUT frank-test-delme-17 { "mappings": { "dynamic": "strict", "runtime": { "host.name": { "type": "keyword", "script": { "source": "emit('changed_hostname')" } } }, "properties": { "@timestamp": { "type": "date" }, "host": { "properties": { "name": { "type": "keyword" } } }, "data_stream": { "properties": { "dataset": { "type": "constant_keyword", "value": "datastream_dataset_name_1" }, "module": { "type": "constant_keyword", "value": "datastream_module_name_1" } } }, "event": { "properties": { "dataset": { "type": "constant_keyword", "value": "event_dataset_name_1" }, "module": { "type": "constant_keyword", "value": "event_module_name_1" } } } } } } # Document without an existing host.name PUT frank-test-delme-17/_doc/1 { "@timestamp": "2021-06-30T15:46:31.800Z" } # Document with an existing host.name PUT frank-test-delme-17/_doc/2 { "@timestamp": "2021-06-30T15:46:31.800Z", "host": { "name": "host_name" } } # Query it to ensure the fields is returned with data that does not exist in _soruce GET frank-test-delme-17/_search { "fields": [ { "field": "*" } ] } ``` For all the different key combinations do the following: Run a single detection rule against the index: <img width="1139" alt="Screen Shot 2021-06-30 at 9 49 12 AM" src="https://user-images.githubusercontent.com/1151048/123997522-b8dc6600-d98d-11eb-9407-5480d5b2cc8a.png"> Ensure two signals are created: <img width="1376" alt="Screen Shot 2021-06-30 at 10 26 03 AM" src="https://user-images.githubusercontent.com/1151048/123997739-f17c3f80-d98d-11eb-9eb9-90e9410f0cde.png"> If your `kibana.yml` or `kibana.dev.yml` you set this key (or omit it as it is the default): ```yml xpack.securitySolution.alertMergeStrategy: 'missingFields' ``` When you click on each signal you should see that `event.module` and `event.dataset` were copied over as well as `data_stream.dataset` and `data_stream.module` since they're `constant_keyword`: <img width="877" alt="Screen Shot 2021-06-30 at 10 20 44 AM" src="https://user-images.githubusercontent.com/1151048/123997961-31432700-d98e-11eb-96ee-06524f21e2d6.png"> However since this only merges missing fields, you should see that in the first record the `host.name` is the runtime field defined since `host.name` does not exist in `_source` and that in the second record it still shows up as `host_name` since we do not override merges right now: First: <img width="887" alt="Screen Shot 2021-06-30 at 10 03 31 AM" src="https://user-images.githubusercontent.com/1151048/123998398-b2022300-d98e-11eb-87be-aa5a153a91bc.png"> Second: <img width="838" alt="Screen Shot 2021-06-30 at 10 03 44 AM" src="https://user-images.githubusercontent.com/1151048/123998413-b4fd1380-d98e-11eb-9821-d6189190918f.png"> When you set in your `kibana.yml` or `kibana.dev.yml` this key: ```yml xpack.securitySolution.alertMergeStrategy: 'noFields' ``` Expect that your `event.module`, `event.dataset`, `data_stream.module`, `data_stream.dataset` are all non-existent since we do not copy anything over from `fields` at all and only use things within `_source`: <img width="804" alt="Screen Shot 2021-06-30 at 9 58 25 AM" src="https://user-images.githubusercontent.com/1151048/123998694-f8578200-d98e-11eb-8d71-a0858d3ed3e7.png"> Expect that `host.name` is missing in the first record and has the default value in the second: First: <img width="797" alt="Screen Shot 2021-06-30 at 9 58 37 AM" src="https://user-images.githubusercontent.com/1151048/123998797-10c79c80-d98f-11eb-81b6-5174d8ef14f2.png"> Second: <img width="806" alt="Screen Shot 2021-06-30 at 9 58 52 AM" src="https://user-images.githubusercontent.com/1151048/123998816-158c5080-d98f-11eb-87a0-0ac2f58793b3.png"> When you set in your `kibana.yml` or `kibana.dev.yml` this key: ```yml xpack.securitySolution.alertMergeStrategy: 'allFields' ``` Expect that `event.module` and `event.dataset` were copied over as well as `data_stream.dataset` and `data_stream.module` since they're `constant_keyword`: <img width="864" alt="Screen Shot 2021-06-30 at 10 03 15 AM" src="https://user-images.githubusercontent.com/1151048/123999000-48364900-d98f-11eb-9803-05349744ac10.png"> Expect that both the first and second records contain the runtime field since we merge both of them: <img width="887" alt="Screen Shot 2021-06-30 at 10 03 31 AM" src="https://user-images.githubusercontent.com/1151048/123999078-58e6bf00-d98f-11eb-83bd-dda6b50fabcd.png"> ### Checklist Delete any items that are not applicable to this PR. - [x] If a plugin configuration key changed, check if it needs to be allowlisted in the [cloud](https://github.com/elastic/cloud) and added to the [docker list](https://github.com/elastic/kibana/blob/c29adfef29e921cc447d2a5ed06ac2047ceab552/src/dev/build/tasks/os_packages/docker_generator/resources/bin/kibana-docker) --- .../resources/base/bin/kibana-docker | 10 ++++ .../security_solution/server/config.ts | 6 +++ .../routes/__mocks__/index.ts | 1 + .../signals/build_bulk_body.test.ts | 49 ++++++++++++++++--- .../signals/build_bulk_body.ts | 18 ++++--- .../signals/search_after_bulk_create.test.ts | 1 + .../signals/signal_rule_alert_type.test.ts | 1 + .../signals/signal_rule_alert_type.ts | 5 ++ .../strategies/get_strategy.ts | 31 ++++++++++++ .../source_fields_merging/strategies/index.ts | 1 + .../merge_all_fields_with_source.ts | 6 +-- .../merge_missing_fields_with_source.ts | 10 ++-- .../strategies/merge_no_fields.ts | 15 ++++++ .../signals/source_fields_merging/types.ts | 7 +++ .../signals/wrap_hits_factory.ts | 5 +- .../signals/wrap_sequences_factory.ts | 5 +- .../security_solution/server/plugin.ts | 1 + 17 files changed, 145 insertions(+), 27 deletions(-) create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/strategies/get_strategy.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/strategies/merge_no_fields.ts diff --git a/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker b/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker index a224793bace3f..643080fda381f 100755 --- a/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker +++ b/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker @@ -380,6 +380,16 @@ kibana_vars=( xpack.security.session.idleTimeout xpack.security.session.lifespan xpack.security.sessionTimeout + xpack.securitySolution.alertMergeStrategy + xpack.securitySolution.alertResultListDefaultDateRange + xpack.securitySolution.endpointResultListDefaultFirstPageIndex + xpack.securitySolution.endpointResultListDefaultPageSize + xpack.securitySolution.maxRuleImportExportSize + xpack.securitySolution.maxRuleImportPayloadBytes + xpack.securitySolution.maxTimelineImportExportSize + xpack.securitySolution.maxTimelineImportPayloadBytes + xpack.securitySolution.packagerTaskInterval + xpack.securitySolution.validateArtifactDownloads xpack.spaces.enabled xpack.spaces.maxSpaces xpack.task_manager.enabled diff --git a/x-pack/plugins/security_solution/server/config.ts b/x-pack/plugins/security_solution/server/config.ts index 8dfe56a1a54f4..d19c36ad21eda 100644 --- a/x-pack/plugins/security_solution/server/config.ts +++ b/x-pack/plugins/security_solution/server/config.ts @@ -21,6 +21,12 @@ export const configSchema = schema.object({ maxRuleImportPayloadBytes: schema.number({ defaultValue: 10485760 }), maxTimelineImportExportSize: schema.number({ defaultValue: 10000 }), maxTimelineImportPayloadBytes: schema.number({ defaultValue: 10485760 }), + alertMergeStrategy: schema.oneOf( + [schema.literal('allFields'), schema.literal('missingFields'), schema.literal('noFields')], + { + defaultValue: 'missingFields', + } + ), [SIGNALS_INDEX_KEY]: schema.string({ defaultValue: DEFAULT_SIGNALS_INDEX }), /** diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/index.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/index.ts index 2e72ac137adcf..084105b7d1c49 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/index.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/index.ts @@ -30,6 +30,7 @@ export const createMockConfig = (): ConfigType => ({ }, packagerTaskInterval: '60s', validateArtifactDownloads: true, + alertMergeStrategy: 'missingFields', }); export const mockGetCurrentUser = { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.test.ts index 4053d64539c49..117dcdf0c18da 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.test.ts @@ -38,7 +38,11 @@ describe('buildBulkBody', () => { const ruleSO = sampleRuleSO(getQueryRuleParams()); const doc = sampleDocNoSortId(); delete doc._source.source; - const fakeSignalSourceHit: SignalHitOptionalTimestamp = buildBulkBody(ruleSO, doc); + const fakeSignalSourceHit: SignalHitOptionalTimestamp = buildBulkBody( + ruleSO, + doc, + 'missingFields' + ); // Timestamp will potentially always be different so remove it for the test delete fakeSignalSourceHit['@timestamp']; const expected: Omit<SignalHit, '@timestamp'> & { someKey: 'someValue' } = { @@ -102,7 +106,11 @@ describe('buildBulkBody', () => { }, }; delete doc._source.source; - const fakeSignalSourceHit: SignalHitOptionalTimestamp = buildBulkBody(ruleSO, doc); + const fakeSignalSourceHit: SignalHitOptionalTimestamp = buildBulkBody( + ruleSO, + doc, + 'missingFields' + ); // Timestamp will potentially always be different so remove it for the test delete fakeSignalSourceHit['@timestamp']; const expected: Omit<SignalHit, '@timestamp'> & { someKey: 'someValue' } = { @@ -180,7 +188,11 @@ describe('buildBulkBody', () => { dataset: 'socket', kind: 'event', }; - const fakeSignalSourceHit: SignalHitOptionalTimestamp = buildBulkBody(ruleSO, doc); + const fakeSignalSourceHit: SignalHitOptionalTimestamp = buildBulkBody( + ruleSO, + doc, + 'missingFields' + ); // Timestamp will potentially always be different so remove it for the test delete fakeSignalSourceHit['@timestamp']; const expected: Omit<SignalHit, '@timestamp'> & { someKey: 'someValue' } = { @@ -244,7 +256,11 @@ describe('buildBulkBody', () => { module: 'system', dataset: 'socket', }; - const fakeSignalSourceHit: SignalHitOptionalTimestamp = buildBulkBody(ruleSO, doc); + const fakeSignalSourceHit: SignalHitOptionalTimestamp = buildBulkBody( + ruleSO, + doc, + 'missingFields' + ); // Timestamp will potentially always be different so remove it for the test delete fakeSignalSourceHit['@timestamp']; const expected: Omit<SignalHit, '@timestamp'> & { someKey: 'someValue' } = { @@ -305,7 +321,11 @@ describe('buildBulkBody', () => { doc._source.event = { kind: 'event', }; - const fakeSignalSourceHit: SignalHitOptionalTimestamp = buildBulkBody(ruleSO, doc); + const fakeSignalSourceHit: SignalHitOptionalTimestamp = buildBulkBody( + ruleSO, + doc, + 'missingFields' + ); // Timestamp will potentially always be different so remove it for the test delete fakeSignalSourceHit['@timestamp']; const expected: Omit<SignalHit, '@timestamp'> & { someKey: 'someValue' } = { @@ -365,7 +385,11 @@ describe('buildBulkBody', () => { signal: 123, }, } as unknown) as SignalSourceHit; - const { '@timestamp': timestamp, ...fakeSignalSourceHit } = buildBulkBody(ruleSO, doc); + const { '@timestamp': timestamp, ...fakeSignalSourceHit } = buildBulkBody( + ruleSO, + doc, + 'missingFields' + ); const expected: Omit<SignalHit, '@timestamp'> & { someKey: string } = { someKey: 'someValue', event: { @@ -421,7 +445,11 @@ describe('buildBulkBody', () => { signal: { child_1: { child_2: 'nested data' } }, }, } as unknown) as SignalSourceHit; - const { '@timestamp': timestamp, ...fakeSignalSourceHit } = buildBulkBody(ruleSO, doc); + const { '@timestamp': timestamp, ...fakeSignalSourceHit } = buildBulkBody( + ruleSO, + doc, + 'missingFields' + ); const expected: Omit<SignalHit, '@timestamp'> & { someKey: string } = { someKey: 'someValue', event: { @@ -645,7 +673,12 @@ describe('buildSignalFromEvent', () => { const ancestor = sampleDocWithAncestors().hits.hits[0]; delete ancestor._source.source; const ruleSO = sampleRuleSO(getQueryRuleParams()); - const signal: SignalHitOptionalTimestamp = buildSignalFromEvent(ancestor, ruleSO, true); + const signal: SignalHitOptionalTimestamp = buildSignalFromEvent( + ancestor, + ruleSO, + true, + 'missingFields' + ); // Timestamp will potentially always be different so remove it for the test delete signal['@timestamp']; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.ts index 819e1f3eb6df1..2e6f4b9303d89 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.ts @@ -6,7 +6,7 @@ */ import { SavedObject } from 'src/core/types'; -import { mergeMissingFieldsWithSource } from './source_fields_merging/strategies/merge_missing_fields_with_source'; +import { getMergeStrategy } from './source_fields_merging/strategies'; import { AlertAttributes, SignalSourceHit, @@ -21,6 +21,7 @@ import { additionalSignalFields, buildSignal } from './build_signal'; import { buildEventTypeSignal } from './build_event_type_signal'; import { EqlSequence } from '../../../../common/detection_engine/types'; import { generateSignalId, wrapBuildingBlocks, wrapSignal } from './utils'; +import type { ConfigType } from '../../../config'; /** * Formats the search_after result for insertion into the signals index. We first create a @@ -33,9 +34,10 @@ import { generateSignalId, wrapBuildingBlocks, wrapSignal } from './utils'; */ export const buildBulkBody = ( ruleSO: SavedObject<AlertAttributes>, - doc: SignalSourceHit + doc: SignalSourceHit, + mergeStrategy: ConfigType['alertMergeStrategy'] ): SignalHit => { - const mergedDoc = mergeMissingFieldsWithSource({ doc }); + const mergedDoc = getMergeStrategy(mergeStrategy)({ doc }); const rule = buildRuleWithOverrides(ruleSO, mergedDoc._source ?? {}); const signal: Signal = { ...buildSignal([mergedDoc], rule), @@ -65,11 +67,12 @@ export const buildBulkBody = ( export const buildSignalGroupFromSequence = ( sequence: EqlSequence<SignalSource>, ruleSO: SavedObject<AlertAttributes>, - outputIndex: string + outputIndex: string, + mergeStrategy: ConfigType['alertMergeStrategy'] ): WrappedSignalHit[] => { const wrappedBuildingBlocks = wrapBuildingBlocks( sequence.events.map((event) => { - const signal = buildSignalFromEvent(event, ruleSO, false); + const signal = buildSignalFromEvent(event, ruleSO, false, mergeStrategy); signal.signal.rule.building_block_type = 'default'; return signal; }), @@ -130,9 +133,10 @@ export const buildSignalFromSequence = ( export const buildSignalFromEvent = ( event: BaseSignalHit, ruleSO: SavedObject<AlertAttributes>, - applyOverrides: boolean + applyOverrides: boolean, + mergeStrategy: ConfigType['alertMergeStrategy'] ): SignalHit => { - const mergedEvent = mergeMissingFieldsWithSource({ doc: event }); + const mergedEvent = getMergeStrategy(mergeStrategy)({ doc: event }); const rule = applyOverrides ? buildRuleWithOverrides(ruleSO, mergedEvent._source ?? {}) : buildRuleWithoutOverrides(ruleSO); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts index 21c1402861e6e..dc03f1bc964f2 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts @@ -69,6 +69,7 @@ describe('searchAfterAndBulkCreate', () => { wrapHits = wrapHitsFactory({ ruleSO, signalsIndex: DEFAULT_SIGNALS_INDEX, + mergeStrategy: 'missingFields', }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts index d8c919b50e9db..39aebb4aa4555 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts @@ -192,6 +192,7 @@ describe('signal_rule_alert_type', () => { version, ml: mlMock, lists: listMock.createSetup(), + mergeStrategy: 'missingFields', }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts index ba665fa43e8b8..6eef97b05b697 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts @@ -68,6 +68,7 @@ import { import { bulkCreateFactory } from './bulk_create_factory'; import { wrapHitsFactory } from './wrap_hits_factory'; import { wrapSequencesFactory } from './wrap_sequences_factory'; +import { ConfigType } from '../../../config'; export const signalRulesAlertType = ({ logger, @@ -75,12 +76,14 @@ export const signalRulesAlertType = ({ version, ml, lists, + mergeStrategy, }: { logger: Logger; eventsTelemetry: TelemetryEventsSender | undefined; version: string; ml: SetupPlugins['ml']; lists: SetupPlugins['lists'] | undefined; + mergeStrategy: ConfigType['alertMergeStrategy']; }): SignalRuleAlertTypeDefinition => { return { id: SIGNALS_ID, @@ -233,11 +236,13 @@ export const signalRulesAlertType = ({ const wrapHits = wrapHitsFactory({ ruleSO: savedObject, signalsIndex: params.outputIndex, + mergeStrategy, }); const wrapSequences = wrapSequencesFactory({ ruleSO: savedObject, signalsIndex: params.outputIndex, + mergeStrategy, }); if (isMlRule(type)) { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/strategies/get_strategy.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/strategies/get_strategy.ts new file mode 100644 index 0000000000000..3c4b1cd0ef373 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/strategies/get_strategy.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { assertUnreachable } from '../../../../../../common'; +import type { ConfigType } from '../../../../../config'; +import { MergeStrategyFunction } from '../types'; +import { mergeAllFieldsWithSource } from './merge_all_fields_with_source'; +import { mergeMissingFieldsWithSource } from './merge_missing_fields_with_source'; +import { mergeNoFields } from './merge_no_fields'; + +export const getMergeStrategy = ( + mergeStrategy: ConfigType['alertMergeStrategy'] +): MergeStrategyFunction => { + switch (mergeStrategy) { + case 'allFields': { + return mergeAllFieldsWithSource; + } + case 'missingFields': { + return mergeMissingFieldsWithSource; + } + case 'noFields': { + return mergeNoFields; + } + default: + return assertUnreachable(mergeStrategy); + } +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/strategies/index.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/strategies/index.ts index 212eba9c6c3be..60460ad5f2e00 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/strategies/index.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/strategies/index.ts @@ -6,3 +6,4 @@ */ export * from './merge_all_fields_with_source'; export * from './merge_missing_fields_with_source'; +export * from './get_strategy'; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/strategies/merge_all_fields_with_source.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/strategies/merge_all_fields_with_source.ts index de8d3ba820e23..da2eea9d2c61e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/strategies/merge_all_fields_with_source.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/strategies/merge_all_fields_with_source.ts @@ -7,9 +7,9 @@ import { get } from 'lodash/fp'; import { set } from '@elastic/safer-lodash-set/fp'; -import { SignalSource, SignalSourceHit } from '../../types'; +import { SignalSource } from '../../types'; import { filterFieldEntries } from '../utils/filter_field_entries'; -import type { FieldsType } from '../types'; +import type { FieldsType, MergeStrategyFunction } from '../types'; import { isObjectLikeOrArrayOfObjectLikes } from '../utils/is_objectlike_or_array_of_objectlikes'; import { isNestedObject } from '../utils/is_nested_object'; import { recursiveUnboxingFields } from '../utils/recursive_unboxing_fields'; @@ -26,7 +26,7 @@ import { isTypeObject } from '../utils/is_type_object'; * @param throwOnFailSafe Defaults to false, but if set to true it will cause a throw if the fail safe is triggered to indicate we need to add a new explicit test condition * @returns The two merged together in one object where we can */ -export const mergeAllFieldsWithSource = ({ doc }: { doc: SignalSourceHit }): SignalSourceHit => { +export const mergeAllFieldsWithSource: MergeStrategyFunction = ({ doc }) => { const source = doc._source ?? {}; const fields = doc.fields ?? {}; const fieldEntries = Object.entries(fields); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/strategies/merge_missing_fields_with_source.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/strategies/merge_missing_fields_with_source.ts index bf541acbe7e33..b66c46ccbf0ca 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/strategies/merge_missing_fields_with_source.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/strategies/merge_missing_fields_with_source.ts @@ -7,9 +7,9 @@ import { get } from 'lodash/fp'; import { set } from '@elastic/safer-lodash-set/fp'; -import { SignalSource, SignalSourceHit } from '../../types'; +import { SignalSource } from '../../types'; import { filterFieldEntries } from '../utils/filter_field_entries'; -import type { FieldsType } from '../types'; +import type { FieldsType, MergeStrategyFunction } from '../types'; import { recursiveUnboxingFields } from '../utils/recursive_unboxing_fields'; import { isTypeObject } from '../utils/is_type_object'; import { arrayInPathExists } from '../utils/array_in_path_exists'; @@ -22,11 +22,7 @@ import { isNestedObject } from '../utils/is_nested_object'; * @param throwOnFailSafe Defaults to false, but if set to true it will cause a throw if the fail safe is triggered to indicate we need to add a new explicit test condition * @returns The two merged together in one object where we can */ -export const mergeMissingFieldsWithSource = ({ - doc, -}: { - doc: SignalSourceHit; -}): SignalSourceHit => { +export const mergeMissingFieldsWithSource: MergeStrategyFunction = ({ doc }) => { const source = doc._source ?? {}; const fields = doc.fields ?? {}; const fieldEntries = Object.entries(fields); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/strategies/merge_no_fields.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/strategies/merge_no_fields.ts new file mode 100644 index 0000000000000..6c2daf2526715 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/strategies/merge_no_fields.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 { MergeStrategyFunction } from '../types'; + +/** + * Does nothing and does not merge source with fields + * @param doc The doc to return and do nothing + * @returns The doc as a no operation and do nothing + */ +export const mergeNoFields: MergeStrategyFunction = ({ doc }) => doc; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/types.ts index e8142e41715e2..1438d2844949c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/source_fields_merging/types.ts @@ -5,7 +5,14 @@ * 2.0. */ +import { SignalSourceHit } from '../types'; + /** * A bit stricter typing since the default fields type is an "any" */ export type FieldsType = string[] | number[] | boolean[] | object[]; + +/** + * The type of the merge strategy functions which must implement to be part of the strategy group + */ +export type MergeStrategyFunction = ({ doc }: { doc: SignalSourceHit }) => SignalSourceHit; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/wrap_hits_factory.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/wrap_hits_factory.ts index d5c05bc890332..b28c46aae8f82 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/wrap_hits_factory.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/wrap_hits_factory.ts @@ -9,13 +9,16 @@ import { SearchAfterAndBulkCreateParams, WrapHits, WrappedSignalHit } from './ty import { generateId } from './utils'; import { buildBulkBody } from './build_bulk_body'; import { filterDuplicateSignals } from './filter_duplicate_signals'; +import type { ConfigType } from '../../../config'; export const wrapHitsFactory = ({ ruleSO, signalsIndex, + mergeStrategy, }: { ruleSO: SearchAfterAndBulkCreateParams['ruleSO']; signalsIndex: string; + mergeStrategy: ConfigType['alertMergeStrategy']; }): WrapHits => (events) => { const wrappedDocs: WrappedSignalHit[] = events.flatMap((doc) => [ { @@ -26,7 +29,7 @@ export const wrapHitsFactory = ({ String(doc._version), ruleSO.attributes.params.ruleId ?? '' ), - _source: buildBulkBody(ruleSO, doc), + _source: buildBulkBody(ruleSO, doc, mergeStrategy), }, ]); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/wrap_sequences_factory.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/wrap_sequences_factory.ts index c53ea7b7ebe72..f0b9e64047692 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/wrap_sequences_factory.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/wrap_sequences_factory.ts @@ -7,18 +7,21 @@ import { SearchAfterAndBulkCreateParams, WrappedSignalHit, WrapSequences } from './types'; import { buildSignalGroupFromSequence } from './build_bulk_body'; +import { ConfigType } from '../../../config'; export const wrapSequencesFactory = ({ ruleSO, signalsIndex, + mergeStrategy, }: { ruleSO: SearchAfterAndBulkCreateParams['ruleSO']; signalsIndex: string; + mergeStrategy: ConfigType['alertMergeStrategy']; }): WrapSequences => (sequences) => sequences.reduce( (acc: WrappedSignalHit[], sequence) => [ ...acc, - ...buildSignalGroupFromSequence(sequence, ruleSO, signalsIndex), + ...buildSignalGroupFromSequence(sequence, ruleSO, signalsIndex, mergeStrategy), ], [] ); diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index 2f523d9d9969d..2f3850ff49f4c 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -387,6 +387,7 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S version: this.context.env.packageInfo.version, ml: plugins.ml, lists: plugins.lists, + mergeStrategy: this.config.alertMergeStrategy, }); const ruleNotificationType = rulesNotificationAlertType({ logger: this.logger, From a3f86bda3e22ecb65585d2cc7d0ac4f7ad6c2800 Mon Sep 17 00:00:00 2001 From: Josh Dover <1813008+joshdover@users.noreply.github.com> Date: Thu, 1 Jul 2021 00:09:26 +0200 Subject: [PATCH 15/51] [Cloud] Fix sessions stitching across domains (#103964) --- x-pack/plugins/cloud/public/fullstory.ts | 39 ++++++++++------------ x-pack/plugins/cloud/public/plugin.test.ts | 13 +++----- x-pack/plugins/cloud/public/plugin.ts | 9 +++-- 3 files changed, 28 insertions(+), 33 deletions(-) diff --git a/x-pack/plugins/cloud/public/fullstory.ts b/x-pack/plugins/cloud/public/fullstory.ts index 31e5ec128b9a3..b118688f31ae1 100644 --- a/x-pack/plugins/cloud/public/fullstory.ts +++ b/x-pack/plugins/cloud/public/fullstory.ts @@ -12,7 +12,7 @@ export interface FullStoryDeps { basePath: IBasePath; orgId: string; packageInfo: PackageInfo; - userIdPromise: Promise<string | undefined>; + userId?: string; } interface FullStoryApi { @@ -24,7 +24,7 @@ export const initializeFullStory = async ({ basePath, orgId, packageInfo, - userIdPromise, + userId, }: FullStoryDeps) => { // @ts-expect-error window._fs_debug = false; @@ -73,28 +73,23 @@ export const initializeFullStory = async ({ /* eslint-enable */ // @ts-expect-error - const fullstory: FullStoryApi = window.FSKibana; + const fullStory: FullStoryApi = window.FSKibana; + + try { + // This needs to be called syncronously to be sure that we populate the user ID soon enough to make sessions merging + // across domains work + if (!userId) return; + // Do the hashing here to keep it at clear as possible in our source code that we do not send literal user IDs + const hashedId = sha256(userId.toString()); + fullStory.identify(hashedId); + } catch (e) { + // eslint-disable-next-line no-console + console.error(`[cloud.full_story] Could not call FS.identify due to error: ${e.toString()}`, e); + } // Record an event that Kibana was opened so we can easily search for sessions that use Kibana - // @ts-expect-error - window.FSKibana.event('Loaded Kibana', { + fullStory.event('Loaded Kibana', { + // `str` suffix is required, see docs: https://help.fullstory.com/hc/en-us/articles/360020623234 kibana_version_str: packageInfo.version, }); - - // Use a promise here so we don't have to wait to retrieve the user to start recording the session - userIdPromise - .then((userId) => { - if (!userId) return; - // Do the hashing here to keep it at clear as possible in our source code that we do not send literal user IDs - const hashedId = sha256(userId.toString()); - // @ts-expect-error - window.FSKibana.identify(hashedId); - }) - .catch((e) => { - // eslint-disable-next-line no-console - console.error( - `[cloud.full_story] Could not call FS.identify due to error: ${e.toString()}`, - e - ); - }); }; diff --git a/x-pack/plugins/cloud/public/plugin.test.ts b/x-pack/plugins/cloud/public/plugin.test.ts index af4d3c4c9005d..264ae61c050e8 100644 --- a/x-pack/plugins/cloud/public/plugin.test.ts +++ b/x-pack/plugins/cloud/public/plugin.test.ts @@ -63,16 +63,11 @@ describe('Cloud Plugin', () => { }); expect(initializeFullStoryMock).toHaveBeenCalled(); - const { - basePath, - orgId, - packageInfo, - userIdPromise, - } = initializeFullStoryMock.mock.calls[0][0]; + const { basePath, orgId, packageInfo, userId } = initializeFullStoryMock.mock.calls[0][0]; expect(basePath.prepend).toBeDefined(); expect(orgId).toEqual('foo'); expect(packageInfo).toEqual(initContext.env.packageInfo); - expect(await userIdPromise).toEqual('1234'); + expect(userId).toEqual('1234'); }); it('passes undefined user ID when security is not available', async () => { @@ -82,9 +77,9 @@ describe('Cloud Plugin', () => { }); expect(initializeFullStoryMock).toHaveBeenCalled(); - const { orgId, userIdPromise } = initializeFullStoryMock.mock.calls[0][0]; + const { orgId, userId } = initializeFullStoryMock.mock.calls[0][0]; expect(orgId).toEqual('foo'); - expect(await userIdPromise).toEqual(undefined); + expect(userId).toEqual(undefined); }); it('does not call initializeFullStory when enabled=false', async () => { diff --git a/x-pack/plugins/cloud/public/plugin.ts b/x-pack/plugins/cloud/public/plugin.ts index 68dece1bc5d3d..98017d09ef807 100644 --- a/x-pack/plugins/cloud/public/plugin.ts +++ b/x-pack/plugins/cloud/public/plugin.ts @@ -166,16 +166,21 @@ export class CloudPlugin implements Plugin<CloudSetup> { } // Keep this import async so that we do not load any FullStory code into the browser when it is disabled. - const { initializeFullStory } = await import('./fullstory'); + const fullStoryChunkPromise = import('./fullstory'); const userIdPromise: Promise<string | undefined> = security ? loadFullStoryUserId({ getCurrentUser: security.authc.getCurrentUser }) : Promise.resolve(undefined); + const [{ initializeFullStory }, userId] = await Promise.all([ + fullStoryChunkPromise, + userIdPromise, + ]); + initializeFullStory({ basePath, orgId, packageInfo: this.initializerContext.env.packageInfo, - userIdPromise, + userId, }); } } From aa5c56c41866fddf95bf0733edec683ddb54a3b0 Mon Sep 17 00:00:00 2001 From: Paul Tavares <56442535+paul-tavares@users.noreply.github.com> Date: Wed, 30 Jun 2021 18:29:25 -0400 Subject: [PATCH 16/51] [Security Solution][Hosts] Show Fleet Agent status and Isolation status for Endpoint Hosts when on the Host Details page (#103781) * Refactor: extract agent status to endpoint host status to reusable utiltiy * Show Fleet Agent status + isolation status * Refactor EndpoinAgentStatus component to use `<AgentStatus>` common component * Move actions service to `endpoint/services` directory * Add pending actions to the search strategy for endpoint data --- .../security_solution/hosts/common/index.ts | 6 + .../view/components/endpoint_agent_status.tsx | 16 +-- .../endpoint_overview/index.test.tsx | 69 ++++++++---- .../host_overview/endpoint_overview/index.tsx | 21 +++- .../endpoint_overview/translations.ts | 11 +- .../routes/actions/audit_log_handler.ts | 2 +- .../server/endpoint/routes/actions/status.ts | 103 +----------------- .../endpoint/routes/metadata/handlers.ts | 17 +-- .../service.ts => services/actions.ts} | 86 ++++++++++++++- .../server/endpoint/services/index.ts | 1 + ...et_agent_status_to_endpoint_host_status.ts | 29 +++++ .../server/endpoint/utils/index.ts | 8 ++ .../factory/hosts/details/helpers.ts | 19 +++- 13 files changed, 233 insertions(+), 155 deletions(-) rename x-pack/plugins/security_solution/server/endpoint/{routes/actions/service.ts => services/actions.ts} (55%) create mode 100644 x-pack/plugins/security_solution/server/endpoint/utils/fleet_agent_status_to_endpoint_host_status.ts create mode 100644 x-pack/plugins/security_solution/server/endpoint/utils/index.ts diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/common/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/common/index.ts index 3175876a8299c..f6f5ad4cd23f1 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/common/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/common/index.ts @@ -8,6 +8,7 @@ import { CloudEcs } from '../../../../ecs/cloud'; import { HostEcs, OsEcs } from '../../../../ecs/host'; import { Hit, Hits, Maybe, SearchHit, StringOrNumber, TotalValue } from '../../../common'; +import { EndpointPendingActions, HostStatus } from '../../../../endpoint/types'; export enum HostPolicyResponseActionStatus { success = 'success', @@ -25,6 +26,11 @@ export interface EndpointFields { endpointPolicy?: Maybe<string>; sensorVersion?: Maybe<string>; policyStatus?: Maybe<HostPolicyResponseActionStatus>; + /** if the host is currently isolated */ + isolation?: Maybe<boolean>; + /** A count of pending endpoint actions against the host */ + pendingActions?: Maybe<EndpointPendingActions['pending_actions']>; + elasticAgentStatus?: Maybe<HostStatus>; id?: Maybe<string>; } diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/components/endpoint_agent_status.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/components/endpoint_agent_status.tsx index 94db233972d67..d422fb736965a 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/components/endpoint_agent_status.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/components/endpoint_agent_status.tsx @@ -6,14 +6,13 @@ */ import React, { memo } from 'react'; -import { EuiBadge, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import styled from 'styled-components'; import { HostInfo, HostMetadata } from '../../../../../../common/endpoint/types'; -import { HOST_STATUS_TO_BADGE_COLOR } from '../host_constants'; import { EndpointHostIsolationStatus } from '../../../../../common/components/endpoint/host_isolation'; import { useEndpointSelector } from '../hooks'; import { getEndpointHostIsolationStatusPropsCallback } from '../../store/selectors'; +import { AgentStatus } from '../../../../../common/components/endpoint/agent_status'; const EuiFlexGroupStyled = styled(EuiFlexGroup)` .isolation-status { @@ -34,16 +33,7 @@ export const EndpointAgentStatus = memo<EndpointAgentStatusProps>( return ( <EuiFlexGroupStyled gutterSize="none" responsive={false} className="eui-textTruncate"> <EuiFlexItem grow={false}> - <EuiBadge - color={hostStatus != null ? HOST_STATUS_TO_BADGE_COLOR[hostStatus] : 'warning'} - data-test-subj="rowHostStatus" - > - <FormattedMessage - id="xpack.securitySolution.endpoint.list.hostStatusValue" - defaultMessage="{hostStatus, select, healthy {Healthy} unhealthy {Unhealthy} updating {Updating} offline {Offline} inactive {Inactive} other {Unhealthy}}" - values={{ hostStatus }} - /> - </EuiBadge> + <AgentStatus hostStatus={hostStatus} /> </EuiFlexItem> <EuiFlexItem grow={false} className="eui-textTruncate isolation-status"> <EndpointHostIsolationStatus diff --git a/x-pack/plugins/security_solution/public/overview/components/host_overview/endpoint_overview/index.test.tsx b/x-pack/plugins/security_solution/public/overview/components/host_overview/endpoint_overview/index.test.tsx index 45898427ee60b..6a0e7c381664c 100644 --- a/x-pack/plugins/security_solution/public/overview/components/host_overview/endpoint_overview/index.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/host_overview/endpoint_overview/index.test.tsx @@ -13,42 +13,71 @@ import '../../../../common/mock/react_beautiful_dnd'; import { TestProviders } from '../../../../common/mock'; import { EndpointOverview } from './index'; -import { HostPolicyResponseActionStatus } from '../../../../../common/search_strategy/security_solution/hosts'; +import { + EndpointFields, + HostPolicyResponseActionStatus, +} from '../../../../../common/search_strategy/security_solution/hosts'; +import { HostStatus } from '../../../../../common/endpoint/types'; jest.mock('../../../../common/lib/kibana'); describe('EndpointOverview Component', () => { - test('it renders with endpoint data', () => { - const endpointData = { - endpointPolicy: 'demo', - policyStatus: HostPolicyResponseActionStatus.success, - sensorVersion: '7.9.0-SNAPSHOT', - }; - const wrapper = mount( + let endpointData: EndpointFields; + let wrapper: ReturnType<typeof mount>; + let findData: ReturnType<typeof wrapper['find']>; + const render = (data: EndpointFields | null = endpointData) => { + wrapper = mount( <TestProviders> - <EndpointOverview data={endpointData} /> + <EndpointOverview data={data} /> </TestProviders> ); - - const findData = wrapper.find( + findData = wrapper.find( 'dl[data-test-subj="endpoint-overview"] dd.euiDescriptionList__description' ); + + return wrapper; + }; + + beforeEach(() => { + endpointData = { + endpointPolicy: 'demo', + policyStatus: HostPolicyResponseActionStatus.success, + sensorVersion: '7.9.0-SNAPSHOT', + isolation: false, + elasticAgentStatus: HostStatus.HEALTHY, + pendingActions: {}, + }; + }); + + test('it renders with endpoint data', () => { + render(); expect(findData.at(0).text()).toEqual(endpointData.endpointPolicy); expect(findData.at(1).text()).toEqual(endpointData.policyStatus); expect(findData.at(2).text()).toContain(endpointData.sensorVersion); // contain because drag adds a space + expect(findData.at(3).text()).toEqual('Healthy'); }); - test('it renders with null data', () => { - const wrapper = mount( - <TestProviders> - <EndpointOverview data={null} /> - </TestProviders> - ); - const findData = wrapper.find( - 'dl[data-test-subj="endpoint-overview"] dd.euiDescriptionList__description' - ); + test('it renders with null data', () => { + render(null); expect(findData.at(0).text()).toEqual('—'); expect(findData.at(1).text()).toEqual('—'); expect(findData.at(2).text()).toContain('—'); // contain because drag adds a space + expect(findData.at(3).text()).toEqual('—'); + }); + + test('it shows isolation status', () => { + endpointData.isolation = true; + render(); + expect(findData.at(3).text()).toEqual('HealthyIsolated'); + }); + + test.each([ + ['isolate', 'Isolating'], + ['unisolate', 'Releasing'], + ])('it shows pending %s status', (action, expectedLabel) => { + endpointData.isolation = true; + endpointData.pendingActions![action] = 1; + render(); + expect(findData.at(3).text()).toEqual(`Healthy${expectedLabel}`); }); }); diff --git a/x-pack/plugins/security_solution/public/overview/components/host_overview/endpoint_overview/index.tsx b/x-pack/plugins/security_solution/public/overview/components/host_overview/endpoint_overview/index.tsx index 1b05b600c8e3e..568bf30dbe711 100644 --- a/x-pack/plugins/security_solution/public/overview/components/host_overview/endpoint_overview/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/host_overview/endpoint_overview/index.tsx @@ -18,6 +18,8 @@ import { EndpointFields, HostPolicyResponseActionStatus, } from '../../../../../common/search_strategy/security_solution/hosts'; +import { AgentStatus } from '../../../../common/components/endpoint/agent_status'; +import { EndpointHostIsolationStatus } from '../../../../common/components/endpoint/host_isolation'; interface Props { contextID?: string; @@ -73,7 +75,24 @@ export const EndpointOverview = React.memo<Props>(({ contextID, data }) => { : getEmptyTagValue(), }, ], - [], // needs 4 columns for design + [ + { + title: i18n.FLEET_AGENT_STATUS, + description: + data != null && data.elasticAgentStatus ? ( + <> + <AgentStatus hostStatus={data.elasticAgentStatus} /> + <EndpointHostIsolationStatus + isIsolated={Boolean(data.isolation)} + pendingIsolate={data.pendingActions?.isolate ?? 0} + pendingUnIsolate={data.pendingActions?.unisolate ?? 0} + /> + </> + ) : ( + getEmptyTagValue() + ), + }, + ], ], [data, getDefaultRenderer] ); diff --git a/x-pack/plugins/security_solution/public/overview/components/host_overview/endpoint_overview/translations.ts b/x-pack/plugins/security_solution/public/overview/components/host_overview/endpoint_overview/translations.ts index 1a007cd7f0f56..51e1f10e4b927 100644 --- a/x-pack/plugins/security_solution/public/overview/components/host_overview/endpoint_overview/translations.ts +++ b/x-pack/plugins/security_solution/public/overview/components/host_overview/endpoint_overview/translations.ts @@ -10,7 +10,7 @@ import { i18n } from '@kbn/i18n'; export const ENDPOINT_POLICY = i18n.translate( 'xpack.securitySolution.host.details.endpoint.endpointPolicy', { - defaultMessage: 'Integration', + defaultMessage: 'Endpoint integration policy', } ); @@ -24,6 +24,13 @@ export const POLICY_STATUS = i18n.translate( export const SENSORVERSION = i18n.translate( 'xpack.securitySolution.host.details.endpoint.sensorversion', { - defaultMessage: 'Sensor Version', + defaultMessage: 'Endpoint version', + } +); + +export const FLEET_AGENT_STATUS = i18n.translate( + 'xpack.securitySolution.host.details.endpoint.fleetAgentStatus', + { + defaultMessage: 'Agent status', } ); 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 index b0cea299af60d..5e9594f478b31 100644 --- 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 @@ -10,7 +10,7 @@ import { EndpointActionLogRequestParams, EndpointActionLogRequestQuery, } from '../../../../common/endpoint/schema/actions'; -import { getAuditLogResponse } from './service'; +import { getAuditLogResponse } from '../../services'; import { SecuritySolutionRequestHandlerContext } from '../../../types'; import { EndpointAppContext } from '../../types'; 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 index eb2c41ccb3506..ec03acee0335d 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/status.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/status.ts @@ -5,15 +5,8 @@ * 2.0. */ -import { ElasticsearchClient, RequestHandler } from 'kibana/server'; +import { RequestHandler } from 'kibana/server'; import { TypeOf } from '@kbn/config-schema'; -import { SearchRequest } from '@elastic/elasticsearch/api/types'; -import { - EndpointAction, - EndpointActionResponse, - EndpointPendingActions, -} 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 { @@ -21,6 +14,7 @@ import { SecuritySolutionRequestHandlerContext, } from '../../../types'; import { EndpointAppContext } from '../../types'; +import { getPendingActionCounts } from '../../services'; /** * Registers routes for checking status of endpoints based on pending actions @@ -53,7 +47,7 @@ export const actionStatusRequestHandler = function ( ? [...new Set(req.query.agent_ids)] : [req.query.agent_ids]; - const response = await getPendingActions(esClient, agentIDs); + const response = await getPendingActionCounts(esClient, agentIDs); return res.ok({ body: { @@ -62,94 +56,3 @@ export const actionStatusRequestHandler = function ( }); }; }; - -const getPendingActions = async ( - esClient: ElasticsearchClient, - agentIDs: string[] -): Promise<EndpointPendingActions[]> => { - // retrieve the unexpired actions for the given hosts - - const recentActions = await searchUntilEmpty<EndpointAction>(esClient, { - 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 - ], - }, - }, - }, - }); - - // retrieve any responses to those action IDs from these agents - const actionIDs = recentActions.map((a) => a.action_id); - const responses = await searchUntilEmpty<EndpointActionResponse>(esClient, { - 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 - ], - }, - }, - }, - }); - - // respond with action-count per agent - const pending: EndpointPendingActions[] = agentIDs.map((aid) => { - const responseIDsFromAgent = responses - .filter((r) => r.agent_id === aid) - .map((r) => r.action_id); - return { - agent_id: aid, - pending_actions: recentActions - .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 EndpointPendingActions['pending_actions']), - }; - }); - - return pending; -}; - -const searchUntilEmpty = async <T>( - esClient: ElasticsearchClient, - query: SearchRequest, - pageSize: number = 1000 -): Promise<T[]> => { - const results: T[] = []; - - for (let i = 0; ; i++) { - const result = await esClient.search<T>( - { - size: pageSize, - from: i * pageSize, - ...query, - }, - { - ignore: [404], - } - ); - if (!result || !result.body?.hits?.hits || result.body?.hits?.hits?.length === 0) { - break; - } - - const response = result.body?.hits?.hits?.map((a) => a._source!) || []; - results.push(...response); - } - - return results; -}; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/handlers.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/handlers.ts index 98610c2e84c02..815f30e6e7426 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/handlers.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/handlers.ts @@ -25,13 +25,14 @@ import { import type { SecuritySolutionRequestHandlerContext } from '../../../types'; import { getESQueryHostMetadataByID, kibanaRequestToMetadataListESQuery } from './query_builders'; -import { Agent, AgentStatus, PackagePolicy } from '../../../../../fleet/common/types/models'; +import { Agent, PackagePolicy } from '../../../../../fleet/common/types/models'; import { AgentNotFoundError } from '../../../../../fleet/server'; import { EndpointAppContext, HostListQueryResult } from '../../types'; import { GetMetadataListRequestSchema, GetMetadataRequestSchema } from './index'; import { findAllUnenrolledAgentIds } from './support/unenroll'; import { findAgentIDsByStatus } from './support/agent_status'; import { EndpointAppContextService } from '../../endpoint_app_context_services'; +import { fleetAgentStatusToEndpointHostStatus } from '../../utils'; export interface MetadataRequestContext { esClient?: IScopedClusterClient; @@ -41,18 +42,6 @@ export interface MetadataRequestContext { savedObjectsClient?: SavedObjectsClientContract; } -const HOST_STATUS_MAPPING = new Map<AgentStatus, HostStatus>([ - ['online', HostStatus.HEALTHY], - ['offline', HostStatus.OFFLINE], - ['inactive', HostStatus.INACTIVE], - ['unenrolling', HostStatus.UPDATING], - ['enrolling', HostStatus.UPDATING], - ['updating', HostStatus.UPDATING], - ['warning', HostStatus.UNHEALTHY], - ['error', HostStatus.UNHEALTHY], - ['degraded', HostStatus.UNHEALTHY], -]); - /** * 00000000-0000-0000-0000-000000000000 is initial Elastic Agent id sent by Endpoint before policy is configured * 11111111-1111-1111-1111-111111111111 is Elastic Agent id sent by Endpoint when policy does not contain an id @@ -375,7 +364,7 @@ export async function enrichHostMetadata( const status = await metadataRequestContext.endpointAppContextService ?.getAgentService() ?.getAgentStatusById(esClient.asCurrentUser, elasticAgentId); - hostStatus = HOST_STATUS_MAPPING.get(status!) || HostStatus.UNHEALTHY; + hostStatus = fleetAgentStatusToEndpointHostStatus(status!); } catch (e) { if (e instanceof AgentNotFoundError) { log.warn(`agent with id ${elasticAgentId} not found`); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/service.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions.ts similarity index 55% rename from x-pack/plugins/security_solution/server/endpoint/routes/actions/service.ts rename to x-pack/plugins/security_solution/server/endpoint/services/actions.ts index 7a82a56b1f19b..9d8db5b9a2154 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/service.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/actions.ts @@ -6,9 +6,14 @@ */ import { ElasticsearchClient, Logger } from 'kibana/server'; -import { AGENT_ACTIONS_INDEX, AGENT_ACTIONS_RESULTS_INDEX } from '../../../../../fleet/common'; -import { SecuritySolutionRequestHandlerContext } from '../../../types'; -import { ActivityLog, EndpointAction } from '../../../../common/endpoint/types'; +import { AGENT_ACTIONS_INDEX, AGENT_ACTIONS_RESULTS_INDEX } from '../../../../fleet/common'; +import { SecuritySolutionRequestHandlerContext } from '../../types'; +import { + ActivityLog, + EndpointAction, + EndpointActionResponse, + EndpointPendingActions, +} from '../../../common/endpoint/types'; export const getAuditLogResponse = async ({ elasticAgentId, @@ -135,3 +140,78 @@ const getActivityLog = async ({ return sortedData; }; + +export const getPendingActionCounts = async ( + esClient: ElasticsearchClient, + agentIDs: string[] +): Promise<EndpointPendingActions[]> => { + // retrieve the unexpired actions for the given hosts + const recentActions = await esClient + .search<EndpointAction>( + { + index: AGENT_ACTIONS_INDEX, + size: 10000, + from: 0, + 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] } + ) + .then((result) => result.body?.hits?.hits?.map((a) => a._source!) || []); + + // retrieve any responses to those action IDs from these agents + const actionIDs = recentActions.map((a) => a.action_id); + const responses = await esClient + .search<EndpointActionResponse>( + { + index: AGENT_ACTIONS_RESULTS_INDEX, + size: 10000, + from: 0, + 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] } + ) + .then((result) => result.body?.hits?.hits?.map((a) => a._source!) || []); + + // respond with action-count per agent + const pending: EndpointPendingActions[] = agentIDs.map((aid) => { + const responseIDsFromAgent = responses + .filter((r) => r.agent_id === aid) + .map((r) => r.action_id); + return { + agent_id: aid, + pending_actions: recentActions + .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 EndpointPendingActions['pending_actions']), + }; + }); + + return pending; +}; diff --git a/x-pack/plugins/security_solution/server/endpoint/services/index.ts b/x-pack/plugins/security_solution/server/endpoint/services/index.ts index 8bf64999c746a..ee6570c4866bd 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/index.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/index.ts @@ -7,3 +7,4 @@ export * from './artifacts'; export { getMetadataForEndpoints } from './metadata'; +export * from './actions'; diff --git a/x-pack/plugins/security_solution/server/endpoint/utils/fleet_agent_status_to_endpoint_host_status.ts b/x-pack/plugins/security_solution/server/endpoint/utils/fleet_agent_status_to_endpoint_host_status.ts new file mode 100644 index 0000000000000..3c02222346a44 --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/utils/fleet_agent_status_to_endpoint_host_status.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 { AgentStatus } from '../../../../fleet/common'; +import { HostStatus } from '../../../common/endpoint/types'; + +const STATUS_MAPPING: ReadonlyMap<AgentStatus, HostStatus> = new Map([ + ['online', HostStatus.HEALTHY], + ['offline', HostStatus.OFFLINE], + ['inactive', HostStatus.INACTIVE], + ['unenrolling', HostStatus.UPDATING], + ['enrolling', HostStatus.UPDATING], + ['updating', HostStatus.UPDATING], + ['warning', HostStatus.UNHEALTHY], + ['error', HostStatus.UNHEALTHY], + ['degraded', HostStatus.UNHEALTHY], +]); + +/** + * A Map of Fleet Agent Status to Endpoint Host Status. + * Default status is `HostStatus.UNHEALTHY` + */ +export const fleetAgentStatusToEndpointHostStatus = (status: AgentStatus): HostStatus => { + return STATUS_MAPPING.get(status) || HostStatus.UNHEALTHY; +}; diff --git a/x-pack/plugins/security_solution/server/endpoint/utils/index.ts b/x-pack/plugins/security_solution/server/endpoint/utils/index.ts new file mode 100644 index 0000000000000..5cf23db57be12 --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/utils/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 * from './fleet_agent_status_to_endpoint_host_status'; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/details/helpers.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/details/helpers.ts index 1b6e927f33638..f4d942f733c1d 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/details/helpers.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/details/helpers.ts @@ -24,6 +24,8 @@ import { import { toObjectArrayOfStrings } from '../../../../../../common/utils/to_array'; import { getHostMetaData } from '../../../../../endpoint/routes/metadata/handlers'; import { EndpointAppContext } from '../../../../../endpoint/types'; +import { fleetAgentStatusToEndpointHostStatus } from '../../../../../endpoint/utils'; +import { getPendingActionCounts } from '../../../../../endpoint/services'; export const HOST_FIELDS = [ '_id', @@ -200,15 +202,30 @@ export const getHostEndpoint = async ( ? await getHostMetaData(metadataRequestContext, id, undefined) : null; + const fleetAgentId = endpointData?.metadata.elastic.agent.id; + const [fleetAgentStatus, pendingActions] = !fleetAgentId + ? [undefined, {}] + : await Promise.all([ + // Get Agent Status + agentService.getAgentStatusById(esClient.asCurrentUser, fleetAgentId), + // Get a list of pending actions (if any) + getPendingActionCounts(esClient.asCurrentUser, [fleetAgentId]).then((results) => { + return results[0].pending_actions; + }), + ]); + return endpointData != null && endpointData.metadata ? { endpointPolicy: endpointData.metadata.Endpoint.policy.applied.name, policyStatus: endpointData.metadata.Endpoint.policy.applied.status, sensorVersion: endpointData.metadata.agent.version, + elasticAgentStatus: fleetAgentStatusToEndpointHostStatus(fleetAgentStatus!), + isolation: endpointData.metadata.Endpoint.state?.isolation ?? false, + pendingActions, } : null; } catch (err) { - logger.warn(JSON.stringify(err, null, 2)); + logger.warn(err); return null; } }; From 58fab48500eed861d7ac963ea9e772ca3b183b42 Mon Sep 17 00:00:00 2001 From: Frank Hassanabad <frank.hassanabad@elastic.co> Date: Wed, 30 Jun 2021 17:34:13 -0600 Subject: [PATCH 17/51] Fixes the unHandledPromise rejections happening from unit tests (#104017) ## Summary We had `unHandledPromise` rejections within some of our unit tests which still pass on CI but technically those tests are not running correctly and will not catch bugs. We were seeing them showing up like so: ```ts PASS x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.test.ts (10.502 s) (node:21059) UnhandledPromiseRejectionWarning: [object Object] at emitUnhandledRejectionWarning (internal/process/promises.js:170:15) at processPromiseRejections (internal/process/promises.js:247:11) at processTicksAndRejections (internal/process/task_queues.js:96:32) (node:21059) UnhandledPromiseRejectionWarning: Unhandled promise rejection. This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). To terminate the node process on unhandled promise rejection, use the CLI flag `--unhandled-rejections=strict` (see https://nodejs.org/api/cli.html#cli_unhandled_rejections_mode). (rejection id: 3) (node:21059) [DEP0018] DeprecationWarning: Unhandled promise rejections are deprecated. In the future, promise rejections that are not handled will terminate the Node.js process with a non-zero exit code. at emitDeprecationWarning (internal/process/promises.js:180:11) at processPromiseRejections (internal/process/promises.js:249:13) at processTicksAndRejections (internal/process/task_queues.js:96:32) PASS x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts PASS x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.test.ts PASS x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_route.test.ts (node:21059) UnhandledPromiseRejectionWarning: Error: bulk failed at emitUnhandledRejectionWarning (internal/process/promises.js:170:15) at processPromiseRejections (internal/process/promises.js:247:11) at processTicksAndRejections (internal/process/task_queues.js:96:32) (node:21059) UnhandledPromiseRejectionWarning: Unhandled promise rejection. This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). To terminate the node process on unhandled promise rejection, use the CLI flag `--unhandled-rejections=strict` (see https://nodejs.org/api/cli.html#cli_unhandled_rejections_mode). (rejection id: 7) ```` You can narrow down `unHandledPromise` rejections and fix tests one by one by running the following command: ```ts node --trace-warnings --unhandled-rejections=strict scripts/jest.js --runInBand x-pack/plugins/security_solution ``` You can manually test if I fixed them by running that command and ensuring all tests run without errors and that the process exits with a 0 for detections only by running: ```ts node --trace-warnings --unhandled-rejections=strict scripts/jest.js --runInBand x-pack/plugins/security_solution/public/detections ``` and ```ts node --trace-warnings --unhandled-rejections=strict scripts/jest.js --runInBand x-pack/plugins/security_solution/server/lib/detection_engine ``` ### Checklist - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or --- .../components/rules/query_bar/index.test.tsx | 33 ++++++++++++++++--- .../rule_actions_overflow/index.test.tsx | 15 ++++++--- .../signals/search_after_bulk_create.test.ts | 14 ++++++-- .../signals/signal_rule_alert_type.test.ts | 12 +++++-- 4 files changed, 60 insertions(+), 14 deletions(-) diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/query_bar/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/query_bar/index.test.tsx index 8c6f74a01e49a..12923609db266 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/query_bar/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/query_bar/index.test.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { mount, shallow } from 'enzyme'; +import { mount } from 'enzyme'; import { QueryBarDefineRule } from './index'; import { @@ -17,7 +17,26 @@ import { import { useGetAllTimeline, getAllTimeline } from '../../../../timelines/containers/all'; import { mockHistory, Router } from '../../../../common/mock/router'; -jest.mock('../../../../common/lib/kibana'); +jest.mock('../../../../common/lib/kibana', () => { + const actual = jest.requireActual('../../../../common/lib/kibana'); + return { + ...actual, + KibanaServices: { + get: jest.fn(() => ({ + http: { + post: jest.fn().mockReturnValue({ + success: true, + success_count: 0, + timelines_installed: 0, + timelines_updated: 0, + errors: [], + }), + fetch: jest.fn(), + }, + })), + }, + }; +}); jest.mock('../../../../timelines/containers/all', () => { const originalModule = jest.requireActual('../../../../timelines/containers/all'); @@ -55,8 +74,14 @@ describe('QueryBarDefineRule', () => { /> ); }; - const wrapper = shallow(<Component />); - expect(wrapper.dive().find('[data-test-subj="query-bar-define-rule"]')).toHaveLength(1); + const wrapper = mount( + <TestProviders> + <Router history={mockHistory}> + <Component /> + </Router> + </TestProviders> + ); + expect(wrapper.find('[data-test-subj="query-bar-define-rule"]').exists()).toBeTruthy(); }); it('renders import query from saved timeline modal actions hidden correctly', () => { diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.test.tsx index c545de7fd8d7d..6a62b05c2e319 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.test.tsx @@ -36,11 +36,16 @@ jest.mock('react-router-dom', () => ({ }), })); -jest.mock('../../../pages/detection_engine/rules/all/actions', () => ({ - deleteRulesAction: jest.fn(), - duplicateRulesAction: jest.fn(), - editRuleAction: jest.fn(), -})); +jest.mock('../../../pages/detection_engine/rules/all/actions', () => { + const actual = jest.requireActual('../../../../common/lib/kibana'); + return { + ...actual, + exportRulesAction: jest.fn(), + deleteRulesAction: jest.fn(), + duplicateRulesAction: jest.fn(), + editRuleAction: jest.fn(), + }; +}); const duplicateRulesActionMock = duplicateRulesAction as jest.Mock; const flushPromises = () => new Promise(setImmediate); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts index dc03f1bc964f2..711db931e9072 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts @@ -31,6 +31,7 @@ import { getQueryRuleParams } from '../schemas/rule_schemas.mock'; import { bulkCreateFactory } from './bulk_create_factory'; import { wrapHitsFactory } from './wrap_hits_factory'; import { mockBuildRuleMessage } from './__mocks__/build_rule_message.mock'; +import { ResponseError } from '@elastic/elasticsearch/lib/errors'; const buildRuleMessage = mockBuildRuleMessage; @@ -739,9 +740,16 @@ describe('searchAfterAndBulkCreate', () => { repeatedSearchResultsWithSortId(4, 1, someGuids.slice(0, 3)) ) ); - mockService.scopedClusterClient.asCurrentUser.bulk.mockRejectedValue( - elasticsearchClientMock.createErrorTransportRequestPromise(new Error('bulk failed')) - ); // Added this recently + mockService.scopedClusterClient.asCurrentUser.bulk.mockReturnValue( + elasticsearchClientMock.createErrorTransportRequestPromise( + new ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 400, + body: { error: { type: 'bulk_error_type' } }, + }) + ) + ) + ); const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ listClient, exceptionsList: [exceptionItem], diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts index 39aebb4aa4555..aec8b6c552b1d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts @@ -32,6 +32,7 @@ import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mo import { queryExecutor } from './executors/query'; import { mlExecutor } from './executors/ml'; import { getMlRuleParams, getQueryRuleParams } from '../schemas/rule_schemas.mock'; +import { ResponseError } from '@elastic/elasticsearch/lib/errors'; jest.mock('./rule_status_saved_objects_client'); jest.mock('./rule_status_service'); @@ -455,8 +456,15 @@ describe('signal_rule_alert_type', () => { }); it('and call ruleStatusService with the default message', async () => { - (queryExecutor as jest.Mock).mockRejectedValue( - elasticsearchClientMock.createErrorTransportRequestPromise({}) + (queryExecutor as jest.Mock).mockReturnValue( + elasticsearchClientMock.createErrorTransportRequestPromise( + new ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 400, + body: { error: { type: 'some_error_type' } }, + }) + ) + ) ); await alert.executor(payload); expect(logger.error).toHaveBeenCalled(); From 3cbce69598012782bcfe9202e0715b395736e6fa Mon Sep 17 00:00:00 2001 From: John Dorlus <silne.dorlus@elastic.co> Date: Wed, 30 Jun 2021 19:52:15 -0400 Subject: [PATCH 18/51] Add CIT for Date Index Processor in Ingest Node Pipelines (#103416) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Added initial work for date index processor CITs. * Fixed the tests and added the remaining coverage. * Fixed message for date rounding error and updated tests to use GMT since that timezone actually works with the API. * Update Date Index Name processor test name. Co-authored-by: Yulia Čech <6585477+yuliacech@users.noreply.github.com> Co-authored-by: Yulia Čech <6585477+yuliacech@users.noreply.github.com> --- .../__jest__/processors/date_index.test.tsx | 124 ++++++++++++++++++ .../__jest__/processors/processor.helpers.tsx | 6 + .../processors/date_index_name.tsx | 20 ++- 3 files changed, 147 insertions(+), 3 deletions(-) create mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/date_index.test.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/date_index.test.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/date_index.test.tsx new file mode 100644 index 0000000000000..264db2c5b65c0 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/date_index.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; 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'; + +const DATE_INDEX_TYPE = 'date_index_name'; + +describe('Processor: Date Index Name', () => { + 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(); + const { + actions: { addProcessor, addProcessorType }, + } = testBed; + // Open the processor flyout + addProcessor(); + + // Add type (the other fields are not visible until a type is selected) + await addProcessorType(DATE_INDEX_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" and "date rounding" are required parameters + expect(form.getErrorsMessages()).toEqual([ + 'A field value is required.', + 'A date rounding value is required.', + ]); + }); + + test('saves with required field and date rounding parameter values', async () => { + const { + actions: { saveNewProcessor }, + form, + } = testBed; + + // Add "field" value (required) + form.setInputValue('fieldNameField.input', '@timestamp'); + + // Select second value for date rounding + form.setSelectValue('dateRoundingField', 's'); + + // Save the field + await saveNewProcessor(); + + const processors = await getProcessorValue(onUpdate, DATE_INDEX_TYPE); + expect(processors[0].date_index_name).toEqual({ + field: '@timestamp', + date_rounding: 's', + }); + }); + + test('allows optional parameters to be set', async () => { + const { + actions: { saveNewProcessor }, + form, + find, + component, + } = testBed; + + form.setInputValue('fieldNameField.input', 'field_1'); + + form.setSelectValue('dateRoundingField', 'd'); + + form.setInputValue('indexNamePrefixField.input', 'prefix'); + + form.setInputValue('indexNameFormatField.input', 'yyyy-MM'); + + await act(async () => { + find('dateFormatsField.input').simulate('change', [{ label: 'ISO8601' }]); + }); + component.update(); + + form.setInputValue('timezoneField.input', 'GMT'); + + form.setInputValue('localeField.input', 'SPANISH'); + // Save the field with new changes + await saveNewProcessor(); + + const processors = await getProcessorValue(onUpdate, DATE_INDEX_TYPE); + expect(processors[0].date_index_name).toEqual({ + field: 'field_1', + date_rounding: 'd', + index_name_format: 'yyyy-MM', + index_name_prefix: 'prefix', + date_formats: ['ISO8601'], + locale: 'SPANISH', + timezone: 'GMT', + }); + }); +}); 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 78bc261aed7df..d50189167a2ff 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 @@ -147,8 +147,14 @@ type TestSubject = | 'mockCodeEditor' | 'tagField.input' | 'typeSelectorField' + | 'dateRoundingField' | 'ignoreMissingSwitch.input' | 'ignoreFailureSwitch.input' + | 'indexNamePrefixField.input' + | 'indexNameFormatField.input' + | 'dateFormatsField.input' + | 'timezoneField.input' + | 'localeField.input' | 'ifField.textarea' | 'targetField.input' | 'targetFieldsField.input' diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/date_index_name.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/date_index_name.tsx index 5c5b5ff89fd20..d4fb74c73ff0c 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/date_index_name.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/date_index_name.tsx @@ -47,7 +47,7 @@ const fieldsConfig: FieldsConfig = { i18n.translate( 'xpack.ingestPipelines.pipelineEditor.dateIndexNameForm.dateRoundingRequiredError', { - defaultMessage: 'A field value is required.', + defaultMessage: 'A date rounding value is required.', } ) ), @@ -160,6 +160,7 @@ export const DateIndexName: FunctionComponent = () => { component={SelectField} componentProps={{ euiFieldProps: { + 'data-test-subj': 'dateRoundingField', options: [ { value: 'y', @@ -217,26 +218,39 @@ export const DateIndexName: FunctionComponent = () => { /> <UseField + data-test-subj="indexNamePrefixField" config={fieldsConfig.index_name_prefix} component={Field} path="fields.index_name_prefix" /> <UseField + data-test-subj="indexNameFormatField" config={fieldsConfig.index_name_format} component={Field} path="fields.index_name_format" /> <UseField + data-test-subj="dateFormatsField" config={fieldsConfig.date_formats} component={ComboBoxField} path="fields.date_formats" /> - <UseField config={fieldsConfig.timezone} component={Field} path="fields.timezone" /> + <UseField + data-test-subj="timezoneField" + config={fieldsConfig.timezone} + component={Field} + path="fields.timezone" + /> - <UseField config={fieldsConfig.locale} component={Field} path="fields.locale" /> + <UseField + data-test-subj="localeField" + config={fieldsConfig.locale} + component={Field} + path="fields.locale" + /> </> ); }; From 81b9e73fed9daabb070809ea4bc2bce5d4de57a7 Mon Sep 17 00:00:00 2001 From: Frank Hassanabad <frank.hassanabad@elastic.co> Date: Wed, 30 Jun 2021 17:59:19 -0600 Subject: [PATCH 19/51] Updates the the PR template to remove links to private repo and fix docker URL with regards to kibana.yml keys (#103901) ## Summary Updates the Pull Request template to have: * Removes links to private repo's * Fixes the docker link to point to the current version within master Before this, our PR template had this checkbox: - [ ] If a plugin configuration key changed, check if it needs to be allowlisted in the [cloud](https://github.com/elastic/cloud) and added to the [docker list](https://github.com/elastic/kibana/blob/c29adfef29e921cc447d2a5ed06ac2047ceab552/src/dev/build/tasks/os_packages/docker_generator/resources/bin/kibana-docker) After this, our PR template becomes: - [ ] If a plugin configuration key changed, check if it needs to be allowlisted in the cloud and added to the [docker list](https://github.com/elastic/kibana/blob/master/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker) --- .github/PULL_REQUEST_TEMPLATE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 726e4257a5aac..1ea9e5a5a75bc 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -12,7 +12,7 @@ Delete any items that are not applicable to this PR. - [ ] [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 - [ ] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) - [ ] Any UI touched in this PR does not create any new axe failures (run axe in browser: [FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/), [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US)) -- [ ] If a plugin configuration key changed, check if it needs to be allowlisted in the [cloud](https://github.com/elastic/cloud) and added to the [docker list](https://github.com/elastic/kibana/blob/c29adfef29e921cc447d2a5ed06ac2047ceab552/src/dev/build/tasks/os_packages/docker_generator/resources/bin/kibana-docker) +- [ ] If a plugin configuration key changed, check if it needs to be allowlisted in the cloud and added to the [docker list](https://github.com/elastic/kibana/blob/master/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker) - [ ] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)) - [ ] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) From fcd16dd87b97c6a1e9b555187d2f2825bd678e79 Mon Sep 17 00:00:00 2001 From: Clint Andrew Hall <clint.hall@elastic.co> Date: Wed, 30 Jun 2021 20:28:21 -0400 Subject: [PATCH 20/51] [canvas] Replace react-beautiful-dnd with EuiDrapDrop (#102688) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../components/dom_preview/dom_preview.tsx | 29 ++++- .../page_manager/page_manager.component.tsx | 112 ++++++++---------- .../toolbar/__stories__/toolbar.stories.tsx | 42 +++---- .../storybook/decorators/redux_decorator.tsx | 9 +- 4 files changed, 104 insertions(+), 88 deletions(-) diff --git a/x-pack/plugins/canvas/public/components/dom_preview/dom_preview.tsx b/x-pack/plugins/canvas/public/components/dom_preview/dom_preview.tsx index 636b40c040e1f..5d9998b16a330 100644 --- a/x-pack/plugins/canvas/public/components/dom_preview/dom_preview.tsx +++ b/x-pack/plugins/canvas/public/components/dom_preview/dom_preview.tsx @@ -9,15 +9,22 @@ import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import { debounce } from 'lodash'; -interface Props { +interface HeightProps { elementId: string; height: number; + width?: never; +} +interface WidthProps { + elementId: string; + width: number; + height?: never; } -export class DomPreview extends PureComponent<Props> { +export class DomPreview extends PureComponent<HeightProps | WidthProps> { static propTypes = { elementId: PropTypes.string.isRequired, - height: PropTypes.number.isRequired, + height: PropTypes.number, + width: PropTypes.number, }; _container: HTMLDivElement | null = null; @@ -78,9 +85,19 @@ export class DomPreview extends PureComponent<Props> { const originalWidth = parseInt(originalStyle.getPropertyValue('width'), 10); const originalHeight = parseInt(originalStyle.getPropertyValue('height'), 10); - const thumbHeight = this.props.height; - const scale = thumbHeight / originalHeight; - const thumbWidth = originalWidth * scale; + let thumbHeight = 0; + let thumbWidth = 0; + let scale = 1; + + if (this.props.height) { + thumbHeight = this.props.height; + scale = thumbHeight / originalHeight; + thumbWidth = originalWidth * scale; + } else if (this.props.width) { + thumbWidth = this.props.width; + scale = thumbWidth / originalWidth; + thumbHeight = originalHeight * scale; + } if (this._content.firstChild) { this._content.removeChild(this._content.firstChild); diff --git a/x-pack/plugins/canvas/public/components/page_manager/page_manager.component.tsx b/x-pack/plugins/canvas/public/components/page_manager/page_manager.component.tsx index 9d1939db43fd5..c4d1e6fb91a69 100644 --- a/x-pack/plugins/canvas/public/components/page_manager/page_manager.component.tsx +++ b/x-pack/plugins/canvas/public/components/page_manager/page_manager.component.tsx @@ -7,9 +7,18 @@ import React, { Fragment, Component } from 'react'; import PropTypes from 'prop-types'; -import { EuiIcon, EuiFlexGroup, EuiFlexItem, EuiText, EuiToolTip } from '@elastic/eui'; +import { + EuiIcon, + EuiFlexGroup, + EuiFlexItem, + EuiText, + EuiToolTip, + EuiDragDropContext, + EuiDraggable, + EuiDroppable, + DragDropContextProps, +} from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { DragDropContext, Droppable, Draggable, DragDropContextProps } from 'react-beautiful-dnd'; // @ts-expect-error untyped dependency import Style from 'style-it'; @@ -173,46 +182,37 @@ export class PageManager extends Component<Props, State> { const pageNumber = i + 1; return ( - <Draggable key={page.id} draggableId={page.id} index={i} isDragDisabled={!isWriteable}> - {(provided) => ( - <div - key={page.id} - className={`canvasPageManager__page ${ - page.id === selectedPage ? 'canvasPageManager__page-isActive' : '' - }`} - ref={(el) => { - if (page.id === selectedPage) { - this._activePageRef = el; - } - provided.innerRef(el); - }} - {...provided.draggableProps} - {...provided.dragHandleProps} - > - <EuiFlexGroup gutterSize="s"> - <EuiFlexItem grow={false}> - <EuiText size="xs" className="canvasPageManager__pageNumber"> - {pageNumber} - </EuiText> - </EuiFlexItem> - <EuiFlexItem grow={false}> - <WorkpadRoutingContext.Consumer> - {({ getUrl }) => ( - <RoutingLink to={getUrl(pageNumber)}> - {Style.it( - workpadCSS, - <div> - <PagePreview height={100} page={page} onRemove={this.onConfirmRemove} /> - </div> - )} - </RoutingLink> + <EuiDraggable + key={page.id} + draggableId={page.id} + index={i} + isDragDisabled={!isWriteable} + className={`canvasPageManager__page ${ + page.id === selectedPage ? 'canvasPageManager__page-isActive' : '' + }`} + > + <EuiFlexGroup gutterSize="s"> + <EuiFlexItem grow={false}> + <EuiText size="xs" className="canvasPageManager__pageNumber"> + {pageNumber} + </EuiText> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <WorkpadRoutingContext.Consumer> + {({ getUrl }) => ( + <RoutingLink to={getUrl(pageNumber)}> + {Style.it( + workpadCSS, + <div> + <PagePreview height={100} page={page} onRemove={this.onConfirmRemove} /> + </div> )} - </WorkpadRoutingContext.Consumer> - </EuiFlexItem> - </EuiFlexGroup> - </div> - )} - </Draggable> + </RoutingLink> + )} + </WorkpadRoutingContext.Consumer> + </EuiFlexItem> + </EuiFlexGroup> + </EuiDraggable> ); }; @@ -224,25 +224,17 @@ export class PageManager extends Component<Props, State> { <Fragment> <EuiFlexGroup gutterSize="none" className="canvasPageManager"> <EuiFlexItem className="canvasPageManager__pages"> - <DragDropContext onDragEnd={this.onDragEnd}> - <Droppable droppableId="droppable-page-manager" direction="horizontal"> - {(provided) => ( - <div - className={`canvasPageManager__pageList ${ - showTrayPop ? 'canvasPageManager--trayPop' : '' - }`} - ref={(el) => { - this._pageListRef = el; - provided.innerRef(el); - }} - {...provided.droppableProps} - > - {pages.map(this.renderPage)} - {provided.placeholder} - </div> - )} - </Droppable> - </DragDropContext> + <EuiDragDropContext onDragEnd={this.onDragEnd}> + <EuiDroppable droppableId="droppable-page-manager" grow={true} direction="horizontal"> + <div + className={`canvasPageManager__pageList ${ + showTrayPop ? 'canvasPageManager--trayPop' : '' + }`} + > + {pages.map(this.renderPage)} + </div> + </EuiDroppable> + </EuiDragDropContext> </EuiFlexItem> {isWriteable && ( <EuiFlexItem grow={false}> diff --git a/x-pack/plugins/canvas/public/components/toolbar/__stories__/toolbar.stories.tsx b/x-pack/plugins/canvas/public/components/toolbar/__stories__/toolbar.stories.tsx index bd47bb52e0030..e571cc12f4425 100644 --- a/x-pack/plugins/canvas/public/components/toolbar/__stories__/toolbar.stories.tsx +++ b/x-pack/plugins/canvas/public/components/toolbar/__stories__/toolbar.stories.tsx @@ -7,26 +7,28 @@ import { storiesOf } from '@storybook/react'; import React from 'react'; -import { Toolbar } from '../toolbar.component'; -// @ts-expect-error untyped local -import { getDefaultElement } from '../../../state/defaults'; +// @ts-expect-error +import { getDefaultPage } from '../../../state/defaults'; +import { reduxDecorator } from '../../../../storybook'; +import { Toolbar } from '../toolbar'; + +const pages = [...new Array(10)].map(() => getDefaultPage()); + +const Pages = ({ story }: { story: Function }) => ( + <div> + {story()} + <div style={{ visibility: 'hidden', position: 'absolute' }}> + {pages.map((page, index) => ( + <div style={{ height: 66, width: 100, textAlign: 'center' }} id={page.id}> + <h1 style={{ paddingTop: 22 }}>Page {index}</h1> + </div> + ))} + </div> + </div> +); storiesOf('components/Toolbar', module) - .add('no element selected', () => ( - <Toolbar - isWriteable={true} - selectedPageNumber={1} - totalPages={1} - workpadName={'My Canvas Workpad'} - /> - )) - .add('element selected', () => ( - <Toolbar - isWriteable={true} - selectedElement={getDefaultElement()} - selectedPageNumber={1} - totalPages={1} - workpadName={'My Canvas Workpad'} - /> - )); + .addDecorator((story) => <Pages story={story} />) + .addDecorator(reduxDecorator({ pages })) + .add('redux', () => <Toolbar />); diff --git a/x-pack/plugins/canvas/storybook/decorators/redux_decorator.tsx b/x-pack/plugins/canvas/storybook/decorators/redux_decorator.tsx index 289171f136ab5..e81ae50ac6dd0 100644 --- a/x-pack/plugins/canvas/storybook/decorators/redux_decorator.tsx +++ b/x-pack/plugins/canvas/storybook/decorators/redux_decorator.tsx @@ -15,7 +15,7 @@ import { set } from '@elastic/safer-lodash-set'; // @ts-expect-error Untyped local import { getDefaultWorkpad } from '../../public/state/defaults'; -import { CanvasWorkpad, CanvasElement, CanvasAsset } from '../../types'; +import { CanvasWorkpad, CanvasElement, CanvasAsset, CanvasPage } from '../../types'; // @ts-expect-error untyped local import { elementsRegistry } from '../../public/lib/elements_registry'; @@ -27,18 +27,23 @@ export { ADDON_ID, ACTIONS_PANEL_ID } from '../addon/src/constants'; export interface Params { workpad?: CanvasWorkpad; + pages?: CanvasPage[]; elements?: CanvasElement[]; assets?: CanvasAsset[]; } export const reduxDecorator = (params: Params = {}) => { const state = cloneDeep(getInitialState()); - const { workpad, elements, assets } = params; + const { workpad, elements, assets, pages } = params; if (workpad) { set(state, 'persistent.workpad', workpad); } + if (pages) { + set(state, 'persistent.workpad.pages', pages); + } + if (elements) { set(state, 'persistent.workpad.pages.0.elements', elements); } From 90db5fd4a4ce59e1703e86ff54a71e31390427ec Mon Sep 17 00:00:00 2001 From: Tiago Costa <tiagoffcc@hotmail.com> Date: Thu, 1 Jul 2021 01:48:48 +0100 Subject: [PATCH 21/51] chore(NA): upgrades bazel rules nodejs into v3.6.0 (#103895) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- 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 acb62043a15ca..ebf7bbc8488ac 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 = "4a5d654a4ccd4a4c24eca5d319d85a88a650edf119601550c95bf400c8cc897e", - urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/3.5.1/rules_nodejs-3.5.1.tar.gz"], + sha256 = "0fa2d443571c9e02fcb7363a74ae591bdcce2dd76af8677a95965edf329d778a", + urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/3.6.0/rules_nodejs-3.6.0.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.1") +check_rules_nodejs_version(minimum_version_string = "3.6.0") # Setup the Node.js toolchain for the architectures we want to support # diff --git a/package.json b/package.json index b1d57d54838bc..1cc379fb807d0 100644 --- a/package.json +++ b/package.json @@ -447,7 +447,7 @@ "@babel/traverse": "^7.12.12", "@babel/types": "^7.12.12", "@bazel/ibazel": "^0.15.10", - "@bazel/typescript": "^3.5.1", + "@bazel/typescript": "^3.6.0", "@cypress/snapshot": "^2.1.7", "@cypress/webpack-preprocessor": "^5.6.0", "@elastic/eslint-config-kibana": "link:bazel-bin/packages/elastic-eslint-config-kibana", diff --git a/yarn.lock b/yarn.lock index b95056a78ea8b..8bce932ee9e4e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1197,10 +1197,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.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== +"@bazel/typescript@^3.6.0": + version "3.6.0" + resolved "https://registry.yarnpkg.com/@bazel/typescript/-/typescript-3.6.0.tgz#4dda2e39505cde4a190f51118fbb82ea0e80fde6" + integrity sha512-cO58iHmSxM4mRHJLLbb3FfoJJxv0pMiVGFLORoiUy/EhLtyYGZ1e7ntf4GxEovwK/E4h/awjSUlQkzPThcukTg== dependencies: protobufjs "6.8.8" semver "5.6.0" From 0cba746e7116daa6188e46175a044d40e6bd0842 Mon Sep 17 00:00:00 2001 From: Frank Hassanabad <frank.hassanabad@elastic.co> Date: Wed, 30 Jun 2021 19:43:50 -0600 Subject: [PATCH 22/51] Should make cypress less flake with two of our tests (#104033) ## Summary Should reduce flake in two of our Cypress tests. * Removed skip on a test recently skipped * Removes a wait() that doesn't seem to have been reducing flake added by a EUI team member * Adds a `.click()` to give focus to a component in order to improve our chances of typing in the input box * Adds some `.should('exists')` which will cause Cypress to ensure something exists and a better chance for click handlers to be added * Adds a pipe as suggested by @yctercero in the flake test ### Checklist - [x] [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 --- .../cypress/integration/timelines/row_renderers.spec.ts | 9 +++++++-- .../cypress/integration/urls/state.spec.ts | 1 - .../security_solution/cypress/tasks/date_picker.ts | 4 ++-- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/x-pack/plugins/security_solution/cypress/integration/timelines/row_renderers.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timelines/row_renderers.spec.ts index b3103963284b4..77a1775494e6a 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timelines/row_renderers.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timelines/row_renderers.spec.ts @@ -46,6 +46,7 @@ describe('Row renderers', () => { loginAndWaitForPage(HOSTS_URL); openTimelineUsingToggle(); populateTimeline(); + cy.get(TIMELINE_SHOW_ROW_RENDERERS_GEAR).should('exist'); cy.get(TIMELINE_SHOW_ROW_RENDERERS_GEAR).first().click({ force: true }); }); @@ -59,6 +60,7 @@ describe('Row renderers', () => { }); it('Selected renderer can be disabled and enabled', () => { + cy.get(TIMELINE_ROW_RENDERERS_SEARCHBOX).should('exist'); cy.get(TIMELINE_ROW_RENDERERS_SEARCHBOX).type('flow'); cy.get(TIMELINE_ROW_RENDERERS_MODAL_ITEMS_CHECKBOX).first().uncheck(); @@ -75,8 +77,11 @@ describe('Row renderers', () => { }); }); - it.skip('Selected renderer can be disabled with one click', () => { - cy.get(TIMELINE_ROW_RENDERERS_DISABLE_ALL_BTN).click({ force: true }); + it('Selected renderer can be disabled with one click', () => { + cy.get(TIMELINE_ROW_RENDERERS_DISABLE_ALL_BTN).should('exist'); + cy.get(TIMELINE_ROW_RENDERERS_DISABLE_ALL_BTN) + .pipe(($el) => $el.trigger('click')) + .should('not.be.visible'); cy.intercept('PATCH', '/api/timeline').as('updateTimeline'); cy.wait('@updateTimeline').its('response.statusCode').should('eq', 200); diff --git a/x-pack/plugins/security_solution/cypress/integration/urls/state.spec.ts b/x-pack/plugins/security_solution/cypress/integration/urls/state.spec.ts index f2b644e8d054c..842dd85b42ef8 100644 --- a/x-pack/plugins/security_solution/cypress/integration/urls/state.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/urls/state.spec.ts @@ -74,7 +74,6 @@ describe('url state', () => { waitForIpsTableToBeLoaded(); setEndDate(ABSOLUTE_DATE.newEndTimeTyped); updateDates(); - cy.wait(300); let startDate: string; let endDate: string; diff --git a/x-pack/plugins/security_solution/cypress/tasks/date_picker.ts b/x-pack/plugins/security_solution/cypress/tasks/date_picker.ts index 5fef4f2f5569b..26512a2fcbc5b 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/date_picker.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/date_picker.ts @@ -21,7 +21,7 @@ export const setEndDate = (date: string) => { cy.get(DATE_PICKER_ABSOLUTE_TAB).first().click({ force: true }); - cy.get(DATE_PICKER_ABSOLUTE_INPUT).clear().type(date); + cy.get(DATE_PICKER_ABSOLUTE_INPUT).click().clear().type(date); }; export const setStartDate = (date: string) => { @@ -29,7 +29,7 @@ export const setStartDate = (date: string) => { cy.get(DATE_PICKER_ABSOLUTE_TAB).first().click({ force: true }); - cy.get(DATE_PICKER_ABSOLUTE_INPUT).clear().type(date); + cy.get(DATE_PICKER_ABSOLUTE_INPUT).click().clear().type(date); }; export const setTimelineEndDate = (date: string) => { From a06c0f1409c138d6c096d37ae85406333a98653c Mon Sep 17 00:00:00 2001 From: spalger <spalger@users.noreply.github.com> Date: Wed, 30 Jun 2021 21:39:10 -0700 Subject: [PATCH 23/51] skip flaky suite (#104042) --- x-pack/test/functional/apps/ml/permissions/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/functional/apps/ml/permissions/index.ts b/x-pack/test/functional/apps/ml/permissions/index.ts index e777f241eaf85..af9f8a5f240d1 100644 --- a/x-pack/test/functional/apps/ml/permissions/index.ts +++ b/x-pack/test/functional/apps/ml/permissions/index.ts @@ -8,7 +8,8 @@ import { FtrProviderContext } from '../../../ftr_provider_context'; export default function ({ loadTestFile }: FtrProviderContext) { - describe('permissions', function () { + // FLAKY: https://github.com/elastic/kibana/issues/104042 + describe.skip('permissions', function () { this.tags(['skipFirefox']); loadTestFile(require.resolve('./full_ml_access')); From de9b62ac4f5fde70db3a8154699e6d9851398e2c Mon Sep 17 00:00:00 2001 From: mgiota <giota85@gmail.com> Date: Thu, 1 Jul 2021 09:03:56 +0200 Subject: [PATCH 24/51] [Metrics UI]: add system.cpu.total.norm.pct to default metrics (#102428) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../metrics_explorer/hooks/use_metrics_explorer_options.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options.ts b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options.ts index c1e5be94acc03..8bf64edcf8970 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options.ts +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options.ts @@ -99,7 +99,7 @@ export const DEFAULT_CHART_OPTIONS: MetricsExplorerChartOptions = { export const DEFAULT_METRICS: MetricsExplorerOptionsMetric[] = [ { aggregation: 'avg', - field: 'system.cpu.user.pct', + field: 'system.cpu.total.norm.pct', color: Color.color0, }, { From a3c079bda645b62e0ac92e739c3af123a9ceb160 Mon Sep 17 00:00:00 2001 From: Tim Roes <tim.roes@elastic.co> Date: Thu, 1 Jul 2021 09:26:43 +0200 Subject: [PATCH 25/51] Make (empty) value subdued (#103833) * Make empty value subdued * Fix highlighting in values * Fix test failures * Add unit tests --- .../field_formats/converters/string.test.ts | 28 +++++++++++++++++++ .../common/field_formats/converters/string.ts | 17 +++++++++-- .../field_formats/converters/_index.scss | 1 + .../field_formats/converters/_string.scss | 3 ++ src/plugins/data/public/index.scss | 1 + 5 files changed, 47 insertions(+), 3 deletions(-) create mode 100644 src/plugins/data/public/field_formats/converters/_index.scss create mode 100644 src/plugins/data/public/field_formats/converters/_string.scss diff --git a/src/plugins/data/common/field_formats/converters/string.test.ts b/src/plugins/data/common/field_formats/converters/string.test.ts index ccb7a58285b20..d691712b674dd 100644 --- a/src/plugins/data/common/field_formats/converters/string.test.ts +++ b/src/plugins/data/common/field_formats/converters/string.test.ts @@ -8,6 +8,14 @@ import { StringFormat } from './string'; +/** + * Removes a wrapping span, that is created by the field formatter infrastructure + * and we're not caring about in these tests. + */ +function stripSpan(input: string): string { + return input.replace(/^\<span ng-non-bindable\>(.*)\<\/span\>$/, '$1'); +} + describe('String Format', () => { test('convert a string to lower case', () => { const string = new StringFormat( @@ -17,6 +25,7 @@ describe('String Format', () => { jest.fn() ); expect(string.convert('Kibana')).toBe('kibana'); + expect(stripSpan(string.convert('Kibana', 'html'))).toBe('kibana'); }); test('convert a string to upper case', () => { @@ -27,6 +36,7 @@ describe('String Format', () => { jest.fn() ); expect(string.convert('Kibana')).toBe('KIBANA'); + expect(stripSpan(string.convert('Kibana', 'html'))).toBe('KIBANA'); }); test('decode a base64 string', () => { @@ -37,6 +47,7 @@ describe('String Format', () => { jest.fn() ); expect(string.convert('Zm9vYmFy')).toBe('foobar'); + expect(stripSpan(string.convert('Zm9vYmFy', 'html'))).toBe('foobar'); }); test('convert a string to title case', () => { @@ -47,10 +58,15 @@ describe('String Format', () => { jest.fn() ); expect(string.convert('PLEASE DO NOT SHOUT')).toBe('Please Do Not Shout'); + expect(stripSpan(string.convert('PLEASE DO NOT SHOUT', 'html'))).toBe('Please Do Not Shout'); expect(string.convert('Mean, variance and standard_deviation.')).toBe( 'Mean, Variance And Standard_deviation.' ); + expect(stripSpan(string.convert('Mean, variance and standard_deviation.', 'html'))).toBe( + 'Mean, Variance And Standard_deviation.' + ); expect(string.convert('Stay CALM!')).toBe('Stay Calm!'); + expect(stripSpan(string.convert('Stay CALM!', 'html'))).toBe('Stay Calm!'); }); test('convert a string to short case', () => { @@ -61,6 +77,7 @@ describe('String Format', () => { jest.fn() ); expect(string.convert('dot.notated.string')).toBe('d.n.string'); + expect(stripSpan(string.convert('dot.notated.string', 'html'))).toBe('d.n.string'); }); test('convert a string to unknown transform case', () => { @@ -82,5 +99,16 @@ describe('String Format', () => { jest.fn() ); expect(string.convert('%EC%95%88%EB%85%95%20%ED%82%A4%EB%B0%94%EB%82%98')).toBe('안녕 키바나'); + expect( + stripSpan(string.convert('%EC%95%88%EB%85%95%20%ED%82%A4%EB%B0%94%EB%82%98', 'html')) + ).toBe('안녕 키바나'); + }); + + test('outputs specific empty value', () => { + const string = new StringFormat(); + expect(string.convert('')).toBe('(empty)'); + expect(stripSpan(string.convert('', 'html'))).toBe( + '<span class="ffString__emptyValue">(empty)</span>' + ); }); }); diff --git a/src/plugins/data/common/field_formats/converters/string.ts b/src/plugins/data/common/field_formats/converters/string.ts index 64367df5d90dd..28dd714abaf41 100644 --- a/src/plugins/data/common/field_formats/converters/string.ts +++ b/src/plugins/data/common/field_formats/converters/string.ts @@ -6,14 +6,15 @@ * Side Public License, v 1. */ +import escape from 'lodash/escape'; import { i18n } from '@kbn/i18n'; -import { asPrettyString } from '../utils'; +import { asPrettyString, getHighlightHtml } from '../utils'; import { KBN_FIELD_TYPES } from '../../kbn_field_types/types'; import { FieldFormat } from '../field_format'; -import { TextContextTypeConvert, FIELD_FORMAT_IDS } from '../types'; +import { TextContextTypeConvert, FIELD_FORMAT_IDS, HtmlContextTypeConvert } from '../types'; import { shortenDottedString } from '../../utils'; -export const emptyLabel = i18n.translate('data.fieldFormats.string.emptyLabel', { +const emptyLabel = i18n.translate('data.fieldFormats.string.emptyLabel', { defaultMessage: '(empty)', }); @@ -127,4 +128,14 @@ export class StringFormat extends FieldFormat { return asPrettyString(val); } }; + + htmlConvert: HtmlContextTypeConvert = (val, { hit, field } = {}) => { + if (val === '') { + return `<span class="ffString__emptyValue">${emptyLabel}</span>`; + } + + return hit?.highlight?.[field?.name] + ? getHighlightHtml(val, hit.highlight[field.name]) + : escape(this.textConvert(val)); + }; } diff --git a/src/plugins/data/public/field_formats/converters/_index.scss b/src/plugins/data/public/field_formats/converters/_index.scss new file mode 100644 index 0000000000000..cc13062a3ef8b --- /dev/null +++ b/src/plugins/data/public/field_formats/converters/_index.scss @@ -0,0 +1 @@ +@import './string'; diff --git a/src/plugins/data/public/field_formats/converters/_string.scss b/src/plugins/data/public/field_formats/converters/_string.scss new file mode 100644 index 0000000000000..9d97f0195780c --- /dev/null +++ b/src/plugins/data/public/field_formats/converters/_string.scss @@ -0,0 +1,3 @@ +.ffString__emptyValue { + color: $euiColorDarkShade; +} diff --git a/src/plugins/data/public/index.scss b/src/plugins/data/public/index.scss index 467efa98934ec..c0eebf3402771 100644 --- a/src/plugins/data/public/index.scss +++ b/src/plugins/data/public/index.scss @@ -1,2 +1,3 @@ @import './ui/index'; @import './utils/table_inspector_view/index'; +@import './field_formats/converters/index'; From 258d33c12029d8833d9d7d328124237e6959e863 Mon Sep 17 00:00:00 2001 From: Jonathan Buttner <56361221+jonathan-buttner@users.noreply.github.com> Date: Thu, 1 Jul 2021 04:21:46 -0400 Subject: [PATCH 26/51] [Cases] Adding migration tests for owner field added in 7.14 (#102577) * Adding migration tests for 7.13 to 7.14 * Adding test for connector mapping * Comments --- .../tests/common/cases/migrations.ts | 25 +- .../tests/common/comments/migrations.ts | 54 +- .../tests/common/configure/migrations.ts | 67 +- .../tests/common/connectors/migrations.ts | 39 + .../tests/common/migrations.ts | 2 + .../tests/common/user_actions/migrations.ts | 86 +- .../cases/migrations/7.13.2/data.json.gz | Bin 0 -> 1351 bytes .../cases/migrations/7.13.2/mappings.json | 2909 +++++++++++++++++ 8 files changed, 3117 insertions(+), 65 deletions(-) create mode 100644 x-pack/test/case_api_integration/security_and_spaces/tests/common/connectors/migrations.ts create mode 100644 x-pack/test/functional/es_archives/cases/migrations/7.13.2/data.json.gz create mode 100644 x-pack/test/functional/es_archives/cases/migrations/7.13.2/mappings.json diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/migrations.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/migrations.ts index 8d158cc1c4f70..941b71fb925db 100644 --- a/x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/migrations.ts +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/migrations.ts @@ -7,7 +7,11 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; -import { CASES_URL } from '../../../../../../plugins/cases/common/constants'; +import { + CASES_URL, + SECURITY_SOLUTION_OWNER, +} from '../../../../../../plugins/cases/common/constants'; +import { getCase } from '../../../../common/lib/utils'; // eslint-disable-next-line import/no-default-export export default function createGetTests({ getService }: FtrProviderContext) { @@ -107,5 +111,24 @@ export default function createGetTests({ getService }: FtrProviderContext) { }); }); }); + + describe('7.13.2', () => { + before(async () => { + await esArchiver.load('x-pack/test/functional/es_archives/cases/migrations/7.13.2'); + }); + + after(async () => { + await esArchiver.unload('x-pack/test/functional/es_archives/cases/migrations/7.13.2'); + }); + + it('adds the owner field', async () => { + const theCase = await getCase({ + supertest, + caseId: 'e49ad6e0-cf9d-11eb-a603-13e7747d215c', + }); + + expect(theCase.owner).to.be(SECURITY_SOLUTION_OWNER); + }); + }); }); } diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/common/comments/migrations.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/common/comments/migrations.ts index 357373e7805ee..67e30987fabac 100644 --- a/x-pack/test/case_api_integration/security_and_spaces/tests/common/comments/migrations.ts +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/common/comments/migrations.ts @@ -7,7 +7,11 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; -import { CASES_URL } from '../../../../../../plugins/cases/common/constants'; +import { + CASES_URL, + SECURITY_SOLUTION_OWNER, +} from '../../../../../../plugins/cases/common/constants'; +import { getComment } from '../../../../common/lib/utils'; // eslint-disable-next-line import/no-default-export export default function createGetTests({ getService }: FtrProviderContext) { @@ -15,23 +19,45 @@ export default function createGetTests({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); describe('migrations', () => { - before(async () => { - await esArchiver.load('x-pack/test/functional/es_archives/cases/migrations/7.10.0'); - }); + describe('7.11.0', () => { + before(async () => { + await esArchiver.load('x-pack/test/functional/es_archives/cases/migrations/7.10.0'); + }); + + after(async () => { + await esArchiver.unload('x-pack/test/functional/es_archives/cases/migrations/7.10.0'); + }); + + it('7.11.0 migrates cases comments', async () => { + const { body: comment } = await supertest + .get( + `${CASES_URL}/e1900ac0-017f-11eb-93f8-d161651bf509/comments/da677740-1ac7-11eb-b5a3-25ee88122510` + ) + .set('kbn-xsrf', 'true') + .send(); - after(async () => { - await esArchiver.unload('x-pack/test/functional/es_archives/cases/migrations/7.10.0'); + expect(comment.type).to.eql('user'); + }); }); - it('7.11.0 migrates cases comments', async () => { - const { body: comment } = await supertest - .get( - `${CASES_URL}/e1900ac0-017f-11eb-93f8-d161651bf509/comments/da677740-1ac7-11eb-b5a3-25ee88122510` - ) - .set('kbn-xsrf', 'true') - .send(); + describe('7.13.2', () => { + before(async () => { + await esArchiver.load('x-pack/test/functional/es_archives/cases/migrations/7.13.2'); + }); + + after(async () => { + await esArchiver.unload('x-pack/test/functional/es_archives/cases/migrations/7.13.2'); + }); + + it('adds the owner field', async () => { + const comment = await getComment({ + supertest, + caseId: 'e49ad6e0-cf9d-11eb-a603-13e7747d215c', + commentId: 'ee59cdd0-cf9d-11eb-a603-13e7747d215c', + }); - expect(comment.type).to.eql('user'); + expect(comment.owner).to.be(SECURITY_SOLUTION_OWNER); + }); }); }); } diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/common/configure/migrations.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/common/configure/migrations.ts index c6d892e3435f1..bf64500a88068 100644 --- a/x-pack/test/case_api_integration/security_and_spaces/tests/common/configure/migrations.ts +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/common/configure/migrations.ts @@ -7,7 +7,11 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; -import { CASE_CONFIGURE_URL } from '../../../../../../plugins/cases/common/constants'; +import { + CASE_CONFIGURE_URL, + SECURITY_SOLUTION_OWNER, +} from '../../../../../../plugins/cases/common/constants'; +import { getConfiguration } from '../../../../common/lib/utils'; // eslint-disable-next-line import/no-default-export export default function createGetTests({ getService }: FtrProviderContext) { @@ -15,29 +19,50 @@ export default function createGetTests({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); describe('migrations', () => { - before(async () => { - await esArchiver.load('x-pack/test/functional/es_archives/cases/migrations/7.10.0'); - }); + describe('7.10.0', () => { + before(async () => { + await esArchiver.load('x-pack/test/functional/es_archives/cases/migrations/7.10.0'); + }); + + after(async () => { + await esArchiver.unload('x-pack/test/functional/es_archives/cases/migrations/7.10.0'); + }); + + it('7.10.0 migrates configure cases connector', async () => { + const { body } = await supertest + .get(`${CASE_CONFIGURE_URL}`) + .set('kbn-xsrf', 'true') + .send() + .expect(200); - after(async () => { - await esArchiver.unload('x-pack/test/functional/es_archives/cases/migrations/7.10.0'); + expect(body.length).to.be(1); + expect(body[0]).key('connector'); + expect(body[0]).not.key('connector_id'); + expect(body[0].connector).to.eql({ + id: 'connector-1', + name: 'Connector 1', + type: '.none', + fields: null, + }); + }); }); - it('7.10.0 migrates configure cases connector', async () => { - const { body } = await supertest - .get(`${CASE_CONFIGURE_URL}`) - .set('kbn-xsrf', 'true') - .send() - .expect(200); - - expect(body.length).to.be(1); - expect(body[0]).key('connector'); - expect(body[0]).not.key('connector_id'); - expect(body[0].connector).to.eql({ - id: 'connector-1', - name: 'Connector 1', - type: '.none', - fields: null, + describe('7.13.2', () => { + before(async () => { + await esArchiver.load('x-pack/test/functional/es_archives/cases/migrations/7.13.2'); + }); + + after(async () => { + await esArchiver.unload('x-pack/test/functional/es_archives/cases/migrations/7.13.2'); + }); + + it('adds the owner field', async () => { + const configuration = await getConfiguration({ + supertest, + query: { owner: SECURITY_SOLUTION_OWNER }, + }); + + expect(configuration[0].owner).to.be(SECURITY_SOLUTION_OWNER); }); }); }); diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/common/connectors/migrations.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/common/connectors/migrations.ts new file mode 100644 index 0000000000000..863c565b4ab08 --- /dev/null +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/common/connectors/migrations.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. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; +import { SECURITY_SOLUTION_OWNER } from '../../../../../../plugins/cases/common/constants'; +import { getConnectorMappingsFromES } from '../../../../common/lib/utils'; + +// eslint-disable-next-line import/no-default-export +export default function createGetTests({ getService }: FtrProviderContext) { + const es = getService('es'); + const esArchiver = getService('esArchiver'); + + describe('migrations', () => { + describe('7.13.2', () => { + before(async () => { + await esArchiver.load('x-pack/test/functional/es_archives/cases/migrations/7.13.2'); + }); + + after(async () => { + await esArchiver.unload('x-pack/test/functional/es_archives/cases/migrations/7.13.2'); + }); + + it('adds the owner field', async () => { + // We don't get the owner field back from the mappings when we retrieve the configuration so the only way to + // check that the migration worked is by checking the saved object stored in Elasticsearch directly + const mappings = await getConnectorMappingsFromES({ es }); + expect(mappings.body.hits.hits.length).to.be(1); + expect(mappings.body.hits.hits[0]._source?.['cases-connector-mappings'].owner).to.eql( + SECURITY_SOLUTION_OWNER + ); + }); + }); + }); +} diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/common/migrations.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/common/migrations.ts index 17d93e76bbdda..810fecc127d08 100644 --- a/x-pack/test/case_api_integration/security_and_spaces/tests/common/migrations.ts +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/common/migrations.ts @@ -12,7 +12,9 @@ export default ({ loadTestFile }: FtrProviderContext): void => { describe('Common migrations', function () { // Migrations loadTestFile(require.resolve('./cases/migrations')); + loadTestFile(require.resolve('./comments/migrations')); loadTestFile(require.resolve('./configure/migrations')); loadTestFile(require.resolve('./user_actions/migrations')); + loadTestFile(require.resolve('./connectors/migrations')); }); }; diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/common/user_actions/migrations.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/common/user_actions/migrations.ts index 030441028c502..b4c2dca47bf5f 100644 --- a/x-pack/test/case_api_integration/security_and_spaces/tests/common/user_actions/migrations.ts +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/common/user_actions/migrations.ts @@ -7,7 +7,11 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; -import { CASES_URL } from '../../../../../../plugins/cases/common/constants'; +import { + CASES_URL, + SECURITY_SOLUTION_OWNER, +} from '../../../../../../plugins/cases/common/constants'; +import { getCaseUserActions } from '../../../../common/lib/utils'; // eslint-disable-next-line import/no-default-export export default function createGetTests({ getService }: FtrProviderContext) { @@ -15,38 +19,62 @@ export default function createGetTests({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); describe('migrations', () => { - before(async () => { - await esArchiver.load('x-pack/test/functional/es_archives/cases/migrations/7.10.0'); - }); + describe('7.10.0', () => { + before(async () => { + await esArchiver.load('x-pack/test/functional/es_archives/cases/migrations/7.10.0'); + }); + + after(async () => { + await esArchiver.unload('x-pack/test/functional/es_archives/cases/migrations/7.10.0'); + }); + + it('7.10.0 migrates user actions connector', async () => { + const { body } = await supertest + .get(`${CASES_URL}/e1900ac0-017f-11eb-93f8-d161651bf509/user_actions`) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + + const connectorUserAction = body[1]; + const oldValue = JSON.parse(connectorUserAction.old_value); + const newValue = JSON.parse(connectorUserAction.new_value); - after(async () => { - await esArchiver.unload('x-pack/test/functional/es_archives/cases/migrations/7.10.0'); + expect(connectorUserAction.action_field.length).eql(1); + expect(connectorUserAction.action_field[0]).eql('connector'); + expect(oldValue).to.eql({ + id: 'c1900ac0-017f-11eb-93f8-d161651bf509', + name: 'none', + type: '.none', + fields: null, + }); + expect(newValue).to.eql({ + id: 'b1900ac0-017f-11eb-93f8-d161651bf509', + name: 'none', + type: '.none', + fields: null, + }); + }); }); - it('7.10.0 migrates user actions connector', async () => { - const { body } = await supertest - .get(`${CASES_URL}/e1900ac0-017f-11eb-93f8-d161651bf509/user_actions`) - .set('kbn-xsrf', 'true') - .send() - .expect(200); - - const connectorUserAction = body[1]; - const oldValue = JSON.parse(connectorUserAction.old_value); - const newValue = JSON.parse(connectorUserAction.new_value); - - expect(connectorUserAction.action_field.length).eql(1); - expect(connectorUserAction.action_field[0]).eql('connector'); - expect(oldValue).to.eql({ - id: 'c1900ac0-017f-11eb-93f8-d161651bf509', - name: 'none', - type: '.none', - fields: null, + describe('7.13.2', () => { + before(async () => { + await esArchiver.load('x-pack/test/functional/es_archives/cases/migrations/7.13.2'); + }); + + after(async () => { + await esArchiver.unload('x-pack/test/functional/es_archives/cases/migrations/7.13.2'); }); - expect(newValue).to.eql({ - id: 'b1900ac0-017f-11eb-93f8-d161651bf509', - name: 'none', - type: '.none', - fields: null, + + it('adds the owner field', async () => { + const userActions = await getCaseUserActions({ + supertest, + caseID: 'e49ad6e0-cf9d-11eb-a603-13e7747d215c', + }); + + expect(userActions.length).to.not.be(0); + for (const action of userActions) { + expect(action.owner).to.be(SECURITY_SOLUTION_OWNER); + } }); }); }); diff --git a/x-pack/test/functional/es_archives/cases/migrations/7.13.2/data.json.gz b/x-pack/test/functional/es_archives/cases/migrations/7.13.2/data.json.gz new file mode 100644 index 0000000000000000000000000000000000000000..c86af3f7d2fbaec66f201962574ac6ce500f4688 GIT binary patch literal 1351 zcmV-N1-SYjiwFqRrpsUe17u-zVJ>QOZ*BnX8BLF)HuRof5pfO-kN_d4?zK{@9!5RP zW)wN_l3|xXWSg0dM)~ix4F;Q#3{0DDBJB~sPd~qJ-bdrHK@WQ{7GopMBV)o3UQt@` zlXv#PK9iV3BSe6h1p#cLh$eYP7T|-u@jcs&HeqZ!4y;Y&+f&n-LJ-V?*mb;;BWIGu z@PqTQz9(yxp_L;cBrkB(h#Pl_QlJc89&%>;g1neCHyP?{!kA3jNGn7+o@={~Y5S%V zJUa7*7c6F;<+-z`4ubVT6UG3rLrKaGvm#9=DqzD7RvFp}>wU{JL|;&5tqV#sz`sF? z2u&g@$_<T*-%An&j^n$|%=e8N<9MplCV`Z{!B>?HzdFPX-LpcM!P`ncqVc}0aG5QZ z#$tKa7)xVPqplAOl~GD%1!1kNz^rdCU>B}0u!RvBCO6`deMfngK}2)BN@Bqv_UE2G z-&}Mi!KtCFg+BtJ<KGjEN>M*3S2~rP9ow;e9YPbDR-}LZX@;Q=EpRGeFO=@d;^Cq6 zN6LgG+15go-Hascsu|FSHO}7%JV-W2)uZw>uAhP5d(?Vib`=bNBwLL5Gye^kRBjzu z&eU>iR>tlWM+t|{aN#^`%QjAAV%PwNECQl=l;-he#KSB^F$?h?;Vu2Ha%*OvJW3FS z*c8g?STf&~pvLh>El1W7^$XTG#Xjb8F2(uGeJZ6?MyUC&c*YxX$;Z*?ePAkF3^32| z?ALnXG$-<odKQu(k+s?&87S?9Ch`esB^2ES1m1VxT#bZ~0v@?#CT)y5$vC!cyM_in zPI__QBj%0tJZmLzhVjKojuUzq!ddxx)?BuH_dY4AJ0R)=)g1}eHxBA70TG6a{_R-) z+|}xsq`0L*76XZJvX^BWk$9u3TxVWc$6Jc4#o}`hp%;w{Pofl$(yv+VID;XIh+rj> z{~0V+OgPTqTB5V7D|pXtrXe|TgA0!ESy%Yj8PwoX9fJVnh9K=1Cx<Nhk^-h2LU94z z)D+P|RJ(i+nd(s#ZQBWsrP>{g8KApos$cWVn7@(6tO`?>#go@0E>NmMKd3<7+_I;^ zQy0ZgkI-v2Bo&$4y9YT__j{LmZs@?jMyWTWe79poiq(AZ!YGcfH1S9=t!bxfrkI%@ z6~kyLza*@@xBBQ`L^@pb7K@0Q16!?*n(JC1`OaL>*|2TVXaRcl&Ru!-VG(l=U3-1G zAz?fZt`f%j^1}1%(EXXqi!#Or>?}@uy*t}ksBNIezbfRq!|@WXpNJCO-Pg7F$+n`i z)VJ3o+=s-#-xPSU5NNgpWlvBZGPT&**Ja&LERAP&uG~$K@E%v+SCku-q3ez_OO9G+ zF*}qC88OyXQsm55Z9v#`R<3u%v{mh4_;)oW$;}L3%}C>woSIIIee+K2_-n^;V>=Gl zVu|d3$d~}rHQZCi>}YK6EMv-Mu2&wbkY&b_Uuw4CYW><${+kG%TvVq2eDHxXr;XF8 zRt?T|6ATNZFbJ<SS!J7KTVPnkp%;7p>}sP`CC4~ftg^#ZZ6v-;HP;8TJuM8|rQXT% z&~+?3bZ&m`yQN93@uFd=>-=~mIra@k*R|$>eXU?Do!s1^{hqggEGq43Gz+g6l`Vm~ z)s<5<yjt7Ls(#9w=C6R?SWnyjUD0UKF-VAc18+gtoD{nq!Ut`8Ir%T;pPPd;EmWk> zX~L`rS$tlN&Jv^Pa9Uv0Meg&bgROsg|9sk1Dr(h1cBan?*1Ljs`0Rktb$hKg{sHTz JR$P}M001q&oJ0Ts literal 0 HcmV?d00001 diff --git a/x-pack/test/functional/es_archives/cases/migrations/7.13.2/mappings.json b/x-pack/test/functional/es_archives/cases/migrations/7.13.2/mappings.json new file mode 100644 index 0000000000000..e79ebf2b8fc10 --- /dev/null +++ b/x-pack/test/functional/es_archives/cases/migrations/7.13.2/mappings.json @@ -0,0 +1,2909 @@ +{ + "type": "index", + "value": { + "aliases": { + ".kibana": { + }, + ".kibana_7.13.2": { + } + }, + "index": ".kibana_1", + "mappings": { + "_meta": { + "migrationMappingPropertyHashes": { + "action": "6e96ac5e648f57523879661ea72525b7", + "action_task_params": "a9d49f184ee89641044be0ca2950fa3a", + "alert": "d75d3b0e95fe394753d73d8f7952cd7d", + "api_key_pending_invalidation": "16f515278a295f6245149ad7c5ddedb7", + "apm-indices": "9bb9b2bf1fa636ed8619cbab5ce6a1dd", + "apm-telemetry": "3d1b76c39bfb2cc8296b024d73854724", + "app_search_telemetry": "3d1b76c39bfb2cc8296b024d73854724", + "application_usage_daily": "43b8830d5d0df85a6823d290885fc9fd", + "application_usage_totals": "3d1b76c39bfb2cc8296b024d73854724", + "application_usage_transactional": "3d1b76c39bfb2cc8296b024d73854724", + "canvas-element": "7390014e1091044523666d97247392fc", + "canvas-workpad": "b0a1706d356228dbdcb4a17e6b9eb231", + "canvas-workpad-template": "ae2673f678281e2c055d764b153e9715", + "cases": "7c28a18fbac7c2a4e79449e9802ef476", + "cases-comments": "112cefc2b6737e613a8ef033234755e6", + "cases-configure": "387c5f3a3bda7e0ae0dd4e106f914a69", + "cases-connector-mappings": "6bc7e49411d38be4969dc6aa8bd43776", + "cases-user-actions": "32277330ec6b721abe3b846cfd939a71", + "config": "c63748b75f39d0c54de12d12c1ccbc20", + "core-usage-stats": "3d1b76c39bfb2cc8296b024d73854724", + "coreMigrationVersion": "2f4316de49999235636386fe51dc06c1", + "dashboard": "40554caf09725935e2c02e02563a2d07", + "endpoint:user-artifact": "4a11183eee21e6fbad864f7a30b39ad0", + "endpoint:user-artifact-manifest": "a0d7b04ad405eed54d76e279c3727862", + "enterprise_search_telemetry": "3d1b76c39bfb2cc8296b024d73854724", + "epm-packages": "0cbbb16506734d341a96aaed65ec6413", + "epm-packages-assets": "44621b2f6052ef966da47b7c3a00f33b", + "exception-list": "baf108c9934dda844921f692a513adae", + "exception-list-agnostic": "baf108c9934dda844921f692a513adae", + "file-upload-usage-collection-telemetry": "a34fbb8e3263d105044869264860c697", + "fleet-agent-actions": "9511b565b1cc6441a42033db3d5de8e9", + "fleet-agents": "59fd74f819f028f8555776db198d2562", + "fleet-enrollment-api-keys": "a69ef7ae661dab31561d6c6f052ef2a7", + "fleet-preconfiguration-deletion-record": "4c36f199189a367e43541f236141204c", + "graph-workspace": "27a94b2edcb0610c6aea54a7c56d7752", + "index-pattern": "45915a1ad866812242df474eb0479052", + "infrastructure-ui-source": "3d1b76c39bfb2cc8296b024d73854724", + "ingest-agent-policies": "cb4dbcc5a695e53f40a359303cb6286f", + "ingest-outputs": "1acb789ca37cbee70259ca79e124d9ad", + "ingest-package-policies": "c91ca97b1ff700f0fc64dc6b13d65a85", + "ingest_manager_settings": "f159646d76ab261bfbf8ef504d9631e4", + "inventory-view": "3d1b76c39bfb2cc8296b024d73854724", + "kql-telemetry": "d12a98a6f19a2d273696597547e064ee", + "legacy-url-alias": "3d1b76c39bfb2cc8296b024d73854724", + "lens": "52346cfec69ff7b47d5f0c12361a2797", + "lens-ui-telemetry": "509bfa5978586998e05f9e303c07a327", + "map": "9134b47593116d7953f6adba096fc463", + "maps-telemetry": "5ef305b18111b77789afefbd36b66171", + "metrics-explorer-view": "3d1b76c39bfb2cc8296b024d73854724", + "migrationVersion": "4a1746014a75ade3a714e1db5763276f", + "ml-job": "3bb64c31915acf93fc724af137a0891b", + "ml-module": "46ef4f0d6682636f0fff9799d6a2d7ac", + "monitoring-telemetry": "2669d5ec15e82391cf58df4294ee9c68", + "namespace": "2f4316de49999235636386fe51dc06c1", + "namespaces": "2f4316de49999235636386fe51dc06c1", + "originId": "2f4316de49999235636386fe51dc06c1", + "query": "11aaeb7f5f7fa5bb43f25e18ce26e7d9", + "references": "7997cf5a56cc02bdc9c93361bde732b0", + "sample-data-telemetry": "7d3cfeb915303c9641c59681967ffeb4", + "search": "db2c00e39b36f40930a3b9fc71c823e1", + "search-session": "4e238afeeaa2550adef326e140454265", + "search-telemetry": "3d1b76c39bfb2cc8296b024d73854724", + "security-rule": "8ae39a88fc70af3375b7050e8d8d5cc7", + "security-solution-signals-migration": "72761fd374ca11122ac8025a92b84fca", + "siem-detection-engine-rule-actions": "6569b288c169539db10cb262bf79de18", + "siem-detection-engine-rule-status": "ae783f41c6937db6b7a2ef5c93a9e9b0", + "siem-ui-timeline": "3e97beae13cdfc6d62bc1846119f7276", + "siem-ui-timeline-note": "8874706eedc49059d4cf0f5094559084", + "siem-ui-timeline-pinned-event": "20638091112f0e14f0e443d512301c29", + "space": "c5ca8acafa0beaa4d08d014a97b6bc6b", + "spaces-usage-stats": "3d1b76c39bfb2cc8296b024d73854724", + "tag": "83d55da58f6530f7055415717ec06474", + "telemetry": "36a616f7026dfa617d6655df850fe16d", + "timelion-sheet": "9a2a2748877c7a7b582fef201ab1d4cf", + "type": "2f4316de49999235636386fe51dc06c1", + "ui-counter": "0d409297dc5ebe1e3a1da691c6ee32e3", + "ui-metric": "0d409297dc5ebe1e3a1da691c6ee32e3", + "updated_at": "00da57df13e94e9d98437d13ace4bfe0", + "upgrade-assistant-reindex-operation": "215107c281839ea9b3ad5f6419819763", + "upgrade-assistant-telemetry": "56702cec857e0a9dacfb696655b4ff7b", + "uptime-dynamic-settings": "3d1b76c39bfb2cc8296b024d73854724", + "url": "c7f66a0df8b1b52f17c28c4adb111105", + "usage-counters": "8cc260bdceffec4ffc3ad165c97dc1b4", + "visualization": "f819cf6636b75c9e76ba733a0c6ef355", + "workplace_search_telemetry": "3d1b76c39bfb2cc8296b024d73854724" + } + }, + "dynamic": "strict", + "properties": { + "action": { + "properties": { + "actionTypeId": { + "type": "keyword" + }, + "config": { + "enabled": false, + "type": "object" + }, + "name": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + }, + "secrets": { + "type": "binary" + } + } + }, + "action_task_params": { + "properties": { + "actionId": { + "type": "keyword" + }, + "apiKey": { + "type": "binary" + }, + "params": { + "enabled": false, + "type": "object" + } + } + }, + "alert": { + "properties": { + "actions": { + "properties": { + "actionRef": { + "type": "keyword" + }, + "actionTypeId": { + "type": "keyword" + }, + "group": { + "type": "keyword" + }, + "params": { + "enabled": false, + "type": "object" + } + }, + "type": "nested" + }, + "alertTypeId": { + "type": "keyword" + }, + "apiKey": { + "type": "binary" + }, + "apiKeyOwner": { + "type": "keyword" + }, + "consumer": { + "type": "keyword" + }, + "createdAt": { + "type": "date" + }, + "createdBy": { + "type": "keyword" + }, + "enabled": { + "type": "boolean" + }, + "executionStatus": { + "properties": { + "error": { + "properties": { + "message": { + "type": "keyword" + }, + "reason": { + "type": "keyword" + } + } + }, + "lastExecutionDate": { + "type": "date" + }, + "status": { + "type": "keyword" + } + } + }, + "meta": { + "properties": { + "versionApiKeyLastmodified": { + "type": "keyword" + } + } + }, + "muteAll": { + "type": "boolean" + }, + "mutedInstanceIds": { + "type": "keyword" + }, + "name": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + }, + "notifyWhen": { + "type": "keyword" + }, + "params": { + "ignore_above": 4096, + "type": "flattened" + }, + "schedule": { + "properties": { + "interval": { + "type": "keyword" + } + } + }, + "scheduledTaskId": { + "type": "keyword" + }, + "tags": { + "type": "keyword" + }, + "throttle": { + "type": "keyword" + }, + "updatedAt": { + "type": "date" + }, + "updatedBy": { + "type": "keyword" + } + } + }, + "api_key_pending_invalidation": { + "properties": { + "apiKeyId": { + "type": "keyword" + }, + "createdAt": { + "type": "date" + } + } + }, + "apm-indices": { + "properties": { + "apm_oss": { + "properties": { + "errorIndices": { + "type": "keyword" + }, + "metricsIndices": { + "type": "keyword" + }, + "onboardingIndices": { + "type": "keyword" + }, + "sourcemapIndices": { + "type": "keyword" + }, + "spanIndices": { + "type": "keyword" + }, + "transactionIndices": { + "type": "keyword" + } + } + } + } + }, + "apm-telemetry": { + "dynamic": "false", + "type": "object" + }, + "app_search_telemetry": { + "dynamic": "false", + "type": "object" + }, + "application_usage_daily": { + "dynamic": "false", + "properties": { + "timestamp": { + "type": "date" + } + } + }, + "application_usage_totals": { + "dynamic": "false", + "type": "object" + }, + "application_usage_transactional": { + "dynamic": "false", + "type": "object" + }, + "canvas-element": { + "dynamic": "false", + "properties": { + "@created": { + "type": "date" + }, + "@timestamp": { + "type": "date" + }, + "content": { + "type": "text" + }, + "help": { + "type": "text" + }, + "image": { + "type": "text" + }, + "name": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "canvas-workpad": { + "dynamic": "false", + "properties": { + "@created": { + "type": "date" + }, + "@timestamp": { + "type": "date" + }, + "name": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "canvas-workpad-template": { + "dynamic": "false", + "properties": { + "help": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + }, + "name": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + }, + "tags": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + }, + "template_key": { + "type": "keyword" + } + } + }, + "cases": { + "properties": { + "closed_at": { + "type": "date" + }, + "closed_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + }, + "connector": { + "properties": { + "fields": { + "properties": { + "key": { + "type": "text" + }, + "value": { + "type": "text" + } + } + }, + "id": { + "type": "keyword" + }, + "name": { + "type": "text" + }, + "type": { + "type": "keyword" + } + } + }, + "created_at": { + "type": "date" + }, + "created_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + }, + "description": { + "type": "text" + }, + "external_service": { + "properties": { + "connector_id": { + "type": "keyword" + }, + "connector_name": { + "type": "keyword" + }, + "external_id": { + "type": "keyword" + }, + "external_title": { + "type": "text" + }, + "external_url": { + "type": "text" + }, + "pushed_at": { + "type": "date" + }, + "pushed_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + } + } + }, + "settings": { + "properties": { + "syncAlerts": { + "type": "boolean" + } + } + }, + "status": { + "type": "keyword" + }, + "tags": { + "type": "keyword" + }, + "title": { + "type": "keyword" + }, + "type": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + }, + "updated_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + } + } + }, + "cases-comments": { + "properties": { + "alertId": { + "type": "keyword" + }, + "associationType": { + "type": "keyword" + }, + "comment": { + "type": "text" + }, + "created_at": { + "type": "date" + }, + "created_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + }, + "index": { + "type": "keyword" + }, + "pushed_at": { + "type": "date" + }, + "pushed_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + }, + "rule": { + "properties": { + "id": { + "type": "keyword" + }, + "name": { + "type": "keyword" + } + } + }, + "type": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + }, + "updated_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + } + } + }, + "cases-configure": { + "properties": { + "closure_type": { + "type": "keyword" + }, + "connector": { + "properties": { + "fields": { + "properties": { + "key": { + "type": "text" + }, + "value": { + "type": "text" + } + } + }, + "id": { + "type": "keyword" + }, + "name": { + "type": "text" + }, + "type": { + "type": "keyword" + } + } + }, + "created_at": { + "type": "date" + }, + "created_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + }, + "updated_at": { + "type": "date" + }, + "updated_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + } + } + }, + "cases-connector-mappings": { + "properties": { + "mappings": { + "properties": { + "action_type": { + "type": "keyword" + }, + "source": { + "type": "keyword" + }, + "target": { + "type": "keyword" + } + } + } + } + }, + "cases-user-actions": { + "properties": { + "action": { + "type": "keyword" + }, + "action_at": { + "type": "date" + }, + "action_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + }, + "action_field": { + "type": "keyword" + }, + "new_value": { + "type": "text" + }, + "old_value": { + "type": "text" + } + } + }, + "config": { + "dynamic": "false", + "properties": { + "buildNum": { + "type": "keyword" + } + } + }, + "core-usage-stats": { + "dynamic": "false", + "type": "object" + }, + "coreMigrationVersion": { + "type": "keyword" + }, + "dashboard": { + "properties": { + "description": { + "type": "text" + }, + "hits": { + "doc_values": false, + "index": false, + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "index": false, + "type": "text" + } + } + }, + "optionsJSON": { + "index": false, + "type": "text" + }, + "panelsJSON": { + "index": false, + "type": "text" + }, + "refreshInterval": { + "properties": { + "display": { + "doc_values": false, + "index": false, + "type": "keyword" + }, + "pause": { + "doc_values": false, + "index": false, + "type": "boolean" + }, + "section": { + "doc_values": false, + "index": false, + "type": "integer" + }, + "value": { + "doc_values": false, + "index": false, + "type": "integer" + } + } + }, + "timeFrom": { + "doc_values": false, + "index": false, + "type": "keyword" + }, + "timeRestore": { + "doc_values": false, + "index": false, + "type": "boolean" + }, + "timeTo": { + "doc_values": false, + "index": false, + "type": "keyword" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "endpoint:user-artifact": { + "properties": { + "body": { + "type": "binary" + }, + "compressionAlgorithm": { + "index": false, + "type": "keyword" + }, + "created": { + "index": false, + "type": "date" + }, + "decodedSha256": { + "index": false, + "type": "keyword" + }, + "decodedSize": { + "index": false, + "type": "long" + }, + "encodedSha256": { + "type": "keyword" + }, + "encodedSize": { + "index": false, + "type": "long" + }, + "encryptionAlgorithm": { + "index": false, + "type": "keyword" + }, + "identifier": { + "type": "keyword" + } + } + }, + "endpoint:user-artifact-manifest": { + "properties": { + "artifacts": { + "properties": { + "artifactId": { + "index": false, + "type": "keyword" + }, + "policyId": { + "index": false, + "type": "keyword" + } + }, + "type": "nested" + }, + "created": { + "index": false, + "type": "date" + }, + "schemaVersion": { + "type": "keyword" + }, + "semanticVersion": { + "index": false, + "type": "keyword" + } + } + }, + "enterprise_search_telemetry": { + "dynamic": "false", + "type": "object" + }, + "epm-packages": { + "properties": { + "es_index_patterns": { + "enabled": false, + "type": "object" + }, + "install_source": { + "type": "keyword" + }, + "install_started_at": { + "type": "date" + }, + "install_status": { + "type": "keyword" + }, + "install_version": { + "type": "keyword" + }, + "installed_es": { + "properties": { + "id": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + }, + "type": "nested" + }, + "installed_kibana": { + "properties": { + "id": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + }, + "type": "nested" + }, + "internal": { + "type": "boolean" + }, + "name": { + "type": "keyword" + }, + "package_assets": { + "properties": { + "id": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + }, + "type": "nested" + }, + "removable": { + "type": "boolean" + }, + "version": { + "type": "keyword" + } + } + }, + "epm-packages-assets": { + "properties": { + "asset_path": { + "type": "keyword" + }, + "data_base64": { + "type": "binary" + }, + "data_utf8": { + "index": false, + "type": "text" + }, + "install_source": { + "type": "keyword" + }, + "media_type": { + "type": "keyword" + }, + "package_name": { + "type": "keyword" + }, + "package_version": { + "type": "keyword" + } + } + }, + "exception-list": { + "properties": { + "_tags": { + "type": "keyword" + }, + "comments": { + "properties": { + "comment": { + "type": "keyword" + }, + "created_at": { + "type": "keyword" + }, + "created_by": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "updated_at": { + "type": "keyword" + }, + "updated_by": { + "type": "keyword" + } + } + }, + "created_at": { + "type": "keyword" + }, + "created_by": { + "type": "keyword" + }, + "description": { + "type": "keyword" + }, + "entries": { + "properties": { + "entries": { + "properties": { + "field": { + "type": "keyword" + }, + "operator": { + "type": "keyword" + }, + "type": { + "type": "keyword" + }, + "value": { + "fields": { + "text": { + "type": "text" + } + }, + "type": "keyword" + } + } + }, + "field": { + "type": "keyword" + }, + "list": { + "properties": { + "id": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + } + }, + "operator": { + "type": "keyword" + }, + "type": { + "type": "keyword" + }, + "value": { + "fields": { + "text": { + "type": "text" + } + }, + "type": "keyword" + } + } + }, + "immutable": { + "type": "boolean" + }, + "item_id": { + "type": "keyword" + }, + "list_id": { + "type": "keyword" + }, + "list_type": { + "type": "keyword" + }, + "meta": { + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "type": "text" + } + }, + "type": "keyword" + }, + "os_types": { + "type": "keyword" + }, + "tags": { + "fields": { + "text": { + "type": "text" + } + }, + "type": "keyword" + }, + "tie_breaker_id": { + "type": "keyword" + }, + "type": { + "type": "keyword" + }, + "updated_by": { + "type": "keyword" + }, + "version": { + "type": "keyword" + } + } + }, + "exception-list-agnostic": { + "properties": { + "_tags": { + "type": "keyword" + }, + "comments": { + "properties": { + "comment": { + "type": "keyword" + }, + "created_at": { + "type": "keyword" + }, + "created_by": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "updated_at": { + "type": "keyword" + }, + "updated_by": { + "type": "keyword" + } + } + }, + "created_at": { + "type": "keyword" + }, + "created_by": { + "type": "keyword" + }, + "description": { + "type": "keyword" + }, + "entries": { + "properties": { + "entries": { + "properties": { + "field": { + "type": "keyword" + }, + "operator": { + "type": "keyword" + }, + "type": { + "type": "keyword" + }, + "value": { + "fields": { + "text": { + "type": "text" + } + }, + "type": "keyword" + } + } + }, + "field": { + "type": "keyword" + }, + "list": { + "properties": { + "id": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + } + }, + "operator": { + "type": "keyword" + }, + "type": { + "type": "keyword" + }, + "value": { + "fields": { + "text": { + "type": "text" + } + }, + "type": "keyword" + } + } + }, + "immutable": { + "type": "boolean" + }, + "item_id": { + "type": "keyword" + }, + "list_id": { + "type": "keyword" + }, + "list_type": { + "type": "keyword" + }, + "meta": { + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "type": "text" + } + }, + "type": "keyword" + }, + "os_types": { + "type": "keyword" + }, + "tags": { + "fields": { + "text": { + "type": "text" + } + }, + "type": "keyword" + }, + "tie_breaker_id": { + "type": "keyword" + }, + "type": { + "type": "keyword" + }, + "updated_by": { + "type": "keyword" + }, + "version": { + "type": "keyword" + } + } + }, + "file-upload-usage-collection-telemetry": { + "properties": { + "file_upload": { + "properties": { + "index_creation_count": { + "type": "long" + } + } + } + } + }, + "fleet-agent-actions": { + "properties": { + "ack_data": { + "type": "text" + }, + "agent_id": { + "type": "keyword" + }, + "created_at": { + "type": "date" + }, + "data": { + "type": "binary" + }, + "policy_id": { + "type": "keyword" + }, + "policy_revision": { + "type": "integer" + }, + "sent_at": { + "type": "date" + }, + "type": { + "type": "keyword" + } + } + }, + "fleet-agents": { + "properties": { + "access_api_key_id": { + "type": "keyword" + }, + "active": { + "type": "boolean" + }, + "current_error_events": { + "index": false, + "type": "text" + }, + "default_api_key": { + "type": "binary" + }, + "default_api_key_id": { + "type": "keyword" + }, + "enrolled_at": { + "type": "date" + }, + "last_checkin": { + "type": "date" + }, + "last_checkin_status": { + "type": "keyword" + }, + "last_updated": { + "type": "date" + }, + "local_metadata": { + "type": "flattened" + }, + "packages": { + "type": "keyword" + }, + "policy_id": { + "type": "keyword" + }, + "policy_revision": { + "type": "integer" + }, + "type": { + "type": "keyword" + }, + "unenrolled_at": { + "type": "date" + }, + "unenrollment_started_at": { + "type": "date" + }, + "updated_at": { + "type": "date" + }, + "upgrade_started_at": { + "type": "date" + }, + "upgraded_at": { + "type": "date" + }, + "user_provided_metadata": { + "type": "flattened" + }, + "version": { + "type": "keyword" + } + } + }, + "fleet-enrollment-api-keys": { + "properties": { + "active": { + "type": "boolean" + }, + "api_key": { + "type": "binary" + }, + "api_key_id": { + "type": "keyword" + }, + "created_at": { + "type": "date" + }, + "expire_at": { + "type": "date" + }, + "name": { + "type": "keyword" + }, + "policy_id": { + "type": "keyword" + }, + "type": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + } + } + }, + "fleet-preconfiguration-deletion-record": { + "properties": { + "id": { + "type": "keyword" + } + } + }, + "graph-workspace": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "legacyIndexPatternRef": { + "index": false, + "type": "text" + }, + "numLinks": { + "type": "integer" + }, + "numVertices": { + "type": "integer" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + }, + "wsState": { + "type": "text" + } + } + }, + "index-pattern": { + "dynamic": "false", + "properties": { + "title": { + "type": "text" + }, + "type": { + "type": "keyword" + } + } + }, + "infrastructure-ui-source": { + "dynamic": "false", + "type": "object" + }, + "ingest-agent-policies": { + "properties": { + "description": { + "type": "text" + }, + "is_default": { + "type": "boolean" + }, + "is_default_fleet_server": { + "type": "boolean" + }, + "is_managed": { + "type": "boolean" + }, + "is_preconfigured": { + "type": "keyword" + }, + "monitoring_enabled": { + "index": false, + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "namespace": { + "type": "keyword" + }, + "package_policies": { + "type": "keyword" + }, + "revision": { + "type": "integer" + }, + "status": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + }, + "updated_by": { + "type": "keyword" + } + } + }, + "ingest-outputs": { + "properties": { + "ca_sha256": { + "index": false, + "type": "keyword" + }, + "config": { + "type": "flattened" + }, + "config_yaml": { + "type": "text" + }, + "hosts": { + "type": "keyword" + }, + "is_default": { + "type": "boolean" + }, + "name": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + } + }, + "ingest-package-policies": { + "properties": { + "created_at": { + "type": "date" + }, + "created_by": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "enabled": { + "type": "boolean" + }, + "inputs": { + "enabled": false, + "properties": { + "compiled_input": { + "type": "flattened" + }, + "config": { + "type": "flattened" + }, + "enabled": { + "type": "boolean" + }, + "streams": { + "properties": { + "compiled_stream": { + "type": "flattened" + }, + "config": { + "type": "flattened" + }, + "data_stream": { + "properties": { + "dataset": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + } + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "keyword" + }, + "vars": { + "type": "flattened" + } + }, + "type": "nested" + }, + "type": { + "type": "keyword" + }, + "vars": { + "type": "flattened" + } + }, + "type": "nested" + }, + "name": { + "type": "keyword" + }, + "namespace": { + "type": "keyword" + }, + "output_id": { + "type": "keyword" + }, + "package": { + "properties": { + "name": { + "type": "keyword" + }, + "title": { + "type": "keyword" + }, + "version": { + "type": "keyword" + } + } + }, + "policy_id": { + "type": "keyword" + }, + "revision": { + "type": "integer" + }, + "updated_at": { + "type": "date" + }, + "updated_by": { + "type": "keyword" + } + } + }, + "ingest_manager_settings": { + "properties": { + "fleet_server_hosts": { + "type": "keyword" + }, + "has_seen_add_data_notice": { + "index": false, + "type": "boolean" + }, + "has_seen_fleet_migration_notice": { + "index": false, + "type": "boolean" + } + } + }, + "inventory-view": { + "dynamic": "false", + "type": "object" + }, + "kql-telemetry": { + "properties": { + "optInCount": { + "type": "long" + }, + "optOutCount": { + "type": "long" + } + } + }, + "legacy-url-alias": { + "dynamic": "false", + "type": "object" + }, + "lens": { + "properties": { + "description": { + "type": "text" + }, + "expression": { + "doc_values": false, + "index": false, + "type": "keyword" + }, + "state": { + "type": "flattened" + }, + "title": { + "type": "text" + }, + "visualizationType": { + "type": "keyword" + } + } + }, + "lens-ui-telemetry": { + "properties": { + "count": { + "type": "integer" + }, + "date": { + "type": "date" + }, + "name": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + } + }, + "map": { + "properties": { + "bounds": { + "dynamic": "false", + "type": "object" + }, + "description": { + "type": "text" + }, + "layerListJSON": { + "type": "text" + }, + "mapStateJSON": { + "type": "text" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "maps-telemetry": { + "enabled": false, + "type": "object" + }, + "metrics-explorer-view": { + "dynamic": "false", + "type": "object" + }, + "migrationVersion": { + "dynamic": "true", + "properties": { + "action": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "cases": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "cases-comments": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "cases-configure": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "cases-user-actions": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "config": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "dashboard": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "index-pattern": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "ingest-agent-policies": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "ingest-outputs": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "ingest-package-policies": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "ingest_manager_settings": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "search": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "space": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "visualization": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "ml-job": { + "properties": { + "datafeed_id": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + }, + "job_id": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + }, + "type": { + "type": "keyword" + } + } + }, + "ml-module": { + "dynamic": "false", + "properties": { + "datafeeds": { + "type": "object" + }, + "defaultIndexPattern": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + }, + "description": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + }, + "id": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + }, + "jobs": { + "type": "object" + }, + "logo": { + "type": "object" + }, + "query": { + "type": "object" + }, + "title": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + }, + "type": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "monitoring-telemetry": { + "properties": { + "reportedClusterUuids": { + "type": "keyword" + } + } + }, + "namespace": { + "type": "keyword" + }, + "namespaces": { + "type": "keyword" + }, + "originId": { + "type": "keyword" + }, + "query": { + "properties": { + "description": { + "type": "text" + }, + "filters": { + "enabled": false, + "type": "object" + }, + "query": { + "properties": { + "language": { + "type": "keyword" + }, + "query": { + "index": false, + "type": "keyword" + } + } + }, + "timefilter": { + "enabled": false, + "type": "object" + }, + "title": { + "type": "text" + } + } + }, + "references": { + "properties": { + "id": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + }, + "type": "nested" + }, + "sample-data-telemetry": { + "properties": { + "installCount": { + "type": "long" + }, + "unInstallCount": { + "type": "long" + } + } + }, + "search": { + "properties": { + "columns": { + "doc_values": false, + "index": false, + "type": "keyword" + }, + "description": { + "type": "text" + }, + "grid": { + "enabled": false, + "type": "object" + }, + "hideChart": { + "doc_values": false, + "index": false, + "type": "boolean" + }, + "hits": { + "doc_values": false, + "index": false, + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "index": false, + "type": "text" + } + } + }, + "sort": { + "doc_values": false, + "index": false, + "type": "keyword" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "search-session": { + "properties": { + "appId": { + "type": "keyword" + }, + "completed": { + "type": "date" + }, + "created": { + "type": "date" + }, + "expires": { + "type": "date" + }, + "idMapping": { + "enabled": false, + "type": "object" + }, + "initialState": { + "enabled": false, + "type": "object" + }, + "name": { + "type": "keyword" + }, + "persisted": { + "type": "boolean" + }, + "realmName": { + "type": "keyword" + }, + "realmType": { + "type": "keyword" + }, + "restoreState": { + "enabled": false, + "type": "object" + }, + "sessionId": { + "type": "keyword" + }, + "status": { + "type": "keyword" + }, + "touched": { + "type": "date" + }, + "urlGeneratorId": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + }, + "search-telemetry": { + "dynamic": "false", + "type": "object" + }, + "security-rule": { + "dynamic": "false", + "properties": { + "name": { + "type": "keyword" + }, + "rule_id": { + "type": "keyword" + }, + "version": { + "type": "long" + } + } + }, + "security-solution-signals-migration": { + "properties": { + "created": { + "index": false, + "type": "date" + }, + "createdBy": { + "index": false, + "type": "text" + }, + "destinationIndex": { + "index": false, + "type": "keyword" + }, + "error": { + "index": false, + "type": "text" + }, + "sourceIndex": { + "type": "keyword" + }, + "status": { + "index": false, + "type": "keyword" + }, + "taskId": { + "index": false, + "type": "keyword" + }, + "updated": { + "index": false, + "type": "date" + }, + "updatedBy": { + "index": false, + "type": "text" + }, + "version": { + "type": "long" + } + } + }, + "siem-detection-engine-rule-actions": { + "properties": { + "actions": { + "properties": { + "action_type_id": { + "type": "keyword" + }, + "group": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "params": { + "enabled": false, + "type": "object" + } + } + }, + "alertThrottle": { + "type": "keyword" + }, + "ruleAlertId": { + "type": "keyword" + }, + "ruleThrottle": { + "type": "keyword" + } + } + }, + "siem-detection-engine-rule-status": { + "properties": { + "alertId": { + "type": "keyword" + }, + "bulkCreateTimeDurations": { + "type": "float" + }, + "gap": { + "type": "text" + }, + "lastFailureAt": { + "type": "date" + }, + "lastFailureMessage": { + "type": "text" + }, + "lastLookBackDate": { + "type": "date" + }, + "lastSuccessAt": { + "type": "date" + }, + "lastSuccessMessage": { + "type": "text" + }, + "searchAfterTimeDurations": { + "type": "float" + }, + "status": { + "type": "keyword" + }, + "statusDate": { + "type": "date" + } + } + }, + "siem-ui-timeline": { + "properties": { + "columns": { + "properties": { + "aggregatable": { + "type": "boolean" + }, + "category": { + "type": "keyword" + }, + "columnHeaderType": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "example": { + "type": "text" + }, + "id": { + "type": "keyword" + }, + "indexes": { + "type": "keyword" + }, + "name": { + "type": "text" + }, + "placeholder": { + "type": "text" + }, + "searchable": { + "type": "boolean" + }, + "type": { + "type": "keyword" + } + } + }, + "created": { + "type": "date" + }, + "createdBy": { + "type": "text" + }, + "dataProviders": { + "properties": { + "and": { + "properties": { + "enabled": { + "type": "boolean" + }, + "excluded": { + "type": "boolean" + }, + "id": { + "type": "keyword" + }, + "kqlQuery": { + "type": "text" + }, + "name": { + "type": "text" + }, + "queryMatch": { + "properties": { + "displayField": { + "type": "text" + }, + "displayValue": { + "type": "text" + }, + "field": { + "type": "text" + }, + "operator": { + "type": "text" + }, + "value": { + "type": "text" + } + } + }, + "type": { + "type": "text" + } + } + }, + "enabled": { + "type": "boolean" + }, + "excluded": { + "type": "boolean" + }, + "id": { + "type": "keyword" + }, + "kqlQuery": { + "type": "text" + }, + "name": { + "type": "text" + }, + "queryMatch": { + "properties": { + "displayField": { + "type": "text" + }, + "displayValue": { + "type": "text" + }, + "field": { + "type": "text" + }, + "operator": { + "type": "text" + }, + "value": { + "type": "text" + } + } + }, + "type": { + "type": "text" + } + } + }, + "dateRange": { + "properties": { + "end": { + "type": "date" + }, + "start": { + "type": "date" + } + } + }, + "description": { + "type": "text" + }, + "eqlOptions": { + "properties": { + "eventCategoryField": { + "type": "text" + }, + "query": { + "type": "text" + }, + "size": { + "type": "text" + }, + "tiebreakerField": { + "type": "text" + }, + "timestampField": { + "type": "text" + } + } + }, + "eventType": { + "type": "keyword" + }, + "excludedRowRendererIds": { + "type": "text" + }, + "favorite": { + "properties": { + "favoriteDate": { + "type": "date" + }, + "fullName": { + "type": "text" + }, + "keySearch": { + "type": "text" + }, + "userName": { + "type": "text" + } + } + }, + "filters": { + "properties": { + "exists": { + "type": "text" + }, + "match_all": { + "type": "text" + }, + "meta": { + "properties": { + "alias": { + "type": "text" + }, + "controlledBy": { + "type": "text" + }, + "disabled": { + "type": "boolean" + }, + "field": { + "type": "text" + }, + "formattedValue": { + "type": "text" + }, + "index": { + "type": "keyword" + }, + "key": { + "type": "keyword" + }, + "negate": { + "type": "boolean" + }, + "params": { + "type": "text" + }, + "type": { + "type": "keyword" + }, + "value": { + "type": "text" + } + } + }, + "missing": { + "type": "text" + }, + "query": { + "type": "text" + }, + "range": { + "type": "text" + }, + "script": { + "type": "text" + } + } + }, + "indexNames": { + "type": "text" + }, + "kqlMode": { + "type": "keyword" + }, + "kqlQuery": { + "properties": { + "filterQuery": { + "properties": { + "kuery": { + "properties": { + "expression": { + "type": "text" + }, + "kind": { + "type": "keyword" + } + } + }, + "serializedQuery": { + "type": "text" + } + } + } + } + }, + "savedQueryId": { + "type": "keyword" + }, + "sort": { + "dynamic": "false", + "properties": { + "columnId": { + "type": "keyword" + }, + "columnType": { + "type": "keyword" + }, + "sortDirection": { + "type": "keyword" + } + } + }, + "status": { + "type": "keyword" + }, + "templateTimelineId": { + "type": "text" + }, + "templateTimelineVersion": { + "type": "integer" + }, + "timelineType": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "updated": { + "type": "date" + }, + "updatedBy": { + "type": "text" + } + } + }, + "siem-ui-timeline-note": { + "properties": { + "created": { + "type": "date" + }, + "createdBy": { + "type": "text" + }, + "eventId": { + "type": "keyword" + }, + "note": { + "type": "text" + }, + "timelineId": { + "type": "keyword" + }, + "updated": { + "type": "date" + }, + "updatedBy": { + "type": "text" + } + } + }, + "siem-ui-timeline-pinned-event": { + "properties": { + "created": { + "type": "date" + }, + "createdBy": { + "type": "text" + }, + "eventId": { + "type": "keyword" + }, + "timelineId": { + "type": "keyword" + }, + "updated": { + "type": "date" + }, + "updatedBy": { + "type": "text" + } + } + }, + "space": { + "properties": { + "_reserved": { + "type": "boolean" + }, + "color": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "disabledFeatures": { + "type": "keyword" + }, + "imageUrl": { + "index": false, + "type": "text" + }, + "initials": { + "type": "keyword" + }, + "name": { + "fields": { + "keyword": { + "ignore_above": 2048, + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "spaces-usage-stats": { + "dynamic": "false", + "type": "object" + }, + "tag": { + "properties": { + "color": { + "type": "text" + }, + "description": { + "type": "text" + }, + "name": { + "type": "text" + } + } + }, + "telemetry": { + "properties": { + "allowChangingOptInStatus": { + "type": "boolean" + }, + "enabled": { + "type": "boolean" + }, + "lastReported": { + "type": "date" + }, + "lastVersionChecked": { + "type": "keyword" + }, + "reportFailureCount": { + "type": "integer" + }, + "reportFailureVersion": { + "type": "keyword" + }, + "sendUsageFrom": { + "type": "keyword" + }, + "userHasSeenNotice": { + "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" + }, + "ui-counter": { + "properties": { + "count": { + "type": "integer" + } + } + }, + "ui-metric": { + "properties": { + "count": { + "type": "integer" + } + } + }, + "updated_at": { + "type": "date" + }, + "upgrade-assistant-reindex-operation": { + "properties": { + "errorMessage": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "indexName": { + "type": "keyword" + }, + "lastCompletedStep": { + "type": "long" + }, + "locked": { + "type": "date" + }, + "newIndexName": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "reindexOptions": { + "properties": { + "openAndClose": { + "type": "boolean" + }, + "queueSettings": { + "properties": { + "queuedAt": { + "type": "long" + }, + "startedAt": { + "type": "long" + } + } + } + } + }, + "reindexTaskId": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "reindexTaskPercComplete": { + "type": "float" + }, + "runningReindexCount": { + "type": "integer" + }, + "status": { + "type": "integer" + } + } + }, + "upgrade-assistant-telemetry": { + "properties": { + "features": { + "properties": { + "deprecation_logging": { + "properties": { + "enabled": { + "null_value": true, + "type": "boolean" + } + } + } + } + }, + "ui_open": { + "properties": { + "cluster": { + "null_value": 0, + "type": "long" + }, + "indices": { + "null_value": 0, + "type": "long" + }, + "overview": { + "null_value": 0, + "type": "long" + } + } + }, + "ui_reindex": { + "properties": { + "close": { + "null_value": 0, + "type": "long" + }, + "open": { + "null_value": 0, + "type": "long" + }, + "start": { + "null_value": 0, + "type": "long" + }, + "stop": { + "null_value": 0, + "type": "long" + } + } + } + } + }, + "uptime-dynamic-settings": { + "dynamic": "false", + "type": "object" + }, + "url": { + "properties": { + "accessCount": { + "type": "long" + }, + "accessDate": { + "type": "date" + }, + "createDate": { + "type": "date" + }, + "url": { + "fields": { + "keyword": { + "ignore_above": 2048, + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "usage-counters": { + "dynamic": "false", + "properties": { + "domainId": { + "type": "keyword" + } + } + }, + "visualization": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "index": false, + "type": "text" + } + } + }, + "savedSearchRefName": { + "doc_values": false, + "index": false, + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "index": false, + "type": "text" + }, + "version": { + "type": "integer" + }, + "visState": { + "index": false, + "type": "text" + } + } + }, + "workplace_search_telemetry": { + "dynamic": "false", + "type": "object" + } + } + }, + "settings": { + "index": { + "auto_expand_replicas": "0-1", + "number_of_replicas": "1", + "number_of_shards": "1", + "priority": "10", + "refresh_interval": "1s" + } + } + } +} \ No newline at end of file From 65ff74ff5a3c90129f3128b0e6dd431f4cdfcb8a Mon Sep 17 00:00:00 2001 From: Joe Reuter <johannes.reuter@elastic.co> Date: Thu, 1 Jul 2021 10:48:34 +0200 Subject: [PATCH 27/51] [Lens] Add functional test for example integration (#103460) --- .../embedded_lens_example/public/app.tsx | 47 +++++++++++++- x-pack/test/examples/config.ts | 2 +- .../embedded_lens/embedded_example.ts | 65 +++++++++++++++++++ x-pack/test/examples/embedded_lens/index.ts | 34 ++++++++++ 4 files changed, 145 insertions(+), 3 deletions(-) create mode 100644 x-pack/test/examples/embedded_lens/embedded_example.ts create mode 100644 x-pack/test/examples/embedded_lens/index.ts diff --git a/x-pack/examples/embedded_lens_example/public/app.tsx b/x-pack/examples/embedded_lens_example/public/app.tsx index a13ddbbd79ef0..913836a244b8a 100644 --- a/x-pack/examples/embedded_lens_example/public/app.tsx +++ b/x-pack/examples/embedded_lens_example/public/app.tsx @@ -17,6 +17,7 @@ import { EuiPageHeader, EuiPageHeaderSection, EuiTitle, + EuiCallOut, } from '@elastic/eui'; import { IndexPattern } from 'src/plugins/data/public'; import { CoreStart } from 'kibana/public'; @@ -149,6 +150,7 @@ export const App = (props: { <EuiFlexGroup> <EuiFlexItem grow={false}> <EuiButton + data-test-subj="lns-example-change-color" isLoading={isLoading} onClick={() => { // eslint-disable-next-line no-bitwise @@ -177,12 +179,32 @@ export const App = (props: { setColor(newColor); }} > - Edit in Lens + Edit in Lens (new tab) + </EuiButton> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiButton + aria-label="Open lens in same tab" + data-test-subj="lns-example-open-editor" + isDisabled={!props.plugins.lens.canUseEditor()} + onClick={() => { + props.plugins.lens.navigateToPrefilledEditor( + { + id: '', + timeRange: time, + attributes: getLensAttributes(props.defaultIndexPattern!, color), + }, + false + ); + }} + > + Edit in Lens (same tab) </EuiButton> </EuiFlexItem> <EuiFlexItem grow={false}> <EuiButton aria-label="Save visualization into library or embed directly into any dashboard" + data-test-subj="lns-example-save" isDisabled={!getLensAttributes(props.defaultIndexPattern, color)} onClick={() => { setIsSaveModalVisible(true); @@ -191,6 +213,21 @@ export const App = (props: { Save Visualization </EuiButton> </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiButton + aria-label="Change time range" + data-test-subj="lns-example-change-time-range" + isDisabled={!getLensAttributes(props.defaultIndexPattern, color)} + onClick={() => { + setTime({ + from: '2015-09-18T06:31:44.000Z', + to: '2015-09-23T18:31:44.000Z', + }); + }} + > + Change time range + </EuiButton> + </EuiFlexItem> </EuiFlexGroup> <LensComponent id="" @@ -230,7 +267,13 @@ export const App = (props: { )} </> ) : ( - <p>This demo only works if your default index pattern is set and time based</p> + <EuiCallOut + title="Please define a default index pattern to use this demo" + color="danger" + iconType="alert" + > + <p>This demo only works if your default index pattern is set and time based</p> + </EuiCallOut> )} </EuiPageContentBody> </EuiPageContent> diff --git a/x-pack/test/examples/config.ts b/x-pack/test/examples/config.ts index 491c23a33a3ef..606f97f9c3de7 100644 --- a/x-pack/test/examples/config.ts +++ b/x-pack/test/examples/config.ts @@ -33,7 +33,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { reportName: 'X-Pack Example plugin functional tests', }, - testFiles: [require.resolve('./search_examples')], + testFiles: [require.resolve('./search_examples'), require.resolve('./embedded_lens')], kbnTestServer: { ...xpackFunctionalConfig.get('kbnTestServer'), diff --git a/x-pack/test/examples/embedded_lens/embedded_example.ts b/x-pack/test/examples/embedded_lens/embedded_example.ts new file mode 100644 index 0000000000000..3a0891079f24e --- /dev/null +++ b/x-pack/test/examples/embedded_lens/embedded_example.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../functional/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const PageObjects = getPageObjects(['lens', 'common', 'dashboard', 'timeToVisualize']); + const elasticChart = getService('elasticChart'); + const testSubjects = getService('testSubjects'); + const retry = getService('retry'); + + async function checkData() { + const data = await elasticChart.getChartDebugData(); + expect(data!.bars![0].bars.length).to.eql(24); + } + + describe('show and save', () => { + beforeEach(async () => { + await PageObjects.common.navigateToApp('embedded_lens_example'); + await elasticChart.setNewChartUiDebugFlag(true); + await testSubjects.click('lns-example-change-time-range'); + await PageObjects.lens.waitForVisualization(); + }); + + it('should show chart', async () => { + await testSubjects.click('lns-example-change-color'); + await PageObjects.lens.waitForVisualization(); + await checkData(); + }); + + it('should save to dashboard', async () => { + await testSubjects.click('lns-example-save'); + await PageObjects.timeToVisualize.setSaveModalValues('From example', { + saveAsNew: true, + redirectToOrigin: false, + addToDashboard: 'new', + dashboardId: undefined, + saveToLibrary: false, + }); + + await testSubjects.click('confirmSaveSavedObjectButton'); + await retry.waitForWithTimeout('Save modal to disappear', 1000, () => + testSubjects + .missingOrFail('confirmSaveSavedObjectButton') + .then(() => true) + .catch(() => false) + ); + await PageObjects.lens.goToTimeRange(); + await PageObjects.dashboard.waitForRenderComplete(); + await checkData(); + }); + + it('should load Lens editor', async () => { + await testSubjects.click('lns-example-open-editor'); + await PageObjects.lens.waitForVisualization(); + await checkData(); + }); + }); +} diff --git a/x-pack/test/examples/embedded_lens/index.ts b/x-pack/test/examples/embedded_lens/index.ts new file mode 100644 index 0000000000000..3bd4ea31cc89b --- /dev/null +++ b/x-pack/test/examples/embedded_lens/index.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { PluginFunctionalProviderContext } from 'test/plugin_functional/services'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService, loadTestFile }: PluginFunctionalProviderContext) { + const esArchiver = getService('esArchiver'); + const kibanaServer = getService('kibanaServer'); + + describe('embedded Lens examples', function () { + before(async () => { + await esArchiver.load('x-pack/test/functional/es_archives/logstash_functional'); + await esArchiver.load('x-pack/test/functional/es_archives/lens/basic'); // need at least one index pattern + await kibanaServer.uiSettings.update({ + defaultIndex: 'logstash-*', + }); + }); + + after(async () => { + await esArchiver.unload('x-pack/test/functional/es_archives/lens/basic'); + }); + + describe('', function () { + this.tags(['ciGroup4', 'skipFirefox']); + + loadTestFile(require.resolve('./embedded_example')); + }); + }); +} From 6e3df60abaf1bcf1cc0ea902dde3913820fc32c8 Mon Sep 17 00:00:00 2001 From: Marta Bondyra <marta.bondyra@gmail.com> Date: Thu, 1 Jul 2021 11:00:56 +0200 Subject: [PATCH 28/51] [Lens] Move editorFrame state to redux (#100858) Co-authored-by: Joe Reuter <johannes.reuter@elastic.co> Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: dej611 <dej611@gmail.com> --- .../embedded_lens_example/public/app.tsx | 1 - .../lens/public/app_plugin/app.test.tsx | 418 ++++------ x-pack/plugins/lens/public/app_plugin/app.tsx | 256 +++--- .../lens/public/app_plugin/lens_top_nav.tsx | 61 +- .../lens/public/app_plugin/mounter.test.tsx | 188 ++++- .../lens/public/app_plugin/mounter.tsx | 236 +++++- .../lens/public/app_plugin/save_modal.tsx | 27 +- .../app_plugin/save_modal_container.tsx | 60 +- .../plugins/lens/public/app_plugin/types.ts | 7 +- .../components/dimension_editor.test.tsx | 2 +- .../visualization.test.tsx | 8 +- .../datatable_visualization/visualization.tsx | 4 +- .../config_panel/config_panel.test.tsx | 165 ++-- .../config_panel/config_panel.tsx | 181 ++-- .../config_panel/layer_actions.test.ts | 2 + .../config_panel/layer_actions.ts | 11 +- .../config_panel/layer_panel.test.tsx | 2 +- .../editor_frame/config_panel/types.ts | 3 - .../editor_frame/data_panel_wrapper.tsx | 89 +- .../editor_frame/editor_frame.test.tsx | 782 ++++-------------- .../editor_frame/editor_frame.tsx | 394 ++------- .../editor_frame/index.ts | 1 - .../editor_frame/save.test.ts | 116 --- .../editor_frame_service/editor_frame/save.ts | 79 -- .../editor_frame/state_helpers.ts | 2 +- .../editor_frame/state_management.test.ts | 415 ---------- .../editor_frame/state_management.ts | 293 ------- .../editor_frame/suggestion_helpers.test.ts | 2 +- .../editor_frame/suggestion_helpers.ts | 23 +- .../editor_frame/suggestion_panel.test.tsx | 187 ++--- .../editor_frame/suggestion_panel.tsx | 25 +- .../workspace_panel/chart_switch.test.tsx | 498 ++++++----- .../workspace_panel/chart_switch.tsx | 159 ++-- .../editor_frame/workspace_panel/title.tsx | 27 + .../workspace_panel/workspace_panel.test.tsx | 96 ++- .../workspace_panel/workspace_panel.tsx | 69 +- .../workspace_panel_wrapper.test.tsx | 22 +- .../workspace_panel_wrapper.tsx | 40 +- .../public/editor_frame_service/mocks.tsx | 117 +-- .../public/editor_frame_service/service.tsx | 14 +- .../visualization.test.ts | 10 +- .../heatmap_visualization/visualization.tsx | 4 +- .../public/indexpattern_datasource/loader.ts | 22 +- .../visualization.test.ts | 17 +- .../metric_visualization/visualization.tsx | 4 +- x-pack/plugins/lens/public/mocks.tsx | 195 ++++- .../pie_visualization/visualization.tsx | 4 +- .../lens/public/state_management/app_slice.ts | 55 -- .../external_context_middleware.ts | 6 +- .../lens/public/state_management/index.ts | 31 +- .../state_management/lens_slice.test.ts | 148 ++++ .../public/state_management/lens_slice.ts | 262 ++++++ .../state_management/optimizing_middleware.ts | 22 + .../time_range_middleware.test.ts | 51 +- .../state_management/time_range_middleware.ts | 13 +- .../lens/public/state_management/types.ts | 23 +- x-pack/plugins/lens/public/types.ts | 18 +- x-pack/plugins/lens/public/utils.ts | 115 ++- .../xy_visualization/to_expression.test.ts | 2 +- .../visual_options_popover.test.tsx | 2 +- .../xy_visualization/visualization.test.ts | 11 +- .../public/xy_visualization/visualization.tsx | 4 +- .../xy_visualization/xy_config_panel.test.tsx | 2 +- .../shared/exploratory_view/header/header.tsx | 1 - x-pack/test/accessibility/apps/lens.ts | 4 +- x-pack/test/functional/apps/lens/dashboard.ts | 5 +- .../test/functional/page_objects/lens_page.ts | 6 + 67 files changed, 2747 insertions(+), 3372 deletions(-) delete mode 100644 x-pack/plugins/lens/public/editor_frame_service/editor_frame/save.test.ts delete mode 100644 x-pack/plugins/lens/public/editor_frame_service/editor_frame/save.ts delete mode 100644 x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_management.test.ts delete mode 100644 x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_management.ts create mode 100644 x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/title.tsx delete mode 100644 x-pack/plugins/lens/public/state_management/app_slice.ts create mode 100644 x-pack/plugins/lens/public/state_management/lens_slice.test.ts create mode 100644 x-pack/plugins/lens/public/state_management/lens_slice.ts create mode 100644 x-pack/plugins/lens/public/state_management/optimizing_middleware.ts diff --git a/x-pack/examples/embedded_lens_example/public/app.tsx b/x-pack/examples/embedded_lens_example/public/app.tsx index 913836a244b8a..bf43e200b902d 100644 --- a/x-pack/examples/embedded_lens_example/public/app.tsx +++ b/x-pack/examples/embedded_lens_example/public/app.tsx @@ -260,7 +260,6 @@ export const App = (props: { color ) as unknown) as LensEmbeddableInput } - isVisible={isSaveModalVisible} onSave={() => {}} onClose={() => setIsSaveModalVisible(false)} /> diff --git a/x-pack/plugins/lens/public/app_plugin/app.test.tsx b/x-pack/plugins/lens/public/app_plugin/app.test.tsx index bced8bf7c04fe..1c49527d9eca8 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.test.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.test.tsx @@ -13,7 +13,13 @@ import { App } from './app'; import { LensAppProps, LensAppServices } from './types'; import { EditorFrameInstance, EditorFrameProps } from '../types'; import { Document } from '../persistence'; -import { makeDefaultServices, mountWithProvider } from '../mocks'; +import { + createMockDatasource, + createMockVisualization, + DatasourceMock, + makeDefaultServices, + mountWithProvider, +} from '../mocks'; import { I18nProvider } from '@kbn/i18n/react'; import { SavedObjectSaveModal, @@ -25,7 +31,6 @@ import { FilterManager, IFieldType, IIndexPattern, - IndexPattern, Query, } from '../../../../../src/plugins/data/public'; import { TopNavMenuData } from '../../../../../src/plugins/navigation/public'; @@ -60,17 +65,41 @@ jest.mock('lodash', () => { // const navigationStartMock = navigationPluginMock.createStartContract(); -function createMockFrame(): jest.Mocked<EditorFrameInstance> { - return { - EditorFrameContainer: jest.fn((props: EditorFrameProps) => <div />), - }; -} - const sessionIdSubject = new Subject<string>(); describe('Lens App', () => { let defaultDoc: Document; let defaultSavedObjectId: string; + const mockDatasource: DatasourceMock = createMockDatasource('testDatasource'); + const mockDatasource2: DatasourceMock = createMockDatasource('testDatasource2'); + const datasourceMap = { + testDatasource2: mockDatasource2, + testDatasource: mockDatasource, + }; + + const mockVisualization = { + ...createMockVisualization(), + id: 'testVis', + visualizationTypes: [ + { + icon: 'empty', + id: 'testVis', + label: 'TEST1', + groupLabel: 'testVisGroup', + }, + ], + }; + const visualizationMap = { + testVis: mockVisualization, + }; + + function createMockFrame(): jest.Mocked<EditorFrameInstance> { + return { + EditorFrameContainer: jest.fn((props: EditorFrameProps) => <div />), + datasourceMap, + visualizationMap, + }; + } const navMenuItems = { expectedSaveButton: { emphasize: true, testId: 'lnsApp_saveButton' }, @@ -86,17 +115,19 @@ describe('Lens App', () => { redirectToOrigin: jest.fn(), onAppLeave: jest.fn(), setHeaderActionMenu: jest.fn(), + datasourceMap, + visualizationMap, }; } async function mountWith({ props = makeDefaultProps(), services = makeDefaultServices(sessionIdSubject), - storePreloadedState, + preloadedState, }: { props?: jest.Mocked<LensAppProps>; services?: jest.Mocked<LensAppServices>; - storePreloadedState?: Partial<LensAppState>; + preloadedState?: Partial<LensAppState>; }) { const wrappingComponent: React.FC<{ children: React.ReactNode; @@ -110,9 +141,11 @@ describe('Lens App', () => { const { instance, lensStore } = await mountWithProvider( <App {...props} />, - services.data, - storePreloadedState, - wrappingComponent + { + data: services.data, + preloadedState, + }, + { wrappingComponent } ); const frame = props.editorFrame as ReturnType<typeof createMockFrame>; @@ -139,8 +172,6 @@ describe('Lens App', () => { Array [ Array [ Object { - "initialContext": undefined, - "onError": [Function], "showNoDataPopover": [Function], }, Object {}, @@ -164,7 +195,7 @@ describe('Lens App', () => { instance.update(); expect(lensStore.getState()).toEqual({ - app: expect.objectContaining({ + lens: expect.objectContaining({ query: { query: '', language: 'lucene' }, filters: [pinnedFilter], resolvedDateRange: { @@ -177,14 +208,6 @@ describe('Lens App', () => { expect(services.data.query.filterManager.getFilters).not.toHaveBeenCalled(); }); - it('displays errors from the frame in a toast', async () => { - const { instance, frame, services } = await mountWith({}); - const onError = frame.EditorFrameContainer.mock.calls[0][0].onError; - onError({ message: 'error' }); - instance.update(); - expect(services.notifications.toasts.addDanger).toHaveBeenCalled(); - }); - describe('breadcrumbs', () => { const breadcrumbDocSavedObjectId = defaultSavedObjectId; const breadcrumbDoc = ({ @@ -237,7 +260,7 @@ describe('Lens App', () => { const { instance, lensStore } = await mountWith({ props, services, - storePreloadedState: { + preloadedState: { isLinkedToOriginatingApp: true, }, }); @@ -275,8 +298,8 @@ describe('Lens App', () => { }); describe('persistence', () => { - it('loads a document and uses query and filters if initial input is provided', async () => { - const { instance, lensStore, services } = await mountWith({}); + it('passes query and indexPatterns to TopNavMenu', async () => { + const { instance, lensStore, services } = await mountWith({ preloadedState: {} }); const document = ({ savedObjectId: defaultSavedObjectId, state: { @@ -290,8 +313,6 @@ describe('Lens App', () => { lensStore.dispatch( setState({ query: ('fake query' as unknown) as Query, - indexPatternsForTopNav: ([{ id: '1' }] as unknown) as IndexPattern[], - lastKnownDoc: document, persistedDoc: document, }) ); @@ -301,7 +322,7 @@ describe('Lens App', () => { expect(services.navigation.ui.TopNavMenu).toHaveBeenCalledWith( expect.objectContaining({ query: 'fake query', - indexPatterns: [{ id: '1' }], + indexPatterns: [{ id: 'mockip' }], }), {} ); @@ -332,16 +353,11 @@ describe('Lens App', () => { } async function save({ - lastKnownDoc = { - references: [], - state: { - filters: [], - }, - }, + preloadedState, initialSavedObjectId, ...saveProps }: SaveProps & { - lastKnownDoc?: object; + preloadedState?: Partial<LensAppState>; initialSavedObjectId?: string; }) { const props = { @@ -366,18 +382,14 @@ describe('Lens App', () => { }, } as jest.ResolvedValue<Document>); - const { frame, instance, lensStore } = await mountWith({ services, props }); - - act(() => { - lensStore.dispatch( - setState({ - isSaveable: true, - lastKnownDoc: { savedObjectId: initialSavedObjectId, ...lastKnownDoc } as Document, - }) - ); + const { frame, instance, lensStore } = await mountWith({ + services, + props, + preloadedState: { + isSaveable: true, + ...preloadedState, + }, }); - - instance.update(); expect(getButton(instance).disableButton).toEqual(false); await act(async () => { testSave(instance, { ...saveProps }); @@ -399,7 +411,6 @@ describe('Lens App', () => { act(() => { lensStore.dispatch( setState({ - lastKnownDoc: ({ savedObjectId: 'will save this' } as unknown) as Document, isSaveable: true, }) ); @@ -415,7 +426,6 @@ describe('Lens App', () => { lensStore.dispatch( setState({ isSaveable: true, - lastKnownDoc: ({ savedObjectId: 'will save this' } as unknown) as Document, }) ); }); @@ -455,7 +465,7 @@ describe('Lens App', () => { const { instance } = await mountWith({ props, services, - storePreloadedState: { + preloadedState: { isLinkedToOriginatingApp: true, }, }); @@ -483,7 +493,7 @@ describe('Lens App', () => { const { instance, services } = await mountWith({ props, - storePreloadedState: { + preloadedState: { isLinkedToOriginatingApp: true, }, }); @@ -540,6 +550,7 @@ describe('Lens App', () => { initialSavedObjectId: defaultSavedObjectId, newCopyOnSave: true, newTitle: 'hello there', + preloadedState: { persistedDoc: defaultDoc }, }); expect(services.attributeService.wrapAttributes).toHaveBeenCalledWith( expect.objectContaining({ @@ -559,10 +570,11 @@ describe('Lens App', () => { }); it('saves existing docs', async () => { - const { props, services, instance, lensStore } = await save({ + const { props, services, instance } = await save({ initialSavedObjectId: defaultSavedObjectId, newCopyOnSave: false, newTitle: 'hello there', + preloadedState: { persistedDoc: defaultDoc }, }); expect(services.attributeService.wrapAttributes).toHaveBeenCalledWith( expect.objectContaining({ @@ -576,22 +588,6 @@ describe('Lens App', () => { await act(async () => { instance.setProps({ initialInput: { savedObjectId: defaultSavedObjectId } }); }); - - expect(lensStore.dispatch).toHaveBeenCalledWith({ - payload: { - lastKnownDoc: expect.objectContaining({ - savedObjectId: defaultSavedObjectId, - title: 'hello there', - }), - persistedDoc: expect.objectContaining({ - savedObjectId: defaultSavedObjectId, - title: 'hello there', - }), - isLinkedToOriginatingApp: false, - }, - type: 'app/setState', - }); - expect(services.notifications.toasts.addSuccess).toHaveBeenCalledWith( "Saved 'hello there'" ); @@ -602,18 +598,13 @@ describe('Lens App', () => { services.attributeService.wrapAttributes = jest .fn() .mockRejectedValue({ message: 'failed' }); - const { instance, props, lensStore } = await mountWith({ services }); - act(() => { - lensStore.dispatch( - setState({ - isSaveable: true, - lastKnownDoc: ({ id: undefined } as unknown) as Document, - }) - ); + const { instance, props } = await mountWith({ + services, + preloadedState: { + isSaveable: true, + }, }); - instance.update(); - await act(async () => { testSave(instance, { newCopyOnSave: false, newTitle: 'hello there' }); }); @@ -655,22 +646,19 @@ describe('Lens App', () => { initialSavedObjectId: defaultSavedObjectId, newCopyOnSave: false, newTitle: 'hello there2', - lastKnownDoc: { - expression: 'kibana 3', - state: { - filters: [pinned, unpinned], - }, + preloadedState: { + persistedDoc: defaultDoc, + filters: [pinned, unpinned], }, }); expect(services.attributeService.wrapAttributes).toHaveBeenCalledWith( - { + expect.objectContaining({ savedObjectId: defaultSavedObjectId, title: 'hello there2', - expression: 'kibana 3', - state: { + state: expect.objectContaining({ filters: [unpinned], - }, - }, + }), + }), true, { id: '5678', savedObjectId: defaultSavedObjectId } ); @@ -681,17 +669,13 @@ describe('Lens App', () => { services.attributeService.wrapAttributes = jest .fn() .mockReturnValue(Promise.resolve({ savedObjectId: '123' })); - const { instance, lensStore } = await mountWith({ services }); - await act(async () => { - lensStore.dispatch( - setState({ - isSaveable: true, - lastKnownDoc: ({ savedObjectId: '123' } as unknown) as Document, - }) - ); + const { instance } = await mountWith({ + services, + preloadedState: { + isSaveable: true, + persistedDoc: ({ savedObjectId: '123' } as unknown) as Document, + }, }); - - instance.update(); await act(async () => { instance.setProps({ initialInput: { savedObjectId: '123' } }); getButton(instance).run(instance.getDOMNode()); @@ -716,17 +700,7 @@ describe('Lens App', () => { }); it('does not show the copy button on first save', async () => { - const { instance, lensStore } = await mountWith({}); - await act(async () => { - lensStore.dispatch( - setState({ - isSaveable: true, - lastKnownDoc: ({} as unknown) as Document, - }) - ); - }); - - instance.update(); + const { instance } = await mountWith({ preloadedState: { isSaveable: true } }); await act(async () => getButton(instance).run(instance.getDOMNode())); instance.update(); expect(instance.find(SavedObjectSaveModal).prop('showCopyOnSave')).toEqual(false); @@ -744,33 +718,18 @@ describe('Lens App', () => { } it('should be disabled when no data is available', async () => { - const { instance, lensStore } = await mountWith({}); - await act(async () => { - lensStore.dispatch( - setState({ - isSaveable: true, - lastKnownDoc: ({} as unknown) as Document, - }) - ); - }); - instance.update(); + const { instance } = await mountWith({ preloadedState: { isSaveable: true } }); expect(getButton(instance).disableButton).toEqual(true); }); it('should disable download when not saveable', async () => { - const { instance, lensStore } = await mountWith({}); - - await act(async () => { - lensStore.dispatch( - setState({ - lastKnownDoc: ({} as unknown) as Document, - isSaveable: false, - activeData: { layer1: { type: 'datatable', columns: [], rows: [] } }, - }) - ); + const { instance } = await mountWith({ + preloadedState: { + isSaveable: false, + activeData: { layer1: { type: 'datatable', columns: [], rows: [] } }, + }, }); - instance.update(); expect(getButton(instance).disableButton).toEqual(true); }); @@ -784,17 +743,13 @@ describe('Lens App', () => { }, }; - const { instance, lensStore } = await mountWith({ services }); - await act(async () => { - lensStore.dispatch( - setState({ - lastKnownDoc: ({} as unknown) as Document, - isSaveable: true, - activeData: { layer1: { type: 'datatable', columns: [], rows: [] } }, - }) - ); + const { instance } = await mountWith({ + services, + preloadedState: { + isSaveable: true, + activeData: { layer1: { type: 'datatable', columns: [], rows: [] } }, + }, }); - instance.update(); expect(getButton(instance).disableButton).toEqual(false); }); }); @@ -812,7 +767,7 @@ describe('Lens App', () => { ); expect(lensStore.getState()).toEqual({ - app: expect.objectContaining({ + lens: expect.objectContaining({ query: { query: '', language: 'lucene' }, resolvedDateRange: { fromDate: '2021-01-10T04:00:00.000Z', @@ -822,49 +777,6 @@ describe('Lens App', () => { }); }); - it('updates the index patterns when the editor frame is changed', async () => { - const { instance, lensStore, services } = await mountWith({}); - expect(services.navigation.ui.TopNavMenu).toHaveBeenCalledWith( - expect.objectContaining({ - indexPatterns: [], - }), - {} - ); - await act(async () => { - lensStore.dispatch( - setState({ - indexPatternsForTopNav: [{ id: '1' }] as IndexPattern[], - lastKnownDoc: ({} as unknown) as Document, - isSaveable: true, - }) - ); - }); - instance.update(); - expect(services.navigation.ui.TopNavMenu).toHaveBeenCalledWith( - expect.objectContaining({ - indexPatterns: [{ id: '1' }], - }), - {} - ); - // Do it again to verify that the dirty checking is done right - await act(async () => { - lensStore.dispatch( - setState({ - indexPatternsForTopNav: [{ id: '2' }] as IndexPattern[], - lastKnownDoc: ({} as unknown) as Document, - isSaveable: true, - }) - ); - }); - instance.update(); - expect(services.navigation.ui.TopNavMenu).toHaveBeenLastCalledWith( - expect.objectContaining({ - indexPatterns: [{ id: '2' }], - }), - {} - ); - }); - it('updates the editor frame when the user changes query or time in the search bar', async () => { const { instance, services, lensStore } = await mountWith({}); (services.data.query.timefilter.timefilter.calculateBounds as jest.Mock).mockReturnValue({ @@ -892,7 +804,7 @@ describe('Lens App', () => { }); expect(lensStore.getState()).toEqual({ - app: expect.objectContaining({ + lens: expect.objectContaining({ query: { query: 'new', language: 'lucene' }, resolvedDateRange: { fromDate: '2021-01-09T04:00:00.000Z', @@ -907,7 +819,7 @@ describe('Lens App', () => { const indexPattern = ({ id: 'index1' } as unknown) as IIndexPattern; const field = ({ name: 'myfield' } as unknown) as IFieldType; expect(lensStore.getState()).toEqual({ - app: expect.objectContaining({ + lens: expect.objectContaining({ filters: [], }), }); @@ -918,7 +830,7 @@ describe('Lens App', () => { ); instance.update(); expect(lensStore.getState()).toEqual({ - app: expect.objectContaining({ + lens: expect.objectContaining({ filters: [esFilters.buildExistsFilter(field, indexPattern)], }), }); @@ -928,7 +840,7 @@ describe('Lens App', () => { const { instance, services, lensStore } = await mountWith({}); expect(lensStore.getState()).toEqual({ - app: expect.objectContaining({ + lens: expect.objectContaining({ searchSessionId: `sessionId-1`, }), }); @@ -942,7 +854,7 @@ describe('Lens App', () => { instance.update(); expect(lensStore.getState()).toEqual({ - app: expect.objectContaining({ + lens: expect.objectContaining({ searchSessionId: `sessionId-2`, }), }); @@ -955,7 +867,7 @@ describe('Lens App', () => { ); instance.update(); expect(lensStore.getState()).toEqual({ - app: expect.objectContaining({ + lens: expect.objectContaining({ searchSessionId: `sessionId-3`, }), }); @@ -968,7 +880,7 @@ describe('Lens App', () => { ); instance.update(); expect(lensStore.getState()).toEqual({ - app: expect.objectContaining({ + lens: expect.objectContaining({ searchSessionId: `sessionId-4`, }), }); @@ -1105,7 +1017,7 @@ describe('Lens App', () => { act(() => instance.find(services.navigation.ui.TopNavMenu).prop('onClearSavedQuery')!()); instance.update(); expect(lensStore.getState()).toEqual({ - app: expect.objectContaining({ + lens: expect.objectContaining({ filters: [pinned], }), }); @@ -1137,7 +1049,7 @@ describe('Lens App', () => { }); instance.update(); expect(lensStore.getState()).toEqual({ - app: expect.objectContaining({ + lens: expect.objectContaining({ searchSessionId: `sessionId-2`, }), }); @@ -1162,30 +1074,12 @@ describe('Lens App', () => { act(() => instance.find(services.navigation.ui.TopNavMenu).prop('onClearSavedQuery')!()); instance.update(); expect(lensStore.getState()).toEqual({ - app: expect.objectContaining({ + lens: expect.objectContaining({ searchSessionId: `sessionId-4`, }), }); }); - const mockUpdate = { - filterableIndexPatterns: [], - doc: { - title: '', - description: '', - visualizationType: '', - state: { - datasourceStates: {}, - visualization: {}, - filters: [], - query: { query: '', language: 'lucene' }, - }, - references: [], - }, - isSaveable: true, - activeData: undefined, - }; - it('updates the state if session id changes from the outside', async () => { const services = makeDefaultServices(sessionIdSubject); const { lensStore } = await mountWith({ props: undefined, services }); @@ -1197,25 +1091,16 @@ describe('Lens App', () => { await new Promise((r) => setTimeout(r, 0)); }); expect(lensStore.getState()).toEqual({ - app: expect.objectContaining({ + lens: expect.objectContaining({ searchSessionId: `new-session-id`, }), }); }); it('does not update the searchSessionId when the state changes', async () => { - const { lensStore } = await mountWith({}); - act(() => { - lensStore.dispatch( - setState({ - indexPatternsForTopNav: [], - lastKnownDoc: mockUpdate.doc, - isSaveable: true, - }) - ); - }); + const { lensStore } = await mountWith({ preloadedState: { isSaveable: true } }); expect(lensStore.getState()).toEqual({ - app: expect.objectContaining({ + lens: expect.objectContaining({ searchSessionId: `sessionId-1`, }), }); @@ -1248,20 +1133,7 @@ describe('Lens App', () => { visualize: { save: false, saveQuery: false, show: true }, }, }; - const { instance, props, lensStore } = await mountWith({ services }); - act(() => { - lensStore.dispatch( - setState({ - indexPatternsForTopNav: [] as IndexPattern[], - lastKnownDoc: ({ - savedObjectId: undefined, - references: [], - } as unknown) as Document, - isSaveable: true, - }) - ); - }); - instance.update(); + const { props } = await mountWith({ services, preloadedState: { isSaveable: true } }); const lastCall = props.onAppLeave.mock.calls[props.onAppLeave.mock.calls.length - 1][0]; lastCall({ default: defaultLeave, confirm: confirmLeave }); expect(defaultLeave).toHaveBeenCalled(); @@ -1269,14 +1141,14 @@ describe('Lens App', () => { }); it('should confirm when leaving with an unsaved doc', async () => { - const { lensStore, props } = await mountWith({}); - act(() => { - lensStore.dispatch( - setState({ - lastKnownDoc: ({ savedObjectId: undefined, state: {} } as unknown) as Document, - isSaveable: true, - }) - ); + const { props } = await mountWith({ + preloadedState: { + visualization: { + activeId: 'testVis', + state: {}, + }, + isSaveable: true, + }, }); const lastCall = props.onAppLeave.mock.calls[props.onAppLeave.mock.calls.length - 1][0]; lastCall({ default: defaultLeave, confirm: confirmLeave }); @@ -1285,18 +1157,15 @@ describe('Lens App', () => { }); it('should confirm when leaving with unsaved changes to an existing doc', async () => { - const { lensStore, props } = await mountWith({}); - act(() => { - lensStore.dispatch( - setState({ - persistedDoc: defaultDoc, - lastKnownDoc: ({ - savedObjectId: defaultSavedObjectId, - references: [], - } as unknown) as Document, - isSaveable: true, - }) - ); + const { props } = await mountWith({ + preloadedState: { + persistedDoc: defaultDoc, + visualization: { + activeId: 'testVis', + state: {}, + }, + isSaveable: true, + }, }); const lastCall = props.onAppLeave.mock.calls[props.onAppLeave.mock.calls.length - 1][0]; lastCall({ default: defaultLeave, confirm: confirmLeave }); @@ -1305,15 +1174,23 @@ describe('Lens App', () => { }); it('should not confirm when changes are saved', async () => { - const { lensStore, props } = await mountWith({}); - act(() => { - lensStore.dispatch( - setState({ - lastKnownDoc: defaultDoc, - persistedDoc: defaultDoc, - isSaveable: true, - }) - ); + const { props } = await mountWith({ + preloadedState: { + persistedDoc: { + ...defaultDoc, + state: { + ...defaultDoc.state, + datasourceStates: { testDatasource: '' }, + visualization: {}, + }, + }, + isSaveable: true, + ...(defaultDoc.state as Partial<LensAppState>), + visualization: { + activeId: 'testVis', + state: {}, + }, + }, }); const lastCall = props.onAppLeave.mock.calls[props.onAppLeave.mock.calls.length - 1][0]; lastCall({ default: defaultLeave, confirm: confirmLeave }); @@ -1321,16 +1198,13 @@ describe('Lens App', () => { expect(confirmLeave).not.toHaveBeenCalled(); }); + // not sure how to test it it('should confirm when the latest doc is invalid', async () => { const { lensStore, props } = await mountWith({}); act(() => { lensStore.dispatch( setState({ persistedDoc: defaultDoc, - lastKnownDoc: ({ - savedObjectId: defaultSavedObjectId, - references: [], - } as unknown) as Document, isSaveable: true, }) ); diff --git a/x-pack/plugins/lens/public/app_plugin/app.tsx b/x-pack/plugins/lens/public/app_plugin/app.tsx index fee64a532553d..8faee830d52bb 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.tsx @@ -10,8 +10,6 @@ import './app.scss'; import { isEqual } from 'lodash'; import React, { useState, useEffect, useCallback } from 'react'; import { i18n } from '@kbn/i18n'; -import { Toast } from 'kibana/public'; -import { VisualizeFieldContext } from 'src/plugins/ui_actions/public'; import { EuiBreadcrumb } from '@elastic/eui'; import { createKbnUrlStateStorage, @@ -24,8 +22,9 @@ import { LensAppProps, LensAppServices } from './types'; import { LensTopNavMenu } from './lens_top_nav'; import { LensByReferenceInput } from '../embeddable'; import { EditorFrameInstance } from '../types'; +import { Document } from '../persistence/saved_object_store'; import { - setState as setAppState, + setState, useLensSelector, useLensDispatch, LensAppState, @@ -36,6 +35,7 @@ import { getLastKnownDocWithoutPinnedFilters, runSaveLensVisualization, } from './save_modal_container'; +import { getSavedObjectFormat } from '../utils'; export type SaveProps = Omit<OnSaveProps, 'onTitleDuplicate' | 'newDescription'> & { returnToOrigin: boolean; @@ -54,7 +54,8 @@ export function App({ incomingState, redirectToOrigin, setHeaderActionMenu, - initialContext, + datasourceMap, + visualizationMap, }: LensAppProps) { const lensAppServices = useKibana<LensAppServices>().services; @@ -73,16 +74,69 @@ export function App({ const dispatch = useLensDispatch(); const dispatchSetState: DispatchSetState = useCallback( - (state: Partial<LensAppState>) => dispatch(setAppState(state)), + (state: Partial<LensAppState>) => dispatch(setState(state)), [dispatch] ); - const appState = useLensSelector((state) => state.app); + const { + datasourceStates, + visualization, + filters, + query, + activeDatasourceId, + persistedDoc, + isLinkedToOriginatingApp, + searchSessionId, + isLoading, + isSaveable, + } = useLensSelector((state) => state.lens); // Used to show a popover that guides the user towards changing the date range when no data is available. const [indicateNoData, setIndicateNoData] = useState(false); const [isSaveModalVisible, setIsSaveModalVisible] = useState(false); - const { lastKnownDoc } = appState; + const [lastKnownDoc, setLastKnownDoc] = useState<Document | undefined>(undefined); + + useEffect(() => { + const activeVisualization = visualization.activeId && visualizationMap[visualization.activeId]; + const activeDatasource = + activeDatasourceId && !datasourceStates[activeDatasourceId].isLoading + ? datasourceMap[activeDatasourceId] + : undefined; + + if (!activeDatasource || !activeVisualization || !visualization.state) { + return; + } + setLastKnownDoc( + // todo: that should be redux store selector + getSavedObjectFormat({ + activeDatasources: Object.keys(datasourceStates).reduce( + (acc, datasourceId) => ({ + ...acc, + [datasourceId]: datasourceMap[datasourceId], + }), + {} + ), + datasourceStates, + visualization, + filters, + query, + title: persistedDoc?.title || '', + description: persistedDoc?.description, + persistedId: persistedDoc?.savedObjectId, + }) + ); + }, [ + persistedDoc?.title, + persistedDoc?.description, + persistedDoc?.savedObjectId, + datasourceStates, + visualization, + filters, + query, + activeDatasourceId, + datasourceMap, + visualizationMap, + ]); const showNoDataPopover = useCallback(() => { setIndicateNoData(true); @@ -92,30 +146,17 @@ export function App({ if (indicateNoData) { setIndicateNoData(false); } - }, [ - setIndicateNoData, - indicateNoData, - appState.indexPatternsForTopNav, - appState.searchSessionId, - ]); - - const onError = useCallback( - (e: { message: string }) => - notifications.toasts.addDanger({ - title: e.message, - }), - [notifications.toasts] - ); + }, [setIndicateNoData, indicateNoData, searchSessionId]); const getIsByValueMode = useCallback( () => Boolean( // Temporarily required until the 'by value' paradigm is default. dashboardFeatureFlag.allowByValueEmbeddables && - appState.isLinkedToOriginatingApp && + isLinkedToOriginatingApp && !(initialInput as LensByReferenceInput)?.savedObjectId ), - [dashboardFeatureFlag.allowByValueEmbeddables, appState.isLinkedToOriginatingApp, initialInput] + [dashboardFeatureFlag.allowByValueEmbeddables, isLinkedToOriginatingApp, initialInput] ); useEffect(() => { @@ -138,13 +179,11 @@ export function App({ onAppLeave((actions) => { // Confirm when the user has made any changes to an existing doc // or when the user has configured something without saving + if ( application.capabilities.visualize.save && - !isEqual( - appState.persistedDoc?.state, - getLastKnownDocWithoutPinnedFilters(lastKnownDoc)?.state - ) && - (appState.isSaveable || appState.persistedDoc) + !isEqual(persistedDoc?.state, getLastKnownDocWithoutPinnedFilters(lastKnownDoc)?.state) && + (isSaveable || persistedDoc) ) { return actions.confirm( i18n.translate('xpack.lens.app.unsavedWorkMessage', { @@ -158,19 +197,13 @@ export function App({ return actions.default(); } }); - }, [ - onAppLeave, - lastKnownDoc, - appState.isSaveable, - appState.persistedDoc, - application.capabilities.visualize.save, - ]); + }, [onAppLeave, lastKnownDoc, isSaveable, persistedDoc, application.capabilities.visualize.save]); // Sync Kibana breadcrumbs any time the saved document's title changes useEffect(() => { const isByValueMode = getIsByValueMode(); const breadcrumbs: EuiBreadcrumb[] = []; - if (appState.isLinkedToOriginatingApp && getOriginatingAppName() && redirectToOrigin) { + if (isLinkedToOriginatingApp && getOriginatingAppName() && redirectToOrigin) { breadcrumbs.push({ onClick: () => { redirectToOrigin(); @@ -193,10 +226,10 @@ export function App({ let currentDocTitle = i18n.translate('xpack.lens.breadcrumbsCreate', { defaultMessage: 'Create', }); - if (appState.persistedDoc) { + if (persistedDoc) { currentDocTitle = isByValueMode ? i18n.translate('xpack.lens.breadcrumbsByValue', { defaultMessage: 'Edit visualization' }) - : appState.persistedDoc.title; + : persistedDoc.title; } breadcrumbs.push({ text: currentDocTitle }); chrome.setBreadcrumbs(breadcrumbs); @@ -207,39 +240,55 @@ export function App({ getIsByValueMode, application, chrome, - appState.isLinkedToOriginatingApp, - appState.persistedDoc, + isLinkedToOriginatingApp, + persistedDoc, ]); - const runSave = (saveProps: SaveProps, options: { saveToLibrary: boolean }) => { - return runSaveLensVisualization( - { - lastKnownDoc, - getIsByValueMode, - savedObjectsTagging, - initialInput, - redirectToOrigin, - persistedDoc: appState.persistedDoc, - onAppLeave, - redirectTo, - originatingApp: incomingState?.originatingApp, - ...lensAppServices, - }, - saveProps, - options - ).then( - (newState) => { - if (newState) { - dispatchSetState(newState); - setIsSaveModalVisible(false); + const runSave = useCallback( + (saveProps: SaveProps, options: { saveToLibrary: boolean }) => { + return runSaveLensVisualization( + { + lastKnownDoc, + getIsByValueMode, + savedObjectsTagging, + initialInput, + redirectToOrigin, + persistedDoc, + onAppLeave, + redirectTo, + originatingApp: incomingState?.originatingApp, + ...lensAppServices, + }, + saveProps, + options + ).then( + (newState) => { + if (newState) { + dispatchSetState(newState); + setIsSaveModalVisible(false); + } + }, + () => { + // error is handled inside the modal + // so ignoring it here } - }, - () => { - // error is handled inside the modal - // so ignoring it here - } - ); - }; + ); + }, + [ + incomingState?.originatingApp, + lastKnownDoc, + persistedDoc, + getIsByValueMode, + savedObjectsTagging, + initialInput, + redirectToOrigin, + onAppLeave, + redirectTo, + lensAppServices, + dispatchSetState, + setIsSaveModalVisible, + ] + ); return ( <> @@ -253,64 +302,53 @@ export function App({ setIsSaveModalVisible={setIsSaveModalVisible} setHeaderActionMenu={setHeaderActionMenu} indicateNoData={indicateNoData} + datasourceMap={datasourceMap} + title={persistedDoc?.title} /> - {(!appState.isAppLoading || appState.persistedDoc) && ( + {(!isLoading || persistedDoc) && ( <MemoizedEditorFrameWrapper editorFrame={editorFrame} - onError={onError} showNoDataPopover={showNoDataPopover} - initialContext={initialContext} /> )} </div> - <SaveModalContainer - isVisible={isSaveModalVisible} - lensServices={lensAppServices} - originatingApp={ - appState.isLinkedToOriginatingApp ? incomingState?.originatingApp : undefined - } - isSaveable={appState.isSaveable} - runSave={runSave} - onClose={() => { - setIsSaveModalVisible(false); - }} - getAppNameFromId={() => getOriginatingAppName()} - lastKnownDoc={lastKnownDoc} - onAppLeave={onAppLeave} - persistedDoc={appState.persistedDoc} - initialInput={initialInput} - redirectTo={redirectTo} - redirectToOrigin={redirectToOrigin} - returnToOriginSwitchLabel={ - getIsByValueMode() && initialInput - ? i18n.translate('xpack.lens.app.updatePanel', { - defaultMessage: 'Update panel on {originatingAppName}', - values: { originatingAppName: getOriginatingAppName() }, - }) - : undefined - } - /> + {isSaveModalVisible && ( + <SaveModalContainer + lensServices={lensAppServices} + originatingApp={isLinkedToOriginatingApp ? incomingState?.originatingApp : undefined} + isSaveable={isSaveable} + runSave={runSave} + onClose={() => { + setIsSaveModalVisible(false); + }} + getAppNameFromId={() => getOriginatingAppName()} + lastKnownDoc={lastKnownDoc} + onAppLeave={onAppLeave} + persistedDoc={persistedDoc} + initialInput={initialInput} + redirectTo={redirectTo} + redirectToOrigin={redirectToOrigin} + returnToOriginSwitchLabel={ + getIsByValueMode() && initialInput + ? i18n.translate('xpack.lens.app.updatePanel', { + defaultMessage: 'Update panel on {originatingAppName}', + values: { originatingAppName: getOriginatingAppName() }, + }) + : undefined + } + /> + )} </> ); } const MemoizedEditorFrameWrapper = React.memo(function EditorFrameWrapper({ editorFrame, - onError, showNoDataPopover, - initialContext, }: { editorFrame: EditorFrameInstance; - onError: (e: { message: string }) => Toast; showNoDataPopover: () => void; - initialContext: VisualizeFieldContext | undefined; }) { const { EditorFrameContainer } = editorFrame; - return ( - <EditorFrameContainer - onError={onError} - showNoDataPopover={showNoDataPopover} - initialContext={initialContext} - /> - ); + return <EditorFrameContainer showNoDataPopover={showNoDataPopover} />; }); diff --git a/x-pack/plugins/lens/public/app_plugin/lens_top_nav.tsx b/x-pack/plugins/lens/public/app_plugin/lens_top_nav.tsx index ecaae04232f8a..5034069b448af 100644 --- a/x-pack/plugins/lens/public/app_plugin/lens_top_nav.tsx +++ b/x-pack/plugins/lens/public/app_plugin/lens_top_nav.tsx @@ -7,21 +7,21 @@ import { isEqual } from 'lodash'; import { i18n } from '@kbn/i18n'; -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { TopNavMenuData } from '../../../../../src/plugins/navigation/public'; import { LensAppServices, LensTopNavActions, LensTopNavMenuProps } from './types'; import { downloadMultipleAs } from '../../../../../src/plugins/share/public'; import { trackUiEvent } from '../lens_ui_telemetry'; -import { exporters } from '../../../../../src/plugins/data/public'; - +import { exporters, IndexPattern } from '../../../../../src/plugins/data/public'; import { useKibana } from '../../../../../src/plugins/kibana_react/public'; import { - setState as setAppState, + setState, useLensSelector, useLensDispatch, LensAppState, DispatchSetState, } from '../state_management'; +import { getIndexPatternsObjects, getIndexPatternsIds } from '../utils'; function getLensTopNavConfig(options: { showSaveAndReturn: boolean; @@ -127,6 +127,8 @@ export const LensTopNavMenu = ({ runSave, onAppLeave, redirectToOrigin, + datasourceMap, + title, }: LensTopNavMenuProps) => { const { data, @@ -139,19 +141,52 @@ export const LensTopNavMenu = ({ const dispatch = useLensDispatch(); const dispatchSetState: DispatchSetState = React.useCallback( - (state: Partial<LensAppState>) => dispatch(setAppState(state)), + (state: Partial<LensAppState>) => dispatch(setState(state)), [dispatch] ); + const [indexPatterns, setIndexPatterns] = useState<IndexPattern[]>([]); + const { isSaveable, isLinkedToOriginatingApp, - indexPatternsForTopNav, query, - lastKnownDoc, activeData, savedQuery, - } = useLensSelector((state) => state.app); + activeDatasourceId, + datasourceStates, + } = useLensSelector((state) => state.lens); + + useEffect(() => { + const activeDatasource = + datasourceMap && activeDatasourceId && !datasourceStates[activeDatasourceId].isLoading + ? datasourceMap[activeDatasourceId] + : undefined; + if (!activeDatasource) { + return; + } + const indexPatternIds = getIndexPatternsIds({ + activeDatasources: Object.keys(datasourceStates).reduce( + (acc, datasourceId) => ({ + ...acc, + [datasourceId]: datasourceMap[datasourceId], + }), + {} + ), + datasourceStates, + }); + const hasIndexPatternsChanged = + indexPatterns.length !== indexPatternIds.length || + indexPatternIds.some((id) => !indexPatterns.find((indexPattern) => indexPattern.id === id)); + // Update the cached index patterns if the user made a change to any of them + if (hasIndexPatternsChanged) { + getIndexPatternsObjects(indexPatternIds, data.indexPatterns).then( + ({ indexPatterns: indexPatternObjects }) => { + setIndexPatterns(indexPatternObjects); + } + ); + } + }, [datasourceStates, activeDatasourceId, data.indexPatterns, datasourceMap, indexPatterns]); const { TopNavMenu } = navigation.ui; const { from, to } = data.query.timefilter.timefilter.getTime(); @@ -190,7 +225,7 @@ export const LensTopNavMenu = ({ if (datatable) { const postFix = datatables.length > 1 ? `-${i + 1}` : ''; - memo[`${lastKnownDoc?.title || unsavedTitle}${postFix}.csv`] = { + memo[`${title || unsavedTitle}${postFix}.csv`] = { content: exporters.datatableToCSV(datatable, { csvSeparator: uiSettings.get('csv:separator', ','), quoteValues: uiSettings.get('csv:quoteValues', true), @@ -208,14 +243,14 @@ export const LensTopNavMenu = ({ } }, saveAndReturn: () => { - if (savingToDashboardPermitted && lastKnownDoc) { + if (savingToDashboardPermitted) { // disabling the validation on app leave because the document has been saved. onAppLeave((actions) => { return actions.default(); }); runSave( { - newTitle: lastKnownDoc.title, + newTitle: title || '', newCopyOnSave: false, isTitleDuplicateConfirmed: false, returnToOrigin: true, @@ -248,7 +283,7 @@ export const LensTopNavMenu = ({ initialInput, isLinkedToOriginatingApp, isSaveable, - lastKnownDoc, + title, onAppLeave, redirectToOrigin, runSave, @@ -321,7 +356,7 @@ export const LensTopNavMenu = ({ onSaved={onSavedWrapped} onSavedQueryUpdated={onSavedQueryUpdatedWrapped} onClearSavedQuery={onClearSavedQueryWrapped} - indexPatterns={indexPatternsForTopNav} + indexPatterns={indexPatterns} query={query} dateRangeFrom={from} dateRangeTo={to} diff --git a/x-pack/plugins/lens/public/app_plugin/mounter.test.tsx b/x-pack/plugins/lens/public/app_plugin/mounter.test.tsx index 4f890a51f9b6a..03eec4f617cfc 100644 --- a/x-pack/plugins/lens/public/app_plugin/mounter.test.tsx +++ b/x-pack/plugins/lens/public/app_plugin/mounter.test.tsx @@ -4,45 +4,150 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { makeDefaultServices, mockLensStore } from '../mocks'; +import { makeDefaultServices, makeLensStore, defaultDoc, createMockVisualization } from '../mocks'; +import { createMockDatasource, DatasourceMock } from '../mocks'; import { act } from 'react-dom/test-utils'; -import { loadDocument } from './mounter'; +import { loadInitialStore } from './mounter'; import { LensEmbeddableInput } from '../embeddable/embeddable'; const defaultSavedObjectId = '1234'; +const preloadedState = { + isLoading: true, + visualization: { + state: null, + activeId: 'testVis', + }, +}; describe('Mounter', () => { const byValueFlag = { allowByValueEmbeddables: true }; - describe('loadDocument', () => { + const mockDatasource: DatasourceMock = createMockDatasource('testDatasource'); + const mockDatasource2: DatasourceMock = createMockDatasource('testDatasource2'); + const datasourceMap = { + testDatasource2: mockDatasource2, + testDatasource: mockDatasource, + }; + const mockVisualization = { + ...createMockVisualization(), + id: 'testVis', + visualizationTypes: [ + { + icon: 'empty', + id: 'testVis', + label: 'TEST1', + groupLabel: 'testVisGroup', + }, + ], + }; + const mockVisualization2 = { + ...createMockVisualization(), + id: 'testVis2', + visualizationTypes: [ + { + icon: 'empty', + id: 'testVis2', + label: 'TEST2', + groupLabel: 'testVis2Group', + }, + ], + }; + const visualizationMap = { + testVis: mockVisualization, + testVis2: mockVisualization2, + }; + + it('should initialize initial datasource', async () => { + const services = makeDefaultServices(); + const redirectCallback = jest.fn(); + services.attributeService.unwrapAttributes = jest.fn().mockResolvedValue(defaultDoc); + + const lensStore = await makeLensStore({ + data: services.data, + preloadedState, + }); + await act(async () => { + await loadInitialStore( + redirectCallback, + undefined, + services, + lensStore, + undefined, + byValueFlag, + datasourceMap, + visualizationMap + ); + }); + expect(mockDatasource.initialize).toHaveBeenCalled(); + }); + + it('should have initialized only the initial datasource and visualization', async () => { + const services = makeDefaultServices(); + const redirectCallback = jest.fn(); + services.attributeService.unwrapAttributes = jest.fn().mockResolvedValue(defaultDoc); + + const lensStore = await makeLensStore({ data: services.data, preloadedState }); + await act(async () => { + await loadInitialStore( + redirectCallback, + undefined, + services, + lensStore, + undefined, + byValueFlag, + datasourceMap, + visualizationMap + ); + }); + expect(mockDatasource.initialize).toHaveBeenCalled(); + expect(mockDatasource2.initialize).not.toHaveBeenCalled(); + + expect(mockVisualization.initialize).toHaveBeenCalled(); + expect(mockVisualization2.initialize).not.toHaveBeenCalled(); + }); + + // it('should initialize all datasources with state from doc', async () => {}) + // it('should pass the datasource api for each layer to the visualization', async () => {}) + // it('should create a separate datasource public api for each layer', async () => {}) + // it('should not initialize visualization before datasource is initialized', async () => {}) + // it('should pass the public frame api into visualization initialize', async () => {}) + // it('should fetch suggestions of currently active datasource when initializes from visualization trigger', async () => {}) + // it.skip('should pass the datasource api for each layer to the visualization', async () => {}) + // it('displays errors from the frame in a toast', async () => { + + describe('loadInitialStore', () => { it('does not load a document if there is no initial input', async () => { const services = makeDefaultServices(); const redirectCallback = jest.fn(); - const lensStore = mockLensStore({ data: services.data }); - await loadDocument(redirectCallback, undefined, services, lensStore, undefined, byValueFlag); + const lensStore = makeLensStore({ data: services.data, preloadedState }); + await loadInitialStore( + redirectCallback, + undefined, + services, + lensStore, + undefined, + byValueFlag, + datasourceMap, + visualizationMap + ); expect(services.attributeService.unwrapAttributes).not.toHaveBeenCalled(); }); it('loads a document and uses query and filters if initial input is provided', async () => { const services = makeDefaultServices(); const redirectCallback = jest.fn(); - services.attributeService.unwrapAttributes = jest.fn().mockResolvedValue({ - savedObjectId: defaultSavedObjectId, - state: { - query: 'fake query', - filters: [{ query: { match_phrase: { src: 'test' } } }], - }, - references: [{ type: 'index-pattern', id: '1', name: 'index-pattern-0' }], - }); + services.attributeService.unwrapAttributes = jest.fn().mockResolvedValue(defaultDoc); - const lensStore = await mockLensStore({ data: services.data }); + const lensStore = await makeLensStore({ data: services.data, preloadedState }); await act(async () => { - await loadDocument( + await loadInitialStore( redirectCallback, { savedObjectId: defaultSavedObjectId } as LensEmbeddableInput, services, lensStore, undefined, - byValueFlag + byValueFlag, + datasourceMap, + visualizationMap ); }); @@ -50,21 +155,16 @@ describe('Mounter', () => { savedObjectId: defaultSavedObjectId, }); - expect(services.data.indexPatterns.get).toHaveBeenCalledWith('1'); - expect(services.data.query.filterManager.setAppFilters).toHaveBeenCalledWith([ { query: { match_phrase: { src: 'test' } } }, ]); expect(lensStore.getState()).toEqual({ - app: expect.objectContaining({ - persistedDoc: expect.objectContaining({ - savedObjectId: defaultSavedObjectId, - state: expect.objectContaining({ - query: 'fake query', - filters: [{ query: { match_phrase: { src: 'test' } } }], - }), - }), + lens: expect.objectContaining({ + persistedDoc: { ...defaultDoc, type: 'lens' }, + query: 'kuery', + isLoading: false, + activeDatasourceId: 'testDatasource', }), }); }); @@ -72,40 +172,46 @@ describe('Mounter', () => { it('does not load documents on sequential renders unless the id changes', async () => { const redirectCallback = jest.fn(); const services = makeDefaultServices(); - const lensStore = mockLensStore({ data: services.data }); + const lensStore = makeLensStore({ data: services.data, preloadedState }); await act(async () => { - await loadDocument( + await loadInitialStore( redirectCallback, { savedObjectId: defaultSavedObjectId } as LensEmbeddableInput, services, lensStore, undefined, - byValueFlag + byValueFlag, + datasourceMap, + visualizationMap ); }); await act(async () => { - await loadDocument( + await loadInitialStore( redirectCallback, { savedObjectId: defaultSavedObjectId } as LensEmbeddableInput, services, lensStore, undefined, - byValueFlag + byValueFlag, + datasourceMap, + visualizationMap ); }); expect(services.attributeService.unwrapAttributes).toHaveBeenCalledTimes(1); await act(async () => { - await loadDocument( + await loadInitialStore( redirectCallback, { savedObjectId: '5678' } as LensEmbeddableInput, services, lensStore, undefined, - byValueFlag + byValueFlag, + datasourceMap, + visualizationMap ); }); @@ -116,18 +222,20 @@ describe('Mounter', () => { const services = makeDefaultServices(); const redirectCallback = jest.fn(); - const lensStore = mockLensStore({ data: services.data }); + const lensStore = makeLensStore({ data: services.data, preloadedState }); services.attributeService.unwrapAttributes = jest.fn().mockRejectedValue('failed to load'); await act(async () => { - await loadDocument( + await loadInitialStore( redirectCallback, { savedObjectId: defaultSavedObjectId } as LensEmbeddableInput, services, lensStore, undefined, - byValueFlag + byValueFlag, + datasourceMap, + visualizationMap ); }); expect(services.attributeService.unwrapAttributes).toHaveBeenCalledWith({ @@ -141,15 +249,17 @@ describe('Mounter', () => { const redirectCallback = jest.fn(); const services = makeDefaultServices(); - const lensStore = mockLensStore({ data: services.data }); + const lensStore = makeLensStore({ data: services.data, preloadedState }); await act(async () => { - await loadDocument( + await loadInitialStore( redirectCallback, ({ savedObjectId: defaultSavedObjectId } as unknown) as LensEmbeddableInput, services, lensStore, undefined, - byValueFlag + byValueFlag, + datasourceMap, + visualizationMap ); }); diff --git a/x-pack/plugins/lens/public/app_plugin/mounter.tsx b/x-pack/plugins/lens/public/app_plugin/mounter.tsx index 7f27b06c51ba4..1fd12460ba3b6 100644 --- a/x-pack/plugins/lens/public/app_plugin/mounter.tsx +++ b/x-pack/plugins/lens/public/app_plugin/mounter.tsx @@ -23,7 +23,7 @@ import { Storage } from '../../../../../src/plugins/kibana_utils/public'; import { LensReportManager, setReportManager, trackUiEvent } from '../lens_ui_telemetry'; import { App } from './app'; -import { EditorFrameStart } from '../types'; +import { Datasource, EditorFrameStart, Visualization } from '../types'; import { addHelpMenuToAppChrome } from '../help_menu_util'; import { LensPluginStartDependencies } from '../plugin'; import { LENS_EMBEDDABLE_TYPE, LENS_EDIT_BY_VALUE, APP_ID } from '../../common'; @@ -32,7 +32,10 @@ import { LensByReferenceInput, LensByValueInput, } from '../embeddable/embeddable'; -import { ACTION_VISUALIZE_LENS_FIELD } from '../../../../../src/plugins/ui_actions/public'; +import { + ACTION_VISUALIZE_LENS_FIELD, + VisualizeFieldContext, +} from '../../../../../src/plugins/ui_actions/public'; import { LensAttributeService } from '../lens_attribute_service'; import { LensAppServices, RedirectToOriginProps, HistoryLocationState } from './types'; import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public'; @@ -43,9 +46,18 @@ import { getPreloadedState, LensRootStore, setState, + LensAppState, + updateLayer, + updateVisualizationState, } from '../state_management'; -import { getResolvedDateRange } from '../utils'; -import { getLastKnownDoc } from './save_modal_container'; +import { getPersistedDoc } from './save_modal_container'; +import { getResolvedDateRange, getInitialDatasourceId } from '../utils'; +import { initializeDatasources } from '../editor_frame_service/editor_frame'; +import { generateId } from '../id_generator'; +import { + getVisualizeFieldSuggestions, + switchToSuggestion, +} from '../editor_frame_service/editor_frame/suggestion_helpers'; export async function getLensServices( coreStart: CoreStart, @@ -166,7 +178,19 @@ export async function mountApp( if (!initialContext) { data.query.filterManager.setAppFilters([]); } + const { datasourceMap, visualizationMap } = instance; + + const initialDatasourceId = getInitialDatasourceId(datasourceMap); + const datasourceStates: LensAppState['datasourceStates'] = {}; + if (initialDatasourceId) { + datasourceStates[initialDatasourceId] = { + state: null, + isLoading: true, + }; + } + const preloadedState = getPreloadedState({ + isLoading: true, query: data.query.queryString.getQuery(), // Do not use app-specific filters from previous app, // only if Lens was opened with the intention to visualize a field (e.g. coming from Discover) @@ -176,10 +200,15 @@ export async function mountApp( searchSessionId: data.search.session.getSessionId(), resolvedDateRange: getResolvedDateRange(data.query.timefilter.timefilter), isLinkedToOriginatingApp: Boolean(embeddableEditorIncomingState?.originatingApp), + activeDatasourceId: initialDatasourceId, + datasourceStates, + visualization: { + state: null, + activeId: Object.keys(visualizationMap)[0] || null, + }, }); const lensStore: LensRootStore = makeConfigureStore(preloadedState, { data }); - const EditorRenderer = React.memo( (props: { id?: string; history: History<unknown>; editByValue?: boolean }) => { const redirectCallback = useCallback( @@ -190,14 +219,18 @@ export async function mountApp( ); trackUiEvent('loaded'); const initialInput = getInitialInput(props.id, props.editByValue); - loadDocument( + loadInitialStore( redirectCallback, initialInput, lensServices, lensStore, embeddableEditorIncomingState, - dashboardFeatureFlag + dashboardFeatureFlag, + datasourceMap, + visualizationMap, + initialContext ); + return ( <Provider store={lensStore}> <App @@ -209,7 +242,8 @@ export async function mountApp( onAppLeave={params.onAppLeave} setHeaderActionMenu={params.setHeaderActionMenu} history={props.history} - initialContext={initialContext} + datasourceMap={datasourceMap} + visualizationMap={visualizationMap} /> </Provider> ); @@ -270,64 +304,180 @@ export async function mountApp( }; } -export function loadDocument( +export function loadInitialStore( redirectCallback: (savedObjectId?: string) => void, initialInput: LensEmbeddableInput | undefined, lensServices: LensAppServices, lensStore: LensRootStore, embeddableEditorIncomingState: EmbeddableEditorState | undefined, - dashboardFeatureFlag: DashboardFeatureFlagConfig + dashboardFeatureFlag: DashboardFeatureFlagConfig, + datasourceMap: Record<string, Datasource>, + visualizationMap: Record<string, Visualization>, + initialContext?: VisualizeFieldContext ) { const { attributeService, chrome, notifications, data } = lensServices; - const { persistedDoc } = lensStore.getState().app; + const { persistedDoc } = lensStore.getState().lens; if ( !initialInput || (attributeService.inputIsRefType(initialInput) && initialInput.savedObjectId === persistedDoc?.savedObjectId) ) { - return; + return initializeDatasources( + datasourceMap, + lensStore.getState().lens.datasourceStates, + undefined, + initialContext, + { + isFullEditor: true, + } + ) + .then((result) => { + const datasourceStates = Object.entries(result).reduce( + (state, [datasourceId, datasourceState]) => ({ + ...state, + [datasourceId]: { + ...datasourceState, + isLoading: false, + }, + }), + {} + ); + lensStore.dispatch( + setState({ + datasourceStates, + isLoading: false, + }) + ); + if (initialContext) { + const selectedSuggestion = getVisualizeFieldSuggestions({ + datasourceMap, + datasourceStates, + visualizationMap, + activeVisualizationId: Object.keys(visualizationMap)[0] || null, + visualizationState: null, + visualizeTriggerFieldContext: initialContext, + }); + if (selectedSuggestion) { + switchToSuggestion(lensStore.dispatch, selectedSuggestion, 'SWITCH_VISUALIZATION'); + } + } + const activeDatasourceId = getInitialDatasourceId(datasourceMap); + const visualization = lensStore.getState().lens.visualization; + const activeVisualization = + visualization.activeId && visualizationMap[visualization.activeId]; + + if (visualization.state === null && activeVisualization) { + const newLayerId = generateId(); + + const initialVisualizationState = activeVisualization.initialize(() => newLayerId); + lensStore.dispatch( + updateLayer({ + datasourceId: activeDatasourceId!, + layerId: newLayerId, + updater: datasourceMap[activeDatasourceId!].insertLayer, + }) + ); + lensStore.dispatch( + updateVisualizationState({ + visualizationId: activeVisualization.id, + updater: initialVisualizationState, + }) + ); + } + }) + .catch((e: { message: string }) => { + notifications.toasts.addDanger({ + title: e.message, + }); + redirectCallback(); + }); } - lensStore.dispatch(setState({ isAppLoading: true })); - getLastKnownDoc({ + getPersistedDoc({ initialInput, attributeService, data, chrome, notifications, - }).then( - (newState) => { - if (newState) { - const { doc, indexPatterns } = newState; - const currentSessionId = data.search.session.getSessionId(); + }) + .then( + (doc) => { + if (doc) { + const currentSessionId = data.search.session.getSessionId(); + const docDatasourceStates = Object.entries(doc.state.datasourceStates).reduce( + (stateMap, [datasourceId, datasourceState]) => ({ + ...stateMap, + [datasourceId]: { + isLoading: true, + state: datasourceState, + }, + }), + {} + ); + + initializeDatasources( + datasourceMap, + docDatasourceStates, + doc.references, + initialContext, + { + isFullEditor: true, + } + ) + .then((result) => { + const activeDatasourceId = getInitialDatasourceId(datasourceMap, doc); + + lensStore.dispatch( + setState({ + query: doc.state.query, + searchSessionId: + dashboardFeatureFlag.allowByValueEmbeddables && + Boolean(embeddableEditorIncomingState?.originatingApp) && + !(initialInput as LensByReferenceInput)?.savedObjectId && + currentSessionId + ? currentSessionId + : data.search.session.start(), + ...(!isEqual(persistedDoc, doc) ? { persistedDoc: doc } : null), + activeDatasourceId, + visualization: { + activeId: doc.visualizationType, + state: doc.state.visualization, + }, + datasourceStates: Object.entries(result).reduce( + (state, [datasourceId, datasourceState]) => ({ + ...state, + [datasourceId]: { + ...datasourceState, + isLoading: false, + }, + }), + {} + ), + isLoading: false, + }) + ); + }) + .catch((e: { message: string }) => + notifications.toasts.addDanger({ + title: e.message, + }) + ); + } else { + redirectCallback(); + } + }, + () => { lensStore.dispatch( setState({ - query: doc.state.query, - isAppLoading: false, - indexPatternsForTopNav: indexPatterns, - lastKnownDoc: doc, - searchSessionId: - dashboardFeatureFlag.allowByValueEmbeddables && - Boolean(embeddableEditorIncomingState?.originatingApp) && - !(initialInput as LensByReferenceInput)?.savedObjectId && - currentSessionId - ? currentSessionId - : data.search.session.start(), - ...(!isEqual(persistedDoc, doc) ? { persistedDoc: doc } : null), + isLoading: false, }) ); - } else { redirectCallback(); } - }, - () => { - lensStore.dispatch( - setState({ - isAppLoading: false, - }) - ); - - redirectCallback(); - } - ); + ) + .catch((e: { message: string }) => + notifications.toasts.addDanger({ + title: e.message, + }) + ); } diff --git a/x-pack/plugins/lens/public/app_plugin/save_modal.tsx b/x-pack/plugins/lens/public/app_plugin/save_modal.tsx index cb4c5325aefbb..124702e0dd90e 100644 --- a/x-pack/plugins/lens/public/app_plugin/save_modal.tsx +++ b/x-pack/plugins/lens/public/app_plugin/save_modal.tsx @@ -7,8 +7,6 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; - -import { Document } from '../persistence'; import type { SavedObjectTaggingPluginStart } from '../../../saved_objects_tagging/public'; import { @@ -23,7 +21,6 @@ import { export type SaveProps = OriginSaveProps | DashboardSaveProps; export interface Props { - isVisible: boolean; savingToLibraryPermitted?: boolean; originatingApp?: string; @@ -32,7 +29,9 @@ export interface Props { savedObjectsTagging?: SavedObjectTaggingPluginStart; tagsIds: string[]; - lastKnownDoc?: Document; + title?: string; + savedObjectId?: string; + description?: string; getAppNameFromId: () => string | undefined; returnToOriginSwitchLabel?: string; @@ -42,16 +41,14 @@ export interface Props { } export const SaveModal = (props: Props) => { - if (!props.isVisible || !props.lastKnownDoc) { - return null; - } - const { originatingApp, savingToLibraryPermitted, savedObjectsTagging, tagsIds, - lastKnownDoc, + savedObjectId, + title, + description, allowByValueEmbeddables, returnToOriginSwitchLabel, getAppNameFromId, @@ -70,9 +67,9 @@ export const SaveModal = (props: Props) => { onSave={(saveProps) => onSave(saveProps, { saveToLibrary: true })} getAppNameFromId={getAppNameFromId} documentInfo={{ - id: lastKnownDoc.savedObjectId, - title: lastKnownDoc.title || '', - description: lastKnownDoc.description || '', + id: savedObjectId, + title: title || '', + description: description || '', }} returnToOriginSwitchLabel={returnToOriginSwitchLabel} objectType={i18n.translate('xpack.lens.app.saveModalType', { @@ -95,9 +92,9 @@ export const SaveModal = (props: Props) => { onClose={onClose} documentInfo={{ // if the user cannot save to the library - treat this as a new document. - id: savingToLibraryPermitted ? lastKnownDoc.savedObjectId : undefined, - title: lastKnownDoc.title || '', - description: lastKnownDoc.description || '', + id: savingToLibraryPermitted ? savedObjectId : undefined, + title: title || '', + description: description || '', }} objectType={i18n.translate('xpack.lens.app.saveModalType', { defaultMessage: 'Lens visualization', diff --git a/x-pack/plugins/lens/public/app_plugin/save_modal_container.tsx b/x-pack/plugins/lens/public/app_plugin/save_modal_container.tsx index facf85d45bcbb..2912daccf8899 100644 --- a/x-pack/plugins/lens/public/app_plugin/save_modal_container.tsx +++ b/x-pack/plugins/lens/public/app_plugin/save_modal_container.tsx @@ -8,21 +8,16 @@ import React, { useEffect, useState } from 'react'; import { ChromeStart, NotificationsStart } from 'kibana/public'; import { i18n } from '@kbn/i18n'; -import { partition, uniq } from 'lodash'; import { METRIC_TYPE } from '@kbn/analytics'; +import { partition } from 'lodash'; import { SaveModal } from './save_modal'; import { LensAppProps, LensAppServices } from './types'; import type { SaveProps } from './app'; import { Document, injectFilterReferences } from '../persistence'; import { LensByReferenceInput, LensEmbeddableInput } from '../embeddable'; import { LensAttributeService } from '../lens_attribute_service'; -import { - DataPublicPluginStart, - esFilters, - IndexPattern, -} from '../../../../../src/plugins/data/public'; +import { DataPublicPluginStart, esFilters } from '../../../../../src/plugins/data/public'; import { APP_ID, getFullPath, LENS_EMBEDDABLE_TYPE } from '../../common'; -import { getAllIndexPatterns } from '../utils'; import { trackUiEvent } from '../lens_ui_telemetry'; import { checkForDuplicateTitle } from '../../../../../src/plugins/saved_objects/public'; import { LensAppState } from '../state_management'; @@ -31,7 +26,6 @@ type ExtraProps = Pick<LensAppProps, 'initialInput'> & Partial<Pick<LensAppProps, 'redirectToOrigin' | 'redirectTo' | 'onAppLeave'>>; export type SaveModalContainerProps = { - isVisible: boolean; originatingApp?: string; persistedDoc?: Document; lastKnownDoc?: Document; @@ -49,7 +43,6 @@ export function SaveModalContainer({ onClose, onSave, runSave, - isVisible, persistedDoc, originatingApp, initialInput, @@ -61,6 +54,14 @@ export function SaveModalContainer({ lensServices, }: SaveModalContainerProps) { const [lastKnownDoc, setLastKnownDoc] = useState<Document | undefined>(initLastKnowDoc); + let title = ''; + let description; + let savedObjectId; + if (lastKnownDoc) { + title = lastKnownDoc.title; + description = lastKnownDoc.description; + savedObjectId = lastKnownDoc.savedObjectId; + } const { attributeService, @@ -77,22 +78,26 @@ export function SaveModalContainer({ }, [initLastKnowDoc]); useEffect(() => { - async function loadLastKnownDoc() { - if (initialInput && isVisible) { - getLastKnownDoc({ + let isMounted = true; + async function loadPersistedDoc() { + if (initialInput) { + getPersistedDoc({ data, initialInput, chrome, notifications, attributeService, - }).then((result) => { - if (result) setLastKnownDoc(result.doc); + }).then((doc) => { + if (doc && isMounted) setLastKnownDoc(doc); }); } } - loadLastKnownDoc(); - }, [chrome, data, initialInput, notifications, attributeService, isVisible]); + loadPersistedDoc(); + return () => { + isMounted = false; + }; + }, [chrome, data, initialInput, notifications, attributeService]); const tagsIds = persistedDoc && savedObjectsTagging @@ -131,7 +136,6 @@ export function SaveModalContainer({ return ( <SaveModal - isVisible={isVisible} originatingApp={originatingApp} savingToLibraryPermitted={savingToLibraryPermitted} allowByValueEmbeddables={dashboardFeatureFlag?.allowByValueEmbeddables} @@ -142,7 +146,9 @@ export function SaveModalContainer({ }} onClose={onClose} getAppNameFromId={getAppNameFromId} - lastKnownDoc={lastKnownDoc} + title={title} + description={description} + savedObjectId={savedObjectId} returnToOriginSwitchLabel={returnToOriginSwitchLabel} /> ); @@ -330,7 +336,10 @@ export const runSaveLensVisualization = async ( ...newInput, }; - return { persistedDoc: newDoc, lastKnownDoc: newDoc, isLinkedToOriginatingApp: false }; + return { + persistedDoc: newDoc, + isLinkedToOriginatingApp: false, + }; } catch (e) { // eslint-disable-next-line no-console console.dir(e); @@ -356,7 +365,7 @@ export function getLastKnownDocWithoutPinnedFilters(doc?: Document) { : doc; } -export const getLastKnownDoc = async ({ +export const getPersistedDoc = async ({ initialInput, attributeService, data, @@ -368,7 +377,7 @@ export const getLastKnownDoc = async ({ data: DataPublicPluginStart; notifications: NotificationsStart; chrome: ChromeStart; -}): Promise<{ doc: Document; indexPatterns: IndexPattern[] } | undefined> => { +}): Promise<Document | undefined> => { let doc: Document; try { @@ -387,19 +396,12 @@ export const getLastKnownDoc = async ({ initialInput.savedObjectId ); } - const indexPatternIds = uniq( - doc.references.filter(({ type }) => type === 'index-pattern').map(({ id }) => id) - ); - const { indexPatterns } = await getAllIndexPatterns(indexPatternIds, data.indexPatterns); // Don't overwrite any pinned filters data.query.filterManager.setAppFilters( injectFilterReferences(doc.state.filters, doc.references) ); - return { - doc, - indexPatterns, - }; + return doc; } catch (e) { notifications.toasts.addDanger( i18n.translate('xpack.lens.app.docLoadingError', { diff --git a/x-pack/plugins/lens/public/app_plugin/types.ts b/x-pack/plugins/lens/public/app_plugin/types.ts index b4e7f18ccfeb8..7f1c21fa5a9bd 100644 --- a/x-pack/plugins/lens/public/app_plugin/types.ts +++ b/x-pack/plugins/lens/public/app_plugin/types.ts @@ -34,7 +34,7 @@ import { EmbeddableEditorState, EmbeddableStateTransfer, } from '../../../../../src/plugins/embeddable/public'; -import { EditorFrameInstance } from '../types'; +import { Datasource, EditorFrameInstance, Visualization } from '../types'; import { PresentationUtilPluginStart } from '../../../../../src/plugins/presentation_util/public'; export interface RedirectToOriginProps { input?: LensEmbeddableInput; @@ -54,7 +54,8 @@ export interface LensAppProps { // State passed in by the container which is used to determine the id of the Originating App. incomingState?: EmbeddableEditorState; - initialContext?: VisualizeFieldContext; + datasourceMap: Record<string, Datasource>; + visualizationMap: Record<string, Visualization>; } export type RunSave = ( @@ -81,6 +82,8 @@ export interface LensTopNavMenuProps { indicateNoData: boolean; setIsSaveModalVisible: React.Dispatch<React.SetStateAction<boolean>>; runSave: RunSave; + datasourceMap: Record<string, Datasource>; + title?: string; } export interface HistoryLocationState { diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.test.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.test.tsx index 3479a9e964d53..d755c5c297d04 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.test.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.test.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { EuiButtonGroup, EuiComboBox, EuiFieldText } from '@elastic/eui'; import { FramePublicAPI, Operation, VisualizationDimensionEditorProps } from '../../types'; import { DatatableVisualizationState } from '../visualization'; -import { createMockDatasource, createMockFramePublicAPI } from '../../editor_frame_service/mocks'; +import { createMockDatasource, createMockFramePublicAPI } from '../../mocks'; import { mountWithIntl } from '@kbn/test/jest'; import { TableDimensionEditor } from './dimension_editor'; import { chartPluginMock } from 'src/plugins/charts/public/mocks'; diff --git a/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx b/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx index ea8237defc291..552f0f94a67de 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx @@ -7,7 +7,7 @@ import { Ast } from '@kbn/interpreter/common'; import { buildExpression } from '../../../../../src/plugins/expressions/public'; -import { createMockDatasource, createMockFramePublicAPI } from '../editor_frame_service/mocks'; +import { createMockDatasource, createMockFramePublicAPI } from '../mocks'; import { DatatableVisualizationState, getDatatableVisualization } from './visualization'; import { Operation, @@ -21,8 +21,6 @@ import { chartPluginMock } from 'src/plugins/charts/public/mocks'; function mockFrame(): FramePublicAPI { return { ...createMockFramePublicAPI(), - addNewLayer: () => 'aaa', - removeLayers: () => {}, datasourceLayers: {}, query: { query: '', language: 'lucene' }, dateRange: { @@ -40,7 +38,7 @@ const datatableVisualization = getDatatableVisualization({ describe('Datatable Visualization', () => { describe('#initialize', () => { it('should initialize from the empty state', () => { - expect(datatableVisualization.initialize(mockFrame(), undefined)).toEqual({ + expect(datatableVisualization.initialize(() => 'aaa', undefined)).toEqual({ layerId: 'aaa', columns: [], }); @@ -51,7 +49,7 @@ describe('Datatable Visualization', () => { layerId: 'foo', columns: [{ columnId: 'saved' }], }; - expect(datatableVisualization.initialize(mockFrame(), expectedState)).toEqual(expectedState); + expect(datatableVisualization.initialize(() => 'foo', expectedState)).toEqual(expectedState); }); }); diff --git a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx index e48cb1b28c084..e7ab4aab88f2e 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx @@ -101,11 +101,11 @@ export const getDatatableVisualization = ({ switchVisualizationType: (_, state) => state, - initialize(frame, state) { + initialize(addNewLayer, state) { return ( state || { columns: [], - layerId: frame.addNewLayer(), + layerId: addNewLayer(), } ); }, diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.test.tsx index 1ec48f516bd32..25d99ed9bfd41 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.test.tsx @@ -12,13 +12,13 @@ import { createMockFramePublicAPI, createMockDatasource, DatasourceMock, -} from '../../mocks'; +} from '../../../mocks'; import { Visualization } from '../../../types'; -import { mountWithIntl } from '@kbn/test/jest'; import { LayerPanels } from './config_panel'; import { LayerPanel } from './layer_panel'; import { coreMock } from 'src/core/public/mocks'; import { generateId } from '../../../id_generator'; +import { mountWithProvider } from '../../../mocks'; jest.mock('../../../id_generator'); @@ -54,17 +54,17 @@ describe('ConfigPanel', () => { vis1: mockVisualization, vis2: mockVisualization2, }, - activeDatasourceId: 'ds1', + activeDatasourceId: 'mockindexpattern', datasourceMap: { - ds1: mockDatasource, + mockindexpattern: mockDatasource, }, activeVisualization: ({ ...mockVisualization, getLayerIds: () => Object.keys(frame.datasourceLayers), - appendLayer: true, + appendLayer: jest.fn(), } as unknown) as Visualization, datasourceStates: { - ds1: { + mockindexpattern: { isLoading: false, state: 'state', }, @@ -110,113 +110,184 @@ describe('ConfigPanel', () => { }; mockVisualization.getLayerIds.mockReturnValue(Object.keys(frame.datasourceLayers)); - mockDatasource = createMockDatasource('ds1'); + mockDatasource = createMockDatasource('mockindexpattern'); }); // in what case is this test needed? - it('should fail to render layerPanels if the public API is out of date', () => { + it('should fail to render layerPanels if the public API is out of date', async () => { const props = getDefaultProps(); props.framePublicAPI.datasourceLayers = {}; - const component = mountWithIntl(<LayerPanels {...props} />); - expect(component.find(LayerPanel).exists()).toBe(false); + const { instance } = await mountWithProvider(<LayerPanels {...props} />); + expect(instance.find(LayerPanel).exists()).toBe(false); }); it('allow datasources and visualizations to use setters', async () => { const props = getDefaultProps(); - const component = mountWithIntl(<LayerPanels {...props} />); - const { updateDatasource, updateAll } = component.find(LayerPanel).props(); + const { instance, lensStore } = await mountWithProvider(<LayerPanels {...props} />, { + preloadedState: { + datasourceStates: { + mockindexpattern: { + isLoading: false, + state: 'state', + }, + }, + }, + }); + const { updateDatasource, updateAll } = instance.find(LayerPanel).props(); const updater = () => 'updated'; - updateDatasource('ds1', updater); - // wait for one tick so async updater has a chance to trigger + updateDatasource('mockindexpattern', updater); await new Promise((r) => setTimeout(r, 0)); - expect(props.dispatch).toHaveBeenCalledTimes(1); - expect(props.dispatch.mock.calls[0][0].updater(props.datasourceStates.ds1.state)).toEqual( - 'updated' - ); + expect(lensStore.dispatch).toHaveBeenCalledTimes(1); + expect( + (lensStore.dispatch as jest.Mock).mock.calls[0][0].payload.updater( + props.datasourceStates.mockindexpattern.state + ) + ).toEqual('updated'); - updateAll('ds1', updater, props.visualizationState); + updateAll('mockindexpattern', updater, props.visualizationState); // wait for one tick so async updater has a chance to trigger await new Promise((r) => setTimeout(r, 0)); - expect(props.dispatch).toHaveBeenCalledTimes(2); - expect(props.dispatch.mock.calls[0][0].updater(props.datasourceStates.ds1.state)).toEqual( - 'updated' - ); + expect(lensStore.dispatch).toHaveBeenCalledTimes(2); + expect( + (lensStore.dispatch as jest.Mock).mock.calls[0][0].payload.updater( + props.datasourceStates.mockindexpattern.state + ) + ).toEqual('updated'); }); describe('focus behavior when adding or removing layers', () => { - it('should focus the only layer when resetting the layer', () => { - const component = mountWithIntl(<LayerPanels {...getDefaultProps()} />, { - attachTo: container, - }); - const firstLayerFocusable = component + it('should focus the only layer when resetting the layer', async () => { + const { instance } = await mountWithProvider( + <LayerPanels {...getDefaultProps()} />, + { + preloadedState: { + datasourceStates: { + mockindexpattern: { + isLoading: false, + state: 'state', + }, + }, + }, + }, + { + attachTo: container, + } + ); + const firstLayerFocusable = instance .find(LayerPanel) .first() .find('section') .first() .instance(); act(() => { - component.find('[data-test-subj="lnsLayerRemove"]').first().simulate('click'); + instance.find('[data-test-subj="lnsLayerRemove"]').first().simulate('click'); }); const focusedEl = document.activeElement; expect(focusedEl).toEqual(firstLayerFocusable); }); - it('should focus the second layer when removing the first layer', () => { + it('should focus the second layer when removing the first layer', async () => { const defaultProps = getDefaultProps(); // overwriting datasourceLayers to test two layers frame.datasourceLayers = { first: mockDatasource.publicAPIMock, second: mockDatasource.publicAPIMock, }; - const component = mountWithIntl(<LayerPanels {...defaultProps} />, { attachTo: container }); - const secondLayerFocusable = component + const { instance } = await mountWithProvider( + <LayerPanels {...defaultProps} />, + { + preloadedState: { + datasourceStates: { + mockindexpattern: { + isLoading: false, + state: 'state', + }, + }, + }, + }, + { + attachTo: container, + } + ); + + const secondLayerFocusable = instance .find(LayerPanel) .at(1) .find('section') .first() .instance(); act(() => { - component.find('[data-test-subj="lnsLayerRemove"]').at(0).simulate('click'); + instance.find('[data-test-subj="lnsLayerRemove"]').at(0).simulate('click'); }); const focusedEl = document.activeElement; expect(focusedEl).toEqual(secondLayerFocusable); }); - it('should focus the first layer when removing the second layer', () => { + it('should focus the first layer when removing the second layer', async () => { const defaultProps = getDefaultProps(); // overwriting datasourceLayers to test two layers frame.datasourceLayers = { first: mockDatasource.publicAPIMock, second: mockDatasource.publicAPIMock, }; - const component = mountWithIntl(<LayerPanels {...defaultProps} />, { attachTo: container }); - const firstLayerFocusable = component + const { instance } = await mountWithProvider( + <LayerPanels {...defaultProps} />, + { + preloadedState: { + datasourceStates: { + mockindexpattern: { + isLoading: false, + state: 'state', + }, + }, + }, + }, + { + attachTo: container, + } + ); + const firstLayerFocusable = instance .find(LayerPanel) .first() .find('section') .first() .instance(); act(() => { - component.find('[data-test-subj="lnsLayerRemove"]').at(2).simulate('click'); + instance.find('[data-test-subj="lnsLayerRemove"]').at(2).simulate('click'); }); const focusedEl = document.activeElement; expect(focusedEl).toEqual(firstLayerFocusable); }); - it('should focus the added layer', () => { + it('should focus the added layer', async () => { (generateId as jest.Mock).mockReturnValue(`second`); - const dispatch = jest.fn((x) => { - if (x.subType === 'ADD_LAYER') { - frame.datasourceLayers.second = mockDatasource.publicAPIMock; - } - }); - const component = mountWithIntl(<LayerPanels {...getDefaultProps()} dispatch={dispatch} />, { - attachTo: container, - }); + const { instance } = await mountWithProvider( + <LayerPanels {...getDefaultProps()} />, + + { + preloadedState: { + datasourceStates: { + mockindexpattern: { + isLoading: false, + state: 'state', + }, + }, + activeDatasourceId: 'mockindexpattern', + }, + dispatch: jest.fn((x) => { + if (x.payload.subType === 'ADD_LAYER') { + frame.datasourceLayers.second = mockDatasource.publicAPIMock; + } + }), + }, + { + attachTo: container, + } + ); act(() => { - component.find('[data-test-subj="lnsLayerAddButton"]').first().simulate('click'); + instance.find('[data-test-subj="lnsLayerAddButton"]').first().simulate('click'); }); const focusedEl = document.activeElement; expect(focusedEl?.children[0].getAttribute('data-test-subj')).toEqual('lns-layerPanel-1'); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx index 81c044af532fb..c7147e75af59a 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx @@ -10,13 +10,21 @@ import './config_panel.scss'; import React, { useMemo, memo } from 'react'; import { EuiFlexItem, EuiToolTip, EuiButton, EuiForm } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { mapValues } from 'lodash'; import { Visualization } from '../../../types'; import { LayerPanel } from './layer_panel'; import { trackUiEvent } from '../../../lens_ui_telemetry'; import { generateId } from '../../../id_generator'; -import { removeLayer, appendLayer } from './layer_actions'; +import { appendLayer } from './layer_actions'; import { ConfigPanelWrapperProps } from './types'; import { useFocusUpdate } from './use_focus_update'; +import { + useLensDispatch, + updateState, + updateDatasourceState, + updateVisualizationState, + setToggleFullscreen, +} from '../../../state_management'; export const ConfigPanelWrapper = memo(function ConfigPanelWrapper(props: ConfigPanelWrapperProps) { const activeVisualization = props.visualizationMap[props.activeVisualizationId || '']; @@ -33,13 +41,8 @@ export function LayerPanels( activeVisualization: Visualization; } ) { - const { - activeVisualization, - visualizationState, - dispatch, - activeDatasourceId, - datasourceMap, - } = props; + const { activeVisualization, visualizationState, activeDatasourceId, datasourceMap } = props; + const dispatchLens = useLensDispatch(); const layerIds = activeVisualization.getLayerIds(visualizationState); const { @@ -50,26 +53,28 @@ export function LayerPanels( const setVisualizationState = useMemo( () => (newState: unknown) => { - dispatch({ - type: 'UPDATE_VISUALIZATION_STATE', - visualizationId: activeVisualization.id, - updater: newState, - clearStagedPreview: false, - }); + dispatchLens( + updateVisualizationState({ + visualizationId: activeVisualization.id, + updater: newState, + clearStagedPreview: false, + }) + ); }, - [dispatch, activeVisualization] + [activeVisualization, dispatchLens] ); const updateDatasource = useMemo( () => (datasourceId: string, newState: unknown) => { - dispatch({ - type: 'UPDATE_DATASOURCE_STATE', - updater: (prevState: unknown) => - typeof newState === 'function' ? newState(prevState) : newState, - datasourceId, - clearStagedPreview: false, - }); + dispatchLens( + updateDatasourceState({ + updater: (prevState: unknown) => + typeof newState === 'function' ? newState(prevState) : newState, + datasourceId, + clearStagedPreview: false, + }) + ); }, - [dispatch] + [dispatchLens] ); const updateDatasourceAsync = useMemo( () => (datasourceId: string, newState: unknown) => { @@ -86,42 +91,42 @@ export function LayerPanels( // React will synchronously update if this is triggered from a third party component, // which we don't want. The timeout lets user interaction have priority, then React updates. setTimeout(() => { - dispatch({ - type: 'UPDATE_STATE', - subType: 'UPDATE_ALL_STATES', - updater: (prevState) => { - const updatedDatasourceState = - typeof newDatasourceState === 'function' - ? newDatasourceState(prevState.datasourceStates[datasourceId].state) - : newDatasourceState; - return { - ...prevState, - datasourceStates: { - ...prevState.datasourceStates, - [datasourceId]: { - state: updatedDatasourceState, - isLoading: false, + dispatchLens( + updateState({ + subType: 'UPDATE_ALL_STATES', + updater: (prevState) => { + const updatedDatasourceState = + typeof newDatasourceState === 'function' + ? newDatasourceState(prevState.datasourceStates[datasourceId].state) + : newDatasourceState; + return { + ...prevState, + datasourceStates: { + ...prevState.datasourceStates, + [datasourceId]: { + state: updatedDatasourceState, + isLoading: false, + }, + }, + visualization: { + ...prevState.visualization, + state: newVisualizationState, }, - }, - visualization: { - ...prevState.visualization, - state: newVisualizationState, - }, - stagedPreview: undefined, - }; - }, - }); + stagedPreview: undefined, + }; + }, + }) + ); }, 0); }, - [dispatch] + [dispatchLens] ); + const toggleFullscreen = useMemo( () => () => { - dispatch({ - type: 'TOGGLE_FULLSCREEN', - }); + dispatchLens(setToggleFullscreen()); }, - [dispatch] + [dispatchLens] ); const datasourcePublicAPIs = props.framePublicAPI.datasourceLayers; @@ -144,18 +149,41 @@ export function LayerPanels( updateAll={updateAll} isOnlyLayer={layerIds.length === 1} onRemoveLayer={() => { - dispatch({ - type: 'UPDATE_STATE', - subType: 'REMOVE_OR_CLEAR_LAYER', - updater: (state) => - removeLayer({ - activeVisualization, - layerId, - trackUiEvent, - datasourceMap, - state, - }), - }); + dispatchLens( + updateState({ + subType: 'REMOVE_OR_CLEAR_LAYER', + updater: (state) => { + const isOnlyLayer = activeVisualization + .getLayerIds(state.visualization.state) + .every((id) => id === layerId); + + return { + ...state, + datasourceStates: mapValues( + state.datasourceStates, + (datasourceState, datasourceId) => { + const datasource = datasourceMap[datasourceId!]; + return { + ...datasourceState, + state: isOnlyLayer + ? datasource.clearLayer(datasourceState.state, layerId) + : datasource.removeLayer(datasourceState.state, layerId), + }; + } + ), + visualization: { + ...state.visualization, + state: + isOnlyLayer || !activeVisualization.removeLayer + ? activeVisualization.clearLayer(state.visualization.state, layerId) + : activeVisualization.removeLayer(state.visualization.state, layerId), + }, + stagedPreview: undefined, + }; + }, + }) + ); + removeLayerRef(layerId); }} toggleFullscreen={toggleFullscreen} @@ -187,18 +215,19 @@ export function LayerPanels( color="text" onClick={() => { const id = generateId(); - dispatch({ - type: 'UPDATE_STATE', - subType: 'ADD_LAYER', - updater: (state) => - appendLayer({ - activeVisualization, - generateId: () => id, - trackUiEvent, - activeDatasource: datasourceMap[activeDatasourceId], - state, - }), - }); + dispatchLens( + updateState({ + subType: 'ADD_LAYER', + updater: (state) => + appendLayer({ + activeVisualization, + generateId: () => id, + trackUiEvent, + activeDatasource: datasourceMap[activeDatasourceId], + state, + }), + }) + ); setNextFocusedLayerId(id); }} iconType="plusInCircleFilled" diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_actions.test.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_actions.test.ts index d28d3acbf3bae..ad15be170e631 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_actions.test.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_actions.test.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { initialState } from '../../../state_management/lens_slice'; import { removeLayer, appendLayer } from './layer_actions'; function createTestArgs(initialLayerIds: string[]) { @@ -42,6 +43,7 @@ function createTestArgs(initialLayerIds: string[]) { return { state: { + ...initialState, activeDatasourceId: 'ds1', datasourceStates, title: 'foo', diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_actions.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_actions.ts index 7d8a373192ee5..328a868cfb893 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_actions.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_actions.ts @@ -6,12 +6,13 @@ */ import { mapValues } from 'lodash'; -import { EditorFrameState } from '../state_management'; +import { LensAppState } from '../../../state_management'; + import { Datasource, Visualization } from '../../../types'; interface RemoveLayerOptions { trackUiEvent: (name: string) => void; - state: EditorFrameState; + state: LensAppState; layerId: string; activeVisualization: Pick<Visualization, 'getLayerIds' | 'clearLayer' | 'removeLayer'>; datasourceMap: Record<string, Pick<Datasource, 'clearLayer' | 'removeLayer'>>; @@ -19,13 +20,13 @@ interface RemoveLayerOptions { interface AppendLayerOptions { trackUiEvent: (name: string) => void; - state: EditorFrameState; + state: LensAppState; generateId: () => string; activeDatasource: Pick<Datasource, 'insertLayer' | 'id'>; activeVisualization: Pick<Visualization, 'appendLayer'>; } -export function removeLayer(opts: RemoveLayerOptions): EditorFrameState { +export function removeLayer(opts: RemoveLayerOptions): LensAppState { const { state, trackUiEvent: trackUiEvent, activeVisualization, layerId, datasourceMap } = opts; const isOnlyLayer = activeVisualization .getLayerIds(state.visualization.state) @@ -61,7 +62,7 @@ export function appendLayer({ state, generateId, activeDatasource, -}: AppendLayerOptions): EditorFrameState { +}: AppendLayerOptions): LensAppState { trackUiEvent('layer_added'); if (!activeVisualization.appendLayer) { diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx index dd1241af14f5a..3bb5fca2141a0 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx @@ -19,7 +19,7 @@ import { createMockFramePublicAPI, createMockDatasource, DatasourceMock, -} from '../../mocks'; +} from '../../../mocks'; jest.mock('../../../id_generator'); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/types.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/types.ts index 1af8c16fa1395..683b96c6b8773 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/types.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/types.ts @@ -5,7 +5,6 @@ * 2.0. */ -import { Action } from '../state_management'; import { Visualization, FramePublicAPI, @@ -18,7 +17,6 @@ export interface ConfigPanelWrapperProps { visualizationState: unknown; visualizationMap: Record<string, Visualization>; activeVisualizationId: string | null; - dispatch: (action: Action) => void; framePublicAPI: FramePublicAPI; datasourceMap: Record<string, Datasource>; datasourceStates: Record< @@ -37,7 +35,6 @@ export interface LayerPanelProps { visualizationState: unknown; datasourceMap: Record<string, Datasource>; activeVisualization: Visualization; - dispatch: (action: Action) => void; framePublicAPI: FramePublicAPI; datasourceStates: Record< string, diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/data_panel_wrapper.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/data_panel_wrapper.tsx index 9bf03025e400f..c50d3f41479f1 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/data_panel_wrapper.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/data_panel_wrapper.tsx @@ -7,54 +7,94 @@ import './data_panel_wrapper.scss'; -import React, { useMemo, memo, useContext, useState } from 'react'; +import React, { useMemo, memo, useContext, useState, useEffect } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiPopover, EuiButtonIcon, EuiContextMenuPanel, EuiContextMenuItem } from '@elastic/eui'; +import { createSelector } from '@reduxjs/toolkit'; import { NativeRenderer } from '../../native_renderer'; -import { Action } from './state_management'; import { DragContext, DragDropIdentifier } from '../../drag_drop'; -import { StateSetter, FramePublicAPI, DatasourceDataPanelProps, Datasource } from '../../types'; -import { Query, Filter } from '../../../../../../src/plugins/data/public'; +import { StateSetter, DatasourceDataPanelProps, Datasource } from '../../types'; import { UiActionsStart } from '../../../../../../src/plugins/ui_actions/public'; +import { + switchDatasource, + useLensDispatch, + updateDatasourceState, + LensState, + useLensSelector, + setState, +} from '../../state_management'; +import { initializeDatasources } from './state_helpers'; interface DataPanelWrapperProps { datasourceState: unknown; datasourceMap: Record<string, Datasource>; activeDatasource: string | null; datasourceIsLoading: boolean; - dispatch: (action: Action) => void; showNoDataPopover: () => void; core: DatasourceDataPanelProps['core']; - query: Query; - dateRange: FramePublicAPI['dateRange']; - filters: Filter[]; dropOntoWorkspace: (field: DragDropIdentifier) => void; hasSuggestionForField: (field: DragDropIdentifier) => boolean; plugins: { uiActions: UiActionsStart }; } +const getExternals = createSelector( + (state: LensState) => state.lens, + ({ resolvedDateRange, query, filters, datasourceStates, activeDatasourceId }) => ({ + dateRange: resolvedDateRange, + query, + filters, + datasourceStates, + activeDatasourceId, + }) +); + export const DataPanelWrapper = memo((props: DataPanelWrapperProps) => { - const { dispatch, activeDatasource } = props; - const setDatasourceState: StateSetter<unknown> = useMemo( - () => (updater) => { - dispatch({ - type: 'UPDATE_DATASOURCE_STATE', - updater, - datasourceId: activeDatasource!, - clearStagedPreview: true, - }); - }, - [dispatch, activeDatasource] + const { activeDatasource } = props; + + const { filters, query, dateRange, datasourceStates, activeDatasourceId } = useLensSelector( + getExternals ); + const dispatchLens = useLensDispatch(); + const setDatasourceState: StateSetter<unknown> = useMemo(() => { + return (updater) => { + dispatchLens( + updateDatasourceState({ + updater, + datasourceId: activeDatasource!, + clearStagedPreview: true, + }) + ); + }; + }, [activeDatasource, dispatchLens]); + + useEffect(() => { + if (activeDatasourceId && datasourceStates[activeDatasourceId].state === null) { + initializeDatasources(props.datasourceMap, datasourceStates, undefined, undefined, { + isFullEditor: true, + }).then((result) => { + const newDatasourceStates = Object.entries(result).reduce( + (state, [datasourceId, datasourceState]) => ({ + ...state, + [datasourceId]: { + ...datasourceState, + isLoading: false, + }, + }), + {} + ); + dispatchLens(setState({ datasourceStates: newDatasourceStates })); + }); + } + }, [datasourceStates, activeDatasourceId, props.datasourceMap, dispatchLens]); const datasourceProps: DatasourceDataPanelProps = { dragDropContext: useContext(DragContext), state: props.datasourceState, setState: setDatasourceState, core: props.core, - query: props.query, - dateRange: props.dateRange, - filters: props.filters, + filters, + query, + dateRange, showNoDataPopover: props.showNoDataPopover, dropOntoWorkspace: props.dropOntoWorkspace, hasSuggestionForField: props.hasSuggestionForField, @@ -98,10 +138,7 @@ export const DataPanelWrapper = memo((props: DataPanelWrapperProps) => { icon={props.activeDatasource === datasourceId ? 'check' : 'empty'} onClick={() => { setDatasourceSwitcher(false); - props.dispatch({ - type: 'SWITCH_DATASOURCE', - newDatasourceId: datasourceId, - }); + dispatchLens(switchDatasource({ newDatasourceId: datasourceId })); }} > {datasourceId} diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx index 0e2ba5ce8ad59..4ce68dc3bc70a 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx @@ -7,7 +7,6 @@ import React, { ReactElement } from 'react'; import { ReactWrapper } from 'enzyme'; -import { setState, LensRootStore } from '../../state_management/index'; // Tests are executed in a jsdom environment who does not have sizing methods, // thus the AutoSizer will always compute a 0x0 size space @@ -37,16 +36,17 @@ import { fromExpression } from '@kbn/interpreter/common'; import { createMockVisualization, createMockDatasource, - createExpressionRendererMock, DatasourceMock, -} from '../mocks'; + createExpressionRendererMock, +} from '../../mocks'; import { ReactExpressionRendererType } from 'src/plugins/expressions/public'; import { DragDrop } from '../../drag_drop'; -import { FrameLayout } from './frame_layout'; import { uiActionsPluginMock } from '../../../../../../src/plugins/ui_actions/public/mocks'; import { chartPluginMock } from '../../../../../../src/plugins/charts/public/mocks'; import { expressionsPluginMock } from '../../../../../../src/plugins/expressions/public/mocks'; import { mockDataPlugin, mountWithProvider } from '../../mocks'; +import { setState, setToggleFullscreen } from '../../state_management'; +import { FrameLayout } from './frame_layout'; function generateSuggestion(state = {}): DatasourceSuggestion { return { @@ -130,68 +130,6 @@ describe('editor_frame', () => { }); describe('initialization', () => { - it('should initialize initial datasource', async () => { - mockVisualization.getLayerIds.mockReturnValue([]); - const props = { - ...getDefaultProps(), - visualizationMap: { - testVis: mockVisualization, - }, - datasourceMap: { - testDatasource: mockDatasource, - }, - - ExpressionRenderer: expressionRendererMock, - }; - - await mountWithProvider(<EditorFrame {...props} />, props.plugins.data); - expect(mockDatasource.initialize).toHaveBeenCalled(); - }); - - it('should initialize all datasources with state from doc', async () => { - const mockDatasource3 = createMockDatasource('testDatasource3'); - const datasource1State = { datasource1: '' }; - const datasource2State = { datasource2: '' }; - const props = { - ...getDefaultProps(), - visualizationMap: { - testVis: mockVisualization, - }, - datasourceMap: { - testDatasource: mockDatasource, - testDatasource2: mockDatasource2, - testDatasource3: mockDatasource3, - }, - - ExpressionRenderer: expressionRendererMock, - }; - - await mountWithProvider(<EditorFrame {...props} />, props.plugins.data, { - persistedDoc: { - visualizationType: 'testVis', - title: '', - state: { - datasourceStates: { - testDatasource: datasource1State, - testDatasource2: datasource2State, - }, - visualization: {}, - query: { query: '', language: 'lucene' }, - filters: [], - }, - references: [], - }, - }); - - expect(mockDatasource.initialize).toHaveBeenCalledWith(datasource1State, [], undefined, { - isFullEditor: true, - }); - expect(mockDatasource2.initialize).toHaveBeenCalledWith(datasource2State, [], undefined, { - isFullEditor: true, - }); - expect(mockDatasource3.initialize).not.toHaveBeenCalled(); - }); - it('should not render something before all datasources are initialized', async () => { const props = { ...getDefaultProps(), @@ -204,177 +142,36 @@ describe('editor_frame', () => { ExpressionRenderer: expressionRendererMock, }; - - await act(async () => { - mountWithProvider(<EditorFrame {...props} />, props.plugins.data); - expect(mockDatasource.renderDataPanel).not.toHaveBeenCalled(); - }); - expect(mockDatasource.renderDataPanel).toHaveBeenCalled(); - }); - - it('should not initialize visualization before datasource is initialized', async () => { - const props = { - ...getDefaultProps(), - visualizationMap: { - testVis: mockVisualization, - }, - datasourceMap: { - testDatasource: mockDatasource, - }, - - ExpressionRenderer: expressionRendererMock, - }; - - await act(async () => { - mountWithProvider(<EditorFrame {...props} />, props.plugins.data); - expect(mockVisualization.initialize).not.toHaveBeenCalled(); - }); - - expect(mockVisualization.initialize).toHaveBeenCalled(); - }); - - it('should pass the public frame api into visualization initialize', async () => { - const props = { - ...getDefaultProps(), - visualizationMap: { - testVis: mockVisualization, - }, - datasourceMap: { - testDatasource: mockDatasource, - }, - - ExpressionRenderer: expressionRendererMock, - }; - await act(async () => { - mountWithProvider(<EditorFrame {...props} />, props.plugins.data); - expect(mockVisualization.initialize).not.toHaveBeenCalled(); - }); - - expect(mockVisualization.initialize).toHaveBeenCalledWith({ - datasourceLayers: {}, - addNewLayer: expect.any(Function), - removeLayers: expect.any(Function), - query: { query: '', language: 'lucene' }, - filters: [], - dateRange: { fromDate: '2021-01-10T04:00:00.000Z', toDate: '2021-01-10T08:00:00.000Z' }, - availablePalettes: props.palettes, - searchSessionId: 'sessionId-1', - }); - }); - - it('should add new layer on active datasource on frame api call', async () => { - const initialState = { datasource2: '' }; - mockDatasource2.initialize.mockReturnValue(Promise.resolve(initialState)); - - const props = { - ...getDefaultProps(), - visualizationMap: { - testVis: mockVisualization, - }, - datasourceMap: { - testDatasource: mockDatasource, - testDatasource2: mockDatasource2, - }, - - ExpressionRenderer: expressionRendererMock, - }; - await mountWithProvider(<EditorFrame {...props} />, props.plugins.data, { - persistedDoc: { - visualizationType: 'testVis', - title: '', - state: { + const lensStore = ( + await mountWithProvider(<EditorFrame {...props} />, { + data: props.plugins.data, + preloadedState: { + activeDatasourceId: 'testDatasource', datasourceStates: { - testDatasource2: mockDatasource2, + testDatasource: { + isLoading: true, + state: { + internalState1: '', + }, + }, }, - visualization: {}, - query: { query: '', language: 'lucene' }, - filters: [], }, - references: [], - }, - }); - act(() => { - mockVisualization.initialize.mock.calls[0][0].addNewLayer(); - }); - - expect(mockDatasource2.insertLayer).toHaveBeenCalledWith(initialState, expect.anything()); - }); - - it('should remove layer on active datasource on frame api call', async () => { - const initialState = { datasource2: '' }; - mockDatasource.getLayers.mockReturnValue(['first']); - mockDatasource2.initialize.mockReturnValue(Promise.resolve(initialState)); - mockDatasource2.getLayers.mockReturnValue(['abc', 'def']); - mockDatasource2.removeLayer.mockReturnValue({ removed: true }); - mockVisualization.getLayerIds.mockReturnValue(['first', 'abc', 'def']); - - const props = { - ...getDefaultProps(), - visualizationMap: { - testVis: mockVisualization, - }, - datasourceMap: { - testDatasource: mockDatasource, - testDatasource2: mockDatasource2, - }, - ExpressionRenderer: expressionRendererMock, - }; - - await mountWithProvider(<EditorFrame {...props} />, props.plugins.data, { - persistedDoc: { - visualizationType: 'testVis', - title: '', - state: { - datasourceStates: { - testDatasource2: mockDatasource2, + }) + ).lensStore; + expect(mockDatasource.renderDataPanel).not.toHaveBeenCalled(); + lensStore.dispatch( + setState({ + datasourceStates: { + testDatasource: { + isLoading: false, + state: { + internalState1: '', + }, }, - visualization: {}, - query: { query: '', language: 'lucene' }, - filters: [], }, - references: [], - }, - }); - - act(() => { - mockVisualization.initialize.mock.calls[0][0].removeLayers(['abc', 'def']); - }); - - expect(mockDatasource2.removeLayer).toHaveBeenCalledWith(initialState, 'abc'); - expect(mockDatasource2.removeLayer).toHaveBeenCalledWith({ removed: true }, 'def'); - }); - - it('should render data panel after initialization is complete', async () => { - const initialState = {}; - let databaseInitialized: ({}) => void; - - const props = { - ...getDefaultProps(), - visualizationMap: { - testVis: mockVisualization, - }, - datasourceMap: { - testDatasource: { - ...mockDatasource, - initialize: () => - new Promise((resolve) => { - databaseInitialized = resolve; - }), - }, - }, - - ExpressionRenderer: expressionRendererMock, - }; - - await mountWithProvider(<EditorFrame {...props} />, props.plugins.data); - - await act(async () => { - databaseInitialized!(initialState); - }); - expect(mockDatasource.renderDataPanel).toHaveBeenCalledWith( - expect.any(Element), - expect.objectContaining({ state: initialState }) + }) ); + expect(mockDatasource.renderDataPanel).toHaveBeenCalled(); }); it('should initialize visualization state and render config panel', async () => { @@ -396,7 +193,12 @@ describe('editor_frame', () => { ExpressionRenderer: expressionRendererMock, }; - await mountWithProvider(<EditorFrame {...props} />, props.plugins.data); + await mountWithProvider(<EditorFrame {...props} />, { + data: props.plugins.data, + preloadedState: { + visualization: { activeId: 'testVis', state: initialState }, + }, + }); expect(mockVisualization.getConfiguration).toHaveBeenCalledWith( expect.objectContaining({ state: initialState }) @@ -422,7 +224,22 @@ describe('editor_frame', () => { ExpressionRenderer: expressionRendererMock, }; - instance = (await mountWithProvider(<EditorFrame {...props} />, props.plugins.data)).instance; + instance = ( + await mountWithProvider(<EditorFrame {...props} />, { + data: props.plugins.data, + preloadedState: { + visualization: { activeId: 'testVis', state: null }, + datasourceStates: { + testDatasource: { + isLoading: false, + state: { + internalState1: '', + }, + }, + }, + }, + }) + ).instance; instance.update(); @@ -437,37 +254,50 @@ describe('editor_frame', () => { mockDatasource.toExpression.mockReturnValue('datasource'); mockDatasource2.toExpression.mockImplementation((_state, layerId) => `datasource_${layerId}`); mockDatasource.initialize.mockImplementation((initialState) => Promise.resolve(initialState)); - mockDatasource.getLayers.mockReturnValue(['first']); + mockDatasource.getLayers.mockReturnValue(['first', 'second']); mockDatasource2.initialize.mockImplementation((initialState) => Promise.resolve(initialState) ); - mockDatasource2.getLayers.mockReturnValue(['second', 'third']); + mockDatasource2.getLayers.mockReturnValue(['third']); const props = { ...getDefaultProps(), visualizationMap: { testVis: { ...mockVisualization, toExpression: () => 'vis' }, }, - datasourceMap: { testDatasource: mockDatasource, testDatasource2: mockDatasource2 }, + datasourceMap: { + testDatasource: { + ...mockDatasource, + toExpression: () => 'datasource', + }, + testDatasource2: { + ...mockDatasource2, + toExpression: () => 'datasource_second', + }, + }, ExpressionRenderer: expressionRendererMock, }; instance = ( - await mountWithProvider(<EditorFrame {...props} />, props.plugins.data, { - persistedDoc: { - visualizationType: 'testVis', - title: '', - state: { - datasourceStates: { - testDatasource: {}, - testDatasource2: {}, + await mountWithProvider(<EditorFrame {...props} />, { + data: props.plugins.data, + preloadedState: { + visualization: { activeId: 'testVis', state: null }, + datasourceStates: { + testDatasource: { + isLoading: false, + state: { + internalState1: '', + }, + }, + testDatasource2: { + isLoading: false, + state: { + internalState1: '', + }, }, - visualization: {}, - query: { query: '', language: 'lucene' }, - filters: [], }, - references: [], }, }) ).instance; @@ -515,7 +345,7 @@ describe('editor_frame', () => { "chain": Array [ Object { "arguments": Object {}, - "function": "datasource_second", + "function": "datasource", "type": "function", }, ], @@ -525,7 +355,7 @@ describe('editor_frame', () => { "chain": Array [ Object { "arguments": Object {}, - "function": "datasource_third", + "function": "datasource_second", "type": "function", }, ], @@ -562,7 +392,19 @@ describe('editor_frame', () => { ExpressionRenderer: expressionRendererMock, }; - await mountWithProvider(<EditorFrame {...props} />, props.plugins.data); + await mountWithProvider(<EditorFrame {...props} />, { + data: props.plugins.data, + preloadedState: { + activeDatasourceId: 'testDatasource', + visualization: { activeId: mockVisualization.id, state: {} }, + datasourceStates: { + testDatasource: { + isLoading: false, + state: '', + }, + }, + }, + }); const updatedState = {}; const setDatasourceState = (mockDatasource.renderDataPanel as jest.Mock).mock.calls[0][1] .setState; @@ -593,7 +435,7 @@ describe('editor_frame', () => { ExpressionRenderer: expressionRendererMock, }; - await mountWithProvider(<EditorFrame {...props} />, props.plugins.data); + await mountWithProvider(<EditorFrame {...props} />, { data: props.plugins.data }); const setDatasourceState = (mockDatasource.renderDataPanel as jest.Mock).mock.calls[0][1] .setState; @@ -629,7 +471,10 @@ describe('editor_frame', () => { ExpressionRenderer: expressionRendererMock, }; - await mountWithProvider(<EditorFrame {...props} />, props.plugins.data); + await mountWithProvider(<EditorFrame {...props} />, { + data: props.plugins.data, + preloadedState: { visualization: { activeId: mockVisualization.id, state: {} } }, + }); const updatedPublicAPI: DatasourcePublicAPI = { datasourceId: 'testDatasource', @@ -659,58 +504,10 @@ describe('editor_frame', () => { }); describe('datasource public api communication', () => { - it('should pass the datasource api for each layer to the visualization', async () => { - mockDatasource.getLayers.mockReturnValue(['first']); - mockDatasource2.getLayers.mockReturnValue(['second', 'third']); - mockVisualization.getLayerIds.mockReturnValue(['first', 'second', 'third']); - const props = { - ...getDefaultProps(), - visualizationMap: { - testVis: mockVisualization, - }, - datasourceMap: { - testDatasource: mockDatasource, - testDatasource2: mockDatasource2, - }, - - ExpressionRenderer: expressionRendererMock, - }; - await mountWithProvider(<EditorFrame {...props} />, props.plugins.data, { - persistedDoc: { - visualizationType: 'testVis', - title: '', - state: { - datasourceStates: { - testDatasource: {}, - testDatasource2: {}, - }, - visualization: {}, - query: { query: '', language: 'lucene' }, - filters: [], - }, - references: [], - }, - }); - - expect(mockVisualization.getConfiguration).toHaveBeenCalled(); - - const datasourceLayers = - mockVisualization.getConfiguration.mock.calls[0][0].frame.datasourceLayers; - expect(datasourceLayers.first).toBe(mockDatasource.publicAPIMock); - expect(datasourceLayers.second).toBe(mockDatasource2.publicAPIMock); - expect(datasourceLayers.third).toBe(mockDatasource2.publicAPIMock); - }); - - it('should create a separate datasource public api for each layer', async () => { - mockDatasource.initialize.mockImplementation((initialState) => Promise.resolve(initialState)); + it('should give access to the datasource state in the datasource factory function', async () => { + const datasourceState = {}; + mockDatasource.initialize.mockResolvedValue(datasourceState); mockDatasource.getLayers.mockReturnValue(['first']); - mockDatasource2.initialize.mockImplementation((initialState) => - Promise.resolve(initialState) - ); - mockDatasource2.getLayers.mockReturnValue(['second', 'third']); - - const datasource1State = { datasource1: '' }; - const datasource2State = { datasource2: '' }; const props = { ...getDefaultProps(), @@ -719,66 +516,22 @@ describe('editor_frame', () => { }, datasourceMap: { testDatasource: mockDatasource, - testDatasource2: mockDatasource2, }, ExpressionRenderer: expressionRendererMock, }; - await mountWithProvider(<EditorFrame {...props} />, props.plugins.data, { - persistedDoc: { - visualizationType: 'testVis', - title: '', - state: { - datasourceStates: { - testDatasource: datasource1State, - testDatasource2: datasource2State, + await mountWithProvider(<EditorFrame {...props} />, { + data: props.plugins.data, + preloadedState: { + datasourceStates: { + testDatasource: { + isLoading: false, + state: {}, }, - visualization: {}, - query: { query: '', language: 'lucene' }, - filters: [], }, - references: [], }, }); - expect(mockDatasource.getPublicAPI).toHaveBeenCalledWith( - expect.objectContaining({ - state: datasource1State, - layerId: 'first', - }) - ); - expect(mockDatasource2.getPublicAPI).toHaveBeenCalledWith( - expect.objectContaining({ - state: datasource2State, - layerId: 'second', - }) - ); - expect(mockDatasource2.getPublicAPI).toHaveBeenCalledWith( - expect.objectContaining({ - state: datasource2State, - layerId: 'third', - }) - ); - }); - - it('should give access to the datasource state in the datasource factory function', async () => { - const datasourceState = {}; - mockDatasource.initialize.mockResolvedValue(datasourceState); - mockDatasource.getLayers.mockReturnValue(['first']); - - const props = { - ...getDefaultProps(), - visualizationMap: { - testVis: mockVisualization, - }, - datasourceMap: { - testDatasource: mockDatasource, - }, - - ExpressionRenderer: expressionRendererMock, - }; - await mountWithProvider(<EditorFrame {...props} />, props.plugins.data); - expect(mockDatasource.getPublicAPI).toHaveBeenCalledWith({ state: datasourceState, layerId: 'first', @@ -832,7 +585,8 @@ describe('editor_frame', () => { ExpressionRenderer: expressionRendererMock, }; - instance = (await mountWithProvider(<EditorFrame {...props} />, props.plugins.data)).instance; + instance = (await mountWithProvider(<EditorFrame {...props} />, { data: props.plugins.data })) + .instance; // necessary to flush elements to dom synchronously instance.update(); @@ -842,14 +596,6 @@ describe('editor_frame', () => { instance.unmount(); }); - it('should have initialized only the initial datasource and visualization', () => { - expect(mockDatasource.initialize).toHaveBeenCalled(); - expect(mockDatasource2.initialize).not.toHaveBeenCalled(); - - expect(mockVisualization.initialize).toHaveBeenCalled(); - expect(mockVisualization2.initialize).not.toHaveBeenCalled(); - }); - it('should initialize other datasource on switch', async () => { await act(async () => { instance.find('button[data-test-subj="datasource-switch"]').simulate('click'); @@ -859,6 +605,7 @@ describe('editor_frame', () => { '[data-test-subj="datasource-switch-testDatasource2"]' ) as HTMLButtonElement).click(); }); + instance.update(); expect(mockDatasource2.initialize).toHaveBeenCalled(); }); @@ -915,9 +662,7 @@ describe('editor_frame', () => { expect(mockDatasource.publicAPIMock.getTableSpec).toHaveBeenCalled(); expect(mockVisualization2.getSuggestions).toHaveBeenCalled(); expect(mockVisualization2.initialize).toHaveBeenCalledWith( - expect.objectContaining({ - datasourceLayers: expect.objectContaining({ first: mockDatasource.publicAPIMock }), - }), + expect.any(Function), // generated layerId undefined, undefined ); @@ -928,28 +673,6 @@ describe('editor_frame', () => { }); describe('suggestions', () => { - it('should fetch suggestions of currently active datasource when initializes from visualization trigger', async () => { - const props = { - ...getDefaultProps(), - initialContext: { - indexPatternId: '1', - fieldName: 'test', - }, - visualizationMap: { - testVis: mockVisualization, - }, - datasourceMap: { - testDatasource: mockDatasource, - testDatasource2: mockDatasource2, - }, - - ExpressionRenderer: expressionRendererMock, - }; - await mountWithProvider(<EditorFrame {...props} />, props.plugins.data); - - expect(mockDatasource.getDatasourceSuggestionsForVisualizeField).toHaveBeenCalled(); - }); - it('should fetch suggestions of currently active datasource', async () => { const props = { ...getDefaultProps(), @@ -963,7 +686,7 @@ describe('editor_frame', () => { ExpressionRenderer: expressionRendererMock, }; - await mountWithProvider(<EditorFrame {...props} />, props.plugins.data); + await mountWithProvider(<EditorFrame {...props} />, { data: props.plugins.data }); expect(mockDatasource.getDatasourceSuggestionsFromCurrentState).toHaveBeenCalled(); expect(mockDatasource2.getDatasourceSuggestionsFromCurrentState).not.toHaveBeenCalled(); @@ -996,7 +719,7 @@ describe('editor_frame', () => { ExpressionRenderer: expressionRendererMock, }; - await mountWithProvider(<EditorFrame {...props} />, props.plugins.data); + await mountWithProvider(<EditorFrame {...props} />, { data: props.plugins.data }); expect(mockVisualization.getSuggestions).toHaveBeenCalled(); expect(mockVisualization2.getSuggestions).toHaveBeenCalled(); @@ -1064,10 +787,9 @@ describe('editor_frame', () => { ExpressionRenderer: expressionRendererMock, }; - instance = (await mountWithProvider(<EditorFrame {...props} />, props.plugins.data)).instance; + instance = (await mountWithProvider(<EditorFrame {...props} />, { data: props.plugins.data })) + .instance; - // TODO why is this necessary? - instance.update(); expect( instance .find('[data-test-subj="lnsSuggestion"]') @@ -1112,18 +834,16 @@ describe('editor_frame', () => { ExpressionRenderer: expressionRendererMock, }; - instance = (await mountWithProvider(<EditorFrame {...props} />, props.plugins.data)).instance; - - // TODO why is this necessary? - instance.update(); + instance = (await mountWithProvider(<EditorFrame {...props} />, { data: props.plugins.data })) + .instance; act(() => { instance.find('[data-test-subj="lnsSuggestion"]').at(2).simulate('click'); }); // validation requires to calls this getConfiguration API - expect(mockVisualization.getConfiguration).toHaveBeenCalledTimes(7); - expect(mockVisualization.getConfiguration).toHaveBeenCalledWith( + expect(mockVisualization.getConfiguration).toHaveBeenCalledTimes(6); + expect(mockVisualization.getConfiguration).toHaveBeenLastCalledWith( expect.objectContaining({ state: suggestionVisState, }) @@ -1172,10 +892,8 @@ describe('editor_frame', () => { ExpressionRenderer: expressionRendererMock, }; - instance = (await mountWithProvider(<EditorFrame {...props} />, props.plugins.data)).instance; - - // TODO why is this necessary? - instance.update(); + instance = (await mountWithProvider(<EditorFrame {...props} />, { data: props.plugins.data })) + .instance; act(() => { instance.find('[data-test-subj="lnsWorkspace"]').last().simulate('drop'); @@ -1191,7 +909,6 @@ describe('editor_frame', () => { it('should use the currently selected visualization if possible on field drop', async () => { mockDatasource.getLayers.mockReturnValue(['first', 'second', 'third']); const suggestionVisState = {}; - const props = { ...getDefaultProps(), visualizationMap: { @@ -1243,9 +960,21 @@ describe('editor_frame', () => { ExpressionRenderer: expressionRendererMock, } as EditorFrameProps; - instance = (await mountWithProvider(<EditorFrame {...props} />, props.plugins.data)).instance; - // TODO why is this necessary? - instance.update(); + instance = ( + await mountWithProvider(<EditorFrame {...props} />, { + data: props.plugins.data, + preloadedState: { + datasourceStates: { + testDatasource: { + isLoading: false, + state: { + internalState1: '', + }, + }, + }, + }, + }) + ).instance; act(() => { instance.find('[data-test-subj="mockVisA"]').find(DragDrop).prop('onDrop')!( @@ -1345,10 +1074,11 @@ describe('editor_frame', () => { ExpressionRenderer: expressionRendererMock, } as EditorFrameProps; - instance = (await mountWithProvider(<EditorFrame {...props} />, props.plugins.data)).instance; - - // TODO why is this necessary? - instance.update(); + instance = ( + await mountWithProvider(<EditorFrame {...props} />, { + data: props.plugins.data, + }) + ).instance; act(() => { instance.find(DragDrop).filter('[dataTestSubj="lnsWorkspace"]').prop('onDrop')!( @@ -1389,32 +1119,21 @@ describe('editor_frame', () => { ExpressionRenderer: expressionRendererMock, }; - const { instance: el } = await mountWithProvider( - <EditorFrame {...props} />, - props.plugins.data - ); + const { instance: el, lensStore } = await mountWithProvider(<EditorFrame {...props} />, { + data: props.plugins.data, + }); instance = el; expect( instance.find(FrameLayout).prop('suggestionsPanel') as ReactElement ).not.toBeUndefined(); - await act(async () => { - (instance.find(FrameLayout).prop('dataPanel') as ReactElement)!.props.dispatch({ - type: 'TOGGLE_FULLSCREEN', - }); - }); - + lensStore.dispatch(setToggleFullscreen()); instance.update(); expect(instance.find(FrameLayout).prop('suggestionsPanel') as ReactElement).toBe(false); - await act(async () => { - (instance.find(FrameLayout).prop('dataPanel') as ReactElement)!.props.dispatch({ - type: 'TOGGLE_FULLSCREEN', - }); - }); - + lensStore.dispatch(setToggleFullscreen()); instance.update(); expect( @@ -1422,211 +1141,4 @@ describe('editor_frame', () => { ).not.toBeUndefined(); }); }); - - describe('passing state back to the caller', () => { - let resolver: (value: unknown) => void; - let instance: ReactWrapper; - - it('should call onChange only when the active datasource is finished loading', async () => { - const onChange = jest.fn(); - - mockDatasource.initialize.mockReturnValue( - new Promise((resolve) => { - resolver = resolve; - }) - ); - mockDatasource.getLayers.mockReturnValue(['first']); - mockDatasource.getPersistableState = jest.fn((x) => ({ - state: x, - savedObjectReferences: [{ type: 'index-pattern', id: '1', name: 'index-pattern-0' }], - })); - mockVisualization.initialize.mockReturnValue({ initialState: true }); - - const props = { - ...getDefaultProps(), - visualizationMap: { - testVis: mockVisualization, - }, - datasourceMap: { - testDatasource: mockDatasource, - }, - - ExpressionRenderer: expressionRendererMock, - onChange, - }; - - let lensStore: LensRootStore = {} as LensRootStore; - await act(async () => { - const mounted = await mountWithProvider(<EditorFrame {...props} />, props.plugins.data); - lensStore = mounted.lensStore; - expect(lensStore.dispatch).toHaveBeenCalledTimes(0); - resolver({}); - }); - - expect(lensStore.dispatch).toHaveBeenCalledTimes(2); - expect(lensStore.dispatch).toHaveBeenNthCalledWith(1, { - payload: { - indexPatternsForTopNav: [{ id: '1' }], - lastKnownDoc: { - savedObjectId: undefined, - description: undefined, - references: [ - { - id: '1', - name: 'index-pattern-0', - type: 'index-pattern', - }, - ], - state: { - visualization: null, // Not yet loaded - datasourceStates: { testDatasource: {} }, - query: { query: '', language: 'lucene' }, - filters: [], - }, - title: '', - type: 'lens', - visualizationType: 'testVis', - }, - }, - type: 'app/onChangeFromEditorFrame', - }); - expect(lensStore.dispatch).toHaveBeenLastCalledWith({ - payload: { - indexPatternsForTopNav: [{ id: '1' }], - lastKnownDoc: { - references: [ - { - id: '1', - name: 'index-pattern-0', - type: 'index-pattern', - }, - ], - description: undefined, - savedObjectId: undefined, - state: { - visualization: { initialState: true }, // Now loaded - datasourceStates: { testDatasource: {} }, - query: { query: '', language: 'lucene' }, - filters: [], - }, - title: '', - type: 'lens', - visualizationType: 'testVis', - }, - }, - type: 'app/onChangeFromEditorFrame', - }); - }); - - it('should send back a persistable document when the state changes', async () => { - const onChange = jest.fn(); - - const initialState = { datasource: '' }; - - mockDatasource.initialize.mockResolvedValue(initialState); - mockDatasource.getLayers.mockReturnValue(['first']); - mockVisualization.initialize.mockReturnValue({ initialState: true }); - - const props = { - ...getDefaultProps(), - visualizationMap: { - testVis: mockVisualization, - }, - datasourceMap: { - testDatasource: mockDatasource, - }, - - ExpressionRenderer: expressionRendererMock, - onChange, - }; - - const { instance: el, lensStore } = await mountWithProvider( - <EditorFrame {...props} />, - props.plugins.data - ); - instance = el; - - expect(lensStore.dispatch).toHaveBeenCalledTimes(2); - - mockDatasource.toExpression.mockReturnValue('data expression'); - mockVisualization.toExpression.mockReturnValue('vis expression'); - await act(async () => { - lensStore.dispatch(setState({ query: { query: 'new query', language: 'lucene' } })); - }); - - instance.update(); - - expect(lensStore.dispatch).toHaveBeenCalledTimes(4); - expect(lensStore.dispatch).toHaveBeenNthCalledWith(3, { - payload: { - query: { - language: 'lucene', - query: 'new query', - }, - }, - type: 'app/setState', - }); - expect(lensStore.dispatch).toHaveBeenNthCalledWith(4, { - payload: { - lastKnownDoc: { - savedObjectId: undefined, - references: [], - state: { - datasourceStates: { testDatasource: { datasource: '' } }, - visualization: { initialState: true }, - query: { query: 'new query', language: 'lucene' }, - filters: [], - }, - title: '', - type: 'lens', - visualizationType: 'testVis', - }, - isSaveable: true, - }, - type: 'app/onChangeFromEditorFrame', - }); - }); - - it('should call onChange when the datasource makes an internal state change', async () => { - const onChange = jest.fn(); - - mockDatasource.initialize.mockResolvedValue({}); - mockDatasource.getLayers.mockReturnValue(['first']); - mockDatasource.getPersistableState = jest.fn((x) => ({ - state: x, - savedObjectReferences: [{ type: 'index-pattern', id: '1', name: '' }], - })); - mockVisualization.initialize.mockReturnValue({ initialState: true }); - - const props = { - ...getDefaultProps(), - visualizationMap: { - testVis: mockVisualization, - }, - datasourceMap: { - testDatasource: mockDatasource, - }, - - ExpressionRenderer: expressionRendererMock, - onChange, - }; - const mounted = await mountWithProvider(<EditorFrame {...props} />, props.plugins.data); - instance = mounted.instance; - const { lensStore } = mounted; - - expect(lensStore.dispatch).toHaveBeenCalledTimes(2); - - await act(async () => { - (instance.find(FrameLayout).prop('dataPanel') as ReactElement)!.props.dispatch({ - type: 'UPDATE_DATASOURCE_STATE', - updater: () => ({ - newState: true, - }), - datasourceId: 'testDatasource', - }); - }); - - expect(lensStore.dispatch).toHaveBeenCalledTimes(3); - }); - }); }); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx index bd96682f427fa..4b725c4cd1850 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx @@ -5,118 +5,53 @@ * 2.0. */ -import React, { useEffect, useReducer, useState, useCallback, useRef, useMemo } from 'react'; +import React, { useCallback, useRef, useMemo } from 'react'; import { CoreStart } from 'kibana/public'; -import { isEqual } from 'lodash'; -import { PaletteRegistry } from 'src/plugins/charts/public'; -import { IndexPattern } from '../../../../../../src/plugins/data/public'; -import { getAllIndexPatterns } from '../../utils'; import { ReactExpressionRendererType } from '../../../../../../src/plugins/expressions/public'; import { Datasource, FramePublicAPI, Visualization } from '../../types'; -import { reducer, getInitialState } from './state_management'; import { DataPanelWrapper } from './data_panel_wrapper'; import { ConfigPanelWrapper } from './config_panel'; import { FrameLayout } from './frame_layout'; import { SuggestionPanel } from './suggestion_panel'; import { WorkspacePanel } from './workspace_panel'; -import { Document } from '../../persistence/saved_object_store'; import { DragDropIdentifier, RootDragDropProvider } from '../../drag_drop'; -import { getSavedObjectFormat } from './save'; -import { generateId } from '../../id_generator'; -import { VisualizeFieldContext } from '../../../../../../src/plugins/ui_actions/public'; import { EditorFrameStartPlugins } from '../service'; -import { initializeDatasources, createDatasourceLayers } from './state_helpers'; -import { - applyVisualizeFieldSuggestions, - getTopSuggestionForField, - switchToSuggestion, - Suggestion, -} from './suggestion_helpers'; +import { createDatasourceLayers } from './state_helpers'; +import { getTopSuggestionForField, switchToSuggestion, Suggestion } from './suggestion_helpers'; import { trackUiEvent } from '../../lens_ui_telemetry'; -import { - useLensSelector, - useLensDispatch, - LensAppState, - DispatchSetState, - onChangeFromEditorFrame, -} from '../../state_management'; +import { useLensSelector, useLensDispatch } from '../../state_management'; export interface EditorFrameProps { datasourceMap: Record<string, Datasource>; visualizationMap: Record<string, Visualization>; ExpressionRenderer: ReactExpressionRendererType; - palettes: PaletteRegistry; - onError: (e: { message: string }) => void; core: CoreStart; plugins: EditorFrameStartPlugins; showNoDataPopover: () => void; - initialContext?: VisualizeFieldContext; } export function EditorFrame(props: EditorFrameProps) { const { - filters, - searchSessionId, - savedQuery, - query, - persistedDoc, - indexPatternsForTopNav, - lastKnownDoc, activeData, - isSaveable, resolvedDateRange: dateRange, - } = useLensSelector((state) => state.app); - const [state, dispatch] = useReducer(reducer, { ...props, doc: persistedDoc }, getInitialState); + query, + filters, + searchSessionId, + activeDatasourceId, + visualization, + datasourceStates, + stagedPreview, + isFullscreenDatasource, + } = useLensSelector((state) => state.lens); + const dispatchLens = useLensDispatch(); - const dispatchChange: DispatchSetState = useCallback( - (s: Partial<LensAppState>) => dispatchLens(onChangeFromEditorFrame(s)), - [dispatchLens] - ); - const [visualizeTriggerFieldContext, setVisualizeTriggerFieldContext] = useState( - props.initialContext - ); - const { onError } = props; - const activeVisualization = - state.visualization.activeId && props.visualizationMap[state.visualization.activeId]; - const allLoaded = Object.values(state.datasourceStates).every( - ({ isLoading }) => typeof isLoading === 'boolean' && !isLoading - ); + const allLoaded = Object.values(datasourceStates).every(({ isLoading }) => isLoading === false); - // Initialize current datasource and all active datasources - useEffect( - () => { - // prevents executing dispatch on unmounted component - let isUnmounted = false; - if (!allLoaded) { - initializeDatasources( - props.datasourceMap, - state.datasourceStates, - persistedDoc?.references, - visualizeTriggerFieldContext, - { isFullEditor: true } - ) - .then((result) => { - if (!isUnmounted) { - Object.entries(result).forEach(([datasourceId, { state: datasourceState }]) => { - dispatch({ - type: 'UPDATE_DATASOURCE_STATE', - updater: datasourceState, - datasourceId, - }); - }); - } - }) - .catch(onError); - } - return () => { - isUnmounted = true; - }; - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [allLoaded, onError] + const datasourceLayers = React.useMemo( + () => createDatasourceLayers(props.datasourceMap, datasourceStates), + [props.datasourceMap, datasourceStates] ); - const datasourceLayers = createDatasourceLayers(props.datasourceMap, state.datasourceStates); const framePublicAPI: FramePublicAPI = useMemo( () => ({ @@ -126,232 +61,15 @@ export function EditorFrame(props: EditorFrameProps) { query, filters, searchSessionId, - availablePalettes: props.palettes, - - addNewLayer() { - const newLayerId = generateId(); - - dispatch({ - type: 'UPDATE_LAYER', - datasourceId: state.activeDatasourceId!, - layerId: newLayerId, - updater: props.datasourceMap[state.activeDatasourceId!].insertLayer, - }); - - return newLayerId; - }, - - removeLayers(layerIds: string[]) { - if (activeVisualization && activeVisualization.removeLayer && state.visualization.state) { - dispatch({ - type: 'UPDATE_VISUALIZATION_STATE', - visualizationId: activeVisualization.id, - updater: layerIds.reduce( - (acc, layerId) => - activeVisualization.removeLayer - ? activeVisualization.removeLayer(acc, layerId) - : acc, - state.visualization.state - ), - }); - } - - layerIds.forEach((layerId) => { - const layerDatasourceId = Object.entries(props.datasourceMap).find( - ([datasourceId, datasource]) => - state.datasourceStates[datasourceId] && - datasource.getLayers(state.datasourceStates[datasourceId].state).includes(layerId) - )![0]; - dispatch({ - type: 'UPDATE_LAYER', - layerId, - datasourceId: layerDatasourceId, - updater: props.datasourceMap[layerDatasourceId].removeLayer, - }); - }); - }, }), - [ - activeData, - activeVisualization, - datasourceLayers, - dateRange, - query, - filters, - searchSessionId, - props.palettes, - props.datasourceMap, - state.activeDatasourceId, - state.datasourceStates, - state.visualization.state, - ] - ); - - useEffect( - () => { - if (persistedDoc) { - dispatch({ - type: 'VISUALIZATION_LOADED', - doc: { - ...persistedDoc, - state: { - ...persistedDoc.state, - visualization: persistedDoc.visualizationType - ? props.visualizationMap[persistedDoc.visualizationType].initialize( - framePublicAPI, - persistedDoc.state.visualization - ) - : persistedDoc.state.visualization, - }, - }, - }); - } else { - dispatch({ - type: 'RESET', - state: getInitialState(props), - }); - } - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [persistedDoc] - ); - - // Initialize visualization as soon as all datasources are ready - useEffect( - () => { - if (allLoaded && state.visualization.state === null && activeVisualization) { - const initialVisualizationState = activeVisualization.initialize(framePublicAPI); - dispatch({ - type: 'UPDATE_VISUALIZATION_STATE', - visualizationId: activeVisualization.id, - updater: initialVisualizationState, - }); - } - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [allLoaded, activeVisualization, state.visualization.state] - ); - - // Get suggestions for visualize field when all datasources are ready - useEffect(() => { - if (allLoaded && visualizeTriggerFieldContext && !persistedDoc) { - applyVisualizeFieldSuggestions({ - datasourceMap: props.datasourceMap, - datasourceStates: state.datasourceStates, - visualizationMap: props.visualizationMap, - activeVisualizationId: state.visualization.activeId, - visualizationState: state.visualization.state, - visualizeTriggerFieldContext, - dispatch, - }); - setVisualizeTriggerFieldContext(undefined); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [allLoaded]); - - const getStateToUpdate: ( - arg: { - filterableIndexPatterns: string[]; - doc: Document; - isSaveable: boolean; - }, - oldState: { - isSaveable: boolean; - indexPatternsForTopNav: IndexPattern[]; - persistedDoc?: Document; - lastKnownDoc?: Document; - } - ) => Promise<Partial<LensAppState> | undefined> = async ( - { filterableIndexPatterns, doc, isSaveable: incomingIsSaveable }, - prevState - ) => { - const batchedStateToUpdate: Partial<LensAppState> = {}; - - if (incomingIsSaveable !== prevState.isSaveable) { - batchedStateToUpdate.isSaveable = incomingIsSaveable; - } - - if (!isEqual(prevState.persistedDoc, doc) && !isEqual(prevState.lastKnownDoc, doc)) { - batchedStateToUpdate.lastKnownDoc = doc; - } - const hasIndexPatternsChanged = - prevState.indexPatternsForTopNav.length !== filterableIndexPatterns.length || - filterableIndexPatterns.some( - (id) => !prevState.indexPatternsForTopNav.find((indexPattern) => indexPattern.id === id) - ); - // Update the cached index patterns if the user made a change to any of them - if (hasIndexPatternsChanged) { - const { indexPatterns } = await getAllIndexPatterns( - filterableIndexPatterns, - props.plugins.data.indexPatterns - ); - if (indexPatterns) { - batchedStateToUpdate.indexPatternsForTopNav = indexPatterns; - } - } - if (Object.keys(batchedStateToUpdate).length) { - return batchedStateToUpdate; - } - }; - - // The frame needs to call onChange every time its internal state changes - useEffect( - () => { - const activeDatasource = - state.activeDatasourceId && !state.datasourceStates[state.activeDatasourceId].isLoading - ? props.datasourceMap[state.activeDatasourceId] - : undefined; - - if (!activeDatasource || !activeVisualization) { - return; - } - - const savedObjectFormat = getSavedObjectFormat({ - activeDatasources: Object.keys(state.datasourceStates).reduce( - (datasourceMap, datasourceId) => ({ - ...datasourceMap, - [datasourceId]: props.datasourceMap[datasourceId], - }), - {} - ), - visualization: activeVisualization, - state, - framePublicAPI, - }); - - // Frame loader (app or embeddable) is expected to call this when it loads and updates - // This should be replaced with a top-down state - getStateToUpdate(savedObjectFormat, { - isSaveable, - persistedDoc, - indexPatternsForTopNav, - lastKnownDoc, - }).then((batchedStateToUpdate) => { - if (batchedStateToUpdate) { - dispatchChange(batchedStateToUpdate); - } - }); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [ - activeVisualization, - state.datasourceStates, - state.visualization, - activeData, - query, - filters, - savedQuery, - state.title, - dispatchChange, - ] + [activeData, datasourceLayers, dateRange, query, filters, searchSessionId] ); // Using a ref to prevent rerenders in the child components while keeping the latest state const getSuggestionForField = useRef<(field: DragDropIdentifier) => Suggestion | undefined>(); getSuggestionForField.current = (field: DragDropIdentifier) => { - const { activeDatasourceId, datasourceStates } = state; - const activeVisualizationId = state.visualization.activeId; - const visualizationState = state.visualization.state; + const activeVisualizationId = visualization.activeId; + const visualizationState = visualization.state; const { visualizationMap, datasourceMap } = props; if (!field || !activeDatasourceId) { @@ -379,93 +97,77 @@ export function EditorFrame(props: EditorFrameProps) { const suggestion = getSuggestionForField.current!(field); if (suggestion) { trackUiEvent('drop_onto_workspace'); - switchToSuggestion(dispatch, suggestion, 'SWITCH_VISUALIZATION'); + switchToSuggestion(dispatchLens, suggestion, 'SWITCH_VISUALIZATION'); } }, - [getSuggestionForField] + [getSuggestionForField, dispatchLens] ); return ( <RootDragDropProvider> <FrameLayout - isFullscreen={Boolean(state.isFullscreenDatasource)} + isFullscreen={Boolean(isFullscreenDatasource)} dataPanel={ <DataPanelWrapper datasourceMap={props.datasourceMap} - activeDatasource={state.activeDatasourceId} - datasourceState={ - state.activeDatasourceId - ? state.datasourceStates[state.activeDatasourceId].state - : null - } - datasourceIsLoading={ - state.activeDatasourceId - ? state.datasourceStates[state.activeDatasourceId].isLoading - : true - } - dispatch={dispatch} core={props.core} - query={query} - dateRange={dateRange} - filters={filters} + plugins={props.plugins} showNoDataPopover={props.showNoDataPopover} + activeDatasource={activeDatasourceId} + datasourceState={activeDatasourceId ? datasourceStates[activeDatasourceId].state : null} + datasourceIsLoading={ + activeDatasourceId ? datasourceStates[activeDatasourceId].isLoading : true + } dropOntoWorkspace={dropOntoWorkspace} hasSuggestionForField={hasSuggestionForField} - plugins={props.plugins} /> } configPanel={ allLoaded && ( <ConfigPanelWrapper - activeDatasourceId={state.activeDatasourceId!} + activeDatasourceId={activeDatasourceId!} datasourceMap={props.datasourceMap} - datasourceStates={state.datasourceStates} + datasourceStates={datasourceStates} visualizationMap={props.visualizationMap} - activeVisualizationId={state.visualization.activeId} - dispatch={dispatch} - visualizationState={state.visualization.state} + activeVisualizationId={visualization.activeId} + visualizationState={visualization.state} framePublicAPI={framePublicAPI} core={props.core} - isFullscreen={Boolean(state.isFullscreenDatasource)} + isFullscreen={Boolean(isFullscreenDatasource)} /> ) } workspacePanel={ allLoaded && ( <WorkspacePanel - title={state.title} - activeDatasourceId={state.activeDatasourceId} - activeVisualizationId={state.visualization.activeId} + activeDatasourceId={activeDatasourceId} + activeVisualizationId={visualization.activeId} datasourceMap={props.datasourceMap} - datasourceStates={state.datasourceStates} + datasourceStates={datasourceStates} framePublicAPI={framePublicAPI} - visualizationState={state.visualization.state} + visualizationState={visualization.state} visualizationMap={props.visualizationMap} - dispatch={dispatch} - isFullscreen={Boolean(state.isFullscreenDatasource)} + isFullscreen={Boolean(isFullscreenDatasource)} ExpressionRenderer={props.ExpressionRenderer} core={props.core} plugins={props.plugins} - visualizeTriggerFieldContext={visualizeTriggerFieldContext} getSuggestionForField={getSuggestionForField.current} /> ) } suggestionsPanel={ allLoaded && - !state.isFullscreenDatasource && ( + !isFullscreenDatasource && ( <SuggestionPanel - frame={framePublicAPI} - activeDatasourceId={state.activeDatasourceId} - activeVisualizationId={state.visualization.activeId} - datasourceMap={props.datasourceMap} - datasourceStates={state.datasourceStates} - visualizationState={state.visualization.state} visualizationMap={props.visualizationMap} - dispatch={dispatch} + datasourceMap={props.datasourceMap} ExpressionRenderer={props.ExpressionRenderer} - stagedPreview={state.stagedPreview} - plugins={props.plugins} + stagedPreview={stagedPreview} + frame={framePublicAPI} + activeVisualizationId={visualization.activeId} + activeDatasourceId={activeDatasourceId} + datasourceStates={datasourceStates} + visualizationState={visualization.state} /> ) } diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/index.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/index.ts index 66d83b1cd697f..8d4fb0683cb0c 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/index.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/index.ts @@ -7,4 +7,3 @@ export * from './editor_frame'; export * from './state_helpers'; -export * from './state_management'; diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/save.test.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/save.test.ts deleted file mode 100644 index b0bff1800d32f..0000000000000 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/save.test.ts +++ /dev/null @@ -1,116 +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 { getSavedObjectFormat, Props } from './save'; -import { createMockDatasource, createMockFramePublicAPI, createMockVisualization } from '../mocks'; -import { esFilters, IIndexPattern, IFieldType } from '../../../../../../src/plugins/data/public'; - -jest.mock('./expression_helpers'); - -describe('save editor frame state', () => { - const mockVisualization = createMockVisualization(); - const mockDatasource = createMockDatasource('a'); - const mockIndexPattern = ({ id: 'indexpattern' } as unknown) as IIndexPattern; - const mockField = ({ name: '@timestamp' } as unknown) as IFieldType; - - mockDatasource.getPersistableState.mockImplementation((x) => ({ - state: x, - savedObjectReferences: [], - })); - const saveArgs: Props = { - activeDatasources: { - indexpattern: mockDatasource, - }, - visualization: mockVisualization, - state: { - title: 'aaa', - datasourceStates: { - indexpattern: { - state: 'hello', - isLoading: false, - }, - }, - activeDatasourceId: 'indexpattern', - visualization: { activeId: '2', state: {} }, - }, - framePublicAPI: { - ...createMockFramePublicAPI(), - addNewLayer: jest.fn(), - removeLayers: jest.fn(), - datasourceLayers: { - first: mockDatasource.publicAPIMock, - }, - query: { query: '', language: 'lucene' }, - dateRange: { fromDate: 'now-7d', toDate: 'now' }, - filters: [esFilters.buildExistsFilter(mockField, mockIndexPattern)], - }, - }; - - it('transforms from internal state to persisted doc format', async () => { - const datasource = createMockDatasource('a'); - datasource.getPersistableState.mockImplementation((state) => ({ - state: { - stuff: `${state}_datasource_persisted`, - }, - savedObjectReferences: [], - })); - datasource.toExpression.mockReturnValue('my | expr'); - - const visualization = createMockVisualization(); - visualization.toExpression.mockReturnValue('vis | expr'); - - const { doc, filterableIndexPatterns, isSaveable } = await getSavedObjectFormat({ - ...saveArgs, - activeDatasources: { - indexpattern: datasource, - }, - state: { - title: 'bbb', - datasourceStates: { - indexpattern: { - state: '2', - isLoading: false, - }, - }, - activeDatasourceId: 'indexpattern', - visualization: { activeId: '3', state: '4' }, - }, - visualization, - }); - - expect(filterableIndexPatterns).toEqual([]); - expect(isSaveable).toEqual(true); - expect(doc).toEqual({ - id: undefined, - state: { - datasourceStates: { - indexpattern: { - stuff: '2_datasource_persisted', - }, - }, - visualization: '4', - query: { query: '', language: 'lucene' }, - filters: [ - { - meta: { indexRefName: 'filter-index-pattern-0' }, - exists: { field: '@timestamp' }, - }, - ], - }, - references: [ - { - id: 'indexpattern', - name: 'filter-index-pattern-0', - type: 'index-pattern', - }, - ], - title: 'bbb', - type: 'lens', - visualizationType: '3', - }); - }); -}); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/save.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/save.ts deleted file mode 100644 index 86a28be65d2b9..0000000000000 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/save.ts +++ /dev/null @@ -1,79 +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 { uniq } from 'lodash'; -import { SavedObjectReference } from 'kibana/public'; -import { EditorFrameState } from './state_management'; -import { Document } from '../../persistence/saved_object_store'; -import { Datasource, Visualization, FramePublicAPI } from '../../types'; -import { extractFilterReferences } from '../../persistence'; -import { buildExpression } from './expression_helpers'; - -export interface Props { - activeDatasources: Record<string, Datasource>; - state: EditorFrameState; - visualization: Visualization; - framePublicAPI: FramePublicAPI; -} - -export function getSavedObjectFormat({ - activeDatasources, - state, - visualization, - framePublicAPI, -}: Props): { - doc: Document; - filterableIndexPatterns: string[]; - isSaveable: boolean; -} { - const datasourceStates: Record<string, unknown> = {}; - const references: SavedObjectReference[] = []; - Object.entries(activeDatasources).forEach(([id, datasource]) => { - const { state: persistableState, savedObjectReferences } = datasource.getPersistableState( - state.datasourceStates[id].state - ); - datasourceStates[id] = persistableState; - references.push(...savedObjectReferences); - }); - - const uniqueFilterableIndexPatternIds = uniq( - references.filter(({ type }) => type === 'index-pattern').map(({ id }) => id) - ); - - const { persistableFilters, references: filterReferences } = extractFilterReferences( - framePublicAPI.filters - ); - - references.push(...filterReferences); - - const expression = buildExpression({ - visualization, - visualizationState: state.visualization.state, - datasourceMap: activeDatasources, - datasourceStates: state.datasourceStates, - datasourceLayers: framePublicAPI.datasourceLayers, - }); - - return { - doc: { - savedObjectId: state.persistedId, - title: state.title, - description: state.description, - type: 'lens', - visualizationType: state.visualization.activeId, - state: { - datasourceStates, - visualization: state.visualization.state, - query: framePublicAPI.query, - filters: persistableFilters, - }, - references, - }, - filterableIndexPatterns: uniqueFilterableIndexPatternIds, - isSaveable: expression !== null, - }; -} diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_helpers.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_helpers.ts index dffb0e75f2109..e861112f3f7b4 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_helpers.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_helpers.ts @@ -19,7 +19,7 @@ import { import { buildExpression } from './expression_helpers'; import { Document } from '../../persistence/saved_object_store'; import { VisualizeFieldContext } from '../../../../../../src/plugins/ui_actions/public'; -import { getActiveDatasourceIdFromDoc } from './state_management'; +import { getActiveDatasourceIdFromDoc } from '../../utils'; import { ErrorMessage } from '../types'; import { getMissingCurrentDatasource, diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_management.test.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_management.test.ts deleted file mode 100644 index af8a9c0a85558..0000000000000 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_management.test.ts +++ /dev/null @@ -1,415 +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 { getInitialState, reducer } from './state_management'; -import { EditorFrameProps } from './index'; -import { Datasource, Visualization } from '../../types'; -import { createExpressionRendererMock } from '../mocks'; -import { coreMock } from 'src/core/public/mocks'; -import { uiActionsPluginMock } from '../../../../../../src/plugins/ui_actions/public/mocks'; -import { dataPluginMock } from '../../../../../../src/plugins/data/public/mocks'; -import { expressionsPluginMock } from '../../../../../../src/plugins/expressions/public/mocks'; -import { chartPluginMock } from 'src/plugins/charts/public/mocks'; - -describe('editor_frame state management', () => { - describe('initialization', () => { - let props: EditorFrameProps; - - beforeEach(() => { - props = { - onError: jest.fn(), - datasourceMap: { testDatasource: ({} as unknown) as Datasource }, - visualizationMap: { testVis: ({ initialize: jest.fn() } as unknown) as Visualization }, - ExpressionRenderer: createExpressionRendererMock(), - core: coreMock.createStart(), - plugins: { - uiActions: uiActionsPluginMock.createStartContract(), - data: dataPluginMock.createStartContract(), - expressions: expressionsPluginMock.createStartContract(), - charts: chartPluginMock.createStartContract(), - }, - palettes: chartPluginMock.createPaletteRegistry(), - showNoDataPopover: jest.fn(), - }; - }); - - it('should store initial datasource and visualization', () => { - const initialState = getInitialState(props); - expect(initialState.activeDatasourceId).toEqual('testDatasource'); - expect(initialState.visualization.activeId).toEqual('testVis'); - }); - - it('should not initialize visualization but set active id', () => { - const initialState = getInitialState(props); - - expect(initialState.visualization.state).toBe(null); - expect(initialState.visualization.activeId).toBe('testVis'); - expect(props.visualizationMap.testVis.initialize).not.toHaveBeenCalled(); - }); - - it('should prefill state if doc is passed in', () => { - const initialState = getInitialState({ - ...props, - doc: { - state: { - datasourceStates: { - testDatasource: { internalState1: '' }, - testDatasource2: { internalState2: '' }, - }, - visualization: {}, - query: { query: '', language: 'lucene' }, - filters: [], - }, - references: [], - title: '', - visualizationType: 'testVis', - }, - }); - - expect(initialState.datasourceStates).toMatchInlineSnapshot(` - Object { - "testDatasource": Object { - "isLoading": true, - "state": Object { - "internalState1": "", - }, - }, - "testDatasource2": Object { - "isLoading": true, - "state": Object { - "internalState2": "", - }, - }, - } - `); - expect(initialState.visualization).toMatchInlineSnapshot(` - Object { - "activeId": "testVis", - "state": null, - } - `); - }); - - it('should not set active id if initiated with empty document and visualizationMap is empty', () => { - const initialState = getInitialState({ ...props, visualizationMap: {} }); - - expect(initialState.visualization.state).toEqual(null); - expect(initialState.visualization.activeId).toEqual(null); - expect(props.visualizationMap.testVis.initialize).not.toHaveBeenCalled(); - }); - }); - - describe('state update', () => { - it('should update the corresponding visualization state on update', () => { - const newVisState = {}; - const newState = reducer( - { - datasourceStates: { - testDatasource: { - state: {}, - isLoading: false, - }, - }, - activeDatasourceId: 'testDatasource', - title: 'aaa', - visualization: { - activeId: 'testVis', - state: {}, - }, - }, - { - type: 'UPDATE_VISUALIZATION_STATE', - visualizationId: 'testVis', - updater: newVisState, - } - ); - - expect(newState.visualization.state).toBe(newVisState); - }); - - it('should update the datasource state with passed in reducer', () => { - const datasourceReducer = jest.fn(() => ({ changed: true })); - const newState = reducer( - { - datasourceStates: { - testDatasource: { - state: {}, - isLoading: false, - }, - }, - activeDatasourceId: 'testDatasource', - title: 'bbb', - visualization: { - activeId: 'testVis', - state: {}, - }, - }, - { - type: 'UPDATE_DATASOURCE_STATE', - updater: datasourceReducer, - datasourceId: 'testDatasource', - } - ); - - expect(newState.datasourceStates.testDatasource.state).toEqual({ changed: true }); - expect(datasourceReducer).toHaveBeenCalledTimes(1); - }); - - it('should update the layer state with passed in reducer', () => { - const newDatasourceState = {}; - const newState = reducer( - { - datasourceStates: { - testDatasource: { - state: {}, - isLoading: false, - }, - }, - activeDatasourceId: 'testDatasource', - title: 'bbb', - visualization: { - activeId: 'testVis', - state: {}, - }, - }, - { - type: 'UPDATE_DATASOURCE_STATE', - updater: newDatasourceState, - datasourceId: 'testDatasource', - } - ); - - expect(newState.datasourceStates.testDatasource.state).toBe(newDatasourceState); - }); - - it('should should switch active visualization', () => { - const testVisState = {}; - const newVisState = {}; - const newState = reducer( - { - datasourceStates: { - testDatasource: { - state: {}, - isLoading: false, - }, - }, - activeDatasourceId: 'testDatasource', - title: 'ccc', - visualization: { - activeId: 'testVis', - state: testVisState, - }, - }, - { - type: 'SWITCH_VISUALIZATION', - newVisualizationId: 'testVis2', - initialState: newVisState, - } - ); - - expect(newState.visualization.state).toBe(newVisState); - }); - - it('should should switch active visualization and update datasource state', () => { - const testVisState = {}; - const newVisState = {}; - const newDatasourceState = {}; - const newState = reducer( - { - datasourceStates: { - testDatasource: { - state: {}, - isLoading: false, - }, - }, - activeDatasourceId: 'testDatasource', - title: 'ddd', - visualization: { - activeId: 'testVis', - state: testVisState, - }, - }, - { - type: 'SWITCH_VISUALIZATION', - newVisualizationId: 'testVis2', - initialState: newVisState, - datasourceState: newDatasourceState, - datasourceId: 'testDatasource', - } - ); - - expect(newState.visualization.state).toBe(newVisState); - expect(newState.datasourceStates.testDatasource.state).toBe(newDatasourceState); - }); - - it('should should switch active datasource and initialize new state', () => { - const newState = reducer( - { - datasourceStates: { - testDatasource: { - state: {}, - isLoading: false, - }, - }, - activeDatasourceId: 'testDatasource', - title: 'eee', - visualization: { - activeId: 'testVis', - state: {}, - }, - }, - { - type: 'SWITCH_DATASOURCE', - newDatasourceId: 'testDatasource2', - } - ); - - expect(newState.activeDatasourceId).toEqual('testDatasource2'); - expect(newState.datasourceStates.testDatasource2.isLoading).toEqual(true); - }); - - it('not initialize already initialized datasource on switch', () => { - const datasource2State = {}; - const newState = reducer( - { - datasourceStates: { - testDatasource: { - state: {}, - isLoading: false, - }, - testDatasource2: { - state: datasource2State, - isLoading: false, - }, - }, - activeDatasourceId: 'testDatasource', - title: 'eee', - visualization: { - activeId: 'testVis', - state: {}, - }, - }, - { - type: 'SWITCH_DATASOURCE', - newDatasourceId: 'testDatasource2', - } - ); - - expect(newState.activeDatasourceId).toEqual('testDatasource2'); - expect(newState.datasourceStates.testDatasource2.state).toBe(datasource2State); - }); - - it('should reset the state', () => { - const newState = reducer( - { - datasourceStates: { - a: { - state: {}, - isLoading: false, - }, - }, - activeDatasourceId: 'a', - title: 'jjj', - visualization: { - activeId: 'b', - state: {}, - }, - }, - { - type: 'RESET', - state: { - datasourceStates: { - z: { - isLoading: false, - state: { hola: 'muchacho' }, - }, - }, - activeDatasourceId: 'z', - persistedId: 'bar', - title: 'lll', - visualization: { - activeId: 'q', - state: { my: 'viz' }, - }, - }, - } - ); - - expect(newState).toMatchObject({ - datasourceStates: { - z: { - isLoading: false, - state: { hola: 'muchacho' }, - }, - }, - activeDatasourceId: 'z', - persistedId: 'bar', - visualization: { - activeId: 'q', - state: { my: 'viz' }, - }, - }); - }); - - it('should load the state from the doc', () => { - const newState = reducer( - { - datasourceStates: { - a: { - state: {}, - isLoading: false, - }, - }, - activeDatasourceId: 'a', - title: 'mmm', - visualization: { - activeId: 'b', - state: {}, - }, - }, - { - type: 'VISUALIZATION_LOADED', - doc: { - savedObjectId: 'b', - state: { - datasourceStates: { a: { foo: 'c' } }, - visualization: { bar: 'd' }, - query: { query: '', language: 'lucene' }, - filters: [], - }, - title: 'heyo!', - description: 'My lens', - type: 'lens', - visualizationType: 'line', - references: [], - }, - } - ); - - expect(newState).toEqual({ - activeDatasourceId: 'a', - datasourceStates: { - a: { - isLoading: true, - state: { - foo: 'c', - }, - }, - }, - persistedId: 'b', - title: 'heyo!', - description: 'My lens', - visualization: { - activeId: 'line', - state: { - bar: 'd', - }, - }, - }); - }); - }); -}); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_management.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_management.ts deleted file mode 100644 index a87aa7a2cb428..0000000000000 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_management.ts +++ /dev/null @@ -1,293 +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 { EditorFrameProps } from './index'; -import { Document } from '../../persistence/saved_object_store'; - -export interface PreviewState { - visualization: { - activeId: string | null; - state: unknown; - }; - datasourceStates: Record<string, { state: unknown; isLoading: boolean }>; -} - -export interface EditorFrameState extends PreviewState { - persistedId?: string; - title: string; - description?: string; - stagedPreview?: PreviewState; - activeDatasourceId: string | null; - isFullscreenDatasource?: boolean; -} - -export type Action = - | { - type: 'RESET'; - state: EditorFrameState; - } - | { - type: 'UPDATE_TITLE'; - title: string; - } - | { - type: 'UPDATE_STATE'; - // Just for diagnostics, so we can determine what action - // caused this update. - subType: string; - updater: (prevState: EditorFrameState) => EditorFrameState; - } - | { - type: 'UPDATE_DATASOURCE_STATE'; - updater: unknown | ((prevState: unknown) => unknown); - datasourceId: string; - clearStagedPreview?: boolean; - } - | { - type: 'UPDATE_VISUALIZATION_STATE'; - visualizationId: string; - updater: unknown | ((state: unknown) => unknown); - clearStagedPreview?: boolean; - } - | { - type: 'UPDATE_LAYER'; - layerId: string; - datasourceId: string; - updater: (state: unknown, layerId: string) => unknown; - } - | { - type: 'VISUALIZATION_LOADED'; - doc: Document; - } - | { - type: 'SWITCH_VISUALIZATION'; - newVisualizationId: string; - initialState: unknown; - } - | { - type: 'SWITCH_VISUALIZATION'; - newVisualizationId: string; - initialState: unknown; - datasourceState: unknown; - datasourceId: string; - } - | { - type: 'SELECT_SUGGESTION'; - newVisualizationId: string; - initialState: unknown; - datasourceState: unknown; - datasourceId: string; - } - | { - type: 'ROLLBACK_SUGGESTION'; - } - | { - type: 'SUBMIT_SUGGESTION'; - } - | { - type: 'SWITCH_DATASOURCE'; - newDatasourceId: string; - } - | { - type: 'TOGGLE_FULLSCREEN'; - }; - -export function getActiveDatasourceIdFromDoc(doc?: Document) { - if (!doc) { - return null; - } - - const [firstDatasourceFromDoc] = Object.keys(doc.state.datasourceStates); - return firstDatasourceFromDoc || null; -} - -export const getInitialState = ( - params: EditorFrameProps & { doc?: Document } -): EditorFrameState => { - const datasourceStates: EditorFrameState['datasourceStates'] = {}; - - const initialDatasourceId = - getActiveDatasourceIdFromDoc(params.doc) || Object.keys(params.datasourceMap)[0] || null; - - const initialVisualizationId = - (params.doc && params.doc.visualizationType) || Object.keys(params.visualizationMap)[0] || null; - - if (params.doc) { - Object.entries(params.doc.state.datasourceStates).forEach(([datasourceId, state]) => { - datasourceStates[datasourceId] = { isLoading: true, state }; - }); - } else if (initialDatasourceId) { - datasourceStates[initialDatasourceId] = { - state: null, - isLoading: true, - }; - } - - return { - title: '', - datasourceStates, - activeDatasourceId: initialDatasourceId, - visualization: { - state: null, - activeId: initialVisualizationId, - }, - }; -}; - -export const reducer = (state: EditorFrameState, action: Action): EditorFrameState => { - switch (action.type) { - case 'RESET': - return action.state; - case 'UPDATE_TITLE': - return { ...state, title: action.title }; - case 'UPDATE_STATE': - return action.updater(state); - case 'UPDATE_LAYER': - return { - ...state, - datasourceStates: { - ...state.datasourceStates, - [action.datasourceId]: { - ...state.datasourceStates[action.datasourceId], - state: action.updater( - state.datasourceStates[action.datasourceId].state, - action.layerId - ), - }, - }, - }; - case 'VISUALIZATION_LOADED': - return { - ...state, - persistedId: action.doc.savedObjectId, - title: action.doc.title, - description: action.doc.description, - datasourceStates: Object.entries(action.doc.state.datasourceStates).reduce( - (stateMap, [datasourceId, datasourceState]) => ({ - ...stateMap, - [datasourceId]: { - isLoading: true, - state: datasourceState, - }, - }), - {} - ), - activeDatasourceId: getActiveDatasourceIdFromDoc(action.doc), - visualization: { - ...state.visualization, - activeId: action.doc.visualizationType, - state: action.doc.state.visualization, - }, - }; - case 'SWITCH_DATASOURCE': - return { - ...state, - datasourceStates: { - ...state.datasourceStates, - [action.newDatasourceId]: state.datasourceStates[action.newDatasourceId] || { - state: null, - isLoading: true, - }, - }, - activeDatasourceId: action.newDatasourceId, - }; - case 'SWITCH_VISUALIZATION': - return { - ...state, - datasourceStates: - 'datasourceId' in action && action.datasourceId - ? { - ...state.datasourceStates, - [action.datasourceId]: { - ...state.datasourceStates[action.datasourceId], - state: action.datasourceState, - }, - } - : state.datasourceStates, - visualization: { - ...state.visualization, - activeId: action.newVisualizationId, - state: action.initialState, - }, - stagedPreview: undefined, - }; - case 'SELECT_SUGGESTION': - return { - ...state, - datasourceStates: - 'datasourceId' in action && action.datasourceId - ? { - ...state.datasourceStates, - [action.datasourceId]: { - ...state.datasourceStates[action.datasourceId], - state: action.datasourceState, - }, - } - : state.datasourceStates, - visualization: { - ...state.visualization, - activeId: action.newVisualizationId, - state: action.initialState, - }, - stagedPreview: state.stagedPreview || { - datasourceStates: state.datasourceStates, - visualization: state.visualization, - }, - }; - case 'ROLLBACK_SUGGESTION': - return { - ...state, - ...(state.stagedPreview || {}), - stagedPreview: undefined, - }; - case 'SUBMIT_SUGGESTION': - return { - ...state, - stagedPreview: undefined, - }; - case 'UPDATE_DATASOURCE_STATE': - return { - ...state, - datasourceStates: { - ...state.datasourceStates, - [action.datasourceId]: { - state: - typeof action.updater === 'function' - ? action.updater(state.datasourceStates[action.datasourceId].state) - : action.updater, - isLoading: false, - }, - }, - stagedPreview: action.clearStagedPreview ? undefined : state.stagedPreview, - }; - case 'UPDATE_VISUALIZATION_STATE': - if (!state.visualization.activeId) { - throw new Error('Invariant: visualization state got updated without active visualization'); - } - // This is a safeguard that prevents us from accidentally updating the - // wrong visualization. This occurs in some cases due to the uncoordinated - // way we manage state across plugins. - if (state.visualization.activeId !== action.visualizationId) { - return state; - } - return { - ...state, - visualization: { - ...state.visualization, - state: - typeof action.updater === 'function' - ? action.updater(state.visualization.state) - : action.updater, - }, - stagedPreview: action.clearStagedPreview ? undefined : state.stagedPreview, - }; - case 'TOGGLE_FULLSCREEN': - return { ...state, isFullscreenDatasource: !state.isFullscreenDatasource }; - default: - return state; - } -}; diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.test.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.test.ts index 0e8c9b962b995..6f33cc4b8aab8 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.test.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.test.ts @@ -6,7 +6,7 @@ */ import { getSuggestions, getTopSuggestionForField } from './suggestion_helpers'; -import { createMockVisualization, createMockDatasource, DatasourceMock } from '../mocks'; +import { createMockVisualization, createMockDatasource, DatasourceMock } from '../../mocks'; import { TableSuggestion, DatasourceSuggestion, Visualization } from '../../types'; import { PaletteOutput } from 'src/plugins/charts/public'; diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts index bd8f134f59fbb..9fdc283c3cc29 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts @@ -19,8 +19,8 @@ import { DatasourceSuggestion, DatasourcePublicAPI, } from '../../types'; -import { Action } from './state_management'; import { DragDropIdentifier } from '../../drag_drop'; +import { LensDispatch, selectSuggestion, switchVisualization } from '../../state_management'; export interface Suggestion { visualizationId: string; @@ -132,14 +132,13 @@ export function getSuggestions({ ).sort((a, b) => b.score - a.score); } -export function applyVisualizeFieldSuggestions({ +export function getVisualizeFieldSuggestions({ datasourceMap, datasourceStates, visualizationMap, activeVisualizationId, visualizationState, visualizeTriggerFieldContext, - dispatch, }: { datasourceMap: Record<string, Datasource>; datasourceStates: Record< @@ -154,8 +153,7 @@ export function applyVisualizeFieldSuggestions({ subVisualizationId?: string; visualizationState: unknown; visualizeTriggerFieldContext?: VisualizeFieldContext; - dispatch: (action: Action) => void; -}): void { +}): Suggestion | undefined { const suggestions = getSuggestions({ datasourceMap, datasourceStates, @@ -165,9 +163,7 @@ export function applyVisualizeFieldSuggestions({ visualizeTriggerFieldContext, }); if (suggestions.length) { - const selectedSuggestion = - suggestions.find((s) => s.visualizationId === activeVisualizationId) || suggestions[0]; - switchToSuggestion(dispatch, selectedSuggestion, 'SWITCH_VISUALIZATION'); + return suggestions.find((s) => s.visualizationId === activeVisualizationId) || suggestions[0]; } } @@ -207,22 +203,25 @@ function getVisualizationSuggestions( } export function switchToSuggestion( - dispatch: (action: Action) => void, + dispatchLens: LensDispatch, suggestion: Pick< Suggestion, 'visualizationId' | 'visualizationState' | 'datasourceState' | 'datasourceId' >, type: 'SWITCH_VISUALIZATION' | 'SELECT_SUGGESTION' = 'SELECT_SUGGESTION' ) { - const action: Action = { - type, + const pickedSuggestion = { newVisualizationId: suggestion.visualizationId, initialState: suggestion.visualizationState, datasourceState: suggestion.datasourceState, datasourceId: suggestion.datasourceId!, }; - dispatch(action); + dispatchLens( + type === 'SELECT_SUGGESTION' + ? selectSuggestion(pickedSuggestion) + : switchVisualization(pickedSuggestion) + ); } export function getTopSuggestionForField( diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.test.tsx index 2b755a2e8bf08..6445038e40d7c 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.test.tsx @@ -6,7 +6,6 @@ */ import React from 'react'; -import { mountWithIntl as mount } from '@kbn/test/jest'; import { Visualization } from '../../types'; import { createMockVisualization, @@ -14,15 +13,15 @@ import { createExpressionRendererMock, DatasourceMock, createMockFramePublicAPI, -} from '../mocks'; +} from '../../mocks'; import { act } from 'react-dom/test-utils'; import { ReactExpressionRendererType } from '../../../../../../src/plugins/expressions/public'; import { esFilters, IFieldType, IIndexPattern } from '../../../../../../src/plugins/data/public'; import { SuggestionPanel, SuggestionPanelProps } from './suggestion_panel'; import { getSuggestions, Suggestion } from './suggestion_helpers'; import { EuiIcon, EuiPanel, EuiToolTip } from '@elastic/eui'; -import { dataPluginMock } from '../../../../../../src/plugins/data/public/mocks'; import { LensIconChartDatatable } from '../../assets/chart_datatable'; +import { mountWithProvider } from '../../mocks'; jest.mock('./suggestion_helpers'); @@ -33,7 +32,6 @@ describe('suggestion_panel', () => { let mockDatasource: DatasourceMock; let expressionRendererMock: ReactExpressionRendererType; - let dispatchMock: jest.Mock; const suggestion1State = { suggestion1: true }; const suggestion2State = { suggestion2: true }; @@ -44,7 +42,6 @@ describe('suggestion_panel', () => { mockVisualization = createMockVisualization(); mockDatasource = createMockDatasource('a'); expressionRendererMock = createExpressionRendererMock(); - dispatchMock = jest.fn(); getSuggestionsMock.mockReturnValue([ { @@ -84,18 +81,16 @@ describe('suggestion_panel', () => { vis2: createMockVisualization(), }, visualizationState: {}, - dispatch: dispatchMock, ExpressionRenderer: expressionRendererMock, frame: createMockFramePublicAPI(), - plugins: { data: dataPluginMock.createStartContract() }, }; }); - it('should list passed in suggestions', () => { - const wrapper = mount(<SuggestionPanel {...defaultProps} />); + it('should list passed in suggestions', async () => { + const { instance } = await mountWithProvider(<SuggestionPanel {...defaultProps} />); expect( - wrapper + instance .find('[data-test-subj="lnsSuggestion"]') .find(EuiPanel) .map((el) => el.parents(EuiToolTip).prop('content')) @@ -129,90 +124,97 @@ describe('suggestion_panel', () => { }; }); - it('should not update suggestions if current state is moved to staged preview', () => { - const wrapper = mount(<SuggestionPanel {...defaultProps} />); + it('should not update suggestions if current state is moved to staged preview', async () => { + const { instance } = await mountWithProvider(<SuggestionPanel {...defaultProps} />); getSuggestionsMock.mockClear(); - wrapper.setProps({ + instance.setProps({ stagedPreview, ...suggestionState, }); - wrapper.update(); + instance.update(); expect(getSuggestionsMock).not.toHaveBeenCalled(); }); - it('should update suggestions if staged preview is removed', () => { - const wrapper = mount(<SuggestionPanel {...defaultProps} />); + it('should update suggestions if staged preview is removed', async () => { + const { instance } = await mountWithProvider(<SuggestionPanel {...defaultProps} />); getSuggestionsMock.mockClear(); - wrapper.setProps({ + instance.setProps({ stagedPreview, ...suggestionState, }); - wrapper.update(); - wrapper.setProps({ + instance.update(); + instance.setProps({ stagedPreview: undefined, ...suggestionState, }); - wrapper.update(); + instance.update(); expect(getSuggestionsMock).toHaveBeenCalledTimes(1); }); - it('should highlight currently active suggestion', () => { - const wrapper = mount(<SuggestionPanel {...defaultProps} />); + it('should highlight currently active suggestion', async () => { + const { instance } = await mountWithProvider(<SuggestionPanel {...defaultProps} />); act(() => { - wrapper.find('[data-test-subj="lnsSuggestion"]').at(2).simulate('click'); + instance.find('[data-test-subj="lnsSuggestion"]').at(2).simulate('click'); }); - wrapper.update(); + instance.update(); - expect(wrapper.find('[data-test-subj="lnsSuggestion"]').at(2).prop('className')).toContain( + expect(instance.find('[data-test-subj="lnsSuggestion"]').at(2).prop('className')).toContain( 'lnsSuggestionPanel__button-isSelected' ); }); - it('should rollback suggestion if current panel is clicked', () => { - const wrapper = mount(<SuggestionPanel {...defaultProps} />); + it('should rollback suggestion if current panel is clicked', async () => { + const { instance, lensStore } = await mountWithProvider( + <SuggestionPanel {...defaultProps} /> + ); act(() => { - wrapper.find('[data-test-subj="lnsSuggestion"]').at(2).simulate('click'); + instance.find('[data-test-subj="lnsSuggestion"]').at(2).simulate('click'); }); - wrapper.update(); + instance.update(); act(() => { - wrapper.find('[data-test-subj="lnsSuggestion"]').at(0).simulate('click'); + instance.find('[data-test-subj="lnsSuggestion"]').at(0).simulate('click'); }); - wrapper.update(); + instance.update(); - expect(dispatchMock).toHaveBeenCalledWith({ - type: 'ROLLBACK_SUGGESTION', + expect(lensStore.dispatch).toHaveBeenCalledWith({ + type: 'lens/rollbackSuggestion', }); }); }); - it('should dispatch visualization switch action if suggestion is clicked', () => { - const wrapper = mount(<SuggestionPanel {...defaultProps} />); + it('should dispatch visualization switch action if suggestion is clicked', async () => { + const { instance, lensStore } = await mountWithProvider(<SuggestionPanel {...defaultProps} />); act(() => { - wrapper.find('button[data-test-subj="lnsSuggestion"]').at(1).simulate('click'); + instance.find('button[data-test-subj="lnsSuggestion"]').at(1).simulate('click'); }); - wrapper.update(); + instance.update(); - expect(dispatchMock).toHaveBeenCalledWith( + expect(lensStore.dispatch).toHaveBeenCalledWith( expect.objectContaining({ - type: 'SELECT_SUGGESTION', - initialState: suggestion1State, + type: 'lens/selectSuggestion', + payload: { + datasourceId: undefined, + datasourceState: {}, + initialState: { suggestion1: true }, + newVisualizationId: 'vis', + }, }) ); }); - it('should render preview expression if there is one', () => { + it('should render render icon if there is no preview expression', async () => { mockDatasource.getLayers.mockReturnValue(['first']); - (getSuggestions as jest.Mock).mockReturnValue([ + getSuggestionsMock.mockReturnValue([ { datasourceState: {}, - previewIcon: 'empty', + previewIcon: LensIconChartDatatable, score: 0.5, visualizationState: suggestion1State, visualizationId: 'vis', @@ -225,43 +227,51 @@ describe('suggestion_panel', () => { visualizationState: suggestion2State, visualizationId: 'vis', title: 'Suggestion2', + previewExpression: 'test | expression', }, ] as Suggestion[]); (mockVisualization.toPreviewExpression as jest.Mock).mockReturnValueOnce(undefined); (mockVisualization.toPreviewExpression as jest.Mock).mockReturnValueOnce('test | expression'); + + // this call will go to the currently active visualization + (mockVisualization.toPreviewExpression as jest.Mock).mockReturnValueOnce('current | preview'); + mockDatasource.toExpression.mockReturnValue('datasource_expression'); - const indexPattern = ({ id: 'index1' } as unknown) as IIndexPattern; - const field = ({ name: 'myfield' } as unknown) as IFieldType; + const { instance } = await mountWithProvider(<SuggestionPanel {...defaultProps} />); - mount( - <SuggestionPanel - {...defaultProps} - frame={{ - ...createMockFramePublicAPI(), - filters: [esFilters.buildExistsFilter(field, indexPattern)], - }} - /> - ); + expect(instance.find(EuiIcon)).toHaveLength(1); + expect(instance.find(EuiIcon).prop('type')).toEqual(LensIconChartDatatable); + }); - expect(expressionRendererMock).toHaveBeenCalledTimes(1); - const passedExpression = (expressionRendererMock as jest.Mock).mock.calls[0][0].expression; + it('should return no suggestion if visualization has missing index-patterns', async () => { + // create a layer that is referencing an indexPatterns not retrieved by the datasource + const missingIndexPatternsState = { + layers: { indexPatternId: 'a' }, + indexPatterns: {}, + }; + mockDatasource.checkIntegrity.mockReturnValue(['a']); + const newProps = { + ...defaultProps, + datasourceStates: { + mock: { + ...defaultProps.datasourceStates.mock, + state: missingIndexPatternsState, + }, + }, + }; - expect(passedExpression).toMatchInlineSnapshot(` - "kibana - | lens_merge_tables layerIds=\\"first\\" tables={datasource_expression} - | test - | expression" - `); + const { instance } = await mountWithProvider(<SuggestionPanel {...newProps} />); + expect(instance.html()).toEqual(null); }); - it('should render render icon if there is no preview expression', () => { + it('should render preview expression if there is one', () => { mockDatasource.getLayers.mockReturnValue(['first']); - getSuggestionsMock.mockReturnValue([ + (getSuggestions as jest.Mock).mockReturnValue([ { datasourceState: {}, - previewIcon: LensIconChartDatatable, + previewIcon: 'empty', score: 0.5, visualizationState: suggestion1State, visualizationId: 'vis', @@ -274,41 +284,34 @@ describe('suggestion_panel', () => { visualizationState: suggestion2State, visualizationId: 'vis', title: 'Suggestion2', - previewExpression: 'test | expression', }, ] as Suggestion[]); (mockVisualization.toPreviewExpression as jest.Mock).mockReturnValueOnce(undefined); (mockVisualization.toPreviewExpression as jest.Mock).mockReturnValueOnce('test | expression'); - - // this call will go to the currently active visualization - (mockVisualization.toPreviewExpression as jest.Mock).mockReturnValueOnce('current | preview'); - mockDatasource.toExpression.mockReturnValue('datasource_expression'); - const wrapper = mount(<SuggestionPanel {...defaultProps} />); + const indexPattern = ({ id: 'index1' } as unknown) as IIndexPattern; + const field = ({ name: 'myfield' } as unknown) as IFieldType; - expect(wrapper.find(EuiIcon)).toHaveLength(1); - expect(wrapper.find(EuiIcon).prop('type')).toEqual(LensIconChartDatatable); - }); + mountWithProvider( + <SuggestionPanel + {...defaultProps} + frame={{ + ...createMockFramePublicAPI(), + filters: [esFilters.buildExistsFilter(field, indexPattern)], + }} + /> + ); - it('should return no suggestion if visualization has missing index-patterns', () => { - // create a layer that is referencing an indexPatterns not retrieved by the datasource - const missingIndexPatternsState = { - layers: { indexPatternId: 'a' }, - indexPatterns: {}, - }; - mockDatasource.checkIntegrity.mockReturnValue(['a']); - const newProps = { - ...defaultProps, - datasourceStates: { - mock: { - ...defaultProps.datasourceStates.mock, - state: missingIndexPatternsState, - }, - }, - }; - const wrapper = mount(<SuggestionPanel {...newProps} />); - expect(wrapper.html()).toEqual(null); + expect(expressionRendererMock).toHaveBeenCalledTimes(1); + const passedExpression = (expressionRendererMock as jest.Mock).mock.calls[0][0].expression; + + expect(passedExpression).toMatchInlineSnapshot(` + "kibana + | lens_merge_tables layerIds=\\"first\\" tables={datasource_expression} + | test + | expression" + `); }); }); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx index 8107b6646500d..6d360a09a5b49 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx @@ -24,8 +24,7 @@ import { IconType } from '@elastic/eui/src/components/icon/icon'; import { Ast, toExpression } from '@kbn/interpreter/common'; import { i18n } from '@kbn/i18n'; import classNames from 'classnames'; -import { DataPublicPluginStart, ExecutionContextSearch } from 'src/plugins/data/public'; -import { Action, PreviewState } from './state_management'; +import { ExecutionContextSearch } from 'src/plugins/data/public'; import { Datasource, Visualization, FramePublicAPI, DatasourcePublicAPI } from '../../types'; import { getSuggestions, switchToSuggestion } from './suggestion_helpers'; import { @@ -35,6 +34,12 @@ import { import { prependDatasourceExpression } from './expression_helpers'; import { trackUiEvent, trackSuggestionEvent } from '../../lens_ui_telemetry'; import { getMissingIndexPattern, validateDatasourceAndVisualization } from './state_helpers'; +import { + PreviewState, + rollbackSuggestion, + submitSuggestion, + useLensDispatch, +} from '../../state_management'; const MAX_SUGGESTIONS_DISPLAYED = 5; @@ -51,11 +56,9 @@ export interface SuggestionPanelProps { activeVisualizationId: string | null; visualizationMap: Record<string, Visualization>; visualizationState: unknown; - dispatch: (action: Action) => void; ExpressionRenderer: ReactExpressionRendererType; frame: FramePublicAPI; stagedPreview?: PreviewState; - plugins: { data: DataPublicPluginStart }; } const PreviewRenderer = ({ @@ -170,12 +173,12 @@ export function SuggestionPanel({ activeVisualizationId, visualizationMap, visualizationState, - dispatch, frame, ExpressionRenderer: ExpressionRendererComponent, stagedPreview, - plugins, }: SuggestionPanelProps) { + const dispatchLens = useLensDispatch(); + const currentDatasourceStates = stagedPreview ? stagedPreview.datasourceStates : datasourceStates; const currentVisualizationState = stagedPreview ? stagedPreview.visualization.state @@ -320,9 +323,7 @@ export function SuggestionPanel({ if (lastSelectedSuggestion !== -1) { trackSuggestionEvent('back_to_current'); setLastSelectedSuggestion(-1); - dispatch({ - type: 'ROLLBACK_SUGGESTION', - }); + dispatchLens(rollbackSuggestion()); } } @@ -352,9 +353,7 @@ export function SuggestionPanel({ iconType="refresh" onClick={() => { trackUiEvent('suggestion_confirmed'); - dispatch({ - type: 'SUBMIT_SUGGESTION', - }); + dispatchLens(submitSuggestion()); }} > {i18n.translate('xpack.lens.sugegstion.refreshSuggestionLabel', { @@ -401,7 +400,7 @@ export function SuggestionPanel({ rollbackToCurrentVisualization(); } else { setLastSelectedSuggestion(index); - switchToSuggestion(dispatch, suggestion); + switchToSuggestion(dispatchLens, suggestion); } }} selected={index === lastSelectedSuggestion} diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.test.tsx index 46e287297828d..9b5766c3e3bfa 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.test.tsx @@ -11,7 +11,8 @@ import { createMockVisualization, createMockFramePublicAPI, createMockDatasource, -} from '../../mocks'; +} from '../../../mocks'; +import { mountWithProvider } from '../../../mocks'; // Tests are executed in a jsdom environment who does not have sizing methods, // thus the AutoSizer will always compute a 0x0 size space @@ -25,9 +26,7 @@ jest.mock('react-virtualized-auto-sizer', () => { }; }); -import { mountWithIntl as mount } from '@kbn/test/jest'; import { Visualization, FramePublicAPI, DatasourcePublicAPI } from '../../../types'; -import { Action } from '../state_management'; import { ChartSwitch } from './chart_switch'; import { PaletteOutput } from 'src/plugins/charts/public'; @@ -157,6 +156,8 @@ describe('chart_switch', () => { keptLayerIds: ['a'], }, ]); + + datasource.getLayers.mockReturnValue(['a']); return { testDatasource: datasource, }; @@ -171,78 +172,94 @@ describe('chart_switch', () => { }; } - function showFlyout(component: ReactWrapper) { - component.find('[data-test-subj="lnsChartSwitchPopover"]').first().simulate('click'); + function showFlyout(instance: ReactWrapper) { + instance.find('[data-test-subj="lnsChartSwitchPopover"]').first().simulate('click'); } - function switchTo(subType: string, component: ReactWrapper) { - showFlyout(component); - component.find(`[data-test-subj="lnsChartSwitchPopover_${subType}"]`).first().simulate('click'); + function switchTo(subType: string, instance: ReactWrapper) { + showFlyout(instance); + instance.find(`[data-test-subj="lnsChartSwitchPopover_${subType}"]`).first().simulate('click'); } - function getMenuItem(subType: string, component: ReactWrapper) { - showFlyout(component); - return component.find(`[data-test-subj="lnsChartSwitchPopover_${subType}"]`).first(); + function getMenuItem(subType: string, instance: ReactWrapper) { + showFlyout(instance); + return instance.find(`[data-test-subj="lnsChartSwitchPopover_${subType}"]`).first(); } - - it('should use suggested state if there is a suggestion from the target visualization', () => { - const dispatch = jest.fn(); + it('should use suggested state if there is a suggestion from the target visualization', async () => { const visualizations = mockVisualizations(); - const component = mount( + const { instance, lensStore } = await mountWithProvider( <ChartSwitch - visualizationId="visA" - visualizationState={'state from a'} visualizationMap={visualizations} - dispatch={dispatch} framePublicAPI={mockFrame(['a'])} datasourceMap={mockDatasourceMap()} - datasourceStates={mockDatasourceStates()} - /> + />, + { + preloadedState: { + visualization: { + activeId: 'visA', + state: 'state from a', + }, + }, + } ); - switchTo('visB', component); + switchTo('visB', instance); - expect(dispatch).toHaveBeenCalledWith({ - initialState: 'suggestion visB', - newVisualizationId: 'visB', - type: 'SWITCH_VISUALIZATION', - datasourceId: 'testDatasource', - datasourceState: {}, + expect(lensStore.dispatch).toHaveBeenCalledWith({ + type: 'lens/switchVisualization', + payload: { + initialState: 'suggestion visB', + newVisualizationId: 'visB', + datasourceId: 'testDatasource', + datasourceState: {}, + }, }); }); - it('should use initial state if there is no suggestion from the target visualization', () => { - const dispatch = jest.fn(); + it('should use initial state if there is no suggestion from the target visualization', async () => { const visualizations = mockVisualizations(); visualizations.visB.getSuggestions.mockReturnValueOnce([]); const frame = mockFrame(['a']); (frame.datasourceLayers.a.getTableSpec as jest.Mock).mockReturnValue([]); - - const component = mount( + const datasourceMap = mockDatasourceMap(); + const datasourceStates = mockDatasourceStates(); + const { instance, lensStore } = await mountWithProvider( <ChartSwitch - visualizationId="visA" - visualizationState={{}} visualizationMap={visualizations} - dispatch={dispatch} framePublicAPI={frame} - datasourceMap={mockDatasourceMap()} - datasourceStates={mockDatasourceStates()} - /> + datasourceMap={datasourceMap} + />, + { + preloadedState: { + datasourceStates, + activeDatasourceId: 'testDatasource', + visualization: { + activeId: 'visA', + state: {}, + }, + }, + } ); - switchTo('visB', component); - - expect(frame.removeLayers).toHaveBeenCalledWith(['a']); - - expect(dispatch).toHaveBeenCalledWith({ - initialState: 'visB initial state', - newVisualizationId: 'visB', - type: 'SWITCH_VISUALIZATION', + switchTo('visB', instance); + expect(datasourceMap.testDatasource.removeLayer).toHaveBeenCalledWith({}, 'a'); // from preloaded state + expect(lensStore.dispatch).toHaveBeenCalledWith({ + type: 'lens/switchVisualization', + payload: { + initialState: 'visB initial state', + newVisualizationId: 'visB', + }, + }); + expect(lensStore.dispatch).toHaveBeenCalledWith({ + type: 'lens/updateLayer', + payload: expect.objectContaining({ + datasourceId: 'testDatasource', + layerId: 'a', + }), }); }); - it('should indicate data loss if not all columns will be used', () => { - const dispatch = jest.fn(); + it('should indicate data loss if not all columns will be used', async () => { const visualizations = mockVisualizations(); const frame = mockFrame(['a']); @@ -282,53 +299,59 @@ describe('chart_switch', () => { { columnId: 'col3' }, ]); - const component = mount( + const { instance } = await mountWithProvider( <ChartSwitch - visualizationId="visA" - visualizationState={{}} visualizationMap={visualizations} - dispatch={dispatch} framePublicAPI={frame} datasourceMap={mockDatasourceMap()} - datasourceStates={mockDatasourceStates()} - /> + />, + { + preloadedState: { + visualization: { + activeId: 'visA', + state: {}, + }, + }, + } ); expect( - getMenuItem('visB', component) + getMenuItem('visB', instance) .find('[data-test-subj="lnsChartSwitchPopoverAlert_visB"]') .first() .props().type ).toEqual('alert'); }); - it('should indicate data loss if not all layers will be used', () => { - const dispatch = jest.fn(); + it('should indicate data loss if not all layers will be used', async () => { const visualizations = mockVisualizations(); const frame = mockFrame(['a', 'b']); - const component = mount( + const { instance } = await mountWithProvider( <ChartSwitch - visualizationId="visA" - visualizationState={{}} visualizationMap={visualizations} - dispatch={dispatch} framePublicAPI={frame} datasourceMap={mockDatasourceMap()} - datasourceStates={mockDatasourceStates()} - /> + />, + { + preloadedState: { + visualization: { + activeId: 'visA', + state: {}, + }, + }, + } ); expect( - getMenuItem('visB', component) + getMenuItem('visB', instance) .find('[data-test-subj="lnsChartSwitchPopoverAlert_visB"]') .first() .props().type ).toEqual('alert'); }); - it('should support multi-layer suggestions without data loss', () => { - const dispatch = jest.fn(); + it('should support multi-layer suggestions without data loss', async () => { const visualizations = mockVisualizations(); const frame = mockFrame(['a', 'b']); @@ -355,75 +378,85 @@ describe('chart_switch', () => { }, ]); - const component = mount( + const { instance } = await mountWithProvider( <ChartSwitch - visualizationId="visA" - visualizationState={{}} visualizationMap={visualizations} - dispatch={dispatch} framePublicAPI={frame} datasourceMap={datasourceMap} - datasourceStates={mockDatasourceStates()} - /> + />, + { + preloadedState: { + visualization: { + activeId: 'visA', + state: {}, + }, + }, + } ); expect( - getMenuItem('visB', component).find('[data-test-subj="lnsChartSwitchPopoverAlert_visB"]') + getMenuItem('visB', instance).find('[data-test-subj="lnsChartSwitchPopoverAlert_visB"]') ).toHaveLength(0); }); - it('should indicate data loss if no data will be used', () => { - const dispatch = jest.fn(); + it('should indicate data loss if no data will be used', async () => { const visualizations = mockVisualizations(); visualizations.visB.getSuggestions.mockReturnValueOnce([]); const frame = mockFrame(['a']); - const component = mount( + const { instance } = await mountWithProvider( <ChartSwitch - visualizationId="visA" - visualizationState={{}} visualizationMap={visualizations} - dispatch={dispatch} framePublicAPI={frame} datasourceMap={mockDatasourceMap()} - datasourceStates={mockDatasourceStates()} - /> + />, + { + preloadedState: { + visualization: { + activeId: 'visA', + state: {}, + }, + }, + } ); expect( - getMenuItem('visB', component) + getMenuItem('visB', instance) .find('[data-test-subj="lnsChartSwitchPopoverAlert_visB"]') .first() .props().type ).toEqual('alert'); }); - it('should not indicate data loss if there is no data', () => { - const dispatch = jest.fn(); + it('should not indicate data loss if there is no data', async () => { const visualizations = mockVisualizations(); visualizations.visB.getSuggestions.mockReturnValueOnce([]); const frame = mockFrame(['a']); (frame.datasourceLayers.a.getTableSpec as jest.Mock).mockReturnValue([]); - const component = mount( + const { instance } = await mountWithProvider( <ChartSwitch - visualizationId="visA" - visualizationState={{}} visualizationMap={visualizations} - dispatch={dispatch} framePublicAPI={frame} datasourceMap={mockDatasourceMap()} - datasourceStates={mockDatasourceStates()} - /> + />, + + { + preloadedState: { + visualization: { + activeId: 'visA', + state: {}, + }, + }, + } ); expect( - getMenuItem('visB', component).find('[data-test-subj="lnsChartSwitchPopoverAlert_visB"]') + getMenuItem('visB', instance).find('[data-test-subj="lnsChartSwitchPopoverAlert_visB"]') ).toHaveLength(0); }); - it('should not show a warning when the subvisualization is the same', () => { - const dispatch = jest.fn(); + it('should not show a warning when the subvisualization is the same', async () => { const frame = mockFrame(['a', 'b', 'c']); const visualizations = mockVisualizations(); visualizations.visC.getVisualizationTypeId.mockReturnValue('subvisC2'); @@ -431,64 +464,81 @@ describe('chart_switch', () => { visualizations.visC.switchVisualizationType = switchVisualizationType; - const component = mount( + const datasourceMap = mockDatasourceMap(); + const datasourceStates = mockDatasourceStates(); + + const { instance } = await mountWithProvider( <ChartSwitch - visualizationId="visC" - visualizationState={{ type: 'subvisC2' }} visualizationMap={visualizations} - dispatch={dispatch} framePublicAPI={frame} - datasourceMap={mockDatasourceMap()} - datasourceStates={mockDatasourceStates()} - /> + datasourceMap={datasourceMap} + />, + { + preloadedState: { + datasourceStates, + activeDatasourceId: 'testDatasource', + visualization: { + activeId: 'visC', + state: { type: 'subvisC2' }, + }, + }, + } ); expect( - getMenuItem('subvisC2', component).find( + getMenuItem('subvisC2', instance).find( '[data-test-subj="lnsChartSwitchPopoverAlert_subvisC2"]' ) ).toHaveLength(0); }); - it('should get suggestions when switching subvisualization', () => { - const dispatch = jest.fn(); + it('should get suggestions when switching subvisualization', async () => { const visualizations = mockVisualizations(); visualizations.visB.getSuggestions.mockReturnValueOnce([]); const frame = mockFrame(['a', 'b', 'c']); + const datasourceMap = mockDatasourceMap(); + datasourceMap.testDatasource.getLayers.mockReturnValue(['a', 'b', 'c']); + const datasourceStates = mockDatasourceStates(); - const component = mount( + const { instance, lensStore } = await mountWithProvider( <ChartSwitch - visualizationId="visA" - visualizationState={{}} visualizationMap={visualizations} - dispatch={dispatch} framePublicAPI={frame} - datasourceMap={mockDatasourceMap()} - datasourceStates={mockDatasourceStates()} - /> + datasourceMap={datasourceMap} + />, + { + preloadedState: { + datasourceStates, + visualization: { + activeId: 'visA', + state: {}, + }, + }, + } ); - switchTo('visB', component); - - expect(frame.removeLayers).toHaveBeenCalledTimes(1); - expect(frame.removeLayers).toHaveBeenCalledWith(['a', 'b', 'c']); - + switchTo('visB', instance); + expect(datasourceMap.testDatasource.removeLayer).toHaveBeenCalledWith({}, 'a'); + expect(datasourceMap.testDatasource.removeLayer).toHaveBeenCalledWith(undefined, 'b'); + expect(datasourceMap.testDatasource.removeLayer).toHaveBeenCalledWith(undefined, 'c'); expect(visualizations.visB.getSuggestions).toHaveBeenCalledWith( expect.objectContaining({ keptLayerIds: ['a'], }) ); - expect(dispatch).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'SWITCH_VISUALIZATION', + expect(lensStore.dispatch).toHaveBeenCalledWith({ + type: 'lens/switchVisualization', + payload: { + datasourceId: undefined, + datasourceState: undefined, initialState: 'visB initial state', - }) - ); + newVisualizationId: 'visB', + }, + }); }); - it('should query main palette from active chart and pass into suggestions', () => { - const dispatch = jest.fn(); + it('should query main palette from active chart and pass into suggestions', async () => { const visualizations = mockVisualizations(); const mockPalette: PaletteOutput = { type: 'palette', name: 'mock' }; visualizations.visA.getMainPalette = jest.fn(() => mockPalette); @@ -496,19 +546,26 @@ describe('chart_switch', () => { const frame = mockFrame(['a', 'b', 'c']); const currentVisState = {}; - const component = mount( + const datasourceMap = mockDatasourceMap(); + datasourceMap.testDatasource.getLayers.mockReturnValue(['a', 'b', 'c']); + + const { instance } = await mountWithProvider( <ChartSwitch - visualizationId="visA" - visualizationState={currentVisState} visualizationMap={visualizations} - dispatch={dispatch} framePublicAPI={frame} - datasourceMap={mockDatasourceMap()} - datasourceStates={mockDatasourceStates()} - /> + datasourceMap={datasourceMap} + />, + { + preloadedState: { + visualization: { + activeId: 'visA', + state: currentVisState, + }, + }, + } ); - switchTo('visB', component); + switchTo('visB', instance); expect(visualizations.visA.getMainPalette).toHaveBeenCalledWith(currentVisState); @@ -520,67 +577,76 @@ describe('chart_switch', () => { ); }); - it('should not remove layers when switching between subtypes', () => { - const dispatch = jest.fn(); + it('should not remove layers when switching between subtypes', async () => { const frame = mockFrame(['a', 'b', 'c']); const visualizations = mockVisualizations(); const switchVisualizationType = jest.fn(() => 'switched'); visualizations.visC.switchVisualizationType = switchVisualizationType; - - const component = mount( + const datasourceMap = mockDatasourceMap(); + const { instance, lensStore } = await mountWithProvider( <ChartSwitch - visualizationId="visC" - visualizationState={{ type: 'subvisC1' }} visualizationMap={visualizations} - dispatch={dispatch} framePublicAPI={frame} - datasourceMap={mockDatasourceMap()} - datasourceStates={mockDatasourceStates()} - /> + datasourceMap={datasourceMap} + />, + { + preloadedState: { + visualization: { + activeId: 'visC', + state: { type: 'subvisC1' }, + }, + }, + } ); - switchTo('subvisC3', component); + switchTo('subvisC3', instance); expect(switchVisualizationType).toHaveBeenCalledWith('subvisC3', { type: 'subvisC3' }); - expect(dispatch).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'SWITCH_VISUALIZATION', + + expect(lensStore.dispatch).toHaveBeenCalledWith({ + type: 'lens/switchVisualization', + payload: { + datasourceId: 'testDatasource', + datasourceState: {}, initialState: 'switched', - }) - ); - expect(frame.removeLayers).not.toHaveBeenCalled(); + newVisualizationId: 'visC', + }, + }); + expect(datasourceMap.testDatasource.removeLayer).not.toHaveBeenCalled(); }); - it('should not remove layers and initialize with existing state when switching between subtypes without data', () => { - const dispatch = jest.fn(); + it('should not remove layers and initialize with existing state when switching between subtypes without data', async () => { const frame = mockFrame(['a']); frame.datasourceLayers.a.getTableSpec = jest.fn().mockReturnValue([]); const visualizations = mockVisualizations(); visualizations.visC.getSuggestions = jest.fn().mockReturnValue([]); visualizations.visC.switchVisualizationType = jest.fn(() => 'switched'); - - const component = mount( + const datasourceMap = mockDatasourceMap(); + const { instance } = await mountWithProvider( <ChartSwitch - visualizationId="visC" - visualizationState={{ type: 'subvisC1' }} visualizationMap={visualizations} - dispatch={dispatch} framePublicAPI={frame} - datasourceMap={mockDatasourceMap()} - datasourceStates={mockDatasourceStates()} - /> + datasourceMap={datasourceMap} + />, + { + preloadedState: { + visualization: { + activeId: 'visC', + state: { type: 'subvisC1' }, + }, + }, + } ); - switchTo('subvisC3', component); + switchTo('subvisC3', instance); expect(visualizations.visC.switchVisualizationType).toHaveBeenCalledWith('subvisC3', { type: 'subvisC1', }); - expect(frame.removeLayers).not.toHaveBeenCalled(); + expect(datasourceMap.testDatasource.removeLayer).not.toHaveBeenCalled(); }); - it('should switch to the updated datasource state', () => { - const dispatch = jest.fn(); + it('should switch to the updated datasource state', async () => { const visualizations = mockVisualizations(); const frame = mockFrame(['a', 'b']); @@ -615,31 +681,36 @@ describe('chart_switch', () => { }, ]); - const component = mount( + const { instance, lensStore } = await mountWithProvider( <ChartSwitch - visualizationId="visA" - visualizationState={{}} visualizationMap={visualizations} - dispatch={dispatch} framePublicAPI={frame} datasourceMap={datasourceMap} - datasourceStates={mockDatasourceStates()} - /> + />, + { + preloadedState: { + visualization: { + activeId: 'visA', + state: {}, + }, + }, + } ); - switchTo('visB', component); + switchTo('visB', instance); - expect(dispatch).toHaveBeenCalledWith({ - type: 'SWITCH_VISUALIZATION', - newVisualizationId: 'visB', - datasourceId: 'testDatasource', - datasourceState: 'testDatasource suggestion', - initialState: 'suggestion visB', - } as Action); + expect(lensStore.dispatch).toHaveBeenCalledWith({ + type: 'lens/switchVisualization', + payload: { + newVisualizationId: 'visB', + datasourceId: 'testDatasource', + datasourceState: 'testDatasource suggestion', + initialState: 'suggestion visB', + }, + }); }); - it('should ensure the new visualization has the proper subtype', () => { - const dispatch = jest.fn(); + it('should ensure the new visualization has the proper subtype', async () => { const visualizations = mockVisualizations(); const switchVisualizationType = jest.fn( (visualizationType, state) => `${state} ${visualizationType}` @@ -647,72 +718,85 @@ describe('chart_switch', () => { visualizations.visB.switchVisualizationType = switchVisualizationType; - const component = mount( + const { instance, lensStore } = await mountWithProvider( <ChartSwitch - visualizationId="visA" - visualizationState={{}} visualizationMap={visualizations} - dispatch={dispatch} framePublicAPI={mockFrame(['a'])} datasourceMap={mockDatasourceMap()} - datasourceStates={mockDatasourceStates()} - /> + />, + { + preloadedState: { + visualization: { + activeId: 'visA', + state: {}, + }, + }, + } ); - switchTo('visB', component); + switchTo('visB', instance); - expect(dispatch).toHaveBeenCalledWith({ - initialState: 'suggestion visB visB', - newVisualizationId: 'visB', - type: 'SWITCH_VISUALIZATION', - datasourceId: 'testDatasource', - datasourceState: {}, + expect(lensStore.dispatch).toHaveBeenCalledWith({ + type: 'lens/switchVisualization', + payload: { + initialState: 'suggestion visB visB', + newVisualizationId: 'visB', + datasourceId: 'testDatasource', + datasourceState: {}, + }, }); }); - it('should use the suggestion that matches the subtype', () => { - const dispatch = jest.fn(); + it('should use the suggestion that matches the subtype', async () => { const visualizations = mockVisualizations(); const switchVisualizationType = jest.fn(); visualizations.visC.switchVisualizationType = switchVisualizationType; - const component = mount( + const { instance } = await mountWithProvider( <ChartSwitch - visualizationId="visC" - visualizationState={{ type: 'subvisC3' }} visualizationMap={visualizations} - dispatch={dispatch} framePublicAPI={mockFrame(['a'])} datasourceMap={mockDatasourceMap()} - datasourceStates={mockDatasourceStates()} - /> + />, + { + preloadedState: { + visualization: { + activeId: 'visC', + state: { type: 'subvisC3' }, + }, + }, + } ); - switchTo('subvisC1', component); + switchTo('subvisC1', instance); expect(switchVisualizationType).toHaveBeenCalledWith('subvisC1', { type: 'subvisC1', notPrimary: true, }); }); - it('should show all visualization types', () => { - const component = mount( + it('should show all visualization types', async () => { + const { instance } = await mountWithProvider( <ChartSwitch - visualizationId="visA" - visualizationState={{}} visualizationMap={mockVisualizations()} - dispatch={jest.fn()} framePublicAPI={mockFrame(['a', 'b'])} datasourceMap={mockDatasourceMap()} - datasourceStates={mockDatasourceStates()} - /> + />, + { + preloadedState: { + visualization: { + activeId: 'visA', + state: {}, + }, + }, + } ); - showFlyout(component); + showFlyout(instance); const allDisplayed = ['visA', 'visB', 'subvisC1', 'subvisC2', 'subvisC3'].every( - (subType) => component.find(`[data-test-subj="lnsChartSwitchPopover_${subType}"]`).length > 0 + (subType) => instance.find(`[data-test-subj="lnsChartSwitchPopover_${subType}"]`).length > 0 ); expect(allDisplayed).toBeTruthy(); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx index 0c3a992e3dd7a..f948ec6a59687 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx @@ -21,10 +21,16 @@ import { import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { Visualization, FramePublicAPI, Datasource, VisualizationType } from '../../../types'; -import { Action } from '../state_management'; import { getSuggestions, switchToSuggestion, Suggestion } from '../suggestion_helpers'; import { trackUiEvent } from '../../../lens_ui_telemetry'; import { ToolbarButton } from '../../../../../../../src/plugins/kibana_react/public'; +import { + updateLayer, + updateVisualizationState, + useLensDispatch, + useLensSelector, +} from '../../../state_management'; +import { generateId } from '../../../id_generator/id_generator'; interface VisualizationSelection { visualizationId: string; @@ -38,27 +44,26 @@ interface VisualizationSelection { } interface Props { - dispatch: (action: Action) => void; visualizationMap: Record<string, Visualization>; - visualizationId: string | null; - visualizationState: unknown; framePublicAPI: FramePublicAPI; datasourceMap: Record<string, Datasource>; - datasourceStates: Record< - string, - { - isLoading: boolean; - state: unknown; - } - >; } type SelectableEntry = EuiSelectableOption<{ value: string }>; -function VisualizationSummary(props: Props) { - const visualization = props.visualizationMap[props.visualizationId || '']; +function VisualizationSummary({ + visualizationMap, + visualization, +}: { + visualizationMap: Record<string, Visualization>; + visualization: { + activeId: string | null; + state: unknown; + }; +}) { + const activeVisualization = visualizationMap[visualization.activeId || '']; - if (!visualization) { + if (!activeVisualization) { return ( <> {i18n.translate('xpack.lens.configPanel.selectVisualization', { @@ -68,7 +73,7 @@ function VisualizationSummary(props: Props) { ); } - const description = visualization.getDescription(props.visualizationState); + const description = activeVisualization.getDescription(visualization.state); return ( <> @@ -99,6 +104,44 @@ function getCurrentVisualizationId( export const ChartSwitch = memo(function ChartSwitch(props: Props) { const [flyoutOpen, setFlyoutOpen] = useState<boolean>(false); + const dispatchLens = useLensDispatch(); + const activeDatasourceId = useLensSelector((state) => state.lens.activeDatasourceId); + const visualization = useLensSelector((state) => state.lens.visualization); + const datasourceStates = useLensSelector((state) => state.lens.datasourceStates); + + function removeLayers(layerIds: string[]) { + const activeVisualization = + visualization.activeId && props.visualizationMap[visualization.activeId]; + if (activeVisualization && activeVisualization.removeLayer && visualization.state) { + dispatchLens( + updateVisualizationState({ + visualizationId: activeVisualization.id, + updater: layerIds.reduce( + (acc, layerId) => + activeVisualization.removeLayer ? activeVisualization.removeLayer(acc, layerId) : acc, + visualization.state + ), + }) + ); + } + layerIds.forEach((layerId) => { + const layerDatasourceId = Object.entries(props.datasourceMap).find( + ([datasourceId, datasource]) => { + return ( + datasourceStates[datasourceId] && + datasource.getLayers(datasourceStates[datasourceId].state).includes(layerId) + ); + } + )![0]; + dispatchLens( + updateLayer({ + layerId, + datasourceId: layerDatasourceId, + updater: props.datasourceMap[layerDatasourceId].removeLayer, + }) + ); + }); + } const commitSelection = (selection: VisualizationSelection) => { setFlyoutOpen(false); @@ -106,7 +149,7 @@ export const ChartSwitch = memo(function ChartSwitch(props: Props) { trackUiEvent(`chart_switch`); switchToSuggestion( - props.dispatch, + dispatchLens, { ...selection, visualizationState: selection.getVisualizationState(), @@ -118,7 +161,7 @@ export const ChartSwitch = memo(function ChartSwitch(props: Props) { (!selection.datasourceId && !selection.sameDatasources) || selection.dataLoss === 'everything' ) { - props.framePublicAPI.removeLayers(Object.keys(props.framePublicAPI.datasourceLayers)); + removeLayers(Object.keys(props.framePublicAPI.datasourceLayers)); } }; @@ -136,16 +179,16 @@ export const ChartSwitch = memo(function ChartSwitch(props: Props) { ); // Always show the active visualization as a valid selection if ( - props.visualizationId === visualizationId && - props.visualizationState && - newVisualization.getVisualizationTypeId(props.visualizationState) === subVisualizationId + visualization.activeId === visualizationId && + visualization.state && + newVisualization.getVisualizationTypeId(visualization.state) === subVisualizationId ) { return { visualizationId, subVisualizationId, dataLoss: 'nothing', keptLayerIds: Object.keys(props.framePublicAPI.datasourceLayers), - getVisualizationState: () => switchVisType(subVisualizationId, props.visualizationState), + getVisualizationState: () => switchVisType(subVisualizationId, visualization.state), sameDatasources: true, }; } @@ -153,6 +196,8 @@ export const ChartSwitch = memo(function ChartSwitch(props: Props) { const topSuggestion = getTopSuggestion( props, visualizationId, + datasourceStates, + visualization, newVisualization, subVisualizationId ); @@ -171,6 +216,19 @@ export const ChartSwitch = memo(function ChartSwitch(props: Props) { dataLoss = 'nothing'; } + function addNewLayer() { + const newLayerId = generateId(); + dispatchLens( + updateLayer({ + datasourceId: activeDatasourceId!, + layerId: newLayerId, + updater: props.datasourceMap[activeDatasourceId!].insertLayer, + }) + ); + + return newLayerId; + } + return { visualizationId, subVisualizationId, @@ -179,29 +237,26 @@ export const ChartSwitch = memo(function ChartSwitch(props: Props) { ? () => switchVisType( subVisualizationId, - newVisualization.initialize(props.framePublicAPI, topSuggestion.visualizationState) + newVisualization.initialize(addNewLayer, topSuggestion.visualizationState) ) - : () => { - return switchVisType( + : () => + switchVisType( subVisualizationId, newVisualization.initialize( - props.framePublicAPI, - props.visualizationId === newVisualization.id - ? props.visualizationState - : undefined, - props.visualizationId && - props.visualizationMap[props.visualizationId].getMainPalette - ? props.visualizationMap[props.visualizationId].getMainPalette!( - props.visualizationState + addNewLayer, + visualization.activeId === newVisualization.id ? visualization.state : undefined, + visualization.activeId && + props.visualizationMap[visualization.activeId].getMainPalette + ? props.visualizationMap[visualization.activeId].getMainPalette!( + visualization.state ) : undefined ) - ); - }, + ), keptLayerIds: topSuggestion ? topSuggestion.keptLayerIds : [], datasourceState: topSuggestion ? topSuggestion.datasourceState : undefined, datasourceId: topSuggestion ? topSuggestion.datasourceId : undefined, - sameDatasources: dataLoss === 'nothing' && props.visualizationId === newVisualization.id, + sameDatasources: dataLoss === 'nothing' && visualization.activeId === newVisualization.id, }; } @@ -213,8 +268,8 @@ export const ChartSwitch = memo(function ChartSwitch(props: Props) { return { visualizationTypes: [], visualizationsLookup: {} }; } const subVisualizationId = getCurrentVisualizationId( - props.visualizationMap[props.visualizationId || ''], - props.visualizationState + props.visualizationMap[visualization.activeId || ''], + visualization.state ); const lowercasedSearchTerm = searchTerm.toLowerCase(); // reorganize visualizations in groups @@ -351,8 +406,8 @@ export const ChartSwitch = memo(function ChartSwitch(props: Props) { flyoutOpen, props.visualizationMap, props.framePublicAPI, - props.visualizationId, - props.visualizationState, + visualization.activeId, + visualization.state, searchTerm, ] ); @@ -371,7 +426,10 @@ export const ChartSwitch = memo(function ChartSwitch(props: Props) { data-test-subj="lnsChartSwitchPopover" fontWeight="bold" > - <VisualizationSummary {...props} /> + <VisualizationSummary + visualization={visualization} + visualizationMap={props.visualizationMap} + /> </ToolbarButton> } isOpen={flyoutOpen} @@ -402,7 +460,7 @@ export const ChartSwitch = memo(function ChartSwitch(props: Props) { }} options={visualizationTypes} onChange={(newOptions) => { - const chosenType = newOptions.find(({ checked }) => checked === 'on')!; + const chosenType = newOptions.find(({ checked }) => checked === 'on'); if (!chosenType) { return; } @@ -434,21 +492,26 @@ export const ChartSwitch = memo(function ChartSwitch(props: Props) { function getTopSuggestion( props: Props, visualizationId: string, + datasourceStates: Record<string, { state: unknown; isLoading: boolean }>, + visualization: { + activeId: string | null; + state: unknown; + }, newVisualization: Visualization<unknown>, subVisualizationId?: string ): Suggestion | undefined { const mainPalette = - props.visualizationId && - props.visualizationMap[props.visualizationId] && - props.visualizationMap[props.visualizationId].getMainPalette - ? props.visualizationMap[props.visualizationId].getMainPalette!(props.visualizationState) + visualization.activeId && + props.visualizationMap[visualization.activeId] && + props.visualizationMap[visualization.activeId].getMainPalette + ? props.visualizationMap[visualization.activeId].getMainPalette!(visualization.state) : undefined; const unfilteredSuggestions = getSuggestions({ datasourceMap: props.datasourceMap, - datasourceStates: props.datasourceStates, + datasourceStates, visualizationMap: { [visualizationId]: newVisualization }, - activeVisualizationId: props.visualizationId, - visualizationState: props.visualizationState, + activeVisualizationId: visualization.activeId, + visualizationState: visualization.state, subVisualizationId, activeData: props.framePublicAPI.activeData, mainPalette, diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/title.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/title.tsx new file mode 100644 index 0000000000000..b7d3d211eb777 --- /dev/null +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/title.tsx @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import './workspace_panel_wrapper.scss'; + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiScreenReaderOnly } from '@elastic/eui'; +import { LensState, useLensSelector } from '../../../state_management'; + +export function WorkspaceTitle() { + const title = useLensSelector((state: LensState) => state.lens.persistedDoc?.title); + return ( + <EuiScreenReaderOnly> + <h1 id="lns_ChartTitle" data-test-subj="lns_ChartTitle"> + {title || + i18n.translate('xpack.lens.chartTitle.unsaved', { + defaultMessage: 'Unsaved visualization', + })} + </h1> + </EuiScreenReaderOnly> + ); +} diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.test.tsx index 38e9bb868b26a..4feb13fcfffd9 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.test.tsx @@ -15,7 +15,7 @@ import { createExpressionRendererMock, DatasourceMock, createMockFramePublicAPI, -} from '../../mocks'; +} from '../../../mocks'; import { mockDataPlugin, mountWithProvider } from '../../../mocks'; jest.mock('../../../debounced_component', () => { return { @@ -24,7 +24,6 @@ jest.mock('../../../debounced_component', () => { }); import { WorkspacePanel } from './workspace_panel'; -import { mountWithIntl as mount } from '@kbn/test/jest'; import { ReactWrapper } from 'enzyme'; import { DragDrop, ChildDragDropProvider } from '../../../drag_drop'; import { fromExpression } from '@kbn/interpreter/common'; @@ -56,7 +55,6 @@ const defaultProps = { framePublicAPI: createMockFramePublicAPI(), activeVisualizationId: 'vis', visualizationState: {}, - dispatch: () => {}, ExpressionRenderer: createExpressionRendererMock(), core: createCoreStartWithPermissions(), plugins: { @@ -104,7 +102,8 @@ describe('workspace_panel', () => { }} ExpressionRenderer={expressionRendererMock} />, - defaultProps.plugins.data + + { data: defaultProps.plugins.data } ); instance = mounted.instance; expect(instance.find('[data-test-subj="empty-workspace"]')).toHaveLength(2); @@ -119,7 +118,8 @@ describe('workspace_panel', () => { vis: { ...mockVisualization, toExpression: () => null }, }} />, - defaultProps.plugins.data + + { data: defaultProps.plugins.data } ); instance = mounted.instance; @@ -135,7 +135,8 @@ describe('workspace_panel', () => { vis: { ...mockVisualization, toExpression: () => 'vis' }, }} />, - defaultProps.plugins.data + + { data: defaultProps.plugins.data } ); instance = mounted.instance; @@ -169,7 +170,7 @@ describe('workspace_panel', () => { }} ExpressionRenderer={expressionRendererMock} />, - defaultProps.plugins.data + { data: defaultProps.plugins.data } ); instance = mounted.instance; @@ -209,7 +210,8 @@ describe('workspace_panel', () => { ExpressionRenderer={expressionRendererMock} plugins={{ ...props.plugins, uiActions: uiActionsMock }} />, - defaultProps.plugins.data + + { data: defaultProps.plugins.data } ); instance = mounted.instance; @@ -229,7 +231,6 @@ describe('workspace_panel', () => { }; mockDatasource.toExpression.mockReturnValue('datasource'); mockDatasource.getLayers.mockReturnValue(['first']); - const dispatch = jest.fn(); const mounted = await mountWithProvider( <WorkspacePanel @@ -247,10 +248,10 @@ describe('workspace_panel', () => { visualizationMap={{ vis: { ...mockVisualization, toExpression: () => 'vis' }, }} - dispatch={dispatch} ExpressionRenderer={expressionRendererMock} />, - defaultProps.plugins.data + + { data: defaultProps.plugins.data } ); instance = mounted.instance; @@ -261,8 +262,8 @@ describe('workspace_panel', () => { onData(undefined, { tables: { tables: tableData } }); expect(mounted.lensStore.dispatch).toHaveBeenCalledWith({ - type: 'app/onActiveDataChange', - payload: { activeData: tableData }, + type: 'lens/onActiveDataChange', + payload: tableData, }); }); @@ -302,7 +303,8 @@ describe('workspace_panel', () => { }} ExpressionRenderer={expressionRendererMock} />, - defaultProps.plugins.data + + { data: defaultProps.plugins.data } ); instance = mounted.instance; @@ -377,7 +379,8 @@ describe('workspace_panel', () => { }} ExpressionRenderer={expressionRendererMock} />, - defaultProps.plugins.data + + { data: defaultProps.plugins.data } ); instance = mounted.instance; }); @@ -430,7 +433,8 @@ describe('workspace_panel', () => { }} ExpressionRenderer={expressionRendererMock} />, - defaultProps.plugins.data + + { data: defaultProps.plugins.data } ); instance = mounted.instance; }); @@ -481,7 +485,8 @@ describe('workspace_panel', () => { vis: { ...mockVisualization, toExpression: () => 'vis' }, }} />, - defaultProps.plugins.data + + { data: defaultProps.plugins.data } ); instance = mounted.instance; @@ -520,7 +525,8 @@ describe('workspace_panel', () => { management: { kibana: { indexPatterns: true } }, })} />, - defaultProps.plugins.data + + { data: defaultProps.plugins.data } ); instance = mounted.instance; @@ -559,7 +565,8 @@ describe('workspace_panel', () => { management: { kibana: { indexPatterns: false } }, })} />, - defaultProps.plugins.data + + { data: defaultProps.plugins.data } ); instance = mounted.instance; @@ -595,7 +602,8 @@ describe('workspace_panel', () => { vis: { ...mockVisualization, toExpression: () => 'vis' }, }} />, - defaultProps.plugins.data + + { data: defaultProps.plugins.data } ); instance = mounted.instance; @@ -632,7 +640,8 @@ describe('workspace_panel', () => { vis: mockVisualization, }} />, - defaultProps.plugins.data + + { data: defaultProps.plugins.data } ); instance = mounted.instance; @@ -671,7 +680,8 @@ describe('workspace_panel', () => { vis: mockVisualization, }} />, - defaultProps.plugins.data + + { data: defaultProps.plugins.data } ); instance = mounted.instance; @@ -707,7 +717,8 @@ describe('workspace_panel', () => { vis: { ...mockVisualization, toExpression: () => 'vis' }, }} />, - defaultProps.plugins.data + + { data: defaultProps.plugins.data } ); instance = mounted.instance; @@ -742,7 +753,8 @@ describe('workspace_panel', () => { }} ExpressionRenderer={expressionRendererMock} />, - defaultProps.plugins.data + + { data: defaultProps.plugins.data } ); instance = mounted.instance; }); @@ -783,7 +795,8 @@ describe('workspace_panel', () => { }} ExpressionRenderer={expressionRendererMock} />, - defaultProps.plugins.data + + { data: defaultProps.plugins.data } ); instance = mounted.instance; }); @@ -805,7 +818,6 @@ describe('workspace_panel', () => { }); describe('suggestions from dropping in workspace panel', () => { - let mockDispatch: jest.Mock; let mockGetSuggestionForField: jest.Mock; let frame: jest.Mocked<FramePublicAPI>; @@ -813,12 +825,11 @@ describe('workspace_panel', () => { beforeEach(() => { frame = createMockFramePublicAPI(); - mockDispatch = jest.fn(); mockGetSuggestionForField = jest.fn(); }); - function initComponent(draggingContext = draggedField) { - instance = mount( + async function initComponent(draggingContext = draggedField) { + const mounted = await mountWithProvider( <ChildDragDropProvider dragging={draggingContext} setDragging={() => {}} @@ -846,11 +857,12 @@ describe('workspace_panel', () => { vis: mockVisualization, vis2: mockVisualization2, }} - dispatch={mockDispatch} getSuggestionForField={mockGetSuggestionForField} /> </ChildDragDropProvider> ); + instance = mounted.instance; + return mounted; } it('should immediately transition if exactly one suggestion is returned', async () => { @@ -860,32 +872,34 @@ describe('workspace_panel', () => { datasourceId: 'mock', visualizationState: {}, }); - initComponent(); + const { lensStore } = await initComponent(); instance.find(DragDrop).prop('onDrop')!(draggedField, 'field_replace'); - expect(mockDispatch).toHaveBeenCalledWith({ - type: 'SWITCH_VISUALIZATION', - newVisualizationId: 'vis', - initialState: {}, - datasourceState: {}, - datasourceId: 'mock', + expect(lensStore.dispatch).toHaveBeenCalledWith({ + type: 'lens/switchVisualization', + payload: { + newVisualizationId: 'vis', + initialState: {}, + datasourceState: {}, + datasourceId: 'mock', + }, }); }); - it('should allow to drop if there are suggestions', () => { + it('should allow to drop if there are suggestions', async () => { mockGetSuggestionForField.mockReturnValue({ visualizationId: 'vis', datasourceState: {}, datasourceId: 'mock', visualizationState: {}, }); - initComponent(); + await initComponent(); expect(instance.find(DragDrop).prop('dropTypes')).toBeTruthy(); }); - it('should refuse to drop if there are no suggestions', () => { - initComponent(); + it('should refuse to drop if there are no suggestions', async () => { + await initComponent(); expect(instance.find(DragDrop).prop('dropType')).toBeFalsy(); }); }); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx index 01d4e84ec4374..943dec8f0ed20 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx @@ -33,7 +33,6 @@ import { ExpressionRenderError, ReactExpressionRendererType, } from '../../../../../../../src/plugins/expressions/public'; -import { Action } from '../state_management'; import { Datasource, Visualization, @@ -46,17 +45,20 @@ import { DragDrop, DragContext, DragDropIdentifier } from '../../../drag_drop'; import { Suggestion, switchToSuggestion } from '../suggestion_helpers'; import { buildExpression } from '../expression_helpers'; import { trackUiEvent } from '../../../lens_ui_telemetry'; -import { - UiActionsStart, - VisualizeFieldContext, -} from '../../../../../../../src/plugins/ui_actions/public'; +import { UiActionsStart } from '../../../../../../../src/plugins/ui_actions/public'; import { VIS_EVENT_TO_TRIGGER } from '../../../../../../../src/plugins/visualizations/public'; import { WorkspacePanelWrapper } from './workspace_panel_wrapper'; import { DropIllustration } from '../../../assets/drop_illustration'; import { getOriginalRequestErrorMessages } from '../../error_helper'; import { getMissingIndexPattern, validateDatasourceAndVisualization } from '../state_helpers'; import { DefaultInspectorAdapters } from '../../../../../../../src/plugins/expressions/common'; -import { onActiveDataChange, useLensDispatch } from '../../../state_management'; +import { + onActiveDataChange, + useLensDispatch, + updateVisualizationState, + updateDatasourceState, + setSaveable, +} from '../../../state_management'; export interface WorkspacePanelProps { activeVisualizationId: string | null; @@ -72,12 +74,9 @@ export interface WorkspacePanelProps { } >; framePublicAPI: FramePublicAPI; - dispatch: (action: Action) => void; ExpressionRenderer: ReactExpressionRendererType; core: CoreStart; plugins: { uiActions?: UiActionsStart; data: DataPublicPluginStart }; - title?: string; - visualizeTriggerFieldContext?: VisualizeFieldContext; getSuggestionForField: (field: DragDropIdentifier) => Suggestion | undefined; isFullscreen: boolean; } @@ -128,17 +127,15 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({ datasourceMap, datasourceStates, framePublicAPI, - dispatch, core, plugins, ExpressionRenderer: ExpressionRendererComponent, - title, - visualizeTriggerFieldContext, suggestionForDraggedField, isFullscreen, }: Omit<WorkspacePanelProps, 'getSuggestionForField'> & { suggestionForDraggedField: Suggestion | undefined; }) { + const dispatchLens = useLensDispatch(); const [localState, setLocalState] = useState<WorkspaceState>({ expressionBuildError: undefined, expandError: false, @@ -196,6 +193,7 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({ datasourceStates, datasourceLayers: framePublicAPI.datasourceLayers, }); + if (ast) { // expression has to be turned into a string for dirty checking - if the ast is rebuilt, // turning it into a string will make sure the expression renderer only re-renders if the @@ -233,6 +231,14 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({ ); const expressionExists = Boolean(expression); + const hasLoaded = Boolean( + activeVisualization && visualizationState && datasourceMap && datasourceStates + ); + useEffect(() => { + if (hasLoaded) { + dispatchLens(setSaveable(expressionExists)); + } + }, [hasLoaded, expressionExists, dispatchLens]); const onEvent = useCallback( (event: ExpressionRendererEvent) => { @@ -251,14 +257,15 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({ }); } if (isLensEditEvent(event) && activeVisualization?.onEditAction) { - dispatch({ - type: 'UPDATE_VISUALIZATION_STATE', - visualizationId: activeVisualization.id, - updater: (oldState: unknown) => activeVisualization.onEditAction!(oldState, event), - }); + dispatchLens( + updateVisualizationState({ + visualizationId: activeVisualization.id, + updater: (oldState: unknown) => activeVisualization.onEditAction!(oldState, event), + }) + ); } }, - [plugins.uiActions, dispatch, activeVisualization] + [plugins.uiActions, activeVisualization, dispatchLens] ); useEffect(() => { @@ -275,9 +282,9 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({ if (suggestionForDraggedField) { trackUiEvent('drop_onto_workspace'); trackUiEvent(expressionExists ? 'drop_non_empty' : 'drop_empty'); - switchToSuggestion(dispatch, suggestionForDraggedField, 'SWITCH_VISUALIZATION'); + switchToSuggestion(dispatchLens, suggestionForDraggedField, 'SWITCH_VISUALIZATION'); } - }, [suggestionForDraggedField, expressionExists, dispatch]); + }, [suggestionForDraggedField, expressionExists, dispatchLens]); const renderEmptyWorkspace = () => { return ( @@ -327,9 +334,7 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({ }; const renderVisualization = () => { - // we don't want to render the emptyWorkspace on visualizing field from Discover - // as it is specific for the drag and drop functionality and can confuse the users - if (expression === null && !visualizeTriggerFieldContext) { + if (expression === null) { return renderEmptyWorkspace(); } return ( @@ -337,7 +342,6 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({ expression={expression} framePublicAPI={framePublicAPI} timefilter={plugins.data.query.timefilter.timefilter} - dispatch={dispatch} onEvent={onEvent} setLocalState={setLocalState} localState={{ ...localState, configurationValidationError, missingRefsErrors }} @@ -387,9 +391,7 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({ return ( <WorkspacePanelWrapper - title={title} framePublicAPI={framePublicAPI} - dispatch={dispatch} visualizationState={visualizationState} visualizationId={activeVisualizationId} datasourceStates={datasourceStates} @@ -410,7 +412,6 @@ export const VisualizationWrapper = ({ setLocalState, localState, ExpressionRendererComponent, - dispatch, application, activeDatasourceId, }: { @@ -418,7 +419,6 @@ export const VisualizationWrapper = ({ framePublicAPI: FramePublicAPI; timefilter: TimefilterContract; onEvent: (event: ExpressionRendererEvent) => void; - dispatch: (action: Action) => void; setLocalState: (dispatch: (prevState: WorkspaceState) => WorkspaceState) => void; localState: WorkspaceState & { configurationValidationError?: Array<{ @@ -454,7 +454,7 @@ export const VisualizationWrapper = ({ const onData$ = useCallback( (data: unknown, inspectorAdapters?: Partial<DefaultInspectorAdapters>) => { if (inspectorAdapters && inspectorAdapters.tables) { - dispatchLens(onActiveDataChange({ activeData: { ...inspectorAdapters.tables.tables } })); + dispatchLens(onActiveDataChange({ ...inspectorAdapters.tables.tables })); } }, [dispatchLens] @@ -480,11 +480,12 @@ export const VisualizationWrapper = ({ data-test-subj="errorFixAction" onClick={async () => { const newState = await validationError.fixAction?.newState(framePublicAPI); - dispatch({ - type: 'UPDATE_DATASOURCE_STATE', - datasourceId: activeDatasourceId, - updater: newState, - }); + dispatchLens( + updateDatasourceState({ + updater: newState, + datasourceId: activeDatasourceId, + }) + ); }} > {validationError.fixAction.label} diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.test.tsx index c18b362e2faa4..fb77ff75324f0 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.test.tsx @@ -7,30 +7,23 @@ import React from 'react'; import { Visualization } from '../../../types'; -import { createMockVisualization, createMockFramePublicAPI, FrameMock } from '../../mocks'; -import { mountWithIntl as mount } from '@kbn/test/jest'; -import { ReactWrapper } from 'enzyme'; -import { WorkspacePanelWrapper, WorkspacePanelWrapperProps } from './workspace_panel_wrapper'; +import { createMockVisualization, createMockFramePublicAPI, FrameMock } from '../../../mocks'; +import { WorkspacePanelWrapper } from './workspace_panel_wrapper'; +import { mountWithProvider } from '../../../mocks'; describe('workspace_panel_wrapper', () => { let mockVisualization: jest.Mocked<Visualization>; let mockFrameAPI: FrameMock; - let instance: ReactWrapper<WorkspacePanelWrapperProps>; beforeEach(() => { mockVisualization = createMockVisualization(); mockFrameAPI = createMockFramePublicAPI(); }); - afterEach(() => { - instance.unmount(); - }); - - it('should render its children', () => { + it('should render its children', async () => { const MyChild = () => <span>The child elements</span>; - instance = mount( + const { instance } = await mountWithProvider( <WorkspacePanelWrapper - dispatch={jest.fn()} framePublicAPI={mockFrameAPI} visualizationState={{}} visualizationId="myVis" @@ -46,12 +39,11 @@ describe('workspace_panel_wrapper', () => { expect(instance.find(MyChild)).toHaveLength(1); }); - it('should call the toolbar renderer if provided', () => { + it('should call the toolbar renderer if provided', async () => { const renderToolbarMock = jest.fn(); const visState = { internalState: 123 }; - instance = mount( + await mountWithProvider( <WorkspacePanelWrapper - dispatch={jest.fn()} framePublicAPI={mockFrameAPI} visualizationState={visState} children={<span />} diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.tsx index 6724002d23e0b..d0e8e0d5a1bab 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.tsx @@ -8,21 +8,19 @@ import './workspace_panel_wrapper.scss'; import React, { useCallback } from 'react'; -import { i18n } from '@kbn/i18n'; -import { EuiPageContent, EuiFlexGroup, EuiFlexItem, EuiScreenReaderOnly } from '@elastic/eui'; +import { EuiPageContent, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import classNames from 'classnames'; import { Datasource, FramePublicAPI, Visualization } from '../../../types'; import { NativeRenderer } from '../../../native_renderer'; -import { Action } from '../state_management'; import { ChartSwitch } from './chart_switch'; import { WarningsPopover } from './warnings_popover'; +import { useLensDispatch, updateVisualizationState } from '../../../state_management'; +import { WorkspaceTitle } from './title'; export interface WorkspacePanelWrapperProps { children: React.ReactNode | React.ReactNode[]; framePublicAPI: FramePublicAPI; visualizationState: unknown; - dispatch: (action: Action) => void; - title?: string; visualizationMap: Record<string, Visualization>; visualizationId: string | null; datasourceMap: Record<string, Datasource>; @@ -40,28 +38,29 @@ export function WorkspacePanelWrapper({ children, framePublicAPI, visualizationState, - dispatch, - title, visualizationId, visualizationMap, datasourceMap, datasourceStates, isFullscreen, }: WorkspacePanelWrapperProps) { + const dispatchLens = useLensDispatch(); + const activeVisualization = visualizationId ? visualizationMap[visualizationId] : null; const setVisualizationState = useCallback( (newState: unknown) => { if (!activeVisualization) { return; } - dispatch({ - type: 'UPDATE_VISUALIZATION_STATE', - visualizationId: activeVisualization.id, - updater: newState, - clearStagedPreview: false, - }); + dispatchLens( + updateVisualizationState({ + visualizationId: activeVisualization.id, + updater: newState, + clearStagedPreview: false, + }) + ); }, - [dispatch, activeVisualization] + [dispatchLens, activeVisualization] ); const warningMessages: React.ReactNode[] = []; if (activeVisualization?.getWarningMessages) { @@ -101,11 +100,7 @@ export function WorkspacePanelWrapper({ <ChartSwitch data-test-subj="lnsChartSwitcher" visualizationMap={visualizationMap} - visualizationId={visualizationId} - visualizationState={visualizationState} datasourceMap={datasourceMap} - datasourceStates={datasourceStates} - dispatch={dispatch} framePublicAPI={framePublicAPI} /> </EuiFlexItem> @@ -136,14 +131,7 @@ export function WorkspacePanelWrapper({ 'lnsWorkspacePanelWrapper--fullscreen': isFullscreen, })} > - <EuiScreenReaderOnly> - <h1 id="lns_ChartTitle" data-test-subj="lns_ChartTitle"> - {title || - i18n.translate('xpack.lens.chartTitle.unsaved', { - defaultMessage: 'Unsaved visualization', - })} - </h1> - </EuiScreenReaderOnly> + <WorkspaceTitle /> {children} </EuiPageContent> </> diff --git a/x-pack/plugins/lens/public/editor_frame_service/mocks.tsx b/x-pack/plugins/lens/public/editor_frame_service/mocks.tsx index 1762e7ff20fab..ff0d81c7fa277 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/mocks.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/mocks.tsx @@ -5,105 +5,14 @@ * 2.0. */ -import React from 'react'; import { PaletteDefinition } from 'src/plugins/charts/public'; -import { - ReactExpressionRendererProps, - ExpressionsSetup, - ExpressionsStart, -} from '../../../../../src/plugins/expressions/public'; +import { ExpressionsSetup, ExpressionsStart } from '../../../../../src/plugins/expressions/public'; import { embeddablePluginMock } from '../../../../../src/plugins/embeddable/public/mocks'; import { expressionsPluginMock } from '../../../../../src/plugins/expressions/public/mocks'; -import { DatasourcePublicAPI, FramePublicAPI, Datasource, Visualization } from '../types'; import { EditorFrameSetupPlugins, EditorFrameStartPlugins } from './service'; import { dataPluginMock } from '../../../../../src/plugins/data/public/mocks'; import { chartPluginMock } from '../../../../../src/plugins/charts/public/mocks'; -export function createMockVisualization(): jest.Mocked<Visualization> { - return { - id: 'TEST_VIS', - clearLayer: jest.fn((state, _layerId) => state), - removeLayer: jest.fn(), - getLayerIds: jest.fn((_state) => ['layer1']), - visualizationTypes: [ - { - icon: 'empty', - id: 'TEST_VIS', - label: 'TEST', - groupLabel: 'TEST_VISGroup', - }, - ], - getVisualizationTypeId: jest.fn((_state) => 'empty'), - getDescription: jest.fn((_state) => ({ label: '' })), - switchVisualizationType: jest.fn((_, x) => x), - getSuggestions: jest.fn((_options) => []), - initialize: jest.fn((_frame, _state?) => ({})), - getConfiguration: jest.fn((props) => ({ - groups: [ - { - groupId: 'a', - groupLabel: 'a', - layerId: 'layer1', - supportsMoreColumns: true, - accessors: [], - filterOperations: jest.fn(() => true), - dataTestSubj: 'mockVisA', - }, - ], - })), - toExpression: jest.fn((_state, _frame) => null), - toPreviewExpression: jest.fn((_state, _frame) => null), - - setDimension: jest.fn(), - removeDimension: jest.fn(), - getErrorMessages: jest.fn((_state) => undefined), - renderDimensionEditor: jest.fn(), - }; -} - -export type DatasourceMock = jest.Mocked<Datasource> & { - publicAPIMock: jest.Mocked<DatasourcePublicAPI>; -}; - -export function createMockDatasource(id: string): DatasourceMock { - const publicAPIMock: jest.Mocked<DatasourcePublicAPI> = { - datasourceId: id, - getTableSpec: jest.fn(() => []), - getOperationForColumnId: jest.fn(), - }; - - return { - id: 'mockindexpattern', - clearLayer: jest.fn((state, _layerId) => state), - getDatasourceSuggestionsForField: jest.fn((_state, _item) => []), - getDatasourceSuggestionsForVisualizeField: jest.fn((_state, _indexpatternId, _fieldName) => []), - getDatasourceSuggestionsFromCurrentState: jest.fn((_state) => []), - getPersistableState: jest.fn((x) => ({ state: x, savedObjectReferences: [] })), - getPublicAPI: jest.fn().mockReturnValue(publicAPIMock), - initialize: jest.fn((_state?) => Promise.resolve()), - renderDataPanel: jest.fn(), - renderLayerPanel: jest.fn(), - toExpression: jest.fn((_frame, _state) => null), - insertLayer: jest.fn((_state, _newLayerId) => {}), - removeLayer: jest.fn((_state, _layerId) => {}), - removeColumn: jest.fn((props) => {}), - getLayers: jest.fn((_state) => []), - uniqueLabels: jest.fn((_state) => ({})), - renderDimensionTrigger: jest.fn(), - renderDimensionEditor: jest.fn(), - getDropProps: jest.fn(), - onDrop: jest.fn(), - - // this is an additional property which doesn't exist on real datasources - // but can be used to validate whether specific API mock functions are called - publicAPIMock, - getErrorMessages: jest.fn((_state) => undefined), - checkIntegrity: jest.fn((_state) => []), - }; -} - -export type FrameMock = jest.Mocked<FramePublicAPI>; - export function createMockPaletteDefinition(): jest.Mocked<PaletteDefinition> { return { getCategoricalColors: jest.fn((_) => ['#ff0000', '#00ff00']), @@ -123,23 +32,6 @@ export function createMockPaletteDefinition(): jest.Mocked<PaletteDefinition> { }; } -export function createMockFramePublicAPI(): FrameMock { - const palette = createMockPaletteDefinition(); - return { - datasourceLayers: {}, - addNewLayer: jest.fn(() => ''), - removeLayers: jest.fn(), - dateRange: { fromDate: 'now-7d', toDate: 'now' }, - query: { query: '', language: 'lucene' }, - filters: [], - availablePalettes: { - get: () => palette, - getAll: () => [palette], - }, - searchSessionId: 'sessionId', - }; -} - type Omit<T, K> = Pick<T, Exclude<keyof T, K>>; export type MockedSetupDependencies = Omit<EditorFrameSetupPlugins, 'expressions'> & { @@ -150,13 +42,6 @@ export type MockedStartDependencies = Omit<EditorFrameStartPlugins, 'expressions expressions: jest.Mocked<ExpressionsStart>; }; -export function createExpressionRendererMock(): jest.Mock< - React.ReactElement, - [ReactExpressionRendererProps] -> { - return jest.fn((_) => <span />); -} - export function createMockSetupDependencies() { return ({ data: dataPluginMock.createSetupContract(), diff --git a/x-pack/plugins/lens/public/editor_frame_service/service.tsx b/x-pack/plugins/lens/public/editor_frame_service/service.tsx index 6a26f85a64acc..63340795ec6c8 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/service.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/service.tsx @@ -105,27 +105,25 @@ export class EditorFrameService { ]); const { EditorFrame } = await import('../async_services'); - const palettes = await plugins.charts.palettes.getPalettes(); return { - EditorFrameContainer: ({ onError, showNoDataPopover, initialContext }) => { + EditorFrameContainer: ({ showNoDataPopover }) => { return ( <div className="lnsApp__frame"> <EditorFrame data-test-subj="lnsEditorFrame" - onError={onError} - datasourceMap={resolvedDatasources} - visualizationMap={resolvedVisualizations} core={core} plugins={plugins} - ExpressionRenderer={plugins.expressions.ReactExpressionRenderer} - palettes={palettes} showNoDataPopover={showNoDataPopover} - initialContext={initialContext} + datasourceMap={resolvedDatasources} + visualizationMap={resolvedVisualizations} + ExpressionRenderer={plugins.expressions.ReactExpressionRenderer} /> </div> ); }, + datasourceMap: resolvedDatasources, + visualizationMap: resolvedVisualizations, }; }; diff --git a/x-pack/plugins/lens/public/heatmap_visualization/visualization.test.ts b/x-pack/plugins/lens/public/heatmap_visualization/visualization.test.ts index 3ed82bef06105..eeec6150dc497 100644 --- a/x-pack/plugins/lens/public/heatmap_visualization/visualization.test.ts +++ b/x-pack/plugins/lens/public/heatmap_visualization/visualization.test.ts @@ -10,7 +10,7 @@ import { getHeatmapVisualization, isCellValueSupported, } from './visualization'; -import { createMockDatasource, createMockFramePublicAPI } from '../editor_frame_service/mocks'; +import { createMockDatasource, createMockFramePublicAPI } from '../mocks'; import { CHART_SHAPES, FUNCTION_NAME, @@ -49,8 +49,8 @@ describe('heatmap', () => { describe('#intialize', () => { test('returns a default state', () => { - expect(getHeatmapVisualization({}).initialize(frame)).toEqual({ - layerId: '', + expect(getHeatmapVisualization({}).initialize(() => 'l1')).toEqual({ + layerId: 'l1', title: 'Empty Heatmap chart', shape: CHART_SHAPES.HEATMAP, legend: { @@ -68,7 +68,9 @@ describe('heatmap', () => { }); test('returns persisted state', () => { - expect(getHeatmapVisualization({}).initialize(frame, exampleState())).toEqual(exampleState()); + expect(getHeatmapVisualization({}).initialize(() => 'test-layer', exampleState())).toEqual( + exampleState() + ); }); }); diff --git a/x-pack/plugins/lens/public/heatmap_visualization/visualization.tsx b/x-pack/plugins/lens/public/heatmap_visualization/visualization.tsx index fce5bf30f47ed..7788e93812b1b 100644 --- a/x-pack/plugins/lens/public/heatmap_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/heatmap_visualization/visualization.tsx @@ -119,10 +119,10 @@ export const getHeatmapVisualization = ({ return CHART_NAMES.heatmap; }, - initialize(frame, state, mainPalette) { + initialize(addNewLayer, state, mainPalette) { return ( state || { - layerId: frame.addNewLayer(), + layerId: addNewLayer(), title: 'Empty Heatmap chart', ...getInitialState(), } diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts b/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts index 2921251babe7f..82c27a76bb483 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts @@ -446,10 +446,13 @@ export async function syncExistingFields({ isFirstExistenceFetch: false, existenceFetchFailed: false, existenceFetchTimeout: false, - existingFields: emptinessInfo.reduce((acc, info) => { - acc[info.indexPatternTitle] = booleanMap(info.existingFieldNames); - return acc; - }, state.existingFields), + existingFields: emptinessInfo.reduce( + (acc, info) => { + acc[info.indexPatternTitle] = booleanMap(info.existingFieldNames); + return acc; + }, + { ...state.existingFields } + ), })); } catch (e) { // show all fields as available if fetch failed or timed out @@ -457,10 +460,13 @@ export async function syncExistingFields({ ...state, existenceFetchFailed: e.res?.status !== 408, existenceFetchTimeout: e.res?.status === 408, - existingFields: indexPatterns.reduce((acc, pattern) => { - acc[pattern.title] = booleanMap(pattern.fields.map((field) => field.name)); - return acc; - }, state.existingFields), + existingFields: indexPatterns.reduce( + (acc, pattern) => { + acc[pattern.title] = booleanMap(pattern.fields.map((field) => field.name)); + return acc; + }, + { ...state.existingFields } + ), })); } } diff --git a/x-pack/plugins/lens/public/metric_visualization/visualization.test.ts b/x-pack/plugins/lens/public/metric_visualization/visualization.test.ts index 66e524435ebc8..2882d9c4c0246 100644 --- a/x-pack/plugins/lens/public/metric_visualization/visualization.test.ts +++ b/x-pack/plugins/lens/public/metric_visualization/visualization.test.ts @@ -7,7 +7,7 @@ import { metricVisualization } from './visualization'; import { MetricState } from './types'; -import { createMockDatasource, createMockFramePublicAPI } from '../editor_frame_service/mocks'; +import { createMockDatasource, createMockFramePublicAPI } from '../mocks'; import { generateId } from '../id_generator'; import { DatasourcePublicAPI, FramePublicAPI } from '../types'; @@ -23,7 +23,6 @@ function exampleState(): MetricState { function mockFrame(): FramePublicAPI { return { ...createMockFramePublicAPI(), - addNewLayer: () => 'l42', datasourceLayers: { l1: createMockDatasource('l1').publicAPIMock, l42: createMockDatasource('l42').publicAPIMock, @@ -35,19 +34,19 @@ describe('metric_visualization', () => { describe('#initialize', () => { it('loads default state', () => { (generateId as jest.Mock).mockReturnValueOnce('test-id1'); - const initialState = metricVisualization.initialize(mockFrame()); + const initialState = metricVisualization.initialize(() => 'test-id1'); expect(initialState.accessor).not.toBeDefined(); expect(initialState).toMatchInlineSnapshot(` - Object { - "accessor": undefined, - "layerId": "l42", - } - `); + Object { + "accessor": undefined, + "layerId": "test-id1", + } + `); }); it('loads from persisted state', () => { - expect(metricVisualization.initialize(mockFrame(), exampleState())).toEqual(exampleState()); + expect(metricVisualization.initialize(() => 'l1', exampleState())).toEqual(exampleState()); }); }); diff --git a/x-pack/plugins/lens/public/metric_visualization/visualization.tsx b/x-pack/plugins/lens/public/metric_visualization/visualization.tsx index e0977be7535af..49565f53bda36 100644 --- a/x-pack/plugins/lens/public/metric_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/metric_visualization/visualization.tsx @@ -85,10 +85,10 @@ export const metricVisualization: Visualization<MetricState> = { getSuggestions, - initialize(frame, state) { + initialize(addNewLayer, state) { return ( state || { - layerId: frame.addNewLayer(), + layerId: addNewLayer(), accessor: undefined, } ); diff --git a/x-pack/plugins/lens/public/mocks.tsx b/x-pack/plugins/lens/public/mocks.tsx index dcdabac36db3a..fc1b3019df386 100644 --- a/x-pack/plugins/lens/public/mocks.tsx +++ b/x-pack/plugins/lens/public/mocks.tsx @@ -15,6 +15,7 @@ import { coreMock } from 'src/core/public/mocks'; import moment from 'moment'; import { Provider } from 'react-redux'; import { act } from 'react-dom/test-utils'; +import { ReactExpressionRendererProps } from 'src/plugins/expressions/public'; import { LensPublicStart } from '.'; import { visualizationTypes } from './xy_visualization/types'; import { navigationPluginMock } from '../../../../src/plugins/navigation/public/mocks'; @@ -37,6 +38,111 @@ import { EmbeddableStateTransfer } from '../../../../src/plugins/embeddable/publ import { makeConfigureStore, getPreloadedState, LensAppState } from './state_management/index'; import { getResolvedDateRange } from './utils'; import { presentationUtilPluginMock } from '../../../../src/plugins/presentation_util/public/mocks'; +import { DatasourcePublicAPI, Datasource, Visualization, FramePublicAPI } from './types'; + +export function createMockVisualization(): jest.Mocked<Visualization> { + return { + id: 'TEST_VIS', + clearLayer: jest.fn((state, _layerId) => state), + removeLayer: jest.fn(), + getLayerIds: jest.fn((_state) => ['layer1']), + visualizationTypes: [ + { + icon: 'empty', + id: 'TEST_VIS', + label: 'TEST', + groupLabel: 'TEST_VISGroup', + }, + ], + getVisualizationTypeId: jest.fn((_state) => 'empty'), + getDescription: jest.fn((_state) => ({ label: '' })), + switchVisualizationType: jest.fn((_, x) => x), + getSuggestions: jest.fn((_options) => []), + initialize: jest.fn((_frame, _state?) => ({})), + getConfiguration: jest.fn((props) => ({ + groups: [ + { + groupId: 'a', + groupLabel: 'a', + layerId: 'layer1', + supportsMoreColumns: true, + accessors: [], + filterOperations: jest.fn(() => true), + dataTestSubj: 'mockVisA', + }, + ], + })), + toExpression: jest.fn((_state, _frame) => null), + toPreviewExpression: jest.fn((_state, _frame) => null), + + setDimension: jest.fn(), + removeDimension: jest.fn(), + getErrorMessages: jest.fn((_state) => undefined), + renderDimensionEditor: jest.fn(), + }; +} + +export type DatasourceMock = jest.Mocked<Datasource> & { + publicAPIMock: jest.Mocked<DatasourcePublicAPI>; +}; + +export function createMockDatasource(id: string): DatasourceMock { + const publicAPIMock: jest.Mocked<DatasourcePublicAPI> = { + datasourceId: id, + getTableSpec: jest.fn(() => []), + getOperationForColumnId: jest.fn(), + }; + + return { + id: 'mockindexpattern', + clearLayer: jest.fn((state, _layerId) => state), + getDatasourceSuggestionsForField: jest.fn((_state, _item) => []), + getDatasourceSuggestionsForVisualizeField: jest.fn((_state, _indexpatternId, _fieldName) => []), + getDatasourceSuggestionsFromCurrentState: jest.fn((_state) => []), + getPersistableState: jest.fn((x) => ({ + state: x, + savedObjectReferences: [{ type: 'index-pattern', id: 'mockip', name: 'mockip' }], + })), + getPublicAPI: jest.fn().mockReturnValue(publicAPIMock), + initialize: jest.fn((_state?) => Promise.resolve()), + renderDataPanel: jest.fn(), + renderLayerPanel: jest.fn(), + toExpression: jest.fn((_frame, _state) => null), + insertLayer: jest.fn((_state, _newLayerId) => {}), + removeLayer: jest.fn((_state, _layerId) => {}), + removeColumn: jest.fn((props) => {}), + getLayers: jest.fn((_state) => []), + uniqueLabels: jest.fn((_state) => ({})), + renderDimensionTrigger: jest.fn(), + renderDimensionEditor: jest.fn(), + getDropProps: jest.fn(), + onDrop: jest.fn(), + + // this is an additional property which doesn't exist on real datasources + // but can be used to validate whether specific API mock functions are called + publicAPIMock, + getErrorMessages: jest.fn((_state) => undefined), + checkIntegrity: jest.fn((_state) => []), + }; +} + +export function createExpressionRendererMock(): jest.Mock< + React.ReactElement, + [ReactExpressionRendererProps] +> { + return jest.fn((_) => <span />); +} + +export type FrameMock = jest.Mocked<FramePublicAPI>; +export function createMockFramePublicAPI(): FrameMock { + return { + datasourceLayers: {}, + dateRange: { fromDate: 'now-7d', toDate: 'now' }, + query: { query: '', language: 'lucene' }, + filters: [], + searchSessionId: 'sessionId', + }; +} export type Start = jest.Mocked<LensPublicStart>; @@ -66,6 +172,9 @@ export const defaultDoc = ({ state: { query: 'kuery', filters: [{ query: { match_phrase: { src: 'test' } } }], + datasourceStates: { + testDatasource: 'datasource', + }, }, references: [{ type: 'index-pattern', id: '1', name: 'index-pattern-0' }], } as unknown) as Document; @@ -257,20 +366,48 @@ export function makeDefaultServices( }; } -export function mockLensStore({ +export const defaultState = { + searchSessionId: 'sessionId-1', + filters: [], + query: { language: 'lucene', query: '' }, + resolvedDateRange: { fromDate: '2021-01-10T04:00:00.000Z', toDate: '2021-01-10T08:00:00.000Z' }, + isFullscreenDatasource: false, + isSaveable: false, + isLoading: false, + isLinkedToOriginatingApp: false, + activeDatasourceId: 'testDatasource', + visualization: { + state: {}, + activeId: 'testVis', + }, + datasourceStates: { + testDatasource: { + isLoading: false, + state: '', + }, + }, +}; + +export function makeLensStore({ data, - storePreloadedState, + preloadedState, + dispatch, }: { - data: DataPublicPluginStart; - storePreloadedState?: Partial<LensAppState>; + data?: DataPublicPluginStart; + preloadedState?: Partial<LensAppState>; + dispatch?: jest.Mock; }) { + if (!data) { + data = mockDataPlugin(); + } const lensStore = makeConfigureStore( getPreloadedState({ + ...defaultState, + searchSessionId: data.search.session.start(), query: data.query.queryString.getQuery(), filters: data.query.filterManager.getGlobalFilters(), - searchSessionId: data.search.session.start(), resolvedDateRange: getResolvedDateRange(data.query.timefilter.timefilter), - ...storePreloadedState, + ...preloadedState, }), { data, @@ -278,36 +415,52 @@ export function mockLensStore({ ); const origDispatch = lensStore.dispatch; - lensStore.dispatch = jest.fn(origDispatch); + lensStore.dispatch = jest.fn(dispatch || origDispatch); return lensStore; } export const mountWithProvider = async ( component: React.ReactElement, - data: DataPublicPluginStart, - storePreloadedState?: Partial<LensAppState>, - extraWrappingComponent?: React.FC<{ - children: React.ReactNode; - }> + store?: { + data?: DataPublicPluginStart; + preloadedState?: Partial<LensAppState>; + dispatch?: jest.Mock; + }, + options?: { + wrappingComponent?: React.FC<{ + children: React.ReactNode; + }>; + attachTo?: HTMLElement; + } ) => { - const lensStore = mockLensStore({ data, storePreloadedState }); + const lensStore = makeLensStore(store || {}); - const wrappingComponent: React.FC<{ + let wrappingComponent: React.FC<{ children: React.ReactNode; - }> = ({ children }) => { - if (extraWrappingComponent) { - return extraWrappingComponent({ - children: <Provider store={lensStore}>{children}</Provider>, - }); - } - return <Provider store={lensStore}>{children}</Provider>; + }> = ({ children }) => <Provider store={lensStore}>{children}</Provider>; + + let restOptions: { + attachTo?: HTMLElement | undefined; }; + if (options) { + const { wrappingComponent: _wrappingComponent, ...rest } = options; + restOptions = rest; + + if (_wrappingComponent) { + wrappingComponent = ({ children }) => { + return _wrappingComponent({ + children: <Provider store={lensStore}>{children}</Provider>, + }); + }; + } + } let instance: ReactWrapper = {} as ReactWrapper; await act(async () => { instance = mount(component, ({ wrappingComponent, + ...restOptions, } as unknown) as ReactWrapper); }); return { instance, lensStore }; diff --git a/x-pack/plugins/lens/public/pie_visualization/visualization.tsx b/x-pack/plugins/lens/public/pie_visualization/visualization.tsx index 6e04d1a4ff958..c82fdb2766f7e 100644 --- a/x-pack/plugins/lens/public/pie_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/visualization.tsx @@ -91,11 +91,11 @@ export const getPieVisualization = ({ shape: visualizationTypeId as PieVisualizationState['shape'], }), - initialize(frame, state, mainPalette) { + initialize(addNewLayer, state, mainPalette) { return ( state || { shape: 'donut', - layers: [newLayerState(frame.addNewLayer())], + layers: [newLayerState(addNewLayer())], palette: mainPalette, } ); diff --git a/x-pack/plugins/lens/public/state_management/app_slice.ts b/x-pack/plugins/lens/public/state_management/app_slice.ts deleted file mode 100644 index 29d5b0bee843f..0000000000000 --- a/x-pack/plugins/lens/public/state_management/app_slice.ts +++ /dev/null @@ -1,55 +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 { createSlice, PayloadAction } from '@reduxjs/toolkit'; -import { isEqual } from 'lodash'; -import { LensAppState } from './types'; - -export const initialState: LensAppState = { - searchSessionId: '', - filters: [], - query: { language: 'kuery', query: '' }, - resolvedDateRange: { fromDate: '', toDate: '' }, - - indexPatternsForTopNav: [], - isSaveable: false, - isAppLoading: false, - isLinkedToOriginatingApp: false, -}; - -export const appSlice = createSlice({ - name: 'app', - initialState, - reducers: { - setState: (state, { payload }: PayloadAction<Partial<LensAppState>>) => { - return { - ...state, - ...payload, - }; - }, - onChangeFromEditorFrame: (state, { payload }: PayloadAction<Partial<LensAppState>>) => { - return { - ...state, - ...payload, - }; - }, - onActiveDataChange: (state, { payload }: PayloadAction<Partial<LensAppState>>) => { - if (!isEqual(state.activeData, payload?.activeData)) { - return { - ...state, - ...payload, - }; - } - return state; - }, - navigateAway: (state) => state, - }, -}); - -export const reducer = { - app: appSlice.reducer, -}; diff --git a/x-pack/plugins/lens/public/state_management/external_context_middleware.ts b/x-pack/plugins/lens/public/state_management/external_context_middleware.ts index 0743dce73eb33..07233b87dd19b 100644 --- a/x-pack/plugins/lens/public/state_management/external_context_middleware.ts +++ b/x-pack/plugins/lens/public/state_management/external_context_middleware.ts @@ -27,7 +27,7 @@ export const externalContextMiddleware = (data: DataPublicPluginStart) => ( store.dispatch ); return (next: Dispatch) => (action: PayloadAction<Partial<LensAppState>>) => { - if (action.type === 'app/navigateAway') { + if (action.type === 'lens/navigateAway') { unsubscribeFromExternalContext(); } next(action); @@ -44,7 +44,7 @@ function subscribeToExternalContext( const dispatchFromExternal = (searchSessionId = search.session.start()) => { const globalFilters = filterManager.getFilters(); - const filters = isEqual(getState().app.filters, globalFilters) + const filters = isEqual(getState().lens.filters, globalFilters) ? null : { filters: globalFilters }; dispatch( @@ -64,7 +64,7 @@ function subscribeToExternalContext( .pipe(delay(0)) // then update if it didn't get updated yet .subscribe((newSessionId?: string) => { - if (newSessionId && getState().app.searchSessionId !== newSessionId) { + if (newSessionId && getState().lens.searchSessionId !== newSessionId) { debounceDispatchFromExternal(newSessionId); } }); diff --git a/x-pack/plugins/lens/public/state_management/index.ts b/x-pack/plugins/lens/public/state_management/index.ts index 429978e60756b..b72c383130208 100644 --- a/x-pack/plugins/lens/public/state_management/index.ts +++ b/x-pack/plugins/lens/public/state_management/index.ts @@ -8,8 +8,9 @@ import { configureStore, DeepPartial, getDefaultMiddleware } from '@reduxjs/toolkit'; import logger from 'redux-logger'; import { useDispatch, useSelector, TypedUseSelectorHook } from 'react-redux'; -import { appSlice, initialState } from './app_slice'; +import { lensSlice, initialState } from './lens_slice'; import { timeRangeMiddleware } from './time_range_middleware'; +import { optimizingMiddleware } from './optimizing_middleware'; import { externalContextMiddleware } from './external_context_middleware'; import { DataPublicPluginStart } from '../../../../../src/plugins/data/public'; @@ -17,19 +18,29 @@ import { LensAppState, LensState } from './types'; export * from './types'; export const reducer = { - app: appSlice.reducer, + lens: lensSlice.reducer, }; export const { setState, navigateAway, - onChangeFromEditorFrame, + setSaveable, onActiveDataChange, -} = appSlice.actions; + updateState, + updateDatasourceState, + updateVisualizationState, + updateLayer, + switchVisualization, + selectSuggestion, + rollbackSuggestion, + submitSuggestion, + switchDatasource, + setToggleFullscreen, +} = lensSlice.actions; export const getPreloadedState = (initializedState: Partial<LensAppState>) => { const state = { - app: { + lens: { ...initialState, ...initializedState, }, @@ -45,15 +56,9 @@ export const makeConfigureStore = ( ) => { const middleware = [ ...getDefaultMiddleware({ - serializableCheck: { - ignoredActions: [ - 'app/setState', - 'app/onChangeFromEditorFrame', - 'app/onActiveDataChange', - 'app/navigateAway', - ], - }, + serializableCheck: false, }), + optimizingMiddleware(), timeRangeMiddleware(data), externalContextMiddleware(data), ]; diff --git a/x-pack/plugins/lens/public/state_management/lens_slice.test.ts b/x-pack/plugins/lens/public/state_management/lens_slice.test.ts new file mode 100644 index 0000000000000..cce0376707143 --- /dev/null +++ b/x-pack/plugins/lens/public/state_management/lens_slice.test.ts @@ -0,0 +1,148 @@ +/* + * 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 { Query } from 'src/plugins/data/public'; +import { + switchDatasource, + switchVisualization, + setState, + updateState, + updateDatasourceState, + updateVisualizationState, +} from '.'; +import { makeLensStore, defaultState } from '../mocks'; + +describe('lensSlice', () => { + const store = makeLensStore({}); + const customQuery = { query: 'custom' } as Query; + + // TODO: need to move some initialization logic from mounter + // describe('initialization', () => { + // }) + + describe('state update', () => { + it('setState: updates state ', () => { + const lensState = store.getState().lens; + expect(lensState).toEqual(defaultState); + store.dispatch(setState({ query: customQuery })); + const changedState = store.getState().lens; + expect(changedState).toEqual({ ...defaultState, query: customQuery }); + }); + + it('updateState: updates state with updater', () => { + const customUpdater = jest.fn((state) => ({ ...state, query: customQuery })); + store.dispatch(updateState({ subType: 'UPDATE', updater: customUpdater })); + const changedState = store.getState().lens; + expect(changedState).toEqual({ ...defaultState, query: customQuery }); + }); + it('should update the corresponding visualization state on update', () => { + const newVisState = {}; + store.dispatch( + updateVisualizationState({ + visualizationId: 'testVis', + updater: newVisState, + }) + ); + + expect(store.getState().lens.visualization.state).toBe(newVisState); + }); + it('should update the datasource state with passed in reducer', () => { + const datasourceUpdater = jest.fn(() => ({ changed: true })); + store.dispatch( + updateDatasourceState({ + datasourceId: 'testDatasource', + updater: datasourceUpdater, + }) + ); + expect(store.getState().lens.datasourceStates.testDatasource.state).toStrictEqual({ + changed: true, + }); + expect(datasourceUpdater).toHaveBeenCalledTimes(1); + }); + it('should update the layer state with passed in reducer', () => { + const newDatasourceState = {}; + store.dispatch( + updateDatasourceState({ + datasourceId: 'testDatasource', + updater: newDatasourceState, + }) + ); + expect(store.getState().lens.datasourceStates.testDatasource.state).toStrictEqual( + newDatasourceState + ); + }); + it('should should switch active visualization', () => { + const newVisState = {}; + store.dispatch( + switchVisualization({ + newVisualizationId: 'testVis2', + initialState: newVisState, + }) + ); + + expect(store.getState().lens.visualization.state).toBe(newVisState); + }); + + it('should should switch active visualization and update datasource state', () => { + const newVisState = {}; + const newDatasourceState = {}; + + store.dispatch( + switchVisualization({ + newVisualizationId: 'testVis2', + initialState: newVisState, + datasourceState: newDatasourceState, + datasourceId: 'testDatasource', + }) + ); + + expect(store.getState().lens.visualization.state).toBe(newVisState); + expect(store.getState().lens.datasourceStates.testDatasource.state).toBe(newDatasourceState); + }); + + it('should switch active datasource and initialize new state', () => { + store.dispatch( + switchDatasource({ + newDatasourceId: 'testDatasource2', + }) + ); + + expect(store.getState().lens.activeDatasourceId).toEqual('testDatasource2'); + expect(store.getState().lens.datasourceStates.testDatasource2.isLoading).toEqual(true); + }); + + it('not initialize already initialized datasource on switch', () => { + const datasource2State = {}; + const customStore = makeLensStore({ + preloadedState: { + datasourceStates: { + testDatasource: { + state: {}, + isLoading: false, + }, + testDatasource2: { + state: datasource2State, + isLoading: false, + }, + }, + }, + }); + + customStore.dispatch( + switchDatasource({ + newDatasourceId: 'testDatasource2', + }) + ); + + expect(customStore.getState().lens.activeDatasourceId).toEqual('testDatasource2'); + expect(customStore.getState().lens.datasourceStates.testDatasource2.isLoading).toEqual(false); + expect(customStore.getState().lens.datasourceStates.testDatasource2.state).toBe( + datasource2State + ); + }); + }); +}); diff --git a/x-pack/plugins/lens/public/state_management/lens_slice.ts b/x-pack/plugins/lens/public/state_management/lens_slice.ts new file mode 100644 index 0000000000000..cb181881a6552 --- /dev/null +++ b/x-pack/plugins/lens/public/state_management/lens_slice.ts @@ -0,0 +1,262 @@ +/* + * 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 { createSlice, current, PayloadAction } from '@reduxjs/toolkit'; +import { TableInspectorAdapter } from '../editor_frame_service/types'; +import { LensAppState } from './types'; + +export const initialState: LensAppState = { + searchSessionId: '', + filters: [], + query: { language: 'kuery', query: '' }, + resolvedDateRange: { fromDate: '', toDate: '' }, + isFullscreenDatasource: false, + isSaveable: false, + isLoading: false, + isLinkedToOriginatingApp: false, + activeDatasourceId: null, + datasourceStates: {}, + visualization: { + state: null, + activeId: null, + }, +}; + +export const lensSlice = createSlice({ + name: 'lens', + initialState, + reducers: { + setState: (state, { payload }: PayloadAction<Partial<LensAppState>>) => { + return { + ...state, + ...payload, + }; + }, + onActiveDataChange: (state, { payload }: PayloadAction<TableInspectorAdapter>) => { + return { + ...state, + activeData: payload, + }; + }, + setSaveable: (state, { payload }: PayloadAction<boolean>) => { + return { + ...state, + isSaveable: payload, + }; + }, + updateState: ( + state, + action: { + payload: { + subType: string; + updater: (prevState: LensAppState) => LensAppState; + }; + } + ) => { + return action.payload.updater(current(state) as LensAppState); + }, + updateDatasourceState: ( + state, + { + payload, + }: { + payload: { + updater: unknown | ((prevState: unknown) => unknown); + datasourceId: string; + clearStagedPreview?: boolean; + }; + } + ) => { + return { + ...state, + datasourceStates: { + ...state.datasourceStates, + [payload.datasourceId]: { + state: + typeof payload.updater === 'function' + ? payload.updater(current(state).datasourceStates[payload.datasourceId].state) + : payload.updater, + isLoading: false, + }, + }, + stagedPreview: payload.clearStagedPreview ? undefined : state.stagedPreview, + }; + }, + updateVisualizationState: ( + state, + { + payload, + }: { + payload: { + visualizationId: string; + updater: unknown | ((state: unknown) => unknown); + clearStagedPreview?: boolean; + }; + } + ) => { + if (!state.visualization.activeId) { + throw new Error('Invariant: visualization state got updated without active visualization'); + } + // This is a safeguard that prevents us from accidentally updating the + // wrong visualization. This occurs in some cases due to the uncoordinated + // way we manage state across plugins. + if (state.visualization.activeId !== payload.visualizationId) { + return state; + } + return { + ...state, + visualization: { + ...state.visualization, + state: + typeof payload.updater === 'function' + ? payload.updater(current(state.visualization.state)) + : payload.updater, + }, + stagedPreview: payload.clearStagedPreview ? undefined : state.stagedPreview, + }; + }, + updateLayer: ( + state, + { + payload, + }: { + payload: { + layerId: string; + datasourceId: string; + updater: (state: unknown, layerId: string) => unknown; + }; + } + ) => { + return { + ...state, + datasourceStates: { + ...state.datasourceStates, + [payload.datasourceId]: { + ...state.datasourceStates[payload.datasourceId], + state: payload.updater( + current(state).datasourceStates[payload.datasourceId].state, + payload.layerId + ), + }, + }, + }; + }, + + switchVisualization: ( + state, + { + payload, + }: { + payload: { + newVisualizationId: string; + initialState: unknown; + datasourceState?: unknown; + datasourceId?: string; + }; + } + ) => { + return { + ...state, + datasourceStates: + 'datasourceId' in payload && payload.datasourceId + ? { + ...state.datasourceStates, + [payload.datasourceId]: { + ...state.datasourceStates[payload.datasourceId], + state: payload.datasourceState, + }, + } + : state.datasourceStates, + visualization: { + ...state.visualization, + activeId: payload.newVisualizationId, + state: payload.initialState, + }, + stagedPreview: undefined, + }; + }, + selectSuggestion: ( + state, + { + payload, + }: { + payload: { + newVisualizationId: string; + initialState: unknown; + datasourceState: unknown; + datasourceId: string; + }; + } + ) => { + return { + ...state, + datasourceStates: + 'datasourceId' in payload && payload.datasourceId + ? { + ...state.datasourceStates, + [payload.datasourceId]: { + ...state.datasourceStates[payload.datasourceId], + state: payload.datasourceState, + }, + } + : state.datasourceStates, + visualization: { + ...state.visualization, + activeId: payload.newVisualizationId, + state: payload.initialState, + }, + stagedPreview: state.stagedPreview || { + datasourceStates: state.datasourceStates, + visualization: state.visualization, + }, + }; + }, + rollbackSuggestion: (state) => { + return { + ...state, + ...(state.stagedPreview || {}), + stagedPreview: undefined, + }; + }, + setToggleFullscreen: (state) => { + return { ...state, isFullscreenDatasource: !state.isFullscreenDatasource }; + }, + submitSuggestion: (state) => { + return { + ...state, + stagedPreview: undefined, + }; + }, + switchDatasource: ( + state, + { + payload, + }: { + payload: { + newDatasourceId: string; + }; + } + ) => { + return { + ...state, + datasourceStates: { + ...state.datasourceStates, + [payload.newDatasourceId]: state.datasourceStates[payload.newDatasourceId] || { + state: null, + isLoading: true, + }, + }, + activeDatasourceId: payload.newDatasourceId, + }; + }, + navigateAway: (state) => state, + }, +}); + +export const reducer = { + lens: lensSlice.reducer, +}; diff --git a/x-pack/plugins/lens/public/state_management/optimizing_middleware.ts b/x-pack/plugins/lens/public/state_management/optimizing_middleware.ts new file mode 100644 index 0000000000000..63e59221a683a --- /dev/null +++ b/x-pack/plugins/lens/public/state_management/optimizing_middleware.ts @@ -0,0 +1,22 @@ +/* + * 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 { Dispatch, MiddlewareAPI, PayloadAction } from '@reduxjs/toolkit'; +import { isEqual } from 'lodash'; +import { LensAppState } from './types'; + +/** cancels updates to the store that don't change the state */ +export const optimizingMiddleware = () => (store: MiddlewareAPI) => { + return (next: Dispatch) => (action: PayloadAction<Partial<LensAppState>>) => { + if (action.type === 'lens/onActiveDataChange') { + if (isEqual(store.getState().lens.activeData, action.payload)) { + return; + } + } + next(action); + }; +}; diff --git a/x-pack/plugins/lens/public/state_management/time_range_middleware.test.ts b/x-pack/plugins/lens/public/state_management/time_range_middleware.test.ts index 4145f8ed5e52c..a3a53a6d380ed 100644 --- a/x-pack/plugins/lens/public/state_management/time_range_middleware.test.ts +++ b/x-pack/plugins/lens/public/state_management/time_range_middleware.test.ts @@ -17,10 +17,9 @@ import { timeRangeMiddleware } from './time_range_middleware'; import { Observable, Subject } from 'rxjs'; import { DataPublicPluginStart, esFilters } from '../../../../../src/plugins/data/public'; import moment from 'moment'; -import { initialState } from './app_slice'; +import { initialState } from './lens_slice'; import { LensAppState } from './types'; import { PayloadAction } from '@reduxjs/toolkit'; -import { Document } from '../persistence'; const sessionIdSubject = new Subject<string>(); @@ -132,7 +131,7 @@ function makeDefaultData(): jest.Mocked<DataPublicPluginStart> { const createMiddleware = (data: DataPublicPluginStart) => { const middleware = timeRangeMiddleware(data); const store = { - getState: jest.fn(() => ({ app: initialState })), + getState: jest.fn(() => ({ lens: initialState })), dispatch: jest.fn(), }; const next = jest.fn(); @@ -157,8 +156,13 @@ describe('timeRangeMiddleware', () => { }); const { next, invoke, store } = createMiddleware(data); const action = { - type: 'app/setState', - payload: { lastKnownDoc: ('new' as unknown) as Document }, + type: 'lens/setState', + payload: { + visualization: { + state: {}, + activeId: 'id2', + }, + }, }; invoke(action); expect(store.dispatch).toHaveBeenCalledWith({ @@ -169,7 +173,7 @@ describe('timeRangeMiddleware', () => { }, searchSessionId: 'sessionId-1', }, - type: 'app/setState', + type: 'lens/setState', }); expect(next).toHaveBeenCalledWith(action); }); @@ -187,8 +191,39 @@ describe('timeRangeMiddleware', () => { }); const { next, invoke, store } = createMiddleware(data); const action = { - type: 'app/setState', - payload: { lastKnownDoc: ('new' as unknown) as Document }, + type: 'lens/setState', + payload: { + visualization: { + state: {}, + activeId: 'id2', + }, + }, + }; + invoke(action); + expect(store.dispatch).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledWith(action); + }); + it('does not trigger another update when the update already contains searchSessionId', () => { + const data = makeDefaultData(); + (data.nowProvider.get as jest.Mock).mockReturnValue(new Date(Date.now() - 30000)); + (data.query.timefilter.timefilter.getTime as jest.Mock).mockReturnValue({ + from: 'now-2m', + to: 'now', + }); + (data.query.timefilter.timefilter.getBounds as jest.Mock).mockReturnValue({ + min: moment(Date.now() - 100000), + max: moment(Date.now() - 30000), + }); + const { next, invoke, store } = createMiddleware(data); + const action = { + type: 'lens/setState', + payload: { + visualization: { + state: {}, + activeId: 'id2', + }, + searchSessionId: 'searchSessionId', + }, }; invoke(action); expect(store.dispatch).not.toHaveBeenCalled(); diff --git a/x-pack/plugins/lens/public/state_management/time_range_middleware.ts b/x-pack/plugins/lens/public/state_management/time_range_middleware.ts index a6c868be60565..cc3e46b71fbfc 100644 --- a/x-pack/plugins/lens/public/state_management/time_range_middleware.ts +++ b/x-pack/plugins/lens/public/state_management/time_range_middleware.ts @@ -5,27 +5,26 @@ * 2.0. */ -import { isEqual } from 'lodash'; import { Dispatch, MiddlewareAPI, PayloadAction } from '@reduxjs/toolkit'; import moment from 'moment'; - import { DataPublicPluginStart } from '../../../../../src/plugins/data/public'; import { setState, LensDispatch } from '.'; import { LensAppState } from './types'; import { getResolvedDateRange, containsDynamicMath, TIME_LAG_PERCENTAGE_LIMIT } from '../utils'; +/** + * checks if TIME_LAG_PERCENTAGE_LIMIT passed to renew searchSessionId + * and request new data. + */ export const timeRangeMiddleware = (data: DataPublicPluginStart) => (store: MiddlewareAPI) => { return (next: Dispatch) => (action: PayloadAction<Partial<LensAppState>>) => { - // if document was modified or sessionId check if too much time passed to update searchSessionId - if ( - action.payload?.lastKnownDoc && - !isEqual(action.payload?.lastKnownDoc, store.getState().app.lastKnownDoc) - ) { + if (!action.payload?.searchSessionId) { updateTimeRange(data, store.dispatch); } next(action); }; }; + function updateTimeRange(data: DataPublicPluginStart, dispatch: LensDispatch) { const timefilter = data.query.timefilter.timefilter; const unresolvedTimeRange = timefilter.getTime(); diff --git a/x-pack/plugins/lens/public/state_management/types.ts b/x-pack/plugins/lens/public/state_management/types.ts index 87045d15cc994..1c696a3d79f9d 100644 --- a/x-pack/plugins/lens/public/state_management/types.ts +++ b/x-pack/plugins/lens/public/state_management/types.ts @@ -5,24 +5,33 @@ * 2.0. */ -import { Filter, IndexPattern, Query, SavedQuery } from '../../../../../src/plugins/data/public'; +import { Filter, Query, SavedQuery } from '../../../../../src/plugins/data/public'; import { Document } from '../persistence'; import { TableInspectorAdapter } from '../editor_frame_service/types'; import { DateRange } from '../../common'; -export interface LensAppState { +export interface PreviewState { + visualization: { + activeId: string | null; + state: unknown; + }; + datasourceStates: Record<string, { state: unknown; isLoading: boolean }>; +} +export interface EditorFrameState extends PreviewState { + activeDatasourceId: string | null; + stagedPreview?: PreviewState; + isFullscreenDatasource?: boolean; +} +export interface LensAppState extends EditorFrameState { persistedDoc?: Document; - lastKnownDoc?: Document; - // index patterns used to determine which filters are available in the top nav. - indexPatternsForTopNav: IndexPattern[]; // Determines whether the lens editor shows the 'save and return' button, and the originating app breadcrumb. isLinkedToOriginatingApp?: boolean; isSaveable: boolean; activeData?: TableInspectorAdapter; - isAppLoading: boolean; + isLoading: boolean; query: Query; filters: Filter[]; savedQuery?: SavedQuery; @@ -38,5 +47,5 @@ export type DispatchSetState = ( }; export interface LensState { - app: LensAppState; + lens: LensAppState; } diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index 7baba15f0fac6..cb47dcf6ec388 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -7,7 +7,7 @@ import { IconType } from '@elastic/eui/src/components/icon/icon'; import { CoreSetup } from 'kibana/public'; -import { PaletteOutput, PaletteRegistry } from 'src/plugins/charts/public'; +import { PaletteOutput } from 'src/plugins/charts/public'; import { SavedObjectReference } from 'kibana/public'; import { MutableRefObject } from 'react'; import { RowClickContext } from '../../../../src/plugins/ui_actions/public'; @@ -45,13 +45,13 @@ export interface PublicAPIProps<T> { } export interface EditorFrameProps { - onError: ErrorCallback; - initialContext?: VisualizeFieldContext; showNoDataPopover: () => void; } export interface EditorFrameInstance { EditorFrameContainer: (props: EditorFrameProps) => React.ReactElement; + datasourceMap: Record<string, Datasource>; + visualizationMap: Record<string, Visualization>; } export interface EditorFrameSetup { @@ -525,20 +525,10 @@ export interface FramePublicAPI { * If accessing, make sure to check whether expected columns actually exist. */ activeData?: Record<string, Datatable>; - dateRange: DateRange; query: Query; filters: Filter[]; searchSessionId: string; - - /** - * A map of all available palettes (keys being the ids). - */ - availablePalettes: PaletteRegistry; - - // Adds a new layer. This has a side effect of updating the datasource state - addNewLayer: () => string; - removeLayers: (layerIds: string[]) => void; } /** @@ -586,7 +576,7 @@ export interface Visualization<T = unknown> { * - Loadingn from a saved visualization * - When using suggestions, the suggested state is passed in */ - initialize: (frame: FramePublicAPI, state?: T, mainPalette?: PaletteOutput) => T; + initialize: (addNewLayer: () => string, state?: T, mainPalette?: PaletteOutput) => T; getMainPalette?: (state: T) => undefined | PaletteOutput; diff --git a/x-pack/plugins/lens/public/utils.ts b/x-pack/plugins/lens/public/utils.ts index 1c4b2c67f96fc..a79480d7d9953 100644 --- a/x-pack/plugins/lens/public/utils.ts +++ b/x-pack/plugins/lens/public/utils.ts @@ -9,6 +9,12 @@ import { i18n } from '@kbn/i18n'; import { IndexPattern, IndexPatternsContract, TimefilterContract } from 'src/plugins/data/public'; import { IUiSettingsClient } from 'kibana/public'; import moment from 'moment-timezone'; +import { SavedObjectReference } from 'kibana/public'; +import { Filter, Query } from 'src/plugins/data/public'; +import { uniq } from 'lodash'; +import { Document } from './persistence/saved_object_store'; +import { Datasource } from './types'; +import { extractFilterReferences } from './persistence'; export function getVisualizeGeoFieldMessage(fieldType: string) { return i18n.translate('xpack.lens.visualizeGeoFieldMessage', { @@ -32,7 +38,105 @@ export function containsDynamicMath(dateMathString: string) { export const TIME_LAG_PERCENTAGE_LIMIT = 0.02; -export async function getAllIndexPatterns( +export function getTimeZone(uiSettings: IUiSettingsClient) { + const configuredTimeZone = uiSettings.get('dateFormat:tz'); + if (configuredTimeZone === 'Browser') { + return moment.tz.guess(); + } + + return configuredTimeZone; +} +export function getActiveDatasourceIdFromDoc(doc?: Document) { + if (!doc) { + return null; + } + + const [firstDatasourceFromDoc] = Object.keys(doc.state.datasourceStates); + return firstDatasourceFromDoc || null; +} + +export const getInitialDatasourceId = ( + datasourceMap: Record<string, Datasource>, + doc?: Document +) => { + return (doc && getActiveDatasourceIdFromDoc(doc)) || Object.keys(datasourceMap)[0] || null; +}; + +export interface GetIndexPatternsObjects { + activeDatasources: Record<string, Datasource>; + datasourceStates: Record<string, { state: unknown; isLoading: boolean }>; + visualization: { + activeId: string | null; + state: unknown; + }; + filters: Filter[]; + query: Query; + title: string; + description?: string; + persistedId?: string; +} + +export function getSavedObjectFormat({ + activeDatasources, + datasourceStates, + visualization, + filters, + query, + title, + description, + persistedId, +}: GetIndexPatternsObjects): Document { + const persistibleDatasourceStates: Record<string, unknown> = {}; + const references: SavedObjectReference[] = []; + Object.entries(activeDatasources).forEach(([id, datasource]) => { + const { state: persistableState, savedObjectReferences } = datasource.getPersistableState( + datasourceStates[id].state + ); + persistibleDatasourceStates[id] = persistableState; + references.push(...savedObjectReferences); + }); + + const { persistableFilters, references: filterReferences } = extractFilterReferences(filters); + + references.push(...filterReferences); + + return { + savedObjectId: persistedId, + title, + description, + type: 'lens', + visualizationType: visualization.activeId, + state: { + datasourceStates: persistibleDatasourceStates, + visualization: visualization.state, + query, + filters: persistableFilters, + }, + references, + }; +} + +export function getIndexPatternsIds({ + activeDatasources, + datasourceStates, +}: { + activeDatasources: Record<string, Datasource>; + datasourceStates: Record<string, { state: unknown; isLoading: boolean }>; +}): string[] { + const references: SavedObjectReference[] = []; + Object.entries(activeDatasources).forEach(([id, datasource]) => { + const { savedObjectReferences } = datasource.getPersistableState(datasourceStates[id].state); + references.push(...savedObjectReferences); + }); + + const uniqueFilterableIndexPatternIds = uniq( + references.filter(({ type }) => type === 'index-pattern').map(({ id }) => id) + ); + + return uniqueFilterableIndexPatternIds; +} + +export async function getIndexPatternsObjects( ids: string[], indexPatternsService: IndexPatternsContract ): Promise<{ indexPatterns: IndexPattern[]; rejectedIds: string[] }> { @@ -46,12 +150,3 @@ export async function getAllIndexPatterns( // return also the rejected ids in case we want to show something later on return { indexPatterns: fullfilled.map((response) => response.value), rejectedIds }; } - -export function getTimeZone(uiSettings: IUiSettingsClient) { - const configuredTimeZone = uiSettings.get('dateFormat:tz'); - if (configuredTimeZone === 'Browser') { - return moment.tz.guess(); - } - - return configuredTimeZone; -} diff --git a/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts b/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts index b88d38e18329c..a7270bdf8f331 100644 --- a/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts @@ -10,7 +10,7 @@ import { Position } from '@elastic/charts'; import { chartPluginMock } from '../../../../../src/plugins/charts/public/mocks'; import { getXyVisualization } from './xy_visualization'; import { Operation } from '../types'; -import { createMockDatasource, createMockFramePublicAPI } from '../editor_frame_service/mocks'; +import { createMockDatasource, createMockFramePublicAPI } from '../mocks'; import { dataPluginMock } from '../../../../../src/plugins/data/public/mocks'; describe('#toExpression', () => { diff --git a/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/visual_options_popover.test.tsx b/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/visual_options_popover.test.tsx index b46ad1940491e..ec0c11a0b1d86 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/visual_options_popover.test.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/visual_options_popover.test.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { shallowWithIntl as shallow } from '@kbn/test/jest'; import { Position } from '@elastic/charts'; import { FramePublicAPI } from '../../types'; -import { createMockDatasource, createMockFramePublicAPI } from '../../editor_frame_service/mocks'; +import { createMockDatasource, createMockFramePublicAPI } from '../../mocks'; import { State } from '../types'; import { VisualOptionsPopover } from './visual_options_popover'; import { ToolbarPopover } from '../../shared_components'; 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 dee0e5763dee4..304e323789c14 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts @@ -9,7 +9,7 @@ import { getXyVisualization } from './visualization'; import { Position } from '@elastic/charts'; import { Operation } from '../types'; import { State, SeriesType, XYLayerConfig } from './types'; -import { createMockDatasource, createMockFramePublicAPI } from '../editor_frame_service/mocks'; +import { createMockDatasource, createMockFramePublicAPI } from '../mocks'; import { LensIconChartBar } from '../assets/chart_bar'; import { chartPluginMock } from '../../../../../src/plugins/charts/public/mocks'; import { dataPluginMock } from '../../../../../src/plugins/data/public/mocks'; @@ -132,8 +132,7 @@ describe('xy_visualization', () => { describe('#initialize', () => { it('loads default state', () => { - const mockFrame = createMockFramePublicAPI(); - const initialState = xyVisualization.initialize(mockFrame); + const initialState = xyVisualization.initialize(() => 'l1'); expect(initialState.layers).toHaveLength(1); expect(initialState.layers[0].xAccessor).not.toBeDefined(); @@ -144,7 +143,7 @@ describe('xy_visualization', () => { "layers": Array [ Object { "accessors": Array [], - "layerId": "", + "layerId": "l1", "position": "top", "seriesType": "bar_stacked", "showGridlines": false, @@ -162,9 +161,7 @@ describe('xy_visualization', () => { }); it('loads from persisted state', () => { - expect(xyVisualization.initialize(createMockFramePublicAPI(), exampleState())).toEqual( - exampleState() - ); + expect(xyVisualization.initialize(() => 'first', exampleState())).toEqual(exampleState()); }); }); diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx index bd20ed300bf61..199dccdf702f7 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx @@ -152,7 +152,7 @@ export const getXyVisualization = ({ getSuggestions, - initialize(frame, state) { + initialize(addNewLayer, state) { return ( state || { title: 'Empty XY chart', @@ -161,7 +161,7 @@ export const getXyVisualization = ({ preferredSeriesType: defaultSeriesType, layers: [ { - layerId: frame.addNewLayer(), + layerId: addNewLayer(), accessors: [], position: Position.Top, seriesType: defaultSeriesType, diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx index bc10236cf1977..9292a8d87bbc4 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx @@ -13,7 +13,7 @@ import { AxisSettingsPopover } from './axis_settings_popover'; import { FramePublicAPI } from '../types'; import { State } from './types'; import { Position } from '@elastic/charts'; -import { createMockFramePublicAPI, createMockDatasource } from '../editor_frame_service/mocks'; +import { createMockFramePublicAPI, createMockDatasource } from '../mocks'; import { chartPluginMock } from 'src/plugins/charts/public/mocks'; import { EuiColorPicker } from '@elastic/eui'; 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 dbe9cd163451d..ded56ec9e817f 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 @@ -99,7 +99,6 @@ export function ExploratoryViewHeader({ seriesId, lensAttributes }: Props) { {isSaveOpen && lensAttributes && ( <LensSaveModalComponent - isVisible={isSaveOpen} initialInput={(lensAttributes as unknown) as LensEmbeddableInput} onClose={() => setIsSaveOpen(false)} onSave={() => {}} diff --git a/x-pack/test/accessibility/apps/lens.ts b/x-pack/test/accessibility/apps/lens.ts index 4157f31525acf..fce15b34a77e4 100644 --- a/x-pack/test/accessibility/apps/lens.ts +++ b/x-pack/test/accessibility/apps/lens.ts @@ -142,8 +142,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.lens.configureDimension( { dimension: 'lnsXY_xDimensionPanel > lns-empty-dimension', - operation: 'terms', - field: 'ip', + operation: 'date_histogram', + field: '@timestamp', }, 1 ); diff --git a/x-pack/test/functional/apps/lens/dashboard.ts b/x-pack/test/functional/apps/lens/dashboard.ts index 844b074e42e74..6e4c20744c5fc 100644 --- a/x-pack/test/functional/apps/lens/dashboard.ts +++ b/x-pack/test/functional/apps/lens/dashboard.ts @@ -223,10 +223,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { // remove the x dimension to trigger the validation error await PageObjects.lens.removeDimension('lnsXY_xDimensionPanel'); - await PageObjects.lens.saveAndReturn(); - - await PageObjects.header.waitUntilLoadingHasFinished(); - await testSubjects.existOrFail('embeddable-lens-failure'); + await PageObjects.lens.expectSaveAndReturnButtonDisabled(); }); }); } diff --git a/x-pack/test/functional/page_objects/lens_page.ts b/x-pack/test/functional/page_objects/lens_page.ts index 0fc85f78ac90b..d02bc591a80a2 100644 --- a/x-pack/test/functional/page_objects/lens_page.ts +++ b/x-pack/test/functional/page_objects/lens_page.ts @@ -491,6 +491,12 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont await testSubjects.click('lnsApp_saveAndReturnButton'); }, + async expectSaveAndReturnButtonDisabled() { + const button = await testSubjects.find('lnsApp_saveAndReturnButton', 10000); + const disabledAttr = await button.getAttribute('disabled'); + expect(disabledAttr).to.be('true'); + }, + async editDimensionLabel(label: string) { await testSubjects.setValue('indexPattern-label-edit', label, { clearWithKeyboard: true }); }, From 6c328cc8e07c193308b1b9d6187345e1a04b489f Mon Sep 17 00:00:00 2001 From: Sergi Massaneda <sergi.massaneda@elastic.co> Date: Thu, 1 Jul 2021 11:12:20 +0200 Subject: [PATCH 29/51] [Security Solutions] Administration breadcrumbs shortened to be consistent with the rest (#103927) * Administration breadcrumbs shortened to bbe consistent with the rest * remove comment --- .../navigation/breadcrumbs/index.test.ts | 11 +++++++---- .../components/navigation/breadcrumbs/index.ts | 13 +------------ .../public/management/common/breadcrumbs.ts | 17 +---------------- 3 files changed, 9 insertions(+), 32 deletions(-) diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/breadcrumbs/index.test.ts b/x-pack/plugins/security_solution/public/common/components/navigation/breadcrumbs/index.test.ts index 6789d8e1d4524..1f7e668b21b98 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/breadcrumbs/index.test.ts +++ b/x-pack/plugins/security_solution/public/common/components/navigation/breadcrumbs/index.test.ts @@ -13,6 +13,7 @@ import { RouteSpyState, SiemRouteType } from '../../../utils/route/types'; import { TabNavigationProps } from '../tab_navigation/types'; import { NetworkRouteType } from '../../../../network/pages/navigation/types'; import { TimelineTabs } from '../../../../../common/types/timeline'; +import { AdministrationSubTab } from '../../../../management/types'; const setBreadcrumbsMock = jest.fn(); const chromeMock = { @@ -26,6 +27,8 @@ const mockDefaultTab = (pageName: string): SiemRouteType | undefined => { return HostsTableType.authentications; case 'network': return NetworkRouteType.flows; + case 'administration': + return AdministrationSubTab.endpoints; default: return undefined; } @@ -423,16 +426,16 @@ describe('Navigation Breadcrumbs', () => { }, ]); }); - test('should return Admin breadcrumbs when supplied admin pathname', () => { + test('should return Admin breadcrumbs when supplied endpoints pathname', () => { const breadcrumbs = getBreadcrumbsForRoute( - getMockObject('administration', '/', undefined), + getMockObject('administration', '/endpoints', undefined), getUrlForAppMock ); expect(breadcrumbs).toEqual([ { text: 'Security', href: 'securitySolution/overview' }, { - text: 'Administration', - href: 'securitySolution/endpoints', + text: 'Endpoints', + href: '', }, ]); }); diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/breadcrumbs/index.ts b/x-pack/plugins/security_solution/public/common/components/navigation/breadcrumbs/index.ts index 4578e16dc5540..03ee38473e58d 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/breadcrumbs/index.ts +++ b/x-pack/plugins/security_solution/public/common/components/navigation/breadcrumbs/index.ts @@ -186,18 +186,7 @@ export const getBreadcrumbsForRoute = ( if (spyState.tabName != null) { urlStateKeys = [...urlStateKeys, getOr(tempNav, spyState.tabName, object.navTabs)]; } - - return [ - siemRootBreadcrumb, - ...getAdminBreadcrumbs( - spyState, - urlStateKeys.reduce( - (acc: string[], item: SearchNavTab) => [...acc, getSearch(item, object)], - [] - ), - getUrlForApp - ), - ]; + return [siemRootBreadcrumb, ...getAdminBreadcrumbs(spyState)]; } if ( diff --git a/x-pack/plugins/security_solution/public/management/common/breadcrumbs.ts b/x-pack/plugins/security_solution/public/management/common/breadcrumbs.ts index d437c45792766..9c3d781f514e9 100644 --- a/x-pack/plugins/security_solution/public/management/common/breadcrumbs.ts +++ b/x-pack/plugins/security_solution/public/management/common/breadcrumbs.ts @@ -6,13 +6,9 @@ */ import { ChromeBreadcrumb } from 'kibana/public'; -import { isEmpty } from 'lodash/fp'; import { AdministrationSubTab } from '../types'; import { ENDPOINTS_TAB, EVENT_FILTERS_TAB, POLICIES_TAB, TRUSTED_APPS_TAB } from './translations'; import { AdministrationRouteSpyState } from '../../common/utils/route/types'; -import { GetUrlForApp } from '../../common/components/navigation/types'; -import { ADMINISTRATION } from '../../app/translations'; -import { APP_ID, SecurityPageName } from '../../../common/constants'; const TabNameMappedToI18nKey: Record<AdministrationSubTab, string> = { [AdministrationSubTab.endpoints]: ENDPOINTS_TAB, @@ -21,19 +17,8 @@ const TabNameMappedToI18nKey: Record<AdministrationSubTab, string> = { [AdministrationSubTab.eventFilters]: EVENT_FILTERS_TAB, }; -export function getBreadcrumbs( - params: AdministrationRouteSpyState, - search: string[], - getUrlForApp: GetUrlForApp -): ChromeBreadcrumb[] { +export function getBreadcrumbs(params: AdministrationRouteSpyState): ChromeBreadcrumb[] { return [ - { - text: ADMINISTRATION, - href: getUrlForApp(APP_ID, { - deepLinkId: SecurityPageName.endpoints, - path: !isEmpty(search[0]) ? search[0] : '', - }), - }, ...(params?.tabName ? [params?.tabName] : []).map((tabName) => ({ text: TabNameMappedToI18nKey[tabName], href: '', From c52e0e12aa6575cbb2f968b76f683eaac2e0d6af Mon Sep 17 00:00:00 2001 From: Stratoula Kalafateli <efstratia.kalafateli@elastic.co> Date: Thu, 1 Jul 2021 12:34:28 +0300 Subject: [PATCH 30/51] [TSVB] Documents the new index pattern mode (#102880) * [TSVB] Document the new index pattern mode * Add a callout to TSVB to advertise the new index pattern mode * Conditionally render the callout, give capability to dismiss it * Fix i18n * Update the notification texts * Update notification text * Change callout storage key * add UseIndexPatternModeCallout component * Update docs/user/dashboard/tsvb.asciidoc Co-authored-by: Kaarina Tungseth <kaarina.tungseth@elastic.co> * Update docs/user/dashboard/tsvb.asciidoc Co-authored-by: Kaarina Tungseth <kaarina.tungseth@elastic.co> * Update docs/user/dashboard/tsvb.asciidoc Co-authored-by: Kaarina Tungseth <kaarina.tungseth@elastic.co> * Update docs/user/dashboard/tsvb.asciidoc Co-authored-by: Kaarina Tungseth <kaarina.tungseth@elastic.co> * Update docs/user/dashboard/tsvb.asciidoc Co-authored-by: Kaarina Tungseth <kaarina.tungseth@elastic.co> * Update docs/user/dashboard/tsvb.asciidoc Co-authored-by: Kaarina Tungseth <kaarina.tungseth@elastic.co> * Update docs/user/dashboard/tsvb.asciidoc Co-authored-by: Kaarina Tungseth <kaarina.tungseth@elastic.co> * Update docs/user/dashboard/tsvb.asciidoc Co-authored-by: Kaarina Tungseth <kaarina.tungseth@elastic.co> * Final docs changes * Remove TSVB from title Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Alexey Antonov <alexwizp@gmail.com> Co-authored-by: Kaarina Tungseth <kaarina.tungseth@elastic.co> --- .../tsvb_index_pattern_selection_mode.png | Bin 0 -> 130582 bytes docs/user/dashboard/tsvb.asciidoc | 25 ++++++- .../public/doc_links/doc_links_service.ts | 1 + .../use_index_patter_mode_callout.tsx | 69 ++++++++++++++++++ .../application/components/vis_editor.tsx | 3 +- 5 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 docs/user/dashboard/images/tsvb_index_pattern_selection_mode.png create mode 100644 src/plugins/vis_type_timeseries/public/application/components/use_index_patter_mode_callout.tsx diff --git a/docs/user/dashboard/images/tsvb_index_pattern_selection_mode.png b/docs/user/dashboard/images/tsvb_index_pattern_selection_mode.png new file mode 100644 index 0000000000000000000000000000000000000000..ef72f291850e48f9f5c9e943f6ba8e9d4e70e867 GIT binary patch literal 130582 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 P2Kc8Srz#7VF?#!d!GQ-w literal 0 HcmV?d00001 diff --git a/docs/user/dashboard/tsvb.asciidoc b/docs/user/dashboard/tsvb.asciidoc index 93ee3627bd8a0..9320b062a8ba9 100644 --- a/docs/user/dashboard/tsvb.asciidoc +++ b/docs/user/dashboard/tsvb.asciidoc @@ -30,6 +30,29 @@ By default, *TSVB* drops the last bucket because the time filter intersects the .. In the *Panel filter* field, enter <<kuery-query, KQL filters>> to view specific documents. +[float] +[[tsvb-index-pattern-mode]] +==== Index pattern mode +Create *TSVB* visualizations with {kib} index patterns. + +IMPORTANT: Creating *TSVB* visualizations with an {es} index string is deprecated and will be removed in a future release. +It is the default one for new visualizations but it can also be switched for the old implementations: + +. Click *Panel options*, then click the gear icon to open the *Index pattern selection mode* options. +. Select *Use only Kibana index patterns*. +. Reselect the index pattern from the dropdown, then select the *Time field*. + +image::images/tsvb_index_pattern_selection_mode.png[Change index pattern selection mode action] + +The index pattern mode unlocks many new features, such as: +* Runtime fields + +* URL drilldowns + +* Interactive filters for time series visualizations + +* Better performance + [float] [[configure-the-data-series]] ==== Configure the series @@ -177,4 +200,4 @@ To group with multiple fields, create runtime fields in the index pattern you ar [role="screenshot"] image::images/tsvb_group_by_multiple_fields.png[Group by multiple fields] -. Create a new TSVB visualization and group by this field. \ No newline at end of file +. Create a new TSVB visualization and group by this field. diff --git a/src/core/public/doc_links/doc_links_service.ts b/src/core/public/doc_links/doc_links_service.ts index 9ab5480b809bc..6bb714e913838 100644 --- a/src/core/public/doc_links/doc_links_service.ts +++ b/src/core/public/doc_links/doc_links_service.ts @@ -263,6 +263,7 @@ export class DocLinksService { lensPanels: `${KIBANA_DOCS}lens.html`, maps: `${ELASTIC_WEBSITE_URL}maps`, vega: `${KIBANA_DOCS}vega.html`, + tsvbIndexPatternMode: `${KIBANA_DOCS}tsvb.html#tsvb-index-pattern-mode`, }, observability: { guide: `${ELASTIC_WEBSITE_URL}guide/en/observability/${DOC_LINK_VERSION}/index.html`, diff --git a/src/plugins/vis_type_timeseries/public/application/components/use_index_patter_mode_callout.tsx b/src/plugins/vis_type_timeseries/public/application/components/use_index_patter_mode_callout.tsx new file mode 100644 index 0000000000000..6191df2ecce5b --- /dev/null +++ b/src/plugins/vis_type_timeseries/public/application/components/use_index_patter_mode_callout.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 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, { useMemo, useCallback } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import useLocalStorage from 'react-use/lib/useLocalStorage'; +import { EuiButton, EuiCallOut, EuiFlexGroup, EuiLink } from '@elastic/eui'; +import { getCoreStart } from '../../services'; + +const LOCAL_STORAGE_KEY = 'TSVB_INDEX_PATTERN_CALLOUT_HIDDEN'; + +export const UseIndexPatternModeCallout = () => { + const [dismissed, setDismissed] = useLocalStorage(LOCAL_STORAGE_KEY, false); + const indexPatternModeLink = useMemo( + () => getCoreStart().docLinks.links.visualize.tsvbIndexPatternMode, + [] + ); + + const dismissNotice = useCallback(() => { + setDismissed(true); + }, [setDismissed]); + + if (dismissed) { + return null; + } + + return ( + <EuiCallOut + title={ + <FormattedMessage + id="visTypeTimeseries.visEditorVisualization.indexPatternMode.notificationTitle" + defaultMessage="TSVB now supports index patterns" + /> + } + iconType="cheer" + size="s" + > + <p> + <FormattedMessage + id="visTypeTimeseries.visEditorVisualization.indexPatternMode.notificationMessage" + defaultMessage="Great news! You can now visualize the data from Elasticsearch indices or Kibana index patterns. {indexPatternModeLink}." + values={{ + indexPatternModeLink: ( + <EuiLink href={indexPatternModeLink} target="_blank" external> + <FormattedMessage + id="visTypeTimeseries.visEditorVisualization.indexPatternMode.link" + defaultMessage="Check it out." + /> + </EuiLink> + ), + }} + /> + </p> + <EuiFlexGroup gutterSize="none"> + <EuiButton size="s" onClick={dismissNotice}> + <FormattedMessage + id="visTypeTimeseries.visEditorVisualization.indexPatternMode.dismissNoticeButtonText" + defaultMessage="Dismiss" + /> + </EuiButton> + </EuiFlexGroup> + </EuiCallOut> + ); +}; diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_editor.tsx b/src/plugins/vis_type_timeseries/public/application/components/vis_editor.tsx index d11b5a60b31b7..424b39feff836 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/vis_editor.tsx +++ b/src/plugins/vis_type_timeseries/public/application/components/vis_editor.tsx @@ -12,7 +12,6 @@ import uuid from 'uuid/v4'; import { share } from 'rxjs/operators'; import { isEqual, isEmpty, debounce } from 'lodash'; import { EventEmitter } from 'events'; - import type { IUiSettingsClient } from 'kibana/public'; import { Vis, @@ -35,6 +34,7 @@ import { VisPicker } from './vis_picker'; import { fetchFields, VisFields } from '../lib/fetch_fields'; import { getDataStart, getCoreStart } from '../../services'; import { TimeseriesVisParams } from '../../types'; +import { UseIndexPatternModeCallout } from './use_index_patter_mode_callout'; const VIS_STATE_DEBOUNCE_DELAY = 200; const APP_NAME = 'VisEditor'; @@ -182,6 +182,7 @@ export class VisEditor extends Component<TimeseriesEditorProps, TimeseriesEditor > <DefaultIndexPatternContext.Provider value={this.state.defaultIndex}> <div className="tvbEditor" data-test-subj="tvbVisEditor"> + {!this.props.vis.params.use_kibana_indexes && <UseIndexPatternModeCallout />} <div className="tvbEditor--hideForReporting"> <VisPicker currentVisType={model.type} onChange={this.handleChange} /> </div> From 1b75ac5eb770031fc29ba6535dbca7e52256ba02 Mon Sep 17 00:00:00 2001 From: Dmitry Tomashevich <39378793+Dmitriynj@users.noreply.github.com> Date: Thu, 1 Jul 2021 12:49:21 +0300 Subject: [PATCH 31/51] [Discover] fix sidebar content for old ff (#103424) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../apps/main/components/sidebar/discover_sidebar.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar.scss b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar.scss index 139230fbdb66a..9ef123fa1a60f 100644 --- a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar.scss +++ b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar.scss @@ -1,4 +1,5 @@ .dscSidebar { + overflow: hidden; margin: 0 !important; flex-grow: 1; padding-left: $euiSize; From ba85f45014e61b654a08d1551e6f642afc7d1406 Mon Sep 17 00:00:00 2001 From: mgiota <giota85@gmail.com> Date: Thu, 1 Jul 2021 12:13:33 +0200 Subject: [PATCH 32/51] [Logs & Metrics] refactor breadcrumbs (#103249) * [Logs & Metrics] refactor breadcrumbs * [Logs & Metrics] remove Header component, move translations and create readonly badge hook * add breadcrumb to metric detail page * fix check_file_casing ci issues * create separate breadcrumb hook for logs and metrics * fix metrics translation title * fix wrong imports and unused variables * fix translation imports * fix unused import * refactor use_breadcrumbs * remove Header component * fix linter exhaustive-deps error by wrapping into useMemo * refactor use_readonly_badge * remove commented out code Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- x-pack/plugins/infra/common/constants.ts | 2 + .../infra/public/components/header/header.tsx | 56 ------------------- .../infra/public/components/header/index.ts | 8 --- .../infra/public/hooks/use_breadcrumbs.ts | 37 ++++++++++++ .../public/hooks/use_logs_breadcrumbs.tsx | 15 +++++ .../public/hooks/use_metrics_breadcrumbs.tsx | 15 +++++ .../infra/public/hooks/use_readonly_badge.tsx | 32 +++++++++++ x-pack/plugins/infra/public/pages/error.tsx | 2 - .../pages/logs/log_entry_categories/page.tsx | 8 +++ .../public/pages/logs/log_entry_rate/page.tsx | 7 +++ .../infra/public/pages/logs/page_content.tsx | 12 +--- .../source_configuration_settings.tsx | 12 ++-- .../infra/public/pages/logs/stream/page.tsx | 8 +++ .../infra/public/pages/metrics/index.tsx | 15 +---- .../pages/metrics/inventory_view/index.tsx | 13 +++-- .../pages/metrics/metric_detail/index.tsx | 24 ++++---- .../pages/metrics/metrics_explorer/index.tsx | 13 +++-- .../source_configuration_settings.tsx | 13 +++-- x-pack/plugins/infra/public/translations.ts | 47 ++++++++++++++++ 19 files changed, 222 insertions(+), 117 deletions(-) delete mode 100644 x-pack/plugins/infra/public/components/header/header.tsx delete mode 100644 x-pack/plugins/infra/public/components/header/index.ts create mode 100644 x-pack/plugins/infra/public/hooks/use_breadcrumbs.ts create mode 100644 x-pack/plugins/infra/public/hooks/use_logs_breadcrumbs.tsx create mode 100644 x-pack/plugins/infra/public/hooks/use_metrics_breadcrumbs.tsx create mode 100644 x-pack/plugins/infra/public/hooks/use_readonly_badge.tsx create mode 100644 x-pack/plugins/infra/public/translations.ts diff --git a/x-pack/plugins/infra/common/constants.ts b/x-pack/plugins/infra/common/constants.ts index 5361434302a7d..9362293fce82f 100644 --- a/x-pack/plugins/infra/common/constants.ts +++ b/x-pack/plugins/infra/common/constants.ts @@ -9,3 +9,5 @@ export const DEFAULT_SOURCE_ID = 'default'; export const METRICS_INDEX_PATTERN = 'metrics-*,metricbeat-*'; export const LOGS_INDEX_PATTERN = 'logs-*,filebeat-*,kibana_sample_data_logs*'; export const TIMESTAMP_FIELD = '@timestamp'; +export const METRICS_APP = 'metrics'; +export const LOGS_APP = 'logs'; diff --git a/x-pack/plugins/infra/public/components/header/header.tsx b/x-pack/plugins/infra/public/components/header/header.tsx deleted file mode 100644 index 6196a0b117879..0000000000000 --- a/x-pack/plugins/infra/public/components/header/header.tsx +++ /dev/null @@ -1,56 +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 { useCallback, useEffect } from 'react'; -import { i18n } from '@kbn/i18n'; -import { ChromeBreadcrumb } from 'src/core/public'; -import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; - -interface HeaderProps { - breadcrumbs?: ChromeBreadcrumb[]; - readOnlyBadge?: boolean; -} - -export const Header = ({ breadcrumbs = [], readOnlyBadge = false }: HeaderProps) => { - const chrome = useKibana().services.chrome; - - // eslint-disable-next-line react-hooks/exhaustive-deps - const badge = readOnlyBadge - ? { - text: i18n.translate('xpack.infra.header.badge.readOnly.text', { - defaultMessage: 'Read only', - }), - tooltip: i18n.translate('xpack.infra.header.badge.readOnly.tooltip', { - defaultMessage: 'Unable to change source configuration', - }), - iconType: 'glasses', - } - : undefined; - - const setBreadcrumbs = useCallback(() => { - return chrome?.setBreadcrumbs(breadcrumbs || []); - }, [breadcrumbs, chrome]); - - const setBadge = useCallback(() => { - return chrome?.setBadge(badge); - }, [badge, chrome]); - - useEffect(() => { - setBreadcrumbs(); - setBadge(); - }, [setBreadcrumbs, setBadge]); - - useEffect(() => { - setBreadcrumbs(); - }, [breadcrumbs, setBreadcrumbs]); - - useEffect(() => { - setBadge(); - }, [badge, setBadge]); - - return null; -}; diff --git a/x-pack/plugins/infra/public/components/header/index.ts b/x-pack/plugins/infra/public/components/header/index.ts deleted file mode 100644 index 37156e1b6cacd..0000000000000 --- a/x-pack/plugins/infra/public/components/header/index.ts +++ /dev/null @@ -1,8 +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 { Header } from './header'; diff --git a/x-pack/plugins/infra/public/hooks/use_breadcrumbs.ts b/x-pack/plugins/infra/public/hooks/use_breadcrumbs.ts new file mode 100644 index 0000000000000..32127f21b75f5 --- /dev/null +++ b/x-pack/plugins/infra/public/hooks/use_breadcrumbs.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ChromeBreadcrumb } from 'kibana/public'; +import { useEffect } from 'react'; +import { observabilityTitle } from '../translations'; +import { useKibanaContextForPlugin } from './use_kibana'; +import { useLinkProps } from './use_link_props'; + +type AppId = 'logs' | 'metrics'; + +export const useBreadcrumbs = (app: AppId, appTitle: string, extraCrumbs: ChromeBreadcrumb[]) => { + const { + services: { chrome }, + } = useKibanaContextForPlugin(); + + const observabilityLinkProps = useLinkProps({ app: 'observability-overview' }); + const appLinkProps = useLinkProps({ app }); + + useEffect(() => { + chrome?.setBreadcrumbs?.([ + { + ...observabilityLinkProps, + text: observabilityTitle, + }, + { + ...appLinkProps, + text: appTitle, + }, + ...extraCrumbs, + ]); + }, [appLinkProps, appTitle, chrome, extraCrumbs, observabilityLinkProps]); +}; diff --git a/x-pack/plugins/infra/public/hooks/use_logs_breadcrumbs.tsx b/x-pack/plugins/infra/public/hooks/use_logs_breadcrumbs.tsx new file mode 100644 index 0000000000000..e00e5b4818ba6 --- /dev/null +++ b/x-pack/plugins/infra/public/hooks/use_logs_breadcrumbs.tsx @@ -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 { ChromeBreadcrumb } from 'kibana/public'; +import { useBreadcrumbs } from './use_breadcrumbs'; +import { LOGS_APP } from '../../common/constants'; +import { logsTitle } from '../translations'; + +export const useLogsBreadcrumbs = (extraCrumbs: ChromeBreadcrumb[]) => { + useBreadcrumbs(LOGS_APP, logsTitle, extraCrumbs); +}; diff --git a/x-pack/plugins/infra/public/hooks/use_metrics_breadcrumbs.tsx b/x-pack/plugins/infra/public/hooks/use_metrics_breadcrumbs.tsx new file mode 100644 index 0000000000000..e55f65a97b63e --- /dev/null +++ b/x-pack/plugins/infra/public/hooks/use_metrics_breadcrumbs.tsx @@ -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 { ChromeBreadcrumb } from 'kibana/public'; +import { useBreadcrumbs } from './use_breadcrumbs'; +import { METRICS_APP } from '../../common/constants'; +import { metricsTitle } from '../translations'; + +export const useMetricsBreadcrumbs = (extraCrumbs: ChromeBreadcrumb[]) => { + useBreadcrumbs(METRICS_APP, metricsTitle, extraCrumbs); +}; diff --git a/x-pack/plugins/infra/public/hooks/use_readonly_badge.tsx b/x-pack/plugins/infra/public/hooks/use_readonly_badge.tsx new file mode 100644 index 0000000000000..a0b0558e0393d --- /dev/null +++ b/x-pack/plugins/infra/public/hooks/use_readonly_badge.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 { useEffect } from 'react'; +import { i18n } from '@kbn/i18n'; +import { useKibana } from '../../../../../src/plugins/kibana_react/public'; + +export const useReadOnlyBadge = (isReadOnly = false) => { + const chrome = useKibana().services.chrome; + + useEffect(() => { + chrome?.setBadge( + isReadOnly + ? { + text: i18n.translate('xpack.infra.header.badge.readOnly.text', { + defaultMessage: 'Read only', + }), + tooltip: i18n.translate('xpack.infra.header.badge.readOnly.tooltip', { + defaultMessage: 'Unable to change source configuration', + }), + iconType: 'glasses', + } + : undefined + ); + }, [chrome, isReadOnly]); + + return null; +}; diff --git a/x-pack/plugins/infra/public/pages/error.tsx b/x-pack/plugins/infra/public/pages/error.tsx index 6b6eaf98b1db6..18cb2a14a9214 100644 --- a/x-pack/plugins/infra/public/pages/error.tsx +++ b/x-pack/plugins/infra/public/pages/error.tsx @@ -18,7 +18,6 @@ import { FormattedMessage } from '@kbn/i18n/react'; import React from 'react'; import { euiStyled } from '../../../../../src/plugins/kibana_react/common'; -import { Header } from '../components/header'; import { ColumnarPage, PageContent } from '../components/page'; const DetailPageContent = euiStyled(PageContent)` @@ -33,7 +32,6 @@ interface Props { export const Error: React.FC<Props> = ({ message }) => { return ( <ColumnarPage> - <Header /> <DetailPageContent> <ErrorPageBody message={message} /> </DetailPageContent> diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page.tsx index 64dbcbdfe2258..34634b194cb85 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page.tsx @@ -7,10 +7,18 @@ import { EuiErrorBoundary } from '@elastic/eui'; import React from 'react'; +import { useLogsBreadcrumbs } from '../../../hooks/use_logs_breadcrumbs'; import { LogEntryCategoriesPageContent } from './page_content'; import { LogEntryCategoriesPageProviders } from './page_providers'; +import { logCategoriesTitle } from '../../../translations'; export const LogEntryCategoriesPage = () => { + useLogsBreadcrumbs([ + { + text: logCategoriesTitle, + }, + ]); + return ( <EuiErrorBoundary> <LogEntryCategoriesPageProviders> diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page.tsx index ff4cba731b616..94950b24b1a94 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page.tsx @@ -9,8 +9,15 @@ import { EuiErrorBoundary } from '@elastic/eui'; import React from 'react'; import { LogEntryRatePageContent } from './page_content'; import { LogEntryRatePageProviders } from './page_providers'; +import { useLogsBreadcrumbs } from '../../../hooks/use_logs_breadcrumbs'; +import { anomaliesTitle } from '../../../translations'; export const LogEntryRatePage = () => { + useLogsBreadcrumbs([ + { + text: anomaliesTitle, + }, + ]); return ( <EuiErrorBoundary> <LogEntryRatePageProviders> diff --git a/x-pack/plugins/infra/public/pages/logs/page_content.tsx b/x-pack/plugins/infra/public/pages/logs/page_content.tsx index c7b145b4b0143..8175a95f6a064 100644 --- a/x-pack/plugins/infra/public/pages/logs/page_content.tsx +++ b/x-pack/plugins/infra/public/pages/logs/page_content.tsx @@ -14,7 +14,6 @@ import useMount from 'react-use/lib/useMount'; import { AlertDropdown } from '../../alerting/log_threshold'; import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; import { DocumentTitle } from '../../components/document_title'; -import { Header } from '../../components/header'; import { HelpCenterContent } from '../../components/help_center_content'; import { useLogSourceContext } from '../../containers/logs/log_source'; import { RedirectWithQueryParams } from '../../utils/redirect_with_query_params'; @@ -25,6 +24,7 @@ import { StreamPage } from './stream'; import { HeaderMenuPortal } from '../../../../observability/public'; import { HeaderActionMenuContext } from '../../utils/header_action_menu_provider'; import { useLinkProps } from '../../hooks/use_link_props'; +import { useReadOnlyBadge } from '../../hooks/use_readonly_badge'; export const LogsPageContent: React.FunctionComponent = () => { const uiCapabilities = useKibana().services.application?.capabilities; @@ -34,6 +34,8 @@ export const LogsPageContent: React.FunctionComponent = () => { const kibana = useKibana(); + useReadOnlyBadge(!uiCapabilities?.logs?.save); + useMount(() => { initialize(); }); @@ -101,14 +103,6 @@ export const LogsPageContent: React.FunctionComponent = () => { </HeaderMenuPortal> )} - <Header - breadcrumbs={[ - { - text: pageTitle, - }, - ]} - readOnlyBadge={!uiCapabilities?.logs?.save} - /> <Switch> <Route path={streamTab.pathname} component={StreamPage} /> <Route path={anomaliesTab.pathname} component={LogEntryRatePage} /> diff --git a/x-pack/plugins/infra/public/pages/logs/settings/source_configuration_settings.tsx b/x-pack/plugins/infra/public/pages/logs/settings/source_configuration_settings.tsx index 180949572b086..a765cf074271c 100644 --- a/x-pack/plugins/infra/public/pages/logs/settings/source_configuration_settings.tsx +++ b/x-pack/plugins/infra/public/pages/logs/settings/source_configuration_settings.tsx @@ -18,6 +18,7 @@ import { FormattedMessage } from '@kbn/i18n/react'; import React, { useCallback, useMemo } from 'react'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { useTrackPageview } from '../../../../../observability/public'; +import { useLogsBreadcrumbs } from '../../../hooks/use_logs_breadcrumbs'; import { SourceLoadingPage } from '../../../components/source_loading_page'; import { useLogSourceContext } from '../../../containers/logs/log_source'; import { Prompt } from '../../../utils/navigation_warning_prompt'; @@ -27,10 +28,7 @@ import { NameConfigurationPanel } from './name_configuration_panel'; import { LogSourceConfigurationFormErrors } from './source_configuration_form_errors'; import { useLogSourceConfigurationFormState } from './source_configuration_form_state'; import { LogsPageTemplate } from '../page_template'; - -const settingsTitle = i18n.translate('xpack.infra.logs.settingsTitle', { - defaultMessage: 'Settings', -}); +import { settingsTitle } from '../../../translations'; export const LogsSettingsPage = () => { const uiCapabilities = useKibana().services.application?.capabilities; @@ -43,6 +41,12 @@ export const LogsSettingsPage = () => { delay: 15000, }); + useLogsBreadcrumbs([ + { + text: settingsTitle, + }, + ]); + const { sourceConfiguration: source, hasFailedLoadingSource, diff --git a/x-pack/plugins/infra/public/pages/logs/stream/page.tsx b/x-pack/plugins/infra/public/pages/logs/stream/page.tsx index 99b66d2d4ab7b..2ac307570cc97 100644 --- a/x-pack/plugins/infra/public/pages/logs/stream/page.tsx +++ b/x-pack/plugins/infra/public/pages/logs/stream/page.tsx @@ -8,13 +8,21 @@ import { EuiErrorBoundary } from '@elastic/eui'; import React from 'react'; import { useTrackPageview } from '../../../../../observability/public'; +import { useLogsBreadcrumbs } from '../../../hooks/use_logs_breadcrumbs'; import { StreamPageContent } from './page_content'; import { StreamPageHeader } from './page_header'; import { LogsPageProviders } from './page_providers'; +import { streamTitle } from '../../../translations'; export const StreamPage = () => { useTrackPageview({ app: 'infra_logs', path: 'stream' }); useTrackPageview({ app: 'infra_logs', path: 'stream', delay: 15000 }); + + useLogsBreadcrumbs([ + { + text: streamTitle, + }, + ]); return ( <EuiErrorBoundary> <LogsPageProviders> diff --git a/x-pack/plugins/infra/public/pages/metrics/index.tsx b/x-pack/plugins/infra/public/pages/metrics/index.tsx index e52d1e90d7efd..045fcb57ae943 100644 --- a/x-pack/plugins/infra/public/pages/metrics/index.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/index.tsx @@ -15,7 +15,7 @@ import { IIndexPattern } from 'src/plugins/data/common'; import { MetricsSourceConfigurationProperties } from '../../../common/metrics_sources'; import { DocumentTitle } from '../../components/document_title'; import { HelpCenterContent } from '../../components/help_center_content'; -import { Header } from '../../components/header'; +import { useReadOnlyBadge } from '../../hooks/use_readonly_badge'; import { MetricsExplorerOptionsContainer, DEFAULT_METRICS_EXPLORER_VIEW_STATE, @@ -56,6 +56,8 @@ export const InfrastructurePage = ({ match }: RouteComponentProps) => { const kibana = useKibana(); + useReadOnlyBadge(!uiCapabilities?.infrastructure?.save); + const settingsLinkProps = useLinkProps({ app: 'metrics', pathname: 'settings', @@ -111,17 +113,6 @@ export const InfrastructurePage = ({ match }: RouteComponentProps) => { </EuiFlexGroup> </HeaderMenuPortal> )} - - <Header - breadcrumbs={[ - { - text: i18n.translate('xpack.infra.header.infrastructureTitle', { - defaultMessage: 'Metrics', - }), - }, - ]} - readOnlyBadge={!uiCapabilities?.infrastructure?.save} - /> <Switch> <Route path={'/inventory'} component={SnapshotPage} /> <Route diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/index.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/index.tsx index 4fb4b4d4eb0a6..9671699dadbad 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/index.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/index.tsx @@ -8,7 +8,6 @@ import { EuiButton, EuiErrorBoundary, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { useContext } from 'react'; - import { FilterBar } from './components/filter_bar'; import { DocumentTitle } from '../../../components/document_title'; @@ -19,6 +18,7 @@ import { SourceLoadingPage } from '../../../components/source_loading_page'; import { ViewSourceConfigurationButton } from '../../../components/source_configuration/view_source_configuration_button'; import { Source } from '../../../containers/metrics_source'; import { useTrackPageview } from '../../../../../observability/public'; +import { useMetricsBreadcrumbs } from '../../../hooks/use_metrics_breadcrumbs'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { LayoutView } from './components/layout_view'; import { useLinkProps } from '../../../hooks/use_link_props'; @@ -28,10 +28,7 @@ import { useWaffleOptionsContext } from './hooks/use_waffle_options'; import { MetricsPageTemplate } from '../page_template'; import { euiStyled } from '../../../../../../../src/plugins/kibana_react/common'; import { APP_WRAPPER_CLASS } from '../../../../../../../src/core/public'; - -const inventoryTitle = i18n.translate('xpack.infra.metrics.inventoryPageTitle', { - defaultMessage: 'Inventory', -}); +import { inventoryTitle } from '../../../translations'; export const SnapshotPage = () => { const uiCapabilities = useKibana().services.application?.capabilities; @@ -52,6 +49,12 @@ export const SnapshotPage = () => { hash: '/tutorial_directory/metrics', }); + useMetricsBreadcrumbs([ + { + text: inventoryTitle, + }, + ]); + return ( <EuiErrorBoundary> <DocumentTitle diff --git a/x-pack/plugins/infra/public/pages/metrics/metric_detail/index.tsx b/x-pack/plugins/infra/public/pages/metrics/metric_detail/index.tsx index a447989530727..7ec43ef64f4a0 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metric_detail/index.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/metric_detail/index.tsx @@ -9,19 +9,19 @@ import { i18n } from '@kbn/i18n'; import React, { useContext, useState } from 'react'; import { EuiTheme, withTheme } from '../../../../../../../src/plugins/kibana_react/common'; import { DocumentTitle } from '../../../components/document_title'; -import { Header } from '../../../components/header'; import { withMetricPageProviders } from './page_providers'; import { useMetadata } from './hooks/use_metadata'; +import { useMetricsBreadcrumbs } from '../../../hooks/use_metrics_breadcrumbs'; import { Source } from '../../../containers/metrics_source'; import { InfraLoadingPanel } from '../../../components/loading'; import { findInventoryModel } from '../../../../common/inventory_models'; import { NavItem } from './lib/side_nav_context'; import { NodeDetailsPage } from './components/node_details_page'; -import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { InventoryItemType } from '../../../../common/inventory_models/types'; import { useMetricsTimeContext } from './hooks/use_metrics_time'; import { useLinkProps } from '../../../hooks/use_link_props'; import { MetricsPageTemplate } from '../page_template'; +import { inventoryTitle } from '../../../translations'; interface Props { theme: EuiTheme | undefined; @@ -35,7 +35,6 @@ interface Props { export const MetricDetail = withMetricPageProviders( withTheme(({ match }: Props) => { - const uiCapabilities = useKibana().services.application?.capabilities; const nodeId = match.params.node; const nodeType = match.params.type as InventoryItemType; const inventoryModel = findInventoryModel(nodeType); @@ -70,20 +69,20 @@ export const MetricDetail = withMetricPageProviders( [sideNav] ); - const metricsLinkProps = useLinkProps({ + const inventoryLinkProps = useLinkProps({ app: 'metrics', - pathname: '/', + pathname: '/inventory', }); - const breadcrumbs = [ + useMetricsBreadcrumbs([ { - ...metricsLinkProps, - text: i18n.translate('xpack.infra.header.infrastructureTitle', { - defaultMessage: 'Metrics', - }), + ...inventoryLinkProps, + text: inventoryTitle, }, - { text: name }, - ]; + { + text: name, + }, + ]); if (metadataLoading && !filteredRequiredMetrics.length) { return ( @@ -101,7 +100,6 @@ export const MetricDetail = withMetricPageProviders( return ( <> - <Header breadcrumbs={breadcrumbs} readOnlyBadge={!uiCapabilities?.infrastructure?.save} /> <DocumentTitle title={i18n.translate('xpack.infra.metricDetailPage.documentTitle', { defaultMessage: 'Infrastructure | Metrics | {name}', diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/index.tsx b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/index.tsx index 1ecadcac4e287..28e56c8337bf8 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/index.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/index.tsx @@ -11,6 +11,8 @@ import React, { useEffect } from 'react'; import { IIndexPattern } from 'src/plugins/data/public'; import { MetricsSourceConfigurationProperties } from '../../../../common/metrics_sources'; import { useTrackPageview } from '../../../../../observability/public'; +import { useMetricsBreadcrumbs } from '../../../hooks/use_metrics_breadcrumbs'; + import { DocumentTitle } from '../../../components/document_title'; import { NoData } from '../../../components/empty_states'; import { MetricsExplorerCharts } from './components/charts'; @@ -18,16 +20,13 @@ import { MetricsExplorerToolbar } from './components/toolbar'; import { useMetricsExplorerState } from './hooks/use_metric_explorer_state'; import { useSavedViewContext } from '../../../containers/saved_view/saved_view'; import { MetricsPageTemplate } from '../page_template'; +import { metricsExplorerTitle } from '../../../translations'; interface MetricsExplorerPageProps { source: MetricsSourceConfigurationProperties; derivedIndexPattern: IIndexPattern; } -const metricsExplorerTitle = i18n.translate('xpack.infra.metrics.metricsExplorerTitle', { - defaultMessage: 'Metrics Explorer', -}); - export const MetricsExplorerPage = ({ source, derivedIndexPattern }: MetricsExplorerPageProps) => { const { loading, @@ -66,6 +65,12 @@ export const MetricsExplorerPage = ({ source, derivedIndexPattern }: MetricsExpl /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [loadData, shouldLoadDefault]); + useMetricsBreadcrumbs([ + { + text: metricsExplorerTitle, + }, + ]); + return ( <EuiErrorBoundary> <DocumentTitle diff --git a/x-pack/plugins/infra/public/pages/metrics/settings/source_configuration_settings.tsx b/x-pack/plugins/infra/public/pages/metrics/settings/source_configuration_settings.tsx index 1066dddad6b5f..7224da4429a36 100644 --- a/x-pack/plugins/infra/public/pages/metrics/settings/source_configuration_settings.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/settings/source_configuration_settings.tsx @@ -25,18 +25,23 @@ import { IndicesConfigurationPanel } from './indices_configuration_panel'; import { MLConfigurationPanel } from './ml_configuration_panel'; import { NameConfigurationPanel } from './name_configuration_panel'; import { useSourceConfigurationFormState } from './source_configuration_form_state'; +import { useMetricsBreadcrumbs } from '../../../hooks/use_metrics_breadcrumbs'; +import { settingsTitle } from '../../../translations'; + import { MetricsPageTemplate } from '../page_template'; interface SourceConfigurationSettingsProps { shouldAllowEdit: boolean; } -const settingsTitle = i18n.translate('xpack.infra.metrics.settingsTitle', { - defaultMessage: 'Settings', -}); - export const SourceConfigurationSettings = ({ shouldAllowEdit, }: SourceConfigurationSettingsProps) => { + useMetricsBreadcrumbs([ + { + text: settingsTitle, + }, + ]); + const { createSourceConfiguration, source, diff --git a/x-pack/plugins/infra/public/translations.ts b/x-pack/plugins/infra/public/translations.ts new file mode 100644 index 0000000000000..4a9b19fde6ef2 --- /dev/null +++ b/x-pack/plugins/infra/public/translations.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const observabilityTitle = i18n.translate('xpack.infra.header.observabilityTitle', { + defaultMessage: 'Observability', +}); + +export const logsTitle = i18n.translate('xpack.infra.header.logsTitle', { + defaultMessage: 'Logs', +}); + +export const streamTitle = i18n.translate('xpack.infra.logs.index.streamTabTitle', { + defaultMessage: 'Stream', +}); + +export const anomaliesTitle = i18n.translate('xpack.infra.logs.index.anomaliesTabTitle', { + defaultMessage: 'Anomalies', +}); + +export const logCategoriesTitle = i18n.translate( + 'xpack.infra.logs.index.logCategoriesBetaBadgeTitle', + { + defaultMessage: 'Categories', + } +); + +export const settingsTitle = i18n.translate('xpack.infra.logs.index.settingsTabTitle', { + defaultMessage: 'Settings', +}); + +export const metricsTitle = i18n.translate('xpack.infra.header.infrastructureTitle', { + defaultMessage: 'Metrics', +}); + +export const inventoryTitle = i18n.translate('xpack.infra.metrics.inventoryPageTitle', { + defaultMessage: 'Inventory', +}); + +export const metricsExplorerTitle = i18n.translate('xpack.infra.metrics.metricsExplorerTitle', { + defaultMessage: 'Metrics Explorer', +}); From b352976b3bac1bb3e5f59514281624b8d7d940eb Mon Sep 17 00:00:00 2001 From: Yaroslav Kuznietsov <kuznetsov.yaroslav.yk@gmail.com> Date: Thu, 1 Jul 2021 13:30:00 +0300 Subject: [PATCH 33/51] [Canvas] Expression reveal image. (#101987) * expression_reveal_image skeleton. * expression_functions added. * expression_renderers added. * Backup of daily work. * Fixed errors. * Added legacy support. Added button for legacy. * Added storybook. * Removed revealImage from canvas. * setState while rendering error fixed. * tsconfig.json added. * jest.config.js added. * Demo doc added. * Types fixed. * added limits. * Removed not used imports. * i18n namespaces fixed. * Fixed test suite error. * Some errors fixed. * Fixed eslint error. * Removed more unused translations. * Moved UI and elements, related to expressionRevealImage from canvas. * Fixed unused translations errors. * Moved type of element to types. * Fixed types and added service for representing elements, ui and supported renderers to canvas. * Added expression registration to canvas. * Fixed * Fixed mutiple call of the function. * Removed support of a legacy lib for revealImage chart. * Removed legacy presentation_utils plugin import. * Doc error fixed. * Removed useless translations and tried to fix error. * One more fix. * Small imports fix. * Fixed translations. * Made fixes based on nits. * Removed useless params. * fix. * Fixed errors, related to jest and __mocks__. * Removed useless type definition. * Replaced RendererHandlers with IInterpreterRendererHandlers. * fixed supported_shareable. * Moved elements back to canvas. * Moved views to canvas, removed expression service and imported renderer to canvas. * Fixed translations. * Types fix. * Moved libs to presentation utils. * Fixed one mistake. * removed dataurl lib. * Fixed jest files. * elasticLogo removed. * Removed elastic_outline. * removed httpurl. * Removed missing_asset. * removed url. * replaced mostly all tests. * Fixed types. * Fixed types and removed function_wrapper.ts * Fixed types of test helpers. * Changed limits of presentationUtil plugin. * Fixed imports. * One more fix. * Fixed huge size of bundle. * Reduced allow limit for presentationUtil * Updated limits for presentationUtil. * Fixed public API. * fixed type errors. * Moved css to component. * Fixed spaces at element. * Changed order of requiredPlugins. * Updated limits. * Removed unused plugin. * Added rule for allowing import from __stories__ directory. * removed useless comment. * Changed readme.md * Fixed docs error. * A possible of smoke test. * onResize changed to useResizeObserver. * Remove useless events and `useEffect` block. * Changed from passing handlers to separate functions. * `function` moved to `server`. * Fixed eslint error. Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .eslintrc.js | 1 + .i18nrc.json | 1 + docs/developer/plugin-list.asciidoc | 4 + packages/kbn-optimizer/limits.yml | 3 +- src/dev/storybook/aliases.ts | 1 + .../expression_reveal_image/.i18nrc.json | 7 + .../.storybook/main.js | 10 ++ src/plugins/expression_reveal_image/README.md | 9 + .../common/constants.ts | 9 + .../common/expression_functions/index.ts | 13 ++ .../expression_functions/reveal_image.test.ts | 168 ++++++++++++++++++ .../reveal_image_function.ts | 41 +---- .../common/i18n/constants.ts | 10 ++ .../dict/reveal_image.ts | 39 ++-- .../expression_functions/function_errors.ts | 13 ++ .../expression_functions/function_help.ts | 21 +++ .../common/i18n/expression_functions/index.ts | 10 ++ .../i18n/expression_renderers/dict/index.ts | 9 + .../expression_renderers/dict/reveal_image.ts | 19 ++ .../common/i18n/expression_renderers/index.ts | 9 + .../expression_renderers/renderer_strings.ts | 21 +++ .../common/i18n/index.ts | 10 ++ .../expression_reveal_image/common/index.ts | 10 ++ .../common/types/expression_functions.ts | 42 +++++ .../common/types/expression_renderers.ts | 21 +++ .../common/types/index.ts | 9 + .../expression_reveal_image/jest.config.js | 13 ++ .../expression_reveal_image/kibana.json | 10 ++ .../public/components/index.ts | 9 + .../public/components}/reveal_image.scss | 0 .../components/reveal_image_component.tsx | 136 ++++++++++++++ .../reveal_image.stories.storyshot | 0 .../reveal_image_renderer.stories.tsx | 26 +++ .../public/expression_renderers/index.ts | 13 ++ .../reveal_image_renderer.tsx | 42 +++++ .../expression_reveal_image/public/index.ts | 17 ++ .../expression_reveal_image/public/plugin.ts | 39 ++++ .../expression_reveal_image/server/index.ts | 15 ++ .../expression_reveal_image/server/plugin.ts | 39 ++++ .../expression_reveal_image/tsconfig.json | 21 +++ .../presentation_util/common/lib/index.ts | 10 ++ .../lib/test_helpers/function_wrapper.ts | 27 +++ .../common/lib/test_helpers/index.ts | 9 + .../common/lib/utils}/dataurl.test.ts | 5 +- .../common/lib/utils}/dataurl.ts | 5 +- .../common/lib/utils}/elastic_logo.ts | 5 +- .../common/lib/utils/elastic_outline.ts | 10 ++ .../common/lib/utils}/httpurl.test.ts | 5 +- .../common/lib/utils}/httpurl.ts | 5 +- .../common/lib/utils/index.ts | 15 ++ .../common/lib/utils/missing_asset.ts | 11 ++ .../common/lib/utils/resolve_dataurl.test.ts | 7 +- .../common/lib/utils}/resolve_dataurl.ts | 9 +- .../common/lib/utils}/url.test.ts | 7 +- .../presentation_util/common/lib/utils/url.ts | 14 ++ src/plugins/presentation_util/jest.config.js | 13 ++ src/plugins/presentation_util/kibana.json | 3 + .../public/__stories__/index.tsx | 9 + .../public/__stories__/render.tsx | 61 +++++++ src/plugins/presentation_util/public/index.ts | 1 + src/plugins/presentation_util/tsconfig.json | 3 + .../public/finder/saved_object_finder.tsx | 4 +- .../saved_object/helpers/apply_es_resp.ts | 10 +- .../saved_object/helpers/create_source.ts | 4 +- .../helpers/initialize_saved_object.ts | 6 +- .../helpers/serialize_saved_object.ts | 4 +- .../functions/browser/markdown.test.js | 2 +- .../common/__fixtures__/test_styles.js | 2 +- .../functions/common/all.test.js | 2 +- .../functions/common/alterColumn.test.js | 2 +- .../functions/common/any.test.js | 3 +- .../functions/common/as.test.js | 2 +- .../functions/common/axis_config.test.js | 2 +- .../functions/common/case.test.js | 2 +- .../functions/common/clear.test.js | 3 +- .../functions/common/columns.test.js | 2 +- .../functions/common/compare.test.js | 2 +- .../functions/common/containerStyle.ts | 2 +- .../functions/common/container_style.test.js | 6 +- .../functions/common/context.test.js | 2 +- .../functions/common/csv.test.ts | 82 ++++++--- .../functions/common/date.test.js | 2 +- .../functions/common/do.test.js | 2 +- .../functions/common/dropdown_control.test.ts | 76 +++++--- .../functions/common/eq.test.js | 2 +- .../functions/common/exactly.test.js | 2 +- .../functions/common/filterrows.test.js | 2 +- .../functions/common/formatdate.test.js | 2 +- .../functions/common/formatnumber.test.js | 2 +- .../functions/common/getCell.test.js | 2 +- .../functions/common/gt.test.js | 2 +- .../functions/common/gte.test.js | 2 +- .../functions/common/head.test.js | 2 +- .../functions/common/if.test.js | 2 +- .../functions/common/image.test.js | 8 +- .../functions/common/image.ts | 7 +- .../functions/common/index.ts | 2 - .../functions/common/join_rows.test.js | 2 +- .../functions/common/lt.test.js | 2 +- .../functions/common/lte.test.js | 2 +- .../functions/common/metric.test.js | 2 +- .../functions/common/neq.test.js | 2 +- .../functions/common/ply.test.js | 2 +- .../functions/common/progress.test.js | 2 +- .../functions/common/render.test.js | 2 +- .../functions/common/render.ts | 1 - .../functions/common/repeat_image.test.js | 8 +- .../functions/common/repeat_image.ts | 6 +- .../functions/common/replace.test.js | 2 +- .../functions/common/reveal_image.test.js | 88 --------- .../functions/common/rounddate.test.js | 2 +- .../functions/common/rowCount.test.js | 2 +- .../functions/common/series_style.test.js | 2 +- .../functions/common/sort.test.js | 2 +- .../functions/common/staticColumn.test.js | 2 +- .../functions/common/string.test.js | 2 +- .../functions/common/switch.test.js | 2 +- .../functions/common/table.test.js | 2 +- .../functions/common/tail.test.js | 2 +- .../functions/common/timefilter.test.js | 2 +- .../common/timefilter_control.test.js | 2 +- .../canvas_plugin_src/lib/elastic_logo.ts | 2 - .../canvas_plugin_src/lib/elastic_outline.ts | 2 - .../renderers/__stories__/image.stories.tsx | 2 +- .../__stories__/repeat_image.stories.tsx | 6 +- .../canvas_plugin_src/renderers/core.ts | 2 - .../renderers/external.ts} | 8 +- .../canvas_plugin_src/renderers/image.tsx | 3 +- .../canvas_plugin_src/renderers/index.ts | 14 +- .../renderers/repeat_image.ts | 6 +- .../__stories__/reveal_image.stories.tsx | 25 --- .../renderers/reveal_image/index.ts | 88 --------- .../uis/arguments/image_upload/index.js | 10 +- .../canvas_plugin_src/uis/views/image.js | 6 +- .../uis/views/revealImage.js | 1 - x-pack/plugins/canvas/common/lib/index.ts | 5 - .../canvas/common/lib/missing_asset.ts | 3 - .../canvas/i18n/functions/function_errors.ts | 2 - .../canvas/i18n/functions/function_help.ts | 2 - x-pack/plugins/canvas/i18n/renderers.ts | 10 -- x-pack/plugins/canvas/kibana.json | 1 + .../components/asset_manager/asset_manager.ts | 2 +- .../custom_element_modal.stories.tsx | 2 +- .../custom_element_modal.tsx | 2 +- .../public/components/download/download.tsx | 2 +- .../__stories__/element_card.stories.tsx | 2 +- .../generate_function_reference.ts | 3 +- .../__stories__/fixtures/test_elements.tsx | 2 +- .../__stories__/element_menu.stories.tsx | 11 -- .../canvas/public/functions/pie.test.js | 2 +- .../canvas/public/functions/plot.test.js | 2 +- .../canvas/public/lib/elastic_outline.js | 2 - x-pack/plugins/canvas/public/style/index.scss | 3 - .../shareable_runtime/supported_renderers.js | 2 +- .../canvas/test_helpers/function_wrapper.js | 19 -- x-pack/plugins/canvas/tsconfig.json | 1 + x-pack/plugins/canvas/types/renderers.ts | 10 +- .../translations/translations/ja-JP.json | 14 +- .../translations/translations/zh-CN.json | 16 +- 159 files changed, 1311 insertions(+), 512 deletions(-) create mode 100755 src/plugins/expression_reveal_image/.i18nrc.json create mode 100644 src/plugins/expression_reveal_image/.storybook/main.js create mode 100755 src/plugins/expression_reveal_image/README.md create mode 100644 src/plugins/expression_reveal_image/common/constants.ts create mode 100644 src/plugins/expression_reveal_image/common/expression_functions/index.ts create mode 100644 src/plugins/expression_reveal_image/common/expression_functions/reveal_image.test.ts rename x-pack/plugins/canvas/canvas_plugin_src/functions/common/revealImage.ts => src/plugins/expression_reveal_image/common/expression_functions/reveal_image_function.ts (59%) create mode 100644 src/plugins/expression_reveal_image/common/i18n/constants.ts rename {x-pack/plugins/canvas/i18n/functions => src/plugins/expression_reveal_image/common/i18n/expression_functions}/dict/reveal_image.ts (52%) create mode 100644 src/plugins/expression_reveal_image/common/i18n/expression_functions/function_errors.ts create mode 100644 src/plugins/expression_reveal_image/common/i18n/expression_functions/function_help.ts create mode 100644 src/plugins/expression_reveal_image/common/i18n/expression_functions/index.ts create mode 100644 src/plugins/expression_reveal_image/common/i18n/expression_renderers/dict/index.ts create mode 100644 src/plugins/expression_reveal_image/common/i18n/expression_renderers/dict/reveal_image.ts create mode 100644 src/plugins/expression_reveal_image/common/i18n/expression_renderers/index.ts create mode 100644 src/plugins/expression_reveal_image/common/i18n/expression_renderers/renderer_strings.ts create mode 100644 src/plugins/expression_reveal_image/common/i18n/index.ts create mode 100755 src/plugins/expression_reveal_image/common/index.ts create mode 100644 src/plugins/expression_reveal_image/common/types/expression_functions.ts create mode 100644 src/plugins/expression_reveal_image/common/types/expression_renderers.ts create mode 100644 src/plugins/expression_reveal_image/common/types/index.ts create mode 100644 src/plugins/expression_reveal_image/jest.config.js create mode 100755 src/plugins/expression_reveal_image/kibana.json create mode 100644 src/plugins/expression_reveal_image/public/components/index.ts rename {x-pack/plugins/canvas/canvas_plugin_src/renderers/reveal_image => src/plugins/expression_reveal_image/public/components}/reveal_image.scss (100%) create mode 100644 src/plugins/expression_reveal_image/public/components/reveal_image_component.tsx rename {x-pack/plugins/canvas/canvas_plugin_src/renderers/reveal_image => src/plugins/expression_reveal_image/public/expression_renderers}/__stories__/__snapshots__/reveal_image.stories.storyshot (100%) create mode 100644 src/plugins/expression_reveal_image/public/expression_renderers/__stories__/reveal_image_renderer.stories.tsx create mode 100644 src/plugins/expression_reveal_image/public/expression_renderers/index.ts create mode 100644 src/plugins/expression_reveal_image/public/expression_renderers/reveal_image_renderer.tsx create mode 100755 src/plugins/expression_reveal_image/public/index.ts create mode 100755 src/plugins/expression_reveal_image/public/plugin.ts create mode 100644 src/plugins/expression_reveal_image/server/index.ts create mode 100644 src/plugins/expression_reveal_image/server/plugin.ts create mode 100644 src/plugins/expression_reveal_image/tsconfig.json create mode 100644 src/plugins/presentation_util/common/lib/index.ts create mode 100644 src/plugins/presentation_util/common/lib/test_helpers/function_wrapper.ts create mode 100644 src/plugins/presentation_util/common/lib/test_helpers/index.ts rename {x-pack/plugins/canvas/common/lib => src/plugins/presentation_util/common/lib/utils}/dataurl.test.ts (94%) rename {x-pack/plugins/canvas/common/lib => src/plugins/presentation_util/common/lib/utils}/dataurl.ts (90%) rename {x-pack/plugins/canvas/public/lib => src/plugins/presentation_util/common/lib/utils}/elastic_logo.ts (96%) create mode 100644 src/plugins/presentation_util/common/lib/utils/elastic_outline.ts rename {x-pack/plugins/canvas/common/lib => src/plugins/presentation_util/common/lib/utils}/httpurl.test.ts (89%) rename {x-pack/plugins/canvas/common/lib => src/plugins/presentation_util/common/lib/utils}/httpurl.ts (67%) create mode 100644 src/plugins/presentation_util/common/lib/utils/index.ts create mode 100644 src/plugins/presentation_util/common/lib/utils/missing_asset.ts rename x-pack/plugins/canvas/common/lib/resolve_dataurl.test.js => src/plugins/presentation_util/common/lib/utils/resolve_dataurl.test.ts (84%) rename {x-pack/plugins/canvas/common/lib => src/plugins/presentation_util/common/lib/utils}/resolve_dataurl.ts (75%) rename {x-pack/plugins/canvas/common/lib => src/plugins/presentation_util/common/lib/utils}/url.test.ts (70%) create mode 100644 src/plugins/presentation_util/common/lib/utils/url.ts create mode 100644 src/plugins/presentation_util/jest.config.js create mode 100644 src/plugins/presentation_util/public/__stories__/index.tsx create mode 100644 src/plugins/presentation_util/public/__stories__/render.tsx delete mode 100644 x-pack/plugins/canvas/canvas_plugin_src/functions/common/reveal_image.test.js delete mode 100644 x-pack/plugins/canvas/canvas_plugin_src/lib/elastic_logo.ts delete mode 100644 x-pack/plugins/canvas/canvas_plugin_src/lib/elastic_outline.ts rename x-pack/plugins/canvas/{common/lib/url.ts => canvas_plugin_src/renderers/external.ts} (54%) delete mode 100644 x-pack/plugins/canvas/canvas_plugin_src/renderers/reveal_image/__stories__/reveal_image.stories.tsx delete mode 100644 x-pack/plugins/canvas/canvas_plugin_src/renderers/reveal_image/index.ts delete mode 100644 x-pack/plugins/canvas/common/lib/missing_asset.ts delete mode 100644 x-pack/plugins/canvas/public/lib/elastic_outline.js delete mode 100644 x-pack/plugins/canvas/test_helpers/function_wrapper.js diff --git a/.eslintrc.js b/.eslintrc.js index 2eea41984b30e..09de32a91bca3 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -445,6 +445,7 @@ module.exports = { '(src|x-pack)/plugins/**/(public|server)/**/*', '!(src|x-pack)/plugins/**/(public|server)/mocks/index.{js,mjs,ts}', '!(src|x-pack)/plugins/**/(public|server)/(index|mocks).{js,mjs,ts,tsx}', + '!(src|x-pack)/plugins/**/__stories__/index.{js,mjs,ts,tsx}', ], allowSameFolder: true, errorMessage: 'Plugins may only import from top-level public and server modules.', diff --git a/.i18nrc.json b/.i18nrc.json index 0926f73722731..390e5e917d08e 100644 --- a/.i18nrc.json +++ b/.i18nrc.json @@ -16,6 +16,7 @@ "esUi": "src/plugins/es_ui_shared", "devTools": "src/plugins/dev_tools", "expressions": "src/plugins/expressions", + "expressionRevealImage": "src/plugins/expression_reveal_image", "inputControl": "src/plugins/input_control_vis", "inspector": "src/plugins/inspector", "inspectorViews": "src/legacy/core_plugins/inspector_views", diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index 231e089950a28..b4be27eee5ed2 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -72,6 +72,10 @@ This API doesn't support angular, for registering angular dev tools, bootstrap a |This plugin contains reusable code in the form of self-contained modules (or libraries). Each of these modules exports a set of functionality relevant to the domain of the module. +|{kib-repo}blob/{branch}/src/plugins/expression_reveal_image/README.md[expressionRevealImage] +|Expression Reveal Image plugin adds a revealImage function to the expression plugin and an associated renderer. The renderer will display the given percentage of a given image. + + |<<kibana-expressions-plugin>> |Expression pipeline is a chain of functions that *pipe* its output to the input of the next function. Functions can be configured using arguments provided diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index c6960621359c7..6627b644daec7 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -99,7 +99,7 @@ pageLoadAssetSize: watcher: 43598 runtimeFields: 41752 stackAlerts: 29684 - presentationUtil: 49767 + presentationUtil: 94301 spacesOss: 18817 indexPatternFieldEditor: 90489 osquery: 107090 @@ -110,4 +110,5 @@ pageLoadAssetSize: timelines: 230410 screenshotMode: 17856 visTypePie: 35583 + expressionRevealImage: 25675 cases: 144442 diff --git a/src/dev/storybook/aliases.ts b/src/dev/storybook/aliases.ts index e0f0432c61463..6fc0841551fad 100644 --- a/src/dev/storybook/aliases.ts +++ b/src/dev/storybook/aliases.ts @@ -17,6 +17,7 @@ export const storybookAliases = { dashboard_enhanced: 'x-pack/plugins/dashboard_enhanced/.storybook', data_enhanced: 'x-pack/plugins/data_enhanced/.storybook', embeddable: 'src/plugins/embeddable/.storybook', + expression_reveal_image: 'src/plugins/expression_reveal_image/.storybook', infra: 'x-pack/plugins/infra/.storybook', security_solution: 'x-pack/plugins/security_solution/.storybook', ui_actions_enhanced: 'x-pack/plugins/ui_actions_enhanced/.storybook', diff --git a/src/plugins/expression_reveal_image/.i18nrc.json b/src/plugins/expression_reveal_image/.i18nrc.json new file mode 100755 index 0000000000000..5b073e4374519 --- /dev/null +++ b/src/plugins/expression_reveal_image/.i18nrc.json @@ -0,0 +1,7 @@ +{ + "prefix": "expressionRevealImage", + "paths": { + "expressionRevealImage": "." + }, + "translations": ["translations/ja-JP.json"] +} diff --git a/src/plugins/expression_reveal_image/.storybook/main.js b/src/plugins/expression_reveal_image/.storybook/main.js new file mode 100644 index 0000000000000..742239e638b8a --- /dev/null +++ b/src/plugins/expression_reveal_image/.storybook/main.js @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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. + */ + +// eslint-disable-next-line import/no-commonjs +module.exports = require('@kbn/storybook').defaultConfig; diff --git a/src/plugins/expression_reveal_image/README.md b/src/plugins/expression_reveal_image/README.md new file mode 100755 index 0000000000000..21c27a6eee05b --- /dev/null +++ b/src/plugins/expression_reveal_image/README.md @@ -0,0 +1,9 @@ +# expressionRevealImage + +Expression Reveal Image plugin adds a `revealImage` function to the expression plugin and an associated renderer. The renderer will display the given percentage of a given image. + +--- + +## Development + +See the [kibana contributing guide](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md) for instructions setting up your development environment. diff --git a/src/plugins/expression_reveal_image/common/constants.ts b/src/plugins/expression_reveal_image/common/constants.ts new file mode 100644 index 0000000000000..68ac53171ee7f --- /dev/null +++ b/src/plugins/expression_reveal_image/common/constants.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 const PLUGIN_ID = 'expressionRevealImage'; +export const PLUGIN_NAME = 'expressionRevealImage'; diff --git a/src/plugins/expression_reveal_image/common/expression_functions/index.ts b/src/plugins/expression_reveal_image/common/expression_functions/index.ts new file mode 100644 index 0000000000000..dba24e8a0cb0a --- /dev/null +++ b/src/plugins/expression_reveal_image/common/expression_functions/index.ts @@ -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. + */ + +import { revealImageFunction } from './reveal_image_function'; + +export const functions = [revealImageFunction]; + +export { revealImageFunction }; diff --git a/src/plugins/expression_reveal_image/common/expression_functions/reveal_image.test.ts b/src/plugins/expression_reveal_image/common/expression_functions/reveal_image.test.ts new file mode 100644 index 0000000000000..633a132fea5e3 --- /dev/null +++ b/src/plugins/expression_reveal_image/common/expression_functions/reveal_image.test.ts @@ -0,0 +1,168 @@ +/* + * 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, + elasticOutline, + elasticLogo, +} from '../../../presentation_util/common/lib'; +import { getFunctionErrors } from '../i18n'; +import { revealImageFunction } from './reveal_image_function'; +import { Origin } from '../types'; +import { ExecutionContext } from 'src/plugins/expressions'; + +const errors = getFunctionErrors().revealImage; + +describe('revealImageFunction', () => { + const fn = functionWrapper(revealImageFunction); + + it('returns a render as revealImage', () => { + const result = fn( + 0.5, + { + image: null, + emptyImage: null, + origin: Origin.BOTTOM, + }, + {} as ExecutionContext + ); + expect(result).toHaveProperty('type', 'render'); + expect(result).toHaveProperty('as', 'revealImage'); + }); + + describe('context', () => { + it('throws when context is not a number between 0 and 1', () => { + expect(() => { + fn( + 10, + { + image: elasticLogo, + emptyImage: elasticOutline, + origin: Origin.TOP, + }, + {} as ExecutionContext + ); + }).toThrow(new RegExp(errors.invalidPercent(10).message)); + + expect(() => { + fn( + -0.1, + { + image: elasticLogo, + emptyImage: elasticOutline, + origin: Origin.TOP, + }, + {} as ExecutionContext + ); + }).toThrow(new RegExp(errors.invalidPercent(-0.1).message)); + }); + }); + + describe('args', () => { + describe('image', () => { + it('sets the image', () => { + const result = fn( + 0.89, + { + emptyImage: null, + origin: Origin.TOP, + image: elasticLogo, + }, + {} as ExecutionContext + ).value; + expect(result).toHaveProperty('image', elasticLogo); + }); + + it('defaults to the Elastic outline logo', () => { + const result = fn( + 0.89, + { + emptyImage: null, + origin: Origin.TOP, + image: null, + }, + {} as ExecutionContext + ).value; + expect(result).toHaveProperty('image', elasticOutline); + }); + }); + + describe('emptyImage', () => { + it('sets the background image', () => { + const result = fn( + 0, + { + emptyImage: elasticLogo, + origin: Origin.TOP, + image: null, + }, + {} as ExecutionContext + ).value; + expect(result).toHaveProperty('emptyImage', elasticLogo); + }); + + it('sets emptyImage to null', () => { + const result = fn( + 0, + { + emptyImage: null, + origin: Origin.TOP, + image: null, + }, + {} as ExecutionContext + ).value; + expect(result).toHaveProperty('emptyImage', null); + }); + }); + + describe('origin', () => { + it('sets which side to start the reveal from', () => { + let result = fn( + 1, + { + emptyImage: null, + origin: Origin.TOP, + image: null, + }, + {} as ExecutionContext + ).value; + expect(result).toHaveProperty('origin', 'top'); + result = fn( + 1, + { + emptyImage: null, + origin: Origin.LEFT, + image: null, + }, + {} as ExecutionContext + ).value; + expect(result).toHaveProperty('origin', 'left'); + result = fn( + 1, + { + emptyImage: null, + origin: Origin.BOTTOM, + image: null, + }, + {} as ExecutionContext + ).value; + expect(result).toHaveProperty('origin', 'bottom'); + result = fn( + 1, + { + emptyImage: null, + origin: Origin.RIGHT, + image: null, + }, + {} as ExecutionContext + ).value; + expect(result).toHaveProperty('origin', 'right'); + }); + }); + }); +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/revealImage.ts b/src/plugins/expression_reveal_image/common/expression_functions/reveal_image_function.ts similarity index 59% rename from x-pack/plugins/canvas/canvas_plugin_src/functions/common/revealImage.ts rename to src/plugins/expression_reveal_image/common/expression_functions/reveal_image_function.ts index 91d70609ab708..33e61e85f9531 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/revealImage.ts +++ b/src/plugins/expression_reveal_image/common/expression_functions/reveal_image_function.ts @@ -1,41 +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; you may not use this file except in compliance with the Elastic License - * 2.0. + * 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 { ExpressionFunctionDefinition, ExpressionValueRender } from 'src/plugins/expressions'; -import { resolveWithMissingImage } from '../../../common/lib/resolve_dataurl'; -import { elasticOutline } from '../../lib/elastic_outline'; -import { getFunctionHelp, getFunctionErrors } from '../../../i18n'; +import { resolveWithMissingImage, elasticOutline } from '../../../presentation_util/common/lib'; +import { getFunctionHelp, getFunctionErrors } from '../i18n'; +import { ExpressionRevealImageFunction, Origin } from '../types'; -export enum Origin { - TOP = 'top', - LEFT = 'left', - BOTTOM = 'bottom', - RIGHT = 'right', -} - -interface Arguments { - image: string | null; - emptyImage: string | null; - origin: Origin; -} - -export interface Output { - image: string; - emptyImage: string; - origin: Origin; - percent: number; -} - -export function revealImage(): ExpressionFunctionDefinition< - 'revealImage', - number, - Arguments, - ExpressionValueRender<Output> -> { +export const revealImageFunction: ExpressionRevealImageFunction = () => { const { help, args: argHelp } = getFunctionHelp().revealImage; const errors = getFunctionErrors().revealImage; @@ -80,4 +55,4 @@ export function revealImage(): ExpressionFunctionDefinition< }; }, }; -} +}; diff --git a/src/plugins/expression_reveal_image/common/i18n/constants.ts b/src/plugins/expression_reveal_image/common/i18n/constants.ts new file mode 100644 index 0000000000000..413f376515a33 --- /dev/null +++ b/src/plugins/expression_reveal_image/common/i18n/constants.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 BASE64 = '`base64`'; +export const URL = 'URL'; diff --git a/x-pack/plugins/canvas/i18n/functions/dict/reveal_image.ts b/src/plugins/expression_reveal_image/common/i18n/expression_functions/dict/reveal_image.ts similarity index 52% rename from x-pack/plugins/canvas/i18n/functions/dict/reveal_image.ts rename to src/plugins/expression_reveal_image/common/i18n/expression_functions/dict/reveal_image.ts index 374334824d61a..ccf9967bd6a65 100644 --- a/x-pack/plugins/canvas/i18n/functions/dict/reveal_image.ts +++ b/src/plugins/expression_reveal_image/common/i18n/expression_functions/dict/reveal_image.ts @@ -1,23 +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. + * 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 { revealImage } from '../../../canvas_plugin_src/functions/common/revealImage'; -import { FunctionHelp } from '../function_help'; -import { FunctionFactory } from '../../../types'; import { Position } from '../../../types'; import { BASE64, URL } from '../../constants'; -export const help: FunctionHelp<FunctionFactory<typeof revealImage>> = { - help: i18n.translate('xpack.canvas.functions.revealImageHelpText', { +export const help = { + help: i18n.translate('expressionRevealImage.functions.revealImageHelpText', { defaultMessage: 'Configures an image reveal element.', }), args: { - image: i18n.translate('xpack.canvas.functions.revealImage.args.imageHelpText', { + image: i18n.translate('expressionRevealImage.functions.revealImage.args.imageHelpText', { defaultMessage: 'The image to reveal. Provide an image asset as a {BASE64} data {URL}, ' + 'or pass in a sub-expression.', @@ -26,16 +24,19 @@ export const help: FunctionHelp<FunctionFactory<typeof revealImage>> = { URL, }, }), - emptyImage: i18n.translate('xpack.canvas.functions.revealImage.args.emptyImageHelpText', { - defaultMessage: - 'An optional background image to reveal over. ' + - 'Provide an image asset as a `{BASE64}` data {URL}, or pass in a sub-expression.', - values: { - BASE64, - URL, - }, - }), - origin: i18n.translate('xpack.canvas.functions.revealImage.args.originHelpText', { + emptyImage: i18n.translate( + 'expressionRevealImage.functions.revealImage.args.emptyImageHelpText', + { + defaultMessage: + 'An optional background image to reveal over. ' + + 'Provide an image asset as a `{BASE64}` data {URL}, or pass in a sub-expression.', + values: { + BASE64, + URL, + }, + } + ), + origin: i18n.translate('expressionRevealImage.functions.revealImage.args.originHelpText', { defaultMessage: 'The position to start the image fill. For example, {list}, or {end}.', values: { list: Object.values(Position) @@ -50,7 +51,7 @@ export const help: FunctionHelp<FunctionFactory<typeof revealImage>> = { export const errors = { invalidPercent: (percent: number) => new Error( - i18n.translate('xpack.canvas.functions.revealImage.invalidPercentErrorMessage', { + i18n.translate('expressionRevealImage.functions.revealImage.invalidPercentErrorMessage', { defaultMessage: "Invalid value: '{percent}'. Percentage must be between 0 and 1", values: { percent, diff --git a/src/plugins/expression_reveal_image/common/i18n/expression_functions/function_errors.ts b/src/plugins/expression_reveal_image/common/i18n/expression_functions/function_errors.ts new file mode 100644 index 0000000000000..09cd26c9e620b --- /dev/null +++ b/src/plugins/expression_reveal_image/common/i18n/expression_functions/function_errors.ts @@ -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. + */ + +import { errors as revealImage } from './dict/reveal_image'; + +export const getFunctionErrors = () => ({ + revealImage, +}); diff --git a/src/plugins/expression_reveal_image/common/i18n/expression_functions/function_help.ts b/src/plugins/expression_reveal_image/common/i18n/expression_functions/function_help.ts new file mode 100644 index 0000000000000..30e79b120771b --- /dev/null +++ b/src/plugins/expression_reveal_image/common/i18n/expression_functions/function_help.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 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 { help as revealImage } from './dict/reveal_image'; + +/** + * Help text for Canvas Functions should be properly localized. This function will + * return a dictionary of help strings, organized by `ExpressionFunctionDefinition` + * specification and then by available arguments within each `ExpressionFunctionDefinition`. + * + * This a function, rather than an object, to future-proof string initialization, + * if ever necessary. + */ +export const getFunctionHelp = () => ({ + revealImage, +}); diff --git a/src/plugins/expression_reveal_image/common/i18n/expression_functions/index.ts b/src/plugins/expression_reveal_image/common/i18n/expression_functions/index.ts new file mode 100644 index 0000000000000..3d36b123421f4 --- /dev/null +++ b/src/plugins/expression_reveal_image/common/i18n/expression_functions/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 './function_help'; +export * from './function_errors'; diff --git a/src/plugins/expression_reveal_image/common/i18n/expression_renderers/dict/index.ts b/src/plugins/expression_reveal_image/common/i18n/expression_renderers/dict/index.ts new file mode 100644 index 0000000000000..4f70f9d30b74b --- /dev/null +++ b/src/plugins/expression_reveal_image/common/i18n/expression_renderers/dict/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 { strings as revealImage } from './reveal_image'; diff --git a/src/plugins/expression_reveal_image/common/i18n/expression_renderers/dict/reveal_image.ts b/src/plugins/expression_reveal_image/common/i18n/expression_renderers/dict/reveal_image.ts new file mode 100644 index 0000000000000..a32fdbd4c0b50 --- /dev/null +++ b/src/plugins/expression_reveal_image/common/i18n/expression_renderers/dict/reveal_image.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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'; + +export const strings = { + getDisplayName: () => + i18n.translate('expressionRevealImage.renderer.revealImage.displayName', { + defaultMessage: 'Image reveal', + }), + getHelpDescription: () => + i18n.translate('expressionRevealImage.renderer.revealImage.helpDescription', { + defaultMessage: 'Reveal a percentage of an image to make a custom gauge-style chart', + }), +}; diff --git a/src/plugins/expression_reveal_image/common/i18n/expression_renderers/index.ts b/src/plugins/expression_reveal_image/common/i18n/expression_renderers/index.ts new file mode 100644 index 0000000000000..7e637f240d15c --- /dev/null +++ b/src/plugins/expression_reveal_image/common/i18n/expression_renderers/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 './renderer_strings'; diff --git a/src/plugins/expression_reveal_image/common/i18n/expression_renderers/renderer_strings.ts b/src/plugins/expression_reveal_image/common/i18n/expression_renderers/renderer_strings.ts new file mode 100644 index 0000000000000..b74230a2a5d76 --- /dev/null +++ b/src/plugins/expression_reveal_image/common/i18n/expression_renderers/renderer_strings.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 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 { revealImage } from './dict'; + +/** + * Help text for Canvas Functions should be properly localized. This function will + * return a dictionary of help strings, organized by `ExpressionFunctionDefinition` + * specification and then by available arguments within each `ExpressionFunctionDefinition`. + * + * This a function, rather than an object, to future-proof string initialization, + * if ever necessary. + */ +export const getRendererStrings = () => ({ + revealImage, +}); diff --git a/src/plugins/expression_reveal_image/common/i18n/index.ts b/src/plugins/expression_reveal_image/common/i18n/index.ts new file mode 100644 index 0000000000000..9c50bfab1305d --- /dev/null +++ b/src/plugins/expression_reveal_image/common/i18n/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 './expression_functions'; +export * from './expression_renderers'; diff --git a/src/plugins/expression_reveal_image/common/index.ts b/src/plugins/expression_reveal_image/common/index.ts new file mode 100755 index 0000000000000..95503b36acdb6 --- /dev/null +++ b/src/plugins/expression_reveal_image/common/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 './constants'; +export * from './expression_functions'; diff --git a/src/plugins/expression_reveal_image/common/types/expression_functions.ts b/src/plugins/expression_reveal_image/common/types/expression_functions.ts new file mode 100644 index 0000000000000..ee291e204acfb --- /dev/null +++ b/src/plugins/expression_reveal_image/common/types/expression_functions.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 { ExpressionFunctionDefinition, ExpressionValueRender } from 'src/plugins/expressions'; + +export enum Origin { + TOP = 'top', + LEFT = 'left', + BOTTOM = 'bottom', + RIGHT = 'right', +} + +interface Arguments { + image: string | null; + emptyImage: string | null; + origin: Origin; +} + +export interface Output { + image: string; + emptyImage: string; + origin: Origin; + percent: number; +} + +export type ExpressionRevealImageFunction = () => ExpressionFunctionDefinition< + 'revealImage', + number, + Arguments, + ExpressionValueRender<Output> +>; + +export enum Position { + TOP = 'top', + BOTTOM = 'bottom', + LEFT = 'left', + RIGHT = 'right', +} diff --git a/src/plugins/expression_reveal_image/common/types/expression_renderers.ts b/src/plugins/expression_reveal_image/common/types/expression_renderers.ts new file mode 100644 index 0000000000000..77dacaefc1bd1 --- /dev/null +++ b/src/plugins/expression_reveal_image/common/types/expression_renderers.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 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 type OriginString = 'bottom' | 'left' | 'top' | 'right'; + +export interface RevealImageRendererConfig { + percent: number; + origin?: OriginString; + image?: string; + emptyImage?: string; +} + +export interface NodeDimensions { + width: number; + height: number; +} diff --git a/src/plugins/expression_reveal_image/common/types/index.ts b/src/plugins/expression_reveal_image/common/types/index.ts new file mode 100644 index 0000000000000..ec934e7affe88 --- /dev/null +++ b/src/plugins/expression_reveal_image/common/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 './expression_functions'; +export * from './expression_renderers'; diff --git a/src/plugins/expression_reveal_image/jest.config.js b/src/plugins/expression_reveal_image/jest.config.js new file mode 100644 index 0000000000000..aac5fad293846 --- /dev/null +++ b/src/plugins/expression_reveal_image/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: ['<rootDir>/src/plugins/expression_reveal_image'], +}; diff --git a/src/plugins/expression_reveal_image/kibana.json b/src/plugins/expression_reveal_image/kibana.json new file mode 100755 index 0000000000000..9af9a5857dcfb --- /dev/null +++ b/src/plugins/expression_reveal_image/kibana.json @@ -0,0 +1,10 @@ +{ + "id": "expressionRevealImage", + "version": "1.0.0", + "kibanaVersion": "kibana", + "server": true, + "ui": true, + "requiredPlugins": ["expressions", "presentationUtil"], + "optionalPlugins": [], + "requiredBundles": [] +} diff --git a/src/plugins/expression_reveal_image/public/components/index.ts b/src/plugins/expression_reveal_image/public/components/index.ts new file mode 100644 index 0000000000000..23cb4d7a20cb8 --- /dev/null +++ b/src/plugins/expression_reveal_image/public/components/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 './reveal_image_component'; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/reveal_image/reveal_image.scss b/src/plugins/expression_reveal_image/public/components/reveal_image.scss similarity index 100% rename from x-pack/plugins/canvas/canvas_plugin_src/renderers/reveal_image/reveal_image.scss rename to src/plugins/expression_reveal_image/public/components/reveal_image.scss diff --git a/src/plugins/expression_reveal_image/public/components/reveal_image_component.tsx b/src/plugins/expression_reveal_image/public/components/reveal_image_component.tsx new file mode 100644 index 0000000000000..a9c24fca78d9b --- /dev/null +++ b/src/plugins/expression_reveal_image/public/components/reveal_image_component.tsx @@ -0,0 +1,136 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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, { useRef, useState, useEffect, useCallback } from 'react'; +import { useResizeObserver } from '@elastic/eui'; +import { IInterpreterRenderHandlers } from 'src/plugins/expressions'; +import { NodeDimensions, RevealImageRendererConfig, OriginString } from '../../common/types'; +import { isValidUrl, elasticOutline } from '../../../presentation_util/public'; +import './reveal_image.scss'; + +interface RevealImageComponentProps extends RevealImageRendererConfig { + onLoaded: IInterpreterRenderHandlers['done']; + parentNode: HTMLElement; +} + +interface ImageStyles { + width?: string; + height?: string; + clipPath?: string; +} + +interface AlignerStyles { + backgroundImage?: string; +} + +function RevealImageComponent({ + onLoaded, + parentNode, + percent, + origin, + image, + emptyImage, +}: RevealImageComponentProps) { + const [loaded, setLoaded] = useState(false); + const [dimensions, setDimensions] = useState<NodeDimensions>({ + width: 1, + height: 1, + }); + + const imgRef = useRef<HTMLImageElement>(null); + + const parentNodeDimensions = useResizeObserver(parentNode); + + // modify the top-level container class + parentNode.className = 'revealImage'; + + // set up the overlay image + const updateImageView = useCallback(() => { + if (imgRef.current) { + setDimensions({ + height: imgRef.current.naturalHeight, + width: imgRef.current.naturalWidth, + }); + + setLoaded(true); + onLoaded(); + } + }, [imgRef, onLoaded]); + + useEffect(() => { + updateImageView(); + }, [parentNodeDimensions, updateImageView]); + + function getClipPath(percentParam: number, originParam: OriginString = 'bottom') { + const directions: Record<OriginString, number> = { bottom: 0, left: 1, top: 2, right: 3 }; + const values: Array<number | string> = [0, 0, 0, 0]; + values[directions[originParam]] = `${100 - percentParam * 100}%`; + return `inset(${values.join(' ')})`; + } + + function getImageSizeStyle() { + const imgStyles: ImageStyles = {}; + + const imgDimensions = { + height: dimensions.height, + width: dimensions.width, + ratio: dimensions.height / dimensions.width, + }; + + const domNodeDimensions = { + width: parentNode.clientWidth, + height: parentNode.clientHeight, + ratio: parentNode.clientHeight / parentNode.clientWidth, + }; + + if (imgDimensions.ratio > domNodeDimensions.ratio) { + imgStyles.height = `${domNodeDimensions.height}px`; + imgStyles.width = 'initial'; + } else { + imgStyles.width = `${domNodeDimensions.width}px`; + imgStyles.height = 'initial'; + } + + return imgStyles; + } + + const imgSrc = isValidUrl(image ?? '') ? image : elasticOutline; + + const alignerStyles: AlignerStyles = {}; + + if (isValidUrl(emptyImage ?? '')) { + // only use empty image if one is provided + alignerStyles.backgroundImage = `url(${emptyImage})`; + } + + let imgStyles: ImageStyles = {}; + if (imgRef.current && loaded) imgStyles = getImageSizeStyle(); + + imgStyles.clipPath = getClipPath(percent, origin); + if (imgRef.current && loaded) { + imgRef.current.style.setProperty('-webkit-clip-path', getClipPath(percent, origin)); + } + + return ( + <div className="revealImageAligner" style={alignerStyles}> + <img + ref={imgRef} + onLoad={updateImageView} + className="revealImage__image" + src={imgSrc ?? ''} + alt="" + role="presentation" + style={imgStyles} + /> + </div> + ); +} + +// default export required for React.Lazy +// eslint-disable-next-line import/no-default-export +export { RevealImageComponent as default }; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/reveal_image/__stories__/__snapshots__/reveal_image.stories.storyshot b/src/plugins/expression_reveal_image/public/expression_renderers/__stories__/__snapshots__/reveal_image.stories.storyshot similarity index 100% rename from x-pack/plugins/canvas/canvas_plugin_src/renderers/reveal_image/__stories__/__snapshots__/reveal_image.stories.storyshot rename to src/plugins/expression_reveal_image/public/expression_renderers/__stories__/__snapshots__/reveal_image.stories.storyshot diff --git a/src/plugins/expression_reveal_image/public/expression_renderers/__stories__/reveal_image_renderer.stories.tsx b/src/plugins/expression_reveal_image/public/expression_renderers/__stories__/reveal_image_renderer.stories.tsx new file mode 100644 index 0000000000000..bc70b3685e24e --- /dev/null +++ b/src/plugins/expression_reveal_image/public/expression_renderers/__stories__/reveal_image_renderer.stories.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 from 'react'; +import { storiesOf } from '@storybook/react'; +import { revealImageRenderer } from '../'; +import { elasticOutline, elasticLogo } from '../../../../presentation_util/public'; +import { Render } from '../../../../presentation_util/public/__stories__'; + +import { Origin } from '../../../common/types/expression_functions'; + +storiesOf('renderers/revealImage', module).add('default', () => { + const config = { + image: elasticLogo, + emptyImage: elasticOutline, + origin: Origin.LEFT, + percent: 0.45, + }; + + return <Render renderer={revealImageRenderer} config={config} />; +}); diff --git a/src/plugins/expression_reveal_image/public/expression_renderers/index.ts b/src/plugins/expression_reveal_image/public/expression_renderers/index.ts new file mode 100644 index 0000000000000..433a81884f157 --- /dev/null +++ b/src/plugins/expression_reveal_image/public/expression_renderers/index.ts @@ -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. + */ + +import { revealImageRenderer } from './reveal_image_renderer'; + +export const renderers = [revealImageRenderer]; + +export { revealImageRenderer }; diff --git a/src/plugins/expression_reveal_image/public/expression_renderers/reveal_image_renderer.tsx b/src/plugins/expression_reveal_image/public/expression_renderers/reveal_image_renderer.tsx new file mode 100644 index 0000000000000..4d84de3da994c --- /dev/null +++ b/src/plugins/expression_reveal_image/public/expression_renderers/reveal_image_renderer.tsx @@ -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 React, { lazy } from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; +import { I18nProvider } from '@kbn/i18n/react'; +import { ExpressionRenderDefinition, IInterpreterRenderHandlers } from 'src/plugins/expressions'; +import { withSuspense } from '../../../presentation_util/public'; +import { getRendererStrings } from '../../common/i18n'; +import { RevealImageRendererConfig } from '../../common/types'; + +const { revealImage: revealImageStrings } = getRendererStrings(); + +const LazyRevealImageComponent = lazy(() => import('../components/reveal_image_component')); +const RevealImageComponent = withSuspense(LazyRevealImageComponent, null); + +export const revealImageRenderer = (): ExpressionRenderDefinition<RevealImageRendererConfig> => ({ + name: 'revealImage', + displayName: revealImageStrings.getDisplayName(), + help: revealImageStrings.getHelpDescription(), + reuseDomNode: true, + render: async ( + domNode: HTMLElement, + config: RevealImageRendererConfig, + handlers: IInterpreterRenderHandlers + ) => { + handlers.onDestroy(() => { + unmountComponentAtNode(domNode); + }); + + render( + <I18nProvider> + <RevealImageComponent {...config} parentNode={domNode} onLoaded={handlers.done} /> + </I18nProvider>, + domNode + ); + }, +}); diff --git a/src/plugins/expression_reveal_image/public/index.ts b/src/plugins/expression_reveal_image/public/index.ts new file mode 100755 index 0000000000000..00cb14e0fc064 --- /dev/null +++ b/src/plugins/expression_reveal_image/public/index.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 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 { ExpressionRevealImagePlugin } from './plugin'; + +export type { ExpressionRevealImagePluginSetup, ExpressionRevealImagePluginStart } from './plugin'; + +export function plugin() { + return new ExpressionRevealImagePlugin(); +} + +export * from './expression_renderers'; diff --git a/src/plugins/expression_reveal_image/public/plugin.ts b/src/plugins/expression_reveal_image/public/plugin.ts new file mode 100755 index 0000000000000..5f6496a25f820 --- /dev/null +++ b/src/plugins/expression_reveal_image/public/plugin.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 { CoreSetup, CoreStart, Plugin } from '../../../core/public'; +import { ExpressionsStart, ExpressionsSetup } from '../../expressions/public'; +import { revealImageRenderer } from './expression_renderers'; + +interface SetupDeps { + expressions: ExpressionsSetup; +} + +interface StartDeps { + expression: ExpressionsStart; +} + +export type ExpressionRevealImagePluginSetup = void; +export type ExpressionRevealImagePluginStart = void; + +export class ExpressionRevealImagePlugin + implements + Plugin< + ExpressionRevealImagePluginSetup, + ExpressionRevealImagePluginStart, + SetupDeps, + StartDeps + > { + public setup(core: CoreSetup, { expressions }: SetupDeps): ExpressionRevealImagePluginSetup { + expressions.registerRenderer(revealImageRenderer); + } + + public start(core: CoreStart): ExpressionRevealImagePluginStart {} + + public stop() {} +} diff --git a/src/plugins/expression_reveal_image/server/index.ts b/src/plugins/expression_reveal_image/server/index.ts new file mode 100644 index 0000000000000..b86c356974321 --- /dev/null +++ b/src/plugins/expression_reveal_image/server/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { ExpressionRevealImagePlugin } from './plugin'; + +export type { ExpressionRevealImagePluginSetup, ExpressionRevealImagePluginStart } from './plugin'; + +export function plugin() { + return new ExpressionRevealImagePlugin(); +} diff --git a/src/plugins/expression_reveal_image/server/plugin.ts b/src/plugins/expression_reveal_image/server/plugin.ts new file mode 100644 index 0000000000000..446ef018eb7d3 --- /dev/null +++ b/src/plugins/expression_reveal_image/server/plugin.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 { CoreSetup, CoreStart, Plugin } from '../../../core/public'; +import { ExpressionsServerStart, ExpressionsServerSetup } from '../../expressions/server'; +import { revealImageFunction } from '../common'; + +interface SetupDeps { + expressions: ExpressionsServerSetup; +} + +interface StartDeps { + expression: ExpressionsServerStart; +} + +export type ExpressionRevealImagePluginSetup = void; +export type ExpressionRevealImagePluginStart = void; + +export class ExpressionRevealImagePlugin + implements + Plugin< + ExpressionRevealImagePluginSetup, + ExpressionRevealImagePluginStart, + SetupDeps, + StartDeps + > { + public setup(core: CoreSetup, { expressions }: SetupDeps): ExpressionRevealImagePluginSetup { + expressions.registerFunction(revealImageFunction); + } + + public start(core: CoreStart): ExpressionRevealImagePluginStart {} + + public stop() {} +} diff --git a/src/plugins/expression_reveal_image/tsconfig.json b/src/plugins/expression_reveal_image/tsconfig.json new file mode 100644 index 0000000000000..aa4562ec73576 --- /dev/null +++ b/src/plugins/expression_reveal_image/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true, + "isolatedModules": true + }, + "include": [ + "common/**/*", + "public/**/*", + "server/**/*", + ], + "references": [ + { "path": "../../core/tsconfig.json" }, + { "path": "../presentation_util/tsconfig.json" }, + { "path": "../expressions/tsconfig.json" }, + ] +} diff --git a/src/plugins/presentation_util/common/lib/index.ts b/src/plugins/presentation_util/common/lib/index.ts new file mode 100644 index 0000000000000..3fe90009ad8df --- /dev/null +++ b/src/plugins/presentation_util/common/lib/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 './utils'; +export * from './test_helpers'; diff --git a/src/plugins/presentation_util/common/lib/test_helpers/function_wrapper.ts b/src/plugins/presentation_util/common/lib/test_helpers/function_wrapper.ts new file mode 100644 index 0000000000000..4ec02fd622cf7 --- /dev/null +++ b/src/plugins/presentation_util/common/lib/test_helpers/function_wrapper.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 { mapValues } from 'lodash'; +import { + ExpressionValueBoxed, + typeSpecs, + ExpressionFunctionDefinition, +} from '../../../../expressions/common'; + +type FnType = () => typeof typeSpecs[number] & + ExpressionFunctionDefinition<string, any, Record<string, any>, ExpressionValueBoxed<any, any>>; + +// It takes a function spec and passes in default args into the spec fn +export const functionWrapper = (fnSpec: FnType): ReturnType<FnType>['fn'] => { + const spec = fnSpec(); + const defaultArgs = mapValues(spec.args, (argSpec) => { + return argSpec.default; + }); + + return (context, args, handlers) => spec.fn(context, { ...defaultArgs, ...args }, handlers); +}; diff --git a/src/plugins/presentation_util/common/lib/test_helpers/index.ts b/src/plugins/presentation_util/common/lib/test_helpers/index.ts new file mode 100644 index 0000000000000..a6ea8da6ac6e9 --- /dev/null +++ b/src/plugins/presentation_util/common/lib/test_helpers/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 './function_wrapper'; diff --git a/x-pack/plugins/canvas/common/lib/dataurl.test.ts b/src/plugins/presentation_util/common/lib/utils/dataurl.test.ts similarity index 94% rename from x-pack/plugins/canvas/common/lib/dataurl.test.ts rename to src/plugins/presentation_util/common/lib/utils/dataurl.test.ts index 9ddd0a50ea9d5..5820b10f589fe 100644 --- a/x-pack/plugins/canvas/common/lib/dataurl.test.ts +++ b/src/plugins/presentation_util/common/lib/utils/dataurl.test.ts @@ -1,8 +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. + * 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 { isValidDataUrl, parseDataUrl } from './dataurl'; diff --git a/x-pack/plugins/canvas/common/lib/dataurl.ts b/src/plugins/presentation_util/common/lib/utils/dataurl.ts similarity index 90% rename from x-pack/plugins/canvas/common/lib/dataurl.ts rename to src/plugins/presentation_util/common/lib/utils/dataurl.ts index 2ae28b621c425..9ac232369cdc1 100644 --- a/x-pack/plugins/canvas/common/lib/dataurl.ts +++ b/src/plugins/presentation_util/common/lib/utils/dataurl.ts @@ -1,8 +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. + * 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 { fromByteArray } from 'base64-js'; diff --git a/x-pack/plugins/canvas/public/lib/elastic_logo.ts b/src/plugins/presentation_util/common/lib/utils/elastic_logo.ts similarity index 96% rename from x-pack/plugins/canvas/public/lib/elastic_logo.ts rename to src/plugins/presentation_util/common/lib/utils/elastic_logo.ts index 81c79c39143d6..9a789d1a5fb03 100644 --- a/x-pack/plugins/canvas/public/lib/elastic_logo.ts +++ b/src/plugins/presentation_util/common/lib/utils/elastic_logo.ts @@ -1,8 +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. + * 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 elasticLogo = diff --git a/src/plugins/presentation_util/common/lib/utils/elastic_outline.ts b/src/plugins/presentation_util/common/lib/utils/elastic_outline.ts new file mode 100644 index 0000000000000..4747be58127f7 --- /dev/null +++ b/src/plugins/presentation_util/common/lib/utils/elastic_outline.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 elasticOutline = + 'data:image/svg+xml,%3C%3Fxml%20version%3D%221.0%22%20encoding%3D%22utf-8%22%3F%3E%0A%3Csvg%20viewBox%3D%22-3.948730230331421%20-1.7549896240234375%20245.25946044921875%20241.40370178222656%22%20width%3D%22245.25946044921875%22%20height%3D%22241.40370178222656%22%20style%3D%22enable-background%3Anew%200%200%20686.2%20235.7%3B%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%3Cdefs%3E%0A%20%20%20%20%3Cstyle%20type%3D%22text%2Fcss%22%3E%0A%09.st0%7Bfill%3A%232D2D2D%3B%7D%0A%3C%2Fstyle%3E%0A%20%20%3C%2Fdefs%3E%0A%20%20%3Cg%20transform%3D%22matrix%281%2C%200%2C%200%2C%201%2C%200%2C%200%29%22%3E%0A%20%20%20%20%3Cg%3E%0A%20%20%20%20%20%20%3Cpath%20class%3D%22st0%22%20d%3D%22M329.4%2C160.3l4.7-0.5l0.3%2C9.6c-12.4%2C1.7-23%2C2.6-31.8%2C2.6c-11.7%2C0-20-3.4-24.9-10.2%26%2310%3B%26%239%3B%26%239%3B%26%239%3Bc-4.9-6.8-7.3-17.4-7.3-31.7c0-28.6%2C11.4-42.9%2C34.1-42.9c11%2C0%2C19.2%2C3.1%2C24.6%2C9.2c5.4%2C6.1%2C8.1%2C15.8%2C8.1%2C28.9l-0.7%2C9.3h-53.8%26%2310%3B%26%239%3B%26%239%3B%26%239%3Bc0%2C9%2C1.6%2C15.7%2C4.9%2C20c3.3%2C4.3%2C8.9%2C6.5%2C17%2C6.5C312.8%2C161.2%2C321.1%2C160.9%2C329.4%2C160.3z%20M325%2C124.9c0-10-1.6-17.1-4.8-21.2%26%2310%3B%26%239%3B%26%239%3B%26%239%3Bc-3.2-4.1-8.4-6.2-15.6-6.2c-7.2%2C0-12.7%2C2.2-16.3%2C6.5c-3.6%2C4.3-5.5%2C11.3-5.6%2C20.9H325z%22%2F%3E%0A%20%20%20%20%20%20%3Cpath%20class%3D%22st0%22%20d%3D%22M354.3%2C171.4V64h12.2v107.4H354.3z%22%2F%3E%0A%20%20%20%20%20%20%3Cpath%20class%3D%22st0%22%20d%3D%22M443.5%2C113.5v41.1c0%2C4.1%2C10.1%2C3.9%2C10.1%2C3.9l-0.6%2C10.8c-8.6%2C0-15.7%2C0.7-20-3.4c-9.8%2C4.3-19.5%2C6.1-29.3%2C6.1%26%2310%3B%26%239%3B%26%239%3B%26%239%3Bc-7.5%2C0-13.2-2.1-17.1-6.4c-3.9-4.2-5.9-10.3-5.9-18.3c0-7.9%2C2-13.8%2C6-17.5c4-3.7%2C10.3-6.1%2C18.9-6.9l25.6-2.4v-7%26%2310%3B%26%239%3B%26%239%3B%26%239%3Bc0-5.5-1.2-9.5-3.6-11.9c-2.4-2.4-5.7-3.6-9.8-3.6l-32.1%2C0V87.2h31.3c9.2%2C0%2C15.9%2C2.1%2C20.1%2C6.4C441.4%2C97.8%2C443.5%2C104.5%2C443.5%2C113.5%26%2310%3B%26%239%3B%26%239%3B%26%239%3Bz%20M393.3%2C146.7c0%2C10%2C4.1%2C15%2C12.4%2C15c7.4%2C0%2C14.7-1.2%2C21.8-3.7l3.7-1.3v-26.9l-24.1%2C2.3c-4.9%2C0.4-8.4%2C1.8-10.6%2C4.2%26%2310%3B%26%239%3B%26%239%3B%26%239%3BC394.4%2C138.7%2C393.3%2C142.2%2C393.3%2C146.7z%22%2F%3E%0A%20%20%20%20%20%20%3Cpath%20class%3D%22st0%22%20d%3D%22M491.2%2C98.2c-11.8%2C0-17.8%2C4.1-17.8%2C12.4c0%2C3.8%2C1.4%2C6.5%2C4.1%2C8.1c2.7%2C1.6%2C8.9%2C3.2%2C18.6%2C4.9%26%2310%3B%26%239%3B%26%239%3B%26%239%3Bc9.7%2C1.7%2C16.5%2C4%2C20.5%2C7.1c4%2C3%2C6%2C8.7%2C6%2C17.1c0%2C8.4-2.7%2C14.5-8.1%2C18.4c-5.4%2C3.9-13.2%2C5.9-23.6%2C5.9c-6.7%2C0-29.2-2.5-29.2-2.5%26%2310%3B%26%239%3B%26%239%3B%26%239%3Bl0.7-10.6c12.9%2C1.2%2C22.3%2C2.2%2C28.6%2C2.2c6.3%2C0%2C11.1-1%2C14.4-3c3.3-2%2C5-5.4%2C5-10.1c0-4.7-1.4-7.9-4.2-9.6c-2.8-1.7-9-3.3-18.6-4.8%26%2310%3B%26%239%3B%26%239%3B%26%239%3Bc-9.6-1.5-16.4-3.7-20.4-6.7c-4-2.9-6-8.4-6-16.3c0-7.9%2C2.8-13.8%2C8.4-17.6c5.6-3.8%2C12.6-5.7%2C20.9-5.7c6.6%2C0%2C29.6%2C1.7%2C29.6%2C1.7%26%2310%3B%26%239%3B%26%239%3B%26%239%3Bv10.7C508.1%2C99%2C498.2%2C98.2%2C491.2%2C98.2z%22%2F%3E%0A%20%20%20%20%20%20%3Cpath%20class%3D%22st0%22%20d%3D%22M581.7%2C99.5h-25.9v39c0%2C9.3%2C0.7%2C15.5%2C2%2C18.4c1.4%2C2.9%2C4.6%2C4.4%2C9.7%2C4.4l14.5-1l0.8%2C10.1%26%2310%3B%26%239%3B%26%239%3B%26%239%3Bc-7.3%2C1.2-12.8%2C1.8-16.6%2C1.8c-8.5%2C0-14.3-2.1-17.6-6.2c-3.3-4.1-4.9-12-4.9-23.6V99.5h-11.6V88.9h11.6V63.9h12.1v24.9h25.9V99.5z%22%2F%3E%0A%20%20%20%20%20%20%3Cpath%20class%3D%22st0%22%20d%3D%22M598.7%2C78.4V64.3h12.2v14.2H598.7z%20M598.7%2C171.4V88.9h12.2v82.5H598.7z%22%2F%3E%0A%20%20%20%20%20%20%3Cpath%20class%3D%22st0%22%20d%3D%22M663.8%2C87.2c3.6%2C0%2C9.7%2C0.7%2C18.3%2C2l3.9%2C0.5l-0.5%2C9.9c-8.7-1-15.1-1.5-19.2-1.5c-9.2%2C0-15.5%2C2.2-18.8%2C6.6%26%2310%3B%26%239%3B%26%239%3B%26%239%3Bc-3.3%2C4.4-5%2C12.6-5%2C24.5c0%2C11.9%2C1.5%2C20.2%2C4.6%2C24.9c3.1%2C4.7%2C9.5%2C7%2C19.3%2C7l19.2-1.5l0.5%2C10.1c-10.1%2C1.5-17.7%2C2.3-22.7%2C2.3%26%2310%3B%26%239%3B%26%239%3B%26%239%3Bc-12.7%2C0-21.5-3.3-26.3-9.8c-4.8-6.5-7.3-17.5-7.3-33c0-15.5%2C2.6-26.4%2C7.8-32.6C643%2C90.4%2C651.7%2C87.2%2C663.8%2C87.2z%22%2F%3E%0A%20%20%20%20%3C%2Fg%3E%0A%20%20%20%20%3Cpath%20class%3D%22st0%22%20d%3D%22M236.6%2C123.5c0-19.8-12.3-37.2-30.8-43.9c0.8-4.2%2C1.2-8.4%2C1.2-12.7C207%2C30%2C177%2C0%2C140.2%2C0%26%2310%3B%26%239%3B%26%239%3BC118.6%2C0%2C98.6%2C10.3%2C86%2C27.7c-6.2-4.8-13.8-7.4-21.7-7.4c-19.6%2C0-35.5%2C15.9-35.5%2C35.5c0%2C4.3%2C0.8%2C8.5%2C2.2%2C12.4%26%2310%3B%26%239%3B%26%239%3BC12.6%2C74.8%2C0%2C92.5%2C0%2C112.2c0%2C19.9%2C12.4%2C37.3%2C30.9%2C44c-0.8%2C4.1-1.2%2C8.4-1.2%2C12.7c0%2C36.8%2C29.9%2C66.7%2C66.7%2C66.7%26%2310%3B%26%239%3B%26%239%3Bc21.6%2C0%2C41.6-10.4%2C54.1-27.8c6.2%2C4.9%2C13.8%2C7.6%2C21.7%2C7.6c19.6%2C0%2C35.5-15.9%2C35.5-35.5c0-4.3-0.8-8.5-2.2-12.4%26%2310%3B%26%239%3B%26%239%3BC223.9%2C160.9%2C236.6%2C143.2%2C236.6%2C123.5z%20M91.6%2C34.8c10.9-15.9%2C28.9-25.4%2C48.1-25.4c32.2%2C0%2C58.4%2C26.2%2C58.4%2C58.4%26%2310%3B%26%239%3B%26%239%3Bc0%2C3.9-0.4%2C7.7-1.1%2C11.5l-52.2%2C45.8L93%2C101.5L82.9%2C79.9L91.6%2C34.8z%20M65.4%2C29c6.2%2C0%2C12.1%2C2%2C17%2C5.7l-7.8%2C40.3l-35.5-8.4%26%2310%3B%26%239%3B%26%239%3Bc-1.1-3.1-1.7-6.3-1.7-9.7C37.4%2C41.6%2C49.9%2C29%2C65.4%2C29z%20M9.1%2C112.3c0-16.7%2C11-31.9%2C26.9-37.2L75%2C84.4l9.1%2C19.5l-49.8%2C45%26%2310%3B%26%239%3B%26%239%3BC19.2%2C143.1%2C9.1%2C128.6%2C9.1%2C112.3z%20M145.2%2C200.9c-10.9%2C16.1-29%2C25.6-48.4%2C25.6c-32.3%2C0-58.6-26.3-58.6-58.5c0-4%2C0.4-7.9%2C1.1-11.7%26%2310%3B%26%239%3B%26%239%3Bl50.9-46l52%2C23.7l11.5%2C22L145.2%2C200.9z%20M171.2%2C206.6c-6.1%2C0-12-2-16.9-5.8l7.7-40.2l35.4%2C8.3c1.1%2C3.1%2C1.7%2C6.3%2C1.7%2C9.7%26%2310%3B%26%239%3B%26%239%3BC199.2%2C194.1%2C186.6%2C206.6%2C171.2%2C206.6z%20M200.5%2C160.5l-39-9.1l-10.4-19.8l51-44.7c15.1%2C5.7%2C25.2%2C20.2%2C25.2%2C36.5%26%2310%3B%26%239%3B%26%239%3BC227.4%2C140.1%2C216.4%2C155.3%2C200.5%2C160.5z%22%2F%3E%0A%20%20%3C%2Fg%3E%0A%3C%2Fsvg%3E'; diff --git a/x-pack/plugins/canvas/common/lib/httpurl.test.ts b/src/plugins/presentation_util/common/lib/utils/httpurl.test.ts similarity index 89% rename from x-pack/plugins/canvas/common/lib/httpurl.test.ts rename to src/plugins/presentation_util/common/lib/utils/httpurl.test.ts index 1cd00114bf7ca..20cd40480691d 100644 --- a/x-pack/plugins/canvas/common/lib/httpurl.test.ts +++ b/src/plugins/presentation_util/common/lib/utils/httpurl.test.ts @@ -1,8 +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. + * 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 { isValidHttpUrl } from './httpurl'; diff --git a/x-pack/plugins/canvas/common/lib/httpurl.ts b/src/plugins/presentation_util/common/lib/utils/httpurl.ts similarity index 67% rename from x-pack/plugins/canvas/common/lib/httpurl.ts rename to src/plugins/presentation_util/common/lib/utils/httpurl.ts index 4f8b03aa2a062..4777eb4c8128d 100644 --- a/x-pack/plugins/canvas/common/lib/httpurl.ts +++ b/src/plugins/presentation_util/common/lib/utils/httpurl.ts @@ -1,8 +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. + * 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. */ // A cheap regex to distinguish an HTTP URL string from a data URL string diff --git a/src/plugins/presentation_util/common/lib/utils/index.ts b/src/plugins/presentation_util/common/lib/utils/index.ts new file mode 100644 index 0000000000000..eed4acf78b2be --- /dev/null +++ b/src/plugins/presentation_util/common/lib/utils/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 './dataurl'; +export * from './elastic_logo'; +export * from './elastic_outline'; +export * from './httpurl'; +export * from './missing_asset'; +export * from './resolve_dataurl'; +export * from './url'; diff --git a/src/plugins/presentation_util/common/lib/utils/missing_asset.ts b/src/plugins/presentation_util/common/lib/utils/missing_asset.ts new file mode 100644 index 0000000000000..10d429870c88c --- /dev/null +++ b/src/plugins/presentation_util/common/lib/utils/missing_asset.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 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. + */ + +// CC0, source: https://pixabay.com/en/question-mark-confirmation-question-838656/ +export const missingImage = + 'data:image/svg+xml;base64,PHN2ZyB2ZXJzaW9uPSIxLjEiIHZpZXdCb3g9IjAgMCAzMSAzMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZGVmcz48ZmlsdGVyIGlkPSJiIiB4PSItLjM2IiB5PSItLjM2IiB3aWR0aD0iMS43MiIgaGVpZ2h0PSIxLjcyIiBjb2xvci1pbnRlcnBvbGF0aW9uLWZpbHRlcnM9InNSR0IiPjxmZUdhdXNzaWFuQmx1ciBzdGREZXZpYXRpb249IjIuNDY0Ii8+PC9maWx0ZXI+PGxpbmVhckdyYWRpZW50IGlkPSJhIiB4MT0iMTU5LjM0IiB4Mj0iMTg0LjQ4IiB5MT0iNzI3LjM2IiB5Mj0iNzQ5Ljg5IiBncmFkaWVudFRyYW5zZm9ybT0idHJhbnNsYXRlKC0xMTEuNTMgLTU0OS42OCkgc2NhbGUoLjc3MDAyKSIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiPjxzdG9wIHN0b3AtY29sb3I9IiNlYmYwZWQiIG9mZnNldD0iMCIvPjxzdG9wIHN0b3AtY29sb3I9IiNmYWZhZmEiIG9mZnNldD0iMSIvPjwvbGluZWFyR3JhZGllbnQ+PC9kZWZzPjxwYXRoIGQ9Ik0xNS40MzcgMi42OTVsMTQuNTA2IDI0LjQ3NkgxLjI4N2wxNC4xNS0yNC40NzZ6IiBmaWxsPSJ1cmwoI2EpIiBzdHJva2U9InJlZCIgc3Ryb2tlLWxpbmVjYXA9InNxdWFyZSIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgc3Ryb2tlLXdpZHRoPSIyIi8+PHBhdGggdHJhbnNmb3JtPSJtYXRyaXgoLjgzMTk3IDAgMCAuNTU0NjYgLTc4LjU4MyAtMzgzLjUxKSIgZD0iTTExMS4zMSA3MzEuMmMtMy4yODMtMy45MjUtMy41OTUtNi4xNDgtMi4wMjQtMTAuNDM4IDMuMzM2LTYuMTQ1IDQuNDk2LTguMDY4IDUuNDEtOS40MDUgMS45MDEgNS4xNjIgMi4xMjYgMTkuMTQtMy4zODYgMTkuODQzeiIgZmlsbD0iI2ZmZiIgZmlsbC1vcGFjaXR5PSIuODc2IiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGZpbHRlcj0idXJsKCNiKSIvPjxnIGZpbGwtb3BhY2l0eT0iLjgyIj48cGF0aCBkPSJNMTUuMDQ2IDIwLjIyN2gtLjQxNWMtLjAxMy0uNzQ4LjAyLTEuMzA4LjEwMS0xLjY3OC4wODgtLjM3MS4zMDctLjg4LjY1OC0xLjUyOC4zNTctLjY1NC41OS0xLjE3Ni42OTctMS41NjcuMTE1LS4zOTguMTcyLS44ODcuMTcyLTEuNDY3IDAtLjg5Ni0uMTc1LTEuNTU3LS41MjYtMS45ODItLjM1LS40MjUtLjc2NS0uNjM3LTEuMjQ0LS42MzctLjM2NCAwLS42Ny4wOTgtLjkyLjI5My0uMTg5LjE0OS0uMjgzLjMwNC0uMjgzLjQ2NiAwIC4xMDcuMDY0LjI3Ni4xOTIuNTA1LjI5LjUyLjQzNS45NjEuNDM1IDEuMzI1IDAgLjMzLS4xMTUuNjA3LS4zNDQuODNhMS4xMzggMS4xMzggMCAwIDEtLjg0LjMzM2MtLjM3NyAwLS42OTQtLjEzMS0uOTUtLjM5NC0uMjU2LS4yNy0uMzg0LS42MjQtLjM4NC0xLjA2MiAwLS43OTYuMzQ0LTEuNDk0IDEuMDMxLTIuMDk0LjY4OC0uNiAxLjY0OS0uOSAyLjg4My0uOSAxLjMwOCAwIDIuMzAyLjMxNCAyLjk4My45NC42ODguNjIxIDEuMDMyIDEuMzczIDEuMDMyIDIuMjU2IDAgLjY0LS4xNzYgMS4yMzQtLjUyNiAxLjc4LS4zNTEuNTQtMS4wMjkgMS4xNC0yLjAzMyAxLjgtLjY3NC40NDUtMS4xMi44NDMtMS4zMzUgMS4xOTQtLjIxLjM0My0uMzM3Ljg3My0uMzg0IDEuNTg3bS0uMTEyIDEuNDc3Yy40NTIgMCAuODM2LjE1OCAxLjE1My40NzUuMzE3LjMxNy40NzYuNzAxLjQ3NiAxLjE1MyAwIC40NTItLjE1OS44NC0uNDc2IDEuMTYzYTEuNTcgMS41NyAwIDAgMS0xLjE1My40NzUgMS41NyAxLjU3IDAgMCAxLTEuMTUzLS40NzUgMS42MDQgMS42MDQgMCAwIDEtLjQ3NS0xLjE2M2MwLS40NTIuMTU5LS44MzYuNDc1LTEuMTUzYTEuNTcgMS41NyAwIDAgMSAxLjE1My0uNDc1IiBmaWxsPSIjZmZmIiBmaWxsLW9wYWNpdHk9Ii40ODYiLz48cGF0aCBkPSJNMTUuMzI3IDIwLjUwOGgtLjQxNWMtLjAxMy0uNzQ4LjAyLTEuMzA4LjEwMS0xLjY3OC4wODgtLjM3MS4zMDctLjg4LjY1OC0xLjUyOC4zNTctLjY1NC41OS0xLjE3Ni42OTctMS41NjcuMTE1LS4zOTguMTcyLS44ODcuMTcyLTEuNDY2IDAtLjg5Ny0uMTc1LTEuNTU4LS41MjYtMS45ODMtLjM1LS40MjQtLjc2NS0uNjM3LTEuMjQzLS42MzctLjM2NSAwLS42NzEuMDk4LS45Mi4yOTMtLjE5LjE0OS0uMjg0LjMwNC0uMjg0LjQ2NiAwIC4xMDguMDY0LjI3Ni4xOTIuNTA1LjI5LjUyLjQzNS45NjEuNDM1IDEuMzI1IDAgLjMzLS4xMTUuNjA3LS4zNDQuODNhMS4xMzggMS4xMzggMCAwIDEtLjg0LjMzM2MtLjM3NyAwLS42OTQtLjEzMS0uOTUtLjM5NC0uMjU2LS4yNy0uMzg0LS42MjQtLjM4NC0xLjA2MiAwLS43OTYuMzQ0LTEuNDkzIDEuMDMxLTIuMDk0LjY4OC0uNiAxLjY0OS0uOSAyLjg4My0uOSAxLjMwOCAwIDIuMzAyLjMxNCAyLjk4My45NC42ODguNjIxIDEuMDMyIDEuMzczIDEuMDMyIDIuMjU2IDAgLjY0LS4xNzYgMS4yMzQtLjUyNiAxLjc4LS4zNS41NC0xLjAyOCAxLjE0LTIuMDMzIDEuOC0uNjc0LjQ0NS0uODUzLjg0My0xLjA2OCAxLjE5NC0uMjEuMzQzLS4zMzcuODczLS4zODUgMS41ODdtLS4zNzggMS40NzdjLjQ1MiAwIC44MzYuMTU4IDEuMTUzLjQ3NS4zMTcuMzE3LjQ3Ni43MDEuNDc2IDEuMTUzIDAgLjQ1Mi0uMTU5Ljg0LS40NzYgMS4xNjNhMS41NyAxLjU3IDAgMCAxLTEuMTUzLjQ3NiAxLjU3IDEuNTcgMCAwIDEtMS4xNTMtLjQ3NiAxLjYwNCAxLjYwNCAwIDAgMS0uNDc1LTEuMTYzYzAtLjQ1Mi4xNTktLjgzNi40NzUtMS4xNTNhMS41NyAxLjU3IDAgMCAxIDEuMTUzLS40NzUiIGZpbGwtb3BhY2l0eT0iLjgyIi8+PC9nPjwvc3ZnPg=='; diff --git a/x-pack/plugins/canvas/common/lib/resolve_dataurl.test.js b/src/plugins/presentation_util/common/lib/utils/resolve_dataurl.test.ts similarity index 84% rename from x-pack/plugins/canvas/common/lib/resolve_dataurl.test.js rename to src/plugins/presentation_util/common/lib/utils/resolve_dataurl.test.ts index 72aaa1dfbd502..c2b9a444d20ef 100644 --- a/x-pack/plugins/canvas/common/lib/resolve_dataurl.test.js +++ b/src/plugins/presentation_util/common/lib/utils/resolve_dataurl.test.ts @@ -1,11 +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; you may not use this file except in compliance with the Elastic License - * 2.0. + * 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 { missingImage } from '../../common/lib/missing_asset'; +import { missingImage } from './missing_asset'; import { resolveFromArgs, resolveWithMissingImage } from './resolve_dataurl'; describe('resolve_dataurl', () => { diff --git a/x-pack/plugins/canvas/common/lib/resolve_dataurl.ts b/src/plugins/presentation_util/common/lib/utils/resolve_dataurl.ts similarity index 75% rename from x-pack/plugins/canvas/common/lib/resolve_dataurl.ts rename to src/plugins/presentation_util/common/lib/utils/resolve_dataurl.ts index 79e49c0595355..db94bdf04c32b 100644 --- a/x-pack/plugins/canvas/common/lib/resolve_dataurl.ts +++ b/src/plugins/presentation_util/common/lib/utils/resolve_dataurl.ts @@ -1,13 +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. + * 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 { get } from 'lodash'; -import { isValidUrl } from '../../common/lib/url'; -import { missingImage } from '../../common/lib/missing_asset'; +import { isValidUrl } from './url'; +import { missingImage } from './missing_asset'; /* * NOTE: args.dataurl can come as an expression here. diff --git a/x-pack/plugins/canvas/common/lib/url.test.ts b/src/plugins/presentation_util/common/lib/utils/url.test.ts similarity index 70% rename from x-pack/plugins/canvas/common/lib/url.test.ts rename to src/plugins/presentation_util/common/lib/utils/url.test.ts index 654602eea2093..4599e776a6266 100644 --- a/x-pack/plugins/canvas/common/lib/url.test.ts +++ b/src/plugins/presentation_util/common/lib/utils/url.test.ts @@ -1,11 +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; you may not use this file except in compliance with the Elastic License - * 2.0. + * 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 { missingImage } from '../../common/lib/missing_asset'; +import { missingImage } from './missing_asset'; import { isValidUrl } from './url'; describe('resolve_dataurl', () => { diff --git a/src/plugins/presentation_util/common/lib/utils/url.ts b/src/plugins/presentation_util/common/lib/utils/url.ts new file mode 100644 index 0000000000000..e6a1064200cc1 --- /dev/null +++ b/src/plugins/presentation_util/common/lib/utils/url.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 { isValidDataUrl } from './dataurl'; +import { isValidHttpUrl } from './httpurl'; + +export function isValidUrl(url: string) { + return isValidDataUrl(url) || isValidHttpUrl(url); +} diff --git a/src/plugins/presentation_util/jest.config.js b/src/plugins/presentation_util/jest.config.js new file mode 100644 index 0000000000000..2250d70acb475 --- /dev/null +++ b/src/plugins/presentation_util/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: ['<rootDir>/src/plugins/presentation_util'], +}; diff --git a/src/plugins/presentation_util/kibana.json b/src/plugins/presentation_util/kibana.json index c7d272dcd02a1..22ec919457cce 100644 --- a/src/plugins/presentation_util/kibana.json +++ b/src/plugins/presentation_util/kibana.json @@ -4,6 +4,9 @@ "kibanaVersion": "kibana", "server": true, "ui": true, + "extraPublicDirs": [ + "common/lib" + ], "requiredPlugins": [ "savedObjects" ], diff --git a/src/plugins/presentation_util/public/__stories__/index.tsx b/src/plugins/presentation_util/public/__stories__/index.tsx new file mode 100644 index 0000000000000..078a16cb8cab2 --- /dev/null +++ b/src/plugins/presentation_util/public/__stories__/index.tsx @@ -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 './render'; diff --git a/src/plugins/presentation_util/public/__stories__/render.tsx b/src/plugins/presentation_util/public/__stories__/render.tsx new file mode 100644 index 0000000000000..29d95e6bf2819 --- /dev/null +++ b/src/plugins/presentation_util/public/__stories__/render.tsx @@ -0,0 +1,61 @@ +/* + * 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 { action } from '@storybook/addon-actions'; +import React, { useRef, useEffect } from 'react'; +import { ExpressionRenderDefinition, IInterpreterRenderHandlers } from 'src/plugins/expressions'; + +export const defaultHandlers: IInterpreterRenderHandlers = { + getRenderMode: () => 'display', + isSyncColorsEnabled: () => false, + done: action('done'), + onDestroy: action('onDestroy'), + reload: action('reload'), + update: action('update'), + event: action('event'), +}; + +/* + Uses a RenderDefinitionFactory and Config to render into an element. + + Intended to be used for stories for RenderDefinitionFactory +*/ +interface RenderAdditionalProps { + height?: string; + width?: string; + handlers?: IInterpreterRenderHandlers; +} + +export const Render = <Renderer,>({ + renderer, + config, + ...rest +}: Renderer extends () => ExpressionRenderDefinition<infer Config> + ? { renderer: Renderer; config: Config } & RenderAdditionalProps + : { renderer: undefined; config: undefined } & RenderAdditionalProps) => { + const { height, width, handlers } = { + height: '200px', + width: '200px', + handlers: defaultHandlers, + ...rest, + }; + + const containerRef = useRef<HTMLDivElement | null>(null); + + useEffect(() => { + if (renderer && containerRef.current !== null) { + renderer().render(containerRef.current, config, handlers); + } + }, [renderer, config, handlers]); + + return ( + <div style={{ width, height }} ref={containerRef}> + {' '} + </div> + ); +}; diff --git a/src/plugins/presentation_util/public/index.ts b/src/plugins/presentation_util/public/index.ts index 9f17133c5b35a..1e26011ff58ae 100644 --- a/src/plugins/presentation_util/public/index.ts +++ b/src/plugins/presentation_util/public/index.ts @@ -28,6 +28,7 @@ export { export { PresentationUtilPluginSetup, PresentationUtilPluginStart } from './types'; export { SaveModalDashboardProps } from './components/types'; export { projectIDs, ProjectID, Project } from '../common/labs'; +export * from '../common/lib'; export { LazyLabsBeakerButton, diff --git a/src/plugins/presentation_util/tsconfig.json b/src/plugins/presentation_util/tsconfig.json index c0fafe8c3aaba..b389d94b19413 100644 --- a/src/plugins/presentation_util/tsconfig.json +++ b/src/plugins/presentation_util/tsconfig.json @@ -7,6 +7,9 @@ "declaration": true, "declarationMap": true }, + "extraPublicDirs": [ + "common" + ], "include": [ "common/**/*", "public/**/*", diff --git a/src/plugins/saved_objects/public/finder/saved_object_finder.tsx b/src/plugins/saved_objects/public/finder/saved_object_finder.tsx index da65b5b9fdda8..0a2e4ff78be26 100644 --- a/src/plugins/saved_objects/public/finder/saved_object_finder.tsx +++ b/src/plugins/saved_objects/public/finder/saved_object_finder.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import _ from 'lodash'; +import { debounce } from 'lodash'; import PropTypes from 'prop-types'; import React from 'react'; @@ -116,7 +116,7 @@ class SavedObjectFinderUi extends React.Component< private isComponentMounted: boolean = false; - private debouncedFetch = _.debounce(async (query: string) => { + private debouncedFetch = debounce(async (query: string) => { const metaDataMap = this.getSavedObjectMetaDataMap(); const fields = Object.values(metaDataMap) diff --git a/src/plugins/saved_objects/public/saved_object/helpers/apply_es_resp.ts b/src/plugins/saved_objects/public/saved_object/helpers/apply_es_resp.ts index 1f2f7dc573dc7..40baff22f52c8 100644 --- a/src/plugins/saved_objects/public/saved_object/helpers/apply_es_resp.ts +++ b/src/plugins/saved_objects/public/saved_object/helpers/apply_es_resp.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import _ from 'lodash'; +import { cloneDeep, defaults, forOwn, assign } from 'lodash'; import { EsResponse, SavedObject, SavedObjectConfig, SavedObjectKibanaServices } from '../../types'; import { SavedObjectNotFound } from '../../../../kibana_utils/public'; import { @@ -28,7 +28,7 @@ export async function applyESResp( ) { const mapping = expandShorthand(config.mapping ?? {}); const savedObjectType = config.type || ''; - savedObject._source = _.cloneDeep(resp._source); + savedObject._source = cloneDeep(resp._source); if (typeof resp.found === 'boolean' && !resp.found) { throw new SavedObjectNotFound(savedObjectType, savedObject.id || ''); } @@ -42,10 +42,10 @@ export async function applyESResp( } // assign the defaults to the response - _.defaults(savedObject._source, savedObject.defaults); + defaults(savedObject._source, savedObject.defaults); // transform the source using _deserializers - _.forOwn(mapping, (fieldMapping, fieldName) => { + forOwn(mapping, (fieldMapping, fieldName) => { if (fieldMapping._deserialize && typeof fieldName === 'string') { savedObject._source[fieldName] = fieldMapping._deserialize( savedObject._source[fieldName] as string @@ -54,7 +54,7 @@ export async function applyESResp( }); // Give obj all of the values in _source.fields - _.assign(savedObject, savedObject._source); + assign(savedObject, savedObject._source); savedObject.lastSavedTitle = savedObject.title; if (meta.searchSourceJSON) { diff --git a/src/plugins/saved_objects/public/saved_object/helpers/create_source.ts b/src/plugins/saved_objects/public/saved_object/helpers/create_source.ts index f1bc614dd1197..7ed729b4b7a0f 100644 --- a/src/plugins/saved_objects/public/saved_object/helpers/create_source.ts +++ b/src/plugins/saved_objects/public/saved_object/helpers/create_source.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import _ from 'lodash'; +import { get } from 'lodash'; import { i18n } from '@kbn/i18n'; import { SavedObjectAttributes } from 'kibana/public'; import { SavedObject, SavedObjectKibanaServices } from '../../types'; @@ -40,7 +40,7 @@ export async function createSource( return await savedObjectsClient.create(esType, source, options); } catch (err) { // record exists, confirm overwriting - if (_.get(err, 'res.status') === 409) { + if (get(err, 'res.status') === 409) { const confirmMessage = i18n.translate( 'savedObjects.confirmModal.overwriteConfirmationMessage', { diff --git a/src/plugins/saved_objects/public/saved_object/helpers/initialize_saved_object.ts b/src/plugins/saved_objects/public/saved_object/helpers/initialize_saved_object.ts index cf0cea8d368da..b6dddf8d82b72 100644 --- a/src/plugins/saved_objects/public/saved_object/helpers/initialize_saved_object.ts +++ b/src/plugins/saved_objects/public/saved_object/helpers/initialize_saved_object.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import _ from 'lodash'; +import { cloneDeep, assign } from 'lodash'; import { SavedObjectsClientContract } from 'kibana/public'; import { SavedObject, SavedObjectConfig } from '../../types'; @@ -24,7 +24,7 @@ export async function intializeSavedObject( if (!savedObject.id) { // just assign the defaults and be done - _.assign(savedObject, savedObject.defaults); + assign(savedObject, savedObject.defaults); await savedObject.hydrateIndexPattern!(); if (typeof config.afterESResp === 'function') { savedObject = await config.afterESResp(savedObject); @@ -36,7 +36,7 @@ export async function intializeSavedObject( const respMapped = { _id: resp.id, _type: resp.type, - _source: _.cloneDeep(resp.attributes), + _source: cloneDeep(resp.attributes), references: resp.references, found: !!resp._version, }; diff --git a/src/plugins/saved_objects/public/saved_object/helpers/serialize_saved_object.ts b/src/plugins/saved_objects/public/saved_object/helpers/serialize_saved_object.ts index eb9bef788fcdc..efe7a85f8f1e1 100644 --- a/src/plugins/saved_objects/public/saved_object/helpers/serialize_saved_object.ts +++ b/src/plugins/saved_objects/public/saved_object/helpers/serialize_saved_object.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import _ from 'lodash'; +import { forOwn } from 'lodash'; import { SavedObject, SavedObjectConfig } from '../../types'; import { extractSearchSourceReferences } from '../../../../data/public'; import { expandShorthand } from './field_mapping'; @@ -17,7 +17,7 @@ export function serializeSavedObject(savedObject: SavedObject, config: SavedObje const attributes = {} as Record<string, any>; const references = []; - _.forOwn(mapping, (fieldMapping, fieldName) => { + forOwn(mapping, (fieldMapping, fieldName) => { if (typeof fieldName !== 'string') { return; } diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/browser/markdown.test.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/browser/markdown.test.js index 0e7a1afb0dbb1..dcb6035dbb687 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/browser/markdown.test.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/browser/markdown.test.js @@ -5,7 +5,7 @@ * 2.0. */ -import { functionWrapper } from '../../../test_helpers/function_wrapper'; +import { functionWrapper } from '../../../../../../src/plugins/presentation_util/common/lib'; import { testTable } from '../common/__fixtures__/test_tables'; import { fontStyle } from '../common/__fixtures__/test_styles'; import { markdown } from './markdown'; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__fixtures__/test_styles.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__fixtures__/test_styles.js index d61fef7abced8..fa831cacbcb18 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__fixtures__/test_styles.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__fixtures__/test_styles.js @@ -5,7 +5,7 @@ * 2.0. */ -import { elasticLogo } from '../../../lib/elastic_logo'; +import { elasticLogo } from '../../../../../../../src/plugins/presentation_util/common/lib'; export const fontStyle = { type: 'style', diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/all.test.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/all.test.js index c09c3ff99d89c..7d983e02f1123 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/all.test.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/all.test.js @@ -5,7 +5,7 @@ * 2.0. */ -import { functionWrapper } from '../../../test_helpers/function_wrapper'; +import { functionWrapper } from '../../../../../../src/plugins/presentation_util/common/lib'; import { all } from './all'; describe('all', () => { diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/alterColumn.test.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/alterColumn.test.js index 7e018427dc4c7..85e062f454bc5 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/alterColumn.test.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/alterColumn.test.js @@ -5,7 +5,7 @@ * 2.0. */ -import { functionWrapper } from '../../../test_helpers/function_wrapper'; +import { functionWrapper } from '../../../../../../src/plugins/presentation_util/common/lib'; import { getFunctionErrors } from '../../../i18n'; import { emptyTable, testTable } from './__fixtures__/test_tables'; import { alterColumn } from './alterColumn'; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/any.test.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/any.test.js index d95029fef8144..b691595409c62 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/any.test.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/any.test.js @@ -4,8 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - -import { functionWrapper } from '../../../test_helpers/function_wrapper'; +import { functionWrapper } from '../../../../../../src/plugins/presentation_util/common/lib'; import { any } from './any'; describe('any', () => { diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/as.test.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/as.test.js index e7c2d3047bb91..fe297e00e7b35 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/as.test.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/as.test.js @@ -5,7 +5,7 @@ * 2.0. */ -import { functionWrapper } from '../../../test_helpers/function_wrapper'; +import { functionWrapper } from '../../../../../../src/plugins/presentation_util/common/lib'; import { asFn } from './as'; describe('as', () => { diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/axis_config.test.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/axis_config.test.js index 491558486eb44..1538ee8254ec3 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/axis_config.test.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/axis_config.test.js @@ -5,7 +5,7 @@ * 2.0. */ -import { functionWrapper } from '../../../test_helpers/function_wrapper'; +import { functionWrapper } from '../../../../../../src/plugins/presentation_util/common/lib'; import { getFunctionErrors } from '../../../i18n'; import { testTable } from './__fixtures__/test_tables'; import { axisConfig } from './axisConfig'; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/case.test.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/case.test.js index adee8a56dea49..d5621943bccaf 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/case.test.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/case.test.js @@ -7,7 +7,7 @@ import { of } from 'rxjs'; import { TestScheduler } from 'rxjs/testing'; -import { functionWrapper } from '../../../test_helpers/function_wrapper'; +import { functionWrapper } from '../../../../../../src/plugins/presentation_util/common/lib'; import { caseFn } from './case'; describe('case', () => { diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/clear.test.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/clear.test.js index 43c24f10c0465..0834dc27d321b 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/clear.test.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/clear.test.js @@ -4,8 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - -import { functionWrapper } from '../../../test_helpers/function_wrapper'; +import { functionWrapper } from '../../../../../../src/plugins/presentation_util/common/lib'; import { testTable } from './__fixtures__/test_tables'; import { clear } from './clear'; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/columns.test.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/columns.test.js index d76c7a9174b81..d7f28559ee0ef 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/columns.test.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/columns.test.js @@ -5,7 +5,7 @@ * 2.0. */ -import { functionWrapper } from '../../../test_helpers/function_wrapper'; +import { functionWrapper } from '../../../../../../src/plugins/presentation_util/common/lib'; import { emptyTable, testTable } from './__fixtures__/test_tables'; import { columns } from './columns'; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/compare.test.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/compare.test.js index b0d80debf4ec3..c04f132a577fd 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/compare.test.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/compare.test.js @@ -5,7 +5,7 @@ * 2.0. */ -import { functionWrapper } from '../../../test_helpers/function_wrapper'; +import { functionWrapper } from '../../../../../../src/plugins/presentation_util/common/lib'; import { getFunctionErrors } from '../../../i18n'; import { compare } from './compare'; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/containerStyle.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/containerStyle.ts index d30324e0e2bfe..12aad5d609414 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/containerStyle.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/containerStyle.ts @@ -8,7 +8,7 @@ import { ExpressionFunctionDefinition } from 'src/plugins/expressions/common'; import { ContainerStyle, Overflow, BackgroundRepeat, BackgroundSize } from '../../../types'; import { getFunctionHelp, getFunctionErrors } from '../../../i18n'; -import { isValidUrl } from '../../../common/lib/url'; +import { isValidUrl } from '../../../../../../src/plugins/presentation_util/common/lib'; interface Output extends ContainerStyle { type: 'containerStyle'; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/container_style.test.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/container_style.test.js index b0a6ddf2caa74..7a3599f47ec86 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/container_style.test.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/container_style.test.js @@ -5,8 +5,10 @@ * 2.0. */ -import { functionWrapper } from '../../../test_helpers/function_wrapper'; -import { elasticLogo } from '../../lib/elastic_logo'; +import { + elasticLogo, + functionWrapper, +} from '../../../../../../src/plugins/presentation_util/common/lib'; import { getFunctionErrors } from '../../../i18n'; import { containerStyle } from './containerStyle'; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/context.test.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/context.test.js index 7cefb41754fd4..e4c45f228aa17 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/context.test.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/context.test.js @@ -5,7 +5,7 @@ * 2.0. */ -import { functionWrapper } from '../../../test_helpers/function_wrapper'; +import { functionWrapper } from '../../../../../../src/plugins/presentation_util/common/lib'; import { testTable, emptyTable } from './__fixtures__/test_tables'; import { context } from './context'; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/csv.test.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/csv.test.ts index 93cf07a9dd5dd..cfef618bee39d 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/csv.test.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/csv.test.ts @@ -5,11 +5,11 @@ * 2.0. */ -// @ts-expect-error untyped lib -import { functionWrapper } from '../../../test_helpers/function_wrapper'; +import { functionWrapper } from '../../../../../../src/plugins/presentation_util/common/lib'; import { getFunctionErrors } from '../../../i18n'; import { csv } from './csv'; -import { Datatable } from 'src/plugins/expressions'; +import { Datatable, ExecutionContext, SerializableState } from 'src/plugins/expressions'; +import { Adapters } from 'src/plugins/inspector'; const errors = getFunctionErrors().csv; @@ -30,43 +30,59 @@ describe('csv', () => { it('should return a datatable', () => { expect( - fn(null, { - data: `name,number + fn( + null, + { + data: `name,number one,1 two,2 fourty two,42`, - }) + }, + {} as ExecutionContext<Adapters, SerializableState> + ) ).toEqual(expected); }); it('should allow custom delimiter', () => { expect( - fn(null, { - data: `name\tnumber + fn( + null, + { + data: `name\tnumber one\t1 two\t2 fourty two\t42`, - delimiter: '\t', - }) + delimiter: '\t', + }, + {} as ExecutionContext<Adapters, SerializableState> + ) ).toEqual(expected); expect( - fn(null, { - data: `name%SPLIT%number + fn( + null, + { + data: `name%SPLIT%number one%SPLIT%1 two%SPLIT%2 fourty two%SPLIT%42`, - delimiter: '%SPLIT%', - }) + delimiter: '%SPLIT%', + }, + {} as ExecutionContext<Adapters, SerializableState> + ) ).toEqual(expected); }); it('should allow custom newline', () => { expect( - fn(null, { - data: `name,number\rone,1\rtwo,2\rfourty two,42`, - newline: '\r', - }) + fn( + null, + { + data: `name,number\rone,1\rtwo,2\rfourty two,42`, + newline: '\r', + }, + {} as ExecutionContext<Adapters, SerializableState> + ) ).toEqual(expected); }); @@ -83,10 +99,14 @@ fourty two%SPLIT%42`, }; expect( - fn(null, { - data: `foo," bar ", baz, " buz " + fn( + null, + { + data: `foo," bar ", baz, " buz " 1,2,3,4`, - }) + }, + {} as ExecutionContext<Adapters, SerializableState> + ) ).toEqual(expectedResult); }); @@ -106,22 +126,30 @@ fourty two%SPLIT%42`, }; expect( - fn(null, { - data: `foo," bar ", baz, " buz " + fn( + null, + { + data: `foo," bar ", baz, " buz " 1," best ",3, " ok" " good", bad, better , " worst " `, - }) + }, + {} as ExecutionContext<Adapters, SerializableState> + ) ).toEqual(expectedResult); }); it('throws when given invalid csv', () => { expect(() => { - fn(null, { - data: `name,number + fn( + null, + { + data: `name,number one|1 two.2 fourty two,42`, - }); + }, + {} as ExecutionContext<Adapters, SerializableState> + ); }).toThrow(new RegExp(errors.invalidInputCSV().message)); }); }); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/date.test.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/date.test.js index 08c43caaf8b9e..cd06ce5fbb463 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/date.test.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/date.test.js @@ -6,7 +6,7 @@ */ import sinon from 'sinon'; -import { functionWrapper } from '../../../test_helpers/function_wrapper'; +import { functionWrapper } from '../../../../../../src/plugins/presentation_util/common/lib'; import { getFunctionErrors } from '../../../i18n'; import { date } from './date'; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/do.test.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/do.test.js index f19318753611c..00429779e2ff1 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/do.test.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/do.test.js @@ -5,7 +5,7 @@ * 2.0. */ -import { functionWrapper } from '../../../test_helpers/function_wrapper'; +import { functionWrapper } from '../../../../../../src/plugins/presentation_util/common/lib'; import { doFn } from './do'; describe('do', () => { diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/dropdown_control.test.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/dropdown_control.test.ts index d8f2e8518daf0..254efd9f5f0d9 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/dropdown_control.test.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/dropdown_control.test.ts @@ -5,23 +5,30 @@ * 2.0. */ -// @ts-expect-error untyped local -import { functionWrapper } from '../../../test_helpers/function_wrapper'; +import { functionWrapper } from '../../../../../../src/plugins/presentation_util/common/lib'; import { testTable, relationalTable } from './__fixtures__/test_tables'; import { dropdownControl } from './dropdownControl'; +import { ExecutionContext, SerializableState } from 'src/plugins/expressions'; +import { Adapters } from 'src/plugins/inspector'; describe('dropdownControl', () => { const fn = functionWrapper(dropdownControl); it('returns a render as dropdown_filter', () => { - expect(fn(testTable, { filterColumn: 'name', valueColumn: 'name' })).toHaveProperty( - 'type', - 'render' - ); - expect(fn(testTable, { filterColumn: 'name', valueColumn: 'name' })).toHaveProperty( - 'as', - 'dropdown_filter' - ); + expect( + fn( + testTable, + { filterColumn: 'name', valueColumn: 'name' }, + {} as ExecutionContext<Adapters, SerializableState> + ) + ).toHaveProperty('type', 'render'); + expect( + fn( + testTable, + { filterColumn: 'name', valueColumn: 'name' }, + {} as ExecutionContext<Adapters, SerializableState> + ) + ).toHaveProperty('as', 'dropdown_filter'); }); describe('args', () => { @@ -32,12 +39,24 @@ describe('dropdownControl', () => { unique.find(([value, label]) => value === name) ? unique : [...unique, [name, name]], [] ); - expect(fn(testTable, { valueColumn: 'name' }).value.choices).toEqual(uniqueNames); + expect( + fn( + testTable, + { valueColumn: 'name' }, + {} as ExecutionContext<Adapters, SerializableState> + )?.value?.choices + ).toEqual(uniqueNames); }); it('returns an empty array when provided an invalid column', () => { - expect(fn(testTable, { valueColumn: 'foo' }).value.choices).toEqual([]); - expect(fn(testTable, { valueColumn: '' }).value.choices).toEqual([]); + expect( + fn(testTable, { valueColumn: 'foo' }, {} as ExecutionContext<Adapters, SerializableState>) + ?.value?.choices + ).toEqual([]); + expect( + fn(testTable, { valueColumn: '' }, {} as ExecutionContext<Adapters, SerializableState>) + ?.value?.choices + ).toEqual([]); }); }); @@ -45,7 +64,11 @@ describe('dropdownControl', () => { it('populates dropdown choices with labels from label column', () => { const expectedChoices = relationalTable.rows.map((row) => [row.id, row.name]); expect( - fn(relationalTable, { valueColumn: 'id', labelColumn: 'name' }).value.choices + fn( + relationalTable, + { valueColumn: 'id', labelColumn: 'name' }, + {} as ExecutionContext<Adapters, SerializableState> + )?.value?.choices ).toEqual(expectedChoices); }); }); @@ -53,19 +76,30 @@ describe('dropdownControl', () => { describe('filterColumn', () => { it('sets which column the filter is applied to', () => { - expect(fn(testTable, { filterColumn: 'name' }).value).toHaveProperty('column', 'name'); - expect(fn(testTable, { filterColumn: 'name', valueColumn: 'price' }).value).toHaveProperty( - 'column', - 'name' - ); + expect( + fn(testTable, { filterColumn: 'name' }, {} as ExecutionContext<Adapters, SerializableState>) + ?.value + ).toHaveProperty('column', 'name'); + expect( + fn( + testTable, + { filterColumn: 'name', valueColumn: 'price' }, + {} as ExecutionContext<Adapters, SerializableState> + )?.value + ).toHaveProperty('column', 'name'); }); it('defaults to valueColumn if not provided', () => { - expect(fn(testTable, { valueColumn: 'price' }).value).toHaveProperty('column', 'price'); + expect( + fn(testTable, { valueColumn: 'price' }, {} as ExecutionContext<Adapters, SerializableState>) + ?.value + ).toHaveProperty('column', 'price'); }); it('sets column to undefined if no args are provided', () => { - expect(fn(testTable).value).toHaveProperty('column', undefined); + expect( + fn(testTable, {}, {} as ExecutionContext<Adapters, SerializableState>)?.value + ).toHaveProperty('column', undefined); }); }); }); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/eq.test.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/eq.test.js index 5f8d9e042125f..5e710fc109396 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/eq.test.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/eq.test.js @@ -5,7 +5,7 @@ * 2.0. */ -import { functionWrapper } from '../../../test_helpers/function_wrapper'; +import { functionWrapper } from '../../../../../../src/plugins/presentation_util/common/lib'; import { eq } from './eq'; describe('eq', () => { diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/exactly.test.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/exactly.test.js index 10781a7af452d..9d3dcb6a99167 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/exactly.test.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/exactly.test.js @@ -5,7 +5,7 @@ * 2.0. */ -import { functionWrapper } from '../../../test_helpers/function_wrapper'; +import { functionWrapper } from '../../../../../../src/plugins/presentation_util/common/lib'; import { emptyFilter } from './__fixtures__/test_filters'; import { exactly } from './exactly'; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/filterrows.test.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/filterrows.test.js index 8c328e3d8adf6..edc2c1db18f64 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/filterrows.test.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/filterrows.test.js @@ -7,7 +7,7 @@ import { of } from 'rxjs'; import { TestScheduler } from 'rxjs/testing'; -import { functionWrapper } from '../../../test_helpers/function_wrapper'; +import { functionWrapper } from '../../../../../../src/plugins/presentation_util/common/lib'; import { testTable } from './__fixtures__/test_tables'; import { filterrows } from './filterrows'; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/formatdate.test.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/formatdate.test.js index 6fda32dfef51a..e725dccc8ca34 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/formatdate.test.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/formatdate.test.js @@ -5,7 +5,7 @@ * 2.0. */ -import { functionWrapper } from '../../../test_helpers/function_wrapper'; +import { functionWrapper } from '../../../../../../src/plugins/presentation_util/common/lib'; import { formatdate } from './formatdate'; describe('formatdate', () => { diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/formatnumber.test.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/formatnumber.test.js index 37d3d2d905e67..e957bf115198f 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/formatnumber.test.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/formatnumber.test.js @@ -5,7 +5,7 @@ * 2.0. */ -import { functionWrapper } from '../../../test_helpers/function_wrapper'; +import { functionWrapper } from '../../../../../../src/plugins/presentation_util/common/lib'; import { formatnumber } from './formatnumber'; describe('formatnumber', () => { diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/getCell.test.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/getCell.test.js index 2dda4d8f4258e..a556c2ddeb48a 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/getCell.test.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/getCell.test.js @@ -5,7 +5,7 @@ * 2.0. */ -import { functionWrapper } from '../../../test_helpers/function_wrapper'; +import { functionWrapper } from '../../../../../../src/plugins/presentation_util/common/lib'; import { getFunctionErrors } from '../../../i18n'; import { emptyTable, testTable } from './__fixtures__/test_tables'; import { getCell } from './getCell'; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/gt.test.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/gt.test.js index 576d2a54dd59b..53675fca2b3ae 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/gt.test.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/gt.test.js @@ -5,7 +5,7 @@ * 2.0. */ -import { functionWrapper } from '../../../test_helpers/function_wrapper'; +import { functionWrapper } from '../../../../../../src/plugins/presentation_util/common/lib'; import { gt } from './gt'; describe('gt', () => { diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/gte.test.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/gte.test.js index 174f617f47a8c..aefb2ccf926ae 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/gte.test.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/gte.test.js @@ -5,7 +5,7 @@ * 2.0. */ -import { functionWrapper } from '../../../test_helpers/function_wrapper'; +import { functionWrapper } from '../../../../../../src/plugins/presentation_util/common/lib'; import { gte } from './gte'; describe('gte', () => { diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/head.test.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/head.test.js index c25d0f7ae727f..4721eaf6cb530 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/head.test.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/head.test.js @@ -5,7 +5,7 @@ * 2.0. */ -import { functionWrapper } from '../../../test_helpers/function_wrapper'; +import { functionWrapper } from '../../../../../../src/plugins/presentation_util/common/lib'; import { emptyTable, testTable } from './__fixtures__/test_tables'; import { head } from './head'; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/if.test.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/if.test.js index cab331807e44c..df576a6a2507f 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/if.test.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/if.test.js @@ -7,7 +7,7 @@ import { of } from 'rxjs'; import { TestScheduler } from 'rxjs/testing'; -import { functionWrapper } from '../../../test_helpers/function_wrapper'; +import { functionWrapper } from '../../../../../../src/plugins/presentation_util/common/lib'; import { ifFn } from './if'; describe('if', () => { diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/image.test.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/image.test.js index cd0809d9b30a2..45b26cd25937d 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/image.test.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/image.test.js @@ -6,14 +6,14 @@ */ import expect from '@kbn/expect'; -// import { functionWrapper } from '../../../test_helpers/function_wrapper'; -import { elasticLogo } from '../../lib/elastic_logo'; -import { elasticOutline } from '../../lib/elastic_outline'; +import { + elasticLogo, + elasticOutline, +} from '../../../../../../src/plugins/presentation_util/common/lib'; // import { image } from './image'; // TODO: the test was not running and is not up to date describe.skip('image', () => { - // const fn = functionWrapper(image); const fn = jest.fn(); it('returns an image object using a dataUrl', () => { diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/image.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/image.ts index b4d067280cb69..c3e64e48b23fc 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/image.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/image.ts @@ -7,9 +7,10 @@ import { ExpressionFunctionDefinition } from 'src/plugins/expressions/common'; import { getFunctionHelp, getFunctionErrors } from '../../../i18n'; - -import { resolveWithMissingImage } from '../../../common/lib/resolve_dataurl'; -import { elasticLogo } from '../../lib/elastic_logo'; +import { + elasticLogo, + resolveWithMissingImage, +} from '../../../../../../src/plugins/presentation_util/common/lib'; export enum ImageMode { CONTAIN = 'contain', diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/index.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/index.ts index 5c4d1d55cff04..9da646e695861 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/index.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/index.ts @@ -43,7 +43,6 @@ import { replace } from './replace'; import { rounddate } from './rounddate'; import { rowCount } from './rowCount'; import { repeatImage } from './repeat_image'; -import { revealImage } from './revealImage'; import { seriesStyle } from './seriesStyle'; import { shape } from './shape'; import { sort } from './sort'; @@ -94,7 +93,6 @@ export const functions = [ render, repeatImage, replace, - revealImage, rounddate, rowCount, seriesStyle, diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/join_rows.test.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/join_rows.test.js index 12b1002d1e377..94fef857983bc 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/join_rows.test.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/join_rows.test.js @@ -5,7 +5,7 @@ * 2.0. */ -import { functionWrapper } from '../../../test_helpers/function_wrapper'; +import { functionWrapper } from '../../../../../../src/plugins/presentation_util/common/lib'; import { getFunctionErrors } from '../../../i18n'; import { testTable } from './__fixtures__/test_tables'; import { joinRows } from './join_rows'; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/lt.test.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/lt.test.js index 8f16e446997ea..1ecfca9fc2f94 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/lt.test.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/lt.test.js @@ -5,7 +5,7 @@ * 2.0. */ -import { functionWrapper } from '../../../test_helpers/function_wrapper'; +import { functionWrapper } from '../../../../../../src/plugins/presentation_util/common/lib'; import { lt } from './lt'; describe('lt', () => { diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/lte.test.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/lte.test.js index 954b30e8c3c92..f32d2d23027c3 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/lte.test.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/lte.test.js @@ -5,7 +5,7 @@ * 2.0. */ -import { functionWrapper } from '../../../test_helpers/function_wrapper'; +import { functionWrapper } from '../../../../../../src/plugins/presentation_util/common/lib'; import { lte } from './lte'; describe('lte', () => { diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/metric.test.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/metric.test.js index a99d4823e5930..3f2d0ad2cb76e 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/metric.test.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/metric.test.js @@ -5,7 +5,7 @@ * 2.0. */ -import { functionWrapper } from '../../../test_helpers/function_wrapper'; +import { functionWrapper } from '../../../../../../src/plugins/presentation_util/common/lib'; import { fontStyle } from './__fixtures__/test_styles'; import { metric } from './metric'; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/neq.test.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/neq.test.js index 0a1980760cd09..88c7a5c18bc7a 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/neq.test.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/neq.test.js @@ -5,7 +5,7 @@ * 2.0. */ -import { functionWrapper } from '../../../test_helpers/function_wrapper'; +import { functionWrapper } from '../../../../../../src/plugins/presentation_util/common/lib'; import { neq } from './neq'; describe('neq', () => { diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/ply.test.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/ply.test.js index 5bf100eb90f4c..282cb2460d61c 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/ply.test.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/ply.test.js @@ -7,7 +7,7 @@ import { of } from 'rxjs'; import { TestScheduler } from 'rxjs/testing'; -import { functionWrapper } from '../../../test_helpers/function_wrapper'; +import { functionWrapper } from '../../../../../../src/plugins/presentation_util/common/lib'; import { getFunctionErrors } from '../../../i18n'; import { testTable } from './__fixtures__/test_tables'; import { ply } from './ply'; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/progress.test.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/progress.test.js index f516cbbe5258f..6438e2a4d19c0 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/progress.test.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/progress.test.js @@ -6,7 +6,7 @@ */ import expect from '@kbn/expect'; -import { functionWrapper } from '../../../test_helpers/function_wrapper'; +import { functionWrapper } from '../../../../../../src/plugins/presentation_util/common/lib'; import { getFunctionErrors } from '../../../i18n'; import { progress } from './progress'; import { fontStyle } from './__fixtures__/test_styles'; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/render.test.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/render.test.js index 6a91f4c280692..3248af5504093 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/render.test.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/render.test.js @@ -5,7 +5,7 @@ * 2.0. */ -import { functionWrapper } from '../../../test_helpers/function_wrapper'; +import { functionWrapper } from '../../../../../../src/plugins/presentation_util/common/lib'; import { DEFAULT_ELEMENT_CSS } from '../../../common/lib/constants'; import { testTable } from './__fixtures__/test_tables'; import { fontStyle, containerStyle } from './__fixtures__/test_styles'; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/render.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/render.ts index cc7fc00a5df1f..7a52833693cc6 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/render.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/render.ts @@ -49,7 +49,6 @@ export function render(): ExpressionFunctionDefinition< 'plot', 'progress', 'repeatImage', - 'revealImage', 'shape', 'table', 'time_filter', diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/repeat_image.test.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/repeat_image.test.js index f95d3d0ec03d0..97f0552721ccf 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/repeat_image.test.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/repeat_image.test.js @@ -5,9 +5,11 @@ * 2.0. */ -import { functionWrapper } from '../../../test_helpers/function_wrapper'; -import { elasticOutline } from '../../lib/elastic_outline'; -import { elasticLogo } from '../../lib/elastic_logo'; +import { + elasticLogo, + elasticOutline, + functionWrapper, +} from '../../../../../../src/plugins/presentation_util/common/lib'; import { repeatImage } from './repeat_image'; describe('repeatImage', () => { diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/repeat_image.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/repeat_image.ts index 6e62139e4da0d..904b2478760ab 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/repeat_image.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/repeat_image.ts @@ -6,8 +6,10 @@ */ import { ExpressionFunctionDefinition } from 'src/plugins/expressions/common'; -import { resolveWithMissingImage } from '../../../common/lib/resolve_dataurl'; -import { elasticOutline } from '../../lib/elastic_outline'; +import { + elasticOutline, + resolveWithMissingImage, +} from '../../../../../../src/plugins/presentation_util/common/lib'; import { Render } from '../../../types'; import { getFunctionHelp } from '../../../i18n'; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/replace.test.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/replace.test.js index 26e44f48f685d..6025ff354cd8d 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/replace.test.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/replace.test.js @@ -5,7 +5,7 @@ * 2.0. */ -import { functionWrapper } from '../../../test_helpers/function_wrapper'; +import { functionWrapper } from '../../../../../../src/plugins/presentation_util/common/lib'; import { replace } from './replace'; describe('replace', () => { diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/reveal_image.test.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/reveal_image.test.js deleted file mode 100644 index d97168c3aacc1..0000000000000 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/reveal_image.test.js +++ /dev/null @@ -1,88 +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 { functionWrapper } from '../../../test_helpers/function_wrapper'; -import { elasticOutline } from '../../lib/elastic_outline'; -import { elasticLogo } from '../../lib/elastic_logo'; -import { getFunctionErrors } from '../../../i18n'; -import { revealImage } from './revealImage'; - -const errors = getFunctionErrors().revealImage; - -describe('revealImage', () => { - const fn = functionWrapper(revealImage); - - it('returns a render as revealImage', () => { - const result = fn(0.5); - expect(result).toHaveProperty('type', 'render'); - expect(result).toHaveProperty('as', 'revealImage'); - }); - - describe('context', () => { - it('throws when context is not a number between 0 and 1', () => { - expect(() => { - fn(10, { - image: elasticLogo, - emptyImage: elasticOutline, - origin: 'top', - }); - }).toThrow(new RegExp(errors.invalidPercent(10).message)); - - expect(() => { - fn(-0.1, { - image: elasticLogo, - emptyImage: elasticOutline, - origin: 'top', - }); - }).toThrow(new RegExp(errors.invalidPercent(-0.1).message)); - }); - }); - - describe('args', () => { - describe('image', () => { - it('sets the image', () => { - const result = fn(0.89, { image: elasticLogo }).value; - expect(result).toHaveProperty('image', elasticLogo); - }); - - it('defaults to the Elastic outline logo', () => { - const result = fn(0.89).value; - expect(result).toHaveProperty('image', elasticOutline); - }); - }); - - describe('emptyImage', () => { - it('sets the background image', () => { - const result = fn(0, { emptyImage: elasticLogo }).value; - expect(result).toHaveProperty('emptyImage', elasticLogo); - }); - - it('sets emptyImage to null', () => { - const result = fn(0).value; - expect(result).toHaveProperty('emptyImage', null); - }); - }); - - describe('origin', () => { - it('sets which side to start the reveal from', () => { - let result = fn(1, { origin: 'top' }).value; - expect(result).toHaveProperty('origin', 'top'); - result = fn(1, { origin: 'left' }).value; - expect(result).toHaveProperty('origin', 'left'); - result = fn(1, { origin: 'bottom' }).value; - expect(result).toHaveProperty('origin', 'bottom'); - result = fn(1, { origin: 'right' }).value; - expect(result).toHaveProperty('origin', 'right'); - }); - - it('defaults to bottom', () => { - const result = fn(1).value; - expect(result).toHaveProperty('origin', 'bottom'); - }); - }); - }); -}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/rounddate.test.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/rounddate.test.js index f2c2f8af50a81..0ef832d973271 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/rounddate.test.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/rounddate.test.js @@ -5,7 +5,7 @@ * 2.0. */ -import { functionWrapper } from '../../../test_helpers/function_wrapper'; +import { functionWrapper } from '../../../../../../src/plugins/presentation_util/common/lib'; import { rounddate } from './rounddate'; describe('rounddate', () => { diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/rowCount.test.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/rowCount.test.js index b47bb662f43d4..7a32849e9161a 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/rowCount.test.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/rowCount.test.js @@ -5,7 +5,7 @@ * 2.0. */ -import { functionWrapper } from '../../../test_helpers/function_wrapper'; +import { functionWrapper } from '../../../../../../src/plugins/presentation_util/common/lib'; import { emptyTable, testTable } from './__fixtures__/test_tables'; import { rowCount } from './rowCount'; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/series_style.test.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/series_style.test.js index ebd1f370db343..6e91b84d82b6f 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/series_style.test.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/series_style.test.js @@ -5,7 +5,7 @@ * 2.0. */ -import { functionWrapper } from '../../../test_helpers/function_wrapper'; +import { functionWrapper } from '../../../../../../src/plugins/presentation_util/common/lib'; import { seriesStyle } from './seriesStyle'; describe('seriesStyle', () => { diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/sort.test.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/sort.test.js index 97f8b20c57efa..f59c517c91d88 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/sort.test.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/sort.test.js @@ -5,7 +5,7 @@ * 2.0. */ -import { functionWrapper } from '../../../test_helpers/function_wrapper'; +import { functionWrapper } from '../../../../../../src/plugins/presentation_util/common/lib'; import { testTable } from './__fixtures__/test_tables'; import { sort } from './sort'; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/staticColumn.test.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/staticColumn.test.js index 3a3bb46e4d395..0260c9e77c424 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/staticColumn.test.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/staticColumn.test.js @@ -5,7 +5,7 @@ * 2.0. */ -import { functionWrapper } from '../../../test_helpers/function_wrapper'; +import { functionWrapper } from '../../../../../../src/plugins/presentation_util/common/lib'; import { testTable, emptyTable } from './__fixtures__/test_tables'; import { staticColumn } from './staticColumn'; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/string.test.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/string.test.js index c598c036bcaa9..48af07b7cd880 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/string.test.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/string.test.js @@ -5,7 +5,7 @@ * 2.0. */ -import { functionWrapper } from '../../../test_helpers/function_wrapper'; +import { functionWrapper } from '../../../../../../src/plugins/presentation_util/common/lib'; import { string } from './string'; describe('string', () => { diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/switch.test.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/switch.test.js index 7a6d483d6c72b..c6f592889c991 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/switch.test.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/switch.test.js @@ -7,7 +7,7 @@ import { of } from 'rxjs'; import { TestScheduler } from 'rxjs/testing'; -import { functionWrapper } from '../../../test_helpers/function_wrapper'; +import { functionWrapper } from '../../../../../../src/plugins/presentation_util/common/lib'; import { switchFn } from './switch'; describe('switch', () => { diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/table.test.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/table.test.js index 2eff610ac8ee5..42e5150b03637 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/table.test.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/table.test.js @@ -5,7 +5,7 @@ * 2.0. */ -import { functionWrapper } from '../../../test_helpers/function_wrapper'; +import { functionWrapper } from '../../../../../../src/plugins/presentation_util/common/lib'; import { testTable } from './__fixtures__/test_tables'; import { fontStyle } from './__fixtures__/test_styles'; import { table } from './table'; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/tail.test.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/tail.test.js index 93461a2ef4575..420489754d20e 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/tail.test.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/tail.test.js @@ -5,7 +5,7 @@ * 2.0. */ -import { functionWrapper } from '../../../test_helpers/function_wrapper'; +import { functionWrapper } from '../../../../../../src/plugins/presentation_util/common/lib'; import { emptyTable, testTable } from './__fixtures__/test_tables'; import { tail } from './tail'; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/timefilter.test.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/timefilter.test.js index e45a11b786d19..f45ec981b1a8a 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/timefilter.test.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/timefilter.test.js @@ -6,7 +6,7 @@ */ import sinon from 'sinon'; -import { functionWrapper } from '../../../test_helpers/function_wrapper'; +import { functionWrapper } from '../../../../../../src/plugins/presentation_util/common/lib'; import { getFunctionErrors } from '../../../i18n'; import { emptyFilter } from './__fixtures__/test_filters'; import { timefilter } from './timefilter'; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/timefilter_control.test.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/timefilter_control.test.js index cf2c316507c35..b4a476807b7ee 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/timefilter_control.test.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/timefilter_control.test.js @@ -5,7 +5,7 @@ * 2.0. */ -import { functionWrapper } from '../../../test_helpers/function_wrapper'; +import { functionWrapper } from '../../../../../../src/plugins/presentation_util/common/lib'; import { timefilterControl } from './timefilterControl'; describe('timefilterControl', () => { diff --git a/x-pack/plugins/canvas/canvas_plugin_src/lib/elastic_logo.ts b/x-pack/plugins/canvas/canvas_plugin_src/lib/elastic_logo.ts deleted file mode 100644 index 1ade7f1f269c0..0000000000000 --- a/x-pack/plugins/canvas/canvas_plugin_src/lib/elastic_logo.ts +++ /dev/null @@ -1,2 +0,0 @@ -/* eslint-disable */ -export const elasticLogo = 'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjxzdmcKICAgeG1sbnM6ZGM9Imh0dHA6Ly9wdXJsLm9yZy9kYy9lbGVtZW50cy8xLjEvIgogICB4bWxuczpjYz0iaHR0cDovL2NyZWF0aXZlY29tbW9ucy5vcmcvbnMjIgogICB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiCiAgIHhtbG5zOnN2Zz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciCiAgIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKICAgdmlld0JveD0iMCAwIDI3MC42MDAwMSAyNjkuNTQ2NjYiCiAgIGhlaWdodD0iMjY5LjU0NjY2IgogICB3aWR0aD0iMjcwLjYwMDAxIgogICB4bWw6c3BhY2U9InByZXNlcnZlIgogICBpZD0ic3ZnMiIKICAgdmVyc2lvbj0iMS4xIj48bWV0YWRhdGEKICAgICBpZD0ibWV0YWRhdGE4Ij48cmRmOlJERj48Y2M6V29yawogICAgICAgICByZGY6YWJvdXQ9IiI+PGRjOmZvcm1hdD5pbWFnZS9zdmcreG1sPC9kYzpmb3JtYXQ+PGRjOnR5cGUKICAgICAgICAgICByZGY6cmVzb3VyY2U9Imh0dHA6Ly9wdXJsLm9yZy9kYy9kY21pdHlwZS9TdGlsbEltYWdlIiAvPjwvY2M6V29yaz48L3JkZjpSREY+PC9tZXRhZGF0YT48ZGVmcwogICAgIGlkPSJkZWZzNiIgLz48ZwogICAgIHRyYW5zZm9ybT0ibWF0cml4KDEuMzMzMzMzMywwLDAsLTEuMzMzMzMzMywwLDI2OS41NDY2NykiCiAgICAgaWQ9ImcxMCI+PGcKICAgICAgIHRyYW5zZm9ybT0ic2NhbGUoMC4xKSIKICAgICAgIGlkPSJnMTIiPjxwYXRoCiAgICAgICAgIGlkPSJwYXRoMTQiCiAgICAgICAgIHN0eWxlPSJmaWxsOiNmZmZmZmY7ZmlsbC1vcGFjaXR5OjE7ZmlsbC1ydWxlOm5vbnplcm87c3Ryb2tlOm5vbmUiCiAgICAgICAgIGQ9Im0gMjAyOS40OCw5NjIuNDQxIGMgMCwxNzAuMDk5IC0xMDUuNDYsMzE4Ljc5OSAtMjY0LjE3LDM3Ni42NTkgNi45OCwzNS44NiAxMC42Miw3MS43MSAxMC42MiwxMDkuMDUgMCwzMTYuMTkgLTI1Ny4yNCw1NzMuNDMgLTU3My40Nyw1NzMuNDMgLTE4NC43MiwwIC0zNTYuNTU4LC04OC41OSAtNDY0LjUzLC0yMzcuODUgLTUzLjA5LDQxLjE4IC0xMTguMjg1LDYzLjc1IC0xODYuMzA1LDYzLjc1IC0xNjcuODM2LDAgLTMwNC4zODMsLTEzNi41NCAtMzA0LjM4MywtMzA0LjM4IDAsLTM3LjA4IDYuNjE3LC03Mi41OCAxOS4wMzEsLTEwNi4wOCBDIDEwOC40ODgsMTM4MC4wOSAwLDEyMjcuODkgMCwxMDU4Ljg4IDAsODg3LjkxIDEwNS45NzcsNzM4LjUzOSAyNjUuMzk4LDY4MS4wOSBjIC02Ljc2OSwtMzUuNDQyIC0xMC40NiwtNzIuMDIgLTEwLjQ2LC0xMDkgQyAyNTQuOTM4LDI1Ni42MjEgNTExLjU2NiwwIDgyNy4wMjcsMCAxMDEyLjIsMCAxMTgzLjk0LDg4Ljk0MTQgMTI5MS4zLDIzOC44MzIgYyA1My40NSwtNDEuOTYxIDExOC44LC02NC45OTIgMTg2LjU2LC02NC45OTIgMTY3LjgzLDAgMzA0LjM4LDEzNi40OTIgMzA0LjM4LDMwNC4zMzIgMCwzNy4wNzggLTYuNjIsNzIuNjI5IC0xOS4wMywxMDYuMTI5IDE1Ny43OCw1Ni44NzkgMjY2LjI3LDIwOS4xMjkgMjY2LjI3LDM3OC4xNCIgLz48cGF0aAogICAgICAgICBpZD0icGF0aDE2IgogICAgICAgICBzdHlsZT0iZmlsbDojZmFjZjA5O2ZpbGwtb3BhY2l0eToxO2ZpbGwtcnVsZTpub256ZXJvO3N0cm9rZTpub25lIgogICAgICAgICBkPSJtIDc5Ny44OTgsMTE1MC45MyA0NDQuMDcyLC0yMDIuNDUgNDQ4LjA1LDM5Mi41OCBjIDYuNDksMzIuMzkgOS42Niw2NC42NyA5LjY2LDk4LjQ2IDAsMjc2LjIzIC0yMjQuNjgsNTAwLjk1IC01MDAuOSw1MDAuOTUgLTE2NS4yNCwwIC0zMTkuMzcsLTgxLjM2IC00MTMuMDUzLC0yMTcuNzkgbCAtNzQuNTI0LC0zODYuNjQgODYuNjk1LC0xODUuMTEiIC8+PHBhdGgKICAgICAgICAgaWQ9InBhdGgxOCIKICAgICAgICAgc3R5bGU9ImZpbGw6IzQ5YzFhZTtmaWxsLW9wYWNpdHk6MTtmaWxsLXJ1bGU6bm9uemVybztzdHJva2U6bm9uZSIKICAgICAgICAgZD0ibSAzMzguMjIzLDY4MC42NzIgYyAtNi40ODksLTMyLjM4MyAtOS44MDksLTY1Ljk4MSAtOS44MDksLTk5Ljk3MyAwLC0yNzYuOTI5IDIyNS4zMzYsLTUwMi4yNTc2IDUwMi4zMTMsLTUwMi4yNTc2IDE2Ni41OTMsMCAzMjEuNDczLDgyLjExNzYgNDE1LjAxMywyMTkuOTQ5NiBsIDczLjk3LDM4NS4zNDcgLTk4LjcyLDE4OC42MjEgTCA3NzUuMTU2LDEwNzUuNTcgMzM4LjIyMyw2ODAuNjcyIiAvPjxwYXRoCiAgICAgICAgIGlkPSJwYXRoMjAiCiAgICAgICAgIHN0eWxlPSJmaWxsOiNlZjI5OWI7ZmlsbC1vcGFjaXR5OjE7ZmlsbC1ydWxlOm5vbnplcm87c3Ryb2tlOm5vbmUiCiAgICAgICAgIGQ9Im0gMzM1LjQxLDE0NDkuMTggMzA0LjMzMiwtNzEuODYgNjYuNjgsMzQ2LjAyIGMgLTQxLjU4NiwzMS43OCAtOTIuOTMsNDkuMTggLTE0NS43MzEsNDkuMTggLTEzMi4yNSwwIC0yMzkuODEyLC0xMDcuNjEgLTIzOS44MTIsLTIzOS44NyAwLC0yOS4yMSA0Ljg3OSwtNTcuMjIgMTQuNTMxLC04My40NyIgLz48cGF0aAogICAgICAgICBpZD0icGF0aDIyIgogICAgICAgICBzdHlsZT0iZmlsbDojNGNhYmU0O2ZpbGwtb3BhY2l0eToxO2ZpbGwtcnVsZTpub256ZXJvO3N0cm9rZTpub25lIgogICAgICAgICBkPSJNIDMwOC45OTIsMTM3Ni43IEMgMTczLjAyLDEzMzEuNjQgNzguNDgwNSwxMjAxLjMgNzguNDgwNSwxMDU3LjkzIDc4LjQ4MDUsOTE4LjM0IDE2NC44Miw3OTMuNjggMjk0LjQwNiw3NDQuMzUyIGwgNDI2Ljk4MSwzODUuOTM4IC03OC4zOTUsMTY3LjUxIC0zMzQsNzguOSIgLz48cGF0aAogICAgICAgICBpZD0icGF0aDI0IgogICAgICAgICBzdHlsZT0iZmlsbDojODVjZTI2O2ZpbGwtb3BhY2l0eToxO2ZpbGwtcnVsZTpub256ZXJvO3N0cm9rZTpub25lIgogICAgICAgICBkPSJtIDEzMjMuOCwyOTguNDEgYyA0MS43NCwtMzIuMDkgOTIuODMsLTQ5LjU5IDE0NC45OCwtNDkuNTkgMTMyLjI1LDAgMjM5LjgxLDEwNy41NTkgMjM5LjgxLDIzOS44MjEgMCwyOS4xNiAtNC44OCw1Ny4xNjggLTE0LjUzLDgzLjQxOCBsIC0zMDQuMDgsNzEuMTYgLTY2LjE4LC0zNDQuODA5IiAvPjxwYXRoCiAgICAgICAgIGlkPSJwYXRoMjYiCiAgICAgICAgIHN0eWxlPSJmaWxsOiMzMTc3YTc7ZmlsbC1vcGFjaXR5OjE7ZmlsbC1ydWxlOm5vbnplcm87c3Ryb2tlOm5vbmUiCiAgICAgICAgIGQ9Im0gMTM4NS42Nyw3MjIuOTMgMzM0Ljc2LC03OC4zMDEgYyAxMzYuMDIsNDQuOTYxIDIzMC41NiwxNzUuMzUxIDIzMC41NiwzMTguNzYyIDAsMTM5LjMzOSAtODYuNTQsMjYzLjg1OSAtMjE2LjM4LDMxMy4wMzkgbCAtNDM3Ljg0LC0zODMuNTkgODguOSwtMTY5LjkxIiAvPjwvZz48L2c+PC9zdmc+'; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/lib/elastic_outline.ts b/x-pack/plugins/canvas/canvas_plugin_src/lib/elastic_outline.ts deleted file mode 100644 index 7271f5b32d547..0000000000000 --- a/x-pack/plugins/canvas/canvas_plugin_src/lib/elastic_outline.ts +++ /dev/null @@ -1,2 +0,0 @@ -/* eslint-disable */ -export const elasticOutline = 'data:image/svg+xml,%3C%3Fxml%20version%3D%221.0%22%20encoding%3D%22utf-8%22%3F%3E%0A%3Csvg%20viewBox%3D%22-3.948730230331421%20-1.7549896240234375%20245.25946044921875%20241.40370178222656%22%20width%3D%22245.25946044921875%22%20height%3D%22241.40370178222656%22%20style%3D%22enable-background%3Anew%200%200%20686.2%20235.7%3B%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%3Cdefs%3E%0A%20%20%20%20%3Cstyle%20type%3D%22text%2Fcss%22%3E%0A%09.st0%7Bfill%3A%232D2D2D%3B%7D%0A%3C%2Fstyle%3E%0A%20%20%3C%2Fdefs%3E%0A%20%20%3Cg%20transform%3D%22matrix%281%2C%200%2C%200%2C%201%2C%200%2C%200%29%22%3E%0A%20%20%20%20%3Cg%3E%0A%20%20%20%20%20%20%3Cpath%20class%3D%22st0%22%20d%3D%22M329.4%2C160.3l4.7-0.5l0.3%2C9.6c-12.4%2C1.7-23%2C2.6-31.8%2C2.6c-11.7%2C0-20-3.4-24.9-10.2%26%2310%3B%26%239%3B%26%239%3B%26%239%3Bc-4.9-6.8-7.3-17.4-7.3-31.7c0-28.6%2C11.4-42.9%2C34.1-42.9c11%2C0%2C19.2%2C3.1%2C24.6%2C9.2c5.4%2C6.1%2C8.1%2C15.8%2C8.1%2C28.9l-0.7%2C9.3h-53.8%26%2310%3B%26%239%3B%26%239%3B%26%239%3Bc0%2C9%2C1.6%2C15.7%2C4.9%2C20c3.3%2C4.3%2C8.9%2C6.5%2C17%2C6.5C312.8%2C161.2%2C321.1%2C160.9%2C329.4%2C160.3z%20M325%2C124.9c0-10-1.6-17.1-4.8-21.2%26%2310%3B%26%239%3B%26%239%3B%26%239%3Bc-3.2-4.1-8.4-6.2-15.6-6.2c-7.2%2C0-12.7%2C2.2-16.3%2C6.5c-3.6%2C4.3-5.5%2C11.3-5.6%2C20.9H325z%22%2F%3E%0A%20%20%20%20%20%20%3Cpath%20class%3D%22st0%22%20d%3D%22M354.3%2C171.4V64h12.2v107.4H354.3z%22%2F%3E%0A%20%20%20%20%20%20%3Cpath%20class%3D%22st0%22%20d%3D%22M443.5%2C113.5v41.1c0%2C4.1%2C10.1%2C3.9%2C10.1%2C3.9l-0.6%2C10.8c-8.6%2C0-15.7%2C0.7-20-3.4c-9.8%2C4.3-19.5%2C6.1-29.3%2C6.1%26%2310%3B%26%239%3B%26%239%3B%26%239%3Bc-7.5%2C0-13.2-2.1-17.1-6.4c-3.9-4.2-5.9-10.3-5.9-18.3c0-7.9%2C2-13.8%2C6-17.5c4-3.7%2C10.3-6.1%2C18.9-6.9l25.6-2.4v-7%26%2310%3B%26%239%3B%26%239%3B%26%239%3Bc0-5.5-1.2-9.5-3.6-11.9c-2.4-2.4-5.7-3.6-9.8-3.6l-32.1%2C0V87.2h31.3c9.2%2C0%2C15.9%2C2.1%2C20.1%2C6.4C441.4%2C97.8%2C443.5%2C104.5%2C443.5%2C113.5%26%2310%3B%26%239%3B%26%239%3B%26%239%3Bz%20M393.3%2C146.7c0%2C10%2C4.1%2C15%2C12.4%2C15c7.4%2C0%2C14.7-1.2%2C21.8-3.7l3.7-1.3v-26.9l-24.1%2C2.3c-4.9%2C0.4-8.4%2C1.8-10.6%2C4.2%26%2310%3B%26%239%3B%26%239%3B%26%239%3BC394.4%2C138.7%2C393.3%2C142.2%2C393.3%2C146.7z%22%2F%3E%0A%20%20%20%20%20%20%3Cpath%20class%3D%22st0%22%20d%3D%22M491.2%2C98.2c-11.8%2C0-17.8%2C4.1-17.8%2C12.4c0%2C3.8%2C1.4%2C6.5%2C4.1%2C8.1c2.7%2C1.6%2C8.9%2C3.2%2C18.6%2C4.9%26%2310%3B%26%239%3B%26%239%3B%26%239%3Bc9.7%2C1.7%2C16.5%2C4%2C20.5%2C7.1c4%2C3%2C6%2C8.7%2C6%2C17.1c0%2C8.4-2.7%2C14.5-8.1%2C18.4c-5.4%2C3.9-13.2%2C5.9-23.6%2C5.9c-6.7%2C0-29.2-2.5-29.2-2.5%26%2310%3B%26%239%3B%26%239%3B%26%239%3Bl0.7-10.6c12.9%2C1.2%2C22.3%2C2.2%2C28.6%2C2.2c6.3%2C0%2C11.1-1%2C14.4-3c3.3-2%2C5-5.4%2C5-10.1c0-4.7-1.4-7.9-4.2-9.6c-2.8-1.7-9-3.3-18.6-4.8%26%2310%3B%26%239%3B%26%239%3B%26%239%3Bc-9.6-1.5-16.4-3.7-20.4-6.7c-4-2.9-6-8.4-6-16.3c0-7.9%2C2.8-13.8%2C8.4-17.6c5.6-3.8%2C12.6-5.7%2C20.9-5.7c6.6%2C0%2C29.6%2C1.7%2C29.6%2C1.7%26%2310%3B%26%239%3B%26%239%3B%26%239%3Bv10.7C508.1%2C99%2C498.2%2C98.2%2C491.2%2C98.2z%22%2F%3E%0A%20%20%20%20%20%20%3Cpath%20class%3D%22st0%22%20d%3D%22M581.7%2C99.5h-25.9v39c0%2C9.3%2C0.7%2C15.5%2C2%2C18.4c1.4%2C2.9%2C4.6%2C4.4%2C9.7%2C4.4l14.5-1l0.8%2C10.1%26%2310%3B%26%239%3B%26%239%3B%26%239%3Bc-7.3%2C1.2-12.8%2C1.8-16.6%2C1.8c-8.5%2C0-14.3-2.1-17.6-6.2c-3.3-4.1-4.9-12-4.9-23.6V99.5h-11.6V88.9h11.6V63.9h12.1v24.9h25.9V99.5z%22%2F%3E%0A%20%20%20%20%20%20%3Cpath%20class%3D%22st0%22%20d%3D%22M598.7%2C78.4V64.3h12.2v14.2H598.7z%20M598.7%2C171.4V88.9h12.2v82.5H598.7z%22%2F%3E%0A%20%20%20%20%20%20%3Cpath%20class%3D%22st0%22%20d%3D%22M663.8%2C87.2c3.6%2C0%2C9.7%2C0.7%2C18.3%2C2l3.9%2C0.5l-0.5%2C9.9c-8.7-1-15.1-1.5-19.2-1.5c-9.2%2C0-15.5%2C2.2-18.8%2C6.6%26%2310%3B%26%239%3B%26%239%3B%26%239%3Bc-3.3%2C4.4-5%2C12.6-5%2C24.5c0%2C11.9%2C1.5%2C20.2%2C4.6%2C24.9c3.1%2C4.7%2C9.5%2C7%2C19.3%2C7l19.2-1.5l0.5%2C10.1c-10.1%2C1.5-17.7%2C2.3-22.7%2C2.3%26%2310%3B%26%239%3B%26%239%3B%26%239%3Bc-12.7%2C0-21.5-3.3-26.3-9.8c-4.8-6.5-7.3-17.5-7.3-33c0-15.5%2C2.6-26.4%2C7.8-32.6C643%2C90.4%2C651.7%2C87.2%2C663.8%2C87.2z%22%2F%3E%0A%20%20%20%20%3C%2Fg%3E%0A%20%20%20%20%3Cpath%20class%3D%22st0%22%20d%3D%22M236.6%2C123.5c0-19.8-12.3-37.2-30.8-43.9c0.8-4.2%2C1.2-8.4%2C1.2-12.7C207%2C30%2C177%2C0%2C140.2%2C0%26%2310%3B%26%239%3B%26%239%3BC118.6%2C0%2C98.6%2C10.3%2C86%2C27.7c-6.2-4.8-13.8-7.4-21.7-7.4c-19.6%2C0-35.5%2C15.9-35.5%2C35.5c0%2C4.3%2C0.8%2C8.5%2C2.2%2C12.4%26%2310%3B%26%239%3B%26%239%3BC12.6%2C74.8%2C0%2C92.5%2C0%2C112.2c0%2C19.9%2C12.4%2C37.3%2C30.9%2C44c-0.8%2C4.1-1.2%2C8.4-1.2%2C12.7c0%2C36.8%2C29.9%2C66.7%2C66.7%2C66.7%26%2310%3B%26%239%3B%26%239%3Bc21.6%2C0%2C41.6-10.4%2C54.1-27.8c6.2%2C4.9%2C13.8%2C7.6%2C21.7%2C7.6c19.6%2C0%2C35.5-15.9%2C35.5-35.5c0-4.3-0.8-8.5-2.2-12.4%26%2310%3B%26%239%3B%26%239%3BC223.9%2C160.9%2C236.6%2C143.2%2C236.6%2C123.5z%20M91.6%2C34.8c10.9-15.9%2C28.9-25.4%2C48.1-25.4c32.2%2C0%2C58.4%2C26.2%2C58.4%2C58.4%26%2310%3B%26%239%3B%26%239%3Bc0%2C3.9-0.4%2C7.7-1.1%2C11.5l-52.2%2C45.8L93%2C101.5L82.9%2C79.9L91.6%2C34.8z%20M65.4%2C29c6.2%2C0%2C12.1%2C2%2C17%2C5.7l-7.8%2C40.3l-35.5-8.4%26%2310%3B%26%239%3B%26%239%3Bc-1.1-3.1-1.7-6.3-1.7-9.7C37.4%2C41.6%2C49.9%2C29%2C65.4%2C29z%20M9.1%2C112.3c0-16.7%2C11-31.9%2C26.9-37.2L75%2C84.4l9.1%2C19.5l-49.8%2C45%26%2310%3B%26%239%3B%26%239%3BC19.2%2C143.1%2C9.1%2C128.6%2C9.1%2C112.3z%20M145.2%2C200.9c-10.9%2C16.1-29%2C25.6-48.4%2C25.6c-32.3%2C0-58.6-26.3-58.6-58.5c0-4%2C0.4-7.9%2C1.1-11.7%26%2310%3B%26%239%3B%26%239%3Bl50.9-46l52%2C23.7l11.5%2C22L145.2%2C200.9z%20M171.2%2C206.6c-6.1%2C0-12-2-16.9-5.8l7.7-40.2l35.4%2C8.3c1.1%2C3.1%2C1.7%2C6.3%2C1.7%2C9.7%26%2310%3B%26%239%3B%26%239%3BC199.2%2C194.1%2C186.6%2C206.6%2C171.2%2C206.6z%20M200.5%2C160.5l-39-9.1l-10.4-19.8l51-44.7c15.1%2C5.7%2C25.2%2C20.2%2C25.2%2C36.5%26%2310%3B%26%239%3B%26%239%3BC227.4%2C140.1%2C216.4%2C155.3%2C200.5%2C160.5z%22%2F%3E%0A%20%20%3C%2Fg%3E%0A%3C%2Fsvg%3E'; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/__stories__/image.stories.tsx b/x-pack/plugins/canvas/canvas_plugin_src/renderers/__stories__/image.stories.tsx index 7276a55bdf49d..8839910d78e0d 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/__stories__/image.stories.tsx +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/__stories__/image.stories.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { storiesOf } from '@storybook/react'; import { image } from '../image'; import { Render } from './render'; -import { elasticLogo } from '../../lib/elastic_logo'; +import { elasticLogo } from '../../../../../../src/plugins/presentation_util/common/lib'; storiesOf('renderers/image', module).add('default', () => { const config = { diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/__stories__/repeat_image.stories.tsx b/x-pack/plugins/canvas/canvas_plugin_src/renderers/__stories__/repeat_image.stories.tsx index 8dd059cf7a32f..ed2706389d83d 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/__stories__/repeat_image.stories.tsx +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/__stories__/repeat_image.stories.tsx @@ -9,8 +9,10 @@ import React from 'react'; import { storiesOf } from '@storybook/react'; import { repeatImage } from '../repeat_image'; import { Render } from './render'; -import { elasticLogo } from '../../lib/elastic_logo'; -import { elasticOutline } from '../../lib/elastic_outline'; +import { + elasticLogo, + elasticOutline, +} from '../../../../../../src/plugins/presentation_util/common/lib'; storiesOf('renderers/repeatImage', module).add('default', () => { const config = { diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/core.ts b/x-pack/plugins/canvas/canvas_plugin_src/renderers/core.ts index c6b40936c288a..3295332bb6316 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/core.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/core.ts @@ -14,7 +14,6 @@ import { pie } from './pie'; import { plot } from './plot'; import { progress } from './progress'; import { repeatImage } from './repeat_image'; -import { revealImage } from './reveal_image'; import { shape } from './shape'; import { table } from './table'; import { text } from './text'; @@ -29,7 +28,6 @@ export const renderFunctions = [ plot, progress, repeatImage, - revealImage, shape, table, text, diff --git a/x-pack/plugins/canvas/common/lib/url.ts b/x-pack/plugins/canvas/canvas_plugin_src/renderers/external.ts similarity index 54% rename from x-pack/plugins/canvas/common/lib/url.ts rename to x-pack/plugins/canvas/canvas_plugin_src/renderers/external.ts index 5018abc027713..bf9b6a744e686 100644 --- a/x-pack/plugins/canvas/common/lib/url.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/external.ts @@ -5,9 +5,7 @@ * 2.0. */ -import { isValidDataUrl } from '../../common/lib/dataurl'; -import { isValidHttpUrl } from '../../common/lib/httpurl'; +import { revealImageRenderer } from '../../../../../src/plugins/expression_reveal_image/public'; -export function isValidUrl(url: string) { - return isValidDataUrl(url) || isValidHttpUrl(url); -} +export const renderFunctions = [revealImageRenderer]; +export const renderFunctionFactories = []; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/image.tsx b/x-pack/plugins/canvas/canvas_plugin_src/renderers/image.tsx index 8c88fe820d5d3..86e9daed105db 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/image.tsx +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/image.tsx @@ -7,8 +7,7 @@ import ReactDOM from 'react-dom'; import React from 'react'; -import { elasticLogo } from '../lib/elastic_logo'; -import { isValidUrl } from '../../common/lib/url'; +import { elasticLogo, isValidUrl } from '../../../../../src/plugins/presentation_util/common/lib'; import { Return as Arguments } from '../functions/common/image'; import { RendererStrings } from '../../i18n'; import { RendererFactory } from '../../types'; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/index.ts b/x-pack/plugins/canvas/canvas_plugin_src/renderers/index.ts index 3c2d90f81eedc..16a052edbbe82 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/index.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/index.ts @@ -15,11 +15,23 @@ import { renderFunctionFactories as filterFactories, } from './filters'; +import { + renderFunctions as externalFunctions, + renderFunctionFactories as externalFactories, +} from './external'; + import { renderFunctions as coreFunctions, renderFunctionFactories as coreFactories } from './core'; -export const renderFunctions = [...coreFunctions, ...filterFunctions, ...embeddableFunctions]; +export const renderFunctions = [ + ...coreFunctions, + ...filterFunctions, + ...embeddableFunctions, + ...externalFunctions, +]; + export const renderFunctionFactories = [ ...coreFactories, ...embeddableFactories, ...filterFactories, + ...externalFactories, ]; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/repeat_image.ts b/x-pack/plugins/canvas/canvas_plugin_src/renderers/repeat_image.ts index 8286609aa334f..149a887683413 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/repeat_image.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/repeat_image.ts @@ -7,8 +7,10 @@ import $ from 'jquery'; import { times } from 'lodash'; -import { elasticOutline } from '../lib/elastic_outline'; -import { isValidUrl } from '../../common/lib/url'; +import { + elasticOutline, + isValidUrl, +} from '../../../../../src/plugins/presentation_util/common/lib'; import { RendererStrings, ErrorStrings } from '../../i18n'; import { Return as Arguments } from '../functions/common/repeat_image'; import { RendererFactory } from '../../types'; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/reveal_image/__stories__/reveal_image.stories.tsx b/x-pack/plugins/canvas/canvas_plugin_src/renderers/reveal_image/__stories__/reveal_image.stories.tsx deleted file mode 100644 index 672cecca1bead..0000000000000 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/reveal_image/__stories__/reveal_image.stories.tsx +++ /dev/null @@ -1,25 +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 { storiesOf } from '@storybook/react'; -import { revealImage } from '../'; -import { Render } from '../../__stories__/render'; -import { elasticOutline } from '../../../lib/elastic_outline'; -import { elasticLogo } from '../../../lib/elastic_logo'; -import { Origin } from '../../../functions/common/revealImage'; - -storiesOf('renderers/revealImage', module).add('default', () => { - const config = { - image: elasticLogo, - emptyImage: elasticOutline, - origin: Origin.LEFT, - percent: 0.45, - }; - - return <Render renderer={revealImage} config={config} />; -}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/reveal_image/index.ts b/x-pack/plugins/canvas/canvas_plugin_src/renderers/reveal_image/index.ts deleted file mode 100644 index 8d9ceb70f17a6..0000000000000 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/reveal_image/index.ts +++ /dev/null @@ -1,88 +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 { elasticOutline } from '../../lib/elastic_outline'; -import { isValidUrl } from '../../../common/lib/url'; -import { RendererStrings } from '../../../i18n'; -import { RendererFactory } from '../../../types'; -import { Output as Arguments } from '../../functions/common/revealImage'; - -const { revealImage: strings } = RendererStrings; - -export const revealImage: RendererFactory<Arguments> = () => ({ - name: 'revealImage', - displayName: strings.getDisplayName(), - help: strings.getHelpDescription(), - reuseDomNode: true, - render(domNode, config, handlers) { - const aligner = document.createElement('div'); - const img = new Image(); - - // modify the top-level container class - domNode.className = 'revealImage'; - - // set up the overlay image - function onLoad() { - setSize(); - finish(); - } - img.onload = onLoad; - - img.className = 'revealImage__image'; - img.style.clipPath = getClipPath(config.percent, config.origin); - img.style.setProperty('-webkit-clip-path', getClipPath(config.percent, config.origin)); - img.src = isValidUrl(config.image) ? config.image : elasticOutline; - handlers.onResize(onLoad); - - // set up the underlay, "empty" image - aligner.className = 'revealImageAligner'; - aligner.appendChild(img); - if (isValidUrl(config.emptyImage)) { - // only use empty image if one is provided - aligner.style.backgroundImage = `url(${config.emptyImage})`; - } - - function finish() { - const firstChild = domNode.firstChild; - if (firstChild) { - domNode.replaceChild(aligner, firstChild); - } else { - domNode.appendChild(aligner); - } - handlers.done(); - } - - function getClipPath(percent: number, origin = 'bottom') { - const directions: Record<string, number> = { bottom: 0, left: 1, top: 2, right: 3 }; - const values: Array<number | string> = [0, 0, 0, 0]; - values[directions[origin]] = `${100 - percent * 100}%`; - return `inset(${values.join(' ')})`; - } - - function setSize() { - const imgDimensions = { - height: img.naturalHeight, - width: img.naturalWidth, - ratio: img.naturalHeight / img.naturalWidth, - }; - - const domNodeDimensions = { - height: domNode.clientHeight, - width: domNode.clientWidth, - ratio: domNode.clientHeight / domNode.clientWidth, - }; - - if (imgDimensions.ratio > domNodeDimensions.ratio) { - img.style.height = `${domNodeDimensions.height}px`; - img.style.width = 'initial'; - } else { - img.style.width = `${domNodeDimensions.width}px`; - img.style.height = 'initial'; - } - } - }, -}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/image_upload/index.js b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/image_upload/index.js index 2caf41f0777e1..480d8ea364c42 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/image_upload/index.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/image_upload/index.js @@ -10,10 +10,12 @@ import PropTypes from 'prop-types'; import { EuiSpacer, EuiFormRow, EuiButtonGroup } from '@elastic/eui'; import { get } from 'lodash'; import { AssetPicker } from '../../../../public/components/asset_picker'; -import { elasticOutline } from '../../../lib/elastic_outline'; -import { resolveFromArgs } from '../../../../common/lib/resolve_dataurl'; -import { isValidHttpUrl } from '../../../../common/lib/httpurl'; -import { encode } from '../../../../common/lib/dataurl'; +import { + encode, + elasticOutline, + isValidHttpUrl, + resolveFromArgs, +} from '../../../../../../../src/plugins/presentation_util/public'; import { templateFromReactComponent } from '../../../../public/lib/template_from_react_component'; import { VALID_IMAGE_TYPES } from '../../../../common/lib/constants'; import { ArgumentStrings } from '../../../../i18n'; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/views/image.js b/x-pack/plugins/canvas/canvas_plugin_src/uis/views/image.js index 37b22e376141f..f974667b7fad9 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/uis/views/image.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/views/image.js @@ -5,8 +5,10 @@ * 2.0. */ -import { elasticLogo } from '../../lib/elastic_logo'; -import { resolveFromArgs } from '../../../common/lib/resolve_dataurl'; +import { + elasticLogo, + resolveFromArgs, +} from '../../../../../../src/plugins/presentation_util/common/lib'; import { ViewStrings } from '../../../i18n'; const { Image: strings } = ViewStrings; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/views/revealImage.js b/x-pack/plugins/canvas/canvas_plugin_src/uis/views/revealImage.js index 30e0b9a640f92..f9bba68c56949 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/uis/views/revealImage.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/views/revealImage.js @@ -6,7 +6,6 @@ */ import { ViewStrings } from '../../../i18n'; - const { RevealImage: strings } = ViewStrings; export const revealImage = () => ({ diff --git a/x-pack/plugins/canvas/common/lib/index.ts b/x-pack/plugins/canvas/common/lib/index.ts index afce09c6d5ee9..a23b569640f5a 100644 --- a/x-pack/plugins/canvas/common/lib/index.ts +++ b/x-pack/plugins/canvas/common/lib/index.ts @@ -8,7 +8,6 @@ export * from './datatable'; export * from './autocomplete'; export * from './constants'; -export * from './dataurl'; export * from './errors'; export * from './expression_form_handlers'; export * from './fetch'; @@ -16,10 +15,6 @@ export * from './fonts'; export * from './get_field_type'; export * from './get_legend_config'; export * from './hex_to_rgb'; -export * from './httpurl'; -export * from './missing_asset'; export * from './palettes'; export * from './pivot_object_array'; -export * from './resolve_dataurl'; export * from './unquote_string'; -export * from './url'; diff --git a/x-pack/plugins/canvas/common/lib/missing_asset.ts b/x-pack/plugins/canvas/common/lib/missing_asset.ts deleted file mode 100644 index d47648b44059c..0000000000000 --- a/x-pack/plugins/canvas/common/lib/missing_asset.ts +++ /dev/null @@ -1,3 +0,0 @@ -/* eslint-disable */ -// CC0, source: https://pixabay.com/en/question-mark-confirmation-question-838656/ -export const missingImage = 'data:image/svg+xml;base64,PHN2ZyB2ZXJzaW9uPSIxLjEiIHZpZXdCb3g9IjAgMCAzMSAzMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZGVmcz48ZmlsdGVyIGlkPSJiIiB4PSItLjM2IiB5PSItLjM2IiB3aWR0aD0iMS43MiIgaGVpZ2h0PSIxLjcyIiBjb2xvci1pbnRlcnBvbGF0aW9uLWZpbHRlcnM9InNSR0IiPjxmZUdhdXNzaWFuQmx1ciBzdGREZXZpYXRpb249IjIuNDY0Ii8+PC9maWx0ZXI+PGxpbmVhckdyYWRpZW50IGlkPSJhIiB4MT0iMTU5LjM0IiB4Mj0iMTg0LjQ4IiB5MT0iNzI3LjM2IiB5Mj0iNzQ5Ljg5IiBncmFkaWVudFRyYW5zZm9ybT0idHJhbnNsYXRlKC0xMTEuNTMgLTU0OS42OCkgc2NhbGUoLjc3MDAyKSIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiPjxzdG9wIHN0b3AtY29sb3I9IiNlYmYwZWQiIG9mZnNldD0iMCIvPjxzdG9wIHN0b3AtY29sb3I9IiNmYWZhZmEiIG9mZnNldD0iMSIvPjwvbGluZWFyR3JhZGllbnQ+PC9kZWZzPjxwYXRoIGQ9Ik0xNS40MzcgMi42OTVsMTQuNTA2IDI0LjQ3NkgxLjI4N2wxNC4xNS0yNC40NzZ6IiBmaWxsPSJ1cmwoI2EpIiBzdHJva2U9InJlZCIgc3Ryb2tlLWxpbmVjYXA9InNxdWFyZSIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgc3Ryb2tlLXdpZHRoPSIyIi8+PHBhdGggdHJhbnNmb3JtPSJtYXRyaXgoLjgzMTk3IDAgMCAuNTU0NjYgLTc4LjU4MyAtMzgzLjUxKSIgZD0iTTExMS4zMSA3MzEuMmMtMy4yODMtMy45MjUtMy41OTUtNi4xNDgtMi4wMjQtMTAuNDM4IDMuMzM2LTYuMTQ1IDQuNDk2LTguMDY4IDUuNDEtOS40MDUgMS45MDEgNS4xNjIgMi4xMjYgMTkuMTQtMy4zODYgMTkuODQzeiIgZmlsbD0iI2ZmZiIgZmlsbC1vcGFjaXR5PSIuODc2IiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGZpbHRlcj0idXJsKCNiKSIvPjxnIGZpbGwtb3BhY2l0eT0iLjgyIj48cGF0aCBkPSJNMTUuMDQ2IDIwLjIyN2gtLjQxNWMtLjAxMy0uNzQ4LjAyLTEuMzA4LjEwMS0xLjY3OC4wODgtLjM3MS4zMDctLjg4LjY1OC0xLjUyOC4zNTctLjY1NC41OS0xLjE3Ni42OTctMS41NjcuMTE1LS4zOTguMTcyLS44ODcuMTcyLTEuNDY3IDAtLjg5Ni0uMTc1LTEuNTU3LS41MjYtMS45ODItLjM1LS40MjUtLjc2NS0uNjM3LTEuMjQ0LS42MzctLjM2NCAwLS42Ny4wOTgtLjkyLjI5My0uMTg5LjE0OS0uMjgzLjMwNC0uMjgzLjQ2NiAwIC4xMDcuMDY0LjI3Ni4xOTIuNTA1LjI5LjUyLjQzNS45NjEuNDM1IDEuMzI1IDAgLjMzLS4xMTUuNjA3LS4zNDQuODNhMS4xMzggMS4xMzggMCAwIDEtLjg0LjMzM2MtLjM3NyAwLS42OTQtLjEzMS0uOTUtLjM5NC0uMjU2LS4yNy0uMzg0LS42MjQtLjM4NC0xLjA2MiAwLS43OTYuMzQ0LTEuNDk0IDEuMDMxLTIuMDk0LjY4OC0uNiAxLjY0OS0uOSAyLjg4My0uOSAxLjMwOCAwIDIuMzAyLjMxNCAyLjk4My45NC42ODguNjIxIDEuMDMyIDEuMzczIDEuMDMyIDIuMjU2IDAgLjY0LS4xNzYgMS4yMzQtLjUyNiAxLjc4LS4zNTEuNTQtMS4wMjkgMS4xNC0yLjAzMyAxLjgtLjY3NC40NDUtMS4xMi44NDMtMS4zMzUgMS4xOTQtLjIxLjM0My0uMzM3Ljg3My0uMzg0IDEuNTg3bS0uMTEyIDEuNDc3Yy40NTIgMCAuODM2LjE1OCAxLjE1My40NzUuMzE3LjMxNy40NzYuNzAxLjQ3NiAxLjE1MyAwIC40NTItLjE1OS44NC0uNDc2IDEuMTYzYTEuNTcgMS41NyAwIDAgMS0xLjE1My40NzUgMS41NyAxLjU3IDAgMCAxLTEuMTUzLS40NzUgMS42MDQgMS42MDQgMCAwIDEtLjQ3NS0xLjE2M2MwLS40NTIuMTU5LS44MzYuNDc1LTEuMTUzYTEuNTcgMS41NyAwIDAgMSAxLjE1My0uNDc1IiBmaWxsPSIjZmZmIiBmaWxsLW9wYWNpdHk9Ii40ODYiLz48cGF0aCBkPSJNMTUuMzI3IDIwLjUwOGgtLjQxNWMtLjAxMy0uNzQ4LjAyLTEuMzA4LjEwMS0xLjY3OC4wODgtLjM3MS4zMDctLjg4LjY1OC0xLjUyOC4zNTctLjY1NC41OS0xLjE3Ni42OTctMS41NjcuMTE1LS4zOTguMTcyLS44ODcuMTcyLTEuNDY2IDAtLjg5Ny0uMTc1LTEuNTU4LS41MjYtMS45ODMtLjM1LS40MjQtLjc2NS0uNjM3LTEuMjQzLS42MzctLjM2NSAwLS42NzEuMDk4LS45Mi4yOTMtLjE5LjE0OS0uMjg0LjMwNC0uMjg0LjQ2NiAwIC4xMDguMDY0LjI3Ni4xOTIuNTA1LjI5LjUyLjQzNS45NjEuNDM1IDEuMzI1IDAgLjMzLS4xMTUuNjA3LS4zNDQuODNhMS4xMzggMS4xMzggMCAwIDEtLjg0LjMzM2MtLjM3NyAwLS42OTQtLjEzMS0uOTUtLjM5NC0uMjU2LS4yNy0uMzg0LS42MjQtLjM4NC0xLjA2MiAwLS43OTYuMzQ0LTEuNDkzIDEuMDMxLTIuMDk0LjY4OC0uNiAxLjY0OS0uOSAyLjg4My0uOSAxLjMwOCAwIDIuMzAyLjMxNCAyLjk4My45NC42ODguNjIxIDEuMDMyIDEuMzczIDEuMDMyIDIuMjU2IDAgLjY0LS4xNzYgMS4yMzQtLjUyNiAxLjc4LS4zNS41NC0xLjAyOCAxLjE0LTIuMDMzIDEuOC0uNjc0LjQ0NS0uODUzLjg0My0xLjA2OCAxLjE5NC0uMjEuMzQzLS4zMzcuODczLS4zODUgMS41ODdtLS4zNzggMS40NzdjLjQ1MiAwIC44MzYuMTU4IDEuMTUzLjQ3NS4zMTcuMzE3LjQ3Ni43MDEuNDc2IDEuMTUzIDAgLjQ1Mi0uMTU5Ljg0LS40NzYgMS4xNjNhMS41NyAxLjU3IDAgMCAxLTEuMTUzLjQ3NiAxLjU3IDEuNTcgMCAwIDEtMS4xNTMtLjQ3NiAxLjYwNCAxLjYwNCAwIDAgMS0uNDc1LTEuMTYzYzAtLjQ1Mi4xNTktLjgzNi40NzUtMS4xNTNhMS41NyAxLjU3IDAgMCAxIDEuMTUzLS40NzUiIGZpbGwtb3BhY2l0eT0iLjgyIi8+PC9nPjwvc3ZnPg=='; diff --git a/x-pack/plugins/canvas/i18n/functions/function_errors.ts b/x-pack/plugins/canvas/i18n/functions/function_errors.ts index 4a85018c1b4ac..a01cb09a38347 100644 --- a/x-pack/plugins/canvas/i18n/functions/function_errors.ts +++ b/x-pack/plugins/canvas/i18n/functions/function_errors.ts @@ -19,7 +19,6 @@ import { errors as joinRows } from './dict/join_rows'; import { errors as ply } from './dict/ply'; import { errors as pointseries } from './dict/pointseries'; import { errors as progress } from './dict/progress'; -import { errors as revealImage } from './dict/reveal_image'; import { errors as timefilter } from './dict/timefilter'; import { errors as to } from './dict/to'; @@ -38,7 +37,6 @@ export const getFunctionErrors = () => ({ ply, pointseries, progress, - revealImage, timefilter, to, }); diff --git a/x-pack/plugins/canvas/i18n/functions/function_help.ts b/x-pack/plugins/canvas/i18n/functions/function_help.ts index 512ebc4ff8c93..b72d410ddd63f 100644 --- a/x-pack/plugins/canvas/i18n/functions/function_help.ts +++ b/x-pack/plugins/canvas/i18n/functions/function_help.ts @@ -57,7 +57,6 @@ import { help as progress } from './dict/progress'; import { help as render } from './dict/render'; import { help as repeatImage } from './dict/repeat_image'; import { help as replace } from './dict/replace'; -import { help as revealImage } from './dict/reveal_image'; import { help as rounddate } from './dict/rounddate'; import { help as rowCount } from './dict/row_count'; import { help as savedLens } from './dict/saved_lens'; @@ -218,7 +217,6 @@ export const getFunctionHelp = (): FunctionHelpDict => ({ render, repeatImage, replace, - revealImage, rounddate, rowCount, savedLens, diff --git a/x-pack/plugins/canvas/i18n/renderers.ts b/x-pack/plugins/canvas/i18n/renderers.ts index f74516433f924..29687155818e7 100644 --- a/x-pack/plugins/canvas/i18n/renderers.ts +++ b/x-pack/plugins/canvas/i18n/renderers.ts @@ -139,16 +139,6 @@ export const RendererStrings = { defaultMessage: 'Repeat an image a given number of times', }), }, - revealImage: { - getDisplayName: () => - i18n.translate('xpack.canvas.renderer.revealImage.displayName', { - defaultMessage: 'Image reveal', - }), - getHelpDescription: () => - i18n.translate('xpack.canvas.renderer.revealImage.helpDescription', { - defaultMessage: 'Reveal a percentage of an image to make a custom gauge-style chart', - }), - }, shape: { getDisplayName: () => i18n.translate('xpack.canvas.renderer.shape.displayName', { diff --git a/x-pack/plugins/canvas/kibana.json b/x-pack/plugins/canvas/kibana.json index 5faeaefc9e392..85d2e0709cb3e 100644 --- a/x-pack/plugins/canvas/kibana.json +++ b/x-pack/plugins/canvas/kibana.json @@ -10,6 +10,7 @@ "charts", "data", "embeddable", + "expressionRevealImage", "expressions", "features", "inspector", diff --git a/x-pack/plugins/canvas/public/components/asset_manager/asset_manager.ts b/x-pack/plugins/canvas/public/components/asset_manager/asset_manager.ts index f8c6354d3935f..e3824798d1df1 100644 --- a/x-pack/plugins/canvas/public/components/asset_manager/asset_manager.ts +++ b/x-pack/plugins/canvas/public/components/asset_manager/asset_manager.ts @@ -13,7 +13,7 @@ import { getId } from '../../lib/get_id'; // @ts-expect-error untyped local import { findExistingAsset } from '../../lib/find_existing_asset'; import { VALID_IMAGE_TYPES } from '../../../common/lib/constants'; -import { encode } from '../../../common/lib/dataurl'; +import { encode } from '../../../../../../src/plugins/presentation_util/public'; // @ts-expect-error untyped local import { elementsRegistry } from '../../lib/elements_registry'; // @ts-expect-error untyped local diff --git a/x-pack/plugins/canvas/public/components/custom_element_modal/__stories__/custom_element_modal.stories.tsx b/x-pack/plugins/canvas/public/components/custom_element_modal/__stories__/custom_element_modal.stories.tsx index 2e6d83cb1c8ac..93574270757f6 100644 --- a/x-pack/plugins/canvas/public/components/custom_element_modal/__stories__/custom_element_modal.stories.tsx +++ b/x-pack/plugins/canvas/public/components/custom_element_modal/__stories__/custom_element_modal.stories.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { storiesOf } from '@storybook/react'; import { action } from '@storybook/addon-actions'; import { CustomElementModal } from '../custom_element_modal'; -import { elasticLogo } from '../../../lib/elastic_logo'; +import { elasticLogo } from '../../../../../../../src/plugins/presentation_util/public'; storiesOf('components/Elements/CustomElementModal', module) .add('with title', () => ( diff --git a/x-pack/plugins/canvas/public/components/custom_element_modal/custom_element_modal.tsx b/x-pack/plugins/canvas/public/components/custom_element_modal/custom_element_modal.tsx index 86d9cab4eeea1..51ffe57fe5e76 100644 --- a/x-pack/plugins/canvas/public/components/custom_element_modal/custom_element_modal.tsx +++ b/x-pack/plugins/canvas/public/components/custom_element_modal/custom_element_modal.tsx @@ -29,7 +29,7 @@ import { import { i18n } from '@kbn/i18n'; import { VALID_IMAGE_TYPES } from '../../../common/lib/constants'; -import { encode } from '../../../common/lib/dataurl'; +import { encode } from '../../../../../../src/plugins/presentation_util/public'; import { ElementCard } from '../element_card'; const MAX_NAME_LENGTH = 40; diff --git a/x-pack/plugins/canvas/public/components/download/download.tsx b/x-pack/plugins/canvas/public/components/download/download.tsx index 856d6cb7e080e..89cd999481007 100644 --- a/x-pack/plugins/canvas/public/components/download/download.tsx +++ b/x-pack/plugins/canvas/public/components/download/download.tsx @@ -9,7 +9,7 @@ import { toByteArray } from 'base64-js'; import fileSaver from 'file-saver'; import PropTypes from 'prop-types'; import React, { ReactElement } from 'react'; -import { parseDataUrl } from '../../../common/lib/dataurl'; +import { parseDataUrl } from '../../../../../../src/plugins/presentation_util/public'; interface Props { children: ReactElement<any>; diff --git a/x-pack/plugins/canvas/public/components/element_card/__stories__/element_card.stories.tsx b/x-pack/plugins/canvas/public/components/element_card/__stories__/element_card.stories.tsx index ae0d4328aa98d..4c68f185b196f 100644 --- a/x-pack/plugins/canvas/public/components/element_card/__stories__/element_card.stories.tsx +++ b/x-pack/plugins/canvas/public/components/element_card/__stories__/element_card.stories.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { storiesOf } from '@storybook/react'; import { action } from '@storybook/addon-actions'; import { ElementCard } from '../element_card'; -import { elasticLogo } from '../../../lib/elastic_logo'; +import { elasticLogo } from '../../../../../../../src/plugins/presentation_util/public'; storiesOf('components/Elements/ElementCard', module) .addDecorator((story) => ( diff --git a/x-pack/plugins/canvas/public/components/function_reference_generator/generate_function_reference.ts b/x-pack/plugins/canvas/public/components/function_reference_generator/generate_function_reference.ts index 8f9b3923ff120..075e65bc24dab 100644 --- a/x-pack/plugins/canvas/public/components/function_reference_generator/generate_function_reference.ts +++ b/x-pack/plugins/canvas/public/components/function_reference_generator/generate_function_reference.ts @@ -10,7 +10,8 @@ import pluralize from 'pluralize'; import { ExpressionFunction, ExpressionFunctionParameter } from 'src/plugins/expressions'; import { functions as browserFunctions } from '../../../canvas_plugin_src/functions/browser'; import { functions as serverFunctions } from '../../../canvas_plugin_src/functions/server'; -import { isValidDataUrl, DATATABLE_COLUMN_TYPES } from '../../../common/lib'; +import { DATATABLE_COLUMN_TYPES } from '../../../common/lib'; +import { isValidDataUrl } from '../../../../../../src/plugins/presentation_util/public'; import { getFunctionExamples, FunctionExample } from './function_examples'; const ALPHABET = 'abcdefghijklmnopqrstuvwxyz'.split(''); diff --git a/x-pack/plugins/canvas/public/components/saved_elements_modal/__stories__/fixtures/test_elements.tsx b/x-pack/plugins/canvas/public/components/saved_elements_modal/__stories__/fixtures/test_elements.tsx index 17d6a3d11b60f..ef48b9815062c 100644 --- a/x-pack/plugins/canvas/public/components/saved_elements_modal/__stories__/fixtures/test_elements.tsx +++ b/x-pack/plugins/canvas/public/components/saved_elements_modal/__stories__/fixtures/test_elements.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { elasticLogo } from '../../../../lib/elastic_logo'; +import { elasticLogo } from '../../../../../../../../src/plugins/presentation_util/public'; export const testCustomElements = [ { diff --git a/x-pack/plugins/canvas/public/components/workpad_header/element_menu/__stories__/element_menu.stories.tsx b/x-pack/plugins/canvas/public/components/workpad_header/element_menu/__stories__/element_menu.stories.tsx index 6f4b6661ded53..80280d55a4e1c 100644 --- a/x-pack/plugins/canvas/public/components/workpad_header/element_menu/__stories__/element_menu.stories.tsx +++ b/x-pack/plugins/canvas/public/components/workpad_header/element_menu/__stories__/element_menu.stories.tsx @@ -95,17 +95,6 @@ You can use standard Markdown in here, but you can also access your piped-in dat | progress shape="gauge" label={formatnumber 0%} font={font size=24 family="Helvetica" color="#000000" align=center} | render`, }, - revealImage: { - name: 'revealImage', - displayName: 'Image reveal', - type: 'image', - help: 'Reveals a percentage of an image', - expression: `filters - | demodata - | math "mean(percent_uptime)" - | revealImage origin=bottom image=null - | render`, - }, shape: { name: 'shape', displayName: 'Shape', diff --git a/x-pack/plugins/canvas/public/functions/pie.test.js b/x-pack/plugins/canvas/public/functions/pie.test.js index b1c1746340892..5e35cc3bf523c 100644 --- a/x-pack/plugins/canvas/public/functions/pie.test.js +++ b/x-pack/plugins/canvas/public/functions/pie.test.js @@ -5,8 +5,8 @@ * 2.0. */ -import { functionWrapper } from '../../test_helpers/function_wrapper'; import { testPie } from '../../canvas_plugin_src/functions/common/__fixtures__/test_pointseries'; +import { functionWrapper } from '../../../../../src/plugins/presentation_util/public'; import { fontStyle, grayscalePalette, diff --git a/x-pack/plugins/canvas/public/functions/plot.test.js b/x-pack/plugins/canvas/public/functions/plot.test.js index 5ed858961d798..8dd2470ea17dc 100644 --- a/x-pack/plugins/canvas/public/functions/plot.test.js +++ b/x-pack/plugins/canvas/public/functions/plot.test.js @@ -5,7 +5,7 @@ * 2.0. */ -import { functionWrapper } from '../../test_helpers/function_wrapper'; +import { functionWrapper } from '../../../../../src/plugins/presentation_util/public'; import { testPlot } from '../../canvas_plugin_src/functions/common/__fixtures__/test_pointseries'; import { fontStyle, diff --git a/x-pack/plugins/canvas/public/lib/elastic_outline.js b/x-pack/plugins/canvas/public/lib/elastic_outline.js deleted file mode 100644 index 7271f5b32d547..0000000000000 --- a/x-pack/plugins/canvas/public/lib/elastic_outline.js +++ /dev/null @@ -1,2 +0,0 @@ -/* eslint-disable */ -export const elasticOutline = 'data:image/svg+xml,%3C%3Fxml%20version%3D%221.0%22%20encoding%3D%22utf-8%22%3F%3E%0A%3Csvg%20viewBox%3D%22-3.948730230331421%20-1.7549896240234375%20245.25946044921875%20241.40370178222656%22%20width%3D%22245.25946044921875%22%20height%3D%22241.40370178222656%22%20style%3D%22enable-background%3Anew%200%200%20686.2%20235.7%3B%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%3Cdefs%3E%0A%20%20%20%20%3Cstyle%20type%3D%22text%2Fcss%22%3E%0A%09.st0%7Bfill%3A%232D2D2D%3B%7D%0A%3C%2Fstyle%3E%0A%20%20%3C%2Fdefs%3E%0A%20%20%3Cg%20transform%3D%22matrix%281%2C%200%2C%200%2C%201%2C%200%2C%200%29%22%3E%0A%20%20%20%20%3Cg%3E%0A%20%20%20%20%20%20%3Cpath%20class%3D%22st0%22%20d%3D%22M329.4%2C160.3l4.7-0.5l0.3%2C9.6c-12.4%2C1.7-23%2C2.6-31.8%2C2.6c-11.7%2C0-20-3.4-24.9-10.2%26%2310%3B%26%239%3B%26%239%3B%26%239%3Bc-4.9-6.8-7.3-17.4-7.3-31.7c0-28.6%2C11.4-42.9%2C34.1-42.9c11%2C0%2C19.2%2C3.1%2C24.6%2C9.2c5.4%2C6.1%2C8.1%2C15.8%2C8.1%2C28.9l-0.7%2C9.3h-53.8%26%2310%3B%26%239%3B%26%239%3B%26%239%3Bc0%2C9%2C1.6%2C15.7%2C4.9%2C20c3.3%2C4.3%2C8.9%2C6.5%2C17%2C6.5C312.8%2C161.2%2C321.1%2C160.9%2C329.4%2C160.3z%20M325%2C124.9c0-10-1.6-17.1-4.8-21.2%26%2310%3B%26%239%3B%26%239%3B%26%239%3Bc-3.2-4.1-8.4-6.2-15.6-6.2c-7.2%2C0-12.7%2C2.2-16.3%2C6.5c-3.6%2C4.3-5.5%2C11.3-5.6%2C20.9H325z%22%2F%3E%0A%20%20%20%20%20%20%3Cpath%20class%3D%22st0%22%20d%3D%22M354.3%2C171.4V64h12.2v107.4H354.3z%22%2F%3E%0A%20%20%20%20%20%20%3Cpath%20class%3D%22st0%22%20d%3D%22M443.5%2C113.5v41.1c0%2C4.1%2C10.1%2C3.9%2C10.1%2C3.9l-0.6%2C10.8c-8.6%2C0-15.7%2C0.7-20-3.4c-9.8%2C4.3-19.5%2C6.1-29.3%2C6.1%26%2310%3B%26%239%3B%26%239%3B%26%239%3Bc-7.5%2C0-13.2-2.1-17.1-6.4c-3.9-4.2-5.9-10.3-5.9-18.3c0-7.9%2C2-13.8%2C6-17.5c4-3.7%2C10.3-6.1%2C18.9-6.9l25.6-2.4v-7%26%2310%3B%26%239%3B%26%239%3B%26%239%3Bc0-5.5-1.2-9.5-3.6-11.9c-2.4-2.4-5.7-3.6-9.8-3.6l-32.1%2C0V87.2h31.3c9.2%2C0%2C15.9%2C2.1%2C20.1%2C6.4C441.4%2C97.8%2C443.5%2C104.5%2C443.5%2C113.5%26%2310%3B%26%239%3B%26%239%3B%26%239%3Bz%20M393.3%2C146.7c0%2C10%2C4.1%2C15%2C12.4%2C15c7.4%2C0%2C14.7-1.2%2C21.8-3.7l3.7-1.3v-26.9l-24.1%2C2.3c-4.9%2C0.4-8.4%2C1.8-10.6%2C4.2%26%2310%3B%26%239%3B%26%239%3B%26%239%3BC394.4%2C138.7%2C393.3%2C142.2%2C393.3%2C146.7z%22%2F%3E%0A%20%20%20%20%20%20%3Cpath%20class%3D%22st0%22%20d%3D%22M491.2%2C98.2c-11.8%2C0-17.8%2C4.1-17.8%2C12.4c0%2C3.8%2C1.4%2C6.5%2C4.1%2C8.1c2.7%2C1.6%2C8.9%2C3.2%2C18.6%2C4.9%26%2310%3B%26%239%3B%26%239%3B%26%239%3Bc9.7%2C1.7%2C16.5%2C4%2C20.5%2C7.1c4%2C3%2C6%2C8.7%2C6%2C17.1c0%2C8.4-2.7%2C14.5-8.1%2C18.4c-5.4%2C3.9-13.2%2C5.9-23.6%2C5.9c-6.7%2C0-29.2-2.5-29.2-2.5%26%2310%3B%26%239%3B%26%239%3B%26%239%3Bl0.7-10.6c12.9%2C1.2%2C22.3%2C2.2%2C28.6%2C2.2c6.3%2C0%2C11.1-1%2C14.4-3c3.3-2%2C5-5.4%2C5-10.1c0-4.7-1.4-7.9-4.2-9.6c-2.8-1.7-9-3.3-18.6-4.8%26%2310%3B%26%239%3B%26%239%3B%26%239%3Bc-9.6-1.5-16.4-3.7-20.4-6.7c-4-2.9-6-8.4-6-16.3c0-7.9%2C2.8-13.8%2C8.4-17.6c5.6-3.8%2C12.6-5.7%2C20.9-5.7c6.6%2C0%2C29.6%2C1.7%2C29.6%2C1.7%26%2310%3B%26%239%3B%26%239%3B%26%239%3Bv10.7C508.1%2C99%2C498.2%2C98.2%2C491.2%2C98.2z%22%2F%3E%0A%20%20%20%20%20%20%3Cpath%20class%3D%22st0%22%20d%3D%22M581.7%2C99.5h-25.9v39c0%2C9.3%2C0.7%2C15.5%2C2%2C18.4c1.4%2C2.9%2C4.6%2C4.4%2C9.7%2C4.4l14.5-1l0.8%2C10.1%26%2310%3B%26%239%3B%26%239%3B%26%239%3Bc-7.3%2C1.2-12.8%2C1.8-16.6%2C1.8c-8.5%2C0-14.3-2.1-17.6-6.2c-3.3-4.1-4.9-12-4.9-23.6V99.5h-11.6V88.9h11.6V63.9h12.1v24.9h25.9V99.5z%22%2F%3E%0A%20%20%20%20%20%20%3Cpath%20class%3D%22st0%22%20d%3D%22M598.7%2C78.4V64.3h12.2v14.2H598.7z%20M598.7%2C171.4V88.9h12.2v82.5H598.7z%22%2F%3E%0A%20%20%20%20%20%20%3Cpath%20class%3D%22st0%22%20d%3D%22M663.8%2C87.2c3.6%2C0%2C9.7%2C0.7%2C18.3%2C2l3.9%2C0.5l-0.5%2C9.9c-8.7-1-15.1-1.5-19.2-1.5c-9.2%2C0-15.5%2C2.2-18.8%2C6.6%26%2310%3B%26%239%3B%26%239%3B%26%239%3Bc-3.3%2C4.4-5%2C12.6-5%2C24.5c0%2C11.9%2C1.5%2C20.2%2C4.6%2C24.9c3.1%2C4.7%2C9.5%2C7%2C19.3%2C7l19.2-1.5l0.5%2C10.1c-10.1%2C1.5-17.7%2C2.3-22.7%2C2.3%26%2310%3B%26%239%3B%26%239%3B%26%239%3Bc-12.7%2C0-21.5-3.3-26.3-9.8c-4.8-6.5-7.3-17.5-7.3-33c0-15.5%2C2.6-26.4%2C7.8-32.6C643%2C90.4%2C651.7%2C87.2%2C663.8%2C87.2z%22%2F%3E%0A%20%20%20%20%3C%2Fg%3E%0A%20%20%20%20%3Cpath%20class%3D%22st0%22%20d%3D%22M236.6%2C123.5c0-19.8-12.3-37.2-30.8-43.9c0.8-4.2%2C1.2-8.4%2C1.2-12.7C207%2C30%2C177%2C0%2C140.2%2C0%26%2310%3B%26%239%3B%26%239%3BC118.6%2C0%2C98.6%2C10.3%2C86%2C27.7c-6.2-4.8-13.8-7.4-21.7-7.4c-19.6%2C0-35.5%2C15.9-35.5%2C35.5c0%2C4.3%2C0.8%2C8.5%2C2.2%2C12.4%26%2310%3B%26%239%3B%26%239%3BC12.6%2C74.8%2C0%2C92.5%2C0%2C112.2c0%2C19.9%2C12.4%2C37.3%2C30.9%2C44c-0.8%2C4.1-1.2%2C8.4-1.2%2C12.7c0%2C36.8%2C29.9%2C66.7%2C66.7%2C66.7%26%2310%3B%26%239%3B%26%239%3Bc21.6%2C0%2C41.6-10.4%2C54.1-27.8c6.2%2C4.9%2C13.8%2C7.6%2C21.7%2C7.6c19.6%2C0%2C35.5-15.9%2C35.5-35.5c0-4.3-0.8-8.5-2.2-12.4%26%2310%3B%26%239%3B%26%239%3BC223.9%2C160.9%2C236.6%2C143.2%2C236.6%2C123.5z%20M91.6%2C34.8c10.9-15.9%2C28.9-25.4%2C48.1-25.4c32.2%2C0%2C58.4%2C26.2%2C58.4%2C58.4%26%2310%3B%26%239%3B%26%239%3Bc0%2C3.9-0.4%2C7.7-1.1%2C11.5l-52.2%2C45.8L93%2C101.5L82.9%2C79.9L91.6%2C34.8z%20M65.4%2C29c6.2%2C0%2C12.1%2C2%2C17%2C5.7l-7.8%2C40.3l-35.5-8.4%26%2310%3B%26%239%3B%26%239%3Bc-1.1-3.1-1.7-6.3-1.7-9.7C37.4%2C41.6%2C49.9%2C29%2C65.4%2C29z%20M9.1%2C112.3c0-16.7%2C11-31.9%2C26.9-37.2L75%2C84.4l9.1%2C19.5l-49.8%2C45%26%2310%3B%26%239%3B%26%239%3BC19.2%2C143.1%2C9.1%2C128.6%2C9.1%2C112.3z%20M145.2%2C200.9c-10.9%2C16.1-29%2C25.6-48.4%2C25.6c-32.3%2C0-58.6-26.3-58.6-58.5c0-4%2C0.4-7.9%2C1.1-11.7%26%2310%3B%26%239%3B%26%239%3Bl50.9-46l52%2C23.7l11.5%2C22L145.2%2C200.9z%20M171.2%2C206.6c-6.1%2C0-12-2-16.9-5.8l7.7-40.2l35.4%2C8.3c1.1%2C3.1%2C1.7%2C6.3%2C1.7%2C9.7%26%2310%3B%26%239%3B%26%239%3BC199.2%2C194.1%2C186.6%2C206.6%2C171.2%2C206.6z%20M200.5%2C160.5l-39-9.1l-10.4-19.8l51-44.7c15.1%2C5.7%2C25.2%2C20.2%2C25.2%2C36.5%26%2310%3B%26%239%3B%26%239%3BC227.4%2C140.1%2C216.4%2C155.3%2C200.5%2C160.5z%22%2F%3E%0A%20%20%3C%2Fg%3E%0A%3C%2Fsvg%3E'; diff --git a/x-pack/plugins/canvas/public/style/index.scss b/x-pack/plugins/canvas/public/style/index.scss index e866eada1f85f..aac898c3dd374 100644 --- a/x-pack/plugins/canvas/public/style/index.scss +++ b/x-pack/plugins/canvas/public/style/index.scss @@ -45,16 +45,13 @@ @import '../components/workpad_page/workpad_static_page/workpad_static_page'; @import '../components/var_config/edit_var'; @import '../components/var_config/var_config'; - @import '../transitions/fade/fade'; @import '../transitions/rotate/rotate'; @import '../transitions/slide/slide'; @import '../transitions/zoom/zoom'; - @import '../../canvas_plugin_src/renderers/filters/advanced_filter/component/advanced_filter.scss'; @import '../../canvas_plugin_src/renderers/filters/dropdown_filter/component/dropdown_filter.scss'; @import '../../canvas_plugin_src/renderers/embeddable/embeddable.scss'; @import '../../canvas_plugin_src/renderers/plot/plot.scss'; -@import '../../canvas_plugin_src/renderers/reveal_image/reveal_image.scss'; @import '../../canvas_plugin_src/renderers/filters/time_filter/time_filter.scss'; @import '../../canvas_plugin_src/uis/arguments/image_upload/image_upload.scss'; diff --git a/x-pack/plugins/canvas/shareable_runtime/supported_renderers.js b/x-pack/plugins/canvas/shareable_runtime/supported_renderers.js index 8ee96aeec2951..60987e987f63a 100644 --- a/x-pack/plugins/canvas/shareable_runtime/supported_renderers.js +++ b/x-pack/plugins/canvas/shareable_runtime/supported_renderers.js @@ -9,7 +9,6 @@ import { debug } from '../canvas_plugin_src/renderers/debug'; import { error } from '../canvas_plugin_src/renderers/error'; import { image } from '../canvas_plugin_src/renderers/image'; import { repeatImage } from '../canvas_plugin_src/renderers/repeat_image'; -import { revealImage } from '../canvas_plugin_src/renderers/reveal_image'; import { markdown } from '../canvas_plugin_src/renderers/markdown'; import { metric } from '../canvas_plugin_src/renderers/metric'; import { pie } from '../canvas_plugin_src/renderers/pie'; @@ -18,6 +17,7 @@ import { progress } from '../canvas_plugin_src/renderers/progress'; import { shape } from '../canvas_plugin_src/renderers/shape'; import { table } from '../canvas_plugin_src/renderers/table'; import { text } from '../canvas_plugin_src/renderers/text'; +import { revealImageRenderer as revealImage } from '../../../../src/plugins/expression_reveal_image/public'; /** * This is a collection of renderers which are bundled with the runtime. If diff --git a/x-pack/plugins/canvas/test_helpers/function_wrapper.js b/x-pack/plugins/canvas/test_helpers/function_wrapper.js deleted file mode 100644 index d20cac18cbb54..0000000000000 --- a/x-pack/plugins/canvas/test_helpers/function_wrapper.js +++ /dev/null @@ -1,19 +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 { mapValues } from 'lodash'; - -// It takes a function spec and passes in default args into the spec fn -export const functionWrapper = (fnSpec, mockReduxStore) => { - const spec = fnSpec(); - const defaultArgs = mapValues(spec.args, (argSpec) => { - return argSpec.default; - }); - - return (context, args, handlers) => - spec.fn(context, { ...defaultArgs, ...args }, handlers, mockReduxStore); -}; diff --git a/x-pack/plugins/canvas/tsconfig.json b/x-pack/plugins/canvas/tsconfig.json index 487b68ba3542b..84581d7be85a3 100644 --- a/x-pack/plugins/canvas/tsconfig.json +++ b/x-pack/plugins/canvas/tsconfig.json @@ -31,6 +31,7 @@ { "path": "../../../src/plugins/discover/tsconfig.json" }, { "path": "../../../src/plugins/embeddable/tsconfig.json" }, { "path": "../../../src/plugins/expressions/tsconfig.json" }, + { "path": "../../../src/plugins/expression_reveal_image/tsconfig.json" }, { "path": "../../../src/plugins/home/tsconfig.json" }, { "path": "../../../src/plugins/inspector/tsconfig.json" }, { "path": "../../../src/plugins/kibana_legacy/tsconfig.json" }, diff --git a/x-pack/plugins/canvas/types/renderers.ts b/x-pack/plugins/canvas/types/renderers.ts index e840ebee43ed3..2c3931485757d 100644 --- a/x-pack/plugins/canvas/types/renderers.ts +++ b/x-pack/plugins/canvas/types/renderers.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { IInterpreterRenderHandlers } from 'src/plugins/expressions'; +import { ExpressionRenderDefinition, IInterpreterRenderHandlers } from 'src/plugins/expressions'; type GenericRendererCallback = (callback: () => void) => void; @@ -35,9 +35,9 @@ export interface RendererSpec<RendererConfig = {}> { /** The render type */ name: string; /** The name to display */ - displayName: string; + displayName?: string; /** A description of what is rendered */ - help: string; + help?: string; /** Indicate whether the element should reuse the existing DOM element when re-rendering */ reuseDomNode: boolean; /** The default width of the element in pixels */ @@ -50,5 +50,7 @@ export interface RendererSpec<RendererConfig = {}> { export type RendererFactory<RendererConfig = {}> = () => RendererSpec<RendererConfig>; -export type AnyRendererFactory = RendererFactory<any>; +export type AnyRendererFactory = + | RendererFactory<any> + | Array<() => ExpressionRenderDefinition<any>>; export type AnyRendererSpec = RendererSpec<any>; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 69553fd53ffc5..c0c14ef4cc6eb 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -6340,15 +6340,15 @@ "xpack.canvas.functions.repeatImage.args.maxHelpText": "画像が繰り返される最高回数です。", "xpack.canvas.functions.repeatImage.args.sizeHelpText": "画像の高さまたは幅のピクセル単位での最高値です。画像が縦長の場合、この関数は高さを制限します。", "xpack.canvas.functions.repeatImageHelpText": "繰り返し画像エレメントを構成します。", + "expressionRevealImage.functions.revealImage.args.emptyImageHelpText": "表示される背景画像です。画像アセットは「{BASE64}」データ {URL} として提供するか、部分式で渡します。", + "expressionRevealImage.functions.revealImage.args.imageHelpText": "表示する画像です。画像アセットは{BASE64}データ{URL}として提供するか、部分式で渡します。", + "expressionRevealImage.functions.revealImage.args.originHelpText": "画像で埋め始める位置です。たとえば、{list}、または {end}です。", + "expressionRevealImage.functions.revealImage.invalidPercentErrorMessage": "無効な値:「{percent}」。パーセンテージは 0 と 1 の間でなければなりません ", + "expressionRevealImage.functions.revealImageHelpText": "画像表示エレメントを構成します。", "xpack.canvas.functions.replace.args.flagsHelpText": "フラグを指定します。{url}を参照してください。", "xpack.canvas.functions.replace.args.patternHelpText": "{JS} 正規表現のテキストまたはパターンです。例:{example}。ここではキャプチャグループを使用できます。", "xpack.canvas.functions.replace.args.replacementHelpText": "文字列の一致する部分の代わりです。キャプチャグループはノードによってアクセス可能です。例:{example}。", "xpack.canvas.functions.replaceImageHelpText": "正規表現で文字列の一部を置き換えます。", - "xpack.canvas.functions.revealImage.args.emptyImageHelpText": "表示される背景画像です。画像アセットは「{BASE64}」データ {URL} として提供するか、部分式で渡します。", - "xpack.canvas.functions.revealImage.args.imageHelpText": "表示する画像です。画像アセットは{BASE64}データ{URL}として提供するか、部分式で渡します。", - "xpack.canvas.functions.revealImage.args.originHelpText": "画像で埋め始める位置です。たとえば、{list}、または {end}です。", - "xpack.canvas.functions.revealImage.invalidPercentErrorMessage": "無効な値:「{percent}」。パーセンテージは 0 と 1 の間でなければなりません ", - "xpack.canvas.functions.revealImageHelpText": "画像表示エレメントを構成します。", "xpack.canvas.functions.rounddate.args.formatHelpText": "バケットに使用する{MOMENTJS}フォーマットです。たとえば、{example}は月単位に端数処理されます。{url}を参照してください。", "xpack.canvas.functions.rounddateHelpText": "新世紀からのミリ秒の繰り上げ・繰り下げに {MOMENTJS} を使用し、新世紀からのミリ秒を戻します。", "xpack.canvas.functions.rowCountHelpText": "行数を返します。{plyFn}と組み合わせて、固有の列値の数、または固有の列値の組み合わせを求めます。", @@ -6543,8 +6543,8 @@ "xpack.canvas.renderer.progress.helpDescription": "エレメントのパーセンテージを示す進捗インジケーターをレンダリングします", "xpack.canvas.renderer.repeatImage.displayName": "画像の繰り返し", "xpack.canvas.renderer.repeatImage.helpDescription": "画像を指定回数繰り返し表示します", - "xpack.canvas.renderer.revealImage.displayName": "画像の部分表示", - "xpack.canvas.renderer.revealImage.helpDescription": "カスタムゲージスタイルチャートを作成するため、画像のパーセンテージを表示します", + "expressionRevealImage.renderer.revealImage.displayName": "画像の部分表示", + "expressionRevealImage.renderer.revealImage.helpDescription": "カスタムゲージスタイルチャートを作成するため、画像のパーセンテージを表示します", "xpack.canvas.renderer.shape.displayName": "形状", "xpack.canvas.renderer.shape.helpDescription": "基本的な図形をレンダリングします", "xpack.canvas.renderer.table.displayName": "データテーブル", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 261f68c2e629a..68bd84f6ae757 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -6383,13 +6383,13 @@ "xpack.canvas.functions.replace.args.flagsHelpText": "指定标志。请参见 {url}。", "xpack.canvas.functions.replace.args.patternHelpText": "{JS} 正则表达式的文本或模式。例如,{example}。您可以在此处使用捕获组。", "xpack.canvas.functions.replace.args.replacementHelpText": "字符串匹配部分的替代。捕获组可以通过其索引进行访问。例如,{example}。", - "xpack.canvas.functions.replaceImageHelpText": "使用正则表达式替换字符串的各部分。", - "xpack.canvas.functions.revealImage.args.emptyImageHelpText": "要显示的可选背景图像。以 `{BASE64}` 数据 {URL} 的形式提供图像资产或传入子表达式。", - "xpack.canvas.functions.revealImage.args.imageHelpText": "要显示的图像。以 {BASE64} 数据 {URL} 的形式提供图像资产或传入子表达式。", - "xpack.canvas.functions.revealImage.args.originHelpText": "要开始图像填充的位置。例如 {list} 或 {end}。", - "xpack.canvas.functions.revealImage.invalidPercentErrorMessage": "无效值:“{percent}”。百分比必须介于 0 和 1 之间", - "xpack.canvas.functions.revealImageHelpText": "配置图像显示元素。", "xpack.canvas.functions.rounddate.args.formatHelpText": "用于存储桶存储的 {MOMENTJS} 格式。例如,{example} 四舍五入到月份。请参见 {url}。", + "xpack.canvas.functions.replaceImageHelpText": "使用正则表达式替换字符串的各部分。", + "expressionRevealImage.functions.revealImage.args.emptyImageHelpText": "要显示的可选背景图像。以 `{BASE64}` 数据 {URL} 的形式提供图像资产或传入子表达式。", + "expressionRevealImage.functions.revealImage.args.imageHelpText": "要显示的图像。以 {BASE64} 数据 {URL} 的形式提供图像资产或传入子表达式。", + "expressionRevealImage.functions.revealImage.args.originHelpText": "要开始图像填充的位置。例如 {list} 或 {end}。", + "expressionRevealImage.functions.revealImage.invalidPercentErrorMessage": "无效值:“{percent}”。百分比必须介于 0 和 1 之间", + "expressionRevealImage.functions.revealImageHelpText": "配置图像显示元素。", "xpack.canvas.functions.rounddateHelpText": "使用 {MOMENTJS} 格式字符串舍入自 Epoch 起毫秒数,并返回自 Epoch 起毫秒数。", "xpack.canvas.functions.rowCountHelpText": "返回行数。与 {plyFn} 搭配使用,可获取唯一列值的计数或唯一列值的组合。", "xpack.canvas.functions.savedLens.args.idHelpText": "已保存 Lens 可视化对象的 ID", @@ -6583,8 +6583,8 @@ "xpack.canvas.renderer.progress.helpDescription": "呈现显示元素百分比的进度指示", "xpack.canvas.renderer.repeatImage.displayName": "图像重复", "xpack.canvas.renderer.repeatImage.helpDescription": "重复图像给定次数", - "xpack.canvas.renderer.revealImage.displayName": "图像显示", - "xpack.canvas.renderer.revealImage.helpDescription": "显示一定百分比的图像,以制作定制的仪表样式图表", + "expressionRevealImage.renderer.revealImage.displayName": "图像显示", + "expressionRevealImage.renderer.revealImage.helpDescription": "显示一定百分比的图像,以制作定制的仪表样式图表", "xpack.canvas.renderer.shape.displayName": "形状", "xpack.canvas.renderer.shape.helpDescription": "呈现基本形状", "xpack.canvas.renderer.table.displayName": "数据表", From 2f25c26abccfd0ab54073cb186c6e0a6e9c8af09 Mon Sep 17 00:00:00 2001 From: Michael Olorunnisola <michael.olorunnisola@elastic.co> Date: Thu, 1 Jul 2021 07:33:42 -0400 Subject: [PATCH 34/51] [Security Solution] External alerts and Modal bug fix (#103933) --- .../public/common/mock/global_state.ts | 4 +- .../hosts/pages/details/details_tabs.test.tsx | 13 +- .../public/hosts/store/helpers.test.ts | 24 +- .../public/hosts/store/model.ts | 2 +- .../__snapshots__/index.test.tsx.snap | 253 +----------------- .../timelines/components/side_panel/index.tsx | 15 +- 6 files changed, 32 insertions(+), 279 deletions(-) diff --git a/x-pack/plugins/security_solution/public/common/mock/global_state.ts b/x-pack/plugins/security_solution/public/common/mock/global_state.ts index 316f8b6214d1e..ffbfd1a5123ad 100644 --- a/x-pack/plugins/security_solution/public/common/mock/global_state.ts +++ b/x-pack/plugins/security_solution/public/common/mock/global_state.ts @@ -59,7 +59,7 @@ export const mockGlobalState: State = { events: { activePage: 0, limit: 10 }, uncommonProcesses: { activePage: 0, limit: 10 }, anomalies: null, - alerts: { activePage: 0, limit: 10 }, + externalAlerts: { activePage: 0, limit: 10 }, }, }, details: { @@ -74,7 +74,7 @@ export const mockGlobalState: State = { events: { activePage: 0, limit: 10 }, uncommonProcesses: { activePage: 0, limit: 10 }, anomalies: null, - alerts: { activePage: 0, limit: 10 }, + externalAlerts: { activePage: 0, limit: 10 }, }, }, }, diff --git a/x-pack/plugins/security_solution/public/hosts/pages/details/details_tabs.test.tsx b/x-pack/plugins/security_solution/public/hosts/pages/details/details_tabs.test.tsx index 3b76ec8a0d13f..5be29a94b5330 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/details/details_tabs.test.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/details/details_tabs.test.tsx @@ -18,6 +18,7 @@ import { hostDetailsPagePath } from '../types'; import { type } from './utils'; import { useMountAppended } from '../../../common/utils/use_mount_appended'; import { getHostDetailsPageFilters } from './helpers'; +import { HostsTableType } from '../../store/model'; jest.mock('../../../common/lib/kibana'); @@ -51,12 +52,12 @@ mockUseResizeObserver.mockImplementation(() => ({})); describe('body', () => { const scenariosMap = { - authentications: 'AuthenticationsQueryTabBody', - allHosts: 'HostsQueryTabBody', - uncommonProcesses: 'UncommonProcessQueryTabBody', - anomalies: 'AnomaliesQueryTabBody', - events: 'EventsQueryTabBody', - alerts: 'HostAlertsQueryTabBody', + [HostsTableType.authentications]: 'AuthenticationsQueryTabBody', + [HostsTableType.hosts]: 'HostsQueryTabBody', + [HostsTableType.uncommonProcesses]: 'UncommonProcessQueryTabBody', + [HostsTableType.anomalies]: 'AnomaliesQueryTabBody', + [HostsTableType.events]: 'EventsQueryTabBody', + [HostsTableType.alerts]: 'HostAlertsQueryTabBody', }; const mockHostDetailsPageFilters = getHostDetailsPageFilters('host-1'); diff --git a/x-pack/plugins/security_solution/public/hosts/store/helpers.test.ts b/x-pack/plugins/security_solution/public/hosts/store/helpers.test.ts index c9dcc3a60b4a9..8c3a3e27ffb38 100644 --- a/x-pack/plugins/security_solution/public/hosts/store/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/hosts/store/helpers.test.ts @@ -71,26 +71,26 @@ describe('Hosts redux store', () => { describe('#setHostsQueriesActivePageToZero', () => { test('set activePage to zero for all queries in hosts page ', () => { expect(setHostsQueriesActivePageToZero(mockHostsState, HostsType.page)).toEqual({ - allHosts: { + [HostsTableType.hosts]: { activePage: 0, direction: 'desc', limit: 10, sortField: 'lastSeen', }, - anomalies: null, - authentications: { + [HostsTableType.anomalies]: null, + [HostsTableType.authentications]: { activePage: 0, limit: 10, }, - events: { + [HostsTableType.events]: { activePage: 0, limit: 10, }, - uncommonProcesses: { + [HostsTableType.uncommonProcesses]: { activePage: 0, limit: 10, }, - alerts: { + [HostsTableType.alerts]: { activePage: 0, limit: 10, }, @@ -99,26 +99,26 @@ describe('Hosts redux store', () => { test('set activePage to zero for all queries in host details ', () => { expect(setHostsQueriesActivePageToZero(mockHostsState, HostsType.details)).toEqual({ - allHosts: { + [HostsTableType.hosts]: { activePage: 0, direction: 'desc', limit: 10, sortField: 'lastSeen', }, - anomalies: null, - authentications: { + [HostsTableType.anomalies]: null, + [HostsTableType.authentications]: { activePage: 0, limit: 10, }, - events: { + [HostsTableType.events]: { activePage: 0, limit: 10, }, - uncommonProcesses: { + [HostsTableType.uncommonProcesses]: { activePage: 0, limit: 10, }, - alerts: { + [HostsTableType.alerts]: { activePage: 0, limit: 10, }, diff --git a/x-pack/plugins/security_solution/public/hosts/store/model.ts b/x-pack/plugins/security_solution/public/hosts/store/model.ts index 2060d46206723..ea168e965fa23 100644 --- a/x-pack/plugins/security_solution/public/hosts/store/model.ts +++ b/x-pack/plugins/security_solution/public/hosts/store/model.ts @@ -19,7 +19,7 @@ export enum HostsTableType { events = 'events', uncommonProcesses = 'uncommonProcesses', anomalies = 'anomalies', - alerts = 'alerts', + alerts = 'externalAlerts', } export interface BasicQueryPaginated { diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap index 06db698a91a6d..edf1a50787a57 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap @@ -241,248 +241,14 @@ exports[`Details Panel Component DetailsPanel:EventDetails: rendering it should exports[`Details Panel Component DetailsPanel:EventDetails: rendering it should render the Event Details view of the Details Panel in the flyout when the panelView is eventDetail and the eventId is set 1`] = ` Array [ - .c1 { - -webkit-flex: 0 1 auto; - -ms-flex: 0 1 auto; - flex: 0 1 auto; - margin-top: 8px; -} - -.c2 .euiFlyoutBody__overflow { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-flex: 1; - -ms-flex: 1; - flex: 1; - overflow: hidden; -} - -.c2 .euiFlyoutBody__overflow .euiFlyoutBody__overflowContent { - -webkit-flex: 1; - -ms-flex: 1; - flex: 1; - overflow: hidden; - padding: 4px 16px 50px; -} - -.c0 { - z-index: 7000; -} - -<Styled(EuiFlyout) - data-test-subj="timeline:details-panel:flyout" - onClose={[Function]} - ownFocus={false} - size="m" - > - <EuiFlyout - className="c0" - data-test-subj="timeline:details-panel:flyout" - onClose={[Function]} - ownFocus={false} - size="m" - > - <div - data-eui="EuiFlyout" - data-test-subj="timeline:details-panel:flyout" - role="dialog" - > - <button - data-test-subj="euiFlyoutCloseButton" - onClick={[Function]} - type="button" - /> - <EventDetailsPanelComponent - browserFields={Object {}} - docValueFields={Array []} - expandedEvent={ - Object { - "eventId": "my-id", - "indexName": "my-index", - } - } - handleOnEventClosed={[Function]} - isFlyoutView={true} - tabType="query" - timelineId="test" - > - <EuiFlyoutHeader - hasBorder={true} - > - <div - className="euiFlyoutHeader euiFlyoutHeader--hasBorder" - > - <ExpandableEventTitle - isAlert={false} - loading={true} - > - <Styled(EuiFlexGroup) - gutterSize="none" - justifyContent="spaceBetween" - wrap={true} - > - <EuiFlexGroup - className="c1" - gutterSize="none" - justifyContent="spaceBetween" - wrap={true} - > - <div - className="euiFlexGroup euiFlexGroup--justifyContentSpaceBetween euiFlexGroup--directionRow euiFlexGroup--responsive euiFlexGroup--wrap c1" - > - <EuiFlexItem - grow={false} - > - <div - className="euiFlexItem euiFlexItem--flexGrowZero" - > - <EuiTitle - size="s" - /> - </div> - </EuiFlexItem> - </div> - </EuiFlexGroup> - </Styled(EuiFlexGroup)> - </ExpandableEventTitle> - </div> - </EuiFlyoutHeader> - <Styled(EuiFlyoutBody)> - <EuiFlyoutBody - className="c2" - > - <div - className="euiFlyoutBody c2" - > - <div - className="euiFlyoutBody__overflow" - tabIndex={0} - > - <div - className="euiFlyoutBody__overflowContent" - > - <ExpandableEvent - browserFields={Object {}} - detailsData={null} - event={ - Object { - "eventId": "my-id", - "indexName": "my-index", - } - } - isAlert={false} - loading={true} - timelineId="test" - timelineTabType="flyout" - > - <EuiLoadingContent - lines={10} - > - <span - className="euiLoadingContent" - > - <span - className="euiLoadingContent__singleLine" - key="0" - > - <span - className="euiLoadingContent__singleLineBackground" - /> - </span> - <span - className="euiLoadingContent__singleLine" - key="1" - > - <span - className="euiLoadingContent__singleLineBackground" - /> - </span> - <span - className="euiLoadingContent__singleLine" - key="2" - > - <span - className="euiLoadingContent__singleLineBackground" - /> - </span> - <span - className="euiLoadingContent__singleLine" - key="3" - > - <span - className="euiLoadingContent__singleLineBackground" - /> - </span> - <span - className="euiLoadingContent__singleLine" - key="4" - > - <span - className="euiLoadingContent__singleLineBackground" - /> - </span> - <span - className="euiLoadingContent__singleLine" - key="5" - > - <span - className="euiLoadingContent__singleLineBackground" - /> - </span> - <span - className="euiLoadingContent__singleLine" - key="6" - > - <span - className="euiLoadingContent__singleLineBackground" - /> - </span> - <span - className="euiLoadingContent__singleLine" - key="7" - > - <span - className="euiLoadingContent__singleLineBackground" - /> - </span> - <span - className="euiLoadingContent__singleLine" - key="8" - > - <span - className="euiLoadingContent__singleLineBackground" - /> - </span> - <span - className="euiLoadingContent__singleLine" - key="9" - > - <span - className="euiLoadingContent__singleLineBackground" - /> - </span> - </span> - </EuiLoadingContent> - </ExpandableEvent> - </div> - </div> - </div> - </EuiFlyoutBody> - </Styled(EuiFlyoutBody)> - </EventDetailsPanelComponent> - </div> - </EuiFlyout> - </Styled(EuiFlyout)>, - .c1 { + .c0 { -webkit-flex: 0 1 auto; -ms-flex: 0 1 auto; flex: 0 1 auto; margin-top: 8px; } -.c2 .euiFlyoutBody__overflow { +.c1 .euiFlyoutBody__overflow { display: -webkit-box; display: -webkit-flex; display: -ms-flexbox; @@ -493,7 +259,7 @@ Array [ overflow: hidden; } -.c2 .euiFlyoutBody__overflow .euiFlyoutBody__overflowContent { +.c1 .euiFlyoutBody__overflow .euiFlyoutBody__overflowContent { -webkit-flex: 1; -ms-flex: 1; flex: 1; @@ -501,12 +267,7 @@ Array [ padding: 4px 16px 50px; } -.c0 { - z-index: 7000; -} - <EuiFlyout - className="c0" data-test-subj="timeline:details-panel:flyout" onClose={[Function]} ownFocus={false} @@ -552,13 +313,13 @@ Array [ wrap={true} > <EuiFlexGroup - className="c1" + className="c0" gutterSize="none" justifyContent="spaceBetween" wrap={true} > <div - className="euiFlexGroup euiFlexGroup--justifyContentSpaceBetween euiFlexGroup--directionRow euiFlexGroup--responsive euiFlexGroup--wrap c1" + className="euiFlexGroup euiFlexGroup--justifyContentSpaceBetween euiFlexGroup--directionRow euiFlexGroup--responsive euiFlexGroup--wrap c0" > <EuiFlexItem grow={false} @@ -579,10 +340,10 @@ Array [ </EuiFlyoutHeader> <Styled(EuiFlyoutBody)> <EuiFlyoutBody - className="c2" + className="c1" > <div - className="euiFlyoutBody c2" + className="euiFlyoutBody c1" > <div className="euiFlyoutBody__overflow" diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/index.tsx index ea408be7c8e9a..3e57ec2e039f5 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/index.tsx @@ -5,10 +5,9 @@ * 2.0. */ -import React, { useCallback, useMemo, ReactNode } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { useDispatch } from 'react-redux'; import { EuiFlyout, EuiFlyoutProps } from '@elastic/eui'; -import styled, { StyledComponent } from 'styled-components'; import { timelineActions, timelineSelectors } from '../../store/timeline'; import { timelineDefaults } from '../../store/timeline/defaults'; import { BrowserFields, DocValueFields } from '../../../common/containers/source'; @@ -18,14 +17,6 @@ import { EventDetailsPanel } from './event_details'; import { HostDetailsPanel } from './host_details'; import { NetworkDetailsPanel } from './network_details'; -// TODO: EUI team follow up on complex types and styled-components `styled` -// https://github.com/elastic/eui/issues/4855 -const StyledEuiFlyout: StyledComponent<typeof EuiFlyout, {}, { children?: ReactNode }> = styled( - EuiFlyout -)` - z-index: ${({ theme }) => theme.eui.euiZLevel7}; -`; - interface DetailsPanelProps { browserFields: BrowserFields; docValueFields: DocValueFields[]; @@ -113,14 +104,14 @@ export const DetailsPanel = React.memo( } return isFlyoutView ? ( - <StyledEuiFlyout + <EuiFlyout data-test-subj="timeline:details-panel:flyout" size={panelSize} onClose={closePanel} ownFocus={false} > {visiblePanel} - </StyledEuiFlyout> + </EuiFlyout> ) : ( visiblePanel ); From 16da1a6dbeed0b63409c3cd259f4ffeef738c4df Mon Sep 17 00:00:00 2001 From: Bhavya RM <bhavya@elastic.co> Date: Thu, 1 Jul 2021 08:13:17 -0400 Subject: [PATCH 35/51] Adding alerts and connectors so to import between versions test (#103968) * adding alerts and connectors to import/export test * removing beforeEach Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../_7.14_import_alerts_actions.ndjson | 24 +++++++++++++++++++ .../import_saved_objects_between_versions.ts | 18 +++++++++++++- 2 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 x-pack/test/functional/apps/saved_objects_management/exports/_7.14_import_alerts_actions.ndjson diff --git a/x-pack/test/functional/apps/saved_objects_management/exports/_7.14_import_alerts_actions.ndjson b/x-pack/test/functional/apps/saved_objects_management/exports/_7.14_import_alerts_actions.ndjson new file mode 100644 index 0000000000000..f0215db3cda69 --- /dev/null +++ b/x-pack/test/functional/apps/saved_objects_management/exports/_7.14_import_alerts_actions.ndjson @@ -0,0 +1,24 @@ +{"attributes":{"actionTypeId":".server-log","config":{},"isMissingSecrets":false,"name":"Monitoring: Write to Kibana log"},"coreMigrationVersion":"7.14.0","id":"f1cf69c0-d9a7-11eb-881a-218d2e96295d","migrationVersion":{"action":"7.14.0"},"references":[],"type":"action","updated_at":"2021-06-30T13:34:50.470Z","version":"WzEzODksMV0="} +{"attributes":{"actionTypeId":".email","config":{"from":"user2@company.com","hasAuth":true,"host":"securehost","port":465,"secure":null,"service":null},"isMissingSecrets":true,"name":"email connector with auth"},"coreMigrationVersion":"7.14.0","id":"7eec9570-d9a4-11eb-881a-218d2e96295d","migrationVersion":{"action":"7.14.0"},"references":[],"type":"action","updated_at":"2021-06-30T13:10:09.234Z","version":"WzI2LDFd"} +{"attributes":{"actionTypeId":".resilient","config":{"apiUrl":"https://resilienttest","orgId":"test"},"isMissingSecrets":true,"name":"ibm resilient connector"},"coreMigrationVersion":"7.14.0","id":"8e08afd0-d9a4-11eb-881a-218d2e96295d","migrationVersion":{"action":"7.14.0"},"references":[],"type":"action","updated_at":"2021-06-30T13:10:34.583Z","version":"WzI3LDFd"} +{"attributes":{"actionTypeId":".email","config":{"from":"user@company.com","hasAuth":false,"host":"host","port":22,"secure":null,"service":null},"isMissingSecrets":false,"name":"email connector no auth"},"coreMigrationVersion":"7.14.0","id":"711e30c0-d9a4-11eb-881a-218d2e96295d","migrationVersion":{"action":"7.14.0"},"references":[],"type":"action","updated_at":"2021-06-30T13:09:46.078Z","version":"WzI1LDFd"} +{"attributes":{"actionTypeId":".index","config":{"executionTimeField":null,"index":"test-index","refresh":false},"isMissingSecrets":false,"name":"index connector"},"coreMigrationVersion":"7.14.0","id":"95d329c0-d9a4-11eb-881a-218d2e96295d","migrationVersion":{"action":"7.14.0"},"references":[],"type":"action","updated_at":"2021-06-30T13:10:47.653Z","version":"WzI5LDFd"} +{"attributes":{"actionTypeId":".webhook","config":{"hasAuth":true,"headers":null,"method":"post","url":"https://webhook"},"isMissingSecrets":true,"name":"webhook with auth"},"coreMigrationVersion":"7.14.0","id":"07f32aa0-d9a5-11eb-881a-218d2e96295d","migrationVersion":{"action":"7.14.0"},"references":[],"type":"action","updated_at":"2021-06-30T13:13:59.125Z","version":"WzM5LDFd"} +{"attributes":{"actionTypeId":".servicenow-sir","config":{"apiUrl":"https://servicenowtestsecops"},"isMissingSecrets":true,"name":"servicenow secops connector"},"coreMigrationVersion":"7.14.0","id":"ca974fb0-d9a4-11eb-881a-218d2e96295d","migrationVersion":{"action":"7.14.0"},"references":[],"type":"action","updated_at":"2021-06-30T13:12:16.181Z","version":"WzM1LDFd"} +{"attributes":{"actionTypeId":".servicenow","config":{"apiUrl":"https://servicenowtest"},"isMissingSecrets":true,"name":"servicenow itsm connector"},"coreMigrationVersion":"7.14.0","id":"be5c5c40-d9a4-11eb-881a-218d2e96295d","migrationVersion":{"action":"7.14.0"},"references":[],"type":"action","updated_at":"2021-06-30T13:11:55.662Z","version":"WzM0LDFd"} +{"attributes":{"actionTypeId":".webhook","config":{"hasAuth":false,"headers":null,"method":"post","url":"https://openwebhook"},"isMissingSecrets":false,"name":"webhook no auth"},"coreMigrationVersion":"7.14.0","id":"ff8c70b0-d9a4-11eb-881a-218d2e96295d","migrationVersion":{"action":"7.14.0"},"references":[],"type":"action","updated_at":"2021-06-30T13:13:45.031Z","version":"WzM3LDFd"} +{"attributes":{"actionTypeId":".pagerduty","config":{"apiUrl":""},"isMissingSecrets":true,"name":"pagerduty connector"},"coreMigrationVersion":"7.14.0","id":"b0bc3380-d9a4-11eb-881a-218d2e96295d","migrationVersion":{"action":"7.14.0"},"references":[],"type":"action","updated_at":"2021-06-30T13:11:32.802Z","version":"WzMyLDFd"} +{"attributes":{"actionTypeId":".jira","config":{"apiUrl":"https://testjira","projectKey":"myproject"},"isMissingSecrets":true,"name":"jira connector"},"coreMigrationVersion":"7.14.0","id":"a081d7e0-d9a4-11eb-881a-218d2e96295d","migrationVersion":{"action":"7.14.0"},"references":[],"type":"action","updated_at":"2021-06-30T13:11:05.577Z","version":"WzMwLDFd"} +{"attributes":{"actionTypeId":".server-log","config":{},"isMissingSecrets":false,"name":"server log connector"},"coreMigrationVersion":"7.14.0","id":"b5442ca0-d9a4-11eb-881a-218d2e96295d","migrationVersion":{"action":"7.14.0"},"references":[],"type":"action","updated_at":"2021-06-30T13:11:40.404Z","version":"WzMzLDFd"} +{"attributes":{"actionTypeId":".teams","config":{},"isMissingSecrets":true,"name":"teams connector"},"coreMigrationVersion":"7.14.0","id":"a94be780-d9a4-11eb-881a-218d2e96295d","migrationVersion":{"action":"7.14.0"},"references":[],"type":"action","updated_at":"2021-06-30T13:11:20.321Z","version":"WzMxLDFd"} +{"attributes":{"actionTypeId":".slack","config":{},"isMissingSecrets":true,"name":"slack connector"},"coreMigrationVersion":"7.14.0","id":"d6d1cdf0-d9a4-11eb-881a-218d2e96295d","migrationVersion":{"action":"7.14.0"},"references":[],"type":"action","updated_at":"2021-06-30T13:12:36.697Z","version":"WzM2LDFd"} +{"attributes":{"actions":[],"alertTypeId":".geo-containment","apiKey":null,"apiKeyOwner":null,"consumer":"alerts","createdAt":"2021-06-30T13:30:28.418Z","createdBy":"elastic","enabled":false,"executionStatus":{"error":null,"lastExecutionDate":"2021-06-30T13:42:03.746Z","status":"pending"},"meta":{"versionApiKeyLastmodified":"8.0.0"},"muteAll":false,"mutedInstanceIds":[],"name":"geo rule","notifyWhen":"onActionGroupChange","params":{"boundaryGeoField":"coordinates","boundaryIndexId":"c2a20a50-d9a6-11eb-881a-218d2e96295d","boundaryIndexTitle":"manhattan_boundaries","boundaryNameField":"<nothing selected>","boundaryType":"entireIndex","dateField":"@timestamp","entity":"azimuth","geoField":"location","index":"tracks*","indexId":"f653fcf0-d9a6-11eb-881a-218d2e96295d"},"schedule":{"interval":"5m"},"scheduledTaskId":null,"tags":["manhattan"],"throttle":null,"updatedAt":"2021-06-30T13:30:28.418Z","updatedBy":"elastic"},"coreMigrationVersion":"7.14.0","id":"55626650-d9a7-11eb-881a-218d2e96295d","migrationVersion":{"alert":"7.13.0"},"references":[],"type":"alert","updated_at":"2021-06-30T13:40:37.967Z","version":"WzE1MDksMV0="} +{"attributes":{"actions":[],"alertTypeId":"logs.alert.document.count","apiKey":null,"apiKeyOwner":null,"consumer":"alerts","createdAt":"2021-06-30T13:20:56.718Z","createdBy":"elastic","enabled":false,"executionStatus":{"error":null,"lastExecutionDate":"2021-06-30T13:42:03.746Z","status":"pending"},"meta":{"versionApiKeyLastmodified":"8.0.0"},"muteAll":false,"mutedInstanceIds":[],"name":"logs threshold rule","notifyWhen":"onActiveAlert","params":{"count":{"comparator":"more than","value":75},"criteria":[{"comparator":"equals","field":"host.keyword","value":"host1"}],"timeSize":5,"timeUnit":"m"},"schedule":{"interval":"1m"},"scheduledTaskId":null,"tags":["logs","test"],"throttle":null,"updatedAt":"2021-06-30T13:20:56.718Z","updatedBy":"elastic"},"coreMigrationVersion":"7.14.0","id":"00b51cc0-d9a6-11eb-881a-218d2e96295d","migrationVersion":{"alert":"7.13.0"},"references":[],"type":"alert","updated_at":"2021-06-30T13:42:01.071Z","version":"WzE1MjYsMV0="} +{"attributes":{"actions":[{"actionRef":"action_0","actionTypeId":".server-log","group":"query matched","params":{"level":"info","message":"Elasticsearch query alert '{{alertName}}' is active:\n\n- Value: {{context.value}}\n- Conditions Met: {{context.conditions}} over {{params.timeWindowSize}}{{params.timeWindowUnit}}\n- Timestamp: {{context.date}}"}}],"alertTypeId":".es-query","apiKey":null,"apiKeyOwner":null,"consumer":"alerts","createdAt":"2021-06-30T13:19:13.441Z","createdBy":"elastic","enabled":false,"executionStatus":{"error":null,"lastExecutionDate":"2021-06-30T13:42:03.746Z","status":"pending"},"meta":{"versionApiKeyLastmodified":"8.0.0"},"muteAll":false,"mutedInstanceIds":[],"name":"es query rule","notifyWhen":"onActionGroupChange","params":{"esQuery":"{\n \"query\":{\n \"match_all\" : {}\n }\n}","index":[".kibana"],"size":100,"threshold":[1000],"thresholdComparator":">","timeField":"updated_at","timeWindowSize":5,"timeWindowUnit":"m"},"schedule":{"interval":"1m"},"scheduledTaskId":null,"tags":["es","query"],"throttle":null,"updatedAt":"2021-06-30T13:19:13.441Z","updatedBy":"elastic"},"coreMigrationVersion":"7.14.0","id":"c3172fc0-d9a5-11eb-881a-218d2e96295d","migrationVersion":{"alert":"7.13.0"},"references":[{"id":"b5442ca0-d9a4-11eb-881a-218d2e96295d","name":"action_0","type":"action"}],"type":"alert","updated_at":"2021-06-30T13:41:19.057Z","version":"WzE1MjAsMV0="} +{"attributes":{"actions":[],"alertTypeId":"xpack.uptime.alerts.monitorStatus","apiKey":null,"apiKeyOwner":null,"consumer":"alerts","createdAt":"2021-06-30T13:22:48.241Z","createdBy":"elastic","enabled":false,"executionStatus":{"error":null,"lastExecutionDate":"2021-06-30T13:42:03.746Z","status":"pending"},"meta":{"versionApiKeyLastmodified":"8.0.0"},"muteAll":false,"mutedInstanceIds":[],"name":"uptime status","notifyWhen":"onActiveAlert","params":{"availability":{"range":30,"rangeUnit":"d","threshold":"99"},"numTimes":5,"search":"","shouldCheckAvailability":true,"shouldCheckStatus":true,"timerangeCount":15,"timerangeUnit":"m"},"schedule":{"interval":"1d"},"scheduledTaskId":null,"tags":[],"throttle":null,"updatedAt":"2021-06-30T13:22:48.241Z","updatedBy":"elastic"},"coreMigrationVersion":"7.14.0","id":"432dddd0-d9a6-11eb-881a-218d2e96295d","migrationVersion":{"alert":"7.13.0"},"references":[],"type":"alert","updated_at":"2021-06-30T13:22:51.893Z","version":"WzEwMywxXQ=="} +{"attributes":{"actions":[],"alertTypeId":"metrics.alert.threshold","apiKey":null,"apiKeyOwner":null,"consumer":"alerts","createdAt":"2021-06-30T13:22:25.161Z","createdBy":"elastic","enabled":false,"executionStatus":{"error":null,"lastExecutionDate":"2021-06-30T13:42:03.746Z","status":"pending"},"meta":{"versionApiKeyLastmodified":"8.0.0"},"muteAll":false,"mutedInstanceIds":[],"name":"metric threshold rule","notifyWhen":"onActionGroupChange","params":{"criteria":[{"aggType":"avg","comparator":">","metric":"_score","threshold":[0.5],"timeSize":1,"timeUnit":"m"}],"sourceId":"default"},"schedule":{"interval":"1h"},"scheduledTaskId":null,"tags":[],"throttle":null,"updatedAt":"2021-06-30T13:22:25.161Z","updatedBy":"elastic"},"coreMigrationVersion":"7.14.0","id":"34dba320-d9a6-11eb-881a-218d2e96295d","migrationVersion":{"alert":"7.13.0"},"references":[],"type":"alert","updated_at":"2021-06-30T13:22:27.874Z","version":"Wzk5LDFd"} +{"attributes":{"actions":[],"alertTypeId":"xpack.uptime.alerts.tlsCertificate","apiKey":null,"apiKeyOwner":null,"consumer":"alerts","createdAt":"2021-06-30T13:23:14.340Z","createdBy":"elastic","enabled":false,"executionStatus":{"error":null,"lastExecutionDate":"2021-06-30T13:42:03.746Z","status":"pending"},"meta":{"versionApiKeyLastmodified":"8.0.0"},"muteAll":false,"mutedInstanceIds":[],"name":"tls rule","notifyWhen":"onThrottleInterval","params":{},"schedule":{"interval":"1d"},"scheduledTaskId":null,"tags":["certificate"],"throttle":"1h","updatedAt":"2021-06-30T13:23:14.340Z","updatedBy":"elastic"},"coreMigrationVersion":"7.14.0","id":"52990290-d9a6-11eb-881a-218d2e96295d","migrationVersion":{"alert":"7.13.0"},"references":[],"type":"alert","updated_at":"2021-06-30T13:23:15.928Z","version":"WzEwNywxXQ=="} +{"attributes":{"actions":[{"actionRef":"action_0","actionTypeId":".index","group":"metrics.inventory_threshold.fired","params":{"documents":[{"alert_triggered":"{{rule.id}}"}]}}],"alertTypeId":"metrics.alert.inventory.threshold","apiKey":null,"apiKeyOwner":null,"consumer":"alerts","createdAt":"2021-06-30T13:21:53.897Z","createdBy":"elastic","enabled":false,"executionStatus":{"error":null,"lastExecutionDate":"2021-06-30T13:42:03.746Z","status":"pending"},"meta":{"versionApiKeyLastmodified":"8.0.0"},"muteAll":false,"mutedInstanceIds":[],"name":"inventory rule","notifyWhen":"onActionGroupChange","params":{"criteria":[{"comparator":">","customMetric":{"aggregation":"avg","field":"","id":"alert-custom-metric","type":"custom"},"metric":"cpu","threshold":[90],"timeSize":1,"timeUnit":"m"}],"nodeType":"host","sourceId":"default"},"schedule":{"interval":"10m"},"scheduledTaskId":null,"tags":["inventory"],"throttle":null,"updatedAt":"2021-06-30T13:21:53.897Z","updatedBy":"elastic"},"coreMigrationVersion":"7.14.0","id":"22e0f9e0-d9a6-11eb-881a-218d2e96295d","migrationVersion":{"alert":"7.13.0"},"references":[{"id":"95d329c0-d9a4-11eb-881a-218d2e96295d","name":"action_0","type":"action"}],"type":"alert","updated_at":"2021-06-30T13:42:01.078Z","version":"WzE1MjcsMV0="} +{"attributes":{"actions":[{"actionRef":"action_0","actionTypeId":".server-log","group":"anomaly_score_match","params":{"level":"info","message":"Elastic Stack Machine Learning Alert:\n- Job IDs: {{context.jobIds}}\n- Time: {{context.timestampIso8601}}\n- Anomaly score: {{context.score}}\n\n{{context.message}}\n\n{{#context.topInfluencers.length}}\n Top influencers:\n {{#context.topInfluencers}}\n {{influencer_field_name}} = {{influencer_field_value}} [{{score}}]\n {{/context.topInfluencers}}\n{{/context.topInfluencers.length}}\n\n{{#context.topRecords.length}}\n Top records:\n {{#context.topRecords}}\n {{function}}({{field_name}}) {{by_field_value}} {{over_field_value}} {{partition_field_value}} [{{score}}]\n {{/context.topRecords}}\n{{/context.topRecords.length}}\n\n{{! Replace kibanaBaseUrl if not configured in Kibana }}\n[Open in Anomaly Explorer]({{{kibanaBaseUrl}}}{{{context.anomalyExplorerUrl}}})\n"}}],"alertTypeId":"xpack.ml.anomaly_detection_alert","apiKey":null,"apiKeyOwner":null,"consumer":"alerts","createdAt":"2021-06-30T13:32:13.689Z","createdBy":"elastic","enabled":false,"executionStatus":{"error":null,"lastExecutionDate":"2021-06-30T13:42:03.746Z","status":"pending"},"meta":{"versionApiKeyLastmodified":"8.0.0"},"muteAll":false,"mutedInstanceIds":[],"name":"ecommerce ml","notifyWhen":"onActionGroupChange","params":{"includeInterim":false,"jobSelection":{"groupIds":[],"jobIds":["high_sum_total_sales"]},"lookbackInterval":null,"resultType":"bucket","severity":75,"topNBuckets":null},"schedule":{"interval":"1h"},"scheduledTaskId":null,"tags":[],"throttle":null,"updatedAt":"2021-06-30T13:32:13.689Z","updatedBy":"elastic"},"coreMigrationVersion":"7.14.0","id":"93ea6530-d9a7-11eb-881a-218d2e96295d","migrationVersion":{"alert":"7.13.0"},"references":[{"id":"b5442ca0-d9a4-11eb-881a-218d2e96295d","name":"action_0","type":"action"}],"type":"alert","updated_at":"2021-06-30T13:32:15.978Z","version":"WzI0NiwxXQ=="} +{"attributes":{"actions":[{"actionRef":"action_0","actionTypeId":".email","group":"threshold met","params":{"message":"alert '{{alertName}}' is active for group '{{context.group}}':\n\n- Value: {{context.value}}\n- Conditions Met: {{context.conditions}} over {{params.timeWindowSize}}{{params.timeWindowUnit}}\n- Timestamp: {{context.date}}","subject":"alert fired!","to":["user@company.com"]}},{"actionRef":"action_1","actionTypeId":".email","group":"threshold met","params":{"message":"alert '{{alertName}}' is active for group '{{context.group}}':\n\n- Value: {{context.value}}\n- Conditions Met: {{context.conditions}} over {{params.timeWindowSize}}{{params.timeWindowUnit}}\n- Timestamp: {{context.date}}","subject":"alert triggered!","to":["user2@company.com"]}},{"actionRef":"action_2","actionTypeId":".index","group":"threshold met","params":{"documents":[{"alert_triggered":"{{rule.id}}"}]}},{"actionRef":"action_3","actionTypeId":".teams","group":"threshold met","params":{"message":"alert '{{alertName}}' is active for group '{{context.group}}':\n\n- Value: {{context.value}}\n- Conditions Met: {{context.conditions}} over {{params.timeWindowSize}}{{params.timeWindowUnit}}\n- Timestamp: {{context.date}}"}},{"actionRef":"action_4","actionTypeId":".pagerduty","group":"threshold met","params":{"dedupKey":"{{rule.id}}:{{alert.id}}","eventAction":"trigger","summary":"triggered"}},{"actionRef":"action_5","actionTypeId":".server-log","group":"threshold met","params":{"level":"info","message":"alert '{{alertName}}' is active for group '{{context.group}}':\n\n- Value: {{context.value}}\n- Conditions Met: {{context.conditions}} over {{params.timeWindowSize}}{{params.timeWindowUnit}}\n- Timestamp: {{context.date}}"}},{"actionRef":"action_6","actionTypeId":".slack","group":"threshold met","params":{"message":"alert '{{alertName}}' is active for group '{{context.group}}':\n\n- Value: {{context.value}}\n- Conditions Met: {{context.conditions}} over {{params.timeWindowSize}}{{params.timeWindowUnit}}\n- Timestamp: {{context.date}}"}},{"actionRef":"action_7","actionTypeId":".webhook","group":"threshold met","params":{"body":"{\n \"alert_triggered\": \"{{rule.id}}\"\n}"}},{"actionRef":"action_8","actionTypeId":".webhook","group":"threshold met","params":{"body":"{\n \"alert_triggered\": \"{{rule.id}}\"\n}"}}],"alertTypeId":".index-threshold","apiKey":null,"apiKeyOwner":null,"consumer":"alerts","createdAt":"2021-06-30T13:18:16.273Z","createdBy":"elastic","enabled":false,"executionStatus":{"error":null,"lastExecutionDate":"2021-06-30T13:42:03.746Z","status":"pending"},"meta":{"versionApiKeyLastmodified":"8.0.0"},"muteAll":false,"mutedInstanceIds":[],"name":"index threshold rule with actions","notifyWhen":"onActionGroupChange","params":{"aggType":"count","groupBy":"all","index":[".kibana"],"termSize":5,"threshold":[1000],"thresholdComparator":">","timeField":"updated_at","timeWindowSize":5,"timeWindowUnit":"m"},"schedule":{"interval":"1m"},"scheduledTaskId":null,"tags":[],"throttle":null,"updatedAt":"2021-06-30T13:41:45.350Z","updatedBy":"elastic"},"coreMigrationVersion":"7.14.0","id":"a0bfd5d0-d9a5-11eb-881a-218d2e96295d","migrationVersion":{"alert":"7.13.0"},"references":[{"id":"711e30c0-d9a4-11eb-881a-218d2e96295d","name":"action_0","type":"action"},{"id":"7eec9570-d9a4-11eb-881a-218d2e96295d","name":"action_1","type":"action"},{"id":"95d329c0-d9a4-11eb-881a-218d2e96295d","name":"action_2","type":"action"},{"id":"a94be780-d9a4-11eb-881a-218d2e96295d","name":"action_3","type":"action"},{"id":"b0bc3380-d9a4-11eb-881a-218d2e96295d","name":"action_4","type":"action"},{"id":"b5442ca0-d9a4-11eb-881a-218d2e96295d","name":"action_5","type":"action"},{"id":"d6d1cdf0-d9a4-11eb-881a-218d2e96295d","name":"action_6","type":"action"},{"id":"ff8c70b0-d9a4-11eb-881a-218d2e96295d","name":"action_7","type":"action"},{"id":"07f32aa0-d9a5-11eb-881a-218d2e96295d","name":"action_8","type":"action"}],"type":"alert","updated_at":"2021-06-30T13:41:45.359Z","version":"WzE1MjQsMV0="} +{"excludedObjects":[{"id":"f27594d0-d9a7-11eb-881a-218d2e96295d","reason":"excluded","type":"alert"},{"id":"f2756dc0-d9a7-11eb-881a-218d2e96295d","reason":"excluded","type":"alert"},{"id":"f2751fa0-d9a7-11eb-881a-218d2e96295d","reason":"excluded","type":"alert"},{"id":"f275bbe0-d9a7-11eb-881a-218d2e96295d","reason":"excluded","type":"alert"},{"id":"bf522690-d9a7-11eb-881a-218d2e96295d","reason":"excluded","type":"alert"},{"id":"f2771b70-d9a7-11eb-881a-218d2e96295d","reason":"excluded","type":"alert"},{"id":"f276f460-d9a7-11eb-881a-218d2e96295d","reason":"excluded","type":"alert"},{"id":"f274f890-d9a7-11eb-881a-218d2e96295d","reason":"excluded","type":"alert"},{"id":"f27594d1-d9a7-11eb-881a-218d2e96295d","reason":"excluded","type":"alert"},{"id":"f276cd50-d9a7-11eb-881a-218d2e96295d","reason":"excluded","type":"alert"},{"id":"f274aa70-d9a7-11eb-881a-218d2e96295d","reason":"excluded","type":"alert"},{"id":"f27546b0-d9a7-11eb-881a-218d2e96295d","reason":"excluded","type":"alert"},{"id":"f27546b1-d9a7-11eb-881a-218d2e96295d","reason":"excluded","type":"alert"},{"id":"f274d180-d9a7-11eb-881a-218d2e96295d","reason":"excluded","type":"alert"},{"id":"f2774280-d9a7-11eb-881a-218d2e96295d","reason":"excluded","type":"alert"}],"excludedObjectsCount":15,"exportedCount":23,"missingRefCount":0,"missingReferences":[]} diff --git a/x-pack/test/functional/apps/saved_objects_management/import_saved_objects_between_versions.ts b/x-pack/test/functional/apps/saved_objects_management/import_saved_objects_between_versions.ts index 47fc2b756e8e8..427e42b7b7a65 100644 --- a/x-pack/test/functional/apps/saved_objects_management/import_saved_objects_between_versions.ts +++ b/x-pack/test/functional/apps/saved_objects_management/import_saved_objects_between_versions.ts @@ -21,7 +21,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const retry = getService('retry'); describe('Export import saved objects between versions', function () { - beforeEach(async function () { + before(async function () { await esArchiver.load('x-pack/test/functional/es_archives/logstash_functional'); await esArchiver.load('x-pack/test/functional/es_archives/getting_started/shakespeare'); await kibanaServer.uiSettings.replace({}); @@ -50,5 +50,21 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { // verifying the count of saved objects after importing .ndjson await expect(importedSavedObjects).to.be('Export 88 objects'); }); + + it('should be able to import alerts and actions saved objects from 7.14 into 8.0.0', async function () { + await retry.tryForTime(10000, async () => { + const existingSavedObjects = await testSubjects.getVisibleText('exportAllObjects'); + // Kibana always has 1 advanced setting as a saved object + await expect(existingSavedObjects).to.be('Export 88 objects'); + }); + await PageObjects.savedObjects.importFile( + path.join(__dirname, 'exports', '_7.14_import_alerts_actions.ndjson') + ); + await PageObjects.savedObjects.checkImportSucceeded(); + await PageObjects.savedObjects.clickImportDone(); + const importedSavedObjects = await testSubjects.getVisibleText('exportAllObjects'); + // verifying the count of saved objects after importing .ndjson + await expect(importedSavedObjects).to.be('Export 111 objects'); + }); }); } From 5df129aaad941639ff2246ec0864674349437c1e Mon Sep 17 00:00:00 2001 From: Alison Goryachev <alison.goryachev@elastic.co> Date: Thu, 1 Jul 2021 08:39:44 -0400 Subject: [PATCH 36/51] [Upgrade Assistant] Auto upgrade ML job model snapshots (#100066) --- .../client_integration/cluster.test.ts | 360 +++++++++++++++++ .../helpers/cluster.helpers.ts | 67 ++++ .../helpers/http_requests.ts | 24 ++ .../client_integration/helpers/index.ts | 1 + .../helpers/setup_environment.tsx | 1 - .../client_integration/indices.test.ts | 12 +- .../client_integration/overview.test.ts | 1 - .../plugins/upgrade_assistant/common/types.ts | 32 +- x-pack/plugins/upgrade_assistant/kibana.json | 2 +- .../public/application/app_context.tsx | 1 - .../es_deprecations/deprecations/cell.tsx | 83 ++-- .../deprecations/index_table.test.tsx | 18 +- .../deprecations/index_table.tsx | 20 +- .../deprecations/list.test.tsx | 8 +- .../es_deprecations/deprecations/list.tsx | 14 +- .../deprecations/ml_snapshots/button.tsx | 125 ++++++ .../ml_snapshots/fix_snapshots_flyout.tsx | 181 +++++++++ .../deprecations/ml_snapshots/index.ts | 8 + .../ml_snapshots/use_snapshot_state.tsx | 151 ++++++++ .../deprecations/reindex/button.tsx | 8 +- .../deprecations/reindex/flyout/container.tsx | 4 +- .../public/application/lib/api.ts | 32 ++ .../application/mount_management_section.ts | 2 - .../upgrade_assistant/public/plugin.ts | 6 +- .../lib/__fixtures__/fake_deprecations.json | 35 +- .../es_migration_apis.test.ts.snap | 75 +++- .../server/lib/es_migration_apis.test.ts | 39 +- .../server/lib/es_migration_apis.ts | 125 +++--- .../upgrade_assistant/server/plugin.ts | 26 +- .../server/routes/__mocks__/routes.mock.ts | 1 + .../server/routes/cluster_checkup.test.ts | 21 - .../server/routes/cluster_checkup.ts | 6 +- .../server/routes/ml_snapshots.test.ts | 365 ++++++++++++++++++ .../server/routes/ml_snapshots.ts | 348 +++++++++++++++++ .../server/routes/register_routes.ts | 25 ++ .../server/saved_object_types/index.ts | 1 + .../ml_upgrade_operation_saved_object_type.ts | 56 +++ .../server/shared_imports.ts | 8 + .../plugins/upgrade_assistant/server/types.ts | 2 - .../plugins/upgrade_assistant/tsconfig.json | 2 +- 40 files changed, 2081 insertions(+), 215 deletions(-) create mode 100644 x-pack/plugins/upgrade_assistant/__jest__/client_integration/cluster.test.ts create mode 100644 x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/cluster.helpers.ts create mode 100644 x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecations/ml_snapshots/button.tsx create mode 100644 x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecations/ml_snapshots/fix_snapshots_flyout.tsx create mode 100644 x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecations/ml_snapshots/index.ts create mode 100644 x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecations/ml_snapshots/use_snapshot_state.tsx create mode 100644 x-pack/plugins/upgrade_assistant/server/routes/ml_snapshots.test.ts create mode 100644 x-pack/plugins/upgrade_assistant/server/routes/ml_snapshots.ts create mode 100644 x-pack/plugins/upgrade_assistant/server/routes/register_routes.ts create mode 100644 x-pack/plugins/upgrade_assistant/server/saved_object_types/ml_upgrade_operation_saved_object_type.ts create mode 100644 x-pack/plugins/upgrade_assistant/server/shared_imports.ts diff --git a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/cluster.test.ts b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/cluster.test.ts new file mode 100644 index 0000000000000..412ce348d56e3 --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/cluster.test.ts @@ -0,0 +1,360 @@ +/* + * 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 { MlAction, UpgradeAssistantStatus } from '../../common/types'; + +import { ClusterTestBed, setupClusterPage, setupEnvironment } from './helpers'; + +describe('Cluster tab', () => { + let testBed: ClusterTestBed; + const { server, httpRequestsMockHelpers } = setupEnvironment(); + + afterAll(() => { + server.restore(); + }); + + describe('with deprecations', () => { + const snapshotId = '1'; + const jobId = 'deprecation_check_job'; + const upgradeStatusMockResponse: UpgradeAssistantStatus = { + readyForUpgrade: false, + cluster: [ + { + level: 'critical', + message: + 'model snapshot [1] for job [deprecation_check_job] needs to be deleted or upgraded', + details: + 'model snapshot [%s] for job [%s] supports minimum version [%s] and needs to be at least [%s]', + url: 'doc_url', + correctiveAction: { + type: 'mlSnapshot', + snapshotId, + jobId, + }, + }, + ], + indices: [], + }; + + beforeEach(async () => { + httpRequestsMockHelpers.setLoadEsDeprecationsResponse(upgradeStatusMockResponse); + httpRequestsMockHelpers.setLoadDeprecationLoggingResponse({ isEnabled: true }); + + await act(async () => { + testBed = await setupClusterPage({ isReadOnlyMode: false }); + }); + + const { actions, component } = testBed; + + component.update(); + + // Navigate to the cluster tab + await act(async () => { + actions.clickTab('cluster'); + }); + + component.update(); + }); + + test('renders deprecations', () => { + const { exists } = testBed; + expect(exists('clusterTabContent')).toBe(true); + expect(exists('deprecationsContainer')).toBe(true); + }); + + describe('fix ml snapshots button', () => { + let flyout: Element | null; + + beforeEach(async () => { + const { component, actions, exists, find } = testBed; + + expect(exists('deprecationsContainer')).toBe(true); + + // Open all deprecations + actions.clickExpandAll(); + + // The data-test-subj is derived from the deprecation message + const accordionTestSubj = `depgroup_${upgradeStatusMockResponse.cluster[0].message + .split(' ') + .join('_')}`; + + await act(async () => { + find(`${accordionTestSubj}.fixMlSnapshotsButton`).simulate('click'); + }); + + component.update(); + + // We need to read the document "body" as the flyout is added there and not inside + // the component DOM tree. + flyout = document.body.querySelector('[data-test-subj="fixSnapshotsFlyout"]'); + + expect(flyout).not.toBe(null); + expect(flyout!.textContent).toContain('Upgrade or delete model snapshot'); + }); + + test('upgrades snapshots', async () => { + const { component } = testBed; + + const upgradeButton: HTMLButtonElement | null = flyout!.querySelector( + '[data-test-subj="upgradeSnapshotButton"]' + ); + + httpRequestsMockHelpers.setUpgradeMlSnapshotResponse({ + nodeId: 'my_node', + snapshotId, + jobId, + status: 'in_progress', + }); + + await act(async () => { + upgradeButton!.click(); + }); + + component.update(); + + // First, we expect a POST request to upgrade the snapshot + const upgradeRequest = server.requests[server.requests.length - 2]; + expect(upgradeRequest.method).toBe('POST'); + expect(upgradeRequest.url).toBe('/api/upgrade_assistant/ml_snapshots'); + + // Next, we expect a GET request to check the status of the upgrade + const statusRequest = server.requests[server.requests.length - 1]; + expect(statusRequest.method).toBe('GET'); + expect(statusRequest.url).toBe( + `/api/upgrade_assistant/ml_snapshots/${jobId}/${snapshotId}` + ); + }); + + test('handles upgrade failure', async () => { + const { component, find } = testBed; + + const upgradeButton: HTMLButtonElement | null = flyout!.querySelector( + '[data-test-subj="upgradeSnapshotButton"]' + ); + + const error = { + statusCode: 500, + error: 'Upgrade snapshot error', + message: 'Upgrade snapshot error', + }; + + httpRequestsMockHelpers.setUpgradeMlSnapshotResponse(undefined, error); + + await act(async () => { + upgradeButton!.click(); + }); + + component.update(); + + const upgradeRequest = server.requests[server.requests.length - 1]; + expect(upgradeRequest.method).toBe('POST'); + expect(upgradeRequest.url).toBe('/api/upgrade_assistant/ml_snapshots'); + + const accordionTestSubj = `depgroup_${upgradeStatusMockResponse.cluster[0].message + .split(' ') + .join('_')}`; + + expect(find(`${accordionTestSubj}.fixMlSnapshotsButton`).text()).toEqual('Failed'); + }); + + test('deletes snapshots', async () => { + const { component } = testBed; + + const deleteButton: HTMLButtonElement | null = flyout!.querySelector( + '[data-test-subj="deleteSnapshotButton"]' + ); + + httpRequestsMockHelpers.setDeleteMlSnapshotResponse({ + acknowledged: true, + }); + + await act(async () => { + deleteButton!.click(); + }); + + component.update(); + + const request = server.requests[server.requests.length - 1]; + const mlDeprecation = upgradeStatusMockResponse.cluster[0]; + + expect(request.method).toBe('DELETE'); + expect(request.url).toBe( + `/api/upgrade_assistant/ml_snapshots/${ + (mlDeprecation.correctiveAction! as MlAction).jobId + }/${(mlDeprecation.correctiveAction! as MlAction).snapshotId}` + ); + }); + + test('handles delete failure', async () => { + const { component, find } = testBed; + + const deleteButton: HTMLButtonElement | null = flyout!.querySelector( + '[data-test-subj="deleteSnapshotButton"]' + ); + + const error = { + statusCode: 500, + error: 'Upgrade snapshot error', + message: 'Upgrade snapshot error', + }; + + httpRequestsMockHelpers.setDeleteMlSnapshotResponse(undefined, error); + + await act(async () => { + deleteButton!.click(); + }); + + component.update(); + + const request = server.requests[server.requests.length - 1]; + const mlDeprecation = upgradeStatusMockResponse.cluster[0]; + + expect(request.method).toBe('DELETE'); + expect(request.url).toBe( + `/api/upgrade_assistant/ml_snapshots/${ + (mlDeprecation.correctiveAction! as MlAction).jobId + }/${(mlDeprecation.correctiveAction! as MlAction).snapshotId}` + ); + + const accordionTestSubj = `depgroup_${upgradeStatusMockResponse.cluster[0].message + .split(' ') + .join('_')}`; + + expect(find(`${accordionTestSubj}.fixMlSnapshotsButton`).text()).toEqual('Failed'); + }); + }); + }); + + describe('no deprecations', () => { + beforeEach(async () => { + const noDeprecationsResponse = { + readyForUpgrade: false, + cluster: [], + indices: [], + }; + + httpRequestsMockHelpers.setLoadEsDeprecationsResponse(noDeprecationsResponse); + + await act(async () => { + testBed = await setupClusterPage({ isReadOnlyMode: false }); + }); + + const { component } = testBed; + + component.update(); + }); + + test('renders prompt', () => { + const { exists, find } = testBed; + expect(exists('noDeprecationsPrompt')).toBe(true); + expect(find('noDeprecationsPrompt').text()).toContain('Ready to upgrade!'); + }); + }); + + describe('error handling', () => { + test('handles 403', async () => { + const error = { + statusCode: 403, + error: 'Forbidden', + message: 'Forbidden', + }; + + httpRequestsMockHelpers.setLoadEsDeprecationsResponse(undefined, error); + + await act(async () => { + testBed = await setupClusterPage({ isReadOnlyMode: false }); + }); + + const { component, exists, find } = testBed; + + component.update(); + + expect(exists('permissionsError')).toBe(true); + expect(find('permissionsError').text()).toContain( + 'You are not authorized to view Elasticsearch deprecations.' + ); + }); + + test('shows upgraded message when all nodes have been upgraded', async () => { + const error = { + statusCode: 426, + error: 'Upgrade required', + message: 'There are some nodes running a different version of Elasticsearch', + attributes: { + // This is marked true in the scenario where none of the nodes have the same major version of Kibana, + // and therefore we assume all have been upgraded + allNodesUpgraded: true, + }, + }; + + httpRequestsMockHelpers.setLoadEsDeprecationsResponse(undefined, error); + + await act(async () => { + testBed = await setupClusterPage({ isReadOnlyMode: false }); + }); + + const { component, exists, find } = testBed; + + component.update(); + + expect(exists('upgradedCallout')).toBe(true); + expect(find('upgradedCallout').text()).toContain( + 'Your configuration is up to date. Kibana and all Elasticsearch nodes are running the same version.' + ); + }); + + test('shows partially upgrade error when nodes are running different versions', async () => { + const error = { + statusCode: 426, + error: 'Upgrade required', + message: 'There are some nodes running a different version of Elasticsearch', + attributes: { + allNodesUpgraded: false, + }, + }; + + httpRequestsMockHelpers.setLoadEsDeprecationsResponse(undefined, error); + + await act(async () => { + testBed = await setupClusterPage({ isReadOnlyMode: false }); + }); + + const { component, exists, find } = testBed; + + component.update(); + + expect(exists('partiallyUpgradedWarning')).toBe(true); + expect(find('partiallyUpgradedWarning').text()).toContain( + 'Upgrade Kibana to the same version as your Elasticsearch cluster. One or more nodes in the cluster is running a different version than Kibana.' + ); + }); + + test('handles generic error', async () => { + const error = { + statusCode: 500, + error: 'Internal server error', + message: 'Internal server error', + }; + + httpRequestsMockHelpers.setLoadEsDeprecationsResponse(undefined, error); + + await act(async () => { + testBed = await setupClusterPage({ isReadOnlyMode: false }); + }); + + const { component, exists, find } = testBed; + + component.update(); + + expect(exists('requestError')).toBe(true); + expect(find('requestError').text()).toContain( + 'Could not retrieve Elasticsearch deprecations.' + ); + }); + }); +}); diff --git a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/cluster.helpers.ts b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/cluster.helpers.ts new file mode 100644 index 0000000000000..2aedface1e32b --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/cluster.helpers.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. + */ + +import { registerTestBed, TestBed, TestBedConfig } from '@kbn/test/jest'; +import { EsDeprecationsContent } from '../../../public/application/components/es_deprecations'; +import { WithAppDependencies } from './setup_environment'; + +const testBedConfig: TestBedConfig = { + memoryRouter: { + initialEntries: ['/es_deprecations/cluster'], + componentRoutePath: '/es_deprecations/:tabName', + }, + doMountAsync: true, +}; + +export type ClusterTestBed = TestBed<ClusterTestSubjects> & { + actions: ReturnType<typeof createActions>; +}; + +const createActions = (testBed: TestBed) => { + /** + * User Actions + */ + const clickTab = (tabName: string) => { + const { find } = testBed; + const camelcaseTabName = tabName.charAt(0).toUpperCase() + tabName.slice(1); + + find(`upgradeAssistant${camelcaseTabName}Tab`).simulate('click'); + }; + + const clickExpandAll = () => { + const { find } = testBed; + find('expandAll').simulate('click'); + }; + + return { + clickTab, + clickExpandAll, + }; +}; + +export const setup = async (overrides?: Record<string, unknown>): Promise<ClusterTestBed> => { + const initTestBed = registerTestBed( + WithAppDependencies(EsDeprecationsContent, overrides), + testBedConfig + ); + const testBed = await initTestBed(); + + return { + ...testBed, + actions: createActions(testBed), + }; +}; + +export type ClusterTestSubjects = + | 'expandAll' + | 'deprecationsContainer' + | 'permissionsError' + | 'requestError' + | 'upgradedCallout' + | 'partiallyUpgradedWarning' + | 'noDeprecationsPrompt' + | string; diff --git a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/http_requests.ts b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/http_requests.ts index e3f6df54db60e..3fd8b7279c073 100644 --- a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/http_requests.ts +++ b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/http_requests.ts @@ -62,11 +62,35 @@ const registerHttpRequestMockHelpers = (server: SinonFakeServer) => { ]); }; + const setUpgradeMlSnapshotResponse = (response?: object, error?: ResponseError) => { + const status = error ? error.statusCode || 400 : 200; + const body = error ? error : response; + + server.respondWith('POST', `${API_BASE_PATH}/ml_snapshots`, [ + status, + { 'Content-Type': 'application/json' }, + JSON.stringify(body), + ]); + }; + + const setDeleteMlSnapshotResponse = (response?: object, error?: ResponseError) => { + const status = error ? error.statusCode || 400 : 200; + const body = error ? error : response; + + server.respondWith('DELETE', `${API_BASE_PATH}/ml_snapshots/:jobId/:snapshotId`, [ + status, + { 'Content-Type': 'application/json' }, + JSON.stringify(body), + ]); + }; + return { setLoadEsDeprecationsResponse, setLoadDeprecationLoggingResponse, setUpdateDeprecationLoggingResponse, setUpdateIndexSettingsResponse, + setUpgradeMlSnapshotResponse, + setDeleteMlSnapshotResponse, }; }; diff --git a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/index.ts b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/index.ts index ddf5787af1037..8e256680253be 100644 --- a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/index.ts +++ b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/index.ts @@ -7,6 +7,7 @@ export { setup as setupOverviewPage, OverviewTestBed } from './overview.helpers'; export { setup as setupIndicesPage, IndicesTestBed } from './indices.helpers'; +export { setup as setupClusterPage, ClusterTestBed } from './cluster.helpers'; export { setup as setupKibanaPage, KibanaTestBed } from './kibana.helpers'; export { setupEnvironment } from './setup_environment'; 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 faeb0e4a40abd..aae5500403322 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 @@ -33,7 +33,6 @@ export const WithAppDependencies = (Comp: any, overrides: Record<string, unknown const contextValue = { http: (mockHttpClient as unknown) as HttpSetup, - isCloudEnabled: false, docLinks: docLinksServiceMock.createStartContract(), kibanaVersionInfo: { currentMajor: mockKibanaSemverVersion.major, diff --git a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/indices.test.ts b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/indices.test.ts index 059980cb5671b..b44a04eb15d86 100644 --- a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/indices.test.ts +++ b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/indices.test.ts @@ -7,7 +7,7 @@ import { act } from 'react-dom/test-utils'; import { indexSettingDeprecations } from '../../common/constants'; -import { MIGRATION_DEPRECATION_LEVEL } from '../../common/types'; +import { UpgradeAssistantStatus } from '../../common/types'; import { IndicesTestBed, setupIndicesPage, setupEnvironment } from './helpers'; @@ -20,16 +20,19 @@ describe('Indices tab', () => { }); describe('with deprecations', () => { - const upgradeStatusMockResponse = { + const upgradeStatusMockResponse: UpgradeAssistantStatus = { readyForUpgrade: false, cluster: [], indices: [ { - level: 'warning' as MIGRATION_DEPRECATION_LEVEL, + level: 'warning', message: indexSettingDeprecations.translog.deprecationMessage, url: 'doc_url', index: 'my_index', - deprecatedIndexSettings: indexSettingDeprecations.translog.settings, + correctiveAction: { + type: 'indexSetting', + deprecatedSettings: indexSettingDeprecations.translog.settings, + }, }, ], }; @@ -56,6 +59,7 @@ describe('Indices tab', () => { test('renders deprecations', () => { const { exists, find } = testBed; + expect(exists('indexTabContent')).toBe(true); expect(exists('deprecationsContainer')).toBe(true); expect(find('indexCount').text()).toEqual('1'); }); diff --git a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/overview.test.ts b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/overview.test.ts index 85efaf38f32a7..9b65b493a74c4 100644 --- a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/overview.test.ts +++ b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/overview.test.ts @@ -38,7 +38,6 @@ describe('Overview page', () => { details: 'translog retention settings [index.translog.retention.size] and [index.translog.retention.age] are ignored because translog is no longer used in peer recoveries with soft-deletes enabled (default in 7.0 or later)', index: 'settings', - reindex: false, }, ], }; diff --git a/x-pack/plugins/upgrade_assistant/common/types.ts b/x-pack/plugins/upgrade_assistant/common/types.ts index 0471fc30f28ea..88fa103bace89 100644 --- a/x-pack/plugins/upgrade_assistant/common/types.ts +++ b/x-pack/plugins/upgrade_assistant/common/types.ts @@ -28,7 +28,6 @@ export enum ReindexStatus { } export const REINDEX_OP_TYPE = 'upgrade-assistant-reindex-operation'; - export interface QueueSettings extends SavedObjectAttributes { /** * A Unix timestamp of when the reindex operation was enqueued. @@ -190,11 +189,9 @@ export interface DeprecationAPIResponse { node_settings: DeprecationInfo[]; index_settings: IndexSettingsDeprecationInfo; } -export interface EnrichedDeprecationInfo extends DeprecationInfo { - index?: string; - node?: string; - reindex?: boolean; - deprecatedIndexSettings?: string[]; + +export interface ReindexAction { + type: 'reindex'; /** * Indicate what blockers have been detected for calling reindex * against this index. @@ -205,6 +202,21 @@ export interface EnrichedDeprecationInfo extends DeprecationInfo { blockerForReindexing?: 'index-closed'; // 'index-closed' can be handled automatically, but requires more resources, user should be warned } +export interface MlAction { + type: 'mlSnapshot'; + snapshotId: string; + jobId: string; +} + +export interface IndexSettingAction { + type: 'indexSetting'; + deprecatedSettings: string[]; +} +export interface EnrichedDeprecationInfo extends DeprecationInfo { + index?: string; + correctiveAction?: ReindexAction | MlAction | IndexSettingAction; +} + export interface UpgradeAssistantStatus { readyForUpgrade: boolean; cluster: EnrichedDeprecationInfo[]; @@ -225,3 +237,11 @@ export interface ResolveIndexResponseFromES { }>; data_streams: Array<{ name: string; backing_indices: string[]; timestamp_field: string }>; } + +export const ML_UPGRADE_OP_TYPE = 'upgrade-assistant-ml-upgrade-operation'; + +export interface MlOperation extends SavedObjectAttributes { + nodeId: string; + snapshotId: string; + jobId: string; +} diff --git a/x-pack/plugins/upgrade_assistant/kibana.json b/x-pack/plugins/upgrade_assistant/kibana.json index d9f4917fa0a6c..d013c16837b77 100644 --- a/x-pack/plugins/upgrade_assistant/kibana.json +++ b/x-pack/plugins/upgrade_assistant/kibana.json @@ -5,6 +5,6 @@ "ui": true, "configPath": ["xpack", "upgrade_assistant"], "requiredPlugins": ["management", "licensing", "features"], - "optionalPlugins": ["cloud", "usageCollection"], + "optionalPlugins": ["usageCollection"], "requiredBundles": ["esUiShared", "kibanaReact"] } diff --git a/x-pack/plugins/upgrade_assistant/public/application/app_context.tsx b/x-pack/plugins/upgrade_assistant/public/application/app_context.tsx index 049318f5b78d9..88b5bd4721c36 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/app_context.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/app_context.tsx @@ -24,7 +24,6 @@ export interface KibanaVersionContext { export interface ContextValue { http: HttpSetup; - isCloudEnabled: boolean; docLinks: DocLinksStart; kibanaVersionInfo: KibanaVersionContext; notifications: NotificationsStart; diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecations/cell.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecations/cell.tsx index b7d3247ffbf21..4324379f456ea 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecations/cell.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecations/cell.tsx @@ -17,34 +17,84 @@ import { EuiTitle, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EnrichedDeprecationInfo } from '../../../../../common/types'; +import { + EnrichedDeprecationInfo, + MlAction, + ReindexAction, + IndexSettingAction, +} from '../../../../../common/types'; import { AppContext } from '../../../app_context'; import { ReindexButton } from './reindex'; import { FixIndexSettingsButton } from './index_settings'; +import { FixMlSnapshotsButton } from './ml_snapshots'; interface DeprecationCellProps { items?: Array<{ title?: string; body: string }>; - reindexIndexName?: string; - deprecatedIndexSettings?: string[]; docUrl?: string; headline?: string; healthColor?: string; children?: ReactNode; - reindexBlocker?: EnrichedDeprecationInfo['blockerForReindexing']; + correctiveAction?: EnrichedDeprecationInfo['correctiveAction']; + indexName?: string; +} + +interface CellActionProps { + correctiveAction: EnrichedDeprecationInfo['correctiveAction']; + indexName?: string; + items: Array<{ title?: string; body: string }>; } +const CellAction: FunctionComponent<CellActionProps> = ({ correctiveAction, indexName, items }) => { + const { type: correctiveActionType } = correctiveAction!; + switch (correctiveActionType) { + case 'mlSnapshot': + const { jobId, snapshotId } = correctiveAction as MlAction; + return ( + <FixMlSnapshotsButton + jobId={jobId} + snapshotId={snapshotId} + // There will only ever be a single item for the cluster deprecations list, so we can use the index to access the first one + description={items[0]?.body} + /> + ); + + case 'reindex': + const { blockerForReindexing } = correctiveAction as ReindexAction; + + return ( + <AppContext.Consumer> + {({ http, docLinks }) => ( + <ReindexButton + docLinks={docLinks} + reindexBlocker={blockerForReindexing} + indexName={indexName!} + http={http} + /> + )} + </AppContext.Consumer> + ); + + case 'indexSetting': + const { deprecatedSettings } = correctiveAction as IndexSettingAction; + + return <FixIndexSettingsButton settings={deprecatedSettings} index={indexName!} />; + + default: + throw new Error(`No UI defined for corrective action: ${correctiveActionType}`); + } +}; + /** * Used to display a deprecation with links to docs, a health indicator, and other descriptive information. */ export const DeprecationCell: FunctionComponent<DeprecationCellProps> = ({ headline, healthColor, - reindexIndexName, - deprecatedIndexSettings, + correctiveAction, + indexName, docUrl, items = [], children, - reindexBlocker, }) => ( <div className="upgDeprecationCell"> <EuiFlexGroup responsive={false} wrap alignItems="baseline"> @@ -82,24 +132,9 @@ export const DeprecationCell: FunctionComponent<DeprecationCellProps> = ({ )} </EuiFlexItem> - {reindexIndexName && ( - <EuiFlexItem grow={false}> - <AppContext.Consumer> - {({ http, docLinks }) => ( - <ReindexButton - docLinks={docLinks} - reindexBlocker={reindexBlocker} - indexName={reindexIndexName} - http={http} - /> - )} - </AppContext.Consumer> - </EuiFlexItem> - )} - - {deprecatedIndexSettings?.length && ( + {correctiveAction && ( <EuiFlexItem grow={false}> - <FixIndexSettingsButton settings={deprecatedIndexSettings} index={reindexIndexName!} /> + <CellAction correctiveAction={correctiveAction} indexName={indexName} items={items} /> </EuiFlexItem> )} </EuiFlexGroup> diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecations/index_table.test.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecations/index_table.test.tsx index 188e70b64ce6a..f4ac573d86b11 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecations/index_table.test.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecations/index_table.test.tsx @@ -13,9 +13,9 @@ import { IndexDeprecationTableProps, IndexDeprecationTable } from './index_table describe('IndexDeprecationTable', () => { const defaultProps = { indices: [ - { index: 'index1', details: 'Index 1 deets', reindex: true }, - { index: 'index2', details: 'Index 2 deets', reindex: true }, - { index: 'index3', details: 'Index 3 deets', reindex: true }, + { index: 'index1', details: 'Index 1 deets', correctiveAction: { type: 'reindex' } }, + { index: 'index2', details: 'Index 2 deets', correctiveAction: { type: 'reindex' } }, + { index: 'index3', details: 'Index 3 deets', correctiveAction: { type: 'reindex' } }, ], } as IndexDeprecationTableProps; @@ -49,19 +49,25 @@ describe('IndexDeprecationTable', () => { items={ Array [ Object { + "correctiveAction": Object { + "type": "reindex", + }, "details": "Index 1 deets", "index": "index1", - "reindex": true, }, Object { + "correctiveAction": Object { + "type": "reindex", + }, "details": "Index 2 deets", "index": "index2", - "reindex": true, }, Object { + "correctiveAction": Object { + "type": "reindex", + }, "details": "Index 3 deets", "index": "index3", - "reindex": true, }, ] } diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecations/index_table.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecations/index_table.tsx index 216884d547eeb..6b0f94ea24bc7 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecations/index_table.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecations/index_table.tsx @@ -10,7 +10,11 @@ import React from 'react'; import { EuiBasicTable } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { EnrichedDeprecationInfo } from '../../../../../common/types'; +import { + EnrichedDeprecationInfo, + IndexSettingAction, + ReindexAction, +} from '../../../../../common/types'; import { AppContext } from '../../../app_context'; import { ReindexButton } from './reindex'; import { FixIndexSettingsButton } from './index_settings'; @@ -19,9 +23,7 @@ const PAGE_SIZES = [10, 25, 50, 100, 250, 500, 1000]; export interface IndexDeprecationDetails { index: string; - reindex: boolean; - deprecatedIndexSettings?: string[]; - blockerForReindexing?: EnrichedDeprecationInfo['blockerForReindexing']; + correctiveAction?: EnrichedDeprecationInfo['correctiveAction']; details?: string; } @@ -152,9 +154,9 @@ export class IndexDeprecationTable extends React.Component< // NOTE: this naive implementation assumes all indices in the table // should show the reindex button or fix indices button. This should work for known use cases. const { indices } = this.props; - const showReindexButton = Boolean(indices.find((i) => i.reindex === true)); + const showReindexButton = Boolean(indices.find((i) => i.correctiveAction?.type === 'reindex')); const showFixSettingsButton = Boolean( - indices.find((i) => i.deprecatedIndexSettings && i.deprecatedIndexSettings.length > 0) + indices.find((i) => i.correctiveAction?.type === 'indexSetting') ); if (showReindexButton === false && showFixSettingsButton === false) { @@ -172,7 +174,9 @@ export class IndexDeprecationTable extends React.Component< return ( <ReindexButton docLinks={docLinks} - reindexBlocker={indexDep.blockerForReindexing} + reindexBlocker={ + (indexDep.correctiveAction as ReindexAction).blockerForReindexing + } indexName={indexDep.index!} http={http} /> @@ -184,7 +188,7 @@ export class IndexDeprecationTable extends React.Component< return ( <FixIndexSettingsButton - settings={indexDep.deprecatedIndexSettings!} + settings={(indexDep.correctiveAction as IndexSettingAction).deprecatedSettings} index={indexDep.index} /> ); diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecations/list.test.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecations/list.test.tsx index 579cf1f4a55bb..2bfa8119e41bc 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecations/list.test.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecations/list.test.tsx @@ -72,18 +72,14 @@ describe('EsDeprecationList', () => { indices={ Array [ Object { - "blockerForReindexing": undefined, - "deprecatedIndexSettings": undefined, + "correctiveAction": undefined, "details": undefined, "index": "0", - "reindex": false, }, Object { - "blockerForReindexing": undefined, - "deprecatedIndexSettings": undefined, + "correctiveAction": undefined, "details": undefined, "index": "1", - "reindex": false, }, ] } diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecations/list.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecations/list.tsx index cb9f238d0e4dd..7b543a7e94b33 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecations/list.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecations/list.tsx @@ -32,11 +32,10 @@ const MessageDeprecation: FunctionComponent<{ return ( <DeprecationCell - reindexBlocker={deprecation.blockerForReindexing} headline={deprecation.message} healthColor={COLOR_MAP[deprecation.level]} - reindexIndexName={deprecation.reindex ? deprecation.index! : undefined} - deprecatedIndexSettings={deprecation.deprecatedIndexSettings} + correctiveAction={deprecation.correctiveAction} + indexName={deprecation.index} docUrl={deprecation.url} items={items} /> @@ -57,10 +56,10 @@ const SimpleMessageDeprecation: FunctionComponent<{ deprecation: EnrichedDepreca return ( <DeprecationCell - reindexBlocker={deprecation.blockerForReindexing} + correctiveAction={deprecation.correctiveAction} + indexName={deprecation.index} items={items} docUrl={deprecation.url} - deprecatedIndexSettings={deprecation.deprecatedIndexSettings} /> ); }; @@ -94,12 +93,11 @@ export const EsDeprecationList: FunctionComponent<{ if (currentGroupBy === GroupByOption.message && deprecations[0].index !== undefined) { // We assume that every deprecation message is the same issue (since they have the same // message) and that each deprecation will have an index associated with it. + const indices = deprecations.map((dep) => ({ index: dep.index!, details: dep.details, - reindex: dep.reindex === true, - deprecatedIndexSettings: dep.deprecatedIndexSettings, - blockerForReindexing: dep.blockerForReindexing, + correctiveAction: dep.correctiveAction, })); return <IndexDeprecation indices={indices} deprecation={deprecations[0]} />; } else if (currentGroupBy === GroupByOption.index) { diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecations/ml_snapshots/button.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecations/ml_snapshots/button.tsx new file mode 100644 index 0000000000000..13b7dacc3b598 --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecations/ml_snapshots/button.tsx @@ -0,0 +1,125 @@ +/* + * 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, useState } from 'react'; + +import { ButtonSize, EuiButton } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { FixSnapshotsFlyout } from './fix_snapshots_flyout'; +import { useAppContext } from '../../../../app_context'; +import { useSnapshotState } from './use_snapshot_state'; + +const i18nTexts = { + fixButtonLabel: i18n.translate( + 'xpack.upgradeAssistant.esDeprecations.mlSnapshots.fixButtonLabel', + { + defaultMessage: 'Fix', + } + ), + upgradingButtonLabel: i18n.translate( + 'xpack.upgradeAssistant.esDeprecations.mlSnapshots.upgradingButtonLabel', + { + defaultMessage: 'Upgrading…', + } + ), + deletingButtonLabel: i18n.translate( + 'xpack.upgradeAssistant.esDeprecations.mlSnapshots.deletingButtonLabel', + { + defaultMessage: 'Deleting…', + } + ), + doneButtonLabel: i18n.translate( + 'xpack.upgradeAssistant.esDeprecations.mlSnapshots.doneButtonLabel', + { + defaultMessage: 'Done', + } + ), + failedButtonLabel: i18n.translate( + 'xpack.upgradeAssistant.esDeprecations.mlSnapshots.failedButtonLabel', + { + defaultMessage: 'Failed', + } + ), +}; + +interface Props { + snapshotId: string; + jobId: string; + description: string; +} + +export const FixMlSnapshotsButton: React.FunctionComponent<Props> = ({ + snapshotId, + jobId, + description, +}) => { + const { api } = useAppContext(); + const { snapshotState, upgradeSnapshot, deleteSnapshot, updateSnapshotStatus } = useSnapshotState( + { + jobId, + snapshotId, + api, + } + ); + + const [showFlyout, setShowFlyout] = useState(false); + + useEffect(() => { + updateSnapshotStatus(); + }, [updateSnapshotStatus]); + + const commonButtonProps = { + size: 's' as ButtonSize, + onClick: () => setShowFlyout(true), + 'data-test-subj': 'fixMlSnapshotsButton', + }; + + let button = <EuiButton {...commonButtonProps}>{i18nTexts.fixButtonLabel}</EuiButton>; + + switch (snapshotState.status) { + case 'in_progress': + button = ( + <EuiButton color="secondary" {...commonButtonProps} isLoading> + {snapshotState.action === 'delete' + ? i18nTexts.deletingButtonLabel + : i18nTexts.upgradingButtonLabel} + </EuiButton> + ); + break; + case 'complete': + button = ( + <EuiButton color="secondary" iconType="check" {...commonButtonProps} disabled> + {i18nTexts.doneButtonLabel} + </EuiButton> + ); + break; + case 'error': + button = ( + <EuiButton color="danger" iconType="cross" {...commonButtonProps}> + {i18nTexts.failedButtonLabel} + </EuiButton> + ); + break; + } + + return ( + <> + {button} + + {showFlyout && ( + <FixSnapshotsFlyout + snapshotState={snapshotState} + upgradeSnapshot={upgradeSnapshot} + deleteSnapshot={deleteSnapshot} + description={description} + closeFlyout={() => setShowFlyout(false)} + /> + )} + </> + ); +}; diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecations/ml_snapshots/fix_snapshots_flyout.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecations/ml_snapshots/fix_snapshots_flyout.tsx new file mode 100644 index 0000000000000..7dafab011a69a --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecations/ml_snapshots/fix_snapshots_flyout.tsx @@ -0,0 +1,181 @@ +/* + * 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 { + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiPortal, + EuiTitle, + EuiText, + EuiCallOut, + EuiSpacer, +} from '@elastic/eui'; +import { SnapshotStatus } from './use_snapshot_state'; +import { ResponseError } from '../../../../lib/api'; + +interface SnapshotState extends SnapshotStatus { + error?: ResponseError; +} +interface Props { + upgradeSnapshot: () => Promise<void>; + deleteSnapshot: () => Promise<void>; + description: string; + closeFlyout: () => void; + snapshotState: SnapshotState; +} + +const i18nTexts = { + upgradeButtonLabel: i18n.translate( + 'xpack.upgradeAssistant.esDeprecations.mlSnapshots.flyout.upgradeButtonLabel', + { + defaultMessage: 'Upgrade', + } + ), + retryUpgradeButtonLabel: i18n.translate( + 'xpack.upgradeAssistant.esDeprecations.mlSnapshots.flyout.retryUpgradeButtonLabel', + { + defaultMessage: 'Retry upgrade', + } + ), + closeButtonLabel: i18n.translate( + 'xpack.upgradeAssistant.esDeprecations.mlSnapshots.flyout.cancelButtonLabel', + { + defaultMessage: 'Close', + } + ), + deleteButtonLabel: i18n.translate( + 'xpack.upgradeAssistant.esDeprecations.mlSnapshots.flyout.deleteButtonLabel', + { + defaultMessage: 'Delete', + } + ), + retryDeleteButtonLabel: i18n.translate( + 'xpack.upgradeAssistant.esDeprecations.mlSnapshots.flyout.retryDeleteButtonLabel', + { + defaultMessage: 'Retry delete', + } + ), + flyoutTitle: i18n.translate('xpack.upgradeAssistant.esDeprecations.mlSnapshots.flyout.title', { + defaultMessage: 'Upgrade or delete model snapshot', + }), + deleteSnapshotErrorTitle: i18n.translate( + 'xpack.upgradeAssistant.esDeprecations.mlSnapshots.flyout.deleteSnapshotErrorTitle', + { + defaultMessage: 'Error deleting snapshot', + } + ), + upgradeSnapshotErrorTitle: i18n.translate( + 'xpack.upgradeAssistant.esDeprecations.mlSnapshots.flyout.upgradeSnapshotErrorTitle', + { + defaultMessage: 'Error upgrading snapshot', + } + ), +}; + +export const FixSnapshotsFlyout = ({ + upgradeSnapshot, + deleteSnapshot, + description, + closeFlyout, + snapshotState, +}: Props) => { + const onUpgradeSnapshot = () => { + upgradeSnapshot(); + closeFlyout(); + }; + + const onDeleteSnapshot = () => { + deleteSnapshot(); + closeFlyout(); + }; + + return ( + <EuiPortal> + <EuiFlyout + onClose={closeFlyout} + ownFocus + size="m" + maxWidth + data-test-subj="fixSnapshotsFlyout" + > + <EuiFlyoutHeader hasBorder> + <EuiTitle size="s"> + <h2>{i18nTexts.flyoutTitle}</h2> + </EuiTitle> + </EuiFlyoutHeader> + <EuiFlyoutBody> + {snapshotState.error && ( + <> + <EuiCallOut + title={ + snapshotState.action === 'delete' + ? i18nTexts.deleteSnapshotErrorTitle + : i18nTexts.upgradeSnapshotErrorTitle + } + color="danger" + iconType="alert" + data-test-subj="upgradeSnapshotError" + > + {snapshotState.error.message} + </EuiCallOut> + <EuiSpacer /> + </> + )} + <EuiText> + <p>{description}</p> + </EuiText> + </EuiFlyoutBody> + <EuiFlyoutFooter> + <EuiFlexGroup justifyContent="spaceBetween"> + <EuiFlexItem grow={false}> + <EuiButtonEmpty iconType="cross" onClick={closeFlyout} flush="left"> + {i18nTexts.closeButtonLabel} + </EuiButtonEmpty> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiFlexGroup> + <EuiFlexItem> + <EuiButtonEmpty + data-test-subj="deleteSnapshotButton" + color="danger" + onClick={onDeleteSnapshot} + isLoading={false} + > + {snapshotState.action === 'delete' && snapshotState.error + ? i18nTexts.retryDeleteButtonLabel + : i18nTexts.deleteButtonLabel} + </EuiButtonEmpty> + </EuiFlexItem> + <EuiFlexItem> + <EuiButton + fill + onClick={onUpgradeSnapshot} + isLoading={false} + data-test-subj="upgradeSnapshotButton" + > + {snapshotState.action === 'upgrade' && snapshotState.error + ? i18nTexts.retryUpgradeButtonLabel + : i18nTexts.upgradeButtonLabel} + </EuiButton> + </EuiFlexItem> + </EuiFlexGroup> + </EuiFlexItem> + </EuiFlexGroup> + </EuiFlyoutFooter> + </EuiFlyout> + </EuiPortal> + ); +}; diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecations/ml_snapshots/index.ts b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecations/ml_snapshots/index.ts new file mode 100644 index 0000000000000..d537c94cf67ae --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecations/ml_snapshots/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 { FixMlSnapshotsButton } from './button'; diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecations/ml_snapshots/use_snapshot_state.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecations/ml_snapshots/use_snapshot_state.tsx new file mode 100644 index 0000000000000..2dd4638c772b3 --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecations/ml_snapshots/use_snapshot_state.tsx @@ -0,0 +1,151 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useRef, useCallback, useState, useEffect } from 'react'; + +import { ApiService, ResponseError } from '../../../../lib/api'; + +const POLL_INTERVAL_MS = 1000; + +export interface SnapshotStatus { + snapshotId: string; + jobId: string; + status: 'complete' | 'in_progress' | 'error' | 'idle'; + action?: 'upgrade' | 'delete'; +} + +export const useSnapshotState = ({ + jobId, + snapshotId, + api, +}: { + jobId: string; + snapshotId: string; + api: ApiService; +}) => { + const [requestError, setRequestError] = useState<ResponseError | undefined>(undefined); + const [snapshotState, setSnapshotState] = useState<SnapshotStatus>({ + status: 'idle', + jobId, + snapshotId, + }); + + const pollIntervalIdRef = useRef<ReturnType<typeof setTimeout> | null>(null); + const isMounted = useRef(false); + + const clearPollInterval = useCallback(() => { + if (pollIntervalIdRef.current) { + clearTimeout(pollIntervalIdRef.current); + pollIntervalIdRef.current = null; + } + }, []); + + const updateSnapshotStatus = useCallback(async () => { + clearPollInterval(); + + const { data, error: updateStatusError } = await api.getMlSnapshotUpgradeStatus({ + jobId, + snapshotId, + }); + + if (updateStatusError) { + setSnapshotState({ + snapshotId, + jobId, + action: 'upgrade', + status: 'error', + }); + setRequestError(updateStatusError); + return; + } + + setSnapshotState(data); + + // Only keep polling if it exists and is in progress. + if (data?.status === 'in_progress') { + pollIntervalIdRef.current = setTimeout(updateSnapshotStatus, POLL_INTERVAL_MS); + } + }, [api, clearPollInterval, jobId, snapshotId]); + + const upgradeSnapshot = useCallback(async () => { + setSnapshotState({ + snapshotId, + jobId, + action: 'upgrade', + status: 'in_progress', + }); + + const { data, error: upgradeError } = await api.upgradeMlSnapshot({ jobId, snapshotId }); + + if (upgradeError) { + setRequestError(upgradeError); + setSnapshotState({ + snapshotId, + jobId, + action: 'upgrade', + status: 'error', + }); + return; + } + + setSnapshotState(data); + updateSnapshotStatus(); + }, [api, jobId, snapshotId, updateSnapshotStatus]); + + const deleteSnapshot = useCallback(async () => { + setSnapshotState({ + snapshotId, + jobId, + action: 'delete', + status: 'in_progress', + }); + + const { error: deleteError } = await api.deleteMlSnapshot({ + snapshotId, + jobId, + }); + + if (deleteError) { + setRequestError(deleteError); + setSnapshotState({ + snapshotId, + jobId, + action: 'delete', + status: 'error', + }); + return; + } + + setSnapshotState({ + snapshotId, + jobId, + action: 'delete', + status: 'complete', + }); + }, [api, jobId, snapshotId]); + + useEffect(() => { + isMounted.current = true; + + return () => { + isMounted.current = false; + + // Clean up on unmount. + clearPollInterval(); + }; + }, [clearPollInterval]); + + return { + snapshotState: { + ...snapshotState, + error: requestError, + }, + upgradeSnapshot, + updateSnapshotStatus, + deleteSnapshot, + }; +}; diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecations/reindex/button.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecations/reindex/button.tsx index 34c1328459cdb..646f253931664 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecations/reindex/button.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecations/reindex/button.tsx @@ -14,11 +14,7 @@ import { EuiButton, EuiLoadingSpinner, EuiText, EuiToolTip } from '@elastic/eui' import { FormattedMessage } from '@kbn/i18n/react'; import { DocLinksStart, HttpSetup } from 'src/core/public'; import { API_BASE_PATH } from '../../../../../../common/constants'; -import { - EnrichedDeprecationInfo, - ReindexStatus, - UIReindexOption, -} from '../../../../../../common/types'; +import { ReindexAction, ReindexStatus, UIReindexOption } from '../../../../../../common/types'; import { LoadingState } from '../../../types'; import { ReindexFlyout } from './flyout'; import { ReindexPollingService, ReindexState } from './polling_service'; @@ -27,7 +23,7 @@ interface ReindexButtonProps { indexName: string; http: HttpSetup; docLinks: DocLinksStart; - reindexBlocker?: EnrichedDeprecationInfo['blockerForReindexing']; + reindexBlocker?: ReindexAction['blockerForReindexing']; } interface ReindexButtonState { diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecations/reindex/flyout/container.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecations/reindex/flyout/container.tsx index 3e7b931452566..97031dd08ee2a 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecations/reindex/flyout/container.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecations/reindex/flyout/container.tsx @@ -19,7 +19,7 @@ import { EuiTitle, } from '@elastic/eui'; -import { EnrichedDeprecationInfo, ReindexStatus } from '../../../../../../../common/types'; +import { ReindexAction, ReindexStatus } from '../../../../../../../common/types'; import { ReindexState } from '../polling_service'; import { ChecklistFlyoutStep } from './checklist_step'; @@ -37,7 +37,7 @@ interface ReindexFlyoutProps { startReindex: () => void; cancelReindex: () => void; docLinks: DocLinksStart; - reindexBlocker?: EnrichedDeprecationInfo['blockerForReindexing']; + reindexBlocker?: ReindexAction['blockerForReindexing']; } interface ReindexFlyoutState { diff --git a/x-pack/plugins/upgrade_assistant/public/application/lib/api.ts b/x-pack/plugins/upgrade_assistant/public/application/lib/api.ts index 1c42c249e9d54..c4d9128baa56a 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/lib/api.ts +++ b/x-pack/plugins/upgrade_assistant/public/application/lib/api.ts @@ -90,6 +90,38 @@ export class ApiService { return result; } + + public async upgradeMlSnapshot(body: { jobId: string; snapshotId: string }) { + const result = await this.sendRequest({ + path: `${API_BASE_PATH}/ml_snapshots`, + method: 'post', + body, + }); + + return result; + } + + public async deleteMlSnapshot({ jobId, snapshotId }: { jobId: string; snapshotId: string }) { + const result = await this.sendRequest({ + path: `${API_BASE_PATH}/ml_snapshots/${jobId}/${snapshotId}`, + method: 'delete', + }); + + return result; + } + + public async getMlSnapshotUpgradeStatus({ + jobId, + snapshotId, + }: { + jobId: string; + snapshotId: string; + }) { + return await this.sendRequest({ + path: `${API_BASE_PATH}/ml_snapshots/${jobId}/${snapshotId}`, + method: 'get', + }); + } } export const apiService = new ApiService(); 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 73e5d33e6c968..8cd9f8b6591e3 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 @@ -14,7 +14,6 @@ import { breadcrumbService } from './lib/breadcrumbs'; export async function mountManagementSection( coreSetup: CoreSetup, - isCloudEnabled: boolean, params: ManagementAppMountParams, kibanaVersionInfo: KibanaVersionContext, readonly: boolean @@ -31,7 +30,6 @@ export async function mountManagementSection( return renderApp({ element, - isCloudEnabled, http, i18n, docLinks, diff --git a/x-pack/plugins/upgrade_assistant/public/plugin.ts b/x-pack/plugins/upgrade_assistant/public/plugin.ts index 4f5429201f304..4cffd40faf380 100644 --- a/x-pack/plugins/upgrade_assistant/public/plugin.ts +++ b/x-pack/plugins/upgrade_assistant/public/plugin.ts @@ -9,19 +9,17 @@ import SemVer from 'semver/classes/semver'; import { i18n } from '@kbn/i18n'; import { Plugin, CoreSetup, PluginInitializerContext } from 'src/core/public'; -import { CloudSetup } from '../../cloud/public'; import { ManagementSetup } from '../../../../src/plugins/management/public'; import { Config } from '../common/config'; interface Dependencies { - cloud: CloudSetup; management: ManagementSetup; } export class UpgradeAssistantUIPlugin implements Plugin { constructor(private ctx: PluginInitializerContext) {} - setup(coreSetup: CoreSetup, { cloud, management }: Dependencies) { + setup(coreSetup: CoreSetup, { management }: Dependencies) { const { enabled, readonly } = this.ctx.config.get<Config>(); if (!enabled) { @@ -29,7 +27,6 @@ export class UpgradeAssistantUIPlugin implements Plugin { } const appRegistrar = management.sections.section.stack; - const isCloudEnabled = Boolean(cloud?.isCloudEnabled); const kibanaVersion = new SemVer(this.ctx.env.packageInfo.version); const kibanaVersionInfo = { @@ -59,7 +56,6 @@ export class UpgradeAssistantUIPlugin implements Plugin { const { mountManagementSection } = await import('./application/mount_management_section'); const unmountAppCallback = await mountManagementSection( coreSetup, - isCloudEnabled, params, kibanaVersionInfo, readonly diff --git a/x-pack/plugins/upgrade_assistant/server/lib/__fixtures__/fake_deprecations.json b/x-pack/plugins/upgrade_assistant/server/lib/__fixtures__/fake_deprecations.json index 10a5d39f5cece..2b8519d75cb2f 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/__fixtures__/fake_deprecations.json +++ b/x-pack/plugins/upgrade_assistant/server/lib/__fixtures__/fake_deprecations.json @@ -19,6 +19,12 @@ "message": "Datafeed [deprecation-datafeed] uses deprecated query options", "url": "https://www.elastic.co/guide/en/elasticsearch/reference/master/breaking-changes-7.0.html#breaking_70_search_changes", "details": "[Deprecated field [use_dis_max] used, replaced by [Set [tie_breaker] to 1 instead]]" + }, + { + "level": "critical", + "message": "model snapshot [1] for job [deprecation_check_job] needs to be deleted or upgraded", + "url": "", + "details": "details" } ], "node_settings": [ @@ -46,6 +52,33 @@ "details": "[[type: tweet, field: liked]]" } ], + "old_index": [ + { + "level": "critical", + "message": "Index created before 7.0", + "url": + "https: //www.elastic.co/guide/en/elasticsearch/reference/master/breaking-changes-8.0.html", + "details": "This index was created using version: 6.8.13" + } + ], + "closed_index": [ + { + "level": "critical", + "message": "Index created before 7.0", + "url": "https: //www.elastic.co/guide/en/elasticsearch/reference/master/breaking-changes-8.0.html", + "details": "This index was created using version: 6.8.13" + } + ], + "deprecated_settings": [ + { + "level": "warning", + "message": "translog retention settings are ignored", + "url": + "https://www.elastic.co/guide/en/elasticsearch/reference/current/index-modules-translog.html", + "details": + "translog retention settings [index.translog.retention.size] and [index.translog.retention.age] are ignored because translog is no longer used in peer recoveries with soft-deletes enabled (default in 7.0 or later)" + } + ], ".kibana": [ { "level": "warning", @@ -79,4 +112,4 @@ } ] } -} \ No newline at end of file +} diff --git a/x-pack/plugins/upgrade_assistant/server/lib/__snapshots__/es_migration_apis.test.ts.snap b/x-pack/plugins/upgrade_assistant/server/lib/__snapshots__/es_migration_apis.test.ts.snap index aefac2b4c63f6..a7890adf1f0eb 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/__snapshots__/es_migration_apis.test.ts.snap +++ b/x-pack/plugins/upgrade_assistant/server/lib/__snapshots__/es_migration_apis.test.ts.snap @@ -4,24 +4,39 @@ exports[`getUpgradeAssistantStatus returns the correct shape of data 1`] = ` Object { "cluster": Array [ Object { + "correctiveAction": undefined, "details": "templates using \`template\` field: security_audit_log,watches,.monitoring-alerts,triggered_watches,.ml-anomalies-,.ml-notifications,.ml-meta,.monitoring-kibana,.monitoring-es,.monitoring-logstash,.watch-history-6,.ml-state,security-index-template", "level": "warning", "message": "Template patterns are no longer using \`template\` field, but \`index_patterns\` instead", "url": "https://www.elastic.co/guide/en/elasticsearch/reference/6.0/breaking_60_indices_changes.html#_index_templates_use_literal_index_patterns_literal_instead_of_literal_template_literal", }, Object { + "correctiveAction": undefined, "details": "{.monitoring-logstash=[Coercion of boolean fields], .monitoring-es=[Coercion of boolean fields], .ml-anomalies-=[Coercion of boolean fields], .watch-history-6=[Coercion of boolean fields], .monitoring-kibana=[Coercion of boolean fields], security-index-template=[Coercion of boolean fields]}", "level": "warning", "message": "one or more templates use deprecated mapping settings", "url": "https://www.elastic.co/guide/en/elasticsearch/reference/6.0/breaking_60_indices_changes.html", }, Object { + "correctiveAction": undefined, "details": "[Deprecated field [use_dis_max] used, replaced by [Set [tie_breaker] to 1 instead]]", "level": "warning", "message": "Datafeed [deprecation-datafeed] uses deprecated query options", "url": "https://www.elastic.co/guide/en/elasticsearch/reference/master/breaking-changes-7.0.html#breaking_70_search_changes", }, Object { + "correctiveAction": Object { + "jobId": "deprecation_check_job", + "snapshotId": "1", + "type": "mlSnapshot", + }, + "details": "details", + "level": "critical", + "message": "model snapshot [1] for job [deprecation_check_job] needs to be deleted or upgraded", + "url": "", + }, + Object { + "correctiveAction": undefined, "details": "This node thing is wrong", "level": "critical", "message": "A node-level issue", @@ -30,63 +45,87 @@ Object { ], "indices": Array [ Object { - "blockerForReindexing": undefined, - "deprecatedIndexSettings": Array [], + "correctiveAction": undefined, "details": "[[type: doc, field: spins], [type: doc, field: mlockall], [type: doc, field: node_master], [type: doc, field: primary]]", "index": ".monitoring-es-6-2018.11.07", "level": "warning", "message": "Coercion of boolean fields", - "reindex": false, "url": "https://www.elastic.co/guide/en/elasticsearch/reference/6.0/breaking_60_mappings_changes.html#_coercion_of_boolean_fields", }, Object { - "blockerForReindexing": undefined, - "deprecatedIndexSettings": Array [], + "correctiveAction": undefined, "details": "[[type: tweet, field: liked]]", "index": "twitter", "level": "warning", "message": "Coercion of boolean fields", - "reindex": false, "url": "https://www.elastic.co/guide/en/elasticsearch/reference/6.0/breaking_60_mappings_changes.html#_coercion_of_boolean_fields", }, Object { - "blockerForReindexing": undefined, - "deprecatedIndexSettings": Array [], + "correctiveAction": Object { + "blockerForReindexing": undefined, + "type": "reindex", + }, + "details": "This index was created using version: 6.8.13", + "index": "old_index", + "level": "critical", + "message": "Index created before 7.0", + "url": "https: //www.elastic.co/guide/en/elasticsearch/reference/master/breaking-changes-8.0.html", + }, + Object { + "correctiveAction": Object { + "blockerForReindexing": "index-closed", + "type": "reindex", + }, + "details": "This index was created using version: 6.8.13", + "index": "closed_index", + "level": "critical", + "message": "Index created before 7.0", + "url": "https: //www.elastic.co/guide/en/elasticsearch/reference/master/breaking-changes-8.0.html", + }, + Object { + "correctiveAction": Object { + "deprecatedSettings": Array [ + "translog.retention.size", + "translog.retention.age", + ], + "type": "indexSetting", + }, + "details": "translog retention settings [index.translog.retention.size] and [index.translog.retention.age] are ignored because translog is no longer used in peer recoveries with soft-deletes enabled (default in 7.0 or later)", + "index": "deprecated_settings", + "level": "warning", + "message": "translog retention settings are ignored", + "url": "https://www.elastic.co/guide/en/elasticsearch/reference/current/index-modules-translog.html", + }, + Object { + "correctiveAction": undefined, "details": "[[type: index-pattern, field: notExpandable], [type: config, field: xPackMonitoring:allowReport], [type: config, field: xPackMonitoring:showBanner], [type: dashboard, field: pause], [type: dashboard, field: timeRestore]]", "index": ".kibana", "level": "warning", "message": "Coercion of boolean fields", - "reindex": false, "url": "https://www.elastic.co/guide/en/elasticsearch/reference/6.0/breaking_60_mappings_changes.html#_coercion_of_boolean_fields", }, Object { - "blockerForReindexing": undefined, - "deprecatedIndexSettings": Array [], + "correctiveAction": undefined, "details": "[[type: doc, field: notify], [type: doc, field: created], [type: doc, field: attach_payload], [type: doc, field: met]]", "index": ".watcher-history-6-2018.11.07", "level": "warning", "message": "Coercion of boolean fields", - "reindex": false, "url": "https://www.elastic.co/guide/en/elasticsearch/reference/6.0/breaking_60_mappings_changes.html#_coercion_of_boolean_fields", }, Object { - "blockerForReindexing": undefined, - "deprecatedIndexSettings": Array [], + "correctiveAction": undefined, "details": "[[type: doc, field: snapshot]]", "index": ".monitoring-kibana-6-2018.11.07", "level": "warning", "message": "Coercion of boolean fields", - "reindex": false, "url": "https://www.elastic.co/guide/en/elasticsearch/reference/6.0/breaking_60_mappings_changes.html#_coercion_of_boolean_fields", }, Object { - "blockerForReindexing": undefined, - "deprecatedIndexSettings": Array [], + "correctiveAction": undefined, "details": "[[type: tweet, field: liked]]", "index": "twitter2", "level": "warning", "message": "Coercion of boolean fields", - "reindex": false, "url": "https://www.elastic.co/guide/en/elasticsearch/reference/6.0/breaking_60_mappings_changes.html#_coercion_of_boolean_fields", }, ], diff --git a/x-pack/plugins/upgrade_assistant/server/lib/es_migration_apis.test.ts b/x-pack/plugins/upgrade_assistant/server/lib/es_migration_apis.test.ts index d78af9162e924..6477ce738c084 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/es_migration_apis.test.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/es_migration_apis.test.ts @@ -22,7 +22,13 @@ const asApiResponse = <T>(body: T): RequestEvent<T> => describe('getUpgradeAssistantStatus', () => { const resolvedIndices = { - indices: fakeIndexNames.map((f) => ({ name: f, attributes: ['open'] })), + indices: fakeIndexNames.map((indexName) => { + // mark one index as closed to test blockerForReindexing flag + if (indexName === 'closed_index') { + return { name: indexName, attributes: ['closed'] }; + } + return { name: indexName, attributes: ['open'] }; + }), }; // @ts-expect-error mock data is too loosely typed @@ -39,12 +45,12 @@ describe('getUpgradeAssistantStatus', () => { esClient.asCurrentUser.indices.resolveIndex.mockResolvedValue(asApiResponse(resolvedIndices)); it('calls /_migration/deprecations', async () => { - await getUpgradeAssistantStatus(esClient, false); + await getUpgradeAssistantStatus(esClient); expect(esClient.asCurrentUser.migration.deprecations).toHaveBeenCalled(); }); it('returns the correct shape of data', async () => { - const resp = await getUpgradeAssistantStatus(esClient, false); + const resp = await getUpgradeAssistantStatus(esClient); expect(resp).toMatchSnapshot(); }); @@ -59,7 +65,7 @@ describe('getUpgradeAssistantStatus', () => { }) ); - await expect(getUpgradeAssistantStatus(esClient, false)).resolves.toHaveProperty( + await expect(getUpgradeAssistantStatus(esClient)).resolves.toHaveProperty( 'readyForUpgrade', false ); @@ -76,32 +82,9 @@ describe('getUpgradeAssistantStatus', () => { }) ); - await expect(getUpgradeAssistantStatus(esClient, false)).resolves.toHaveProperty( + await expect(getUpgradeAssistantStatus(esClient)).resolves.toHaveProperty( 'readyForUpgrade', true ); }); - - it('filters out security realm deprecation on Cloud', async () => { - esClient.asCurrentUser.migration.deprecations.mockResolvedValue( - // @ts-expect-error not full interface - asApiResponse({ - cluster_settings: [ - { - level: 'critical', - message: 'Security realm settings structure changed', - url: 'https://...', - }, - ], - node_settings: [], - ml_settings: [], - index_settings: {}, - }) - ); - - const result = await getUpgradeAssistantStatus(esClient, true); - - expect(result).toHaveProperty('readyForUpgrade', true); - expect(result).toHaveProperty('cluster', []); - }); }); diff --git a/x-pack/plugins/upgrade_assistant/server/lib/es_migration_apis.ts b/x-pack/plugins/upgrade_assistant/server/lib/es_migration_apis.ts index e775190d426df..85cde9069d60f 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/es_migration_apis.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/es_migration_apis.ts @@ -16,26 +16,12 @@ import { import { esIndicesStateCheck } from './es_indices_state_check'; export async function getUpgradeAssistantStatus( - dataClient: IScopedClusterClient, - isCloudEnabled: boolean + dataClient: IScopedClusterClient ): Promise<UpgradeAssistantStatus> { const { body: deprecations } = await dataClient.asCurrentUser.migration.deprecations(); - const cluster = getClusterDeprecations(deprecations, isCloudEnabled); - const indices = getCombinedIndexInfos(deprecations); - - const indexNames = indices.map(({ index }) => index!); - - // If we have found deprecation information for index/indices check whether the index is - // open or closed. - if (indexNames.length) { - const indexStates = await esIndicesStateCheck(dataClient.asCurrentUser, indexNames); - - indices.forEach((indexData) => { - indexData.blockerForReindexing = - indexStates[indexData.index!] === 'closed' ? 'index-closed' : undefined; - }); - } + const cluster = getClusterDeprecations(deprecations); + const indices = await getCombinedIndexInfos(deprecations, dataClient); const criticalWarnings = cluster.concat(indices).filter((d) => d.level === 'critical'); @@ -47,38 +33,91 @@ export async function getUpgradeAssistantStatus( } // Reformats the index deprecations to an array of deprecation warnings extended with an index field. -const getCombinedIndexInfos = (deprecations: DeprecationAPIResponse) => - Object.keys(deprecations.index_settings).reduce((indexDeprecations, indexName) => { - return indexDeprecations.concat( - deprecations.index_settings[indexName].map( - (d) => - ({ - ...d, - index: indexName, - reindex: /Index created before/.test(d.message), - deprecatedIndexSettings: getIndexSettingDeprecations(d.message), - } as EnrichedDeprecationInfo) - ) - ); - }, [] as EnrichedDeprecationInfo[]); - -const getClusterDeprecations = (deprecations: DeprecationAPIResponse, isCloudEnabled: boolean) => { - const combined = deprecations.cluster_settings +const getCombinedIndexInfos = async ( + deprecations: DeprecationAPIResponse, + dataClient: IScopedClusterClient +) => { + const indices = Object.keys(deprecations.index_settings).reduce( + (indexDeprecations, indexName) => { + return indexDeprecations.concat( + deprecations.index_settings[indexName].map( + (d) => + ({ + ...d, + index: indexName, + correctiveAction: getCorrectiveAction(d.message), + } as EnrichedDeprecationInfo) + ) + ); + }, + [] as EnrichedDeprecationInfo[] + ); + + const indexNames = indices.map(({ index }) => index!); + + // If we have found deprecation information for index/indices + // check whether the index is open or closed. + if (indexNames.length) { + const indexStates = await esIndicesStateCheck(dataClient.asCurrentUser, indexNames); + + indices.forEach((indexData) => { + if (indexData.correctiveAction?.type === 'reindex') { + indexData.correctiveAction.blockerForReindexing = + indexStates[indexData.index!] === 'closed' ? 'index-closed' : undefined; + } + }); + } + return indices as EnrichedDeprecationInfo[]; +}; + +const getClusterDeprecations = (deprecations: DeprecationAPIResponse) => { + const combinedDeprecations = deprecations.cluster_settings .concat(deprecations.ml_settings) .concat(deprecations.node_settings); - if (isCloudEnabled) { - // In Cloud, this is changed at upgrade time. Filter it out to improve upgrade UX. - return combined.filter((d) => d.message !== 'Security realm settings structure changed'); - } else { - return combined; - } + return combinedDeprecations.map((deprecation) => { + return { + ...deprecation, + correctiveAction: getCorrectiveAction(deprecation.message), + }; + }) as EnrichedDeprecationInfo[]; }; -const getIndexSettingDeprecations = (message: string) => { - const indexDeprecation = Object.values(indexSettingDeprecations).find( +const getCorrectiveAction = (message: string) => { + const indexSettingDeprecation = Object.values(indexSettingDeprecations).find( ({ deprecationMessage }) => deprecationMessage === message ); + const requiresReindexAction = /Index created before/.test(message); + const requiresIndexSettingsAction = Boolean(indexSettingDeprecation); + const requiresMlAction = /model snapshot/.test(message); + + if (requiresReindexAction) { + return { + type: 'reindex', + }; + } + + if (requiresIndexSettingsAction) { + return { + type: 'indexSetting', + deprecatedSettings: indexSettingDeprecation!.settings, + }; + } + + if (requiresMlAction) { + // This logic is brittle, as we are expecting the message to be in a particular format to extract the snapshot ID and job ID + // Implementing https://github.com/elastic/elasticsearch/issues/73089 in ES should address this concern + const regex = /(?<=\[).*?(?=\])/g; + const matches = message.match(regex); + + if (matches?.length === 2) { + return { + type: 'mlSnapshot', + snapshotId: matches[0], + jobId: matches[1], + }; + } + } - return indexDeprecation?.settings || []; + return undefined; }; diff --git a/x-pack/plugins/upgrade_assistant/server/plugin.ts b/x-pack/plugins/upgrade_assistant/server/plugin.ts index ae5975c2bc8a7..50b7330b4d466 100644 --- a/x-pack/plugins/upgrade_assistant/server/plugin.ts +++ b/x-pack/plugins/upgrade_assistant/server/plugin.ts @@ -17,7 +17,6 @@ import { SavedObjectsServiceStart, } from '../../../../src/core/server'; -import { CloudSetup } from '../../cloud/server'; import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; import { LicensingPluginSetup } from '../../licensing/server'; @@ -25,12 +24,13 @@ import { CredentialStore, credentialStoreFactory } from './lib/reindexing/creden import { ReindexWorker } from './lib/reindexing'; import { registerUpgradeAssistantUsageCollector } from './lib/telemetry'; import { versionService } from './lib/version'; -import { registerClusterCheckupRoutes } from './routes/cluster_checkup'; -import { registerDeprecationLoggingRoutes } from './routes/deprecation_logging'; -import { registerReindexIndicesRoutes, createReindexWorker } from './routes/reindex_indices'; -import { registerTelemetryRoutes } from './routes/telemetry'; -import { registerUpdateSettingsRoute } from './routes/update_index_settings'; -import { telemetrySavedObjectType, reindexOperationSavedObjectType } from './saved_object_types'; +import { createReindexWorker } from './routes/reindex_indices'; +import { registerRoutes } from './routes/register_routes'; +import { + telemetrySavedObjectType, + reindexOperationSavedObjectType, + mlSavedObjectType, +} from './saved_object_types'; import { RouteDependencies } from './types'; @@ -38,7 +38,6 @@ interface PluginsSetup { usageCollection: UsageCollectionSetup; licensing: LicensingPluginSetup; features: FeaturesPluginSetup; - cloud?: CloudSetup; } export class UpgradeAssistantServerPlugin implements Plugin { @@ -68,12 +67,13 @@ export class UpgradeAssistantServerPlugin implements Plugin { setup( { http, getStartServices, capabilities, savedObjects }: CoreSetup, - { usageCollection, cloud, features, licensing }: PluginsSetup + { usageCollection, features, licensing }: PluginsSetup ) { this.licensing = licensing; savedObjects.registerType(reindexOperationSavedObjectType); savedObjects.registerType(telemetrySavedObjectType); + savedObjects.registerType(mlSavedObjectType); features.registerElasticsearchFeature({ id: 'upgrade_assistant', @@ -91,7 +91,6 @@ export class UpgradeAssistantServerPlugin implements Plugin { const router = http.createRouter(); const dependencies: RouteDependencies = { - cloud, router, credentialStore: this.credentialStore, log: this.logger, @@ -107,12 +106,7 @@ export class UpgradeAssistantServerPlugin implements Plugin { // Initialize version service with current kibana version versionService.setup(this.kibanaVersion); - registerClusterCheckupRoutes(dependencies); - registerDeprecationLoggingRoutes(dependencies); - registerReindexIndicesRoutes(dependencies, this.getWorker.bind(this)); - // Bootstrap the needed routes and the collector for the telemetry - registerTelemetryRoutes(dependencies); - registerUpdateSettingsRoute(dependencies); + registerRoutes(dependencies, this.getWorker.bind(this)); if (usageCollection) { getStartServices().then(([{ savedObjects: savedObjectsService, elasticsearch }]) => { diff --git a/x-pack/plugins/upgrade_assistant/server/routes/__mocks__/routes.mock.ts b/x-pack/plugins/upgrade_assistant/server/routes/__mocks__/routes.mock.ts index 3aabae87c06b1..09da52e4b6ffd 100644 --- a/x-pack/plugins/upgrade_assistant/server/routes/__mocks__/routes.mock.ts +++ b/x-pack/plugins/upgrade_assistant/server/routes/__mocks__/routes.mock.ts @@ -49,6 +49,7 @@ export const createMockRouter = () => { post: assign('post'), put: assign('put'), patch: assign('patch'), + delete: assign('delete'), }; }; diff --git a/x-pack/plugins/upgrade_assistant/server/routes/cluster_checkup.test.ts b/x-pack/plugins/upgrade_assistant/server/routes/cluster_checkup.test.ts index a5da4741b10eb..934fdb1c4eb37 100644 --- a/x-pack/plugins/upgrade_assistant/server/routes/cluster_checkup.test.ts +++ b/x-pack/plugins/upgrade_assistant/server/routes/cluster_checkup.test.ts @@ -32,9 +32,6 @@ describe('cluster checkup API', () => { beforeEach(() => { mockRouter = createMockRouter(); routeDependencies = { - cloud: { - isCloudEnabled: true, - }, router: mockRouter, }; registerClusterCheckupRoutes(routeDependencies); @@ -44,24 +41,6 @@ describe('cluster checkup API', () => { jest.resetAllMocks(); }); - describe('with cloud enabled', () => { - it('is provided to getUpgradeAssistantStatus', async () => { - const spy = jest.spyOn(MigrationApis, 'getUpgradeAssistantStatus'); - - MigrationApis.getUpgradeAssistantStatus.mockResolvedValue({ - cluster: [], - indices: [], - nodes: [], - }); - - await routeDependencies.router.getHandler({ - method: 'get', - pathPattern: '/api/upgrade_assistant/status', - })(routeHandlerContextMock, createRequestMock(), kibanaResponseFactory); - expect(spy.mock.calls[0][1]).toBe(true); - }); - }); - describe('GET /api/upgrade_assistant/reindex/{indexName}.json', () => { it('returns state', async () => { MigrationApis.getUpgradeAssistantStatus.mockResolvedValue({ diff --git a/x-pack/plugins/upgrade_assistant/server/routes/cluster_checkup.ts b/x-pack/plugins/upgrade_assistant/server/routes/cluster_checkup.ts index fe5b9baef6c8d..31026be55fa30 100644 --- a/x-pack/plugins/upgrade_assistant/server/routes/cluster_checkup.ts +++ b/x-pack/plugins/upgrade_assistant/server/routes/cluster_checkup.ts @@ -12,9 +12,7 @@ import { RouteDependencies } from '../types'; import { reindexActionsFactory } from '../lib/reindexing/reindex_actions'; import { reindexServiceFactory } from '../lib/reindexing'; -export function registerClusterCheckupRoutes({ cloud, router, licensing, log }: RouteDependencies) { - const isCloudEnabled = Boolean(cloud?.isCloudEnabled); - +export function registerClusterCheckupRoutes({ router, licensing, log }: RouteDependencies) { router.get( { path: `${API_BASE_PATH}/status`, @@ -32,7 +30,7 @@ export function registerClusterCheckupRoutes({ cloud, router, licensing, log }: response ) => { try { - const status = await getUpgradeAssistantStatus(client, isCloudEnabled); + const status = await getUpgradeAssistantStatus(client); const asCurrentUser = client.asCurrentUser; const reindexActions = reindexActionsFactory(savedObjectsClient, asCurrentUser); diff --git a/x-pack/plugins/upgrade_assistant/server/routes/ml_snapshots.test.ts b/x-pack/plugins/upgrade_assistant/server/routes/ml_snapshots.test.ts new file mode 100644 index 0000000000000..741f704adac90 --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/server/routes/ml_snapshots.test.ts @@ -0,0 +1,365 @@ +/* + * 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 { kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { createMockRouter, MockRouter, routeHandlerContextMock } from './__mocks__/routes.mock'; +import { createRequestMock } from './__mocks__/request.mock'; +import { registerMlSnapshotRoutes } from './ml_snapshots'; + +jest.mock('../lib/es_version_precheck', () => ({ + versionCheckHandlerWrapper: <P, Q, B>(handler: RequestHandler<P, Q, B>) => handler, +})); + +const JOB_ID = 'job_id'; +const SNAPSHOT_ID = 'snapshot_id'; +const NODE_ID = 'node_id'; + +describe('ML snapshots APIs', () => { + let mockRouter: MockRouter; + let routeDependencies: any; + + beforeEach(() => { + mockRouter = createMockRouter(); + routeDependencies = { + router: mockRouter, + }; + registerMlSnapshotRoutes(routeDependencies); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('POST /api/upgrade_assistant/ml_snapshots', () => { + it('returns 200 status and in_progress status', async () => { + (routeHandlerContextMock.core.elasticsearch.client.asCurrentUser.ml + .upgradeJobSnapshot as jest.Mock).mockResolvedValue({ + body: { + node: NODE_ID, + completed: false, + }, + }); + + const resp = await routeDependencies.router.getHandler({ + method: 'post', + pathPattern: '/api/upgrade_assistant/ml_snapshots', + })( + routeHandlerContextMock, + createRequestMock({ + body: { + snapshotId: SNAPSHOT_ID, + jobId: JOB_ID, + }, + }), + kibanaResponseFactory + ); + + expect(resp.status).toEqual(200); + expect(resp.payload).toEqual({ + jobId: JOB_ID, + nodeId: NODE_ID, + snapshotId: SNAPSHOT_ID, + status: 'in_progress', + }); + }); + + it('returns 200 status and complete status', async () => { + (routeHandlerContextMock.core.elasticsearch.client.asCurrentUser.ml + .upgradeJobSnapshot as jest.Mock).mockResolvedValue({ + body: { + node: NODE_ID, + completed: true, + }, + }); + + const resp = await routeDependencies.router.getHandler({ + method: 'post', + pathPattern: '/api/upgrade_assistant/ml_snapshots', + })( + routeHandlerContextMock, + createRequestMock({ + body: { + snapshotId: SNAPSHOT_ID, + jobId: JOB_ID, + }, + }), + kibanaResponseFactory + ); + + expect(resp.status).toEqual(200); + expect(resp.payload).toEqual({ + jobId: JOB_ID, + nodeId: NODE_ID, + snapshotId: SNAPSHOT_ID, + status: 'complete', + }); + }); + + it('returns an error if it throws', async () => { + (routeHandlerContextMock.core.elasticsearch.client.asCurrentUser.ml + .upgradeJobSnapshot as jest.Mock).mockRejectedValue(new Error('scary error!')); + await expect( + routeDependencies.router.getHandler({ + method: 'post', + pathPattern: '/api/upgrade_assistant/ml_snapshots', + })( + routeHandlerContextMock, + createRequestMock({ + body: { + snapshotId: SNAPSHOT_ID, + jobId: JOB_ID, + }, + }), + kibanaResponseFactory + ) + ).rejects.toThrow('scary error!'); + }); + }); + + describe('DELETE /api/upgrade_assistant/ml_snapshots/:jobId/:snapshotId', () => { + it('returns 200 status', async () => { + (routeHandlerContextMock.core.elasticsearch.client.asCurrentUser.ml + .deleteModelSnapshot as jest.Mock).mockResolvedValue({ + body: { acknowledged: true }, + }); + + const resp = await routeDependencies.router.getHandler({ + method: 'delete', + pathPattern: '/api/upgrade_assistant/ml_snapshots/{jobId}/{snapshotId}', + })( + routeHandlerContextMock, + createRequestMock({ + params: { snapshotId: 'snapshot_id1', jobId: 'job_id1' }, + }), + kibanaResponseFactory + ); + + expect(resp.status).toEqual(200); + expect(resp.payload).toEqual({ + acknowledged: true, + }); + }); + + it('returns an error if it throws', async () => { + (routeHandlerContextMock.core.elasticsearch.client.asCurrentUser.ml + .deleteModelSnapshot as jest.Mock).mockRejectedValue(new Error('scary error!')); + await expect( + routeDependencies.router.getHandler({ + method: 'delete', + pathPattern: '/api/upgrade_assistant/ml_snapshots/{jobId}/{snapshotId}', + })( + routeHandlerContextMock, + createRequestMock({ + params: { snapshotId: 'snapshot_id1', jobId: 'job_id1' }, + }), + kibanaResponseFactory + ) + ).rejects.toThrow('scary error!'); + }); + }); + + describe('GET /api/upgrade_assistant/ml_snapshots/:jobId/:snapshotId', () => { + it('returns "idle" status if saved object does not exist', async () => { + (routeHandlerContextMock.core.elasticsearch.client.asCurrentUser.ml + .getModelSnapshots as jest.Mock).mockResolvedValue({ + body: { + count: 1, + model_snapshots: [ + { + job_id: JOB_ID, + min_version: '6.4.0', + timestamp: 1575402237000, + description: 'State persisted due to job close at 2019-12-03T19:43:57+0000', + snapshot_id: SNAPSHOT_ID, + snapshot_doc_count: 1, + model_size_stats: {}, + latest_record_time_stamp: 1576971072000, + latest_result_time_stamp: 1576965600000, + retain: false, + }, + ], + }, + }); + + const resp = await routeDependencies.router.getHandler({ + method: 'get', + pathPattern: '/api/upgrade_assistant/ml_snapshots/{jobId}/{snapshotId}', + })( + routeHandlerContextMock, + createRequestMock({ + params: { + snapshotId: SNAPSHOT_ID, + jobId: JOB_ID, + }, + }), + kibanaResponseFactory + ); + + expect(resp.status).toEqual(200); + expect(resp.payload).toEqual({ + jobId: JOB_ID, + nodeId: undefined, + snapshotId: SNAPSHOT_ID, + status: 'idle', + }); + }); + + it('returns "in_progress" status if snapshot upgrade is in progress', async () => { + (routeHandlerContextMock.core.elasticsearch.client.asCurrentUser.ml + .getModelSnapshots as jest.Mock).mockResolvedValue({ + body: { + count: 1, + model_snapshots: [ + { + job_id: JOB_ID, + min_version: '6.4.0', + timestamp: 1575402237000, + description: 'State persisted due to job close at 2019-12-03T19:43:57+0000', + snapshot_id: SNAPSHOT_ID, + snapshot_doc_count: 1, + model_size_stats: {}, + latest_record_time_stamp: 1576971072000, + latest_result_time_stamp: 1576965600000, + retain: false, + }, + ], + }, + }); + + (routeHandlerContextMock.core.savedObjects.client.find as jest.Mock).mockResolvedValue({ + total: 1, + saved_objects: [ + { + attributes: { + nodeId: NODE_ID, + jobId: JOB_ID, + snapshotId: SNAPSHOT_ID, + }, + }, + ], + }); + + (routeHandlerContextMock.core.elasticsearch.client.asCurrentUser.tasks + .list as jest.Mock).mockResolvedValue({ + body: { + nodes: { + [NODE_ID]: { + tasks: { + [`${NODE_ID}:12345`]: { + description: `job-snapshot-upgrade-${JOB_ID}-${SNAPSHOT_ID}`, + }, + }, + }, + }, + }, + }); + + const resp = await routeDependencies.router.getHandler({ + method: 'get', + pathPattern: '/api/upgrade_assistant/ml_snapshots/{jobId}/{snapshotId}', + })( + routeHandlerContextMock, + createRequestMock({ + params: { + snapshotId: SNAPSHOT_ID, + jobId: JOB_ID, + }, + }), + kibanaResponseFactory + ); + + expect(resp.status).toEqual(200); + expect(resp.payload).toEqual({ + jobId: JOB_ID, + nodeId: NODE_ID, + snapshotId: SNAPSHOT_ID, + status: 'in_progress', + }); + }); + + it('returns "complete" status if snapshot upgrade has completed', async () => { + (routeHandlerContextMock.core.elasticsearch.client.asCurrentUser.ml + .getModelSnapshots as jest.Mock).mockResolvedValue({ + body: { + count: 1, + model_snapshots: [ + { + job_id: JOB_ID, + min_version: '6.4.0', + timestamp: 1575402237000, + description: 'State persisted due to job close at 2019-12-03T19:43:57+0000', + snapshot_id: SNAPSHOT_ID, + snapshot_doc_count: 1, + model_size_stats: {}, + latest_record_time_stamp: 1576971072000, + latest_result_time_stamp: 1576965600000, + retain: false, + }, + ], + }, + }); + + (routeHandlerContextMock.core.savedObjects.client.find as jest.Mock).mockResolvedValue({ + total: 1, + saved_objects: [ + { + attributes: { + nodeId: NODE_ID, + jobId: JOB_ID, + snapshotId: SNAPSHOT_ID, + }, + }, + ], + }); + + (routeHandlerContextMock.core.elasticsearch.client.asCurrentUser.tasks + .list as jest.Mock).mockResolvedValue({ + body: { + nodes: { + [NODE_ID]: { + tasks: {}, + }, + }, + }, + }); + + (routeHandlerContextMock.core.elasticsearch.client.asCurrentUser.migration + .deprecations as jest.Mock).mockResolvedValue({ + body: { + cluster_settings: [], + ml_settings: [], + node_settings: [], + index_settings: {}, + }, + }); + + (routeHandlerContextMock.core.savedObjects.client.delete as jest.Mock).mockResolvedValue({}); + + const resp = await routeDependencies.router.getHandler({ + method: 'get', + pathPattern: '/api/upgrade_assistant/ml_snapshots/{jobId}/{snapshotId}', + })( + routeHandlerContextMock, + createRequestMock({ + params: { + snapshotId: SNAPSHOT_ID, + jobId: JOB_ID, + }, + }), + kibanaResponseFactory + ); + + expect(resp.status).toEqual(200); + expect(resp.payload).toEqual({ + jobId: JOB_ID, + nodeId: NODE_ID, + snapshotId: SNAPSHOT_ID, + status: 'complete', + }); + }); + }); +}); diff --git a/x-pack/plugins/upgrade_assistant/server/routes/ml_snapshots.ts b/x-pack/plugins/upgrade_assistant/server/routes/ml_snapshots.ts new file mode 100644 index 0000000000000..80f5f2eb60e09 --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/server/routes/ml_snapshots.ts @@ -0,0 +1,348 @@ +/* + * 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 { ResponseError } from '@elastic/elasticsearch/lib/errors'; +import { schema } from '@kbn/config-schema'; +import { IScopedClusterClient, SavedObjectsClientContract } from 'kibana/server'; +import { API_BASE_PATH } from '../../common/constants'; +import { MlOperation, ML_UPGRADE_OP_TYPE } from '../../common/types'; +import { versionCheckHandlerWrapper } from '../lib/es_version_precheck'; +import { handleEsError } from '../shared_imports'; +import { RouteDependencies } from '../types'; + +const findMlOperation = async ( + savedObjectsClient: SavedObjectsClientContract, + snapshotId: string +) => { + return savedObjectsClient.find<MlOperation>({ + type: ML_UPGRADE_OP_TYPE, + search: `"${snapshotId}"`, + searchFields: ['snapshotId'], + }); +}; + +const createMlOperation = async ( + savedObjectsClient: SavedObjectsClientContract, + attributes: MlOperation +) => { + const foundSnapshots = await findMlOperation(savedObjectsClient, attributes.snapshotId); + + if (foundSnapshots?.total > 0) { + throw new Error(`A ML operation is already in progress for snapshot: ${attributes.snapshotId}`); + } + + return savedObjectsClient.create<MlOperation>(ML_UPGRADE_OP_TYPE, attributes); +}; + +const deleteMlOperation = (savedObjectsClient: SavedObjectsClientContract, id: string) => { + return savedObjectsClient.delete(ML_UPGRADE_OP_TYPE, id); +}; + +/* + * The tasks API can only tell us if the snapshot upgrade is in progress. + * We cannot rely on it to determine if a snapshot was upgraded successfully. + * If the task does not exist, it can mean one of two things: + * 1. The snapshot was upgraded successfully. + * 2. There was a failure upgrading the snapshot. + * In order to verify it was successful, we need to recheck the deprecation info API + * and verify the deprecation no longer exists. If it still exists, we assume there was a failure. + */ +const verifySnapshotUpgrade = async ( + esClient: IScopedClusterClient, + snapshot: { snapshotId: string; jobId: string } +): Promise<{ + isSuccessful: boolean; + error?: ResponseError; +}> => { + const { snapshotId, jobId } = snapshot; + + try { + const { body: deprecations } = await esClient.asCurrentUser.migration.deprecations(); + + const mlSnapshotDeprecations = deprecations.ml_settings.filter((deprecation) => { + return /model snapshot/.test(deprecation.message); + }); + + // If there are no ML deprecations, we assume the deprecation was resolved successfully + if (typeof mlSnapshotDeprecations === 'undefined' || mlSnapshotDeprecations.length === 0) { + return { + isSuccessful: true, + }; + } + + const isSuccessful = Boolean( + mlSnapshotDeprecations.find((snapshotDeprecation) => { + const regex = /(?<=\[).*?(?=\])/g; + const matches = snapshotDeprecation.message.match(regex); + + if (matches?.length === 2) { + // If there is no matching snapshot, we assume the deprecation was resolved successfully + return matches[0] === snapshotId && matches[1] === jobId ? false : true; + } + + return false; + }) + ); + + return { + isSuccessful, + }; + } catch (e) { + return { + isSuccessful: false, + error: e, + }; + } +}; + +export function registerMlSnapshotRoutes({ router }: RouteDependencies) { + // Upgrade ML model snapshot + router.post( + { + path: `${API_BASE_PATH}/ml_snapshots`, + validate: { + body: schema.object({ + snapshotId: schema.string(), + jobId: schema.string(), + }), + }, + }, + versionCheckHandlerWrapper( + async ( + { + core: { + savedObjects: { client: savedObjectsClient }, + elasticsearch: { client: esClient }, + }, + }, + request, + response + ) => { + try { + const { snapshotId, jobId } = request.body; + + const { body } = await esClient.asCurrentUser.ml.upgradeJobSnapshot({ + job_id: jobId, + snapshot_id: snapshotId, + }); + + const snapshotInfo: MlOperation = { + nodeId: body.node, + snapshotId, + jobId, + }; + + // Store snapshot in saved object if upgrade not complete + if (body.completed !== true) { + await createMlOperation(savedObjectsClient, snapshotInfo); + } + + return response.ok({ + body: { + ...snapshotInfo, + status: body.completed === true ? 'complete' : 'in_progress', + }, + }); + } catch (e) { + return handleEsError({ error: e, response }); + } + } + ) + ); + + // Get the status of the upgrade snapshot task + router.get( + { + path: `${API_BASE_PATH}/ml_snapshots/{jobId}/{snapshotId}`, + validate: { + params: schema.object({ + snapshotId: schema.string(), + jobId: schema.string(), + }), + }, + }, + versionCheckHandlerWrapper( + async ( + { + core: { + savedObjects: { client: savedObjectsClient }, + elasticsearch: { client: esClient }, + }, + }, + request, + response + ) => { + try { + const { snapshotId, jobId } = request.params; + + // Verify snapshot exists + await esClient.asCurrentUser.ml.getModelSnapshots({ + job_id: jobId, + snapshot_id: snapshotId, + }); + + const foundSnapshots = await findMlOperation(savedObjectsClient, snapshotId); + + // If snapshot is *not* found in SO, assume there has not been an upgrade operation started + if (typeof foundSnapshots === 'undefined' || foundSnapshots.total === 0) { + return response.ok({ + body: { + snapshotId, + jobId, + nodeId: undefined, + status: 'idle', + }, + }); + } + + const snapshotOp = foundSnapshots.saved_objects[0]; + const { nodeId } = snapshotOp.attributes; + + // Now that we have the node ID, check the upgrade snapshot task progress + const { body: taskResponse } = await esClient.asCurrentUser.tasks.list({ + nodes: [nodeId], + actions: 'xpack/ml/job/snapshot/upgrade', + detailed: true, // necessary in order to filter if there are more than 1 snapshot upgrades in progress + }); + + const nodeTaskInfo = taskResponse?.nodes && taskResponse!.nodes[nodeId]; + const snapshotInfo: MlOperation = { + ...snapshotOp.attributes, + }; + + if (nodeTaskInfo) { + // Find the correct snapshot task ID based on the task description + const snapshotTaskId = Object.keys(nodeTaskInfo.tasks).find((task) => { + // The description is in the format of "job-snapshot-upgrade-<job_id>-<snapshot_id>" + const taskDescription = nodeTaskInfo.tasks[task].description; + const taskSnapshotAndJobIds = taskDescription!.replace('job-snapshot-upgrade-', ''); + const taskSnapshotAndJobIdParts = taskSnapshotAndJobIds.split('-'); + const taskSnapshotId = + taskSnapshotAndJobIdParts[taskSnapshotAndJobIdParts.length - 1]; + const taskJobId = taskSnapshotAndJobIdParts.slice(0, 1).join('-'); + + return taskSnapshotId === snapshotId && taskJobId === jobId; + }); + + // If the snapshot task exists, assume the upgrade is in progress + if (snapshotTaskId && nodeTaskInfo.tasks[snapshotTaskId]) { + return response.ok({ + body: { + ...snapshotInfo, + status: 'in_progress', + }, + }); + } else { + // The task ID was not found; verify the deprecation was resolved + const { + isSuccessful: isSnapshotDeprecationResolved, + error: upgradeSnapshotError, + } = await verifySnapshotUpgrade(esClient, { + snapshotId, + jobId, + }); + + // Delete the SO; if it's complete, no need to store it anymore. If there's an error, this will give the user a chance to retry + await deleteMlOperation(savedObjectsClient, snapshotOp.id); + + if (isSnapshotDeprecationResolved) { + return response.ok({ + body: { + ...snapshotInfo, + status: 'complete', + }, + }); + } + + return response.customError({ + statusCode: upgradeSnapshotError ? upgradeSnapshotError.statusCode : 500, + body: { + message: + upgradeSnapshotError?.body?.error?.reason || + 'There was an error upgrading your snapshot. Check the Elasticsearch logs for more details.', + }, + }); + } + } else { + // No tasks found; verify the deprecation was resolved + const { + isSuccessful: isSnapshotDeprecationResolved, + error: upgradeSnapshotError, + } = await verifySnapshotUpgrade(esClient, { + snapshotId, + jobId, + }); + + // Delete the SO; if it's complete, no need to store it anymore. If there's an error, this will give the user a chance to retry + await deleteMlOperation(savedObjectsClient, snapshotOp.id); + + if (isSnapshotDeprecationResolved) { + return response.ok({ + body: { + ...snapshotInfo, + status: 'complete', + }, + }); + } + + return response.customError({ + statusCode: upgradeSnapshotError ? upgradeSnapshotError.statusCode : 500, + body: { + message: + upgradeSnapshotError?.body?.error?.reason || + 'There was an error upgrading your snapshot. Check the Elasticsearch logs for more details.', + }, + }); + } + } catch (e) { + return handleEsError({ error: e, response }); + } + } + ) + ); + + // Delete ML model snapshot + router.delete( + { + path: `${API_BASE_PATH}/ml_snapshots/{jobId}/{snapshotId}`, + validate: { + params: schema.object({ + snapshotId: schema.string(), + jobId: schema.string(), + }), + }, + }, + versionCheckHandlerWrapper( + async ( + { + core: { + elasticsearch: { client }, + }, + }, + request, + response + ) => { + try { + const { snapshotId, jobId } = request.params; + + const { + body: deleteSnapshotResponse, + } = await client.asCurrentUser.ml.deleteModelSnapshot({ + job_id: jobId, + snapshot_id: snapshotId, + }); + + return response.ok({ + body: deleteSnapshotResponse, + }); + } catch (e) { + return handleEsError({ error: e, response }); + } + } + ) + ); +} diff --git a/x-pack/plugins/upgrade_assistant/server/routes/register_routes.ts b/x-pack/plugins/upgrade_assistant/server/routes/register_routes.ts new file mode 100644 index 0000000000000..50cb9257462b9 --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/server/routes/register_routes.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { RouteDependencies } from '../types'; + +import { registerClusterCheckupRoutes } from './cluster_checkup'; +import { registerDeprecationLoggingRoutes } from './deprecation_logging'; +import { registerReindexIndicesRoutes } from './reindex_indices'; +import { registerTelemetryRoutes } from './telemetry'; +import { registerUpdateSettingsRoute } from './update_index_settings'; +import { registerMlSnapshotRoutes } from './ml_snapshots'; +import { ReindexWorker } from '../lib/reindexing'; + +export function registerRoutes(dependencies: RouteDependencies, getWorker: () => ReindexWorker) { + registerClusterCheckupRoutes(dependencies); + registerDeprecationLoggingRoutes(dependencies); + registerReindexIndicesRoutes(dependencies, getWorker); + registerTelemetryRoutes(dependencies); + registerUpdateSettingsRoute(dependencies); + registerMlSnapshotRoutes(dependencies); +} diff --git a/x-pack/plugins/upgrade_assistant/server/saved_object_types/index.ts b/x-pack/plugins/upgrade_assistant/server/saved_object_types/index.ts index 91779bd4224b8..e394cac5100f9 100644 --- a/x-pack/plugins/upgrade_assistant/server/saved_object_types/index.ts +++ b/x-pack/plugins/upgrade_assistant/server/saved_object_types/index.ts @@ -7,3 +7,4 @@ export { reindexOperationSavedObjectType } from './reindex_operation_saved_object_type'; export { telemetrySavedObjectType } from './telemetry_saved_object_type'; +export { mlSavedObjectType } from './ml_upgrade_operation_saved_object_type'; diff --git a/x-pack/plugins/upgrade_assistant/server/saved_object_types/ml_upgrade_operation_saved_object_type.ts b/x-pack/plugins/upgrade_assistant/server/saved_object_types/ml_upgrade_operation_saved_object_type.ts new file mode 100644 index 0000000000000..6dc70fab1203f --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/server/saved_object_types/ml_upgrade_operation_saved_object_type.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SavedObjectsType } from 'src/core/server'; + +import { ML_UPGRADE_OP_TYPE } from '../../common/types'; + +export const mlSavedObjectType: SavedObjectsType = { + name: ML_UPGRADE_OP_TYPE, + hidden: false, + namespaceType: 'agnostic', + mappings: { + properties: { + nodeId: { + type: 'text', + fields: { + keyword: { + type: 'keyword', + ignore_above: 256, + }, + }, + }, + snapshotId: { + type: 'text', + fields: { + keyword: { + type: 'keyword', + ignore_above: 256, + }, + }, + }, + jobId: { + type: 'text', + fields: { + keyword: { + type: 'keyword', + ignore_above: 256, + }, + }, + }, + status: { + type: 'text', + fields: { + keyword: { + type: 'keyword', + ignore_above: 256, + }, + }, + }, + }, + }, +}; diff --git a/x-pack/plugins/upgrade_assistant/server/shared_imports.ts b/x-pack/plugins/upgrade_assistant/server/shared_imports.ts new file mode 100644 index 0000000000000..7f55d189457c7 --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/server/shared_imports.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 { handleEsError } from '../../../../src/plugins/es_ui_shared/server'; diff --git a/x-pack/plugins/upgrade_assistant/server/types.ts b/x-pack/plugins/upgrade_assistant/server/types.ts index 80c60e3f310bc..b25b73070e4cf 100644 --- a/x-pack/plugins/upgrade_assistant/server/types.ts +++ b/x-pack/plugins/upgrade_assistant/server/types.ts @@ -6,7 +6,6 @@ */ import { IRouter, Logger, SavedObjectsServiceStart } from 'src/core/server'; -import { CloudSetup } from '../../cloud/server'; import { CredentialStore } from './lib/reindexing/credential_store'; import { LicensingPluginSetup } from '../../licensing/server'; @@ -16,5 +15,4 @@ export interface RouteDependencies { log: Logger; getSavedObjectsService: () => SavedObjectsServiceStart; licensing: LicensingPluginSetup; - cloud?: CloudSetup; } diff --git a/x-pack/plugins/upgrade_assistant/tsconfig.json b/x-pack/plugins/upgrade_assistant/tsconfig.json index 6303b06c0d899..750bea75c6656 100644 --- a/x-pack/plugins/upgrade_assistant/tsconfig.json +++ b/x-pack/plugins/upgrade_assistant/tsconfig.json @@ -20,8 +20,8 @@ { "path": "../../../src/core/tsconfig.json" }, { "path": "../../../src/plugins/management/tsconfig.json" }, { "path": "../../../src/plugins/usage_collection/tsconfig.json" }, - { "path": "../cloud/tsconfig.json" }, { "path": "../features/tsconfig.json" }, { "path": "../licensing/tsconfig.json" }, + { "path": "../../../src/plugins/es_ui_shared/tsconfig.json" }, ] } From 5d95e2e0cd59438e76e7a585eed7ac382f78b057 Mon Sep 17 00:00:00 2001 From: Esteban Beltran <academo@users.noreply.github.com> Date: Thu, 1 Jul 2021 14:56:21 +0200 Subject: [PATCH 37/51] [Security Solution] Add advance policy keys for memory signature and shellcode protection (#101721) Co-authored-by: Gabriel Landau <42078554+gabriellandau@users.noreply.github.com> --- .../policy/models/advanced_policy_schema.ts | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/models/advanced_policy_schema.ts b/x-pack/plugins/security_solution/public/management/pages/policy/models/advanced_policy_schema.ts index 166d3f3b98a85..62d51c3630db7 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/models/advanced_policy_schema.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/models/advanced_policy_schema.ts @@ -658,4 +658,37 @@ export const AdvancedPolicySchema: AdvancedPolicySchemaType[] = [ } ), }, + { + key: 'windows.advanced.memory_protection.shellcode_enhanced_pe_parsing', + first_supported_version: '7.15', + documentation: i18n.translate( + 'xpack.securitySolution.endpoint.policy.advanced.windows.advanced.memory_protection.shellcode_enhanced_pe_parsing', + { + defaultMessage: + "A value of 'false' disables enhanced parsing of PEs found within shellcode payloads. Default: true.", + } + ), + }, + { + key: 'windows.advanced.memory_protection.shellcode', + first_supported_version: '7.15', + documentation: i18n.translate( + 'xpack.securitySolution.endpoint.policy.advanced.windows.advanced.memory_protection.shellcode', + { + defaultMessage: + "A value of 'false' disables Shellcode Injection Protection, a feature of Memory Protection. Default: true.", + } + ), + }, + { + key: 'windows.advanced.memory_protection.memory_scan', + first_supported_version: '7.15', + documentation: i18n.translate( + 'xpack.securitySolution.endpoint.policy.advanced.windows.advanced.memory_protection.signature', + { + defaultMessage: + "A value of 'false' disables Memory Signature Scanning, a feature of Memory Protection. Default: true.", + } + ), + }, ]; From b612fca2e7e32299cfd721cfe2b369b286ec3068 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Marcondes?= <55978943+cauemarcondes@users.noreply.github.com> Date: Thu, 1 Jul 2021 09:52:26 -0400 Subject: [PATCH 38/51] Add minimum bucket size when using metric powered ui (#103773) * Add minimum bucket size when using metric powered ui * addressing PR comments * addressing comments Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../lib/helpers/get_bucket_size/index.ts | 14 ++---- .../index.test.ts | 44 +++++++++++++++++++ .../index.ts | 23 ++++++++++ ...ervice_instances_transaction_statistics.ts | 15 ++++--- ...e_transaction_group_detailed_statistics.ts | 13 ++++-- .../get_service_transaction_stats.ts | 7 +-- .../apm/server/lib/services/get_throughput.ts | 10 +++-- .../lib/transaction_groups/get_error_rate.ts | 15 ++++--- .../transactions/get_latency_charts/index.ts | 12 +++-- .../get_throughput_charts/index.ts | 11 +++-- 10 files changed, 125 insertions(+), 39 deletions(-) create mode 100644 x-pack/plugins/apm/server/lib/helpers/get_bucket_size_for_aggregated_transactions/index.test.ts create mode 100644 x-pack/plugins/apm/server/lib/helpers/get_bucket_size_for_aggregated_transactions/index.ts diff --git a/x-pack/plugins/apm/server/lib/helpers/get_bucket_size/index.ts b/x-pack/plugins/apm/server/lib/helpers/get_bucket_size/index.ts index 3f36515e72a7a..eb82a89811087 100644 --- a/x-pack/plugins/apm/server/lib/helpers/get_bucket_size/index.ts +++ b/x-pack/plugins/apm/server/lib/helpers/get_bucket_size/index.ts @@ -13,24 +13,18 @@ export function getBucketSize({ start, end, numBuckets = 100, + minBucketSize, }: { start: number; end: number; numBuckets?: number; + minBucketSize?: number; }) { const duration = moment.duration(end - start, 'ms'); const bucketSize = Math.max( calculateAuto.near(numBuckets, duration).asSeconds(), - 1 + minBucketSize || 1 ); - const intervalString = `${bucketSize}s`; - if (bucketSize < 0) { - return { - bucketSize: 0, - intervalString: 'auto', - }; - } - - return { bucketSize, intervalString }; + return { bucketSize, intervalString: `${bucketSize}s` }; } diff --git a/x-pack/plugins/apm/server/lib/helpers/get_bucket_size_for_aggregated_transactions/index.test.ts b/x-pack/plugins/apm/server/lib/helpers/get_bucket_size_for_aggregated_transactions/index.test.ts new file mode 100644 index 0000000000000..6af6d3342986c --- /dev/null +++ b/x-pack/plugins/apm/server/lib/helpers/get_bucket_size_for_aggregated_transactions/index.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { getBucketSizeForAggregatedTransactions } from './'; + +describe('getBucketSizeForAggregatedTransactions', () => { + describe('when searchAggregatedTransactions is enabled', () => { + it('returns min bucket size when date difference is lower than 60s', () => { + expect( + getBucketSizeForAggregatedTransactions({ + start: new Date('2021-06-30T15:00:00.000Z').valueOf(), + end: new Date('2021-06-30T15:00:30.000Z').valueOf(), + numBuckets: 10, + searchAggregatedTransactions: true, + }) + ).toEqual({ bucketSize: 60, intervalString: '60s' }); + }); + it('returns bucket size when date difference is greater than 60s', () => { + expect( + getBucketSizeForAggregatedTransactions({ + start: new Date('2021-06-30T15:00:00.000Z').valueOf(), + end: new Date('2021-06-30T15:30:00.000Z').valueOf(), + numBuckets: 10, + searchAggregatedTransactions: true, + }) + ).toEqual({ bucketSize: 300, intervalString: '300s' }); + }); + }); + describe('when searchAggregatedTransactions is disabled', () => { + it('returns 1s as bucket size', () => { + expect( + getBucketSizeForAggregatedTransactions({ + start: new Date('2021-06-30T15:00:00.000Z').valueOf(), + end: new Date('2021-06-30T15:00:30.000Z').valueOf(), + numBuckets: 10, + searchAggregatedTransactions: false, + }) + ).toEqual({ bucketSize: 1, intervalString: '1s' }); + }); + }); +}); diff --git a/x-pack/plugins/apm/server/lib/helpers/get_bucket_size_for_aggregated_transactions/index.ts b/x-pack/plugins/apm/server/lib/helpers/get_bucket_size_for_aggregated_transactions/index.ts new file mode 100644 index 0000000000000..b475e518ce982 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/helpers/get_bucket_size_for_aggregated_transactions/index.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 { getBucketSize } from '../get_bucket_size'; + +export function getBucketSizeForAggregatedTransactions({ + start, + end, + numBuckets = 100, + searchAggregatedTransactions, +}: { + start: number; + end: number; + numBuckets?: number; + searchAggregatedTransactions?: boolean; +}) { + const minBucketSize = searchAggregatedTransactions ? 60 : undefined; + return getBucketSize({ start, end, numBuckets, minBucketSize }); +} diff --git a/x-pack/plugins/apm/server/lib/services/get_service_instances/get_service_instances_transaction_statistics.ts b/x-pack/plugins/apm/server/lib/services/get_service_instances/get_service_instances_transaction_statistics.ts index 7d9dca9b2a706..6110ad3459911 100644 --- a/x-pack/plugins/apm/server/lib/services/get_service_instances/get_service_instances_transaction_statistics.ts +++ b/x-pack/plugins/apm/server/lib/services/get_service_instances/get_service_instances_transaction_statistics.ts @@ -20,7 +20,7 @@ import { getTransactionDurationFieldForAggregatedTransactions, } from '../../helpers/aggregated_transactions'; import { calculateThroughput } from '../../helpers/calculate_throughput'; -import { getBucketSize } from '../../helpers/get_bucket_size'; +import { getBucketSizeForAggregatedTransactions } from '../../helpers/get_bucket_size_for_aggregated_transactions'; import { getLatencyAggregation, getLatencyValue, @@ -78,11 +78,14 @@ export async function getServiceInstancesTransactionStatistics< }): Promise<Array<ServiceInstanceTransactionStatistics<T>>> { const { apmEventClient } = setup; - const { intervalString, bucketSize } = getBucketSize({ - start, - end, - numBuckets, - }); + const { intervalString, bucketSize } = getBucketSizeForAggregatedTransactions( + { + start, + end, + numBuckets, + searchAggregatedTransactions, + } + ); const field = getTransactionDurationFieldForAggregatedTransactions( searchAggregatedTransactions diff --git a/x-pack/plugins/apm/server/lib/services/get_service_transaction_group_detailed_statistics.ts b/x-pack/plugins/apm/server/lib/services/get_service_transaction_group_detailed_statistics.ts index 36d372e322cbc..ea33c942cfc3b 100644 --- a/x-pack/plugins/apm/server/lib/services/get_service_transaction_group_detailed_statistics.ts +++ b/x-pack/plugins/apm/server/lib/services/get_service_transaction_group_detailed_statistics.ts @@ -6,7 +6,6 @@ */ import { keyBy } from 'lodash'; -import { offsetPreviousPeriodCoordinates } from '../../../common/utils/offset_previous_period_coordinate'; import { EVENT_OUTCOME, SERVICE_NAME, @@ -15,10 +14,11 @@ import { } from '../../../common/elasticsearch_fieldnames'; import { EventOutcome } from '../../../common/event_outcome'; import { LatencyAggregationType } from '../../../common/latency_aggregation_types'; +import { offsetPreviousPeriodCoordinates } from '../../../common/utils/offset_previous_period_coordinate'; import { environmentQuery, - rangeQuery, kqlQuery, + rangeQuery, } from '../../../server/utils/queries'; import { Coordinate } from '../../../typings/timeseries'; import { @@ -26,7 +26,7 @@ import { getProcessorEventForAggregatedTransactions, getTransactionDurationFieldForAggregatedTransactions, } from '../helpers/aggregated_transactions'; -import { getBucketSize } from '../helpers/get_bucket_size'; +import { getBucketSizeForAggregatedTransactions } from '../helpers/get_bucket_size_for_aggregated_transactions'; import { getLatencyAggregation, getLatencyValue, @@ -68,7 +68,12 @@ export async function getServiceTransactionGroupDetailedStatistics({ }> > { const { apmEventClient } = setup; - const { intervalString } = getBucketSize({ start, end, numBuckets }); + const { intervalString } = getBucketSizeForAggregatedTransactions({ + start, + end, + numBuckets, + searchAggregatedTransactions, + }); const field = getTransactionDurationFieldForAggregatedTransactions( searchAggregatedTransactions diff --git a/x-pack/plugins/apm/server/lib/services/get_services/get_service_transaction_stats.ts b/x-pack/plugins/apm/server/lib/services/get_services/get_service_transaction_stats.ts index 019ab8770887a..7f48c591521e7 100644 --- a/x-pack/plugins/apm/server/lib/services/get_services/get_service_transaction_stats.ts +++ b/x-pack/plugins/apm/server/lib/services/get_services/get_service_transaction_stats.ts @@ -17,8 +17,8 @@ import { } from '../../../../common/transaction_types'; import { environmentQuery, - rangeQuery, kqlQuery, + rangeQuery, } from '../../../../server/utils/queries'; import { AgentName } from '../../../../typings/es_schemas/ui/fields/agent'; import { @@ -26,8 +26,8 @@ import { getProcessorEventForAggregatedTransactions, getTransactionDurationFieldForAggregatedTransactions, } from '../../helpers/aggregated_transactions'; -import { getBucketSize } from '../../helpers/get_bucket_size'; import { calculateThroughput } from '../../helpers/calculate_throughput'; +import { getBucketSizeForAggregatedTransactions } from '../../helpers/get_bucket_size_for_aggregated_transactions'; import { calculateTransactionErrorPercentage, getOutcomeAggregation, @@ -117,10 +117,11 @@ export async function getServiceTransactionStats({ timeseries: { date_histogram: { field: '@timestamp', - fixed_interval: getBucketSize({ + fixed_interval: getBucketSizeForAggregatedTransactions({ start, end, numBuckets: 20, + searchAggregatedTransactions, }).intervalString, min_doc_count: 0, extended_bounds: { min: start, max: end }, diff --git a/x-pack/plugins/apm/server/lib/services/get_throughput.ts b/x-pack/plugins/apm/server/lib/services/get_throughput.ts index 0490c31e7c63d..7eacf47f15b7a 100644 --- a/x-pack/plugins/apm/server/lib/services/get_throughput.ts +++ b/x-pack/plugins/apm/server/lib/services/get_throughput.ts @@ -12,14 +12,14 @@ import { } from '../../../common/elasticsearch_fieldnames'; import { environmentQuery, - rangeQuery, kqlQuery, + rangeQuery, } from '../../../server/utils/queries'; import { getDocumentTypeFilterForAggregatedTransactions, getProcessorEventForAggregatedTransactions, } from '../helpers/aggregated_transactions'; -import { getBucketSize } from '../helpers/get_bucket_size'; +import { getBucketSizeForAggregatedTransactions } from '../helpers/get_bucket_size_for_aggregated_transactions'; import { Setup } from '../helpers/setup_request'; interface Options { @@ -44,7 +44,11 @@ function fetcher({ end, }: Options) { const { apmEventClient } = setup; - const { intervalString } = getBucketSize({ start, end }); + const { intervalString } = getBucketSizeForAggregatedTransactions({ + start, + end, + searchAggregatedTransactions, + }); const filter: ESFilter[] = [ { term: { [SERVICE_NAME]: serviceName } }, { term: { [TRANSACTION_TYPE]: transactionType } }, diff --git a/x-pack/plugins/apm/server/lib/transaction_groups/get_error_rate.ts b/x-pack/plugins/apm/server/lib/transaction_groups/get_error_rate.ts index 6499e80be9302..cc3a13ef5c648 100644 --- a/x-pack/plugins/apm/server/lib/transaction_groups/get_error_rate.ts +++ b/x-pack/plugins/apm/server/lib/transaction_groups/get_error_rate.ts @@ -5,9 +5,6 @@ * 2.0. */ -import { offsetPreviousPeriodCoordinates } from '../../../common/utils/offset_previous_period_coordinate'; -import { Coordinate } from '../../../typings/timeseries'; - import { EVENT_OUTCOME, SERVICE_NAME, @@ -15,16 +12,18 @@ import { TRANSACTION_TYPE, } from '../../../common/elasticsearch_fieldnames'; import { EventOutcome } from '../../../common/event_outcome'; +import { offsetPreviousPeriodCoordinates } from '../../../common/utils/offset_previous_period_coordinate'; import { environmentQuery, - rangeQuery, kqlQuery, + rangeQuery, } from '../../../server/utils/queries'; +import { Coordinate } from '../../../typings/timeseries'; import { getDocumentTypeFilterForAggregatedTransactions, getProcessorEventForAggregatedTransactions, } from '../helpers/aggregated_transactions'; -import { getBucketSize } from '../helpers/get_bucket_size'; +import { getBucketSizeForAggregatedTransactions } from '../helpers/get_bucket_size_for_aggregated_transactions'; import { Setup, SetupTimeRange } from '../helpers/setup_request'; import { calculateTransactionErrorPercentage, @@ -101,7 +100,11 @@ export async function getErrorRate({ timeseries: { date_histogram: { field: '@timestamp', - fixed_interval: getBucketSize({ start, end }).intervalString, + fixed_interval: getBucketSizeForAggregatedTransactions({ + start, + end, + searchAggregatedTransactions, + }).intervalString, min_doc_count: 0, extended_bounds: { min: start, max: end }, }, diff --git a/x-pack/plugins/apm/server/lib/transactions/get_latency_charts/index.ts b/x-pack/plugins/apm/server/lib/transactions/get_latency_charts/index.ts index 1f8170921aac3..e3f59ca2e4328 100644 --- a/x-pack/plugins/apm/server/lib/transactions/get_latency_charts/index.ts +++ b/x-pack/plugins/apm/server/lib/transactions/get_latency_charts/index.ts @@ -5,7 +5,6 @@ * 2.0. */ -import { offsetPreviousPeriodCoordinates } from '../../../../common/utils/offset_previous_period_coordinate'; import { ESFilter } from '../../../../../../../src/core/types/elasticsearch'; import { PromiseReturnType } from '../../../../../observability/typings/common'; import { @@ -14,18 +13,19 @@ import { TRANSACTION_TYPE, } from '../../../../common/elasticsearch_fieldnames'; import { LatencyAggregationType } from '../../../../common/latency_aggregation_types'; +import { offsetPreviousPeriodCoordinates } from '../../../../common/utils/offset_previous_period_coordinate'; import { environmentQuery, - rangeQuery, kqlQuery, + rangeQuery, } from '../../../../server/utils/queries'; import { getDocumentTypeFilterForAggregatedTransactions, getProcessorEventForAggregatedTransactions, getTransactionDurationFieldForAggregatedTransactions, } from '../../../lib/helpers/aggregated_transactions'; -import { getBucketSize } from '../../../lib/helpers/get_bucket_size'; import { Setup, SetupTimeRange } from '../../../lib/helpers/setup_request'; +import { getBucketSizeForAggregatedTransactions } from '../../helpers/get_bucket_size_for_aggregated_transactions'; import { getLatencyAggregation, getLatencyValue, @@ -58,7 +58,11 @@ function searchLatency({ end: number; }) { const { apmEventClient } = setup; - const { intervalString } = getBucketSize({ start, end }); + const { intervalString } = getBucketSizeForAggregatedTransactions({ + start, + end, + searchAggregatedTransactions, + }); const filter: ESFilter[] = [ { term: { [SERVICE_NAME]: serviceName } }, diff --git a/x-pack/plugins/apm/server/lib/transactions/get_throughput_charts/index.ts b/x-pack/plugins/apm/server/lib/transactions/get_throughput_charts/index.ts index ed85e700c3473..ff3534159d19b 100644 --- a/x-pack/plugins/apm/server/lib/transactions/get_throughput_charts/index.ts +++ b/x-pack/plugins/apm/server/lib/transactions/get_throughput_charts/index.ts @@ -15,15 +15,15 @@ import { } from '../../../../common/elasticsearch_fieldnames'; import { environmentQuery, - rangeQuery, kqlQuery, + rangeQuery, } from '../../../../server/utils/queries'; import { getDocumentTypeFilterForAggregatedTransactions, getProcessorEventForAggregatedTransactions, } from '../../../lib/helpers/aggregated_transactions'; -import { getBucketSize } from '../../../lib/helpers/get_bucket_size'; import { Setup, SetupTimeRange } from '../../../lib/helpers/setup_request'; +import { getBucketSizeForAggregatedTransactions } from '../../helpers/get_bucket_size_for_aggregated_transactions'; import { getThroughputBuckets } from './transform'; export type ThroughputChartsResponse = PromiseReturnType< @@ -115,7 +115,12 @@ export async function getThroughputCharts({ setup: Setup & SetupTimeRange; searchAggregatedTransactions: boolean; }) { - const { bucketSize, intervalString } = getBucketSize(setup); + const { bucketSize, intervalString } = getBucketSizeForAggregatedTransactions( + { + ...setup, + searchAggregatedTransactions, + } + ); const response = await searchThroughput({ environment, From a842a731e80d9142c1bf9ed1d94eb55e233fe433 Mon Sep 17 00:00:00 2001 From: "Joey F. Poon" <joey.poon@elastic.co> Date: Thu, 1 Jul 2021 08:58:57 -0500 Subject: [PATCH 39/51] [Security Solution] fix failed packages call infinite retry (#103998) --- .../pages/endpoint_hosts/store/middleware.ts | 5 ++--- .../management/pages/endpoint_hosts/store/selectors.ts | 10 +++------- 2 files changed, 5 insertions(+), 10 deletions(-) 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 1a431ea88ad6a..2f8ced9d2a771 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 @@ -36,8 +36,7 @@ import { getLastLoadedActivityLogData, detailsData, getEndpointDetailsFlyoutView, - getIsEndpointPackageInfoPending, - getIsEndpointPackageInfoSuccessful, + getIsEndpointPackageInfoUninitialized, } from './selectors'; import { AgentIdsPendingActions, EndpointState, PolicyIds } from '../types'; import { @@ -598,7 +597,7 @@ async function getEndpointPackageInfo( dispatch: Dispatch<EndpointPackageInfoStateChanged>, coreStart: CoreStart ) { - if (getIsEndpointPackageInfoPending(state) || getIsEndpointPackageInfoSuccessful(state)) return; + if (!getIsEndpointPackageInfoUninitialized(state)) return; dispatch({ type: 'endpointPackageInfoStateChanged', 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 c09e4032d6222..5771fbac957d8 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 @@ -33,6 +33,7 @@ import { isFailedResourceState, isLoadedResourceState, isLoadingResourceState, + isUninitialisedResourceState, } from '../../../state'; import { ServerApiError } from '../../../../common/types'; @@ -69,15 +70,10 @@ export const policyItemsLoading = (state: Immutable<EndpointState>) => state.pol export const selectedPolicyId = (state: Immutable<EndpointState>) => state.selectedPolicyId; export const endpointPackageInfo = (state: Immutable<EndpointState>) => state.endpointPackageInfo; -export const getIsEndpointPackageInfoPending: ( +export const getIsEndpointPackageInfoUninitialized: ( state: Immutable<EndpointState> ) => boolean = createSelector(endpointPackageInfo, (packageInfo) => - isLoadingResourceState(packageInfo) -); -export const getIsEndpointPackageInfoSuccessful: ( - state: Immutable<EndpointState> -) => boolean = createSelector(endpointPackageInfo, (packageInfo) => - isLoadedResourceState(packageInfo) + isUninitialisedResourceState(packageInfo) ); export const isAutoRefreshEnabled = (state: Immutable<EndpointState>) => state.isAutoRefreshEnabled; From ff3b5231c6f0e36eacb2c47049435954e61e3f8b Mon Sep 17 00:00:00 2001 From: ymao1 <ying.mao@elastic.co> Date: Thu, 1 Jul 2021 10:23:44 -0400 Subject: [PATCH 40/51] Aligning logger contexts (#103741) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- x-pack/plugins/actions/server/plugin.ts | 2 +- x-pack/plugins/alerting/server/plugin.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/actions/server/plugin.ts b/x-pack/plugins/actions/server/plugin.ts index 65b28015f7f93..2c5287525c597 100644 --- a/x-pack/plugins/actions/server/plugin.ts +++ b/x-pack/plugins/actions/server/plugin.ts @@ -152,7 +152,7 @@ export class ActionsPlugin implements Plugin<PluginSetupContract, PluginStartCon private readonly kibanaIndexConfig: { kibana: { index: string } }; constructor(initContext: PluginInitializerContext) { - this.logger = initContext.logger.get('actions'); + this.logger = initContext.logger.get(); this.actionsConfig = getValidatedConfig( this.logger, resolveCustomHosts(this.logger, initContext.config.get<ActionsConfig>()) diff --git a/x-pack/plugins/alerting/server/plugin.ts b/x-pack/plugins/alerting/server/plugin.ts index df63625bf242d..b906983017ff6 100644 --- a/x-pack/plugins/alerting/server/plugin.ts +++ b/x-pack/plugins/alerting/server/plugin.ts @@ -153,7 +153,7 @@ export class AlertingPlugin { constructor(initializerContext: PluginInitializerContext) { this.config = initializerContext.config.create<AlertsConfig>().pipe(first()).toPromise(); - this.logger = initializerContext.logger.get('plugins', 'alerting'); + this.logger = initializerContext.logger.get(); this.taskRunnerFactory = new TaskRunnerFactory(); this.alertsClientFactory = new AlertsClientFactory(); this.alertingAuthorizationClientFactory = new AlertingAuthorizationClientFactory(); From dd3a80669043ada55784aa8852e9a8e103d118cc Mon Sep 17 00:00:00 2001 From: Melissa Alvarez <melissa.alvarez@elastic.co> Date: Thu, 1 Jul 2021 10:24:24 -0400 Subject: [PATCH 41/51] switch to using internal user (#103931) --- .../components/job_details/job_messages_pane.tsx | 2 +- .../models/job_audit_messages/job_audit_messages.js | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/job_messages_pane.tsx b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/job_messages_pane.tsx index a78a832fdb6e9..9a4d6036428f8 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/job_messages_pane.tsx +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/job_messages_pane.tsx @@ -102,7 +102,7 @@ export const JobMessagesPane: FC<JobMessagesPaneProps> = React.memo( return ( <> - <EuiSpacer /> + {canCreateJob && showClearButton ? <EuiSpacer /> : null} <EuiFlexGroup direction="column"> {canCreateJob && showClearButton ? ( <EuiFlexItem grow={false}> diff --git a/x-pack/plugins/ml/server/models/job_audit_messages/job_audit_messages.js b/x-pack/plugins/ml/server/models/job_audit_messages/job_audit_messages.js index 318c103b39636..137df3a6f3151 100644 --- a/x-pack/plugins/ml/server/models/job_audit_messages/job_audit_messages.js +++ b/x-pack/plugins/ml/server/models/job_audit_messages/job_audit_messages.js @@ -39,7 +39,7 @@ const anomalyDetectorTypeFilter = { }, }; -export function jobAuditMessagesProvider({ asInternalUser, asCurrentUser }, mlClient) { +export function jobAuditMessagesProvider({ asInternalUser }, mlClient) { // search for audit messages, // jobId is optional. without it, all jobs will be listed. // from is optional and should be a string formatted in ES time units. e.g. 12h, 1d, 7d @@ -310,10 +310,10 @@ export function jobAuditMessagesProvider({ asInternalUser, asCurrentUser }, mlCl }; await Promise.all([ - asCurrentUser.updateByQuery({ + asInternalUser.updateByQuery({ index: ML_NOTIFICATION_INDEX_02, ignore_unavailable: true, - refresh: true, + refresh: false, conflicts: 'proceed', body: { query, @@ -323,7 +323,7 @@ export function jobAuditMessagesProvider({ asInternalUser, asCurrentUser }, mlCl }, }, }), - asCurrentUser.index({ + asInternalUser.index({ index: ML_NOTIFICATION_INDEX_02, body: newClearedMessage, refresh: 'wait_for', From 7cc112d245c8bee34814a44c378a2cfcec34c285 Mon Sep 17 00:00:00 2001 From: ymao1 <ying.mao@elastic.co> Date: Thu, 1 Jul 2021 10:39:21 -0400 Subject: [PATCH 42/51] [Task Manager] Fixing typo in field name (#103948) * Fixing typo * Fixing typo Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../task-manager-troubleshooting.asciidoc | 6 ++-- .../server/lib/log_health_metrics.test.ts | 2 +- .../monitoring/capacity_estimation.test.ts | 28 +++++++++---------- .../server/monitoring/capacity_estimation.ts | 8 +++--- .../monitoring/workload_statistics.test.ts | 2 +- .../server/monitoring/workload_statistics.ts | 6 ++-- .../task_manager/server/routes/health.test.ts | 2 +- .../test_suites/task_manager/health_route.ts | 8 +++--- 8 files changed, 31 insertions(+), 31 deletions(-) diff --git a/docs/user/production-considerations/task-manager-troubleshooting.asciidoc b/docs/user/production-considerations/task-manager-troubleshooting.asciidoc index 363562d4cd193..98201087b9aae 100644 --- a/docs/user/production-considerations/task-manager-troubleshooting.asciidoc +++ b/docs/user/production-considerations/task-manager-troubleshooting.asciidoc @@ -248,7 +248,7 @@ The API returns the following: "overdue": 10, "overdue_non_recurring": 10, "estimated_schedule_density": [0, 1, 0, 0, 0, 1, 0, 1, 0, 1, 0, 0, 0, 1, 0, 0, 1, 1, 1, 0, 0, 3, 0, 0, 0, 1, 0, 1, 0, 1, 0, 0, 0, 1, 0, 0, 1, 1, 1, 0], - "capacity_requirments": { + "capacity_requirements": { "per_minute": 6, "per_hour": 28, "per_day": 2 @@ -737,7 +737,7 @@ Evaluating the preceding health stats in the previous example, you see the follo 0, 3, 0, 0, 0, 1, 0, 1, 0, 1, 0, 0, 0, 1, 0, 0, 1, 1, 1, 0 ], - "capacity_requirments": { # <10> + "capacity_requirements": { # <10> "per_minute": 14, "per_hour": 240, "per_day": 0 @@ -819,7 +819,7 @@ Suppose the output of `stats.workload.value` looked something like this: 0, 31, 0, 12, 16, 31, 0, 10, 0, 10, 3, 22, 0, 10, 0, 2, 10, 10, 1, 0 ], - "capacity_requirments": { + "capacity_requirements": { "per_minute": 329, # <4> "per_hour": 4272, # <5> "per_day": 61 # <6> diff --git a/x-pack/plugins/task_manager/server/lib/log_health_metrics.test.ts b/x-pack/plugins/task_manager/server/lib/log_health_metrics.test.ts index f5163f4ca5ed8..aca73a4b77434 100644 --- a/x-pack/plugins/task_manager/server/lib/log_health_metrics.test.ts +++ b/x-pack/plugins/task_manager/server/lib/log_health_metrics.test.ts @@ -360,7 +360,7 @@ function getMockMonitoredHealth(overrides = {}): MonitoredHealth { non_recurring: 20, owner_ids: 2, estimated_schedule_density: [], - capacity_requirments: { + capacity_requirements: { per_minute: 150, per_hour: 360, per_day: 820, diff --git a/x-pack/plugins/task_manager/server/monitoring/capacity_estimation.test.ts b/x-pack/plugins/task_manager/server/monitoring/capacity_estimation.test.ts index c68e307dbec03..bd8ecf0cc6d93 100644 --- a/x-pack/plugins/task_manager/server/monitoring/capacity_estimation.test.ts +++ b/x-pack/plugins/task_manager/server/monitoring/capacity_estimation.test.ts @@ -17,7 +17,7 @@ describe('estimateCapacity', () => { { owner_ids: 1, overdue_non_recurring: 0, - capacity_requirments: { + capacity_requirements: { per_minute: 60, per_hour: 0, per_day: 0, @@ -72,7 +72,7 @@ describe('estimateCapacity', () => { { owner_ids: 1, overdue_non_recurring: 0, - capacity_requirments: { + capacity_requirements: { per_minute: 60, per_hour: 0, per_day: 0, @@ -129,7 +129,7 @@ describe('estimateCapacity', () => { { owner_ids: 1, overdue_non_recurring: 0, - capacity_requirments: { + capacity_requirements: { per_minute: 60, per_hour: 0, per_day: 0, @@ -165,7 +165,7 @@ describe('estimateCapacity', () => { { owner_ids: 1, overdue_non_recurring: 0, - capacity_requirments: { + capacity_requirements: { per_minute: 0, per_hour: 12000, per_day: 200, @@ -221,7 +221,7 @@ describe('estimateCapacity', () => { // 0 active tasks at this moment in time, so no owners identifiable owner_ids: 0, overdue_non_recurring: 0, - capacity_requirments: { + capacity_requirements: { per_minute: 60, per_hour: 0, per_day: 0, @@ -276,7 +276,7 @@ describe('estimateCapacity', () => { { owner_ids: 3, overdue_non_recurring: 0, - capacity_requirments: { + capacity_requirements: { per_minute: 150, per_hour: 60, per_day: 0, @@ -337,7 +337,7 @@ describe('estimateCapacity', () => { { owner_ids: provisionedKibanaInstances, overdue_non_recurring: 0, - capacity_requirments: { + capacity_requirements: { per_minute: 150, per_hour: 60, per_day: 0, @@ -417,7 +417,7 @@ describe('estimateCapacity', () => { { owner_ids: provisionedKibanaInstances, overdue_non_recurring: 0, - capacity_requirments: { + capacity_requirements: { per_minute: recurringTasksPerMinute, per_hour: 0, per_day: 0, @@ -498,7 +498,7 @@ describe('estimateCapacity', () => { { owner_ids: 1, overdue_non_recurring: 0, - capacity_requirments: { + capacity_requirements: { per_minute: 170, per_hour: 0, per_day: 0, @@ -562,7 +562,7 @@ describe('estimateCapacity', () => { { owner_ids: 1, overdue_non_recurring: 0, - capacity_requirments: { + capacity_requirements: { per_minute: 175, per_hour: 0, per_day: 0, @@ -623,7 +623,7 @@ describe('estimateCapacity', () => { { owner_ids: 1, overdue_non_recurring: 0, - capacity_requirments: { + capacity_requirements: { per_minute: 210, per_hour: 0, per_day: 0, @@ -684,7 +684,7 @@ describe('estimateCapacity', () => { { owner_ids: 1, overdue_non_recurring: 0, - capacity_requirments: { + capacity_requirements: { per_minute: 28, per_hour: 27, per_day: 2, @@ -759,7 +759,7 @@ describe('estimateCapacity', () => { { owner_ids: 1, overdue_non_recurring: 0, - capacity_requirments: { + capacity_requirements: { per_minute: 210, per_hour: 0, per_day: 0, @@ -871,7 +871,7 @@ function mockStats( estimated_schedule_density: [], non_recurring: 20, owner_ids: 2, - capacity_requirments: { + capacity_requirements: { per_minute: 150, per_hour: 360, per_day: 820, diff --git a/x-pack/plugins/task_manager/server/monitoring/capacity_estimation.ts b/x-pack/plugins/task_manager/server/monitoring/capacity_estimation.ts index 073112f94e049..90f564152c8c7 100644 --- a/x-pack/plugins/task_manager/server/monitoring/capacity_estimation.ts +++ b/x-pack/plugins/task_manager/server/monitoring/capacity_estimation.ts @@ -58,7 +58,7 @@ export function estimateCapacity( recurring: percentageOfExecutionsUsedByRecurringTasks, non_recurring: percentageOfExecutionsUsedByNonRecurringTasks, } = capacityStats.runtime.value.execution.persistence; - const { overdue, capacity_requirments: capacityRequirments } = workload; + const { overdue, capacity_requirements: capacityRequirements } = workload; const { poll_interval: pollInterval, max_workers: maxWorkers, @@ -130,9 +130,9 @@ export function estimateCapacity( * On average, how many tasks per minute does this cluster need to execute? */ const averageRecurringRequiredPerMinute = - capacityRequirments.per_minute + - capacityRequirments.per_hour / 60 + - capacityRequirments.per_day / 24 / 60; + capacityRequirements.per_minute + + capacityRequirements.per_hour / 60 + + capacityRequirements.per_day / 24 / 60; /** * how many Kibana are needed solely for the recurring tasks diff --git a/x-pack/plugins/task_manager/server/monitoring/workload_statistics.test.ts b/x-pack/plugins/task_manager/server/monitoring/workload_statistics.test.ts index 3fe003ebc6591..9125bca8f5b05 100644 --- a/x-pack/plugins/task_manager/server/monitoring/workload_statistics.test.ts +++ b/x-pack/plugins/task_manager/server/monitoring/workload_statistics.test.ts @@ -624,7 +624,7 @@ describe('Workload Statistics Aggregator', () => { expect(result.key).toEqual('workload'); expect(result.value).toMatchObject({ - capacity_requirments: { + capacity_requirements: { // these are buckets of required capacity, rather than aggregated requirmenets. per_minute: 150, per_hour: 360, diff --git a/x-pack/plugins/task_manager/server/monitoring/workload_statistics.ts b/x-pack/plugins/task_manager/server/monitoring/workload_statistics.ts index 64c1c66140196..5c4e7d6cbe2cf 100644 --- a/x-pack/plugins/task_manager/server/monitoring/workload_statistics.ts +++ b/x-pack/plugins/task_manager/server/monitoring/workload_statistics.ts @@ -36,7 +36,7 @@ interface RawWorkloadStat extends JsonObject { overdue: number; overdue_non_recurring: number; estimated_schedule_density: number[]; - capacity_requirments: CapacityRequirments; + capacity_requirements: CapacityRequirements; } export interface WorkloadStat extends RawWorkloadStat { @@ -45,7 +45,7 @@ export interface WorkloadStat extends RawWorkloadStat { export interface SummarizedWorkloadStat extends RawWorkloadStat { owner_ids: number; } -export interface CapacityRequirments extends JsonObject { +export interface CapacityRequirements extends JsonObject { per_minute: number; per_hour: number; per_day: number; @@ -277,7 +277,7 @@ export function createWorkloadAggregator( pollInterval, scheduleDensity ), - capacity_requirments: { + capacity_requirements: { per_minute: cadence.perMinute, per_hour: cadence.perHour, per_day: cadence.perDay, diff --git a/x-pack/plugins/task_manager/server/routes/health.test.ts b/x-pack/plugins/task_manager/server/routes/health.test.ts index 735029e90c2d3..ece91ed571f88 100644 --- a/x-pack/plugins/task_manager/server/routes/health.test.ts +++ b/x-pack/plugins/task_manager/server/routes/health.test.ts @@ -464,7 +464,7 @@ function mockHealthStats(overrides = {}) { non_recurring: 20, owner_ids: [0, 0, 0, 1, 2, 0, 0, 2, 2, 2, 1, 2, 1, 1], estimated_schedule_density: [], - capacity_requirments: { + capacity_requirements: { per_minute: 150, per_hour: 360, per_day: 820, diff --git a/x-pack/test/plugin_api_integration/test_suites/task_manager/health_route.ts b/x-pack/test/plugin_api_integration/test_suites/task_manager/health_route.ts index 2626ef2421f0b..fd3a5abc0e4bf 100644 --- a/x-pack/test/plugin_api_integration/test_suites/task_manager/health_route.ts +++ b/x-pack/test/plugin_api_integration/test_suites/task_manager/health_route.ts @@ -30,7 +30,7 @@ interface MonitoringStats { non_recurring: number; owner_ids: number; estimated_schedule_density: number[]; - capacity_requirments: { + capacity_requirements: { per_minute: number; per_hour: number; per_day: number; @@ -218,9 +218,9 @@ export default function ({ getService }: FtrProviderContext) { expect(typeof workload.non_recurring).to.eql('number'); expect(typeof workload.owner_ids).to.eql('number'); - expect(typeof workload.capacity_requirments.per_minute).to.eql('number'); - expect(typeof workload.capacity_requirments.per_hour).to.eql('number'); - expect(typeof workload.capacity_requirments.per_day).to.eql('number'); + expect(typeof workload.capacity_requirements.per_minute).to.eql('number'); + expect(typeof workload.capacity_requirements.per_hour).to.eql('number'); + expect(typeof workload.capacity_requirements.per_day).to.eql('number'); expect(Array.isArray(workload.estimated_schedule_density)).to.eql(true); From 027446634e896165295cf2999437380a5fd4a4ff Mon Sep 17 00:00:00 2001 From: Dima Arnautov <dmitrii.arnautov@elastic.co> Date: Thu, 1 Jul 2021 17:27:36 +0200 Subject: [PATCH 43/51] [ML] Fix missing script aggs on the transform preview table (#103913) * [ML] get field type from sampled doc for script fields * [ML] refactor, unit tests --- .../public/app/hooks/use_pivot_data.test.ts | 78 +++++++++++++++++++ .../public/app/hooks/use_pivot_data.ts | 24 +++++- 2 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 x-pack/plugins/transform/public/app/hooks/use_pivot_data.test.ts diff --git a/x-pack/plugins/transform/public/app/hooks/use_pivot_data.test.ts b/x-pack/plugins/transform/public/app/hooks/use_pivot_data.test.ts new file mode 100644 index 0000000000000..aa8f5421184e5 --- /dev/null +++ b/x-pack/plugins/transform/public/app/hooks/use_pivot_data.test.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 { getCombinedProperties } from './use_pivot_data'; +import { ES_FIELD_TYPES } from '../../../../../../src/plugins/data/common'; + +describe('getCombinedProperties', () => { + test('extracts missing mappings from docs', () => { + const mappingProps = { + testProp: { + type: ES_FIELD_TYPES.STRING, + }, + }; + + const docs = [ + { + testProp: 'test_value1', + scriptProp: 1, + }, + { + testProp: 'test_value2', + scriptProp: 2, + }, + { + testProp: 'test_value3', + scriptProp: 3, + }, + ]; + + expect(getCombinedProperties(mappingProps, docs)).toEqual({ + testProp: { + type: 'string', + }, + scriptProp: { + type: 'number', + }, + }); + }); + + test('does not override defined mappings', () => { + const mappingProps = { + testProp: { + type: ES_FIELD_TYPES.STRING, + }, + scriptProp: { + type: ES_FIELD_TYPES.LONG, + }, + }; + + const docs = [ + { + testProp: 'test_value1', + scriptProp: 1, + }, + { + testProp: 'test_value2', + scriptProp: 2, + }, + { + testProp: 'test_value3', + scriptProp: 3, + }, + ]; + + expect(getCombinedProperties(mappingProps, docs)).toEqual({ + testProp: { + type: 'string', + }, + scriptProp: { + type: 'long', + }, + }); + }); +}); diff --git a/x-pack/plugins/transform/public/app/hooks/use_pivot_data.ts b/x-pack/plugins/transform/public/app/hooks/use_pivot_data.ts index 9a49ed9480359..329e2d5f87131 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_pivot_data.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_pivot_data.ts @@ -13,6 +13,7 @@ import { EuiDataGridColumn } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { getFlattenedObject } from '@kbn/std'; +import { sample, difference } from 'lodash'; import { ES_FIELD_TYPES } from '../../../../../../src/plugins/data/common'; import type { PreviewMappingsProperties } from '../../../common/api_schemas/transforms'; @@ -71,6 +72,25 @@ function sortColumnsForLatest(sortField: string) { }; } +/** + * Extracts missing mappings from docs. + */ +export function getCombinedProperties( + populatedProperties: PreviewMappingsProperties, + docs: Array<Record<string, unknown>> +): PreviewMappingsProperties { + // Take a sample from docs and resolve missing mappings + const sampleDoc = sample(docs) ?? {}; + const missingMappings = difference(Object.keys(sampleDoc), Object.keys(populatedProperties)); + return { + ...populatedProperties, + ...missingMappings.reduce((acc, curr) => { + acc[curr] = { type: typeof sampleDoc[curr] as ES_FIELD_TYPES }; + return acc; + }, {} as PreviewMappingsProperties), + }; +} + export const usePivotData = ( indexPatternTitle: SearchItems['indexPattern']['title'], query: PivotQuery, @@ -170,7 +190,7 @@ export const usePivotData = ( const populatedFields = [...new Set(docs.map(Object.keys).flat(1))]; // 3. Filter mapping properties by populated fields - const populatedProperties: PreviewMappingsProperties = Object.entries( + let populatedProperties: PreviewMappingsProperties = Object.entries( resp.generated_dest_index.mappings.properties ) .filter(([key]) => populatedFields.includes(key)) @@ -182,6 +202,8 @@ export const usePivotData = ( {} ); + populatedProperties = getCombinedProperties(populatedProperties, docs); + setTableItems(docs); setRowCount(docs.length); setRowCountRelation(ES_CLIENT_TOTAL_HITS_RELATION.EQ); From 0fe301d083137399069c96fa1a3af53c13bd296e Mon Sep 17 00:00:00 2001 From: Kyle Pollich <kyle.pollich@elastic.co> Date: Thu, 1 Jul 2021 11:30:13 -0400 Subject: [PATCH 44/51] Search integrations for all substrings + don't search on description (#104099) --- .../applications/integrations/hooks/use_local_search.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/fleet/public/applications/integrations/hooks/use_local_search.tsx b/x-pack/plugins/fleet/public/applications/integrations/hooks/use_local_search.tsx index 43db5657b0615..fc2966697418a 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/hooks/use_local_search.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/hooks/use_local_search.tsx @@ -5,19 +5,20 @@ * 2.0. */ -import { Search as LocalSearch } from 'js-search'; +import { Search as LocalSearch, AllSubstringsIndexStrategy } from 'js-search'; import { useEffect, useRef } from 'react'; import type { PackageList } from '../../../types'; export const searchIdField = 'id'; -export const fieldsToSearch = ['description', 'name', 'title']; +export const fieldsToSearch = ['name', 'title']; export function useLocalSearch(packageList: PackageList) { const localSearchRef = useRef<LocalSearch | null>(null); useEffect(() => { const localSearch = new LocalSearch(searchIdField); + localSearch.indexStrategy = new AllSubstringsIndexStrategy(); fieldsToSearch.forEach((field) => localSearch.addIndex(field)); localSearch.addDocuments(packageList); localSearchRef.current = localSearch; From b8747bde686e16bfbbb4d2df49df589fb7b68b79 Mon Sep 17 00:00:00 2001 From: Alison Goryachev <alison.goryachev@elastic.co> Date: Thu, 1 Jul 2021 11:47:06 -0400 Subject: [PATCH 45/51] [Ingest pipelines] Support output_format in date processor (#103729) --- .../__jest__/processors/date.test.tsx | 10 ++-- .../__jest__/processors/processor.helpers.tsx | 1 + .../processor_form/processors/date.tsx | 48 +++++++++++++++++-- .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - 5 files changed, 49 insertions(+), 12 deletions(-) diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/date.test.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/date.test.tsx index 555ed7a09fe4f..390f8e0191ce9 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/date.test.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/date.test.tsx @@ -95,22 +95,19 @@ describe('Processor: Date', () => { component, } = testBed; + // Set required parameters form.setInputValue('fieldNameField.input', 'field_1'); - // Set optional parameteres await act(async () => { find('formatsValueField.input').simulate('change', [{ label: 'ISO8601' }]); }); component.update(); - // Set target field + // Set optional parameters form.setInputValue('targetField.input', 'target_field'); - - // Set locale field form.setInputValue('localeField.input', 'SPANISH'); - - // Set timezone field. form.setInputValue('timezoneField.input', 'EST'); + form.setInputValue('outputFormatField.input', 'yyyy-MM-dd'); // Save the field with new changes await saveNewProcessor(); @@ -122,6 +119,7 @@ describe('Processor: Date', () => { target_field: 'target_field', locale: 'SPANISH', timezone: 'EST', + output_format: 'yyyy-MM-dd', }); }); }); 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 d50189167a2ff..24e1ddce008ea 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 @@ -140,6 +140,7 @@ type TestSubject = | 'appendValueField.input' | 'formatsValueField.input' | 'timezoneField.input' + | 'outputFormatField.input' | 'localeField.input' | 'processorTypeSelector.input' | 'fieldNameField.input' diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/date.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/date.tsx index b1e42d067e56e..90138757c97aa 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/date.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/date.tsx @@ -32,10 +32,20 @@ const fieldsConfig: FieldsConfig = { label: i18n.translate('xpack.ingestPipelines.pipelineEditor.dateForm.formatsFieldLabel', { defaultMessage: 'Formats', }), - helpText: i18n.translate('xpack.ingestPipelines.pipelineEditor.dateForm.formatsFieldHelpText', { - defaultMessage: - 'Expected date formats. Provided formats are applied sequentially. Accepts a Java time pattern, ISO8601, UNIX, UNIX_MS, or TAI64N formats.', - }), + helpText: ( + <FormattedMessage + id="xpack.ingestPipelines.pipelineEditor.dateForm.formatsFieldHelpText" + defaultMessage="Expected date formats. Provided formats are applied sequentially. Accepts a Java time pattern or one of the following formats: {allowedFormats}." + values={{ + allowedFormats: ( + <> + <EuiCode>{'ISO8601'}</EuiCode>,<EuiCode>{'UNIX'}</EuiCode>, + <EuiCode>{'UNIX_MS'}</EuiCode>,<EuiCode>{'TAI64N'}</EuiCode> + </> + ), + }} + /> + ), validations: [ { validator: minLengthField({ @@ -79,6 +89,29 @@ const fieldsConfig: FieldsConfig = { /> ), }, + output_format: { + type: FIELD_TYPES.TEXT, + serializer: from.emptyStringToUndefined, + label: i18n.translate('xpack.ingestPipelines.pipelineEditor.dateForm.outputFormatFieldLabel', { + defaultMessage: 'Output format (optional)', + }), + helpText: ( + <FormattedMessage + id="xpack.ingestPipelines.pipelineEditor.dateForm.outputFormatHelpText" + defaultMessage="Format to use when writing the date to {targetField}. Accepts a Java time pattern or one of the following formats: {allowedFormats}. Defaults to {defaultFormat}." + values={{ + targetField: <EuiCode>{'target_field'}</EuiCode>, + allowedFormats: ( + <> + <EuiCode>{'ISO8601'}</EuiCode>,<EuiCode>{'UNIX'}</EuiCode>, + <EuiCode>{'UNIX_MS'}</EuiCode>,<EuiCode>{'TAI64N'}</EuiCode> + </> + ), + defaultFormat: <EuiCode>{`yyyy-MM-dd'T'HH:mm:ss.SSSXXX`}</EuiCode>, + }} + /> + ), + }, }; /** @@ -126,6 +159,13 @@ export const DateProcessor: FunctionComponent = () => { component={Field} path="fields.locale" /> + + <UseField + data-test-subj="outputFormatField" + config={fieldsConfig.output_format} + component={Field} + path="fields.output_format" + /> </> ); }; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index c0c14ef4cc6eb..a32ea7b53f6ee 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -11946,7 +11946,6 @@ "xpack.ingestPipelines.pipelineEditor.customForm.optionsFieldAriaLabel": "構成JSONエディター", "xpack.ingestPipelines.pipelineEditor.customForm.optionsFieldLabel": "構成", "xpack.ingestPipelines.pipelineEditor.dateForm.fieldNameHelpText": "変換するフィールド。", - "xpack.ingestPipelines.pipelineEditor.dateForm.formatsFieldHelpText": "想定されるデータ形式。指定された形式は連続で適用されます。Java時刻パターン、ISO8601、UNIX、UNIX_MS、TAI64Nを使用できます。", "xpack.ingestPipelines.pipelineEditor.dateForm.formatsFieldLabel": "形式", "xpack.ingestPipelines.pipelineEditor.dateForm.formatsRequiredError": "形式の値は必須です。", "xpack.ingestPipelines.pipelineEditor.dateForm.localeFieldLabel": "ロケール (任意) ", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 68bd84f6ae757..eb96616c53053 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -12110,7 +12110,6 @@ "xpack.ingestPipelines.pipelineEditor.customForm.optionsFieldAriaLabel": "配置 JSON 编辑器", "xpack.ingestPipelines.pipelineEditor.customForm.optionsFieldLabel": "配置", "xpack.ingestPipelines.pipelineEditor.dateForm.fieldNameHelpText": "要转换的字段。", - "xpack.ingestPipelines.pipelineEditor.dateForm.formatsFieldHelpText": "预期的日期格式。提供的格式按顺序应用。接受 Java 时间模式、ISO8601、UNIX、UNIX_MS 或 TAI64N 格式。", "xpack.ingestPipelines.pipelineEditor.dateForm.formatsFieldLabel": "格式", "xpack.ingestPipelines.pipelineEditor.dateForm.formatsRequiredError": "需要格式的值。", "xpack.ingestPipelines.pipelineEditor.dateForm.localeFieldLabel": "区域设置 (可选) ", From 644d2ce9189e120554001f94020972629f2f1593 Mon Sep 17 00:00:00 2001 From: Christos Nasikas <christos.nasikas@elastic.co> Date: Thu, 1 Jul 2021 19:02:11 +0300 Subject: [PATCH 46/51] [Detections] Truncate case title in toaster when attaching an alert to case (#103228) --- x-pack/plugins/cases/common/constants.ts | 6 ++ .../cases/public/common/translations.ts | 6 ++ .../public/components/all_cases/columns.tsx | 5 +- .../components/create/form_context.test.tsx | 30 ++++++ .../cases/public/components/create/schema.tsx | 10 +- .../__snapshots__/title.test.tsx.snap | 18 +++- .../header_page/editable_title.test.tsx | 29 ++++++ .../components/header_page/editable_title.tsx | 96 +++++++++++-------- .../components/header_page/title.test.tsx | 6 ++ .../public/components/header_page/title.tsx | 4 +- .../components/header_page/translations.ts | 2 + .../components/recent_cases/recent_cases.tsx | 3 +- .../components/truncated_text/index.tsx | 29 ++++++ .../cases/server/client/cases/create.ts | 7 ++ .../cases/server/client/cases/update.ts | 20 ++++ .../timeline_actions/helpers.test.tsx | 25 ++++- .../components/timeline_actions/helpers.tsx | 13 ++- .../tests/common/cases/patch_cases.ts | 20 ++++ .../tests/common/cases/post_case.ts | 7 ++ 19 files changed, 286 insertions(+), 50 deletions(-) create mode 100644 x-pack/plugins/cases/public/components/truncated_text/index.tsx diff --git a/x-pack/plugins/cases/common/constants.ts b/x-pack/plugins/cases/common/constants.ts index 5d7ee47bb8ea0..fb3a0475d627a 100644 --- a/x-pack/plugins/cases/common/constants.ts +++ b/x-pack/plugins/cases/common/constants.ts @@ -94,3 +94,9 @@ if (ENABLE_CASE_CONNECTOR) { export const MAX_DOCS_PER_PAGE = 10000; export const MAX_CONCURRENT_SEARCHES = 10; + +/** + * Validation + */ + +export const MAX_TITLE_LENGTH = 64; diff --git a/x-pack/plugins/cases/public/common/translations.ts b/x-pack/plugins/cases/public/common/translations.ts index 020d301c8e30e..c81ec1c25d84f 100644 --- a/x-pack/plugins/cases/public/common/translations.ts +++ b/x-pack/plugins/cases/public/common/translations.ts @@ -228,3 +228,9 @@ export const SELECTABLE_MESSAGE_COLLECTIONS = i18n.translate( export const SELECT_CASE_TITLE = i18n.translate('xpack.cases.common.allCases.caseModal.title', { defaultMessage: 'Select case', }); + +export const MAX_LENGTH_ERROR = (field: string, length: number) => + i18n.translate('xpack.cases.createCase.maxLengthError', { + values: { field, length }, + defaultMessage: 'The length of the {field} is too long. The maximum length is {length}.', + }); diff --git a/x-pack/plugins/cases/public/components/all_cases/columns.tsx b/x-pack/plugins/cases/public/components/all_cases/columns.tsx index ad4447223837c..140dbf2f53c25 100644 --- a/x-pack/plugins/cases/public/components/all_cases/columns.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/columns.tsx @@ -34,6 +34,7 @@ import { useDeleteCases } from '../../containers/use_delete_cases'; import { ConfirmDeleteCaseModal } from '../confirm_delete_case'; import { useKibana } from '../../common/lib/kibana'; import { StatusContextMenu } from '../case_action_bar/status_context_menu'; +import { TruncatedText } from '../truncated_text'; export type CasesColumns = | EuiTableActionsColumnType<Case> @@ -145,10 +146,10 @@ export const useCasesColumns = ({ subCaseId={isSubCase(theCase) ? theCase.id : undefined} title={theCase.title} > - {theCase.title} + <TruncatedText text={theCase.title} /> </CaseDetailsLink> ) : ( - <span>{theCase.title}</span> + <TruncatedText text={theCase.title} /> ); return theCase.status !== CaseStatuses.closed ? ( caseDetailsLinkComponent diff --git a/x-pack/plugins/cases/public/components/create/form_context.test.tsx b/x-pack/plugins/cases/public/components/create/form_context.test.tsx index e083f11ced777..0ddab55c621d3 100644 --- a/x-pack/plugins/cases/public/components/create/form_context.test.tsx +++ b/x-pack/plugins/cases/public/components/create/form_context.test.tsx @@ -183,6 +183,36 @@ describe('Create case', () => { await waitFor(() => expect(postCase).toBeCalledWith(sampleData)); }); + it('it does not submits the title when the length is longer than 64 characters', async () => { + const longTitle = + 'This is a title that should not be saved as it is longer than 64 characters.'; + + const wrapper = mount( + <TestProviders> + <FormContext onSuccess={onFormSubmitSuccess}> + <CreateCaseForm {...defaultCreateCaseForm} /> + <SubmitCaseButton /> + </FormContext> + </TestProviders> + ); + + act(() => { + wrapper + .find(`[data-test-subj="caseTitle"] input`) + .first() + .simulate('change', { target: { value: longTitle } }); + wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click'); + }); + + await waitFor(() => { + wrapper.update(); + expect(wrapper.find('[data-test-subj="caseTitle"] .euiFormErrorText').text()).toBe( + 'The length of the title is too long. The maximum length is 64.' + ); + }); + expect(postCase).not.toHaveBeenCalled(); + }); + it('should toggle sync settings', async () => { useConnectorsMock.mockReturnValue({ ...sampleConnectorData, diff --git a/x-pack/plugins/cases/public/components/create/schema.tsx b/x-pack/plugins/cases/public/components/create/schema.tsx index bea1a46d93760..41709a74d2fa5 100644 --- a/x-pack/plugins/cases/public/components/create/schema.tsx +++ b/x-pack/plugins/cases/public/components/create/schema.tsx @@ -5,12 +5,12 @@ * 2.0. */ -import { CasePostRequest, ConnectorTypeFields } from '../../../common'; +import { CasePostRequest, ConnectorTypeFields, MAX_TITLE_LENGTH } from '../../../common'; import { FIELD_TYPES, fieldValidators, FormSchema } from '../../common/shared_imports'; import * as i18n from './translations'; import { OptionalFieldLabel } from './optional_field_label'; -const { emptyField } = fieldValidators; +const { emptyField, maxLengthField } = fieldValidators; export const schemaTags = { type: FIELD_TYPES.COMBO_BOX, @@ -33,6 +33,12 @@ export const schema: FormSchema<FormProps> = { { validator: emptyField(i18n.TITLE_REQUIRED), }, + { + validator: maxLengthField({ + length: MAX_TITLE_LENGTH, + message: i18n.MAX_LENGTH_ERROR('title', MAX_TITLE_LENGTH), + }), + }, ], }, description: { diff --git a/x-pack/plugins/cases/public/components/header_page/__snapshots__/title.test.tsx.snap b/x-pack/plugins/cases/public/components/header_page/__snapshots__/title.test.tsx.snap index 05af2fee2c2a2..9ff9b0616c57e 100644 --- a/x-pack/plugins/cases/public/components/header_page/__snapshots__/title.test.tsx.snap +++ b/x-pack/plugins/cases/public/components/header_page/__snapshots__/title.test.tsx.snap @@ -7,7 +7,9 @@ exports[`Title it renders 1`] = ` <h1 data-test-subj="header-page-title" > - Test title + <Memo(TruncatedTextComponent) + text="Test title" + /> <StyledEuiBetaBadge label="Beta" @@ -17,3 +19,17 @@ exports[`Title it renders 1`] = ` </h1> </EuiTitle> `; + +exports[`Title it renders the title if is not a string 1`] = ` +<EuiTitle + size="l" +> + <h1 + data-test-subj="header-page-title" + > + <span> + Test title + </span> + </h1> +</EuiTitle> +`; diff --git a/x-pack/plugins/cases/public/components/header_page/editable_title.test.tsx b/x-pack/plugins/cases/public/components/header_page/editable_title.test.tsx index babfeb584677b..19aea39f1f793 100644 --- a/x-pack/plugins/cases/public/components/header_page/editable_title.test.tsx +++ b/x-pack/plugins/cases/public/components/header_page/editable_title.test.tsx @@ -187,4 +187,33 @@ describe('EditableTitle', () => { expect(submitTitle.mock.calls[0][0]).toEqual(newTitle); expect(wrapper.find('[data-test-subj="editable-title-edit-icon"]').first().exists()).toBe(true); }); + + test('it does not submits the title when the length is longer than 64 characters', () => { + const longTitle = + 'This is a title that should not be saved as it is longer than 64 characters.'; + + const wrapper = mount( + <TestProviders> + <EditableTitle {...defaultProps} /> + </TestProviders> + ); + + wrapper.find('button[data-test-subj="editable-title-edit-icon"]').simulate('click'); + wrapper.update(); + + wrapper + .find('input[data-test-subj="editable-title-input-field"]') + .simulate('change', { target: { value: longTitle } }); + + wrapper.find('button[data-test-subj="editable-title-submit-btn"]').simulate('click'); + wrapper.update(); + expect(wrapper.find('.euiFormErrorText').text()).toBe( + 'The length of the title is too long. The maximum length is 64.' + ); + + expect(submitTitle).not.toHaveBeenCalled(); + expect(wrapper.find('[data-test-subj="editable-title-edit-icon"]').first().exists()).toBe( + false + ); + }); }); diff --git a/x-pack/plugins/cases/public/components/header_page/editable_title.tsx b/x-pack/plugins/cases/public/components/header_page/editable_title.tsx index 7856a77332275..4dcfa9ad98fde 100644 --- a/x-pack/plugins/cases/public/components/header_page/editable_title.tsx +++ b/x-pack/plugins/cases/public/components/header_page/editable_title.tsx @@ -16,10 +16,11 @@ import { EuiFieldText, EuiButtonIcon, EuiLoadingSpinner, + EuiFormRow, } from '@elastic/eui'; +import { MAX_TITLE_LENGTH } from '../../../common'; import * as i18n from './translations'; - import { Title } from './title'; const MyEuiButtonIcon = styled(EuiButtonIcon)` @@ -37,7 +38,7 @@ const MySpinner = styled(EuiLoadingSpinner)` export interface EditableTitleProps { userCanCrud: boolean; isLoading: boolean; - title: string | React.ReactNode; + title: string; onSubmit: (title: string) => void; } @@ -48,57 +49,72 @@ const EditableTitleComponent: React.FC<EditableTitleProps> = ({ title, }) => { const [editMode, setEditMode] = useState(false); - const [changedTitle, onTitleChange] = useState<string>(typeof title === 'string' ? title : ''); + const [errors, setErrors] = useState<string[]>([]); + const [newTitle, setNewTitle] = useState<string>(title); - const onCancel = useCallback(() => setEditMode(false), []); - const onClickEditIcon = useCallback(() => setEditMode(true), []); + const onCancel = useCallback(() => { + setEditMode(false); + setErrors([]); + setNewTitle(title); + }, [title]); + const onClickEditIcon = useCallback(() => setEditMode(true), []); const onClickSubmit = useCallback((): void => { - if (changedTitle !== title) { - onSubmit(changedTitle); + if (newTitle.length > MAX_TITLE_LENGTH) { + setErrors([i18n.MAX_LENGTH_ERROR('title', MAX_TITLE_LENGTH)]); + return; + } + + if (newTitle !== title) { + onSubmit(newTitle); } setEditMode(false); - }, [changedTitle, onSubmit, title]); + }, [newTitle, onSubmit, title]); const handleOnChange = useCallback( - (e: ChangeEvent<HTMLInputElement>) => onTitleChange(e.target.value), + (e: ChangeEvent<HTMLInputElement>) => setNewTitle(e.target.value), [] ); + + const hasErrors = errors.length > 0; + return editMode ? ( - <EuiFlexGroup alignItems="center" gutterSize="m" justifyContent="spaceBetween"> - <EuiFlexItem grow={false}> - <EuiFieldText - onChange={handleOnChange} - value={`${changedTitle}`} - data-test-subj="editable-title-input-field" - /> - </EuiFlexItem> - <EuiFlexGroup gutterSize="none" responsive={false} wrap={true}> + <EuiFormRow isInvalid={hasErrors} error={errors} fullWidth> + <EuiFlexGroup alignItems="center" gutterSize="m" justifyContent="spaceBetween"> <EuiFlexItem grow={false}> - <EuiButton - color="secondary" - data-test-subj="editable-title-submit-btn" - fill - iconType="save" - onClick={onClickSubmit} - size="s" - > - {i18n.SAVE} - </EuiButton> - </EuiFlexItem> - <EuiFlexItem grow={false}> - <EuiButtonEmpty - data-test-subj="editable-title-cancel-btn" - iconType="cross" - onClick={onCancel} - size="s" - > - {i18n.CANCEL} - </EuiButtonEmpty> + <EuiFieldText + onChange={handleOnChange} + value={`${newTitle}`} + data-test-subj="editable-title-input-field" + /> </EuiFlexItem> + <EuiFlexGroup gutterSize="none" responsive={false} wrap={true}> + <EuiFlexItem grow={false}> + <EuiButton + color="secondary" + data-test-subj="editable-title-submit-btn" + fill + iconType="save" + onClick={onClickSubmit} + size="s" + > + {i18n.SAVE} + </EuiButton> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiButtonEmpty + data-test-subj="editable-title-cancel-btn" + iconType="cross" + onClick={onCancel} + size="s" + > + {i18n.CANCEL} + </EuiButtonEmpty> + </EuiFlexItem> + </EuiFlexGroup> + <EuiFlexItem /> </EuiFlexGroup> - <EuiFlexItem /> - </EuiFlexGroup> + </EuiFormRow> ) : ( <EuiFlexGroup alignItems="center" gutterSize="none" responsive={false}> <EuiFlexItem grow={false}> diff --git a/x-pack/plugins/cases/public/components/header_page/title.test.tsx b/x-pack/plugins/cases/public/components/header_page/title.test.tsx index 2423104eb8819..063b21e4d8906 100644 --- a/x-pack/plugins/cases/public/components/header_page/title.test.tsx +++ b/x-pack/plugins/cases/public/components/header_page/title.test.tsx @@ -36,4 +36,10 @@ describe('Title', () => { expect(wrapper.find('[data-test-subj="header-page-title"]').first().exists()).toBe(true); }); + + test('it renders the title if is not a string', () => { + const wrapper = shallow(<Title title={<span>{'Test title'}</span>} />); + + expect(wrapper).toMatchSnapshot(); + }); }); diff --git a/x-pack/plugins/cases/public/components/header_page/title.tsx b/x-pack/plugins/cases/public/components/header_page/title.tsx index 3a0390a436e1c..629aa612610ee 100644 --- a/x-pack/plugins/cases/public/components/header_page/title.tsx +++ b/x-pack/plugins/cases/public/components/header_page/title.tsx @@ -6,10 +6,12 @@ */ import React from 'react'; +import { isString } from 'lodash'; import { EuiBetaBadge, EuiBadge, EuiTitle } from '@elastic/eui'; import styled from 'styled-components'; import { BadgeOptions, TitleProp } from './types'; +import { TruncatedText } from '../truncated_text'; const StyledEuiBetaBadge = styled(EuiBetaBadge)` vertical-align: middle; @@ -30,7 +32,7 @@ interface Props { const TitleComponent: React.FC<Props> = ({ title, badgeOptions }) => ( <EuiTitle size="l"> <h1 data-test-subj="header-page-title"> - {title} + {isString(title) ? <TruncatedText text={title} /> : title} {badgeOptions && ( <> {' '} diff --git a/x-pack/plugins/cases/public/components/header_page/translations.ts b/x-pack/plugins/cases/public/components/header_page/translations.ts index b24c347857a6c..ba987d1f45f15 100644 --- a/x-pack/plugins/cases/public/components/header_page/translations.ts +++ b/x-pack/plugins/cases/public/components/header_page/translations.ts @@ -7,6 +7,8 @@ import { i18n } from '@kbn/i18n'; +export * from '../../common/translations'; + export const SAVE = i18n.translate('xpack.cases.header.editableTitle.save', { defaultMessage: 'Save', }); diff --git a/x-pack/plugins/cases/public/components/recent_cases/recent_cases.tsx b/x-pack/plugins/cases/public/components/recent_cases/recent_cases.tsx index bfe44dda6c6ef..e08c629913258 100644 --- a/x-pack/plugins/cases/public/components/recent_cases/recent_cases.tsx +++ b/x-pack/plugins/cases/public/components/recent_cases/recent_cases.tsx @@ -19,6 +19,7 @@ import { NoCases } from './no_cases'; import { isSubCase } from '../all_cases/helpers'; import { MarkdownRenderer } from '../markdown_editor'; import { FilterOptions } from '../../containers/types'; +import { TruncatedText } from '../truncated_text'; const MarkdownContainer = styled.div` max-height: 150px; @@ -80,7 +81,7 @@ export const RecentCasesComp = ({ title={c.title} subCaseId={isSubCase(c) ? c.id : undefined} > - {c.title} + <TruncatedText text={c.title} /> </CaseDetailsLink> </EuiText> diff --git a/x-pack/plugins/cases/public/components/truncated_text/index.tsx b/x-pack/plugins/cases/public/components/truncated_text/index.tsx new file mode 100644 index 0000000000000..8a480ed9dbdd1 --- /dev/null +++ b/x-pack/plugins/cases/public/components/truncated_text/index.tsx @@ -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 React from 'react'; +import styled from 'styled-components'; + +const LINE_CLAMP = 3; + +const Text = styled.span` + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: ${LINE_CLAMP}; + -webkit-box-orient: vertical; + overflow: hidden; +`; + +interface Props { + text: string; +} + +const TruncatedTextComponent: React.FC<Props> = ({ text }) => { + return <Text title={text}>{text}</Text>; +}; + +export const TruncatedText = React.memo(TruncatedTextComponent); diff --git a/x-pack/plugins/cases/server/client/cases/create.ts b/x-pack/plugins/cases/server/client/cases/create.ts index 0eebeb343e814..03ea76ede5c2e 100644 --- a/x-pack/plugins/cases/server/client/cases/create.ts +++ b/x-pack/plugins/cases/server/client/cases/create.ts @@ -22,6 +22,7 @@ import { CaseType, OWNER_FIELD, ENABLE_CASE_CONNECTOR, + MAX_TITLE_LENGTH, } from '../../../common'; import { buildCaseUserActionItem } from '../../services/user_actions/helpers'; import { getConnectorFromConfiguration } from '../utils'; @@ -72,6 +73,12 @@ export const create = async ( fold(throwErrors(Boom.badRequest), identity) ); + if (query.title.length > MAX_TITLE_LENGTH) { + throw Boom.badRequest( + `The length of the title is too long. The maximum length is ${MAX_TITLE_LENGTH}.` + ); + } + try { const savedObjectID = SavedObjectsUtils.generateId(); diff --git a/x-pack/plugins/cases/server/client/cases/update.ts b/x-pack/plugins/cases/server/client/cases/update.ts index e5d9e1cddeee6..afe43171563ce 100644 --- a/x-pack/plugins/cases/server/client/cases/update.ts +++ b/x-pack/plugins/cases/server/client/cases/update.ts @@ -40,6 +40,7 @@ import { MAX_CONCURRENT_SEARCHES, SUB_CASE_SAVED_OBJECT, throwErrors, + MAX_TITLE_LENGTH, } from '../../../common'; import { buildCaseUserActions } from '../../services/user_actions/helpers'; import { getCaseToUpdate } from '../utils'; @@ -181,6 +182,24 @@ async function throwIfInvalidUpdateOfTypeWithAlerts({ } } +/** + * Throws an error if any of the requests updates a title and the length is over MAX_TITLE_LENGTH. + */ +function throwIfTitleIsInvalid(requests: ESCasePatchRequest[]) { + const requestsInvalidTitle = requests.filter( + (req) => req.title !== undefined && req.title.length > MAX_TITLE_LENGTH + ); + + if (requestsInvalidTitle.length > 0) { + const ids = requestsInvalidTitle.map((req) => req.id); + throw Boom.badRequest( + `The length of the title is too long. The maximum length is ${MAX_TITLE_LENGTH}, ids: [${ids.join( + ', ' + )}]` + ); + } +} + /** * Get the id from a reference in a comment for a specific type. */ @@ -477,6 +496,7 @@ export const update = async ( } throwIfUpdateOwner(updateFilterCases); + throwIfTitleIsInvalid(updateFilterCases); throwIfUpdateStatusOfCollection(updateFilterCases, casesMap); throwIfUpdateTypeCollectionToIndividual(updateFilterCases, casesMap); await throwIfInvalidUpdateOfTypeWithAlerts({ diff --git a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/helpers.test.tsx b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/helpers.test.tsx index 9722447b96ad5..3e0aa17a3830e 100644 --- a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/helpers.test.tsx @@ -5,6 +5,9 @@ * 2.0. */ +import React from 'react'; +import { mount } from 'enzyme'; +import 'jest-styled-components'; import { createUpdateSuccessToaster } from './helpers'; import { Case } from '../../../../../cases/common'; @@ -23,12 +26,30 @@ describe('helpers', () => { it('creates the correct toast when the sync alerts is on', () => { // We remove the id as is randomly generated and the text as it is a React component // which is being test on toaster_content.test.tsx - const { id, text, ...toast } = createUpdateSuccessToaster(theCase, onViewCaseClick); + const { id, text, title, ...toast } = createUpdateSuccessToaster(theCase, onViewCaseClick); + const mountedTitle = mount(<>{title}</>); + expect(toast).toEqual({ color: 'success', iconType: 'check', - title: 'An alert has been added to "My case"', }); + expect(mountedTitle).toMatchInlineSnapshot(` + .c0 { + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + } + + <styled.span> + <span + className="c0" + > + An alert has been added to "My case" + </span> + </styled.span> + `); }); }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/helpers.tsx b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/helpers.tsx index 8682b6680830d..93e1f0499893e 100644 --- a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/helpers.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/helpers.tsx @@ -7,11 +7,22 @@ import React from 'react'; import uuid from 'uuid'; +import styled from 'styled-components'; import { AppToast } from '../../../common/components/toasters'; import { ToasterContent } from './toaster_content'; import * as i18n from './translations'; import { Case } from '../../../../../cases/common'; +const LINE_CLAMP = 3; + +const Title = styled.span` + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: ${LINE_CLAMP}; + -webkit-box-orient: vertical; + overflow: hidden; +`; + export const createUpdateSuccessToaster = ( theCase: Case, onViewCaseClick: (id: string) => void @@ -20,7 +31,7 @@ export const createUpdateSuccessToaster = ( id: uuid.v4(), color: 'success', iconType: 'check', - title: i18n.CASE_CREATED_SUCCESS_TOAST(theCase.title), + title: <Title>{i18n.CASE_CREATED_SUCCESS_TOAST(theCase.title)}, text: ( { expectedHttpCode: 400, }); }); + + it('400s if the title is too long', async () => { + const longTitle = + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed nulla enim, rutrum sit amet euismod venenatis, blandit et massa. Nulla id consectetur enim.'; + + const postedCase = await createCase(supertest, postCaseReq); + await updateCase({ + supertest, + params: { + cases: [ + { + id: postedCase.id, + version: postedCase.version, + title: longTitle, + }, + ], + }, + expectedHttpCode: 400, + }); + }); }); describe('alerts', () => { diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/post_case.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/post_case.ts index e8337fa9db502..2fe5a4c0165c0 100644 --- a/x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/post_case.ts +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/post_case.ts @@ -238,6 +238,13 @@ export default ({ getService }: FtrProviderContext): void => { .send({ ...req, status: CaseStatuses.open }) .expect(400); }); + + it('400s if the title is too long', async () => { + const longTitle = + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed nulla enim, rutrum sit amet euismod venenatis, blandit et massa. Nulla id consectetur enim.'; + + await createCase(supertest, getPostCaseRequest({ title: longTitle }), 400); + }); }); describe('rbac', () => { From 8bb13a97179e4a0e21a9efb746232956355ba4e4 Mon Sep 17 00:00:00 2001 From: Clint Andrew Hall Date: Thu, 1 Jul 2021 12:09:21 -0400 Subject: [PATCH 47/51] [canvas] Create Notify Service; remove legacy service (#103821) --- .../function_reference_generator.tsx | 13 ++++----- .../saved_elements_modal.ts | 9 +++--- .../share_menu/flyout/flyout.ts | 5 ++-- .../canvas/public/lib/download_workpad.ts | 23 ++++++++------- .../public/lib/element_handler_creators.ts | 28 ++++++++++-------- .../plugins/canvas/public/lib/es_service.ts | 29 ++++++++++--------- .../canvas/public/lib/run_interpreter.ts | 5 ++-- .../public/routes/workpad/workpad_route.tsx | 8 ++--- .../plugins/canvas/public/services/index.ts | 3 ++ .../canvas/public/services/kibana/index.ts | 3 ++ .../services/{legacy => kibana}/notify.ts | 21 +++++++------- .../canvas/public/services/legacy/context.tsx | 3 -- .../canvas/public/services/legacy/index.ts | 5 ---- .../public/services/legacy/stubs/index.ts | 2 -- .../plugins/canvas/public/services/notify.ts | 15 ++++++++++ .../canvas/public/services/storybook/index.ts | 2 ++ .../public/services/storybook/notify.ts | 22 ++++++++++++++ .../canvas/public/services/stubs/index.ts | 3 ++ .../services/{legacy => }/stubs/notify.ts | 10 +++++-- .../plugins/canvas/public/services/workpad.ts | 1 - .../canvas/public/state/actions/elements.js | 11 ++++--- .../public/state/middleware/es_persist.js | 10 ++++--- x-pack/plugins/canvas/storybook/preview.ts | 10 +------ 23 files changed, 145 insertions(+), 96 deletions(-) rename x-pack/plugins/canvas/public/services/{legacy => kibana}/notify.ts (74%) create mode 100644 x-pack/plugins/canvas/public/services/notify.ts create mode 100644 x-pack/plugins/canvas/public/services/storybook/notify.ts rename x-pack/plugins/canvas/public/services/{legacy => }/stubs/notify.ts (54%) diff --git a/x-pack/plugins/canvas/public/components/function_reference_generator/function_reference_generator.tsx b/x-pack/plugins/canvas/public/components/function_reference_generator/function_reference_generator.tsx index 6f98baf944bac..81532816d9c83 100644 --- a/x-pack/plugins/canvas/public/components/function_reference_generator/function_reference_generator.tsx +++ b/x-pack/plugins/canvas/public/components/function_reference_generator/function_reference_generator.tsx @@ -9,7 +9,7 @@ import React, { FC } from 'react'; import { ExpressionFunction } from 'src/plugins/expressions'; import { EuiButtonEmpty } from '@elastic/eui'; import copy from 'copy-to-clipboard'; -import { notifyService } from '../../services'; +import { useNotifyService } from '../../services'; import { generateFunctionReference } from './generate_function_reference'; interface Props { @@ -17,16 +17,15 @@ interface Props { } export const FunctionReferenceGenerator: FC = ({ functionRegistry }) => { + const notifyService = useNotifyService(); const functionDefinitions = Object.values(functionRegistry); const copyDocs = () => { copy(generateFunctionReference(functionDefinitions)); - notifyService - .getService() - .success( - `Please paste updated docs into '/kibana/docs/canvas/canvas-function-reference.asciidoc' and commit your changes.`, - { title: 'Copied function docs to clipboard' } - ); + notifyService.success( + `Please paste updated docs into '/kibana/docs/canvas/canvas-function-reference.asciidoc' and commit your changes.`, + { title: 'Copied function docs to clipboard' } + ); }; return ( diff --git a/x-pack/plugins/canvas/public/components/saved_elements_modal/saved_elements_modal.ts b/x-pack/plugins/canvas/public/components/saved_elements_modal/saved_elements_modal.ts index 9b592d402f84c..524c1a48b6cee 100644 --- a/x-pack/plugins/canvas/public/components/saved_elements_modal/saved_elements_modal.ts +++ b/x-pack/plugins/canvas/public/components/saved_elements_modal/saved_elements_modal.ts @@ -11,7 +11,7 @@ import { compose, withState } from 'recompose'; import { camelCase } from 'lodash'; import { cloneSubgraphs } from '../../lib/clone_subgraphs'; import * as customElementService from '../../lib/custom_element_service'; -import { withServices, WithServicesProps } from '../../services'; +import { withServices, WithServicesProps, pluginServices } from '../../services'; // @ts-expect-error untyped local import { selectToplevelNodes } from '../../state/actions/transient'; // @ts-expect-error untyped local @@ -68,6 +68,7 @@ const mergeProps = ( dispatchProps: DispatchProps, ownProps: OwnPropsWithState & WithServicesProps ): ComponentProps => { + const notifyService = pluginServices.getServices().notify; const { pageId } = stateProps; const { onClose, search, setCustomElements } = ownProps; @@ -94,7 +95,7 @@ const mergeProps = ( try { await findCustomElements(); } catch (err) { - ownProps.services.notify.error(err, { + notifyService.error(err, { title: `Couldn't find custom elements`, }); } @@ -105,7 +106,7 @@ const mergeProps = ( await customElementService.remove(id); await findCustomElements(); } catch (err) { - ownProps.services.notify.error(err, { + notifyService.error(err, { title: `Couldn't delete custom elements`, }); } @@ -121,7 +122,7 @@ const mergeProps = ( }); await findCustomElements(); } catch (err) { - ownProps.services.notify.error(err, { + notifyService.error(err, { title: `Couldn't update custom elements`, }); } diff --git a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/flyout.ts b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/flyout.ts index 65c9d6598578d..9b9c3d3dfee9f 100644 --- a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/flyout.ts +++ b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/flyout.ts @@ -30,6 +30,7 @@ import { withKibana } from '../../../../../../../../src/plugins/kibana_react/pub import { OnCloseFn } from '../share_menu.component'; import { ZIP } from '../../../../../i18n/constants'; import { WithKibanaProps } from '../../../../index'; +import { pluginServices } from '../../../../services'; export { OnDownloadFn, OnCopyFn } from './flyout.component'; @@ -95,7 +96,7 @@ export const ShareWebsiteFlyout = compose unsupportedRenderers, onClose, onCopy: () => { - kibana.services.canvas.notify.info(strings.getCopyShareConfigMessage()); + pluginServices.getServices().notify.info(strings.getCopyShareConfigMessage()); }, onDownload: (type) => { switch (type) { @@ -111,7 +112,7 @@ export const ShareWebsiteFlyout = compose .post(`${basePath}${API_ROUTE_SHAREABLE_ZIP}`, JSON.stringify(renderedWorkpad)) .then((blob) => downloadZippedRuntime(blob.data)) .catch((err: Error) => { - kibana.services.canvas.notify.error(err, { + pluginServices.getServices().notify.error(err, { title: strings.getShareableZipErrorTitle(workpad.name), }); }); diff --git a/x-pack/plugins/canvas/public/lib/download_workpad.ts b/x-pack/plugins/canvas/public/lib/download_workpad.ts index 8deda818a43d3..a346de3322d09 100644 --- a/x-pack/plugins/canvas/public/lib/download_workpad.ts +++ b/x-pack/plugins/canvas/public/lib/download_workpad.ts @@ -8,7 +8,10 @@ import fileSaver from 'file-saver'; import { API_ROUTE_SHAREABLE_RUNTIME_DOWNLOAD } from '../../common/lib/constants'; import { ErrorStrings } from '../../i18n'; -import { notifyService } from '../services'; + +// TODO: clint - convert this whole file to hooks +import { pluginServices } from '../services'; + // @ts-expect-error untyped local import * as workpadService from './workpad_service'; import { CanvasRenderedWorkpad } from '../../shareable_runtime/types'; @@ -21,7 +24,8 @@ export const downloadWorkpad = async (workpadId: string) => { const jsonBlob = new Blob([JSON.stringify(workpad)], { type: 'application/json' }); fileSaver.saveAs(jsonBlob, `canvas-workpad-${workpad.name}-${workpad.id}.json`); } catch (err) { - notifyService.getService().error(err, { title: strings.getDownloadFailureErrorMessage() }); + const notifyService = pluginServices.getServices().notify; + notifyService.error(err, { title: strings.getDownloadFailureErrorMessage() }); } }; @@ -33,9 +37,8 @@ export const downloadRenderedWorkpad = async (renderedWorkpad: CanvasRenderedWor `canvas-embed-workpad-${renderedWorkpad.name}-${renderedWorkpad.id}.json` ); } catch (err) { - notifyService - .getService() - .error(err, { title: strings.getDownloadRenderedWorkpadFailureErrorMessage() }); + const notifyService = pluginServices.getServices().notify; + notifyService.error(err, { title: strings.getDownloadRenderedWorkpadFailureErrorMessage() }); } }; @@ -45,9 +48,8 @@ export const downloadRuntime = async (basePath: string) => { window.open(path); return; } catch (err) { - notifyService - .getService() - .error(err, { title: strings.getDownloadRuntimeFailureErrorMessage() }); + const notifyService = pluginServices.getServices().notify; + notifyService.error(err, { title: strings.getDownloadRuntimeFailureErrorMessage() }); } }; @@ -56,8 +58,7 @@ export const downloadZippedRuntime = async (data: any) => { const zip = new Blob([data], { type: 'octet/stream' }); fileSaver.saveAs(zip, 'canvas-workpad-embed.zip'); } catch (err) { - notifyService - .getService() - .error(err, { title: strings.getDownloadZippedRuntimeFailureErrorMessage() }); + const notifyService = pluginServices.getServices().notify; + notifyService.error(err, { title: strings.getDownloadZippedRuntimeFailureErrorMessage() }); } }; diff --git a/x-pack/plugins/canvas/public/lib/element_handler_creators.ts b/x-pack/plugins/canvas/public/lib/element_handler_creators.ts index cdf9324e947da..a46252081e672 100644 --- a/x-pack/plugins/canvas/public/lib/element_handler_creators.ts +++ b/x-pack/plugins/canvas/public/lib/element_handler_creators.ts @@ -8,7 +8,7 @@ import { camelCase } from 'lodash'; import { getClipboardData, setClipboardData } from './clipboard'; import { cloneSubgraphs } from './clone_subgraphs'; -import { notifyService } from '../services'; +import { pluginServices } from '../services'; import * as customElementService from './custom_element_service'; import { getId } from './get_id'; import { PositionedElement } from '../../types'; @@ -70,6 +70,8 @@ export const basicHandlerCreators = { description = '', image = '' ): void => { + const notifyService = pluginServices.getServices().notify; + if (selectedNodes.length) { const content = JSON.stringify({ selectedNodes }); const customElement = { @@ -83,17 +85,15 @@ export const basicHandlerCreators = { customElementService .create(customElement) .then(() => - notifyService - .getService() - .success( - `Custom element '${customElement.displayName || customElement.id}' was saved`, - { - 'data-test-subj': 'canvasCustomElementCreate-success', - } - ) + notifyService.success( + `Custom element '${customElement.displayName || customElement.id}' was saved`, + { + 'data-test-subj': 'canvasCustomElementCreate-success', + } + ) ) .catch((error: Error) => - notifyService.getService().warning(error, { + notifyService.warning(error, { title: `Custom element '${ customElement.displayName || customElement.id }' was not saved`, @@ -135,16 +135,20 @@ export const groupHandlerCreators = { // handlers for cut/copy/paste export const clipboardHandlerCreators = { cutNodes: ({ pageId, removeNodes, selectedNodes }: Props) => (): void => { + const notifyService = pluginServices.getServices().notify; + if (selectedNodes.length) { setClipboardData({ selectedNodes }); removeNodes(selectedNodes.map(extractId), pageId); - notifyService.getService().success('Cut element to clipboard'); + notifyService.success('Cut element to clipboard'); } }, copyNodes: ({ selectedNodes }: Props) => (): void => { + const notifyService = pluginServices.getServices().notify; + if (selectedNodes.length) { setClipboardData({ selectedNodes }); - notifyService.getService().success('Copied element to clipboard'); + notifyService.success('Copied element to clipboard'); } }, pasteNodes: ({ insertNodes, pageId, selectToplevelNodes }: Props) => (): void => { diff --git a/x-pack/plugins/canvas/public/lib/es_service.ts b/x-pack/plugins/canvas/public/lib/es_service.ts index 25b63bf26c5bb..c1a4a17970ffa 100644 --- a/x-pack/plugins/canvas/public/lib/es_service.ts +++ b/x-pack/plugins/canvas/public/lib/es_service.ts @@ -5,12 +5,14 @@ * 2.0. */ +// TODO - clint: convert to service abstraction + import { IndexPatternAttributes } from 'src/plugins/data/public'; import { API_ROUTE } from '../../common/lib/constants'; import { fetch } from '../../common/lib/fetch'; import { ErrorStrings } from '../../i18n'; -import { notifyService } from '../services'; +import { pluginServices } from '../services'; import { platformService } from '../services'; const { esService: strings } = ErrorStrings; @@ -36,11 +38,12 @@ export const getFields = (index = '_all') => { .filter((field) => !field.startsWith('_')) // filters out meta fields .sort() ) - .catch((err: Error) => - notifyService.getService().error(err, { + .catch((err: Error) => { + const notifyService = pluginServices.getServices().notify; + notifyService.error(err, { title: strings.getFieldsFetchErrorMessage(index), - }) - ); + }); + }); }; export const getIndices = () => @@ -56,9 +59,10 @@ export const getIndices = () => return savedObject.attributes.title; }); }) - .catch((err: Error) => - notifyService.getService().error(err, { title: strings.getIndicesFetchErrorMessage() }) - ); + .catch((err: Error) => { + const notifyService = pluginServices.getServices().notify; + notifyService.error(err, { title: strings.getIndicesFetchErrorMessage() }); + }); export const getDefaultIndex = () => { const defaultIndexId = getAdvancedSettings().get('defaultIndex'); @@ -67,10 +71,9 @@ export const getDefaultIndex = () => { ? getSavedObjectsClient() .get('index-pattern', defaultIndexId) .then((defaultIndex) => defaultIndex.attributes.title) - .catch((err) => - notifyService - .getService() - .error(err, { title: strings.getDefaultIndexFetchErrorMessage() }) - ) + .catch((err) => { + const notifyService = pluginServices.getServices().notify; + notifyService.error(err, { title: strings.getDefaultIndexFetchErrorMessage() }); + }) : Promise.resolve(''); }; diff --git a/x-pack/plugins/canvas/public/lib/run_interpreter.ts b/x-pack/plugins/canvas/public/lib/run_interpreter.ts index eb9be96c5367b..149e90a8f6b73 100644 --- a/x-pack/plugins/canvas/public/lib/run_interpreter.ts +++ b/x-pack/plugins/canvas/public/lib/run_interpreter.ts @@ -7,7 +7,7 @@ import { fromExpression, getType } from '@kbn/interpreter/common'; import { ExpressionValue, ExpressionAstExpression } from 'src/plugins/expressions/public'; -import { notifyService, expressionsService } from '../services'; +import { pluginServices, expressionsService } from '../services'; interface Options { castToRender?: boolean; @@ -57,7 +57,8 @@ export async function runInterpreter( throw new Error(`Ack! I don't know how to render a '${getType(renderable)}'`); } catch (err) { - notifyService.getService().error(err); + const { error: displayError } = pluginServices.getServices().notify; + displayError(err); throw err; } } diff --git a/x-pack/plugins/canvas/public/routes/workpad/workpad_route.tsx b/x-pack/plugins/canvas/public/routes/workpad/workpad_route.tsx index 7683b3413f681..95caba08517ee 100644 --- a/x-pack/plugins/canvas/public/routes/workpad/workpad_route.tsx +++ b/x-pack/plugins/canvas/public/routes/workpad/workpad_route.tsx @@ -13,7 +13,7 @@ import { ExportApp } from '../../components/export_app'; import { CanvasLoading } from '../../components/canvas_loading'; // @ts-expect-error import { fetchAllRenderables } from '../../state/actions/elements'; -import { useServices } from '../../services'; +import { useNotifyService } from '../../services'; import { CanvasWorkpad } from '../../../types'; import { ErrorStrings } from '../../../i18n'; import { useWorkpad } from './hooks/use_workpad'; @@ -98,13 +98,13 @@ const WorkpadLoaderComponent: FC<{ children: (workpad: CanvasWorkpad) => JSX.Element; }> = ({ params, children, loadPages }) => { const [workpad, error] = useWorkpad(params.id, loadPages); - const services = useServices(); + const notifyService = useNotifyService(); useEffect(() => { if (error) { - services.notify.error(error, { title: strings.getLoadFailureErrorMessage() }); + notifyService.error(error, { title: strings.getLoadFailureErrorMessage() }); } - }, [error, services.notify]); + }, [error, notifyService]); if (error) { return ; diff --git a/x-pack/plugins/canvas/public/services/index.ts b/x-pack/plugins/canvas/public/services/index.ts index 49408fcec1ec4..83a54a8a673a1 100644 --- a/x-pack/plugins/canvas/public/services/index.ts +++ b/x-pack/plugins/canvas/public/services/index.ts @@ -9,11 +9,14 @@ export * from './legacy'; import { PluginServices } from '../../../../../src/plugins/presentation_util/public'; import { CanvasWorkpadService } from './workpad'; +import { CanvasNotifyService } from './notify'; export interface CanvasPluginServices { workpad: CanvasWorkpadService; + notify: CanvasNotifyService; } export const pluginServices = new PluginServices(); export const useWorkpadService = () => (() => pluginServices.getHooks().workpad.useService())(); +export const useNotifyService = () => (() => pluginServices.getHooks().notify.useService())(); diff --git a/x-pack/plugins/canvas/public/services/kibana/index.ts b/x-pack/plugins/canvas/public/services/kibana/index.ts index 99012003b3a15..7bb2be3f77e27 100644 --- a/x-pack/plugins/canvas/public/services/kibana/index.ts +++ b/x-pack/plugins/canvas/public/services/kibana/index.ts @@ -13,16 +13,19 @@ import { } from '../../../../../../src/plugins/presentation_util/public'; import { workpadServiceFactory } from './workpad'; +import { notifyServiceFactory } from './notify'; import { CanvasPluginServices } from '..'; import { CanvasStartDeps } from '../../plugin'; export { workpadServiceFactory } from './workpad'; +export { notifyServiceFactory } from './notify'; export const pluginServiceProviders: PluginServiceProviders< CanvasPluginServices, KibanaPluginServiceParams > = { workpad: new PluginServiceProvider(workpadServiceFactory), + notify: new PluginServiceProvider(notifyServiceFactory), }; export const pluginServiceRegistry = new PluginServiceRegistry< diff --git a/x-pack/plugins/canvas/public/services/legacy/notify.ts b/x-pack/plugins/canvas/public/services/kibana/notify.ts similarity index 74% rename from x-pack/plugins/canvas/public/services/legacy/notify.ts rename to x-pack/plugins/canvas/public/services/kibana/notify.ts index 22dcfa671d0b5..0082b523d050e 100644 --- a/x-pack/plugins/canvas/public/services/legacy/notify.ts +++ b/x-pack/plugins/canvas/public/services/kibana/notify.ts @@ -6,9 +6,17 @@ */ import { get } from 'lodash'; -import { CanvasServiceFactory } from '.'; +import { KibanaPluginServiceFactory } from '../../../../../../src/plugins/presentation_util/public'; + import { formatMsg } from '../../../../../../src/plugins/kibana_legacy/public'; import { ToastInputFields } from '../../../../../../src/core/public'; +import { CanvasStartDeps } from '../../plugin'; +import { CanvasNotifyService } from '../notify'; + +export type CanvasNotifyServiceFactory = KibanaPluginServiceFactory< + CanvasNotifyService, + CanvasStartDeps +>; const getToast = (err: Error | string, opts: ToastInputFields = {}) => { const errData = (get(err, 'response') || err) as Error | string; @@ -28,15 +36,8 @@ const getToast = (err: Error | string, opts: ToastInputFields = {}) => { }; }; -export interface NotifyService { - error: (err: string | Error, opts?: ToastInputFields) => void; - warning: (err: string | Error, opts?: ToastInputFields) => void; - info: (err: string | Error, opts?: ToastInputFields) => void; - success: (err: string | Error, opts?: ToastInputFields) => void; -} - -export const notifyServiceFactory: CanvasServiceFactory = (setup, start) => { - const toasts = start.notifications.toasts; +export const notifyServiceFactory: CanvasNotifyServiceFactory = ({ coreStart }) => { + const toasts = coreStart.notifications.toasts; return { /* diff --git a/x-pack/plugins/canvas/public/services/legacy/context.tsx b/x-pack/plugins/canvas/public/services/legacy/context.tsx index 7a90c6870df4a..dd2e45740f041 100644 --- a/x-pack/plugins/canvas/public/services/legacy/context.tsx +++ b/x-pack/plugins/canvas/public/services/legacy/context.tsx @@ -22,7 +22,6 @@ export interface WithServicesProps { const defaultContextValue = { embeddables: {}, expressions: {}, - notify: {}, platform: {}, navLink: {}, search: {}, @@ -34,7 +33,6 @@ export const useServices = () => useContext(context); export const usePlatformService = () => useServices().platform; export const useEmbeddablesService = () => useServices().embeddables; export const useExpressionsService = () => useServices().expressions; -export const useNotifyService = () => useServices().notify; export const useNavLinkService = () => useServices().navLink; export const useLabsService = () => useServices().labs; @@ -52,7 +50,6 @@ export const LegacyServicesProvider: FC<{ const value = { embeddables: specifiedProviders.embeddables.getService(), expressions: specifiedProviders.expressions.getService(), - notify: specifiedProviders.notify.getService(), platform: specifiedProviders.platform.getService(), navLink: specifiedProviders.navLink.getService(), search: specifiedProviders.search.getService(), diff --git a/x-pack/plugins/canvas/public/services/legacy/index.ts b/x-pack/plugins/canvas/public/services/legacy/index.ts index e23057daa7359..763fd657ad800 100644 --- a/x-pack/plugins/canvas/public/services/legacy/index.ts +++ b/x-pack/plugins/canvas/public/services/legacy/index.ts @@ -8,7 +8,6 @@ import { BehaviorSubject } from 'rxjs'; import { CoreSetup, CoreStart, AppUpdater } from '../../../../../../src/core/public'; import { CanvasSetupDeps, CanvasStartDeps } from '../../plugin'; -import { notifyServiceFactory } from './notify'; import { platformServiceFactory } from './platform'; import { navLinkServiceFactory } from './nav_link'; import { embeddablesServiceFactory } from './embeddables'; @@ -17,7 +16,6 @@ import { searchServiceFactory } from './search'; import { labsServiceFactory } from './labs'; import { reportingServiceFactory } from './reporting'; -export { NotifyService } from './notify'; export { SearchService } from './search'; export { PlatformService } from './platform'; export { NavLinkService } from './nav_link'; @@ -79,7 +77,6 @@ export type ServiceFromProvider

= P extends CanvasServiceProvider ? export const services = { embeddables: new CanvasServiceProvider(embeddablesServiceFactory), expressions: new CanvasServiceProvider(expressionsServiceFactory), - notify: new CanvasServiceProvider(notifyServiceFactory), platform: new CanvasServiceProvider(platformServiceFactory), navLink: new CanvasServiceProvider(navLinkServiceFactory), search: new CanvasServiceProvider(searchServiceFactory), @@ -92,7 +89,6 @@ export type CanvasServiceProviders = typeof services; export interface CanvasServices { embeddables: ServiceFromProvider; expressions: ServiceFromProvider; - notify: ServiceFromProvider; platform: ServiceFromProvider; navLink: ServiceFromProvider; search: ServiceFromProvider; @@ -120,7 +116,6 @@ export const stopServices = () => { export const { embeddables: embeddableService, - notify: notifyService, platform: platformService, navLink: navLinkService, expressions: expressionsService, diff --git a/x-pack/plugins/canvas/public/services/legacy/stubs/index.ts b/x-pack/plugins/canvas/public/services/legacy/stubs/index.ts index 7246a34d7f491..cebefdd7682cc 100644 --- a/x-pack/plugins/canvas/public/services/legacy/stubs/index.ts +++ b/x-pack/plugins/canvas/public/services/legacy/stubs/index.ts @@ -10,7 +10,6 @@ import { embeddablesService } from './embeddables'; import { expressionsService } from './expressions'; import { reportingService } from './reporting'; import { navLinkService } from './nav_link'; -import { notifyService } from './notify'; import { labsService } from './labs'; import { platformService } from './platform'; import { searchService } from './search'; @@ -20,7 +19,6 @@ export const stubs: CanvasServices = { expressions: expressionsService, reporting: reportingService, navLink: navLinkService, - notify: notifyService, platform: platformService, search: searchService, labs: labsService, diff --git a/x-pack/plugins/canvas/public/services/notify.ts b/x-pack/plugins/canvas/public/services/notify.ts new file mode 100644 index 0000000000000..67c5cb6bf79c4 --- /dev/null +++ b/x-pack/plugins/canvas/public/services/notify.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 { ToastInputFields } from '../../../../../src/core/public'; + +export interface CanvasNotifyService { + error: (err: string | Error, opts?: ToastInputFields) => void; + warning: (err: string | Error, opts?: ToastInputFields) => void; + info: (err: string | Error, opts?: ToastInputFields) => void; + success: (err: string | Error, opts?: ToastInputFields) => void; +} diff --git a/x-pack/plugins/canvas/public/services/storybook/index.ts b/x-pack/plugins/canvas/public/services/storybook/index.ts index de231f730faf5..86ff52155a0bf 100644 --- a/x-pack/plugins/canvas/public/services/storybook/index.ts +++ b/x-pack/plugins/canvas/public/services/storybook/index.ts @@ -13,6 +13,7 @@ import { import { CanvasPluginServices } from '..'; import { pluginServiceProviders as stubProviders } from '../stubs'; import { workpadServiceFactory } from './workpad'; +import { notifyServiceFactory } from './notify'; export interface StorybookParams { hasTemplates?: boolean; @@ -26,6 +27,7 @@ export const pluginServiceProviders: PluginServiceProviders< > = { ...stubProviders, workpad: new PluginServiceProvider(workpadServiceFactory), + notify: new PluginServiceProvider(notifyServiceFactory), }; export const argTypes = { diff --git a/x-pack/plugins/canvas/public/services/storybook/notify.ts b/x-pack/plugins/canvas/public/services/storybook/notify.ts new file mode 100644 index 0000000000000..7ffd2ef9d1453 --- /dev/null +++ b/x-pack/plugins/canvas/public/services/storybook/notify.ts @@ -0,0 +1,22 @@ +/* + * 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 { action } from '@storybook/addon-actions'; + +import { PluginServiceFactory } from '../../../../../../src/plugins/presentation_util/public'; + +import { StorybookParams } from '.'; +import { CanvasNotifyService } from '../notify'; + +type CanvasNotifyServiceFactory = PluginServiceFactory; + +export const notifyServiceFactory: CanvasNotifyServiceFactory = () => ({ + success: (message) => action(`success: ${message}`)(), + error: (message) => action(`error: ${message}`)(), + info: (message) => action(`info: ${message}`)(), + warning: (message) => action(`warning: ${message}`)(), +}); diff --git a/x-pack/plugins/canvas/public/services/stubs/index.ts b/x-pack/plugins/canvas/public/services/stubs/index.ts index 586007201db81..5c3440cc4cdbc 100644 --- a/x-pack/plugins/canvas/public/services/stubs/index.ts +++ b/x-pack/plugins/canvas/public/services/stubs/index.ts @@ -15,11 +15,14 @@ import { import { CanvasPluginServices } from '..'; import { workpadServiceFactory } from './workpad'; +import { notifyServiceFactory } from './notify'; export { workpadServiceFactory } from './workpad'; +export { notifyServiceFactory } from './notify'; export const pluginServiceProviders: PluginServiceProviders = { workpad: new PluginServiceProvider(workpadServiceFactory), + notify: new PluginServiceProvider(notifyServiceFactory), }; export const pluginServiceRegistry = new PluginServiceRegistry( diff --git a/x-pack/plugins/canvas/public/services/legacy/stubs/notify.ts b/x-pack/plugins/canvas/public/services/stubs/notify.ts similarity index 54% rename from x-pack/plugins/canvas/public/services/legacy/stubs/notify.ts rename to x-pack/plugins/canvas/public/services/stubs/notify.ts index 866da3d459ed3..0ad322a414f0d 100644 --- a/x-pack/plugins/canvas/public/services/legacy/stubs/notify.ts +++ b/x-pack/plugins/canvas/public/services/stubs/notify.ts @@ -5,13 +5,17 @@ * 2.0. */ -import { NotifyService } from '../notify'; +import { PluginServiceFactory } from '../../../../../../src/plugins/presentation_util/public'; + +import { CanvasNotifyService } from '../notify'; + +type CanvasNotifyServiceFactory = PluginServiceFactory; const noop = (..._args: any[]): any => {}; -export const notifyService: NotifyService = { +export const notifyServiceFactory: CanvasNotifyServiceFactory = () => ({ error: noop, info: noop, success: noop, warning: noop, -}; +}); diff --git a/x-pack/plugins/canvas/public/services/workpad.ts b/x-pack/plugins/canvas/public/services/workpad.ts index 37664244b2d55..6b90cc346834b 100644 --- a/x-pack/plugins/canvas/public/services/workpad.ts +++ b/x-pack/plugins/canvas/public/services/workpad.ts @@ -17,7 +17,6 @@ export interface WorkpadFindResponse { export interface TemplateFindResponse { templates: CanvasTemplate[]; } - export interface CanvasWorkpadService { get: (id: string) => Promise; create: (workpad: CanvasWorkpad) => Promise; diff --git a/x-pack/plugins/canvas/public/state/actions/elements.js b/x-pack/plugins/canvas/public/state/actions/elements.js index ac5d768de53b9..a8302cf094016 100644 --- a/x-pack/plugins/canvas/public/state/actions/elements.js +++ b/x-pack/plugins/canvas/public/state/actions/elements.js @@ -22,7 +22,7 @@ import { getDefaultElement } from '../defaults'; import { ErrorStrings } from '../../../i18n'; import { runInterpreter, interpretAst } from '../../lib/run_interpreter'; import { subMultitree } from '../../lib/aeroelastic/functional'; -import { services } from '../../services'; +import { pluginServices } from '../../services'; import { selectToplevelNodes } from './transient'; import * as args from './resolved_args'; @@ -144,7 +144,8 @@ const fetchRenderableWithContextFn = ({ dispatch, getState }, element, ast, cont dispatch(getAction(renderable)); }) .catch((err) => { - services.notify.getService().error(err); + const notifyService = pluginServices.getServices().notify; + notifyService.error(err); dispatch(getAction(err)); }); }; @@ -188,7 +189,8 @@ export const fetchAllRenderables = createThunk( return runInterpreter(ast, null, variables, { castToRender: true }) .then((renderable) => ({ path: argumentPath, value: renderable })) .catch((err) => { - services.notify.getService().error(err); + const notifyService = pluginServices.getServices().notify; + notifyService.error(err); return { path: argumentPath, value: err }; }); }); @@ -307,7 +309,8 @@ const setAst = createThunk('setAst', ({ dispatch }, ast, element, pageId, doRend const expression = toExpression(ast); dispatch(setExpression(expression, element.id, pageId, doRender)); } catch (err) { - services.notify.getService().error(err); + const notifyService = pluginServices.getServices().notify; + notifyService.error(err); // TODO: remove this, may have been added just to cause a re-render, but why? dispatch(setExpression(element.expression, element.id, pageId, doRender)); diff --git a/x-pack/plugins/canvas/public/state/middleware/es_persist.js b/x-pack/plugins/canvas/public/state/middleware/es_persist.js index 61a2e612215b5..17d0c9649b912 100644 --- a/x-pack/plugins/canvas/public/state/middleware/es_persist.js +++ b/x-pack/plugins/canvas/public/state/middleware/es_persist.js @@ -15,7 +15,7 @@ import { setAssets, resetAssets } from '../actions/assets'; import * as transientActions from '../actions/transient'; import * as resolvedArgsActions from '../actions/resolved_args'; import { update, updateAssets, updateWorkpad } from '../../lib/workpad_service'; -import { services } from '../../services'; +import { pluginServices } from '../../services'; import { canUserWrite } from '../selectors/app'; const { esPersist: strings } = ErrorStrings; @@ -61,17 +61,19 @@ export const esPersistMiddleware = ({ getState }) => { const notifyError = (err) => { const statusCode = err.response && err.response.status; + const notifyService = pluginServices.getServices().notify; + switch (statusCode) { case 400: - return services.notify.getService().error(err.response, { + return notifyService.error(err.response, { title: strings.getSaveFailureTitle(), }); case 413: - return services.notify.getService().error(strings.getTooLargeErrorMessage(), { + return notifyService.error(strings.getTooLargeErrorMessage(), { title: strings.getSaveFailureTitle(), }); default: - return services.notify.getService().error(err, { + return notifyService.error(err, { title: strings.getUpdateFailureTitle(), }); } diff --git a/x-pack/plugins/canvas/storybook/preview.ts b/x-pack/plugins/canvas/storybook/preview.ts index 266ff767c566a..8eae76abaf415 100644 --- a/x-pack/plugins/canvas/storybook/preview.ts +++ b/x-pack/plugins/canvas/storybook/preview.ts @@ -5,7 +5,6 @@ * 2.0. */ -import { action } from '@storybook/addon-actions'; import { addParameters } from '@storybook/react'; import { startServices } from '../public/services/stubs'; @@ -14,14 +13,7 @@ import { addDecorators } from './decorators'; // Import Canvas CSS import '../public/style/index.scss'; -startServices({ - notify: { - success: (message) => action(`success: ${message}`)(), - error: (message) => action(`error: ${message}`)(), - info: (message) => action(`info: ${message}`)(), - warning: (message) => action(`warning: ${message}`)(), - }, -}); +startServices(); addDecorators(); addParameters({ From 9dc303a4c6eda6d8957eb29fafacfefee5ec8c81 Mon Sep 17 00:00:00 2001 From: Jonathan Buttner <56361221+jonathan-buttner@users.noreply.github.com> Date: Thu, 1 Jul 2021 12:12:00 -0400 Subject: [PATCH 48/51] Addressing feedback for the migrations (#104104) --- .../tests/common/configure/migrations.ts | 15 ++++++- .../tests/common/connectors/migrations.ts | 39 ------------------- .../tests/common/migrations.ts | 1 - 3 files changed, 13 insertions(+), 42 deletions(-) delete mode 100644 x-pack/test/case_api_integration/security_and_spaces/tests/common/connectors/migrations.ts diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/common/configure/migrations.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/common/configure/migrations.ts index bf64500a88068..67eb23a43f397 100644 --- a/x-pack/test/case_api_integration/security_and_spaces/tests/common/configure/migrations.ts +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/common/configure/migrations.ts @@ -11,12 +11,13 @@ import { CASE_CONFIGURE_URL, SECURITY_SOLUTION_OWNER, } from '../../../../../../plugins/cases/common/constants'; -import { getConfiguration } from '../../../../common/lib/utils'; +import { getConfiguration, getConnectorMappingsFromES } from '../../../../common/lib/utils'; // eslint-disable-next-line import/no-default-export -export default function createGetTests({ getService }: FtrProviderContext) { +export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const esArchiver = getService('esArchiver'); + const es = getService('es'); describe('migrations', () => { describe('7.10.0', () => { @@ -64,6 +65,16 @@ export default function createGetTests({ getService }: FtrProviderContext) { expect(configuration[0].owner).to.be(SECURITY_SOLUTION_OWNER); }); + + it('adds the owner field to the connector mapping', async () => { + // We don't get the owner field back from the mappings when we retrieve the configuration so the only way to + // check that the migration worked is by checking the saved object stored in Elasticsearch directly + const mappings = await getConnectorMappingsFromES({ es }); + expect(mappings.body.hits.hits.length).to.be(1); + expect(mappings.body.hits.hits[0]._source?.['cases-connector-mappings'].owner).to.eql( + SECURITY_SOLUTION_OWNER + ); + }); }); }); } diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/common/connectors/migrations.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/common/connectors/migrations.ts deleted file mode 100644 index 863c565b4ab08..0000000000000 --- a/x-pack/test/case_api_integration/security_and_spaces/tests/common/connectors/migrations.ts +++ /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 expect from '@kbn/expect'; -import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; -import { SECURITY_SOLUTION_OWNER } from '../../../../../../plugins/cases/common/constants'; -import { getConnectorMappingsFromES } from '../../../../common/lib/utils'; - -// eslint-disable-next-line import/no-default-export -export default function createGetTests({ getService }: FtrProviderContext) { - const es = getService('es'); - const esArchiver = getService('esArchiver'); - - describe('migrations', () => { - describe('7.13.2', () => { - before(async () => { - await esArchiver.load('x-pack/test/functional/es_archives/cases/migrations/7.13.2'); - }); - - after(async () => { - await esArchiver.unload('x-pack/test/functional/es_archives/cases/migrations/7.13.2'); - }); - - it('adds the owner field', async () => { - // We don't get the owner field back from the mappings when we retrieve the configuration so the only way to - // check that the migration worked is by checking the saved object stored in Elasticsearch directly - const mappings = await getConnectorMappingsFromES({ es }); - expect(mappings.body.hits.hits.length).to.be(1); - expect(mappings.body.hits.hits[0]._source?.['cases-connector-mappings'].owner).to.eql( - SECURITY_SOLUTION_OWNER - ); - }); - }); - }); -} diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/common/migrations.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/common/migrations.ts index 810fecc127d08..122eeee411431 100644 --- a/x-pack/test/case_api_integration/security_and_spaces/tests/common/migrations.ts +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/common/migrations.ts @@ -15,6 +15,5 @@ export default ({ loadTestFile }: FtrProviderContext): void => { loadTestFile(require.resolve('./comments/migrations')); loadTestFile(require.resolve('./configure/migrations')); loadTestFile(require.resolve('./user_actions/migrations')); - loadTestFile(require.resolve('./connectors/migrations')); }); }; From bc99bb03997157be32ba6020fbb8adf68d190db0 Mon Sep 17 00:00:00 2001 From: Dominique Clarke Date: Thu, 1 Jul 2021 12:46:45 -0400 Subject: [PATCH 49/51] [Uptime] update panels with hasBorder prop (#103752) * update panels with hasBorder prop * remove panels where unnecessary --- .../responsive_wrapper.test.tsx.snap | 221 ------------------ .../higher_order/responsive_wrapper.test.tsx | 10 +- .../higher_order/responsive_wrapper.tsx | 4 +- .../monitor_duration/monitor_duration.tsx | 2 +- .../monitor/ping_list/ping_list.tsx | 2 +- .../monitor/status_details/status_details.tsx | 2 +- .../step_detail/step_detail_container.tsx | 6 +- .../data_or_index_missing.test.tsx.snap | 92 -------- .../data_or_index_missing.test.tsx | 7 +- .../empty_state/data_or_index_missing.tsx | 2 +- .../empty_state/empty_state_error.tsx | 2 +- .../__snapshots__/monitor_list.test.tsx.snap | 2 +- .../overview/monitor_list/monitor_list.tsx | 2 +- .../components/overview/status_panel.tsx | 2 +- .../synthetics/check_steps/steps_list.tsx | 12 +- .../plugins/uptime/public/pages/settings.tsx | 5 +- 16 files changed, 27 insertions(+), 346 deletions(-) delete mode 100644 x-pack/plugins/uptime/public/components/common/higher_order/__snapshots__/responsive_wrapper.test.tsx.snap delete mode 100644 x-pack/plugins/uptime/public/components/overview/empty_state/__snapshots__/data_or_index_missing.test.tsx.snap diff --git a/x-pack/plugins/uptime/public/components/common/higher_order/__snapshots__/responsive_wrapper.test.tsx.snap b/x-pack/plugins/uptime/public/components/common/higher_order/__snapshots__/responsive_wrapper.test.tsx.snap deleted file mode 100644 index 65b6d7cc39e55..0000000000000 --- a/x-pack/plugins/uptime/public/components/common/higher_order/__snapshots__/responsive_wrapper.test.tsx.snap +++ /dev/null @@ -1,221 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`ResponsiveWrapper HOC is not responsive when prop is false 1`] = ` - - - -`; - -exports[`ResponsiveWrapper HOC renders a responsive wrapper 1`] = ` - - - -`; diff --git a/x-pack/plugins/uptime/public/components/common/higher_order/responsive_wrapper.test.tsx b/x-pack/plugins/uptime/public/components/common/higher_order/responsive_wrapper.test.tsx index 5a3dca171b206..db254fcb56081 100644 --- a/x-pack/plugins/uptime/public/components/common/higher_order/responsive_wrapper.test.tsx +++ b/x-pack/plugins/uptime/public/components/common/higher_order/responsive_wrapper.test.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { shallowWithIntl } from '@kbn/test/jest'; +import { render } from '../../../lib/helper/rtl_helpers'; import { withResponsiveWrapper } from './responsive_wrapper'; interface Prop { @@ -20,12 +20,12 @@ describe('ResponsiveWrapper HOC', () => { }); it('renders a responsive wrapper', () => { - const component = shallowWithIntl(); - expect(component).toMatchSnapshot(); + const { getByTestId } = render(); + expect(getByTestId('uptimeWithResponsiveWrapper--wrapper')).toBeInTheDocument(); }); it('is not responsive when prop is false', () => { - const component = shallowWithIntl(); - expect(component).toMatchSnapshot(); + const { getByTestId } = render(); + expect(getByTestId('uptimeWithResponsiveWrapper--panel')).toBeInTheDocument(); }); }); diff --git a/x-pack/plugins/uptime/public/components/common/higher_order/responsive_wrapper.tsx b/x-pack/plugins/uptime/public/components/common/higher_order/responsive_wrapper.tsx index 6802682db5f56..0e33cc3e38f03 100644 --- a/x-pack/plugins/uptime/public/components/common/higher_order/responsive_wrapper.tsx +++ b/x-pack/plugins/uptime/public/components/common/higher_order/responsive_wrapper.tsx @@ -32,11 +32,11 @@ export const withResponsiveWrapper =

( Component: FC

): FC => ({ isResponsive, ...rest }: ResponsiveWrapperProps) => isResponsive ? ( - + ) : ( - + ); diff --git a/x-pack/plugins/uptime/public/components/monitor/monitor_duration/monitor_duration.tsx b/x-pack/plugins/uptime/public/components/monitor/monitor_duration/monitor_duration.tsx index 86602a064b9d4..9ce5a509bdd52 100644 --- a/x-pack/plugins/uptime/public/components/monitor/monitor_duration/monitor_duration.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/monitor_duration/monitor_duration.tsx @@ -34,7 +34,7 @@ export const MonitorDurationComponent = ({ hasMLJob, }: DurationChartProps) => { return ( - + diff --git a/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list.tsx b/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list.tsx index b9ad176b8ed76..06c7ab7bff843 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list.tsx @@ -251,7 +251,7 @@ export const PingList = () => { }; return ( - + + 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 df8f5dff59dc2..610107f406306 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 @@ -5,7 +5,7 @@ * 2.0. */ -import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiText, EuiLoadingSpinner } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiText, EuiLoadingSpinner } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { useEffect, useCallback, useMemo } from 'react'; import { useSelector, useDispatch } from 'react-redux'; @@ -104,7 +104,7 @@ export const StepDetailContainer: React.FC = ({ checkGroup, stepIndex }) : [], }} > - + <> {(!journey || journey.loading) && ( @@ -124,7 +124,7 @@ export const StepDetailContainer: React.FC = ({ checkGroup, stepIndex }) {journey && activeStep && !journey.loading && ( )} - + ); }; 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 deleted file mode 100644 index 45e40f71c0fde..0000000000000 --- a/x-pack/plugins/uptime/public/components/overview/empty_state/__snapshots__/data_or_index_missing.test.tsx.snap +++ /dev/null @@ -1,92 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`DataOrIndexMissing component renders headingMessage 1`] = ` - - - - - - - - - - - - - - - - - } - body={ - -

- -

-

- -

- - } - iconType="logoUptime" - title={ - -

- - heartbeat-* - , - } - } - /> -

-
- } - /> - -
-
-`; diff --git a/x-pack/plugins/uptime/public/components/overview/empty_state/data_or_index_missing.test.tsx b/x-pack/plugins/uptime/public/components/overview/empty_state/data_or_index_missing.test.tsx index c6898971a693e..caff055ce987c 100644 --- a/x-pack/plugins/uptime/public/components/overview/empty_state/data_or_index_missing.test.tsx +++ b/x-pack/plugins/uptime/public/components/overview/empty_state/data_or_index_missing.test.tsx @@ -6,8 +6,9 @@ */ import React from 'react'; -import { shallowWithIntl } from '@kbn/test/jest'; +import { screen } from '@testing-library/react'; import { FormattedMessage } from '@kbn/i18n/react'; +import { render } from '../../../lib/helper/rtl_helpers'; import { DataOrIndexMissing } from './data_or_index_missing'; describe('DataOrIndexMissing component', () => { @@ -19,7 +20,7 @@ describe('DataOrIndexMissing component', () => { values={{ indexName: heartbeat-* }} /> ); - const component = shallowWithIntl(); - expect(component).toMatchSnapshot(); + render(); + expect(screen.getByText(/heartbeat-*/)).toBeInTheDocument(); }); }); 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 7f9839ff94dbe..44e55de990bbf 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 @@ -30,7 +30,7 @@ export const DataOrIndexMissing = ({ headingMessage, settings }: DataMissingProp - + { return ( - +
+ ( - + diff --git a/x-pack/plugins/uptime/public/components/synthetics/check_steps/steps_list.tsx b/x-pack/plugins/uptime/public/components/synthetics/check_steps/steps_list.tsx index 47b89e82dc5c7..0da6f034e53bb 100644 --- a/x-pack/plugins/uptime/public/components/synthetics/check_steps/steps_list.tsx +++ b/x-pack/plugins/uptime/public/components/synthetics/check_steps/steps_list.tsx @@ -5,13 +5,7 @@ * 2.0. */ -import { - EuiBasicTable, - EuiBasicTableColumn, - EuiButtonIcon, - EuiPanel, - EuiTitle, -} from '@elastic/eui'; +import { EuiBasicTable, EuiBasicTableColumn, EuiButtonIcon, EuiTitle } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { MouseEvent } from 'react'; import styled from 'styled-components'; @@ -147,7 +141,7 @@ export const StepsList = ({ data, error, loading }: Props) => { }; return ( - + <>

{statusMessage( @@ -176,6 +170,6 @@ export const StepsList = ({ data, error, loading }: Props) => { tableLayout={'auto'} rowProps={getRowProps} /> - + ); }; diff --git a/x-pack/plugins/uptime/public/pages/settings.tsx b/x-pack/plugins/uptime/public/pages/settings.tsx index 5f2699240425a..88bae5536c05f 100644 --- a/x-pack/plugins/uptime/public/pages/settings.tsx +++ b/x-pack/plugins/uptime/public/pages/settings.tsx @@ -13,7 +13,6 @@ import { EuiFlexGroup, EuiFlexItem, EuiForm, - EuiPanel, EuiSpacer, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -148,7 +147,7 @@ export const SettingsPage: React.FC = () => { ); return ( - + <> {cannotEditNotice} @@ -213,6 +212,6 @@ export const SettingsPage: React.FC = () => {

-
+ ); }; From 03c713123c027f54af4f535c2ef41c988035d8a5 Mon Sep 17 00:00:00 2001 From: Pete Harverson Date: Thu, 1 Jul 2021 17:57:33 +0100 Subject: [PATCH 50/51] [ML] Data visualizer: Removes experimental badge from file data visualizer (#104075) * [ML] Data visualizer: Removes experimental badge from file data visualizer * [ML] Remove experimental badge scss import --- .../application/common/components/_index.scss | 1 - .../_experimental_badge.scss | 7 --- .../components/experimental_badge/_index.scss | 1 - .../experimental_badge/experimental_badge.tsx | 28 ------------ .../components/experimental_badge/index.ts | 8 ---- .../about_panel/welcome_content.tsx | 44 +------------------ .../components/import_view/import_view.js | 10 ----- .../datavisualizer_selector.tsx | 13 ------ .../translations/translations/ja-JP.json | 7 --- .../translations/translations/zh-CN.json | 7 --- 10 files changed, 2 insertions(+), 124 deletions(-) delete mode 100644 x-pack/plugins/data_visualizer/public/application/common/components/experimental_badge/_experimental_badge.scss delete mode 100644 x-pack/plugins/data_visualizer/public/application/common/components/experimental_badge/_index.scss delete mode 100644 x-pack/plugins/data_visualizer/public/application/common/components/experimental_badge/experimental_badge.tsx delete mode 100644 x-pack/plugins/data_visualizer/public/application/common/components/experimental_badge/index.ts diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/_index.scss b/x-pack/plugins/data_visualizer/public/application/common/components/_index.scss index f57abbbe6396b..02a8766b3d24c 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/_index.scss +++ b/x-pack/plugins/data_visualizer/public/application/common/components/_index.scss @@ -1,4 +1,3 @@ @import 'embedded_map/index'; -@import 'experimental_badge/index'; @import 'stats_table/index'; @import 'top_values/top_values'; diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/experimental_badge/_experimental_badge.scss b/x-pack/plugins/data_visualizer/public/application/common/components/experimental_badge/_experimental_badge.scss deleted file mode 100644 index 8b21620542ff7..0000000000000 --- a/x-pack/plugins/data_visualizer/public/application/common/components/experimental_badge/_experimental_badge.scss +++ /dev/null @@ -1,7 +0,0 @@ -.experimental-badge.euiBetaBadge { - font-size: 10px; - vertical-align: middle; - margin-bottom: 5px; - padding: 0 20px; - line-height: 20px; -} diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/experimental_badge/_index.scss b/x-pack/plugins/data_visualizer/public/application/common/components/experimental_badge/_index.scss deleted file mode 100644 index 9e25affd5e5f6..0000000000000 --- a/x-pack/plugins/data_visualizer/public/application/common/components/experimental_badge/_index.scss +++ /dev/null @@ -1 +0,0 @@ -@import 'experimental_badge' diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/experimental_badge/experimental_badge.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/experimental_badge/experimental_badge.tsx deleted file mode 100644 index 9c39ee54a2a86..0000000000000 --- a/x-pack/plugins/data_visualizer/public/application/common/components/experimental_badge/experimental_badge.tsx +++ /dev/null @@ -1,28 +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 { FormattedMessage } from '@kbn/i18n/react'; -import React, { FC } from 'react'; - -import { EuiBetaBadge } from '@elastic/eui'; - -export const ExperimentalBadge: FC<{ tooltipContent: string }> = ({ tooltipContent }) => { - return ( - - - } - tooltipContent={tooltipContent} - /> - - ); -}; diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/experimental_badge/index.ts b/x-pack/plugins/data_visualizer/public/application/common/components/experimental_badge/index.ts deleted file mode 100644 index 94203c2b156af..0000000000000 --- a/x-pack/plugins/data_visualizer/public/application/common/components/experimental_badge/index.ts +++ /dev/null @@ -1,8 +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 { ExperimentalBadge } from './experimental_badge'; diff --git a/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/about_panel/welcome_content.tsx b/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/about_panel/welcome_content.tsx index 86b869fe06fa1..7b091e699b617 100644 --- a/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/about_panel/welcome_content.tsx +++ b/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/about_panel/welcome_content.tsx @@ -7,30 +7,12 @@ import { FormattedMessage } from '@kbn/i18n/react'; import React, { FC } from 'react'; -import { i18n } from '@kbn/i18n'; -import { - EuiFlexGroup, - EuiFlexItem, - EuiIcon, - EuiLink, - EuiSpacer, - EuiText, - EuiTitle, -} from '@elastic/eui'; - -import { ExperimentalBadge } from '../../../common/components/experimental_badge'; +import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui'; import { useDataVisualizerKibana } from '../../../kibana_context'; export const WelcomeContent: FC = () => { - const toolTipContent = i18n.translate( - 'xpack.dataVisualizer.file.welcomeContent.experimentalFeatureTooltip', - { - defaultMessage: "Experimental feature. We'd love to hear your feedback.", - } - ); - const { services: { fileUpload: { getMaxBytesFormatted }, @@ -48,10 +30,7 @@ export const WelcomeContent: FC = () => {

, - }} + defaultMessage="Visualize data from a log file" />

@@ -132,25 +111,6 @@ export const WelcomeContent: FC = () => { />

- - -

- - GitHub - - ), - }} - /> -

-
); diff --git a/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/import_view/import_view.js b/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/import_view/import_view.js index 74a3638f555d0..232a32c75dc29 100644 --- a/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/import_view/import_view.js +++ b/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/import_view/import_view.js @@ -31,7 +31,6 @@ import { addCombinedFieldsToMappings, getDefaultCombinedFields, } from '../../../common/components/combined_fields'; -import { ExperimentalBadge } from '../../../common/components/experimental_badge'; const DEFAULT_TIME_FIELD = '@timestamp'; const DEFAULT_INDEX_SETTINGS = { number_of_shards: 1 }; @@ -510,15 +509,6 @@ export class ImportView extends Component { id="xpack.dataVisualizer.file.importView.importDataTitle" defaultMessage="Import data" /> -   - - } - /> diff --git a/x-pack/plugins/ml/public/application/datavisualizer/datavisualizer_selector.tsx b/x-pack/plugins/ml/public/application/datavisualizer/datavisualizer_selector.tsx index 3b3b1af30610d..f9df1b452f475 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/datavisualizer_selector.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/datavisualizer_selector.tsx @@ -20,7 +20,6 @@ import { EuiText, EuiTitle, } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { isFullLicense } from '../license'; @@ -122,18 +121,6 @@ export const DatavisualizerSelector: FC = () => { values={{ maxFileSize }} /> } - betaBadgeLabel={i18n.translate( - 'xpack.ml.datavisualizer.selector.experimentalBadgeLabel', - { - defaultMessage: 'Experimental', - } - )} - betaBadgeTooltipContent={ - - } footer={ Date: Thu, 1 Jul 2021 13:07:08 -0400 Subject: [PATCH 51/51] [Docs] Add documentation on multiple tenants (#103125) --- docs/spaces/index.asciidoc | 2 ++ docs/user/security/authorization/index.asciidoc | 16 +++++++++++++++- .../how-to-secure-access-to-kibana.asciidoc | 2 +- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/docs/spaces/index.asciidoc b/docs/spaces/index.asciidoc index 81f3945779503..8eea3b1ee4552 100644 --- a/docs/spaces/index.asciidoc +++ b/docs/spaces/index.asciidoc @@ -30,6 +30,8 @@ Kibana supports spaces in several ways. You can: The `kibana_admin` role or equivilent is required to manage **Spaces**. +TIP: Looking to support multiple tenants? See <> for more information. + [float] [[spaces-managing]] === View, create, and delete spaces diff --git a/docs/user/security/authorization/index.asciidoc b/docs/user/security/authorization/index.asciidoc index c62f137f98528..523a90bdf07ce 100644 --- a/docs/user/security/authorization/index.asciidoc +++ b/docs/user/security/authorization/index.asciidoc @@ -6,7 +6,21 @@ The Elastic Stack comes with the `kibana_admin` {ref}/built-in-roles.html[built- When you assign a user multiple roles, the user receives a union of the roles’ privileges. Therefore, assigning the `kibana_admin` role in addition to a custom role that grants {kib} privileges is ineffective because `kibana_admin` has access to all the features in all spaces. -NOTE: When running multiple tenants of {kib} by changing the `kibana.index` in your `kibana.yml`, you cannot use `kibana_admin` to grant access. You must create custom roles that authorize the user for that specific tenant. Although multi-tenant installations are supported, the recommended approach to securing access to {kib} segments is to grant users access to specific spaces. +[[xpack-security-multiple-tenants]] +==== Supporting multiple tenants + +There are two approaches to supporting multi-tenancy in {kib}: + +1. *Recommended:* Create a space and a limited role for each tenant, and configure each user with the appropriate role. See +<> for more details. +2. deprecated:[7.13.0,"In 8.0 and later, the `kibana.index` setting will no longer be supported."] Set up separate {kib} instances to work +with a single {es} cluster by changing the `kibana.index` setting in your `kibana.yml` file. ++ +NOTE: When using multiple {kib} instances this way, you cannot use the `kibana_admin` role to grant access. You must create custom roles +that authorize the user for each specific instance. + +Whichever approach you use, be careful when granting cluster privileges and index privileges. Both of these approaches share the same {es} +cluster, and {kib} spaces do not prevent you from granting users of two different tenants access to the same index. [role="xpack"] [[xpack-kibana-role-management]] diff --git a/docs/user/security/tutorials/how-to-secure-access-to-kibana.asciidoc b/docs/user/security/tutorials/how-to-secure-access-to-kibana.asciidoc index 63b83712e3e6e..199f138347fa0 100644 --- a/docs/user/security/tutorials/how-to-secure-access-to-kibana.asciidoc +++ b/docs/user/security/tutorials/how-to-secure-access-to-kibana.asciidoc @@ -11,7 +11,7 @@ This guide introduces you to three of {kib}'s security features: spaces, roles, [float] === Spaces -Do you have multiple teams using {kib}? Do you want a “playground” to experiment with new visualizations or alerts? If so, then <> can help. +Do you have multiple teams or tenants using {kib}? Do you want a “playground” to experiment with new visualizations or alerts? If so, then <> can help. Think of a space as another instance of {kib}. A space allows you to organize your <>, <>, <>, and much more into their own categories. For example, you might have a Marketing space for your marketeers to track the results of their campaigns, and an Engineering space for your developers to {apm-get-started-ref}/overview.html[monitor application performance].