From 2cbedcd29c361e0f05021ef9fdae7c43d236b529 Mon Sep 17 00:00:00 2001 From: Chris Cowan Date: Thu, 19 May 2022 07:54:36 -0600 Subject: [PATCH 01/35] [Observability] Use Observability rule type registry for list of rule types (#132484) --- .../observability/public/hooks/use_fetch_rules.ts | 14 ++++++++------ .../alerts/containers/alerts_page/alerts_page.tsx | 5 ++--- .../observability/public/pages/rules/config.ts | 14 -------------- .../create_observability_rule_type_registry.ts | 1 + 4 files changed, 11 insertions(+), 23 deletions(-) diff --git a/x-pack/plugins/observability/public/hooks/use_fetch_rules.ts b/x-pack/plugins/observability/public/hooks/use_fetch_rules.ts index 229a54c754e4f..b8c3445fffabc 100644 --- a/x-pack/plugins/observability/public/hooks/use_fetch_rules.ts +++ b/x-pack/plugins/observability/public/hooks/use_fetch_rules.ts @@ -10,8 +10,8 @@ import { isEmpty } from 'lodash'; import { loadRules, loadRuleTags } from '@kbn/triggers-actions-ui-plugin/public'; import { RULES_LOAD_ERROR, RULE_TAGS_LOAD_ERROR } from '../pages/rules/translations'; import { FetchRulesProps, RuleState, TagsState } from '../pages/rules/types'; -import { OBSERVABILITY_RULE_TYPES } from '../pages/rules/config'; import { useKibana } from '../utils/kibana_react'; +import { usePluginContext } from './use_plugin_context'; export function useFetchRules({ searchText, @@ -24,6 +24,7 @@ export function useFetchRules({ sort, }: FetchRulesProps) { const { http } = useKibana().services; + const { observabilityRuleTypeRegistry } = usePluginContext(); const [rulesState, setRulesState] = useState({ isLoading: false, @@ -60,7 +61,7 @@ export function useFetchRules({ http, page, searchText, - typesFilter: typesFilter.length > 0 ? typesFilter : OBSERVABILITY_RULE_TYPES, + typesFilter: typesFilter.length > 0 ? typesFilter : observabilityRuleTypeRegistry.list(), tagsFilter, ruleExecutionStatusesFilter: ruleLastResponseFilter, ruleStatusesFilter, @@ -93,14 +94,15 @@ export function useFetchRules({ }, [ http, page, - setPage, searchText, - ruleLastResponseFilter, + typesFilter, + observabilityRuleTypeRegistry, tagsFilter, - loadRuleTagsAggs, + ruleLastResponseFilter, ruleStatusesFilter, - typesFilter, sort, + loadRuleTagsAggs, + setPage, ]); useEffect(() => { fetchRules(); diff --git a/x-pack/plugins/observability/public/pages/alerts/containers/alerts_page/alerts_page.tsx b/x-pack/plugins/observability/public/pages/alerts/containers/alerts_page/alerts_page.tsx index 8838ccd2ac56f..f51d00787c822 100644 --- a/x-pack/plugins/observability/public/pages/alerts/containers/alerts_page/alerts_page.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/containers/alerts_page/alerts_page.tsx @@ -38,7 +38,6 @@ import './styles.scss'; import { AlertsStatusFilter, AlertsDisclaimer, AlertsSearchBar } from '../../components'; import { renderRuleStats } from '../../components/rule_stats'; import { ObservabilityAppServices } from '../../../../application/types'; -import { OBSERVABILITY_RULE_TYPES } from '../../../rules/config'; interface RuleStatsState { total: number; @@ -69,7 +68,7 @@ const ALERT_STATUS_REGEX = new RegExp( const ALERT_TABLE_STATE_STORAGE_KEY = 'xpack.observability.alert.tableState'; function AlertsPage() { - const { ObservabilityPageTemplate, config } = usePluginContext(); + const { ObservabilityPageTemplate, config, observabilityRuleTypeRegistry } = usePluginContext(); const [alertFilterStatus, setAlertFilterStatus] = useState('' as AlertStatusFilterButton); const refetch = useRef<() => void>(); const timefilterService = useTimefilterService(); @@ -110,7 +109,7 @@ function AlertsPage() { try { const response = await loadRuleAggregations({ http, - typesFilter: OBSERVABILITY_RULE_TYPES, + typesFilter: observabilityRuleTypeRegistry.list(), }); const { ruleExecutionStatus, ruleMutedStatus, ruleEnabledStatus, ruleSnoozedStatus } = response; diff --git a/x-pack/plugins/observability/public/pages/rules/config.ts b/x-pack/plugins/observability/public/pages/rules/config.ts index 4e7b9e83d5ab1..de3ef1219fde7 100644 --- a/x-pack/plugins/observability/public/pages/rules/config.ts +++ b/x-pack/plugins/observability/public/pages/rules/config.ts @@ -42,20 +42,6 @@ export const rulesStatusesTranslationsMapping = { warning: RULE_STATUS_WARNING, }; -export const OBSERVABILITY_RULE_TYPES = [ - 'xpack.uptime.alerts.monitorStatus', - 'xpack.uptime.alerts.tls', - 'xpack.uptime.alerts.tlsCertificate', - 'xpack.uptime.alerts.durationAnomaly', - 'apm.error_rate', - 'apm.transaction_error_rate', - 'apm.anomaly', - 'apm.transaction_duration', - 'metrics.alert.inventory.threshold', - 'metrics.alert.threshold', - 'logs.alert.document.count', -]; - export const OBSERVABILITY_SOLUTIONS = ['logs', 'uptime', 'infrastructure', 'apm']; export type InitialRule = Partial & diff --git a/x-pack/plugins/observability/public/rules/create_observability_rule_type_registry.ts b/x-pack/plugins/observability/public/rules/create_observability_rule_type_registry.ts index 5612601ebd803..021203e832441 100644 --- a/x-pack/plugins/observability/public/rules/create_observability_rule_type_registry.ts +++ b/x-pack/plugins/observability/public/rules/create_observability_rule_type_registry.ts @@ -35,6 +35,7 @@ export function createObservabilityRuleTypeRegistry(ruleTypeRegistry: RuleTypeRe getFormatter: (typeId: string) => { return formatters.find((formatter) => formatter.typeId === typeId)?.fn; }, + list: () => formatters.map((formatter) => formatter.typeId), }; } From 51acefc2e2ce8fdfcc26376143c9cbf263f54734 Mon Sep 17 00:00:00 2001 From: Caroline Horn <549577+cchaos@users.noreply.github.com> Date: Thu, 19 May 2022 10:01:13 -0400 Subject: [PATCH 02/35] [KibanaPageTemplateSolutionNavAvatar] Increase specificity of styles (#132448) --- .../public/page_template/solution_nav/solution_nav_avatar.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/kibana_react/public/page_template/solution_nav/solution_nav_avatar.scss b/src/plugins/kibana_react/public/page_template/solution_nav/solution_nav_avatar.scss index 4b47fefc65891..73b4241c8a18b 100644 --- a/src/plugins/kibana_react/public/page_template/solution_nav/solution_nav_avatar.scss +++ b/src/plugins/kibana_react/public/page_template/solution_nav/solution_nav_avatar.scss @@ -1,7 +1,7 @@ .kbnPageTemplateSolutionNavAvatar { @include euiBottomShadowSmall; - &--xxl { + &.kbnPageTemplateSolutionNavAvatar--xxl { @include euiBottomShadowMedium; @include size(100px); line-height: 100px; From d2b61738e2b086a62944ee822670821220b3ad81 Mon Sep 17 00:00:00 2001 From: Vitalii Dmyterko <92328789+vitaliidm@users.noreply.github.com> Date: Thu, 19 May 2022 15:09:31 +0100 Subject: [PATCH 03/35] [Security solution]Dynamic split of cypress tests (#125986) - adds `parallelism: 4` for security_solution cypress buildkite pipeline - added parsing /integrations folder with cypress tests, to retrieve paths to individual test files using `glob` utility - list of test files split equally between agents(there are approx 70+ tests files, split ~20 per job with **parallelism=4**) - small refactoring of existing cypress runners for `security_solution` Old metrics(before @MadameSheema https://github.com/elastic/kibana/pull/127558 performance improvements): before split: average time of completion ~ 1h 40m for tests, 1h 55m for Kibana build after split in 4 chunks: chunk completion between 20m - 30m, Kibana build 1h 20m **Current metrics:** before split: average time of completion ~ 1h for tests, 1h 10m for Kibana build after split in 4 chunks: each chunk completion between 10m - 20m, 1h Kibana build 1h --- .buildkite/ftr_configs.yml | 1 + .../pull_request/security_solution.yml | 1 + .../steps/functional/security_solution.sh | 6 +- .../security_solution/cypress/README.md | 12 ++ .../integration/users/user_details.spec.ts | 5 +- x-pack/plugins/security_solution/package.json | 3 +- .../cli_config_parallel.ts | 25 ++++ .../test/security_solution_cypress/runner.ts | 140 ++++++------------ 8 files changed, 90 insertions(+), 103 deletions(-) create mode 100644 x-pack/test/security_solution_cypress/cli_config_parallel.ts diff --git a/.buildkite/ftr_configs.yml b/.buildkite/ftr_configs.yml index f07ac997e31c2..e070baa844ea9 100644 --- a/.buildkite/ftr_configs.yml +++ b/.buildkite/ftr_configs.yml @@ -26,6 +26,7 @@ disabled: - x-pack/test/security_solution_cypress/cases_cli_config.ts - x-pack/test/security_solution_cypress/ccs_config.ts - x-pack/test/security_solution_cypress/cli_config.ts + - x-pack/test/security_solution_cypress/cli_config_parallel.ts - x-pack/test/security_solution_cypress/config.firefox.ts - x-pack/test/security_solution_cypress/config.ts - x-pack/test/security_solution_cypress/response_ops_cli_config.ts diff --git a/.buildkite/pipelines/pull_request/security_solution.yml b/.buildkite/pipelines/pull_request/security_solution.yml index 974469a700715..5903aac568a83 100644 --- a/.buildkite/pipelines/pull_request/security_solution.yml +++ b/.buildkite/pipelines/pull_request/security_solution.yml @@ -5,6 +5,7 @@ steps: queue: ci-group-6 depends_on: build timeout_in_minutes: 120 + parallelism: 4 retry: automatic: - exit_status: '*' diff --git a/.buildkite/scripts/steps/functional/security_solution.sh b/.buildkite/scripts/steps/functional/security_solution.sh index ae81eaa4f48e2..5e3b1513826f9 100755 --- a/.buildkite/scripts/steps/functional/security_solution.sh +++ b/.buildkite/scripts/steps/functional/security_solution.sh @@ -5,11 +5,13 @@ set -euo pipefail source .buildkite/scripts/steps/functional/common.sh export JOB=kibana-security-solution-chrome +export CLI_NUMBER=$((BUILDKITE_PARALLEL_JOB+1)) +export CLI_COUNT=$BUILDKITE_PARALLEL_JOB_COUNT echo "--- Security Solution tests (Chrome)" -checks-reporter-with-killswitch "Security Solution Cypress Tests (Chrome)" \ +checks-reporter-with-killswitch "Security Solution Cypress Tests (Chrome) $CLI_NUMBER" \ node scripts/functional_tests \ --debug --bail \ --kibana-install-dir "$KIBANA_BUILD_LOCATION" \ - --config x-pack/test/security_solution_cypress/cli_config.ts + --config x-pack/test/security_solution_cypress/cli_config_parallel.ts diff --git a/x-pack/plugins/security_solution/cypress/README.md b/x-pack/plugins/security_solution/cypress/README.md index e0430ea332e99..620a2148f6cf7 100644 --- a/x-pack/plugins/security_solution/cypress/README.md +++ b/x-pack/plugins/security_solution/cypress/README.md @@ -64,6 +64,18 @@ A headless browser is a browser simulation program that does not have a user int This is the configuration used by CI. It uses the FTR to spawn both a Kibana instance (http://localhost:5620) and an Elasticsearch instance (http://localhost:9220) with a preloaded minimum set of data (see preceding "Test data" section), and then executes cypress against this stack. You can find this configuration in `x-pack/test/security_solution_cypress` +Tests run on buildkite PR pipeline is parallelized(current value = 4 parallel jobs). It can be configured in [.buildkite/pipelines/pull_request/security_solution.yml](https://github.com/elastic/kibana/blob/main/.buildkite/pipelines/pull_request/security_solution.yml) with property `parallelism` + +```yml + ... + agents: + queue: ci-group-6 + depends_on: build + timeout_in_minutes: 120 + parallelism: 4 + ... +``` + #### Custom Targets This configuration runs cypress tests against an arbitrary host. diff --git a/x-pack/plugins/security_solution/cypress/integration/users/user_details.spec.ts b/x-pack/plugins/security_solution/cypress/integration/users/user_details.spec.ts index c1b4a81e14d0a..83eae1d259b2c 100644 --- a/x-pack/plugins/security_solution/cypress/integration/users/user_details.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/users/user_details.spec.ts @@ -24,13 +24,14 @@ describe('user details flyout', () => { before(() => { cleanKibana(); login(); + }); + + it('shows user detail flyout from alert table', () => { visitWithoutDateRange(ALERTS_URL); createCustomRuleEnabled({ ...getNewRule(), customQuery: 'user.name:*' }); refreshPage(); waitForAlertsToPopulate(); - }); - it('shows user detail flyout from alert table', () => { scrollAlertTableColumnIntoView(USER_COLUMN); expandAlertTableCellValue(USER_COLUMN); openUserDetailsFlyout(); diff --git a/x-pack/plugins/security_solution/package.json b/x-pack/plugins/security_solution/package.json index b62b6d08fd892..8853cb9aa582c 100644 --- a/x-pack/plugins/security_solution/package.json +++ b/x-pack/plugins/security_solution/package.json @@ -13,12 +13,13 @@ "cypress:open-as-ci": "node ../../../scripts/functional_tests --config ../../test/security_solution_cypress/visual_config.ts", "cypress:open:upgrade": "yarn cypress:open --config integrationFolder=./cypress/upgrade_integration", "cypress:run": "yarn cypress:run:reporter --browser chrome --spec './cypress/integration/**/*.spec.ts'; status=$?; yarn junit:merge && exit $status", + "cypress:run:spec": "yarn cypress:run:reporter --browser chrome --spec ${SPEC_LIST:-'./cypress/integration/**/*.spec.ts'}; status=$?; yarn junit:merge && exit $status", "cypress:run:cases": "yarn cypress:run:reporter --browser chrome --spec './cypress/integration/cases/*.spec.ts'; status=$?; yarn junit:merge && exit $status", "cypress:run:firefox": "yarn cypress:run:reporter --browser firefox --spec './cypress/integration/**/*.spec.ts'; status=$?; yarn junit:merge && exit $status", "cypress:run:reporter": "yarn cypress run --config-file ./cypress/cypress.json --reporter ../../../node_modules/cypress-multi-reporters --reporter-options configFile=./cypress/reporter_config.json", "cypress:run:respops": "yarn cypress:run:reporter --browser chrome --spec ./cypress/integration/detection_alerts/*.spec.ts,./cypress/integration/detection_rules/*.spec.ts,./cypress/integration/exceptions/*.spec.ts; status=$?; yarn junit:merge && exit $status", "cypress:run:ccs": "yarn cypress:run:reporter --browser chrome --config integrationFolder=./cypress/ccs_integration; status=$?; yarn junit:merge && exit $status", - "cypress:run-as-ci": "node --max-old-space-size=2048 ../../../scripts/functional_tests --config ../../test/security_solution_cypress/cli_config.ts", + "cypress:run-as-ci": "node --max-old-space-size=2048 ../../../scripts/functional_tests --config ../../test/security_solution_cypress/cli_config_parallel.ts", "cypress:run-as-ci:firefox": "node --max-old-space-size=2048 ../../../scripts/functional_tests --config ../../test/security_solution_cypress/config.firefox.ts", "cypress:run:upgrade": "yarn cypress:run:reporter --browser chrome --config integrationFolder=./cypress/upgrade_integration", "cypress:run:upgrade:old": "yarn cypress:run:reporter --browser chrome --config integrationFolder=./cypress/upgrade_integration --spec ./cypress/upgrade_integration/threat_hunting/**/*.spec.ts,./cypress/upgrade_integration/detections/**/custom_query_rule.spec.ts; status=$?; yarn junit:merge && exit $status", diff --git a/x-pack/test/security_solution_cypress/cli_config_parallel.ts b/x-pack/test/security_solution_cypress/cli_config_parallel.ts new file mode 100644 index 0000000000000..20abaed99a1b9 --- /dev/null +++ b/x-pack/test/security_solution_cypress/cli_config_parallel.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrConfigProviderContext } from '@kbn/test'; +import { FtrProviderContext } from './ftr_provider_context'; + +import { SecuritySolutionCypressCliTestRunnerCI } from './runner'; + +const cliNumber = parseInt(process.env.CLI_NUMBER ?? '1', 10); +const cliCount = parseInt(process.env.CLI_COUNT ?? '1', 10); + +// eslint-disable-next-line import/no-default-export +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const securitySolutionCypressConfig = await readConfigFile(require.resolve('./config.ts')); + return { + ...securitySolutionCypressConfig.getAll(), + + testRunner: (context: FtrProviderContext) => + SecuritySolutionCypressCliTestRunnerCI(context, cliCount, cliNumber), + }; +} diff --git a/x-pack/test/security_solution_cypress/runner.ts b/x-pack/test/security_solution_cypress/runner.ts index 2c4b69799f1cc..2f4f76de53ced 100644 --- a/x-pack/test/security_solution_cypress/runner.ts +++ b/x-pack/test/security_solution_cypress/runner.ts @@ -5,7 +5,10 @@ * 2.0. */ +import { chunk } from 'lodash'; import { resolve } from 'path'; +import glob from 'glob'; + import Url from 'url'; import { withProcRunner } from '@kbn/dev-proc-runner'; @@ -13,7 +16,22 @@ import { withProcRunner } from '@kbn/dev-proc-runner'; import semver from 'semver'; import { FtrProviderContext } from './ftr_provider_context'; -export async function SecuritySolutionCypressCliTestRunner({ getService }: FtrProviderContext) { +const retrieveIntegrations = (chunksTotal: number, chunkIndex: number) => { + const pattern = resolve( + __dirname, + '../../plugins/security_solution/cypress/integration/**/*.spec.ts' + ); + const integrationsPaths = glob.sync(pattern); + const chunkSize = Math.ceil(integrationsPaths.length / chunksTotal); + + return chunk(integrationsPaths, chunkSize)[chunkIndex - 1]; +}; + +export async function SecuritySolutionConfigurableCypressTestRunner( + { getService }: FtrProviderContext, + command: string, + envVars?: Record +) { const log = getService('log'); const config = getService('config'); const esArchiver = getService('esArchiver'); @@ -23,7 +41,7 @@ export async function SecuritySolutionCypressCliTestRunner({ getService }: FtrPr await withProcRunner(log, async (procs) => { await procs.run('cypress', { cmd: 'yarn', - args: ['cypress:run'], + args: [command], cwd: resolve(__dirname, '../../plugins/security_solution'), env: { FORCE_COLOR: '1', @@ -32,91 +50,42 @@ export async function SecuritySolutionCypressCliTestRunner({ getService }: FtrPr CYPRESS_ELASTICSEARCH_USERNAME: config.get('servers.elasticsearch.username'), CYPRESS_ELASTICSEARCH_PASSWORD: config.get('servers.elasticsearch.password'), ...process.env, + ...envVars, }, wait: true, }); }); } -export async function SecuritySolutionCypressCliResponseOpsTestRunner({ - getService, -}: FtrProviderContext) { - const log = getService('log'); - const config = getService('config'); - const esArchiver = getService('esArchiver'); - - await esArchiver.load('x-pack/test/security_solution_cypress/es_archives/auditbeat'); - - await withProcRunner(log, async (procs) => { - await procs.run('cypress', { - cmd: 'yarn', - args: ['cypress:run:respops'], - cwd: resolve(__dirname, '../../plugins/security_solution'), - env: { - FORCE_COLOR: '1', - CYPRESS_BASE_URL: Url.format(config.get('servers.kibana')), - CYPRESS_ELASTICSEARCH_URL: Url.format(config.get('servers.elasticsearch')), - CYPRESS_ELASTICSEARCH_USERNAME: config.get('servers.elasticsearch.username'), - CYPRESS_ELASTICSEARCH_PASSWORD: config.get('servers.elasticsearch.password'), - ...process.env, - }, - wait: true, - }); +export async function SecuritySolutionCypressCliTestRunnerCI( + context: FtrProviderContext, + totalCiJobs: number, + ciJobNumber: number +) { + const integrations = retrieveIntegrations(totalCiJobs, ciJobNumber); + return SecuritySolutionConfigurableCypressTestRunner(context, 'cypress:run:spec', { + SPEC_LIST: integrations.join(','), }); } -export async function SecuritySolutionCypressCliCasesTestRunner({ - getService, -}: FtrProviderContext) { - const log = getService('log'); - const config = getService('config'); - const esArchiver = getService('esArchiver'); - - await esArchiver.load('x-pack/test/security_solution_cypress/es_archives/auditbeat'); +export async function SecuritySolutionCypressCliResponseOpsTestRunner(context: FtrProviderContext) { + return SecuritySolutionConfigurableCypressTestRunner(context, 'cypress:run:respops'); +} - await withProcRunner(log, async (procs) => { - await procs.run('cypress', { - cmd: 'yarn', - args: ['cypress:run:cases'], - cwd: resolve(__dirname, '../../plugins/security_solution'), - env: { - FORCE_COLOR: '1', - CYPRESS_BASE_URL: Url.format(config.get('servers.kibana')), - CYPRESS_ELASTICSEARCH_URL: Url.format(config.get('servers.elasticsearch')), - CYPRESS_ELASTICSEARCH_USERNAME: config.get('servers.elasticsearch.username'), - CYPRESS_ELASTICSEARCH_PASSWORD: config.get('servers.elasticsearch.password'), - ...process.env, - }, - wait: true, - }); - }); +export async function SecuritySolutionCypressCliCasesTestRunner(context: FtrProviderContext) { + return SecuritySolutionConfigurableCypressTestRunner(context, 'cypress:run:cases'); } -export async function SecuritySolutionCypressCliFirefoxTestRunner({ - getService, -}: FtrProviderContext) { - const log = getService('log'); - const config = getService('config'); - const esArchiver = getService('esArchiver'); +export async function SecuritySolutionCypressCliTestRunner(context: FtrProviderContext) { + return SecuritySolutionConfigurableCypressTestRunner(context, 'cypress:run'); +} - await esArchiver.load('x-pack/test/security_solution_cypress/es_archives/auditbeat'); +export async function SecuritySolutionCypressCliFirefoxTestRunner(context: FtrProviderContext) { + return SecuritySolutionConfigurableCypressTestRunner(context, 'cypress:run:firefox'); +} - await withProcRunner(log, async (procs) => { - await procs.run('cypress', { - cmd: 'yarn', - args: ['cypress:run:firefox'], - cwd: resolve(__dirname, '../../plugins/security_solution'), - env: { - FORCE_COLOR: '1', - CYPRESS_BASE_URL: Url.format(config.get('servers.kibana')), - CYPRESS_ELASTICSEARCH_URL: Url.format(config.get('servers.elasticsearch')), - CYPRESS_ELASTICSEARCH_USERNAME: config.get('servers.elasticsearch.username'), - CYPRESS_ELASTICSEARCH_PASSWORD: config.get('servers.elasticsearch.password'), - ...process.env, - }, - wait: true, - }); - }); +export async function SecuritySolutionCypressVisualTestRunner(context: FtrProviderContext) { + return SecuritySolutionConfigurableCypressTestRunner(context, 'cypress:open'); } export async function SecuritySolutionCypressCcsTestRunner({ getService }: FtrProviderContext) { @@ -143,31 +112,6 @@ export async function SecuritySolutionCypressCcsTestRunner({ getService }: FtrPr }); } -export async function SecuritySolutionCypressVisualTestRunner({ getService }: FtrProviderContext) { - const log = getService('log'); - const config = getService('config'); - const esArchiver = getService('esArchiver'); - - await esArchiver.load('x-pack/test/security_solution_cypress/es_archives/auditbeat'); - - await withProcRunner(log, async (procs) => { - await procs.run('cypress', { - cmd: 'yarn', - args: ['cypress:open'], - cwd: resolve(__dirname, '../../plugins/security_solution'), - env: { - FORCE_COLOR: '1', - CYPRESS_BASE_URL: Url.format(config.get('servers.kibana')), - CYPRESS_ELASTICSEARCH_URL: Url.format(config.get('servers.elasticsearch')), - CYPRESS_ELASTICSEARCH_USERNAME: config.get('servers.elasticsearch.username'), - CYPRESS_ELASTICSEARCH_PASSWORD: config.get('servers.elasticsearch.password'), - ...process.env, - }, - wait: true, - }); - }); -} - export async function SecuritySolutionCypressUpgradeCliTestRunner({ getService, }: FtrProviderContext) { From 14a8997a80613ade9b97e848f3db7ae35e9bc321 Mon Sep 17 00:00:00 2001 From: Ying Mao Date: Thu, 19 May 2022 10:13:54 -0400 Subject: [PATCH 04/35] [Response Ops] Use `active/new/recovered` alert counts in event log `execute` doc to populate exec log (#131187) * Using new metrics in event log execute * Returning version from event log docs and updating cell value based on version * Fixing types * Cleanup * Using updated event log fields * importing specific semver function * Moving to library function * Cleanup Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../alerting/common/execution_log_types.ts | 4 + .../lib/get_execution_log_aggregation.test.ts | 248 +++++++++--------- .../lib/get_execution_log_aggregation.ts | 55 ++-- .../routes/get_rule_execution_log.test.ts | 2 + .../server/routes/get_rule_execution_log.ts | 3 + .../tests/get_execution_log.test.ts | 56 ++-- .../public/application/constants/index.ts | 6 + .../components/rule_event_log_data_grid.tsx | 2 + .../components/rule_event_log_list.test.tsx | 4 + ...rule_event_log_list_cell_renderer.test.tsx | 8 + .../rule_event_log_list_cell_renderer.tsx | 9 +- .../lib/format_rule_alert_count.test.ts | 34 +++ .../common/lib/format_rule_alert_count.ts | 23 ++ 13 files changed, 277 insertions(+), 177 deletions(-) create mode 100644 x-pack/plugins/triggers_actions_ui/public/common/lib/format_rule_alert_count.test.ts create mode 100644 x-pack/plugins/triggers_actions_ui/public/common/lib/format_rule_alert_count.ts diff --git a/x-pack/plugins/alerting/common/execution_log_types.ts b/x-pack/plugins/alerting/common/execution_log_types.ts index 4fff1f14ca5bd..cdfc7601190dd 100644 --- a/x-pack/plugins/alerting/common/execution_log_types.ts +++ b/x-pack/plugins/alerting/common/execution_log_types.ts @@ -13,6 +13,9 @@ export const executionLogSortableColumns = [ 'schedule_delay', 'num_triggered_actions', 'num_generated_actions', + 'num_active_alerts', + 'num_recovered_alerts', + 'num_new_alerts', ] as const; export type ExecutionLogSortFields = typeof executionLogSortableColumns[number]; @@ -23,6 +26,7 @@ export interface IExecutionLog { duration_ms: number; status: string; message: string; + version: string; num_active_alerts: number; num_new_alerts: number; num_recovered_alerts: number; diff --git a/x-pack/plugins/alerting/server/lib/get_execution_log_aggregation.test.ts b/x-pack/plugins/alerting/server/lib/get_execution_log_aggregation.test.ts index 6927ef86dd47c..f5be4f0fcd34e 100644 --- a/x-pack/plugins/alerting/server/lib/get_execution_log_aggregation.test.ts +++ b/x-pack/plugins/alerting/server/lib/get_execution_log_aggregation.test.ts @@ -83,7 +83,7 @@ describe('getExecutionLogAggregation', () => { sort: [{ notsortable: { order: 'asc' } }], }); }).toThrowErrorMatchingInlineSnapshot( - `"Invalid sort field \\"notsortable\\" - must be one of [timestamp,execution_duration,total_search_duration,es_search_duration,schedule_delay,num_triggered_actions,num_generated_actions]"` + `"Invalid sort field \\"notsortable\\" - must be one of [timestamp,execution_duration,total_search_duration,es_search_duration,schedule_delay,num_triggered_actions,num_generated_actions,num_active_alerts,num_recovered_alerts,num_new_alerts]"` ); }); @@ -95,7 +95,7 @@ describe('getExecutionLogAggregation', () => { sort: [{ notsortable: { order: 'asc' } }, { timestamp: { order: 'asc' } }], }); }).toThrowErrorMatchingInlineSnapshot( - `"Invalid sort field \\"notsortable\\" - must be one of [timestamp,execution_duration,total_search_duration,es_search_duration,schedule_delay,num_triggered_actions,num_generated_actions]"` + `"Invalid sort field \\"notsortable\\" - must be one of [timestamp,execution_duration,total_search_duration,es_search_duration,schedule_delay,num_triggered_actions,num_generated_actions,num_active_alerts,num_recovered_alerts,num_new_alerts]"` ); }); @@ -164,15 +164,6 @@ describe('getExecutionLogAggregation', () => { gap_policy: 'insert_zeros', }, }, - alertCounts: { - filters: { - filters: { - newAlerts: { match: { 'event.action': 'new-instance' } }, - activeAlerts: { match: { 'event.action': 'active-instance' } }, - recoveredAlerts: { match: { 'event.action': 'recovered-instance' } }, - }, - }, - }, actionExecution: { filter: { bool: { @@ -216,11 +207,28 @@ describe('getExecutionLogAggregation', () => { field: 'kibana.alert.rule.execution.metrics.number_of_generated_actions', }, }, + numActiveAlerts: { + max: { + field: 'kibana.alert.rule.execution.metrics.alert_counts.active', + }, + }, + numRecoveredAlerts: { + max: { + field: 'kibana.alert.rule.execution.metrics.alert_counts.recovered', + }, + }, + numNewAlerts: { + max: { + field: 'kibana.alert.rule.execution.metrics.alert_counts.new', + }, + }, executionDuration: { max: { field: 'event.duration' } }, outcomeAndMessage: { top_hits: { size: 1, - _source: { includes: ['event.outcome', 'message', 'error.message'] }, + _source: { + includes: ['event.outcome', 'message', 'error.message', 'kibana.version'], + }, }, }, }, @@ -278,20 +286,6 @@ describe('formatExecutionLogResult', () => { meta: {}, doc_count: 0, }, - alertCounts: { - meta: {}, - buckets: { - activeAlerts: { - doc_count: 5, - }, - newAlerts: { - doc_count: 5, - }, - recoveredAlerts: { - doc_count: 0, - }, - }, - }, ruleExecution: { meta: {}, doc_count: 1, @@ -301,6 +295,15 @@ describe('formatExecutionLogResult', () => { numGeneratedActions: { value: 5.0, }, + numActiveAlerts: { + value: 5.0, + }, + numNewAlerts: { + value: 5.0, + }, + numRecoveredAlerts: { + value: 0.0, + }, outcomeAndMessage: { hits: { total: { @@ -317,6 +320,9 @@ describe('formatExecutionLogResult', () => { event: { outcome: 'success', }, + kibana: { + version: '8.2.0', + }, message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", }, @@ -363,20 +369,6 @@ describe('formatExecutionLogResult', () => { meta: {}, doc_count: 0, }, - alertCounts: { - meta: {}, - buckets: { - activeAlerts: { - doc_count: 5, - }, - newAlerts: { - doc_count: 5, - }, - recoveredAlerts: { - doc_count: 5, - }, - }, - }, ruleExecution: { meta: {}, doc_count: 1, @@ -386,6 +378,15 @@ describe('formatExecutionLogResult', () => { numGeneratedActions: { value: 5.0, }, + numActiveAlerts: { + value: 5.0, + }, + numNewAlerts: { + value: 5.0, + }, + numRecoveredAlerts: { + value: 5.0, + }, outcomeAndMessage: { hits: { total: { @@ -402,6 +403,9 @@ describe('formatExecutionLogResult', () => { event: { outcome: 'success', }, + kibana: { + version: '8.2.0', + }, message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", }, @@ -459,6 +463,7 @@ describe('formatExecutionLogResult', () => { status: 'success', message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + version: '8.2.0', num_active_alerts: 5, num_new_alerts: 5, num_recovered_alerts: 0, @@ -478,6 +483,7 @@ describe('formatExecutionLogResult', () => { status: 'success', message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + version: '8.2.0', num_active_alerts: 5, num_new_alerts: 5, num_recovered_alerts: 5, @@ -512,20 +518,6 @@ describe('formatExecutionLogResult', () => { meta: {}, doc_count: 0, }, - alertCounts: { - meta: {}, - buckets: { - activeAlerts: { - doc_count: 5, - }, - newAlerts: { - doc_count: 5, - }, - recoveredAlerts: { - doc_count: 0, - }, - }, - }, ruleExecution: { meta: {}, doc_count: 1, @@ -535,6 +527,15 @@ describe('formatExecutionLogResult', () => { numGeneratedActions: { value: 5.0, }, + numActiveAlerts: { + value: 5.0, + }, + numNewAlerts: { + value: 5.0, + }, + numRecoveredAlerts: { + value: 0.0, + }, outcomeAndMessage: { hits: { total: { @@ -551,6 +552,9 @@ describe('formatExecutionLogResult', () => { event: { outcome: 'failure', }, + kibana: { + version: '8.2.0', + }, message: "rule execution failure: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", error: { @@ -600,20 +604,6 @@ describe('formatExecutionLogResult', () => { meta: {}, doc_count: 0, }, - alertCounts: { - meta: {}, - buckets: { - activeAlerts: { - doc_count: 5, - }, - newAlerts: { - doc_count: 5, - }, - recoveredAlerts: { - doc_count: 5, - }, - }, - }, ruleExecution: { meta: {}, doc_count: 1, @@ -623,6 +613,15 @@ describe('formatExecutionLogResult', () => { numGeneratedActions: { value: 5.0, }, + numActiveAlerts: { + value: 5.0, + }, + numNewAlerts: { + value: 5.0, + }, + numRecoveredAlerts: { + value: 5.0, + }, outcomeAndMessage: { hits: { total: { @@ -639,6 +638,9 @@ describe('formatExecutionLogResult', () => { event: { outcome: 'success', }, + kibana: { + version: '8.2.0', + }, message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", }, @@ -696,6 +698,7 @@ describe('formatExecutionLogResult', () => { status: 'failure', message: "rule execution failure: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule' - I am erroring in rule execution!!", + version: '8.2.0', num_active_alerts: 5, num_new_alerts: 5, num_recovered_alerts: 0, @@ -715,6 +718,7 @@ describe('formatExecutionLogResult', () => { status: 'success', message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + version: '8.2.0', num_active_alerts: 5, num_new_alerts: 5, num_recovered_alerts: 5, @@ -749,20 +753,6 @@ describe('formatExecutionLogResult', () => { meta: {}, doc_count: 1, }, - alertCounts: { - meta: {}, - buckets: { - activeAlerts: { - doc_count: 0, - }, - newAlerts: { - doc_count: 0, - }, - recoveredAlerts: { - doc_count: 0, - }, - }, - }, ruleExecution: { meta: {}, doc_count: 1, @@ -772,6 +762,15 @@ describe('formatExecutionLogResult', () => { numGeneratedActions: { value: 0.0, }, + numActiveAlerts: { + value: 0.0, + }, + numNewAlerts: { + value: 0.0, + }, + numRecoveredAlerts: { + value: 0.0, + }, outcomeAndMessage: { hits: { total: { @@ -788,6 +787,9 @@ describe('formatExecutionLogResult', () => { event: { outcome: 'success', }, + kibana: { + version: '8.2.0', + }, message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", }, @@ -829,20 +831,6 @@ describe('formatExecutionLogResult', () => { meta: {}, doc_count: 0, }, - alertCounts: { - meta: {}, - buckets: { - activeAlerts: { - doc_count: 5, - }, - newAlerts: { - doc_count: 5, - }, - recoveredAlerts: { - doc_count: 5, - }, - }, - }, ruleExecution: { meta: {}, doc_count: 1, @@ -852,6 +840,15 @@ describe('formatExecutionLogResult', () => { numGeneratedActions: { value: 5.0, }, + numActiveAlerts: { + value: 5.0, + }, + numNewAlerts: { + value: 5.0, + }, + numRecoveredAlerts: { + value: 5.0, + }, outcomeAndMessage: { hits: { total: { @@ -868,6 +865,9 @@ describe('formatExecutionLogResult', () => { event: { outcome: 'success', }, + kibana: { + version: '8.2.0', + }, message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", }, @@ -925,6 +925,7 @@ describe('formatExecutionLogResult', () => { status: 'success', message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + version: '8.2.0', num_active_alerts: 0, num_new_alerts: 0, num_recovered_alerts: 0, @@ -944,6 +945,7 @@ describe('formatExecutionLogResult', () => { status: 'success', message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + version: '8.2.0', num_active_alerts: 5, num_new_alerts: 5, num_recovered_alerts: 5, @@ -978,20 +980,6 @@ describe('formatExecutionLogResult', () => { meta: {}, doc_count: 0, }, - alertCounts: { - meta: {}, - buckets: { - activeAlerts: { - doc_count: 5, - }, - newAlerts: { - doc_count: 5, - }, - recoveredAlerts: { - doc_count: 5, - }, - }, - }, ruleExecution: { meta: {}, doc_count: 1, @@ -1001,6 +989,15 @@ describe('formatExecutionLogResult', () => { numGeneratedActions: { value: 5.0, }, + numActiveAlerts: { + value: 5.0, + }, + numNewAlerts: { + value: 5.0, + }, + numRecoveredAlerts: { + value: 5.0, + }, outcomeAndMessage: { hits: { total: { @@ -1017,6 +1014,9 @@ describe('formatExecutionLogResult', () => { event: { outcome: 'success', }, + kibana: { + version: '8.2.0', + }, message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", }, @@ -1063,20 +1063,6 @@ describe('formatExecutionLogResult', () => { meta: {}, doc_count: 0, }, - alertCounts: { - meta: {}, - buckets: { - activeAlerts: { - doc_count: 5, - }, - newAlerts: { - doc_count: 5, - }, - recoveredAlerts: { - doc_count: 5, - }, - }, - }, ruleExecution: { meta: {}, doc_count: 1, @@ -1086,6 +1072,15 @@ describe('formatExecutionLogResult', () => { numGeneratedActions: { value: 5.0, }, + numActiveAlerts: { + value: 5.0, + }, + numNewAlerts: { + value: 5.0, + }, + numRecoveredAlerts: { + value: 5.0, + }, outcomeAndMessage: { hits: { total: { @@ -1102,6 +1097,9 @@ describe('formatExecutionLogResult', () => { event: { outcome: 'success', }, + kibana: { + version: '8.2.0', + }, message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", }, @@ -1159,6 +1157,7 @@ describe('formatExecutionLogResult', () => { status: 'success', message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + version: '8.2.0', num_active_alerts: 5, num_new_alerts: 5, num_recovered_alerts: 5, @@ -1178,6 +1177,7 @@ describe('formatExecutionLogResult', () => { status: 'success', message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + version: '8.2.0', num_active_alerts: 5, num_new_alerts: 5, num_recovered_alerts: 5, diff --git a/x-pack/plugins/alerting/server/lib/get_execution_log_aggregation.ts b/x-pack/plugins/alerting/server/lib/get_execution_log_aggregation.ts index 03e1077b02eda..aa8a7f6de88cf 100644 --- a/x-pack/plugins/alerting/server/lib/get_execution_log_aggregation.ts +++ b/x-pack/plugins/alerting/server/lib/get_execution_log_aggregation.ts @@ -20,6 +20,7 @@ const ACTION_FIELD = 'event.action'; const OUTCOME_FIELD = 'event.outcome'; const DURATION_FIELD = 'event.duration'; const MESSAGE_FIELD = 'message'; +const VERSION_FIELD = 'kibana.version'; const ERROR_MESSAGE_FIELD = 'error.message'; const SCHEDULE_DELAY_FIELD = 'kibana.task.schedule_delay'; const ES_SEARCH_DURATION_FIELD = 'kibana.alert.rule.execution.metrics.es_search_duration_ms'; @@ -28,6 +29,10 @@ const NUMBER_OF_TRIGGERED_ACTIONS_FIELD = 'kibana.alert.rule.execution.metrics.number_of_triggered_actions'; const NUMBER_OF_GENERATED_ACTIONS_FIELD = 'kibana.alert.rule.execution.metrics.number_of_generated_actions'; +const NUMBER_OF_ACTIVE_ALERTS_FIELD = 'kibana.alert.rule.execution.metrics.alert_counts.active'; +const NUMBER_OF_NEW_ALERTS_FIELD = 'kibana.alert.rule.execution.metrics.alert_counts.new'; +const NUMBER_OF_RECOVERED_ALERTS_FIELD = + 'kibana.alert.rule.execution.metrics.alert_counts.recovered'; const EXECUTION_UUID_FIELD = 'kibana.alert.rule.execution.uuid'; const Millis2Nanos = 1000 * 1000; @@ -37,14 +42,6 @@ export const EMPTY_EXECUTION_LOG_RESULT = { data: [], }; -interface IAlertCounts extends estypes.AggregationsMultiBucketAggregateBase { - buckets: { - activeAlerts: estypes.AggregationsSingleBucketAggregateBase; - newAlerts: estypes.AggregationsSingleBucketAggregateBase; - recoveredAlerts: estypes.AggregationsSingleBucketAggregateBase; - }; -} - interface IActionExecution extends estypes.AggregationsTermsAggregateBase<{ key: string; doc_count: number }> { buckets: Array<{ key: string; doc_count: number }>; @@ -60,9 +57,11 @@ interface IExecutionUuidAggBucket extends estypes.AggregationsStringTermsBucketK totalSearchDuration: estypes.AggregationsMaxAggregate; numTriggeredActions: estypes.AggregationsMaxAggregate; numGeneratedActions: estypes.AggregationsMaxAggregate; + numActiveAlerts: estypes.AggregationsMaxAggregate; + numRecoveredAlerts: estypes.AggregationsMaxAggregate; + numNewAlerts: estypes.AggregationsMaxAggregate; outcomeAndMessage: estypes.AggregationsTopHitsAggregate; }; - alertCounts: IAlertCounts; actionExecution: { actionOutcomes: IActionExecution; }; @@ -91,6 +90,9 @@ const ExecutionLogSortFields: Record = { schedule_delay: 'ruleExecution>scheduleDelay', num_triggered_actions: 'ruleExecution>numTriggeredActions', num_generated_actions: 'ruleExecution>numGeneratedActions', + num_active_alerts: 'ruleExecution>numActiveAlerts', + num_recovered_alerts: 'ruleExecution>numRecoveredAlerts', + num_new_alerts: 'ruleExecution>numNewAlerts', }; export function getExecutionLogAggregation({ page, perPage, sort }: IExecutionLogAggOptions) { @@ -153,16 +155,6 @@ export function getExecutionLogAggregation({ page, perPage, sort }: IExecutionLo gap_policy: 'insert_zeros' as estypes.AggregationsGapPolicy, }, }, - // Get counts for types of alerts and whether there was an execution timeout - alertCounts: { - filters: { - filters: { - newAlerts: { match: { [ACTION_FIELD]: 'new-instance' } }, - activeAlerts: { match: { [ACTION_FIELD]: 'active-instance' } }, - recoveredAlerts: { match: { [ACTION_FIELD]: 'recovered-instance' } }, - }, - }, - }, // Filter by action execute doc and get information from this event actionExecution: { filter: getProviderAndActionFilter('actions', 'execute'), @@ -209,6 +201,21 @@ export function getExecutionLogAggregation({ page, perPage, sort }: IExecutionLo field: NUMBER_OF_GENERATED_ACTIONS_FIELD, }, }, + numActiveAlerts: { + max: { + field: NUMBER_OF_ACTIVE_ALERTS_FIELD, + }, + }, + numRecoveredAlerts: { + max: { + field: NUMBER_OF_RECOVERED_ALERTS_FIELD, + }, + }, + numNewAlerts: { + max: { + field: NUMBER_OF_NEW_ALERTS_FIELD, + }, + }, executionDuration: { max: { field: DURATION_FIELD, @@ -218,7 +225,7 @@ export function getExecutionLogAggregation({ page, perPage, sort }: IExecutionLo top_hits: { size: 1, _source: { - includes: [OUTCOME_FIELD, MESSAGE_FIELD, ERROR_MESSAGE_FIELD], + includes: [OUTCOME_FIELD, MESSAGE_FIELD, ERROR_MESSAGE_FIELD, VERSION_FIELD], }, }, }, @@ -275,15 +282,17 @@ function formatExecutionLogAggBucket(bucket: IExecutionUuidAggBucket): IExecutio status === 'failure' ? `${outcomeAndMessage?.message ?? ''} - ${outcomeAndMessage?.error?.message ?? ''}` : outcomeAndMessage?.message ?? ''; + const version = outcomeAndMessage ? outcomeAndMessage?.kibana?.version ?? '' : ''; return { id: bucket?.key ?? '', timestamp: bucket?.ruleExecution?.executeStartTime.value_as_string ?? '', duration_ms: durationUs / Millis2Nanos, status, message, - num_active_alerts: bucket?.alertCounts?.buckets?.activeAlerts?.doc_count ?? 0, - num_new_alerts: bucket?.alertCounts?.buckets?.newAlerts?.doc_count ?? 0, - num_recovered_alerts: bucket?.alertCounts?.buckets?.recoveredAlerts?.doc_count ?? 0, + version, + num_active_alerts: bucket?.ruleExecution?.numActiveAlerts?.value ?? 0, + num_new_alerts: bucket?.ruleExecution?.numNewAlerts?.value ?? 0, + num_recovered_alerts: bucket?.ruleExecution?.numRecoveredAlerts?.value ?? 0, num_triggered_actions: bucket?.ruleExecution?.numTriggeredActions?.value ?? 0, num_generated_actions: bucket?.ruleExecution?.numGeneratedActions?.value ?? 0, num_succeeded_actions: actionExecutionSuccess, diff --git a/x-pack/plugins/alerting/server/routes/get_rule_execution_log.test.ts b/x-pack/plugins/alerting/server/routes/get_rule_execution_log.test.ts index cbcff65cdbdca..4a67404ab232e 100644 --- a/x-pack/plugins/alerting/server/routes/get_rule_execution_log.test.ts +++ b/x-pack/plugins/alerting/server/routes/get_rule_execution_log.test.ts @@ -34,6 +34,7 @@ describe('getRuleExecutionLogRoute', () => { status: 'success', message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + version: '8.2.0', num_active_alerts: 5, num_new_alerts: 5, num_recovered_alerts: 0, @@ -53,6 +54,7 @@ describe('getRuleExecutionLogRoute', () => { status: 'success', message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + version: '8.2.0', num_active_alerts: 5, num_new_alerts: 5, num_recovered_alerts: 5, diff --git a/x-pack/plugins/alerting/server/routes/get_rule_execution_log.ts b/x-pack/plugins/alerting/server/routes/get_rule_execution_log.ts index 650bdd83a0a83..4a8a91089203d 100644 --- a/x-pack/plugins/alerting/server/routes/get_rule_execution_log.ts +++ b/x-pack/plugins/alerting/server/routes/get_rule_execution_log.ts @@ -26,6 +26,9 @@ const sortFieldSchema = schema.oneOf([ schema.object({ schedule_delay: schema.object({ order: sortOrderSchema }) }), schema.object({ num_triggered_actions: schema.object({ order: sortOrderSchema }) }), schema.object({ num_generated_actions: schema.object({ order: sortOrderSchema }) }), + schema.object({ num_active_alerts: schema.object({ order: sortOrderSchema }) }), + schema.object({ num_recovered_alerts: schema.object({ order: sortOrderSchema }) }), + schema.object({ num_new_alerts: schema.object({ order: sortOrderSchema }) }), ]); const sortFieldsSchema = schema.arrayOf(sortFieldSchema, { diff --git a/x-pack/plugins/alerting/server/rules_client/tests/get_execution_log.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/get_execution_log.test.ts index 541e55f5c8d90..04653d491f28b 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/get_execution_log.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/get_execution_log.test.ts @@ -111,20 +111,6 @@ const aggregateResults = { meta: {}, doc_count: 0, }, - alertCounts: { - meta: {}, - buckets: { - activeAlerts: { - doc_count: 5, - }, - newAlerts: { - doc_count: 5, - }, - recoveredAlerts: { - doc_count: 0, - }, - }, - }, ruleExecution: { meta: {}, doc_count: 1, @@ -134,6 +120,15 @@ const aggregateResults = { numGeneratedActions: { value: 5.0, }, + numActiveAlerts: { + value: 5.0, + }, + numNewAlerts: { + value: 5.0, + }, + numRecoveredAlerts: { + value: 0.0, + }, outcomeAndMessage: { hits: { total: { @@ -150,6 +145,9 @@ const aggregateResults = { event: { outcome: 'success', }, + kibana: { + version: '8.2.0', + }, message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", }, @@ -196,20 +194,6 @@ const aggregateResults = { meta: {}, doc_count: 0, }, - alertCounts: { - meta: {}, - buckets: { - activeAlerts: { - doc_count: 5, - }, - newAlerts: { - doc_count: 5, - }, - recoveredAlerts: { - doc_count: 5, - }, - }, - }, ruleExecution: { meta: {}, doc_count: 1, @@ -219,6 +203,15 @@ const aggregateResults = { numGeneratedActions: { value: 5.0, }, + numActiveAlerts: { + value: 5.0, + }, + numNewAlerts: { + value: 5.0, + }, + numRecoveredAlerts: { + value: 5.0, + }, outcomeAndMessage: { hits: { total: { @@ -235,6 +228,9 @@ const aggregateResults = { event: { outcome: 'success', }, + kibana: { + version: '8.2.0', + }, message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", }, @@ -631,6 +627,7 @@ describe('getExecutionLogForRule()', () => { status: 'success', message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + version: '8.2.0', num_active_alerts: 5, num_new_alerts: 5, num_recovered_alerts: 0, @@ -650,6 +647,7 @@ describe('getExecutionLogForRule()', () => { status: 'success', message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + version: '8.2.0', num_active_alerts: 5, num_new_alerts: 5, num_recovered_alerts: 5, @@ -929,7 +927,7 @@ describe('getExecutionLogForRule()', () => { getExecutionLogByIdParams({ sort: [{ foo: { order: 'desc' } }] }) ) ).rejects.toMatchInlineSnapshot( - `[Error: Invalid sort field "foo" - must be one of [timestamp,execution_duration,total_search_duration,es_search_duration,schedule_delay,num_triggered_actions,num_generated_actions]]` + `[Error: Invalid sort field "foo" - must be one of [timestamp,execution_duration,total_search_duration,es_search_duration,schedule_delay,num_triggered_actions,num_generated_actions,num_active_alerts,num_recovered_alerts,num_new_alerts]]` ); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/constants/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/constants/index.ts index 99c115def07e6..a416eb18b5a52 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/constants/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/constants/index.ts @@ -67,6 +67,12 @@ export const RULE_EXECUTION_LOG_DURATION_COLUMNS = [ 'schedule_delay', ]; +export const RULE_EXECUTION_LOG_ALERT_COUNT_COLUMNS = [ + 'num_new_alerts', + 'num_active_alerts', + 'num_recovered_alerts', +]; + export const RULE_EXECUTION_DEFAULT_INITIAL_VISIBLE_COLUMNS = [ 'timestamp', 'execution_duration', diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_data_grid.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_data_grid.tsx index 7c2f5518c5c45..6f166af876004 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_data_grid.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_data_grid.tsx @@ -258,10 +258,12 @@ export const RuleEventLogDataGrid = (props: RuleEventLogDataGrid) => { const pagedRowIndex = rowIndex - pageIndex * pageSize; const value = logs[pagedRowIndex]?.[columnId as keyof IExecutionLog] as string; + const version = logs?.[pagedRowIndex]?.version; return ( ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list.test.tsx index 0284ab14f6ce0..7bf2c05b843dc 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list.test.tsx @@ -29,6 +29,7 @@ const mockLogResponse: any = { duration: 5000000, status: 'success', message: 'rule execution #1', + version: '8.2.0', num_active_alerts: 2, num_new_alerts: 4, num_recovered_alerts: 3, @@ -46,6 +47,7 @@ const mockLogResponse: any = { duration: 6000000, status: 'success', message: 'rule execution #2', + version: '8.2.0', num_active_alerts: 4, num_new_alerts: 2, num_recovered_alerts: 4, @@ -63,6 +65,7 @@ const mockLogResponse: any = { duration: 340000, status: 'failure', message: 'rule execution #3', + version: '8.2.0', num_active_alerts: 8, num_new_alerts: 5, num_recovered_alerts: 0, @@ -80,6 +83,7 @@ const mockLogResponse: any = { duration: 3000000, status: 'unknown', message: 'rule execution #4', + version: '8.2.0', num_active_alerts: 4, num_new_alerts: 4, num_recovered_alerts: 4, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list_cell_renderer.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list_cell_renderer.test.tsx index a33bdf7e25916..e38e57f61878b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list_cell_renderer.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list_cell_renderer.test.tsx @@ -38,6 +38,14 @@ describe('rule_event_log_list_cell_renderer', () => { expect(wrapper.find(RuleDurationFormat).props().duration).toEqual(100000); }); + it('renders alert count correctly', () => { + const wrapper = shallow( + + ); + + expect(wrapper.text()).toEqual('3'); + }); + it('renders timestamps correctly', () => { const time = '2022-03-20T07:40:44-07:00'; const wrapper = shallow(); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list_cell_renderer.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list_cell_renderer.tsx index 20e9274f2d73e..84fc3404f228e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list_cell_renderer.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list_cell_renderer.tsx @@ -9,11 +9,13 @@ import React from 'react'; import moment from 'moment'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { EcsEventOutcome } from '@kbn/core/server'; +import { formatRuleAlertCount } from '../../../../common/lib/format_rule_alert_count'; import { RuleEventLogListStatus } from './rule_event_log_list_status'; import { RuleDurationFormat } from '../../rules_list/components/rule_duration_format'; import { RULE_EXECUTION_LOG_COLUMN_IDS, RULE_EXECUTION_LOG_DURATION_COLUMNS, + RULE_EXECUTION_LOG_ALERT_COUNT_COLUMNS, } from '../../../constants'; export const DEFAULT_DATE_FORMAT = 'MMM D, YYYY @ HH:mm:ss.SSS'; @@ -22,12 +24,13 @@ export type ColumnId = typeof RULE_EXECUTION_LOG_COLUMN_IDS[number]; interface RuleEventLogListCellRendererProps { columnId: ColumnId; + version?: string; value?: string; dateFormat?: string; } export const RuleEventLogListCellRenderer = (props: RuleEventLogListCellRendererProps) => { - const { columnId, value, dateFormat = DEFAULT_DATE_FORMAT } = props; + const { columnId, value, version, dateFormat = DEFAULT_DATE_FORMAT } = props; if (typeof value === 'undefined') { return null; @@ -41,6 +44,10 @@ export const RuleEventLogListCellRenderer = (props: RuleEventLogListCellRenderer return <>{moment(value).format(dateFormat)}; } + if (RULE_EXECUTION_LOG_ALERT_COUNT_COLUMNS.includes(columnId)) { + return <>{formatRuleAlertCount(value, version)}; + } + if (RULE_EXECUTION_LOG_DURATION_COLUMNS.includes(columnId)) { return ; } diff --git a/x-pack/plugins/triggers_actions_ui/public/common/lib/format_rule_alert_count.test.ts b/x-pack/plugins/triggers_actions_ui/public/common/lib/format_rule_alert_count.test.ts new file mode 100644 index 0000000000000..99da6c01e66aa --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/common/lib/format_rule_alert_count.test.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { formatRuleAlertCount } from './format_rule_alert_count'; + +describe('formatRuleAlertCount', () => { + it('returns value if version is undefined', () => { + expect(formatRuleAlertCount('0')).toEqual('0'); + }); + + it('renders zero value if version is greater than or equal to 8.3.0', () => { + expect(formatRuleAlertCount('0', '8.3.0')).toEqual('0'); + }); + + it('renders non-zero value if version is greater than or equal to 8.3.0', () => { + expect(formatRuleAlertCount('4', '8.3.0')).toEqual('4'); + }); + + it('renders dashes for zero value if version is less than 8.3.0', () => { + expect(formatRuleAlertCount('0', '8.2.9')).toEqual('--'); + }); + + it('renders non-zero value event if version is less than to 8.3.0', () => { + expect(formatRuleAlertCount('5', '8.2.9')).toEqual('5'); + }); + + it('renders as is if value is unexpectedly not an integer', () => { + expect(formatRuleAlertCount('yo', '8.2.9')).toEqual('yo'); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/common/lib/format_rule_alert_count.ts b/x-pack/plugins/triggers_actions_ui/public/common/lib/format_rule_alert_count.ts new file mode 100644 index 0000000000000..10ceb40ce19b0 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/common/lib/format_rule_alert_count.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import semverLt from 'semver/functions/lt'; + +export const formatRuleAlertCount = (value: string, version?: string): string => { + if (version) { + try { + const intValue = parseInt(value, 10); + if (intValue === 0 && semverLt(version, '8.3.0')) { + return '--'; + } + } catch (err) { + return value; + } + } + + return value; +}; From a7012a319b9eca89f362e262667c8491232e8739 Mon Sep 17 00:00:00 2001 From: James Gowdy Date: Thu, 19 May 2022 15:22:08 +0100 Subject: [PATCH 05/35] [ML] Creating anomaly detection jobs from Lens visualizations (#129762) * [ML] Lens to ML ON week experiment * [CI] Auto-commit changed files from 'node scripts/eslint --no-cache --fix' * adding enabled check * refactor * type clean up * type updates * adding error text * variable rename * refactoring url generation * query refactor * translations * refactoring create job code * tiny refactor * adding getSavedVis function * adding undefined check * improving isCompatible check * improving field extraction * improving date parsing * code clean up * adding check for filter and timeShift * changing case of menu item * improving ml link generation * adding check for multiple split fields * adding layer types * renaming things * fixing queries and field type checks * using default bucket span * using locator * fixing query merging * fixing from and to string decoding * adding layer selection flyout * error tranlations and improving error reporting * removing annotatio and reference line layers * moving popout button * adding tick icon * tiny code clean up * removing commented code * using full labels * fixing full label selection * changing style of layer panels * fixing error text * adjusting split card border * style changes * removing border color * removing split card border * adding create job permission check Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../lens/public/embeddable/embeddable.tsx | 4 + x-pack/plugins/lens/public/index.ts | 4 +- x-pack/plugins/ml/common/constants/locator.ts | 1 + x-pack/plugins/ml/common/types/locator.ts | 1 + x-pack/plugins/ml/common/util/date_utils.ts | 11 + x-pack/plugins/ml/kibana.json | 2 + .../job_creator/advanced_job_creator.ts | 14 - .../new_job/common/job_creator/job_creator.ts | 14 + .../common/job_creator/util/general.ts | 9 +- .../convert_lens_to_job_action.tsx | 34 ++ .../jobs/new_job/job_from_lens/create_job.ts | 401 ++++++++++++++++++ .../jobs/new_job/job_from_lens/index.ts | 12 + .../new_job/job_from_lens/route_resolver.ts | 92 ++++ .../jobs/new_job/job_from_lens/utils.ts | 176 ++++++++ .../components/split_cards/split_cards.tsx | 5 +- .../components/split_cards/style.scss | 4 + .../jobs/new_job/pages/new_job/page.tsx | 9 +- .../jobs/new_job/utils/new_job_utils.ts | 117 +++-- .../routing/routes/new_job/from_lens.tsx | 36 ++ .../routing/routes/new_job/index.ts | 1 + .../application/services/job_service.d.ts | 1 + .../application/services/job_service.js | 1 + .../ml/public/embeddables/lens/index.ts | 8 + .../flyout.tsx | 80 ++++ .../flyout_body.tsx | 144 +++++++ .../lens_vis_layer_selection_flyout/index.ts | 8 + .../style.scss | 3 + .../public/embeddables/lens/show_flyout.tsx | 87 ++++ .../plugins/ml/public/locator/ml_locator.ts | 1 + x-pack/plugins/ml/public/plugin.ts | 3 + x-pack/plugins/ml/public/ui_actions/index.ts | 4 + .../ui_actions/open_lens_vis_in_ml_action.tsx | 64 +++ 32 files changed, 1283 insertions(+), 68 deletions(-) create mode 100644 x-pack/plugins/ml/public/application/jobs/new_job/job_from_lens/convert_lens_to_job_action.tsx create mode 100644 x-pack/plugins/ml/public/application/jobs/new_job/job_from_lens/create_job.ts create mode 100644 x-pack/plugins/ml/public/application/jobs/new_job/job_from_lens/index.ts create mode 100644 x-pack/plugins/ml/public/application/jobs/new_job/job_from_lens/route_resolver.ts create mode 100644 x-pack/plugins/ml/public/application/jobs/new_job/job_from_lens/utils.ts create mode 100644 x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/split_cards/style.scss create mode 100644 x-pack/plugins/ml/public/application/routing/routes/new_job/from_lens.tsx create mode 100644 x-pack/plugins/ml/public/embeddables/lens/index.ts create mode 100644 x-pack/plugins/ml/public/embeddables/lens/lens_vis_layer_selection_flyout/flyout.tsx create mode 100644 x-pack/plugins/ml/public/embeddables/lens/lens_vis_layer_selection_flyout/flyout_body.tsx create mode 100644 x-pack/plugins/ml/public/embeddables/lens/lens_vis_layer_selection_flyout/index.ts create mode 100644 x-pack/plugins/ml/public/embeddables/lens/lens_vis_layer_selection_flyout/style.scss create mode 100644 x-pack/plugins/ml/public/embeddables/lens/show_flyout.tsx create mode 100644 x-pack/plugins/ml/public/ui_actions/open_lens_vis_in_ml_action.tsx diff --git a/x-pack/plugins/lens/public/embeddable/embeddable.tsx b/x-pack/plugins/lens/public/embeddable/embeddable.tsx index 7ca68c5ca5d21..bc7770e815ba6 100644 --- a/x-pack/plugins/lens/public/embeddable/embeddable.tsx +++ b/x-pack/plugins/lens/public/embeddable/embeddable.tsx @@ -800,6 +800,10 @@ export class Embeddable return this.savedVis && this.savedVis.description; } + public getSavedVis(): Readonly { + return this.savedVis; + } + destroy() { super.destroy(); this.isDestroyed = true; diff --git a/x-pack/plugins/lens/public/index.ts b/x-pack/plugins/lens/public/index.ts index edf57ba703a2e..caa08ee9cc418 100644 --- a/x-pack/plugins/lens/public/index.ts +++ b/x-pack/plugins/lens/public/index.ts @@ -68,6 +68,7 @@ export type { FormulaPublicApi, StaticValueIndexPatternColumn, TimeScaleIndexPatternColumn, + IndexPatternLayer, } from './indexpattern_datasource/types'; export type { XYArgs, @@ -103,7 +104,8 @@ export type { LabelsOrientationConfigResult, AxisTitlesVisibilityConfigResult, } from '@kbn/expression-xy-plugin/common'; -export type { LensEmbeddableInput } from './embeddable'; +export type { LensEmbeddableInput, LensSavedObjectAttributes, Embeddable } from './embeddable'; + export { layerTypes } from '../common'; export type { LensPublicStart, LensPublicSetup } from './plugin'; diff --git a/x-pack/plugins/ml/common/constants/locator.ts b/x-pack/plugins/ml/common/constants/locator.ts index 0c19c5b59766c..7b98eefe0ab24 100644 --- a/x-pack/plugins/ml/common/constants/locator.ts +++ b/x-pack/plugins/ml/common/constants/locator.ts @@ -42,6 +42,7 @@ export const ML_PAGES = { ANOMALY_DETECTION_CREATE_JOB_ADVANCED: `jobs/new_job/advanced`, ANOMALY_DETECTION_CREATE_JOB_SELECT_TYPE: `jobs/new_job/step/job_type`, ANOMALY_DETECTION_CREATE_JOB_SELECT_INDEX: `jobs/new_job/step/index_or_search`, + ANOMALY_DETECTION_CREATE_JOB_FROM_LENS: `jobs/new_job/from_lens`, SETTINGS: 'settings', CALENDARS_MANAGE: 'settings/calendars_list', CALENDARS_NEW: 'settings/calendars_list/new_calendar', diff --git a/x-pack/plugins/ml/common/types/locator.ts b/x-pack/plugins/ml/common/types/locator.ts index a440aaa349bcc..0d5cb7aeddd81 100644 --- a/x-pack/plugins/ml/common/types/locator.ts +++ b/x-pack/plugins/ml/common/types/locator.ts @@ -48,6 +48,7 @@ export type MlGenericUrlState = MLPageState< | typeof ML_PAGES.ANOMALY_DETECTION_CREATE_JOB | typeof ML_PAGES.ANOMALY_DETECTION_CREATE_JOB_RECOGNIZER | typeof ML_PAGES.ANOMALY_DETECTION_CREATE_JOB_ADVANCED + | typeof ML_PAGES.ANOMALY_DETECTION_CREATE_JOB_FROM_LENS | typeof ML_PAGES.ANOMALY_DETECTION_CREATE_JOB_SELECT_TYPE | typeof ML_PAGES.ANOMALY_DETECTION_CREATE_JOB_SELECT_INDEX | typeof ML_PAGES.DATA_FRAME_ANALYTICS_CREATE_JOB diff --git a/x-pack/plugins/ml/common/util/date_utils.ts b/x-pack/plugins/ml/common/util/date_utils.ts index c5f5fdaabf388..d6605e5856d8b 100644 --- a/x-pack/plugins/ml/common/util/date_utils.ts +++ b/x-pack/plugins/ml/common/util/date_utils.ts @@ -31,6 +31,17 @@ export function validateTimeRange(time?: TimeRange): boolean { return !!(momentDateFrom && momentDateFrom.isValid() && momentDateTo && momentDateTo.isValid()); } +export function createAbsoluteTimeRange(time: TimeRange) { + if (validateTimeRange(time) === false) { + return null; + } + + return { + to: dateMath.parse(time.to)?.valueOf(), + from: dateMath.parse(time.from)?.valueOf(), + }; +} + export const timeFormatter = (value: number) => { return formatDate(value, TIME_FORMAT); }; diff --git a/x-pack/plugins/ml/kibana.json b/x-pack/plugins/ml/kibana.json index f62cec0ec0fca..fd105b98805ac 100644 --- a/x-pack/plugins/ml/kibana.json +++ b/x-pack/plugins/ml/kibana.json @@ -28,6 +28,7 @@ "charts", "dashboard", "home", + "lens", "licenseManagement", "management", "maps", @@ -44,6 +45,7 @@ "fieldFormats", "kibanaReact", "kibanaUtils", + "lens", "maps", "savedObjects", "usageCollection", diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/advanced_job_creator.ts b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/advanced_job_creator.ts index ebf3a43626c99..d6e7a8c3b21e2 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/advanced_job_creator.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/advanced_job_creator.ts @@ -21,7 +21,6 @@ import { createBasicDetector } from './util/default_configs'; import { JOB_TYPE } from '../../../../../../common/constants/new_job'; import { getRichDetectors } from './util/general'; import { isValidJson } from '../../../../../../common/util/validation_utils'; -import { ml } from '../../../../services/ml_api_service'; export interface RichDetector { agg: Aggregation | null; @@ -181,19 +180,6 @@ export class AdvancedJobCreator extends JobCreator { return isValidJson(this._queryString); } - // load the start and end times for the selected index - // and apply them to the job creator - public async autoSetTimeRange() { - const { start, end } = await ml.getTimeFieldRange({ - index: this._indexPatternTitle, - timeFieldName: this.timeFieldName, - query: this.query, - runtimeMappings: this.datafeedConfig.runtime_mappings, - indicesOptions: this.datafeedConfig.indices_options, - }); - this.setTimeRange(start.epoch, end.epoch); - } - public cloneFromExistingJob(job: Job, datafeed: Datafeed) { this._overrideConfigs(job, datafeed); const detectors = getRichDetectors(job, datafeed, this.additionalFields, true); diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/job_creator.ts b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/job_creator.ts index 750669a794bd8..4e0ed5f3bdf92 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/job_creator.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/job_creator.ts @@ -43,6 +43,7 @@ import { Calendar } from '../../../../../../common/types/calendars'; import { mlCalendarService } from '../../../../services/calendar_service'; import { getDatafeedAggregations } from '../../../../../../common/util/datafeed_utils'; import { getFirstKeyInObject } from '../../../../../../common/util/object_utils'; +import { ml } from '../../../../services/ml_api_service'; export class JobCreator { protected _type: JOB_TYPE = JOB_TYPE.SINGLE_METRIC; @@ -762,6 +763,19 @@ export class JobCreator { } } + // load the start and end times for the selected index + // and apply them to the job creator + public async autoSetTimeRange() { + const { start, end } = await ml.getTimeFieldRange({ + index: this._indexPatternTitle, + timeFieldName: this.timeFieldName, + query: this.query, + runtimeMappings: this.datafeedConfig.runtime_mappings, + indicesOptions: this.datafeedConfig.indices_options, + }); + this.setTimeRange(start.epoch, end.epoch); + } + protected _overrideConfigs(job: Job, datafeed: Datafeed) { this._job_config = job; this._datafeed_config = datafeed; diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/util/general.ts b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/util/general.ts index 8f7b66b35ec4f..bd7b6277a542d 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/util/general.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/util/general.ts @@ -230,10 +230,11 @@ export function isSparseDataJob(job: Job, datafeed: Datafeed): boolean { return false; } -function stashJobForCloning( +export function stashJobForCloning( jobCreator: JobCreatorType, skipTimeRangeStep: boolean = false, - includeTimeRange: boolean = false + includeTimeRange: boolean = false, + autoSetTimeRange: boolean = false ) { mlJobService.tempJobCloningObjects.job = jobCreator.jobConfig; mlJobService.tempJobCloningObjects.datafeed = jobCreator.datafeedConfig; @@ -242,10 +243,12 @@ function stashJobForCloning( // skip over the time picker step of the wizard mlJobService.tempJobCloningObjects.skipTimeRangeStep = skipTimeRangeStep; - if (includeTimeRange === true) { + if (includeTimeRange === true && autoSetTimeRange === false) { // auto select the start and end dates of the time picker mlJobService.tempJobCloningObjects.start = jobCreator.start; mlJobService.tempJobCloningObjects.end = jobCreator.end; + } else if (autoSetTimeRange === true) { + mlJobService.tempJobCloningObjects.autoSetTimeRange = true; } mlJobService.tempJobCloningObjects.calendars = jobCreator.calendars; diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/job_from_lens/convert_lens_to_job_action.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/job_from_lens/convert_lens_to_job_action.tsx new file mode 100644 index 0000000000000..ab00fa7e2d474 --- /dev/null +++ b/x-pack/plugins/ml/public/application/jobs/new_job/job_from_lens/convert_lens_to_job_action.tsx @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { SharePluginStart } from '@kbn/share-plugin/public'; +import type { Embeddable } from '@kbn/lens-plugin/public'; +import { getJobsItemsFromEmbeddable } from './utils'; +import { ML_PAGES, ML_APP_LOCATOR } from '../../../../../common/constants/locator'; + +export async function convertLensToADJob( + embeddable: Embeddable, + share: SharePluginStart, + layerIndex?: number +) { + const { query, filters, to, from, vis } = getJobsItemsFromEmbeddable(embeddable); + const locator = share.url.locators.get(ML_APP_LOCATOR); + + const url = await locator?.getUrl({ + page: ML_PAGES.ANOMALY_DETECTION_CREATE_JOB_FROM_LENS, + pageState: { + vis: vis as any, + from, + to, + query, + filters, + layerIndex, + }, + }); + + window.open(url, '_blank'); +} diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/job_from_lens/create_job.ts b/x-pack/plugins/ml/public/application/jobs/new_job/job_from_lens/create_job.ts new file mode 100644 index 0000000000000..7abc30c9f924e --- /dev/null +++ b/x-pack/plugins/ml/public/application/jobs/new_job/job_from_lens/create_job.ts @@ -0,0 +1,401 @@ +/* + * Copyright 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 { mergeWith } from 'lodash'; +import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import type { IUiSettingsClient, SavedObjectReference } from '@kbn/core/public'; +import type { DataViewsContract } from '@kbn/data-views-plugin/public'; + +import { Filter, Query, DataViewBase } from '@kbn/es-query'; + +import type { + LensPublicStart, + LensSavedObjectAttributes, + FieldBasedIndexPatternColumn, + XYDataLayerConfig, + IndexPatternPersistedState, + IndexPatternLayer, + XYLayerConfig, +} from '@kbn/lens-plugin/public'; +import { layerTypes } from '@kbn/lens-plugin/public'; +import type { TimefilterContract } from '@kbn/data-plugin/public'; + +import { i18n } from '@kbn/i18n'; + +import type { JobCreatorType } from '../common/job_creator'; +import { createEmptyJob, createEmptyDatafeed } from '../common/job_creator/util/default_configs'; +import { stashJobForCloning } from '../common/job_creator/util/general'; +import { CREATED_BY_LABEL, DEFAULT_BUCKET_SPAN } from '../../../../../common/constants/new_job'; +import { ErrorType } from '../../../../../common/util/errors'; +import { createQueries } from '../utils/new_job_utils'; +import { + getVisTypeFactory, + isCompatibleLayer, + hasIncompatibleProperties, + hasSourceField, + isTermsField, + isStringField, + getMlFunction, +} from './utils'; + +type VisualizationType = Awaited>[number]; + +export interface LayerResult { + id: string; + layerType: typeof layerTypes[keyof typeof layerTypes]; + label: string; + icon: VisualizationType['icon']; + isCompatible: boolean; + jobWizardType: CREATED_BY_LABEL | null; + error?: ErrorType; +} + +export async function canCreateAndStashADJob( + vis: LensSavedObjectAttributes, + startString: string, + endString: string, + query: Query, + filters: Filter[], + dataViewClient: DataViewsContract, + kibanaConfig: IUiSettingsClient, + timeFilter: TimefilterContract, + layerIndex: number | undefined +) { + try { + const { jobConfig, datafeedConfig, createdBy } = await createADJobFromLensSavedObject( + vis, + query, + filters, + dataViewClient, + kibanaConfig, + layerIndex + ); + + let start: number | undefined; + let end: number | undefined; + let includeTimeRange = true; + + try { + // attempt to parse the start and end dates. + // if start and end values cannot be determined + // instruct the job cloning code to auto-select the + // full time range for the index. + const { min, max } = timeFilter.calculateBounds({ to: endString, from: startString }); + start = min?.valueOf(); + end = max?.valueOf(); + + if (start === undefined || end === undefined || isNaN(start) || isNaN(end)) { + throw Error( + i18n.translate('xpack.ml.newJob.fromLens.createJob.error.timeRange', { + defaultMessage: 'Incompatible time range', + }) + ); + } + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + includeTimeRange = false; + start = undefined; + end = undefined; + } + + // add job config and start and end dates to the + // job cloning stash, so they can be used + // by the new job wizards + stashJobForCloning( + { + jobConfig, + datafeedConfig, + createdBy, + start, + end, + } as JobCreatorType, + true, + includeTimeRange, + !includeTimeRange + ); + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + } +} +export async function getLayers( + vis: LensSavedObjectAttributes, + dataViewClient: DataViewsContract, + lens: LensPublicStart +): Promise { + const visualization = vis.state.visualization as { layers: XYLayerConfig[] }; + const getVisType = await getVisTypeFactory(lens); + + const layers: LayerResult[] = await Promise.all( + visualization.layers + .filter(({ layerType }) => layerType === layerTypes.DATA) // remove non chart layers + .map(async (layer) => { + const { icon, label } = getVisType(layer); + try { + const { fields, splitField } = await extractFields(layer, vis, dataViewClient); + const detectors = createDetectors(fields, splitField); + const createdBy = + splitField || detectors.length > 1 + ? CREATED_BY_LABEL.MULTI_METRIC + : CREATED_BY_LABEL.SINGLE_METRIC; + + return { + id: layer.layerId, + layerType: layer.layerType, + label, + icon, + jobWizardType: createdBy, + isCompatible: true, + }; + } catch (error) { + return { + id: layer.layerId, + layerType: layer.layerType, + label, + icon, + jobWizardType: null, + isCompatible: false, + error, + }; + } + }) + ); + + return layers; +} + +async function createADJobFromLensSavedObject( + vis: LensSavedObjectAttributes, + query: Query, + filters: Filter[], + dataViewClient: DataViewsContract, + kibanaConfig: IUiSettingsClient, + layerIndex?: number +) { + const visualization = vis.state.visualization as { layers: XYDataLayerConfig[] }; + + const compatibleLayers = visualization.layers.filter(isCompatibleLayer); + + const selectedLayer = + layerIndex !== undefined ? visualization.layers[layerIndex] : compatibleLayers[0]; + + const { fields, timeField, splitField, dataView } = await extractFields( + selectedLayer, + vis, + dataViewClient + ); + + const jobConfig = createEmptyJob(); + const datafeedConfig = createEmptyDatafeed(dataView.title); + + const combinedFiltersAndQueries = combineQueriesAndFilters( + { query, filters }, + { query: vis.state.query, filters: vis.state.filters }, + dataView, + kibanaConfig + ); + + datafeedConfig.query = combinedFiltersAndQueries; + + jobConfig.analysis_config.detectors = createDetectors(fields, splitField); + + jobConfig.data_description.time_field = timeField.sourceField; + jobConfig.analysis_config.bucket_span = DEFAULT_BUCKET_SPAN; + if (splitField) { + jobConfig.analysis_config.influencers = [splitField.sourceField]; + } + + const createdBy = + splitField || jobConfig.analysis_config.detectors.length > 1 + ? CREATED_BY_LABEL.MULTI_METRIC + : CREATED_BY_LABEL.SINGLE_METRIC; + + return { + jobConfig, + datafeedConfig, + createdBy, + }; +} + +async function extractFields( + layer: XYLayerConfig, + vis: LensSavedObjectAttributes, + dataViewClient: DataViewsContract +) { + if (!isCompatibleLayer(layer)) { + throw Error( + i18n.translate('xpack.ml.newJob.fromLens.createJob.error.incompatibleLayerType', { + defaultMessage: 'Layer is incompatible. Only chart layers can be used.', + }) + ); + } + + const indexpattern = vis.state.datasourceStates.indexpattern as IndexPatternPersistedState; + const compatibleIndexPatternLayer = Object.entries(indexpattern.layers).find( + ([id]) => layer.layerId === id + ); + if (compatibleIndexPatternLayer === undefined) { + throw Error( + i18n.translate('xpack.ml.newJob.fromLens.createJob.error.noCompatibleLayers', { + defaultMessage: + 'Visualization does not contain any layers which can be used for creating an anomaly detection job.', + }) + ); + } + + const [layerId, columnsLayer] = compatibleIndexPatternLayer; + + const columns = getColumns(columnsLayer, layer); + const timeField = Object.values(columns).find(({ dataType }) => dataType === 'date'); + if (timeField === undefined) { + throw Error( + i18n.translate('xpack.ml.newJob.fromLens.createJob.error.noDateField', { + defaultMessage: 'Cannot find a date field.', + }) + ); + } + + const fields = layer.accessors.map((a) => columns[a]); + + const splitField = layer.splitAccessor ? columns[layer.splitAccessor] : null; + + if ( + splitField !== null && + isTermsField(splitField) && + splitField.params.secondaryFields?.length + ) { + throw Error( + i18n.translate('xpack.ml.newJob.fromLens.createJob.error.splitFieldHasMultipleFields', { + defaultMessage: 'Selected split field contains more than one field.', + }) + ); + } + + if (splitField !== null && isStringField(splitField) === false) { + throw Error( + i18n.translate('xpack.ml.newJob.fromLens.createJob.error.splitFieldMustBeString', { + defaultMessage: 'Selected split field type must be string.', + }) + ); + } + + const dataView = await getDataViewFromLens(vis.references, layerId, dataViewClient); + if (dataView === null) { + throw Error( + i18n.translate('xpack.ml.newJob.fromLens.createJob.error.noDataViews', { + defaultMessage: 'No data views can be found in the visualization.', + }) + ); + } + + if (timeField.sourceField !== dataView.timeFieldName) { + throw Error( + i18n.translate('xpack.ml.newJob.fromLens.createJob.error.timeFieldNotInDataView', { + defaultMessage: + 'Selected time field must be the default time field configured for data view.', + }) + ); + } + + return { fields, timeField, splitField, dataView }; +} + +function createDetectors( + fields: FieldBasedIndexPatternColumn[], + splitField: FieldBasedIndexPatternColumn | null +) { + return fields.map(({ operationType, sourceField }) => { + return { + function: getMlFunction(operationType), + field_name: sourceField, + ...(splitField ? { partition_field_name: splitField.sourceField } : {}), + }; + }); +} + +async function getDataViewFromLens( + references: SavedObjectReference[], + layerId: string, + dataViewClient: DataViewsContract +) { + const dv = references.find( + (r) => r.type === 'index-pattern' && r.name === `indexpattern-datasource-layer-${layerId}` + ); + if (!dv) { + return null; + } + return dataViewClient.get(dv.id); +} + +function getColumns( + { columns }: Omit, + layer: XYDataLayerConfig +) { + layer.accessors.forEach((a) => { + const col = columns[a]; + // fail early if any of the cols being used as accessors + // contain functions we don't support + return col.dataType !== 'date' && getMlFunction(col.operationType); + }); + + if (Object.values(columns).some((c) => hasSourceField(c) === false)) { + throw Error( + i18n.translate('xpack.ml.newJob.fromLens.createJob.error.colsNoSourceField', { + defaultMessage: 'Some columns do not contain a source field.', + }) + ); + } + + if (Object.values(columns).some((c) => hasIncompatibleProperties(c) === true)) { + throw Error( + i18n.translate('xpack.ml.newJob.fromLens.createJob.error.colsUsingFilterTimeSift', { + defaultMessage: + 'Columns contain settings which are incompatible with ML detectors, time shift and filter by are not supported.', + }) + ); + } + + return columns as Record; +} + +function combineQueriesAndFilters( + dashboard: { query: Query; filters: Filter[] }, + vis: { query: Query; filters: Filter[] }, + dataView: DataViewBase, + kibanaConfig: IUiSettingsClient +): estypes.QueryDslQueryContainer { + const { combinedQuery: dashboardQueries } = createQueries( + { + query: dashboard.query, + filter: dashboard.filters, + }, + dataView, + kibanaConfig + ); + + const { combinedQuery: visQueries } = createQueries( + { + query: vis.query, + filter: vis.filters, + }, + dataView, + kibanaConfig + ); + + const mergedQueries = mergeWith( + dashboardQueries, + visQueries, + (objValue: estypes.QueryDslQueryContainer, srcValue: estypes.QueryDslQueryContainer) => { + if (Array.isArray(objValue)) { + return objValue.concat(srcValue); + } + } + ); + + return mergedQueries; +} diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/job_from_lens/index.ts b/x-pack/plugins/ml/public/application/jobs/new_job/job_from_lens/index.ts new file mode 100644 index 0000000000000..911595f9673da --- /dev/null +++ b/x-pack/plugins/ml/public/application/jobs/new_job/job_from_lens/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export type { LayerResult } from './create_job'; +export { resolver } from './route_resolver'; +export { getLayers } from './create_job'; +export { convertLensToADJob } from './convert_lens_to_job_action'; +export { getJobsItemsFromEmbeddable, isCompatibleVisualizationType } from './utils'; diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/job_from_lens/route_resolver.ts b/x-pack/plugins/ml/public/application/jobs/new_job/job_from_lens/route_resolver.ts new file mode 100644 index 0000000000000..b305c69c47d87 --- /dev/null +++ b/x-pack/plugins/ml/public/application/jobs/new_job/job_from_lens/route_resolver.ts @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import rison from 'rison-node'; +import { Query } from '@kbn/data-plugin/public'; +import { Filter } from '@kbn/es-query'; +import type { LensSavedObjectAttributes } from '@kbn/lens-plugin/public'; +import { canCreateAndStashADJob } from './create_job'; +import { + getUiSettings, + getDataViews, + getSavedObjectsClient, + getTimefilter, +} from '../../../util/dependency_cache'; +import { getDefaultQuery } from '../utils/new_job_utils'; + +export async function resolver( + lensSavedObjectId: string | undefined, + lensSavedObjectRisonString: string | undefined, + fromRisonStrong: string, + toRisonStrong: string, + queryRisonString: string, + filtersRisonString: string, + layerIndexRisonString: string +) { + let vis: LensSavedObjectAttributes; + if (lensSavedObjectId) { + vis = await getLensSavedObject(lensSavedObjectId); + } else if (lensSavedObjectRisonString) { + vis = rison.decode(lensSavedObjectRisonString) as unknown as LensSavedObjectAttributes; + } else { + throw new Error('Cannot create visualization'); + } + + let query: Query; + let filters: Filter[]; + try { + query = rison.decode(queryRisonString) as Query; + } catch (error) { + query = getDefaultQuery(); + } + try { + filters = rison.decode(filtersRisonString) as Filter[]; + } catch (error) { + filters = []; + } + + let from: string; + let to: string; + try { + from = rison.decode(fromRisonStrong) as string; + } catch (error) { + from = ''; + } + try { + to = rison.decode(toRisonStrong) as string; + } catch (error) { + to = ''; + } + let layerIndex: number | undefined; + try { + layerIndex = rison.decode(layerIndexRisonString) as number; + } catch (error) { + layerIndex = undefined; + } + + const dataViewClient = getDataViews(); + const kibanaConfig = getUiSettings(); + const timeFilter = getTimefilter(); + + await canCreateAndStashADJob( + vis, + from, + to, + query, + filters, + dataViewClient, + kibanaConfig, + timeFilter, + layerIndex + ); +} + +async function getLensSavedObject(id: string) { + const savedObjectClient = getSavedObjectsClient(); + const so = await savedObjectClient.get('lens', id); + return so.attributes; +} diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/job_from_lens/utils.ts b/x-pack/plugins/ml/public/application/jobs/new_job/job_from_lens/utils.ts new file mode 100644 index 0000000000000..e4b2ae91b3ba2 --- /dev/null +++ b/x-pack/plugins/ml/public/application/jobs/new_job/job_from_lens/utils.ts @@ -0,0 +1,176 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import type { + Embeddable, + LensPublicStart, + LensSavedObjectAttributes, + FieldBasedIndexPatternColumn, + XYDataLayerConfig, + GenericIndexPatternColumn, + TermsIndexPatternColumn, + SeriesType, + XYLayerConfig, +} from '@kbn/lens-plugin/public'; +import { layerTypes } from '@kbn/lens-plugin/public'; + +export const COMPATIBLE_SERIES_TYPES: SeriesType[] = [ + 'line', + 'bar', + 'bar_stacked', + 'bar_percentage_stacked', + 'bar_horizontal', + 'bar_horizontal_stacked', + 'area', + 'area_stacked', + 'area_percentage_stacked', +]; + +export const COMPATIBLE_LAYER_TYPE: XYDataLayerConfig['layerType'] = layerTypes.DATA; + +export const COMPATIBLE_VISUALIZATION = 'lnsXY'; + +export function getJobsItemsFromEmbeddable(embeddable: Embeddable) { + const { query, filters, timeRange } = embeddable.getInput(); + + if (timeRange === undefined) { + throw Error( + i18n.translate('xpack.ml.newJob.fromLens.createJob.error.noTimeRange', { + defaultMessage: 'Time range not specified.', + }) + ); + } + const { to, from } = timeRange; + + const vis = embeddable.getSavedVis(); + if (vis === undefined) { + throw Error( + i18n.translate('xpack.ml.newJob.fromLens.createJob.error.visNotFound', { + defaultMessage: 'Visualization cannot be found.', + }) + ); + } + + return { + vis, + from, + to, + query, + filters, + }; +} + +export function lensOperationToMlFunction(operationType: string) { + switch (operationType) { + case 'average': + return 'mean'; + case 'count': + return 'count'; + case 'max': + return 'max'; + case 'median': + return 'median'; + case 'min': + return 'min'; + case 'sum': + return 'sum'; + case 'unique_count': + return 'distinct_count'; + + default: + return null; + } +} + +export function getMlFunction(operationType: string) { + const func = lensOperationToMlFunction(operationType); + if (func === null) { + throw Error( + i18n.translate('xpack.ml.newJob.fromLens.createJob.error.incorrectFunction', { + defaultMessage: + 'Selected function {operationType} is not supported by anomaly detection detectors', + values: { operationType }, + }) + ); + } + return func; +} + +export async function getVisTypeFactory(lens: LensPublicStart) { + const visTypes = await lens.getXyVisTypes(); + return (layer: XYLayerConfig) => { + switch (layer.layerType) { + case layerTypes.DATA: + const type = visTypes.find((t) => t.id === layer.seriesType); + return { + label: type?.fullLabel || type?.label || layer.layerType, + icon: type?.icon ?? '', + }; + case layerTypes.ANNOTATIONS: + // Annotation and Reference line layers are not displayed. + // but for consistency leave the labels in, in case we decide + // to display these layers in the future + return { + label: i18n.translate('xpack.ml.newJob.fromLens.createJob.VisType.annotations', { + defaultMessage: 'Annotations', + }), + icon: '', + }; + case layerTypes.REFERENCELINE: + return { + label: i18n.translate('xpack.ml.newJob.fromLens.createJob.VisType.referenceLine', { + defaultMessage: 'Reference line', + }), + icon: '', + }; + default: + return { + // @ts-expect-error just in case a new layer type appears in the future + label: layer.layerType, + icon: '', + }; + } + }; +} + +export async function isCompatibleVisualizationType(savedObject: LensSavedObjectAttributes) { + const visualization = savedObject.state.visualization as { layers: XYLayerConfig[] }; + return ( + savedObject.visualizationType === COMPATIBLE_VISUALIZATION && + visualization.layers.some((l) => l.layerType === layerTypes.DATA) + ); +} + +export function isCompatibleLayer(layer: XYLayerConfig): layer is XYDataLayerConfig { + return ( + isDataLayer(layer) && + layer.layerType === COMPATIBLE_LAYER_TYPE && + COMPATIBLE_SERIES_TYPES.includes(layer.seriesType) + ); +} + +export function isDataLayer(layer: XYLayerConfig): layer is XYDataLayerConfig { + return 'seriesType' in layer; +} +export function hasSourceField( + column: GenericIndexPatternColumn +): column is FieldBasedIndexPatternColumn { + return 'sourceField' in column; +} + +export function isTermsField(column: GenericIndexPatternColumn): column is TermsIndexPatternColumn { + return column.operationType === 'terms' && 'params' in column; +} + +export function isStringField(column: GenericIndexPatternColumn) { + return column.dataType === 'string'; +} + +export function hasIncompatibleProperties(column: GenericIndexPatternColumn) { + return 'timeShift' in column || 'filter' in column; +} diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/split_cards/split_cards.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/split_cards/split_cards.tsx index 67b411ebc628e..597645d2fa87e 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/split_cards/split_cards.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/split_cards/split_cards.tsx @@ -11,6 +11,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiHorizontalRule, EuiSpacer } fro import { SplitField } from '../../../../../../../../../common/types/fields'; import { JOB_TYPE } from '../../../../../../../../../common/constants/new_job'; +import './style.scss'; interface Props { fieldValues: string[]; @@ -72,7 +73,7 @@ export const SplitCards: FC = memo(
storePanels(ref, marginBottom)} style={style}>
= memo( {getBackPanels()}
= ({ existingJobsAndGroups, jobType }) => { ? WIZARD_STEPS.ADVANCED_CONFIGURE_DATAFEED : WIZARD_STEPS.TIME_RANGE; - let autoSetTimeRange = false; + let autoSetTimeRange = mlJobService.tempJobCloningObjects.autoSetTimeRange; + mlJobService.tempJobCloningObjects.autoSetTimeRange = false; if ( mlJobService.tempJobCloningObjects.job !== undefined && @@ -106,7 +107,7 @@ export const Page: FC = ({ existingJobsAndGroups, jobType }) => { } else { // if not start and end times are set and this is an advanced job, // auto set the time range based on the index - autoSetTimeRange = isAdvancedJobCreator(jobCreator); + autoSetTimeRange = autoSetTimeRange || isAdvancedJobCreator(jobCreator); } if (mlJobService.tempJobCloningObjects.calendars) { @@ -148,7 +149,7 @@ export const Page: FC = ({ existingJobsAndGroups, jobType }) => { } } - if (autoSetTimeRange && isAdvancedJobCreator(jobCreator)) { + if (autoSetTimeRange) { // for advanced jobs, load the full time range start and end times // so they can be used for job validation and bucket span estimation jobCreator.autoSetTimeRange().catch((error) => { @@ -183,7 +184,7 @@ export const Page: FC = ({ existingJobsAndGroups, jobType }) => { chartInterval.setInterval('auto'); const chartLoader = useMemo( - () => new ChartLoader(mlContext.currentDataView, mlContext.combinedQuery), + () => new ChartLoader(mlContext.currentDataView, jobCreator.query), [] ); diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/utils/new_job_utils.ts b/x-pack/plugins/ml/public/application/jobs/new_job/utils/new_job_utils.ts index 69bdfc666b06a..1f0be5bdb0516 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/utils/new_job_utils.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/utils/new_job_utils.ts @@ -5,6 +5,7 @@ * 2.0. */ +import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { cloneDeep } from 'lodash'; import { Query, @@ -14,6 +15,7 @@ import { buildQueryFromFilters, DataViewBase, } from '@kbn/es-query'; +import { Filter } from '@kbn/es-query'; import { IUiSettingsClient } from '@kbn/core/public'; import { getEsQueryConfig } from '@kbn/data-plugin/public'; import { SEARCH_QUERY_LANGUAGE } from '../../../../../common/constants/search'; @@ -22,7 +24,7 @@ import { getQueryFromSavedSearchObject } from '../../../util/index_utils'; // Provider for creating the items used for searching and job creation. -const DEFAULT_QUERY = { +const DEFAULT_DSL_QUERY: estypes.QueryDslQueryContainer = { bool: { must: [ { @@ -32,7 +34,16 @@ const DEFAULT_QUERY = { }, }; +export const DEFAULT_QUERY: Query = { + query: '', + language: 'lucene', +}; + export function getDefaultDatafeedQuery() { + return cloneDeep(DEFAULT_DSL_QUERY); +} + +export function getDefaultQuery() { return cloneDeep(DEFAULT_QUERY); } @@ -45,57 +56,75 @@ export function createSearchItems( // 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: any = getDefaultDatafeedQuery(); - if (savedSearch !== null) { - const data = getQueryFromSavedSearchObject(savedSearch); + if (savedSearch === null) { + return { + query: getDefaultQuery(), + combinedQuery: getDefaultDatafeedQuery(), + }; + } - query = data.query; - const filter = data.filter; + const data = getQueryFromSavedSearchObject(savedSearch); + return createQueries(data, indexPattern, kibanaConfig); +} - const filters = Array.isArray(filter) ? filter : []; +export function createQueries( + data: { query: Query; filter: Filter[] }, + dataView: DataViewBase | undefined, + kibanaConfig: IUiSettingsClient +) { + let query = getDefaultQuery(); + let combinedQuery: estypes.QueryDslQueryContainer = getDefaultDatafeedQuery(); - 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 === undefined) { - combinedQuery.bool = {}; - // toElasticsearchQuery may add a single multi_match item to the - // root of its returned query, rather than putting it inside - // a bool.should - // in this case, move it to a bool.should - if (combinedQuery.multi_match !== undefined) { - combinedQuery.bool.should = { - multi_match: combinedQuery.multi_match, - }; - delete combinedQuery.multi_match; - } - } + query = data.query; + const filter = data.filter; + const filters = Array.isArray(filter) ? filter : []; - if (Array.isArray(combinedQuery.bool.filter) === false) { - combinedQuery.bool.filter = - combinedQuery.bool.filter === undefined ? [] : [combinedQuery.bool.filter]; + if (query.language === SEARCH_QUERY_LANGUAGE.KUERY) { + const ast = fromKueryExpression(query.query); + if (query.query !== '') { + combinedQuery = toElasticsearchQuery(ast, dataView); + } + const filterQuery = buildQueryFromFilters(filters, dataView); + + if (combinedQuery.bool === undefined) { + combinedQuery.bool = {}; + // toElasticsearchQuery may add a single multi_match item to the + // root of its returned query, rather than putting it inside + // a bool.should + // in this case, move it to a bool.should + if (combinedQuery.multi_match !== undefined) { + combinedQuery.bool.should = { + multi_match: combinedQuery.multi_match, + }; + delete combinedQuery.multi_match; } + } - if (Array.isArray(combinedQuery.bool.must_not) === false) { - combinedQuery.bool.must_not = - combinedQuery.bool.must_not === undefined ? [] : [combinedQuery.bool.must_not]; - } + if (Array.isArray(combinedQuery.bool.filter) === false) { + combinedQuery.bool.filter = + combinedQuery.bool.filter === undefined + ? [] + : [combinedQuery.bool.filter as estypes.QueryDslQueryContainer]; + } - 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); + if (Array.isArray(combinedQuery.bool.must_not) === false) { + combinedQuery.bool.must_not = + combinedQuery.bool.must_not === undefined + ? [] + : [combinedQuery.bool.must_not as estypes.QueryDslQueryContainer]; } + + combinedQuery.bool.filter = [ + ...(combinedQuery.bool.filter as estypes.QueryDslQueryContainer[]), + ...filterQuery.filter, + ]; + combinedQuery.bool.must_not = [ + ...(combinedQuery.bool.must_not as estypes.QueryDslQueryContainer[]), + ...filterQuery.must_not, + ]; + } else { + const esQueryConfigs = getEsQueryConfig(kibanaConfig); + combinedQuery = buildEsQuery(dataView, [query], filters, esQueryConfigs); } return { diff --git a/x-pack/plugins/ml/public/application/routing/routes/new_job/from_lens.tsx b/x-pack/plugins/ml/public/application/routing/routes/new_job/from_lens.tsx new file mode 100644 index 0000000000000..ad24bcfba89a9 --- /dev/null +++ b/x-pack/plugins/ml/public/application/routing/routes/new_job/from_lens.tsx @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC } from 'react'; + +import { Redirect } from 'react-router-dom'; +import { parse } from 'query-string'; + +import { MlRoute, PageLoader, PageProps } from '../../router'; +import { useResolver } from '../../use_resolver'; + +import { resolver } from '../../../jobs/new_job/job_from_lens'; + +export const fromLensRouteFactory = (): MlRoute => ({ + path: '/jobs/new_job/from_lens', + render: (props, deps) => , + breadcrumbs: [], +}); + +const PageWrapper: FC = ({ location, deps }) => { + const { lensId, vis, from, to, query, filters, layerIndex }: Record = parse( + location.search, + { + sort: false, + } + ); + + const { context } = useResolver(undefined, undefined, deps.config, deps.dataViewsContract, { + redirect: () => resolver(lensId, vis, from, to, query, filters, layerIndex), + }); + return {}; +}; diff --git a/x-pack/plugins/ml/public/application/routing/routes/new_job/index.ts b/x-pack/plugins/ml/public/application/routing/routes/new_job/index.ts index b76a1b45588de..d02d4b16264c6 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/new_job/index.ts +++ b/x-pack/plugins/ml/public/application/routing/routes/new_job/index.ts @@ -10,3 +10,4 @@ export * from './job_type'; export * from './new_job'; export * from './wizard'; export * from './recognize'; +export * from './from_lens'; diff --git a/x-pack/plugins/ml/public/application/services/job_service.d.ts b/x-pack/plugins/ml/public/application/services/job_service.d.ts index be0f035786923..465e4528bd9c5 100644 --- a/x-pack/plugins/ml/public/application/services/job_service.d.ts +++ b/x-pack/plugins/ml/public/application/services/job_service.d.ts @@ -25,6 +25,7 @@ declare interface JobService { start?: number; end?: number; calendars: Calendar[] | undefined; + autoSetTimeRange?: boolean; }; skipTimeRangeStep: boolean; saveNewJob(job: Job): Promise; diff --git a/x-pack/plugins/ml/public/application/services/job_service.js b/x-pack/plugins/ml/public/application/services/job_service.js index ebb89b84dd638..32cd957ff0f20 100644 --- a/x-pack/plugins/ml/public/application/services/job_service.js +++ b/x-pack/plugins/ml/public/application/services/job_service.js @@ -35,6 +35,7 @@ class JobService { start: undefined, end: undefined, calendars: undefined, + autoSetTimeRange: false, }; this.jobs = []; diff --git a/x-pack/plugins/ml/public/embeddables/lens/index.ts b/x-pack/plugins/ml/public/embeddables/lens/index.ts new file mode 100644 index 0000000000000..ad44424293dbb --- /dev/null +++ b/x-pack/plugins/ml/public/embeddables/lens/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { showLensVisToADJobFlyout } from './show_flyout'; diff --git a/x-pack/plugins/ml/public/embeddables/lens/lens_vis_layer_selection_flyout/flyout.tsx b/x-pack/plugins/ml/public/embeddables/lens/lens_vis_layer_selection_flyout/flyout.tsx new file mode 100644 index 0000000000000..edb882390e1ed --- /dev/null +++ b/x-pack/plugins/ml/public/embeddables/lens/lens_vis_layer_selection_flyout/flyout.tsx @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC } from 'react'; +import type { Embeddable } from '@kbn/lens-plugin/public'; +import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; +import type { SharePluginStart } from '@kbn/share-plugin/public'; +import type { LensPublicStart } from '@kbn/lens-plugin/public'; +import { FormattedMessage } from '@kbn/i18n-react'; + +import { + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiFlexGroup, + EuiFlexItem, + EuiButtonEmpty, + EuiFlyoutBody, + EuiTitle, + EuiSpacer, + EuiText, +} from '@elastic/eui'; + +import { FlyoutBody } from './flyout_body'; + +interface Props { + embeddable: Embeddable; + data: DataPublicPluginStart; + share: SharePluginStart; + lens: LensPublicStart; + onClose: () => void; +} + +export const LensLayerSelectionFlyout: FC = ({ onClose, embeddable, data, share, lens }) => { + return ( + <> + + +

+ +

+
+ + + + +
+ + + + + + + + + + + + + + ); +}; diff --git a/x-pack/plugins/ml/public/embeddables/lens/lens_vis_layer_selection_flyout/flyout_body.tsx b/x-pack/plugins/ml/public/embeddables/lens/lens_vis_layer_selection_flyout/flyout_body.tsx new file mode 100644 index 0000000000000..fbda903daa7e7 --- /dev/null +++ b/x-pack/plugins/ml/public/embeddables/lens/lens_vis_layer_selection_flyout/flyout_body.tsx @@ -0,0 +1,144 @@ +/* + * Copyright 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, useState, useEffect, useMemo } from 'react'; +import type { Embeddable } from '@kbn/lens-plugin/public'; +import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; +import type { SharePluginStart } from '@kbn/share-plugin/public'; +import type { LensPublicStart } from '@kbn/lens-plugin/public'; +import { FormattedMessage } from '@kbn/i18n-react'; + +import './style.scss'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiButton, + EuiSpacer, + EuiIcon, + EuiText, + EuiSplitPanel, + EuiHorizontalRule, +} from '@elastic/eui'; + +import { + getLayers, + getJobsItemsFromEmbeddable, + convertLensToADJob, +} from '../../../application/jobs/new_job/job_from_lens'; +import type { LayerResult } from '../../../application/jobs/new_job/job_from_lens'; +import { CREATED_BY_LABEL } from '../../../../common/constants/new_job'; +import { extractErrorMessage } from '../../../../common/util/errors'; + +interface Props { + embeddable: Embeddable; + data: DataPublicPluginStart; + share: SharePluginStart; + lens: LensPublicStart; + onClose: () => void; +} + +export const FlyoutBody: FC = ({ onClose, embeddable, data, share, lens }) => { + const embeddableItems = useMemo(() => getJobsItemsFromEmbeddable(embeddable), [embeddable]); + + const [layerResult, setLayerResults] = useState([]); + + useEffect(() => { + const { vis } = embeddableItems; + + getLayers(vis, data.dataViews, lens).then((layers) => { + setLayerResults(layers); + }); + }, []); + + function createADJob(layerIndex: number) { + convertLensToADJob(embeddable, share, layerIndex); + } + + return ( + <> + {layerResult.map((layer, i) => ( + <> + + + + {layer.icon && ( + + + + )} + + +
{layer.label}
+
+
+
+
+ + + {layer.isCompatible ? ( + <> + + + + + + + + + + + + + + + {' '} + + + + ) : ( + <> + + + + + + + + + {layer.error ? ( + extractErrorMessage(layer.error) + ) : ( + + )} + + + + + )} + +
+ + + ))} + + ); +}; diff --git a/x-pack/plugins/ml/public/embeddables/lens/lens_vis_layer_selection_flyout/index.ts b/x-pack/plugins/ml/public/embeddables/lens/lens_vis_layer_selection_flyout/index.ts new file mode 100644 index 0000000000000..4fa9391434162 --- /dev/null +++ b/x-pack/plugins/ml/public/embeddables/lens/lens_vis_layer_selection_flyout/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { LensLayerSelectionFlyout } from './flyout'; diff --git a/x-pack/plugins/ml/public/embeddables/lens/lens_vis_layer_selection_flyout/style.scss b/x-pack/plugins/ml/public/embeddables/lens/lens_vis_layer_selection_flyout/style.scss new file mode 100644 index 0000000000000..0da0eb92c9637 --- /dev/null +++ b/x-pack/plugins/ml/public/embeddables/lens/lens_vis_layer_selection_flyout/style.scss @@ -0,0 +1,3 @@ +.mlLensToJobFlyoutBody { + background-color: $euiColorLightestShade; +} diff --git a/x-pack/plugins/ml/public/embeddables/lens/show_flyout.tsx b/x-pack/plugins/ml/public/embeddables/lens/show_flyout.tsx new file mode 100644 index 0000000000000..525b7aa74cbc7 --- /dev/null +++ b/x-pack/plugins/ml/public/embeddables/lens/show_flyout.tsx @@ -0,0 +1,87 @@ +/* + * Copyright 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 { takeUntil } from 'rxjs/operators'; +import { from } from 'rxjs'; +import type { Embeddable } from '@kbn/lens-plugin/public'; +import type { CoreStart } from '@kbn/core/public'; +import type { SharePluginStart } from '@kbn/share-plugin/public'; +import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; +import type { LensPublicStart } from '@kbn/lens-plugin/public'; + +import { + toMountPoint, + wrapWithTheme, + KibanaContextProvider, +} from '@kbn/kibana-react-plugin/public'; +import { DashboardConstants } from '@kbn/dashboard-plugin/public'; +import { getMlGlobalServices } from '../../application/app'; +import { LensLayerSelectionFlyout } from './lens_vis_layer_selection_flyout'; + +export async function showLensVisToADJobFlyout( + embeddable: Embeddable, + coreStart: CoreStart, + share: SharePluginStart, + data: DataPublicPluginStart, + lens: LensPublicStart +): Promise { + const { + http, + theme: { theme$ }, + overlays, + application: { currentAppId$ }, + } = coreStart; + + return new Promise(async (resolve, reject) => { + try { + const onFlyoutClose = () => { + flyoutSession.close(); + resolve(); + }; + + const flyoutSession = overlays.openFlyout( + toMountPoint( + wrapWithTheme( + + { + onFlyoutClose(); + resolve(); + }} + data={data} + share={share} + lens={lens} + /> + , + theme$ + ) + ), + { + 'data-test-subj': 'mlFlyoutJobSelector', + ownFocus: true, + closeButtonAriaLabel: 'jobSelectorFlyout', + onClose: onFlyoutClose, + // @ts-expect-error should take any number/string compatible with the CSS width attribute + size: '35vw', + } + ); + + // Close the flyout when user navigates out of the dashboard plugin + currentAppId$.pipe(takeUntil(from(flyoutSession.onClose))).subscribe((appId) => { + if (appId !== DashboardConstants.DASHBOARDS_ID) { + flyoutSession.close(); + } + }); + } catch (error) { + reject(error); + } + }); +} diff --git a/x-pack/plugins/ml/public/locator/ml_locator.ts b/x-pack/plugins/ml/public/locator/ml_locator.ts index 01d63aa0ebf3f..295dbaebbbae6 100644 --- a/x-pack/plugins/ml/public/locator/ml_locator.ts +++ b/x-pack/plugins/ml/public/locator/ml_locator.ts @@ -79,6 +79,7 @@ export class MlLocatorDefinition implements LocatorDefinition { case ML_PAGES.ANOMALY_DETECTION_CREATE_JOB: case ML_PAGES.ANOMALY_DETECTION_CREATE_JOB_RECOGNIZER: case ML_PAGES.ANOMALY_DETECTION_CREATE_JOB_ADVANCED: + case ML_PAGES.ANOMALY_DETECTION_CREATE_JOB_FROM_LENS: case ML_PAGES.DATA_VISUALIZER: case ML_PAGES.DATA_VISUALIZER_FILE: case ML_PAGES.DATA_VISUALIZER_INDEX_VIEWER: diff --git a/x-pack/plugins/ml/public/plugin.ts b/x-pack/plugins/ml/public/plugin.ts index 79f386d521da1..7a3d605a1e8cf 100644 --- a/x-pack/plugins/ml/public/plugin.ts +++ b/x-pack/plugins/ml/public/plugin.ts @@ -23,6 +23,7 @@ import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; import type { HomePublicPluginSetup } from '@kbn/home-plugin/public'; import type { EmbeddableSetup, EmbeddableStart } from '@kbn/embeddable-plugin/public'; import type { SpacesPluginStart } from '@kbn/spaces-plugin/public'; +import type { LensPublicStart } from '@kbn/lens-plugin/public'; import { AppStatus, AppUpdater, DEFAULT_APP_CATEGORIES } from '@kbn/core/public'; import type { UiActionsSetup, UiActionsStart } from '@kbn/ui-actions-plugin/public'; @@ -64,6 +65,7 @@ export interface MlStartDependencies { fieldFormats: FieldFormatsStart; dashboard: DashboardStart; charts: ChartsPluginStart; + lens?: LensPublicStart; } export interface MlSetupDependencies { @@ -130,6 +132,7 @@ export class MlPlugin implements Plugin { aiops: pluginsStart.aiops, usageCollection: pluginsSetup.usageCollection, fieldFormats: pluginsStart.fieldFormats, + lens: pluginsStart.lens, }, params ); diff --git a/x-pack/plugins/ml/public/ui_actions/index.ts b/x-pack/plugins/ml/public/ui_actions/index.ts index a663fa0e2fa01..4aac7c46b70ac 100644 --- a/x-pack/plugins/ml/public/ui_actions/index.ts +++ b/x-pack/plugins/ml/public/ui_actions/index.ts @@ -10,6 +10,7 @@ import { UiActionsSetup } from '@kbn/ui-actions-plugin/public'; import { CONTEXT_MENU_TRIGGER } from '@kbn/embeddable-plugin/public'; import { createEditSwimlanePanelAction } from './edit_swimlane_panel_action'; import { createOpenInExplorerAction } from './open_in_anomaly_explorer_action'; +import { createLensVisToADJobAction } from './open_lens_vis_in_ml_action'; import { MlPluginStart, MlStartDependencies } from '../plugin'; import { createApplyInfluencerFiltersAction } from './apply_influencer_filters_action'; import { @@ -26,6 +27,7 @@ export { APPLY_TIME_RANGE_SELECTION_ACTION } from './apply_time_range_action'; export { EDIT_SWIMLANE_PANEL_ACTION } from './edit_swimlane_panel_action'; export { APPLY_INFLUENCER_FILTERS_ACTION } from './apply_influencer_filters_action'; export { OPEN_IN_ANOMALY_EXPLORER_ACTION } from './open_in_anomaly_explorer_action'; +export { CREATE_LENS_VIS_TO_ML_AD_JOB_ACTION } from './open_lens_vis_in_ml_action'; export { SWIM_LANE_SELECTION_TRIGGER }; /** * Register ML UI actions @@ -42,6 +44,7 @@ export function registerMlUiActions( const applyTimeRangeSelectionAction = createApplyTimeRangeSelectionAction(core.getStartServices); const clearSelectionAction = createClearSelectionAction(core.getStartServices); const editExplorerPanelAction = createEditAnomalyChartsPanelAction(core.getStartServices); + const lensVisToADJobAction = createLensVisToADJobAction(core.getStartServices); // Register actions uiActions.registerAction(editSwimlanePanelAction); @@ -65,4 +68,5 @@ export function registerMlUiActions( uiActions.addTriggerAction(SWIM_LANE_SELECTION_TRIGGER, openInExplorerAction); uiActions.addTriggerAction(SWIM_LANE_SELECTION_TRIGGER, clearSelectionAction); uiActions.addTriggerAction(EXPLORER_ENTITY_FIELD_SELECTION_TRIGGER, applyEntityFieldFilterAction); + uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, lensVisToADJobAction); } diff --git a/x-pack/plugins/ml/public/ui_actions/open_lens_vis_in_ml_action.tsx b/x-pack/plugins/ml/public/ui_actions/open_lens_vis_in_ml_action.tsx new file mode 100644 index 0000000000000..692f0e2ac5f9b --- /dev/null +++ b/x-pack/plugins/ml/public/ui_actions/open_lens_vis_in_ml_action.tsx @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import type { Embeddable } from '@kbn/lens-plugin/public'; +import { createAction } from '@kbn/ui-actions-plugin/public'; +import { MlCoreSetup } from '../plugin'; + +export const CREATE_LENS_VIS_TO_ML_AD_JOB_ACTION = 'createMLADJobAction'; + +export function createLensVisToADJobAction(getStartServices: MlCoreSetup['getStartServices']) { + return createAction<{ embeddable: Embeddable }>({ + id: 'create-ml-ad-job-action', + type: CREATE_LENS_VIS_TO_ML_AD_JOB_ACTION, + getIconType(context): string { + return 'machineLearningApp'; + }, + getDisplayName: () => + i18n.translate('xpack.ml.actions.createADJobFromLens', { + defaultMessage: 'Create anomaly detection job', + }), + async execute({ embeddable }) { + if (!embeddable) { + throw new Error('Not possible to execute an action without the embeddable context'); + } + + try { + const [{ showLensVisToADJobFlyout }, [coreStart, { share, data, lens }]] = + await Promise.all([import('../embeddables/lens'), getStartServices()]); + if (lens === undefined) { + return; + } + await showLensVisToADJobFlyout(embeddable, coreStart, share, data, lens); + } catch (e) { + return Promise.reject(); + } + }, + async isCompatible(context: { embeddable: Embeddable }) { + if (context.embeddable.type !== 'lens') { + return false; + } + + const [{ getJobsItemsFromEmbeddable, isCompatibleVisualizationType }, [coreStart]] = + await Promise.all([ + import('../application/jobs/new_job/job_from_lens'), + getStartServices(), + ]); + + if ( + !coreStart.application.capabilities.ml?.canCreateJob || + !coreStart.application.capabilities.ml?.canStartStopDatafeed + ) { + return false; + } + + const { vis } = getJobsItemsFromEmbeddable(context.embeddable); + return isCompatibleVisualizationType(vis); + }, + }); +} From 3effa893da12cd968cc3e34bdab1391857b5b445 Mon Sep 17 00:00:00 2001 From: Spencer Date: Thu, 19 May 2022 07:28:47 -0700 Subject: [PATCH 06/35] [ci] always supply defaults for parallelism vars (#132520) --- .buildkite/scripts/steps/code_coverage/jest_parallel.sh | 6 +++--- .buildkite/scripts/steps/test/ftr_configs.sh | 4 ++-- .buildkite/scripts/steps/test/jest.sh | 4 +++- .buildkite/scripts/steps/test/jest_integration.sh | 4 +++- .buildkite/scripts/steps/test/jest_parallel.sh | 2 +- 5 files changed, 12 insertions(+), 8 deletions(-) diff --git a/.buildkite/scripts/steps/code_coverage/jest_parallel.sh b/.buildkite/scripts/steps/code_coverage/jest_parallel.sh index dc8a67320c5ed..44ea80bf95257 100755 --- a/.buildkite/scripts/steps/code_coverage/jest_parallel.sh +++ b/.buildkite/scripts/steps/code_coverage/jest_parallel.sh @@ -2,8 +2,8 @@ set -uo pipefail -JOB=$BUILDKITE_PARALLEL_JOB -JOB_COUNT=$BUILDKITE_PARALLEL_JOB_COUNT +JOB=${BUILDKITE_PARALLEL_JOB:-0} +JOB_COUNT=${BUILDKITE_PARALLEL_JOB_COUNT:-1} # a jest failure will result in the script returning an exit code of 10 @@ -35,4 +35,4 @@ while read -r config; do # uses heredoc to avoid the while loop being in a sub-shell thus unable to overwrite exitCode done <<< "$(find src x-pack packages -name jest.config.js -not -path "*/__fixtures__/*" | sort)" -exit $exitCode \ No newline at end of file +exit $exitCode diff --git a/.buildkite/scripts/steps/test/ftr_configs.sh b/.buildkite/scripts/steps/test/ftr_configs.sh index 244b108a269f8..447dc5bca9e6b 100755 --- a/.buildkite/scripts/steps/test/ftr_configs.sh +++ b/.buildkite/scripts/steps/test/ftr_configs.sh @@ -4,10 +4,10 @@ set -euo pipefail source .buildkite/scripts/steps/functional/common.sh -export JOB_NUM=$BUILDKITE_PARALLEL_JOB +export JOB_NUM=${BUILDKITE_PARALLEL_JOB:-0} export JOB=ftr-configs-${JOB_NUM} -FAILED_CONFIGS_KEY="${BUILDKITE_STEP_ID}${BUILDKITE_PARALLEL_JOB:-0}" +FAILED_CONFIGS_KEY="${BUILDKITE_STEP_ID}${JOB_NUM}" # a FTR failure will result in the script returning an exit code of 10 exitCode=0 diff --git a/.buildkite/scripts/steps/test/jest.sh b/.buildkite/scripts/steps/test/jest.sh index cbf8bce703cc6..7b09c3f0d788a 100755 --- a/.buildkite/scripts/steps/test/jest.sh +++ b/.buildkite/scripts/steps/test/jest.sh @@ -8,6 +8,8 @@ is_test_execution_step .buildkite/scripts/bootstrap.sh +JOB=${BUILDKITE_PARALLEL_JOB:-0} + echo '--- Jest' -checks-reporter-with-killswitch "Jest Unit Tests $((BUILDKITE_PARALLEL_JOB+1))" \ +checks-reporter-with-killswitch "Jest Unit Tests $((JOB+1))" \ .buildkite/scripts/steps/test/jest_parallel.sh jest.config.js diff --git a/.buildkite/scripts/steps/test/jest_integration.sh b/.buildkite/scripts/steps/test/jest_integration.sh index 13412881cb6fa..2dce8fec0f26c 100755 --- a/.buildkite/scripts/steps/test/jest_integration.sh +++ b/.buildkite/scripts/steps/test/jest_integration.sh @@ -8,6 +8,8 @@ is_test_execution_step .buildkite/scripts/bootstrap.sh +JOB=${BUILDKITE_PARALLEL_JOB:-0} + echo '--- Jest Integration Tests' -checks-reporter-with-killswitch "Jest Integration Tests $((BUILDKITE_PARALLEL_JOB+1))" \ +checks-reporter-with-killswitch "Jest Integration Tests $((JOB+1))" \ .buildkite/scripts/steps/test/jest_parallel.sh jest.integration.config.js diff --git a/.buildkite/scripts/steps/test/jest_parallel.sh b/.buildkite/scripts/steps/test/jest_parallel.sh index 71ecf7a853d4a..8ca025a3e6516 100755 --- a/.buildkite/scripts/steps/test/jest_parallel.sh +++ b/.buildkite/scripts/steps/test/jest_parallel.sh @@ -2,7 +2,7 @@ set -euo pipefail -export JOB=$BUILDKITE_PARALLEL_JOB +export JOB=${BUILDKITE_PARALLEL_JOB:-0} # a jest failure will result in the script returning an exit code of 10 exitCode=0 From 0c2d06dd816780b3aaa3c19bc1f953eda4ae8c39 Mon Sep 17 00:00:00 2001 From: Nick Peihl Date: Thu, 19 May 2022 10:55:09 -0400 Subject: [PATCH 07/35] [Spacetime] [Maps] Localized basemaps (#130930) --- package.json | 2 +- src/dev/license_checker/config.ts | 2 +- x-pack/plugins/maps/common/constants.ts | 2 + .../layer_descriptor_types.ts | 1 + .../maps/public/actions/layer_actions.ts | 9 +++ .../create_basemap_layer_descriptor.test.ts | 3 +- .../layers/create_basemap_layer_descriptor.ts | 2 + .../ems_vector_tile_layer.test.ts | 20 ++++++ .../ems_vector_tile_layer.tsx | 42 ++++++++++++- .../maps/public/classes/layers/layer.tsx | 10 +++ .../edit_layer_panel/layer_settings/index.tsx | 2 + .../layer_settings/layer_settings.tsx | 62 ++++++++++++++++++- yarn.lock | 9 +-- 13 files changed, 156 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index 7e4e2ea78175a..84f9be547e7a1 100644 --- a/package.json +++ b/package.json @@ -108,7 +108,7 @@ "@elastic/charts": "46.0.1", "@elastic/datemath": "5.0.3", "@elastic/elasticsearch": "npm:@elastic/elasticsearch-canary@8.2.0-canary.2", - "@elastic/ems-client": "8.3.0", + "@elastic/ems-client": "8.3.2", "@elastic/eui": "55.1.2", "@elastic/filesaver": "1.1.2", "@elastic/node-crypto": "1.2.1", diff --git a/src/dev/license_checker/config.ts b/src/dev/license_checker/config.ts index 0ccab6fcf1b24..f10fb0231352d 100644 --- a/src/dev/license_checker/config.ts +++ b/src/dev/license_checker/config.ts @@ -76,7 +76,7 @@ export const DEV_ONLY_LICENSE_ALLOWED = ['MPL-2.0']; export const LICENSE_OVERRIDES = { 'jsts@1.6.2': ['Eclipse Distribution License - v 1.0'], // cf. https://github.com/bjornharrtell/jsts '@mapbox/jsonlint-lines-primitives@2.0.2': ['MIT'], // license in readme https://github.com/tmcw/jsonlint - '@elastic/ems-client@8.3.0': ['Elastic License 2.0'], + '@elastic/ems-client@8.3.2': ['Elastic License 2.0'], '@elastic/eui@55.1.2': ['SSPL-1.0 OR Elastic License 2.0'], 'language-subtag-registry@0.3.21': ['CC-BY-4.0'], // retired ODC‑By license https://github.com/mattcg/language-subtag-registry }; diff --git a/x-pack/plugins/maps/common/constants.ts b/x-pack/plugins/maps/common/constants.ts index b51259307f3a1..53660c5256497 100644 --- a/x-pack/plugins/maps/common/constants.ts +++ b/x-pack/plugins/maps/common/constants.ts @@ -298,6 +298,8 @@ export const MAPS_NEW_VECTOR_LAYER_META_CREATED_BY = 'maps-new-vector-layer'; export const MAX_DRAWING_SIZE_BYTES = 10485760; // 10MB +export const NO_EMS_LOCALE = 'none'; +export const AUTOSELECT_EMS_LOCALE = 'autoselect'; export const emsWorldLayerId = 'world_countries'; export enum WIZARD_ID { diff --git a/x-pack/plugins/maps/common/descriptor_types/layer_descriptor_types.ts b/x-pack/plugins/maps/common/descriptor_types/layer_descriptor_types.ts index 996e3d7303b82..5aba9ba06dc48 100644 --- a/x-pack/plugins/maps/common/descriptor_types/layer_descriptor_types.ts +++ b/x-pack/plugins/maps/common/descriptor_types/layer_descriptor_types.ts @@ -61,6 +61,7 @@ export type LayerDescriptor = { attribution?: Attribution; id: string; label?: string | null; + locale?: string | null; areLabelsOnTop?: boolean; minZoom?: number; maxZoom?: number; diff --git a/x-pack/plugins/maps/public/actions/layer_actions.ts b/x-pack/plugins/maps/public/actions/layer_actions.ts index 257b27e422e2f..6ffd9d59b1434 100644 --- a/x-pack/plugins/maps/public/actions/layer_actions.ts +++ b/x-pack/plugins/maps/public/actions/layer_actions.ts @@ -472,6 +472,15 @@ export function updateLayerLabel(id: string, newLabel: string) { }; } +export function updateLayerLocale(id: string, locale: string) { + return { + type: UPDATE_LAYER_PROP, + id, + propName: 'locale', + newValue: locale, + }; +} + export function setLayerAttribution(id: string, attribution: Attribution) { return { type: UPDATE_LAYER_PROP, diff --git a/x-pack/plugins/maps/public/classes/layers/create_basemap_layer_descriptor.test.ts b/x-pack/plugins/maps/public/classes/layers/create_basemap_layer_descriptor.test.ts index eded70a75e4ac..9c81b4c3aa72f 100644 --- a/x-pack/plugins/maps/public/classes/layers/create_basemap_layer_descriptor.test.ts +++ b/x-pack/plugins/maps/public/classes/layers/create_basemap_layer_descriptor.test.ts @@ -80,10 +80,11 @@ describe('EMS is enabled', () => { id: '12345', includeInFitToBounds: true, label: null, + locale: 'autoselect', maxZoom: 24, minZoom: 0, - source: undefined, sourceDescriptor: { + id: undefined, isAutoSelect: true, lightModeDefault: 'road_map_desaturated', type: 'EMS_TMS', diff --git a/x-pack/plugins/maps/public/classes/layers/create_basemap_layer_descriptor.ts b/x-pack/plugins/maps/public/classes/layers/create_basemap_layer_descriptor.ts index e104261f90847..dd569951f90e4 100644 --- a/x-pack/plugins/maps/public/classes/layers/create_basemap_layer_descriptor.ts +++ b/x-pack/plugins/maps/public/classes/layers/create_basemap_layer_descriptor.ts @@ -14,6 +14,7 @@ import { KibanaTilemapSource } from '../sources/kibana_tilemap_source'; import { RasterTileLayer } from './raster_tile_layer/raster_tile_layer'; import { EmsVectorTileLayer } from './ems_vector_tile_layer/ems_vector_tile_layer'; import { EMSTMSSource } from '../sources/ems_tms_source'; +import { AUTOSELECT_EMS_LOCALE } from '../../../common/constants'; export function createBasemapLayerDescriptor(): LayerDescriptor | null { const tilemapSourceFromKibana = getKibanaTileMap(); @@ -27,6 +28,7 @@ export function createBasemapLayerDescriptor(): LayerDescriptor | null { const isEmsEnabled = getEMSSettings()!.isEMSEnabled(); if (isEmsEnabled) { const layerDescriptor = EmsVectorTileLayer.createDescriptor({ + locale: AUTOSELECT_EMS_LOCALE, sourceDescriptor: EMSTMSSource.createDescriptor({ isAutoSelect: true }), }); return layerDescriptor; diff --git a/x-pack/plugins/maps/public/classes/layers/ems_vector_tile_layer/ems_vector_tile_layer.test.ts b/x-pack/plugins/maps/public/classes/layers/ems_vector_tile_layer/ems_vector_tile_layer.test.ts index 21c9c1f79d970..5f12f4cbc2b61 100644 --- a/x-pack/plugins/maps/public/classes/layers/ems_vector_tile_layer/ems_vector_tile_layer.test.ts +++ b/x-pack/plugins/maps/public/classes/layers/ems_vector_tile_layer/ems_vector_tile_layer.test.ts @@ -55,6 +55,26 @@ describe('EmsVectorTileLayer', () => { expect(actualErrorMessage).toStrictEqual('network error'); }); + describe('getLocale', () => { + test('should set locale to none for existing layers where locale is not defined', () => { + const layer = new EmsVectorTileLayer({ + source: {} as unknown as EMSTMSSource, + layerDescriptor: {} as unknown as LayerDescriptor, + }); + expect(layer.getLocale()).toBe('none'); + }); + + test('should set locale for new layers', () => { + const layer = new EmsVectorTileLayer({ + source: {} as unknown as EMSTMSSource, + layerDescriptor: { + locale: 'xx', + } as unknown as LayerDescriptor, + }); + expect(layer.getLocale()).toBe('xx'); + }); + }); + describe('isInitialDataLoadComplete', () => { test('should return false when tile loading has not started', () => { const layer = new EmsVectorTileLayer({ diff --git a/x-pack/plugins/maps/public/classes/layers/ems_vector_tile_layer/ems_vector_tile_layer.tsx b/x-pack/plugins/maps/public/classes/layers/ems_vector_tile_layer/ems_vector_tile_layer.tsx index 646ccb3c09acd..6f8bc3470d792 100644 --- a/x-pack/plugins/maps/public/classes/layers/ems_vector_tile_layer/ems_vector_tile_layer.tsx +++ b/x-pack/plugins/maps/public/classes/layers/ems_vector_tile_layer/ems_vector_tile_layer.tsx @@ -6,11 +6,19 @@ */ import type { Map as MbMap, LayerSpecification, StyleSpecification } from '@kbn/mapbox-gl'; +import { TMSService } from '@elastic/ems-client'; +import { i18n } from '@kbn/i18n'; import _ from 'lodash'; // @ts-expect-error import { RGBAImage } from './image_utils'; import { AbstractLayer } from '../layer'; -import { SOURCE_DATA_REQUEST_ID, LAYER_TYPE, LAYER_STYLE_TYPE } from '../../../../common/constants'; +import { + AUTOSELECT_EMS_LOCALE, + NO_EMS_LOCALE, + SOURCE_DATA_REQUEST_ID, + LAYER_TYPE, + LAYER_STYLE_TYPE, +} from '../../../../common/constants'; import { LayerDescriptor } from '../../../../common/descriptor_types'; import { DataRequest } from '../../util/data_request'; import { isRetina } from '../../../util'; @@ -50,6 +58,7 @@ export class EmsVectorTileLayer extends AbstractLayer { const tileLayerDescriptor = super.createDescriptor(options); tileLayerDescriptor.type = LAYER_TYPE.EMS_VECTOR_TILE; tileLayerDescriptor.alpha = _.get(options, 'alpha', 1); + tileLayerDescriptor.locale = _.get(options, 'locale', AUTOSELECT_EMS_LOCALE); tileLayerDescriptor.style = { type: LAYER_STYLE_TYPE.TILE }; return tileLayerDescriptor; } @@ -87,6 +96,10 @@ export class EmsVectorTileLayer extends AbstractLayer { return this._style; } + getLocale() { + return this._descriptor.locale ?? NO_EMS_LOCALE; + } + _canSkipSync({ prevDataRequest, nextMeta, @@ -309,7 +322,6 @@ export class EmsVectorTileLayer extends AbstractLayer { return; } this._addSpriteSheetToMapFromImageData(newJson, imageData, mbMap); - // sync layers const layers = vectorStyle.layers ? vectorStyle.layers : []; layers.forEach((layer) => { @@ -391,6 +403,27 @@ export class EmsVectorTileLayer extends AbstractLayer { }); } + _setLanguage(mbMap: MbMap, mbLayer: LayerSpecification, mbLayerId: string) { + const locale = this.getLocale(); + if (locale === null || locale === NO_EMS_LOCALE) { + if (mbLayer.type !== 'symbol') return; + + const textProperty = mbLayer.layout?.['text-field']; + if (mbLayer.layout && textProperty) { + mbMap.setLayoutProperty(mbLayerId, 'text-field', textProperty); + } + return; + } + + const textProperty = + locale === AUTOSELECT_EMS_LOCALE + ? TMSService.transformLanguageProperty(mbLayer, i18n.getLocale()) + : TMSService.transformLanguageProperty(mbLayer, locale); + if (textProperty !== undefined) { + mbMap.setLayoutProperty(mbLayerId, 'text-field', textProperty); + } + } + _setLayerZoomRange(mbMap: MbMap, mbLayer: LayerSpecification, mbLayerId: string) { let minZoom = this.getMinZoom(); if (typeof mbLayer.minzoom === 'number') { @@ -414,6 +447,7 @@ export class EmsVectorTileLayer extends AbstractLayer { this.syncVisibilityWithMb(mbMap, mbLayerId); this._setLayerZoomRange(mbMap, mbLayer, mbLayerId); this._setOpacityForType(mbMap, mbLayer, mbLayerId); + this._setLanguage(mbMap, mbLayer, mbLayerId); }); } @@ -425,6 +459,10 @@ export class EmsVectorTileLayer extends AbstractLayer { return true; } + supportsLabelLocales(): boolean { + return true; + } + async getLicensedFeatures() { return this._source.getLicensedFeatures(); } diff --git a/x-pack/plugins/maps/public/classes/layers/layer.tsx b/x-pack/plugins/maps/public/classes/layers/layer.tsx index 29aa19103e511..369f3a0099d66 100644 --- a/x-pack/plugins/maps/public/classes/layers/layer.tsx +++ b/x-pack/plugins/maps/public/classes/layers/layer.tsx @@ -53,6 +53,7 @@ export interface ILayer { supportsFitToBounds(): Promise; getAttributions(): Promise; getLabel(): string; + getLocale(): string | null; hasLegendDetails(): Promise; renderLegendDetails(): ReactElement | null; showAtZoomLevel(zoom: number): boolean; @@ -101,6 +102,7 @@ export interface ILayer { isPreviewLayer: () => boolean; areLabelsOnTop: () => boolean; supportsLabelsOnTop: () => boolean; + supportsLabelLocales: () => boolean; isFittable(): Promise; isIncludeInFitToBounds(): boolean; getLicensedFeatures(): Promise; @@ -250,6 +252,10 @@ export class AbstractLayer implements ILayer { return this._descriptor.label ? this._descriptor.label : ''; } + getLocale(): string | null { + return null; + } + getLayerIcon(isTocIcon: boolean): LayerIcon { return { icon: , @@ -461,6 +467,10 @@ export class AbstractLayer implements ILayer { return false; } + supportsLabelLocales(): boolean { + return false; + } + async getLicensedFeatures(): Promise { return []; } diff --git a/x-pack/plugins/maps/public/connected_components/edit_layer_panel/layer_settings/index.tsx b/x-pack/plugins/maps/public/connected_components/edit_layer_panel/layer_settings/index.tsx index 931557a3febe8..44336a5bbaf56 100644 --- a/x-pack/plugins/maps/public/connected_components/edit_layer_panel/layer_settings/index.tsx +++ b/x-pack/plugins/maps/public/connected_components/edit_layer_panel/layer_settings/index.tsx @@ -12,6 +12,7 @@ import { clearLayerAttribution, setLayerAttribution, updateLayerLabel, + updateLayerLocale, updateLayerMaxZoom, updateLayerMinZoom, updateLayerAlpha, @@ -26,6 +27,7 @@ function mapDispatchToProps(dispatch: Dispatch) { setLayerAttribution: (id: string, attribution: Attribution) => dispatch(setLayerAttribution(id, attribution)), updateLabel: (id: string, label: string) => dispatch(updateLayerLabel(id, label)), + updateLocale: (id: string, locale: string) => dispatch(updateLayerLocale(id, locale)), updateMinZoom: (id: string, minZoom: number) => dispatch(updateLayerMinZoom(id, minZoom)), updateMaxZoom: (id: string, maxZoom: number) => dispatch(updateLayerMaxZoom(id, maxZoom)), updateAlpha: (id: string, alpha: number) => dispatch(updateLayerAlpha(id, alpha)), diff --git a/x-pack/plugins/maps/public/connected_components/edit_layer_panel/layer_settings/layer_settings.tsx b/x-pack/plugins/maps/public/connected_components/edit_layer_panel/layer_settings/layer_settings.tsx index e975834f2cf50..4ae95b9dc5c48 100644 --- a/x-pack/plugins/maps/public/connected_components/edit_layer_panel/layer_settings/layer_settings.tsx +++ b/x-pack/plugins/maps/public/connected_components/edit_layer_panel/layer_settings/layer_settings.tsx @@ -11,6 +11,7 @@ import { EuiPanel, EuiFormRow, EuiFieldText, + EuiSelect, EuiSpacer, EuiSwitch, EuiSwitchEvent, @@ -20,7 +21,7 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { ValidatedDualRange } from '@kbn/kibana-react-plugin/public'; import { Attribution } from '../../../../common/descriptor_types'; -import { MAX_ZOOM } from '../../../../common/constants'; +import { AUTOSELECT_EMS_LOCALE, NO_EMS_LOCALE, MAX_ZOOM } from '../../../../common/constants'; import { AlphaSlider } from '../../../components/alpha_slider'; import { ILayer } from '../../../classes/layers/layer'; import { AttributionFormRow } from './attribution_form_row'; @@ -30,6 +31,7 @@ export interface Props { clearLayerAttribution: (layerId: string) => void; setLayerAttribution: (id: string, attribution: Attribution) => void; updateLabel: (layerId: string, label: string) => void; + updateLocale: (layerId: string, locale: string) => void; updateMinZoom: (layerId: string, minZoom: number) => void; updateMaxZoom: (layerId: string, maxZoom: number) => void; updateAlpha: (layerId: string, alpha: number) => void; @@ -48,6 +50,11 @@ export function LayerSettings(props: Props) { props.updateLabel(layerId, label); }; + const onLocaleChange = (event: ChangeEvent) => { + const { value } = event.target; + if (value) props.updateLocale(layerId, value); + }; + const onZoomChange = (value: [string, string]) => { props.updateMinZoom(layerId, Math.max(minVisibilityZoom, parseInt(value[0], 10))); props.updateMaxZoom(layerId, Math.min(maxVisibilityZoom, parseInt(value[1], 10))); @@ -155,6 +162,58 @@ export function LayerSettings(props: Props) { ); }; + const renderShowLocaleSelector = () => { + if (!props.layer.supportsLabelLocales()) { + return null; + } + + const options = [ + { + text: i18n.translate( + 'xpack.maps.layerPanel.settingsPanel.labelLanguageAutoselectDropDown', + { + defaultMessage: 'Autoselect based on Kibana locale', + } + ), + value: AUTOSELECT_EMS_LOCALE, + }, + { value: 'ar', text: 'العربية' }, + { value: 'de', text: 'Deutsch' }, + { value: 'en', text: 'English' }, + { value: 'es', text: 'Español' }, + { value: 'fr-fr', text: 'Français' }, + { value: 'hi-in', text: 'हिन्दी' }, + { value: 'it', text: 'Italiano' }, + { value: 'ja-jp', text: '日本語' }, + { value: 'ko', text: '한국어' }, + { value: 'pt-pt', text: 'Português' }, + { value: 'ru-ru', text: 'русский' }, + { value: 'zh-cn', text: '简体中文' }, + { + text: i18n.translate('xpack.maps.layerPanel.settingsPanel.labelLanguageNoneDropDown', { + defaultMessage: 'None', + }), + value: NO_EMS_LOCALE, + }, + ]; + + return ( + + + + ); + }; + return ( @@ -172,6 +231,7 @@ export function LayerSettings(props: Props) { {renderZoomSliders()} {renderShowLabelsOnTop()} + {renderShowLocaleSelector()} {renderIncludeInFitToBounds()} diff --git a/yarn.lock b/yarn.lock index ec5afced2df22..ef1d5d849ca75 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1483,15 +1483,16 @@ "@elastic/transport" "^8.0.2" tslib "^2.3.0" -"@elastic/ems-client@8.3.0": - version "8.3.0" - resolved "https://registry.yarnpkg.com/@elastic/ems-client/-/ems-client-8.3.0.tgz#9d40c02e33c407d433b8e509d83c5edec24c4902" - integrity sha512-DlJDyUQzNrxGbS0AWxGiBNfq1hPQUP3Ib/Zyotgv7+VGGklb0mBwppde7WLVvuj0E+CYc6E63TJsoD8KNUO0MQ== +"@elastic/ems-client@8.3.2": + version "8.3.2" + resolved "https://registry.yarnpkg.com/@elastic/ems-client/-/ems-client-8.3.2.tgz#a12eafcfd9ac8d3068da78a5a77503ea8a89f67c" + integrity sha512-81u+Z7+4Y2Fu+sTl9QOKdG3SVeCzzpfyCsHFR8X0V2WFCpQa+SU4sSN9WhdLHz/pe9oi6Gtt5eFMF90TOO/ckg== dependencies: "@types/geojson" "^7946.0.7" "@types/lru-cache" "^5.1.0" "@types/topojson-client" "^3.0.0" "@types/topojson-specification" "^1.0.1" + chroma-js "^2.1.0" lodash "^4.17.15" lru-cache "^6.0.0" semver "^7.3.2" From d12156ec22324b882ff6aa97bc044537d1f44393 Mon Sep 17 00:00:00 2001 From: Lisa Cawley Date: Thu, 19 May 2022 08:06:32 -0700 Subject: [PATCH 08/35] [DOCS] Add severity field to case APIs (#132289) --- docs/api/cases/cases-api-add-comment.asciidoc | 1 + docs/api/cases/cases-api-create.asciidoc | 5 + docs/api/cases/cases-api-find-cases.asciidoc | 5 + .../cases-api-get-case-activity.asciidoc | 402 +++--------------- docs/api/cases/cases-api-get-case.asciidoc | 1 + docs/api/cases/cases-api-push.asciidoc | 1 + .../cases/cases-api-update-comment.asciidoc | 1 + docs/api/cases/cases-api-update.asciidoc | 5 + .../plugins/cases/docs/openapi/bundled.json | 37 ++ .../plugins/cases/docs/openapi/bundled.yaml | 27 ++ .../examples/create_case_response.yaml | 1 + .../examples/update_case_response.yaml | 1 + .../schemas/case_response_properties.yaml | 2 + .../openapi/components/schemas/severity.yaml | 8 + .../cases/docs/openapi/paths/api@cases.yaml | 4 + .../openapi/paths/s@{spaceid}@api@cases.yaml | 4 + 16 files changed, 151 insertions(+), 354 deletions(-) create mode 100644 x-pack/plugins/cases/docs/openapi/components/schemas/severity.yaml diff --git a/docs/api/cases/cases-api-add-comment.asciidoc b/docs/api/cases/cases-api-add-comment.asciidoc index 203492d6aa632..b179c9ac2e4fb 100644 --- a/docs/api/cases/cases-api-add-comment.asciidoc +++ b/docs/api/cases/cases-api-add-comment.asciidoc @@ -120,6 +120,7 @@ The API returns details about the case and its comments. For example: }, "owner": "cases", "duration": null, + "severity": "low", "closed_at": null, "closed_by": null, "created_at": "2022-03-24T00:37:03.906Z", diff --git a/docs/api/cases/cases-api-create.asciidoc b/docs/api/cases/cases-api-create.asciidoc index 73c89937466b3..b39125cf7538e 100644 --- a/docs/api/cases/cases-api-create.asciidoc +++ b/docs/api/cases/cases-api-create.asciidoc @@ -140,6 +140,10 @@ An object that contains the case settings. (Required, boolean) Turns alert syncing on or off. ==== +`severity`:: +(Optional,string) The severity of the case. Valid values are: `critical`, `high`, +`low`, and `medium`. + `tags`:: (Required, string array) The words and phrases that help categorize cases. It can be an empty array. @@ -206,6 +210,7 @@ the case identifier, version, and creation time. For example: "owner": "securitySolution", "description": "James Bond clicked on a highly suspicious email banner advertising cheap holidays for underpaid civil servants. Operation bubblegum is active. Repeat - operation bubblegum is now active", "duration": null, + "severity": "low", "closed_at": null, "closed_by": null, "created_at": "2022-05-13T09:16:17.416Z", diff --git a/docs/api/cases/cases-api-find-cases.asciidoc b/docs/api/cases/cases-api-find-cases.asciidoc index 3e94dd56ffa36..92b23a4aafb8d 100644 --- a/docs/api/cases/cases-api-find-cases.asciidoc +++ b/docs/api/cases/cases-api-find-cases.asciidoc @@ -62,6 +62,10 @@ filters the objects in the response. (Optional, string or array of strings) The fields to perform the `simple_query_string` parsed query against. +`severity`:: +(Optional,string) The severity of the case. Valid values are: `critical`, `high`, +`low`, and `medium`. + `sortField`:: (Optional, string) Determines which field is used to sort the results, `createdAt` or `updatedAt`. Defaults to `createdAt`. @@ -126,6 +130,7 @@ The API returns a JSON object listing the retrieved cases. For example: }, "owner": "securitySolution", "duration": null, + "severity": "low", "closed_at": null, "closed_by": null, "created_at": "2022-03-29T13:03:23.533Z", diff --git a/docs/api/cases/cases-api-get-case-activity.asciidoc b/docs/api/cases/cases-api-get-case-activity.asciidoc index 25d102dc11ee7..0f931965df248 100644 --- a/docs/api/cases/cases-api-get-case-activity.asciidoc +++ b/docs/api/cases/cases-api-get-case-activity.asciidoc @@ -51,362 +51,56 @@ The API returns a JSON object with all the activity for the case. For example: [source,json] -------------------------------------------------- [ - { - "action": "create", - "action_id": "5275af50-5e7d-11ec-9ee9-cd64f0b77b3c", - "case_id": "5257a000-5e7d-11ec-9ee9-cd64f0b77b3c", - "comment_id": null, - "created_at": "2021-12-16T14:34:48.709Z", - "created_by": { - "email": "", - "full_name": "", - "username": "elastic" - }, - "owner": "securitySolution", - "payload": { - "connector": { - "fields": null, - "id": "none", - "name": "none", - "type": ".none" - }, - "description": "migrating user actions", - "settings": { - "syncAlerts": true - }, - "status": "open", - "tags": [ - "user", - "actions" - ], - "title": "User actions", - "owner": "securitySolution" - }, - "sub_case_id": "", - "type": "create_case" - }, - { - "action": "create", - "action_id": "72e73240-5e7d-11ec-9ee9-cd64f0b77b3c", - "case_id": "5257a000-5e7d-11ec-9ee9-cd64f0b77b3c", - "comment_id": "72a03e30-5e7d-11ec-9ee9-cd64f0b77b3c", - "created_at": "2021-12-16T14:35:42.872Z", - "created_by": { - "email": "", - "full_name": "", - "username": "elastic" - }, - "owner": "securitySolution", - "payload": { - "comment": { - "comment": "a comment", - "owner": "securitySolution", - "type": "user" - } - }, - "sub_case_id": "", - "type": "comment" - }, - { - "action": "update", - "action_id": "7685b5c0-5e7d-11ec-9ee9-cd64f0b77b3c", - "case_id": "5257a000-5e7d-11ec-9ee9-cd64f0b77b3c", - "comment_id": null, - "created_at": "2021-12-16T14:35:48.826Z", - "created_by": { - "email": "", - "full_name": "", - "username": "elastic" - }, - "owner": "securitySolution", - "payload": { - "title": "User actions!" - }, - "sub_case_id": "", - "type": "title" - }, - { - "action": "update", - "action_id": "7a2d8810-5e7d-11ec-9ee9-cd64f0b77b3c", - "case_id": "5257a000-5e7d-11ec-9ee9-cd64f0b77b3c", - "comment_id": null, - "created_at": "2021-12-16T14:35:55.421Z", - "created_by": { - "email": "", - "full_name": "", - "username": "elastic" - }, - "owner": "securitySolution", - "payload": { - "description": "migrating user actions and update!" - }, - "sub_case_id": "", - "type": "description" - }, - { - "action": "update", - "action_id": "7f942160-5e7d-11ec-9ee9-cd64f0b77b3c", - "case_id": "5257a000-5e7d-11ec-9ee9-cd64f0b77b3c", - "comment_id": "72a03e30-5e7d-11ec-9ee9-cd64f0b77b3c", - "created_at": "2021-12-16T14:36:04.120Z", - "created_by": { - "email": "", - "full_name": "", - "username": "elastic" - }, - "owner": "securitySolution", - "payload": { - "comment": { - "comment": "a comment updated!", - "owner": "securitySolution", - "type": "user" - } - }, - "sub_case_id": "", - "type": "comment" - }, - { - "action": "add", - "action_id": "8591a380-5e7d-11ec-9ee9-cd64f0b77b3c", - "case_id": "5257a000-5e7d-11ec-9ee9-cd64f0b77b3c", - "comment_id": null, - "created_at": "2021-12-16T14:36:13.840Z", - "created_by": { - "email": "", - "full_name": "", - "username": "elastic" - }, - "owner": "securitySolution", - "payload": { - "tags": [ - "migration" - ] - }, - "sub_case_id": "", - "type": "tags" - }, - { - "action": "delete", - "action_id": "8591a381-5e7d-11ec-9ee9-cd64f0b77b3c", - "case_id": "5257a000-5e7d-11ec-9ee9-cd64f0b77b3c", - "comment_id": null, - "created_at": "2021-12-16T14:36:13.840Z", - "created_by": { - "email": "", - "full_name": "", - "username": "elastic" - }, - "owner": "securitySolution", - "payload": { - "tags": [ - "user" - ] - }, - "sub_case_id": "", - "type": "tags" + { + "created_at": "2022-12-16T14:34:48.709Z", + "created_by": { + "email": "", + "full_name": "", + "username": "elastic" }, - { - "action": "update", - "action_id": "87fadb50-5e7d-11ec-9ee9-cd64f0b77b3c", - "case_id": "5257a000-5e7d-11ec-9ee9-cd64f0b77b3c", - "comment_id": null, - "created_at": "2021-12-16T14:36:17.764Z", - "created_by": { - "email": "", - "full_name": "", - "username": "elastic" - }, - "owner": "securitySolution", - "payload": { - "settings": { - "syncAlerts": false - } - }, - "sub_case_id": "", - "type": "settings" - }, - { - "action": "update", - "action_id": "89ca4420-5e7d-11ec-9ee9-cd64f0b77b3c", - "case_id": "5257a000-5e7d-11ec-9ee9-cd64f0b77b3c", - "comment_id": null, - "created_at": "2021-12-16T14:36:21.509Z", - "created_by": { - "email": "", - "full_name": "", - "username": "elastic" - }, - "owner": "securitySolution", - "payload": { - "status": "in-progress" - }, - "sub_case_id": "", - "type": "status" - }, - { - "action": "update", - "action_id": "9060aae0-5e7d-11ec-9ee9-cd64f0b77b3c", - "case_id": "5257a000-5e7d-11ec-9ee9-cd64f0b77b3c", - "comment_id": null, - "created_at": "2021-12-16T14:36:32.716Z", - "created_by": { - "email": "", - "full_name": "", - "username": "elastic" - }, - "owner": "securitySolution", - "payload": { - "connector": { - "fields": { - "issueType": "10001", - "parent": null, - "priority": "High" - }, - "id": "6773fba0-5e7d-11ec-9ee9-cd64f0b77b3c", - "name": "Jira", - "type": ".jira" - } - }, - "sub_case_id": "", - "type": "connector" - }, - { - "action": "push_to_service", - "action_id": "988579d0-5e7d-11ec-9ee9-cd64f0b77b3c", - "case_id": "5257a000-5e7d-11ec-9ee9-cd64f0b77b3c", - "comment_id": null, - "created_at": "2021-12-16T14:36:46.443Z", - "created_by": { - "email": "", - "full_name": "", - "username": "elastic" - }, - "owner": "securitySolution", - "payload": { - "externalService": { - "connector_id": "6773fba0-5e7d-11ec-9ee9-cd64f0b77b3c", - "connector_name": "Jira", - "external_id": "26225", - "external_title": "CASES-229", - "external_url": "https://example.com/browse/CASES-229", - "pushed_at": "2021-12-16T14:36:46.443Z", - "pushed_by": { - "email": "", - "full_name": "", - "username": "elastic" - } - } - }, - "sub_case_id": "", - "type": "pushed" + "owner": "securitySolution", + "action": "create", + "payload": { + "title": "User actions", + "tags": [ + "user", + "actions" + ], + "connector": { + "fields": null, + "id": "none", + "name": "none", + "type": ".none" + }, + "settings": { + "syncAlerts": true + }, + "owner": "cases", + "severity": "low", + "description": "migrating user actions", + "status": "open" }, - { - "action": "update", - "action_id": "bcb76020-5e7d-11ec-9ee9-cd64f0b77b3c", - "case_id": "5257a000-5e7d-11ec-9ee9-cd64f0b77b3c", - "comment_id": null, - "created_at": "2021-12-16T14:37:46.863Z", - "created_by": { - "email": "", - "full_name": "", - "username": "elastic" - }, - "owner": "securitySolution", - "payload": { - "connector": { - "fields": { - "incidentTypes": [ - "17", - "4" - ], - "severityCode": "5" - }, - "id": "b3214df0-5e7d-11ec-9ee9-cd64f0b77b3c", - "name": "IBM", - "type": ".resilient" - } - }, - "sub_case_id": "", - "type": "connector" - }, - { - "action": "push_to_service", - "action_id": "c0338e90-5e7d-11ec-9ee9-cd64f0b77b3c", - "case_id": "5257a000-5e7d-11ec-9ee9-cd64f0b77b3c", - "comment_id": null, - "created_at": "2021-12-16T14:37:53.016Z", - "created_by": { - "email": "", - "full_name": "", - "username": "elastic" - }, - "owner": "securitySolution", - "payload": { - "externalService": { - "connector_id": "b3214df0-5e7d-11ec-9ee9-cd64f0b77b3c", - "connector_name": "IBM", - "external_id": "17574", - "external_title": "17574", - "external_url": "https://example.com/#incidents/17574", - "pushed_at": "2021-12-16T14:37:53.016Z", - "pushed_by": { - "email": "", - "full_name": "", - "username": "elastic" - } - } - }, - "sub_case_id": "", - "type": "pushed" + "type": "create_case", + "action_id": "5275af50-5e7d-11ec-9ee9-cd64f0b77b3c", + "case_id": "5257a000-5e7d-11ec-9ee9-cd64f0b77b3c", + "comment_id": null + }, + { + "created_at": "2022-12-16T14:35:42.872Z", + "created_by": { + "email": "", + "full_name": "", + "username": "elastic" }, - { - "action": "update", - "action_id": "c5b6d7a0-5e7d-11ec-9ee9-cd64f0b77b3c", - "case_id": "5257a000-5e7d-11ec-9ee9-cd64f0b77b3c", - "comment_id": null, - "created_at": "2021-12-16T14:38:01.895Z", - "created_by": { - "email": "", - "full_name": "", - "username": "elastic" - }, - "owner": "securitySolution", - "payload": { - "connector": { - "fields": { - "issueType": "10001", - "parent": null, - "priority": "Lowest" - }, - "id": "6773fba0-5e7d-11ec-9ee9-cd64f0b77b3c", - "name": "Jira", - "type": ".jira" - } - }, - "sub_case_id": "", - "type": "connector" + "owner": "cases", + "action": "add", + "payload": { + "tags": ["bubblegum"] }, - { - "action": "create", - "action_id": "ca8f61c0-5e7d-11ec-9ee9-cd64f0b77b3c", - "case_id": "5257a000-5e7d-11ec-9ee9-cd64f0b77b3c", - "comment_id": "ca1d17f0-5e7d-11ec-9ee9-cd64f0b77b3c", - "created_at": "2021-12-16T14:38:09.649Z", - "created_by": { - "email": "", - "full_name": "", - "username": "elastic" - }, - "owner": "securitySolution", - "payload": { - "comment": { - "comment": "and another comment!", - "owner": "securitySolution", - "type": "user" - } - }, - "sub_case_id": "", - "type": "comment" - } - ] + "type": "tags", + "action_id": "72e73240-5e7d-11ec-9ee9-cd64f0b77b3c", + "case_id": "5257a000-5e7d-11ec-9ee9-cd64f0b77b3c", + "comment_id": null + } +] -------------------------------------------------- \ No newline at end of file diff --git a/docs/api/cases/cases-api-get-case.asciidoc b/docs/api/cases/cases-api-get-case.asciidoc index 42cf0672065e7..a3adc90fe09bf 100644 --- a/docs/api/cases/cases-api-get-case.asciidoc +++ b/docs/api/cases/cases-api-get-case.asciidoc @@ -91,6 +91,7 @@ The API returns a JSON object with the retrieved case. For example: "syncAlerts": true }, "owner": "securitySolution", + "severity": "low", "duration": null, "tags": [ "phishing", diff --git a/docs/api/cases/cases-api-push.asciidoc b/docs/api/cases/cases-api-push.asciidoc index 16c411104caed..46dbc1110d589 100644 --- a/docs/api/cases/cases-api-push.asciidoc +++ b/docs/api/cases/cases-api-push.asciidoc @@ -68,6 +68,7 @@ The API returns a JSON object representing the pushed case. For example: "syncAlerts": true }, "owner": "securitySolution", + "severity": "low", "duration": null, "closed_at": null, "closed_by": null, diff --git a/docs/api/cases/cases-api-update-comment.asciidoc b/docs/api/cases/cases-api-update-comment.asciidoc index d00d1eb66ea7c..a4ea53ec19468 100644 --- a/docs/api/cases/cases-api-update-comment.asciidoc +++ b/docs/api/cases/cases-api-update-comment.asciidoc @@ -135,6 +135,7 @@ The API returns details about the case and its comments. For example: "settings": {"syncAlerts":false}, "owner": "cases", "duration": null, + "severity": "low", "closed_at": null, "closed_by": null, "created_at": "2022-03-24T00:37:03.906Z", diff --git a/docs/api/cases/cases-api-update.asciidoc b/docs/api/cases/cases-api-update.asciidoc index ebad2feaedff4..ea33394a6ee63 100644 --- a/docs/api/cases/cases-api-update.asciidoc +++ b/docs/api/cases/cases-api-update.asciidoc @@ -144,6 +144,10 @@ An object that contains the case settings. (Required, boolean) Turn on or off synching with alerts. ===== +`severity`:: +(Optional,string) The severity of the case. Valid values are: `critical`, `high`, +`low`, and `medium`. + `status`:: (Optional, string) The case status. Valid values are: `closed`, `in-progress`, and `open`. @@ -227,6 +231,7 @@ The API returns the updated case with a new `version` value. For example: "owner": "securitySolution", "description": "James Bond clicked on a highly suspicious email banner advertising cheap holidays for underpaid civil servants. Operation bubblegum is active. Repeat - operation bubblegum is now active!", "duration": null, + "severity": "low", "closed_at": null, "closed_by": null, "created_at": "2022-05-13T09:16:17.416Z", diff --git a/x-pack/plugins/cases/docs/openapi/bundled.json b/x-pack/plugins/cases/docs/openapi/bundled.json index 0cb084b5beb7c..d673f470de740 100644 --- a/x-pack/plugins/cases/docs/openapi/bundled.json +++ b/x-pack/plugins/cases/docs/openapi/bundled.json @@ -157,6 +157,9 @@ } } }, + "severity": { + "$ref": "#/components/schemas/severity" + }, "tags": { "description": "The words and phrases that help categorize cases. It can be an empty array.", "type": "array", @@ -402,6 +405,9 @@ } } }, + "severity": { + "$ref": "#/components/schemas/severity" + }, "status": { "$ref": "#/components/schemas/status" }, @@ -636,6 +642,9 @@ } } }, + "severity": { + "$ref": "#/components/schemas/severity" + }, "status": { "$ref": "#/components/schemas/status" }, @@ -887,6 +896,9 @@ } } }, + "severity": { + "$ref": "#/components/schemas/severity" + }, "status": { "$ref": "#/components/schemas/status" }, @@ -1093,6 +1105,9 @@ } } }, + "severity": { + "$ref": "#/components/schemas/severity" + }, "tags": { "description": "The words and phrases that help categorize cases. It can be an empty array.", "type": "array", @@ -1338,6 +1353,9 @@ } } }, + "severity": { + "$ref": "#/components/schemas/severity" + }, "status": { "$ref": "#/components/schemas/status" }, @@ -1578,6 +1596,9 @@ } } }, + "severity": { + "$ref": "#/components/schemas/severity" + }, "status": { "$ref": "#/components/schemas/status" }, @@ -1829,6 +1850,9 @@ } } }, + "severity": { + "$ref": "#/components/schemas/severity" + }, "status": { "$ref": "#/components/schemas/status" }, @@ -1959,6 +1983,17 @@ "securitySolution" ] }, + "severity": { + "type": "string", + "description": "The severity of the case.", + "enum": [ + "critical", + "high", + "low", + "medium" + ], + "default": "low" + }, "status": { "type": "string", "description": "The status of the case.", @@ -2015,6 +2050,7 @@ "owner": "securitySolution", "description": "James Bond clicked on a highly suspicious email banner advertising cheap holidays for underpaid civil servants. Operation bubblegum is active. Repeat - operation bubblegum is now active", "duration": null, + "severity": "low", "closed_at": null, "closed_by": null, "created_at": "2022-05-13T09:16:17.416Z", @@ -2090,6 +2126,7 @@ "owner": "securitySolution", "description": "James Bond clicked on a highly suspicious email banner advertising cheap holidays for underpaid civil servants. Operation bubblegum is active. Repeat - operation bubblegum is now active!", "duration": null, + "severity": "low", "closed_at": null, "closed_by": null, "created_at": "2022-05-13T09:16:17.416Z", diff --git a/x-pack/plugins/cases/docs/openapi/bundled.yaml b/x-pack/plugins/cases/docs/openapi/bundled.yaml index 083aef3c25ad2..6dcde228ebd7c 100644 --- a/x-pack/plugins/cases/docs/openapi/bundled.yaml +++ b/x-pack/plugins/cases/docs/openapi/bundled.yaml @@ -147,6 +147,8 @@ paths: syncAlerts: description: Turns alert syncing on or off. type: boolean + severity: + $ref: '#/components/schemas/severity' tags: description: >- The words and phrases that help categorize cases. It can be @@ -363,6 +365,8 @@ paths: syncAlerts: type: boolean example: true + severity: + $ref: '#/components/schemas/severity' status: $ref: '#/components/schemas/status' tags: @@ -569,6 +573,8 @@ paths: syncAlerts: description: Turns alert syncing on or off. type: boolean + severity: + $ref: '#/components/schemas/severity' status: $ref: '#/components/schemas/status' tags: @@ -784,6 +790,8 @@ paths: syncAlerts: type: boolean example: true + severity: + $ref: '#/components/schemas/severity' status: $ref: '#/components/schemas/status' tags: @@ -960,6 +968,8 @@ paths: syncAlerts: description: Turns alert syncing on or off. type: boolean + severity: + $ref: '#/components/schemas/severity' tags: description: >- The words and phrases that help categorize cases. It can be @@ -1176,6 +1186,8 @@ paths: syncAlerts: type: boolean example: true + severity: + $ref: '#/components/schemas/severity' status: $ref: '#/components/schemas/status' tags: @@ -1384,6 +1396,8 @@ paths: syncAlerts: description: Turns alert syncing on or off. type: boolean + severity: + $ref: '#/components/schemas/severity' status: $ref: '#/components/schemas/status' tags: @@ -1599,6 +1613,8 @@ paths: syncAlerts: type: boolean example: true + severity: + $ref: '#/components/schemas/severity' status: $ref: '#/components/schemas/status' tags: @@ -1686,6 +1702,15 @@ components: - cases - observability - securitySolution + severity: + type: string + description: The severity of the case. + enum: + - critical + - high + - low + - medium + default: low status: type: string description: The status of the case. @@ -1738,6 +1763,7 @@ components: cheap holidays for underpaid civil servants. Operation bubblegum is active. Repeat - operation bubblegum is now active duration: null + severity: low closed_at: null closed_by: null created_at: '2022-05-13T09:16:17.416Z' @@ -1804,6 +1830,7 @@ components: cheap holidays for underpaid civil servants. Operation bubblegum is active. Repeat - operation bubblegum is now active! duration: null + severity: low closed_at: null closed_by: null created_at: '2022-05-13T09:16:17.416Z' diff --git a/x-pack/plugins/cases/docs/openapi/components/examples/create_case_response.yaml b/x-pack/plugins/cases/docs/openapi/components/examples/create_case_response.yaml index bc5fa1f5bc049..9646425bca0fe 100644 --- a/x-pack/plugins/cases/docs/openapi/components/examples/create_case_response.yaml +++ b/x-pack/plugins/cases/docs/openapi/components/examples/create_case_response.yaml @@ -18,6 +18,7 @@ value: "owner": "securitySolution", "description": "James Bond clicked on a highly suspicious email banner advertising cheap holidays for underpaid civil servants. Operation bubblegum is active. Repeat - operation bubblegum is now active", "duration": null, + "severity": "low", "closed_at": null, "closed_by": null, "created_at": "2022-05-13T09:16:17.416Z", diff --git a/x-pack/plugins/cases/docs/openapi/components/examples/update_case_response.yaml b/x-pack/plugins/cases/docs/openapi/components/examples/update_case_response.yaml index 114669b893651..c7b02cd47deaa 100644 --- a/x-pack/plugins/cases/docs/openapi/components/examples/update_case_response.yaml +++ b/x-pack/plugins/cases/docs/openapi/components/examples/update_case_response.yaml @@ -19,6 +19,7 @@ value: "owner": "securitySolution", "description": "James Bond clicked on a highly suspicious email banner advertising cheap holidays for underpaid civil servants. Operation bubblegum is active. Repeat - operation bubblegum is now active!", "duration": null, + "severity": "low", "closed_at": null, "closed_by": null, "created_at": "2022-05-13T09:16:17.416Z", diff --git a/x-pack/plugins/cases/docs/openapi/components/schemas/case_response_properties.yaml b/x-pack/plugins/cases/docs/openapi/components/schemas/case_response_properties.yaml index 6a2c3c3963c3c..53f1fd3910224 100644 --- a/x-pack/plugins/cases/docs/openapi/components/schemas/case_response_properties.yaml +++ b/x-pack/plugins/cases/docs/openapi/components/schemas/case_response_properties.yaml @@ -84,6 +84,8 @@ settings: syncAlerts: type: boolean example: true +severity: + $ref: 'severity.yaml' status: $ref: 'status.yaml' tags: diff --git a/x-pack/plugins/cases/docs/openapi/components/schemas/severity.yaml b/x-pack/plugins/cases/docs/openapi/components/schemas/severity.yaml new file mode 100644 index 0000000000000..cf5967f8f012e --- /dev/null +++ b/x-pack/plugins/cases/docs/openapi/components/schemas/severity.yaml @@ -0,0 +1,8 @@ +type: string +description: The severity of the case. +enum: + - critical + - high + - low + - medium +default: low \ No newline at end of file diff --git a/x-pack/plugins/cases/docs/openapi/paths/api@cases.yaml b/x-pack/plugins/cases/docs/openapi/paths/api@cases.yaml index c37bb3ecef645..62816ae2767cc 100644 --- a/x-pack/plugins/cases/docs/openapi/paths/api@cases.yaml +++ b/x-pack/plugins/cases/docs/openapi/paths/api@cases.yaml @@ -30,6 +30,8 @@ post: syncAlerts: description: Turns alert syncing on or off. type: boolean + severity: + $ref: '../components/schemas/severity.yaml' tags: description: The words and phrases that help categorize cases. It can be an empty array. type: array @@ -123,6 +125,8 @@ patch: syncAlerts: description: Turns alert syncing on or off. type: boolean + severity: + $ref: '../components/schemas/severity.yaml' status: $ref: '../components/schemas/status.yaml' tags: diff --git a/x-pack/plugins/cases/docs/openapi/paths/s@{spaceid}@api@cases.yaml b/x-pack/plugins/cases/docs/openapi/paths/s@{spaceid}@api@cases.yaml index c03ea64a53538..b2c2a8e4e11f1 100644 --- a/x-pack/plugins/cases/docs/openapi/paths/s@{spaceid}@api@cases.yaml +++ b/x-pack/plugins/cases/docs/openapi/paths/s@{spaceid}@api@cases.yaml @@ -31,6 +31,8 @@ post: syncAlerts: description: Turns alert syncing on or off. type: boolean + severity: + $ref: '../components/schemas/severity.yaml' tags: description: The words and phrases that help categorize cases. It can be an empty array. type: array @@ -126,6 +128,8 @@ patch: syncAlerts: description: Turns alert syncing on or off. type: boolean + severity: + $ref: '../components/schemas/severity.yaml' status: $ref: '../components/schemas/status.yaml' tags: From dd6dacf0035e959885b04296444603492d6b0c71 Mon Sep 17 00:00:00 2001 From: Spencer Date: Thu, 19 May 2022 08:21:20 -0700 Subject: [PATCH 09/35] [jest/ci-stats] when jest fails to execute a test file, report it as a failure (#132527) --- packages/kbn-test/src/jest/ci_stats_jest_reporter.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/kbn-test/src/jest/ci_stats_jest_reporter.ts b/packages/kbn-test/src/jest/ci_stats_jest_reporter.ts index 3ac4a64c1f3f7..6cf979eb46a26 100644 --- a/packages/kbn-test/src/jest/ci_stats_jest_reporter.ts +++ b/packages/kbn-test/src/jest/ci_stats_jest_reporter.ts @@ -41,6 +41,7 @@ export default class CiStatsJestReporter extends BaseReporter { private startTime: number | undefined; private passCount = 0; private failCount = 0; + private testExecErrorCount = 0; private group: CiStatsReportTestsOptions['group'] | undefined; private readonly testRuns: CiStatsReportTestsOptions['testRuns'] = []; @@ -90,6 +91,10 @@ export default class CiStatsJestReporter extends BaseReporter { return; } + if (testResult.testExecError) { + this.testExecErrorCount += 1; + } + let elapsedTime = 0; for (const t of testResult.testResults) { const result = t.status === 'failed' ? 'fail' : t.status === 'passed' ? 'pass' : 'skip'; @@ -123,7 +128,8 @@ export default class CiStatsJestReporter extends BaseReporter { } this.group.durationMs = Date.now() - this.startTime; - this.group.result = this.failCount ? 'fail' : this.passCount ? 'pass' : 'skip'; + this.group.result = + this.failCount || this.testExecErrorCount ? 'fail' : this.passCount ? 'pass' : 'skip'; await this.reporter.reportTests({ group: this.group, From 75941b1eaaa862ce9525037f99e44302a675b633 Mon Sep 17 00:00:00 2001 From: Marco Liberati Date: Thu, 19 May 2022 18:01:54 +0200 Subject: [PATCH 10/35] Prevent react event pooling to clear data when used (#132419) --- .../operations/definitions/date_histogram.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.tsx index 3b6d75879640d..3bbd329a39396 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.tsx @@ -197,12 +197,15 @@ export const dateHistogramOperation: OperationDefinition< const onChangeDropPartialBuckets = useCallback( (ev: EuiSwitchEvent) => { + // updateColumnParam will be called async + // store the checked value before the event pooling clears it + const value = ev.target.checked; updateLayer((newLayer) => updateColumnParam({ layer: newLayer, columnId, paramName: 'dropPartials', - value: ev.target.checked, + value, }) ); }, From e2827350e97804601905add05debe2a7ea9690dc Mon Sep 17 00:00:00 2001 From: Ashokaditya <1849116+ashokaditya@users.noreply.github.com> Date: Thu, 19 May 2022 18:02:42 +0200 Subject: [PATCH 11/35] [Security Solution][Endpoint][EventFilters] Port Event Filters to use `ArtifactListPage` component (#130995) * Delete redundant files fixes elastic/security-team/issues/3093 * Make the event filter form work fixes elastic/security-team/issues/3093 * Update event_filters_list.test.tsx fixes elastic/security-team/issues/3093 * update form tests fixes elastic/security-team/issues/3093 * update event filter flyout fixes elastic/security-team/issues/3093 * Show apt copy when OS options are not visible * update tests fixes elastic/security-team/issues/3093 * extract static OS options review changes * test for each type of artifact list review changes * [CI] Auto-commit changed files from 'node scripts/precommit_hook.js --ref HEAD~1..HEAD --fix' * update test mocks * update form review changes * update state handler name review changes * extract test id prefix Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../public/common/store/actions.ts | 7 +- .../timeline_actions/alert_context_menu.tsx | 2 +- .../management/pages/event_filters/index.tsx | 4 +- ...nt_filters_api_client.ts => api_client.ts} | 0 .../pages/event_filters/store/action.ts | 92 --- .../pages/event_filters/store/builders.ts | 38 -- .../event_filters/store/middleware.test.ts | 387 ------------ .../pages/event_filters/store/middleware.ts | 342 ----------- .../pages/event_filters/store/reducer.test.ts | 221 ------- .../pages/event_filters/store/reducer.ts | 271 --------- .../pages/event_filters/store/selector.ts | 224 ------- .../event_filters/store/selectors.test.ts | 391 ------------ .../pages/event_filters/test_utils/index.ts | 19 +- .../management/pages/event_filters/types.ts | 29 - .../view/components/empty/index.tsx | 64 -- .../event_filter_delete_modal.test.tsx | 177 ------ .../components/event_filter_delete_modal.tsx | 159 ----- .../components/event_filters_flyout.test.tsx | 222 +++++++ .../view/components/event_filters_flyout.tsx | 239 ++++++++ .../view/components/flyout/index.test.tsx | 287 --------- .../view/components/flyout/index.tsx | 302 ---------- .../view/components/form.test.tsx | 468 +++++++++++++++ .../event_filters/view/components/form.tsx | 558 ++++++++++++++++++ .../view/components/form/index.test.tsx | 338 ----------- .../view/components/form/index.tsx | 487 --------------- .../view/components/form/translations.ts | 44 -- .../view/event_filters_list.test.tsx | 57 ++ .../event_filters/view/event_filters_list.tsx | 150 +++++ .../view/event_filters_list_page.test.tsx | 247 -------- .../view/event_filters_list_page.tsx | 339 ----------- .../pages/event_filters/view/hooks.ts | 78 --- .../pages/event_filters/view/translations.ts | 47 +- .../use_event_filters_notification.test.tsx | 230 -------- .../event_filters/{store => view}/utils.ts | 0 .../policy_artifacts_delete_modal.test.tsx | 48 +- .../flyout/policy_artifacts_flyout.test.tsx | 2 +- .../layout/policy_artifacts_layout.test.tsx | 2 +- .../list/policy_artifacts_list.test.tsx | 2 +- .../components/fleet_artifacts_card.test.tsx | 2 +- .../fleet_integration_artifacts_card.test.tsx | 2 +- .../endpoint_package_custom_extension.tsx | 2 +- .../endpoint_policy_edit_extension.tsx | 2 +- .../pages/policy/view/tabs/policy_tabs.tsx | 2 +- .../public/management/store/middleware.ts | 7 - .../public/management/store/reducer.ts | 5 - .../public/management/types.ts | 2 - .../side_panel/event_details/footer.tsx | 2 +- .../translations/translations/fr-FR.json | 30 - .../translations/translations/ja-JP.json | 30 - .../translations/translations/zh-CN.json | 30 - 50 files changed, 1754 insertions(+), 4936 deletions(-) rename x-pack/plugins/security_solution/public/management/pages/event_filters/service/{event_filters_api_client.ts => api_client.ts} (100%) delete mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/store/action.ts delete mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/store/builders.ts delete mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/store/middleware.test.ts delete mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/store/middleware.ts delete mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/store/reducer.test.ts delete mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/store/reducer.ts delete mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/store/selector.ts delete mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/store/selectors.test.ts delete mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/empty/index.tsx delete mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/event_filter_delete_modal.test.tsx delete mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/event_filter_delete_modal.tsx create mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/event_filters_flyout.test.tsx create mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/event_filters_flyout.tsx delete mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/flyout/index.test.tsx delete mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/flyout/index.tsx create mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form.test.tsx create mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form.tsx delete mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/index.test.tsx delete mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/index.tsx delete mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/translations.ts create mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list.test.tsx create mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list.tsx delete mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.test.tsx delete mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.tsx delete mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/view/hooks.ts delete mode 100644 x-pack/plugins/security_solution/public/management/pages/event_filters/view/use_event_filters_notification.test.tsx rename x-pack/plugins/security_solution/public/management/pages/event_filters/{store => view}/utils.ts (100%) diff --git a/x-pack/plugins/security_solution/public/common/store/actions.ts b/x-pack/plugins/security_solution/public/common/store/actions.ts index 585fdb98a0323..f1d5e51e172ba 100644 --- a/x-pack/plugins/security_solution/public/common/store/actions.ts +++ b/x-pack/plugins/security_solution/public/common/store/actions.ts @@ -7,7 +7,6 @@ import { EndpointAction } from '../../management/pages/endpoint_hosts/store/action'; import { PolicyDetailsAction } from '../../management/pages/policy/store/policy_details'; -import { EventFiltersPageAction } from '../../management/pages/event_filters/store/action'; export { appActions } from './app'; export { dragAndDropActions } from './drag_and_drop'; @@ -15,8 +14,4 @@ export { inputsActions } from './inputs'; export { sourcererActions } from './sourcerer'; import { RoutingAction } from './routing'; -export type AppAction = - | EndpointAction - | RoutingAction - | PolicyDetailsAction - | EventFiltersPageAction; +export type AppAction = EndpointAction | RoutingAction | PolicyDetailsAction; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx index 160252f4d11c1..05a91f094ed38 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx @@ -28,7 +28,7 @@ import { TimelineId } from '../../../../../common/types'; import { AlertData, EcsHit } from '../../../../common/components/exceptions/types'; import { useQueryAlerts } from '../../../containers/detection_engine/alerts/use_query'; import { useSignalIndex } from '../../../containers/detection_engine/alerts/use_signal_index'; -import { EventFiltersFlyout } from '../../../../management/pages/event_filters/view/components/flyout'; +import { EventFiltersFlyout } from '../../../../management/pages/event_filters/view/components/event_filters_flyout'; import { useAlertsActions } from './use_alerts_actions'; import { useExceptionFlyout } from './use_add_exception_flyout'; import { useExceptionActions } from './use_add_exception_actions'; diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/index.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/index.tsx index 86c2f2364961d..54d18f85b739a 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/index.tsx @@ -9,12 +9,12 @@ import { Route, Switch } from 'react-router-dom'; import React from 'react'; import { NotFoundPage } from '../../../app/404'; import { MANAGEMENT_ROUTING_EVENT_FILTERS_PATH } from '../../common/constants'; -import { EventFiltersListPage } from './view/event_filters_list_page'; +import { EventFiltersList } from './view/event_filters_list'; export const EventFiltersContainer = () => { return ( - + ); diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/service/event_filters_api_client.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/service/api_client.ts similarity index 100% rename from x-pack/plugins/security_solution/public/management/pages/event_filters/service/event_filters_api_client.ts rename to x-pack/plugins/security_solution/public/management/pages/event_filters/service/api_client.ts diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/action.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/store/action.ts deleted file mode 100644 index 4325c4d90951a..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/action.ts +++ /dev/null @@ -1,92 +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 { Action } from 'redux'; -import type { - ExceptionListItemSchema, - CreateExceptionListItemSchema, - UpdateExceptionListItemSchema, -} from '@kbn/securitysolution-io-ts-list-types'; -import { AsyncResourceState } from '../../../state/async_resource_state'; -import { EventFiltersListPageState } from '../types'; - -export type EventFiltersListPageDataChanged = Action<'eventFiltersListPageDataChanged'> & { - payload: EventFiltersListPageState['listPage']['data']; -}; - -export type EventFiltersListPageDataExistsChanged = - Action<'eventFiltersListPageDataExistsChanged'> & { - payload: EventFiltersListPageState['listPage']['dataExist']; - }; - -export type EventFilterForDeletion = Action<'eventFilterForDeletion'> & { - payload: ExceptionListItemSchema; -}; - -export type EventFilterDeletionReset = Action<'eventFilterDeletionReset'>; - -export type EventFilterDeleteSubmit = Action<'eventFilterDeleteSubmit'>; - -export type EventFilterDeleteStatusChanged = Action<'eventFilterDeleteStatusChanged'> & { - payload: EventFiltersListPageState['listPage']['deletion']['status']; -}; - -export type EventFiltersInitForm = Action<'eventFiltersInitForm'> & { - payload: { - entry: UpdateExceptionListItemSchema | CreateExceptionListItemSchema; - }; -}; - -export type EventFiltersInitFromId = Action<'eventFiltersInitFromId'> & { - payload: { - id: string; - }; -}; - -export type EventFiltersChangeForm = Action<'eventFiltersChangeForm'> & { - payload: { - entry?: UpdateExceptionListItemSchema | CreateExceptionListItemSchema; - hasNameError?: boolean; - hasItemsError?: boolean; - hasOSError?: boolean; - newComment?: string; - }; -}; - -export type EventFiltersUpdateStart = Action<'eventFiltersUpdateStart'>; -export type EventFiltersUpdateSuccess = Action<'eventFiltersUpdateSuccess'>; -export type EventFiltersCreateStart = Action<'eventFiltersCreateStart'>; -export type EventFiltersCreateSuccess = Action<'eventFiltersCreateSuccess'>; -export type EventFiltersCreateError = Action<'eventFiltersCreateError'>; - -export type EventFiltersFormStateChanged = Action<'eventFiltersFormStateChanged'> & { - payload: AsyncResourceState; -}; - -export type EventFiltersForceRefresh = Action<'eventFiltersForceRefresh'> & { - payload: { - forceRefresh: boolean; - }; -}; - -export type EventFiltersPageAction = - | EventFiltersListPageDataChanged - | EventFiltersListPageDataExistsChanged - | EventFiltersInitForm - | EventFiltersInitFromId - | EventFiltersChangeForm - | EventFiltersUpdateStart - | EventFiltersUpdateSuccess - | EventFiltersCreateStart - | EventFiltersCreateSuccess - | EventFiltersCreateError - | EventFiltersFormStateChanged - | EventFilterForDeletion - | EventFilterDeletionReset - | EventFilterDeleteSubmit - | EventFilterDeleteStatusChanged - | EventFiltersForceRefresh; diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/builders.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/store/builders.ts deleted file mode 100644 index 397a7c2ae1e79..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/builders.ts +++ /dev/null @@ -1,38 +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 { MANAGEMENT_DEFAULT_PAGE, MANAGEMENT_DEFAULT_PAGE_SIZE } from '../../../common/constants'; -import { EventFiltersListPageState } from '../types'; -import { createUninitialisedResourceState } from '../../../state'; - -export const initialEventFiltersPageState = (): EventFiltersListPageState => ({ - entries: [], - form: { - entry: undefined, - hasNameError: false, - hasItemsError: false, - hasOSError: false, - newComment: '', - submissionResourceState: createUninitialisedResourceState(), - }, - location: { - page_index: MANAGEMENT_DEFAULT_PAGE, - page_size: MANAGEMENT_DEFAULT_PAGE_SIZE, - filter: '', - included_policies: '', - }, - listPage: { - active: false, - forceRefresh: false, - data: createUninitialisedResourceState(), - dataExist: createUninitialisedResourceState(), - deletion: { - item: undefined, - status: createUninitialisedResourceState(), - }, - }, -}); diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/middleware.test.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/store/middleware.test.ts deleted file mode 100644 index 9ec7e84d693fd..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/middleware.test.ts +++ /dev/null @@ -1,387 +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 { applyMiddleware, createStore, Store } from 'redux'; - -import { - createSpyMiddleware, - MiddlewareActionSpyHelper, -} from '../../../../common/store/test_utils'; -import { AppAction } from '../../../../common/store/actions'; -import { createEventFiltersPageMiddleware } from './middleware'; -import { eventFiltersPageReducer } from './reducer'; - -import { initialEventFiltersPageState } from './builders'; -import { getInitialExceptionFromEvent } from './utils'; -import { createdEventFilterEntryMock, ecsEventMock } from '../test_utils'; -import { EventFiltersListPageState, EventFiltersService } from '../types'; -import { getFoundExceptionListItemSchemaMock } from '@kbn/lists-plugin/common/schemas/response/found_exception_list_item_schema.mock'; -import { isFailedResourceState, isLoadedResourceState } from '../../../state'; -import { getListFetchError } from './selector'; -import type { - ExceptionListItemSchema, - CreateExceptionListItemSchema, -} from '@kbn/securitysolution-io-ts-list-types'; -import { Immutable } from '../../../../../common/endpoint/types'; -import { parsePoliciesAndFilterToKql } from '../../../common/utils'; - -const createEventFiltersServiceMock = (): jest.Mocked => ({ - addEventFilters: jest.fn(), - getList: jest.fn(), - getOne: jest.fn(), - updateOne: jest.fn(), - deleteOne: jest.fn(), - getSummary: jest.fn(), -}); - -const createStoreSetup = (eventFiltersService: EventFiltersService) => { - const spyMiddleware = createSpyMiddleware(); - - return { - spyMiddleware, - store: createStore( - eventFiltersPageReducer, - applyMiddleware( - createEventFiltersPageMiddleware(eventFiltersService), - spyMiddleware.actionSpyMiddleware - ) - ), - }; -}; - -describe('Event filters middleware', () => { - let service: jest.Mocked; - let store: Store; - let spyMiddleware: MiddlewareActionSpyHelper; - let initialState: EventFiltersListPageState; - - beforeEach(() => { - initialState = initialEventFiltersPageState(); - service = createEventFiltersServiceMock(); - - const storeSetup = createStoreSetup(service); - - store = storeSetup.store as Store; - spyMiddleware = storeSetup.spyMiddleware; - }); - - describe('initial state', () => { - it('sets initial state properly', async () => { - expect(createStoreSetup(createEventFiltersServiceMock()).store.getState()).toStrictEqual( - initialState - ); - }); - }); - - describe('when on the List page', () => { - const changeUrl = (searchParams: string = '') => { - store.dispatch({ - type: 'userChangedUrl', - payload: { - pathname: '/administration/event_filters', - search: searchParams, - hash: '', - key: 'ylsd7h', - }, - }); - }; - - beforeEach(() => { - service.getList.mockResolvedValue(getFoundExceptionListItemSchemaMock()); - }); - - it.each([ - [undefined, undefined, undefined], - [3, 50, ['1', '2']], - ])( - 'should trigger api call to retrieve event filters with url params page_index[%s] page_size[%s] included_policies[%s]', - async (pageIndex, perPage, policies) => { - const dataLoaded = spyMiddleware.waitForAction('eventFiltersListPageDataChanged', { - validate({ payload }) { - return isLoadedResourceState(payload); - }, - }); - - changeUrl( - (pageIndex && - perPage && - `?page_index=${pageIndex}&page_size=${perPage}&included_policies=${policies}`) || - '' - ); - await dataLoaded; - - expect(service.getList).toHaveBeenCalledWith({ - page: (pageIndex ?? 0) + 1, - perPage: perPage ?? 10, - sortField: 'created_at', - sortOrder: 'desc', - filter: policies ? parsePoliciesAndFilterToKql({ policies }) : undefined, - }); - } - ); - - it('should not refresh the list if nothing in the query has changed', async () => { - const dataLoaded = spyMiddleware.waitForAction('eventFiltersListPageDataChanged', { - validate({ payload }) { - return isLoadedResourceState(payload); - }, - }); - - changeUrl(); - await dataLoaded; - const getListCallCount = service.getList.mock.calls.length; - changeUrl('&show=create'); - - expect(service.getList.mock.calls.length).toBe(getListCallCount); - }); - - it('should trigger second api call to check if data exists if first returned no records', async () => { - const dataLoaded = spyMiddleware.waitForAction('eventFiltersListPageDataExistsChanged', { - validate({ payload }) { - return isLoadedResourceState(payload); - }, - }); - - service.getList.mockResolvedValue({ - data: [], - total: 0, - page: 1, - per_page: 10, - }); - - changeUrl(); - await dataLoaded; - - expect(service.getList).toHaveBeenCalledTimes(2); - expect(service.getList).toHaveBeenNthCalledWith(2, { - page: 1, - perPage: 1, - }); - }); - - it('should dispatch a Failure if an API error was encountered', async () => { - const dataLoaded = spyMiddleware.waitForAction('eventFiltersListPageDataChanged', { - validate({ payload }) { - return isFailedResourceState(payload); - }, - }); - - service.getList.mockRejectedValue({ - body: { message: 'error message', statusCode: 500, error: 'Internal Server Error' }, - }); - - changeUrl(); - await dataLoaded; - - expect(getListFetchError(store.getState())).toEqual({ - message: 'error message', - statusCode: 500, - error: 'Internal Server Error', - }); - }); - }); - - describe('submit creation event filter', () => { - it('does not submit when entry is undefined', async () => { - store.dispatch({ type: 'eventFiltersCreateStart' }); - expect(store.getState()).toStrictEqual({ - ...initialState, - form: { - ...store.getState().form, - submissionResourceState: { type: 'UninitialisedResourceState' }, - }, - }); - }); - - it('does submit when entry is not undefined', async () => { - service.addEventFilters.mockResolvedValue(createdEventFilterEntryMock()); - const entry = getInitialExceptionFromEvent(ecsEventMock()); - store.dispatch({ - type: 'eventFiltersInitForm', - payload: { entry }, - }); - - store.dispatch({ type: 'eventFiltersCreateStart' }); - - await spyMiddleware.waitForAction('eventFiltersFormStateChanged'); - expect(store.getState()).toStrictEqual({ - ...initialState, - form: { - ...store.getState().form, - submissionResourceState: { - type: 'LoadedResourceState', - data: createdEventFilterEntryMock(), - }, - }, - }); - }); - - it('does submit when entry has empty comments with white spaces', async () => { - service.addEventFilters.mockImplementation( - async (exception: Immutable) => { - expect(exception.comments).toStrictEqual(createdEventFilterEntryMock().comments); - return createdEventFilterEntryMock(); - } - ); - const entry = getInitialExceptionFromEvent(ecsEventMock()); - store.dispatch({ - type: 'eventFiltersInitForm', - payload: { entry }, - }); - - store.dispatch({ - type: 'eventFiltersChangeForm', - payload: { newComment: ' ', entry }, - }); - - store.dispatch({ type: 'eventFiltersCreateStart' }); - await spyMiddleware.waitForAction('eventFiltersFormStateChanged'); - expect(store.getState()).toStrictEqual({ - ...initialState, - form: { - ...store.getState().form, - submissionResourceState: { - type: 'LoadedResourceState', - data: createdEventFilterEntryMock(), - }, - }, - }); - }); - - it('does throw error when creating', async () => { - service.addEventFilters.mockRejectedValue({ - body: { message: 'error message', statusCode: 500, error: 'Internal Server Error' }, - }); - const entry = getInitialExceptionFromEvent(ecsEventMock()); - store.dispatch({ - type: 'eventFiltersInitForm', - payload: { entry }, - }); - - store.dispatch({ type: 'eventFiltersCreateStart' }); - - await spyMiddleware.waitForAction('eventFiltersFormStateChanged'); - expect(store.getState()).toStrictEqual({ - ...initialState, - form: { - ...store.getState().form, - submissionResourceState: { - type: 'FailedResourceState', - lastLoadedState: undefined, - error: { - error: 'Internal Server Error', - message: 'error message', - statusCode: 500, - }, - }, - }, - }); - }); - }); - describe('load event filterby id', () => { - it('init form with an entry loaded by id from API', async () => { - service.getOne.mockResolvedValue(createdEventFilterEntryMock()); - store.dispatch({ type: 'eventFiltersInitFromId', payload: { id: 'id' } }); - await spyMiddleware.waitForAction('eventFiltersInitForm'); - expect(store.getState()).toStrictEqual({ - ...initialState, - form: { - ...store.getState().form, - entry: createdEventFilterEntryMock(), - }, - }); - }); - - it('does throw error when getting by id', async () => { - service.getOne.mockRejectedValue({ - body: { message: 'error message', statusCode: 500, error: 'Internal Server Error' }, - }); - store.dispatch({ type: 'eventFiltersInitFromId', payload: { id: 'id' } }); - await spyMiddleware.waitForAction('eventFiltersFormStateChanged'); - expect(store.getState()).toStrictEqual({ - ...initialState, - form: { - ...store.getState().form, - submissionResourceState: { - type: 'FailedResourceState', - lastLoadedState: undefined, - error: { - error: 'Internal Server Error', - message: 'error message', - statusCode: 500, - }, - }, - }, - }); - }); - }); - describe('submit update event filter', () => { - it('does not submit when entry is undefined', async () => { - store.dispatch({ type: 'eventFiltersUpdateStart' }); - expect(store.getState()).toStrictEqual({ - ...initialState, - form: { - ...store.getState().form, - submissionResourceState: { type: 'UninitialisedResourceState' }, - }, - }); - }); - - it('does submit when entry is not undefined', async () => { - service.updateOne.mockResolvedValue(createdEventFilterEntryMock()); - - store.dispatch({ - type: 'eventFiltersInitForm', - payload: { entry: createdEventFilterEntryMock() }, - }); - - store.dispatch({ type: 'eventFiltersUpdateStart' }); - - await spyMiddleware.waitForAction('eventFiltersFormStateChanged'); - expect(store.getState()).toStrictEqual({ - ...initialState, - form: { - ...store.getState().form, - submissionResourceState: { - type: 'LoadedResourceState', - data: createdEventFilterEntryMock(), - }, - }, - }); - }); - - it('does throw error when creating', async () => { - service.updateOne.mockRejectedValue({ - body: { message: 'error message', statusCode: 500, error: 'Internal Server Error' }, - }); - const entry = getInitialExceptionFromEvent(ecsEventMock()); - store.dispatch({ - type: 'eventFiltersInitForm', - payload: { entry }, - }); - - store.dispatch({ type: 'eventFiltersUpdateStart' }); - - await spyMiddleware.waitForAction('eventFiltersFormStateChanged'); - expect(store.getState()).toStrictEqual({ - ...initialState, - form: { - ...store.getState().form, - submissionResourceState: { - type: 'FailedResourceState', - lastLoadedState: undefined, - error: { - error: 'Internal Server Error', - message: 'error message', - statusCode: 500, - }, - }, - }, - }); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/middleware.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/store/middleware.ts deleted file mode 100644 index a8bf725e61b2a..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/middleware.ts +++ /dev/null @@ -1,342 +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 type { - CreateExceptionListItemSchema, - ExceptionListItemSchema, - UpdateExceptionListItemSchema, -} from '@kbn/securitysolution-io-ts-list-types'; -import { transformNewItemOutput, transformOutput } from '@kbn/securitysolution-list-hooks'; -import { AppAction } from '../../../../common/store/actions'; -import { - ImmutableMiddleware, - ImmutableMiddlewareAPI, - ImmutableMiddlewareFactory, -} from '../../../../common/store'; - -import { EventFiltersHttpService } from '../service'; - -import { - getCurrentListPageDataState, - getCurrentLocation, - getListIsLoading, - getListPageDataExistsState, - getListPageIsActive, - listDataNeedsRefresh, - getFormEntry, - getSubmissionResource, - getNewComment, - isDeletionInProgress, - getItemToDelete, - getDeletionState, -} from './selector'; - -import { parseQueryFilterToKQL, parsePoliciesAndFilterToKql } from '../../../common/utils'; -import { SEARCHABLE_FIELDS } from '../constants'; -import { - EventFiltersListPageData, - EventFiltersListPageState, - EventFiltersService, - EventFiltersServiceGetListOptions, -} from '../types'; -import { - asStaleResourceState, - createFailedResourceState, - createLoadedResourceState, - createLoadingResourceState, - getLastLoadedResourceState, -} from '../../../state'; -import { ServerApiError } from '../../../../common/types'; - -const addNewComments = ( - entry: UpdateExceptionListItemSchema | CreateExceptionListItemSchema, - newComment: string -): UpdateExceptionListItemSchema | CreateExceptionListItemSchema => { - if (newComment) { - if (!entry.comments) entry.comments = []; - const trimmedComment = newComment.trim(); - if (trimmedComment) entry.comments.push({ comment: trimmedComment }); - } - return entry; -}; - -type MiddlewareActionHandler = ( - store: ImmutableMiddlewareAPI, - eventFiltersService: EventFiltersService -) => Promise; - -const eventFiltersCreate: MiddlewareActionHandler = async (store, eventFiltersService) => { - const submissionResourceState = store.getState().form.submissionResourceState; - try { - const formEntry = getFormEntry(store.getState()); - if (!formEntry) return; - store.dispatch({ - type: 'eventFiltersFormStateChanged', - payload: createLoadingResourceState({ - type: 'UninitialisedResourceState', - }), - }); - - const sanitizedEntry = transformNewItemOutput(formEntry as CreateExceptionListItemSchema); - const updatedCommentsEntry = addNewComments( - sanitizedEntry, - getNewComment(store.getState()) - ) as CreateExceptionListItemSchema; - - const exception = await eventFiltersService.addEventFilters(updatedCommentsEntry); - - store.dispatch({ - type: 'eventFiltersFormStateChanged', - payload: { - type: 'LoadedResourceState', - data: exception, - }, - }); - store.dispatch({ - type: 'eventFiltersCreateSuccess', - }); - } catch (error) { - store.dispatch({ - type: 'eventFiltersFormStateChanged', - payload: { - type: 'FailedResourceState', - error: error.body || error, - lastLoadedState: getLastLoadedResourceState(submissionResourceState), - }, - }); - } -}; - -const eventFiltersUpdate = async ( - store: ImmutableMiddlewareAPI, - eventFiltersService: EventFiltersService -) => { - const submissionResourceState = getSubmissionResource(store.getState()); - try { - const formEntry = getFormEntry(store.getState()); - if (!formEntry) return; - store.dispatch({ - type: 'eventFiltersFormStateChanged', - payload: { - type: 'LoadingResourceState', - previousState: { type: 'UninitialisedResourceState' }, - }, - }); - - const sanitizedEntry: UpdateExceptionListItemSchema = transformOutput( - formEntry as UpdateExceptionListItemSchema - ); - const updatedCommentsEntry = addNewComments( - sanitizedEntry, - getNewComment(store.getState()) - ) as UpdateExceptionListItemSchema; - - const exception = await eventFiltersService.updateOne(updatedCommentsEntry); - store.dispatch({ - type: 'eventFiltersUpdateSuccess', - }); - store.dispatch({ - type: 'eventFiltersFormStateChanged', - payload: createLoadedResourceState(exception), - }); - } catch (error) { - store.dispatch({ - type: 'eventFiltersFormStateChanged', - payload: createFailedResourceState( - error.body ?? error, - getLastLoadedResourceState(submissionResourceState) - ), - }); - } -}; - -const eventFiltersLoadById = async ( - store: ImmutableMiddlewareAPI, - eventFiltersService: EventFiltersService, - id: string -) => { - const submissionResourceState = getSubmissionResource(store.getState()); - try { - const entry = await eventFiltersService.getOne(id); - store.dispatch({ - type: 'eventFiltersInitForm', - payload: { entry }, - }); - } catch (error) { - store.dispatch({ - type: 'eventFiltersFormStateChanged', - payload: { - type: 'FailedResourceState', - error: error.body || error, - lastLoadedState: getLastLoadedResourceState(submissionResourceState), - }, - }); - } -}; - -const checkIfEventFilterDataExist: MiddlewareActionHandler = async ( - { dispatch, getState }, - eventFiltersService: EventFiltersService -) => { - dispatch({ - type: 'eventFiltersListPageDataExistsChanged', - payload: createLoadingResourceState( - asStaleResourceState(getListPageDataExistsState(getState())) - ), - }); - - try { - const anythingInListResults = await eventFiltersService.getList({ perPage: 1, page: 1 }); - - dispatch({ - type: 'eventFiltersListPageDataExistsChanged', - payload: createLoadedResourceState(Boolean(anythingInListResults.total)), - }); - } catch (error) { - dispatch({ - type: 'eventFiltersListPageDataExistsChanged', - payload: createFailedResourceState(error.body ?? error), - }); - } -}; - -const refreshListDataIfNeeded: MiddlewareActionHandler = async (store, eventFiltersService) => { - const { dispatch, getState } = store; - const state = getState(); - const isLoading = getListIsLoading(state); - - if (!isLoading && listDataNeedsRefresh(state)) { - dispatch({ - type: 'eventFiltersListPageDataChanged', - payload: { - type: 'LoadingResourceState', - previousState: asStaleResourceState(getCurrentListPageDataState(state)), - }, - }); - - const { - page_size: pageSize, - page_index: pageIndex, - filter, - included_policies: includedPolicies, - } = getCurrentLocation(state); - - const kuery = parseQueryFilterToKQL(filter, SEARCHABLE_FIELDS) || undefined; - - const query: EventFiltersServiceGetListOptions = { - page: pageIndex + 1, - perPage: pageSize, - sortField: 'created_at', - sortOrder: 'desc', - filter: parsePoliciesAndFilterToKql({ - kuery, - policies: includedPolicies ? includedPolicies.split(',') : [], - }), - }; - - try { - const results = await eventFiltersService.getList(query); - - dispatch({ - type: 'eventFiltersListPageDataChanged', - payload: createLoadedResourceState({ - query: { ...query, filter }, - content: results, - }), - }); - - // If no results were returned, then just check to make sure data actually exists for - // event filters. This is used to drive the UI between showing "empty state" and "no items found" - // messages to the user - if (results.total === 0) { - await checkIfEventFilterDataExist(store, eventFiltersService); - } else { - dispatch({ - type: 'eventFiltersListPageDataExistsChanged', - payload: { - type: 'LoadedResourceState', - data: Boolean(results.total), - }, - }); - } - } catch (error) { - dispatch({ - type: 'eventFiltersListPageDataChanged', - payload: createFailedResourceState(error.body ?? error), - }); - } - } -}; - -const eventFilterDeleteEntry: MiddlewareActionHandler = async ( - { getState, dispatch }, - eventFiltersService -) => { - const state = getState(); - - if (isDeletionInProgress(state)) { - return; - } - - const itemId = getItemToDelete(state)?.id; - - if (!itemId) { - return; - } - - dispatch({ - type: 'eventFilterDeleteStatusChanged', - payload: createLoadingResourceState(asStaleResourceState(getDeletionState(state).status)), - }); - - try { - const response = await eventFiltersService.deleteOne(itemId); - dispatch({ - type: 'eventFilterDeleteStatusChanged', - payload: createLoadedResourceState(response), - }); - } catch (e) { - dispatch({ - type: 'eventFilterDeleteStatusChanged', - payload: createFailedResourceState(e.body ?? e), - }); - } -}; - -export const createEventFiltersPageMiddleware = ( - eventFiltersService: EventFiltersService -): ImmutableMiddleware => { - return (store) => (next) => async (action) => { - next(action); - - if (action.type === 'eventFiltersCreateStart') { - await eventFiltersCreate(store, eventFiltersService); - } else if (action.type === 'eventFiltersInitFromId') { - await eventFiltersLoadById(store, eventFiltersService, action.payload.id); - } else if (action.type === 'eventFiltersUpdateStart') { - await eventFiltersUpdate(store, eventFiltersService); - } - - // Middleware that only applies to the List Page for Event Filters - if (getListPageIsActive(store.getState())) { - if ( - action.type === 'userChangedUrl' || - action.type === 'eventFiltersCreateSuccess' || - action.type === 'eventFiltersUpdateSuccess' || - action.type === 'eventFilterDeleteStatusChanged' - ) { - refreshListDataIfNeeded(store, eventFiltersService); - } else if (action.type === 'eventFilterDeleteSubmit') { - eventFilterDeleteEntry(store, eventFiltersService); - } - } - }; -}; - -export const eventFiltersPageMiddlewareFactory: ImmutableMiddlewareFactory< - EventFiltersListPageState -> = (coreStart) => createEventFiltersPageMiddleware(new EventFiltersHttpService(coreStart.http)); diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/reducer.test.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/store/reducer.test.ts deleted file mode 100644 index 0deb7cb51c850..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/reducer.test.ts +++ /dev/null @@ -1,221 +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 { initialEventFiltersPageState } from './builders'; -import { eventFiltersPageReducer } from './reducer'; -import { getInitialExceptionFromEvent } from './utils'; -import { createdEventFilterEntryMock, ecsEventMock } from '../test_utils'; -import { UserChangedUrl } from '../../../../common/store/routing/action'; -import { getListPageIsActive } from './selector'; -import { EventFiltersListPageState } from '../types'; - -describe('event filters reducer', () => { - let initialState: EventFiltersListPageState; - - beforeEach(() => { - initialState = initialEventFiltersPageState(); - }); - - describe('EventFiltersForm', () => { - it('sets the initial form values', () => { - const entry = getInitialExceptionFromEvent(ecsEventMock()); - const result = eventFiltersPageReducer(initialState, { - type: 'eventFiltersInitForm', - payload: { entry }, - }); - - expect(result).toStrictEqual({ - ...initialState, - form: { - ...initialState.form, - entry, - hasNameError: !entry.name, - submissionResourceState: { - type: 'UninitialisedResourceState', - }, - }, - }); - }); - - it('change form values', () => { - const entry = getInitialExceptionFromEvent(ecsEventMock()); - const nameChanged = 'name changed'; - const newComment = 'new comment'; - const result = eventFiltersPageReducer(initialState, { - type: 'eventFiltersChangeForm', - payload: { entry: { ...entry, name: nameChanged }, newComment }, - }); - - expect(result).toStrictEqual({ - ...initialState, - form: { - ...initialState.form, - entry: { - ...entry, - name: nameChanged, - }, - newComment, - hasNameError: false, - submissionResourceState: { - type: 'UninitialisedResourceState', - }, - }, - }); - }); - - it('change form values without entry', () => { - const newComment = 'new comment'; - const result = eventFiltersPageReducer(initialState, { - type: 'eventFiltersChangeForm', - payload: { newComment }, - }); - - expect(result).toStrictEqual({ - ...initialState, - form: { - ...initialState.form, - newComment, - submissionResourceState: { - type: 'UninitialisedResourceState', - }, - }, - }); - }); - - it('change form status', () => { - const result = eventFiltersPageReducer(initialState, { - type: 'eventFiltersFormStateChanged', - payload: { - type: 'LoadedResourceState', - data: createdEventFilterEntryMock(), - }, - }); - - expect(result).toStrictEqual({ - ...initialState, - form: { - ...initialState.form, - submissionResourceState: { - type: 'LoadedResourceState', - data: createdEventFilterEntryMock(), - }, - }, - }); - }); - - it('clean form after change form status', () => { - const entry = getInitialExceptionFromEvent(ecsEventMock()); - const nameChanged = 'name changed'; - const newComment = 'new comment'; - const result = eventFiltersPageReducer(initialState, { - type: 'eventFiltersChangeForm', - payload: { entry: { ...entry, name: nameChanged }, newComment }, - }); - const cleanState = eventFiltersPageReducer(result, { - type: 'eventFiltersInitForm', - payload: { entry }, - }); - - expect(cleanState).toStrictEqual({ - ...initialState, - form: { ...initialState.form, entry, hasNameError: true, newComment: '' }, - }); - }); - - it('create is success and force list refresh', () => { - const initialStateWithListPageActive = { - ...initialState, - listPage: { ...initialState.listPage, active: true }, - }; - const result = eventFiltersPageReducer(initialStateWithListPageActive, { - type: 'eventFiltersCreateSuccess', - }); - - expect(result).toStrictEqual({ - ...initialStateWithListPageActive, - listPage: { - ...initialStateWithListPageActive.listPage, - forceRefresh: true, - }, - }); - }); - }); - describe('UserChangedUrl', () => { - const userChangedUrlAction = ( - search: string = '', - pathname = '/administration/event_filters' - ): UserChangedUrl => ({ - type: 'userChangedUrl', - payload: { search, pathname, hash: '' }, - }); - - describe('When url is the Event List page', () => { - it('should mark page active when on the list url', () => { - const result = eventFiltersPageReducer(initialState, userChangedUrlAction()); - expect(getListPageIsActive(result)).toBe(true); - }); - - it('should mark page not active when not on the list url', () => { - const result = eventFiltersPageReducer( - initialState, - userChangedUrlAction('', '/some-other-page') - ); - expect(getListPageIsActive(result)).toBe(false); - }); - }); - - describe('When `show=create`', () => { - it('receives a url change with show=create', () => { - const result = eventFiltersPageReducer(initialState, userChangedUrlAction('?show=create')); - - expect(result).toStrictEqual({ - ...initialState, - location: { - ...initialState.location, - id: undefined, - show: 'create', - }, - listPage: { - ...initialState.listPage, - active: true, - }, - }); - }); - }); - }); - - describe('ForceRefresh', () => { - it('sets the force refresh state to true', () => { - const result = eventFiltersPageReducer( - { - ...initialState, - listPage: { ...initialState.listPage, forceRefresh: false }, - }, - { type: 'eventFiltersForceRefresh', payload: { forceRefresh: true } } - ); - - expect(result).toStrictEqual({ - ...initialState, - listPage: { ...initialState.listPage, forceRefresh: true }, - }); - }); - it('sets the force refresh state to false', () => { - const result = eventFiltersPageReducer( - { - ...initialState, - listPage: { ...initialState.listPage, forceRefresh: true }, - }, - { type: 'eventFiltersForceRefresh', payload: { forceRefresh: false } } - ); - - expect(result).toStrictEqual({ - ...initialState, - listPage: { ...initialState.listPage, forceRefresh: false }, - }); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/reducer.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/store/reducer.ts deleted file mode 100644 index 95b0078f80f8b..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/reducer.ts +++ /dev/null @@ -1,271 +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. - */ - -// eslint-disable-next-line import/no-nodejs-modules -import { parse } from 'querystring'; -import { matchPath } from 'react-router-dom'; -import { ImmutableReducer } from '../../../../common/store'; -import { AppAction } from '../../../../common/store/actions'; -import { AppLocation, Immutable } from '../../../../../common/endpoint/types'; -import { UserChangedUrl } from '../../../../common/store/routing/action'; -import { MANAGEMENT_ROUTING_EVENT_FILTERS_PATH } from '../../../common/constants'; -import { extractEventFiltersPageLocation } from '../../../common/routing'; -import { - isLoadedResourceState, - isUninitialisedResourceState, -} from '../../../state/async_resource_state'; - -import { - EventFiltersInitForm, - EventFiltersChangeForm, - EventFiltersFormStateChanged, - EventFiltersCreateSuccess, - EventFiltersUpdateSuccess, - EventFiltersListPageDataChanged, - EventFiltersListPageDataExistsChanged, - EventFilterForDeletion, - EventFilterDeletionReset, - EventFilterDeleteStatusChanged, - EventFiltersForceRefresh, -} from './action'; - -import { initialEventFiltersPageState } from './builders'; -import { getListPageIsActive } from './selector'; -import { EventFiltersListPageState } from '../types'; - -type StateReducer = ImmutableReducer; -type CaseReducer = ( - state: Immutable, - action: Immutable -) => Immutable; - -const isEventFiltersPageLocation = (location: Immutable) => { - return ( - matchPath(location.pathname ?? '', { - path: MANAGEMENT_ROUTING_EVENT_FILTERS_PATH, - exact: true, - }) !== null - ); -}; - -const handleEventFiltersListPageDataChanges: CaseReducer = ( - state, - action -) => { - return { - ...state, - listPage: { - ...state.listPage, - forceRefresh: false, - data: action.payload, - }, - }; -}; - -const handleEventFiltersListPageDataExistChanges: CaseReducer< - EventFiltersListPageDataExistsChanged -> = (state, action) => { - return { - ...state, - listPage: { - ...state.listPage, - dataExist: action.payload, - }, - }; -}; - -const eventFiltersInitForm: CaseReducer = (state, action) => { - return { - ...state, - form: { - ...state.form, - entry: action.payload.entry, - hasNameError: !action.payload.entry.name, - hasOSError: !action.payload.entry.os_types?.length, - newComment: '', - submissionResourceState: { - type: 'UninitialisedResourceState', - }, - }, - }; -}; - -const eventFiltersChangeForm: CaseReducer = (state, action) => { - return { - ...state, - form: { - ...state.form, - entry: action.payload.entry !== undefined ? action.payload.entry : state.form.entry, - hasItemsError: - action.payload.hasItemsError !== undefined - ? action.payload.hasItemsError - : state.form.hasItemsError, - hasNameError: - action.payload.hasNameError !== undefined - ? action.payload.hasNameError - : state.form.hasNameError, - hasOSError: - action.payload.hasOSError !== undefined ? action.payload.hasOSError : state.form.hasOSError, - newComment: - action.payload.newComment !== undefined ? action.payload.newComment : state.form.newComment, - }, - }; -}; - -const eventFiltersFormStateChanged: CaseReducer = (state, action) => { - return { - ...state, - form: { - ...state.form, - entry: isUninitialisedResourceState(action.payload) ? undefined : state.form.entry, - newComment: isUninitialisedResourceState(action.payload) ? '' : state.form.newComment, - submissionResourceState: action.payload, - }, - }; -}; - -const eventFiltersCreateSuccess: CaseReducer = (state, action) => { - return { - ...state, - // If we are on the List page, then force a refresh of data - listPage: getListPageIsActive(state) - ? { - ...state.listPage, - forceRefresh: true, - } - : state.listPage, - }; -}; - -const eventFiltersUpdateSuccess: CaseReducer = (state, action) => { - return { - ...state, - // If we are on the List page, then force a refresh of data - listPage: getListPageIsActive(state) - ? { - ...state.listPage, - forceRefresh: true, - } - : state.listPage, - }; -}; - -const userChangedUrl: CaseReducer = (state, action) => { - if (isEventFiltersPageLocation(action.payload)) { - const location = extractEventFiltersPageLocation(parse(action.payload.search.slice(1))); - return { - ...state, - location, - listPage: { - ...state.listPage, - active: true, - }, - }; - } else { - // Reset the list page state if needed - if (state.listPage.active) { - const { listPage } = initialEventFiltersPageState(); - - return { - ...state, - listPage, - }; - } - - return state; - } -}; - -const handleEventFilterForDeletion: CaseReducer = (state, action) => { - return { - ...state, - listPage: { - ...state.listPage, - deletion: { - ...state.listPage.deletion, - item: action.payload, - }, - }, - }; -}; - -const handleEventFilterDeletionReset: CaseReducer = (state) => { - return { - ...state, - listPage: { - ...state.listPage, - deletion: initialEventFiltersPageState().listPage.deletion, - }, - }; -}; - -const handleEventFilterDeleteStatusChanges: CaseReducer = ( - state, - action -) => { - return { - ...state, - listPage: { - ...state.listPage, - forceRefresh: isLoadedResourceState(action.payload) ? true : state.listPage.forceRefresh, - deletion: { - ...state.listPage.deletion, - status: action.payload, - }, - }, - }; -}; - -const handleEventFilterForceRefresh: CaseReducer = (state, action) => { - return { - ...state, - listPage: { - ...state.listPage, - forceRefresh: action.payload.forceRefresh, - }, - }; -}; - -export const eventFiltersPageReducer: StateReducer = ( - state = initialEventFiltersPageState(), - action -) => { - switch (action.type) { - case 'eventFiltersInitForm': - return eventFiltersInitForm(state, action); - case 'eventFiltersChangeForm': - return eventFiltersChangeForm(state, action); - case 'eventFiltersFormStateChanged': - return eventFiltersFormStateChanged(state, action); - case 'eventFiltersCreateSuccess': - return eventFiltersCreateSuccess(state, action); - case 'eventFiltersUpdateSuccess': - return eventFiltersUpdateSuccess(state, action); - case 'userChangedUrl': - return userChangedUrl(state, action); - case 'eventFiltersForceRefresh': - return handleEventFilterForceRefresh(state, action); - } - - // actions only handled if we're on the List Page - if (getListPageIsActive(state)) { - switch (action.type) { - case 'eventFiltersListPageDataChanged': - return handleEventFiltersListPageDataChanges(state, action); - case 'eventFiltersListPageDataExistsChanged': - return handleEventFiltersListPageDataExistChanges(state, action); - case 'eventFilterForDeletion': - return handleEventFilterForDeletion(state, action); - case 'eventFilterDeletionReset': - return handleEventFilterDeletionReset(state, action); - case 'eventFilterDeleteStatusChanged': - return handleEventFilterDeleteStatusChanges(state, action); - } - } - - return state; -}; diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/selector.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/store/selector.ts deleted file mode 100644 index 9e5eb5c531b6e..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/selector.ts +++ /dev/null @@ -1,224 +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 { createSelector } from 'reselect'; -import { Pagination } from '@elastic/eui'; - -import type { - ExceptionListItemSchema, - FoundExceptionListItemSchema, -} from '@kbn/securitysolution-io-ts-list-types'; -import { EventFiltersListPageState, EventFiltersServiceGetListOptions } from '../types'; - -import { ServerApiError } from '../../../../common/types'; -import { - isLoadingResourceState, - isLoadedResourceState, - isFailedResourceState, - isUninitialisedResourceState, - getLastLoadedResourceState, -} from '../../../state/async_resource_state'; -import { - MANAGEMENT_DEFAULT_PAGE_SIZE, - MANAGEMENT_PAGE_SIZE_OPTIONS, -} from '../../../common/constants'; -import { Immutable } from '../../../../../common/endpoint/types'; - -type StoreState = Immutable; -type EventFiltersSelector = (state: StoreState) => T; - -export const getCurrentListPageState: EventFiltersSelector = (state) => { - return state.listPage; -}; - -export const getListPageIsActive: EventFiltersSelector = createSelector( - getCurrentListPageState, - (listPage) => listPage.active -); - -export const getCurrentListPageDataState: EventFiltersSelector = ( - state -) => state.listPage.data; - -/** - * Will return the API response with event filters. If the current state is attempting to load a new - * page of content, then return the previous API response if we have one - */ -export const getListApiSuccessResponse: EventFiltersSelector< - Immutable | undefined -> = createSelector(getCurrentListPageDataState, (listPageData) => { - return getLastLoadedResourceState(listPageData)?.data.content; -}); - -export const getListItems: EventFiltersSelector> = - createSelector(getListApiSuccessResponse, (apiResponseData) => { - return apiResponseData?.data || []; - }); - -export const getTotalCountListItems: EventFiltersSelector> = createSelector( - getListApiSuccessResponse, - (apiResponseData) => { - return apiResponseData?.total || 0; - } -); - -/** - * Will return the query that was used with the currently displayed list of content. If a new page - * of content is being loaded, this selector will then attempt to use the previousState to return - * the query used. - */ -export const getCurrentListItemsQuery: EventFiltersSelector = - createSelector(getCurrentListPageDataState, (pageDataState) => { - return getLastLoadedResourceState(pageDataState)?.data.query ?? {}; - }); - -export const getListPagination: EventFiltersSelector = createSelector( - getListApiSuccessResponse, - // memoized via `reselect` until the API response changes - (response) => { - return { - totalItemCount: response?.total ?? 0, - pageSize: response?.per_page ?? MANAGEMENT_DEFAULT_PAGE_SIZE, - pageSizeOptions: [...MANAGEMENT_PAGE_SIZE_OPTIONS], - pageIndex: (response?.page ?? 1) - 1, - }; - } -); - -export const getListFetchError: EventFiltersSelector | undefined> = - createSelector(getCurrentListPageDataState, (listPageDataState) => { - return (isFailedResourceState(listPageDataState) && listPageDataState.error) || undefined; - }); - -export const getListPageDataExistsState: EventFiltersSelector< - StoreState['listPage']['dataExist'] -> = ({ listPage: { dataExist } }) => dataExist; - -export const getListIsLoading: EventFiltersSelector = createSelector( - getCurrentListPageDataState, - getListPageDataExistsState, - (listDataState, dataExists) => - isLoadingResourceState(listDataState) || isLoadingResourceState(dataExists) -); - -export const getListPageDoesDataExist: EventFiltersSelector = createSelector( - getListPageDataExistsState, - (dataExistsState) => { - return !!getLastLoadedResourceState(dataExistsState)?.data; - } -); - -export const getFormEntryState: EventFiltersSelector = (state) => { - return state.form.entry; -}; -// Needed for form component as we modify the existing entry on exceptuionBuilder component -export const getFormEntryStateMutable = ( - state: EventFiltersListPageState -): EventFiltersListPageState['form']['entry'] => { - return state.form.entry; -}; - -export const getFormEntry = createSelector(getFormEntryState, (entry) => entry); - -export const getNewCommentState: EventFiltersSelector = ( - state -) => { - return state.form.newComment; -}; - -export const getNewComment = createSelector(getNewCommentState, (newComment) => newComment); - -export const getHasNameError = (state: EventFiltersListPageState): boolean => { - return state.form.hasNameError; -}; - -export const getFormHasError = (state: EventFiltersListPageState): boolean => { - return state.form.hasItemsError || state.form.hasNameError || state.form.hasOSError; -}; - -export const isCreationInProgress = (state: EventFiltersListPageState): boolean => { - return isLoadingResourceState(state.form.submissionResourceState); -}; - -export const isCreationSuccessful = (state: EventFiltersListPageState): boolean => { - return isLoadedResourceState(state.form.submissionResourceState); -}; - -export const isUninitialisedForm = (state: EventFiltersListPageState): boolean => { - return isUninitialisedResourceState(state.form.submissionResourceState); -}; - -export const getActionError = (state: EventFiltersListPageState): ServerApiError | undefined => { - const submissionResourceState = state.form.submissionResourceState; - - return isFailedResourceState(submissionResourceState) ? submissionResourceState.error : undefined; -}; - -export const getSubmissionResourceState: EventFiltersSelector< - StoreState['form']['submissionResourceState'] -> = (state) => { - return state.form.submissionResourceState; -}; - -export const getSubmissionResource = createSelector( - getSubmissionResourceState, - (submissionResourceState) => submissionResourceState -); - -export const getCurrentLocation: EventFiltersSelector = (state) => - state.location; - -/** Compares the URL param values to the values used in the last data query */ -export const listDataNeedsRefresh: EventFiltersSelector = createSelector( - getCurrentLocation, - getCurrentListItemsQuery, - (state) => state.listPage.forceRefresh, - (location, currentQuery, forceRefresh) => { - return ( - forceRefresh || - location.page_index + 1 !== currentQuery.page || - location.page_size !== currentQuery.perPage - ); - } -); - -export const getDeletionState = createSelector( - getCurrentListPageState, - (listState) => listState.deletion -); - -export const showDeleteModal: EventFiltersSelector = createSelector( - getDeletionState, - ({ item }) => { - return Boolean(item); - } -); - -export const getItemToDelete: EventFiltersSelector = - createSelector(getDeletionState, ({ item }) => item); - -export const isDeletionInProgress: EventFiltersSelector = createSelector( - getDeletionState, - ({ status }) => { - return isLoadingResourceState(status); - } -); - -export const wasDeletionSuccessful: EventFiltersSelector = createSelector( - getDeletionState, - ({ status }) => { - return isLoadedResourceState(status); - } -); - -export const getDeleteError: EventFiltersSelector = createSelector( - getDeletionState, - ({ status }) => { - if (isFailedResourceState(status)) { - return status.error; - } - } -); diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/selectors.test.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/store/selectors.test.ts deleted file mode 100644 index fa3a519bc1908..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/selectors.test.ts +++ /dev/null @@ -1,391 +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 { initialEventFiltersPageState } from './builders'; -import { - getFormEntry, - getFormHasError, - getCurrentLocation, - getNewComment, - getHasNameError, - getCurrentListPageState, - getListPageIsActive, - getCurrentListPageDataState, - getListApiSuccessResponse, - getListItems, - getTotalCountListItems, - getCurrentListItemsQuery, - getListPagination, - getListFetchError, - getListIsLoading, - getListPageDoesDataExist, - listDataNeedsRefresh, -} from './selector'; -import { ecsEventMock } from '../test_utils'; -import { getInitialExceptionFromEvent } from './utils'; -import { EventFiltersListPageState, EventFiltersPageLocation } from '../types'; -import { MANAGEMENT_DEFAULT_PAGE, MANAGEMENT_DEFAULT_PAGE_SIZE } from '../../../common/constants'; -import { getFoundExceptionListItemSchemaMock } from '@kbn/lists-plugin/common/schemas/response/found_exception_list_item_schema.mock'; -import { - asStaleResourceState, - createFailedResourceState, - createLoadedResourceState, - createLoadingResourceState, - createUninitialisedResourceState, - getLastLoadedResourceState, -} from '../../../state'; - -describe('event filters selectors', () => { - let initialState: EventFiltersListPageState; - - // When `setToLoadingState()` is called, this variable will hold the prevousState in order to - // avoid ts-ignores due to know issues (#830) around the LoadingResourceState - let previousStateWhileLoading: EventFiltersListPageState['listPage']['data'] | undefined; - - const setToLoadedState = () => { - initialState.listPage.data = createLoadedResourceState({ - query: { page: 2, perPage: 10, filter: '' }, - content: getFoundExceptionListItemSchemaMock(), - }); - }; - - const setToLoadingState = ( - previousState: EventFiltersListPageState['listPage']['data'] = createLoadedResourceState({ - query: { page: 5, perPage: 50, filter: '' }, - content: getFoundExceptionListItemSchemaMock(), - }) - ) => { - previousStateWhileLoading = previousState; - - initialState.listPage.data = createLoadingResourceState(asStaleResourceState(previousState)); - }; - - beforeEach(() => { - initialState = initialEventFiltersPageState(); - }); - - describe('getCurrentListPageState()', () => { - it('should retrieve list page state', () => { - expect(getCurrentListPageState(initialState)).toEqual(initialState.listPage); - }); - }); - - describe('getListPageIsActive()', () => { - it('should return active state', () => { - expect(getListPageIsActive(initialState)).toBe(false); - }); - }); - - describe('getCurrentListPageDataState()', () => { - it('should return list data state', () => { - expect(getCurrentListPageDataState(initialState)).toEqual(initialState.listPage.data); - }); - }); - - describe('getListApiSuccessResponse()', () => { - it('should return api response', () => { - setToLoadedState(); - expect(getListApiSuccessResponse(initialState)).toEqual( - getLastLoadedResourceState(initialState.listPage.data)?.data.content - ); - }); - - it('should return undefined if not available', () => { - setToLoadingState(createUninitialisedResourceState()); - expect(getListApiSuccessResponse(initialState)).toBeUndefined(); - }); - - it('should return previous success response if currently loading', () => { - setToLoadingState(); - expect(getListApiSuccessResponse(initialState)).toEqual( - getLastLoadedResourceState(previousStateWhileLoading!)?.data.content - ); - }); - }); - - describe('getListItems()', () => { - it('should return the list items from api response', () => { - setToLoadedState(); - expect(getListItems(initialState)).toEqual( - getLastLoadedResourceState(initialState.listPage.data)?.data.content.data - ); - }); - - it('should return empty array if no api response', () => { - expect(getListItems(initialState)).toEqual([]); - }); - }); - - describe('getTotalCountListItems()', () => { - it('should return the list items from api response', () => { - setToLoadedState(); - expect(getTotalCountListItems(initialState)).toEqual( - getLastLoadedResourceState(initialState.listPage.data)?.data.content.total - ); - }); - - it('should return empty array if no api response', () => { - expect(getTotalCountListItems(initialState)).toEqual(0); - }); - }); - - describe('getCurrentListItemsQuery()', () => { - it('should return empty object if Uninitialized', () => { - expect(getCurrentListItemsQuery(initialState)).toEqual({}); - }); - - it('should return query from current loaded state', () => { - setToLoadedState(); - expect(getCurrentListItemsQuery(initialState)).toEqual({ page: 2, perPage: 10, filter: '' }); - }); - - it('should return query from previous state while Loading new page', () => { - setToLoadingState(); - expect(getCurrentListItemsQuery(initialState)).toEqual({ page: 5, perPage: 50, filter: '' }); - }); - }); - - describe('getListPagination()', () => { - it('should return pagination defaults if no API response is available', () => { - expect(getListPagination(initialState)).toEqual({ - totalItemCount: 0, - pageSize: 10, - pageSizeOptions: [10, 20, 50], - pageIndex: 0, - }); - }); - - it('should return pagination based on API response', () => { - setToLoadedState(); - expect(getListPagination(initialState)).toEqual({ - totalItemCount: 1, - pageSize: 1, - pageSizeOptions: [10, 20, 50], - pageIndex: 0, - }); - }); - }); - - describe('getListFetchError()', () => { - it('should return undefined if no error exists', () => { - expect(getListFetchError(initialState)).toBeUndefined(); - }); - - it('should return the API error', () => { - const error = { - statusCode: 500, - error: 'Internal Server Error', - message: 'Something is not right', - }; - - initialState.listPage.data = createFailedResourceState(error); - expect(getListFetchError(initialState)).toBe(error); - }); - }); - - describe('getListIsLoading()', () => { - it('should return false if not in a Loading state', () => { - expect(getListIsLoading(initialState)).toBe(false); - }); - - it('should return true if in a Loading state', () => { - setToLoadingState(); - expect(getListIsLoading(initialState)).toBe(true); - }); - }); - - describe('getListPageDoesDataExist()', () => { - it('should return false (default) until we get a Loaded Resource state', () => { - expect(getListPageDoesDataExist(initialState)).toBe(false); - - // Set DataExists to Loading - initialState.listPage.dataExist = createLoadingResourceState( - asStaleResourceState(initialState.listPage.dataExist) - ); - expect(getListPageDoesDataExist(initialState)).toBe(false); - - // Set DataExists to Failure - initialState.listPage.dataExist = createFailedResourceState({ - statusCode: 500, - error: 'Internal Server Error', - message: 'Something is not right', - }); - expect(getListPageDoesDataExist(initialState)).toBe(false); - }); - - it('should return false if no data exists', () => { - initialState.listPage.dataExist = createLoadedResourceState(false); - expect(getListPageDoesDataExist(initialState)).toBe(false); - }); - }); - - describe('listDataNeedsRefresh()', () => { - beforeEach(() => { - setToLoadedState(); - - initialState.location = { - page_index: 1, - page_size: 10, - filter: '', - id: '', - show: undefined, - included_policies: '', - }; - }); - - it('should return false if location url params match those that were used in api call', () => { - expect(listDataNeedsRefresh(initialState)).toBe(false); - }); - - it('should return true if `forceRefresh` is set', () => { - initialState.listPage.forceRefresh = true; - expect(listDataNeedsRefresh(initialState)).toBe(true); - }); - - it('should should return true if any of the url params differ from last api call', () => { - initialState.location.page_index = 10; - expect(listDataNeedsRefresh(initialState)).toBe(true); - }); - }); - - describe('getFormEntry()', () => { - it('returns undefined when there is no entry', () => { - expect(getFormEntry(initialState)).toBe(undefined); - }); - it('returns entry when there is an entry on form', () => { - const entry = getInitialExceptionFromEvent(ecsEventMock()); - const state = { - ...initialState, - form: { - ...initialState.form, - entry, - }, - }; - expect(getFormEntry(state)).toBe(entry); - }); - }); - describe('getHasNameError()', () => { - it('returns false when there is no entry', () => { - expect(getHasNameError(initialState)).toBeFalsy(); - }); - it('returns true when entry with name error', () => { - const state = { - ...initialState, - form: { - ...initialState.form, - hasNameError: true, - }, - }; - expect(getHasNameError(state)).toBeTruthy(); - }); - it('returns false when entry with no name error', () => { - const state = { - ...initialState, - form: { - ...initialState.form, - hasNameError: false, - }, - }; - expect(getHasNameError(state)).toBeFalsy(); - }); - }); - describe('getFormHasError()', () => { - it('returns false when there is no entry', () => { - expect(getFormHasError(initialState)).toBeFalsy(); - }); - it('returns true when entry with name error', () => { - const state = { - ...initialState, - form: { - ...initialState.form, - hasNameError: true, - }, - }; - expect(getFormHasError(state)).toBeTruthy(); - }); - it('returns true when entry with item error', () => { - const state = { - ...initialState, - form: { - ...initialState.form, - hasItemsError: true, - }, - }; - expect(getFormHasError(state)).toBeTruthy(); - }); - it('returns true when entry with os error', () => { - const state = { - ...initialState, - form: { - ...initialState.form, - hasOSError: true, - }, - }; - expect(getFormHasError(state)).toBeTruthy(); - }); - it('returns true when entry with item error, name error and os error', () => { - const state = { - ...initialState, - form: { - ...initialState.form, - hasItemsError: true, - hasNameError: true, - hasOSError: true, - }, - }; - expect(getFormHasError(state)).toBeTruthy(); - }); - - it('returns false when entry without errors', () => { - const state = { - ...initialState, - form: { - ...initialState.form, - hasItemsError: false, - hasNameError: false, - hasOSError: false, - }, - }; - expect(getFormHasError(state)).toBeFalsy(); - }); - }); - describe('getCurrentLocation()', () => { - it('returns current locations', () => { - const expectedLocation: EventFiltersPageLocation = { - show: 'create', - page_index: MANAGEMENT_DEFAULT_PAGE, - page_size: MANAGEMENT_DEFAULT_PAGE_SIZE, - filter: 'filter', - included_policies: '1', - }; - const state = { - ...initialState, - location: expectedLocation, - }; - expect(getCurrentLocation(state)).toBe(expectedLocation); - }); - }); - describe('getNewComment()', () => { - it('returns new comment', () => { - const newComment = 'this is a new comment'; - const state = { - ...initialState, - form: { - ...initialState.form, - newComment, - }, - }; - expect(getNewComment(state)).toBe(newComment); - }); - it('returns empty comment', () => { - const state = { - ...initialState, - }; - expect(getNewComment(state)).toBe(''); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/test_utils/index.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/test_utils/index.ts index 398b3d9fa6d37..6edff2d89c416 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/test_utils/index.ts +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/test_utils/index.ts @@ -5,7 +5,6 @@ * 2.0. */ -import { combineReducers, createStore } from 'redux'; import type { FoundExceptionListItemSchema, ExceptionListItemSchema, @@ -17,27 +16,11 @@ import { getExceptionListItemSchemaMock } from '@kbn/lists-plugin/common/schemas import { getSummaryExceptionListSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_summary_schema.mock'; import { Ecs } from '../../../../../common/ecs'; -import { - MANAGEMENT_STORE_GLOBAL_NAMESPACE, - MANAGEMENT_STORE_EVENT_FILTERS_NAMESPACE, -} from '../../../common/constants'; - -import { eventFiltersPageReducer } from '../store/reducer'; import { httpHandlerMockFactory, ResponseProvidersInterface, } from '../../../../common/mock/endpoint/http_handler_mock_factory'; -export const createGlobalNoMiddlewareStore = () => { - return createStore( - combineReducers({ - [MANAGEMENT_STORE_GLOBAL_NAMESPACE]: combineReducers({ - [MANAGEMENT_STORE_EVENT_FILTERS_NAMESPACE]: eventFiltersPageReducer, - }), - }) - ); -}; - export const ecsEventMock = (): Ecs => ({ _id: 'unLfz3gB2mJZsMY3ytx3', timestamp: '2021-04-14T15:34:15.330Z', @@ -206,6 +189,8 @@ export const esResponseData = () => ({ ], }, }, + indexFields: [], + indicesExist: [], isPartial: false, isRunning: false, total: 1, diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/types.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/types.ts index f15bd47e0f3e7..b6a7c3b555daa 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/types.ts +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/types.ts @@ -12,7 +12,6 @@ import type { UpdateExceptionListItemSchema, ExceptionListSummarySchema, } from '@kbn/securitysolution-io-ts-list-types'; -import { AsyncResourceState } from '../../state/async_resource_state'; import { Immutable } from '../../../../common/endpoint/types'; export interface EventFiltersPageLocation { @@ -25,15 +24,6 @@ export interface EventFiltersPageLocation { included_policies: string; } -export interface EventFiltersForm { - entry: UpdateExceptionListItemSchema | CreateExceptionListItemSchema | undefined; - newComment: string; - hasNameError: boolean; - hasItemsError: boolean; - hasOSError: boolean; - submissionResourceState: AsyncResourceState; -} - export type EventFiltersServiceGetListOptions = Partial<{ page: number; perPage: number; @@ -60,22 +50,3 @@ export interface EventFiltersListPageData { /** The data retrieved from the API */ content: FoundExceptionListItemSchema; } - -export interface EventFiltersListPageState { - entries: ExceptionListItemSchema[]; - form: EventFiltersForm; - location: EventFiltersPageLocation; - /** State for the Event Filters List page */ - listPage: { - active: boolean; - forceRefresh: boolean; - data: AsyncResourceState; - /** tracks if the overall list (not filtered or with invalid page numbers) contains data */ - dataExist: AsyncResourceState; - /** state for deletion of items from the list */ - deletion: { - item: ExceptionListItemSchema | undefined; - status: AsyncResourceState; - }; - }; -} diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/empty/index.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/empty/index.tsx deleted file mode 100644 index e48d4f8fb4d21..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/empty/index.tsx +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { memo } from 'react'; -import styled, { css } from 'styled-components'; -import { EuiButton, EuiEmptyPrompt } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n-react'; -import { ManagementEmptyStateWrapper } from '../../../../../components/management_empty_state_wrapper'; - -const EmptyPrompt = styled(EuiEmptyPrompt)` - ${() => css` - max-width: 100%; - `} -`; - -export const EventFiltersListEmptyState = memo<{ - onAdd: () => void; - /** Should the Add button be disabled */ - isAddDisabled?: boolean; - backComponent?: React.ReactNode; -}>(({ onAdd, isAddDisabled = false, backComponent }) => { - return ( - - - - - } - body={ - - } - actions={[ - - - , - ...(backComponent ? [backComponent] : []), - ]} - /> - - ); -}); - -EventFiltersListEmptyState.displayName = 'EventFiltersListEmptyState'; diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/event_filter_delete_modal.test.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/event_filter_delete_modal.test.tsx deleted file mode 100644 index 9e245e5c8214e..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/event_filter_delete_modal.test.tsx +++ /dev/null @@ -1,177 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { - AppContextTestRender, - createAppRootMockRenderer, -} from '../../../../../common/mock/endpoint'; -import { act } from '@testing-library/react'; -import { fireEvent } from '@testing-library/dom'; -import { EventFilterDeleteModal } from './event_filter_delete_modal'; -import { getExceptionListItemSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_item_schema.mock'; -import { showDeleteModal } from '../../store/selector'; -import { isFailedResourceState, isLoadedResourceState } from '../../../../state'; -import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; - -describe('When event filters delete modal is shown', () => { - let renderAndSetup: ( - customEventFilterProps?: Partial - ) => Promise>; - let renderResult: ReturnType; - let coreStart: AppContextTestRender['coreStart']; - let history: AppContextTestRender['history']; - let waitForAction: AppContextTestRender['middlewareSpy']['waitForAction']; - let store: AppContextTestRender['store']; - - const getConfirmButton = () => - renderResult.baseElement.querySelector( - '[data-test-subj="eventFilterDeleteModalConfirmButton"]' - ) as HTMLButtonElement; - - const getCancelButton = () => - renderResult.baseElement.querySelector( - '[data-test-subj="eventFilterDeleteModalCancelButton"]' - ) as HTMLButtonElement; - - const getCurrentState = () => store.getState().management.eventFilters; - - beforeEach(() => { - const mockedContext = createAppRootMockRenderer(); - - ({ history, store, coreStart } = mockedContext); - renderAndSetup = async (customEventFilterProps) => { - renderResult = mockedContext.render(); - - await act(async () => { - history.push('/administration/event_filters'); - - await waitForAction('userChangedUrl'); - - mockedContext.store.dispatch({ - type: 'eventFilterForDeletion', - payload: getExceptionListItemSchemaMock({ - id: '123', - name: 'tic-tac-toe', - tags: [], - ...(customEventFilterProps ? customEventFilterProps : {}), - }), - }); - }); - - return renderResult; - }; - - waitForAction = mockedContext.middlewareSpy.waitForAction; - }); - - it("should display calllout when it's assigned to one policy", async () => { - await renderAndSetup({ tags: ['policy:1'] }); - expect(renderResult.getByTestId('eventFilterDeleteModalCalloutMessage').textContent).toMatch( - /Deleting this entry will remove it from 1 associated policy./ - ); - }); - - it("should display calllout when it's assigned to more than one policy", async () => { - await renderAndSetup({ tags: ['policy:1', 'policy:2', 'policy:3'] }); - expect(renderResult.getByTestId('eventFilterDeleteModalCalloutMessage').textContent).toMatch( - /Deleting this entry will remove it from 3 associated policies./ - ); - }); - - it("should display calllout when it's assigned globally", async () => { - await renderAndSetup({ tags: ['policy:all'] }); - expect(renderResult.getByTestId('eventFilterDeleteModalCalloutMessage').textContent).toMatch( - /Deleting this entry will remove it from all associated policies./ - ); - }); - - it("should display calllout when it's unassigned", async () => { - await renderAndSetup({ tags: [] }); - expect(renderResult.getByTestId('eventFilterDeleteModalCalloutMessage').textContent).toMatch( - /Deleting this entry will remove it from 0 associated policies./ - ); - }); - - it('should close dialog if cancel button is clicked', async () => { - await renderAndSetup(); - act(() => { - fireEvent.click(getCancelButton()); - }); - - expect(showDeleteModal(getCurrentState())).toBe(false); - }); - - it('should close dialog if the close X button is clicked', async () => { - await renderAndSetup(); - const dialogCloseButton = renderResult.baseElement.querySelector( - '[aria-label="Closes this modal window"]' - )!; - act(() => { - fireEvent.click(dialogCloseButton); - }); - - expect(showDeleteModal(getCurrentState())).toBe(false); - }); - - it('should disable action buttons when confirmed', async () => { - await renderAndSetup(); - act(() => { - fireEvent.click(getConfirmButton()); - }); - - expect(getCancelButton().disabled).toBe(true); - expect(getConfirmButton().disabled).toBe(true); - }); - - it('should set confirm button to loading', async () => { - await renderAndSetup(); - act(() => { - fireEvent.click(getConfirmButton()); - }); - - expect(getConfirmButton().querySelector('.euiLoadingSpinner')).not.toBeNull(); - }); - - it('should show success toast', async () => { - await renderAndSetup(); - const updateCompleted = waitForAction('eventFilterDeleteStatusChanged', { - validate(action) { - return isLoadedResourceState(action.payload); - }, - }); - - await act(async () => { - fireEvent.click(getConfirmButton()); - await updateCompleted; - }); - - expect(coreStart.notifications.toasts.addSuccess).toHaveBeenCalledWith( - '"tic-tac-toe" has been removed from the event filters list.' - ); - }); - - it('should show error toast if error is countered', async () => { - coreStart.http.delete.mockRejectedValue(new Error('oh oh')); - await renderAndSetup(); - const updateFailure = waitForAction('eventFilterDeleteStatusChanged', { - validate(action) { - return isFailedResourceState(action.payload); - }, - }); - - await act(async () => { - fireEvent.click(getConfirmButton()); - await updateFailure; - }); - - expect(coreStart.notifications.toasts.addDanger).toHaveBeenCalledWith( - 'Unable to remove "tic-tac-toe" from the event filters list. Reason: oh oh' - ); - expect(showDeleteModal(getCurrentState())).toBe(true); - }); -}); diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/event_filter_delete_modal.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/event_filter_delete_modal.tsx deleted file mode 100644 index 75e49bf270bab..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/event_filter_delete_modal.tsx +++ /dev/null @@ -1,159 +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 { - EuiButtonEmpty, - EuiCallOut, - EuiModal, - EuiModalBody, - EuiModalFooter, - EuiModalHeader, - EuiModalHeaderTitle, - EuiSpacer, - EuiText, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n-react'; -import React, { memo, useCallback, useEffect } from 'react'; -import { useDispatch } from 'react-redux'; -import { Dispatch } from 'redux'; -import { AutoFocusButton } from '../../../../../common/components/autofocus_button/autofocus_button'; -import { useToasts } from '../../../../../common/lib/kibana'; -import { AppAction } from '../../../../../common/store/actions'; -import { - getArtifactPoliciesIdByTag, - isGlobalPolicyEffected, -} from '../../../../components/effected_policy_select/utils'; -import { - getDeleteError, - getItemToDelete, - isDeletionInProgress, - wasDeletionSuccessful, -} from '../../store/selector'; -import { useEventFiltersSelector } from '../hooks'; - -export const EventFilterDeleteModal = memo<{}>(() => { - const dispatch = useDispatch>(); - const toasts = useToasts(); - - const isDeleting = useEventFiltersSelector(isDeletionInProgress); - const eventFilter = useEventFiltersSelector(getItemToDelete); - const wasDeleted = useEventFiltersSelector(wasDeletionSuccessful); - const deleteError = useEventFiltersSelector(getDeleteError); - - const onCancel = useCallback(() => { - dispatch({ type: 'eventFilterDeletionReset' }); - }, [dispatch]); - - const onConfirm = useCallback(() => { - dispatch({ type: 'eventFilterDeleteSubmit' }); - }, [dispatch]); - - // Show toast for success - useEffect(() => { - if (wasDeleted) { - toasts.addSuccess( - i18n.translate('xpack.securitySolution.eventFilters.deletionDialog.deleteSuccess', { - defaultMessage: '"{name}" has been removed from the event filters list.', - values: { name: eventFilter?.name }, - }) - ); - - dispatch({ type: 'eventFilterDeletionReset' }); - } - }, [dispatch, eventFilter?.name, toasts, wasDeleted]); - - // show toast for failures - useEffect(() => { - if (deleteError) { - toasts.addDanger( - i18n.translate('xpack.securitySolution.eventFilters.deletionDialog.deleteFailure', { - defaultMessage: - 'Unable to remove "{name}" from the event filters list. Reason: {message}', - values: { name: eventFilter?.name, message: deleteError.message }, - }) - ); - } - }, [deleteError, eventFilter?.name, toasts]); - - return ( - - - - {eventFilter?.name ?? ''} }} - /> - - - - - - -

- -

-
- -

- -

-
-
- - - - - - - - - - -
- ); -}); - -EventFilterDeleteModal.displayName = 'EventFilterDeleteModal'; diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/event_filters_flyout.test.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/event_filters_flyout.test.tsx new file mode 100644 index 0000000000000..21bd1fa655c2e --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/event_filters_flyout.test.tsx @@ -0,0 +1,222 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EventFiltersFlyout, EventFiltersFlyoutProps } from './event_filters_flyout'; +import { act, cleanup } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { + AppContextTestRender, + createAppRootMockRenderer, +} from '../../../../../common/mock/endpoint'; + +import { getInitialExceptionFromEvent } from '../utils'; +import { useCreateArtifact } from '../../../../hooks/artifacts/use_create_artifact'; +import { useGetEndpointSpecificPolicies } from '../../../../services/policies/hooks'; +import { ecsEventMock, esResponseData } from '../../test_utils'; + +import { useKibana, useToasts } from '../../../../../common/lib/kibana'; +import { of } from 'rxjs'; +import { ExceptionsListItemGenerator } from '../../../../../../common/endpoint/data_generators/exceptions_list_item_generator'; +import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; + +// mocked modules +jest.mock('../../../../../common/lib/kibana'); +jest.mock('../../../../services/policies/hooks'); +jest.mock('../../../../services/policies/policies'); +jest.mock('../../../../hooks/artifacts/use_create_artifact'); +jest.mock('../utils'); + +let mockedContext: AppContextTestRender; +let render: ( + props?: Partial +) => ReturnType; +let renderResult: ReturnType; +let onCancelMock: jest.Mock; +const exceptionsGenerator = new ExceptionsListItemGenerator(); + +describe('Event filter flyout', () => { + beforeEach(async () => { + mockedContext = createAppRootMockRenderer(); + onCancelMock = jest.fn(); + + (useKibana as jest.Mock).mockReturnValue({ + services: { + docLinks: { + links: { + securitySolution: { + eventFilters: '', + }, + }, + }, + http: {}, + data: { + search: { + search: jest.fn().mockImplementation(() => of(esResponseData())), + }, + }, + notifications: {}, + unifiedSearch: {}, + }, + }); + (useToasts as jest.Mock).mockReturnValue({ + addSuccess: jest.fn(), + addError: jest.fn(), + addWarning: jest.fn(), + remove: jest.fn(), + }); + + (useCreateArtifact as jest.Mock).mockImplementation(() => { + return { + isLoading: false, + mutateAsync: jest.fn(), + }; + }); + + (useGetEndpointSpecificPolicies as jest.Mock).mockImplementation(() => { + return { isLoading: false, isRefetching: false }; + }); + + render = (props) => { + renderResult = mockedContext.render( + + ); + return renderResult; + }; + }); + + afterEach(() => { + cleanup(); + }); + + describe('On initial render', () => { + const exception = exceptionsGenerator.generateEventFilterForCreate({ + meta: {}, + entries: [ + { + field: 'event.category', + operator: 'included', + type: 'match', + value: 'a', + }, + { + field: 'process.executable', + operator: 'included', + type: 'match', + value: 'b', + }, + ], + name: '', + }); + beforeEach(() => { + (getInitialExceptionFromEvent as jest.Mock).mockImplementation(() => { + return exception; + }); + }); + it('should render correctly without data ', () => { + render(); + expect(renderResult.getAllByText('Add event filter')).not.toBeNull(); + expect(renderResult.getByText('Cancel')).not.toBeNull(); + }); + + it('should render correctly with data ', () => { + act(() => { + render({ data: ecsEventMock() }); + }); + expect(renderResult.getAllByText('Add endpoint event filter')).not.toBeNull(); + expect(renderResult.getByText('Cancel')).not.toBeNull(); + }); + + it('should start with "add event filter" button disabled', () => { + render(); + const confirmButton = renderResult.getByTestId('add-exception-confirm-button'); + expect(confirmButton.hasAttribute('disabled')).toBeTruthy(); + }); + + it('should close when click on cancel button', () => { + render(); + const cancelButton = renderResult.getByTestId('cancelExceptionAddButton'); + expect(onCancelMock).toHaveBeenCalledTimes(0); + + userEvent.click(cancelButton); + expect(onCancelMock).toHaveBeenCalledTimes(1); + }); + }); + + describe('When valid form state', () => { + const exceptionOptions: Partial = { + meta: {}, + entries: [ + { + field: 'event.category', + operator: 'included', + type: 'match', + value: 'a', + }, + { + field: 'process.executable', + operator: 'included', + type: 'match', + value: 'b', + }, + ], + name: 'some name', + }; + beforeEach(() => { + const exception = exceptionsGenerator.generateEventFilterForCreate(exceptionOptions); + (getInitialExceptionFromEvent as jest.Mock).mockImplementation(() => { + return exception; + }); + }); + it('should change to "add event filter" button enabled', () => { + render(); + const confirmButton = renderResult.getByTestId('add-exception-confirm-button'); + expect(confirmButton.hasAttribute('disabled')).toBeFalsy(); + }); + it('should prevent close when submitting data', () => { + (useCreateArtifact as jest.Mock).mockImplementation(() => { + return { isLoading: true, mutateAsync: jest.fn() }; + }); + render(); + const cancelButton = renderResult.getByTestId('cancelExceptionAddButton'); + expect(onCancelMock).toHaveBeenCalledTimes(0); + + userEvent.click(cancelButton); + expect(onCancelMock).toHaveBeenCalledTimes(0); + }); + + it('should close when exception has been submitted successfully and close flyout', () => { + // mock submit query + (useCreateArtifact as jest.Mock).mockImplementation(() => { + return { + isLoading: false, + mutateAsync: ( + _: Parameters['mutateAsync']>[0], + options: Parameters['mutateAsync']>[1] + ) => { + if (!options) return; + if (!options.onSuccess) return; + const exception = exceptionsGenerator.generateEventFilter(exceptionOptions); + + options.onSuccess(exception, exception, () => null); + }, + }; + }); + + render(); + + const confirmButton = renderResult.getByTestId('add-exception-confirm-button'); + expect(confirmButton.hasAttribute('disabled')).toBeFalsy(); + expect(onCancelMock).toHaveBeenCalledTimes(0); + userEvent.click(confirmButton); + + expect(useToasts().addSuccess).toHaveBeenCalled(); + expect(onCancelMock).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/event_filters_flyout.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/event_filters_flyout.tsx new file mode 100644 index 0000000000000..c370f548e6812 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/event_filters_flyout.tsx @@ -0,0 +1,239 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useMemo, useEffect, useCallback, useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; + +import { + EuiFlyout, + EuiFlyoutHeader, + EuiTitle, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiTextColor, +} from '@elastic/eui'; +import { lastValueFrom } from 'rxjs'; + +import { useWithArtifactSubmitData } from '../../../../components/artifact_list_page/hooks/use_with_artifact_submit_data'; +import { + ArtifactFormComponentOnChangeCallbackProps, + ArtifactFormComponentProps, +} from '../../../../components/artifact_list_page/types'; +import { EventFiltersForm } from './form'; + +import { getInitialExceptionFromEvent } from '../utils'; +import { Ecs } from '../../../../../../common/ecs'; +import { useHttp, useKibana, useToasts } from '../../../../../common/lib/kibana'; +import { useGetEndpointSpecificPolicies } from '../../../../services/policies/hooks'; +import { getLoadPoliciesError } from '../../../../common/translations'; + +import { EventFiltersApiClient } from '../../service/api_client'; +import { getCreationSuccessMessage, getCreationErrorMessage } from '../translations'; +export interface EventFiltersFlyoutProps { + data?: Ecs; + onCancel(): void; + maskProps?: { + style?: string; + }; +} + +export const EventFiltersFlyout: React.FC = memo( + ({ onCancel: onClose, data, ...flyoutProps }) => { + const toasts = useToasts(); + const http = useHttp(); + + const { isLoading: isSubmittingData, mutateAsync: submitData } = useWithArtifactSubmitData( + EventFiltersApiClient.getInstance(http), + 'create' + ); + + const [enrichedData, setEnrichedData] = useState(); + const [isFormValid, setIsFormValid] = useState(false); + const { + data: { search }, + } = useKibana().services; + + // load the list of policies> + const policiesRequest = useGetEndpointSpecificPolicies({ + perPage: 1000, + onError: (error) => { + toasts.addWarning(getLoadPoliciesError(error)); + }, + }); + + const [exception, setException] = useState( + getInitialExceptionFromEvent(data) + ); + + const policiesIsLoading = useMemo( + () => policiesRequest.isLoading || policiesRequest.isRefetching, + [policiesRequest] + ); + + useEffect(() => { + const enrichEvent = async () => { + if (!data || !data._index) return; + const searchResponse = await lastValueFrom( + search.search({ + params: { + index: data._index, + body: { + query: { + match: { + _id: data._id, + }, + }, + }, + }, + }) + ); + setEnrichedData({ + ...data, + host: { + ...data.host, + os: { + ...(data?.host?.os || {}), + name: [searchResponse.rawResponse.hits.hits[0]._source.host.os.name], + }, + }, + }); + }; + + if (data) { + enrichEvent(); + } + + return () => { + setException(getInitialExceptionFromEvent()); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleOnClose = useCallback(() => { + if (policiesIsLoading || isSubmittingData) return; + onClose(); + }, [isSubmittingData, policiesIsLoading, onClose]); + + const handleOnSubmit = useCallback(() => { + return submitData(exception, { + onSuccess: (result) => { + toasts.addSuccess(getCreationSuccessMessage(result)); + onClose(); + }, + onError: (error) => { + toasts.addError(error, getCreationErrorMessage(error)); + }, + }); + }, [exception, onClose, submitData, toasts]); + + const confirmButtonMemo = useMemo( + () => ( + + {data ? ( + + ) : ( + + )} + + ), + [data, enrichedData, handleOnSubmit, isFormValid, isSubmittingData, policiesIsLoading] + ); + + // update flyout state with form state + const onChange = useCallback((formState?: ArtifactFormComponentOnChangeCallbackProps) => { + if (!formState) return; + setIsFormValid(formState.isValid); + setException(formState.item); + }, []); + + return ( + + + +

+ {data ? ( + + ) : ( + + )} +

+
+ {data ? ( + + + + ) : null} +
+ + + + + + + + + + + + + {confirmButtonMemo} + + +
+ ); + } +); + +EventFiltersFlyout.displayName = 'EventFiltersFlyout'; diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/flyout/index.test.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/flyout/index.test.tsx deleted file mode 100644 index 0ba0a3385dcb6..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/flyout/index.test.tsx +++ /dev/null @@ -1,287 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ -import React from 'react'; -import { EventFiltersFlyout, EventFiltersFlyoutProps } from '.'; -import * as reactTestingLibrary from '@testing-library/react'; -import { fireEvent } from '@testing-library/dom'; -import { - AppContextTestRender, - createAppRootMockRenderer, -} from '../../../../../../common/mock/endpoint'; -import { MiddlewareActionSpyHelper } from '../../../../../../common/store/test_utils'; -import { sendGetEndpointSpecificPackagePolicies } from '../../../../../services/policies/policies'; -import { sendGetEndpointSpecificPackagePoliciesMock } from '../../../../../services/policies/test_mock_utils'; -import type { - CreateExceptionListItemSchema, - ExceptionListItemSchema, -} from '@kbn/securitysolution-io-ts-list-types'; -import { ecsEventMock, esResponseData, eventFiltersListQueryHttpMock } from '../../../test_utils'; -import { getFormEntryState, isUninitialisedForm } from '../../../store/selector'; -import { EventFiltersListPageState } from '../../../types'; -import { useKibana } from '../../../../../../common/lib/kibana'; -import { licenseService } from '../../../../../../common/hooks/use_license'; -import { getExceptionListItemSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_item_schema.mock'; -import { of } from 'rxjs'; - -jest.mock('../../../../../../common/lib/kibana'); -jest.mock('../form'); -jest.mock('../../../../../services/policies/policies'); - -jest.mock('../../hooks', () => { - const originalModule = jest.requireActual('../../hooks'); - const useEventFiltersNotification = jest.fn().mockImplementation(() => {}); - - return { - ...originalModule, - useEventFiltersNotification, - }; -}); - -jest.mock('../../../../../../common/hooks/use_license', () => { - const licenseServiceInstance = { - isPlatinumPlus: jest.fn(), - }; - return { - licenseService: licenseServiceInstance, - useLicense: () => { - return licenseServiceInstance; - }, - }; -}); - -(sendGetEndpointSpecificPackagePolicies as jest.Mock).mockImplementation( - sendGetEndpointSpecificPackagePoliciesMock -); - -let component: reactTestingLibrary.RenderResult; -let mockedContext: AppContextTestRender; -let waitForAction: MiddlewareActionSpyHelper['waitForAction']; -let render: ( - props?: Partial -) => ReturnType; -const act = reactTestingLibrary.act; -let onCancelMock: jest.Mock; -let getState: () => EventFiltersListPageState; -let mockedApi: ReturnType; - -describe('Event filter flyout', () => { - beforeEach(() => { - (licenseService.isPlatinumPlus as jest.Mock).mockReturnValue(true); - mockedContext = createAppRootMockRenderer(); - waitForAction = mockedContext.middlewareSpy.waitForAction; - onCancelMock = jest.fn(); - getState = () => mockedContext.store.getState().management.eventFilters; - mockedApi = eventFiltersListQueryHttpMock(mockedContext.coreStart.http); - - render = (props) => { - return mockedContext.render(); - }; - - (useKibana as jest.Mock).mockReturnValue({ - services: { - docLinks: { - links: { - securitySolution: { - eventFilters: '', - }, - }, - }, - http: {}, - data: { - search: { - search: jest.fn().mockImplementation(() => of(esResponseData())), - }, - }, - notifications: {}, - }, - }); - }); - - afterEach(() => reactTestingLibrary.cleanup()); - - it('should renders correctly', () => { - component = render(); - expect(component.getAllByText('Add event filter')).not.toBeNull(); - expect(component.getByText('Cancel')).not.toBeNull(); - }); - - it('should renders correctly with data ', async () => { - await act(async () => { - component = render({ data: ecsEventMock() }); - await waitForAction('eventFiltersInitForm'); - }); - expect(component.getAllByText('Add endpoint event filter')).not.toBeNull(); - expect(component.getByText('Cancel')).not.toBeNull(); - }); - - it('should dispatch action to init form store on mount', async () => { - await act(async () => { - render(); - await waitForAction('eventFiltersInitForm'); - }); - - expect(getFormEntryState(getState())).not.toBeUndefined(); - expect(getFormEntryState(getState())?.entries[0].field).toBe(''); - }); - - it('should confirm form when button is disabled', () => { - component = render(); - const confirmButton = component.getByTestId('add-exception-confirm-button'); - act(() => { - fireEvent.click(confirmButton); - }); - expect(isUninitialisedForm(getState())).toBeTruthy(); - }); - - it('should confirm form when button is enabled', async () => { - component = render(); - - mockedContext.store.dispatch({ - type: 'eventFiltersChangeForm', - payload: { - entry: { - ...(getState().form?.entry as CreateExceptionListItemSchema), - name: 'test', - os_types: ['windows'], - }, - hasNameError: false, - hasOSError: false, - }, - }); - await reactTestingLibrary.waitFor(() => { - expect(sendGetEndpointSpecificPackagePolicies).toHaveBeenCalled(); - }); - const confirmButton = component.getByTestId('add-exception-confirm-button'); - - await act(async () => { - fireEvent.click(confirmButton); - await waitForAction('eventFiltersCreateSuccess'); - }); - expect(isUninitialisedForm(getState())).toBeTruthy(); - expect(confirmButton.hasAttribute('disabled')).toBeFalsy(); - }); - - it('should close when exception has been submitted correctly', () => { - render(); - expect(onCancelMock).toHaveBeenCalledTimes(0); - - act(() => { - mockedContext.store.dispatch({ - type: 'eventFiltersFormStateChanged', - payload: { - type: 'LoadedResourceState', - data: getState().form?.entry as ExceptionListItemSchema, - }, - }); - }); - - expect(onCancelMock).toHaveBeenCalledTimes(1); - }); - - it('should close when click on cancel button', () => { - component = render(); - const cancelButton = component.getByText('Cancel'); - expect(onCancelMock).toHaveBeenCalledTimes(0); - - act(() => { - fireEvent.click(cancelButton); - }); - - expect(onCancelMock).toHaveBeenCalledTimes(1); - }); - - it('should close when close flyout', () => { - component = render(); - const flyoutCloseButton = component.getByTestId('euiFlyoutCloseButton'); - expect(onCancelMock).toHaveBeenCalledTimes(0); - - act(() => { - fireEvent.click(flyoutCloseButton); - }); - - expect(onCancelMock).toHaveBeenCalledTimes(1); - }); - - it('should prevent close when is loading action', () => { - component = render(); - act(() => { - mockedContext.store.dispatch({ - type: 'eventFiltersFormStateChanged', - payload: { - type: 'LoadingResourceState', - previousState: { type: 'UninitialisedResourceState' }, - }, - }); - }); - - const cancelButton = component.getByText('Cancel'); - expect(onCancelMock).toHaveBeenCalledTimes(0); - - act(() => { - fireEvent.click(cancelButton); - }); - - expect(onCancelMock).toHaveBeenCalledTimes(0); - }); - - it('should renders correctly when id and edit type', () => { - component = render({ id: 'fakeId', type: 'edit' }); - - expect(component.getAllByText('Update event filter')).not.toBeNull(); - expect(component.getByText('Cancel')).not.toBeNull(); - }); - - it('should dispatch action to init form store on mount with id', async () => { - await act(async () => { - render({ id: 'fakeId', type: 'edit' }); - await waitForAction('eventFiltersInitFromId'); - }); - - expect(getFormEntryState(getState())).not.toBeUndefined(); - expect(getFormEntryState(getState())?.item_id).toBe( - mockedApi.responseProvider.eventFiltersGetOne.getMockImplementation()!().item_id - ); - }); - - it('should not display banner when platinum license', async () => { - await act(async () => { - component = render({ id: 'fakeId', type: 'edit' }); - await waitForAction('eventFiltersInitFromId'); - }); - - expect(component.queryByTestId('expired-license-callout')).toBeNull(); - }); - - it('should not display banner when under platinum license and create mode', async () => { - component = render(); - expect(component.queryByTestId('expired-license-callout')).toBeNull(); - }); - - it('should not display banner when under platinum license and edit mode with global assignment', async () => { - mockedApi.responseProvider.eventFiltersGetOne.mockReturnValue({ - ...getExceptionListItemSchemaMock(), - tags: ['policy:all'], - }); - (licenseService.isPlatinumPlus as jest.Mock).mockReturnValue(false); - await act(async () => { - component = render({ id: 'fakeId', type: 'edit' }); - await waitForAction('eventFiltersInitFromId'); - }); - - expect(component.queryByTestId('expired-license-callout')).toBeNull(); - }); - - it('should display banner when under platinum license and edit mode with by policy assignment', async () => { - (licenseService.isPlatinumPlus as jest.Mock).mockReturnValue(false); - await act(async () => { - component = render({ id: 'fakeId', type: 'edit' }); - await waitForAction('eventFiltersInitFromId'); - }); - - expect(component.queryByTestId('expired-license-callout')).not.toBeNull(); - }); -}); diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/flyout/index.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/flyout/index.tsx deleted file mode 100644 index ed4e0e11975c7..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/flyout/index.tsx +++ /dev/null @@ -1,302 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { memo, useMemo, useEffect, useCallback, useState } from 'react'; -import { useDispatch } from 'react-redux'; -import { Dispatch } from 'redux'; - -import { FormattedMessage } from '@kbn/i18n-react'; -import { i18n } from '@kbn/i18n'; -import { - EuiFlyout, - EuiFlyoutHeader, - EuiTitle, - EuiFlyoutBody, - EuiFlyoutFooter, - EuiButton, - EuiButtonEmpty, - EuiFlexGroup, - EuiFlexItem, - EuiTextColor, - EuiCallOut, - EuiLink, -} from '@elastic/eui'; -import { lastValueFrom } from 'rxjs'; -import { AppAction } from '../../../../../../common/store/actions'; -import { EventFiltersForm } from '../form'; -import { useEventFiltersSelector, useEventFiltersNotification } from '../../hooks'; -import { - getFormEntryStateMutable, - getFormHasError, - isCreationInProgress, - isCreationSuccessful, -} from '../../../store/selector'; -import { getInitialExceptionFromEvent } from '../../../store/utils'; -import { Ecs } from '../../../../../../../common/ecs'; -import { useKibana, useToasts } from '../../../../../../common/lib/kibana'; -import { useGetEndpointSpecificPolicies } from '../../../../../services/policies/hooks'; -import { getLoadPoliciesError } from '../../../../../common/translations'; -import { useLicense } from '../../../../../../common/hooks/use_license'; -import { isGlobalPolicyEffected } from '../../../../../components/effected_policy_select/utils'; - -export interface EventFiltersFlyoutProps { - type?: 'create' | 'edit'; - id?: string; - data?: Ecs; - onCancel(): void; - maskProps?: { - style?: string; - }; -} - -export const EventFiltersFlyout: React.FC = memo( - ({ onCancel, id, type = 'create', data, ...flyoutProps }) => { - useEventFiltersNotification(); - const [enrichedData, setEnrichedData] = useState(); - const toasts = useToasts(); - const dispatch = useDispatch>(); - const formHasError = useEventFiltersSelector(getFormHasError); - const creationInProgress = useEventFiltersSelector(isCreationInProgress); - const creationSuccessful = useEventFiltersSelector(isCreationSuccessful); - const exception = useEventFiltersSelector(getFormEntryStateMutable); - const { - data: { search }, - docLinks, - } = useKibana().services; - - // load the list of policies> - const policiesRequest = useGetEndpointSpecificPolicies({ - perPage: 1000, - onError: (error) => { - toasts.addWarning(getLoadPoliciesError(error)); - }, - }); - - const isPlatinumPlus = useLicense().isPlatinumPlus(); - const isEditMode = useMemo(() => type === 'edit' && !!id, [type, id]); - const [wasByPolicy, setWasByPolicy] = useState(undefined); - - const showExpiredLicenseBanner = useMemo(() => { - return !isPlatinumPlus && isEditMode && wasByPolicy; - }, [isPlatinumPlus, isEditMode, wasByPolicy]); - - useEffect(() => { - if (exception && wasByPolicy === undefined) { - setWasByPolicy(!isGlobalPolicyEffected(exception?.tags)); - } - }, [exception, wasByPolicy]); - - useEffect(() => { - if (creationSuccessful) { - onCancel(); - dispatch({ - type: 'eventFiltersFormStateChanged', - payload: { - type: 'UninitialisedResourceState', - }, - }); - } - }, [creationSuccessful, onCancel, dispatch]); - - // Initialize the store with the id passed as prop to allow render the form. It acts as componentDidMount - useEffect(() => { - const enrichEvent = async () => { - if (!data || !data._index) return; - const searchResponse = await lastValueFrom( - search.search({ - params: { - index: data._index, - body: { - query: { - match: { - _id: data._id, - }, - }, - }, - }, - }) - ); - - setEnrichedData({ - ...data, - host: { - ...data.host, - os: { - ...(data?.host?.os || {}), - name: [searchResponse.rawResponse.hits.hits[0]._source.host.os.name], - }, - }, - }); - }; - - if (type === 'edit' && !!id) { - dispatch({ - type: 'eventFiltersInitFromId', - payload: { id }, - }); - } else if (data) { - enrichEvent(); - } else { - dispatch({ - type: 'eventFiltersInitForm', - payload: { entry: getInitialExceptionFromEvent() }, - }); - } - - return () => { - dispatch({ - type: 'eventFiltersFormStateChanged', - payload: { - type: 'UninitialisedResourceState', - }, - }); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - // Initialize the store with the enriched event to allow render the form - useEffect(() => { - if (enrichedData) { - dispatch({ - type: 'eventFiltersInitForm', - payload: { entry: getInitialExceptionFromEvent(enrichedData) }, - }); - } - }, [dispatch, enrichedData]); - - const handleOnCancel = useCallback(() => { - if (creationInProgress) return; - onCancel(); - }, [creationInProgress, onCancel]); - - const confirmButtonMemo = useMemo( - () => ( - - id - ? dispatch({ type: 'eventFiltersUpdateStart' }) - : dispatch({ type: 'eventFiltersCreateStart' }) - } - isLoading={creationInProgress} - > - {id ? ( - - ) : data ? ( - - ) : ( - - )} - - ), - [formHasError, creationInProgress, data, enrichedData, id, dispatch, policiesRequest] - ); - - return ( - - - -

- {id ? ( - - ) : data ? ( - - ) : ( - - )} -

-
- {data ? ( - - - - ) : null} -
- - {showExpiredLicenseBanner && ( - - - - - - - )} - - - - - - - - - - - - - {confirmButtonMemo} - - -
- ); - } -); - -EventFiltersFlyout.displayName = 'EventFiltersFlyout'; diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form.test.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form.test.tsx new file mode 100644 index 0000000000000..e20abb2f93264 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form.test.tsx @@ -0,0 +1,468 @@ +/* + * Copyright 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 { act, cleanup } from '@testing-library/react'; +import { fireEvent } from '@testing-library/dom'; +import { stubIndexPattern } from '@kbn/data-plugin/common/stubs'; +import { useFetchIndex } from '../../../../../common/containers/source'; +import { NAME_ERROR } from '../event_filters_list'; +import { useCurrentUser, useKibana } from '../../../../../common/lib/kibana'; +import { licenseService } from '../../../../../common/hooks/use_license'; +import { + AppContextTestRender, + createAppRootMockRenderer, +} from '../../../../../common/mock/endpoint'; +import userEvent from '@testing-library/user-event'; +import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; + +import { ENDPOINT_EVENT_FILTERS_LIST_ID } from '@kbn/securitysolution-list-constants'; +import { + ArtifactFormComponentOnChangeCallbackProps, + ArtifactFormComponentProps, +} from '../../../../components/artifact_list_page'; +import { OperatingSystem } from '@kbn/securitysolution-utils'; +import { EventFiltersForm } from './form'; +import { EndpointDocGenerator } from '../../../../../../common/endpoint/generate_data'; +import { PolicyData } from '../../../../../../common/endpoint/types'; + +jest.mock('../../../../../common/lib/kibana'); +jest.mock('../../../../../common/containers/source'); +jest.mock('../../../../../common/hooks/use_license', () => { + const licenseServiceInstance = { + isPlatinumPlus: jest.fn(), + isGoldPlus: jest.fn(), + }; + return { + licenseService: licenseServiceInstance, + useLicense: () => { + return licenseServiceInstance; + }, + }; +}); + +describe('Event filter form', () => { + const formPrefix = 'eventFilters-form'; + const generator = new EndpointDocGenerator('effected-policy-select'); + + let formProps: jest.Mocked; + let mockedContext: AppContextTestRender; + let renderResult: ReturnType; + let latestUpdatedItem: ArtifactFormComponentProps['item']; + + const getUI = () => ; + const render = () => { + return (renderResult = mockedContext.render(getUI())); + }; + const rerender = () => renderResult.rerender(getUI()); + const rerenderWithLatestProps = () => { + formProps.item = latestUpdatedItem; + rerender(); + }; + + function createEntry( + overrides?: ExceptionListItemSchema['entries'][number] + ): ExceptionListItemSchema['entries'][number] { + const defaultEntry: ExceptionListItemSchema['entries'][number] = { + field: '', + operator: 'included', + type: 'match', + value: '', + }; + + return { + ...defaultEntry, + ...overrides, + }; + } + + function createItem( + overrides: Partial = {} + ): ArtifactFormComponentProps['item'] { + const defaults: ArtifactFormComponentProps['item'] = { + id: 'some_item_id', + list_id: ENDPOINT_EVENT_FILTERS_LIST_ID, + name: '', + description: '', + os_types: [OperatingSystem.WINDOWS], + entries: [createEntry()], + type: 'simple', + tags: ['policy:all'], + }; + return { + ...defaults, + ...overrides, + }; + } + + function createOnChangeArgs( + overrides: Partial + ): ArtifactFormComponentOnChangeCallbackProps { + const defaults = { + item: createItem(), + isValid: false, + }; + return { + ...defaults, + ...overrides, + }; + } + + function createPolicies(): PolicyData[] { + const policies = [ + generator.generatePolicyPackagePolicy(), + generator.generatePolicyPackagePolicy(), + ]; + policies.map((p, i) => { + p.id = `id-${i}`; + p.name = `some-policy-${Math.random().toString(36).split('.').pop()}`; + return p; + }); + return policies; + } + + beforeEach(async () => { + (useCurrentUser as jest.Mock).mockReturnValue({ username: 'test-username' }); + (useKibana as jest.Mock).mockReturnValue({ + services: { + http: {}, + data: {}, + unifiedSearch: {}, + notifications: {}, + }, + }); + (licenseService.isPlatinumPlus as jest.Mock).mockReturnValue(true); + mockedContext = createAppRootMockRenderer(); + latestUpdatedItem = createItem(); + (useFetchIndex as jest.Mock).mockImplementation(() => [ + false, + { + indexPatterns: stubIndexPattern, + }, + ]); + + formProps = { + item: latestUpdatedItem, + mode: 'create', + disabled: false, + error: undefined, + policiesIsLoading: false, + onChange: jest.fn((updates) => { + latestUpdatedItem = updates.item; + }), + policies: [], + }; + }); + + afterEach(() => { + cleanup(); + }); + + describe('Details and Conditions', () => { + it('should render correctly without data', () => { + formProps.policies = createPolicies(); + formProps.policiesIsLoading = true; + formProps.item.tags = [formProps.policies.map((p) => `policy:${p.id}`)[0]]; + formProps.item.entries = []; + render(); + expect(renderResult.getByTestId('loading-spinner')).not.toBeNull(); + }); + + it('should render correctly with data', async () => { + formProps.policies = createPolicies(); + render(); + expect(renderResult.queryByTestId('loading-spinner')).toBeNull(); + expect(renderResult.getByTestId('exceptionsBuilderWrapper')).not.toBeNull(); + }); + + it('should display sections', async () => { + render(); + expect(renderResult.queryByText('Details')).not.toBeNull(); + expect(renderResult.queryByText('Conditions')).not.toBeNull(); + expect(renderResult.queryByText('Comments')).not.toBeNull(); + }); + + it('should display name error only when on blur and empty name', async () => { + render(); + expect(renderResult.queryByText(NAME_ERROR)).toBeNull(); + const nameInput = renderResult.getByTestId(`${formPrefix}-name-input`); + act(() => { + fireEvent.blur(nameInput); + }); + rerenderWithLatestProps(); + expect(renderResult.queryByText(NAME_ERROR)).not.toBeNull(); + }); + + it('should change name', async () => { + render(); + const nameInput = renderResult.getByTestId(`${formPrefix}-name-input`); + + act(() => { + fireEvent.change(nameInput, { + target: { + value: 'Exception name', + }, + }); + fireEvent.blur(nameInput); + }); + rerenderWithLatestProps(); + + expect(formProps.item?.name).toBe('Exception name'); + expect(renderResult.queryByText(NAME_ERROR)).toBeNull(); + }); + + it('should change name with a white space still shows an error', async () => { + render(); + const nameInput = renderResult.getByTestId(`${formPrefix}-name-input`); + + act(() => { + fireEvent.change(nameInput, { + target: { + value: ' ', + }, + }); + fireEvent.blur(nameInput); + }); + rerenderWithLatestProps(); + + expect(formProps.item.name).toBe(''); + expect(renderResult.queryByText(NAME_ERROR)).not.toBeNull(); + }); + + it('should change description', async () => { + render(); + const nameInput = renderResult.getByTestId(`${formPrefix}-description-input`); + + act(() => { + fireEvent.change(nameInput, { + target: { + value: 'Exception description', + }, + }); + fireEvent.blur(nameInput); + }); + rerenderWithLatestProps(); + + expect(formProps.item.description).toBe('Exception description'); + }); + + it('should change comments', async () => { + render(); + const commentInput = renderResult.getByLabelText('Comment Input'); + + act(() => { + fireEvent.change(commentInput, { + target: { + value: 'Exception comment', + }, + }); + fireEvent.blur(commentInput); + }); + rerenderWithLatestProps(); + + expect(formProps.item.comments).toEqual([{ comment: 'Exception comment' }]); + }); + }); + + describe('Policy section', () => { + beforeEach(() => { + formProps.policies = createPolicies(); + }); + + afterEach(() => { + cleanup(); + }); + + it('should display loader when policies are still loading', () => { + formProps.policiesIsLoading = true; + formProps.item.tags = [formProps.policies.map((p) => `policy:${p.id}`)[0]]; + render(); + expect(renderResult.getByTestId('loading-spinner')).not.toBeNull(); + }); + + it('should display the policy list when "per policy" is selected', async () => { + render(); + userEvent.click(renderResult.getByTestId('perPolicy')); + rerenderWithLatestProps(); + // policy selector should show up + expect(renderResult.getByTestId('effectedPolicies-select-policiesSelectable')).toBeTruthy(); + }); + + it('should call onChange when a policy is selected from the policy selection', async () => { + formProps.item.tags = [formProps.policies.map((p) => `policy:${p.id}`)[0]]; + render(); + const policyId = formProps.policies[0].id; + userEvent.click(renderResult.getByTestId('effectedPolicies-select-perPolicy')); + userEvent.click(renderResult.getByTestId(`policy-${policyId}`)); + formProps.item.tags = formProps.onChange.mock.calls[0][0].item.tags; + rerender(); + const expected = createOnChangeArgs({ + item: { + ...formProps.item, + tags: [`policy:${policyId}`], + }, + }); + expect(formProps.onChange).toHaveBeenCalledWith(expected); + }); + + it('should have global policy by default', async () => { + render(); + expect(renderResult.getByTestId('globalPolicy')).toBeChecked(); + expect(renderResult.getByTestId('perPolicy')).not.toBeChecked(); + }); + + it('should retain the previous policy selection when switching from per-policy to global', async () => { + formProps.item.tags = [formProps.policies.map((p) => `policy:${p.id}`)[0]]; + render(); + const policyId = formProps.policies[0].id; + + // move to per-policy and select the first + userEvent.click(renderResult.getByTestId('perPolicy')); + userEvent.click(renderResult.getByTestId(`policy-${policyId}`)); + formProps.item.tags = formProps.onChange.mock.calls[0][0].item.tags; + rerender(); + expect(renderResult.queryByTestId('effectedPolicies-select-policiesSelectable')).toBeTruthy(); + expect(formProps.item.tags).toEqual([`policy:${policyId}`]); + + // move back to global + userEvent.click(renderResult.getByTestId('globalPolicy')); + formProps.item.tags = ['policy:all']; + rerenderWithLatestProps(); + expect(formProps.item.tags).toEqual(['policy:all']); + expect(renderResult.queryByTestId('effectedPolicies-select-policiesSelectable')).toBeFalsy(); + + // move back to per-policy + userEvent.click(renderResult.getByTestId('perPolicy')); + formProps.item.tags = [`policy:${policyId}`]; + rerender(); + // on change called with the previous policy + expect(formProps.item.tags).toEqual([`policy:${policyId}`]); + // the previous selected policy should be selected + // expect(renderResult.getByTestId(`policy-${policyId}`)).toHaveAttribute( + // 'data-test-selected', + // 'true' + // ); + }); + }); + + describe('Policy section with downgraded license', () => { + beforeEach(() => { + const policies = createPolicies(); + formProps.policies = policies; + formProps.item.tags = [policies.map((p) => `policy:${p.id}`)[0]]; + formProps.mode = 'edit'; + // downgrade license + (licenseService.isPlatinumPlus as jest.Mock).mockReturnValue(false); + }); + + it('should hide assignment section when no license', () => { + render(); + formProps.item.tags = ['policy:all']; + rerender(); + expect(renderResult.queryByTestId('effectedPolicies-select')).toBeNull(); + }); + + it('should hide assignment section when create mode and no license even with by policy', () => { + render(); + formProps.mode = 'create'; + rerender(); + expect(renderResult.queryByTestId('effectedPolicies-select')).toBeNull(); + }); + + it('should show disabled assignment section when edit mode and no license with by policy', async () => { + render(); + formProps.item.tags = ['policy:id-0']; + rerender(); + + expect(renderResult.queryByTestId('perPolicy')).not.toBeNull(); + expect(renderResult.getByTestId('policy-id-0').getAttribute('aria-disabled')).toBe('true'); + }); + + it("allows the user to set the event filter entry to 'Global' in the edit option", () => { + render(); + const globalButtonInput = renderResult.getByTestId('globalPolicy') as HTMLButtonElement; + userEvent.click(globalButtonInput); + formProps.item.tags = ['policy:all']; + rerender(); + const expected = createOnChangeArgs({ + item: { + ...formProps.item, + tags: ['policy:all'], + }, + }); + expect(formProps.onChange).toHaveBeenCalledWith(expected); + + const policyItem = formProps.onChange.mock.calls[0][0].item.tags + ? formProps.onChange.mock.calls[0][0].item.tags[0] + : ''; + + expect(policyItem).toBe('policy:all'); + }); + }); + + describe('Warnings', () => { + beforeEach(() => { + render(); + }); + + it('should not show warning text when unique fields are added', async () => { + formProps.item.entries = [ + { + field: 'event.category', + operator: 'included', + type: 'match', + value: 'some value', + }, + { + field: 'file.name', + operator: 'excluded', + type: 'match', + value: 'some other value', + }, + ]; + rerender(); + expect(renderResult.queryByTestId('duplicate-fields-warning-message')).toBeNull(); + }); + + it('should not show warning text when field values are not added', async () => { + formProps.item.entries = [ + { + field: 'event.category', + operator: 'included', + type: 'match', + value: '', + }, + { + field: 'event.category', + operator: 'excluded', + type: 'match', + value: '', + }, + ]; + rerender(); + expect(renderResult.queryByTestId('duplicate-fields-warning-message')).toBeNull(); + }); + + it('should show warning text when duplicate fields are added with values', async () => { + formProps.item.entries = [ + { + field: 'event.category', + operator: 'included', + type: 'match', + value: 'some value', + }, + { + field: 'event.category', + operator: 'excluded', + type: 'match', + value: 'some other value', + }, + ]; + rerender(); + expect(renderResult.findByTestId('duplicate-fields-warning-message')).not.toBeNull(); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form.tsx new file mode 100644 index 0000000000000..4e021d12dac36 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form.tsx @@ -0,0 +1,558 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useMemo, useCallback, useState, useEffect } from 'react'; + +import { isEqual } from 'lodash'; +import { + EuiFieldText, + EuiSpacer, + EuiForm, + EuiFormRow, + EuiSuperSelect, + EuiSuperSelectOption, + EuiText, + EuiHorizontalRule, + EuiTextArea, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; + +import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import { EVENT_FILTERS_OPERATORS } from '@kbn/securitysolution-list-utils'; +import { OperatingSystem } from '@kbn/securitysolution-utils'; + +import { getExceptionBuilderComponentLazy } from '@kbn/lists-plugin/public'; +import type { OnChangeProps } from '@kbn/lists-plugin/public'; +import { useTestIdGenerator } from '../../../../components/hooks/use_test_id_generator'; +import { PolicyData } from '../../../../../../common/endpoint/types'; +import { AddExceptionComments } from '../../../../../common/components/exceptions/add_exception_comments'; +import { useFetchIndex } from '../../../../../common/containers/source'; +import { Loader } from '../../../../../common/components/loader'; +import { useLicense } from '../../../../../common/hooks/use_license'; +import { useKibana } from '../../../../../common/lib/kibana'; +import { ArtifactFormComponentProps } from '../../../../components/artifact_list_page'; +import { filterIndexPatterns } from '../../../../../common/components/exceptions/helpers'; +import { + isArtifactGlobal, + getPolicyIdsFromArtifact, + GLOBAL_ARTIFACT_TAG, + BY_POLICY_ARTIFACT_TAG_PREFIX, +} from '../../../../../../common/endpoint/service/artifacts'; + +import { + ABOUT_EVENT_FILTERS, + NAME_LABEL, + NAME_ERROR, + DESCRIPTION_LABEL, + OS_LABEL, + RULE_NAME, +} from '../event_filters_list'; +import { OS_TITLES } from '../../../../common/translations'; +import { ENDPOINT_EVENT_FILTERS_LIST_ID, EVENT_FILTER_LIST_TYPE } from '../../constants'; + +import { + EffectedPolicySelect, + EffectedPolicySelection, +} from '../../../../components/effected_policy_select'; +import { isGlobalPolicyEffected } from '../../../../components/effected_policy_select/utils'; + +const OPERATING_SYSTEMS: readonly OperatingSystem[] = [ + OperatingSystem.MAC, + OperatingSystem.WINDOWS, + OperatingSystem.LINUX, +]; + +// OS options +const osOptions: Array> = OPERATING_SYSTEMS.map((os) => ({ + value: os, + inputDisplay: OS_TITLES[os], +})); + +const getAddedFieldsCounts = (formFields: string[]): { [k: string]: number } => + formFields.reduce<{ [k: string]: number }>((allFields, field) => { + if (field in allFields) { + allFields[field]++; + } else { + allFields[field] = 1; + } + return allFields; + }, {}); + +const computeHasDuplicateFields = (formFieldsList: Record): boolean => + Object.values(formFieldsList).some((e) => e > 1); + +const defaultConditionEntry = (): ExceptionListItemSchema['entries'] => [ + { + field: '', + operator: 'included', + type: 'match', + value: '', + }, +]; + +const cleanupEntries = ( + item: ArtifactFormComponentProps['item'] +): ArtifactFormComponentProps['item']['entries'] => { + return item.entries.map( + (e: ArtifactFormComponentProps['item']['entries'][number] & { id?: string }) => { + delete e.id; + return e; + } + ); +}; + +type EventFilterItemEntries = Array<{ + field: string; + value: string; + operator: 'included' | 'excluded'; + type: Exclude; +}>; + +export const EventFiltersForm: React.FC = + memo(({ allowSelectOs = true, item: exception, policies, policiesIsLoading, onChange, mode }) => { + const getTestId = useTestIdGenerator('eventFilters-form'); + const { http, unifiedSearch } = useKibana().services; + + const [hasFormChanged, setHasFormChanged] = useState(false); + const [hasNameError, toggleHasNameError] = useState(!exception.name); + const [newComment, setNewComment] = useState(''); + const [hasBeenInputNameVisited, setHasBeenInputNameVisited] = useState(false); + const [selectedPolicies, setSelectedPolicies] = useState([]); + const isPlatinumPlus = useLicense().isPlatinumPlus(); + const isGlobal = useMemo( + () => isArtifactGlobal(exception as ExceptionListItemSchema), + [exception] + ); + const [wasByPolicy, setWasByPolicy] = useState(!isGlobalPolicyEffected(exception?.tags)); + + const [hasDuplicateFields, setHasDuplicateFields] = useState(false); + // This value has to be memoized to avoid infinite useEffect loop on useFetchIndex + const indexNames = useMemo(() => ['logs-endpoint.events.*'], []); + const [isIndexPatternLoading, { indexPatterns }] = useFetchIndex(indexNames); + const [areConditionsValid, setAreConditionsValid] = useState( + !!exception.entries.length || false + ); + // compute this for initial render only + const existingComments = useMemo( + () => (exception as ExceptionListItemSchema)?.comments, + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + const showAssignmentSection = useMemo(() => { + return ( + isPlatinumPlus || + (mode === 'edit' && (!isGlobal || (wasByPolicy && isGlobal && hasFormChanged))) + ); + }, [mode, isGlobal, hasFormChanged, isPlatinumPlus, wasByPolicy]); + + const isFormValid = useMemo(() => { + // verify that it has legit entries + // and not just default entry without values + return ( + !hasNameError && + !!exception.entries.length && + (exception.entries as EventFilterItemEntries).some((e) => e.value !== '' || e.value.length) + ); + }, [hasNameError, exception.entries]); + + const processChanged = useCallback( + (updatedItem?: Partial) => { + const item = updatedItem + ? { + ...exception, + ...updatedItem, + } + : exception; + cleanupEntries(item); + onChange({ + item, + isValid: isFormValid && areConditionsValid, + }); + }, + [areConditionsValid, exception, isFormValid, onChange] + ); + + // set initial state of `wasByPolicy` that checks + // if the initial state of the exception was by policy or not + useEffect(() => { + if (!hasFormChanged && exception.tags) { + setWasByPolicy(!isGlobalPolicyEffected(exception.tags)); + } + }, [exception.tags, hasFormChanged]); + + // select policies if editing + useEffect(() => { + if (hasFormChanged) return; + const policyIds = exception.tags ? getPolicyIdsFromArtifact({ tags: exception.tags }) : []; + + if (!policyIds.length) return; + const policiesData = policies.filter((policy) => policyIds.includes(policy.id)); + setSelectedPolicies(policiesData); + }, [hasFormChanged, exception, policies]); + + const eventFilterItem = useMemo(() => { + const ef: ArtifactFormComponentProps['item'] = exception; + ef.entries = exception.entries.length + ? (exception.entries as ExceptionListItemSchema['entries']) + : defaultConditionEntry(); + + // TODO: `id` gets added to the exception.entries item + // Is there a simpler way to this? + cleanupEntries(ef); + + setAreConditionsValid(!!exception.entries.length); + return ef; + }, [exception]); + + // name and handler + const handleOnChangeName = useCallback( + (event: React.ChangeEvent) => { + if (!exception) return; + const name = event.target.value.trim(); + toggleHasNameError(!name); + processChanged({ name }); + if (!hasFormChanged) setHasFormChanged(true); + }, + [exception, hasFormChanged, processChanged] + ); + + const nameInputMemo = useMemo( + () => ( + + !hasBeenInputNameVisited && setHasBeenInputNameVisited(true)} + /> + + ), + [getTestId, hasNameError, handleOnChangeName, hasBeenInputNameVisited, exception?.name] + ); + + // description and handler + const handleOnDescriptionChange = useCallback( + (event: React.ChangeEvent) => { + if (!exception) return; + if (!hasFormChanged) setHasFormChanged(true); + processChanged({ description: event.target.value.toString().trim() }); + }, + [exception, hasFormChanged, processChanged] + ); + const descriptionInputMemo = useMemo( + () => ( + + + + ), + [exception?.description, getTestId, handleOnDescriptionChange] + ); + + // selected OS and handler + const selectedOs = useMemo((): OperatingSystem => { + if (!exception?.os_types?.length) { + return OperatingSystem.WINDOWS; + } + return exception.os_types[0] as OperatingSystem; + }, [exception?.os_types]); + + const handleOnOsChange = useCallback( + (os: OperatingSystem) => { + if (!exception) return; + processChanged({ + os_types: [os], + entries: exception.entries, + }); + if (!hasFormChanged) setHasFormChanged(true); + }, + [exception, hasFormChanged, processChanged] + ); + + const osInputMemo = useMemo( + () => ( + + + + ), + [handleOnOsChange, selectedOs] + ); + + // comments and handler + const handleOnChangeComment = useCallback( + (value: string) => { + if (!exception) return; + setNewComment(value); + processChanged({ comments: [{ comment: value }] }); + if (!hasFormChanged) setHasFormChanged(true); + }, + [exception, hasFormChanged, processChanged] + ); + const commentsInputMemo = useMemo( + () => ( + + ), + [existingComments, handleOnChangeComment, newComment] + ); + + // comments + const commentsSection = useMemo( + () => ( + <> + +

+ +

+
+ + +

+ +

+
+ + {commentsInputMemo} + + ), + [commentsInputMemo] + ); + + // details + const detailsSection = useMemo( + () => ( + <> + +

+ +

+
+ + +

{ABOUT_EVENT_FILTERS}

+
+ + {nameInputMemo} + {descriptionInputMemo} + + ), + [nameInputMemo, descriptionInputMemo] + ); + + // conditions and handler + const handleOnBuilderChange = useCallback( + (arg: OnChangeProps) => { + const hasDuplicates = + (!hasFormChanged && arg.exceptionItems[0] === undefined) || + isEqual(arg.exceptionItems[0]?.entries, exception?.entries); + if (hasDuplicates) { + const addedFields = arg.exceptionItems[0]?.entries.map((e) => e.field) || ['']; + setHasDuplicateFields(computeHasDuplicateFields(getAddedFieldsCounts(addedFields))); + if (!hasFormChanged) setHasFormChanged(true); + return; + } + const updatedItem: Partial = + arg.exceptionItems[0] !== undefined + ? { + ...arg.exceptionItems[0], + name: exception?.name ?? '', + description: exception?.description ?? '', + comments: exception?.comments ?? [], + os_types: exception?.os_types ?? [OperatingSystem.WINDOWS], + tags: exception?.tags ?? [], + } + : exception; + const hasValidConditions = + arg.exceptionItems[0] !== undefined + ? !(arg.errorExists && !arg.exceptionItems[0]?.entries?.length) + : false; + + setAreConditionsValid(hasValidConditions); + processChanged(updatedItem); + if (!hasFormChanged) setHasFormChanged(true); + }, + [exception, hasFormChanged, processChanged] + ); + const exceptionBuilderComponentMemo = useMemo( + () => + getExceptionBuilderComponentLazy({ + allowLargeValueLists: false, + httpService: http, + autocompleteService: unifiedSearch.autocomplete, + exceptionListItems: [eventFilterItem as ExceptionListItemSchema], + listType: EVENT_FILTER_LIST_TYPE, + listId: ENDPOINT_EVENT_FILTERS_LIST_ID, + listNamespaceType: 'agnostic', + ruleName: RULE_NAME, + indexPatterns, + isOrDisabled: true, + isOrHidden: true, + isAndDisabled: false, + isNestedDisabled: false, + dataTestSubj: 'alert-exception-builder', + idAria: 'alert-exception-builder', + onChange: handleOnBuilderChange, + listTypeSpecificIndexPatternFilter: filterIndexPatterns, + operatorsList: EVENT_FILTERS_OPERATORS, + osTypes: exception.os_types, + }), + [unifiedSearch, handleOnBuilderChange, http, indexPatterns, exception, eventFilterItem] + ); + + // conditions + const criteriaSection = useMemo( + () => ( + <> + +

+ +

+
+ + +

+ {allowSelectOs ? ( + + ) : ( + + )} +

+
+ + {allowSelectOs ? ( + <> + {osInputMemo} + + + ) : null} + {exceptionBuilderComponentMemo} + + ), + [allowSelectOs, exceptionBuilderComponentMemo, osInputMemo] + ); + + // policy and handler + const handleOnPolicyChange = useCallback( + (change: EffectedPolicySelection) => { + const tags = change.isGlobal + ? [GLOBAL_ARTIFACT_TAG] + : change.selected.map((policy) => `${BY_POLICY_ARTIFACT_TAG_PREFIX}${policy.id}`); + + // Preserve old selected policies when switching to global + if (!change.isGlobal) { + setSelectedPolicies(change.selected); + } + processChanged({ tags }); + if (!hasFormChanged) setHasFormChanged(true); + }, + [processChanged, hasFormChanged, setSelectedPolicies] + ); + + const policiesSection = useMemo( + () => ( + + ), + [ + policies, + selectedPolicies, + isGlobal, + isPlatinumPlus, + handleOnPolicyChange, + policiesIsLoading, + ] + ); + + useEffect(() => { + processChanged(); + }, [processChanged]); + + if (isIndexPatternLoading || !exception) { + return ; + } + + return ( + + {detailsSection} + + {criteriaSection} + {hasDuplicateFields && ( + <> + + + + + + )} + {showAssignmentSection && ( + <> + + {policiesSection} + + )} + + {commentsSection} + + ); + }); + +EventFiltersForm.displayName = 'EventFiltersForm'; diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/index.test.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/index.test.tsx deleted file mode 100644 index f0589099a8077..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/index.test.tsx +++ /dev/null @@ -1,338 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ -import React from 'react'; -import { EventFiltersForm } from '.'; -import { RenderResult, act } from '@testing-library/react'; -import { fireEvent, waitFor } from '@testing-library/dom'; -import { stubIndexPattern } from '@kbn/data-plugin/common/stubs'; -import { getInitialExceptionFromEvent } from '../../../store/utils'; -import { useFetchIndex } from '../../../../../../common/containers/source'; -import { ecsEventMock } from '../../../test_utils'; -import { NAME_ERROR, NAME_PLACEHOLDER } from './translations'; -import { useCurrentUser, useKibana } from '../../../../../../common/lib/kibana'; -import { licenseService } from '../../../../../../common/hooks/use_license'; -import { - AppContextTestRender, - createAppRootMockRenderer, -} from '../../../../../../common/mock/endpoint'; -import { EventFiltersListPageState } from '../../../types'; -import { sendGetEndpointSpecificPackagePoliciesMock } from '../../../../../services/policies/test_mock_utils'; -import { GetPolicyListResponse } from '../../../../policy/types'; -import userEvent from '@testing-library/user-event'; -import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; - -jest.mock('../../../../../../common/lib/kibana'); -jest.mock('../../../../../../common/containers/source'); -jest.mock('../../../../../../common/hooks/use_license', () => { - const licenseServiceInstance = { - isPlatinumPlus: jest.fn(), - }; - return { - licenseService: licenseServiceInstance, - useLicense: () => { - return licenseServiceInstance; - }, - }; -}); - -describe('Event filter form', () => { - let component: RenderResult; - let mockedContext: AppContextTestRender; - let render: ( - props?: Partial> - ) => ReturnType; - let renderWithData: ( - customEventFilterProps?: Partial - ) => Promise>; - let getState: () => EventFiltersListPageState; - let policiesRequest: GetPolicyListResponse; - - beforeEach(async () => { - (licenseService.isPlatinumPlus as jest.Mock).mockReturnValue(true); - mockedContext = createAppRootMockRenderer(); - policiesRequest = await sendGetEndpointSpecificPackagePoliciesMock(); - getState = () => mockedContext.store.getState().management.eventFilters; - render = (props) => - mockedContext.render( - - ); - renderWithData = async (customEventFilterProps = {}) => { - const renderResult = render(); - const entry = getInitialExceptionFromEvent(ecsEventMock()); - - act(() => { - mockedContext.store.dispatch({ - type: 'eventFiltersInitForm', - payload: { entry: { ...entry, ...customEventFilterProps } }, - }); - }); - await waitFor(() => { - expect(renderResult.getByTestId('exceptionsBuilderWrapper')).toBeInTheDocument(); - }); - return renderResult; - }; - - (useFetchIndex as jest.Mock).mockImplementation(() => [ - false, - { - indexPatterns: stubIndexPattern, - }, - ]); - (useCurrentUser as jest.Mock).mockReturnValue({ username: 'test-username' }); - (useKibana as jest.Mock).mockReturnValue({ - services: { - http: {}, - data: {}, - unifiedSearch: {}, - notifications: {}, - }, - }); - }); - - it('should renders correctly without data', () => { - component = render(); - expect(component.getByTestId('loading-spinner')).not.toBeNull(); - }); - - it('should renders correctly with data', async () => { - component = await renderWithData(); - - expect(component.getByTestId('exceptionsBuilderWrapper')).not.toBeNull(); - }); - - it('should displays loader when policies are still loading', () => { - component = render({ arePoliciesLoading: true }); - - expect(component.queryByTestId('exceptionsBuilderWrapper')).toBeNull(); - expect(component.getByTestId('loading-spinner')).not.toBeNull(); - }); - - it('should display sections', async () => { - component = await renderWithData(); - - expect(component.queryByText('Details')).not.toBeNull(); - expect(component.queryByText('Conditions')).not.toBeNull(); - expect(component.queryByText('Comments')).not.toBeNull(); - }); - - it('should display name error only when on blur and empty name', async () => { - component = await renderWithData(); - expect(component.queryByText(NAME_ERROR)).toBeNull(); - const nameInput = component.getByPlaceholderText(NAME_PLACEHOLDER); - act(() => { - fireEvent.blur(nameInput); - }); - expect(component.queryByText(NAME_ERROR)).not.toBeNull(); - }); - - it('should change name', async () => { - component = await renderWithData(); - - const nameInput = component.getByPlaceholderText(NAME_PLACEHOLDER); - - act(() => { - fireEvent.change(nameInput, { - target: { - value: 'Exception name', - }, - }); - }); - - expect(getState().form.entry?.name).toBe('Exception name'); - expect(getState().form.hasNameError).toBeFalsy(); - }); - - it('should change name with a white space still shows an error', async () => { - component = await renderWithData(); - - const nameInput = component.getByPlaceholderText(NAME_PLACEHOLDER); - - act(() => { - fireEvent.change(nameInput, { - target: { - value: ' ', - }, - }); - }); - - expect(getState().form.entry?.name).toBe(''); - expect(getState().form.hasNameError).toBeTruthy(); - }); - - it('should change description', async () => { - component = await renderWithData(); - - const nameInput = component.getByTestId('eventFilters-form-description-input'); - - act(() => { - fireEvent.change(nameInput, { - target: { - value: 'Exception description', - }, - }); - }); - - expect(getState().form.entry?.description).toBe('Exception description'); - }); - - it('should change comments', async () => { - component = await renderWithData(); - - const commentInput = component.getByPlaceholderText('Add a new comment...'); - - act(() => { - fireEvent.change(commentInput, { - target: { - value: 'Exception comment', - }, - }); - }); - - expect(getState().form.newComment).toBe('Exception comment'); - }); - - it('should display the policy list when "per policy" is selected', async () => { - component = await renderWithData(); - userEvent.click(component.getByTestId('perPolicy')); - - // policy selector should show up - expect(component.getByTestId('effectedPolicies-select-policiesSelectable')).toBeTruthy(); - }); - - it('should call onChange when a policy is selected from the policy selection', async () => { - component = await renderWithData(); - - const policyId = policiesRequest.items[0].id; - userEvent.click(component.getByTestId('perPolicy')); - userEvent.click(component.getByTestId(`policy-${policyId}`)); - expect(getState().form.entry?.tags).toEqual([`policy:${policyId}`]); - }); - - it('should have global policy by default', async () => { - component = await renderWithData(); - - expect(component.getByTestId('globalPolicy')).toBeChecked(); - expect(component.getByTestId('perPolicy')).not.toBeChecked(); - }); - - it('should retain the previous policy selection when switching from per-policy to global', async () => { - const policyId = policiesRequest.items[0].id; - - component = await renderWithData(); - - // move to per-policy and select the first - userEvent.click(component.getByTestId('perPolicy')); - userEvent.click(component.getByTestId(`policy-${policyId}`)); - expect(component.queryByTestId('effectedPolicies-select-policiesSelectable')).toBeTruthy(); - expect(getState().form.entry?.tags).toEqual([`policy:${policyId}`]); - - // move back to global - userEvent.click(component.getByTestId('globalPolicy')); - expect(component.queryByTestId('effectedPolicies-select-policiesSelectable')).toBeFalsy(); - expect(getState().form.entry?.tags).toEqual([`policy:all`]); - - // move back to per-policy - userEvent.click(component.getByTestId('perPolicy')); - // the previous selected policy should be selected - expect(component.getByTestId(`policy-${policyId}`)).toHaveAttribute( - 'data-test-selected', - 'true' - ); - // on change called with the previous policy - expect(getState().form.entry?.tags).toEqual([`policy:${policyId}`]); - }); - - it('should hide assignment section when no license', async () => { - (licenseService.isPlatinumPlus as jest.Mock).mockReturnValue(false); - component = await renderWithData(); - expect(component.queryByTestId('perPolicy')).toBeNull(); - }); - - it('should hide assignment section when create mode and no license even with by policy', async () => { - const policyId = policiesRequest.items[0].id; - (licenseService.isPlatinumPlus as jest.Mock).mockReturnValue(false); - component = await renderWithData({ tags: [`policy:${policyId}`] }); - expect(component.queryByTestId('perPolicy')).toBeNull(); - }); - - it('should show disabled assignment section when edit mode and no license with by policy', async () => { - const policyId = policiesRequest.items[0].id; - (licenseService.isPlatinumPlus as jest.Mock).mockReturnValue(false); - component = await renderWithData({ tags: [`policy:${policyId}`], item_id: '1' }); - expect(component.queryByTestId('perPolicy')).not.toBeNull(); - expect(component.getByTestId(`policy-${policyId}`).getAttribute('aria-disabled')).toBe('true'); - }); - - it('should change from by policy to global when edit mode and no license with by policy', async () => { - const policyId = policiesRequest.items[0].id; - (licenseService.isPlatinumPlus as jest.Mock).mockReturnValue(false); - component = await renderWithData({ tags: [`policy:${policyId}`], item_id: '1' }); - userEvent.click(component.getByTestId('globalPolicy')); - expect(component.queryByTestId('effectedPolicies-select-policiesSelectable')).toBeFalsy(); - expect(getState().form.entry?.tags).toEqual([`policy:all`]); - }); - - it('should not show warning text when unique fields are added', async () => { - component = await renderWithData({ - entries: [ - { - field: 'event.category', - operator: 'included', - type: 'match', - value: 'some value', - }, - { - field: 'file.name', - operator: 'excluded', - type: 'match', - value: 'some other value', - }, - ], - }); - expect(component.queryByTestId('duplicate-fields-warning-message')).toBeNull(); - }); - - it('should not show warning text when field values are not added', async () => { - component = await renderWithData({ - entries: [ - { - field: 'event.category', - operator: 'included', - type: 'match', - value: '', - }, - { - field: 'event.category', - operator: 'excluded', - type: 'match', - value: '', - }, - ], - }); - expect(component.queryByTestId('duplicate-fields-warning-message')).toBeNull(); - }); - - it('should show warning text when duplicate fields are added with values', async () => { - component = await renderWithData({ - entries: [ - { - field: 'event.category', - operator: 'included', - type: 'match', - value: 'some value', - }, - { - field: 'event.category', - operator: 'excluded', - type: 'match', - value: 'some other value', - }, - ], - }); - expect(component.queryByTestId('duplicate-fields-warning-message')).not.toBeNull(); - }); -}); diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/index.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/index.tsx deleted file mode 100644 index 11d1af0a5a2e9..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/index.tsx +++ /dev/null @@ -1,487 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { memo, useMemo, useCallback, useState, useEffect } from 'react'; -import { useDispatch } from 'react-redux'; -import { Dispatch } from 'redux'; - -import { isEqual } from 'lodash'; -import { - EuiFieldText, - EuiSpacer, - EuiForm, - EuiFormRow, - EuiSuperSelect, - EuiSuperSelectOption, - EuiText, - EuiHorizontalRule, - EuiTextArea, -} from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n-react'; - -import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; -import { EVENT_FILTERS_OPERATORS } from '@kbn/securitysolution-list-utils'; -import { OperatingSystem } from '@kbn/securitysolution-utils'; - -import { getExceptionBuilderComponentLazy } from '@kbn/lists-plugin/public'; -import type { OnChangeProps } from '@kbn/lists-plugin/public'; -import { PolicyData } from '../../../../../../../common/endpoint/types'; -import { AddExceptionComments } from '../../../../../../common/components/exceptions/add_exception_comments'; -import { filterIndexPatterns } from '../../../../../../common/components/exceptions/helpers'; -import { Loader } from '../../../../../../common/components/loader'; -import { useKibana } from '../../../../../../common/lib/kibana'; -import { useFetchIndex } from '../../../../../../common/containers/source'; -import { AppAction } from '../../../../../../common/store/actions'; -import { useEventFiltersSelector } from '../../hooks'; -import { getFormEntryStateMutable, getHasNameError, getNewComment } from '../../../store/selector'; -import { - NAME_LABEL, - NAME_ERROR, - DESCRIPTION_LABEL, - DESCRIPTION_PLACEHOLDER, - NAME_PLACEHOLDER, - OS_LABEL, - RULE_NAME, -} from './translations'; -import { OS_TITLES } from '../../../../../common/translations'; -import { ENDPOINT_EVENT_FILTERS_LIST_ID, EVENT_FILTER_LIST_TYPE } from '../../../constants'; -import { ABOUT_EVENT_FILTERS } from '../../translations'; -import { - EffectedPolicySelect, - EffectedPolicySelection, - EffectedPolicySelectProps, -} from '../../../../../components/effected_policy_select'; -import { - getArtifactTagsByEffectedPolicySelection, - getArtifactTagsWithoutPolicies, - getEffectedPolicySelectionByTags, - isGlobalPolicyEffected, -} from '../../../../../components/effected_policy_select/utils'; -import { useLicense } from '../../../../../../common/hooks/use_license'; - -const OPERATING_SYSTEMS: readonly OperatingSystem[] = [ - OperatingSystem.MAC, - OperatingSystem.WINDOWS, - OperatingSystem.LINUX, -]; - -const getAddedFieldsCounts = (formFields: string[]): { [k: string]: number } => - formFields.reduce<{ [k: string]: number }>((allFields, field) => { - if (field in allFields) { - allFields[field]++; - } else { - allFields[field] = 1; - } - return allFields; - }, {}); - -const computeHasDuplicateFields = (formFieldsList: Record): boolean => - Object.values(formFieldsList).some((e) => e > 1); -interface EventFiltersFormProps { - allowSelectOs?: boolean; - policies: PolicyData[]; - arePoliciesLoading: boolean; -} -export const EventFiltersForm: React.FC = memo( - ({ allowSelectOs = false, policies, arePoliciesLoading }) => { - const { http, unifiedSearch } = useKibana().services; - - const dispatch = useDispatch>(); - const exception = useEventFiltersSelector(getFormEntryStateMutable); - const hasNameError = useEventFiltersSelector(getHasNameError); - const newComment = useEventFiltersSelector(getNewComment); - const [hasBeenInputNameVisited, setHasBeenInputNameVisited] = useState(false); - const isPlatinumPlus = useLicense().isPlatinumPlus(); - const [hasFormChanged, setHasFormChanged] = useState(false); - const [hasDuplicateFields, setHasDuplicateFields] = useState(false); - - // This value has to be memoized to avoid infinite useEffect loop on useFetchIndex - const indexNames = useMemo(() => ['logs-endpoint.events.*'], []); - const [isIndexPatternLoading, { indexPatterns }] = useFetchIndex(indexNames); - - const [selection, setSelection] = useState({ - selected: [], - isGlobal: isGlobalPolicyEffected(exception?.tags), - }); - - const isEditMode = useMemo(() => !!exception?.item_id, [exception?.item_id]); - const [wasByPolicy, setWasByPolicy] = useState(!isGlobalPolicyEffected(exception?.tags)); - - const showAssignmentSection = useMemo(() => { - return ( - isPlatinumPlus || - (isEditMode && - (!selection.isGlobal || (wasByPolicy && selection.isGlobal && hasFormChanged))) - ); - }, [isEditMode, selection.isGlobal, hasFormChanged, isPlatinumPlus, wasByPolicy]); - - // set current policies if not previously selected - useEffect(() => { - if (selection.selected.length === 0 && exception?.tags) { - setSelection(getEffectedPolicySelectionByTags(exception.tags, policies)); - } - }, [exception?.tags, policies, selection.selected.length]); - - // set initial state of `wasByPolicy` that checks if the initial state of the exception was by policy or not - useEffect(() => { - if (!hasFormChanged && exception?.tags) { - setWasByPolicy(!isGlobalPolicyEffected(exception?.tags)); - } - }, [exception?.tags, hasFormChanged]); - - const osOptions: Array> = useMemo( - () => OPERATING_SYSTEMS.map((os) => ({ value: os, inputDisplay: OS_TITLES[os] })), - [] - ); - - const handleOnBuilderChange = useCallback( - (arg: OnChangeProps) => { - if ( - (!hasFormChanged && arg.exceptionItems[0] === undefined) || - isEqual(arg.exceptionItems[0]?.entries, exception?.entries) - ) { - const addedFields = arg.exceptionItems[0]?.entries.map((e) => e.field) || ['']; - setHasDuplicateFields(computeHasDuplicateFields(getAddedFieldsCounts(addedFields))); - setHasFormChanged(true); - return; - } - setHasFormChanged(true); - dispatch({ - type: 'eventFiltersChangeForm', - payload: { - ...(arg.exceptionItems[0] !== undefined - ? { - entry: { - ...arg.exceptionItems[0], - name: exception?.name ?? '', - description: exception?.description ?? '', - comments: exception?.comments ?? [], - os_types: exception?.os_types ?? [OperatingSystem.WINDOWS], - tags: exception?.tags ?? [], - }, - hasItemsError: arg.errorExists || !arg.exceptionItems[0]?.entries?.length, - } - : { - hasItemsError: true, - }), - }, - }); - }, - [dispatch, exception, hasFormChanged] - ); - - const handleOnChangeName = useCallback( - (e: React.ChangeEvent) => { - if (!exception) return; - setHasFormChanged(true); - const name = e.target.value.toString().trim(); - dispatch({ - type: 'eventFiltersChangeForm', - payload: { - entry: { ...exception, name }, - hasNameError: !name, - }, - }); - }, - [dispatch, exception] - ); - - const handleOnDescriptionChange = useCallback( - (e: React.ChangeEvent) => { - if (!exception) return; - setHasFormChanged(true); - const description = e.target.value.toString().trim(); - dispatch({ - type: 'eventFiltersChangeForm', - payload: { - entry: { ...exception, description }, - }, - }); - }, - [dispatch, exception] - ); - - const handleOnChangeComment = useCallback( - (value: string) => { - if (!exception) return; - setHasFormChanged(true); - dispatch({ - type: 'eventFiltersChangeForm', - payload: { - entry: exception, - newComment: value, - }, - }); - }, - [dispatch, exception] - ); - - const exceptionBuilderComponentMemo = useMemo( - () => - getExceptionBuilderComponentLazy({ - allowLargeValueLists: false, - httpService: http, - autocompleteService: unifiedSearch.autocomplete, - exceptionListItems: [exception as ExceptionListItemSchema], - listType: EVENT_FILTER_LIST_TYPE, - listId: ENDPOINT_EVENT_FILTERS_LIST_ID, - listNamespaceType: 'agnostic', - ruleName: RULE_NAME, - indexPatterns, - isOrDisabled: true, - isOrHidden: true, - isAndDisabled: false, - isNestedDisabled: false, - dataTestSubj: 'alert-exception-builder', - idAria: 'alert-exception-builder', - onChange: handleOnBuilderChange, - listTypeSpecificIndexPatternFilter: filterIndexPatterns, - operatorsList: EVENT_FILTERS_OPERATORS, - osTypes: exception?.os_types, - }), - [unifiedSearch, handleOnBuilderChange, http, indexPatterns, exception] - ); - - const nameInputMemo = useMemo( - () => ( - - !hasBeenInputNameVisited && setHasBeenInputNameVisited(true)} - /> - - ), - [hasNameError, exception?.name, handleOnChangeName, hasBeenInputNameVisited] - ); - - const descriptionInputMemo = useMemo( - () => ( - - - - ), - [exception?.description, handleOnDescriptionChange] - ); - - const osInputMemo = useMemo( - () => ( - - { - if (!exception) return; - dispatch({ - type: 'eventFiltersChangeForm', - payload: { - entry: { - ...exception, - os_types: [value as 'windows' | 'linux' | 'macos'], - }, - }, - }); - }} - /> - - ), - [dispatch, exception, osOptions] - ); - - const commentsInputMemo = useMemo( - () => ( - - ), - [exception, handleOnChangeComment, newComment] - ); - - const detailsSection = useMemo( - () => ( - <> - -

- -

-
- - -

{ABOUT_EVENT_FILTERS}

-
- - {nameInputMemo} - {descriptionInputMemo} - - ), - [nameInputMemo, descriptionInputMemo] - ); - - const criteriaSection = useMemo( - () => ( - <> - -

- -

-
- - -

- -

-
- - {allowSelectOs ? ( - <> - {osInputMemo} - - - ) : null} - {exceptionBuilderComponentMemo} - - ), - [allowSelectOs, exceptionBuilderComponentMemo, osInputMemo] - ); - - const handleOnChangeEffectScope: EffectedPolicySelectProps['onChange'] = useCallback( - (currentSelection) => { - if (currentSelection.isGlobal) { - // Preserve last selection inputs - setSelection({ ...selection, isGlobal: true }); - } else { - setSelection(currentSelection); - } - - if (!exception) return; - setHasFormChanged(true); - - dispatch({ - type: 'eventFiltersChangeForm', - payload: { - entry: { - ...exception, - tags: getArtifactTagsByEffectedPolicySelection( - currentSelection, - getArtifactTagsWithoutPolicies(exception?.tags ?? []) - ), - }, - }, - }); - }, - [dispatch, exception, selection] - ); - const policiesSection = useMemo( - () => ( - - ), - [policies, selection, isPlatinumPlus, handleOnChangeEffectScope, arePoliciesLoading] - ); - - const commentsSection = useMemo( - () => ( - <> - -

- -

-
- - -

- -

-
- - {commentsInputMemo} - - ), - [commentsInputMemo] - ); - - if (isIndexPatternLoading || !exception) { - return ; - } - - return ( - - {detailsSection} - - {criteriaSection} - {hasDuplicateFields && ( - <> - - - - - - )} - {showAssignmentSection && ( - <> - {policiesSection} - - )} - - {commentsSection} - - ); - } -); - -EventFiltersForm.displayName = 'EventFiltersForm'; diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/translations.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/translations.ts deleted file mode 100644 index 20bdde0364e2c..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/translations.ts +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { i18n } from '@kbn/i18n'; - -export const NAME_PLACEHOLDER = i18n.translate( - 'xpack.securitySolution.eventFilter.form.name.placeholder', - { - defaultMessage: 'Event filter name', - } -); - -export const NAME_LABEL = i18n.translate('xpack.securitySolution.eventFilter.form.name.label', { - defaultMessage: 'Name your event filter', -}); -export const DESCRIPTION_LABEL = i18n.translate( - 'xpack.securitySolution.eventFilter.form.description.placeholder', - { - defaultMessage: 'Description', - } -); - -export const DESCRIPTION_PLACEHOLDER = i18n.translate( - 'xpack.securitySolution.eventFilter.form.description.label', - { - defaultMessage: 'Describe your event filter', - } -); - -export const NAME_ERROR = i18n.translate('xpack.securitySolution.eventFilter.form.name.error', { - defaultMessage: "The name can't be empty", -}); - -export const OS_LABEL = i18n.translate('xpack.securitySolution.eventFilter.form.os.label', { - defaultMessage: 'Select operating system', -}); - -export const RULE_NAME = i18n.translate('xpack.securitySolution.eventFilter.form.rule.name', { - defaultMessage: 'Endpoint Event Filtering', -}); diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list.test.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list.test.tsx new file mode 100644 index 0000000000000..79afbce97caf6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list.test.tsx @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { EVENT_FILTERS_PATH } from '../../../../../common/constants'; +import { AppContextTestRender, createAppRootMockRenderer } from '../../../../common/mock/endpoint'; +import { EventFiltersList } from './event_filters_list'; +import { exceptionsListAllHttpMocks } from '../../mocks/exceptions_list_http_mocks'; +import { SEARCHABLE_FIELDS } from '../constants'; +import { parseQueryFilterToKQL } from '../../../common/utils'; + +describe('When on the Event Filters list page', () => { + let render: () => ReturnType; + let renderResult: ReturnType; + let history: AppContextTestRender['history']; + let mockedContext: AppContextTestRender; + let apiMocks: ReturnType; + + beforeEach(() => { + mockedContext = createAppRootMockRenderer(); + ({ history } = mockedContext); + render = () => (renderResult = mockedContext.render()); + apiMocks = exceptionsListAllHttpMocks(mockedContext.coreStart.http); + act(() => { + history.push(EVENT_FILTERS_PATH); + }); + }); + + it('should search using expected exception item fields', async () => { + const expectedFilterString = parseQueryFilterToKQL('fooFooFoo', SEARCHABLE_FIELDS); + const { findAllByTestId } = render(); + await waitFor(async () => { + await expect(findAllByTestId('EventFiltersListPage-card')).resolves.toHaveLength(10); + }); + + apiMocks.responseProvider.exceptionsFind.mockClear(); + userEvent.type(renderResult.getByTestId('searchField'), 'fooFooFoo'); + userEvent.click(renderResult.getByTestId('searchButton')); + await waitFor(() => { + expect(apiMocks.responseProvider.exceptionsFind).toHaveBeenCalled(); + }); + + expect(apiMocks.responseProvider.exceptionsFind).toHaveBeenLastCalledWith( + expect.objectContaining({ + query: expect.objectContaining({ + filter: expectedFilterString, + }), + }) + ); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list.tsx new file mode 100644 index 0000000000000..f303987e1acab --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list.tsx @@ -0,0 +1,150 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { DocLinks } from '@kbn/doc-links'; +import { EuiLink } from '@elastic/eui'; + +import { useHttp } from '../../../../common/lib/kibana'; +import { ArtifactListPage, ArtifactListPageProps } from '../../../components/artifact_list_page'; +import { EventFiltersApiClient } from '../service/api_client'; +import { EventFiltersForm } from './components/form'; +import { SEARCHABLE_FIELDS } from '../constants'; + +export const ABOUT_EVENT_FILTERS = i18n.translate('xpack.securitySolution.eventFilters.aboutInfo', { + defaultMessage: + 'Event filters exclude high volume or unwanted events from being written to Elasticsearch.', +}); + +export const NAME_LABEL = i18n.translate('xpack.securitySolution.eventFilter.form.name.label', { + defaultMessage: 'Name', +}); +export const DESCRIPTION_LABEL = i18n.translate( + 'xpack.securitySolution.eventFilter.form.description.placeholder', + { + defaultMessage: 'Description', + } +); + +export const NAME_ERROR = i18n.translate('xpack.securitySolution.eventFilter.form.name.error', { + defaultMessage: "The name can't be empty", +}); + +export const OS_LABEL = i18n.translate('xpack.securitySolution.eventFilter.form.os.label', { + defaultMessage: 'Select operating system', +}); + +export const RULE_NAME = i18n.translate('xpack.securitySolution.eventFilter.form.rule.name', { + defaultMessage: 'Endpoint Event Filtering', +}); + +const EVENT_FILTERS_PAGE_LABELS: ArtifactListPageProps['labels'] = { + pageTitle: i18n.translate('xpack.securitySolution.eventFilters.pageTitle', { + defaultMessage: 'Event Filters', + }), + pageAboutInfo: i18n.translate('xpack.securitySolution.eventFilters.pageAboutInfo', { + defaultMessage: + 'Event filters exclude high volume or unwanted events from being written to Elasticsearch.', + }), + pageAddButtonTitle: i18n.translate('xpack.securitySolution.eventFilters.pageAddButtonTitle', { + defaultMessage: 'Add event filter', + }), + getShowingCountLabel: (total) => + i18n.translate('xpack.securitySolution.eventFilters.showingTotal', { + defaultMessage: 'Showing {total} {total, plural, one {event filter} other {event filters}}', + values: { total }, + }), + cardActionEditLabel: i18n.translate('xpack.securitySolution.eventFilters.cardActionEditLabel', { + defaultMessage: 'Edit event filter', + }), + cardActionDeleteLabel: i18n.translate( + 'xpack.securitySolution.eventFilters.cardActionDeleteLabel', + { + defaultMessage: 'Delete event filter', + } + ), + flyoutCreateTitle: i18n.translate('xpack.securitySolution.eventFilters.flyoutCreateTitle', { + defaultMessage: 'Add event filter', + }), + flyoutEditTitle: i18n.translate('xpack.securitySolution.eventFilters.flyoutEditTitle', { + defaultMessage: 'Edit event filter', + }), + flyoutCreateSubmitButtonLabel: i18n.translate( + 'xpack.securitySolution.eventFilters.flyoutCreateSubmitButtonLabel', + { defaultMessage: 'Add event filter' } + ), + flyoutCreateSubmitSuccess: ({ name }) => + i18n.translate('xpack.securitySolution.eventFilters.flyoutCreateSubmitSuccess', { + defaultMessage: '"{name}" has been added to the event filters list.', + values: { name }, + }), + flyoutEditSubmitSuccess: ({ name }) => + i18n.translate('xpack.securitySolution.eventFilters.flyoutEditSubmitSuccess', { + defaultMessage: '"{name}" has been updated.', + values: { name }, + }), + flyoutDowngradedLicenseDocsInfo: ( + securitySolutionDocsLinks: DocLinks['securitySolution'] + ): React.ReactNode => { + return ( + <> + + + + + + ); + }, + deleteActionSuccess: (itemName) => + i18n.translate('xpack.securitySolution.eventFilters.deleteSuccess', { + defaultMessage: '"{itemName}" has been removed from event filters list.', + values: { itemName }, + }), + emptyStateTitle: i18n.translate('xpack.securitySolution.eventFilters.emptyStateTitle', { + defaultMessage: 'Add your first event filter', + }), + emptyStateInfo: i18n.translate('xpack.securitySolution.eventFilters.emptyStateInfo', { + defaultMessage: + 'Add an event filter to exclude high volume or unwanted events from being written to Elasticsearch.', + }), + emptyStatePrimaryButtonLabel: i18n.translate( + 'xpack.securitySolution.eventFilters.emptyStatePrimaryButtonLabel', + { defaultMessage: 'Add event filter' } + ), + searchPlaceholderInfo: i18n.translate( + 'xpack.securitySolution.eventFilters.searchPlaceholderInfo', + { + defaultMessage: 'Search on the fields below: name, description, comments, value', + } + ), +}; + +export const EventFiltersList = memo(() => { + const http = useHttp(); + const eventFiltersApiClient = EventFiltersApiClient.getInstance(http); + + return ( + + ); +}); + +EventFiltersList.displayName = 'EventFiltersList'; diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.test.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.test.tsx deleted file mode 100644 index ec0adf0c10a23..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.test.tsx +++ /dev/null @@ -1,247 +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 { AppContextTestRender, createAppRootMockRenderer } from '../../../../common/mock/endpoint'; -import React from 'react'; -import { fireEvent, act, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { EventFiltersListPage } from './event_filters_list_page'; -import { eventFiltersListQueryHttpMock } from '../test_utils'; -import { isFailedResourceState, isLoadedResourceState } from '../../../state'; -import { sendGetEndpointSpecificPackagePolicies } from '../../../services/policies/policies'; -import { sendGetEndpointSpecificPackagePoliciesMock } from '../../../services/policies/test_mock_utils'; - -// Needed to mock the data services used by the ExceptionItem component -jest.mock('../../../../common/lib/kibana'); -jest.mock('../../../../common/components/user_privileges'); -jest.mock('../../../services/policies/policies'); - -(sendGetEndpointSpecificPackagePolicies as jest.Mock).mockImplementation( - sendGetEndpointSpecificPackagePoliciesMock -); - -describe('When on the Event Filters List Page', () => { - let render: () => ReturnType; - let renderResult: ReturnType; - let history: AppContextTestRender['history']; - let coreStart: AppContextTestRender['coreStart']; - let waitForAction: AppContextTestRender['middlewareSpy']['waitForAction']; - let mockedApi: ReturnType; - - const dataReceived = () => - act(async () => { - await waitForAction('eventFiltersListPageDataChanged', { - validate(action) { - return isLoadedResourceState(action.payload); - }, - }); - }); - - beforeEach(() => { - const mockedContext = createAppRootMockRenderer(); - - ({ history, coreStart } = mockedContext); - render = () => (renderResult = mockedContext.render()); - mockedApi = eventFiltersListQueryHttpMock(coreStart.http); - waitForAction = mockedContext.middlewareSpy.waitForAction; - - act(() => { - history.push('/administration/event_filters'); - }); - }); - - describe('And no data exists', () => { - beforeEach(async () => { - mockedApi.responseProvider.eventFiltersList.mockReturnValue({ - data: [], - page: 1, - per_page: 10, - total: 0, - }); - - render(); - - await act(async () => { - await waitForAction('eventFiltersListPageDataExistsChanged', { - validate(action) { - return isLoadedResourceState(action.payload); - }, - }); - }); - }); - - it('should show the Empty message', () => { - expect(renderResult.getByTestId('eventFiltersEmpty')).toBeTruthy(); - expect(renderResult.getByTestId('eventFiltersListEmptyStateAddButton')).toBeTruthy(); - }); - - it('should open create flyout when add button in empty state is clicked', async () => { - act(() => { - fireEvent.click(renderResult.getByTestId('eventFiltersListEmptyStateAddButton')); - }); - - expect(renderResult.getByTestId('eventFiltersCreateEditFlyout')).toBeTruthy(); - expect(history.location.search).toEqual('?show=create'); - }); - }); - - describe('And data exists', () => { - it('should show loading indicator while retrieving data', async () => { - let releaseApiResponse: () => void; - - mockedApi.responseProvider.eventFiltersList.mockDelay.mockReturnValue( - new Promise((r) => (releaseApiResponse = r)) - ); - render(); - - expect(renderResult.getByTestId('eventFilterListLoader')).toBeTruthy(); - - const wasReceived = dataReceived(); - releaseApiResponse!(); - await wasReceived; - - expect(renderResult.container.querySelector('.euiProgress')).toBeNull(); - }); - - it('should show items on the list', async () => { - render(); - await dataReceived(); - - expect(renderResult.getByTestId('eventFilterCard')).toBeTruthy(); - }); - - it('should render expected fields on card', async () => { - render(); - await dataReceived(); - - [ - ['subHeader-touchedBy-createdBy-value', 'some user'], - ['subHeader-touchedBy-updatedBy-value', 'some user'], - ['header-created-value', '4/20/2020'], - ['header-updated-value', '4/20/2020'], - ].forEach(([suffix, value]) => - expect(renderResult.getByTestId(`eventFilterCard-${suffix}`).textContent).toEqual(value) - ); - }); - - it('should show API error if one is encountered', async () => { - mockedApi.responseProvider.eventFiltersList.mockImplementation(() => { - throw new Error('oh no'); - }); - render(); - await act(async () => { - await waitForAction('eventFiltersListPageDataChanged', { - validate(action) { - return isFailedResourceState(action.payload); - }, - }); - }); - - expect(renderResult.getByTestId('eventFiltersContent-error').textContent).toEqual(' oh no'); - }); - - it('should show modal when delete is clicked on a card', async () => { - render(); - await dataReceived(); - - await act(async () => { - (await renderResult.findAllByTestId('eventFilterCard-header-actions-button'))[0].click(); - }); - - await act(async () => { - (await renderResult.findByTestId('deleteEventFilterAction')).click(); - }); - - expect( - renderResult.baseElement.querySelector('[data-test-subj="eventFilterDeleteModalHeader"]') - ).not.toBeNull(); - }); - }); - - describe('And search is dispatched', () => { - beforeEach(async () => { - act(() => { - history.push('/administration/event_filters?filter=test'); - }); - renderResult = render(); - await act(async () => { - await waitForAction('eventFiltersListPageDataChanged'); - }); - }); - - it('search bar is filled with query params', () => { - expect(renderResult.getByDisplayValue('test')).not.toBeNull(); - }); - - it('search action is dispatched', async () => { - await act(async () => { - fireEvent.click(renderResult.getByTestId('searchButton')); - expect(await waitForAction('userChangedUrl')).not.toBeNull(); - }); - }); - }); - - describe('And policies select is dispatched', () => { - it('should apply policy filter', async () => { - const policies = await sendGetEndpointSpecificPackagePoliciesMock(); - (sendGetEndpointSpecificPackagePolicies as jest.Mock).mockResolvedValue(policies); - - renderResult = render(); - await waitFor(() => { - expect(sendGetEndpointSpecificPackagePolicies).toHaveBeenCalled(); - }); - - const firstPolicy = policies.items[0]; - - userEvent.click(renderResult.getByTestId('policiesSelectorButton')); - userEvent.click(renderResult.getByTestId(`policiesSelector-popover-items-${firstPolicy.id}`)); - await waitFor(() => expect(waitForAction('userChangedUrl')).not.toBeNull()); - }); - }); - - describe('and the back button is present', () => { - beforeEach(async () => { - renderResult = render(); - act(() => { - history.push('/administration/event_filters', { - onBackButtonNavigateTo: [{ appId: 'appId' }], - backButtonLabel: 'back to fleet', - backButtonUrl: '/fleet', - }); - }); - }); - - it('back button is present', () => { - const button = renderResult.queryByTestId('backToOrigin'); - expect(button).not.toBeNull(); - expect(button).toHaveAttribute('href', '/fleet'); - }); - - it('back button is still present after push history', () => { - act(() => { - history.push('/administration/event_filters'); - }); - const button = renderResult.queryByTestId('backToOrigin'); - expect(button).not.toBeNull(); - expect(button).toHaveAttribute('href', '/fleet'); - }); - }); - - describe('and the back button is not present', () => { - beforeEach(async () => { - renderResult = render(); - act(() => { - history.push('/administration/event_filters'); - }); - }); - - it('back button is not present when missing history params', () => { - const button = renderResult.queryByTestId('backToOrigin'); - expect(button).toBeNull(); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.tsx deleted file mode 100644 index b982c260f9ca8..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.tsx +++ /dev/null @@ -1,339 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { memo, useCallback, useMemo, useEffect } from 'react'; -import { useDispatch } from 'react-redux'; -import { Dispatch } from 'redux'; -import { useHistory, useLocation } from 'react-router-dom'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n-react'; -import { EuiButton, EuiSpacer, EuiText } from '@elastic/eui'; -import styled from 'styled-components'; - -import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; -import { AppAction } from '../../../../common/store/actions'; -import { getEventFiltersListPath } from '../../../common/routing'; -import { AdministrationListPage as _AdministrationListPage } from '../../../components/administration_list_page'; - -import { EventFiltersListEmptyState } from './components/empty'; -import { useEventFiltersNavigateCallback, useEventFiltersSelector } from './hooks'; -import { EventFiltersFlyout } from './components/flyout'; -import { - getListFetchError, - getListIsLoading, - getListItems, - getListPagination, - getCurrentLocation, - getListPageDoesDataExist, - getActionError, - getFormEntry, - showDeleteModal, - getTotalCountListItems, -} from '../store/selector'; -import { PaginatedContent, PaginatedContentProps } from '../../../components/paginated_content'; -import { Immutable, ListPageRouteState } from '../../../../../common/endpoint/types'; -import { ExceptionItem } from '../../../../common/components/exceptions/viewer/exception_item'; -import { - AnyArtifact, - ArtifactEntryCard, - ArtifactEntryCardProps, -} from '../../../components/artifact_entry_card'; -import { EventFilterDeleteModal } from './components/event_filter_delete_modal'; - -import { SearchExceptions } from '../../../components/search_exceptions'; -import { BackToExternalAppSecondaryButton } from '../../../components/back_to_external_app_secondary_button'; -import { BackToExternalAppButton } from '../../../components/back_to_external_app_button'; -import { ABOUT_EVENT_FILTERS } from './translations'; -import { useGetEndpointSpecificPolicies } from '../../../services/policies/hooks'; -import { useToasts } from '../../../../common/lib/kibana'; -import { getLoadPoliciesError } from '../../../common/translations'; -import { useEndpointPoliciesToArtifactPolicies } from '../../../components/artifact_entry_card/hooks/use_endpoint_policies_to_artifact_policies'; -import { ManagementPageLoader } from '../../../components/management_page_loader'; -import { useMemoizedRouteState } from '../../../common/hooks'; - -type ArtifactEntryCardType = typeof ArtifactEntryCard; - -type EventListPaginatedContent = PaginatedContentProps< - Immutable, - typeof ExceptionItem ->; - -const AdministrationListPage = styled(_AdministrationListPage)` - .event-filter-container > * { - margin-bottom: ${({ theme }) => theme.eui.euiSizeL}; - - &:last-child { - margin-bottom: 0; - } - } -`; - -const EDIT_EVENT_FILTER_ACTION_LABEL = i18n.translate( - 'xpack.securitySolution.eventFilters.list.cardAction.edit', - { - defaultMessage: 'Edit event filter', - } -); - -const DELETE_EVENT_FILTER_ACTION_LABEL = i18n.translate( - 'xpack.securitySolution.eventFilters.list.cardAction.delete', - { - defaultMessage: 'Delete event filter', - } -); - -export const EventFiltersListPage = memo(() => { - const { state: routeState } = useLocation(); - const history = useHistory(); - const dispatch = useDispatch>(); - const toasts = useToasts(); - const isActionError = useEventFiltersSelector(getActionError); - const formEntry = useEventFiltersSelector(getFormEntry); - const listItems = useEventFiltersSelector(getListItems); - const totalCountListItems = useEventFiltersSelector(getTotalCountListItems); - const pagination = useEventFiltersSelector(getListPagination); - const isLoading = useEventFiltersSelector(getListIsLoading); - const fetchError = useEventFiltersSelector(getListFetchError); - const location = useEventFiltersSelector(getCurrentLocation); - const doesDataExist = useEventFiltersSelector(getListPageDoesDataExist); - const showDelete = useEventFiltersSelector(showDeleteModal); - - const navigateCallback = useEventFiltersNavigateCallback(); - const showFlyout = !!location.show; - - const memoizedRouteState = useMemoizedRouteState(routeState); - - const backButtonEmptyComponent = useMemo(() => { - if (memoizedRouteState && memoizedRouteState.onBackButtonNavigateTo) { - return ; - } - }, [memoizedRouteState]); - - const backButtonHeaderComponent = useMemo(() => { - if (memoizedRouteState && memoizedRouteState.onBackButtonNavigateTo) { - return ; - } - }, [memoizedRouteState]); - - // load the list of policies - const policiesRequest = useGetEndpointSpecificPolicies({ - perPage: 1000, - onError: (err) => { - toasts.addDanger(getLoadPoliciesError(err)); - }, - }); - - const artifactCardPolicies = useEndpointPoliciesToArtifactPolicies(policiesRequest.data?.items); - - // Clean url params if wrong - useEffect(() => { - if ((location.show === 'edit' && !location.id) || (location.show === 'create' && !!location.id)) - navigateCallback({ - show: 'create', - id: undefined, - }); - }, [location, navigateCallback]); - - // Catch fetch error -> actionError + empty entry in form - useEffect(() => { - if (isActionError && !formEntry) { - // Replace the current URL route so that user does not keep hitting this page via browser back/fwd buttons - history.replace( - getEventFiltersListPath({ - ...location, - show: undefined, - id: undefined, - }) - ); - dispatch({ - type: 'eventFiltersFormStateChanged', - payload: { - type: 'UninitialisedResourceState', - }, - }); - } - }, [dispatch, formEntry, history, isActionError, location, navigateCallback]); - - const handleAddButtonClick = useCallback( - () => - navigateCallback({ - show: 'create', - id: undefined, - }), - [navigateCallback] - ); - - const handleCancelButtonClick = useCallback( - () => - navigateCallback({ - show: undefined, - id: undefined, - }), - [navigateCallback] - ); - - const handlePaginatedContentChange: EventListPaginatedContent['onChange'] = useCallback( - ({ pageIndex, pageSize }) => { - navigateCallback({ - page_index: pageIndex, - page_size: pageSize, - }); - }, - [navigateCallback] - ); - - const handleOnSearch = useCallback( - (query: string, includedPolicies?: string) => { - dispatch({ type: 'eventFiltersForceRefresh', payload: { forceRefresh: true } }); - navigateCallback({ filter: query, included_policies: includedPolicies }); - }, - [navigateCallback, dispatch] - ); - - const artifactCardPropsPerItem = useMemo(() => { - const cachedCardProps: Record = {}; - - // Casting `listItems` below to remove the `Immutable<>` from it in order to prevent errors - // with common component's props - for (const eventFilter of listItems as ExceptionListItemSchema[]) { - cachedCardProps[eventFilter.id] = { - item: eventFilter as AnyArtifact, - policies: artifactCardPolicies, - 'data-test-subj': 'eventFilterCard', - actions: [ - { - icon: 'controlsHorizontal', - onClick: () => { - history.push( - getEventFiltersListPath({ - ...location, - show: 'edit', - id: eventFilter.id, - }) - ); - }, - 'data-test-subj': 'editEventFilterAction', - children: EDIT_EVENT_FILTER_ACTION_LABEL, - }, - { - icon: 'trash', - onClick: () => { - dispatch({ - type: 'eventFilterForDeletion', - payload: eventFilter, - }); - }, - 'data-test-subj': 'deleteEventFilterAction', - children: DELETE_EVENT_FILTER_ACTION_LABEL, - }, - ], - hideDescription: !eventFilter.description, - hideComments: !eventFilter.comments.length, - }; - } - - return cachedCardProps; - }, [artifactCardPolicies, dispatch, history, listItems, location]); - - const handleArtifactCardProps = useCallback( - (eventFilter: ExceptionListItemSchema) => { - return artifactCardPropsPerItem[eventFilter.id]; - }, - [artifactCardPropsPerItem] - ); - - if (isLoading && !doesDataExist) { - return ; - } - - return ( - - } - subtitle={ABOUT_EVENT_FILTERS} - actions={ - doesDataExist && ( - - - - ) - } - hideHeader={!doesDataExist} - > - {showFlyout && ( - - )} - - {showDelete && } - - {doesDataExist && ( - <> - - - - - - - - )} - - - items={listItems} - ItemComponent={ArtifactEntryCard} - itemComponentProps={handleArtifactCardProps} - onChange={handlePaginatedContentChange} - error={fetchError?.message} - loading={isLoading} - pagination={pagination} - contentClassName="event-filter-container" - data-test-subj="eventFiltersContent" - noItemsMessage={ - !doesDataExist && ( - - ) - } - /> - - ); -}); - -EventFiltersListPage.displayName = 'EventFiltersListPage'; diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/hooks.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/hooks.ts deleted file mode 100644 index e48f11c7f8bae..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/hooks.ts +++ /dev/null @@ -1,78 +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 { useState, useCallback } from 'react'; -import { useSelector } from 'react-redux'; -import { useHistory } from 'react-router-dom'; - -import { - isCreationSuccessful, - getFormEntryStateMutable, - getActionError, - getCurrentLocation, -} from '../store/selector'; - -import { useToasts } from '../../../../common/lib/kibana'; -import { - getCreationSuccessMessage, - getUpdateSuccessMessage, - getCreationErrorMessage, - getUpdateErrorMessage, - getGetErrorMessage, -} from './translations'; - -import { State } from '../../../../common/store'; -import { EventFiltersListPageState, EventFiltersPageLocation } from '../types'; -import { getEventFiltersListPath } from '../../../common/routing'; - -import { - MANAGEMENT_STORE_EVENT_FILTERS_NAMESPACE as EVENT_FILTER_NS, - MANAGEMENT_STORE_GLOBAL_NAMESPACE as GLOBAL_NS, -} from '../../../common/constants'; - -export function useEventFiltersSelector(selector: (state: EventFiltersListPageState) => R): R { - return useSelector((state: State) => - selector(state[GLOBAL_NS][EVENT_FILTER_NS] as EventFiltersListPageState) - ); -} - -export const useEventFiltersNotification = () => { - const creationSuccessful = useEventFiltersSelector(isCreationSuccessful); - const actionError = useEventFiltersSelector(getActionError); - const formEntry = useEventFiltersSelector(getFormEntryStateMutable); - const toasts = useToasts(); - const [wasAlreadyHandled] = useState(new WeakSet()); - - if (creationSuccessful && formEntry && !wasAlreadyHandled.has(formEntry)) { - wasAlreadyHandled.add(formEntry); - if (formEntry.item_id) { - toasts.addSuccess(getUpdateSuccessMessage(formEntry)); - } else { - toasts.addSuccess(getCreationSuccessMessage(formEntry)); - } - } else if (actionError && !wasAlreadyHandled.has(actionError)) { - wasAlreadyHandled.add(actionError); - if (formEntry && formEntry.item_id) { - toasts.addDanger(getUpdateErrorMessage(actionError)); - } else if (formEntry) { - toasts.addDanger(getCreationErrorMessage(actionError)); - } else { - toasts.addWarning(getGetErrorMessage(actionError)); - } - } -}; - -export function useEventFiltersNavigateCallback() { - const location = useEventFiltersSelector(getCurrentLocation); - const history = useHistory(); - - return useCallback( - (args: Partial) => - history.push(getEventFiltersListPath({ ...location, ...args })), - [history, location] - ); -} diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/translations.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/translations.ts index 6177fb7822c92..db6908f2baa8d 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/translations.ts +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/translations.ts @@ -5,47 +5,20 @@ * 2.0. */ +import { HttpFetchError } from '@kbn/core/public'; import { i18n } from '@kbn/i18n'; +import { ArtifactFormComponentProps } from '../../../components/artifact_list_page'; -import { ServerApiError } from '../../../../common/types'; -import { EventFiltersForm } from '../types'; - -export const getCreationSuccessMessage = (entry: EventFiltersForm['entry']) => { - return i18n.translate('xpack.securitySolution.eventFilter.form.creationSuccessToastTitle', { +export const getCreationSuccessMessage = (item: ArtifactFormComponentProps['item']) => { + return i18n.translate('xpack.securitySolution.eventFilter.flyoutForm.creationSuccessToastTitle', { defaultMessage: '"{name}" has been added to the event filters list.', - values: { name: entry?.name }, - }); -}; - -export const getUpdateSuccessMessage = (entry: EventFiltersForm['entry']) => { - return i18n.translate('xpack.securitySolution.eventFilter.form.updateSuccessToastTitle', { - defaultMessage: '"{name}" has been updated successfully.', - values: { name: entry?.name }, - }); -}; - -export const getCreationErrorMessage = (creationError: ServerApiError) => { - return i18n.translate('xpack.securitySolution.eventFilter.form.failedToastTitle.create', { - defaultMessage: 'There was an error creating the new event filter: "{error}"', - values: { error: creationError.message }, - }); -}; - -export const getUpdateErrorMessage = (updateError: ServerApiError) => { - return i18n.translate('xpack.securitySolution.eventFilter.form.failedToastTitle.update', { - defaultMessage: 'There was an error updating the event filter: "{error}"', - values: { error: updateError.message }, + values: { name: item?.name }, }); }; -export const getGetErrorMessage = (getError: ServerApiError) => { - return i18n.translate('xpack.securitySolution.eventFilter.form.failedToastTitle.get', { - defaultMessage: 'Unable to edit event filter: "{error}"', - values: { error: getError.message }, - }); +export const getCreationErrorMessage = (creationError: HttpFetchError) => { + return { + title: 'There was an error creating the new event filter: "{error}"', + message: { error: creationError.message }, + }; }; - -export const ABOUT_EVENT_FILTERS = i18n.translate('xpack.securitySolution.eventFilters.aboutInfo', { - defaultMessage: - 'Add an event filter to exclude high volume or unwanted events from being written to Elasticsearch.', -}); diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/use_event_filters_notification.test.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/use_event_filters_notification.test.tsx deleted file mode 100644 index 7643125c587e7..0000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/use_event_filters_notification.test.tsx +++ /dev/null @@ -1,230 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { Provider } from 'react-redux'; -import { renderHook, act } from '@testing-library/react-hooks'; - -import { NotificationsStart } from '@kbn/core/public'; -import { coreMock } from '@kbn/core/public/mocks'; -import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public/context'; -import type { - CreateExceptionListItemSchema, - ExceptionListItemSchema, -} from '@kbn/securitysolution-io-ts-list-types'; - -import { - createdEventFilterEntryMock, - createGlobalNoMiddlewareStore, - ecsEventMock, -} from '../test_utils'; -import { useEventFiltersNotification } from './hooks'; -import { - getCreationErrorMessage, - getCreationSuccessMessage, - getGetErrorMessage, - getUpdateSuccessMessage, - getUpdateErrorMessage, -} from './translations'; -import { getInitialExceptionFromEvent } from '../store/utils'; -import { - getLastLoadedResourceState, - FailedResourceState, -} from '../../../state/async_resource_state'; - -const mockNotifications = () => coreMock.createStart({ basePath: '/mock' }).notifications; - -const renderNotifications = ( - store: ReturnType, - notifications: NotificationsStart -) => { - const Wrapper: React.FC = ({ children }) => ( - - {children} - - ); - return renderHook(useEventFiltersNotification, { wrapper: Wrapper }); -}; - -describe('EventFiltersNotification', () => { - it('renders correctly initially', () => { - const notifications = mockNotifications(); - - renderNotifications(createGlobalNoMiddlewareStore(), notifications); - - expect(notifications.toasts.addSuccess).not.toBeCalled(); - expect(notifications.toasts.addDanger).not.toBeCalled(); - }); - - it('shows success notification when creation successful', () => { - const store = createGlobalNoMiddlewareStore(); - const notifications = mockNotifications(); - - renderNotifications(store, notifications); - - act(() => { - const entry = getInitialExceptionFromEvent(ecsEventMock()); - store.dispatch({ - type: 'eventFiltersInitForm', - payload: { entry }, - }); - }); - - act(() => { - store.dispatch({ - type: 'eventFiltersFormStateChanged', - payload: { - type: 'LoadedResourceState', - data: store.getState().management.eventFilters.form.entry as ExceptionListItemSchema, - }, - }); - }); - - expect(notifications.toasts.addSuccess).toBeCalledWith( - getCreationSuccessMessage( - store.getState().management.eventFilters.form.entry as CreateExceptionListItemSchema - ) - ); - expect(notifications.toasts.addDanger).not.toBeCalled(); - }); - - it('shows success notification when update successful', () => { - const store = createGlobalNoMiddlewareStore(); - const notifications = mockNotifications(); - - renderNotifications(store, notifications); - - act(() => { - store.dispatch({ - type: 'eventFiltersInitForm', - payload: { entry: createdEventFilterEntryMock() }, - }); - }); - - act(() => { - store.dispatch({ - type: 'eventFiltersFormStateChanged', - payload: { - type: 'LoadedResourceState', - data: store.getState().management.eventFilters.form.entry as ExceptionListItemSchema, - }, - }); - }); - - expect(notifications.toasts.addSuccess).toBeCalledWith( - getUpdateSuccessMessage( - store.getState().management.eventFilters.form.entry as CreateExceptionListItemSchema - ) - ); - expect(notifications.toasts.addDanger).not.toBeCalled(); - }); - - it('shows error notification when creation fails', () => { - const store = createGlobalNoMiddlewareStore(); - const notifications = mockNotifications(); - - renderNotifications(store, notifications); - - act(() => { - const entry = getInitialExceptionFromEvent(ecsEventMock()); - store.dispatch({ - type: 'eventFiltersInitForm', - payload: { entry }, - }); - }); - - act(() => { - store.dispatch({ - type: 'eventFiltersFormStateChanged', - payload: { - type: 'FailedResourceState', - error: { message: 'error message', statusCode: 500, error: 'error' }, - lastLoadedState: getLastLoadedResourceState( - store.getState().management.eventFilters.form.submissionResourceState - ), - }, - }); - }); - - expect(notifications.toasts.addSuccess).not.toBeCalled(); - expect(notifications.toasts.addDanger).toBeCalledWith( - getCreationErrorMessage( - ( - store.getState().management.eventFilters.form - .submissionResourceState as FailedResourceState - ).error - ) - ); - }); - - it('shows error notification when update fails', () => { - const store = createGlobalNoMiddlewareStore(); - const notifications = mockNotifications(); - - renderNotifications(store, notifications); - - act(() => { - store.dispatch({ - type: 'eventFiltersInitForm', - payload: { entry: createdEventFilterEntryMock() }, - }); - }); - - act(() => { - store.dispatch({ - type: 'eventFiltersFormStateChanged', - payload: { - type: 'FailedResourceState', - error: { message: 'error message', statusCode: 500, error: 'error' }, - lastLoadedState: getLastLoadedResourceState( - store.getState().management.eventFilters.form.submissionResourceState - ), - }, - }); - }); - - expect(notifications.toasts.addSuccess).not.toBeCalled(); - expect(notifications.toasts.addDanger).toBeCalledWith( - getUpdateErrorMessage( - ( - store.getState().management.eventFilters.form - .submissionResourceState as FailedResourceState - ).error - ) - ); - }); - - it('shows error notification when get fails', () => { - const store = createGlobalNoMiddlewareStore(); - const notifications = mockNotifications(); - - renderNotifications(store, notifications); - - act(() => { - store.dispatch({ - type: 'eventFiltersFormStateChanged', - payload: { - type: 'FailedResourceState', - error: { message: 'error message', statusCode: 500, error: 'error' }, - lastLoadedState: getLastLoadedResourceState( - store.getState().management.eventFilters.form.submissionResourceState - ), - }, - }); - }); - - expect(notifications.toasts.addSuccess).not.toBeCalled(); - expect(notifications.toasts.addWarning).toBeCalledWith( - getGetErrorMessage( - ( - store.getState().management.eventFilters.form - .submissionResourceState as FailedResourceState - ).error - ) - ); - }); -}); diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/utils.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/utils.ts similarity index 100% rename from x-pack/plugins/security_solution/public/management/pages/event_filters/store/utils.ts rename to x-pack/plugins/security_solution/public/management/pages/event_filters/view/utils.ts diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/delete_modal/policy_artifacts_delete_modal.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/delete_modal/policy_artifacts_delete_modal.test.tsx index c30b5a8887338..11772324ff51c 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/delete_modal/policy_artifacts_delete_modal.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/delete_modal/policy_artifacts_delete_modal.test.tsx @@ -5,7 +5,10 @@ * 2.0. */ -import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import { + CreateExceptionListSchema, + ExceptionListItemSchema, +} from '@kbn/securitysolution-io-ts-list-types'; import { act, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; @@ -16,17 +19,26 @@ import { createAppRootMockRenderer, } from '../../../../../../common/mock/endpoint'; import { PolicyArtifactsDeleteModal } from './policy_artifacts_delete_modal'; -import { eventFiltersListQueryHttpMock } from '../../../../event_filters/test_utils'; -import { EventFiltersApiClient } from '../../../../event_filters/service/event_filters_api_client'; +import { exceptionsListAllHttpMocks } from '../../../../mocks/exceptions_list_http_mocks'; +import { ExceptionsListApiClient } from '../../../../../services/exceptions_list/exceptions_list_api_client'; import { POLICY_ARTIFACT_DELETE_MODAL_LABELS } from './translations'; -describe('Policy details artifacts delete modal', () => { +const listType: Array = [ + 'endpoint_events', + 'detection', + 'endpoint', + 'endpoint_trusted_apps', + 'endpoint_host_isolation_exceptions', + 'endpoint_blocklists', +]; + +describe.each(listType)('Policy details %s artifact delete modal', (type) => { let policyId: string; let render: () => Promise>; let renderResult: ReturnType; let mockedContext: AppContextTestRender; let exception: ExceptionListItemSchema; - let mockedApi: ReturnType; + let mockedApi: ReturnType; let onCloseMock: () => jest.Mock; beforeEach(() => { @@ -34,20 +46,30 @@ describe('Policy details artifacts delete modal', () => { mockedContext = createAppRootMockRenderer(); exception = getExceptionListItemSchemaMock(); onCloseMock = jest.fn(); - mockedApi = eventFiltersListQueryHttpMock(mockedContext.coreStart.http); + mockedApi = exceptionsListAllHttpMocks(mockedContext.coreStart.http); render = async () => { await act(async () => { renderResult = mockedContext.render( ); - await waitFor(mockedApi.responseProvider.eventFiltersList); + + mockedApi.responseProvider.exceptionsFind.mockReturnValue({ + data: [], + total: 0, + page: 1, + per_page: 10, + }); }); return renderResult; }; @@ -75,9 +97,9 @@ describe('Policy details artifacts delete modal', () => { const confirmButton = renderResult.getByTestId('confirmModalConfirmButton'); userEvent.click(confirmButton); await waitFor(() => { - expect(mockedApi.responseProvider.eventFiltersUpdateOne).toHaveBeenLastCalledWith({ + expect(mockedApi.responseProvider.exceptionUpdate).toHaveBeenLastCalledWith({ body: JSON.stringify( - EventFiltersApiClient.cleanExceptionsBeforeUpdate({ + ExceptionsListApiClient.cleanExceptionsBeforeUpdate({ ...exception, tags: ['policy:1234', 'policy:4321', 'not-a-policy-tag'], }) @@ -93,7 +115,7 @@ describe('Policy details artifacts delete modal', () => { userEvent.click(confirmButton); await waitFor(() => { - expect(mockedApi.responseProvider.eventFiltersUpdateOne).toHaveBeenCalled(); + expect(mockedApi.responseProvider.exceptionUpdate).toHaveBeenCalled(); }); expect(onCloseMock).toHaveBeenCalled(); @@ -102,7 +124,7 @@ describe('Policy details artifacts delete modal', () => { it('should show an error toast if the operation failed', async () => { const error = new Error('the server is too far away'); - mockedApi.responseProvider.eventFiltersUpdateOne.mockImplementation(() => { + mockedApi.responseProvider.exceptionUpdate.mockImplementation(() => { throw error; }); @@ -111,7 +133,7 @@ describe('Policy details artifacts delete modal', () => { userEvent.click(confirmButton); await waitFor(() => { - expect(mockedApi.responseProvider.eventFiltersUpdateOne).toHaveBeenCalled(); + expect(mockedApi.responseProvider.exceptionUpdate).toHaveBeenCalled(); }); expect(mockedContext.coreStart.notifications.toasts.addError).toHaveBeenCalledWith(error, { diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/flyout/policy_artifacts_flyout.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/flyout/policy_artifacts_flyout.test.tsx index edf9f5b21d8b4..056a8daa92d3a 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/flyout/policy_artifacts_flyout.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/flyout/policy_artifacts_flyout.test.tsx @@ -27,7 +27,7 @@ import { UpdateExceptionListItemSchema, } from '@kbn/securitysolution-io-ts-list-types'; import { cleanEventFilterToUpdate } from '../../../../event_filters/service/service_actions'; -import { EventFiltersApiClient } from '../../../../event_filters/service/event_filters_api_client'; +import { EventFiltersApiClient } from '../../../../event_filters/service/api_client'; import { POLICY_ARTIFACT_FLYOUT_LABELS } from './translations'; const getDefaultQueryParameters = (customFilter: string | undefined = '') => ({ diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/layout/policy_artifacts_layout.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/layout/policy_artifacts_layout.test.tsx index 453c84f63689e..67452fd11df53 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/layout/policy_artifacts_layout.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/layout/policy_artifacts_layout.test.tsx @@ -24,7 +24,7 @@ import { eventFiltersListQueryHttpMock } from '../../../../event_filters/test_ut import { getFoundExceptionListItemSchemaMock } from '@kbn/lists-plugin/common/schemas/response/found_exception_list_item_schema.mock'; import { getEndpointPrivilegesInitialStateMock } from '../../../../../../common/components/user_privileges/endpoint/mocks'; import { POLICY_ARTIFACT_EVENT_FILTERS_LABELS } from '../../tabs/event_filters_translations'; -import { EventFiltersApiClient } from '../../../../event_filters/service/event_filters_api_client'; +import { EventFiltersApiClient } from '../../../../event_filters/service/api_client'; import { SEARCHABLE_FIELDS as EVENT_FILTERS_SEARCHABLE_FIELDS } from '../../../../event_filters/constants'; import { FormattedMessage } from '@kbn/i18n-react'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/list/policy_artifacts_list.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/list/policy_artifacts_list.test.tsx index de2f245a9c098..b3c104b27977f 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/list/policy_artifacts_list.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/list/policy_artifacts_list.test.tsx @@ -22,7 +22,7 @@ import { parseQueryFilterToKQL, parsePoliciesAndFilterToKql } from '../../../../ import { SEARCHABLE_FIELDS } from '../../../../event_filters/constants'; import { getEndpointPrivilegesInitialStateMock } from '../../../../../../common/components/user_privileges/endpoint/mocks'; import { POLICY_ARTIFACT_LIST_LABELS } from './translations'; -import { EventFiltersApiClient } from '../../../../event_filters/service/event_filters_api_client'; +import { EventFiltersApiClient } from '../../../../event_filters/service/api_client'; const endpointGenerator = new EndpointDocGenerator('seed'); const getDefaultQueryParameters = (customFilter: string | undefined = '') => ({ diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_artifacts_card.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_artifacts_card.test.tsx index 87860db1fe69d..16b5e9f975e22 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_artifacts_card.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_artifacts_card.test.tsx @@ -15,7 +15,7 @@ import { getEventFiltersListPath } from '../../../../../../common/routing'; import { eventFiltersListQueryHttpMock } from '../../../../../event_filters/test_utils'; import { getEndpointPrivilegesInitialStateMock } from '../../../../../../../common/components/user_privileges/endpoint/mocks'; import { useToasts } from '../../../../../../../common/lib/kibana'; -import { EventFiltersApiClient } from '../../../../../event_filters/service/event_filters_api_client'; +import { EventFiltersApiClient } from '../../../../../event_filters/service/api_client'; import { FleetArtifactsCard } from './fleet_artifacts_card'; import { EVENT_FILTERS_LABELS } from '..'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_integration_artifacts_card.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_integration_artifacts_card.test.tsx index c88f54f01fd2b..b8724850e1188 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_integration_artifacts_card.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_integration_artifacts_card.test.tsx @@ -19,7 +19,7 @@ import { EndpointDocGenerator } from '../../../../../../../../common/endpoint/ge import { getPolicyEventFiltersPath } from '../../../../../../common/routing'; import { PolicyData } from '../../../../../../../../common/endpoint/types'; import { getSummaryExceptionListSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_summary_schema.mock'; -import { EventFiltersApiClient } from '../../../../../event_filters/service/event_filters_api_client'; +import { EventFiltersApiClient } from '../../../../../event_filters/service/api_client'; import { SEARCHABLE_FIELDS } from '../../../../../event_filters/constants'; import { EVENT_FILTERS_LABELS } from '../../endpoint_policy_edit_extension'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/endpoint_package_custom_extension.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/endpoint_package_custom_extension.tsx index 72cc9852b0e7d..f1af7c3505297 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/endpoint_package_custom_extension.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/endpoint_package_custom_extension.tsx @@ -11,7 +11,7 @@ import { PackageCustomExtensionComponentProps } from '@kbn/fleet-plugin/public'; import { useHttp } from '../../../../../../common/lib/kibana'; import { useCanSeeHostIsolationExceptionsMenu } from '../../../../host_isolation_exceptions/view/hooks'; import { TrustedAppsApiClient } from '../../../../trusted_apps/service/api_client'; -import { EventFiltersApiClient } from '../../../../event_filters/service/event_filters_api_client'; +import { EventFiltersApiClient } from '../../../../event_filters/service/api_client'; import { HostIsolationExceptionsApiClient } from '../../../../host_isolation_exceptions/host_isolation_exceptions_api_client'; import { BlocklistsApiClient } from '../../../../blocklist/services'; import { FleetArtifactsCard } from './components/fleet_artifacts_card'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_policy_edit_extension.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_policy_edit_extension.tsx index dfb2677ecb594..9ac612aec05ed 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_policy_edit_extension.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_policy_edit_extension.tsx @@ -35,7 +35,7 @@ import { useUserPrivileges } from '../../../../../common/components/user_privile import { FleetIntegrationArtifactsCard } from './endpoint_package_custom_extension/components/fleet_integration_artifacts_card'; import { BlocklistsApiClient } from '../../../blocklist/services'; import { HostIsolationExceptionsApiClient } from '../../../host_isolation_exceptions/host_isolation_exceptions_api_client'; -import { EventFiltersApiClient } from '../../../event_filters/service/event_filters_api_client'; +import { EventFiltersApiClient } from '../../../event_filters/service/api_client'; import { TrustedAppsApiClient } from '../../../trusted_apps/service/api_client'; import { SEARCHABLE_FIELDS as BLOCKLIST_SEARCHABLE_FIELDS } from '../../../blocklist/constants'; import { SEARCHABLE_FIELDS as HOST_ISOLATION_EXCEPTIONS_SEARCHABLE_FIELDS } from '../../../host_isolation_exceptions/constants'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/tabs/policy_tabs.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/tabs/policy_tabs.tsx index f3a20a1abfd66..f81b55b5e8a31 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/tabs/policy_tabs.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/tabs/policy_tabs.tsx @@ -42,7 +42,7 @@ import { POLICY_ARTIFACT_TRUSTED_APPS_LABELS } from './trusted_apps_translations import { POLICY_ARTIFACT_HOST_ISOLATION_EXCEPTIONS_LABELS } from './host_isolation_exceptions_translations'; import { POLICY_ARTIFACT_BLOCKLISTS_LABELS } from './blocklists_translations'; import { TrustedAppsApiClient } from '../../../trusted_apps/service/api_client'; -import { EventFiltersApiClient } from '../../../event_filters/service/event_filters_api_client'; +import { EventFiltersApiClient } from '../../../event_filters/service/api_client'; import { BlocklistsApiClient } from '../../../blocklist/services/blocklists_api_client'; import { HostIsolationExceptionsApiClient } from '../../../host_isolation_exceptions/host_isolation_exceptions_api_client'; import { SEARCHABLE_FIELDS as TRUSTED_APPS_SEARCHABLE_FIELDS } from '../../../trusted_apps/constants'; diff --git a/x-pack/plugins/security_solution/public/management/store/middleware.ts b/x-pack/plugins/security_solution/public/management/store/middleware.ts index 86a5ade340058..475fe0bc9bb7c 100644 --- a/x-pack/plugins/security_solution/public/management/store/middleware.ts +++ b/x-pack/plugins/security_solution/public/management/store/middleware.ts @@ -14,11 +14,9 @@ import { MANAGEMENT_STORE_ENDPOINTS_NAMESPACE, MANAGEMENT_STORE_GLOBAL_NAMESPACE, MANAGEMENT_STORE_POLICY_DETAILS_NAMESPACE, - MANAGEMENT_STORE_EVENT_FILTERS_NAMESPACE, } from '../common/constants'; import { policyDetailsMiddlewareFactory } from '../pages/policy/store/policy_details'; import { endpointMiddlewareFactory } from '../pages/endpoint_hosts/store/middleware'; -import { eventFiltersPageMiddlewareFactory } from '../pages/event_filters/store/middleware'; type ManagementSubStateKey = keyof State[typeof MANAGEMENT_STORE_GLOBAL_NAMESPACE]; @@ -40,10 +38,5 @@ export const managementMiddlewareFactory: SecuritySubPluginMiddlewareFactory = ( createSubStateSelector(MANAGEMENT_STORE_ENDPOINTS_NAMESPACE), endpointMiddlewareFactory(coreStart, depsStart) ), - - substateMiddlewareFactory( - createSubStateSelector(MANAGEMENT_STORE_EVENT_FILTERS_NAMESPACE), - eventFiltersPageMiddlewareFactory(coreStart, depsStart) - ), ]; }; diff --git a/x-pack/plugins/security_solution/public/management/store/reducer.ts b/x-pack/plugins/security_solution/public/management/store/reducer.ts index 2fd20129ddca8..678819a51d747 100644 --- a/x-pack/plugins/security_solution/public/management/store/reducer.ts +++ b/x-pack/plugins/security_solution/public/management/store/reducer.ts @@ -13,14 +13,11 @@ import { import { MANAGEMENT_STORE_ENDPOINTS_NAMESPACE, MANAGEMENT_STORE_POLICY_DETAILS_NAMESPACE, - MANAGEMENT_STORE_EVENT_FILTERS_NAMESPACE, } from '../common/constants'; import { ImmutableCombineReducers } from '../../common/store'; import { Immutable } from '../../../common/endpoint/types'; import { ManagementState } from '../types'; import { endpointListReducer } from '../pages/endpoint_hosts/store/reducer'; -import { initialEventFiltersPageState } from '../pages/event_filters/store/builders'; -import { eventFiltersPageReducer } from '../pages/event_filters/store/reducer'; import { initialEndpointPageState } from '../pages/endpoint_hosts/store/builders'; const immutableCombineReducers: ImmutableCombineReducers = combineReducers; @@ -31,7 +28,6 @@ const immutableCombineReducers: ImmutableCombineReducers = combineReducers; export const mockManagementState: Immutable = { [MANAGEMENT_STORE_POLICY_DETAILS_NAMESPACE]: initialPolicyDetailsState(), [MANAGEMENT_STORE_ENDPOINTS_NAMESPACE]: initialEndpointPageState(), - [MANAGEMENT_STORE_EVENT_FILTERS_NAMESPACE]: initialEventFiltersPageState(), }; /** @@ -40,5 +36,4 @@ export const mockManagementState: Immutable = { export const managementReducer = immutableCombineReducers({ [MANAGEMENT_STORE_POLICY_DETAILS_NAMESPACE]: policyDetailsReducer, [MANAGEMENT_STORE_ENDPOINTS_NAMESPACE]: endpointListReducer, - [MANAGEMENT_STORE_EVENT_FILTERS_NAMESPACE]: eventFiltersPageReducer, }); diff --git a/x-pack/plugins/security_solution/public/management/types.ts b/x-pack/plugins/security_solution/public/management/types.ts index 0ad0f2e757c00..f1cb7b2623b39 100644 --- a/x-pack/plugins/security_solution/public/management/types.ts +++ b/x-pack/plugins/security_solution/public/management/types.ts @@ -9,7 +9,6 @@ import { CombinedState } from 'redux'; import { SecurityPageName } from '../app/types'; import { PolicyDetailsState } from './pages/policy/types'; import { EndpointState } from './pages/endpoint_hosts/types'; -import { EventFiltersListPageState } from './pages/event_filters/types'; /** * The type for the management store global namespace. Used mostly internally to reference @@ -20,7 +19,6 @@ export type ManagementStoreGlobalNamespace = 'management'; export type ManagementState = CombinedState<{ policyDetails: PolicyDetailsState; endpoints: EndpointState; - eventFilters: EventFiltersListPageState; }>; /** diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/footer.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/footer.tsx index 0f3bb6e7177bd..86a8047b3ad76 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/footer.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/footer.tsx @@ -14,7 +14,7 @@ import type { TimelineEventsDetailsItem } from '../../../../../common/search_str import { TimelineId } from '../../../../../common/types'; import { useExceptionFlyout } from '../../../../detections/components/alerts_table/timeline_actions/use_add_exception_flyout'; import { AddExceptionFlyoutWrapper } from '../../../../detections/components/alerts_table/timeline_actions/alert_context_menu'; -import { EventFiltersFlyout } from '../../../../management/pages/event_filters/view/components/flyout'; +import { EventFiltersFlyout } from '../../../../management/pages/event_filters/view/components/event_filters_flyout'; import { useEventFilterModal } from '../../../../detections/components/alerts_table/timeline_actions/use_event_filter_modal'; import { getFieldValue } from '../../../../detections/components/host_isolation/helpers'; import { Status } from '../../../../../common/detection_engine/schemas/common/schemas'; diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index a9d350146c0d9..70d3a81a2f808 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -25469,52 +25469,22 @@ "xpack.securitySolution.eventDetails.viewAllFields": "Afficher tous les champs dans le tableau", "xpack.securitySolution.eventDetails.viewColumnCheckboxAriaLabel": "Afficher la colonne {field}", "xpack.securitySolution.eventDetails.viewRuleDetailPage": "Afficher la page Détails de la règle", - "xpack.securitySolution.eventFilter.form.creationSuccessToastTitle": "\"{name}\" a été ajouté à la liste de filtres d'événements.", - "xpack.securitySolution.eventFilter.form.description.label": "Décrivez votre filtre d'événement", "xpack.securitySolution.eventFilter.form.description.placeholder": "Description", - "xpack.securitySolution.eventFilter.form.failedToastTitle.create": "Une erreur est survenue lors de la création du nouveau filtre d'événement : \"{error}\"", - "xpack.securitySolution.eventFilter.form.failedToastTitle.get": "Impossible de modifier le filtre d'événement : \"{error}\"", - "xpack.securitySolution.eventFilter.form.failedToastTitle.update": "Une erreur est survenue lors de la mise à jour du filtre d'événement : \"{error}\"", "xpack.securitySolution.eventFilter.form.name.error": "Le nom doit être renseigné", "xpack.securitySolution.eventFilter.form.name.label": "Nommer votre filtre d'événement", - "xpack.securitySolution.eventFilter.form.name.placeholder": "Nom du filtre d'événement", "xpack.securitySolution.eventFilter.form.os.label": "Sélectionner un système d'exploitation", "xpack.securitySolution.eventFilter.form.rule.name": "Filtrage d'événement Endpoint", - "xpack.securitySolution.eventFilter.form.updateSuccessToastTitle": "{name} a été mis à jour avec succès.", - "xpack.securitySolution.eventFilter.search.placeholder": "Rechercher sur les champs ci-dessous : nom, description, commentaires, valeur", "xpack.securitySolution.eventFilters.aboutInfo": "Ajouter un filtre d'événement pour exclure les volumes importants ou les événements non souhaités de l'écriture dans Elasticsearch.", "xpack.securitySolution.eventFilters.commentsSectionDescription": "Ajouter un commentaire à votre filtre d'événement.", "xpack.securitySolution.eventFilters.commentsSectionTitle": "Commentaires", - "xpack.securitySolution.eventFilters.criteriaSectionDescription": "Sélectionnez un système d'exploitation et ajoutez des conditions.", "xpack.securitySolution.eventFilters.criteriaSectionTitle": "Conditions", - "xpack.securitySolution.eventFilters.deletionDialog.calloutMessage": "La suppression de cette entrée entraînera son retrait dans {count} {count, plural, one {politique associée} other {politiques associées}}.", - "xpack.securitySolution.eventFilters.deletionDialog.calloutTitle": "Avertissement", - "xpack.securitySolution.eventFilters.deletionDialog.cancelButton": "Annuler", - "xpack.securitySolution.eventFilters.deletionDialog.confirmButton": "Supprimer", - "xpack.securitySolution.eventFilters.deletionDialog.deleteFailure": "Impossible de retirer \"{name}\" de la liste de filtres d'événements. Raison : {message}", - "xpack.securitySolution.eventFilters.deletionDialog.deleteSuccess": "\"{name}\" a été retiré de la liste de filtres d'événements.", - "xpack.securitySolution.eventFilters.deletionDialog.subMessage": "Cette action ne peut pas être annulée. Voulez-vous vraiment continuer ?", - "xpack.securitySolution.eventFilters.deletionDialog.title": "Supprimer \"{name}\"", "xpack.securitySolution.eventFilters.detailsSectionTitle": "Détails", - "xpack.securitySolution.eventFilters.docsLink": "Documentation relative aux filtres d'événements.", "xpack.securitySolution.eventFilters.eventFiltersFlyout.actions.cancel": "Annuler", "xpack.securitySolution.eventFilters.eventFiltersFlyout.actions.confirm.create": "Ajouter un filtre d'événement", - "xpack.securitySolution.eventFilters.eventFiltersFlyout.actions.confirm.update": "Enregistrer", "xpack.securitySolution.eventFilters.eventFiltersFlyout.actions.confirm.update.withData": "Ajouter un filtre d'événement de point de terminaison", "xpack.securitySolution.eventFilters.eventFiltersFlyout.subtitle.create": "Ajouter un filtre d'événement", "xpack.securitySolution.eventFilters.eventFiltersFlyout.subtitle.create.withData": "Endpoint Security", - "xpack.securitySolution.eventFilters.eventFiltersFlyout.subtitle.update": "Mettre à jour le filtre d'événement", "xpack.securitySolution.eventFilters.eventFiltersFlyout.title.create.withData": "Ajouter un filtre d'événement de point de terminaison", - "xpack.securitySolution.eventFilters.expiredLicenseMessage": "Votre licence Kibana est passée à une version inférieure. Les futures configurations de politiques seront désormais globalement affectées à toutes les politiques. Pour en savoir plus, consultez notre ", - "xpack.securitySolution.eventFilters.expiredLicenseTitle": "Licence expirée", - "xpack.securitySolution.eventFilters.list.cardAction.delete": "Supprimer le filtre d'événement", - "xpack.securitySolution.eventFilters.list.cardAction.edit": "Modifier le filtre d'événement", - "xpack.securitySolution.eventFilters.list.pageAddButton": "Ajouter un filtre d'événement", - "xpack.securitySolution.eventFilters.list.pageTitle": "Filtres d'événements", - "xpack.securitySolution.eventFilters.list.totalCount": "Affichage de {total, plural, one {# filtre d'événement} other {# filtres d'événements}}", - "xpack.securitySolution.eventFilters.listEmpty.addButton": "Ajouter un filtre d'événement", - "xpack.securitySolution.eventFilters.listEmpty.message": "Ajouter un filtre d'événement pour exclure les volumes importants ou les événements non souhaités de l'écriture dans Elasticsearch.", - "xpack.securitySolution.eventFilters.listEmpty.title": "Ajouter votre premier filtre d'événement", "xpack.securitySolution.eventFiltersTab": "Filtres d'événements", "xpack.securitySolution.eventRenderers.alertsDescription": "Les alertes sont affichées lorsqu'un malware ou ransomware est bloqué ou détecté", "xpack.securitySolution.eventRenderers.alertsName": "Alertes", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 89813c1104606..a20feeeccdb1b 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -25619,52 +25619,22 @@ "xpack.securitySolution.eventDetails.viewAllFields": "テーブルのすべてのフィールドを表示", "xpack.securitySolution.eventDetails.viewColumnCheckboxAriaLabel": "{field} 列を表示", "xpack.securitySolution.eventDetails.viewRuleDetailPage": "ルール詳細ページを表示", - "xpack.securitySolution.eventFilter.form.creationSuccessToastTitle": "\"{name}\"がイベントフィルターリストに追加されました。", - "xpack.securitySolution.eventFilter.form.description.label": "イベントフィルターの説明", "xpack.securitySolution.eventFilter.form.description.placeholder": "説明", - "xpack.securitySolution.eventFilter.form.failedToastTitle.create": "新しいイベントフィルターの作成中にエラーが発生しました:\"{error}\"", - "xpack.securitySolution.eventFilter.form.failedToastTitle.get": "イベントフィルターを編集できません:\"{error}\"", - "xpack.securitySolution.eventFilter.form.failedToastTitle.update": "イベントフィルターの更新中にエラーが発生しました:\"{error}\"", "xpack.securitySolution.eventFilter.form.name.error": "名前を空にすることはできません", "xpack.securitySolution.eventFilter.form.name.label": "イベントフィルターの名前を付ける", - "xpack.securitySolution.eventFilter.form.name.placeholder": "イベントフィルター名", "xpack.securitySolution.eventFilter.form.os.label": "オペレーティングシステムを選択", "xpack.securitySolution.eventFilter.form.rule.name": "エンドポイントイベントフィルター", - "xpack.securitySolution.eventFilter.form.updateSuccessToastTitle": "\"{name}\"が正常に更新されました", - "xpack.securitySolution.eventFilter.search.placeholder": "次のフィールドで検索:名前、説明、コメント、値", "xpack.securitySolution.eventFilters.aboutInfo": "イベントフィルターを追加して、大量のイベントや不要なイベントがElasticsearchに書き込まれないように除外します。", "xpack.securitySolution.eventFilters.commentsSectionDescription": "イベントフィルターにコメントを追加します。", "xpack.securitySolution.eventFilters.commentsSectionTitle": "コメント", - "xpack.securitySolution.eventFilters.criteriaSectionDescription": "オペレーティングシステムを選択して、条件を追加します。", "xpack.securitySolution.eventFilters.criteriaSectionTitle": "条件", - "xpack.securitySolution.eventFilters.deletionDialog.calloutMessage": "このエントリを削除すると、{count}個の関連付けられた{count, plural, other {ポリシー}}から削除されます。", - "xpack.securitySolution.eventFilters.deletionDialog.calloutTitle": "警告", - "xpack.securitySolution.eventFilters.deletionDialog.cancelButton": "キャンセル", - "xpack.securitySolution.eventFilters.deletionDialog.confirmButton": "削除", - "xpack.securitySolution.eventFilters.deletionDialog.deleteFailure": "イベントフィルターリストから\"{name}\"を削除できません。理由:{message}", - "xpack.securitySolution.eventFilters.deletionDialog.deleteSuccess": "\"{name}\"がイベントフィルターリストから削除されました。", - "xpack.securitySolution.eventFilters.deletionDialog.subMessage": "この操作は元に戻すことができません。続行していいですか?", - "xpack.securitySolution.eventFilters.deletionDialog.title": "\"{name}\"を削除", "xpack.securitySolution.eventFilters.detailsSectionTitle": "詳細", - "xpack.securitySolution.eventFilters.docsLink": "イベントフィルタードキュメント。", "xpack.securitySolution.eventFilters.eventFiltersFlyout.actions.cancel": "キャンセル", "xpack.securitySolution.eventFilters.eventFiltersFlyout.actions.confirm.create": "イベントフィルターを追加", - "xpack.securitySolution.eventFilters.eventFiltersFlyout.actions.confirm.update": "保存", "xpack.securitySolution.eventFilters.eventFiltersFlyout.actions.confirm.update.withData": "エンドポイントイベントフィルターを追加", "xpack.securitySolution.eventFilters.eventFiltersFlyout.subtitle.create": "イベントフィルターを追加", "xpack.securitySolution.eventFilters.eventFiltersFlyout.subtitle.create.withData": "Endpoint Security", - "xpack.securitySolution.eventFilters.eventFiltersFlyout.subtitle.update": "イベントフィルターを更新", "xpack.securitySolution.eventFilters.eventFiltersFlyout.title.create.withData": "エンドポイントイベントフィルターを追加", - "xpack.securitySolution.eventFilters.expiredLicenseMessage": "Kibanaライセンスがダウングレードされました。今後のポリシー構成はグローバルにすべてのポリシーに割り当てられます。詳細はご覧ください。 ", - "xpack.securitySolution.eventFilters.expiredLicenseTitle": "失効したライセンス", - "xpack.securitySolution.eventFilters.list.cardAction.delete": "イベントフィルターを削除", - "xpack.securitySolution.eventFilters.list.cardAction.edit": "イベントフィルターを編集", - "xpack.securitySolution.eventFilters.list.pageAddButton": "イベントフィルターを追加", - "xpack.securitySolution.eventFilters.list.pageTitle": "イベントフィルター", - "xpack.securitySolution.eventFilters.list.totalCount": "{total, plural, other {# 個のイベントフィルター}}を表示中", - "xpack.securitySolution.eventFilters.listEmpty.addButton": "イベントフィルターを追加", - "xpack.securitySolution.eventFilters.listEmpty.message": "イベントフィルターを追加して、大量のイベントや不要なイベントがElasticsearchに書き込まれないように除外します。", - "xpack.securitySolution.eventFilters.listEmpty.title": "最初のイベントフィルターを追加", "xpack.securitySolution.eventFilters.warningMessage.duplicateFields": "同じフィールド値の乗数を使用すると、エンドポイントパフォーマンスが劣化したり、効果的ではないルールが作成されたりすることがあります", "xpack.securitySolution.eventFiltersTab": "イベントフィルター", "xpack.securitySolution.eventRenderers.alertsDescription": "マルウェアまたはランサムウェアが防御、検出されたときにアラートが表示されます。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index a9278d13031f4..a2c33d9a1fae7 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -25652,52 +25652,22 @@ "xpack.securitySolution.eventDetails.viewAllFields": "查看表中的所有字段", "xpack.securitySolution.eventDetails.viewColumnCheckboxAriaLabel": "查看 {field} 列", "xpack.securitySolution.eventDetails.viewRuleDetailPage": "查看规则详情页面", - "xpack.securitySolution.eventFilter.form.creationSuccessToastTitle": "“{name}”已添加到事件筛选列表。", - "xpack.securitySolution.eventFilter.form.description.label": "描述您的事件筛选", "xpack.securitySolution.eventFilter.form.description.placeholder": "描述", - "xpack.securitySolution.eventFilter.form.failedToastTitle.create": "创建新事件筛选时出错:“{error}”", - "xpack.securitySolution.eventFilter.form.failedToastTitle.get": "无法编辑事件筛选:“{error}”", - "xpack.securitySolution.eventFilter.form.failedToastTitle.update": "更新事件筛选时出错:“{error}”", "xpack.securitySolution.eventFilter.form.name.error": "名称不能为空", "xpack.securitySolution.eventFilter.form.name.label": "命名您的事件筛选", - "xpack.securitySolution.eventFilter.form.name.placeholder": "事件筛选名称", "xpack.securitySolution.eventFilter.form.os.label": "选择操作系统", "xpack.securitySolution.eventFilter.form.rule.name": "终端事件筛选", - "xpack.securitySolution.eventFilter.form.updateSuccessToastTitle": "“{name}”已成功更新。", - "xpack.securitySolution.eventFilter.search.placeholder": "搜索下面的字段:name、description、comments、value", "xpack.securitySolution.eventFilters.aboutInfo": "添加事件筛选以阻止高数目或非预期事件写入到 Elasticsearch。", "xpack.securitySolution.eventFilters.commentsSectionDescription": "将注释添加到事件筛选。", "xpack.securitySolution.eventFilters.commentsSectionTitle": "注释", - "xpack.securitySolution.eventFilters.criteriaSectionDescription": "选择操作系统,然后添加条件。", "xpack.securitySolution.eventFilters.criteriaSectionTitle": "条件", - "xpack.securitySolution.eventFilters.deletionDialog.calloutMessage": "删除此条目会将其从 {count} 个关联{count, plural, other {策略}}中移除。", - "xpack.securitySolution.eventFilters.deletionDialog.calloutTitle": "警告", - "xpack.securitySolution.eventFilters.deletionDialog.cancelButton": "取消", - "xpack.securitySolution.eventFilters.deletionDialog.confirmButton": "删除", - "xpack.securitySolution.eventFilters.deletionDialog.deleteFailure": "无法从事件筛选列表中移除“{name}”。原因:{message}", - "xpack.securitySolution.eventFilters.deletionDialog.deleteSuccess": "“{name}”已从事件筛选列表中移除。", - "xpack.securitySolution.eventFilters.deletionDialog.subMessage": "此操作无法撤消。是否确定要继续?", - "xpack.securitySolution.eventFilters.deletionDialog.title": "删除“{name}”", "xpack.securitySolution.eventFilters.detailsSectionTitle": "详情", - "xpack.securitySolution.eventFilters.docsLink": "事件筛选文档。", "xpack.securitySolution.eventFilters.eventFiltersFlyout.actions.cancel": "取消", "xpack.securitySolution.eventFilters.eventFiltersFlyout.actions.confirm.create": "添加事件筛选", - "xpack.securitySolution.eventFilters.eventFiltersFlyout.actions.confirm.update": "保存", "xpack.securitySolution.eventFilters.eventFiltersFlyout.actions.confirm.update.withData": "添加终端事件筛选", "xpack.securitySolution.eventFilters.eventFiltersFlyout.subtitle.create": "添加事件筛选", "xpack.securitySolution.eventFilters.eventFiltersFlyout.subtitle.create.withData": "Endpoint Security", - "xpack.securitySolution.eventFilters.eventFiltersFlyout.subtitle.update": "更新事件筛选", "xpack.securitySolution.eventFilters.eventFiltersFlyout.title.create.withData": "添加终端事件筛选", - "xpack.securitySolution.eventFilters.expiredLicenseMessage": "您的 Kibana 许可证已降级。现在会将未来的策略配置全局分配给所有策略。有关更多信息,请参见 ", - "xpack.securitySolution.eventFilters.expiredLicenseTitle": "已过期许可证", - "xpack.securitySolution.eventFilters.list.cardAction.delete": "删除事件筛选", - "xpack.securitySolution.eventFilters.list.cardAction.edit": "编辑事件筛选", - "xpack.securitySolution.eventFilters.list.pageAddButton": "添加事件筛选", - "xpack.securitySolution.eventFilters.list.pageTitle": "事件筛选", - "xpack.securitySolution.eventFilters.list.totalCount": "正在显示 {total, plural, other {# 个事件筛选}}", - "xpack.securitySolution.eventFilters.listEmpty.addButton": "添加事件筛选", - "xpack.securitySolution.eventFilters.listEmpty.message": "添加事件筛选以阻止高数目或非预期事件写入到 Elasticsearch。", - "xpack.securitySolution.eventFilters.listEmpty.title": "添加您的首个事件筛选", "xpack.securitySolution.eventFilters.warningMessage.duplicateFields": "使用相同提交值的倍数可能会降低终端性能和/或创建低效规则", "xpack.securitySolution.eventFiltersTab": "事件筛选", "xpack.securitySolution.eventRenderers.alertsDescription": "阻止或检测到恶意软件或勒索软件时,显示告警", From 8de3401dffbe2954b24fd749c34a2c92145a528f Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Thu, 19 May 2022 10:12:00 -0600 Subject: [PATCH 12/35] [Controls] Field first control creation (#131461) * Field first *creation* * Field first *editing* * Add support for custom control options * Add i18n * Make field picker accept predicate again + clean up imports * Fix functional tests * Attempt 1 at case sensitivity * Works both ways * [CI] Auto-commit changed files from 'node scripts/precommit_hook.js --ref HEAD~1..HEAD --fix' * Clean up code * Use React useMemo to calculate field registry * Fix functional tests * Fix default state + control settings label * Fix functional tests Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../control_types/options_list/types.ts | 1 - src/plugins/controls/common/types.ts | 2 + .../control_group/control_group_strings.ts | 20 + .../control_group/editor/control_editor.tsx | 345 +++++++++++------- .../control_group/editor/create_control.tsx | 28 +- .../control_group/editor/edit_control.tsx | 104 +++--- .../options_list/options_list_editor.tsx | 182 --------- .../options_list_editor_options.tsx | 54 +++ .../options_list/options_list_embeddable.tsx | 14 +- .../options_list_embeddable_factory.tsx | 15 +- .../range_slider/range_slider_editor.tsx | 111 ------ .../range_slider_embeddable_factory.tsx | 9 +- .../time_slider/time_slider_editor.tsx | 110 ------ .../time_slider_embeddable_factory.tsx | 9 +- src/plugins/controls/public/plugin.ts | 5 +- src/plugins/controls/public/types.ts | 29 +- .../controls/control_group_settings.ts | 4 +- .../controls/options_list.ts | 4 +- .../controls/range_slider.ts | 4 +- .../controls/replace_controls.ts | 22 +- .../page_objects/dashboard_page_controls.ts | 43 ++- 21 files changed, 479 insertions(+), 636 deletions(-) delete mode 100644 src/plugins/controls/public/control_types/options_list/options_list_editor.tsx create mode 100644 src/plugins/controls/public/control_types/options_list/options_list_editor_options.tsx delete mode 100644 src/plugins/controls/public/control_types/range_slider/range_slider_editor.tsx delete mode 100644 src/plugins/controls/public/control_types/time_slider/time_slider_editor.tsx diff --git a/src/plugins/controls/common/control_types/options_list/types.ts b/src/plugins/controls/common/control_types/options_list/types.ts index 7dfdfab742d1a..7ab1c3c4f67a0 100644 --- a/src/plugins/controls/common/control_types/options_list/types.ts +++ b/src/plugins/controls/common/control_types/options_list/types.ts @@ -17,7 +17,6 @@ export const OPTIONS_LIST_CONTROL = 'optionsListControl'; export interface OptionsListEmbeddableInput extends DataControlInput { selectedOptions?: string[]; runPastTimeout?: boolean; - textFieldName?: string; singleSelect?: boolean; loading?: boolean; } diff --git a/src/plugins/controls/common/types.ts b/src/plugins/controls/common/types.ts index 4108e886e757d..7d70f53c32933 100644 --- a/src/plugins/controls/common/types.ts +++ b/src/plugins/controls/common/types.ts @@ -30,5 +30,7 @@ export type ControlInput = EmbeddableInput & { export type DataControlInput = ControlInput & { fieldName: string; + parentFieldName?: string; + childFieldName?: string; dataViewId: string; }; diff --git a/src/plugins/controls/public/control_group/control_group_strings.ts b/src/plugins/controls/public/control_group/control_group_strings.ts index 58ef91ed28173..23be81f3585d3 100644 --- a/src/plugins/controls/public/control_group/control_group_strings.ts +++ b/src/plugins/controls/public/control_group/control_group_strings.ts @@ -44,6 +44,14 @@ export const ControlGroupStrings = { i18n.translate('controls.controlGroup.manageControl.editFlyoutTitle', { defaultMessage: 'Edit control', }), + getDataViewTitle: () => + i18n.translate('controls.controlGroup.manageControl.dataViewTitle', { + defaultMessage: 'Data view', + }), + getFieldTitle: () => + i18n.translate('controls.controlGroup.manageControl.fielditle', { + defaultMessage: 'Field', + }), getTitleInputTitle: () => i18n.translate('controls.controlGroup.manageControl.titleInputTitle', { defaultMessage: 'Label', @@ -56,6 +64,10 @@ export const ControlGroupStrings = { i18n.translate('controls.controlGroup.manageControl.widthInputTitle', { defaultMessage: 'Minimum width', }), + getControlSettingsTitle: () => + i18n.translate('controls.controlGroup.manageControl.controlSettingsTitle', { + defaultMessage: 'Additional settings', + }), getSaveChangesTitle: () => i18n.translate('controls.controlGroup.manageControl.saveChangesTitle', { defaultMessage: 'Save and close', @@ -64,6 +76,14 @@ export const ControlGroupStrings = { i18n.translate('controls.controlGroup.manageControl.cancelTitle', { defaultMessage: 'Cancel', }), + getSelectFieldMessage: () => + i18n.translate('controls.controlGroup.manageControl.selectFieldMessage', { + defaultMessage: 'Please select a field', + }), + getSelectDataViewMessage: () => + i18n.translate('controls.controlGroup.manageControl.selectDataViewMessage', { + defaultMessage: 'Please select a data view', + }), getGrowSwitchTitle: () => i18n.translate('controls.controlGroup.manageControl.growSwitchTitle', { defaultMessage: 'Expand width to fit available space', diff --git a/src/plugins/controls/public/control_group/editor/control_editor.tsx b/src/plugins/controls/public/control_group/editor/control_editor.tsx index fdf99dc0f9c48..4f52ef67ed7b1 100644 --- a/src/plugins/controls/public/control_group/editor/control_editor.tsx +++ b/src/plugins/controls/public/control_group/editor/control_editor.tsx @@ -14,7 +14,9 @@ * Side Public License, v 1. */ -import React, { useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; +import useMount from 'react-use/lib/useMount'; + import { EuiFlyoutHeader, EuiButtonGroup, @@ -29,32 +31,35 @@ import { EuiForm, EuiButtonEmpty, EuiSpacer, - EuiKeyPadMenu, - EuiKeyPadMenuItem, EuiIcon, - EuiToolTip, EuiSwitch, + EuiTextColor, } from '@elastic/eui'; +import { DataViewListItem, DataView, DataViewField } from '@kbn/data-views-plugin/common'; +import { IFieldSubTypeMulti } from '@kbn/es-query'; +import { + LazyDataViewPicker, + LazyFieldPicker, + withSuspense, +} from '@kbn/presentation-util-plugin/public'; -import { EmbeddableFactoryDefinition } from '@kbn/embeddable-plugin/public'; import { ControlGroupStrings } from '../control_group_strings'; import { ControlEmbeddable, - ControlInput, ControlWidth, + DataControlFieldRegistry, DataControlInput, IEditableControlFactory, } from '../../types'; import { CONTROL_WIDTH_OPTIONS } from './editor_constants'; import { pluginServices } from '../../services'; - interface EditControlProps { - embeddable?: ControlEmbeddable; + embeddable?: ControlEmbeddable; isCreate: boolean; title?: string; width: ControlWidth; + onSave: (type?: string) => void; grow: boolean; - onSave: (type: string) => void; onCancel: () => void; removeControl?: () => void; updateGrow?: (grow: boolean) => void; @@ -62,9 +67,18 @@ interface EditControlProps { updateWidth: (newWidth: ControlWidth) => void; getRelevantDataViewId?: () => string | undefined; setLastUsedDataViewId?: (newDataViewId: string) => void; - onTypeEditorChange: (partial: Partial) => void; + onTypeEditorChange: (partial: Partial) => void; } +interface ControlEditorState { + dataViewListItems: DataViewListItem[]; + selectedDataView?: DataView; + selectedField?: DataViewField; +} + +const FieldPicker = withSuspense(LazyFieldPicker, null); +const DataViewPicker = withSuspense(LazyDataViewPicker, null); + export const ControlEditor = ({ embeddable, isCreate, @@ -81,81 +95,104 @@ export const ControlEditor = ({ getRelevantDataViewId, setLastUsedDataViewId, }: EditControlProps) => { + const { dataViews } = pluginServices.getHooks(); + const { getIdsWithTitle, getDefaultId, get } = dataViews.useService(); + const { controls } = pluginServices.getServices(); const { getControlTypes, getControlFactory } = controls; + const [state, setState] = useState({ + dataViewListItems: [], + }); - const [selectedType, setSelectedType] = useState( - !isCreate && embeddable ? embeddable.type : getControlTypes()[0] - ); const [defaultTitle, setDefaultTitle] = useState(); const [currentTitle, setCurrentTitle] = useState(title); const [currentWidth, setCurrentWidth] = useState(width); const [currentGrow, setCurrentGrow] = useState(grow); const [controlEditorValid, setControlEditorValid] = useState(false); const [selectedField, setSelectedField] = useState( - embeddable - ? (embeddable.getInput() as DataControlInput).fieldName // CLEAN THIS ONCE OTHER PR GETS IN - : undefined + embeddable ? embeddable.getInput().fieldName : undefined ); - const getControlTypeEditor = (type: string) => { - const factory = getControlFactory(type); - const ControlTypeEditor = (factory as IEditableControlFactory).controlEditorComponent; - return ControlTypeEditor ? ( - { - if (!currentTitle || currentTitle === defaultTitle) { - setCurrentTitle(newDefaultTitle); - updateTitle(newDefaultTitle); - } - setDefaultTitle(newDefaultTitle); - }} - /> - ) : null; + const doubleLinkFields = (dataView: DataView) => { + // double link the parent-child relationship specifically for case-sensitivity support for options lists + const fieldRegistry: DataControlFieldRegistry = {}; + + for (const field of dataView.fields.getAll()) { + if (!fieldRegistry[field.name]) { + fieldRegistry[field.name] = { field, compatibleControlTypes: [] }; + } + const parentFieldName = (field.subType as IFieldSubTypeMulti)?.multi?.parent; + if (parentFieldName) { + fieldRegistry[field.name].parentFieldName = parentFieldName; + + const parentField = dataView.getFieldByName(parentFieldName); + if (!fieldRegistry[parentFieldName] && parentField) { + fieldRegistry[parentFieldName] = { field: parentField, compatibleControlTypes: [] }; + } + fieldRegistry[parentFieldName].childFieldName = field.name; + } + } + return fieldRegistry; }; - const getTypeButtons = () => { - return getControlTypes().map((type) => { - const factory = getControlFactory(type); - const icon = (factory as EmbeddableFactoryDefinition).getIconType?.(); - const tooltip = (factory as EmbeddableFactoryDefinition).getDescription?.(); - const menuPadItem = ( - { - setSelectedType(type); - if (!isCreate) - setSelectedField( - embeddable && type === embeddable.type - ? (embeddable.getInput() as DataControlInput).fieldName - : undefined - ); - }} - > - - - ); + const fieldRegistry = useMemo(() => { + if (!state.selectedDataView) return; + const newFieldRegistry: DataControlFieldRegistry = doubleLinkFields(state.selectedDataView); - return tooltip ? ( - - {menuPadItem} - - ) : ( - menuPadItem - ); + const controlFactories = getControlTypes().map( + (controlType) => getControlFactory(controlType) as IEditableControlFactory + ); + state.selectedDataView.fields.map((dataViewField) => { + for (const factory of controlFactories) { + if (factory.isFieldCompatible) { + factory.isFieldCompatible(newFieldRegistry[dataViewField.name]); + } + } + + if (newFieldRegistry[dataViewField.name]?.compatibleControlTypes.length === 0) { + delete newFieldRegistry[dataViewField.name]; + } }); - }; + return newFieldRegistry; + }, [state.selectedDataView, getControlFactory, getControlTypes]); + + useMount(() => { + let mounted = true; + if (selectedField) setDefaultTitle(selectedField); + + (async () => { + const dataViewListItems = await getIdsWithTitle(); + const initialId = + embeddable?.getInput().dataViewId ?? getRelevantDataViewId?.() ?? (await getDefaultId()); + let dataView: DataView | undefined; + if (initialId) { + onTypeEditorChange({ dataViewId: initialId }); + dataView = await get(initialId); + } + if (!mounted) return; + setState((s) => ({ + ...s, + selectedDataView: dataView, + dataViewListItems, + })); + })(); + return () => { + mounted = false; + }; + }); + + useEffect( + () => setControlEditorValid(Boolean(selectedField) && Boolean(state.selectedDataView)), + [selectedField, setControlEditorValid, state.selectedDataView] + ); + + const { selectedDataView: dataView } = state; + const controlType = + selectedField && fieldRegistry && fieldRegistry[selectedField].compatibleControlTypes[0]; + const factory = controlType && getControlFactory(controlType); + const CustomSettings = + factory && (factory as IEditableControlFactory).controlEditorOptionsComponent; return ( <> @@ -169,64 +206,124 @@ export const ControlEditor = ({ + + { + setLastUsedDataViewId?.(dataViewId); + if (dataViewId === dataView?.id) return; + + onTypeEditorChange({ dataViewId }); + setSelectedField(undefined); + get(dataViewId).then((newDataView) => { + setState((s) => ({ ...s, selectedDataView: newDataView })); + }); + }} + trigger={{ + label: + state.selectedDataView?.title ?? + ControlGroupStrings.manageControl.getSelectDataViewMessage(), + }} + /> + + + { + return Boolean(fieldRegistry?.[field.name]); + }} + selectedFieldName={selectedField} + dataView={dataView} + onSelectField={(field) => { + onTypeEditorChange({ + fieldName: field.name, + parentFieldName: fieldRegistry?.[field.name].parentFieldName, + childFieldName: fieldRegistry?.[field.name].childFieldName, + }); + + const newDefaultTitle = field.displayName ?? field.name; + setDefaultTitle(newDefaultTitle); + setSelectedField(field.name); + if (!currentTitle || currentTitle === defaultTitle) { + setCurrentTitle(newDefaultTitle); + updateTitle(newDefaultTitle); + } + }} + /> + - {getTypeButtons()} + {factory ? ( + + + + + + {factory.getDisplayName()} + + + ) : ( + + {ControlGroupStrings.manageControl.getSelectFieldMessage()} + + )} + + + { + updateTitle(e.target.value || defaultTitle); + setCurrentTitle(e.target.value); + }} + /> - {selectedType && ( + + { + setCurrentWidth(newWidth as ControlWidth); + updateWidth(newWidth as ControlWidth); + }} + /> + + {updateGrow ? ( + + { + setCurrentGrow(!currentGrow); + updateGrow(!currentGrow); + }} + data-test-subj="control-editor-grow-switch" + /> + + ) : null} + {CustomSettings && (factory as IEditableControlFactory).controlEditorOptionsComponent && ( + + + + )} + {removeControl && ( <> - {getControlTypeEditor(selectedType)} - - { - updateTitle(e.target.value || defaultTitle); - setCurrentTitle(e.target.value); - }} - /> - - - { - setCurrentWidth(newWidth as ControlWidth); - updateWidth(newWidth as ControlWidth); - }} - /> - - {updateGrow ? ( - - { - setCurrentGrow(!currentGrow); - updateGrow(!currentGrow); - }} - data-test-subj="control-editor-grow-switch" - /> - - ) : null} - {removeControl && ( - { - onCancel(); - removeControl(); - }} - > - {ControlGroupStrings.management.getDeleteButtonTitle()} - - )} + { + onCancel(); + removeControl(); + }} + > + {ControlGroupStrings.management.getDeleteButtonTitle()} + )} @@ -250,7 +347,7 @@ export const ControlEditor = ({ iconType="check" color="primary" disabled={!controlEditorValid} - onClick={() => onSave(selectedType)} + onClick={() => onSave(controlType)} > {ControlGroupStrings.manageControl.getSaveChangesTitle()} diff --git a/src/plugins/controls/public/control_group/editor/create_control.tsx b/src/plugins/controls/public/control_group/editor/create_control.tsx index 2f791ac74d3ae..a3da7071d7ceb 100644 --- a/src/plugins/controls/public/control_group/editor/create_control.tsx +++ b/src/plugins/controls/public/control_group/editor/create_control.tsx @@ -14,7 +14,7 @@ import { toMountPoint } from '@kbn/kibana-react-plugin/public'; import { pluginServices } from '../../services'; import { ControlEditor } from './control_editor'; import { ControlGroupStrings } from '../control_group_strings'; -import { ControlWidth, ControlInput, IEditableControlFactory } from '../../types'; +import { ControlWidth, ControlInput, IEditableControlFactory, DataControlInput } from '../../types'; import { DEFAULT_CONTROL_WIDTH, DEFAULT_CONTROL_GROW, @@ -59,7 +59,7 @@ export const CreateControlButton = ({ const PresentationUtilProvider = pluginServices.getContextProvider(); const initialInputPromise = new Promise((resolve, reject) => { - let inputToReturn: Partial = {}; + let inputToReturn: Partial = {}; const onCancel = (ref: OverlayRef) => { if (Object.keys(inputToReturn).length === 0) { @@ -80,6 +80,21 @@ export const CreateControlButton = ({ }); }; + const onSave = (ref: OverlayRef, type?: string) => { + if (!type) { + reject(); + ref.close(); + return; + } + + const factory = getControlFactory(type) as IEditableControlFactory; + if (factory.presaveTransformFunction) { + inputToReturn = factory.presaveTransformFunction(inputToReturn); + } + resolve({ type, controlInput: inputToReturn }); + ref.close(); + }; + const flyoutInstance = openFlyout( toMountPoint( @@ -92,14 +107,7 @@ export const CreateControlButton = ({ updateTitle={(newTitle) => (inputToReturn.title = newTitle)} updateWidth={updateDefaultWidth} updateGrow={updateDefaultGrow} - onSave={(type: string) => { - const factory = getControlFactory(type) as IEditableControlFactory; - if (factory.presaveTransformFunction) { - inputToReturn = factory.presaveTransformFunction(inputToReturn); - } - resolve({ type, controlInput: inputToReturn }); - flyoutInstance.close(); - }} + onSave={(type) => onSave(flyoutInstance, type)} onCancel={() => onCancel(flyoutInstance)} onTypeEditorChange={(partialInput) => (inputToReturn = { ...inputToReturn, ...partialInput }) diff --git a/src/plugins/controls/public/control_group/editor/edit_control.tsx b/src/plugins/controls/public/control_group/editor/edit_control.tsx index b3fa8834da5e0..370b4f7caa011 100644 --- a/src/plugins/controls/public/control_group/editor/edit_control.tsx +++ b/src/plugins/controls/public/control_group/editor/edit_control.tsx @@ -11,14 +11,19 @@ import { EuiButtonIcon } from '@elastic/eui'; import React, { useEffect, useRef } from 'react'; import { OverlayRef } from '@kbn/core/public'; +import { toMountPoint } from '@kbn/kibana-react-plugin/public'; import { EmbeddableFactoryNotFoundError } from '@kbn/embeddable-plugin/public'; import { useReduxContainerContext } from '@kbn/presentation-util-plugin/public'; import { ControlGroupInput } from '../types'; import { ControlEditor } from './control_editor'; import { pluginServices } from '../../services'; -import { forwardAllContext } from './forward_all_context'; import { ControlGroupStrings } from '../control_group_strings'; -import { IEditableControlFactory, ControlInput } from '../../types'; +import { + IEditableControlFactory, + ControlInput, + DataControlInput, + ControlEmbeddable, +} from '../../types'; import { controlGroupReducers } from '../state/control_group_reducers'; import { ControlGroupContainer, setFlyoutRef } from '../embeddable/control_group_container'; @@ -56,15 +61,19 @@ export const EditControlButton = ({ embeddableId }: { embeddableId: string }) => }, [panels, embeddableId]); const editControl = async () => { - const panel = panels[embeddableId]; - let factory = getControlFactory(panel.type); - if (!factory) throw new EmbeddableFactoryNotFoundError(panel.type); - - const embeddable = await untilEmbeddableLoaded(embeddableId); - const controlGroup = embeddable.getRoot() as ControlGroupContainer; + const PresentationUtilProvider = pluginServices.getContextProvider(); + const embeddable = (await untilEmbeddableLoaded( + embeddableId + )) as ControlEmbeddable; const initialInputPromise = new Promise((resolve, reject) => { - let inputToReturn: Partial = {}; + const panel = panels[embeddableId]; + let factory = getControlFactory(panel.type); + if (!factory) throw new EmbeddableFactoryNotFoundError(panel.type); + + const controlGroup = embeddable.getRoot() as ControlGroupContainer; + + let inputToReturn: Partial = {}; let removed = false; const onCancel = (ref: OverlayRef) => { @@ -94,7 +103,13 @@ export const EditControlButton = ({ embeddableId }: { embeddableId: string }) => }); }; - const onSave = (type: string, ref: OverlayRef) => { + const onSave = (ref: OverlayRef, type?: string) => { + if (!type) { + reject(); + ref.close(); + return; + } + // if the control now has a new type, need to replace the old factory with // one of the correct new type if (latestPanelState.current.type !== type) { @@ -110,44 +125,47 @@ export const EditControlButton = ({ embeddableId }: { embeddableId: string }) => }; const flyoutInstance = openFlyout( - forwardAllContext( - onCancel(flyoutInstance)} - updateTitle={(newTitle) => (inputToReturn.title = newTitle)} - setLastUsedDataViewId={(lastUsed) => controlGroup.setLastUsedDataViewId(lastUsed)} - updateWidth={(newWidth) => dispatch(setControlWidth({ width: newWidth, embeddableId }))} - updateGrow={(grow) => dispatch(setControlGrow({ grow, embeddableId }))} - onTypeEditorChange={(partialInput) => { - inputToReturn = { ...inputToReturn, ...partialInput }; - }} - onSave={(type) => onSave(type, flyoutInstance)} - removeControl={() => { - openConfirm(ControlGroupStrings.management.deleteControls.getSubtitle(), { - confirmButtonText: ControlGroupStrings.management.deleteControls.getConfirm(), - cancelButtonText: ControlGroupStrings.management.deleteControls.getCancel(), - title: ControlGroupStrings.management.deleteControls.getDeleteTitle(), - buttonColor: 'danger', - }).then((confirmed) => { - if (confirmed) { - removeEmbeddable(embeddableId); - removed = true; - flyoutInstance.close(); - } - }); - }} - />, - reduxContainerContext + toMountPoint( + + onCancel(flyoutInstance)} + updateTitle={(newTitle) => (inputToReturn.title = newTitle)} + setLastUsedDataViewId={(lastUsed) => controlGroup.setLastUsedDataViewId(lastUsed)} + updateWidth={(newWidth) => + dispatch(setControlWidth({ width: newWidth, embeddableId })) + } + updateGrow={(grow) => dispatch(setControlGrow({ grow, embeddableId }))} + onTypeEditorChange={(partialInput) => { + inputToReturn = { ...inputToReturn, ...partialInput }; + }} + onSave={(type) => onSave(flyoutInstance, type)} + removeControl={() => { + openConfirm(ControlGroupStrings.management.deleteControls.getSubtitle(), { + confirmButtonText: ControlGroupStrings.management.deleteControls.getConfirm(), + cancelButtonText: ControlGroupStrings.management.deleteControls.getCancel(), + title: ControlGroupStrings.management.deleteControls.getDeleteTitle(), + buttonColor: 'danger', + }).then((confirmed) => { + if (confirmed) { + removeEmbeddable(embeddableId); + removed = true; + flyoutInstance.close(); + } + }); + }} + /> + ), { outsideClickCloses: false, onClose: (flyout) => { - setFlyoutRef(undefined); onCancel(flyout); + setFlyoutRef(undefined); }, } ); diff --git a/src/plugins/controls/public/control_types/options_list/options_list_editor.tsx b/src/plugins/controls/public/control_types/options_list/options_list_editor.tsx deleted file mode 100644 index b6d5a0877d7ce..0000000000000 --- a/src/plugins/controls/public/control_types/options_list/options_list_editor.tsx +++ /dev/null @@ -1,182 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import useMount from 'react-use/lib/useMount'; -import React, { useEffect, useState } from 'react'; - -import { - LazyDataViewPicker, - LazyFieldPicker, - withSuspense, -} from '@kbn/presentation-util-plugin/public'; -import { IFieldSubTypeMulti } from '@kbn/es-query'; -import { EuiFormRow, EuiSwitch } from '@elastic/eui'; -import { DataViewListItem, DataView } from '@kbn/data-views-plugin/common'; - -import { pluginServices } from '../../services'; -import { ControlEditorProps } from '../../types'; -import { OptionsListStrings } from './options_list_strings'; -import { OptionsListEmbeddableInput, OptionsListField } from './types'; -interface OptionsListEditorState { - singleSelect?: boolean; - runPastTimeout?: boolean; - dataViewListItems: DataViewListItem[]; - fieldsMap?: { [key: string]: OptionsListField }; - dataView?: DataView; -} - -const FieldPicker = withSuspense(LazyFieldPicker, null); -const DataViewPicker = withSuspense(LazyDataViewPicker, null); - -export const OptionsListEditor = ({ - onChange, - initialInput, - setValidState, - setDefaultTitle, - getRelevantDataViewId, - setLastUsedDataViewId, - selectedField, - setSelectedField, -}: ControlEditorProps) => { - // Controls Services Context - const { dataViews } = pluginServices.getHooks(); - const { getIdsWithTitle, getDefaultId, get } = dataViews.useService(); - - const [state, setState] = useState({ - singleSelect: initialInput?.singleSelect, - runPastTimeout: initialInput?.runPastTimeout, - dataViewListItems: [], - }); - - useMount(() => { - let mounted = true; - if (selectedField) setDefaultTitle(selectedField); - (async () => { - const dataViewListItems = await getIdsWithTitle(); - const initialId = - initialInput?.dataViewId ?? getRelevantDataViewId?.() ?? (await getDefaultId()); - let dataView: DataView | undefined; - if (initialId) { - onChange({ dataViewId: initialId }); - dataView = await get(initialId); - } - if (!mounted) return; - setState((s) => ({ ...s, dataView, dataViewListItems, fieldsMap: {} })); - })(); - return () => { - mounted = false; - }; - }); - - useEffect(() => { - if (!state.dataView) return; - - // double link the parent-child relationship so that we can filter in fields which are multi-typed to text / keyword - const doubleLinkedFields: OptionsListField[] = state.dataView?.fields.getAll(); - for (const field of doubleLinkedFields) { - const parentFieldName = (field.subType as IFieldSubTypeMulti)?.multi?.parent; - if (parentFieldName) { - (field as OptionsListField).parentFieldName = parentFieldName; - const parentField = state.dataView?.getFieldByName(parentFieldName); - (parentField as OptionsListField).childFieldName = field.name; - } - } - - const newFieldsMap: OptionsListEditorState['fieldsMap'] = {}; - for (const field of doubleLinkedFields) { - if (field.type === 'boolean') { - newFieldsMap[field.name] = field; - } - - // field type is keyword, check if this field is related to a text mapped field and include it. - else if (field.aggregatable && field.type === 'string') { - const childField = - (field.childFieldName && state.dataView?.fields.getByName(field.childFieldName)) || - undefined; - const parentField = - (field.parentFieldName && state.dataView?.fields.getByName(field.parentFieldName)) || - undefined; - - const textFieldName = childField?.esTypes?.includes('text') - ? childField.name - : parentField?.esTypes?.includes('text') - ? parentField.name - : undefined; - - newFieldsMap[field.name] = { ...field, textFieldName } as OptionsListField; - } - } - setState((s) => ({ ...s, fieldsMap: newFieldsMap })); - }, [state.dataView]); - - useEffect( - () => setValidState(Boolean(selectedField) && Boolean(state.dataView)), - [selectedField, setValidState, state.dataView] - ); - - const { dataView } = state; - return ( - <> - - { - setLastUsedDataViewId?.(dataViewId); - if (dataViewId === dataView?.id) return; - - onChange({ dataViewId }); - setSelectedField(undefined); - get(dataViewId).then((newDataView) => { - setState((s) => ({ ...s, dataView: newDataView })); - }); - }} - trigger={{ - label: state.dataView?.title ?? OptionsListStrings.editor.getNoDataViewTitle(), - }} - /> - - - Boolean(state.fieldsMap?.[field.name])} - selectedFieldName={selectedField} - dataView={dataView} - onSelectField={(field) => { - setDefaultTitle(field.displayName ?? field.name); - const textFieldName = state.fieldsMap?.[field.name].textFieldName; - onChange({ - fieldName: field.name, - textFieldName, - }); - setSelectedField(field.name); - }} - /> - - - { - onChange({ singleSelect: !state.singleSelect }); - setState((s) => ({ ...s, singleSelect: !s.singleSelect })); - }} - /> - - - { - onChange({ runPastTimeout: !state.runPastTimeout }); - setState((s) => ({ ...s, runPastTimeout: !s.runPastTimeout })); - }} - /> - - - ); -}; diff --git a/src/plugins/controls/public/control_types/options_list/options_list_editor_options.tsx b/src/plugins/controls/public/control_types/options_list/options_list_editor_options.tsx new file mode 100644 index 0000000000000..e09d1887aac1f --- /dev/null +++ b/src/plugins/controls/public/control_types/options_list/options_list_editor_options.tsx @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useState } from 'react'; +import { EuiFormRow, EuiSwitch } from '@elastic/eui'; + +import { OptionsListEmbeddableInput } from './types'; +import { OptionsListStrings } from './options_list_strings'; +import { ControlEditorProps } from '../..'; + +interface OptionsListEditorState { + singleSelect?: boolean; + runPastTimeout?: boolean; +} + +export const OptionsListEditorOptions = ({ + initialInput, + onChange, +}: ControlEditorProps) => { + const [state, setState] = useState({ + singleSelect: initialInput?.singleSelect, + runPastTimeout: initialInput?.runPastTimeout, + }); + + return ( + <> + + { + onChange({ singleSelect: !state.singleSelect }); + setState((s) => ({ ...s, singleSelect: !s.singleSelect })); + }} + /> + + + { + onChange({ runPastTimeout: !state.runPastTimeout }); + setState((s) => ({ ...s, runPastTimeout: !s.runPastTimeout })); + }} + /> + + + ); +}; diff --git a/src/plugins/controls/public/control_types/options_list/options_list_embeddable.tsx b/src/plugins/controls/public/control_types/options_list/options_list_embeddable.tsx index edf4cb6ddaff1..0376776121eea 100644 --- a/src/plugins/controls/public/control_types/options_list/options_list_embeddable.tsx +++ b/src/plugins/controls/public/control_types/options_list/options_list_embeddable.tsx @@ -179,7 +179,8 @@ export class OptionsListEmbeddable extends Embeddable => { - const { dataViewId, fieldName, textFieldName } = this.getInput(); + const { dataViewId, fieldName, parentFieldName, childFieldName } = this.getInput(); + if (!this.dataView || this.dataView.id !== dataViewId) { this.dataView = await this.dataViewsService.get(dataViewId); if (this.dataView === undefined) { @@ -192,6 +193,16 @@ export class OptionsListEmbeddable extends Embeddable { + if ( + (dataControlField.field.aggregatable && dataControlField.field.type === 'string') || + dataControlField.field.type === 'boolean' + ) { + dataControlField.compatibleControlTypes.push(this.type); + } + }; + + public controlEditorOptionsComponent = OptionsListEditorOptions; public isEditable = () => Promise.resolve(false); diff --git a/src/plugins/controls/public/control_types/range_slider/range_slider_editor.tsx b/src/plugins/controls/public/control_types/range_slider/range_slider_editor.tsx deleted file mode 100644 index 13f688c5dd318..0000000000000 --- a/src/plugins/controls/public/control_types/range_slider/range_slider_editor.tsx +++ /dev/null @@ -1,111 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import useMount from 'react-use/lib/useMount'; -import React, { useEffect, useState } from 'react'; -import { EuiFormRow } from '@elastic/eui'; - -import { DataViewListItem, DataView } from '@kbn/data-views-plugin/common'; -import { - LazyDataViewPicker, - LazyFieldPicker, - withSuspense, -} from '@kbn/presentation-util-plugin/public'; -import { pluginServices } from '../../services'; -import { ControlEditorProps } from '../../types'; -import { RangeSliderEmbeddableInput } from './types'; -import { RangeSliderStrings } from './range_slider_strings'; - -interface RangeSliderEditorState { - dataViewListItems: DataViewListItem[]; - dataView?: DataView; -} - -const FieldPicker = withSuspense(LazyFieldPicker, null); -const DataViewPicker = withSuspense(LazyDataViewPicker, null); - -export const RangeSliderEditor = ({ - onChange, - initialInput, - setValidState, - setDefaultTitle, - getRelevantDataViewId, - setLastUsedDataViewId, - selectedField, - setSelectedField, -}: ControlEditorProps) => { - // Controls Services Context - const { dataViews } = pluginServices.getHooks(); - const { getIdsWithTitle, getDefaultId, get } = dataViews.useService(); - - const [state, setState] = useState({ - dataViewListItems: [], - }); - - useMount(() => { - let mounted = true; - if (selectedField) setDefaultTitle(selectedField); - (async () => { - const dataViewListItems = await getIdsWithTitle(); - const initialId = - initialInput?.dataViewId ?? getRelevantDataViewId?.() ?? (await getDefaultId()); - let dataView: DataView | undefined; - if (initialId) { - onChange({ dataViewId: initialId }); - dataView = await get(initialId); - } - if (!mounted) return; - setState((s) => ({ ...s, dataView, dataViewListItems })); - })(); - return () => { - mounted = false; - }; - }); - - useEffect( - () => setValidState(Boolean(selectedField) && Boolean(state.dataView)), - [selectedField, setValidState, state.dataView] - ); - - const { dataView } = state; - return ( - <> - - { - setLastUsedDataViewId?.(dataViewId); - if (dataViewId === dataView?.id) return; - - onChange({ dataViewId }); - setSelectedField(undefined); - get(dataViewId).then((newDataView) => { - setState((s) => ({ ...s, dataView: newDataView })); - }); - }} - trigger={{ - label: state.dataView?.title ?? RangeSliderStrings.editor.getNoDataViewTitle(), - }} - /> - - - field.aggregatable && field.type === 'number'} - selectedFieldName={selectedField} - dataView={dataView} - onSelectField={(field) => { - setDefaultTitle(field.displayName ?? field.name); - onChange({ fieldName: field.name }); - setSelectedField(field.name); - }} - /> - - - ); -}; diff --git a/src/plugins/controls/public/control_types/range_slider/range_slider_embeddable_factory.tsx b/src/plugins/controls/public/control_types/range_slider/range_slider_embeddable_factory.tsx index bd8b8a394988b..962937a8dc500 100644 --- a/src/plugins/controls/public/control_types/range_slider/range_slider_embeddable_factory.tsx +++ b/src/plugins/controls/public/control_types/range_slider/range_slider_embeddable_factory.tsx @@ -9,8 +9,7 @@ import deepEqual from 'fast-deep-equal'; import { EmbeddableFactoryDefinition, IContainer } from '@kbn/embeddable-plugin/public'; -import { RangeSliderEditor } from './range_slider_editor'; -import { ControlEmbeddable, IEditableControlFactory } from '../../types'; +import { ControlEmbeddable, DataControlField, IEditableControlFactory } from '../../types'; import { RangeSliderEmbeddableInput, RANGE_SLIDER_CONTROL } from './types'; import { createRangeSliderExtract, @@ -46,7 +45,11 @@ export class RangeSliderEmbeddableFactory return newInput; }; - public controlEditorComponent = RangeSliderEditor; + public isFieldCompatible = (dataControlField: DataControlField) => { + if (dataControlField.field.aggregatable && dataControlField.field.type === 'number') { + dataControlField.compatibleControlTypes.push(this.type); + } + }; public isEditable = () => Promise.resolve(false); diff --git a/src/plugins/controls/public/control_types/time_slider/time_slider_editor.tsx b/src/plugins/controls/public/control_types/time_slider/time_slider_editor.tsx deleted file mode 100644 index d8f130661983f..0000000000000 --- a/src/plugins/controls/public/control_types/time_slider/time_slider_editor.tsx +++ /dev/null @@ -1,110 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import useMount from 'react-use/lib/useMount'; -import React, { useEffect, useState } from 'react'; -import { EuiFormRow } from '@elastic/eui'; - -import { DataViewListItem, DataView } from '@kbn/data-views-plugin/common'; -import { - LazyDataViewPicker, - LazyFieldPicker, - withSuspense, -} from '@kbn/presentation-util-plugin/public'; -import { pluginServices } from '../../services'; -import { ControlEditorProps } from '../../types'; -import { TimeSliderStrings } from './time_slider_strings'; - -interface TimeSliderEditorState { - dataViewListItems: DataViewListItem[]; - dataView?: DataView; -} - -const FieldPicker = withSuspense(LazyFieldPicker, null); -const DataViewPicker = withSuspense(LazyDataViewPicker, null); - -export const TimeSliderEditor = ({ - onChange, - initialInput, - setValidState, - setDefaultTitle, - getRelevantDataViewId, - setLastUsedDataViewId, - selectedField, - setSelectedField, -}: ControlEditorProps) => { - // Controls Services Context - const { dataViews } = pluginServices.getHooks(); - const { getIdsWithTitle, getDefaultId, get } = dataViews.useService(); - - const [state, setState] = useState({ - dataViewListItems: [], - }); - - useMount(() => { - let mounted = true; - if (selectedField) setDefaultTitle(selectedField); - (async () => { - const dataViewListItems = await getIdsWithTitle(); - const initialId = - initialInput?.dataViewId ?? getRelevantDataViewId?.() ?? (await getDefaultId()); - let dataView: DataView | undefined; - if (initialId) { - onChange({ dataViewId: initialId }); - dataView = await get(initialId); - } - if (!mounted) return; - setState((s) => ({ ...s, dataView, dataViewListItems })); - })(); - return () => { - mounted = false; - }; - }); - - useEffect( - () => setValidState(Boolean(selectedField) && Boolean(state.dataView)), - [selectedField, setValidState, state.dataView] - ); - - const { dataView } = state; - return ( - <> - - { - setLastUsedDataViewId?.(dataViewId); - if (dataViewId === dataView?.id) return; - - onChange({ dataViewId }); - setSelectedField(undefined); - get(dataViewId).then((newDataView) => { - setState((s) => ({ ...s, dataView: newDataView })); - }); - }} - trigger={{ - label: state.dataView?.title ?? TimeSliderStrings.editor.getNoDataViewTitle(), - }} - /> - - - field.type === 'date'} - selectedFieldName={selectedField} - dataView={dataView} - onSelectField={(field) => { - setDefaultTitle(field.displayName ?? field.name); - onChange({ fieldName: field.name }); - setSelectedField(field.name); - }} - /> - - - ); -}; diff --git a/src/plugins/controls/public/control_types/time_slider/time_slider_embeddable_factory.tsx b/src/plugins/controls/public/control_types/time_slider/time_slider_embeddable_factory.tsx index a49a0b85818f2..6fad0139b98e2 100644 --- a/src/plugins/controls/public/control_types/time_slider/time_slider_embeddable_factory.tsx +++ b/src/plugins/controls/public/control_types/time_slider/time_slider_embeddable_factory.tsx @@ -10,12 +10,11 @@ import deepEqual from 'fast-deep-equal'; import { EmbeddableFactoryDefinition, IContainer } from '@kbn/embeddable-plugin/public'; import { TIME_SLIDER_CONTROL } from '../..'; -import { ControlEmbeddable, IEditableControlFactory } from '../../types'; +import { ControlEmbeddable, DataControlField, IEditableControlFactory } from '../../types'; import { createOptionsListExtract, createOptionsListInject, } from '../../../common/control_types/options_list/options_list_persistable_state'; -import { TimeSliderEditor } from './time_slider_editor'; import { TimeSliderControlEmbeddableInput } from '../../../common/control_types/time_slider/types'; import { TimeSliderStrings } from './time_slider_strings'; @@ -48,7 +47,11 @@ export class TimesliderEmbeddableFactory return newInput; }; - public controlEditorComponent = TimeSliderEditor; + public isFieldCompatible = (dataControlField: DataControlField) => { + if (dataControlField.field.type === 'date') { + dataControlField.compatibleControlTypes.push(this.type); + } + }; public isEditable = () => Promise.resolve(false); diff --git a/src/plugins/controls/public/plugin.ts b/src/plugins/controls/public/plugin.ts index 9b0d754b3f150..352ed60b554a2 100644 --- a/src/plugins/controls/public/plugin.ts +++ b/src/plugins/controls/public/plugin.ts @@ -61,10 +61,11 @@ export class ControlsPlugin factoryDef: IEditableControlFactory, factory: EmbeddableFactory ) { - (factory as IEditableControlFactory).controlEditorComponent = - factoryDef.controlEditorComponent; + (factory as IEditableControlFactory).controlEditorOptionsComponent = + factoryDef.controlEditorOptionsComponent ?? undefined; (factory as IEditableControlFactory).presaveTransformFunction = factoryDef.presaveTransformFunction; + (factory as IEditableControlFactory).isFieldCompatible = factoryDef.isFieldCompatible; } public setup( diff --git a/src/plugins/controls/public/types.ts b/src/plugins/controls/public/types.ts index 4ab4db2eec037..71436fa9926e0 100644 --- a/src/plugins/controls/public/types.ts +++ b/src/plugins/controls/public/types.ts @@ -16,7 +16,7 @@ import { IEmbeddable, } from '@kbn/embeddable-plugin/public'; import { DataPublicPluginStart } from '@kbn/data-plugin/public'; -import { DataView, DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; +import { DataView, DataViewField, DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; import { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public'; import { ControlInput } from '../common/types'; import { ControlsService } from './services/controls'; @@ -28,7 +28,11 @@ export interface CommonControlOutput { export type ControlOutput = EmbeddableOutput & CommonControlOutput; -export type ControlFactory = EmbeddableFactory; +export type ControlFactory = EmbeddableFactory< + ControlInput, + ControlOutput, + ControlEmbeddable +>; export type ControlEmbeddable< TControlEmbeddableInput extends ControlInput = ControlInput, @@ -39,21 +43,28 @@ export type ControlEmbeddable< * Control embeddable editor types */ export interface IEditableControlFactory { - controlEditorComponent?: (props: ControlEditorProps) => JSX.Element; + controlEditorOptionsComponent?: (props: ControlEditorProps) => JSX.Element; presaveTransformFunction?: ( newState: Partial, embeddable?: ControlEmbeddable ) => Partial; + isFieldCompatible?: (dataControlField: DataControlField) => void; // reducer } + export interface ControlEditorProps { initialInput?: Partial; - getRelevantDataViewId?: () => string | undefined; - setLastUsedDataViewId?: (newId: string) => void; onChange: (partial: Partial) => void; - setValidState: (valid: boolean) => void; - setDefaultTitle: (defaultTitle: string) => void; - selectedField: string | undefined; - setSelectedField: (newField: string | undefined) => void; +} + +export interface DataControlField { + field: DataViewField; + parentFieldName?: string; + childFieldName?: string; + compatibleControlTypes: string[]; +} + +export interface DataControlFieldRegistry { + [fieldName: string]: DataControlField; } /** diff --git a/test/functional/apps/dashboard_elements/controls/control_group_settings.ts b/test/functional/apps/dashboard_elements/controls/control_group_settings.ts index 23f44575ff45e..4648698ec0b5f 100644 --- a/test/functional/apps/dashboard_elements/controls/control_group_settings.ts +++ b/test/functional/apps/dashboard_elements/controls/control_group_settings.ts @@ -42,7 +42,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('apply new default width and grow', async () => { it('defaults to medium width and grow enabled', async () => { - await dashboardControls.openCreateControlFlyout(OPTIONS_LIST_CONTROL); + await dashboardControls.openCreateControlFlyout(); const mediumWidthButton = await testSubjects.find('control-editor-width-medium'); expect(await mediumWidthButton.elementHasClass('euiButtonGroupButton-isSelected')).to.be( true @@ -70,7 +70,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(await secondControl.elementHasClass('controlFrameWrapper--small')).to.be(true); expect(await secondControl.elementHasClass('euiFlexItem--flexGrowZero')).to.be(true); - await dashboardControls.openCreateControlFlyout(OPTIONS_LIST_CONTROL); + await dashboardControls.openCreateControlFlyout(); const smallWidthButton = await testSubjects.find('control-editor-width-small'); expect(await smallWidthButton.elementHasClass('euiButtonGroupButton-isSelected')).to.be( true diff --git a/test/functional/apps/dashboard_elements/controls/options_list.ts b/test/functional/apps/dashboard_elements/controls/options_list.ts index 17a028a39464e..162444883873a 100644 --- a/test/functional/apps/dashboard_elements/controls/options_list.ts +++ b/test/functional/apps/dashboard_elements/controls/options_list.ts @@ -110,7 +110,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(await saveButton.isEnabled()).to.be(true); await dashboardControls.controlsEditorSetDataView('animals-*'); expect(await saveButton.isEnabled()).to.be(false); - await dashboardControls.controlsEditorSetfield('animal.keyword'); + await dashboardControls.controlsEditorSetfield('animal.keyword', OPTIONS_LIST_CONTROL); await dashboardControls.controlEditorSave(); // when creating a new filter, the ability to select a data view should be removed, because the dashboard now only has one data view @@ -129,7 +129,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await dashboardControls.optionsListEnsurePopoverIsClosed(secondId); await dashboardControls.editExistingControl(secondId); - await dashboardControls.controlsEditorSetfield('animal.keyword'); + await dashboardControls.controlsEditorSetfield('animal.keyword', OPTIONS_LIST_CONTROL); await dashboardControls.controlEditorSave(); const selectionString = await dashboardControls.optionsListGetSelectionsString(secondId); diff --git a/test/functional/apps/dashboard_elements/controls/range_slider.ts b/test/functional/apps/dashboard_elements/controls/range_slider.ts index a4b84206bde84..9cc390fbe405a 100644 --- a/test/functional/apps/dashboard_elements/controls/range_slider.ts +++ b/test/functional/apps/dashboard_elements/controls/range_slider.ts @@ -121,7 +121,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(await saveButton.isEnabled()).to.be(true); await dashboardControls.controlsEditorSetDataView('kibana_sample_data_flights'); expect(await saveButton.isEnabled()).to.be(false); - await dashboardControls.controlsEditorSetfield('dayOfWeek'); + await dashboardControls.controlsEditorSetfield('dayOfWeek', RANGE_SLIDER_CONTROL); await dashboardControls.controlEditorSave(); await dashboardControls.rangeSliderWaitForLoading(); validateRange('placeholder', firstId, '0', '6'); @@ -164,7 +164,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('editing field clears selections', async () => { const secondId = (await dashboardControls.getAllControlIds())[1]; await dashboardControls.editExistingControl(secondId); - await dashboardControls.controlsEditorSetfield('FlightDelayMin'); + await dashboardControls.controlsEditorSetfield('FlightDelayMin', RANGE_SLIDER_CONTROL); await dashboardControls.controlEditorSave(); await dashboardControls.rangeSliderWaitForLoading(); diff --git a/test/functional/apps/dashboard_elements/controls/replace_controls.ts b/test/functional/apps/dashboard_elements/controls/replace_controls.ts index f6af399905077..3697300e1b7d3 100644 --- a/test/functional/apps/dashboard_elements/controls/replace_controls.ts +++ b/test/functional/apps/dashboard_elements/controls/replace_controls.ts @@ -6,8 +6,6 @@ * Side Public License, v 1. */ -import expect from '@kbn/expect'; - import { OPTIONS_LIST_CONTROL, RANGE_SLIDER_CONTROL, @@ -28,24 +26,20 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { 'header', ]); - const changeFieldType = async (newField: string) => { - const saveButton = await testSubjects.find('control-editor-save'); - expect(await saveButton.isEnabled()).to.be(false); - await dashboardControls.controlsEditorSetfield(newField); - expect(await saveButton.isEnabled()).to.be(true); + const changeFieldType = async (controlId: string, newField: string, expectedType?: string) => { + await dashboardControls.editExistingControl(controlId); + await dashboardControls.controlsEditorSetfield(newField, expectedType); await dashboardControls.controlEditorSave(); }; const replaceWithOptionsList = async (controlId: string) => { - await dashboardControls.controlEditorSetType(OPTIONS_LIST_CONTROL); - await changeFieldType('sound.keyword'); + await changeFieldType(controlId, 'sound.keyword', OPTIONS_LIST_CONTROL); await testSubjects.waitForEnabled(`optionsList-control-${controlId}`); await dashboardControls.verifyControlType(controlId, 'optionsList-control'); }; const replaceWithRangeSlider = async (controlId: string) => { - await dashboardControls.controlEditorSetType(RANGE_SLIDER_CONTROL); - await changeFieldType('weightLbs'); + await changeFieldType(controlId, 'weightLbs', RANGE_SLIDER_CONTROL); await retry.try(async () => { await dashboardControls.rangeSliderWaitForLoading(); await dashboardControls.verifyControlType(controlId, 'range-slider-control'); @@ -53,8 +47,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }; const replaceWithTimeSlider = async (controlId: string) => { - await dashboardControls.controlEditorSetType(TIME_SLIDER_CONTROL); - await changeFieldType('@timestamp'); + await changeFieldType(controlId, '@timestamp', TIME_SLIDER_CONTROL); await testSubjects.waitForDeleted('timeSlider-loading-spinner'); await dashboardControls.verifyControlType(controlId, 'timeSlider'); }; @@ -78,7 +71,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { fieldName: 'sound.keyword', }); controlId = (await dashboardControls.getAllControlIds())[0]; - await dashboardControls.editExistingControl(controlId); }); it('with range slider', async () => { @@ -102,7 +94,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); await dashboardControls.rangeSliderWaitForLoading(); controlId = (await dashboardControls.getAllControlIds())[0]; - await dashboardControls.editExistingControl(controlId); }); it('with options list', async () => { @@ -124,7 +115,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); await testSubjects.waitForDeleted('timeSlider-loading-spinner'); controlId = (await dashboardControls.getAllControlIds())[0]; - await dashboardControls.editExistingControl(controlId); }); it('with options list', async () => { diff --git a/test/functional/page_objects/dashboard_page_controls.ts b/test/functional/page_objects/dashboard_page_controls.ts index f0438b391ac93..2f8f21c73692e 100644 --- a/test/functional/page_objects/dashboard_page_controls.ts +++ b/test/functional/page_objects/dashboard_page_controls.ts @@ -7,12 +7,22 @@ */ import expect from '@kbn/expect'; -import { OPTIONS_LIST_CONTROL, ControlWidth } from '@kbn/controls-plugin/common'; +import { + OPTIONS_LIST_CONTROL, + RANGE_SLIDER_CONTROL, + ControlWidth, +} from '@kbn/controls-plugin/common'; import { ControlGroupChainingSystem } from '@kbn/controls-plugin/common/control_group/types'; import { WebElementWrapper } from '../services/lib/web_element_wrapper'; import { FtrService } from '../ftr_provider_context'; +const CONTROL_DISPLAY_NAMES: { [key: string]: string } = { + default: 'Please select a field', + [OPTIONS_LIST_CONTROL]: 'Options list', + [RANGE_SLIDER_CONTROL]: 'Range slider', +}; + export class DashboardPageControls extends FtrService { private readonly log = this.ctx.getService('log'); private readonly find = this.ctx.getService('find'); @@ -78,14 +88,14 @@ export class DashboardPageControls extends FtrService { } } - public async openCreateControlFlyout(type: string) { - this.log.debug(`Opening flyout for ${type} control`); + public async openCreateControlFlyout() { + this.log.debug(`Opening flyout for creating a control`); await this.testSubjects.click('dashboard-controls-menu-button'); await this.testSubjects.click('controls-create-button'); await this.retry.try(async () => { await this.testSubjects.existOrFail('control-editor-flyout'); }); - await this.controlEditorSetType(type); + await this.controlEditorVerifyType('default'); } /* ----------------------------------------------------------- @@ -238,10 +248,12 @@ export class DashboardPageControls extends FtrService { grow?: boolean; }) { this.log.debug(`Creating ${controlType} control ${title ?? fieldName}`); - await this.openCreateControlFlyout(controlType); + await this.openCreateControlFlyout(); if (dataViewTitle) await this.controlsEditorSetDataView(dataViewTitle); - if (fieldName) await this.controlsEditorSetfield(fieldName); + + if (fieldName) await this.controlsEditorSetfield(fieldName, controlType); + if (title) await this.controlEditorSetTitle(title); if (width) await this.controlEditorSetWidth(width); if (grow !== undefined) await this.controlEditorSetGrow(grow); @@ -377,6 +389,9 @@ export class DashboardPageControls extends FtrService { public async controlEditorSave() { this.log.debug(`Saving changes in control editor`); await this.testSubjects.click(`control-editor-save`); + await this.retry.waitFor('flyout to close', async () => { + return !(await this.testSubjects.exists('control-editor-flyout')); + }); } public async controlEditorCancel(confirm?: boolean) { @@ -396,7 +411,11 @@ export class DashboardPageControls extends FtrService { await this.testSubjects.click(`data-view-picker-${dataViewTitle}`); } - public async controlsEditorSetfield(fieldName: string, shouldSearch: boolean = false) { + public async controlsEditorSetfield( + fieldName: string, + expectedType?: string, + shouldSearch: boolean = false + ) { this.log.debug(`Setting control field to ${fieldName}`); if (shouldSearch) { await this.testSubjects.setValue('field-search-input', fieldName); @@ -405,17 +424,19 @@ export class DashboardPageControls extends FtrService { await this.testSubjects.existOrFail(`field-picker-select-${fieldName}`); }); await this.testSubjects.click(`field-picker-select-${fieldName}`); + if (expectedType) await this.controlEditorVerifyType(expectedType); } - public async controlEditorSetType(type: string) { - this.log.debug(`Setting control type to ${type}`); - await this.testSubjects.click(`create-${type}-control`); + public async controlEditorVerifyType(type: string) { + this.log.debug(`Verifying that the control editor picked the type ${type}`); + const autoSelectedType = await this.testSubjects.getVisibleText('control-editor-type'); + expect(autoSelectedType).to.equal(CONTROL_DISPLAY_NAMES[type]); } // Options List editor functions public async optionsListEditorGetCurrentDataView(openAndCloseFlyout?: boolean) { if (openAndCloseFlyout) { - await this.openCreateControlFlyout(OPTIONS_LIST_CONTROL); + await this.openCreateControlFlyout(); } const dataViewName = (await this.testSubjects.find('open-data-view-picker')).getVisibleText(); if (openAndCloseFlyout) { From a80bfb7283ea8a648514c248a8047b16f46bded6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Thu, 19 May 2022 17:18:21 +0100 Subject: [PATCH 13/35] [Content management] Add "Last updated" metadata to TableListView (#132321) --- .../public/services/saved_object_loader.ts | 20 ++- .../table_list_view.test.tsx.snap | 7 +- .../table_list_view/table_list_view.test.tsx | 170 +++++++++++++++++- .../table_list_view/table_list_view.tsx | 158 ++++++++++++---- .../public/utils/saved_visualize_utils.ts | 4 + .../vis_types/vis_type_alias_registry.ts | 4 +- .../public/helpers/saved_workspace_utils.ts | 1 + x-pack/plugins/lens/public/vis_type_alias.ts | 3 +- .../maps/common/map_saved_object_type.ts | 4 - .../maps/public/maps_vis_type_alias.ts | 8 +- .../routes/list_page/maps_list_view.tsx | 1 + .../maps/server/maps_telemetry/find_maps.ts | 6 +- .../index_pattern_stats_collector.ts | 5 +- 13 files changed, 334 insertions(+), 57 deletions(-) diff --git a/src/plugins/dashboard/public/services/saved_object_loader.ts b/src/plugins/dashboard/public/services/saved_object_loader.ts index 3c406357c0294..780daa2939aa4 100644 --- a/src/plugins/dashboard/public/services/saved_object_loader.ts +++ b/src/plugins/dashboard/public/services/saved_object_loader.ts @@ -98,12 +98,16 @@ export class SavedObjectLoader { mapHitSource( source: Record, id: string, - references: SavedObjectReference[] = [] - ) { - source.id = id; - source.url = this.urlFor(id); - source.references = references; - return source; + references: SavedObjectReference[] = [], + updatedAt?: string + ): Record { + return { + ...source, + id, + url: this.urlFor(id), + references, + updatedAt, + }; } /** @@ -116,12 +120,14 @@ export class SavedObjectLoader { attributes, id, references = [], + updatedAt, }: { attributes: Record; id: string; references?: SavedObjectReference[]; + updatedAt?: string; }) { - return this.mapHitSource(attributes, id, references); + return this.mapHitSource(attributes, id, references, updatedAt); } /** diff --git a/src/plugins/kibana_react/public/table_list_view/__snapshots__/table_list_view.test.tsx.snap b/src/plugins/kibana_react/public/table_list_view/__snapshots__/table_list_view.test.tsx.snap index a0c34cfdfee07..2ad9af679e8c6 100644 --- a/src/plugins/kibana_react/public/table_list_view/__snapshots__/table_list_view.test.tsx.snap +++ b/src/plugins/kibana_react/public/table_list_view/__snapshots__/table_list_view.test.tsx.snap @@ -129,6 +129,7 @@ exports[`TableListView render list view 1`] = ` } /> } + onChange={[Function]} pagination={ Object { "initialPageIndex": 0, @@ -155,7 +156,11 @@ exports[`TableListView render list view 1`] = ` "toolsLeft": undefined, } } - sorting={true} + sorting={ + Object { + "sort": undefined, + } + } tableCaption="test caption" tableLayout="fixed" /> diff --git a/src/plugins/kibana_react/public/table_list_view/table_list_view.test.tsx b/src/plugins/kibana_react/public/table_list_view/table_list_view.test.tsx index 13423047bc3f0..ba76a6b879e61 100644 --- a/src/plugins/kibana_react/public/table_list_view/table_list_view.test.tsx +++ b/src/plugins/kibana_react/public/table_list_view/table_list_view.test.tsx @@ -7,13 +7,24 @@ */ import { EuiEmptyPrompt } from '@elastic/eui'; -import { shallowWithIntl } from '@kbn/test-jest-helpers'; +import { shallowWithIntl, registerTestBed, TestBed } from '@kbn/test-jest-helpers'; import { ToastsStart } from '@kbn/core/public'; import React from 'react'; +import moment, { Moment } from 'moment'; +import { act } from 'react-dom/test-utils'; import { themeServiceMock, applicationServiceMock } from '@kbn/core/public/mocks'; -import { TableListView } from './table_list_view'; +import { TableListView, TableListViewProps } from './table_list_view'; -const requiredProps = { +jest.mock('lodash', () => { + const original = jest.requireActual('lodash'); + + return { + ...original, + debounce: (handler: () => void) => handler, + }; +}); + +const requiredProps: TableListViewProps> = { entityName: 'test', entityNamePlural: 'tests', listingLimit: 5, @@ -30,6 +41,14 @@ const requiredProps = { }; describe('TableListView', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + test('render default empty prompt', async () => { const component = shallowWithIntl(); @@ -81,4 +100,149 @@ describe('TableListView', () => { expect(component).toMatchSnapshot(); }); + + describe('default columns', () => { + let testBed: TestBed; + + const tableColumns = [ + { + field: 'title', + name: 'Title', + sortable: true, + }, + { + field: 'description', + name: 'Description', + sortable: true, + }, + ]; + + const twoDaysAgo = new Date(new Date().setDate(new Date().getDate() - 2)); + const yesterday = new Date(new Date().setDate(new Date().getDate() - 1)); + + const hits = [ + { + title: 'Item 1', + description: 'Item 1 description', + updatedAt: twoDaysAgo, + }, + { + title: 'Item 2', + description: 'Item 2 description', + // This is the latest updated and should come first in the table + updatedAt: yesterday, + }, + ]; + + const findItems = jest.fn(() => Promise.resolve({ total: hits.length, hits })); + + const defaultProps: TableListViewProps> = { + ...requiredProps, + tableColumns, + findItems, + createItem: () => undefined, + }; + + const setup = registerTestBed(TableListView, { defaultProps }); + + test('should add a "Last updated" column if "updatedAt" is provided', async () => { + await act(async () => { + testBed = await setup(); + }); + + const { component, table } = testBed!; + component.update(); + + const { tableCellsValues } = table.getMetaData('itemsInMemTable'); + + expect(tableCellsValues).toEqual([ + ['Item 2', 'Item 2 description', 'yesterday'], // Comes first as it is the latest updated + ['Item 1', 'Item 1 description', '2 days ago'], + ]); + }); + + test('should not display relative time for items updated more than 7 days ago', async () => { + const updatedAtValues: Moment[] = []; + + const updatedHits = hits.map(({ title, description }, i) => { + const updatedAt = new Date(new Date().setDate(new Date().getDate() - (7 + i))); + updatedAtValues[i] = moment(updatedAt); + + return { + title, + description, + updatedAt, + }; + }); + + await act(async () => { + testBed = await setup({ + findItems: jest.fn(() => + Promise.resolve({ + total: updatedHits.length, + hits: updatedHits, + }) + ), + }); + }); + + const { component, table } = testBed!; + component.update(); + + const { tableCellsValues } = table.getMetaData('itemsInMemTable'); + + expect(tableCellsValues).toEqual([ + // Renders the datetime with this format: "05/10/2022 @ 2:34 PM" + ['Item 1', 'Item 1 description', updatedAtValues[0].format('LL')], + ['Item 2', 'Item 2 description', updatedAtValues[1].format('LL')], + ]); + }); + + test('should not add a "Last updated" column if no "updatedAt" is provided', async () => { + await act(async () => { + testBed = await setup({ + findItems: jest.fn(() => + Promise.resolve({ + total: hits.length, + hits: hits.map(({ title, description }) => ({ title, description })), + }) + ), + }); + }); + + const { component, table } = testBed!; + component.update(); + + const { tableCellsValues } = table.getMetaData('itemsInMemTable'); + + expect(tableCellsValues).toEqual([ + ['Item 1', 'Item 1 description'], // Sorted by title + ['Item 2', 'Item 2 description'], + ]); + }); + + test('should not display anything if there is no updatedAt metadata for an item', async () => { + await act(async () => { + testBed = await setup({ + findItems: jest.fn(() => + Promise.resolve({ + total: hits.length + 1, + hits: [...hits, { title: 'Item 3', description: 'Item 3 description' }], + }) + ), + }); + }); + + const { component, table } = testBed!; + component.update(); + + const { tableCellsValues } = table.getMetaData('itemsInMemTable'); + + expect(tableCellsValues).toEqual([ + ['Item 2', 'Item 2 description', 'yesterday'], + ['Item 1', 'Item 1 description', '2 days ago'], + ['Item 3', 'Item 3 description', '-'], // Empty column as no updatedAt provided + ]); + }); + }); }); diff --git a/src/plugins/kibana_react/public/table_list_view/table_list_view.tsx b/src/plugins/kibana_react/public/table_list_view/table_list_view.tsx index ece2fa37cc832..5baaaa78b76ec 100644 --- a/src/plugins/kibana_react/public/table_list_view/table_list_view.tsx +++ b/src/plugins/kibana_react/public/table_list_view/table_list_view.tsx @@ -13,16 +13,21 @@ import { EuiConfirmModal, EuiEmptyPrompt, EuiInMemoryTable, + Criteria, + PropertySort, + Direction, EuiLink, EuiSpacer, EuiTableActionsColumnType, SearchFilterConfig, + EuiToolTip, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n-react'; +import { FormattedMessage, FormattedRelative } from '@kbn/i18n-react'; import { ThemeServiceStart, HttpFetchError, ToastsStart, ApplicationStart } from '@kbn/core/public'; import { debounce, keyBy, sortBy, uniq } from 'lodash'; import React from 'react'; +import moment from 'moment'; import { KibanaPageTemplate } from '../page_template'; import { toMountPoint } from '../util'; @@ -64,6 +69,7 @@ export interface TableListViewProps { export interface TableListViewState { items: V[]; hasInitialFetchReturned: boolean; + hasUpdatedAtMetadata: boolean | null; isFetchingItems: boolean; isDeletingItems: boolean; showDeleteModal: boolean; @@ -72,6 +78,10 @@ export interface TableListViewState { filter: string; selectedIds: string[]; totalItems: number; + tableSort?: { + field: keyof V; + direction: Direction; + }; } // saved object client does not support sorting by title because title is only mapped as analyzed @@ -94,10 +104,12 @@ class TableListView extends React.Component< initialPageSize: props.initialPageSize, pageSizeOptions: uniq([10, 20, 50, props.initialPageSize]).sort(), }; + this.state = { items: [], totalItems: 0, hasInitialFetchReturned: false, + hasUpdatedAtMetadata: null, isFetchingItems: false, isDeletingItems: false, showDeleteModal: false, @@ -120,6 +132,28 @@ class TableListView extends React.Component< this.fetchItems(); } + componentDidUpdate(prevProps: TableListViewProps, prevState: TableListViewState) { + if (this.state.hasUpdatedAtMetadata === null && prevState.items !== this.state.items) { + // We check if the saved object have the "updatedAt" metadata + // to render or not that column in the table + const hasUpdatedAtMetadata = Boolean( + this.state.items.find((item: { updatedAt?: string }) => Boolean(item.updatedAt)) + ); + + this.setState((prev) => { + return { + hasUpdatedAtMetadata, + tableSort: hasUpdatedAtMetadata + ? { + field: 'updatedAt' as keyof V, + direction: 'desc' as const, + } + : prev.tableSort, + }; + }); + } + } + debouncedFetch = debounce(async (filter: string) => { try { const response = await this.props.findItems(filter); @@ -420,6 +454,12 @@ class TableListView extends React.Component< ); } + onTableChange(criteria: Criteria) { + if (criteria.sort) { + this.setState({ tableSort: criteria.sort }); + } + } + renderTable() { const { searchFilters } = this.props; @@ -435,24 +475,6 @@ class TableListView extends React.Component< } : undefined; - const actions: EuiTableActionsColumnType['actions'] = [ - { - name: i18n.translate('kibana-react.tableListView.listing.table.editActionName', { - defaultMessage: 'Edit', - }), - description: i18n.translate( - 'kibana-react.tableListView.listing.table.editActionDescription', - { - defaultMessage: 'Edit', - } - ), - icon: 'pencil', - type: 'icon', - enabled: (v) => !(v as unknown as { error: string })?.error, - onClick: this.props.editItem, - }, - ]; - const search = { onChange: this.setFilter.bind(this), toolsLeft: this.renderToolsLeft(), @@ -464,17 +486,6 @@ class TableListView extends React.Component< filters: searchFilters ?? [], }; - const columns = this.props.tableColumns.slice(); - if (this.props.editItem) { - columns.push({ - name: i18n.translate('kibana-react.tableListView.listing.table.actionTitle', { - defaultMessage: 'Actions', - }), - width: '100px', - actions, - }); - } - const noItemsMessage = ( extends React.Component< values={{ entityNamePlural: this.props.entityNamePlural }} /> ); + return ( extends React.Component< ); } + getTableColumns() { + const columns = this.props.tableColumns.slice(); + + // Add "Last update" column + if (this.state.hasUpdatedAtMetadata) { + const renderUpdatedAt = (dateTime?: string) => { + if (!dateTime) { + return ( + + - + + ); + } + const updatedAt = moment(dateTime); + + if (updatedAt.diff(moment(), 'days') > -7) { + return ( + + {(formattedDate: string) => ( + + {formattedDate} + + )} + + ); + } + return ( + + {updatedAt.format('LL')} + + ); + }; + + columns.push({ + field: 'updatedAt', + name: i18n.translate('kibana-react.tableListView.lastUpdatedColumnTitle', { + defaultMessage: 'Last updated', + }), + render: (field: string, record: { updatedAt?: string }) => + renderUpdatedAt(record.updatedAt), + sortable: true, + width: '150px', + }); + } + + // Add "Actions" column + if (this.props.editItem) { + const actions: EuiTableActionsColumnType['actions'] = [ + { + name: i18n.translate('kibana-react.tableListView.listing.table.editActionName', { + defaultMessage: 'Edit', + }), + description: i18n.translate( + 'kibana-react.tableListView.listing.table.editActionDescription', + { + defaultMessage: 'Edit', + } + ), + icon: 'pencil', + type: 'icon', + enabled: (v) => !(v as unknown as { error: string })?.error, + onClick: this.props.editItem, + }, + ]; + + columns.push({ + name: i18n.translate('kibana-react.tableListView.listing.table.actionTitle', { + defaultMessage: 'Actions', + }), + width: '100px', + actions, + }); + } + + return columns; + } + renderCreateButton() { if (this.props.createItem) { return ( diff --git a/src/plugins/visualizations/public/utils/saved_visualize_utils.ts b/src/plugins/visualizations/public/utils/saved_visualize_utils.ts index 5b8ba8ce04cb4..f5444b6269e22 100644 --- a/src/plugins/visualizations/public/utils/saved_visualize_utils.ts +++ b/src/plugins/visualizations/public/utils/saved_visualize_utils.ts @@ -64,10 +64,12 @@ export function mapHitSource( attributes, id, references, + updatedAt, }: { attributes: SavedObjectAttributes; id: string; references: SavedObjectReference[]; + updatedAt?: string; } ) { const newAttributes: { @@ -76,6 +78,7 @@ export function mapHitSource( url: string; savedObjectType?: string; editUrl?: string; + updatedAt?: string; type?: BaseVisType; icon?: BaseVisType['icon']; image?: BaseVisType['image']; @@ -85,6 +88,7 @@ export function mapHitSource( id, references, url: urlFor(id), + updatedAt, ...attributes, }; diff --git a/src/plugins/visualizations/public/vis_types/vis_type_alias_registry.ts b/src/plugins/visualizations/public/vis_types/vis_type_alias_registry.ts index 2945aaa1a0cc8..f113a0a212fe6 100644 --- a/src/plugins/visualizations/public/vis_types/vis_type_alias_registry.ts +++ b/src/plugins/visualizations/public/vis_types/vis_type_alias_registry.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { SavedObject } from '@kbn/core/types/saved_objects'; +import type { SimpleSavedObject } from '@kbn/core/public'; import { BaseVisType } from './base_vis_type'; export type VisualizationStage = 'experimental' | 'beta' | 'production'; @@ -30,7 +30,7 @@ export interface VisualizationListItem { export interface VisualizationsAppExtension { docTypes: string[]; searchFields?: string[]; - toListItem: (savedObject: SavedObject) => VisualizationListItem; + toListItem: (savedObject: SimpleSavedObject) => VisualizationListItem; } export interface VisTypeAlias { diff --git a/x-pack/plugins/graph/public/helpers/saved_workspace_utils.ts b/x-pack/plugins/graph/public/helpers/saved_workspace_utils.ts index 72cca61832ca0..202d13f9cd539 100644 --- a/x-pack/plugins/graph/public/helpers/saved_workspace_utils.ts +++ b/x-pack/plugins/graph/public/helpers/saved_workspace_utils.ts @@ -53,6 +53,7 @@ function mapHits(hit: any, url: string): GraphWorkspaceSavedObject { const source = hit.attributes; source.id = hit.id; source.url = url; + source.updatedAt = hit.updatedAt; source.icon = 'fa-share-alt'; // looks like a graph return source; } diff --git a/x-pack/plugins/lens/public/vis_type_alias.ts b/x-pack/plugins/lens/public/vis_type_alias.ts index be8a5620ce614..11a97ae82470f 100644 --- a/x-pack/plugins/lens/public/vis_type_alias.ts +++ b/x-pack/plugins/lens/public/vis_type_alias.ts @@ -31,12 +31,13 @@ export const getLensAliasConfig = (): VisTypeAlias => ({ docTypes: ['lens'], searchFields: ['title^3'], toListItem(savedObject) { - const { id, type, attributes } = savedObject; + const { id, type, updatedAt, attributes } = savedObject; const { title, description } = attributes as { title: string; description?: string }; return { id, title, description, + updatedAt, editUrl: getEditPath(id), editApp: 'lens', icon: 'lensApp', diff --git a/x-pack/plugins/maps/common/map_saved_object_type.ts b/x-pack/plugins/maps/common/map_saved_object_type.ts index b37c1af5949c1..f16683f56ef6d 100644 --- a/x-pack/plugins/maps/common/map_saved_object_type.ts +++ b/x-pack/plugins/maps/common/map_saved_object_type.ts @@ -7,8 +7,6 @@ /* eslint-disable @typescript-eslint/consistent-type-definitions */ -import { SavedObject } from '@kbn/core/types/saved_objects'; - export type MapSavedObjectAttributes = { title: string; description?: string; @@ -16,5 +14,3 @@ export type MapSavedObjectAttributes = { layerListJSON?: string; uiStateJSON?: string; }; - -export type MapSavedObject = SavedObject; diff --git a/x-pack/plugins/maps/public/maps_vis_type_alias.ts b/x-pack/plugins/maps/public/maps_vis_type_alias.ts index e6dad590b037a..911e886a8199e 100644 --- a/x-pack/plugins/maps/public/maps_vis_type_alias.ts +++ b/x-pack/plugins/maps/public/maps_vis_type_alias.ts @@ -7,8 +7,9 @@ import { i18n } from '@kbn/i18n'; import type { VisualizationsSetup, VisualizationStage } from '@kbn/visualizations-plugin/public'; +import type { SimpleSavedObject } from '@kbn/core/public'; import type { SavedObject } from '@kbn/core/types/saved_objects'; -import type { MapSavedObject } from '../common/map_saved_object_type'; +import type { MapSavedObjectAttributes } from '../common/map_saved_object_type'; import { APP_ID, APP_ICON, @@ -38,12 +39,15 @@ export function getMapsVisTypeAlias(visualizations: VisualizationsSetup) { docTypes: [MAP_SAVED_OBJECT_TYPE], searchFields: ['title^3'], toListItem(savedObject: SavedObject) { - const { id, type, attributes } = savedObject as MapSavedObject; + const { id, type, updatedAt, attributes } = + savedObject as SimpleSavedObject; const { title, description } = attributes; + return { id, title, description, + updatedAt, editUrl: getEditPath(id), editApp: APP_ID, icon: APP_ICON, diff --git a/x-pack/plugins/maps/public/routes/list_page/maps_list_view.tsx b/x-pack/plugins/maps/public/routes/list_page/maps_list_view.tsx index 5aa8e7877628a..9278f08bd4d2d 100644 --- a/x-pack/plugins/maps/public/routes/list_page/maps_list_view.tsx +++ b/x-pack/plugins/maps/public/routes/list_page/maps_list_view.tsx @@ -113,6 +113,7 @@ async function findMaps(searchQuery: string) { title: savedObject.attributes.title, description: savedObject.attributes.description, references: savedObject.references, + updatedAt: savedObject.updatedAt, }; }), }; diff --git a/x-pack/plugins/maps/server/maps_telemetry/find_maps.ts b/x-pack/plugins/maps/server/maps_telemetry/find_maps.ts index cab4b98ffd784..213c1a6cde3ee 100644 --- a/x-pack/plugins/maps/server/maps_telemetry/find_maps.ts +++ b/x-pack/plugins/maps/server/maps_telemetry/find_maps.ts @@ -6,13 +6,13 @@ */ import { asyncForEach } from '@kbn/std'; -import { ISavedObjectsRepository } from '@kbn/core/server'; +import type { ISavedObjectsRepository, SavedObject } from '@kbn/core/server'; import { MAP_SAVED_OBJECT_TYPE } from '../../common/constants'; -import { MapSavedObject, MapSavedObjectAttributes } from '../../common/map_saved_object_type'; +import type { MapSavedObjectAttributes } from '../../common/map_saved_object_type'; export async function findMaps( savedObjectsClient: Pick, - callback: (savedObject: MapSavedObject) => Promise + callback: (savedObject: SavedObject) => Promise ) { let nextPage = 1; let hasMorePages = false; diff --git a/x-pack/plugins/maps/server/maps_telemetry/index_pattern_stats/index_pattern_stats_collector.ts b/x-pack/plugins/maps/server/maps_telemetry/index_pattern_stats/index_pattern_stats_collector.ts index ad1c0239963b4..dcbc9c884275d 100644 --- a/x-pack/plugins/maps/server/maps_telemetry/index_pattern_stats/index_pattern_stats_collector.ts +++ b/x-pack/plugins/maps/server/maps_telemetry/index_pattern_stats/index_pattern_stats_collector.ts @@ -5,6 +5,7 @@ * 2.0. */ +import type { SavedObject } from '@kbn/core/server'; import { asyncForEach } from '@kbn/std'; import { KBN_FIELD_TYPES } from '@kbn/field-types'; import { DataViewsService } from '@kbn/data-views-plugin/common'; @@ -15,7 +16,7 @@ import { ESSearchSourceDescriptor, LayerDescriptor, } from '../../../common/descriptor_types'; -import { MapSavedObject } from '../../../common/map_saved_object_type'; +import type { MapSavedObjectAttributes } from '../../../common/map_saved_object_type'; import { IndexPatternStats } from './types'; /* @@ -29,7 +30,7 @@ export class IndexPatternStatsCollector { this._indexPatternsService = indexPatternService; } - async push(savedObject: MapSavedObject) { + async push(savedObject: SavedObject) { let layerList: LayerDescriptor[] = []; try { const { attributes } = injectReferences(savedObject); From 0e6e381e38d00f56855b987becadf1e8e5c69912 Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Thu, 19 May 2022 13:25:30 -0400 Subject: [PATCH 14/35] [Fleet] display current upgrades (#132379) --- .../plugins/fleet/common/services/routes.ts | 3 + .../fleet/common/types/models/agent.ts | 1 + .../current_bulk_upgrade_callout.tsx | 89 +++++++++++++++ .../agent_list_page/components/index.tsx | 9 ++ .../agents/agent_list_page/hooks/index.tsx | 8 ++ .../hooks/use_current_upgrades.tsx | 108 ++++++++++++++++++ .../sections/agents/agent_list_page/index.tsx | 18 ++- .../fleet/public/hooks/use_request/agents.ts | 15 +++ x-pack/plugins/fleet/public/types/index.ts | 2 + .../fleet/server/services/agents/upgrade.ts | 1 + 10 files changed, 249 insertions(+), 5 deletions(-) create mode 100644 x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/current_bulk_upgrade_callout.tsx create mode 100644 x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/index.tsx create mode 100644 x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/index.tsx create mode 100644 x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/use_current_upgrades.tsx diff --git a/x-pack/plugins/fleet/common/services/routes.ts b/x-pack/plugins/fleet/common/services/routes.ts index d4e8375bbaa5d..a8a6c34f06f3c 100644 --- a/x-pack/plugins/fleet/common/services/routes.ts +++ b/x-pack/plugins/fleet/common/services/routes.ts @@ -175,6 +175,9 @@ export const agentRouteService = { getUpgradePath: (agentId: string) => AGENT_API_ROUTES.UPGRADE_PATTERN.replace('{agentId}', agentId), getBulkUpgradePath: () => AGENT_API_ROUTES.BULK_UPGRADE_PATTERN, + getCurrentUpgradesPath: () => AGENT_API_ROUTES.CURRENT_UPGRADES_PATTERN, + getCancelActionPath: (actionId: string) => + AGENT_API_ROUTES.CANCEL_ACTIONS_PATTERN.replace('{actionId}', actionId), getListPath: () => AGENT_API_ROUTES.LIST_PATTERN, getStatusPath: () => AGENT_API_ROUTES.STATUS_PATTERN, getIncomingDataPath: () => AGENT_API_ROUTES.DATA_PATTERN, diff --git a/x-pack/plugins/fleet/common/types/models/agent.ts b/x-pack/plugins/fleet/common/types/models/agent.ts index b3847ac8c6892..a26f63eba755b 100644 --- a/x-pack/plugins/fleet/common/types/models/agent.ts +++ b/x-pack/plugins/fleet/common/types/models/agent.ts @@ -98,6 +98,7 @@ export interface CurrentUpgrade { complete: boolean; nbAgents: number; nbAgentsAck: number; + version: string; } // Generated from FleetServer schema.json diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/current_bulk_upgrade_callout.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/current_bulk_upgrade_callout.tsx new file mode 100644 index 0000000000000..a77c26f8fef2f --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/current_bulk_upgrade_callout.tsx @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { + EuiCallOut, + EuiLink, + EuiFlexGroup, + EuiFlexItem, + EuiButton, + EuiLoadingSpinner, +} from '@elastic/eui'; + +import { useStartServices } from '../../../../hooks'; +import type { CurrentUpgrade } from '../../../../types'; + +export interface CurrentBulkUpgradeCalloutProps { + currentUpgrade: CurrentUpgrade; + abortUpgrade: (currentUpgrade: CurrentUpgrade) => Promise; +} + +export const CurrentBulkUpgradeCallout: React.FunctionComponent = ({ + currentUpgrade, + abortUpgrade, +}) => { + const { docLinks } = useStartServices(); + const [isAborting, setIsAborting] = useState(false); + const onClickAbortUpgrade = useCallback(async () => { + try { + setIsAborting(true); + await abortUpgrade(currentUpgrade); + } finally { + setIsAborting(false); + } + }, [currentUpgrade, abortUpgrade]); + + return ( + + + +
+ +    + +
+
+ + + + + +
+ + + + ), + }} + /> +
+ ); +}; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/index.tsx new file mode 100644 index 0000000000000..36028c0d2c9b5 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/index.tsx @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { CurrentBulkUpgradeCallout } from './current_bulk_upgrade_callout'; +export type { CurrentBulkUpgradeCalloutProps } from './current_bulk_upgrade_callout'; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/index.tsx new file mode 100644 index 0000000000000..4ab06bfcc8a91 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/index.tsx @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { useCurrentUpgrades } from './use_current_upgrades'; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/use_current_upgrades.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/use_current_upgrades.tsx new file mode 100644 index 0000000000000..02463025c86db --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/use_current_upgrades.tsx @@ -0,0 +1,108 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { i18n } from '@kbn/i18n'; + +import { sendGetCurrentUpgrades, sendPostCancelAction, useStartServices } from '../../../../hooks'; + +import type { CurrentUpgrade } from '../../../../types'; + +const POLL_INTERVAL = 30 * 1000; + +export function useCurrentUpgrades() { + const [currentUpgrades, setCurrentUpgrades] = useState([]); + const currentTimeoutRef = useRef(); + const isCancelledRef = useRef(false); + const { notifications, overlays } = useStartServices(); + + const refreshUpgrades = useCallback(async () => { + try { + const res = await sendGetCurrentUpgrades(); + if (isCancelledRef.current) { + return; + } + if (res.error) { + throw res.error; + } + + if (!res.data) { + throw new Error('No data'); + } + + setCurrentUpgrades(res.data.items); + } catch (err) { + notifications.toasts.addError(err, { + title: i18n.translate('xpack.fleet.currentUpgrade.fetchRequestError', { + defaultMessage: 'An error happened while fetching current upgrades', + }), + }); + } + }, [notifications.toasts]); + + const abortUpgrade = useCallback( + async (currentUpgrade: CurrentUpgrade) => { + try { + const confirmRes = await overlays.openConfirm( + i18n.translate('xpack.fleet.currentUpgrade.confirmDescription', { + defaultMessage: 'This action will abort upgrade of {nbAgents} agents', + values: { + nbAgents: currentUpgrade.nbAgents - currentUpgrade.nbAgentsAck, + }, + }), + { + title: i18n.translate('xpack.fleet.currentUpgrade.confirmTitle', { + defaultMessage: 'Abort upgrade?', + }), + } + ); + + if (!confirmRes) { + return; + } + await sendPostCancelAction(currentUpgrade.actionId); + await refreshUpgrades(); + } catch (err) { + notifications.toasts.addError(err, { + title: i18n.translate('xpack.fleet.currentUpgrade.abortRequestError', { + defaultMessage: 'An error happened while aborting upgrade', + }), + }); + } + }, + [refreshUpgrades, notifications.toasts, overlays] + ); + + // Poll for upgrades + useEffect(() => { + isCancelledRef.current = false; + + async function pollData() { + await refreshUpgrades(); + if (isCancelledRef.current) { + return; + } + currentTimeoutRef.current = setTimeout(() => pollData(), POLL_INTERVAL); + } + + pollData(); + + return () => { + isCancelledRef.current = true; + + if (currentTimeoutRef.current) { + clearTimeout(currentTimeoutRef.current); + } + }; + }, [refreshUpgrades]); + + return { + currentUpgrades, + refreshUpgrades, + abortUpgrade, + }; +} diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx index be38f7688c735..f12a99c6e37f9 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx @@ -46,12 +46,14 @@ import { } from '../components'; import { useFleetServerUnhealthy } from '../hooks/use_fleet_server_unhealthy'; +import { CurrentBulkUpgradeCallout } from './components'; import { AgentTableHeader } from './components/table_header'; import type { SelectionMode } from './components/types'; import { SearchAndFilterBar } from './components/search_and_filter_bar'; import { Tags } from './components/tags'; import { TableRowActions } from './components/table_row_actions'; import { EmptyPrompt } from './components/empty_prompt'; +import { useCurrentUpgrades } from './hooks'; const REFRESH_INTERVAL_MS = 30000; @@ -335,6 +337,9 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { flyoutContext.openFleetServerFlyout(); }, [flyoutContext]); + // Current upgrades + const { abortUpgrade, currentUpgrades, refreshUpgrades } = useCurrentUpgrades(); + const columns = [ { field: 'local_metadata.host.hostname', @@ -490,7 +495,6 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { /> )} - {agentToUpgrade && ( = () => { onClose={() => { setAgentToUpgrade(undefined); fetchData(); + refreshUpgrades(); }} version={kibanaVersion} /> )} - {isFleetServerUnhealthy && ( <> {cloud?.deploymentUrl ? ( @@ -515,7 +519,13 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { )} - + {/* Current upgrades callout */} + {currentUpgrades.map((currentUpgrade) => ( + + + + + ))} {/* Search and filter bar */} = () => { refreshAgents={() => fetchData()} /> - {/* Agent total, bulk actions and status bar */} = () => { }} /> - {/* Agent list table */} ref={tableRef} diff --git a/x-pack/plugins/fleet/public/hooks/use_request/agents.ts b/x-pack/plugins/fleet/public/hooks/use_request/agents.ts index 9bfba13052c35..94390d2f529d2 100644 --- a/x-pack/plugins/fleet/public/hooks/use_request/agents.ts +++ b/x-pack/plugins/fleet/public/hooks/use_request/agents.ts @@ -29,6 +29,7 @@ import type { PostBulkAgentUpgradeResponse, PostNewAgentActionRequest, PostNewAgentActionResponse, + GetCurrentUpgradesResponse, } from '../../types'; import { useRequest, sendRequest } from './use_request'; @@ -177,3 +178,17 @@ export function sendPostBulkAgentUpgrade( ...options, }); } + +export function sendGetCurrentUpgrades() { + return sendRequest({ + path: agentRouteService.getCurrentUpgradesPath(), + method: 'get', + }); +} + +export function sendPostCancelAction(actionId: string) { + return sendRequest({ + path: agentRouteService.getCancelActionPath(actionId), + method: 'post', + }); +} diff --git a/x-pack/plugins/fleet/public/types/index.ts b/x-pack/plugins/fleet/public/types/index.ts index fc29f046aac04..2cd27e81be9d8 100644 --- a/x-pack/plugins/fleet/public/types/index.ts +++ b/x-pack/plugins/fleet/public/types/index.ts @@ -25,6 +25,7 @@ export type { Output, DataStream, Settings, + CurrentUpgrade, GetFleetStatusResponse, GetAgentPoliciesRequest, GetAgentPoliciesResponse, @@ -77,6 +78,7 @@ export type { PostEnrollmentAPIKeyResponse, PostLogstashApiKeyResponse, GetOutputsResponse, + GetCurrentUpgradesResponse, PutOutputRequest, PutOutputResponse, PostOutputRequest, diff --git a/x-pack/plugins/fleet/server/services/agents/upgrade.ts b/x-pack/plugins/fleet/server/services/agents/upgrade.ts index 55c105495fd54..6d0174e064184 100644 --- a/x-pack/plugins/fleet/server/services/agents/upgrade.ts +++ b/x-pack/plugins/fleet/server/services/agents/upgrade.ts @@ -331,6 +331,7 @@ async function _getUpgradeActions(esClient: ElasticsearchClient, now = new Date( nbAgents: 0, complete: false, nbAgentsAck: 0, + version: hit._source.data?.version as string, }; } From 6383b42e5d767325d575fceb40f65f39f242db2a Mon Sep 17 00:00:00 2001 From: Andrea Del Rio Date: Thu, 19 May 2022 10:37:33 -0700 Subject: [PATCH 15/35] [Controls] Improve banner (#132301) --- .../public/control_group/control_group_strings.ts | 7 ++++++- .../public/controls_callout/controls_callout.scss | 9 +++++---- .../public/controls_callout/controls_callout.tsx | 12 +++++++++++- .../controls_callout/controls_illustration.tsx | 14 ++------------ 4 files changed, 24 insertions(+), 18 deletions(-) diff --git a/src/plugins/controls/public/control_group/control_group_strings.ts b/src/plugins/controls/public/control_group/control_group_strings.ts index 23be81f3585d3..cb7b1b2001842 100644 --- a/src/plugins/controls/public/control_group/control_group_strings.ts +++ b/src/plugins/controls/public/control_group/control_group_strings.ts @@ -18,9 +18,14 @@ export const ControlGroupStrings = { defaultMessage: 'Controls', }), emptyState: { + getBadge: () => + i18n.translate('controls.controlGroup.emptyState.badgeText', { + defaultMessage: 'New', + }), getCallToAction: () => i18n.translate('controls.controlGroup.emptyState.callToAction', { - defaultMessage: 'Controls let you filter and interact with your dashboard data', + defaultMessage: + 'Filtering your data just got better with Controls, letting you display only the data you want to explore.', }), getAddControlButtonTitle: () => i18n.translate('controls.controlGroup.emptyState.addControlButtonTitle', { diff --git a/src/plugins/controls/public/controls_callout/controls_callout.scss b/src/plugins/controls/public/controls_callout/controls_callout.scss index e0f7e1481d156..74add651a5237 100644 --- a/src/plugins/controls/public/controls_callout/controls_callout.scss +++ b/src/plugins/controls/public/controls_callout/controls_callout.scss @@ -1,5 +1,5 @@ @include euiBreakpoint('xs', 's') { - .controlsIllustration { + .controlsIllustration, .emptyStateBadge { display: none; } } @@ -15,14 +15,15 @@ } @include euiBreakpoint('m', 'l', 'xl') { - height: $euiSize * 4; + height: $euiSizeS * 6; - .emptyStateText { + .emptyStateBadge { padding-left: $euiSize * 2; + text-transform: uppercase; } } @include euiBreakpoint('xs', 's') { - min-height: $euiSize * 4; + min-height: $euiSizeS * 6; .emptyStateText { padding-left: 0; diff --git a/src/plugins/controls/public/controls_callout/controls_callout.tsx b/src/plugins/controls/public/controls_callout/controls_callout.tsx index 708b224187e1c..b207657cc0288 100644 --- a/src/plugins/controls/public/controls_callout/controls_callout.tsx +++ b/src/plugins/controls/public/controls_callout/controls_callout.tsx @@ -6,7 +6,14 @@ * Side Public License, v 1. */ -import { EuiFlexGroup, EuiFlexItem, EuiText, EuiButtonEmpty, EuiPanel } from '@elastic/eui'; +import { + EuiBadge, + EuiFlexGroup, + EuiFlexItem, + EuiText, + EuiButtonEmpty, + EuiPanel, +} from '@elastic/eui'; import React from 'react'; import useLocalStorage from 'react-use/lib/useLocalStorage'; @@ -39,6 +46,9 @@ export const ControlsCallout = ({ getCreateControlButton }: CalloutProps) => { + + {ControlGroupStrings.emptyState.getBadge()} +

{ControlGroupStrings.emptyState.getCallToAction()}

diff --git a/src/plugins/controls/public/controls_callout/controls_illustration.tsx b/src/plugins/controls/public/controls_callout/controls_illustration.tsx index 925dd90fc8700..39d96ee8ad857 100644 --- a/src/plugins/controls/public/controls_callout/controls_illustration.tsx +++ b/src/plugins/controls/public/controls_callout/controls_illustration.tsx @@ -11,8 +11,8 @@ import React from 'react'; export const ControlsIllustration = () => ( ( fill="#FCC316" d="M67.873 63.635l-2.678 4.641-2.678-4.64-2.678-4.642H70.55l-2.678 4.641z" /> - - - - Date: Thu, 19 May 2022 13:42:10 -0400 Subject: [PATCH 16/35] Display tooltips for long tags, even if there are less than 3 total tags (#132528) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../agents/agent_list_page/components/tags.tsx | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/tags.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/tags.tsx index 9e084b07e64d1..f93646eb120ab 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/tags.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/tags.tsx @@ -9,7 +9,7 @@ import { EuiToolTip } from '@elastic/eui'; import { take } from 'lodash'; import React from 'react'; -import { truncateTag } from '../utils'; +import { truncateTag, MAX_TAG_DISPLAY_LENGTH } from '../utils'; interface Props { tags: string[]; @@ -30,7 +30,20 @@ export const Tags: React.FunctionComponent = ({ tags }) => { ) : ( - {tags.map(truncateTag).join(', ')} + + {tags.map((tag, index) => ( + <> + {index > 0 && ', '} + {tag.length > MAX_TAG_DISPLAY_LENGTH ? ( + {tag}} key={tag}> + {truncateTag(tag)} + + ) : ( + {tag} + )} + + ))} + )} ); From 0dfa6374ba4211f23ad2689555bdae495010dad7 Mon Sep 17 00:00:00 2001 From: Larry Gregory Date: Thu, 19 May 2022 13:48:42 -0400 Subject: [PATCH 17/35] Remove broadcast-channel dependency from security plugin (#132427) * Remove broadcast-channel dependency from security plugin * cleanup * Update x-pack/plugins/security/public/session/session_timeout.ts Co-authored-by: Joe Portner <5295965+jportner@users.noreply.github.com> Co-authored-by: Joe Portner <5295965+jportner@users.noreply.github.com> --- package.json | 1 - packages/kbn-test-jest-helpers/src/index.ts | 2 + .../src/stub_broadcast_channel.ts | 83 +++++++++++++++++++ renovate.json | 1 - .../plugins/security/public/plugin.test.tsx | 11 +-- .../public/session/session_timeout.test.ts | 29 ++++--- .../public/session/session_timeout.ts | 20 +++-- yarn.lock | 46 +--------- 8 files changed, 118 insertions(+), 75 deletions(-) create mode 100644 packages/kbn-test-jest-helpers/src/stub_broadcast_channel.ts diff --git a/package.json b/package.json index 84f9be547e7a1..6330d68c742b1 100644 --- a/package.json +++ b/package.json @@ -229,7 +229,6 @@ "base64-js": "^1.3.1", "bitmap-sdf": "^1.0.3", "brace": "0.11.1", - "broadcast-channel": "4.10.0", "canvg": "^3.0.9", "chalk": "^4.1.0", "cheerio": "^1.0.0-rc.10", diff --git a/packages/kbn-test-jest-helpers/src/index.ts b/packages/kbn-test-jest-helpers/src/index.ts index 809d4380df10a..5e794abdbbb78 100644 --- a/packages/kbn-test-jest-helpers/src/index.ts +++ b/packages/kbn-test-jest-helpers/src/index.ts @@ -18,6 +18,8 @@ export * from './redux_helpers'; export * from './router_helpers'; +export * from './stub_broadcast_channel'; + export * from './stub_browser_storage'; export * from './stub_web_worker'; diff --git a/packages/kbn-test-jest-helpers/src/stub_broadcast_channel.ts b/packages/kbn-test-jest-helpers/src/stub_broadcast_channel.ts new file mode 100644 index 0000000000000..ecf34aa7bb68e --- /dev/null +++ b/packages/kbn-test-jest-helpers/src/stub_broadcast_channel.ts @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +const channelCache: BroadcastChannel[] = []; + +class StubBroadcastChannel implements BroadcastChannel { + constructor(public readonly name: string) { + channelCache.push(this); + } + + onmessage = jest.fn(); + onmessageerror = jest.fn(); + close = jest.fn(); + postMessage = jest.fn().mockImplementation((data: any) => { + channelCache.forEach((channel) => { + if (channel === this) return; // don't postMessage to ourselves + if (channel.onmessage) { + channel.onmessage(new MessageEvent(this.name, { data })); + } + }); + }); + + addEventListener( + type: K, + listener: (this: BroadcastChannel, ev: BroadcastChannelEventMap[K]) => any, + options?: boolean | AddEventListenerOptions + ): void; + addEventListener( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | AddEventListenerOptions + ): void; + addEventListener(type: any, listener: any, options?: any): void { + throw new Error('Method not implemented.'); + } + removeEventListener( + type: K, + listener: (this: BroadcastChannel, ev: BroadcastChannelEventMap[K]) => any, + options?: boolean | EventListenerOptions + ): void; + removeEventListener( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | EventListenerOptions + ): void; + removeEventListener(type: any, listener: any, options?: any): void { + throw new Error('Method not implemented.'); + } + dispatchEvent(event: Event): boolean { + throw new Error('Method not implemented.'); + } +} + +/** + * Returns all BroadcastChannel instances. + * @returns BroadcastChannel[] + */ +function getBroadcastChannelInstances() { + return [...channelCache]; +} + +/** + * Removes all BroadcastChannel instances. + */ +function clearBroadcastChannelInstances() { + channelCache.splice(0, channelCache.length); +} + +/** + * Stubs the global window.BroadcastChannel for use in jest tests. + */ +function stubBroadcastChannel() { + if (!window.BroadcastChannel) { + window.BroadcastChannel = StubBroadcastChannel; + } +} + +export { stubBroadcastChannel, getBroadcastChannelInstances, clearBroadcastChannelInstances }; diff --git a/renovate.json b/renovate.json index 3d24e88d638b0..628eeec7c6e35 100644 --- a/renovate.json +++ b/renovate.json @@ -114,7 +114,6 @@ { "groupName": "platform security modules", "matchPackageNames": [ - "broadcast-channel", "node-forge", "@types/node-forge", "require-in-the-middle", diff --git a/x-pack/plugins/security/public/plugin.test.tsx b/x-pack/plugins/security/public/plugin.test.tsx index 5a7cbd659ca7e..8082bb3b34fc9 100644 --- a/x-pack/plugins/security/public/plugin.test.tsx +++ b/x-pack/plugins/security/public/plugin.test.tsx @@ -5,7 +5,6 @@ * 2.0. */ -import { enforceOptions } from 'broadcast-channel'; import { Observable } from 'rxjs'; import type { CoreSetup } from '@kbn/core/public'; @@ -14,19 +13,15 @@ import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; import type { FeaturesPluginStart } from '@kbn/features-plugin/public'; import { licensingMock } from '@kbn/licensing-plugin/public/mocks'; import { managementPluginMock } from '@kbn/management-plugin/public/mocks'; +import { stubBroadcastChannel } from '@kbn/test-jest-helpers'; import { ManagementService } from './management'; import type { PluginStartDependencies } from './plugin'; import { SecurityPlugin } from './plugin'; -describe('Security Plugin', () => { - beforeAll(() => { - enforceOptions({ type: 'simulate' }); - }); - afterAll(() => { - enforceOptions(null); - }); +stubBroadcastChannel(); +describe('Security Plugin', () => { describe('#setup', () => { it('should be able to setup if optional plugins are not available', () => { const plugin = new SecurityPlugin(coreMock.createPluginInitializerContext()); diff --git a/x-pack/plugins/security/public/session/session_timeout.test.ts b/x-pack/plugins/security/public/session/session_timeout.test.ts index 09b67082b1a97..e43c1af6ac9c7 100644 --- a/x-pack/plugins/security/public/session/session_timeout.test.ts +++ b/x-pack/plugins/security/public/session/session_timeout.test.ts @@ -5,10 +5,14 @@ * 2.0. */ -import type { BroadcastChannel } from 'broadcast-channel'; - import type { ToastInputFields } from '@kbn/core/public'; import { coreMock } from '@kbn/core/public/mocks'; +import { + clearBroadcastChannelInstances, + getBroadcastChannelInstances, + stubBroadcastChannel, +} from '@kbn/test-jest-helpers'; +stubBroadcastChannel(); import { SESSION_CHECK_MS, @@ -19,11 +23,8 @@ import { } from '../../common/constants'; import type { SessionInfo } from '../../common/types'; import { createSessionExpiredMock } from './session_expired.mock'; -import type { SessionState } from './session_timeout'; import { SessionTimeout, startTimer } from './session_timeout'; -jest.mock('broadcast-channel'); - jest.useFakeTimers(); jest.spyOn(window, 'addEventListener'); @@ -56,6 +57,7 @@ describe('SessionTimeout', () => { afterEach(async () => { jest.clearAllMocks(); jest.clearAllTimers(); + clearBroadcastChannelInstances(); }); test(`does not initialize when starting an anonymous path`, async () => { @@ -242,14 +244,17 @@ describe('SessionTimeout', () => { jest.advanceTimersByTime(30 * 1000); - const [broadcastChannelMock] = jest.requireMock('broadcast-channel').BroadcastChannel.mock - .instances as [BroadcastChannel]; + const [broadcastChannelMock] = getBroadcastChannelInstances(); - broadcastChannelMock.onmessage!({ - lastExtensionTime: Date.now(), - expiresInMs: 60 * 1000, - canBeExtended: true, - }); + broadcastChannelMock.onmessage!( + new MessageEvent('name', { + data: { + lastExtensionTime: Date.now(), + expiresInMs: 60 * 1000, + canBeExtended: true, + }, + }) + ); jest.advanceTimersByTime(30 * 1000); diff --git a/x-pack/plugins/security/public/session/session_timeout.ts b/x-pack/plugins/security/public/session/session_timeout.ts index be7fc4dba883c..02e43c2fd3a83 100644 --- a/x-pack/plugins/security/public/session/session_timeout.ts +++ b/x-pack/plugins/security/public/session/session_timeout.ts @@ -5,7 +5,6 @@ * 2.0. */ -import type { BroadcastChannel as BroadcastChannelType } from 'broadcast-channel'; import type { Subscription } from 'rxjs'; import { BehaviorSubject } from 'rxjs'; import { skip, tap, throttleTime } from 'rxjs/operators'; @@ -34,7 +33,7 @@ export interface SessionState extends Pick; + private channel?: BroadcastChannel; private isVisible = document.visibilityState !== 'hidden'; private isFetchingSessionInfo = false; @@ -77,11 +76,8 @@ export class SessionTimeout { // Subscribe to a broadcast channel for session timeout messages. // This allows us to synchronize the UX across tabs and avoid repetitive API calls. try { - const { BroadcastChannel } = await import('broadcast-channel'); - this.channel = new BroadcastChannel(`${this.tenant}/session_timeout`, { - webWorkerSupport: false, - }); - this.channel.onmessage = this.handleChannelMessage; + this.channel = new BroadcastChannel(`${this.tenant}/session_timeout`); + this.channel.onmessage = (event) => this.handleChannelMessage(event); } catch (error) { // eslint-disable-next-line no-console console.warn( @@ -108,8 +104,14 @@ export class SessionTimeout { /** * Event handler that receives session information from other browser tabs. */ - private handleChannelMessage = (message: SessionState) => { - this.sessionState$.next(message); + private handleChannelMessage = (messageEvent: MessageEvent) => { + if (this.isSessionState(messageEvent.data)) { + this.sessionState$.next(messageEvent.data); + } + }; + + private isSessionState = (data: unknown): data is SessionState => { + return typeof data === 'object' && Object.hasOwn(data ?? {}, 'canBeExtended'); }; /** diff --git a/yarn.lock b/yarn.lock index ef1d5d849ca75..5225ebe505cbe 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9428,20 +9428,6 @@ brfs@^2.0.0, brfs@^2.0.2: static-module "^3.0.2" through2 "^2.0.0" -broadcast-channel@4.10.0: - version "4.10.0" - resolved "https://registry.yarnpkg.com/broadcast-channel/-/broadcast-channel-4.10.0.tgz#d19fb902df227df40b1b580351713d30c302d198" - integrity sha512-hOUh312XyHk6JTVyX9cyXaH1UYs+2gHVtnW16oQAu9FL7ALcXGXc/YoJWqlkV8vUn14URQPMmRi4A9q4UrwVEQ== - dependencies: - "@babel/runtime" "^7.16.0" - detect-node "^2.1.0" - microseconds "0.2.0" - nano-time "1.0.0" - oblivious-set "1.0.0" - p-queue "6.6.2" - rimraf "3.0.2" - unload "2.3.1" - broadcast-channel@^3.4.1: version "3.4.1" resolved "https://registry.yarnpkg.com/broadcast-channel/-/broadcast-channel-3.4.1.tgz#65b63068d0a5216026a19905c9b2d5e9adf0928a" @@ -12520,7 +12506,7 @@ detect-node-es@^1.1.0: resolved "https://registry.yarnpkg.com/detect-node-es/-/detect-node-es-1.1.0.tgz#163acdf643330caa0b4cd7c21e7ee7755d6fa493" integrity sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ== -detect-node@2.1.0, detect-node@^2.0.4, detect-node@^2.1.0: +detect-node@^2.0.4: version "2.1.0" resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.1.0.tgz#c9c70775a49c3d03bc2c06d9a73be550f978f8b1" integrity sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g== @@ -13921,7 +13907,7 @@ eventemitter2@^6.4.3: resolved "https://registry.yarnpkg.com/eventemitter2/-/eventemitter2-6.4.3.tgz#35c563619b13f3681e7eb05cbdaf50f56ba58820" integrity sha512-t0A2msp6BzOf+QAcI6z9XMktLj52OjGQg+8SJH6v5+3uxNpWYRR3wQmfA+6xtMU9kOC59qk9licus5dYcrYkMQ== -eventemitter3@^4.0.0, eventemitter3@^4.0.4: +eventemitter3@^4.0.0: version "4.0.7" resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f" integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw== @@ -21304,11 +21290,6 @@ objectorarray@^1.0.4: resolved "https://registry.yarnpkg.com/objectorarray/-/objectorarray-1.0.4.tgz#d69b2f0ff7dc2701903d308bb85882f4ddb49483" integrity sha512-91k8bjcldstRz1bG6zJo8lWD7c6QXcB4nTDUqiEvIL1xAsLoZlOOZZG+nd6YPz+V7zY1580J4Xxh1vZtyv4i/w== -oblivious-set@1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/oblivious-set/-/oblivious-set-1.0.0.tgz#c8316f2c2fb6ff7b11b6158db3234c49f733c566" - integrity sha512-z+pI07qxo4c2CulUHCDf9lcqDlMSo72N/4rLUpRXf6fu+q8vjt8y0xS+Tlf8NTJDdTXHbdeO1n3MlbctwEoXZw== - oboe@^2.1.4: version "2.1.4" resolved "https://registry.yarnpkg.com/oboe/-/oboe-2.1.4.tgz#20c88cdb0c15371bb04119257d4fdd34b0aa49f6" @@ -21654,14 +21635,6 @@ p-map@^4.0.0: dependencies: aggregate-error "^3.0.0" -p-queue@6.6.2: - version "6.6.2" - resolved "https://registry.yarnpkg.com/p-queue/-/p-queue-6.6.2.tgz#2068a9dcf8e67dd0ec3e7a2bcb76810faa85e426" - integrity sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ== - dependencies: - eventemitter3 "^4.0.4" - p-timeout "^3.2.0" - p-retry@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/p-retry/-/p-retry-3.0.1.tgz#316b4c8893e2c8dc1cfa891f406c4b422bebf328" @@ -21684,13 +21657,6 @@ p-timeout@^2.0.1: dependencies: p-finally "^1.0.0" -p-timeout@^3.2.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/p-timeout/-/p-timeout-3.2.0.tgz#c7e17abc971d2a7962ef83626b35d635acf23dfe" - integrity sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg== - dependencies: - p-finally "^1.0.0" - p-try@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/p-try/-/p-try-1.0.0.tgz#cbc79cdbaf8fd4228e13f621f2b1a237c1b207b3" @@ -28557,14 +28523,6 @@ unload@2.2.0: "@babel/runtime" "^7.6.2" detect-node "^2.0.4" -unload@2.3.1: - version "2.3.1" - resolved "https://registry.yarnpkg.com/unload/-/unload-2.3.1.tgz#9d16862d372a5ce5cb630ad1309c2fd6e35dacfe" - integrity sha512-MUZEiDqvAN9AIDRbbBnVYVvfcR6DrjCqeU2YQMmliFZl9uaBUjTkhuDQkBiyAy8ad5bx1TXVbqZ3gg7namsWjA== - dependencies: - "@babel/runtime" "^7.6.2" - detect-node "2.1.0" - unpipe@1.0.0, unpipe@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" From 483cc454030e0b52f22322fe2a6a655b28d4fa78 Mon Sep 17 00:00:00 2001 From: mgiota Date: Thu, 19 May 2022 20:10:24 +0200 Subject: [PATCH 18/35] [Actionable Observability] update alerts table rule details link to point to o11y rule detail page (#132479) * update alerts table rule details link to point to o11y rule detail page * [CI] Auto-commit changed files from 'node scripts/precommit_hook.js --ref HEAD~1..HEAD --fix' * o11y alert flyout should also link to o11y rule details page * [CI] Auto-commit changed files from 'node scripts/precommit_hook.js --ref HEAD~1..HEAD --fix' * add data-test-subj to rule details page title and add move path definition * fix failing tests by checking existance of Observability in breadcrumb * use alerts and rules link from the paths file * [CI] Auto-commit changed files from 'node scripts/precommit_hook.js --ref HEAD~1..HEAD --fix' * update link in alert flyout to use paths * update rule details link in the rules page Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../plugins/observability/public/config/paths.ts | 7 ++++++- .../components/alerts_flyout/alerts_flyout.tsx | 3 +-- .../alerts_table_t_grid/alerts_table_t_grid.tsx | 3 +-- .../public/pages/rule_details/config.ts | 3 --- .../public/pages/rule_details/index.tsx | 16 ++++++---------- .../public/pages/rules/components/name.tsx | 3 ++- .../apps/observability/alerts/index.ts | 4 +++- 7 files changed, 19 insertions(+), 20 deletions(-) diff --git a/x-pack/plugins/observability/public/config/paths.ts b/x-pack/plugins/observability/public/config/paths.ts index 57bbc95fef40b..7f6599ef3c483 100644 --- a/x-pack/plugins/observability/public/config/paths.ts +++ b/x-pack/plugins/observability/public/config/paths.ts @@ -5,9 +5,14 @@ * 2.0. */ +export const ALERT_PAGE_LINK = '/app/observability/alerts'; +export const RULES_PAGE_LINK = `${ALERT_PAGE_LINK}/rules`; + export const paths = { observability: { - alerts: '/app/observability/alerts', + alerts: ALERT_PAGE_LINK, + rules: RULES_PAGE_LINK, + ruleDetails: (ruleId: string) => `${RULES_PAGE_LINK}/${encodeURI(ruleId)}`, }, management: { rules: '/app/management/insightsAndAlerting/triggersActions/rules', diff --git a/x-pack/plugins/observability/public/pages/alerts/components/alerts_flyout/alerts_flyout.tsx b/x-pack/plugins/observability/public/pages/alerts/components/alerts_flyout/alerts_flyout.tsx index d0957f0224b53..5a1b88ff1a420 100644 --- a/x-pack/plugins/observability/public/pages/alerts/components/alerts_flyout/alerts_flyout.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/components/alerts_flyout/alerts_flyout.tsx @@ -77,8 +77,7 @@ export function AlertsFlyout({ } const ruleId = alertData.fields['kibana.alert.rule.uuid'] ?? null; - const linkToRule = ruleId && prepend ? prepend(paths.management.ruleDetails(ruleId)) : null; - + const linkToRule = ruleId && prepend ? prepend(paths.observability.ruleDetails(ruleId)) : null; const overviewListItems = [ { title: translations.alertsFlyout.statusLabel, diff --git a/x-pack/plugins/observability/public/pages/alerts/containers/alerts_table_t_grid/alerts_table_t_grid.tsx b/x-pack/plugins/observability/public/pages/alerts/containers/alerts_table_t_grid/alerts_table_t_grid.tsx index 621a43eedfc25..c9d2d67e11bdc 100644 --- a/x-pack/plugins/observability/public/pages/alerts/containers/alerts_table_t_grid/alerts_table_t_grid.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/containers/alerts_table_t_grid/alerts_table_t_grid.tsx @@ -170,8 +170,7 @@ function ObservabilityActions({ const casePermissions = useGetUserCasesPermissions(); const ruleId = alert.fields['kibana.alert.rule.uuid'] ?? null; - const linkToRule = ruleId ? http.basePath.prepend(paths.management.ruleDetails(ruleId)) : null; - + const linkToRule = ruleId ? http.basePath.prepend(paths.observability.ruleDetails(ruleId)) : null; const caseAttachments: CaseAttachments = useMemo(() => { return ecsData?._id ? [ diff --git a/x-pack/plugins/observability/public/pages/rule_details/config.ts b/x-pack/plugins/observability/public/pages/rule_details/config.ts index e73849f47e7b3..8822c68a85a0b 100644 --- a/x-pack/plugins/observability/public/pages/rule_details/config.ts +++ b/x-pack/plugins/observability/public/pages/rule_details/config.ts @@ -18,6 +18,3 @@ export function hasAllPrivilege(rule: InitialRule, ruleType?: RuleType): boolean export const hasExecuteActionsCapability = (capabilities: Capabilities) => capabilities?.actions?.execute; - -export const RULES_PAGE_LINK = '/app/observability/alerts/rules'; -export const ALERT_PAGE_LINK = '/app/observability/alerts'; diff --git a/x-pack/plugins/observability/public/pages/rule_details/index.tsx b/x-pack/plugins/observability/public/pages/rule_details/index.tsx index 9cce5bfb99c92..e5d6cccab60a8 100644 --- a/x-pack/plugins/observability/public/pages/rule_details/index.tsx +++ b/x-pack/plugins/observability/public/pages/rule_details/index.tsx @@ -56,12 +56,8 @@ import { PageTitle, ItemTitleRuleSummary, ItemValueRuleSummary, Actions } from ' import { useKibana } from '../../utils/kibana_react'; import { useFetchLast24hAlerts } from '../../hooks/use_fetch_last24h_alerts'; import { formatInterval } from './utils'; -import { - hasExecuteActionsCapability, - hasAllPrivilege, - RULES_PAGE_LINK, - ALERT_PAGE_LINK, -} from './config'; +import { hasExecuteActionsCapability, hasAllPrivilege } from './config'; +import { paths } from '../../config/paths'; export function RuleDetailsPage() { const { @@ -125,10 +121,10 @@ export function RuleDetailsPage() { text: i18n.translate('xpack.observability.breadcrumbs.alertsLinkText', { defaultMessage: 'Alerts', }), - href: http.basePath.prepend(ALERT_PAGE_LINK), + href: http.basePath.prepend(paths.observability.alerts), }, { - href: http.basePath.prepend(RULES_PAGE_LINK), + href: http.basePath.prepend(paths.observability.rules), text: RULES_BREADCRUMB_TEXT, }, { @@ -476,11 +472,11 @@ export function RuleDetailsPage() { { setRuleToDelete([]); - navigateToUrl(http.basePath.prepend(RULES_PAGE_LINK)); + navigateToUrl(http.basePath.prepend(paths.observability.rules)); }} onErrors={async () => { setRuleToDelete([]); - navigateToUrl(http.basePath.prepend(RULES_PAGE_LINK)); + navigateToUrl(http.basePath.prepend(paths.observability.rules)); }} onCancel={() => {}} apiDeleteCall={deleteRules} diff --git a/x-pack/plugins/observability/public/pages/rules/components/name.tsx b/x-pack/plugins/observability/public/pages/rules/components/name.tsx index 15cb44412d880..96418758df0a5 100644 --- a/x-pack/plugins/observability/public/pages/rules/components/name.tsx +++ b/x-pack/plugins/observability/public/pages/rules/components/name.tsx @@ -9,10 +9,11 @@ import React from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiLink, EuiText } from '@elastic/eui'; import { RuleNameProps } from '../types'; import { useKibana } from '../../../utils/kibana_react'; +import { paths } from '../../../config/paths'; export function Name({ name, rule }: RuleNameProps) { const { http } = useKibana().services; - const detailsLink = http.basePath.prepend(`/app/observability/alerts/rules/${rule.id}`); + const detailsLink = http.basePath.prepend(paths.observability.ruleDetails(rule.id)); const link = ( diff --git a/x-pack/test/observability_functional/apps/observability/alerts/index.ts b/x-pack/test/observability_functional/apps/observability/alerts/index.ts index e1fd795d55ffb..5afdb0b00c774 100644 --- a/x-pack/test/observability_functional/apps/observability/alerts/index.ts +++ b/x-pack/test/observability_functional/apps/observability/alerts/index.ts @@ -223,7 +223,9 @@ export default ({ getService }: FtrProviderContext) => { const actionsButton = await observability.alerts.common.getActionsButtonByIndex(0); await actionsButton.click(); await observability.alerts.common.viewRuleDetailsButtonClick(); - expect(await find.existsByCssSelector('[title="Rules and Connectors"]')).to.eql(true); + expect( + await (await find.byCssSelector('[data-test-subj="breadcrumb first"]')).getVisibleText() + ).to.eql('Observability'); }); }); From 956fbc76d96c1a98f13e983c15000c227341a489 Mon Sep 17 00:00:00 2001 From: mgiota Date: Thu, 19 May 2022 20:11:04 +0200 Subject: [PATCH 19/35] [Actionable Observability] render human readable rule type name and notify when fields in o11y rule details page (#132404) * render rule type name * [CI] Auto-commit changed files from 'node scripts/precommit_hook.js --ref HEAD~1..HEAD --fix' * human readable text for notify field * [CI] Auto-commit changed files from 'node scripts/precommit_hook.js --ref HEAD~1..HEAD --fix' * create getNotifyText function * increase bundle size for triggers_actions_ui plugin (temp) Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- packages/kbn-optimizer/limits.yml | 2 +- .../public/pages/rule_details/index.tsx | 16 +++++++++++----- .../sections/rule_form/rule_notify_when.tsx | 2 +- .../plugins/triggers_actions_ui/public/index.ts | 3 +-- 4 files changed, 14 insertions(+), 9 deletions(-) diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 504ba4906ffd5..b9012d30b0f18 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -58,7 +58,7 @@ pageLoadAssetSize: telemetry: 51957 telemetryManagementSection: 38586 transform: 41007 - triggersActionsUi: 107800 #This is temporary. Check https://github.com/elastic/kibana/pull/130710#issuecomment-1119843458 & https://github.com/elastic/kibana/issues/130728 + triggersActionsUi: 119000 #This is temporary. Check https://github.com/elastic/kibana/pull/130710#issuecomment-1119843458 & https://github.com/elastic/kibana/issues/130728 upgradeAssistant: 81241 urlForwarding: 32579 usageCollection: 39762 diff --git a/x-pack/plugins/observability/public/pages/rule_details/index.tsx b/x-pack/plugins/observability/public/pages/rule_details/index.tsx index e5d6cccab60a8..31b9a888ec266 100644 --- a/x-pack/plugins/observability/public/pages/rule_details/index.tsx +++ b/x-pack/plugins/observability/public/pages/rule_details/index.tsx @@ -34,6 +34,7 @@ import { deleteRules, useLoadRuleTypes, RuleType, + NOTIFY_WHEN_OPTIONS, RuleEventLogListProps, } from '@kbn/triggers-actions-ui-plugin/public'; // TODO: use a Delete modal from triggersActionUI when it's sharable @@ -75,7 +76,7 @@ export function RuleDetailsPage() { const { ruleId } = useParams(); const { ObservabilityPageTemplate } = usePluginContext(); const { isRuleLoading, rule, errorRule, reloadRule } = useFetchRule({ ruleId, http }); - const { ruleTypes } = useLoadRuleTypes({ + const { ruleTypes, ruleTypeIndex } = useLoadRuleTypes({ filteredSolutions: OBSERVABILITY_SOLUTIONS, }); @@ -109,8 +110,9 @@ export function RuleDetailsPage() { useEffect(() => { if (ruleTypes.length && rule) { const matchedRuleType = ruleTypes.find((type) => type.id === rule.ruleTypeId); + setRuleType(matchedRuleType); + if (rule.consumer === ALERTS_FEATURE_ID && matchedRuleType && matchedRuleType.producer) { - setRuleType(matchedRuleType); setFeatures(matchedRuleType.producer); } else setFeatures(rule.consumer); } @@ -217,6 +219,9 @@ export function RuleDetailsPage() { /> ); + const getNotifyText = () => + NOTIFY_WHEN_OPTIONS.find((option) => option.value === rule?.notifyWhen)?.inputDisplay || + rule.notifyWhen; return ( - + @@ -438,8 +445,7 @@ export function RuleDetailsPage() { defaultMessage: 'Notify', })} - - + diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_notify_when.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_notify_when.tsx index 4c23aa0dda40d..992c4df4e5798 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_notify_when.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_notify_when.tsx @@ -28,7 +28,7 @@ import { RuleNotifyWhenType } from '../../../types'; const DEFAULT_NOTIFY_WHEN_VALUE: RuleNotifyWhenType = 'onActionGroupChange'; -const NOTIFY_WHEN_OPTIONS: Array> = [ +export const NOTIFY_WHEN_OPTIONS: Array> = [ { value: 'onActionGroupChange', inputDisplay: i18n.translate( diff --git a/x-pack/plugins/triggers_actions_ui/public/index.ts b/x-pack/plugins/triggers_actions_ui/public/index.ts index 001f63bc6cc6f..9c08dfe597ecf 100644 --- a/x-pack/plugins/triggers_actions_ui/public/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/index.ts @@ -89,9 +89,8 @@ export { loadRuleAggregations, loadRuleTags } from './application/lib/rule_api/a export { useLoadRuleTypes } from './application/hooks/use_load_rule_types'; export { loadRule } from './application/lib/rule_api/get_rule'; export { loadAllActions } from './application/lib/action_connector_api'; - export { loadActionTypes } from './application/lib/action_connector_api/connector_types'; - +export { NOTIFY_WHEN_OPTIONS } from './application/sections/rule_form/rule_notify_when'; export type { TIME_UNITS } from './application/constants'; export { getTimeUnitLabel } from './common/lib/get_time_unit_label'; export type { TriggersAndActionsUiServices } from './application/app'; From 4b262a52fd7b48ad7b5729d540a99b8318a2e5f2 Mon Sep 17 00:00:00 2001 From: Chris Roberson Date: Thu, 19 May 2022 15:04:50 -0400 Subject: [PATCH 20/35] Fix test (#132546) --- .../apps/triggers_actions_ui/alerts_table.ts | 103 ++++++++---------- 1 file changed, 48 insertions(+), 55 deletions(-) diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts_table.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts_table.ts index 56026093c88dd..27989942d3e95 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts_table.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts_table.ts @@ -87,48 +87,41 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { ); }); - // This keeps failing in CI because the next button is not clickable - // Revisit this once we change the UI around based on feedback - /* - fail: Actions and Triggers app Alerts table should open a flyout and paginate through the flyout - │ Error: retry.try timeout: ElementClickInterceptedError: element click intercepted: Element ... is not clickable at point (1564, 795). Other element would receive the click:
...
- */ - // it('should open a flyout and paginate through the flyout', async () => { - // await PageObjects.common.navigateToUrlWithBrowserHistory('triggersActions', '/alerts'); - // await waitTableIsLoaded(); - // await testSubjects.click('expandColumnCellOpenFlyoutButton-0'); - // await waitFlyoutOpen(); - // await waitFlyoutIsLoaded(); - - // expect(await testSubjects.getVisibleText('alertsFlyoutName')).to.be( - // 'APM Failed Transaction Rate (one)' - // ); - // expect(await testSubjects.getVisibleText('alertsFlyoutReason')).to.be( - // 'Failed transactions rate is greater than 5.0% (current value is 31%) for elastic-co-frontend' - // ); - - // await testSubjects.click('pagination-button-next'); - - // expect(await testSubjects.getVisibleText('alertsFlyoutName')).to.be( - // 'APM Failed Transaction Rate (one)' - // ); - // expect(await testSubjects.getVisibleText('alertsFlyoutReason')).to.be( - // 'Failed transactions rate is greater than 5.0% (current value is 35%) for opbeans-python' - // ); - - // await testSubjects.click('pagination-button-previous'); - // await testSubjects.click('pagination-button-previous'); - - // await waitTableIsLoaded(); - - // const rows = await getRows(); - // expect(rows[0].status).to.be('close'); - // expect(rows[0].lastUpdated).to.be('2021-10-19T14:55:14.503Z'); - // expect(rows[0].duration).to.be('252002000'); - // expect(rows[0].reason).to.be( - // 'CPU usage is greater than a threshold of 40 (current value is 56.7%) for gke-edge-oblt-default-pool-350b44de-c3dd' - // ); - // }); + it('should open a flyout and paginate through the flyout', async () => { + await PageObjects.common.navigateToUrlWithBrowserHistory('triggersActions', '/alerts'); + await waitTableIsLoaded(); + await testSubjects.click('expandColumnCellOpenFlyoutButton-0'); + await waitFlyoutOpen(); + await waitFlyoutIsLoaded(); + + expect(await testSubjects.getVisibleText('alertsFlyoutName')).to.be( + 'APM Failed Transaction Rate (one)' + ); + expect(await testSubjects.getVisibleText('alertsFlyoutReason')).to.be( + 'Failed transactions rate is greater than 5.0% (current value is 31%) for elastic-co-frontend' + ); + + await testSubjects.click('alertsFlyoutPagination > pagination-button-next'); + + expect(await testSubjects.getVisibleText('alertsFlyoutName')).to.be( + 'APM Failed Transaction Rate (one)' + ); + expect(await testSubjects.getVisibleText('alertsFlyoutReason')).to.be( + 'Failed transactions rate is greater than 5.0% (current value is 35%) for opbeans-python' + ); + + await testSubjects.click('alertsFlyoutPagination > pagination-button-previous'); + + await waitTableIsLoaded(); + + const rows = await getRows(); + expect(rows[0].status).to.be('active'); + expect(rows[0].lastUpdated).to.be('2021-10-19T15:20:38.749Z'); + expect(rows[0].duration).to.be('1197194000'); + expect(rows[0].reason).to.be( + 'Failed transactions rate is greater than 5.0% (current value is 31%) for elastic-co-frontend' + ); + }); async function waitTableIsLoaded() { return await retry.try(async () => { @@ -137,19 +130,19 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }); } - // async function waitFlyoutOpen() { - // return await retry.try(async () => { - // const exists = await testSubjects.exists('alertsFlyout'); - // if (!exists) throw new Error('Still loading...'); - // }); - // } - - // async function waitFlyoutIsLoaded() { - // return await retry.try(async () => { - // const exists = await testSubjects.exists('alertsFlyoutLoading'); - // if (exists) throw new Error('Still loading...'); - // }); - // } + async function waitFlyoutOpen() { + return await retry.try(async () => { + const exists = await testSubjects.exists('alertsFlyout'); + if (!exists) throw new Error('Still loading...'); + }); + } + + async function waitFlyoutIsLoaded() { + return await retry.try(async () => { + const exists = await testSubjects.exists('alertsFlyoutLoading'); + if (exists) throw new Error('Still loading...'); + }); + } async function getRows() { const euiDataGridRows = await find.allByCssSelector('.euiDataGridRow'); From ee8158002035e3e9e8de5200ca0c6b128a76b423 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Thu, 19 May 2022 14:16:49 -0500 Subject: [PATCH 21/35] skip failing test suite (#132288) --- test/functional/apps/discover/_chart_hidden.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/functional/apps/discover/_chart_hidden.ts b/test/functional/apps/discover/_chart_hidden.ts index a9179fd234905..44fa42e568a0b 100644 --- a/test/functional/apps/discover/_chart_hidden.ts +++ b/test/functional/apps/discover/_chart_hidden.ts @@ -19,7 +19,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { defaultIndex: 'logstash-*', }; - describe('discover show/hide chart test', function () { + // Failing: See https://github.com/elastic/kibana/issues/132288 + describe.skip('discover show/hide chart test', function () { before(async function () { log.debug('load kibana index with default index pattern'); From f96ff560ed38ddf9e3027cb1cea5d4da1a0ccdec Mon Sep 17 00:00:00 2001 From: Jen Huang Date: Thu, 19 May 2022 12:28:00 -0700 Subject: [PATCH 22/35] [Fleet] Reduce bundle size limit (#132488) --- packages/kbn-optimizer/limits.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index b9012d30b0f18..8856f7f0aaabb 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -27,7 +27,7 @@ pageLoadAssetSize: indexLifecycleManagement: 107090 indexManagement: 140608 infra: 184320 - fleet: 250000 + fleet: 95000 ingestPipelines: 58003 inputControlVis: 172675 inspector: 148711 From 9814c8515dcb1c767f10b79df0b2bee0dd6e6039 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Thu, 19 May 2022 15:41:38 -0500 Subject: [PATCH 23/35] skip failing test suite (#132553) --- test/functional/apps/discover/_context_encoded_url_param.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/functional/apps/discover/_context_encoded_url_param.ts b/test/functional/apps/discover/_context_encoded_url_param.ts index fdbee7a637f46..95540c929130c 100644 --- a/test/functional/apps/discover/_context_encoded_url_param.ts +++ b/test/functional/apps/discover/_context_encoded_url_param.ts @@ -17,7 +17,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const testSubjects = getService('testSubjects'); const es = getService('es'); - describe('encoded URL params in context page', () => { + // Failing: See https://github.com/elastic/kibana/issues/132553 + describe.skip('encoded URL params in context page', () => { before(async function () { await security.testUser.setRoles(['kibana_admin', 'context_encoded_param']); await PageObjects.common.navigateToApp('settings'); From 42eec11a8d30d63c4d82de2d3a0ecd0272a1a9a4 Mon Sep 17 00:00:00 2001 From: Tre Date: Thu, 19 May 2022 22:12:22 +0100 Subject: [PATCH 24/35] Rebalance dashboard group 1 (#132193) Split a group of the files to group 6. Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../functional/apps/dashboard/group1/index.ts | 13 -- .../apps/dashboard/group6/config.ts | 18 ++ .../group6/create_and_add_embeddables.ts | 169 ++++++++++++++++++ .../dashboard_back_button.ts | 0 .../dashboard_error_handling.ts | 0 .../{group1 => group6}/dashboard_options.ts | 0 .../{group1 => group6}/dashboard_query_bar.ts | 0 .../data_shared_attributes.ts | 0 .../{group1 => group6}/embed_mode.ts | 0 .../apps/dashboard/group6/empty_dashboard.ts | 67 +++++++ .../functional/apps/dashboard/group6/index.ts | 46 +++++ .../{group1 => group6}/legacy_urls.ts | 0 .../saved_search_embeddable.ts | 0 .../dashboard/{group1 => group6}/share.ts | 0 14 files changed, 300 insertions(+), 13 deletions(-) create mode 100644 test/functional/apps/dashboard/group6/config.ts create mode 100644 test/functional/apps/dashboard/group6/create_and_add_embeddables.ts rename test/functional/apps/dashboard/{group1 => group6}/dashboard_back_button.ts (100%) rename test/functional/apps/dashboard/{group1 => group6}/dashboard_error_handling.ts (100%) rename test/functional/apps/dashboard/{group1 => group6}/dashboard_options.ts (100%) rename test/functional/apps/dashboard/{group1 => group6}/dashboard_query_bar.ts (100%) rename test/functional/apps/dashboard/{group1 => group6}/data_shared_attributes.ts (100%) rename test/functional/apps/dashboard/{group1 => group6}/embed_mode.ts (100%) create mode 100644 test/functional/apps/dashboard/group6/empty_dashboard.ts create mode 100644 test/functional/apps/dashboard/group6/index.ts rename test/functional/apps/dashboard/{group1 => group6}/legacy_urls.ts (100%) rename test/functional/apps/dashboard/{group1 => group6}/saved_search_embeddable.ts (100%) rename test/functional/apps/dashboard/{group1 => group6}/share.ts (100%) diff --git a/test/functional/apps/dashboard/group1/index.ts b/test/functional/apps/dashboard/group1/index.ts index 597102433ef45..736dfd6f577f8 100644 --- a/test/functional/apps/dashboard/group1/index.ts +++ b/test/functional/apps/dashboard/group1/index.ts @@ -37,18 +37,5 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./dashboard_unsaved_state')); loadTestFile(require.resolve('./dashboard_unsaved_listing')); loadTestFile(require.resolve('./edit_visualizations')); - loadTestFile(require.resolve('./dashboard_options')); - loadTestFile(require.resolve('./data_shared_attributes')); - loadTestFile(require.resolve('./share')); - loadTestFile(require.resolve('./embed_mode')); - loadTestFile(require.resolve('./dashboard_back_button')); - loadTestFile(require.resolve('./dashboard_error_handling')); - loadTestFile(require.resolve('./legacy_urls')); - loadTestFile(require.resolve('./saved_search_embeddable')); - - // Note: This one must be last because it unloads some data for one of its tests! - // No, this isn't ideal, but loading/unloading takes so much time and these are all bunched - // to improve efficiency... - loadTestFile(require.resolve('./dashboard_query_bar')); }); } diff --git a/test/functional/apps/dashboard/group6/config.ts b/test/functional/apps/dashboard/group6/config.ts new file mode 100644 index 0000000000000..a70a190ca63f8 --- /dev/null +++ b/test/functional/apps/dashboard/group6/config.ts @@ -0,0 +1,18 @@ +/* + * Copyright 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 { FtrConfigProviderContext } from '@kbn/test'; + +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const functionalConfig = await readConfigFile(require.resolve('../../../config.base.js')); + + return { + ...functionalConfig.getAll(), + testFiles: [require.resolve('.')], + }; +} diff --git a/test/functional/apps/dashboard/group6/create_and_add_embeddables.ts b/test/functional/apps/dashboard/group6/create_and_add_embeddables.ts new file mode 100644 index 0000000000000..c96e596a88ecf --- /dev/null +++ b/test/functional/apps/dashboard/group6/create_and_add_embeddables.ts @@ -0,0 +1,169 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import expect from '@kbn/expect'; + +import { VisualizeConstants } from '@kbn/visualizations-plugin/common/constants'; +import { VISUALIZE_ENABLE_LABS_SETTING } from '@kbn/visualizations-plugin/common/constants'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const retry = getService('retry'); + const PageObjects = getPageObjects(['dashboard', 'header', 'visualize', 'settings', 'common']); + const browser = getService('browser'); + const kibanaServer = getService('kibanaServer'); + const dashboardAddPanel = getService('dashboardAddPanel'); + + describe('create and add embeddables', () => { + before(async () => { + await kibanaServer.savedObjects.cleanStandardList(); + await kibanaServer.importExport.load( + 'test/functional/fixtures/kbn_archiver/dashboard/current/kibana' + ); + await kibanaServer.uiSettings.replace({ + defaultIndex: '0bf35f60-3dc9-11e8-8660-4d65aa086b3c', + }); + }); + + it('ensure toolbar popover closes on add', async () => { + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.clickNewDashboard(); + await PageObjects.dashboard.switchToEditMode(); + await dashboardAddPanel.clickEditorMenuButton(); + await dashboardAddPanel.clickAddNewEmbeddableLink('LOG_STREAM_EMBEDDABLE'); + await dashboardAddPanel.expectEditorMenuClosed(); + }); + + after(async () => { + await kibanaServer.savedObjects.cleanStandardList(); + }); + + describe('add new visualization link', () => { + before(async () => { + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.preserveCrossAppState(); + await PageObjects.dashboard.loadSavedDashboard('few panels'); + }); + + it('adds new visualization via the top nav link', async () => { + const originalPanelCount = await PageObjects.dashboard.getPanelCount(); + await PageObjects.dashboard.switchToEditMode(); + await dashboardAddPanel.clickEditorMenuButton(); + await dashboardAddPanel.clickAggBasedVisualizations(); + await PageObjects.visualize.clickAreaChart(); + await PageObjects.visualize.clickNewSearch(); + await PageObjects.visualize.saveVisualizationExpectSuccess( + 'visualization from top nav add new panel', + { redirectToOrigin: true } + ); + await retry.try(async () => { + const panelCount = await PageObjects.dashboard.getPanelCount(); + expect(panelCount).to.eql(originalPanelCount + 1); + }); + await PageObjects.dashboard.waitForRenderComplete(); + }); + + it('adds a new visualization', async () => { + const originalPanelCount = await PageObjects.dashboard.getPanelCount(); + await dashboardAddPanel.clickEditorMenuButton(); + await dashboardAddPanel.clickAggBasedVisualizations(); + await PageObjects.visualize.clickAreaChart(); + await PageObjects.visualize.clickNewSearch(); + await PageObjects.visualize.saveVisualizationExpectSuccess( + 'visualization from add new link', + { redirectToOrigin: true } + ); + + await retry.try(async () => { + const panelCount = await PageObjects.dashboard.getPanelCount(); + expect(panelCount).to.eql(originalPanelCount + 1); + }); + await PageObjects.dashboard.waitForRenderComplete(); + }); + + it('adds a new timelion visualization', async () => { + // adding this case, as the timelion agg-based viz doesn't need the `clickNewSearch()` step + const originalPanelCount = await PageObjects.dashboard.getPanelCount(); + await dashboardAddPanel.clickEditorMenuButton(); + await dashboardAddPanel.clickAggBasedVisualizations(); + await PageObjects.visualize.clickTimelion(); + await PageObjects.visualize.saveVisualizationExpectSuccess( + 'timelion visualization from add new link', + { redirectToOrigin: true } + ); + + await retry.try(async () => { + const panelCount = await PageObjects.dashboard.getPanelCount(); + expect(panelCount).to.eql(originalPanelCount + 1); + }); + await PageObjects.dashboard.waitForRenderComplete(); + }); + + it('adds a markdown visualization via the quick button', async () => { + const originalPanelCount = await PageObjects.dashboard.getPanelCount(); + await dashboardAddPanel.clickMarkdownQuickButton(); + await PageObjects.visualize.saveVisualizationExpectSuccess( + 'visualization from markdown quick button', + { redirectToOrigin: true } + ); + + await retry.try(async () => { + const panelCount = await PageObjects.dashboard.getPanelCount(); + expect(panelCount).to.eql(originalPanelCount + 1); + }); + await PageObjects.dashboard.waitForRenderComplete(); + }); + + it('saves the listing page instead of the visualization to the app link', async () => { + await PageObjects.header.clickVisualize(true); + const currentUrl = await browser.getCurrentUrl(); + expect(currentUrl).not.to.contain(VisualizeConstants.EDIT_PATH); + }); + + after(async () => { + await PageObjects.header.clickDashboard(); + }); + }); + + describe('visualize:enableLabs advanced setting', () => { + const LAB_VIS_NAME = 'Rendering Test: input control'; + + it('should display lab visualizations in add panel', async () => { + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.clickNewDashboard(); + const exists = await dashboardAddPanel.panelAddLinkExists(LAB_VIS_NAME); + await dashboardAddPanel.closeAddPanel(); + expect(exists).to.be(true); + }); + + describe('is false', () => { + before(async () => { + await PageObjects.header.clickStackManagement(); + await PageObjects.settings.clickKibanaSettings(); + await PageObjects.settings.toggleAdvancedSettingCheckbox(VISUALIZE_ENABLE_LABS_SETTING); + }); + + it('should not display lab visualizations in add panel', async () => { + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.clickNewDashboard(); + + const exists = await dashboardAddPanel.panelAddLinkExists(LAB_VIS_NAME); + await dashboardAddPanel.closeAddPanel(); + expect(exists).to.be(false); + }); + + after(async () => { + await PageObjects.header.clickStackManagement(); + await PageObjects.settings.clickKibanaSettings(); + await PageObjects.settings.clearAdvancedSettings(VISUALIZE_ENABLE_LABS_SETTING); + await PageObjects.header.clickDashboard(); + }); + }); + }); + }); +} diff --git a/test/functional/apps/dashboard/group1/dashboard_back_button.ts b/test/functional/apps/dashboard/group6/dashboard_back_button.ts similarity index 100% rename from test/functional/apps/dashboard/group1/dashboard_back_button.ts rename to test/functional/apps/dashboard/group6/dashboard_back_button.ts diff --git a/test/functional/apps/dashboard/group1/dashboard_error_handling.ts b/test/functional/apps/dashboard/group6/dashboard_error_handling.ts similarity index 100% rename from test/functional/apps/dashboard/group1/dashboard_error_handling.ts rename to test/functional/apps/dashboard/group6/dashboard_error_handling.ts diff --git a/test/functional/apps/dashboard/group1/dashboard_options.ts b/test/functional/apps/dashboard/group6/dashboard_options.ts similarity index 100% rename from test/functional/apps/dashboard/group1/dashboard_options.ts rename to test/functional/apps/dashboard/group6/dashboard_options.ts diff --git a/test/functional/apps/dashboard/group1/dashboard_query_bar.ts b/test/functional/apps/dashboard/group6/dashboard_query_bar.ts similarity index 100% rename from test/functional/apps/dashboard/group1/dashboard_query_bar.ts rename to test/functional/apps/dashboard/group6/dashboard_query_bar.ts diff --git a/test/functional/apps/dashboard/group1/data_shared_attributes.ts b/test/functional/apps/dashboard/group6/data_shared_attributes.ts similarity index 100% rename from test/functional/apps/dashboard/group1/data_shared_attributes.ts rename to test/functional/apps/dashboard/group6/data_shared_attributes.ts diff --git a/test/functional/apps/dashboard/group1/embed_mode.ts b/test/functional/apps/dashboard/group6/embed_mode.ts similarity index 100% rename from test/functional/apps/dashboard/group1/embed_mode.ts rename to test/functional/apps/dashboard/group6/embed_mode.ts diff --git a/test/functional/apps/dashboard/group6/empty_dashboard.ts b/test/functional/apps/dashboard/group6/empty_dashboard.ts new file mode 100644 index 0000000000000..e559c0ef81f60 --- /dev/null +++ b/test/functional/apps/dashboard/group6/empty_dashboard.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import expect from '@kbn/expect'; + +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const testSubjects = getService('testSubjects'); + const kibanaServer = getService('kibanaServer'); + const dashboardAddPanel = getService('dashboardAddPanel'); + const dashboardVisualizations = getService('dashboardVisualizations'); + const dashboardExpect = getService('dashboardExpect'); + const PageObjects = getPageObjects(['common', 'dashboard']); + + describe('empty dashboard', () => { + before(async () => { + await kibanaServer.savedObjects.cleanStandardList(); + await kibanaServer.importExport.load( + 'test/functional/fixtures/kbn_archiver/dashboard/current/kibana' + ); + await kibanaServer.uiSettings.replace({ + defaultIndex: '0bf35f60-3dc9-11e8-8660-4d65aa086b3c', + }); + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.preserveCrossAppState(); + await PageObjects.dashboard.clickNewDashboard(); + }); + + after(async () => { + await dashboardAddPanel.closeAddPanel(); + await PageObjects.dashboard.gotoDashboardLandingPage(); + await kibanaServer.savedObjects.cleanStandardList(); + }); + + it('should display empty widget', async () => { + const emptyWidgetExists = await testSubjects.exists('emptyDashboardWidget'); + expect(emptyWidgetExists).to.be(true); + }); + + it('should open add panel when add button is clicked', async () => { + await dashboardAddPanel.clickOpenAddPanel(); + const isAddPanelOpen = await dashboardAddPanel.isAddPanelOpen(); + expect(isAddPanelOpen).to.be(true); + await testSubjects.click('euiFlyoutCloseButton'); + }); + + it('should add new visualization from dashboard', async () => { + await dashboardVisualizations.createAndAddMarkdown({ + name: 'Dashboard Test Markdown', + markdown: 'Markdown text', + }); + await PageObjects.dashboard.waitForRenderComplete(); + await dashboardExpect.markdownWithValuesExists(['Markdown text']); + }); + + it('should open editor menu when editor button is clicked', async () => { + await dashboardAddPanel.clickEditorMenuButton(); + await testSubjects.existOrFail('dashboardEditorContextMenu'); + }); + }); +} diff --git a/test/functional/apps/dashboard/group6/index.ts b/test/functional/apps/dashboard/group6/index.ts new file mode 100644 index 0000000000000..f78f7e2d549b8 --- /dev/null +++ b/test/functional/apps/dashboard/group6/index.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService, loadTestFile }: FtrProviderContext) { + const browser = getService('browser'); + const esArchiver = getService('esArchiver'); + + async function loadCurrentData() { + await browser.setWindowSize(1300, 900); + await esArchiver.unload('test/functional/fixtures/es_archiver/logstash_functional'); + await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/dashboard/current/data'); + } + + async function unloadCurrentData() { + await esArchiver.unload('test/functional/fixtures/es_archiver/dashboard/current/data'); + } + + describe('dashboard app - group 1', function () { + before(loadCurrentData); + after(unloadCurrentData); + + // This has to be first since the other tests create some embeddables as side affects and our counting assumes + // a fresh index. + loadTestFile(require.resolve('./empty_dashboard')); + loadTestFile(require.resolve('./dashboard_options')); + loadTestFile(require.resolve('./data_shared_attributes')); + loadTestFile(require.resolve('./share')); + loadTestFile(require.resolve('./embed_mode')); + loadTestFile(require.resolve('./dashboard_back_button')); + loadTestFile(require.resolve('./dashboard_error_handling')); + loadTestFile(require.resolve('./legacy_urls')); + loadTestFile(require.resolve('./saved_search_embeddable')); + + // Note: This one must be last because it unloads some data for one of its tests! + // No, this isn't ideal, but loading/unloading takes so much time and these are all bunched + // to improve efficiency... + loadTestFile(require.resolve('./dashboard_query_bar')); + }); +} diff --git a/test/functional/apps/dashboard/group1/legacy_urls.ts b/test/functional/apps/dashboard/group6/legacy_urls.ts similarity index 100% rename from test/functional/apps/dashboard/group1/legacy_urls.ts rename to test/functional/apps/dashboard/group6/legacy_urls.ts diff --git a/test/functional/apps/dashboard/group1/saved_search_embeddable.ts b/test/functional/apps/dashboard/group6/saved_search_embeddable.ts similarity index 100% rename from test/functional/apps/dashboard/group1/saved_search_embeddable.ts rename to test/functional/apps/dashboard/group6/saved_search_embeddable.ts diff --git a/test/functional/apps/dashboard/group1/share.ts b/test/functional/apps/dashboard/group6/share.ts similarity index 100% rename from test/functional/apps/dashboard/group1/share.ts rename to test/functional/apps/dashboard/group6/share.ts From b2008488ba0efeff58347e0e998692c3b7701cc0 Mon Sep 17 00:00:00 2001 From: Clint Andrew Hall Date: Thu, 19 May 2022 16:17:14 -0500 Subject: [PATCH 25/35] [Shared UX] Move No Data Views to package (#131996) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- package.json | 2 + packages/BUILD.bazel | 2 + packages/kbn-shared-ux-components/BUILD.bazel | 2 + .../src/empty_state/index.ts | 1 - .../empty_state/kibana_no_data_page.test.tsx | 8 +- .../src/empty_state/kibana_no_data_page.tsx | 33 +- .../no_data_views/no_data_views.stories.tsx | 49 -- .../kbn-shared-ux-components/src/index.ts | 41 -- .../prompt/no_data_views/BUILD.bazel | 142 +++++ .../prompt/no_data_views/README.mdx} | 8 +- .../prompt/no_data_views/jest.config.js} | 7 +- .../prompt/no_data_views/package.json | 8 + .../documentation_link.test.tsx.snap | 4 +- .../src/data_view_illustration.tsx | 552 ++++++++++++++++++ .../src}/documentation_link.test.tsx | 0 .../no_data_views/src}/documentation_link.tsx | 4 +- .../prompt/no_data_views/src/index.tsx | 47 ++ .../src}/no_data_views.component.test.tsx | 12 +- .../src}/no_data_views.component.tsx | 27 +- .../src/no_data_views.stories.tsx | 68 +++ .../no_data_views/src}/no_data_views.test.tsx | 30 +- .../no_data_views/src}/no_data_views.tsx | 20 +- .../prompt/no_data_views/src/services.tsx | 115 ++++ .../prompt/no_data_views/tsconfig.json | 20 + .../empty_prompts/empty_prompts.tsx | 4 +- .../translations/translations/fr-FR.json | 4 +- .../translations/translations/ja-JP.json | 4 +- .../translations/translations/zh-CN.json | 4 +- yarn.lock | 10 + 29 files changed, 1067 insertions(+), 161 deletions(-) delete mode 100644 packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.stories.tsx create mode 100644 packages/shared-ux/prompt/no_data_views/BUILD.bazel rename packages/{kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.mdx => shared-ux/prompt/no_data_views/README.mdx} (74%) rename packages/{kbn-shared-ux-components/src/empty_state/no_data_views/index.tsx => shared-ux/prompt/no_data_views/jest.config.js} (72%) create mode 100644 packages/shared-ux/prompt/no_data_views/package.json rename packages/{kbn-shared-ux-components/src/empty_state/no_data_views => shared-ux/prompt/no_data_views/src}/__snapshots__/documentation_link.test.tsx.snap (82%) create mode 100644 packages/shared-ux/prompt/no_data_views/src/data_view_illustration.tsx rename packages/{kbn-shared-ux-components/src/empty_state/no_data_views => shared-ux/prompt/no_data_views/src}/documentation_link.test.tsx (100%) rename packages/{kbn-shared-ux-components/src/empty_state/no_data_views => shared-ux/prompt/no_data_views/src}/documentation_link.tsx (88%) create mode 100644 packages/shared-ux/prompt/no_data_views/src/index.tsx rename packages/{kbn-shared-ux-components/src/empty_state/no_data_views => shared-ux/prompt/no_data_views/src}/no_data_views.component.test.tsx (79%) rename packages/{kbn-shared-ux-components/src/empty_state/no_data_views => shared-ux/prompt/no_data_views/src}/no_data_views.component.tsx (77%) create mode 100644 packages/shared-ux/prompt/no_data_views/src/no_data_views.stories.tsx rename packages/{kbn-shared-ux-components/src/empty_state/no_data_views => shared-ux/prompt/no_data_views/src}/no_data_views.test.tsx (53%) rename packages/{kbn-shared-ux-components/src/empty_state/no_data_views => shared-ux/prompt/no_data_views/src}/no_data_views.tsx (72%) create mode 100644 packages/shared-ux/prompt/no_data_views/src/services.tsx create mode 100644 packages/shared-ux/prompt/no_data_views/tsconfig.json diff --git a/package.json b/package.json index 6330d68c742b1..72f4acfc18354 100644 --- a/package.json +++ b/package.json @@ -184,6 +184,7 @@ "@kbn/shared-ux-components": "link:bazel-bin/packages/kbn-shared-ux-components", "@kbn/shared-ux-link-redirect-app": "link:bazel-bin/packages/shared-ux/link/redirect_app", "@kbn/shared-ux-page-analytics-no-data": "link:bazel-bin/packages/shared-ux/page/analytics_no_data", + "@kbn/shared-ux-prompt-no-data-views": "link:bazel-bin/packages/shared-ux/prompt/no_data_views", "@kbn/shared-ux-services": "link:bazel-bin/packages/kbn-shared-ux-services", "@kbn/shared-ux-storybook": "link:bazel-bin/packages/kbn-shared-ux-storybook", "@kbn/shared-ux-utility": "link:bazel-bin/packages/kbn-shared-ux-utility", @@ -682,6 +683,7 @@ "@types/kbn__shared-ux-components": "link:bazel-bin/packages/kbn-shared-ux-components/npm_module_types", "@types/kbn__shared-ux-link-redirect-app": "link:bazel-bin/packages/shared-ux/link/redirect_app/npm_module_types", "@types/kbn__shared-ux-page-analytics-no-data": "link:bazel-bin/packages/shared-ux/page/analytics_no_data/npm_module_types", + "@types/kbn__shared-ux-prompt-no-data-views": "link:bazel-bin/packages/shared-ux/prompt/no_data_views/npm_module_types", "@types/kbn__shared-ux-services": "link:bazel-bin/packages/kbn-shared-ux-services/npm_module_types", "@types/kbn__shared-ux-storybook": "link:bazel-bin/packages/kbn-shared-ux-storybook/npm_module_types", "@types/kbn__shared-ux-utility": "link:bazel-bin/packages/kbn-shared-ux-utility/npm_module_types", diff --git a/packages/BUILD.bazel b/packages/BUILD.bazel index 234a69cb4bdf7..51db32d5d89f7 100644 --- a/packages/BUILD.bazel +++ b/packages/BUILD.bazel @@ -116,6 +116,7 @@ filegroup( "//packages/shared-ux/button/exit_full_screen:build", "//packages/shared-ux/link/redirect_app:build", "//packages/shared-ux/page/analytics_no_data:build", + "//packages/shared-ux/prompt/no_data_views:build", ], ) @@ -215,6 +216,7 @@ filegroup( "//packages/shared-ux/button/exit_full_screen:build_types", "//packages/shared-ux/link/redirect_app:build_types", "//packages/shared-ux/page/analytics_no_data:build_types", + "//packages/shared-ux/prompt/no_data_views:build_types", ], ) diff --git a/packages/kbn-shared-ux-components/BUILD.bazel b/packages/kbn-shared-ux-components/BUILD.bazel index b1420f5376041..1a4a7100ded72 100644 --- a/packages/kbn-shared-ux-components/BUILD.bazel +++ b/packages/kbn-shared-ux-components/BUILD.bazel @@ -44,6 +44,7 @@ RUNTIME_DEPS = [ "//packages/kbn-i18n", "//packages/shared-ux/avatar/solution", "//packages/shared-ux/link/redirect_app", + "//packages/shared-ux/prompt/no_data_views", "//packages/kbn-shared-ux-services", "//packages/kbn-shared-ux-storybook", "//packages/kbn-shared-ux-utility", @@ -72,6 +73,7 @@ TYPES_DEPS = [ "//packages/kbn-i18n:npm_module_types", "//packages/shared-ux/avatar/solution:npm_module_types", "//packages/shared-ux/link/redirect_app:npm_module_types", + "//packages/shared-ux/prompt/no_data_views:npm_module_types", "//packages/kbn-shared-ux-services:npm_module_types", "//packages/kbn-shared-ux-storybook:npm_module_types", "//packages/kbn-shared-ux-utility:npm_module_types", diff --git a/packages/kbn-shared-ux-components/src/empty_state/index.ts b/packages/kbn-shared-ux-components/src/empty_state/index.ts index 68defa5269344..9883d595633a7 100644 --- a/packages/kbn-shared-ux-components/src/empty_state/index.ts +++ b/packages/kbn-shared-ux-components/src/empty_state/index.ts @@ -6,5 +6,4 @@ * Side Public License, v 1. */ -export { NoDataViews, NoDataViewsComponent } from './no_data_views'; export { KibanaNoDataPage } from './kibana_no_data_page'; diff --git a/packages/kbn-shared-ux-components/src/empty_state/kibana_no_data_page.test.tsx b/packages/kbn-shared-ux-components/src/empty_state/kibana_no_data_page.test.tsx index 4f565e55ef52c..3b117f54369a0 100644 --- a/packages/kbn-shared-ux-components/src/empty_state/kibana_no_data_page.test.tsx +++ b/packages/kbn-shared-ux-components/src/empty_state/kibana_no_data_page.test.tsx @@ -12,10 +12,10 @@ import { act } from 'react-dom/test-utils'; import { EuiLoadingElastic } from '@elastic/eui'; import { mountWithIntl } from '@kbn/test-jest-helpers'; import { SharedUxServicesProvider, mockServicesFactory } from '@kbn/shared-ux-services'; +import { NoDataViewsPrompt } from '@kbn/shared-ux-prompt-no-data-views'; import { KibanaNoDataPage } from './kibana_no_data_page'; import { NoDataConfigPage } from '../page_template'; -import { NoDataViews } from './no_data_views'; describe('Kibana No Data Page', () => { const noDataConfig = { @@ -52,7 +52,7 @@ describe('Kibana No Data Page', () => { component.update(); expect(component.find(NoDataConfigPage).length).toBe(1); - expect(component.find(NoDataViews).length).toBe(0); + expect(component.find(NoDataViewsPrompt).length).toBe(0); }); test('renders NoDataViews', async () => { @@ -66,7 +66,7 @@ describe('Kibana No Data Page', () => { await act(() => new Promise(setImmediate)); component.update(); - expect(component.find(NoDataViews).length).toBe(1); + expect(component.find(NoDataViewsPrompt).length).toBe(1); expect(component.find(NoDataConfigPage).length).toBe(0); }); @@ -90,7 +90,7 @@ describe('Kibana No Data Page', () => { component.update(); expect(component.find(EuiLoadingElastic).length).toBe(1); - expect(component.find(NoDataViews).length).toBe(0); + expect(component.find(NoDataViewsPrompt).length).toBe(0); expect(component.find(NoDataConfigPage).length).toBe(0); }); }); diff --git a/packages/kbn-shared-ux-components/src/empty_state/kibana_no_data_page.tsx b/packages/kbn-shared-ux-components/src/empty_state/kibana_no_data_page.tsx index 89ba915c07cfd..5d0f84e0bd41b 100644 --- a/packages/kbn-shared-ux-components/src/empty_state/kibana_no_data_page.tsx +++ b/packages/kbn-shared-ux-components/src/empty_state/kibana_no_data_page.tsx @@ -6,10 +6,14 @@ * Side Public License, v 1. */ import React, { useEffect, useState } from 'react'; +import { useData, useDocLinks, useEditors, usePermissions } from '@kbn/shared-ux-services'; +import { + NoDataViewsPrompt, + NoDataViewsPromptProvider, + NoDataViewsPromptServices, +} from '@kbn/shared-ux-prompt-no-data-views'; import { EuiLoadingElastic } from '@elastic/eui'; -import { useData } from '@kbn/shared-ux-services'; import { NoDataConfigPage, NoDataPageProps } from '../page_template'; -import { NoDataViews } from './no_data_views'; export interface Props { onDataViewCreated: (dataView: unknown) => void; @@ -17,6 +21,11 @@ export interface Props { } export const KibanaNoDataPage = ({ onDataViewCreated, noDataConfig }: Props) => { + // These hooks are temporary, until this component is moved to a package. + const { canCreateNewDataView } = usePermissions(); + const { dataViewsDocLink } = useDocLinks(); + const { openDataViewEditor } = useEditors(); + const { hasESData, hasUserDataView } = useData(); const [isLoading, setIsLoading] = useState(true); const [dataExists, setDataExists] = useState(false); @@ -43,8 +52,26 @@ export const KibanaNoDataPage = ({ onDataViewCreated, noDataConfig }: Props) => return ; } + /* + TODO: clintandrewhall - the use and population of `NoDataViewPromptProvider` here is temporary, + until `KibanaNoDataPage` is moved to a package of its own. + + Once `KibanaNoDataPage` is moved to a package, `NoDataViewsPromptProvider` will be *combined* + with `KibanaNoDataPageProvider`, creating a single Provider that manages contextual dependencies + throughout the React tree from the top-level of composition and consumption. + */ if (!hasUserDataViews) { - return ; + const services: NoDataViewsPromptServices = { + canCreateNewDataView, + dataViewsDocLink, + openDataViewEditor, + }; + + return ( + + + + ); } return null; diff --git a/packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.stories.tsx b/packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.stories.tsx deleted file mode 100644 index bee7c87d2841b..0000000000000 --- a/packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.stories.tsx +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ -import React from 'react'; -import { action } from '@storybook/addon-actions'; - -import { servicesFactory } from '@kbn/shared-ux-storybook'; - -import { NoDataViews as NoDataViewsComponent, Props } from './no_data_views.component'; -import { NoDataViews } from './no_data_views'; - -import mdx from './no_data_views.mdx'; - -const services = servicesFactory({}); - -export default { - title: 'No Data/No Data Views', - description: 'A component to display when there are no user-created data views available.', - parameters: { - docs: { - page: mdx, - }, - }, -}; - -export const ConnectedComponent = () => { - return ; -}; - -type Params = Pick; - -export const PureComponent = (params: Params) => { - return ; -}; - -PureComponent.argTypes = { - canCreateNewDataView: { - control: 'boolean', - defaultValue: true, - }, - dataViewsDocLink: { - options: [services.docLinks.dataViewsDocLink, undefined], - control: { type: 'radio' }, - }, -}; diff --git a/packages/kbn-shared-ux-components/src/index.ts b/packages/kbn-shared-ux-components/src/index.ts index 77586e8592b6a..fb4676e9f4e55 100644 --- a/packages/kbn-shared-ux-components/src/index.ts +++ b/packages/kbn-shared-ux-components/src/index.ts @@ -90,44 +90,3 @@ export const KibanaPageTemplateSolutionNavLazy = React.lazy(() => default: KibanaPageTemplateSolutionNav, })) ); - -/** - * A `KibanaPageTemplateSolutionNav` component that is wrapped by the `withSuspense` HOC. This component can - * be used directly by consumers and will load the `KibanaPageTemplateSolutionNavLazy` component lazily with - * a predefined fallback and error boundary. - */ -export const KibanaPageTemplateSolutionNav = withSuspense(KibanaPageTemplateSolutionNavLazy); - -/** - * The Lazily-loaded `NoDataViews` component. Consumers should use `React.Suspennse` or the - * `withSuspense` HOC to load this component. - */ -export const NoDataViewsLazy = React.lazy(() => - import('./empty_state/no_data_views').then(({ NoDataViews }) => ({ - default: NoDataViews, - })) -); - -/** - * A `NoDataViews` component that is wrapped by the `withSuspense` HOC. This component can - * be used directly by consumers and will load the `LazyNoDataViews` component lazily with - * a predefined fallback and error boundary. - */ -export const NoDataViews = withSuspense(NoDataViewsLazy); - -/** - * A pure `NoDataViews` component, with no services hooks. Consumers should use `React.Suspennse` or the - * `withSuspense` HOC to load this component. - */ -export const NoDataViewsComponentLazy = React.lazy(() => - import('./empty_state/no_data_views').then(({ NoDataViewsComponent }) => ({ - default: NoDataViewsComponent, - })) -); - -/** - * A pure `NoDataViews` component, with no services hooks. The component is wrapped by the `withSuspense` HOC. - * This component can be used directly by consumers and will load the `LazyNoDataViewsComponent` lazily with - * a predefined fallback and error boundary. - */ -export const NoDataViewsComponent = withSuspense(NoDataViewsComponentLazy); diff --git a/packages/shared-ux/prompt/no_data_views/BUILD.bazel b/packages/shared-ux/prompt/no_data_views/BUILD.bazel new file mode 100644 index 0000000000000..91fae6aeddea9 --- /dev/null +++ b/packages/shared-ux/prompt/no_data_views/BUILD.bazel @@ -0,0 +1,142 @@ +load("@npm//@bazel/typescript:index.bzl", "ts_config") +load("@build_bazel_rules_nodejs//:index.bzl", "js_library") +load("//src/dev/bazel:index.bzl", "jsts_transpiler", "pkg_npm", "pkg_npm_types", "ts_project") + +PKG_DIRNAME = "no_data_views" +PKG_REQUIRE_NAME = "@kbn/shared-ux-prompt-no-data-views" + +SOURCE_FILES = glob( + [ + "src/**/*.ts", + "src/**/*.tsx", + "src/**/*.mdx", + ], + exclude = [ + "**/*.test.*", + ], +) + +SRCS = SOURCE_FILES + +filegroup( + name = "srcs", + srcs = SRCS, +) + +NPM_MODULE_EXTRA_FILES = [ + "package.json", +] + +# In this array place runtime dependencies, including other packages and NPM packages +# which must be available for this code to run. +# +# To reference other packages use: +# "//repo/relative/path/to/package" +# eg. "//packages/kbn-utils" +# +# To reference a NPM package use: +# "@npm//name-of-package" +# eg. "@npm//lodash" +RUNTIME_DEPS = [ + "@npm//@elastic/eui", + "@npm//@emotion/css", + "@npm//@emotion/react", + "@npm//@storybook/addon-actions", + "@npm//enzyme", + "@npm//react", + "//packages/kbn-i18n-react", + "//packages/kbn-i18n", + "//packages/kbn-shared-ux-utility", +] + +# In this array place dependencies necessary to build the types, which will include the +# :npm_module_types target of other packages and packages from NPM, including @types/* +# packages. +# +# To reference the types for another package use: +# "//repo/relative/path/to/package:npm_module_types" +# eg. "//packages/kbn-utils:npm_module_types" +# +# References to NPM packages work the same as RUNTIME_DEPS +TYPES_DEPS = [ + "@npm//@elastic/eui", + "@npm//@emotion/css", + "@npm//@emotion/react", + "@npm//@storybook/addon-actions", + "@npm//@types/enzyme", + "@npm//@types/jest", + "@npm//@types/node", + "@npm//@types/react", + "//packages/kbn-ambient-ui-types", + "//packages/kbn-i18n-react:npm_module_types", + "//packages/kbn-i18n:npm_module_types", + "//packages/kbn-shared-ux-utility:npm_module_types", +] + +jsts_transpiler( + name = "target_node", + srcs = SRCS, + build_pkg_name = package_name(), +) + +jsts_transpiler( + name = "target_web", + srcs = SRCS, + build_pkg_name = package_name(), + web = True, +) + +ts_config( + name = "tsconfig", + src = "tsconfig.json", + deps = [ + "//:tsconfig.base.json", + "//:tsconfig.bazel.json", + ], +) + +ts_project( + name = "tsc_types", + args = ['--pretty'], + srcs = SRCS, + deps = TYPES_DEPS, + declaration = True, + emit_declaration_only = True, + out_dir = "target_types", + root_dir = "src", + tsconfig = ":tsconfig", +) + +js_library( + name = PKG_DIRNAME, + srcs = NPM_MODULE_EXTRA_FILES, + deps = RUNTIME_DEPS + [":target_node", ":target_web"], + package_name = PKG_REQUIRE_NAME, + visibility = ["//visibility:public"], +) + +pkg_npm( + name = "npm_module", + deps = [":" + PKG_DIRNAME], +) + +filegroup( + name = "build", + srcs = [":npm_module"], + visibility = ["//visibility:public"], +) + +pkg_npm_types( + name = "npm_module_types", + srcs = SRCS, + deps = [":tsc_types"], + package_name = PKG_REQUIRE_NAME, + tsconfig = ":tsconfig", + visibility = ["//visibility:public"], +) + +filegroup( + name = "build_types", + srcs = [":npm_module_types"], + visibility = ["//visibility:public"], +) diff --git a/packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.mdx b/packages/shared-ux/prompt/no_data_views/README.mdx similarity index 74% rename from packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.mdx rename to packages/shared-ux/prompt/no_data_views/README.mdx index ef8812c565a9f..730470c72f170 100644 --- a/packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.mdx +++ b/packages/shared-ux/prompt/no_data_views/README.mdx @@ -1,7 +1,7 @@ -**id:** sharedUX/Components/NoDataViewsPage -**slug:** /shared-ux/components/no-data-views-page -**title:** No Data Views Page -**summary:** A page to be displayed when there is data in Elasticsearch, but no data views +**id:** sharedUX/Components/NoDataViewsPrompt +**slug:** /shared-ux/components/no-data-views +**title:** No Data Views +**summary:** A prompt to be displayed when there is data in Elasticsearch, but no data views **tags:** ['shared-ux', 'component'] **date:** 2022-02-09 diff --git a/packages/kbn-shared-ux-components/src/empty_state/no_data_views/index.tsx b/packages/shared-ux/prompt/no_data_views/jest.config.js similarity index 72% rename from packages/kbn-shared-ux-components/src/empty_state/no_data_views/index.tsx rename to packages/shared-ux/prompt/no_data_views/jest.config.js index 6719fffa36740..a89d3ff222089 100644 --- a/packages/kbn-shared-ux-components/src/empty_state/no_data_views/index.tsx +++ b/packages/shared-ux/prompt/no_data_views/jest.config.js @@ -6,5 +6,8 @@ * Side Public License, v 1. */ -export { NoDataViews } from './no_data_views'; -export { NoDataViews as NoDataViewsComponent } from './no_data_views.component'; +module.exports = { + preset: '@kbn/test', + rootDir: '../../../..', + roots: ['/packages/shared-ux/prompt/no_data_views'], +}; diff --git a/packages/shared-ux/prompt/no_data_views/package.json b/packages/shared-ux/prompt/no_data_views/package.json new file mode 100644 index 0000000000000..79070e1242994 --- /dev/null +++ b/packages/shared-ux/prompt/no_data_views/package.json @@ -0,0 +1,8 @@ +{ + "name": "@kbn/shared-ux-prompt-no-data-views", + "private": true, + "version": "1.0.0", + "main": "./target_node/index.js", + "browser": "./target_web/index.js", + "license": "SSPL-1.0 OR Elastic License 2.0" +} diff --git a/packages/kbn-shared-ux-components/src/empty_state/no_data_views/__snapshots__/documentation_link.test.tsx.snap b/packages/shared-ux/prompt/no_data_views/src/__snapshots__/documentation_link.test.tsx.snap similarity index 82% rename from packages/kbn-shared-ux-components/src/empty_state/no_data_views/__snapshots__/documentation_link.test.tsx.snap rename to packages/shared-ux/prompt/no_data_views/src/__snapshots__/documentation_link.test.tsx.snap index e84b997d8df87..0f7160c7b06e8 100644 --- a/packages/kbn-shared-ux-components/src/empty_state/no_data_views/__snapshots__/documentation_link.test.tsx.snap +++ b/packages/shared-ux/prompt/no_data_views/src/__snapshots__/documentation_link.test.tsx.snap @@ -10,7 +10,7 @@ exports[` is rendered correctly 1`] = ` > @@ -26,7 +26,7 @@ exports[` is rendered correctly 1`] = ` > diff --git a/packages/shared-ux/prompt/no_data_views/src/data_view_illustration.tsx b/packages/shared-ux/prompt/no_data_views/src/data_view_illustration.tsx new file mode 100644 index 0000000000000..8a889a9267dee --- /dev/null +++ b/packages/shared-ux/prompt/no_data_views/src/data_view_illustration.tsx @@ -0,0 +1,552 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { useEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/css'; + +export const DataViewIllustration = () => { + const { euiTheme } = useEuiTheme(); + const { colors } = euiTheme; + + const dataViewIllustrationVerticalStripes = css` + fill: ${colors.fullShade}; + `; + + const dataViewIllustrationDots = css` + fill: ${colors.lightShade}; + `; + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/packages/kbn-shared-ux-components/src/empty_state/no_data_views/documentation_link.test.tsx b/packages/shared-ux/prompt/no_data_views/src/documentation_link.test.tsx similarity index 100% rename from packages/kbn-shared-ux-components/src/empty_state/no_data_views/documentation_link.test.tsx rename to packages/shared-ux/prompt/no_data_views/src/documentation_link.test.tsx diff --git a/packages/kbn-shared-ux-components/src/empty_state/no_data_views/documentation_link.tsx b/packages/shared-ux/prompt/no_data_views/src/documentation_link.tsx similarity index 88% rename from packages/kbn-shared-ux-components/src/empty_state/no_data_views/documentation_link.tsx rename to packages/shared-ux/prompt/no_data_views/src/documentation_link.tsx index 3b3e742ea74ce..2b40f30acc779 100644 --- a/packages/kbn-shared-ux-components/src/empty_state/no_data_views/documentation_link.tsx +++ b/packages/shared-ux/prompt/no_data_views/src/documentation_link.tsx @@ -20,7 +20,7 @@ export function DocumentationLink({ href }: Props) {
@@ -29,7 +29,7 @@ export function DocumentationLink({ href }: Props) {
diff --git a/packages/shared-ux/prompt/no_data_views/src/index.tsx b/packages/shared-ux/prompt/no_data_views/src/index.tsx new file mode 100644 index 0000000000000..23c2ed068f2af --- /dev/null +++ b/packages/shared-ux/prompt/no_data_views/src/index.tsx @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { withSuspense } from '@kbn/shared-ux-utility'; + +export { NoDataViewsPromptKibanaProvider, NoDataViewsPromptProvider } from './services'; +export type { NoDataViewsPromptKibanaServices, NoDataViewsPromptServices } from './services'; + +/** + * The Lazily-loaded `NoDataViewsPrompt` component. Consumers should use `React.Suspennse` or the + * `withSuspense` HOC to load this component. + */ +export const NoDataViewsPromptLazy = React.lazy(() => + import('./no_data_views').then(({ NoDataViewsPrompt }) => ({ + default: NoDataViewsPrompt, + })) +); + +/** + * A `NoDataViewsPrompt` component that is wrapped by the `withSuspense` HOC. This component can + * be used directly by consumers and will load the `NoDataViewsPromptLazy` component lazily with + * a predefined fallback and error boundary. + */ +export const NoDataViewsPrompt = withSuspense(NoDataViewsPromptLazy); + +/** + * A pure `NoDataViewsPrompt` component, with no services hooks. Consumers should use `React.Suspennse` or the + * `withSuspense` HOC to load this component. + */ +export const NoDataViewsPromptComponentLazy = React.lazy(() => + import('./no_data_views.component').then(({ NoDataViewsPrompt: Component }) => ({ + default: Component, + })) +); + +/** + * A pure `NoDataViewsPrompt` component, with no services hooks. The component is wrapped by the `withSuspense` HOC. + * This component can be used directly by consumers and will load the `NoDataViewsComponentLazy` lazily with + * a predefined fallback and error boundary. + */ +export const NoDataViewsPromptComponent = withSuspense(NoDataViewsPromptComponentLazy); diff --git a/packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.component.test.tsx b/packages/shared-ux/prompt/no_data_views/src/no_data_views.component.test.tsx similarity index 79% rename from packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.component.test.tsx rename to packages/shared-ux/prompt/no_data_views/src/no_data_views.component.test.tsx index 87dd68e202bc2..d0de72797cc2f 100644 --- a/packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.component.test.tsx +++ b/packages/shared-ux/prompt/no_data_views/src/no_data_views.component.test.tsx @@ -9,13 +9,13 @@ import React from 'react'; import { mountWithIntl } from '@kbn/test-jest-helpers'; import { EuiButton, EuiEmptyPrompt } from '@elastic/eui'; -import { NoDataViews } from './no_data_views.component'; +import { NoDataViewsPrompt } from './no_data_views.component'; import { DocumentationLink } from './documentation_link'; -describe('', () => { +describe('', () => { test('is rendered correctly', () => { const component = mountWithIntl( - ', () => { }); test('does not render button if canCreateNewDataViews is false', () => { - const component = mountWithIntl(); + const component = mountWithIntl(); expect(component.find(EuiButton).length).toBe(0); }); test('does not documentation link if linkToDocumentation is not provided', () => { const component = mountWithIntl( - + ); expect(component.find(DocumentationLink).length).toBe(0); @@ -43,7 +43,7 @@ describe('', () => { test('onClickCreate', () => { const onClickCreate = jest.fn(); const component = mountWithIntl( - + ); component.find('button').simulate('click'); diff --git a/packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.component.tsx b/packages/shared-ux/prompt/no_data_views/src/no_data_views.component.tsx similarity index 77% rename from packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.component.tsx rename to packages/shared-ux/prompt/no_data_views/src/no_data_views.component.tsx index 3131b6ab2a73c..f53a187265703 100644 --- a/packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.component.tsx +++ b/packages/shared-ux/prompt/no_data_views/src/no_data_views.component.tsx @@ -13,7 +13,7 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiButton, EuiEmptyPrompt, EuiEmptyPromptProps } from '@elastic/eui'; -import { DataViewIllustration } from '../assets'; +import { DataViewIllustration } from './data_view_illustration'; import { DocumentationLink } from './documentation_link'; export interface Props { @@ -23,7 +23,7 @@ export interface Props { emptyPromptColor?: EuiEmptyPromptProps['color']; } -const createDataViewText = i18n.translate('sharedUXComponents.noDataViewsPrompt.addDataViewText', { +const createDataViewText = i18n.translate('sharedUXPackages.noDataViewsPrompt.addDataViewText', { defaultMessage: 'Create data view', }); @@ -33,13 +33,13 @@ const MAX_WIDTH = 830; /** * A presentational component that is shown in cases when there are no data views created yet. */ -export const NoDataViews = ({ +export const NoDataViewsPrompt = ({ onClickCreate, canCreateNewDataView, dataViewsDocLink, emptyPromptColor = 'plain', }: Props) => { - const createNewButton = canCreateNewDataView && ( + const actions = canCreateNewDataView && (
) : (

@@ -74,19 +74,22 @@ export const NoDataViews = ({ const body = canCreateNewDataView ? (

) : (

); + const icon = ; + const footer = dataViewsDocLink ? : undefined; + return ( } - title={title} - body={body} - actions={createNewButton} - footer={dataViewsDocLink && } + {...{ actions, icon, title, body, footer }} /> ); }; diff --git a/packages/shared-ux/prompt/no_data_views/src/no_data_views.stories.tsx b/packages/shared-ux/prompt/no_data_views/src/no_data_views.stories.tsx new file mode 100644 index 0000000000000..c9e983c5f01b2 --- /dev/null +++ b/packages/shared-ux/prompt/no_data_views/src/no_data_views.stories.tsx @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import React from 'react'; +import { action } from '@storybook/addon-actions'; + +import { NoDataViewsPrompt as NoDataViewsPromptComponent, Props } from './no_data_views.component'; +import { NoDataViewsPrompt } from './no_data_views'; +import { NoDataViewsPromptProvider, NoDataViewsPromptServices } from './services'; + +import mdx from '../README.mdx'; + +export default { + title: 'No Data/No Data Views', + description: 'A component to display when there are no user-created data views available.', + parameters: { + docs: { + page: mdx, + }, + }, +}; + +type ConnectedParams = Pick; + +const openDataViewEditor: NoDataViewsPromptServices['openDataViewEditor'] = (options) => { + action('openDataViewEditor')(options); + return () => {}; +}; + +export const ConnectedComponent = (params: ConnectedParams) => { + return ( + + + + ); +}; + +ConnectedComponent.argTypes = { + canCreateNewDataView: { + control: 'boolean', + defaultValue: true, + }, + dataViewsDocLink: { + options: ['some/link', undefined], + control: { type: 'radio' }, + }, +}; + +type PureParams = Pick; + +export const PureComponent = (params: PureParams) => { + return ; +}; + +PureComponent.argTypes = { + canCreateNewDataView: { + control: 'boolean', + defaultValue: true, + }, + dataViewsDocLink: { + options: ['some/link', undefined], + control: { type: 'radio' }, + }, +}; diff --git a/packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.test.tsx b/packages/shared-ux/prompt/no_data_views/src/no_data_views.test.tsx similarity index 53% rename from packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.test.tsx rename to packages/shared-ux/prompt/no_data_views/src/no_data_views.test.tsx index bb067544013c8..041e71d87e2ae 100644 --- a/packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.test.tsx +++ b/packages/shared-ux/prompt/no_data_views/src/no_data_views.test.tsx @@ -12,21 +12,23 @@ import { ReactWrapper } from 'enzyme'; import { mountWithIntl } from '@kbn/test-jest-helpers'; import { EuiButton } from '@elastic/eui'; -import { - SharedUxServicesProvider, - SharedUxServices, - mockServicesFactory, -} from '@kbn/shared-ux-services'; -import { NoDataViews } from './no_data_views'; - -describe('', () => { - let services: SharedUxServices; +import { NoDataViewsPrompt } from './no_data_views'; +import { NoDataViewsPromptServices, NoDataViewsPromptProvider } from './services'; + +const getServices = (canCreateNewDataView: boolean = true) => ({ + canCreateNewDataView, + openDataViewEditor: jest.fn(), + dataViewsDocLink: 'some/link', +}); + +describe('', () => { + let services: NoDataViewsPromptServices; let mount: (element: JSX.Element) => ReactWrapper; beforeEach(() => { - services = mockServicesFactory(); + services = getServices(); mount = (element: JSX.Element) => - mountWithIntl({element}); + mountWithIntl({element}); }); afterEach(() => { @@ -34,13 +36,13 @@ describe('', () => { }); test('on dataView created', () => { - const component = mount(); + const component = mount(); - expect(services.editors.openDataViewEditor).not.toHaveBeenCalled(); + expect(services.openDataViewEditor).not.toHaveBeenCalled(); component.find(EuiButton).simulate('click'); component.unmount(); - expect(services.editors.openDataViewEditor).toHaveBeenCalled(); + expect(services.openDataViewEditor).toHaveBeenCalled(); }); }); diff --git a/packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.tsx b/packages/shared-ux/prompt/no_data_views/src/no_data_views.tsx similarity index 72% rename from packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.tsx rename to packages/shared-ux/prompt/no_data_views/src/no_data_views.tsx index 8d0e6d93275e1..da618674810ce 100644 --- a/packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.tsx +++ b/packages/shared-ux/prompt/no_data_views/src/no_data_views.tsx @@ -8,20 +8,18 @@ import React, { useCallback, useEffect, useRef } from 'react'; -import { useEditors, usePermissions, useDocLinks } from '@kbn/shared-ux-services'; -import type { SharedUxEditorsService } from '@kbn/shared-ux-services'; - -import { NoDataViews as NoDataViewsComponent } from './no_data_views.component'; +import { NoDataViewsPrompt as NoDataViewsPromptComponent } from './no_data_views.component'; +import { useServices, NoDataViewsPromptServices } from './services'; // TODO: https://github.com/elastic/kibana/issues/127695 export interface Props { onDataViewCreated: (dataView: unknown) => void; } -type CloseDataViewEditorFn = ReturnType; +type CloseDataViewEditorFn = ReturnType; /** - * A service-enabled component that provides Kibana-specific functionality to the `NoDataViews` + * A service-enabled component that provides Kibana-specific functionality to the `NoDataViewsPrompt` * component. * * Use of this component requires both the `EuiTheme` context as well as either a configured Shared UX @@ -29,10 +27,8 @@ type CloseDataViewEditorFn = ReturnType { - const { canCreateNewDataView } = usePermissions(); - const { openDataViewEditor } = useEditors(); - const { dataViewsDocLink } = useDocLinks(); +export const NoDataViewsPrompt = ({ onDataViewCreated }: Props) => { + const { canCreateNewDataView, openDataViewEditor, dataViewsDocLink } = useServices(); const closeDataViewEditor = useRef(); useEffect(() => { @@ -69,5 +65,7 @@ export const NoDataViews = ({ onDataViewCreated }: Props) => { } }, [canCreateNewDataView, openDataViewEditor, setDataViewEditorRef, onDataViewCreated]); - return ; + return ( + + ); }; diff --git a/packages/shared-ux/prompt/no_data_views/src/services.tsx b/packages/shared-ux/prompt/no_data_views/src/services.tsx new file mode 100644 index 0000000000000..58d21d1845b56 --- /dev/null +++ b/packages/shared-ux/prompt/no_data_views/src/services.tsx @@ -0,0 +1,115 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { FC, useContext } from 'react'; + +/** + * TODO: `DataView` is a class exported by `src/plugins/data_views/public`. Since this service + * is contained in this package-- and packages can only depend on other packages and never on + * plugins-- we have to set this to `unknown`. If and when `DataView` is exported from a + * stateless package, we can remove this. + * + * @see: https://github.com/elastic/kibana/issues/127695 + */ +type DataView = unknown; + +/** + * A subset of the `DataViewEditorOptions` interface relevant to our service and components. + * + * @see: src/plugins/data_view_editor/public/types.ts + */ +interface DataViewEditorOptions { + /** Handler to be invoked when the Data View Editor completes a save operation. */ + onSave: (dataView: DataView) => void; + /** If set to false, will skip empty prompt in data view editor. */ + showEmptyPrompt?: boolean; +} + +/** + * Abstract external services for this component. + */ +export interface NoDataViewsPromptServices { + /** True if the user has permission to create a new Data View, false otherwise. */ + canCreateNewDataView: boolean; + /** A method to open the Data View Editor flow. */ + openDataViewEditor: (options: DataViewEditorOptions) => () => void; + /** A link to information about Data Views in Kibana */ + dataViewsDocLink: string; +} + +const NoDataViewsPromptContext = React.createContext(null); + +/** + * Abstract external service Provider. + */ +export const NoDataViewsPromptProvider: FC = ({ + children, + ...services +}) => { + return ( + + {children} + + ); +}; + +/** + * Kibana-specific service types. + */ +export interface NoDataViewsPromptKibanaServices { + coreStart: { + docLinks: { + links: { + indexPatterns: { + introduction: string; + }; + }; + }; + }; + dataViewEditor: { + userPermissions: { + editDataView: () => boolean; + }; + openEditor: (options: DataViewEditorOptions) => () => void; + }; +} + +/** + * Kibana-specific Provider that maps to known dependency types. + */ +export const NoDataViewsPromptKibanaProvider: FC = ({ + children, + ...services +}) => { + return ( + + {children} + + ); +}; + +/** + * React hook for accessing pre-wired services. + */ +export function useServices() { + const context = useContext(NoDataViewsPromptContext); + + if (!context) { + throw new Error( + 'NoDataViewsPromptContext is missing. Ensure your component or React root is wrapped with NoDataViewsPromptProvider.' + ); + } + + return context; +} diff --git a/packages/shared-ux/prompt/no_data_views/tsconfig.json b/packages/shared-ux/prompt/no_data_views/tsconfig.json new file mode 100644 index 0000000000000..45842fa3da472 --- /dev/null +++ b/packages/shared-ux/prompt/no_data_views/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../../../tsconfig.bazel.json", + "compilerOptions": { + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "target_types", + "rootDir": "src", + "stripInternal": false, + "types": [ + "jest", + "node", + "react", + "@emotion/react/types/css-prop", + "@kbn/ambient-ui-types", + ] + }, + "include": [ + "src/**/*" + ] +} diff --git a/src/plugins/data_view_editor/public/components/empty_prompts/empty_prompts.tsx b/src/plugins/data_view_editor/public/components/empty_prompts/empty_prompts.tsx index ecfdd9e5c1c92..690bfa1f7acb8 100644 --- a/src/plugins/data_view_editor/public/components/empty_prompts/empty_prompts.tsx +++ b/src/plugins/data_view_editor/public/components/empty_prompts/empty_prompts.tsx @@ -9,9 +9,9 @@ import React, { useState, FC, useEffect } from 'react'; import useAsync from 'react-use/lib/useAsync'; -import { NoDataViewsComponent } from '@kbn/shared-ux-components'; import { EuiFlyoutBody } from '@elastic/eui'; import { DEFAULT_ASSETS_TO_IGNORE } from '@kbn/data-plugin/common'; +import { NoDataViewsPromptComponent } from '@kbn/shared-ux-prompt-no-data-views'; import { useKibana } from '../../shared_imports'; import { MatchedItem, DataViewEditorContext } from '../../types'; @@ -105,7 +105,7 @@ export const EmptyPrompts: FC = ({ return ( <> - setGoToForm(true)} canCreateNewDataView={application.capabilities.indexPatterns.save as boolean} dataViewsDocLink={docLinks.links.indexPatterns.introduction} diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 70d3a81a2f808..f211cc9fede8e 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -5367,8 +5367,8 @@ "sharedUXComponents.noDataPage.elasticAgentCard.noPermission.description": "Cette intégration n'est pas encore activée. Votre administrateur possède les autorisations requises pour l’activer.", "sharedUXComponents.noDataPage.elasticAgentCard.noPermission.title": "Contactez votre administrateur", "sharedUXComponents.noDataPage.elasticAgentCard.title": "Ajouter Elastic Agent", - "sharedUXComponents.noDataViews.learnMore": "Envie d'en savoir plus ?", - "sharedUXComponents.noDataViews.readDocumentation": "Lisez les documents", + "sharedUXPackages.noDataViewsPrompt.learnMore": "Envie d'en savoir plus ?", + "sharedUXPackages.noDataViewsPrompt.readDocumentation": "Lisez les documents", "sharedUXComponents.pageTemplate.noDataCard.description": "Continuer sans collecter de données", "sharedUXComponents.toolbar.buttons.addFromLibrary.libraryButtonLabel": "Ajouter depuis la bibliothèque", "telemetry.callout.appliesSettingTitle": "Les modifications apportées à ce paramètre s'appliquent dans {allOfKibanaText} et sont enregistrées automatiquement.", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index a20feeeccdb1b..eec41bfb71c81 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -5469,8 +5469,8 @@ "sharedUXComponents.noDataPage.elasticAgentCard.noPermission.description": "この統合はまだ有効ではありません。管理者にはオンにするために必要なアクセス権があります。", "sharedUXComponents.noDataPage.elasticAgentCard.noPermission.title": "管理者にお問い合わせください", "sharedUXComponents.noDataPage.elasticAgentCard.title": "Elasticエージェントの追加", - "sharedUXComponents.noDataViews.learnMore": "詳細について", - "sharedUXComponents.noDataViews.readDocumentation": "ドキュメントを読む", + "sharedUXPackages.noDataViewsPrompt.learnMore": "詳細について", + "sharedUXPackages.noDataViewsPrompt.readDocumentation": "ドキュメントを読む", "sharedUXComponents.pageTemplate.noDataCard.description": "データを収集せずに続行", "sharedUXComponents.toolbar.buttons.addFromLibrary.libraryButtonLabel": "ライブラリから追加", "telemetry.callout.appliesSettingTitle": "この設定に加えた変更は {allOfKibanaText} に適用され、自動的に保存されます。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index a2c33d9a1fae7..2d7566bdd8c87 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -5480,8 +5480,8 @@ "sharedUXComponents.noDataPage.elasticAgentCard.noPermission.description": "尚未启用此集成。您的管理员具有打开它所需的权限。", "sharedUXComponents.noDataPage.elasticAgentCard.noPermission.title": "请联系您的管理员", "sharedUXComponents.noDataPage.elasticAgentCard.title": "添加 Elastic 代理", - "sharedUXComponents.noDataViews.learnMore": "希望了解详情?", - "sharedUXComponents.noDataViews.readDocumentation": "阅读文档", + "sharedUXPackages.noDataViewsPrompt.learnMore": "希望了解详情?", + "sharedUXPackages.noDataViewsPrompt.readDocumentation": "阅读文档", "sharedUXComponents.pageTemplate.noDataCard.description": "继续,而不收集数据", "sharedUXComponents.toolbar.buttons.addFromLibrary.libraryButtonLabel": "从库中添加", "telemetry.callout.appliesSettingTitle": "对此设置的更改将应用到{allOfKibanaText} 且会自动保存。", diff --git a/yarn.lock b/yarn.lock index 5225ebe505cbe..3668e805f67cb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3216,6 +3216,11 @@ version "0.0.0" uid "" + +"@kbn/shared-ux-prompt-no-data-views@link:bazel-bin/packages/shared-ux/prompt/no_data_views": + version "0.0.0" + uid "" + "@kbn/shared-ux-services@link:bazel-bin/packages/kbn-shared-ux-services": version "0.0.0" uid "" @@ -6421,6 +6426,11 @@ version "0.0.0" uid "" + +"@types/kbn__shared-ux-prompt-no-data-views@link:bazel-bin/packages/shared-ux/prompt/no_data_views/npm_module_types": + version "0.0.0" + uid "" + "@types/kbn__shared-ux-services@link:bazel-bin/packages/kbn-shared-ux-services/npm_module_types": version "0.0.0" uid "" From 0c43f86470bb4cc52969e434103e654a854c2c57 Mon Sep 17 00:00:00 2001 From: nastasha-solomon <79124755+nastasha-solomon@users.noreply.github.com> Date: Thu, 19 May 2022 17:29:48 -0400 Subject: [PATCH 26/35] [DOCS] Remove note that pre-configured connectors are not supported on cases (#132186) --- docs/management/connectors/pre-configured-connectors.asciidoc | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/management/connectors/pre-configured-connectors.asciidoc b/docs/management/connectors/pre-configured-connectors.asciidoc index 27d1d80ea7305..7498784ef389e 100644 --- a/docs/management/connectors/pre-configured-connectors.asciidoc +++ b/docs/management/connectors/pre-configured-connectors.asciidoc @@ -12,8 +12,6 @@ action are predefined, including the connector name and ID. - Appear in all spaces because they are not saved objects. - Cannot be edited or deleted. -NOTE: Preconfigured connectors cannot be used with cases. - [float] [[preconfigured-connector-example]] ==== Preconfigured connectors example From efd30bc0077f98db0b162911c23fc703a1ad7880 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 19 May 2022 15:22:51 -0700 Subject: [PATCH 27/35] Update ftr (#132558) Co-authored-by: Renovate Bot --- package.json | 6 +++--- yarn.lock | 33 +++++++++++++++++++++------------ 2 files changed, 24 insertions(+), 15 deletions(-) diff --git a/package.json b/package.json index 72f4acfc18354..9b01ec9decdcb 100644 --- a/package.json +++ b/package.json @@ -757,7 +757,7 @@ "@types/redux-logger": "^3.0.8", "@types/resolve": "^1.20.1", "@types/seedrandom": ">=2.0.0 <4.0.0", - "@types/selenium-webdriver": "^4.0.19", + "@types/selenium-webdriver": "^4.1.0", "@types/semver": "^7", "@types/set-value": "^2.0.0", "@types/sinon": "^7.0.13", @@ -812,7 +812,7 @@ "callsites": "^3.1.0", "chai": "3.5.0", "chance": "1.0.18", - "chromedriver": "^100.0.0", + "chromedriver": "^101.0.0", "clean-webpack-plugin": "^3.0.0", "cmd-shim": "^2.1.0", "compression-webpack-plugin": "^4.0.0", @@ -933,7 +933,7 @@ "resolve": "^1.22.0", "rxjs-marbles": "^5.0.6", "sass-loader": "^10.2.0", - "selenium-webdriver": "^4.1.1", + "selenium-webdriver": "^4.1.2", "shelljs": "^0.8.4", "simple-git": "1.116.0", "sinon": "^7.4.2", diff --git a/yarn.lock b/yarn.lock index 3668e805f67cb..88a23a226d0e8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7112,10 +7112,12 @@ resolved "https://registry.yarnpkg.com/@types/seedrandom/-/seedrandom-2.4.28.tgz#9ce8fa048c1e8c85cb71d7fe4d704e000226036f" integrity sha512-SMA+fUwULwK7sd/ZJicUztiPs8F1yCPwF3O23Z9uQ32ME5Ha0NmDK9+QTsYE4O2tHXChzXomSWWeIhCnoN1LqA== -"@types/selenium-webdriver@^4.0.19": - version "4.0.19" - resolved "https://registry.yarnpkg.com/@types/selenium-webdriver/-/selenium-webdriver-4.0.19.tgz#25699713552a63ee70215effdfd2e5d6dda19f8e" - integrity sha512-Irrh+iKc6Cxj6DwTupi4zgWhSBm1nK+JElOklIUiBVE6rcLYDtT1mwm9oFkHie485BQXNmZRoayjwxhowdInnA== +"@types/selenium-webdriver@^4.1.0": + version "4.1.0" + resolved "https://registry.yarnpkg.com/@types/selenium-webdriver/-/selenium-webdriver-4.1.0.tgz#b23ba7e7f4f59069529c57f0cbb7f5fba74affe7" + integrity sha512-ehqwZemosqiWVe+W0f5GqcLH7NgtjMBmcknmeaPG6YZHc7EZ69XbD7VVNZcT/L8lyMIL/KG99MsGcvDuFWo3Yw== + dependencies: + "@types/ws" "*" "@types/semver@^7": version "7.3.4" @@ -7387,6 +7389,13 @@ resolved "https://registry.yarnpkg.com/@types/write-pkg/-/write-pkg-3.1.0.tgz#f58767f4fb9a6a3ad8e95d3e9cd1f2d026ceab26" integrity sha512-JRGsPEPCrYqTXU0Cr+Yu7esPBE2yvH7ucOHr+JuBy0F59kglPvO5gkmtyEvf3P6dASSkScvy/XQ6SC1QEBFDuA== +"@types/ws@*": + version "8.5.3" + resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.5.3.tgz#7d25a1ffbecd3c4f2d35068d0b283c037003274d" + integrity sha512-6YOoWjruKj1uLf3INHH7D3qTXwFfEsg1kf3c0uDdSBJwfa/llkwIjrAGV7j7mVgGNbzTQ3HiHKKDXl6bJPD97w== + dependencies: + "@types/node" "*" + "@types/xml-crypto@^1.4.2": version "1.4.2" resolved "https://registry.yarnpkg.com/@types/xml-crypto/-/xml-crypto-1.4.2.tgz#5ea7ef970f525ae8fe1e2ce0b3d40da1e3b279ae" @@ -10255,10 +10264,10 @@ chrome-trace-event@^1.0.2: dependencies: tslib "^1.9.0" -chromedriver@^100.0.0: - version "100.0.0" - resolved "https://registry.yarnpkg.com/chromedriver/-/chromedriver-100.0.0.tgz#1b4bf5c89cea12c79f53bc94d8f5bb5aa79ed7be" - integrity sha512-oLfB0IgFEGY9qYpFQO/BNSXbPw7bgfJUN5VX8Okps9W2qNT4IqKh5hDwKWtpUIQNI6K3ToWe2/J5NdpurTY02g== +chromedriver@^101.0.0: + version "101.0.0" + resolved "https://registry.yarnpkg.com/chromedriver/-/chromedriver-101.0.0.tgz#ad19003008dd5df1770a1ad96059a9c5fe78e365" + integrity sha512-LkkWxy6KM/0YdJS8qBeg5vfkTZTRamhBfOttb4oic4echDgWvCU1E8QcBbUBOHqZpSrYMyi7WMKmKMhXFUaZ+w== dependencies: "@testim/chrome-version" "^1.1.2" axios "^0.24.0" @@ -25515,10 +25524,10 @@ select-hose@^2.0.0: resolved "https://registry.yarnpkg.com/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca" integrity sha1-Yl2GWPhlr0Psliv8N2o3NZpJlMo= -selenium-webdriver@^4.1.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/selenium-webdriver/-/selenium-webdriver-4.1.1.tgz#da083177d811f36614950e809e2982570f67d02e" - integrity sha512-Fr9e9LC6zvD6/j7NO8M1M/NVxFX67abHcxDJoP5w2KN/Xb1SyYLjMVPGgD14U2TOiKe4XKHf42OmFw9g2JgCBQ== +selenium-webdriver@^4.1.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/selenium-webdriver/-/selenium-webdriver-4.1.2.tgz#d463b4335632d2ea41a9e988e435a55dc41f5314" + integrity sha512-e4Ap8vQvhipgBB8Ry9zBiKGkU6kHKyNnWiavGGLKkrdW81Zv7NVMtFOL/j3yX0G8QScM7XIXijKssNd4EUxSOw== dependencies: jszip "^3.6.0" tmp "^0.2.1" From 1ea3fc6d32486656d8ed5e2f5e637e61baf24245 Mon Sep 17 00:00:00 2001 From: "Joey F. Poon" Date: Thu, 19 May 2022 18:00:14 -0500 Subject: [PATCH 28/35] [Security Solution] improve endpoint metadata tests (#125883) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../data_loaders/index_fleet_agent.ts | 2 +- .../services/endpoint.ts | 68 +++++++++++++++---- .../apis/endpoint_authz.ts | 9 --- .../apis/metadata.ts | 49 ++++++------- 4 files changed, 80 insertions(+), 48 deletions(-) diff --git a/x-pack/plugins/security_solution/common/endpoint/data_loaders/index_fleet_agent.ts b/x-pack/plugins/security_solution/common/endpoint/data_loaders/index_fleet_agent.ts index b051eff37edc7..8719db5036b83 100644 --- a/x-pack/plugins/security_solution/common/endpoint/data_loaders/index_fleet_agent.ts +++ b/x-pack/plugins/security_solution/common/endpoint/data_loaders/index_fleet_agent.ts @@ -23,7 +23,7 @@ import { wrapErrorAndRejectPromise } from './utils'; const defaultFleetAgentGenerator = new FleetAgentGenerator(); export interface IndexedFleetAgentResponse { - agents: Agent[]; + agents: Array; fleetAgentsIndex: string; } diff --git a/x-pack/test/security_solution_endpoint/services/endpoint.ts b/x-pack/test/security_solution_endpoint/services/endpoint.ts index 27dcd67c6d684..d526c59ee6864 100644 --- a/x-pack/test/security_solution_endpoint/services/endpoint.ts +++ b/x-pack/test/security_solution_endpoint/services/endpoint.ts @@ -11,6 +11,7 @@ import { metadataCurrentIndexPattern, metadataTransformPrefix, METADATA_UNITED_INDEX, + METADATA_UNITED_TRANSFORM, } from '@kbn/security-solution-plugin/common/endpoint/constants'; import { deleteIndexedHostsAndAlerts, @@ -77,6 +78,27 @@ export class EndpointTestResources extends FtrService { await this.transform.api.updateTransform(transform.id, { frequency }).catch(catchAndWrapError); } + private async stopTransform(transformId: string) { + const stopRequest = { + transform_id: `${transformId}*`, + force: true, + wait_for_completion: true, + allow_no_match: true, + }; + return this.esClient.transform.stopTransform(stopRequest); + } + + private async startTransform(transformId: string) { + const transformsResponse = await this.esClient.transform.getTransform({ + transform_id: `${transformId}*`, + }); + return Promise.all( + transformsResponse.transforms.map((transform) => { + return this.esClient.transform.startTransform({ transform_id: transform.id }); + }) + ); + } + /** * Loads endpoint host/alert/event data into elasticsearch * @param [options] @@ -86,6 +108,8 @@ export class EndpointTestResources extends FtrService { * @param [options.enableFleetIntegration=true] When set to `true`, Fleet data will also be loaded (ex. Integration Policies, Agent Policies, "fake" Agents) * @param [options.generatorSeed='seed`] The seed to be used by the data generator. Important in order to ensure the same data is generated on very run. * @param [options.waitUntilTransformed=true] If set to `true`, the data loading process will wait until the endpoint hosts metadata is processed by the transform + * @param [options.waitTimeout=60000] If waitUntilTransformed=true, number of ms to wait until timeout + * @param [options.customIndexFn] If provided, will use this function to generate and index data instead */ async loadEndpointData( options: Partial<{ @@ -95,6 +119,8 @@ export class EndpointTestResources extends FtrService { enableFleetIntegration: boolean; generatorSeed: string; waitUntilTransformed: boolean; + waitTimeout: number; + customIndexFn: () => Promise; }> = {} ): Promise { const { @@ -104,25 +130,39 @@ export class EndpointTestResources extends FtrService { enableFleetIntegration = true, generatorSeed = 'seed', waitUntilTransformed = true, + waitTimeout = 60000, + customIndexFn, } = options; + if (waitUntilTransformed) { + // need this before indexing docs so that the united transform doesn't + // create a checkpoint with a timestamp after the doc timestamps + await this.stopTransform(METADATA_UNITED_TRANSFORM); + } + // load data into the system - const indexedData = await indexHostsAndAlerts( - this.esClient as Client, - this.kbnClient, - generatorSeed, - numHosts, - numHostDocs, - 'metrics-endpoint.metadata-default', - 'metrics-endpoint.policy-default', - 'logs-endpoint.events.process-default', - 'logs-endpoint.alerts-default', - alertsPerHost, - enableFleetIntegration - ); + const indexedData = customIndexFn + ? await customIndexFn() + : await indexHostsAndAlerts( + this.esClient as Client, + this.kbnClient, + generatorSeed, + numHosts, + numHostDocs, + 'metrics-endpoint.metadata-default', + 'metrics-endpoint.policy-default', + 'logs-endpoint.events.process-default', + 'logs-endpoint.alerts-default', + alertsPerHost, + enableFleetIntegration + ); if (waitUntilTransformed) { - await this.waitForEndpoints(indexedData.hosts.map((host) => host.agent.id)); + const metadataIds = Array.from(new Set(indexedData.hosts.map((host) => host.agent.id))); + await this.waitForEndpoints(metadataIds, waitTimeout); + await this.startTransform(METADATA_UNITED_TRANSFORM); + const agentIds = Array.from(new Set(indexedData.agents.map((agent) => agent.agent!.id))); + await this.waitForUnitedEndpoints(agentIds, waitTimeout); } return indexedData; diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/endpoint_authz.ts b/x-pack/test/security_solution_endpoint_api_int/apis/endpoint_authz.ts index f560103c6c862..1a009aaef07ec 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/endpoint_authz.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/endpoint_authz.ts @@ -5,7 +5,6 @@ * 2.0. */ -import { IndexedHostsAndAlertsResponse } from '@kbn/security-solution-plugin/common/endpoint/index_data'; import { wrapErrorAndRejectPromise } from '@kbn/security-solution-plugin/common/endpoint/data_loaders/utils'; import { FtrProviderContext } from '../ftr_provider_context'; import { @@ -15,23 +14,15 @@ import { } from '../../common/services/security_solution'; export default function ({ getService }: FtrProviderContext) { - const endpointTestResources = getService('endpointTestResources'); const supertestWithoutAuth = getService('supertestWithoutAuth'); describe('When attempting to call an endpoint api with no authz', () => { - let loadedData: IndexedHostsAndAlertsResponse; - before(async () => { // create role/user await createUserAndRole(getService, ROLES.t1_analyst); - loadedData = await endpointTestResources.loadEndpointData(); }); after(async () => { - if (loadedData) { - await endpointTestResources.unloadEndpointData(loadedData); - } - // delete role/user await deleteUserAndRole(getService, ROLES.t1_analyst); }); diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts b/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts index 9b023e6992385..047b21827c5c3 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts @@ -19,6 +19,8 @@ import { import { AGENTS_INDEX } from '@kbn/fleet-plugin/common'; import { indexFleetEndpointPolicy } from '@kbn/security-solution-plugin/common/endpoint/data_loaders/index_fleet_endpoint_policy'; import { TRANSFORM_STATES } from '@kbn/security-solution-plugin/common/constants'; +import type { IndexedHostsAndAlertsResponse } from '@kbn/security-solution-plugin/common/endpoint/index_data'; + import { generateAgentDocs, generateMetadataDocs } from './metadata.fixtures'; import { deleteAllDocsFromMetadataCurrentIndex, @@ -47,38 +49,37 @@ export default function ({ getService }: FtrProviderContext) { const numberOfHostsInFixture = 2; before(async () => { - await stopTransform(getService, `${METADATA_UNITED_TRANSFORM}*`); await deleteAllDocsFromFleetAgents(getService); await deleteAllDocsFromMetadataDatastream(getService); await deleteAllDocsFromMetadataCurrentIndex(getService); await deleteAllDocsFromIndex(getService, METADATA_UNITED_INDEX); - // generate an endpoint policy and attach id to agents since - // metadata list api filters down to endpoint policies only - const policy = await indexFleetEndpointPolicy( - getService('kibanaServer'), - `Default ${uuid.v4()}`, - '1.1.1' - ); - const policyId = policy.integrationPolicies[0].policy_id; - const currentTime = new Date().getTime(); + const customIndexFn = async (): Promise => { + // generate an endpoint policy and attach id to agents since + // metadata list api filters down to endpoint policies only + const policy = await indexFleetEndpointPolicy( + getService('kibanaServer'), + `Default ${uuid.v4()}`, + '1.1.1' + ); + const policyId = policy.integrationPolicies[0].policy_id; + const currentTime = new Date().getTime(); - const agentDocs = generateAgentDocs(currentTime, policyId); + const agentDocs = generateAgentDocs(currentTime, policyId); + const metadataDocs = generateMetadataDocs(currentTime); - await Promise.all([ - bulkIndex(getService, AGENTS_INDEX, agentDocs), - bulkIndex(getService, METADATA_DATASTREAM, generateMetadataDocs(currentTime)), - ]); + await Promise.all([ + bulkIndex(getService, AGENTS_INDEX, agentDocs), + bulkIndex(getService, METADATA_DATASTREAM, metadataDocs), + ]); - await endpointTestResources.waitForEndpoints( - agentDocs.map((doc) => doc.agent.id), - 60000 - ); - await startTransform(getService, METADATA_UNITED_TRANSFORM); - await endpointTestResources.waitForUnitedEndpoints( - agentDocs.map((doc) => doc.agent.id), - 60000 - ); + return { + agents: agentDocs, + hosts: metadataDocs, + } as unknown as IndexedHostsAndAlertsResponse; + }; + + await endpointTestResources.loadEndpointData({ customIndexFn }); }); after(async () => { From cadd7b33b84d403c4dca2b2fb7c99aa78f505d17 Mon Sep 17 00:00:00 2001 From: Kaarina Tungseth Date: Thu, 19 May 2022 18:16:59 -0500 Subject: [PATCH 29/35] Adds example for how to change a field format (#132541) --- docs/api/data-views/update-fields.asciidoc | 50 +++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/docs/api/data-views/update-fields.asciidoc b/docs/api/data-views/update-fields.asciidoc index 3ec4b7c84694a..c43daff187528 100644 --- a/docs/api/data-views/update-fields.asciidoc +++ b/docs/api/data-views/update-fields.asciidoc @@ -60,6 +60,53 @@ $ curl -X POST api/data_views/data-view/my-view/fields -------------------------------------------------- // KIBANA +Change a simple field format: + +[source,sh] +-------------------------------------------------- +$ curl -X POST api/data_views/data-view/my-view/fields +{ + "fields": { + "foo": { + "format": { + "id": "bytes" + } + } + } +} +-------------------------------------------------- +// KIBANA + +Change a complex field format: + +[source,sh] +-------------------------------------------------- +$ curl -X POST api/data_views/data-view/my-view/fields +{ + "fields": { + "foo": { + "format": { + "id": "static_lookup", + "params": { + "lookupEntries": [ + { + "key": "1", + "value": "100" + }, + { + "key": "2", + "value": "200" + } + ], + "unknownKeyValue": "5000" + } + } + } + } +} +-------------------------------------------------- +// KIBANA + Update multiple metadata fields in one request: [source,sh] @@ -80,6 +127,7 @@ $ curl -X POST api/data_views/data-view/my-view/fields // KIBANA Use `null` value to delete metadata: + [source,sh] -------------------------------------------------- $ curl -X POST api/data_views/data-view/my-pattern/fields @@ -93,8 +141,8 @@ $ curl -X POST api/data_views/data-view/my-pattern/fields -------------------------------------------------- // KIBANA - The endpoint returns the updated data view object: + [source,sh] -------------------------------------------------- { From 04f47dda7453fe8c02d8b7137d805f7d406e25a6 Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Thu, 19 May 2022 20:19:00 -0400 Subject: [PATCH 30/35] Fix upgrade available overflow (#132555) --- .../fleet/sections/agents/agent_list_page/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx index f12a99c6e37f9..223ff395eb444 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx @@ -400,12 +400,12 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { }, { field: 'local_metadata.elastic.agent.version', - width: '120px', + width: '135px', name: i18n.translate('xpack.fleet.agentList.versionTitle', { defaultMessage: 'Version', }), render: (version: string, agent: Agent) => ( - + {safeMetadata(version)} From 419d4e2e5942c378045667e8675a20b9db0e19fc Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Fri, 20 May 2022 02:30:01 +0100 Subject: [PATCH 31/35] docs(NA): adds @kbn/test-subj-selector into ops devdocs (#132505) --- dev_docs/operations/operations_landing.mdx | 1 + nav-kibana-dev.docnav.json | 3 ++- packages/kbn-test-subj-selector/BUILD.bazel | 1 - packages/kbn-test-subj-selector/README.md | 3 --- packages/kbn-test-subj-selector/README.mdx | 10 ++++++++++ 5 files changed, 13 insertions(+), 5 deletions(-) delete mode 100755 packages/kbn-test-subj-selector/README.md create mode 100755 packages/kbn-test-subj-selector/README.mdx diff --git a/dev_docs/operations/operations_landing.mdx b/dev_docs/operations/operations_landing.mdx index cda44a96fe4dd..8a54ee0a90a43 100644 --- a/dev_docs/operations/operations_landing.mdx +++ b/dev_docs/operations/operations_landing.mdx @@ -45,5 +45,6 @@ layout: landing { pageId: "kibDevDocsOpsExpect" }, { pageId: "kibDevDocsOpsAmbientStorybookTypes" }, { pageId: "kibDevDocsOpsAmbientUiTypes"}, + { pageId: "kibDevDocsOpsTestSubjSelector"}, ]} /> \ No newline at end of file diff --git a/nav-kibana-dev.docnav.json b/nav-kibana-dev.docnav.json index 4704430ba94b6..4bd2349cb18d3 100644 --- a/nav-kibana-dev.docnav.json +++ b/nav-kibana-dev.docnav.json @@ -200,7 +200,8 @@ { "id": "kibDevDocsOpsJestSerializers" }, { "id": "kibDevDocsOpsExpect" }, { "id": "kibDevDocsOpsAmbientStorybookTypes" }, - { "id": "kibDevDocsOpsAmbientUiTypes" } + { "id": "kibDevDocsOpsAmbientUiTypes" }, + { "id": "kibDevDocsOpsTestSubjSelector"} ] } ] diff --git a/packages/kbn-test-subj-selector/BUILD.bazel b/packages/kbn-test-subj-selector/BUILD.bazel index f494b558ad5a6..cc3334650a5d9 100644 --- a/packages/kbn-test-subj-selector/BUILD.bazel +++ b/packages/kbn-test-subj-selector/BUILD.bazel @@ -18,7 +18,6 @@ filegroup( NPM_MODULE_EXTRA_FILES = [ "package.json", - "README.md", ] RUNTIME_DEPS = [] diff --git a/packages/kbn-test-subj-selector/README.md b/packages/kbn-test-subj-selector/README.md deleted file mode 100755 index 463d6c808e298..0000000000000 --- a/packages/kbn-test-subj-selector/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# test-subj-selector - -Convert a string from test subject syntax to css selectors. diff --git a/packages/kbn-test-subj-selector/README.mdx b/packages/kbn-test-subj-selector/README.mdx new file mode 100755 index 0000000000000..c924d15937129 --- /dev/null +++ b/packages/kbn-test-subj-selector/README.mdx @@ -0,0 +1,10 @@ +--- +id: kibDevDocsOpsTestSubjSelector +slug: /kibana-dev-docs/ops/test-subj-selector +title: "@kbn/test-subj-selector" +description: An utility package to quickly get css selectors from strings +date: 2022-05-19 +tags: ['kibana', 'dev', 'contributor', 'operations', 'test', 'subj', 'selector'] +--- + +Converts a string from a test subject syntax into a css selectors composed by `data-test-subj`. From 963b91d86b49327cf59397da31597f63f4f8fef7 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Fri, 20 May 2022 03:28:05 +0100 Subject: [PATCH 32/35] docs(NA): adds @kbn/babel-plugin-synthentic-packages into ops devdocs (#132512) * docs(NA): adds @kbn/babel-plugin-synthentic-packages into ops devdocs * chore(NA): update packages/kbn-babel-plugin-synthetic-packages/README.mdx Co-authored-by: Jonathan Budzenski Co-authored-by: Jonathan Budzenski --- dev_docs/operations/operations_landing.mdx | 1 + nav-kibana-dev.docnav.json | 3 ++- .../kbn-babel-plugin-synthetic-packages/README.mdx | 13 +++++++++++++ 3 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 packages/kbn-babel-plugin-synthetic-packages/README.mdx diff --git a/dev_docs/operations/operations_landing.mdx b/dev_docs/operations/operations_landing.mdx index 8a54ee0a90a43..27bec68ac9014 100644 --- a/dev_docs/operations/operations_landing.mdx +++ b/dev_docs/operations/operations_landing.mdx @@ -25,6 +25,7 @@ layout: landing { pageId: "kibDevDocsOpsOptimizer" }, { pageId: "kibDevDocsOpsBabelPreset" }, { pageId: "kibDevDocsOpsTypeSummarizer" }, + { pageId: "kibDevDocsOpsBabelPluginSyntheticPackages"}, ]} /> diff --git a/nav-kibana-dev.docnav.json b/nav-kibana-dev.docnav.json index 4bd2349cb18d3..d182492c3da14 100644 --- a/nav-kibana-dev.docnav.json +++ b/nav-kibana-dev.docnav.json @@ -181,7 +181,8 @@ "items": [ { "id": "kibDevDocsOpsOptimizer" }, { "id": "kibDevDocsOpsBabelPreset" }, - { "id": "kibDevDocsOpsTypeSummarizer" } + { "id": "kibDevDocsOpsTypeSummarizer" }, + { "id": "kibDevDocsOpsBabelPluginSyntheticPackages"} ] }, { diff --git a/packages/kbn-babel-plugin-synthetic-packages/README.mdx b/packages/kbn-babel-plugin-synthetic-packages/README.mdx new file mode 100644 index 0000000000000..6f11e9cf2d6b9 --- /dev/null +++ b/packages/kbn-babel-plugin-synthetic-packages/README.mdx @@ -0,0 +1,13 @@ +--- +id: kibDevDocsOpsBabelPluginSyntheticPackages +slug: /kibana-dev-docs/ops/babel-plugin-synthetic-packages +title: "@kbn/babel-plugin-synthetic-packages" +description: A babel plugin that transforms our @kbn/{NAME} imports into paths +date: 2022-05-19 +tags: ['kibana', 'dev', 'contributor', 'operations', 'babel', 'plugin', 'synthetic', 'packages'] +--- + +When developing inside the Kibana repository importing a package from any other package is just easy as importing `@kbn/{package-name}`. +However not every package is a node_module yet and while that is something we are working on to accomplish we need a way to dealing with it for +now. Using this babel plugin is our transitory solution. It allows us to import from module ids and then transform it automatically back into +paths on the transpiled code without friction for our engineering teams. \ No newline at end of file From 753fd99d64d52a5bf836a05a4c3f077406720406 Mon Sep 17 00:00:00 2001 From: Matthew Kime Date: Thu, 19 May 2022 23:56:33 -0500 Subject: [PATCH 33/35] add internal/search test for correct handling of 403 error (#132046) --- .../api_integration/apis/search/search.ts | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/x-pack/test/api_integration/apis/search/search.ts b/x-pack/test/api_integration/apis/search/search.ts index e459616304843..e7dfbb52ec701 100644 --- a/x-pack/test/api_integration/apis/search/search.ts +++ b/x-pack/test/api_integration/apis/search/search.ts @@ -7,6 +7,7 @@ import expect from '@kbn/expect'; import type { Context } from 'mocha'; +import { parse as parseCookie } from 'tough-cookie'; import { FtrProviderContext } from '../../ftr_provider_context'; import { verifyErrorResponse } from '../../../../../test/api_integration/apis/search/verify_error'; @@ -16,6 +17,8 @@ export default function ({ getService }: FtrProviderContext) { const es = getService('es'); const log = getService('log'); const retry = getService('retry'); + const security = getService('security'); + const supertestNoAuth = getService('supertestWithoutAuth'); const shardDelayAgg = (delay: string) => ({ aggs: { @@ -266,6 +269,48 @@ export default function ({ getService }: FtrProviderContext) { verifyErrorResponse(resp.body, 400, 'parsing_exception', true); }); + + it('should return 403 for lack of privledges', async () => { + const username = 'no_access'; + const password = 't0pS3cr3t'; + + await security.user.create(username, { + password, + roles: ['test_shakespeare_reader'], + }); + + const loginResponse = await supertestNoAuth + .post('/internal/security/login') + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'basic', + providerName: 'basic', + currentURL: '/', + params: { username, password }, + }) + .expect(200); + + const sessionCookie = parseCookie(loginResponse.headers['set-cookie'][0]); + + await supertestNoAuth + .post(`/internal/search/ese`) + .set('kbn-xsrf', 'foo') + .set('Cookie', sessionCookie!.cookieString()) + .send({ + params: { + index: 'log*', + body: { + query: { + match_all: {}, + }, + }, + wait_for_completion_timeout: '10s', + }, + }) + .expect(403); + + await security.testUser.restoreDefaults(); + }); }); describe('rollup', () => { From 6bdef369052fc5040c5aaa5893a1b96f7e90550d Mon Sep 17 00:00:00 2001 From: Uladzislau Lasitsa Date: Fri, 20 May 2022 10:23:19 +0300 Subject: [PATCH 34/35] Use warn instead of warning (#132516) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../saved_object_migrations.test.ts | 32 +++++++++---------- .../migrations/saved_object_migrations.ts | 4 +-- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/x-pack/plugins/lens/server/migrations/saved_object_migrations.test.ts b/x-pack/plugins/lens/server/migrations/saved_object_migrations.test.ts index d43d4c4cb2a38..53765ed69cdac 100644 --- a/x-pack/plugins/lens/server/migrations/saved_object_migrations.test.ts +++ b/x-pack/plugins/lens/server/migrations/saved_object_migrations.test.ts @@ -181,7 +181,7 @@ describe('Lens migrations', () => { }); describe('7.8.0 auto timestamp', () => { - const context = { log: { warning: () => {} } } as unknown as SavedObjectMigrationContext; + const context = { log: { warn: () => {} } } as unknown as SavedObjectMigrationContext; const example = { type: 'lens', @@ -533,7 +533,7 @@ describe('Lens migrations', () => { }); describe('7.11.0 remove suggested priority', () => { - const context = { log: { warning: () => {} } } as unknown as SavedObjectMigrationContext; + const context = { log: { warn: () => {} } } as unknown as SavedObjectMigrationContext; const example = { type: 'lens', @@ -618,7 +618,7 @@ describe('Lens migrations', () => { }); describe('7.12.0 restructure datatable state', () => { - const context = { log: { warning: () => {} } } as unknown as SavedObjectMigrationContext; + const context = { log: { warn: () => {} } } as unknown as SavedObjectMigrationContext; const example = { type: 'lens', id: 'mock-saved-object-id', @@ -691,7 +691,7 @@ describe('Lens migrations', () => { }); describe('7.13.0 rename operations for Formula', () => { - const context = { log: { warning: () => {} } } as unknown as SavedObjectMigrationContext; + const context = { log: { warn: () => {} } } as unknown as SavedObjectMigrationContext; const example = { type: 'lens', id: 'mocked-saved-object-id', @@ -869,7 +869,7 @@ describe('Lens migrations', () => { }); describe('7.14.0 remove time zone from date histogram', () => { - const context = { log: { warning: () => {} } } as unknown as SavedObjectMigrationContext; + const context = { log: { warn: () => {} } } as unknown as SavedObjectMigrationContext; const example = { type: 'lens', id: 'mocked-saved-object-id', @@ -961,7 +961,7 @@ describe('Lens migrations', () => { }); describe('7.15.0 add layer type information', () => { - const context = { log: { warning: () => {} } } as unknown as SavedObjectMigrationContext; + const context = { log: { warn: () => {} } } as unknown as SavedObjectMigrationContext; const example = { type: 'lens', id: 'mocked-saved-object-id', @@ -1143,7 +1143,7 @@ describe('Lens migrations', () => { }); describe('7.16.0 move reversed default palette to custom palette', () => { - const context = { log: { warning: () => {} } } as unknown as SavedObjectMigrationContext; + const context = { log: { warn: () => {} } } as unknown as SavedObjectMigrationContext; const example = { type: 'lens', id: 'mocked-saved-object-id', @@ -1417,7 +1417,7 @@ describe('Lens migrations', () => { }); describe('8.1.0 update filter reference schema', () => { - const context = { log: { warning: () => {} } } as unknown as SavedObjectMigrationContext; + const context = { log: { warn: () => {} } } as unknown as SavedObjectMigrationContext; const example = { type: 'lens', id: 'mocked-saved-object-id', @@ -1523,7 +1523,7 @@ describe('Lens migrations', () => { }); describe('8.1.0 rename records field', () => { - const context = { log: { warning: () => {} } } as unknown as SavedObjectMigrationContext; + const context = { log: { warn: () => {} } } as unknown as SavedObjectMigrationContext; const example = { type: 'lens', id: 'mocked-saved-object-id', @@ -1709,7 +1709,7 @@ describe('Lens migrations', () => { }); describe('8.1.0 add parentFormat to terms operation', () => { - const context = { log: { warning: () => {} } } as unknown as SavedObjectMigrationContext; + const context = { log: { warn: () => {} } } as unknown as SavedObjectMigrationContext; const example = { type: 'lens', id: 'mocked-saved-object-id', @@ -1785,7 +1785,7 @@ describe('Lens migrations', () => { describe('8.2.0', () => { describe('last_value columns', () => { - const context = { log: { warning: () => {} } } as unknown as SavedObjectMigrationContext; + const context = { log: { warn: () => {} } } as unknown as SavedObjectMigrationContext; const example = { type: 'lens', id: 'mocked-saved-object-id', @@ -1877,7 +1877,7 @@ describe('Lens migrations', () => { }); describe('rename fitRowToContent to new detailed rowHeight and rowHeightLines', () => { - const context = { log: { warning: () => {} } } as unknown as SavedObjectMigrationContext; + const context = { log: { warn: () => {} } } as unknown as SavedObjectMigrationContext; function getExample(fitToContent: boolean) { return { type: 'lens', @@ -1996,7 +1996,7 @@ describe('Lens migrations', () => { }); describe('8.2.0 include empty rows for date histogram columns', () => { - const context = { log: { warning: () => {} } } as unknown as SavedObjectMigrationContext; + const context = { log: { warn: () => {} } } as unknown as SavedObjectMigrationContext; const example = { type: 'lens', id: 'mocked-saved-object-id', @@ -2067,7 +2067,7 @@ describe('Lens migrations', () => { }); describe('8.3.0 old metric visualization defaults', () => { - const context = { log: { warning: () => {} } } as unknown as SavedObjectMigrationContext; + const context = { log: { warn: () => {} } } as unknown as SavedObjectMigrationContext; const example = { type: 'lens', id: 'mocked-saved-object-id', @@ -2117,7 +2117,7 @@ describe('Lens migrations', () => { }); describe('8.3.0 - convert legend sizes to strings', () => { - const context = { log: { warning: () => {} } } as unknown as SavedObjectMigrationContext; + const context = { log: { warn: () => {} } } as unknown as SavedObjectMigrationContext; const migrate = migrations['8.3.0']; const autoLegendSize = 'auto'; @@ -2185,7 +2185,7 @@ describe('Lens migrations', () => { }); describe('8.3.0 valueLabels in XY', () => { - const context = { log: { warning: () => {} } } as unknown as SavedObjectMigrationContext; + const context = { log: { warn: () => {} } } as unknown as SavedObjectMigrationContext; const example = { type: 'lens', id: 'mocked-saved-object-id', diff --git a/x-pack/plugins/lens/server/migrations/saved_object_migrations.ts b/x-pack/plugins/lens/server/migrations/saved_object_migrations.ts index 3870bab9fad65..e6daa2cb99439 100644 --- a/x-pack/plugins/lens/server/migrations/saved_object_migrations.ts +++ b/x-pack/plugins/lens/server/migrations/saved_object_migrations.ts @@ -198,7 +198,7 @@ const removeLensAutoDate: SavedObjectMigrationFn Date: Fri, 20 May 2022 11:18:17 +0300 Subject: [PATCH 35/35] [XY] Usable reference lines for `xyVis`. (#132192) * ReferenceLineLayer -> referenceLine. * Added the referenceLine and splitted the logic at ReferenceLineAnnotations. * Fixed formatters of referenceLines * Added referenceLines keys. * Added test for the referenceLine fn. * Added some tests for reference_lines. * Unified the two different approaches of referenceLines. * Fixed types at tests and limits. --- packages/kbn-optimizer/limits.yml | 2 +- .../expression_xy/common/constants.ts | 3 +- .../common_reference_line_layer_args.ts | 25 - .../extended_reference_line_layer.ts | 50 -- .../common/expression_functions/index.ts | 2 +- .../expression_functions/layered_xy_vis.ts | 9 +- .../reference_line.test.ts | 140 ++++ .../expression_functions/reference_line.ts | 114 +++ .../reference_line_layer.ts | 29 +- .../expression_functions/xy_vis.test.ts | 17 +- .../common/expression_functions/xy_vis.ts | 8 +- .../common/expression_functions/xy_vis_fn.ts | 8 +- .../common/helpers/layers.test.ts | 2 +- .../expression_xy/common/i18n/index.tsx | 14 +- .../expression_xy/common/index.ts | 1 - .../common/types/expression_functions.ts | 65 +- .../common/utils/log_datatables.ts | 13 +- .../public/components/annotations.tsx | 2 +- .../components/reference_lines.test.tsx | 369 ---------- .../public/components/reference_lines.tsx | 268 ------- .../components/reference_lines/index.ts | 10 + .../reference_lines/reference_line.tsx | 56 ++ .../reference_line_annotations.tsx | 137 ++++ .../reference_lines/reference_line_layer.tsx | 92 +++ .../reference_lines.scss | 0 .../reference_lines/reference_lines.test.tsx | 683 ++++++++++++++++++ .../reference_lines/reference_lines.tsx | 79 ++ .../components/reference_lines/utils.tsx | 143 ++++ .../public/components/xy_chart.tsx | 28 +- .../expression_xy/public/helpers/layers.ts | 6 +- .../expression_xy/public/helpers/state.ts | 8 +- .../public/helpers/visualization.ts | 28 +- .../expression_xy/public/plugin.ts | 4 +- .../expression_xy/server/plugin.ts | 6 +- .../public/xy_visualization/to_expression.ts | 2 +- 35 files changed, 1615 insertions(+), 808 deletions(-) delete mode 100644 src/plugins/chart_expressions/expression_xy/common/expression_functions/common_reference_line_layer_args.ts delete mode 100644 src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_reference_line_layer.ts create mode 100644 src/plugins/chart_expressions/expression_xy/common/expression_functions/reference_line.test.ts create mode 100644 src/plugins/chart_expressions/expression_xy/common/expression_functions/reference_line.ts delete mode 100644 src/plugins/chart_expressions/expression_xy/public/components/reference_lines.test.tsx delete mode 100644 src/plugins/chart_expressions/expression_xy/public/components/reference_lines.tsx create mode 100644 src/plugins/chart_expressions/expression_xy/public/components/reference_lines/index.ts create mode 100644 src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_line.tsx create mode 100644 src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_line_annotations.tsx create mode 100644 src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_line_layer.tsx rename src/plugins/chart_expressions/expression_xy/public/components/{ => reference_lines}/reference_lines.scss (100%) create mode 100644 src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_lines.test.tsx create mode 100644 src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_lines.tsx create mode 100644 src/plugins/chart_expressions/expression_xy/public/components/reference_lines/utils.tsx diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 8856f7f0aaabb..97e9f23784f60 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -128,5 +128,5 @@ pageLoadAssetSize: eventAnnotation: 19334 screenshotting: 22870 synthetics: 40958 - expressionXY: 31000 + expressionXY: 33000 kibanaUsageCollection: 16463 diff --git a/src/plugins/chart_expressions/expression_xy/common/constants.ts b/src/plugins/chart_expressions/expression_xy/common/constants.ts index 68ac2963c9646..fc2e41700b94f 100644 --- a/src/plugins/chart_expressions/expression_xy/common/constants.ts +++ b/src/plugins/chart_expressions/expression_xy/common/constants.ts @@ -9,6 +9,7 @@ export const XY_VIS = 'xyVis'; export const LAYERED_XY_VIS = 'layeredXyVis'; export const Y_CONFIG = 'yConfig'; +export const REFERENCE_LINE_Y_CONFIG = 'referenceLineYConfig'; export const EXTENDED_Y_CONFIG = 'extendedYConfig'; export const DATA_LAYER = 'dataLayer'; export const EXTENDED_DATA_LAYER = 'extendedDataLayer'; @@ -19,8 +20,8 @@ export const ANNOTATION_LAYER = 'annotationLayer'; export const EXTENDED_ANNOTATION_LAYER = 'extendedAnnotationLayer'; export const TICK_LABELS_CONFIG = 'tickLabelsConfig'; export const AXIS_EXTENT_CONFIG = 'axisExtentConfig'; +export const REFERENCE_LINE = 'referenceLine'; export const REFERENCE_LINE_LAYER = 'referenceLineLayer'; -export const EXTENDED_REFERENCE_LINE_LAYER = 'extendedReferenceLineLayer'; export const LABELS_ORIENTATION_CONFIG = 'labelsOrientationConfig'; export const AXIS_TITLES_VISIBILITY_CONFIG = 'axisTitlesVisibilityConfig'; diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/common_reference_line_layer_args.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/common_reference_line_layer_args.ts deleted file mode 100644 index d85f5ae2b2f77..0000000000000 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/common_reference_line_layer_args.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 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 { EXTENDED_Y_CONFIG } from '../constants'; -import { strings } from '../i18n'; -import { ReferenceLineLayerFn, ExtendedReferenceLineLayerFn } from '../types'; - -type CommonReferenceLineLayerFn = ReferenceLineLayerFn | ExtendedReferenceLineLayerFn; - -export const commonReferenceLineLayerArgs: Omit = { - yConfig: { - types: [EXTENDED_Y_CONFIG], - help: strings.getRLYConfigHelp(), - multi: true, - }, - columnToLabel: { - types: ['string'], - help: strings.getColumnToLabelHelp(), - }, -}; diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_reference_line_layer.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_reference_line_layer.ts deleted file mode 100644 index 41b264cf53a4d..0000000000000 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_reference_line_layer.ts +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { validateAccessor } from '@kbn/visualizations-plugin/common/utils'; -import { LayerTypes, EXTENDED_REFERENCE_LINE_LAYER } from '../constants'; -import { ExtendedReferenceLineLayerFn } from '../types'; -import { strings } from '../i18n'; -import { commonReferenceLineLayerArgs } from './common_reference_line_layer_args'; - -export const extendedReferenceLineLayerFunction: ExtendedReferenceLineLayerFn = { - name: EXTENDED_REFERENCE_LINE_LAYER, - aliases: [], - type: EXTENDED_REFERENCE_LINE_LAYER, - help: strings.getRLHelp(), - inputTypes: ['datatable'], - args: { - ...commonReferenceLineLayerArgs, - accessors: { - types: ['string'], - help: strings.getRLAccessorsHelp(), - multi: true, - }, - table: { - types: ['datatable'], - help: strings.getTableHelp(), - }, - layerId: { - types: ['string'], - help: strings.getLayerIdHelp(), - }, - }, - fn(input, args) { - const table = args.table ?? input; - const accessors = args.accessors ?? []; - accessors.forEach((accessor) => validateAccessor(accessor, table.columns)); - - return { - type: EXTENDED_REFERENCE_LINE_LAYER, - ...args, - accessors: args.accessors ?? [], - layerType: LayerTypes.REFERENCELINE, - table, - }; - }, -}; diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/index.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/index.ts index 30a76217b5c0e..dc82220db6e23 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/index.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/index.ts @@ -18,6 +18,6 @@ export * from './grid_lines_config'; export * from './axis_extent_config'; export * from './tick_labels_config'; export * from './labels_orientation_config'; +export * from './reference_line'; export * from './reference_line_layer'; -export * from './extended_reference_line_layer'; export * from './axis_titles_visibility_config'; diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/layered_xy_vis.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/layered_xy_vis.ts index 695bd16613715..f419891e079ea 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/layered_xy_vis.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/layered_xy_vis.ts @@ -6,10 +6,11 @@ * Side Public License, v 1. */ +import { i18n } from '@kbn/i18n'; import { LayeredXyVisFn } from '../types'; import { EXTENDED_DATA_LAYER, - EXTENDED_REFERENCE_LINE_LAYER, + REFERENCE_LINE_LAYER, LAYERED_XY_VIS, EXTENDED_ANNOTATION_LAYER, } from '../constants'; @@ -24,8 +25,10 @@ export const layeredXyVisFunction: LayeredXyVisFn = { args: { ...commonXYArgs, layers: { - types: [EXTENDED_DATA_LAYER, EXTENDED_REFERENCE_LINE_LAYER, EXTENDED_ANNOTATION_LAYER], - help: strings.getLayersHelp(), + types: [EXTENDED_DATA_LAYER, REFERENCE_LINE_LAYER, EXTENDED_ANNOTATION_LAYER], + help: i18n.translate('expressionXY.layeredXyVis.layers.help', { + defaultMessage: 'Layers of visual series', + }), multi: true, }, }, diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/reference_line.test.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/reference_line.test.ts new file mode 100644 index 0000000000000..b96f39923fab2 --- /dev/null +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/reference_line.test.ts @@ -0,0 +1,140 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { createMockExecutionContext } from '@kbn/expressions-plugin/common/mocks'; +import { ReferenceLineArgs, ReferenceLineConfigResult } from '../types'; +import { referenceLineFunction } from './reference_line'; + +describe('referenceLine', () => { + test('produces the correct arguments for minimum arguments', async () => { + const args: ReferenceLineArgs = { + value: 100, + }; + + const result = referenceLineFunction.fn(null, args, createMockExecutionContext()); + + const expectedResult: ReferenceLineConfigResult = { + type: 'referenceLine', + layerType: 'referenceLine', + lineLength: 0, + yConfig: [ + { + type: 'referenceLineYConfig', + ...args, + textVisibility: false, + }, + ], + }; + expect(result).toEqual(expectedResult); + }); + + test('produces the correct arguments for maximum arguments', async () => { + const args: ReferenceLineArgs = { + name: 'some value', + value: 100, + icon: 'alert', + iconPosition: 'below', + axisMode: 'bottom', + lineStyle: 'solid', + lineWidth: 10, + color: '#fff', + fill: 'below', + textVisibility: true, + }; + + const result = referenceLineFunction.fn(null, args, createMockExecutionContext()); + + const expectedResult: ReferenceLineConfigResult = { + type: 'referenceLine', + layerType: 'referenceLine', + lineLength: 0, + yConfig: [ + { + type: 'referenceLineYConfig', + ...args, + }, + ], + }; + expect(result).toEqual(expectedResult); + }); + + test('adds text visibility if name is provided ', async () => { + const args: ReferenceLineArgs = { + name: 'some name', + value: 100, + }; + + const result = referenceLineFunction.fn(null, args, createMockExecutionContext()); + + const expectedResult: ReferenceLineConfigResult = { + type: 'referenceLine', + layerType: 'referenceLine', + lineLength: 0, + yConfig: [ + { + type: 'referenceLineYConfig', + ...args, + textVisibility: true, + }, + ], + }; + expect(result).toEqual(expectedResult); + }); + + test('hides text if textVisibility is true and no text is provided', async () => { + const args: ReferenceLineArgs = { + value: 100, + textVisibility: true, + }; + + const result = referenceLineFunction.fn(null, args, createMockExecutionContext()); + + const expectedResult: ReferenceLineConfigResult = { + type: 'referenceLine', + layerType: 'referenceLine', + lineLength: 0, + yConfig: [ + { + type: 'referenceLineYConfig', + ...args, + textVisibility: false, + }, + ], + }; + expect(result).toEqual(expectedResult); + }); + + test('applies text visibility if name is provided', async () => { + const checktextVisibility = (textVisibility: boolean = false) => { + const args: ReferenceLineArgs = { + value: 100, + name: 'some text', + textVisibility, + }; + + const result = referenceLineFunction.fn(null, args, createMockExecutionContext()); + + const expectedResult: ReferenceLineConfigResult = { + type: 'referenceLine', + layerType: 'referenceLine', + lineLength: 0, + yConfig: [ + { + type: 'referenceLineYConfig', + ...args, + textVisibility, + }, + ], + }; + expect(result).toEqual(expectedResult); + }; + + checktextVisibility(); + checktextVisibility(true); + }); +}); diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/reference_line.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/reference_line.ts new file mode 100644 index 0000000000000..c294d6ca5aaec --- /dev/null +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/reference_line.ts @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; +import { + AvailableReferenceLineIcons, + FillStyles, + IconPositions, + LayerTypes, + LineStyles, + REFERENCE_LINE, + REFERENCE_LINE_Y_CONFIG, + YAxisModes, +} from '../constants'; +import { ReferenceLineFn } from '../types'; +import { strings } from '../i18n'; + +export const referenceLineFunction: ReferenceLineFn = { + name: REFERENCE_LINE, + aliases: [], + type: REFERENCE_LINE, + help: strings.getRLHelp(), + inputTypes: ['datatable', 'null'], + args: { + name: { + types: ['string'], + help: strings.getReferenceLineNameHelp(), + }, + value: { + types: ['number'], + help: strings.getReferenceLineValueHelp(), + required: true, + }, + axisMode: { + types: ['string'], + options: [...Object.values(YAxisModes)], + help: strings.getAxisModeHelp(), + default: YAxisModes.AUTO, + strict: true, + }, + color: { + types: ['string'], + help: strings.getColorHelp(), + }, + lineStyle: { + types: ['string'], + options: [...Object.values(LineStyles)], + help: i18n.translate('expressionXY.yConfig.lineStyle.help', { + defaultMessage: 'The style of the reference line', + }), + default: LineStyles.SOLID, + strict: true, + }, + lineWidth: { + types: ['number'], + help: i18n.translate('expressionXY.yConfig.lineWidth.help', { + defaultMessage: 'The width of the reference line', + }), + default: 1, + }, + icon: { + types: ['string'], + help: i18n.translate('expressionXY.yConfig.icon.help', { + defaultMessage: 'An optional icon used for reference lines', + }), + options: [...Object.values(AvailableReferenceLineIcons)], + strict: true, + }, + iconPosition: { + types: ['string'], + options: [...Object.values(IconPositions)], + help: i18n.translate('expressionXY.yConfig.iconPosition.help', { + defaultMessage: 'The placement of the icon for the reference line', + }), + default: IconPositions.AUTO, + strict: true, + }, + textVisibility: { + types: ['boolean'], + help: i18n.translate('expressionXY.yConfig.textVisibility.help', { + defaultMessage: 'Visibility of the label on the reference line', + }), + }, + fill: { + types: ['string'], + options: [...Object.values(FillStyles)], + help: i18n.translate('expressionXY.yConfig.fill.help', { + defaultMessage: 'Fill', + }), + default: FillStyles.NONE, + strict: true, + }, + }, + fn(table, args) { + const textVisibility = + args.name !== undefined && args.textVisibility === undefined + ? true + : args.name === undefined + ? false + : args.textVisibility; + + return { + type: REFERENCE_LINE, + layerType: LayerTypes.REFERENCELINE, + lineLength: table?.rows.length ?? 0, + yConfig: [{ ...args, textVisibility, type: REFERENCE_LINE_Y_CONFIG }], + }; + }, +}; diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/reference_line_layer.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/reference_line_layer.ts index 04c06f92d616f..6b51edd2d209e 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/reference_line_layer.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/reference_line_layer.ts @@ -7,10 +7,9 @@ */ import { validateAccessor } from '@kbn/visualizations-plugin/common/utils'; -import { LayerTypes, REFERENCE_LINE_LAYER } from '../constants'; +import { LayerTypes, REFERENCE_LINE_LAYER, EXTENDED_Y_CONFIG } from '../constants'; import { ReferenceLineLayerFn } from '../types'; import { strings } from '../i18n'; -import { commonReferenceLineLayerArgs } from './common_reference_line_layer_args'; export const referenceLineLayerFunction: ReferenceLineLayerFn = { name: REFERENCE_LINE_LAYER, @@ -19,14 +18,31 @@ export const referenceLineLayerFunction: ReferenceLineLayerFn = { help: strings.getRLHelp(), inputTypes: ['datatable'], args: { - ...commonReferenceLineLayerArgs, accessors: { - types: ['string', 'vis_dimension'], + types: ['string'], help: strings.getRLAccessorsHelp(), multi: true, }, + yConfig: { + types: [EXTENDED_Y_CONFIG], + help: strings.getRLYConfigHelp(), + multi: true, + }, + columnToLabel: { + types: ['string'], + help: strings.getColumnToLabelHelp(), + }, + table: { + types: ['datatable'], + help: strings.getTableHelp(), + }, + layerId: { + types: ['string'], + help: strings.getLayerIdHelp(), + }, }, - fn(table, args) { + fn(input, args) { + const table = args.table ?? input; const accessors = args.accessors ?? []; accessors.forEach((accessor) => validateAccessor(accessor, table.columns)); @@ -34,8 +50,7 @@ export const referenceLineLayerFunction: ReferenceLineLayerFn = { type: REFERENCE_LINE_LAYER, ...args, layerType: LayerTypes.REFERENCELINE, - accessors, - table, + table: args.table ?? input, }; }, }; diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.test.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.test.ts index 8ec1961416638..73d4444217d90 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.test.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.test.ts @@ -30,11 +30,12 @@ describe('xyVis', () => { } ), } as Datatable; + const { layers, ...rest } = args; const { layerId, layerType, table, type, ...restLayerArgs } = sampleLayer; const result = await xyVisFunction.fn( newData, - { ...rest, ...restLayerArgs, referenceLineLayers: [], annotationLayers: [] }, + { ...rest, ...restLayerArgs, referenceLines: [], annotationLayers: [] }, createMockExecutionContext() ); @@ -60,7 +61,7 @@ describe('xyVis', () => { ...rest, ...{ ...sampleLayer, markSizeAccessor: 'b' }, markSizeRatio: 0, - referenceLineLayers: [], + referenceLines: [], annotationLayers: [], }, createMockExecutionContext() @@ -74,7 +75,7 @@ describe('xyVis', () => { ...rest, ...{ ...sampleLayer, markSizeAccessor: 'b' }, markSizeRatio: 101, - referenceLineLayers: [], + referenceLines: [], annotationLayers: [], }, createMockExecutionContext() @@ -92,7 +93,7 @@ describe('xyVis', () => { ...rest, ...restLayerArgs, minTimeBarInterval: '1q', - referenceLineLayers: [], + referenceLines: [], annotationLayers: [], }, createMockExecutionContext() @@ -111,7 +112,7 @@ describe('xyVis', () => { ...rest, ...restLayerArgs, minTimeBarInterval: '1h', - referenceLineLayers: [], + referenceLines: [], annotationLayers: [], }, createMockExecutionContext() @@ -131,7 +132,7 @@ describe('xyVis', () => { { ...rest, ...restLayerArgs, - referenceLineLayers: [], + referenceLines: [], annotationLayers: [], splitRowAccessor, }, @@ -152,7 +153,7 @@ describe('xyVis', () => { { ...rest, ...restLayerArgs, - referenceLineLayers: [], + referenceLines: [], annotationLayers: [], splitColumnAccessor, }, @@ -172,7 +173,7 @@ describe('xyVis', () => { { ...rest, ...restLayerArgs, - referenceLineLayers: [], + referenceLines: [], annotationLayers: [], markSizeRatio: 5, }, diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.ts index 37baf028178cc..7d2783cf6f1cd 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.ts @@ -7,7 +7,7 @@ */ import { XyVisFn } from '../types'; -import { XY_VIS, REFERENCE_LINE_LAYER, ANNOTATION_LAYER } from '../constants'; +import { XY_VIS, REFERENCE_LINE, ANNOTATION_LAYER } from '../constants'; import { strings } from '../i18n'; import { commonXYArgs } from './common_xy_args'; import { commonDataLayerArgs } from './common_data_layer_args'; @@ -33,9 +33,9 @@ export const xyVisFunction: XyVisFn = { help: strings.getAccessorsHelp(), multi: true, }, - referenceLineLayers: { - types: [REFERENCE_LINE_LAYER], - help: strings.getReferenceLineLayerHelp(), + referenceLines: { + types: [REFERENCE_LINE], + help: strings.getReferenceLinesHelp(), multi: true, }, annotationLayers: { diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis_fn.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis_fn.ts index e879f33b76548..3de2dd35831e4 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis_fn.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis_fn.ts @@ -13,7 +13,7 @@ import { } from '@kbn/visualizations-plugin/common/utils'; import type { Datatable } from '@kbn/expressions-plugin/common'; import { ExpressionValueVisDimension } from '@kbn/visualizations-plugin/common/expression_functions'; -import { LayerTypes, XY_VIS_RENDERER, DATA_LAYER } from '../constants'; +import { LayerTypes, XY_VIS_RENDERER, DATA_LAYER, REFERENCE_LINE } from '../constants'; import { appendLayerIds, getAccessors, normalizeTable } from '../helpers'; import { DataLayerConfigResult, XYLayerConfig, XyVisFn, XYArgs } from '../types'; import { getLayerDimensions } from '../utils'; @@ -53,7 +53,7 @@ export const xyVisFn: XyVisFn['fn'] = async (data, args, handlers) => { validateAccessor(args.splitColumnAccessor, data.columns); const { - referenceLineLayers = [], + referenceLines = [], annotationLayers = [], // data_layer args seriesType, @@ -81,7 +81,7 @@ export const xyVisFn: XyVisFn['fn'] = async (data, args, handlers) => { const layers: XYLayerConfig[] = [ ...appendLayerIds(dataLayers, 'dataLayers'), - ...appendLayerIds(referenceLineLayers, 'referenceLineLayers'), + ...appendLayerIds(referenceLines, 'referenceLines'), ...appendLayerIds(annotationLayers, 'annotationLayers'), ]; @@ -90,7 +90,7 @@ export const xyVisFn: XyVisFn['fn'] = async (data, args, handlers) => { handlers.inspectorAdapters.tables.allowCsvExport = true; const layerDimensions = layers.reduce((dimensions, layer) => { - if (layer.layerType === LayerTypes.ANNOTATIONS) { + if (layer.layerType === LayerTypes.ANNOTATIONS || layer.type === REFERENCE_LINE) { return dimensions; } diff --git a/src/plugins/chart_expressions/expression_xy/common/helpers/layers.test.ts b/src/plugins/chart_expressions/expression_xy/common/helpers/layers.test.ts index a3eea973fbf91..895abdb7a60df 100644 --- a/src/plugins/chart_expressions/expression_xy/common/helpers/layers.test.ts +++ b/src/plugins/chart_expressions/expression_xy/common/helpers/layers.test.ts @@ -63,7 +63,7 @@ describe('#getDataLayers', () => { palette: { type: 'system_palette', name: 'system' }, }, { - type: 'extendedReferenceLineLayer', + type: 'referenceLineLayer', layerType: 'referenceLine', accessors: ['y'], table: { rows: [], columns: [], type: 'datatable' }, diff --git a/src/plugins/chart_expressions/expression_xy/common/i18n/index.tsx b/src/plugins/chart_expressions/expression_xy/common/i18n/index.tsx index f3425ec2db625..ba26bb973f64f 100644 --- a/src/plugins/chart_expressions/expression_xy/common/i18n/index.tsx +++ b/src/plugins/chart_expressions/expression_xy/common/i18n/index.tsx @@ -93,9 +93,9 @@ export const strings = { i18n.translate('expressionXY.xyVis.dataLayer.help', { defaultMessage: 'Data layer of visual series', }), - getReferenceLineLayerHelp: () => - i18n.translate('expressionXY.xyVis.referenceLineLayer.help', { - defaultMessage: 'Reference line layer', + getReferenceLinesHelp: () => + i18n.translate('expressionXY.xyVis.referenceLines.help', { + defaultMessage: 'Reference line', }), getAnnotationLayerHelp: () => i18n.translate('expressionXY.xyVis.annotationLayer.help', { @@ -237,4 +237,12 @@ export const strings = { i18n.translate('expressionXY.annotationLayer.annotations.help', { defaultMessage: 'Annotations', }), + getReferenceLineNameHelp: () => + i18n.translate('expressionXY.referenceLine.name.help', { + defaultMessage: 'Reference line name', + }), + getReferenceLineValueHelp: () => + i18n.translate('expressionXY.referenceLine.Value.help', { + defaultMessage: 'Reference line value', + }), }; diff --git a/src/plugins/chart_expressions/expression_xy/common/index.ts b/src/plugins/chart_expressions/expression_xy/common/index.ts index 7211a7a7db1b7..005f6c2867c18 100755 --- a/src/plugins/chart_expressions/expression_xy/common/index.ts +++ b/src/plugins/chart_expressions/expression_xy/common/index.ts @@ -58,6 +58,5 @@ export type { ReferenceLineLayerConfigResult, CommonXYReferenceLineLayerConfig, AxisTitlesVisibilityConfigResult, - ExtendedReferenceLineLayerConfigResult, CommonXYReferenceLineLayerConfigResult, } from './types'; diff --git a/src/plugins/chart_expressions/expression_xy/common/types/expression_functions.ts b/src/plugins/chart_expressions/expression_xy/common/types/expression_functions.ts index 0e10f680811ec..0a7b93c495c29 100644 --- a/src/plugins/chart_expressions/expression_xy/common/types/expression_functions.ts +++ b/src/plugins/chart_expressions/expression_xy/common/types/expression_functions.ts @@ -26,7 +26,7 @@ import { XYCurveTypes, YAxisModes, YScaleTypes, - REFERENCE_LINE_LAYER, + REFERENCE_LINE, Y_CONFIG, AXIS_TITLES_VISIBILITY_CONFIG, LABELS_ORIENTATION_CONFIG, @@ -36,7 +36,7 @@ import { DATA_LAYER, AXIS_EXTENT_CONFIG, EXTENDED_DATA_LAYER, - EXTENDED_REFERENCE_LINE_LAYER, + REFERENCE_LINE_LAYER, ANNOTATION_LAYER, EndValues, EXTENDED_Y_CONFIG, @@ -44,6 +44,7 @@ import { XY_VIS, LAYERED_XY_VIS, EXTENDED_ANNOTATION_LAYER, + REFERENCE_LINE_Y_CONFIG, } from '../constants'; import { XYRender } from './expression_renderers'; @@ -194,7 +195,7 @@ export interface XYArgs extends DataLayerArgs { endValue?: EndValue; emphasizeFitting?: boolean; valueLabels: ValueLabelMode; - referenceLineLayers: ReferenceLineLayerConfigResult[]; + referenceLines: ReferenceLineConfigResult[]; annotationLayers: AnnotationLayerConfigResult[]; fittingFunction?: FittingFunction; axisTitlesVisibilitySettings?: AxisTitlesVisibilityConfigResult; @@ -287,13 +288,12 @@ export type ExtendedAnnotationLayerConfigResult = ExtendedAnnotationLayerArgs & layerType: typeof LayerTypes.ANNOTATIONS; }; -export interface ReferenceLineLayerArgs { - accessors: Array; - columnToLabel?: string; - yConfig?: ExtendedYConfigResult[]; +export interface ReferenceLineArgs extends Omit { + name?: string; + value: number; } -export interface ExtendedReferenceLineLayerArgs { +export interface ReferenceLineLayerArgs { layerId?: string; accessors: string[]; columnToLabel?: string; @@ -301,26 +301,31 @@ export interface ExtendedReferenceLineLayerArgs { table?: Datatable; } -export type XYLayerArgs = DataLayerArgs | ReferenceLineLayerArgs | AnnotationLayerArgs; -export type XYLayerConfig = DataLayerConfig | ReferenceLineLayerConfig | AnnotationLayerConfig; +export type XYLayerArgs = DataLayerArgs | ReferenceLineArgs | AnnotationLayerArgs; +export type XYLayerConfig = DataLayerConfig | ReferenceLineConfig | AnnotationLayerConfig; export type XYExtendedLayerConfig = | ExtendedDataLayerConfig - | ExtendedReferenceLineLayerConfig + | ReferenceLineLayerConfig | ExtendedAnnotationLayerConfig; export type XYExtendedLayerConfigResult = | ExtendedDataLayerConfigResult - | ExtendedReferenceLineLayerConfigResult + | ReferenceLineLayerConfigResult | ExtendedAnnotationLayerConfigResult; -export type ReferenceLineLayerConfigResult = ReferenceLineLayerArgs & { - type: typeof REFERENCE_LINE_LAYER; +export interface ReferenceLineYConfig extends ReferenceLineArgs { + type: typeof REFERENCE_LINE_Y_CONFIG; +} + +export interface ReferenceLineConfigResult { + type: typeof REFERENCE_LINE; layerType: typeof LayerTypes.REFERENCELINE; - table: Datatable; -}; + lineLength: number; + yConfig: [ReferenceLineYConfig]; +} -export type ExtendedReferenceLineLayerConfigResult = ExtendedReferenceLineLayerArgs & { - type: typeof EXTENDED_REFERENCE_LINE_LAYER; +export type ReferenceLineLayerConfigResult = ReferenceLineLayerArgs & { + type: typeof REFERENCE_LINE_LAYER; layerType: typeof LayerTypes.REFERENCELINE; table: Datatable; }; @@ -337,11 +342,11 @@ export interface WithLayerId { } export type DataLayerConfig = DataLayerConfigResult & WithLayerId; -export type ReferenceLineLayerConfig = ReferenceLineLayerConfigResult & WithLayerId; +export type ReferenceLineConfig = ReferenceLineConfigResult & WithLayerId; export type AnnotationLayerConfig = AnnotationLayerConfigResult & WithLayerId; export type ExtendedDataLayerConfig = ExtendedDataLayerConfigResult & WithLayerId; -export type ExtendedReferenceLineLayerConfig = ExtendedReferenceLineLayerConfigResult & WithLayerId; +export type ReferenceLineLayerConfig = ReferenceLineLayerConfigResult & WithLayerId; export type ExtendedAnnotationLayerConfig = ExtendedAnnotationLayerConfigResult & WithLayerId; export type ExtendedDataLayerConfigResult = Omit & { @@ -370,13 +375,11 @@ export type TickLabelsConfigResult = AxesSettingsConfig & { type: typeof TICK_LA export type CommonXYLayerConfig = XYLayerConfig | XYExtendedLayerConfig; export type CommonXYDataLayerConfigResult = DataLayerConfigResult | ExtendedDataLayerConfigResult; export type CommonXYReferenceLineLayerConfigResult = - | ReferenceLineLayerConfigResult - | ExtendedReferenceLineLayerConfigResult; + | ReferenceLineConfigResult + | ReferenceLineLayerConfigResult; export type CommonXYDataLayerConfig = DataLayerConfig | ExtendedDataLayerConfig; -export type CommonXYReferenceLineLayerConfig = - | ReferenceLineLayerConfig - | ExtendedReferenceLineLayerConfig; +export type CommonXYReferenceLineLayerConfig = ReferenceLineConfig | ReferenceLineLayerConfig; export type CommonXYAnnotationLayerConfig = AnnotationLayerConfig | ExtendedAnnotationLayerConfig; @@ -400,18 +403,18 @@ export type ExtendedDataLayerFn = ExpressionFunctionDefinition< Promise >; +export type ReferenceLineFn = ExpressionFunctionDefinition< + typeof REFERENCE_LINE, + Datatable | null, + ReferenceLineArgs, + ReferenceLineConfigResult +>; export type ReferenceLineLayerFn = ExpressionFunctionDefinition< typeof REFERENCE_LINE_LAYER, Datatable, ReferenceLineLayerArgs, ReferenceLineLayerConfigResult >; -export type ExtendedReferenceLineLayerFn = ExpressionFunctionDefinition< - typeof EXTENDED_REFERENCE_LINE_LAYER, - Datatable, - ExtendedReferenceLineLayerArgs, - ExtendedReferenceLineLayerConfigResult ->; export type YConfigFn = ExpressionFunctionDefinition; export type ExtendedYConfigFn = ExpressionFunctionDefinition< diff --git a/src/plugins/chart_expressions/expression_xy/common/utils/log_datatables.ts b/src/plugins/chart_expressions/expression_xy/common/utils/log_datatables.ts index 79a3cbd2eef19..44026b30ed493 100644 --- a/src/plugins/chart_expressions/expression_xy/common/utils/log_datatables.ts +++ b/src/plugins/chart_expressions/expression_xy/common/utils/log_datatables.ts @@ -8,13 +8,9 @@ import { ExecutionContext } from '@kbn/expressions-plugin'; import { Dimension, prepareLogTable } from '@kbn/visualizations-plugin/common/utils'; -import { LayerTypes } from '../constants'; +import { LayerTypes, REFERENCE_LINE } from '../constants'; import { strings } from '../i18n'; -import { - CommonXYDataLayerConfig, - CommonXYLayerConfig, - CommonXYReferenceLineLayerConfig, -} from '../types'; +import { CommonXYDataLayerConfig, CommonXYLayerConfig, ReferenceLineLayerConfig } from '../types'; export const logDatatables = (layers: CommonXYLayerConfig[], handlers: ExecutionContext) => { if (!handlers?.inspectorAdapters?.tables) { @@ -25,16 +21,17 @@ export const logDatatables = (layers: CommonXYLayerConfig[], handlers: Execution handlers.inspectorAdapters.tables.allowCsvExport = true; layers.forEach((layer) => { - if (layer.layerType === LayerTypes.ANNOTATIONS) { + if (layer.layerType === LayerTypes.ANNOTATIONS || layer.type === REFERENCE_LINE) { return; } + const logTable = prepareLogTable(layer.table, getLayerDimensions(layer), true); handlers.inspectorAdapters.tables.logDatatable(layer.layerId, logTable); }); }; export const getLayerDimensions = ( - layer: CommonXYDataLayerConfig | CommonXYReferenceLineLayerConfig + layer: CommonXYDataLayerConfig | ReferenceLineLayerConfig ): Dimension[] => { let xAccessor; let splitAccessor; diff --git a/src/plugins/chart_expressions/expression_xy/public/components/annotations.tsx b/src/plugins/chart_expressions/expression_xy/public/components/annotations.tsx index 842baeb82d78d..6d76a230737ed 100644 --- a/src/plugins/chart_expressions/expression_xy/public/components/annotations.tsx +++ b/src/plugins/chart_expressions/expression_xy/public/components/annotations.tsx @@ -7,7 +7,7 @@ */ import './annotations.scss'; -import './reference_lines.scss'; +import './reference_lines/reference_lines.scss'; import React from 'react'; import { snakeCase } from 'lodash'; diff --git a/src/plugins/chart_expressions/expression_xy/public/components/reference_lines.test.tsx b/src/plugins/chart_expressions/expression_xy/public/components/reference_lines.test.tsx deleted file mode 100644 index 23e5011fe54a7..0000000000000 --- a/src/plugins/chart_expressions/expression_xy/public/components/reference_lines.test.tsx +++ /dev/null @@ -1,369 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 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 { LineAnnotation, RectAnnotation } from '@elastic/charts'; -import { shallow } from 'enzyme'; -import React from 'react'; -import { Datatable } from '@kbn/expressions-plugin/common'; -import { FieldFormat } from '@kbn/field-formats-plugin/common'; -import { LayerTypes } from '../../common/constants'; -import { - ReferenceLineLayerArgs, - ReferenceLineLayerConfig, - ExtendedYConfig, -} from '../../common/types'; -import { ReferenceLineAnnotations, ReferenceLineAnnotationsProps } from './reference_lines'; - -const row: Record = { - xAccessorFirstId: 1, - xAccessorSecondId: 2, - yAccessorLeftFirstId: 5, - yAccessorLeftSecondId: 10, - yAccessorRightFirstId: 5, - yAccessorRightSecondId: 10, -}; - -const data: Datatable = { - type: 'datatable', - rows: [row], - columns: Object.keys(row).map((id) => ({ - id, - name: `Static value: ${row[id]}`, - meta: { - type: 'number', - params: { id: 'number' }, - }, - })), -}; - -function createLayers(yConfigs: ReferenceLineLayerArgs['yConfig']): ReferenceLineLayerConfig[] { - return [ - { - layerId: 'first', - accessors: (yConfigs || []).map(({ forAccessor }) => forAccessor), - yConfig: yConfigs, - type: 'referenceLineLayer', - layerType: LayerTypes.REFERENCELINE, - table: data, - }, - ]; -} - -interface YCoords { - y0: number | undefined; - y1: number | undefined; -} -interface XCoords { - x0: number | undefined; - x1: number | undefined; -} - -function getAxisFromId(layerPrefix: string): ExtendedYConfig['axisMode'] { - return /left/i.test(layerPrefix) ? 'left' : /right/i.test(layerPrefix) ? 'right' : 'bottom'; -} - -const emptyCoords = { x0: undefined, x1: undefined, y0: undefined, y1: undefined }; - -describe('ReferenceLineAnnotations', () => { - describe('with fill', () => { - let formatters: Record<'left' | 'right' | 'bottom', FieldFormat | undefined>; - let defaultProps: Omit; - - beforeEach(() => { - formatters = { - left: { convert: jest.fn((x) => x) } as unknown as FieldFormat, - right: { convert: jest.fn((x) => x) } as unknown as FieldFormat, - bottom: { convert: jest.fn((x) => x) } as unknown as FieldFormat, - }; - - defaultProps = { - formatters, - isHorizontal: false, - axesMap: { left: true, right: false }, - paddingMap: {}, - }; - }); - - it.each([ - ['yAccessorLeft', 'above'], - ['yAccessorLeft', 'below'], - ['yAccessorRight', 'above'], - ['yAccessorRight', 'below'], - ] as Array<[string, ExtendedYConfig['fill']]>)( - 'should render a RectAnnotation for a reference line with fill set: %s %s', - (layerPrefix, fill) => { - const axisMode = getAxisFromId(layerPrefix); - const wrapper = shallow( - - ); - - const y0 = fill === 'above' ? 5 : undefined; - const y1 = fill === 'above' ? undefined : 5; - - expect(wrapper.find(LineAnnotation).exists()).toBe(true); - expect(wrapper.find(RectAnnotation).exists()).toBe(true); - expect(wrapper.find(RectAnnotation).prop('dataValues')).toEqual( - expect.arrayContaining([ - { - coordinates: { x0: undefined, x1: undefined, y0, y1 }, - details: y0 ?? y1, - header: undefined, - }, - ]) - ); - } - ); - - it.each([ - ['xAccessor', 'above'], - ['xAccessor', 'below'], - ] as Array<[string, ExtendedYConfig['fill']]>)( - 'should render a RectAnnotation for a reference line with fill set: %s %s', - (layerPrefix, fill) => { - const wrapper = shallow( - - ); - - const x0 = fill === 'above' ? 1 : undefined; - const x1 = fill === 'above' ? undefined : 1; - - expect(wrapper.find(LineAnnotation).exists()).toBe(true); - expect(wrapper.find(RectAnnotation).exists()).toBe(true); - expect(wrapper.find(RectAnnotation).prop('dataValues')).toEqual( - expect.arrayContaining([ - { - coordinates: { ...emptyCoords, x0, x1 }, - details: x0 ?? x1, - header: undefined, - }, - ]) - ); - } - ); - - it.each([ - ['yAccessorLeft', 'above', { y0: 5, y1: 10 }, { y0: 10, y1: undefined }], - ['yAccessorLeft', 'below', { y0: undefined, y1: 5 }, { y0: 5, y1: 10 }], - ['yAccessorRight', 'above', { y0: 5, y1: 10 }, { y0: 10, y1: undefined }], - ['yAccessorRight', 'below', { y0: undefined, y1: 5 }, { y0: 5, y1: 10 }], - ] as Array<[string, ExtendedYConfig['fill'], YCoords, YCoords]>)( - 'should avoid overlap between two reference lines with fill in the same direction: 2 x %s %s', - (layerPrefix, fill, coordsA, coordsB) => { - const axisMode = getAxisFromId(layerPrefix); - const wrapper = shallow( - - ); - - expect(wrapper.find(RectAnnotation).first().prop('dataValues')).toEqual( - expect.arrayContaining([ - { - coordinates: { ...emptyCoords, ...coordsA }, - details: coordsA.y0 ?? coordsA.y1, - header: undefined, - }, - ]) - ); - expect(wrapper.find(RectAnnotation).last().prop('dataValues')).toEqual( - expect.arrayContaining([ - { - coordinates: { ...emptyCoords, ...coordsB }, - details: coordsB.y1 ?? coordsB.y0, - header: undefined, - }, - ]) - ); - } - ); - - it.each([ - ['xAccessor', 'above', { x0: 1, x1: 2 }, { x0: 2, x1: undefined }], - ['xAccessor', 'below', { x0: undefined, x1: 1 }, { x0: 1, x1: 2 }], - ] as Array<[string, ExtendedYConfig['fill'], XCoords, XCoords]>)( - 'should avoid overlap between two reference lines with fill in the same direction: 2 x %s %s', - (layerPrefix, fill, coordsA, coordsB) => { - const wrapper = shallow( - - ); - - expect(wrapper.find(RectAnnotation).first().prop('dataValues')).toEqual( - expect.arrayContaining([ - { - coordinates: { ...emptyCoords, ...coordsA }, - details: coordsA.x0 ?? coordsA.x1, - header: undefined, - }, - ]) - ); - expect(wrapper.find(RectAnnotation).last().prop('dataValues')).toEqual( - expect.arrayContaining([ - { - coordinates: { ...emptyCoords, ...coordsB }, - details: coordsB.x1 ?? coordsB.x0, - header: undefined, - }, - ]) - ); - } - ); - - it.each(['yAccessorLeft', 'yAccessorRight', 'xAccessor'])( - 'should let areas in different directions overlap: %s', - (layerPrefix) => { - const axisMode = getAxisFromId(layerPrefix); - - const wrapper = shallow( - - ); - - expect(wrapper.find(RectAnnotation).first().prop('dataValues')).toEqual( - expect.arrayContaining([ - { - coordinates: { ...emptyCoords, ...(axisMode === 'bottom' ? { x0: 1 } : { y0: 5 }) }, - details: axisMode === 'bottom' ? 1 : 5, - header: undefined, - }, - ]) - ); - expect(wrapper.find(RectAnnotation).last().prop('dataValues')).toEqual( - expect.arrayContaining([ - { - coordinates: { ...emptyCoords, ...(axisMode === 'bottom' ? { x1: 2 } : { y1: 10 }) }, - details: axisMode === 'bottom' ? 2 : 10, - header: undefined, - }, - ]) - ); - } - ); - - it.each([ - ['above', { y0: 5, y1: 10 }, { y0: 10, y1: undefined }], - ['below', { y0: undefined, y1: 5 }, { y0: 5, y1: 10 }], - ] as Array<[ExtendedYConfig['fill'], YCoords, YCoords]>)( - 'should be robust and works also for different axes when on same direction: 1x Left + 1x Right both %s', - (fill, coordsA, coordsB) => { - const wrapper = shallow( - - ); - - expect(wrapper.find(RectAnnotation).first().prop('dataValues')).toEqual( - expect.arrayContaining([ - { - coordinates: { ...emptyCoords, ...coordsA }, - details: coordsA.y0 ?? coordsA.y1, - header: undefined, - }, - ]) - ); - expect(wrapper.find(RectAnnotation).last().prop('dataValues')).toEqual( - expect.arrayContaining([ - { - coordinates: { ...emptyCoords, ...coordsB }, - details: coordsB.y1 ?? coordsB.y0, - header: undefined, - }, - ]) - ); - } - ); - }); -}); diff --git a/src/plugins/chart_expressions/expression_xy/public/components/reference_lines.tsx b/src/plugins/chart_expressions/expression_xy/public/components/reference_lines.tsx deleted file mode 100644 index d17dbf2a70ad1..0000000000000 --- a/src/plugins/chart_expressions/expression_xy/public/components/reference_lines.tsx +++ /dev/null @@ -1,268 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import './reference_lines.scss'; - -import React from 'react'; -import { groupBy } from 'lodash'; -import { RectAnnotation, AnnotationDomainType, LineAnnotation, Position } from '@elastic/charts'; -import { euiLightVars } from '@kbn/ui-theme'; -import type { FieldFormat } from '@kbn/field-formats-plugin/common'; -import type { CommonXYReferenceLineLayerConfig, IconPosition, YAxisMode } from '../../common/types'; -import { - LINES_MARKER_SIZE, - mapVerticalToHorizontalPlacement, - Marker, - MarkerBody, -} from '../helpers'; - -export const computeChartMargins = ( - referenceLinePaddings: Partial>, - labelVisibility: Partial>, - titleVisibility: Partial>, - axesMap: Record<'left' | 'right', unknown>, - isHorizontal: boolean -) => { - const result: Partial> = {}; - if (!labelVisibility?.x && !titleVisibility?.x && referenceLinePaddings.bottom) { - const placement = isHorizontal ? mapVerticalToHorizontalPlacement('bottom') : 'bottom'; - result[placement] = referenceLinePaddings.bottom; - } - if ( - referenceLinePaddings.left && - (isHorizontal || (!labelVisibility?.yLeft && !titleVisibility?.yLeft)) - ) { - const placement = isHorizontal ? mapVerticalToHorizontalPlacement('left') : 'left'; - result[placement] = referenceLinePaddings.left; - } - if ( - referenceLinePaddings.right && - (isHorizontal || !axesMap.right || (!labelVisibility?.yRight && !titleVisibility?.yRight)) - ) { - const placement = isHorizontal ? mapVerticalToHorizontalPlacement('right') : 'right'; - result[placement] = referenceLinePaddings.right; - } - // there's no top axis, so just check if a margin has been computed - if (referenceLinePaddings.top) { - const placement = isHorizontal ? mapVerticalToHorizontalPlacement('top') : 'top'; - result[placement] = referenceLinePaddings.top; - } - return result; -}; - -// if there's just one axis, put it on the other one -// otherwise use the same axis -// this function assume the chart is vertical -export function getBaseIconPlacement( - iconPosition: IconPosition | undefined, - axesMap?: Record, - axisMode?: YAxisMode -) { - if (iconPosition === 'auto') { - if (axisMode === 'bottom') { - return Position.Top; - } - if (axesMap) { - if (axisMode === 'left') { - return axesMap.right ? Position.Left : Position.Right; - } - return axesMap.left ? Position.Right : Position.Left; - } - } - - if (iconPosition === 'left') { - return Position.Left; - } - if (iconPosition === 'right') { - return Position.Right; - } - if (iconPosition === 'below') { - return Position.Bottom; - } - return Position.Top; -} - -export interface ReferenceLineAnnotationsProps { - layers: CommonXYReferenceLineLayerConfig[]; - formatters: Record<'left' | 'right' | 'bottom', FieldFormat | undefined>; - axesMap: Record<'left' | 'right', boolean>; - isHorizontal: boolean; - paddingMap: Partial>; -} - -export const ReferenceLineAnnotations = ({ - layers, - formatters, - axesMap, - isHorizontal, - paddingMap, -}: ReferenceLineAnnotationsProps) => { - return ( - <> - {layers.flatMap((layer) => { - if (!layer.yConfig) { - return []; - } - const { columnToLabel, yConfig: yConfigs, table } = layer; - const columnToLabelMap: Record = columnToLabel - ? JSON.parse(columnToLabel) - : {}; - - const row = table.rows[0]; - - const yConfigByValue = yConfigs.sort( - ({ forAccessor: idA }, { forAccessor: idB }) => row[idA] - row[idB] - ); - - const groupedByDirection = groupBy(yConfigByValue, 'fill'); - if (groupedByDirection.below) { - groupedByDirection.below.reverse(); - } - - return yConfigByValue.flatMap((yConfig, i) => { - // Find the formatter for the given axis - const groupId = - yConfig.axisMode === 'bottom' - ? undefined - : yConfig.axisMode === 'right' - ? 'right' - : 'left'; - - const formatter = formatters[groupId || 'bottom']; - - const defaultColor = euiLightVars.euiColorDarkShade; - - // get the position for vertical chart - const markerPositionVertical = getBaseIconPlacement( - yConfig.iconPosition, - axesMap, - yConfig.axisMode - ); - // the padding map is built for vertical chart - const hasReducedPadding = paddingMap[markerPositionVertical] === LINES_MARKER_SIZE; - - const props = { - groupId, - marker: ( - - ), - markerBody: ( - - ), - // rotate the position if required - markerPosition: isHorizontal - ? mapVerticalToHorizontalPlacement(markerPositionVertical) - : markerPositionVertical, - }; - const annotations = []; - - const sharedStyle = { - strokeWidth: yConfig.lineWidth || 1, - stroke: yConfig.color || defaultColor, - dash: - yConfig.lineStyle === 'dashed' - ? [(yConfig.lineWidth || 1) * 3, yConfig.lineWidth || 1] - : yConfig.lineStyle === 'dotted' - ? [yConfig.lineWidth || 1, yConfig.lineWidth || 1] - : undefined, - }; - - annotations.push( - ({ - dataValue: row[yConfig.forAccessor], - header: columnToLabelMap[yConfig.forAccessor], - details: formatter?.convert(row[yConfig.forAccessor]) || row[yConfig.forAccessor], - }))} - domainType={ - yConfig.axisMode === 'bottom' - ? AnnotationDomainType.XDomain - : AnnotationDomainType.YDomain - } - style={{ - line: { - ...sharedStyle, - opacity: 1, - }, - }} - /> - ); - - if (yConfig.fill && yConfig.fill !== 'none') { - const isFillAbove = yConfig.fill === 'above'; - const indexFromSameType = groupedByDirection[yConfig.fill].findIndex( - ({ forAccessor }) => forAccessor === yConfig.forAccessor - ); - const shouldCheckNextReferenceLine = - indexFromSameType < groupedByDirection[yConfig.fill].length - 1; - annotations.push( - { - const nextValue = shouldCheckNextReferenceLine - ? row[groupedByDirection[yConfig.fill!][indexFromSameType + 1].forAccessor] - : undefined; - if (yConfig.axisMode === 'bottom') { - return { - coordinates: { - x0: isFillAbove ? row[yConfig.forAccessor] : nextValue, - y0: undefined, - x1: isFillAbove ? nextValue : row[yConfig.forAccessor], - y1: undefined, - }, - header: columnToLabelMap[yConfig.forAccessor], - details: - formatter?.convert(row[yConfig.forAccessor]) || row[yConfig.forAccessor], - }; - } - return { - coordinates: { - x0: undefined, - y0: isFillAbove ? row[yConfig.forAccessor] : nextValue, - x1: undefined, - y1: isFillAbove ? nextValue : row[yConfig.forAccessor], - }, - header: columnToLabelMap[yConfig.forAccessor], - details: - formatter?.convert(row[yConfig.forAccessor]) || row[yConfig.forAccessor], - }; - })} - style={{ - ...sharedStyle, - fill: yConfig.color || defaultColor, - opacity: 0.1, - }} - /> - ); - } - return annotations; - }); - })} - - ); -}; diff --git a/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/index.ts b/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/index.ts new file mode 100644 index 0000000000000..62b3b31bf8bd5 --- /dev/null +++ b/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './reference_lines'; +export * from './utils'; diff --git a/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_line.tsx b/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_line.tsx new file mode 100644 index 0000000000000..74bb18597f2f2 --- /dev/null +++ b/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_line.tsx @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { FC } from 'react'; +import { Position } from '@elastic/charts'; +import { FieldFormat } from '@kbn/field-formats-plugin/common'; +import { ReferenceLineConfig } from '../../../common/types'; +import { getGroupId } from './utils'; +import { ReferenceLineAnnotations } from './reference_line_annotations'; + +interface ReferenceLineProps { + layer: ReferenceLineConfig; + paddingMap: Partial>; + formatters: Record<'left' | 'right' | 'bottom', FieldFormat | undefined>; + axesMap: Record<'left' | 'right', boolean>; + isHorizontal: boolean; +} + +export const ReferenceLine: FC = ({ + layer, + axesMap, + formatters, + paddingMap, + isHorizontal, +}) => { + const { + yConfig: [yConfig], + } = layer; + + if (!yConfig) { + return null; + } + + const { axisMode, value } = yConfig; + + // Find the formatter for the given axis + const groupId = getGroupId(axisMode); + + const formatter = formatters[groupId || 'bottom']; + const id = `${layer.layerId}-${value}`; + + return ( + + ); +}; diff --git a/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_line_annotations.tsx b/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_line_annotations.tsx new file mode 100644 index 0000000000000..b5b94b4c2df51 --- /dev/null +++ b/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_line_annotations.tsx @@ -0,0 +1,137 @@ +/* + * Copyright 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 { AnnotationDomainType, LineAnnotation, Position, RectAnnotation } from '@elastic/charts'; +import { euiLightVars } from '@kbn/ui-theme'; +import React, { FC } from 'react'; +import { FieldFormat } from '@kbn/field-formats-plugin/common'; +import { LINES_MARKER_SIZE } from '../../helpers'; +import { + AvailableReferenceLineIcon, + FillStyle, + IconPosition, + LineStyle, + YAxisMode, +} from '../../../common/types'; +import { + getBaseIconPlacement, + getBottomRect, + getGroupId, + getHorizontalRect, + getLineAnnotationProps, + getSharedStyle, +} from './utils'; + +export interface ReferenceLineAnnotationConfig { + id: string; + name?: string; + value: number; + nextValue?: number; + icon?: AvailableReferenceLineIcon; + lineWidth?: number; + lineStyle?: LineStyle; + fill?: FillStyle; + iconPosition?: IconPosition; + textVisibility?: boolean; + axisMode?: YAxisMode; + color?: string; +} + +interface Props { + config: ReferenceLineAnnotationConfig; + paddingMap: Partial>; + formatter?: FieldFormat; + axesMap: Record<'left' | 'right', boolean>; + isHorizontal: boolean; +} + +const getRectDataValue = ( + annotationConfig: ReferenceLineAnnotationConfig, + formatter: FieldFormat | undefined +) => { + const { name, value, nextValue, fill, axisMode } = annotationConfig; + const isFillAbove = fill === 'above'; + + if (axisMode === 'bottom') { + return getBottomRect(name, isFillAbove, formatter, value, nextValue); + } + + return getHorizontalRect(name, isFillAbove, formatter, value, nextValue); +}; + +export const ReferenceLineAnnotations: FC = ({ + config, + axesMap, + formatter, + paddingMap, + isHorizontal, +}) => { + const { id, axisMode, iconPosition, name, textVisibility, value, fill, color } = config; + + // Find the formatter for the given axis + const groupId = getGroupId(axisMode); + const defaultColor = euiLightVars.euiColorDarkShade; + // get the position for vertical chart + const markerPositionVertical = getBaseIconPlacement(iconPosition, axesMap, axisMode); + // the padding map is built for vertical chart + const hasReducedPadding = paddingMap[markerPositionVertical] === LINES_MARKER_SIZE; + + const props = getLineAnnotationProps( + config, + { + markerLabel: name, + markerBodyLabel: textVisibility && !hasReducedPadding ? name : undefined, + }, + axesMap, + paddingMap, + groupId, + isHorizontal + ); + + const sharedStyle = getSharedStyle(config); + + const dataValues = { + dataValue: value, + header: name, + details: formatter?.convert(value) || value.toString(), + }; + + const line = ( + + ); + + let rect; + if (fill && fill !== 'none') { + const rectDataValues = getRectDataValue(config, formatter); + + rect = ( + + ); + } + return ( + <> + {line} + {rect} + + ); +}; diff --git a/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_line_layer.tsx b/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_line_layer.tsx new file mode 100644 index 0000000000000..210f5bda0126b --- /dev/null +++ b/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_line_layer.tsx @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { FC } from 'react'; +import { FieldFormat } from '@kbn/field-formats-plugin/common'; +import { groupBy } from 'lodash'; +import { Position } from '@elastic/charts'; +import { ReferenceLineLayerConfig } from '../../../common/types'; +import { getGroupId } from './utils'; +import { ReferenceLineAnnotations } from './reference_line_annotations'; + +interface ReferenceLineLayerProps { + layer: ReferenceLineLayerConfig; + formatters: Record<'left' | 'right' | 'bottom', FieldFormat | undefined>; + paddingMap: Partial>; + axesMap: Record<'left' | 'right', boolean>; + isHorizontal: boolean; +} + +export const ReferenceLineLayer: FC = ({ + layer, + formatters, + paddingMap, + axesMap, + isHorizontal, +}) => { + if (!layer.yConfig) { + return null; + } + + const { columnToLabel, yConfig: yConfigs, table } = layer; + const columnToLabelMap: Record = columnToLabel ? JSON.parse(columnToLabel) : {}; + + const row = table.rows[0]; + + const yConfigByValue = yConfigs.sort( + ({ forAccessor: idA }, { forAccessor: idB }) => row[idA] - row[idB] + ); + + const groupedByDirection = groupBy(yConfigByValue, 'fill'); + if (groupedByDirection.below) { + groupedByDirection.below.reverse(); + } + + const referenceLineElements = yConfigByValue.flatMap((yConfig) => { + const { axisMode } = yConfig; + + // Find the formatter for the given axis + const groupId = getGroupId(axisMode); + + const formatter = formatters[groupId || 'bottom']; + const name = columnToLabelMap[yConfig.forAccessor]; + const value = row[yConfig.forAccessor]; + const yConfigsWithSameDirection = groupedByDirection[yConfig.fill!]; + const indexFromSameType = yConfigsWithSameDirection.findIndex( + ({ forAccessor }) => forAccessor === yConfig.forAccessor + ); + + const shouldCheckNextReferenceLine = indexFromSameType < yConfigsWithSameDirection.length - 1; + + const nextValue = shouldCheckNextReferenceLine + ? row[yConfigsWithSameDirection[indexFromSameType + 1].forAccessor] + : undefined; + + const { forAccessor, type, ...restAnnotationConfig } = yConfig; + const id = `${layer.layerId}-${yConfig.forAccessor}`; + + return ( + + ); + }); + + return <>{referenceLineElements}; +}; diff --git a/src/plugins/chart_expressions/expression_xy/public/components/reference_lines.scss b/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_lines.scss similarity index 100% rename from src/plugins/chart_expressions/expression_xy/public/components/reference_lines.scss rename to src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_lines.scss diff --git a/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_lines.test.tsx b/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_lines.test.tsx new file mode 100644 index 0000000000000..35e434d65bc18 --- /dev/null +++ b/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_lines.test.tsx @@ -0,0 +1,683 @@ +/* + * Copyright 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 { LineAnnotation, RectAnnotation } from '@elastic/charts'; +import { shallow } from 'enzyme'; +import React from 'react'; +import { Datatable } from '@kbn/expressions-plugin/common'; +import { FieldFormat } from '@kbn/field-formats-plugin/common'; +import { LayerTypes } from '../../../common/constants'; +import { + ReferenceLineLayerArgs, + ReferenceLineLayerConfig, + ExtendedYConfig, + ReferenceLineArgs, + ReferenceLineConfig, +} from '../../../common/types'; +import { ReferenceLines, ReferenceLinesProps } from './reference_lines'; +import { ReferenceLineLayer } from './reference_line_layer'; +import { ReferenceLine } from './reference_line'; +import { ReferenceLineAnnotations } from './reference_line_annotations'; + +const row: Record = { + xAccessorFirstId: 1, + xAccessorSecondId: 2, + yAccessorLeftFirstId: 5, + yAccessorLeftSecondId: 10, + yAccessorRightFirstId: 5, + yAccessorRightSecondId: 10, +}; + +const data: Datatable = { + type: 'datatable', + rows: [row], + columns: Object.keys(row).map((id) => ({ + id, + name: `Static value: ${row[id]}`, + meta: { + type: 'number', + params: { id: 'number' }, + }, + })), +}; + +function createLayers(yConfigs: ReferenceLineLayerArgs['yConfig']): ReferenceLineLayerConfig[] { + return [ + { + layerId: 'first', + accessors: (yConfigs || []).map(({ forAccessor }) => forAccessor), + yConfig: yConfigs, + type: 'referenceLineLayer', + layerType: LayerTypes.REFERENCELINE, + table: data, + }, + ]; +} + +function createReferenceLine( + layerId: string, + lineLength: number, + args: ReferenceLineArgs +): ReferenceLineConfig { + return { + layerId, + type: 'referenceLine', + layerType: 'referenceLine', + lineLength, + yConfig: [{ type: 'referenceLineYConfig', ...args }], + }; +} + +interface YCoords { + y0: number | undefined; + y1: number | undefined; +} +interface XCoords { + x0: number | undefined; + x1: number | undefined; +} + +function getAxisFromId(layerPrefix: string): ExtendedYConfig['axisMode'] { + return /left/i.test(layerPrefix) ? 'left' : /right/i.test(layerPrefix) ? 'right' : 'bottom'; +} + +const emptyCoords = { x0: undefined, x1: undefined, y0: undefined, y1: undefined }; + +describe('ReferenceLines', () => { + describe('referenceLineLayers', () => { + let formatters: Record<'left' | 'right' | 'bottom', FieldFormat | undefined>; + let defaultProps: Omit; + + beforeEach(() => { + formatters = { + left: { convert: jest.fn((x) => x) } as unknown as FieldFormat, + right: { convert: jest.fn((x) => x) } as unknown as FieldFormat, + bottom: { convert: jest.fn((x) => x) } as unknown as FieldFormat, + }; + + defaultProps = { + formatters, + isHorizontal: false, + axesMap: { left: true, right: false }, + paddingMap: {}, + }; + }); + + it.each([ + ['yAccessorLeft', 'above'], + ['yAccessorLeft', 'below'], + ['yAccessorRight', 'above'], + ['yAccessorRight', 'below'], + ] as Array<[string, ExtendedYConfig['fill']]>)( + 'should render a RectAnnotation for a reference line with fill set: %s %s', + (layerPrefix, fill) => { + const axisMode = getAxisFromId(layerPrefix); + const wrapper = shallow( + + ); + const referenceLineLayer = wrapper.find(ReferenceLineLayer).dive(); + + const y0 = fill === 'above' ? 5 : undefined; + const y1 = fill === 'above' ? undefined : 5; + + const referenceLineAnnotation = referenceLineLayer.find(ReferenceLineAnnotations).dive(); + expect(referenceLineAnnotation.find(LineAnnotation).exists()).toBe(true); + expect(referenceLineAnnotation.find(RectAnnotation).exists()).toBe(true); + expect(referenceLineAnnotation.find(RectAnnotation).prop('dataValues')).toEqual( + expect.arrayContaining([ + { + coordinates: { x0: undefined, x1: undefined, y0, y1 }, + details: y0 ?? y1, + header: undefined, + }, + ]) + ); + } + ); + + it.each([ + ['xAccessor', 'above'], + ['xAccessor', 'below'], + ] as Array<[string, ExtendedYConfig['fill']]>)( + 'should render a RectAnnotation for a reference line with fill set: %s %s', + (layerPrefix, fill) => { + const wrapper = shallow( + + ); + const referenceLineLayer = wrapper.find(ReferenceLineLayer).dive(); + + const x0 = fill === 'above' ? 1 : undefined; + const x1 = fill === 'above' ? undefined : 1; + + const referenceLineAnnotation = referenceLineLayer.find(ReferenceLineAnnotations).dive(); + expect(referenceLineAnnotation.find(LineAnnotation).exists()).toBe(true); + expect(referenceLineAnnotation.find(RectAnnotation).exists()).toBe(true); + expect(referenceLineAnnotation.find(RectAnnotation).prop('dataValues')).toEqual( + expect.arrayContaining([ + { + coordinates: { ...emptyCoords, x0, x1 }, + details: x0 ?? x1, + header: undefined, + }, + ]) + ); + } + ); + + it.each([ + ['yAccessorLeft', 'above', { y0: 5, y1: 10 }, { y0: 10, y1: undefined }], + ['yAccessorLeft', 'below', { y0: undefined, y1: 5 }, { y0: 5, y1: 10 }], + ['yAccessorRight', 'above', { y0: 5, y1: 10 }, { y0: 10, y1: undefined }], + ['yAccessorRight', 'below', { y0: undefined, y1: 5 }, { y0: 5, y1: 10 }], + ] as Array<[string, ExtendedYConfig['fill'], YCoords, YCoords]>)( + 'should avoid overlap between two reference lines with fill in the same direction: 2 x %s %s', + (layerPrefix, fill, coordsA, coordsB) => { + const axisMode = getAxisFromId(layerPrefix); + const wrapper = shallow( + + ); + const referenceLineLayer = wrapper.find(ReferenceLineLayer).dive(); + const referenceLineAnnotations = referenceLineLayer.find(ReferenceLineAnnotations); + + expect( + referenceLineAnnotations.first().dive().find(RectAnnotation).prop('dataValues') + ).toEqual( + expect.arrayContaining([ + { + coordinates: { ...emptyCoords, ...coordsA }, + details: coordsA.y0 ?? coordsA.y1, + header: undefined, + }, + ]) + ); + expect( + referenceLineAnnotations.last().dive().find(RectAnnotation).prop('dataValues') + ).toEqual( + expect.arrayContaining([ + { + coordinates: { ...emptyCoords, ...coordsB }, + details: coordsB.y1 ?? coordsB.y0, + header: undefined, + }, + ]) + ); + } + ); + + it.each([ + ['xAccessor', 'above', { x0: 1, x1: 2 }, { x0: 2, x1: undefined }], + ['xAccessor', 'below', { x0: undefined, x1: 1 }, { x0: 1, x1: 2 }], + ] as Array<[string, ExtendedYConfig['fill'], XCoords, XCoords]>)( + 'should avoid overlap between two reference lines with fill in the same direction: 2 x %s %s', + (layerPrefix, fill, coordsA, coordsB) => { + const wrapper = shallow( + + ); + const referenceLineLayer = wrapper.find(ReferenceLineLayer).dive(); + const referenceLineAnnotations = referenceLineLayer.find(ReferenceLineAnnotations); + + expect( + referenceLineAnnotations.first().dive().find(RectAnnotation).prop('dataValues') + ).toEqual( + expect.arrayContaining([ + { + coordinates: { ...emptyCoords, ...coordsA }, + details: coordsA.x0 ?? coordsA.x1, + header: undefined, + }, + ]) + ); + expect( + referenceLineAnnotations.last().dive().find(RectAnnotation).prop('dataValues') + ).toEqual( + expect.arrayContaining([ + { + coordinates: { ...emptyCoords, ...coordsB }, + details: coordsB.x1 ?? coordsB.x0, + header: undefined, + }, + ]) + ); + } + ); + + it.each(['yAccessorLeft', 'yAccessorRight', 'xAccessor'])( + 'should let areas in different directions overlap: %s', + (layerPrefix) => { + const axisMode = getAxisFromId(layerPrefix); + + const wrapper = shallow( + + ); + const referenceLineLayer = wrapper.find(ReferenceLineLayer).dive(); + const referenceLineAnnotations = referenceLineLayer.find(ReferenceLineAnnotations); + + expect( + referenceLineAnnotations.first().dive().find(RectAnnotation).prop('dataValues') + ).toEqual( + expect.arrayContaining([ + { + coordinates: { ...emptyCoords, ...(axisMode === 'bottom' ? { x0: 1 } : { y0: 5 }) }, + details: axisMode === 'bottom' ? 1 : 5, + header: undefined, + }, + ]) + ); + expect( + referenceLineAnnotations.last().dive().find(RectAnnotation).prop('dataValues') + ).toEqual( + expect.arrayContaining([ + { + coordinates: { ...emptyCoords, ...(axisMode === 'bottom' ? { x1: 2 } : { y1: 10 }) }, + details: axisMode === 'bottom' ? 2 : 10, + header: undefined, + }, + ]) + ); + } + ); + + it.each([ + ['above', { y0: 5, y1: 10 }, { y0: 10, y1: undefined }], + ['below', { y0: undefined, y1: 5 }, { y0: 5, y1: 10 }], + ] as Array<[ExtendedYConfig['fill'], YCoords, YCoords]>)( + 'should be robust and works also for different axes when on same direction: 1x Left + 1x Right both %s', + (fill, coordsA, coordsB) => { + const wrapper = shallow( + + ); + const referenceLineLayer = wrapper.find(ReferenceLineLayer).dive(); + const referenceLineAnnotations = referenceLineLayer.find(ReferenceLineAnnotations); + + expect( + referenceLineAnnotations.first().dive().find(RectAnnotation).prop('dataValues') + ).toEqual( + expect.arrayContaining([ + { + coordinates: { ...emptyCoords, ...coordsA }, + details: coordsA.y0 ?? coordsA.y1, + header: undefined, + }, + ]) + ); + expect( + referenceLineAnnotations.last().dive().find(RectAnnotation).prop('dataValues') + ).toEqual( + expect.arrayContaining([ + { + coordinates: { ...emptyCoords, ...coordsB }, + details: coordsB.y1 ?? coordsB.y0, + header: undefined, + }, + ]) + ); + } + ); + }); + + describe('referenceLines', () => { + let formatters: Record<'left' | 'right' | 'bottom', FieldFormat | undefined>; + let defaultProps: Omit; + + beforeEach(() => { + formatters = { + left: { convert: jest.fn((x) => x) } as unknown as FieldFormat, + right: { convert: jest.fn((x) => x) } as unknown as FieldFormat, + bottom: { convert: jest.fn((x) => x) } as unknown as FieldFormat, + }; + + defaultProps = { + formatters, + isHorizontal: false, + axesMap: { left: true, right: false }, + paddingMap: {}, + }; + }); + + it.each([ + ['yAccessorLeft', 'above'], + ['yAccessorLeft', 'below'], + ['yAccessorRight', 'above'], + ['yAccessorRight', 'below'], + ] as Array<[string, ExtendedYConfig['fill']]>)( + 'should render a RectAnnotation for a reference line with fill set: %s %s', + (layerPrefix, fill) => { + const axisMode = getAxisFromId(layerPrefix); + const value = 5; + const wrapper = shallow( + + ); + const referenceLine = wrapper.find(ReferenceLine).dive(); + const referenceLineAnnotation = referenceLine.find(ReferenceLineAnnotations).dive(); + + const y0 = fill === 'above' ? value : undefined; + const y1 = fill === 'above' ? undefined : value; + + expect(referenceLineAnnotation.find(LineAnnotation).exists()).toBe(true); + expect(referenceLineAnnotation.find(RectAnnotation).exists()).toBe(true); + expect(referenceLineAnnotation.find(RectAnnotation).prop('dataValues')).toEqual( + expect.arrayContaining([ + { + coordinates: { x0: undefined, x1: undefined, y0, y1 }, + details: y0 ?? y1, + header: undefined, + }, + ]) + ); + } + ); + + it.each([ + ['xAccessor', 'above'], + ['xAccessor', 'below'], + ] as Array<[string, ExtendedYConfig['fill']]>)( + 'should render a RectAnnotation for a reference line with fill set: %s %s', + (layerPrefix, fill) => { + const value = 1; + const wrapper = shallow( + + ); + const referenceLine = wrapper.find(ReferenceLine).dive(); + const referenceLineAnnotation = referenceLine.find(ReferenceLineAnnotations).dive(); + + const x0 = fill === 'above' ? value : undefined; + const x1 = fill === 'above' ? undefined : value; + + expect(referenceLineAnnotation.find(LineAnnotation).exists()).toBe(true); + expect(referenceLineAnnotation.find(RectAnnotation).exists()).toBe(true); + expect(referenceLineAnnotation.find(RectAnnotation).prop('dataValues')).toEqual( + expect.arrayContaining([ + { + coordinates: { ...emptyCoords, x0, x1 }, + details: x0 ?? x1, + header: undefined, + }, + ]) + ); + } + ); + + it.each([ + ['yAccessorLeft', 'above', { y0: 10, y1: undefined }, { y0: 10, y1: undefined }], + ['yAccessorLeft', 'below', { y0: undefined, y1: 5 }, { y0: undefined, y1: 5 }], + ] as Array<[string, ExtendedYConfig['fill'], YCoords, YCoords]>)( + 'should avoid overlap between two reference lines with fill in the same direction: 2 x %s %s', + (layerPrefix, fill, coordsA, coordsB) => { + const axisMode = getAxisFromId(layerPrefix); + const value = coordsA.y0 ?? coordsA.y1!; + const wrapper = shallow( + + ); + const referenceLine = wrapper.find(ReferenceLine).first().dive(); + const referenceLineAnnotation = referenceLine.find(ReferenceLineAnnotations).dive(); + + expect(referenceLineAnnotation.find(RectAnnotation).first().prop('dataValues')).toEqual( + expect.arrayContaining([ + { + coordinates: { ...emptyCoords, ...coordsA }, + details: value, + header: undefined, + }, + ]) + ); + expect(referenceLineAnnotation.find(RectAnnotation).last().prop('dataValues')).toEqual( + expect.arrayContaining([ + { + coordinates: { ...emptyCoords, ...coordsB }, + details: value, + header: undefined, + }, + ]) + ); + } + ); + + it.each([ + ['xAccessor', 'above', { x0: 1, x1: undefined }, { x0: 1, x1: undefined }], + ['xAccessor', 'below', { x0: undefined, x1: 1 }, { x0: undefined, x1: 1 }], + ] as Array<[string, ExtendedYConfig['fill'], XCoords, XCoords]>)( + 'should avoid overlap between two reference lines with fill in the same direction: 2 x %s %s', + (layerPrefix, fill, coordsA, coordsB) => { + const value = coordsA.x0 ?? coordsA.x1!; + const wrapper = shallow( + + ); + const referenceLine1 = wrapper.find(ReferenceLine).first().dive(); + const referenceLineAnnotation1 = referenceLine1.find(ReferenceLineAnnotations).dive(); + + expect(referenceLineAnnotation1.find(RectAnnotation).first().prop('dataValues')).toEqual( + expect.arrayContaining([ + { + coordinates: { ...emptyCoords, ...coordsA }, + details: value, + header: undefined, + }, + ]) + ); + + const referenceLine2 = wrapper.find(ReferenceLine).last().dive(); + const referenceLineAnnotation2 = referenceLine2.find(ReferenceLineAnnotations).dive(); + + expect(referenceLineAnnotation2.find(RectAnnotation).last().prop('dataValues')).toEqual( + expect.arrayContaining([ + { + coordinates: { ...emptyCoords, ...coordsB }, + details: value, + header: undefined, + }, + ]) + ); + } + ); + + it.each(['yAccessorLeft', 'yAccessorRight', 'xAccessor'])( + 'should let areas in different directions overlap: %s', + (layerPrefix) => { + const axisMode = getAxisFromId(layerPrefix); + const value1 = 1; + const value2 = 10; + const wrapper = shallow( + + ); + const referenceLine1 = wrapper.find(ReferenceLine).first().dive(); + const referenceLineAnnotation1 = referenceLine1.find(ReferenceLineAnnotations).dive(); + + expect(referenceLineAnnotation1.find(RectAnnotation).first().prop('dataValues')).toEqual( + expect.arrayContaining([ + { + coordinates: { + ...emptyCoords, + ...(axisMode === 'bottom' ? { x0: value1 } : { y0: value1 }), + }, + details: value1, + header: undefined, + }, + ]) + ); + + const referenceLine2 = wrapper.find(ReferenceLine).last().dive(); + const referenceLineAnnotation2 = referenceLine2.find(ReferenceLineAnnotations).dive(); + + expect(referenceLineAnnotation2.find(RectAnnotation).last().prop('dataValues')).toEqual( + expect.arrayContaining([ + { + coordinates: { + ...emptyCoords, + ...(axisMode === 'bottom' ? { x1: value2 } : { y1: value2 }), + }, + details: value2, + header: undefined, + }, + ]) + ); + } + ); + }); +}); diff --git a/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_lines.tsx b/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_lines.tsx new file mode 100644 index 0000000000000..9dca7b6107072 --- /dev/null +++ b/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_lines.tsx @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 './reference_lines.scss'; + +import React from 'react'; +import { Position } from '@elastic/charts'; +import type { FieldFormat } from '@kbn/field-formats-plugin/common'; +import type { CommonXYReferenceLineLayerConfig } from '../../../common/types'; +import { isReferenceLine, mapVerticalToHorizontalPlacement } from '../../helpers'; +import { ReferenceLineLayer } from './reference_line_layer'; +import { ReferenceLine } from './reference_line'; + +export const computeChartMargins = ( + referenceLinePaddings: Partial>, + labelVisibility: Partial>, + titleVisibility: Partial>, + axesMap: Record<'left' | 'right', unknown>, + isHorizontal: boolean +) => { + const result: Partial> = {}; + if (!labelVisibility?.x && !titleVisibility?.x && referenceLinePaddings.bottom) { + const placement = isHorizontal ? mapVerticalToHorizontalPlacement('bottom') : 'bottom'; + result[placement] = referenceLinePaddings.bottom; + } + if ( + referenceLinePaddings.left && + (isHorizontal || (!labelVisibility?.yLeft && !titleVisibility?.yLeft)) + ) { + const placement = isHorizontal ? mapVerticalToHorizontalPlacement('left') : 'left'; + result[placement] = referenceLinePaddings.left; + } + if ( + referenceLinePaddings.right && + (isHorizontal || !axesMap.right || (!labelVisibility?.yRight && !titleVisibility?.yRight)) + ) { + const placement = isHorizontal ? mapVerticalToHorizontalPlacement('right') : 'right'; + result[placement] = referenceLinePaddings.right; + } + // there's no top axis, so just check if a margin has been computed + if (referenceLinePaddings.top) { + const placement = isHorizontal ? mapVerticalToHorizontalPlacement('top') : 'top'; + result[placement] = referenceLinePaddings.top; + } + return result; +}; + +export interface ReferenceLinesProps { + layers: CommonXYReferenceLineLayerConfig[]; + formatters: Record<'left' | 'right' | 'bottom', FieldFormat | undefined>; + axesMap: Record<'left' | 'right', boolean>; + isHorizontal: boolean; + paddingMap: Partial>; +} + +export const ReferenceLines = ({ layers, ...rest }: ReferenceLinesProps) => { + return ( + <> + {layers.flatMap((layer) => { + if (!layer.yConfig) { + return null; + } + + if (isReferenceLine(layer)) { + return ; + } + + return ( + + ); + })} + + ); +}; diff --git a/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/utils.tsx b/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/utils.tsx new file mode 100644 index 0000000000000..1a6eae6a490e6 --- /dev/null +++ b/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/utils.tsx @@ -0,0 +1,143 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { Position } from '@elastic/charts'; +import { euiLightVars } from '@kbn/ui-theme'; +import { FieldFormat } from '@kbn/field-formats-plugin/common'; +import { IconPosition, YAxisMode } from '../../../common/types'; +import { + LINES_MARKER_SIZE, + mapVerticalToHorizontalPlacement, + Marker, + MarkerBody, +} from '../../helpers'; +import { ReferenceLineAnnotationConfig } from './reference_line_annotations'; + +// if there's just one axis, put it on the other one +// otherwise use the same axis +// this function assume the chart is vertical +export function getBaseIconPlacement( + iconPosition: IconPosition | undefined, + axesMap?: Record, + axisMode?: YAxisMode +) { + if (iconPosition === 'auto') { + if (axisMode === 'bottom') { + return Position.Top; + } + if (axesMap) { + if (axisMode === 'left') { + return axesMap.right ? Position.Left : Position.Right; + } + return axesMap.left ? Position.Right : Position.Left; + } + } + + if (iconPosition === 'left') { + return Position.Left; + } + if (iconPosition === 'right') { + return Position.Right; + } + if (iconPosition === 'below') { + return Position.Bottom; + } + return Position.Top; +} + +export const getSharedStyle = (config: ReferenceLineAnnotationConfig) => ({ + strokeWidth: config.lineWidth || 1, + stroke: config.color || euiLightVars.euiColorDarkShade, + dash: + config.lineStyle === 'dashed' + ? [(config.lineWidth || 1) * 3, config.lineWidth || 1] + : config.lineStyle === 'dotted' + ? [config.lineWidth || 1, config.lineWidth || 1] + : undefined, +}); + +export const getLineAnnotationProps = ( + config: ReferenceLineAnnotationConfig, + labels: { markerLabel?: string; markerBodyLabel?: string }, + axesMap: Record<'left' | 'right', boolean>, + paddingMap: Partial>, + groupId: 'left' | 'right' | undefined, + isHorizontal: boolean +) => { + // get the position for vertical chart + const markerPositionVertical = getBaseIconPlacement( + config.iconPosition, + axesMap, + config.axisMode + ); + // the padding map is built for vertical chart + const hasReducedPadding = paddingMap[markerPositionVertical] === LINES_MARKER_SIZE; + + return { + groupId, + marker: ( + + ), + markerBody: ( + + ), + // rotate the position if required + markerPosition: isHorizontal + ? mapVerticalToHorizontalPlacement(markerPositionVertical) + : markerPositionVertical, + }; +}; + +export const getGroupId = (axisMode: YAxisMode | undefined) => + axisMode === 'bottom' ? undefined : axisMode === 'right' ? 'right' : 'left'; + +export const getBottomRect = ( + headerLabel: string | undefined, + isFillAbove: boolean, + formatter: FieldFormat | undefined, + currentValue: number, + nextValue?: number +) => ({ + coordinates: { + x0: isFillAbove ? currentValue : nextValue, + y0: undefined, + x1: isFillAbove ? nextValue : currentValue, + y1: undefined, + }, + header: headerLabel, + details: formatter?.convert(currentValue) || currentValue.toString(), +}); + +export const getHorizontalRect = ( + headerLabel: string | undefined, + isFillAbove: boolean, + formatter: FieldFormat | undefined, + currentValue: number, + nextValue?: number +) => ({ + coordinates: { + x0: undefined, + y0: isFillAbove ? currentValue : nextValue, + x1: undefined, + y1: isFillAbove ? nextValue : currentValue, + }, + header: headerLabel, + details: formatter?.convert(currentValue) || currentValue.toString(), +}); diff --git a/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.tsx b/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.tsx index 9bb3ea4f498e4..80048bcb84038 100644 --- a/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.tsx +++ b/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.tsx @@ -42,14 +42,24 @@ import { LegendSizeToPixels, } from '@kbn/visualizations-plugin/common/constants'; import type { FilterEvent, BrushEvent, FormatFactory } from '../types'; -import type { CommonXYDataLayerConfig, SeriesType, XYChartProps } from '../../common/types'; +import type { + CommonXYDataLayerConfig, + ExtendedYConfig, + ReferenceLineYConfig, + SeriesType, + XYChartProps, +} from '../../common/types'; import { isHorizontalChart, getAnnotationsLayers, getDataLayers, Series, getFormat, + isReferenceLineYConfig, getFormattedTablesByLayers, +} from '../helpers'; + +import { getFilteredLayers, getReferenceLayers, isDataLayer, @@ -60,7 +70,7 @@ import { } from '../helpers'; import { getXDomain, XyEndzones } from './x_domain'; import { getLegendAction } from './legend_action'; -import { ReferenceLineAnnotations, computeChartMargins } from './reference_lines'; +import { ReferenceLines, computeChartMargins } from './reference_lines'; import { visualizationDefinitions } from '../definitions'; import { CommonXYLayerConfig } from '../../common/types'; import { SplitChart } from './split_chart'; @@ -270,6 +280,7 @@ export function XYChart({ }; const referenceLineLayers = getReferenceLayers(layers); + const annotationsLayers = getAnnotationsLayers(layers); const firstTable = dataLayers[0]?.table; @@ -286,7 +297,9 @@ export function XYChart({ const rangeAnnotations = getRangeAnnotations(annotationsLayers); const visualConfigs = [ - ...referenceLineLayers.flatMap(({ yConfig }) => yConfig), + ...referenceLineLayers.flatMap( + ({ yConfig }) => yConfig + ), ...groupedLineAnnotations, ].filter(Boolean); @@ -364,9 +377,10 @@ export function XYChart({ l.yConfig ? l.yConfig.map((yConfig) => ({ layerId: l.layerId, yConfig })) : [] ) .filter(({ yConfig }) => yConfig.axisMode === axis.groupId) - .map( - ({ layerId, yConfig }) => - `${layerId}-${yConfig.forAccessor}-${yConfig.fill !== 'none' ? 'rect' : 'line'}` + .map(({ layerId, yConfig }) => + isReferenceLineYConfig(yConfig) + ? `${layerId}-${yConfig.value}-${yConfig.fill !== 'none' ? 'rect' : 'line'}` + : `${layerId}-${yConfig.forAccessor}-${yConfig.fill !== 'none' ? 'rect' : 'line'}` ), }; }; @@ -668,7 +682,7 @@ export function XYChart({ /> )} {referenceLineLayers.length ? ( - ( - (layer): layer is CommonXYReferenceLineLayerConfig | CommonXYDataLayerConfig => { + return layers.filter( + (layer): layer is ReferenceLineLayerConfig | CommonXYDataLayerConfig => { let table: Datatable | undefined; let accessors: Array = []; let xAccessor: undefined | string | number; diff --git a/src/plugins/chart_expressions/expression_xy/public/helpers/state.ts b/src/plugins/chart_expressions/expression_xy/public/helpers/state.ts index e2f95491dbce8..900cba4784853 100644 --- a/src/plugins/chart_expressions/expression_xy/public/helpers/state.ts +++ b/src/plugins/chart_expressions/expression_xy/public/helpers/state.ts @@ -7,7 +7,7 @@ */ import type { CommonXYLayerConfig, SeriesType, ExtendedYConfig, YConfig } from '../../common'; -import { getDataLayers, isAnnotationsLayer, isDataLayer } from './visualization'; +import { getDataLayers, isAnnotationsLayer, isDataLayer, isReferenceLine } from './visualization'; export function isHorizontalSeries(seriesType: SeriesType) { return ( @@ -26,7 +26,11 @@ export function isHorizontalChart(layers: CommonXYLayerConfig[]) { } export const getSeriesColor = (layer: CommonXYLayerConfig, accessor: string) => { - if ((isDataLayer(layer) && layer.splitAccessor) || isAnnotationsLayer(layer)) { + if ( + (isDataLayer(layer) && layer.splitAccessor) || + isAnnotationsLayer(layer) || + isReferenceLine(layer) + ) { return null; } const yConfig: Array | undefined = layer?.yConfig; diff --git a/src/plugins/chart_expressions/expression_xy/public/helpers/visualization.ts b/src/plugins/chart_expressions/expression_xy/public/helpers/visualization.ts index db0b431d56fac..480fa5374238e 100644 --- a/src/plugins/chart_expressions/expression_xy/public/helpers/visualization.ts +++ b/src/plugins/chart_expressions/expression_xy/public/helpers/visualization.ts @@ -6,12 +6,21 @@ * Side Public License, v 1. */ -import { LayerTypes } from '../../common/constants'; +import { + LayerTypes, + REFERENCE_LINE, + REFERENCE_LINE_LAYER, + REFERENCE_LINE_Y_CONFIG, +} from '../../common/constants'; import { CommonXYLayerConfig, CommonXYDataLayerConfig, CommonXYReferenceLineLayerConfig, CommonXYAnnotationLayerConfig, + ReferenceLineLayerConfig, + ReferenceLineConfig, + ExtendedYConfigResult, + ReferenceLineYConfig, } from '../../common/types'; export const isDataLayer = (layer: CommonXYLayerConfig): layer is CommonXYDataLayerConfig => @@ -20,13 +29,24 @@ export const isDataLayer = (layer: CommonXYLayerConfig): layer is CommonXYDataLa export const getDataLayers = (layers: CommonXYLayerConfig[]) => (layers || []).filter((layer): layer is CommonXYDataLayerConfig => isDataLayer(layer)); -export const isReferenceLayer = ( +export const isReferenceLayer = (layer: CommonXYLayerConfig): layer is ReferenceLineLayerConfig => + layer.layerType === LayerTypes.REFERENCELINE && layer.type === REFERENCE_LINE_LAYER; + +export const isReferenceLine = (layer: CommonXYLayerConfig): layer is ReferenceLineConfig => + layer.type === REFERENCE_LINE; + +export const isReferenceLineYConfig = ( + yConfig: ReferenceLineYConfig | ExtendedYConfigResult +): yConfig is ReferenceLineYConfig => yConfig.type === REFERENCE_LINE_Y_CONFIG; + +export const isReferenceLineOrLayer = ( layer: CommonXYLayerConfig ): layer is CommonXYReferenceLineLayerConfig => layer.layerType === LayerTypes.REFERENCELINE; export const getReferenceLayers = (layers: CommonXYLayerConfig[]) => - (layers || []).filter((layer): layer is CommonXYReferenceLineLayerConfig => - isReferenceLayer(layer) + (layers || []).filter( + (layer): layer is CommonXYReferenceLineLayerConfig => + isReferenceLayer(layer) || isReferenceLine(layer) ); const isAnnotationLayerCommon = ( diff --git a/src/plugins/chart_expressions/expression_xy/public/plugin.ts b/src/plugins/chart_expressions/expression_xy/public/plugin.ts index 5c27da6b82b28..0dc6f62df3183 100755 --- a/src/plugins/chart_expressions/expression_xy/public/plugin.ts +++ b/src/plugins/chart_expressions/expression_xy/public/plugin.ts @@ -24,8 +24,8 @@ import { gridlinesConfigFunction, axisExtentConfigFunction, tickLabelsConfigFunction, + referenceLineFunction, referenceLineLayerFunction, - extendedReferenceLineLayerFunction, annotationLayerFunction, labelsOrientationConfigFunction, axisTitlesVisibilityConfigFunction, @@ -64,8 +64,8 @@ export class ExpressionXyPlugin { expressions.registerFunction(annotationLayerFunction); expressions.registerFunction(extendedAnnotationLayerFunction); expressions.registerFunction(labelsOrientationConfigFunction); + expressions.registerFunction(referenceLineFunction); expressions.registerFunction(referenceLineLayerFunction); - expressions.registerFunction(extendedReferenceLineLayerFunction); expressions.registerFunction(axisTitlesVisibilityConfigFunction); expressions.registerFunction(xyVisFunction); expressions.registerFunction(layeredXyVisFunction); diff --git a/src/plugins/chart_expressions/expression_xy/server/plugin.ts b/src/plugins/chart_expressions/expression_xy/server/plugin.ts index cefde5d38a5f4..4ddac2b3a3f79 100755 --- a/src/plugins/chart_expressions/expression_xy/server/plugin.ts +++ b/src/plugins/chart_expressions/expression_xy/server/plugin.ts @@ -19,10 +19,10 @@ import { tickLabelsConfigFunction, annotationLayerFunction, labelsOrientationConfigFunction, - referenceLineLayerFunction, + referenceLineFunction, axisTitlesVisibilityConfigFunction, extendedDataLayerFunction, - extendedReferenceLineLayerFunction, + referenceLineLayerFunction, layeredXyVisFunction, extendedAnnotationLayerFunction, } from '../common/expression_functions'; @@ -42,8 +42,8 @@ export class ExpressionXyPlugin expressions.registerFunction(annotationLayerFunction); expressions.registerFunction(extendedAnnotationLayerFunction); expressions.registerFunction(labelsOrientationConfigFunction); + expressions.registerFunction(referenceLineFunction); expressions.registerFunction(referenceLineLayerFunction); - expressions.registerFunction(extendedReferenceLineLayerFunction); expressions.registerFunction(axisTitlesVisibilityConfigFunction); expressions.registerFunction(xyVisFunction); expressions.registerFunction(layeredXyVisFunction); 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 cb6e6cff2d70e..ff5a692a76e96 100644 --- a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts +++ b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts @@ -356,7 +356,7 @@ const referenceLineLayerToExpression = ( chain: [ { type: 'function', - function: 'extendedReferenceLineLayer', + function: 'referenceLineLayer', arguments: { layerId: [layer.layerId], yConfig: layer.yConfig