From 507ab0e8d042d80c7ad56160416bc1abf404b8ab Mon Sep 17 00:00:00 2001 From: Matthias Wilhelm Date: Thu, 24 Jun 2021 11:29:23 +0200 Subject: [PATCH 01/16] [Discover] Add right permission for unmapped field test in cloud env (#102853) --- .../_indexpattern_with_unmapped_fields.ts | 15 ++++++++++----- test/functional/config.js | 15 +++++++++++++++ 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/test/functional/apps/discover/_indexpattern_with_unmapped_fields.ts b/test/functional/apps/discover/_indexpattern_with_unmapped_fields.ts index e986429a15d264..264885490cdfcc 100644 --- a/test/functional/apps/discover/_indexpattern_with_unmapped_fields.ts +++ b/test/functional/apps/discover/_indexpattern_with_unmapped_fields.ts @@ -12,26 +12,31 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const kibanaServer = getService('kibanaServer'); - const log = getService('log'); + const security = getService('security'); const retry = getService('retry'); const PageObjects = getPageObjects(['common', 'timePicker', 'discover']); describe('index pattern with unmapped fields', () => { before(async () => { await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/unmapped_fields'); + await security.testUser.setRoles(['kibana_admin', 'test-index-unmapped-fields']); + const fromTime = 'Jan 20, 2021 @ 00:00:00.000'; + const toTime = 'Jan 25, 2021 @ 00:00:00.000'; + await kibanaServer.uiSettings.replace({ defaultIndex: 'test-index-unmapped-fields', 'discover:searchFieldsFromSource': false, + 'timepicker:timeDefaults': `{ "from": "${fromTime}", "to": "${toTime}"}`, }); - log.debug('discover'); - const fromTime = 'Jan 20, 2021 @ 00:00:00.000'; - const toTime = 'Jan 25, 2021 @ 00:00:00.000'; + await PageObjects.common.navigateToApp('discover'); - await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); }); after(async () => { await esArchiver.unload('test/functional/fixtures/es_archiver/unmapped_fields'); + await kibanaServer.uiSettings.unset('defaultIndex'); + await kibanaServer.uiSettings.unset('discover:searchFieldsFromSource'); + await kibanaServer.uiSettings.unset('timepicker:timeDefaults'); }); it('unmapped fields exist on a new saved search', async () => { diff --git a/test/functional/config.js b/test/functional/config.js index bab1148cf372a4..b28a9fd36c1c0b 100644 --- a/test/functional/config.js +++ b/test/functional/config.js @@ -292,6 +292,21 @@ export default async function ({ readConfigFile }) { kibana: [], }, + 'test-index-unmapped-fields': { + elasticsearch: { + cluster: [], + indices: [ + { + names: ['test-index-unmapped-fields'], + privileges: ['read', 'view_index_metadata'], + field_security: { grant: ['*'], except: [] }, + }, + ], + run_as: [], + }, + kibana: [], + }, + animals: { elasticsearch: { cluster: [], From 1d2cebafd22017c9e6998c675b6010967fe9505a Mon Sep 17 00:00:00 2001 From: Stratoula Kalafateli Date: Thu, 24 Jun 2021 12:33:35 +0300 Subject: [PATCH 02/16] [VisTypePie] Use a different advanced setting for pie charts (#103049) * Different switch for pie * Remove unused translations Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- docs/management/advanced-options.asciidoc | 3 + .../server/collectors/management/schema.ts | 4 ++ .../server/collectors/management/types.ts | 1 + src/plugins/telemetry/schema/oss_plugins.json | 6 ++ src/plugins/vis_type_pie/common/index.ts | 1 + src/plugins/vis_type_pie/kibana.json | 2 + src/plugins/vis_type_pie/public/plugin.ts | 4 +- src/plugins/vis_type_pie/server/index.ts | 10 ++++ src/plugins/vis_type_pie/server/plugin.ts | 56 +++++++++++++++++++ src/plugins/vis_type_vislib/public/plugin.ts | 24 ++++---- src/plugins/vis_type_xy/common/index.ts | 2 + src/plugins/vis_type_xy/kibana.json | 2 + src/plugins/vis_type_xy/public/plugin.ts | 2 +- src/plugins/vis_type_xy/server/index.ts | 10 ++++ src/plugins/vis_type_xy/server/plugin.ts | 56 +++++++++++++++++++ .../visualizations/common/constants.ts | 1 - src/plugins/visualizations/server/plugin.ts | 23 +------- .../apps/dashboard/dashboard_state.ts | 1 + test/functional/apps/dashboard/index.ts | 2 + .../apps/getting_started/_shakespeare.ts | 1 + test/functional/apps/getting_started/index.ts | 2 + test/functional/apps/visualize/index.ts | 2 + test/functional/config.js | 1 + .../page_objects/visualize_chart_page.ts | 3 +- .../functional/page_objects/visualize_page.ts | 1 + .../translations/translations/ja-JP.json | 2 - .../translations/translations/zh-CN.json | 2 - x-pack/test/functional/config.js | 1 + 28 files changed, 183 insertions(+), 42 deletions(-) create mode 100644 src/plugins/vis_type_pie/server/index.ts create mode 100644 src/plugins/vis_type_pie/server/plugin.ts create mode 100644 src/plugins/vis_type_xy/server/index.ts create mode 100644 src/plugins/vis_type_xy/server/plugin.ts diff --git a/docs/management/advanced-options.asciidoc b/docs/management/advanced-options.asciidoc index 853180ec816e97..66a23ee189ae1f 100644 --- a/docs/management/advanced-options.asciidoc +++ b/docs/management/advanced-options.asciidoc @@ -482,6 +482,9 @@ of buckets to try to represent. [[visualization-visualize-chartslibrary]]`visualization:visualize:legacyChartsLibrary`:: Enables the legacy charts library for aggregation-based area, line, and bar charts in *Visualize*. +[[visualization-visualize-pieChartslibrary]]`visualization:visualize:legacyPieChartsLibrary`:: +Enables the legacy charts library for aggregation-based pie charts in *Visualize*. + [[visualization-colormapping]]`visualization:colorMapping`:: **This setting is deprecated and will not be supported as of 8.0.** Maps values to specific colors in charts using the *Compatibility* palette. diff --git a/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts b/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts index a6b79a9e2c0090..ff637b6686612c 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts @@ -396,6 +396,10 @@ export const stackManagementSchema: MakeSchemaFrom = { type: 'boolean', _meta: { description: 'Non-default value of setting.' }, }, + 'visualization:visualize:legacyPieChartsLibrary': { + type: 'boolean', + _meta: { description: 'Non-default value of setting.' }, + }, 'doc_table:legacy': { type: 'boolean', _meta: { description: 'Non-default value of setting.' }, diff --git a/src/plugins/kibana_usage_collection/server/collectors/management/types.ts b/src/plugins/kibana_usage_collection/server/collectors/management/types.ts index 8448b359ce6079..b59abc3aa71586 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/types.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/types.ts @@ -26,6 +26,7 @@ export interface UsageStats { 'autocomplete:useTimeRange': boolean; 'search:timeout': number; 'visualization:visualize:legacyChartsLibrary': boolean; + 'visualization:visualize:legacyPieChartsLibrary': boolean; 'doc_table:legacy': boolean; 'discover:modifyColumnsOnSwitch': boolean; 'discover:searchFieldsFromSource': boolean; diff --git a/src/plugins/telemetry/schema/oss_plugins.json b/src/plugins/telemetry/schema/oss_plugins.json index 99c6dcb40e57d4..496335a3b0dc8b 100644 --- a/src/plugins/telemetry/schema/oss_plugins.json +++ b/src/plugins/telemetry/schema/oss_plugins.json @@ -8594,6 +8594,12 @@ "description": "Non-default value of setting." } }, + "visualization:visualize:legacyPieChartsLibrary": { + "type": "boolean", + "_meta": { + "description": "Non-default value of setting." + } + }, "doc_table:legacy": { "type": "boolean", "_meta": { diff --git a/src/plugins/vis_type_pie/common/index.ts b/src/plugins/vis_type_pie/common/index.ts index 1aa1680530b324..a02a2b2ba10f2a 100644 --- a/src/plugins/vis_type_pie/common/index.ts +++ b/src/plugins/vis_type_pie/common/index.ts @@ -7,3 +7,4 @@ */ export const DEFAULT_PERCENT_DECIMALS = 2; +export const LEGACY_PIE_CHARTS_LIBRARY = 'visualization:visualize:legacyPieChartsLibrary'; diff --git a/src/plugins/vis_type_pie/kibana.json b/src/plugins/vis_type_pie/kibana.json index ee312fd19e8d5e..eebefc42681b75 100644 --- a/src/plugins/vis_type_pie/kibana.json +++ b/src/plugins/vis_type_pie/kibana.json @@ -2,8 +2,10 @@ "id": "visTypePie", "version": "kibana", "ui": true, + "server": true, "requiredPlugins": ["charts", "data", "expressions", "visualizations", "usageCollection"], "requiredBundles": ["visDefaultEditor"], + "extraPublicDirs": ["common/index"], "owner": { "name": "Kibana App", "githubTeam": "kibana-app" diff --git a/src/plugins/vis_type_pie/public/plugin.ts b/src/plugins/vis_type_pie/public/plugin.ts index 440a3a75a2eb19..787f49c19aca3f 100644 --- a/src/plugins/vis_type_pie/public/plugin.ts +++ b/src/plugins/vis_type_pie/public/plugin.ts @@ -12,7 +12,7 @@ import { Plugin as ExpressionsPublicPlugin } from '../../expressions/public'; import { ChartsPluginSetup } from '../../charts/public'; import { UsageCollectionSetup } from '../../usage_collection/public'; import { DataPublicPluginStart } from '../../data/public'; -import { LEGACY_CHARTS_LIBRARY } from '../../visualizations/common/constants'; +import { LEGACY_PIE_CHARTS_LIBRARY } from '../common'; import { pieLabels as pieLabelsExpressionFunction } from './expression_functions/pie_labels'; import { createPieVisFn } from './pie_fn'; import { getPieVisRenderer } from './pie_renderer'; @@ -43,7 +43,7 @@ export class VisTypePiePlugin { core: CoreSetup, { expressions, visualizations, charts, usageCollection }: VisTypePieSetupDependencies ) { - if (!core.uiSettings.get(LEGACY_CHARTS_LIBRARY, false)) { + if (!core.uiSettings.get(LEGACY_PIE_CHARTS_LIBRARY, false)) { const getStartDeps = async () => { const [coreStart, deps] = await core.getStartServices(); return { diff --git a/src/plugins/vis_type_pie/server/index.ts b/src/plugins/vis_type_pie/server/index.ts new file mode 100644 index 00000000000000..201071fbb5fcaa --- /dev/null +++ b/src/plugins/vis_type_pie/server/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { VisTypePieServerPlugin } from './plugin'; + +export const plugin = () => new VisTypePieServerPlugin(); diff --git a/src/plugins/vis_type_pie/server/plugin.ts b/src/plugins/vis_type_pie/server/plugin.ts new file mode 100644 index 00000000000000..48576bdff5d330 --- /dev/null +++ b/src/plugins/vis_type_pie/server/plugin.ts @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; +import { schema } from '@kbn/config-schema'; + +import { CoreSetup, Plugin, UiSettingsParams } from 'kibana/server'; + +import { LEGACY_PIE_CHARTS_LIBRARY } from '../common'; + +export const getUiSettingsConfig: () => Record> = () => ({ + // TODO: Remove this when vis_type_vislib is removed + // https://github.com/elastic/kibana/issues/56143 + [LEGACY_PIE_CHARTS_LIBRARY]: { + name: i18n.translate('visTypePie.advancedSettings.visualization.legacyPieChartsLibrary.name', { + defaultMessage: 'Pie legacy charts library', + }), + requiresPageReload: true, + value: false, + description: i18n.translate( + 'visTypePie.advancedSettings.visualization.legacyPieChartsLibrary.description', + { + defaultMessage: 'Enables legacy charts library for pie charts in visualize.', + } + ), + deprecation: { + message: i18n.translate( + 'visTypePie.advancedSettings.visualization.legacyPieChartsLibrary.deprecation', + { + defaultMessage: + 'The legacy charts library for pie in visualize is deprecated and will not be supported as of 8.0.', + } + ), + docLinksKey: 'visualizationSettings', + }, + category: ['visualization'], + schema: schema.boolean(), + }, +}); + +export class VisTypePieServerPlugin implements Plugin { + public setup(core: CoreSetup) { + core.uiSettings.register(getUiSettingsConfig()); + + return {}; + } + + public start() { + return {}; + } +} diff --git a/src/plugins/vis_type_vislib/public/plugin.ts b/src/plugins/vis_type_vislib/public/plugin.ts index 52faf8a74778c3..cdc02aacafa3b1 100644 --- a/src/plugins/vis_type_vislib/public/plugin.ts +++ b/src/plugins/vis_type_vislib/public/plugin.ts @@ -13,7 +13,8 @@ import { VisualizationsSetup } from '../../visualizations/public'; import { ChartsPluginSetup } from '../../charts/public'; import { DataPublicPluginStart } from '../../data/public'; import { KibanaLegacyStart } from '../../kibana_legacy/public'; -import { LEGACY_CHARTS_LIBRARY } from '../../visualizations/common/constants'; +import { LEGACY_CHARTS_LIBRARY } from '../../vis_type_xy/common/index'; +import { LEGACY_PIE_CHARTS_LIBRARY } from '../../vis_type_pie/common/index'; import { createVisTypeVislibVisFn } from './vis_type_vislib_vis_fn'; import { createPieVisFn } from './pie_fn'; @@ -50,17 +51,18 @@ export class VisTypeVislibPlugin core: VisTypeVislibCoreSetup, { expressions, visualizations, charts }: VisTypeVislibPluginSetupDependencies ) { - if (!core.uiSettings.get(LEGACY_CHARTS_LIBRARY, false)) { - // Register only non-replaced vis types - convertedTypeDefinitions.forEach(visualizations.createBaseVisualization); - expressions.registerRenderer(getVislibVisRenderer(core, charts)); - expressions.registerFunction(createVisTypeVislibVisFn()); - } else { - // Register all vis types - visLibVisTypeDefinitions.forEach(visualizations.createBaseVisualization); + const typeDefinitions = !core.uiSettings.get(LEGACY_CHARTS_LIBRARY, false) + ? convertedTypeDefinitions + : visLibVisTypeDefinitions; + // register vislib XY axis charts + typeDefinitions.forEach(visualizations.createBaseVisualization); + expressions.registerRenderer(getVislibVisRenderer(core, charts)); + expressions.registerFunction(createVisTypeVislibVisFn()); + + if (core.uiSettings.get(LEGACY_PIE_CHARTS_LIBRARY, false)) { + // register vislib pie chart visualizations.createBaseVisualization(pieVisTypeDefinition); - expressions.registerRenderer(getVislibVisRenderer(core, charts)); - [createVisTypeVislibVisFn(), createPieVisFn()].forEach(expressions.registerFunction); + expressions.registerFunction(createPieVisFn()); } } diff --git a/src/plugins/vis_type_xy/common/index.ts b/src/plugins/vis_type_xy/common/index.ts index f17bc8476d9a68..a80946f7c62fa3 100644 --- a/src/plugins/vis_type_xy/common/index.ts +++ b/src/plugins/vis_type_xy/common/index.ts @@ -19,3 +19,5 @@ export enum ChartType { * Type of xy visualizations */ export type XyVisType = ChartType | 'horizontal_bar'; + +export const LEGACY_CHARTS_LIBRARY = 'visualization:visualize:legacyChartsLibrary'; diff --git a/src/plugins/vis_type_xy/kibana.json b/src/plugins/vis_type_xy/kibana.json index 1d7fd6a0813b4e..c25f035fb6d4b0 100644 --- a/src/plugins/vis_type_xy/kibana.json +++ b/src/plugins/vis_type_xy/kibana.json @@ -2,8 +2,10 @@ "id": "visTypeXy", "version": "kibana", "ui": true, + "server": true, "requiredPlugins": ["charts", "data", "expressions", "visualizations", "usageCollection"], "requiredBundles": ["kibanaUtils", "visDefaultEditor"], + "extraPublicDirs": ["common/index"], "owner": { "name": "Kibana App", "githubTeam": "kibana-app" diff --git a/src/plugins/vis_type_xy/public/plugin.ts b/src/plugins/vis_type_xy/public/plugin.ts index e8d53127765b4c..b595d3172f143e 100644 --- a/src/plugins/vis_type_xy/public/plugin.ts +++ b/src/plugins/vis_type_xy/public/plugin.ts @@ -23,7 +23,7 @@ import { } from './services'; import { visTypesDefinitions } from './vis_types'; -import { LEGACY_CHARTS_LIBRARY } from '../../visualizations/common/constants'; +import { LEGACY_CHARTS_LIBRARY } from '../common/'; import { xyVisRenderer } from './vis_renderer'; import * as expressionFunctions from './expression_functions'; diff --git a/src/plugins/vis_type_xy/server/index.ts b/src/plugins/vis_type_xy/server/index.ts new file mode 100644 index 00000000000000..a27ac49c0ea490 --- /dev/null +++ b/src/plugins/vis_type_xy/server/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { VisTypeXyServerPlugin } from './plugin'; + +export const plugin = () => new VisTypeXyServerPlugin(); diff --git a/src/plugins/vis_type_xy/server/plugin.ts b/src/plugins/vis_type_xy/server/plugin.ts new file mode 100644 index 00000000000000..46d6531204c241 --- /dev/null +++ b/src/plugins/vis_type_xy/server/plugin.ts @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; +import { schema } from '@kbn/config-schema'; + +import { CoreSetup, Plugin, UiSettingsParams } from 'kibana/server'; + +import { LEGACY_CHARTS_LIBRARY } from '../common'; + +export const getUiSettingsConfig: () => Record> = () => ({ + // TODO: Remove this when vis_type_vislib is removed + // https://github.com/elastic/kibana/issues/56143 + [LEGACY_CHARTS_LIBRARY]: { + name: i18n.translate('visTypeXy.advancedSettings.visualization.legacyChartsLibrary.name', { + defaultMessage: 'XY axis legacy charts library', + }), + requiresPageReload: true, + value: false, + description: i18n.translate( + 'visTypeXy.advancedSettings.visualization.legacyChartsLibrary.description', + { + defaultMessage: 'Enables legacy charts library for area, line and bar charts in visualize.', + } + ), + deprecation: { + message: i18n.translate( + 'visTypeXy.advancedSettings.visualization.legacyChartsLibrary.deprecation', + { + defaultMessage: + 'The legacy charts library for area, line and bar charts in visualize is deprecated and will not be supported as of 7.16.', + } + ), + docLinksKey: 'visualizationSettings', + }, + category: ['visualization'], + schema: schema.boolean(), + }, +}); + +export class VisTypeXyServerPlugin implements Plugin { + public setup(core: CoreSetup) { + core.uiSettings.register(getUiSettingsConfig()); + + return {}; + } + + public start() { + return {}; + } +} diff --git a/src/plugins/visualizations/common/constants.ts b/src/plugins/visualizations/common/constants.ts index a33e74b498a2ce..a8a0963ac89480 100644 --- a/src/plugins/visualizations/common/constants.ts +++ b/src/plugins/visualizations/common/constants.ts @@ -7,4 +7,3 @@ */ export const VISUALIZE_ENABLE_LABS_SETTING = 'visualize:enableLabs'; -export const LEGACY_CHARTS_LIBRARY = 'visualization:visualize:legacyChartsLibrary'; diff --git a/src/plugins/visualizations/server/plugin.ts b/src/plugins/visualizations/server/plugin.ts index 1fec63f2bb45ad..5a5a80b2689d6e 100644 --- a/src/plugins/visualizations/server/plugin.ts +++ b/src/plugins/visualizations/server/plugin.ts @@ -18,7 +18,7 @@ import { Logger, } from '../../../core/server'; -import { VISUALIZE_ENABLE_LABS_SETTING, LEGACY_CHARTS_LIBRARY } from '../common/constants'; +import { VISUALIZE_ENABLE_LABS_SETTING } from '../common/constants'; import { visualizationSavedObjectType } from './saved_objects'; @@ -58,27 +58,6 @@ export class VisualizationsPlugin category: ['visualization'], schema: schema.boolean(), }, - // TODO: Remove this when vis_type_vislib is removed - // https://github.com/elastic/kibana/issues/56143 - [LEGACY_CHARTS_LIBRARY]: { - name: i18n.translate( - 'visualizations.advancedSettings.visualization.legacyChartsLibrary.name', - { - defaultMessage: 'Legacy charts library', - } - ), - requiresPageReload: true, - value: false, - description: i18n.translate( - 'visualizations.advancedSettings.visualization.legacyChartsLibrary.description', - { - defaultMessage: - 'Enables legacy charts library for area, line, bar, pie charts in visualize.', - } - ), - category: ['visualization'], - schema: schema.boolean(), - }, }); if (plugins.usageCollection) { diff --git a/test/functional/apps/dashboard/dashboard_state.ts b/test/functional/apps/dashboard/dashboard_state.ts index 047681e1a8ace7..6c259f5a71efa4 100644 --- a/test/functional/apps/dashboard/dashboard_state.ts +++ b/test/functional/apps/dashboard/dashboard_state.ts @@ -53,6 +53,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { if (isNewChartsLibraryEnabled) { await kibanaServer.uiSettings.update({ 'visualization:visualize:legacyChartsLibrary': false, + 'visualization:visualize:legacyPieChartsLibrary': false, }); await browser.refresh(); } diff --git a/test/functional/apps/dashboard/index.ts b/test/functional/apps/dashboard/index.ts index 4b83b2ac92deb9..e4dc04282e4ac7 100644 --- a/test/functional/apps/dashboard/index.ts +++ b/test/functional/apps/dashboard/index.ts @@ -123,6 +123,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { await loadLogstash(); await kibanaServer.uiSettings.update({ 'visualization:visualize:legacyChartsLibrary': false, + 'visualization:visualize:legacyPieChartsLibrary': false, }); await browser.refresh(); }); @@ -131,6 +132,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { await unloadLogstash(); await kibanaServer.uiSettings.update({ 'visualization:visualize:legacyChartsLibrary': true, + 'visualization:visualize:legacyPieChartsLibrary': true, }); await browser.refresh(); }); diff --git a/test/functional/apps/getting_started/_shakespeare.ts b/test/functional/apps/getting_started/_shakespeare.ts index 945c1fdcbdcf4e..ae6841b85c98dd 100644 --- a/test/functional/apps/getting_started/_shakespeare.ts +++ b/test/functional/apps/getting_started/_shakespeare.ts @@ -57,6 +57,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { if (isNewChartsLibraryEnabled) { await kibanaServer.uiSettings.update({ 'visualization:visualize:legacyChartsLibrary': false, + 'visualization:visualize:legacyPieChartsLibrary': false, }); await browser.refresh(); } diff --git a/test/functional/apps/getting_started/index.ts b/test/functional/apps/getting_started/index.ts index b75a30037d065e..4c1c052ef15a28 100644 --- a/test/functional/apps/getting_started/index.ts +++ b/test/functional/apps/getting_started/index.ts @@ -24,6 +24,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { before(async () => { await kibanaServer.uiSettings.update({ 'visualization:visualize:legacyChartsLibrary': false, + 'visualization:visualize:legacyPieChartsLibrary': false, }); await browser.refresh(); }); @@ -31,6 +32,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { after(async () => { await kibanaServer.uiSettings.update({ 'visualization:visualize:legacyChartsLibrary': true, + 'visualization:visualize:legacyPieChartsLibrary': true, }); await browser.refresh(); }); diff --git a/test/functional/apps/visualize/index.ts b/test/functional/apps/visualize/index.ts index cecd206abd1db0..bc6160eba38468 100644 --- a/test/functional/apps/visualize/index.ts +++ b/test/functional/apps/visualize/index.ts @@ -31,6 +31,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { before(async () => { await kibanaServer.uiSettings.update({ 'visualization:visualize:legacyChartsLibrary': false, + 'visualization:visualize:legacyPieChartsLibrary': false, }); await browser.refresh(); }); @@ -38,6 +39,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { after(async () => { await kibanaServer.uiSettings.update({ 'visualization:visualize:legacyChartsLibrary': true, + 'visualization:visualize:legacyPieChartsLibrary': true, }); await browser.refresh(); }); diff --git a/test/functional/config.js b/test/functional/config.js index b28a9fd36c1c0b..670488003e56cf 100644 --- a/test/functional/config.js +++ b/test/functional/config.js @@ -58,6 +58,7 @@ export default async function ({ readConfigFile }) { 'accessibility:disableAnimations': true, 'dateFormat:tz': 'UTC', 'visualization:visualize:legacyChartsLibrary': true, + 'visualization:visualize:legacyPieChartsLibrary': true, }, }, diff --git a/test/functional/page_objects/visualize_chart_page.ts b/test/functional/page_objects/visualize_chart_page.ts index c8587f4ffd3469..64b8c363fa6c2e 100644 --- a/test/functional/page_objects/visualize_chart_page.ts +++ b/test/functional/page_objects/visualize_chart_page.ts @@ -37,7 +37,8 @@ export class VisualizeChartPageObject extends FtrService { public async isNewChartsLibraryEnabled(): Promise { const legacyChartsLibrary = Boolean( - await this.kibanaServer.uiSettings.get('visualization:visualize:legacyChartsLibrary') + (await this.kibanaServer.uiSettings.get('visualization:visualize:legacyChartsLibrary')) && + (await this.kibanaServer.uiSettings.get('visualization:visualize:legacyPieChartsLibrary')) ) ?? true; const enabled = !legacyChartsLibrary; this.log.debug(`-- isNewChartsLibraryEnabled = ${enabled}`); diff --git a/test/functional/page_objects/visualize_page.ts b/test/functional/page_objects/visualize_page.ts index a11a254509e7a8..e930406cdcce84 100644 --- a/test/functional/page_objects/visualize_page.ts +++ b/test/functional/page_objects/visualize_page.ts @@ -57,6 +57,7 @@ export class VisualizePageObject extends FtrService { defaultIndex: 'logstash-*', [UI_SETTINGS.FORMAT_BYTES_DEFAULT_PATTERN]: '0,0.[000]b', 'visualization:visualize:legacyChartsLibrary': !isNewLibrary, + 'visualization:visualize:legacyPieChartsLibrary': !isNewLibrary, }); } diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index e73bb19627b8fe..87813b64f1f208 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -4941,8 +4941,6 @@ "visTypePie.editors.pie.showLabelsLabel": "ラベルを表示", "visTypePie.editors.pie.showTopLevelOnlyLabel": "トップレベルのみ表示", "visTypePie.editors.pie.showValuesLabel": "値を表示", - "visualizations.advancedSettings.visualization.legacyChartsLibrary.description": "Visualizeでエリア、折れ線、棒グラフのレガシーグラフライブラリを有効にします。", - "visualizations.advancedSettings.visualization.legacyChartsLibrary.name": "レガシーグラフライブラリ", "visualizations.advancedSettings.visualizeEnableLabsText": "ユーザーが実験的なビジュアライゼーションを作成、表示、編集できるようになります。無効の場合、\n ユーザーは本番準備が整ったビジュアライゼーションのみを利用できます。", "visualizations.advancedSettings.visualizeEnableLabsTitle": "実験的なビジュアライゼーションを有効にする", "visualizations.disabledLabVisualizationLink": "ドキュメンテーションを表示", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 51f6378f481e2d..4e98de541ce603 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -4968,8 +4968,6 @@ "visTypePie.editors.pie.showLabelsLabel": "显示标签", "visTypePie.editors.pie.showTopLevelOnlyLabel": "仅显示顶级", "visTypePie.editors.pie.showValuesLabel": "显示值", - "visualizations.advancedSettings.visualization.legacyChartsLibrary.description": "在 Visualize 中启用面积图、折线图和条形图的旧版图表库。", - "visualizations.advancedSettings.visualization.legacyChartsLibrary.name": "旧版图表库", "visualizations.advancedSettings.visualizeEnableLabsText": "允许用户创建、查看和编辑实验性可视化。如果禁用,\n 仅被视为生产就绪的可视化可供用户使用。", "visualizations.advancedSettings.visualizeEnableLabsTitle": "启用实验性可视化", "visualizations.disabledLabVisualizationLink": "阅读文档", diff --git a/x-pack/test/functional/config.js b/x-pack/test/functional/config.js index cf05bd6e15898f..2c3a3c93e2a0ae 100644 --- a/x-pack/test/functional/config.js +++ b/x-pack/test/functional/config.js @@ -103,6 +103,7 @@ export default async function ({ readConfigFile }) { 'accessibility:disableAnimations': true, 'dateFormat:tz': 'UTC', 'visualization:visualize:legacyChartsLibrary': true, + 'visualization:visualize:legacyPieChartsLibrary': true, }, }, // the apps section defines the urls that From 59d422394aad7124cf07efbd651fed944e73f0e5 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Thu, 24 Jun 2021 11:43:33 +0200 Subject: [PATCH 03/16] [Lens] Move empty string handling into field formatter (#102877) --- .../common/field_formats/converters/string.ts | 7 ++ .../vis_type_pie/public/utils/get_layers.ts | 6 - .../public/components/detailed_tooltip.tsx | 4 +- .../public/components/xy_settings.tsx | 3 +- .../vis_type_xy/public/config/get_axis.ts | 4 +- .../public/utils/get_series_name_fn.ts | 13 +- .../components/table_actions.ts | 6 +- .../heatmap_visualization/chart_component.tsx | 5 +- .../rename_columns.test.ts | 23 ---- .../indexpattern_datasource/rename_columns.ts | 14 +-- .../pie_visualization/render_function.tsx | 3 +- .../legend_action_popover.tsx | 5 +- x-pack/plugins/lens/public/utils.test.ts | 118 ------------------ x-pack/plugins/lens/public/utils.ts | 36 ------ .../public/xy_visualization/expression.tsx | 3 +- .../translations/translations/ja-JP.json | 2 - .../translations/translations/zh-CN.json | 2 - .../discover/__snapshots__/reporting.snap | 4 +- 18 files changed, 23 insertions(+), 235 deletions(-) delete mode 100644 x-pack/plugins/lens/public/utils.test.ts diff --git a/src/plugins/data/common/field_formats/converters/string.ts b/src/plugins/data/common/field_formats/converters/string.ts index ec92d75910522d..64367df5d90dda 100644 --- a/src/plugins/data/common/field_formats/converters/string.ts +++ b/src/plugins/data/common/field_formats/converters/string.ts @@ -13,6 +13,10 @@ import { FieldFormat } from '../field_format'; import { TextContextTypeConvert, FIELD_FORMAT_IDS } from '../types'; import { shortenDottedString } from '../../utils'; +export const emptyLabel = i18n.translate('data.fieldFormats.string.emptyLabel', { + defaultMessage: '(empty)', +}); + const TRANSFORM_OPTIONS = [ { kind: false, @@ -103,6 +107,9 @@ export class StringFormat extends FieldFormat { } textConvert: TextContextTypeConvert = (val) => { + if (val === '') { + return emptyLabel; + } switch (this.param('transform')) { case 'lower': return String(val).toLowerCase(); diff --git a/src/plugins/vis_type_pie/public/utils/get_layers.ts b/src/plugins/vis_type_pie/public/utils/get_layers.ts index 27dcf2d379811d..5a82871bf36884 100644 --- a/src/plugins/vis_type_pie/public/utils/get_layers.ts +++ b/src/plugins/vis_type_pie/public/utils/get_layers.ts @@ -6,7 +6,6 @@ * Side Public License, v 1. */ -import { i18n } from '@kbn/i18n'; import { Datum, PartitionFillLabel, @@ -125,11 +124,6 @@ export const getLayers = ( }, showAccessor: (d: Datum) => d !== EMPTY_SLICE, nodeLabel: (d: unknown) => { - if (d === '') { - return i18n.translate('visTypePie.emptyLabelValue', { - defaultMessage: '(empty)', - }); - } if (col.format) { const formattedLabel = formatter.deserialize(col.format).convert(d) ?? ''; if (visParams.labels.truncate && formattedLabel.length <= visParams.labels.truncate) { diff --git a/src/plugins/vis_type_xy/public/components/detailed_tooltip.tsx b/src/plugins/vis_type_xy/public/components/detailed_tooltip.tsx index c9ed82fcf58e55..fb6b4bb41d9baa 100644 --- a/src/plugins/vis_type_xy/public/components/detailed_tooltip.tsx +++ b/src/plugins/vis_type_xy/public/components/detailed_tooltip.tsx @@ -19,7 +19,6 @@ import { import { Aspects } from '../types'; import './_detailed_tooltip.scss'; -import { fillEmptyValue } from '../utils/get_series_name_fn'; import { COMPLEX_SPLIT_ACCESSOR, isRangeAggType } from '../utils/accessors'; interface TooltipData { @@ -100,8 +99,7 @@ export const getTooltipData = ( return data; }; -const renderData = ({ label, value: rawValue }: TooltipData, index: number) => { - const value = fillEmptyValue(rawValue); +const renderData = ({ label, value }: TooltipData, index: number) => { return label && value ? ( diff --git a/src/plugins/vis_type_xy/public/components/xy_settings.tsx b/src/plugins/vis_type_xy/public/components/xy_settings.tsx index 8922f512522a04..8d6a7eecdfe522 100644 --- a/src/plugins/vis_type_xy/public/components/xy_settings.tsx +++ b/src/plugins/vis_type_xy/public/components/xy_settings.tsx @@ -29,7 +29,6 @@ import { renderEndzoneTooltip } from '../../../charts/public'; import { getThemeService, getUISettings } from '../services'; import { VisConfig } from '../types'; -import { fillEmptyValue } from '../utils/get_series_name_fn'; declare global { interface Window { @@ -134,7 +133,7 @@ export const XYSettings: FC = ({ }; const headerValueFormatter: TickFormatter | undefined = xAxis.ticks?.formatter - ? (value) => fillEmptyValue(xAxis.ticks?.formatter?.(value)) ?? '' + ? (value) => xAxis.ticks?.formatter?.(value) ?? '' : undefined; const headerFormatter = isTimeChart && xDomain && adjustedXDomain diff --git a/src/plugins/vis_type_xy/public/config/get_axis.ts b/src/plugins/vis_type_xy/public/config/get_axis.ts index 08b17c882eea6b..71d33cc20d057f 100644 --- a/src/plugins/vis_type_xy/public/config/get_axis.ts +++ b/src/plugins/vis_type_xy/public/config/get_axis.ts @@ -27,7 +27,6 @@ import { YScaleType, SeriesParam, } from '../types'; -import { fillEmptyValue } from '../utils/get_series_name_fn'; export function getAxis( { type, title: axisTitle, labels, scale: axisScale, ...axis }: CategoryAxis, @@ -90,8 +89,7 @@ function getLabelFormatter( } return (value: any) => { - const formattedStringValue = `${formatter ? formatter(value) : value}`; - const finalValue = fillEmptyValue(formattedStringValue); + const finalValue = `${formatter ? formatter(value) : value}`; if (finalValue.length > truncate) { return `${finalValue.slice(0, truncate)}...`; diff --git a/src/plugins/vis_type_xy/public/utils/get_series_name_fn.ts b/src/plugins/vis_type_xy/public/utils/get_series_name_fn.ts index 0e54650e22f750..137f8a55580101 100644 --- a/src/plugins/vis_type_xy/public/utils/get_series_name_fn.ts +++ b/src/plugins/vis_type_xy/public/utils/get_series_name_fn.ts @@ -8,21 +8,10 @@ import { memoize } from 'lodash'; -import { i18n } from '@kbn/i18n'; import { XYChartSeriesIdentifier, SeriesName } from '@elastic/charts'; import { VisConfig } from '../types'; -const emptyTextLabel = i18n.translate('visTypeXy.emptyTextColumnValue', { - defaultMessage: '(empty)', -}); - -/** - * Returns empty values - */ -export const fillEmptyValue = (value: T) => - value === '' ? emptyTextLabel : value; - function getSplitValues( splitAccessors: XYChartSeriesIdentifier['splitAccessors'], seriesAspects?: VisConfig['aspects']['series'] @@ -36,7 +25,7 @@ function getSplitValues( const split = (seriesAspects ?? []).find(({ accessor }) => accessor === key); splitValues.push(split?.formatter ? split?.formatter(value) : value); }); - return splitValues.map(fillEmptyValue); + return splitValues; } export const getSeriesNameFn = (aspects: VisConfig['aspects'], multipleY = false) => diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/table_actions.ts b/x-pack/plugins/lens/public/datatable_visualization/components/table_actions.ts index 0d44ae3aa6dec3..8615ed65363160 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/table_actions.ts +++ b/x-pack/plugins/lens/public/datatable_visualization/components/table_actions.ts @@ -15,8 +15,6 @@ import type { LensToggleAction, } from './types'; import { ColumnConfig } from './table_basic'; - -import { desanitizeFilterContext } from '../../utils'; import { getOriginalId } from '../transpose_helpers'; export const createGridResizeHandler = ( @@ -92,7 +90,7 @@ export const createGridFilterHandler = ( timeFieldName, }; - onClickValue(desanitizeFilterContext(data)); + onClickValue(data); }; export const createTransposeColumnFilterHandler = ( @@ -125,7 +123,7 @@ export const createTransposeColumnFilterHandler = ( timeFieldName, }; - onClickValue(desanitizeFilterContext(data)); + onClickValue(data); }; export const createGridSortingConfig = ( diff --git a/x-pack/plugins/lens/public/heatmap_visualization/chart_component.tsx b/x-pack/plugins/lens/public/heatmap_visualization/chart_component.tsx index 3048f3b3db580c..8214d5ba129d40 100644 --- a/x-pack/plugins/lens/public/heatmap_visualization/chart_component.tsx +++ b/x-pack/plugins/lens/public/heatmap_visualization/chart_component.tsx @@ -21,7 +21,6 @@ import { VisualizationContainer } from '../visualization_container'; import { HeatmapRenderProps } from './types'; import './index.scss'; import { LensBrushEvent, LensFilterEvent } from '../types'; -import { desanitizeFilterContext } from '../utils'; import { EmptyPlaceholder } from '../shared_components'; import { LensIconChartHeatmap } from '../assets/chart_heatmap'; @@ -117,7 +116,7 @@ export const HeatmapComponent: FC = ({ })), timeFieldName, }; - onClickValue(desanitizeFilterContext(context)); + onClickValue(context); }) as ElementClickListener; const onBrushEnd = (e: HeatmapBrushEvent) => { @@ -164,7 +163,7 @@ export const HeatmapComponent: FC = ({ })), timeFieldName, }; - onClickValue(desanitizeFilterContext(context)); + onClickValue(context); } }; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/rename_columns.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/rename_columns.test.ts index 0750b99db5f674..5654a599c5e27b 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/rename_columns.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/rename_columns.test.ts @@ -83,29 +83,6 @@ describe('rename_columns', () => { `); }); - it('should replace "" with a visible value', () => { - const input: Datatable = { - type: 'datatable', - columns: [{ id: 'a', name: 'A', meta: { type: 'string' } }], - rows: [{ a: '' }], - }; - - const idMap = { - a: { - id: 'a', - label: 'Austrailia', - }, - }; - - const result = renameColumns.fn( - input, - { idMap: JSON.stringify(idMap) }, - createMockExecutionContext() - ); - - expect(result.rows[0].a).toEqual('(empty)'); - }); - it('should keep columns which are not mapped', () => { const input: Datatable = { type: 'datatable', diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/rename_columns.ts b/x-pack/plugins/lens/public/indexpattern_datasource/rename_columns.ts index 89c63880248d00..a16756126c030c 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/rename_columns.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/rename_columns.ts @@ -49,9 +49,9 @@ export const renameColumns: ExpressionFunctionDefinition< Object.entries(row).forEach(([id, value]) => { if (id in idMap) { - mappedRow[idMap[id].id] = sanitizeValue(value); + mappedRow[idMap[id].id] = value; } else { - mappedRow[id] = sanitizeValue(value); + mappedRow[id] = value; } }); @@ -86,13 +86,3 @@ function getColumnName(originalColumn: OriginalColumn, newColumn: DatatableColum return originalColumn.label; } - -function sanitizeValue(value: unknown) { - if (value === '') { - return i18n.translate('xpack.lens.indexpattern.emptyTextColumnValue', { - defaultMessage: '(empty)', - }); - } - - return value; -} diff --git a/x-pack/plugins/lens/public/pie_visualization/render_function.tsx b/x-pack/plugins/lens/public/pie_visualization/render_function.tsx index f329cfe1bb8b9d..2e5a06b4f705f0 100644 --- a/x-pack/plugins/lens/public/pie_visualization/render_function.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/render_function.tsx @@ -31,7 +31,6 @@ import { PieExpressionProps } from './types'; import { getSliceValue, getFilterContext } from './render_helpers'; import { EmptyPlaceholder } from '../shared_components'; import './visualization.scss'; -import { desanitizeFilterContext } from '../utils'; import { ChartsPluginSetup, PaletteRegistry, @@ -254,7 +253,7 @@ export function PieComponent( const onElementClickHandler: ElementClickListener = (args) => { const context = getFilterContext(args[0][0] as LayerValue[], groups, firstTable); - onClickValue(desanitizeFilterContext(context)); + onClickValue(context); }; return ( diff --git a/x-pack/plugins/lens/public/shared_components/legend_action_popover.tsx b/x-pack/plugins/lens/public/shared_components/legend_action_popover.tsx index e344cb5289f51e..5027629ef6ae56 100644 --- a/x-pack/plugins/lens/public/shared_components/legend_action_popover.tsx +++ b/x-pack/plugins/lens/public/shared_components/legend_action_popover.tsx @@ -9,7 +9,6 @@ import React, { useState } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiContextMenuPanelDescriptor, EuiIcon, EuiPopover, EuiContextMenu } from '@elastic/eui'; import type { LensFilterEvent } from '../types'; -import { desanitizeFilterContext } from '../utils'; export interface LegendActionPopoverProps { /** @@ -45,7 +44,7 @@ export const LegendActionPopover: React.FunctionComponent, onClick: () => { setPopoverOpen(false); - onFilter(desanitizeFilterContext(context)); + onFilter(context); }, }, { @@ -56,7 +55,7 @@ export const LegendActionPopover: React.FunctionComponent, onClick: () => { setPopoverOpen(false); - onFilter(desanitizeFilterContext({ ...context, negate: true })); + onFilter({ ...context, negate: true }); }, }, ], diff --git a/x-pack/plugins/lens/public/utils.test.ts b/x-pack/plugins/lens/public/utils.test.ts deleted file mode 100644 index 76597870b3beb8..00000000000000 --- a/x-pack/plugins/lens/public/utils.test.ts +++ /dev/null @@ -1,118 +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 { LensFilterEvent } from './types'; -import { desanitizeFilterContext } from './utils'; -import { Datatable } from '../../../../src/plugins/expressions/common'; - -describe('desanitizeFilterContext', () => { - it(`When filtered value equals '(empty)' replaces it with '' in table and in value.`, () => { - const table: Datatable = { - type: 'datatable', - rows: [ - { - 'f903668f-1175-4705-a5bd-713259d10326': 1589414640000, - '5d5446b2-72e8-4f86-91e0-88380f0fa14c': '(empty)', - 'col-1-9f0b6f88-c399-43a0-a993-0ad943c9af25': 1, - }, - { - 'f903668f-1175-4705-a5bd-713259d10326': 1589414670000, - 'col-1-9f0b6f88-c399-43a0-a993-0ad943c9af25': 0, - }, - { - 'f903668f-1175-4705-a5bd-713259d10326': 1589414880000, - '5d5446b2-72e8-4f86-91e0-88380f0fa14c': '123123123', - 'col-1-9f0b6f88-c399-43a0-a993-0ad943c9af25': 1, - }, - { - 'f903668f-1175-4705-a5bd-713259d10326': 1589414910000, - '5d5446b2-72e8-4f86-91e0-88380f0fa14c': '(empty)', - 'col-1-9f0b6f88-c399-43a0-a993-0ad943c9af25': 1, - }, - ], - columns: [ - { - id: 'f903668f-1175-4705-a5bd-713259d10326', - name: 'order_date per 30 seconds', - meta: { type: 'date' }, - }, - { - id: '5d5446b2-72e8-4f86-91e0-88380f0fa14c', - name: 'Top values of customer_phone', - meta: { type: 'string' }, - }, - { - id: '9f0b6f88-c399-43a0-a993-0ad943c9af25', - name: 'Count of records', - meta: { type: 'number' }, - }, - ], - }; - - const contextWithEmptyValue: LensFilterEvent['data'] = { - data: [ - { - row: 3, - column: 0, - value: 1589414910000, - table, - }, - { - row: 0, - column: 1, - value: '(empty)', - table, - }, - ], - timeFieldName: 'order_date', - }; - - const desanitizedFilterContext = desanitizeFilterContext(contextWithEmptyValue); - - expect(desanitizedFilterContext).toEqual({ - data: [ - { - row: 3, - column: 0, - value: 1589414910000, - table, - }, - { - value: '', - row: 0, - column: 1, - table: { - rows: [ - { - 'f903668f-1175-4705-a5bd-713259d10326': 1589414640000, - '5d5446b2-72e8-4f86-91e0-88380f0fa14c': '', - 'col-1-9f0b6f88-c399-43a0-a993-0ad943c9af25': 1, - }, - { - 'f903668f-1175-4705-a5bd-713259d10326': 1589414670000, - 'col-1-9f0b6f88-c399-43a0-a993-0ad943c9af25': 0, - }, - { - 'f903668f-1175-4705-a5bd-713259d10326': 1589414880000, - '5d5446b2-72e8-4f86-91e0-88380f0fa14c': '123123123', - 'col-1-9f0b6f88-c399-43a0-a993-0ad943c9af25': 1, - }, - { - 'f903668f-1175-4705-a5bd-713259d10326': 1589414910000, - '5d5446b2-72e8-4f86-91e0-88380f0fa14c': '(empty)', - 'col-1-9f0b6f88-c399-43a0-a993-0ad943c9af25': 1, - }, - ], - columns: table.columns, - type: 'datatable', - }, - }, - ], - timeFieldName: 'order_date', - }); - }); -}); diff --git a/x-pack/plugins/lens/public/utils.ts b/x-pack/plugins/lens/public/utils.ts index 2706fe977c68e0..1c4b2c67f96fcd 100644 --- a/x-pack/plugins/lens/public/utils.ts +++ b/x-pack/plugins/lens/public/utils.ts @@ -9,42 +9,6 @@ import { i18n } from '@kbn/i18n'; import { IndexPattern, IndexPatternsContract, TimefilterContract } from 'src/plugins/data/public'; import { IUiSettingsClient } from 'kibana/public'; import moment from 'moment-timezone'; -import { LensFilterEvent } from './types'; - -/** replaces the value `(empty) to empty string for proper filtering` */ -export const desanitizeFilterContext = ( - context: LensFilterEvent['data'] -): LensFilterEvent['data'] => { - const emptyTextValue = i18n.translate('xpack.lens.indexpattern.emptyTextColumnValue', { - defaultMessage: '(empty)', - }); - const result: LensFilterEvent['data'] = { - ...context, - data: context.data.map((point) => - point.value === emptyTextValue - ? { - ...point, - value: '', - table: { - ...point.table, - rows: point.table.rows.map((row, index) => - index === point.row - ? { - ...row, - [point.table.columns[point.column].id]: '', - } - : row - ), - }, - } - : point - ), - }; - if (context.timeFieldName) { - result.timeFieldName = context.timeFieldName; - } - return result; -}; export function getVisualizeGeoFieldMessage(fieldType: string) { return i18n.translate('xpack.lens.visualizeGeoFieldMessage', { diff --git a/x-pack/plugins/lens/public/xy_visualization/expression.tsx b/x-pack/plugins/lens/public/xy_visualization/expression.tsx index 1de5cf6b305335..3fe98282a18b0c 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression.tsx @@ -53,7 +53,6 @@ import { SeriesLayer, } from '../../../../../src/plugins/charts/public'; import { EmptyPlaceholder } from '../shared_components'; -import { desanitizeFilterContext } from '../utils'; import { fittingFunctionDefinitions, getFitOptions } from './fitting_functions'; import { getAxesConfiguration, GroupsConfiguration, validateExtent } from './axes_configuration'; import { getColorAssignments } from './color_assignment'; @@ -575,7 +574,7 @@ export function XYChart({ })), timeFieldName: xDomain && isDateField ? xAxisFieldName : undefined, }; - onClickValue(desanitizeFilterContext(context)); + onClickValue(context); }; const brushHandler: BrushEndListener = ({ x }) => { diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 87813b64f1f208..837716ec9dd5a8 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -5055,7 +5055,6 @@ "visTypeXy.editors.pointSeries.thresholdLine.valueLabel": "しきい値", "visTypeXy.editors.pointSeries.thresholdLine.widthLabel": "線の幅", "visTypeXy.editors.pointSeries.thresholdLineSettingsTitle": "しきい線", - "visTypeXy.emptyTextColumnValue": " (空) ", "visTypeXy.fittingFunctionsTitle.carry": "最後 (ギャップを最後の値で埋める) ", "visTypeXy.fittingFunctionsTitle.linear": "線形 (ギャップを線で埋める) ", "visTypeXy.fittingFunctionsTitle.lookahead": "次 (ギャップを次の値で埋める) ", @@ -12574,7 +12573,6 @@ "xpack.lens.indexPattern.emptyDimensionButton": "空のディメンション", "xpack.lens.indexPattern.emptyFieldsLabel": "空のフィールド", "xpack.lens.indexPattern.emptyFieldsLabelHelp": "空のフィールドには、フィルターに基づく最初の 500 件のドキュメントの値が含まれていませんでした。", - "xpack.lens.indexpattern.emptyTextColumnValue": " (空) ", "xpack.lens.indexPattern.existenceErrorAriaLabel": "存在の取り込みに失敗しました", "xpack.lens.indexPattern.existenceErrorLabel": "フィールド情報を読み込めません", "xpack.lens.indexPattern.existenceTimeoutAriaLabel": "存在の取り込みがタイムアウトしました", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 4e98de541ce603..0192566db07312 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -5083,7 +5083,6 @@ "visTypeXy.editors.pointSeries.thresholdLine.valueLabel": "阈值", "visTypeXy.editors.pointSeries.thresholdLine.widthLabel": "线条宽度", "visTypeXy.editors.pointSeries.thresholdLineSettingsTitle": "阈值线条", - "visTypeXy.emptyTextColumnValue": " (空) ", "visTypeXy.fittingFunctionsTitle.carry": "最后一个 (使用最后一个值填充缺口) ", "visTypeXy.fittingFunctionsTitle.linear": "线 (使用线填充缺口) ", "visTypeXy.fittingFunctionsTitle.lookahead": "下一个 (使用下一个值填充缺口) ", @@ -12744,7 +12743,6 @@ "xpack.lens.indexPattern.emptyDimensionButton": "空维度", "xpack.lens.indexPattern.emptyFieldsLabel": "空字段", "xpack.lens.indexPattern.emptyFieldsLabelHelp": "空字段在基于您的筛选的前 500 个文档中不包含任何值。", - "xpack.lens.indexpattern.emptyTextColumnValue": " (空) ", "xpack.lens.indexPattern.existenceErrorAriaLabel": "现有内容提取失败", "xpack.lens.indexPattern.existenceErrorLabel": "无法加载字段信息", "xpack.lens.indexPattern.existenceTimeoutAriaLabel": "现有内容提取超时", diff --git a/x-pack/test/functional/apps/discover/__snapshots__/reporting.snap b/x-pack/test/functional/apps/discover/__snapshots__/reporting.snap index baa49cb6f9d819..c7666bf00dd53e 100644 --- a/x-pack/test/functional/apps/discover/__snapshots__/reporting.snap +++ b/x-pack/test/functional/apps/discover/__snapshots__/reporting.snap @@ -65,7 +65,7 @@ exports[`discover Discover CSV Export Generate CSV: archived search generates a exports[`discover Discover CSV Export Generate CSV: new search generates a report from a new search with data: default 1`] = ` "\\"_id\\",\\"_index\\",\\"_score\\",\\"_type\\",category,\\"category.keyword\\",currency,\\"customer_first_name\\",\\"customer_first_name.keyword\\",\\"customer_full_name\\",\\"customer_full_name.keyword\\",\\"customer_gender\\",\\"customer_id\\",\\"customer_last_name\\",\\"customer_last_name.keyword\\",\\"customer_phone\\",\\"day_of_week\\",\\"day_of_week_i\\",email,\\"geoip.city_name\\",\\"geoip.continent_name\\",\\"geoip.country_iso_code\\",\\"geoip.location\\",\\"geoip.region_name\\",manufacturer,\\"manufacturer.keyword\\",\\"order_date\\",\\"order_id\\",\\"products._id\\",\\"products._id.keyword\\",\\"products.base_price\\",\\"products.base_unit_price\\",\\"products.category\\",\\"products.category.keyword\\",\\"products.created_on\\",\\"products.discount_amount\\",\\"products.discount_percentage\\",\\"products.manufacturer\\",\\"products.manufacturer.keyword\\",\\"products.min_price\\",\\"products.price\\",\\"products.product_id\\",\\"products.product_name\\",\\"products.product_name.keyword\\",\\"products.quantity\\",\\"products.sku\\",\\"products.tax_amount\\",\\"products.taxful_price\\",\\"products.taxless_price\\",\\"products.unit_discount_amount\\",sku,\\"taxful_total_price\\",\\"taxless_total_price\\",\\"total_quantity\\",\\"total_unique_products\\",type,user -3AMtOW0BH63Xcmy432DJ,ecommerce,\\"-\\",\\"-\\",\\"Men's Shoes, Men's Clothing, Women's Accessories, Men's Accessories\\",\\"Men's Shoes, Men's Clothing, Women's Accessories, Men's Accessories\\",EUR,\\"Sultan Al\\",\\"Sultan Al\\",\\"Sultan Al Boone\\",\\"Sultan Al Boone\\",MALE,19,Boone,Boone,,Saturday,5,\\"sultan al@boone-family.zzz\\",\\"Abu Dhabi\\",Asia,AE,\\"{ +3AMtOW0BH63Xcmy432DJ,ecommerce,\\"-\\",\\"-\\",\\"Men's Shoes, Men's Clothing, Women's Accessories, Men's Accessories\\",\\"Men's Shoes, Men's Clothing, Women's Accessories, Men's Accessories\\",EUR,\\"Sultan Al\\",\\"Sultan Al\\",\\"Sultan Al Boone\\",\\"Sultan Al Boone\\",MALE,19,Boone,Boone,\\"(empty)\\",Saturday,5,\\"sultan al@boone-family.zzz\\",\\"Abu Dhabi\\",Asia,AE,\\"{ \\"\\"coordinates\\"\\": [ 54.4, 24.5 @@ -77,7 +77,7 @@ exports[`discover Discover CSV Export Generate CSV: new search generates a repor exports[`discover Discover CSV Export Generate CSV: new search generates a report from a new search with data: discover:searchFieldsFromSource 1`] = ` "\\"_id\\",\\"_index\\",\\"_score\\",\\"_type\\",category,\\"category.keyword\\",currency,\\"customer_first_name\\",\\"customer_first_name.keyword\\",\\"customer_full_name\\",\\"customer_full_name.keyword\\",\\"customer_gender\\",\\"customer_id\\",\\"customer_last_name\\",\\"customer_last_name.keyword\\",\\"customer_phone\\",\\"day_of_week\\",\\"day_of_week_i\\",email,\\"geoip.city_name\\",\\"geoip.continent_name\\",\\"geoip.country_iso_code\\",\\"geoip.location\\",\\"geoip.region_name\\",manufacturer,\\"manufacturer.keyword\\",\\"order_date\\",\\"order_id\\",\\"products._id\\",\\"products._id.keyword\\",\\"products.base_price\\",\\"products.base_unit_price\\",\\"products.category\\",\\"products.category.keyword\\",\\"products.created_on\\",\\"products.discount_amount\\",\\"products.discount_percentage\\",\\"products.manufacturer\\",\\"products.manufacturer.keyword\\",\\"products.min_price\\",\\"products.price\\",\\"products.product_id\\",\\"products.product_name\\",\\"products.product_name.keyword\\",\\"products.quantity\\",\\"products.sku\\",\\"products.tax_amount\\",\\"products.taxful_price\\",\\"products.taxless_price\\",\\"products.unit_discount_amount\\",sku,\\"taxful_total_price\\",\\"taxless_total_price\\",\\"total_quantity\\",\\"total_unique_products\\",type,user -3AMtOW0BH63Xcmy432DJ,ecommerce,\\"-\\",\\"-\\",\\"Men's Shoes, Men's Clothing, Women's Accessories, Men's Accessories\\",\\"Men's Shoes, Men's Clothing, Women's Accessories, Men's Accessories\\",EUR,\\"Sultan Al\\",\\"Sultan Al\\",\\"Sultan Al Boone\\",\\"Sultan Al Boone\\",MALE,19,Boone,Boone,,Saturday,5,\\"sultan al@boone-family.zzz\\",\\"Abu Dhabi\\",Asia,AE,\\"{ +3AMtOW0BH63Xcmy432DJ,ecommerce,\\"-\\",\\"-\\",\\"Men's Shoes, Men's Clothing, Women's Accessories, Men's Accessories\\",\\"Men's Shoes, Men's Clothing, Women's Accessories, Men's Accessories\\",EUR,\\"Sultan Al\\",\\"Sultan Al\\",\\"Sultan Al Boone\\",\\"Sultan Al Boone\\",MALE,19,Boone,Boone,\\"(empty)\\",Saturday,5,\\"sultan al@boone-family.zzz\\",\\"Abu Dhabi\\",Asia,AE,\\"{ \\"\\"coordinates\\"\\": [ 54.4, 24.5 From 4c2449fd2860ad7757fd210788563f2bede86354 Mon Sep 17 00:00:00 2001 From: Ignacio Rivas Date: Thu, 24 Jun 2021 11:55:28 +0200 Subject: [PATCH 04/16] [Ingest pipelines] add extract_device_type to user agent processor (#100986) * testing layouts * fix copy for beta badge * replace hardcoded text with i18n strings * avoid updating types and just replace label * Small cr changes * get rid of style prop and just use a smaller badge --- .../__jest__/processors/processor.helpers.tsx | 3 + .../__jest__/processors/user_agent.test.tsx | 125 ++++++++++++++++++ .../common_fields/properties_field.tsx | 13 +- .../processor_form/processors/user_agent.tsx | 62 ++++++++- 4 files changed, 188 insertions(+), 15 deletions(-) create mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/user_agent.test.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/processor.helpers.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/processor.helpers.tsx index 15e8c323b1308e..5f6ace20694104 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/processor.helpers.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/processor.helpers.tsx @@ -156,6 +156,9 @@ type TestSubject = | 'separatorValueField.input' | 'quoteValueField.input' | 'emptyValueField.input' + | 'extractDeviceTypeSwitch.input' + | 'propertiesValueField' + | 'regexFileField.input' | 'valueFieldInput' | 'mediaTypeSelectorField' | 'ignoreEmptyField.input' diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/user_agent.test.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/user_agent.test.tsx new file mode 100644 index 00000000000000..fa1c24c9dfb392 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/user_agent.test.tsx @@ -0,0 +1,125 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act } from 'react-dom/test-utils'; +import { setup, SetupResult, getProcessorValue } from './processor.helpers'; + +// Default parameter values automatically added to the user agent processor when saved +const defaultUserAgentParameters = { + if: undefined, + regex_file: undefined, + properties: undefined, + description: undefined, + ignore_missing: undefined, + ignore_failure: undefined, + extract_device_type: undefined, +}; + +const USER_AGENT_TYPE = 'user_agent'; + +describe('Processor: User Agent', () => { + let onUpdate: jest.Mock; + let testBed: SetupResult; + + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + beforeEach(async () => { + onUpdate = jest.fn(); + + await act(async () => { + testBed = await setup({ + value: { + processors: [], + }, + onFlyoutOpen: jest.fn(), + onUpdate, + }); + }); + + testBed.component.update(); + + // Open flyout to add new processor + testBed.actions.addProcessor(); + // Add type (the other fields are not visible until a type is selected) + await testBed.actions.addProcessorType(USER_AGENT_TYPE); + }); + + test('prevents form submission if required fields are not provided', async () => { + const { + actions: { saveNewProcessor }, + form, + } = testBed; + + // Click submit button with only the processor type defined + await saveNewProcessor(); + + // Expect form error as "field" is required parameter + expect(form.getErrorsMessages()).toEqual(['A field value is required.']); + }); + + test('saves with just the default parameter value', async () => { + const { + actions: { saveNewProcessor }, + form, + } = testBed; + + // Add "field" value (required) + form.setInputValue('fieldNameField.input', 'field_1'); + // Save the field + await saveNewProcessor(); + + const processors = getProcessorValue(onUpdate, USER_AGENT_TYPE); + expect(processors[0][USER_AGENT_TYPE]).toEqual({ + ...defaultUserAgentParameters, + field: 'field_1', + }); + }); + + test('allows optional parameters to be set', async () => { + const { + actions: { saveNewProcessor }, + form, + find, + component, + } = testBed; + + // Add "field" value (required) + form.setInputValue('fieldNameField.input', 'field_1'); + + // Set optional parameteres + form.setInputValue('targetField.input', 'target_field'); + form.setInputValue('regexFileField.input', 'hello*'); + form.toggleEuiSwitch('ignoreMissingSwitch.input'); + form.toggleEuiSwitch('ignoreFailureSwitch.input'); + form.toggleEuiSwitch('extractDeviceTypeSwitch.input'); + await act(async () => { + find('propertiesValueField').simulate('change', [{ label: 'os' }]); + }); + component.update(); + + // Save the field with new changes + await saveNewProcessor(); + + const processors = getProcessorValue(onUpdate, USER_AGENT_TYPE); + expect(processors[0][USER_AGENT_TYPE]).toEqual({ + ...defaultUserAgentParameters, + field: 'field_1', + target_field: 'target_field', + properties: ['os'], + regex_file: 'hello*', + extract_device_type: true, + ignore_missing: true, + ignore_failure: true, + }); + }); +}); diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/common_fields/properties_field.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/common_fields/properties_field.tsx index dd52375a19436d..c8a50cf64484e4 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/common_fields/properties_field.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/common_fields/properties_field.tsx @@ -6,9 +6,9 @@ */ import React, { FunctionComponent } from 'react'; +import { EuiComboBoxProps } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { EuiComboBoxOptionOption } from '@elastic/eui'; import { ComboBoxField, FIELD_TYPES, UseField } from '../../../../../../../shared_imports'; import { FieldsConfig, to } from '../shared'; @@ -29,10 +29,10 @@ const fieldsConfig: FieldsConfig = { interface Props { helpText?: React.ReactNode; - propertyOptions?: EuiComboBoxOptionOption[]; + euiFieldProps?: EuiComboBoxProps; } -export const PropertiesField: FunctionComponent = ({ helpText, propertyOptions }) => { +export const PropertiesField: FunctionComponent = ({ helpText, euiFieldProps }) => { return ( = ({ helpText, propertyOp }} component={ComboBoxField} path="fields.properties" - componentProps={{ - euiFieldProps: { - options: propertyOptions || [], - noSuggestions: !propertyOptions, - }, - }} + componentProps={{ euiFieldProps }} /> ); }; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/user_agent.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/user_agent.tsx index 893e52bcc0073e..2b5a68f799b7e5 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/user_agent.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/user_agent.tsx @@ -6,20 +6,20 @@ */ import React, { FunctionComponent } from 'react'; -import { EuiCode } from '@elastic/eui'; +import { EuiCode, EuiBetaBadge, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { EuiComboBoxOptionOption } from '@elastic/eui'; -import { FIELD_TYPES, UseField, Field } from '../../../../../../shared_imports'; +import { FIELD_TYPES, ToggleField, UseField, Field } from '../../../../../../shared_imports'; -import { FieldsConfig, from } from './shared'; +import { FieldsConfig, from, to } from './shared'; import { IgnoreMissingField } from './common_fields/ignore_missing_field'; import { FieldNameField } from './common_fields/field_name_field'; import { TargetField } from './common_fields/target_field'; import { PropertiesField } from './common_fields/properties_field'; -const propertyOptions: EuiComboBoxOptionOption[] = [ +const propertyOptions: Array> = [ { label: 'name' }, { label: 'os' }, { label: 'device' }, @@ -47,6 +47,18 @@ const fieldsConfig: FieldsConfig = { } ), }, + extract_device_type: { + type: FIELD_TYPES.TOGGLE, + defaultValue: false, + deserializer: to.booleanOrUndef, + serializer: from.undefinedIfValue(false), + helpText: i18n.translate( + 'xpack.ingestPipelines.pipelineEditor.userAgentForm.extractDeviceTypeFieldHelpText', + { + defaultMessage: 'Extracts device type from the user agent string.', + } + ), + }, }; export const UserAgent: FunctionComponent = () => { @@ -59,7 +71,12 @@ export const UserAgent: FunctionComponent = () => { )} /> - + { 'xpack.ingestPipelines.pipelineEditor.userAgentForm.propertiesFieldHelpText', { defaultMessage: 'Properties added to the target field.' } )} - propertyOptions={propertyOptions} + euiFieldProps={{ + options: propertyOptions, + noSuggestions: false, + 'data-test-subj': 'propertiesValueField', + }} + /> + + + + + + + + + + ), + }} /> From 9a1f5a4a7a81603f1b11ff722c3c1c3707e00ca9 Mon Sep 17 00:00:00 2001 From: Peter Pisljar Date: Thu, 24 Jun 2021 12:23:41 +0200 Subject: [PATCH 05/16] switching to peggy (#103169) --- packages/kbn-interpreter/BUILD.bazel | 8 ++++---- .../grammar/{grammar.pegjs => grammar.peggy} | 0 2 files changed, 4 insertions(+), 4 deletions(-) rename packages/kbn-interpreter/grammar/{grammar.pegjs => grammar.peggy} (100%) diff --git a/packages/kbn-interpreter/BUILD.bazel b/packages/kbn-interpreter/BUILD.bazel index 4492faabfdf81e..c29faf65638ca2 100644 --- a/packages/kbn-interpreter/BUILD.bazel +++ b/packages/kbn-interpreter/BUILD.bazel @@ -1,5 +1,5 @@ load("@npm//@bazel/typescript:index.bzl", "ts_config", "ts_project") -load("@npm//pegjs:index.bzl", "pegjs") +load("@npm//peggy:index.bzl", "peggy") load("@build_bazel_rules_nodejs//:index.bzl", "js_library", "pkg_npm") PKG_BASE_NAME = "kbn-interpreter" @@ -37,10 +37,10 @@ TYPES_DEPS = [ DEPS = SRC_DEPS + TYPES_DEPS -pegjs( +peggy( name = "grammar", data = [ - ":grammar/grammar.pegjs" + ":grammar/grammar.peggy" ], output_dir = True, args = [ @@ -48,7 +48,7 @@ pegjs( "expression,argument", "-o", "$(@D)/index.js", - "./%s/grammar/grammar.pegjs" % package_name() + "./%s/grammar/grammar.peggy" % package_name() ], ) diff --git a/packages/kbn-interpreter/grammar/grammar.pegjs b/packages/kbn-interpreter/grammar/grammar.peggy similarity index 100% rename from packages/kbn-interpreter/grammar/grammar.pegjs rename to packages/kbn-interpreter/grammar/grammar.peggy From fa71c6d7ac5abfcf79428fc120561d7cc9beb22f Mon Sep 17 00:00:00 2001 From: Pete Harverson Date: Thu, 24 Jun 2021 11:31:30 +0100 Subject: [PATCH 06/16] [ML] Transforms: Converts management pages to new layout (#102648) * [ML] Transforms: Converts management pages to new layout * [ML] Fix vertical centering of error state in app Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- x-pack/plugins/transform/public/app/app.tsx | 36 ++-- .../public/app/components/section_error.tsx | 18 +- .../components/with_privileges.tsx | 44 ++--- .../clone_transform_section.tsx | 77 +++++---- .../create_transform_section.tsx | 73 ++++---- .../transform_list.test.tsx.snap | 57 ++++--- .../transform_list/transform_list.tsx | 54 +++--- .../transform_management_section.tsx | 156 ++++++++++-------- 8 files changed, 287 insertions(+), 228 deletions(-) diff --git a/x-pack/plugins/transform/public/app/app.tsx b/x-pack/plugins/transform/public/app/app.tsx index d4936783a02973..9219f29e4d9f06 100644 --- a/x-pack/plugins/transform/public/app/app.tsx +++ b/x-pack/plugins/transform/public/app/app.tsx @@ -10,7 +10,7 @@ import { render, unmountComponentAtNode } from 'react-dom'; import { Router, Route, Switch } from 'react-router-dom'; import { ScopedHistory } from 'kibana/public'; -import { EuiErrorBoundary } from '@elastic/eui'; +import { EuiErrorBoundary, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -35,7 +35,7 @@ export const App: FC<{ history: ScopedHistory }> = ({ history }) => { title={ } error={apiError} @@ -44,21 +44,23 @@ export const App: FC<{ history: ScopedHistory }> = ({ history }) => { } return ( -
- - - - - - - -
+ + + + + + + + + + + ); }; diff --git a/x-pack/plugins/transform/public/app/components/section_error.tsx b/x-pack/plugins/transform/public/app/components/section_error.tsx index 2af0c19fb88178..964c13d775d4bb 100644 --- a/x-pack/plugins/transform/public/app/components/section_error.tsx +++ b/x-pack/plugins/transform/public/app/components/section_error.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { EuiCallOut } from '@elastic/eui'; +import { EuiEmptyPrompt, EuiPageContent } from '@elastic/eui'; import React from 'react'; interface Props { @@ -23,9 +23,17 @@ export const SectionError: React.FunctionComponent = ({ const errorMessage = error?.message ?? JSON.stringify(error, null, 2); return ( - -
{errorMessage}
- {actions ? actions : null} -
+ + {title}} + body={ +

+

{errorMessage}
+ {actions ? actions : null} +

+ } + /> +
); }; diff --git a/x-pack/plugins/transform/public/app/lib/authorization/components/with_privileges.tsx b/x-pack/plugins/transform/public/app/lib/authorization/components/with_privileges.tsx index ef009e6a125e76..cdf4407b4233f3 100644 --- a/x-pack/plugins/transform/public/app/lib/authorization/components/with_privileges.tsx +++ b/x-pack/plugins/transform/public/app/lib/authorization/components/with_privileges.tsx @@ -7,7 +7,7 @@ import React, { useContext, FC } from 'react'; -import { EuiPageContent } from '@elastic/eui'; +import { EuiFlexItem, EuiFlexGroup, EuiPageContent } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -74,27 +74,31 @@ const MissingClusterPrivileges: FC = ({ missingPrivileges, privilegesCount, }) => ( - - - } - message={ - + + + + } + message={ + + } /> - } - /> - + + + ); export const PrivilegesWrapper: FC<{ privileges: string | string[] }> = ({ diff --git a/x-pack/plugins/transform/public/app/sections/clone_transform/clone_transform_section.tsx b/x-pack/plugins/transform/public/app/sections/clone_transform/clone_transform_section.tsx index e4ecc0418d7820..8aecf403186c5c 100644 --- a/x-pack/plugins/transform/public/app/sections/clone_transform/clone_transform_section.tsx +++ b/x-pack/plugins/transform/public/app/sections/clone_transform/clone_transform_section.tsx @@ -15,12 +15,9 @@ import { i18n } from '@kbn/i18n'; import { EuiButtonEmpty, EuiCallOut, - EuiFlexGroup, - EuiFlexItem, - EuiPageContent, EuiPageContentBody, + EuiPageHeader, EuiSpacer, - EuiTitle, } from '@elastic/eui'; import { APP_CREATE_TRANSFORM_CLUSTER_PRIVILEGES } from '../../../../common/constants'; @@ -105,37 +102,38 @@ export const CloneTransformSection: FC = ({ match, location }) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + const docsLink = ( + + + + ); + return ( - - - - -

- -

-
- - - - - -
-
- - - {typeof errorMessage !== 'undefined' && ( + + } + rightSideItems={[docsLink]} + bottomBorder + /> + + + + + {typeof errorMessage !== 'undefined' && ( + <> = ({ match, location }) => { >
{JSON.stringify(errorMessage)}
- )} - {searchItems !== undefined && isInitialized === true && transformConfig !== undefined && ( - - )} -
-
+ + + )} + {searchItems !== undefined && isInitialized === true && transformConfig !== undefined && ( + + )} +
); }; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/create_transform_section.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/create_transform_section.tsx index b88eb8ce48601e..d736bd60f2df64 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/create_transform_section.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/create_transform_section.tsx @@ -13,12 +13,9 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { EuiButtonEmpty, EuiCallOut, - EuiFlexGroup, - EuiFlexItem, - EuiPageContent, EuiPageContentBody, + EuiPageHeader, EuiSpacer, - EuiTitle, } from '@elastic/eui'; import { APP_CREATE_TRANSFORM_CLUSTER_PRIVILEGES } from '../../../../common/constants'; @@ -42,42 +39,44 @@ export const CreateTransformSection: FC = ({ match }) => { const { error: searchItemsError, searchItems } = useSearchItems(match.params.savedObjectId); + const docsLink = ( + + + + ); + return ( - - - - -

- -

-
- - - - - -
-
- - - {searchItemsError !== undefined && ( + + } + rightSideItems={[docsLink]} + bottomBorder + /> + + + + + {searchItemsError !== undefined && ( + <> - )} - {searchItems !== undefined && } - -
+ + + )} + {searchItems !== undefined && } +
); }; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/__snapshots__/transform_list.test.tsx.snap b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/__snapshots__/transform_list.test.tsx.snap index e2de4c0ea1f6c0..cf80421711355f 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/__snapshots__/transform_list.test.tsx.snap +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/__snapshots__/transform_list.test.tsx.snap @@ -1,23 +1,42 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Transform: Transform List Minimal initialization 1`] = ` - - Create your first transform - , - ] - } - data-test-subj="transformNoTransformsFound" - title={ -

- No transforms found -

- } -/> + + + + + + Create your first transform + , + ] + } + data-test-subj="transformNoTransformsFound" + title={ +

+ No transforms found +

+ } + /> +
+
+
`; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transform_list.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transform_list.tsx index bacf8f9deccae3..ab30f4793a3158 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transform_list.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transform_list.tsx @@ -10,12 +10,15 @@ import React, { MouseEventHandler, FC, useContext, useState } from 'react'; import { i18n } from '@kbn/i18n'; import { + EuiButton, EuiButtonEmpty, EuiButtonIcon, EuiEmptyPrompt, EuiFlexGroup, EuiFlexItem, + EuiPageContent, EuiPopover, + EuiSpacer, EuiTitle, EuiInMemoryTable, EuiSearchBarProps, @@ -135,27 +138,36 @@ export const TransformList: FC = ({ if (transforms.length === 0) { return ( - - {i18n.translate('xpack.transform.list.emptyPromptTitle', { - defaultMessage: 'No transforms found', - })} - - } - actions={[ - - {i18n.translate('xpack.transform.list.emptyPromptButtonText', { - defaultMessage: 'Create your first transform', - })} - , - ]} - data-test-subj="transformNoTransformsFound" - /> + + + + + + {i18n.translate('xpack.transform.list.emptyPromptTitle', { + defaultMessage: 'No transforms found', + })} + + } + actions={[ + + {i18n.translate('xpack.transform.list.emptyPromptButtonText', { + defaultMessage: 'Create your first transform', + })} + , + ]} + data-test-subj="transformNoTransformsFound" + /> + + + ); } diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/transform_management_section.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/transform_management_section.tsx index cc4c502f21eb59..2479d34f1579a7 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/transform_management_section.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/transform_management_section.tsx @@ -5,23 +5,21 @@ * 2.0. */ -import React, { FC, Fragment, useEffect, useState } from 'react'; +import React, { FC, useEffect, useState } from 'react'; -import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiButtonEmpty, - EuiCallOut, + EuiEmptyPrompt, EuiFlexGroup, EuiFlexItem, EuiLoadingContent, EuiModal, EuiPageContent, EuiPageContentBody, + EuiPageHeader, EuiSpacer, - EuiText, - EuiTitle, } from '@elastic/eui'; import { APP_GET_TRANSFORM_CLUSTER_PRIVILEGES } from '../../../../common/constants'; @@ -77,73 +75,91 @@ export const TransformManagement: FC = () => { setSavedObjectId(id); }; + const docsLink = ( + + + + ); + return ( - - - - - -

- -

-
- - - - - -
-
- - - + <> + - - - - - {!isInitialized && } - {isInitialized && ( - <> - - - {typeof errorMessage !== 'undefined' && ( - -
{JSON.stringify(errorMessage)}
-
- )} - {typeof errorMessage === 'undefined' && ( - - )} - - )} -
-
+ + } + description={ + + } + rightSideItems={[docsLink]} + bottomBorder + /> + + + + + {!isInitialized && } + {isInitialized && ( + <> + + + {typeof errorMessage !== 'undefined' && ( + + + + + + + + } + body={ +

+

{JSON.stringify(errorMessage)}
+

+ } + actions={[]} + /> +
+
+
+ )} + {typeof errorMessage === 'undefined' && ( + + )} + + )} +
+ {isSearchSelectionVisible && ( { )} -
+ ); }; From d44f9fe6e6fa85ca411ac9c9e89c7e6711d09753 Mon Sep 17 00:00:00 2001 From: Nathan L Smith Date: Thu, 24 Jun 2021 06:29:53 -0500 Subject: [PATCH 07/16] Use observability plugin breadcrumbs in APM (#103168) Both APM and Observability plugins have a `useBreadcrumbs` hook. APM's takes the whole list of route definitions, creates the whole path of breadcrumbs, and has an effect to set the breadcrumbs and the page title. The Observability plugin's `useBreadcrumbs` just takes an array of breadcrumb objects, adds onclick handlers for them, and has an effect to set the breadcrumbs and the page title. Rename APM's `useBreadcrumbs` to `useApmBreadcrumbs`. It still constructs the path based on the routes and the current route, but then just calls out to the Observability plugin's `useBreadcrumbs` to do the breadcrumb and title setting. Now all APM breadcrumbs begin with "Observability" which links to the Observability overview, but the rest of them remain the same. --- .../public/components/routing/app_root.tsx | 4 +- .../apm_plugin/mock_apm_plugin_context.tsx | 1 + ....test.tsx => use_apm_breadcrumbs.test.tsx} | 58 ++++--------------- ..._breadcrumbs.ts => use_apm_breadcrumbs.ts} | 29 ++-------- 4 files changed, 18 insertions(+), 74 deletions(-) rename x-pack/plugins/apm/public/hooks/{use_breadcrumbs.test.tsx => use_apm_breadcrumbs.test.tsx} (79%) rename x-pack/plugins/apm/public/hooks/{use_breadcrumbs.ts => use_apm_breadcrumbs.ts} (85%) diff --git a/x-pack/plugins/apm/public/components/routing/app_root.tsx b/x-pack/plugins/apm/public/components/routing/app_root.tsx index 2bb387ae315ff7..8fc59a01eeca04 100644 --- a/x-pack/plugins/apm/public/components/routing/app_root.tsx +++ b/x-pack/plugins/apm/public/components/routing/app_root.tsx @@ -24,7 +24,7 @@ import { } from '../../context/apm_plugin/apm_plugin_context'; import { LicenseProvider } from '../../context/license/license_context'; import { UrlParamsProvider } from '../../context/url_params_context/url_params_context'; -import { useBreadcrumbs } from '../../hooks/use_breadcrumbs'; +import { useApmBreadcrumbs } from '../../hooks/use_apm_breadcrumbs'; import { ApmPluginStartDeps } from '../../plugin'; import { HeaderMenuPortal } from '../../../../observability/public'; import { ApmHeaderActionMenu } from '../shared/apm_header_action_menu'; @@ -79,7 +79,7 @@ export function ApmAppRoot({ } function MountApmHeaderActionMenu() { - useBreadcrumbs(apmRouteConfig); + useApmBreadcrumbs(apmRouteConfig); const { setHeaderActionMenu } = useApmPluginContext().appMountParameters; return ( diff --git a/x-pack/plugins/apm/public/context/apm_plugin/mock_apm_plugin_context.tsx b/x-pack/plugins/apm/public/context/apm_plugin/mock_apm_plugin_context.tsx index a16f81826636ba..bcc1932dde7cbd 100644 --- a/x-pack/plugins/apm/public/context/apm_plugin/mock_apm_plugin_context.tsx +++ b/x-pack/plugins/apm/public/context/apm_plugin/mock_apm_plugin_context.tsx @@ -44,6 +44,7 @@ const mockCore = { ml: {}, }, currentAppId$: new Observable(), + getUrlForApp: (appId: string) => '', navigateToUrl: (url: string) => {}, }, chrome: { diff --git a/x-pack/plugins/apm/public/hooks/use_breadcrumbs.test.tsx b/x-pack/plugins/apm/public/hooks/use_apm_breadcrumbs.test.tsx similarity index 79% rename from x-pack/plugins/apm/public/hooks/use_breadcrumbs.test.tsx rename to x-pack/plugins/apm/public/hooks/use_apm_breadcrumbs.test.tsx index 64990651b52bbd..1cdb84c3247501 100644 --- a/x-pack/plugins/apm/public/hooks/use_breadcrumbs.test.tsx +++ b/x-pack/plugins/apm/public/hooks/use_apm_breadcrumbs.test.tsx @@ -15,14 +15,15 @@ import { mockApmPluginContextValue, MockApmPluginContextWrapper, } from '../context/apm_plugin/mock_apm_plugin_context'; -import { useBreadcrumbs } from './use_breadcrumbs'; +import { useApmBreadcrumbs } from './use_apm_breadcrumbs'; +import { useBreadcrumbs } from '../../../observability/public'; + +jest.mock('../../../observability/public'); function createWrapper(path: string) { return ({ children }: { children?: ReactNode }) => { const value = (produce(mockApmPluginContextValue, (draft) => { draft.core.application.navigateToUrl = (url: string) => Promise.resolve(); - draft.core.chrome.docTitle.change = changeTitle; - draft.core.chrome.setBreadcrumbs = setBreadcrumbs; }) as unknown) as ApmPluginContextValue; return ( @@ -36,27 +37,18 @@ function createWrapper(path: string) { } function mountBreadcrumb(path: string) { - renderHook(() => useBreadcrumbs(apmRouteConfig), { + renderHook(() => useApmBreadcrumbs(apmRouteConfig), { wrapper: createWrapper(path), }); } -const changeTitle = jest.fn(); -const setBreadcrumbs = jest.fn(); - -describe('useBreadcrumbs', () => { - it('changes the page title', () => { - mountBreadcrumb('/'); - - expect(changeTitle).toHaveBeenCalledWith(['APM']); - }); - +describe('useApmBreadcrumbs', () => { test('/services/:serviceName/errors/:groupId', () => { mountBreadcrumb( '/services/opbeans-node/errors/myGroupId?kuery=myKuery&rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0' ); - expect(setBreadcrumbs).toHaveBeenCalledWith( + expect(useBreadcrumbs).toHaveBeenCalledWith( expect.arrayContaining([ expect.objectContaining({ text: 'APM', @@ -81,20 +73,12 @@ describe('useBreadcrumbs', () => { expect.objectContaining({ text: 'myGroupId', href: undefined }), ]) ); - - expect(changeTitle).toHaveBeenCalledWith([ - 'myGroupId', - 'Errors', - 'opbeans-node', - 'Services', - 'APM', - ]); }); test('/services/:serviceName/errors', () => { mountBreadcrumb('/services/opbeans-node/errors?kuery=myKuery'); - expect(setBreadcrumbs).toHaveBeenCalledWith( + expect(useBreadcrumbs).toHaveBeenCalledWith( expect.arrayContaining([ expect.objectContaining({ text: 'APM', @@ -111,19 +95,12 @@ describe('useBreadcrumbs', () => { expect.objectContaining({ text: 'Errors', href: undefined }), ]) ); - - expect(changeTitle).toHaveBeenCalledWith([ - 'Errors', - 'opbeans-node', - 'Services', - 'APM', - ]); }); test('/services/:serviceName/transactions', () => { mountBreadcrumb('/services/opbeans-node/transactions?kuery=myKuery'); - expect(setBreadcrumbs).toHaveBeenCalledWith( + expect(useBreadcrumbs).toHaveBeenCalledWith( expect.arrayContaining([ expect.objectContaining({ text: 'APM', @@ -140,13 +117,6 @@ describe('useBreadcrumbs', () => { expect.objectContaining({ text: 'Transactions', href: undefined }), ]) ); - - expect(changeTitle).toHaveBeenCalledWith([ - 'Transactions', - 'opbeans-node', - 'Services', - 'APM', - ]); }); test('/services/:serviceName/transactions/view?transactionName=my-transaction-name', () => { @@ -154,7 +124,7 @@ describe('useBreadcrumbs', () => { '/services/opbeans-node/transactions/view?kuery=myKuery&transactionName=my-transaction-name' ); - expect(setBreadcrumbs).toHaveBeenCalledWith( + expect(useBreadcrumbs).toHaveBeenCalledWith( expect.arrayContaining([ expect.objectContaining({ text: 'APM', @@ -179,13 +149,5 @@ describe('useBreadcrumbs', () => { }), ]) ); - - expect(changeTitle).toHaveBeenCalledWith([ - 'my-transaction-name', - 'Transactions', - 'opbeans-node', - 'Services', - 'APM', - ]); }); }); diff --git a/x-pack/plugins/apm/public/hooks/use_breadcrumbs.ts b/x-pack/plugins/apm/public/hooks/use_apm_breadcrumbs.ts similarity index 85% rename from x-pack/plugins/apm/public/hooks/use_breadcrumbs.ts rename to x-pack/plugins/apm/public/hooks/use_apm_breadcrumbs.ts index d907c27319d260..d64bcadf795775 100644 --- a/x-pack/plugins/apm/public/hooks/use_breadcrumbs.ts +++ b/x-pack/plugins/apm/public/hooks/use_apm_breadcrumbs.ts @@ -7,14 +7,15 @@ import { History, Location } from 'history'; import { ChromeBreadcrumb } from 'kibana/public'; -import { MouseEvent, ReactNode, useEffect } from 'react'; +import { MouseEvent } from 'react'; import { + match as Match, matchPath, RouteComponentProps, useHistory, - match as Match, useLocation, } from 'react-router-dom'; +import { useBreadcrumbs } from '../../../observability/public'; import { APMRouteDefinition, BreadcrumbTitle } from '../application/routes'; import { getAPMHref } from '../components/shared/Links/apm/APMLink'; import { useApmPluginContext } from '../context/apm_plugin/use_apm_plugin_context'; @@ -164,33 +165,17 @@ function routeDefinitionsToBreadcrumbs({ return breadcrumbs; } -/** - * Get an array for a page title from a list of breadcrumbs - */ -function getTitleFromBreadcrumbs(breadcrumbs: ChromeBreadcrumb[]): string[] { - function removeNonStrings(item: ReactNode): item is string { - return typeof item === 'string'; - } - - return breadcrumbs - .map(({ text }) => text) - .reverse() - .filter(removeNonStrings); -} - /** * Determine the breadcrumbs from the routes, set them, and update the page * title when the route changes. */ -export function useBreadcrumbs(routes: APMRouteDefinition[]) { +export function useApmBreadcrumbs(routes: APMRouteDefinition[]) { const history = useHistory(); const location = useLocation(); const { search } = location; const { core } = useApmPluginContext(); const { basePath } = core.http; const { navigateToUrl } = core.application; - const { docTitle, setBreadcrumbs } = core.chrome; - const changeTitle = docTitle.change; function wrappedGetAPMHref(path: string) { return getAPMHref({ basePath, path, search }); @@ -206,10 +191,6 @@ export function useBreadcrumbs(routes: APMRouteDefinition[]) { wrappedGetAPMHref, navigateToUrl, }); - const title = getTitleFromBreadcrumbs(breadcrumbs); - useEffect(() => { - changeTitle(title); - setBreadcrumbs(breadcrumbs); - }, [breadcrumbs, changeTitle, location, title, setBreadcrumbs]); + useBreadcrumbs(breadcrumbs); } From 7a3d61fb671fe549444dcb266b46d4664d8f3dc5 Mon Sep 17 00:00:00 2001 From: Dzmitry Lemechko Date: Thu, 24 Jun 2021 13:45:39 +0200 Subject: [PATCH 08/16] [load testing] adjust ES heap size (#101906) * [load testing] increase es heap to 2g * update default simulation * [heap size] 4g * [heap size] 6g * [load testing] es heap size 8g Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- x-pack/test/load/config.ts | 1 + x-pack/test/load/runner.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/load/config.ts b/x-pack/test/load/config.ts index 514440fd73f465..8f8708d155fb15 100644 --- a/x-pack/test/load/config.ts +++ b/x-pack/test/load/config.ts @@ -30,6 +30,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { esTestCluster: { ...xpackFunctionalTestsConfig.get('esTestCluster'), serverArgs: [...xpackFunctionalTestsConfig.get('esTestCluster.serverArgs')], + esJavaOpts: '-Xms8g -Xmx8g', }, kbnTestServer: { diff --git a/x-pack/test/load/runner.ts b/x-pack/test/load/runner.ts index 3e7a4817eeef17..2d379391b20897 100644 --- a/x-pack/test/load/runner.ts +++ b/x-pack/test/load/runner.ts @@ -18,7 +18,7 @@ const simulationPackage = 'org.kibanaLoadTest.simulation'; const simulationFIleExtension = '.scala'; const gatlingProjectRootPath: string = process.env.GATLING_PROJECT_PATH || resolve(REPO_ROOT, '../kibana-load-testing'); -const simulationEntry: string = process.env.GATLING_SIMULATIONS || 'DemoJourney'; +const simulationEntry: string = process.env.GATLING_SIMULATIONS || 'branch.DemoJourney'; if (!Fs.existsSync(gatlingProjectRootPath)) { throw createFlagError( From 9b9c47b269a437dad69ddb384b2ea6b124a60e5b Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Thu, 24 Jun 2021 14:45:15 +0200 Subject: [PATCH 09/16] [Fleet] Fix double policy header layout (#103076) * Fix double policy header layout - Use the default page title without tabs while loading the add integration view * remove unused import Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../fleet/public/applications/fleet/app.tsx | 30 +++++++++----- .../fleet/layouts/{ => default}/default.tsx | 35 +++------------- .../layouts/default/default_page_title.tsx | 40 +++++++++++++++++++ .../fleet/layouts/default/index.ts | 9 +++++ .../applications/fleet/layouts/index.tsx | 2 +- 5 files changed, 75 insertions(+), 41 deletions(-) rename x-pack/plugins/fleet/public/applications/fleet/layouts/{ => default}/default.tsx (65%) create mode 100644 x-pack/plugins/fleet/public/applications/fleet/layouts/default/default_page_title.tsx create mode 100644 x-pack/plugins/fleet/public/applications/fleet/layouts/default/index.ts diff --git a/x-pack/plugins/fleet/public/applications/fleet/app.tsx b/x-pack/plugins/fleet/public/applications/fleet/app.tsx index c4cc4d92f5d95c..8be6232733defc 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/app.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/app.tsx @@ -5,12 +5,13 @@ * 2.0. */ +import type { FunctionComponent } from 'react'; import React, { memo, useEffect, useState } from 'react'; import type { AppMountParameters } from 'kibana/public'; import { EuiCode, EuiEmptyPrompt, EuiErrorBoundary, EuiPanel, EuiPortal } from '@elastic/eui'; import type { History } from 'history'; import { createHashHistory } from 'history'; -import { Router, Redirect, Route, Switch } from 'react-router-dom'; +import { Router, Redirect, Route, Switch, useRouteMatch } from 'react-router-dom'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import styled from 'styled-components'; @@ -39,7 +40,7 @@ import { Error, Loading, SettingFlyout, FleetSetupLoading } from './components'; import type { UIExtensionsStorage } from './types'; import { FLEET_ROUTING_PATHS } from './constants'; -import { DefaultLayout, WithoutHeaderLayout } from './layouts'; +import { DefaultLayout, DefaultPageTitle, WithoutHeaderLayout, WithHeaderLayout } from './layouts'; import { AgentPolicyApp } from './sections/agent_policy'; import { DataStreamApp } from './sections/data_stream'; import { AgentsApp } from './sections/agents'; @@ -48,11 +49,18 @@ import { EnrollmentTokenListPage } from './sections/agents/enrollment_token_list const FEEDBACK_URL = 'https://ela.st/fleet-feedback'; -const ErrorLayout = ({ children }: { children: JSX.Element }) => ( +const ErrorLayout: FunctionComponent<{ isAddIntegrationsPath: boolean }> = ({ + isAddIntegrationsPath, + children, +}) => ( - - {children} - + {isAddIntegrationsPath ? ( + }>{children} + ) : ( + + {children} + + )} ); @@ -71,6 +79,8 @@ export const WithPermissionsAndSetup: React.FC = memo(({ children }) => { const [isInitialized, setIsInitialized] = useState(false); const [initializationError, setInitializationError] = useState(null); + const isAddIntegrationsPath = !!useRouteMatch(FLEET_ROUTING_PATHS.add_integration_to_policy); + useEffect(() => { (async () => { setIsPermissionsLoading(false); @@ -109,7 +119,7 @@ export const WithPermissionsAndSetup: React.FC = memo(({ children }) => { if (isPermissionsLoading || permissionsError) { return ( - + {isPermissionsLoading ? ( ) : permissionsError === 'REQUEST_ERROR' ? ( @@ -168,7 +178,7 @@ export const WithPermissionsAndSetup: React.FC = memo(({ children }) => { if (!isInitialized || initializationError) { return ( - + {initializationError ? ( - - - + diff --git a/x-pack/plugins/fleet/public/applications/fleet/layouts/default.tsx b/x-pack/plugins/fleet/public/applications/fleet/layouts/default/default.tsx similarity index 65% rename from x-pack/plugins/fleet/public/applications/fleet/layouts/default.tsx rename to x-pack/plugins/fleet/public/applications/fleet/layouts/default/default.tsx index f312ff374d792c..c6ef212b3995eb 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/layouts/default.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/layouts/default/default.tsx @@ -6,12 +6,13 @@ */ import React from 'react'; -import { EuiText, EuiFlexGroup, EuiFlexItem, EuiTitle } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import type { Section } from '../sections'; -import { useLink, useConfig } from '../hooks'; -import { WithHeaderLayout } from '../../../layouts'; +import type { Section } from '../../sections'; +import { useLink, useConfig } from '../../hooks'; +import { WithHeaderLayout } from '../../../../layouts'; + +import { DefaultPageTitle } from './default_page_title'; interface Props { section?: Section; @@ -24,31 +25,7 @@ export const DefaultLayout: React.FunctionComponent = ({ section, childre return ( - - - - -

- -

-
-
-
-
- - -

- -

-
-
- - } + leftColumn={} tabs={[ { name: ( diff --git a/x-pack/plugins/fleet/public/applications/fleet/layouts/default/default_page_title.tsx b/x-pack/plugins/fleet/public/applications/fleet/layouts/default/default_page_title.tsx new file mode 100644 index 00000000000000..e525a059b78372 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/layouts/default/default_page_title.tsx @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { FunctionComponent } from 'react'; +import React from 'react'; + +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiFlexGroup, EuiFlexItem, EuiTitle, EuiText } from '@elastic/eui'; + +export const DefaultPageTitle: FunctionComponent = () => { + return ( + + + + + +

+ +

+
+
+
+
+ + +

+ +

+
+
+
+ ); +}; diff --git a/x-pack/plugins/fleet/public/applications/fleet/layouts/default/index.ts b/x-pack/plugins/fleet/public/applications/fleet/layouts/default/index.ts new file mode 100644 index 00000000000000..9b0d3ee06138f1 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/layouts/default/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { DefaultLayout } from './default'; +export { DefaultPageTitle } from './default_page_title'; diff --git a/x-pack/plugins/fleet/public/applications/fleet/layouts/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/layouts/index.tsx index 71cb8d3aeeb369..0c07f1ffecb792 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/layouts/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/layouts/index.tsx @@ -7,4 +7,4 @@ export * from '../../../layouts'; -export { DefaultLayout } from './default'; +export { DefaultLayout, DefaultPageTitle } from './default'; From 2a8f3eb2f921cfb1d6a5faaac59cbac5aac24910 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Thu, 24 Jun 2021 14:45:35 +0200 Subject: [PATCH 10/16] [Fleet] Fix staleness bug in "Add agent" flyout (#103095) * * Fix stale enrollment api token bug * Refactored naming * raise the state of the selected enrollment api key to parent to avoid state sync issues * removed consts for onKeyChange and selectedApiKeyId * fix typo Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- ...advanced_agent_authentication_settings.tsx | 38 ++++++++---------- .../agent_policy_selection.tsx | 40 ++++++++++--------- .../managed_instructions.tsx | 11 ++--- .../agent_enrollment_flyout/steps.tsx | 12 ++++-- 4 files changed, 52 insertions(+), 49 deletions(-) diff --git a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/advanced_agent_authentication_settings.tsx b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/advanced_agent_authentication_settings.tsx index 25602b7e108fdd..96fab27a550508 100644 --- a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/advanced_agent_authentication_settings.tsx +++ b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/advanced_agent_authentication_settings.tsx @@ -21,20 +21,19 @@ import { interface Props { agentPolicyId?: string; + selectedApiKeyId?: string; onKeyChange: (key?: string) => void; } export const AdvancedAgentAuthenticationSettings: FunctionComponent = ({ agentPolicyId, + selectedApiKeyId, onKeyChange, }) => { const { notifications } = useStartServices(); const [enrollmentAPIKeys, setEnrollmentAPIKeys] = useState( [] ); - // TODO: Remove this piece of state since we don't need it here. The currently selected enrollment API key only - // needs to live on the form - const [selectedEnrollmentApiKey, setSelectedEnrollmentApiKey] = useState(); const [isLoadingEnrollmentKey, setIsLoadingEnrollmentKey] = useState(false); const [isAuthenticationSettingsOpen, setIsAuthenticationSettingsOpen] = useState(false); @@ -51,7 +50,7 @@ export const AdvancedAgentAuthenticationSettings: FunctionComponent = ({ return; } setEnrollmentAPIKeys([res.data.item]); - setSelectedEnrollmentApiKey(res.data.item.id); + onKeyChange(res.data.item.id); notifications.toasts.addSuccess( i18n.translate('xpack.fleet.newEnrollmentKey.keyCreatedToasts', { defaultMessage: 'Enrollment token created', @@ -66,15 +65,6 @@ export const AdvancedAgentAuthenticationSettings: FunctionComponent = ({ } }; - useEffect( - function triggerOnKeyChangeEffect() { - if (onKeyChange) { - onKeyChange(selectedEnrollmentApiKey); - } - }, - [onKeyChange, selectedEnrollmentApiKey] - ); - useEffect( function useEnrollmentKeysForAgentPolicyEffect() { if (!agentPolicyId) { @@ -97,9 +87,13 @@ export const AdvancedAgentAuthenticationSettings: FunctionComponent = ({ throw new Error('No data while fetching enrollment API keys'); } - setEnrollmentAPIKeys( - res.data.list.filter((key) => key.policy_id === agentPolicyId && key.active === true) + const enrollmentAPIKeysResponse = res.data.list.filter( + (key) => key.policy_id === agentPolicyId && key.active === true ); + + setEnrollmentAPIKeys(enrollmentAPIKeysResponse); + // Default to the first enrollment key if there is one. + onKeyChange(enrollmentAPIKeysResponse[0]?.id); } catch (error) { notifications.toasts.addError(error, { title: 'Error', @@ -108,21 +102,21 @@ export const AdvancedAgentAuthenticationSettings: FunctionComponent = ({ } fetchEnrollmentAPIKeys(); }, - [agentPolicyId, notifications.toasts] + [onKeyChange, agentPolicyId, notifications.toasts] ); useEffect( function useDefaultEnrollmentKeyForAgentPolicyEffect() { if ( - !selectedEnrollmentApiKey && + !selectedApiKeyId && enrollmentAPIKeys.length > 0 && enrollmentAPIKeys[0].policy_id === agentPolicyId ) { const enrollmentAPIKeyId = enrollmentAPIKeys[0].id; - setSelectedEnrollmentApiKey(enrollmentAPIKeyId); + onKeyChange(enrollmentAPIKeyId); } }, - [enrollmentAPIKeys, selectedEnrollmentApiKey, agentPolicyId] + [enrollmentAPIKeys, selectedApiKeyId, agentPolicyId, onKeyChange] ); return ( <> @@ -139,14 +133,14 @@ export const AdvancedAgentAuthenticationSettings: FunctionComponent = ({ {isAuthenticationSettingsOpen && ( <> - {enrollmentAPIKeys.length && selectedEnrollmentApiKey ? ( + {enrollmentAPIKeys.length && selectedApiKeyId ? ( ({ value: key.id, text: key.name, }))} - value={selectedEnrollmentApiKey || undefined} + value={selectedApiKeyId || undefined} prepend={ = ({ } onChange={(e) => { - setSelectedEnrollmentApiKey(e.target.value); + onKeyChange(e.target.value); }} /> ) : ( diff --git a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_policy_selection.tsx b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_policy_selection.tsx index f92b2d48259351..d9d1aa2e77f86f 100644 --- a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_policy_selection.tsx +++ b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_policy_selection.tsx @@ -22,6 +22,7 @@ type Props = { } & ( | { withKeySelection: true; + selectedApiKeyId?: string; onKeyChange?: (key?: string) => void; } | { @@ -31,9 +32,9 @@ type Props = { const resolveAgentId = ( agentPolicies?: AgentPolicy[], - selectedAgentId?: string + selectedAgentPolicyId?: string ): undefined | string => { - if (agentPolicies && agentPolicies.length && !selectedAgentId) { + if (agentPolicies && agentPolicies.length && !selectedAgentPolicyId) { if (agentPolicies.length === 1) { return agentPolicies[0].id; } @@ -44,33 +45,33 @@ const resolveAgentId = ( } } - return selectedAgentId; + return selectedAgentPolicyId; }; export const EnrollmentStepAgentPolicy: React.FC = (props) => { - const { withKeySelection, agentPolicies, onAgentPolicyChange, excludeFleetServer } = props; - const onKeyChange = props.withKeySelection && props.onKeyChange; - const [selectedAgentId, setSelectedAgentId] = useState( + const { agentPolicies, onAgentPolicyChange, excludeFleetServer } = props; + + const [selectedAgentPolicyId, setSelectedAgentPolicyId] = useState( () => resolveAgentId(agentPolicies, undefined) // no agent id selected yet ); useEffect( function triggerOnAgentPolicyChangeEffect() { if (onAgentPolicyChange) { - onAgentPolicyChange(selectedAgentId); + onAgentPolicyChange(selectedAgentPolicyId); } }, - [selectedAgentId, onAgentPolicyChange] + [selectedAgentPolicyId, onAgentPolicyChange] ); useEffect( function useDefaultAgentPolicyEffect() { - const resolvedId = resolveAgentId(agentPolicies, selectedAgentId); - if (resolvedId !== selectedAgentId) { - setSelectedAgentId(resolvedId); + const resolvedId = resolveAgentId(agentPolicies, selectedAgentPolicyId); + if (resolvedId !== selectedAgentPolicyId) { + setSelectedAgentPolicyId(resolvedId); } }, - [agentPolicies, selectedAgentId] + [agentPolicies, selectedAgentPolicyId] ); return ( @@ -90,25 +91,26 @@ export const EnrollmentStepAgentPolicy: React.FC = (props) => { value: agentPolicy.id, text: agentPolicy.name, }))} - value={selectedAgentId || undefined} - onChange={(e) => setSelectedAgentId(e.target.value)} + value={selectedAgentPolicyId || undefined} + onChange={(e) => setSelectedAgentPolicyId(e.target.value)} aria-label={i18n.translate('xpack.fleet.enrollmentStepAgentPolicy.policySelectAriaLabel', { defaultMessage: 'Agent policy', })} /> - {selectedAgentId && ( + {selectedAgentPolicyId && ( )} - {withKeySelection && onKeyChange && ( + {props.withKeySelection && props.onKeyChange && ( <> )} diff --git a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/managed_instructions.tsx b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/managed_instructions.tsx index 919f0c3052db91..efae8db377f7f1 100644 --- a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/managed_instructions.tsx +++ b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/managed_instructions.tsx @@ -62,10 +62,10 @@ export const ManagedInstructions = React.memo( ({ agentPolicy, agentPolicies, viewDataStepContent }) => { const fleetStatus = useFleetStatus(); - const [selectedAPIKeyId, setSelectedAPIKeyId] = useState(); + const [selectedApiKeyId, setSelectedAPIKeyId] = useState(); const [isFleetServerPolicySelected, setIsFleetServerPolicySelected] = useState(false); - const apiKey = useGetOneEnrollmentAPIKey(selectedAPIKeyId); + const apiKey = useGetOneEnrollmentAPIKey(selectedApiKeyId); const settings = useGetSettings(); const fleetServerInstructions = useFleetServerInstructions(apiKey?.data?.item?.policy_id); @@ -84,10 +84,11 @@ export const ManagedInstructions = React.memo( !agentPolicy ? AgentPolicySelectionStep({ agentPolicies, + selectedApiKeyId, setSelectedAPIKeyId, setIsFleetServerPolicySelected, }) - : AgentEnrollmentKeySelectionStep({ agentPolicy, setSelectedAPIKeyId }), + : AgentEnrollmentKeySelectionStep({ agentPolicy, selectedApiKeyId, setSelectedAPIKeyId }), ]; if (isFleetServerPolicySelected) { baseSteps.push( @@ -101,7 +102,7 @@ export const ManagedInstructions = React.memo( title: i18n.translate('xpack.fleet.agentEnrollment.stepEnrollAndRunAgentTitle', { defaultMessage: 'Enroll and start the Elastic Agent', }), - children: selectedAPIKeyId && apiKey.data && ( + children: selectedApiKeyId && apiKey.data && ( ), }); @@ -115,7 +116,7 @@ export const ManagedInstructions = React.memo( }, [ agentPolicy, agentPolicies, - selectedAPIKeyId, + selectedApiKeyId, apiKey.data, isFleetServerPolicySelected, settings.data?.item?.fleet_server_hosts, diff --git a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/steps.tsx b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/steps.tsx index 03cff88e639695..8b12994473e347 100644 --- a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/steps.tsx +++ b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/steps.tsx @@ -49,14 +49,16 @@ export const DownloadStep = () => { export const AgentPolicySelectionStep = ({ agentPolicies, - setSelectedAPIKeyId, setSelectedPolicyId, - setIsFleetServerPolicySelected, + selectedApiKeyId, + setSelectedAPIKeyId, excludeFleetServer, + setIsFleetServerPolicySelected, }: { agentPolicies?: AgentPolicy[]; - setSelectedAPIKeyId?: (key?: string) => void; setSelectedPolicyId?: (policyId?: string) => void; + selectedApiKeyId?: string; + setSelectedAPIKeyId?: (key?: string) => void; setIsFleetServerPolicySelected?: (selected: boolean) => void; excludeFleetServer?: boolean; }) => { @@ -99,6 +101,7 @@ export const AgentPolicySelectionStep = ({ void; }) => { return { @@ -132,6 +137,7 @@ export const AgentEnrollmentKeySelectionStep = ({ From a50d94908c3202ba462aac8421b3f9acb333f4d2 Mon Sep 17 00:00:00 2001 From: Scotty Bollinger Date: Thu, 24 Jun 2021 07:57:49 -0500 Subject: [PATCH 11/16] [Enterprise Search] Add User management feature (#103173) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Rename method to close both flyouts This is shared with the forthcoming user flyouts closeRoleMappingFlyout -> closeUsersAndRolesFlyout * Add logic for elasticsearch users and single user role mappings * Add logic for various form states - Showing and hiding flyouts - Select and text input values - User created state to turn flyout into a success message state * Add User server routes * Add logic for saving a user * Add User components * Add User list and User flyout to RoleMappings view * Fix path * Rename things - Users & roles -> Users and roles - roleId -> roleMappingId (matches other places in code) - also added a missing prop to the actions col * Set default group when modal closed The UI sets the default group on page load but did not cover the case where the user has chosen a group in a previous interaction and the closed the flyout. This commit adds a method that resets that state when the flyout is closed Part of porting of https://github.com/elastic/ent-search/pull/3865 Specifically: https://github.com/elastic/ent-search/commit/a4131b95dab7c0df97bd78e660f25e09ac3e7cec * Adds tooltip for external attribute This was missed from the design Part of porting of https://github.com/elastic/ent-search/pull/3865 Specifically: https://github.com/elastic/ent-search/commit/03aa349cab4fb32069b64ab8c51a7252ba52e805 * Fix invitations link * Fix incorrect role type Role-> RoleTypes 🤷🏽‍♀️ * Add EuiPortal to Flyout Wasn’t needed in ent-search; already done for RomeMappingFlyout. Hide whitespace changes plskthx * Auth provider deprecation warning in mapping UI Since we're moving fully into Kibana, we're losing our concept of auth providers. In 8.0, role mappings the specify an auth provider will no longer work, so this adds a small deprecation warning in the role mappings table. https://github.com/elastic/ent-search/pull/3885 * Email is no longer required After a slack discussion, it was determined that email should be optional. This commit also fixes another instance of the App Search role type being wrong. * Existing users’ usernames should not be editable * Use EuiLink instead of anchor * Add validation tests * Change URL for users_and_roles Need to change folder and file names but will punt until after 7.14FF I did throw in updating the logic file path * Remove unused import Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../app_search/components/layout/nav.test.tsx | 4 +- .../app_search/components/layout/nav.tsx | 4 +- .../components/role_mappings/role_mapping.tsx | 4 +- .../role_mappings/role_mappings.test.tsx | 42 ++- .../role_mappings/role_mappings.tsx | 31 ++ .../role_mappings/role_mappings_logic.test.ts | 274 +++++++++++++++- .../role_mappings/role_mappings_logic.ts | 181 ++++++++++- .../components/role_mappings/user.test.tsx | 124 +++++++ .../components/role_mappings/user.tsx | 106 ++++++ .../applications/app_search/index.test.tsx | 2 +- .../public/applications/app_search/index.tsx | 6 +- .../public/applications/app_search/routes.ts | 2 +- .../shared/role_mapping/constants.ts | 18 +- .../role_mapping/role_mapping_flyout.test.tsx | 4 +- .../role_mapping/role_mapping_flyout.tsx | 10 +- .../role_mapping/role_mappings_table.test.tsx | 12 +- .../role_mapping/role_mappings_table.tsx | 42 ++- .../role_mapping/user_added_info.test.tsx | 100 +++++- .../shared/role_mapping/user_added_info.tsx | 6 +- .../shared/role_mapping/user_flyout.tsx | 39 ++- .../role_mapping/user_invitation_callout.tsx | 2 +- .../role_mapping/user_selector.test.tsx | 3 +- .../shared/role_mapping/user_selector.tsx | 5 +- .../shared/role_mapping/users_table.tsx | 5 +- .../components/layout/nav.test.tsx | 4 +- .../components/layout/nav.tsx | 6 +- .../workplace_search/constants.ts | 2 +- .../applications/workplace_search/index.tsx | 4 +- .../applications/workplace_search/routes.ts | 2 +- .../views/role_mappings/role_mapping.tsx | 4 +- .../role_mappings/role_mappings.test.tsx | 40 ++- .../views/role_mappings/role_mappings.tsx | 31 ++ .../role_mappings/role_mappings_logic.test.ts | 302 ++++++++++++++++-- .../role_mappings/role_mappings_logic.ts | 192 ++++++++++- .../views/role_mappings/user.test.tsx | 123 +++++++ .../views/role_mappings/user.tsx | 103 ++++++ .../routes/app_search/role_mappings.test.ts | 49 +++ .../server/routes/app_search/role_mappings.ts | 26 ++ .../workplace_search/role_mappings.test.ts | 49 +++ .../routes/workplace_search/role_mappings.ts | 29 ++ 40 files changed, 1868 insertions(+), 124 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/user.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/user.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/user.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/user.tsx diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/nav.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/nav.test.tsx index c9f5452e254e16..ce4a118bef095a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/nav.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/nav.test.tsx @@ -100,8 +100,8 @@ describe('useAppSearchNav', () => { }, { id: 'usersRoles', - name: 'Users & roles', - href: '/role_mappings', + name: 'Users and roles', + href: '/users_and_roles', }, ], }, diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/nav.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/nav.tsx index c3b8ec642233bc..793a36f48fe826 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/nav.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/nav.tsx @@ -13,7 +13,7 @@ import { generateNavLink } from '../../../shared/layout'; import { ROLE_MAPPINGS_TITLE } from '../../../shared/role_mapping/constants'; import { AppLogic } from '../../app_logic'; -import { ENGINES_PATH, SETTINGS_PATH, CREDENTIALS_PATH, ROLE_MAPPINGS_PATH } from '../../routes'; +import { ENGINES_PATH, SETTINGS_PATH, CREDENTIALS_PATH, USERS_AND_ROLES_PATH } from '../../routes'; import { CREDENTIALS_TITLE } from '../credentials'; import { useEngineNav } from '../engine/engine_nav'; import { ENGINES_TITLE } from '../engines'; @@ -57,7 +57,7 @@ export const useAppSearchNav = () => { navItems.push({ id: 'usersRoles', name: ROLE_MAPPINGS_TITLE, - ...generateNavLink({ to: ROLE_MAPPINGS_PATH }), + ...generateNavLink({ to: USERS_AND_ROLES_PATH }), }); } diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mapping.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mapping.tsx index b6a9dd72cfd05e..dbebd8e46a2195 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mapping.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mapping.tsx @@ -28,7 +28,7 @@ export const RoleMapping: React.FC = () => { handleAuthProviderChange, handleRoleChange, handleSaveMapping, - closeRoleMappingFlyout, + closeUsersAndRolesFlyout, } = useActions(RoleMappingsLogic); const { @@ -68,7 +68,7 @@ export const RoleMapping: React.FC = () => { 0} error={roleMappingErrors}> diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings.test.tsx index 308022ccb2e5a7..64bf41a50a2f05 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings.test.tsx @@ -12,26 +12,39 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { RoleMappingsTable, RoleMappingsHeading } from '../../../shared/role_mapping'; -import { wsRoleMapping } from '../../../shared/role_mapping/__mocks__/roles'; +import { + RoleMappingsTable, + RoleMappingsHeading, + UsersHeading, + UsersEmptyPrompt, +} from '../../../shared/role_mapping'; +import { + asRoleMapping, + asSingleUserRoleMapping, +} from '../../../shared/role_mapping/__mocks__/roles'; import { RoleMapping } from './role_mapping'; import { RoleMappings } from './role_mappings'; +import { User } from './user'; describe('RoleMappings', () => { const initializeRoleMappings = jest.fn(); const initializeRoleMapping = jest.fn(); + const initializeSingleUserRoleMapping = jest.fn(); const handleDeleteMapping = jest.fn(); const mockValues = { - roleMappings: [wsRoleMapping], + roleMappings: [asRoleMapping], dataLoading: false, multipleAuthProvidersConfig: false, + singleUserRoleMappings: [asSingleUserRoleMapping], + singleUserRoleMappingFlyoutOpen: false, }; beforeEach(() => { setMockActions({ initializeRoleMappings, initializeRoleMapping, + initializeSingleUserRoleMapping, handleDeleteMapping, }); setMockValues(mockValues); @@ -50,10 +63,31 @@ describe('RoleMappings', () => { expect(wrapper.find(RoleMapping)).toHaveLength(1); }); - it('handles onClick', () => { + it('renders User flyout', () => { + setMockValues({ ...mockValues, singleUserRoleMappingFlyoutOpen: true }); + const wrapper = shallow(); + + expect(wrapper.find(User)).toHaveLength(1); + }); + + it('handles RoleMappingsHeading onClick', () => { const wrapper = shallow(); wrapper.find(RoleMappingsHeading).prop('onClick')(); expect(initializeRoleMapping).toHaveBeenCalled(); }); + + it('handles UsersHeading onClick', () => { + const wrapper = shallow(); + wrapper.find(UsersHeading).prop('onClick')(); + + expect(initializeSingleUserRoleMapping).toHaveBeenCalled(); + }); + + it('handles empty users state', () => { + setMockValues({ ...mockValues, singleUserRoleMappings: [] }); + const wrapper = shallow(); + + expect(wrapper.find(UsersEmptyPrompt)).toHaveLength(1); + }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings.tsx index 03e2ae67eca9ea..3e692aa48623e3 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings.tsx @@ -9,11 +9,16 @@ import React, { useEffect } from 'react'; import { useActions, useValues } from 'kea'; +import { EuiSpacer } from '@elastic/eui'; + import { APP_SEARCH_PLUGIN } from '../../../../../common/constants'; import { RoleMappingsTable, RoleMappingsHeading, RolesEmptyPrompt, + UsersTable, + UsersHeading, + UsersEmptyPrompt, } from '../../../shared/role_mapping'; import { ROLE_MAPPINGS_TITLE } from '../../../shared/role_mapping/constants'; @@ -23,6 +28,7 @@ import { AppSearchPageTemplate } from '../layout'; import { ROLE_MAPPINGS_ENGINE_ACCESS_HEADING } from './constants'; import { RoleMapping } from './role_mapping'; import { RoleMappingsLogic } from './role_mappings_logic'; +import { User } from './user'; const ROLES_DOCS_LINK = `${DOCS_PREFIX}/security-and-users.html`; @@ -31,14 +37,17 @@ export const RoleMappings: React.FC = () => { enableRoleBasedAccess, initializeRoleMappings, initializeRoleMapping, + initializeSingleUserRoleMapping, handleDeleteMapping, resetState, } = useActions(RoleMappingsLogic); const { roleMappings, + singleUserRoleMappings, multipleAuthProvidersConfig, dataLoading, roleMappingFlyoutOpen, + singleUserRoleMappingFlyoutOpen, } = useValues(RoleMappingsLogic); useEffect(() => { @@ -46,6 +55,8 @@ export const RoleMappings: React.FC = () => { return resetState; }, []); + const hasUsers = singleUserRoleMappings.length > 0; + const rolesEmptyState = ( { ); + const usersTable = ( + + ); + + const usersSection = ( + <> + initializeSingleUserRoleMapping()} /> + + {hasUsers ? usersTable : } + + ); + return ( { emptyState={rolesEmptyState} > {roleMappingFlyoutOpen && } + {singleUserRoleMappingFlyoutOpen && } {roleMappingsSection} + + {usersSection} ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_logic.test.ts index 6985f213d1dd56..16b44e9ec1f11e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_logic.test.ts @@ -15,11 +15,18 @@ import { engines } from '../../__mocks__/engines.mock'; import { nextTick } from '@kbn/test/jest'; -import { asRoleMapping } from '../../../shared/role_mapping/__mocks__/roles'; +import { elasticsearchUsers } from '../../../shared/role_mapping/__mocks__/elasticsearch_users'; + +import { + asRoleMapping, + asSingleUserRoleMapping, +} from '../../../shared/role_mapping/__mocks__/roles'; import { ANY_AUTH_PROVIDER } from '../../../shared/role_mapping/constants'; import { RoleMappingsLogic } from './role_mappings_logic'; +const emptyUser = { username: '', email: '' }; + describe('RoleMappingsLogic', () => { const { http } = mockHttpValues; const { clearFlashMessages, flashAPIErrors, setSuccessMessage } = mockFlashMessageHelpers; @@ -28,6 +35,8 @@ describe('RoleMappingsLogic', () => { attributes: [], availableAuthProviders: [], elasticsearchRoles: [], + elasticsearchUser: emptyUser, + elasticsearchUsers: [], roleMapping: null, roleMappingFlyoutOpen: false, roleMappings: [], @@ -43,6 +52,12 @@ describe('RoleMappingsLogic', () => { selectedAuthProviders: [ANY_AUTH_PROVIDER], selectedOptions: [], roleMappingErrors: [], + singleUserRoleMapping: null, + singleUserRoleMappings: [], + singleUserRoleMappingFlyoutOpen: false, + userCreated: false, + userFormIsNewUser: true, + userFormUserIsExisting: true, }; const mappingsServerProps = { @@ -53,6 +68,8 @@ describe('RoleMappingsLogic', () => { availableEngines: engines, elasticsearchRoles: [], hasAdvancedRoles: false, + singleUserRoleMappings: [asSingleUserRoleMapping], + elasticsearchUsers, }; beforeEach(() => { @@ -83,7 +100,19 @@ describe('RoleMappingsLogic', () => { elasticsearchRoles: mappingsServerProps.elasticsearchRoles, selectedEngines: new Set(), selectedOptions: [], + elasticsearchUsers, + elasticsearchUser: elasticsearchUsers[0], + singleUserRoleMappings: [asSingleUserRoleMapping], + }); + }); + + it('handles fallback if no elasticsearch users present', () => { + RoleMappingsLogic.actions.setRoleMappingsData({ + ...mappingsServerProps, + elasticsearchUsers: [], }); + + expect(RoleMappingsLogic.values.elasticsearchUser).toEqual(emptyUser); }); }); @@ -94,6 +123,26 @@ describe('RoleMappingsLogic', () => { expect(RoleMappingsLogic.values.dataLoading).toEqual(false); }); + describe('setElasticsearchUser', () => { + it('sets user', () => { + RoleMappingsLogic.actions.setElasticsearchUser(elasticsearchUsers[0]); + + expect(RoleMappingsLogic.values.elasticsearchUser).toEqual(elasticsearchUsers[0]); + }); + + it('handles fallback if no user present', () => { + RoleMappingsLogic.actions.setElasticsearchUser(undefined); + + expect(RoleMappingsLogic.values.elasticsearchUser).toEqual(emptyUser); + }); + }); + + it('setSingleUserRoleMapping', () => { + RoleMappingsLogic.actions.setSingleUserRoleMapping(asSingleUserRoleMapping); + + expect(RoleMappingsLogic.values.singleUserRoleMapping).toEqual(asSingleUserRoleMapping); + }); + it('handleRoleChange', () => { RoleMappingsLogic.actions.handleRoleChange('dev'); @@ -152,6 +201,12 @@ describe('RoleMappingsLogic', () => { }); }); + it('setUserExistingRadioValue', () => { + RoleMappingsLogic.actions.setUserExistingRadioValue(false); + + expect(RoleMappingsLogic.values.userFormUserIsExisting).toEqual(false); + }); + describe('handleAttributeSelectorChange', () => { const elasticsearchRoles = ['foo', 'bar']; @@ -174,6 +229,8 @@ describe('RoleMappingsLogic', () => { attributeName: 'role', elasticsearchRoles, selectedEngines: new Set(), + elasticsearchUsers, + singleUserRoleMappings: [asSingleUserRoleMapping], }); }); @@ -260,16 +317,59 @@ describe('RoleMappingsLogic', () => { expect(clearFlashMessages).toHaveBeenCalled(); }); - it('closeRoleMappingFlyout', () => { + it('openSingleUserRoleMappingFlyout', () => { + mount(mappingsServerProps); + RoleMappingsLogic.actions.openSingleUserRoleMappingFlyout(); + + expect(RoleMappingsLogic.values.singleUserRoleMappingFlyoutOpen).toEqual(true); + expect(clearFlashMessages).toHaveBeenCalled(); + }); + + it('closeUsersAndRolesFlyout', () => { mount({ ...mappingsServerProps, roleMappingFlyoutOpen: true, }); - RoleMappingsLogic.actions.closeRoleMappingFlyout(); + RoleMappingsLogic.actions.closeUsersAndRolesFlyout(); expect(RoleMappingsLogic.values.roleMappingFlyoutOpen).toEqual(false); expect(clearFlashMessages).toHaveBeenCalled(); }); + + it('setElasticsearchUsernameValue', () => { + const username = 'newName'; + RoleMappingsLogic.actions.setElasticsearchUsernameValue(username); + + expect(RoleMappingsLogic.values).toEqual({ + ...DEFAULT_VALUES, + elasticsearchUser: { + ...RoleMappingsLogic.values.elasticsearchUser, + username, + }, + }); + }); + + it('setElasticsearchEmailValue', () => { + const email = 'newEmail@foo.cats'; + RoleMappingsLogic.actions.setElasticsearchEmailValue(email); + + expect(RoleMappingsLogic.values).toEqual({ + ...DEFAULT_VALUES, + elasticsearchUser: { + ...RoleMappingsLogic.values.elasticsearchUser, + email, + }, + }); + }); + + it('setUserCreated', () => { + RoleMappingsLogic.actions.setUserCreated(); + + expect(RoleMappingsLogic.values).toEqual({ + ...DEFAULT_VALUES, + userCreated: true, + }); + }); }); describe('listeners', () => { @@ -335,6 +435,39 @@ describe('RoleMappingsLogic', () => { }); }); + describe('initializeSingleUserRoleMapping', () => { + let setElasticsearchUserSpy: jest.MockedFunction; + let setRoleMappingSpy: jest.MockedFunction; + let setSingleUserRoleMappingSpy: jest.MockedFunction; + beforeEach(() => { + setElasticsearchUserSpy = jest.spyOn(RoleMappingsLogic.actions, 'setElasticsearchUser'); + setRoleMappingSpy = jest.spyOn(RoleMappingsLogic.actions, 'setRoleMapping'); + setSingleUserRoleMappingSpy = jest.spyOn( + RoleMappingsLogic.actions, + 'setSingleUserRoleMapping' + ); + }); + + it('should handle the new user state and only set an empty mapping', () => { + RoleMappingsLogic.actions.initializeSingleUserRoleMapping(); + + expect(setElasticsearchUserSpy).not.toHaveBeenCalled(); + expect(setRoleMappingSpy).not.toHaveBeenCalled(); + expect(setSingleUserRoleMappingSpy).toHaveBeenCalledWith(undefined); + }); + + it('should handle an existing user state and set mapping', () => { + RoleMappingsLogic.actions.setRoleMappingsData(mappingsServerProps); + RoleMappingsLogic.actions.initializeSingleUserRoleMapping( + asSingleUserRoleMapping.roleMapping.id + ); + + expect(setElasticsearchUserSpy).toHaveBeenCalled(); + expect(setRoleMappingSpy).toHaveBeenCalled(); + expect(setSingleUserRoleMappingSpy).toHaveBeenCalledWith(asSingleUserRoleMapping); + }); + }); + describe('handleSaveMapping', () => { const body = { roleType: 'owner', @@ -430,6 +563,94 @@ describe('RoleMappingsLogic', () => { }); }); + describe('handleSaveUser', () => { + it('calls API and refreshes list when new mapping', async () => { + const initializeRoleMappingsSpy = jest.spyOn( + RoleMappingsLogic.actions, + 'initializeRoleMappings' + ); + const setUserCreatedSpy = jest.spyOn(RoleMappingsLogic.actions, 'setUserCreated'); + const setSingleUserRoleMappingSpy = jest.spyOn( + RoleMappingsLogic.actions, + 'setSingleUserRoleMapping' + ); + RoleMappingsLogic.actions.setRoleMappingsData(mappingsServerProps); + + http.post.mockReturnValue(Promise.resolve(mappingsServerProps)); + RoleMappingsLogic.actions.handleSaveUser(); + + expect(http.post).toHaveBeenCalledWith('/api/app_search/single_user_role_mapping', { + body: JSON.stringify({ + roleMapping: { + engines: [], + roleType: 'owner', + accessAllEngines: true, + }, + elasticsearchUser: { + username: elasticsearchUsers[0].username, + email: elasticsearchUsers[0].email, + }, + }), + }); + await nextTick(); + + expect(initializeRoleMappingsSpy).toHaveBeenCalled(); + expect(setUserCreatedSpy).toHaveBeenCalled(); + expect(setSingleUserRoleMappingSpy).toHaveBeenCalled(); + }); + + it('calls API and refreshes list when existing mapping', async () => { + const initializeRoleMappingsSpy = jest.spyOn( + RoleMappingsLogic.actions, + 'initializeRoleMappings' + ); + RoleMappingsLogic.actions.setSingleUserRoleMapping(asSingleUserRoleMapping); + RoleMappingsLogic.actions.handleAccessAllEnginesChange(false); + + http.put.mockReturnValue(Promise.resolve(mappingsServerProps)); + RoleMappingsLogic.actions.handleSaveUser(); + + expect(http.post).toHaveBeenCalledWith('/api/app_search/single_user_role_mapping', { + body: JSON.stringify({ + roleMapping: { + engines: [], + roleType: 'owner', + accessAllEngines: false, + id: asSingleUserRoleMapping.roleMapping.id, + }, + elasticsearchUser: { + username: '', + email: '', + }, + }), + }); + await nextTick(); + + expect(initializeRoleMappingsSpy).toHaveBeenCalled(); + }); + + it('handles error', async () => { + const setRoleMappingErrorsSpy = jest.spyOn( + RoleMappingsLogic.actions, + 'setRoleMappingErrors' + ); + + http.post.mockReturnValue( + Promise.reject({ + body: { + attributes: { + errors: ['this is an error'], + }, + }, + }) + ); + RoleMappingsLogic.actions.handleSaveUser(); + await nextTick(); + + expect(setRoleMappingErrorsSpy).toHaveBeenCalledWith(['this is an error']); + }); + }); + describe('handleDeleteMapping', () => { const roleMappingId = 'r1'; @@ -458,5 +679,52 @@ describe('RoleMappingsLogic', () => { expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); }); + + describe('handleUsernameSelectChange', () => { + it('sets elasticsearchUser when match found', () => { + RoleMappingsLogic.actions.setRoleMappingsData(mappingsServerProps); + const setElasticsearchUserSpy = jest.spyOn( + RoleMappingsLogic.actions, + 'setElasticsearchUser' + ); + RoleMappingsLogic.actions.handleUsernameSelectChange(elasticsearchUsers[0].username); + + expect(setElasticsearchUserSpy).toHaveBeenCalledWith(elasticsearchUsers[0]); + }); + + it('does not set elasticsearchUser when no match found', () => { + RoleMappingsLogic.actions.setRoleMappingsData(mappingsServerProps); + const setElasticsearchUserSpy = jest.spyOn( + RoleMappingsLogic.actions, + 'setElasticsearchUser' + ); + RoleMappingsLogic.actions.handleUsernameSelectChange('bogus'); + + expect(setElasticsearchUserSpy).not.toHaveBeenCalled(); + }); + }); + + describe('setUserExistingRadioValue', () => { + it('handles existing user', () => { + RoleMappingsLogic.actions.setRoleMappingsData(mappingsServerProps); + const setElasticsearchUserSpy = jest.spyOn( + RoleMappingsLogic.actions, + 'setElasticsearchUser' + ); + RoleMappingsLogic.actions.setUserExistingRadioValue(true); + + expect(setElasticsearchUserSpy).toHaveBeenCalledWith(elasticsearchUsers[0]); + }); + + it('handles new user', () => { + const setElasticsearchUserSpy = jest.spyOn( + RoleMappingsLogic.actions, + 'setElasticsearchUser' + ); + RoleMappingsLogic.actions.setUserExistingRadioValue(false); + + expect(setElasticsearchUserSpy).toHaveBeenCalledWith(emptyUser); + }); + }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_logic.ts index e2ef75897528c6..0b57e1d08a2946 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_logic.ts @@ -16,7 +16,7 @@ import { } from '../../../shared/flash_messages'; import { HttpLogic } from '../../../shared/http'; import { ANY_AUTH_PROVIDER } from '../../../shared/role_mapping/constants'; -import { AttributeName } from '../../../shared/types'; +import { AttributeName, SingleUserRoleMapping, ElasticsearchUser } from '../../../shared/types'; import { ASRoleMapping, RoleTypes } from '../../types'; import { roleHasScopedEngines } from '../../utils/role/has_scoped_engines'; import { Engine } from '../engine/types'; @@ -27,20 +27,25 @@ import { ROLE_MAPPING_UPDATED_MESSAGE, } from './constants'; +type UserMapping = SingleUserRoleMapping; + interface RoleMappingsServerDetails { roleMappings: ASRoleMapping[]; attributes: string[]; authProviders: string[]; availableEngines: Engine[]; elasticsearchRoles: string[]; + elasticsearchUsers: ElasticsearchUser[]; hasAdvancedRoles: boolean; multipleAuthProvidersConfig: boolean; + singleUserRoleMappings: UserMapping[]; } const getFirstAttributeName = (roleMapping: ASRoleMapping) => Object.entries(roleMapping.rules)[0][0] as AttributeName; const getFirstAttributeValue = (roleMapping: ASRoleMapping) => Object.entries(roleMapping.rules)[0][1] as AttributeName; +const emptyUser = { username: '', email: '' } as ElasticsearchUser; interface RoleMappingsActions { handleAccessAllEnginesChange(selected: boolean): { selected: boolean }; @@ -53,21 +58,34 @@ interface RoleMappingsActions { handleDeleteMapping(roleMappingId: string): { roleMappingId: string }; handleEngineSelectionChange(engineNames: string[]): { engineNames: string[] }; handleRoleChange(roleType: RoleTypes): { roleType: RoleTypes }; + handleUsernameSelectChange(username: string): { username: string }; handleSaveMapping(): void; + handleSaveUser(): void; initializeRoleMapping(roleMappingId?: string): { roleMappingId?: string }; + initializeSingleUserRoleMapping(roleMappingId?: string): { roleMappingId?: string }; initializeRoleMappings(): void; resetState(): void; setRoleMapping(roleMapping: ASRoleMapping): { roleMapping: ASRoleMapping }; + setSingleUserRoleMapping(data?: UserMapping): { singleUserRoleMapping: UserMapping }; setRoleMappings({ roleMappings, }: { roleMappings: ASRoleMapping[]; }): { roleMappings: ASRoleMapping[] }; setRoleMappingsData(data: RoleMappingsServerDetails): RoleMappingsServerDetails; + setElasticsearchUser( + elasticsearchUser?: ElasticsearchUser + ): { elasticsearchUser: ElasticsearchUser }; openRoleMappingFlyout(): void; - closeRoleMappingFlyout(): void; + openSingleUserRoleMappingFlyout(): void; + closeUsersAndRolesFlyout(): void; setRoleMappingErrors(errors: string[]): { errors: string[] }; enableRoleBasedAccess(): void; + setUserExistingRadioValue(userFormUserIsExisting: boolean): { userFormUserIsExisting: boolean }; + setElasticsearchUsernameValue(username: string): { username: string }; + setElasticsearchEmailValue(email: string): { email: string }; + setUserCreated(): void; + setUserFormIsNewUser(userFormIsNewUser: boolean): { userFormIsNewUser: boolean }; } interface RoleMappingsValues { @@ -79,27 +97,38 @@ interface RoleMappingsValues { availableEngines: Engine[]; dataLoading: boolean; elasticsearchRoles: string[]; + elasticsearchUsers: ElasticsearchUser[]; + elasticsearchUser: ElasticsearchUser; hasAdvancedRoles: boolean; multipleAuthProvidersConfig: boolean; roleMapping: ASRoleMapping | null; roleMappings: ASRoleMapping[]; + singleUserRoleMapping: UserMapping | null; + singleUserRoleMappings: UserMapping[]; roleType: RoleTypes; selectedAuthProviders: string[]; selectedEngines: Set; roleMappingFlyoutOpen: boolean; + singleUserRoleMappingFlyoutOpen: boolean; selectedOptions: EuiComboBoxOptionOption[]; roleMappingErrors: string[]; + userFormUserIsExisting: boolean; + userCreated: boolean; + userFormIsNewUser: boolean; } export const RoleMappingsLogic = kea>({ - path: ['enterprise_search', 'app_search', 'role_mappings'], + path: ['enterprise_search', 'app_search', 'users_and_roles'], actions: { setRoleMappingsData: (data: RoleMappingsServerDetails) => data, setRoleMapping: (roleMapping: ASRoleMapping) => ({ roleMapping }), + setElasticsearchUser: (elasticsearchUser: ElasticsearchUser) => ({ elasticsearchUser }), + setSingleUserRoleMapping: (singleUserRoleMapping: UserMapping) => ({ singleUserRoleMapping }), setRoleMappings: ({ roleMappings }: { roleMappings: ASRoleMapping[] }) => ({ roleMappings }), setRoleMappingErrors: (errors: string[]) => ({ errors }), handleAuthProviderChange: (value: string) => ({ value }), handleRoleChange: (roleType: RoleTypes) => ({ roleType }), + handleUsernameSelectChange: (username: string) => ({ username }), handleEngineSelectionChange: (engineNames: string[]) => ({ engineNames }), handleAttributeSelectorChange: (value: string, firstElasticsearchRole: string) => ({ value, @@ -108,13 +137,21 @@ export const RoleMappingsLogic = kea ({ value }), handleAccessAllEnginesChange: (selected: boolean) => ({ selected }), enableRoleBasedAccess: true, + openSingleUserRoleMappingFlyout: true, + setUserExistingRadioValue: (userFormUserIsExisting: boolean) => ({ userFormUserIsExisting }), resetState: true, initializeRoleMappings: true, + initializeSingleUserRoleMapping: (roleMappingId?: string) => ({ roleMappingId }), initializeRoleMapping: (roleMappingId) => ({ roleMappingId }), handleDeleteMapping: (roleMappingId: string) => ({ roleMappingId }), handleSaveMapping: true, + handleSaveUser: true, openRoleMappingFlyout: true, - closeRoleMappingFlyout: false, + closeUsersAndRolesFlyout: false, + setElasticsearchUsernameValue: (username: string) => ({ username }), + setElasticsearchEmailValue: (email: string) => ({ email }), + setUserCreated: true, + setUserFormIsNewUser: (userFormIsNewUser: boolean) => ({ userFormIsNewUser }), }, reducers: { dataLoading: [ @@ -134,6 +171,13 @@ export const RoleMappingsLogic = kea [], }, ], + singleUserRoleMappings: [ + [], + { + setRoleMappingsData: (_, { singleUserRoleMappings }) => singleUserRoleMappings, + resetState: () => [], + }, + ], multipleAuthProvidersConfig: [ false, { @@ -165,6 +209,14 @@ export const RoleMappingsLogic = kea elasticsearchRoles, + closeUsersAndRolesFlyout: () => [ANY_AUTH_PROVIDER], + }, + ], + elasticsearchUsers: [ + [], + { + setRoleMappingsData: (_, { elasticsearchUsers }) => elasticsearchUsers, + resetState: () => [], }, ], roleMapping: [ @@ -172,7 +224,7 @@ export const RoleMappingsLogic = kea roleMapping, resetState: () => null, - closeRoleMappingFlyout: () => null, + closeUsersAndRolesFlyout: () => null, }, ], roleType: [ @@ -188,6 +240,7 @@ export const RoleMappingsLogic = kea roleMapping.accessAllEngines, handleRoleChange: (_, { roleType }) => !roleHasScopedEngines(roleType), handleAccessAllEnginesChange: (_, { selected }) => selected, + closeUsersAndRolesFlyout: () => true, }, ], attributeValue: [ @@ -198,7 +251,7 @@ export const RoleMappingsLogic = kea value, resetState: () => '', - closeRoleMappingFlyout: () => '', + closeUsersAndRolesFlyout: () => '', }, ], attributeName: [ @@ -207,7 +260,7 @@ export const RoleMappingsLogic = kea getFirstAttributeName(roleMapping), handleAttributeSelectorChange: (_, { value }) => value, resetState: () => 'username', - closeRoleMappingFlyout: () => 'username', + closeUsersAndRolesFlyout: () => 'username', }, ], selectedEngines: [ @@ -222,6 +275,7 @@ export const RoleMappingsLogic = kea new Set(), }, ], availableAuthProviders: [ @@ -251,17 +305,68 @@ export const RoleMappingsLogic = kea true, - closeRoleMappingFlyout: () => false, + closeUsersAndRolesFlyout: () => false, initializeRoleMappings: () => false, initializeRoleMapping: () => true, }, ], + singleUserRoleMappingFlyoutOpen: [ + false, + { + openSingleUserRoleMappingFlyout: () => true, + closeUsersAndRolesFlyout: () => false, + initializeSingleUserRoleMapping: () => true, + }, + ], + singleUserRoleMapping: [ + null, + { + setSingleUserRoleMapping: (_, { singleUserRoleMapping }) => singleUserRoleMapping || null, + closeUsersAndRolesFlyout: () => null, + }, + ], roleMappingErrors: [ [], { setRoleMappingErrors: (_, { errors }) => errors, handleSaveMapping: () => [], - closeRoleMappingFlyout: () => [], + closeUsersAndRolesFlyout: () => [], + }, + ], + userFormUserIsExisting: [ + true, + { + setUserExistingRadioValue: (_, { userFormUserIsExisting }) => userFormUserIsExisting, + closeUsersAndRolesFlyout: () => true, + }, + ], + elasticsearchUser: [ + emptyUser, + { + setRoleMappingsData: (_, { elasticsearchUsers }) => elasticsearchUsers[0] || emptyUser, + setElasticsearchUser: (_, { elasticsearchUser }) => elasticsearchUser || emptyUser, + setElasticsearchUsernameValue: (state, { username }) => ({ + ...state, + username, + }), + setElasticsearchEmailValue: (state, { email }) => ({ + ...state, + email, + }), + closeUsersAndRolesFlyout: () => emptyUser, + }, + ], + userCreated: [ + false, + { + setUserCreated: () => true, + closeUsersAndRolesFlyout: () => false, + }, + ], + userFormIsNewUser: [ + true, + { + setUserFormIsNewUser: (_, { userFormIsNewUser }) => userFormIsNewUser, }, ], }, @@ -303,6 +408,17 @@ export const RoleMappingsLogic = kea id === roleMappingId); if (roleMapping) actions.setRoleMapping(roleMapping); }, + initializeSingleUserRoleMapping: ({ roleMappingId }) => { + const singleUserRoleMapping = values.singleUserRoleMappings.find( + ({ roleMapping }) => roleMapping.id === roleMappingId + ); + if (singleUserRoleMapping) { + actions.setElasticsearchUser(singleUserRoleMapping.elasticsearchUser); + actions.setRoleMapping(singleUserRoleMapping.roleMapping); + } + actions.setSingleUserRoleMapping(singleUserRoleMapping); + actions.setUserFormIsNewUser(!singleUserRoleMapping); + }, handleDeleteMapping: async ({ roleMappingId }) => { const { http } = HttpLogic.values; const route = `/api/app_search/role_mappings/${roleMappingId}`; @@ -357,11 +473,56 @@ export const RoleMappingsLogic = kea { clearFlashMessages(); }, - closeRoleMappingFlyout: () => { + handleSaveUser: async () => { + const { http } = HttpLogic.values; + const { + roleType, + singleUserRoleMapping, + accessAllEngines, + selectedEngines, + elasticsearchUser: { email, username }, + } = values; + + const body = JSON.stringify({ + roleMapping: { + engines: accessAllEngines ? [] : Array.from(selectedEngines), + roleType, + accessAllEngines, + id: singleUserRoleMapping?.roleMapping?.id, + }, + elasticsearchUser: { + username, + email, + }, + }); + + try { + const response = await http.post('/api/app_search/single_user_role_mapping', { body }); + actions.setSingleUserRoleMapping(response); + actions.setUserCreated(); + actions.initializeRoleMappings(); + } catch (e) { + actions.setRoleMappingErrors(e?.body?.attributes?.errors); + } + }, + closeUsersAndRolesFlyout: () => { clearFlashMessages(); + const firstUser = values.elasticsearchUsers[0]; + actions.setElasticsearchUser(firstUser); }, openRoleMappingFlyout: () => { clearFlashMessages(); }, + openSingleUserRoleMappingFlyout: () => { + clearFlashMessages(); + }, + setUserExistingRadioValue: ({ userFormUserIsExisting }) => { + const firstUser = values.elasticsearchUsers[0]; + actions.setElasticsearchUser(userFormUserIsExisting ? firstUser : emptyUser); + }, + handleUsernameSelectChange: ({ username }) => { + const user = values.elasticsearchUsers.find((u) => u.username === username); + if (user) actions.setElasticsearchUser(user); + }, }), }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/user.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/user.test.tsx new file mode 100644 index 00000000000000..88103532bd1492 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/user.test.tsx @@ -0,0 +1,124 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import '../../../__mocks__/react_router'; +import '../../../__mocks__/shallow_useeffect.mock'; +import { setMockActions, setMockValues } from '../../../__mocks__/kea_logic'; +import { engines } from '../../__mocks__/engines.mock'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { UserFlyout, UserAddedInfo, UserInvitationCallout } from '../../../shared/role_mapping'; +import { elasticsearchUsers } from '../../../shared/role_mapping/__mocks__/elasticsearch_users'; +import { wsSingleUserRoleMapping } from '../../../shared/role_mapping/__mocks__/roles'; + +import { EngineAssignmentSelector } from './engine_assignment_selector'; +import { User } from './user'; + +describe('User', () => { + const handleSaveUser = jest.fn(); + const closeUsersAndRolesFlyout = jest.fn(); + const setUserExistingRadioValue = jest.fn(); + const setElasticsearchUsernameValue = jest.fn(); + const setElasticsearchEmailValue = jest.fn(); + const handleRoleChange = jest.fn(); + const handleUsernameSelectChange = jest.fn(); + + const mockValues = { + availableEngines: [], + singleUserRoleMapping: null, + userFormUserIsExisting: false, + elasticsearchUsers: [], + elasticsearchUser: {}, + roleType: 'admin', + roleMappingErrors: [], + userCreated: false, + userFormIsNewUser: false, + hasAdvancedRoles: false, + }; + + beforeEach(() => { + setMockActions({ + handleSaveUser, + closeUsersAndRolesFlyout, + setUserExistingRadioValue, + setElasticsearchUsernameValue, + setElasticsearchEmailValue, + handleRoleChange, + handleUsernameSelectChange, + }); + + setMockValues(mockValues); + }); + + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(UserFlyout)).toHaveLength(1); + }); + + it('renders engine assignment selector when groups present', () => { + setMockValues({ ...mockValues, availableEngines: engines, hasAdvancedRoles: true }); + const wrapper = shallow(); + + expect(wrapper.find(EngineAssignmentSelector)).toHaveLength(1); + }); + + it('renders userInvitationCallout', () => { + setMockValues({ + ...mockValues, + singleUserRoleMapping: wsSingleUserRoleMapping, + }); + const wrapper = shallow(); + + expect(wrapper.find(UserInvitationCallout)).toHaveLength(1); + }); + + it('renders user added info when user created', () => { + setMockValues({ + ...mockValues, + singleUserRoleMapping: wsSingleUserRoleMapping, + userCreated: true, + }); + const wrapper = shallow(); + + expect(wrapper.find(UserAddedInfo)).toHaveLength(1); + }); + + it('disables form when username value not present', () => { + setMockValues({ + ...mockValues, + singleUserRoleMapping: wsSingleUserRoleMapping, + elasticsearchUsers, + elasticsearchUser: { + username: null, + email: 'email@user.com', + }, + }); + const wrapper = shallow(); + + expect(wrapper.find(UserFlyout).prop('disabled')).toEqual(true); + }); + + it('enables form when userFormUserIsExisting', () => { + setMockValues({ + ...mockValues, + userFormUserIsExisting: true.valueOf, + singleUserRoleMapping: wsSingleUserRoleMapping, + elasticsearchUsers, + elasticsearchUser: { + username: null, + email: 'email@user.com', + }, + }); + const wrapper = shallow(); + + expect(wrapper.find(UserFlyout).prop('disabled')).toEqual(false); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/user.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/user.tsx new file mode 100644 index 00000000000000..df231fac64df74 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/user.tsx @@ -0,0 +1,106 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { useActions, useValues } from 'kea'; + +import { EuiForm } from '@elastic/eui'; + +import { getAppSearchUrl } from '../../../shared/enterprise_search_url'; +import { + UserFlyout, + UserSelector, + UserAddedInfo, + UserInvitationCallout, +} from '../../../shared/role_mapping'; +import { RoleTypes } from '../../types'; + +import { EngineAssignmentSelector } from './engine_assignment_selector'; +import { RoleMappingsLogic } from './role_mappings_logic'; + +const standardRoles = (['owner', 'admin'] as unknown) as RoleTypes[]; +const advancedRoles = (['dev', 'editor', 'analyst'] as unknown) as RoleTypes[]; + +export const User: React.FC = () => { + const { + handleSaveUser, + closeUsersAndRolesFlyout, + setUserExistingRadioValue, + setElasticsearchUsernameValue, + setElasticsearchEmailValue, + handleRoleChange, + handleUsernameSelectChange, + } = useActions(RoleMappingsLogic); + + const { + availableEngines, + singleUserRoleMapping, + hasAdvancedRoles, + userFormUserIsExisting, + elasticsearchUsers, + elasticsearchUser, + roleType, + roleMappingErrors, + userCreated, + userFormIsNewUser, + } = useValues(RoleMappingsLogic); + + const roleTypes = hasAdvancedRoles ? [...standardRoles, ...advancedRoles] : standardRoles; + const hasEngines = availableEngines.length > 0; + const showEngineAssignmentSelector = hasEngines && hasAdvancedRoles; + const flyoutDisabled = + !userFormUserIsExisting && (!elasticsearchUser.email || !elasticsearchUser.username); + + const userAddedInfo = singleUserRoleMapping && ( + + ); + + const userInvitationCallout = singleUserRoleMapping?.invitation && ( + + ); + + const createUserForm = ( + 0} error={roleMappingErrors}> + + {showEngineAssignmentSelector && } + + ); + + return ( + + {userCreated ? userAddedInfo : createUserForm} + {userInvitationCallout} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx index 2402a6ecc64016..00acea945177a5 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx @@ -196,6 +196,6 @@ describe('AppSearchNav', () => { setMockValues({ myRole: { canViewRoleMappings: true } }); const wrapper = shallow(); - expect(wrapper.find(SideNavLink).last().prop('to')).toEqual('/role_mappings'); + expect(wrapper.find(SideNavLink).last().prop('to')).toEqual('/users_and_roles'); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx index 191758af267583..d7ddad5683f389 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx @@ -37,7 +37,7 @@ import { SETUP_GUIDE_PATH, SETTINGS_PATH, CREDENTIALS_PATH, - ROLE_MAPPINGS_PATH, + USERS_AND_ROLES_PATH, ENGINES_PATH, ENGINE_PATH, LIBRARY_PATH, @@ -128,7 +128,7 @@ export const AppSearchConfigured: React.FC> = (props) = )} {canViewRoleMappings && ( - + )} @@ -162,7 +162,7 @@ export const AppSearchNav: React.FC = () => { {CREDENTIALS_TITLE} )} {canViewRoleMappings && ( - + {ROLE_MAPPINGS_TITLE} )} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts index d9d1935c648f72..f086a32bbf5901 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts @@ -15,7 +15,7 @@ export const LIBRARY_PATH = '/library'; export const SETTINGS_PATH = '/settings'; export const CREDENTIALS_PATH = '/credentials'; -export const ROLE_MAPPINGS_PATH = '/role_mappings'; +export const USERS_AND_ROLES_PATH = '/users_and_roles'; export const ENGINES_PATH = '/engines'; export const ENGINE_CREATION_PATH = `${ENGINES_PATH}/new`; // This is safe from conflicting with an :engineName path because new is a reserved name diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/constants.ts b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/constants.ts index 45cab32b67e088..215c76ffb7ef41 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/constants.ts @@ -136,7 +136,7 @@ export const FILTER_ROLE_MAPPINGS_PLACEHOLDER = i18n.translate( export const ROLE_MAPPINGS_TITLE = i18n.translate( 'xpack.enterpriseSearch.roleMapping.roleMappingsTitle', { - defaultMessage: 'Users & roles', + defaultMessage: 'Users and roles', } ); @@ -406,3 +406,19 @@ export const FILTER_USERS_LABEL = i18n.translate( export const NO_USERS_LABEL = i18n.translate('xpack.enterpriseSearch.roleMapping.noUsersLabel', { defaultMessage: 'No matching users found', }); + +export const EXTERNAL_ATTRIBUTE_TOOLTIP = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.externalAttributeTooltip', + { + defaultMessage: + 'External attributes are defined by the identity provider, and varies from service to service.', + } +); + +export const AUTH_PROVIDER_TOOLTIP = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.authProviderTooltip', + { + defaultMessage: + 'Provider-specific role mapping is still applied, but configuration is now deprecated.', + } +); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mapping_flyout.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mapping_flyout.test.tsx index c0973bb2c95044..ffcf5508233fcf 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mapping_flyout.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mapping_flyout.test.tsx @@ -20,13 +20,13 @@ import { import { RoleMappingFlyout } from './role_mapping_flyout'; describe('RoleMappingFlyout', () => { - const closeRoleMappingFlyout = jest.fn(); + const closeUsersAndRolesFlyout = jest.fn(); const handleSaveMapping = jest.fn(); const props = { isNew: true, disabled: false, - closeRoleMappingFlyout, + closeUsersAndRolesFlyout, handleSaveMapping, }; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mapping_flyout.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mapping_flyout.tsx index bae991fef36550..4416a2de28011a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mapping_flyout.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mapping_flyout.tsx @@ -36,7 +36,7 @@ interface Props { children: React.ReactNode; isNew: boolean; disabled: boolean; - closeRoleMappingFlyout(): void; + closeUsersAndRolesFlyout(): void; handleSaveMapping(): void; } @@ -44,13 +44,13 @@ export const RoleMappingFlyout: React.FC = ({ children, isNew, disabled, - closeRoleMappingFlyout, + closeUsersAndRolesFlyout, handleSaveMapping, }) => ( @@ -71,7 +71,9 @@ export const RoleMappingFlyout: React.FC = ({ - {CANCEL_BUTTON_LABEL} + + {CANCEL_BUTTON_LABEL} + { }); it('renders auth provider display names', () => { - const wrapper = mount(); + const roleMappingWithAuths = { + ...wsRoleMapping, + authProvider: ['saml', 'native'], + }; + const wrapper = mount(); - expect(wrapper.find('[data-test-subj="AuthProviderDisplayValue"]').prop('children')).toEqual( - `${ANY_AUTH_PROVIDER_OPTION_LABEL}, other_auth` - ); + expect(wrapper.find('[data-test-subj="ProviderSpecificList"]')).toHaveLength(1); }); it('handles manage click', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.tsx index eb9621c7a242c2..4136d114d34207 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.tsx @@ -7,14 +7,17 @@ import React from 'react'; -import { EuiIconTip, EuiInMemoryTable, EuiBasicTableColumn } from '@elastic/eui'; +import { EuiIconTip, EuiInMemoryTable, EuiBasicTableColumn, EuiLink } from '@elastic/eui'; import { ASRoleMapping } from '../../app_search/types'; import { WSRoleMapping } from '../../workplace_search/types'; +import { docLinks } from '../doc_links'; import { RoleRules } from '../types'; import './role_mappings_table.scss'; +const AUTH_PROVIDER_DOCUMENTATION_URL = `${docLinks.enterpriseSearchBase}/users-access.html`; + import { ANY_AUTH_PROVIDER, ANY_AUTH_PROVIDER_OPTION_LABEL, @@ -25,6 +28,8 @@ import { ATTRIBUTE_VALUE_LABEL, FILTER_ROLE_MAPPINGS_PLACEHOLDER, ROLE_MAPPINGS_NO_RESULTS_MESSAGE, + EXTERNAL_ATTRIBUTE_TOOLTIP, + AUTH_PROVIDER_TOOLTIP, } from './constants'; import { UsersAndRolesRowActions } from './users_and_roles_row_actions'; @@ -46,9 +51,6 @@ interface Props { handleDeleteMapping(roleMappingId: string): void; } -const getAuthProviderDisplayValue = (authProvider: string) => - authProvider === ANY_AUTH_PROVIDER ? ANY_AUTH_PROVIDER_OPTION_LABEL : authProvider; - export const RoleMappingsTable: React.FC = ({ accessItemKey, accessHeader, @@ -69,7 +71,19 @@ export const RoleMappingsTable: React.FC = ({ const attributeNameCol: EuiBasicTableColumn = { field: 'attribute', - name: EXTERNAL_ATTRIBUTE_LABEL, + name: ( + + {EXTERNAL_ATTRIBUTE_LABEL}{' '} + + + ), render: (_, { rules }: SharedRoleMapping) => getFirstAttributeName(rules), }; @@ -105,11 +119,19 @@ export const RoleMappingsTable: React.FC = ({ const authProviderCol: EuiBasicTableColumn = { field: 'authProvider', name: AUTH_PROVIDER_LABEL, - render: (_, { authProvider }: SharedRoleMapping) => ( - - {authProvider.map(getAuthProviderDisplayValue).join(', ')} - - ), + render: (_, { authProvider }: SharedRoleMapping) => { + if (authProvider[0] === ANY_AUTH_PROVIDER) { + return ANY_AUTH_PROVIDER_OPTION_LABEL; + } + return ( + + {authProvider.join(', ')}{' '} + + + + + ); + }, }; const actionsCol: EuiBasicTableColumn = { diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_added_info.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_added_info.test.tsx index 30bdaa0010b584..57200b389591da 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_added_info.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_added_info.test.tsx @@ -9,8 +9,6 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { EuiText } from '@elastic/eui'; - import { UserAddedInfo } from './'; describe('UserAddedInfo', () => { @@ -20,9 +18,103 @@ describe('UserAddedInfo', () => { roleType: 'user', }; - it('renders', () => { + it('renders with email', () => { const wrapper = shallow(); - expect(wrapper.find(EuiText)).toHaveLength(6); + expect(wrapper).toMatchInlineSnapshot(` + + + + Username + + + + user1 + + + + + Email + + + + test@test.com + + + + + Role + + + + user + + + + `); + }); + + it('renders without email', () => { + const wrapper = shallow(); + + expect(wrapper).toMatchInlineSnapshot(` + + + + Username + + + + user1 + + + + + Email + + + + + — + + + + + + Role + + + + user + + + + `); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_added_info.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_added_info.tsx index a12eae66262a06..37804414a94a96 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_added_info.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_added_info.tsx @@ -7,7 +7,7 @@ import React from 'react'; -import { EuiSpacer, EuiText } from '@elastic/eui'; +import { EuiSpacer, EuiText, EuiTextColor } from '@elastic/eui'; import { USERNAME_LABEL, EMAIL_LABEL } from '../constants'; @@ -19,6 +19,8 @@ interface Props { roleType: string; } +const noItemsPlaceholder = ; + export const UserAddedInfo: React.FC = ({ username, email, roleType }) => ( <> @@ -29,7 +31,7 @@ export const UserAddedInfo: React.FC = ({ username, email, roleType }) => {EMAIL_LABEL} - {email} + {email || noItemsPlaceholder} {ROLE_LABEL} diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_flyout.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_flyout.tsx index e13a56a716929f..a3be5e295ddfeb 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_flyout.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_flyout.tsx @@ -17,6 +17,7 @@ import { EuiFlyoutFooter, EuiFlyoutHeader, EuiIcon, + EuiPortal, EuiText, EuiTitle, EuiSpacer, @@ -92,22 +93,26 @@ export const UserFlyout: React.FC = ({ ); return ( - - - -

{isComplete ? IS_COMPLETE_HEADING : IS_EDITING_HEADING}

-
- {!isComplete && ( - -

{IS_EDITING_DESCRIPTION}

-
- )} -
- - {children} - - - {isComplete ? completedFooterAction : editingFooterActions} -
+ + + + +

{isComplete ? IS_COMPLETE_HEADING : IS_EDITING_HEADING}

+
+ {!isComplete && ( + +

{IS_EDITING_DESCRIPTION}

+
+ )} +
+ + {children} + + + + {isComplete ? completedFooterAction : editingFooterActions} + +
+
); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_invitation_callout.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_invitation_callout.tsx index 8310077ad6f2e3..d6d0ce7b050ab0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_invitation_callout.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_invitation_callout.tsx @@ -23,7 +23,7 @@ interface Props { } export const UserInvitationCallout: React.FC = ({ isNew, invitationCode, urlPrefix }) => { - const link = urlPrefix + invitationCode; + const link = `${urlPrefix}/invitations/${invitationCode}`; const label = isNew ? NEW_INVITATION_LABEL : EXISTING_INVITATION_LABEL; return ( diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_selector.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_selector.test.tsx index 08ddc7ba5427fa..60bac97d09835b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_selector.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_selector.test.tsx @@ -13,7 +13,7 @@ import { shallow } from 'enzyme'; import { EuiFormRow } from '@elastic/eui'; -import { Role as ASRole } from '../../app_search/types'; +import { RoleTypes as ASRole } from '../../app_search/types'; import { REQUIRED_LABEL, USERNAME_NO_USERS_TEXT } from './constants'; @@ -107,6 +107,5 @@ describe('UserSelector', () => { expect(wrapper.find(EuiFormRow).at(0).prop('helpText')).toEqual(USERNAME_NO_USERS_TEXT); expect(wrapper.find(EuiFormRow).at(1).prop('helpText')).toEqual(REQUIRED_LABEL); - expect(wrapper.find(EuiFormRow).at(2).prop('helpText')).toEqual(REQUIRED_LABEL); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_selector.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_selector.tsx index 70348bf29894aa..d65f97265f6a3e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_selector.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_selector.tsx @@ -16,7 +16,7 @@ import { EuiSpacer, } from '@elastic/eui'; -import { Role as ASRole } from '../../app_search/types'; +import { RoleTypes as ASRole } from '../../app_search/types'; import { ElasticsearchUser } from '../../shared/types'; import { Role as WSRole } from '../../workplace_search/types'; @@ -80,7 +80,7 @@ export const UserSelector: React.FC = ({ ); const emailInput = ( - + = ({ setElasticsearchUsernameValue(e.target.value)} diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_table.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_table.tsx index 86dc2c2626229f..674796775b1d32 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_table.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_table.tsx @@ -46,8 +46,8 @@ interface SharedRoleMapping extends ASRoleMapping, WSRoleMapping { interface Props { accessItemKey: 'groups' | 'engines'; singleUserRoleMappings: Array>; - initializeSingleUserRoleMapping(roleId: string): string; - handleDeleteMapping(roleId: string): string; + initializeSingleUserRoleMapping(roleMappingId: string): void; + handleDeleteMapping(roleMappingId: string): void; } const noItemsPlaceholder = ; @@ -110,6 +110,7 @@ export const UsersTable: React.FC = ({ { field: 'id', name: '', + align: 'right', render: (_, { id, username }: SharedUser) => ( { }, { id: 'usersRoles', - name: 'Users & roles', - href: '/role_mappings', + name: 'Users and roles', + href: '/users_and_roles', }, { id: 'security', diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx index ce2f8bf7ef7e46..c8d821dcdae2e4 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx @@ -15,7 +15,7 @@ import { NAV } from '../../constants'; import { SOURCES_PATH, SECURITY_PATH, - ROLE_MAPPINGS_PATH, + USERS_AND_ROLES_PATH, GROUPS_PATH, ORG_SETTINGS_PATH, } from '../../routes'; @@ -48,7 +48,7 @@ export const useWorkplaceSearchNav = () => { { id: 'usersRoles', name: NAV.ROLE_MAPPINGS, - ...generateNavLink({ to: ROLE_MAPPINGS_PATH }), + ...generateNavLink({ to: USERS_AND_ROLES_PATH }), }, { id: 'security', @@ -92,7 +92,7 @@ export const WorkplaceSearchNav: React.FC = ({ {NAV.GROUPS} - + {NAV.ROLE_MAPPINGS} {NAV.SECURITY} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts index aa5419f12c7f30..cf459171a808a2 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts @@ -40,7 +40,7 @@ export const NAV = { defaultMessage: 'Content', }), ROLE_MAPPINGS: i18n.translate('xpack.enterpriseSearch.workplaceSearch.nav.roleMappings', { - defaultMessage: 'Users & roles', + defaultMessage: 'Users and roles', }), SECURITY: i18n.translate('xpack.enterpriseSearch.workplaceSearch.nav.security', { defaultMessage: 'Security', diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx index 8a1e9c02753225..05018be2934b42 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx @@ -26,7 +26,7 @@ import { SOURCE_ADDED_PATH, PERSONAL_SOURCES_PATH, ORG_SETTINGS_PATH, - ROLE_MAPPINGS_PATH, + USERS_AND_ROLES_PATH, SECURITY_PATH, PERSONAL_SETTINGS_PATH, PERSONAL_PATH, @@ -103,7 +103,7 @@ export const WorkplaceSearchConfigured: React.FC = (props) => { - + 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 3c564c1f912ecc..b9309ffd948091 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 @@ -48,7 +48,7 @@ export const ENT_SEARCH_LICENSE_MANAGEMENT = `${docLinks.enterpriseSearchBase}/l export const PERSONAL_PATH = '/p'; -export const ROLE_MAPPINGS_PATH = '/role_mappings'; +export const USERS_AND_ROLES_PATH = '/users_and_roles'; export const USERS_PATH = '/users'; export const SECURITY_PATH = '/security'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mapping.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mapping.tsx index cc773895bff1c4..20211d40d7010b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mapping.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mapping.tsx @@ -43,7 +43,7 @@ export const RoleMapping: React.FC = () => { handleAttributeSelectorChange, handleRoleChange, handleAuthProviderChange, - closeRoleMappingFlyout, + closeUsersAndRolesFlyout, } = useActions(RoleMappingsLogic); const { @@ -70,7 +70,7 @@ export const RoleMapping: React.FC = () => { 0} error={roleMappingErrors}> diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings.test.tsx index 308022ccb2e5a7..2e13f24a13eee9 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings.test.tsx @@ -12,26 +12,39 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { RoleMappingsTable, RoleMappingsHeading } from '../../../shared/role_mapping'; -import { wsRoleMapping } from '../../../shared/role_mapping/__mocks__/roles'; +import { + RoleMappingsTable, + RoleMappingsHeading, + UsersHeading, + UsersEmptyPrompt, +} from '../../../shared/role_mapping'; +import { + wsRoleMapping, + wsSingleUserRoleMapping, +} from '../../../shared/role_mapping/__mocks__/roles'; import { RoleMapping } from './role_mapping'; import { RoleMappings } from './role_mappings'; +import { User } from './user'; describe('RoleMappings', () => { const initializeRoleMappings = jest.fn(); const initializeRoleMapping = jest.fn(); + const initializeSingleUserRoleMapping = jest.fn(); const handleDeleteMapping = jest.fn(); const mockValues = { roleMappings: [wsRoleMapping], dataLoading: false, multipleAuthProvidersConfig: false, + singleUserRoleMappings: [wsSingleUserRoleMapping], + singleUserRoleMappingFlyoutOpen: false, }; beforeEach(() => { setMockActions({ initializeRoleMappings, initializeRoleMapping, + initializeSingleUserRoleMapping, handleDeleteMapping, }); setMockValues(mockValues); @@ -50,10 +63,31 @@ describe('RoleMappings', () => { expect(wrapper.find(RoleMapping)).toHaveLength(1); }); - it('handles onClick', () => { + it('renders User flyout', () => { + setMockValues({ ...mockValues, singleUserRoleMappingFlyoutOpen: true }); + const wrapper = shallow(); + + expect(wrapper.find(User)).toHaveLength(1); + }); + + it('handles RoleMappingsHeading onClick', () => { const wrapper = shallow(); wrapper.find(RoleMappingsHeading).prop('onClick')(); expect(initializeRoleMapping).toHaveBeenCalled(); }); + + it('handles UsersHeading onClick', () => { + const wrapper = shallow(); + wrapper.find(UsersHeading).prop('onClick')(); + + expect(initializeSingleUserRoleMapping).toHaveBeenCalled(); + }); + + it('handles empty users state', () => { + setMockValues({ ...mockValues, singleUserRoleMappings: [] }); + const wrapper = shallow(); + + expect(wrapper.find(UsersEmptyPrompt)).toHaveLength(1); + }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings.tsx index 01d32bec14ebd7..df5d7e42676900 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings.tsx @@ -9,11 +9,16 @@ import React, { useEffect } from 'react'; import { useActions, useValues } from 'kea'; +import { EuiSpacer } from '@elastic/eui'; + import { WORKPLACE_SEARCH_PLUGIN } from '../../../../../common/constants'; import { RoleMappingsTable, RoleMappingsHeading, RolesEmptyPrompt, + UsersTable, + UsersHeading, + UsersEmptyPrompt, } from '../../../shared/role_mapping'; import { ROLE_MAPPINGS_TITLE } from '../../../shared/role_mapping/constants'; import { WorkplaceSearchPageTemplate } from '../../components/layout'; @@ -23,26 +28,32 @@ import { ROLE_MAPPINGS_TABLE_HEADER } from './constants'; import { RoleMapping } from './role_mapping'; import { RoleMappingsLogic } from './role_mappings_logic'; +import { User } from './user'; export const RoleMappings: React.FC = () => { const { enableRoleBasedAccess, initializeRoleMappings, initializeRoleMapping, + initializeSingleUserRoleMapping, handleDeleteMapping, } = useActions(RoleMappingsLogic); const { roleMappings, + singleUserRoleMappings, dataLoading, multipleAuthProvidersConfig, roleMappingFlyoutOpen, + singleUserRoleMappingFlyoutOpen, } = useValues(RoleMappingsLogic); useEffect(() => { initializeRoleMappings(); }, []); + const hasUsers = singleUserRoleMappings.length > 0; + const rolesEmptyState = ( { ); + const usersTable = ( + + ); + + const usersSection = ( + <> + initializeSingleUserRoleMapping()} /> + + {hasUsers ? usersTable : } + + ); + return ( { emptyState={rolesEmptyState} > {roleMappingFlyoutOpen && } + {singleUserRoleMappingFlyoutOpen && } {roleMappingsSection} + + {usersSection} ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_logic.test.ts index a4bbddbd23b497..c85e86ebcca2cb 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_logic.test.ts @@ -15,11 +15,18 @@ import { groups } from '../../__mocks__/groups.mock'; import { nextTick } from '@kbn/test/jest'; -import { wsRoleMapping } from '../../../shared/role_mapping/__mocks__/roles'; +import { elasticsearchUsers } from '../../../shared/role_mapping/__mocks__/elasticsearch_users'; + +import { + wsRoleMapping, + wsSingleUserRoleMapping, +} from '../../../shared/role_mapping/__mocks__/roles'; import { ANY_AUTH_PROVIDER } from '../../../shared/role_mapping/constants'; import { RoleMappingsLogic } from './role_mappings_logic'; +const emptyUser = { username: '', email: '' }; + describe('RoleMappingsLogic', () => { const { http } = mockHttpValues; const { clearFlashMessages, flashAPIErrors } = mockFlashMessageHelpers; @@ -28,6 +35,8 @@ describe('RoleMappingsLogic', () => { attributes: [], availableAuthProviders: [], elasticsearchRoles: [], + elasticsearchUser: emptyUser, + elasticsearchUsers: [], roleMapping: null, roleMappingFlyoutOpen: false, roleMappings: [], @@ -42,6 +51,12 @@ describe('RoleMappingsLogic', () => { selectedAuthProviders: [ANY_AUTH_PROVIDER], selectedOptions: [], roleMappingErrors: [], + singleUserRoleMapping: null, + singleUserRoleMappings: [], + singleUserRoleMappingFlyoutOpen: false, + userCreated: false, + userFormIsNewUser: true, + userFormUserIsExisting: true, }; const roleGroup = { id: '123', @@ -59,6 +74,8 @@ describe('RoleMappingsLogic', () => { authProviders: [], availableGroups: [roleGroup, defaultGroup], elasticsearchRoles: [], + singleUserRoleMappings: [wsSingleUserRoleMapping], + elasticsearchUsers, }; beforeEach(() => { @@ -71,23 +88,36 @@ describe('RoleMappingsLogic', () => { }); describe('actions', () => { - it('setRoleMappingsData', () => { - RoleMappingsLogic.actions.setRoleMappingsData(mappingsServerProps); + describe('setRoleMappingsData', () => { + it('sets data based on server response from the `mappings` (plural) endpoint', () => { + RoleMappingsLogic.actions.setRoleMappingsData(mappingsServerProps); - expect(RoleMappingsLogic.values.roleMappings).toEqual([wsRoleMapping]); - expect(RoleMappingsLogic.values.dataLoading).toEqual(false); - expect(RoleMappingsLogic.values.multipleAuthProvidersConfig).toEqual(true); - expect(RoleMappingsLogic.values.dataLoading).toEqual(false); - expect(RoleMappingsLogic.values.attributes).toEqual(mappingsServerProps.attributes); - expect(RoleMappingsLogic.values.availableGroups).toEqual(mappingsServerProps.availableGroups); - expect(RoleMappingsLogic.values.includeInAllGroups).toEqual(false); - expect(RoleMappingsLogic.values.elasticsearchRoles).toEqual( - mappingsServerProps.elasticsearchRoles - ); - expect(RoleMappingsLogic.values.selectedOptions).toEqual([ - { label: defaultGroup.name, value: defaultGroup.id }, - ]); - expect(RoleMappingsLogic.values.selectedGroups).toEqual(new Set([defaultGroup.id])); + expect(RoleMappingsLogic.values.roleMappings).toEqual([wsRoleMapping]); + expect(RoleMappingsLogic.values.dataLoading).toEqual(false); + expect(RoleMappingsLogic.values.multipleAuthProvidersConfig).toEqual(true); + expect(RoleMappingsLogic.values.dataLoading).toEqual(false); + expect(RoleMappingsLogic.values.attributes).toEqual(mappingsServerProps.attributes); + expect(RoleMappingsLogic.values.availableGroups).toEqual( + mappingsServerProps.availableGroups + ); + expect(RoleMappingsLogic.values.includeInAllGroups).toEqual(false); + expect(RoleMappingsLogic.values.elasticsearchRoles).toEqual( + mappingsServerProps.elasticsearchRoles + ); + expect(RoleMappingsLogic.values.selectedOptions).toEqual([ + { label: defaultGroup.name, value: defaultGroup.id }, + ]); + expect(RoleMappingsLogic.values.selectedGroups).toEqual(new Set([defaultGroup.id])); + }); + + it('handles fallback if no elasticsearch users present', () => { + RoleMappingsLogic.actions.setRoleMappingsData({ + ...mappingsServerProps, + elasticsearchUsers: [], + }); + + expect(RoleMappingsLogic.values.elasticsearchUser).toEqual(emptyUser); + }); }); it('setRoleMappings', () => { @@ -97,6 +127,26 @@ describe('RoleMappingsLogic', () => { expect(RoleMappingsLogic.values.dataLoading).toEqual(false); }); + describe('setElasticsearchUser', () => { + it('sets user', () => { + RoleMappingsLogic.actions.setElasticsearchUser(elasticsearchUsers[0]); + + expect(RoleMappingsLogic.values.elasticsearchUser).toEqual(elasticsearchUsers[0]); + }); + + it('handles fallback if no user present', () => { + RoleMappingsLogic.actions.setElasticsearchUser(undefined); + + expect(RoleMappingsLogic.values.elasticsearchUser).toEqual(emptyUser); + }); + }); + + it('setSingleUserRoleMapping', () => { + RoleMappingsLogic.actions.setSingleUserRoleMapping(wsSingleUserRoleMapping); + + expect(RoleMappingsLogic.values.singleUserRoleMapping).toEqual(wsSingleUserRoleMapping); + }); + it('handleRoleChange', () => { RoleMappingsLogic.actions.handleRoleChange('user'); @@ -133,6 +183,12 @@ describe('RoleMappingsLogic', () => { expect(RoleMappingsLogic.values.includeInAllGroups).toEqual(true); }); + it('setUserExistingRadioValue', () => { + RoleMappingsLogic.actions.setUserExistingRadioValue(false); + + expect(RoleMappingsLogic.values.userFormUserIsExisting).toEqual(false); + }); + describe('handleAttributeSelectorChange', () => { const elasticsearchRoles = ['foo', 'bar']; @@ -228,16 +284,50 @@ describe('RoleMappingsLogic', () => { expect(clearFlashMessages).toHaveBeenCalled(); }); - it('closeRoleMappingFlyout', () => { + it('openSingleUserRoleMappingFlyout', () => { + mount(mappingsServerProps); + RoleMappingsLogic.actions.openSingleUserRoleMappingFlyout(); + + expect(RoleMappingsLogic.values.singleUserRoleMappingFlyoutOpen).toEqual(true); + expect(clearFlashMessages).toHaveBeenCalled(); + }); + + it('closeUsersAndRolesFlyout', () => { mount({ ...mappingsServerProps, roleMappingFlyoutOpen: true, }); - RoleMappingsLogic.actions.closeRoleMappingFlyout(); + RoleMappingsLogic.actions.closeUsersAndRolesFlyout(); expect(RoleMappingsLogic.values.roleMappingFlyoutOpen).toEqual(false); expect(clearFlashMessages).toHaveBeenCalled(); }); + + it('setElasticsearchUsernameValue', () => { + const username = 'newName'; + RoleMappingsLogic.actions.setElasticsearchUsernameValue(username); + + expect(RoleMappingsLogic.values.elasticsearchUser).toEqual({ + ...RoleMappingsLogic.values.elasticsearchUser, + username, + }); + }); + + it('setElasticsearchEmailValue', () => { + const email = 'newEmail@foo.cats'; + RoleMappingsLogic.actions.setElasticsearchEmailValue(email); + + expect(RoleMappingsLogic.values.elasticsearchUser).toEqual({ + ...RoleMappingsLogic.values.elasticsearchUser, + email, + }); + }); + + it('setUserCreated', () => { + RoleMappingsLogic.actions.setUserCreated(); + + expect(RoleMappingsLogic.values.userCreated).toEqual(true); + }); }); describe('listeners', () => { @@ -303,6 +393,39 @@ describe('RoleMappingsLogic', () => { }); }); + describe('initializeSingleUserRoleMapping', () => { + let setElasticsearchUserSpy: jest.MockedFunction; + let setRoleMappingSpy: jest.MockedFunction; + let setSingleUserRoleMappingSpy: jest.MockedFunction; + beforeEach(() => { + setElasticsearchUserSpy = jest.spyOn(RoleMappingsLogic.actions, 'setElasticsearchUser'); + setRoleMappingSpy = jest.spyOn(RoleMappingsLogic.actions, 'setRoleMapping'); + setSingleUserRoleMappingSpy = jest.spyOn( + RoleMappingsLogic.actions, + 'setSingleUserRoleMapping' + ); + }); + + it('should handle the new user state and only set an empty mapping', () => { + RoleMappingsLogic.actions.initializeSingleUserRoleMapping(); + + expect(setElasticsearchUserSpy).not.toHaveBeenCalled(); + expect(setRoleMappingSpy).not.toHaveBeenCalled(); + expect(setSingleUserRoleMappingSpy).toHaveBeenCalledWith(undefined); + }); + + it('should handle an existing user state and set mapping', () => { + RoleMappingsLogic.actions.setRoleMappingsData(mappingsServerProps); + RoleMappingsLogic.actions.initializeSingleUserRoleMapping( + wsSingleUserRoleMapping.roleMapping.id + ); + + expect(setElasticsearchUserSpy).toHaveBeenCalled(); + expect(setRoleMappingSpy).toHaveBeenCalled(); + expect(setSingleUserRoleMappingSpy).toHaveBeenCalledWith(wsSingleUserRoleMapping); + }); + }); + describe('handleSaveMapping', () => { it('calls API and refreshes list when new mapping', async () => { const initializeRoleMappingsSpy = jest.spyOn( @@ -381,6 +504,100 @@ describe('RoleMappingsLogic', () => { }); }); + describe('handleSaveUser', () => { + it('calls API and refreshes list when new mapping', async () => { + const initializeRoleMappingsSpy = jest.spyOn( + RoleMappingsLogic.actions, + 'initializeRoleMappings' + ); + const setUserCreatedSpy = jest.spyOn(RoleMappingsLogic.actions, 'setUserCreated'); + const setSingleUserRoleMappingSpy = jest.spyOn( + RoleMappingsLogic.actions, + 'setSingleUserRoleMapping' + ); + RoleMappingsLogic.actions.setRoleMappingsData(mappingsServerProps); + + http.post.mockReturnValue(Promise.resolve(mappingsServerProps)); + RoleMappingsLogic.actions.handleSaveUser(); + + expect(http.post).toHaveBeenCalledWith( + '/api/workplace_search/org/single_user_role_mapping', + { + body: JSON.stringify({ + roleMapping: { + groups: [defaultGroup.id], + roleType: 'admin', + allGroups: false, + }, + elasticsearchUser: { + username: elasticsearchUsers[0].username, + email: elasticsearchUsers[0].email, + }, + }), + } + ); + await nextTick(); + + expect(initializeRoleMappingsSpy).toHaveBeenCalled(); + expect(setUserCreatedSpy).toHaveBeenCalled(); + expect(setSingleUserRoleMappingSpy).toHaveBeenCalled(); + }); + + it('calls API and refreshes list when existing mapping', async () => { + const initializeRoleMappingsSpy = jest.spyOn( + RoleMappingsLogic.actions, + 'initializeRoleMappings' + ); + RoleMappingsLogic.actions.setSingleUserRoleMapping(wsSingleUserRoleMapping); + RoleMappingsLogic.actions.handleAllGroupsSelectionChange(true); + + http.put.mockReturnValue(Promise.resolve(mappingsServerProps)); + RoleMappingsLogic.actions.handleSaveUser(); + + expect(http.post).toHaveBeenCalledWith( + '/api/workplace_search/org/single_user_role_mapping', + { + body: JSON.stringify({ + roleMapping: { + groups: [], + roleType: 'admin', + allGroups: true, + id: wsSingleUserRoleMapping.roleMapping.id, + }, + elasticsearchUser: { + username: '', + email: '', + }, + }), + } + ); + await nextTick(); + + expect(initializeRoleMappingsSpy).toHaveBeenCalled(); + }); + + it('handles error', async () => { + const setRoleMappingErrorsSpy = jest.spyOn( + RoleMappingsLogic.actions, + 'setRoleMappingErrors' + ); + + http.post.mockReturnValue( + Promise.reject({ + body: { + attributes: { + errors: ['this is an error'], + }, + }, + }) + ); + RoleMappingsLogic.actions.handleSaveUser(); + await nextTick(); + + expect(setRoleMappingErrorsSpy).toHaveBeenCalledWith(['this is an error']); + }); + }); + describe('handleDeleteMapping', () => { const roleMappingId = 'r1'; @@ -410,5 +627,52 @@ describe('RoleMappingsLogic', () => { expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); }); + + describe('handleUsernameSelectChange', () => { + it('sets elasticsearchUser when match found', () => { + RoleMappingsLogic.actions.setRoleMappingsData(mappingsServerProps); + const setElasticsearchUserSpy = jest.spyOn( + RoleMappingsLogic.actions, + 'setElasticsearchUser' + ); + RoleMappingsLogic.actions.handleUsernameSelectChange(elasticsearchUsers[0].username); + + expect(setElasticsearchUserSpy).toHaveBeenCalledWith(elasticsearchUsers[0]); + }); + + it('does not set elasticsearchUser when no match found', () => { + RoleMappingsLogic.actions.setRoleMappingsData(mappingsServerProps); + const setElasticsearchUserSpy = jest.spyOn( + RoleMappingsLogic.actions, + 'setElasticsearchUser' + ); + RoleMappingsLogic.actions.handleUsernameSelectChange('bogus'); + + expect(setElasticsearchUserSpy).not.toHaveBeenCalled(); + }); + }); + + describe('setUserExistingRadioValue', () => { + it('handles existing user', () => { + RoleMappingsLogic.actions.setRoleMappingsData(mappingsServerProps); + const setElasticsearchUserSpy = jest.spyOn( + RoleMappingsLogic.actions, + 'setElasticsearchUser' + ); + RoleMappingsLogic.actions.setUserExistingRadioValue(true); + + expect(setElasticsearchUserSpy).toHaveBeenCalledWith(elasticsearchUsers[0]); + }); + + it('handles new user', () => { + const setElasticsearchUserSpy = jest.spyOn( + RoleMappingsLogic.actions, + 'setElasticsearchUser' + ); + RoleMappingsLogic.actions.setUserExistingRadioValue(false); + + expect(setElasticsearchUserSpy).toHaveBeenCalledWith(emptyUser); + }); + }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_logic.ts index 76b41b2f383ebd..7f26c8738786c8 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_logic.ts @@ -16,7 +16,7 @@ import { } from '../../../shared/flash_messages'; import { HttpLogic } from '../../../shared/http'; import { ANY_AUTH_PROVIDER } from '../../../shared/role_mapping/constants'; -import { AttributeName } from '../../../shared/types'; +import { AttributeName, SingleUserRoleMapping, ElasticsearchUser } from '../../../shared/types'; import { RoleGroup, WSRoleMapping, Role } from '../../types'; import { @@ -26,19 +26,24 @@ import { DEFAULT_GROUP_NAME, } from './constants'; +type UserMapping = SingleUserRoleMapping; + interface RoleMappingsServerDetails { roleMappings: WSRoleMapping[]; attributes: string[]; authProviders: string[]; availableGroups: RoleGroup[]; + elasticsearchUsers: ElasticsearchUser[]; elasticsearchRoles: string[]; multipleAuthProvidersConfig: boolean; + singleUserRoleMappings: UserMapping[]; } const getFirstAttributeName = (roleMapping: WSRoleMapping): AttributeName => Object.entries(roleMapping.rules)[0][0] as AttributeName; const getFirstAttributeValue = (roleMapping: WSRoleMapping): string => Object.entries(roleMapping.rules)[0][1] as string; +const emptyUser = { username: '', email: '' } as ElasticsearchUser; interface RoleMappingsActions { handleAllGroupsSelectionChange(selected: boolean): { selected: boolean }; @@ -51,21 +56,35 @@ interface RoleMappingsActions { handleDeleteMapping(roleMappingId: string): { roleMappingId: string }; handleGroupSelectionChange(groupIds: string[]): { groupIds: string[] }; handleRoleChange(roleType: Role): { roleType: Role }; + handleUsernameSelectChange(username: string): { username: string }; handleSaveMapping(): void; + handleSaveUser(): void; initializeRoleMapping(roleMappingId?: string): { roleMappingId?: string }; + initializeSingleUserRoleMapping(roleMappingId?: string): { roleMappingId?: string }; initializeRoleMappings(): void; resetState(): void; setRoleMapping(roleMapping: WSRoleMapping): { roleMapping: WSRoleMapping }; + setSingleUserRoleMapping(data?: UserMapping): { singleUserRoleMapping: UserMapping }; setRoleMappings({ roleMappings, }: { roleMappings: WSRoleMapping[]; }): { roleMappings: WSRoleMapping[] }; setRoleMappingsData(data: RoleMappingsServerDetails): RoleMappingsServerDetails; + setElasticsearchUser( + elasticsearchUser?: ElasticsearchUser + ): { elasticsearchUser: ElasticsearchUser }; + setDefaultGroup(availableGroups: RoleGroup[]): { availableGroups: RoleGroup[] }; openRoleMappingFlyout(): void; - closeRoleMappingFlyout(): void; + openSingleUserRoleMappingFlyout(): void; + closeUsersAndRolesFlyout(): void; setRoleMappingErrors(errors: string[]): { errors: string[] }; enableRoleBasedAccess(): void; + setUserExistingRadioValue(userFormUserIsExisting: boolean): { userFormUserIsExisting: boolean }; + setElasticsearchUsernameValue(username: string): { username: string }; + setElasticsearchEmailValue(email: string): { email: string }; + setUserCreated(): void; + setUserFormIsNewUser(userFormIsNewUser: boolean): { userFormIsNewUser: boolean }; } interface RoleMappingsValues { @@ -77,26 +96,37 @@ interface RoleMappingsValues { availableGroups: RoleGroup[]; dataLoading: boolean; elasticsearchRoles: string[]; + elasticsearchUsers: ElasticsearchUser[]; + elasticsearchUser: ElasticsearchUser; multipleAuthProvidersConfig: boolean; roleMapping: WSRoleMapping | null; roleMappings: WSRoleMapping[]; + singleUserRoleMapping: UserMapping | null; + singleUserRoleMappings: UserMapping[]; roleType: Role; selectedAuthProviders: string[]; selectedGroups: Set; roleMappingFlyoutOpen: boolean; + singleUserRoleMappingFlyoutOpen: boolean; selectedOptions: EuiComboBoxOptionOption[]; roleMappingErrors: string[]; + userFormUserIsExisting: boolean; + userCreated: boolean; + userFormIsNewUser: boolean; } export const RoleMappingsLogic = kea>({ - path: ['enterprise_search', 'workplace_search', 'role_mappings'], + path: ['enterprise_search', 'workplace_search', 'users_and_roles'], actions: { setRoleMappingsData: (data: RoleMappingsServerDetails) => data, setRoleMapping: (roleMapping: WSRoleMapping) => ({ roleMapping }), + setElasticsearchUser: (elasticsearchUser: ElasticsearchUser) => ({ elasticsearchUser }), + setSingleUserRoleMapping: (singleUserRoleMapping: UserMapping) => ({ singleUserRoleMapping }), setRoleMappings: ({ roleMappings }: { roleMappings: WSRoleMapping[] }) => ({ roleMappings }), setRoleMappingErrors: (errors: string[]) => ({ errors }), handleAuthProviderChange: (value: string[]) => ({ value }), handleRoleChange: (roleType: Role) => ({ roleType }), + handleUsernameSelectChange: (username: string) => ({ username }), handleGroupSelectionChange: (groupIds: string[]) => ({ groupIds }), handleAttributeSelectorChange: (value: string, firstElasticsearchRole: string) => ({ value, @@ -105,13 +135,22 @@ export const RoleMappingsLogic = kea ({ value }), handleAllGroupsSelectionChange: (selected: boolean) => ({ selected }), enableRoleBasedAccess: true, + openSingleUserRoleMappingFlyout: true, + setUserExistingRadioValue: (userFormUserIsExisting: boolean) => ({ userFormUserIsExisting }), resetState: true, initializeRoleMappings: true, + initializeSingleUserRoleMapping: (roleMappingId?: string) => ({ roleMappingId }), initializeRoleMapping: (roleMappingId?: string) => ({ roleMappingId }), handleDeleteMapping: (roleMappingId: string) => ({ roleMappingId }), handleSaveMapping: true, + handleSaveUser: true, + setDefaultGroup: (availableGroups: RoleGroup[]) => ({ availableGroups }), openRoleMappingFlyout: true, - closeRoleMappingFlyout: false, + closeUsersAndRolesFlyout: false, + setElasticsearchUsernameValue: (username: string) => ({ username }), + setElasticsearchEmailValue: (email: string) => ({ email }), + setUserCreated: true, + setUserFormIsNewUser: (userFormIsNewUser: boolean) => ({ userFormIsNewUser }), }, reducers: { dataLoading: [ @@ -131,6 +170,13 @@ export const RoleMappingsLogic = kea [], }, ], + singleUserRoleMappings: [ + [], + { + setRoleMappingsData: (_, { singleUserRoleMappings }) => singleUserRoleMappings, + resetState: () => [], + }, + ], multipleAuthProvidersConfig: [ false, { @@ -154,6 +200,13 @@ export const RoleMappingsLogic = kea elasticsearchRoles, + closeUsersAndRolesFlyout: () => [ANY_AUTH_PROVIDER], + }, + ], + elasticsearchUsers: [ + [], + { + setRoleMappingsData: (_, { elasticsearchUsers }) => elasticsearchUsers, }, ], roleMapping: [ @@ -161,7 +214,14 @@ export const RoleMappingsLogic = kea roleMapping, resetState: () => null, - closeRoleMappingFlyout: () => null, + closeUsersAndRolesFlyout: () => null, + }, + ], + singleUserRoleMapping: [ + null, + { + setSingleUserRoleMapping: (_, { singleUserRoleMapping }) => singleUserRoleMapping || null, + closeUsersAndRolesFlyout: () => null, }, ], roleType: [ @@ -176,6 +236,7 @@ export const RoleMappingsLogic = kea roleMapping.allGroups, handleAllGroupsSelectionChange: (_, { selected }) => selected, + closeUsersAndRolesFlyout: () => false, }, ], attributeValue: [ @@ -186,7 +247,7 @@ export const RoleMappingsLogic = kea value, resetState: () => '', - closeRoleMappingFlyout: () => '', + closeUsersAndRolesFlyout: () => '', }, ], attributeName: [ @@ -195,7 +256,7 @@ export const RoleMappingsLogic = kea getFirstAttributeName(roleMapping), handleAttributeSelectorChange: (_, { value }) => value, resetState: () => 'username', - closeRoleMappingFlyout: () => 'username', + closeUsersAndRolesFlyout: () => 'username', }, ], selectedGroups: [ @@ -207,6 +268,12 @@ export const RoleMappingsLogic = kea group.name === DEFAULT_GROUP_NAME) .map((group) => group.id) ), + setDefaultGroup: (_, { availableGroups }) => + new Set( + availableGroups + .filter((group) => group.name === DEFAULT_GROUP_NAME) + .map((group) => group.id) + ), setRoleMapping: (_, { roleMapping }) => new Set(roleMapping.groups.map((group: RoleGroup) => group.id)), handleGroupSelectionChange: (_, { groupIds }) => { @@ -215,6 +282,7 @@ export const RoleMappingsLogic = kea new Set(), }, ], availableAuthProviders: [ @@ -244,17 +312,61 @@ export const RoleMappingsLogic = kea true, - closeRoleMappingFlyout: () => false, + closeUsersAndRolesFlyout: () => false, initializeRoleMappings: () => false, initializeRoleMapping: () => true, }, ], + singleUserRoleMappingFlyoutOpen: [ + false, + { + openSingleUserRoleMappingFlyout: () => true, + closeUsersAndRolesFlyout: () => false, + initializeSingleUserRoleMapping: () => true, + }, + ], roleMappingErrors: [ [], { setRoleMappingErrors: (_, { errors }) => errors, handleSaveMapping: () => [], - closeRoleMappingFlyout: () => [], + closeUsersAndRolesFlyout: () => [], + }, + ], + userFormUserIsExisting: [ + true, + { + setUserExistingRadioValue: (_, { userFormUserIsExisting }) => userFormUserIsExisting, + closeUsersAndRolesFlyout: () => true, + }, + ], + elasticsearchUser: [ + emptyUser, + { + setRoleMappingsData: (_, { elasticsearchUsers }) => elasticsearchUsers[0] || emptyUser, + setElasticsearchUser: (_, { elasticsearchUser }) => elasticsearchUser || emptyUser, + setElasticsearchUsernameValue: (state, { username }) => ({ + ...state, + username, + }), + setElasticsearchEmailValue: (state, { email }) => ({ + ...state, + email, + }), + closeUsersAndRolesFlyout: () => emptyUser, + }, + ], + userCreated: [ + false, + { + setUserCreated: () => true, + closeUsersAndRolesFlyout: () => false, + }, + ], + userFormIsNewUser: [ + true, + { + setUserFormIsNewUser: (_, { userFormIsNewUser }) => userFormIsNewUser, }, ], }, @@ -296,6 +408,18 @@ export const RoleMappingsLogic = kea id === roleMappingId); if (roleMapping) actions.setRoleMapping(roleMapping); }, + initializeSingleUserRoleMapping: ({ roleMappingId }) => { + const singleUserRoleMapping = values.singleUserRoleMappings.find( + ({ roleMapping }) => roleMapping.id === roleMappingId + ); + + if (singleUserRoleMapping) { + actions.setElasticsearchUser(singleUserRoleMapping.elasticsearchUser); + actions.setRoleMapping(singleUserRoleMapping.roleMapping); + } + actions.setSingleUserRoleMapping(singleUserRoleMapping); + actions.setUserFormIsNewUser(!singleUserRoleMapping); + }, handleDeleteMapping: async ({ roleMappingId }) => { const { http } = HttpLogic.values; const route = `/api/workplace_search/org/role_mappings/${roleMappingId}`; @@ -349,11 +473,59 @@ export const RoleMappingsLogic = kea { clearFlashMessages(); }, - closeRoleMappingFlyout: () => { + handleSaveUser: async () => { + const { http } = HttpLogic.values; + const { + roleType, + singleUserRoleMapping, + includeInAllGroups, + selectedGroups, + elasticsearchUser: { email, username }, + } = values; + + const body = JSON.stringify({ + roleMapping: { + groups: includeInAllGroups ? [] : Array.from(selectedGroups), + roleType, + allGroups: includeInAllGroups, + id: singleUserRoleMapping?.roleMapping?.id, + }, + elasticsearchUser: { + username, + email, + }, + }); + + try { + const response = await http.post('/api/workplace_search/org/single_user_role_mapping', { + body, + }); + actions.setSingleUserRoleMapping(response); + actions.setUserCreated(); + actions.initializeRoleMappings(); + } catch (e) { + actions.setRoleMappingErrors(e?.body?.attributes?.errors); + } + }, + closeUsersAndRolesFlyout: () => { clearFlashMessages(); + const firstUser = values.elasticsearchUsers[0]; + actions.setElasticsearchUser(firstUser); + actions.setDefaultGroup(values.availableGroups); }, openRoleMappingFlyout: () => { clearFlashMessages(); }, + openSingleUserRoleMappingFlyout: () => { + clearFlashMessages(); + }, + setUserExistingRadioValue: ({ userFormUserIsExisting }) => { + const firstUser = values.elasticsearchUsers[0]; + actions.setElasticsearchUser(userFormUserIsExisting ? firstUser : emptyUser); + }, + handleUsernameSelectChange: ({ username }) => { + const user = values.elasticsearchUsers.find((u) => u.username === username); + if (user) actions.setElasticsearchUser(user); + }, }), }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/user.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/user.test.tsx new file mode 100644 index 00000000000000..32ee1a7f22875f --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/user.test.tsx @@ -0,0 +1,123 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import '../../../__mocks__/react_router'; +import '../../../__mocks__/shallow_useeffect.mock'; +import { setMockActions, setMockValues } from '../../../__mocks__/kea_logic'; +import { groups } from '../../__mocks__/groups.mock'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { UserFlyout, UserAddedInfo, UserInvitationCallout } from '../../../shared/role_mapping'; +import { elasticsearchUsers } from '../../../shared/role_mapping/__mocks__/elasticsearch_users'; +import { wsSingleUserRoleMapping } from '../../../shared/role_mapping/__mocks__/roles'; + +import { GroupAssignmentSelector } from './group_assignment_selector'; +import { User } from './user'; + +describe('User', () => { + const handleSaveUser = jest.fn(); + const closeUsersAndRolesFlyout = jest.fn(); + const setUserExistingRadioValue = jest.fn(); + const setElasticsearchUsernameValue = jest.fn(); + const setElasticsearchEmailValue = jest.fn(); + const handleRoleChange = jest.fn(); + const handleUsernameSelectChange = jest.fn(); + + const mockValues = { + availableGroups: [], + singleUserRoleMapping: null, + userFormUserIsExisting: false, + elasticsearchUsers: [], + elasticsearchUser: {}, + roleType: 'admin', + roleMappingErrors: [], + userCreated: false, + userFormIsNewUser: false, + }; + + beforeEach(() => { + setMockActions({ + handleSaveUser, + closeUsersAndRolesFlyout, + setUserExistingRadioValue, + setElasticsearchUsernameValue, + setElasticsearchEmailValue, + handleRoleChange, + handleUsernameSelectChange, + }); + + setMockValues(mockValues); + }); + + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(UserFlyout)).toHaveLength(1); + }); + + it('renders group assignment selector when groups present', () => { + setMockValues({ ...mockValues, availableGroups: groups }); + const wrapper = shallow(); + + expect(wrapper.find(GroupAssignmentSelector)).toHaveLength(1); + }); + + it('renders userInvitationCallout', () => { + setMockValues({ + ...mockValues, + singleUserRoleMapping: wsSingleUserRoleMapping, + }); + const wrapper = shallow(); + + expect(wrapper.find(UserInvitationCallout)).toHaveLength(1); + }); + + it('renders user added info when user created', () => { + setMockValues({ + ...mockValues, + singleUserRoleMapping: wsSingleUserRoleMapping, + userCreated: true, + }); + const wrapper = shallow(); + + expect(wrapper.find(UserAddedInfo)).toHaveLength(1); + }); + + it('disables form when username value not present', () => { + setMockValues({ + ...mockValues, + singleUserRoleMapping: wsSingleUserRoleMapping, + elasticsearchUsers, + elasticsearchUser: { + username: null, + email: 'email@user.com', + }, + }); + const wrapper = shallow(); + + expect(wrapper.find(UserFlyout).prop('disabled')).toEqual(true); + }); + + it('enables form when userFormUserIsExisting', () => { + setMockValues({ + ...mockValues, + userFormUserIsExisting: true.valueOf, + singleUserRoleMapping: wsSingleUserRoleMapping, + elasticsearchUsers, + elasticsearchUser: { + username: null, + email: 'email@user.com', + }, + }); + const wrapper = shallow(); + + expect(wrapper.find(UserFlyout).prop('disabled')).toEqual(false); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/user.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/user.tsx new file mode 100644 index 00000000000000..bfb32ee31c121d --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/user.tsx @@ -0,0 +1,103 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { useActions, useValues } from 'kea'; + +import { EuiForm } from '@elastic/eui'; + +import { getWorkplaceSearchUrl } from '../../../shared/enterprise_search_url'; +import { + UserFlyout, + UserSelector, + UserAddedInfo, + UserInvitationCallout, +} from '../../../shared/role_mapping'; +import { Role } from '../../types'; + +import { GroupAssignmentSelector } from './group_assignment_selector'; +import { RoleMappingsLogic } from './role_mappings_logic'; + +const roleTypes = (['admin', 'user'] as unknown) as Role[]; + +export const User: React.FC = () => { + const { + handleSaveUser, + closeUsersAndRolesFlyout, + setUserExistingRadioValue, + setElasticsearchUsernameValue, + setElasticsearchEmailValue, + handleRoleChange, + handleUsernameSelectChange, + } = useActions(RoleMappingsLogic); + + const { + availableGroups, + singleUserRoleMapping, + userFormUserIsExisting, + elasticsearchUsers, + elasticsearchUser, + roleType, + roleMappingErrors, + userCreated, + userFormIsNewUser, + } = useValues(RoleMappingsLogic); + + const showGroupAssignmentSelector = availableGroups.length > 0; + const hasAvailableUsers = elasticsearchUsers.length > 0; + const flyoutDisabled = + (!userFormUserIsExisting || !hasAvailableUsers) && !elasticsearchUser.username; + + const userAddedInfo = singleUserRoleMapping && ( + + ); + + const userInvitationCallout = singleUserRoleMapping?.invitation && ( + + ); + + const createUserForm = ( + 0} error={roleMappingErrors}> + + {showGroupAssignmentSelector && } + + ); + + return ( + + {userCreated ? userAddedInfo : createUserForm} + {userInvitationCallout} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/role_mappings.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/role_mappings.test.ts index 7d9f08627516be..dfb9765f834b64 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/role_mappings.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/role_mappings.test.ts @@ -11,6 +11,7 @@ import { registerEnableRoleMappingsRoute, registerRoleMappingsRoute, registerRoleMappingRoute, + registerUserRoute, } from './role_mappings'; const roleMappingBaseSchema = { @@ -160,4 +161,52 @@ describe('role mappings routes', () => { }); }); }); + + describe('POST /api/app_search/single_user_role_mapping', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + mockRouter = new MockRouter({ + method: 'post', + path: '/api/app_search/single_user_role_mapping', + }); + + registerUserRoute({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + describe('validates', () => { + it('correctly', () => { + const request = { + body: { + roleMapping: { + engines: ['foo', 'bar'], + roleType: 'admin', + accessAllEngines: true, + id: '123asf', + }, + elasticsearchUser: { + username: 'user2@elastic.co', + email: 'user2', + }, + }, + }; + mockRouter.shouldValidate(request); + }); + + it('missing required fields', () => { + const request = { body: {} }; + mockRouter.shouldThrow(request); + }); + }); + + it('creates a request handler', () => { + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/as/role_mappings/upsert_single_user_role_mapping', + }); + }); + }); }); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/role_mappings.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/role_mappings.ts index da620be2ea9505..d90a005cb25325 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/role_mappings.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/role_mappings.ts @@ -93,8 +93,34 @@ export function registerRoleMappingRoute({ ); } +export function registerUserRoute({ router, enterpriseSearchRequestHandler }: RouteDependencies) { + router.post( + { + path: '/api/app_search/single_user_role_mapping', + validate: { + body: schema.object({ + roleMapping: schema.object({ + engines: schema.arrayOf(schema.string()), + roleType: schema.string(), + accessAllEngines: schema.boolean(), + id: schema.maybe(schema.string()), + }), + elasticsearchUser: schema.object({ + username: schema.string(), + email: schema.string(), + }), + }), + }, + }, + enterpriseSearchRequestHandler.createRequest({ + path: '/as/role_mappings/upsert_single_user_role_mapping', + }) + ); +} + export const registerRoleMappingsRoutes = (dependencies: RouteDependencies) => { registerEnableRoleMappingsRoute(dependencies); registerRoleMappingsRoute(dependencies); registerRoleMappingRoute(dependencies); + registerUserRoute(dependencies); }; diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/role_mappings.test.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/role_mappings.test.ts index aa0e9983166c02..ef8f1bd63f5d38 100644 --- a/x-pack/plugins/enterprise_search/server/routes/workplace_search/role_mappings.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/role_mappings.test.ts @@ -11,6 +11,7 @@ import { registerOrgEnableRoleMappingsRoute, registerOrgRoleMappingsRoute, registerOrgRoleMappingRoute, + registerOrgUserRoute, } from './role_mappings'; describe('role mappings routes', () => { @@ -128,4 +129,52 @@ describe('role mappings routes', () => { }); }); }); + + describe('POST /api/workplace_search/org/single_user_role_mapping', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + mockRouter = new MockRouter({ + method: 'post', + path: '/api/workplace_search/org/single_user_role_mapping', + }); + + registerOrgUserRoute({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + describe('validates', () => { + it('correctly', () => { + const request = { + body: { + roleMapping: { + groups: ['foo', 'bar'], + roleType: 'admin', + allGroups: true, + id: '123asf', + }, + elasticsearchUser: { + username: 'user2@elastic.co', + email: 'user2', + }, + }, + }; + mockRouter.shouldValidate(request); + }); + + it('missing required fields', () => { + const request = { body: {} }; + mockRouter.shouldThrow(request); + }); + }); + + it('creates a request handler', () => { + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/ws/org/role_mappings/upsert_single_user_role_mapping', + }); + }); + }); }); diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/role_mappings.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/role_mappings.ts index cea7bcb311ce8a..e6f4919ed2a2fe 100644 --- a/x-pack/plugins/enterprise_search/server/routes/workplace_search/role_mappings.ts +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/role_mappings.ts @@ -93,8 +93,37 @@ export function registerOrgRoleMappingRoute({ ); } +export function registerOrgUserRoute({ + router, + enterpriseSearchRequestHandler, +}: RouteDependencies) { + router.post( + { + path: '/api/workplace_search/org/single_user_role_mapping', + validate: { + body: schema.object({ + roleMapping: schema.object({ + groups: schema.arrayOf(schema.string()), + roleType: schema.string(), + allGroups: schema.boolean(), + id: schema.maybe(schema.string()), + }), + elasticsearchUser: schema.object({ + username: schema.string(), + email: schema.string(), + }), + }), + }, + }, + enterpriseSearchRequestHandler.createRequest({ + path: '/ws/org/role_mappings/upsert_single_user_role_mapping', + }) + ); +} + export const registerRoleMappingsRoutes = (dependencies: RouteDependencies) => { registerOrgEnableRoleMappingsRoute(dependencies); registerOrgRoleMappingsRoute(dependencies); registerOrgRoleMappingRoute(dependencies); + registerOrgUserRoute(dependencies); }; From 9b56549c6c26a1f86c44e709b12c4e295aaebda3 Mon Sep 17 00:00:00 2001 From: Jonathan Buttner <56361221+jonathan-buttner@users.noreply.github.com> Date: Thu, 24 Jun 2021 09:05:26 -0400 Subject: [PATCH 12/16] [Cases] Including owner when patching a comment Closes #102732 (#103020) * Including owner when patching a comment * Fixing tests --- .../cases/public/containers/api.test.tsx | 31 +++++---- x-pack/plugins/cases/public/containers/api.ts | 26 ++++--- .../containers/use_update_comment.test.tsx | 67 +++++++++---------- .../public/containers/use_update_comment.tsx | 13 ++-- 4 files changed, 77 insertions(+), 60 deletions(-) diff --git a/x-pack/plugins/cases/public/containers/api.test.tsx b/x-pack/plugins/cases/public/containers/api.test.tsx index abdee387a2c423..30a76e28e74850 100644 --- a/x-pack/plugins/cases/public/containers/api.test.tsx +++ b/x-pack/plugins/cases/public/containers/api.test.tsx @@ -363,13 +363,14 @@ describe('Case Configuration API', () => { }); test('check url, method, signal', async () => { - await patchComment( - basicCase.id, - basicCase.comments[0].id, - 'updated comment', - basicCase.comments[0].version, - abortCtrl.signal - ); + await patchComment({ + caseId: basicCase.id, + commentId: basicCase.comments[0].id, + commentUpdate: 'updated comment', + version: basicCase.comments[0].version, + signal: abortCtrl.signal, + owner: SECURITY_SOLUTION_OWNER, + }); expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}/${basicCase.id}/comments`, { method: 'PATCH', body: JSON.stringify({ @@ -377,19 +378,21 @@ describe('Case Configuration API', () => { type: CommentType.user, id: basicCase.comments[0].id, version: basicCase.comments[0].version, + owner: SECURITY_SOLUTION_OWNER, }), signal: abortCtrl.signal, }); }); test('happy path', async () => { - const resp = await patchComment( - basicCase.id, - basicCase.comments[0].id, - 'updated comment', - basicCase.comments[0].version, - abortCtrl.signal - ); + const resp = await patchComment({ + caseId: basicCase.id, + commentId: basicCase.comments[0].id, + commentUpdate: 'updated comment', + version: basicCase.comments[0].version, + signal: abortCtrl.signal, + owner: SECURITY_SOLUTION_OWNER, + }); expect(resp).toEqual(basicCase); }); }); diff --git a/x-pack/plugins/cases/public/containers/api.ts b/x-pack/plugins/cases/public/containers/api.ts index 1a2a92850a4adb..b144a874cfc53a 100644 --- a/x-pack/plugins/cases/public/containers/api.ts +++ b/x-pack/plugins/cases/public/containers/api.ts @@ -283,14 +283,23 @@ export const postComment = async ( return convertToCamelCase(decodeCaseResponse(response)); }; -export const patchComment = async ( - caseId: string, - commentId: string, - commentUpdate: string, - version: string, - signal: AbortSignal, - subCaseId?: string -): Promise => { +export const patchComment = async ({ + caseId, + commentId, + commentUpdate, + version, + signal, + owner, + subCaseId, +}: { + caseId: string; + commentId: string; + commentUpdate: string; + version: string; + signal: AbortSignal; + owner: string; + subCaseId?: string; +}): Promise => { const response = await KibanaServices.get().http.fetch(getCaseCommentsUrl(caseId), { method: 'PATCH', body: JSON.stringify({ @@ -298,6 +307,7 @@ export const patchComment = async ( type: CommentType.user, id: commentId, version, + owner, }), ...(subCaseId ? { query: { subCaseId } } : {}), signal, diff --git a/x-pack/plugins/cases/public/containers/use_update_comment.test.tsx b/x-pack/plugins/cases/public/containers/use_update_comment.test.tsx index b936eb126f0d4f..14cc4dfab3599a 100644 --- a/x-pack/plugins/cases/public/containers/use_update_comment.test.tsx +++ b/x-pack/plugins/cases/public/containers/use_update_comment.test.tsx @@ -5,10 +5,13 @@ * 2.0. */ +import React from 'react'; import { renderHook, act } from '@testing-library/react-hooks'; import { useUpdateComment, UseUpdateComment } from './use_update_comment'; import { basicCase, basicCaseCommentPatch, basicSubCaseId } from './mock'; import * as api from './api'; +import { TestProviders } from '../common/mock'; +import { SECURITY_SOLUTION_OWNER } from '../../common'; jest.mock('./api'); jest.mock('../common/lib/kibana'); @@ -25,6 +28,12 @@ describe('useUpdateComment', () => { updateCase, version: basicCase.comments[0].version, }; + + const renderHookUseUpdateComment = () => + renderHook(() => useUpdateComment(), { + wrapper: ({ children }) => {children}, + }); + beforeEach(() => { jest.clearAllMocks(); jest.restoreAllMocks(); @@ -32,9 +41,7 @@ describe('useUpdateComment', () => { it('init', async () => { await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useUpdateComment() - ); + const { result, waitForNextUpdate } = renderHookUseUpdateComment(); await waitForNextUpdate(); expect(result.current).toEqual({ isLoadingIds: [], @@ -48,21 +55,20 @@ describe('useUpdateComment', () => { const spyOnPatchComment = jest.spyOn(api, 'patchComment'); await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useUpdateComment() - ); + const { result, waitForNextUpdate } = renderHookUseUpdateComment(); await waitForNextUpdate(); result.current.patchComment(sampleUpdate); await waitForNextUpdate(); - expect(spyOnPatchComment).toBeCalledWith( - basicCase.id, - basicCase.comments[0].id, - 'updated comment', - basicCase.comments[0].version, - abortCtrl.signal, - undefined - ); + expect(spyOnPatchComment).toBeCalledWith({ + caseId: basicCase.id, + commentId: basicCase.comments[0].id, + commentUpdate: 'updated comment', + version: basicCase.comments[0].version, + signal: abortCtrl.signal, + owner: SECURITY_SOLUTION_OWNER, + subCaseId: undefined, + }); }); }); @@ -70,29 +76,26 @@ describe('useUpdateComment', () => { const spyOnPatchComment = jest.spyOn(api, 'patchComment'); await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useUpdateComment() - ); + const { result, waitForNextUpdate } = renderHookUseUpdateComment(); await waitForNextUpdate(); result.current.patchComment({ ...sampleUpdate, subCaseId: basicSubCaseId }); await waitForNextUpdate(); - expect(spyOnPatchComment).toBeCalledWith( - basicCase.id, - basicCase.comments[0].id, - 'updated comment', - basicCase.comments[0].version, - abortCtrl.signal, - basicSubCaseId - ); + expect(spyOnPatchComment).toBeCalledWith({ + caseId: basicCase.id, + commentId: basicCase.comments[0].id, + commentUpdate: 'updated comment', + version: basicCase.comments[0].version, + signal: abortCtrl.signal, + owner: SECURITY_SOLUTION_OWNER, + subCaseId: basicSubCaseId, + }); }); }); it('patch comment', async () => { await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useUpdateComment() - ); + const { result, waitForNextUpdate } = renderHookUseUpdateComment(); await waitForNextUpdate(); result.current.patchComment(sampleUpdate); await waitForNextUpdate(); @@ -108,9 +111,7 @@ describe('useUpdateComment', () => { it('set isLoading to true when posting case', async () => { await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useUpdateComment() - ); + const { result, waitForNextUpdate } = renderHookUseUpdateComment(); await waitForNextUpdate(); result.current.patchComment(sampleUpdate); @@ -125,9 +126,7 @@ describe('useUpdateComment', () => { }); await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useUpdateComment() - ); + const { result, waitForNextUpdate } = renderHookUseUpdateComment(); await waitForNextUpdate(); result.current.patchComment(sampleUpdate); diff --git a/x-pack/plugins/cases/public/containers/use_update_comment.tsx b/x-pack/plugins/cases/public/containers/use_update_comment.tsx index 478d7ebf1fc32b..3c307d86ac7bc3 100644 --- a/x-pack/plugins/cases/public/containers/use_update_comment.tsx +++ b/x-pack/plugins/cases/public/containers/use_update_comment.tsx @@ -7,6 +7,7 @@ import { useReducer, useCallback, useRef, useEffect } from 'react'; import { useToasts } from '../common/lib/kibana'; +import { useOwnerContext } from '../components/owner_context/use_owner_context'; import { patchComment } from './api'; import * as i18n from './translations'; import { Case } from './types'; @@ -72,6 +73,9 @@ export const useUpdateComment = (): UseUpdateComment => { const toasts = useToasts(); const isCancelledRef = useRef(false); const abortCtrlRef = useRef(new AbortController()); + // this hook guarantees that there will be at least one value in the owner array, we'll + // just use the first entry just in case there are more than one entry + const owner = useOwnerContext()[0]; const dispatchUpdateComment = useCallback( async ({ @@ -89,14 +93,15 @@ export const useUpdateComment = (): UseUpdateComment => { abortCtrlRef.current = new AbortController(); dispatch({ type: 'FETCH_INIT', payload: commentId }); - const response = await patchComment( + const response = await patchComment({ caseId, commentId, commentUpdate, version, - abortCtrlRef.current.signal, - subCaseId - ); + signal: abortCtrlRef.current.signal, + subCaseId, + owner, + }); if (!isCancelledRef.current) { updateCase(response); From 1ef5a6aa05e9b4bd0fb96809b979877e7081f654 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20G=C3=B3mez?= Date: Thu, 24 Jun 2021 15:06:07 +0200 Subject: [PATCH 13/16] [Fleet][Logs UI] Prevent double loading of entries in `` component. (#102980) * Use better loading indicator for `useLogSource` * Use clearer name for the loading entries flag * Reuse query object if its value persists --- .../public/components/log_stream/log_stream.tsx | 6 +++--- .../public/containers/logs/log_stream/index.ts | 13 ++++++++++--- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/x-pack/plugins/infra/public/components/log_stream/log_stream.tsx b/x-pack/plugins/infra/public/components/log_stream/log_stream.tsx index 0087d559a42e60..ff9b749911c848 100644 --- a/x-pack/plugins/infra/public/components/log_stream/log_stream.tsx +++ b/x-pack/plugins/infra/public/components/log_stream/log_stream.tsx @@ -112,7 +112,7 @@ Read more at https://github.com/elastic/kibana/blob/master/src/plugins/kibana_re const { derivedIndexPattern, - isLoadingSourceConfiguration, + isLoading: isLoadingSource, loadSource, sourceConfiguration, } = useLogSource({ @@ -138,7 +138,7 @@ Read more at https://github.com/elastic/kibana/blob/master/src/plugins/kibana_re hasMoreAfter, hasMoreBefore, isLoadingMore, - isReloading, + isReloading: isLoadingEntries, } = useLogStream({ sourceId, startTimestamp, @@ -198,7 +198,7 @@ Read more at https://github.com/elastic/kibana/blob/master/src/plugins/kibana_re items={streamItems} scale="medium" wrap={true} - isReloading={isLoadingSourceConfiguration || isReloading} + isReloading={isLoadingSource || isLoadingEntries} isLoadingMore={isLoadingMore} hasMoreBeforeStart={hasMoreBefore} hasMoreAfterEnd={hasMoreAfter} diff --git a/x-pack/plugins/infra/public/containers/logs/log_stream/index.ts b/x-pack/plugins/infra/public/containers/logs/log_stream/index.ts index 021aa8f79fe59c..4cdeb678c432b9 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_stream/index.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_stream/index.ts @@ -5,8 +5,9 @@ * 2.0. */ +import { isEqual } from 'lodash'; import createContainer from 'constate'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState, useRef } from 'react'; import usePrevious from 'react-use/lib/usePrevious'; import useSetState from 'react-use/lib/useSetState'; import { esQuery } from '../../../../../../../src/plugins/data/public'; @@ -65,6 +66,12 @@ export function useLogStream({ const prevStartTimestamp = usePrevious(startTimestamp); const prevEndTimestamp = usePrevious(endTimestamp); + const cachedQuery = useRef(query); + + if (!isEqual(query, cachedQuery)) { + cachedQuery.current = query; + } + useEffect(() => { if (prevStartTimestamp && prevStartTimestamp > startTimestamp) { setState({ hasMoreBefore: true }); @@ -82,10 +89,10 @@ export function useLogStream({ sourceId, startTimestamp, endTimestamp, - query, + query: cachedQuery.current, columnOverrides: columns, }), - [columns, endTimestamp, query, sourceId, startTimestamp] + [columns, endTimestamp, cachedQuery, sourceId, startTimestamp] ); const { From 0a2042eed55100f46766faefd03aeab2dc3607c3 Mon Sep 17 00:00:00 2001 From: Tim Roes Date: Thu, 24 Jun 2021 15:14:48 +0200 Subject: [PATCH 14/16] Prevent showing filter on unfilterable fields (#103241) --- .../discover_grid/discover_grid_cell_actions.test.tsx | 9 ++++++++- .../discover_grid/discover_grid_cell_actions.tsx | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/plugins/discover/public/application/components/discover_grid/discover_grid_cell_actions.test.tsx b/src/plugins/discover/public/application/components/discover_grid/discover_grid_cell_actions.test.tsx index 965d3cb6a30c43..de3c55ad7a869c 100644 --- a/src/plugins/discover/public/application/components/discover_grid/discover_grid_cell_actions.test.tsx +++ b/src/plugins/discover/public/application/components/discover_grid/discover_grid_cell_actions.test.tsx @@ -9,14 +9,21 @@ import React from 'react'; import { mountWithIntl } from '@kbn/test/jest'; import { findTestSubject } from '@elastic/eui/lib/test'; -import { FilterInBtn, FilterOutBtn } from './discover_grid_cell_actions'; +import { FilterInBtn, FilterOutBtn, buildCellActions } from './discover_grid_cell_actions'; import { DiscoverGridContext } from './discover_grid_context'; import { indexPatternMock } from '../../../__mocks__/index_pattern'; import { esHits } from '../../../__mocks__/es_hits'; import { EuiButton } from '@elastic/eui'; +import { IndexPatternField } from 'src/plugins/data/common'; describe('Discover cell actions ', function () { + it('should not show cell actions for unfilterable fields', async () => { + expect( + buildCellActions({ name: 'foo', filterable: false } as IndexPatternField) + ).toBeUndefined(); + }); + it('triggers filter function when FilterInBtn is clicked', async () => { const contextMock = { expanded: undefined, diff --git a/src/plugins/discover/public/application/components/discover_grid/discover_grid_cell_actions.tsx b/src/plugins/discover/public/application/components/discover_grid/discover_grid_cell_actions.tsx index 4e9218f0881cd4..ab80cd3e7b461c 100644 --- a/src/plugins/discover/public/application/components/discover_grid/discover_grid_cell_actions.tsx +++ b/src/plugins/discover/public/application/components/discover_grid/discover_grid_cell_actions.tsx @@ -79,7 +79,7 @@ export const FilterOutBtn = ({ }; export function buildCellActions(field: IndexPatternField) { - if (!field.aggregatable && !field.searchable) { + if (!field.filterable) { return undefined; } From 4e38dfee1430889667b5333ac25a00a8ef2ce89e Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Thu, 24 Jun 2021 14:15:44 +0100 Subject: [PATCH 15/16] skip flaky suite (#98240) --- test/api_integration/apis/ui_counters/ui_counters.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/api_integration/apis/ui_counters/ui_counters.ts b/test/api_integration/apis/ui_counters/ui_counters.ts index 2be6ea4341fb08..019dcfd6216558 100644 --- a/test/api_integration/apis/ui_counters/ui_counters.ts +++ b/test/api_integration/apis/ui_counters/ui_counters.ts @@ -56,7 +56,8 @@ export default function ({ getService }: FtrProviderContext) { return savedObject; }; - describe('UI Counters API', () => { + // FLAKY: https://github.com/elastic/kibana/issues/98240 + describe.skip('UI Counters API', () => { const dayDate = moment().format('DDMMYYYY'); before(async () => await esArchiver.emptyKibanaIndex()); From 4266957a0df426cb88e9f8313e18d1f8c26b8652 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Thu, 24 Jun 2021 15:20:28 +0200 Subject: [PATCH 16/16] fix filter input debouncing (#103087) --- .../lens/public/indexpattern_datasource/query_input.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/query_input.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/query_input.tsx index a67199a9d34325..1b418ee3b408f5 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/query_input.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/query_input.tsx @@ -7,6 +7,7 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; +import { isEqual } from 'lodash'; import { QueryStringInput, Query } from '../../../../../src/plugins/data/public'; import { useDebouncedValue } from '../shared_components'; @@ -36,7 +37,11 @@ export const QueryInput = ({ bubbleSubmitEvent={false} indexPatterns={[indexPatternTitle]} query={inputValue} - onChange={handleInputChange} + onChange={(newQuery) => { + if (!isEqual(newQuery, inputValue)) { + handleInputChange(newQuery); + } + }} onSubmit={() => { if (inputValue.query) { onSubmit();