diff --git a/.buildkite/pipelines/es_snapshots/verify.yml b/.buildkite/pipelines/es_snapshots/verify.yml index 9af2e938db49d..61212e1fcf0a8 100755 --- a/.buildkite/pipelines/es_snapshots/verify.yml +++ b/.buildkite/pipelines/es_snapshots/verify.yml @@ -91,13 +91,5 @@ steps: - wait: ~ continue_on_failure: true - - plugins: - - junit-annotate#v1.9.0: - artifacts: target/junit/**/*.xml - job-uuid-file-pattern: '-bk__(.*).xml' - - - wait: ~ - continue_on_failure: true - - command: .buildkite/scripts/lifecycle/post_build.sh label: Post-Build diff --git a/.buildkite/pipelines/hourly.yml b/.buildkite/pipelines/hourly.yml index 89541023be8e2..279c8cf96bfe3 100644 --- a/.buildkite/pipelines/hourly.yml +++ b/.buildkite/pipelines/hourly.yml @@ -159,13 +159,5 @@ steps: - wait: ~ continue_on_failure: true - - plugins: - - junit-annotate#v1.9.0: - artifacts: target/junit/**/*.xml - job-uuid-file-pattern: '-bk__(.*).xml' - - - wait: ~ - continue_on_failure: true - - command: .buildkite/scripts/lifecycle/post_build.sh label: Post-Build diff --git a/.buildkite/scripts/lifecycle/annotate_test_failures.js b/.buildkite/scripts/lifecycle/annotate_test_failures.js new file mode 100644 index 0000000000000..caf1e08c2bb4d --- /dev/null +++ b/.buildkite/scripts/lifecycle/annotate_test_failures.js @@ -0,0 +1,14 @@ +const { TestFailures } = require('kibana-buildkite-library'); + +(async () => { + try { + await TestFailures.annotateTestFailures(); + } catch (ex) { + console.error('Annotate test failures error', ex.message); + if (ex.response) { + console.error('HTTP Error Response Status', ex.response.status); + console.error('HTTP Error Response Body', ex.response.data); + } + process.exit(1); + } +})(); diff --git a/.buildkite/scripts/lifecycle/post_command.sh b/.buildkite/scripts/lifecycle/post_command.sh index 23f44a586e978..0f98035f9f828 100755 --- a/.buildkite/scripts/lifecycle/post_command.sh +++ b/.buildkite/scripts/lifecycle/post_command.sh @@ -23,4 +23,9 @@ if [[ "$IS_TEST_EXECUTION_STEP" == "true" ]]; then buildkite-agent artifact upload '.es/**/*.hprof' node scripts/report_failed_tests --build-url="${BUILDKITE_BUILD_URL}#${BUILDKITE_JOB_ID}" 'target/junit/**/*.xml' + + if [[ -d 'target/test_failures' ]]; then + buildkite-agent artifact upload 'target/test_failures/**/*' + node .buildkite/scripts/lifecycle/annotate_test_failures.js + fi fi diff --git a/docs/setup/install/deb.asciidoc b/docs/setup/install/deb.asciidoc index 1ec73e8c3c7f5..a229426185200 100644 --- a/docs/setup/install/deb.asciidoc +++ b/docs/setup/install/deb.asciidoc @@ -157,7 +157,6 @@ locations for a Debian-based system: | Configuration files including `kibana.yml` | /etc/kibana | <> - d| | data | The location of the data files written to disk by Kibana and its plugins diff --git a/packages/elastic-eslint-config-kibana/.eslintrc.js b/packages/elastic-eslint-config-kibana/.eslintrc.js index 1c6b96c81afc7..6bfefc8e118d4 100644 --- a/packages/elastic-eslint-config-kibana/.eslintrc.js +++ b/packages/elastic-eslint-config-kibana/.eslintrc.js @@ -93,5 +93,6 @@ module.exports = { '@kbn/eslint/no_async_promise_body': 'error', '@kbn/eslint/no_async_foreach': 'error', + '@kbn/eslint/no_trailing_import_slash': 'error', }, }; diff --git a/packages/kbn-cli-dev-mode/src/get_server_watch_paths.test.ts b/packages/kbn-cli-dev-mode/src/get_server_watch_paths.test.ts index 2208c122bf250..9cb902882ffd7 100644 --- a/packages/kbn-cli-dev-mode/src/get_server_watch_paths.test.ts +++ b/packages/kbn-cli-dev-mode/src/get_server_watch_paths.test.ts @@ -38,7 +38,7 @@ it('produces the right watch and ignore list', () => { expect(ignorePaths).toMatchInlineSnapshot(` Array [ /\\[\\\\\\\\\\\\/\\]\\(\\\\\\.\\.\\*\\|node_modules\\|bower_components\\|target\\|public\\|__\\[a-z0-9_\\]\\+__\\|coverage\\)\\(\\[\\\\\\\\\\\\/\\]\\|\\$\\)/, - /\\\\\\.test\\\\\\.\\(js\\|tsx\\?\\)\\$/, + /\\\\\\.\\(test\\|spec\\)\\\\\\.\\(js\\|ts\\|tsx\\)\\$/, /\\\\\\.\\(md\\|sh\\|txt\\)\\$/, /debug\\\\\\.log\\$/, /src/plugins/*/test/**, diff --git a/packages/kbn-cli-dev-mode/src/get_server_watch_paths.ts b/packages/kbn-cli-dev-mode/src/get_server_watch_paths.ts index ce614d33c735c..53f52279c8be8 100644 --- a/packages/kbn-cli-dev-mode/src/get_server_watch_paths.ts +++ b/packages/kbn-cli-dev-mode/src/get_server_watch_paths.ts @@ -52,7 +52,7 @@ export function getServerWatchPaths({ pluginPaths, pluginScanDirs }: Options) { const ignorePaths = [ /[\\\/](\..*|node_modules|bower_components|target|public|__[a-z0-9_]+__|coverage)([\\\/]|$)/, - /\.test\.(js|tsx?)$/, + /\.(test|spec)\.(js|ts|tsx)$/, /\.(md|sh|txt)$/, /debug\.log$/, ...pluginInternalDirsIgnore, diff --git a/packages/kbn-dev-utils/src/vscode_config/managed_config_keys.ts b/packages/kbn-dev-utils/src/vscode_config/managed_config_keys.ts index 8b941e9e4f71f..07156ca9257fb 100644 --- a/packages/kbn-dev-utils/src/vscode_config/managed_config_keys.ts +++ b/packages/kbn-dev-utils/src/vscode_config/managed_config_keys.ts @@ -8,7 +8,7 @@ export interface ManagedConfigKey { key: string; - value: string | Record; + value: string | Record | boolean; } /** @@ -42,4 +42,8 @@ export const MANAGED_CONFIG_KEYS: ManagedConfigKey[] = [ // we use a relative path here so that it works with remote vscode connections value: './node_modules/typescript/lib', }, + { + key: 'typescript.enablePromptUseWorkspaceTsdk', + value: true, + }, ]; diff --git a/packages/kbn-dev-utils/src/vscode_config/update_vscode_config.ts b/packages/kbn-dev-utils/src/vscode_config/update_vscode_config.ts index 42a642ef1b6c4..b337c8baa5e32 100644 --- a/packages/kbn-dev-utils/src/vscode_config/update_vscode_config.ts +++ b/packages/kbn-dev-utils/src/vscode_config/update_vscode_config.ts @@ -69,10 +69,10 @@ const createObjectPropOfManagedValues = (key: string, value: Record const addManagedProp = ( ast: t.ObjectExpression, key: string, - value: string | Record + value: string | Record | boolean ) => { ast.properties.push( - typeof value === 'string' + typeof value === 'string' || typeof value === 'boolean' ? createManagedProp(key, value) : createObjectPropOfManagedValues(key, value) ); @@ -89,7 +89,7 @@ const addManagedProp = ( const replaceManagedProp = ( ast: t.ObjectExpression, existing: BasicObjectProp, - value: string | Record + value: string | Record | boolean ) => { remove(ast.properties, existing); addManagedProp(ast, existing.key.value, value); diff --git a/packages/kbn-eslint-plugin-eslint/index.js b/packages/kbn-eslint-plugin-eslint/index.js index a37d3c762a748..22d9c752d4745 100644 --- a/packages/kbn-eslint-plugin-eslint/index.js +++ b/packages/kbn-eslint-plugin-eslint/index.js @@ -15,5 +15,6 @@ module.exports = { no_export_all: require('./rules/no_export_all'), no_async_promise_body: require('./rules/no_async_promise_body'), no_async_foreach: require('./rules/no_async_foreach'), + no_trailing_import_slash: require('./rules/no_trailing_import_slash'), }, }; diff --git a/packages/kbn-eslint-plugin-eslint/rules/no_trailing_import_slash.js b/packages/kbn-eslint-plugin-eslint/rules/no_trailing_import_slash.js new file mode 100644 index 0000000000000..bd315bee93110 --- /dev/null +++ b/packages/kbn-eslint-plugin-eslint/rules/no_trailing_import_slash.js @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +/** @typedef {import("eslint").Rule.RuleModule} Rule */ +/** @typedef {import("@typescript-eslint/typescript-estree").TSESTree.ImportDeclaration} ImportDeclaration */ + +const ERROR_MSG = + 'Using a trailing slash in package import statements causes issues with webpack and is inconsistent with the rest of the respository.'; + +/** @type {Rule} */ +module.exports = { + meta: { + fixable: 'code', + schema: [], + }, + create: (context) => ({ + ImportDeclaration(_) { + const node = /** @type {ImportDeclaration} */ (_); + const req = node.source.value; + + if (!req.startsWith('.') && req.endsWith('/')) { + context.report({ + message: ERROR_MSG, + loc: node.source.loc, + fix(fixer) { + return fixer.replaceText(node.source, `'${req.slice(0, -1)}'`); + }, + }); + } + }, + }), +}; diff --git a/packages/kbn-eslint-plugin-eslint/rules/no_trailing_import_slash.test.js b/packages/kbn-eslint-plugin-eslint/rules/no_trailing_import_slash.test.js new file mode 100644 index 0000000000000..0b122dfae0cf3 --- /dev/null +++ b/packages/kbn-eslint-plugin-eslint/rules/no_trailing_import_slash.test.js @@ -0,0 +1,75 @@ +/* + * Copyright 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. + */ + +const { RuleTester } = require('eslint'); +const rule = require('./no_trailing_import_slash'); +const dedent = require('dedent'); + +const ruleTester = new RuleTester({ + parser: require.resolve('@typescript-eslint/parser'), + parserOptions: { + sourceType: 'module', + ecmaVersion: 2018, + ecmaFeatures: { + jsx: true, + }, + }, +}); + +ruleTester.run('@kbn/eslint/no_trailing_import_slash', rule, { + valid: [ + { + code: dedent` + import foo from 'bar'; + `, + }, + { + code: dedent` + import foo from './bar'; + `, + }, + { + code: dedent` + import foo from './bar/'; + `, + }, + ], + + invalid: [ + { + code: dedent` + import foo from 'bar/'; + `, + errors: [ + { + line: 1, + message: + 'Using a trailing slash in package import statements causes issues with webpack and is inconsistent with the rest of the respository.', + }, + ], + output: dedent` + import foo from 'bar'; + `, + }, + { + code: dedent` + import foo from 'bar/box/'; + `, + errors: [ + { + line: 1, + message: + 'Using a trailing slash in package import statements causes issues with webpack and is inconsistent with the rest of the respository.', + }, + ], + output: dedent` + import foo from 'bar/box'; + `, + }, + ], +}); diff --git a/packages/kbn-test/BUILD.bazel b/packages/kbn-test/BUILD.bazel index 36e81df6d8c3c..efaf01f7137d9 100644 --- a/packages/kbn-test/BUILD.bazel +++ b/packages/kbn-test/BUILD.bazel @@ -50,6 +50,7 @@ RUNTIME_DEPS = [ "@npm//exit-hook", "@npm//form-data", "@npm//globby", + "@npm//he", "@npm//history", "@npm//jest", "@npm//jest-cli", @@ -85,6 +86,7 @@ TYPES_DEPS = [ "@npm//xmlbuilder", "@npm//@types/chance", "@npm//@types/enzyme", + "@npm//@types/he", "@npm//@types/history", "@npm//@types/jest", "@npm//@types/joi", diff --git a/packages/kbn-test/src/failed_tests_reporter/report_failures_to_file.ts b/packages/kbn-test/src/failed_tests_reporter/report_failures_to_file.ts new file mode 100644 index 0000000000000..aca2e6838faec --- /dev/null +++ b/packages/kbn-test/src/failed_tests_reporter/report_failures_to_file.ts @@ -0,0 +1,138 @@ +/* + * Copyright 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 { createHash } from 'crypto'; +import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'fs'; +import { join, basename, resolve } from 'path'; + +import { ToolingLog } from '@kbn/dev-utils'; +import { REPO_ROOT } from '@kbn/utils'; +import { escape } from 'he'; + +import { TestFailure } from './get_failures'; + +const findScreenshots = (dirPath: string, allScreenshots: string[] = []) => { + const files = readdirSync(dirPath); + + for (const file of files) { + if (statSync(join(dirPath, file)).isDirectory()) { + if (file.match(/node_modules/)) { + continue; + } + + allScreenshots = findScreenshots(join(dirPath, file), allScreenshots); + } else { + const fullPath = join(dirPath, file); + if (fullPath.match(/screenshots\/failure\/.+\.png$/)) { + allScreenshots.push(fullPath); + } + } + } + + return allScreenshots; +}; + +export function reportFailuresToFile(log: ToolingLog, failures: TestFailure[]) { + if (!failures?.length) { + return; + } + + let screenshots: string[]; + try { + screenshots = [ + ...findScreenshots(join(REPO_ROOT, 'test', 'functional')), + ...findScreenshots(join(REPO_ROOT, 'x-pack', 'test', 'functional')), + ]; + } catch (e) { + log.error(e as Error); + screenshots = []; + } + + const screenshotsByName: Record = {}; + for (const screenshot of screenshots) { + const [name] = basename(screenshot).split('.'); + screenshotsByName[name] = screenshot; + } + + // Jest could, in theory, fail 1000s of tests and write 1000s of failures + // So let's just write files for the first 20 + for (const failure of failures.slice(0, 20)) { + const hash = createHash('md5').update(failure.name).digest('hex'); + const filenameBase = `${ + process.env.BUILDKITE_JOB_ID ? process.env.BUILDKITE_JOB_ID + '_' : '' + }${hash}`; + const dir = join('target', 'test_failures'); + + const failureLog = [ + ['Test:', '-----', failure.classname, failure.name, ''], + ['Failure:', '--------', failure.failure], + failure['system-out'] ? ['', 'Standard Out:', '-------------', failure['system-out']] : [], + ] + .flat() + .join('\n'); + + const failureJSON = JSON.stringify( + { + ...failure, + hash, + buildId: process.env.BUJILDKITE_BUILD_ID || '', + jobId: process.env.BUILDKITE_JOB_ID || '', + url: process.env.BUILDKITE_BUILD_URL || '', + jobName: process.env.BUILDKITE_LABEL + ? `${process.env.BUILDKITE_LABEL}${ + process.env.BUILDKITE_PARALLEL_JOB ? ` #${process.env.BUILDKITE_PARALLEL_JOB}` : '' + }` + : '', + }, + null, + 2 + ); + + let screenshot = ''; + const screenshotName = `${failure.name.replace(/([^ a-zA-Z0-9-]+)/g, '_')}`; + if (screenshotsByName[screenshotName]) { + try { + screenshot = readFileSync(screenshotsByName[screenshotName]).toString('base64'); + } catch (e) { + log.error(e as Error); + } + } + + const screenshotHtml = screenshot + ? `` + : ''; + + const failureHTML = readFileSync( + resolve( + REPO_ROOT, + 'packages/kbn-test/src/failed_tests_reporter/report_failures_to_file_html_template.html' + ) + ) + .toString() + .replace('$TITLE', escape(failure.name)) + .replace( + '$MAIN', + ` + ${failure.classname + .split('.') + .map((part) => `
${escape(part.replace('·', '.'))}
`) + .join('')} +
+

${escape(failure.name)}

+
${escape(failure.failure)}
+ ${screenshotHtml} +
${escape(failure['system-out'] || '')}
+ ` + ); + + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, `${filenameBase}.log`), failureLog, 'utf8'); + writeFileSync(join(dir, `${filenameBase}.html`), failureHTML, 'utf8'); + writeFileSync(join(dir, `${filenameBase}.json`), failureJSON, 'utf8'); + } +} diff --git a/packages/kbn-test/src/failed_tests_reporter/report_failures_to_file_html_template.html b/packages/kbn-test/src/failed_tests_reporter/report_failures_to_file_html_template.html new file mode 100644 index 0000000000000..485a2c2d4eb3f --- /dev/null +++ b/packages/kbn-test/src/failed_tests_reporter/report_failures_to_file_html_template.html @@ -0,0 +1,41 @@ + + + + + + + + $TITLE + + +
+
$MAIN
+
+ + + diff --git a/packages/kbn-test/src/failed_tests_reporter/run_failed_tests_reporter_cli.ts b/packages/kbn-test/src/failed_tests_reporter/run_failed_tests_reporter_cli.ts index 0129614fe658d..6c88b7408b628 100644 --- a/packages/kbn-test/src/failed_tests_reporter/run_failed_tests_reporter_cli.ts +++ b/packages/kbn-test/src/failed_tests_reporter/run_failed_tests_reporter_cli.ts @@ -21,6 +21,7 @@ import { readTestReport } from './test_report'; import { addMessagesToReport } from './add_messages_to_report'; import { getReportMessageIter } from './report_metadata'; import { reportFailuresToEs } from './report_failures_to_es'; +import { reportFailuresToFile } from './report_failures_to_file'; const DEFAULT_PATTERNS = [Path.resolve(REPO_ROOT, 'target/junit/**/*.xml')]; @@ -98,6 +99,8 @@ export function runFailedTestsReporterCli() { const messages = Array.from(getReportMessageIter(report)); const failures = await getFailures(report); + reportFailuresToFile(log, failures); + if (indexInEs) { await reportFailuresToEs(log, failures); } diff --git a/src/cli/serve/serve.js b/src/cli/serve/serve.js index be949350f7229..705acfe4fdf54 100644 --- a/src/cli/serve/serve.js +++ b/src/cli/serve/serve.js @@ -52,7 +52,6 @@ const pathCollector = function () { }; const configPathCollector = pathCollector(); -const pluginDirCollector = pathCollector(); const pluginPathCollector = pathCollector(); function applyConfigOverrides(rawConfig, opts, extraCliOptions) { @@ -138,7 +137,6 @@ function applyConfigOverrides(rawConfig, opts, extraCliOptions) { } } - set('plugins.scanDirs', _.compact([].concat(get('plugins.scanDirs'), opts.pluginDir))); set('plugins.paths', _.compact([].concat(get('plugins.paths'), opts.pluginPath))); merge(extraCliOptions); @@ -169,13 +167,6 @@ export default function (program) { '-l, --log-file ', 'Deprecated, set logging file destination in your configuration' ) - .option( - '--plugin-dir ', - 'A path to scan for plugins, this can be specified multiple ' + - 'times to specify multiple directories', - pluginDirCollector, - [fromRoot('plugins')] - ) .option( '--plugin-path ', 'A path to a plugin which should be included by the server, ' + @@ -183,7 +174,6 @@ export default function (program) { pluginPathCollector, [] ) - .option('--plugins ', 'an alias for --plugin-dir', pluginDirCollector) .option('--optimize', 'Deprecated, running the optimizer is no longer required'); if (!isKibanaDistributable()) { diff --git a/src/core/server/i18n/get_kibana_translation_files.test.ts b/src/core/server/i18n/get_kibana_translation_files.test.ts index 45e1a8dfec9cb..89a03c35d66ae 100644 --- a/src/core/server/i18n/get_kibana_translation_files.test.ts +++ b/src/core/server/i18n/get_kibana_translation_files.test.ts @@ -41,7 +41,7 @@ describe('getKibanaTranslationPaths', () => { }); }); - it('calls getTranslationPaths for each config returned in plugin.paths and plugins.scanDirs', async () => { + it('calls getTranslationPaths for each config returned in plugin.paths', async () => { const pluginPaths = ['/path/to/pluginA', '/path/to/pluginB']; await getKibanaTranslationFiles(locale, pluginPaths); diff --git a/src/core/server/plugins/plugins_config.ts b/src/core/server/plugins/plugins_config.ts index 45d80445f376e..6624fc40d2c0a 100644 --- a/src/core/server/plugins/plugins_config.ts +++ b/src/core/server/plugins/plugins_config.ts @@ -24,7 +24,6 @@ export type PluginsConfigType = TypeOf; export const config: ServiceConfigDescriptor = { path: 'plugins', schema: configSchema, - deprecations: ({ unusedFromRoot }) => [unusedFromRoot('plugins.scanDirs')], }; /** @internal */ diff --git a/src/core/test_helpers/kbn_server.ts b/src/core/test_helpers/kbn_server.ts index a2aa453cf55e7..67bd6c7455d6d 100644 --- a/src/core/test_helpers/kbn_server.ts +++ b/src/core/test_helpers/kbn_server.ts @@ -15,7 +15,6 @@ import { kibanaTestUser, } from '@kbn/test'; import { defaultsDeep } from 'lodash'; -import { resolve } from 'path'; import { BehaviorSubject } from 'rxjs'; import supertest from 'supertest'; @@ -99,7 +98,6 @@ export function createRoot(settings = {}, cliArgs: Partial = {}) { */ export function createRootWithCorePlugins(settings = {}, cliArgs: Partial = {}) { const DEFAULT_SETTINGS_WITH_CORE_PLUGINS = { - plugins: { scanDirs: [resolve(__dirname, '../../legacy/core_plugins')] }, elasticsearch: { hosts: [esTestConfig.getUrl()], username: kibanaServerTestUser.username, diff --git a/src/dev/eslint/lint_files.ts b/src/dev/eslint/lint_files.ts index 5c6118edeb2ec..b5849c0f8485f 100644 --- a/src/dev/eslint/lint_files.ts +++ b/src/dev/eslint/lint_files.ts @@ -12,6 +12,45 @@ import { REPO_ROOT } from '@kbn/utils'; import { createFailError, ToolingLog } from '@kbn/dev-utils'; import { File } from '../file'; +// For files living on the filesystem +function lintFilesOnFS(cli: CLIEngine, files: File[]) { + const paths = files.map((file) => file.getRelativePath()); + return cli.executeOnFiles(paths); +} + +// For files living somewhere else (ie. git object) +async function lintFilesOnContent(cli: CLIEngine, files: File[]) { + const report: { + results: any[]; + errorCount: number; + warningCount: number; + fixableErrorCount: number; + fixableWarningCount: number; + } = { + results: [], + errorCount: 0, + warningCount: 0, + fixableErrorCount: 0, + fixableWarningCount: 0, + }; + + for (let i = 0; i < files.length; i++) { + const r = cli.executeOnText(await files[i].getContent(), files[i].getRelativePath()); + // Despite a relative path was given, the result would contain an absolute one. Work around it. + r.results[0].filePath = r.results[0].filePath.replace( + files[i].getAbsolutePath(), + files[i].getRelativePath() + ); + report.results.push(...r.results); + report.errorCount += r.errorCount; + report.warningCount += r.warningCount; + report.fixableErrorCount += r.fixableErrorCount; + report.fixableWarningCount += r.fixableWarningCount; + } + + return report; +} + /** * Lints a list of files with eslint. eslint reports are written to the log * and a FailError is thrown when linting errors occur. @@ -20,15 +59,16 @@ import { File } from '../file'; * @param {Array} files * @return {undefined} */ -export function lintFiles(log: ToolingLog, files: File[], { fix }: { fix?: boolean } = {}) { +export async function lintFiles(log: ToolingLog, files: File[], { fix }: { fix?: boolean } = {}) { const cli = new CLIEngine({ cache: true, cwd: REPO_ROOT, fix, }); - const paths = files.map((file) => file.getRelativePath()); - const report = cli.executeOnFiles(paths); + const virtualFilesCount = files.filter((file) => file.isVirtual()).length; + const report = + virtualFilesCount && !fix ? await lintFilesOnContent(cli, files) : lintFilesOnFS(cli, files); if (fix) { CLIEngine.outputFixes(report); diff --git a/src/dev/file.ts b/src/dev/file.ts index b532a7bb70602..01005b257a403 100644 --- a/src/dev/file.ts +++ b/src/dev/file.ts @@ -7,11 +7,13 @@ */ import { dirname, extname, join, relative, resolve, sep, basename } from 'path'; +import { createFailError } from '@kbn/dev-utils'; export class File { private path: string; private relativePath: string; private ext: string; + private fileReader: undefined | (() => Promise); constructor(path: string) { this.path = resolve(path); @@ -55,6 +57,11 @@ export class File { ); } + // Virtual files cannot be read as usual, an helper is needed + public isVirtual() { + return this.fileReader !== undefined; + } + public getRelativeParentDirs() { const parents: string[] = []; @@ -81,4 +88,15 @@ export class File { public toJSON() { return this.relativePath; } + + public setFileReader(fileReader: () => Promise) { + this.fileReader = fileReader; + } + + public getContent() { + if (this.fileReader) { + return this.fileReader(); + } + throw createFailError('getContent() was invoked on a non-virtual File'); + } } diff --git a/src/dev/precommit_hook/get_files_for_commit.js b/src/dev/precommit_hook/get_files_for_commit.js index 44c8c9d5e6bc0..52dfab49c5c64 100644 --- a/src/dev/precommit_hook/get_files_for_commit.js +++ b/src/dev/precommit_hook/get_files_for_commit.js @@ -6,12 +6,65 @@ * Side Public License, v 1. */ +import { format } from 'util'; import SimpleGit from 'simple-git'; import { fromNode as fcb } from 'bluebird'; import { REPO_ROOT } from '@kbn/utils'; import { File } from '../file'; +/** + * Return the `git diff` argument used for building the list of files + * + * @param {String} gitRef + * @return {String} + * + * gitRef return + * '' '--cached' + * '' '~1..' + * '..' '..' + * '...' '...' + * '..' '..' + * '...' '...' + * '..' '..' + * '...' '...' + */ +function getRefForDiff(gitRef) { + if (!gitRef) { + return '--cached'; + } else if (gitRef.includes('..')) { + return gitRef; + } else { + return format('%s~1..%s', gitRef, gitRef); + } +} + +/** + * Return the used for reading files content + * + * @param {String} gitRef + * @return {String} + * + * gitRef return + * '' '' + * '' '' + * '..' 'HEAD' + * '...' 'HEAD' + * '..' '' + * '...' '' + * '..' '' + * '...' '' + */ +function getRefForCat(gitRef) { + if (!gitRef) { + return ''; + } else if (gitRef.includes('..')) { + return gitRef.endsWith('..') ? 'HEAD' : gitRef.slice(gitRef.lastIndexOf('..') + 2); + } else { + return gitRef; + } +} + /** * Get the files that are staged for commit (excluding deleted files) * as `File` objects that are aware of their commit status. @@ -21,29 +74,23 @@ import { File } from '../file'; */ export async function getFilesForCommit(gitRef) { const simpleGit = new SimpleGit(REPO_ROOT); - const gitRefForDiff = gitRef ? gitRef : '--cached'; - const output = await fcb((cb) => simpleGit.diff(['--name-status', gitRefForDiff], cb)); + const gitRefForDiff = getRefForDiff(gitRef); + const gitRefForCat = getRefForCat(gitRef); + + const output = await fcb((cb) => { + simpleGit.diff(['--diff-filter=d', '--name-only', gitRefForDiff], cb); + }); return ( output .split('\n') // Ignore blank lines .filter((line) => line.trim().length > 0) - // git diff --name-status outputs lines with two OR three parts - // separated by a tab character - .map((line) => line.trim().split('\t')) - .map(([status, ...paths]) => { - // ignore deleted files - if (status === 'D') { - return undefined; - } - - // the status is always in the first column - // .. If the file is edited the line will only have two columns - // .. If the file is renamed it will have three columns - // .. In any case, the last column is the CURRENT path to the file - return new File(paths[paths.length - 1]); + .map((path) => { + const file = new File(path); + const object = format('%s:%s', gitRefForCat, path); + file.setFileReader(() => fcb((cb) => simpleGit.catFile(['-p', object], cb))); + return file; }) - .filter(Boolean) ); } diff --git a/src/dev/run_precommit_hook.js b/src/dev/run_precommit_hook.js index 73394a62e3396..e1eafaf28d95d 100644 --- a/src/dev/run_precommit_hook.js +++ b/src/dev/run_precommit_hook.js @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { run, combineErrors, createFlagError } from '@kbn/dev-utils'; +import { run, combineErrors, createFlagError, createFailError } from '@kbn/dev-utils'; import * as Eslint from './eslint'; import * as Stylelint from './stylelint'; import { getFilesForCommit, checkFileCasing } from './precommit_hook'; @@ -23,6 +23,11 @@ run( throw createFlagError('expected --max-files to be a number greater than 0'); } + const virtualFilesCount = files.filter((file) => file.isVirtual()).length; + if (virtualFilesCount > 0 && virtualFilesCount < files.length) { + throw createFailError('Mixing of virtual and on-filesystem files is unsupported'); + } + if (maxFilesCount && files.length > maxFilesCount) { log.warning( `--max-files is set to ${maxFilesCount} and ${files.length} were discovered. The current script execution will be skipped.` @@ -66,7 +71,11 @@ run( help: ` --fix Execute eslint in --fix mode --max-files Max files number to check against. If exceeded the script will skip the execution - --ref Run checks against any git ref files (example HEAD or ) instead of running against staged ones + --ref Run checks against git ref files instead of running against staged ones + Examples: + HEAD~1..HEAD files changed in the commit at HEAD + HEAD equivalent to HEAD~1..HEAD + main... files changed in current branch since the common ancestor with main `, }, } diff --git a/src/dev/stylelint/lint_files.js b/src/dev/stylelint/lint_files.js index 6e62c85d44ae8..1ebc981728814 100644 --- a/src/dev/stylelint/lint_files.js +++ b/src/dev/stylelint/lint_files.js @@ -16,6 +16,51 @@ import { createFailError } from '@kbn/dev-utils'; const stylelintPath = path.resolve(__dirname, '..', '..', '..', '.stylelintrc'); const styleLintConfig = safeLoad(fs.readFileSync(stylelintPath)); +// For files living on the filesystem +function lintFilesOnFS(files) { + const paths = files.map((file) => file.getRelativePath()); + + const options = { + files: paths, + config: styleLintConfig, + formatter: 'string', + ignorePath: path.resolve(__dirname, '..', '..', '..', '.stylelintignore'), + }; + + return stylelint.lint(options); +} + +// For files living somewhere else (ie. git object) +async function lintFilesOnContent(files) { + const report = { + errored: false, + output: '', + postcssResults: [], + results: [], + maxWarningsExceeded: { + maxWarnings: 0, + foundWarnings: 0, + }, + }; + + for (let i = 0; i < files.length; i++) { + const options = { + code: await files[i].getContent(), + config: styleLintConfig, + formatter: 'string', + ignorePath: path.resolve(__dirname, '..', '..', '..', '.stylelintignore'), + }; + const r = await stylelint.lint(options); + report.errored = report.errored || r.errored; + report.output += r.output.replace(//, files[i].getRelativePath()).slice(0, -1); + report.postcssResults.push(...(r.postcssResults || [])); + report.maxWarnings = r.maxWarnings; + report.foundWarnings += r.foundWarnings; + } + + return report; +} + /** * Lints a list of files with eslint. eslint reports are written to the log * and a FailError is thrown when linting errors occur. @@ -25,16 +70,9 @@ const styleLintConfig = safeLoad(fs.readFileSync(stylelintPath)); * @return {undefined} */ export async function lintFiles(log, files) { - const paths = files.map((file) => file.getRelativePath()); - - const options = { - files: paths, - config: styleLintConfig, - formatter: 'string', - ignorePath: path.resolve(__dirname, '..', '..', '..', '.stylelintignore'), - }; + const virtualFilesCount = files.filter((file) => file.isVirtual()).length; + const report = virtualFilesCount ? await lintFilesOnContent(files) : await lintFilesOnFS(files); - const report = await stylelint.lint(options); if (report.errored) { log.error(report.output); throw createFailError('[stylelint] errors'); diff --git a/src/plugins/discover/public/application/apps/main/components/doc_table/components/pager/tool_bar_pagination.tsx b/src/plugins/discover/public/application/apps/main/components/doc_table/components/pager/tool_bar_pagination.tsx index 878a9b8162628..ccdb620e0ab85 100644 --- a/src/plugins/discover/public/application/apps/main/components/doc_table/components/pager/tool_bar_pagination.tsx +++ b/src/plugins/discover/public/application/apps/main/components/doc_table/components/pager/tool_bar_pagination.tsx @@ -17,7 +17,7 @@ import { EuiPopover, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import { i18n } from '@kbn/i18n/'; +import { i18n } from '@kbn/i18n'; interface ToolBarPaginationProps { pageSize: number; diff --git a/x-pack/plugins/apm/server/tutorial/envs/elastic_cloud.ts b/x-pack/plugins/apm/server/tutorial/envs/elastic_cloud.ts index a7efaa3b00f34..c62e42f222194 100644 --- a/x-pack/plugins/apm/server/tutorial/envs/elastic_cloud.ts +++ b/x-pack/plugins/apm/server/tutorial/envs/elastic_cloud.ts @@ -57,10 +57,10 @@ function getApmServerInstructionSet( id: INSTRUCTION_VARIANT.ESC, instructions: [ { - title: 'Enable the APM Server in the ESS console', + title: 'Enable the APM Server in the Elastic Cloud user console', textPre: i18n.translate('xpack.apm.tutorial.elasticCloud.textPre', { defaultMessage: - 'To enable the APM Server go to [the Elastic Cloud console](https://cloud.elastic.co/deployments/{deploymentId}/edit) and enable APM in the deployment settings. Once enabled, refresh this page.', + 'To enable the APM Server go to [the Elastic Cloud console](https://cloud.elastic.co/deployments/{deploymentId}/edit) and enable APM and Fleet in the deployment edit page by clicking on add capacity, and then click on save. Once enabled, refresh this page.', values: { deploymentId }, }), }, diff --git a/x-pack/plugins/data_visualizer/common/constants.ts b/x-pack/plugins/data_visualizer/common/constants.ts index 55ebdf9a196d6..f7bea807c3e61 100644 --- a/x-pack/plugins/data_visualizer/common/constants.ts +++ b/x-pack/plugins/data_visualizer/common/constants.ts @@ -33,19 +33,6 @@ export const JOB_FIELD_TYPES = { UNKNOWN: 'unknown', } as const; -export const JOB_FIELD_TYPES_OPTIONS = { - [JOB_FIELD_TYPES.BOOLEAN]: { name: 'Boolean', icon: 'tokenBoolean' }, - [JOB_FIELD_TYPES.DATE]: { name: 'Date', icon: 'tokenDate' }, - [JOB_FIELD_TYPES.GEO_POINT]: { name: 'Geo point', icon: 'tokenGeo' }, - [JOB_FIELD_TYPES.GEO_SHAPE]: { name: 'Geo shape', icon: 'tokenGeo' }, - [JOB_FIELD_TYPES.IP]: { name: 'IP address', icon: 'tokenIP' }, - [JOB_FIELD_TYPES.KEYWORD]: { name: 'Keyword', icon: 'tokenKeyword' }, - [JOB_FIELD_TYPES.NUMBER]: { name: 'Number', icon: 'tokenNumber' }, - [JOB_FIELD_TYPES.TEXT]: { name: 'Text', icon: 'tokenString' }, - [JOB_FIELD_TYPES.HISTOGRAM]: { name: 'Histogram', icon: 'tokenNumber' }, - [JOB_FIELD_TYPES.UNKNOWN]: { name: 'Unknown' }, -}; - export const OMIT_FIELDS: string[] = ['_source', '_type', '_index', '_id', '_version', '_score']; export const NON_AGGREGATABLE_FIELD_TYPES = new Set([ diff --git a/x-pack/plugins/data_visualizer/common/types/index.ts b/x-pack/plugins/data_visualizer/common/types/index.ts index 8b51142e19129..1153b45e1cce2 100644 --- a/x-pack/plugins/data_visualizer/common/types/index.ts +++ b/x-pack/plugins/data_visualizer/common/types/index.ts @@ -6,6 +6,7 @@ */ import type { SimpleSavedObject } from 'kibana/public'; +import { isPopulatedObject } from '../utils/object_utils'; export type { JobFieldType } from './job_field_type'; export type { FieldRequestConfig, @@ -27,3 +28,7 @@ export interface DataVisualizerTableState { } export type SavedSearchSavedObject = SimpleSavedObject; + +export function isSavedSearchSavedObject(arg: unknown): arg is SavedSearchSavedObject { + return isPopulatedObject(arg, ['id', 'type', 'attributes']); +} diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/embedded_map/_embedded_map.scss b/x-pack/plugins/data_visualizer/public/application/common/components/embedded_map/_embedded_map.scss index 99ee60f62bb21..a3682bfd7d96c 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/embedded_map/_embedded_map.scss +++ b/x-pack/plugins/data_visualizer/public/application/common/components/embedded_map/_embedded_map.scss @@ -1,4 +1,4 @@ -.embeddedMapContent { +.embeddedMap__content { width: 100%; height: 100%; display: flex; diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/embedded_map/embedded_map.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/embedded_map/embedded_map.tsx index 13aab06640bd5..cf357a462d9b3 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/embedded_map/embedded_map.tsx +++ b/x-pack/plugins/data_visualizer/public/application/common/components/embedded_map/embedded_map.tsx @@ -39,7 +39,7 @@ export function EmbeddedMapComponent({ const baseLayers = useRef(); const { - services: { embeddable: embeddablePlugin, maps: mapsPlugin }, + services: { embeddable: embeddablePlugin, maps: mapsPlugin, data }, } = useDataVisualizerKibana(); const factory: @@ -73,7 +73,7 @@ export function EmbeddedMapComponent({ const input: MapEmbeddableInput = { id: htmlIdGenerator()(), attributes: { title: '' }, - filters: [], + filters: data.query.filterManager.getFilters() ?? [], hidePanelTitles: true, viewMode: ViewMode.VIEW, isLayerTOCOpen: false, @@ -143,7 +143,7 @@ export function EmbeddedMapComponent({ return (
); diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/examples_list/examples_list.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/examples_list/examples_list.tsx index 296820479437c..1aa14a88a5248 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/examples_list/examples_list.tsx +++ b/x-pack/plugins/data_visualizer/public/application/common/components/examples_list/examples_list.tsx @@ -11,6 +11,7 @@ import { EuiListGroup, EuiListGroupItem } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { ExpandedRowFieldHeader } from '../stats_table/components/expanded_row_field_header'; +import { ExpandedRowPanel } from '../stats_table/components/field_data_expanded_row/expanded_row_panel'; interface Props { examples: Array; } @@ -31,8 +32,7 @@ export const ExamplesList: FC = ({ examples }) => { examplesContent = examples.map((example, i) => { return ( @@ -41,7 +41,10 @@ export const ExamplesList: FC = ({ examples }) => { } return ( -
+ = ({ examples }) => { {examplesContent} -
+ ); }; diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/expanded_row/file_based_expanded_row.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/expanded_row/file_based_expanded_row.tsx index 4517d62ec2aa1..8a9f9a25c16fa 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/expanded_row/file_based_expanded_row.tsx +++ b/x-pack/plugins/data_visualizer/public/application/common/components/expanded_row/file_based_expanded_row.tsx @@ -52,10 +52,7 @@ export const FileBasedDataVisualizerExpandedRow = ({ item }: { item: FileBasedFi } return ( -
+
{getCardContent()}
); diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/expanded_row/geo_point_content/geo_point_content.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/expanded_row/geo_point_content/geo_point_content.tsx index b732e542658b5..542500df71a6f 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/expanded_row/geo_point_content/geo_point_content.tsx +++ b/x-pack/plugins/data_visualizer/public/application/common/components/expanded_row/geo_point_content/geo_point_content.tsx @@ -6,8 +6,6 @@ */ import React, { FC, useMemo } from 'react'; - -import { EuiFlexItem } from '@elastic/eui'; import { Feature, Point } from 'geojson'; import type { FieldDataRowProps } from '../../stats_table/types/field_data_row'; import { DocumentStatsTable } from '../../stats_table/components/field_data_expanded_row/document_stats'; @@ -15,6 +13,7 @@ import { EmbeddedMapComponent } from '../../embedded_map'; import { convertWKTGeoToLonLat, getGeoPointsLayer } from './format_utils'; import { ExpandedRowContent } from '../../stats_table/components/field_data_expanded_row/expanded_row_content'; import { ExamplesList } from '../../examples_list'; +import { ExpandedRowPanel } from '../../stats_table/components/field_data_expanded_row/expanded_row_panel'; export const DEFAULT_GEO_REGEX = RegExp('(?.+) (?.+)'); @@ -63,17 +62,12 @@ export const GeoPointContent: FC = ({ config }) => { {formattedResults && Array.isArray(formattedResults.examples) && ( - - - + )} {formattedResults && Array.isArray(formattedResults.layerList) && ( - + - + )} ); diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/expanded_row/geo_point_content_with_map/geo_point_content_with_map.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/expanded_row/geo_point_content_with_map/geo_point_content_with_map.tsx index 082083fa92ff2..5da44262d29da 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/expanded_row/geo_point_content_with_map/geo_point_content_with_map.tsx +++ b/x-pack/plugins/data_visualizer/public/application/common/components/expanded_row/geo_point_content_with_map/geo_point_content_with_map.tsx @@ -6,7 +6,6 @@ */ import React, { FC, useEffect, useState } from 'react'; -import { EuiFlexItem } from '@elastic/eui'; import { IndexPattern } from '../../../../../../../../../src/plugins/data/common'; import { CombinedQuery } from '../../../../index_data_visualizer/types/combined_query'; import { ExpandedRowContent } from '../../stats_table/components/field_data_expanded_row/expanded_row_content'; @@ -17,6 +16,7 @@ import { useDataVisualizerKibana } from '../../../../kibana_context'; import { JOB_FIELD_TYPES } from '../../../../../../common'; import { ES_GEO_FIELD_TYPE, LayerDescriptor } from '../../../../../../../maps/common'; import { EmbeddedMapComponent } from '../../embedded_map'; +import { ExpandedRowPanel } from '../../stats_table/components/field_data_expanded_row/expanded_row_panel'; export const GeoPointContentWithMap: FC<{ config: FieldVisConfig; @@ -26,7 +26,7 @@ export const GeoPointContentWithMap: FC<{ const { stats } = config; const [layerList, setLayerList] = useState([]); const { - services: { maps: mapsPlugin }, + services: { maps: mapsPlugin, data }, } = useDataVisualizerKibana(); // Update the layer list with updated geo points upon refresh @@ -42,6 +42,7 @@ export const GeoPointContentWithMap: FC<{ indexPatternId: indexPattern.id, geoFieldName: config.fieldName, geoFieldType: config.type as ES_GEO_FIELD_TYPE, + filters: data.query.filterManager.getFilters() ?? [], query: { query: combinedQuery.searchString, language: combinedQuery.searchQueryLanguage, @@ -57,19 +58,16 @@ export const GeoPointContentWithMap: FC<{ } updateIndexPatternSearchLayer(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [indexPattern, combinedQuery, config, mapsPlugin]); + }, [indexPattern, combinedQuery, config, mapsPlugin, data.query]); if (stats?.examples === undefined) return null; return ( - - - - - + + - + ); }; diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/expanded_row/index_based_expanded_row.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/expanded_row/index_based_expanded_row.tsx index ca9c8301bcfba..79af35f1c8005 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/expanded_row/index_based_expanded_row.tsx +++ b/x-pack/plugins/data_visualizer/public/application/common/components/expanded_row/index_based_expanded_row.tsx @@ -22,15 +22,21 @@ import { FieldVisConfig } from '../stats_table/types'; import { IndexPattern } from '../../../../../../../../src/plugins/data/common'; import { CombinedQuery } from '../../../index_data_visualizer/types/combined_query'; import { LoadingIndicator } from '../loading_indicator'; +import { IndexPatternField } from '../../../../../../../../src/plugins/data/common'; export const IndexBasedDataVisualizerExpandedRow = ({ item, indexPattern, combinedQuery, + onAddFilter, }: { item: FieldVisConfig; indexPattern: IndexPattern | undefined; combinedQuery: CombinedQuery; + /** + * Callback to add a filter to filter bar + */ + onAddFilter?: (field: IndexPatternField | string, value: string, type: '+' | '-') => void; }) => { const config = item; const { loading, type, existsInDocs, fieldName } = config; @@ -42,7 +48,7 @@ export const IndexBasedDataVisualizerExpandedRow = ({ switch (type) { case JOB_FIELD_TYPES.NUMBER: - return ; + return ; case JOB_FIELD_TYPES.BOOLEAN: return ; @@ -61,10 +67,10 @@ export const IndexBasedDataVisualizerExpandedRow = ({ ); case JOB_FIELD_TYPES.IP: - return ; + return ; case JOB_FIELD_TYPES.KEYWORD: - return ; + return ; case JOB_FIELD_TYPES.TEXT: return ; @@ -75,10 +81,7 @@ export const IndexBasedDataVisualizerExpandedRow = ({ } return ( -
+
{loading === true ? : getCardContent()}
); diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/field_count_panel/field_count_panel.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/field_count_panel/field_count_panel.tsx index c79ed4ade7092..ce98ecd2fa5c7 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/field_count_panel/field_count_panel.tsx +++ b/x-pack/plugins/data_visualizer/public/application/common/components/field_count_panel/field_count_panel.tsx @@ -28,12 +28,13 @@ export const FieldCountPanel: FC = ({ - + , combinedQuery: CombinedQuery, actionFlyoutRef: MutableRefObject<(() => void | undefined) | undefined> ): Array> { - const { lens: lensPlugin, indexPatternFieldEditor } = services; + const { lens: lensPlugin, data } = services; const actions: Array> = []; + const filters = data?.query.filterManager.getFilters() ?? []; const refreshPage = () => { const refresh: Refresh = { @@ -49,7 +50,7 @@ export function getActions( available: (item: FieldVisConfig) => getCompatibleLensDataType(item.type) !== undefined && canUseLensEditor, onClick: (item: FieldVisConfig) => { - const lensAttributes = getLensAttributes(indexPattern, combinedQuery, item); + const lensAttributes = getLensAttributes(indexPattern, combinedQuery, filters, item); if (lensAttributes) { lensPlugin.navigateToPrefilledEditor({ id: `dataVisualizer-${item.fieldName}`, @@ -62,7 +63,7 @@ export function getActions( } // Allow to edit index pattern field - if (indexPatternFieldEditor?.userPermissions.editIndexPattern()) { + if (services.indexPatternFieldEditor?.userPermissions.editIndexPattern()) { actions.push({ name: i18n.translate('xpack.dataVisualizer.index.dataGrid.editIndexPatternFieldTitle', { defaultMessage: 'Edit index pattern field', @@ -76,7 +77,7 @@ export function getActions( type: 'icon', icon: 'indexEdit', onClick: (item: FieldVisConfig) => { - actionFlyoutRef.current = indexPatternFieldEditor?.openEditor({ + actionFlyoutRef.current = services.indexPatternFieldEditor?.openEditor({ ctx: { indexPattern }, fieldName: item.fieldName, onSave: refreshPage, @@ -100,7 +101,7 @@ export function getActions( return item.deletable === true; }, onClick: (item: FieldVisConfig) => { - actionFlyoutRef.current = indexPatternFieldEditor?.openDeleteModal({ + actionFlyoutRef.current = services.indexPatternFieldEditor?.openDeleteModal({ ctx: { indexPattern }, fieldName: item.fieldName!, onDelete: refreshPage, diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/field_data_row/action_menu/lens_utils.ts b/x-pack/plugins/data_visualizer/public/application/common/components/field_data_row/action_menu/lens_utils.ts index 3f80bbefcc259..615ba84afb5b7 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/field_data_row/action_menu/lens_utils.ts +++ b/x-pack/plugins/data_visualizer/public/application/common/components/field_data_row/action_menu/lens_utils.ts @@ -6,6 +6,7 @@ */ import { i18n } from '@kbn/i18n'; +import type { Filter } from '@kbn/es-query'; import type { IndexPattern } from '../../../../../../../../../src/plugins/data/common'; import type { CombinedQuery } from '../../../../index_data_visualizer/types/combined_query'; import type { @@ -15,6 +16,7 @@ import type { } from '../../../../../../../lens/public'; import { FieldVisConfig } from '../../stats_table/types'; import { JOB_FIELD_TYPES } from '../../../../../../common'; + interface ColumnsAndLayer { columns: Record; layer: XYLayerConfig; @@ -241,6 +243,7 @@ function getColumnsAndLayer( export function getLensAttributes( defaultIndexPattern: IndexPattern | undefined, combinedQuery: CombinedQuery, + filters: Filter[], item: FieldVisConfig ): TypedLensByValueInput['attributes'] | undefined { if (defaultIndexPattern === undefined || item.type === undefined || item.fieldName === undefined) @@ -279,7 +282,7 @@ export function getLensAttributes( }, }, }, - filters: [], + filters, query: { language: combinedQuery.searchQueryLanguage, query: combinedQuery.searchString }, visualization: { axisTitlesVisibilitySettings: { x: true, yLeft: true, yRight: true }, diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/field_data_row/number_content_preview.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/field_data_row/number_content_preview.tsx index 08d2d42c6c027..0e38be72b20a4 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/field_data_row/number_content_preview.tsx +++ b/x-pack/plugins/data_visualizer/public/application/common/components/field_data_row/number_content_preview.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; import { FileBasedFieldVisConfig } from '../stats_table/types'; export const FileBasedNumberContentPreview = ({ config }: { config: FileBasedFieldVisConfig }) => { @@ -23,28 +23,34 @@ export const FileBasedNumberContentPreview = ({ config }: { config: FileBasedFie - + - + - + - + - + - + - {stats.min} - {stats.median} - {stats.max} + + {stats.min} + + + {stats.median} + + + {stats.max} + ); diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/field_type_icon/__snapshots__/field_type_icon.test.tsx.snap b/x-pack/plugins/data_visualizer/public/application/common/components/field_type_icon/__snapshots__/field_type_icon.test.tsx.snap index e69e2e7626718..927d8ddb7a851 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/field_type_icon/__snapshots__/field_type_icon.test.tsx.snap +++ b/x-pack/plugins/data_visualizer/public/application/common/components/field_type_icon/__snapshots__/field_type_icon.test.tsx.snap @@ -2,6 +2,7 @@ exports[`FieldTypeIcon render component when type matches a field type 1`] = ` diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/field_type_icon/_field_type_icon.scss b/x-pack/plugins/data_visualizer/public/application/common/components/field_type_icon/_field_type_icon.scss new file mode 100644 index 0000000000000..67023a4421636 --- /dev/null +++ b/x-pack/plugins/data_visualizer/public/application/common/components/field_type_icon/_field_type_icon.scss @@ -0,0 +1,4 @@ +.dvFieldTypeIcon__anchor { + display: flex; + align-items: center; +} diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/field_type_icon/_index.scss b/x-pack/plugins/data_visualizer/public/application/common/components/field_type_icon/_index.scss new file mode 100644 index 0000000000000..e57d750df95ec --- /dev/null +++ b/x-pack/plugins/data_visualizer/public/application/common/components/field_type_icon/_index.scss @@ -0,0 +1 @@ +@import 'field_type_icon'; diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/field_type_icon/field_type_icon.test.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/field_type_icon/field_type_icon.test.tsx index 6b7c9eafc8c3e..b6a5ff3e5dbed 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/field_type_icon/field_type_icon.test.tsx +++ b/x-pack/plugins/data_visualizer/public/application/common/components/field_type_icon/field_type_icon.test.tsx @@ -26,11 +26,10 @@ describe('FieldTypeIcon', () => { const typeIconComponent = mount( ); - const container = typeIconComponent.find({ 'data-test-subj': 'fieldTypeIcon' }); expect(typeIconComponent.find('EuiToolTip').children()).toHaveLength(1); - container.simulate('mouseover'); + typeIconComponent.simulate('mouseover'); // Run the timers so the EuiTooltip will be visible jest.runAllTimers(); @@ -38,7 +37,7 @@ describe('FieldTypeIcon', () => { typeIconComponent.update(); expect(typeIconComponent.find('EuiToolTip').children()).toHaveLength(2); - container.simulate('mouseout'); + typeIconComponent.simulate('mouseout'); // Run the timers so the EuiTooltip will be hidden again jest.runAllTimers(); diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/field_type_icon/field_type_icon.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/field_type_icon/field_type_icon.tsx index ee4b4f8171d7d..3f84950c1345b 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/field_type_icon/field_type_icon.tsx +++ b/x-pack/plugins/data_visualizer/public/application/common/components/field_type_icon/field_type_icon.tsx @@ -6,91 +6,62 @@ */ import React, { FC } from 'react'; - import { EuiToken, EuiToolTip } from '@elastic/eui'; - import { i18n } from '@kbn/i18n'; - import { getJobTypeAriaLabel } from '../../util/field_types_utils'; -import { JOB_FIELD_TYPES } from '../../../../../common'; import type { JobFieldType } from '../../../../../common'; +import './_index.scss'; interface FieldTypeIconProps { tooltipEnabled: boolean; type: JobFieldType; - fieldName?: string; needsAria: boolean; } interface FieldTypeIconContainerProps { ariaLabel: string | null; iconType: string; - color: string; + color?: string; needsAria: boolean; [key: string]: any; } +// defaultIcon => a unknown datatype +const defaultIcon = { iconType: 'questionInCircle', color: 'gray' }; + +// Extended & modified version of src/plugins/kibana_react/public/field_icon/field_icon.tsx +export const typeToEuiIconMap: Record = { + boolean: { iconType: 'tokenBoolean' }, + // icon for an index pattern mapping conflict in discover + conflict: { iconType: 'alert', color: 'euiColorVis9' }, + date: { iconType: 'tokenDate' }, + date_range: { iconType: 'tokenDate' }, + geo_point: { iconType: 'tokenGeo' }, + geo_shape: { iconType: 'tokenGeo' }, + ip: { iconType: 'tokenIP' }, + ip_range: { iconType: 'tokenIP' }, + // is a plugin's data type https://www.elastic.co/guide/en/elasticsearch/plugins/current/mapper-murmur3-usage.html + murmur3: { iconType: 'tokenFile' }, + number: { iconType: 'tokenNumber' }, + number_range: { iconType: 'tokenNumber' }, + histogram: { iconType: 'tokenHistogram' }, + _source: { iconType: 'editorCodeBlock', color: 'gray' }, + string: { iconType: 'tokenString' }, + text: { iconType: 'tokenString' }, + keyword: { iconType: 'tokenKeyword' }, + nested: { iconType: 'tokenNested' }, +}; + export const FieldTypeIcon: FC = ({ tooltipEnabled = false, type, - fieldName, needsAria = true, }) => { const ariaLabel = getJobTypeAriaLabel(type); - - let iconType = 'questionInCircle'; - let color = 'euiColorVis6'; - - switch (type) { - // Set icon types and colors - case JOB_FIELD_TYPES.BOOLEAN: - iconType = 'tokenBoolean'; - color = 'euiColorVis5'; - break; - case JOB_FIELD_TYPES.DATE: - iconType = 'tokenDate'; - color = 'euiColorVis7'; - break; - case JOB_FIELD_TYPES.GEO_POINT: - case JOB_FIELD_TYPES.GEO_SHAPE: - iconType = 'tokenGeo'; - color = 'euiColorVis8'; - break; - case JOB_FIELD_TYPES.TEXT: - iconType = 'document'; - color = 'euiColorVis9'; - break; - case JOB_FIELD_TYPES.IP: - iconType = 'tokenIP'; - color = 'euiColorVis3'; - break; - case JOB_FIELD_TYPES.KEYWORD: - iconType = 'tokenText'; - color = 'euiColorVis0'; - break; - case JOB_FIELD_TYPES.NUMBER: - iconType = 'tokenNumber'; - color = fieldName !== undefined ? 'euiColorVis1' : 'euiColorVis2'; - break; - case JOB_FIELD_TYPES.HISTOGRAM: - iconType = 'tokenHistogram'; - color = 'euiColorVis7'; - case JOB_FIELD_TYPES.UNKNOWN: - // Use defaults - break; - } - - const containerProps = { - ariaLabel, - iconType, - color, - needsAria, - }; + const token = typeToEuiIconMap[type] || defaultIcon; + const containerProps = { ...token, ariaLabel, needsAria }; if (tooltipEnabled === true) { - // wrap the inner component inside because EuiToolTip doesn't seem - // to support having another component directly inside the tooltip anchor - // see https://github.com/elastic/eui/issues/839 return ( = ({ defaultMessage: '{type} type', values: { type }, })} + anchorClassName="dvFieldTypeIcon__anchor" > @@ -122,12 +94,15 @@ const FieldTypeIconContainer: FC = ({ if (needsAria && ariaLabel) { wrapperProps['aria-label'] = ariaLabel; } - return ( - - - - - + ); }; diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/field_types_filter/field_types_filter.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/field_types_filter/field_types_filter.tsx index 511a068f305f9..97dc2077d5931 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/field_types_filter/field_types_filter.tsx +++ b/x-pack/plugins/data_visualizer/public/application/common/components/field_types_filter/field_types_filter.tsx @@ -14,7 +14,7 @@ import type { FileBasedUnknownFieldVisConfig, } from '../stats_table/types/field_vis_config'; import { FieldTypeIcon } from '../field_type_icon'; -import { JOB_FIELD_TYPES_OPTIONS } from '../../../../../common'; +import { jobTypeLabels } from '../../util/field_types_utils'; interface Props { fields: Array; @@ -39,27 +39,18 @@ export const DataVisualizerFieldTypesFilter: FC = ({ const fieldTypesTracker = new Set(); const fieldTypes: Option[] = []; fields.forEach(({ type }) => { - if ( - type !== undefined && - !fieldTypesTracker.has(type) && - JOB_FIELD_TYPES_OPTIONS[type] !== undefined - ) { - const item = JOB_FIELD_TYPES_OPTIONS[type]; + if (type !== undefined && !fieldTypesTracker.has(type) && jobTypeLabels[type] !== undefined) { + const label = jobTypeLabels[type]; fieldTypesTracker.add(type); fieldTypes.push({ value: type, name: ( - {item.name} + {label} {type && ( - + )} diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/fields_stats_grid/fields_stats_grid.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/fields_stats_grid/fields_stats_grid.tsx index 8e59345c9bd63..b57072eed2944 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/fields_stats_grid/fields_stats_grid.tsx +++ b/x-pack/plugins/data_visualizer/public/application/common/components/fields_stats_grid/fields_stats_grid.tsx @@ -25,7 +25,7 @@ interface Props { export const getDefaultDataVisualizerListState = (): DataVisualizerTableState => ({ pageIndex: 0, - pageSize: 10, + pageSize: 25, sortField: 'fieldName', sortDirection: 'asc', visibleFieldTypes: [], diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/multi_select_picker/multi_select_picker.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/multi_select_picker/multi_select_picker.tsx index caa58009fda5d..ff4701e22953f 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/multi_select_picker/multi_select_picker.tsx +++ b/x-pack/plugins/data_visualizer/public/application/common/components/multi_select_picker/multi_select_picker.tsx @@ -98,7 +98,7 @@ export const MultiSelectPicker: FC<{ ); return ( - + ( - - - - + .euiTableRowCell { - border-bottom: 0; - border-top: $euiBorderThin; +@include euiBreakpoint('m', 'l', 'xl') { + .dvTable { + .columnHeader__title { + display: flex; + align-items: center; + } - } - .euiTableRow-isExpandedRow { + .columnHeader__icon { + padding-right: $euiSizeXS; + } + + .euiTableRow > .euiTableRowCell { + border-bottom: 0; + border-top: $euiBorderThin; - .euiTableRowCell { - background-color: $euiColorEmptyShade !important; - border-top: 0; - border-bottom: $euiBorderThin; - &:hover { + } + + .euiTableCellContent { + padding: $euiSizeXS; + } + + .euiTableRow-isExpandedRow { + + .euiTableRowCell { background-color: $euiColorEmptyShade !important; + border-top: 0; + border-bottom: $euiBorderThin; + &:hover { + background-color: $euiColorEmptyShade !important; + } } } - } - .dataVisualizerSummaryTable { - max-width: 350px; - min-width: 250px; - .euiTableRow > .euiTableRowCell { - border-bottom: 0; + + .dvSummaryTable { + .euiTableRow > .euiTableRowCell { + border-bottom: 0; + } + .euiTableHeaderCell { + display: none; + } + } + + .dvSummaryTable__wrapper { + min-width: $panelWidthS; + max-width: $panelWidthS; } - .euiTableHeaderCell { - display: none; + + .dvTopValues__wrapper { + min-width: fit-content; + } + + .dvPanel__wrapper { + margin: $euiSizeXS $euiSizeM $euiSizeM 0; + &.dvPanel--compressed { + width: $panelWidthS; + } + &.dvPanel--uniform { + min-width: $panelWidthS; + max-width: $panelWidthS; + } + } + + .dvMap__wrapper { + height: $euiSize * 15; //240px + } + + .dvText__wrapper { + min-width: $panelWidthS; } - } - .dataVisualizerSummaryTableWrapper { - max-width: 300px; - } - .dataVisualizerMapWrapper { - min-height: 300px; - min-width: 600px; } } diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/expanded_row_field_header/expanded_row_field_header.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/expanded_row_field_header/expanded_row_field_header.tsx index 7279bceb8be93..8fdb68c6efa4c 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/expanded_row_field_header/expanded_row_field_header.tsx +++ b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/expanded_row_field_header/expanded_row_field_header.tsx @@ -9,7 +9,12 @@ import { EuiText } from '@elastic/eui'; import React from 'react'; export const ExpandedRowFieldHeader = ({ children }: { children: React.ReactNode }) => ( - + {children} ); diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_count_stats/_index.scss b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_count_stats/_index.scss index e44082c90ba32..0774cb198ea90 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_count_stats/_index.scss +++ b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_count_stats/_index.scss @@ -1,3 +1,12 @@ -.dataVisualizerFieldCountContainer { +.dvFieldCount__panel { + margin-left: $euiSizeXS; + @include euiBreakpoint('xs', 's') { + flex-direction: column; + align-items: flex-start; + } +} + +.dvFieldCount__item { max-width: 300px; + min-width: 300px; } diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_count_stats/metric_fields_count.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_count_stats/metric_fields_count.tsx index 7996e6366c497..3b1dbf0c6376d 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_count_stats/metric_fields_count.tsx +++ b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_count_stats/metric_fields_count.tsx @@ -30,8 +30,9 @@ export const MetricFieldsCount: FC = ({ metricsStats }) diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_count_stats/total_fields_count.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_count_stats/total_fields_count.tsx index 8e9e3e59f1281..53aa84c09d3a7 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_count_stats/total_fields_count.tsx +++ b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_count_stats/total_fields_count.tsx @@ -30,8 +30,9 @@ export const TotalFieldsCount: FC = ({ fieldsCountStats } diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_expanded_row/boolean_content.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_expanded_row/boolean_content.tsx index 2869b5030f81b..754d0e470fe40 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_expanded_row/boolean_content.tsx +++ b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_expanded_row/boolean_content.tsx @@ -6,7 +6,7 @@ */ import React, { FC, ReactNode, useMemo } from 'react'; -import { EuiBasicTable, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import { EuiBasicTable, EuiSpacer, RIGHT_ALIGNMENT, HorizontalAlignment } from '@elastic/eui'; import { Axis, BarSeries, Chart, Settings } from '@elastic/charts'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -18,6 +18,7 @@ import { roundToDecimalPlace } from '../../../utils'; import { useDataVizChartTheme } from '../../hooks'; import { DocumentStatsTable } from './document_stats'; import { ExpandedRowContent } from './expanded_row_content'; +import { ExpandedRowPanel } from './expanded_row_panel'; function getPercentLabel(value: number): string { if (value === 0) { @@ -35,7 +36,7 @@ function getFormattedValue(value: number, totalCount: number): string { return `${value} (${getPercentLabel(percentage)})`; } -const BOOLEAN_DISTRIBUTION_CHART_HEIGHT = 100; +const BOOLEAN_DISTRIBUTION_CHART_HEIGHT = 70; export const BooleanContent: FC = ({ config }) => { const fieldFormat = 'fieldFormat' in config ? config.fieldFormat : undefined; @@ -68,9 +69,11 @@ export const BooleanContent: FC = ({ config }) => { ]; const summaryTableColumns = [ { + field: 'function', name: '', - render: (summaryItem: { display: ReactNode }) => summaryItem.display, - width: '75px', + render: (_: string, summaryItem: { display: ReactNode }) => summaryItem.display, + width: '25px', + align: RIGHT_ALIGNMENT as HorizontalAlignment, }, { field: 'value', @@ -90,18 +93,18 @@ export const BooleanContent: FC = ({ config }) => { - + {summaryTableTitle} - + - + = ({ config }) => { yScaleType="linear" /> - + ); }; diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_expanded_row/choropleth_map.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_expanded_row/choropleth_map.tsx index 318ff655abb21..9192ea85d5868 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_expanded_row/choropleth_map.tsx +++ b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_expanded_row/choropleth_map.tsx @@ -6,7 +6,7 @@ */ import React, { FC, useMemo } from 'react'; -import { EuiFlexItem, EuiSpacer, EuiText, htmlIdGenerator } from '@elastic/eui'; +import { EuiSpacer, EuiText, htmlIdGenerator } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { @@ -20,6 +20,7 @@ import { import { EMSTermJoinConfig } from '../../../../../../../../maps/public'; import { EmbeddedMapComponent } from '../../../embedded_map'; import { FieldVisStats } from '../../../../../../../common/types'; +import { ExpandedRowPanel } from './expanded_row_panel'; export const getChoroplethTopValuesLayer = ( fieldName: string, @@ -104,14 +105,19 @@ export const ChoroplethMap: FC = ({ stats, suggestion }) => { ); return ( - -
+ +
+ {isTopValuesSampled === true && ( - <> - - +
+ + = ({ stats, suggestion }) => { }} /> - +
)} - +
); }; diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_expanded_row/date_content.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_expanded_row/date_content.tsx index 4adb76575dd48..8d5704fc16fd5 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_expanded_row/date_content.tsx +++ b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_expanded_row/date_content.tsx @@ -6,16 +6,18 @@ */ import React, { FC, ReactNode } from 'react'; -import { EuiBasicTable, EuiFlexItem } from '@elastic/eui'; +import { EuiBasicTable, HorizontalAlignment } from '@elastic/eui'; // @ts-ignore import { formatDate } from '@elastic/eui/lib/services/format'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; +import { RIGHT_ALIGNMENT } from '@elastic/eui'; import type { FieldDataRowProps } from '../../types/field_data_row'; import { ExpandedRowFieldHeader } from '../expanded_row_field_header'; import { DocumentStatsTable } from './document_stats'; import { ExpandedRowContent } from './expanded_row_content'; +import { ExpandedRowPanel } from './expanded_row_panel'; const TIME_FORMAT = 'MMM D YYYY, HH:mm:ss.SSS'; interface SummaryTableItem { function: string; @@ -60,8 +62,10 @@ export const DateContent: FC = ({ config }) => { const summaryTableColumns = [ { name: '', - render: (summaryItem: { display: ReactNode }) => summaryItem.display, - width: '75px', + field: 'function', + render: (func: string, summaryItem: { display: ReactNode }) => summaryItem.display, + width: '70px', + align: RIGHT_ALIGNMENT as HorizontalAlignment, }, { field: 'value', @@ -73,10 +77,10 @@ export const DateContent: FC = ({ config }) => { return ( - + {summaryTableTitle} - className={'dataVisualizerSummaryTable'} + className={'dvSummaryTable'} data-test-subj={'dataVisualizerDateSummaryTable'} compressed items={summaryTableItems} @@ -84,7 +88,7 @@ export const DateContent: FC = ({ config }) => { tableCaption={summaryTableTitle} tableLayout="auto" /> - + ); }; diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_expanded_row/document_stats.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_expanded_row/document_stats.tsx index f4ed74193d90a..5995b81555f9b 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_expanded_row/document_stats.tsx +++ b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_expanded_row/document_stats.tsx @@ -8,16 +8,19 @@ import { FormattedMessage } from '@kbn/i18n/react'; import React, { FC, ReactNode } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiBasicTable, EuiFlexItem } from '@elastic/eui'; +import { EuiBasicTable, HorizontalAlignment, RIGHT_ALIGNMENT } from '@elastic/eui'; import { ExpandedRowFieldHeader } from '../expanded_row_field_header'; import { FieldDataRowProps } from '../../types'; import { roundToDecimalPlace } from '../../../utils'; +import { ExpandedRowPanel } from './expanded_row_panel'; const metaTableColumns = [ { + field: 'function', name: '', - render: (metaItem: { display: ReactNode }) => metaItem.display, - width: '75px', + render: (_: string, metaItem: { display: ReactNode }) => metaItem.display, + width: '25px', + align: RIGHT_ALIGNMENT as HorizontalAlignment, }, { field: 'value', @@ -76,18 +79,18 @@ export const DocumentStatsTable: FC = ({ config }) => { ]; return ( - {metaTableTitle} - + ); }; diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_expanded_row/expanded_row_content.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_expanded_row/expanded_row_content.tsx index a9f5dc6eaab1d..87caa0386da94 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_expanded_row/expanded_row_content.tsx +++ b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_expanded_row/expanded_row_content.tsx @@ -6,7 +6,7 @@ */ import React, { FC, ReactNode } from 'react'; -import { EuiFlexGroup } from '@elastic/eui'; +import { EuiFlexGrid } from '@elastic/eui'; interface Props { children: ReactNode; @@ -14,12 +14,8 @@ interface Props { } export const ExpandedRowContent: FC = ({ children, dataTestSubj }) => { return ( - + {children} - + ); }; diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_expanded_row/expanded_row_panel.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_expanded_row/expanded_row_panel.tsx new file mode 100644 index 0000000000000..b738dbdf67178 --- /dev/null +++ b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_expanded_row/expanded_row_panel.tsx @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC, ReactNode } from 'react'; +import { EuiPanel } from '@elastic/eui'; +import { EuiFlexItemProps } from '@elastic/eui/src/components/flex/flex_item'; + +interface Props { + children: ReactNode; + dataTestSubj?: string; + grow?: EuiFlexItemProps['grow']; + className?: string; +} +export const ExpandedRowPanel: FC = ({ children, dataTestSubj, grow, className }) => { + return ( + + {children} + + ); +}; diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_expanded_row/ip_content.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_expanded_row/ip_content.tsx index 77cf5fad5cca8..a5db86e0c30a0 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_expanded_row/ip_content.tsx +++ b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_expanded_row/ip_content.tsx @@ -11,7 +11,7 @@ import { TopValues } from '../../../top_values'; import { DocumentStatsTable } from './document_stats'; import { ExpandedRowContent } from './expanded_row_content'; -export const IpContent: FC = ({ config }) => { +export const IpContent: FC = ({ config, onAddFilter }) => { const { stats } = config; if (stats === undefined) return null; const { count, sampleCount, cardinality } = stats; @@ -21,7 +21,12 @@ export const IpContent: FC = ({ config }) => { return ( - + ); }; diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_expanded_row/keyword_content.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_expanded_row/keyword_content.tsx index 1baea4b3f2f7c..2bae49323a6bb 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_expanded_row/keyword_content.tsx +++ b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_expanded_row/keyword_content.tsx @@ -14,7 +14,7 @@ import { DocumentStatsTable } from './document_stats'; import { ExpandedRowContent } from './expanded_row_content'; import { ChoroplethMap } from './choropleth_map'; -export const KeywordContent: FC = ({ config }) => { +export const KeywordContent: FC = ({ config, onAddFilter }) => { const [EMSSuggestion, setEMSSuggestion] = useState(); const { stats, fieldName } = config; const fieldFormat = 'fieldFormat' in config ? config.fieldFormat : undefined; @@ -44,7 +44,12 @@ export const KeywordContent: FC = ({ config }) => { return ( - + {EMSSuggestion && stats && } ); diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_expanded_row/number_content.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_expanded_row/number_content.tsx index ef3ac5a267346..d22638af1a2eb 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_expanded_row/number_content.tsx +++ b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_expanded_row/number_content.tsx @@ -6,7 +6,13 @@ */ import React, { FC, ReactNode, useEffect, useState } from 'react'; -import { EuiBasicTable, EuiFlexItem, EuiText } from '@elastic/eui'; +import { + EuiBasicTable, + EuiFlexItem, + EuiText, + HorizontalAlignment, + RIGHT_ALIGNMENT, +} from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; @@ -21,8 +27,9 @@ import { TopValues } from '../../../top_values'; import { ExpandedRowFieldHeader } from '../expanded_row_field_header'; import { DocumentStatsTable } from './document_stats'; import { ExpandedRowContent } from './expanded_row_content'; +import { ExpandedRowPanel } from './expanded_row_panel'; -const METRIC_DISTRIBUTION_CHART_WIDTH = 325; +const METRIC_DISTRIBUTION_CHART_WIDTH = 260; const METRIC_DISTRIBUTION_CHART_HEIGHT = 200; interface SummaryTableItem { @@ -31,7 +38,7 @@ interface SummaryTableItem { value: number | string | undefined | null; } -export const NumberContent: FC = ({ config }) => { +export const NumberContent: FC = ({ config, onAddFilter }) => { const { stats } = config; useEffect(() => { @@ -83,7 +90,8 @@ export const NumberContent: FC = ({ config }) => { { name: '', render: (summaryItem: { display: ReactNode }) => summaryItem.display, - width: '75px', + width: '25px', + align: RIGHT_ALIGNMENT as HorizontalAlignment, }, { field: 'value', @@ -101,23 +109,33 @@ export const NumberContent: FC = ({ config }) => { return ( - + {summaryTableTitle} - className={'dataVisualizerSummaryTable'} + className={'dvSummaryTable'} compressed items={summaryTableItems} columns={summaryTableColumns} tableCaption={summaryTableTitle} data-test-subj={'dataVisualizerNumberSummaryTable'} /> - + {stats && ( - + )} {distribution && ( - + = ({ config }) => { /> - + = ({ config }) => { /> - + )} ); diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_expanded_row/other_content.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_expanded_row/other_content.tsx index 98d5cb2ec0fc9..4307da33523ed 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_expanded_row/other_content.tsx +++ b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_expanded_row/other_content.tsx @@ -6,7 +6,6 @@ */ import React, { FC } from 'react'; -import { EuiFlexItem } from '@elastic/eui'; import type { FieldDataRowProps } from '../../types/field_data_row'; import { ExamplesList } from '../../../examples_list'; import { DocumentStatsTable } from './document_stats'; @@ -15,14 +14,12 @@ import { ExpandedRowContent } from './expanded_row_content'; export const OtherContent: FC = ({ config }) => { const { stats } = config; if (stats === undefined) return null; - return ( + return stats.count === undefined ? ( + <>{Array.isArray(stats.examples) && } + ) : ( - {Array.isArray(stats.examples) && ( - - - - )} + {Array.isArray(stats.examples) && } ); }; diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_expanded_row/text_content.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_expanded_row/text_content.tsx index 700a715a33396..6f946fc1025ed 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_expanded_row/text_content.tsx +++ b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_expanded_row/text_content.tsx @@ -6,7 +6,7 @@ */ import React, { FC, Fragment } from 'react'; -import { EuiCallOut, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import { EuiCallOut, EuiSpacer, EuiFlexItem } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; @@ -26,7 +26,7 @@ export const TextContent: FC = ({ config }) => { return ( - + {numExamples > 0 && } {numExamples === 0 && ( @@ -44,7 +44,7 @@ export const TextContent: FC = ({ config }) => { id="xpack.dataVisualizer.dataGrid.fieldText.fieldNotPresentDescription" defaultMessage="This field was not present in the {sourceParam} field of documents queried." values={{ - sourceParam: _source, + sourceParam: _source, }} /> @@ -54,10 +54,10 @@ export const TextContent: FC = ({ config }) => { id="xpack.dataVisualizer.dataGrid.fieldText.fieldMayBePopulatedDescription" defaultMessage="It may be populated, for example, using a {copyToParam} parameter in the document mapping, or be pruned from the {sourceParam} field after indexing through the use of {includesParam} and {excludesParam} parameters." values={{ - copyToParam: copy_to, - sourceParam: _source, - includesParam: includes, - excludesParam: excludes, + copyToParam: copy_to, + sourceParam: _source, + includesParam: includes, + excludesParam: excludes, }} /> diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_row/column_chart.scss b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_row/column_chart.scss index 63603ee9bd2ec..8a0b9cc992c3e 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_row/column_chart.scss +++ b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_row/column_chart.scss @@ -1,19 +1,22 @@ .dataGridChart__histogram { width: 100%; - height: $euiSizeXL + $euiSizeXXL; +} + +.dataGridChart__column-chart { + width: 100%; } .dataGridChart__legend { @include euiTextTruncate; - @include euiFontSizeXS; color: $euiColorMediumShade; display: block; overflow-x: hidden; - margin: $euiSizeXS 0 0 0; font-style: italic; font-weight: normal; text-align: left; + line-height: 1.1; + font-size: #{$euiFontSizeL / 2}; // 10px } .dataGridChart__legend--numeric { @@ -21,9 +24,7 @@ } .dataGridChart__legendBoolean { - width: 100%; - min-width: $euiButtonMinWidth; - td { text-align: center } + width: #{$euiSizeXS * 2.5} // 10px } /* Override to align column header to bottom of cell when no chart is available */ diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_row/column_chart.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_row/column_chart.tsx index ed4b82005db29..453754d4d6bd4 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_row/column_chart.tsx +++ b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_row/column_chart.tsx @@ -8,7 +8,7 @@ import React, { FC } from 'react'; import classNames from 'classnames'; -import { BarSeries, Chart, Settings } from '@elastic/charts'; +import { Axis, BarSeries, Chart, Position, ScaleType, Settings } from '@elastic/charts'; import { EuiDataGridColumn } from '@elastic/eui'; import './column_chart.scss'; @@ -25,22 +25,9 @@ interface Props { maxChartColumns?: number; } -const columnChartTheme = { - background: { color: 'transparent' }, - chartMargins: { - left: 0, - right: 0, - top: 0, - bottom: 1, - }, - chartPaddings: { - left: 0, - right: 0, - top: 0, - bottom: 0, - }, - scales: { barsPadding: 0.1 }, -}; +const zeroSize = { bottom: 0, left: 0, right: 0, top: 0 }; +const size = { width: 100, height: 10 }; + export const ColumnChart: FC = ({ chartData, columnType, @@ -48,26 +35,34 @@ export const ColumnChart: FC = ({ hideLabel, maxChartColumns, }) => { - const { data, legendText, xScaleType } = useColumnChart(chartData, columnType, maxChartColumns); + const { data, legendText } = useColumnChart(chartData, columnType, maxChartColumns); return (
{!isUnsupportedChartData(chartData) && data.length > 0 && ( -
- - - d.datum.color} - data={data} - /> - -
+ + + { + return `${data[idx]?.key_as_string ?? ''}`; + }} + hide + /> + d.datum.color} + /> + )}
{ +interface Props { + cardinality?: number; + showIcon?: boolean; +} + +export const DistinctValues = ({ cardinality, showIcon }: Props) => { if (cardinality === undefined) return null; return ( - - - - - - {cardinality} - - + <> + {showIcon ? : null} + {cardinality} + ); }; diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_row/document_stats.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_row/document_stats.tsx index 7d0bda6ac47ea..01b8f0af9538d 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_row/document_stats.tsx +++ b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_row/document_stats.tsx @@ -5,29 +5,36 @@ * 2.0. */ -import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiText } from '@elastic/eui'; +import { EuiIcon, EuiText } from '@elastic/eui'; import React from 'react'; import type { FieldDataRowProps } from '../../types/field_data_row'; import { roundToDecimalPlace } from '../../../utils'; +import { isIndexBasedFieldVisConfig } from '../../types'; -export const DocumentStat = ({ config }: FieldDataRowProps) => { +interface Props extends FieldDataRowProps { + showIcon?: boolean; +} +export const DocumentStat = ({ config, showIcon }: Props) => { const { stats } = config; if (stats === undefined) return null; - const { count, sampleCount } = stats; - if (count === undefined || sampleCount === undefined) return null; - const docsPercent = roundToDecimalPlace((count / sampleCount) * 100); + // If field exists is docs but we don't have count stats then don't show + // Otherwise if field doesn't appear in docs at all, show 0% + const docsCount = + count ?? (isIndexBasedFieldVisConfig(config) && config.existsInDocs === true ? undefined : 0); + const docsPercent = + docsCount !== undefined && sampleCount !== undefined + ? roundToDecimalPlace((docsCount / sampleCount) * 100) + : 0; - return ( - - - - - - {count} ({docsPercent}%) + return docsCount !== undefined ? ( + <> + {showIcon ? : null} + + {docsCount} ({docsPercent}%) - - ); + + ) : null; }; diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_row/number_content_preview.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_row/number_content_preview.tsx index 651e41b0cbea8..dd8685fdb9380 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_row/number_content_preview.tsx +++ b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_row/number_content_preview.tsx @@ -6,7 +6,7 @@ */ import React, { FC, useEffect, useState } from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import classNames from 'classnames'; import { MetricDistributionChart, @@ -16,8 +16,8 @@ import { import { FieldVisConfig } from '../../types'; import { kibanaFieldFormat, formatSingleValue } from '../../../utils'; -const METRIC_DISTRIBUTION_CHART_WIDTH = 150; -const METRIC_DISTRIBUTION_CHART_HEIGHT = 80; +const METRIC_DISTRIBUTION_CHART_WIDTH = 100; +const METRIC_DISTRIBUTION_CHART_HEIGHT = 10; export interface NumberContentPreviewProps { config: FieldVisConfig; @@ -59,8 +59,11 @@ export const IndexBasedNumberContentPreview: FC = ({
{legendText && ( <> - - + {kibanaFieldFormat(legendText.min, fieldFormat)} diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_row/use_column_chart.test.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_row/use_column_chart.test.tsx index aff4d6d62c6c8..228719552da9e 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_row/use_column_chart.test.tsx +++ b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_row/use_column_chart.test.tsx @@ -122,8 +122,8 @@ describe('getLegendText()', () => { })} ); - expect(getByText('true')).toBeInTheDocument(); - expect(getByText('false')).toBeInTheDocument(); + expect(getByText('t')).toBeInTheDocument(); + expect(getByText('f')).toBeInTheDocument(); }); it('should return the chart legend text for ordinal chart data with less than max categories', () => { expect(getLegendText({ ...validOrdinalChartData, data: [{ key: 'cat', doc_count: 10 }] })).toBe( diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_row/use_column_chart.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_row/use_column_chart.tsx index 2bcf1854235d2..2c0817228655e 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_row/use_column_chart.tsx +++ b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_row/use_column_chart.tsx @@ -94,11 +94,19 @@ export const getLegendText = ( if (chartData.type === 'boolean') { return ( - +
- {chartData.data[0] !== undefined && } - {chartData.data[1] !== undefined && } + {chartData.data[0] !== undefined && ( + + )} + {chartData.data[1] !== undefined && ( + + )}
{chartData.data[0].key_as_string}{chartData.data[1].key_as_string} + {chartData.data[0].key_as_string?.slice(0, 1) ?? ''} + + {chartData.data[1].key_as_string?.slice(0, 1) ?? ''} +
@@ -185,14 +193,16 @@ export const useColumnChart = ( // The if/else if/else is a work-around because `.map()` doesn't work with union types. // See TS Caveats for details: https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-3.html#caveats if (isOrdinalChartData(chartData)) { - data = chartData.data.map((d: OrdinalDataItem) => ({ + data = chartData.data.map((d: OrdinalDataItem, idx) => ({ ...d, + x: idx, key_as_string: d.key_as_string ?? d.key, color: getColor(d), })); } else if (isNumericChartData(chartData)) { - data = chartData.data.map((d: NumericDataItem) => ({ + data = chartData.data.map((d: NumericDataItem, idx) => ({ ...d, + x: idx, key_as_string: d.key_as_string || d.key, color: getColor(d), })); diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/metric_distribution_chart/metric_distribution_chart.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/metric_distribution_chart/metric_distribution_chart.tsx index 2c4739206d47f..627c206e87fb0 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/metric_distribution_chart/metric_distribution_chart.tsx +++ b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/metric_distribution_chart/metric_distribution_chart.tsx @@ -75,14 +75,17 @@ export const MetricDistributionChart: FC = ({ return ( ); }; return ( -
+
{ updatePageState: (update: DataVisualizerTableState) => void; getItemIdToExpandedRowMap: (itemIds: string[], items: T[]) => ItemIdToExpandedRowMap; extendedColumns?: Array>; + showPreviewByDefault?: boolean; + /** Callback to receive any updates when table or page state is changed **/ + onChange?: (update: Partial) => void; } export const DataVisualizerTable = ({ @@ -57,23 +62,52 @@ export const DataVisualizerTable = ({ updatePageState, getItemIdToExpandedRowMap, extendedColumns, + showPreviewByDefault, + onChange, }: DataVisualizerTableProps) => { const [expandedRowItemIds, setExpandedRowItemIds] = useState([]); - const [expandAll, toggleExpandAll] = useState(false); + const [expandAll, setExpandAll] = useState(false); const { onTableChange, pagination, sorting } = useTableSettings( items, pageState, updatePageState ); - const showDistributions: boolean = - ('showDistributions' in pageState && pageState.showDistributions) ?? true; - const toggleShowDistribution = () => { - updatePageState({ - ...pageState, - showDistributions: !showDistributions, - }); - }; + const [showDistributions, setShowDistributions] = useState(showPreviewByDefault ?? true); + const [dimensions, setDimensions] = useState(calculateTableColumnsDimensions()); + const [tableWidth, setTableWidth] = useState(1400); + + const toggleExpandAll = useCallback( + (shouldExpandAll: boolean) => { + setExpandedRowItemIds( + shouldExpandAll + ? // Update list of ids in expandedRowIds to include all + (items.map((item) => item.fieldName).filter((id) => id !== undefined) as string[]) + : // Otherwise, reset list of ids in expandedRowIds + [] + ); + setExpandAll(shouldExpandAll); + }, + [items] + ); + + // eslint-disable-next-line react-hooks/exhaustive-deps + const resizeHandler = useCallback( + throttle((e: { width: number; height: number }) => { + // When window or table is resized, + // update the column widths and other settings accordingly + setTableWidth(e.width); + setDimensions(calculateTableColumnsDimensions(e.width)); + }, 500), + [tableWidth] + ); + + const toggleShowDistribution = useCallback(() => { + setShowDistributions(!showDistributions); + if (onChange) { + onChange({ showDistributions: !showDistributions }); + } + }, [onChange, showDistributions]); function toggleDetails(item: DataVisualizerTableItem) { if (item.fieldName === undefined) return; @@ -90,31 +124,32 @@ export const DataVisualizerTable = ({ const columns = useMemo(() => { const expanderColumn: EuiTableComputedColumnType = { - name: ( - toggleExpandAll(!expandAll)} - aria-label={ - !expandAll - ? i18n.translate('xpack.dataVisualizer.dataGrid.expandDetailsForAllAriaLabel', { - defaultMessage: 'Expand details for all fields', - }) - : i18n.translate('xpack.dataVisualizer.dataGrid.collapseDetailsForAllAriaLabel', { - defaultMessage: 'Collapse details for all fields', - }) - } - iconType={expandAll ? 'arrowUp' : 'arrowDown'} - /> - ), + name: + dimensions.breakPoint !== 'xs' && dimensions.breakPoint !== 's' ? ( + toggleExpandAll(!expandAll)} + aria-label={ + !expandAll + ? i18n.translate('xpack.dataVisualizer.dataGrid.expandDetailsForAllAriaLabel', { + defaultMessage: 'Expand details for all fields', + }) + : i18n.translate('xpack.dataVisualizer.dataGrid.collapseDetailsForAllAriaLabel', { + defaultMessage: 'Collapse details for all fields', + }) + } + iconType={expandAll ? 'arrowDown' : 'arrowRight'} + /> + ) : null, align: RIGHT_ALIGNMENT, - width: '40px', + width: dimensions.expander, isExpander: true, render: (item: DataVisualizerTableItem) => { const displayName = item.displayName ?? item.fieldName; if (item.fieldName === undefined) return null; - const direction = expandedRowItemIds.includes(item.fieldName) ? 'arrowUp' : 'arrowDown'; + const direction = expandedRowItemIds.includes(item.fieldName) ? 'arrowDown' : 'arrowRight'; return ( ({ render: (fieldType: JobFieldType) => { return ; }, - width: '75px', + width: dimensions.type, sortable: true, align: CENTER_ALIGNMENT as HorizontalAlignment, 'data-test-subj': 'dataVisualizerTableColumnType', @@ -163,8 +198,8 @@ export const DataVisualizerTable = ({ const displayName = item.displayName ?? item.fieldName; return ( - - {displayName} + + {displayName} ); }, @@ -177,56 +212,65 @@ export const DataVisualizerTable = ({ defaultMessage: 'Documents (%)', }), render: (value: number | undefined, item: DataVisualizerTableItem) => ( - + ), sortable: (item: DataVisualizerTableItem) => item?.stats?.count, align: LEFT_ALIGNMENT as HorizontalAlignment, 'data-test-subj': 'dataVisualizerTableColumnDocumentsCount', + width: dimensions.docCount, }, { field: 'stats.cardinality', name: i18n.translate('xpack.dataVisualizer.dataGrid.distinctValuesColumnName', { defaultMessage: 'Distinct values', }), - render: (cardinality?: number) => , + render: (cardinality: number | undefined) => ( + + ), + sortable: true, align: LEFT_ALIGNMENT as HorizontalAlignment, 'data-test-subj': 'dataVisualizerTableColumnDistinctValues', + width: dimensions.distinctValues, }, { name: ( -
- +
+ {dimensions.showIcon ? ( + + ) : null} {i18n.translate('xpack.dataVisualizer.dataGrid.distributionsColumnName', { defaultMessage: 'Distributions', })} - - toggleShowDistribution()} - aria-label={ + { + - + > + toggleShowDistribution()} + aria-label={ + showDistributions + ? i18n.translate('xpack.dataVisualizer.dataGrid.showDistributionsAriaLabel', { + defaultMessage: 'Show distributions', + }) + : i18n.translate('xpack.dataVisualizer.dataGrid.hideDistributionsAriaLabel', { + defaultMessage: 'Hide distributions', + }) + } + /> + + }
), render: (item: DataVisualizerTableItem) => { @@ -252,41 +296,49 @@ export const DataVisualizerTable = ({ return null; }, + width: dimensions.distributions, align: LEFT_ALIGNMENT as HorizontalAlignment, 'data-test-subj': 'dataVisualizerTableColumnDistribution', }, ]; return extendedColumns ? [...baseColumns, ...extendedColumns] : baseColumns; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [expandAll, showDistributions, updatePageState, extendedColumns]); + }, [ + expandAll, + showDistributions, + updatePageState, + extendedColumns, + dimensions.breakPoint, + toggleExpandAll, + ]); const itemIdToExpandedRowMap = useMemo(() => { - let itemIds = expandedRowItemIds; - if (expandAll) { - itemIds = items.map((i) => i[FIELD_NAME]).filter((f) => f !== undefined) as string[]; - } + const itemIds = expandedRowItemIds; return getItemIdToExpandedRowMap(itemIds, items); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [expandAll, items, expandedRowItemIds]); + }, [items, expandedRowItemIds, getItemIdToExpandedRowMap]); return ( - - - className={'dataVisualizer'} - items={items} - itemId={FIELD_NAME} - columns={columns} - pagination={pagination} - sorting={sorting} - isExpandable={true} - itemIdToExpandedRowMap={itemIdToExpandedRowMap} - isSelectable={false} - onTableChange={onTableChange} - data-test-subj={'dataVisualizerTable'} - rowProps={(item) => ({ - 'data-test-subj': `dataVisualizerRow row-${item.fieldName}`, - })} - /> - + + {(resizeRef) => ( +
+ + className={'dvTable'} + items={items} + itemId={FIELD_NAME} + columns={columns} + pagination={pagination} + sorting={sorting} + isExpandable={true} + itemIdToExpandedRowMap={itemIdToExpandedRowMap} + isSelectable={false} + onTableChange={onTableChange} + data-test-subj={'dataVisualizerTable'} + rowProps={(item) => ({ + 'data-test-subj': `dataVisualizerRow row-${item.fieldName}`, + })} + /> +
+ )} +
); }; diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/types/field_data_row.ts b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/types/field_data_row.ts index 24209af23ceb4..94b704764c93b 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/types/field_data_row.ts +++ b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/types/field_data_row.ts @@ -6,7 +6,9 @@ */ import type { FieldVisConfig, FileBasedFieldVisConfig } from './field_vis_config'; +import { IndexPatternField } from '../../../../../../../../../src/plugins/data/common'; export interface FieldDataRowProps { config: FieldVisConfig | FileBasedFieldVisConfig; + onAddFilter?: (field: IndexPatternField | string, value: string, type: '+' | '-') => void; } diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/use_table_settings.ts b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/use_table_settings.ts index 3fbf333bdc876..87d936edc2957 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/use_table_settings.ts +++ b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/use_table_settings.ts @@ -10,7 +10,7 @@ import { useCallback, useMemo } from 'react'; import { DataVisualizerTableState } from '../../../../../common'; -const PAGE_SIZE_OPTIONS = [10, 25, 50]; +const PAGE_SIZE_OPTIONS = [10, 25, 50, 100]; interface UseTableSettingsReturnValue { onTableChange: EuiBasicTableProps['onChange']; diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/utils.ts b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/utils.ts index 27da91153b3ba..d30a33a96c590 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/utils.ts +++ b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/utils.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { getBreakpoint } from '@elastic/eui'; import { FileBasedFieldVisConfig } from './types'; export const getTFPercentage = (config: FileBasedFieldVisConfig) => { @@ -36,3 +37,45 @@ export const getTFPercentage = (config: FileBasedFieldVisConfig) => { falseCount, }; }; + +export const calculateTableColumnsDimensions = (width?: number) => { + const defaultSettings = { + expander: '40px', + type: '75px', + docCount: '225px', + distinctValues: '225px', + distributions: '225px', + showIcon: true, + breakPoint: 'xl', + }; + if (width === undefined) return defaultSettings; + const breakPoint = getBreakpoint(width); + switch (breakPoint) { + case 'xs': + case 's': + return { + expander: '25px', + type: '40px', + docCount: 'auto', + distinctValues: 'auto', + distributions: 'auto', + showIcon: false, + breakPoint, + }; + + case 'm': + case 'l': + return { + expander: '25px', + type: '40px', + docCount: 'auto', + distinctValues: 'auto', + distributions: 'auto', + showIcon: false, + breakPoint, + }; + + default: + return defaultSettings; + } +}; diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/top_values/_top_values.scss b/x-pack/plugins/data_visualizer/public/application/common/components/top_values/_top_values.scss index 05fa1bfa94b2d..bb227dd24d48a 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/top_values/_top_values.scss +++ b/x-pack/plugins/data_visualizer/public/application/common/components/top_values/_top_values.scss @@ -4,16 +4,4 @@ .topValuesValueLabelContainer { margin-right: $euiSizeM; - &.topValuesValueLabelContainer--small { - width:70px; - } - - &.topValuesValueLabelContainer--large { - width: 200px; - } -} - -.topValuesPercentLabelContainer { - margin-left: $euiSizeM; - width:70px; } diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/top_values/top_values.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/top_values/top_values.tsx index a019f7fb0976c..45e8944c7c667 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/top_values/top_values.tsx +++ b/x-pack/plugins/data_visualizer/public/application/common/components/top_values/top_values.tsx @@ -12,21 +12,25 @@ import { EuiProgress, EuiSpacer, EuiText, - EuiToolTip, + EuiButtonIcon, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import classNames from 'classnames'; +import { i18n } from '@kbn/i18n'; import { roundToDecimalPlace, kibanaFieldFormat } from '../utils'; import { ExpandedRowFieldHeader } from '../stats_table/components/expanded_row_field_header'; import { FieldVisStats } from '../../../../../common/types'; +import { ExpandedRowPanel } from '../stats_table/components/field_data_expanded_row/expanded_row_panel'; +import { IndexPatternField } from '../../../../../../../../src/plugins/data/common/data_views/fields'; interface Props { stats: FieldVisStats | undefined; fieldFormat?: any; barColor?: 'primary' | 'secondary' | 'danger' | 'subdued' | 'accent'; compressed?: boolean; + onAddFilter?: (field: IndexPatternField | string, value: string, type: '+' | '-') => void; } function getPercentLabel(docCount: number, topValuesSampleSize: number): string { @@ -38,13 +42,23 @@ function getPercentLabel(docCount: number, topValuesSampleSize: number): string } } -export const TopValues: FC = ({ stats, fieldFormat, barColor, compressed }) => { +export const TopValues: FC = ({ stats, fieldFormat, barColor, compressed, onAddFilter }) => { if (stats === undefined) return null; - const { topValues, topValuesSampleSize, topValuesSamplerShardSize, count, isTopValuesSampled } = - stats; + const { + topValues, + topValuesSampleSize, + topValuesSamplerShardSize, + count, + isTopValuesSampled, + fieldName, + } = stats; + const progressBarMax = isTopValuesSampled === true ? topValuesSampleSize : count; return ( - + = ({ stats, fieldFormat, barColor, compressed
{Array.isArray(topValues) && topValues.map((value) => ( - - - - {kibanaFieldFormat(value.key, fieldFormat)} - - - - {progressBarMax !== undefined && ( - - - {getPercentLabel(value.doc_count, progressBarMax)} - - - )} + {fieldName !== undefined && value.key !== undefined && onAddFilter !== undefined ? ( + <> + + onAddFilter( + fieldName, + typeof value.key === 'number' ? value.key.toString() : value.key, + '+' + ) + } + aria-label={i18n.translate( + 'xpack.dataVisualizer.dataGrid.field.addFilterAriaLabel', + { + defaultMessage: 'Filter for {fieldName}: "{value}"', + values: { fieldName, value: value.key }, + } + )} + data-test-subj={`dvFieldDataTopValuesAddFilterButton-${value.key}-${value.key}`} + style={{ + minHeight: 'auto', + minWidth: 'auto', + paddingRight: 2, + paddingLeft: 2, + paddingTop: 0, + paddingBottom: 0, + }} + /> + + onAddFilter( + fieldName, + typeof value.key === 'number' ? value.key.toString() : value.key, + '-' + ) + } + aria-label={i18n.translate( + 'xpack.dataVisualizer.dataGrid.field.removeFilterAriaLabel', + { + defaultMessage: 'Filter out {fieldName}: "{value}"', + values: { fieldName, value: value.key }, + } + )} + data-test-subj={`dvFieldDataTopValuesExcludeFilterButton-${value.key}-${value.key}`} + style={{ + minHeight: 'auto', + minWidth: 'auto', + paddingTop: 0, + paddingBottom: 0, + paddingRight: 2, + paddingLeft: 2, + }} + /> + + ) : null} ))} {isTopValuesSampled === true && ( - + = ({ stats, fieldFormat, barColor, compressed )}
-
+ ); }; diff --git a/x-pack/plugins/data_visualizer/public/application/common/util/field_types_utils.ts b/x-pack/plugins/data_visualizer/public/application/common/util/field_types_utils.ts index 54e0b2d5310f3..3e459cd2b079b 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/util/field_types_utils.ts +++ b/x-pack/plugins/data_visualizer/public/application/common/util/field_types_utils.ts @@ -23,6 +23,9 @@ export const jobTypeAriaLabels = { geoPointParam: 'geo point', }, }), + GEO_SHAPE: i18n.translate('xpack.dataVisualizer.fieldTypeIcon.geoShapeTypeAriaLabel', { + defaultMessage: 'geo shape type', + }), IP: i18n.translate('xpack.dataVisualizer.fieldTypeIcon.ipTypeAriaLabel', { defaultMessage: 'ip type', }), @@ -32,6 +35,9 @@ export const jobTypeAriaLabels = { NUMBER: i18n.translate('xpack.dataVisualizer.fieldTypeIcon.numberTypeAriaLabel', { defaultMessage: 'number type', }), + HISTOGRAM: i18n.translate('xpack.dataVisualizer.fieldTypeIcon.histogramTypeAriaLabel', { + defaultMessage: 'histogram type', + }), TEXT: i18n.translate('xpack.dataVisualizer.fieldTypeIcon.textTypeAriaLabel', { defaultMessage: 'text type', }), @@ -40,6 +46,48 @@ export const jobTypeAriaLabels = { }), }; +export const jobTypeLabels = { + [JOB_FIELD_TYPES.BOOLEAN]: i18n.translate('xpack.dataVisualizer.fieldTypeIcon.booleanTypeLabel', { + defaultMessage: 'Boolean', + }), + [JOB_FIELD_TYPES.DATE]: i18n.translate('xpack.dataVisualizer.fieldTypeIcon.dateTypeLabel', { + defaultMessage: 'Date', + }), + [JOB_FIELD_TYPES.GEO_POINT]: i18n.translate( + 'xpack.dataVisualizer.fieldTypeIcon.geoPointTypeLabel', + { + defaultMessage: 'Geo point', + } + ), + [JOB_FIELD_TYPES.GEO_SHAPE]: i18n.translate( + 'xpack.dataVisualizer.fieldTypeIcon.geoShapeTypeLabel', + { + defaultMessage: 'Geo shape', + } + ), + [JOB_FIELD_TYPES.IP]: i18n.translate('xpack.dataVisualizer.fieldTypeIcon.ipTypeLabel', { + defaultMessage: 'IP', + }), + [JOB_FIELD_TYPES.KEYWORD]: i18n.translate('xpack.dataVisualizer.fieldTypeIcon.keywordTypeLabel', { + defaultMessage: 'Keyword', + }), + [JOB_FIELD_TYPES.NUMBER]: i18n.translate('xpack.dataVisualizer.fieldTypeIcon.numberTypeLabel', { + defaultMessage: 'Number', + }), + [JOB_FIELD_TYPES.HISTOGRAM]: i18n.translate( + 'xpack.dataVisualizer.fieldTypeIcon.histogramTypeLabel', + { + defaultMessage: 'Histogram', + } + ), + [JOB_FIELD_TYPES.TEXT]: i18n.translate('xpack.dataVisualizer.fieldTypeIcon.textTypeLabel', { + defaultMessage: 'Text', + }), + [JOB_FIELD_TYPES.UNKNOWN]: i18n.translate('xpack.dataVisualizer.fieldTypeIcon.unknownTypeLabel', { + defaultMessage: 'Unknown', + }), +}; + export const getJobTypeAriaLabel = (type: string) => { const requestedFieldType = Object.keys(JOB_FIELD_TYPES).find( (k) => JOB_FIELD_TYPES[k as keyof typeof JOB_FIELD_TYPES] === type diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/actions_panel/actions_panel.tsx b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/actions_panel/actions_panel.tsx index bc68bdf4b6ce0..186d3ef840c21 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/actions_panel/actions_panel.tsx +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/actions_panel/actions_panel.tsx @@ -40,6 +40,7 @@ export const ActionsPanel: FC = ({ const { services: { + data, application: { capabilities }, share: { urlGenerators: { getUrlGenerator }, @@ -60,6 +61,9 @@ export const ActionsPanel: FC = ({ const state: DiscoverUrlGeneratorState = { indexPatternId, }; + + state.filters = data.query.filterManager.getFilters() ?? []; + if (searchString && searchQueryLanguage !== undefined) { state.query = { query: searchString, language: searchQueryLanguage }; } @@ -113,6 +117,7 @@ export const ActionsPanel: FC = ({ capabilities, getUrlGenerator, additionalLinks, + data.query, ]); // Note we use display:none for the DataRecognizer section as it needs to be diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_data_visualizer_view/_index.scss b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_data_visualizer_view/_index.scss new file mode 100644 index 0000000000000..c9b1d78320aee --- /dev/null +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_data_visualizer_view/_index.scss @@ -0,0 +1 @@ +@import 'index_data_visualizer_view'; diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_data_visualizer_view/_index_data_visualizer_view.scss b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_data_visualizer_view/_index_data_visualizer_view.scss new file mode 100644 index 0000000000000..f49cb73454178 --- /dev/null +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_data_visualizer_view/_index_data_visualizer_view.scss @@ -0,0 +1,13 @@ +.dataViewTitleHeader { + min-width: 300px; + display: flex; + flex-direction: row; + align-items: center; +} + +@include euiBreakpoint('xs', 's', 'm', 'l') { + .dataVisualizerPageHeader { + flex-direction: column; + align-items: flex-start; + } +} diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_data_visualizer_view/index_data_visualizer_view.tsx b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_data_visualizer_view/index_data_visualizer_view.tsx index 0eb8e6363d607..fdd723dea3487 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_data_visualizer_view/index_data_visualizer_view.tsx +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_data_visualizer_view/index_data_visualizer_view.tsx @@ -23,12 +23,12 @@ import { EuiTableActionsColumnType } from '@elastic/eui/src/components/basic_tab import { FormattedMessage } from '@kbn/i18n/react'; import { Required } from 'utility-types'; import { i18n } from '@kbn/i18n'; +import { Filter } from '@kbn/es-query'; import { - IndexPatternField, KBN_FIELD_TYPES, UI_SETTINGS, Query, - IndexPattern, + generateFilters, } from '../../../../../../../../src/plugins/data/public'; import { FullTimeRangeSelector } from '../full_time_range_selector'; import { usePageUrlState, useUrlState } from '../../../common/util/url_state'; @@ -65,10 +65,12 @@ import { DatePickerWrapper } from '../../../common/components/date_picker_wrappe import { dataVisualizerRefresh$ } from '../../services/timefilter_refresh_service'; import { HelpMenu } from '../../../common/components/help_menu'; import { TimeBuckets } from '../../services/time_buckets'; -import { extractSearchData } from '../../utils/saved_search_utils'; +import { createMergedEsQuery, getEsQueryFromSavedSearch } from '../../utils/saved_search_utils'; import { DataVisualizerIndexPatternManagement } from '../index_pattern_management'; import { ResultLink } from '../../../common/components/results_links'; import { extractErrorProperties } from '../../utils/error_utils'; +import { IndexPatternField, IndexPattern } from '../../../../../../../../src/plugins/data/common'; +import './_index.scss'; interface DataVisualizerPageState { overallStats: OverallStats; @@ -85,7 +87,7 @@ const defaultSearchQuery = { match_all: {}, }; -function getDefaultPageState(): DataVisualizerPageState { +export function getDefaultPageState(): DataVisualizerPageState { return { overallStats: { totalCount: 0, @@ -103,22 +105,25 @@ function getDefaultPageState(): DataVisualizerPageState { documentCountStats: undefined, }; } -export const getDefaultDataVisualizerListState = - (): Required => ({ - pageIndex: 0, - pageSize: 10, - sortField: 'fieldName', - sortDirection: 'asc', - visibleFieldTypes: [], - visibleFieldNames: [], - samplerShardSize: 5000, - searchString: '', - searchQuery: defaultSearchQuery, - searchQueryLanguage: SEARCH_QUERY_LANGUAGE.KUERY, - showDistributions: true, - showAllFields: false, - showEmptyFields: false, - }); +export const getDefaultDataVisualizerListState = ( + overrides?: Partial +): Required => ({ + pageIndex: 0, + pageSize: 25, + sortField: 'fieldName', + sortDirection: 'asc', + visibleFieldTypes: [], + visibleFieldNames: [], + samplerShardSize: 5000, + searchString: '', + searchQuery: defaultSearchQuery, + searchQueryLanguage: SEARCH_QUERY_LANGUAGE.KUERY, + filters: [], + showDistributions: true, + showAllFields: false, + showEmptyFields: false, + ...overrides, +}); export interface IndexDataVisualizerViewProps { currentIndexPattern: IndexPattern; @@ -129,7 +134,7 @@ const restorableDefaults = getDefaultDataVisualizerListState(); export const IndexDataVisualizerView: FC = (dataVisualizerProps) => { const { services } = useDataVisualizerKibana(); - const { docLinks, notifications, uiSettings } = services; + const { docLinks, notifications, uiSettings, data } = services; const { toasts } = notifications; const [dataVisualizerListState, setDataVisualizerListState] = usePageUrlState( @@ -150,6 +155,15 @@ export const IndexDataVisualizerView: FC = (dataVi } }, [dataVisualizerProps?.currentSavedSearch]); + useEffect(() => { + return () => { + // When navigating away from the index pattern + // Reset all previously set filters + // to make sure new page doesn't have unrelated filters + data.query.filterManager.removeAll(); + }; + }, [currentIndexPattern.id, data.query.filterManager]); + const getTimeBuckets = useCallback(() => { return new TimeBuckets({ [UI_SETTINGS.HISTOGRAM_MAX_BARS]: uiSettings.get(UI_SETTINGS.HISTOGRAM_MAX_BARS), @@ -227,13 +241,17 @@ export const IndexDataVisualizerView: FC = (dataVi const defaults = getDefaultPageState(); const { searchQueryLanguage, searchString, searchQuery } = useMemo(() => { - const searchData = extractSearchData( - currentSavedSearch, - currentIndexPattern, - uiSettings.get(UI_SETTINGS.QUERY_STRING_OPTIONS) - ); + const searchData = getEsQueryFromSavedSearch({ + indexPattern: currentIndexPattern, + uiSettings, + savedSearch: currentSavedSearch, + filterManager: data.query.filterManager, + }); if (searchData === undefined || dataVisualizerListState.searchString !== '') { + if (dataVisualizerListState.filters) { + data.query.filterManager.setFilters(dataVisualizerListState.filters); + } return { searchQuery: dataVisualizerListState.searchQuery, searchString: dataVisualizerListState.searchString, @@ -247,26 +265,31 @@ export const IndexDataVisualizerView: FC = (dataVi }; } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [currentSavedSearch, currentIndexPattern, dataVisualizerListState]); - - const setSearchParams = (searchParams: { - searchQuery: Query['query']; - searchString: Query['query']; - queryLanguage: SearchQueryLanguage; - }) => { - // When the user loads saved search and then clear or modify the query - // we should remove the saved search and replace it with the index pattern id - if (currentSavedSearch !== null) { - setCurrentSavedSearch(null); - } + }, [currentSavedSearch, currentIndexPattern, dataVisualizerListState, data.query]); + + const setSearchParams = useCallback( + (searchParams: { + searchQuery: Query['query']; + searchString: Query['query']; + queryLanguage: SearchQueryLanguage; + filters: Filter[]; + }) => { + // When the user loads saved search and then clear or modify the query + // we should remove the saved search and replace it with the index pattern id + if (currentSavedSearch !== null) { + setCurrentSavedSearch(null); + } - setDataVisualizerListState({ - ...dataVisualizerListState, - searchQuery: searchParams.searchQuery, - searchString: searchParams.searchString, - searchQueryLanguage: searchParams.queryLanguage, - }); - }; + setDataVisualizerListState({ + ...dataVisualizerListState, + searchQuery: searchParams.searchQuery, + searchString: searchParams.searchString, + searchQueryLanguage: searchParams.queryLanguage, + filters: searchParams.filters, + }); + }, + [currentSavedSearch, dataVisualizerListState, setDataVisualizerListState] + ); const samplerShardSize = dataVisualizerListState.samplerShardSize ?? restorableDefaults.samplerShardSize; @@ -305,6 +328,52 @@ export const IndexDataVisualizerView: FC = (dataVi const [nonMetricConfigs, setNonMetricConfigs] = useState(defaults.nonMetricConfigs); const [nonMetricsLoaded, setNonMetricsLoaded] = useState(defaults.nonMetricsLoaded); + const onAddFilter = useCallback( + (field: IndexPatternField | string, values: string, operation: '+' | '-') => { + const newFilters = generateFilters( + data.query.filterManager, + field, + values, + operation, + String(currentIndexPattern.id) + ); + if (newFilters) { + data.query.filterManager.addFilters(newFilters); + } + + // Merge current query with new filters + const mergedQuery = { + query: searchString || '', + language: searchQueryLanguage, + }; + + const combinedQuery = createMergedEsQuery( + { + query: searchString || '', + language: searchQueryLanguage, + }, + data.query.filterManager.getFilters() ?? [], + currentIndexPattern, + uiSettings + ); + + setSearchParams({ + searchQuery: combinedQuery, + searchString: mergedQuery.query, + queryLanguage: mergedQuery.language as SearchQueryLanguage, + filters: data.query.filterManager.getFilters(), + }); + }, + [ + currentIndexPattern, + data.query.filterManager, + searchQueryLanguage, + searchString, + setSearchParams, + uiSettings, + ] + ); + useEffect(() => { const timeUpdateSubscription = merge( timefilter.getTimeUpdate$(), @@ -666,11 +735,11 @@ export const IndexDataVisualizerView: FC = (dataVi const fieldData = nonMetricFieldData.find((f) => f.fieldName === field.spec.name); const nonMetricConfig = { - ...fieldData, + ...(fieldData ? fieldData : {}), fieldFormat: currentIndexPattern.getFormatterForField(field), aggregatable: field.aggregatable, scripted: field.scripted, - loading: fieldData.existsInDocs, + loading: fieldData?.existsInDocs, deletable: field.runtimeField !== undefined, }; @@ -751,13 +820,14 @@ export const IndexDataVisualizerView: FC = (dataVi item={item} indexPattern={currentIndexPattern} combinedQuery={{ searchQueryLanguage, searchString }} + onAddFilter={onAddFilter} /> ); } return m; }, {} as ItemIdToExpandedRowMap); }, - [currentIndexPattern, searchQueryLanguage, searchString] + [currentIndexPattern, searchQueryLanguage, searchString, onAddFilter] ); // Some actions open up fly-out or popup @@ -809,17 +879,10 @@ export const IndexDataVisualizerView: FC = (dataVi - + -
- +
+

{currentIndexPattern.title}

= (dataVi
- - - {currentIndexPattern.timeFieldName !== undefined && ( - - - - )} + + {currentIndexPattern.timeFieldName !== undefined && ( - + - - + )} + + + + @@ -862,8 +928,6 @@ export const IndexDataVisualizerView: FC = (dataVi /> )} - - = (dataVi visibleFieldNames={visibleFieldNames} setVisibleFieldNames={setVisibleFieldNames} showEmptyFields={showEmptyFields} + onAddFilter={onAddFilter} /> - + = ({ indexedFieldTypes, setVisibleFieldTypes, visibleFieldTypes }) => { const options: Option[] = useMemo(() => { return indexedFieldTypes.map((indexedFieldName) => { - const item = JOB_FIELD_TYPES_OPTIONS[indexedFieldName]; + const label = jobTypeLabels[indexedFieldName] ?? ''; return { value: indexedFieldName, name: ( - {item.name} + {label} {indexedFieldName && ( - + )} diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/search_panel/search_panel.scss b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/search_panel/search_panel.scss new file mode 100644 index 0000000000000..6f274921d5ebf --- /dev/null +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/search_panel/search_panel.scss @@ -0,0 +1,20 @@ +.dvSearchPanel__controls { + flex-direction: row; + padding: $euiSizeS; +} + +@include euiBreakpoint('xs', 's', 'm', 'l') { + .dvSearchPanel__container { + flex-direction: column; + } + .dvSearchBar { + min-width: #{'max(100%, 500px)'}; + } + .dvSearchPanel__controls { + padding: 0; + } + // prevent margin -16 which scrunches the filter bar + .globalFilterGroup__wrapper-isVisible { + margin: 0 !important; + } +} diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/search_panel/search_panel.tsx b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/search_panel/search_panel.tsx index 91ec1e449bb38..f55114ca36d78 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/search_panel/search_panel.tsx +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/search_panel/search_panel.tsx @@ -6,21 +6,22 @@ */ import React, { FC, useEffect, useState } from 'react'; -import { EuiCode, EuiFlexItem, EuiFlexGroup, EuiInputPopover } from '@elastic/eui'; +import { EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { Query, fromKueryExpression, luceneStringToDsl, toElasticsearchQuery } from '@kbn/es-query'; -import { QueryStringInput } from '../../../../../../../../src/plugins/data/public'; +import { Query, Filter } from '@kbn/es-query'; import { ShardSizeFilter } from './shard_size_select'; import { DataVisualizerFieldNamesFilter } from './field_name_filter'; -import { DatavisualizerFieldTypeFilter } from './field_type_filter'; -import { IndexPattern } from '../../../../../../../../src/plugins/data/common'; -import { JobFieldType } from '../../../../../common/types'; +import { DataVisualizerFieldTypeFilter } from './field_type_filter'; import { - ErrorMessage, - SEARCH_QUERY_LANGUAGE, - SearchQueryLanguage, -} from '../../types/combined_query'; - + IndexPattern, + IndexPatternField, + TimeRange, +} from '../../../../../../../../src/plugins/data/common'; +import { JobFieldType } from '../../../../../common/types'; +import { SearchQueryLanguage } from '../../types/combined_query'; +import { useDataVisualizerKibana } from '../../../kibana_context'; +import './_index.scss'; +import { createMergedEsQuery } from '../../utils/saved_search_utils'; interface Props { indexPattern: IndexPattern; searchString: Query['query']; @@ -38,12 +39,15 @@ interface Props { searchQuery, searchString, queryLanguage, + filters, }: { searchQuery: Query['query']; searchString: Query['query']; queryLanguage: SearchQueryLanguage; + filters: Filter[]; }): void; showEmptyFields: boolean; + onAddFilter?: (field: IndexPatternField | string, value: string, type: '+' | '-') => void; } export const SearchPanel: FC = ({ @@ -61,98 +65,109 @@ export const SearchPanel: FC = ({ setSearchParams, showEmptyFields, }) => { + const { + services: { + uiSettings, + notifications: { toasts }, + data: { + query: queryManager, + ui: { SearchBar }, + }, + }, + } = useDataVisualizerKibana(); // The internal state of the input query bar updated on every key stroke. const [searchInput, setSearchInput] = useState({ query: searchString || '', language: searchQueryLanguage, }); - const [errorMessage, setErrorMessage] = useState(undefined); useEffect(() => { setSearchInput({ query: searchString || '', language: searchQueryLanguage, }); - }, [searchQueryLanguage, searchString]); + }, [searchQueryLanguage, searchString, queryManager.filterManager]); - const searchHandler = (query: Query) => { - let filterQuery; + const searchHandler = ({ query, filters }: { query?: Query; filters?: Filter[] }) => { + const mergedQuery = query ?? searchInput; + const mergedFilters = filters ?? queryManager.filterManager.getFilters(); try { - if (query.language === SEARCH_QUERY_LANGUAGE.KUERY) { - filterQuery = toElasticsearchQuery(fromKueryExpression(query.query), indexPattern); - } else if (query.language === SEARCH_QUERY_LANGUAGE.LUCENE) { - filterQuery = luceneStringToDsl(query.query); - } else { - filterQuery = {}; + if (mergedFilters) { + queryManager.filterManager.setFilters(mergedFilters); } + + const combinedQuery = createMergedEsQuery( + mergedQuery, + queryManager.filterManager.getFilters() ?? [], + indexPattern, + uiSettings + ); + setSearchParams({ - searchQuery: filterQuery, - searchString: query.query, - queryLanguage: query.language as SearchQueryLanguage, + searchQuery: combinedQuery, + searchString: mergedQuery.query, + queryLanguage: mergedQuery.language as SearchQueryLanguage, + filters: mergedFilters, }); } catch (e) { console.log('Invalid syntax', JSON.stringify(e, null, 2)); // eslint-disable-line no-console - setErrorMessage({ query: query.query as string, message: e.message }); + toasts.addError(e, { + title: i18n.translate('xpack.dataVisualizer.searchPanel.invalidSyntax', { + defaultMessage: 'Invalid syntax', + }), + }); } }; - const searchChangeHandler = (query: Query) => setSearchInput(query); return ( - - - setErrorMessage(undefined)} - input={ - + + + + searchHandler({ query: params.query }) } - isOpen={errorMessage?.query === searchInput.query && errorMessage?.message !== ''} - > - - {i18n.translate( - 'xpack.dataVisualizer.searchPanel.invalidKuerySyntaxErrorMessageQueryBar', - { - defaultMessage: 'Invalid query', - } - )} - {': '} - {errorMessage?.message.split('\n')[0]} - - + // @ts-expect-error onFiltersUpdated is a valid prop on SearchBar + onFiltersUpdated={(filters: Filter[]) => searchHandler({ filters })} + indexPatterns={[indexPattern]} + placeholder={i18n.translate('xpack.dataVisualizer.searchPanel.queryBarPlaceholderText', { + defaultMessage: 'Search… (e.g. status:200 AND extension:"PHP")', + })} + displayStyle={'inPage'} + isClearable={true} + customSubmitButton={
} + /> - + + + + - - ); }; diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/index_data_visualizer.tsx b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/index_data_visualizer.tsx index f9748da51a22d..c3f3d744a3978 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/index_data_visualizer.tsx +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/index_data_visualizer.tsx @@ -49,6 +49,7 @@ export const DataVisualizerUrlStateContextProvider: FC( undefined @@ -56,7 +57,6 @@ export const DataVisualizerUrlStateContextProvider: FC | null>( null ); - const { search: searchString } = useLocation(); useEffect(() => { const prevSearchString = searchString; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_settings.tsx b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/locator/index.ts similarity index 72% rename from x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_settings.tsx rename to x-pack/plugins/data_visualizer/public/application/index_data_visualizer/locator/index.ts index 4bff7f3b2ef5e..fb3e0100bbf75 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_settings.tsx +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/locator/index.ts @@ -5,8 +5,4 @@ * 2.0. */ -import React from 'react'; - -export const CurationsSettings: React.FC = () => { - return null; -}; +export * from './locator'; diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/locator/locator.test.ts b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/locator/locator.test.ts new file mode 100644 index 0000000000000..c8762aa79bbd5 --- /dev/null +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/locator/locator.test.ts @@ -0,0 +1,105 @@ +/* + * Copyright 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 { IndexDataVisualizerLocatorDefinition } from './locator'; + +describe('Index data visualizer locator', () => { + const definition = new IndexDataVisualizerLocatorDefinition(); + + it('should generate valid URL for the Index Data Visualizer Viewer page with global settings', async () => { + const location = await definition.getLocation({ + indexPatternId: '3da93760-e0af-11ea-9ad3-3bcfc330e42a', + timeRange: { + from: 'now-30m', + to: 'now', + }, + refreshInterval: { pause: false, value: 300 }, + }); + + expect(location).toMatchObject({ + app: 'ml', + path: '/jobs/new_job/datavisualizer?index=3da93760-e0af-11ea-9ad3-3bcfc330e42a&_a=(DATA_VISUALIZER_INDEX_VIEWER:())&_g=(refreshInterval:(pause:!f,value:300),time:(from:now-30m,to:now))', + state: {}, + }); + }); + + it('should prioritize savedSearchId even when index pattern id is available', async () => { + const location = await definition.getLocation({ + indexPatternId: '3da93760-e0af-11ea-9ad3-3bcfc330e42a', + savedSearchId: '45014020-dffa-11eb-b120-a105fbbe93b3', + }); + + expect(location).toMatchObject({ + app: 'ml', + path: '/jobs/new_job/datavisualizer?savedSearchId=45014020-dffa-11eb-b120-a105fbbe93b3&_a=(DATA_VISUALIZER_INDEX_VIEWER:())&_g=()', + state: {}, + }); + }); + + it('should generate valid URL with field names and field types', async () => { + const location = await definition.getLocation({ + indexPatternId: '3da93760-e0af-11ea-9ad3-3bcfc330e42a', + visibleFieldNames: ['@timestamp', 'responsetime'], + visibleFieldTypes: ['number'], + }); + + expect(location).toMatchObject({ + app: 'ml', + path: "/jobs/new_job/datavisualizer?index=3da93760-e0af-11ea-9ad3-3bcfc330e42a&_a=(DATA_VISUALIZER_INDEX_VIEWER:(visibleFieldNames:!('@timestamp',responsetime),visibleFieldTypes:!(number)))&_g=()", + }); + }); + + it('should generate valid URL with KQL query', async () => { + const location = await definition.getLocation({ + indexPatternId: '3da93760-e0af-11ea-9ad3-3bcfc330e42a', + query: { + searchQuery: { + bool: { + should: [ + { + match: { + region: 'ap-northwest-1', + }, + }, + ], + minimum_should_match: 1, + }, + }, + searchString: 'region : ap-northwest-1', + searchQueryLanguage: 'kuery', + }, + }); + + expect(location).toMatchObject({ + app: 'ml', + path: "/jobs/new_job/datavisualizer?index=3da93760-e0af-11ea-9ad3-3bcfc330e42a&_a=(DATA_VISUALIZER_INDEX_VIEWER:(searchQuery:(bool:(minimum_should_match:1,should:!((match:(region:ap-northwest-1))))),searchQueryLanguage:kuery,searchString:'region : ap-northwest-1'))&_g=()", + state: {}, + }); + }); + + it('should generate valid URL with Lucene query', async () => { + const location = await definition.getLocation({ + indexPatternId: '3da93760-e0af-11ea-9ad3-3bcfc330e42a', + query: { + searchQuery: { + query_string: { + query: 'region: ap-northwest-1', + analyze_wildcard: true, + }, + }, + searchString: 'region : ap-northwest-1', + searchQueryLanguage: 'lucene', + }, + }); + + expect(location).toMatchObject({ + app: 'ml', + path: "/jobs/new_job/datavisualizer?index=3da93760-e0af-11ea-9ad3-3bcfc330e42a&_a=(DATA_VISUALIZER_INDEX_VIEWER:(searchQuery:(query_string:(analyze_wildcard:!t,query:'region: ap-northwest-1')),searchQueryLanguage:lucene,searchString:'region : ap-northwest-1'))&_g=()", + state: {}, + }); + }); +}); diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/locator/locator.ts b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/locator/locator.ts new file mode 100644 index 0000000000000..c26a668bd04ab --- /dev/null +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/locator/locator.ts @@ -0,0 +1,131 @@ +/* + * Copyright 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. + */ + +// @ts-ignore +import { encode } from 'rison-node'; +import { stringify } from 'query-string'; +import { SerializableRecord } from '@kbn/utility-types'; +import { RefreshInterval, TimeRange } from '../../../../../../../src/plugins/data/common'; +import { LocatorDefinition, LocatorPublic } from '../../../../../../../src/plugins/share/common'; +import { QueryState } from '../../../../../../../src/plugins/data/public'; +import { Dictionary, isRisonSerializationRequired } from '../../common/util/url_state'; +import { SearchQueryLanguage } from '../types/combined_query'; + +export const DATA_VISUALIZER_APP_LOCATOR = 'DATA_VISUALIZER_APP_LOCATOR'; + +export interface IndexDataVisualizerLocatorParams extends SerializableRecord { + /** + * Optionally set saved search ID. + */ + savedSearchId?: string; + + /** + * Optionally set index pattern ID. + */ + indexPatternId?: string; + + /** + * Optionally set the time range in the time picker. + */ + timeRange?: TimeRange; + + /** + * Optionally set the refresh interval. + */ + refreshInterval?: RefreshInterval & SerializableRecord; + + /** + * Optionally set a query. + */ + query?: { + searchQuery: SerializableRecord; + searchString: string | SerializableRecord; + searchQueryLanguage: SearchQueryLanguage; + }; + + /** + * If not given, will use the uiSettings configuration for `storeInSessionStorage`. useHash determines + * whether to hash the data in the url to avoid url length issues. + */ + useHash?: boolean; + /** + * Optionally set visible field names. + */ + visibleFieldNames?: string[]; + /** + * Optionally set visible field types. + */ + visibleFieldTypes?: string[]; +} + +export type IndexDataVisualizerLocator = LocatorPublic; + +export class IndexDataVisualizerLocatorDefinition + implements LocatorDefinition +{ + public readonly id = DATA_VISUALIZER_APP_LOCATOR; + + constructor() {} + + public readonly getLocation = async (params: IndexDataVisualizerLocatorParams) => { + const { + indexPatternId, + query, + refreshInterval, + savedSearchId, + timeRange, + visibleFieldNames, + visibleFieldTypes, + } = params; + + const appState: { + searchQuery?: { [key: string]: any }; + searchQueryLanguage?: string; + searchString?: string | SerializableRecord; + visibleFieldNames?: string[]; + visibleFieldTypes?: string[]; + } = {}; + const queryState: QueryState = {}; + + if (query) { + appState.searchQuery = query.searchQuery; + appState.searchString = query.searchString; + appState.searchQueryLanguage = query.searchQueryLanguage; + } + if (visibleFieldNames) appState.visibleFieldNames = visibleFieldNames; + if (visibleFieldTypes) appState.visibleFieldTypes = visibleFieldTypes; + + if (timeRange) queryState.time = timeRange; + if (refreshInterval) queryState.refreshInterval = refreshInterval; + + const urlState: Dictionary = { + ...(savedSearchId ? { savedSearchId } : { index: indexPatternId }), + _a: { DATA_VISUALIZER_INDEX_VIEWER: appState }, + _g: queryState, + }; + + const parsedQueryString: Dictionary = {}; + Object.keys(urlState).forEach((a) => { + if (isRisonSerializationRequired(a)) { + parsedQueryString[a] = encode(urlState[a]); + } else { + parsedQueryString[a] = urlState[a]; + } + }); + const newLocationSearchString = stringify(parsedQueryString, { + sort: false, + encode: false, + }); + + const path = `/jobs/new_job/datavisualizer?${newLocationSearchString}`; + return { + app: 'ml', + path, + state: {}, + }; + }; +} diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/types/index_data_visualizer_state.ts b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/types/index_data_visualizer_state.ts index 7cd1c2bb3ce09..81c5a9097e0ef 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/types/index_data_visualizer_state.ts +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/types/index_data_visualizer_state.ts @@ -5,6 +5,7 @@ * 2.0. */ +import type { Filter } from '@kbn/es-query'; import { Query } from '../../../../../../../src/plugins/data/common/query'; import { SearchQueryLanguage } from './combined_query'; @@ -25,4 +26,5 @@ export interface DataVisualizerIndexBasedAppState extends Omit50","language":"lucene"},"filter":[{"meta":{"index":"90a978e0-1c80-11ec-b1d7-f7e5cf21b9e0","negate":false,"disabled":false,"alias":null,"type":"phrase","key":"airline","value":"ASA","params":{"query":"ASA","type":"phrase"}},"query":{"match":{"airline":{"query":"ASA","type":"phrase"}}},"$state":{"store":"appState"}}],"indexRefName":"kibanaSavedObjectMeta.searchSourceJSON.index"}', + }, + }, + id: '93fc4d60-1c80-11ec-b1d7-f7e5cf21b9e0', + type: 'search', +}; + +// @ts-expect-error We don't need the full object here +const luceneInvalidSavedSearchObj: SavedSearchSavedObject = { + attributes: { + kibanaSavedObjectMeta: { + searchSourceJSON: null, + }, + }, + id: '93fc4d60-1c80-11ec-b1d7-f7e5cf21b9e0', + type: 'search', +}; + +const kqlSavedSearch: SavedSearch = { + title: 'farequote_filter_and_kuery', + description: '', + columns: ['_source'], + // @ts-expect-error We don't need the full object here + kibanaSavedObjectMeta: { + searchSourceJSON: + '{"highlightAll":true,"version":true,"query":{"query":"responsetime > 49","language":"kuery"},"filter":[{"meta":{"index":"90a978e0-1c80-11ec-b1d7-f7e5cf21b9e0","negate":false,"disabled":false,"alias":null,"type":"phrase","key":"airline","value":"ASA","params":{"query":"ASA","type":"phrase"}},"query":{"match":{"airline":{"query":"ASA","type":"phrase"}}},"$state":{"store":"appState"}}],"indexRefName":"kibanaSavedObjectMeta.searchSourceJSON.index"}', + }, +}; + +describe('getQueryFromSavedSearch()', () => { + it('should return parsed searchSourceJSON with query and filter', () => { + expect(getQueryFromSavedSearch(luceneSavedSearchObj)).toEqual({ + filter: [ + { + $state: { store: 'appState' }, + meta: { + alias: null, + disabled: false, + index: '90a978e0-1c80-11ec-b1d7-f7e5cf21b9e0', + key: 'airline', + negate: false, + params: { query: 'ASA', type: 'phrase' }, + type: 'phrase', + value: 'ASA', + }, + query: { match: { airline: { query: 'ASA', type: 'phrase' } } }, + }, + ], + highlightAll: true, + indexRefName: 'kibanaSavedObjectMeta.searchSourceJSON.index', + query: { language: 'lucene', query: 'responsetime:>50' }, + version: true, + }); + expect(getQueryFromSavedSearch(kqlSavedSearch)).toEqual({ + filter: [ + { + $state: { store: 'appState' }, + meta: { + alias: null, + disabled: false, + index: '90a978e0-1c80-11ec-b1d7-f7e5cf21b9e0', + key: 'airline', + negate: false, + params: { query: 'ASA', type: 'phrase' }, + type: 'phrase', + value: 'ASA', + }, + query: { match: { airline: { query: 'ASA', type: 'phrase' } } }, + }, + ], + highlightAll: true, + indexRefName: 'kibanaSavedObjectMeta.searchSourceJSON.index', + query: { language: 'kuery', query: 'responsetime > 49' }, + version: true, + }); + }); + it('should return undefined if invalid searchSourceJSON', () => { + expect(getQueryFromSavedSearch(luceneInvalidSavedSearchObj)).toEqual(undefined); + }); +}); + +describe('createMergedEsQuery()', () => { + const luceneQuery = { + query: 'responsetime:>50', + language: 'lucene', + }; + const kqlQuery = { + query: 'responsetime > 49', + language: 'kuery', + }; + const mockFilters: Filter[] = [ + { + meta: { + index: '90a978e0-1c80-11ec-b1d7-f7e5cf21b9e0', + negate: false, + disabled: false, + alias: null, + type: 'phrase', + key: 'airline', + params: { + query: 'ASA', + }, + }, + query: { + match: { + airline: { + query: 'ASA', + type: 'phrase', + }, + }, + }, + $state: { + store: 'appState' as FilterStateStore, + }, + }, + ]; + + it('return formatted ES bool query with both the original query and filters combined', () => { + expect(createMergedEsQuery(luceneQuery, mockFilters)).toEqual({ + bool: { + filter: [{ match_phrase: { airline: { query: 'ASA' } } }], + must: [{ query_string: { query: 'responsetime:>50' } }], + must_not: [], + should: [], + }, + }); + expect(createMergedEsQuery(kqlQuery, mockFilters)).toEqual({ + bool: { + filter: [{ match_phrase: { airline: { query: 'ASA' } } }], + minimum_should_match: 1, + must_not: [], + should: [{ range: { responsetime: { gt: '49' } } }], + }, + }); + }); + it('return formatted ES bool query without filters ', () => { + expect(createMergedEsQuery(luceneQuery)).toEqual({ + bool: { + filter: [], + must: [{ query_string: { query: 'responsetime:>50' } }], + must_not: [], + should: [], + }, + }); + expect(createMergedEsQuery(kqlQuery)).toEqual({ + bool: { + filter: [], + minimum_should_match: 1, + must_not: [], + should: [{ range: { responsetime: { gt: '49' } } }], + }, + }); + }); +}); + +describe('getEsQueryFromSavedSearch()', () => { + it('return undefined if saved search is not provided', () => { + expect( + getEsQueryFromSavedSearch({ + indexPattern: mockDataView, + savedSearch: undefined, + uiSettings: mockUiSettings, + }) + ).toEqual(undefined); + }); + it('return search data from saved search if neither query nor filter is provided ', () => { + expect( + getEsQueryFromSavedSearch({ + indexPattern: mockDataView, + savedSearch: luceneSavedSearchObj, + uiSettings: mockUiSettings, + }) + ).toEqual({ + queryLanguage: 'lucene', + searchQuery: { + bool: { + filter: [{ match_phrase: { airline: { query: 'ASA' } } }], + must: [{ query_string: { query: 'responsetime:>50' } }], + must_not: [], + should: [], + }, + }, + searchString: 'responsetime:>50', + }); + }); + it('should override original saved search with the provided query ', () => { + expect( + getEsQueryFromSavedSearch({ + indexPattern: mockDataView, + savedSearch: luceneSavedSearchObj, + uiSettings: mockUiSettings, + query: { + query: 'responsetime:>100', + language: 'lucene', + }, + }) + ).toEqual({ + queryLanguage: 'lucene', + searchQuery: { + bool: { + filter: [{ match_phrase: { airline: { query: 'ASA' } } }], + must: [{ query_string: { query: 'responsetime:>100' } }], + must_not: [], + should: [], + }, + }, + searchString: 'responsetime:>100', + }); + }); + + it('should override original saved search with the provided filters ', () => { + expect( + getEsQueryFromSavedSearch({ + indexPattern: mockDataView, + savedSearch: luceneSavedSearchObj, + uiSettings: mockUiSettings, + query: { + query: 'responsetime:>100', + language: 'lucene', + }, + filters: [ + { + meta: { + index: '90a978e0-1c80-11ec-b1d7-f7e5cf21b9e0', + alias: null, + negate: true, + disabled: false, + type: 'phrase', + key: 'airline', + params: { + query: 'JZA', + }, + }, + query: { + match_phrase: { + airline: 'JZA', + }, + }, + $state: { + store: 'appState' as FilterStateStore, + }, + }, + ], + }) + ).toEqual({ + queryLanguage: 'lucene', + searchQuery: { + bool: { + filter: [], + must: [{ query_string: { query: 'responsetime:>100' } }], + must_not: [{ match_phrase: { airline: 'JZA' } }], + should: [], + }, + }, + searchString: 'responsetime:>100', + }); + }); +}); diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/utils/saved_search_utils.ts b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/utils/saved_search_utils.ts index cb80e491fc7e5..80a2069aab1a8 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/utils/saved_search_utils.ts +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/utils/saved_search_utils.ts @@ -8,55 +8,155 @@ import { cloneDeep } from 'lodash'; import { IUiSettingsClient } from 'kibana/public'; import { - buildEsQuery, - buildQueryFromFilters, - decorateQuery, fromKueryExpression, - luceneStringToDsl, toElasticsearchQuery, + buildQueryFromFilters, + buildEsQuery, + Query, + Filter, } from '@kbn/es-query'; -import { estypes } from '@elastic/elasticsearch'; -import { SavedSearchSavedObject } from '../../../../common/types'; +import { isSavedSearchSavedObject, SavedSearchSavedObject } from '../../../../common/types'; import { IndexPattern } from '../../../../../../../src/plugins/data/common'; import { SEARCH_QUERY_LANGUAGE, SearchQueryLanguage } from '../types/combined_query'; -import { getEsQueryConfig, Query } from '../../../../../../../src/plugins/data/public'; - -export function getQueryFromSavedSearch(savedSearch: SavedSearchSavedObject) { - const search = savedSearch.attributes.kibanaSavedObjectMeta as { searchSourceJSON: string }; - return JSON.parse(search.searchSourceJSON) as { - query: Query; - filter: any[]; - }; +import { SavedSearch } from '../../../../../../../src/plugins/discover/public'; +import { getEsQueryConfig } from '../../../../../../../src/plugins/data/common'; +import { FilterManager } from '../../../../../../../src/plugins/data/public'; + +/** + * Parse the stringified searchSourceJSON + * from a saved search or saved search object + */ +export function getQueryFromSavedSearch(savedSearch: SavedSearchSavedObject | SavedSearch) { + const search = isSavedSearchSavedObject(savedSearch) + ? savedSearch?.attributes?.kibanaSavedObjectMeta + : // @ts-expect-error kibanaSavedObjectMeta does exist + savedSearch?.kibanaSavedObjectMeta; + + const parsed = + typeof search?.searchSourceJSON === 'string' + ? (JSON.parse(search.searchSourceJSON) as { + query: Query; + filter: Filter[]; + }) + : undefined; + + // Remove indexRefName because saved search might no longer be relevant + // if user modifies the query or filter + // after opening a saved search + if (parsed && Array.isArray(parsed.filter)) { + parsed.filter.forEach((f) => { + // @ts-expect-error indexRefName does appear in meta for newly created saved search + f.meta.indexRefName = undefined; + }); + } + return parsed; } /** - * Extract query data from the saved search object. + * Create an Elasticsearch query that combines both lucene/kql query string and filters + * Should also form a valid query if only the query or filters is provided */ -export function extractSearchData( - savedSearch: SavedSearchSavedObject | null, - currentIndexPattern: IndexPattern, - queryStringOptions: Record | string +export function createMergedEsQuery( + query?: Query, + filters?: Filter[], + indexPattern?: IndexPattern, + uiSettings?: IUiSettingsClient ) { - if (!savedSearch) { - return undefined; - } + let combinedQuery: any = getDefaultQuery(); + + if (query && query.language === SEARCH_QUERY_LANGUAGE.KUERY) { + const ast = fromKueryExpression(query.query); + if (query.query !== '') { + combinedQuery = toElasticsearchQuery(ast, indexPattern); + } + const filterQuery = buildQueryFromFilters(filters, indexPattern); + + if (Array.isArray(combinedQuery.bool.filter) === false) { + combinedQuery.bool.filter = + combinedQuery.bool.filter === undefined ? [] : [combinedQuery.bool.filter]; + } + + if (Array.isArray(combinedQuery.bool.must_not) === false) { + combinedQuery.bool.must_not = + combinedQuery.bool.must_not === undefined ? [] : [combinedQuery.bool.must_not]; + } - const { query: extractedQuery } = getQueryFromSavedSearch(savedSearch); - const queryLanguage = extractedQuery.language as SearchQueryLanguage; - const qryString = extractedQuery.query; - let qry; - if (queryLanguage === SEARCH_QUERY_LANGUAGE.KUERY) { - const ast = fromKueryExpression(qryString); - qry = toElasticsearchQuery(ast, currentIndexPattern); + combinedQuery.bool.filter = [...combinedQuery.bool.filter, ...filterQuery.filter]; + combinedQuery.bool.must_not = [...combinedQuery.bool.must_not, ...filterQuery.must_not]; } else { - qry = luceneStringToDsl(qryString); - decorateQuery(qry, queryStringOptions); + combinedQuery = buildEsQuery( + indexPattern, + query ? [query] : [], + filters ? filters : [], + uiSettings ? getEsQueryConfig(uiSettings) : undefined + ); + } + return combinedQuery; +} + +/** + * Extract query data from the saved search object + * with overrides from the provided query data and/or filters + */ +export function getEsQueryFromSavedSearch({ + indexPattern, + uiSettings, + savedSearch, + query, + filters, + filterManager, +}: { + indexPattern: IndexPattern; + uiSettings: IUiSettingsClient; + savedSearch: SavedSearchSavedObject | SavedSearch | null | undefined; + query?: Query; + filters?: Filter[]; + filterManager?: FilterManager; +}) { + if (!indexPattern || !savedSearch) return; + + const savedSearchData = getQueryFromSavedSearch(savedSearch); + const userQuery = query; + const userFilters = filters; + + // If no saved search available, use user's query and filters + if (!savedSearchData && userQuery) { + if (filterManager && userFilters) filterManager.setFilters(userFilters); + + const combinedQuery = createMergedEsQuery( + userQuery, + Array.isArray(userFilters) ? userFilters : [], + indexPattern, + uiSettings + ); + + return { + searchQuery: combinedQuery, + searchString: userQuery.query, + queryLanguage: userQuery.language as SearchQueryLanguage, + }; + } + + // If saved search available, merge saved search with latest user query or filters differ from extracted saved search data + if (savedSearchData) { + const currentQuery = userQuery ?? savedSearchData?.query; + const currentFilters = userFilters ?? savedSearchData?.filter; + + if (filterManager) filterManager.setFilters(currentFilters); + + const combinedQuery = createMergedEsQuery( + currentQuery, + Array.isArray(currentFilters) ? currentFilters : [], + indexPattern, + uiSettings + ); + + return { + searchQuery: combinedQuery, + searchString: currentQuery.query, + queryLanguage: currentQuery.language as SearchQueryLanguage, + }; } - return { - searchQuery: qry, - searchString: qryString, - queryLanguage, - }; } const DEFAULT_QUERY = { @@ -69,64 +169,6 @@ const DEFAULT_QUERY = { }, }; -export function getDefaultDatafeedQuery() { +export function getDefaultQuery() { return cloneDeep(DEFAULT_QUERY); } - -export function createSearchItems( - kibanaConfig: IUiSettingsClient, - indexPattern: IndexPattern | undefined, - savedSearch: SavedSearchSavedObject | null -) { - // query is only used by the data visualizer as it needs - // a lucene query_string. - // Using a blank query will cause match_all:{} to be used - // when passed through luceneStringToDsl - let query: Query = { - query: '', - language: 'lucene', - }; - - let combinedQuery: estypes.QueryDslQueryContainer = getDefaultDatafeedQuery(); - if (savedSearch !== null) { - const data = getQueryFromSavedSearch(savedSearch); - - query = data.query; - const filter = data.filter; - - const filters = Array.isArray(filter) ? filter : []; - - if (query.language === SEARCH_QUERY_LANGUAGE.KUERY) { - const ast = fromKueryExpression(query.query); - if (query.query !== '') { - combinedQuery = toElasticsearchQuery(ast, indexPattern); - } - const filterQuery = buildQueryFromFilters(filters, indexPattern); - - if (!combinedQuery.bool) { - throw new Error('Missing bool on query'); - } - - if (!Array.isArray(combinedQuery.bool.filter)) { - combinedQuery.bool.filter = - combinedQuery.bool.filter === undefined ? [] : [combinedQuery.bool.filter]; - } - - if (!Array.isArray(combinedQuery.bool.must_not)) { - combinedQuery.bool.must_not = - combinedQuery.bool.must_not === undefined ? [] : [combinedQuery.bool.must_not]; - } - - combinedQuery.bool.filter = [...combinedQuery.bool.filter, ...filterQuery.filter]; - combinedQuery.bool.must_not = [...combinedQuery.bool.must_not, ...filterQuery.must_not]; - } else { - const esQueryConfigs = getEsQueryConfig(kibanaConfig); - combinedQuery = buildEsQuery(indexPattern, [query], filters, esQueryConfigs); - } - } - - return { - query, - combinedQuery, - }; -} diff --git a/x-pack/plugins/data_visualizer/public/plugin.ts b/x-pack/plugins/data_visualizer/public/plugin.ts index 54f27a2e9d72e..112294f4b246f 100644 --- a/x-pack/plugins/data_visualizer/public/plugin.ts +++ b/x-pack/plugins/data_visualizer/public/plugin.ts @@ -48,7 +48,10 @@ export class DataVisualizerPlugin DataVisualizerStartDependencies > { - public setup(core: CoreSetup, plugins: DataVisualizerSetupDependencies) { + public setup( + core: CoreSetup, + plugins: DataVisualizerSetupDependencies + ) { if (plugins.home) { registerHomeAddData(plugins.home); registerHomeFeatureCatalogue(plugins.home); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/automated_icon.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/automated_icon.test.tsx new file mode 100644 index 0000000000000..c9be6e609f37c --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/automated_icon.test.tsx @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { AutomatedIcon } from './automated_icon'; + +describe('AutomatedIcon', () => { + it('renders', () => { + expect(shallow().is('svg')).toBe(true); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/automated_icon.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/automated_icon.tsx new file mode 100644 index 0000000000000..d50cf101e6059 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/automated_icon.tsx @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +export const AutomatedIcon: React.FC = ({ ...props }) => ( + +); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_settings.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_settings.test.tsx deleted file mode 100644 index 855570829cce4..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_settings.test.tsx +++ /dev/null @@ -1,27 +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 '../../../../__mocks__/react_router'; -import '../../../__mocks__/engine_logic.mock'; - -import React from 'react'; - -import { shallow } from 'enzyme'; - -import { CurationsSettings } from './curations_settings'; - -describe('CurationsSettings', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('renders empty', () => { - const wrapper = shallow(); - - expect(wrapper.isEmptyRender()).toBe(true); - }); -}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_settings/curations_settings.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_settings/curations_settings.test.tsx new file mode 100644 index 0000000000000..4b4e11c31d4b8 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_settings/curations_settings.test.tsx @@ -0,0 +1,233 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import '../../../../../__mocks__/shallow_useeffect.mock'; +import '../../../../../__mocks__/react_router'; +import '../../../../__mocks__/engine_logic.mock'; + +import { setMockActions, setMockValues } from '../../../../../__mocks__/kea_logic'; + +import React from 'react'; + +import { shallow, ShallowWrapper } from 'enzyme'; + +import { EuiButtonEmpty, EuiCallOut, EuiSwitch } from '@elastic/eui'; + +import { mountWithIntl } from '@kbn/test/jest'; + +import { Loading } from '../../../../../shared/loading'; +import { EuiButtonTo } from '../../../../../shared/react_router_helpers'; +import { DataPanel } from '../../../data_panel'; +import { LogRetentionOptions } from '../../../log_retention'; + +import { CurationsSettings } from '.'; + +const MOCK_VALUES = { + // CurationsSettingsLogic + dataLoading: false, + curationsSettings: { + enabled: true, + mode: 'automatic', + }, + // LogRetentionLogic + isLogRetentionUpdating: false, + logRetention: { + [LogRetentionOptions.Analytics]: { + enabled: true, + }, + }, + // LicensingLogic + hasPlatinumLicense: true, +}; + +const MOCK_ACTIONS = { + // CurationsSettingsLogic + loadCurationsSettings: jest.fn(), + onSkipLoadingCurationsSettings: jest.fn(), + toggleCurationsEnabled: jest.fn(), + toggleCurationsMode: jest.fn(), + // LogRetentionLogic + fetchLogRetention: jest.fn(), +}; + +describe('CurationsSettings', () => { + beforeEach(() => { + jest.clearAllMocks(); + setMockActions(MOCK_ACTIONS); + }); + + it('loads curations and log retention settings on load', () => { + setMockValues(MOCK_VALUES); + mountWithIntl(); + + expect(MOCK_ACTIONS.loadCurationsSettings).toHaveBeenCalled(); + expect(MOCK_ACTIONS.fetchLogRetention).toHaveBeenCalled(); + }); + + it('contains a switch to toggle curations settings', () => { + let wrapper: ShallowWrapper; + + setMockValues({ + ...MOCK_VALUES, + curationsSettings: { ...MOCK_VALUES.curationsSettings, enabled: true }, + }); + wrapper = shallow(); + + expect(wrapper.find(EuiSwitch).at(0).prop('checked')).toBe(true); + + setMockValues({ + ...MOCK_VALUES, + curationsSettings: { ...MOCK_VALUES.curationsSettings, enabled: false }, + }); + wrapper = shallow(); + + expect(wrapper.find(EuiSwitch).at(0).prop('checked')).toBe(false); + + wrapper.find(EuiSwitch).at(0).simulate('change'); + expect(MOCK_ACTIONS.toggleCurationsEnabled).toHaveBeenCalled(); + }); + + it('contains a switch to toggle the curations mode', () => { + let wrapper: ShallowWrapper; + + setMockValues({ + ...MOCK_VALUES, + curationsSettings: { ...MOCK_VALUES.curationsSettings, mode: 'automatic' }, + }); + wrapper = shallow(); + + expect(wrapper.find(EuiSwitch).at(1).prop('checked')).toBe(true); + + setMockValues({ + ...MOCK_VALUES, + curationsSettings: { ...MOCK_VALUES.curationsSettings, mode: 'manual' }, + }); + wrapper = shallow(); + + expect(wrapper.find(EuiSwitch).at(1).prop('checked')).toBe(false); + + wrapper.find(EuiSwitch).at(1).simulate('change'); + expect(MOCK_ACTIONS.toggleCurationsMode).toHaveBeenCalled(); + }); + + it('enables form elements and hides the callout when analytics retention is enabled', () => { + setMockValues({ + ...MOCK_VALUES, + logRetention: { + [LogRetentionOptions.Analytics]: { + enabled: true, + }, + }, + }); + const wrapper = shallow(); + + expect(wrapper.find(EuiSwitch).at(0).prop('disabled')).toBe(false); + expect(wrapper.find(EuiSwitch).at(1).prop('disabled')).toBe(false); + expect(wrapper.find(EuiCallOut)).toHaveLength(0); + }); + + it('display a callout and disables form elements when analytics retention is disabled', () => { + setMockValues({ + ...MOCK_VALUES, + logRetention: { + [LogRetentionOptions.Analytics]: { + enabled: false, + }, + }, + }); + const wrapper = shallow(); + + expect(wrapper.find(EuiSwitch).at(0).prop('disabled')).toBe(true); + expect(wrapper.find(EuiSwitch).at(1).prop('disabled')).toBe(true); + expect(wrapper.find(EuiCallOut).dive().find(EuiButtonTo).prop('to')).toEqual('/settings'); + }); + + it('returns a loading state when curations data is loading', () => { + setMockValues({ + ...MOCK_VALUES, + dataLoading: true, + }); + const wrapper = shallow(); + + expect(wrapper.is(Loading)).toBe(true); + }); + + it('returns a loading state when log retention data is loading', () => { + setMockValues({ + ...MOCK_VALUES, + isLogRetentionUpdating: true, + }); + const wrapper = shallow(); + + expect(wrapper.is(Loading)).toBe(true); + }); + + describe('loading curation settings based on log retention', () => { + it('loads curation settings when log retention is enabled', () => { + setMockValues({ + ...MOCK_VALUES, + logRetention: { + [LogRetentionOptions.Analytics]: { + enabled: true, + }, + }, + }); + + shallow(); + + expect(MOCK_ACTIONS.loadCurationsSettings).toHaveBeenCalledTimes(1); + }); + + it('skips loading curation settings when log retention is enabled', () => { + setMockValues({ + ...MOCK_VALUES, + logRetention: { + [LogRetentionOptions.Analytics]: { + enabled: false, + }, + }, + }); + + shallow(); + + expect(MOCK_ACTIONS.onSkipLoadingCurationsSettings).toHaveBeenCalledTimes(1); + }); + + it('takes no action if log retention has not yet been loaded', () => { + setMockValues({ + ...MOCK_VALUES, + logRetention: null, + }); + + shallow(); + + expect(MOCK_ACTIONS.loadCurationsSettings).toHaveBeenCalledTimes(0); + expect(MOCK_ACTIONS.onSkipLoadingCurationsSettings).toHaveBeenCalledTimes(0); + }); + }); + + describe('when the user has no platinum license', () => { + beforeEach(() => { + setMockValues({ + ...MOCK_VALUES, + hasPlatinumLicense: false, + }); + }); + + it('it does not fetch log retention', () => { + shallow(); + expect(MOCK_ACTIONS.fetchLogRetention).toHaveBeenCalledTimes(0); + }); + + it('shows a CTA to upgrade your license when the user when the user', () => { + const wrapper = shallow(); + expect(wrapper.is(DataPanel)).toBe(true); + expect(wrapper.prop('action').props.to).toEqual('/app/management/stack/license_management'); + expect(wrapper.find(EuiButtonEmpty).prop('href')).toEqual('/license-management.html'); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_settings/curations_settings.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_settings/curations_settings.tsx new file mode 100644 index 0000000000000..de669298b11d9 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_settings/curations_settings.tsx @@ -0,0 +1,216 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useEffect } from 'react'; + +import { useActions, useValues } from 'kea'; + +import { + EuiButtonEmpty, + EuiCallOut, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiSpacer, + EuiSwitch, + EuiText, + EuiTitle, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { FormattedMessage } from '@kbn/i18n/react'; + +import { docLinks } from '../../../../../shared/doc_links'; +import { LicensingLogic } from '../../../../../shared/licensing'; +import { Loading } from '../../../../../shared/loading'; +import { EuiButtonTo } from '../../../../../shared/react_router_helpers'; +import { SETTINGS_PATH } from '../../../../routes'; +import { DataPanel } from '../../../data_panel'; +import { LogRetentionLogic, LogRetentionOptions } from '../../../log_retention'; + +import { AutomatedIcon } from '../../components/automated_icon'; + +import { CurationsSettingsLogic } from './curations_settings_logic'; + +export const CurationsSettings: React.FC = () => { + const { hasPlatinumLicense } = useValues(LicensingLogic); + + const { + curationsSettings: { enabled, mode }, + dataLoading, + } = useValues(CurationsSettingsLogic); + const { + loadCurationsSettings, + onSkipLoadingCurationsSettings, + toggleCurationsEnabled, + toggleCurationsMode, + } = useActions(CurationsSettingsLogic); + + const { isLogRetentionUpdating, logRetention } = useValues(LogRetentionLogic); + const { fetchLogRetention } = useActions(LogRetentionLogic); + + const analyticsDisabled = !logRetention?.[LogRetentionOptions.Analytics].enabled; + + useEffect(() => { + if (hasPlatinumLicense) { + fetchLogRetention(); + } + }, [hasPlatinumLicense]); + + useEffect(() => { + if (logRetention) { + if (!analyticsDisabled) { + loadCurationsSettings(); + } else { + onSkipLoadingCurationsSettings(); + } + } + }, [logRetention]); + + if (!hasPlatinumLicense) + return ( + + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.curations.settings.licenseUpgradeCTATitle', + { + defaultMessage: 'Introducing automated curations', + } + )} + + } + subtitle={ + + {i18n.translate('xpack.enterpriseSearch.appSearch.curations.settings.platinum', { + defaultMessage: 'Platinum', + })} + + ), + }} + /> + } + action={ + + {i18n.translate( + 'xpack.enterpriseSearch.curations.settings.start30DayTrialButtonLabel', + { + defaultMessage: 'Start a 30-day trial', + } + )} + + } + > + + {i18n.translate('xpack.enterpriseSearch.curations.settings.licenseUpgradeLink', { + defaultMessage: 'Learn more about license upgrades', + })} + + + ); + if (dataLoading || isLogRetentionUpdating) return ; + + return ( + <> + + + + + + +

+ {i18n.translate( + 'xpack.enterpriseSearch.appSearch.curations.settings.automaticCurationsTitle', + { + defaultMessage: 'Automated Curations', + } + )} +

+
+
+
+ + {analyticsDisabled && ( + <> + +

+ {i18n.translate( + 'xpack.enterpriseSearch.appSearch.curations.settings.analyticsDisabledCalloutDescription', + { + defaultMessage: + 'Automated curations require analytics to be enabled on your account.', + } + )} +

+ + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.curations.settings.manageAnalyticsButtonLabel', + { defaultMessage: 'Manage analytics' } + )} + +
+ + + )} + + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.curations.settings.automaticCurationsDescription', + { + defaultMessage: + "Suggested curations will monitor your engine's analytics and make automatic suggestions to help you deliver the most relevant results. Each suggested curation can be accepted, rejected, or modified.", + } + )} + + + + + + + + + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_settings/curations_settings_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_settings/curations_settings_logic.test.ts new file mode 100644 index 0000000000000..818fac3d0706e --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_settings/curations_settings_logic.test.ts @@ -0,0 +1,221 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + LogicMounter, + mockHttpValues, + mockFlashMessageHelpers, +} from '../../../../../__mocks__/kea_logic'; +import '../../../../__mocks__/engine_logic.mock'; + +import { nextTick } from '@kbn/test/jest'; + +import { CurationsSettingsLogic } from './curations_settings_logic'; + +const DEFAULT_VALUES = { + dataLoading: true, + curationsSettings: { + enabled: false, + mode: 'manual', + }, +}; + +describe('CurationsSettingsLogic', () => { + const { mount } = new LogicMounter(CurationsSettingsLogic); + const { http } = mockHttpValues; + const { flashAPIErrors } = mockFlashMessageHelpers; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('has correct default values', () => { + mount(); + expect(CurationsSettingsLogic.values).toEqual(DEFAULT_VALUES); + }); + + describe('actions', () => { + describe('onCurationsSettingsLoad', () => { + it('saves curation settings and that data has loaded', () => { + mount(); + + CurationsSettingsLogic.actions.onCurationsSettingsLoad({ + enabled: true, + mode: 'automatic', + }); + + expect(CurationsSettingsLogic.values.dataLoading).toEqual(false); + expect(CurationsSettingsLogic.values.curationsSettings).toEqual({ + enabled: true, + mode: 'automatic', + }); + }); + }); + + describe('onSkipCurationsSettingsLoad', () => { + it('saves that data has loaded', () => { + mount(); + + CurationsSettingsLogic.actions.onSkipLoadingCurationsSettings(); + + expect(CurationsSettingsLogic.values.dataLoading).toEqual(false); + }); + }); + }); + + describe('listeners', () => { + describe('loadCurationsSettings', () => { + it('calls the curations settings API and saves the returned settings', async () => { + http.get.mockReturnValueOnce( + Promise.resolve({ + curation: { + enabled: true, + mode: 'automatic', + }, + }) + ); + mount(); + jest.spyOn(CurationsSettingsLogic.actions, 'onCurationsSettingsLoad'); + + CurationsSettingsLogic.actions.loadCurationsSettings(); + await nextTick(); + + expect(http.get).toHaveBeenCalledWith( + '/internal/app_search/engines/some-engine/search_relevance_suggestions/settings' + ); + expect(CurationsSettingsLogic.actions.onCurationsSettingsLoad).toHaveBeenCalledWith({ + enabled: true, + mode: 'automatic', + }); + }); + + it('presents any API errors to the user', async () => { + http.get.mockReturnValueOnce(Promise.reject('error')); + mount(); + + CurationsSettingsLogic.actions.loadCurationsSettings(); + await nextTick(); + + expect(flashAPIErrors).toHaveBeenCalledWith('error'); + }); + }); + + describe('toggleCurationsEnabled', () => { + it('enables curations when they are currently disabled', () => { + mount({ + curationsSettings: { + ...DEFAULT_VALUES.curationsSettings, + enabled: false, + }, + }); + jest.spyOn(CurationsSettingsLogic.actions, 'updateCurationsSetting'); + + CurationsSettingsLogic.actions.toggleCurationsEnabled(); + + expect(CurationsSettingsLogic.actions.updateCurationsSetting).toHaveBeenCalledWith({ + enabled: true, + }); + }); + + it('disables curations when they are currently enabled', () => { + mount({ + curationsSettings: { + ...DEFAULT_VALUES.curationsSettings, + enabled: true, + }, + }); + jest.spyOn(CurationsSettingsLogic.actions, 'updateCurationsSetting'); + + CurationsSettingsLogic.actions.toggleCurationsEnabled(); + + expect(CurationsSettingsLogic.actions.updateCurationsSetting).toHaveBeenCalledWith({ + enabled: false, + mode: 'manual', + }); + }); + }); + + describe('toggleCurationsMode', () => { + it('sets to manual mode when it is currently automatic', () => { + mount({ + curationsSettings: { + ...DEFAULT_VALUES.curationsSettings, + mode: 'automatic', + }, + }); + jest.spyOn(CurationsSettingsLogic.actions, 'updateCurationsSetting'); + + CurationsSettingsLogic.actions.toggleCurationsMode(); + + expect(CurationsSettingsLogic.actions.updateCurationsSetting).toHaveBeenCalledWith({ + mode: 'manual', + }); + }); + + it('sets to automatic mode when it is currently manual', () => { + mount({ + curationsSettings: { + ...DEFAULT_VALUES.curationsSettings, + mode: 'manual', + }, + }); + jest.spyOn(CurationsSettingsLogic.actions, 'updateCurationsSetting'); + + CurationsSettingsLogic.actions.toggleCurationsMode(); + + expect(CurationsSettingsLogic.actions.updateCurationsSetting).toHaveBeenCalledWith({ + mode: 'automatic', + }); + }); + }); + + describe('updateCurationsSetting', () => { + it('calls the curations settings API and saves the returned settings', async () => { + http.put.mockReturnValueOnce( + Promise.resolve({ + curation: { + enabled: true, + mode: 'automatic', + }, + }) + ); + mount(); + jest.spyOn(CurationsSettingsLogic.actions, 'onCurationsSettingsLoad'); + + CurationsSettingsLogic.actions.updateCurationsSetting({ + enabled: true, + }); + await nextTick(); + + expect(http.put).toHaveBeenCalledWith( + '/internal/app_search/engines/some-engine/search_relevance_suggestions/settings', + { + body: JSON.stringify({ + curation: { + enabled: true, + }, + }), + } + ); + expect(CurationsSettingsLogic.actions.onCurationsSettingsLoad).toHaveBeenCalledWith({ + enabled: true, + mode: 'automatic', + }); + }); + + it('presents any API errors to the user', async () => { + http.put.mockReturnValueOnce(Promise.reject('error')); + mount(); + + CurationsSettingsLogic.actions.updateCurationsSetting({}); + await nextTick(); + + expect(flashAPIErrors).toHaveBeenCalledWith('error'); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_settings/curations_settings_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_settings/curations_settings_logic.ts new file mode 100644 index 0000000000000..d79ad64a69788 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_settings/curations_settings_logic.ts @@ -0,0 +1,109 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { kea, MakeLogicType } from 'kea'; + +import { flashAPIErrors } from '../../../../../shared/flash_messages'; +import { HttpLogic } from '../../../../../shared/http'; +import { EngineLogic } from '../../../engine'; + +export interface CurationsSettings { + enabled: boolean; + mode: 'automatic' | 'manual'; +} + +interface CurationsSettingsValues { + dataLoading: boolean; + curationsSettings: CurationsSettings; +} + +interface CurationsSettingsActions { + loadCurationsSettings(): void; + onCurationsSettingsLoad(curationsSettings: CurationsSettings): { + curationsSettings: CurationsSettings; + }; + onSkipLoadingCurationsSettings(): void; + toggleCurationsEnabled(): void; + toggleCurationsMode(): void; + updateCurationsSetting(currationsSetting: Partial): { + currationsSetting: Partial; + }; +} + +export const CurationsSettingsLogic = kea< + MakeLogicType +>({ + path: ['enterprise_search', 'app_search', 'curations', 'curations_settings_logic'], + actions: () => ({ + loadCurationsSettings: true, + onCurationsSettingsLoad: (curationsSettings) => ({ curationsSettings }), + onSkipLoadingCurationsSettings: true, + toggleCurationsEnabled: true, + toggleCurationsMode: true, + updateCurationsSetting: (currationsSetting) => ({ currationsSetting }), + }), + reducers: () => ({ + dataLoading: [ + true, + { + onCurationsSettingsLoad: () => false, + onSkipLoadingCurationsSettings: () => false, + }, + ], + curationsSettings: [ + { + enabled: false, + mode: 'manual', + }, + { + onCurationsSettingsLoad: (_, { curationsSettings }) => curationsSettings, + }, + ], + }), + listeners: ({ actions, values }) => ({ + loadCurationsSettings: async () => { + const { http } = HttpLogic.values; + const { engineName } = EngineLogic.values; + + try { + const response = await http.get( + `/internal/app_search/engines/${engineName}/search_relevance_suggestions/settings` + ); + actions.onCurationsSettingsLoad(response.curation); + } catch (e) { + flashAPIErrors(e); + } + }, + toggleCurationsEnabled: async () => { + if (values.curationsSettings.enabled) { + actions.updateCurationsSetting({ enabled: false, mode: 'manual' }); + } else { + actions.updateCurationsSetting({ enabled: true }); + } + }, + toggleCurationsMode: async () => { + actions.updateCurationsSetting({ + mode: values.curationsSettings.mode === 'automatic' ? 'manual' : 'automatic', + }); + }, + updateCurationsSetting: async ({ currationsSetting }) => { + const { http } = HttpLogic.values; + const { engineName } = EngineLogic.values; + try { + const response = await http.put( + `/internal/app_search/engines/${engineName}/search_relevance_suggestions/settings`, + { + body: JSON.stringify({ curation: currationsSetting }), + } + ); + actions.onCurationsSettingsLoad(response.curation); + } catch (e) { + flashAPIErrors(e); + } + }, + }), +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_settings/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_settings/index.ts new file mode 100644 index 0000000000000..fd7d3156cc5ab --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_settings/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 { CurationsSettings } from './curations_settings'; +export { CurationsSettingsLogic } from './curations_settings_logic'; diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/search_relevance_suggestions.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/search_relevance_suggestions.test.ts index 555a66cedc85e..d6f741526b29b 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/search_relevance_suggestions.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/search_relevance_suggestions.test.ts @@ -37,4 +37,53 @@ describe('search relevance insights routes', () => { }); }); }); + + describe('GET /internal/app_search/engines/{name}/search_relevance_suggestions/settings', () => { + const mockRouter = new MockRouter({ + method: 'get', + path: '/internal/app_search/engines/{engineName}/search_relevance_suggestions/settings', + }); + + beforeEach(() => { + registerSearchRelevanceSuggestionsRoutes({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('creates a request to enterprise search', () => { + mockRouter.callRoute({ + params: { engineName: 'some-engine' }, + }); + + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/api/as/v0/engines/:engineName/search_relevance_suggestions/settings', + }); + }); + }); + + describe('PUT /internal/app_search/engines/{name}/search_relevance_suggestions/settings', () => { + const mockRouter = new MockRouter({ + method: 'put', + path: '/internal/app_search/engines/{engineName}/search_relevance_suggestions/settings', + }); + + beforeEach(() => { + registerSearchRelevanceSuggestionsRoutes({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('creates a request to enterprise search', () => { + mockRouter.callRoute({ + params: { engineName: 'some-engine' }, + body: { curation: { enabled: true } }, + }); + + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/api/as/v0/engines/:engineName/search_relevance_suggestions/settings', + }); + }); + }); }); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/search_relevance_suggestions.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/search_relevance_suggestions.ts index 147f68f0476ee..861d8c52b537f 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/search_relevance_suggestions.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/search_relevance_suggestions.ts @@ -7,6 +7,8 @@ import { schema } from '@kbn/config-schema'; +import { skipBodyValidation } from '../../lib/route_config_helpers'; + import { RouteDependencies } from '../../plugin'; export function registerSearchRelevanceSuggestionsRoutes({ @@ -36,4 +38,32 @@ export function registerSearchRelevanceSuggestionsRoutes({ path: '/api/as/v0/engines/:engineName/search_relevance_suggestions', }) ); + + router.get( + { + path: '/internal/app_search/engines/{engineName}/search_relevance_suggestions/settings', + validate: { + params: schema.object({ + engineName: schema.string(), + }), + }, + }, + enterpriseSearchRequestHandler.createRequest({ + path: '/api/as/v0/engines/:engineName/search_relevance_suggestions/settings', + }) + ); + + router.put( + skipBodyValidation({ + path: '/internal/app_search/engines/{engineName}/search_relevance_suggestions/settings', + validate: { + params: schema.object({ + engineName: schema.string(), + }), + }, + }), + enterpriseSearchRequestHandler.createRequest({ + path: '/api/as/v0/engines/:engineName/search_relevance_suggestions/settings', + }) + ); } diff --git a/x-pack/plugins/fleet/server/integration_tests/router.test.ts b/x-pack/plugins/fleet/server/integration_tests/router.test.ts index d5f40e4db2cfe..55518923e65f2 100644 --- a/x-pack/plugins/fleet/server/integration_tests/router.test.ts +++ b/x-pack/plugins/fleet/server/integration_tests/router.test.ts @@ -19,7 +19,6 @@ import * as kbnTestServer from '../../../../../src/test_utils/kbn_server'; function createXPackRoot(config: {} = {}) { return kbnTestServer.createRoot({ plugins: { - scanDirs: [], paths: [ resolve(__dirname, '../../../../../x-pack/plugins/encrypted_saved_objects'), resolve(__dirname, '../../../../../x-pack/plugins/fleet'), diff --git a/x-pack/plugins/index_management/public/application/mount_management_section.ts b/x-pack/plugins/index_management/public/application/mount_management_section.ts index 17ac095128a76..71e0f80365430 100644 --- a/x-pack/plugins/index_management/public/application/mount_management_section.ts +++ b/x-pack/plugins/index_management/public/application/mount_management_section.ts @@ -8,7 +8,7 @@ import { i18n } from '@kbn/i18n'; import { SemVer } from 'semver'; import { CoreSetup } from 'src/core/public'; -import { ManagementAppMountParams } from 'src/plugins/management/public/'; +import { ManagementAppMountParams } from 'src/plugins/management/public'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/public'; import { UIM_APP_NAME } from '../../common/constants'; diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.tsx b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.tsx index b1a37caf149d9..6a5019d4683c8 100644 --- a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.tsx +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.tsx @@ -260,6 +260,28 @@ export const Expressions: React.FC = (props) => { [alertParams.groupBy] ); + const groupByFilterTestPatterns = useMemo(() => { + if (!alertParams.groupBy) return null; + const groups = !Array.isArray(alertParams.groupBy) + ? [alertParams.groupBy] + : alertParams.groupBy; + return groups.map((group: string) => ({ + groupName: group, + pattern: new RegExp(`{"match(_phrase)?":{"${group}":"(.*?)"}}`), + })); + }, [alertParams.groupBy]); + + const redundantFilterGroupBy = useMemo(() => { + if (!alertParams.filterQuery || !groupByFilterTestPatterns) return []; + return groupByFilterTestPatterns + .map(({ groupName, pattern }) => { + if (pattern.test(alertParams.filterQuery!)) { + return groupName; + } + }) + .filter((g) => typeof g === 'string') as string[]; + }, [alertParams.filterQuery, groupByFilterTestPatterns]); + return ( <> @@ -425,8 +447,24 @@ export const Expressions: React.FC = (props) => { ...options, groupBy: alertParams.groupBy || undefined, }} + errorOptions={redundantFilterGroupBy} /> + {redundantFilterGroupBy.length > 0 && ( + <> + + + {redundantFilterGroupBy.join(', ')}, + groupCount: redundantFilterGroupBy.length, + }} + /> + + + )} void; fields: IFieldType[]; + errorOptions?: string[]; } -export const MetricsExplorerGroupBy = ({ options, onChange, fields }: Props) => { +export const MetricsExplorerGroupBy = ({ options, onChange, fields, errorOptions }: Props) => { const handleChange = useCallback( (selectedOptions: Array<{ label: string }>) => { const groupBy = selectedOptions.map((option) => option.label); @@ -28,9 +29,17 @@ export const MetricsExplorerGroupBy = ({ options, onChange, fields }: Props) => ); const selectedOptions = Array.isArray(options.groupBy) - ? options.groupBy.map((field) => ({ label: field })) + ? options.groupBy.map((field) => ({ + label: field, + color: errorOptions?.includes(field) ? 'danger' : undefined, + })) : options.groupBy - ? [{ label: options.groupBy }] + ? [ + { + label: options.groupBy, + color: errorOptions?.includes(options.groupBy) ? 'danger' : undefined, + }, + ] : []; return ( diff --git a/x-pack/plugins/lens/public/_mixins.scss b/x-pack/plugins/lens/public/_mixins.scss index 5a798bcc6c23b..7282de214636c 100644 --- a/x-pack/plugins/lens/public/_mixins.scss +++ b/x-pack/plugins/lens/public/_mixins.scss @@ -15,7 +15,7 @@ // Static styles for a draggable item @mixin lnsDraggable { @include euiSlightShadow; - background: lightOrDarkTheme($euiColorEmptyShade, $euiColorLightestShade); + background: $euiColorEmptyShade; border: $euiBorderWidthThin dashed transparent; cursor: grab; } diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/add_layer.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/add_layer.tsx index 69e4aa629cec6..e052e06f1b2f1 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/add_layer.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/add_layer.tsx @@ -64,7 +64,6 @@ export function AddLayerButton({
diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.scss deleted file mode 100644 index 0d51108fb2dcb..0000000000000 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.scss +++ /dev/null @@ -1,10 +0,0 @@ -.lnsConfigPanel__addLayerBtn { - @include kbnThemeStyle('v7') { - // sass-lint:disable-block no-important - background-color: transparent !important; - color: transparentize($euiColorMediumShade, .3) !important; - border-color: $euiColorLightShade !important; - box-shadow: none !important; - } - -} diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx index 57e4cf5b8dffd..0b6223ac87ce2 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx @@ -5,8 +5,6 @@ * 2.0. */ -import './config_panel.scss'; - import React, { useMemo, memo } from 'react'; import { EuiForm } from '@elastic/eui'; import { mapValues } from 'lodash'; diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.scss index 135286fc2172b..692db8171d124 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.scss +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.scss @@ -24,48 +24,24 @@ } } -.lnsDimensionContainer__footer { - padding: $euiSizeS; - - .lnsFrameLayout__sidebar-isFullscreen & { - display: none; - } -} - .lnsDimensionContainer__header { - padding: $euiSizeS $euiSizeXS; + padding: $euiSize; .lnsFrameLayout__sidebar-isFullscreen & { display: none; } } -.lnsDimensionContainer__headerTitle { - padding: $euiSizeS $euiSizeXS; - cursor: pointer; - - &:hover { - text-decoration: underline; - } +.lnsDimensionContainer__content { + @include euiYScroll; + flex: 1; } -.lnsDimensionContainer__headerLink { - &:focus-within { - background-color: transparentize($euiColorVis1, .9); - - .lnsDimensionContainer__headerTitle { - text-decoration: underline; - } - } -} - -.lnsDimensionContainer__backIcon { - &:hover { - transform: none !important; // sass-lint:disable-line no-important - } +.lnsDimensionContainer__footer { + padding: $euiSize; - &:focus { - background-color: transparent; + .lnsFrameLayout__sidebar-isFullscreen & { + display: none; } } diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.tsx index c62b10093e6e5..f7402e78ebd96 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.tsx @@ -98,13 +98,7 @@ export function DimensionContainer({ }} > - +

+ - - {panel} - + +
{panel}
+ {i18n.translate('xpack.lens.dimensionContainer.close', { diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.scss index 7a1cbb8237f50..781a08d0f60bb 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.scss +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.scss @@ -36,32 +36,32 @@ background: $euiColorLightestShade; padding: $euiSize; + &:last-child { + border-radius: 0 0 $euiBorderRadius $euiBorderRadius; + } + // Add border to the top of the next same panel & + & { border-top: $euiBorderThin; margin-top: 0; } - &:last-child { - border-bottom-right-radius: $euiBorderRadius; - border-bottom-left-radius: $euiBorderRadius; + + & > * { + margin-bottom: 0; } -} -.lnsLayerPanel__row--notSupportsMoreColumns { - padding-bottom: 0; + // Targeting EUI class as we are unable to apply a class to this element in component + &, + .euiFormRow__fieldWrapper { + & > * + * { + margin-top: $euiSize; + } + } } .lnsLayerPanel__group { - padding: $euiSizeS 0; - margin-bottom: $euiSizeS; -} - -.lnsLayerPanel__group:empty { - padding: 0; -} - -.lnsLayerPanel__error { - padding: 0 $euiSizeS; + margin: (-$euiSizeS) (-$euiSize); + padding: $euiSizeS $euiSize; } .lnsLayerPanel__dimension { @@ -87,11 +87,10 @@ } .lnsLayerPanel__dimensionContainer { - margin: 0 0 $euiSizeS; position: relative; - &:last-child { - margin-bottom: 0; + & + & { + margin-top: $euiSizeS; } } @@ -105,6 +104,10 @@ min-height: $euiSizeXXL - 2; word-break: break-word; font-weight: $euiFontWeightRegular; + + @include kbnThemeStyle('v7') { + font-size: $euiFontSizeS; + } } .lnsLayerPanel__triggerTextLabel { @@ -119,7 +122,8 @@ } .lnsLayerPanel__styleEditor { - padding: 0 $euiSizeS $euiSizeS; + margin-top: -$euiSizeS; + padding: 0 $euiSize $euiSize; } .lnsLayerPanel__colorIndicator { diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx index f777fd0976dfd..5b432f85efde2 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx @@ -222,7 +222,7 @@ describe('LayerPanel', () => { const group = instance .find(EuiFormRow) - .findWhere((e) => e.prop('error')?.props?.children === 'Required dimension'); + .findWhere((e) => e.prop('error') === 'Required dimension'); expect(group).toHaveLength(1); }); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx index 6e2e77af4f3b0..8d19620cebbdc 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx @@ -326,12 +326,7 @@ export function LayerPanel( className="lnsLayerPanel" style={{ visibility: isDimensionPanelOpen ? 'hidden' : 'visible' }} > - +
@@ -394,16 +389,13 @@ export function LayerPanel( {groups.map((group, groupIndex) => { const isMissing = !isEmptyLayer && group.required && group.accessors.length === 0; + return ( + <> {group.groupLabel} {group.groupTooltip && ( <> @@ -420,92 +412,91 @@ export function LayerPanel( /> )} -

+ } labelType="legend" key={group.groupId} isInvalid={isMissing} error={ - isMissing ? ( -
- {i18n.translate('xpack.lens.editorFrame.requiredDimensionWarningLabel', { + isMissing + ? i18n.translate('xpack.lens.editorFrame.requiredDimensionWarningLabel', { defaultMessage: 'Required dimension', - })} -
- ) : ( - [] - ) + }) + : [] } > <> - - {group.accessors.map((accessorConfig, accessorIndex) => { - const { columnId } = accessorConfig; - - return ( - -
- { - setActiveDimension({ - isNew: false, - activeGroup: group, - activeId: id, - }); - }} - onRemoveClick={(id: string) => { - trackUiEvent('indexpattern_dimension_removed'); - props.updateAll( - datasourceId, - layerDatasource.removeColumn({ - layerId, - columnId: id, - prevState: layerDatasourceState, - }), - activeVisualization.removeDimension({ - layerId, - columnId: id, - prevState: props.visualizationState, - frame: framePublicAPI, - }) - ); - removeButtonRef(id); - }} - > - + {group.accessors.map((accessorConfig, accessorIndex) => { + const { columnId } = accessorConfig; + + return ( + +
+ { + setActiveDimension({ + isNew: false, + activeGroup: group, + activeId: id, + }); }} - /> - -
-
- ); - })} - + onRemoveClick={(id: string) => { + trackUiEvent('indexpattern_dimension_removed'); + props.updateAll( + datasourceId, + layerDatasource.removeColumn({ + layerId, + columnId: id, + prevState: layerDatasourceState, + }), + activeVisualization.removeDimension({ + layerId, + columnId: id, + prevState: props.visualizationState, + frame: framePublicAPI, + }) + ); + removeButtonRef(id); + }} + > + +
+
+
+ ); + })} +
+ ) : null} + {group.supportsMoreColumns ? ( * { flex: 1 1 100%; @@ -38,10 +42,13 @@ } .lnsWorkspacePanel__dragDrop { - width: 100%; - height: 100%; - border: $euiBorderThin; - border-radius: $euiBorderRadiusSmall; + @include kbnThemeStyle('v7') { + border: $euiBorderThin; + } + + @include kbnThemeStyle('v8') { + border: $euiBorderWidthThin solid transparent; + } &.lnsDragDrop-isDropTarget { @include lnsDroppable; diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.tsx index e1cb1aeb9f825..d8959e714d16e 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.tsx @@ -125,10 +125,15 @@ export function WorkspacePanelWrapper({
+ {children} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.scss b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.scss index 0303e6549d8df..c93f05c6eb19a 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.scss +++ b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.scss @@ -54,6 +54,5 @@ .lnsLayerPanelChartSwitch_title { font-weight: 600; display: inline; - vertical-align: middle; padding-left: 8px; } \ No newline at end of file diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.scss b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.scss index 874291ae25e34..30e2e00c7c85d 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.scss +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.scss @@ -6,8 +6,8 @@ position: sticky; top: 0; background: $euiColorEmptyShade; - // Raise it above the elements that are after it in DOM order - z-index: $euiZLevel1; + z-index: $euiZLevel1; // Raise it above the elements that are after it in DOM order + padding: 0 $euiSize; } .lnsIndexPatternDimensionEditor-isFullscreen { @@ -23,21 +23,14 @@ } .lnsIndexPatternDimensionEditor__section--padded { - padding: $euiSizeS; + padding: $euiSize; } .lnsIndexPatternDimensionEditor__section--shaded { background-color: $euiColorLightestShade; -} - -.lnsIndexPatternDimensionEditor__section--top { border-bottom: $euiBorderThin; } -.lnsIndexPatternDimensionEditor__section--bottom { - border-top: $euiBorderThin; -} - .lnsIndexPatternDimensionEditor__columns { column-count: 2; column-gap: $euiSizeXL; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx index 333e76f5a4f57..29bbe6a96b9e1 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx @@ -422,7 +422,7 @@ export function DimensionEditor(props: DimensionEditorProps) { maxWidth={false} />
- +
{!incompleteInfo && selectedColumn && @@ -636,8 +636,6 @@ export function DimensionEditor(props: DimensionEditorProps) { /> )}
- - ); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/editor/formula.scss b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/editor/formula.scss index d66e19bec8a1c..92a778ebfb803 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/editor/formula.scss +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/editor/formula.scss @@ -17,8 +17,6 @@ } .lnsFormula__editor { - border-bottom: $euiBorderThin; - .lnsIndexPatternDimensionEditor-isFullscreen & { border-bottom: none; display: flex; @@ -32,7 +30,7 @@ .lnsFormula__editorHeader, .lnsFormula__editorFooter { - padding: $euiSizeS; + padding: $euiSizeS $euiSize; } .lnsFormula__editorFooter { @@ -130,7 +128,7 @@ } .lnsFormula__docsSearch { - padding: $euiSizeS; + padding: $euiSize; } .lnsFormula__docsNav { @@ -138,7 +136,7 @@ } .lnsFormula__docsNavGroup { - padding: $euiSizeS; + padding: $euiSize; & + & { border-top: $euiBorderThin; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/editor/formula_editor.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/editor/formula_editor.tsx index 4f6f13ea843ef..7abe80003ea0e 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/editor/formula_editor.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/editor/formula_editor.tsx @@ -549,6 +549,8 @@ export function FormulaEditor({ dimension: { width: 320, height: 200 }, fixedOverflowWidgets: true, matchBrackets: 'always', + // Undocumented Monaco option to force left margin width + lineDecorationsWidth: 16, }, }; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/editor/formula_help.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/editor/formula_help.tsx index 3c3068a595bc0..47dd8fbc9c569 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/editor/formula_help.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/editor/formula_help.tsx @@ -18,6 +18,7 @@ import { EuiTitle, EuiFieldSearch, EuiHighlight, + EuiSpacer, } from '@elastic/eui'; import { Markdown } from '../../../../../../../../../src/plugins/kibana_react/public'; import { IndexPattern } from '../../../../types'; @@ -298,7 +299,7 @@ sum(products.base_price) / overall_sum(sum(products.base_price)) return ( <> - + {i18n.translate('xpack.lens.formulaDocumentation.header', { defaultMessage: 'Formula reference', })} @@ -347,22 +348,28 @@ sum(products.base_price) / overall_sum(sum(products.base_price)) - - {helpGroup.items.map((helpItem) => { - return ( - {helpItem.label} - } - size="s" - onClick={() => { - setSelectedFunction(helpItem.label); - }} - /> - ); - })} - + {helpGroup.items.length ? ( + <> + + + + {helpGroup.items.map((helpItem) => { + return ( + {helpItem.label} + } + size="s" + onClick={() => { + setSelectedFunction(helpItem.label); + }} + /> + ); + })} + + + ) : null} ); })} diff --git a/x-pack/plugins/lens/public/shared_components/coloring/palette_configuration.scss b/x-pack/plugins/lens/public/shared_components/coloring/palette_configuration.scss index c6b14c5c5f9a3..82165b172eab9 100644 --- a/x-pack/plugins/lens/public/shared_components/coloring/palette_configuration.scss +++ b/x-pack/plugins/lens/public/shared_components/coloring/palette_configuration.scss @@ -1,7 +1,8 @@ -.lnsPalettePanel__section--shaded { - background-color: $euiColorLightestShade; +.lnsPalettePanel__section { + padding: $euiSize; } -.lnsPalettePanel__section { - padding: $euiSizeS; +.lnsPalettePanel__section--shaded { + background-color: $euiColorLightestShade; + border-bottom: $euiBorderThin; } \ No newline at end of file diff --git a/x-pack/plugins/lens/public/shared_components/coloring/palette_panel_container.scss b/x-pack/plugins/lens/public/shared_components/coloring/palette_panel_container.scss index db14d064d1881..edf6c472321bf 100644 --- a/x-pack/plugins/lens/public/shared_components/coloring/palette_panel_container.scss +++ b/x-pack/plugins/lens/public/shared_components/coloring/palette_panel_container.scss @@ -15,39 +15,15 @@ z-index: $euiZLevel3 + 1 } -.lnsPalettePanelContainer__footer { - padding: $euiSizeS; -} - .lnsPalettePanelContainer__header { - padding: $euiSizeS $euiSizeXS; -} - -.lnsPalettePanelContainer__headerTitle { - padding: $euiSizeS $euiSizeXS; - cursor: pointer; - - &:hover { - text-decoration: underline; - } + padding: $euiSize; } -.lnsPalettePanelContainer__headerLink { - &:focus-within { - background-color: transparentize($euiColorVis1, .9); - - .lnsPalettePanelContainer__headerTitle { - text-decoration: underline; - } - } +.lnsPalettePanelContainer__content { + @include euiYScroll; + flex: 1; } -.lnsPalettePanelContainer__backIcon { - &:hover { - transform: none !important; // sass-lint:disable-line no-important - } - - &:focus { - background-color: transparent; - } -} +.lnsPalettePanelContainer__footer { + padding: $euiSize; +} \ No newline at end of file diff --git a/x-pack/plugins/lens/public/shared_components/coloring/palette_panel_container.tsx b/x-pack/plugins/lens/public/shared_components/coloring/palette_panel_container.tsx index 583d6e25ed4e2..b546ffe5fb6fb 100644 --- a/x-pack/plugins/lens/public/shared_components/coloring/palette_panel_container.tsx +++ b/x-pack/plugins/lens/public/shared_components/coloring/palette_panel_container.tsx @@ -59,12 +59,7 @@ export function PalettePanelContainer({ className="lnsPalettePanelContainer" > - + +

- - {children} - + +
{children}
+ {i18n.translate('xpack.lens.table.palettePanelContainer.back', { diff --git a/x-pack/plugins/lens/public/xy_visualization/color_assignment.ts b/x-pack/plugins/lens/public/xy_visualization/color_assignment.ts index 1e00d821d9b30..30238507c3566 100644 --- a/x-pack/plugins/lens/public/xy_visualization/color_assignment.ts +++ b/x-pack/plugins/lens/public/xy_visualization/color_assignment.ts @@ -8,9 +8,10 @@ import { uniq, mapValues } from 'lodash'; import type { PaletteOutput, PaletteRegistry } from 'src/plugins/charts/public'; import type { Datatable } from 'src/plugins/expressions'; +import { euiLightVars } from '@kbn/ui-shared-deps-src/theme'; import type { AccessorConfig, FramePublicAPI } from '../types'; import { getColumnToLabelMap } from './state_helpers'; -import type { FormatFactory } from '../../common'; +import { FormatFactory, LayerType, layerTypes } from '../../common'; import type { XYLayerConfig } from '../../common/expressions'; const isPrimitive = (value: unknown): boolean => value != null && typeof value !== 'object'; @@ -20,8 +21,11 @@ interface LayerColorConfig { splitAccessor?: string; accessors: string[]; layerId: string; + layerType: LayerType; } +export const defaultThresholdColor = euiLightVars.euiColorDarkShade; + export type ColorAssignments = Record< string, { @@ -37,13 +41,15 @@ export function getColorAssignments( ): ColorAssignments { const layersPerPalette: Record = {}; - layers.forEach((layer) => { - const palette = layer.palette?.name || 'default'; - if (!layersPerPalette[palette]) { - layersPerPalette[palette] = []; - } - layersPerPalette[palette].push(layer); - }); + layers + .filter(({ layerType }) => layerType === layerTypes.DATA) + .forEach((layer) => { + const palette = layer.palette?.name || 'default'; + if (!layersPerPalette[palette]) { + layersPerPalette[palette] = []; + } + layersPerPalette[palette].push(layer); + }); return mapValues(layersPerPalette, (paletteLayers) => { const seriesPerLayer = paletteLayers.map((layer, layerIndex) => { @@ -111,6 +117,13 @@ export function getAccessorColorConfig( triggerIcon: 'disabled', }; } + if (layer.layerType === layerTypes.THRESHOLD) { + return { + columnId: accessor as string, + triggerIcon: 'color', + color: currentYConfig?.color || defaultThresholdColor, + }; + } const columnToLabel = getColumnToLabelMap(layer, frame.datasourceLayers[layer.layerId]); const rank = colorAssignments[currentPalette.name].getRank( layer, @@ -133,7 +146,7 @@ export function getAccessorColorConfig( return { columnId: accessor as string, triggerIcon: customColor ? 'color' : 'disabled', - color: customColor ? customColor : undefined, + color: customColor ?? undefined, }; }); } diff --git a/x-pack/plugins/lens/public/xy_visualization/expression.tsx b/x-pack/plugins/lens/public/xy_visualization/expression.tsx index 765870530de8b..0cea52b5d3c9e 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression.tsx @@ -878,7 +878,6 @@ export function XYChart({ ; paletteService: PaletteRegistry; syncColors: boolean; @@ -155,12 +153,11 @@ export const ThresholdAnnotations = ({ if (!thresholdLayer.yConfig) { return []; } - const { columnToLabel, palette, yConfig: yConfigs, layerId } = thresholdLayer; + const { columnToLabel, yConfig: yConfigs, layerId } = thresholdLayer; const columnToLabelMap: Record = columnToLabel ? JSON.parse(columnToLabel) : {}; const table = data.tables[layerId]; - const colorAssignment = colorAssignments[palette.name]; const row = table.rows[0]; @@ -181,27 +178,7 @@ export const ThresholdAnnotations = ({ const formatter = formatters[groupId || 'bottom']; - const seriesLayers: SeriesLayer[] = [ - { - name: columnToLabelMap[yConfig.forAccessor], - totalSeriesAtDepth: colorAssignment.totalSeriesCount, - rankAtDepth: colorAssignment.getRank( - thresholdLayer, - String(yConfig.forAccessor), - String(yConfig.forAccessor) - ), - }, - ]; - const defaultColor = paletteService.get(palette.name).getCategoricalColor( - seriesLayers, - { - maxDepth: 1, - behindText: false, - totalSeries: colorAssignment.totalSeriesCount, - syncColors, - }, - palette.params - ); + const defaultColor = euiLightVars.euiColorDarkShade; const props = { groupId, @@ -224,7 +201,7 @@ export const ThresholdAnnotations = ({ const sharedStyle = { strokeWidth: yConfig.lineWidth || 1, - stroke: (yConfig.color || defaultColor) ?? '#f00', + stroke: yConfig.color || defaultColor, dash: dashStyle, }; @@ -304,7 +281,7 @@ export const ThresholdAnnotations = ({ })} style={{ ...sharedStyle, - fill: (yConfig.color || defaultColor) ?? '#f00', + fill: yConfig.color || defaultColor, opacity: 0.1, }} /> diff --git a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts index 136945a27e7c6..2fce7c6a612ae 100644 --- a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts +++ b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts @@ -13,6 +13,7 @@ import { OperationMetadata, DatasourcePublicAPI } from '../types'; import { getColumnToLabelMap } from './state_helpers'; import type { ValidLayer, XYLayerConfig } from '../../common/expressions'; import { layerTypes } from '../../common'; +import { defaultThresholdColor } from './color_assignment'; export const getSortedAccessors = (datasource: DatasourcePublicAPI, layer: XYLayerConfig) => { const originalOrder = datasource @@ -334,9 +335,9 @@ export const buildExpression = ( arguments: { forAccessor: [yConfig.forAccessor], axisMode: yConfig.axisMode ? [yConfig.axisMode] : [], - color: yConfig.color ? [yConfig.color] : [], - lineStyle: yConfig.lineStyle ? [yConfig.lineStyle] : [], - lineWidth: yConfig.lineWidth ? [yConfig.lineWidth] : [], + color: [yConfig.color || defaultThresholdColor], + lineStyle: [yConfig.lineStyle || 'solid'], + lineWidth: [yConfig.lineWidth || 1], fill: [yConfig.fill || 'none'], icon: yConfig.icon ? [yConfig.icon] : [], iconPosition: [yConfig.iconPosition || 'auto'], diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts b/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts index 8907db4954f99..8052b0d593215 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts @@ -347,9 +347,6 @@ describe('xy_visualization', () => { { axisMode: 'bottom', forAccessor: 'newCol', - icon: undefined, - lineStyle: 'solid', - lineWidth: 1, }, ], }); diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx index 33cd01c8fda7a..4e279d2e0026d 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx @@ -448,12 +448,7 @@ export const getXyVisualization = ({ if (!hasYConfig) { newLayer.yConfig = [ ...(newLayer.yConfig || []), - // TODO: move this - // add a default config if none is available { - icon: undefined, - lineStyle: 'solid', - lineWidth: 1, // override with previous styling, ...previousYConfig, // but keep the new group & id config diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/color_picker.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/color_picker.tsx index 5a6458a4654d0..516adbf585b9f 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/color_picker.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/color_picker.tsx @@ -13,9 +13,13 @@ import { EuiFormRow, EuiColorPicker, EuiColorPickerProps, EuiToolTip, EuiIcon } import type { PaletteRegistry } from 'src/plugins/charts/public'; import type { VisualizationDimensionEditorProps } from '../../types'; import { State } from '../types'; -import { FormatFactory } from '../../../common'; +import { FormatFactory, layerTypes } from '../../../common'; import { getSeriesColor } from '../state_helpers'; -import { getAccessorColorConfig, getColorAssignments } from '../color_assignment'; +import { + defaultThresholdColor, + getAccessorColorConfig, + getColorAssignments, +} from '../color_assignment'; import { getSortedAccessors } from '../to_expression'; import { updateLayer } from '.'; import { TooltipWrapper } from '../../shared_components'; @@ -56,6 +60,9 @@ export const ColorPicker = ({ const overwriteColor = getSeriesColor(layer, accessor); const currentColor = useMemo(() => { if (overwriteColor || !frame.activeData) return overwriteColor; + if (layer.layerType === layerTypes.THRESHOLD) { + return defaultThresholdColor; + } const datasource = frame.datasourceLayers[layer.layerId]; const sortedAccessors: string[] = getSortedAccessors(datasource, layer); diff --git a/x-pack/plugins/ml/public/application/routing/routes/new_job/index_or_search.tsx b/x-pack/plugins/ml/public/application/routing/routes/new_job/index_or_search.tsx index 5d903bd865911..8500d85d5580a 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/new_job/index_or_search.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/new_job/index_or_search.tsx @@ -83,6 +83,7 @@ const PageWrapper: FC = ({ nextStepPath, deps, mode }) = application: { navigateToUrl }, }, } = useMlKibana(); + const { redirectToMlAccessDeniedPage } = deps; const redirectToJobsManagementPage = useCreateAndNavigateToMlLink( ML_PAGES.ANOMALY_DETECTION_JOBS_MANAGE diff --git a/x-pack/plugins/ml/public/locator/ml_locator.test.ts b/x-pack/plugins/ml/public/locator/ml_locator.test.ts index 3b736a9af4e3e..cac31abd9f62a 100644 --- a/x-pack/plugins/ml/public/locator/ml_locator.test.ts +++ b/x-pack/plugins/ml/public/locator/ml_locator.test.ts @@ -9,7 +9,7 @@ import { MlLocatorDefinition } from './ml_locator'; import { ML_PAGES } from '../../common/constants/locator'; import { ANALYSIS_CONFIG_TYPE } from '../../common/constants/data_frame_analytics'; -describe('MlUrlGenerator', () => { +describe('ML locator', () => { const definition = new MlLocatorDefinition(); describe('AnomalyDetection', () => { diff --git a/x-pack/plugins/monitoring/public/application/index.tsx b/x-pack/plugins/monitoring/public/application/index.tsx index 690ea26319bd3..dea8d18bb65b1 100644 --- a/x-pack/plugins/monitoring/public/application/index.tsx +++ b/x-pack/plugins/monitoring/public/application/index.tsx @@ -22,6 +22,7 @@ import { NoDataPage } from './pages/no_data'; import { ElasticsearchOverviewPage } from './pages/elasticsearch/overview'; import { BeatsOverviewPage } from './pages/beats/overview'; import { BeatsInstancesPage } from './pages/beats/instances'; +import { BeatsInstancePage } from './pages/beats/instance'; import { CODE_PATH_ELASTICSEARCH, CODE_PATH_BEATS } from '../../common/constants'; import { ElasticsearchNodesPage } from './pages/elasticsearch/nodes_page'; import { ElasticsearchIndicesPage } from './pages/elasticsearch/indices_page'; @@ -111,6 +112,13 @@ const MonitoringApp: React.FC<{ /> {/* Beats Views */} + + = ({ cluster, ...props }) => { - const tabs: TabMenuItem[] = [ - { +export const BeatsTemplate: React.FC = ({ instance, ...props }) => { + const tabs: TabMenuItem[] = []; + + if (!instance) { + tabs.push({ id: 'overview', label: i18n.translate('xpack.monitoring.beatsNavigation.overviewLinkText', { defaultMessage: 'Overview', }), route: '/beats', - }, - { + }); + tabs.push({ id: 'instances', label: i18n.translate('xpack.monitoring.beatsNavigation.instancesLinkText', { defaultMessage: 'Instances', }), route: '/beats/beats', - }, - ]; + }); + } else { + tabs.push({ + id: 'overview', + label: i18n.translate('xpack.monitoring.beatsNavigation.instance.overviewLinkText', { + defaultMessage: 'Overview', + }), + route: `/beats/beat/${instance}`, + }); + } return ; }; diff --git a/x-pack/plugins/monitoring/public/application/pages/beats/instance.tsx b/x-pack/plugins/monitoring/public/application/pages/beats/instance.tsx new file mode 100644 index 0000000000000..f7ff03898fda6 --- /dev/null +++ b/x-pack/plugins/monitoring/public/application/pages/beats/instance.tsx @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useContext, useState, useCallback, useEffect } from 'react'; +import { useParams } from 'react-router-dom'; +import { i18n } from '@kbn/i18n'; +import { find } from 'lodash'; +import { ComponentProps } from '../../route_init'; +import { GlobalStateContext } from '../../global_state_context'; +import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; +import { useCharts } from '../../hooks/use_charts'; +// @ts-ignore +import { Beat } from '../../../components/beats/beat'; +import { BreadcrumbContainer } from '../../hooks/use_breadcrumbs'; +import { BeatsTemplate } from './beats_template'; + +export const BeatsInstancePage: React.FC = ({ clusters }) => { + const { instance }: { instance: string } = useParams(); + + const globalState = useContext(GlobalStateContext); + const { services } = useKibana<{ data: any }>(); + const { generate: generateBreadcrumbs } = useContext(BreadcrumbContainer.Context); + const { zoomInfo, onBrush } = useCharts(); + const clusterUuid = globalState.cluster_uuid; + const ccs = globalState.ccs; + const cluster = find(clusters, { + cluster_uuid: clusterUuid, + }) as any; + const [data, setData] = useState({} as any); + const [beatName, setBeatName] = useState(''); + + const title = i18n.translate('xpack.monitoring.beats.instance.routeTitle', { + defaultMessage: 'Beats - {instanceName} - Overview', + values: { + instanceName: beatName, + }, + }); + + const pageTitle = i18n.translate('xpack.monitoring.beats.instance.pageTitle', { + defaultMessage: 'Beat instance: {beatName}', + values: { + beatName, + }, + }); + + useEffect(() => { + if (cluster) { + generateBreadcrumbs(cluster.cluster_name, { + inBeats: true, + instance: beatName, + }); + } + }, [cluster, beatName, generateBreadcrumbs]); + + const getPageData = useCallback(async () => { + const bounds = services.data?.query.timefilter.timefilter.getBounds(); + const url = `../api/monitoring/v1/clusters/${clusterUuid}/beats/beat/${instance}`; + const response = await services.http?.fetch(url, { + method: 'POST', + body: JSON.stringify({ + ccs, + timeRange: { + min: bounds.min.toISOString(), + max: bounds.max.toISOString(), + }, + }), + }); + + setData(response); + setBeatName(response.summary.name); + }, [ccs, clusterUuid, instance, services.data?.query.timefilter.timefilter, services.http]); + + return ( + +
+ +
+
+ ); +}; diff --git a/x-pack/plugins/monitoring/public/application/pages/beats/instances.tsx b/x-pack/plugins/monitoring/public/application/pages/beats/instances.tsx index 3f32e1abf9a88..873cc7e939269 100644 --- a/x-pack/plugins/monitoring/public/application/pages/beats/instances.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/beats/instances.tsx @@ -81,7 +81,6 @@ export const BeatsInstancesPage: React.FC = ({ clusters }) => { pageTitle={pageTitle} getPageData={getPageData} data-test-subj="beatsListingPage" - cluster={cluster} >
= ({ clusters }) => { pageTitle={pageTitle} getPageData={getPageData} data-test-subj="beatsOverviewPage" - cluster={cluster} >
{renderOverview(data)}
diff --git a/x-pack/plugins/reporting/server/config/create_config.ts b/x-pack/plugins/reporting/server/config/create_config.ts index a3fc285c702a8..b1d69d17334be 100644 --- a/x-pack/plugins/reporting/server/config/create_config.ts +++ b/x-pack/plugins/reporting/server/config/create_config.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { i18n } from '@kbn/i18n/'; +import { i18n } from '@kbn/i18n'; import crypto from 'crypto'; import { upperFirst } from 'lodash'; import { Observable } from 'rxjs'; diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index d77a555991df8..4316b1c033ec6 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -10,6 +10,7 @@ import { ENABLE_CASE_CONNECTOR } from '../../cases/common'; import { metadataTransformPattern } from './endpoint/constants'; export const APP_ID = 'securitySolution'; +export const CASES_FEATURE_ID = 'securitySolutionCases'; export const SERVER_APP_ID = 'siem'; export const APP_NAME = 'Security'; export const APP_ICON = 'securityAnalyticsApp'; diff --git a/x-pack/plugins/security_solution/cypress/integration/cases/privileges.spec.ts b/x-pack/plugins/security_solution/cypress/integration/cases/privileges.spec.ts index 4d6c60e93ee20..23016ecc512b1 100644 --- a/x-pack/plugins/security_solution/cypress/integration/cases/privileges.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/cases/privileges.spec.ts @@ -81,6 +81,7 @@ const secAll: Role = { { feature: { siem: ['all'], + securitySolutionCases: ['all'], actions: ['all'], actionsSimulators: ['all'], }, @@ -110,7 +111,8 @@ const secReadCasesAll: Role = { kibana: [ { feature: { - siem: ['minimal_read', 'cases_all'], + siem: ['read'], + securitySolutionCases: ['all'], actions: ['all'], actionsSimulators: ['all'], }, diff --git a/x-pack/plugins/security_solution/public/app/deep_links/index.test.ts b/x-pack/plugins/security_solution/public/app/deep_links/index.test.ts index 5bcc598c7be5e..a3dc6565b19c6 100644 --- a/x-pack/plugins/security_solution/public/app/deep_links/index.test.ts +++ b/x-pack/plugins/security_solution/public/app/deep_links/index.test.ts @@ -8,6 +8,7 @@ import { getDeepLinks, PREMIUM_DEEP_LINK_IDS } from '.'; import { AppDeepLink, Capabilities } from '../../../../../../src/core/public'; import { SecurityPageName } from '../types'; import { mockGlobalState } from '../../common/mock'; +import { CASES_FEATURE_ID } from '../../../common/constants'; const findDeepLink = (id: string, deepLinks: AppDeepLink[]): AppDeepLink | null => deepLinks.reduce((deepLinkFound: AppDeepLink | null, deepLink) => { @@ -58,7 +59,7 @@ describe('deepLinks', () => { it('should return case links for basic license with only read_cases capabilities', () => { const basicLicense = 'basic'; const basicLinks = getDeepLinks(mockGlobalState.app.enableExperimental, basicLicense, { - siem: { read_cases: true, crud_cases: false }, + [CASES_FEATURE_ID]: { read_cases: true, crud_cases: false }, } as unknown as Capabilities); expect(findDeepLink(SecurityPageName.case, basicLinks)).toBeTruthy(); @@ -67,7 +68,7 @@ describe('deepLinks', () => { it('should return case links with NO deepLinks for basic license with only read_cases capabilities', () => { const basicLicense = 'basic'; const basicLinks = getDeepLinks(mockGlobalState.app.enableExperimental, basicLicense, { - siem: { read_cases: true, crud_cases: false }, + [CASES_FEATURE_ID]: { read_cases: true, crud_cases: false }, } as unknown as Capabilities); expect(findDeepLink(SecurityPageName.case, basicLinks)?.deepLinks?.length === 0).toBeTruthy(); }); @@ -75,7 +76,7 @@ describe('deepLinks', () => { it('should return case links with deepLinks for basic license with crud_cases capabilities', () => { const basicLicense = 'basic'; const basicLinks = getDeepLinks(mockGlobalState.app.enableExperimental, basicLicense, { - siem: { read_cases: true, crud_cases: true }, + [CASES_FEATURE_ID]: { read_cases: true, crud_cases: true }, } as unknown as Capabilities); expect( @@ -86,7 +87,7 @@ describe('deepLinks', () => { it('should return NO case links for basic license with NO read_cases capabilities', () => { const basicLicense = 'basic'; const basicLinks = getDeepLinks(mockGlobalState.app.enableExperimental, basicLicense, { - siem: { read_cases: false, crud_cases: false }, + [CASES_FEATURE_ID]: { read_cases: false, crud_cases: false }, } as unknown as Capabilities); expect(findDeepLink(SecurityPageName.case, basicLinks)).toBeFalsy(); diff --git a/x-pack/plugins/security_solution/public/app/deep_links/index.ts b/x-pack/plugins/security_solution/public/app/deep_links/index.ts index e8d4f5d09e5f7..aaa8ce789591f 100644 --- a/x-pack/plugins/security_solution/public/app/deep_links/index.ts +++ b/x-pack/plugins/security_solution/public/app/deep_links/index.ts @@ -48,6 +48,7 @@ import { TRUSTED_APPS_PATH, EVENT_FILTERS_PATH, UEBA_PATH, + CASES_FEATURE_ID, HOST_ISOLATION_EXCEPTIONS_PATH, } from '../../../common/constants'; import { ExperimentalFeatures } from '../../../common/experimental_features'; @@ -362,7 +363,7 @@ export function getDeepLinks( return false; } if (deepLink.id === SecurityPageName.case) { - return capabilities == null || capabilities.siem.read_cases === true; + return capabilities == null || capabilities[CASES_FEATURE_ID].read_cases === true; } if (deepLink.id === SecurityPageName.ueba) { return enableExperimental.uebaEnabled; @@ -373,7 +374,7 @@ export function getDeepLinks( if ( deepLink.id === SecurityPageName.case && capabilities != null && - capabilities.siem.crud_cases === false + capabilities[CASES_FEATURE_ID].crud_cases === false ) { return { ...deepLink, diff --git a/x-pack/plugins/security_solution/public/common/lib/kibana/hooks.ts b/x-pack/plugins/security_solution/public/common/lib/kibana/hooks.ts index adc06468d9a02..fdaed64ba91d7 100644 --- a/x-pack/plugins/security_solution/public/common/lib/kibana/hooks.ts +++ b/x-pack/plugins/security_solution/public/common/lib/kibana/hooks.ts @@ -12,7 +12,12 @@ import { i18n } from '@kbn/i18n'; import { camelCase, isArray, isObject } from 'lodash'; import { set } from '@elastic/safer-lodash-set'; -import { APP_ID, DEFAULT_DATE_FORMAT, DEFAULT_DATE_FORMAT_TZ } from '../../../../common/constants'; +import { + APP_ID, + CASES_FEATURE_ID, + DEFAULT_DATE_FORMAT, + DEFAULT_DATE_FORMAT_TZ, +} from '../../../../common/constants'; import { errorToToaster, useStateToaster } from '../../components/toasters'; import { AuthenticatedUser } from '../../../../../security/common/model'; import { NavigateToAppOptions } from '../../../../../../../src/core/public'; @@ -151,13 +156,9 @@ export const useGetUserCasesPermissions = () => { const uiCapabilities = useKibana().services.application.capabilities; useEffect(() => { - const capabilitiesCanUserCRUD: boolean = - typeof uiCapabilities.siem.crud_cases === 'boolean' ? uiCapabilities.siem.crud_cases : false; - const capabilitiesCanUserRead: boolean = - typeof uiCapabilities.siem.read_cases === 'boolean' ? uiCapabilities.siem.read_cases : false; setCasesPermissions({ - crud: capabilitiesCanUserCRUD, - read: capabilitiesCanUserRead, + crud: !!uiCapabilities[CASES_FEATURE_ID].crud_cases, + read: !!uiCapabilities[CASES_FEATURE_ID].read_cases, }); }, [uiCapabilities]); diff --git a/x-pack/plugins/security_solution/server/features.ts b/x-pack/plugins/security_solution/server/features.ts index cff1e2482a1ee..185bf43a3da37 100644 --- a/x-pack/plugins/security_solution/server/features.ts +++ b/x-pack/plugins/security_solution/server/features.ts @@ -9,49 +9,48 @@ import { i18n } from '@kbn/i18n'; import { KibanaFeatureConfig, SubFeatureConfig } from '../../features/common'; import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/server'; -import { APP_ID, SERVER_APP_ID } from '../common/constants'; +import { APP_ID, CASES_FEATURE_ID, SERVER_APP_ID } from '../common/constants'; import { savedObjectTypes } from './saved_objects'; -const CASES_SUB_FEATURE: SubFeatureConfig = { - name: 'Cases', - privilegeGroups: [ - { - groupType: 'mutually_exclusive', - privileges: [ - { - id: 'cases_all', - includeIn: 'all', - name: 'All', - savedObject: { - all: [], - read: [], - }, - // using variables with underscores here otherwise when we retrieve them from the kibana - // capabilities in a hook I get type errors regarding boolean | ReadOnly<{[x: string]: boolean}> - ui: ['crud_cases', 'read_cases'], // uiCapabilities.siem.crud_cases - cases: { - all: [APP_ID], - }, - }, - { - id: 'cases_read', - includeIn: 'read', - name: 'Read', - savedObject: { - all: [], - read: [], - }, - // using variables with underscores here otherwise when we retrieve them from the kibana - // capabilities in a hook I get type errors regarding boolean | ReadOnly<{[x: string]: boolean}> - ui: ['read_cases'], // uiCapabilities.siem.read_cases - cases: { - read: [APP_ID], - }, - }, - ], +export const getCasesKibanaFeature = (): KibanaFeatureConfig => ({ + id: CASES_FEATURE_ID, + name: i18n.translate('xpack.securitySolution.featureRegistry.linkSecuritySolutionCaseTitle', { + defaultMessage: 'Cases', + }), + order: 1100, + category: DEFAULT_APP_CATEGORIES.security, + app: [CASES_FEATURE_ID, 'kibana'], + catalogue: [APP_ID], + cases: [APP_ID], + privileges: { + all: { + app: [CASES_FEATURE_ID, 'kibana'], + catalogue: [APP_ID], + cases: { + all: [APP_ID], + }, + api: [], + savedObject: { + all: [], + read: [], + }, + ui: ['crud_cases', 'read_cases'], // uiCapabilities[CASES_FEATURE_ID].crud_cases or read_cases }, - ], -}; + read: { + app: [CASES_FEATURE_ID, 'kibana'], + catalogue: [APP_ID], + cases: { + read: [APP_ID], + }, + api: [], + savedObject: { + all: [], + read: [], + }, + ui: ['read_cases'], // uiCapabilities[CASES_FEATURE_ID].read_cases + }, + }, +}); export const getAlertsSubFeature = (ruleTypes: string[]): SubFeatureConfig => ({ name: i18n.translate('xpack.securitySolution.featureRegistry.manageAlertsName', { @@ -108,18 +107,17 @@ export const getKibanaPrivilegesFeaturePrivileges = (ruleTypes: string[]): Kiban order: 1100, category: DEFAULT_APP_CATEGORIES.security, app: [APP_ID, 'kibana'], - catalogue: ['securitySolution'], + catalogue: [APP_ID], management: { insightsAndAlerting: ['triggersActions'], }, alerting: ruleTypes, - cases: [APP_ID], - subFeatures: [{ ...CASES_SUB_FEATURE } /* , { ...getAlertsSubFeature(ruleTypes) } */], + subFeatures: [], privileges: { all: { app: [APP_ID, 'kibana'], - catalogue: ['securitySolution'], - api: ['securitySolution', 'lists-all', 'lists-read', 'rac'], + catalogue: [APP_ID], + api: [APP_ID, 'lists-all', 'lists-read', 'rac'], savedObject: { all: ['alert', 'exception-list', 'exception-list-agnostic', ...savedObjectTypes], read: [], @@ -136,8 +134,8 @@ export const getKibanaPrivilegesFeaturePrivileges = (ruleTypes: string[]): Kiban }, read: { app: [APP_ID, 'kibana'], - catalogue: ['securitySolution'], - api: ['securitySolution', 'lists-read', 'rac'], + catalogue: [APP_ID], + api: [APP_ID, 'lists-read', 'rac'], savedObject: { all: [], read: ['exception-list', 'exception-list-agnostic', ...savedObjectTypes], diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/detections_admin/detections_role.json b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/detections_admin/detections_role.json index ef3a3bef324f9..7d81897708422 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/detections_admin/detections_role.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/detections_admin/detections_role.json @@ -33,6 +33,7 @@ "feature": { "ml": ["all"], "siem": ["all", "read_alerts", "crud_alerts"], + "securitySolutionCases": ["all"], "actions": ["read"], "builtInAlerts": ["all"], "dev_tools": ["all"] diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/hunter/detections_role.json b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/hunter/detections_role.json index f9d2c68e6878a..34d8b7b4d4446 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/hunter/detections_role.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/hunter/detections_role.json @@ -38,6 +38,7 @@ "feature": { "ml": ["read"], "siem": ["all", "read_alerts", "crud_alerts"], + "securitySolutionCases": ["all"], "actions": ["read"], "builtInAlerts": ["all"] }, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/platform_engineer/detections_role.json b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/platform_engineer/detections_role.json index 5c6188b053d20..f6b31d4b3ed81 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/platform_engineer/detections_role.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/platform_engineer/detections_role.json @@ -33,6 +33,7 @@ "feature": { "ml": ["all"], "siem": ["all", "read_alerts", "crud_alerts"], + "securitySolutionCases": ["all"], "actions": ["all"], "builtInAlerts": ["all"] }, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/reader/detections_role.json b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/reader/detections_role.json index d04251542d11b..4cfc6a3ec80ed 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/reader/detections_role.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/reader/detections_role.json @@ -26,6 +26,7 @@ "feature": { "ml": ["read"], "siem": ["read", "read_alerts"], + "securitySolutionCases": ["read"], "actions": ["read"], "builtInAlerts": ["read"] }, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/rule_author/detections_role.json b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/rule_author/detections_role.json index f7b8818d6c004..a23aec6d6e403 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/rule_author/detections_role.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/rule_author/detections_role.json @@ -36,6 +36,7 @@ "feature": { "ml": ["read"], "siem": ["all", "read_alerts", "crud_alerts"], + "securitySolutionCases": ["all"], "actions": ["read"], "builtInAlerts": ["all"] }, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/soc_manager/detections_role.json b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/soc_manager/detections_role.json index 324fb2737f24f..da855c6926438 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/soc_manager/detections_role.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/soc_manager/detections_role.json @@ -36,6 +36,7 @@ "feature": { "ml": ["read"], "siem": ["all", "read_alerts", "crud_alerts"], + "securitySolutionCases": ["all"], "actions": ["all"], "builtInAlerts": ["all"] }, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/t1_analyst/detections_role.json b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/t1_analyst/detections_role.json index 90232bdb53fed..a63d0186a2a91 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/t1_analyst/detections_role.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/t1_analyst/detections_role.json @@ -26,6 +26,7 @@ "feature": { "ml": ["read"], "siem": ["read", "read_alerts"], + "securitySolutionCases": ["read"], "actions": ["read"], "builtInAlerts": ["read"] }, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/t2_analyst/detections_role.json b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/t2_analyst/detections_role.json index 9885ba0ee610b..de1ff5af99c1b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/t2_analyst/detections_role.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/t2_analyst/detections_role.json @@ -28,6 +28,7 @@ "feature": { "ml": ["read"], "siem": ["read", "read_alerts"], + "securitySolutionCases": ["read"], "actions": ["read"], "builtInAlerts": ["read"] }, diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index 59bf5057f2796..391beb3c40121 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -100,7 +100,7 @@ import aadFieldConversion from './lib/detection_engine/routes/index/signal_aad_m import { alertsFieldMap } from './lib/detection_engine/rule_types/field_maps/alerts'; import { rulesFieldMap } from './lib/detection_engine/rule_types/field_maps/rules'; import { RuleExecutionLogClient } from './lib/detection_engine/rule_execution_log/rule_execution_log_client'; -import { getKibanaPrivilegesFeaturePrivileges } from './features'; +import { getKibanaPrivilegesFeaturePrivileges, getCasesKibanaFeature } from './features'; import { EndpointMetadataService } from './endpoint/services/metadata'; import { CreateRuleOptions } from './lib/detection_engine/rule_types/types'; import { ctiFieldMap } from './lib/detection_engine/rule_types/field_maps/cti'; @@ -305,6 +305,7 @@ export class Plugin implements IPlugin { const esArchiver = getService('esArchiver'); const pageObjects = getPageObjects(['common', 'infraHome', 'infraSavedViews']); - // FLAKY: See https://github.com/elastic/kibana/issues/106650 - describe.skip('Home page', function () { + describe('Home page', function () { this.tags('includeFirefox'); before(async () => { await esArchiver.load('x-pack/test/functional/es_archives/empty_kibana'); diff --git a/x-pack/test/functional/page_objects/infra_home_page.ts b/x-pack/test/functional/page_objects/infra_home_page.ts index 09941c129c819..0790c694e772c 100644 --- a/x-pack/test/functional/page_objects/infra_home_page.ts +++ b/x-pack/test/functional/page_objects/infra_home_page.ts @@ -24,6 +24,7 @@ export function InfraHomePageProvider({ getService, getPageObjects }: FtrProvide ); await datePickerInput.clearValueWithKeyboard({ charByChar: true }); await datePickerInput.type([time, browser.keys.RETURN]); + await this.waitForLoading(); }, async getWaffleMap() { diff --git a/x-pack/test/functional/services/ml/data_visualizer_table.ts b/x-pack/test/functional/services/ml/data_visualizer_table.ts index 2f67a9b75e3d6..8094f0ad1f8d2 100644 --- a/x-pack/test/functional/services/ml/data_visualizer_table.ts +++ b/x-pack/test/functional/services/ml/data_visualizer_table.ts @@ -110,11 +110,11 @@ export function MachineLearningDataVisualizerTableProvider( if (!(await testSubjects.exists(this.detailsSelector(fieldName)))) { const selector = this.rowSelector( fieldName, - `dataVisualizerDetailsToggle-${fieldName}-arrowDown` + `dataVisualizerDetailsToggle-${fieldName}-arrowRight` ); await testSubjects.click(selector); await testSubjects.existOrFail( - this.rowSelector(fieldName, `dataVisualizerDetailsToggle-${fieldName}-arrowUp`), + this.rowSelector(fieldName, `dataVisualizerDetailsToggle-${fieldName}-arrowDown`), { timeout: 1000, } @@ -128,10 +128,10 @@ export function MachineLearningDataVisualizerTableProvider( await retry.tryForTime(10000, async () => { if (await testSubjects.exists(this.detailsSelector(fieldName))) { await testSubjects.click( - this.rowSelector(fieldName, `dataVisualizerDetailsToggle-${fieldName}-arrowUp`) + this.rowSelector(fieldName, `dataVisualizerDetailsToggle-${fieldName}-arrowDown`) ); await testSubjects.existOrFail( - this.rowSelector(fieldName, `dataVisualizerDetailsToggle-${fieldName}-arrowDown`), + this.rowSelector(fieldName, `dataVisualizerDetailsToggle-${fieldName}-arrowRight`), { timeout: 1000, } @@ -150,7 +150,7 @@ export function MachineLearningDataVisualizerTableProvider( const docCount = await testSubjects.getVisibleText(docCountFormattedSelector); expect(docCount).to.eql( docCountFormatted, - `Expected field document count to be '${docCountFormatted}' (got '${docCount}')` + `Expected field ${fieldName}'s document count to be '${docCountFormatted}' (got '${docCount}')` ); } diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alert_create_flyout.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alert_create_flyout.ts index adad80874dbc9..fa144bd5bf9f6 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alert_create_flyout.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alert_create_flyout.ts @@ -103,7 +103,8 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await testSubjects.click('test.always-firing-SelectOption'); } - describe('create alert', function () { + // FLAKY https://github.com/elastic/kibana/issues/112749 + describe.skip('create alert', function () { before(async () => { await pageObjects.common.navigateToApp('triggersActions'); await testSubjects.click('rulesTab');