From ab43a29f01cabc2ccff37348e9955fec74a40567 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20Haro?= Date: Tue, 10 May 2022 14:04:45 +0200 Subject: [PATCH 01/14] [EBT] Track "click" events (#131755) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../public/analytics/analytics_service.ts | 2 + .../public/analytics/track_clicks.test.ts | 96 +++++++++++++++++++ src/core/public/analytics/track_clicks.ts | 77 +++++++++++++++ .../from_the_browser/click.ts | 33 +++++++ .../from_the_browser/index.ts | 1 + 5 files changed, 209 insertions(+) create mode 100644 src/core/public/analytics/track_clicks.test.ts create mode 100644 src/core/public/analytics/track_clicks.ts create mode 100644 test/analytics/tests/instrumented_events/from_the_browser/click.ts diff --git a/src/core/public/analytics/analytics_service.ts b/src/core/public/analytics/analytics_service.ts index 723122ffbaef2..f1c00b293808b 100644 --- a/src/core/public/analytics/analytics_service.ts +++ b/src/core/public/analytics/analytics_service.ts @@ -9,6 +9,7 @@ import type { AnalyticsClient } from '@kbn/analytics-client'; import { createAnalytics } from '@kbn/analytics-client'; import { of } from 'rxjs'; +import { trackClicks } from './track_clicks'; import { InjectedMetadataSetup } from '../injected_metadata'; import { CoreContext } from '../core_system'; import { getSessionId } from './get_session_id'; @@ -53,6 +54,7 @@ export class AnalyticsService { // and can benefit other consumers of the client. this.registerSessionIdContext(); this.registerBrowserInfoAnalyticsContext(); + trackClicks(this.analyticsClient, core.env.mode.dev); } public setup({ injectedMetadata }: AnalyticsServiceSetupDeps): AnalyticsServiceSetup { diff --git a/src/core/public/analytics/track_clicks.test.ts b/src/core/public/analytics/track_clicks.test.ts new file mode 100644 index 0000000000000..db1b8fc215cf8 --- /dev/null +++ b/src/core/public/analytics/track_clicks.test.ts @@ -0,0 +1,96 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { firstValueFrom, ReplaySubject } from 'rxjs'; +import { analyticsClientMock } from './analytics_service.test.mocks'; +import { trackClicks } from './track_clicks'; +import { take } from 'rxjs/operators'; + +describe('trackClicks', () => { + const addEventListenerSpy = jest.spyOn(window, 'addEventListener'); + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('registers the analytics event type and a listener to the "click" events', () => { + trackClicks(analyticsClientMock, true); + + expect(analyticsClientMock.registerEventType).toHaveBeenCalledTimes(1); + expect(analyticsClientMock.registerEventType).toHaveBeenCalledWith( + expect.objectContaining({ + eventType: 'click', + }) + ); + expect(addEventListenerSpy).toHaveBeenCalledTimes(1); + expect(addEventListenerSpy).toHaveBeenCalledWith('click', expect.any(Function), undefined); + }); + + test('reports an analytics event when a click event occurs', async () => { + // Gather an actual "click" event + const event$ = new ReplaySubject(1); + const parent = document.createElement('div'); + parent.setAttribute('data-test-subj', 'test-click-target-parent'); + const element = document.createElement('button'); + parent.appendChild(element); + element.setAttribute('data-test-subj', 'test-click-target'); + element.innerText = 'test'; // Only to validate that it is not included in the event. + element.value = 'test'; // Only to validate that it is not included in the event. + element.addEventListener('click', (e) => event$.next(e)); + element.click(); + // Using an observable because the event might not be immediately available + const event = await firstValueFrom(event$.pipe(take(1))); + event$.complete(); // No longer needed + + trackClicks(analyticsClientMock, true); + expect(addEventListenerSpy).toHaveBeenCalledTimes(1); + + (addEventListenerSpy.mock.calls[0][1] as EventListener)(event); + expect(analyticsClientMock.reportEvent).toHaveBeenCalledTimes(1); + expect(analyticsClientMock.reportEvent).toHaveBeenCalledWith('click', { + target: [ + 'DIV', + 'data-test-subj=test-click-target-parent', + 'BUTTON', + 'data-test-subj=test-click-target', + ], + }); + }); + + test('handles any processing errors logging them in dev mode', async () => { + trackClicks(analyticsClientMock, true); + expect(addEventListenerSpy).toHaveBeenCalledTimes(1); + + // A basic MouseEvent does not have a target and will fail the logic, making it go to the catch branch as intended. + (addEventListenerSpy.mock.calls[0][1] as EventListener)(new MouseEvent('click')); + expect(analyticsClientMock.reportEvent).toHaveBeenCalledTimes(0); + expect(consoleErrorSpy).toHaveBeenCalledTimes(1); + expect(consoleErrorSpy.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "Failed to report the click event", + Object { + "error": [TypeError: Cannot read properties of null (reading 'parentElement')], + "event": MouseEvent { + "isTrusted": false, + }, + }, + ] + `); + }); + + test('swallows any processing errors when not in dev mode', async () => { + trackClicks(analyticsClientMock, false); + expect(addEventListenerSpy).toHaveBeenCalledTimes(1); + + // A basic MouseEvent does not have a target and will fail the logic, making it go to the catch branch as intended. + (addEventListenerSpy.mock.calls[0][1] as EventListener)(new MouseEvent('click')); + expect(analyticsClientMock.reportEvent).toHaveBeenCalledTimes(0); + expect(consoleErrorSpy).toHaveBeenCalledTimes(0); + }); +}); diff --git a/src/core/public/analytics/track_clicks.ts b/src/core/public/analytics/track_clicks.ts new file mode 100644 index 0000000000000..f2ba7c25de768 --- /dev/null +++ b/src/core/public/analytics/track_clicks.ts @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { fromEvent } from 'rxjs'; +import type { AnalyticsClient } from '@kbn/analytics-client'; + +/** HTML attributes that should be skipped from reporting because they might contain user data */ +const POTENTIAL_PII_HTML_ATTRIBUTES = ['value']; + +/** + * Registers the event type "click" in the analytics client. + * Then it listens to all the "click" events in the UI and reports them with the `target` property being a + * full list of the element's and its parents' attributes. This allows + * @param analytics + */ +export function trackClicks(analytics: AnalyticsClient, isDevMode: boolean) { + analytics.registerEventType<{ target: string[] }>({ + eventType: 'click', + schema: { + target: { + type: 'array', + items: { + type: 'keyword', + _meta: { + description: + 'The attributes of the clicked element and all its parents in the form `{attr.name}={attr.value}`. It allows finding the clicked elements by looking up its attributes like "data-test-subj=my-button".', + }, + }, + }, + }, + }); + + // window or document? + // I tested it on multiple browsers and it seems to work the same. + // My assumption is that window captures other documents like iframes as well? + return fromEvent(window, 'click').subscribe((event) => { + try { + const target = event.target as HTMLElement; + analytics.reportEvent('click', { target: getTargetDefinition(target) }); + } catch (error) { + if (isDevMode) { + // Defensively log the error in dev mode to catch any potential bugs. + // eslint-disable-next-line no-console + console.error(`Failed to report the click event`, { event, error }); + } + } + }); +} + +/** + * Returns a list of strings consisting on the tag name and all the attributes of the element. + * Additionally, it recursively walks up the DOM tree to find all the parents' definitions and prepends them to the list. + * + * @example + * From + * ```html + *
+ *
+ *
+ * ``` + * it returns ['DIV', 'data-test-subj=my-parent', 'DIV', 'data-test-subj=my-button'] + * @param target The child node to start from. + */ +function getTargetDefinition(target: HTMLElement): string[] { + return [ + ...(target.parentElement ? getTargetDefinition(target.parentElement) : []), + target.tagName, + ...[...target.attributes] + .filter((attr) => !POTENTIAL_PII_HTML_ATTRIBUTES.includes(attr.name)) + .map((attr) => `${attr.name}=${attr.value}`), + ]; +} diff --git a/test/analytics/tests/instrumented_events/from_the_browser/click.ts b/test/analytics/tests/instrumented_events/from_the_browser/click.ts new file mode 100644 index 0000000000000..7b9816ba13e4e --- /dev/null +++ b/test/analytics/tests/instrumented_events/from_the_browser/click.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../services'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const ebtUIHelper = getService('kibana_ebt_ui'); + const { common } = getPageObjects(['common']); + + describe('General "click"', () => { + beforeEach(async () => { + await common.navigateToApp('home'); + // Just click on the top div and expect it's still there... we're just testing the click event generation + await common.clickAndValidate('kibanaChrome', 'kibanaChrome'); + }); + + it('should emit a "click" event', async () => { + const [event] = await ebtUIHelper.getLastEvents(1, ['click']); + expect(event.event_type).to.eql('click'); + expect(event.properties.target).to.be.an('array'); + const targets = event.properties.target as string[]; + expect(targets.includes('DIV')).to.be(true); + expect(targets.includes('id=kibana-body')).to.be(true); + expect(targets.includes('data-test-subj=kibanaChrome')).to.be(true); + }); + }); +} diff --git a/test/analytics/tests/instrumented_events/from_the_browser/index.ts b/test/analytics/tests/instrumented_events/from_the_browser/index.ts index 69aff97006d72..2fe99373d5214 100644 --- a/test/analytics/tests/instrumented_events/from_the_browser/index.ts +++ b/test/analytics/tests/instrumented_events/from_the_browser/index.ts @@ -11,6 +11,7 @@ import { FtrProviderContext } from '../../../services'; export default function ({ loadTestFile }: FtrProviderContext) { describe('from the browser', () => { // Add tests for UI-instrumented events here: + loadTestFile(require.resolve('./click')); loadTestFile(require.resolve('./loaded_kibana')); loadTestFile(require.resolve('./core_context_providers')); }); From 15d9aaf1084bb90fb9176e6afcbf439c6ce448b6 Mon Sep 17 00:00:00 2001 From: Giorgos Bamparopoulos Date: Tue, 10 May 2022 13:13:16 +0100 Subject: [PATCH 02/14] [Fleet] APM Integration - demote Java agent attacher from beta to experimental (#130875) * Demote Java agent attacher from beta to experimental * Remove unused translations * Add technical preview component Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../agent_instructions_accordion.tsx | 16 ++--------- .../shared/technical_preview_badge.tsx | 27 +++++++++++++++++++ .../translations/translations/fr-FR.json | 2 -- .../translations/translations/ja-JP.json | 2 -- .../translations/translations/zh-CN.json | 2 -- 5 files changed, 29 insertions(+), 20 deletions(-) create mode 100644 x-pack/plugins/apm/public/components/shared/technical_preview_badge.tsx diff --git a/x-pack/plugins/apm/public/components/fleet_integration/apm_agents/agent_instructions_accordion.tsx b/x-pack/plugins/apm/public/components/fleet_integration/apm_agents/agent_instructions_accordion.tsx index 238ffc760d93f..a9ec9778ed3e6 100644 --- a/x-pack/plugins/apm/public/components/fleet_integration/apm_agents/agent_instructions_accordion.tsx +++ b/x-pack/plugins/apm/public/components/fleet_integration/apm_agents/agent_instructions_accordion.tsx @@ -13,7 +13,6 @@ import { EuiText, EuiCodeBlock, EuiTabbedContent, - EuiBetaBadge, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { ComponentType } from 'react'; @@ -32,6 +31,7 @@ import type { } from '../apm_policy_form/typings'; import { getCommands } from '../../../tutorial/config_agent/commands/get_commands'; import { renderMustache } from './render_mustache'; +import { TechnicalPreviewBadge } from '../../shared/technical_preview_badge'; function AccordionButtonContent({ agentName, @@ -240,19 +240,7 @@ export function AgentInstructionsAccordion({ )} - + ), diff --git a/x-pack/plugins/apm/public/components/shared/technical_preview_badge.tsx b/x-pack/plugins/apm/public/components/shared/technical_preview_badge.tsx new file mode 100644 index 0000000000000..7068c9c6fe793 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/technical_preview_badge.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 { EuiBetaBadge } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; + +export function TechnicalPreviewBadge() { + return ( + + ); +} diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 8a30c8bb80de3..704a6fe19ac17 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -7459,8 +7459,6 @@ "xpack.apm.fleetIntegration.apmAgent.editDisacoveryRule.type": "Type", "xpack.apm.fleetIntegration.apmAgent.runtimeAttachment.addRule": "Ajouter une règle", "xpack.apm.fleetIntegration.apmAgent.runtimeAttachment.autoAttachment": "Rattachement automatique", - "xpack.apm.fleetIntegration.apmAgent.runtimeAttachment.betaBadge.label": "BÊTA", - "xpack.apm.fleetIntegration.apmAgent.runtimeAttachment.betaBadge.tooltipContent": "Le rattachement automatique pour Java n'est pas disponible. Nous vous remercions de bien vouloir nous aider en nous signalant tout bug.", "xpack.apm.fleetIntegration.apmAgent.runtimeAttachment.discoveryRules": "Règles de découverte", "xpack.apm.fleetIntegration.apmAgent.runtimeAttachment.editRule.add": "Ajouter", "xpack.apm.fleetIntegration.apmAgent.runtimeAttachment.editRule.helpText": "Choisir parmi les paramètres autorisés", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 907d08f97089b..8fe72dfd4a870 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -7558,8 +7558,6 @@ "xpack.apm.fleetIntegration.apmAgent.editDisacoveryRule.type": "型", "xpack.apm.fleetIntegration.apmAgent.runtimeAttachment.addRule": "ルールを追加", "xpack.apm.fleetIntegration.apmAgent.runtimeAttachment.autoAttachment": "自動接続", - "xpack.apm.fleetIntegration.apmAgent.runtimeAttachment.betaBadge.label": "BETA", - "xpack.apm.fleetIntegration.apmAgent.runtimeAttachment.betaBadge.tooltipContent": "Javaの自動接続は一般公開されていません。不具合が発生したら報告してください。", "xpack.apm.fleetIntegration.apmAgent.runtimeAttachment.discoveryRules": "検出ルール", "xpack.apm.fleetIntegration.apmAgent.runtimeAttachment.editRule.add": "追加", "xpack.apm.fleetIntegration.apmAgent.runtimeAttachment.editRule.helpText": "許可されたパラメーターから選択", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index b91ddb7353c27..6509cfadf3b99 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -7576,8 +7576,6 @@ "xpack.apm.fleetIntegration.apmAgent.editDisacoveryRule.type": "类型", "xpack.apm.fleetIntegration.apmAgent.runtimeAttachment.addRule": "添加规则", "xpack.apm.fleetIntegration.apmAgent.runtimeAttachment.autoAttachment": "自动附件", - "xpack.apm.fleetIntegration.apmAgent.runtimeAttachment.betaBadge.label": "公测版", - "xpack.apm.fleetIntegration.apmAgent.runtimeAttachment.betaBadge.tooltipContent": "用于 Java 的自动附件不是 GA 版。请通过报告错误来帮助我们。", "xpack.apm.fleetIntegration.apmAgent.runtimeAttachment.discoveryRules": "发现规则", "xpack.apm.fleetIntegration.apmAgent.runtimeAttachment.editRule.add": "添加", "xpack.apm.fleetIntegration.apmAgent.runtimeAttachment.editRule.helpText": "从允许的参数中选择", From 96b32e46f0b7502617b6857722894016ed7acd6e Mon Sep 17 00:00:00 2001 From: Corey Robertson Date: Tue, 10 May 2022 08:27:33 -0400 Subject: [PATCH 03/14] [Canvas] Embeddable expression migrations bug (#131825) * Change canvas saved object migrations to be generated at migration time * Adds functional test for Workpad import * Fix check --- .../common/executor/executor.test.ts | 16 ++--- .../expressions/common/executor/executor.ts | 6 +- .../functions/external/embeddable.ts | 10 ++-- .../server/saved_objects/custom_element.ts | 2 +- .../canvas/server/saved_objects/workpad.ts | 2 +- .../server/saved_objects/workpad_template.ts | 2 +- .../apps/canvas/exports/8.2.workpad.ndjson | 2 + x-pack/test/functional/apps/canvas/index.js | 60 ++++++++++--------- .../apps/canvas/migrations_smoke_test.ts | 35 +++++++++++ 9 files changed, 93 insertions(+), 42 deletions(-) create mode 100644 x-pack/test/functional/apps/canvas/exports/8.2.workpad.ndjson create mode 100644 x-pack/test/functional/apps/canvas/migrations_smoke_test.ts diff --git a/src/plugins/expressions/common/executor/executor.test.ts b/src/plugins/expressions/common/executor/executor.test.ts index 35f0b9c13aa1a..ea7116a5307ba 100644 --- a/src/plugins/expressions/common/executor/executor.test.ts +++ b/src/plugins/expressions/common/executor/executor.test.ts @@ -141,13 +141,15 @@ describe('Executor', () => { inject: (state: ExpressionAstFunction['arguments']) => { return injectFn(state); }, - migrations: { - '7.10.0': ((state: ExpressionAstFunction, version: string): ExpressionAstFunction => { - return migrateFn(state, version); - }) as unknown as MigrateFunction, - '7.10.1': ((state: ExpressionAstFunction, version: string): ExpressionAstFunction => { - return migrateFn(state, version); - }) as unknown as MigrateFunction, + migrations: () => { + return { + '7.10.0': ((state: ExpressionAstFunction, version: string): ExpressionAstFunction => { + return migrateFn(state, version); + }) as unknown as MigrateFunction, + '7.10.1': ((state: ExpressionAstFunction, version: string): ExpressionAstFunction => { + return migrateFn(state, version); + }) as unknown as MigrateFunction, + }; }, fn: jest.fn(), }; diff --git a/src/plugins/expressions/common/executor/executor.ts b/src/plugins/expressions/common/executor/executor.ts index 0a5e8d388fe00..4071f8f7f003f 100644 --- a/src/plugins/expressions/common/executor/executor.ts +++ b/src/plugins/expressions/common/executor/executor.ts @@ -336,7 +336,11 @@ export class Executor = Record Object.keys(fn.migrations)) + .map((fn) => { + const migrations = + typeof fn.migrations === 'function' ? fn.migrations() : fn.migrations || {}; + return Object.keys(migrations); + }) .flat(1) ); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/external/embeddable.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/external/embeddable.ts index 2554d2efd1bc9..a74b4c262dd79 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/external/embeddable.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/external/embeddable.ts @@ -163,10 +163,12 @@ export function embeddableFunctionFactory({ return state; }, - migrations: mapValues< - MigrateFunctionsObject, - MigrateFunction - >(embeddablePersistableStateService.getAllMigrations(), migrateByValueEmbeddable), + migrations: () => { + return mapValues< + MigrateFunctionsObject, + MigrateFunction + >(embeddablePersistableStateService.getAllMigrations(), migrateByValueEmbeddable); + }, }; }; } diff --git a/x-pack/plugins/canvas/server/saved_objects/custom_element.ts b/x-pack/plugins/canvas/server/saved_objects/custom_element.ts index db87909fbd44f..c9f9ea8453e5f 100644 --- a/x-pack/plugins/canvas/server/saved_objects/custom_element.ts +++ b/x-pack/plugins/canvas/server/saved_objects/custom_element.ts @@ -32,7 +32,7 @@ export const customElementType = (deps: CanvasSavedObjectTypeMigrationsDeps): Sa '@created': { type: 'date' }, }, }, - migrations: customElementMigrationsFactory(deps), + migrations: () => customElementMigrationsFactory(deps), management: { icon: 'canvasApp', defaultSearchField: 'name', diff --git a/x-pack/plugins/canvas/server/saved_objects/workpad.ts b/x-pack/plugins/canvas/server/saved_objects/workpad.ts index b23ea62f88954..6c392aaaa62b1 100644 --- a/x-pack/plugins/canvas/server/saved_objects/workpad.ts +++ b/x-pack/plugins/canvas/server/saved_objects/workpad.ts @@ -32,7 +32,7 @@ export const workpadTypeFactory = ( '@created': { type: 'date' }, }, }, - migrations: workpadMigrationsFactory(deps), + migrations: () => workpadMigrationsFactory(deps), management: { importableAndExportable: true, icon: 'canvasApp', diff --git a/x-pack/plugins/canvas/server/saved_objects/workpad_template.ts b/x-pack/plugins/canvas/server/saved_objects/workpad_template.ts index ec852113e1ca4..224af9bed6759 100644 --- a/x-pack/plugins/canvas/server/saved_objects/workpad_template.ts +++ b/x-pack/plugins/canvas/server/saved_objects/workpad_template.ts @@ -50,7 +50,7 @@ export const workpadTemplateType = ( }, }, }, - migrations: templateWorkpadMigrationsFactory(deps), + migrations: () => templateWorkpadMigrationsFactory(deps), management: { importableAndExportable: false, icon: 'canvasApp', diff --git a/x-pack/test/functional/apps/canvas/exports/8.2.workpad.ndjson b/x-pack/test/functional/apps/canvas/exports/8.2.workpad.ndjson new file mode 100644 index 0000000000000..b8a2c0f06827f --- /dev/null +++ b/x-pack/test/functional/apps/canvas/exports/8.2.workpad.ndjson @@ -0,0 +1,2 @@ +{"attributes":{"@created":"2022-05-09T15:35:33.151Z","@timestamp":"2022-05-09T15:35:57.797Z","assets":{},"colors":["#37988d","#c19628","#b83c6f","#3f9939","#1785b0","#ca5f35","#45bdb0","#f2bc33","#e74b8b","#4fbf48","#1ea6dc","#fd7643","#72cec3","#f5cc5d","#ec77a8","#7acf74","#4cbce4","#fd986f","#a1ded7","#f8dd91","#f2a4c5","#a6dfa2","#86d2ed","#fdba9f","#000000","#444444","#777777","#BBBBBB","#FFFFFF","rgba(255,255,255,0)"],"css":".canvasPage {\n\n}","height":720,"isWriteable":true,"name":"Test Canvas Workpad","page":0,"pages":[{"elements":[{"expression":"kibana\n| selectFilter\n| demodata\n| pointseries x=\"project\" y=\"sum(price)\" color=\"state\" size=\"size(username)\"\n| plot defaultStyle={seriesStyle points=5 fill=1}\n| render","filter":null,"id":"element-02d3f58d-b35a-42a1-8a91-ee6a6af99b8a","position":{"angle":0,"height":300,"left":229,"parent":null,"top":211,"width":700}}],"groups":[],"id":"page-55e10c30-e5ff-443e-bb9d-47264b477412","style":{"background":"#FFF"},"transition":{}}],"variables":[],"width":1080},"coreMigrationVersion":"8.2.0","id":"workpad-c25be373-fb42-49b5-9515-d13cbc041d46","migrationVersion":{"canvas-workpad":"8.2.0"},"references":[],"type":"canvas-workpad","updated_at":"2022-05-09T15:35:57.822Z","version":"WzEyOSwxXQ=="} +{"excludedObjects":[],"excludedObjectsCount":0,"exportedCount":1,"missingRefCount":0,"missingReferences":[]} \ No newline at end of file diff --git a/x-pack/test/functional/apps/canvas/index.js b/x-pack/test/functional/apps/canvas/index.js index 2c6a46b75e510..b572642f14df6 100644 --- a/x-pack/test/functional/apps/canvas/index.js +++ b/x-pack/test/functional/apps/canvas/index.js @@ -9,35 +9,41 @@ export default function canvasApp({ loadTestFile, getService }) { const security = getService('security'); const esArchiver = getService('esArchiver'); - describe('Canvas app', function canvasAppTestSuite() { - before(async () => { - // init data - await security.testUser.setRoles([ - 'test_logstash_reader', - 'global_canvas_all', - 'global_discover_all', - 'global_maps_all', - // TODO: Fix permission check, save and return button is disabled when dashboard is disabled - 'global_dashboard_all', - ]); - await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/logstash_functional'); - }); + describe('Canvas', function canvasAppTestSuite() { + describe('Canvas app', () => { + before(async () => { + // init data + await security.testUser.setRoles([ + 'test_logstash_reader', + 'global_canvas_all', + 'global_discover_all', + 'global_maps_all', + // TODO: Fix permission check, save and return button is disabled when dashboard is disabled + 'global_dashboard_all', + ]); + await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/logstash_functional'); + }); + + after(async () => { + await security.testUser.restoreDefaults(); + }); - after(async () => { - await security.testUser.restoreDefaults(); + loadTestFile(require.resolve('./smoke_test')); + loadTestFile(require.resolve('./expression')); + loadTestFile(require.resolve('./filters')); + loadTestFile(require.resolve('./custom_elements')); + loadTestFile(require.resolve('./feature_controls/canvas_security')); + loadTestFile(require.resolve('./feature_controls/canvas_spaces')); + loadTestFile(require.resolve('./embeddables/lens')); + loadTestFile(require.resolve('./embeddables/maps')); + loadTestFile(require.resolve('./embeddables/saved_search')); + loadTestFile(require.resolve('./embeddables/visualization')); + loadTestFile(require.resolve('./reports')); + loadTestFile(require.resolve('./saved_object_resolve')); }); - loadTestFile(require.resolve('./smoke_test')); - loadTestFile(require.resolve('./expression')); - loadTestFile(require.resolve('./filters')); - loadTestFile(require.resolve('./custom_elements')); - loadTestFile(require.resolve('./feature_controls/canvas_security')); - loadTestFile(require.resolve('./feature_controls/canvas_spaces')); - loadTestFile(require.resolve('./embeddables/lens')); - loadTestFile(require.resolve('./embeddables/maps')); - loadTestFile(require.resolve('./embeddables/saved_search')); - loadTestFile(require.resolve('./embeddables/visualization')); - loadTestFile(require.resolve('./reports')); - loadTestFile(require.resolve('./saved_object_resolve')); + describe('Canvas management', () => { + loadTestFile(require.resolve('./migrations_smoke_test')); + }); }); } diff --git a/x-pack/test/functional/apps/canvas/migrations_smoke_test.ts b/x-pack/test/functional/apps/canvas/migrations_smoke_test.ts new file mode 100644 index 0000000000000..12a75e5a2ba8b --- /dev/null +++ b/x-pack/test/functional/apps/canvas/migrations_smoke_test.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import path from 'path'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getPageObjects, getService }: FtrProviderContext) { + const PageObjects = getPageObjects(['common', 'settings', 'savedObjects']); + + describe('migration smoke test', function () { + it('imports an 8.2 workpad', async function () { + /* + In 8.1 Canvas introduced by value embeddables, which requires expressions to know about embeddable migrations + Starting in 8.3, we were seeing an error during migration where it would appear that an 8.2 workpad was + from a future version. This was because there were missing embeddable migrations on the expression because + the Canvas plugin was adding the embeddable expression with all of it's migrations before other embeddables had + registered their own migrations. + + This smoke test is intended to import an 8.2 workpad to ensure that we don't hit a similar scenario in the future + */ + await PageObjects.settings.navigateTo(); + await PageObjects.settings.clickKibanaSavedObjects(); + await PageObjects.savedObjects.waitTableIsLoaded(); + await PageObjects.savedObjects.importFile( + path.join(__dirname, 'exports', '8.2.workpad.ndjson') + ); + await PageObjects.savedObjects.checkImportSucceeded(); + await PageObjects.savedObjects.clickImportDone(); + }); + }); +} From 1e1ae429b3e64c13721039545a778f0b6a17dd3d Mon Sep 17 00:00:00 2001 From: Julia Bardi <90178898+juliaElastic@users.noreply.github.com> Date: Tue, 10 May 2022 14:44:47 +0200 Subject: [PATCH 04/14] [Fleet] fixed window code block max width (#131896) * fixed window code block max width * use map instead of switch --- .../public/components/platform_selector.tsx | 47 ++++++++----------- 1 file changed, 19 insertions(+), 28 deletions(-) diff --git a/x-pack/plugins/fleet/public/components/platform_selector.tsx b/x-pack/plugins/fleet/public/components/platform_selector.tsx index ae18f56b4b3ac..0209bf8e31fd6 100644 --- a/x-pack/plugins/fleet/public/components/platform_selector.tsx +++ b/x-pack/plugins/fleet/public/components/platform_selector.tsx @@ -50,6 +50,14 @@ export const PlatformSelector: React.FunctionComponent = ({ /> ); + const commandsByPlatform: Record = { + linux: linuxCommand, + mac: macCommand, + windows: windowsCommand, + deb: linuxDebCommand, + rpm: linuxRpmCommand, + }; + return ( <> {isK8s ? ( @@ -67,39 +75,22 @@ export const PlatformSelector: React.FunctionComponent = ({ })} /> - {platform === 'linux' && ( - - {linuxCommand} - - )} - {platform === 'mac' && ( - - {macCommand} - - )} - {platform === 'windows' && ( - - {windowsCommand} - - )} - {platform === 'deb' && ( - <> - {systemPackageCallout} - - - {linuxDebCommand} - - - )} - {platform === 'rpm' && ( + {(platform === 'deb' || platform === 'rpm') && ( <> {systemPackageCallout} - - {linuxRpmCommand} - )} + + {commandsByPlatform[platform]} + )} From 0fa8b6f6b0bfe12eb0522a967504aaed7f62dad0 Mon Sep 17 00:00:00 2001 From: Alessandro Stoltenberg Date: Tue, 10 May 2022 14:46:10 +0200 Subject: [PATCH 05/14] [ILM] Fixing missing documentation link(#131750) * Missing documentation link in Index Lifecycle policy editor. * Adds missing link to documentation in Index Lifecycle policy editor. --- .../components/phases/shared_fields/snapshot_policies_field.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/snapshot_policies_field.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/snapshot_policies_field.tsx index 62f322e4f48f3..e89189df7667b 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/snapshot_policies_field.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/snapshot_policies_field.tsx @@ -159,7 +159,7 @@ export const SnapshotPoliciesField: React.FunctionComponent = () => { id="xpack.indexLifecycleMgmt.editPolicy.deletePhase.waitForSnapshotDescription" defaultMessage="Specify a snapshot policy to be executed before the deletion of the index. This ensures that a snapshot of the deleted index is available." />{' '} - + } titleSize="xs" From 7bed8e88080175c229bedd47a958154cbe6fdd3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Marcondes?= <55978943+cauemarcondes@users.noreply.github.com> Date: Tue, 10 May 2022 08:47:33 -0400 Subject: [PATCH 06/14] [APM][Fleet] Adding storybook on the APM Edit policy (#131817) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../edit_apm_policy_form.stories.tsx | 272 ++++++++++++++++++ 1 file changed, 272 insertions(+) create mode 100644 x-pack/plugins/apm/public/components/fleet_integration/apm_policy_form/edit_apm_policy_form.stories.tsx diff --git a/x-pack/plugins/apm/public/components/fleet_integration/apm_policy_form/edit_apm_policy_form.stories.tsx b/x-pack/plugins/apm/public/components/fleet_integration/apm_policy_form/edit_apm_policy_form.stories.tsx new file mode 100644 index 0000000000000..e4e6f7062ffdb --- /dev/null +++ b/x-pack/plugins/apm/public/components/fleet_integration/apm_policy_form/edit_apm_policy_form.stories.tsx @@ -0,0 +1,272 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { useState } from 'react'; +import { Meta, Story } from '@storybook/react'; +import { CoreStart } from '@kbn/core/public'; +import { createKibanaReactContext } from '@kbn/kibana-react-plugin/public'; +import { EditAPMPolicyForm } from './edit_apm_policy_form'; +import { NewPackagePolicy, PackagePolicy } from './typings'; + +const coreMock = { + http: { get: async () => ({}) }, + notifications: { toasts: { add: () => {} } }, + uiSettings: { get: () => {} }, +} as unknown as CoreStart; + +const KibanaReactContext = createKibanaReactContext(coreMock); + +const stories: Meta<{}> = { + title: 'fleet/Edit APM policy', + component: EditAPMPolicyForm, + decorators: [ + (StoryComponent) => { + return ( +
+ + + +
+ ); + }, + ], +}; +export default stories; + +export const EditAPMPolicy: Story = () => { + const [newPolicy, setNewPolicy] = useState(policy); + const [isPolicyValid, setIsPolicyValid] = useState(true); + + return ( + <> +
+
+          {`Is Policy valid: ${isPolicyValid} (when false, "Save integration" button is disabled)`}
+        
+
+ { + setIsPolicyValid(value.isValid); + const updatedVars = value.updatedPolicy.inputs?.[0].vars; + setNewPolicy((state) => ({ + ...state, + inputs: [{ ...state.inputs[0], vars: updatedVars }], + })); + }} + /> +
+
+
{JSON.stringify(newPolicy, null, 4)}
+ + ); +}; + +const policy = { + version: 'WzM2OTksMl0=', + name: 'Elastic APM', + namespace: 'default', + enabled: true, + policy_id: 'policy-elastic-agent-on-cloud', + output_id: '', + package: { + name: 'apm', + version: '8.3.0', + title: 'Elastic APM', + }, + elasticsearch: { + privileges: { + cluster: ['cluster:monitor/main'], + }, + }, + inputs: [ + { + type: 'apm', + enabled: true, + config: { + 'apm-server': { + value: { + rum: { + source_mapping: { + metadata: [], + }, + }, + agent_config: [], + }, + }, + }, + streams: [], + vars: { + host: { + type: 'text', + value: '0.0.0.0:8200', + }, + url: { + type: 'text', + value: 'cloud_apm_url_test', + }, + secret_token: { + type: 'text', + value: 'asdfkjhasdf', + }, + api_key_enabled: { + type: 'bool', + value: true, + }, + enable_rum: { + type: 'bool', + value: true, + }, + anonymous_enabled: { + type: 'bool', + value: true, + }, + anonymous_allow_agent: { + type: 'text', + value: ['rum-js', 'js-base', 'iOS/swift'], + }, + anonymous_allow_service: { + type: 'text', + value: '', + }, + anonymous_rate_limit_event_limit: { + type: 'integer', + value: 300, + }, + anonymous_rate_limit_ip_limit: { + type: 'integer', + value: 1000, + }, + default_service_environment: { + type: 'text', + value: '', + }, + rum_allow_origins: { + type: 'text', + value: ['"*"'], + }, + rum_allow_headers: { + type: 'text', + value: '', + }, + rum_response_headers: { + type: 'yaml', + value: '', + }, + rum_library_pattern: { + type: 'text', + value: '"node_modules|bower_components|~"', + }, + rum_exclude_from_grouping: { + type: 'text', + value: '"^/webpack"', + }, + api_key_limit: { + type: 'integer', + value: 100, + }, + max_event_bytes: { + type: 'integer', + value: 307200, + }, + capture_personal_data: { + type: 'bool', + value: true, + }, + max_header_bytes: { + type: 'integer', + value: 1048576, + }, + idle_timeout: { + type: 'text', + value: '45s', + }, + read_timeout: { + type: 'text', + value: '3600s', + }, + shutdown_timeout: { + type: 'text', + value: '30s', + }, + write_timeout: { + type: 'text', + value: '30s', + }, + max_connections: { + type: 'integer', + value: 0, + }, + response_headers: { + type: 'yaml', + value: '', + }, + expvar_enabled: { + type: 'bool', + value: false, + }, + java_attacher_discovery_rules: { + type: 'yaml', + value: '', + }, + java_attacher_agent_version: { + type: 'text', + value: '', + }, + java_attacher_enabled: { + type: 'bool', + value: false, + }, + tls_enabled: { + type: 'bool', + value: false, + }, + tls_certificate: { + type: 'text', + value: '', + }, + tls_key: { + type: 'text', + value: '', + }, + tls_supported_protocols: { + type: 'text', + value: ['TLSv1.0', 'TLSv1.1', 'TLSv1.2'], + }, + tls_cipher_suites: { + type: 'text', + value: '', + }, + tls_curve_types: { + type: 'text', + value: '', + }, + tail_sampling_policies: { + type: 'yaml', + value: '- sample_rate: 0.1\n', + }, + tail_sampling_interval: { + type: 'text', + value: '1m', + }, + tail_sampling_enabled: { + type: 'bool', + value: false, + }, + }, + }, + ], +} as NewPackagePolicy; From 2a51a37bcb94c13350a8fd6066027c5f73abee1d Mon Sep 17 00:00:00 2001 From: Ersin Erdal <92688503+ersin-erdal@users.noreply.github.com> Date: Tue, 10 May 2022 14:49:30 +0200 Subject: [PATCH 07/14] Add debug log on task run end (#131639) * Add debug log on task run end --- .../server/task_running/task_runner.test.ts | 48 +++++++++++++++++++ .../server/task_running/task_runner.ts | 4 +- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/task_manager/server/task_running/task_runner.test.ts b/x-pack/plugins/task_manager/server/task_running/task_runner.test.ts index 73d30b022c626..5cce445e7bb4c 100644 --- a/x-pack/plugins/task_manager/server/task_running/task_runner.test.ts +++ b/x-pack/plugins/task_manager/server/task_running/task_runner.test.ts @@ -1517,6 +1517,54 @@ describe('TaskManagerRunner', () => { `Skipping reschedule for task bar \"${id}\" due to the task expiring` ); }); + + test('Prints debug logs on task start/end', async () => { + const { runner, logger } = await readyToRunStageSetup({ + definitions: { + bar: { + title: 'Bar!', + createTaskRunner: () => ({ + async run() { + return { state: {} }; + }, + }), + }, + }, + }); + await runner.run(); + + expect(logger.debug).toHaveBeenCalledTimes(2); + expect(logger.debug).toHaveBeenNthCalledWith(1, 'Running task bar "foo"', { + tags: ['task:start', 'foo', 'bar'], + }); + expect(logger.debug).toHaveBeenNthCalledWith(2, 'Task bar "foo" ended', { + tags: ['task:end', 'foo', 'bar'], + }); + }); + + test('Prints debug logs on task start/end even if it throws error', async () => { + const { runner, logger } = await readyToRunStageSetup({ + definitions: { + bar: { + title: 'Bar!', + createTaskRunner: () => ({ + async run() { + throw new Error(); + }, + }), + }, + }, + }); + await runner.run(); + + expect(logger.debug).toHaveBeenCalledTimes(2); + expect(logger.debug).toHaveBeenNthCalledWith(1, 'Running task bar "foo"', { + tags: ['task:start', 'foo', 'bar'], + }); + expect(logger.debug).toHaveBeenNthCalledWith(2, 'Task bar "foo" ended', { + tags: ['task:end', 'foo', 'bar'], + }); + }); }); interface TestOpts { diff --git a/x-pack/plugins/task_manager/server/task_running/task_runner.ts b/x-pack/plugins/task_manager/server/task_running/task_runner.ts index b6199f06300f1..d305a49bef55e 100644 --- a/x-pack/plugins/task_manager/server/task_running/task_runner.ts +++ b/x-pack/plugins/task_manager/server/task_running/task_runner.ts @@ -283,7 +283,7 @@ export class TaskManagerRunner implements TaskRunner { }` ); } - this.logger.debug(`Running task ${this}`); + this.logger.debug(`Running task ${this}`, { tags: ['task:start', this.id, this.taskType] }); const apmTrans = apm.startTransaction(this.taskType, TASK_MANAGER_RUN_TRANSACTION_TYPE, { childOf: this.instance.task.traceparent, @@ -324,6 +324,8 @@ export class TaskManagerRunner implements TaskRunner { ); if (apmTrans) apmTrans.end('failure'); return processedResult; + } finally { + this.logger.debug(`Task ${this} ended`, { tags: ['task:end', this.id, this.taskType] }); } } From 57597f76179dcc88360958b5968de11d3c12ee8f Mon Sep 17 00:00:00 2001 From: Andrew Tate Date: Tue, 10 May 2022 08:03:05 -0500 Subject: [PATCH 08/14] [Lens] update default legend size to medium (#130336) --- .../expression_functions/heatmap_legend.ts | 14 ++- .../common/types/expression_functions.ts | 3 +- .../components/heatmap_component.test.tsx | 29 ++++++ .../public/components/heatmap_component.tsx | 6 +- .../mosaic_vis_function.test.ts.snap | 2 +- .../pie_vis_function.test.ts.snap | 4 +- .../treemap_vis_function.test.ts.snap | 2 +- .../waffle_vis_function.test.ts.snap | 2 +- .../common/expression_functions/i18n.ts | 2 +- .../mosaic_vis_function.ts | 12 ++- .../pie_vis_function.test.ts | 3 +- .../expression_functions/pie_vis_function.ts | 12 ++- .../treemap_vis_function.ts | 12 ++- .../waffle_vis_function.ts | 12 ++- .../common/types/expression_renderers.ts | 3 +- .../partition_vis_component.test.tsx.snap | 5 + .../partition_vis_component.test.tsx | 30 ++++++ .../components/partition_vis_component.tsx | 8 +- .../expression_functions/legend_config.ts | 14 ++- .../common/types/expression_functions.ts | 3 +- .../__snapshots__/xy_chart.test.tsx.snap | 7 ++ .../public/components/xy_chart.test.tsx | 32 ++++++ .../public/components/xy_chart.tsx | 6 +- src/plugins/vis_default_editor/kibana.json | 11 +- .../options/legend_size_settings.test.tsx | 83 +++++++++++++++ .../options/legend_size_settings.tsx | 55 +++++----- .../public/editor/components/heatmap.tsx | 8 +- src/plugins/vis_types/heatmap/public/types.ts | 3 +- .../public/__snapshots__/to_ast.test.ts.snap | 3 + .../pie/public/editor/components/pie.tsx | 8 +- .../pie/public/sample_vis.test.mocks.ts | 2 + src/plugins/vis_types/pie/public/to_ast.ts | 6 +- .../public/__snapshots__/to_ast.test.ts.snap | 2 +- .../__snapshots__/to_ast_pie.test.ts.snap | 2 +- .../public/__snapshots__/to_ast.test.ts.snap | 3 + .../options/point_series/point_series.tsx | 10 +- .../public/expression_functions/xy_vis_fn.ts | 20 +++- .../xy/public/sample_vis.test.mocks.ts | 4 + .../vis_types/xy/public/types/param.ts | 5 +- .../vis_types/xy/public/vis_component.tsx | 8 +- .../visualizations/common/constants.ts | 18 ++++ src/plugins/visualizations/common/index.ts | 1 + src/plugins/visualizations/public/index.ts | 3 + .../make_visualize_embeddable_factory.ts | 7 ++ .../visualization_common_migrations.ts | 31 ++++++ ...ualization_saved_object_migrations.test.ts | 59 +++++++++++ .../visualization_saved_object_migrations.ts | 26 +++++ .../dashboard/group2/dashboard_snapshots.ts | 2 +- .../screenshots/baseline/area_chart.png | Bin 67063 -> 190127 bytes x-pack/plugins/lens/common/types.ts | 3 +- .../toolbar_component.tsx | 15 ++- .../heatmap_visualization/visualization.tsx | 1 + .../public/pie_visualization/to_expression.ts | 30 +++--- .../lens/public/pie_visualization/toolbar.tsx | 10 +- .../legend_settings_popover.test.tsx | 1 + .../legend_settings_popover.tsx | 12 ++- .../legend_size_settings.test.tsx | 97 ++++++++++++++++++ .../legend_size_settings.tsx | 52 +++++----- .../public/xy_visualization/to_expression.ts | 12 ++- .../xy_config_panel/index.tsx | 14 ++- .../make_lens_embeddable_factory.ts | 5 +- .../server/migrations/common_migrations.ts | 32 ++++++ .../saved_object_migrations.test.ts | 70 +++++++++++++ .../migrations/saved_object_migrations.ts | 7 +- 64 files changed, 835 insertions(+), 129 deletions(-) create mode 100644 src/plugins/vis_default_editor/public/components/options/legend_size_settings.test.tsx create mode 100644 x-pack/plugins/lens/public/shared_components/legend_size_settings.test.tsx diff --git a/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/heatmap_legend.ts b/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/heatmap_legend.ts index 1135708db8c22..79a356ddad934 100644 --- a/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/heatmap_legend.ts +++ b/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/heatmap_legend.ts @@ -8,6 +8,7 @@ import { Position } from '@elastic/charts'; import { i18n } from '@kbn/i18n'; import type { ExpressionFunctionDefinition } from '@kbn/expressions-plugin/common'; +import { DEFAULT_LEGEND_SIZE, LegendSize } from '@kbn/visualizations-plugin/common/constants'; import { EXPRESSION_HEATMAP_LEGEND_NAME } from '../constants'; import { HeatmapLegendConfig, HeatmapLegendConfigResult } from '../types'; @@ -52,10 +53,19 @@ export const heatmapLegendConfig: ExpressionFunctionDefinition< }), }, legendSize: { - types: ['number'], + types: ['string'], + default: DEFAULT_LEGEND_SIZE, help: i18n.translate('expressionHeatmap.function.args.legendSize.help', { - defaultMessage: 'Specifies the legend size in pixels.', + defaultMessage: 'Specifies the legend size.', }), + options: [ + LegendSize.AUTO, + LegendSize.SMALL, + LegendSize.MEDIUM, + LegendSize.LARGE, + LegendSize.EXTRA_LARGE, + ], + strict: true, }, }, fn(input, args) { diff --git a/src/plugins/chart_expressions/expression_heatmap/common/types/expression_functions.ts b/src/plugins/chart_expressions/expression_heatmap/common/types/expression_functions.ts index d3e7444ad08f2..19f63f9df9890 100644 --- a/src/plugins/chart_expressions/expression_heatmap/common/types/expression_functions.ts +++ b/src/plugins/chart_expressions/expression_heatmap/common/types/expression_functions.ts @@ -15,6 +15,7 @@ import { import { ExpressionValueVisDimension } from '@kbn/visualizations-plugin/common'; import { CustomPaletteState } from '@kbn/charts-plugin/common'; +import { LegendSize } from '@kbn/visualizations-plugin/public'; import { EXPRESSION_HEATMAP_NAME, EXPRESSION_HEATMAP_LEGEND_NAME, @@ -43,7 +44,7 @@ export interface HeatmapLegendConfig { * Exact legend width (vertical) or height (horizontal) * Limited to max of 70% of the chart container dimension Vertical legends limited to min of 30% of computed width */ - legendSize?: number; + legendSize?: LegendSize; } export type HeatmapLegendConfigResult = HeatmapLegendConfig & { diff --git a/src/plugins/chart_expressions/expression_heatmap/public/components/heatmap_component.test.tsx b/src/plugins/chart_expressions/expression_heatmap/public/components/heatmap_component.test.tsx index 4f3e77b8f1d6e..19a57272116c8 100644 --- a/src/plugins/chart_expressions/expression_heatmap/public/components/heatmap_component.test.tsx +++ b/src/plugins/chart_expressions/expression_heatmap/public/components/heatmap_component.test.tsx @@ -17,6 +17,7 @@ import { findTestSubject } from '@elastic/eui/lib/test'; import { act } from 'react-dom/test-utils'; import { HeatmapRenderProps, HeatmapArguments } from '../../common'; import HeatmapComponent from './heatmap_component'; +import { LegendSize } from '@kbn/visualizations-plugin/common'; jest.mock('@elastic/charts', () => { const original = jest.requireActual('@elastic/charts'); @@ -47,6 +48,7 @@ const args: HeatmapArguments = { isVisible: true, position: 'top', type: 'heatmap_legend', + legendSize: LegendSize.SMALL, }, gridConfig: { isCellLabelVisible: true, @@ -119,6 +121,33 @@ describe('HeatmapComponent', function () { expect(component.find(Settings).prop('legendPosition')).toEqual('top'); }); + it('sets correct legend sizes', () => { + const component = shallowWithIntl(); + expect(component.find(Settings).prop('legendSize')).toEqual(80); + + component.setProps({ + args: { + ...args, + legend: { + ...args.legend, + legendSize: LegendSize.AUTO, + }, + }, + }); + expect(component.find(Settings).prop('legendSize')).toBeUndefined(); + + component.setProps({ + args: { + ...args, + legend: { + ...args.legend, + legendSize: undefined, + }, + }, + }); + expect(component.find(Settings).prop('legendSize')).toEqual(130); + }); + it('renders the legend toggle component if uiState is set', async () => { const component = mountWithIntl(); await actWithTimeout(async () => { diff --git a/src/plugins/chart_expressions/expression_heatmap/public/components/heatmap_component.tsx b/src/plugins/chart_expressions/expression_heatmap/public/components/heatmap_component.tsx index a9b70d1bc2edd..36270ef896e46 100644 --- a/src/plugins/chart_expressions/expression_heatmap/public/components/heatmap_component.tsx +++ b/src/plugins/chart_expressions/expression_heatmap/public/components/heatmap_component.tsx @@ -29,6 +29,10 @@ import { getAccessorByDimension, getFormatByAccessor, } from '@kbn/visualizations-plugin/common/utils'; +import { + DEFAULT_LEGEND_SIZE, + LegendSizeToPixels, +} from '@kbn/visualizations-plugin/common/constants'; import type { HeatmapRenderProps, FilterEvent, BrushEvent } from '../../common'; import { applyPaletteParams, findMinMaxByColumnId, getSortPredicate } from './helpers'; import { @@ -485,7 +489,7 @@ export const HeatmapComponent: FC = memo( onElementClick={interactive ? (onElementClick as ElementClickListener) : undefined} showLegend={showLegend ?? args.legend.isVisible} legendPosition={args.legend.position} - legendSize={args.legend.legendSize} + legendSize={LegendSizeToPixels[args.legend.legendSize ?? DEFAULT_LEGEND_SIZE]} legendColorPicker={uiState ? LegendColorPickerWrapper : undefined} debugState={window._echDebugStateFlag ?? false} tooltip={tooltip} diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/mosaic_vis_function.test.ts.snap b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/mosaic_vis_function.test.ts.snap index 81ada60a772cd..2a06459822a0e 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/mosaic_vis_function.test.ts.snap +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/mosaic_vis_function.test.ts.snap @@ -112,7 +112,7 @@ Object { }, "legendDisplay": "show", "legendPosition": "right", - "legendSize": undefined, + "legendSize": "medium", "maxLegendLines": 2, "metric": Object { "accessor": 0, diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/pie_vis_function.test.ts.snap b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/pie_vis_function.test.ts.snap index 28d5f35c89cbf..0f64f4c0a4779 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/pie_vis_function.test.ts.snap +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/pie_vis_function.test.ts.snap @@ -112,7 +112,7 @@ Object { }, "legendDisplay": "show", "legendPosition": "right", - "legendSize": undefined, + "legendSize": "small", "maxLegendLines": 2, "metric": Object { "accessor": 0, @@ -246,7 +246,7 @@ Object { }, "legendDisplay": "show", "legendPosition": "right", - "legendSize": undefined, + "legendSize": "small", "maxLegendLines": 2, "metric": Object { "accessor": 0, diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/treemap_vis_function.test.ts.snap b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/treemap_vis_function.test.ts.snap index e1d9f98f57209..9f6210f42b48a 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/treemap_vis_function.test.ts.snap +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/treemap_vis_function.test.ts.snap @@ -112,7 +112,7 @@ Object { }, "legendDisplay": "show", "legendPosition": "right", - "legendSize": undefined, + "legendSize": "medium", "maxLegendLines": 2, "metric": Object { "accessor": 0, diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/waffle_vis_function.test.ts.snap b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/waffle_vis_function.test.ts.snap index 33525b33f6f96..9cdc69904460a 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/waffle_vis_function.test.ts.snap +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/waffle_vis_function.test.ts.snap @@ -86,7 +86,7 @@ Object { }, "legendDisplay": "show", "legendPosition": "right", - "legendSize": undefined, + "legendSize": "medium", "maxLegendLines": 2, "metric": Object { "accessor": 0, diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/i18n.ts b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/i18n.ts index 250d0f1033ffe..d7839d1f7d1e9 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/i18n.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/i18n.ts @@ -47,7 +47,7 @@ export const strings = { }), getLegendSizeArgHelp: () => i18n.translate('expressionPartitionVis.reusable.function.args.legendSizeHelpText', { - defaultMessage: 'Specifies the legend size in pixels', + defaultMessage: 'Specifies the legend size', }), getNestedLegendArgHelp: () => i18n.translate('expressionPartitionVis.reusable.function.args.nestedLegendHelpText', { diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/mosaic_vis_function.ts b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/mosaic_vis_function.ts index 74a85dd01e6e4..2f08ecb28c931 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/mosaic_vis_function.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/mosaic_vis_function.ts @@ -8,6 +8,7 @@ import { Position } from '@elastic/charts'; import { prepareLogTable, validateAccessor } from '@kbn/visualizations-plugin/common/utils'; +import { DEFAULT_LEGEND_SIZE, LegendSize } from '@kbn/visualizations-plugin/common/constants'; import { LegendDisplay, PartitionVisParams } from '../types/expression_renderers'; import { ChartTypes, MosaicVisExpressionFunctionDefinition } from '../types'; import { @@ -64,8 +65,17 @@ export const mosaicVisFunction = (): MosaicVisExpressionFunctionDefinition => ({ strict: true, }, legendSize: { - types: ['number'], + types: ['string'], + default: DEFAULT_LEGEND_SIZE, help: strings.getLegendSizeArgHelp(), + options: [ + LegendSize.AUTO, + LegendSize.SMALL, + LegendSize.MEDIUM, + LegendSize.LARGE, + LegendSize.EXTRA_LARGE, + ], + strict: true, }, nestedLegend: { types: ['boolean'], diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/pie_vis_function.test.ts b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/pie_vis_function.test.ts index c542a25c30875..58ba8e837d339 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/pie_vis_function.test.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/pie_vis_function.test.ts @@ -14,7 +14,7 @@ import { ValueFormats, LegendDisplay, } from '../types/expression_renderers'; -import { ExpressionValueVisDimension } from '@kbn/visualizations-plugin/common'; +import { ExpressionValueVisDimension, LegendSize } from '@kbn/visualizations-plugin/common'; import { Datatable } from '@kbn/expressions-plugin/common/expression_types/specs'; import { pieVisFunction } from './pie_vis_function'; import { PARTITION_LABELS_VALUE } from '../constants'; @@ -31,6 +31,7 @@ describe('interpreter/functions#pieVis', () => { addTooltip: true, legendDisplay: LegendDisplay.SHOW, legendPosition: 'right', + legendSize: LegendSize.SMALL, isDonut: true, emptySizeRatio: EmptySizeRatios.SMALL, nestedLegend: true, diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/pie_vis_function.ts b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/pie_vis_function.ts index 9a30008cc6bb3..707334466ea99 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/pie_vis_function.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/pie_vis_function.ts @@ -8,6 +8,7 @@ import { Position } from '@elastic/charts'; import { prepareLogTable, validateAccessor } from '@kbn/visualizations-plugin/common/utils'; +import { DEFAULT_LEGEND_SIZE, LegendSize } from '@kbn/visualizations-plugin/common/constants'; import { EmptySizeRatios, LegendDisplay, PartitionVisParams } from '../types/expression_renderers'; import { ChartTypes, PieVisExpressionFunctionDefinition } from '../types'; import { @@ -64,8 +65,17 @@ export const pieVisFunction = (): PieVisExpressionFunctionDefinition => ({ strict: true, }, legendSize: { - types: ['number'], + types: ['string'], + default: DEFAULT_LEGEND_SIZE, help: strings.getLegendSizeArgHelp(), + options: [ + LegendSize.AUTO, + LegendSize.SMALL, + LegendSize.MEDIUM, + LegendSize.LARGE, + LegendSize.EXTRA_LARGE, + ], + strict: true, }, nestedLegend: { types: ['boolean'], diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/treemap_vis_function.ts b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/treemap_vis_function.ts index 062cf7e78b4ea..ab6f0c962e205 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/treemap_vis_function.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/treemap_vis_function.ts @@ -8,6 +8,7 @@ import { Position } from '@elastic/charts'; import { prepareLogTable, validateAccessor } from '@kbn/visualizations-plugin/common/utils'; +import { DEFAULT_LEGEND_SIZE, LegendSize } from '@kbn/visualizations-plugin/common/constants'; import { LegendDisplay, PartitionVisParams } from '../types/expression_renderers'; import { ChartTypes, TreemapVisExpressionFunctionDefinition } from '../types'; import { @@ -64,8 +65,17 @@ export const treemapVisFunction = (): TreemapVisExpressionFunctionDefinition => strict: true, }, legendSize: { - types: ['number'], + types: ['string'], + default: DEFAULT_LEGEND_SIZE, help: strings.getLegendSizeArgHelp(), + options: [ + LegendSize.AUTO, + LegendSize.SMALL, + LegendSize.MEDIUM, + LegendSize.LARGE, + LegendSize.EXTRA_LARGE, + ], + strict: true, }, nestedLegend: { types: ['boolean'], diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/waffle_vis_function.ts b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/waffle_vis_function.ts index 2f947a3d5fea6..0311f5466142f 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/waffle_vis_function.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/waffle_vis_function.ts @@ -8,6 +8,7 @@ import { Position } from '@elastic/charts'; import { prepareLogTable, validateAccessor } from '@kbn/visualizations-plugin/common/utils'; +import { DEFAULT_LEGEND_SIZE, LegendSize } from '@kbn/visualizations-plugin/common/constants'; import { LegendDisplay, PartitionVisParams } from '../types/expression_renderers'; import { ChartTypes, WaffleVisExpressionFunctionDefinition } from '../types'; import { @@ -63,8 +64,17 @@ export const waffleVisFunction = (): WaffleVisExpressionFunctionDefinition => ({ strict: true, }, legendSize: { - types: ['number'], + types: ['string'], + default: DEFAULT_LEGEND_SIZE, help: strings.getLegendSizeArgHelp(), + options: [ + LegendSize.AUTO, + LegendSize.SMALL, + LegendSize.MEDIUM, + LegendSize.LARGE, + LegendSize.EXTRA_LARGE, + ], + strict: true, }, truncateLegend: { types: ['boolean'], diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/types/expression_renderers.ts b/src/plugins/chart_expressions/expression_partition_vis/common/types/expression_renderers.ts index 05613af4f2f33..89a242fe26de1 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/common/types/expression_renderers.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/common/types/expression_renderers.ts @@ -11,6 +11,7 @@ import type { PaletteOutput } from '@kbn/coloring'; import { Datatable, DatatableColumn } from '@kbn/expressions-plugin/common'; import { SerializedFieldFormat } from '@kbn/field-formats-plugin/common'; import { ExpressionValueVisDimension } from '@kbn/visualizations-plugin/common'; +import { LegendSize } from '@kbn/visualizations-plugin/public'; import { ChartTypes, ExpressionValuePartitionLabels } from './expression_functions'; export enum EmptySizeRatios { @@ -52,7 +53,7 @@ interface VisCommonParams { legendPosition: Position; truncateLegend: boolean; maxLegendLines: number; - legendSize?: number; + legendSize?: LegendSize; ariaLabel?: string; } diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/components/__snapshots__/partition_vis_component.test.tsx.snap b/src/plugins/chart_expressions/expression_partition_vis/public/components/__snapshots__/partition_vis_component.test.tsx.snap index 3c48d3cb36771..0fcee477c99de 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/public/components/__snapshots__/partition_vis_component.test.tsx.snap +++ b/src/plugins/chart_expressions/expression_partition_vis/public/components/__snapshots__/partition_vis_component.test.tsx.snap @@ -247,6 +247,7 @@ exports[`PartitionVisComponent should render correct structure for donut 1`] = ` legendColorPicker={[Function]} legendMaxDepth={1} legendPosition="right" + legendSize={130} onElementClick={[Function]} onRenderChange={[Function]} showLegend={true} @@ -674,6 +675,7 @@ exports[`PartitionVisComponent should render correct structure for mosaic 1`] = legendAction={[Function]} legendColorPicker={[Function]} legendPosition="right" + legendSize={130} onElementClick={[Function]} onRenderChange={[Function]} showLegend={true} @@ -1054,6 +1056,7 @@ exports[`PartitionVisComponent should render correct structure for pie 1`] = ` legendColorPicker={[Function]} legendMaxDepth={1} legendPosition="right" + legendSize={130} onElementClick={[Function]} onRenderChange={[Function]} showLegend={true} @@ -1465,6 +1468,7 @@ exports[`PartitionVisComponent should render correct structure for treemap 1`] = legendAction={[Function]} legendColorPicker={[Function]} legendPosition="right" + legendSize={130} onElementClick={[Function]} onRenderChange={[Function]} showLegend={true} @@ -1866,6 +1870,7 @@ exports[`PartitionVisComponent should render correct structure for waffle 1`] = legendColorPicker={[Function]} legendMaxDepth={1} legendPosition="right" + legendSize={130} onElementClick={[Function]} onRenderChange={[Function]} showLegend={true} diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/components/partition_vis_component.test.tsx b/src/plugins/chart_expressions/expression_partition_vis/public/components/partition_vis_component.test.tsx index 648df546b2992..70c120e4fd759 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/public/components/partition_vis_component.test.tsx +++ b/src/plugins/chart_expressions/expression_partition_vis/public/components/partition_vis_component.test.tsx @@ -25,6 +25,7 @@ import { createMockWaffleParams, } from '../mocks'; import { ChartTypes } from '../../common/types'; +import { LegendSize } from '@kbn/visualizations-plugin/common'; jest.mock('@elastic/charts', () => { const original = jest.requireActual('@elastic/charts'); @@ -177,6 +178,35 @@ describe('PartitionVisComponent', function () { expect(component.find(Settings).prop('legendMaxDepth')).toBeUndefined(); }); + it('sets correct legend sizes', () => { + const component = shallow( + + ); + expect(component.find(Settings).prop('legendSize')).toEqual(80); + + component.setProps({ + visParams: { + ...visParams, + legendSize: LegendSize.AUTO, + }, + }); + expect(component.find(Settings).prop('legendSize')).toBeUndefined(); + + component.setProps({ + visParams: { + ...visParams, + legendSize: undefined, + }, + }); + expect(component.find(Settings).prop('legendSize')).toEqual(130); + }); + it('defaults on displaying the tooltip', () => { const component = shallow(); expect(component.find(Settings).prop('tooltip')).toStrictEqual({ type: TooltipType.Follow }); diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/components/partition_vis_component.tsx b/src/plugins/chart_expressions/expression_partition_vis/public/components/partition_vis_component.tsx index ef6d0d1c4525c..d25126869e087 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/public/components/partition_vis_component.tsx +++ b/src/plugins/chart_expressions/expression_partition_vis/public/components/partition_vis_component.tsx @@ -22,7 +22,11 @@ import { import { useEuiTheme } from '@elastic/eui'; import type { PaletteRegistry } from '@kbn/coloring'; import { LegendToggle, ChartsPluginSetup } from '@kbn/charts-plugin/public'; -import type { PersistedState } from '@kbn/visualizations-plugin/public'; +import { + DEFAULT_LEGEND_SIZE, + LegendSizeToPixels, +} from '@kbn/visualizations-plugin/common/constants'; +import { PersistedState } from '@kbn/visualizations-plugin/public'; import { getColumnByAccessor } from '@kbn/visualizations-plugin/common/utils'; import { Datatable, @@ -387,7 +391,7 @@ const PartitionVisComponent = (props: PartitionVisComponentProps) => { showLegend ?? shouldShowLegend(visType, visParams.legendDisplay, bucketColumns) } legendPosition={legendPosition} - legendSize={visParams.legendSize} + legendSize={LegendSizeToPixels[visParams.legendSize ?? DEFAULT_LEGEND_SIZE]} legendMaxDepth={visParams.nestedLegend ? undefined : 1} legendColorPicker={props.uiState ? LegendColorPickerWrapper : undefined} flatLegend={flatLegend} diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/legend_config.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/legend_config.ts index 2b383f1899d44..ddb46d5e55f13 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/legend_config.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/legend_config.ts @@ -8,6 +8,7 @@ import { HorizontalAlignment, Position, VerticalAlignment } from '@elastic/charts'; import { i18n } from '@kbn/i18n'; +import { DEFAULT_LEGEND_SIZE, LegendSize } from '@kbn/visualizations-plugin/common/constants'; import { LEGEND_CONFIG } from '../constants'; import { LegendConfigFn } from '../types'; @@ -85,10 +86,19 @@ export const legendConfigFunction: LegendConfigFn = { }), }, legendSize: { - types: ['number'], + types: ['string'], + default: DEFAULT_LEGEND_SIZE, help: i18n.translate('expressionXY.legendConfig.legendSize.help', { - defaultMessage: 'Specifies the legend size in pixels.', + defaultMessage: 'Specifies the legend size.', }), + options: [ + LegendSize.AUTO, + LegendSize.SMALL, + LegendSize.MEDIUM, + LegendSize.LARGE, + LegendSize.EXTRA_LARGE, + ], + strict: true, }, }, async fn(input, args, handlers) { diff --git a/src/plugins/chart_expressions/expression_xy/common/types/expression_functions.ts b/src/plugins/chart_expressions/expression_xy/common/types/expression_functions.ts index f7f7c5bcd1544..86eb173d4d4ed 100644 --- a/src/plugins/chart_expressions/expression_xy/common/types/expression_functions.ts +++ b/src/plugins/chart_expressions/expression_xy/common/types/expression_functions.ts @@ -10,6 +10,7 @@ import { HorizontalAlignment, Position, VerticalAlignment } from '@elastic/chart import { $Values } from '@kbn/utility-types'; import type { PaletteOutput } from '@kbn/coloring'; import { Datatable, ExpressionFunctionDefinition } from '@kbn/expressions-plugin'; +import { LegendSize } from '@kbn/visualizations-plugin/public'; import { EventAnnotationOutput } from '@kbn/event-annotation-plugin/common'; import { AxisExtentModes, @@ -170,7 +171,7 @@ export interface LegendConfig { * Exact legend width (vertical) or height (horizontal) * Limited to max of 70% of the chart container dimension Vertical legends limited to min of 30% of computed width */ - legendSize?: number; + legendSize?: LegendSize; } export interface LabelsOrientationConfig { diff --git a/src/plugins/chart_expressions/expression_xy/public/components/__snapshots__/xy_chart.test.tsx.snap b/src/plugins/chart_expressions/expression_xy/public/components/__snapshots__/xy_chart.test.tsx.snap index 3b11ee812da6f..2bcfb37aca2e5 100644 --- a/src/plugins/chart_expressions/expression_xy/public/components/__snapshots__/xy_chart.test.tsx.snap +++ b/src/plugins/chart_expressions/expression_xy/public/components/__snapshots__/xy_chart.test.tsx.snap @@ -305,6 +305,7 @@ exports[`XYChart component it renders area 1`] = ` } } legendPosition="top" + legendSize={130} onBrushEnd={[Function]} onElementClick={[Function]} onPointerUpdate={[Function]} @@ -846,6 +847,7 @@ exports[`XYChart component it renders bar 1`] = ` } } legendPosition="top" + legendSize={130} onBrushEnd={[Function]} onElementClick={[Function]} onPointerUpdate={[Function]} @@ -1387,6 +1389,7 @@ exports[`XYChart component it renders horizontal bar 1`] = ` } } legendPosition="top" + legendSize={130} onBrushEnd={[Function]} onElementClick={[Function]} onPointerUpdate={[Function]} @@ -1928,6 +1931,7 @@ exports[`XYChart component it renders line 1`] = ` } } legendPosition="top" + legendSize={130} onBrushEnd={[Function]} onElementClick={[Function]} onPointerUpdate={[Function]} @@ -2469,6 +2473,7 @@ exports[`XYChart component it renders stacked area 1`] = ` } } legendPosition="top" + legendSize={130} onBrushEnd={[Function]} onElementClick={[Function]} onPointerUpdate={[Function]} @@ -3010,6 +3015,7 @@ exports[`XYChart component it renders stacked bar 1`] = ` } } legendPosition="top" + legendSize={130} onBrushEnd={[Function]} onElementClick={[Function]} onPointerUpdate={[Function]} @@ -3551,6 +3557,7 @@ exports[`XYChart component it renders stacked horizontal bar 1`] = ` } } legendPosition="top" + legendSize={130} onBrushEnd={[Function]} onElementClick={[Function]} onPointerUpdate={[Function]} diff --git a/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.test.tsx b/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.test.tsx index de67e814d5b78..7df73082dfa14 100644 --- a/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.test.tsx +++ b/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.test.tsx @@ -57,6 +57,7 @@ import { } from '../../common/types'; import { DataLayers } from './data_layers'; import { Annotations } from './annotations'; +import { LegendSize } from '@kbn/visualizations-plugin/common'; const onClickValue = jest.fn(); const onSelectRange = jest.fn(); @@ -2377,6 +2378,37 @@ describe('XYChart component', () => { expect(component.find(Settings).prop('legendPosition')).toEqual('top'); }); + it('computes correct legend sizes', () => { + const { args } = sampleArgs(); + + const component = shallow( + + ); + expect(component.find(Settings).prop('legendSize')).toEqual(80); + + component.setProps({ + args: { + ...args, + legend: { ...args.legend, legendSize: LegendSize.AUTO }, + }, + }); + expect(component.find(Settings).prop('legendSize')).toBeUndefined(); + + component.setProps({ + args: { + ...args, + legend: { ...args.legend, legendSize: undefined }, + }, + }); + expect(component.find(Settings).prop('legendSize')).toEqual(130); + }); + test('it should apply the fitting function to all non-bar series', () => { const data: Datatable = createSampleDatatableWithRows([ { a: 1, b: 2, c: 'I', d: 'Foo' }, diff --git a/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.tsx b/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.tsx index db653861a337e..6e3f142996949 100644 --- a/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.tsx +++ b/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.tsx @@ -33,6 +33,10 @@ import { EmptyPlaceholder } from '@kbn/charts-plugin/public'; import { EventAnnotationServiceType } from '@kbn/event-annotation-plugin/public'; import { ChartsPluginSetup, ChartsPluginStart, useActiveCursor } from '@kbn/charts-plugin/public'; import { MULTILAYER_TIME_AXIS_STYLE } from '@kbn/charts-plugin/common'; +import { + DEFAULT_LEGEND_SIZE, + LegendSizeToPixels, +} from '@kbn/visualizations-plugin/common/constants'; import type { FilterEvent, BrushEvent, FormatFactory } from '../types'; import type { CommonXYDataLayerConfig, SeriesType, XYChartProps } from '../../common/types'; import { @@ -506,7 +510,7 @@ export function XYChart({ : legend.isVisible } legendPosition={legend?.isInside ? legendInsideParams : legend.position} - legendSize={legend.legendSize} + legendSize={LegendSizeToPixels[legend.legendSize ?? DEFAULT_LEGEND_SIZE]} theme={{ ...chartTheme, barSeriesStyle: { diff --git a/src/plugins/vis_default_editor/kibana.json b/src/plugins/vis_default_editor/kibana.json index e6a56bd65fcc7..240eb7ccab6fa 100644 --- a/src/plugins/vis_default_editor/kibana.json +++ b/src/plugins/vis_default_editor/kibana.json @@ -3,7 +3,16 @@ "version": "kibana", "ui": true, "optionalPlugins": ["visualizations"], - "requiredBundles": ["unifiedSearch", "kibanaUtils", "kibanaReact", "data", "fieldFormats", "discover", "esUiShared"], + "requiredBundles": [ + "unifiedSearch", + "kibanaUtils", + "kibanaReact", + "data", + "fieldFormats", + "discover", + "esUiShared", + "visualizations" + ], "owner": { "name": "Vis Editors", "githubTeam": "kibana-vis-editors" diff --git a/src/plugins/vis_default_editor/public/components/options/legend_size_settings.test.tsx b/src/plugins/vis_default_editor/public/components/options/legend_size_settings.test.tsx new file mode 100644 index 0000000000000..3eeb93e6155df --- /dev/null +++ b/src/plugins/vis_default_editor/public/components/options/legend_size_settings.test.tsx @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { LegendSizeSettings } from './legend_size_settings'; +import { LegendSize, DEFAULT_LEGEND_SIZE } from '@kbn/visualizations-plugin/public'; +import { EuiSuperSelect } from '@elastic/eui'; +import { shallow } from 'enzyme'; + +describe('legend size settings', () => { + it('select is disabled if not vertical legend', () => { + const instance = shallow( + {}} + isVerticalLegend={false} + showAutoOption={true} + /> + ); + + expect(instance.find(EuiSuperSelect).props().disabled).toBeTruthy(); + }); + + it('reflects current setting in select', () => { + const CURRENT_SIZE = LegendSize.SMALL; + + const instance = shallow( + {}} + isVerticalLegend={true} + showAutoOption={true} + /> + ); + + expect(instance.find(EuiSuperSelect).props().valueOfSelected).toBe(CURRENT_SIZE); + }); + + it('allows user to select a new option', () => { + const onSizeChange = jest.fn(); + + const instance = shallow( + + ); + + const onChange = instance.find(EuiSuperSelect).props().onChange; + + onChange(LegendSize.EXTRA_LARGE); + onChange(DEFAULT_LEGEND_SIZE); + + expect(onSizeChange).toHaveBeenNthCalledWith(1, LegendSize.EXTRA_LARGE); + expect(onSizeChange).toHaveBeenNthCalledWith(2, undefined); + }); + + it('hides "auto" option if visualization not using it', () => { + const getOptions = (showAutoOption: boolean) => + shallow( + {}} + isVerticalLegend={true} + showAutoOption={showAutoOption} + /> + ) + .find(EuiSuperSelect) + .props().options; + + const autoOption = expect.objectContaining({ value: LegendSize.AUTO }); + + expect(getOptions(true)).toContainEqual(autoOption); + expect(getOptions(false)).not.toContainEqual(autoOption); + }); +}); diff --git a/src/plugins/vis_default_editor/public/components/options/legend_size_settings.tsx b/src/plugins/vis_default_editor/public/components/options/legend_size_settings.tsx index 768db7d3dd78e..bbe47295c99e6 100644 --- a/src/plugins/vis_default_editor/public/components/options/legend_size_settings.tsx +++ b/src/plugins/vis_default_editor/public/components/options/legend_size_settings.tsx @@ -10,27 +10,11 @@ import React, { useCallback, useEffect } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiFormRow, EuiSuperSelect, EuiToolTip } from '@elastic/eui'; +import { LegendSize, DEFAULT_LEGEND_SIZE } from '@kbn/visualizations-plugin/public'; -enum LegendSizes { - AUTO = '0', - SMALL = '80', - MEDIUM = '130', - LARGE = '180', - EXTRA_LARGE = '230', -} - -const legendSizeOptions: Array<{ value: LegendSizes; inputDisplay: string }> = [ +const legendSizeOptions: Array<{ value: LegendSize; inputDisplay: string }> = [ { - value: LegendSizes.AUTO, - inputDisplay: i18n.translate( - 'visDefaultEditor.options.legendSizeSetting.legendSizeOptions.auto', - { - defaultMessage: 'Auto', - } - ), - }, - { - value: LegendSizes.SMALL, + value: LegendSize.SMALL, inputDisplay: i18n.translate( 'visDefaultEditor.options.legendSizeSetting.legendSizeOptions.small', { @@ -39,7 +23,7 @@ const legendSizeOptions: Array<{ value: LegendSizes; inputDisplay: string }> = [ ), }, { - value: LegendSizes.MEDIUM, + value: LegendSize.MEDIUM, inputDisplay: i18n.translate( 'visDefaultEditor.options.legendSizeSetting.legendSizeOptions.medium', { @@ -48,7 +32,7 @@ const legendSizeOptions: Array<{ value: LegendSizes; inputDisplay: string }> = [ ), }, { - value: LegendSizes.LARGE, + value: LegendSize.LARGE, inputDisplay: i18n.translate( 'visDefaultEditor.options.legendSizeSetting.legendSizeOptions.large', { @@ -57,7 +41,7 @@ const legendSizeOptions: Array<{ value: LegendSizes; inputDisplay: string }> = [ ), }, { - value: LegendSizes.EXTRA_LARGE, + value: LegendSize.EXTRA_LARGE, inputDisplay: i18n.translate( 'visDefaultEditor.options.legendSizeSetting.legendSizeOptions.extraLarge', { @@ -68,15 +52,17 @@ const legendSizeOptions: Array<{ value: LegendSizes; inputDisplay: string }> = [ ]; interface LegendSizeSettingsProps { - legendSize?: number; - onLegendSizeChange: (size?: number) => void; + legendSize?: LegendSize; + onLegendSizeChange: (size?: LegendSize) => void; isVerticalLegend: boolean; + showAutoOption: boolean; } export const LegendSizeSettings = ({ legendSize, onLegendSizeChange, isVerticalLegend, + showAutoOption, }: LegendSizeSettingsProps) => { useEffect(() => { if (legendSize && !isVerticalLegend) { @@ -85,16 +71,31 @@ export const LegendSizeSettings = ({ }, [isVerticalLegend, legendSize, onLegendSizeChange]); const onLegendSizeOptionChange = useCallback( - (option) => onLegendSizeChange(Number(option) || undefined), + (option) => onLegendSizeChange(option === DEFAULT_LEGEND_SIZE ? undefined : option), [onLegendSizeChange] ); + const options = showAutoOption + ? [ + { + value: LegendSize.AUTO, + inputDisplay: i18n.translate( + 'visDefaultEditor.options.legendSizeSetting.legendSizeOptions.auto', + { + defaultMessage: 'Auto', + } + ), + }, + ...legendSizeOptions, + ] + : legendSizeOptions; + const legendSizeSelect = ( diff --git a/src/plugins/vis_types/heatmap/public/editor/components/heatmap.tsx b/src/plugins/vis_types/heatmap/public/editor/components/heatmap.tsx index f592ee3933c1c..3c06e65e2cff4 100644 --- a/src/plugins/vis_types/heatmap/public/editor/components/heatmap.tsx +++ b/src/plugins/vis_types/heatmap/public/editor/components/heatmap.tsx @@ -26,7 +26,7 @@ import { LegendSizeSettings, } from '@kbn/vis-default-editor-plugin/public'; import { colorSchemas } from '@kbn/charts-plugin/public'; -import { VisEditorOptionsProps } from '@kbn/visualizations-plugin/public'; +import { LegendSize, VisEditorOptionsProps } from '@kbn/visualizations-plugin/public'; import { HeatmapVisParams, HeatmapTypeProps, ValueAxis } from '../../types'; import { LabelsPanel } from './labels_panel'; import { legendPositions, scaleTypes } from '../collections'; @@ -42,6 +42,9 @@ const HeatmapOptions = (props: HeatmapOptionsProps) => { const isColorsNumberInvalid = stateParams.colorsNumber < 2 || stateParams.colorsNumber > 10; const [isColorRangesValid, setIsColorRangesValid] = useState(false); + const legendSize = stateParams.legendSize; + const [hadAutoLegendSize] = useState(() => legendSize === LegendSize.AUTO); + const setValueAxisScale = useCallback( (paramName: T, value: ValueAxis['scale'][T]) => setValue('valueAxes', [ @@ -91,12 +94,13 @@ const HeatmapOptions = (props: HeatmapOptionsProps) => { setValue={setValue} /> )} diff --git a/src/plugins/vis_types/heatmap/public/types.ts b/src/plugins/vis_types/heatmap/public/types.ts index 8301d246e9f63..9d41a132f00b1 100644 --- a/src/plugins/vis_types/heatmap/public/types.ts +++ b/src/plugins/vis_types/heatmap/public/types.ts @@ -9,6 +9,7 @@ import { UiCounterMetricType } from '@kbn/analytics'; import type { Position } from '@elastic/charts'; import type { ChartsPluginSetup, Style, Labels, ColorSchemas } from '@kbn/charts-plugin/public'; import { Range } from '@kbn/expressions-plugin/public'; +import { LegendSize } from '@kbn/visualizations-plugin/public'; export interface HeatmapTypeProps { showElasticChartsOptions?: boolean; @@ -23,7 +24,7 @@ export interface HeatmapVisParams { legendPosition: Position; truncateLegend?: boolean; maxLegendLines?: number; - legendSize?: number; + legendSize?: LegendSize; lastRangeIsRightOpen: boolean; percentageMode: boolean; valueAxes: ValueAxis[]; diff --git a/src/plugins/vis_types/pie/public/__snapshots__/to_ast.test.ts.snap b/src/plugins/vis_types/pie/public/__snapshots__/to_ast.test.ts.snap index 904dff6ee1192..5b8bd613609f9 100644 --- a/src/plugins/vis_types/pie/public/__snapshots__/to_ast.test.ts.snap +++ b/src/plugins/vis_types/pie/public/__snapshots__/to_ast.test.ts.snap @@ -93,6 +93,9 @@ Object { "legendPosition": Array [ "right", ], + "legendSize": Array [ + "large", + ], "metric": Array [ Object { "chain": Array [ diff --git a/src/plugins/vis_types/pie/public/editor/components/pie.tsx b/src/plugins/vis_types/pie/public/editor/components/pie.tsx index f1f335f186ffd..cd1e565861d78 100644 --- a/src/plugins/vis_types/pie/public/editor/components/pie.tsx +++ b/src/plugins/vis_types/pie/public/editor/components/pie.tsx @@ -31,7 +31,7 @@ import { LongLegendOptions, LegendSizeSettings, } from '@kbn/vis-default-editor-plugin/public'; -import { VisEditorOptionsProps } from '@kbn/visualizations-plugin/public'; +import { LegendSize, VisEditorOptionsProps } from '@kbn/visualizations-plugin/public'; import { PartitionVisParams, LabelPositions, @@ -97,6 +97,9 @@ const PieOptions = (props: PieOptionsProps) => { const hasSplitChart = Boolean(aggs?.aggs?.find((agg) => agg.schema === 'split' && agg.enabled)); const segments = aggs?.aggs?.filter((agg) => agg.schema === 'segment' && agg.enabled) ?? []; + const legendSize = stateParams.legendSize; + const [hadAutoLegendSize] = useState(() => legendSize === LegendSize.AUTO); + const getLegendDisplay = useCallback( (isVisible: boolean) => (isVisible ? LegendDisplay.SHOW : LegendDisplay.HIDE), [] @@ -234,12 +237,13 @@ const PieOptions = (props: PieOptionsProps) => { setValue={setValue} /> )} diff --git a/src/plugins/vis_types/pie/public/sample_vis.test.mocks.ts b/src/plugins/vis_types/pie/public/sample_vis.test.mocks.ts index f8836f208d916..4c638689ca310 100644 --- a/src/plugins/vis_types/pie/public/sample_vis.test.mocks.ts +++ b/src/plugins/vis_types/pie/public/sample_vis.test.mocks.ts @@ -7,6 +7,7 @@ */ import { LegendDisplay } from '@kbn/expression-partition-vis-plugin/common'; +import { LegendSize } from '@kbn/visualizations-plugin/common'; export const samplePieVis = { type: { @@ -142,6 +143,7 @@ export const samplePieVis = { addTooltip: true, legendDisplay: LegendDisplay.SHOW, legendPosition: 'right', + legendSize: LegendSize.LARGE, isDonut: true, labels: { show: true, diff --git a/src/plugins/vis_types/pie/public/to_ast.ts b/src/plugins/vis_types/pie/public/to_ast.ts index aaac3040d7bd3..7a131dbb76b9c 100644 --- a/src/plugins/vis_types/pie/public/to_ast.ts +++ b/src/plugins/vis_types/pie/public/to_ast.ts @@ -62,14 +62,14 @@ export const toExpressionAst: VisToExpressionAst = async (vi addTooltip: vis.params.addTooltip, legendDisplay: vis.params.legendDisplay, legendPosition: vis.params.legendPosition, - nestedLegend: vis.params?.nestedLegend ?? false, + nestedLegend: vis.params.nestedLegend ?? false, truncateLegend: vis.params.truncateLegend, maxLegendLines: vis.params.maxLegendLines, legendSize: vis.params.legendSize, - distinctColors: vis.params?.distinctColors, + distinctColors: vis.params.distinctColors, isDonut: vis.params.isDonut ?? false, emptySizeRatio: vis.params.emptySizeRatio, - palette: preparePalette(vis.params?.palette), + palette: preparePalette(vis.params.palette), labels: prepareLabels(vis.params.labels), metric: schemas.metric.map(prepareDimension), buckets: schemas.segment?.map(prepareDimension), diff --git a/src/plugins/vis_types/vislib/public/__snapshots__/to_ast.test.ts.snap b/src/plugins/vis_types/vislib/public/__snapshots__/to_ast.test.ts.snap index 233940d97d38a..6d20088dbff32 100644 --- a/src/plugins/vis_types/vislib/public/__snapshots__/to_ast.test.ts.snap +++ b/src/plugins/vis_types/vislib/public/__snapshots__/to_ast.test.ts.snap @@ -8,7 +8,7 @@ Object { "area", ], "visConfig": Array [ - "{\\"type\\":\\"area\\",\\"grid\\":{\\"categoryLines\\":false,\\"style\\":{\\"color\\":\\"#eee\\"}},\\"categoryAxes\\":[{\\"id\\":\\"CategoryAxis-1\\",\\"type\\":\\"category\\",\\"position\\":\\"bottom\\",\\"show\\":true,\\"style\\":{},\\"scale\\":{\\"type\\":\\"linear\\"},\\"labels\\":{\\"show\\":true,\\"truncate\\":100},\\"title\\":{}}],\\"valueAxes\\":[{\\"id\\":\\"ValueAxis-1\\",\\"name\\":\\"LeftAxis-1\\",\\"type\\":\\"value\\",\\"position\\":\\"left\\",\\"show\\":true,\\"style\\":{},\\"scale\\":{\\"type\\":\\"linear\\",\\"mode\\":\\"normal\\"},\\"labels\\":{\\"show\\":true,\\"rotate\\":0,\\"filter\\":false,\\"truncate\\":100},\\"title\\":{\\"text\\":\\"Sum of total_quantity\\"}}],\\"seriesParams\\":[{\\"show\\":\\"true\\",\\"type\\":\\"area\\",\\"mode\\":\\"stacked\\",\\"data\\":{\\"label\\":\\"Sum of total_quantity\\",\\"id\\":\\"1\\"},\\"drawLinesBetweenPoints\\":true,\\"showCircles\\":true,\\"circlesRadius\\":5,\\"interpolate\\":\\"linear\\",\\"valueAxis\\":\\"ValueAxis-1\\"}],\\"addTooltip\\":true,\\"addLegend\\":true,\\"legendPosition\\":\\"top\\",\\"times\\":[],\\"addTimeMarker\\":false,\\"truncateLegend\\":true,\\"maxLegendLines\\":1,\\"thresholdLine\\":{\\"show\\":false,\\"value\\":10,\\"width\\":1,\\"style\\":\\"full\\",\\"color\\":\\"#E7664C\\"},\\"palette\\":{\\"name\\":\\"default\\"},\\"labels\\":{},\\"dimensions\\":{\\"x\\":{\\"accessor\\":1,\\"format\\":{\\"id\\":\\"date\\",\\"params\\":{\\"pattern\\":\\"HH:mm:ss.SSS\\"}},\\"params\\":{}},\\"y\\":[{\\"accessor\\":0,\\"format\\":{\\"id\\":\\"number\\",\\"params\\":{\\"parsedUrl\\":{\\"origin\\":\\"http://localhost:5801\\",\\"pathname\\":\\"/app/visualize\\",\\"basePath\\":\\"\\"}}},\\"params\\":{}}],\\"series\\":[{\\"accessor\\":2,\\"format\\":{\\"id\\":\\"terms\\",\\"params\\":{\\"id\\":\\"string\\",\\"otherBucketLabel\\":\\"Other\\",\\"missingBucketLabel\\":\\"Missing\\",\\"parsedUrl\\":{\\"origin\\":\\"http://localhost:5801\\",\\"pathname\\":\\"/app/visualize\\",\\"basePath\\":\\"\\"}}},\\"params\\":{}}]}}", + "{\\"type\\":\\"area\\",\\"grid\\":{\\"categoryLines\\":false,\\"style\\":{\\"color\\":\\"#eee\\"}},\\"categoryAxes\\":[{\\"id\\":\\"CategoryAxis-1\\",\\"type\\":\\"category\\",\\"position\\":\\"bottom\\",\\"show\\":true,\\"style\\":{},\\"scale\\":{\\"type\\":\\"linear\\"},\\"labels\\":{\\"show\\":true,\\"truncate\\":100},\\"title\\":{}}],\\"valueAxes\\":[{\\"id\\":\\"ValueAxis-1\\",\\"name\\":\\"LeftAxis-1\\",\\"type\\":\\"value\\",\\"position\\":\\"left\\",\\"show\\":true,\\"style\\":{},\\"scale\\":{\\"type\\":\\"linear\\",\\"mode\\":\\"normal\\"},\\"labels\\":{\\"show\\":true,\\"rotate\\":0,\\"filter\\":false,\\"truncate\\":100},\\"title\\":{\\"text\\":\\"Sum of total_quantity\\"}}],\\"seriesParams\\":[{\\"show\\":\\"true\\",\\"type\\":\\"area\\",\\"mode\\":\\"stacked\\",\\"data\\":{\\"label\\":\\"Sum of total_quantity\\",\\"id\\":\\"1\\"},\\"drawLinesBetweenPoints\\":true,\\"showCircles\\":true,\\"circlesRadius\\":5,\\"interpolate\\":\\"linear\\",\\"valueAxis\\":\\"ValueAxis-1\\"}],\\"addTooltip\\":true,\\"addLegend\\":true,\\"legendPosition\\":\\"top\\",\\"legendSize\\":\\"small\\",\\"times\\":[],\\"addTimeMarker\\":false,\\"truncateLegend\\":true,\\"maxLegendLines\\":1,\\"thresholdLine\\":{\\"show\\":false,\\"value\\":10,\\"width\\":1,\\"style\\":\\"full\\",\\"color\\":\\"#E7664C\\"},\\"palette\\":{\\"name\\":\\"default\\"},\\"labels\\":{},\\"dimensions\\":{\\"x\\":{\\"accessor\\":1,\\"format\\":{\\"id\\":\\"date\\",\\"params\\":{\\"pattern\\":\\"HH:mm:ss.SSS\\"}},\\"params\\":{}},\\"y\\":[{\\"accessor\\":0,\\"format\\":{\\"id\\":\\"number\\",\\"params\\":{\\"parsedUrl\\":{\\"origin\\":\\"http://localhost:5801\\",\\"pathname\\":\\"/app/visualize\\",\\"basePath\\":\\"\\"}}},\\"params\\":{}}],\\"series\\":[{\\"accessor\\":2,\\"format\\":{\\"id\\":\\"terms\\",\\"params\\":{\\"id\\":\\"string\\",\\"otherBucketLabel\\":\\"Other\\",\\"missingBucketLabel\\":\\"Missing\\",\\"parsedUrl\\":{\\"origin\\":\\"http://localhost:5801\\",\\"pathname\\":\\"/app/visualize\\",\\"basePath\\":\\"\\"}}},\\"params\\":{}}]}}", ], }, "getArgument": [Function], diff --git a/src/plugins/vis_types/vislib/public/__snapshots__/to_ast_pie.test.ts.snap b/src/plugins/vis_types/vislib/public/__snapshots__/to_ast_pie.test.ts.snap index 1eedae99ffedb..80e52d95be5c9 100644 --- a/src/plugins/vis_types/vislib/public/__snapshots__/to_ast_pie.test.ts.snap +++ b/src/plugins/vis_types/vislib/public/__snapshots__/to_ast_pie.test.ts.snap @@ -5,7 +5,7 @@ Object { "addArgument": [Function], "arguments": Object { "visConfig": Array [ - "{\\"type\\":\\"pie\\",\\"addTooltip\\":true,\\"legendDisplay\\":\\"show\\",\\"legendPosition\\":\\"right\\",\\"isDonut\\":true,\\"labels\\":{\\"show\\":true,\\"values\\":true,\\"last_level\\":true,\\"truncate\\":100},\\"dimensions\\":{\\"metric\\":{\\"accessor\\":0,\\"format\\":{\\"id\\":\\"number\\"},\\"params\\":{}},\\"buckets\\":[{\\"accessor\\":1,\\"format\\":{\\"id\\":\\"terms\\",\\"params\\":{\\"id\\":\\"string\\",\\"otherBucketLabel\\":\\"Other\\",\\"missingBucketLabel\\":\\"Missing\\",\\"parsedUrl\\":{\\"origin\\":\\"http://localhost:5801\\",\\"pathname\\":\\"/app/visualize\\",\\"basePath\\":\\"\\"}}},\\"params\\":{}}]}}", + "{\\"type\\":\\"pie\\",\\"addTooltip\\":true,\\"legendDisplay\\":\\"show\\",\\"legendPosition\\":\\"right\\",\\"legendSize\\":\\"large\\",\\"isDonut\\":true,\\"labels\\":{\\"show\\":true,\\"values\\":true,\\"last_level\\":true,\\"truncate\\":100},\\"dimensions\\":{\\"metric\\":{\\"accessor\\":0,\\"format\\":{\\"id\\":\\"number\\"},\\"params\\":{}},\\"buckets\\":[{\\"accessor\\":1,\\"format\\":{\\"id\\":\\"terms\\",\\"params\\":{\\"id\\":\\"string\\",\\"otherBucketLabel\\":\\"Other\\",\\"missingBucketLabel\\":\\"Missing\\",\\"parsedUrl\\":{\\"origin\\":\\"http://localhost:5801\\",\\"pathname\\":\\"/app/visualize\\",\\"basePath\\":\\"\\"}}},\\"params\\":{}}]}}", ], }, "getArgument": [Function], diff --git a/src/plugins/vis_types/xy/public/__snapshots__/to_ast.test.ts.snap b/src/plugins/vis_types/xy/public/__snapshots__/to_ast.test.ts.snap index 7ee1b0d2b2053..048b07dbf34ed 100644 --- a/src/plugins/vis_types/xy/public/__snapshots__/to_ast.test.ts.snap +++ b/src/plugins/vis_types/xy/public/__snapshots__/to_ast.test.ts.snap @@ -32,6 +32,9 @@ Object { "legendPosition": Array [ "top", ], + "legendSize": Array [ + "small", + ], "maxLegendLines": Array [ 1, ], diff --git a/src/plugins/vis_types/xy/public/editor/components/options/point_series/point_series.tsx b/src/plugins/vis_types/xy/public/editor/components/options/point_series/point_series.tsx index 15b5adf00b41f..c12eae1b20b8e 100644 --- a/src/plugins/vis_types/xy/public/editor/components/options/point_series/point_series.tsx +++ b/src/plugins/vis_types/xy/public/editor/components/options/point_series/point_series.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { EuiPanel, EuiTitle, EuiSpacer } from '@elastic/eui'; import { Position } from '@elastic/charts'; import { i18n } from '@kbn/i18n'; @@ -20,6 +20,7 @@ import { } from '@kbn/vis-default-editor-plugin/public'; import { BUCKET_TYPES } from '@kbn/data-plugin/public'; +import { LegendSize } from '@kbn/visualizations-plugin/public'; import { VisParams } from '../../../../types'; import { GridPanel } from './grid_panel'; import { ThresholdPanel } from './threshold_panel'; @@ -41,6 +42,10 @@ export function PointSeriesOptions(props: ValidationVisOptionsProps) [stateParams.seriesParams, aggs.aggs] ); + const legendSize = stateParams.legendSize; + + const [hadAutoLegendSize] = useState(() => legendSize === LegendSize.AUTO); + const handleLegendSizeChange = useCallback((size) => setValue('legendSize', size), [setValue]); return ( @@ -64,12 +69,13 @@ export function PointSeriesOptions(props: ValidationVisOptionsProps) setValue={setValue} /> {vis.data.aggs!.aggs.some( diff --git a/src/plugins/vis_types/xy/public/expression_functions/xy_vis_fn.ts b/src/plugins/vis_types/xy/public/expression_functions/xy_vis_fn.ts index 96c4ab112caf1..08319e8e9a11b 100644 --- a/src/plugins/vis_types/xy/public/expression_functions/xy_vis_fn.ts +++ b/src/plugins/vis_types/xy/public/expression_functions/xy_vis_fn.ts @@ -13,7 +13,12 @@ import type { Datatable, Render, } from '@kbn/expressions-plugin/common'; -import { prepareLogTable, Dimension } from '@kbn/visualizations-plugin/public'; +import { + prepareLogTable, + Dimension, + DEFAULT_LEGEND_SIZE, + LegendSize, +} from '@kbn/visualizations-plugin/public'; import type { ChartType } from '../../common'; import type { VisParams, XYVisConfig } from '../types'; @@ -73,10 +78,19 @@ export const visTypeXyVisFn = (): VisTypeXyExpressionFunctionDefinition => ({ }), }, legendSize: { - types: ['number'], + types: ['string'], + default: DEFAULT_LEGEND_SIZE, help: i18n.translate('visTypeXy.function.args.args.legendSize.help', { - defaultMessage: 'Specifies the legend size in pixels.', + defaultMessage: 'Specifies the legend size.', }), + options: [ + LegendSize.AUTO, + LegendSize.SMALL, + LegendSize.MEDIUM, + LegendSize.LARGE, + LegendSize.EXTRA_LARGE, + ], + strict: true, }, addLegend: { types: ['boolean'], diff --git a/src/plugins/vis_types/xy/public/sample_vis.test.mocks.ts b/src/plugins/vis_types/xy/public/sample_vis.test.mocks.ts index 436a284b1657a..3c1d87d2efc3c 100644 --- a/src/plugins/vis_types/xy/public/sample_vis.test.mocks.ts +++ b/src/plugins/vis_types/xy/public/sample_vis.test.mocks.ts @@ -5,6 +5,9 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ + +import { LegendSize } from '@kbn/visualizations-plugin/common'; + export const sampleAreaVis = { type: { name: 'area', @@ -282,6 +285,7 @@ export const sampleAreaVis = { addTooltip: true, addLegend: true, legendPosition: 'top', + legendSize: LegendSize.SMALL, times: [], addTimeMarker: false, truncateLegend: true, diff --git a/src/plugins/vis_types/xy/public/types/param.ts b/src/plugins/vis_types/xy/public/types/param.ts index 708eb1cbdd196..a491efad97fcb 100644 --- a/src/plugins/vis_types/xy/public/types/param.ts +++ b/src/plugins/vis_types/xy/public/types/param.ts @@ -15,6 +15,7 @@ import type { FakeParams, HistogramParams, DateHistogramParams, + LegendSize, } from '@kbn/visualizations-plugin/public'; import type { ChartType, XyVisType } from '../../common'; import type { @@ -124,7 +125,7 @@ export interface VisParams { addTimeMarker: boolean; truncateLegend: boolean; maxLegendLines: number; - legendSize?: number; + legendSize?: LegendSize; categoryAxes: CategoryAxis[]; orderBucketsBySum?: boolean; labels: Labels; @@ -165,7 +166,7 @@ export interface XYVisConfig { addTimeMarker: boolean; truncateLegend: boolean; maxLegendLines: number; - legendSize?: number; + legendSize?: LegendSize; orderBucketsBySum?: boolean; labels: ExpressionValueLabel; thresholdLine: ExpressionValueThresholdLine; diff --git a/src/plugins/vis_types/xy/public/vis_component.tsx b/src/plugins/vis_types/xy/public/vis_component.tsx index 7c0636ab284fb..a744841601a67 100644 --- a/src/plugins/vis_types/xy/public/vis_component.tsx +++ b/src/plugins/vis_types/xy/public/vis_component.tsx @@ -33,7 +33,11 @@ import { useActiveCursor, } from '@kbn/charts-plugin/public'; import { Datatable, IInterpreterRenderHandlers } from '@kbn/expressions-plugin/public'; -import type { PersistedState } from '@kbn/visualizations-plugin/public'; +import { + DEFAULT_LEGEND_SIZE, + LegendSizeToPixels, + PersistedState, +} from '@kbn/visualizations-plugin/public'; import { VisParams } from './types'; import { getAdjustedDomain, @@ -361,7 +365,7 @@ const VisComponent = (props: VisComponentProps) => { tooltip: { visible: syncTooltips, placement: Placement.Right }, }} legendPosition={legendPosition} - legendSize={visParams.legendSize} + legendSize={LegendSizeToPixels[visParams.legendSize ?? DEFAULT_LEGEND_SIZE]} xDomain={xDomain} adjustedXDomain={adjustedXDomain} legendColorPicker={legendColorPicker} diff --git a/src/plugins/visualizations/common/constants.ts b/src/plugins/visualizations/common/constants.ts index 0b840c8ff13fc..ea695e6bdca02 100644 --- a/src/plugins/visualizations/common/constants.ts +++ b/src/plugins/visualizations/common/constants.ts @@ -26,3 +26,21 @@ export const VisualizeConstants = { EDIT_BY_VALUE_PATH: '/edit_by_value', APP_ID: 'visualize', }; + +export enum LegendSize { + AUTO = 'auto', + SMALL = 'small', + MEDIUM = 'medium', + LARGE = 'large', + EXTRA_LARGE = 'xlarge', +} + +export const LegendSizeToPixels = { + [LegendSize.AUTO]: undefined, + [LegendSize.SMALL]: 80, + [LegendSize.MEDIUM]: 130, + [LegendSize.LARGE]: 180, + [LegendSize.EXTRA_LARGE]: 230, +} as const; + +export const DEFAULT_LEGEND_SIZE = LegendSize.MEDIUM; diff --git a/src/plugins/visualizations/common/index.ts b/src/plugins/visualizations/common/index.ts index d784fcfd09eb9..1dd9a0e90477c 100644 --- a/src/plugins/visualizations/common/index.ts +++ b/src/plugins/visualizations/common/index.ts @@ -13,3 +13,4 @@ export * from './types'; export * from './utils'; export * from './expression_functions'; +export { LegendSize, LegendSizeToPixels, DEFAULT_LEGEND_SIZE } from './constants'; diff --git a/src/plugins/visualizations/public/index.ts b/src/plugins/visualizations/public/index.ts index 22217e9de9abe..67b13c8236708 100644 --- a/src/plugins/visualizations/public/index.ts +++ b/src/plugins/visualizations/public/index.ts @@ -56,6 +56,9 @@ export { VISUALIZE_ENABLE_LABS_SETTING, SAVED_OBJECTS_LIMIT_SETTING, SAVED_OBJECTS_PER_PAGE_SETTING, + LegendSize, + LegendSizeToPixels, + DEFAULT_LEGEND_SIZE, } from '../common/constants'; export type { SavedVisState, VisParams, Dimension } from '../common'; export { prepareLogTable } from '../common'; diff --git a/src/plugins/visualizations/server/embeddable/make_visualize_embeddable_factory.ts b/src/plugins/visualizations/server/embeddable/make_visualize_embeddable_factory.ts index d92810743bed4..1d8a00ab2e33b 100644 --- a/src/plugins/visualizations/server/embeddable/make_visualize_embeddable_factory.ts +++ b/src/plugins/visualizations/server/embeddable/make_visualize_embeddable_factory.ts @@ -26,6 +26,7 @@ import { commonAddDropLastBucketIntoTSVBModel714Above, commonRemoveMarkdownLessFromTSVB, commonUpdatePieVisApi, + commonPreserveOldLegendSizeDefault, } from '../migrations/visualization_common_migrations'; import { SerializedVis } from '../../common'; @@ -97,6 +98,11 @@ const byValueUpdatePieVisApi = (state: SerializableRecord) => ({ savedVis: commonUpdatePieVisApi(state.savedVis), }); +const byValuePreserveOldLegendSizeDefault = (state: SerializableRecord) => ({ + ...state, + savedVis: commonPreserveOldLegendSizeDefault(state.savedVis), +}); + const getEmbeddedVisualizationSearchSourceMigrations = ( searchSourceMigrations: MigrateFunctionsObject ) => @@ -144,6 +150,7 @@ export const makeVisualizeEmbeddableFactory = '7.17.0': (state) => flow(byValueAddDropLastBucketIntoTSVBModel714Above)(state), '8.0.0': (state) => flow(byValueRemoveMarkdownLessFromTSVB)(state), '8.1.0': (state) => flow(byValueUpdatePieVisApi)(state), + '8.3.0': (state) => flow(byValuePreserveOldLegendSizeDefault)(state), } ), }; diff --git a/src/plugins/visualizations/server/migrations/visualization_common_migrations.ts b/src/plugins/visualizations/server/migrations/visualization_common_migrations.ts index aec452e356abe..57d8142822882 100644 --- a/src/plugins/visualizations/server/migrations/visualization_common_migrations.ts +++ b/src/plugins/visualizations/server/migrations/visualization_common_migrations.ts @@ -215,3 +215,34 @@ export const commonUpdatePieVisApi = (visState: any) => { return visState; }; + +export const commonPreserveOldLegendSizeDefault = (visState: any) => { + const visualizationTypesWithLegends = [ + 'pie', + 'area', + 'histogram', + 'horizontal_bar', + 'line', + 'heatmap', + ]; + + const pixelsToLegendSize: Record = { + undefined: 'auto', + '80': 'small', + '130': 'medium', + '180': 'large', + '230': 'xlarge', + }; + + if (visualizationTypesWithLegends.includes(visState?.type)) { + return { + ...visState, + params: { + ...visState.params, + legendSize: pixelsToLegendSize[visState.params?.legendSize], + }, + }; + } + + return visState; +}; diff --git a/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.test.ts b/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.test.ts index 19f117ec18cc8..626dc14e05396 100644 --- a/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.test.ts +++ b/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.test.ts @@ -2564,4 +2564,63 @@ describe('migration visualization', () => { expect(otherParams.addLegend).toBeUndefined(); }); }); + + describe('8.3.0 - preserves default legend size for existing visualizations', () => { + const getDoc = (type: string, legendSize: number | undefined) => ({ + attributes: { + title: 'Some Vis with a Legend', + description: '', + visState: JSON.stringify({ + type, + title: 'Pie vis', + params: { + legendSize, + }, + }), + }, + }); + const migrate = (doc: any) => + visualizationSavedObjectTypeMigrations['8.3.0']( + doc as Parameters[0], + savedObjectMigrationContext + ); + + const autoLegendSize = 'auto'; + const largeLegendSize = 'large'; + const largeLegendSizePx = 180; + + test.each([ + ['pie', undefined, autoLegendSize], + ['area', undefined, autoLegendSize], + ['histogram', undefined, autoLegendSize], + ['horizontal_bar', undefined, autoLegendSize], + ['line', undefined, autoLegendSize], + ['heatmap', undefined, autoLegendSize], + ['pie', largeLegendSizePx, largeLegendSize], + ['area', largeLegendSizePx, largeLegendSize], + ['histogram', largeLegendSizePx, largeLegendSize], + ['horizontal_bar', largeLegendSizePx, largeLegendSize], + ['line', largeLegendSizePx, largeLegendSize], + ['heatmap', largeLegendSizePx, largeLegendSize], + ])( + 'given a %s visualization with current legend size of %s -- sets legend size to %s', + ( + visualizationType: string, + currentLegendSize: number | undefined, + expectedLegendSize: string + ) => { + const visState = JSON.parse( + migrate(getDoc(visualizationType, currentLegendSize)).attributes.visState + ); + + expect(visState.params.legendSize).toBe(expectedLegendSize); + } + ); + + test.each(['metric', 'gauge', 'table'])('leaves visualization without legend alone: %s', () => { + const visState = JSON.parse(migrate(getDoc('table', undefined)).attributes.visState); + + expect(visState.params.legendSize).toBeUndefined(); + }); + }); }); diff --git a/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.ts b/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.ts index d236ad83c853a..bb2d68cfd35d9 100644 --- a/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.ts +++ b/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.ts @@ -28,6 +28,7 @@ import { commonAddDropLastBucketIntoTSVBModel714Above, commonRemoveMarkdownLessFromTSVB, commonUpdatePieVisApi, + commonPreserveOldLegendSizeDefault, } from './visualization_common_migrations'; import { VisualizationSavedObjectAttributes } from '../../common'; @@ -1158,6 +1159,30 @@ export const updatePieVisApi: SavedObjectMigrationFn = (doc) => { return doc; }; +const preserveOldLegendSizeDefault: SavedObjectMigrationFn = (doc) => { + const visStateJSON = get(doc, 'attributes.visState'); + let visState; + + if (visStateJSON) { + try { + visState = JSON.parse(visStateJSON); + } catch (e) { + // Let it go, the data is invalid and we'll leave it as is + } + + const newVisState = commonPreserveOldLegendSizeDefault(visState); + return { + ...doc, + attributes: { + ...doc.attributes, + visState: JSON.stringify(newVisState), + }, + }; + } + + return doc; +}; + const visualizationSavedObjectTypeMigrations = { /** * We need to have this migration twice, once with a version prior to 7.0.0 once with a version @@ -1214,6 +1239,7 @@ const visualizationSavedObjectTypeMigrations = { '7.17.0': flow(addDropLastBucketIntoTSVBModel714Above), '8.0.0': flow(removeMarkdownLessFromTSVB), '8.1.0': flow(updatePieVisApi), + '8.3.0': preserveOldLegendSizeDefault, }; /** diff --git a/test/functional/apps/dashboard/group2/dashboard_snapshots.ts b/test/functional/apps/dashboard/group2/dashboard_snapshots.ts index dc1a74ea74b7d..56dcfe2388bc2 100644 --- a/test/functional/apps/dashboard/group2/dashboard_snapshots.ts +++ b/test/functional/apps/dashboard/group2/dashboard_snapshots.ts @@ -84,7 +84,7 @@ export default function ({ ); await PageObjects.dashboard.clickExitFullScreenLogoButton(); - expect(percentDifference).to.be.lessThan(0.022); + expect(percentDifference).to.be.lessThan(0.029); }); }); } diff --git a/test/functional/screenshots/baseline/area_chart.png b/test/functional/screenshots/baseline/area_chart.png index 851f53499e94fb8c3837e98ab820d9ddbfaeb24f..7bbaa256f036098b8b8256e4a39e111024b65a42 100644 GIT binary patch literal 190127 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 z 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

V>&!E(b0i)#P+!`Y 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�SrmkHV9x%DTnE-k!Z-}#HYI40bL!Xc|b8_={zqlgsLR@%tPfgjzOCgeX%W%v@8d zU;SfLi4IN+dqUd9Al3HMcN1RT&C~o?l!VBvvnGS2Z>fV@AFHMj0@3`gEI#$=6iLk< zOJxsB?JygfQm_MR|2*;j4vs_5_`MU<^%=pSc@5p<@Dv8*wP6$H#&|Ji8|T*^vx(g< z=!{t0RK3N;8Nd9lfd9Cf;V z9Z?mKHe{P>aWj-G;iK4)lYOr%8n0t2l+5Kd#zla9-Yi;OyN7u;syM4jeAvKQw=Y{EDHgH6J16Pn zUVHv$q+WG<8( zdgVLFeHCtubOnd|aw1u0y$Ln@-D80Vvn`<$^jlg4KhT#3*wE(0OO$1YN zRm5zW16Tf?4vWjszxejzqL*LktEq9}Q;T6h;<*lWJ-qUUFFm~8zwH>c!1&?W@Q{QA zH%-uo8y^CcXRc2nme{oh_>7G5c;ux1+p|KFd729)TlLJdRnHF7qLP9O3n_muIenqo z=WKwB&_#6(G8YO`Z0~Ywdh~uo+UN zHV@8t$HBdAHBx$R?oGtS1!k7@%t4Vd)K_bxvZCUlc(if4R`%9FUyr=CwUCt5M^5{G zmx{1K-=A+(^z$MFQTpp*`XooN!{e_`y-I8)(M@pmynym@o}hbc!Dq$nrRC+)?~-Cy zr(|7CS6&t#B%@=S7l}&)XNS6}7%lRG5xM@2{$+>w;m={3&YPZ=;J%Rh5@jPO~=MjZkZSwU_}# zct?TFc>|llBBpCSod0k}dfmQ+uA#)VuFgAcM7!7(V`Ii9uOB)VW24{9H+50xV>~jt zcr~%B0r4vy+8W}m6Me@`_Qy%^@pOeuT>lnC^MajJg9>VISU>*s@w8el=Bk}t@#+{o zIejp}Z~`LVn=EeJrd7p zS2MILJ8C6zknQBilE&a)p-BV2@ItZfPOU*W_>s*1=GNw5roP3H`>>nX)r0wX(GR0#%S$A4>I+)Y(a{U- zd9TY68e>gyg5BNSgFj(U&W_f;M>*v>zJY*?3keMg)D&Khc6@b>^gySOS(-@c@j)D1 z^B6rjHm}XI>eSNOkJo0EEmA!bJ;|Wp98Z?`wK$>~86u(@pvpfj7C~hL;X_itATead z`+Kkk@EVe>{&`{#w^uz->ILkPmXB27v%0ADto`oFEX8W5;$_^!xOANe%P&--niel} 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

A-A;AR&`jrF( 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

vx;Fa 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 diff --git a/x-pack/plugins/lens/common/types.ts b/x-pack/plugins/lens/common/types.ts index f85c8ba88a076..43f4afb9e0484 100644 --- a/x-pack/plugins/lens/common/types.ts +++ b/x-pack/plugins/lens/common/types.ts @@ -12,6 +12,7 @@ import type { CustomPaletteParams, PaletteOutput } from '@kbn/coloring'; import type { IFieldFormat, SerializedFieldFormat } from '@kbn/field-formats-plugin/common'; import type { Datatable } from '@kbn/expressions-plugin/common'; import type { ColorMode } from '@kbn/charts-plugin/common'; +import { LegendSize } from '@kbn/visualizations-plugin/common'; import { CategoryDisplay, layerTypes, @@ -83,7 +84,7 @@ export interface SharedPieLayerState { percentDecimals?: number; emptySizeRatio?: number; legendMaxLines?: number; - legendSize?: number; + legendSize?: LegendSize; truncateLegend?: boolean; } diff --git a/x-pack/plugins/lens/public/heatmap_visualization/toolbar_component.tsx b/x-pack/plugins/lens/public/heatmap_visualization/toolbar_component.tsx index a8d94b743adf8..96f4ef7daf89b 100644 --- a/x-pack/plugins/lens/public/heatmap_visualization/toolbar_component.tsx +++ b/x-pack/plugins/lens/public/heatmap_visualization/toolbar_component.tsx @@ -5,10 +5,11 @@ * 2.0. */ -import React, { memo } from 'react'; +import React, { memo, useState } from 'react'; import { EuiFlexGroup, EuiFlexItem, IconType } from '@elastic/eui'; import { Position } from '@elastic/charts'; import { i18n } from '@kbn/i18n'; +import { LegendSize } from '@kbn/visualizations-plugin/public'; import type { VisualizationToolbarProps } from '../types'; import { LegendSettingsPopover, @@ -49,6 +50,11 @@ export const HeatmapToolbar = memo( state, frame.datasourceLayers ).truncateText; + + const legendSize = state?.legend.legendSize; + + const [hadAutoLegendSize] = useState(() => legendSize === LegendSize.AUTO); + return ( @@ -112,16 +118,17 @@ export const HeatmapToolbar = memo( legend: { ...state.legend, shouldTruncate: !current }, }); }} - legendSize={state?.legend.legendSize} - onLegendSizeChange={(legendSize) => { + legendSize={legendSize} + onLegendSizeChange={(newLegendSize) => { setState({ ...state, legend: { ...state.legend, - legendSize, + legendSize: newLegendSize, }, }); }} + showAutoLegendSizeOption={hadAutoLegendSize} /> diff --git a/x-pack/plugins/lens/public/heatmap_visualization/visualization.tsx b/x-pack/plugins/lens/public/heatmap_visualization/visualization.tsx index 23b484f4cfd13..f0a8e65889e8e 100644 --- a/x-pack/plugins/lens/public/heatmap_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/heatmap_visualization/visualization.tsx @@ -304,6 +304,7 @@ export const getHeatmapVisualization = ({ if (!originalOrder || !state.valueAccessor) { return null; } + return { type: 'expression', chain: [ diff --git a/x-pack/plugins/lens/public/pie_visualization/to_expression.ts b/x-pack/plugins/lens/public/pie_visualization/to_expression.ts index 9f506c7beb878..73ddeea627967 100644 --- a/x-pack/plugins/lens/public/pie_visualization/to_expression.ts +++ b/x-pack/plugins/lens/public/pie_visualization/to_expression.ts @@ -134,20 +134,22 @@ const generateCommonArguments: GenerateExpressionAstArguments = ( layer, datasourceLayers, paletteService -) => ({ - labels: generateCommonLabelsAstArgs(state, attributes, layer), - buckets: operations.map((o) => o.columnId).map(prepareDimension), - metric: layer.metric ? [prepareDimension(layer.metric)] : [], - legendDisplay: [attributes.isPreview ? LegendDisplay.HIDE : layer.legendDisplay], - legendPosition: [layer.legendPosition || Position.Right], - maxLegendLines: [layer.legendMaxLines ?? 1], - legendSize: layer.legendSize ? [layer.legendSize] : [], - nestedLegend: [!!layer.nestedLegend], - truncateLegend: [ - layer.truncateLegend ?? getDefaultVisualValuesForLayer(state, datasourceLayers).truncateText, - ], - palette: generatePaletteAstArguments(paletteService, state.palette), -}); +) => { + return { + labels: generateCommonLabelsAstArgs(state, attributes, layer), + buckets: operations.map((o) => o.columnId).map(prepareDimension), + metric: layer.metric ? [prepareDimension(layer.metric)] : [], + legendDisplay: [attributes.isPreview ? LegendDisplay.HIDE : layer.legendDisplay], + legendPosition: [layer.legendPosition || Position.Right], + maxLegendLines: [layer.legendMaxLines ?? 1], + legendSize: layer.legendSize ? [layer.legendSize] : [], + nestedLegend: [!!layer.nestedLegend], + truncateLegend: [ + layer.truncateLegend ?? getDefaultVisualValuesForLayer(state, datasourceLayers).truncateText, + ], + palette: generatePaletteAstArguments(paletteService, state.palette), + }; +}; const generatePieVisAst: GenerateExpressionAstFunction = (...rest) => ({ type: 'expression', diff --git a/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx b/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx index 67659d91ecefe..fefa01d708dc7 100644 --- a/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx @@ -6,7 +6,7 @@ */ import './toolbar.scss'; -import React, { useCallback } from 'react'; +import React, { useCallback, useState } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiFlexGroup, @@ -18,6 +18,7 @@ import { } from '@elastic/eui'; import type { Position } from '@elastic/charts'; import type { PaletteRegistry } from '@kbn/coloring'; +import { LegendSize } from '@kbn/visualizations-plugin/public'; import { DEFAULT_PERCENT_DECIMALS } from './constants'; import { PartitionChartsMeta } from './partition_charts_meta'; import { LegendDisplay, PieVisualizationState, SharedPieLayerState } from '../../common'; @@ -73,6 +74,10 @@ export function PieToolbar(props: VisualizationToolbarProps legendSize === LegendSize.AUTO); + const onStateChange = useCallback( (part: Record) => { setState({ @@ -259,8 +264,9 @@ export function PieToolbar(props: VisualizationToolbarProps ); diff --git a/x-pack/plugins/lens/public/shared_components/legend_settings_popover.test.tsx b/x-pack/plugins/lens/public/shared_components/legend_settings_popover.test.tsx index 9bf9a1885e6ac..777f2860cb8b6 100644 --- a/x-pack/plugins/lens/public/shared_components/legend_settings_popover.test.tsx +++ b/x-pack/plugins/lens/public/shared_components/legend_settings_popover.test.tsx @@ -36,6 +36,7 @@ describe('Legend Settings', () => { props = { legendOptions, mode: 'auto', + showAutoLegendSizeOption: true, onDisplayChange: jest.fn(), onPositionChange: jest.fn(), onLegendSizeChange: jest.fn(), diff --git a/x-pack/plugins/lens/public/shared_components/legend_settings_popover.tsx b/x-pack/plugins/lens/public/shared_components/legend_settings_popover.tsx index 944c55fb56091..e0dd990f0a99e 100644 --- a/x-pack/plugins/lens/public/shared_components/legend_settings_popover.tsx +++ b/x-pack/plugins/lens/public/shared_components/legend_settings_popover.tsx @@ -16,6 +16,7 @@ import { } from '@elastic/eui'; import { Position, VerticalAlignment, HorizontalAlignment } from '@elastic/charts'; import { ToolbarButtonProps } from '@kbn/kibana-react-plugin/public'; +import { LegendSize } from '@kbn/visualizations-plugin/public'; import { ToolbarPopover } from '.'; import { LegendLocationSettings } from './legend_location_settings'; import { ColumnsNumberSetting } from './columns_number_setting'; @@ -122,11 +123,16 @@ export interface LegendSettingsPopoverProps { /** * Legend size in pixels */ - legendSize?: number; + legendSize?: LegendSize; /** * Callback on legend size change */ - onLegendSizeChange: (size?: number) => void; + onLegendSizeChange: (size?: LegendSize) => void; + /** + * Whether to show auto legend size option. Should only be true for pre 8.3 visualizations that already had it as their setting. + * (We're trying to get people to stop using it so it can eventually be removed.) + */ + showAutoLegendSizeOption: boolean; } const DEFAULT_TRUNCATE_LINES = 1; @@ -185,6 +191,7 @@ export const LegendSettingsPopover: React.FunctionComponent {}, legendSize, onLegendSizeChange, + showAutoLegendSizeOption, }) => { return ( )} {location && ( diff --git a/x-pack/plugins/lens/public/shared_components/legend_size_settings.test.tsx b/x-pack/plugins/lens/public/shared_components/legend_size_settings.test.tsx new file mode 100644 index 0000000000000..9d5fa2cd8794c --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/legend_size_settings.test.tsx @@ -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 React from 'react'; +import { LegendSizeSettings } from './legend_size_settings'; +import { EuiSuperSelect } from '@elastic/eui'; +import { shallow } from 'enzyme'; +import { DEFAULT_LEGEND_SIZE, LegendSize } from '@kbn/visualizations-plugin/public'; + +describe('legend size settings', () => { + it('renders nothing if not vertical legend', () => { + const instance = shallow( + {}} + isVerticalLegend={false} + showAutoOption={false} + /> + ); + + expect(instance.html()).toBeNull(); + }); + + it('defaults to correct value', () => { + const instance = shallow( + {}} + isVerticalLegend={true} + showAutoOption={false} + /> + ); + + expect(instance.find(EuiSuperSelect).props().valueOfSelected).toBe( + DEFAULT_LEGEND_SIZE.toString() + ); + }); + + it('reflects current setting in select', () => { + const CURRENT_SIZE = LegendSize.SMALL; + + const instance = shallow( + {}} + isVerticalLegend={true} + showAutoOption={false} + /> + ); + + expect(instance.find(EuiSuperSelect).props().valueOfSelected).toBe(CURRENT_SIZE); + }); + + it('allows user to select a new option', () => { + const onSizeChange = jest.fn(); + + const instance = shallow( + + ); + + const onChange = instance.find(EuiSuperSelect).props().onChange; + + onChange(LegendSize.EXTRA_LARGE); + onChange(DEFAULT_LEGEND_SIZE); + + expect(onSizeChange).toHaveBeenNthCalledWith(1, LegendSize.EXTRA_LARGE); + expect(onSizeChange).toHaveBeenNthCalledWith(2, undefined); + }); + + it('hides "auto" option if visualization not using it', () => { + const getOptions = (showAutoOption: boolean) => + shallow( + {}} + isVerticalLegend={true} + showAutoOption={showAutoOption} + /> + ) + .find(EuiSuperSelect) + .props().options; + + const autoOption = expect.objectContaining({ value: LegendSize.AUTO }); + + expect(getOptions(true)).toContainEqual(autoOption); + expect(getOptions(false)).not.toContainEqual(autoOption); + }); +}); diff --git a/x-pack/plugins/lens/public/shared_components/legend_size_settings.tsx b/x-pack/plugins/lens/public/shared_components/legend_size_settings.tsx index 53da283de0b68..15e74f2601b2b 100644 --- a/x-pack/plugins/lens/public/shared_components/legend_size_settings.tsx +++ b/x-pack/plugins/lens/public/shared_components/legend_size_settings.tsx @@ -8,48 +8,36 @@ import React, { useEffect, useCallback } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiFormRow, EuiSuperSelect } from '@elastic/eui'; - -export enum LegendSizes { - AUTO = '0', - SMALL = '80', - MEDIUM = '130', - LARGE = '180', - EXTRA_LARGE = '230', -} +import { DEFAULT_LEGEND_SIZE, LegendSize } from '@kbn/visualizations-plugin/public'; interface LegendSizeSettingsProps { - legendSize: number | undefined; - onLegendSizeChange: (size?: number) => void; + legendSize?: LegendSize; + onLegendSizeChange: (size?: LegendSize) => void; isVerticalLegend: boolean; + showAutoOption: boolean; } -const legendSizeOptions: Array<{ value: LegendSizes; inputDisplay: string }> = [ - { - value: LegendSizes.AUTO, - inputDisplay: i18n.translate('xpack.lens.shared.legendSizeSetting.legendSizeOptions.auto', { - defaultMessage: 'Auto', - }), - }, +const legendSizeOptions: Array<{ value: LegendSize; inputDisplay: string }> = [ { - value: LegendSizes.SMALL, + value: LegendSize.SMALL, inputDisplay: i18n.translate('xpack.lens.shared.legendSizeSetting.legendSizeOptions.small', { defaultMessage: 'Small', }), }, { - value: LegendSizes.MEDIUM, + value: LegendSize.MEDIUM, inputDisplay: i18n.translate('xpack.lens.shared.legendSizeSetting.legendSizeOptions.medium', { defaultMessage: 'Medium', }), }, { - value: LegendSizes.LARGE, + value: LegendSize.LARGE, inputDisplay: i18n.translate('xpack.lens.shared.legendSizeSetting.legendSizeOptions.large', { defaultMessage: 'Large', }), }, { - value: LegendSizes.EXTRA_LARGE, + value: LegendSize.EXTRA_LARGE, inputDisplay: i18n.translate( 'xpack.lens.shared.legendSizeSetting.legendSizeOptions.extraLarge', { @@ -63,6 +51,7 @@ export const LegendSizeSettings = ({ legendSize, onLegendSizeChange, isVerticalLegend, + showAutoOption, }: LegendSizeSettingsProps) => { useEffect(() => { if (legendSize && !isVerticalLegend) { @@ -71,12 +60,27 @@ export const LegendSizeSettings = ({ }, [isVerticalLegend, legendSize, onLegendSizeChange]); const onLegendSizeOptionChange = useCallback( - (option) => onLegendSizeChange(Number(option) || undefined), + (option: LegendSize) => onLegendSizeChange(option === DEFAULT_LEGEND_SIZE ? undefined : option), [onLegendSizeChange] ); if (!isVerticalLegend) return null; + const options = showAutoOption + ? [ + { + value: LegendSize.AUTO, + inputDisplay: i18n.translate( + 'xpack.lens.shared.legendSizeSetting.legendSizeOptions.auto', + { + defaultMessage: 'Auto', + } + ), + }, + ...legendSizeOptions, + ] + : legendSizeOptions; + return ( diff --git a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts index 1c73c455dfe9e..1acc53a9db512 100644 --- a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts +++ b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts @@ -10,7 +10,8 @@ import { ScaleType } from '@elastic/charts'; import type { PaletteRegistry } from '@kbn/coloring'; import { EventAnnotationServiceType } from '@kbn/event-annotation-plugin/public'; -import type { AxisExtentConfig, ExtendedYConfig, YConfig } from '@kbn/expression-xy-plugin/common'; +import { LegendSize } from '@kbn/visualizations-plugin/common/constants'; +import type { AxisExtentConfig, YConfig, ExtendedYConfig } from '@kbn/expression-xy-plugin/common'; import type { ExpressionAstExpression } from '@kbn/expressions-plugin/common'; import { State, @@ -214,10 +215,11 @@ export const buildExpression = ( : [], position: !state.legend.isInside ? [state.legend.position] : [], isInside: state.legend.isInside ? [state.legend.isInside] : [], - legendSize: - !state.legend.isInside && state.legend.legendSize - ? [state.legend.legendSize] - : [], + legendSize: state.legend.isInside + ? [LegendSize.AUTO] + : state.legend.legendSize + ? [state.legend.legendSize] + : [], horizontalAlignment: state.legend.horizontalAlignment && state.legend.isInside ? [state.legend.horizontalAlignment] diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/index.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/index.tsx index 00c4e9c8eaeb2..bd39a61b08acd 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/index.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/index.tsx @@ -5,11 +5,12 @@ * 2.0. */ -import React, { memo, useCallback } from 'react'; +import React, { memo, useCallback, useState } from 'react'; import { i18n } from '@kbn/i18n'; import { Position, ScaleType } from '@elastic/charts'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { AxesSettingsConfig, AxisExtentConfig } from '@kbn/expression-xy-plugin/common'; +import { LegendSize } from '@kbn/visualizations-plugin/public'; import type { VisualizationToolbarProps, FramePublicAPI } from '../../types'; import { State, XYState } from '../types'; import { isHorizontalChart } from '../state_helpers'; @@ -294,6 +295,10 @@ export const XyToolbar = memo(function XyToolbar( props.frame.datasourceLayers ).truncateText; + const legendSize = state.legend.legendSize; + + const [hadAutoLegendSize] = useState(() => legendSize === LegendSize.AUTO); + return ( @@ -398,16 +403,17 @@ export const XyToolbar = memo(function XyToolbar( valuesInLegend: !state.valuesInLegend, }); }} - legendSize={state.legend.legendSize} - onLegendSizeChange={(legendSize) => { + legendSize={legendSize} + onLegendSizeChange={(newLegendSize) => { setState({ ...state, legend: { ...state.legend, - legendSize, + legendSize: newLegendSize, }, }); }} + showAutoLegendSizeOption={hadAutoLegendSize} /> diff --git a/x-pack/plugins/lens/server/embeddable/make_lens_embeddable_factory.ts b/x-pack/plugins/lens/server/embeddable/make_lens_embeddable_factory.ts index 215f080d3dbdf..dc3933d852979 100644 --- a/x-pack/plugins/lens/server/embeddable/make_lens_embeddable_factory.ts +++ b/x-pack/plugins/lens/server/embeddable/make_lens_embeddable_factory.ts @@ -14,6 +14,7 @@ import { import { DOC_TYPE } from '../../common'; import { commonEnhanceTableRowHeight, + commonPreserveOldLegendSizeDefault, commonFixValueLabelsInXY, commonLockOldMetricVisSettings, commonMakeReversePaletteAsCustom, @@ -35,7 +36,6 @@ import { LensDocShapePre712, VisState716, VisState810, - VisState820, VisStatePre715, VisStatePre830, } from '../migrations/types'; @@ -113,8 +113,9 @@ export const makeLensEmbeddableFactory = } as unknown as SerializableRecord; }, '8.3.0': (state) => { - const lensState = state as unknown as { attributes: LensDocShape810 }; + const lensState = state as unknown as { attributes: LensDocShape810 }; let migratedLensState = commonLockOldMetricVisSettings(lensState.attributes); + migratedLensState = commonPreserveOldLegendSizeDefault(migratedLensState); migratedLensState = commonFixValueLabelsInXY( migratedLensState as LensDocShape810 ); diff --git a/x-pack/plugins/lens/server/migrations/common_migrations.ts b/x-pack/plugins/lens/server/migrations/common_migrations.ts index 7cafa41f569d4..10fc9b25e6f34 100644 --- a/x-pack/plugins/lens/server/migrations/common_migrations.ts +++ b/x-pack/plugins/lens/server/migrations/common_migrations.ts @@ -255,6 +255,38 @@ export const commonLockOldMetricVisSettings = ( return newAttributes as LensDocShape830; }; +export const commonPreserveOldLegendSizeDefault = ( + attributes: LensDocShape810 +): LensDocShape830 => { + const newAttributes = cloneDeep(attributes); + + const pixelsToLegendSize: Record = { + undefined: 'auto', + '80': 'small', + '130': 'medium', + '180': 'large', + '230': 'xlarge', + }; + + if (['lnsXY', 'lnsHeatmap'].includes(newAttributes.visualizationType + '')) { + const legendConfig = (newAttributes.state.visualization as { legend: { legendSize: number } }) + .legend; + (legendConfig.legendSize as unknown as string) = + pixelsToLegendSize[String(legendConfig.legendSize)]; + } + + if (newAttributes.visualizationType === 'lnsPie') { + const layers = (newAttributes.state.visualization as { layers: Array<{ legendSize: number }> }) + .layers; + + layers.forEach((layer) => { + (layer.legendSize as unknown as string) = pixelsToLegendSize[String(layer.legendSize)]; + }); + } + + return newAttributes as LensDocShape830; +}; + const getApplyCustomVisualizationMigrationToLens = (id: string, migration: MigrateFunction) => { return (savedObject: { attributes: LensDocShape }) => { if (savedObject.attributes.visualizationType !== id) return savedObject; diff --git a/x-pack/plugins/lens/server/migrations/saved_object_migrations.test.ts b/x-pack/plugins/lens/server/migrations/saved_object_migrations.test.ts index af68c5020e420..d43d4c4cb2a38 100644 --- a/x-pack/plugins/lens/server/migrations/saved_object_migrations.test.ts +++ b/x-pack/plugins/lens/server/migrations/saved_object_migrations.test.ts @@ -2065,6 +2065,7 @@ describe('Lens migrations', () => { expect(layer2Columns['4'].params).toHaveProperty('includeEmptyRows', true); }); }); + describe('8.3.0 old metric visualization defaults', () => { const context = { log: { warning: () => {} } } as unknown as SavedObjectMigrationContext; const example = { @@ -2115,6 +2116,74 @@ describe('Lens migrations', () => { }); }); + describe('8.3.0 - convert legend sizes to strings', () => { + const context = { log: { warning: () => {} } } as unknown as SavedObjectMigrationContext; + const migrate = migrations['8.3.0']; + + const autoLegendSize = 'auto'; + const largeLegendSize = 'large'; + const largeLegendSizePx = 180; + + it('works for XY visualization and heatmap', () => { + const getDoc = (type: string, legendSize: number | undefined) => + ({ + attributes: { + visualizationType: type, + state: { + visualization: { + legend: { + legendSize, + }, + }, + }, + }, + } as unknown as SavedObjectUnsanitizedDoc); + + expect( + migrate(getDoc('lnsXY', undefined), context).attributes.state.visualization.legend + .legendSize + ).toBe(autoLegendSize); + expect( + migrate(getDoc('lnsXY', largeLegendSizePx), context).attributes.state.visualization.legend + .legendSize + ).toBe(largeLegendSize); + + expect( + migrate(getDoc('lnsHeatmap', undefined), context).attributes.state.visualization.legend + .legendSize + ).toBe(autoLegendSize); + expect( + migrate(getDoc('lnsHeatmap', largeLegendSizePx), context).attributes.state.visualization + .legend.legendSize + ).toBe(largeLegendSize); + }); + + it('works for pie visualization', () => { + const pieVisDoc = { + attributes: { + visualizationType: 'lnsPie', + state: { + visualization: { + layers: [ + { + legendSize: undefined, + }, + { + legendSize: largeLegendSizePx, + }, + ], + }, + }, + }, + } as unknown as SavedObjectUnsanitizedDoc; + + expect(migrate(pieVisDoc, context).attributes.state.visualization.layers).toEqual([ + { legendSize: autoLegendSize }, + { legendSize: largeLegendSize }, + ]); + }); + }); + describe('8.3.0 valueLabels in XY', () => { const context = { log: { warning: () => {} } } as unknown as SavedObjectMigrationContext; const example = { @@ -2128,6 +2197,7 @@ describe('Lens migrations', () => { state: { visualization: { valueLabels: 'inside', + legend: {}, }, }, }, diff --git a/x-pack/plugins/lens/server/migrations/saved_object_migrations.ts b/x-pack/plugins/lens/server/migrations/saved_object_migrations.ts index 00ec6c29154e3..3870bab9fad65 100644 --- a/x-pack/plugins/lens/server/migrations/saved_object_migrations.ts +++ b/x-pack/plugins/lens/server/migrations/saved_object_migrations.ts @@ -49,6 +49,7 @@ import { commonSetIncludeEmptyRowsDateHistogram, commonFixValueLabelsInXY, commonLockOldMetricVisSettings, + commonPreserveOldLegendSizeDefault, } from './common_migrations'; interface LensDocShapePre710 { @@ -505,6 +506,10 @@ const lockOldMetricVisSettings: SavedObjectMigrationFn ({ ...doc, attributes: commonLockOldMetricVisSettings(doc.attributes) }); +const preserveOldLegendSizeDefault: SavedObjectMigrationFn = ( + doc +) => ({ ...doc, attributes: commonPreserveOldLegendSizeDefault(doc.attributes) }); + const lensMigrations: SavedObjectMigrationMap = { '7.7.0': removeInvalidAccessors, // The order of these migrations matter, since the timefield migration relies on the aggConfigs @@ -524,7 +529,7 @@ const lensMigrations: SavedObjectMigrationMap = { setIncludeEmptyRowsDateHistogram, enhanceTableRowHeight ), - '8.3.0': flow(lockOldMetricVisSettings, fixValueLabelsInXY), + '8.3.0': flow(lockOldMetricVisSettings, preserveOldLegendSizeDefault, fixValueLabelsInXY), }; export const getAllMigrations = ( From 818f5e63b2b2d8ab37da8f77674117b7b30e74f6 Mon Sep 17 00:00:00 2001 From: Sergi Massaneda Date: Tue, 10 May 2022 15:35:45 +0200 Subject: [PATCH 09/14] [Security Solution] [Field Browser] Prevent pagination reset on field selection (#131714) * prevent pagination reset on selection * sorting controls Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../fields_browser/field_table.test.tsx | 145 +++++++++++++----- .../toolbar/fields_browser/field_table.tsx | 65 +++++++- 2 files changed, 167 insertions(+), 43 deletions(-) diff --git a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_table.test.tsx b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_table.test.tsx index 151ed99c3621c..77d5a754d6a97 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_table.test.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_table.test.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { render } from '@testing-library/react'; +import { render, RenderResult } from '@testing-library/react'; import { mockBrowserFields, TestProviders } from '../../../../mock'; import { tGridActions } from '../../../../store/t_grid'; import { defaultColumnHeaderType } from '../../body/column_headers/default_headers'; @@ -155,50 +155,117 @@ describe('FieldTable', () => { expect(checkbox).toHaveAttribute('checked'); }); - it('should dispatch remove column action on field unchecked', () => { - const result = render( - - - - ); + describe('selection', () => { + it('should dispatch remove column action on field unchecked', () => { + const result = render( + + + + ); - result.getByTestId(`field-${timestampFieldId}-checkbox`).click(); + result.getByTestId(`field-${timestampFieldId}-checkbox`).click(); - expect(mockDispatch).toHaveBeenCalledTimes(1); - expect(mockDispatch).toHaveBeenCalledWith( - tGridActions.removeColumn({ id: timelineId, columnId: timestampFieldId }) - ); + expect(mockDispatch).toHaveBeenCalledTimes(1); + expect(mockDispatch).toHaveBeenCalledWith( + tGridActions.removeColumn({ id: timelineId, columnId: timestampFieldId }) + ); + }); + + it('should dispatch upsert column action on field checked', () => { + const result = render( + + + + ); + + result.getByTestId(`field-${timestampFieldId}-checkbox`).click(); + + expect(mockDispatch).toHaveBeenCalledTimes(1); + expect(mockDispatch).toHaveBeenCalledWith( + tGridActions.upsertColumn({ + id: timelineId, + column: { + columnHeaderType: defaultColumnHeaderType, + id: timestampFieldId, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, + }, + index: 1, + }) + ); + }); }); - it('should dispatch upsert column action on field checked', () => { - const result = render( - + describe('pagination', () => { + const isAtFirstPage = (result: RenderResult) => + result.getByTestId('pagination-button-0').classList.contains('euiPaginationButton-isActive'); + + const changePage = (result: RenderResult) => { + result.getByTestId('pagination-button-1').click(); + }; + + const defaultPaginationProps: FieldTableProps = { + ...defaultProps, + filteredBrowserFields: mockBrowserFields, + }; + + it('should paginate on page clicked', () => { + const result = render( + + + + ); + + expect(isAtFirstPage(result)).toBeTruthy(); + + changePage(result); + + expect(isAtFirstPage(result)).toBeFalsy(); + }); + + it('should not reset on field checked', () => { + const result = render( + + + + ); + + changePage(result); + + result.getAllByRole('checkbox').at(0)?.click(); + expect(mockDispatch).toHaveBeenCalled(); // assert some field has been selected + + expect(isAtFirstPage(result)).toBeFalsy(); + }); + + it('should reset on filter change', () => { + const result = render( , + { wrapper: TestProviders } + ); + + changePage(result); + expect(isAtFirstPage(result)).toBeFalsy(); + + result.rerender( + - - ); + ); - result.getByTestId(`field-${timestampFieldId}-checkbox`).click(); - - expect(mockDispatch).toHaveBeenCalledTimes(1); - expect(mockDispatch).toHaveBeenCalledWith( - tGridActions.upsertColumn({ - id: timelineId, - column: { - columnHeaderType: defaultColumnHeaderType, - id: timestampFieldId, - initialWidth: DEFAULT_COLUMN_MIN_WIDTH, - }, - index: 1, - }) - ); + expect(isAtFirstPage(result)).toBeTruthy(); + }); }); }); diff --git a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_table.tsx b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_table.tsx index 684b09d0395ab..4f62cdd246871 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_table.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_table.tsx @@ -5,9 +5,9 @@ * 2.0. */ -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import styled from 'styled-components'; -import { EuiInMemoryTable } from '@elastic/eui'; +import { EuiInMemoryTable, Pagination, Direction } from '@elastic/eui'; import { useDispatch } from 'react-redux'; import { BrowserFields, ColumnHeaderOptions } from '../../../../../common'; import { getColumnHeader, getFieldColumns, getFieldItems, isActionsColumn } from './field_items'; @@ -16,6 +16,11 @@ import { tGridActions } from '../../../../store/t_grid'; import type { GetFieldTableColumns } from '../../../../../common/types/fields_browser'; import { FieldTableHeader } from './field_table_header'; +const DEFAULT_SORTING: { field: string; direction: Direction } = { + field: '', + direction: 'asc', +} as const; + export interface FieldTableProps { timelineId: string; columnHeaders: ColumnHeaderOptions[]; @@ -69,6 +74,12 @@ const FieldTableComponent: React.FC = ({ timelineId, onHide, }) => { + const [pageIndex, setPageIndex] = useState(0); + const [pageSize, setPageSize] = useState(10); + + const [sortField, setSortField] = useState(DEFAULT_SORTING.field); + const [sortDirection, setSortDirection] = useState(DEFAULT_SORTING.direction); + const dispatch = useDispatch(); const fieldItems = useMemo( @@ -103,6 +114,51 @@ const FieldTableComponent: React.FC = ({ [columnHeaders, dispatch, timelineId] ); + /** + * Pagination controls + */ + const pagination: Pagination = useMemo( + () => ({ + pageIndex, + pageSize, + totalItemCount: fieldItems.length, + pageSizeOptions: [10, 25, 50], + }), + [fieldItems.length, pageIndex, pageSize] + ); + + useEffect(() => { + // Resets the pagination when some filter has changed, consequently, the number of fields is different + setPageIndex(0); + }, [fieldItems.length]); + + /** + * Sorting controls + */ + const sorting = useMemo( + () => ({ + sort: { + field: sortField, + direction: sortDirection, + }, + }), + [sortDirection, sortField] + ); + + const onTableChange = useCallback(({ page, sort = DEFAULT_SORTING }) => { + const { index, size } = page; + const { field, direction } = sort; + + setPageIndex(index); + setPageSize(size); + + setSortField(field); + setSortDirection(direction); + }, []); + + /** + * Process columns + */ const columns = useMemo( () => getFieldColumns({ highlight: searchInput, onToggleColumn, getFieldTableColumns, onHide }), [onToggleColumn, searchInput, getFieldTableColumns, onHide] @@ -124,9 +180,10 @@ const FieldTableComponent: React.FC = ({ items={fieldItems} itemId="name" columns={columns} - pagination={true} - sorting={true} + pagination={pagination} + sorting={sorting} hasActions={hasActions} + onChange={onTableChange} compressed /> From 425838b83834256ec04349cf56b59c9e2561c239 Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Tue, 10 May 2022 09:40:07 -0400 Subject: [PATCH 10/14] [Fleet] Cleanup action types and use one document per action instead of one per agent (#131826) --- .../fleet/common/types/models/agent.ts | 48 +++++--------- .../fleet/common/types/rest_spec/agent.ts | 2 +- .../server/routes/agent/actions_handlers.ts | 2 +- .../fleet/server/services/agents/actions.ts | 19 ++++-- .../fleet/server/services/agents/reassign.ts | 17 ++--- .../server/services/agents/saved_objects.ts | 66 +------------------ .../server/services/agents/unenroll.test.ts | 18 +---- .../fleet/server/services/agents/unenroll.ts | 17 ++--- .../fleet/server/services/agents/upgrade.ts | 23 +++---- x-pack/plugins/fleet/server/types/index.tsx | 4 -- 10 files changed, 58 insertions(+), 158 deletions(-) diff --git a/x-pack/plugins/fleet/common/types/models/agent.ts b/x-pack/plugins/fleet/common/types/models/agent.ts index 68c2a0ecb7d88..d41a08b8b4755 100644 --- a/x-pack/plugins/fleet/common/types/models/agent.ts +++ b/x-pack/plugins/fleet/common/types/models/agent.ts @@ -11,8 +11,6 @@ import type { AGENT_TYPE_TEMPORARY, } from '../../constants'; -import type { FullAgentPolicy } from './agent_policy'; - export type AgentType = | typeof AGENT_TYPE_EPHEMERAL | typeof AGENT_TYPE_PERMANENT @@ -41,7 +39,11 @@ export type AgentActionType = export interface NewAgentAction { type: AgentActionType; data?: any; + ack_data?: any; sent_at?: string; + agents: string[]; + created_at?: string; + id?: string; } export interface AgentAction extends NewAgentAction { @@ -49,41 +51,10 @@ export interface AgentAction extends NewAgentAction { data?: any; sent_at?: string; id: string; - agent_id: string; created_at: string; ack_data?: any; } -export interface AgentPolicyAction extends NewAgentAction { - id: string; - type: AgentActionType; - data: { - policy: FullAgentPolicy; - }; - policy_id: string; - policy_revision: number; - created_at: string; - ack_data?: any; -} - -interface CommonAgentActionSOAttributes { - type: AgentActionType; - sent_at?: string; - timestamp?: string; - created_at: string; - data?: string; - ack_data?: string; -} - -export type AgentActionSOAttributes = CommonAgentActionSOAttributes & { - agent_id: string; -}; -export type AgentPolicyActionSOAttributes = CommonAgentActionSOAttributes & { - policy_id: string; - policy_revision: number; -}; -export type BaseAgentActionSOAttributes = AgentActionSOAttributes | AgentPolicyActionSOAttributes; - export interface AgentMetadata { [x: string]: any; } @@ -273,6 +244,17 @@ export interface FleetServerAgentAction { * The Agent IDs the action is intended for. No support for json.RawMessage with the current generator. Could be useful to lazy parse the agent ids */ agents?: string[]; + + /** + * Date when the agent should execute that agent. This field could be altered by Fleet server for progressive rollout of the action. + */ + start_time?: string; + + /** + * Minimun execution duration in seconds, used for progressive rollout of the action. + */ + minimum_execution_duration?: number; + /** * The opaque payload. */ diff --git a/x-pack/plugins/fleet/common/types/rest_spec/agent.ts b/x-pack/plugins/fleet/common/types/rest_spec/agent.ts index 40570bc599053..aa256db95634a 100644 --- a/x-pack/plugins/fleet/common/types/rest_spec/agent.ts +++ b/x-pack/plugins/fleet/common/types/rest_spec/agent.ts @@ -34,7 +34,7 @@ export interface GetOneAgentResponse { export interface PostNewAgentActionRequest { body: { - action: NewAgentAction; + action: Omit; }; params: { agentId: string; diff --git a/x-pack/plugins/fleet/server/routes/agent/actions_handlers.ts b/x-pack/plugins/fleet/server/routes/agent/actions_handlers.ts index 4f3cad9edab26..80a6eac2d81b0 100644 --- a/x-pack/plugins/fleet/server/routes/agent/actions_handlers.ts +++ b/x-pack/plugins/fleet/server/routes/agent/actions_handlers.ts @@ -33,7 +33,7 @@ export const postNewAgentActionHandlerBuilder = function ( const savedAgentAction = await actionsService.createAgentAction(esClient, { created_at: new Date().toISOString(), ...newAgentAction, - agent_id: agent.id, + agents: [agent.id], }); const body: PostNewAgentActionResponse = { diff --git a/x-pack/plugins/fleet/server/services/agents/actions.ts b/x-pack/plugins/fleet/server/services/agents/actions.ts index 3ea8060e8e492..7a13e1612cb0c 100644 --- a/x-pack/plugins/fleet/server/services/agents/actions.ts +++ b/x-pack/plugins/fleet/server/services/agents/actions.ts @@ -8,20 +8,26 @@ import uuid from 'uuid'; import type { ElasticsearchClient } from '@kbn/core/server'; -import type { Agent, AgentAction, FleetServerAgentAction } from '../../../common/types/models'; +import type { + Agent, + AgentAction, + NewAgentAction, + FleetServerAgentAction, +} from '../../../common/types/models'; import { AGENT_ACTIONS_INDEX } from '../../../common/constants'; const ONE_MONTH_IN_MS = 2592000000; export async function createAgentAction( esClient: ElasticsearchClient, - newAgentAction: Omit + newAgentAction: NewAgentAction ): Promise { - const id = uuid.v4(); + const id = newAgentAction.id ?? uuid.v4(); + const timestamp = new Date().toISOString(); const body: FleetServerAgentAction = { - '@timestamp': new Date().toISOString(), + '@timestamp': timestamp, expiration: new Date(Date.now() + ONE_MONTH_IN_MS).toISOString(), - agents: [newAgentAction.agent_id], + agents: newAgentAction.agents, action_id: id, data: newAgentAction.data, type: newAgentAction.type, @@ -37,6 +43,7 @@ export async function createAgentAction( return { id, ...newAgentAction, + created_at: timestamp, }; } @@ -62,7 +69,7 @@ export async function bulkCreateAgentActions( const body: FleetServerAgentAction = { '@timestamp': new Date().toISOString(), expiration: new Date(Date.now() + ONE_MONTH_IN_MS).toISOString(), - agents: [action.agent_id], + agents: action.agents, action_id: action.id, data: action.data, type: action.type, diff --git a/x-pack/plugins/fleet/server/services/agents/reassign.ts b/x-pack/plugins/fleet/server/services/agents/reassign.ts index d342e8d54bb84..c842bfb8f72c7 100644 --- a/x-pack/plugins/fleet/server/services/agents/reassign.ts +++ b/x-pack/plugins/fleet/server/services/agents/reassign.ts @@ -20,7 +20,7 @@ import { bulkUpdateAgents, } from './crud'; import type { GetAgentsOptions } from '.'; -import { createAgentAction, bulkCreateAgentActions } from './actions'; +import { createAgentAction } from './actions'; import { searchHitToAgent } from './helpers'; export async function reassignAgent( @@ -42,7 +42,7 @@ export async function reassignAgent( }); await createAgentAction(esClient, { - agent_id: agentId, + agents: [agentId], created_at: new Date().toISOString(), type: 'POLICY_REASSIGN', }); @@ -161,14 +161,11 @@ export async function reassignAgents( }); const now = new Date().toISOString(); - await bulkCreateAgentActions( - esClient, - agentsToUpdate.map((agent) => ({ - agent_id: agent.id, - created_at: now, - type: 'POLICY_REASSIGN', - })) - ); + await createAgentAction(esClient, { + agents: agentsToUpdate.map((agent) => agent.id), + created_at: now, + type: 'POLICY_REASSIGN', + }); return { items: orderedOut }; } diff --git a/x-pack/plugins/fleet/server/services/agents/saved_objects.ts b/x-pack/plugins/fleet/server/services/agents/saved_objects.ts index a26194ef6ddeb..596c7db5d8472 100644 --- a/x-pack/plugins/fleet/server/services/agents/saved_objects.ts +++ b/x-pack/plugins/fleet/server/services/agents/saved_objects.ts @@ -5,18 +5,9 @@ * 2.0. */ -import Boom from '@hapi/boom'; import type { SavedObject } from '@kbn/core/server'; -import type { - Agent, - AgentSOAttributes, - AgentAction, - AgentPolicyAction, - AgentActionSOAttributes, - AgentPolicyActionSOAttributes, - BaseAgentActionSOAttributes, -} from '../../types'; +import type { Agent, AgentSOAttributes } from '../../types'; export function savedObjectToAgent(so: SavedObject): Agent { if (so.error) { @@ -33,58 +24,3 @@ export function savedObjectToAgent(so: SavedObject): Agent { packages: so.attributes.packages ?? [], }; } - -export function savedObjectToAgentAction(so: SavedObject): AgentAction; -export function savedObjectToAgentAction( - so: SavedObject -): AgentPolicyAction; -export function savedObjectToAgentAction( - so: SavedObject -): AgentAction | AgentPolicyAction { - if (so.error) { - if (so.error.statusCode === 404) { - throw Boom.notFound(so.error.message); - } - - throw new Error(so.error.message); - } - - // If it's an AgentPolicyAction - if (isPolicyActionSavedObject(so)) { - return { - id: so.id, - type: so.attributes.type, - created_at: so.attributes.created_at, - policy_id: so.attributes.policy_id, - policy_revision: so.attributes.policy_revision, - data: so.attributes.data ? JSON.parse(so.attributes.data) : undefined, - ack_data: so.attributes.ack_data ? JSON.parse(so.attributes.ack_data) : undefined, - }; - } - - if (!isAgentActionSavedObject(so)) { - throw new Error(`Malformed saved object AgentAction ${so.id}`); - } - - // If it's an AgentAction - return { - id: so.id, - type: so.attributes.type, - created_at: so.attributes.created_at, - agent_id: so.attributes.agent_id, - data: so.attributes.data ? JSON.parse(so.attributes.data) : undefined, - ack_data: so.attributes.ack_data ? JSON.parse(so.attributes.ack_data) : undefined, - }; -} - -export function isAgentActionSavedObject( - so: SavedObject -): so is SavedObject { - return (so.attributes as AgentActionSOAttributes).agent_id !== undefined; -} - -export function isPolicyActionSavedObject( - so: SavedObject -): so is SavedObject { - return (so.attributes as AgentPolicyActionSOAttributes).policy_id !== undefined; -} diff --git a/x-pack/plugins/fleet/server/services/agents/unenroll.test.ts b/x-pack/plugins/fleet/server/services/agents/unenroll.test.ts index e6327c16c3ccc..45f40916598a1 100644 --- a/x-pack/plugins/fleet/server/services/agents/unenroll.test.ts +++ b/x-pack/plugins/fleet/server/services/agents/unenroll.test.ts @@ -96,7 +96,7 @@ describe('unenrollAgents (plural)', () => { await unenrollAgents(soClient, esClient, { agentIds: idsToUnenroll }); // calls ES update with correct values - const calledWith = esClient.bulk.mock.calls[1][0]; + const calledWith = esClient.bulk.mock.calls[0][0]; const ids = (calledWith as estypes.BulkRequest)?.body ?.filter((i: any) => i.update !== undefined) .map((i: any) => i.update._id); @@ -116,7 +116,7 @@ describe('unenrollAgents (plural)', () => { // calls ES update with correct values const onlyRegular = [agentInRegularDoc._id, agentInRegularDoc2._id]; - const calledWith = esClient.bulk.mock.calls[1][0]; + const calledWith = esClient.bulk.mock.calls[0][0]; const ids = (calledWith as estypes.BulkRequest)?.body ?.filter((i: any) => i.update !== undefined) .map((i: any) => i.update._id); @@ -175,7 +175,7 @@ describe('unenrollAgents (plural)', () => { await unenrollAgents(soClient, esClient, { agentIds: idsToUnenroll, force: true }); // calls ES update with correct values - const calledWith = esClient.bulk.mock.calls[1][0]; + const calledWith = esClient.bulk.mock.calls[0][0]; const ids = (calledWith as estypes.BulkRequest)?.body ?.filter((i: any) => i.update !== undefined) .map((i: any) => i.update._id); @@ -232,18 +232,6 @@ describe('unenrollAgents (plural)', () => { function createClientMock() { const soClientMock = savedObjectsClientMock.create(); - // need to mock .create & bulkCreate due to (bulk)createAgentAction(s) in unenrollAgent(s) - // @ts-expect-error - soClientMock.create.mockResolvedValue({ attributes: { agent_id: 'tata' } }); - soClientMock.bulkCreate.mockImplementation(async ([{ type, attributes }]) => { - return { - saved_objects: [await soClientMock.create(type, attributes)], - }; - }); - soClientMock.bulkUpdate.mockResolvedValue({ - saved_objects: [], - }); - soClientMock.get.mockImplementation(async (_, id) => { switch (id) { case regularAgentPolicySO.id: diff --git a/x-pack/plugins/fleet/server/services/agents/unenroll.ts b/x-pack/plugins/fleet/server/services/agents/unenroll.ts index 461caff1ada6c..92dd0f1ba22f8 100644 --- a/x-pack/plugins/fleet/server/services/agents/unenroll.ts +++ b/x-pack/plugins/fleet/server/services/agents/unenroll.ts @@ -11,7 +11,7 @@ import type { Agent, BulkActionResult } from '../../types'; import * as APIKeyService from '../api_keys'; import { HostedAgentPolicyRestrictionRelatedError } from '../../errors'; -import { createAgentAction, bulkCreateAgentActions } from './actions'; +import { createAgentAction } from './actions'; import type { GetAgentsOptions } from './crud'; import { getAgentById, @@ -53,7 +53,7 @@ export async function unenrollAgent( } const now = new Date().toISOString(); await createAgentAction(esClient, { - agent_id: agentId, + agents: [agentId], created_at: now, type: 'UNENROLL', }); @@ -105,14 +105,11 @@ export async function unenrollAgents( await invalidateAPIKeysForAgents(agentsToUpdate); } else { // Create unenroll action for each agent - await bulkCreateAgentActions( - esClient, - agentsToUpdate.map((agent) => ({ - agent_id: agent.id, - created_at: now, - type: 'UNENROLL', - })) - ); + await createAgentAction(esClient, { + agents: agentsToUpdate.map((agent) => agent.id), + created_at: now, + type: 'UNENROLL', + }); } // Update the necessary agents diff --git a/x-pack/plugins/fleet/server/services/agents/upgrade.ts b/x-pack/plugins/fleet/server/services/agents/upgrade.ts index 36568ca6e0004..00470d5e25f8d 100644 --- a/x-pack/plugins/fleet/server/services/agents/upgrade.ts +++ b/x-pack/plugins/fleet/server/services/agents/upgrade.ts @@ -17,7 +17,7 @@ import { import { isAgentUpgradeable } from '../../../common/services'; import { appContextService } from '../app_context'; -import { bulkCreateAgentActions, createAgentAction } from './actions'; +import { createAgentAction } from './actions'; import type { GetAgentsOptions } from './crud'; import { getAgentDocuments, @@ -59,7 +59,7 @@ export async function sendUpgradeAgentAction({ } await createAgentAction(esClient, { - agent_id: agentId, + agents: [agentId], created_at: now, data, ack_data: data, @@ -75,8 +75,8 @@ export async function sendUpgradeAgentsActions( soClient: SavedObjectsClientContract, esClient: ElasticsearchClient, options: ({ agents: Agent[] } | GetAgentsOptions) & { - sourceUri: string | undefined; version: string; + sourceUri?: string | undefined; force?: boolean; } ) { @@ -158,16 +158,13 @@ export async function sendUpgradeAgentsActions( source_uri: options.sourceUri, }; - await bulkCreateAgentActions( - esClient, - agentsToUpdate.map((agent) => ({ - agent_id: agent.id, - created_at: now, - data, - ack_data: data, - type: 'UPGRADE', - })) - ); + await createAgentAction(esClient, { + created_at: now, + data, + ack_data: data, + type: 'UPGRADE', + agents: agentsToUpdate.map((agent) => agent.id), + }); await bulkUpdateAgents( esClient, diff --git a/x-pack/plugins/fleet/server/types/index.tsx b/x-pack/plugins/fleet/server/types/index.tsx index 6356ff8aa6cac..37dde581d4b8f 100644 --- a/x-pack/plugins/fleet/server/types/index.tsx +++ b/x-pack/plugins/fleet/server/types/index.tsx @@ -12,10 +12,6 @@ export type { AgentStatus, AgentType, AgentAction, - AgentPolicyAction, - BaseAgentActionSOAttributes, - AgentActionSOAttributes, - AgentPolicyActionSOAttributes, PackagePolicy, PackagePolicyInput, PackagePolicyInputStream, From 7b2d8d440861fcd324a4abfbff836e8bda45cc24 Mon Sep 17 00:00:00 2001 From: Michael Dokolin Date: Tue, 10 May 2022 15:48:24 +0200 Subject: [PATCH 11/14] [Reporting] Fix reporting job completion toast to not disappear (#131402) --- .../__snapshots__/stream_handler.test.ts.snap | 62 +++++++++---------- .../public/lib/stream_handler.test.ts | 3 +- .../reporting/public/notifier/job_success.tsx | 7 +++ .../functional/apps/discover/reporting.ts | 4 ++ 4 files changed, 43 insertions(+), 33 deletions(-) diff --git a/x-pack/plugins/reporting/public/lib/__snapshots__/stream_handler.test.ts.snap b/x-pack/plugins/reporting/public/lib/__snapshots__/stream_handler.test.ts.snap index 4187db5b20641..935f3e297b2cb 100644 --- a/x-pack/plugins/reporting/public/lib/__snapshots__/stream_handler.test.ts.snap +++ b/x-pack/plugins/reporting/public/lib/__snapshots__/stream_handler.test.ts.snap @@ -188,42 +188,40 @@ Array [ `; exports[`stream handler showNotifications show success 1`] = ` -Array [ - Object { - "color": "success", - "data-test-subj": "completeReportSuccess", - "text": MountPoint { - "reactNode": -

- -

- +

+ - , - }, - "title": MountPoint { - "reactNode": + , - }, + /> + , }, -] + "title": MountPoint { + "reactNode": , + }, +} `; diff --git a/x-pack/plugins/reporting/public/lib/stream_handler.test.ts b/x-pack/plugins/reporting/public/lib/stream_handler.test.ts index 6f575652450c1..d3075d4e5a906 100644 --- a/x-pack/plugins/reporting/public/lib/stream_handler.test.ts +++ b/x-pack/plugins/reporting/public/lib/stream_handler.test.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { omit } from 'lodash'; import sinon, { stub } from 'sinon'; import { NotificationsStart } from '@kbn/core/public'; import { coreMock, themeServiceMock, docLinksServiceMock } from '@kbn/core/public/mocks'; @@ -123,7 +124,7 @@ describe('stream handler', () => { expect(mockShowDanger.callCount).toBe(0); expect(mockShowSuccess.callCount).toBe(1); expect(mockShowWarning.callCount).toBe(0); - expect(mockShowSuccess.args[0]).toMatchSnapshot(); + expect(omit(mockShowSuccess.args[0][0], 'toastLifeTimeMs')).toMatchSnapshot(); done(); }); }); diff --git a/x-pack/plugins/reporting/public/notifier/job_success.tsx b/x-pack/plugins/reporting/public/notifier/job_success.tsx index 44389e164472a..f7b71d78de8bd 100644 --- a/x-pack/plugins/reporting/public/notifier/job_success.tsx +++ b/x-pack/plugins/reporting/public/notifier/job_success.tsx @@ -37,5 +37,12 @@ export const getSuccessToast = ( , { theme$: theme.theme$ } ), + /** + * If timeout is an Infinity value, a Not-a-Number (NaN) value, or negative, then timeout will be zero. + * And we cannot use `Number.MAX_SAFE_INTEGER` because EUI's Timer implementation + * subtracts it from the current time to evaluate the remainder. + * @see https://www.w3.org/TR/2011/WD-html5-20110525/timers.html + */ + toastLifeTimeMs: Number.MAX_SAFE_INTEGER - Date.now(), 'data-test-subj': 'completeReportSuccess', }); diff --git a/x-pack/test/functional/apps/discover/reporting.ts b/x-pack/test/functional/apps/discover/reporting.ts index 55282dd143b7f..d1eb2e7e03c27 100644 --- a/x-pack/test/functional/apps/discover/reporting.ts +++ b/x-pack/test/functional/apps/discover/reporting.ts @@ -270,6 +270,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await setupPage(); }); + afterEach(async () => { + await PageObjects.reporting.checkForReportingToasts(); + }); + it('generates a report with data', async () => { await PageObjects.discover.loadSavedSearch('Ecommerce Data'); await retry.try(async () => { From 9044d1f76f218798f3761eadb4c4eba499bf2f58 Mon Sep 17 00:00:00 2001 From: Jonathan Budzenski Date: Tue, 10 May 2022 08:57:12 -0500 Subject: [PATCH 12/14] [artifacts] skip smoke tests --- .buildkite/scripts/steps/package_testing/test.sh | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.buildkite/scripts/steps/package_testing/test.sh b/.buildkite/scripts/steps/package_testing/test.sh index e5ed00f760864..390adc2dbacee 100755 --- a/.buildkite/scripts/steps/package_testing/test.sh +++ b/.buildkite/scripts/steps/package_testing/test.sh @@ -41,9 +41,9 @@ trap "echoKibanaLogs" EXIT vagrant provision "$TEST_PACKAGE" -export TEST_BROWSER_HEADLESS=1 -export TEST_KIBANA_URL="http://elastic:changeme@$KIBANA_IP_ADDRESS:5601" -export TEST_ES_URL=http://elastic:changeme@192.168.56.1:9200 +# export TEST_BROWSER_HEADLESS=1 +# export TEST_KIBANA_URL="http://elastic:changeme@$KIBANA_IP_ADDRESS:5601" +# export TEST_ES_URL=http://elastic:changeme@192.168.56.1:9200 -cd x-pack -node scripts/functional_test_runner.js --include-tag=smoke +# cd x-pack +# node scripts/functional_test_runner.js --include-tag=smoke From 4ef0f1e0b3d67802677bac6bb469c877a98df5b1 Mon Sep 17 00:00:00 2001 From: Steph Milovic Date: Tue, 10 May 2022 08:30:24 -0600 Subject: [PATCH 13/14] [Security Solution] Nav unified (#131157) --- .../security_solution/common/constants.ts | 1 - .../security_solution/public/cases/links.ts | 36 ++ .../common/components/navigation/types.ts | 43 +- .../public/common/links/app_links.ts | 49 ++ .../public/common/links/index.tsx | 8 + .../public/common/links/links.test.ts | 421 ++++++++++++++++++ .../public/common/links/links.ts | 197 ++++++++ .../public/common/links/types.ts | 68 +++ .../public/detections/links.ts | 25 ++ .../security_solution/public/hosts/links.ts | 79 ++++ .../public/landing_pages/routes.tsx | 2 +- .../public/management/links.ts | 111 +++++ .../security_solution/public/network/links.ts | 62 +++ .../public/overview/links.ts | 73 +++ .../security_solution/public/plugin.tsx | 1 + .../public/timelines/links.ts | 34 ++ .../security_solution/public/users/links.ts | 64 +++ 17 files changed, 1251 insertions(+), 23 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/cases/links.ts create mode 100644 x-pack/plugins/security_solution/public/common/links/app_links.ts create mode 100644 x-pack/plugins/security_solution/public/common/links/index.tsx create mode 100644 x-pack/plugins/security_solution/public/common/links/links.test.ts create mode 100644 x-pack/plugins/security_solution/public/common/links/links.ts create mode 100644 x-pack/plugins/security_solution/public/common/links/types.ts create mode 100644 x-pack/plugins/security_solution/public/detections/links.ts create mode 100644 x-pack/plugins/security_solution/public/hosts/links.ts create mode 100644 x-pack/plugins/security_solution/public/management/links.ts create mode 100644 x-pack/plugins/security_solution/public/network/links.ts create mode 100644 x-pack/plugins/security_solution/public/overview/links.ts create mode 100644 x-pack/plugins/security_solution/public/timelines/links.ts create mode 100644 x-pack/plugins/security_solution/public/users/links.ts diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index 1d898901455e7..244143c8eeef6 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -121,7 +121,6 @@ export enum SecurityPageName { usersExternalAlerts = 'users-external_alerts', threatHuntingLanding = 'threat-hunting', dashboardsLanding = 'dashboards', - manageLanding = 'manage', } export const THREAT_HUNTING_PATH = '/threat_hunting' as const; diff --git a/x-pack/plugins/security_solution/public/cases/links.ts b/x-pack/plugins/security_solution/public/cases/links.ts new file mode 100644 index 0000000000000..3765dfadc8fcc --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/links.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getCasesDeepLinks } from '@kbn/cases-plugin/public'; +import { CASES_PATH, SecurityPageName } from '../../common/constants'; +import { FEATURE, LinkItem } from '../common/links/types'; + +export const getCasesLinkItems = (): LinkItem => { + const casesLinks = getCasesDeepLinks({ + basePath: CASES_PATH, + extend: { + [SecurityPageName.case]: { + globalNavEnabled: true, + globalNavOrder: 9006, + features: [FEATURE.casesRead], + }, + [SecurityPageName.caseConfigure]: { + features: [FEATURE.casesCrud], + licenseType: 'gold', + }, + [SecurityPageName.caseCreate]: { + features: [FEATURE.casesCrud], + }, + }, + }); + const { id, deepLinks, ...rest } = casesLinks; + return { + ...rest, + id: SecurityPageName.case, + links: deepLinks as LinkItem[], + }; +}; diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/types.ts b/x-pack/plugins/security_solution/public/common/components/navigation/types.ts index 1cb8a918ea481..bc20a98eae1e8 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/types.ts +++ b/x-pack/plugins/security_solution/public/common/components/navigation/types.ts @@ -6,7 +6,7 @@ */ import { UrlStateType } from '../url_state/constants'; -import type { SecurityPageName } from '../../../app/types'; +import { SecurityPageName } from '../../../app/types'; import { UrlState } from '../url_state/types'; import { SiemRouteType } from '../../utils/route/types'; @@ -40,26 +40,27 @@ export interface NavTab { pageId?: SecurityPageName; isBeta?: boolean; } - -export type SecurityNavKey = - | SecurityPageName.administration - | SecurityPageName.alerts - | SecurityPageName.blocklist - | SecurityPageName.detectionAndResponse - | SecurityPageName.case - | SecurityPageName.endpoints - | SecurityPageName.landing - | SecurityPageName.policies - | SecurityPageName.eventFilters - | SecurityPageName.exceptions - | SecurityPageName.hostIsolationExceptions - | SecurityPageName.hosts - | SecurityPageName.network - | SecurityPageName.overview - | SecurityPageName.rules - | SecurityPageName.timelines - | SecurityPageName.trustedApps - | SecurityPageName.users; +export const securityNavKeys = [ + SecurityPageName.administration, + SecurityPageName.alerts, + SecurityPageName.blocklist, + SecurityPageName.detectionAndResponse, + SecurityPageName.case, + SecurityPageName.endpoints, + SecurityPageName.landing, + SecurityPageName.policies, + SecurityPageName.eventFilters, + SecurityPageName.exceptions, + SecurityPageName.hostIsolationExceptions, + SecurityPageName.hosts, + SecurityPageName.network, + SecurityPageName.overview, + SecurityPageName.rules, + SecurityPageName.timelines, + SecurityPageName.trustedApps, + SecurityPageName.users, +] as const; +export type SecurityNavKey = typeof securityNavKeys[number]; export type SecurityNav = Record; diff --git a/x-pack/plugins/security_solution/public/common/links/app_links.ts b/x-pack/plugins/security_solution/public/common/links/app_links.ts new file mode 100644 index 0000000000000..4a972bd5deb1f --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/links/app_links.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { i18n } from '@kbn/i18n'; +import { SecurityPageName, THREAT_HUNTING_PATH } from '../../../common/constants'; +import { THREAT_HUNTING } from '../../app/translations'; +import { FEATURE, LinkItem, UserPermissions } from './types'; +import { links as hostsLinks } from '../../hosts/links'; +import { links as detectionLinks } from '../../detections/links'; +import { links as networkLinks } from '../../network/links'; +import { links as usersLinks } from '../../users/links'; +import { links as timelinesLinks } from '../../timelines/links'; +import { getCasesLinkItems } from '../../cases/links'; +import { links as managementLinks } from '../../management/links'; +import { gettingStartedLinks, dashboardsLandingLinks } from '../../overview/links'; + +export const appLinks: Readonly = Object.freeze([ + gettingStartedLinks, + dashboardsLandingLinks, + detectionLinks, + { + id: SecurityPageName.threatHuntingLanding, + title: THREAT_HUNTING, + path: THREAT_HUNTING_PATH, + globalNavEnabled: false, + features: [FEATURE.general], + globalSearchKeywords: [ + i18n.translate('xpack.securitySolution.appLinks.threatHunting', { + defaultMessage: 'Threat hunting', + }), + ], + links: [hostsLinks, networkLinks, usersLinks], + }, + timelinesLinks, + getCasesLinkItems(), + managementLinks, +]); + +export const getAppLinks = async ({ + enableExperimental, + license, + capabilities, +}: UserPermissions) => { + // OLM team, implement async behavior here + return appLinks; +}; diff --git a/x-pack/plugins/security_solution/public/common/links/index.tsx b/x-pack/plugins/security_solution/public/common/links/index.tsx new file mode 100644 index 0000000000000..6d8e99cd416d2 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/links/index.tsx @@ -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 './links'; diff --git a/x-pack/plugins/security_solution/public/common/links/links.test.ts b/x-pack/plugins/security_solution/public/common/links/links.test.ts new file mode 100644 index 0000000000000..d8f6711cfc629 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/links/links.test.ts @@ -0,0 +1,421 @@ +/* + * Copyright 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 { + getAncestorLinksInfo, + getDeepLinks, + getInitialDeepLinks, + getLinkInfo, + getNavLinkItems, + needsUrlState, +} from './links'; +import { CASES_FEATURE_ID, SecurityPageName, SERVER_APP_ID } from '../../../common/constants'; +import { Capabilities } from '@kbn/core/types'; +import { AppDeepLink } from '@kbn/core/public'; +import { mockGlobalState } from '../mock'; +import { NavLinkItem } from './types'; +import { LicenseType } from '@kbn/licensing-plugin/common/types'; +import { LicenseService } from '../../../common/license'; + +const mockExperimentalDefaults = mockGlobalState.app.enableExperimental; +const mockCapabilities = { + [CASES_FEATURE_ID]: { read_cases: true, crud_cases: true }, + [SERVER_APP_ID]: { show: true }, +} as unknown as Capabilities; + +const findDeepLink = (id: string, deepLinks: AppDeepLink[]): AppDeepLink | null => + deepLinks.reduce((deepLinkFound: AppDeepLink | null, deepLink) => { + if (deepLinkFound !== null) { + return deepLinkFound; + } + if (deepLink.id === id) { + return deepLink; + } + if (deepLink.deepLinks) { + return findDeepLink(id, deepLink.deepLinks); + } + return null; + }, null); + +const findNavLink = (id: SecurityPageName, navLinks: NavLinkItem[]): NavLinkItem | null => + navLinks.reduce((deepLinkFound: NavLinkItem | null, deepLink) => { + if (deepLinkFound !== null) { + return deepLinkFound; + } + if (deepLink.id === id) { + return deepLink; + } + if (deepLink.links) { + return findNavLink(id, deepLink.links); + } + return null; + }, null); + +// remove filter once new nav is live +const allPages = Object.values(SecurityPageName).filter( + (pageName) => + pageName !== SecurityPageName.explore && + pageName !== SecurityPageName.detections && + pageName !== SecurityPageName.investigate +); +const casesPages = [ + SecurityPageName.case, + SecurityPageName.caseConfigure, + SecurityPageName.caseCreate, +]; +const featureFlagPages = [ + SecurityPageName.detectionAndResponse, + SecurityPageName.hostsAuthentications, + SecurityPageName.hostsRisk, + SecurityPageName.usersRisk, +]; +const premiumPages = [ + SecurityPageName.caseConfigure, + SecurityPageName.hostsAnomalies, + SecurityPageName.networkAnomalies, + SecurityPageName.usersAnomalies, + SecurityPageName.detectionAndResponse, + SecurityPageName.hostsRisk, + SecurityPageName.usersRisk, +]; +const nonCasesPages = allPages.reduce( + (acc: SecurityPageName[], p) => + casesPages.includes(p) || featureFlagPages.includes(p) ? acc : [p, ...acc], + [] +); + +const licenseBasicMock = jest.fn().mockImplementation((arg: LicenseType) => arg === 'basic'); +const licensePremiumMock = jest.fn().mockReturnValue(true); +const mockLicense = { + isAtLeast: licensePremiumMock, +} as unknown as LicenseService; + +describe('security app link helpers', () => { + beforeEach(() => { + mockLicense.isAtLeast = licensePremiumMock; + }); + describe('getInitialDeepLinks', () => { + it('should return all pages in the app', () => { + const links = getInitialDeepLinks(); + allPages.forEach((page) => expect(findDeepLink(page, links)).toBeTruthy()); + }); + }); + describe('getDeepLinks', () => { + it('basicLicense should return only basic links', async () => { + mockLicense.isAtLeast = licenseBasicMock; + + const links = await getDeepLinks({ + enableExperimental: mockExperimentalDefaults, + license: mockLicense, + capabilities: mockCapabilities, + }); + expect(findDeepLink(SecurityPageName.hostsAnomalies, links)).toBeFalsy(); + allPages.forEach((page) => { + if (premiumPages.includes(page)) { + return expect(findDeepLink(page, links)).toBeFalsy(); + } + if (featureFlagPages.includes(page)) { + // ignore feature flag pages + return; + } + expect(findDeepLink(page, links)).toBeTruthy(); + }); + }); + it('platinumLicense should return all links', async () => { + const links = await getDeepLinks({ + enableExperimental: mockExperimentalDefaults, + license: mockLicense, + capabilities: mockCapabilities, + }); + allPages.forEach((page) => { + if (premiumPages.includes(page) && !featureFlagPages.includes(page)) { + return expect(findDeepLink(page, links)).toBeTruthy(); + } + if (featureFlagPages.includes(page)) { + // ignore feature flag pages + return; + } + expect(findDeepLink(page, links)).toBeTruthy(); + }); + }); + it('hideWhenExperimentalKey hides entry when key = true', async () => { + const links = await getDeepLinks({ + enableExperimental: { ...mockExperimentalDefaults, usersEnabled: true }, + license: mockLicense, + capabilities: mockCapabilities, + }); + expect(findDeepLink(SecurityPageName.hostsAuthentications, links)).toBeFalsy(); + }); + it('hideWhenExperimentalKey shows entry when key = false', async () => { + const links = await getDeepLinks({ + enableExperimental: { ...mockExperimentalDefaults, usersEnabled: false }, + license: mockLicense, + capabilities: mockCapabilities, + }); + expect(findDeepLink(SecurityPageName.hostsAuthentications, links)).toBeTruthy(); + }); + it('experimentalKey shows entry when key = false', async () => { + const links = await getDeepLinks({ + enableExperimental: { + ...mockExperimentalDefaults, + riskyHostsEnabled: false, + riskyUsersEnabled: false, + detectionResponseEnabled: false, + }, + license: mockLicense, + capabilities: mockCapabilities, + }); + expect(findDeepLink(SecurityPageName.hostsRisk, links)).toBeFalsy(); + expect(findDeepLink(SecurityPageName.usersRisk, links)).toBeFalsy(); + expect(findDeepLink(SecurityPageName.detectionAndResponse, links)).toBeFalsy(); + }); + it('experimentalKey shows entry when key = true', async () => { + const links = await getDeepLinks({ + enableExperimental: { + ...mockExperimentalDefaults, + riskyHostsEnabled: true, + riskyUsersEnabled: true, + detectionResponseEnabled: true, + }, + license: mockLicense, + capabilities: mockCapabilities, + }); + expect(findDeepLink(SecurityPageName.hostsRisk, links)).toBeTruthy(); + expect(findDeepLink(SecurityPageName.usersRisk, links)).toBeTruthy(); + expect(findDeepLink(SecurityPageName.detectionAndResponse, links)).toBeTruthy(); + }); + + it('Removes siem features when siem capabilities are false', async () => { + const capabilities = { + ...mockCapabilities, + [SERVER_APP_ID]: { show: false }, + } as unknown as Capabilities; + const links = await getDeepLinks({ + enableExperimental: mockExperimentalDefaults, + license: mockLicense, + capabilities, + }); + nonCasesPages.forEach((page) => { + // investigate is active for both Cases and Timelines pages + if (page === SecurityPageName.investigate) { + return expect(findDeepLink(page, links)).toBeTruthy(); + } + return expect(findDeepLink(page, links)).toBeFalsy(); + }); + casesPages.forEach((page) => expect(findDeepLink(page, links)).toBeTruthy()); + }); + it('Removes cases features when cases capabilities are false', async () => { + const capabilities = { + ...mockCapabilities, + [CASES_FEATURE_ID]: { read_cases: false, crud_cases: false }, + } as unknown as Capabilities; + const links = await getDeepLinks({ + enableExperimental: mockExperimentalDefaults, + license: mockLicense, + capabilities, + }); + nonCasesPages.forEach((page) => expect(findDeepLink(page, links)).toBeTruthy()); + casesPages.forEach((page) => expect(findDeepLink(page, links)).toBeFalsy()); + }); + }); + + describe('getNavLinkItems', () => { + it('basicLicense should return only basic links', () => { + mockLicense.isAtLeast = licenseBasicMock; + const links = getNavLinkItems({ + enableExperimental: mockExperimentalDefaults, + license: mockLicense, + capabilities: mockCapabilities, + }); + expect(findNavLink(SecurityPageName.hostsAnomalies, links)).toBeFalsy(); + allPages.forEach((page) => { + if (premiumPages.includes(page)) { + return expect(findNavLink(page, links)).toBeFalsy(); + } + if (featureFlagPages.includes(page)) { + // ignore feature flag pages + return; + } + expect(findNavLink(page, links)).toBeTruthy(); + }); + }); + it('platinumLicense should return all links', () => { + const links = getNavLinkItems({ + enableExperimental: mockExperimentalDefaults, + license: mockLicense, + capabilities: mockCapabilities, + }); + allPages.forEach((page) => { + if (premiumPages.includes(page) && !featureFlagPages.includes(page)) { + return expect(findNavLink(page, links)).toBeTruthy(); + } + if (featureFlagPages.includes(page)) { + // ignore feature flag pages + return; + } + expect(findNavLink(page, links)).toBeTruthy(); + }); + }); + it('hideWhenExperimentalKey hides entry when key = true', () => { + const links = getNavLinkItems({ + enableExperimental: { ...mockExperimentalDefaults, usersEnabled: true }, + license: mockLicense, + capabilities: mockCapabilities, + }); + expect(findNavLink(SecurityPageName.hostsAuthentications, links)).toBeFalsy(); + }); + it('hideWhenExperimentalKey shows entry when key = false', () => { + const links = getNavLinkItems({ + enableExperimental: { ...mockExperimentalDefaults, usersEnabled: false }, + license: mockLicense, + capabilities: mockCapabilities, + }); + expect(findNavLink(SecurityPageName.hostsAuthentications, links)).toBeTruthy(); + }); + it('experimentalKey shows entry when key = false', () => { + const links = getNavLinkItems({ + enableExperimental: { + ...mockExperimentalDefaults, + riskyHostsEnabled: false, + riskyUsersEnabled: false, + detectionResponseEnabled: false, + }, + license: mockLicense, + capabilities: mockCapabilities, + }); + expect(findNavLink(SecurityPageName.hostsRisk, links)).toBeFalsy(); + expect(findNavLink(SecurityPageName.usersRisk, links)).toBeFalsy(); + expect(findNavLink(SecurityPageName.detectionAndResponse, links)).toBeFalsy(); + }); + it('experimentalKey shows entry when key = true', () => { + const links = getNavLinkItems({ + enableExperimental: { + ...mockExperimentalDefaults, + riskyHostsEnabled: true, + riskyUsersEnabled: true, + detectionResponseEnabled: true, + }, + license: mockLicense, + capabilities: mockCapabilities, + }); + expect(findNavLink(SecurityPageName.hostsRisk, links)).toBeTruthy(); + expect(findNavLink(SecurityPageName.usersRisk, links)).toBeTruthy(); + expect(findNavLink(SecurityPageName.detectionAndResponse, links)).toBeTruthy(); + }); + + it('Removes siem features when siem capabilities are false', () => { + const capabilities = { + ...mockCapabilities, + [SERVER_APP_ID]: { show: false }, + } as unknown as Capabilities; + const links = getNavLinkItems({ + enableExperimental: mockExperimentalDefaults, + license: mockLicense, + capabilities, + }); + nonCasesPages.forEach((page) => { + // investigate is active for both Cases and Timelines pages + if (page === SecurityPageName.investigate) { + return expect(findNavLink(page, links)).toBeTruthy(); + } + return expect(findNavLink(page, links)).toBeFalsy(); + }); + casesPages.forEach((page) => expect(findNavLink(page, links)).toBeTruthy()); + }); + it('Removes cases features when cases capabilities are false', () => { + const capabilities = { + ...mockCapabilities, + [CASES_FEATURE_ID]: { read_cases: false, crud_cases: false }, + } as unknown as Capabilities; + const links = getNavLinkItems({ + enableExperimental: mockExperimentalDefaults, + license: mockLicense, + capabilities, + }); + nonCasesPages.forEach((page) => expect(findNavLink(page, links)).toBeTruthy()); + casesPages.forEach((page) => expect(findNavLink(page, links)).toBeFalsy()); + }); + }); + + describe('getAncestorLinksInfo', () => { + it('finds flattened links for hosts', () => { + const hierarchy = getAncestorLinksInfo(SecurityPageName.hosts); + expect(hierarchy).toEqual([ + { + features: ['siem.show'], + globalNavEnabled: false, + globalSearchKeywords: ['Threat hunting'], + id: 'threat-hunting', + path: '/threat_hunting', + title: 'Threat Hunting', + }, + { + globalNavEnabled: true, + globalNavOrder: 9002, + globalSearchEnabled: true, + globalSearchKeywords: ['Hosts'], + id: 'hosts', + path: '/hosts', + title: 'Hosts', + }, + ]); + }); + it('finds flattened links for uncommonProcesses', () => { + const hierarchy = getAncestorLinksInfo(SecurityPageName.uncommonProcesses); + expect(hierarchy).toEqual([ + { + features: ['siem.show'], + globalNavEnabled: false, + globalSearchKeywords: ['Threat hunting'], + id: 'threat-hunting', + path: '/threat_hunting', + title: 'Threat Hunting', + }, + { + globalNavEnabled: true, + globalNavOrder: 9002, + globalSearchEnabled: true, + globalSearchKeywords: ['Hosts'], + id: 'hosts', + path: '/hosts', + title: 'Hosts', + }, + { + id: 'uncommon_processes', + path: '/hosts/uncommonProcesses', + title: 'Uncommon Processes', + }, + ]); + }); + }); + + describe('needsUrlState', () => { + it('returns true when url state exists for page', () => { + const needsUrl = needsUrlState(SecurityPageName.hosts); + expect(needsUrl).toEqual(true); + }); + it('returns false when url state does not exist for page', () => { + const needsUrl = needsUrlState(SecurityPageName.landing); + expect(needsUrl).toEqual(false); + }); + }); + + describe('getLinkInfo', () => { + it('gets information for an individual link', () => { + const linkInfo = getLinkInfo(SecurityPageName.hosts); + expect(linkInfo).toEqual({ + globalNavEnabled: true, + globalNavOrder: 9002, + globalSearchEnabled: true, + globalSearchKeywords: ['Hosts'], + id: 'hosts', + path: '/hosts', + title: 'Hosts', + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/links/links.ts b/x-pack/plugins/security_solution/public/common/links/links.ts new file mode 100644 index 0000000000000..290a1f3fbd820 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/links/links.ts @@ -0,0 +1,197 @@ +/* + * Copyright 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 { AppDeepLink, AppNavLinkStatus, Capabilities } from '@kbn/core/public'; +import { get } from 'lodash'; +import { SecurityPageName } from '../../../common/constants'; +import { appLinks, getAppLinks } from './app_links'; +import { + Feature, + LinkInfo, + LinkItem, + NavLinkItem, + NormalizedLink, + NormalizedLinks, + UserPermissions, +} from './types'; + +const createDeepLink = (link: LinkItem, linkProps?: UserPermissions): AppDeepLink => ({ + id: link.id, + path: link.path, + title: link.title, + ...(link.links && link.links.length + ? { + deepLinks: reduceLinks({ + links: link.links, + linkProps, + formatFunction: createDeepLink, + }), + } + : {}), + ...(link.icon != null ? { euiIconType: link.icon } : {}), + ...(link.image != null ? { icon: link.image } : {}), + ...(link.globalSearchKeywords != null ? { keywords: link.globalSearchKeywords } : {}), + ...(link.globalNavEnabled != null + ? { navLinkStatus: link.globalNavEnabled ? AppNavLinkStatus.visible : AppNavLinkStatus.hidden } + : {}), + ...(link.globalNavOrder != null ? { order: link.globalNavOrder } : {}), + ...(link.globalSearchEnabled != null ? { searchable: link.globalSearchEnabled } : {}), +}); + +const createNavLinkItem = (link: LinkItem, linkProps?: UserPermissions): NavLinkItem => ({ + id: link.id, + path: link.path, + title: link.title, + ...(link.description != null ? { description: link.description } : {}), + ...(link.icon != null ? { icon: link.icon } : {}), + ...(link.image != null ? { image: link.image } : {}), + ...(link.links && link.links.length + ? { + links: reduceLinks({ + links: link.links, + linkProps, + formatFunction: createNavLinkItem, + }), + } + : {}), + ...(link.skipUrlState != null ? { skipUrlState: link.skipUrlState } : {}), +}); + +const hasFeaturesCapability = ( + features: Feature[] | undefined, + capabilities: Capabilities +): boolean => { + if (!features) { + return true; + } + return features.some((featureKey) => get(capabilities, featureKey, false)); +}; + +const isLinkAllowed = (link: LinkItem, linkProps?: UserPermissions) => + !( + linkProps != null && + // exclude link when license is basic and link is premium + ((linkProps.license && !linkProps.license.isAtLeast(link.licenseType ?? 'basic')) || + // exclude link when enableExperimental[hideWhenExperimentalKey] is enabled and link has hideWhenExperimentalKey + (link.hideWhenExperimentalKey != null && + linkProps.enableExperimental[link.hideWhenExperimentalKey]) || + // exclude link when enableExperimental[experimentalKey] is disabled and link has experimentalKey + (link.experimentalKey != null && !linkProps.enableExperimental[link.experimentalKey]) || + // exclude link when link is not part of enabled feature capabilities + (linkProps.capabilities != null && + !hasFeaturesCapability(link.features, linkProps.capabilities))) + ); + +export function reduceLinks({ + links, + linkProps, + formatFunction, +}: { + links: Readonly; + linkProps?: UserPermissions; + formatFunction: (link: LinkItem, linkProps?: UserPermissions) => T; +}): T[] { + return links.reduce( + (deepLinks: T[], link: LinkItem) => + isLinkAllowed(link, linkProps) ? [...deepLinks, formatFunction(link, linkProps)] : deepLinks, + [] + ); +} + +export const getInitialDeepLinks = (): AppDeepLink[] => { + return appLinks.map((link) => createDeepLink(link)); +}; + +export const getDeepLinks = async ({ + enableExperimental, + license, + capabilities, +}: UserPermissions): Promise => { + const links = await getAppLinks({ enableExperimental, license, capabilities }); + return reduceLinks({ + links, + linkProps: { enableExperimental, license, capabilities }, + formatFunction: createDeepLink, + }); +}; + +export const getNavLinkItems = ({ + enableExperimental, + license, + capabilities, +}: UserPermissions): NavLinkItem[] => + reduceLinks({ + links: appLinks, + linkProps: { enableExperimental, license, capabilities }, + formatFunction: createNavLinkItem, + }); + +/** + * Recursive function to create the `NormalizedLinks` structure from a `LinkItem` array parameter + */ +const getNormalizedLinks = ( + currentLinks: Readonly, + parentId?: SecurityPageName +): NormalizedLinks => { + const result = currentLinks.reduce>( + (normalized, { links, ...currentLink }) => { + normalized[currentLink.id] = { + ...currentLink, + parentId, + }; + if (links && links.length > 0) { + Object.assign(normalized, getNormalizedLinks(links, currentLink.id)); + } + return normalized; + }, + {} + ); + return result as NormalizedLinks; +}; + +/** + * Normalized indexed version of the global `links` array, referencing the parent by id, instead of having nested links children + */ +const normalizedLinks: Readonly = Object.freeze(getNormalizedLinks(appLinks)); + +/** + * Returns the `NormalizedLink` from a link id parameter. + * The object reference is frozen to make sure it is not mutated by the caller. + */ +const getNormalizedLink = (id: SecurityPageName): Readonly => + Object.freeze(normalizedLinks[id]); + +/** + * Returns the `LinkInfo` from a link id parameter + */ +export const getLinkInfo = (id: SecurityPageName): LinkInfo => { + // discards the parentId and creates the linkInfo copy. + const { parentId, ...linkInfo } = getNormalizedLink(id); + return linkInfo; +}; + +/** + * Returns the `LinkInfo` of all the ancestors to the parameter id link, also included. + */ +export const getAncestorLinksInfo = (id: SecurityPageName): LinkInfo[] => { + const ancestors: LinkInfo[] = []; + let currentId: SecurityPageName | undefined = id; + while (currentId) { + const { parentId, ...linkInfo } = getNormalizedLink(currentId); + ancestors.push(linkInfo); + currentId = parentId; + } + return ancestors.reverse(); +}; + +/** + * Returns `true` if the links needs to carry the application state in the url. + * Defaults to `true` if the `skipUrlState` property of the `LinkItem` is `undefined`. + */ +export const needsUrlState = (id: SecurityPageName): boolean => { + return !getNormalizedLink(id).skipUrlState; +}; diff --git a/x-pack/plugins/security_solution/public/common/links/types.ts b/x-pack/plugins/security_solution/public/common/links/types.ts new file mode 100644 index 0000000000000..eea348b3df737 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/links/types.ts @@ -0,0 +1,68 @@ +/* + * Copyright 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 { Capabilities } from '@kbn/core/types'; +import { LicenseType } from '@kbn/licensing-plugin/common/types'; +import { LicenseService } from '../../../common/license'; +import { ExperimentalFeatures } from '../../../common/experimental_features'; +import { CASES_FEATURE_ID, SecurityPageName, SERVER_APP_ID } from '../../../common/constants'; + +export const FEATURE = { + general: `${SERVER_APP_ID}.show`, + casesRead: `${CASES_FEATURE_ID}.read_cases`, + casesCrud: `${CASES_FEATURE_ID}.crud_cases`, +}; + +export type Feature = Readonly; + +export interface UserPermissions { + enableExperimental: ExperimentalFeatures; + license?: LicenseService; + capabilities?: Capabilities; +} + +export interface LinkItem { + description?: string; + disabled?: boolean; // default false + /** + * Displays deep link when feature flag is enabled. + */ + experimentalKey?: keyof ExperimentalFeatures; + features?: Feature[]; + /** + * Hides deep link when feature flag is enabled. + */ + globalNavEnabled?: boolean; // default false + globalNavOrder?: number; + globalSearchEnabled?: boolean; + globalSearchKeywords?: string[]; + hideWhenExperimentalKey?: keyof ExperimentalFeatures; + icon?: string; + id: SecurityPageName; + image?: string; + isBeta?: boolean; + licenseType?: LicenseType; + links?: LinkItem[]; + path: string; + skipUrlState?: boolean; // defaults to false + title: string; +} + +export interface NavLinkItem { + description?: string; + icon?: string; + id: SecurityPageName; + links?: NavLinkItem[]; + image?: string; + path: string; + title: string; + skipUrlState?: boolean; // default to false +} + +export type LinkInfo = Omit; +export type NormalizedLink = LinkInfo & { parentId?: SecurityPageName }; +export type NormalizedLinks = Record; diff --git a/x-pack/plugins/security_solution/public/detections/links.ts b/x-pack/plugins/security_solution/public/detections/links.ts new file mode 100644 index 0000000000000..1cfac62d80e6e --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/links.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 { i18n } from '@kbn/i18n'; +import { ALERTS_PATH, SecurityPageName } from '../../common/constants'; +import { ALERTS } from '../app/translations'; +import { LinkItem, FEATURE } from '../common/links/types'; + +export const links: LinkItem = { + id: SecurityPageName.alerts, + title: ALERTS, + path: ALERTS_PATH, + features: [FEATURE.general], + globalNavEnabled: true, + globalSearchKeywords: [ + i18n.translate('xpack.securitySolution.appLinks.alerts', { + defaultMessage: 'Alerts', + }), + ], + globalSearchEnabled: true, + globalNavOrder: 9001, +}; diff --git a/x-pack/plugins/security_solution/public/hosts/links.ts b/x-pack/plugins/security_solution/public/hosts/links.ts new file mode 100644 index 0000000000000..35730291d6c74 --- /dev/null +++ b/x-pack/plugins/security_solution/public/hosts/links.ts @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { i18n } from '@kbn/i18n'; +import { HOSTS_PATH, SecurityPageName } from '../../common/constants'; +import { HOSTS } from '../app/translations'; +import { LinkItem } from '../common/links/types'; + +export const links: LinkItem = { + id: SecurityPageName.hosts, + title: HOSTS, + path: HOSTS_PATH, + globalNavEnabled: true, + globalSearchKeywords: [ + i18n.translate('xpack.securitySolution.appLinks.hosts', { + defaultMessage: 'Hosts', + }), + ], + globalSearchEnabled: true, + globalNavOrder: 9002, + links: [ + { + id: SecurityPageName.hostsAuthentications, + title: i18n.translate('xpack.securitySolution.appLinks.hosts.authentications', { + defaultMessage: 'Authentications', + }), + path: `${HOSTS_PATH}/authentications`, + hideWhenExperimentalKey: 'usersEnabled', + }, + { + id: SecurityPageName.uncommonProcesses, + title: i18n.translate('xpack.securitySolution.appLinks.hosts.uncommonProcesses', { + defaultMessage: 'Uncommon Processes', + }), + path: `${HOSTS_PATH}/uncommonProcesses`, + }, + { + id: SecurityPageName.hostsAnomalies, + title: i18n.translate('xpack.securitySolution.appLinks.hosts.anomalies', { + defaultMessage: 'Anomalies', + }), + path: `${HOSTS_PATH}/anomalies`, + licenseType: 'gold', + }, + { + id: SecurityPageName.hostsEvents, + title: i18n.translate('xpack.securitySolution.appLinks.hosts.events', { + defaultMessage: 'Events', + }), + path: `${HOSTS_PATH}/events`, + }, + { + id: SecurityPageName.hostsExternalAlerts, + title: i18n.translate('xpack.securitySolution.appLinks.hosts.externalAlerts', { + defaultMessage: 'External Alerts', + }), + path: `${HOSTS_PATH}/externalAlerts`, + }, + { + id: SecurityPageName.hostsRisk, + title: i18n.translate('xpack.securitySolution.appLinks.hosts.risk', { + defaultMessage: 'Hosts by risk', + }), + path: `${HOSTS_PATH}/hostRisk`, + experimentalKey: 'riskyHostsEnabled', + }, + { + id: SecurityPageName.sessions, + title: i18n.translate('xpack.securitySolution.appLinks.hosts.sessions', { + defaultMessage: 'Sessions', + }), + path: `${HOSTS_PATH}/sessions`, + isBeta: true, + }, + ], +}; diff --git a/x-pack/plugins/security_solution/public/landing_pages/routes.tsx b/x-pack/plugins/security_solution/public/landing_pages/routes.tsx index af8ce9dbdaf2a..3fbe33cc0ec88 100644 --- a/x-pack/plugins/security_solution/public/landing_pages/routes.tsx +++ b/x-pack/plugins/security_solution/public/landing_pages/routes.tsx @@ -27,7 +27,7 @@ export const DashboardRoutes = () => ( ); export const ManageRoutes = () => ( - + ); diff --git a/x-pack/plugins/security_solution/public/management/links.ts b/x-pack/plugins/security_solution/public/management/links.ts new file mode 100644 index 0000000000000..d941d538c80f7 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/links.ts @@ -0,0 +1,111 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { + BLOCKLIST_PATH, + ENDPOINTS_PATH, + EVENT_FILTERS_PATH, + EXCEPTIONS_PATH, + HOST_ISOLATION_EXCEPTIONS_PATH, + MANAGEMENT_PATH, + POLICIES_PATH, + RULES_PATH, + SecurityPageName, + TRUSTED_APPS_PATH, +} from '../../common/constants'; +import { + BLOCKLIST, + ENDPOINTS, + EVENT_FILTERS, + EXCEPTIONS, + HOST_ISOLATION_EXCEPTIONS, + MANAGE, + POLICIES, + RULES, + TRUSTED_APPLICATIONS, +} from '../app/translations'; +import { FEATURE, LinkItem } from '../common/links/types'; + +export const links: LinkItem = { + id: SecurityPageName.administration, + title: MANAGE, + path: MANAGEMENT_PATH, + skipUrlState: true, + globalNavEnabled: false, + features: [FEATURE.general], + globalSearchKeywords: [ + i18n.translate('xpack.securitySolution.appLinks.manage', { + defaultMessage: 'Manage', + }), + ], + links: [ + { + id: SecurityPageName.rules, + title: RULES, + path: RULES_PATH, + globalNavEnabled: false, + globalSearchKeywords: [ + i18n.translate('xpack.securitySolution.appLinks.rules', { + defaultMessage: 'Rules', + }), + ], + globalSearchEnabled: true, + }, + { + id: SecurityPageName.exceptions, + title: EXCEPTIONS, + path: EXCEPTIONS_PATH, + globalNavEnabled: false, + globalSearchKeywords: [ + i18n.translate('xpack.securitySolution.appLinks.exceptions', { + defaultMessage: 'Exception lists', + }), + ], + globalSearchEnabled: true, + }, + { + id: SecurityPageName.endpoints, + globalNavEnabled: true, + title: ENDPOINTS, + globalNavOrder: 9006, + path: ENDPOINTS_PATH, + skipUrlState: true, + }, + { + id: SecurityPageName.policies, + title: POLICIES, + path: POLICIES_PATH, + skipUrlState: true, + experimentalKey: 'policyListEnabled', + }, + { + id: SecurityPageName.trustedApps, + title: TRUSTED_APPLICATIONS, + path: TRUSTED_APPS_PATH, + skipUrlState: true, + }, + { + id: SecurityPageName.eventFilters, + title: EVENT_FILTERS, + path: EVENT_FILTERS_PATH, + skipUrlState: true, + }, + { + id: SecurityPageName.hostIsolationExceptions, + title: HOST_ISOLATION_EXCEPTIONS, + path: HOST_ISOLATION_EXCEPTIONS_PATH, + skipUrlState: true, + }, + { + id: SecurityPageName.blocklist, + title: BLOCKLIST, + path: BLOCKLIST_PATH, + skipUrlState: true, + }, + ], +}; diff --git a/x-pack/plugins/security_solution/public/network/links.ts b/x-pack/plugins/security_solution/public/network/links.ts new file mode 100644 index 0000000000000..ad209a220eebc --- /dev/null +++ b/x-pack/plugins/security_solution/public/network/links.ts @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { NETWORK_PATH, SecurityPageName } from '../../common/constants'; +import { NETWORK } from '../app/translations'; +import { LinkItem } from '../common/links/types'; + +export const links: LinkItem = { + id: SecurityPageName.network, + title: NETWORK, + path: NETWORK_PATH, + globalNavEnabled: true, + globalSearchKeywords: [ + i18n.translate('xpack.securitySolution.appLinks.network', { + defaultMessage: 'Network', + }), + ], + globalNavOrder: 9003, + links: [ + { + id: SecurityPageName.networkDns, + title: i18n.translate('xpack.securitySolution.appLinks.network.dns', { + defaultMessage: 'DNS', + }), + path: `${NETWORK_PATH}/dns`, + }, + { + id: SecurityPageName.networkHttp, + title: i18n.translate('xpack.securitySolution.appLinks.network.http', { + defaultMessage: 'HTTP', + }), + path: `${NETWORK_PATH}/http`, + }, + { + id: SecurityPageName.networkTls, + title: i18n.translate('xpack.securitySolution.appLinks.network.tls', { + defaultMessage: 'TLS', + }), + path: `${NETWORK_PATH}/tls`, + }, + { + id: SecurityPageName.networkExternalAlerts, + title: i18n.translate('xpack.securitySolution.appLinks.network.externalAlerts', { + defaultMessage: 'External Alerts', + }), + path: `${NETWORK_PATH}/external-alerts`, + }, + { + id: SecurityPageName.networkAnomalies, + title: i18n.translate('xpack.securitySolution.appLinks.hosts.anomalies', { + defaultMessage: 'Anomalies', + }), + path: `${NETWORK_PATH}/anomalies`, + licenseType: 'gold', + }, + ], +}; diff --git a/x-pack/plugins/security_solution/public/overview/links.ts b/x-pack/plugins/security_solution/public/overview/links.ts new file mode 100644 index 0000000000000..89f75053b3d6f --- /dev/null +++ b/x-pack/plugins/security_solution/public/overview/links.ts @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { + DASHBOARDS_PATH, + DETECTION_RESPONSE_PATH, + LANDING_PATH, + OVERVIEW_PATH, + SecurityPageName, +} from '../../common/constants'; +import { DASHBOARDS, DETECTION_RESPONSE, GETTING_STARTED, OVERVIEW } from '../app/translations'; +import { FEATURE, LinkItem } from '../common/links/types'; + +export const overviewLinks: LinkItem = { + id: SecurityPageName.overview, + title: OVERVIEW, + path: OVERVIEW_PATH, + globalNavEnabled: true, + features: [FEATURE.general], + globalSearchKeywords: [ + i18n.translate('xpack.securitySolution.appLinks.overview', { + defaultMessage: 'Overview', + }), + ], + globalNavOrder: 9000, +}; + +export const gettingStartedLinks: LinkItem = { + id: SecurityPageName.landing, + title: GETTING_STARTED, + path: LANDING_PATH, + globalNavEnabled: false, + features: [FEATURE.general], + globalSearchKeywords: [ + i18n.translate('xpack.securitySolution.appLinks.getStarted', { + defaultMessage: 'Getting started', + }), + ], + skipUrlState: true, +}; + +export const detectionResponseLinks: LinkItem = { + id: SecurityPageName.detectionAndResponse, + title: DETECTION_RESPONSE, + path: DETECTION_RESPONSE_PATH, + globalNavEnabled: false, + experimentalKey: 'detectionResponseEnabled', + features: [FEATURE.general], + globalSearchKeywords: [ + i18n.translate('xpack.securitySolution.appLinks.detectionAndResponse', { + defaultMessage: 'Detection & Response', + }), + ], +}; + +export const dashboardsLandingLinks: LinkItem = { + id: SecurityPageName.dashboardsLanding, + title: DASHBOARDS, + path: DASHBOARDS_PATH, + globalNavEnabled: false, + features: [FEATURE.general], + globalSearchKeywords: [ + i18n.translate('xpack.securitySolution.appLinks.dashboards', { + defaultMessage: 'Dashboards', + }), + ], + links: [overviewLinks, detectionResponseLinks], +}; diff --git a/x-pack/plugins/security_solution/public/plugin.tsx b/x-pack/plugins/security_solution/public/plugin.tsx index 343259d88cb76..4b49c04f295a5 100644 --- a/x-pack/plugins/security_solution/public/plugin.tsx +++ b/x-pack/plugins/security_solution/public/plugin.tsx @@ -222,6 +222,7 @@ export class Plugin implements IPlugin Date: Tue, 10 May 2022 10:45:57 -0400 Subject: [PATCH 14/14] [Workplace Search] Refactor Add Source Views to support base service types for external connectors (#131802) --- .../workplace_search/routes.test.tsx | 31 + .../applications/workplace_search/routes.ts | 10 +- .../applications/workplace_search/types.ts | 6 +- .../utils/has_multiple_connector_options.ts | 17 - .../workplace_search/utils/index.ts | 1 - .../add_custom_source.test.tsx | 24 +- .../add_custom_source/add_custom_source.tsx | 33 +- .../add_custom_source_logic.test.ts | 48 +- .../add_custom_source_logic.ts | 23 +- .../configure_custom.test.tsx | 8 +- .../add_custom_source/configure_custom.tsx | 10 +- .../add_custom_source/save_custom.test.tsx | 35 +- .../add_custom_source/save_custom.tsx | 16 +- .../external_connector_config.test.tsx | 28 +- .../external_connector_config.tsx | 72 +- .../external_connector_logic.test.ts | 8 - .../external_connector_logic.ts | 7 - .../components/add_source/add_source.test.tsx | 104 +- .../components/add_source/add_source.tsx | 77 +- .../add_source/add_source_choice.test.tsx | 82 ++ .../add_source/add_source_choice.tsx | 57 ++ .../add_source/add_source_intro.test.tsx | 79 ++ .../add_source/add_source_intro.tsx | 60 ++ .../add_source/add_source_logic.test.ts | 247 ++--- .../components/add_source/add_source_logic.ts | 898 +++++++++--------- .../available_sources_list.test.tsx | 2 +- .../add_source/available_sources_list.tsx | 37 +- .../add_source/configuration_choice.test.tsx | 82 +- .../add_source/configuration_choice.tsx | 154 +-- .../add_source/configuration_intro.test.tsx | 3 +- .../add_source/configuration_intro.tsx | 14 +- .../add_source/configure_oauth.test.tsx | 2 +- .../components/add_source/configure_oauth.tsx | 10 +- .../configured_sources_list.test.tsx | 50 +- .../add_source/configured_sources_list.tsx | 14 +- .../add_source/connect_instance.test.tsx | 4 +- .../add_source/connect_instance.tsx | 5 +- .../components/add_source/source_features.tsx | 4 +- .../custom_source_deployment.test.tsx | 18 +- .../components/custom_source_deployment.tsx | 19 +- .../components/overview.test.tsx | 29 - .../content_sources/components/overview.tsx | 9 +- .../components/source_settings.tsx | 2 +- .../views/content_sources/source_data.tsx | 130 ++- .../content_sources/sources_logic.test.ts | 19 +- .../views/content_sources/sources_logic.ts | 15 +- .../content_sources/sources_router.test.tsx | 20 +- .../views/content_sources/sources_router.tsx | 164 +--- .../components/source_config.test.tsx | 27 +- .../settings/components/source_config.tsx | 29 +- .../views/settings/settings_router.test.tsx | 10 +- .../views/settings/settings_router.tsx | 9 +- 52 files changed, 1415 insertions(+), 1447 deletions(-) delete mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/has_multiple_connector_options.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_choice.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_choice.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_intro.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_intro.tsx diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.test.tsx index b69303aae2106..0213aa26d5ef3 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.test.tsx @@ -21,6 +21,8 @@ import { SOURCES_PATH, PRIVATE_SOURCES_PATH, SOURCE_DETAILS_PATH, + getAddPath, + getEditPath, } from './routes'; const TestComponent = ({ id, isOrg }: { id: string; isOrg?: boolean }) => { @@ -86,3 +88,32 @@ describe('getReindexJobRoute', () => { ); }); }); + +describe('getAddPath', () => { + it('should handle a service type', () => { + expect(getAddPath('share_point')).toEqual('/sources/add/share_point'); + }); + + it('should should handle an external service type with no base service type', () => { + expect(getAddPath('external')).toEqual('/sources/add/external'); + }); + + it('should should handle an external service type with a base service type', () => { + expect(getAddPath('external', 'share_point')).toEqual('/sources/add/share_point/external'); + }); + it('should should handle a custom service type with no base service type', () => { + expect(getAddPath('external')).toEqual('/sources/add/external'); + }); + + it('should should handle a custom service type with a base service type', () => { + expect(getAddPath('custom', 'share_point_server')).toEqual( + '/sources/add/share_point_server/custom' + ); + }); +}); + +describe('getEditPath', () => { + it('should handle a service type', () => { + expect(getEditPath('share_point')).toEqual('/settings/connectors/share_point/edit'); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts index cbcd1d885b120..fe1be10aa3b06 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts @@ -77,6 +77,14 @@ export const getReindexJobRoute = ( isOrganization: boolean ) => getSourcesPath(generatePath(REINDEX_JOB_PATH, { sourceId, activeReindexJobId }), isOrganization); -export const getAddPath = (serviceType: string): string => `${SOURCES_PATH}/add/${serviceType}`; + +export const getAddPath = (serviceType: string, baseServiceType?: string): string => { + const baseServiceTypePath = baseServiceType + ? `${baseServiceType}/${serviceType}` + : `${serviceType}`; + return `${SOURCES_PATH}/add/${baseServiceTypePath}`; +}; + +// TODO this should handle base service type once we are getting it back from registered external connectors export const getEditPath = (serviceType: string): string => `${ORG_SETTINGS_CONNECTORS_PATH}/${serviceType}/edit`; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts index 984e6664681b4..32353230b36aa 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts @@ -72,18 +72,14 @@ export interface Configuration { export interface SourceDataItem { name: string; - iconName: string; categories?: string[]; serviceType: string; + baseServiceType?: string; configuration: Configuration; - configured?: boolean; connected?: boolean; features?: Features; objTypes?: string[]; accountContextOnly: boolean; - internalConnectorAvailable?: boolean; - externalConnectorAvailable?: boolean; - customConnectorAvailable?: boolean; isBeta?: boolean; } diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/has_multiple_connector_options.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/has_multiple_connector_options.ts deleted file mode 100644 index fbfda1ddf8d5e..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/has_multiple_connector_options.ts +++ /dev/null @@ -1,17 +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 { SourceDataItem } from '../types'; - -export const hasMultipleConnectorOptions = ({ - internalConnectorAvailable, - externalConnectorAvailable, - customConnectorAvailable, -}: SourceDataItem) => - [externalConnectorAvailable, internalConnectorAvailable, customConnectorAvailable].filter( - (available) => !!available - ).length > 1; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/index.ts index c66a6d1ca0fc0..6f6af758c0283 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/index.ts @@ -11,6 +11,5 @@ export { mimeType } from './mime_types'; export { readUploadedFileAsBase64 } from './read_uploaded_file_as_base64'; export { readUploadedFileAsText } from './read_uploaded_file_as_text'; export { handlePrivateKeyUpload } from './handle_private_key_upload'; -export { hasMultipleConnectorOptions } from './has_multiple_connector_options'; export { isNotNullish } from './is_not_nullish'; export { sortByName } from './sort_by_name'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source.test.tsx index b606f9d7f56fd..9ff64dfe4f65b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source.test.tsx @@ -7,6 +7,7 @@ import '../../../../../../__mocks__/shallow_useeffect.mock'; import { setMockValues } from '../../../../../../__mocks__/kea_logic'; +import { mockUseParams } from '../../../../../../__mocks__/react_router'; import { sourceConfigData } from '../../../../../__mocks__/content_sources.mock'; import React from 'react'; @@ -17,7 +18,6 @@ import { WorkplaceSearchPageTemplate, PersonalDashboardLayout, } from '../../../../../components/layout'; -import { staticSourceData } from '../../../source_data'; import { AddCustomSource } from './add_custom_source'; import { AddCustomSourceSteps } from './add_custom_source_logic'; @@ -25,11 +25,6 @@ import { ConfigureCustom } from './configure_custom'; import { SaveCustom } from './save_custom'; describe('AddCustomSource', () => { - const props = { - sourceData: staticSourceData[0], - initialValues: undefined, - }; - const values = { sourceConfigData, isOrganization: true, @@ -37,17 +32,26 @@ describe('AddCustomSource', () => { beforeEach(() => { setMockValues({ ...values }); + mockUseParams.mockReturnValue({ baseServiceType: 'share_point_server' }); }); it('renders', () => { - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(WorkplaceSearchPageTemplate)).toHaveLength(1); }); + it('returns null if there is no matching source data for the service type', () => { + mockUseParams.mockReturnValue({ baseServiceType: 'doesnt_exist' }); + + const wrapper = shallow(); + + expect(wrapper.isEmptyRender()).toBe(true); + }); + it('should show correct layout for personal dashboard', () => { setMockValues({ isOrganization: false }); - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(WorkplaceSearchPageTemplate)).toHaveLength(0); expect(wrapper.find(PersonalDashboardLayout)).toHaveLength(1); @@ -55,14 +59,14 @@ describe('AddCustomSource', () => { it('should show Configure Custom for custom configuration step', () => { setMockValues({ currentStep: AddCustomSourceSteps.ConfigureCustomStep }); - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(ConfigureCustom)).toHaveLength(1); }); it('should show Save Custom for save custom step', () => { setMockValues({ currentStep: AddCustomSourceSteps.SaveCustomStep }); - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(SaveCustom)).toHaveLength(1); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source.tsx index c2f6afba032c7..b15129665a7d4 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source.tsx @@ -7,6 +7,8 @@ import React from 'react'; +import { useParams } from 'react-router-dom'; + import { useValues } from 'kea'; import { AppLogic } from '../../../../../app_logic'; @@ -16,27 +18,38 @@ import { } from '../../../../../components/layout'; import { NAV } from '../../../../../constants'; -import { SourceDataItem } from '../../../../../types'; +import { getSourceData } from '../../../source_data'; import { AddCustomSourceLogic, AddCustomSourceSteps } from './add_custom_source_logic'; import { ConfigureCustom } from './configure_custom'; import { SaveCustom } from './save_custom'; -interface Props { - sourceData: SourceDataItem; - initialValue?: string; -} -export const AddCustomSource: React.FC = ({ sourceData, initialValue = '' }) => { - const addCustomSourceLogic = AddCustomSourceLogic({ sourceData, initialValue }); +export const AddCustomSource: React.FC = () => { + const { baseServiceType } = useParams<{ baseServiceType?: string }>(); + const sourceData = getSourceData('custom', baseServiceType); + + const addCustomSourceLogic = AddCustomSourceLogic({ + baseServiceType, + initialValue: sourceData?.name, + }); + const { currentStep } = useValues(addCustomSourceLogic); const { isOrganization } = useValues(AppLogic); + if (!sourceData) { + return null; + } + const Layout = isOrganization ? WorkplaceSearchPageTemplate : PersonalDashboardLayout; return ( - - {currentStep === AddCustomSourceSteps.ConfigureCustomStep && } - {currentStep === AddCustomSourceSteps.SaveCustomStep && } + + {currentStep === AddCustomSourceSteps.ConfigureCustomStep && ( + + )} + {currentStep === AddCustomSourceSteps.SaveCustomStep && ( + + )} ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source_logic.test.ts index d2187bd0b21a1..2ca3462da0f57 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source_logic.test.ts @@ -14,7 +14,6 @@ import { sourceConfigData } from '../../../../../__mocks__/content_sources.mock' import { nextTick } from '@kbn/test-jest-helpers'; -import { docLinks } from '../../../../../../shared/doc_links'; import { itShowsServerErrorAsFlashMessage } from '../../../../../../test_helpers'; jest.mock('../../../../../app_logic', () => ({ @@ -22,35 +21,17 @@ jest.mock('../../../../../app_logic', () => ({ })); import { AppLogic } from '../../../../../app_logic'; -import { SOURCE_NAMES } from '../../../../../constants'; -import { CustomSource, SourceDataItem } from '../../../../../types'; +import { CustomSource } from '../../../../../types'; import { AddCustomSourceLogic, AddCustomSourceSteps } from './add_custom_source_logic'; -const CUSTOM_SOURCE_DATA_ITEM: SourceDataItem = { - name: SOURCE_NAMES.CUSTOM, - iconName: SOURCE_NAMES.CUSTOM, - serviceType: 'custom', - configuration: { - isPublicKey: false, - hasOauthRedirect: false, - needsBaseUrl: false, - documentationUrl: docLinks.workplaceSearchCustomSources, - applicationPortalUrl: '', - }, - accountContextOnly: false, -}; - const DEFAULT_VALUES = { currentStep: AddCustomSourceSteps.ConfigureCustomStep, buttonLoading: false, customSourceNameValue: '', newCustomSource: {} as CustomSource, - sourceData: CUSTOM_SOURCE_DATA_ITEM, }; -const MOCK_PROPS = { initialValue: '', sourceData: CUSTOM_SOURCE_DATA_ITEM }; - const MOCK_NAME = 'name'; describe('AddCustomSourceLogic', () => { @@ -60,7 +41,7 @@ describe('AddCustomSourceLogic', () => { beforeEach(() => { jest.clearAllMocks(); - mount({}, MOCK_PROPS); + mount({}); }); it('has expected default values', () => { @@ -112,12 +93,9 @@ describe('AddCustomSourceLogic', () => { describe('listeners', () => { beforeEach(() => { - mount( - { - customSourceNameValue: MOCK_NAME, - }, - MOCK_PROPS - ); + mount({ + customSourceNameValue: MOCK_NAME, + }); }); describe('organization context', () => { @@ -151,11 +129,7 @@ describe('AddCustomSourceLogic', () => { customSourceNameValue: MOCK_NAME, }, { - ...MOCK_PROPS, - sourceData: { - ...CUSTOM_SOURCE_DATA_ITEM, - serviceType: 'sharepoint-server', - }, + baseServiceType: 'share_point_server', } ); @@ -165,7 +139,7 @@ describe('AddCustomSourceLogic', () => { body: JSON.stringify({ service_type: 'custom', name: MOCK_NAME, - base_service_type: 'sharepoint-server', + base_service_type: 'share_point_server', }), }); }); @@ -199,11 +173,7 @@ describe('AddCustomSourceLogic', () => { customSourceNameValue: MOCK_NAME, }, { - ...MOCK_PROPS, - sourceData: { - ...CUSTOM_SOURCE_DATA_ITEM, - serviceType: 'sharepoint-server', - }, + baseServiceType: 'share_point_server', } ); @@ -215,7 +185,7 @@ describe('AddCustomSourceLogic', () => { body: JSON.stringify({ service_type: 'custom', name: MOCK_NAME, - base_service_type: 'sharepoint-server', + base_service_type: 'share_point_server', }), } ); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source_logic.ts index f85e0761f51b5..5b02fffa5892d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source_logic.ts @@ -10,11 +10,11 @@ import { kea, MakeLogicType } from 'kea'; import { flashAPIErrors, clearFlashMessages } from '../../../../../../shared/flash_messages'; import { HttpLogic } from '../../../../../../shared/http'; import { AppLogic } from '../../../../../app_logic'; -import { CustomSource, SourceDataItem } from '../../../../../types'; +import { CustomSource } from '../../../../../types'; export interface AddCustomSourceProps { - sourceData: SourceDataItem; - initialValue: string; + baseServiceType?: string; + initialValue?: string; } export enum AddCustomSourceSteps { @@ -34,7 +34,6 @@ interface AddCustomSourceValues { currentStep: AddCustomSourceSteps; customSourceNameValue: string; newCustomSource: CustomSource; - sourceData: SourceDataItem; } /** @@ -67,7 +66,7 @@ export const AddCustomSourceLogic = kea< }, ], customSourceNameValue: [ - props.initialValue, + props.initialValue || '', { setCustomSourceNameValue: (_, customSourceNameValue) => customSourceNameValue, }, @@ -78,7 +77,6 @@ export const AddCustomSourceLogic = kea< setNewCustomSource: (_, newCustomSource) => newCustomSource, }, ], - sourceData: [props.sourceData], }), listeners: ({ actions, values, props }) => ({ createContentSource: async () => { @@ -90,21 +88,12 @@ export const AddCustomSourceLogic = kea< const { customSourceNameValue } = values; - const baseParams = { + const params = { service_type: 'custom', name: customSourceNameValue, + base_service_type: props.baseServiceType, }; - // pre-configured custom sources have a serviceType reflecting their target service - // we submit this as `base_service_type` to keep track of - const params = - props.sourceData.serviceType === 'custom' - ? baseParams - : { - ...baseParams, - base_service_type: props.sourceData.serviceType, - }; - try { const response = await HttpLogic.values.http.post(route, { body: JSON.stringify(params), diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/configure_custom.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/configure_custom.test.tsx index 3ed60614d294a..a0713ec530b28 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/configure_custom.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/configure_custom.test.tsx @@ -21,24 +21,24 @@ import { ConfigureCustom } from './configure_custom'; describe('ConfigureCustom', () => { const setCustomSourceNameValue = jest.fn(); const createContentSource = jest.fn(); + const sourceData = staticSourceData[1]; beforeEach(() => { setMockActions({ setCustomSourceNameValue, createContentSource }); setMockValues({ customSourceNameValue: 'name', buttonLoading: false, - sourceData: staticSourceData[1], }); }); it('renders', () => { - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(EuiForm)).toHaveLength(1); }); it('handles input change', () => { - const wrapper = shallow(); + const wrapper = shallow(); const text = 'changed for the better'; const input = wrapper.find(EuiFieldText); input.simulate('change', { target: { value: text } }); @@ -47,7 +47,7 @@ describe('ConfigureCustom', () => { }); it('handles form submission', () => { - const wrapper = shallow(); + const wrapper = shallow(); const preventDefault = jest.fn(); wrapper.find('EuiForm').simulate('submit', { preventDefault }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/configure_custom.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/configure_custom.tsx index 024dd698cc0a2..4f673f56231cc 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/configure_custom.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/configure_custom.tsx @@ -21,11 +21,13 @@ import { EuiText, EuiTitle, } from '@elastic/eui'; + import { FormattedMessage } from '@kbn/i18n-react'; import { docLinks } from '../../../../../../shared/doc_links'; import connectionIllustration from '../../../../../assets/connection_illustration.svg'; +import { SourceDataItem } from '../../../../../types'; import { SOURCE_NAME_LABEL } from '../../../constants'; import { AddSourceHeader } from '../add_source_header'; @@ -33,9 +35,13 @@ import { CONFIG_CUSTOM_BUTTON, CONFIG_CUSTOM_LINK_TEXT, CONFIG_INTRO_ALT_TEXT } import { AddCustomSourceLogic } from './add_custom_source_logic'; -export const ConfigureCustom: React.FC = () => { +interface ConfigureCustomProps { + sourceData: SourceDataItem; +} + +export const ConfigureCustom: React.FC = ({ sourceData }) => { const { setCustomSourceNameValue, createContentSource } = useActions(AddCustomSourceLogic); - const { customSourceNameValue, buttonLoading, sourceData } = useValues(AddCustomSourceLogic); + const { customSourceNameValue, buttonLoading } = useValues(AddCustomSourceLogic); const handleFormSubmit = (e: FormEvent) => { e.preventDefault(); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/save_custom.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/save_custom.test.tsx index 3de514a3e4d71..8f4e6e7205ef2 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/save_custom.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/save_custom.test.tsx @@ -25,18 +25,21 @@ const mockValues = { accessToken: 'token', name: 'name', }, - sourceData: staticCustomSourceData, }; +const sourceData = staticCustomSourceData; + describe('SaveCustom', () => { + beforeAll(() => { + jest.clearAllMocks(); + setMockValues(mockValues); + }); + describe('default behavior', () => { let wrapper: ShallowWrapper; beforeAll(() => { - jest.clearAllMocks(); - setMockValues(mockValues); - - wrapper = shallow(); + wrapper = shallow(); }); it('contains a button back to the sources list', () => { @@ -52,20 +55,14 @@ describe('SaveCustom', () => { let wrapper: ShallowWrapper; beforeAll(() => { - jest.clearAllMocks(); - setMockValues({ - ...mockValues, - sourceData: { - ...staticCustomSourceData, - serviceType: 'sharepoint-server', - configuration: { - ...staticCustomSourceData.configuration, - githubRepository: 'elastic/sharepoint-server-connector', - }, - }, - }); - - wrapper = shallow(); + wrapper = shallow( + + ); }); it('includes a link to provide feedback', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/save_custom.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/save_custom.tsx index 9e5e3ac2782ee..df62d2b2bdf16 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/save_custom.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/save_custom.tsx @@ -21,12 +21,14 @@ import { EuiCallOut, EuiLink, } from '@elastic/eui'; + import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiButtonTo } from '../../../../../../shared/react_router_helpers'; import { AppLogic } from '../../../../../app_logic'; import { SOURCES_PATH, getSourcesPath } from '../../../../../routes'; +import { SourceDataItem } from '../../../../../types'; import { CustomSourceDeployment } from '../../custom_source_deployment'; @@ -35,10 +37,14 @@ import { SAVE_CUSTOM_BODY1 as READY_TO_ACCEPT_REQUESTS_LABEL } from '../constant import { AddCustomSourceLogic } from './add_custom_source_logic'; -export const SaveCustom: React.FC = () => { - const { newCustomSource, sourceData } = useValues(AddCustomSourceLogic); +interface SaveCustomProps { + sourceData: SourceDataItem; +} + +export const SaveCustom: React.FC = ({ sourceData }) => { + const { newCustomSource } = useValues(AddCustomSourceLogic); const { isOrganization } = useValues(AppLogic); - const { serviceType, name, categories = [] } = sourceData; + const { serviceType, baseServiceType, name, categories = [] } = sourceData; return ( <> @@ -92,10 +98,10 @@ export const SaveCustom: React.FC = () => { - + - {serviceType !== 'custom' && ( + {baseServiceType && ( <> diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_config.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_config.test.tsx index 2d8b5192fd3b1..8f517b740b152 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_config.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_config.test.tsx @@ -7,6 +7,7 @@ import '../../../../../../__mocks__/shallow_useeffect.mock'; import { setMockActions, setMockValues } from '../../../../../../__mocks__/kea_logic'; +import { mockUseParams } from '../../../../../../__mocks__/react_router'; import { sourceConfigData } from '../../../../../__mocks__/content_sources.mock'; import React from 'react'; @@ -19,24 +20,15 @@ import { WorkplaceSearchPageTemplate, PersonalDashboardLayout, } from '../../../../../components/layout'; -import { staticSourceData } from '../../../source_data'; import { ExternalConnectorConfig } from './external_connector_config'; import { ExternalConnectorFormFields } from './external_connector_form_fields'; describe('ExternalConnectorConfig', () => { - const goBack = jest.fn(); - const onDeleteConfig = jest.fn(); const setExternalConnectorApiKey = jest.fn(); const setExternalConnectorUrl = jest.fn(); const saveExternalConnectorConfig = jest.fn(); - const props = { - sourceData: staticSourceData[0], - goBack, - onDeleteConfig, - }; - const values = { sourceConfigData, buttonLoading: false, @@ -48,37 +40,47 @@ describe('ExternalConnectorConfig', () => { }; beforeEach(() => { + jest.clearAllMocks(); setMockActions({ setExternalConnectorApiKey, setExternalConnectorUrl, saveExternalConnectorConfig, }); setMockValues({ ...values }); + mockUseParams.mockReturnValue({}); + }); + + it('returns null if there is no matching source data for the service type', () => { + mockUseParams.mockReturnValue({ baseServiceType: 'doesnt_exist' }); + + const wrapper = shallow(); + + expect(wrapper.isEmptyRender()).toBe(true); }); it('renders', () => { - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(EuiSteps)).toHaveLength(1); expect(wrapper.find(EuiSteps).dive().find(ExternalConnectorFormFields)).toHaveLength(1); }); it('renders organizstion layout', () => { - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(WorkplaceSearchPageTemplate)).toHaveLength(1); }); it('should show correct layout for personal dashboard', () => { setMockValues({ ...values, isOrganization: false }); - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(WorkplaceSearchPageTemplate)).toHaveLength(0); expect(wrapper.find(PersonalDashboardLayout)).toHaveLength(1); }); it('handles form submission', () => { - const wrapper = shallow(); + const wrapper = shallow(); const preventDefault = jest.fn(); wrapper.find('form').simulate('submit', { preventDefault }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_config.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_config.tsx index 5a2558f141ea0..0b4e34f47103b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_config.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_config.tsx @@ -7,11 +7,12 @@ import React, { FormEvent } from 'react'; +import { useParams } from 'react-router-dom'; + import { useActions, useValues } from 'kea'; import { EuiButton, - EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiForm, @@ -26,56 +27,41 @@ import { PersonalDashboardLayout, WorkplaceSearchPageTemplate, } from '../../../../../components/layout'; -import { NAV, REMOVE_BUTTON } from '../../../../../constants'; -import { SourceDataItem } from '../../../../../types'; - -import { staticExternalSourceData } from '../../../source_data'; +import { NAV } from '../../../../../constants'; +import { getSourceData } from '../../../source_data'; import { AddSourceHeader } from '../add_source_header'; import { ConfigDocsLinks } from '../config_docs_links'; -import { OAUTH_SAVE_CONFIG_BUTTON, OAUTH_BACK_BUTTON } from '../constants'; +import { OAUTH_SAVE_CONFIG_BUTTON } from '../constants'; import { ExternalConnectorDocumentation } from './external_connector_documentation'; import { ExternalConnectorFormFields } from './external_connector_form_fields'; import { ExternalConnectorLogic } from './external_connector_logic'; -interface SaveConfigProps { - sourceData: SourceDataItem; - goBack?: () => void; - onDeleteConfig?: () => void; -} - -export const ExternalConnectorConfig: React.FC = ({ - sourceData, - goBack, - onDeleteConfig, -}) => { - const serviceType = 'external'; +export const ExternalConnectorConfig: React.FC = () => { + const { baseServiceType } = useParams<{ baseServiceType?: string }>(); + const sourceData = getSourceData('external', baseServiceType); const { saveExternalConnectorConfig } = useActions(ExternalConnectorLogic); - const { - formDisabled, - buttonLoading, - externalConnectorUrl, - externalConnectorApiKey, - sourceConfigData, - urlValid, - } = useValues(ExternalConnectorLogic); + const { formDisabled, buttonLoading, externalConnectorUrl, externalConnectorApiKey, urlValid } = + useValues(ExternalConnectorLogic); const handleFormSubmission = (e: FormEvent) => { e.preventDefault(); saveExternalConnectorConfig({ url: externalConnectorUrl, apiKey: externalConnectorApiKey }); }; - const { name, categories } = sourceConfigData; - const { - configuration: { applicationLinkTitle, applicationPortalUrl }, - } = sourceData; const { isOrganization } = useValues(AppLogic); + if (!sourceData) { + return null; + } + const { - configuration: { documentationUrl }, - } = staticExternalSourceData; + name, + categories = [], + configuration: { applicationLinkTitle, applicationPortalUrl, documentationUrl }, + } = sourceData; const saveButton = ( @@ -83,22 +69,10 @@ export const ExternalConnectorConfig: React.FC = ({ ); - const deleteButton = ( - - {REMOVE_BUTTON} - - ); - - const backButton = {OAUTH_BACK_BUTTON}; - const formActions = ( {saveButton} - - {goBack && backButton} - {onDeleteConfig && deleteButton} - ); @@ -132,11 +106,17 @@ export const ExternalConnectorConfig: React.FC = ({ }, ]; - const header = ; + const header = ( + + ); const Layout = isOrganization ? WorkplaceSearchPageTemplate : PersonalDashboardLayout; return ( - + {header} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_logic.test.ts index fb09695a3529d..0603b59cc75b0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_logic.test.ts @@ -36,10 +36,6 @@ describe('ExternalConnectorLogic', () => { formDisabled: true, externalConnectorUrl: '', externalConnectorApiKey: '', - sourceConfigData: { - name: '', - categories: [], - }, urlValid: true, showInsecureUrlCallout: false, insecureUrl: true, @@ -52,7 +48,6 @@ describe('ExternalConnectorLogic', () => { formDisabled: false, insecureUrl: false, dataLoading: false, - sourceConfigData, }; beforeEach(() => { @@ -87,7 +82,6 @@ describe('ExternalConnectorLogic', () => { it('saves the source config', () => { expect(ExternalConnectorLogic.values).toEqual({ ...DEFAULT_VALUES_SUCCESS, - sourceConfigData, }); }); @@ -104,7 +98,6 @@ describe('ExternalConnectorLogic', () => { ...DEFAULT_VALUES_SUCCESS, externalConnectorUrl: '', insecureUrl: true, - sourceConfigData: newSourceConfigData, }); }); it('sets undefined api key to empty string', () => { @@ -119,7 +112,6 @@ describe('ExternalConnectorLogic', () => { expect(ExternalConnectorLogic.values).toEqual({ ...DEFAULT_VALUES_SUCCESS, externalConnectorApiKey: '', - sourceConfigData: newSourceConfigData, }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_logic.ts index d1e4cf7f4f008..e36b790edd8e9 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_logic.ts @@ -48,7 +48,6 @@ export interface ExternalConnectorValues { externalConnectorApiKey: string; externalConnectorUrl: string; urlValid: boolean; - sourceConfigData: SourceConfigData | Pick; insecureUrl: boolean; showInsecureUrlCallout: boolean; } @@ -107,12 +106,6 @@ export const ExternalConnectorLogic = kea< setShowInsecureUrlCallout: (_, showCallout) => showCallout, }, ], - sourceConfigData: [ - { name: '', categories: [] }, - { - fetchExternalSourceSuccess: (_, sourceConfigData) => sourceConfigData, - }, - ], urlValid: [ true, { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.test.tsx index a7cfa81d30021..8811a68e49181 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.test.tsx @@ -11,6 +11,7 @@ import { setMockActions, setMockValues, } from '../../../../../__mocks__/kea_logic'; +import { mockUseParams } from '../../../../../__mocks__/react_router'; import { sourceConfigData } from '../../../../__mocks__/content_sources.mock'; import React from 'react'; @@ -22,13 +23,9 @@ import { PersonalDashboardLayout, } from '../../../../components/layout'; -import { staticSourceData } from '../../source_data'; - import { AddSource } from './add_source'; import { AddSourceSteps } from './add_source_logic'; import { ConfigCompleted } from './config_completed'; -import { ConfigurationChoice } from './configuration_choice'; -import { ConfigurationIntro } from './configuration_intro'; import { ConfigureOauth } from './configure_oauth'; import { ConnectInstance } from './connect_instance'; import { Reauthenticate } from './reauthenticate'; @@ -36,7 +33,7 @@ import { SaveConfig } from './save_config'; describe('AddSourceList', () => { const { navigateToUrl } = mockKibanaValues; - const initializeAddSource = jest.fn(); + const getSourceConfigData = jest.fn(); const setAddSourceStep = jest.fn(); const saveSourceConfig = jest.fn((_, setConfigCompletedStep) => { setConfigCompletedStep(); @@ -47,7 +44,7 @@ describe('AddSourceList', () => { const resetSourcesState = jest.fn(); const mockValues = { - addSourceCurrentStep: AddSourceSteps.ConfigIntroStep, + addSourceCurrentStep: null, sourceConfigData, dataLoading: false, newCustomSource: {}, @@ -56,68 +53,29 @@ describe('AddSourceList', () => { }; beforeEach(() => { + jest.clearAllMocks(); setMockActions({ - initializeAddSource, + getSourceConfigData, setAddSourceStep, saveSourceConfig, createContentSource, resetSourcesState, }); setMockValues(mockValues); - }); - - it('renders default state', () => { - const wrapper = shallow(); - wrapper.find(ConfigurationIntro).prop('advanceStep')(); - - expect(setAddSourceStep).toHaveBeenCalledWith(AddSourceSteps.SaveConfigStep); - expect(initializeAddSource).toHaveBeenCalled(); - }); - - it('renders default state correctly when there are multiple connector options', () => { - const wrapper = shallow( - - ); - wrapper.find(ConfigurationIntro).prop('advanceStep')(); - - expect(setAddSourceStep).toHaveBeenCalledWith(AddSourceSteps.ChoiceStep); - }); - - it('renders default state correctly when there are multiple connector options but external connector is configured', () => { - setMockValues({ ...mockValues, externalConfigured: true }); - const wrapper = shallow( - - ); - wrapper.find(ConfigurationIntro).prop('advanceStep')(); - - expect(setAddSourceStep).toHaveBeenCalledWith(AddSourceSteps.SaveConfigStep); + mockUseParams.mockReturnValue({ serviceType: 'confluence_cloud' }); }); describe('layout', () => { it('renders the default workplace search layout when on an organization view', () => { setMockValues({ ...mockValues, isOrganization: true }); - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.type()).toEqual(WorkplaceSearchPageTemplate); }); it('renders the personal dashboard layout when not in an organization', () => { setMockValues({ ...mockValues, isOrganization: false }); - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.type()).toEqual(PersonalDashboardLayout); }); @@ -125,7 +83,7 @@ describe('AddSourceList', () => { it('renders a breadcrumb fallback while data is loading', () => { setMockValues({ ...mockValues, dataLoading: true, sourceConfigData: {} }); - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.prop('pageChrome')).toEqual(['Sources', 'Add Source', '...']); }); @@ -135,26 +93,24 @@ describe('AddSourceList', () => { ...mockValues, addSourceCurrentStep: AddSourceSteps.ConfigCompletedStep, }); - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(ConfigCompleted).prop('showFeedbackLink')).toEqual(false); wrapper.find(ConfigCompleted).prop('advanceStep')(); - expect(navigateToUrl).toHaveBeenCalledWith('/sources/add/confluence_cloud/connect'); expect(setAddSourceStep).toHaveBeenCalledWith(AddSourceSteps.ConnectInstanceStep); }); it('renders Config Completed step with feedback for external connectors', () => { + mockUseParams.mockReturnValue({ serviceType: 'external' }); setMockValues({ ...mockValues, sourceConfigData: { ...sourceConfigData, serviceType: 'external' }, addSourceCurrentStep: AddSourceSteps.ConfigCompletedStep, }); - const wrapper = shallow( - - ); + const wrapper = shallow(); expect(wrapper.find(ConfigCompleted).prop('showFeedbackLink')).toEqual(true); + wrapper.find(ConfigCompleted).prop('advanceStep')(); - expect(navigateToUrl).toHaveBeenCalledWith('/sources/add/confluence_cloud/connect'); expect(setAddSourceStep).toHaveBeenCalledWith(AddSourceSteps.ConnectInstanceStep); }); @@ -163,13 +119,13 @@ describe('AddSourceList', () => { ...mockValues, addSourceCurrentStep: AddSourceSteps.SaveConfigStep, }); - const wrapper = shallow(); + const wrapper = shallow(); const saveConfig = wrapper.find(SaveConfig); saveConfig.prop('advanceStep')(); - saveConfig.prop('goBackStep')!(); - - expect(setAddSourceStep).toHaveBeenCalledWith(AddSourceSteps.ConfigIntroStep); expect(saveSourceConfig).toHaveBeenCalled(); + + saveConfig.prop('goBackStep')!(); + expect(navigateToUrl).toHaveBeenCalledWith('/sources/add/confluence_cloud/intro'); }); it('renders Connect Instance step', () => { @@ -178,10 +134,11 @@ describe('AddSourceList', () => { sourceConfigData, addSourceCurrentStep: AddSourceSteps.ConnectInstanceStep, }); - const wrapper = shallow(); + + const wrapper = shallow(); wrapper.find(ConnectInstance).prop('onFormCreated')('foo'); - expect(navigateToUrl).toHaveBeenCalledWith('/sources/add/confluence_cloud/connect'); + expect(navigateToUrl).toHaveBeenCalledWith('/sources'); }); it('renders Configure Oauth step', () => { @@ -189,11 +146,11 @@ describe('AddSourceList', () => { ...mockValues, addSourceCurrentStep: AddSourceSteps.ConfigureOauthStep, }); - const wrapper = shallow(); + const wrapper = shallow(); wrapper.find(ConfigureOauth).prop('onFormCreated')('foo'); - expect(navigateToUrl).toHaveBeenCalledWith('/sources/add/confluence_cloud/connect'); + expect(navigateToUrl).toHaveBeenCalledWith('/sources'); }); it('renders Reauthenticate step', () => { @@ -201,23 +158,8 @@ describe('AddSourceList', () => { ...mockValues, addSourceCurrentStep: AddSourceSteps.ReauthenticateStep, }); - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(Reauthenticate)).toHaveLength(1); }); - - it('renders Config Choice step', () => { - setMockValues({ - ...mockValues, - addSourceCurrentStep: AddSourceSteps.ChoiceStep, - }); - const wrapper = shallow(); - const advance = wrapper.find(ConfigurationChoice).prop('goToInternalStep'); - expect(advance).toBeDefined(); - if (advance) { - advance(); - } - - expect(setAddSourceStep).toHaveBeenCalledWith(AddSourceSteps.SaveConfigStep); - }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.tsx index 4bdf8db217a7b..5b992703def61 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.tsx @@ -7,29 +7,28 @@ import React, { useEffect } from 'react'; +import { useParams } from 'react-router-dom'; + import { useActions, useValues } from 'kea'; import { i18n } from '@kbn/i18n'; import { flashSuccessToast } from '../../../../../shared/flash_messages'; import { KibanaLogic } from '../../../../../shared/kibana'; +import { LicensingLogic } from '../../../../../shared/licensing'; import { AppLogic } from '../../../../app_logic'; import { WorkplaceSearchPageTemplate, PersonalDashboardLayout, } from '../../../../components/layout'; import { NAV } from '../../../../constants'; -import { SOURCES_PATH, getSourcesPath, getAddPath } from '../../../../routes'; - -import { hasMultipleConnectorOptions } from '../../../../utils'; +import { SOURCES_PATH, getSourcesPath, getAddPath, ADD_SOURCE_PATH } from '../../../../routes'; -import { SourcesLogic } from '../../sources_logic'; +import { getSourceData } from '../../source_data'; import { AddSourceHeader } from './add_source_header'; -import { AddSourceLogic, AddSourceProps, AddSourceSteps } from './add_source_logic'; +import { AddSourceLogic, AddSourceSteps } from './add_source_logic'; import { ConfigCompleted } from './config_completed'; -import { ConfigurationChoice } from './configuration_choice'; -import { ConfigurationIntro } from './configuration_intro'; import { ConfigureOauth } from './configure_oauth'; import { ConnectInstance } from './connect_instance'; import { Reauthenticate } from './reauthenticate'; @@ -37,27 +36,42 @@ import { SaveConfig } from './save_config'; import './add_source.scss'; -export const AddSource: React.FC = (props) => { - const { initializeAddSource, setAddSourceStep, saveSourceConfig, resetSourceState } = - useActions(AddSourceLogic); - const { addSourceCurrentStep, sourceConfigData, dataLoading } = useValues(AddSourceLogic); - const { name, categories, needsPermissions, accountContextOnly, privateSourcesEnabled } = - sourceConfigData; - const { serviceType, configuration, features, objTypes } = props.sourceData; - const addPath = getAddPath(serviceType); +export const AddSource: React.FC = () => { + const { serviceType, initialStep } = useParams<{ serviceType: string; initialStep?: string }>(); + const addSourceLogic = AddSourceLogic({ serviceType, initialStep }); + const { getSourceConfigData, setAddSourceStep, saveSourceConfig, resetSourceState } = + useActions(addSourceLogic); + const { addSourceCurrentStep, sourceConfigData, dataLoading } = useValues(addSourceLogic); const { isOrganization } = useValues(AppLogic); - const { externalConfigured } = useValues(SourcesLogic); + const { hasPlatinumLicense } = useValues(LicensingLogic); + const { navigateToUrl } = useValues(KibanaLogic); useEffect(() => { - initializeAddSource(props); + getSourceConfigData(); return resetSourceState; - }, []); + }, [serviceType]); + + const sourceData = getSourceData(serviceType); + + if (!sourceData) { + return null; + } + + const { configuration, features, objTypes } = sourceData; + + const { name, categories, needsPermissions, accountContextOnly, privateSourcesEnabled } = + sourceConfigData; + + if (!hasPlatinumLicense && accountContextOnly) { + navigateToUrl(getSourcesPath(ADD_SOURCE_PATH, isOrganization)); + } - const goToConfigurationIntro = () => setAddSourceStep(AddSourceSteps.ConfigIntroStep); - const goToSaveConfig = () => setAddSourceStep(AddSourceSteps.SaveConfigStep); + const goToConfigurationIntro = () => + KibanaLogic.values.navigateToUrl( + `${getSourcesPath(getAddPath(serviceType), isOrganization)}/intro` + ); const setConfigCompletedStep = () => setAddSourceStep(AddSourceSteps.ConfigCompletedStep); const goToConfigCompleted = () => saveSourceConfig(false, setConfigCompletedStep); - const goToChoice = () => setAddSourceStep(AddSourceSteps.ChoiceStep); const FORM_SOURCE_ADDED_SUCCESS_MESSAGE = i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.contentSource.formSourceAddedSuccessMessage', { @@ -66,11 +80,7 @@ export const AddSource: React.FC = (props) => { } ); - const goToConnectInstance = () => { - setAddSourceStep(AddSourceSteps.ConnectInstanceStep); - KibanaLogic.values.navigateToUrl(`${getSourcesPath(addPath, isOrganization)}/connect`); - }; - + const goToConnectInstance = () => setAddSourceStep(AddSourceSteps.ConnectInstanceStep); const goToFormSourceCreated = () => { KibanaLogic.values.navigateToUrl(`${getSourcesPath(SOURCES_PATH, isOrganization)}`); flashSuccessToast(FORM_SOURCE_ADDED_SUCCESS_MESSAGE); @@ -81,18 +91,6 @@ export const AddSource: React.FC = (props) => { return ( - {addSourceCurrentStep === AddSourceSteps.ConfigIntroStep && ( - - )} {addSourceCurrentStep === AddSourceSteps.SaveConfigStep && ( = (props) => { {addSourceCurrentStep === AddSourceSteps.ReauthenticateStep && ( )} - {addSourceCurrentStep === AddSourceSteps.ChoiceStep && ( - - )} ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_choice.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_choice.test.tsx new file mode 100644 index 0000000000000..75b45da2b38b1 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_choice.test.tsx @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import '../../../../../__mocks__/shallow_useeffect.mock'; +import { setMockValues, mockKibanaValues } from '../../../../../__mocks__/kea_logic'; + +import { mockUseParams } from '../../../../../__mocks__/react_router'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { + WorkplaceSearchPageTemplate, + PersonalDashboardLayout, +} from '../../../../components/layout'; + +import { getSourceData } from '../../source_data'; + +import { AddSourceChoice } from './add_source_choice'; +import { ConfigurationChoice } from './configuration_choice'; + +describe('AddSourceChoice', () => { + const { navigateToUrl } = mockKibanaValues; + + const mockValues = { + isOrganization: true, + hasPlatinumLicense: true, + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockUseParams.mockReturnValue({ serviceType: 'share_point' }); + }); + + it('returns null if there is no matching source data for the service type', () => { + mockUseParams.mockReturnValue({ serviceType: 'doesnt_exist' }); + setMockValues(mockValues); + + const wrapper = shallow(); + + expect(wrapper.isEmptyRender()).toBe(true); + }); + + it('redirects to root add source path if user does not have a platinum license and the service is account context only', () => { + mockUseParams.mockReturnValue({ serviceType: 'slack' }); + setMockValues({ ...mockValues, hasPlatinumLicense: false }); + + shallow(); + + expect(navigateToUrl).toHaveBeenCalledWith('/sources/add'); + }); + + describe('layout', () => { + it('renders the default workplace search layout when on an organization view', () => { + setMockValues({ ...mockValues, isOrganization: true }); + const wrapper = shallow(); + + expect(wrapper.type()).toEqual(WorkplaceSearchPageTemplate); + }); + + it('renders the personal dashboard layout when not in an organization', () => { + setMockValues({ ...mockValues, isOrganization: false }); + const wrapper = shallow(); + + expect(wrapper.type()).toEqual(PersonalDashboardLayout); + }); + }); + + it('renders Config Choice step', () => { + setMockValues(mockValues); + const wrapper = shallow(); + + expect(wrapper.find(ConfigurationChoice).prop('sourceData')).toEqual( + getSourceData('share_point') + ); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_choice.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_choice.tsx new file mode 100644 index 0000000000000..1034d207c9907 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_choice.tsx @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { useParams } from 'react-router-dom'; + +import { useValues } from 'kea'; + +import { KibanaLogic } from '../../../../../shared/kibana'; +import { LicensingLogic } from '../../../../../shared/licensing'; + +import { AppLogic } from '../../../../app_logic'; +import { + WorkplaceSearchPageTemplate, + PersonalDashboardLayout, +} from '../../../../components/layout'; +import { NAV } from '../../../../constants'; + +import { getSourcesPath, ADD_SOURCE_PATH } from '../../../../routes'; + +import { getSourceData } from '../../source_data'; + +import { ConfigurationChoice } from './configuration_choice'; + +import './add_source.scss'; + +export const AddSourceChoice: React.FC = () => { + const { serviceType } = useParams<{ serviceType: string }>(); + const sourceData = getSourceData(serviceType); + + const { isOrganization } = useValues(AppLogic); + const { hasPlatinumLicense } = useValues(LicensingLogic); + const { navigateToUrl } = useValues(KibanaLogic); + + if (!sourceData) { + return null; + } + + const { name, accountContextOnly } = sourceData; + + if (!hasPlatinumLicense && accountContextOnly) { + navigateToUrl(getSourcesPath(ADD_SOURCE_PATH, isOrganization)); + } + + const Layout = isOrganization ? WorkplaceSearchPageTemplate : PersonalDashboardLayout; + + return ( + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_intro.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_intro.test.tsx new file mode 100644 index 0000000000000..a7eeadf3a615e --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_intro.test.tsx @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import '../../../../../__mocks__/shallow_useeffect.mock'; +import { setMockValues } from '../../../../../__mocks__/kea_logic'; +import { mockUseParams } from '../../../../../__mocks__/react_router'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { + WorkplaceSearchPageTemplate, + PersonalDashboardLayout, +} from '../../../../components/layout'; + +import { AddSourceIntro } from './add_source_intro'; +import { ConfigurationIntro } from './configuration_intro'; + +describe('AddSourceList', () => { + const mockValues = { + isOrganization: true, + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockUseParams.mockReturnValue({ serviceType: 'share_point' }); + }); + + it('returns null if there is no matching source data for the service type', () => { + mockUseParams.mockReturnValue({ serviceType: 'doesnt_exist' }); + setMockValues(mockValues); + + const wrapper = shallow(); + + expect(wrapper.isEmptyRender()).toBe(true); + }); + + it('sends the user to a choice view when there are multiple connector options', () => { + setMockValues(mockValues); + + const wrapper = shallow(); + + expect(wrapper.find(ConfigurationIntro).prop('advanceStepTo')).toEqual( + '/sources/add/share_point/choice' + ); + }); + + it('sends the user to the add source view by default', () => { + mockUseParams.mockReturnValue({ serviceType: 'slack' }); + setMockValues(mockValues); + + const wrapper = shallow(); + + expect(wrapper.find(ConfigurationIntro).prop('advanceStepTo')).toEqual('/sources/add/slack/'); + }); + + describe('layout', () => { + it('renders the default workplace search layout when on an organization view', () => { + setMockValues({ ...mockValues, isOrganization: true }); + + const wrapper = shallow(); + + expect(wrapper.type()).toEqual(WorkplaceSearchPageTemplate); + }); + + it('renders the personal dashboard layout when not in an organization', () => { + setMockValues({ ...mockValues, isOrganization: false }); + + const wrapper = shallow(); + + expect(wrapper.type()).toEqual(PersonalDashboardLayout); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_intro.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_intro.tsx new file mode 100644 index 0000000000000..b375f04a27f0f --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_intro.tsx @@ -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 React from 'react'; + +import { useParams } from 'react-router-dom'; + +import { useValues } from 'kea'; + +import { KibanaLogic } from '../../../../../shared/kibana'; +import { LicensingLogic } from '../../../../../shared/licensing'; + +import { AppLogic } from '../../../../app_logic'; +import { + WorkplaceSearchPageTemplate, + PersonalDashboardLayout, +} from '../../../../components/layout'; +import { NAV } from '../../../../constants'; +import { getSourcesPath, ADD_SOURCE_PATH, getAddPath } from '../../../../routes'; + +import { getSourceData, hasMultipleConnectorOptions } from '../../source_data'; + +import { AddSourceHeader } from './add_source_header'; +import { ConfigurationIntro } from './configuration_intro'; + +import './add_source.scss'; + +export const AddSourceIntro: React.FC = () => { + const { serviceType } = useParams<{ serviceType: string }>(); + const sourceData = getSourceData(serviceType); + + const { isOrganization } = useValues(AppLogic); + const { hasPlatinumLicense } = useValues(LicensingLogic); + const { navigateToUrl } = useValues(KibanaLogic); + + if (!sourceData) { + return null; + } + + const { name, categories = [], accountContextOnly } = sourceData; + + if (!hasPlatinumLicense && accountContextOnly) { + navigateToUrl(getSourcesPath(ADD_SOURCE_PATH, isOrganization)); + } + + const header = ; + const Layout = isOrganization ? WorkplaceSearchPageTemplate : PersonalDashboardLayout; + const to = + `${getSourcesPath(getAddPath(serviceType), isOrganization)}/` + + (hasMultipleConnectorOptions(serviceType) ? 'choice' : ''); + return ( + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.test.ts index 88ca96b8c0fbf..3224628e72c73 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.test.ts @@ -15,7 +15,6 @@ import { sourceConfigData } from '../../../../__mocks__/content_sources.mock'; import { nextTick } from '@kbn/test-jest-helpers'; -import { docLinks } from '../../../../../shared/doc_links'; import { itShowsServerErrorAsFlashMessage } from '../../../../../test_helpers'; jest.mock('../../../../app_logic', () => ({ @@ -23,10 +22,9 @@ jest.mock('../../../../app_logic', () => ({ })); import { AppLogic } from '../../../../app_logic'; -import { SOURCE_NAMES, SOURCE_OBJ_TYPES } from '../../../../constants'; import { SOURCES_PATH, PRIVATE_SOURCES_PATH, getSourcesPath } from '../../../../routes'; -import { FeatureIds } from '../../../../types'; import { PERSONAL_DASHBOARD_SOURCE_ERROR } from '../../constants'; +import { staticSourceData } from '../../source_data'; import { SourcesLogic } from '../../sources_logic'; import { ExternalConnectorLogic } from './add_external_connector/external_connector_logic'; @@ -37,7 +35,6 @@ import { SourceConnectData, OrganizationsMap, AddSourceValues, - AddSourceProps, } from './add_source_logic'; describe('AddSourceLogic', () => { @@ -47,8 +44,7 @@ describe('AddSourceLogic', () => { const { clearFlashMessages, flashAPIErrors, setErrorMessage } = mockFlashMessageHelpers; const DEFAULT_VALUES: AddSourceValues = { - addSourceCurrentStep: AddSourceSteps.ConfigIntroStep, - addSourceProps: {} as AddSourceProps, + addSourceCurrentStep: null, dataLoading: true, sectionLoading: true, buttonLoading: false, @@ -62,11 +58,11 @@ describe('AddSourceLogic', () => { sourceConfigData: {} as SourceConfigData, sourceConnectData: {} as SourceConnectData, oauthConfigCompleted: false, - currentServiceType: '', githubOrganizations: [], selectedGithubOrganizationsMap: {} as OrganizationsMap, selectedGithubOrganizations: [], preContentSourceId: '', + sourceData: staticSourceData[0], }; const sourceConnectData = { @@ -79,40 +75,13 @@ describe('AddSourceLogic', () => { serviceType: 'github', githubOrganizations: ['foo', 'bar'], }; - const DEFAULT_SERVICE_TYPE = { - name: SOURCE_NAMES.BOX, - iconName: SOURCE_NAMES.BOX, - serviceType: 'box', - configuration: { - isPublicKey: false, - hasOauthRedirect: true, - needsBaseUrl: false, - documentationUrl: docLinks.workplaceSearchBox, - applicationPortalUrl: 'https://app.box.com/developers/console', - }, - objTypes: [SOURCE_OBJ_TYPES.FOLDERS, SOURCE_OBJ_TYPES.ALL_FILES], - features: { - basicOrgContext: [ - FeatureIds.SyncFrequency, - FeatureIds.SyncedItems, - FeatureIds.GlobalAccessPermissions, - ], - basicOrgContextExcludedFeatures: [FeatureIds.DocumentLevelPermissions], - platinumOrgContext: [FeatureIds.SyncFrequency, FeatureIds.SyncedItems], - platinumPrivateContext: [ - FeatureIds.Private, - FeatureIds.SyncFrequency, - FeatureIds.SyncedItems, - ], - }, - accountContextOnly: false, - }; + const DEFAULT_SERVICE_TYPE = 'box'; beforeEach(() => { jest.clearAllMocks(); ExternalConnectorLogic.mount(); SourcesLogic.mount(); - mount(); + mount({}, { serviceType: 'box' }); }); it('has expected default values', () => { @@ -215,7 +184,6 @@ describe('AddSourceLogic', () => { oauthConfigCompleted: true, dataLoading: false, sectionLoading: false, - currentServiceType: config.serviceType, githubOrganizations: config.githubOrganizations, }); }); @@ -286,140 +254,90 @@ describe('AddSourceLogic', () => { }); describe('listeners', () => { - it('initializeAddSource', () => { - const addSourceProps = { sourceData: DEFAULT_SERVICE_TYPE }; - const getSourceConfigDataSpy = jest.spyOn(AddSourceLogic.actions, 'getSourceConfigData'); - const setAddSourcePropsSpy = jest.spyOn(AddSourceLogic.actions, 'setAddSourceProps'); - - AddSourceLogic.actions.initializeAddSource(addSourceProps); - - expect(setAddSourcePropsSpy).toHaveBeenCalledWith({ addSourceProps }); - expect(getSourceConfigDataSpy).toHaveBeenCalledWith('box', addSourceProps); - }); - describe('setFirstStep', () => { - it('sets intro as first step', () => { + it('sets save config as first step if unconfigured', () => { + mount( + { + sourceConfigData: { + ...sourceConfigData, + configured: false, + }, + }, + { serviceType: DEFAULT_SERVICE_TYPE } + ); const setAddSourceStepSpy = jest.spyOn(AddSourceLogic.actions, 'setAddSourceStep'); - const addSourceProps = { sourceData: DEFAULT_SERVICE_TYPE }; - AddSourceLogic.actions.setFirstStep(addSourceProps); - expect(setAddSourceStepSpy).toHaveBeenCalledWith(AddSourceSteps.ConfigIntroStep); + AddSourceLogic.actions.setFirstStep(); + + expect(setAddSourceStepSpy).toHaveBeenCalledWith(AddSourceSteps.SaveConfigStep); }); + it('sets connect as first step', () => { + mount({ sourceConfigData }, { serviceType: DEFAULT_SERVICE_TYPE, initialStep: 'connect' }); const setAddSourceStepSpy = jest.spyOn(AddSourceLogic.actions, 'setAddSourceStep'); - const addSourceProps = { sourceData: DEFAULT_SERVICE_TYPE, connect: true }; - AddSourceLogic.actions.setFirstStep(addSourceProps); + + AddSourceLogic.actions.setFirstStep(); expect(setAddSourceStepSpy).toHaveBeenCalledWith(AddSourceSteps.ConnectInstanceStep); }); it('sets configure as first step', () => { + mount( + { sourceConfigData }, + { serviceType: DEFAULT_SERVICE_TYPE, initialStep: 'configure' } + ); const setAddSourceStepSpy = jest.spyOn(AddSourceLogic.actions, 'setAddSourceStep'); - const addSourceProps = { sourceData: DEFAULT_SERVICE_TYPE, configure: true }; - AddSourceLogic.actions.setFirstStep(addSourceProps); + + AddSourceLogic.actions.setFirstStep(); expect(setAddSourceStepSpy).toHaveBeenCalledWith(AddSourceSteps.ConfigureOauthStep); }); - it('sets reAuthenticate as first step', () => { + it('sets reauthenticate as first step', () => { + mount( + { sourceConfigData }, + { serviceType: DEFAULT_SERVICE_TYPE, initialStep: 'reauthenticate' } + ); const setAddSourceStepSpy = jest.spyOn(AddSourceLogic.actions, 'setAddSourceStep'); - const addSourceProps = { sourceData: DEFAULT_SERVICE_TYPE, reAuthenticate: true }; - AddSourceLogic.actions.setFirstStep(addSourceProps); + + AddSourceLogic.actions.setFirstStep(); expect(setAddSourceStepSpy).toHaveBeenCalledWith(AddSourceSteps.ReauthenticateStep); }); - it('sets SaveConfig as first step for external connectors', () => { - const setAddSourceStepSpy = jest.spyOn(AddSourceLogic.actions, 'setAddSourceStep'); - const addSourceProps = { - sourceData: { - ...DEFAULT_SERVICE_TYPE, - serviceType: 'external', - }, - }; - AddSourceLogic.actions.setFirstStep(addSourceProps); - expect(setAddSourceStepSpy).toHaveBeenCalledWith(AddSourceSteps.SaveConfigStep); - }); - it('sets SaveConfigStep for when external connector is available and configured', () => { - const setAddSourceStepSpy = jest.spyOn(AddSourceLogic.actions, 'setAddSourceStep'); - const addSourceProps = { - sourceData: { - ...DEFAULT_SERVICE_TYPE, - externalConnectorAvailable: true, - }, - }; - AddSourceLogic.actions.setSourceConfigData({ - ...sourceConfigData, - serviceType: 'external', - configured: false, - }); - SourcesLogic.mount(); - SourcesLogic.actions.onInitializeSources({ - contentSources: [], - serviceTypes: [ - { - serviceType: 'external', + it('sets connect step if configured', () => { + mount( + { + sourceConfigData: { + ...sourceConfigData, configured: true, }, - ], - } as any); - AddSourceLogic.actions.setFirstStep(addSourceProps); - - expect(setAddSourceStepSpy).toHaveBeenCalledWith(AddSourceSteps.SaveConfigStep); - }); - it('sets Connect step when configured and external connector is available and configured', () => { - const setAddSourceStepSpy = jest.spyOn(AddSourceLogic.actions, 'setAddSourceStep'); - const addSourceProps = { - sourceData: { - ...DEFAULT_SERVICE_TYPE, - externalConnectorAvailable: true, - configured: true, }, - }; - AddSourceLogic.actions.setSourceConfigData({ - ...sourceConfigData, - serviceType: 'external', - configured: true, - }); - SourcesLogic.mount(); - SourcesLogic.actions.onInitializeSources({ - contentSources: [], - serviceTypes: [ - { - serviceType: 'external', - configured: true, - }, - ], - } as any); - AddSourceLogic.actions.setFirstStep(addSourceProps); + { serviceType: DEFAULT_SERVICE_TYPE } + ); + const setAddSourceStepSpy = jest.spyOn(AddSourceLogic.actions, 'setAddSourceStep'); + AddSourceLogic.actions.setFirstStep(); expect(setAddSourceStepSpy).toHaveBeenCalledWith(AddSourceSteps.ConnectInstanceStep); }); - it('sets Connect step when external and fully configured', () => { - const setAddSourceStepSpy = jest.spyOn(AddSourceLogic.actions, 'setAddSourceStep'); - const addSourceProps = { - sourceData: { - ...DEFAULT_SERVICE_TYPE, - serviceType: 'external', - }, - }; - AddSourceLogic.actions.setSourceConfigData({ - ...sourceConfigData, - configured: true, - serviceType: 'external', - configuredFields: { clientId: 'a', clientSecret: 'b' }, - }); - SourcesLogic.mount(); - SourcesLogic.actions.onInitializeSources({ - contentSources: [], - serviceTypes: [ - { + + it('sets connect step if external connector has client id and secret', () => { + mount( + { + sourceConfigData: { + ...sourceConfigData, serviceType: 'external', - configured: true, + configuredFields: { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + }, }, - ], - } as any); - AddSourceLogic.actions.setFirstStep(addSourceProps); + }, + { serviceType: DEFAULT_SERVICE_TYPE } + ); + const setAddSourceStepSpy = jest.spyOn(AddSourceLogic.actions, 'setAddSourceStep'); + + AddSourceLogic.actions.setFirstStep(); expect(setAddSourceStepSpy).toHaveBeenCalledWith(AddSourceSteps.ConnectInstanceStep); }); @@ -541,30 +459,33 @@ describe('AddSourceLogic', () => { const setSourceConfigDataSpy = jest.spyOn(AddSourceLogic.actions, 'setSourceConfigData'); http.get.mockReturnValue(Promise.resolve(sourceConfigData)); - AddSourceLogic.actions.getSourceConfigData('github'); + AddSourceLogic.actions.getSourceConfigData(); + await nextTick(); + expect(http.get).toHaveBeenCalledWith( - '/internal/workplace_search/org/settings/connectors/github' + '/internal/workplace_search/org/settings/connectors/box' ); - await nextTick(); expect(setSourceConfigDataSpy).toHaveBeenCalledWith(sourceConfigData); }); + it('calls API and sets values and calls setFirstStep if AddSourceProps is provided', async () => { const setSourceConfigDataSpy = jest.spyOn(AddSourceLogic.actions, 'setSourceConfigData'); const setFirstStepSpy = jest.spyOn(AddSourceLogic.actions, 'setFirstStep'); - const addSourceProps = { sourceData: DEFAULT_SERVICE_TYPE }; + http.get.mockReturnValue(Promise.resolve(sourceConfigData)); - AddSourceLogic.actions.getSourceConfigData('github', addSourceProps); + AddSourceLogic.actions.getSourceConfigData(); + await nextTick(); + expect(http.get).toHaveBeenCalledWith( - '/internal/workplace_search/org/settings/connectors/github' + '/internal/workplace_search/org/settings/connectors/box' ); - await nextTick(); expect(setSourceConfigDataSpy).toHaveBeenCalledWith(sourceConfigData); - expect(setFirstStepSpy).toHaveBeenCalledWith(addSourceProps); + expect(setFirstStepSpy).toHaveBeenCalled(); }); itShowsServerErrorAsFlashMessage(http.get, () => { - AddSourceLogic.actions.getSourceConfigData('github'); + AddSourceLogic.actions.getSourceConfigData(); }); }); @@ -579,7 +500,7 @@ describe('AddSourceLogic', () => { ); http.get.mockReturnValue(Promise.resolve(sourceConnectData)); - AddSourceLogic.actions.getSourceConnectData('github', successCallback); + AddSourceLogic.actions.getSourceConnectData(successCallback); const query = { index_permissions: false, @@ -588,7 +509,7 @@ describe('AddSourceLogic', () => { expect(clearFlashMessages).toHaveBeenCalled(); expect(AddSourceLogic.values.buttonLoading).toEqual(true); expect(http.get).toHaveBeenCalledWith( - '/internal/workplace_search/org/sources/github/prepare', + '/internal/workplace_search/org/sources/box/prepare', { query, } @@ -602,7 +523,7 @@ describe('AddSourceLogic', () => { it('passes query params', () => { AddSourceLogic.actions.setSourceSubdomainValue('subdomain'); AddSourceLogic.actions.setSourceIndexPermissionsValue(true); - AddSourceLogic.actions.getSourceConnectData('github', successCallback); + AddSourceLogic.actions.getSourceConnectData(successCallback); const query = { index_permissions: true, @@ -610,7 +531,7 @@ describe('AddSourceLogic', () => { }; expect(http.get).toHaveBeenCalledWith( - '/internal/workplace_search/org/sources/github/prepare', + '/internal/workplace_search/org/sources/box/prepare', { query, } @@ -618,7 +539,7 @@ describe('AddSourceLogic', () => { }); itShowsServerErrorAsFlashMessage(http.get, () => { - AddSourceLogic.actions.getSourceConnectData('github', successCallback); + AddSourceLogic.actions.getSourceConnectData(successCallback); }); }); @@ -833,7 +754,7 @@ describe('AddSourceLogic', () => { const successCallback = jest.fn(); const errorCallback = jest.fn(); - const serviceType = 'zendesk'; + const serviceType = 'box'; const login = 'login'; const password = 'password'; const indexPermissions = false; @@ -859,7 +780,7 @@ describe('AddSourceLogic', () => { const setButtonNotLoadingSpy = jest.spyOn(AddSourceLogic.actions, 'setButtonNotLoading'); http.post.mockReturnValue(Promise.resolve()); - AddSourceLogic.actions.createContentSource(serviceType, successCallback, errorCallback); + AddSourceLogic.actions.createContentSource(successCallback, errorCallback); expect(clearFlashMessages).toHaveBeenCalled(); expect(AddSourceLogic.values.buttonLoading).toEqual(true); @@ -875,7 +796,7 @@ describe('AddSourceLogic', () => { const setButtonNotLoadingSpy = jest.spyOn(AddSourceLogic.actions, 'setButtonNotLoading'); http.post.mockReturnValue(Promise.reject('this is an error')); - AddSourceLogic.actions.createContentSource(serviceType, successCallback, errorCallback); + AddSourceLogic.actions.createContentSource(successCallback, errorCallback); await nextTick(); expect(setButtonNotLoadingSpy).toHaveBeenCalled(); @@ -891,10 +812,10 @@ describe('AddSourceLogic', () => { }); it('getSourceConnectData', () => { - AddSourceLogic.actions.getSourceConnectData('github', jest.fn()); + AddSourceLogic.actions.getSourceConnectData(jest.fn()); expect(http.get).toHaveBeenCalledWith( - '/internal/workplace_search/account/sources/github/prepare', + '/internal/workplace_search/account/sources/box/prepare', { query: {} } ); }); @@ -915,10 +836,10 @@ describe('AddSourceLogic', () => { }); it('createContentSource', () => { - AddSourceLogic.actions.createContentSource('github', jest.fn()); + AddSourceLogic.actions.createContentSource(jest.fn()); expect(http.post).toHaveBeenCalledWith('/internal/workplace_search/account/create_source', { - body: JSON.stringify({ service_type: 'github' }), + body: JSON.stringify({ service_type: 'box' }), }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.ts index 97a58966ad76a..a087f1b78571b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.ts @@ -23,6 +23,7 @@ import { AppLogic } from '../../../../app_logic'; import { SOURCES_PATH, PRIVATE_SOURCES_PATH, getSourcesPath, getAddPath } from '../../../../routes'; import { SourceDataItem } from '../../../../types'; import { PERSONAL_DASHBOARD_SOURCE_ERROR } from '../../constants'; +import { getSourceData } from '../../source_data'; import { SourcesLogic } from '../../sources_logic'; import { @@ -31,20 +32,16 @@ import { } from './add_external_connector/external_connector_logic'; export interface AddSourceProps { - sourceData: SourceDataItem; - connect?: boolean; - configure?: boolean; - reAuthenticate?: boolean; + serviceType: string; + initialStep?: string; } export enum AddSourceSteps { - ConfigIntroStep = 'Config Intro', SaveConfigStep = 'Save Config', ConfigCompletedStep = 'Config Completed', ConnectInstanceStep = 'Connect Instance', ConfigureOauthStep = 'Configure Oauth', ReauthenticateStep = 'Reauthenticate', - ChoiceStep = 'Choice', } export interface OauthParams { @@ -57,10 +54,6 @@ export interface OauthParams { } export interface AddSourceActions { - initializeAddSource: (addSourceProps: AddSourceProps) => { addSourceProps: AddSourceProps }; - setAddSourceProps: ({ addSourceProps }: { addSourceProps: AddSourceProps }) => { - addSourceProps: AddSourceProps; - }; setAddSourceStep(addSourceCurrentStep: AddSourceSteps): AddSourceSteps; setSourceConfigData(sourceConfigData: SourceConfigData): SourceConfigData; setSourceConnectData(sourceConnectData: SourceConnectData): SourceConnectData; @@ -76,10 +69,9 @@ export interface AddSourceActions { setSelectedGithubOrganizations(option: string): string; resetSourceState(): void; createContentSource( - serviceType: string, successCallback: () => void, errorCallback?: () => void - ): { serviceType: string; successCallback(): void; errorCallback?(): void }; + ): { successCallback(): void; errorCallback?(): void }; saveSourceConfig( isUpdating: boolean, successCallback?: () => void @@ -89,24 +81,22 @@ export interface AddSourceActions { params: OauthParams, isOrganization: boolean ): { search: Search; params: OauthParams; isOrganization: boolean }; - getSourceConfigData( - serviceType: string, - addSourceProps?: AddSourceProps - ): { serviceType: string; addSourceProps: AddSourceProps | undefined }; - getSourceConnectData( - serviceType: string, - successCallback: (oauthUrl: string) => void - ): { serviceType: string; successCallback(oauthUrl: string): void }; + getSourceConfigData(): void; + getSourceConnectData(successCallback: (oauthUrl: string) => void): { + successCallback(oauthUrl: string): void; + }; getSourceReConnectData(sourceId: string): { sourceId: string }; getPreContentSourceConfigData(): void; setButtonNotLoading(): void; - setFirstStep(addSourceProps: AddSourceProps): { addSourceProps: AddSourceProps }; + setFirstStep(): void; } export interface SourceConfigData { serviceType: string; + baseServiceType?: string; name: string; configured: boolean; + externalConnectorServiceDescribed?: boolean; categories: string[]; needsPermissions?: boolean; privateSourcesEnabled: boolean; @@ -133,8 +123,7 @@ export interface OrganizationsMap { } export interface AddSourceValues { - addSourceProps: AddSourceProps; - addSourceCurrentStep: AddSourceSteps; + addSourceCurrentStep: AddSourceSteps | null; dataLoading: boolean; sectionLoading: boolean; buttonLoading: boolean; @@ -147,12 +136,12 @@ export interface AddSourceValues { indexPermissionsValue: boolean; sourceConfigData: SourceConfigData; sourceConnectData: SourceConnectData; - currentServiceType: string; githubOrganizations: string[]; selectedGithubOrganizationsMap: OrganizationsMap; selectedGithubOrganizations: string[]; preContentSourceId: string; oauthConfigCompleted: boolean; + sourceData: SourceDataItem | null; } interface PreContentSourceResponse { @@ -161,471 +150,436 @@ interface PreContentSourceResponse { githubOrganizations: string[]; } -export const AddSourceLogic = kea>({ - path: ['enterprise_search', 'workplace_search', 'add_source_logic'], - actions: { - initializeAddSource: (addSourceProps: AddSourceProps) => ({ addSourceProps }), - setAddSourceProps: ({ addSourceProps }: { addSourceProps: AddSourceProps }) => ({ - addSourceProps, - }), - setAddSourceStep: (addSourceCurrentStep: AddSourceSteps) => addSourceCurrentStep, - setSourceConfigData: (sourceConfigData: SourceConfigData) => sourceConfigData, - setSourceConnectData: (sourceConnectData: SourceConnectData) => sourceConnectData, - setClientIdValue: (clientIdValue: string) => clientIdValue, - setClientSecretValue: (clientSecretValue: string) => clientSecretValue, - setBaseUrlValue: (baseUrlValue: string) => baseUrlValue, - setSourceLoginValue: (loginValue: string) => loginValue, - setSourcePasswordValue: (passwordValue: string) => passwordValue, - setSourceSubdomainValue: (subdomainValue: string) => subdomainValue, - setSourceIndexPermissionsValue: (indexPermissionsValue: boolean) => indexPermissionsValue, - setPreContentSourceConfigData: (data: PreContentSourceResponse) => data, - setPreContentSourceId: (preContentSourceId: string) => preContentSourceId, - setSelectedGithubOrganizations: (option: string) => option, - getSourceConfigData: (serviceType: string, addSourceProps?: AddSourceProps) => ({ - serviceType, - addSourceProps, - }), - getSourceConnectData: (serviceType: string, successCallback: (oauthUrl: string) => string) => ({ - serviceType, - successCallback, - }), - getSourceReConnectData: (sourceId: string) => ({ sourceId }), - getPreContentSourceConfigData: () => true, - saveSourceConfig: (isUpdating: boolean, successCallback?: () => void) => ({ - isUpdating, - successCallback, +export const AddSourceLogic = kea>( + { + path: ['enterprise_search', 'workplace_search', 'add_source_logic'], + actions: { + setAddSourceStep: (addSourceCurrentStep: AddSourceSteps) => addSourceCurrentStep, + setSourceConfigData: (sourceConfigData: SourceConfigData) => sourceConfigData, + setSourceConnectData: (sourceConnectData: SourceConnectData) => sourceConnectData, + setClientIdValue: (clientIdValue: string) => clientIdValue, + setClientSecretValue: (clientSecretValue: string) => clientSecretValue, + setBaseUrlValue: (baseUrlValue: string) => baseUrlValue, + setSourceLoginValue: (loginValue: string) => loginValue, + setSourcePasswordValue: (passwordValue: string) => passwordValue, + setSourceSubdomainValue: (subdomainValue: string) => subdomainValue, + setSourceIndexPermissionsValue: (indexPermissionsValue: boolean) => indexPermissionsValue, + setPreContentSourceConfigData: (data: PreContentSourceResponse) => data, + setPreContentSourceId: (preContentSourceId: string) => preContentSourceId, + setSelectedGithubOrganizations: (option: string) => option, + getSourceConfigData: () => true, + getSourceConnectData: (successCallback: (oauthUrl: string) => string) => ({ + successCallback, + }), + getSourceReConnectData: (sourceId: string) => ({ sourceId }), + getPreContentSourceConfigData: () => true, + saveSourceConfig: (isUpdating: boolean, successCallback?: () => void) => ({ + isUpdating, + successCallback, + }), + saveSourceParams: (search: Search, params: OauthParams, isOrganization: boolean) => ({ + search, + params, + isOrganization, + }), + createContentSource: (successCallback: () => void, errorCallback?: () => void) => ({ + successCallback, + errorCallback, + }), + resetSourceState: () => true, + setButtonNotLoading: () => true, + setFirstStep: () => true, + }, + reducers: ({ props }) => ({ + addSourceCurrentStep: [ + null, + { + setAddSourceStep: (_, addSourceCurrentStep) => addSourceCurrentStep, + }, + ], + sourceConfigData: [ + {} as SourceConfigData, + { + setSourceConfigData: (_, sourceConfigData) => sourceConfigData, + }, + ], + sourceConnectData: [ + {} as SourceConnectData, + { + setSourceConnectData: (_, sourceConnectData) => sourceConnectData, + }, + ], + dataLoading: [ + true, + { + setSourceConfigData: () => false, + resetSourceState: () => false, + setPreContentSourceConfigData: () => false, + getSourceConfigData: () => true, + }, + ], + buttonLoading: [ + false, + { + setButtonNotLoading: () => false, + setSourceConnectData: () => false, + setSourceConfigData: () => false, + resetSourceState: () => false, + saveSourceConfig: () => true, + getSourceConnectData: () => true, + createContentSource: () => true, + }, + ], + sectionLoading: [ + true, + { + getPreContentSourceConfigData: () => true, + setPreContentSourceConfigData: () => false, + }, + ], + clientIdValue: [ + '', + { + setClientIdValue: (_, clientIdValue) => clientIdValue, + setSourceConfigData: (_, { configuredFields: { clientId } }) => clientId || '', + resetSourceState: () => '', + }, + ], + clientSecretValue: [ + '', + { + setClientSecretValue: (_, clientSecretValue) => clientSecretValue, + setSourceConfigData: (_, { configuredFields: { clientSecret } }) => clientSecret || '', + resetSourceState: () => '', + }, + ], + baseUrlValue: [ + '', + { + setBaseUrlValue: (_, baseUrlValue) => baseUrlValue, + setSourceConfigData: (_, { configuredFields: { baseUrl } }) => baseUrl || '', + resetSourceState: () => '', + }, + ], + loginValue: [ + '', + { + setSourceLoginValue: (_, loginValue) => loginValue, + resetSourceState: () => '', + }, + ], + passwordValue: [ + '', + { + setSourcePasswordValue: (_, passwordValue) => passwordValue, + resetSourceState: () => '', + }, + ], + subdomainValue: [ + '', + { + setSourceSubdomainValue: (_, subdomainValue) => subdomainValue, + resetSourceState: () => '', + }, + ], + indexPermissionsValue: [ + false, + { + setSourceIndexPermissionsValue: (_, indexPermissionsValue) => indexPermissionsValue, + resetSourceState: () => false, + }, + ], + githubOrganizations: [ + [], + { + setPreContentSourceConfigData: (_, { githubOrganizations }) => githubOrganizations, + resetSourceState: () => [], + }, + ], + selectedGithubOrganizationsMap: [ + {} as OrganizationsMap, + { + setSelectedGithubOrganizations: (state, option) => ({ + ...state, + ...{ [option]: !state[option] }, + }), + resetSourceState: () => ({}), + }, + ], + preContentSourceId: [ + '', + { + setPreContentSourceId: (_, preContentSourceId) => preContentSourceId, + setPreContentSourceConfigData: () => '', + resetSourceState: () => '', + }, + ], + oauthConfigCompleted: [ + false, + { + setPreContentSourceConfigData: () => true, + }, + ], + sourceData: [getSourceData(props.serviceType) || null, {}], }), - saveSourceParams: (search: Search, params: OauthParams, isOrganization: boolean) => ({ - search, - params, - isOrganization, + selectors: ({ selectors }) => ({ + selectedGithubOrganizations: [ + () => [selectors.selectedGithubOrganizationsMap], + (orgsMap) => keys(pickBy(orgsMap)), + ], }), - createContentSource: ( - serviceType: string, - successCallback: () => void, - errorCallback?: () => void - ) => ({ serviceType, successCallback, errorCallback }), - resetSourceState: () => true, - setButtonNotLoading: () => false, - setFirstStep: (addSourceProps) => ({ addSourceProps }), - }, - reducers: { - addSourceProps: [ - {} as AddSourceProps, - { - setAddSourceProps: (_, { addSourceProps }) => addSourceProps, - }, - ], - addSourceCurrentStep: [ - AddSourceSteps.ConfigIntroStep, - { - setAddSourceStep: (_, addSourceCurrentStep) => addSourceCurrentStep, - }, - ], - sourceConfigData: [ - {} as SourceConfigData, - { - setSourceConfigData: (_, sourceConfigData) => sourceConfigData, - }, - ], - sourceConnectData: [ - {} as SourceConnectData, - { - setSourceConnectData: (_, sourceConnectData) => sourceConnectData, - }, - ], - dataLoading: [ - true, - { - setSourceConfigData: () => false, - resetSourceState: () => false, - setPreContentSourceConfigData: () => false, - getSourceConfigData: () => true, - }, - ], - buttonLoading: [ - false, - { - setButtonNotLoading: () => false, - setSourceConnectData: () => false, - setSourceConfigData: () => false, - resetSourceState: () => false, - saveSourceConfig: () => true, - getSourceConnectData: () => true, - createContentSource: () => true, - }, - ], - sectionLoading: [ - true, - { - getPreContentSourceConfigData: () => true, - setPreContentSourceConfigData: () => false, - }, - ], - clientIdValue: [ - '', - { - setClientIdValue: (_, clientIdValue) => clientIdValue, - setSourceConfigData: (_, { configuredFields: { clientId } }) => clientId || '', - resetSourceState: () => '', - }, - ], - clientSecretValue: [ - '', - { - setClientSecretValue: (_, clientSecretValue) => clientSecretValue, - setSourceConfigData: (_, { configuredFields: { clientSecret } }) => clientSecret || '', - resetSourceState: () => '', - }, - ], - baseUrlValue: [ - '', - { - setBaseUrlValue: (_, baseUrlValue) => baseUrlValue, - setSourceConfigData: (_, { configuredFields: { baseUrl } }) => baseUrl || '', - resetSourceState: () => '', - }, - ], - loginValue: [ - '', - { - setSourceLoginValue: (_, loginValue) => loginValue, - resetSourceState: () => '', - }, - ], - passwordValue: [ - '', - { - setSourcePasswordValue: (_, passwordValue) => passwordValue, - resetSourceState: () => '', - }, - ], - subdomainValue: [ - '', - { - setSourceSubdomainValue: (_, subdomainValue) => subdomainValue, - resetSourceState: () => '', - }, - ], - indexPermissionsValue: [ - false, - { - setSourceIndexPermissionsValue: (_, indexPermissionsValue) => indexPermissionsValue, - resetSourceState: () => false, - }, - ], - currentServiceType: [ - '', - { - setPreContentSourceConfigData: (_, { serviceType }) => serviceType, - resetSourceState: () => '', - }, - ], - githubOrganizations: [ - [], - { - setPreContentSourceConfigData: (_, { githubOrganizations }) => githubOrganizations, - resetSourceState: () => [], + listeners: ({ actions, values, props }) => ({ + getSourceConfigData: async () => { + const { serviceType } = props; + // TODO: Once multi-config support for connectors is added, this request url will need to include an ID + const route = `/internal/workplace_search/org/settings/connectors/${serviceType}`; + + try { + const response = await HttpLogic.values.http.get(route); + actions.setSourceConfigData(response); + actions.setFirstStep(); + } catch (e) { + flashAPIErrors(e); + } }, - ], - selectedGithubOrganizationsMap: [ - {} as OrganizationsMap, - { - setSelectedGithubOrganizations: (state, option) => ({ - ...state, - ...{ [option]: !state[option] }, - }), - resetSourceState: () => ({}), + getSourceConnectData: async ({ successCallback }) => { + const { serviceType } = props; + clearFlashMessages(); + const { isOrganization } = AppLogic.values; + const { subdomainValue: subdomain, indexPermissionsValue: indexPermissions } = values; + + const route = isOrganization + ? `/internal/workplace_search/org/sources/${serviceType}/prepare` + : `/internal/workplace_search/account/sources/${serviceType}/prepare`; + + const indexPermissionsQuery = isOrganization + ? { index_permissions: indexPermissions } + : undefined; + + const query = subdomain + ? { + ...indexPermissionsQuery, + subdomain, + } + : { ...indexPermissionsQuery }; + + try { + const response = await HttpLogic.values.http.get(route, { + query, + }); + actions.setSourceConnectData(response); + successCallback(response.oauthUrl); + } catch (e) { + flashAPIErrors(e); + } finally { + actions.setButtonNotLoading(); + } }, - ], - preContentSourceId: [ - '', - { - setPreContentSourceId: (_, preContentSourceId) => preContentSourceId, - setPreContentSourceConfigData: () => '', - resetSourceState: () => '', + getSourceReConnectData: async ({ sourceId }) => { + const { isOrganization } = AppLogic.values; + const route = isOrganization + ? `/internal/workplace_search/org/sources/${sourceId}/reauth_prepare` + : `/internal/workplace_search/account/sources/${sourceId}/reauth_prepare`; + + try { + const response = await HttpLogic.values.http.get(route); + actions.setSourceConnectData(response); + } catch (e) { + flashAPIErrors(e); + } }, - ], - oauthConfigCompleted: [ - false, - { - setPreContentSourceConfigData: () => true, + getPreContentSourceConfigData: async () => { + const { isOrganization } = AppLogic.values; + const { preContentSourceId } = values; + const route = isOrganization + ? `/internal/workplace_search/org/pre_sources/${preContentSourceId}` + : `/internal/workplace_search/account/pre_sources/${preContentSourceId}`; + + try { + const response = await HttpLogic.values.http.get(route); + actions.setPreContentSourceConfigData(response); + } catch (e) { + flashAPIErrors(e); + } }, - ], - }, - selectors: ({ selectors }) => ({ - selectedGithubOrganizations: [ - () => [selectors.selectedGithubOrganizationsMap], - (orgsMap) => keys(pickBy(orgsMap)), - ], - }), - listeners: ({ actions, values }) => ({ - initializeAddSource: ({ addSourceProps }) => { - const { serviceType } = addSourceProps.sourceData; - actions.setAddSourceProps({ addSourceProps }); - actions.getSourceConfigData(serviceType, addSourceProps); - }, - getSourceConfigData: async ({ serviceType, addSourceProps }) => { - const route = `/internal/workplace_search/org/settings/connectors/${serviceType}`; - - try { - const response = await HttpLogic.values.http.get(route); - actions.setSourceConfigData(response); - if (addSourceProps) { - actions.setFirstStep(addSourceProps); + saveSourceConfig: async ({ isUpdating, successCallback }) => { + clearFlashMessages(); + const { + sourceConfigData: { serviceType }, + baseUrlValue, + clientIdValue, + clientSecretValue, + sourceConfigData, + } = values; + + const { externalConnectorUrl, externalConnectorApiKey } = ExternalConnectorLogic.values; + if ( + serviceType === 'external' && + externalConnectorUrl && + !isValidExternalUrl(externalConnectorUrl) + ) { + ExternalConnectorLogic.actions.setUrlValidation(false); + actions.setButtonNotLoading(); + return; } - } catch (e) { - flashAPIErrors(e); - } - }, - getSourceConnectData: async ({ serviceType, successCallback }) => { - clearFlashMessages(); - const { isOrganization } = AppLogic.values; - const { subdomainValue: subdomain, indexPermissionsValue: indexPermissions } = values; - - const route = isOrganization - ? `/internal/workplace_search/org/sources/${serviceType}/prepare` - : `/internal/workplace_search/account/sources/${serviceType}/prepare`; - - const indexPermissionsQuery = isOrganization - ? { index_permissions: indexPermissions } - : undefined; - - const query = subdomain - ? { - ...indexPermissionsQuery, - subdomain, + + const route = isUpdating + ? `/internal/workplace_search/org/settings/connectors/${serviceType}` + : '/internal/workplace_search/org/settings/connectors'; + + const http = isUpdating ? HttpLogic.values.http.put : HttpLogic.values.http.post; + + const params = { + base_url: baseUrlValue || undefined, + client_id: clientIdValue || undefined, + client_secret: clientSecretValue || undefined, + service_type: serviceType, + private_key: sourceConfigData.configuredFields?.privateKey, + public_key: sourceConfigData.configuredFields?.publicKey, + consumer_key: sourceConfigData.configuredFields?.consumerKey, + external_connector_url: (serviceType === 'external' && externalConnectorUrl) || undefined, + external_connector_api_key: + (serviceType === 'external' && externalConnectorApiKey) || undefined, + }; + + try { + const response = await http(route, { + body: JSON.stringify(params), + }); + if (successCallback) successCallback(); + if (isUpdating) { + flashSuccessToast( + i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.flashMessages.contentSourceConfigUpdated', + { + defaultMessage: 'Successfully updated configuration.', + } + ) + ); } - : { ...indexPermissionsQuery }; - - try { - const response = await HttpLogic.values.http.get(route, { - query, - }); - actions.setSourceConnectData(response); - successCallback(response.oauthUrl); - } catch (e) { - flashAPIErrors(e); - } finally { - actions.setButtonNotLoading(); - } - }, - getSourceReConnectData: async ({ sourceId }) => { - const { isOrganization } = AppLogic.values; - const route = isOrganization - ? `/internal/workplace_search/org/sources/${sourceId}/reauth_prepare` - : `/internal/workplace_search/account/sources/${sourceId}/reauth_prepare`; - - try { - const response = await HttpLogic.values.http.get(route); - actions.setSourceConnectData(response); - } catch (e) { - flashAPIErrors(e); - } - }, - getPreContentSourceConfigData: async () => { - const { isOrganization } = AppLogic.values; - const { preContentSourceId } = values; - const route = isOrganization - ? `/internal/workplace_search/org/pre_sources/${preContentSourceId}` - : `/internal/workplace_search/account/pre_sources/${preContentSourceId}`; - - try { - const response = await HttpLogic.values.http.get(route); - actions.setPreContentSourceConfigData(response); - } catch (e) { - flashAPIErrors(e); - } - }, - saveSourceConfig: async ({ isUpdating, successCallback }) => { - clearFlashMessages(); - const { - sourceConfigData: { serviceType }, - baseUrlValue, - clientIdValue, - clientSecretValue, - sourceConfigData, - } = values; - - const { externalConnectorUrl, externalConnectorApiKey } = ExternalConnectorLogic.values; - if ( - serviceType === 'external' && - externalConnectorUrl && - !isValidExternalUrl(externalConnectorUrl) - ) { - ExternalConnectorLogic.actions.setUrlValidation(false); - actions.setButtonNotLoading(); - return; - } - - const route = isUpdating - ? `/internal/workplace_search/org/settings/connectors/${serviceType}` - : '/internal/workplace_search/org/settings/connectors'; - - const http = isUpdating ? HttpLogic.values.http.put : HttpLogic.values.http.post; - - const params = { - base_url: baseUrlValue || undefined, - client_id: clientIdValue || undefined, - client_secret: clientSecretValue || undefined, - service_type: serviceType, - private_key: sourceConfigData.configuredFields?.privateKey, - public_key: sourceConfigData.configuredFields?.publicKey, - consumer_key: sourceConfigData.configuredFields?.consumerKey, - external_connector_url: (serviceType === 'external' && externalConnectorUrl) || undefined, - external_connector_api_key: - (serviceType === 'external' && externalConnectorApiKey) || undefined, - }; - - try { - const response = await http(route, { - body: JSON.stringify(params), - }); - if (successCallback) successCallback(); - if (isUpdating) { - flashSuccessToast( - i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.sources.flashMessages.contentSourceConfigUpdated', - { - defaultMessage: 'Successfully updated configuration.', - } - ) - ); + actions.setSourceConfigData(response); + } catch (e) { + flashAPIErrors(e); + } finally { + actions.setButtonNotLoading(); } - actions.setSourceConfigData(response); - } catch (e) { - flashAPIErrors(e); - } finally { - actions.setButtonNotLoading(); - } - }, - saveSourceParams: async ({ search, params, isOrganization }) => { - const { http } = HttpLogic.values; - const { navigateToUrl } = KibanaLogic.values; - const { setAddedSource } = SourcesLogic.actions; - const query = { ...params }; - const route = '/internal/workplace_search/sources/create'; - - /** + }, + saveSourceParams: async ({ search, params, isOrganization }) => { + const { http } = HttpLogic.values; + const { navigateToUrl } = KibanaLogic.values; + const { setAddedSource } = SourcesLogic.actions; + const query = { ...params }; + const route = '/internal/workplace_search/sources/create'; + + /** There is an extreme edge case where the user is trying to connect Github as source from ent-search, after configuring it in Kibana. When this happens, Github redirects the user from ent-search to Kibana with special error properties in the query params. In this case we need to redirect the user to the app home page and display the error message, and not persist the other query params to the server. */ - if (params.error_description) { - navigateToUrl(isOrganization ? '/' : PRIVATE_SOURCES_PATH); - setErrorMessage( - isOrganization - ? params.error_description - : PERSONAL_DASHBOARD_SOURCE_ERROR(params.error_description) - ); - return; - } - - try { - const response = await http.get<{ - serviceName: string; - indexPermissions: boolean; - serviceType: string; - preContentSourceId: string; - hasConfigureStep: boolean; - }>(route, { query }); - const { serviceName, indexPermissions, serviceType, preContentSourceId, hasConfigureStep } = - response; - - // GitHub requires an intermediate configuration step, where we collect the repos to index. - if (hasConfigureStep && !values.oauthConfigCompleted) { - actions.setPreContentSourceId(preContentSourceId); - navigateToUrl( - getSourcesPath(`${getAddPath('github')}/configure${search}`, isOrganization) + if (params.error_description) { + navigateToUrl(isOrganization ? '/' : PRIVATE_SOURCES_PATH); + setErrorMessage( + isOrganization + ? params.error_description + : PERSONAL_DASHBOARD_SOURCE_ERROR(params.error_description) ); - } else { - setAddedSource(serviceName, indexPermissions, serviceType); + return; + } + + try { + const response = await http.get<{ + serviceName: string; + indexPermissions: boolean; + serviceType: string; + preContentSourceId: string; + hasConfigureStep: boolean; + }>(route, { query }); + const { + serviceName, + indexPermissions, + serviceType, + preContentSourceId, + hasConfigureStep, + } = response; + + // GitHub requires an intermediate configuration step, where we collect the repos to index. + if (hasConfigureStep && !values.oauthConfigCompleted) { + actions.setPreContentSourceId(preContentSourceId); + navigateToUrl( + getSourcesPath(`${getAddPath('github')}/configure${search}`, isOrganization) + ); + } else { + setAddedSource(serviceName, indexPermissions, serviceType); + navigateToUrl(getSourcesPath(SOURCES_PATH, isOrganization)); + } + } catch (e) { navigateToUrl(getSourcesPath(SOURCES_PATH, isOrganization)); + flashAPIErrors(e); } - } catch (e) { - navigateToUrl(getSourcesPath(SOURCES_PATH, isOrganization)); - flashAPIErrors(e); - } - }, - setFirstStep: ({ addSourceProps }) => { - const firstStep = getFirstStep( - addSourceProps, - values.sourceConfigData, - SourcesLogic.values.externalConfigured - ); - actions.setAddSourceStep(firstStep); - }, - createContentSource: async ({ serviceType, successCallback, errorCallback }) => { - clearFlashMessages(); - const { isOrganization } = AppLogic.values; - const route = isOrganization - ? '/internal/workplace_search/org/create_source' - : '/internal/workplace_search/account/create_source'; - - const { - selectedGithubOrganizations: githubOrganizations, - loginValue, - passwordValue, - indexPermissionsValue, - } = values; - - const params = { - service_type: serviceType, - login: loginValue || undefined, - password: passwordValue || undefined, - organizations: githubOrganizations.length > 0 ? githubOrganizations : undefined, - index_permissions: indexPermissionsValue || undefined, - } as { - [key: string]: string | string[] | undefined; - }; - - // Remove undefined values from params - Object.keys(params).forEach((key) => params[key] === undefined && delete params[key]); - - try { - await HttpLogic.values.http.post(route, { - body: JSON.stringify({ ...params }), - }); - successCallback(); - } catch (e) { - flashAPIErrors(e); - if (errorCallback) errorCallback(); - } finally { - actions.setButtonNotLoading(); - } - }, - }), -}); - -const getFirstStep = ( - props: AddSourceProps, - sourceConfigData: SourceConfigData, - externalConfigured: boolean -): AddSourceSteps => { + }, + setFirstStep: () => { + const firstStep = getFirstStep(values.sourceConfigData, props.initialStep); + actions.setAddSourceStep(firstStep); + }, + createContentSource: async ({ successCallback, errorCallback }) => { + const { serviceType } = props; + clearFlashMessages(); + const { isOrganization } = AppLogic.values; + const route = isOrganization + ? '/internal/workplace_search/org/create_source' + : '/internal/workplace_search/account/create_source'; + + const { + selectedGithubOrganizations: githubOrganizations, + loginValue, + passwordValue, + indexPermissionsValue, + } = values; + + const params = { + service_type: serviceType, + login: loginValue || undefined, + password: passwordValue || undefined, + organizations: githubOrganizations.length > 0 ? githubOrganizations : undefined, + index_permissions: indexPermissionsValue || undefined, + } as { + [key: string]: string | string[] | undefined; + }; + + // Remove undefined values from params + Object.keys(params).forEach((key) => params[key] === undefined && delete params[key]); + + try { + await HttpLogic.values.http.post(route, { + body: JSON.stringify({ ...params }), + }); + successCallback(); + } catch (e) { + flashAPIErrors(e); + if (errorCallback) errorCallback(); + } finally { + actions.setButtonNotLoading(); + } + }, + }), + } +); + +const getFirstStep = (sourceConfigData: SourceConfigData, initialStep?: string): AddSourceSteps => { const { - connect, - configure, - reAuthenticate, - sourceData: { serviceType, externalConnectorAvailable }, - } = props; - // We can land on this page from a choice page for multiple types of connectors - // If that's the case we want to skip the intro and configuration, if the external & internal connector have already been configured - const { configuredFields, configured } = sourceConfigData; - if (externalConnectorAvailable && configured && externalConfigured) + serviceType, + configured, + configuredFields: { clientId, clientSecret }, + } = sourceConfigData; + if (initialStep === 'connect') return AddSourceSteps.ConnectInstanceStep; + if (initialStep === 'configure') return AddSourceSteps.ConfigureOauthStep; + if (initialStep === 'reauthenticate') return AddSourceSteps.ReauthenticateStep; + if (serviceType !== 'external' && configured) return AddSourceSteps.ConnectInstanceStep; + + // TODO remove this once external/BYO connectors track `configured` properly + if (serviceType === 'external' && clientId && clientSecret) return AddSourceSteps.ConnectInstanceStep; - if (externalConnectorAvailable && !configured && externalConfigured) - return AddSourceSteps.SaveConfigStep; - if (serviceType === 'external') { - // external connectors can be partially configured, so we need to check which fields are filled - if (configuredFields?.clientId && configuredFields?.clientSecret) { - return AddSourceSteps.ConnectInstanceStep; - } - // Unconfigured external connectors have already shown the intro step before the choice page, so we don't want to show it again - return AddSourceSteps.SaveConfigStep; - } - if (connect) return AddSourceSteps.ConnectInstanceStep; - if (configure) return AddSourceSteps.ConfigureOauthStep; - if (reAuthenticate) return AddSourceSteps.ReauthenticateStep; - return AddSourceSteps.ConfigIntroStep; + + return AddSourceSteps.SaveConfigStep; }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/available_sources_list.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/available_sources_list.test.tsx index 06815ab3330f0..a44b5f54852c9 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/available_sources_list.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/available_sources_list.test.tsx @@ -26,7 +26,7 @@ describe('AvailableSourcesList', () => { const wrapper = shallow(); expect(wrapper.find(EuiTitle)).toHaveLength(1); - expect(wrapper.find('[data-test-subj="AvailableSourceListItem"]')).toHaveLength(24); + expect(wrapper.find('[data-test-subj="AvailableSourceListItem"]')).toHaveLength(25); expect(wrapper.find('[data-test-subj="CustomAPISourceLink"]')).toHaveLength(1); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/available_sources_list.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/available_sources_list.tsx index 7dc9ad9ca0f60..9a2787d779070 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/available_sources_list.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/available_sources_list.tsx @@ -18,6 +18,7 @@ import { EuiTitle, EuiText, EuiToolTip, + EuiButtonEmpty, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; @@ -43,8 +44,13 @@ interface AvailableSourcesListProps { export const AvailableSourcesList: React.FC = ({ sources }) => { const { hasPlatinumLicense } = useValues(LicensingLogic); - const getSourceCard = ({ name, serviceType, accountContextOnly }: SourceDataItem) => { - const addPath = getAddPath(serviceType); + const getSourceCard = ({ + accountContextOnly, + baseServiceType, + name, + serviceType, + }: SourceDataItem) => { + const addPath = getAddPath(serviceType, baseServiceType); const disabled = !hasPlatinumLicense && accountContextOnly; const connectButton = () => { @@ -61,15 +67,30 @@ export const AvailableSourcesList: React.FC = ({ sour } )} > - - Connect - + + {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSource.availableSourceList.connectButtonLabel', + { + defaultMessage: 'Connect', + } + )} + ); } else { return ( - - Connect + + {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSource.availableSourceList.connectButtonLabel', + { + defaultMessage: 'Connect', + } + )} ); } @@ -79,7 +100,7 @@ export const AvailableSourcesList: React.FC = ({ sour <> - + {name} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_choice.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_choice.test.tsx index 94821c0561cf4..0ed33a01d606f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_choice.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_choice.test.tsx @@ -5,20 +5,19 @@ * 2.0. */ -import { mockKibanaValues, setMockValues } from '../../../../../__mocks__/kea_logic'; +import { setMockActions, setMockValues } from '../../../../../__mocks__/kea_logic'; import React from 'react'; import { mount } from 'enzyme'; -import { EuiButton } from '@elastic/eui'; +import { EuiButtonTo } from '../../../../../shared/react_router_helpers'; import { staticSourceData } from '../../source_data'; import { ConfigurationChoice } from './configuration_choice'; describe('ConfigurationChoice', () => { - const { navigateToUrl } = mockKibanaValues; const props = { sourceData: staticSourceData[0], }; @@ -28,31 +27,23 @@ describe('ConfigurationChoice', () => { categories: [], }, }; + const mockActions = { + initializeSources: jest.fn(), + resetSourcesState: jest.fn(), + }; beforeEach(() => { - setMockValues(mockValues); jest.clearAllMocks(); + setMockValues(mockValues); + setMockActions(mockActions); }); it('renders internal connector if available', () => { const wrapper = mount(); - expect(wrapper.find('EuiCard')).toHaveLength(1); - expect(wrapper.find(EuiButton)).toHaveLength(1); - }); - it('should navigate to internal connector on internal connector click', () => { - const wrapper = mount(); - const button = wrapper.find(EuiButton); - button.simulate('click'); - expect(navigateToUrl).toHaveBeenCalledWith('/sources/add/box/internal/'); - }); - it('should call prop function when provided on internal connector click', () => { - const advanceSpy = jest.fn(); - const wrapper = mount(); - const button = wrapper.find(EuiButton); - button.simulate('click'); - expect(navigateToUrl).not.toHaveBeenCalled(); - expect(advanceSpy).toHaveBeenCalled(); + const internalConnectorCard = wrapper.find('[data-test-subj="InternalConnectorCard"]'); + expect(internalConnectorCard).toHaveLength(1); + expect(internalConnectorCard.find(EuiButtonTo).prop('to')).toEqual('/sources/add/box/'); }); it('renders external connector if available', () => { @@ -62,32 +53,36 @@ describe('ConfigurationChoice', () => { ...props, sourceData: { ...props.sourceData, - internalConnectorAvailable: false, - externalConnectorAvailable: true, + serviceType: 'share_point', }, }} /> ); - expect(wrapper.find('EuiCard')).toHaveLength(1); - expect(wrapper.find(EuiButton)).toHaveLength(1); + const externalConnectorCard = wrapper.find('[data-test-subj="ExternalConnectorCard"]'); + expect(externalConnectorCard).toHaveLength(1); + expect(externalConnectorCard.find(EuiButtonTo).prop('to')).toEqual( + '/sources/add/share_point/external/connector_registration' + ); }); - it('should navigate to external connector on external connector click', () => { + + it('renders disabled message if external connector is available but user has already configured', () => { + setMockValues({ ...mockValues, externalConfigured: true }); + const wrapper = mount( ); - const button = wrapper.find(EuiButton); - button.simulate('click'); - expect(navigateToUrl).toHaveBeenCalledWith('/sources/add/box/external/'); + + const externalConnectorCard = wrapper.find('[data-test-subj="ExternalConnectorCard"]'); + expect(externalConnectorCard.prop('disabledMessage')).toBeDefined(); }); it('renders custom connector if available', () => { @@ -97,33 +92,16 @@ describe('ConfigurationChoice', () => { ...props, sourceData: { ...props.sourceData, - internalConnectorAvailable: false, - externalConnectorAvailable: false, - customConnectorAvailable: true, + serviceType: 'share_point_server', }, }} /> ); - expect(wrapper.find('EuiCard')).toHaveLength(1); - expect(wrapper.find(EuiButton)).toHaveLength(1); - }); - it('should navigate to custom connector on custom connector click', () => { - const wrapper = mount( - + const customConnectorCard = wrapper.find('[data-test-subj="CustomConnectorCard"]'); + expect(customConnectorCard).toHaveLength(1); + expect(customConnectorCard.find(EuiButtonTo).prop('to')).toEqual( + '/sources/add/share_point_server/custom' ); - const button = wrapper.find(EuiButton); - button.simulate('click'); - expect(navigateToUrl).toHaveBeenCalledWith('/sources/add/box/custom/'); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_choice.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_choice.tsx index 8d8311d2a0a6f..7d5721d8547d2 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_choice.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_choice.tsx @@ -5,92 +5,85 @@ * 2.0. */ -import React from 'react'; +import React, { useEffect } from 'react'; -import { useValues } from 'kea'; +import { useActions, useValues } from 'kea'; + +import { EuiCard, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; -import { EuiButton, EuiCard, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { KibanaLogic } from '../../../../../shared/kibana'; +import { EuiButtonTo } from '../../../../../shared/react_router_helpers'; import { AppLogic } from '../../../../app_logic'; import { getAddPath, getSourcesPath } from '../../../../routes'; import { SourceDataItem } from '../../../../types'; -import { AddSourceHeader } from './add_source_header'; -import { AddSourceLogic } from './add_source_logic'; +import { hasCustomConnectorOption, hasExternalConnectorOption } from '../../source_data'; -interface ConfigurationChoiceProps { - sourceData: SourceDataItem; - goToInternalStep?: () => void; -} +import { SourcesLogic } from '../../sources_logic'; + +import { AddSourceHeader } from './add_source_header'; interface CardProps { title: string; description: string; buttonText: string; - onClick: () => void; + to: string; badgeLabel?: string; + disabledMessage?: string; +} + +const ConnectorCard: React.FC = ({ + title, + description, + buttonText, + to, + badgeLabel, + disabledMessage, +}: CardProps) => ( + + + {buttonText} + + } + /> + +); + +interface ConfigurationChoiceProps { + sourceData: SourceDataItem; } export const ConfigurationChoice: React.FC = ({ - sourceData: { - name, - serviceType, - externalConnectorAvailable, - internalConnectorAvailable, - customConnectorAvailable, - }, - goToInternalStep, + sourceData: { name, categories = [], serviceType }, }) => { + const externalConnectorAvailable = hasExternalConnectorOption(serviceType); + const customConnectorAvailable = hasCustomConnectorOption(serviceType); + const { isOrganization } = useValues(AppLogic); - const { sourceConfigData } = useValues(AddSourceLogic); - const { categories } = sourceConfigData; - const goToInternal = goToInternalStep - ? goToInternalStep - : () => - KibanaLogic.values.navigateToUrl( - `${getSourcesPath( - `${getSourcesPath(getAddPath(serviceType), isOrganization)}/internal`, - isOrganization - )}/` - ); - const goToExternal = () => - KibanaLogic.values.navigateToUrl( - `${getSourcesPath( - `${getSourcesPath(getAddPath(serviceType), isOrganization)}/external`, - isOrganization - )}/` - ); - const goToCustom = () => - KibanaLogic.values.navigateToUrl( - `${getSourcesPath( - `${getSourcesPath(getAddPath(serviceType), isOrganization)}/custom`, - isOrganization - )}/` - ); - - const ConnectorCard: React.FC = ({ - title, - description, - buttonText, - onClick, - badgeLabel, - }: CardProps) => ( - - - {buttonText} - - } - /> - - ); + + const { initializeSources, resetSourcesState } = useActions(SourcesLogic); + + const { externalConfigured } = useValues(SourcesLogic); + + useEffect(() => { + initializeSources(); + return resetSourcesState; + }, []); + + const internalTo = `${getSourcesPath(getAddPath(serviceType), isOrganization)}/`; + const externalTo = `${getSourcesPath( + getAddPath('external', serviceType), + isOrganization + )}/connector_registration`; + const customTo = `${getSourcesPath(getAddPath('custom', serviceType), isOrganization)}`; const internalConnectorProps: CardProps = { title: i18n.translate( @@ -118,7 +111,7 @@ export const ConfigurationChoice: React.FC = ({ defaultMessage: 'Recommended', } ), - onClick: goToInternal, + to: internalTo, }; const externalConnectorProps: CardProps = { @@ -141,7 +134,7 @@ export const ConfigurationChoice: React.FC = ({ defaultMessage: 'Instructions', } ), - onClick: goToExternal, + to: externalTo, badgeLabel: i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.external.betaLabel', { @@ -169,7 +162,7 @@ export const ConfigurationChoice: React.FC = ({ defaultMessage: 'Instructions', } ), - onClick: goToCustom, + to: customTo, }; return ( @@ -177,9 +170,26 @@ export const ConfigurationChoice: React.FC = ({ - {internalConnectorAvailable && } - {externalConnectorAvailable && } - {customConnectorAvailable && } + + {externalConnectorAvailable && ( + + )} + {customConnectorAvailable && ( + + )} ); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_intro.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_intro.test.tsx index b3ce53a0321dc..0f1beff70735c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_intro.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_intro.test.tsx @@ -14,11 +14,10 @@ import { EuiText, EuiTitle } from '@elastic/eui'; import { ConfigurationIntro } from './configuration_intro'; describe('ConfigurationIntro', () => { - const advanceStep = jest.fn(); const props = { header:

Header

, name: 'foo', - advanceStep, + advanceStepTo: '', }; it('renderscontext', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_intro.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_intro.tsx index 5c52537d4a738..e5da9f6e00316 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_intro.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_intro.tsx @@ -9,7 +9,6 @@ import React from 'react'; import { EuiBadge, - EuiButton, EuiFlexGroup, EuiFlexItem, EuiFormRow, @@ -18,9 +17,12 @@ import { EuiText, EuiTitle, } from '@elastic/eui'; + import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; +import { EuiButtonTo } from '../../../../../shared/react_router_helpers'; + import connectionIllustration from '../../../../assets/connection_illustration.svg'; import { @@ -37,12 +39,12 @@ import { interface ConfigurationIntroProps { header: React.ReactNode; name: string; - advanceStep(): void; + advanceStepTo: string; } export const ConfigurationIntro: React.FC = ({ name, - advanceStep, + advanceStepTo, header, }) => ( <> @@ -144,11 +146,11 @@ export const ConfigurationIntro: React.FC = ({ - {i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.contentSource.configIntro.configure.button', @@ -157,7 +159,7 @@ export const ConfigurationIntro: React.FC = ({ values: { name }, } )} - + diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configure_oauth.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configure_oauth.test.tsx index 332456cae99ad..c776723377f44 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configure_oauth.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configure_oauth.test.tsx @@ -22,7 +22,7 @@ describe('ConfigureOauth', () => { const onFormCreated = jest.fn(); const getPreContentSourceConfigData = jest.fn(); const setSelectedGithubOrganizations = jest.fn(); - const createContentSource = jest.fn((_, formSubmitSuccess, handleFormSubmitError) => { + const createContentSource = jest.fn((formSubmitSuccess, handleFormSubmitError) => { formSubmitSuccess(); handleFormSubmitError(); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configure_oauth.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configure_oauth.tsx index ce5a92a19e387..af50e8267da2f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configure_oauth.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configure_oauth.tsx @@ -35,12 +35,8 @@ export const ConfigureOauth: React.FC = ({ name, onFormCrea const { getPreContentSourceConfigData, setSelectedGithubOrganizations, createContentSource } = useActions(AddSourceLogic); - const { - currentServiceType, - githubOrganizations, - selectedGithubOrganizationsMap, - sectionLoading, - } = useValues(AddSourceLogic); + const { githubOrganizations, selectedGithubOrganizationsMap, sectionLoading } = + useValues(AddSourceLogic); const checkboxOptions = githubOrganizations.map((item) => ({ id: item, label: item })); @@ -54,7 +50,7 @@ export const ConfigureOauth: React.FC = ({ name, onFormCrea const handleFormSubmit = (e: FormEvent) => { setFormLoading(true); e.preventDefault(); - createContentSource(currentServiceType, formSubmitSuccess, handleFormSubmitError); + createContentSource(formSubmitSuccess, handleFormSubmitError); }; const configfieldsForm = ( diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configured_sources_list.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configured_sources_list.test.tsx index 5b23368289f1a..998a4c1d53b8a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configured_sources_list.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configured_sources_list.test.tsx @@ -11,6 +11,8 @@ import React from 'react'; import { shallow } from 'enzyme'; +import { EuiButtonEmpty } from '@elastic/eui'; + import { EuiButtonEmptyTo } from '../../../../../shared/react_router_helpers'; import { ConfiguredSourcesList } from './configured_sources_list'; @@ -24,47 +26,38 @@ describe('ConfiguredSourcesList', () => { it('renders', () => { const wrapper = shallow(); - expect(wrapper.find('[data-test-subj="UnConnectedTooltip"]')).toHaveLength(20); + expect(wrapper.find('[data-test-subj="UnConnectedTooltip"]')).toHaveLength(21); expect(wrapper.find('[data-test-subj="AccountOnlyTooltip"]')).toHaveLength(2); - expect(wrapper.find('[data-test-subj="ConfiguredSourcesListItem"]')).toHaveLength(23); - }); - - it('does show connect button for a connected external source', () => { - const wrapper = shallow( - - ); - expect(wrapper.find(EuiButtonEmptyTo)).toHaveLength(1); + expect(wrapper.find('[data-test-subj="ConfiguredSourcesListItem"]')).toHaveLength(24); }); - it('does show connect button for an unconnected external source', () => { + it('shows connect button for an source with multiple connector options that routes to choice page', () => { const wrapper = shallow( ); const button = wrapper.find(EuiButtonEmptyTo); expect(button).toHaveLength(1); - expect(button.prop('to')).toEqual('/sources/add/external/connect'); + expect(button.prop('to')).toEqual('/sources/add/share_point/choice'); }); - it('connect button for an unconnected source with multiple connector options routes to choice page', () => { + it('shows connect button for a source without multiple connector options that routes to add page', () => { const wrapper = shallow( { ); const button = wrapper.find(EuiButtonEmptyTo); expect(button).toHaveLength(1); - expect(button.prop('to')).toEqual('/sources/add/share_point/'); + expect(button.prop('to')).toEqual('/sources/add/slack/'); }); - it('connect button for a source with multiple connector options routes to connect page for private sources', () => { + it('disabled when in organization mode and connector is account context only', () => { const wrapper = shallow( ); - const button = wrapper.find(EuiButtonEmptyTo); + const button = wrapper.find(EuiButtonEmpty); expect(button).toHaveLength(1); - expect(button.prop('to')).toEqual('/p/sources/add/share_point/connect'); + expect(button.prop('isDisabled')).toBe(true); }); it('handles empty state', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configured_sources_list.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configured_sources_list.tsx index bbec096ae07d8..820df302725b7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configured_sources_list.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configured_sources_list.tsx @@ -27,7 +27,8 @@ import { EuiButtonEmptyTo } from '../../../../../shared/react_router_helpers'; import { SourceIcon } from '../../../../components/shared/source_icon'; import { getAddPath, getSourcesPath } from '../../../../routes'; import { SourceDataItem } from '../../../../types'; -import { hasMultipleConnectorOptions } from '../../../../utils'; + +import { hasMultipleConnectorOptions } from '../../source_data'; import { CONFIGURED_SOURCES_LIST_UNCONNECTED_TOOLTIP, @@ -72,7 +73,8 @@ export const ConfiguredSourcesList: React.FC = ({ const visibleSources = ( {sources.map((sourceData, i) => { - const { connected, accountContextOnly, name, serviceType, isBeta } = sourceData; + const { connected, accountContextOnly, name, serviceType, isBeta, baseServiceType } = + sourceData; return ( = ({ responsive={false} > - + @@ -128,7 +134,7 @@ export const ConfiguredSourcesList: React.FC = ({ {!connected diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/connect_instance.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/connect_instance.test.tsx index 3e850277c0b72..992bb561796fe 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/connect_instance.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/connect_instance.test.tsx @@ -33,10 +33,10 @@ describe('ConnectInstance', () => { const setSourcePasswordValue = jest.fn(); const setSourceSubdomainValue = jest.fn(); const setSourceIndexPermissionsValue = jest.fn(); - const getSourceConnectData = jest.fn((_, redirectOauth) => { + const getSourceConnectData = jest.fn((redirectOauth) => { redirectOauth(); }); - const createContentSource = jest.fn((_, redirectFormCreated) => { + const createContentSource = jest.fn((redirectFormCreated) => { redirectFormCreated(); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/connect_instance.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/connect_instance.tsx index 352addd8176d8..0a4c1a9692e63 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/connect_instance.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/connect_instance.tsx @@ -46,7 +46,6 @@ export const ConnectInstance: React.FC = ({ features, objTypes, name, - serviceType, needsPermissions, onFormCreated, header, @@ -74,8 +73,8 @@ export const ConnectInstance: React.FC = ({ const redirectOauth = (oauthUrl: string) => window.location.replace(oauthUrl); const redirectFormCreated = () => onFormCreated(name); - const onOauthFormSubmit = () => getSourceConnectData(serviceType, redirectOauth); - const onCredentialsFormSubmit = () => createContentSource(serviceType, redirectFormCreated); + const onOauthFormSubmit = () => getSourceConnectData(redirectOauth); + const onCredentialsFormSubmit = () => createContentSource(redirectFormCreated); const handleFormSubmit = (e: FormEvent) => { e.preventDefault(); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/source_features.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/source_features.tsx index edfb2897fce15..7a80c9d6980b5 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/source_features.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/source_features.tsx @@ -39,7 +39,7 @@ import { SOURCE_FEATURES_GLOBAL_ACCESS_PERMISSIONS_FEATURE_DESCRIPTION, } from './constants'; -interface ConnectInstanceProps { +interface SourceFeatureProps { features?: Features; objTypes?: string[]; name: string; @@ -47,7 +47,7 @@ interface ConnectInstanceProps { type IncludedFeatureIds = Exclude; -export const SourceFeatures: React.FC = ({ features, objTypes, name }) => { +export const SourceFeatures: React.FC = ({ features, objTypes, name }) => { const { hasPlatinumLicense } = useValues(LicensingLogic); const { isOrganization } = useValues(AppLogic); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/custom_source_deployment.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/custom_source_deployment.test.tsx index afacfd0ccbbf9..017a9eb5b5dd0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/custom_source_deployment.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/custom_source_deployment.test.tsx @@ -24,14 +24,6 @@ const customSource = { name: 'name', }; -const preconfiguredSourceData = { - ...staticCustomSourceData, - serviceType: 'sharepoint-server', - configuration: { - ...staticCustomSourceData.configuration, - githubRepository: 'elastic/sharepoint-server-connector', - }, -}; const mockValues = { sourceData: staticCustomSourceData, }; @@ -44,9 +36,7 @@ describe('CustomSourceDeployment', () => { jest.clearAllMocks(); setMockValues(mockValues); - wrapper = shallow( - - ); + wrapper = shallow(); }); it('contains a source identifier', () => { @@ -69,7 +59,7 @@ describe('CustomSourceDeployment', () => { }); wrapper = shallow( - + ); }); @@ -86,9 +76,7 @@ describe('CustomSourceDeployment', () => { jest.clearAllMocks(); setMockValues(mockValues); - const wrapper = shallow( - - ); + const wrapper = shallow(); expect(wrapper.find(EuiPanel).prop('paddingSize')).toEqual('m'); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/custom_source_deployment.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/custom_source_deployment.tsx index 7d34783e998a7..8910a8acd0c5a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/custom_source_deployment.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/custom_source_deployment.tsx @@ -14,17 +14,30 @@ import { EuiLinkTo } from '../../../../shared/react_router_helpers'; import { API_KEY_LABEL } from '../../../constants'; import { API_KEYS_PATH } from '../../../routes'; -import { ContentSource, CustomSource, SourceDataItem } from '../../../types'; +import { ContentSource, CustomSource } from '../../../types'; + +import { getSourceData } from '../source_data'; import { SourceIdentifier } from './source_identifier'; interface Props { source: ContentSource | CustomSource; - sourceData: SourceDataItem; + baseServiceType?: string; small?: boolean; } -export const CustomSourceDeployment: React.FC = ({ source, sourceData, small = false }) => { +export const CustomSourceDeployment: React.FC = ({ + source, + baseServiceType, + small = false, +}) => { const { name, id } = source; + + const sourceData = getSourceData('custom', baseServiceType); + + if (!sourceData) { + return null; + } + const { configuration: { documentationUrl, githubRepository }, } = sourceData; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.test.tsx index 9af4eae693d7c..ae6e516ef7d4a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.test.tsx @@ -16,8 +16,6 @@ import { EuiCallOut, EuiConfirmModal, EuiEmptyPrompt, EuiTable } from '@elastic/ import { ComponentLoader } from '../../../components/shared/component_loader'; -import * as SourceData from '../source_data'; - import { CustomSourceDeployment } from './custom_source_deployment'; import { Overview } from './overview'; @@ -144,33 +142,6 @@ describe('Overview', () => { expect(initializeSourceSynchronization).toHaveBeenCalled(); }); - it('uses a base service type if one is provided', () => { - jest.spyOn(SourceData, 'getSourceData'); - setMockValues({ - ...mockValues, - contentSource: { - ...fullContentSources[0], - baseServiceType: 'share_point_server', - }, - }); - - shallow(); - - expect(SourceData.getSourceData).toHaveBeenCalledWith('share_point_server'); - }); - - it('defaults to the regular service tye', () => { - jest.spyOn(SourceData, 'getSourceData'); - setMockValues({ - ...mockValues, - contentSource: fullContentSources[0], - }); - - shallow(); - - expect(SourceData.getSourceData).toHaveBeenCalledWith('custom'); - }); - describe('custom sources', () => { it('includes deployment instructions', () => { setMockValues({ diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx index 698dc7a60eea4..ac31ee8314fc8 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx @@ -81,7 +81,6 @@ import { SOURCE_SYNC_CONFIRM_TITLE, SOURCE_SYNC_CONFIRM_MESSAGE, } from '../constants'; -import { getSourceData } from '../source_data'; import { SourceLogic } from '../source_logic'; import { CustomSourceDeployment } from './custom_source_deployment'; @@ -106,12 +105,10 @@ export const Overview: React.FC = () => { isFederatedSource, isIndexedSource, name, + serviceType, + baseServiceType, } = contentSource; - const serviceType = contentSource.baseServiceType || contentSource.serviceType; - - const sourceData = getSourceData(serviceType); - const [isSyncing, setIsSyncing] = useState(false); const [isModalVisible, setIsModalVisible] = useState(false); const closeModal = () => setIsModalVisible(false); @@ -431,7 +428,7 @@ export const Overview: React.FC = () => { - + ); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx index d660b4499e210..f872648fc101d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx @@ -88,7 +88,7 @@ export const SourceSettings: React.FC = () => { const { isOrganization } = useValues(AppLogic); useEffect(() => { - getSourceConfigData(serviceType); + getSourceConfigData(); }, []); const isGithubApp = diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx index 282de2590df7f..0088e80066a02 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx @@ -17,9 +17,10 @@ import { } from '../../constants'; import { FeatureIds, SourceDataItem } from '../../types'; -export const staticExternalSourceData: SourceDataItem = { +// TODO remove Sharepoint-specific content after BYO connector support +export const staticGenericExternalSourceData: SourceDataItem = { name: SOURCE_NAMES.SHAREPOINT, - iconName: SOURCE_NAMES.SHAREPOINT, + categories: [], serviceType: 'external', configuration: { isPublicKey: false, @@ -40,16 +41,12 @@ export const staticExternalSourceData: SourceDataItem = { platinumPrivateContext: [FeatureIds.Private, FeatureIds.SyncFrequency, FeatureIds.SyncedItems], }, accountContextOnly: false, - internalConnectorAvailable: true, - externalConnectorAvailable: false, - customConnectorAvailable: false, isBeta: true, }; export const staticSourceData: SourceDataItem[] = [ { name: SOURCE_NAMES.BOX, - iconName: SOURCE_NAMES.BOX, serviceType: 'box', configuration: { isPublicKey: false, @@ -74,11 +71,9 @@ export const staticSourceData: SourceDataItem[] = [ ], }, accountContextOnly: false, - internalConnectorAvailable: true, }, { name: SOURCE_NAMES.CONFLUENCE, - iconName: SOURCE_NAMES.CONFLUENCE, serviceType: 'confluence_cloud', configuration: { isPublicKey: false, @@ -108,11 +103,9 @@ export const staticSourceData: SourceDataItem[] = [ ], }, accountContextOnly: false, - internalConnectorAvailable: true, }, { name: SOURCE_NAMES.CONFLUENCE_SERVER, - iconName: SOURCE_NAMES.CONFLUENCE_SERVER, serviceType: 'confluence_server', configuration: { isPublicKey: true, @@ -140,11 +133,9 @@ export const staticSourceData: SourceDataItem[] = [ ], }, accountContextOnly: false, - internalConnectorAvailable: true, }, { name: SOURCE_NAMES.DROPBOX, - iconName: SOURCE_NAMES.DROPBOX, serviceType: 'dropbox', configuration: { isPublicKey: false, @@ -169,11 +160,9 @@ export const staticSourceData: SourceDataItem[] = [ ], }, accountContextOnly: false, - internalConnectorAvailable: true, }, { name: SOURCE_NAMES.GITHUB, - iconName: SOURCE_NAMES.GITHUB, serviceType: 'github', configuration: { isPublicKey: false, @@ -205,11 +194,9 @@ export const staticSourceData: SourceDataItem[] = [ ], }, accountContextOnly: false, - internalConnectorAvailable: true, }, { name: SOURCE_NAMES.GITHUB_ENTERPRISE, - iconName: SOURCE_NAMES.GITHUB_ENTERPRISE, serviceType: 'github_enterprise_server', configuration: { isPublicKey: false, @@ -247,11 +234,9 @@ export const staticSourceData: SourceDataItem[] = [ ], }, accountContextOnly: false, - internalConnectorAvailable: true, }, { name: SOURCE_NAMES.GMAIL, - iconName: SOURCE_NAMES.GMAIL, serviceType: 'gmail', configuration: { isPublicKey: false, @@ -265,11 +250,9 @@ export const staticSourceData: SourceDataItem[] = [ platinumPrivateContext: [FeatureIds.Remote, FeatureIds.Private, FeatureIds.SearchableContent], }, accountContextOnly: true, - internalConnectorAvailable: true, }, { name: SOURCE_NAMES.GOOGLE_DRIVE, - iconName: SOURCE_NAMES.GOOGLE_DRIVE, serviceType: 'google_drive', configuration: { isPublicKey: false, @@ -298,11 +281,9 @@ export const staticSourceData: SourceDataItem[] = [ ], }, accountContextOnly: false, - internalConnectorAvailable: true, }, { name: SOURCE_NAMES.JIRA, - iconName: SOURCE_NAMES.JIRA, serviceType: 'jira_cloud', configuration: { isPublicKey: false, @@ -334,11 +315,9 @@ export const staticSourceData: SourceDataItem[] = [ ], }, accountContextOnly: false, - internalConnectorAvailable: true, }, { name: SOURCE_NAMES.JIRA_SERVER, - iconName: SOURCE_NAMES.JIRA_SERVER, serviceType: 'jira_server', configuration: { isPublicKey: true, @@ -369,13 +348,12 @@ export const staticSourceData: SourceDataItem[] = [ ], }, accountContextOnly: false, - internalConnectorAvailable: true, }, { name: SOURCE_NAMES.NETWORK_DRVE, - iconName: SOURCE_NAMES.NETWORK_DRVE, categories: [SOURCE_CATEGORIES.STORAGE], - serviceType: 'network_drive', // this doesn't exist on the BE + serviceType: 'custom', + baseServiceType: 'network_drive', configuration: { isPublicKey: false, hasOauthRedirect: false, @@ -385,12 +363,9 @@ export const staticSourceData: SourceDataItem[] = [ githubRepository: 'elastic/enterprise-search-network-drive-connector', }, accountContextOnly: false, - internalConnectorAvailable: false, - customConnectorAvailable: true, }, { name: SOURCE_NAMES.ONEDRIVE, - iconName: SOURCE_NAMES.ONEDRIVE, serviceType: 'one_drive', configuration: { isPublicKey: false, @@ -415,17 +390,16 @@ export const staticSourceData: SourceDataItem[] = [ ], }, accountContextOnly: false, - internalConnectorAvailable: true, }, { name: SOURCE_NAMES.OUTLOOK, - iconName: SOURCE_NAMES.OUTLOOK, categories: [ SOURCE_CATEGORIES.COMMUNICATIONS, SOURCE_CATEGORIES.PRODUCTIVITY, SOURCE_CATEGORIES.MICROSOFT, ], - serviceType: 'outlook', // this doesn't exist on the BE + serviceType: 'custom', + baseServiceType: 'outlook', configuration: { isPublicKey: false, hasOauthRedirect: false, @@ -435,12 +409,9 @@ export const staticSourceData: SourceDataItem[] = [ githubRepository: 'elastic/enterprise-search-outlook-connector', }, accountContextOnly: false, - internalConnectorAvailable: false, - customConnectorAvailable: true, }, { name: SOURCE_NAMES.SALESFORCE, - iconName: SOURCE_NAMES.SALESFORCE, serviceType: 'salesforce', configuration: { isPublicKey: false, @@ -472,11 +443,9 @@ export const staticSourceData: SourceDataItem[] = [ ], }, accountContextOnly: false, - internalConnectorAvailable: true, }, { name: SOURCE_NAMES.SALESFORCE_SANDBOX, - iconName: SOURCE_NAMES.SALESFORCE_SANDBOX, serviceType: 'salesforce_sandbox', configuration: { isPublicKey: false, @@ -508,11 +477,9 @@ export const staticSourceData: SourceDataItem[] = [ ], }, accountContextOnly: false, - internalConnectorAvailable: true, }, { name: SOURCE_NAMES.SERVICENOW, - iconName: SOURCE_NAMES.SERVICENOW, serviceType: 'service_now', configuration: { isPublicKey: false, @@ -541,11 +508,9 @@ export const staticSourceData: SourceDataItem[] = [ ], }, accountContextOnly: false, - internalConnectorAvailable: true, }, { name: SOURCE_NAMES.SHAREPOINT, - iconName: SOURCE_NAMES.SHAREPOINT, serviceType: 'share_point', configuration: { isPublicKey: false, @@ -570,13 +535,39 @@ export const staticSourceData: SourceDataItem[] = [ ], }, accountContextOnly: false, - internalConnectorAvailable: true, - externalConnectorAvailable: true, }, - staticExternalSourceData, + { + name: SOURCE_NAMES.SHAREPOINT, + categories: [], + serviceType: 'external', + baseServiceType: 'share_point', + configuration: { + isPublicKey: false, + hasOauthRedirect: true, + needsBaseUrl: false, + documentationUrl: docLinks.workplaceSearchExternalSharePointOnline, + applicationPortalUrl: 'https://portal.azure.com/', + }, + objTypes: [SOURCE_OBJ_TYPES.ALL_STORED_FILES], + features: { + basicOrgContext: [ + FeatureIds.SyncFrequency, + FeatureIds.SyncedItems, + FeatureIds.GlobalAccessPermissions, + ], + basicOrgContextExcludedFeatures: [FeatureIds.DocumentLevelPermissions], + platinumOrgContext: [FeatureIds.SyncFrequency, FeatureIds.SyncedItems], + platinumPrivateContext: [ + FeatureIds.Private, + FeatureIds.SyncFrequency, + FeatureIds.SyncedItems, + ], + }, + accountContextOnly: false, + isBeta: true, + }, { name: SOURCE_NAMES.SHAREPOINT_SERVER, - iconName: SOURCE_NAMES.SHAREPOINT_SERVER, categories: [ SOURCE_CATEGORIES.FILE_SHARING, SOURCE_CATEGORIES.STORAGE, @@ -584,7 +575,8 @@ export const staticSourceData: SourceDataItem[] = [ SOURCE_CATEGORIES.MICROSOFT, SOURCE_CATEGORIES.OFFICE_365, ], - serviceType: 'share_point_server', // this doesn't exist on the BE + serviceType: 'custom', + baseServiceType: 'share_point_server', configuration: { isPublicKey: false, hasOauthRedirect: false, @@ -594,12 +586,9 @@ export const staticSourceData: SourceDataItem[] = [ githubRepository: 'elastic/enterprise-search-sharepoint-server-connector', }, accountContextOnly: false, - internalConnectorAvailable: false, - customConnectorAvailable: true, }, { name: SOURCE_NAMES.SLACK, - iconName: SOURCE_NAMES.SLACK, serviceType: 'slack', configuration: { isPublicKey: false, @@ -617,17 +606,16 @@ export const staticSourceData: SourceDataItem[] = [ platinumPrivateContext: [FeatureIds.Remote, FeatureIds.Private, FeatureIds.SearchableContent], }, accountContextOnly: true, - internalConnectorAvailable: true, }, { name: SOURCE_NAMES.TEAMS, - iconName: SOURCE_NAMES.TEAMS, categories: [ SOURCE_CATEGORIES.COMMUNICATIONS, SOURCE_CATEGORIES.PRODUCTIVITY, SOURCE_CATEGORIES.MICROSOFT, ], - serviceType: 'teams', // this doesn't exist on the BE + serviceType: 'custom', + baseServiceType: 'teams', configuration: { isPublicKey: false, hasOauthRedirect: false, @@ -637,12 +625,9 @@ export const staticSourceData: SourceDataItem[] = [ githubRepository: 'elastic/enterprise-search-teams-connector', }, accountContextOnly: false, - internalConnectorAvailable: false, - customConnectorAvailable: true, }, { name: SOURCE_NAMES.ZENDESK, - iconName: SOURCE_NAMES.ZENDESK, serviceType: 'zendesk', configuration: { isPublicKey: false, @@ -667,13 +652,12 @@ export const staticSourceData: SourceDataItem[] = [ ], }, accountContextOnly: false, - internalConnectorAvailable: true, }, { name: SOURCE_NAMES.ZOOM, - iconName: SOURCE_NAMES.ZOOM, categories: [SOURCE_CATEGORIES.COMMUNICATIONS, SOURCE_CATEGORIES.PRODUCTIVITY], - serviceType: 'zoom', // this doesn't exist on the BE + serviceType: 'custom', + baseServiceType: 'zoom', configuration: { isPublicKey: false, hasOauthRedirect: false, @@ -683,14 +667,12 @@ export const staticSourceData: SourceDataItem[] = [ githubRepository: 'elastic/enterprise-search-zoom-connector', }, accountContextOnly: false, - internalConnectorAvailable: false, - customConnectorAvailable: true, }, + staticGenericExternalSourceData, ]; export const staticCustomSourceData: SourceDataItem = { name: SOURCE_NAMES.CUSTOM, - iconName: SOURCE_NAMES.CUSTOM, categories: ['API', 'Custom'], serviceType: 'custom', configuration: { @@ -701,12 +683,26 @@ export const staticCustomSourceData: SourceDataItem = { applicationPortalUrl: '', }, accountContextOnly: false, - customConnectorAvailable: true, }; -export const getSourceData = (serviceType: string): SourceDataItem => { - return ( - staticSourceData.find((staticSource) => staticSource.serviceType === serviceType) || - staticCustomSourceData +export const getSourceData = ( + serviceType: string, + baseServiceType?: string +): SourceDataItem | undefined => { + if (serviceType === 'custom' && typeof baseServiceType === 'undefined') { + return staticCustomSourceData; + } + return staticSourceData.find( + (staticSource) => + staticSource.serviceType === serviceType && staticSource.baseServiceType === baseServiceType ); }; + +export const hasExternalConnectorOption = (serviceType: string): boolean => + !!getSourceData('external', serviceType); + +export const hasCustomConnectorOption = (serviceType: string): boolean => + !!getSourceData('custom', serviceType); + +export const hasMultipleConnectorOptions = (serviceType: string): boolean => + hasExternalConnectorOption(serviceType) || hasCustomConnectorOption(serviceType); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.test.ts index 0f113ad402f28..0fdb827f6011d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.test.ts @@ -23,7 +23,12 @@ import { itShowsServerErrorAsFlashMessage } from '../../../test_helpers'; import { AppLogic } from '../../app_logic'; import { staticSourceData } from './source_data'; -import { SourcesLogic, fetchSourceStatuses, POLLING_INTERVAL } from './sources_logic'; +import { + SourcesLogic, + fetchSourceStatuses, + POLLING_INTERVAL, + mergeServerAndStaticData, +} from './sources_logic'; describe('SourcesLogic', () => { const { http } = mockHttpValues; @@ -37,8 +42,14 @@ describe('SourcesLogic', () => { const defaultValues = { contentSources: [], privateContentSources: [], - sourceData: staticSourceData.map((data) => ({ ...data, connected: false })), - availableSources: staticSourceData.map((data) => ({ ...data, connected: false })), + sourceData: mergeServerAndStaticData([], staticSourceData, []).map((data) => ({ + ...data, + connected: false, + })), + availableSources: mergeServerAndStaticData([], staticSourceData, []).map((data) => ({ + ...data, + connected: false, + })), configuredSources: [], serviceTypes: [], permissionsModal: null, @@ -322,7 +333,7 @@ describe('SourcesLogic', () => { it('availableSources & configuredSources have correct length', () => { SourcesLogic.actions.onInitializeSources(serverResponse); - expect(SourcesLogic.values.availableSources).toHaveLength(18); + expect(SourcesLogic.values.availableSources).toHaveLength(19); expect(SourcesLogic.values.configuredSources).toHaveLength(5); }); it('externalConfigured is set to true if external is configured', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.ts index 868831ab7c7fb..0f61ee580f677 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.ts @@ -51,7 +51,7 @@ export interface IPermissionsModalProps { additionalConfiguration: boolean; } -type CombinedDataItem = SourceDataItem & { connected: boolean }; +type CombinedDataItem = SourceDataItem & Partial & { connected: boolean }; export interface ISourcesValues { contentSources: ContentSourceDetails[]; @@ -145,17 +145,17 @@ export const SourcesLogic = kea>( selectors: ({ selectors }) => ({ availableSources: [ () => [selectors.sourceData], - (sourceData: SourceDataItem[]) => + (sourceData: CombinedDataItem[]) => sortByName(sourceData.filter(({ configured }) => !configured)), ], configuredSources: [ () => [selectors.sourceData], - (sourceData: SourceDataItem[]) => + (sourceData: CombinedDataItem[]) => sortByName(sourceData.filter(({ configured }) => configured)), ], externalConfigured: [ () => [selectors.configuredSources], - (configuredSources: SourceDataItem[]) => + (configuredSources: CombinedDataItem[]) => !!configuredSources.find((item) => item.serviceType === 'external'), ], sourceData: [ @@ -312,9 +312,12 @@ export const mergeServerAndStaticData = ( contentSources: ContentSourceDetails[] ): CombinedDataItem[] => { const unsortedData = staticData.map((staticItem) => { - const serverItem = serverData.find(({ serviceType }) => serviceType === staticItem.serviceType); + const serverItem = staticItem.baseServiceType + ? undefined // static items with base service types will never have matching external connectors, BE doesn't pass us a baseServiceType + : serverData.find(({ serviceType }) => serviceType === staticItem.serviceType); const connectedSource = contentSources.find( - ({ serviceType }) => serviceType === staticItem.serviceType + ({ baseServiceType, serviceType }) => + serviceType === staticItem.serviceType && baseServiceType === staticItem.baseServiceType ); return { ...staticItem, diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.test.tsx index 0fa263beab539..07baa82a5cdb0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.test.tsx @@ -10,11 +10,11 @@ import '../../../__mocks__/shallow_useeffect.mock'; import { setMockValues, setMockActions } from '../../../__mocks__/kea_logic'; import React from 'react'; -import { Route, Switch, Redirect } from 'react-router-dom'; +import { Redirect } from 'react-router-dom'; import { shallow } from 'enzyme'; -import { ADD_SOURCE_PATH, PRIVATE_SOURCES_PATH, SOURCES_PATH, getSourcesPath } from '../../routes'; +import { ADD_SOURCE_PATH, PRIVATE_SOURCES_PATH, getSourcesPath } from '../../routes'; import { SourcesRouter } from './sources_router'; @@ -34,19 +34,13 @@ describe('SourcesRouter', () => { }); it('renders sources routes', () => { - const TOTAL_ROUTES = 103; const wrapper = shallow(); - expect(wrapper.find(Switch)).toHaveLength(1); - expect(wrapper.find(Route)).toHaveLength(TOTAL_ROUTES); - }); - - it('redirects when nonplatinum license and accountOnly context', () => { - setMockValues({ ...mockValues, hasPlatinumLicense: false }); - const wrapper = shallow(); - - expect(wrapper.find(Redirect).last().prop('from')).toEqual(ADD_SOURCE_PATH); - expect(wrapper.find(Redirect).last().prop('to')).toEqual(SOURCES_PATH); + expect(wrapper.find('[data-test-subj="ConnectorIntroRoute"]')).toHaveLength(1); + expect(wrapper.find('[data-test-subj="ConnectorChoiceRoute"]')).toHaveLength(1); + expect(wrapper.find('[data-test-subj="ExternalConnectorConfigRoute"]')).toHaveLength(1); + expect(wrapper.find('[data-test-subj="AddCustomSourceRoute"]')).toHaveLength(2); + expect(wrapper.find('[data-test-subj="AddSourceRoute"]')).toHaveLength(1); }); it('redirects when cannot create sources', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.tsx index 19af955f8780c..4d4ec077213a0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.tsx @@ -11,7 +11,6 @@ import { Redirect, Route, Switch, useLocation } from 'react-router-dom'; import { Location } from 'history'; import { useActions, useValues } from 'kea'; -import { LicensingLogic } from '../../../shared/licensing'; import { AppLogic } from '../../app_logic'; import { GITHUB_ENTERPRISE_SERVER_VIA_APP_SERVICE_TYPE, @@ -24,17 +23,15 @@ import { SOURCES_PATH, getSourcesPath, getAddPath, - ADD_CUSTOM_PATH, } from '../../routes'; -import { hasMultipleConnectorOptions } from '../../utils'; import { AddSource, AddSourceList, GitHubViaApp } from './components/add_source'; import { AddCustomSource } from './components/add_source/add_custom_source'; import { ExternalConnectorConfig } from './components/add_source/add_external_connector'; -import { ConfigurationChoice } from './components/add_source/configuration_choice'; +import { AddSourceChoice } from './components/add_source/add_source_choice'; +import { AddSourceIntro } from './components/add_source/add_source_intro'; import { OrganizationSources } from './organization_sources'; import { PrivateSources } from './private_sources'; -import { staticCustomSourceData, staticSourceData as sources } from './source_data'; import { SourceRouter } from './source_router'; import { SourcesLogic } from './sources_logic'; @@ -42,7 +39,6 @@ import './sources.scss'; export const SourcesRouter: React.FC = () => { const { pathname } = useLocation() as Location; - const { hasPlatinumLicense } = useValues(LicensingLogic); const { resetSourcesState } = useActions(SourcesLogic); const { account: { canCreatePrivateSources }, @@ -82,119 +78,51 @@ export const SourcesRouter: React.FC = () => { - {sources.map((sourceData, i) => { - const { serviceType, externalConnectorAvailable, internalConnectorAvailable } = sourceData; - const path = `${getSourcesPath(getAddPath(serviceType), isOrganization)}`; - const defaultOption = internalConnectorAvailable - ? 'internal' - : externalConnectorAvailable - ? 'external' - : 'custom'; - const showChoice = defaultOption !== 'internal' && hasMultipleConnectorOptions(sourceData); - return ( - - {showChoice ? ( - - ) : ( - - )} - - ); - })} - - + + + + + + + + + + + + + + + + + - {sources - .filter((sourceData) => sourceData.internalConnectorAvailable) - .map((sourceData, i) => { - const { serviceType, accountContextOnly } = sourceData; - return ( - - {!hasPlatinumLicense && accountContextOnly ? ( - - ) : ( - - )} - - ); - })} - {sources - .filter((sourceData) => sourceData.externalConnectorAvailable) - .map((sourceData, i) => { - const { serviceType, accountContextOnly } = sourceData; - - return ( - - {!hasPlatinumLicense && accountContextOnly ? ( - - ) : ( - - )} - - ); - })} - {sources - .filter((sourceData) => sourceData.customConnectorAvailable) - .map((sourceData, i) => { - const { serviceType, accountContextOnly } = sourceData; - return ( - - {!hasPlatinumLicense && accountContextOnly ? ( - - ) : ( - - )} - - ); - })} - {sources.map((sourceData, i) => ( - - - - ))} - {sources.map((sourceData, i) => ( - - - - ))} - {sources.map((sourceData, i) => { - if (sourceData.configuration.needsConfiguration) - return ( - - - - ); - })} {canCreatePrivateSources ? ( diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.test.tsx index 8399df946ea83..bc457ca0a1c00 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.test.tsx @@ -8,6 +8,7 @@ import '../../../../__mocks__/shallow_useeffect.mock'; import { setMockValues, setMockActions } from '../../../../__mocks__/kea_logic'; +import { mockUseParams } from '../../../../__mocks__/react_router'; import { sourceConfigData } from '../../../__mocks__/content_sources.mock'; import React from 'react'; @@ -18,8 +19,6 @@ import { EuiCallOut, EuiConfirmModal } from '@elastic/eui'; import { SaveConfig } from '../../content_sources/components/add_source/save_config'; -import { staticSourceData } from '../../content_sources/source_data'; - import { SourceConfig } from './source_config'; describe('SourceConfig', () => { @@ -30,10 +29,11 @@ describe('SourceConfig', () => { beforeEach(() => { setMockValues({ sourceConfigData, dataLoading: false }); setMockActions({ deleteSourceConfig, getSourceConfigData, saveSourceConfig }); + mockUseParams.mockReturnValue({ serviceType: 'share_point' }); }); it('renders', () => { - const wrapper = shallow(); + const wrapper = shallow(); const saveConfig = wrapper.find(SaveConfig); // Trigger modal visibility @@ -43,15 +43,23 @@ describe('SourceConfig', () => { expect(wrapper.find(EuiCallOut)).toHaveLength(0); }); + it('returns null if there is no matching source data for the service type', () => { + mockUseParams.mockReturnValue({ serviceType: 'doesnt_exist' }); + + const wrapper = shallow(); + + expect(wrapper.isEmptyRender()).toBe(true); + }); + it('renders a breadcrumb fallback while data is loading', () => { setMockValues({ dataLoading: true, sourceConfigData: {} }); - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.prop('pageChrome')).toEqual(['Settings', 'Content source connectors', '...']); }); it('handles delete click', () => { - const wrapper = shallow(); + const wrapper = shallow(); const saveConfig = wrapper.find(SaveConfig); // Trigger modal visibility @@ -63,7 +71,7 @@ describe('SourceConfig', () => { }); it('saves source config', () => { - const wrapper = shallow(); + const wrapper = shallow(); const saveConfig = wrapper.find(SaveConfig); // Trigger modal visibility @@ -75,7 +83,7 @@ describe('SourceConfig', () => { }); it('cancels and closes modal', () => { - const wrapper = shallow(); + const wrapper = shallow(); const saveConfig = wrapper.find(SaveConfig); // Trigger modal visibility @@ -87,9 +95,8 @@ describe('SourceConfig', () => { }); it('shows feedback link for external sources', () => { - const wrapper = shallow( - - ); + mockUseParams.mockReturnValue({ serviceType: 'external' }); + const wrapper = shallow(); expect(wrapper.find(EuiCallOut)).toHaveLength(1); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.tsx index 6973732fa6727..76ed6023109d2 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.tsx @@ -7,6 +7,8 @@ import React, { useEffect, useState } from 'react'; +import { useParams } from 'react-router-dom'; + import { useActions, useValues } from 'kea'; import { @@ -21,29 +23,34 @@ import { i18n } from '@kbn/i18n'; import { WorkplaceSearchPageTemplate } from '../../../components/layout'; import { NAV, REMOVE_BUTTON, CANCEL_BUTTON } from '../../../constants'; -import { SourceDataItem } from '../../../types'; import { AddSourceHeader } from '../../content_sources/components/add_source/add_source_header'; import { AddSourceLogic } from '../../content_sources/components/add_source/add_source_logic'; import { SaveConfig } from '../../content_sources/components/add_source/save_config'; +import { getSourceData } from '../../content_sources/source_data'; import { SettingsLogic } from '../settings_logic'; -interface SourceConfigProps { - sourceData: SourceDataItem; -} - -export const SourceConfig: React.FC = ({ sourceData }) => { +export const SourceConfig: React.FC = () => { + const { serviceType } = useParams<{ serviceType: string }>(); const [confirmModalVisible, setConfirmModalVisibility] = useState(false); - const { configuration, serviceType } = sourceData; + const addSourceLogic = AddSourceLogic({ serviceType }); const { deleteSourceConfig } = useActions(SettingsLogic); - const { saveSourceConfig, getSourceConfigData } = useActions(AddSourceLogic); + const { saveSourceConfig, getSourceConfigData, resetSourceState } = useActions(addSourceLogic); const { sourceConfigData: { name, categories }, dataLoading, - } = useValues(AddSourceLogic); + } = useValues(addSourceLogic); + const sourceData = getSourceData(serviceType); useEffect(() => { - getSourceConfigData(serviceType); - }, []); + getSourceConfigData(); + return resetSourceState; + }, [serviceType]); + + if (!sourceData) { + return null; + } + + const { configuration } = sourceData; const hideConfirmModal = () => setConfirmModalVisibility(false); const showConfirmModal = () => setConfirmModalVisibility(true); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_router.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_router.test.tsx index 123167f0ad1d0..604c155215724 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_router.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_router.test.tsx @@ -10,12 +10,10 @@ import '../../../__mocks__/shallow_useeffect.mock'; import { setMockActions } from '../../../__mocks__/kea_logic'; import React from 'react'; -import { Route, Redirect, Switch } from 'react-router-dom'; +import { Redirect, Switch } from 'react-router-dom'; import { shallow } from 'enzyme'; -import { staticSourceData } from '../content_sources/source_data'; - import { Connectors } from './components/connectors'; import { Customize } from './components/customize'; import { OauthApplication } from './components/oauth_application'; @@ -24,9 +22,6 @@ import { SettingsRouter } from './settings_router'; describe('SettingsRouter', () => { const initializeSettings = jest.fn(); - const NUM_SOURCES = staticSourceData.length; - // Should be 4 routes other than the sources listed: Connectors, Customize, & OauthApplication, & a redirect - const NUM_ROUTES = NUM_SOURCES + 4; beforeEach(() => { setMockActions({ initializeSettings }); @@ -36,11 +31,10 @@ describe('SettingsRouter', () => { const wrapper = shallow(); expect(wrapper.find(Switch)).toHaveLength(1); - expect(wrapper.find(Route)).toHaveLength(NUM_ROUTES); expect(wrapper.find(Redirect)).toHaveLength(1); expect(wrapper.find(Connectors)).toHaveLength(1); expect(wrapper.find(Customize)).toHaveLength(1); expect(wrapper.find(OauthApplication)).toHaveLength(1); - expect(wrapper.find(SourceConfig)).toHaveLength(NUM_SOURCES); + expect(wrapper.find(SourceConfig)).toHaveLength(1); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_router.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_router.tsx index 7c5e501d6a2a1..fc250bbfbf4e4 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_router.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_router.tsx @@ -16,7 +16,6 @@ import { ORG_SETTINGS_OAUTH_APPLICATION_PATH, getEditPath, } from '../../routes'; -import { staticSourceData } from '../content_sources/source_data'; import { Connectors } from './components/connectors'; import { Customize } from './components/customize'; @@ -42,11 +41,9 @@ export const SettingsRouter: React.FC = () => { - {staticSourceData.map((sourceData, i) => ( - - - - ))} + + +