From 8353a7e160ef6030a6b213924d24d63ea89220b8 Mon Sep 17 00:00:00 2001 From: Navarone Feekery <13634519+navarone-feekery@users.noreply.github.com> Date: Fri, 29 Sep 2023 14:05:22 +0200 Subject: [PATCH 01/15] [Search] Add extraction service RCF to native connectors (#167527) ## Summary Add the `use_text_extraction_service` rich configurable field to some native connectors. Native connectors will not have access to the feature, but the field is required in case the user converts their native connector to a self-managed connector. --- .../types/native_connectors.ts | 107 +++++++++++++++--- 1 file changed, 93 insertions(+), 14 deletions(-) diff --git a/packages/kbn-search-connectors/types/native_connectors.ts b/packages/kbn-search-connectors/types/native_connectors.ts index 0e767b4bdd345..1c4b0bb7e1694 100644 --- a/packages/kbn-search-connectors/types/native_connectors.ts +++ b/packages/kbn-search-connectors/types/native_connectors.ts @@ -50,6 +50,22 @@ const MAX_CONCURRENT_DOWNLOADS_LABEL = i18n.translate( } ); +const USE_TEXT_EXTRACTION_SERVICE_LABEL = i18n.translate( + 'searchConnectors.nativeConnectors.textExtractionService.label', + { + defaultMessage: 'Use text extraction service', + } +); + +const USE_TEXT_EXTRACTION_SERVICE_TOOLTIP = i18n.translate( + 'searchConnectors.nativeConnectors.textExtractionService.tooltip', + { + defaultMessage: + 'Requires a separate deployment of the Elastic Data Extraction Service. ' + + 'Also requires that pipeline settings disable text extraction.', + } +); + const DATABASE_LABEL = i18n.translate('searchConnectors.nativeConnectors.databaseLabel', { defaultMessage: 'Database', }); @@ -157,6 +173,21 @@ export const NATIVE_CONNECTOR_DEFINITIONS: Record Date: Fri, 29 Sep 2023 08:05:35 -0400 Subject: [PATCH 02/15] chore(slo): Improve form field selectors (#167564) --- .../common/index_field_selector.tsx | 5 +- .../custom_metric/custom_metric_type_form.tsx | 9 +- .../custom_metric/metric_indicator.tsx | 98 +++++++------ .../histogram/histogram_indicator.tsx | 131 ++++++++++-------- .../histogram_indicator_type_form.tsx | 5 +- 5 files changed, 141 insertions(+), 107 deletions(-) diff --git a/x-pack/plugins/observability/public/pages/slo_edit/components/common/index_field_selector.tsx b/x-pack/plugins/observability/public/pages/slo_edit/components/common/index_field_selector.tsx index 6bb6996f95b2a..ef9121e871bbd 100644 --- a/x-pack/plugins/observability/public/pages/slo_edit/components/common/index_field_selector.tsx +++ b/x-pack/plugins/observability/public/pages/slo_edit/components/common/index_field_selector.tsx @@ -6,7 +6,6 @@ */ import { EuiComboBox, EuiComboBoxOptionOption, EuiFlexItem, EuiFormRow } from '@elastic/eui'; -import { ALL_VALUE } from '@kbn/slo-schema'; import React, { useEffect, useState } from 'react'; import { Controller, useFormContext } from 'react-hook-form'; import { Field } from '../../../../hooks/slo/use_fetch_index_pattern_fields'; @@ -42,7 +41,7 @@ export function IndexFieldSelector({ { diff --git a/x-pack/plugins/observability/public/pages/slo_edit/components/custom_metric/custom_metric_type_form.tsx b/x-pack/plugins/observability/public/pages/slo_edit/components/custom_metric/custom_metric_type_form.tsx index c7fe7999f9be4..45870dcd68bd0 100644 --- a/x-pack/plugins/observability/public/pages/slo_edit/components/custom_metric/custom_metric_type_form.tsx +++ b/x-pack/plugins/observability/public/pages/slo_edit/components/custom_metric/custom_metric_type_form.tsx @@ -27,6 +27,8 @@ import { MetricIndicator } from './metric_indicator'; export { NEW_CUSTOM_METRIC } from './metric_indicator'; +const SUPPORTED_METRIC_FIELD_TYPES = ['number', 'histogram']; + export function CustomMetricIndicatorTypeForm() { const { watch } = useFormContext(); const index = watch('indicator.params.index'); @@ -34,6 +36,9 @@ export function CustomMetricIndicatorTypeForm() { useFetchIndexPatternFields(index); const timestampFields = indexFields.filter((field) => field.type === 'date'); const partitionByFields = indexFields.filter((field) => field.aggregatable); + const metricFields = indexFields.filter((field) => + SUPPORTED_METRIC_FIELD_TYPES.includes(field.type) + ); return ( <> @@ -115,7 +120,7 @@ export function CustomMetricIndicatorTypeForm() { @@ -136,7 +141,7 @@ export function CustomMetricIndicatorTypeForm() { diff --git a/x-pack/plugins/observability/public/pages/slo_edit/components/custom_metric/metric_indicator.tsx b/x-pack/plugins/observability/public/pages/slo_edit/components/custom_metric/metric_indicator.tsx index a333a353f97d9..0c1fbce49d64e 100644 --- a/x-pack/plugins/observability/public/pages/slo_edit/components/custom_metric/metric_indicator.tsx +++ b/x-pack/plugins/observability/public/pages/slo_edit/components/custom_metric/metric_indicator.tsx @@ -19,16 +19,16 @@ import { import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { first, range, xor } from 'lodash'; -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { Controller, useFieldArray, useFormContext } from 'react-hook-form'; import { Field } from '../../../../hooks/slo/use_fetch_index_pattern_fields'; -import { createOptionsFromFields } from '../../helpers/create_options'; +import { createOptionsFromFields, Option } from '../../helpers/create_options'; import { CreateSLOForm } from '../../types'; import { QueryBuilder } from '../common/query_builder'; interface MetricIndicatorProps { type: 'good' | 'total'; - indexFields: Field[]; + metricFields: Field[]; isLoadingIndex: boolean; } @@ -47,51 +47,52 @@ function createEquationFromMetric(names: string[]) { return names.join(' + '); } -const SUPPORTED_FIELD_TYPES = ['number', 'histogram']; +const metricLabel = i18n.translate( + 'xpack.observability.slo.sloEdit.sliType.customMetric.metricLabel', + { defaultMessage: 'Metric' } +); -export function MetricIndicator({ type, indexFields, isLoadingIndex }: MetricIndicatorProps) { - const metricLabel = i18n.translate( - 'xpack.observability.slo.sloEdit.sliType.customMetric.metricLabel', - { defaultMessage: 'Metric' } - ); +const filterLabel = i18n.translate( + 'xpack.observability.slo.sloEdit.sliType.customMetric.filterLabel', + { defaultMessage: 'Filter' } +); - const filterLabel = i18n.translate( - 'xpack.observability.slo.sloEdit.sliType.customMetric.filterLabel', - { defaultMessage: 'Filter' } - ); +const metricTooltip = ( + +); - const metricTooltip = ( - - ); +const equationLabel = i18n.translate( + 'xpack.observability.slo.sloEdit.sliType.customMetric.equationLabel', + { defaultMessage: 'Equation' } +); - const equationLabel = i18n.translate( - 'xpack.observability.slo.sloEdit.sliType.customMetric.equationLabel', - { defaultMessage: 'Equation' } - ); +const equationTooltip = ( + +); - const equationTooltip = ( - - ); +export function MetricIndicator({ type, metricFields, isLoadingIndex }: MetricIndicatorProps) { + const { control, watch, setValue, register, getFieldState } = useFormContext(); + const [options, setOptions] = useState(createOptionsFromFields(metricFields)); - const { control, watch, setValue, register } = useFormContext(); - const metricFields = indexFields.filter((field) => SUPPORTED_FIELD_TYPES.includes(field.type)); + useEffect(() => { + setOptions(createOptionsFromFields(metricFields)); + }, [metricFields]); const { fields, append, remove } = useFieldArray({ control, @@ -134,6 +135,7 @@ export function MetricIndicator({ type, indexFields, isLoadingIndex }: MetricInd {metricLabel} {metric.name} {metricTooltip} @@ -163,8 +165,9 @@ export function MetricIndicator({ type, indexFields, isLoadingIndex }: MetricInd 'xpack.observability.slo.sloEdit.sliType.customMetric.metricField.placeholder', { defaultMessage: 'Select a metric field' } )} + isClearable isInvalid={fieldState.invalid} - isDisabled={!indexPattern} + isDisabled={isLoadingIndex || !indexPattern} isLoading={!!indexPattern && isLoadingIndex} onChange={(selected: EuiComboBoxOptionOption[]) => { if (selected.length) { @@ -184,7 +187,14 @@ export function MetricIndicator({ type, indexFields, isLoadingIndex }: MetricInd ] : [] } - options={createOptionsFromFields(metricFields)} + onSearchChange={(searchValue: string) => { + setOptions( + createOptionsFromFields(metricFields, ({ value }) => + value.includes(searchValue) + ) + ); + }} + options={options} /> )} /> diff --git a/x-pack/plugins/observability/public/pages/slo_edit/components/histogram/histogram_indicator.tsx b/x-pack/plugins/observability/public/pages/slo_edit/components/histogram/histogram_indicator.tsx index 2b6f983470f82..1362674828d74 100644 --- a/x-pack/plugins/observability/public/pages/slo_edit/components/histogram/histogram_indicator.tsx +++ b/x-pack/plugins/observability/public/pages/slo_edit/components/histogram/histogram_indicator.tsx @@ -5,9 +5,6 @@ * 2.0. */ -import React, { Fragment } from 'react'; -import { Controller, useFormContext } from 'react-hook-form'; -import { i18n } from '@kbn/i18n'; import { EuiComboBox, EuiComboBoxOptionOption, @@ -18,14 +15,17 @@ import { EuiIconTip, EuiSpacer, } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { Fragment, useEffect, useState } from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; +import { Field } from '../../../../hooks/slo/use_fetch_index_pattern_fields'; +import { createOptionsFromFields, Option } from '../../helpers/create_options'; import { CreateSLOForm } from '../../types'; import { QueryBuilder } from '../common/query_builder'; -import { Field } from '../../../../hooks/slo/use_fetch_index_pattern_fields'; -import { createOptionsFromFields } from '../../helpers/create_options'; interface HistogramIndicatorProps { type: 'good' | 'total'; - indexFields: Field[]; + histogramFields: Field[]; isLoadingIndex: boolean; } @@ -46,61 +46,69 @@ const AGGREGATIONS = { const AGGREGATION_OPTIONS = Object.values(AGGREGATIONS); -export function HistogramIndicator({ type, indexFields, isLoadingIndex }: HistogramIndicatorProps) { - const { control, watch } = useFormContext(); +const aggregationTooltip = ( + +); - const histogramFields = indexFields.filter((field) => field.type === 'histogram'); - const indexPattern = watch('indicator.params.index'); - const aggregation = watch(`indicator.params.${type}.aggregation`); +const fromTooltip = ( + +); - const aggregationTooltip = ( - - ); +const toTooltip = ( + +); - const fromTooltip = ( - - ); +const aggregationLabel = i18n.translate( + 'xpack.observability.slo.sloEdit.sliType.histogram.aggregationLabel', + { defaultMessage: 'Aggregation' } +); - const toTooltip = ( - - ); +const metricLabel = i18n.translate( + 'xpack.observability.slo.sloEdit.sliType.histogram.metricLabel', + { defaultMessage: 'Field' } +); - const aggregationLabel = i18n.translate( - 'xpack.observability.slo.sloEdit.sliType.histogram.aggregationLabel', - { defaultMessage: 'Aggregation' } - ); +const toLabel = i18n.translate('xpack.observability.slo.sloEdit.sliType.histogram.toLabel', { + defaultMessage: 'To', +}); - const metricLabel = i18n.translate( - 'xpack.observability.slo.sloEdit.sliType.histogram.metricLabel', - { defaultMessage: 'Field' } - ); +const fromLabel = i18n.translate('xpack.observability.slo.sloEdit.sliType.histogram.fromLabel', { + defaultMessage: 'From', +}); + +export function HistogramIndicator({ + type, + histogramFields, + isLoadingIndex, +}: HistogramIndicatorProps) { + const { control, watch, getFieldState } = useFormContext(); + const [options, setOptions] = useState(createOptionsFromFields(histogramFields)); - const toLabel = i18n.translate('xpack.observability.slo.sloEdit.sliType.histogram.toLabel', { - defaultMessage: 'To', - }); + useEffect(() => { + setOptions(createOptionsFromFields(histogramFields)); + }, [histogramFields]); - const fromLabel = i18n.translate('xpack.observability.slo.sloEdit.sliType.histogram.fromLabel', { - defaultMessage: 'From', - }); + const indexPattern = watch('indicator.params.index'); + const aggregation = watch(`indicator.params.${type}.aggregation`); return ( @@ -149,7 +157,11 @@ export function HistogramIndicator({ type, indexFields, isLoadingIndex }: Histog - {metricLabel}}> + {metricLabel}} + > { if (selected.length) { @@ -190,7 +202,14 @@ export function HistogramIndicator({ type, indexFields, isLoadingIndex }: Histog ] : [] } - options={createOptionsFromFields(histogramFields)} + onSearchChange={(searchValue: string) => { + setOptions( + createOptionsFromFields(histogramFields, ({ value }) => + value.includes(searchValue) + ) + ); + }} + options={options} /> )} /> diff --git a/x-pack/plugins/observability/public/pages/slo_edit/components/histogram/histogram_indicator_type_form.tsx b/x-pack/plugins/observability/public/pages/slo_edit/components/histogram/histogram_indicator_type_form.tsx index dfbf305235b41..eb18e0a363628 100644 --- a/x-pack/plugins/observability/public/pages/slo_edit/components/histogram/histogram_indicator_type_form.tsx +++ b/x-pack/plugins/observability/public/pages/slo_edit/components/histogram/histogram_indicator_type_form.tsx @@ -31,6 +31,7 @@ export function HistogramIndicatorTypeForm() { const { isLoading: isIndexFieldsLoading, data: indexFields = [] } = useFetchIndexPatternFields(index); + const histogramFields = indexFields.filter((field) => field.type === 'histogram'); const timestampFields = indexFields.filter((field) => field.type === 'date'); const partitionByFields = indexFields.filter((field) => field.aggregatable); @@ -109,7 +110,7 @@ export function HistogramIndicatorTypeForm() { @@ -128,7 +129,7 @@ export function HistogramIndicatorTypeForm() { From 706f6fa7a02253fdbfe9bba195d4703a4398b0b9 Mon Sep 17 00:00:00 2001 From: Jonathan Budzenski Date: Thu, 28 Sep 2023 20:13:10 -0500 Subject: [PATCH 03/15] Revert "[AO][SERVERLESS] Fix Custom Threshold rule tests for Serverless (#166942)" This reverts commit 4c1ca7e92fbc387279d0d41fd1238e790ce53518. --- .../composable/component/base.json | 25 -- .../composable/component/event.json | 24 -- .../composable/component/host.json | 189 --------------- .../composable/component/metricset.json | 18 -- .../composable/component/system.json | 69 ------ .../src/data_sources/composable/template.json | 52 ----- .../src/data_sources/fake_hosts/index.ts | 2 +- .../fake_hosts/index_template_def.ts | 38 --- .../src/data_sources/fake_hosts/template.ts | 218 ++++++++++++++++++ .../src/lib/install_template.ts | 28 +++ .../src/lib/manage_template.ts | 59 ----- .../packages/kbn-infra-forge/src/lib/queue.ts | 4 +- x-pack/packages/kbn-infra-forge/src/run.ts | 7 +- .../custom_threshold_rule/avg_pct_fired.ts | 7 +- .../custom_eq_avg_bytes_fired.ts | 7 +- .../documents_count_fired.ts | 7 +- .../custom_threshold_rule/group_by_fired.ts | 7 +- .../observability/metric_threshold_rule.ts | 6 - .../custom_threshold_rule/avg_pct_fired.ts | 23 +- .../custom_threshold_rule/avg_pct_no_data.ts | 10 +- .../custom_eq_avg_bytes_fired.ts | 14 +- .../documents_count_fired.ts | 16 +- .../custom_threshold_rule/group_by_fired.ts | 19 +- 23 files changed, 296 insertions(+), 553 deletions(-) delete mode 100644 x-pack/packages/kbn-infra-forge/src/data_sources/composable/component/base.json delete mode 100644 x-pack/packages/kbn-infra-forge/src/data_sources/composable/component/event.json delete mode 100644 x-pack/packages/kbn-infra-forge/src/data_sources/composable/component/host.json delete mode 100644 x-pack/packages/kbn-infra-forge/src/data_sources/composable/component/metricset.json delete mode 100644 x-pack/packages/kbn-infra-forge/src/data_sources/composable/component/system.json delete mode 100644 x-pack/packages/kbn-infra-forge/src/data_sources/composable/template.json delete mode 100644 x-pack/packages/kbn-infra-forge/src/data_sources/fake_hosts/index_template_def.ts create mode 100644 x-pack/packages/kbn-infra-forge/src/data_sources/fake_hosts/template.ts create mode 100644 x-pack/packages/kbn-infra-forge/src/lib/install_template.ts delete mode 100644 x-pack/packages/kbn-infra-forge/src/lib/manage_template.ts diff --git a/x-pack/packages/kbn-infra-forge/src/data_sources/composable/component/base.json b/x-pack/packages/kbn-infra-forge/src/data_sources/composable/component/base.json deleted file mode 100644 index cebc9c2971745..0000000000000 --- a/x-pack/packages/kbn-infra-forge/src/data_sources/composable/component/base.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "_meta": { - "documentation": "https://www.elastic.co/guide/en/ecs/current/ecs-base.html", - "ecs_version": "8.0.0" - }, - "template": { - "mappings": { - "properties": { - "@timestamp": { - "type": "date" - }, - "labels": { - "type": "object" - }, - "message": { - "type": "match_only_text" - }, - "tags": { - "ignore_above": 1024, - "type": "keyword" - } - } - } - } -} diff --git a/x-pack/packages/kbn-infra-forge/src/data_sources/composable/component/event.json b/x-pack/packages/kbn-infra-forge/src/data_sources/composable/component/event.json deleted file mode 100644 index f235248f3ee83..0000000000000 --- a/x-pack/packages/kbn-infra-forge/src/data_sources/composable/component/event.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "_meta": { - "documentation": "https://www.elastic.co/guide/en/ecs/current/ecs-event.html", - "ecs_version": "8.0.0" - }, - "template": { - "mappings": { - "properties": { - "event": { - "properties": { - "dataset": { - "ignore_above": 1024, - "type": "keyword" - }, - "module": { - "ignore_above": 1024, - "type": "keyword" - } - } - } - } - } - } -} diff --git a/x-pack/packages/kbn-infra-forge/src/data_sources/composable/component/host.json b/x-pack/packages/kbn-infra-forge/src/data_sources/composable/component/host.json deleted file mode 100644 index cc370a5270eba..0000000000000 --- a/x-pack/packages/kbn-infra-forge/src/data_sources/composable/component/host.json +++ /dev/null @@ -1,189 +0,0 @@ -{ - "_meta": { - "documentation": "https://www.elastic.co/guide/en/ecs/current/ecs-host.html", - "ecs_version": "8.0.0" - }, - "template": { - "mappings": { - "properties": { - "host": { - "properties": { - "architecture": { - "ignore_above": 1024, - "type": "keyword" - }, - "cpu": { - "properties": { - "usage": { - "scaling_factor": 1000, - "type": "scaled_float" - } - } - }, - "disk": { - "properties": { - "read": { - "properties": { - "bytes": { - "type": "long" - } - } - }, - "write": { - "properties": { - "bytes": { - "type": "long" - } - } - } - } - }, - "domain": { - "ignore_above": 1024, - "type": "keyword" - }, - "geo": { - "properties": { - "city_name": { - "ignore_above": 1024, - "type": "keyword" - }, - "continent_code": { - "ignore_above": 1024, - "type": "keyword" - }, - "continent_name": { - "ignore_above": 1024, - "type": "keyword" - }, - "country_iso_code": { - "ignore_above": 1024, - "type": "keyword" - }, - "country_name": { - "ignore_above": 1024, - "type": "keyword" - }, - "location": { - "type": "geo_point" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "postal_code": { - "ignore_above": 1024, - "type": "keyword" - }, - "region_iso_code": { - "ignore_above": 1024, - "type": "keyword" - }, - "region_name": { - "ignore_above": 1024, - "type": "keyword" - }, - "timezone": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "hostname": { - "ignore_above": 1024, - "type": "keyword" - }, - "id": { - "ignore_above": 1024, - "type": "keyword" - }, - "ip": { - "type": "ip" - }, - "mac": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "network": { - "properties": { - "egress": { - "properties": { - "bytes": { - "type": "long" - }, - "packets": { - "type": "long" - } - } - }, - "ingress": { - "properties": { - "bytes": { - "type": "long" - }, - "packets": { - "type": "long" - } - } - } - } - }, - "os": { - "properties": { - "family": { - "ignore_above": 1024, - "type": "keyword" - }, - "full": { - "fields": { - "text": { - "type": "match_only_text" - } - }, - "ignore_above": 1024, - "type": "keyword" - }, - "kernel": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "fields": { - "text": { - "type": "match_only_text" - } - }, - "ignore_above": 1024, - "type": "keyword" - }, - "platform": { - "ignore_above": 1024, - "type": "keyword" - }, - "type": { - "ignore_above": 1024, - "type": "keyword" - }, - "version": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "type": { - "ignore_above": 1024, - "type": "keyword" - }, - "uptime": { - "type": "long" - } - } - } - } - } - } -} diff --git a/x-pack/packages/kbn-infra-forge/src/data_sources/composable/component/metricset.json b/x-pack/packages/kbn-infra-forge/src/data_sources/composable/component/metricset.json deleted file mode 100644 index 06ef8b57bf4b3..0000000000000 --- a/x-pack/packages/kbn-infra-forge/src/data_sources/composable/component/metricset.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "_meta": { - "ecs_version": "8.0.0" - }, - "template": { - "mappings": { - "properties": { - "metricset": { - "properties": { - "interval": { - "type": "long" - } - } - } - } - } - } -} diff --git a/x-pack/packages/kbn-infra-forge/src/data_sources/composable/component/system.json b/x-pack/packages/kbn-infra-forge/src/data_sources/composable/component/system.json deleted file mode 100644 index 46335a6da442c..0000000000000 --- a/x-pack/packages/kbn-infra-forge/src/data_sources/composable/component/system.json +++ /dev/null @@ -1,69 +0,0 @@ -{ - "_meta": { - "ecs_version": "8.0.0" - }, - "template": { - "mappings": { - "properties": { - "system": { - "properties": { - "cpu": { - "properties": { - "cores": { - "type": "integer" - }, - "system": { - "properties": { - "pct": { - "type": "float" - } - } - }, - "total": { - "properties": { - "norm": { - "properties": { - "pct": { - "type": "float" - } - } - } - } - }, - "user": { - "properties": { - "pct": { - "type": "float" - } - } - } - } - }, - "network": { - "properties": { - "in": { - "properties": { - "bytes": { - "type": "long" - } - } - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "out": { - "properties": { - "bytes": { - "type": "long" - } - } - } - } - } - } - } - } - } - } -} diff --git a/x-pack/packages/kbn-infra-forge/src/data_sources/composable/template.json b/x-pack/packages/kbn-infra-forge/src/data_sources/composable/template.json deleted file mode 100644 index 8064e7866e5f6..0000000000000 --- a/x-pack/packages/kbn-infra-forge/src/data_sources/composable/template.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "_meta": { - "description": "Sample composable template that includes all ECS fields", - "ecs_version": "8.0.0" - }, - "composed_of": [ - "ecs_8.0.0_base", - "ecs_8.0.0_event", - "ecs_8.0.0_host", - "ecs_8.0.0_metricset", - "ecs_8.0.0_system" - ], - "index_patterns": [ - "kbn-data-forge-fake_hosts" - ], - "priority": 1, - "template": { - "mappings": { - "_meta": { - "version": "1.6.0" - }, - "date_detection": false, - "dynamic_templates": [ - { - "strings_as_keyword": { - "mapping": { - "fields": { - "text": { - "norms": false, - "type": "text" - } - }, - "ignore_above": 1024, - "type": "keyword" - }, - "match_mapping_type": "string" - } - } - ] - }, - "settings": { - "index": { - "codec": "best_compression", - "mapping": { - "total_fields": { - "limit": 2000 - } - } - } - } - } -} diff --git a/x-pack/packages/kbn-infra-forge/src/data_sources/fake_hosts/index.ts b/x-pack/packages/kbn-infra-forge/src/data_sources/fake_hosts/index.ts index fe969726499b4..f6b685077ec6a 100644 --- a/x-pack/packages/kbn-infra-forge/src/data_sources/fake_hosts/index.ts +++ b/x-pack/packages/kbn-infra-forge/src/data_sources/fake_hosts/index.ts @@ -8,7 +8,7 @@ import lodash from 'lodash'; import type { Moment } from 'moment'; -export { indexTemplate } from './index_template_def'; +export { template } from './template'; const createGroupIndex = (index: number) => Math.floor(index / 1000) * 1000; diff --git a/x-pack/packages/kbn-infra-forge/src/data_sources/fake_hosts/index_template_def.ts b/x-pack/packages/kbn-infra-forge/src/data_sources/fake_hosts/index_template_def.ts deleted file mode 100644 index ab3876edabf5a..0000000000000 --- a/x-pack/packages/kbn-infra-forge/src/data_sources/fake_hosts/index_template_def.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 * as rt from 'io-ts'; -import base from '../composable/component/base.json'; -import event from '../composable/component/event.json'; -import host from '../composable/component/host.json'; -import metricset from '../composable/component/metricset.json'; -import system from '../composable/component/system.json'; - -import template from '../composable/template.json'; -const IndexTemplateDefRT = rt.type({ - namespace: rt.string, - template: rt.UnknownRecord, - components: rt.array(rt.type({ name: rt.string, template: rt.UnknownRecord })), -}); - -export type IndexTemplateDef = rt.TypeOf; - -const ECS_VERSION = template._meta.ecs_version; - -const components = [ - { name: `fake_hosts_${ECS_VERSION}_base`, template: base }, - { name: `fake_hosts_${ECS_VERSION}_event`, template: event }, - { name: `fake_hosts_${ECS_VERSION}_host`, template: host }, - { name: `fake_hosts_${ECS_VERSION}_metricset`, template: metricset }, - { name: `fake_hosts_${ECS_VERSION}_system`, template: system }, -]; - -export const indexTemplate: IndexTemplateDef = { - namespace: 'fake_hosts', - template: { ...template, composed_of: components.map(({ name }) => name) }, - components, -}; diff --git a/x-pack/packages/kbn-infra-forge/src/data_sources/fake_hosts/template.ts b/x-pack/packages/kbn-infra-forge/src/data_sources/fake_hosts/template.ts new file mode 100644 index 0000000000000..145002606a789 --- /dev/null +++ b/x-pack/packages/kbn-infra-forge/src/data_sources/fake_hosts/template.ts @@ -0,0 +1,218 @@ +/* + * 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 const template = { + order: 1, + index_patterns: ['kbn-data-forge*'], + settings: { + index: { + mapping: { + total_fields: { + limit: '10000', + }, + }, + number_of_shards: '1', + number_of_replicas: '0', + query: { + default_field: ['message', 'labels.*', 'event.*'], + }, + }, + }, + mappings: { + dynamic_templates: [ + { + labels: { + path_match: 'labels.*', + mapping: { + type: 'keyword', + }, + match_mapping_type: 'string', + }, + }, + { + strings_as_keyword: { + mapping: { + ignore_above: 1024, + type: 'keyword', + }, + match_mapping_type: 'string', + }, + }, + ], + date_detection: false, + properties: { + '@timestamp': { + type: 'date', + }, + tags: { + type: 'keyword', + }, + metricset: { + properties: { + period: { + type: 'long', + }, + }, + }, + host: { + properties: { + name: { + type: 'keyword', + ignore_above: 256, + }, + network: { + properties: { + name: { + type: 'keyword', + ignore_above: 256, + }, + }, + }, + }, + }, + event: { + properties: { + dataset: { + type: 'keyword', + ignore_above: 256, + }, + module: { + type: 'keyword', + ignore_above: 256, + }, + }, + }, + system: { + properties: { + cpu: { + properties: { + cores: { + type: 'long', + }, + total: { + properties: { + norm: { + properties: { + pct: { + scaling_factor: 1000, + type: 'scaled_float', + }, + }, + }, + }, + }, + user: { + properties: { + pct: { + scaling_factor: 1000, + type: 'scaled_float', + }, + norm: { + properties: { + pct: { + scaling_factor: 1000, + type: 'scaled_float', + }, + }, + }, + }, + }, + system: { + properties: { + pct: { + scaling_factor: 1000, + type: 'scaled_float', + }, + }, + }, + }, + }, + network: { + properties: { + name: { + type: 'keyword', + ignore_above: 256, + }, + in: { + properties: { + bytes: { + type: 'long', + }, + }, + }, + out: { + properties: { + bytes: { + type: 'long', + }, + }, + }, + }, + }, + }, + }, + container: { + properties: { + id: { + type: 'keyword', + ignore_above: 256, + }, + name: { + type: 'keyword', + ignore_above: 256, + }, + cpu: { + properties: { + cores: { + type: 'long', + }, + total: { + properties: { + norm: { + properties: { + pct: { + scaling_factor: 1000, + type: 'scaled_float', + }, + }, + }, + }, + }, + user: { + properties: { + pct: { + scaling_factor: 1000, + type: 'scaled_float', + }, + norm: { + properties: { + pct: { + scaling_factor: 1000, + type: 'scaled_float', + }, + }, + }, + }, + }, + system: { + properties: { + pct: { + scaling_factor: 1000, + type: 'scaled_float', + }, + }, + }, + }, + }, + }, + }, + }, + }, + aliases: { + 'metrics-fake_hosts': {}, + }, +}; diff --git a/x-pack/packages/kbn-infra-forge/src/lib/install_template.ts b/x-pack/packages/kbn-infra-forge/src/lib/install_template.ts new file mode 100644 index 0000000000000..02e9f1cb8442d --- /dev/null +++ b/x-pack/packages/kbn-infra-forge/src/lib/install_template.ts @@ -0,0 +1,28 @@ +/* + * 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 { Client } from '@elastic/elasticsearch'; +import type { ToolingLog } from '@kbn/tooling-log'; + +export function installTemplate( + client: Client, + template: object, + namespace: string, + logger: ToolingLog +) { + logger.debug(`installTemplate > template name: kbn-data-forge-${namespace}`); + return client.indices + .putTemplate({ name: `kbn-data-forge-${namespace}`, body: template }) + .catch((error: any) => logger.error(`installTemplate > ${JSON.stringify(error)}`)); +} + +export function deleteTemplate(client: Client, namespace: string, logger: ToolingLog) { + logger.debug(`deleteTemplate > template name: kbn-data-forge-${namespace}`); + return client.indices + .deleteTemplate({ name: `kbn-data-forge-${namespace}` }) + .catch((error: any) => logger.error(`deleteTemplate > ${JSON.stringify(error)}`)); +} diff --git a/x-pack/packages/kbn-infra-forge/src/lib/manage_template.ts b/x-pack/packages/kbn-infra-forge/src/lib/manage_template.ts deleted file mode 100644 index 722d1cb0b8ac4..0000000000000 --- a/x-pack/packages/kbn-infra-forge/src/lib/manage_template.ts +++ /dev/null @@ -1,59 +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 { Client } from '@elastic/elasticsearch'; -import type { ToolingLog } from '@kbn/tooling-log'; -import { IndexTemplateDef } from '../data_sources/fake_hosts/index_template_def'; - -export async function installTemplate( - client: Client, - template: IndexTemplateDef, - namespace: string, - logger: ToolingLog -) { - logger.info(`Installing index templates (${namespace})`); - const componentNames = template.components.map(({ name }) => name); - logger.info(`Installing components for ${template.namespace} (${componentNames})`); - for (const component of template.components) { - await client.cluster - .putComponentTemplate({ - name: component.name, - ...component.template, - }) - .catch((error) => logger.error(`Failed installing component > ${JSON.stringify(error)}`)); - } - logger.info(`Installing index template (${template.namespace})`); - await client.indices - .putIndexTemplate({ - name: template.namespace, - ...template.template, - }) - .catch((error) => logger.error(`Failed installing template > ${JSON.stringify(error)}`)); -} - -export async function deleteTemplate( - client: Client, - template: IndexTemplateDef, - logger: ToolingLog -) { - logger.info(`deleteIndexTemplate > template name: ${template.namespace}`); - await client.indices - .deleteIndexTemplate({ - name: template.namespace, - }) - .catch((error: any) => - logger.error(`deleteIndexTemplate > ${template.namespace} ${JSON.stringify(error)}`) - ); - for (const component of template.components) { - logger.info(`deleteComponents > component name: ${component.name}`); - await client.cluster - .deleteComponentTemplate({ name: component.name }) - .catch((error: any) => - logger.error(`deleteComponents > ${component.name} ${JSON.stringify(error)}`) - ); - } -} diff --git a/x-pack/packages/kbn-infra-forge/src/lib/queue.ts b/x-pack/packages/kbn-infra-forge/src/lib/queue.ts index 75f3affb743d3..bf6553fe01b88 100644 --- a/x-pack/packages/kbn-infra-forge/src/lib/queue.ts +++ b/x-pack/packages/kbn-infra-forge/src/lib/queue.ts @@ -24,7 +24,7 @@ export const createQueue = ( logger.debug(`createQueue > index name: ${indexName}`); return async.cargoQueue( (docs: object[], callback) => { - const body: object[] = []; + const body: any[] = []; docs.forEach((doc) => { body.push({ create: { @@ -34,7 +34,7 @@ export const createQueue = ( body.push(omit(doc, 'namespace')); }); esClient - .bulk({ body, refresh: true }) + .bulk({ body }) .then((resp) => { if (resp.errors) { logger.error( diff --git a/x-pack/packages/kbn-infra-forge/src/run.ts b/x-pack/packages/kbn-infra-forge/src/run.ts index aad2adb24bc93..470c0afbdccc6 100644 --- a/x-pack/packages/kbn-infra-forge/src/run.ts +++ b/x-pack/packages/kbn-infra-forge/src/run.ts @@ -12,7 +12,7 @@ import type { Moment } from 'moment'; import type { ToolingLog } from '@kbn/tooling-log'; import type { Client } from '@elastic/elasticsearch'; import { createQueue, getIndexName } from './lib/queue'; -import { deleteTemplate, installTemplate } from './lib/manage_template'; +import { deleteTemplate, installTemplate } from './lib/install_template'; import * as fakeHosts from './data_sources/fake_hosts'; const generateEventsFns = { @@ -20,7 +20,7 @@ const generateEventsFns = { }; const templates = { - fake_hosts: fakeHosts.indexTemplate, + fake_hosts: fakeHosts.template, }; const EVENTS_PER_CYCLE = 1; @@ -68,6 +68,5 @@ export const generate = async ({ }; export const cleanup = async ({ esClient, logger }: { esClient: Client; logger: ToolingLog }) => { - const template = templates[DATASET]; - await deleteTemplate(esClient, template, logger); + await deleteTemplate(esClient, DATASET, logger); }; diff --git a/x-pack/test/alerting_api_integration/observability/custom_threshold_rule/avg_pct_fired.ts b/x-pack/test/alerting_api_integration/observability/custom_threshold_rule/avg_pct_fired.ts index c2d91677af2a2..fd5a4054f0895 100644 --- a/x-pack/test/alerting_api_integration/observability/custom_threshold_rule/avg_pct_fired.ts +++ b/x-pack/test/alerting_api_integration/observability/custom_threshold_rule/avg_pct_fired.ts @@ -28,9 +28,6 @@ export default function ({ getService }: FtrProviderContext) { describe('Custom Threshold rule - AVG - PCT - FIRED', () => { const CUSTOM_THRESHOLD_RULE_ALERT_INDEX = '.alerts-observability.threshold.alerts-default'; const ALERT_ACTION_INDEX = 'alert-action-threshold'; - // DATE_VIEW should match the index template: - // x-pack/packages/kbn-infra-forge/src/data_sources/composable/template.json - const DATE_VIEW = 'kbn-data-forge-fake_hosts'; const DATA_VIEW_ID = 'data-view-id'; let infraDataIndex: string; let actionId: string; @@ -40,9 +37,9 @@ export default function ({ getService }: FtrProviderContext) { infraDataIndex = await generate({ esClient, lookback: 'now-15m', logger }); await createDataView({ supertest, - name: DATE_VIEW, + name: 'metrics-fake_hosts', id: DATA_VIEW_ID, - title: DATE_VIEW, + title: 'metrics-fake_hosts', }); }); diff --git a/x-pack/test/alerting_api_integration/observability/custom_threshold_rule/custom_eq_avg_bytes_fired.ts b/x-pack/test/alerting_api_integration/observability/custom_threshold_rule/custom_eq_avg_bytes_fired.ts index d17e0a1568603..7f7e66d050593 100644 --- a/x-pack/test/alerting_api_integration/observability/custom_threshold_rule/custom_eq_avg_bytes_fired.ts +++ b/x-pack/test/alerting_api_integration/observability/custom_threshold_rule/custom_eq_avg_bytes_fired.ts @@ -34,9 +34,6 @@ export default function ({ getService }: FtrProviderContext) { describe('Custom Threshold rule - CUSTOM_EQ - AVG - BYTES - FIRED', () => { const CUSTOM_THRESHOLD_RULE_ALERT_INDEX = '.alerts-observability.threshold.alerts-default'; const ALERT_ACTION_INDEX = 'alert-action-threshold'; - // DATE_VIEW should match the index template: - // x-pack/packages/kbn-infra-forge/src/data_sources/composable/template.json - const DATE_VIEW = 'kbn-data-forge-fake_hosts'; const DATA_VIEW_ID = 'data-view-id'; let infraDataIndex: string; let actionId: string; @@ -46,9 +43,9 @@ export default function ({ getService }: FtrProviderContext) { infraDataIndex = await generate({ esClient, lookback: 'now-15m', logger }); await createDataView({ supertest, - name: DATE_VIEW, + name: 'metrics-fake_hosts', id: DATA_VIEW_ID, - title: DATE_VIEW, + title: 'metrics-fake_hosts', }); }); diff --git a/x-pack/test/alerting_api_integration/observability/custom_threshold_rule/documents_count_fired.ts b/x-pack/test/alerting_api_integration/observability/custom_threshold_rule/documents_count_fired.ts index 4f5a1077c22d1..6a17340094e80 100644 --- a/x-pack/test/alerting_api_integration/observability/custom_threshold_rule/documents_count_fired.ts +++ b/x-pack/test/alerting_api_integration/observability/custom_threshold_rule/documents_count_fired.ts @@ -28,9 +28,6 @@ export default function ({ getService }: FtrProviderContext) { describe('Custom Threshold rule - DOCUMENTS_COUNT - FIRED', () => { const CUSTOM_THRESHOLD_RULE_ALERT_INDEX = '.alerts-observability.threshold.alerts-default'; const ALERT_ACTION_INDEX = 'alert-action-threshold'; - // DATE_VIEW should match the index template: - // x-pack/packages/kbn-infra-forge/src/data_sources/composable/template.json - const DATE_VIEW = 'kbn-data-forge-fake_hosts'; const DATA_VIEW_ID = 'data-view-id'; let infraDataIndex: string; let actionId: string; @@ -40,9 +37,9 @@ export default function ({ getService }: FtrProviderContext) { infraDataIndex = await generate({ esClient, lookback: 'now-15m', logger }); await createDataView({ supertest, - name: DATE_VIEW, + name: 'metrics-fake_hosts', id: DATA_VIEW_ID, - title: DATE_VIEW, + title: 'metrics-fake_hosts', }); }); diff --git a/x-pack/test/alerting_api_integration/observability/custom_threshold_rule/group_by_fired.ts b/x-pack/test/alerting_api_integration/observability/custom_threshold_rule/group_by_fired.ts index 01386c9bd0250..da18b429c45c0 100644 --- a/x-pack/test/alerting_api_integration/observability/custom_threshold_rule/group_by_fired.ts +++ b/x-pack/test/alerting_api_integration/observability/custom_threshold_rule/group_by_fired.ts @@ -41,9 +41,6 @@ export default function ({ getService }: FtrProviderContext) { describe('Custom Threshold rule - GROUP_BY - FIRED', () => { const CUSTOM_THRESHOLD_RULE_ALERT_INDEX = '.alerts-observability.threshold.alerts-default'; const ALERT_ACTION_INDEX = 'alert-action-threshold'; - // DATE_VIEW should match the index template: - // x-pack/packages/kbn-infra-forge/src/data_sources/composable/template.json - const DATE_VIEW = 'kbn-data-forge-fake_hosts'; const DATA_VIEW_ID = 'data-view-id'; let infraDataIndex: string; let actionId: string; @@ -53,9 +50,9 @@ export default function ({ getService }: FtrProviderContext) { infraDataIndex = await generate({ esClient, lookback: 'now-15m', logger }); await createDataView({ supertest, - name: DATE_VIEW, + name: 'metrics-fake_hosts', id: DATA_VIEW_ID, - title: DATE_VIEW, + title: 'metrics-fake_hosts', }); }); diff --git a/x-pack/test/alerting_api_integration/observability/metric_threshold_rule.ts b/x-pack/test/alerting_api_integration/observability/metric_threshold_rule.ts index 0de7e3d600612..2b727820ade70 100644 --- a/x-pack/test/alerting_api_integration/observability/metric_threshold_rule.ts +++ b/x-pack/test/alerting_api_integration/observability/metric_threshold_rule.ts @@ -36,12 +36,6 @@ export default function ({ getService }: FtrProviderContext) { describe('alert and action creation', () => { before(async () => { - await supertest.patch(`/api/metrics/source/default`).set('kbn-xsrf', 'foo').send({ - anomalyThreshold: 50, - description: '', - metricAlias: 'kbn-data-forge*', - name: 'Default', - }); infraDataIndex = await generate({ esClient, lookback: 'now-15m', logger }); actionId = await createIndexConnector({ supertest, diff --git a/x-pack/test_serverless/api_integration/test_suites/observability/custom_threshold_rule/avg_pct_fired.ts b/x-pack/test_serverless/api_integration/test_suites/observability/custom_threshold_rule/avg_pct_fired.ts index de26cb2e906d4..0389d0eace4e7 100644 --- a/x-pack/test_serverless/api_integration/test_suites/observability/custom_threshold_rule/avg_pct_fired.ts +++ b/x-pack/test_serverless/api_integration/test_suites/observability/custom_threshold_rule/avg_pct_fired.ts @@ -23,11 +23,10 @@ export default function ({ getService }: FtrProviderContext) { const dataViewApi = getService('dataViewApi'); const logger = getService('log'); - describe('Custom Threshold rule - AVG - PCT - FIRED', () => { + // Blocked API: index_not_found_exception: no such index [.alerts-observability.threshold.alerts-default] + // Issue: https://github.com/elastic/kibana/issues/165138 + describe.skip('Custom Threshold rule - AVG - PCT - FIRED', () => { const CUSTOM_THRESHOLD_RULE_ALERT_INDEX = '.alerts-observability.threshold.alerts-default'; - // DATE_VIEW should match the index template: - // x-pack/packages/kbn-infra-forge/src/data_sources/composable/template.json - const DATE_VIEW = 'kbn-data-forge-fake_hosts'; const ALERT_ACTION_INDEX = 'alert-action-threshold'; const DATA_VIEW_ID = 'data-view-id'; let infraDataIndex: string; @@ -35,15 +34,11 @@ export default function ({ getService }: FtrProviderContext) { let ruleId: string; before(async () => { - infraDataIndex = await generate({ - esClient, - lookback: 'now-15m', - logger, - }); + infraDataIndex = await generate({ esClient, lookback: 'now-15m', logger }); await dataViewApi.create({ - name: DATE_VIEW, + name: 'metrics-fake_hosts', id: DATA_VIEW_ID, - title: DATE_VIEW, + title: 'metrics-fake_hosts', }); }); @@ -62,7 +57,7 @@ export default function ({ getService }: FtrProviderContext) { }); await esClient.deleteByQuery({ index: '.kibana-event-log-*', - query: { term: { 'kibana.alert.rule.consumer': 'apm' } }, + query: { term: { 'kibana.alert.rule.consumer': 'logs' } }, }); await dataViewApi.delete({ id: DATA_VIEW_ID, @@ -80,7 +75,7 @@ export default function ({ getService }: FtrProviderContext) { const createdRule = await alertingApi.createRule({ tags: ['observability'], - consumer: 'apm', + consumer: 'logs', name: 'Threshold rule', ruleTypeId: OBSERVABILITY_THRESHOLD_RULE_TYPE_ID, params: { @@ -147,7 +142,7 @@ export default function ({ getService }: FtrProviderContext) { 'kibana.alert.rule.category', 'Custom threshold (BETA)' ); - expect(resp.hits.hits[0]._source).property('kibana.alert.rule.consumer', 'apm'); + expect(resp.hits.hits[0]._source).property('kibana.alert.rule.consumer', 'logs'); expect(resp.hits.hits[0]._source).property('kibana.alert.rule.name', 'Threshold rule'); expect(resp.hits.hits[0]._source).property('kibana.alert.rule.producer', 'observability'); expect(resp.hits.hits[0]._source).property('kibana.alert.rule.revision', 0); diff --git a/x-pack/test_serverless/api_integration/test_suites/observability/custom_threshold_rule/avg_pct_no_data.ts b/x-pack/test_serverless/api_integration/test_suites/observability/custom_threshold_rule/avg_pct_no_data.ts index b1cc5c41f6805..0bd7fdf7bbb6f 100644 --- a/x-pack/test_serverless/api_integration/test_suites/observability/custom_threshold_rule/avg_pct_no_data.ts +++ b/x-pack/test_serverless/api_integration/test_suites/observability/custom_threshold_rule/avg_pct_no_data.ts @@ -20,7 +20,9 @@ export default function ({ getService }: FtrProviderContext) { const alertingApi = getService('alertingApi'); const dataViewApi = getService('dataViewApi'); - describe('Custom Threshold rule - AVG - PCT - NoData', () => { + // Blocked API: index_not_found_exception: no such index [.alerts-observability.threshold.alerts-default] + // Issue: https://github.com/elastic/kibana/issues/165138 + describe.skip('Custom Threshold rule - AVG - PCT - NoData', () => { const CUSTOM_THRESHOLD_RULE_ALERT_INDEX = '.alerts-observability.threshold.alerts-default'; const ALERT_ACTION_INDEX = 'alert-action-threshold'; const DATA_VIEW_ID = 'data-view-id-no-data'; @@ -50,7 +52,7 @@ export default function ({ getService }: FtrProviderContext) { }); await esClient.deleteByQuery({ index: '.kibana-event-log-*', - query: { term: { 'kibana.alert.rule.consumer': 'apm' } }, + query: { term: { 'kibana.alert.rule.consumer': 'logs' } }, }); await dataViewApi.delete({ id: DATA_VIEW_ID, @@ -66,7 +68,7 @@ export default function ({ getService }: FtrProviderContext) { const createdRule = await alertingApi.createRule({ tags: ['observability'], - consumer: 'apm', + consumer: 'logs', name: 'Threshold rule', ruleTypeId: OBSERVABILITY_THRESHOLD_RULE_TYPE_ID, params: { @@ -133,7 +135,7 @@ export default function ({ getService }: FtrProviderContext) { 'kibana.alert.rule.category', 'Custom threshold (BETA)' ); - expect(resp.hits.hits[0]._source).property('kibana.alert.rule.consumer', 'apm'); + expect(resp.hits.hits[0]._source).property('kibana.alert.rule.consumer', 'logs'); expect(resp.hits.hits[0]._source).property('kibana.alert.rule.name', 'Threshold rule'); expect(resp.hits.hits[0]._source).property('kibana.alert.rule.producer', 'observability'); expect(resp.hits.hits[0]._source).property('kibana.alert.rule.revision', 0); diff --git a/x-pack/test_serverless/api_integration/test_suites/observability/custom_threshold_rule/custom_eq_avg_bytes_fired.ts b/x-pack/test_serverless/api_integration/test_suites/observability/custom_threshold_rule/custom_eq_avg_bytes_fired.ts index b4b1858e9bac4..5ee54e1c9ad17 100644 --- a/x-pack/test_serverless/api_integration/test_suites/observability/custom_threshold_rule/custom_eq_avg_bytes_fired.ts +++ b/x-pack/test_serverless/api_integration/test_suites/observability/custom_threshold_rule/custom_eq_avg_bytes_fired.ts @@ -29,11 +29,9 @@ export default function ({ getService }: FtrProviderContext) { const alertingApi = getService('alertingApi'); const dataViewApi = getService('dataViewApi'); - describe('Custom Threshold rule - CUSTOM_EQ - AVG - BYTES - FIRED', () => { + // Issue: https://github.com/elastic/kibana/issues/165138 + describe.skip('Custom Threshold rule - CUSTOM_EQ - AVG - BYTES - FIRED', () => { const CUSTOM_THRESHOLD_RULE_ALERT_INDEX = '.alerts-observability.threshold.alerts-default'; - // DATE_VIEW should match the index template: - // x-pack/packages/kbn-infra-forge/src/data_sources/composable/template.json - const DATE_VIEW = 'kbn-data-forge-fake_hosts'; const ALERT_ACTION_INDEX = 'alert-action-threshold'; const DATA_VIEW_ID = 'data-view-id'; let infraDataIndex: string; @@ -43,9 +41,9 @@ export default function ({ getService }: FtrProviderContext) { before(async () => { infraDataIndex = await generate({ esClient, lookback: 'now-15m', logger }); await dataViewApi.create({ - name: DATE_VIEW, + name: 'metrics-fake_hosts', id: DATA_VIEW_ID, - title: DATE_VIEW, + title: 'metrics-fake_hosts', }); }); @@ -82,7 +80,7 @@ export default function ({ getService }: FtrProviderContext) { const createdRule = await alertingApi.createRule({ tags: ['observability'], - consumer: 'apm', + consumer: 'logs', name: 'Threshold rule', ruleTypeId: OBSERVABILITY_THRESHOLD_RULE_TYPE_ID, params: { @@ -151,7 +149,7 @@ export default function ({ getService }: FtrProviderContext) { 'kibana.alert.rule.category', 'Custom threshold (BETA)' ); - expect(resp.hits.hits[0]._source).property('kibana.alert.rule.consumer', 'apm'); + expect(resp.hits.hits[0]._source).property('kibana.alert.rule.consumer', 'logs'); expect(resp.hits.hits[0]._source).property('kibana.alert.rule.name', 'Threshold rule'); expect(resp.hits.hits[0]._source).property('kibana.alert.rule.producer', 'observability'); expect(resp.hits.hits[0]._source).property('kibana.alert.rule.revision', 0); diff --git a/x-pack/test_serverless/api_integration/test_suites/observability/custom_threshold_rule/documents_count_fired.ts b/x-pack/test_serverless/api_integration/test_suites/observability/custom_threshold_rule/documents_count_fired.ts index f5b0305993a91..56412a8380d2c 100644 --- a/x-pack/test_serverless/api_integration/test_suites/observability/custom_threshold_rule/documents_count_fired.ts +++ b/x-pack/test_serverless/api_integration/test_suites/observability/custom_threshold_rule/documents_count_fired.ts @@ -23,11 +23,9 @@ export default function ({ getService }: FtrProviderContext) { const alertingApi = getService('alertingApi'); const dataViewApi = getService('dataViewApi'); - describe('Custom Threshold rule - DOCUMENTS_COUNT - FIRED', () => { + // Issue: https://github.com/elastic/kibana/issues/165138 + describe.skip('Custom Threshold rule - DOCUMENTS_COUNT - FIRED', () => { const CUSTOM_THRESHOLD_RULE_ALERT_INDEX = '.alerts-observability.threshold.alerts-default'; - // DATE_VIEW should match the index template: - // x-pack/packages/kbn-infra-forge/src/data_sources/composable/template.json - const DATE_VIEW = 'kbn-data-forge-fake_hosts'; const ALERT_ACTION_INDEX = 'alert-action-threshold'; const DATA_VIEW_ID = 'data-view-id'; let infraDataIndex: string; @@ -37,9 +35,9 @@ export default function ({ getService }: FtrProviderContext) { before(async () => { infraDataIndex = await generate({ esClient, lookback: 'now-15m', logger }); await dataViewApi.create({ - name: DATE_VIEW, + name: 'metrics-fake_hosts', id: DATA_VIEW_ID, - title: DATE_VIEW, + title: 'metrics-fake_hosts', }); }); @@ -58,7 +56,7 @@ export default function ({ getService }: FtrProviderContext) { }); await esClient.deleteByQuery({ index: '.kibana-event-log-*', - query: { term: { 'kibana.alert.rule.consumer': 'apm' } }, + query: { term: { 'kibana.alert.rule.consumer': 'logs' } }, }); await dataViewApi.delete({ id: DATA_VIEW_ID, @@ -76,7 +74,7 @@ export default function ({ getService }: FtrProviderContext) { const createdRule = await alertingApi.createRule({ tags: ['observability'], - consumer: 'apm', + consumer: 'logs', name: 'Threshold rule', ruleTypeId: OBSERVABILITY_THRESHOLD_RULE_TYPE_ID, params: { @@ -141,7 +139,7 @@ export default function ({ getService }: FtrProviderContext) { 'kibana.alert.rule.category', 'Custom threshold (BETA)' ); - expect(resp.hits.hits[0]._source).property('kibana.alert.rule.consumer', 'apm'); + expect(resp.hits.hits[0]._source).property('kibana.alert.rule.consumer', 'logs'); expect(resp.hits.hits[0]._source).property('kibana.alert.rule.name', 'Threshold rule'); expect(resp.hits.hits[0]._source).property('kibana.alert.rule.producer', 'observability'); expect(resp.hits.hits[0]._source).property('kibana.alert.rule.revision', 0); diff --git a/x-pack/test_serverless/api_integration/test_suites/observability/custom_threshold_rule/group_by_fired.ts b/x-pack/test_serverless/api_integration/test_suites/observability/custom_threshold_rule/group_by_fired.ts index 09ae231091369..f1b3c949421d4 100644 --- a/x-pack/test_serverless/api_integration/test_suites/observability/custom_threshold_rule/group_by_fired.ts +++ b/x-pack/test_serverless/api_integration/test_suites/observability/custom_threshold_rule/group_by_fired.ts @@ -33,11 +33,9 @@ export default function ({ getService }: FtrProviderContext) { let alertId: string; let startedAt: string; - describe('Custom Threshold rule - GROUP_BY - FIRED', () => { + // Issue: https://github.com/elastic/kibana/issues/165138 + describe.skip('Custom Threshold rule - GROUP_BY - FIRED', () => { const CUSTOM_THRESHOLD_RULE_ALERT_INDEX = '.alerts-observability.threshold.alerts-default'; - // DATE_VIEW should match the index template: - // x-pack/packages/kbn-infra-forge/src/data_sources/composable/template.json - const DATE_VIEW = 'kbn-data-forge-fake_hosts'; const ALERT_ACTION_INDEX = 'alert-action-threshold'; const DATA_VIEW_ID = 'data-view-id'; let infraDataIndex: string; @@ -47,9 +45,9 @@ export default function ({ getService }: FtrProviderContext) { before(async () => { infraDataIndex = await generate({ esClient, lookback: 'now-15m', logger }); await dataViewApi.create({ - name: DATE_VIEW, + name: 'metrics-fake_hosts', id: DATA_VIEW_ID, - title: DATE_VIEW, + title: 'metrics-fake_hosts', }); }); @@ -68,7 +66,7 @@ export default function ({ getService }: FtrProviderContext) { }); await esClient.deleteByQuery({ index: '.kibana-event-log-*', - query: { term: { 'kibana.alert.rule.consumer': 'apm' } }, + query: { term: { 'kibana.alert.rule.consumer': 'logs' } }, }); await dataViewApi.delete({ id: DATA_VIEW_ID, @@ -86,7 +84,7 @@ export default function ({ getService }: FtrProviderContext) { const createdRule = await alertingApi.createRule({ tags: ['observability'], - consumer: 'apm', + consumer: 'logs', name: 'Threshold rule', ruleTypeId: OBSERVABILITY_THRESHOLD_RULE_TYPE_ID, params: { @@ -160,7 +158,7 @@ export default function ({ getService }: FtrProviderContext) { 'kibana.alert.rule.category', 'Custom threshold (BETA)' ); - expect(resp.hits.hits[0]._source).property('kibana.alert.rule.consumer', 'apm'); + expect(resp.hits.hits[0]._source).property('kibana.alert.rule.consumer', 'logs'); expect(resp.hits.hits[0]._source).property('kibana.alert.rule.name', 'Threshold rule'); expect(resp.hits.hits[0]._source).property('kibana.alert.rule.producer', 'observability'); expect(resp.hits.hits[0]._source).property('kibana.alert.rule.revision', 0); @@ -226,8 +224,7 @@ export default function ({ getService }: FtrProviderContext) { expect(resp.hits.hits[0]._source?.ruleType).eql('observability.rules.custom_threshold'); expect(resp.hits.hits[0]._source?.alertDetailsUrl).eql( - // Added the S to protocol.getUrlParts as not returning the correct value. - `${protocol}s://${hostname}:${port}/app/observability/alerts?_a=(kuery:%27kibana.alert.uuid:%20%22${alertId}%22%27%2CrangeFrom:%27${rangeFrom}%27%2CrangeTo:now%2Cstatus:all)` + `${protocol}://${hostname}:${port}/app/observability/alerts?_a=(kuery:%27kibana.alert.uuid:%20%22${alertId}%22%27%2CrangeFrom:%27${rangeFrom}%27%2CrangeTo:now%2Cstatus:all)` ); expect(resp.hits.hits[0]._source?.reason).eql( 'Custom equation is 0.8 in the last 1 min for host-0. Alert when >= 0.2.' From 6431d7c61ec1e1f81969e1750f03f233a076ff52 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Fri, 29 Sep 2023 08:29:23 -0400 Subject: [PATCH 04/15] skip failing test suite (#167619) --- .../observability/custom_threshold_rule/documents_count_fired.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/x-pack/test_serverless/api_integration/test_suites/observability/custom_threshold_rule/documents_count_fired.ts b/x-pack/test_serverless/api_integration/test_suites/observability/custom_threshold_rule/documents_count_fired.ts index 56412a8380d2c..7c67217ca96ee 100644 --- a/x-pack/test_serverless/api_integration/test_suites/observability/custom_threshold_rule/documents_count_fired.ts +++ b/x-pack/test_serverless/api_integration/test_suites/observability/custom_threshold_rule/documents_count_fired.ts @@ -24,6 +24,7 @@ export default function ({ getService }: FtrProviderContext) { const dataViewApi = getService('dataViewApi'); // Issue: https://github.com/elastic/kibana/issues/165138 + // Failing: See https://github.com/elastic/kibana/issues/167619 describe.skip('Custom Threshold rule - DOCUMENTS_COUNT - FIRED', () => { const CUSTOM_THRESHOLD_RULE_ALERT_INDEX = '.alerts-observability.threshold.alerts-default'; const ALERT_ACTION_INDEX = 'alert-action-threshold'; From d7b7659322405e72c4d2dde86c4860fc057c82a6 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Fri, 29 Sep 2023 08:49:05 -0400 Subject: [PATCH 05/15] skip failing test suite (#167560) --- x-pack/test/saved_object_tagging/functional/tests/edit.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/saved_object_tagging/functional/tests/edit.ts b/x-pack/test/saved_object_tagging/functional/tests/edit.ts index 96a91c72c3f3e..f29212b251827 100644 --- a/x-pack/test/saved_object_tagging/functional/tests/edit.ts +++ b/x-pack/test/saved_object_tagging/functional/tests/edit.ts @@ -15,7 +15,8 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const tagManagementPage = PageObjects.tagManagement; - describe('edit tag', () => { + // Failing: See https://github.com/elastic/kibana/issues/167560 + describe.skip('edit tag', () => { let tagModal: typeof tagManagementPage['tagModal']; before(async () => { From 3545d3023e77238958fb8f3ee9902c6bb8be0ca9 Mon Sep 17 00:00:00 2001 From: Xavier Mouligneau Date: Fri, 29 Sep 2023 09:02:01 -0400 Subject: [PATCH 06/15] [RAM] Allow conditional action for rule type with AAD (#167556) ## Summary With this change, we will let rule type to use conditional action if the rule type create alert in the alert index. We will need to some clean up on these two fields `HasAlertsMappings` and `hasFieldsForAAD` in future release. (https://github.com/elastic/kibana/issues/167558) --- .../public/connector_types/slack/action_form.test.tsx | 1 + .../sections/action_connector_form/action_type_form.tsx | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/stack_connectors/public/connector_types/slack/action_form.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/slack/action_form.test.tsx index e4e1cb533aa93..f06461dd5fbc9 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/slack/action_form.test.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/slack/action_form.test.tsx @@ -164,6 +164,7 @@ describe('ActionForm - Slack API Connector', () => { const testProps = { ...baseProps, + hasAlertsMappings: false, actions: testActions, }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx index 2cd0c43f5aa4f..3f0982b1b24f3 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx @@ -358,7 +358,8 @@ export const ActionTypeForm = ({ setActionGroupIdByIndex && !actionItem.frequency?.summary; - const showActionAlertsFilter = hasFieldsForAAD || producerId === AlertConsumers.SIEM; + const showActionAlertsFilter = + hasFieldsForAAD || producerId === AlertConsumers.SIEM || hasAlertsMappings; const accordionContent = checkEnabledResult.isEnabled ? ( <> From d9b026d7e1a87e4b4575cef795be16761318d1f3 Mon Sep 17 00:00:00 2001 From: Chris Cressman Date: Fri, 29 Sep 2023 09:08:05 -0400 Subject: [PATCH 07/15] [Enterprise Search] Update URLs for docs migrating from Enterprise Search (#167565) Several docs are going to migrate from Enterprise Search to Elasticsearch. Replace Enterprise Search URLs with Elasticsearch URLs. --- packages/kbn-doc-links/src/get_doc_links.ts | 26 ++++++++++----------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/kbn-doc-links/src/get_doc_links.ts b/packages/kbn-doc-links/src/get_doc_links.ts index d9cbd6aed1315..ea463a75d474b 100644 --- a/packages/kbn-doc-links/src/get_doc_links.ts +++ b/packages/kbn-doc-links/src/get_doc_links.ts @@ -133,9 +133,9 @@ export const getDocLinks = ({ kibanaBranch }: GetDocLinkOptions): DocLinks => { aiSearchDoc: `${ESRE_DOCS}`, aiSearchHelp: `${ESRE_DOCS}help.html`, apiKeys: `${KIBANA_DOCS}api-keys.html`, - behavioralAnalytics: `${ENTERPRISE_SEARCH_DOCS}analytics-overview.html`, - behavioralAnalyticsCORS: `${ENTERPRISE_SEARCH_DOCS}analytics-cors-proxy.html`, - behavioralAnalyticsEvents: `${ENTERPRISE_SEARCH_DOCS}analytics-events.html`, + behavioralAnalytics: `${ELASTICSEARCH_DOCS}behavioral-analytics-overview.html`, + behavioralAnalyticsCORS: `${ELASTICSEARCH_DOCS}behavioral-analytics-cors.html`, + behavioralAnalyticsEvents: `${ELASTICSEARCH_DOCS}behavioral-analytics-event.html`, buildConnector: `${ENTERPRISE_SEARCH_DOCS}build-connector.html`, bulkApi: `${ELASTICSEARCH_DOCS}docs-bulk.html`, configuration: `${ENTERPRISE_SEARCH_DOCS}configuration.html`, @@ -170,24 +170,24 @@ export const getDocLinks = ({ kibanaBranch }: GetDocLinkOptions): DocLinks => { crawlerOverview: `${ENTERPRISE_SEARCH_DOCS}crawler.html`, deployTrainedModels: `${MACHINE_LEARNING_DOCS}ml-nlp-deploy-models.html`, documentLevelSecurity: `${ELASTICSEARCH_DOCS}document-level-security.html`, - elser: `${ENTERPRISE_SEARCH_DOCS}elser-text-expansion.html`, + elser: `${ELASTICSEARCH_DOCS}semantic-search-elser.html`, engines: `${ENTERPRISE_SEARCH_DOCS}engines.html`, indexApi: `${ELASTICSEARCH_DOCS}docs-index_.html`, - ingestionApis: `${ENTERPRISE_SEARCH_DOCS}ingestion-apis.html`, - ingestPipelines: `${ENTERPRISE_SEARCH_DOCS}ingest-pipelines.html`, + ingestionApis: `${ELASTICSEARCH_DOCS}search-your-data.html`, + ingestPipelines: `${ELASTICSEARCH_DOCS}ingest-pipeline-search.html`, knnSearch: `${ELASTICSEARCH_DOCS}knn-search.html`, knnSearchCombine: `${ELASTICSEARCH_DOCS}knn-search.html#_combine_approximate_knn_with_other_features`, languageAnalyzers: `${ELASTICSEARCH_DOCS}analysis-lang-analyzer.html`, languageClients: `${ENTERPRISE_SEARCH_DOCS}programming-language-clients.html`, licenseManagement: `${ENTERPRISE_SEARCH_DOCS}license-management.html`, - machineLearningStart: `${ENTERPRISE_SEARCH_DOCS}machine-learning-start.html`, + machineLearningStart: `${ELASTICSEARCH_DOCS}nlp-example.html`, mailService: `${ENTERPRISE_SEARCH_DOCS}mailer-configuration.html`, - mlDocumentEnrichment: `${ENTERPRISE_SEARCH_DOCS}document-enrichment.html`, - mlDocumentEnrichmentUpdateMappings: `${ENTERPRISE_SEARCH_DOCS}document-enrichment.html#document-enrichment-update-mappings`, - searchApplicationsTemplates: `${ENTERPRISE_SEARCH_DOCS}search-applications-templates.html`, - searchApplicationsSearchApi: `${ENTERPRISE_SEARCH_DOCS}search-applications-safe-search.html`, - searchApplications: `${ENTERPRISE_SEARCH_DOCS}search-applications.html`, - searchApplicationsSearch: `${ENTERPRISE_SEARCH_DOCS}search-applications-search.html`, + mlDocumentEnrichment: `${ELASTICSEARCH_DOCS}ingest-pipeline-search-inference.html`, + mlDocumentEnrichmentUpdateMappings: `${ELASTICSEARCH_DOCS}ingest-pipeline-search-inference.html#ingest-pipeline-search-inference-update-mapping`, + searchApplicationsTemplates: `${ELASTICSEARCH_DOCS}search-application-api.html`, + searchApplicationsSearchApi: `${ELASTICSEARCH_DOCS}search-application-security.html`, + searchApplications: `${ELASTICSEARCH_DOCS}search-application-overview.html`, + searchApplicationsSearch: `${ELASTICSEARCH_DOCS}search-application-client.html`, searchLabs: `${SEARCH_LABS_URL}`, searchLabsRepo: `${SEARCH_LABS_REPO}`, searchTemplates: `${ELASTICSEARCH_DOCS}search-template.html`, From 4c4b2d449715338c89e5df312de5beebb9cd8656 Mon Sep 17 00:00:00 2001 From: Gabriel Landau <42078554+gabriellandau@users.noreply.github.com> Date: Fri, 29 Sep 2023 09:31:12 -0400 Subject: [PATCH 08/15] Endpoint Advanced Policy Option: `advanced.events.check_debug_registers` (#167308) ## Summary New advanced Endpoint/Defend option. See its description for details. ### Checklist Delete any items that are not applicable to this PR. - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../pages/policy/models/advanced_policy_schema.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/models/advanced_policy_schema.ts b/x-pack/plugins/security_solution/public/management/pages/policy/models/advanced_policy_schema.ts index 056577f7944ef..02d856fa4ebdd 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/models/advanced_policy_schema.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/models/advanced_policy_schema.ts @@ -1351,4 +1351,15 @@ export const AdvancedPolicySchema: AdvancedPolicySchemaType[] = [ } ), }, + { + key: 'windows.advanced.events.check_debug_registers', + first_supported_version: '8.11', + documentation: i18n.translate( + 'xpack.securitySolution.endpoint.policy.advanced.windows.advanced.events.check_debug_registers', + { + defaultMessage: + 'Check debug registers inline to detect the use of hardware breakpoints. Malware may use hardware breakpoints to forge benign-looking call stacks. Default: true', + } + ), + }, ]; From 7393bfefbae0666234e896a29780ddf9fed8ccbf Mon Sep 17 00:00:00 2001 From: Stratoula Kalafateli Date: Fri, 29 Sep 2023 16:35:52 +0300 Subject: [PATCH 09/15] [Lens] Fixes mosaic with 2 axis coloring (#167035) ## Summary Fixes https://github.com/elastic/kibana/issues/164964 The Other label was not formatted correctly. image ### Checklist - [ ] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --- .../public/utils/layers/get_color.test.ts | 62 ++++++++++--------- .../public/utils/layers/get_color.ts | 16 ++++- .../public/utils/layers/get_layers.ts | 6 +- 3 files changed, 50 insertions(+), 34 deletions(-) diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/utils/layers/get_color.test.ts b/src/plugins/chart_expressions/expression_partition_vis/public/utils/layers/get_color.test.ts index c6fffce60ba0b..f8728ebf7a51c 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/public/utils/layers/get_color.test.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/public/utils/layers/get_color.test.ts @@ -8,7 +8,6 @@ import type { PaletteOutput, PaletteDefinition } from '@kbn/coloring'; import { chartPluginMock } from '@kbn/charts-plugin/public/mocks'; -import { Datatable } from '@kbn/expressions-plugin/common'; import { byDataColorPaletteMap, SimplifiedArrayNode } from './get_color'; import type { SeriesLayer } from '@kbn/coloring'; import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; @@ -21,29 +20,28 @@ import { ChartTypes } from '../../../common/types'; import { getDistinctSeries } from '..'; describe('#byDataColorPaletteMap', () => { - let datatable: Datatable; let paletteDefinition: PaletteDefinition; let palette: PaletteOutput; - const columnId = 'foo'; + const visData = createMockVisData(); + const defaultFormatter = jest.fn((...args) => fieldFormatsMock.deserialize(...args)); + const formatters = generateFormatters(visData, defaultFormatter); beforeEach(() => { - datatable = { - rows: [ - { - [columnId]: '1', - }, - { - [columnId]: '2', - }, - ], - } as unknown as Datatable; paletteDefinition = chartPluginMock.createPaletteRegistry().get('default'); palette = { type: 'palette' } as PaletteOutput; }); it('should create byDataColorPaletteMap', () => { - expect(byDataColorPaletteMap(datatable.rows, columnId, paletteDefinition, palette)) - .toMatchInlineSnapshot(` + expect( + byDataColorPaletteMap( + visData.rows, + visData.columns[0], + paletteDefinition, + palette, + formatters, + fieldFormatsMock + ) + ).toMatchInlineSnapshot(` Object { "getColor": [Function], } @@ -52,21 +50,25 @@ describe('#byDataColorPaletteMap', () => { it('should get color', () => { const colorPaletteMap = byDataColorPaletteMap( - datatable.rows, - columnId, + visData.rows, + visData.columns[0], paletteDefinition, - palette + palette, + formatters, + fieldFormatsMock ); - expect(colorPaletteMap.getColor('1')).toBe('black'); + expect(colorPaletteMap.getColor('Logstash Airways')).toBe('black'); }); it('should return undefined in case if values not in datatable', () => { const colorPaletteMap = byDataColorPaletteMap( - datatable.rows, - columnId, + visData.rows, + visData.columns[0], paletteDefinition, - palette + palette, + formatters, + fieldFormatsMock ); expect(colorPaletteMap.getColor('wrong')).toBeUndefined(); @@ -74,24 +76,26 @@ describe('#byDataColorPaletteMap', () => { it('should increase rankAtDepth for each new value', () => { const colorPaletteMap = byDataColorPaletteMap( - datatable.rows, - columnId, + visData.rows, + visData.columns[0], paletteDefinition, - palette + palette, + formatters, + fieldFormatsMock ); - colorPaletteMap.getColor('1'); - colorPaletteMap.getColor('2'); + colorPaletteMap.getColor('Logstash Airways'); + colorPaletteMap.getColor('JetBeats'); expect(paletteDefinition.getCategoricalColor).toHaveBeenNthCalledWith( 1, - [{ name: '1', rankAtDepth: 0, totalSeriesAtDepth: 2 }], + [{ name: 'Logstash Airways', rankAtDepth: 0, totalSeriesAtDepth: 4 }], { behindText: false }, undefined ); expect(paletteDefinition.getCategoricalColor).toHaveBeenNthCalledWith( 2, - [{ name: '2', rankAtDepth: 1, totalSeriesAtDepth: 2 }], + [{ name: 'JetBeats', rankAtDepth: 1, totalSeriesAtDepth: 4 }], { behindText: false }, undefined ); diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/utils/layers/get_color.ts b/src/plugins/chart_expressions/expression_partition_vis/public/utils/layers/get_color.ts index 2f93b9bc8d373..bb9538a02fb27 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/public/utils/layers/get_color.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/public/utils/layers/get_color.ts @@ -21,12 +21,22 @@ const isTreemapOrMosaicChart = (shape: ChartTypes) => export const byDataColorPaletteMap = ( rows: Datatable['rows'], - columnId: string, + column: Partial, paletteDefinition: PaletteDefinition, - { params }: PaletteOutput + { params }: PaletteOutput, + formatters: Record, + formatter: FieldFormatsStart ) => { const colorMap = new Map( - rows.map((item) => [String(item[columnId]), undefined]) + rows.map((item) => { + const formattedName = getNodeLabel( + item[column.id ?? ''], + column, + formatters, + formatter.deserialize + ); + return [formattedName, undefined]; + }) ); let rankAtDepth = 0; diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/utils/layers/get_layers.ts b/src/plugins/chart_expressions/expression_partition_vis/public/utils/layers/get_layers.ts index 6f40097809e18..73d6e29a5b1ed 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/public/utils/layers/get_layers.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/public/utils/layers/get_layers.ts @@ -61,9 +61,11 @@ export const getLayers = ( if (!syncColors && columns[1]?.id && palettes && visParams.palette) { byDataPalette = byDataColorPaletteMap( rows, - columns[1].id, + columns[1], palettes?.get(visParams.palette.name), - visParams.palette + visParams.palette, + formatters, + formatter ); } From a32693db40945e18c783723b69663b82a6bd5868 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Fri, 29 Sep 2023 09:43:47 -0400 Subject: [PATCH 10/15] skip failing test suite (#167629) --- .../observability/custom_threshold_rule/avg_pct_no_data.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/x-pack/test_serverless/api_integration/test_suites/observability/custom_threshold_rule/avg_pct_no_data.ts b/x-pack/test_serverless/api_integration/test_suites/observability/custom_threshold_rule/avg_pct_no_data.ts index 0bd7fdf7bbb6f..6b7fd85af9962 100644 --- a/x-pack/test_serverless/api_integration/test_suites/observability/custom_threshold_rule/avg_pct_no_data.ts +++ b/x-pack/test_serverless/api_integration/test_suites/observability/custom_threshold_rule/avg_pct_no_data.ts @@ -22,6 +22,7 @@ export default function ({ getService }: FtrProviderContext) { // Blocked API: index_not_found_exception: no such index [.alerts-observability.threshold.alerts-default] // Issue: https://github.com/elastic/kibana/issues/165138 + // Failing: See https://github.com/elastic/kibana/issues/167629 describe.skip('Custom Threshold rule - AVG - PCT - NoData', () => { const CUSTOM_THRESHOLD_RULE_ALERT_INDEX = '.alerts-observability.threshold.alerts-default'; const ALERT_ACTION_INDEX = 'alert-action-threshold'; From 8c17d8ab5d1d4636b2a99248c8bd7712926259ef Mon Sep 17 00:00:00 2001 From: Carlos Crespo Date: Fri, 29 Sep 2023 15:59:05 +0200 Subject: [PATCH 11/15] [Infra UI] Add missing configuration to display dotted lines in the metric charts (#167509) fixes https://github.com/elastic/kibana/issues/166850 ## Summary This PR fixes a problem where the charts were not configured to display dotted lines when there were data points missing. image image ### How to test - Setup a local Kibana instance pointing to an oblt cluster - Navigate to `Infrastructure` > `Hosts` - Click on a host to navigate to the details page - Verify if charts with missing data points display dottet lines --- .../asset_details/tabs/overview/metrics/chart.tsx | 3 ++- .../asset_details/tabs/overview/metrics/metrics_grid.tsx | 6 +++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/infra/public/components/asset_details/tabs/overview/metrics/chart.tsx b/x-pack/plugins/infra/public/components/asset_details/tabs/overview/metrics/chart.tsx index 087315f5834e3..ab096e8337874 100644 --- a/x-pack/plugins/infra/public/components/asset_details/tabs/overview/metrics/chart.tsx +++ b/x-pack/plugins/infra/public/components/asset_details/tabs/overview/metrics/chart.tsx @@ -39,7 +39,7 @@ export const Chart = ({ assetName, ...props }: ChartProps) => { - const { setDateRange } = useDateRangeProviderContext(); + const { setDateRange, refreshTs } = useDateRangeProviderContext(); const dataView = useMemo(() => { return dataViewOrigin === 'metrics' ? metricsDataView : logsDataView; @@ -93,6 +93,7 @@ export const Chart = ({ filters={filters} title={title} overrides={overrides} + lastReloadRequestTime={refreshTs} visualizationType="lnsXY" onBrushEnd={handleBrushEnd} onFilter={handleFilter} diff --git a/x-pack/plugins/infra/public/components/asset_details/tabs/overview/metrics/metrics_grid.tsx b/x-pack/plugins/infra/public/components/asset_details/tabs/overview/metrics/metrics_grid.tsx index f0d8daaf63456..d0bfd585e5080 100644 --- a/x-pack/plugins/infra/public/components/asset_details/tabs/overview/metrics/metrics_grid.tsx +++ b/x-pack/plugins/infra/public/components/asset_details/tabs/overview/metrics/metrics_grid.tsx @@ -8,7 +8,10 @@ import React, { useMemo } from 'react'; import type { DataView } from '@kbn/data-views-plugin/public'; import { EuiFlexItem, EuiFlexGrid } from '@elastic/eui'; import type { TimeRange } from '@kbn/es-query'; -import type { XYConfig } from '../../../../../common/visualizations'; +import { + type XYConfig, + XY_MISSING_VALUE_DOTTED_LINE_CONFIG, +} from '../../../../../common/visualizations'; import { useMetadataStateProviderContext } from '../../../hooks/use_metadata_state'; import { Chart } from './chart'; @@ -55,6 +58,7 @@ export const MetricsGrid = ({ logsDataView={logsDataView} metricsDataView={metricsDataView} data-test-subj={props['data-test-subj']} + visualOptions={XY_MISSING_VALUE_DOTTED_LINE_CONFIG} /> ))} From 9d3213e1372b72b5dd143e42b48e517d66cd3820 Mon Sep 17 00:00:00 2001 From: Kevin Delemme Date: Fri, 29 Sep 2023 10:19:28 -0400 Subject: [PATCH 12/15] fix(slo): Handle partial indicator url state (#167247) --- .../apm_availability_indicator_type_form.tsx | 20 ++- .../apm_latency_indicator_type_form.tsx | 20 ++- .../slo_edit/components/slo_edit_form.tsx | 4 +- .../helpers/process_slo_form_values.test.ts | 152 ++++++++++++++++++ .../helpers/process_slo_form_values.ts | 74 ++++++--- .../slo_edit/hooks/use_parse_url_state.ts | 7 +- .../slo_edit/hooks/use_unregister_fields.ts | 2 +- .../public/pages/slo_edit/slo_edit.test.tsx | 10 +- 8 files changed, 246 insertions(+), 43 deletions(-) create mode 100644 x-pack/plugins/observability/public/pages/slo_edit/helpers/process_slo_form_values.test.ts diff --git a/x-pack/plugins/observability/public/pages/slo_edit/components/apm_availability/apm_availability_indicator_type_form.tsx b/x-pack/plugins/observability/public/pages/slo_edit/components/apm_availability/apm_availability_indicator_type_form.tsx index bfb629f3c8071..d22509da43599 100644 --- a/x-pack/plugins/observability/public/pages/slo_edit/components/apm_availability/apm_availability_indicator_type_form.tsx +++ b/x-pack/plugins/observability/public/pages/slo_edit/components/apm_availability/apm_availability_indicator_type_form.tsx @@ -7,8 +7,9 @@ import { EuiFlexGroup, EuiFlexItem, EuiIconTip } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import React from 'react'; +import React, { useEffect } from 'react'; import { useFormContext } from 'react-hook-form'; +import { useFetchApmIndex } from '../../../../hooks/slo/use_fetch_apm_indices'; import { useFetchIndexPatternFields } from '../../../../hooks/slo/use_fetch_index_pattern_fields'; import { CreateSLOForm } from '../../types'; import { FieldSelector } from '../apm_common/field_selector'; @@ -17,10 +18,17 @@ import { IndexFieldSelector } from '../common/index_field_selector'; import { QueryBuilder } from '../common/query_builder'; export function ApmAvailabilityIndicatorTypeForm() { - const { watch } = useFormContext(); - const index = watch('indicator.params.index'); + const { watch, setValue } = useFormContext(); + const { data: apmIndex } = useFetchApmIndex(); + + useEffect(() => { + if (apmIndex !== '') { + setValue('indicator.params.index', apmIndex); + } + }, [setValue, apmIndex]); + const { isLoading: isIndexFieldsLoading, data: indexFields = [] } = - useFetchIndexPatternFields(index); + useFetchIndexPatternFields(apmIndex); const partitionByFields = indexFields.filter((field) => field.aggregatable); return ( @@ -144,8 +152,8 @@ export function ApmAvailabilityIndicatorTypeForm() { placeholder={i18n.translate('xpack.observability.slo.sloEdit.groupBy.placeholder', { defaultMessage: 'Select an optional field to partition by', })} - isLoading={!!index && isIndexFieldsLoading} - isDisabled={!index} + isLoading={!!apmIndex && isIndexFieldsLoading} + isDisabled={!apmIndex} /> diff --git a/x-pack/plugins/observability/public/pages/slo_edit/components/apm_latency/apm_latency_indicator_type_form.tsx b/x-pack/plugins/observability/public/pages/slo_edit/components/apm_latency/apm_latency_indicator_type_form.tsx index 8d21a0c7d2546..af357ea0c18d1 100644 --- a/x-pack/plugins/observability/public/pages/slo_edit/components/apm_latency/apm_latency_indicator_type_form.tsx +++ b/x-pack/plugins/observability/public/pages/slo_edit/components/apm_latency/apm_latency_indicator_type_form.tsx @@ -7,8 +7,9 @@ import { EuiFieldNumber, EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiIconTip } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import React from 'react'; +import React, { useEffect } from 'react'; import { Controller, useFormContext } from 'react-hook-form'; +import { useFetchApmIndex } from '../../../../hooks/slo/use_fetch_apm_indices'; import { useFetchIndexPatternFields } from '../../../../hooks/slo/use_fetch_index_pattern_fields'; import { CreateSLOForm } from '../../types'; import { FieldSelector } from '../apm_common/field_selector'; @@ -17,10 +18,17 @@ import { IndexFieldSelector } from '../common/index_field_selector'; import { QueryBuilder } from '../common/query_builder'; export function ApmLatencyIndicatorTypeForm() { - const { control, watch, getFieldState } = useFormContext(); - const index = watch('indicator.params.index'); + const { control, watch, getFieldState, setValue } = useFormContext(); + const { data: apmIndex } = useFetchApmIndex(); + + useEffect(() => { + if (apmIndex !== '') { + setValue('indicator.params.index', apmIndex); + } + }, [setValue, apmIndex]); + const { isLoading: isIndexFieldsLoading, data: indexFields = [] } = - useFetchIndexPatternFields(index); + useFetchIndexPatternFields(apmIndex); const partitionByFields = indexFields.filter((field) => field.aggregatable); return ( @@ -187,8 +195,8 @@ export function ApmLatencyIndicatorTypeForm() { placeholder={i18n.translate('xpack.observability.slo.sloEdit.groupBy.placeholder', { defaultMessage: 'Select an optional field to partition by', })} - isLoading={!!index && isIndexFieldsLoading} - isDisabled={!index} + isLoading={!!apmIndex && isIndexFieldsLoading} + isDisabled={!apmIndex} /> diff --git a/x-pack/plugins/observability/public/pages/slo_edit/components/slo_edit_form.tsx b/x-pack/plugins/observability/public/pages/slo_edit/components/slo_edit_form.tsx index ba3970d93cab0..19c27928b292a 100644 --- a/x-pack/plugins/observability/public/pages/slo_edit/components/slo_edit_form.tsx +++ b/x-pack/plugins/observability/public/pages/slo_edit/components/slo_edit_form.tsx @@ -62,7 +62,7 @@ export function SloEditForm({ slo }: Props) { sloIds: slo?.id ? [slo.id] : undefined, }); - const sloFormValuesUrlState = useParseUrlState(); + const sloFormValuesFromUrlState = useParseUrlState(); const isAddRuleFlyoutOpen = useAddRuleFlyoutState(isEditMode); const [isCreateRuleCheckboxChecked, setIsCreateRuleCheckboxChecked] = useState(true); @@ -73,7 +73,7 @@ export function SloEditForm({ slo }: Props) { }, [isEditMode, rules, slo]); const methods = useForm({ - defaultValues: Object.assign({}, SLO_EDIT_FORM_DEFAULT_VALUES, sloFormValuesUrlState), + defaultValues: Object.assign({}, SLO_EDIT_FORM_DEFAULT_VALUES, sloFormValuesFromUrlState), values: transformSloResponseToCreateSloForm(slo), mode: 'all', }); diff --git a/x-pack/plugins/observability/public/pages/slo_edit/helpers/process_slo_form_values.test.ts b/x-pack/plugins/observability/public/pages/slo_edit/helpers/process_slo_form_values.test.ts new file mode 100644 index 0000000000000..475dfb01b1998 --- /dev/null +++ b/x-pack/plugins/observability/public/pages/slo_edit/helpers/process_slo_form_values.test.ts @@ -0,0 +1,152 @@ +/* + * 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 { transformPartialUrlStateToFormState as transform } from './process_slo_form_values'; + +describe('Transform Partial URL State into partial State Form', () => { + describe('indicators', () => { + it("returns an empty '{}' when no indicator type is specified", () => { + expect(transform({ indicator: { params: { index: 'my-index' } } })).toEqual({}); + }); + + it('handles partial APM Availability state', () => { + expect( + transform({ + indicator: { + type: 'sli.apm.transactionErrorRate', + params: { + service: 'override-service', + }, + }, + }) + ).toEqual({ + indicator: { + type: 'sli.apm.transactionErrorRate', + params: { + service: 'override-service', + environment: '', + filter: '', + index: '', + transactionName: '', + transactionType: '', + }, + }, + }); + }); + + it('handles partial APM Latency state', () => { + expect( + transform({ + indicator: { + type: 'sli.apm.transactionDuration', + params: { + service: 'override-service', + }, + }, + }) + ).toEqual({ + indicator: { + type: 'sli.apm.transactionDuration', + params: { + service: 'override-service', + environment: '', + filter: '', + index: '', + transactionName: '', + transactionType: '', + threshold: 250, + }, + }, + }); + }); + + it('handles partial Custom KQL state', () => { + expect( + transform({ + indicator: { + type: 'sli.kql.custom', + params: { + good: "some.override.filter:'foo'", + index: 'override-index', + }, + }, + }) + ).toEqual({ + indicator: { + type: 'sli.kql.custom', + params: { + index: 'override-index', + timestampField: '', + filter: '', + good: "some.override.filter:'foo'", + total: '', + }, + }, + }); + }); + + it('handles partial Custom Metric state', () => { + expect( + transform({ + indicator: { + type: 'sli.metric.custom', + params: { + index: 'override-index', + }, + }, + }) + ).toEqual({ + indicator: { + type: 'sli.metric.custom', + params: { + index: 'override-index', + filter: '', + timestampField: '', + good: { + equation: 'A', + metrics: [{ aggregation: 'sum', field: '', name: 'A' }], + }, + total: { + equation: 'A', + metrics: [{ aggregation: 'sum', field: '', name: 'A' }], + }, + }, + }, + }); + }); + + it('handles partial Custom Histogram state', () => { + expect( + transform({ + indicator: { + type: 'sli.histogram.custom', + params: { + index: 'override-index', + }, + }, + }) + ).toEqual({ + indicator: { + type: 'sli.histogram.custom', + params: { + index: 'override-index', + filter: '', + timestampField: '', + good: { + aggregation: 'value_count', + field: '', + }, + total: { + aggregation: 'value_count', + field: '', + }, + }, + }, + }); + }); + }); +}); diff --git a/x-pack/plugins/observability/public/pages/slo_edit/helpers/process_slo_form_values.ts b/x-pack/plugins/observability/public/pages/slo_edit/helpers/process_slo_form_values.ts index a4ecec640e2df..c6de2126adacf 100644 --- a/x-pack/plugins/observability/public/pages/slo_edit/helpers/process_slo_form_values.ts +++ b/x-pack/plugins/observability/public/pages/slo_edit/helpers/process_slo_form_values.ts @@ -5,8 +5,17 @@ * 2.0. */ -import { CreateSLOInput, SLOWithSummaryResponse, UpdateSLOInput } from '@kbn/slo-schema'; +import { CreateSLOInput, Indicator, SLOWithSummaryResponse, UpdateSLOInput } from '@kbn/slo-schema'; +import { assertNever } from '@kbn/std'; +import { RecursivePartial } from '@kbn/utility-types'; import { toDuration } from '../../../utils/slo/duration'; +import { + APM_AVAILABILITY_DEFAULT_VALUES, + APM_LATENCY_DEFAULT_VALUES, + CUSTOM_KQL_DEFAULT_VALUES, + CUSTOM_METRIC_DEFAULT_VALUES, + HISTOGRAM_DEFAULT_VALUES, +} from '../constants'; import { CreateSLOForm } from '../types'; export function transformSloResponseToCreateSloForm( @@ -91,21 +100,50 @@ export function transformValuesToUpdateSLOInput(values: CreateSLOForm): UpdateSL }; } -export function transformPartialCreateSLOInputToPartialCreateSLOForm( - values: Partial -): Partial { - return { - ...values, - ...(values.objective && { - objective: { - target: values.objective.target * 100, - ...(values.objective.timesliceTarget && { - timesliceTarget: values.objective.timesliceTarget * 100, - }), - ...(values.objective.timesliceWindow && { - timesliceWindow: String(toDuration(values.objective.timesliceWindow).value), - }), - }, - }), - }; +function transformPartialIndicatorState( + indicator?: RecursivePartial +): Indicator | undefined { + if (indicator === undefined || indicator.type === undefined) return undefined; + + const indicatorType = indicator.type; + switch (indicatorType) { + case 'sli.apm.transactionDuration': + return { + type: 'sli.apm.transactionDuration' as const, + params: Object.assign({}, APM_LATENCY_DEFAULT_VALUES.params, indicator.params ?? {}), + }; + case 'sli.apm.transactionErrorRate': + return { + type: 'sli.apm.transactionErrorRate' as const, + params: Object.assign({}, APM_AVAILABILITY_DEFAULT_VALUES.params, indicator.params ?? {}), + }; + case 'sli.histogram.custom': + return { + type: 'sli.histogram.custom' as const, + params: Object.assign({}, HISTOGRAM_DEFAULT_VALUES.params, indicator.params ?? {}), + }; + case 'sli.kql.custom': + return { + type: 'sli.kql.custom' as const, + params: Object.assign({}, CUSTOM_KQL_DEFAULT_VALUES.params, indicator.params ?? {}), + }; + case 'sli.metric.custom': + return { + type: 'sli.metric.custom' as const, + params: Object.assign({}, CUSTOM_METRIC_DEFAULT_VALUES.params, indicator.params ?? {}), + }; + default: + assertNever(indicatorType); + } +} + +export function transformPartialUrlStateToFormState( + values: RecursivePartial> +): Partial | {} { + const state: Partial = {}; + + const parsedIndicator = transformPartialIndicatorState(values.indicator); + if (parsedIndicator !== undefined) state.indicator = parsedIndicator; + + return state; } diff --git a/x-pack/plugins/observability/public/pages/slo_edit/hooks/use_parse_url_state.ts b/x-pack/plugins/observability/public/pages/slo_edit/hooks/use_parse_url_state.ts index 7ae38e9976f96..f8354ae030403 100644 --- a/x-pack/plugins/observability/public/pages/slo_edit/hooks/use_parse_url_state.ts +++ b/x-pack/plugins/observability/public/pages/slo_edit/hooks/use_parse_url_state.ts @@ -7,8 +7,9 @@ import { createKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public'; import { CreateSLOInput } from '@kbn/slo-schema'; +import { RecursivePartial } from '@kbn/utility-types'; import { useHistory } from 'react-router-dom'; -import { transformPartialCreateSLOInputToPartialCreateSLOForm } from '../helpers/process_slo_form_values'; +import { transformPartialUrlStateToFormState } from '../helpers/process_slo_form_values'; import { CreateSLOForm } from '../types'; export function useParseUrlState(): Partial | null { @@ -19,7 +20,7 @@ export function useParseUrlState(): Partial | null { useHashQuery: false, }); - const urlParams = urlStateStorage.get>('_a'); + const urlParams = urlStateStorage.get>('_a'); - return !!urlParams ? transformPartialCreateSLOInputToPartialCreateSLOForm(urlParams) : null; + return !!urlParams ? transformPartialUrlStateToFormState(urlParams) : null; } diff --git a/x-pack/plugins/observability/public/pages/slo_edit/hooks/use_unregister_fields.ts b/x-pack/plugins/observability/public/pages/slo_edit/hooks/use_unregister_fields.ts index 973a023bdae77..c15b5cb7fbbfc 100644 --- a/x-pack/plugins/observability/public/pages/slo_edit/hooks/use_unregister_fields.ts +++ b/x-pack/plugins/observability/public/pages/slo_edit/hooks/use_unregister_fields.ts @@ -32,8 +32,8 @@ export function useUnregisterFields({ isEditMode }: { isEditMode: boolean }) { const [indicatorTypeState, setIndicatorTypeState] = useState( watch('indicator.type') ); - const indicatorType = watch('indicator.type'); + const indicatorType = watch('indicator.type'); useEffect(() => { if (indicatorType !== indicatorTypeState && !isEditMode) { setIndicatorTypeState(indicatorType); diff --git a/x-pack/plugins/observability/public/pages/slo_edit/slo_edit.test.tsx b/x-pack/plugins/observability/public/pages/slo_edit/slo_edit.test.tsx index 06d3285f8945d..c38689200a164 100644 --- a/x-pack/plugins/observability/public/pages/slo_edit/slo_edit.test.tsx +++ b/x-pack/plugins/observability/public/pages/slo_edit/slo_edit.test.tsx @@ -370,7 +370,7 @@ describe('SLO Edit Page', () => { const history = createBrowserHistory(); history.push( - '/slos/create?_a=(name:%27prefilledSloName%27,indicator:(params:(environment:prod,service:cartService),type:sli.apm.transactionDuration))' + '/slos/create?_a=(indicator:(params:(environment:prod,service:cartService),type:sli.apm.transactionDuration))' ); jest.spyOn(Router, 'useHistory').mockReturnValue(history); jest @@ -409,18 +409,14 @@ describe('SLO Edit Page', () => { expect(screen.queryByTestId('sloForm')).toBeTruthy(); expect(screen.queryByTestId('sloEditFormIndicatorSection')).toBeTruthy(); - // Show default values from the kql indicator expect(screen.queryByTestId('sloFormIndicatorTypeSelect')).toHaveValue( 'sli.apm.transactionDuration' ); - - expect(screen.queryByTestId('sloEditFormObjectiveSection')).toBeTruthy(); - expect(screen.queryByTestId('sloEditFormDescriptionSection')).toBeTruthy(); - expect(screen.queryByTestId('apmLatencyServiceSelector')).toHaveTextContent('cartService'); expect(screen.queryByTestId('apmLatencyEnvironmentSelector')).toHaveTextContent('prod'); - expect(screen.queryByTestId('sloFormNameInput')).toHaveValue('prefilledSloName'); + expect(screen.queryByTestId('sloEditFormObjectiveSection')).toBeFalsy(); + expect(screen.queryByTestId('sloEditFormDescriptionSection')).toBeFalsy(); }); }); From 9e8312f2e4cd8be74d535b1aaf71e92bbd05d4f0 Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Fri, 29 Sep 2023 08:25:51 -0600 Subject: [PATCH 13/15] [Dashboard Navigation] Make links panel available under technical preview (#166896) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary This PR wraps up the work the @elastic/kibana-presentation team has done to finish the MVP of [Phase 1](https://github.com/elastic/kibana/issues/154354) of the `Link` embeddable, which enables users to add panels to their dashboard that contain links to other dashboards + external links - with respect to dashboard links, we give the author control over which pieces of context should be kept across dashboards so that things like filter pills, queries, and time ranges are not lost. This marks a huge improvement in dashboard navigation overall, which was previously only available via a variety of different workarounds including (but not limited to): - Creating (essentially) a `noop` dashboard-to-dashboard drilldown - Using markdown panels with hard Dashboard links, which are prone to break across updates - Avoiding navigation all together, which resulted in large, slow-to-load dashboards. As an added benefit, because these panels contain **references** to each dashboard rather than hard links, (1) unlike markdown links, they should not break after updates and (2) if a links panel is exported and imported into another space or instance, all of the dashboards it links to will also be imported. https://github.com/elastic/kibana/assets/8698078/1a86b713-47e7-4db9-8a04-29d41b13681a > **Note** > 🔉 The above video has audio! Turn on your sound for the best experience. ### Note about this PR - A majority of this work was done on a feature branch, with thorough reviews from @andreadelrio on behalf of @elastic/kibana-design along the way. Therefore, while feedback on the design is encouraged, any large concerns brought up in this PR should be filed as separate issues and addressed in follow-up PRs. - This PR contains work for giving embeddables control over their own panel size / default positioning on the dashboard. This was especially important for the links panel, since we assume that (a) most links panels would be located somewhere near the top of the dashboard and (b) the horizontal links panel should have a different default "shape" (longer than it is tall) than the vertical panel (taller than it is long). - This PR also contains work for caching dashboard saved objects, which makes navigation much more seamless. ### Flaky Test Runner - https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/3251 ![image](https://github.com/elastic/kibana/assets/8698078/7616443e-0cb0-43ce-a1d0-41f8bee6cbfc) ### Checklist - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [ ] ~[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials~ This will be addressed in a follow up: https://github.com/elastic/kibana/issues/166750 - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - ~Units tests are added, functional tests are forthcoming~ Edit: All tests are in. - [x] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) - [x] Any UI touched in this PR does not create any new axe failures (run axe in browser: [FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/), [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US)) - [x] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)) - [x] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) ### For maintainers - [ ] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --------- Co-authored-by: Nick Peihl Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Andrea Del Rio Co-authored-by: Devon Thomson Co-authored-by: Nick Peihl Co-authored-by: Gerard Soldevila --- .buildkite/ftr_configs.yml | 1 + .github/CODEOWNERS | 1 + .i18nrc.json | 1 + docs/developer/plugin-list.asciidoc | 4 + package.json | 1 + .../current_mappings.json | 18 + packages/kbn-optimizer/limits.yml | 1 + .../kbn_client/kbn_client_saved_objects.ts | 1 + .../group2/check_registered_types.test.ts | 1 + .../group3/type_registrations.test.ts | 1 + .../group5/dot_kibana_split.test.ts | 1 + .../actions/edit_control_flyout.tsx | 6 +- .../embeddable/control_group_container.tsx | 28 +- src/plugins/dashboard/jest_setup.ts | 12 +- .../clone_panel_action.test.tsx | 12 +- .../dashboard_actions/clone_panel_action.tsx | 30 +- .../expand_panel_action.test.tsx | 12 +- .../export_csv_action.test.tsx | 12 +- .../replace_panel_action.test.tsx | 12 +- .../replace_panel_flyout.tsx | 30 +- .../locator/get_dashboard_locator_params.ts | 47 ++ .../top_nav/dashboard_editing_toolbar.tsx | 20 +- .../dashboard/public/dashboard_constants.ts | 6 + .../_dashboard_container.scss | 1 - .../dashboard_empty_screen.test.tsx | 2 +- .../{panel => grid}/_dashboard_panel.scss | 0 .../component/grid/_index.scss | 1 + .../component/grid/dashboard_grid.test.tsx | 22 +- .../component/grid/dashboard_grid.tsx | 7 +- .../grid/dashboard_grid_item.test.tsx | 2 +- .../component/panel/_index.scss | 1 - .../panel/create_panel_state.test.ts | 83 ---- .../component/panel/create_panel_state.ts | 60 --- .../component/panel_placement/index.ts | 11 + .../place_clone_panel_strategy.ts} | 103 +---- .../place_new_panel_strategies.ts | 104 +++++ .../panel_placement/place_panel.test.ts | 167 +++++++ .../component/panel_placement/place_panel.ts | 60 +++ .../component/panel_placement/types.ts | 41 ++ .../component/viewport/dashboard_viewport.tsx | 4 +- .../embeddable/api/panel_management.ts | 50 +- .../embeddable/create/create_dashboard.ts | 3 +- .../embeddable/dashboard_container.test.tsx | 50 +- .../embeddable/dashboard_container.tsx | 15 +- src/plugins/dashboard/public/index.ts | 2 + src/plugins/dashboard/public/mocks.tsx | 10 +- .../dashboard_content_management.stub.ts | 1 + .../dashboard_content_management_cache.ts | 40 ++ .../dashboard_content_management_service.ts | 10 +- .../lib/delete_dashboards.ts | 10 +- .../lib/find_dashboards.ts | 47 +- .../lib/load_dashboard_state.ts | 32 +- .../lib/save_dashboard_state.ts | 5 +- .../dashboard_content_management/types.ts | 6 +- .../dashboard/public/services/mocks.ts | 16 + .../add_panel_flyout/add_panel_flyout.tsx | 3 +- .../customize_panel_action.tsx | 8 +- .../edit_panel_action/edit_panel_action.ts | 15 +- src/plugins/embeddable/public/index.ts | 1 + .../public/lib/containers/container.ts | 75 +-- .../public/lib/containers/i_container.ts | 8 +- .../default_embeddable_factory_provider.ts | 1 + .../lib/embeddables/embeddable_factory.ts | 19 +- .../server/collectors/management/schema.ts | 4 + .../server/collectors/management/types.ts | 1 + src/plugins/links/README.md | 3 + src/plugins/links/common/constants.ts | 19 + .../common/content_management/cm_services.ts | 21 + .../links/common/content_management/index.ts | 30 ++ .../links/common/content_management/latest.ts | 9 + .../content_management/v1/cm_services.ts | 139 ++++++ .../common/content_management/v1/constants.ts | 19 + .../common/content_management/v1/index.ts | 24 + .../common/content_management/v1/types.ts | 67 +++ .../links/common/embeddable/extract.test.ts | 64 +++ .../links/common/embeddable/extract.ts | 35 ++ src/plugins/links/common/embeddable/index.ts | 10 + .../links/common/embeddable/inject.test.ts | 67 +++ src/plugins/links/common/embeddable/inject.ts | 40 ++ src/plugins/links/common/embeddable/types.ts | 14 + src/plugins/links/common/index.ts | 9 + src/plugins/links/common/mocks.tsx | 53 +++ .../links/common/persistable_state/index.ts | 9 + .../persistable_state/references.test.ts | 163 +++++++ .../common/persistable_state/references.ts | 81 ++++ src/plugins/links/common/types.ts | 19 + src/plugins/links/jest.config.js | 17 + src/plugins/links/jest_setup.ts | 13 + src/plugins/links/kibana.jsonc | 22 + src/plugins/links/public/_mixins.scss | 38 ++ .../dashboard_link_component.test.tsx | 224 +++++++++ .../dashboard_link_component.tsx | 171 +++++++ .../dashboard_link_destination_picker.tsx | 149 ++++++ .../dashboard_link/dashboard_link_strings.ts | 44 ++ .../dashboard_link/dashboard_link_tools.ts | 151 ++++++ .../components/editor/link_destination.tsx | 83 ++++ .../public/components/editor/link_editor.tsx | 188 ++++++++ .../public/components/editor/link_options.tsx | 71 +++ .../components/editor/links_editor.scss | 77 ++++ .../components/editor/links_editor.test.tsx | 121 +++++ .../public/components/editor/links_editor.tsx | 318 +++++++++++++ .../editor/links_editor_empty_prompt.tsx | 41 ++ .../editor/links_editor_single_link.tsx | 185 ++++++++ .../external_link_component.test.tsx | 103 +++++ .../external_link/external_link_component.tsx | 87 ++++ .../external_link_destination_picker.tsx | 85 ++++ .../external_link/external_link_strings.ts | 40 ++ .../external_link/external_link_tools.ts | 35 ++ .../public/components/links_component.scss | 58 +++ .../public/components/links_component.tsx | 75 +++ .../links/public/components/links_strings.ts | 140 ++++++ .../public/components/tooltip_wrapper.tsx | 35 ++ .../duplicate_title_check.ts | 57 +++ .../links/public/content_management/index.ts | 10 + .../links_content_management_client.ts | 67 +++ .../content_management/save_to_library.tsx | 88 ++++ .../public/editor/links_editor_tools.tsx | 30 ++ .../public/editor/open_editor_flyout.tsx | 130 ++++++ .../public/editor/open_link_editor_flyout.tsx | 77 ++++ src/plugins/links/public/embeddable/index.ts | 11 + .../public/embeddable/links_embeddable.tsx | 171 +++++++ .../links_embeddable_factory.test.ts | 46 ++ .../embeddable/links_embeddable_factory.ts | 161 +++++++ .../links/public/embeddable/links_reducers.ts | 27 ++ src/plugins/links/public/embeddable/types.ts | 90 ++++ src/plugins/links/public/index.ts | 16 + src/plugins/links/public/mocks.tsx | 25 + src/plugins/links/public/plugin.ts | 60 +++ .../public/services/attribute_service.ts | 97 ++++ .../links/public/services/kibana_services.ts | 47 ++ .../server/content_management}/index.ts | 2 +- .../content_management/links_storage.ts | 32 ++ src/plugins/links/server/index.ts | 12 + src/plugins/links/server/plugin.ts | 50 ++ .../links/server/saved_objects/index.ts | 9 + .../links/server/saved_objects/links.ts | 41 ++ src/plugins/links/tsconfig.json | 34 ++ src/plugins/presentation_util/common/labs.ts | 32 +- .../dashboard_drilldown_options.tsx | 55 +++ .../dashboard_drilldown_options/types.ts | 20 + .../public/components/index.tsx | 18 + .../i18n/dashboard_drilldown_config.tsx | 29 ++ src/plugins/presentation_util/public/index.ts | 3 + src/plugins/telemetry/schema/oss_plugins.json | 6 + .../url_drilldown/components/index.ts | 5 +- .../url_drilldown_collect_config/i18n.ts | 2 +- .../url_drilldown_collect_config/index.ts | 2 +- .../url_drilldown_collect_config/lazy.tsx | 15 + .../test_samples/demo.tsx | 1 + .../url_drilldown_collect_config.tsx | 46 +- .../url_drilldown_options.tsx | 58 +++ .../drilldowns/url_drilldown/constants.ts | 14 + .../public/drilldowns/url_drilldown/index.ts | 10 +- .../public/drilldowns/url_drilldown/types.ts | 8 +- .../ui_actions_enhanced/public/index.ts | 3 + .../apps/dashboard_elements/links/config.ts | 38 ++ .../apps/dashboard_elements/links/index.ts | 45 ++ .../links/links_create_edit.ts | 152 ++++++ .../links/links_navigation.ts | 222 +++++++++ .../dashboard/current/kibana.json | 435 +++++++++++++++++- .../functional/page_objects/dashboard_page.ts | 6 +- .../page_objects/dashboard_page_links.ts | 191 ++++++++ test/functional/page_objects/index.ts | 2 + .../dashboard_embed_mode_scrolling.png | Bin 92765 -> 83762 bytes test/tsconfig.json | 3 +- tsconfig.base.json | 2 + .../canvas/common/lib/embeddable_dataurl.ts | 6 +- .../embeddable_flyout/flyout.component.tsx | 6 +- .../editor_menu/editor_menu.tsx | 2 +- .../dashboard_drilldown_persistable_state.ts | 6 +- .../drilldowns/dashboard_drilldown/index.ts | 2 +- .../drilldowns/dashboard_drilldown/types.ts | 10 +- .../plugins/dashboard_enhanced/kibana.jsonc | 8 +- .../abstract_dashboard_drilldown.tsx | 12 +- .../components/collect_config_container.tsx | 26 +- .../dashboard_drilldown_config.story.tsx | 64 --- .../dashboard_drilldown_config.tsx | 68 +-- .../dashboard_drilldown_config/i18n.ts | 21 - .../abstract_dashboard_drilldown/types.ts | 4 +- .../embeddable_to_dashboard_drilldown.tsx | 44 +- .../plugins/dashboard_enhanced/tsconfig.json | 15 +- .../public/lib/url_drilldown.test.ts | 8 + .../translations/translations/fr-FR.json | 3 - .../translations/translations/ja-JP.json | 3 - .../translations/translations/zh-CN.json | 3 - .../exports/serverless_dashboard_8_11.ndjson | 8 +- yarn.lock | 4 + 187 files changed, 7203 insertions(+), 758 deletions(-) create mode 100644 src/plugins/dashboard/public/dashboard_app/locator/get_dashboard_locator_params.ts rename src/plugins/dashboard/public/dashboard_container/component/{panel => grid}/_dashboard_panel.scss (100%) delete mode 100644 src/plugins/dashboard/public/dashboard_container/component/panel/_index.scss delete mode 100644 src/plugins/dashboard/public/dashboard_container/component/panel/create_panel_state.test.ts delete mode 100644 src/plugins/dashboard/public/dashboard_container/component/panel/create_panel_state.ts create mode 100644 src/plugins/dashboard/public/dashboard_container/component/panel_placement/index.ts rename src/plugins/dashboard/public/dashboard_container/component/{panel/dashboard_panel_placement.ts => panel_placement/place_clone_panel_strategy.ts} (53%) create mode 100644 src/plugins/dashboard/public/dashboard_container/component/panel_placement/place_new_panel_strategies.ts create mode 100644 src/plugins/dashboard/public/dashboard_container/component/panel_placement/place_panel.test.ts create mode 100644 src/plugins/dashboard/public/dashboard_container/component/panel_placement/place_panel.ts create mode 100644 src/plugins/dashboard/public/dashboard_container/component/panel_placement/types.ts create mode 100644 src/plugins/dashboard/public/services/dashboard_content_management/dashboard_content_management_cache.ts create mode 100644 src/plugins/dashboard/public/services/mocks.ts create mode 100644 src/plugins/links/README.md create mode 100644 src/plugins/links/common/constants.ts create mode 100644 src/plugins/links/common/content_management/cm_services.ts create mode 100644 src/plugins/links/common/content_management/index.ts create mode 100644 src/plugins/links/common/content_management/latest.ts create mode 100644 src/plugins/links/common/content_management/v1/cm_services.ts create mode 100644 src/plugins/links/common/content_management/v1/constants.ts create mode 100644 src/plugins/links/common/content_management/v1/index.ts create mode 100644 src/plugins/links/common/content_management/v1/types.ts create mode 100644 src/plugins/links/common/embeddable/extract.test.ts create mode 100644 src/plugins/links/common/embeddable/extract.ts create mode 100644 src/plugins/links/common/embeddable/index.ts create mode 100644 src/plugins/links/common/embeddable/inject.test.ts create mode 100644 src/plugins/links/common/embeddable/inject.ts create mode 100644 src/plugins/links/common/embeddable/types.ts create mode 100644 src/plugins/links/common/index.ts create mode 100644 src/plugins/links/common/mocks.tsx create mode 100644 src/plugins/links/common/persistable_state/index.ts create mode 100644 src/plugins/links/common/persistable_state/references.test.ts create mode 100644 src/plugins/links/common/persistable_state/references.ts create mode 100644 src/plugins/links/common/types.ts create mode 100644 src/plugins/links/jest.config.js create mode 100644 src/plugins/links/jest_setup.ts create mode 100644 src/plugins/links/kibana.jsonc create mode 100644 src/plugins/links/public/_mixins.scss create mode 100644 src/plugins/links/public/components/dashboard_link/dashboard_link_component.test.tsx create mode 100644 src/plugins/links/public/components/dashboard_link/dashboard_link_component.tsx create mode 100644 src/plugins/links/public/components/dashboard_link/dashboard_link_destination_picker.tsx create mode 100644 src/plugins/links/public/components/dashboard_link/dashboard_link_strings.ts create mode 100644 src/plugins/links/public/components/dashboard_link/dashboard_link_tools.ts create mode 100644 src/plugins/links/public/components/editor/link_destination.tsx create mode 100644 src/plugins/links/public/components/editor/link_editor.tsx create mode 100644 src/plugins/links/public/components/editor/link_options.tsx create mode 100644 src/plugins/links/public/components/editor/links_editor.scss create mode 100644 src/plugins/links/public/components/editor/links_editor.test.tsx create mode 100644 src/plugins/links/public/components/editor/links_editor.tsx create mode 100644 src/plugins/links/public/components/editor/links_editor_empty_prompt.tsx create mode 100644 src/plugins/links/public/components/editor/links_editor_single_link.tsx create mode 100644 src/plugins/links/public/components/external_link/external_link_component.test.tsx create mode 100644 src/plugins/links/public/components/external_link/external_link_component.tsx create mode 100644 src/plugins/links/public/components/external_link/external_link_destination_picker.tsx create mode 100644 src/plugins/links/public/components/external_link/external_link_strings.ts create mode 100644 src/plugins/links/public/components/external_link/external_link_tools.ts create mode 100644 src/plugins/links/public/components/links_component.scss create mode 100644 src/plugins/links/public/components/links_component.tsx create mode 100644 src/plugins/links/public/components/links_strings.ts create mode 100644 src/plugins/links/public/components/tooltip_wrapper.tsx create mode 100644 src/plugins/links/public/content_management/duplicate_title_check.ts create mode 100644 src/plugins/links/public/content_management/index.ts create mode 100644 src/plugins/links/public/content_management/links_content_management_client.ts create mode 100644 src/plugins/links/public/content_management/save_to_library.tsx create mode 100644 src/plugins/links/public/editor/links_editor_tools.tsx create mode 100644 src/plugins/links/public/editor/open_editor_flyout.tsx create mode 100644 src/plugins/links/public/editor/open_link_editor_flyout.tsx create mode 100644 src/plugins/links/public/embeddable/index.ts create mode 100644 src/plugins/links/public/embeddable/links_embeddable.tsx create mode 100644 src/plugins/links/public/embeddable/links_embeddable_factory.test.ts create mode 100644 src/plugins/links/public/embeddable/links_embeddable_factory.ts create mode 100644 src/plugins/links/public/embeddable/links_reducers.ts create mode 100644 src/plugins/links/public/embeddable/types.ts create mode 100644 src/plugins/links/public/index.ts create mode 100644 src/plugins/links/public/mocks.tsx create mode 100644 src/plugins/links/public/plugin.ts create mode 100644 src/plugins/links/public/services/attribute_service.ts create mode 100644 src/plugins/links/public/services/kibana_services.ts rename src/plugins/{dashboard/public/dashboard_container/component/panel => links/server/content_management}/index.ts (86%) create mode 100644 src/plugins/links/server/content_management/links_storage.ts create mode 100644 src/plugins/links/server/index.ts create mode 100644 src/plugins/links/server/plugin.ts create mode 100644 src/plugins/links/server/saved_objects/index.ts create mode 100644 src/plugins/links/server/saved_objects/links.ts create mode 100644 src/plugins/links/tsconfig.json create mode 100644 src/plugins/presentation_util/public/components/dashboard_drilldown_options/dashboard_drilldown_options.tsx create mode 100644 src/plugins/presentation_util/public/components/dashboard_drilldown_options/types.ts create mode 100644 src/plugins/presentation_util/public/i18n/dashboard_drilldown_config.tsx create mode 100644 src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_options.tsx create mode 100644 src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/constants.ts create mode 100644 test/functional/apps/dashboard_elements/links/config.ts create mode 100644 test/functional/apps/dashboard_elements/links/index.ts create mode 100644 test/functional/apps/dashboard_elements/links/links_create_edit.ts create mode 100644 test/functional/apps/dashboard_elements/links/links_navigation.ts create mode 100644 test/functional/page_objects/dashboard_page_links.ts delete mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/components/dashboard_drilldown_config/dashboard_drilldown_config.story.tsx diff --git a/.buildkite/ftr_configs.yml b/.buildkite/ftr_configs.yml index 3b1e56ddc9010..0afb3e5c4ee68 100644 --- a/.buildkite/ftr_configs.yml +++ b/.buildkite/ftr_configs.yml @@ -100,6 +100,7 @@ enabled: - test/functional/apps/dashboard_elements/controls/options_list/config.ts - test/functional/apps/dashboard_elements/image_embeddable/config.ts - test/functional/apps/dashboard_elements/input_control_vis/config.ts + - test/functional/apps/dashboard_elements/links/config.ts - test/functional/apps/dashboard_elements/markdown/config.ts - test/functional/apps/dashboard/group1/config.ts - test/functional/apps/dashboard/group2/config.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index c9f1926a01d21..7f8a9fce8b716 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -470,6 +470,7 @@ x-pack/plugins/lens @elastic/kibana-visualizations x-pack/plugins/license_api_guard @elastic/platform-deployment-management x-pack/plugins/license_management @elastic/platform-deployment-management x-pack/plugins/licensing @elastic/kibana-core +src/plugins/links @elastic/kibana-presentation packages/kbn-lint-packages-cli @elastic/kibana-operations packages/kbn-lint-ts-projects-cli @elastic/kibana-operations x-pack/plugins/lists @elastic/security-detection-engine diff --git a/.i18nrc.json b/.i18nrc.json index 4657840019f6c..266db6c1f1577 100644 --- a/.i18nrc.json +++ b/.i18nrc.json @@ -83,6 +83,7 @@ ], "monaco": "packages/kbn-monaco/src", "navigation": "src/plugins/navigation", + "links": "src/plugins/links", "newsfeed": "src/plugins/newsfeed", "presentationUtil": "src/plugins/presentation_util", "randomSampling": "x-pack/packages/kbn-random-sampling", diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index ccea77d906970..40a5135d25a5b 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -238,6 +238,10 @@ in Kibana, e.g. visualizations. It has the form of a flyout panel. |Utilities for building Kibana plugins. +|{kib-repo}blob/{branch}/src/plugins/links/README.md[links] +|This plugin adds the Links panel which allows authors to create hard links to navigate on click and bring all context from the source dashboard to the destination dashboard. + + |{kib-repo}blob/{branch}/src/plugins/management/README.md[management] |This plugins contains the "Stack Management" page framework. It offers navigation and an API to link individual management section into it. This plugin does not contain any individual diff --git a/package.json b/package.json index f2d5295783e26..6ea5ef3486efe 100644 --- a/package.json +++ b/package.json @@ -496,6 +496,7 @@ "@kbn/license-api-guard-plugin": "link:x-pack/plugins/license_api_guard", "@kbn/license-management-plugin": "link:x-pack/plugins/license_management", "@kbn/licensing-plugin": "link:x-pack/plugins/licensing", + "@kbn/links-plugin": "link:src/plugins/links", "@kbn/lists-plugin": "link:x-pack/plugins/lists", "@kbn/locator-examples-plugin": "link:examples/locator_examples", "@kbn/locator-explorer-plugin": "link:examples/locator_explorer", diff --git a/packages/kbn-check-mappings-update-cli/current_mappings.json b/packages/kbn-check-mappings-update-cli/current_mappings.json index d23a717c01e49..c18777c8b052d 100644 --- a/packages/kbn-check-mappings-update-cli/current_mappings.json +++ b/packages/kbn-check-mappings-update-cli/current_mappings.json @@ -1062,6 +1062,24 @@ } } }, + "links": { + "dynamic": false, + "properties": { + "id": { + "type": "text" + }, + "title": { + "type": "text" + }, + "description": { + "type": "text" + }, + "links": { + "dynamic": false, + "properties": {} + } + } + }, "lens": { "properties": { "title": { diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 9b12708a506fc..c319208456963 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -87,6 +87,7 @@ pageLoadAssetSize: lens: 38000 licenseManagement: 41817 licensing: 29004 + links: 44490 lists: 22900 logExplorer: 39045 logsShared: 281060 diff --git a/packages/kbn-test/src/kbn_client/kbn_client_saved_objects.ts b/packages/kbn-test/src/kbn_client/kbn_client_saved_objects.ts index c64c9d7a543aa..63e60a0eb19e4 100644 --- a/packages/kbn-test/src/kbn_client/kbn_client_saved_objects.ts +++ b/packages/kbn-test/src/kbn_client/kbn_client_saved_objects.ts @@ -96,6 +96,7 @@ const STANDARD_LIST_TYPES = [ 'dashboard', 'search', 'lens', + 'links', 'map', 'cases', // synthetics based objects diff --git a/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts b/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts index b624d4871a1b6..ec5fd0ca2add5 100644 --- a/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts +++ b/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts @@ -113,6 +113,7 @@ describe('checking migration metadata changes on all registered SO types', () => "legacy-url-alias": "9b8cca3fbb2da46fd12823d3cd38fdf1c9f24bc8", "lens": "5cfa2c52b979b4f8df56dd13c477e152183468b9", "lens-ui-telemetry": "8c47a9e393861f76e268345ecbadfc8a5fb1e0bd", + "links": "39117a08966e9082d0f47b0b2e7e508499fc1e6d", "maintenance-window": "d893544460abad56ff7a0e25b78f78776dfe10d1", "map": "76c71023bd198fb6b1163b31bafd926fe2ceb9da", "metrics-data-source": "81b69dc9830699d9ead5ac8dcb9264612e2a3c89", diff --git a/src/core/server/integration_tests/saved_objects/migrations/group3/type_registrations.test.ts b/src/core/server/integration_tests/saved_objects/migrations/group3/type_registrations.test.ts index 1809437f3cdcd..efb439e058cc2 100644 --- a/src/core/server/integration_tests/saved_objects/migrations/group3/type_registrations.test.ts +++ b/src/core/server/integration_tests/saved_objects/migrations/group3/type_registrations.test.ts @@ -81,6 +81,7 @@ const previouslyRegisteredTypes = [ 'legacy-url-alias', 'lens', 'lens-ui-telemetry', + 'links', 'maintenance-window', 'map', 'maps-telemetry', diff --git a/src/core/server/integration_tests/saved_objects/migrations/group5/dot_kibana_split.test.ts b/src/core/server/integration_tests/saved_objects/migrations/group5/dot_kibana_split.test.ts index 25dc5a46a6793..6a4bc23a814b1 100644 --- a/src/core/server/integration_tests/saved_objects/migrations/group5/dot_kibana_split.test.ts +++ b/src/core/server/integration_tests/saved_objects/migrations/group5/dot_kibana_split.test.ts @@ -438,6 +438,7 @@ describe('split .kibana index into multiple system indices', () => { "legacy-url-alias", "lens", "lens-ui-telemetry", + "links", "maintenance-window", "map", "metrics-data-source", diff --git a/src/plugins/controls/public/control_group/actions/edit_control_flyout.tsx b/src/plugins/controls/public/control_group/actions/edit_control_flyout.tsx index 95fcda036c329..9b54d83e3fb68 100644 --- a/src/plugins/controls/public/control_group/actions/edit_control_flyout.tsx +++ b/src/plugins/controls/public/control_group/actions/edit_control_flyout.tsx @@ -92,7 +92,11 @@ export const EditControlFlyout = ({ } closeFlyout(); - await controlGroup.replaceEmbeddable(embeddable.id, inputToReturn, type); + if (panel.type === type) { + controlGroup.updateInputForChild(embeddable.id, inputToReturn); + } else { + await controlGroup.replaceEmbeddable(embeddable.id, inputToReturn, type); + } }; return ( diff --git a/src/plugins/controls/public/control_group/embeddable/control_group_container.tsx b/src/plugins/controls/public/control_group/embeddable/control_group_container.tsx index 6344c768eae63..2bf18a70a97e7 100644 --- a/src/plugins/controls/public/control_group/embeddable/control_group_container.tsx +++ b/src/plugins/controls/public/control_group/embeddable/control_group_container.tsx @@ -222,22 +222,22 @@ export class ControlGroupContainer extends Container< public async addDataControlFromField(controlProps: AddDataControlProps) { const panelState = await getDataControlPanelState(this.getInput(), controlProps); - return this.createAndSaveEmbeddable(panelState.type, panelState); + return this.createAndSaveEmbeddable(panelState.type, panelState, this.getInput().panels); } public addOptionsListControl(controlProps: AddOptionsListControlProps) { const panelState = getOptionsListPanelState(this.getInput(), controlProps); - return this.createAndSaveEmbeddable(panelState.type, panelState); + return this.createAndSaveEmbeddable(panelState.type, panelState, this.getInput().panels); } public addRangeSliderControl(controlProps: AddRangeSliderControlProps) { const panelState = getRangeSliderPanelState(this.getInput(), controlProps); - return this.createAndSaveEmbeddable(panelState.type, panelState); + return this.createAndSaveEmbeddable(panelState.type, panelState, this.getInput().panels); } public addTimeSliderControl() { const panelState = getTimeSliderPanelState(this.getInput()); - return this.createAndSaveEmbeddable(panelState.type, panelState); + return this.createAndSaveEmbeddable(panelState.type, panelState, this.getInput().panels); } public openAddDataControlFlyout = openAddDataControlFlyout; @@ -283,15 +283,19 @@ export class ControlGroupContainer extends Container< protected createNewPanelState( factory: EmbeddableFactory, - partial: Partial = {} - ): ControlPanelState { - const panelState = super.createNewPanelState(factory, partial); + partial: Partial = {}, + otherPanels: ControlGroupInput['panels'] + ) { + const { newPanel } = super.createNewPanelState(factory, partial); return { - order: getNextPanelOrder(this.getInput().panels), - width: this.getInput().defaultControlWidth, - grow: this.getInput().defaultControlGrow, - ...panelState, - } as ControlPanelState; + newPanel: { + order: getNextPanelOrder(this.getInput().panels), + width: this.getInput().defaultControlWidth, + grow: this.getInput().defaultControlGrow, + ...newPanel, + } as ControlPanelState, + otherPanels, + }; } protected onRemoveEmbeddable(idToRemove: string) { diff --git a/src/plugins/dashboard/jest_setup.ts b/src/plugins/dashboard/jest_setup.ts index 5683ecd4e288b..68be9b5227e49 100644 --- a/src/plugins/dashboard/jest_setup.ts +++ b/src/plugins/dashboard/jest_setup.ts @@ -6,7 +6,13 @@ * Side Public License, v 1. */ -import { pluginServices } from './public/services/plugin_services'; -import { registry } from './public/services/plugin_services.stub'; +import { setStubDashboardServices } from './public/services/mocks'; -pluginServices.setRegistry(registry.start({})); +/** + * CAUTION: Be very mindful of the things you import in to this `jest_setup` file - anything that is imported + * here (either directly or implicitly through dependencies) will be **unable** to be mocked elsewhere! + * + * Refer to the "Caution" section here: + * https://jestjs.io/docs/jest-object#jestmockmodulename-factory-options + */ +setStubDashboardServices(); diff --git a/src/plugins/dashboard/public/dashboard_actions/clone_panel_action.test.tsx b/src/plugins/dashboard/public/dashboard_actions/clone_panel_action.test.tsx index 5ec0ac57c574b..76b62f28993ad 100644 --- a/src/plugins/dashboard/public/dashboard_actions/clone_panel_action.test.tsx +++ b/src/plugins/dashboard/public/dashboard_actions/clone_panel_action.test.tsx @@ -47,11 +47,13 @@ beforeEach(async () => { .fn() .mockReturnValue(mockEmbeddableFactory); container = buildMockDashboard({ - panels: { - '123': getSampleDashboardPanel({ - explicitInput: { firstName: 'Kibanana', id: '123' }, - type: CONTACT_CARD_EMBEDDABLE, - }), + overrides: { + panels: { + '123': getSampleDashboardPanel({ + explicitInput: { firstName: 'Kibanana', id: '123' }, + type: CONTACT_CARD_EMBEDDABLE, + }), + }, }, }); diff --git a/src/plugins/dashboard/public/dashboard_actions/clone_panel_action.tsx b/src/plugins/dashboard/public/dashboard_actions/clone_panel_action.tsx index 8b3e9545114ff..273a7c3040a36 100644 --- a/src/plugins/dashboard/public/dashboard_actions/clone_panel_action.tsx +++ b/src/plugins/dashboard/public/dashboard_actions/clone_panel_action.tsx @@ -22,10 +22,9 @@ import { Action, IncompatibleActionError } from '@kbn/ui-actions-plugin/public'; import { type DashboardPanelState } from '../../common'; import { pluginServices } from '../services/plugin_services'; -import { createPanelState } from '../dashboard_container/component/panel'; import { dashboardClonePanelActionStrings } from './_dashboard_actions_strings'; +import { placeClonePanel } from '../dashboard_container/component/panel_placement'; import { DASHBOARD_CONTAINER_TYPE, type DashboardContainer } from '../dashboard_container'; -import { placePanelBeside } from '../dashboard_container/component/panel/dashboard_panel_placement'; export const ACTION_CLONE_PANEL = 'clonePanel'; @@ -82,6 +81,7 @@ export class ClonePanelAction implements Action { throw new PanelNotFoundError(); } + // Clone panel input const clonedPanelState: PanelState = await (async () => { const newTitle = await this.getCloneTitle(embeddable, embeddable.getTitle() || ''); const id = uuidv4(); @@ -110,18 +110,20 @@ export class ClonePanelAction implements Action { 'data-test-subj': 'addObjectToContainerSuccess', }); - const { otherPanels, newPanel } = createPanelState( - clonedPanelState, - dashboard.getInput().panels, - placePanelBeside, - { - width: panelToClone.gridData.w, - height: panelToClone.gridData.h, - currentPanels: dashboard.getInput().panels, - placeBesideId: panelToClone.explicitInput.id, - scrollToPanel: true, - } - ); + const { newPanelPlacement, otherPanels } = placeClonePanel({ + width: panelToClone.gridData.w, + height: panelToClone.gridData.h, + currentPanels: dashboard.getInput().panels, + placeBesideId: panelToClone.explicitInput.id, + }); + + const newPanel = { + ...clonedPanelState, + gridData: { + ...newPanelPlacement, + i: clonedPanelState.explicitInput.id, + }, + }; dashboard.updateInput({ panels: { diff --git a/src/plugins/dashboard/public/dashboard_actions/expand_panel_action.test.tsx b/src/plugins/dashboard/public/dashboard_actions/expand_panel_action.test.tsx index 877488c6d8041..194edc675b108 100644 --- a/src/plugins/dashboard/public/dashboard_actions/expand_panel_action.test.tsx +++ b/src/plugins/dashboard/public/dashboard_actions/expand_panel_action.test.tsx @@ -31,11 +31,13 @@ pluginServices.getServices().embeddable.getEmbeddableFactory = jest beforeEach(async () => { container = buildMockDashboard({ - panels: { - '123': getSampleDashboardPanel({ - explicitInput: { firstName: 'Sam', id: '123' }, - type: CONTACT_CARD_EMBEDDABLE, - }), + overrides: { + panels: { + '123': getSampleDashboardPanel({ + explicitInput: { firstName: 'Sam', id: '123' }, + type: CONTACT_CARD_EMBEDDABLE, + }), + }, }, }); diff --git a/src/plugins/dashboard/public/dashboard_actions/export_csv_action.test.tsx b/src/plugins/dashboard/public/dashboard_actions/export_csv_action.test.tsx index 350db8fad40b1..0fbbe9c76b2cf 100644 --- a/src/plugins/dashboard/public/dashboard_actions/export_csv_action.test.tsx +++ b/src/plugins/dashboard/public/dashboard_actions/export_csv_action.test.tsx @@ -46,11 +46,13 @@ describe('Export CSV action', () => { }; container = buildMockDashboard({ - panels: { - '123': getSampleDashboardPanel({ - explicitInput: { firstName: 'Kibanana', id: '123' }, - type: CONTACT_CARD_EXPORTABLE_EMBEDDABLE, - }), + overrides: { + panels: { + '123': getSampleDashboardPanel({ + explicitInput: { firstName: 'Kibanana', id: '123' }, + type: CONTACT_CARD_EXPORTABLE_EMBEDDABLE, + }), + }, }, }); diff --git a/src/plugins/dashboard/public/dashboard_actions/replace_panel_action.test.tsx b/src/plugins/dashboard/public/dashboard_actions/replace_panel_action.test.tsx index 0829f89424ede..5873253e105d4 100644 --- a/src/plugins/dashboard/public/dashboard_actions/replace_panel_action.test.tsx +++ b/src/plugins/dashboard/public/dashboard_actions/replace_panel_action.test.tsx @@ -29,11 +29,13 @@ let container: DashboardContainer; let embeddable: ContactCardEmbeddable; beforeEach(async () => { container = buildMockDashboard({ - panels: { - '123': getSampleDashboardPanel({ - explicitInput: { firstName: 'Sam', id: '123' }, - type: CONTACT_CARD_EMBEDDABLE, - }), + overrides: { + panels: { + '123': getSampleDashboardPanel({ + explicitInput: { firstName: 'Sam', id: '123' }, + type: CONTACT_CARD_EMBEDDABLE, + }), + }, }, }); diff --git a/src/plugins/dashboard/public/dashboard_actions/replace_panel_flyout.tsx b/src/plugins/dashboard/public/dashboard_actions/replace_panel_flyout.tsx index 14067f0b6aa68..6f93b08a2708f 100644 --- a/src/plugins/dashboard/public/dashboard_actions/replace_panel_flyout.tsx +++ b/src/plugins/dashboard/public/dashboard_actions/replace_panel_flyout.tsx @@ -18,7 +18,6 @@ import { } from '@kbn/embeddable-plugin/public'; import { Toast } from '@kbn/core/public'; -import { DashboardPanelState } from '../../common'; import { pluginServices } from '../services/plugin_services'; import { dashboardReplacePanelActionStrings } from './_dashboard_actions_strings'; import { DashboardContainer } from '../dashboard_container'; @@ -58,30 +57,15 @@ export class ReplacePanelFlyout extends React.Component { public onReplacePanel = async (savedObjectId: string, type: string, name: string) => { const { panelToRemove, container } = this.props; - const { w, h, x, y } = (container.getInput().panels[panelToRemove.id] as DashboardPanelState) - .gridData; - const { id } = await container.addNewEmbeddable(type, { - savedObjectId, - }); - - const { [panelToRemove.id]: omit, ...panels } = container.getInput().panels; - - container.updateInput({ - panels: { - ...panels, - [id]: { - ...panels[id], - gridData: { - ...(panels[id] as DashboardPanelState).gridData, - w, - h, - x, - y, - }, - } as DashboardPanelState, + const id = await container.replaceEmbeddable( + panelToRemove.id, + { + savedObjectId, }, - }); + type, + true + ); (container as DashboardContainer).setHighlightPanelId(id); this.showToast(name); diff --git a/src/plugins/dashboard/public/dashboard_app/locator/get_dashboard_locator_params.ts b/src/plugins/dashboard/public/dashboard_app/locator/get_dashboard_locator_params.ts new file mode 100644 index 0000000000000..f2805d10c29da --- /dev/null +++ b/src/plugins/dashboard/public/dashboard_app/locator/get_dashboard_locator_params.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { isQuery, isTimeRange } from '@kbn/data-plugin/common'; +import { Filter, isFilterPinned, Query, TimeRange } from '@kbn/es-query'; +import { EmbeddableInput, IEmbeddable } from '@kbn/embeddable-plugin/public'; +import { DashboardDrilldownOptions } from '@kbn/presentation-util-plugin/public'; + +import { DashboardAppLocatorParams } from './locator'; + +interface EmbeddableQueryInput extends EmbeddableInput { + query?: Query; + filters?: Filter[]; + timeRange?: TimeRange; +} + +export const getEmbeddableParams = ( + source: IEmbeddable, + options: DashboardDrilldownOptions +): Partial => { + const params: DashboardAppLocatorParams = {}; + + const input = source.getInput(); + if (isQuery(input.query) && options.useCurrentFilters) { + params.query = input.query; + } + + // if useCurrentDashboardDataRange is enabled, then preserve current time range + // if undefined is passed, then destination dashboard will figure out time range itself + // for brush event this time range would be overwritten + if (isTimeRange(input.timeRange) && options.useCurrentDateRange) { + params.timeRange = input.timeRange; + } + + // if useCurrentDashboardFilters enabled, then preserve all the filters (pinned, unpinned, and from controls) + // otherwise preserve only pinned + params.filters = options.useCurrentFilters + ? input.filters + : input.filters?.filter((f) => isFilterPinned(f)); + + return params; +}; diff --git a/src/plugins/dashboard/public/dashboard_app/top_nav/dashboard_editing_toolbar.tsx b/src/plugins/dashboard/public/dashboard_app/top_nav/dashboard_editing_toolbar.tsx index 71ee49eed260b..63dd1d96d1169 100644 --- a/src/plugins/dashboard/public/dashboard_app/top_nav/dashboard_editing_toolbar.tsx +++ b/src/plugins/dashboard/public/dashboard_app/top_nav/dashboard_editing_toolbar.tsx @@ -12,8 +12,9 @@ import { METRIC_TYPE } from '@kbn/analytics'; import { useEuiTheme } from '@elastic/eui'; import { AddFromLibraryButton, Toolbar, ToolbarButton } from '@kbn/shared-ux-button-toolbar'; -import { EmbeddableFactory } from '@kbn/embeddable-plugin/public'; +import { EmbeddableFactory, EmbeddableInput } from '@kbn/embeddable-plugin/public'; import { BaseVisType, VisTypeAlias } from '@kbn/visualizations-plugin/public'; +import { isExplicitInputWithAttributes } from '@kbn/embeddable-plugin/public'; import { getCreateVisualizationButtonTitle } from '../_dashboard_app_strings'; import { EditorMenu } from './editor_menu'; @@ -83,15 +84,26 @@ export function DashboardEditingToolbar({ isDisabled }: { isDisabled?: boolean } trackUiMetric(METRIC_TYPE.CLICK, embeddableFactory.type); } - let explicitInput: Awaited>; + let explicitInput: Partial; + let attributes: unknown; try { - explicitInput = await embeddableFactory.getExplicitInput(); + const explicitInputReturn = await embeddableFactory.getExplicitInput(undefined, dashboard); + if (isExplicitInputWithAttributes(explicitInputReturn)) { + explicitInput = explicitInputReturn.newInput; + attributes = explicitInputReturn.attributes; + } else { + explicitInput = explicitInputReturn; + } } catch (e) { // error likely means user canceled embeddable creation return; } - const newEmbeddable = await dashboard.addNewEmbeddable(embeddableFactory.type, explicitInput); + const newEmbeddable = await dashboard.addNewEmbeddable( + embeddableFactory.type, + explicitInput, + attributes + ); if (newEmbeddable) { dashboard.setScrollToPanelId(newEmbeddable.id); diff --git a/src/plugins/dashboard/public/dashboard_constants.ts b/src/plugins/dashboard/public/dashboard_constants.ts index 572d1b9d0f11a..1764f55a176e7 100644 --- a/src/plugins/dashboard/public/dashboard_constants.ts +++ b/src/plugins/dashboard/public/dashboard_constants.ts @@ -65,8 +65,14 @@ export const DEFAULT_PANEL_WIDTH = DASHBOARD_GRID_COLUMN_COUNT / 2; export const CHANGE_CHECK_DEBOUNCE = 100; +// ------------------------------------------------------------------ +// Content Management +// ------------------------------------------------------------------ export { CONTENT_ID as DASHBOARD_CONTENT_ID } from '../common/content_management/constants'; +export const DASHBOARD_CACHE_SIZE = 20; // only store a max of 20 dashboards +export const DASHBOARD_CACHE_TTL = 1000 * 60 * 5; // time to live = 5 minutes + // ------------------------------------------------------------------ // Default State // ------------------------------------------------------------------ diff --git a/src/plugins/dashboard/public/dashboard_container/_dashboard_container.scss b/src/plugins/dashboard/public/dashboard_container/_dashboard_container.scss index aa5b5950a0d59..12c11f778d616 100644 --- a/src/plugins/dashboard/public/dashboard_container/_dashboard_container.scss +++ b/src/plugins/dashboard/public/dashboard_container/_dashboard_container.scss @@ -1,7 +1,6 @@ @import '../../../embeddable/public/variables'; @import './component/grid/index'; -@import './component/panel/index'; @import './component/viewport/index'; .dashboardContainer, .dashboardViewport { diff --git a/src/plugins/dashboard/public/dashboard_container/component/empty_screen/dashboard_empty_screen.test.tsx b/src/plugins/dashboard/public/dashboard_container/component/empty_screen/dashboard_empty_screen.test.tsx index 439fc43ce8eb0..fb2f6e2f16b28 100644 --- a/src/plugins/dashboard/public/dashboard_container/component/empty_screen/dashboard_empty_screen.test.tsx +++ b/src/plugins/dashboard/public/dashboard_container/component/empty_screen/dashboard_empty_screen.test.tsx @@ -22,7 +22,7 @@ pluginServices.getServices().visualizations.getAliases = jest describe('DashboardEmptyScreen', () => { function mountComponent(viewMode: ViewMode) { - const dashboardContainer = buildMockDashboard({ viewMode }); + const dashboardContainer = buildMockDashboard({ overrides: { viewMode } }); return mountWithIntl( diff --git a/src/plugins/dashboard/public/dashboard_container/component/panel/_dashboard_panel.scss b/src/plugins/dashboard/public/dashboard_container/component/grid/_dashboard_panel.scss similarity index 100% rename from src/plugins/dashboard/public/dashboard_container/component/panel/_dashboard_panel.scss rename to src/plugins/dashboard/public/dashboard_container/component/grid/_dashboard_panel.scss diff --git a/src/plugins/dashboard/public/dashboard_container/component/grid/_index.scss b/src/plugins/dashboard/public/dashboard_container/component/grid/_index.scss index eb393d7603b8a..cb324e984f7ef 100644 --- a/src/plugins/dashboard/public/dashboard_container/component/grid/_index.scss +++ b/src/plugins/dashboard/public/dashboard_container/component/grid/_index.scss @@ -1 +1,2 @@ @import './dashboard_grid'; +@import './dashboard_panel'; \ No newline at end of file diff --git a/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid.test.tsx b/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid.test.tsx index 6c8a123d19588..93f25962a0916 100644 --- a/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid.test.tsx +++ b/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid.test.tsx @@ -46,16 +46,18 @@ jest.mock('./dashboard_grid_item', () => { const createAndMountDashboardGrid = () => { const dashboardContainer = buildMockDashboard({ - panels: { - '1': { - gridData: { x: 0, y: 0, w: 6, h: 6, i: '1' }, - type: CONTACT_CARD_EMBEDDABLE, - explicitInput: { id: '1' }, - }, - '2': { - gridData: { x: 6, y: 6, w: 6, h: 6, i: '2' }, - type: CONTACT_CARD_EMBEDDABLE, - explicitInput: { id: '2' }, + overrides: { + panels: { + '1': { + gridData: { x: 0, y: 0, w: 6, h: 6, i: '1' }, + type: CONTACT_CARD_EMBEDDABLE, + explicitInput: { id: '1' }, + }, + '2': { + gridData: { x: 6, y: 6, w: 6, h: 6, i: '2' }, + type: CONTACT_CARD_EMBEDDABLE, + explicitInput: { id: '2' }, + }, }, }, }); diff --git a/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid.tsx b/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid.tsx index e496939e8ade0..868bd3d535aa1 100644 --- a/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid.tsx +++ b/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid.tsx @@ -88,6 +88,8 @@ export const DashboardGrid = ({ viewportWidth }: { viewportWidth: number }) => { const onLayoutChange = useCallback( (newLayout: Array) => { + if (viewMode !== ViewMode.EDIT) return; + const updatedPanels: { [key: string]: DashboardPanelState } = newLayout.reduce( (updatedPanelsAcc, panelLayout) => { updatedPanelsAcc[panelLayout.i] = { @@ -102,7 +104,7 @@ export const DashboardGrid = ({ viewportWidth }: { viewportWidth: number }) => { dashboard.dispatch.setPanels(updatedPanels); } }, - [dashboard, panels] + [dashboard, panels, viewMode] ); const classes = classNames({ @@ -127,8 +129,7 @@ export const DashboardGrid = ({ viewportWidth }: { viewportWidth: number }) => { className={classes} width={viewportWidth} breakpoints={breakpoints} - onDragStop={onLayoutChange} - onResizeStop={onLayoutChange} + onLayoutChange={onLayoutChange} isResizable={!expandedPanelId && !focusedPanelId} isDraggable={!expandedPanelId && !focusedPanelId} rowHeight={DASHBOARD_GRID_HEIGHT} diff --git a/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid_item.test.tsx b/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid_item.test.tsx index fa26677ba1f17..66e2c7c8f83ea 100644 --- a/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid_item.test.tsx +++ b/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid_item.test.tsx @@ -43,7 +43,7 @@ const createAndMountDashboardGridItem = (props: DashboardGridItemProps) => { explicitInput: { id: '2' }, }, }; - const dashboardContainer = buildMockDashboard({ panels }); + const dashboardContainer = buildMockDashboard({ overrides: { panels } }); const component = mountWithIntl( diff --git a/src/plugins/dashboard/public/dashboard_container/component/panel/_index.scss b/src/plugins/dashboard/public/dashboard_container/component/panel/_index.scss deleted file mode 100644 index 8212aad12abf1..0000000000000 --- a/src/plugins/dashboard/public/dashboard_container/component/panel/_index.scss +++ /dev/null @@ -1 +0,0 @@ -@import './dashboard_panel'; diff --git a/src/plugins/dashboard/public/dashboard_container/component/panel/create_panel_state.test.ts b/src/plugins/dashboard/public/dashboard_container/component/panel/create_panel_state.test.ts deleted file mode 100644 index acfec6de31d08..0000000000000 --- a/src/plugins/dashboard/public/dashboard_container/component/panel/create_panel_state.test.ts +++ /dev/null @@ -1,83 +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 { DashboardPanelState } from '../../../../common'; -import { EmbeddableInput } from '@kbn/embeddable-plugin/public'; -import { CONTACT_CARD_EMBEDDABLE } from '@kbn/embeddable-plugin/public/lib/test_samples'; -import { DEFAULT_PANEL_HEIGHT, DEFAULT_PANEL_WIDTH } from '../../../dashboard_constants'; - -import { createPanelState } from './create_panel_state'; - -interface TestInput extends EmbeddableInput { - test: string; -} -const panels: { [key: string]: DashboardPanelState } = {}; - -test('createPanelState adds a new panel state in 0,0 position', () => { - const { newPanel: panelState } = createPanelState( - { - type: CONTACT_CARD_EMBEDDABLE, - explicitInput: { test: 'hi', id: '123' }, - }, - panels - ); - expect(panelState.explicitInput.test).toBe('hi'); - expect(panelState.type).toBe(CONTACT_CARD_EMBEDDABLE); - expect(panelState.explicitInput.id).toBeDefined(); - expect(panelState.gridData.x).toBe(0); - expect(panelState.gridData.y).toBe(0); - expect(panelState.gridData.h).toBe(DEFAULT_PANEL_HEIGHT); - expect(panelState.gridData.w).toBe(DEFAULT_PANEL_WIDTH); - - panels[panelState.explicitInput.id] = panelState; -}); - -test('createPanelState adds a second new panel state', () => { - const { newPanel: panelState } = createPanelState( - { type: CONTACT_CARD_EMBEDDABLE, explicitInput: { test: 'bye', id: '456' } }, - panels - ); - - expect(panelState.gridData.x).toBe(DEFAULT_PANEL_WIDTH); - expect(panelState.gridData.y).toBe(0); - expect(panelState.gridData.h).toBe(DEFAULT_PANEL_HEIGHT); - expect(panelState.gridData.w).toBe(DEFAULT_PANEL_WIDTH); - - panels[panelState.explicitInput.id] = panelState; -}); - -test('createPanelState adds a third new panel state', () => { - const { newPanel: panelState } = createPanelState( - { - type: CONTACT_CARD_EMBEDDABLE, - explicitInput: { test: 'bye', id: '789' }, - }, - panels - ); - expect(panelState.gridData.x).toBe(0); - expect(panelState.gridData.y).toBe(DEFAULT_PANEL_HEIGHT); - expect(panelState.gridData.h).toBe(DEFAULT_PANEL_HEIGHT); - expect(panelState.gridData.w).toBe(DEFAULT_PANEL_WIDTH); - - panels[panelState.explicitInput.id] = panelState; -}); - -test('createPanelState adds a new panel state in the top most position', () => { - delete panels['456']; - const { newPanel: panelState } = createPanelState( - { - type: CONTACT_CARD_EMBEDDABLE, - explicitInput: { test: 'bye', id: '987' }, - }, - panels - ); - expect(panelState.gridData.x).toBe(DEFAULT_PANEL_WIDTH); - expect(panelState.gridData.y).toBe(0); - expect(panelState.gridData.h).toBe(DEFAULT_PANEL_HEIGHT); - expect(panelState.gridData.w).toBe(DEFAULT_PANEL_WIDTH); -}); diff --git a/src/plugins/dashboard/public/dashboard_container/component/panel/create_panel_state.ts b/src/plugins/dashboard/public/dashboard_container/component/panel/create_panel_state.ts deleted file mode 100644 index 8f060f26cfe51..0000000000000 --- a/src/plugins/dashboard/public/dashboard_container/component/panel/create_panel_state.ts +++ /dev/null @@ -1,60 +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 { PanelState, EmbeddableInput } from '@kbn/embeddable-plugin/public'; - -import { - IPanelPlacementArgs, - findTopLeftMostOpenSpace, - PanelPlacementMethod, -} from './dashboard_panel_placement'; -import { DashboardPanelState } from '../../../../common'; -import { DEFAULT_PANEL_HEIGHT, DEFAULT_PANEL_WIDTH } from '../../../dashboard_constants'; - -/** - * Creates and initializes a basic panel state. - */ -export function createPanelState< - TEmbeddableInput extends EmbeddableInput, - TPlacementMethodArgs extends IPanelPlacementArgs = IPanelPlacementArgs ->( - panelState: PanelState, - currentPanels: { [key: string]: DashboardPanelState }, - placementMethod?: PanelPlacementMethod, - placementArgs?: TPlacementMethodArgs -): { - newPanel: DashboardPanelState; - otherPanels: { [key: string]: DashboardPanelState }; -} { - const defaultPlacementArgs = { - width: DEFAULT_PANEL_WIDTH, - height: DEFAULT_PANEL_HEIGHT, - currentPanels, - }; - const finalPlacementArgs = placementArgs - ? { - ...defaultPlacementArgs, - ...placementArgs, - } - : defaultPlacementArgs; - - const { newPanelPlacement, otherPanels } = placementMethod - ? placementMethod(finalPlacementArgs as TPlacementMethodArgs) - : findTopLeftMostOpenSpace(defaultPlacementArgs); - - return { - newPanel: { - gridData: { - ...newPanelPlacement, - i: panelState.explicitInput.id, - }, - ...panelState, - }, - otherPanels, - }; -} diff --git a/src/plugins/dashboard/public/dashboard_container/component/panel_placement/index.ts b/src/plugins/dashboard/public/dashboard_container/component/panel_placement/index.ts new file mode 100644 index 0000000000000..8e7444712c281 --- /dev/null +++ b/src/plugins/dashboard/public/dashboard_container/component/panel_placement/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { placePanel } from './place_panel'; + +export { placeClonePanel } from './place_clone_panel_strategy'; diff --git a/src/plugins/dashboard/public/dashboard_container/component/panel/dashboard_panel_placement.ts b/src/plugins/dashboard/public/dashboard_container/component/panel_placement/place_clone_panel_strategy.ts similarity index 53% rename from src/plugins/dashboard/public/dashboard_container/component/panel/dashboard_panel_placement.ts rename to src/plugins/dashboard/public/dashboard_container/component/panel_placement/place_clone_panel_strategy.ts index 829b26072f0d9..affe85dff5d26 100644 --- a/src/plugins/dashboard/public/dashboard_container/component/panel/dashboard_panel_placement.ts +++ b/src/plugins/dashboard/public/dashboard_container/component/panel_placement/place_clone_panel_strategy.ts @@ -6,103 +6,14 @@ * Side Public License, v 1. */ -import _ from 'lodash'; +import { cloneDeep, forOwn } from 'lodash'; import { PanelNotFoundError } from '@kbn/embeddable-plugin/public'; + import { DashboardPanelState } from '../../../../common'; import { GridData } from '../../../../common/content_management'; +import { PanelPlacementProps, PanelPlacementReturn } from './types'; import { DASHBOARD_GRID_COLUMN_COUNT } from '../../../dashboard_constants'; -export type PanelPlacementMethod = ( - args: PlacementArgs -) => PanelPlacementMethodReturn; - -interface PanelPlacementMethodReturn { - newPanelPlacement: Omit; - otherPanels: { [key: string]: DashboardPanelState }; -} - -export interface IPanelPlacementArgs { - width: number; - height: number; - currentPanels: { [key: string]: DashboardPanelState }; - scrollToPanel?: boolean; -} - -export interface IPanelPlacementBesideArgs extends IPanelPlacementArgs { - placeBesideId: string; -} - -// Look for the smallest y and x value where the default panel will fit. -export function findTopLeftMostOpenSpace({ - width, - height, - currentPanels, -}: IPanelPlacementArgs): PanelPlacementMethodReturn { - let maxY = -1; - - const currentPanelsArray = Object.values(currentPanels); - currentPanelsArray.forEach((panel) => { - maxY = Math.max(panel.gridData.y + panel.gridData.h, maxY); - }); - - // Handle case of empty grid. - if (maxY < 0) { - return { newPanelPlacement: { x: 0, y: 0, w: width, h: height }, otherPanels: currentPanels }; - } - - const grid = new Array(maxY); - for (let y = 0; y < maxY; y++) { - grid[y] = new Array(DASHBOARD_GRID_COLUMN_COUNT).fill(0); - } - - currentPanelsArray.forEach((panel) => { - for (let x = panel.gridData.x; x < panel.gridData.x + panel.gridData.w; x++) { - for (let y = panel.gridData.y; y < panel.gridData.y + panel.gridData.h; y++) { - const row = grid[y]; - if (row === undefined) { - throw new Error( - `Attempted to access a row that doesn't exist at ${y} for panel ${JSON.stringify( - panel - )}` - ); - } - grid[y][x] = 1; - } - } - }); - - for (let y = 0; y < maxY; y++) { - for (let x = 0; x < DASHBOARD_GRID_COLUMN_COUNT; x++) { - if (grid[y][x] === 1) { - // Space is filled - continue; - } else { - for (let h = y; h < Math.min(y + height, maxY); h++) { - for (let w = x; w < Math.min(x + width, DASHBOARD_GRID_COLUMN_COUNT); w++) { - const spaceIsEmpty = grid[h][w] === 0; - const fitsPanelWidth = w === x + width - 1; - // If the panel is taller than any other panel in the current grid, it can still fit in the space, hence - // we check the minimum of maxY and the panel height. - const fitsPanelHeight = h === Math.min(y + height - 1, maxY - 1); - - if (spaceIsEmpty && fitsPanelWidth && fitsPanelHeight) { - // Found space - return { - newPanelPlacement: { x, y, w: width, h: height }, - otherPanels: currentPanels, - }; - } else if (grid[h][w] === 1) { - // x, y spot doesn't work, break. - break; - } - } - } - } - } - } - return { newPanelPlacement: { x: 0, y: maxY, w: width, h: height }, otherPanels: currentPanels }; -} - interface IplacementDirection { grid: Omit; fits: boolean; @@ -128,19 +39,19 @@ function comparePanels(a: GridData, b: GridData): number { return 1; } -export function placePanelBeside({ +export function placeClonePanel({ width, height, currentPanels, placeBesideId, -}: IPanelPlacementBesideArgs): PanelPlacementMethodReturn { +}: PanelPlacementProps & { placeBesideId: string }): PanelPlacementReturn { const panelToPlaceBeside = currentPanels[placeBesideId]; if (!panelToPlaceBeside) { throw new PanelNotFoundError(); } const beside = panelToPlaceBeside.gridData; const otherPanelGridData: GridData[] = []; - _.forOwn(currentPanels, (panel: DashboardPanelState, key: string | undefined) => { + forOwn(currentPanels, (panel: DashboardPanelState, key: string | undefined) => { otherPanelGridData.push(panel.gridData); }); @@ -197,7 +108,7 @@ export function placePanelBeside({ for (let j = position + 1; j < grid.length; j++) { originalPositionInTheGrid = grid[j].i; - const movedPanel = _.cloneDeep(otherPanels[originalPositionInTheGrid]); + const movedPanel = cloneDeep(otherPanels[originalPositionInTheGrid]); movedPanel.gridData.y = movedPanel.gridData.y + diff; otherPanels[originalPositionInTheGrid] = movedPanel; } diff --git a/src/plugins/dashboard/public/dashboard_container/component/panel_placement/place_new_panel_strategies.ts b/src/plugins/dashboard/public/dashboard_container/component/panel_placement/place_new_panel_strategies.ts new file mode 100644 index 0000000000000..8a8c8a83193eb --- /dev/null +++ b/src/plugins/dashboard/public/dashboard_container/component/panel_placement/place_new_panel_strategies.ts @@ -0,0 +1,104 @@ +/* + * 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 { cloneDeep } from 'lodash'; +import { DASHBOARD_GRID_COLUMN_COUNT } from '../../../dashboard_constants'; +import { PanelPlacementProps, PanelPlacementReturn } from './types'; + +export const panelPlacementStrategies = { + // Place on the very top of the Dashboard, add the height of this panel to all other panels. + placeAtTop: ({ width, height, currentPanels }: PanelPlacementProps): PanelPlacementReturn => { + const otherPanels = { ...currentPanels }; + for (const [id, panel] of Object.entries(currentPanels)) { + const currentPanel = cloneDeep(panel); + currentPanel.gridData.y = currentPanel.gridData.y + height; + otherPanels[id] = currentPanel; + } + return { + newPanelPlacement: { x: 0, y: 0, w: width, h: height }, + otherPanels, + }; + }, + + // Look for the smallest y and x value where the default panel will fit. + findTopLeftMostOpenSpace: ({ + width, + height, + currentPanels, + }: PanelPlacementProps): PanelPlacementReturn => { + let maxY = -1; + + const currentPanelsArray = Object.values(currentPanels); + currentPanelsArray.forEach((panel) => { + maxY = Math.max(panel.gridData.y + panel.gridData.h, maxY); + }); + + // Handle case of empty grid. + if (maxY < 0) { + return { + newPanelPlacement: { x: 0, y: 0, w: width, h: height }, + otherPanels: currentPanels, + }; + } + + const grid = new Array(maxY); + for (let y = 0; y < maxY; y++) { + grid[y] = new Array(DASHBOARD_GRID_COLUMN_COUNT).fill(0); + } + + currentPanelsArray.forEach((panel) => { + for (let x = panel.gridData.x; x < panel.gridData.x + panel.gridData.w; x++) { + for (let y = panel.gridData.y; y < panel.gridData.y + panel.gridData.h; y++) { + const row = grid[y]; + if (row === undefined) { + throw new Error( + `Attempted to access a row that doesn't exist at ${y} for panel ${JSON.stringify( + panel + )}` + ); + } + grid[y][x] = 1; + } + } + }); + + for (let y = 0; y < maxY; y++) { + for (let x = 0; x < DASHBOARD_GRID_COLUMN_COUNT; x++) { + if (grid[y][x] === 1) { + // Space is filled + continue; + } else { + for (let h = y; h < Math.min(y + height, maxY); h++) { + for (let w = x; w < Math.min(x + width, DASHBOARD_GRID_COLUMN_COUNT); w++) { + const spaceIsEmpty = grid[h][w] === 0; + const fitsPanelWidth = w === x + width - 1; + // If the panel is taller than any other panel in the current grid, it can still fit in the space, hence + // we check the minimum of maxY and the panel height. + const fitsPanelHeight = h === Math.min(y + height - 1, maxY - 1); + + if (spaceIsEmpty && fitsPanelWidth && fitsPanelHeight) { + // Found space + return { + newPanelPlacement: { x, y, w: width, h: height }, + otherPanels: currentPanels, + }; + } else if (grid[h][w] === 1) { + // x, y spot doesn't work, break. + break; + } + } + } + } + } + } + return { + newPanelPlacement: { x: 0, y: maxY, w: width, h: height }, + otherPanels: currentPanels, + }; + }, +} as const; diff --git a/src/plugins/dashboard/public/dashboard_container/component/panel_placement/place_panel.test.ts b/src/plugins/dashboard/public/dashboard_container/component/panel_placement/place_panel.test.ts new file mode 100644 index 0000000000000..24023ba92dbce --- /dev/null +++ b/src/plugins/dashboard/public/dashboard_container/component/panel_placement/place_panel.test.ts @@ -0,0 +1,167 @@ +/* + * 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 { DashboardPanelState } from '../../../../common'; +import { EmbeddableFactory, EmbeddableInput } from '@kbn/embeddable-plugin/public'; +import { CONTACT_CARD_EMBEDDABLE } from '@kbn/embeddable-plugin/public/lib/test_samples'; +import { DEFAULT_PANEL_HEIGHT, DEFAULT_PANEL_WIDTH } from '../../../dashboard_constants'; + +import { placePanel } from './place_panel'; +import { IProvidesPanelPlacementSettings } from './types'; + +interface TestInput extends EmbeddableInput { + test: string; +} +const panels: { [key: string]: DashboardPanelState } = {}; + +test('adds a new panel state in 0,0 position', () => { + const { newPanel: panelState } = placePanel( + {} as unknown as EmbeddableFactory, + { + type: CONTACT_CARD_EMBEDDABLE, + explicitInput: { test: 'hi', id: '123' }, + }, + panels + ); + expect(panelState.explicitInput.test).toBe('hi'); + expect(panelState.type).toBe(CONTACT_CARD_EMBEDDABLE); + expect(panelState.explicitInput.id).toBeDefined(); + expect(panelState.gridData.x).toBe(0); + expect(panelState.gridData.y).toBe(0); + expect(panelState.gridData.h).toBe(DEFAULT_PANEL_HEIGHT); + expect(panelState.gridData.w).toBe(DEFAULT_PANEL_WIDTH); + + panels[panelState.explicitInput.id] = panelState; +}); + +test('adds a second new panel state', () => { + const { newPanel: panelState } = placePanel( + {} as unknown as EmbeddableFactory, + { type: CONTACT_CARD_EMBEDDABLE, explicitInput: { test: 'bye', id: '456' } }, + panels + ); + + expect(panelState.gridData.x).toBe(DEFAULT_PANEL_WIDTH); + expect(panelState.gridData.y).toBe(0); + expect(panelState.gridData.h).toBe(DEFAULT_PANEL_HEIGHT); + expect(panelState.gridData.w).toBe(DEFAULT_PANEL_WIDTH); + + panels[panelState.explicitInput.id] = panelState; +}); + +test('adds a third new panel state', () => { + const { newPanel: panelState } = placePanel( + {} as unknown as EmbeddableFactory, + { + type: CONTACT_CARD_EMBEDDABLE, + explicitInput: { test: 'bye', id: '789' }, + }, + panels + ); + expect(panelState.gridData.x).toBe(0); + expect(panelState.gridData.y).toBe(DEFAULT_PANEL_HEIGHT); + expect(panelState.gridData.h).toBe(DEFAULT_PANEL_HEIGHT); + expect(panelState.gridData.w).toBe(DEFAULT_PANEL_WIDTH); + + panels[panelState.explicitInput.id] = panelState; +}); + +test('adds a new panel state in the top most position when it is open', () => { + // deleting panel 456 means that the top leftmost open position will be at the top of the Dashboard. + delete panels['456']; + const { newPanel: panelState } = placePanel( + {} as unknown as EmbeddableFactory, + { + type: CONTACT_CARD_EMBEDDABLE, + explicitInput: { test: 'bye', id: '987' }, + }, + panels + ); + expect(panelState.gridData.x).toBe(DEFAULT_PANEL_WIDTH); + expect(panelState.gridData.y).toBe(0); + expect(panelState.gridData.h).toBe(DEFAULT_PANEL_HEIGHT); + expect(panelState.gridData.w).toBe(DEFAULT_PANEL_WIDTH); + + // replace the topmost panel. + panels[panelState.explicitInput.id] = panelState; +}); + +test('adds a new panel state at the very top of the Dashboard with default sizing', () => { + const embeddableFactoryStub: IProvidesPanelPlacementSettings = { + getPanelPlacementSettings: jest.fn().mockImplementation(() => { + return { strategy: 'placeAtTop' }; + }), + }; + + const { newPanel: panelState } = placePanel( + embeddableFactoryStub as unknown as EmbeddableFactory, + { + type: CONTACT_CARD_EMBEDDABLE, + explicitInput: { test: 'wowee', id: '9001' }, + }, + panels + ); + expect(panelState.gridData.x).toBe(0); + expect(panelState.gridData.y).toBe(0); + expect(panelState.gridData.h).toBe(DEFAULT_PANEL_HEIGHT); + expect(panelState.gridData.w).toBe(DEFAULT_PANEL_WIDTH); + + expect(embeddableFactoryStub.getPanelPlacementSettings).toHaveBeenCalledWith( + { id: '9001', test: 'wowee' }, + undefined + ); +}); + +test('adds a new panel state at the very top of the Dashboard with custom sizing', () => { + const embeddableFactoryStub: IProvidesPanelPlacementSettings = { + getPanelPlacementSettings: jest.fn().mockImplementation(() => { + return { strategy: 'placeAtTop', width: 10, height: 5 }; + }), + }; + + const { newPanel: panelState } = placePanel( + embeddableFactoryStub as unknown as EmbeddableFactory, + { + type: CONTACT_CARD_EMBEDDABLE, + explicitInput: { test: 'woweee', id: '9002' }, + }, + panels + ); + expect(panelState.gridData.x).toBe(0); + expect(panelState.gridData.y).toBe(0); + expect(panelState.gridData.h).toBe(5); + expect(panelState.gridData.w).toBe(10); + + expect(embeddableFactoryStub.getPanelPlacementSettings).toHaveBeenCalledWith( + { id: '9002', test: 'woweee' }, + undefined + ); +}); + +test('passes through given attributes', () => { + const embeddableFactoryStub: IProvidesPanelPlacementSettings = { + getPanelPlacementSettings: jest.fn().mockImplementation(() => { + return { strategy: 'placeAtTop', width: 10, height: 5 }; + }), + }; + + placePanel( + embeddableFactoryStub as unknown as EmbeddableFactory, + { + type: CONTACT_CARD_EMBEDDABLE, + explicitInput: { test: 'wow', id: '9004' }, + }, + panels, + { testAttr: 'hello' } + ); + + expect(embeddableFactoryStub.getPanelPlacementSettings).toHaveBeenCalledWith( + { id: '9004', test: 'wow' }, + { testAttr: 'hello' } + ); +}); diff --git a/src/plugins/dashboard/public/dashboard_container/component/panel_placement/place_panel.ts b/src/plugins/dashboard/public/dashboard_container/component/panel_placement/place_panel.ts new file mode 100644 index 0000000000000..a65c4fca9c115 --- /dev/null +++ b/src/plugins/dashboard/public/dashboard_container/component/panel_placement/place_panel.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { PanelState, EmbeddableInput, EmbeddableFactory } from '@kbn/embeddable-plugin/public'; + +import { DashboardPanelState } from '../../../../common'; +import { panelPlacementStrategies } from './place_new_panel_strategies'; +import { IProvidesPanelPlacementSettings, PanelPlacementSettings } from './types'; +import { DEFAULT_PANEL_HEIGHT, DEFAULT_PANEL_WIDTH } from '../../../dashboard_constants'; + +export const providesPanelPlacementSettings = ( + value: unknown +): value is IProvidesPanelPlacementSettings => { + return Boolean((value as IProvidesPanelPlacementSettings).getPanelPlacementSettings); +}; + +export function placePanel( + factory: EmbeddableFactory, + newPanel: PanelState, + currentPanels: { [key: string]: DashboardPanelState }, + attributes?: unknown +): { + newPanel: DashboardPanelState; + otherPanels: { [key: string]: DashboardPanelState }; +} { + let placementSettings: PanelPlacementSettings = { + width: DEFAULT_PANEL_WIDTH, + height: DEFAULT_PANEL_HEIGHT, + strategy: 'findTopLeftMostOpenSpace', + }; + if (providesPanelPlacementSettings(factory)) { + placementSettings = { + ...placementSettings, + ...factory.getPanelPlacementSettings(newPanel.explicitInput, attributes), + }; + } + const { width, height, strategy } = placementSettings; + + const { newPanelPlacement, otherPanels } = panelPlacementStrategies[strategy]({ + currentPanels, + height, + width, + }); + + return { + newPanel: { + gridData: { + ...newPanelPlacement, + i: newPanel.explicitInput.id, + }, + ...newPanel, + }, + otherPanels, + }; +} diff --git a/src/plugins/dashboard/public/dashboard_container/component/panel_placement/types.ts b/src/plugins/dashboard/public/dashboard_container/component/panel_placement/types.ts new file mode 100644 index 0000000000000..7fb20b469c1a9 --- /dev/null +++ b/src/plugins/dashboard/public/dashboard_container/component/panel_placement/types.ts @@ -0,0 +1,41 @@ +/* + * 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 { EmbeddableInput } from '@kbn/embeddable-plugin/public'; +import { DashboardPanelState } from '../../../../common'; +import { GridData } from '../../../../common/content_management'; +import { panelPlacementStrategies } from './place_new_panel_strategies'; + +export type PanelPlacementStrategy = keyof typeof panelPlacementStrategies; + +export interface PanelPlacementSettings { + strategy: PanelPlacementStrategy; + height: number; + width: number; +} + +export interface PanelPlacementReturn { + newPanelPlacement: Omit; + otherPanels: { [key: string]: DashboardPanelState }; +} + +export interface PanelPlacementProps { + width: number; + height: number; + currentPanels: { [key: string]: DashboardPanelState }; +} + +export interface IProvidesPanelPlacementSettings< + InputType extends EmbeddableInput = EmbeddableInput, + AttributesType = unknown +> { + getPanelPlacementSettings: ( + input: InputType, + attributes?: AttributesType + ) => Partial; +} diff --git a/src/plugins/dashboard/public/dashboard_container/component/viewport/dashboard_viewport.tsx b/src/plugins/dashboard/public/dashboard_container/component/viewport/dashboard_viewport.tsx index d1931d7ed3e61..e37f14ee5b977 100644 --- a/src/plugins/dashboard/public/dashboard_container/component/viewport/dashboard_viewport.tsx +++ b/src/plugins/dashboard/public/dashboard_container/component/viewport/dashboard_viewport.tsx @@ -86,7 +86,9 @@ export const DashboardViewportComponent = () => { data-description={description} data-shared-items-count={panelCount} > - + {/* Wait for `viewportWidth` to actually be set before rendering the dashboard grid - + otherwise, there is a race condition where the panels can end up being squashed */} + {viewportWidth !== 0 && } ); diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/api/panel_management.ts b/src/plugins/dashboard/public/dashboard_container/embeddable/api/panel_management.ts index 848600a2767d6..2a5662387c477 100644 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/api/panel_management.ts +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/api/panel_management.ts @@ -12,7 +12,6 @@ import { IEmbeddable, PanelState, } from '@kbn/embeddable-plugin/public'; -import { v4 as uuidv4 } from 'uuid'; import { DashboardPanelState } from '../../../../common'; import { DashboardContainer } from '../dashboard_container'; @@ -41,45 +40,14 @@ export async function replacePanel( newPanelState: Partial, generateNewId?: boolean ): Promise { - let panels; - let panelId; - - if (generateNewId) { - // replace panel can be called with generateNewId in order to totally destroy and recreate the embeddable - panelId = uuidv4(); - panels = { ...this.input.panels }; - delete panels[previousPanelState.explicitInput.id]; - panels[panelId] = { - ...previousPanelState, - ...newPanelState, - gridData: { - ...previousPanelState.gridData, - i: panelId, - }, - explicitInput: { - ...newPanelState.explicitInput, - id: panelId, - }, - }; - } else { - // Because the embeddable type can change, we have to operate at the container level here - panelId = previousPanelState.explicitInput.id; - panels = { - ...this.input.panels, - [panelId]: { - ...previousPanelState, - ...newPanelState, - gridData: { - ...previousPanelState.gridData, - }, - explicitInput: { - ...newPanelState.explicitInput, - id: panelId, - }, - }, - }; - } - - await this.updateInput({ panels }); + const panelId = await this.replaceEmbeddable( + previousPanelState.explicitInput.id, + { + ...newPanelState.explicitInput, + id: previousPanelState.explicitInput.id, + }, + newPanelState.type, + generateNewId + ); return panelId; } diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.ts b/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.ts index 70e81ca7a76d1..e843d07ad6ff1 100644 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.ts +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.ts @@ -25,9 +25,9 @@ import { pluginServices } from '../../../services/plugin_services'; import { DashboardCreationOptions } from '../dashboard_container_factory'; import { DashboardContainerInput, DashboardPanelState } from '../../../../common'; import { startSyncingDashboardDataViews } from './data_views/sync_dashboard_data_views'; -import { findTopLeftMostOpenSpace } from '../../component/panel/dashboard_panel_placement'; import { LoadDashboardReturn } from '../../../services/dashboard_content_management/types'; import { syncUnifiedSearchState } from './unified_search/sync_dashboard_unified_search_state'; +import { panelPlacementStrategies } from '../../component/panel_placement/place_new_panel_strategies'; import { DEFAULT_DASHBOARD_INPUT, DEFAULT_PANEL_HEIGHT, @@ -297,6 +297,7 @@ export const initializeDashboard = async ({ const { width, height } = incomingEmbeddable.size; const currentPanels = container.getInput().panels; const embeddableId = incomingEmbeddable.embeddableId ?? v4(); + const { findTopLeftMostOpenSpace } = panelPlacementStrategies; const { newPanelPlacement } = findTopLeftMostOpenSpace({ width: width ?? DEFAULT_PANEL_WIDTH, height: height ?? DEFAULT_PANEL_HEIGHT, diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.test.tsx b/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.test.tsx index 59c16a26d0402..651d71c106ced 100644 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.test.tsx +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.test.tsx @@ -43,11 +43,13 @@ pluginServices.getServices().embeddable.getEmbeddableFactory = jest test('DashboardContainer initializes embeddables', (done) => { const container = buildMockDashboard({ - panels: { - '123': getSampleDashboardPanel({ - explicitInput: { firstName: 'Sam', id: '123' }, - type: CONTACT_CARD_EMBEDDABLE, - }), + overrides: { + panels: { + '123': getSampleDashboardPanel({ + explicitInput: { firstName: 'Sam', id: '123' }, + type: CONTACT_CARD_EMBEDDABLE, + }), + }, }, }); @@ -94,11 +96,13 @@ test('DashboardContainer.replacePanel', (done) => { const ID = '123'; const container = buildMockDashboard({ - panels: { - [ID]: getSampleDashboardPanel({ - explicitInput: { firstName: 'Sam', id: ID }, - type: CONTACT_CARD_EMBEDDABLE, - }), + overrides: { + panels: { + [ID]: getSampleDashboardPanel({ + explicitInput: { firstName: 'Sam', id: ID }, + type: CONTACT_CARD_EMBEDDABLE, + }), + }, }, }); let counter = 0; @@ -134,11 +138,13 @@ test('DashboardContainer.replacePanel', (done) => { test('Container view mode change propagates to existing children', async () => { const container = buildMockDashboard({ - panels: { - '123': getSampleDashboardPanel({ - explicitInput: { firstName: 'Sam', id: '123' }, - type: CONTACT_CARD_EMBEDDABLE, - }), + overrides: { + panels: { + '123': getSampleDashboardPanel({ + explicitInput: { firstName: 'Sam', id: '123' }, + type: CONTACT_CARD_EMBEDDABLE, + }), + }, }, }); @@ -197,7 +203,7 @@ test('DashboardContainer in edit mode shows edit mode actions', async () => { uiActionsSetup.registerAction(editModeAction); uiActionsSetup.addTriggerAction(CONTEXT_MENU_TRIGGER, editModeAction); - const container = buildMockDashboard({ viewMode: ViewMode.VIEW }); + const container = buildMockDashboard({ overrides: { viewMode: ViewMode.VIEW } }); const embeddable = await container.addNewEmbeddable< ContactCardEmbeddableInput, @@ -273,8 +279,10 @@ describe('getInheritedInput', () => { test('Should pass dashboard timeRange and timeslice to panel when panel does not have custom time range', async () => { const container = buildMockDashboard({ - timeRange: dashboardTimeRange, - timeslice: dashboardTimeslice, + overrides: { + timeRange: dashboardTimeRange, + timeslice: dashboardTimeslice, + }, }); const embeddable = await container.addNewEmbeddable( CONTACT_CARD_EMBEDDABLE, @@ -296,8 +304,10 @@ describe('getInheritedInput', () => { test('Should not pass dashboard timeRange and timeslice to panel when panel has custom time range', async () => { const container = buildMockDashboard({ - timeRange: dashboardTimeRange, - timeslice: dashboardTimeslice, + overrides: { + timeRange: dashboardTimeRange, + timeslice: dashboardTimeslice, + }, }); const embeddableTimeRange = { to: 'now', diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx b/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx index 2a618ad6a04d4..38c5c8b077979 100644 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx @@ -50,7 +50,7 @@ import { DashboardRenderPerformanceStats, } from '../types'; import { DASHBOARD_CONTAINER_TYPE } from '../..'; -import { createPanelState } from '../component/panel'; +import { placePanel } from '../component/panel_placement'; import { pluginServices } from '../../services/plugin_services'; import { initializeDashboard } from './create/create_dashboard'; import { DASHBOARD_LOADED_EVENT } from '../../dashboard_constants'; @@ -213,11 +213,14 @@ export class DashboardContainer extends Container >( factory: EmbeddableFactory, - partial: Partial = {} - ): DashboardPanelState { - const panelState = super.createNewPanelState(factory, partial); - const { newPanel } = createPanelState(panelState, this.input.panels); - return newPanel; + partial: Partial = {}, + attributes?: unknown + ): { + newPanel: DashboardPanelState; + otherPanels: DashboardContainerInput['panels']; + } { + const { newPanel } = super.createNewPanelState(factory, partial, attributes); + return placePanel(factory, newPanel, this.input.panels, attributes); } public render(dom: HTMLElement) { diff --git a/src/plugins/dashboard/public/index.ts b/src/plugins/dashboard/public/index.ts index 0bfe5dfae8b4c..6882090df441a 100644 --- a/src/plugins/dashboard/public/index.ts +++ b/src/plugins/dashboard/public/index.ts @@ -13,6 +13,7 @@ export { createDashboardEditUrl, DASHBOARD_APP_ID, LEGACY_DASHBOARD_APP_ID, + DASHBOARD_GRID_COLUMN_COUNT, } from './dashboard_constants'; export { type DashboardAPI, @@ -30,6 +31,7 @@ export { type DashboardAppLocatorParams, cleanEmptyKeys, } from './dashboard_app/locator/locator'; +export { getEmbeddableParams } from './dashboard_app/locator/get_dashboard_locator_params'; export function plugin(initializerContext: PluginInitializerContext) { return new DashboardPlugin(initializerContext); diff --git a/src/plugins/dashboard/public/mocks.tsx b/src/plugins/dashboard/public/mocks.tsx index c081a3ab276b8..7611e12b90ae4 100644 --- a/src/plugins/dashboard/public/mocks.tsx +++ b/src/plugins/dashboard/public/mocks.tsx @@ -66,7 +66,13 @@ export function setupIntersectionObserverMock({ }); } -export function buildMockDashboard(overrides?: Partial) { +export function buildMockDashboard({ + overrides, + savedObjectId, +}: { + overrides?: Partial; + savedObjectId?: string; +} = {}) { const initialInput = getSampleDashboardInput(overrides); const dashboardContainer = new DashboardContainer( initialInput, @@ -75,7 +81,7 @@ export function buildMockDashboard(overrides?: Partial) undefined, undefined, undefined, - { lastSavedInput: initialInput } + { lastSavedInput: initialInput, lastSavedId: savedObjectId } ); return dashboardContainer; } diff --git a/src/plugins/dashboard/public/services/dashboard_content_management/dashboard_content_management.stub.ts b/src/plugins/dashboard/public/services/dashboard_content_management/dashboard_content_management.stub.ts index 027e1d0e7d47d..bb63c01b0e2d7 100644 --- a/src/plugins/dashboard/public/services/dashboard_content_management/dashboard_content_management.stub.ts +++ b/src/plugins/dashboard/public/services/dashboard_content_management/dashboard_content_management.stub.ts @@ -44,6 +44,7 @@ export const dashboardContentManagementServiceFactory: DashboardContentManagemen hits, }); }), + findById: jest.fn(), findByIds: jest.fn().mockImplementation(() => Promise.resolve([ { diff --git a/src/plugins/dashboard/public/services/dashboard_content_management/dashboard_content_management_cache.ts b/src/plugins/dashboard/public/services/dashboard_content_management/dashboard_content_management_cache.ts new file mode 100644 index 0000000000000..20b9e5a9cb2a7 --- /dev/null +++ b/src/plugins/dashboard/public/services/dashboard_content_management/dashboard_content_management_cache.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 LRUCache from 'lru-cache'; +import { DashboardCrudTypes } from '../../../common/content_management'; +import { DASHBOARD_CACHE_SIZE, DASHBOARD_CACHE_TTL } from '../../dashboard_constants'; + +export class DashboardContentManagementCache { + private cache: LRUCache; + + constructor() { + this.cache = new LRUCache({ + max: DASHBOARD_CACHE_SIZE, + maxAge: DASHBOARD_CACHE_TTL, + }); + } + + /** Fetch the dashboard with `id` from the cache */ + public fetchDashboard(id: string) { + return this.cache.get(id); + } + + /** Add the fetched dashboard to the cache */ + public addDashboard({ item: dashboard, meta }: DashboardCrudTypes['GetOut']) { + this.cache.set(dashboard.id, { + meta, + item: dashboard, + }); + } + + /** Delete the dashboard with `id` from the cache */ + public deleteDashboard(id: string) { + this.cache.del(id); + } +} diff --git a/src/plugins/dashboard/public/services/dashboard_content_management/dashboard_content_management_service.ts b/src/plugins/dashboard/public/services/dashboard_content_management/dashboard_content_management_service.ts index 08e7176e19059..b3689b9be4238 100644 --- a/src/plugins/dashboard/public/services/dashboard_content_management/dashboard_content_management_service.ts +++ b/src/plugins/dashboard/public/services/dashboard_content_management/dashboard_content_management_service.ts @@ -13,6 +13,7 @@ import { checkForDuplicateDashboardTitle } from './lib/check_for_duplicate_dashb import { searchDashboards, + findDashboardById, findDashboardsByIds, findDashboardIdByTitle, } from './lib/find_dashboards'; @@ -21,9 +22,10 @@ import type { DashboardContentManagementRequiredServices, DashboardContentManagementService, } from './types'; -import { loadDashboardState } from './lib/load_dashboard_state'; import { deleteDashboards } from './lib/delete_dashboards'; +import { loadDashboardState } from './lib/load_dashboard_state'; import { updateDashboardMeta } from './lib/update_dashboard_meta'; +import { DashboardContentManagementCache } from './dashboard_content_management_cache'; export type DashboardContentManagementServiceFactory = KibanaPluginServiceFactory< DashboardContentManagementService, @@ -31,6 +33,8 @@ export type DashboardContentManagementServiceFactory = KibanaPluginServiceFactor DashboardContentManagementRequiredServices >; +export const dashboardContentManagementCache = new DashboardContentManagementCache(); + export const dashboardContentManagementServiceFactory: DashboardContentManagementServiceFactory = ( { startPlugins: { contentManagement } }, requiredServices @@ -66,14 +70,16 @@ export const dashboardContentManagementServiceFactory: DashboardContentManagemen dashboardSessionStorage, }), findDashboards: { - search: ({ hasReference, hasNoReference, search, size }) => + search: ({ hasReference, hasNoReference, search, size, options }) => searchDashboards({ contentManagement, hasNoReference, hasReference, + options, search, size, }), + findById: (id) => findDashboardById(contentManagement, id), findByIds: (ids) => findDashboardsByIds(contentManagement, ids), findByTitle: (title) => findDashboardIdByTitle(contentManagement, title), }, diff --git a/src/plugins/dashboard/public/services/dashboard_content_management/lib/delete_dashboards.ts b/src/plugins/dashboard/public/services/dashboard_content_management/lib/delete_dashboards.ts index e18841eacfcfd..cf861a02b45e9 100644 --- a/src/plugins/dashboard/public/services/dashboard_content_management/lib/delete_dashboards.ts +++ b/src/plugins/dashboard/public/services/dashboard_content_management/lib/delete_dashboards.ts @@ -9,20 +9,22 @@ import { DashboardStartDependencies } from '../../../plugin'; import { DASHBOARD_CONTENT_ID } from '../../../dashboard_constants'; import { DashboardCrudTypes } from '../../../../common/content_management'; +import { dashboardContentManagementCache } from '../dashboard_content_management_service'; export const deleteDashboards = async ( ids: string[], contentManagement: DashboardStartDependencies['contentManagement'] ) => { - const deletePromises = ids.map((id) => - contentManagement.client.delete< + const deletePromises = ids.map((id) => { + dashboardContentManagementCache.deleteDashboard(id); + return contentManagement.client.delete< DashboardCrudTypes['DeleteIn'], DashboardCrudTypes['DeleteOut'] >({ contentTypeId: DASHBOARD_CONTENT_ID, id, - }) - ); + }); + }); await Promise.all(deletePromises); }; diff --git a/src/plugins/dashboard/public/services/dashboard_content_management/lib/find_dashboards.ts b/src/plugins/dashboard/public/services/dashboard_content_management/lib/find_dashboards.ts index 2b2f6ac4804f7..efeaa76297f9e 100644 --- a/src/plugins/dashboard/public/services/dashboard_content_management/lib/find_dashboards.ts +++ b/src/plugins/dashboard/public/services/dashboard_content_management/lib/find_dashboards.ts @@ -16,9 +16,11 @@ import { } from '../../../../common/content_management'; import { DashboardStartDependencies } from '../../../plugin'; import { DASHBOARD_CONTENT_ID } from '../../../dashboard_constants'; +import { dashboardContentManagementCache } from '../dashboard_content_management_service'; export interface SearchDashboardsArgs { contentManagement: DashboardStartDependencies['contentManagement']; + options?: DashboardCrudTypes['SearchIn']['options']; hasNoReference?: SavedObjectsFindOptionsReference[]; hasReference?: SavedObjectsFindOptionsReference[]; search: string; @@ -34,6 +36,7 @@ export async function searchDashboards({ contentManagement, hasNoReference, hasReference, + options, search, size, }: SearchDashboardsArgs): Promise { @@ -53,6 +56,7 @@ export async function searchDashboards({ excluded: (hasNoReference ?? []).map(({ id }) => id), }, }, + options, }); return { total, @@ -65,23 +69,42 @@ export type FindDashboardsByIdResponse = { id: string } & ( | { status: 'error'; error: SavedObjectError } ); -export async function findDashboardsByIds( +export async function findDashboardById( contentManagement: DashboardStartDependencies['contentManagement'], - ids: string[] -): Promise { - const findPromises = ids.map((id) => - contentManagement.client.get({ + id: string +): Promise { + /** If the dashboard exists in the cache, then return the result from that */ + const cachedDashboard = dashboardContentManagementCache.fetchDashboard(id); + if (cachedDashboard) { + return { + id, + status: 'success', + attributes: cachedDashboard.item.attributes, + references: cachedDashboard.item.references, + }; + } + /** Otherwise, fetch the dashboard from the content management client, add it to the cache, and return the result */ + const response = await contentManagement.client + .get({ contentTypeId: DASHBOARD_CONTENT_ID, id, }) - ); - const results = await Promise.all(findPromises); + .then((result) => { + dashboardContentManagementCache.addDashboard(result); + return { id, status: 'success', attributes: result.item.attributes }; + }) + .catch((e) => ({ status: 'error', error: e.body, id })); - return results.map((result) => { - if (result.item.error) return { status: 'error', error: result.item.error, id: result.item.id }; - const { attributes, id, references } = result.item; - return { id, status: 'success', attributes, references }; - }); + return response as FindDashboardsByIdResponse; +} + +export async function findDashboardsByIds( + contentManagement: DashboardStartDependencies['contentManagement'], + ids: string[] +): Promise { + const findPromises = ids.map((id) => findDashboardById(contentManagement, id)); + const results = await Promise.all(findPromises); + return results as FindDashboardsByIdResponse[]; } export async function findDashboardIdByTitle( diff --git a/src/plugins/dashboard/public/services/dashboard_content_management/lib/load_dashboard_state.ts b/src/plugins/dashboard/public/services/dashboard_content_management/lib/load_dashboard_state.ts index 93034531a791b..21ab8c8143d68 100644 --- a/src/plugins/dashboard/public/services/dashboard_content_management/lib/load_dashboard_state.ts +++ b/src/plugins/dashboard/public/services/dashboard_content_management/lib/load_dashboard_state.ts @@ -21,10 +21,11 @@ import { convertSavedPanelsToPanelMap, } from '../../../../common'; import { migrateDashboardInput } from './migrate_dashboard_input'; +import { convertNumberToDashboardVersion } from './dashboard_versioning'; import { DashboardCrudTypes } from '../../../../common/content_management'; import type { LoadDashboardFromSavedObjectProps, LoadDashboardReturn } from '../types'; +import { dashboardContentManagementCache } from '../dashboard_content_management_service'; import { DASHBOARD_CONTENT_ID, DEFAULT_DASHBOARD_INPUT } from '../../../dashboard_constants'; -import { convertNumberToDashboardVersion } from './dashboard_versioning'; export function migrateLegacyQuery(query: Query | { [key: string]: any } | string): Query { // Lucene was the only option before, so language-less queries are all lucene @@ -60,14 +61,27 @@ export const loadDashboardState = async ({ /** * Load the saved object from Content Management */ - const { item: rawDashboardContent, meta: resolveMeta } = await contentManagement.client - .get({ - contentTypeId: DASHBOARD_CONTENT_ID, - id, - }) - .catch((e) => { - throw new SavedObjectNotFound(DASHBOARD_CONTENT_ID, id); - }); + let rawDashboardContent; + let resolveMeta; + + const cachedDashboard = dashboardContentManagementCache.fetchDashboard(id); + if (cachedDashboard) { + /** If the dashboard exists in the cache, use the cached version to load the dashboard */ + ({ item: rawDashboardContent, meta: resolveMeta } = cachedDashboard); + } else { + /** Otherwise, fetch and load the dashboard from the content management client, and add it to the cache */ + const result = await contentManagement.client + .get({ + contentTypeId: DASHBOARD_CONTENT_ID, + id, + }) + .catch((e) => { + throw new SavedObjectNotFound(DASHBOARD_CONTENT_ID, id); + }); + + dashboardContentManagementCache.addDashboard(result); + ({ item: rawDashboardContent, meta: resolveMeta } = result); + } if (!rawDashboardContent || !rawDashboardContent.version) { return { diff --git a/src/plugins/dashboard/public/services/dashboard_content_management/lib/save_dashboard_state.ts b/src/plugins/dashboard/public/services/dashboard_content_management/lib/save_dashboard_state.ts index 7f0ed1e8305cd..eac769f03de6c 100644 --- a/src/plugins/dashboard/public/services/dashboard_content_management/lib/save_dashboard_state.ts +++ b/src/plugins/dashboard/public/services/dashboard_content_management/lib/save_dashboard_state.ts @@ -29,10 +29,11 @@ import { } from '../types'; import { DashboardStartDependencies } from '../../../plugin'; import { DASHBOARD_CONTENT_ID } from '../../../dashboard_constants'; +import { convertDashboardVersionToNumber } from './dashboard_versioning'; import { LATEST_DASHBOARD_CONTAINER_VERSION } from '../../../dashboard_container'; +import { dashboardContentManagementCache } from '../dashboard_content_management_service'; import { DashboardCrudTypes, DashboardAttributes } from '../../../../common/content_management'; import { dashboardSaveToastStrings } from '../../../dashboard_container/_dashboard_container_strings'; -import { convertDashboardVersionToNumber } from './dashboard_versioning'; export const serializeControlGroupInput = ( controlGroupInput: DashboardContainerInput['controlGroupInput'] @@ -203,6 +204,8 @@ export const saveDashboardState = async ({ if (newId !== lastSavedId) { dashboardSessionStorage.clearState(lastSavedId); return { redirectRequired: true, id: newId }; + } else { + dashboardContentManagementCache.deleteDashboard(newId); // something changed in an existing dashboard, so delete it from the cache so that it can be re-fetched } } return { id: newId }; diff --git a/src/plugins/dashboard/public/services/dashboard_content_management/types.ts b/src/plugins/dashboard/public/services/dashboard_content_management/types.ts index 90a6bc49de25a..7eb9a0114bfec 100644 --- a/src/plugins/dashboard/public/services/dashboard_content_management/types.ts +++ b/src/plugins/dashboard/public/services/dashboard_content_management/types.ts @@ -92,8 +92,12 @@ export interface SaveDashboardReturn { */ export interface FindDashboardsService { search: ( - props: Pick + props: Pick< + SearchDashboardsArgs, + 'hasReference' | 'hasNoReference' | 'search' | 'size' | 'options' + > ) => Promise; + findById: (id: string) => Promise; findByIds: (ids: string[]) => Promise; findByTitle: (title: string) => Promise<{ id: string } | undefined>; } diff --git a/src/plugins/dashboard/public/services/mocks.ts b/src/plugins/dashboard/public/services/mocks.ts new file mode 100644 index 0000000000000..d695722102edb --- /dev/null +++ b/src/plugins/dashboard/public/services/mocks.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { pluginServices } from './plugin_services'; +import { registry } from './plugin_services.stub'; + +export function setStubDashboardServices() { + pluginServices.setRegistry(registry.start({})); +} + +setStubDashboardServices(); diff --git a/src/plugins/embeddable/public/add_panel_flyout/add_panel_flyout.tsx b/src/plugins/embeddable/public/add_panel_flyout/add_panel_flyout.tsx index 07765836057ff..f0554fed61782 100644 --- a/src/plugins/embeddable/public/add_panel_flyout/add_panel_flyout.tsx +++ b/src/plugins/embeddable/public/add_panel_flyout/add_panel_flyout.tsx @@ -103,7 +103,8 @@ export const AddPanelFlyout = ({ const embeddable = await container.addNewEmbeddable( factoryForSavedObjectType.type, - { savedObjectId: id } + { savedObjectId: id }, + savedObject.attributes ); onAddPanel?.(embeddable.id); diff --git a/src/plugins/embeddable/public/embeddable_panel/panel_actions/customize_panel_action/customize_panel_action.tsx b/src/plugins/embeddable/public/embeddable_panel/panel_actions/customize_panel_action/customize_panel_action.tsx index c3b99ca8fd4b7..63fc1902102b6 100644 --- a/src/plugins/embeddable/public/embeddable_panel/panel_actions/customize_panel_action/customize_panel_action.tsx +++ b/src/plugins/embeddable/public/embeddable_panel/panel_actions/customize_panel_action/customize_panel_action.tsx @@ -75,9 +75,15 @@ export class CustomizePanelAction implements Action (embeddable as VisualizeEmbeddable).getOutput().visTypeName === 'markdown'; const isImage = embeddable.type === 'image'; + const isNavigation = embeddable.type === 'navigation'; return Boolean( - embeddable && hasTimeRange(embeddable) && !isInputControl && !isMarkdown && !isImage + embeddable && + hasTimeRange(embeddable) && + !isInputControl && + !isMarkdown && + !isImage && + !isNavigation ); } diff --git a/src/plugins/embeddable/public/embeddable_panel/panel_actions/edit_panel_action/edit_panel_action.ts b/src/plugins/embeddable/public/embeddable_panel/panel_actions/edit_panel_action/edit_panel_action.ts index 3f465f2d37439..fe55b9a39158b 100644 --- a/src/plugins/embeddable/public/embeddable_panel/panel_actions/edit_panel_action/edit_panel_action.ts +++ b/src/plugins/embeddable/public/embeddable_panel/panel_actions/edit_panel_action/edit_panel_action.ts @@ -18,6 +18,7 @@ import { EmbeddableInput, EmbeddableEditorState, EmbeddableStateTransfer, + isExplicitInputWithAttributes, } from '../../../lib'; import { ViewMode } from '../../../lib/types'; import { EmbeddableStart } from '../../../plugin'; @@ -95,7 +96,19 @@ export class EditPanelAction implements Action { } const oldExplicitInput = embeddable.getExplicitInput(); - const newExplicitInput = await factory.getExplicitInput(oldExplicitInput); + let newExplicitInput: Partial; + try { + const explicitInputReturn = await factory.getExplicitInput( + oldExplicitInput, + embeddable.parent + ); + newExplicitInput = isExplicitInputWithAttributes(explicitInputReturn) + ? explicitInputReturn.newInput + : explicitInputReturn; + } catch (e) { + // error likely means user canceled editing + return; + } embeddable.parent?.replaceEmbeddable(embeddable.id, newExplicitInput); return; } diff --git a/src/plugins/embeddable/public/index.ts b/src/plugins/embeddable/public/index.ts index 91e6efcdc41c8..0e3650ea8a8a4 100644 --- a/src/plugins/embeddable/public/index.ts +++ b/src/plugins/embeddable/public/index.ts @@ -76,6 +76,7 @@ export { EmbeddableRenderer, useEmbeddableFactory, isFilterableEmbeddable, + isExplicitInputWithAttributes, shouldFetch$, shouldRefreshFilterCompareOptions, PANEL_HOVER_TRIGGER, diff --git a/src/plugins/embeddable/public/lib/containers/container.ts b/src/plugins/embeddable/public/lib/containers/container.ts index fceae56cf4b96..546c9a9a9bf7f 100644 --- a/src/plugins/embeddable/public/lib/containers/container.ts +++ b/src/plugins/embeddable/public/lib/containers/container.ts @@ -160,23 +160,32 @@ export abstract class Container< EEI extends EmbeddableInput = EmbeddableInput, EEO extends EmbeddableOutput = EmbeddableOutput, E extends IEmbeddable = IEmbeddable - >(type: string, explicitInput: Partial): Promise { + >(type: string, explicitInput: Partial, attributes?: unknown): Promise { const factory = this.getFactory(type) as EmbeddableFactory | undefined; if (!factory) { throw new EmbeddableFactoryNotFoundError(type); } - const panelState = this.createNewPanelState(factory, explicitInput); + const { newPanel, otherPanels } = this.createNewPanelState( + factory, + explicitInput, + attributes + ); - return this.createAndSaveEmbeddable(type, panelState); + return this.createAndSaveEmbeddable(type, newPanel, otherPanels); } public async replaceEmbeddable< EEI extends EmbeddableInput = EmbeddableInput, EEO extends EmbeddableOutput = EmbeddableOutput, E extends IEmbeddable = IEmbeddable - >(id: string, newExplicitInput: Partial, newType?: string) { + >( + id: string, + newExplicitInput: Partial, + newType?: string, + generateNewId?: boolean + ): Promise { if (!this.input.panels[id]) { throw new PanelNotFoundError(); } @@ -186,21 +195,28 @@ export abstract class Container< if (!factory) { throw new EmbeddableFactoryNotFoundError(newType); } - this.updateInput({ - panels: { - ...this.input.panels, - [id]: { - ...this.input.panels[id], - explicitInput: { ...newExplicitInput, id }, - type: newType, - }, - }, - } as Partial); - } else { - this.updateInputForChild(id, newExplicitInput); } + const panels = { ...this.input.panels }; + const oldPanel = panels[id]; + + if (generateNewId) { + delete panels[id]; + id = uuidv4(); + } + this.updateInput({ + panels: { + ...panels, + [id]: { + ...oldPanel, + explicitInput: { ...newExplicitInput, id }, + type: newType ?? oldPanel.type, + }, + }, + } as Partial); + await this.untilEmbeddableLoaded(id); + return id; } public removeEmbeddable(embeddableId: string) { @@ -301,7 +317,7 @@ export abstract class Container< public async getExplicitInputIsEqual(lastInput: TContainerInput) { const { panels: lastPanels, ...restOfLastInput } = lastInput; - const { panels: currentPanels, ...restOfCurrentInput } = this.getInput(); + const { panels: currentPanels, ...restOfCurrentInput } = this.getExplicitInput(); const otherInputIsEqual = isEqual(restOfLastInput, restOfCurrentInput); if (!otherInputIsEqual) return false; @@ -330,8 +346,9 @@ export abstract class Container< TEmbeddable extends IEmbeddable >( factory: EmbeddableFactory, - partial: Partial = {} - ): PanelState { + partial: Partial = {}, + attributes?: unknown + ): { newPanel: PanelState; otherPanels: TContainerInput['panels'] } { const embeddableId = partial.id || uuidv4(); const explicitInput = this.createNewExplicitEmbeddableInput( @@ -341,12 +358,15 @@ export abstract class Container< ); return { - type: factory.type, - explicitInput: { - ...explicitInput, - id: embeddableId, - version: factory.latestVersion, - } as TEmbeddableInput, + newPanel: { + type: factory.type, + explicitInput: { + ...explicitInput, + id: embeddableId, + version: factory.latestVersion, + } as TEmbeddableInput, + }, + otherPanels: this.getInput().panels, }; } @@ -372,7 +392,6 @@ export abstract class Container< initializeSettings?: EmbeddableContainerSettings ) { let initializeOrder = Object.keys(initialInput.panels); - if (initializeSettings?.childIdInitializeOrder) { const initializeOrderSet = new Set(); @@ -401,10 +420,10 @@ export abstract class Container< protected async createAndSaveEmbeddable< TEmbeddableInput extends EmbeddableInput = EmbeddableInput, TEmbeddable extends IEmbeddable = IEmbeddable - >(type: string, panelState: PanelState) { + >(type: string, panelState: PanelState, otherPanels: TContainerInput['panels']) { this.updateInput({ panels: { - ...this.input.panels, + ...otherPanels, [panelState.explicitInput.id]: panelState, }, } as Partial); diff --git a/src/plugins/embeddable/public/lib/containers/i_container.ts b/src/plugins/embeddable/public/lib/containers/i_container.ts index 5539f854b24d9..53226e7d15146 100644 --- a/src/plugins/embeddable/public/lib/containers/i_container.ts +++ b/src/plugins/embeddable/public/lib/containers/i_container.ts @@ -96,7 +96,8 @@ export interface IContainer< E extends Embeddable = Embeddable >( type: string, - explicitInput: Partial + explicitInput: Partial, + attributes?: unknown ): Promise; replaceEmbeddable< @@ -106,6 +107,7 @@ export interface IContainer< >( id: string, newExplicitInput: Partial, - newType?: string - ): void; + newType?: string, + generateNewId?: boolean + ): Promise; } diff --git a/src/plugins/embeddable/public/lib/embeddables/default_embeddable_factory_provider.ts b/src/plugins/embeddable/public/lib/embeddables/default_embeddable_factory_provider.ts index 472840208e139..50555601d4bca 100644 --- a/src/plugins/embeddable/public/lib/embeddables/default_embeddable_factory_provider.ts +++ b/src/plugins/embeddable/public/lib/embeddables/default_embeddable_factory_provider.ts @@ -30,6 +30,7 @@ export const defaultEmbeddableFactoryProvider = < } const factory: EmbeddableFactory = { + ...def, latestVersion: def.latestVersion, isContainerType: def.isContainerType ?? false, canCreateNew: def.canCreateNew ? def.canCreateNew.bind(def) : () => true, diff --git a/src/plugins/embeddable/public/lib/embeddables/embeddable_factory.ts b/src/plugins/embeddable/public/lib/embeddables/embeddable_factory.ts index 8f6b51ec41b23..a96287a61d0f3 100644 --- a/src/plugins/embeddable/public/lib/embeddables/embeddable_factory.ts +++ b/src/plugins/embeddable/public/lib/embeddables/embeddable_factory.ts @@ -24,6 +24,17 @@ export interface OutputSpec { [key: string]: PropertySpec; } +export interface ExplicitInputWithAttributes { + newInput: Partial; + attributes?: unknown; +} + +export const isExplicitInputWithAttributes = ( + value: ExplicitInputWithAttributes | Partial +): value is ExplicitInputWithAttributes => { + return Boolean((value as ExplicitInputWithAttributes).newInput); +}; + /** * EmbeddableFactories create and initialize an embeddable instance */ @@ -106,8 +117,14 @@ export interface EmbeddableFactory< * input passed down from the parent container. * * Can be used to edit an embeddable by re-requesting explicit input. Initial input can be provided to allow the editor to show the current state. + * + * If saved object information is needed for creation use-cases, getExplicitInput can also return an unknown typed attributes object which will be passed + * into the container's addNewEmbeddable function. */ - getExplicitInput(initialInput?: Partial): Promise>; + getExplicitInput( + initialInput?: Partial, + parent?: IContainer + ): Promise | ExplicitInputWithAttributes>; /** * Creates a new embeddable instance based off the saved object id. diff --git a/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts b/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts index f17f1e1cc42e8..0f3db5fc2bbab 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts @@ -493,6 +493,10 @@ export const stackManagementSchema: MakeSchemaFrom = { type: 'boolean', _meta: { description: 'Non-default value of setting.' }, }, + 'labs:dashboard:linksPanel': { + type: 'boolean', + _meta: { description: 'Non-default value of setting.' }, + }, 'discover:showFieldStatistics': { type: 'boolean', _meta: { description: 'Non-default value of setting.' }, diff --git a/src/plugins/kibana_usage_collection/server/collectors/management/types.ts b/src/plugins/kibana_usage_collection/server/collectors/management/types.ts index 902190f0cf675..fb3c31bf44d89 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/types.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/types.ts @@ -135,6 +135,7 @@ export interface UsageStats { 'labs:canvas:useDataService': boolean; 'labs:presentation:timeToPresent': boolean; 'labs:dashboard:enable_ui': boolean; + 'labs:dashboard:linksPanel': boolean; 'labs:dashboard:deferBelowFold': boolean; 'labs:dashboard:dashboardControls': boolean; 'discover:rowHeightOption': number; diff --git a/src/plugins/links/README.md b/src/plugins/links/README.md new file mode 100644 index 0000000000000..f2e37b203902b --- /dev/null +++ b/src/plugins/links/README.md @@ -0,0 +1,3 @@ +# Links panel + +This plugin adds the Links panel which allows authors to create hard links to navigate on click and bring all context from the source dashboard to the destination dashboard. diff --git a/src/plugins/links/common/constants.ts b/src/plugins/links/common/constants.ts new file mode 100644 index 0000000000000..eeba785bf21cd --- /dev/null +++ b/src/plugins/links/common/constants.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; + +export const LATEST_VERSION = 1; + +export const CONTENT_ID = 'links'; + +export const APP_ICON = 'link'; + +export const APP_NAME = i18n.translate('links.visTypeAlias.title', { + defaultMessage: 'Links', +}); diff --git a/src/plugins/links/common/content_management/cm_services.ts b/src/plugins/links/common/content_management/cm_services.ts new file mode 100644 index 0000000000000..8767b7badd9bd --- /dev/null +++ b/src/plugins/links/common/content_management/cm_services.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { + ContentManagementServicesDefinition as ServicesDefinition, + Version, +} from '@kbn/object-versioning'; + +// We export the versioned service definition from this file and not the index file to avoid adding +// the schemas in the "public" js bundle + +import { serviceDefinition as v1 } from './v1/cm_services'; + +export const cmServicesDefinition: { [version: Version]: ServicesDefinition } = { + 1: v1, +}; diff --git a/src/plugins/links/common/content_management/index.ts b/src/plugins/links/common/content_management/index.ts new file mode 100644 index 0000000000000..e2aa69ec32e4f --- /dev/null +++ b/src/plugins/links/common/content_management/index.ts @@ -0,0 +1,30 @@ +/* + * 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 { LATEST_VERSION, CONTENT_ID } from '../constants'; + +export type { LinksContentType } from '../types'; + +export type { + LinkType, + LinksLayoutType, + LinkOptions, + Link, + LinksItem, + LinksCrudTypes, + LinksAttributes, +} from './latest'; + +export { + EXTERNAL_LINK_TYPE, + DASHBOARD_LINK_TYPE, + LINKS_VERTICAL_LAYOUT, + LINKS_HORIZONTAL_LAYOUT, +} from './latest'; + +export * as LinksV1 from './v1'; diff --git a/src/plugins/links/common/content_management/latest.ts b/src/plugins/links/common/content_management/latest.ts new file mode 100644 index 0000000000000..e9c79f0f50f93 --- /dev/null +++ b/src/plugins/links/common/content_management/latest.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './v1'; diff --git a/src/plugins/links/common/content_management/v1/cm_services.ts b/src/plugins/links/common/content_management/v1/cm_services.ts new file mode 100644 index 0000000000000..597fcfe0d8451 --- /dev/null +++ b/src/plugins/links/common/content_management/v1/cm_services.ts @@ -0,0 +1,139 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import type { ContentManagementServicesDefinition as ServicesDefinition } from '@kbn/object-versioning'; +import { + savedObjectSchema, + createResultSchema, + updateOptionsSchema, + createOptionsSchemas, + objectTypeToGetResultSchema, +} from '@kbn/content-management-utils'; +import { DASHBOARD_LINK_TYPE, EXTERNAL_LINK_TYPE } from '.'; +import { LINKS_HORIZONTAL_LAYOUT, LINKS_VERTICAL_LAYOUT } from './constants'; + +const baseLinkSchema = { + id: schema.string(), + label: schema.maybe(schema.string()), + order: schema.number(), +}; + +const dashboardLinkSchema = schema.object({ + ...baseLinkSchema, + destinationRefName: schema.string(), + type: schema.literal(DASHBOARD_LINK_TYPE), + options: schema.maybe( + schema.object( + { + openInNewTab: schema.boolean(), + useCurrentFilters: schema.boolean(), + useCurrentDateRange: schema.boolean(), + }, + { unknowns: 'forbid' } + ) + ), +}); + +const externalLinkSchema = schema.object({ + ...baseLinkSchema, + type: schema.literal(EXTERNAL_LINK_TYPE), + destination: schema.string(), + options: schema.maybe( + schema.object( + { + openInNewTab: schema.boolean(), + encodeUrl: schema.boolean(), + }, + { unknowns: 'forbid' } + ) + ), +}); + +const linksAttributesSchema = schema.object( + { + title: schema.string(), + description: schema.maybe(schema.string()), + links: schema.arrayOf(schema.oneOf([dashboardLinkSchema, externalLinkSchema])), + layout: schema.maybe( + schema.oneOf([schema.literal(LINKS_HORIZONTAL_LAYOUT), schema.literal(LINKS_VERTICAL_LAYOUT)]) + ), + }, + { unknowns: 'forbid' } +); + +const linksSavedObjectSchema = savedObjectSchema(linksAttributesSchema); + +const searchOptionsSchema = schema.maybe( + schema.object( + { + onlyTitle: schema.maybe(schema.boolean()), + }, + { unknowns: 'forbid' } + ) +); + +const linksCreateOptionsSchema = schema.object({ + references: schema.maybe(createOptionsSchemas.references), + overwrite: createOptionsSchemas.overwrite, +}); + +const linksUpdateOptionsSchema = schema.object({ + references: updateOptionsSchema.references, +}); + +// Content management service definition. +// We need it for BWC support between different versions of the content +export const serviceDefinition: ServicesDefinition = { + get: { + out: { + result: { + schema: objectTypeToGetResultSchema(linksSavedObjectSchema), + }, + }, + }, + create: { + in: { + options: { + schema: linksCreateOptionsSchema, + }, + data: { + schema: linksAttributesSchema, + }, + }, + out: { + result: { + schema: createResultSchema(linksSavedObjectSchema), + }, + }, + }, + update: { + in: { + options: { + schema: linksUpdateOptionsSchema, // same schema as "create" + }, + data: { + schema: linksAttributesSchema, + }, + }, + }, + search: { + in: { + options: { + schema: searchOptionsSchema, + }, + }, + }, + mSearch: { + out: { + result: { + schema: linksSavedObjectSchema, + }, + }, + }, +}; diff --git a/src/plugins/links/common/content_management/v1/constants.ts b/src/plugins/links/common/content_management/v1/constants.ts new file mode 100644 index 0000000000000..f14fdbeaf28cb --- /dev/null +++ b/src/plugins/links/common/content_management/v1/constants.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +/** + * Link types + */ +export const DASHBOARD_LINK_TYPE = 'dashboardLink'; +export const EXTERNAL_LINK_TYPE = 'externalLink'; + +/** + * Layout options + */ +export const LINKS_HORIZONTAL_LAYOUT = 'horizontal'; +export const LINKS_VERTICAL_LAYOUT = 'vertical'; diff --git a/src/plugins/links/common/content_management/v1/index.ts b/src/plugins/links/common/content_management/v1/index.ts new file mode 100644 index 0000000000000..65738f89ff8a6 --- /dev/null +++ b/src/plugins/links/common/content_management/v1/index.ts @@ -0,0 +1,24 @@ +/* + * 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 { LinksCrudTypes } from './types'; +export type { + LinksCrudTypes, + LinksAttributes, + Link, + LinkOptions, + LinksLayoutType, + LinkType, +} from './types'; +export type LinksItem = LinksCrudTypes['Item']; +export { + EXTERNAL_LINK_TYPE, + DASHBOARD_LINK_TYPE, + LINKS_VERTICAL_LAYOUT, + LINKS_HORIZONTAL_LAYOUT, +} from './constants'; diff --git a/src/plugins/links/common/content_management/v1/types.ts b/src/plugins/links/common/content_management/v1/types.ts new file mode 100644 index 0000000000000..880bcbc67dd1d --- /dev/null +++ b/src/plugins/links/common/content_management/v1/types.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 type { + ContentManagementCrudTypes, + SavedObjectCreateOptions, + SavedObjectUpdateOptions, +} from '@kbn/content-management-utils'; +import { type UrlDrilldownOptions } from '@kbn/ui-actions-enhanced-plugin/public'; +import { type DashboardDrilldownOptions } from '@kbn/presentation-util-plugin/public'; + +import { LinksContentType } from '../../types'; +import { + DASHBOARD_LINK_TYPE, + EXTERNAL_LINK_TYPE, + LINKS_HORIZONTAL_LAYOUT, + LINKS_VERTICAL_LAYOUT, +} from './constants'; + +export type LinksCrudTypes = ContentManagementCrudTypes< + LinksContentType, + LinksAttributes, + Pick, + Pick, + { + /** Flag to indicate to only search the text on the "title" field */ + onlyTitle?: boolean; + } +>; + +export type LinkType = typeof DASHBOARD_LINK_TYPE | typeof EXTERNAL_LINK_TYPE; + +export type LinkOptions = DashboardDrilldownOptions | UrlDrilldownOptions; +interface BaseLink { + id: string; + label?: string; + order: number; + options?: LinkOptions; + destination?: string; +} + +interface DashboardLink extends BaseLink { + type: typeof DASHBOARD_LINK_TYPE; + destinationRefName?: string; +} + +interface ExternalLink extends BaseLink { + type: typeof EXTERNAL_LINK_TYPE; + destination: string; +} + +export type Link = DashboardLink | ExternalLink; + +export type LinksLayoutType = typeof LINKS_HORIZONTAL_LAYOUT | typeof LINKS_VERTICAL_LAYOUT; + +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type LinksAttributes = { + title: string; + description?: string; + links?: Link[]; + layout?: LinksLayoutType; +}; diff --git a/src/plugins/links/common/embeddable/extract.test.ts b/src/plugins/links/common/embeddable/extract.test.ts new file mode 100644 index 0000000000000..8653a3d650d70 --- /dev/null +++ b/src/plugins/links/common/embeddable/extract.test.ts @@ -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 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 { extract } from './extract'; + +test('Should return original state and empty references with by-reference embeddable state', () => { + const linksByReferenceInput = { + id: '2192e502-0ec7-4316-82fb-c9bbf78525c4', + type: 'links', + }; + + expect(extract!(linksByReferenceInput)).toEqual({ + state: linksByReferenceInput, + references: [], + }); +}); + +test('Should update state with refNames with by-value embeddable state', () => { + const linksByValueInput = { + id: '8d62c3f0-c61f-4c09-ac24-9b8ee4320e20', + attributes: { + links: [ + { + type: 'dashboardLink', + id: 'fc7b8c70-2eb9-40b2-936d-457d1721a438', + destination: 'elastic_agent-1a4e7280-6b5e-11ed-98de-67bdecd21824', + order: 0, + }, + ], + layout: 'horizontal', + }, + type: 'links', + }; + + expect(extract!(linksByValueInput)).toEqual({ + references: [ + { + name: 'link_fc7b8c70-2eb9-40b2-936d-457d1721a438_dashboard', + type: 'dashboard', + id: 'elastic_agent-1a4e7280-6b5e-11ed-98de-67bdecd21824', + }, + ], + state: { + id: '8d62c3f0-c61f-4c09-ac24-9b8ee4320e20', + attributes: { + links: [ + { + type: 'dashboardLink', + id: 'fc7b8c70-2eb9-40b2-936d-457d1721a438', + destinationRefName: 'link_fc7b8c70-2eb9-40b2-936d-457d1721a438_dashboard', + order: 0, + }, + ], + layout: 'horizontal', + }, + type: 'links', + }, + }); +}); diff --git a/src/plugins/links/common/embeddable/extract.ts b/src/plugins/links/common/embeddable/extract.ts new file mode 100644 index 0000000000000..5fe842e4316b1 --- /dev/null +++ b/src/plugins/links/common/embeddable/extract.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { EmbeddableRegistryDefinition } from '@kbn/embeddable-plugin/common'; +import type { LinksAttributes } from '../content_management'; +import { extractReferences } from '../persistable_state'; +import { LinksPersistableState } from './types'; + +export const extract: EmbeddableRegistryDefinition['extract'] = (state) => { + const typedState = state as LinksPersistableState; + + // by-reference embeddable + if (!('attributes' in typedState) || typedState.attributes === undefined) { + // No references to extract for by-reference embeddable since all references are stored with by-reference saved object + return { state, references: [] }; + } + + // by-value embeddable + const { attributes, references } = extractReferences({ + attributes: typedState.attributes as unknown as LinksAttributes, + }); + + return { + state: { + ...state, + attributes, + }, + references, + }; +}; diff --git a/src/plugins/links/common/embeddable/index.ts b/src/plugins/links/common/embeddable/index.ts new file mode 100644 index 0000000000000..c526b0bf9bff8 --- /dev/null +++ b/src/plugins/links/common/embeddable/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 { inject } from './inject'; +export { extract } from './extract'; diff --git a/src/plugins/links/common/embeddable/inject.test.ts b/src/plugins/links/common/embeddable/inject.test.ts new file mode 100644 index 0000000000000..4fdef93f8e3a9 --- /dev/null +++ b/src/plugins/links/common/embeddable/inject.test.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 { inject } from './inject'; + +test('Should return original state with by-reference embeddable state', () => { + const linksByReferenceInput = { + id: 'ea40fd4e-216c-49a7-917f-f733c8a2c817', + type: 'links', + }; + + const references = [ + { + name: 'panel_ea40fd4e-216c-49a7-917f-f733c8a2c817', + type: 'links', + id: '7f92d7d0-8e5f-11ec-9477-312c8a6de896', + }, + ]; + + expect(inject!(linksByReferenceInput, references)).toEqual(linksByReferenceInput); +}); + +test('Should inject refNames with by-value embeddable state', () => { + const linksByValueInput = { + id: 'c3937cf9-29be-43df-a4af-a4df742d7d35', + attributes: { + links: [ + { + type: 'dashboardLink', + id: 'fc7b8c70-2eb9-40b2-936d-457d1721a438', + destinationRefName: 'link_fc7b8c70-2eb9-40b2-936d-457d1721a438_dashboard', + order: 0, + }, + ], + layout: 'horizontal', + }, + type: 'links', + }; + const references = [ + { + name: 'link_fc7b8c70-2eb9-40b2-936d-457d1721a438_dashboard', + type: 'dashboard', + id: 'elastic_agent-1a4e7280-6b5e-11ed-98de-67bdecd21824', + }, + ]; + + expect(inject!(linksByValueInput, references)).toEqual({ + id: 'c3937cf9-29be-43df-a4af-a4df742d7d35', + attributes: { + links: [ + { + type: 'dashboardLink', + id: 'fc7b8c70-2eb9-40b2-936d-457d1721a438', + destination: 'elastic_agent-1a4e7280-6b5e-11ed-98de-67bdecd21824', + order: 0, + }, + ], + layout: 'horizontal', + }, + type: 'links', + }); +}); diff --git a/src/plugins/links/common/embeddable/inject.ts b/src/plugins/links/common/embeddable/inject.ts new file mode 100644 index 0000000000000..134a508406361 --- /dev/null +++ b/src/plugins/links/common/embeddable/inject.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { EmbeddableRegistryDefinition } from '@kbn/embeddable-plugin/common'; +import { LinksAttributes } from '../content_management'; +import { injectReferences } from '../persistable_state'; +import { LinksPersistableState } from './types'; + +export const inject: EmbeddableRegistryDefinition['inject'] = (state, references) => { + const typedState = state as LinksPersistableState; + + // by-reference embeddable + if (!('attributes' in typedState) || typedState.attributes === undefined) { + return typedState; + } + + // by-value embeddable + try { + const { attributes: attributesWithInjectedIds } = injectReferences({ + attributes: typedState.attributes as unknown as LinksAttributes, + references, + }); + + return { + ...typedState, + attributes: attributesWithInjectedIds, + }; + } catch (error) { + // inject exception prevents entire dashboard from display + // Instead of throwing, swallow error and let dashboard display + // Errors will surface in links panel. + // Users can then manually edit links to resolve any problems. + return typedState; + } +}; diff --git a/src/plugins/links/common/embeddable/types.ts b/src/plugins/links/common/embeddable/types.ts new file mode 100644 index 0000000000000..b916d34f70840 --- /dev/null +++ b/src/plugins/links/common/embeddable/types.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { EmbeddableStateWithType } from '@kbn/embeddable-plugin/common'; +import { SerializableRecord } from '@kbn/utility-types'; + +export type LinksPersistableState = EmbeddableStateWithType & { + attributes: SerializableRecord; +}; diff --git a/src/plugins/links/common/index.ts b/src/plugins/links/common/index.ts new file mode 100644 index 0000000000000..9cb4fc42124aa --- /dev/null +++ b/src/plugins/links/common/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { APP_ICON, APP_NAME, CONTENT_ID, LATEST_VERSION } from './constants'; diff --git a/src/plugins/links/common/mocks.tsx b/src/plugins/links/common/mocks.tsx new file mode 100644 index 0000000000000..299f9edcacdc4 --- /dev/null +++ b/src/plugins/links/common/mocks.tsx @@ -0,0 +1,53 @@ +/* + * 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 { buildMockDashboard } from '@kbn/dashboard-plugin/public/mocks'; +import { DashboardContainerInput } from '@kbn/dashboard-plugin/common'; +import { LinksByValueInput } from '../public/embeddable/types'; +import { LinksFactoryDefinition } from '../public'; +import { LinksAttributes } from './content_management'; + +jest.mock('../public/services/attribute_service', () => { + return { + getLinksAttributeService: jest.fn(() => { + return { + saveMethod: jest.fn(), + unwrapMethod: jest.fn(), + checkForDuplicateTitle: jest.fn(), + unwrapAttributes: jest.fn((attributes: LinksByValueInput) => Promise.resolve(attributes)), + wrapAttributes: jest.fn((attributes: LinksAttributes) => Promise.resolve(attributes)), + }; + }), + }; +}); + +export const mockLinksInput = (partial?: Partial): LinksByValueInput => ({ + id: 'mocked_links_panel', + attributes: { + title: 'mocked_links', + }, + ...(partial ?? {}), +}); + +export const mockLinksPanel = async ({ + explicitInput, + dashboardExplicitInput, +}: { + explicitInput?: Partial; + dashboardExplicitInput?: Partial; +}) => { + const dashboardContainer = buildMockDashboard({ + overrides: dashboardExplicitInput, + savedObjectId: '123', + }); + const linksFactoryStub = new LinksFactoryDefinition(); + + const links = await linksFactoryStub.create(mockLinksInput(explicitInput), dashboardContainer); + + return links; +}; diff --git a/src/plugins/links/common/persistable_state/index.ts b/src/plugins/links/common/persistable_state/index.ts new file mode 100644 index 0000000000000..c3e09839f0f2f --- /dev/null +++ b/src/plugins/links/common/persistable_state/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { extractReferences, injectReferences } from './references'; diff --git a/src/plugins/links/common/persistable_state/references.test.ts b/src/plugins/links/common/persistable_state/references.test.ts new file mode 100644 index 0000000000000..cf74ba929b1aa --- /dev/null +++ b/src/plugins/links/common/persistable_state/references.test.ts @@ -0,0 +1,163 @@ +/* + * 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 { DASHBOARD_LINK_TYPE, EXTERNAL_LINK_TYPE } from '../content_management'; +import { extractReferences, injectReferences } from './references'; + +describe('extractReferences', () => { + test('should handle missing links attribute', () => { + const attributes = { + title: 'my links', + }; + expect(extractReferences({ attributes })).toEqual({ + attributes: { + title: 'my links', + }, + references: [], + }); + }); + + test('should extract dashboard references from dashboard links', () => { + const attributes = { + title: 'my links', + links: [ + { + id: 'fb1b3fc7-6e12-4542-bcf5-c61ad77241c5', + type: DASHBOARD_LINK_TYPE as typeof DASHBOARD_LINK_TYPE, + destination: '19e149f0-e95e-404b-b6f8-fc751317c6be', + order: 0, + }, + { + id: '4d5cd000-5632-4d3a-ad41-11d7800ff2aa', + type: EXTERNAL_LINK_TYPE as typeof EXTERNAL_LINK_TYPE, + destination: 'https://example.com', + order: 1, + }, + { + id: '1409fabb-1d2b-49c2-a2dc-705bd8fabd0c', + type: DASHBOARD_LINK_TYPE as typeof DASHBOARD_LINK_TYPE, + destination: '39555f99-a3b8-4210-b1ef-fa0fa86fa3da', + order: 2, + }, + ], + }; + expect(extractReferences({ attributes })).toEqual({ + attributes: { + title: 'my links', + links: [ + { + id: 'fb1b3fc7-6e12-4542-bcf5-c61ad77241c5', + type: 'dashboardLink', + destinationRefName: 'link_fb1b3fc7-6e12-4542-bcf5-c61ad77241c5_dashboard', + order: 0, + }, + { + id: '4d5cd000-5632-4d3a-ad41-11d7800ff2aa', + type: 'externalLink', + destination: 'https://example.com', + order: 1, + }, + { + id: '1409fabb-1d2b-49c2-a2dc-705bd8fabd0c', + type: 'dashboardLink', + destinationRefName: 'link_1409fabb-1d2b-49c2-a2dc-705bd8fabd0c_dashboard', + order: 2, + }, + ], + }, + references: [ + { + id: '19e149f0-e95e-404b-b6f8-fc751317c6be', + name: 'link_fb1b3fc7-6e12-4542-bcf5-c61ad77241c5_dashboard', + type: 'dashboard', + }, + { + id: '39555f99-a3b8-4210-b1ef-fa0fa86fa3da', + name: 'link_1409fabb-1d2b-49c2-a2dc-705bd8fabd0c_dashboard', + type: 'dashboard', + }, + ], + }); + }); +}); + +describe('injectReferences', () => { + test('should handle missing links attribute', () => { + const attributes = { + title: 'my links', + }; + expect(injectReferences({ attributes, references: [] })).toEqual({ + attributes: { + title: 'my links', + }, + }); + }); + + test('should inject dashboard references into dashboard links', () => { + const attributes = { + title: 'my links', + links: [ + { + id: 'fb1b3fc7-6e12-4542-bcf5-c61ad77241c5', + type: DASHBOARD_LINK_TYPE as typeof DASHBOARD_LINK_TYPE, + destinationRefName: 'link_fb1b3fc7-6e12-4542-bcf5-c61ad77241c5_dashboard', + order: 0, + }, + { + id: '4d5cd000-5632-4d3a-ad41-11d7800ff2aa', + type: EXTERNAL_LINK_TYPE as typeof EXTERNAL_LINK_TYPE, + destination: 'https://example.com', + order: 1, + }, + { + id: '1409fabb-1d2b-49c2-a2dc-705bd8fabd0c', + type: DASHBOARD_LINK_TYPE as typeof DASHBOARD_LINK_TYPE, + destinationRefName: 'link_1409fabb-1d2b-49c2-a2dc-705bd8fabd0c_dashboard', + order: 2, + }, + ], + }; + const references = [ + { + id: '19e149f0-e95e-404b-b6f8-fc751317c6be', + name: 'link_fb1b3fc7-6e12-4542-bcf5-c61ad77241c5_dashboard', + type: 'dashboard', + }, + { + id: '39555f99-a3b8-4210-b1ef-fa0fa86fa3da', + name: 'link_1409fabb-1d2b-49c2-a2dc-705bd8fabd0c_dashboard', + type: 'dashboard', + }, + ]; + expect(injectReferences({ attributes, references })).toEqual({ + attributes: { + title: 'my links', + links: [ + { + id: 'fb1b3fc7-6e12-4542-bcf5-c61ad77241c5', + type: 'dashboardLink', + destination: '19e149f0-e95e-404b-b6f8-fc751317c6be', + order: 0, + }, + { + id: '4d5cd000-5632-4d3a-ad41-11d7800ff2aa', + type: 'externalLink', + destination: 'https://example.com', + order: 1, + }, + { + id: '1409fabb-1d2b-49c2-a2dc-705bd8fabd0c', + type: 'dashboardLink', + destination: '39555f99-a3b8-4210-b1ef-fa0fa86fa3da', + order: 2, + }, + ], + }, + }); + }); +}); diff --git a/src/plugins/links/common/persistable_state/references.ts b/src/plugins/links/common/persistable_state/references.ts new file mode 100644 index 0000000000000..1410cdc53d234 --- /dev/null +++ b/src/plugins/links/common/persistable_state/references.ts @@ -0,0 +1,81 @@ +/* + * 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 } from '@kbn/content-management-utils'; +import { DASHBOARD_LINK_TYPE, LinksAttributes } from '../content_management'; + +export function extractReferences({ + attributes, + references = [], +}: { + attributes: LinksAttributes; + references?: Reference[]; +}) { + if (!attributes.links) { + return { attributes, references }; + } + + const { links } = attributes; + const extractedReferences: Reference[] = []; + links.forEach((link) => { + if (link.type === DASHBOARD_LINK_TYPE && link.destination) { + const refName = `link_${link.id}_dashboard`; + link.destinationRefName = refName; + extractedReferences.push({ + name: refName, + type: 'dashboard', + id: link.destination, + }); + delete link.destination; + } + }); + + return { + attributes: { + ...attributes, + links, + }, + references: references.concat(extractedReferences), + }; +} + +function findReference(targetName: string, references: Reference[]) { + const reference = references.find(({ name }) => name === targetName); + if (!reference) { + throw new Error(`Could not find reference "${targetName}"`); + } + return reference; +} + +export function injectReferences({ + attributes, + references, +}: { + attributes: LinksAttributes; + references: Reference[]; +}) { + if (!attributes.links) { + return { attributes }; + } + + const { links } = attributes; + links.forEach((link) => { + if (link.type === DASHBOARD_LINK_TYPE && link.destinationRefName) { + const reference = findReference(link.destinationRefName, references); + link.destination = reference.id; + delete link.destinationRefName; + } + }); + + return { + attributes: { + ...attributes, + links, + }, + }; +} diff --git a/src/plugins/links/common/types.ts b/src/plugins/links/common/types.ts new file mode 100644 index 0000000000000..54c14afec77bf --- /dev/null +++ b/src/plugins/links/common/types.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { SavedObjectsResolveResponse } from '@kbn/core-saved-objects-api-server'; +import { CONTENT_ID } from './constants'; + +export type LinksContentType = typeof CONTENT_ID; + +export interface SharingSavedObjectProps { + outcome: SavedObjectsResolveResponse['outcome']; + aliasTargetId?: SavedObjectsResolveResponse['alias_target_id']; + aliasPurpose?: SavedObjectsResolveResponse['alias_purpose']; + sourceId?: string; +} diff --git a/src/plugins/links/jest.config.js b/src/plugins/links/jest.config.js new file mode 100644 index 0000000000000..51cc1202f61aa --- /dev/null +++ b/src/plugins/links/jest.config.js @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../..', + roots: ['/src/plugins/links'], + coverageDirectory: '/target/kibana-coverage/jest/src/plugins/links', + coverageReporters: ['text', 'html'], + collectCoverageFrom: ['/src/plugins/links/{common,public,server}/**/*.{ts,tsx}'], + setupFiles: ['/src/plugins/links/jest_setup.ts'], +}; diff --git a/src/plugins/links/jest_setup.ts b/src/plugins/links/jest_setup.ts new file mode 100644 index 0000000000000..32ed14af1efcc --- /dev/null +++ b/src/plugins/links/jest_setup.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { setStubDashboardServices } from '@kbn/dashboard-plugin/public/services/mocks'; +import { setStubKibanaServices } from './public/mocks'; + +setStubKibanaServices(); +setStubDashboardServices(); diff --git a/src/plugins/links/kibana.jsonc b/src/plugins/links/kibana.jsonc new file mode 100644 index 0000000000000..5f0796d55b43a --- /dev/null +++ b/src/plugins/links/kibana.jsonc @@ -0,0 +1,22 @@ +{ + "type": "plugin", + "id": "@kbn/links-plugin", + "owner": "@elastic/kibana-presentation", + "description": "A dashboard panel for creating links to dashboards or external links.", + "plugin": { + "id": "links", + "server": true, + "browser": true, + "requiredPlugins": [ + "contentManagement", + "dashboard", + "embeddable", + "kibanaReact", + "presentationUtil", + "uiActionsEnhanced", + "kibanaUtils" + ], + "optionalPlugins": ["triggersActionsUi"], + "requiredBundles": ["savedObjects"] + } +} diff --git a/src/plugins/links/public/_mixins.scss b/src/plugins/links/public/_mixins.scss new file mode 100644 index 0000000000000..cc9b7a5168d80 --- /dev/null +++ b/src/plugins/links/public/_mixins.scss @@ -0,0 +1,38 @@ +@import '../../../core/public/mixins'; + +@keyframes euiFlyoutOpenAnimation { + 0% { + opacity: 0; + transform: translateX(100%); + } + + 100% { + opacity: 1; + transform: translateX(0%); + } +} + +@keyframes euiFlyoutCloseAnimation { + 0% { + opacity: 1; + transform: translateX(0%); + } + + 100% { + opacity: 0; + transform: translateX(100%); + } +} + +@mixin euiFlyout { + @include kibanaFullBodyHeight(); + position: fixed; + display: flex; + inline-size: 50vw; + z-index: $euiZFlyout; + align-items: stretch; + flex-direction: column; + border-left: $euiBorderThin; + background: $euiColorEmptyShade; + min-width: ($euiSizeXL * 13) + $euiSizeS; // 424px +} \ No newline at end of file diff --git a/src/plugins/links/public/components/dashboard_link/dashboard_link_component.test.tsx b/src/plugins/links/public/components/dashboard_link/dashboard_link_component.test.tsx new file mode 100644 index 0000000000000..90dbdd434e2eb --- /dev/null +++ b/src/plugins/links/public/components/dashboard_link/dashboard_link_component.test.tsx @@ -0,0 +1,224 @@ +/* + * 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 userEvent from '@testing-library/user-event'; +import { createEvent, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { DEFAULT_DASHBOARD_DRILLDOWN_OPTIONS } from '@kbn/presentation-util-plugin/public'; +import { DashboardLinkStrings } from './dashboard_link_strings'; +import { LinksEmbeddable, LinksContext } from '../../embeddable/links_embeddable'; +import { mockLinksPanel } from '../../../common/mocks'; +import { LINKS_VERTICAL_LAYOUT } from '../../../common/content_management'; +import { DashboardLinkComponent } from './dashboard_link_component'; +import { fetchDashboard, getDashboardHref, getDashboardLocator } from './dashboard_link_tools'; +import { coreServices } from '../../services/kibana_services'; + +jest.mock('./dashboard_link_tools'); + +describe('Dashboard link component', () => { + const mockDashboards = [ + { + id: '456', + status: 'success', + attributes: { + title: 'another dashboard', + description: 'something awesome', + panelsJSON: [], + timeRestore: false, + version: '1', + }, + }, + { + id: '123', + status: 'success', + attributes: { + title: 'current dashboard', + description: '', + panelsJSON: [], + timeRestore: false, + version: '1', + }, + }, + ]; + + const defaultLinkInfo = { + destination: '456', + order: 1, + id: 'foo', + type: 'dashboardLink' as const, + }; + + let linksEmbeddable: LinksEmbeddable; + beforeEach(async () => { + window.open = jest.fn(); + (fetchDashboard as jest.Mock).mockResolvedValue(mockDashboards[0]); + (getDashboardLocator as jest.Mock).mockResolvedValue({ + app: 'dashboard', + path: '/dashboardItem/456', + state: {}, + }); + (getDashboardHref as jest.Mock).mockReturnValue('https://my-kibana.com/dashboard/123'); + linksEmbeddable = await mockLinksPanel({ + dashboardExplicitInput: mockDashboards[1].attributes, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('by default uses navigateToApp to open in same tab', async () => { + render( + + + + ); + + await waitFor(() => expect(fetchDashboard).toHaveBeenCalledTimes(1)); + expect(fetchDashboard).toHaveBeenCalledWith(defaultLinkInfo.destination); + expect(getDashboardLocator).toHaveBeenCalledTimes(1); + expect(getDashboardLocator).toHaveBeenCalledWith({ + link: { + ...defaultLinkInfo, + options: DEFAULT_DASHBOARD_DRILLDOWN_OPTIONS, + }, + linksEmbeddable, + }); + + const link = await screen.findByTestId('dashboardLink--foo'); + expect(link).toHaveTextContent('another dashboard'); + await userEvent.click(link); + expect(coreServices.application.navigateToApp).toBeCalledTimes(1); + expect(coreServices.application.navigateToApp).toBeCalledWith('dashboard', { + path: '/dashboardItem/456', + state: {}, + }); + }); + + test('modified click does not trigger event.preventDefault', async () => { + render( + + + + ); + await waitFor(() => expect(fetchDashboard).toHaveBeenCalledTimes(1)); + const link = await screen.findByTestId('dashboardLink--foo'); + const clickEvent = createEvent.click(link, { ctrlKey: true }); + const preventDefault = jest.spyOn(clickEvent, 'preventDefault'); + fireEvent(link, clickEvent); + expect(preventDefault).toHaveBeenCalledTimes(0); + }); + + test('openInNewTab uses window.open, not navigateToApp', async () => { + const linkInfo = { + ...defaultLinkInfo, + options: { ...DEFAULT_DASHBOARD_DRILLDOWN_OPTIONS, openInNewTab: true }, + }; + render( + + + + ); + await waitFor(() => expect(fetchDashboard).toHaveBeenCalledTimes(1)); + expect(fetchDashboard).toHaveBeenCalledWith(linkInfo.destination); + expect(getDashboardLocator).toHaveBeenCalledWith({ link: linkInfo, linksEmbeddable }); + const link = await screen.findByTestId('dashboardLink--foo'); + expect(link).toBeInTheDocument(); + await userEvent.click(link); + expect(coreServices.application.navigateToApp).toBeCalledTimes(0); + expect(window.open).toHaveBeenCalledWith('https://my-kibana.com/dashboard/123', '_blank'); + }); + + test('passes linkOptions to getDashboardLocator', async () => { + const linkInfo = { + ...defaultLinkInfo, + options: { + ...DEFAULT_DASHBOARD_DRILLDOWN_OPTIONS, + useCurrentFilters: false, + useCurrentTimeRange: false, + useCurrentDateRange: false, + }, + }; + render( + + + + ); + await waitFor(() => expect(fetchDashboard).toHaveBeenCalledTimes(1)); + expect(getDashboardLocator).toHaveBeenCalledWith({ link: linkInfo, linksEmbeddable }); + }); + + test('shows an error when fetchDashboard fails', async () => { + (fetchDashboard as jest.Mock).mockRejectedValue(new Error('some error')); + const linkInfo = { + ...defaultLinkInfo, + id: 'notfound', + }; + render( + + + + ); + await waitFor(() => expect(fetchDashboard).toHaveBeenCalledTimes(1)); + const link = await screen.findByTestId('dashboardLink--notfound--error'); + expect(link).toHaveTextContent(DashboardLinkStrings.getDashboardErrorLabel()); + }); + + test('current dashboard is not a clickable href', async () => { + const linkInfo = { + ...defaultLinkInfo, + destination: '123', + id: 'bar', + }; + render( + + + + ); + await waitFor(() => expect(fetchDashboard).toHaveBeenCalledTimes(1)); + const link = await screen.findByTestId('dashboardLink--bar'); + expect(link).toHaveTextContent('current dashboard'); + await userEvent.click(link); + expect(coreServices.application.navigateToApp).toBeCalledTimes(0); + expect(window.open).toBeCalledTimes(0); + }); + + test('shows dashboard title and description in tooltip', async () => { + render( + + + + ); + await waitFor(() => expect(fetchDashboard).toHaveBeenCalledTimes(1)); + const link = await screen.findByTestId('dashboardLink--foo'); + await userEvent.hover(link); + const tooltip = await screen.findByTestId('dashboardLink--foo--tooltip'); + expect(tooltip).toHaveTextContent('another dashboard'); // title + expect(tooltip).toHaveTextContent('something awesome'); // description + }); + + test('can override link label', async () => { + const label = 'my custom label'; + const linkInfo = { + ...defaultLinkInfo, + label, + }; + render( + + + + ); + await waitFor(() => expect(fetchDashboard).toHaveBeenCalledTimes(1)); + const link = await screen.findByTestId('dashboardLink--foo'); + expect(link).toHaveTextContent(label); + await userEvent.hover(link); + const tooltip = await screen.findByTestId('dashboardLink--foo--tooltip'); + expect(tooltip).toHaveTextContent(label); + }); +}); diff --git a/src/plugins/links/public/components/dashboard_link/dashboard_link_component.tsx b/src/plugins/links/public/components/dashboard_link/dashboard_link_component.tsx new file mode 100644 index 0000000000000..563cf6277c796 --- /dev/null +++ b/src/plugins/links/public/components/dashboard_link/dashboard_link_component.tsx @@ -0,0 +1,171 @@ +/* + * 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 classNames from 'classnames'; +import useAsync from 'react-use/lib/useAsync'; +import React, { useMemo, useState } from 'react'; +import useObservable from 'react-use/lib/useObservable'; + +import { + DashboardDrilldownOptions, + DEFAULT_DASHBOARD_DRILLDOWN_OPTIONS, +} from '@kbn/presentation-util-plugin/public'; +import { EuiButtonEmpty, EuiListGroupItem } from '@elastic/eui'; +import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; + +import { LINKS_VERTICAL_LAYOUT, LinksLayoutType, Link } from '../../../common/content_management'; +import { coreServices } from '../../services/kibana_services'; +import { DashboardLinkStrings } from './dashboard_link_strings'; +import { useLinks } from '../../embeddable/links_embeddable'; +import { fetchDashboard, getDashboardHref, getDashboardLocator } from './dashboard_link_tools'; + +export const DashboardLinkComponent = ({ + link, + layout, +}: { + link: Link; + layout: LinksLayoutType; +}) => { + const linksEmbeddable = useLinks(); + const [error, setError] = useState(); + + const dashboardContainer = linksEmbeddable.parent as DashboardContainer; + const parentDashboardInput = useObservable(dashboardContainer.getInput$()); + const parentDashboardId = dashboardContainer.select((state) => state.componentState.lastSavedId); + + /** Fetch the dashboard that the link is pointing to */ + const { loading: loadingDestinationDashboard, value: destinationDashboard } = + useAsync(async () => { + if (link.id !== parentDashboardId && link.destination) { + /** + * only fetch the dashboard if it's not the current dashboard - if it is the current dashboard, + * use `dashboardContainer` and its corresponding state (title, description, etc.) instead. + */ + const dashboard = await fetchDashboard(link.destination) + .then((result) => { + setError(undefined); + return result; + }) + .catch((e) => setError(e)); + return dashboard; + } + }, [link, parentDashboardId]); + + /** + * Returns the title and description of the dashboard that the link points to; note that, if the link points to + * the current dashboard, then we need to get the most up-to-date information via the `parentDashboardInput` - this + * will respond to changes so that the link label/tooltip remains in sync with the dashboard title/description. + */ + const [dashboardTitle, dashboardDescription] = useMemo(() => { + return link.destination === parentDashboardId + ? [parentDashboardInput?.title, parentDashboardInput?.description] + : [destinationDashboard?.attributes.title, destinationDashboard?.attributes.description]; + }, [link.destination, parentDashboardId, parentDashboardInput, destinationDashboard]); + + /** + * Memoized link information + */ + const linkLabel = useMemo(() => { + return link.label || (dashboardTitle ?? DashboardLinkStrings.getDashboardErrorLabel()); + }, [link, dashboardTitle]); + + const { tooltipTitle, tooltipMessage } = useMemo(() => { + if (error) { + return { + tooltipTitle: DashboardLinkStrings.getDashboardErrorLabel(), + tooltipMessage: error.message, + }; + } + return { + tooltipTitle: Boolean(dashboardDescription) ? linkLabel : undefined, + tooltipMessage: dashboardDescription || linkLabel, + }; + }, [error, linkLabel, dashboardDescription]); + + /** + * Dashboard-to-dashboard navigation + */ + const { loading: loadingOnClickProps, value: onClickProps } = useAsync(async () => { + /** If the link points to the current dashboard, then there should be no `onClick` or `href` prop */ + if (link.destination === parentDashboardId) return; + + const linkOptions = { + ...DEFAULT_DASHBOARD_DRILLDOWN_OPTIONS, + ...link.options, + } as DashboardDrilldownOptions; + + const locator = await getDashboardLocator({ + link: { ...link, options: linkOptions }, + linksEmbeddable, + }); + if (!locator) return; + + const href = getDashboardHref(locator); + return { + href, + onClick: async (event: React.MouseEvent) => { + /** + * If the link is being opened via a modified click, then we should use the default `href` navigation behaviour + * by passing all the dashboard state via the URL - this will keep behaviour consistent across all browsers. + */ + const modifiedClick = event.ctrlKey || event.metaKey || event.shiftKey; + if (modifiedClick) { + return; + } + + /** Otherwise, prevent the default behaviour and handle click depending on `openInNewTab` option */ + event.preventDefault(); + if (linkOptions.openInNewTab) { + window.open(href, '_blank'); + } else { + const { app, path, state } = locator; + await coreServices.application.navigateToApp(app, { + path, + state, + }); + } + }, + }; + }, [link]); + + const id = `dashboardLink--${link.id}`; + + return loadingDestinationDashboard ? ( +
  • + + {DashboardLinkStrings.getLoadingDashboardLabel()} + +
  • + ) : ( + + ); +}; diff --git a/src/plugins/links/public/components/dashboard_link/dashboard_link_destination_picker.tsx b/src/plugins/links/public/components/dashboard_link/dashboard_link_destination_picker.tsx new file mode 100644 index 0000000000000..137d604c2e01e --- /dev/null +++ b/src/plugins/links/public/components/dashboard_link/dashboard_link_destination_picker.tsx @@ -0,0 +1,149 @@ +/* + * 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 { debounce } from 'lodash'; +import useAsync from 'react-use/lib/useAsync'; +import useMount from 'react-use/lib/useMount'; +import useUnmount from 'react-use/lib/useUnmount'; +import React, { useCallback, useMemo, useState } from 'react'; + +import { + EuiBadge, + EuiComboBox, + EuiFlexItem, + EuiHighlight, + EuiFlexGroup, + EuiComboBoxOptionOption, +} from '@elastic/eui'; +import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; + +import { DashboardItem } from '../../embeddable/types'; +import { DashboardLinkStrings } from './dashboard_link_strings'; +import { fetchDashboard, fetchDashboards } from './dashboard_link_tools'; + +type DashboardComboBoxOption = EuiComboBoxOptionOption; + +export const DashboardLinkDestinationPicker = ({ + onDestinationPicked, + initialSelection, + parentDashboard, + onUnmount, + ...other +}: { + initialSelection?: string; + parentDashboard?: DashboardContainer; + onUnmount: (dashboardId?: string) => void; + onDestinationPicked: (selectedDashboard?: DashboardItem) => void; +}) => { + const [searchString, setSearchString] = useState(''); + const [selectedOption, setSelectedOption] = useState([]); + + const parentDashboardId = parentDashboard?.select((state) => state.componentState.lastSavedId); + + const getDashboardItem = useCallback((dashboard: DashboardItem) => { + return { + key: dashboard.id, + value: dashboard, + label: dashboard.attributes.title, + className: 'linksDashboardItem', + }; + }, []); + + useMount(async () => { + if (initialSelection) { + const dashboard = await fetchDashboard(initialSelection).catch(() => { + /** + * Swallow the error that is thrown, since this just means the selected dashboard was deleted and + * so we should treat this the same as "no previous selection." + */ + }); + if (dashboard) { + onDestinationPicked(dashboard); + setSelectedOption([getDashboardItem(dashboard)]); + } else { + onDestinationPicked(undefined); + } + } + }); + + useUnmount(() => { + /** Save the current selection so we can re-populate it if we switch back to this link editor */ + onUnmount(selectedOption[0]?.key); + }); + + const { loading: loadingDashboardList, value: dashboardList } = useAsync(async () => { + const dashboards = await fetchDashboards({ + search: searchString, + parentDashboardId, + selectedDashboardId: initialSelection, + }); + const dashboardOptions = (dashboards ?? []).map((dashboard: DashboardItem) => { + return getDashboardItem(dashboard); + }); + return dashboardOptions; + }, [searchString, parentDashboardId, getDashboardItem]); + + const debouncedSetSearch = useMemo( + () => + debounce((newSearch: string) => { + setSearchString(newSearch); + }, 250), + [setSearchString] + ); + + const renderOption = useCallback( + (option, searchValue, contentClassName) => { + const { label, key: dashboardId } = option; + return ( + + {dashboardId === parentDashboardId && ( + + {DashboardLinkStrings.getCurrentDashboardLabel()} + + )} + + + {label} + + + + ); + }, + [parentDashboardId] + ); + + /* {...other} is needed so the EuiComboBox is treated as part of the form */ + return ( + { + debouncedSetSearch(searchValue); + }} + renderOption={renderOption} + selectedOptions={selectedOption} + onChange={(option) => { + setSelectedOption(option); + if (option.length > 0) { + // single select is `true`, so there is only ever one item in the array + onDestinationPicked(option[0].value); + } else { + onDestinationPicked(undefined); + } + }} + data-test-subj="links--linkEditor--dashboardLink--comboBox" + /> + ); +}; diff --git a/src/plugins/links/public/components/dashboard_link/dashboard_link_strings.ts b/src/plugins/links/public/components/dashboard_link/dashboard_link_strings.ts new file mode 100644 index 0000000000000..873a7ca51c7fe --- /dev/null +++ b/src/plugins/links/public/components/dashboard_link/dashboard_link_strings.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; + +export const DashboardLinkStrings = { + getType: () => + i18n.translate('links.dashboardLink.type', { + defaultMessage: 'Dashboard link', + }), + getDisplayName: () => + i18n.translate('links.dashboardLink.displayName', { + defaultMessage: 'Dashboard', + }), + getDescription: () => + i18n.translate('links.dashboardLink.description', { + defaultMessage: 'Go to dashboard', + }), + getDashboardPickerPlaceholder: () => + i18n.translate('links.dashboardLink.editor.dashboardComboBoxPlaceholder', { + defaultMessage: 'Search for a dashboard', + }), + getDashboardPickerAriaLabel: () => + i18n.translate('links.dashboardLink.editor.dashboardPickerAriaLabel', { + defaultMessage: 'Pick a destination dashboard', + }), + getCurrentDashboardLabel: () => + i18n.translate('links.dashboardLink.editor.currentDashboardLabel', { + defaultMessage: 'Current', + }), + getLoadingDashboardLabel: () => + i18n.translate('links.dashboardLink.editor.loadingDashboardLabel', { + defaultMessage: 'Loading...', + }), + getDashboardErrorLabel: () => + i18n.translate('links.dashboardLink.editor.dashboardErrorLabel', { + defaultMessage: 'Error fetching dashboard', + }), +}; diff --git a/src/plugins/links/public/components/dashboard_link/dashboard_link_tools.ts b/src/plugins/links/public/components/dashboard_link/dashboard_link_tools.ts new file mode 100644 index 0000000000000..eb51758bd9b68 --- /dev/null +++ b/src/plugins/links/public/components/dashboard_link/dashboard_link_tools.ts @@ -0,0 +1,151 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { isEmpty, filter } from 'lodash'; + +import { + cleanEmptyKeys, + getEmbeddableParams, + DashboardAppLocatorParams, +} from '@kbn/dashboard-plugin/public'; +import { isFilterPinned } from '@kbn/es-query'; +import { KibanaLocation } from '@kbn/share-plugin/public'; +import { setStateToKbnUrl } from '@kbn/kibana-utils-plugin/public'; +import { DashboardDrilldownOptions } from '@kbn/presentation-util-plugin/public'; + +import { DashboardItem } from '../../embeddable/types'; +import type { LinksEmbeddable } from '../../embeddable'; +import { Link } from '../../../common/content_management'; +import { coreServices, dashboardServices } from '../../services/kibana_services'; + +/** + * ---------------------------------- + * Fetch a single dashboard + * ---------------------------------- + */ + +export const fetchDashboard = async (dashboardId: string): Promise => { + const findDashboardsService = await dashboardServices.findDashboardsService(); + const response = await findDashboardsService.findById(dashboardId); + if (response.status === 'error') { + throw new Error(response.error.message); + } + return response; +}; + +/** + * ---------------------------------- + * Fetch lists of dashboards + * ---------------------------------- + */ + +interface FetchDashboardsProps { + size?: number; + search?: string; + parentDashboardId?: string; + selectedDashboardId?: string; +} + +export const fetchDashboards = async ({ + search = '', + size = 10, + parentDashboardId, + selectedDashboardId, +}: FetchDashboardsProps): Promise => { + const findDashboardsService = await dashboardServices.findDashboardsService(); + const responses = await findDashboardsService.search({ + search, + size, + options: { onlyTitle: true }, + }); + + let dashboardList: DashboardItem[] = responses.hits; + + /** If there is no search string... */ + if (isEmpty(search)) { + /** ... filter out both the parent and selected dashboard from the list ... */ + dashboardList = filter(dashboardList, (dash) => { + return dash.id !== parentDashboardId && dash.id !== selectedDashboardId; + }); + + /** ... so that we can force them to the top of the list as necessary. */ + if (parentDashboardId) { + dashboardList.unshift(await fetchDashboard(parentDashboardId)); + } + + if (selectedDashboardId && selectedDashboardId !== parentDashboardId) { + const selectedDashboard = await fetchDashboard(selectedDashboardId).catch(() => { + /** + * Swallow the error thrown, since this just means the selected dashboard was deleted and therefore + * it should not be added to the top of the dashboard list + */ + }); + if (selectedDashboard) dashboardList.unshift(await fetchDashboard(selectedDashboardId)); + } + } + + /** Then, only return the parts of the dashboard object that we need */ + const simplifiedDashboardList = dashboardList.map((hit) => { + return { id: hit.id, attributes: hit.attributes }; + }); + + return simplifiedDashboardList; +}; + +/** + * ---------------------------------- + * Navigate from one dashboard to another + * ---------------------------------- + */ + +interface GetDashboardLocatorProps { + link: Link & { options: DashboardDrilldownOptions }; + linksEmbeddable: LinksEmbeddable; +} + +/** + * Fetch the locator to use for dashboard navigation + * @param props `GetDashboardLocatorProps` + * @returns The locator to use for dashboard navigation + */ +export const getDashboardLocator = async ({ link, linksEmbeddable }: GetDashboardLocatorProps) => { + const params: DashboardAppLocatorParams = { + dashboardId: link.destination, + ...getEmbeddableParams(linksEmbeddable, link.options), + }; + + const locator = dashboardServices.locator; // TODO: Make this generic as part of https://github.com/elastic/kibana/issues/164748 + if (locator) { + const location: KibanaLocation = await locator.getLocation(params); + return location; + } +}; + +/** + * Get URL for dashboard app - should only be used when relying on native `href` functionality + * @param locator Locator that should be used to get the URL + * @returns A full URL to the dashboard, with all state included + */ +export const getDashboardHref = ({ + app, + path, + state, +}: KibanaLocation): string => { + return coreServices.application.getUrlForApp(app, { + path: setStateToKbnUrl( + '_a', + cleanEmptyKeys({ + query: state.query, + filters: state.filters?.filter((f) => !isFilterPinned(f)), + }), + { useHash: false, storeInHashQuery: true }, + path + ), + absolute: true, + }); +}; diff --git a/src/plugins/links/public/components/editor/link_destination.tsx b/src/plugins/links/public/components/editor/link_destination.tsx new file mode 100644 index 0000000000000..bd33b6245ab51 --- /dev/null +++ b/src/plugins/links/public/components/editor/link_destination.tsx @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useState } from 'react'; + +import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; + +import { EuiFormRow } from '@elastic/eui'; +import { + LinkType, + EXTERNAL_LINK_TYPE, + DASHBOARD_LINK_TYPE, +} from '../../../common/content_management'; +import { UnorderedLink } from '../../editor/open_link_editor_flyout'; +import { ExternalLinkDestinationPicker } from '../external_link/external_link_destination_picker'; +import { DashboardLinkDestinationPicker } from '../dashboard_link/dashboard_link_destination_picker'; +import { LinksStrings } from '../links_strings'; + +export const LinkDestination = ({ + link, + setDestination, + parentDashboard, + selectedLinkType, +}: { + selectedLinkType: LinkType; + parentDashboard?: DashboardContainer; + link?: UnorderedLink; + setDestination: (destination?: string, defaultLabel?: string) => void; +}) => { + const [destinationError, setDestinationError] = useState(); + + /** + * Store the dashboard / external destinations separately so that we can remember the selections + * made in each component even when the selected link type changes + */ + const [dashboardLinkDestination, setDashboardLinkDestination] = useState( + link && link.type === DASHBOARD_LINK_TYPE ? link.destination : undefined + ); + const [externalLinkDestination, setExternalLinkDestination] = useState( + link && link.type === EXTERNAL_LINK_TYPE ? link.destination : undefined + ); + + const isInvalid = Boolean(destinationError); + + return ( + + {selectedLinkType === DASHBOARD_LINK_TYPE ? ( + { + setDestination(undefined, undefined); + if (selectedDashboardId) setDashboardLinkDestination(selectedDashboardId); + }} + parentDashboard={parentDashboard} + initialSelection={dashboardLinkDestination} + onDestinationPicked={(dashboard) => + setDestination(dashboard?.id, dashboard?.attributes.title) + } + /> + ) : ( + { + setDestinationError(undefined); + setDestination(undefined, undefined); + if (selectedUrl) setExternalLinkDestination(selectedUrl); + }} + initialSelection={externalLinkDestination} + onDestinationPicked={(url) => setDestination(url, url)} + setDestinationError={setDestinationError} + /> + )} + + ); +}; diff --git a/src/plugins/links/public/components/editor/link_editor.tsx b/src/plugins/links/public/components/editor/link_editor.tsx new file mode 100644 index 0000000000000..5cd4c60870c5e --- /dev/null +++ b/src/plugins/links/public/components/editor/link_editor.tsx @@ -0,0 +1,188 @@ +/* + * 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 { v4 as uuidv4 } from 'uuid'; +import React, { useCallback, useMemo, useState } from 'react'; + +import { + EuiForm, + EuiIcon, + EuiTitle, + EuiButton, + EuiFormRow, + EuiFlexItem, + EuiFieldText, + EuiFocusTrap, + EuiFlexGroup, + EuiRadioGroup, + EuiFlyoutBody, + EuiButtonEmpty, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiRadioGroupOption, +} from '@elastic/eui'; +import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; + +import { + LinkType, + EXTERNAL_LINK_TYPE, + DASHBOARD_LINK_TYPE, + LinkOptions, + Link, +} from '../../../common/content_management'; +import { LinksStrings } from '../links_strings'; +import { LinkInfo } from '../../embeddable/types'; +import { LinkOptionsComponent } from './link_options'; +import { UnorderedLink } from '../../editor/open_link_editor_flyout'; +import { LinkDestination } from './link_destination'; + +export const LinkEditor = ({ + link, + onSave, + onClose, + parentDashboard, +}: { + onClose: () => void; + parentDashboard?: DashboardContainer; + link?: UnorderedLink; // will only be defined if **editing** a link; otherwise, creating a new link + onSave: (newLink: Omit) => void; +}) => { + const [selectedLinkType, setSelectedLinkType] = useState( + link?.type ?? DASHBOARD_LINK_TYPE + ); + const [defaultLinkLabel, setDefaultLinkLabel] = useState(); + const [currentLinkLabel, setCurrentLinkLabel] = useState(link?.label ?? ''); + const [linkOptions, setLinkOptions] = useState(); + const [linkDestination, setLinkDestination] = useState(link?.destination); + + const linkTypes: EuiRadioGroupOption[] = useMemo(() => { + return ([DASHBOARD_LINK_TYPE, EXTERNAL_LINK_TYPE] as LinkType[]).map((type) => { + return { + id: type, + label: ( + + + + + {LinkInfo[type].displayName} + + ), + 'data-test-subj': `links--linkEditor--${type}--radioBtn`, + }; + }); + }, []); + + /** When a new destination is picked, handle the logic for what to display as the current + default labels */ + const handleDestinationPicked = useCallback( + (destination?: string, label?: string) => { + setLinkDestination(destination); + if (!currentLinkLabel || defaultLinkLabel === currentLinkLabel) { + setCurrentLinkLabel(label ?? ''); + } + setDefaultLinkLabel(label); + }, + [defaultLinkLabel, currentLinkLabel] + ); + + return ( + + + onClose()} + > + +

    + {link + ? LinksStrings.editor.getEditLinkTitle() + : LinksStrings.editor.getAddButtonLabel()} +

    +
    +
    +
    + + + + { + if (currentLinkLabel === defaultLinkLabel) { + setCurrentLinkLabel(link?.type === id ? link.label ?? '' : ''); + } + setSelectedLinkType(id as LinkType); + }} + /> + + + + setCurrentLinkLabel(e.target.value)} + data-test-subj="links--linkEditor--linkLabel--input" + /> + + + + + + + + onClose()} + iconType="cross" + data-test-subj="links--linkEditor--closeBtn" + > + {LinksStrings.editor.getCancelButtonLabel()} + + + + { + // this check should always be true, since the button is disabled otherwise - this is just for type safety + if (linkDestination) { + onSave({ + label: currentLinkLabel === defaultLinkLabel ? undefined : currentLinkLabel, + type: selectedLinkType, + id: link?.id ?? uuidv4(), + destination: linkDestination, + options: linkOptions, + }); + + onClose(); + } + }} + data-test-subj="links--linkEditor--saveBtn" + > + {link + ? LinksStrings.editor.getUpdateButtonLabel() + : LinksStrings.editor.getAddButtonLabel()} + + + + +
    + ); +}; diff --git a/src/plugins/links/public/components/editor/link_options.tsx b/src/plugins/links/public/components/editor/link_options.tsx new file mode 100644 index 0000000000000..46d1f5d1086de --- /dev/null +++ b/src/plugins/links/public/components/editor/link_options.tsx @@ -0,0 +1,71 @@ +/* + * 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 } from '@elastic/eui'; +import { + DashboardDrilldownOptions, + DashboardDrilldownOptionsComponent, + DEFAULT_DASHBOARD_DRILLDOWN_OPTIONS, +} from '@kbn/presentation-util-plugin/public'; +import { + UrlDrilldownOptions, + UrlDrilldownOptionsComponent, + DEFAULT_URL_DRILLDOWN_OPTIONS, +} from '@kbn/ui-actions-enhanced-plugin/public'; + +import { + LinkType, + EXTERNAL_LINK_TYPE, + DASHBOARD_LINK_TYPE, + LinkOptions, +} from '../../../common/content_management'; +import { LinksStrings } from '../links_strings'; +import { UnorderedLink } from '../../editor/open_link_editor_flyout'; + +export const LinkOptionsComponent = ({ + link, + setLinkOptions, + selectedLinkType, +}: { + selectedLinkType: LinkType; + link?: UnorderedLink; + setLinkOptions: (options: LinkOptions) => void; +}) => { + const [dashboardLinkOptions, setDashboardLinkOptions] = useState({ + ...DEFAULT_DASHBOARD_DRILLDOWN_OPTIONS, + ...(link && link.type === DASHBOARD_LINK_TYPE ? link.options : {}), + }); + const [externalLinkOptions, setExternalLinkOptions] = useState({ + ...DEFAULT_URL_DRILLDOWN_OPTIONS, + ...(link && link.type === EXTERNAL_LINK_TYPE ? link.options : {}), + }); + + return ( + + {selectedLinkType === DASHBOARD_LINK_TYPE ? ( + { + setDashboardLinkOptions({ ...dashboardLinkOptions, ...change }); + setLinkOptions({ ...dashboardLinkOptions, ...change }); + }} + /> + ) : ( + { + setExternalLinkOptions({ ...externalLinkOptions, ...change }); + setLinkOptions({ ...externalLinkOptions, ...change }); + }} + /> + )} + + ); +}; diff --git a/src/plugins/links/public/components/editor/links_editor.scss b/src/plugins/links/public/components/editor/links_editor.scss new file mode 100644 index 0000000000000..3eb0d574ddf27 --- /dev/null +++ b/src/plugins/links/public/components/editor/links_editor.scss @@ -0,0 +1,77 @@ +@import '../../mixins'; + +.linksPanelEditor { + .linkEditor { + @include euiFlyout; + max-inline-size: $euiSizeXXL * 18; // 40px * 18 = 720px + + &.in { + animation: euiFlyoutOpenAnimation $euiAnimSpeedNormal $euiAnimSlightResistance; + } + + &.out { + animation: euiFlyoutCloseAnimation $euiAnimSpeedNormal $euiAnimSlightResistance; + } + + .linkEditorBackButton { + height: auto; + } + } +} + +.linksDashboardItem { + .euiBadge { + cursor: pointer !important; + } + + // in order to ensure that the "Current" badge doesn't recieve an underline on hover, we have to set the + // text-decoration to `none` for the entire list item and manually set the underline **only** on the text + &:hover { + text-decoration: none; + } + + .linksPanelLinkText { + &:hover { + text-decoration: underline !important; + } + } +} + +.linksPanelLink { + padding: $euiSizeXS $euiSizeS; + color: $euiTextColor; + + .linksPanelLinkText { + flex: 1; + min-width: 0; + } + + &.linkError { + border: 1px solid transparentize($euiColorWarningText, .7); + + .linksPanelLinkText { + color: $euiColorWarningText; + } + + .linksPanelLinkText--noLabel { + font-style: italic; + } + } + + .links_hoverActions { + opacity: 0; + visibility: hidden; + transition: visibility $euiAnimSpeedNormal, opacity $euiAnimSpeedNormal; + } + + &:hover, &:focus-within { + .links_hoverActions { + opacity: 1; + visibility: visible; + } + } +} + +.linksDroppableLinksArea { + margin: 0 (-$euiSizeXS); +} diff --git a/src/plugins/links/public/components/editor/links_editor.test.tsx b/src/plugins/links/public/components/editor/links_editor.test.tsx new file mode 100644 index 0000000000000..c9c1c0eff183d --- /dev/null +++ b/src/plugins/links/public/components/editor/links_editor.test.tsx @@ -0,0 +1,121 @@ +/* + * 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 userEvent from '@testing-library/user-event'; +import { render, screen, waitFor } from '@testing-library/react'; +import LinksEditor from './links_editor'; +import { LinksStrings } from '../links_strings'; +import { Link, LINKS_VERTICAL_LAYOUT } from '../../../common/content_management'; +import { fetchDashboard } from '../dashboard_link/dashboard_link_tools'; + +jest.mock('../dashboard_link/dashboard_link_tools', () => { + return { + fetchDashboard: jest.fn().mockImplementation((id: string) => + Promise.resolve({ + id, + status: 'success', + attributes: { + title: `dashboard #${id}`, + description: '', + panelsJSON: [], + timeRestore: false, + version: '1', + }, + references: [], + }) + ), + }; +}); + +describe('LinksEditor', () => { + const defaultProps = { + onSaveToLibrary: jest.fn().mockImplementation(() => Promise.resolve()), + onAddToDashboard: jest.fn(), + onClose: jest.fn(), + isByReference: false, + }; + + const someLinks: Link[] = [ + { + id: 'foo', + type: 'dashboardLink' as const, + order: 1, + destination: '123', + }, + { + id: 'bar', + type: 'dashboardLink' as const, + order: 4, + destination: '456', + }, + { + id: 'bizz', + type: 'externalLink' as const, + order: 3, + destination: 'http://example.com', + }, + { + id: 'buzz', + type: 'externalLink' as const, + order: 2, + destination: 'http://elastic.co', + }, + ]; + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('shows empty state with no links', async () => { + render(); + expect(screen.getByTestId('links--panelEditor--title')).toHaveTextContent( + LinksStrings.editor.panelEditor.getCreateFlyoutTitle() + ); + expect(screen.getByTestId('links--panelEditor--emptyPrompt')).toBeInTheDocument(); + expect(screen.getByTestId('links--panelEditor--saveBtn')).toBeDisabled(); + + await userEvent.click(screen.getByTestId('links--panelEditor--closeBtn')); + expect(defaultProps.onClose).toHaveBeenCalledTimes(1); + }); + + test('shows links in order', async () => { + const expectedLinkIds = [...someLinks].sort((a, b) => a.order - b.order).map(({ id }) => id); + render(); + await waitFor(() => expect(fetchDashboard).toHaveBeenCalledTimes(2)); + expect(screen.getByTestId('links--panelEditor--title')).toHaveTextContent( + LinksStrings.editor.panelEditor.getEditFlyoutTitle() + ); + const draggableLinks = screen.getAllByTestId('links--panelEditor--draggableLink'); + expect(draggableLinks.length).toEqual(4); + + draggableLinks.forEach((link, idx) => { + expect(link).toHaveAttribute('data-rfd-draggable-id', expectedLinkIds[idx]); + }); + }); + + test('saving by reference panels calls onSaveToLibrary', async () => { + const orderedLinks = [...someLinks].sort((a, b) => a.order - b.order); + render(); + await waitFor(() => expect(fetchDashboard).toHaveBeenCalledTimes(2)); + const saveButton = screen.getByTestId('links--panelEditor--saveBtn'); + await userEvent.click(saveButton); + await waitFor(() => expect(defaultProps.onSaveToLibrary).toHaveBeenCalledTimes(1)); + expect(defaultProps.onSaveToLibrary).toHaveBeenCalledWith(orderedLinks, LINKS_VERTICAL_LAYOUT); + }); + + test('saving by value panel calls onAddToDashboard', async () => { + const orderedLinks = [...someLinks].sort((a, b) => a.order - b.order); + render(); + await waitFor(() => expect(fetchDashboard).toHaveBeenCalledTimes(2)); + const saveButton = screen.getByTestId('links--panelEditor--saveBtn'); + await userEvent.click(saveButton); + expect(defaultProps.onAddToDashboard).toHaveBeenCalledTimes(1); + expect(defaultProps.onAddToDashboard).toHaveBeenCalledWith(orderedLinks, LINKS_VERTICAL_LAYOUT); + }); +}); diff --git a/src/plugins/links/public/components/editor/links_editor.tsx b/src/plugins/links/public/components/editor/links_editor.tsx new file mode 100644 index 0000000000000..0fb22efaf8507 --- /dev/null +++ b/src/plugins/links/public/components/editor/links_editor.tsx @@ -0,0 +1,318 @@ +/* + * 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, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { + EuiForm, + EuiBadge, + EuiTitle, + EuiButton, + EuiSwitch, + EuiFormRow, + EuiToolTip, + EuiFlexItem, + EuiFlexGroup, + EuiDroppable, + EuiDraggable, + EuiFlyoutBody, + EuiButtonEmpty, + EuiButtonGroup, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiDragDropContext, + euiDragDropReorder, + EuiButtonGroupOptionProps, +} from '@elastic/eui'; +import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; + +import { LinksLayoutInfo } from '../../embeddable/types'; +import { + Link, + LinksLayoutType, + LINKS_HORIZONTAL_LAYOUT, + LINKS_VERTICAL_LAYOUT, +} from '../../../common/content_management'; +import { coreServices } from '../../services/kibana_services'; +import { LinksStrings } from '../links_strings'; +import { openLinkEditorFlyout } from '../../editor/open_link_editor_flyout'; +import { memoizedGetOrderedLinkList } from '../../editor/links_editor_tools'; +import { LinksEditorEmptyPrompt } from './links_editor_empty_prompt'; +import { LinksEditorSingleLink } from './links_editor_single_link'; + +import { TooltipWrapper } from '../tooltip_wrapper'; + +import './links_editor.scss'; + +const layoutOptions: EuiButtonGroupOptionProps[] = [ + { + id: LINKS_VERTICAL_LAYOUT, + label: LinksLayoutInfo[LINKS_VERTICAL_LAYOUT].displayName, + 'data-test-subj': `links--panelEditor--${LINKS_VERTICAL_LAYOUT}LayoutBtn`, + }, + { + id: LINKS_HORIZONTAL_LAYOUT, + label: LinksLayoutInfo[LINKS_HORIZONTAL_LAYOUT].displayName, + 'data-test-subj': `links--panelEditor--${LINKS_HORIZONTAL_LAYOUT}LayoutBtn`, + }, +]; + +const LinksEditor = ({ + onSaveToLibrary, + onAddToDashboard, + onClose, + initialLinks, + initialLayout, + parentDashboard, + isByReference, +}: { + onSaveToLibrary: (newLinks: Link[], newLayout: LinksLayoutType) => Promise; + onAddToDashboard: (newLinks: Link[], newLayout: LinksLayoutType) => void; + onClose: () => void; + initialLinks?: Link[]; + initialLayout?: LinksLayoutType; + parentDashboard?: DashboardContainer; + isByReference: boolean; +}) => { + const toasts = coreServices.notifications.toasts; + const editLinkFlyoutRef = useRef(null); + + const [currentLayout, setCurrentLayout] = useState( + initialLayout ?? LINKS_VERTICAL_LAYOUT + ); + const [isSaving, setIsSaving] = useState(false); + const [orderedLinks, setOrderedLinks] = useState([]); + const [saveByReference, setSaveByReference] = useState(!initialLinks ? true : isByReference); + + const isEditingExisting = initialLinks || isByReference; + + useEffect(() => { + if (!initialLinks) { + setOrderedLinks([]); + return; + } + setOrderedLinks(memoizedGetOrderedLinkList(initialLinks)); + }, [initialLinks]); + + const onDragEnd = useCallback( + ({ source, destination }) => { + if (source && destination) { + const newList = euiDragDropReorder(orderedLinks, source.index, destination.index).map( + (link, i) => { + return { ...link, order: i }; + } + ); + setOrderedLinks(newList); + } + }, + [orderedLinks] + ); + + const addOrEditLink = useCallback( + async (linkToEdit?: Link) => { + const newLink = await openLinkEditorFlyout({ + parentDashboard, + link: linkToEdit, + ref: editLinkFlyoutRef, + }); + if (newLink) { + if (linkToEdit) { + setOrderedLinks( + orderedLinks.map((link) => { + if (link.id === linkToEdit.id) { + return { ...newLink, order: linkToEdit.order } as Link; + } + return link; + }) + ); + } else { + setOrderedLinks([...orderedLinks, { ...newLink, order: orderedLinks.length } as Link]); + } + } + }, + [editLinkFlyoutRef, orderedLinks, parentDashboard] + ); + + const hasZeroLinks = useMemo(() => { + return orderedLinks.length === 0; + }, [orderedLinks]); + + const deleteLink = useCallback( + (linkId: string) => { + setOrderedLinks( + orderedLinks.filter((link) => { + return link.id !== linkId; + }) + ); + }, + [orderedLinks] + ); + + return ( + <> +
    + + + + +

    + {isEditingExisting + ? LinksStrings.editor.panelEditor.getEditFlyoutTitle() + : LinksStrings.editor.panelEditor.getCreateFlyoutTitle()} +

    +
    +
    + + + {/* The EuiBadge needs an empty title to prevent the default tooltip */} + + {LinksStrings.editor.panelEditor.getTechnicalPreviewLabel()} + + + +
    +
    + + + + { + setCurrentLayout(id as LinksLayoutType); + }} + legend={LinksStrings.editor.panelEditor.getLayoutSettingsLegend()} + /> + + + {/* Needs to be surrounded by a div rather than a fragment so the EuiFormRow can respond + to the focus of the inner elements */} +
    + {hasZeroLinks ? ( + addOrEditLink()} /> + ) : ( + <> + + + {orderedLinks.map((link, idx) => ( + + {(provided) => ( + addOrEditLink(link)} + deleteLink={() => deleteLink(link.id)} + dragHandleProps={provided.dragHandleProps ?? undefined} // casting `null` to `undefined` + /> + )} + + ))} + + + addOrEditLink()} + data-test-subj="links--panelEditor--addLinkBtn" + > + {LinksStrings.editor.getAddButtonLabel()} + + + )} +
    +
    +
    +
    + + + + + {LinksStrings.editor.getCancelButtonLabel()} + + + + + {!initialLinks || !isByReference ? ( + + + setSaveByReference(!saveByReference)} + data-test-subj="links--panelEditor--saveByReferenceSwitch" + /> + + + ) : null} + + + { + if (saveByReference) { + setIsSaving(true); + onSaveToLibrary(orderedLinks, currentLayout) + .catch((e) => { + toasts.addError(e, { + title: LinksStrings.editor.panelEditor.getErrorDuringSaveToastTitle(), + }); + }) + .finally(() => { + setIsSaving(false); + }); + } else { + onAddToDashboard(orderedLinks, currentLayout); + } + }} + > + {LinksStrings.editor.panelEditor.getSaveButtonLabel()} + + + + + + + + + ); +}; + +// required for dynamic import using React.lazy() +// eslint-disable-next-line import/no-default-export +export default LinksEditor; diff --git a/src/plugins/links/public/components/editor/links_editor_empty_prompt.tsx b/src/plugins/links/public/components/editor/links_editor_empty_prompt.tsx new file mode 100644 index 0000000000000..763b08437e4d3 --- /dev/null +++ b/src/plugins/links/public/components/editor/links_editor_empty_prompt.tsx @@ -0,0 +1,41 @@ +/* + * 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 { EuiText, EuiPanel, EuiSpacer, EuiButton, EuiEmptyPrompt, EuiFormRow } from '@elastic/eui'; + +import { LinksStrings } from '../links_strings'; + +export const LinksEditorEmptyPrompt = ({ addLink }: { addLink: () => Promise }) => { + return ( + + + + {LinksStrings.editor.panelEditor.getEmptyLinksMessage()} + + + {LinksStrings.editor.getAddButtonLabel()} + + + } + /> + + + ); +}; diff --git a/src/plugins/links/public/components/editor/links_editor_single_link.tsx b/src/plugins/links/public/components/editor/links_editor_single_link.tsx new file mode 100644 index 0000000000000..e6f7bc12ee6a4 --- /dev/null +++ b/src/plugins/links/public/components/editor/links_editor_single_link.tsx @@ -0,0 +1,185 @@ +/* + * 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 classNames from 'classnames'; +import React, { useMemo, useState } from 'react'; +import useAsync from 'react-use/lib/useAsync'; + +import { + EuiText, + EuiIcon, + EuiPanel, + EuiToolTip, + EuiFlexItem, + EuiFlexGroup, + EuiButtonIcon, + EuiSkeletonTitle, + DraggableProvidedDragHandleProps, +} from '@elastic/eui'; +import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; + +import { LinkInfo } from '../../embeddable/types'; +import { validateUrl } from '../external_link/external_link_tools'; +import { fetchDashboard } from '../dashboard_link/dashboard_link_tools'; +import { LinksStrings } from '../links_strings'; +import { DashboardLinkStrings } from '../dashboard_link/dashboard_link_strings'; +import { DASHBOARD_LINK_TYPE, Link } from '../../../common/content_management'; + +export const LinksEditorSingleLink = ({ + link, + editLink, + deleteLink, + parentDashboard, + dragHandleProps, +}: { + editLink: () => void; + deleteLink: () => void; + link: Link; + parentDashboard?: DashboardContainer; + dragHandleProps?: DraggableProvidedDragHandleProps; +}) => { + const [destinationError, setDestinationError] = useState(); + const parentDashboardTitle = parentDashboard?.select((state) => state.explicitInput.title); + const parentDashboardId = parentDashboard?.select((state) => state.componentState.lastSavedId); + + const { value: linkLabel, loading: linkLabelLoading } = useAsync(async () => { + if (!link.destination) { + setDestinationError(new Error(DashboardLinkStrings.getDashboardErrorLabel())); + return; + } + + if (link.type === DASHBOARD_LINK_TYPE) { + if (parentDashboardId === link.destination) { + return link.label || parentDashboardTitle; + } else { + const dashboard = await fetchDashboard(link.destination) + .then((result) => { + setDestinationError(undefined); + return result; + }) + .catch((error) => setDestinationError(error)); + return ( + link.label || + (dashboard ? dashboard.attributes.title : DashboardLinkStrings.getDashboardErrorLabel()) + ); + } + } else { + const { valid, message } = validateUrl(link.destination); + if (!valid && message) { + setDestinationError(new Error(message)); + } + return link.label || link.destination; + } + }, [link]); + + const LinkLabel = useMemo(() => { + const labelText = ( + + + + + + + + + {linkLabel} + + + + + ); + + return () => + destinationError ? ( + + {labelText} + + ) : ( + labelText + ); + }, [linkLabel, linkLabelLoading, destinationError, link.label, link.type]); + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/src/plugins/links/public/components/external_link/external_link_component.test.tsx b/src/plugins/links/public/components/external_link/external_link_component.test.tsx new file mode 100644 index 0000000000000..1afdc17c43563 --- /dev/null +++ b/src/plugins/links/public/components/external_link/external_link_component.test.tsx @@ -0,0 +1,103 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 userEvent from '@testing-library/user-event'; +import { createEvent, fireEvent, render, screen } from '@testing-library/react'; +import { LinksEmbeddable, LinksContext } from '../../embeddable/links_embeddable'; +import { mockLinksPanel } from '../../../common/mocks'; +import { LINKS_VERTICAL_LAYOUT } from '../../../common/content_management'; +import { ExternalLinkComponent } from './external_link_component'; +import { coreServices } from '../../services/kibana_services'; +import { DEFAULT_URL_DRILLDOWN_OPTIONS } from '@kbn/ui-actions-enhanced-plugin/public'; + +describe('external link component', () => { + const defaultLinkInfo = { + destination: 'https://example.com', + order: 1, + id: 'foo', + type: 'externalLink' as const, + }; + + let links: LinksEmbeddable; + beforeEach(async () => { + window.open = jest.fn(); + links = await mockLinksPanel({}); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('by default opens in new tab', async () => { + render( + + + + ); + + const link = await screen.findByTestId('externalLink--foo'); + expect(link).toBeInTheDocument(); + await userEvent.click(link); + expect(window.open).toHaveBeenCalledWith('https://example.com', '_blank'); + }); + + test('modified click does not trigger event.preventDefault', async () => { + const linkInfo = { + ...defaultLinkInfo, + options: { ...DEFAULT_URL_DRILLDOWN_OPTIONS, openInNewTab: false }, + }; + render( + + + + ); + const link = await screen.findByTestId('externalLink--foo'); + expect(link).toHaveTextContent('https://example.com'); + const clickEvent = createEvent.click(link, { ctrlKey: true }); + const preventDefault = jest.spyOn(clickEvent, 'preventDefault'); + fireEvent(link, clickEvent); + expect(preventDefault).toHaveBeenCalledTimes(0); + }); + + test('uses navigateToUrl when openInNewTab is false', async () => { + const linkInfo = { + ...defaultLinkInfo, + options: { ...DEFAULT_URL_DRILLDOWN_OPTIONS, openInNewTab: false }, + }; + render( + + + + ); + const link = await screen.findByTestId('externalLink--foo'); + await userEvent.click(link); + expect(coreServices.application.navigateToUrl).toBeCalledTimes(1); + expect(coreServices.application.navigateToUrl).toBeCalledWith('https://example.com'); + }); + + test('disables link when url validation fails', async () => { + const linkInfo = { + ...defaultLinkInfo, + destination: 'file://buzz', + }; + render( + + + + ); + const link = await screen.findByTestId('externalLink--foo--error'); + expect(link).toBeDisabled(); + /** + * TODO: We should test the tooltip content, but the component is disabled + * so it has pointer-events: none. This means we can not use userEvent.hover(). + * See https://testing-library.com/docs/ecosystem-user-event#pointer-events-options + */ + }); +}); diff --git a/src/plugins/links/public/components/external_link/external_link_component.tsx b/src/plugins/links/public/components/external_link/external_link_component.tsx new file mode 100644 index 0000000000000..9c2231fe6b711 --- /dev/null +++ b/src/plugins/links/public/components/external_link/external_link_component.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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useMemo, useState } from 'react'; + +import { + UrlDrilldownOptions, + DEFAULT_URL_DRILLDOWN_OPTIONS, +} from '@kbn/ui-actions-enhanced-plugin/public'; +import { EuiListGroupItem } from '@elastic/eui'; + +import { validateUrl } from './external_link_tools'; +import { coreServices } from '../../services/kibana_services'; +import { Link, LinksLayoutType, LINKS_VERTICAL_LAYOUT } from '../../../common/content_management'; + +export const ExternalLinkComponent = ({ + link, + layout, +}: { + link: Link; + layout: LinksLayoutType; +}) => { + const [error, setError] = useState(); + + const linkOptions = useMemo(() => { + return { + ...DEFAULT_URL_DRILLDOWN_OPTIONS, + ...link.options, + } as UrlDrilldownOptions; + }, [link.options]); + + const isValidUrl = useMemo(() => { + if (!link.destination) return false; + const { valid, message } = validateUrl(link.destination); + if (!valid) setError(message); + return valid; + }, [link.destination]); + + const destination = useMemo(() => { + return link.destination && linkOptions.encodeUrl + ? encodeURI(link.destination) + : link.destination; + }, [linkOptions, link.destination]); + + const id = `externalLink--${link.id}`; + + return ( + { + if (!destination) return; + + /** Only use `navigateToUrl` if we **aren't** opening in a new window/tab; otherwise, just use default href handling */ + const modifiedClick = event.ctrlKey || event.metaKey || event.shiftKey; + if (!modifiedClick) { + event.preventDefault(); + if (linkOptions.openInNewTab) { + window.open(destination, '_blank'); + } else { + await coreServices.application.navigateToUrl(destination); + } + } + }} + /> + ); +}; diff --git a/src/plugins/links/public/components/external_link/external_link_destination_picker.tsx b/src/plugins/links/public/components/external_link/external_link_destination_picker.tsx new file mode 100644 index 0000000000000..30aca970a0783 --- /dev/null +++ b/src/plugins/links/public/components/external_link/external_link_destination_picker.tsx @@ -0,0 +1,85 @@ +/* + * 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 useUnmount from 'react-use/lib/useUnmount'; +import React, { useState } from 'react'; + +import { EuiFieldText } from '@elastic/eui'; + +import { ExternalLinkStrings } from './external_link_strings'; +import { validateUrl } from './external_link_tools'; + +export const ExternalLinkDestinationPicker = ({ + onDestinationPicked, + setDestinationError, + initialSelection, + onUnmount, + ...other +}: { + initialSelection?: string; + onUnmount: (destination: string) => void; + onDestinationPicked: (destination?: string) => void; + setDestinationError: (error: string | undefined) => void; +}) => { + const [validUrl, setValidUrl] = useState(true); + const [currentUrl, setCurrentUrl] = useState(initialSelection ?? ''); + + useMount(() => { + if (initialSelection) { + const { valid, message } = validateUrl(initialSelection); + + if (!valid) { + setValidUrl(false); + setDestinationError(message); + onDestinationPicked(undefined); // prevent re-saving an invalid link + } else { + onDestinationPicked(initialSelection); + } + } + }); + + useUnmount(() => { + /** Save the current selection so we can re-populate it if we switch back to this link editor */ + onUnmount(currentUrl); + }); + + /* {...other} is needed so all inner elements are treated as part of the form */ + return ( +
    + { + const url = event.target.value; + setCurrentUrl(url); + + if (url === '') { + /* no need to validate the empty string - not an error, but also not a valid destination */ + setValidUrl(true); + onDestinationPicked(undefined); + setDestinationError(undefined); + return; + } + + const { valid, message } = validateUrl(url); + setValidUrl(valid); + if (valid) { + onDestinationPicked(url); + setDestinationError(undefined); + } else { + onDestinationPicked(undefined); + setDestinationError(message); + } + }} + data-test-subj="links--linkEditor--externalLink--input" + /> +
    + ); +}; diff --git a/src/plugins/links/public/components/external_link/external_link_strings.ts b/src/plugins/links/public/components/external_link/external_link_strings.ts new file mode 100644 index 0000000000000..e2460347afea8 --- /dev/null +++ b/src/plugins/links/public/components/external_link/external_link_strings.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; + +export const ExternalLinkStrings = { + getType: () => + i18n.translate('links.externalLink.type', { + defaultMessage: 'External URL', + }), + getDisplayName: () => + i18n.translate('links.externalLink.displayName', { + defaultMessage: 'URL', + }), + getDescription: () => + i18n.translate('links.externalLink.description', { + defaultMessage: 'Go to URL', + }), + getPlaceholder: () => + i18n.translate('links.externalLink.editor.placeholder', { + defaultMessage: 'Enter external URL', + }), + getUrlFormatError: () => + i18n.translate('links.externalLink.editor.urlFormatError', { + defaultMessage: 'Invalid format. Example: {exampleUrl}', + values: { + exampleUrl: 'https://elastic.co/', + }, + }), + getDisallowedUrlError: () => + i18n.translate('links.externalLink.editor.disallowedUrlError', { + defaultMessage: + 'This URL is not allowed by your administrator. Refer to "externalUrl.policy" configuration.', + }), +}; diff --git a/src/plugins/links/public/components/external_link/external_link_tools.ts b/src/plugins/links/public/components/external_link/external_link_tools.ts new file mode 100644 index 0000000000000..4048f1c0f5ccd --- /dev/null +++ b/src/plugins/links/public/components/external_link/external_link_tools.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { urlDrilldownValidateUrl } from '@kbn/ui-actions-enhanced-plugin/public'; +import { coreServices } from '../../services/kibana_services'; +import { ExternalLinkStrings } from './external_link_strings'; + +/** + * + * @param url The URl to validate + * @returns Whether or not the URL is valid; if it is not, it will also return the reason it is invalid via the `message` + */ +export const validateUrl = (url: string): { valid: boolean; message?: string } => { + try { + /** This check will throw an error on invalid format, so catch it below */ + const allowedUrl = coreServices.http.externalUrl.validateUrl(url); + + if (allowedUrl === null) { + return { valid: false, message: ExternalLinkStrings.getDisallowedUrlError() }; + } + const validatedUrl = urlDrilldownValidateUrl(url); + if (!validatedUrl.isValid) { + throw new Error(); // will be caught below + } + } catch { + return { valid: false, message: ExternalLinkStrings.getUrlFormatError() }; + } + + return { valid: true }; +}; diff --git a/src/plugins/links/public/components/links_component.scss b/src/plugins/links/public/components/links_component.scss new file mode 100644 index 0000000000000..ecd801492b9e4 --- /dev/null +++ b/src/plugins/links/public/components/links_component.scss @@ -0,0 +1,58 @@ +.linksComponent { + + .linksPanelLink { + max-width: fit-content; // added this so that the error tooltip shows up **right beside** the link label + + &.dashboardLinkError { + &.dashboardLinkError--noLabel .euiListGroupItem__button { + font-style: italic; + } + + .dashboardLinkIcon { + margin-right: $euiSizeS; + } + } + + &.linkCurrent { + border-radius: 0; + .euiListGroupItem__text { + cursor: default; + color: $euiColorPrimary; + } + } + } + + .verticalLayoutWrapper { + gap: $euiSizeXS; + .linksPanelLink { + &.linkCurrent { + &::before { + content: ''; + position: absolute; + width: .5 * $euiSizeXS; + height: 75%; + background-color: $euiColorPrimary; + } + } + } + } + + .horizontalLayoutWrapper { + height: 100%; + display: flex; + flex-wrap: nowrap; + align-items: center; + flex-direction: row; + + .linksPanelLink { + &.linkCurrent { + padding: 0 $euiSizeS; + + .euiListGroupItem__text { + box-shadow: $euiColorPrimary 0 (-.5 * $euiSizeXS) inset; + padding-inline: 0; + } + } + } + } +} diff --git a/src/plugins/links/public/components/links_component.tsx b/src/plugins/links/public/components/links_component.tsx new file mode 100644 index 0000000000000..9400dc9fe7308 --- /dev/null +++ b/src/plugins/links/public/components/links_component.tsx @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useMemo } from 'react'; +import { EuiListGroup, EuiPanel } from '@elastic/eui'; +import { useLinks } from '../embeddable/links_embeddable'; +import { ExternalLinkComponent } from './external_link/external_link_component'; +import { DashboardLinkComponent } from './dashboard_link/dashboard_link_component'; +import { memoizedGetOrderedLinkList } from '../editor/links_editor_tools'; +import { + DASHBOARD_LINK_TYPE, + LINKS_HORIZONTAL_LAYOUT, + LINKS_VERTICAL_LAYOUT, +} from '../../common/content_management'; + +import './links_component.scss'; + +export const LinksComponent = () => { + const linksEmbeddable = useLinks(); + const links = linksEmbeddable.select((state) => state.componentState.links); + const layout = linksEmbeddable.select((state) => state.componentState.layout); + + const orderedLinks = useMemo(() => { + if (!links) return []; + return memoizedGetOrderedLinkList(links); + }, [links]); + + const linkItems: { [id: string]: { id: string; content: JSX.Element } } = useMemo(() => { + return (links ?? []).reduce((prev, currentLink) => { + return { + ...prev, + [currentLink.id]: { + id: currentLink.id, + content: + currentLink.type === DASHBOARD_LINK_TYPE ? ( + + ) : ( + + ), + }, + }; + }, {}); + }, [links, layout]); + + return ( + + + {orderedLinks.map((link) => linkItems[link.id].content)} + + + ); +}; diff --git a/src/plugins/links/public/components/links_strings.ts b/src/plugins/links/public/components/links_strings.ts new file mode 100644 index 0000000000000..4756bc28e3bdd --- /dev/null +++ b/src/plugins/links/public/components/links_strings.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 { i18n } from '@kbn/i18n'; + +export const LinksStrings = { + getDescription: () => + i18n.translate('links.description', { + defaultMessage: 'Use links to navigate to commonly used dashboards and websites.', + }), + editor: { + getAddButtonLabel: () => + i18n.translate('links.editor.addButtonLabel', { + defaultMessage: 'Add link', + }), + getUpdateButtonLabel: () => + i18n.translate('links.editor.updateButtonLabel', { + defaultMessage: 'Update link', + }), + getEditLinkTitle: () => + i18n.translate('links.editor.editLinkTitle', { + defaultMessage: 'Edit link', + }), + getDeleteLinkTitle: () => + i18n.translate('links.editor.deleteLinkTitle', { + defaultMessage: 'Delete link', + }), + getCancelButtonLabel: () => + i18n.translate('links.editor.cancelButtonLabel', { + defaultMessage: 'Close', + }), + panelEditor: { + getTechnicalPreviewTooltip: () => + i18n.translate('links.panelEditor.technicalPreviewTooltip', { + defaultMessage: + 'This functionality is in technical preview and may be changed or removed completely in a future release. Elastic will take a best effort approach to fix any issues, but features in technical preview are not subject to the support SLA of official GA features.', + }), + getTechnicalPreviewLabel: () => + i18n.translate('links.panelEditor.technicalPreviewLabel', { + defaultMessage: 'Technical preview', + }), + getLinksTitle: () => + i18n.translate('links.panelEditor.linksTitle', { + defaultMessage: 'Links', + }), + getEmptyLinksMessage: () => + i18n.translate('links.panelEditor.emptyLinksMessage', { + defaultMessage: "You haven't added any links yet.", + }), + getEmptyLinksTooltip: () => + i18n.translate('links.panelEditor.emptyLinksTooltip', { + defaultMessage: 'Add one or more links.', + }), + getCreateFlyoutTitle: () => + i18n.translate('links.panelEditor.createFlyoutTitle', { + defaultMessage: 'Create links panel', + }), + getEditFlyoutTitle: () => + i18n.translate('links.panelEditor.editFlyoutTitle', { + defaultMessage: 'Edit links panel', + }), + getSaveButtonLabel: () => + i18n.translate('links.panelEditor.saveButtonLabel', { + defaultMessage: 'Save', + }), + getSaveToLibrarySwitchLabel: () => + i18n.translate('links.panelEditor.saveToLibrarySwitchLabel', { + defaultMessage: 'Save to library', + }), + getSaveToLibrarySwitchTooltip: () => + i18n.translate('links.panelEditor.saveToLibrarySwitchTooltip', { + defaultMessage: + 'Save this links panel to the library so you can easily add it to other dashboards.', + }), + getTitleInputLabel: () => + i18n.translate('links.panelEditor.titleInputLabel', { + defaultMessage: 'Title', + }), + getBrokenDashboardLinkAriaLabel: () => + i18n.translate('links.panelEditor.brokenDashboardLinkAriaLabel', { + defaultMessage: 'Broken dashboard link', + }), + getDragHandleAriaLabel: () => + i18n.translate('links.panelEditor.dragHandleAriaLabel', { + defaultMessage: 'Link drag handle', + }), + getLayoutSettingsTitle: () => + i18n.translate('links.panelEditor.layoutSettingsTitle', { + defaultMessage: 'Layout', + }), + getLayoutSettingsLegend: () => + i18n.translate('links.panelEditor.layoutSettingsLegend', { + defaultMessage: 'Choose how to display your links.', + }), + getHorizontalLayoutLabel: () => + i18n.translate('links.editor.horizontalLayout', { + defaultMessage: 'Horizontal', + }), + getVerticalLayoutLabel: () => + i18n.translate('links.editor.verticalLayout', { + defaultMessage: 'Vertical', + }), + getErrorDuringSaveToastTitle: () => + i18n.translate('links.editor.unableToSaveToastTitle', { + defaultMessage: 'Error saving Link panel', + }), + }, + linkEditor: { + getGoBackAriaLabel: () => + i18n.translate('links.linkEditor.goBackAriaLabel', { + defaultMessage: 'Go back to panel editor.', + }), + getLinkTypePickerLabel: () => + i18n.translate('links.linkEditor.linkTypeFormLabel', { + defaultMessage: 'Go to', + }), + getLinkDestinationLabel: () => + i18n.translate('links.linkEditor.linkDestinationLabel', { + defaultMessage: 'Choose destination', + }), + getLinkTextLabel: () => + i18n.translate('links.linkEditor.linkTextLabel', { + defaultMessage: 'Text', + }), + getLinkTextPlaceholder: () => + i18n.translate('links.linkEditor.linkTextPlaceholder', { + defaultMessage: 'Enter text for link', + }), + getLinkOptionsLabel: () => + i18n.translate('links.linkEditor.linkOptionsLabel', { + defaultMessage: 'Options', + }), + }, + }, +}; diff --git a/src/plugins/links/public/components/tooltip_wrapper.tsx b/src/plugins/links/public/components/tooltip_wrapper.tsx new file mode 100644 index 0000000000000..a477c62d3bd9c --- /dev/null +++ b/src/plugins/links/public/components/tooltip_wrapper.tsx @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { EuiToolTip, EuiToolTipProps } from '@elastic/eui'; + +type TooltipWrapperProps = Partial> & { + tooltipContent: string; + /** When the condition is truthy, the tooltip will be shown */ + condition: boolean; +}; + +export const TooltipWrapper: React.FunctionComponent = ({ + children, + condition, + tooltipContent, + ...tooltipProps +}) => { + return ( + <> + {condition ? ( + + <>{children} + + ) : ( + children + )} + + ); +}; diff --git a/src/plugins/links/public/content_management/duplicate_title_check.ts b/src/plugins/links/public/content_management/duplicate_title_check.ts new file mode 100644 index 0000000000000..53d93502d7728 --- /dev/null +++ b/src/plugins/links/public/content_management/duplicate_title_check.ts @@ -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 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 { linksClient } from './links_content_management_client'; + +const rejectErrorMessage = i18n.translate('links.saveDuplicateRejectedDescription', { + defaultMessage: 'Save with duplicate title confirmation was rejected', +}); + +interface Props { + title: string; + id?: string; + onTitleDuplicate: () => void; + lastSavedTitle: string; + copyOnSave: boolean; + isTitleDuplicateConfirmed: boolean; +} + +export const checkForDuplicateTitle = async ({ + id, + title, + lastSavedTitle, + copyOnSave, + isTitleDuplicateConfirmed, + onTitleDuplicate, +}: Props) => { + if (isTitleDuplicateConfirmed) { + return true; + } + + if (title === lastSavedTitle && !copyOnSave) { + return true; + } + + const { hits } = await linksClient.search( + { + text: `"${title}"`, + limit: 10, + }, + { onlyTitle: true } + ); + + const existing = hits.find((obj) => obj.attributes.title.toLowerCase() === title.toLowerCase()); + + if (!existing || existing.id === id) { + return true; + } + + onTitleDuplicate(); + return Promise.reject(new Error(rejectErrorMessage)); +}; diff --git a/src/plugins/links/public/content_management/index.ts b/src/plugins/links/public/content_management/index.ts new file mode 100644 index 0000000000000..c7bc84b8f6b80 --- /dev/null +++ b/src/plugins/links/public/content_management/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 { linksClient } from './links_content_management_client'; +export { checkForDuplicateTitle } from './duplicate_title_check'; diff --git a/src/plugins/links/public/content_management/links_content_management_client.ts b/src/plugins/links/public/content_management/links_content_management_client.ts new file mode 100644 index 0000000000000..777fd8731d691 --- /dev/null +++ b/src/plugins/links/public/content_management/links_content_management_client.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 type { SearchQuery } from '@kbn/content-management-plugin/common'; +import type { LinksCrudTypes } from '../../common/content_management'; +import { CONTENT_ID as contentTypeId } from '../../common'; +import { contentManagement } from '../services/kibana_services'; + +const get = async (id: string) => { + return contentManagement.client.get({ + contentTypeId, + id, + }); +}; + +const create = async ({ data, options }: Omit) => { + const res = await contentManagement.client.create< + LinksCrudTypes['CreateIn'], + LinksCrudTypes['CreateOut'] + >({ + contentTypeId, + data, + options, + }); + return res; +}; + +const update = async ({ id, data, options }: Omit) => { + const res = await contentManagement.client.update< + LinksCrudTypes['UpdateIn'], + LinksCrudTypes['UpdateOut'] + >({ + contentTypeId, + id, + data, + options, + }); + return res; +}; + +const deleteLinks = async (id: string) => { + await contentManagement.client.delete({ + contentTypeId, + id, + }); +}; + +const search = async (query: SearchQuery = {}, options?: LinksCrudTypes['SearchOptions']) => { + return contentManagement.client.search({ + contentTypeId, + query, + options, + }); +}; + +export const linksClient = { + get, + create, + update, + delete: deleteLinks, + search, +}; diff --git a/src/plugins/links/public/content_management/save_to_library.tsx b/src/plugins/links/public/content_management/save_to_library.tsx new file mode 100644 index 0000000000000..6bb00217224cb --- /dev/null +++ b/src/plugins/links/public/content_management/save_to_library.tsx @@ -0,0 +1,88 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { + showSaveModal, + OnSaveProps, + SavedObjectSaveModal, + SaveResult, +} from '@kbn/saved-objects-plugin/public'; + +import { CONTENT_ID } from '../../common'; +import { LinksAttributes } from '../../common/content_management'; +import { LinksByReferenceInput, LinksInput } from '../embeddable/types'; +import { checkForDuplicateTitle } from './duplicate_title_check'; +import { getLinksAttributeService } from '../services/attribute_service'; + +const modalTitle = i18n.translate('links.contentManagement.saveModalTitle', { + defaultMessage: `Save {contentId} panel to library`, + values: { + contentId: CONTENT_ID, + }, +}); + +export const runSaveToLibrary = async ( + newAttributes: LinksAttributes, + initialInput: LinksInput +): Promise => { + return new Promise((resolve) => { + const onSave = async ({ + newTitle, + newDescription, + onTitleDuplicate, + isTitleDuplicateConfirmed, + }: OnSaveProps): Promise => { + const stateFromSaveModal = { + title: newTitle, + description: newDescription, + }; + + if ( + !(await checkForDuplicateTitle({ + title: newTitle, + lastSavedTitle: newAttributes.title, + copyOnSave: false, + onTitleDuplicate, + isTitleDuplicateConfirmed, + })) + ) { + return {}; + } + + const stateToSave = { + ...newAttributes, + ...stateFromSaveModal, + }; + + const updatedInput = (await getLinksAttributeService().wrapAttributes( + stateToSave, + true, + initialInput + )) as unknown as LinksByReferenceInput; + + resolve(updatedInput); + return { id: updatedInput.savedObjectId }; + }; + + const saveModal = ( + resolve(undefined)} + title={newAttributes.title} + customModalTitle={modalTitle} + description={newAttributes.description} + showDescription + showCopyOnSave={false} + objectType={CONTENT_ID} + /> + ); + showSaveModal(saveModal); + }); +}; diff --git a/src/plugins/links/public/editor/links_editor_tools.tsx b/src/plugins/links/public/editor/links_editor_tools.tsx new file mode 100644 index 0000000000000..780ef5fd21679 --- /dev/null +++ b/src/plugins/links/public/editor/links_editor_tools.tsx @@ -0,0 +1,30 @@ +/* + * 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 { memoize } from 'lodash'; +import { Link } from '../../common/content_management'; + +const getOrderedLinkList = (links: Link[]): Link[] => { + return [...links].sort((linkA, linkB) => { + return linkA.order - linkB.order; + }); +}; + +/** + * Memoizing this prevents the links panel editor from having to unnecessarily calculate this + * a second time once the embeddable exists - after all, the links component should have already + * calculated this so, we can get away with using the cached version in the editor + */ +export const memoizedGetOrderedLinkList = memoize( + (links: Link[]) => { + return getOrderedLinkList(links); + }, + (links: Link[]) => { + return links; + } +); diff --git a/src/plugins/links/public/editor/open_editor_flyout.tsx b/src/plugins/links/public/editor/open_editor_flyout.tsx new file mode 100644 index 0000000000000..1c722a484eb1d --- /dev/null +++ b/src/plugins/links/public/editor/open_editor_flyout.tsx @@ -0,0 +1,130 @@ +/* + * 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'; +import { toMountPoint } from '@kbn/react-kibana-mount'; +import { EuiLoadingSpinner, EuiPanel } from '@elastic/eui'; +import { tracksOverlays } from '@kbn/embeddable-plugin/public'; +import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; + +import { LinksInput, LinksByReferenceInput, LinksEditorFlyoutReturn } from '../embeddable/types'; +import { coreServices } from '../services/kibana_services'; +import { runSaveToLibrary } from '../content_management/save_to_library'; +import { Link, LinksLayoutType } from '../../common/content_management'; +import { getLinksAttributeService } from '../services/attribute_service'; + +const LazyLinksEditor = React.lazy(() => import('../components/editor/links_editor')); + +const LinksEditor = withSuspense( + LazyLinksEditor, + + + +); + +/** + * @throws in case user cancels + */ +export async function openEditorFlyout( + initialInput: LinksInput, + parentDashboard?: DashboardContainer +): Promise { + const attributeService = getLinksAttributeService(); + const { attributes } = await attributeService.unwrapAttributes(initialInput); + const isByReference = attributeService.inputIsRefType(initialInput); + const initialLinks = attributes?.links; + const overlayTracker = tracksOverlays(parentDashboard) ? parentDashboard : undefined; + + if (!initialLinks) { + /** + * When creating a new links panel, the tooltip from the "Add panel" popover interacts badly with the flyout + * and can cause a "double opening" animation if the flyout opens before the tooltip has time to unmount; so, + * when creating a new links panel, we need to slow down the process a little bit so that the tooltip has time + * to disappear before we try to open the flyout. + * + * This does not apply to editing existing links panels, since there is no tooltip for this action. + */ + await new Promise((resolve) => setTimeout(resolve, 50)); + } + + return new Promise((resolve, reject) => { + const onSaveToLibrary = async (newLinks: Link[], newLayout: LinksLayoutType) => { + const newAttributes = { + ...attributes, + links: newLinks, + layout: newLayout, + }; + const updatedInput = (initialInput as LinksByReferenceInput).savedObjectId + ? await attributeService.wrapAttributes(newAttributes, true, initialInput) + : await runSaveToLibrary(newAttributes, initialInput); + if (!updatedInput) { + return; + } + resolve({ + newInput: updatedInput, + + // pass attributes via attributes so that the Dashboard can choose the right panel size. + attributes: newAttributes, + }); + parentDashboard?.reload(); + if (overlayTracker) overlayTracker.clearOverlays(); + }; + + const onAddToDashboard = (newLinks: Link[], newLayout: LinksLayoutType) => { + const newAttributes = { + ...attributes, + links: newLinks, + layout: newLayout, + }; + const newInput: LinksInput = { + ...initialInput, + attributes: newAttributes, + }; + resolve({ + newInput, + + // pass attributes so that the Dashboard can choose the right panel size. + attributes: newAttributes, + }); + parentDashboard?.reload(); + if (overlayTracker) overlayTracker.clearOverlays(); + }; + + const onCancel = () => { + reject(); + if (overlayTracker) overlayTracker.clearOverlays(); + }; + + const editorFlyout = coreServices.overlays.openFlyout( + toMountPoint( + , + { theme: coreServices.theme, i18n: coreServices.i18n } + ), + { + maxWidth: 720, + ownFocus: true, + outsideClickCloses: false, + onClose: onCancel, + className: 'linksPanelEditor', + 'data-test-subj': 'links--panelEditor--flyout', + } + ); + + if (overlayTracker) overlayTracker.openOverlay(editorFlyout); + }); +} diff --git a/src/plugins/links/public/editor/open_link_editor_flyout.tsx b/src/plugins/links/public/editor/open_link_editor_flyout.tsx new file mode 100644 index 0000000000000..85ecb33afaab4 --- /dev/null +++ b/src/plugins/links/public/editor/open_link_editor_flyout.tsx @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; + +import { KibanaThemeProvider } from '@kbn/react-kibana-context-theme'; +import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; + +import { coreServices } from '../services/kibana_services'; +import { Link } from '../../common/content_management'; +import { LinkEditor } from '../components/editor/link_editor'; + +export interface LinksEditorProps { + link?: Link; + parentDashboard?: DashboardContainer; + ref: React.RefObject; +} + +/** + * This editor has no context about other links, so it cannot determine order; order will be determined + * by the **caller** (i.e. the panel editor, which contains the context about **all links**) + */ +export type UnorderedLink = Omit; + +/** + * @throws in case user cancels + */ +export async function openLinkEditorFlyout({ + ref, + link, + parentDashboard, +}: LinksEditorProps): Promise { + const unmountFlyout = async () => { + if (ref.current) { + ref.current.children[1].className = 'linkEditor out'; + } + await new Promise(() => { + // wait for close animation before unmounting + setTimeout(() => { + if (ref.current) ReactDOM.unmountComponentAtNode(ref.current); + }, 180); + }); + }; + + return new Promise((resolve, reject) => { + const onSave = async (newLink: UnorderedLink) => { + resolve(newLink); + await unmountFlyout(); + }; + + const onCancel = async () => { + reject(); + await unmountFlyout(); + }; + + ReactDOM.render( + + + , + ref.current + ); + }).catch(() => { + // on reject (i.e. on cancel), just return the original list of links + return undefined; + }); +} diff --git a/src/plugins/links/public/embeddable/index.ts b/src/plugins/links/public/embeddable/index.ts new file mode 100644 index 0000000000000..ab89b768f1285 --- /dev/null +++ b/src/plugins/links/public/embeddable/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { LinksEmbeddable } from './links_embeddable'; +export type { LinksFactory } from './links_embeddable_factory'; +export { LinksFactoryDefinition } from './links_embeddable_factory'; diff --git a/src/plugins/links/public/embeddable/links_embeddable.tsx b/src/plugins/links/public/embeddable/links_embeddable.tsx new file mode 100644 index 0000000000000..863bda323c39b --- /dev/null +++ b/src/plugins/links/public/embeddable/links_embeddable.tsx @@ -0,0 +1,171 @@ +/* + * 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, { createContext, useContext } from 'react'; +import { Subscription, distinctUntilChanged, skip } from 'rxjs'; +import deepEqual from 'fast-deep-equal'; + +import { + AttributeService, + Embeddable, + ReferenceOrValueEmbeddable, + SavedObjectEmbeddableInput, +} from '@kbn/embeddable-plugin/public'; +import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; +import { ReduxEmbeddableTools, ReduxToolsPackage } from '@kbn/presentation-util-plugin/public'; + +import { linksReducers } from './links_reducers'; +import { LinksByReferenceInput, LinksByValueInput, LinksReduxState } from './types'; +import { LinksComponent } from '../components/links_component'; +import { LinksInput, LinksOutput } from './types'; +import { LinksAttributes } from '../../common/content_management'; +import { CONTENT_ID } from '../../common'; + +export const LinksContext = createContext(null); +export const useLinks = (): LinksEmbeddable => { + const linksEmbeddable = useContext(LinksContext); + if (linksEmbeddable == null) { + throw new Error('useLinks must be used inside LinksContext.'); + } + return linksEmbeddable!; +}; + +type LinksReduxEmbeddableTools = ReduxEmbeddableTools; + +export interface LinksConfig { + editable: boolean; +} + +export class LinksEmbeddable + extends Embeddable + implements ReferenceOrValueEmbeddable +{ + public readonly type = CONTENT_ID; + deferEmbeddableLoad = true; + + private isDestroyed?: boolean; + private subscriptions: Subscription = new Subscription(); + + // state management + /** + * TODO: Keep track of the necessary state without the redux embeddable tools; it's kind of overkill here. + * Related issue: https://github.com/elastic/kibana/issues/167577 + */ + public select: LinksReduxEmbeddableTools['select']; + public getState: LinksReduxEmbeddableTools['getState']; + public dispatch: LinksReduxEmbeddableTools['dispatch']; + public onStateChange: LinksReduxEmbeddableTools['onStateChange']; + + private cleanupStateTools: () => void; + + constructor( + reduxToolsPackage: ReduxToolsPackage, + config: LinksConfig, + initialInput: LinksInput, + private attributeService: AttributeService, + parent?: DashboardContainer + ) { + super( + initialInput, + { + editable: config.editable, + editableWithExplicitInput: true, + }, + parent + ); + + /** Build redux embeddable tools */ + const reduxEmbeddableTools = reduxToolsPackage.createReduxEmbeddableTools< + LinksReduxState, + typeof linksReducers + >({ + embeddable: this, + reducers: linksReducers, + initialComponentState: { + title: '', + }, + }); + + this.select = reduxEmbeddableTools.select; + this.getState = reduxEmbeddableTools.getState; + this.dispatch = reduxEmbeddableTools.dispatch; + this.cleanupStateTools = reduxEmbeddableTools.cleanup; + this.onStateChange = reduxEmbeddableTools.onStateChange; + + this.initializeSavedLinks() + .then(() => this.setInitializationFinished()) + .catch((e: Error) => this.onFatalError(e)); + + // By-value panels should update the componentState when input changes + this.subscriptions.add( + this.getInput$() + .pipe(distinctUntilChanged(deepEqual), skip(1)) + .subscribe(async () => await this.initializeSavedLinks()) + ); + } + + private async initializeSavedLinks() { + const { attributes } = await this.attributeService.unwrapAttributes(this.getInput()); + if (this.isDestroyed) return; + + this.dispatch.setAttributes(attributes); + + await this.initializeOutput(); + } + + private async initializeOutput() { + const attributes = this.getState().componentState; + const { title, description } = this.getInput(); + this.updateOutput({ + defaultTitle: attributes.title, + defaultDescription: attributes.description, + title: title ?? attributes.title, + description: description ?? attributes.description, + }); + } + + public inputIsRefType( + input: LinksByValueInput | LinksByReferenceInput + ): input is LinksByReferenceInput { + return this.attributeService.inputIsRefType(input); + } + + public async getInputAsRefType(): Promise { + return this.attributeService.getInputAsRefType(this.getExplicitInput(), { + showSaveModal: true, + saveModalTitle: this.getTitle(), + }); + } + + public async getInputAsValueType(): Promise { + return this.attributeService.getInputAsValueType(this.getExplicitInput()); + } + + public async reload() { + if (this.isDestroyed) return; + // By-reference embeddable panels are reloaded when changed, so update the componentState + this.initializeSavedLinks(); + this.render(); + } + + public destroy() { + this.isDestroyed = true; + super.destroy(); + this.subscriptions.unsubscribe(); + this.cleanupStateTools(); + } + + public render() { + if (this.isDestroyed) return; + return ( + + + + ); + } +} diff --git a/src/plugins/links/public/embeddable/links_embeddable_factory.test.ts b/src/plugins/links/public/embeddable/links_embeddable_factory.test.ts new file mode 100644 index 0000000000000..427827a1ace4b --- /dev/null +++ b/src/plugins/links/public/embeddable/links_embeddable_factory.test.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 { LinksFactoryDefinition } from './links_embeddable_factory'; +import { LinksInput } from './types'; + +describe('linksFactory', () => { + test('returns an empty object when not given proper meta information', () => { + const linksFactory = new LinksFactoryDefinition(); + const settings = linksFactory.getPanelPlacementSettings({} as unknown as LinksInput, {}); + expect(settings.height).toBeUndefined(); + expect(settings.width).toBeUndefined(); + expect(settings.strategy).toBeUndefined(); + }); + + test('returns a horizontal layout', () => { + const linksFactory = new LinksFactoryDefinition(); + const settings = linksFactory.getPanelPlacementSettings({} as unknown as LinksInput, { + layout: 'horizontal', + links: [], + }); + expect(settings.height).toBe(4); + expect(settings.width).toBe(48); + expect(settings.strategy).toBe('placeAtTop'); + }); + + test('returns a vertical layout with the appropriate height', () => { + const linksFactory = new LinksFactoryDefinition(); + const settings = linksFactory.getPanelPlacementSettings({} as unknown as LinksInput, { + layout: 'vertical', + links: [ + { type: 'dashboardLink', destination: 'superDashboard1' }, + { type: 'dashboardLink', destination: 'superDashboard2' }, + { type: 'dashboardLink', destination: 'superDashboard3' }, + ], + }); + expect(settings.height).toBe(7); // 4 base plus 3 for each link. + expect(settings.width).toBe(8); + expect(settings.strategy).toBe('placeAtTop'); + }); +}); diff --git a/src/plugins/links/public/embeddable/links_embeddable_factory.ts b/src/plugins/links/public/embeddable/links_embeddable_factory.ts new file mode 100644 index 0000000000000..e0502d34a742c --- /dev/null +++ b/src/plugins/links/public/embeddable/links_embeddable_factory.ts @@ -0,0 +1,161 @@ +/* + * 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 { + EmbeddableFactory, + EmbeddableFactoryDefinition, + ErrorEmbeddable, +} from '@kbn/embeddable-plugin/public'; +import { + MigrateFunctionsObject, + GetMigrationFunctionObjectFn, +} from '@kbn/kibana-utils-plugin/common'; +import { EmbeddableStateWithType } from '@kbn/embeddable-plugin/common'; +import { DASHBOARD_GRID_COLUMN_COUNT } from '@kbn/dashboard-plugin/public'; +import { UiActionsPresentableGrouping } from '@kbn/ui-actions-plugin/public'; +import { lazyLoadReduxToolsPackage } from '@kbn/presentation-util-plugin/public'; +import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; +import { IProvidesPanelPlacementSettings } from '@kbn/dashboard-plugin/public/dashboard_container/component/panel_placement/types'; + +import { + coreServices, + presentationUtil, + untilPluginStartServicesReady, +} from '../services/kibana_services'; +import { extract, inject } from '../../common/embeddable'; +import type { LinksEmbeddable } from './links_embeddable'; +import { LinksStrings } from '../components/links_strings'; +import { APP_ICON, APP_NAME, CONTENT_ID } from '../../common'; +import { LinksAttributes } from '../../common/content_management'; +import { getLinksAttributeService } from '../services/attribute_service'; +import { LinksInput, LinksByReferenceInput, LinksEditorFlyoutReturn } from './types'; + +export type LinksFactory = EmbeddableFactory; + +// TODO: Replace string 'OPEN_FLYOUT_ADD_DRILLDOWN' with constant once the dashboardEnhanced plugin is removed +// and it is no longer locked behind `x-pack` +const getDefaultLinksInput = (): Partial => ({ + disabledActions: ['OPEN_FLYOUT_ADD_DRILLDOWN'], +}); + +const isLinksAttributes = (attributes?: unknown): attributes is LinksAttributes => { + return ( + attributes !== undefined && + Boolean((attributes as LinksAttributes).layout || (attributes as LinksAttributes).links) + ); +}; + +export class LinksFactoryDefinition + implements EmbeddableFactoryDefinition, IProvidesPanelPlacementSettings +{ + latestVersion?: string | undefined; + telemetry?: + | ((state: EmbeddableStateWithType, stats: Record) => Record) + | undefined; + migrations?: MigrateFunctionsObject | GetMigrationFunctionObjectFn | undefined; + grouping?: UiActionsPresentableGrouping | undefined; + public readonly type = CONTENT_ID; + + public readonly isContainerType = false; + + public readonly savedObjectMetaData = { + name: APP_NAME, + type: CONTENT_ID, + getIconForSavedObject: () => APP_ICON, + }; + + public getPanelPlacementSettings: IProvidesPanelPlacementSettings< + LinksInput, + LinksAttributes | unknown + >['getPanelPlacementSettings'] = (input, attributes) => { + if (!isLinksAttributes(attributes) || !attributes.layout) { + // if we have no information about the layout of this links panel defer to default panel size and placement. + return {}; + } + + const isHorizontal = attributes.layout === 'horizontal'; + const width = isHorizontal ? DASHBOARD_GRID_COLUMN_COUNT : 8; + const height = isHorizontal ? 4 : (attributes.links?.length ?? 1 * 3) + 4; + return { width, height, strategy: 'placeAtTop' }; + }; + + public async isEditable() { + await untilPluginStartServicesReady(); + return Boolean(coreServices.application.capabilities.dashboard?.showWriteControls); + } + + public canCreateNew() { + return presentationUtil.labsService.isProjectEnabled('labs:dashboard:linksPanel'); + } + + public getDefaultInput(): Partial { + return getDefaultLinksInput(); + } + + public async createFromSavedObject( + savedObjectId: string, + input: LinksInput, + parent: DashboardContainer + ): Promise { + if (!(input as LinksByReferenceInput).savedObjectId) { + (input as LinksByReferenceInput).savedObjectId = savedObjectId; + } + return this.create(input, parent); + } + + public async create(initialInput: LinksInput, parent: DashboardContainer) { + await untilPluginStartServicesReady(); + + const reduxEmbeddablePackage = await lazyLoadReduxToolsPackage(); + const { LinksEmbeddable } = await import('./links_embeddable'); + const editable = await this.isEditable(); + + return new LinksEmbeddable( + reduxEmbeddablePackage, + { editable }, + { ...getDefaultLinksInput(), ...initialInput }, + getLinksAttributeService(), + parent + ); + } + + public async getExplicitInput( + initialInput: LinksInput, + parent?: DashboardContainer + ): Promise { + if (!parent) return { newInput: {} }; + + const { openEditorFlyout } = await import('../editor/open_editor_flyout'); + + const { newInput, attributes } = await openEditorFlyout( + { + ...getDefaultLinksInput(), + ...initialInput, + }, + parent + ); + + return { newInput, attributes }; + } + + public getDisplayName() { + return APP_NAME; + } + + public getIconType() { + return 'link'; + } + + public getDescription() { + return LinksStrings.getDescription(); + } + + inject = inject; + + extract = extract; +} diff --git a/src/plugins/links/public/embeddable/links_reducers.ts b/src/plugins/links/public/embeddable/links_reducers.ts new file mode 100644 index 0000000000000..659b19058adbb --- /dev/null +++ b/src/plugins/links/public/embeddable/links_reducers.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { WritableDraft } from 'immer/dist/types/types-external'; + +import { PayloadAction } from '@reduxjs/toolkit'; + +import { LinksReduxState } from './types'; +import { LinksAttributes } from '../../common/content_management'; + +export const linksReducers = { + setLoading: (state: WritableDraft, action: PayloadAction) => { + state.output.loading = action.payload; + }, + + setAttributes: ( + state: WritableDraft, + action: PayloadAction + ) => { + state.componentState = { ...action.payload }; + }, +}; diff --git a/src/plugins/links/public/embeddable/types.ts b/src/plugins/links/public/embeddable/types.ts new file mode 100644 index 0000000000000..d16d8431a5601 --- /dev/null +++ b/src/plugins/links/public/embeddable/types.ts @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { ReduxEmbeddableState } from '@kbn/presentation-util-plugin/public'; +import { + EmbeddableInput, + EmbeddableOutput, + SavedObjectEmbeddableInput, +} from '@kbn/embeddable-plugin/public'; + +import { DashboardAttributes } from '@kbn/dashboard-plugin/common'; +import { + LinkType, + EXTERNAL_LINK_TYPE, + DASHBOARD_LINK_TYPE, + LINKS_VERTICAL_LAYOUT, + LinksLayoutType, + LINKS_HORIZONTAL_LAYOUT, + LinksAttributes, +} from '../../common/content_management'; +import { DashboardLinkStrings } from '../components/dashboard_link/dashboard_link_strings'; +import { ExternalLinkStrings } from '../components/external_link/external_link_strings'; +import { LinksStrings } from '../components/links_strings'; + +export const LinksLayoutInfo: { + [id in LinksLayoutType]: { displayName: string }; +} = { + [LINKS_HORIZONTAL_LAYOUT]: { + displayName: LinksStrings.editor.panelEditor.getHorizontalLayoutLabel(), + }, + [LINKS_VERTICAL_LAYOUT]: { + displayName: LinksStrings.editor.panelEditor.getVerticalLayoutLabel(), + }, +}; + +export interface DashboardItem { + id: string; + attributes: DashboardAttributes; +} + +export const LinkInfo: { + [id in LinkType]: { + icon: string; + type: string; + displayName: string; + description: string; + }; +} = { + [DASHBOARD_LINK_TYPE]: { + icon: 'dashboardApp', + type: DashboardLinkStrings.getType(), + displayName: DashboardLinkStrings.getDisplayName(), + description: DashboardLinkStrings.getDescription(), + }, + [EXTERNAL_LINK_TYPE]: { + icon: 'link', + type: ExternalLinkStrings.getType(), + displayName: ExternalLinkStrings.getDisplayName(), + description: ExternalLinkStrings.getDescription(), + }, +}; + +export interface LinksEditorFlyoutReturn { + attributes?: unknown; + newInput: Partial; +} + +export type LinksByValueInput = { + attributes: LinksAttributes; +} & EmbeddableInput; + +export type LinksByReferenceInput = SavedObjectEmbeddableInput; + +export type LinksInput = LinksByValueInput | LinksByReferenceInput; + +export type LinksOutput = EmbeddableOutput & { + attributes?: LinksAttributes; +}; + +/** + * Links embeddable redux state + */ +export type LinksComponentState = LinksAttributes; + +export type LinksReduxState = ReduxEmbeddableState; diff --git a/src/plugins/links/public/index.ts b/src/plugins/links/public/index.ts new file mode 100644 index 0000000000000..3389cd48f4b67 --- /dev/null +++ b/src/plugins/links/public/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export type { LinksFactory } from './embeddable'; +export { LinksFactoryDefinition, LinksEmbeddable } from './embeddable'; + +import { LinksPlugin } from './plugin'; + +export function plugin() { + return new LinksPlugin(); +} diff --git a/src/plugins/links/public/mocks.tsx b/src/plugins/links/public/mocks.tsx new file mode 100644 index 0000000000000..6a27185c9b09a --- /dev/null +++ b/src/plugins/links/public/mocks.tsx @@ -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 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 { coreMock } from '@kbn/core/public/mocks'; +import { dashboardPluginMock } from '@kbn/dashboard-plugin/public/mocks'; +import { embeddablePluginMock } from '@kbn/embeddable-plugin/public/mocks'; +import { contentManagementMock } from '@kbn/content-management-plugin/public/mocks'; +import { presentationUtilPluginMock } from '@kbn/presentation-util-plugin/public/mocks'; +import { setKibanaServices } from './services/kibana_services'; + +export const setStubKibanaServices = () => { + const core = coreMock.createStart(); + + setKibanaServices(core, { + dashboard: dashboardPluginMock.createStartContract(), + embeddable: embeddablePluginMock.createStartContract(), + contentManagement: contentManagementMock.createStartContract(), + presentationUtil: presentationUtilPluginMock.createStartContract(core), + }); +}; diff --git a/src/plugins/links/public/plugin.ts b/src/plugins/links/public/plugin.ts new file mode 100644 index 0000000000000..7927de88b80e7 --- /dev/null +++ b/src/plugins/links/public/plugin.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { CoreSetup, CoreStart, Plugin } from '@kbn/core/public'; +import { + ContentManagementPublicSetup, + ContentManagementPublicStart, +} from '@kbn/content-management-plugin/public'; +import { DashboardStart } from '@kbn/dashboard-plugin/public'; +import { EmbeddableSetup, EmbeddableStart } from '@kbn/embeddable-plugin/public'; +import { PresentationUtilPluginStart } from '@kbn/presentation-util-plugin/public'; + +import { APP_NAME } from '../common'; +import { LinksFactoryDefinition } from './embeddable'; +import { CONTENT_ID, LATEST_VERSION } from '../common'; +import { setKibanaServices } from './services/kibana_services'; + +export interface LinksSetupDependencies { + embeddable: EmbeddableSetup; + contentManagement: ContentManagementPublicSetup; +} + +export interface LinksStartDependencies { + embeddable: EmbeddableStart; + dashboard: DashboardStart; + presentationUtil: PresentationUtilPluginStart; + contentManagement: ContentManagementPublicStart; +} + +export class LinksPlugin + implements Plugin +{ + constructor() {} + + public setup(core: CoreSetup, plugins: LinksSetupDependencies) { + core.getStartServices().then(([_, deps]) => { + plugins.embeddable.registerEmbeddableFactory(CONTENT_ID, new LinksFactoryDefinition()); + + plugins.contentManagement.registry.register({ + id: CONTENT_ID, + version: { + latest: LATEST_VERSION, + }, + name: APP_NAME, + }); + }); + } + + public start(core: CoreStart, plugins: LinksStartDependencies) { + setKibanaServices(core, plugins); + return {}; + } + + public stop() {} +} diff --git a/src/plugins/links/public/services/attribute_service.ts b/src/plugins/links/public/services/attribute_service.ts new file mode 100644 index 0000000000000..bde2ab27c1d15 --- /dev/null +++ b/src/plugins/links/public/services/attribute_service.ts @@ -0,0 +1,97 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 } from '@kbn/content-management-utils'; +import { AttributeService } from '@kbn/embeddable-plugin/public'; +import type { OnSaveProps } from '@kbn/saved-objects-plugin/public'; +import { SharingSavedObjectProps } from '../../common/types'; +import { LinksAttributes } from '../../common/content_management'; +import { extractReferences, injectReferences } from '../../common/persistable_state'; +import { LinksByReferenceInput, LinksByValueInput } from '../embeddable/types'; +import { embeddableService } from './kibana_services'; +import { checkForDuplicateTitle, linksClient } from '../content_management'; +import { CONTENT_ID } from '../../common'; + +export type LinksDocument = LinksAttributes & { + references?: Reference[]; +}; + +export interface LinksUnwrapMetaInfo { + sharingSavedObjectProps?: SharingSavedObjectProps; +} + +export type LinksAttributeService = AttributeService< + LinksDocument, + LinksByValueInput, + LinksByReferenceInput, + LinksUnwrapMetaInfo +>; + +let linksAttributeService: LinksAttributeService | null = null; +export function getLinksAttributeService(): LinksAttributeService { + if (linksAttributeService) return linksAttributeService; + + linksAttributeService = embeddableService.getAttributeService< + LinksDocument, + LinksByValueInput, + LinksByReferenceInput, + LinksUnwrapMetaInfo + >(CONTENT_ID, { + saveMethod: async (attributes: LinksDocument, savedObjectId?: string) => { + const { attributes: updatedAttributes, references } = extractReferences({ + attributes, + references: attributes.references, + }); + const { + item: { id }, + } = await (savedObjectId + ? linksClient.update({ + id: savedObjectId, + data: updatedAttributes, + options: { references }, + }) + : linksClient.create({ data: updatedAttributes, options: { references } })); + return { id }; + }, + unwrapMethod: async ( + savedObjectId: string + ): Promise<{ + attributes: LinksDocument; + metaInfo: LinksUnwrapMetaInfo; + }> => { + const { + item: savedObject, + meta: { outcome, aliasPurpose, aliasTargetId }, + } = await linksClient.get(savedObjectId); + if (savedObject.error) throw savedObject.error; + + const { attributes } = injectReferences(savedObject); + return { + attributes, + metaInfo: { + sharingSavedObjectProps: { + aliasTargetId, + outcome, + aliasPurpose, + sourceId: savedObjectId, + }, + }, + }; + }, + checkForDuplicateTitle: (props: OnSaveProps) => { + return checkForDuplicateTitle({ + title: props.newTitle, + copyOnSave: false, + lastSavedTitle: '', + isTitleDuplicateConfirmed: props.isTitleDuplicateConfirmed, + onTitleDuplicate: props.onTitleDuplicate, + }); + }, + }); + return linksAttributeService; +} diff --git a/src/plugins/links/public/services/kibana_services.ts b/src/plugins/links/public/services/kibana_services.ts new file mode 100644 index 0000000000000..76acd242f7575 --- /dev/null +++ b/src/plugins/links/public/services/kibana_services.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { BehaviorSubject } from 'rxjs'; + +import { CoreStart } from '@kbn/core/public'; +import { DashboardStart } from '@kbn/dashboard-plugin/public'; +import { EmbeddableStart } from '@kbn/embeddable-plugin/public'; +import { PresentationUtilPluginStart } from '@kbn/presentation-util-plugin/public'; +import { ContentManagementPublicStart } from '@kbn/content-management-plugin/public'; + +import { LinksStartDependencies } from '../plugin'; + +export let coreServices: CoreStart; +export let dashboardServices: DashboardStart; +export let embeddableService: EmbeddableStart; +export let presentationUtil: PresentationUtilPluginStart; +export let contentManagement: ContentManagementPublicStart; + +const servicesReady$ = new BehaviorSubject(false); + +export const untilPluginStartServicesReady = () => { + if (servicesReady$.value) return Promise.resolve(); + return new Promise((resolve) => { + const subscription = servicesReady$.subscribe((isInitialized) => { + if (isInitialized) { + subscription.unsubscribe(); + resolve(); + } + }); + }); +}; + +export const setKibanaServices = (kibanaCore: CoreStart, deps: LinksStartDependencies) => { + coreServices = kibanaCore; + dashboardServices = deps.dashboard; + embeddableService = deps.embeddable; + presentationUtil = deps.presentationUtil; + contentManagement = deps.contentManagement; + + servicesReady$.next(true); +}; diff --git a/src/plugins/dashboard/public/dashboard_container/component/panel/index.ts b/src/plugins/links/server/content_management/index.ts similarity index 86% rename from src/plugins/dashboard/public/dashboard_container/component/panel/index.ts rename to src/plugins/links/server/content_management/index.ts index 015b31ed725d9..82666a940d249 100644 --- a/src/plugins/dashboard/public/dashboard_container/component/panel/index.ts +++ b/src/plugins/links/server/content_management/index.ts @@ -6,4 +6,4 @@ * Side Public License, v 1. */ -export { createPanelState } from './create_panel_state'; +export { LinksStorage } from './links_storage'; diff --git a/src/plugins/links/server/content_management/links_storage.ts b/src/plugins/links/server/content_management/links_storage.ts new file mode 100644 index 0000000000000..21a5e4aa0de0d --- /dev/null +++ b/src/plugins/links/server/content_management/links_storage.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 type { Logger } from '@kbn/logging'; +import { SOContentStorage } from '@kbn/content-management-utils'; +import { CONTENT_ID } from '../../common'; +import type { LinksCrudTypes } from '../../common/content_management'; +import { cmServicesDefinition } from '../../common/content_management/cm_services'; + +export class LinksStorage extends SOContentStorage { + constructor({ + logger, + throwOnResultValidationError, + }: { + logger: Logger; + throwOnResultValidationError: boolean; + }) { + super({ + savedObjectType: CONTENT_ID, + cmServicesDefinition, + enableMSearch: true, + allowedSavedObjectAttributes: ['id', 'title', 'description', 'links', 'layout'], + logger, + throwOnResultValidationError, + }); + } +} diff --git a/src/plugins/links/server/index.ts b/src/plugins/links/server/index.ts new file mode 100644 index 0000000000000..c60d084fa66d2 --- /dev/null +++ b/src/plugins/links/server/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 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 { PluginInitializerContext } from '@kbn/core-plugins-server'; +import { LinksServerPlugin } from './plugin'; + +export const plugin = (initContext: PluginInitializerContext) => new LinksServerPlugin(initContext); diff --git a/src/plugins/links/server/plugin.ts b/src/plugins/links/server/plugin.ts new file mode 100644 index 0000000000000..b1a0bfafed763 --- /dev/null +++ b/src/plugins/links/server/plugin.ts @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { CoreSetup, CoreStart, Logger, Plugin, PluginInitializerContext } from '@kbn/core/server'; +import type { ContentManagementServerSetup } from '@kbn/content-management-plugin/server'; +import { CONTENT_ID, LATEST_VERSION } from '../common'; +import { LinksAttributes } from '../common/content_management'; +import { LinksStorage } from './content_management'; +import { linksSavedObjectType } from './saved_objects'; + +export class LinksServerPlugin implements Plugin { + private readonly logger: Logger; + + constructor(private initializerContext: PluginInitializerContext) { + this.logger = initializerContext.logger.get(); + } + + public setup( + core: CoreSetup, + plugins: { + contentManagement: ContentManagementServerSetup; + } + ) { + plugins.contentManagement.register({ + id: CONTENT_ID, + storage: new LinksStorage({ + throwOnResultValidationError: this.initializerContext.env.mode.dev, + logger: this.logger.get('storage'), + }), + version: { + latest: LATEST_VERSION, + }, + }); + + core.savedObjects.registerType(linksSavedObjectType); + + return {}; + } + + public start(core: CoreStart) { + return {}; + } + + public stop() {} +} diff --git a/src/plugins/links/server/saved_objects/index.ts b/src/plugins/links/server/saved_objects/index.ts new file mode 100644 index 0000000000000..d6303bb2b8b78 --- /dev/null +++ b/src/plugins/links/server/saved_objects/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { linksSavedObjectType } from './links'; diff --git a/src/plugins/links/server/saved_objects/links.ts b/src/plugins/links/server/saved_objects/links.ts new file mode 100644 index 0000000000000..b00f49e6d8cae --- /dev/null +++ b/src/plugins/links/server/saved_objects/links.ts @@ -0,0 +1,41 @@ +/* + * 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 type { SavedObjectsType } from '@kbn/core/server'; +import { ANALYTICS_SAVED_OBJECT_INDEX } from '@kbn/core-saved-objects-server'; +import { APP_ICON, CONTENT_ID } from '../../common'; + +export const linksSavedObjectType: SavedObjectsType = { + name: CONTENT_ID, + indexPattern: ANALYTICS_SAVED_OBJECT_INDEX, + hidden: false, + namespaceType: 'multiple-isolated', + management: { + icon: APP_ICON, + defaultSearchField: 'title', + importableAndExportable: true, + getTitle(obj) { + return obj.attributes.title; + }, + }, + mappings: { + dynamic: false, + properties: { + id: { type: 'text' }, + title: { type: 'text' }, + description: { type: 'text' }, + links: { + dynamic: false, + properties: {}, + }, + }, + }, + migrations: () => { + return {}; + }, +}; diff --git a/src/plugins/links/tsconfig.json b/src/plugins/links/tsconfig.json new file mode 100644 index 0000000000000..ba9b5b67d058f --- /dev/null +++ b/src/plugins/links/tsconfig.json @@ -0,0 +1,34 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types" + }, + "include": ["*.ts", "public/**/*", "common/**/*", "server/**/*", "public/**/*.json"], + "kbn_references": [ + "@kbn/core", + "@kbn/i18n", + "@kbn/dashboard-plugin", + "@kbn/embeddable-plugin", + "@kbn/presentation-util-plugin", + "@kbn/object-versioning", + "@kbn/config-schema", + "@kbn/content-management-utils", + "@kbn/content-management-plugin", + "@kbn/shared-ux-utility", + "@kbn/core-saved-objects-api-server", + "@kbn/saved-objects-plugin", + "@kbn/core-saved-objects-server", + "@kbn/saved-objects-plugin", + "@kbn/ui-actions-enhanced-plugin", + "@kbn/es-query", + "@kbn/share-plugin", + "@kbn/kibana-utils-plugin", + "@kbn/utility-types", + "@kbn/ui-actions-plugin", + "@kbn/logging", + "@kbn/core-plugins-server", + "@kbn/react-kibana-mount", + "@kbn/react-kibana-context-theme" + ], + "exclude": ["target/**/*"] +} diff --git a/src/plugins/presentation_util/common/labs.ts b/src/plugins/presentation_util/common/labs.ts index b900eb7a6e36c..18fd77a364429 100644 --- a/src/plugins/presentation_util/common/labs.ts +++ b/src/plugins/presentation_util/common/labs.ts @@ -10,13 +10,26 @@ import { i18n } from '@kbn/i18n'; export const LABS_PROJECT_PREFIX = 'labs:'; export const DEFER_BELOW_FOLD = `${LABS_PROJECT_PREFIX}dashboard:deferBelowFold` as const; +export const DASHBOARD_LINKS_PANEL = `${LABS_PROJECT_PREFIX}dashboard:linksPanel` as const; export const DASHBOARD_CONTROLS = `${LABS_PROJECT_PREFIX}dashboard:dashboardControls` as const; export const BY_VALUE_EMBEDDABLE = `${LABS_PROJECT_PREFIX}canvas:byValueEmbeddable` as const; -export const projectIDs = [DEFER_BELOW_FOLD, DASHBOARD_CONTROLS, BY_VALUE_EMBEDDABLE] as const; +export const projectIDs = [ + DEFER_BELOW_FOLD, + DASHBOARD_CONTROLS, + BY_VALUE_EMBEDDABLE, + DASHBOARD_LINKS_PANEL, +] as const; export const environmentNames = ['kibana', 'browser', 'session'] as const; export const solutionNames = ['canvas', 'dashboard', 'presentation'] as const; +const technicalPreviewLabel = i18n.translate( + 'presentationUtil.advancedSettings.technicalPreviewLabel', + { + defaultMessage: 'technical preview', + } +); + /** * This is a list of active Labs Projects for the Presentation Team. It is the "source of truth" for all projects * provided to users of our solutions in Kibana. @@ -50,6 +63,23 @@ export const projects: { [ID in ProjectID]: ProjectConfig & { id: ID } } = { }), solutions: ['dashboard'], }, + [DASHBOARD_LINKS_PANEL]: { + id: DASHBOARD_LINKS_PANEL, + isActive: true, + isDisplayed: true, + environments: ['kibana', 'browser', 'session'], + name: i18n.translate('presentationUtil.labs.enableLinksPanelProjectName', { + defaultMessage: 'Enable links panel', + }), + description: i18n.translate('presentationUtil.labs.enableLinksPanelProjectDescription', { + defaultMessage: + '{technicalPreviewLabel} Enables the links panel for dashboard, which allows dashboard authors to easily link dashboards together.', + values: { + technicalPreviewLabel: `[${technicalPreviewLabel}]`, + }, + }), + solutions: ['dashboard'], + }, [BY_VALUE_EMBEDDABLE]: { id: BY_VALUE_EMBEDDABLE, isActive: true, diff --git a/src/plugins/presentation_util/public/components/dashboard_drilldown_options/dashboard_drilldown_options.tsx b/src/plugins/presentation_util/public/components/dashboard_drilldown_options/dashboard_drilldown_options.tsx new file mode 100644 index 0000000000000..a87195b305f1b --- /dev/null +++ b/src/plugins/presentation_util/public/components/dashboard_drilldown_options/dashboard_drilldown_options.tsx @@ -0,0 +1,55 @@ +/* + * 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 { EuiFormRow, EuiSwitch } from '@elastic/eui'; + +import { DashboardDrilldownOptions } from './types'; +import { dashboardDrilldownConfigStrings } from '../../i18n/dashboard_drilldown_config'; + +export interface DashboardDrilldownOptionsProps { + options: DashboardDrilldownOptions; + onOptionChange: (newOptions: Partial) => void; +} + +export const DashboardDrilldownOptionsComponent = ({ + options, + onOptionChange, +}: DashboardDrilldownOptionsProps) => { + return ( + <> + + onOptionChange({ useCurrentFilters: !options.useCurrentFilters })} + data-test-subj="dashboardDrillDownOptions--useCurrentFilters--checkbox" + /> + + + onOptionChange({ useCurrentDateRange: !options.useCurrentDateRange })} + data-test-subj="dashboardDrillDownOptions--useCurrentDateRange--checkbox" + /> + + + onOptionChange({ openInNewTab: !options.openInNewTab })} + data-test-subj="dashboardDrillDownOptions--openInNewTab--checkbox" + /> + + + ); +}; diff --git a/src/plugins/presentation_util/public/components/dashboard_drilldown_options/types.ts b/src/plugins/presentation_util/public/components/dashboard_drilldown_options/types.ts new file mode 100644 index 0000000000000..60d8646b67532 --- /dev/null +++ b/src/plugins/presentation_util/public/components/dashboard_drilldown_options/types.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type DashboardDrilldownOptions = { + useCurrentFilters: boolean; + useCurrentDateRange: boolean; + openInNewTab: boolean; +}; + +export const DEFAULT_DASHBOARD_DRILLDOWN_OPTIONS: DashboardDrilldownOptions = { + openInNewTab: false, + useCurrentDateRange: true, + useCurrentFilters: true, +}; diff --git a/src/plugins/presentation_util/public/components/index.tsx b/src/plugins/presentation_util/public/components/index.tsx index cff38e8a79d2b..c51f31eedd5bb 100644 --- a/src/plugins/presentation_util/public/components/index.tsx +++ b/src/plugins/presentation_util/public/components/index.tsx @@ -42,6 +42,24 @@ export const LazyDataViewPicker = React.lazy(() => import('./data_view_picker/da export const LazyFieldPicker = React.lazy(() => import('./field_picker/field_picker')); +const LazyDashboardDrilldownOptionsComponent = React.lazy(() => + import('./dashboard_drilldown_options/dashboard_drilldown_options').then( + ({ DashboardDrilldownOptionsComponent }) => ({ + default: DashboardDrilldownOptionsComponent, + }) + ) +); + +export const DashboardDrilldownOptionsComponent = withSuspense( + LazyDashboardDrilldownOptionsComponent, + null +); + +export { + type DashboardDrilldownOptions, + DEFAULT_DASHBOARD_DRILLDOWN_OPTIONS, +} from './dashboard_drilldown_options/types'; + export { FloatingActions } from './floating_actions/floating_actions'; /** diff --git a/src/plugins/presentation_util/public/i18n/dashboard_drilldown_config.tsx b/src/plugins/presentation_util/public/i18n/dashboard_drilldown_config.tsx new file mode 100644 index 0000000000000..2bb326cb6f841 --- /dev/null +++ b/src/plugins/presentation_util/public/i18n/dashboard_drilldown_config.tsx @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; + +export const dashboardDrilldownConfigStrings = { + component: { + getUseCurrentFiltersLabel: () => + i18n.translate( + 'presentationUtil.dashboardDrilldownConfig.components.useCurrentFiltersLabel', + { + defaultMessage: 'Use filters and query from origin dashboard', + } + ), + getUseCurrentDateRange: () => + i18n.translate('presentationUtil.dashboardDrilldownConfig.components.useCurrentDateRange', { + defaultMessage: 'Use date range from origin dashboard', + }), + getOpenInNewTab: () => + i18n.translate('presentationUtil.dashboardDrilldownConfig.components.openInNewTab', { + defaultMessage: 'Open dashboard in new tab', + }), + }, +}; diff --git a/src/plugins/presentation_util/public/index.ts b/src/plugins/presentation_util/public/index.ts index 280fc4b979ce0..f2ca659d2da32 100644 --- a/src/plugins/presentation_util/public/index.ts +++ b/src/plugins/presentation_util/public/index.ts @@ -33,6 +33,9 @@ export { LazyDataViewPicker, LazyFieldPicker, FloatingActions, + type DashboardDrilldownOptions, + DashboardDrilldownOptionsComponent, + DEFAULT_DASHBOARD_DRILLDOWN_OPTIONS, } from './components'; export { diff --git a/src/plugins/telemetry/schema/oss_plugins.json b/src/plugins/telemetry/schema/oss_plugins.json index bc8686101eb21..e2013ff091c76 100644 --- a/src/plugins/telemetry/schema/oss_plugins.json +++ b/src/plugins/telemetry/schema/oss_plugins.json @@ -9929,6 +9929,12 @@ "description": "Non-default value of setting." } }, + "labs:dashboard:linksPanel": { + "type": "boolean", + "_meta": { + "description": "Non-default value of setting." + } + }, "discover:showFieldStatistics": { "type": "boolean", "_meta": { diff --git a/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/index.ts b/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/index.ts index 4e73e9a43a9e2..b50b1fe96d46a 100644 --- a/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/index.ts +++ b/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/index.ts @@ -6,4 +6,7 @@ * Side Public License, v 1. */ -export { UrlDrilldownCollectConfig } from './url_drilldown_collect_config'; +export { + UrlDrilldownCollectConfig, + UrlDrilldownOptionsComponent, +} from './url_drilldown_collect_config'; diff --git a/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/i18n.ts b/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/i18n.ts index dcb36cbab257a..48f5fe1fdc8b6 100644 --- a/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/i18n.ts +++ b/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/i18n.ts @@ -57,7 +57,7 @@ export const txtUrlTemplatePreviewLinkText = i18n.translate( export const txtUrlTemplateOpenInNewTab = i18n.translate( 'uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.openInNewTabLabel', { - defaultMessage: 'Open in new window', + defaultMessage: 'Open URL in new tab', } ); diff --git a/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/index.ts b/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/index.ts index fd96f908fda23..02a3097eb9d9e 100644 --- a/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/index.ts +++ b/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/index.ts @@ -7,4 +7,4 @@ */ export type { UrlDrilldownCollectConfigProps } from './lazy'; -export { UrlDrilldownCollectConfig } from './lazy'; +export { UrlDrilldownCollectConfig, UrlDrilldownOptionsComponent } from './lazy'; diff --git a/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/lazy.tsx b/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/lazy.tsx index eb666d6151ece..cc2b6500de042 100644 --- a/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/lazy.tsx +++ b/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/lazy.tsx @@ -7,6 +7,7 @@ */ import * as React from 'react'; +import { UrlDrilldownOptionsProps } from './url_drilldown_options'; import type { UrlDrilldownCollectConfigProps } from './url_drilldown_collect_config'; const UrlDrilldownCollectConfigLazy = React.lazy(() => @@ -24,3 +25,17 @@ export const UrlDrilldownCollectConfig: React.FC ); }; + +const UrlDrilldownOptionsComponentLazy = React.lazy(() => + import('./url_drilldown_options').then(({ UrlDrilldownOptionsComponent }) => ({ + default: UrlDrilldownOptionsComponent, + })) +); + +export const UrlDrilldownOptionsComponent: React.FC = (props) => { + return ( + + + + ); +}; diff --git a/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/test_samples/demo.tsx b/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/test_samples/demo.tsx index 8fc2fe3c68c2e..28afcea46e6ca 100644 --- a/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/test_samples/demo.tsx +++ b/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/test_samples/demo.tsx @@ -13,6 +13,7 @@ import { UrlDrilldownCollectConfig } from '../url_drilldown_collect_config'; export const Demo = () => { const [config, onConfig] = React.useState({ openInNewTab: false, + encodeUrl: true, url: { template: '' }, }); diff --git a/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_collect_config.tsx b/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_collect_config.tsx index 0e4825dd58e50..0495f2d61063c 100644 --- a/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_collect_config.tsx +++ b/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_collect_config.tsx @@ -7,15 +7,7 @@ */ import React, { useRef } from 'react'; -import { - EuiFormRow, - EuiLink, - EuiSwitch, - EuiAccordion, - EuiSpacer, - EuiPanel, - EuiTextColor, -} from '@elastic/eui'; +import { EuiFormRow, EuiLink, EuiAccordion, EuiSpacer, EuiPanel } from '@elastic/eui'; import { monaco } from '@kbn/monaco'; import { UrlTemplateEditor, UrlTemplateEditorVariable } from '@kbn/kibana-react-plugin/public'; import { UrlDrilldownConfig } from '../../types'; @@ -23,12 +15,11 @@ import './index.scss'; import { txtUrlTemplateSyntaxHelpLinkText, txtUrlTemplateLabel, - txtUrlTemplateOpenInNewTab, txtUrlTemplateAdditionalOptions, - txtUrlTemplateEncodeUrl, - txtUrlTemplateEncodeDescription, } from './i18n'; import { VariablePopover } from '../variable_popover'; +import { UrlDrilldownOptionsComponent } from './lazy'; +import { DEFAULT_URL_DRILLDOWN_OPTIONS } from '../../constants'; export interface UrlDrilldownCollectConfigProps { config: UrlDrilldownConfig; @@ -114,31 +105,12 @@ export const UrlDrilldownCollectConfig: React.FC > - - onConfig({ ...config, openInNewTab: !config.openInNewTab })} - data-test-subj="urlDrilldownOpenInNewTab" - /> - - - - {txtUrlTemplateEncodeUrl} - - {txtUrlTemplateEncodeDescription} - - } - checked={config.encodeUrl ?? true} - onChange={() => onConfig({ ...config, encodeUrl: !(config.encodeUrl ?? true) })} - /> - + { + onConfig({ ...config, ...change }); + }} + /> diff --git a/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_options.tsx b/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_options.tsx new file mode 100644 index 0000000000000..a0f5da726a13a --- /dev/null +++ b/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_options.tsx @@ -0,0 +1,58 @@ +/* + * 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 { EuiFormRow, EuiSpacer, EuiSwitch, EuiTextColor } from '@elastic/eui'; + +import { + txtUrlTemplateEncodeDescription, + txtUrlTemplateEncodeUrl, + txtUrlTemplateOpenInNewTab, +} from './i18n'; +import { UrlDrilldownOptions } from '../../types'; + +export interface UrlDrilldownOptionsProps { + options: UrlDrilldownOptions; + onOptionChange: (newOptions: Partial) => void; +} + +export const UrlDrilldownOptionsComponent = ({ + options, + onOptionChange, +}: UrlDrilldownOptionsProps) => { + return ( + <> + + onOptionChange({ openInNewTab: !options.openInNewTab })} + data-test-subj="urlDrilldownOpenInNewTab" + /> + + + + {txtUrlTemplateEncodeUrl} + + {txtUrlTemplateEncodeDescription} + + } + checked={options.encodeUrl} + onChange={() => onOptionChange({ encodeUrl: !options.encodeUrl })} + data-test-subj="urlDrilldownEncodeUrl" + /> + + + ); +}; diff --git a/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/constants.ts b/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/constants.ts new file mode 100644 index 0000000000000..67e48c74274eb --- /dev/null +++ b/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/constants.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { UrlDrilldownOptions } from './types'; + +export const DEFAULT_URL_DRILLDOWN_OPTIONS: UrlDrilldownOptions = { + encodeUrl: true, + openInNewTab: true, +}; diff --git a/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/index.ts b/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/index.ts index 543546132a3a2..4f6ef220a0ba1 100644 --- a/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/index.ts +++ b/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/index.ts @@ -6,8 +6,14 @@ * Side Public License, v 1. */ -export type { UrlDrilldownConfig, UrlDrilldownGlobalScope, UrlDrilldownScope } from './types'; -export { UrlDrilldownCollectConfig } from './components'; +export type { + UrlDrilldownScope, + UrlDrilldownConfig, + UrlDrilldownOptions, + UrlDrilldownGlobalScope, +} from './types'; +export { DEFAULT_URL_DRILLDOWN_OPTIONS } from './constants'; +export { UrlDrilldownCollectConfig, UrlDrilldownOptionsComponent } from './components'; export { validateUrlTemplate as urlDrilldownValidateUrlTemplate, validateUrl as urlDrilldownValidateUrl, diff --git a/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/types.ts b/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/types.ts index 3566b6712c78d..6dc9ee66de9f0 100644 --- a/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/types.ts +++ b/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/types.ts @@ -8,8 +8,14 @@ export type UrlDrilldownConfig = { url: { format?: 'handlebars_v1'; template: string }; +} & UrlDrilldownOptions; + +/** + * User-configurable options for URL drilldowns + */ +export type UrlDrilldownOptions = { openInNewTab: boolean; - encodeUrl?: boolean; + encodeUrl: boolean; }; /** diff --git a/src/plugins/ui_actions_enhanced/public/index.ts b/src/plugins/ui_actions_enhanced/public/index.ts index b609c5807a562..c419e6c682734 100644 --- a/src/plugins/ui_actions_enhanced/public/index.ts +++ b/src/plugins/ui_actions_enhanced/public/index.ts @@ -45,11 +45,14 @@ export type { UrlDrilldownConfig, UrlDrilldownGlobalScope, UrlDrilldownScope, + UrlDrilldownOptions, } from './drilldowns/url_drilldown'; export { urlDrilldownCompileUrl, UrlDrilldownCollectConfig, + UrlDrilldownOptionsComponent, urlDrilldownGlobalScopeProvider, urlDrilldownValidateUrl, urlDrilldownValidateUrlTemplate, + DEFAULT_URL_DRILLDOWN_OPTIONS, } from './drilldowns/url_drilldown'; diff --git a/test/functional/apps/dashboard_elements/links/config.ts b/test/functional/apps/dashboard_elements/links/config.ts new file mode 100644 index 0000000000000..f6692ef0d0772 --- /dev/null +++ b/test/functional/apps/dashboard_elements/links/config.ts @@ -0,0 +1,38 @@ +/* + * 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 commonConfig = await readConfigFile(require.resolve('../../../../common/config.js')); + const functionalConfig = await readConfigFile(require.resolve('../../../config.base.js')); + + return { + ...functionalConfig.getAll(), + testFiles: [require.resolve('.')], + junit: { + reportName: 'Dashboard Elements - Links panel tests', + }, + kbnTestServer: { + ...commonConfig.get('kbnTestServer'), + serverArgs: [ + ...commonConfig.get('kbnTestServer.serverArgs'), + `--externalUrl.policy=${JSON.stringify([ + { + allow: false, + host: 'danger.example.com', + }, + { + allow: true, + host: 'example.com', + }, + ])}`, + ], + }, + }; +} diff --git a/test/functional/apps/dashboard_elements/links/index.ts b/test/functional/apps/dashboard_elements/links/index.ts new file mode 100644 index 0000000000000..6c2b1372f07e1 --- /dev/null +++ b/test/functional/apps/dashboard_elements/links/index.ts @@ -0,0 +1,45 @@ +/* + * 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 ({ loadTestFile, getService, getPageObjects }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const kibanaServer = getService('kibanaServer'); + const security = getService('security'); + + const { dashboard } = getPageObjects(['dashboardControls', 'dashboard']); + + async function setup() { + await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/dashboard/current/data'); + await kibanaServer.savedObjects.cleanStandardList(); + await kibanaServer.importExport.load( + 'test/functional/fixtures/kbn_archiver/dashboard/current/kibana' + ); + await security.testUser.setRoles(['kibana_admin', 'test_logstash_reader', 'animals']); + await kibanaServer.uiSettings.replace({ + defaultIndex: '0bf35f60-3dc9-11e8-8660-4d65aa086b3c', + }); + + await dashboard.navigateToApp(); + await dashboard.preserveCrossAppState(); + } + + async function teardown() { + await esArchiver.unload('test/functional/fixtures/es_archiver/dashboard/current/data'); + await security.testUser.restoreDefaults(); + await kibanaServer.savedObjects.cleanStandardList(); + } + + describe('links panel', function () { + before(setup); + after(teardown); + loadTestFile(require.resolve('./links_create_edit')); + loadTestFile(require.resolve('./links_navigation')); + }); +} diff --git a/test/functional/apps/dashboard_elements/links/links_create_edit.ts b/test/functional/apps/dashboard_elements/links/links_create_edit.ts new file mode 100644 index 0000000000000..4a6e94c656bac --- /dev/null +++ b/test/functional/apps/dashboard_elements/links/links_create_edit.ts @@ -0,0 +1,152 @@ +/* + * 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 dashboardAddPanel = getService('dashboardAddPanel'); + const deployment = getService('deployment'); + const dashboardPanelActions = getService('dashboardPanelActions'); + const { dashboardLinks, dashboard, common, header } = getPageObjects([ + 'dashboardLinks', + 'dashboard', + 'common', + 'header', + ]); + + async function createSomeLinks() { + await dashboardLinks.addExternalLink( + `${deployment.getHostPort()}/app/foo`, + true, + true, + 'Link to new tab' + ); + await dashboardLinks.addExternalLink(`${deployment.getHostPort()}/app/bar`, false, true); + + await dashboardLinks.addDashboardLink(DASHBOARD_NAME); + await dashboardLinks.addDashboardLink('links 001'); + } + + const DASHBOARD_NAME = 'Test Links panel'; + const LINKS_PANEL_NAME = 'Some links'; + + describe('links panel create and edit', () => { + describe('creation', async () => { + before(async () => { + await dashboard.navigateToApp(); + await dashboard.preserveCrossAppState(); + await dashboard.gotoDashboardLandingPage(); + await dashboard.clickNewDashboard(); + await dashboard.saveDashboard(DASHBOARD_NAME, { exitFromEditMode: false }); + await dashboard.loadSavedDashboard(DASHBOARD_NAME); + await dashboard.switchToEditMode(); + }); + + it('can not add an external link that violates externalLinks.policy', async () => { + await dashboardAddPanel.clickEditorMenuButton(); + await dashboardAddPanel.clickAddNewEmbeddableLink('links'); + + await dashboardLinks.setExternalUrlInput('https://danger.example.com'); + expect(await testSubjects.exists('links--linkDestination--error')).to.be(true); + await dashboardLinks.clickLinkEditorCloseButton(); + await dashboardLinks.clickPanelEditorCloseButton(); + }); + + it('can create a new by-reference links panel', async () => { + await dashboardAddPanel.clickEditorMenuButton(); + await dashboardAddPanel.clickAddNewEmbeddableLink('links'); + + await createSomeLinks(); + await dashboardLinks.toggleSaveByReference(true); + await dashboardLinks.clickPanelEditorSaveButton(); + + await testSubjects.exists('savedObjectSaveModal'); + await testSubjects.setValue('savedObjectTitle', LINKS_PANEL_NAME); + await testSubjects.click('confirmSaveSavedObjectButton'); + await common.waitForSaveModalToClose(); + await testSubjects.exists('addObjectToDashboardSuccess'); + + expect(await testSubjects.existOrFail('links--component')); + expect(await dashboardLinks.getNumberOfLinksInPanel()).to.equal(4); + await dashboard.clickDiscardChanges(); + }); + + it('can create a new by-value links panel', async () => { + await dashboardAddPanel.clickEditorMenuButton(); + await dashboardAddPanel.clickAddNewEmbeddableLink('links'); + await dashboardLinks.setLayout('horizontal'); + await createSomeLinks(); + await dashboardLinks.toggleSaveByReference(false); + await dashboardLinks.clickPanelEditorSaveButton(); + await testSubjects.exists('addObjectToDashboardSuccess'); + + expect(await testSubjects.existOrFail('links--component')); + expect(await dashboardLinks.getNumberOfLinksInPanel()).to.equal(4); + await dashboard.clickDiscardChanges(); + }); + }); + + describe('editing', () => { + it('can reorder links in an existing panel', async () => { + await dashboard.loadSavedDashboard('links 001'); + await dashboard.switchToEditMode(); + + await dashboardPanelActions.openContextMenu(); + await dashboardPanelActions.clickEdit(); + await dashboardLinks.expectFlyoutIsOpen(); + + // Move the third link up one step + await dashboardLinks.reorderLinks('link003', 3, 1, true); + + await dashboardLinks.clickPanelEditorSaveButton(); + await header.waitUntilLoadingHasFinished(); + + // The second link in the component should be the link we moved + const listGroup = await testSubjects.find('links--component--listGroup'); + const listItem = await listGroup.findByCssSelector(`li:nth-child(2)`); + expect(await listItem.getVisibleText()).to.equal('links 003 - external'); + }); + + it('can edit link in existing panel', async () => { + await dashboard.loadSavedDashboard('links 001'); + await dashboard.switchToEditMode(); + + await dashboardPanelActions.openContextMenu(); + await dashboardPanelActions.clickEdit(); + await dashboardLinks.expectFlyoutIsOpen(); + + await dashboardLinks.editLinkByIndex(5); + await testSubjects.exists('links--linkEditor--flyout'); + await testSubjects.setValue('links--linkEditor--linkLabel--input', 'to be deleted'); + await dashboardLinks.clickLinksEditorSaveButton(); + await dashboardLinks.clickPanelEditorSaveButton(); + + await header.waitUntilLoadingHasFinished(); + const link = await testSubjects.find('dashboardLink--link005'); + expect(await link.getVisibleText()).to.equal('to be deleted'); + }); + + it('can delete link from existing panel', async () => { + await dashboard.loadSavedDashboard('links 001'); + await dashboard.switchToEditMode(); + + await dashboardPanelActions.openContextMenu(); + await dashboardPanelActions.clickEdit(); + await dashboardLinks.expectFlyoutIsOpen(); + + await dashboardLinks.deleteLinkByIndex(5); + await dashboardLinks.clickPanelEditorSaveButton(); + + await header.waitUntilLoadingHasFinished(); + expect(await dashboardLinks.getNumberOfLinksInPanel()).to.equal(4); + }); + }); + }); +} diff --git a/test/functional/apps/dashboard_elements/links/links_navigation.ts b/test/functional/apps/dashboard_elements/links/links_navigation.ts new file mode 100644 index 0000000000000..7f525f6ffa870 --- /dev/null +++ b/test/functional/apps/dashboard_elements/links/links_navigation.ts @@ -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 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 browser = getService('browser'); + const esArchiver = getService('esArchiver'); + const security = getService('security'); + const filterBar = getService('filterBar'); + const testSubjects = getService('testSubjects'); + const kibanaServer = getService('kibanaServer'); + const dashboardAddPanel = getService('dashboardAddPanel'); + + const { dashboard, common, timePicker } = getPageObjects(['dashboard', 'common', 'timePicker']); + + const FROM_TIME = 'Oct 22, 2018 @ 00:00:00.000'; + const TO_TIME = 'Dec 3, 2018 @ 00:00:00.000'; + + describe('links panel navigation', () => { + before(async () => { + await kibanaServer.savedObjects.cleanStandardList(); + await security.testUser.setRoles([ + 'kibana_admin', + 'kibana_sample_admin', + 'test_logstash_reader', + ]); + await esArchiver.load('test/functional/fixtures/es_archiver/kibana_sample_data_flights'); + await kibanaServer.importExport.load( + 'test/functional/fixtures/kbn_archiver/dashboard/current/kibana' + ); + await kibanaServer.importExport.load( + 'test/functional/fixtures/kbn_archiver/kibana_sample_data_flights_index_pattern' + ); + await kibanaServer.uiSettings.replace({ + defaultIndex: '0bf35f60-3dc9-11e8-8660-4d65aa086b3c', + }); + await common.setTime({ + from: FROM_TIME, + to: TO_TIME, + }); + }); + + after(async () => { + await kibanaServer.importExport.unload( + 'test/functional/fixtures/kbn_archiver/kibana_sample_data_flights_index_pattern' + ); + await esArchiver.unload('test/functional/fixtures/es_archiver/kibana_sample_data_flights'); + await kibanaServer.uiSettings.unset('defaultIndex'); + await common.unsetTime(); + await security.testUser.restoreDefaults(); + }); + + describe('embeddable panel', () => { + afterEach(async () => { + await dashboard.clickDiscardChanges(); + }); + + it('adds links panel to top of dashboard', async () => { + await dashboard.loadSavedDashboard('links 003'); + await dashboard.switchToEditMode(); + await dashboardAddPanel.addEmbeddable('a few horizontal links', 'links'); + const topPanelTitle = (await dashboard.getPanelTitles())[0]; + expect(topPanelTitle).to.equal('a few horizontal links'); + }); + }); + + describe('dashboard links', () => { + afterEach(async () => { + // close any new tabs that were opened + const windowHandlers = await browser.getAllWindowHandles(); + if (windowHandlers.length > 1) { + await browser.closeCurrentWindow(); + await browser.switchToWindow(windowHandlers[0]); + } + }); + + it('should disable link if dashboard does not exist', async () => { + await dashboard.loadSavedDashboard('links 001'); + expect(await testSubjects.exists('dashboardLink--link004--error')).to.be(true); + expect(await testSubjects.isEnabled('dashboardLink--link004--error')).to.be(false); + }); + + it('useCurrentFilters should pass filter pills and query', async () => { + /** + * dashboard links002 has a saved filter and query bar. + * The link to dashboard links001 only has useCurrentFilters enabled + * so the link should pass the filters and query to dashboard links001 + * but should not override the date range. + */ + await dashboard.loadSavedDashboard('links 002'); + await testSubjects.click('dashboardLink--link001'); + expect(await dashboard.getDashboardIdFromCurrentUrl()).to.equal( + '0930f310-5bc2-11ee-9a85-7b86504227bc' + ); + // Should pass the filters + expect(await filterBar.getFilterCount()).to.equal(2); + const filterLabels = await filterBar.getFiltersLabel(); + expect( + filterLabels.includes('This filter should only pass from links002 to links001') + ).to.equal(true); + expect( + filterLabels.includes('This filter should not pass from links001 to links002') + ).to.equal(true); + + // Should not pass the date range + const time = await timePicker.getTimeConfig(); + expect(time.start).to.be('Oct 31, 2018 @ 00:00:00.000'); + expect(time.end).to.be('Nov 1, 2018 @ 00:00:00.000'); + + await dashboard.clickDiscardChanges(); + }); + + it('useCurrentDateRange should pass date range', async () => { + /** + * dashboard links001 has saved filters and a saved date range. + * dashboard links002 has a different saved date range than links001. + * The link to dashboard links002 only has useCurrentDateRange enabled + * so the link should override the date range on dashboard links002 + * but should not pass its filters. + */ + await dashboard.loadSavedDashboard('links 001'); + await testSubjects.click('dashboardLink--link002'); + expect(await dashboard.getDashboardIdFromCurrentUrl()).to.equal( + '24751520-5bc2-11ee-9a85-7b86504227bc' + ); + // Should pass the date range + const time = await timePicker.getTimeConfig(); + expect(time.start).to.be('Oct 31, 2018 @ 00:00:00.000'); + expect(time.end).to.be('Nov 1, 2018 @ 00:00:00.000'); + + // Should not pass the filters + expect(await filterBar.getFilterCount()).to.equal(1); + const filterLabels = await filterBar.getFiltersLabel(); + expect( + filterLabels.includes('This filter should only pass from links002 to links001') + ).to.equal(true); + expect( + filterLabels.includes('This filter should not pass from links001 to links002') + ).to.equal(false); + + await dashboard.clickDiscardChanges(); + }); + + it('openInNewTab should create an external link', async () => { + /** + * The link to dashboard links003 only has openInNewTab enabled. + * Clicking the link should open a new tab. + * Other dashboards should not pass their filters or date range + * to dashboard links003. + */ + await dashboard.loadSavedDashboard('links 001'); + await testSubjects.click('dashboardLink--link003'); + + // Should have opened another tab + const windowHandlers = await browser.getAllWindowHandles(); + expect(windowHandlers.length).to.equal(2); + await browser.switchToWindow(windowHandlers[1]); + expect(await dashboard.getDashboardIdFromCurrentUrl()).to.equal( + '27398c50-5bc2-11ee-9a85-7b86504227bc' + ); + + // Should not pass any filters + expect((await filterBar.getFiltersLabel()).length).to.equal(0); + + // Should not pass any date range + const time = await timePicker.getTimeConfig(); + expect(time.start).to.be('Dec 24, 2018 @ 00:00:00.000'); + expect(time.end).to.be('Dec 26, 2018 @ 00:00:00.000'); + }); + }); + + describe('external links', () => { + before(async () => { + await dashboard.loadSavedDashboard('dashboard with external links'); + }); + + afterEach(async () => { + // close any new tabs that were opened + const windowHandlers = await browser.getAllWindowHandles(); + if (windowHandlers.length > 1) { + await browser.closeCurrentWindow(); + await browser.switchToWindow(windowHandlers[0]); + } + }); + + it('should disable link if forbidden by external url policy', async () => { + const button = await testSubjects.find('externalLink--link777--error'); + const isDisabled = await button.getAttribute('disabled'); + expect(isDisabled).to.be('true'); + }); + + it('should create an external link when openInNewTab is enabled', async () => { + await testSubjects.click('externalLink--link999'); + + // Should have opened another tab + const windowHandlers = await browser.getAllWindowHandles(); + expect(windowHandlers.length).to.equal(2); + await browser.switchToWindow(windowHandlers[1]); + const currentUrl = await browser.getCurrentUrl(); + expect(currentUrl).to.be('https://example.com/1'); + }); + + it('should open in same tab when openInNewTab is disabled', async () => { + await testSubjects.click('externalLink--link888'); + + // Should have opened in the same tab + const windowHandlers = await browser.getAllWindowHandles(); + expect(windowHandlers.length).to.equal(1); + await browser.switchToWindow(windowHandlers[0]); + const currentUrl = await browser.getCurrentUrl(); + expect(currentUrl).to.be('https://example.com/2'); + }); + }); + }); +} diff --git a/test/functional/fixtures/kbn_archiver/dashboard/current/kibana.json b/test/functional/fixtures/kbn_archiver/dashboard/current/kibana.json index 1af0e682d585c..500443f11900a 100644 --- a/test/functional/fixtures/kbn_archiver/dashboard/current/kibana.json +++ b/test/functional/fixtures/kbn_archiver/dashboard/current/kibana.json @@ -2288,7 +2288,7 @@ "searchSourceJSON": "{\"query\":{\"language\":\"lucene\",\"query\":\"\"},\"filter\":[],\"highlightAll\":true,\"version\":true}" }, "optionsJSON": "{\"darkTheme\":false,\"hidePanelTitles\":false,\"useMargins\":true}", - "panelsJSON": "[{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":15,\"w\":24,\"h\":15,\"i\":\"3\"},\"panelIndex\":\"3\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_3\"}]", + "panelsJSON": "[{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":0,\"w\":24,\"h\":15,\"i\":\"3\"},\"panelIndex\":\"3\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_3\"}]", "refreshInterval": { "display": "Off", "pause": false, @@ -2743,4 +2743,435 @@ "dashboard": "8.6.0" }, "coreMigrationVersion": "8.6.0" -} \ No newline at end of file +} + +{ + "id": "16e12160-5bc2-11ee-9a85-7b86504227bc", + "type": "links", + "namespaces": [ + "default" + ], + "updated_at": "2023-09-26T17:43:44.844Z", + "created_at": "2023-09-26T16:30:45.296Z", + "version": "WzIxMDEsMV0=", + "attributes": { + "links": [ + { + "label": "links 001 - filters", + "type": "dashboardLink", + "id": "link001", + "options": { + "openInNewTab": false, + "useCurrentDateRange": false, + "useCurrentFilters": true + }, + "order": 0, + "destinationRefName": "link_link001_dashboard" + }, + { + "label": "links 002 - date range", + "type": "dashboardLink", + "id": "link002", + "options": { + "openInNewTab": false, + "useCurrentDateRange": true, + "useCurrentFilters": false + }, + "order": 1, + "destinationRefName": "link_link002_dashboard" + }, + { + "label": "links 003 - external", + "type": "dashboardLink", + "id": "link003", + "options": { + "openInNewTab": true, + "useCurrentDateRange": false, + "useCurrentFilters": false + }, + "order": 2, + "destinationRefName": "link_link003_dashboard" + } + ], + "layout": "vertical", + "title": "a few vertical links", + "description": "" + }, + "references": [ + { + "name": "link_link001_dashboard", + "type": "dashboard", + "id": "0930f310-5bc2-11ee-9a85-7b86504227bc" + }, + { + "name": "link_link002_dashboard", + "type": "dashboard", + "id": "24751520-5bc2-11ee-9a85-7b86504227bc" + }, + { + "name": "link_link003_dashboard", + "type": "dashboard", + "id": "27398c50-5bc2-11ee-9a85-7b86504227bc" + } + ], + "managed": false, + "coreMigrationVersion": "8.8.0" +} + +{ + "id": "4dd6f084-ba56-4256-b018-b6df4092a66c", + "type": "links", + "namespaces": [ + "default" + ], + "updated_at": "2023-09-26T17:43:44.844Z", + "created_at": "2023-09-26T16:30:45.296Z", + "version": "WzIxMDEsMV0=", + "attributes": { + "links": [ + { + "label": "links 001 - filters", + "type": "dashboardLink", + "id": "link001", + "options": { + "openInNewTab": false, + "useCurrentDateRange": false, + "useCurrentFilters": true + }, + "order": 0, + "destinationRefName": "link_link001_dashboard" + }, + { + "label": "links 002 - date range", + "type": "dashboardLink", + "id": "link002", + "options": { + "openInNewTab": false, + "useCurrentDateRange": true, + "useCurrentFilters": false + }, + "order": 1, + "destinationRefName": "link_link002_dashboard" + }, + { + "label": "links 003 - external", + "type": "dashboardLink", + "id": "link003", + "options": { + "openInNewTab": true, + "useCurrentDateRange": false, + "useCurrentFilters": false + }, + "order": 2, + "destinationRefName": "link_link003_dashboard" + }, + { + "label": "links 004 - broken", + "type": "dashboardLink", + "id": "link004", + "options": { + "openInNewTab": true, + "useCurrentDateRange": false, + "useCurrentFilters": false + }, + "order": 3, + "destinationRefName": "link_link004_dashboard" + }, + { + "label": "links 005 - delete me", + "type": "dashboardLink", + "id": "link005", + "options": { + "openInNewTab": true, + "useCurrentDateRange": false, + "useCurrentFilters": false + }, + "order": 4, + "destinationRefName": "link_link005_dashboard" + } + ], + "layout": "horizontal", + "title": "a few horizontal links", + "description": "" + }, + "references": [ + { + "name": "link_link001_dashboard", + "type": "dashboard", + "id": "0930f310-5bc2-11ee-9a85-7b86504227bc" + }, + { + "name": "link_link002_dashboard", + "type": "dashboard", + "id": "24751520-5bc2-11ee-9a85-7b86504227bc" + }, + { + "name": "link_link003_dashboard", + "type": "dashboard", + "id": "27398c50-5bc2-11ee-9a85-7b86504227bc" + }, + { + "name": "link_link004_dashboard", + "type": "dashboard", + "id": "does-not-exist" + }, + { + "name": "link_link005_dashboard", + "type": "dashboard", + "id": "89566e10-5d4a-11ee-9513-d3f0b68b64f8" + } + ], + "managed": false, + "coreMigrationVersion": "8.8.0" +} + +{ + "id": "0930f310-5bc2-11ee-9a85-7b86504227bc", + "type": "dashboard", + "namespaces": [ + "default" + ], + "updated_at": "2023-09-26T17:51:54.615Z", + "created_at": "2023-09-26T17:51:54.615Z", + "version": "WzIxMDksMV0=", + "attributes": { + "version": 1, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[{\"meta\":{\"disabled\":false,\"negate\":false,\"alias\":\"This filter should not pass from links001 to links002\",\"key\":\"geo.dest\",\"field\":\"geo.dest\",\"params\":{\"query\":\"CA\"},\"type\":\"phrase\",\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index\"},\"query\":{\"match_phrase\":{\"geo.dest\":\"CA\"}},\"$state\":{\"store\":\"appState\"}}]}" + }, + "description": "", + "refreshInterval": { + "pause": true, + "value": 60000 + }, + "timeRestore": true, + "optionsJSON": "{\"useMargins\":true,\"syncColors\":false,\"syncCursor\":true,\"syncTooltips\":false,\"hidePanelTitles\":false}", + "panelsJSON": "[{\"type\":\"links\",\"gridData\":{\"x\":0,\"y\":0,\"w\":48,\"h\":4,\"i\":\"26533acc-3516-445c-9c05-3b0e18686f38\"},\"panelIndex\":\"26533acc-3516-445c-9c05-3b0e18686f38\",\"embeddableConfig\":{\"disabledActions\":[\"OPEN_FLYOUT_ADD_DRILLDOWN\"],\"enhancements\":{}},\"panelRefName\":\"panel_26533acc-3516-445c-9c05-3b0e18686f38\"}]", + "timeFrom": "2018-10-31T00:00:00.000Z", + "title": "links 001", + "timeTo": "2018-11-01T00:00:00.000Z" + }, + "references": [ + { + "name": "kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index", + "type": "index-pattern", + "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c" + }, + { + "name": "26533acc-3516-445c-9c05-3b0e18686f38:panel_26533acc-3516-445c-9c05-3b0e18686f38", + "type": "links", + "id": "4dd6f084-ba56-4256-b018-b6df4092a66c" + }, + { + "type": "tag", + "id": "067be530-5bc2-11ee-9a85-7b86504227bc", + "name": "tag-ref-067be530-5bc2-11ee-9a85-7b86504227bc" + } + ], + "managed": false, + "coreMigrationVersion": "8.8.0", + "typeMigrationVersion": "8.9.0" +} + +{ + "id": "24751520-5bc2-11ee-9a85-7b86504227bc", + "type": "dashboard", + "namespaces": [ + "default" + ], + "updated_at": "2023-09-26T17:52:24.244Z", + "created_at": "2023-09-26T17:52:24.244Z", + "version": "WzIxMTAsMV0=", + "attributes": { + "version": 1, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"extension: \\\"links002 filter\\\"\",\"language\":\"kuery\"},\"filter\":[{\"meta\":{\"disabled\":false,\"negate\":false,\"alias\":\"This filter should only pass from links002 to links001\",\"key\":\"machine.os\",\"field\":\"machine.os\",\"params\":{\"query\":\"ios\"},\"type\":\"phrase\",\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index\"},\"query\":{\"match_phrase\":{\"machine.os\":\"ios\"}},\"$state\":{\"store\":\"appState\"}}]}" + }, + "description": "", + "refreshInterval": { + "pause": true, + "value": 60000 + }, + "timeRestore": true, + "optionsJSON": "{\"useMargins\":true,\"syncColors\":false,\"syncCursor\":true,\"syncTooltips\":false,\"hidePanelTitles\":false}", + "panelsJSON": "[{\"type\":\"links\",\"gridData\":{\"x\":0,\"y\":0,\"w\":48,\"h\":4,\"i\":\"26533acc-3516-445c-9c05-3b0e18686f38\"},\"panelIndex\":\"26533acc-3516-445c-9c05-3b0e18686f38\",\"embeddableConfig\":{\"disabledActions\":[\"OPEN_FLYOUT_ADD_DRILLDOWN\"],\"enhancements\":{}},\"panelRefName\":\"panel_26533acc-3516-445c-9c05-3b0e18686f38\"}]", + "timeFrom": "2018-11-11T00:00:00.000Z", + "title": "links 002", + "timeTo": "2018-11-12T00:00:00.000Z" + }, + "references": [ + { + "name": "kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index", + "type": "index-pattern", + "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c" + }, + { + "name": "26533acc-3516-445c-9c05-3b0e18686f38:panel_26533acc-3516-445c-9c05-3b0e18686f38", + "type": "links", + "id": "4dd6f084-ba56-4256-b018-b6df4092a66c" + }, + { + "type": "tag", + "id": "067be530-5bc2-11ee-9a85-7b86504227bc", + "name": "tag-ref-067be530-5bc2-11ee-9a85-7b86504227bc" + } + ], + "managed": false, + "coreMigrationVersion": "8.8.0", + "typeMigrationVersion": "8.9.0" +} + +{ + "id": "27398c50-5bc2-11ee-9a85-7b86504227bc", + "type": "dashboard", + "namespaces": [ + "default" + ], + "updated_at": "2023-09-26T16:30:45.296Z", + "created_at": "2023-09-26T16:30:45.296Z", + "version": "WzIwOTEsMV0=", + "attributes": { + "version": 1, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[]}" + }, + "description": "", + "timeRestore": true, + "optionsJSON": "{\"useMargins\":true,\"syncColors\":false,\"syncCursor\":true,\"syncTooltips\":false,\"hidePanelTitles\":false}", + "panelsJSON": "[{\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":0,\"w\":48,\"h\":3,\"i\":\"12e3dea4-44cc-405e-99de-408c5879ab55\"},\"panelIndex\":\"12e3dea4-44cc-405e-99de-408c5879ab55\",\"embeddableConfig\":{\"savedVis\":{\"id\":\"\",\"title\":\"\",\"description\":\"\",\"type\":\"markdown\",\"params\":{\"fontSize\":12,\"openLinksInNewTab\":false,\"markdown\":\"This panel appears at the top\"},\"uiState\":{},\"data\":{\"aggs\":[],\"searchSource\":{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[]}}},\"enhancements\":{}}}]", + "timeFrom": "2018-12-24T00:00:00.000Z", + "title": "links 003", + "timeTo": "2018-12-26T00:00:00.000Z" + }, + "references": [ + { + "type": "tag", + "id": "067be530-5bc2-11ee-9a85-7b86504227bc", + "name": "tag-ref-067be530-5bc2-11ee-9a85-7b86504227bc" + } + ], + "managed": false, + "coreMigrationVersion": "8.8.0", + "typeMigrationVersion": "8.9.0" +} + +{ + "id": "89566e10-5d4a-11ee-9513-d3f0b68b64f8", + "type": "dashboard", + "namespaces": [ + "default" + ], + "updated_at": "2023-09-27T15:29:41.343Z", + "created_at": "2023-09-27T15:29:41.343Z", + "version": "WzIwMDAsMV0=", + "attributes": { + "version": 1, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[]}" + }, + "description": "", + "refreshInterval": { + "pause": true, + "value": 60000 + }, + "timeRestore": true, + "optionsJSON": "{\"useMargins\":true,\"syncColors\":false,\"syncCursor\":true,\"syncTooltips\":false,\"hidePanelTitles\":false}", + "panelsJSON": "[{\"type\":\"links\",\"gridData\":{\"x\":0,\"y\":0,\"w\":48,\"h\":4,\"i\":\"7e4355a2-41e9-4627-982b-66ec7d98d58d\"},\"panelIndex\":\"7e4355a2-41e9-4627-982b-66ec7d98d58d\",\"embeddableConfig\":{\"disabledActions\":[\"OPEN_FLYOUT_ADD_DRILLDOWN\"],\"enhancements\":{}},\"panelRefName\":\"panel_7e4355a2-41e9-4627-982b-66ec7d98d58d\"}]", + "timeFrom": "2018-12-24T00:00:00.000Z", + "title": "links 005", + "timeTo": "2018-12-26T00:00:00.000Z" + }, + "references": [ + { + "name": "7e4355a2-41e9-4627-982b-66ec7d98d58d:panel_7e4355a2-41e9-4627-982b-66ec7d98d58d", + "type": "links", + "id": "4dd6f084-ba56-4256-b018-b6df4092a66c" + }, + { + "type": "tag", + "id": "067be530-5bc2-11ee-9a85-7b86504227bc", + "name": "tag-ref-067be530-5bc2-11ee-9a85-7b86504227bc" + } + ], + "managed": false, + "coreMigrationVersion": "8.8.0", + "typeMigrationVersion": "8.9.0" +} + +{ + "id": "d8e17750-5d6c-11ee-be0d-9787f0515106", + "type": "links", + "namespaces": [ + "default" + ], + "updated_at": "2023-09-27T19:34:17.157Z", + "created_at": "2023-09-27T19:34:17.157Z", + "version": "WzM0NDAsMV0=", + "attributes": { + "links": [ + { + "label": "opens in new tab", + "type": "externalLink", + "id": "link999", + "destination": "https://example.com/1", + "order": 0 + }, + { + "label": "opens in same tab", + "type": "externalLink", + "id": "link888", + "destination": "https://example.com/2", + "options": { + "openInNewTab": false, + "encodeUrl": true + }, + "order": 1 + }, + { + "label": "external link violation", + "type": "externalLink", + "id": "link777", + "destination": "https://danger.example.com", + "order": 2 + } + ], + "layout": "vertical", + "title": "some external links", + "description": "" + }, + "references": [], + "managed": false, + "coreMigrationVersion": "8.8.0" +} + +{ + "id": "379c1b60-5d6d-11ee-be0d-9787f0515106", + "type": "dashboard", + "namespaces": [ + "default" + ], + "updated_at": "2023-09-27T19:36:56.086Z", + "created_at": "2023-09-27T19:36:56.086Z", + "version": "WzM0NDEsMV0=", + "attributes": { + "version": 1, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[]}" + }, + "description": "", + "timeRestore": false, + "optionsJSON": "{\"useMargins\":true,\"syncColors\":false,\"syncCursor\":true,\"syncTooltips\":false,\"hidePanelTitles\":false}", + "panelsJSON": "[{\"type\":\"links\",\"gridData\":{\"x\":0,\"y\":0,\"w\":9,\"h\":6,\"i\":\"fd19ed9e-8e8b-4769-bb25-0a76923e9f80\"},\"panelIndex\":\"fd19ed9e-8e8b-4769-bb25-0a76923e9f80\",\"embeddableConfig\":{\"disabledActions\":[\"OPEN_FLYOUT_ADD_DRILLDOWN\"],\"enhancements\":{}},\"panelRefName\":\"panel_fd19ed9e-8e8b-4769-bb25-0a76923e9f80\"}]", + "title": "dashboard with external links" + }, + "references": [ + { + "name": "fd19ed9e-8e8b-4769-bb25-0a76923e9f80:panel_fd19ed9e-8e8b-4769-bb25-0a76923e9f80", + "type": "links", + "id": "d8e17750-5d6c-11ee-be0d-9787f0515106" + } + ], + "managed": false, + "coreMigrationVersion": "8.8.0", + "typeMigrationVersion": "8.9.0" +} diff --git a/test/functional/page_objects/dashboard_page.ts b/test/functional/page_objects/dashboard_page.ts index 6ff48c6b0cfbe..570edce8d96cb 100644 --- a/test/functional/page_objects/dashboard_page.ts +++ b/test/functional/page_objects/dashboard_page.ts @@ -612,8 +612,12 @@ export class DashboardPageObject extends FtrService { return visibilities; } + public async getPanels() { + return await this.find.allByCssSelector('.react-grid-item'); // These are gridster-defined elements and classes + } + public async getPanelDimensions() { - const panels = await this.find.allByCssSelector('.react-grid-item'); // These are gridster-defined elements and classes + const panels = await this.getPanels(); return await Promise.all( panels.map(async (panel) => { const size = await panel.getSize(); diff --git a/test/functional/page_objects/dashboard_page_links.ts b/test/functional/page_objects/dashboard_page_links.ts new file mode 100644 index 0000000000000..5055c16818c72 --- /dev/null +++ b/test/functional/page_objects/dashboard_page_links.ts @@ -0,0 +1,191 @@ +/* + * 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 { LinksLayoutType } from '@kbn/links-plugin/common/content_management'; +import { FtrService } from '../ftr_provider_context'; + +export class DashboardPageLinks extends FtrService { + private readonly retry = this.ctx.getService('retry'); + private readonly browser = this.ctx.getService('browser'); + private readonly testSubjects = this.ctx.getService('testSubjects'); + private readonly comboBox = this.ctx.getService('comboBox'); + + private readonly header = this.ctx.getPageObject('header'); + private readonly settings = this.ctx.getPageObject('settings'); + + public async toggleLinksLab(value?: boolean) { + await this.header.clickStackManagement(); + await this.settings.clickKibanaSettings(); + + await this.settings.toggleAdvancedSettingCheckbox('labs:dashboard:linksPanel', value); + } + + /* ----------------------------------------------------------- + Links panel + ----------------------------------------------------------- */ + + public async getAllLinksInPanel() { + const listGroup = await this.testSubjects.find('links--component--listGroup'); + return await listGroup.findAllByCssSelector('li'); + } + + public async getNumberOfLinksInPanel() { + const links = await this.getAllLinksInPanel(); + return links.length; + } + + /* ----------------------------------------------------------- + Links flyout + ----------------------------------------------------------- */ + + public async expectFlyoutIsOpen() { + await this.testSubjects.exists('links--panelEditor--flyout'); + } + + public async clickPanelEditorSaveButton() { + await this.expectFlyoutIsOpen(); + await this.testSubjects.clickWhenNotDisabled('links--panelEditor--saveBtn'); + } + + public async clickLinkEditorCloseButton() { + await this.testSubjects.click('links--linkEditor--closeBtn'); + } + + public async clickPanelEditorCloseButton() { + await this.testSubjects.click('links--panelEditor--closeBtn'); + } + + public async clickLinksEditorSaveButton() { + await this.testSubjects.clickWhenNotDisabled('links--linkEditor--saveBtn'); + } + + public async findDraggableLinkByIndex(index: number) { + await this.testSubjects.exists('links--panelEditor--flyout'); + const linksFormRow = await this.testSubjects.find('links--panelEditor--linksAreaDroppable'); + return await linksFormRow.findByCssSelector( + `[data-test-subj="links--panelEditor--draggableLink"]:nth-child(${index})` + ); + } + + public async addDashboardLink( + destination: string, + useCurrentFilters: boolean = true, + useCurrentDateRange: boolean = true, + openInNewTab: boolean = false, + linkLabel?: string + ) { + await this.expectFlyoutIsOpen(); + await this.testSubjects.click('links--panelEditor--addLinkBtn'); + await this.testSubjects.exists('links--linkEditor--flyout'); + const radioOption = await this.testSubjects.find('links--linkEditor--dashboardLink--radioBtn'); + const label = await radioOption.findByCssSelector('label[for="dashboardLink"]'); + await label.click(); + + await this.comboBox.set('links--linkEditor--dashboardLink--comboBox', destination); + if (linkLabel) { + await this.testSubjects.setValue('links--linkEditor--linkLabel--input', linkLabel); + } + + await this.testSubjects.setEuiSwitch( + 'dashboardDrillDownOptions--useCurrentFilters--checkbox', + useCurrentFilters ? 'check' : 'uncheck' + ); + await this.testSubjects.setEuiSwitch( + 'dashboardDrillDownOptions--useCurrentDateRange--checkbox', + useCurrentDateRange ? 'check' : 'uncheck' + ); + await this.testSubjects.setEuiSwitch( + 'dashboardDrillDownOptions--openInNewTab--checkbox', + openInNewTab ? 'check' : 'uncheck' + ); + + await this.clickLinksEditorSaveButton(); + } + + public async addExternalLink( + destination: string, + openInNewTab: boolean = true, + encodeUrl: boolean = true, + linkLabel?: string + ) { + await this.setExternalUrlInput(destination); + if (linkLabel) { + await this.testSubjects.setValue('links--linkEditor--linkLabel--input', linkLabel); + } + await this.testSubjects.setEuiSwitch( + 'urlDrilldownOpenInNewTab', + openInNewTab ? 'check' : 'uncheck' + ); + await this.testSubjects.setEuiSwitch('urlDrilldownEncodeUrl', encodeUrl ? 'check' : 'uncheck'); + + await this.clickLinksEditorSaveButton(); + } + + public async deleteLinkByIndex(index: number) { + const linkToDelete = await this.findDraggableLinkByIndex(index); + await this.retry.try(async () => { + await linkToDelete.moveMouseTo(); + await this.testSubjects.existOrFail(`panelEditorLink--deleteBtn`); + }); + const deleteButton = await linkToDelete.findByTestSubject(`panelEditorLink--deleteBtn`); + await deleteButton.click(); + } + + public async editLinkByIndex(index: number) { + const linkToEdit = await this.findDraggableLinkByIndex(index); + await this.retry.try(async () => { + await linkToEdit.moveMouseTo(); + await this.testSubjects.existOrFail(`panelEditorLink--editBtn`); + }); + const editButton = await linkToEdit.findByTestSubject(`panelEditorLink--editBtn`); + await editButton.click(); + } + + public async reorderLinks(linkLabel: string, startIndex: number, steps: number, reverse = false) { + const linkToMove = await this.findDraggableLinkByIndex(startIndex); + const draggableButton = await linkToMove.findByTestSubject(`panelEditorLink--dragHandle`); + expect(await draggableButton.getAttribute('data-rfd-drag-handle-draggable-id')).to.equal( + linkLabel + ); + await draggableButton.focus(); + await this.browser.pressKeys(this.browser.keys.SPACE); + + for (let i = 0; i < steps; i++) { + await this.browser.pressKeys(reverse ? this.browser.keys.UP : this.browser.keys.DOWN); + } + await this.browser.pressKeys(this.browser.keys.SPACE); + await this.retry.try(async () => { + expect(await linkToMove.elementHasClass('euiDraggable--isDragging')).to.be(false); + }); + } + + public async setLayout(layout: LinksLayoutType) { + await this.expectFlyoutIsOpen(); + const testSubj = `links--panelEditor--${layout}LayoutBtn`; + await this.testSubjects.click(testSubj); + } + + public async setExternalUrlInput(destination: string) { + await this.expectFlyoutIsOpen(); + await this.testSubjects.click('links--panelEditor--addLinkBtn'); + await this.testSubjects.exists('links--linkEditor--flyout'); + const option = await this.testSubjects.find('links--linkEditor--externalLink--radioBtn'); + const label = await option.findByCssSelector('label[for="externalLink"]'); + await label.click(); + await this.testSubjects.setValue('links--linkEditor--externalLink--input', destination); + } + + public async toggleSaveByReference(checked: boolean) { + await this.expectFlyoutIsOpen(); + await this.testSubjects.setEuiSwitch( + 'links--panelEditor--saveByReferenceSwitch', + checked ? 'check' : 'uncheck' + ); + } +} diff --git a/test/functional/page_objects/index.ts b/test/functional/page_objects/index.ts index 9a2312e0fedee..34859cfe943d3 100644 --- a/test/functional/page_objects/index.ts +++ b/test/functional/page_objects/index.ts @@ -31,6 +31,7 @@ import { SavedObjectsPageObject } from './management/saved_objects_page'; import { LegacyDataTableVisPageObject } from './legacy/data_table_vis'; import { IndexPatternFieldEditorPageObject } from './management/indexpattern_field_editor_page'; import { DashboardPageControls } from './dashboard_page_controls'; +import { DashboardPageLinks } from './dashboard_page_links'; import { UnifiedSearchPageObject } from './unified_search_page'; import { UnifiedFieldListPageObject } from './unified_field_list'; import { FilesManagementPageObject } from './files_management'; @@ -43,6 +44,7 @@ export const pageObjects = { context: ContextPageObject, dashboard: DashboardPageObject, dashboardControls: DashboardPageControls, + dashboardLinks: DashboardPageLinks, discover: DiscoverPageObject, error: ErrorPageObject, header: HeaderPageObject, diff --git a/test/functional/screenshots/baseline/dashboard_embed_mode_scrolling.png b/test/functional/screenshots/baseline/dashboard_embed_mode_scrolling.png index eed23bc7ed78f3860e11ba9461b54900c881ea81..a98a60b96ea68b65289c981bef454fca61e418b3 100644 GIT binary patch literal 83762 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 Q{EDY2t0q$UOD*UvM)-K|u^9dMG%31Z3R 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

    |IQcC~~4SpP{B!(u0tz#9Hc+SoD!yF+=e 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⁢X_d&AjzR5)!qRd?B--D8dHZj#u2nfR>5ek> zIVw1sKcT;sMj_aMWu(Fc=T=9>|&JaCYQfUARA_81Vo~oPn*7z@M<} zbTJQ(B8Y?w(58Ccsp$ng%vKn(4Vz5_0kBVEa|l>`mosM)Z+0nSS`i z60}JvH$#1GJ^; zdSbrK$d+mdy$hd*&nsAMa+RU?pxNzz_8p|;wA+8iZnWdm@Z%v&muG#b#|365YF+{c zrlwtoPve`J`#3|DIZ25r@u@tPcku-a@yPmXD=S}OpGz<_2{K4pr#1f1R%g)s)CQQ1 zXQY4L`bpD0(5;nXbp-*7oyGGKttIs=G<}|V`L@mo#w?XNT0V3g@y`XY?R~SM+f=JG z-rBl04DgvRE)}9spn`iBSe3CdIhR$0 zfq;}Hg2LX8*CNc6GbHyjy;eBZYeNVH zt`m?vgzZoJvx?P$o)ETRkLZ7MhdxjLafkjF6?e>@8yeP*E?xySNYV4O-JY~(*l*4a z)Zgy8{tp?OD&>+)u}yX~`*#PNb?L{n%;#;Wm1)%?HK6Y!InctMT2NY$0KpTj_f3+> z{E=FtuVed*y6l?-1j?P!sHK_W4E7r(sh~JWHvFmdETAYpS6S^I5&BX&{;!g@%tv+{ z4}w(UYr6^Txd{m7^|B}8u%^os$;ggvCRT*0&qK4eT5f^5PB2 zwwPlp^YyuSo}gJMuj<4~^_IB9wgPbUzAfz7V399C`UJeIGw1rtp|;0^Cjii^wz_mFw> zU$tE32h@vtyjWcdv{$}f_$M!@rzt%Qy@fvi`woAO0=pLvobAv?ltc`L;`#Zr4HR-u zBz&&Iouv}|56X7D5&&p8*aT~Wdu;`5f)^)89F+&;{sbwttqx~nWgFf7MYwp>#|&K~ zaJpo(ui^0&R_0ZqzwxG~?hqB-YR*5GLdKeec~g1Io?(hv;W(jbLN_)vTh;JqqOy&{ zcVA!JUMXR@7$KZA*=3`icbb_R_4iuVtj0?zGP?ABmUA{vO(`+-0ULvB6pYD zVgg!EjZfoa*pZo(?FgyO)Y1~7Zhw4ws8$e^g-M`CMtFk^%VmER>lWKrpPH(&DXA$- zC{CpvJ4cJ?01a3gO~VYPIwDAWMqx0pf}6efUwCcPdZ48Xuf;p#XA4hgL+57Wi3kWH zDJ|Q_X8ir|Um1E@-eJD>%8*lmcSmw;yA*wZ#ex%V_%lK41eA|-h*(d`iF)_Ina9eu zwOnX352f%6< zI#1s1&rc|w1WC3H&lI<3-D=1r`w4my8%&kE4Au5gc|o9Ea5+8Ppqy+0T@DlgV}egW z^yYCLLH9+K4v~LH8FXM z5?M3I%3(yCZ9|Ezk9G_QXeLQc*2&-{0vkMO{koe1c*-ApN~>}A z`F20V(@X0gKpg_TwDec<{afVALjQLS-_!6L#i@^Grr+bWL0U?E3mLFVI8#nbzM-769rm%2Z&C4a{0r32p(P7g+Z)zSP zyFF8>=-%PrFoQ+w-!JiuzG0Qz(H8bU^NM}vb6Vy{|GriEZ8zaZj{GkV^GZ$qWFL6m zoPWlE#60aurdsI#j>CFu`D3OxNt>qD?97af=pj2yl!L(o0_~PTK|u!A*4Qm81BvTG!ZFBn;Cvc>OvG><6>D)YGL|oek-Kg29Hcj|?CQ#47U; zOx7bKf}Kdhyw~=1$|^?z{bQ~|33wn92*m$c$4l$QKo3{O+y;Cz;^$-OYgww-Sy@^A zn+ySN0$f`x=|7500#fvzy2v6>G~+G1f_yi_X-Ng}LSI5StfA3@)wq`PezNT&Q`vuo z=>JWKJ5yZfBlGd&k-6v?Ke5H|&U*&fG0MvOS&4_MlI!WocM0EJ{h7%eykx42A)cpS zUpU16Ay`6nN|rpgW1An6~2nJi~E_piP*`IIR?CnSYh*Ft=I0yX_F*7#9rNLqGpdtLke_%DEYZulX>fhffaj)@jOy_Mw zbzDNdZySz;E^Bo_GDk8E?xcv=_p5c*mWvDeCI)t9my60l~K*k9}fE+RtJ{3#Ket2rceMzXIr;i5`C2f8*oL zNAT5dQ6R7S<4pm=9#YpBZNn$IZZ>?J=p013_Fp}*grt_Ziaj`dza4^nPL$pqoIuq~ zZZ4jdnbAB`Q*$=>Y}yiZ%?i9~nIw}Wp8vQMpqi#N8tyOo+3Z;&!?X|f)3cap?U^4H z44oh98os-~J)E^n?GDYnY+-5`Tv76E+Sm%MxTP)m_Y%1xla**iI3;r}nVg5S_N>?|}ObI?BIhS2}s`{?DxS(NfrKG37Lkx`d0OG)H+~c)HRuOIK72= zO17uAq@jAb{0nE3ciW4es$}8+a2*8K{^JVX{;ec^m*}V1Q!0shn{45NJFCq0{_XvC z`%_|KT!a#vQ>Sxmf{@GRRTg#Y$Drg?%2vH!Y&jcP|Na_`BO8D-;@$Wxt^J#MBYGRn}`! zsD9vZ>%UpDYkT_ZzK5U}nTM7O#O&+911omK))7Nmkz|{4Y`#1@>3O!s6ff+awC^-o zEkiNge}RfGC@E29RUh()ILN}`C|ekY&f-&1kzLu}NcrMq-`?Jijj-AO?o~OazilR% z-0&TC=lAcZUGK7LV&z(+r|2P|kbg7<81EPWP>+DPza`%ex#x8l9jaci3w&W8uLpWD)s1opI6(h5M0p^M?AQejVjOGWA#+c%%OdomHA zkVw`~V+MGQxoz1-mJrZ(f}EVYIon?{wVLqq@;a9$^GU_TXz1?VB|ty}GAxJHzZAH! zhcCN7bGmFFs33lNKS?R@6xZtE6gUF8*gv#3ARKAswv&U|Hr;k>wFZc$d7E3zJB{|iA%R8n#z z1G97w&#OqX4QUa#Bba?udGykpcba*If0zZWlmgZaJz2F*4qq*=9z z^z{F_j4m3&4SEv;HJ(~WZfNDk;rvhv`SN>zZ##j?uL~O3NuIh8A5z^@&%tuX6i+e= zjd{Vz@hf7bWGh3_UC6u-O9ixLoT2_q6d>I6+}<*}0WfyR599X7>gw3|z{>4LPbNdd z;o%y4B2a`jK*yudZx8&cw1olMy#lQU9I+8MU3PXXad7N4w4+*9cqlfv#BVsg8TW?S zhs~fqfa*n&zH={O56bX+!S&Gi5MAg9tO7pb5j5Oh72FhsR+hM^!3XuAJs&<++#*D< z>7HXkp`DMT#g~QbaR}0EVKu(;7!mHT{{5hy->`X>Zi(;)0(=G-`^~k#%U`+79_-T` zCS=#ZoCX-`h&A7?Z8*73)17K7{wa=wghs^k1IUU8D6@ZdJeZAY2wod|e6QVwq_Df2 z5iM+U9ILRRqGxR1nt^hAuk%ZG6+&ljivl2>+>qO_FagL|tnMw{)=X2*4OD!c%g6Kg zQvWFX%cLb#34KJ6KBCs5k0oR6OsBNOb1j{-4L)6O9yPbzA%by?{q|1J#}zC>${cCB%y?5QjDI^ z07QPKP`=zOkA&gQTd(AUxIh^hbuh8?z zYuugQ!j3{bzVJlb4!s*xfAJCBaO`5-dHy(7?R|^0S^3okAhydM^9|#6@|=ZN+oFzm zFc?xiycrjPsBw2}sy;HqYDW!O&os8&mP-)87#)3ngSwwoI^=}z&J;QNbj)vK@t=>i zz_s4Le+>vC)pn6HQCI+|fI}pz-aqro<$Ek9MhmdY-EiXztYXD{tIv$>oHoBr-<*5$ zys^y1NR`JEbs5$G-7VI5l~bFo32k!NJqF3Q6wcK3qrJqqy5@;YUB9@!rnvyK(W4{9 zIx;}9y%{ScMSysLy(D41+P2T>HaU5sbXopQvaEBK%9se&Kp4=2X3Iy9N*X}%g)Z!U znl^fy&*ZDt31MK6Q@$^sBy@W`kOo z;Zcv;N6(gfr8u~_B<5oIOxIAD!otFTD=0qv{3(boAHQThUJo69ZFUB!&}u#ehxy#i z?-~_&m{&r4^YZWniY?w7h@Xf@wEikLxe%ag?@gwJhj^2QCdB_Y-*C;r9lY~h7O0Gk zqFdHix9%GlXx%7## zXBaV$4}44IDBvm^(l^vG5xzGl0_!nPu5)=ojlFV&)-T3`3(r2@Zvsf_oke-+rt78V z)^%X&Y@D3Lx-UkmHa64SE`nyArEns8Xelm_v}3$my&DkV?lDe*y}qW{RKs{_9qTGB zGnXW^kfD8dJ)*`L$1*7`aQ&0Lr&q+UTcf?b#f+Ad`KUJIm~f#Zdf~yA6CtBnlOQra z<>h6v^Yh1MYkw=Wi@c&U7JWsPP3p%aN#ipD*@U_->?dn$BHRuOJY#sILqt6CSN9() z;uy! za9@ygTyv|0vRYR;s+0W-J_hH5_?m3Jno1b5ty>@KNfXg9dN!59 zPA_A>DEHz|0Y}Zjx!-6NpH>(#wVpKgEYWGcpv`9Dh3DBZW0q5{*0I%$`q8_df&!wd z=Yy4wERuP8Ird#-cVx6G%uB??#rsd^Ot#7O_~R5LrSjVCJ%UC{&2(oDD)tsoFKVrkHV08;y$-RGeWo~+x{(&zdDp8gh_tW1vmS}&{0tAa>G4F)J!_7 zYCh!!+oi`ZhzcI}WN4SM39R)ikc&9MuZ-vTeN)IlFXp12i*B5tvnogy8DtKGH_Ocjzh_C)R4C$Qh(DjE zyS~^FouLB%zBXFIy^peXoR}&yE*f!Yl_WK7D~^UQ?#x!nD6Ks;uT~Jq%F}aMzX;f? zxNb4TEpKdMvi$R{3j;l6?>nDo{iX6OG7yelF+cv4;Ak?`2TB)Ko<=4ns*9t$+)hbnb ze`Sy7_%LCH!ewf35P>^2V8C|D=0j=t&noVAZNt-Ke<;&8u3F9rqjy!mu1e?X4_m z`(=4uh#aLWSJ~-=(Kl{hnM0oI65Ma(aF}j7idu3x8gRB7q7|~+`oTTn_Bx`}dNRw_ zNF=l|RM6aJyfWxIem+q*M-R7r&GB)SR>3B|@ZMZw<=hO;r9mfKj(IYs_$ve1s8Y2x z2kjguE(S~CZ^>TxO{mFVm(T2%5_!|_wX4)i=V>>Y6)o@jhxg~`iSOl2D%Gs;DESEx zkgOY0Ayic{@QE3g_s6XIRl2HEb*uI>Ra*AlcJ@g3j-qCF;lKV06=~#dz2J3R+)($#ReM`VNiP_Lkr7 zepSluX`po`2!?^R+;mw$iuK|Qd_$@%m#* zD7Y^UBr)`@w{xAP%+~YkP@#Ss#v=rY?+RpAR2jwMRwPVKQTt)3Z}Qby2#I#B4Xe-P zKMnat&|4}BzOhVtl8syw5THwrcN?%sjNc>AeqD1Vd)-Du_ZC7`v$W$)+5P!oJPvsk z>JVwYe08>{XI5p}8Qj+9jzLXMTgF4n=k6Q*#!-5=x!2r;sQKe6D%JubbM~ehFEZwz zd8t5I!()rf2-OED-nHIi)~bOddHX$~BBP(9tx(JyE|p7*VjMsiQ7{2 zTlSinw3RdrN$$ujw%{6N;K9#Hb1Fe)BI9gI^5fhs)CF~N%?C`*#1TwMXNA}#goMc; zxaFtBgFhp0crQlB_(w+4%o83L-WZD$u>6v)a3aHAcUWvbke3#kBBCrSn|)8=kT!7- z1HqjB0#$0!9?YaGlA7OOfo(!e&0Gfd6xFP~KG;2G-TVGvQg?L-OF-!c@g>Q`S316- z>@PdqMek-fTo`HSk*npber@KJP^%5Rr|Vx}!P9J5m_eh%6iFa6C?Z>|>YqaF{!ro)G6+jt$0a?!6W$v7eGE zxs>@xY|Sa|mY#Zs&yeuK*CFo6xi;m1_B7Rl(Af4gV&~xJx>jkG$OHS3d1U;?`PQKQ zjlvOPYVz#O&wsTgOnH3#KFn2k&1snW(~4yuB~TC$W^Z;Y#Sv=-h590tWK^>W0uTtq zqg!;u7{t=PE#d4hBU1f^Fbw4xXO$6&$psD{3=;>BF%Ssvy?~Xmo()W;zGYSU3>A5c z51P6lM2l#vw07>GC8_00Rk?;hC>_nMp{VjF7Ym|vy$T+TT|pookC=EIU6Ryq;o6{Z ztC9LSNeo{&-;Uzh-rMp}iQf@1xqWnk91x>2R#Vh0g)BfRlFZju?(6L(rNVB@Vh{Ul zT{iW{lQ+@7)isn+L|PD-?Zq0Ga*R~)lQ$TM!=RF1Dp)LCcHj^rKp+wy%$3^>)w5+z zk}{q15L@!bdevJ^l30M_Xj~@CR!(t?CV@}#{^9&cxN)+@{?R!F)shV~vvxBs8v{3= zMn14}aPkc=_8ZTzKiqa=^6%drZBc7Sm+c}9LzUD1n5d|!m2wsRxU}yJpTndNu|iv0 zo5*_lgJ{i>Hs<+YFZgkRf{EI`?FsVj6h3i?UgDTlSv&k$c3|ebKOpJ`mx&Lsy%cz&sHC6Uc z)()kZZuM{2cW=O*7O2V`BaQh5lm!TxM&FcP)z0X-YbR-b@ zN$xUSEKO~}#u7`HE9rEnyM9>R#R3i~ZsTXLj=!q&GtdiuQz!r)P?bV*ex3TQX~Va4 z#7K;keE;e=1^v6VO;t#WN=jr?EZ#lzVPZ0EYwM}vB^?NTu(IcCK-ovSD?B;0Ay}y* zTZa!9{)TwKI($1nYYCMSxwj;7Uor8kbA;hUiM>ahl>Q>u#tu?FJFxF#Y6_fI8^eI2 z1^J0!+JA3opq4achC-8i{~zDp9Gy^2IlXcv6{*%PbS&gqvEIBm5EPHR7&4XuFMgdLH1QVcqH>r3KW zcWybXF13W{{U~@q%r6GzfJl^U+kBb>jDSaAJMep!{}Xq70v$hpN_){^q6gYsgj zF?q0$U*PF($&2n!5wkVa@V2+kyD`I9pa|A`g63c;Jn}J{w?$)`Z=W(+$ktf{y5Jmpd9wi$1-z9+nMp# z@*VVU%KZ6F!B6ZPo@=$#+qa3Thmord=|U&Oyjp0h^47Nc&kNa6QXGj%*2$}q`hFZ6 zzAOV`;<9J3cMt_?CEYhG9v0z@ze-C~4yZi|3qB%4f8DH03Ng-c#dm4uTjfm2?5f;1 z-uIxdBhtHXc{4v{X)IBaS>S1hAbDW{LBQot=wCs(6v?+4jTW6PH6u~?j`Q@?zbF_g z1V5XtjZZu~G5XHN!1P^*By0JhiI%QZVN-Ln`D*!ATW4o!C9w?2#tA0kj`iau6lz{r zZTRe6o(CzfCE5lVrxb{2D908P4=UUl#&P4Ye!b)J<>D+PhR1rXt^{QhlUI4{maALE zb_Z+0acs>wOLJ#1ASjS!%OZ+ZKQBtpQ(ixajI_Wl-fMc^BSCm$?TA!QWZDTvYGvY! zoYKE^yfqi8KG{tdB9avm(eGX2x@@eD{tURVYZ|d4vy5{6?fKPtqUVP@`qkv`lFG!vs73H(~+jg#|A^JTPS^4T$9z+wsNX%a94pmLk@8A4AIOeesb;|e`%)xDiukGJ zdgA6P7|A=fmGOwQ|C+JiYqSe>tcG3sv+%)4+B?#beR(8XUc7K;Z>)87_LhD~4+E(_ zNOkBfhs$NcAU>Ddqp``;DqG8B$*)w4EqfD5&#G4~@h7Hi5VDoroV;2+%TdQc*0S+w zn!OTuh`1F?TqPSWmGNZfr<_!Sj;KM@HcOrws+5Z7<>Q>5yh;BtRe>xlWIvpHa?+;hRRgZOw<2gJHEVk`4@fxF(MkXp`IQ4kHeZRMs zx`AF@;iLXdj}5P;`KXm~=ZXHRjp{7uD8lkx>g&ZK3tOEkpq`bB6W9ZJjbIxk;9*YSr)L`yV7{jxHq&~9a zwX(^OqY1C*lblYGMUN7*Ax+X76~8X1C^xE*3(j-l)ag+o)qXU2Mwip~)W8tU-Tl<@#}KiP_E<+OD7?x7~qd-Oe!j zwc$b)c&Pg!#oC32X>e|G0kBB>Z9m_h-@LrV7~iY!#TrHbCzekiVE1k^UH7G`4aOX! zMYfht6VR_9@Cu%QB9%Z~(xSeLCTy(t_@X_j8=jx(bv>H~j!y$QA=c?7QWVMUQ`kt~ zr&1isEgN_J+Y^X|^g^F}Ps~nbKZVyz^Xhu+n~sxm-%`;BrS`?BUE>i2}w9l+SjH2okYPJ4rI$qqnAPJ{6O?*g=q@dwyY9*i zWGd_83mR5TUudSM|Ck$Cp&Gt49 z8W?*1TqQC-J`awj$J)kdjr76zMZv056}9Uj29>N_|9gDlY*)kA!#E z#-wFrWC8*L2D4v;hNXgF5e*{6(5cLx>@BN&x0ejVqkNQ!P2;J$wY60_^=@Z(@6b9T zG}N}^#B+OjQDF8{A3CSIHtCUbv_rbIT7#(C1w@Av?f0;y~YO@>_G(IWn! zIQ~S2Xw-SybfZ4g_Cu1n9L`zpxpzA+icG#X2UB=;?5@5D&Dq;#$b{UsT8tfz_y-U!ZVY>b^Vf(7rbM!!dTf zde%wu!%z=Per-}#pUIrNrShVIatX;|^@(j8cZpe_768?Ky=h*{q0$KvW3%Yo@rBM` zjB%$EU0Gm6w_$fOQMeAZvVAww)L<+wkJAQ^cUjH$*pOj)EK==lmC!SWgj%lUDwRfq{u_(KS&Mmb95C`=45XRQ|ftY(P8#Z49<5pB4Zl z$PsOJ(&ZDfUi*o%%4F-;#hY|~rOoKeY6^^^RIyKy!sW5*APq~Ut~oxDX6ig=a4sHL z3Z>&V?SAZta!RN6n#m$W(xAV-EfQv^UgY&!|c2Fna!l75?ys;&kk+@OHp(u2onOA=LTvAfn;`|hgd~0INyst9y%Xx`T z)xn(ShPsYzwA=n_l7Qoish8ZR+7r(o&~{UI&U6rfRg2?K(ON|&xoVPmY7!FVpm``U ze~~))U6Vy-WjrHFw3~HFr`4jqK@x0p&wKp88H@2qE!h3)8?`M8#$Km|(hlu^_{`h- zm)%Z3W`KveC(J+R;kY{dB!^ZVwcXA>u$+;QeqSZyS=dT{RtjLem)3AL(*25u0=i4UOe+>WE4#JZ*jbG2LN?Eo*M zMx%U5!{v|mmPR(d9=!Qm9@;7V`ZIdvV5}JG9ZqN82kY7w<|_ucUJDnyo8tIP$Aid~ zzKu6$9IgTI^>sGFEG$LcJ2r6LK7;!WJB=ak6S|M)Jdj(i>jH_jbr=aAr*At~Cgf|} z_xew{JkB!1@y+@z!Pm?~akKg7H3kIM+xZX8-P3eXgOoH*{TB zyfD>zxX=h29nFBjR4_2$+1(q?rZ=ONfn{{PG4#Z_*c#wNHg|YbVfgtCDUVk=?v3WKo6Tk7JG8_#(#s%^cY7BTJin9k zm=h0A^cW=5r@el)6gf1}-QW`lfVe84*)n0%d*=saIEPj^DvQ6}qF_K?I6b%m;e&*M zoFmLeVfU2XUqp&#v*~}bU*vqb*h;?C+{zJ%GXL@i$v z4ZA-d_xGpmimaXqIrS8vIy&RF_WS|M8tgi0A|ERoMoxKzcYW-^!uiQYPqTb1kJDe5 zASyn+>1k+YwVC0FV&x+_j=tWT=)WN#;Fgg3*ZzlIO*lOX5)bY%ba(P-vREeklBm&> znHf9j>!o0X!updX?SVsSqT&gv%G@c)91XCa;IW?g>oqy_B|rYIcxM;UtxgDBCrJ>A zyB^5uY73_0JUddxN3S%mgF&(&VJu=n;na1e2>LLN$_VAS^VOV>wzqY3^v}^`0Q4VS zd6Z7kUv6t8(;Gokr{dyLej{IfWMss=C)uZzQj;eCPHlnpV(4;Tx|#W6p6>Z91iWGN z!WVySpiP>7SVT08gmXm2E1kLe(2STu$B#2}*u%*OYb>@Dwa08#yN1HM-I!sitE+DS z$xF+~fO+ikS{z<^&!6L?in{9hJEDtrA$Z&UHCut2raE(fDkmp&PnA;CGuiWG&ADdl zn5A;>OYH6_%G&Jj5Rt5og-rE!8)~%HC}W``KI>tXQ_m5p&Z_=m0mq-%gzw>sR_S2k zOuBj9wK^Ftmm>tGH!EL0%+%b+bXOquxx?swMvsnQBCWbv-k7@AL@Ap-b4A^aAHH;_}~~*dK?UB6|9tmC>nMeXh{4g=8m6DAZ;v$!|Ss+-j&L z!Q);?OA3Wmxmwun`EUGUn`v5M;Zs*#$Gtspd`h8f5cQ~8YL)c{kgzyzAn}*KjIICX zYgh>jWPc815zln2b;-+mTIA+*OXbQ6s${M!jUn&IbP-oXJq$`nHu_g*1;g^UW7 ztn9t_-ZP_Q%RXd>?DZjsBK)rV==(gs=a1)pdA)jd?(2SE@4c?~IXCL`sD>Rx^`Zf` z%Z&2IEkRbj>WTLFRHvRLAJq21uIKjWw+}V+^^>j7h2?DAce5Tw%p`X5vwHPJq@7;8 zefTRph=tj+#R&Ap?I%l-zO=Ho1CtJ2K1S_rezi9H#y~$%d-ICtXUC%pt!mM(Q@XL5 zJ8o7ZnXg=*P@IBOCqVXSv;FsTy3>R_|MNN1<1{b1XYw zFco-d8;2=y3U$xDLydh=R&{pM>QDk3S3>ha`0dRM-w4n}JNom$WOI7mcw!{W>wzF% z%fiRuUn{Y;$-MUUY^^_Og5OS!zJ2IdtYhM<;kDpV@fbSw8a`>x9_q}tW8U=kTET1D zKl>pSxtW(>GCr;uUU38&nV_nb*3;8F-5HN`pRp~VYA9oG|EpP$Hlug@0jqZB z4Lly_4%lT?2p;)Hi~PNbN(n!|qXP#0R-bSxzn_ohy?b^UG`)!xB) zYaR~&x1?GGJigC)c*Ffxr;LE3nS^WqZ71;y$j-UkFm@>-Dn@s!m1$ z^Sq&vsw1f;{#xg!C{(A{JtTAx$9-UsM)JS^9U4LW|GN_R@8t6hcjk4)CbIB>mNjhp5j z!5^=!X8YWS>SW*!G>=4i9VZaH_LCFEy~yU}etY*G%7)GGDdVbUVYnAgW#=9Oq-9nl zAu}FNv{()5C%!BO)HUp8u{|krzgAiE>=;werLxei-B$vrFt6k z+&gS#p@wBUI~12|pxvD5ygE8;Aq}<9Ry_x?Wh$mpoPx~9yYWiPNAEuL`&-nOcJv0k zMivP@>o-M-k)#sdkti1MYJr?n0aQoYz|DAU(mmMBdyt_4ik&hxJXm1neP8NFpnLVU zQ;i-RO8{ogDF{hc^@@+Kx)$l(<5L0dt`OIJdHFsWnL;L@2+ya2#1w17l6J&Y-VQB)C(ofsg;g0nj+=JEo6Ax3mYMxD2rMQHjr`~R z4-YB(<<*p|UuTcs*)a92#PMMk^MbtOh}yLL#m0ztwpT?WZ-U+mV%?{D%d%gUp9ZAyqKLyc|a z?M7sG=i|J!hWe0MmnA*Lt=lo~)Z00l z$fHcKNx}l`AmrNa_QHtwb_rvJEfc^aEsDTC6etN0FU}(b7Rwsf++lJ^u~2az58kdy zBr^Z!{7h`K!K`|Y0uNnN^I?=a0)7ub5-|2q?PF@f;pjnt4PjGRtujr<64+n-#Un4d`|%VTkhK+n zcC=L3P{uOOWfGp0DHhKX^B7OCC1a12Z(xMt_l#PcGah+zLA(y*Gq#Cuq6@ zR3zt3R;` zICutsPYeAPHdP&j(x+-SF51H_QIw)8a!j!9k+%0#a_snC>4P`#tYAO?Qo#UKIwd}B zTrrm~(TE)@Zm6AaJliN+W1jt3K7n6IhCRbGKY0S1*<%*;b0+^TJ}3f%Ib66>8UCW$ z&^*8vvSfSd=Kg3;hJRjDO~F&p5jji{z4*yYN5@d2GhD;-ce4kSGv&b_;y0k7p|rHJ z4L{CgZhP0FZ6;M(xd0|cuOJZKVFb89i0Zw^YrSS}@UMHC>MF(Ybfx1aH>Vgyg{HU0 z5K0HrdZ*83>HTUQV)T*4Whn;r$ZrL;YCVF4{hXg%$wTZ2*)W4c2l_QD{?yd6|7!oh zV6r!tZDO8XYu+1&aqGTc{%01>?GJg8-DUw{+GW&VM%$T zG#Cp_csvhAQHDe=U7HLY<;Rk2(!sCfOUs9b8E$86&9xfnc%x^?`x;r7lBlv zhUJfxRUjNj?=v)L;=Pp$dEA1O!33Rp>BhUucZ#$IG#<-?pTM}VKy9Vv&N{ZJdEvKC z7as!M31oV6dFMuid1A=r#pDFsuh}6vAd@ilxC2S@y?bTk z3GCW$NEhuk#1xNo{$gCZIB8wDBCL$YN@cQo*z8Pg*L?Q=Ou2K2N|YI-W!t3t@MP{g1@F!4@k1f^b5DHS3MXA3 z2X0b6Bl?(;IbndVVYVzLg|E6<7JR#r%PL0sV7f2);aLT7cbR59f6rF+>oG;6J@uMO z>(E5L(Jl4Y>e9~&_w42+?$n8CvD!~YnaFR7p@q>eA7wLYkVkuMHhd4L6?7pdFU{0cH)IQ+YnI5V zcO1$4roZv=xM1s3_qT{k$;{XG>a~t)!qu2>@fy87e35~)ifj6qBNi7c1Px)pux}`N z%f|hN8tJNrAg?$wTrY2~(-IQ6MU0^v#5fEdib_1h zCF*4#a&vXOdGo?EZ8i2gDRe{$ zBRIk)QnO9uqDwjQ`f|gs-KOoXE-m4Gad7%c_5QEHL&w?NhL+SkdxP5eM;e~=lIU#U zHE<0~!ks*p4bycZy!s~lsbz&>*R&I`V!xLkbv8Rr(|9aa8$a~iF7wAD8?aAyRfb~pR zp@q&bTbA>i$51N^*H2eEw&pg6#Ob&g==IyX_r@v9z(^$bkow9N?{~s0iip4_S-^8m zKToc4O3IgJjhg*W*XS<7`cr;URZIQm+d?Cs-J?C9(v?lFf7?`&0D`aoTRA$8@q8ho_2Jesh80!ByWKC|r3})G$e(S#6eZ-aG1vwu;V` zusInQ=?u{~gtSdO^BwgXd0ZonOHKFgZ@stkq=2!yL$ODF$I8iVPLZOU3p$tf3+nl{ zcUiK(oc$?yI8kW!24~h^&j&IF`Hv-2`ryqyKm$LB6G||5#g(hpD4>sF;_hLKE*bj9pYahb@KUqM3yACeVXZ|#AvpEHkdUGN(8fOYOi<6@H z9Y(Si`;^?dKrFawUpbFR``T^RcLrHRI?3vWw)zAO>ceG!4ExG!)$2sI*PHYY*Ikt_ z98>epe~0rqEfg1x`wQ+>nc9jGfK*L$QI#xbpCR0=8M64-H(t@a4zW#E@Uq$5#anzf z7@^nCIS1IKi4AhbkTTukf%rk4;#rx(cdoo5b|0f%)g!%y1=cI+_mfO^Ji+lhF^ZBy z+V3XrC^$%`kx%A6;TQSa!GSCz&zh;T--Jfev%3PQP7?3$CdBpj)}7i7V)^k}zowy0 zI)p}kessjzV1@Jc&7!6!Z52)|DKGnA5u6Ns`n&5&$XwAk?h-LEWo$RKzV_3Or{1pS z^OmccO%m`ME#ab=#m;NVvMgy&2 z>;v}W8CGazETe{FXUTz(js3x^rzHazOrL^MpX2?pNZ)fDmw1?g)IsjxR~g-AD)vFC zfWr#W0ydje?QDzBW1;{8LXUHmG=2uW)-zpKAen;@yAl{e(XUin{TTP&EpVAnW%E(EuJ6u>i zC$9oy0wScgTLJ;`3tO)7>3b7hTdoLsqp3sTGNrjvcdv_8CcEH?5xlY?cCFc|zx%Fq z40#**27B6`p7ldp{`q?pJ_9b1*A>rRjkNmeMuM76ENdEAc#YMu1!BZ zTRdFHWwoc?DSVABqNp&);lIrEpwl(D{dp;m4GdK1a?2>CJo8|LXALCRjr?p|FAf=h zL>`k#XbKcjIb1&z`F@s8-e{|S93Z1JKQcqCqkRBL7#t5OMe}Uz_~$KbdI#)C1P2Ad zd3eAqw-or}1J#D_g*-DKc>n|8E>q1;>DaExXKb0+l0=Y(wl0y=5m(Z}1FN}(==BSR z(()IFnx5|3zcnzzXG#KQ<)PYAlIy9hqb?xw=l7dvSAHv~8hVZKil+Zt^>5TEc!>k3 zw2*l)t+9nLcaJ|FA#HoS2>$f3?k!gs(ReR3rA&#g;<2t_%ESspZXz2g#cB?42(YSK zjpu7$RuT8~Y>w~wt=3K_&R}Cz`G(-JqIkfV1B`m)`zk4TRYF|84Xqfh64vX%#54n> z+XYLPRf6L@&}ra+kW!iNxN3HtYcHITZ)Xy`N4C^qM5Z9!R5`aw)~2V^1*4MS%EWwx zkWSJ~uau}LBQL>Ktp^ChB|~TP{M&~o2hV-W*q|vR7(#*oPV)ouE^-F`u~R&yY}ds( z8}Xzr4VuNB9Q7?c7@<{xIDyJ~1O%WE%rA;;P(d!UTInO|-_r3&)*g3SP1 zmELV=ZKik_Lb?bn1Ol%pCs$0w)^uLnn=h()FsK$XMLW&|Ob%K{5CkHD-CI|6-j}F? z8P6v(fDWvVYN?w@hXtiBYb*ULI;gP;;n|wURumI8C1S`|rP3ve7zE-%WBA*H-rRL4 z=TJZwbiKxXMWgb4sP^2)c2@@&JD3;^mzFqv%01`R{OU>AUS_o^AK8jOwj zwe=P#05C*|`aB!z(yu@!308Y^KnHms z#<)|*Vd0ViagIuPz7(VkHHmQj=|jINiw{UEXh#?eh*C7H;U*=VLe_dlHv*~MFN>{` zDe49QOz2t-6DJJTk8704a7c6XAcejD{w?4#i}d*f8euJ*S2Pi3$@!D?yCa3lVwD)T z18GR}i?-ug$6jCK&#w%%AW#OqDd_fqVwS5pky-t@m(fpS8+PIOl;5>AQMbX z%jM?}OBiql_85o*AJ|6z2zIOEr&TtVl!1^hti>jJ)X5XO`fb#HyPIhs#lk}X##Bkv zpt#PKT+->7@QHo^nm~hzTS;f?b|rDG^5V6&UD(sLi~*e$rz42_SpMt5B~vHStRN&t zaXM((wB>lO@}j*H_=ZCstWcUJ27fce>VsuXGc*ef6Yn5|Yr8D&U1-_IG#CJmDm?4H zXeDd&4F1P)%rNmP90QUD_4XYTYrs*aDzAppso(R4wb0|B-3M-idJ*K;wmT6S!1l!N zJD-H+>A#?sr56%|ge#sW4$w&Cx}Or4dBU$8-RF4=*8j>Zx%uz#VjVMWI0D_*W5U4f zVxsTao+RJyVeSDe!U<%0Jia{&SQNK(@ITX!#U?6AJmL2%0Eq)Yu<&&@_c*G(omkfh z3Rqx}8r+ZCRQpiJX_Wxo|Kcx3kki0lQP+IsVi*bF+8Fst4nh=pMQXPpPjU3OSxZ0J zIZ(?GVp-;E8bs+&r6j?f{t;ht2530@X|$5O&c&aWXjfNk=!_QlAM_dxAI(70IN;e} z^3oETzAOCw|3Rh+`%Ty6>b@Dh_#FjJ1;Uu9F}oBTm-m`xQmAYI|D3}D?yjJYX6|bz zK)_(|N8o;cY!O~x3lIC66EMI)NvJfrjsLaRG?xZiqDg`k8W+W=p_fkKSBsK^maH_p z{4vk-ohvOs_f+p=y)vh>j8V>bo>mMc-+8bO7s~#=$+CdUM=8i)2nZ}RSiCBl(`qa7 zO-Y@gEo7MDRp%7<%H(}LE9KGtO%n$8d1scaFeE?zo3aMtj>66>t%10xm~TGlRu#cd zY$9gDmT&oLDeXEDOJEEU2Cw3YG?-#Fc-#`+2h|@ifkp|pbS+$Bq>yB=y zcok;V>~pdfi+ErVfmVAaO-lH(jF4vv%mXNZj>AQ$Ndd?o0GnU~rSd8h6B{C2Bzb7X zihqPCg)3<+?xx##+=rPq6--_*FH1wDSK_}izi!$;F_*Zi;F$+0zAhURtx<;KB1?=e z+M-zi12BHRFiNJX`wrYQ^|MdaE_GZY#(){n=1lJaW{Jdgx=^qJ%**_^(?6P$GWpMx zHRZ8FCD`WE@=Po)>VfrQ3+6O9XjXstzzRK$SimNFGpBkM-)qlRb_W&_zl*Z_8jt#C zQ(6Ewf`P-e4N&oyf&lY+vS*fmyAr%m2d#mKJOcQQs*DrXIH*9o0mCYDk2C-Z3Lm%Z zLF*5k9qi52vpqIiK8(!i2K z0Zn-Sb+G**iSf6E6UYgP2XmSa_%z{lUd5ogQg?{{aVKKE;K zD^cS72Z0PgNN#m1Lehc9xy`qIJ~E(V$uunjmnKB%Jf%_|-J`__#IXsm4zcc%u8K#? zJC+k5XP%(k-u>4BP>V2wnxEi21mFQK@jTf~lE=77q5V%X9p2_Zde2nab>K4N^F+q@ zN-4yX9wBh=dQS6rU2;KU!Eu_H#B=>0Ww-z@4lAb4m@=z_ZAfRCJ~#gzy{SfMuj%VNJ+AGfj$CUZ-LRqS4&)q>~9?BsRwyY76 zqa0~&p((fm@>Ct z;8xsqQ?KK8$bcMkUc+C+r(-K?j(->hiH~A>rYHw=Rn$N{vTiKgSJ~HHy}Yc? zrD1UPm>gX>v;txRSD)&wEOoOchA7UZ1M&;~@03Jy(`?_aawZ+4eMg>5rtT00UrRid zA@E2Y?>XtlYn!oueEmyMr>H`HISobbz%f^SBXO--;a8hO*i>b9yjnGtH|%tC%E`1U z3S^P0>f@EC#rM=}My3+^7yJe-Zr0S)im+<6jE~oHpI3So6xb@-RZl)bDuPQ$L6uYkS; z9Eb&fCoMudF$UlRDFmK-o~KoQ-r_=B_rMjCS?Ad*Z^Ii#yc0Pw@oiZ8IKI|g$C{$%mG&QF9C-!?_2X$} zLeG5li$y_S{NC|%ozMX-HdLlv3Pm00o^+5zE1oDLK)ZJaBU>_G6pw%|dB@w2v&e z9|uK|+>j=ecKP}N9Ci9oP&PDCQHl%=wk`;*48G|0O%v!0ldBlS&FnC{WsXhFMDR5; zk>JZ+C7XTLO?MCEepA3>jOyd)Vt@bk;*)KPAzSc+v77Ew6+`pa;24Dm^ zM*G=P&?ZAXw=W?nDILwhQv3Ih8ms2QcSpam?w-cflb-h3S8N2F`?pXnoZRf$jH=k1 z`xR}iqshn>(kh8grHDdTx_dXg=GmJfUI|a=3abSOo;z$6KAVini0APiNUG_2s*swR zZfGJVucMlrEqA<+yczJP(DMk@i1+r>ukWw?Vxb`*m^0y%HP_VNlT$+rIz|9LdFf@J z{aWSIhl-*_@zt{OOU!qNS10^Gd|;T^XewFAYS(iviXKf|(d=a~IQHt(-xfqCqd~;M z%TIcBO9qnl#)cg{9yugIaC z_K_;?DXGJ@Xl@wFNDd+_5Rr|bxY_-5_@r`<^g#uWelGi9tKP%SMwPrObMNjLwF|cS zqsKDg1@C2KPA{r?o9&T4aXp6>!d<_8Edvr>hCl}^FjFNsQO_#|)%I=gX%E~WL&a*f zMg5m0Yua^+(b?VS@4v0kS*sWpm#5E_uJg4@Dbi0oQ3qQ&co;?&Y#t$_93)AYqfN{_YQpwcLzz9}i{|q67txawyj$;?E7Y`vmjv-8 z*G8`yDIjDazt>SXsu}iXSMIwQA+3%9kHn(kk(x4FQJxaTt9P8qMw}9=PL0|c3en{& z)YA1-8?KaS^^<&GPt${scU)*wxCj_5?dMY0*7l!Naae*HeT3v!65cp^)I_&EQd@fu zY-g?$kkNc2qA3FXY?hS8M=2?P{r263UBMfY*)-b4?87ZGd80Hk@OyHs=3&Llw;YUH z1V-{=hd;BRr9xIqEI{-t+o$csYKoHplkOwUdDq`Ng-pgnmdi5lNOJO{ z)kQw-G1294PPq_ZMpAt*XzX#rhaQ!E(^5!r=65HUhKj+- zVh=ny3>!T?l|PM{BrVUO4)i=mHAX;az_KK>5~Tv3c#e2rf$uNZO7ADtOOS52M5sHD zO-`AxH^$}-KN;BfX)%(6R-=i;SUeTv_|7`Oclo6AVMCh?=RK{j|4ooPsjU>T5RW$y zvw8q*Vh;Uw#YBJnb1OH>Wbt$VK8hDwVHqDtnE*b8$|{>1co8f8x5TQ68-h5PA&lsT z0*OF!IK{ifcg?zcVf>t2r_Mbd3bSF`@as;!C#YdXq3O=M1=G`0%!^!FXnmlS$V$A% z$zI&51YcIxo?_ROI*^u8{^}9?`oS;ZZ)K^ODai&#auR_%JKI^7kdF%_%m#9b7hd_c zFjJen`4vWb=;!Bhj0yCRn*9jX{EsaFEG+;T+lAytA`HK|n+JXsR;65xo0<6j6JTp@ zV*>O7VhI5mVD^_F8Wr!^SYCxQC59Wy{U@lVq+h@di~mhWpNhd`!z4Lx9zPVLVdgt| zJ7K6_FI1Ev`!%6wP?5A62gCwXZ>wm%*7HAo%0vuS*JhXr5>^;!HHf#NjCwk} zElZIgwv<~?Q>X9mdj2!wSCX6qVIPn2Dcpf>n_2?)j(Ff22G(isC(Wz@wFAgjaK&%B zLgxYZZ>zA3 zCVm}74T zSmZ`|ErJu>_RU0)lJ@EA+0&){UJs8P7X1{>nzeHum+g?56f%RlU1Kdh(27IgJloD6d0nU3Yer+i%>|@Y#&h@%S-9 zD6g*U5tq-$&qx@VfkG06>c!TR;>Z-1KYLyJQa<*s8Q0G#7o z@Aa~YeP0wy<^A_T!TB_^?~SAWbg@FWKk9UM560VK0V+QYOs*j-$)Yj_Yc0>yIB35iHI!YB*h$%pcU@#MNz&j?r^^ zflCN*my^mDPW*%(JSz;`Ee1Zy4UVpsahbOS#YNwfs46lHO-_v6m{R-kluLVA5R*+k znr`@dnb)(8((xe(1z152Fj7mNqNnmz+a%79*VJT!(rkW{5?6FhdRfZgDqXLF|LwhWu&3KCQhpt{2 ze=vlJ=@SUr*N}@S5NYfr$UZ1vOxPgaIZo> zj6}DM7^j>e5G2H9a`9D$_(Eqst1g978!x(_F({_rHkN-31OY9;qJlhGm|E&)?d$s8 zUI|DG|L))zxIF%H3*8Unz~nIz=%`-*a{P1Y%}y4HWDl3EgOF>l2kw)9mM9wn+|ZNF zrb??)q{Yn>+mQlI+z%3BIb~-pmPR*UX%PKiK1P|5N@S#K?1Vu61)+n;2ngpCE8nOg zL`X={wq=8hmH1^E%TPo;h2SlhlJPRPe&zxf8QJHEz}s4lvH zLV$yGM%+NJ33AD^#&bm^XYd=wgTLHo_b5SVfHiueGs4~xPs^k_Z3nCS(1#Z*$OLzu zG;0OUj*yq&H#uz2r%FK8@f!9_F@C^xk{`kq_XCI|FXhYnxIi?7z44b7^I4VYtNdV& z@RmJom=}txk9``YAG1o459t()Ry-@wiqK+W2t>crsd*uyJOpm(y90nm8Wf=f`BA2nL z^x6C%xz7|lF6`OuFC9}r;`MIR5WMsUF!w7+i-N`_UHW$`b|xbhsPL>4)p0`sST_+l z_n!0w09L#%!gVOn zi?K-?Ny36ZCke(QPR)0?^GINfi&Mwxw5Oj3S`0x(Q5zRG;5vimTNrk*IX>>6wwXl4 z*OcFrTk$5sUt-N+Uqw{+ea$}rd=rl?n{XMb(PDWAapTK}!f?C`mk@?K4<{U2yukTK zEO%SoX?u%*MFIXpC z%AIM5*N+f}QE~3yXzy%vU5FlQ3}%Pbo%%U7EHtAqpzObfnMsT4L(9MF2IR#SCHCZo za^fA7DtN`evRdlazpE*9vGnSei1b8&4}2$tczz1vW0u#1*n`=7z)51j$-I{)C3f2& z`NQ6!e3H+5XFf-&NJ63OLsXUa+0C1^uD?{>o&RgxeuDjKXvQb+B`CX~N3rh%dP05E zC%LlT5&CRC%g`46ea}OKDMG$mpOQ+rTGkL4hnlUeoxEjiU3sBXpW6Sx|NE=_LGeYC_FNvaG?)d%5UI8b-6?7+yUFq*h>X3DKI~nNI*aG+m3)xhf(5g+XytcTTxS={y+lSh~(N{o-50iw;eOH&b2dl zGhC1r+t-U00=d%LmyN~S7C%}{h(ntEU1Kz=Tg9Lsc)2`SLfH?Qqp)8b6kz#-j|&LRocfFC`lGsg})obNeXvdc$3(EbdY6{pK3 z>e#Pr&6Lzne_eb2& z#8$tTbfI{WzQm(u*#%6F@9&Oi>;8MzElLH(-81jjhU_|RQhQ_?*@SP#_iQ||7S(wv z#;K~Pn}YURtE5xtgMI2$bh{&G`o2~&fodb@z8qAvcom=TJ=|<*Nl&QLW)xcyRG$PI zHu-dM`j0`~qF>8}!->k&$0!C+YqUDb5=hn--NHNr}Prus;D7HcK0Ae5;4(I|^-93ZTFw|2D zr0R%fwxq4)XBOOar-Tm!*;+5z&hyvb&6@Tqrq!V+HXttt zd+eO3BWX@|t(QLbLtwfd281$hx&G-&&M-LRju+C0RcPhKtvQVj{V*`vXsB zssk#t#oQaTg@4~W|5>MFC?`eLQeGsJZg%ae5-2I$h6P*dS1a8vE=CQ0lmZ?k(qXz? z$KZHhOR-vMKZDQc;j^@?YF#Curm-k3GMBU3ebqaROEN^igi&~Sn;9l^<@>FTQY~j^ zscVvEy24qq;UK13K=_Ims=*^_wo~-sM4W7%CnuYV!=icYB4@GXg&Dagsww5L^ zzisrb>~y(CwK?K7W=+#Jufv{?^1jbsv$ArBkRu6rOZD;#^A#qY@g)W4+mY1R8#TPx~?fVja5kPx7!ggAB2@t}Q=83j2GSs}N<9lehLYrv=hyafcY6@QdWAq!q^e1au)GRFH%f~r zG(xNUUh`|KVOW$0IXQg^4;e_!p?in;!Pyq1_Na?Vd_X1j{v21Cl;}UO+W_nbk{qDU znkjHsP)Z)kI%Gd}%9tCBM;}{7T_Xy`$UM2#34`5=fo1_r!$uhd*U)6GOp+REppnyo z6J1-*+2s~^0)tgk?|Gj(&no>(FPzliH>9nEvr_Wwp>|-F*Z2eIQO`#G+?#J7Mz2CBi?n5dBJ0Pq)X*rhQkQ-neDeG)Qmryq5Ff* z7&Z~6nyQ7u`xe80Q{zBdd3RCY@8u~AL};z3s2|@rx@h4D5mXt!MgV}MvD_)+|k0v-99?YmliG`y03`jX;Pz;>0)AJQyc#&>rt*- z{6hHRbfq-TGAw%~mAI25T9ntK9LkoOA~0cDhtqn$hW%5}bGFX-HWfPxUssYx7ntfY zE=$5bTIwBAJn1I!Ge3O}3Z+gl5*W37@w+Zm)v+EI&s7P5L%Yb;-?mu=*sggud`lx0 z{0tiH@pRHJqLUYBmIaP-GE8ya=}G77_&l#8?$>Nlu8>pu^k(3a=GW!sI*SNg->bTI zgVZgXrB-`#Cg%D|U!;|Xxl_^jegj}=F=)OqH{JBtvuhYx?&8Zc1s~ch)+<@@2&A5O`T^5y52P za-}etKM?Sc4xKd?b-h?RKbW(E6-zn$knzTqXCZow(?#&K+x)bDP6oJRanqL`3v^7Y zmNpRn&aWjoAB#?N%6-d|2f#X-80qBwYW?E>p<4+jlKS%WbnP$X$OGLL^=l`tqB;B- zQRt&3QpnrXRTj?x<8ro^(5fr9xBF-V8j28T1oZIN2JOqn7r_ABk3lbXBgRGje<)|F z!9(8Jgt4XQmXt6kLk*ek`B?^4Wb`dwCA2I8Ex-;Ibbe>iR-wLsH(*2&jq98H57-l| z1krIcH75>-l(wj;R>>r>wF4kP!hh*EM8ivz(8~dRe*(0Ez!nn^tXUC?wn+eiD7Wze zrlTgkUswEurAlX!s1sTou_t&yq7d+!twwU0bD@F125iX{wy15cm)N&+1T9yX>)Kkp zCu>>xDm8k@9h(3!wI=$)?7$W3qpQKFx0%dc&i7>!{x$HQTnjdTM=Gy%5OiVp`7V0F zh4oTiaoE2S43-MIba!6n`o*&)ag2gIwKrKt3wdg~jeEr)*gDAXZkFagWHBj8?1{W5 z{+sU8JO5hOw9;*Ah`2zv>{tEQWYvB3g4;NqssPKKN1>hS=m!L*{vBIOaz?(iUK2(; z`5Uk>Jsag#AnKm0q`2ccD3u5o_2=8n(-dgb0EVU${n?!JZLVoA2h7pRfz5@}CA>_U zBTtuidc15^o&&PK^Umc};Lzi3ba&8>S3))`@vURhWES%_14KIQv0S*uQ4iEch0ugS z&4@dC_NdPL2_;-m-yIgh%c+*T&u&6HpnkQ>b+C(Nm>N*p+4wR~Xa(xcsy*mn4OC8` zDUk%MQYE!;N99_hbraBP+~7GldLI=d9fub666h$eUmQnU>1%3%^AcF9m90nk#rl`L z|6%c7l5?0;{-k`m@YX>PVAg-l#V;gJN*6k~u5fq+a9AhJnaMVvuiC-^%n%=Y&oAgd z*JKPVPVp+71i^`J2Y#H;o6W2kYW)f{ZiIi0eY8K>$NNfS#o=mj;wbiEy+RZ!1}GV# zgO8VQbJ*q^I2D0d;YH-lX_v>;O#*n}>X6AquuylC z#L4C~dwshhqi2V0O3@{vdQvuB81!K2pYjRdbYZTL=IGtmYIc|ghbu6msLKZPG;3|m zDkrov{)qurSUzqoY0@%D>3PHobKOt*m9R0>_bqh;EjI7hl2w6zw#SmK6?yYOA-s25 z!Ko_6+e!!tapNomi|M4kpOb9?$e=;Ije0wY?SgM6o+PlO#R z^w`3F#s~m(d{H<4>92q|XdMb$Xc8S9{nuOqLidg!exNWS1wPifU9LX&>q!v02YW~m zOhd$(Zg)?{XX8+rDOB392ZO+z?qea9u~!`Z*Lznduu%nYqDPxwbC*jG73N!fa?GjP ziD9zrOEO0tGY!wIW$3J~$mUF@{WL_9Q|Ezq>ay-51HT3flLW1s{bW3SH4O&Za&#^i zH4GkUI%oaBLZw9@$lL1NCUebI41~% zo$f`)`3z6+{o~I{m?UB6>Gz}Q(@C_NVn?+b)ha!Fp0af1wmUFzWWst2tOt=|Y{uZT zK-ll0<@=}7Jw zxJ8lr3p>_|q6D)PG;2EK!9s3YbROMwpwCO3&) => - Buffer.from(JSON.stringify(input)).toString('base64'); +export const encode = ( + input: ExplicitInputWithAttributes | Partial | Readonly +) => Buffer.from(JSON.stringify(input)).toString('base64'); export const decode = (serializedInput: string) => JSON.parse(Buffer.from(serializedInput, 'base64').toString()); diff --git a/x-pack/plugins/canvas/public/components/embeddable_flyout/flyout.component.tsx b/x-pack/plugins/canvas/public/components/embeddable_flyout/flyout.component.tsx index d93a9fb49d359..87a575ee44b12 100644 --- a/x-pack/plugins/canvas/public/components/embeddable_flyout/flyout.component.tsx +++ b/x-pack/plugins/canvas/public/components/embeddable_flyout/flyout.component.tsx @@ -61,7 +61,11 @@ export const AddEmbeddableFlyout: FC = ({ const embeddableFactories = getEmbeddableFactories(); const availableSavedObjects = Array.from(embeddableFactories) - .filter((factory) => isByValueEnabled || availableEmbeddables.includes(factory.type)) + .filter( + (factory) => + factory.type !== 'links' && // Links panels only exist on Dashboards + (isByValueEnabled || availableEmbeddables.includes(factory.type)) + ) .map((factory) => factory.savedObjectMetaData) .filter>(function ( maybeSavedObjectMetaData diff --git a/x-pack/plugins/canvas/public/components/workpad_header/editor_menu/editor_menu.tsx b/x-pack/plugins/canvas/public/components/workpad_header/editor_menu/editor_menu.tsx index 2258b63a49377..dbcd3b9cd2786 100644 --- a/x-pack/plugins/canvas/public/components/workpad_header/editor_menu/editor_menu.tsx +++ b/x-pack/plugins/canvas/public/components/workpad_header/editor_menu/editor_menu.tsx @@ -142,7 +142,7 @@ export const EditorMenu: FC = ({ addElement }) => { isEditable && !isContainerType && canCreateNew() && - !['visualization', 'ml'].some((factoryType) => { + !['visualization', 'ml', 'links'].some((factoryType) => { return type.includes(factoryType); }) ) diff --git a/x-pack/plugins/dashboard_enhanced/common/drilldowns/dashboard_drilldown/dashboard_drilldown_persistable_state.ts b/x-pack/plugins/dashboard_enhanced/common/drilldowns/dashboard_drilldown/dashboard_drilldown_persistable_state.ts index fe71ebd65ea8f..aff6c1cabedca 100644 --- a/x-pack/plugins/dashboard_enhanced/common/drilldowns/dashboard_drilldown/dashboard_drilldown_persistable_state.ts +++ b/x-pack/plugins/dashboard_enhanced/common/drilldowns/dashboard_drilldown/dashboard_drilldown_persistable_state.ts @@ -8,7 +8,7 @@ import { SavedObjectReference } from '@kbn/core/types'; import { PersistableStateService } from '@kbn/kibana-utils-plugin/common'; import { SerializedAction, SerializedEvent } from '@kbn/ui-actions-enhanced-plugin/common'; -import { DrilldownConfig } from './types'; +import { DashboardDrilldownConfig } from './types'; type DashboardDrilldownPersistableState = PersistableStateService; @@ -34,7 +34,7 @@ export const createInject = ({ drilldownId: string; }): DashboardDrilldownPersistableState['inject'] => { return (state: SerializedEvent, references: SavedObjectReference[]) => { - const action = state.action as SerializedAction; + const action = state.action as SerializedAction; const refName = generateRefName(state, drilldownId); const ref = references.find((r) => r.name === refName); if (!ref) return state; @@ -49,7 +49,7 @@ export const createExtract = ({ drilldownId: string; }): DashboardDrilldownPersistableState['extract'] => { return (state: SerializedEvent) => { - const action = state.action as SerializedAction; + const action = state.action as SerializedAction; const references: SavedObjectReference[] = action.config.dashboardId ? [ { diff --git a/x-pack/plugins/dashboard_enhanced/common/drilldowns/dashboard_drilldown/index.ts b/x-pack/plugins/dashboard_enhanced/common/drilldowns/dashboard_drilldown/index.ts index fde3b5b06de2d..ab8d69d88475e 100644 --- a/x-pack/plugins/dashboard_enhanced/common/drilldowns/dashboard_drilldown/index.ts +++ b/x-pack/plugins/dashboard_enhanced/common/drilldowns/dashboard_drilldown/index.ts @@ -7,4 +7,4 @@ export { createExtract, createInject } from './dashboard_drilldown_persistable_state'; export { EMBEDDABLE_TO_DASHBOARD_DRILLDOWN } from './constants'; -export type { DrilldownConfig } from './types'; +export type { DashboardDrilldownConfig } from './types'; diff --git a/x-pack/plugins/dashboard_enhanced/common/drilldowns/dashboard_drilldown/types.ts b/x-pack/plugins/dashboard_enhanced/common/drilldowns/dashboard_drilldown/types.ts index ee68681ccc8f5..cf479aedcf8e5 100644 --- a/x-pack/plugins/dashboard_enhanced/common/drilldowns/dashboard_drilldown/types.ts +++ b/x-pack/plugins/dashboard_enhanced/common/drilldowns/dashboard_drilldown/types.ts @@ -5,10 +5,8 @@ * 2.0. */ -// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -export type DrilldownConfig = { +import { type DashboardDrilldownOptions } from '@kbn/presentation-util-plugin/public'; + +export type DashboardDrilldownConfig = { dashboardId?: string; - useCurrentFilters: boolean; - useCurrentDateRange: boolean; - openInNewTab: boolean; -}; +} & DashboardDrilldownOptions; diff --git a/x-pack/plugins/dashboard_enhanced/kibana.jsonc b/x-pack/plugins/dashboard_enhanced/kibana.jsonc index 30b70b7c9067f..88bb64bb00503 100644 --- a/x-pack/plugins/dashboard_enhanced/kibana.jsonc +++ b/x-pack/plugins/dashboard_enhanced/kibana.jsonc @@ -6,10 +6,7 @@ "id": "dashboardEnhanced", "server": true, "browser": true, - "configPath": [ - "xpack", - "dashboardEnhanced" - ], + "configPath": ["xpack", "dashboardEnhanced"], "requiredPlugins": [ "dashboard", "data", @@ -23,7 +20,8 @@ "embeddableEnhanced", "kibanaReact", "kibanaUtils", - "imageEmbeddable" + "imageEmbeddable", + "presentationUtil" ] } } diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/abstract_dashboard_drilldown.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/abstract_dashboard_drilldown.tsx index 9305d515bdcef..a7d887e690f3a 100644 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/abstract_dashboard_drilldown.tsx +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/abstract_dashboard_drilldown.tsx @@ -15,11 +15,11 @@ import { UiActionsEnhancedDrilldownDefinition as Drilldown, } from '@kbn/ui-actions-enhanced-plugin/public'; import { CollectConfigProps, StartServicesGetter } from '@kbn/kibana-utils-plugin/public'; -import { DrilldownConfig } from '../../../../common/drilldowns/dashboard_drilldown/types'; +import { DEFAULT_DASHBOARD_DRILLDOWN_OPTIONS } from '@kbn/presentation-util-plugin/public'; + import { CollectConfigContainer } from './components'; import { txtGoToDashboard } from './i18n'; import { Config } from './types'; - export interface Params { start: StartServicesGetter<{ uiActionsEnhanced: AdvancedUiActionsStart; @@ -56,15 +56,11 @@ export abstract class AbstractDashboardDrilldown >; - public readonly CollectConfig: React.FC< - CollectConfigProps - >; + public readonly CollectConfig: React.FC>; public readonly createConfig = () => ({ dashboardId: '', - useCurrentFilters: true, - useCurrentDateRange: true, - openInNewTab: false, + ...DEFAULT_DASHBOARD_DRILLDOWN_OPTIONS, }); public readonly isConfigValid = (config: Config): config is Config => { diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/components/collect_config_container.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/components/collect_config_container.tsx index 4a9818b3f23f5..0829bf1710719 100644 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/components/collect_config_container.tsx +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/components/collect_config_container.tsx @@ -80,11 +80,7 @@ export class CollectConfigContainer extends React.Component< return ( { @@ -94,24 +90,10 @@ export class CollectConfigContainer extends React.Component< } }} onSearchChange={this.debouncedLoadDashboards} - onCurrentFiltersToggle={() => - onConfig({ - ...config, - useCurrentFilters: !config.useCurrentFilters, - }) - } - onKeepRangeToggle={() => - onConfig({ - ...config, - useCurrentDateRange: !config.useCurrentDateRange, - }) - } - onOpenInNewTab={() => - onConfig({ - ...config, - openInNewTab: !config.openInNewTab, - }) - } + config={config} + onConfigChange={(changes: Partial) => { + onConfig({ ...config, ...changes }); + }} /> ); } diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/components/dashboard_drilldown_config/dashboard_drilldown_config.story.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/components/dashboard_drilldown_config/dashboard_drilldown_config.story.tsx deleted file mode 100644 index 0c549f76b4ff4..0000000000000 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/components/dashboard_drilldown_config/dashboard_drilldown_config.story.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. - */ - -/* eslint-disable no-console */ - -import * as React from 'react'; -import { storiesOf } from '@storybook/react'; -import { DashboardDrilldownConfig } from './dashboard_drilldown_config'; - -export const dashboards = [ - { value: 'dashboard1', label: 'Dashboard 1' }, - { value: 'dashboard2', label: 'Dashboard 2' }, - { value: 'dashboard3', label: 'Dashboard 3' }, -]; - -const InteractiveDemo: React.FC = () => { - const [activeDashboardId, setActiveDashboardId] = React.useState('dashboard1'); - const [currentFilters, setCurrentFilters] = React.useState(false); - const [keepRange, setKeepRange] = React.useState(false); - - return ( - setActiveDashboardId(id)} - onCurrentFiltersToggle={() => setCurrentFilters((old) => !old)} - onKeepRangeToggle={() => setKeepRange((old) => !old)} - onSearchChange={() => {}} - isLoading={false} - /> - ); -}; - -storiesOf( - 'services/drilldowns/dashboard_to_dashboard_drilldown/components/dashboard_drilldown_config', - module -) - .add('default', () => ( - console.log('onDashboardSelect', e)} - onSearchChange={() => {}} - isLoading={false} - /> - )) - .add('with switches', () => ( - console.log('onDashboardSelect', e)} - onCurrentFiltersToggle={() => console.log('onCurrentFiltersToggle')} - onKeepRangeToggle={() => console.log('onKeepRangeToggle')} - onSearchChange={() => {}} - isLoading={false} - /> - )) - .add('interactive demo', () => ); diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/components/dashboard_drilldown_config/dashboard_drilldown_config.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/components/dashboard_drilldown_config/dashboard_drilldown_config.tsx index d6e00f7de0edb..78ab1db7212b2 100644 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/components/dashboard_drilldown_config/dashboard_drilldown_config.tsx +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/components/dashboard_drilldown_config/dashboard_drilldown_config.tsx @@ -6,44 +6,37 @@ */ import React from 'react'; -import { EuiFormRow, EuiSwitch, EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; +import { EuiFormRow, EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; import { - txtChooseDestinationDashboard, - txtUseCurrentFilters, - txtUseCurrentDateRange, - txtOpenInNewTab, -} from './i18n'; + withSuspense, + DashboardDrilldownOptionsComponent, +} from '@kbn/presentation-util-plugin/public'; + +import { txtChooseDestinationDashboard } from './i18n'; +import { Config as DrilldownConfig } from '../../types'; + +const DashboardDrilldownOptions = withSuspense(DashboardDrilldownOptionsComponent, null); export interface DashboardDrilldownConfigProps { - activeDashboardId?: string; dashboards: Array>; - currentFilters?: boolean; - keepRange?: boolean; - openInNewTab?: boolean; onDashboardSelect: (dashboardId: string) => void; - onCurrentFiltersToggle?: () => void; - onKeepRangeToggle?: () => void; - onOpenInNewTab?: () => void; onSearchChange: (searchString: string) => void; isLoading: boolean; error?: string; + config: DrilldownConfig; + onConfigChange: (changes: Partial) => void; } export const DashboardDrilldownConfig: React.FC = ({ - activeDashboardId, dashboards, - currentFilters, - keepRange, - openInNewTab, onDashboardSelect, - onCurrentFiltersToggle, - onKeepRangeToggle, - onOpenInNewTab, onSearchChange, isLoading, error, + config, + onConfigChange, }: DashboardDrilldownConfigProps) => { - const selectedTitle = dashboards.find((item) => item.value === activeDashboardId)?.label || ''; + const selectedTitle = dashboards.find((item) => item.value === config.dashboardId)?.label || ''; return ( <> @@ -51,7 +44,7 @@ export const DashboardDrilldownConfig: React.FC = async selectedOptions={ - activeDashboardId ? [{ label: selectedTitle, value: activeDashboardId }] : [] + config.dashboardId ? [{ label: selectedTitle, value: config.dashboardId }] : [] } options={dashboards} onChange={([{ value = '' } = { value: '' }]) => onDashboardSelect(value)} @@ -63,36 +56,7 @@ export const DashboardDrilldownConfig: React.FC = isInvalid={!!error} /> - {!!onCurrentFiltersToggle && ( - - - - )} - {!!onKeepRangeToggle && ( - - - - )} - {!!onOpenInNewTab && ( - - - - )} + ); }; diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/components/dashboard_drilldown_config/i18n.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/components/dashboard_drilldown_config/i18n.ts index cf822a6a72d7a..5ee0794076348 100644 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/components/dashboard_drilldown_config/i18n.ts +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/components/dashboard_drilldown_config/i18n.ts @@ -13,24 +13,3 @@ export const txtChooseDestinationDashboard = i18n.translate( defaultMessage: 'Choose destination dashboard', } ); - -export const txtUseCurrentFilters = i18n.translate( - 'xpack.dashboard.components.DashboardDrilldownConfig.useCurrentFilters', - { - defaultMessage: 'Use filters and query from origin dashboard', - } -); - -export const txtUseCurrentDateRange = i18n.translate( - 'xpack.dashboard.components.DashboardDrilldownConfig.useCurrentDateRange', - { - defaultMessage: 'Use date range from origin dashboard', - } -); - -export const txtOpenInNewTab = i18n.translate( - 'xpack.dashboard.components.DashboardDrilldownConfig.openInNewTab', - { - defaultMessage: 'Open dashboard in new tab', - } -); diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/types.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/types.ts index 599ecce6217e2..46181deabb8b5 100644 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/types.ts +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/types.ts @@ -6,8 +6,8 @@ */ import { UiActionsEnhancedBaseActionFactoryContext } from '@kbn/ui-actions-enhanced-plugin/public'; -import { DrilldownConfig } from '../../../../common'; +import { DashboardDrilldownConfig } from '../../../../common'; -export type Config = DrilldownConfig; +export type Config = DashboardDrilldownConfig; export type FactoryContext = UiActionsEnhancedBaseActionFactoryContext; diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/embeddable_to_dashboard_drilldown/embeddable_to_dashboard_drilldown.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/embeddable_to_dashboard_drilldown/embeddable_to_dashboard_drilldown.tsx index 468cbaf9940a9..9a984af52d21c 100644 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/embeddable_to_dashboard_drilldown/embeddable_to_dashboard_drilldown.tsx +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/embeddable_to_dashboard_drilldown/embeddable_to_dashboard_drilldown.tsx @@ -4,29 +4,25 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { type Filter, isFilterPinned, Query, TimeRange } from '@kbn/es-query'; +import { extractTimeRange, isFilterPinned } from '@kbn/es-query'; import type { KibanaLocation } from '@kbn/share-plugin/public'; -import { DashboardAppLocatorParams, cleanEmptyKeys } from '@kbn/dashboard-plugin/public'; +import { + cleanEmptyKeys, + DashboardAppLocatorParams, + getEmbeddableParams, +} from '@kbn/dashboard-plugin/public'; import { setStateToKbnUrl } from '@kbn/kibana-utils-plugin/public'; -import { APPLY_FILTER_TRIGGER, isQuery, isTimeRange } from '@kbn/data-plugin/public'; -import { extractTimeRange } from '@kbn/es-query'; +import { APPLY_FILTER_TRIGGER } from '@kbn/data-plugin/public'; import { ApplyGlobalFilterActionContext } from '@kbn/unified-search-plugin/public'; -import { IEmbeddable, EmbeddableInput } from '@kbn/embeddable-plugin/public'; import { EnhancedEmbeddableContext } from '@kbn/embeddable-enhanced-plugin/public'; import { IMAGE_CLICK_TRIGGER } from '@kbn/image-embeddable-plugin/public'; import { AbstractDashboardDrilldown, AbstractDashboardDrilldownParams, - AbstractDashboardDrilldownConfig as Config, } from '../abstract_dashboard_drilldown'; import { EMBEDDABLE_TO_DASHBOARD_DRILLDOWN } from './constants'; import { createExtract, createInject } from '../../../../common'; - -interface EmbeddableQueryInput extends EmbeddableInput { - query?: Query; - filters?: Filter[]; - timeRange?: TimeRange; -} +import { AbstractDashboardDrilldownConfig as Config } from '../abstract_dashboard_drilldown'; type Context = EnhancedEmbeddableContext & ApplyGlobalFilterActionContext; export type Params = AbstractDashboardDrilldownParams; @@ -48,28 +44,16 @@ export class EmbeddableToDashboardDrilldown extends AbstractDashboardDrilldown { - const params: DashboardAppLocatorParams = { - dashboardId: config.dashboardId, - }; + let params: DashboardAppLocatorParams = { dashboardId: config.dashboardId }; if (context.embeddable) { - const embeddable = context.embeddable as IEmbeddable; - const input = embeddable.getInput(); - if (isQuery(input.query) && config.useCurrentFilters) params.query = input.query; - - // if useCurrentDashboardDataRange is enabled, then preserve current time range - // if undefined is passed, then destination dashboard will figure out time range itself - // for brush event this time range would be overwritten - if (isTimeRange(input.timeRange) && config.useCurrentDateRange) - params.timeRange = input.timeRange; - - // if useCurrentDashboardFilters enabled, then preserve all the filters (pinned, unpinned, and from controls) - // otherwise preserve only pinned - params.filters = config.useCurrentFilters - ? input.filters - : input.filters?.filter((f) => isFilterPinned(f)); + params = { + ...params, + ...getEmbeddableParams(context.embeddable, config), + }; } + /** Get event params */ const { restOfFilters: filtersFromEvent, timeRange: timeRangeFromEvent } = extractTimeRange( context.filters, context.timeFieldName diff --git a/x-pack/plugins/dashboard_enhanced/tsconfig.json b/x-pack/plugins/dashboard_enhanced/tsconfig.json index dd5a78b87dc4f..4c08a46b6e2d6 100644 --- a/x-pack/plugins/dashboard_enhanced/tsconfig.json +++ b/x-pack/plugins/dashboard_enhanced/tsconfig.json @@ -1,13 +1,9 @@ { "extends": "../../../tsconfig.base.json", "compilerOptions": { - "outDir": "target/types", + "outDir": "target/types" }, - "include": [ - "common/**/*", - "public/**/*", - "server/**/*", - ], + "include": ["common/**/*", "public/**/*", "server/**/*"], "kbn_references": [ "@kbn/kibana-react-plugin", "@kbn/kibana-utils-plugin", @@ -22,9 +18,8 @@ "@kbn/es-query", "@kbn/unified-search-plugin", "@kbn/ui-actions-plugin", - "@kbn/image-embeddable-plugin" + "@kbn/image-embeddable-plugin", + "@kbn/presentation-util-plugin" ], - "exclude": [ - "target/**/*", - ] + "exclude": ["target/**/*"] } diff --git a/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.test.ts b/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.test.ts index 9cfeea206300e..500723a6786b9 100644 --- a/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.test.ts +++ b/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.test.ts @@ -107,6 +107,7 @@ describe('UrlDrilldown', () => { template: `https://elasti.co/?{{event.value}}`, }, openInNewTab: false, + encodeUrl: true, }; const context: ActionContext = { @@ -124,6 +125,7 @@ describe('UrlDrilldown', () => { template: `https://elasti.co/?{{event.value}}&{{rison context.panel.query}}`, }, openInNewTab: false, + encodeUrl: true, }; const context: ActionContext = { @@ -143,6 +145,7 @@ describe('UrlDrilldown', () => { template: `https://elasti.co/?{{event.value}}&{{rison context.panel.somethingFake}}`, }, openInNewTab: false, + encodeUrl: true, }; const context: ActionContext = { @@ -163,6 +166,7 @@ describe('UrlDrilldown', () => { template: `https://elasti.co/?{{event.value}}&{{rison context.panel.query}}`, }, openInNewTab: false, + encodeUrl: true, }; const context: ActionContext = { @@ -191,6 +195,7 @@ describe('UrlDrilldown', () => { template: `https://elasti.co/?{{event.value}}&{{rison context.panel.query}}`, }, openInNewTab: false, + encodeUrl: true, }; const context: ActionContext = { @@ -213,6 +218,7 @@ describe('UrlDrilldown', () => { template: `https://elasti.co/?{{event.value}}&{{rison context.panel.invalid}}`, }, openInNewTab: false, + encodeUrl: true, }; const context: ActionContext = { @@ -235,6 +241,7 @@ describe('UrlDrilldown', () => { template: `https://elasti.co/?{{event.value}}&{{rison context.panel.query}}`, }, openInNewTab: false, + encodeUrl: true, }; const context: ActionContext = { @@ -503,6 +510,7 @@ describe('encoding', () => { template: 'https://elastic.co?foo=head%26shoulders', }, openInNewTab: false, + encodeUrl: true, }; const url = await urlDrilldown.getHref(config, context); diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index a9a7ce2caa70c..59dbc72ac62b0 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -39698,9 +39698,6 @@ "xpack.cloudLinks.setupGuide": "Guides de configuration", "xpack.cloudLinks.userMenuLinks.profileLinkText": "Modifier le profil", "xpack.dashboard.components.DashboardDrilldownConfig.chooseDestinationDashboard": "Choisir le tableau de bord de destination", - "xpack.dashboard.components.DashboardDrilldownConfig.openInNewTab": "Ouvrir le tableau de bord dans un nouvel onglet", - "xpack.dashboard.components.DashboardDrilldownConfig.useCurrentDateRange": "Utiliser la plage de dates du tableau de bord d'origine", - "xpack.dashboard.components.DashboardDrilldownConfig.useCurrentFilters": "Utiliser les filtres et la requête du tableau de bord d'origine", "xpack.dashboard.drilldown.errorDestinationDashboardIsMissing": "Le tableau de bord de destination (\"{dashboardId}\") n'existe plus. Choisissez un autre tableau de bord.", "xpack.dashboard.drilldown.goToDashboard": "Accéder au tableau de bord", "xpack.dashboard.FlyoutCreateDrilldownAction.displayName": "Créer une recherche", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 42eb7d822fdbd..8174504c2c605 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -39689,9 +39689,6 @@ "xpack.cloudLinks.setupGuide": "セットアップガイド", "xpack.cloudLinks.userMenuLinks.profileLinkText": "プロフィールを編集", "xpack.dashboard.components.DashboardDrilldownConfig.chooseDestinationDashboard": "対象ダッシュボードを選択", - "xpack.dashboard.components.DashboardDrilldownConfig.openInNewTab": "新しいタブでダッシュボードを開く", - "xpack.dashboard.components.DashboardDrilldownConfig.useCurrentDateRange": "元のダッシュボードから日付範囲を使用", - "xpack.dashboard.components.DashboardDrilldownConfig.useCurrentFilters": "元のダッシュボードからフィルターとクエリを使用", "xpack.dashboard.drilldown.errorDestinationDashboardIsMissing": "対象ダッシュボード('{dashboardId}')は存在しません。別のダッシュボードを選択してください。", "xpack.dashboard.drilldown.goToDashboard": "ダッシュボードに移動", "xpack.dashboard.FlyoutCreateDrilldownAction.displayName": "ドリルダウンを作成", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 61fcbaa2bbbd0..44791093b7bd5 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -39683,9 +39683,6 @@ "xpack.cloudLinks.setupGuide": "设置指南", "xpack.cloudLinks.userMenuLinks.profileLinkText": "编辑配置文件", "xpack.dashboard.components.DashboardDrilldownConfig.chooseDestinationDashboard": "选择目标仪表板", - "xpack.dashboard.components.DashboardDrilldownConfig.openInNewTab": "在新选项卡中打开仪表板", - "xpack.dashboard.components.DashboardDrilldownConfig.useCurrentDateRange": "使用源仪表板的日期范围", - "xpack.dashboard.components.DashboardDrilldownConfig.useCurrentFilters": "使用源仪表板的筛选和查询", "xpack.dashboard.drilldown.errorDestinationDashboardIsMissing": "目标仪表板(“{dashboardId}”)已不存在。选择其他仪表板。", "xpack.dashboard.drilldown.goToDashboard": "前往仪表板", "xpack.dashboard.FlyoutCreateDrilldownAction.displayName": "创建向下钻取", diff --git a/x-pack/test_serverless/functional/test_suites/search/dashboards/exports/serverless_dashboard_8_11.ndjson b/x-pack/test_serverless/functional/test_suites/search/dashboards/exports/serverless_dashboard_8_11.ndjson index 87cb044789f7e..f68023c5d4538 100644 --- a/x-pack/test_serverless/functional/test_suites/search/dashboards/exports/serverless_dashboard_8_11.ndjson +++ b/x-pack/test_serverless/functional/test_suites/search/dashboards/exports/serverless_dashboard_8_11.ndjson @@ -1,5 +1,5 @@ -{"attributes":{"fields":"[{\"name\":\"@message\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"@message.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"@tags\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"@tags.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"@timestamp\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"_id\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_index\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_score\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_source\",\"type\":\"_source\",\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"agent\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"agent.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"bytes\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"clientip\",\"type\":\"ip\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"extension\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"extension.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.coordinates\",\"type\":\"geo_point\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.dest\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.src\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.srcdest\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"headings\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"headings.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"host\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"host.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"id\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"index\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"index.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"ip\",\"type\":\"ip\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"links\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"links.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"machine.os\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"machine.os.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"machine.ram\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"memory\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"meta.char\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"meta.related\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"meta.user.firstname\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"meta.user.lastname\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"phpmemory\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"referer\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:modified_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:published_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:section\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.article:section.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:tag\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.article:tag.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:description\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:description.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:image\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:image.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:image:height\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:image:height.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:image:width\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:image:width.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:site_name\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:site_name.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:title\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:title.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:type.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:url.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:card\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:card.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:description\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:description.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:image\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:image.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:site\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:site.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:title\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:title.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.url.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"request\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"request.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"response\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"response.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"spaces\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"spaces.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"url.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"utc_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"xss\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"xss.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true}]","timeFieldName":"@timestamp","title":"logstash-*"},"coreMigrationVersion":"8.8.0","created_at":"2023-08-25T14:45:18.313Z","id":"logstash-*","managed":false,"references":[],"type":"index-pattern","typeMigrationVersion":"7.11.0","updated_at":"2023-08-25T14:45:18.313Z","version":"WzE0MSwxXQ=="} -{"attributes":{"state":{"datasourceStates":{"formBased":{"layers":{"4ba1a1be-6e67-434b-b3a0-f30db8ea5395":{"columnOrder":["7a5d833b-ca6f-4e48-a924-d2a28d365dc3","3cf18f28-3495-4d45-a55f-d97f88022099","3dc0bd55-2087-4e60-aea2-f9910714f7db"],"columns":{"3cf18f28-3495-4d45-a55f-d97f88022099":{"dataType":"date","isBucketed":true,"label":"@timestamp","operationType":"date_histogram","params":{"includeEmptyRows":false,"interval":"auto"},"scale":"interval","sourceField":"@timestamp"},"3dc0bd55-2087-4e60-aea2-f9910714f7db":{"dataType":"number","isBucketed":false,"label":"Average of bytes","operationType":"average","scale":"ratio","sourceField":"bytes"},"7a5d833b-ca6f-4e48-a924-d2a28d365dc3":{"dataType":"ip","isBucketed":true,"label":"Top values of ip","operationType":"terms","params":{"orderBy":{"columnId":"3dc0bd55-2087-4e60-aea2-f9910714f7db","type":"column"},"orderDirection":"desc","size":3},"scale":"ordinal","sourceField":"ip"}}}}}},"filters":[],"query":{"language":"kuery","query":""},"visualization":{"layers":[{"accessors":["3dc0bd55-2087-4e60-aea2-f9910714f7db"],"layerId":"4ba1a1be-6e67-434b-b3a0-f30db8ea5395","seriesType":"bar_stacked","splitAccessor":"7a5d833b-ca6f-4e48-a924-d2a28d365dc3","xAccessor":"3cf18f28-3495-4d45-a55f-d97f88022099"}],"legend":{"isVisible":true,"legendSize":"auto","position":"right"},"preferredSeriesType":"bar_stacked"}},"title":"lnsXYvis","visualizationType":"lnsXY"},"coreMigrationVersion":"8.8.0","created_at":"2023-08-25T14:45:18.313Z","id":"76fc4200-cf44-11e9-b933-fd84270f3ac2","managed":false,"references":[{"id":"logstash-*","name":"indexpattern-datasource-current-indexpattern","type":"index-pattern"},{"id":"logstash-*","name":"indexpattern-datasource-layer-4ba1a1be-6e67-434b-b3a0-f30db8ea5395","type":"index-pattern"}],"type":"lens","typeMigrationVersion":"8.9.0","updated_at":"2023-08-25T14:45:18.313Z","version":"WzE0MywxXQ=="} -{"attributes":{"state":{"datasourceStates":{"formBased":{"layers":{"c61a8afb-a185-4fae-a064-fb3846f6c451":{"columnOrder":["2cd09808-3915-49f4-b3b0-82767eba23f7"],"columns":{"2cd09808-3915-49f4-b3b0-82767eba23f7":{"dataType":"number","isBucketed":false,"label":"Maximum of bytes","operationType":"max","scale":"ratio","sourceField":"bytes"}}}}}},"filters":[],"query":{"language":"kuery","query":""},"visualization":{"accessor":"2cd09808-3915-49f4-b3b0-82767eba23f7","isHorizontal":false,"layerId":"c61a8afb-a185-4fae-a064-fb3846f6c451","layerType":"data","layers":[{"accessors":["d3e62a7a-c259-4fff-a2fc-eebf20b7008a","26ef70a9-c837-444c-886e-6bd905ee7335"],"layerId":"c61a8afb-a185-4fae-a064-fb3846f6c451","layerType":"data","seriesType":"area","splitAccessor":"54cd64ed-2a44-4591-af84-b2624504569a","xAccessor":"d6e40cea-6299-43b4-9c9d-b4ee305a2ce8"}],"legend":{"isVisible":true,"position":"right"},"preferredSeriesType":"area","size":"xl","textAlign":"center","titlePosition":"bottom"}},"title":"Artistpreviouslyknownaslens","visualizationType":"lnsLegacyMetric"},"coreMigrationVersion":"8.8.0","created_at":"2023-08-25T14:45:18.313Z","id":"76fc4200-cf44-11e9-b933-fd84270f3ac1","managed":false,"references":[{"id":"logstash-*","name":"indexpattern-datasource-current-indexpattern","type":"index-pattern"},{"id":"logstash-*","name":"indexpattern-datasource-layer-c61a8afb-a185-4fae-a064-fb3846f6c451","type":"index-pattern"}],"type":"lens","typeMigrationVersion":"8.9.0","updated_at":"2023-08-25T14:45:18.313Z","version":"WzE0MiwxXQ=="} -{"attributes":{"description":"","kibanaSavedObjectMeta":{"searchSourceJSON":"{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[{\"meta\":{\"disabled\":false,\"negate\":true,\"alias\":null,\"key\":\"agent.raw\",\"field\":\"agent.raw\",\"params\":{\"query\":\"Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)\"},\"type\":\"phrase\",\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index\"},\"query\":{\"match_phrase\":{\"agent.raw\":\"Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)\"}},\"$state\":{\"store\":\"appState\"}}]}"},"optionsJSON":"{\"useMargins\":true,\"syncColors\":false,\"syncCursor\":true,\"syncTooltips\":false,\"hidePanelTitles\":false}","panelsJSON":"[{\"version\":\"8.11.0\",\"type\":\"lens\",\"gridData\":{\"x\":0,\"y\":0,\"w\":12,\"h\":13,\"i\":\"5b087cde-634a-4815-9093-71891a900380\"},\"panelIndex\":\"5b087cde-634a-4815-9093-71891a900380\",\"embeddableConfig\":{\"attributes\":{\"title\":\"\",\"description\":\"\",\"visualizationType\":\"lnsPie\",\"type\":\"lens\",\"references\":[{\"type\":\"index-pattern\",\"id\":\"logstash-*\",\"name\":\"indexpattern-datasource-layer-36023ac0-43e8-41de-aee2-ebabf1b4fb65\"}],\"state\":{\"visualization\":{\"shape\":\"donut\",\"layers\":[{\"layerId\":\"36023ac0-43e8-41de-aee2-ebabf1b4fb65\",\"primaryGroups\":[\"fbf03774-001d-4032-a808-004140e94918\"],\"metrics\":[\"65a386b0-e74e-4076-a419-ee1d3ed7ce87\"],\"numberDisplay\":\"percent\",\"categoryDisplay\":\"default\",\"legendDisplay\":\"default\",\"nestedLegend\":false,\"layerType\":\"data\"}]},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[],\"datasourceStates\":{\"formBased\":{\"layers\":{\"36023ac0-43e8-41de-aee2-ebabf1b4fb65\":{\"columns\":{\"fbf03774-001d-4032-a808-004140e94918\":{\"label\":\"Top 3 values of ip\",\"dataType\":\"ip\",\"operationType\":\"terms\",\"scale\":\"ordinal\",\"sourceField\":\"ip\",\"isBucketed\":true,\"params\":{\"size\":3,\"orderBy\":{\"type\":\"column\",\"columnId\":\"65a386b0-e74e-4076-a419-ee1d3ed7ce87\"},\"orderDirection\":\"desc\",\"otherBucket\":true,\"missingBucket\":false,\"parentFormat\":{\"id\":\"terms\"},\"include\":[],\"exclude\":[],\"includeIsRegex\":false,\"excludeIsRegex\":false}},\"65a386b0-e74e-4076-a419-ee1d3ed7ce87\":{\"label\":\"Average of bytes\",\"dataType\":\"number\",\"operationType\":\"average\",\"sourceField\":\"bytes\",\"isBucketed\":false,\"scale\":\"ratio\",\"params\":{\"emptyAsNull\":true}}},\"columnOrder\":[\"fbf03774-001d-4032-a808-004140e94918\",\"65a386b0-e74e-4076-a419-ee1d3ed7ce87\"],\"sampling\":1,\"ignoreGlobalFilters\":false,\"incompleteColumns\":{}}}},\"indexpattern\":{\"layers\":{}},\"textBased\":{\"layers\":{}}},\"internalReferences\":[],\"adHocDataViews\":{}}},\"enhancements\":{},\"hidePanelTitles\":false},\"title\":\"Custom Title\"},{\"version\":\"8.11.0\",\"type\":\"lens\",\"gridData\":{\"x\":12,\"y\":1,\"w\":36,\"h\":13,\"i\":\"ee9dceec-afc3-4258-9998-44a00c2b36fc\"},\"panelIndex\":\"ee9dceec-afc3-4258-9998-44a00c2b36fc\",\"embeddableConfig\":{\"hidePanelTitles\":false,\"description\":\"Wow what a neat description\",\"enhancements\":{}},\"title\":\"Custom Title on a Library Item\",\"panelRefName\":\"panel_ee9dceec-afc3-4258-9998-44a00c2b36fc\"},{\"version\":\"8.11.0\",\"type\":\"map\",\"gridData\":{\"x\":0,\"y\":14,\"w\":38,\"h\":16,\"i\":\"7557df66-cfde-4401-a926-aff27d774715\"},\"panelIndex\":\"7557df66-cfde-4401-a926-aff27d774715\",\"embeddableConfig\":{\"attributes\":{\"title\":\"\",\"description\":\"\",\"layerListJSON\":\"[{\\\"locale\\\":\\\"autoselect\\\",\\\"sourceDescriptor\\\":{\\\"type\\\":\\\"EMS_TMS\\\",\\\"isAutoSelect\\\":true,\\\"lightModeDefault\\\":\\\"road_map_desaturated\\\"},\\\"id\\\":\\\"710998eb-fda1-462c-80f2-d498df132ebf\\\",\\\"label\\\":null,\\\"minZoom\\\":0,\\\"maxZoom\\\":24,\\\"alpha\\\":1,\\\"visible\\\":true,\\\"style\\\":{\\\"type\\\":\\\"EMS_VECTOR_TILE\\\",\\\"color\\\":\\\"\\\"},\\\"includeInFitToBounds\\\":true,\\\"type\\\":\\\"EMS_VECTOR_TILE\\\"},{\\\"sourceDescriptor\\\":{\\\"geoField\\\":\\\"geo.coordinates\\\",\\\"scalingType\\\":\\\"MVT\\\",\\\"id\\\":\\\"4ed5225d-d42d-40e3-9515-5a918208db27\\\",\\\"type\\\":\\\"ES_SEARCH\\\",\\\"applyGlobalQuery\\\":true,\\\"applyGlobalTime\\\":true,\\\"applyForceRefresh\\\":true,\\\"filterByMapBounds\\\":true,\\\"tooltipProperties\\\":[],\\\"sortField\\\":\\\"\\\",\\\"sortOrder\\\":\\\"desc\\\",\\\"topHitsGroupByTimeseries\\\":false,\\\"topHitsSplitField\\\":\\\"\\\",\\\"topHitsSize\\\":1,\\\"indexPatternRefName\\\":\\\"layer_1_source_index_pattern\\\"},\\\"id\\\":\\\"07facc2c-117d-4335-bd53-90e0ab36aa53\\\",\\\"label\\\":null,\\\"minZoom\\\":0,\\\"maxZoom\\\":24,\\\"alpha\\\":1,\\\"visible\\\":true,\\\"style\\\":{\\\"type\\\":\\\"VECTOR\\\",\\\"properties\\\":{\\\"icon\\\":{\\\"type\\\":\\\"STATIC\\\",\\\"options\\\":{\\\"value\\\":\\\"marker\\\"}},\\\"fillColor\\\":{\\\"type\\\":\\\"DYNAMIC\\\",\\\"options\\\":{\\\"color\\\":\\\"Blues\\\",\\\"colorCategory\\\":\\\"palette_0\\\",\\\"field\\\":{\\\"name\\\":\\\"extension.raw\\\",\\\"origin\\\":\\\"source\\\"},\\\"fieldMetaOptions\\\":{\\\"isEnabled\\\":true,\\\"sigma\\\":3},\\\"type\\\":\\\"CATEGORICAL\\\",\\\"otherCategoryColor\\\":\\\"#000000\\\"}},\\\"lineColor\\\":{\\\"type\\\":\\\"STATIC\\\",\\\"options\\\":{\\\"color\\\":\\\"#41937c\\\"}},\\\"lineWidth\\\":{\\\"type\\\":\\\"STATIC\\\",\\\"options\\\":{\\\"size\\\":0}},\\\"iconSize\\\":{\\\"type\\\":\\\"STATIC\\\",\\\"options\\\":{\\\"size\\\":6}},\\\"iconOrientation\\\":{\\\"type\\\":\\\"STATIC\\\",\\\"options\\\":{\\\"orientation\\\":0}},\\\"labelText\\\":{\\\"type\\\":\\\"STATIC\\\",\\\"options\\\":{\\\"value\\\":\\\"\\\"}},\\\"labelColor\\\":{\\\"type\\\":\\\"STATIC\\\",\\\"options\\\":{\\\"color\\\":\\\"#000000\\\"}},\\\"labelSize\\\":{\\\"type\\\":\\\"STATIC\\\",\\\"options\\\":{\\\"size\\\":14}},\\\"labelZoomRange\\\":{\\\"options\\\":{\\\"useLayerZoomRange\\\":true,\\\"minZoom\\\":0,\\\"maxZoom\\\":24}},\\\"labelBorderColor\\\":{\\\"type\\\":\\\"STATIC\\\",\\\"options\\\":{\\\"color\\\":\\\"#FFFFFF\\\"}},\\\"symbolizeAs\\\":{\\\"options\\\":{\\\"value\\\":\\\"circle\\\"}},\\\"labelBorderSize\\\":{\\\"options\\\":{\\\"size\\\":\\\"SMALL\\\"}},\\\"labelPosition\\\":{\\\"options\\\":{\\\"position\\\":\\\"CENTER\\\"}}},\\\"isTimeAware\\\":true},\\\"includeInFitToBounds\\\":true,\\\"type\\\":\\\"MVT_VECTOR\\\",\\\"joins\\\":[],\\\"disableTooltips\\\":false}]\",\"mapStateJSON\":\"{\\\"adHocDataViews\\\":[],\\\"zoom\\\":3.53,\\\"center\\\":{\\\"lon\\\":-98.19524,\\\"lat\\\":42.06188},\\\"timeFilters\\\":{\\\"from\\\":\\\"2015-09-19T06:31:44.000Z\\\",\\\"to\\\":\\\"2015-09-23T18:31:44.000Z\\\"},\\\"refreshConfig\\\":{\\\"isPaused\\\":true,\\\"interval\\\":60000},\\\"query\\\":{\\\"query\\\":\\\"\\\",\\\"language\\\":\\\"kuery\\\"},\\\"filters\\\":[],\\\"settings\\\":{\\\"autoFitToDataBounds\\\":false,\\\"backgroundColor\\\":\\\"#ffffff\\\",\\\"customIcons\\\":[],\\\"disableInteractive\\\":false,\\\"disableTooltipControl\\\":false,\\\"hideToolbarOverlay\\\":false,\\\"hideLayerControl\\\":false,\\\"hideViewControl\\\":false,\\\"initialLocation\\\":\\\"LAST_SAVED_LOCATION\\\",\\\"fixedLocation\\\":{\\\"lat\\\":0,\\\"lon\\\":0,\\\"zoom\\\":2},\\\"browserLocation\\\":{\\\"zoom\\\":2},\\\"keydownScrollZoom\\\":false,\\\"maxZoom\\\":24,\\\"minZoom\\\":0,\\\"showScaleControl\\\":false,\\\"showSpatialFilters\\\":true,\\\"showTimesliderToggleButton\\\":true,\\\"spatialFiltersAlpa\\\":0.3,\\\"spatialFiltersFillColor\\\":\\\"#DA8B45\\\",\\\"spatialFiltersLineColor\\\":\\\"#DA8B45\\\"}}\",\"uiStateJSON\":\"{\\\"isLayerTOCOpen\\\":true,\\\"openTOCDetails\\\":[\\\"07facc2c-117d-4335-bd53-90e0ab36aa53\\\"]}\"},\"mapCenter\":{\"lat\":42.37743,\"lon\":-101.55858,\"zoom\":3.53},\"mapBuffer\":{\"minLon\":-135,\"minLat\":21.94305,\"maxLon\":-45,\"maxLat\":55.77657},\"isLayerTOCOpen\":true,\"openTOCDetails\":[\"07facc2c-117d-4335-bd53-90e0ab36aa53\"],\"hiddenLayers\":[],\"enhancements\":{}}},{\"version\":\"8.11.0\",\"type\":\"lens\",\"gridData\":{\"x\":38,\"y\":20,\"w\":10,\"h\":9,\"i\":\"d3089be5-dff0-4bbe-9a36-76dd1dec98ef\"},\"panelIndex\":\"d3089be5-dff0-4bbe-9a36-76dd1dec98ef\",\"embeddableConfig\":{\"hidePanelTitles\":false,\"timeRange\":{\"from\":\"2015-09-21T06:31:44.000Z\",\"to\":\"2015-09-23T18:31:44.000Z\"},\"enhancements\":{}},\"panelRefName\":\"panel_d3089be5-dff0-4bbe-9a36-76dd1dec98ef\"},{\"version\":\"8.11.0\",\"type\":\"lens\",\"gridData\":{\"x\":38,\"y\":12,\"w\":10,\"h\":7,\"i\":\"b7b17dfe-87b7-4c8b-81f5-aab625251c3f\"},\"panelIndex\":\"b7b17dfe-87b7-4c8b-81f5-aab625251c3f\",\"embeddableConfig\":{\"attributes\":{\"title\":\"\",\"description\":\"\",\"visualizationType\":\"lnsMetric\",\"type\":\"lens\",\"references\":[{\"type\":\"index-pattern\",\"id\":\"logstash-*\",\"name\":\"indexpattern-datasource-layer-c0b8fb6e-3ed0-4565-b5f5-635ed8fe4d9f\"},{\"type\":\"index-pattern\",\"id\":\"logstash-*\",\"name\":\"indexpattern-datasource-layer-5dd18300-eb6a-4259-8e68-2e302c2b9bcb\"}],\"state\":{\"visualization\":{\"layerId\":\"c0b8fb6e-3ed0-4565-b5f5-635ed8fe4d9f\",\"layerType\":\"data\",\"metricAccessor\":\"f8cf87ac-8f5b-4eda-b266-0c37d0205858\",\"showBar\":false,\"trendlineLayerId\":\"5dd18300-eb6a-4259-8e68-2e302c2b9bcb\",\"trendlineLayerType\":\"metricTrendline\",\"trendlineTimeAccessor\":\"c993571d-2a82-4162-a367-3542041c811f\",\"trendlineMetricAccessor\":\"5c57e520-1e7b-41f4-a362-a20f8b2762d4\",\"color\":\"#fccada\"},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[],\"datasourceStates\":{\"formBased\":{\"layers\":{\"c0b8fb6e-3ed0-4565-b5f5-635ed8fe4d9f\":{\"columns\":{\"f8cf87ac-8f5b-4eda-b266-0c37d0205858\":{\"label\":\"Median RAM\",\"dataType\":\"number\",\"operationType\":\"median\",\"sourceField\":\"machine.ram\",\"isBucketed\":false,\"scale\":\"ratio\",\"params\":{\"emptyAsNull\":true,\"format\":{\"id\":\"bytes\",\"params\":{\"decimals\":1}}},\"customLabel\":true}},\"columnOrder\":[\"f8cf87ac-8f5b-4eda-b266-0c37d0205858\"],\"sampling\":1,\"ignoreGlobalFilters\":false,\"incompleteColumns\":{}},\"5dd18300-eb6a-4259-8e68-2e302c2b9bcb\":{\"linkToLayers\":[\"c0b8fb6e-3ed0-4565-b5f5-635ed8fe4d9f\"],\"columns\":{\"c993571d-2a82-4162-a367-3542041c811f\":{\"label\":\"@timestamp\",\"dataType\":\"date\",\"operationType\":\"date_histogram\",\"sourceField\":\"@timestamp\",\"isBucketed\":true,\"scale\":\"interval\",\"params\":{\"interval\":\"auto\",\"includeEmptyRows\":true,\"dropPartials\":false}},\"5c57e520-1e7b-41f4-a362-a20f8b2762d4\":{\"label\":\"Median RAM\",\"dataType\":\"number\",\"operationType\":\"median\",\"sourceField\":\"machine.ram\",\"isBucketed\":false,\"scale\":\"ratio\",\"params\":{\"emptyAsNull\":true,\"format\":{\"id\":\"bytes\",\"params\":{\"decimals\":1}}},\"customLabel\":true}},\"columnOrder\":[\"c993571d-2a82-4162-a367-3542041c811f\",\"5c57e520-1e7b-41f4-a362-a20f8b2762d4\"],\"sampling\":1,\"ignoreGlobalFilters\":false,\"incompleteColumns\":{}}}},\"indexpattern\":{\"layers\":{}},\"textBased\":{\"layers\":{}}},\"internalReferences\":[],\"adHocDataViews\":{}}},\"enhancements\":{}}}]","refreshInterval":{"pause":true,"value":60000},"timeFrom":"2015-09-19T06:31:44.000Z","timeRestore":true,"timeTo":"2015-09-23T18:31:44.000Z","title":"Super Saved Serverless","version":1},"coreMigrationVersion":"8.8.0","created_at":"2023-08-25T16:37:35.012Z","id":"4dc11f80-42b5-11ee-89b3-c776e03685a8","managed":false,"references":[{"id":"logstash-*","name":"kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index","type":"index-pattern"},{"id":"logstash-*","name":"5b087cde-634a-4815-9093-71891a900380:indexpattern-datasource-layer-36023ac0-43e8-41de-aee2-ebabf1b4fb65","type":"index-pattern"},{"id":"76fc4200-cf44-11e9-b933-fd84270f3ac2","name":"ee9dceec-afc3-4258-9998-44a00c2b36fc:panel_ee9dceec-afc3-4258-9998-44a00c2b36fc","type":"lens"},{"id":"logstash-*","name":"7557df66-cfde-4401-a926-aff27d774715:layer_1_source_index_pattern","type":"index-pattern"},{"id":"76fc4200-cf44-11e9-b933-fd84270f3ac1","name":"d3089be5-dff0-4bbe-9a36-76dd1dec98ef:panel_d3089be5-dff0-4bbe-9a36-76dd1dec98ef","type":"lens"},{"id":"logstash-*","name":"b7b17dfe-87b7-4c8b-81f5-aab625251c3f:indexpattern-datasource-layer-c0b8fb6e-3ed0-4565-b5f5-635ed8fe4d9f","type":"index-pattern"},{"id":"logstash-*","name":"b7b17dfe-87b7-4c8b-81f5-aab625251c3f:indexpattern-datasource-layer-5dd18300-eb6a-4259-8e68-2e302c2b9bcb","type":"index-pattern"}],"type":"dashboard","typeMigrationVersion":"8.9.0","updated_at":"2023-08-25T16:37:35.012Z","version":"WzE2NCwxXQ=="} +{"attributes":{"fields":"[{\"name\":\"@message\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"@message.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"@tags\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"@tags.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"@timestamp\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"_id\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_index\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_score\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_source\",\"type\":\"_source\",\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"agent\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"agent.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"bytes\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"clientip\",\"type\":\"ip\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"extension\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"extension.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.coordinates\",\"type\":\"geo_point\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.dest\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.src\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.srcdest\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"headings\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"headings.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"host\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"host.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"id\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"index\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"index.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"ip\",\"type\":\"ip\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"links\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"links.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"machine.os\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"machine.os.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"machine.ram\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"memory\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"meta.char\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"meta.related\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"meta.user.firstname\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"meta.user.lastname\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"phpmemory\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"referer\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:modified_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:published_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:section\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.article:section.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:tag\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.article:tag.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:description\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:description.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:image\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:image.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:image:height\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:image:height.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:image:width\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:image:width.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:site_name\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:site_name.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:title\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:title.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:type.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:url.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:card\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:card.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:description\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:description.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:image\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:image.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:site\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:site.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:title\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:title.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.url.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"request\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"request.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"response\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"response.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"spaces\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"spaces.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"url.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"utc_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"xss\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"xss.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true}]","timeFieldName":"@timestamp","title":"logstash-*"},"coreMigrationVersion":"8.8.0","created_at":"2023-09-28T17:07:19.398Z","id":"logstash-*","managed":false,"references":[],"type":"index-pattern","typeMigrationVersion":"7.11.0","updated_at":"2023-09-28T17:07:19.398Z","version":"WzE4LDFd"} +{"attributes":{"state":{"datasourceStates":{"formBased":{"layers":{"4ba1a1be-6e67-434b-b3a0-f30db8ea5395":{"columnOrder":["7a5d833b-ca6f-4e48-a924-d2a28d365dc3","3cf18f28-3495-4d45-a55f-d97f88022099","3dc0bd55-2087-4e60-aea2-f9910714f7db"],"columns":{"3cf18f28-3495-4d45-a55f-d97f88022099":{"dataType":"date","isBucketed":true,"label":"@timestamp","operationType":"date_histogram","params":{"includeEmptyRows":false,"interval":"auto"},"scale":"interval","sourceField":"@timestamp"},"3dc0bd55-2087-4e60-aea2-f9910714f7db":{"dataType":"number","isBucketed":false,"label":"Average of bytes","operationType":"average","scale":"ratio","sourceField":"bytes"},"7a5d833b-ca6f-4e48-a924-d2a28d365dc3":{"dataType":"ip","isBucketed":true,"label":"Top values of ip","operationType":"terms","params":{"orderBy":{"columnId":"3dc0bd55-2087-4e60-aea2-f9910714f7db","type":"column"},"orderDirection":"desc","size":3},"scale":"ordinal","sourceField":"ip"}}}}}},"filters":[],"query":{"language":"kuery","query":""},"visualization":{"layers":[{"accessors":["3dc0bd55-2087-4e60-aea2-f9910714f7db"],"layerId":"4ba1a1be-6e67-434b-b3a0-f30db8ea5395","seriesType":"bar_stacked","splitAccessor":"7a5d833b-ca6f-4e48-a924-d2a28d365dc3","xAccessor":"3cf18f28-3495-4d45-a55f-d97f88022099"}],"legend":{"isVisible":true,"legendSize":"auto","position":"right"},"preferredSeriesType":"bar_stacked"}},"title":"lnsXYvis","visualizationType":"lnsXY"},"coreMigrationVersion":"8.8.0","created_at":"2023-09-28T17:07:19.398Z","id":"76fc4200-cf44-11e9-b933-fd84270f3ac2","managed":false,"references":[{"id":"logstash-*","name":"indexpattern-datasource-current-indexpattern","type":"index-pattern"},{"id":"logstash-*","name":"indexpattern-datasource-layer-4ba1a1be-6e67-434b-b3a0-f30db8ea5395","type":"index-pattern"}],"type":"lens","typeMigrationVersion":"8.9.0","updated_at":"2023-09-28T17:07:19.398Z","version":"WzE5LDFd"} +{"attributes":{"state":{"datasourceStates":{"formBased":{"layers":{"c61a8afb-a185-4fae-a064-fb3846f6c451":{"columnOrder":["2cd09808-3915-49f4-b3b0-82767eba23f7"],"columns":{"2cd09808-3915-49f4-b3b0-82767eba23f7":{"dataType":"number","isBucketed":false,"label":"Maximum of bytes","operationType":"max","scale":"ratio","sourceField":"bytes"}}}}}},"filters":[],"query":{"language":"kuery","query":""},"visualization":{"accessor":"2cd09808-3915-49f4-b3b0-82767eba23f7","isHorizontal":false,"layerId":"c61a8afb-a185-4fae-a064-fb3846f6c451","layerType":"data","layers":[{"accessors":["d3e62a7a-c259-4fff-a2fc-eebf20b7008a","26ef70a9-c837-444c-886e-6bd905ee7335"],"layerId":"c61a8afb-a185-4fae-a064-fb3846f6c451","layerType":"data","seriesType":"area","splitAccessor":"54cd64ed-2a44-4591-af84-b2624504569a","xAccessor":"d6e40cea-6299-43b4-9c9d-b4ee305a2ce8"}],"legend":{"isVisible":true,"position":"right"},"preferredSeriesType":"area","size":"xl","textAlign":"center","titlePosition":"bottom"}},"title":"Artistpreviouslyknownaslens","visualizationType":"lnsLegacyMetric"},"coreMigrationVersion":"8.8.0","created_at":"2023-09-28T17:07:19.398Z","id":"76fc4200-cf44-11e9-b933-fd84270f3ac1","managed":false,"references":[{"id":"logstash-*","name":"indexpattern-datasource-current-indexpattern","type":"index-pattern"},{"id":"logstash-*","name":"indexpattern-datasource-layer-c61a8afb-a185-4fae-a064-fb3846f6c451","type":"index-pattern"}],"type":"lens","typeMigrationVersion":"8.9.0","updated_at":"2023-09-28T17:07:19.398Z","version":"WzIwLDFd"} +{"attributes":{"description":"","kibanaSavedObjectMeta":{"searchSourceJSON":"{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[{\"meta\":{\"disabled\":false,\"negate\":true,\"alias\":null,\"key\":\"agent.raw\",\"field\":\"agent.raw\",\"params\":{\"query\":\"Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)\"},\"type\":\"phrase\",\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index\"},\"query\":{\"match_phrase\":{\"agent.raw\":\"Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)\"}},\"$state\":{\"store\":\"appState\"}}]}"},"optionsJSON":"{\"useMargins\":true,\"syncColors\":false,\"syncCursor\":true,\"syncTooltips\":false,\"hidePanelTitles\":false}","panelsJSON":"[{\"type\":\"lens\",\"gridData\":{\"x\":0,\"y\":0,\"w\":12,\"h\":13,\"i\":\"5b087cde-634a-4815-9093-71891a900380\"},\"panelIndex\":\"5b087cde-634a-4815-9093-71891a900380\",\"embeddableConfig\":{\"attributes\":{\"title\":\"\",\"description\":\"\",\"visualizationType\":\"lnsPie\",\"type\":\"lens\",\"references\":[{\"id\":\"logstash-*\",\"name\":\"indexpattern-datasource-layer-36023ac0-43e8-41de-aee2-ebabf1b4fb65\",\"type\":\"index-pattern\"}],\"state\":{\"visualization\":{\"shape\":\"donut\",\"layers\":[{\"layerId\":\"36023ac0-43e8-41de-aee2-ebabf1b4fb65\",\"primaryGroups\":[\"fbf03774-001d-4032-a808-004140e94918\"],\"metrics\":[\"65a386b0-e74e-4076-a419-ee1d3ed7ce87\"],\"numberDisplay\":\"percent\",\"categoryDisplay\":\"default\",\"legendDisplay\":\"default\",\"nestedLegend\":false,\"layerType\":\"data\"}]},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[],\"datasourceStates\":{\"formBased\":{\"layers\":{\"36023ac0-43e8-41de-aee2-ebabf1b4fb65\":{\"columns\":{\"fbf03774-001d-4032-a808-004140e94918\":{\"label\":\"Top 3 values of ip\",\"dataType\":\"ip\",\"operationType\":\"terms\",\"scale\":\"ordinal\",\"sourceField\":\"ip\",\"isBucketed\":true,\"params\":{\"size\":3,\"orderBy\":{\"type\":\"column\",\"columnId\":\"65a386b0-e74e-4076-a419-ee1d3ed7ce87\"},\"orderDirection\":\"desc\",\"otherBucket\":true,\"missingBucket\":false,\"parentFormat\":{\"id\":\"terms\"},\"include\":[],\"exclude\":[],\"includeIsRegex\":false,\"excludeIsRegex\":false}},\"65a386b0-e74e-4076-a419-ee1d3ed7ce87\":{\"label\":\"Average of bytes\",\"dataType\":\"number\",\"operationType\":\"average\",\"sourceField\":\"bytes\",\"isBucketed\":false,\"scale\":\"ratio\",\"params\":{\"emptyAsNull\":true}}},\"columnOrder\":[\"fbf03774-001d-4032-a808-004140e94918\",\"65a386b0-e74e-4076-a419-ee1d3ed7ce87\"],\"sampling\":1,\"ignoreGlobalFilters\":false,\"incompleteColumns\":{}}}},\"indexpattern\":{\"layers\":{}},\"textBased\":{\"layers\":{}}},\"internalReferences\":[],\"adHocDataViews\":{}}},\"enhancements\":{},\"hidePanelTitles\":false},\"title\":\"Custom Title\"},{\"type\":\"lens\",\"gridData\":{\"x\":12,\"y\":0,\"w\":36,\"h\":13,\"i\":\"ee9dceec-afc3-4258-9998-44a00c2b36fc\"},\"panelIndex\":\"ee9dceec-afc3-4258-9998-44a00c2b36fc\",\"embeddableConfig\":{\"hidePanelTitles\":false,\"description\":\"Wow what a neat description\",\"enhancements\":{}},\"title\":\"Custom Title on a Library Item\",\"panelRefName\":\"panel_ee9dceec-afc3-4258-9998-44a00c2b36fc\"},{\"type\":\"lens\",\"gridData\":{\"x\":38,\"y\":13,\"w\":10,\"h\":7,\"i\":\"b7b17dfe-87b7-4c8b-81f5-aab625251c3f\"},\"panelIndex\":\"b7b17dfe-87b7-4c8b-81f5-aab625251c3f\",\"embeddableConfig\":{\"attributes\":{\"title\":\"\",\"description\":\"\",\"visualizationType\":\"lnsMetric\",\"type\":\"lens\",\"references\":[{\"id\":\"logstash-*\",\"name\":\"indexpattern-datasource-layer-c0b8fb6e-3ed0-4565-b5f5-635ed8fe4d9f\",\"type\":\"index-pattern\"},{\"id\":\"logstash-*\",\"name\":\"indexpattern-datasource-layer-5dd18300-eb6a-4259-8e68-2e302c2b9bcb\",\"type\":\"index-pattern\"}],\"state\":{\"visualization\":{\"layerId\":\"c0b8fb6e-3ed0-4565-b5f5-635ed8fe4d9f\",\"layerType\":\"data\",\"metricAccessor\":\"f8cf87ac-8f5b-4eda-b266-0c37d0205858\",\"showBar\":false,\"trendlineLayerId\":\"5dd18300-eb6a-4259-8e68-2e302c2b9bcb\",\"trendlineLayerType\":\"metricTrendline\",\"trendlineTimeAccessor\":\"c993571d-2a82-4162-a367-3542041c811f\",\"trendlineMetricAccessor\":\"5c57e520-1e7b-41f4-a362-a20f8b2762d4\",\"color\":\"#fccada\"},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[],\"datasourceStates\":{\"formBased\":{\"layers\":{\"c0b8fb6e-3ed0-4565-b5f5-635ed8fe4d9f\":{\"columns\":{\"f8cf87ac-8f5b-4eda-b266-0c37d0205858\":{\"label\":\"Median RAM\",\"dataType\":\"number\",\"operationType\":\"median\",\"sourceField\":\"machine.ram\",\"isBucketed\":false,\"scale\":\"ratio\",\"params\":{\"emptyAsNull\":true,\"format\":{\"id\":\"bytes\",\"params\":{\"decimals\":1}}},\"customLabel\":true}},\"columnOrder\":[\"f8cf87ac-8f5b-4eda-b266-0c37d0205858\"],\"sampling\":1,\"ignoreGlobalFilters\":false,\"incompleteColumns\":{}},\"5dd18300-eb6a-4259-8e68-2e302c2b9bcb\":{\"linkToLayers\":[\"c0b8fb6e-3ed0-4565-b5f5-635ed8fe4d9f\"],\"columns\":{\"c993571d-2a82-4162-a367-3542041c811f\":{\"label\":\"@timestamp\",\"dataType\":\"date\",\"operationType\":\"date_histogram\",\"sourceField\":\"@timestamp\",\"isBucketed\":true,\"scale\":\"interval\",\"params\":{\"interval\":\"auto\",\"includeEmptyRows\":true,\"dropPartials\":false}},\"5c57e520-1e7b-41f4-a362-a20f8b2762d4\":{\"label\":\"Median RAM\",\"dataType\":\"number\",\"operationType\":\"median\",\"sourceField\":\"machine.ram\",\"isBucketed\":false,\"scale\":\"ratio\",\"params\":{\"emptyAsNull\":true,\"format\":{\"id\":\"bytes\",\"params\":{\"decimals\":1}}},\"customLabel\":true}},\"columnOrder\":[\"c993571d-2a82-4162-a367-3542041c811f\",\"5c57e520-1e7b-41f4-a362-a20f8b2762d4\"],\"sampling\":1,\"ignoreGlobalFilters\":false,\"incompleteColumns\":{}}}},\"indexpattern\":{\"layers\":{}},\"textBased\":{\"layers\":{}}},\"internalReferences\":[],\"adHocDataViews\":{}}},\"enhancements\":{}}},{\"type\":\"map\",\"gridData\":{\"x\":0,\"y\":13,\"w\":38,\"h\":16,\"i\":\"7557df66-cfde-4401-a926-aff27d774715\"},\"panelIndex\":\"7557df66-cfde-4401-a926-aff27d774715\",\"embeddableConfig\":{\"attributes\":{\"title\":\"\",\"description\":\"\",\"layerListJSON\":\"[{\\\"locale\\\":\\\"autoselect\\\",\\\"sourceDescriptor\\\":{\\\"type\\\":\\\"EMS_TMS\\\",\\\"isAutoSelect\\\":true,\\\"lightModeDefault\\\":\\\"road_map_desaturated\\\"},\\\"id\\\":\\\"710998eb-fda1-462c-80f2-d498df132ebf\\\",\\\"label\\\":null,\\\"minZoom\\\":0,\\\"maxZoom\\\":24,\\\"alpha\\\":1,\\\"visible\\\":true,\\\"style\\\":{\\\"type\\\":\\\"EMS_VECTOR_TILE\\\",\\\"color\\\":\\\"\\\"},\\\"includeInFitToBounds\\\":true,\\\"type\\\":\\\"EMS_VECTOR_TILE\\\"},{\\\"sourceDescriptor\\\":{\\\"geoField\\\":\\\"geo.coordinates\\\",\\\"scalingType\\\":\\\"MVT\\\",\\\"id\\\":\\\"4ed5225d-d42d-40e3-9515-5a918208db27\\\",\\\"type\\\":\\\"ES_SEARCH\\\",\\\"applyGlobalQuery\\\":true,\\\"applyGlobalTime\\\":true,\\\"applyForceRefresh\\\":true,\\\"filterByMapBounds\\\":true,\\\"tooltipProperties\\\":[],\\\"sortField\\\":\\\"\\\",\\\"sortOrder\\\":\\\"desc\\\",\\\"topHitsGroupByTimeseries\\\":false,\\\"topHitsSplitField\\\":\\\"\\\",\\\"topHitsSize\\\":1,\\\"indexPatternRefName\\\":\\\"layer_1_source_index_pattern\\\"},\\\"id\\\":\\\"07facc2c-117d-4335-bd53-90e0ab36aa53\\\",\\\"label\\\":null,\\\"minZoom\\\":0,\\\"maxZoom\\\":24,\\\"alpha\\\":1,\\\"visible\\\":true,\\\"style\\\":{\\\"type\\\":\\\"VECTOR\\\",\\\"properties\\\":{\\\"icon\\\":{\\\"type\\\":\\\"STATIC\\\",\\\"options\\\":{\\\"value\\\":\\\"marker\\\"}},\\\"fillColor\\\":{\\\"type\\\":\\\"DYNAMIC\\\",\\\"options\\\":{\\\"color\\\":\\\"Blues\\\",\\\"colorCategory\\\":\\\"palette_0\\\",\\\"field\\\":{\\\"name\\\":\\\"extension.raw\\\",\\\"origin\\\":\\\"source\\\"},\\\"fieldMetaOptions\\\":{\\\"isEnabled\\\":true,\\\"sigma\\\":3},\\\"type\\\":\\\"CATEGORICAL\\\",\\\"otherCategoryColor\\\":\\\"#000000\\\"}},\\\"lineColor\\\":{\\\"type\\\":\\\"STATIC\\\",\\\"options\\\":{\\\"color\\\":\\\"#41937c\\\"}},\\\"lineWidth\\\":{\\\"type\\\":\\\"STATIC\\\",\\\"options\\\":{\\\"size\\\":0}},\\\"iconSize\\\":{\\\"type\\\":\\\"STATIC\\\",\\\"options\\\":{\\\"size\\\":6}},\\\"iconOrientation\\\":{\\\"type\\\":\\\"STATIC\\\",\\\"options\\\":{\\\"orientation\\\":0}},\\\"labelText\\\":{\\\"type\\\":\\\"STATIC\\\",\\\"options\\\":{\\\"value\\\":\\\"\\\"}},\\\"labelColor\\\":{\\\"type\\\":\\\"STATIC\\\",\\\"options\\\":{\\\"color\\\":\\\"#000000\\\"}},\\\"labelSize\\\":{\\\"type\\\":\\\"STATIC\\\",\\\"options\\\":{\\\"size\\\":14}},\\\"labelZoomRange\\\":{\\\"options\\\":{\\\"useLayerZoomRange\\\":true,\\\"minZoom\\\":0,\\\"maxZoom\\\":24}},\\\"labelBorderColor\\\":{\\\"type\\\":\\\"STATIC\\\",\\\"options\\\":{\\\"color\\\":\\\"#FFFFFF\\\"}},\\\"symbolizeAs\\\":{\\\"options\\\":{\\\"value\\\":\\\"circle\\\"}},\\\"labelBorderSize\\\":{\\\"options\\\":{\\\"size\\\":\\\"SMALL\\\"}},\\\"labelPosition\\\":{\\\"options\\\":{\\\"position\\\":\\\"CENTER\\\"}}},\\\"isTimeAware\\\":true},\\\"includeInFitToBounds\\\":true,\\\"type\\\":\\\"MVT_VECTOR\\\",\\\"joins\\\":[],\\\"disableTooltips\\\":false}]\",\"mapStateJSON\":\"{\\\"adHocDataViews\\\":[],\\\"zoom\\\":3.53,\\\"center\\\":{\\\"lon\\\":-98.19524,\\\"lat\\\":42.06188},\\\"timeFilters\\\":{\\\"from\\\":\\\"2015-09-19T06:31:44.000Z\\\",\\\"to\\\":\\\"2015-09-23T18:31:44.000Z\\\"},\\\"refreshConfig\\\":{\\\"isPaused\\\":true,\\\"interval\\\":60000},\\\"query\\\":{\\\"query\\\":\\\"\\\",\\\"language\\\":\\\"kuery\\\"},\\\"filters\\\":[],\\\"settings\\\":{\\\"autoFitToDataBounds\\\":false,\\\"backgroundColor\\\":\\\"#ffffff\\\",\\\"customIcons\\\":[],\\\"disableInteractive\\\":false,\\\"disableTooltipControl\\\":false,\\\"hideToolbarOverlay\\\":false,\\\"hideLayerControl\\\":false,\\\"hideViewControl\\\":false,\\\"initialLocation\\\":\\\"LAST_SAVED_LOCATION\\\",\\\"fixedLocation\\\":{\\\"lat\\\":0,\\\"lon\\\":0,\\\"zoom\\\":2},\\\"browserLocation\\\":{\\\"zoom\\\":2},\\\"keydownScrollZoom\\\":false,\\\"maxZoom\\\":24,\\\"minZoom\\\":0,\\\"showScaleControl\\\":false,\\\"showSpatialFilters\\\":true,\\\"showTimesliderToggleButton\\\":true,\\\"spatialFiltersAlpa\\\":0.3,\\\"spatialFiltersFillColor\\\":\\\"#DA8B45\\\",\\\"spatialFiltersLineColor\\\":\\\"#DA8B45\\\"}}\",\"uiStateJSON\":\"{\\\"isLayerTOCOpen\\\":true,\\\"openTOCDetails\\\":[\\\"07facc2c-117d-4335-bd53-90e0ab36aa53\\\"]}\"},\"mapCenter\":{\"lat\":42.37743,\"lon\":-101.55858,\"zoom\":3.53},\"mapBuffer\":{\"minLon\":-135,\"minLat\":21.94305,\"maxLon\":-45,\"maxLat\":55.77657},\"isLayerTOCOpen\":true,\"openTOCDetails\":[\"07facc2c-117d-4335-bd53-90e0ab36aa53\"],\"hiddenLayers\":[],\"enhancements\":{}}},{\"type\":\"lens\",\"gridData\":{\"x\":38,\"y\":20,\"w\":10,\"h\":9,\"i\":\"d3089be5-dff0-4bbe-9a36-76dd1dec98ef\"},\"panelIndex\":\"d3089be5-dff0-4bbe-9a36-76dd1dec98ef\",\"embeddableConfig\":{\"hidePanelTitles\":false,\"timeRange\":{\"from\":\"2015-09-21T06:31:44.000Z\",\"to\":\"2015-09-23T18:31:44.000Z\"},\"enhancements\":{}},\"panelRefName\":\"panel_d3089be5-dff0-4bbe-9a36-76dd1dec98ef\"}]","refreshInterval":{"pause":true,"value":60000},"timeFrom":"2015-09-19T06:31:44.000Z","timeRestore":true,"timeTo":"2015-09-23T18:31:44.000Z","title":"Super Saved Serverless","version":1},"coreMigrationVersion":"8.8.0","created_at":"2023-09-28T17:07:48.232Z","id":"4dc11f80-42b5-11ee-89b3-c776e03685a8","managed":false,"references":[{"id":"logstash-*","name":"kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index","type":"index-pattern"},{"id":"logstash-*","name":"5b087cde-634a-4815-9093-71891a900380:indexpattern-datasource-layer-36023ac0-43e8-41de-aee2-ebabf1b4fb65","type":"index-pattern"},{"id":"76fc4200-cf44-11e9-b933-fd84270f3ac2","name":"ee9dceec-afc3-4258-9998-44a00c2b36fc:panel_ee9dceec-afc3-4258-9998-44a00c2b36fc","type":"lens"},{"id":"logstash-*","name":"b7b17dfe-87b7-4c8b-81f5-aab625251c3f:indexpattern-datasource-layer-c0b8fb6e-3ed0-4565-b5f5-635ed8fe4d9f","type":"index-pattern"},{"id":"logstash-*","name":"b7b17dfe-87b7-4c8b-81f5-aab625251c3f:indexpattern-datasource-layer-5dd18300-eb6a-4259-8e68-2e302c2b9bcb","type":"index-pattern"},{"id":"logstash-*","name":"7557df66-cfde-4401-a926-aff27d774715:layer_1_source_index_pattern","type":"index-pattern"},{"id":"76fc4200-cf44-11e9-b933-fd84270f3ac1","name":"d3089be5-dff0-4bbe-9a36-76dd1dec98ef:panel_d3089be5-dff0-4bbe-9a36-76dd1dec98ef","type":"lens"}],"type":"dashboard","typeMigrationVersion":"8.9.0","updated_at":"2023-09-28T17:07:48.232Z","version":"WzI0LDFd"} {"excludedObjects":[],"excludedObjectsCount":0,"exportedCount":4,"missingRefCount":0,"missingReferences":[]} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 55ccb83905494..d85a677823caf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4811,6 +4811,10 @@ version "0.0.0" uid "" +"@kbn/links-plugin@link:src/plugins/links": + version "0.0.0" + uid "" + "@kbn/lint-packages-cli@link:packages/kbn-lint-packages-cli": version "0.0.0" uid "" From 567a6de97796739c068461e5487f3ea0d8685060 Mon Sep 17 00:00:00 2001 From: jennypavlova Date: Fri, 29 Sep 2023 16:26:23 +0200 Subject: [PATCH 14/15] [Infra UI] Add options to the alerts link in the asset details flyout (#167616) Closes #167524 ## Summary This PR adds the possibility of overriding the alerts flyout options. This is used to change the metric value during the alert creation when it is changed in the menu on inventory. ## Testing - Go to Inventory - Change the metric in the drop-down menu - The metric should be changed in the alerts flyout - Go to hosts view flyout (alert rule should remain the same) - Go to asset details page view (alert rule should remain the same) https://github.com/elastic/kibana/assets/14139027/2bb31be0-04c8-4611-a8d2-b91a465a09b8 --- .../asset_details/tabs/overview/alerts.tsx | 3 +++ .../infra/public/components/asset_details/types.ts | 2 ++ .../inventory_view/components/nodes_overview.tsx | 1 + .../components/waffle/asset_details_flyout.tsx | 13 ++++++++++++- 4 files changed, 18 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/infra/public/components/asset_details/tabs/overview/alerts.tsx b/x-pack/plugins/infra/public/components/asset_details/tabs/overview/alerts.tsx index 43c71f190e004..2fa09451118cb 100644 --- a/x-pack/plugins/infra/public/components/asset_details/tabs/overview/alerts.tsx +++ b/x-pack/plugins/infra/public/components/asset_details/tabs/overview/alerts.tsx @@ -21,6 +21,7 @@ import { AlertFlyout } from '../../../../alerting/inventory/components/alert_fly import { useBoolean } from '../../../../hooks/use_boolean'; import { ALERT_STATUS_ALL } from '../../../../common/alerts/constants'; import { AlertsSectionTitle } from '../../components/section_titles'; +import { useAssetDetailsRenderPropsContext } from '../../hooks/use_asset_details_render_props'; export const AlertsSummaryContent = ({ assetName, @@ -32,6 +33,7 @@ export const AlertsSummaryContent = ({ dateRange: TimeRange; }) => { const [isAlertFlyoutVisible, { toggle: toggleAlertFlyout }] = useBoolean(false); + const { overrides } = useAssetDetailsRenderPropsContext(); const alertsEsQueryByStatus = useMemo( () => @@ -67,6 +69,7 @@ export const AlertsSummaryContent = ({ nodeType={assetType} setVisible={toggleAlertFlyout} visible={isAlertFlyoutVisible} + options={overrides?.alertRule?.options} /> ); diff --git a/x-pack/plugins/infra/public/components/asset_details/types.ts b/x-pack/plugins/infra/public/components/asset_details/types.ts index 9bb33c6d6c304..cc985f8d782bd 100644 --- a/x-pack/plugins/infra/public/components/asset_details/types.ts +++ b/x-pack/plugins/infra/public/components/asset_details/types.ts @@ -8,6 +8,7 @@ import { TimeRange } from '@kbn/es-query'; import { Search } from 'history'; import type { InventoryItemType } from '../../../common/inventory_models/types'; +import type { InfraWaffleMapOptions } from '../../lib/lib'; export type { AssetDetailsUrlState } from './hooks/use_asset_details_url_state'; @@ -37,6 +38,7 @@ export interface OverridableTabState { }; alertRule?: { onCreateRuleClick?: () => void; + options?: Partial; }; } diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/nodes_overview.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/nodes_overview.tsx index a0ffd01ceee4f..858b68a4b646d 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/nodes_overview.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/nodes_overview.tsx @@ -147,6 +147,7 @@ export const NodesOverview = ({ assetName={detailsItemId} assetType={nodeType} currentTime={currentTime} + options={options} /> )} void; currentTime: number; + options?: InfraWaffleMapOptions; } const ONE_HOUR = 60 * 60 * 1000; @@ -32,7 +34,13 @@ const flyoutTabs = [ }, ]; -export const AssetDetailsFlyout = ({ assetName, assetType, closeFlyout, currentTime }: Props) => { +export const AssetDetailsFlyout = ({ + assetName, + assetType, + closeFlyout, + currentTime, + options, +}: Props) => { const { source } = useSourceContext(); return source ? ( @@ -43,6 +51,9 @@ export const AssetDetailsFlyout = ({ assetName, assetType, closeFlyout, currentT metadata: { showActionsColumn: false, }, + alertRule: { + options, + }, }} tabs={flyoutTabs} links={['nodeDetails']} From 2047a5556a1a0a14b74babebf08af3b57802a59c Mon Sep 17 00:00:00 2001 From: Achyut Jhunjhunwala Date: Fri, 29 Sep 2023 16:31:51 +0200 Subject: [PATCH 15/15] [APM] Fix cypress memory issue due to log panel (#167623) ## Summary Recent [EUI bump](https://github.com/elastic/kibana/pull/166868) from v88.3.0 to v88.5.0 increased the memory usage significantly. Due to which Cypress Tests started failing due to browser crashing with memory limits. EUI team has been informed about this. As reverting EUI is not a simple change, disabling the Cypress Log panel solves the problem for now. Details can be found [here](https://github.com/cypress-io/cypress/issues/27415#issuecomment-1668073106) This brings change to how we run the Cypress Test in Head Mode ## Before ![image](https://github.com/elastic/kibana/assets/7416358/12a92c64-2e31-4423-8e8a-94a311757bd7) ## After ![image (1)](https://github.com/elastic/kibana/assets/7416358/d3a522c9-66e8-497c-b046-32a6c866b600) --- .../apm/ftr_e2e/cypress/e2e/settings/agent_configurations.cy.ts | 2 +- x-pack/plugins/apm/ftr_e2e/cypress_test_runner.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/apm/ftr_e2e/cypress/e2e/settings/agent_configurations.cy.ts b/x-pack/plugins/apm/ftr_e2e/cypress/e2e/settings/agent_configurations.cy.ts index f60bdf19b0071..e0ca47df79996 100644 --- a/x-pack/plugins/apm/ftr_e2e/cypress/e2e/settings/agent_configurations.cy.ts +++ b/x-pack/plugins/apm/ftr_e2e/cypress/e2e/settings/agent_configurations.cy.ts @@ -123,7 +123,7 @@ describe('Agent configuration', () => { .click({ force: true }) .type('All'); - cy.get('mark').contains('All').click(); + cy.get('mark').contains('All').click({ force: true }); cy.contains('Next step').click(); cy.contains('Service name All'); cy.contains('Environment All'); diff --git a/x-pack/plugins/apm/ftr_e2e/cypress_test_runner.ts b/x-pack/plugins/apm/ftr_e2e/cypress_test_runner.ts index 9bb705088735c..5bf566a7f99b0 100644 --- a/x-pack/plugins/apm/ftr_e2e/cypress_test_runner.ts +++ b/x-pack/plugins/apm/ftr_e2e/cypress_test_runner.ts @@ -76,6 +76,7 @@ export async function cypressTestRunner({ getService }: FtrProviderContext) { ES_NODE: esNode, ES_REQUEST_TIMEOUT: esRequestTimeout, TEST_CLOUD: process.env.TEST_CLOUD, + NO_COMMAND_LOG: 1, // Temp fix, With Cypress 13, this might not be required https://github.com/elastic/kibana/pull/162383 }, });