From ef7c1a689bf8e700e4a65520ff17987ecf1a07dd Mon Sep 17 00:00:00 2001 From: Maryam Saeidi Date: Wed, 9 Nov 2022 16:18:16 +0100 Subject: [PATCH 1/4] [Actionable Observability] Integrate alert search bar on rule details page (#144718) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves #143962 ## ๐Ÿ“ Summary In this PR, an alerts search bar was added to the rule details page by syncing its state to the URL. This will enable navigating to the alerts table for a specific rule with a filtered state based on active or recovered. ### Notes - Renamed alert page container to alert search bar container and used it both in alerts and rule details page (it will be responsible to sync search bar params to the URL) --> moved to a shared component - Moved AlertsStatusFilter to be a sub-component of the shared observability search bar - Allowed ObservabilityAlertSearchBar to be used both as a stand-alone component and as a wired component with syncing params to the URL (ObservabilityAlertSearchBar, ObservabilityAlertSearchbarWithUrlSync) - Set a minHeight for the Alerts and Execution tab, otherwise, the page will have extra scroll on the tab change while content is loading (very annoying!) ## ๐ŸŽจ Preview ![image](https://user-images.githubusercontent.com/12370520/200547324-d9c4ef3c-8a82-4c16-88bd-f1d4b2bc8006.png) ## ๐Ÿงช How to test - Create a rule and go to the rule details page - Click on the alerts tab and change the search criteria, you should be able to see the criteria in the query parameter - Refresh the page, alerts tab should be selected and you should be able to see the filters that you applied in the previous step - As a side test, check alert search bar on alerts page as well, it should work as before Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../alert_search_bar/alert_search_bar.tsx | 124 ++++++++++++++++ .../alert_search_bar_with_url_sync.tsx | 29 ++++ .../components/alerts_status_filter.tsx | 44 ++++++ .../alert_search_bar/components/index.ts} | 3 +- .../shared/alert_search_bar/constants.ts} | 42 +----- .../alert_search_bar/containers/index.tsx | 9 ++ .../containers/state_container.tsx | 62 ++++++++ .../use_alert_search_bar_state_container.tsx} | 16 +- .../shared/alert_search_bar/index.ts | 9 ++ .../shared/alert_search_bar/types.ts | 39 +++++ .../alerts_flyout/alerts_flyout_body.test.tsx | 2 +- .../alerts_flyout/alerts_flyout_body.tsx | 2 +- .../alerts/components/alerts_search_bar.tsx | 6 + .../public/pages/alerts/components/index.ts | 1 - .../components/observability_actions.test.tsx | 2 +- .../components/observability_actions.tsx | 2 +- .../containers/alerts_page/alerts_page.tsx | 139 +++--------------- .../containers/alerts_page/constants.ts | 1 + .../alerts/containers/alerts_page/index.ts | 2 +- .../public/pages/alerts/containers/index.ts | 1 - .../state_container/state_container.tsx | 52 ------- .../public/pages/rule_details/constants.ts | 12 ++ .../public/pages/rule_details/index.tsx | 114 +++++++++----- .../public/pages/rule_details/types.ts | 35 +---- .../observability/public/utils/url.test.ts | 93 ++++++++++++ 25 files changed, 549 insertions(+), 292 deletions(-) create mode 100644 x-pack/plugins/observability/public/components/shared/alert_search_bar/alert_search_bar.tsx create mode 100644 x-pack/plugins/observability/public/components/shared/alert_search_bar/alert_search_bar_with_url_sync.tsx create mode 100644 x-pack/plugins/observability/public/components/shared/alert_search_bar/components/alerts_status_filter.tsx rename x-pack/plugins/observability/public/{pages/alerts/containers/state_container/index.tsx => components/shared/alert_search_bar/components/index.ts} (62%) rename x-pack/plugins/observability/public/{pages/alerts/components/alerts_status_filter.tsx => components/shared/alert_search_bar/constants.ts} (55%) create mode 100644 x-pack/plugins/observability/public/components/shared/alert_search_bar/containers/index.tsx create mode 100644 x-pack/plugins/observability/public/components/shared/alert_search_bar/containers/state_container.tsx rename x-pack/plugins/observability/public/{pages/alerts/containers/state_container/use_alerts_page_state_container.tsx => components/shared/alert_search_bar/containers/use_alert_search_bar_state_container.tsx} (85%) create mode 100644 x-pack/plugins/observability/public/components/shared/alert_search_bar/index.ts create mode 100644 x-pack/plugins/observability/public/components/shared/alert_search_bar/types.ts create mode 100644 x-pack/plugins/observability/public/pages/alerts/components/alerts_search_bar.tsx delete mode 100644 x-pack/plugins/observability/public/pages/alerts/containers/state_container/state_container.tsx create mode 100644 x-pack/plugins/observability/public/pages/rule_details/constants.ts create mode 100644 x-pack/plugins/observability/public/utils/url.test.ts diff --git a/x-pack/plugins/observability/public/components/shared/alert_search_bar/alert_search_bar.tsx b/x-pack/plugins/observability/public/components/shared/alert_search_bar/alert_search_bar.tsx new file mode 100644 index 0000000000000..04c6bb27de2fe --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/alert_search_bar/alert_search_bar.tsx @@ -0,0 +1,124 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; + +import React, { useCallback, useEffect } from 'react'; +import { Query } from '@kbn/es-query'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { observabilityAlertFeatureIds } from '../../../config'; +import { ObservabilityAppServices } from '../../../application/types'; +import { AlertsStatusFilter } from './components'; +import { ALERT_STATUS_QUERY, DEFAULT_QUERIES } from './constants'; +import { AlertSearchBarProps } from './types'; +import { buildEsQuery } from '../../../utils/build_es_query'; +import { AlertStatus } from '../../../../common/typings'; + +const getAlertStatusQuery = (status: string): Query[] => { + return status ? [{ query: ALERT_STATUS_QUERY[status], language: 'kuery' }] : []; +}; + +export function AlertSearchBar({ + appName, + rangeFrom, + setRangeFrom, + rangeTo, + setRangeTo, + kuery, + setKuery, + status, + setStatus, + setEsQuery, + queries = DEFAULT_QUERIES, +}: AlertSearchBarProps) { + const { + data: { + query: { + timefilter: { timefilter: timeFilterService }, + }, + }, + triggersActionsUi: { getAlertsSearchBar: AlertsSearchBar }, + } = useKibana().services; + + const onStatusChange = useCallback( + (alertStatus: AlertStatus) => { + setEsQuery( + buildEsQuery( + { + to: rangeTo, + from: rangeFrom, + }, + kuery, + [...getAlertStatusQuery(alertStatus), ...queries] + ) + ); + }, + [kuery, queries, rangeFrom, rangeTo, setEsQuery] + ); + + useEffect(() => { + onStatusChange(status); + }, [onStatusChange, status]); + + const onSearchBarParamsChange = useCallback( + ({ dateRange, query }) => { + timeFilterService.setTime(dateRange); + setRangeFrom(dateRange.from); + setRangeTo(dateRange.to); + setKuery(query); + setEsQuery( + buildEsQuery( + { + to: rangeTo, + from: rangeFrom, + }, + query, + [...getAlertStatusQuery(status), ...queries] + ) + ); + }, + [ + timeFilterService, + setRangeFrom, + setRangeTo, + setKuery, + setEsQuery, + rangeTo, + rangeFrom, + status, + queries, + ] + ); + + return ( + + + + + + + + + { + setStatus(id as AlertStatus); + }} + /> + + + + + ); +} diff --git a/x-pack/plugins/observability/public/components/shared/alert_search_bar/alert_search_bar_with_url_sync.tsx b/x-pack/plugins/observability/public/components/shared/alert_search_bar/alert_search_bar_with_url_sync.tsx new file mode 100644 index 0000000000000..cc9977a93c61f --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/alert_search_bar/alert_search_bar_with_url_sync.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { + alertSearchBarStateContainer, + Provider, + useAlertSearchBarStateContainer, +} from './containers'; +import { AlertSearchBar } from './alert_search_bar'; +import { AlertSearchBarWithUrlSyncProps } from './types'; + +function InternalAlertSearchbarWithUrlSync(props: AlertSearchBarWithUrlSyncProps) { + const stateProps = useAlertSearchBarStateContainer(); + + return ; +} + +export function AlertSearchbarWithUrlSync(props: AlertSearchBarWithUrlSyncProps) { + return ( + + + + ); +} diff --git a/x-pack/plugins/observability/public/components/shared/alert_search_bar/components/alerts_status_filter.tsx b/x-pack/plugins/observability/public/components/shared/alert_search_bar/components/alerts_status_filter.tsx new file mode 100644 index 0000000000000..fa1f362120713 --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/alert_search_bar/components/alerts_status_filter.tsx @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiButtonGroup, EuiButtonGroupOptionProps } from '@elastic/eui'; +import React from 'react'; +import { ALL_ALERTS, ACTIVE_ALERTS, RECOVERED_ALERTS } from '../constants'; +import { AlertStatusFilterProps } from '../types'; + +const options: EuiButtonGroupOptionProps[] = [ + { + id: ALL_ALERTS.status, + label: ALL_ALERTS.label, + value: ALL_ALERTS.query, + 'data-test-subj': 'alert-status-filter-show-all-button', + }, + { + id: ACTIVE_ALERTS.status, + label: ACTIVE_ALERTS.label, + value: ACTIVE_ALERTS.query, + 'data-test-subj': 'alert-status-filter-active-button', + }, + { + id: RECOVERED_ALERTS.status, + label: RECOVERED_ALERTS.label, + value: RECOVERED_ALERTS.query, + 'data-test-subj': 'alert-status-filter-recovered-button', + }, +]; + +export function AlertsStatusFilter({ status, onChange }: AlertStatusFilterProps) { + return ( + + ); +} diff --git a/x-pack/plugins/observability/public/pages/alerts/containers/state_container/index.tsx b/x-pack/plugins/observability/public/components/shared/alert_search_bar/components/index.ts similarity index 62% rename from x-pack/plugins/observability/public/pages/alerts/containers/state_container/index.tsx rename to x-pack/plugins/observability/public/components/shared/alert_search_bar/components/index.ts index c057ec901686e..ceabe0e31ba5a 100644 --- a/x-pack/plugins/observability/public/pages/alerts/containers/state_container/index.tsx +++ b/x-pack/plugins/observability/public/components/shared/alert_search_bar/components/index.ts @@ -5,5 +5,4 @@ * 2.0. */ -export { Provider, alertsPageStateContainer } from './state_container'; -export { useAlertsPageStateContainer } from './use_alerts_page_state_container'; +export { AlertsStatusFilter } from './alerts_status_filter'; diff --git a/x-pack/plugins/observability/public/pages/alerts/components/alerts_status_filter.tsx b/x-pack/plugins/observability/public/components/shared/alert_search_bar/constants.ts similarity index 55% rename from x-pack/plugins/observability/public/pages/alerts/components/alerts_status_filter.tsx rename to x-pack/plugins/observability/public/components/shared/alert_search_bar/constants.ts index 5ef1d98f133c3..9c5bfe4d0620e 100644 --- a/x-pack/plugins/observability/public/pages/alerts/components/alerts_status_filter.tsx +++ b/x-pack/plugins/observability/public/components/shared/alert_search_bar/constants.ts @@ -5,17 +5,12 @@ * 2.0. */ -import { EuiButtonGroup, EuiButtonGroupOptionProps } from '@elastic/eui'; +import { Query } from '@kbn/es-query'; import { i18n } from '@kbn/i18n'; -import React from 'react'; import { ALERT_STATUS_ACTIVE, ALERT_STATUS_RECOVERED, ALERT_STATUS } from '@kbn/rule-data-utils'; -import { AlertStatus } from '../../../../common/typings'; import { AlertStatusFilter } from '../../../../common/typings'; -export interface AlertStatusFilterProps { - status: AlertStatus; - onChange: (id: string, value: string) => void; -} +export const DEFAULT_QUERIES: Query[] = []; export const ALL_ALERTS: AlertStatusFilter = { status: '', @@ -45,36 +40,3 @@ export const ALERT_STATUS_QUERY = { [ACTIVE_ALERTS.status]: ACTIVE_ALERTS.query, [RECOVERED_ALERTS.status]: RECOVERED_ALERTS.query, }; - -const options: EuiButtonGroupOptionProps[] = [ - { - id: ALL_ALERTS.status, - label: ALL_ALERTS.label, - value: ALL_ALERTS.query, - 'data-test-subj': 'alert-status-filter-show-all-button', - }, - { - id: ACTIVE_ALERTS.status, - label: ACTIVE_ALERTS.label, - value: ACTIVE_ALERTS.query, - 'data-test-subj': 'alert-status-filter-active-button', - }, - { - id: RECOVERED_ALERTS.status, - label: RECOVERED_ALERTS.label, - value: RECOVERED_ALERTS.query, - 'data-test-subj': 'alert-status-filter-recovered-button', - }, -]; - -export function AlertsStatusFilter({ status, onChange }: AlertStatusFilterProps) { - return ( - - ); -} diff --git a/x-pack/plugins/observability/public/components/shared/alert_search_bar/containers/index.tsx b/x-pack/plugins/observability/public/components/shared/alert_search_bar/containers/index.tsx new file mode 100644 index 0000000000000..edffc97a57fa2 --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/alert_search_bar/containers/index.tsx @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { Provider, alertSearchBarStateContainer } from './state_container'; +export { useAlertSearchBarStateContainer } from './use_alert_search_bar_state_container'; diff --git a/x-pack/plugins/observability/public/components/shared/alert_search_bar/containers/state_container.tsx b/x-pack/plugins/observability/public/components/shared/alert_search_bar/containers/state_container.tsx new file mode 100644 index 0000000000000..1905181356786 --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/alert_search_bar/containers/state_container.tsx @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + createStateContainer, + createStateContainerReactHelpers, +} from '@kbn/kibana-utils-plugin/public'; +import { AlertStatus } from '../../../../../common/typings'; +import { ALL_ALERTS } from '../constants'; + +interface AlertSearchBarContainerState { + rangeFrom: string; + rangeTo: string; + kuery: string; + status: AlertStatus; +} + +interface AlertSearchBarStateTransitions { + setRangeFrom: ( + state: AlertSearchBarContainerState + ) => (rangeFrom: string) => AlertSearchBarContainerState; + setRangeTo: ( + state: AlertSearchBarContainerState + ) => (rangeTo: string) => AlertSearchBarContainerState; + setKuery: ( + state: AlertSearchBarContainerState + ) => (kuery: string) => AlertSearchBarContainerState; + setStatus: ( + state: AlertSearchBarContainerState + ) => (status: AlertStatus) => AlertSearchBarContainerState; +} + +const defaultState: AlertSearchBarContainerState = { + rangeFrom: 'now-15m', + rangeTo: 'now', + kuery: '', + status: ALL_ALERTS.status as AlertStatus, +}; + +const transitions: AlertSearchBarStateTransitions = { + setRangeFrom: (state) => (rangeFrom) => ({ ...state, rangeFrom }), + setRangeTo: (state) => (rangeTo) => ({ ...state, rangeTo }), + setKuery: (state) => (kuery) => ({ ...state, kuery }), + setStatus: (state) => (status) => ({ ...state, status }), +}; + +const alertSearchBarStateContainer = createStateContainer(defaultState, transitions); + +type AlertSearchBarStateContainer = typeof alertSearchBarStateContainer; + +const { Provider, useContainer } = createStateContainerReactHelpers(); + +export { Provider, alertSearchBarStateContainer, useContainer, defaultState }; +export type { + AlertSearchBarStateContainer, + AlertSearchBarContainerState, + AlertSearchBarStateTransitions, +}; diff --git a/x-pack/plugins/observability/public/pages/alerts/containers/state_container/use_alerts_page_state_container.tsx b/x-pack/plugins/observability/public/components/shared/alert_search_bar/containers/use_alert_search_bar_state_container.tsx similarity index 85% rename from x-pack/plugins/observability/public/pages/alerts/containers/state_container/use_alerts_page_state_container.tsx rename to x-pack/plugins/observability/public/components/shared/alert_search_bar/containers/use_alert_search_bar_state_container.tsx index 7a6f13be0e864..b53f1717e8eb1 100644 --- a/x-pack/plugins/observability/public/pages/alerts/containers/state_container/use_alerts_page_state_container.tsx +++ b/x-pack/plugins/observability/public/components/shared/alert_search_bar/containers/use_alert_search_bar_state_container.tsx @@ -20,11 +20,11 @@ import { useTimefilterService } from '../../../../hooks/use_timefilter_service'; import { useContainer, defaultState, - AlertsPageStateContainer, - AlertsPageContainerState, + AlertSearchBarStateContainer, + AlertSearchBarContainerState, } from './state_container'; -export function useAlertsPageStateContainer() { +export function useAlertSearchBarStateContainer() { const stateContainer = useContainer(); useUrlStateSyncEffect(stateContainer); @@ -47,7 +47,7 @@ export function useAlertsPageStateContainer() { }; } -function useUrlStateSyncEffect(stateContainer: AlertsPageStateContainer) { +function useUrlStateSyncEffect(stateContainer: AlertSearchBarStateContainer) { const history = useHistory(); const timefilterService = useTimefilterService(); @@ -68,11 +68,11 @@ function useUrlStateSyncEffect(stateContainer: AlertsPageStateContainer) { } function setupUrlStateSync( - stateContainer: AlertsPageStateContainer, + stateContainer: AlertSearchBarStateContainer, stateStorage: IKbnUrlStateStorage ) { // This handles filling the state when an incomplete URL set is provided - const setWithDefaults = (changedState: Partial | null) => { + const setWithDefaults = (changedState: Partial | null) => { stateContainer.set({ ...defaultState, ...changedState }); }; @@ -88,10 +88,10 @@ function setupUrlStateSync( function syncUrlStateWithInitialContainerState( timefilterService: TimefilterContract, - stateContainer: AlertsPageStateContainer, + stateContainer: AlertSearchBarStateContainer, urlStateStorage: IKbnUrlStateStorage ) { - const urlState = urlStateStorage.get>('_a'); + const urlState = urlStateStorage.get>('_a'); if (urlState) { const newState = { diff --git a/x-pack/plugins/observability/public/components/shared/alert_search_bar/index.ts b/x-pack/plugins/observability/public/components/shared/alert_search_bar/index.ts new file mode 100644 index 0000000000000..02e1a4faccdff --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/alert_search_bar/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { AlertSearchBar as ObservabilityAlertSearchBar } from './alert_search_bar'; +export { AlertSearchbarWithUrlSync as ObservabilityAlertSearchbarWithUrlSync } from './alert_search_bar_with_url_sync'; diff --git a/x-pack/plugins/observability/public/components/shared/alert_search_bar/types.ts b/x-pack/plugins/observability/public/components/shared/alert_search_bar/types.ts new file mode 100644 index 0000000000000..cd58d90777af4 --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/alert_search_bar/types.ts @@ -0,0 +1,39 @@ +/* + * 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 { BoolQuery, Query } from '@kbn/es-query'; +import { AlertStatus } from '../../../../common/typings'; + +export interface AlertStatusFilterProps { + status: AlertStatus; + onChange: (id: string, value: string) => void; +} + +interface AlertSearchBarContainerState { + rangeFrom: string; + rangeTo: string; + kuery: string; + status: AlertStatus; +} + +interface AlertSearchBarStateTransitions { + setRangeFrom: (rangeFrom: string) => AlertSearchBarContainerState; + setRangeTo: (rangeTo: string) => AlertSearchBarContainerState; + setKuery: (kuery: string) => AlertSearchBarContainerState; + setStatus: (status: AlertStatus) => AlertSearchBarContainerState; +} + +export interface AlertSearchBarWithUrlSyncProps { + appName: string; + setEsQuery: (query: { bool: BoolQuery }) => void; + queries?: Query[]; +} + +export interface AlertSearchBarProps + extends AlertSearchBarContainerState, + AlertSearchBarStateTransitions, + AlertSearchBarWithUrlSyncProps {} diff --git a/x-pack/plugins/observability/public/pages/alerts/components/alerts_flyout/alerts_flyout_body.test.tsx b/x-pack/plugins/observability/public/pages/alerts/components/alerts_flyout/alerts_flyout_body.test.tsx index 019b7a974e874..8d143429a2994 100644 --- a/x-pack/plugins/observability/public/pages/alerts/components/alerts_flyout/alerts_flyout_body.test.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/components/alerts_flyout/alerts_flyout_body.test.tsx @@ -11,7 +11,7 @@ import { createObservabilityRuleTypeRegistryMock } from '../../../../rules/obser import AlertsFlyoutBody from './alerts_flyout_body'; import { inventoryThresholdAlert } from '../../../../rules/fixtures/example_alerts'; import { parseAlert } from '../parse_alert'; -import { RULE_DETAILS_PAGE_ID } from '../../../rule_details/types'; +import { RULE_DETAILS_PAGE_ID } from '../../../rule_details/constants'; describe('AlertsFlyoutBody', () => { jest diff --git a/x-pack/plugins/observability/public/pages/alerts/components/alerts_flyout/alerts_flyout_body.tsx b/x-pack/plugins/observability/public/pages/alerts/components/alerts_flyout/alerts_flyout_body.tsx index 9a8b97df31939..56daa3a4f8f9b 100644 --- a/x-pack/plugins/observability/public/pages/alerts/components/alerts_flyout/alerts_flyout_body.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/components/alerts_flyout/alerts_flyout_body.tsx @@ -26,7 +26,7 @@ import { } from '@kbn/rule-data-utils'; import moment from 'moment-timezone'; import { useKibana, useUiSetting } from '@kbn/kibana-react-plugin/public'; -import { RULE_DETAILS_PAGE_ID } from '../../../rule_details/types'; +import { RULE_DETAILS_PAGE_ID } from '../../../rule_details/constants'; import { asDuration } from '../../../../../common/utils/formatters'; import { translations, paths } from '../../../../config'; import { AlertStatusIndicator } from '../../../../components/shared/alert_status_indicator'; diff --git a/x-pack/plugins/observability/public/pages/alerts/components/alerts_search_bar.tsx b/x-pack/plugins/observability/public/pages/alerts/components/alerts_search_bar.tsx new file mode 100644 index 0000000000000..1fec1c76430eb --- /dev/null +++ b/x-pack/plugins/observability/public/pages/alerts/components/alerts_search_bar.tsx @@ -0,0 +1,6 @@ +/* + * 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. + */ diff --git a/x-pack/plugins/observability/public/pages/alerts/components/index.ts b/x-pack/plugins/observability/public/pages/alerts/components/index.ts index 0f3422049d2c6..1955514d6c7b0 100644 --- a/x-pack/plugins/observability/public/pages/alerts/components/index.ts +++ b/x-pack/plugins/observability/public/pages/alerts/components/index.ts @@ -11,4 +11,3 @@ export * from './severity_badge'; export * from './workflow_status_filter'; export * from './filter_for_value'; export * from './parse_alert'; -export * from './alerts_status_filter'; diff --git a/x-pack/plugins/observability/public/pages/alerts/components/observability_actions.test.tsx b/x-pack/plugins/observability/public/pages/alerts/components/observability_actions.test.tsx index 12159285c88dc..f984fe29bd503 100644 --- a/x-pack/plugins/observability/public/pages/alerts/components/observability_actions.test.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/components/observability_actions.test.tsx @@ -10,7 +10,7 @@ import React from 'react'; import { mountWithIntl, nextTick } from '@kbn/test-jest-helpers'; import { ObservabilityActions, ObservabilityActionsProps } from './observability_actions'; import { inventoryThresholdAlert } from '../../../rules/fixtures/example_alerts'; -import { RULE_DETAILS_PAGE_ID } from '../../rule_details/types'; +import { RULE_DETAILS_PAGE_ID } from '../../rule_details/constants'; import { createObservabilityRuleTypeRegistryMock } from '../../../rules/observability_rule_type_registry_mock'; import { TimelineNonEcsData } from '@kbn/timelines-plugin/common'; import * as pluginContext from '../../../hooks/use_plugin_context'; diff --git a/x-pack/plugins/observability/public/pages/alerts/components/observability_actions.tsx b/x-pack/plugins/observability/public/pages/alerts/components/observability_actions.tsx index 0583b9a35eb64..b90955dbe93f0 100644 --- a/x-pack/plugins/observability/public/pages/alerts/components/observability_actions.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/components/observability_actions.tsx @@ -26,7 +26,7 @@ import { parseAlert } from './parse_alert'; import { translations, paths } from '../../../config'; import { ADD_TO_EXISTING_CASE, ADD_TO_NEW_CASE } from '../containers/alerts_table/translations'; import { ObservabilityAppServices } from '../../../application/types'; -import { RULE_DETAILS_PAGE_ID } from '../../rule_details/types'; +import { RULE_DETAILS_PAGE_ID } from '../../rule_details/constants'; import type { TopAlert } from '../containers/alerts_page/types'; import { ObservabilityRuleTypeRegistry } from '../../..'; import { ALERT_DETAILS_PAGE_ID } from '../../alert_details/types'; diff --git a/x-pack/plugins/observability/public/pages/alerts/containers/alerts_page/alerts_page.tsx b/x-pack/plugins/observability/public/pages/alerts/containers/alerts_page/alerts_page.tsx index ead8a2e89e117..7480726177293 100644 --- a/x-pack/plugins/observability/public/pages/alerts/containers/alerts_page/alerts_page.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/containers/alerts_page/alerts_page.tsx @@ -7,13 +7,13 @@ import { EuiFlexGroup, EuiFlexItem, EuiFlyoutSize } from '@elastic/eui'; -import React, { useCallback, useEffect, useState } from 'react'; -import { Query, BoolQuery } from '@kbn/es-query'; +import React, { useEffect, useState } from 'react'; +import { BoolQuery } from '@kbn/es-query'; import { i18n } from '@kbn/i18n'; import { useKibana } from '@kbn/kibana-react-plugin/public'; import { loadRuleAggregations } from '@kbn/triggers-actions-ui-plugin/public'; import { AlertConsumers } from '@kbn/rule-data-utils'; -import { AlertStatus } from '../../../../../common/typings'; +import { ObservabilityAlertSearchbarWithUrlSync } from '../../../../components/shared/alert_search_bar'; import { observabilityAlertFeatureIds } from '../../../../config'; import { useGetUserCasesPermissions } from '../../../../hooks/use_get_user_cases_permissions'; import { observabilityFeatureId } from '../../../../../common'; @@ -21,42 +21,23 @@ import { useBreadcrumbs } from '../../../../hooks/use_breadcrumbs'; import { useHasData } from '../../../../hooks/use_has_data'; import { usePluginContext } from '../../../../hooks/use_plugin_context'; import { getNoDataConfig } from '../../../../utils/no_data_config'; -import { buildEsQuery } from '../../../../utils/build_es_query'; import { LoadingObservability } from '../../../overview'; -import { - Provider, - alertsPageStateContainer, - useAlertsPageStateContainer, -} from '../state_container'; import './styles.scss'; -import { AlertsStatusFilter, ALERT_STATUS_QUERY } from '../../components'; import { renderRuleStats } from '../../components/rule_stats'; import { ObservabilityAppServices } from '../../../../application/types'; -import { ALERTS_PER_PAGE, ALERTS_TABLE_ID } from './constants'; +import { ALERTS_PER_PAGE, ALERTS_SEARCH_BAR_ID, ALERTS_TABLE_ID } from './constants'; import { RuleStatsState } from './types'; -const getAlertStatusQuery = (status: string): Query[] => { - return status ? [{ query: ALERT_STATUS_QUERY[status], language: 'kuery' }] : []; -}; - -function AlertsPage() { +export function AlertsPage() { const { ObservabilityPageTemplate, observabilityRuleTypeRegistry } = usePluginContext(); const { cases, docLinks, http, notifications: { toasts }, - triggersActionsUi: { - alertsTableConfigurationRegistry, - getAlertsStateTable: AlertsStateTable, - getAlertsSearchBar: AlertsSearchBar, - }, - data: { - query: { - timefilter: { timefilter: timeFilterService }, - }, - }, + triggersActionsUi: { alertsTableConfigurationRegistry, getAlertsStateTable: AlertsStateTable }, } = useKibana().services; + const [ruleStatsLoading, setRuleStatsLoading] = useState(false); const [ruleStats, setRuleStats] = useState({ total: 0, @@ -66,18 +47,7 @@ function AlertsPage() { snoozed: 0, }); const { hasAnyData, isAllRequestsComplete } = useHasData(); - const { rangeFrom, setRangeFrom, rangeTo, setRangeTo, kuery, setKuery, status, setStatus } = - useAlertsPageStateContainer(); - const [esQuery, setEsQuery] = useState<{ bool: BoolQuery }>( - buildEsQuery( - { - to: rangeTo, - from: rangeFrom, - }, - kuery, - getAlertStatusQuery(status) - ) - ); + const [esQuery, setEsQuery] = useState<{ bool: BoolQuery }>(); useBreadcrumbs([ { @@ -129,46 +99,6 @@ function AlertsPage() { const manageRulesHref = http.basePath.prepend('/app/observability/alerts/rules'); - const onStatusChange = useCallback( - (alertStatus: AlertStatus) => { - setEsQuery( - buildEsQuery( - { - to: rangeTo, - from: rangeFrom, - }, - kuery, - getAlertStatusQuery(alertStatus) - ) - ); - }, - [kuery, rangeFrom, rangeTo] - ); - - useEffect(() => { - onStatusChange(status); - }, [onStatusChange, status]); - - const onSearchBarParamsChange = useCallback( - ({ dateRange, query }) => { - timeFilterService.setTime(dateRange); - setRangeFrom(dateRange.from); - setRangeTo(dateRange.to); - setKuery(query); - setEsQuery( - buildEsQuery( - { - to: rangeTo, - from: rangeFrom, - }, - query, - getAlertStatusQuery(status) - ) - ); - }, - [rangeFrom, setRangeFrom, rangeTo, status, setRangeTo, setKuery, timeFilterService] - ); - // If there is any data, set hasData to true otherwise we need to wait till all the data is loaded before setting hasData to true or false; undefined indicates the data is still loading. const hasData = hasAnyData === true || (isAllRequestsComplete === false ? undefined : false); @@ -199,56 +129,33 @@ function AlertsPage() { > - - - - - { - setStatus(id as AlertStatus); - }} - /> - - - - - + {esQuery && ( + + )} ); } - -export function WrappedAlertsPage() { - return ( - - - - ); -} diff --git a/x-pack/plugins/observability/public/pages/alerts/containers/alerts_page/constants.ts b/x-pack/plugins/observability/public/pages/alerts/containers/alerts_page/constants.ts index 47a6ef7f8752c..31113bd8221e0 100644 --- a/x-pack/plugins/observability/public/pages/alerts/containers/alerts_page/constants.ts +++ b/x-pack/plugins/observability/public/pages/alerts/containers/alerts_page/constants.ts @@ -6,5 +6,6 @@ */ export const ALERTS_PAGE_ID = 'alerts-o11y'; +export const ALERTS_SEARCH_BAR_ID = 'alerts-search-bar-o11y'; export const ALERTS_PER_PAGE = 50; export const ALERTS_TABLE_ID = 'xpack.observability.alerts.alert.table'; diff --git a/x-pack/plugins/observability/public/pages/alerts/containers/alerts_page/index.ts b/x-pack/plugins/observability/public/pages/alerts/containers/alerts_page/index.ts index 0be17d63b7291..8f9b6c26b5427 100644 --- a/x-pack/plugins/observability/public/pages/alerts/containers/alerts_page/index.ts +++ b/x-pack/plugins/observability/public/pages/alerts/containers/alerts_page/index.ts @@ -5,5 +5,5 @@ * 2.0. */ -export { WrappedAlertsPage as AlertsPage } from './alerts_page'; +export { AlertsPage } from './alerts_page'; export type { TopAlert } from './types'; diff --git a/x-pack/plugins/observability/public/pages/alerts/containers/index.ts b/x-pack/plugins/observability/public/pages/alerts/containers/index.ts index 23b65105b7881..957a0dbdb906d 100644 --- a/x-pack/plugins/observability/public/pages/alerts/containers/index.ts +++ b/x-pack/plugins/observability/public/pages/alerts/containers/index.ts @@ -6,4 +6,3 @@ */ export * from './alerts_page'; -export * from './state_container'; diff --git a/x-pack/plugins/observability/public/pages/alerts/containers/state_container/state_container.tsx b/x-pack/plugins/observability/public/pages/alerts/containers/state_container/state_container.tsx deleted file mode 100644 index 1ddbd151a12ea..0000000000000 --- a/x-pack/plugins/observability/public/pages/alerts/containers/state_container/state_container.tsx +++ /dev/null @@ -1,52 +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 { - createStateContainer, - createStateContainerReactHelpers, -} from '@kbn/kibana-utils-plugin/public'; -import { AlertStatus } from '../../../../../common/typings'; -import { ALL_ALERTS } from '../..'; - -interface AlertsPageContainerState { - rangeFrom: string; - rangeTo: string; - kuery: string; - status: AlertStatus; -} - -interface AlertsPageStateTransitions { - setRangeFrom: ( - state: AlertsPageContainerState - ) => (rangeFrom: string) => AlertsPageContainerState; - setRangeTo: (state: AlertsPageContainerState) => (rangeTo: string) => AlertsPageContainerState; - setKuery: (state: AlertsPageContainerState) => (kuery: string) => AlertsPageContainerState; - setStatus: (state: AlertsPageContainerState) => (status: AlertStatus) => AlertsPageContainerState; -} - -const defaultState: AlertsPageContainerState = { - rangeFrom: 'now-15m', - rangeTo: 'now', - kuery: '', - status: ALL_ALERTS.status as AlertStatus, -}; - -const transitions: AlertsPageStateTransitions = { - setRangeFrom: (state) => (rangeFrom) => ({ ...state, rangeFrom }), - setRangeTo: (state) => (rangeTo) => ({ ...state, rangeTo }), - setKuery: (state) => (kuery) => ({ ...state, kuery }), - setStatus: (state) => (status) => ({ ...state, status }), -}; - -const alertsPageStateContainer = createStateContainer(defaultState, transitions); - -type AlertsPageStateContainer = typeof alertsPageStateContainer; - -const { Provider, useContainer } = createStateContainerReactHelpers(); - -export { Provider, alertsPageStateContainer, useContainer, defaultState }; -export type { AlertsPageStateContainer, AlertsPageContainerState, AlertsPageStateTransitions }; diff --git a/x-pack/plugins/observability/public/pages/rule_details/constants.ts b/x-pack/plugins/observability/public/pages/rule_details/constants.ts new file mode 100644 index 0000000000000..f204749427f8c --- /dev/null +++ b/x-pack/plugins/observability/public/pages/rule_details/constants.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const EXECUTION_TAB = 'execution'; +export const ALERTS_TAB = 'alerts'; +export const EVENT_ERROR_LOG_TAB = 'rule_error_log_list'; +export const RULE_DETAILS_PAGE_ID = 'rule-details-alerts-o11y'; +export const RULE_DETAILS_ALERTS_SEARCH_BAR_ID = 'rule-details-alerts-search-bar-o11y'; diff --git a/x-pack/plugins/observability/public/pages/rule_details/index.tsx b/x-pack/plugins/observability/public/pages/rule_details/index.tsx index 27bbc1fc0f4a0..1350a613c169c 100644 --- a/x-pack/plugins/observability/public/pages/rule_details/index.tsx +++ b/x-pack/plugins/observability/public/pages/rule_details/index.tsx @@ -6,7 +6,7 @@ */ import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'; -import { useParams } from 'react-router-dom'; +import { useHistory, useParams, useLocation } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; import { EuiText, @@ -14,13 +14,14 @@ import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, - EuiFlyoutSize, EuiPanel, EuiPopover, EuiTabbedContent, EuiEmptyPrompt, EuiSuperSelectOption, EuiButton, + EuiFlyoutSize, + EuiTabbedContentTab, } from '@elastic/eui'; import { @@ -32,17 +33,21 @@ import { } from '@kbn/triggers-actions-ui-plugin/public'; // TODO: use a Delete modal from triggersActionUI when it's sharable import { ALERTS_FEATURE_ID, RuleExecutionStatusErrorReasons } from '@kbn/alerting-plugin/common'; +import { Query, BoolQuery } from '@kbn/es-query'; import { AlertConsumers } from '@kbn/rule-data-utils'; import { RuleDefinitionProps } from '@kbn/triggers-actions-ui-plugin/public'; import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { fromQuery, toQuery } from '../../utils/url'; +import { ObservabilityAlertSearchbarWithUrlSync } from '../../components/shared/alert_search_bar'; import { DeleteModalConfirmation } from './components/delete_modal_confirmation'; import { CenterJustifiedSpinner } from './components/center_justified_spinner'; import { - RuleDetailsPathParams, - EVENT_LOG_LIST_TAB, - ALERT_LIST_TAB, + EXECUTION_TAB, + ALERTS_TAB, RULE_DETAILS_PAGE_ID, -} from './types'; + RULE_DETAILS_ALERTS_SEARCH_BAR_ID, +} from './constants'; +import { RuleDetailsPathParams, TabId } from './types'; import { useBreadcrumbs } from '../../hooks/use_breadcrumbs'; import { usePluginContext } from '../../hooks/use_plugin_context'; import { useFetchRule } from '../../hooks/use_fetch_rule'; @@ -63,7 +68,7 @@ export function RuleDetailsPage() { ruleTypeRegistry, getEditAlertFlyout, getRuleEventLogList, - getAlertsStateTable, + getAlertsStateTable: AlertsStateTable, getRuleAlertsSummary, getRuleStatusPanel, getRuleDefinition, @@ -74,6 +79,8 @@ export function RuleDetailsPage() { const { ruleId } = useParams(); const { ObservabilityPageTemplate, observabilityRuleTypeRegistry } = usePluginContext(); + const history = useHistory(); + const location = useLocation(); const filteredRuleTypes = useMemo( () => observabilityRuleTypeRegistry.list(), @@ -84,12 +91,34 @@ export function RuleDetailsPage() { const { ruleTypes } = useLoadRuleTypes({ filteredRuleTypes, }); + const [tabId, setTabId] = useState( + (toQuery(location.search)?.tabId as TabId) || EXECUTION_TAB + ); const [features, setFeatures] = useState(''); const [ruleType, setRuleType] = useState>(); const [ruleToDelete, setRuleToDelete] = useState([]); const [isPageLoading, setIsPageLoading] = useState(false); const [editFlyoutVisible, setEditFlyoutVisible] = useState(false); const [isRuleEditPopoverOpen, setIsRuleEditPopoverOpen] = useState(false); + const [esQuery, setEsQuery] = useState<{ bool: BoolQuery }>(); + const ruleQuery = useRef([ + { query: `kibana.alert.rule.uuid: ${ruleId}`, language: 'kuery' }, + ] as Query[]); + + const updateUrl = (nextQuery: { tabId: TabId }) => { + history.push({ + ...location, + search: fromQuery({ + ...toQuery(location.search), + ...nextQuery, + }), + }); + }; + + const onTabIdChange = (newTabId: TabId) => { + setTabId(newTabId); + updateUrl({ tabId: newTabId }); + }; const NOTIFY_WHEN_OPTIONS = useRef>>([]); useEffect(() => { @@ -157,40 +186,26 @@ export function RuleDetailsPage() { ? !ruleTypeRegistry.get(rule.ruleTypeId).requiresAppContext : false); - const alertStateProps = { - alertsTableConfigurationRegistry, - configurationId: observabilityFeatureId, - id: RULE_DETAILS_PAGE_ID, - flyoutSize: 's' as EuiFlyoutSize, - featureIds: [features] as AlertConsumers[], - query: { - bool: { - filter: [ - { - term: { - 'kibana.alert.rule.uuid': ruleId, - }, - }, - ], - }, - }, - showExpandToDetails: false, - }; - - const tabs = [ + const tabs: EuiTabbedContentTab[] = [ { - id: EVENT_LOG_LIST_TAB, + id: EXECUTION_TAB, name: i18n.translate('xpack.observability.ruleDetails.rule.eventLogTabText', { defaultMessage: 'Execution history', }), 'data-test-subj': 'eventLogListTab', - content: getRuleEventLogList<'default'>({ - ruleId: rule?.id, - ruleType, - } as RuleEventLogListProps), + content: ( + + + {getRuleEventLogList<'default'>({ + ruleId: rule?.id, + ruleType, + } as RuleEventLogListProps)} + + + ), }, { - id: ALERT_LIST_TAB, + id: ALERTS_TAB, name: i18n.translate('xpack.observability.ruleDetails.rule.alertsTabText', { defaultMessage: 'Alerts', }), @@ -198,7 +213,27 @@ export function RuleDetailsPage() { content: ( <> - {getAlertsStateTable(alertStateProps)} + + + + + {esQuery && ( + + )} + + ), }, @@ -324,7 +359,14 @@ export function RuleDetailsPage() { - + tab.id === tabId)} + onTabClick={(tab) => { + onTabIdChange(tab.id as TabId); + }} + /> {editFlyoutVisible && getEditAlertFlyout({ initialRule: rule, diff --git a/x-pack/plugins/observability/public/pages/rule_details/types.ts b/x-pack/plugins/observability/public/pages/rule_details/types.ts index 0baaf78ce756b..ead002266e53e 100644 --- a/x-pack/plugins/observability/public/pages/rule_details/types.ts +++ b/x-pack/plugins/observability/public/pages/rule_details/types.ts @@ -6,12 +6,10 @@ */ import { HttpSetup } from '@kbn/core/public'; -import { - Rule, - RuleSummary, - RuleType, - ActionTypeRegistryContract, -} from '@kbn/triggers-actions-ui-plugin/public'; +import { Rule, RuleSummary, RuleType } from '@kbn/triggers-actions-ui-plugin/public'; +import { ALERTS_TAB, EXECUTION_TAB } from './constants'; + +export type TabId = typeof ALERTS_TAB | typeof EXECUTION_TAB; export interface RuleDetailsPathParams { ruleId: string; @@ -36,15 +34,6 @@ export interface FetchRuleSummaryProps { ruleId: string; http: HttpSetup; } -export interface FetchRuleActionConnectorsProps { - http: HttpSetup; - ruleActions: any[]; -} - -export interface FetchRuleExecutionLogProps { - http: HttpSetup; - ruleId: string; -} export interface FetchRuleSummary { isLoadingRuleSummary: boolean; @@ -65,19 +54,3 @@ export interface AlertListItem { isMuted: boolean; sortPriority: number; } -export interface ItemTitleRuleSummaryProps { - children: string; -} -export interface ItemValueRuleSummaryProps { - itemValue: string; - extraSpace?: boolean; -} -export interface ActionsProps { - ruleActions: any[]; - actionTypeRegistry: ActionTypeRegistryContract; -} - -export const EVENT_LOG_LIST_TAB = 'rule_event_log_list'; -export const ALERT_LIST_TAB = 'rule_alert_list'; -export const EVENT_ERROR_LOG_TAB = 'rule_error_log_list'; -export const RULE_DETAILS_PAGE_ID = 'rule-details-alerts-o11y'; diff --git a/x-pack/plugins/observability/public/utils/url.test.ts b/x-pack/plugins/observability/public/utils/url.test.ts new file mode 100644 index 0000000000000..7a28232f09254 --- /dev/null +++ b/x-pack/plugins/observability/public/utils/url.test.ts @@ -0,0 +1,93 @@ +/* + * 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 { toQuery, fromQuery } from './url'; + +describe('toQuery', () => { + it('should parse string to object', () => { + expect(toQuery('?foo=bar&name=john%20doe')).toEqual({ + foo: 'bar', + name: 'john doe', + }); + }); +}); + +describe('fromQuery', () => { + it('should not encode the following characters', () => { + expect( + fromQuery({ + a: true, + b: 5000, + c: ':', + }) + ).toEqual('a=true&b=5000&c=:'); + }); + + it('should encode the following characters', () => { + expect( + fromQuery({ + a: '@', + b: '.', + c: ';', + d: ' ', + }) + ).toEqual('a=%40&b=.&c=%3B&d=%20'); + }); + + it('should handle null and undefined', () => { + expect( + fromQuery({ + a: undefined, + b: null, + }) + ).toEqual('a=&b='); + }); + + it('should handle arrays', () => { + expect( + fromQuery({ + arr: ['a', 'b'], + }) + ).toEqual('arr=a%2Cb'); + }); + + it('should parse object to string', () => { + expect( + fromQuery({ + traceId: 'bar', + transactionId: 'john doe', + }) + ).toEqual('traceId=bar&transactionId=john%20doe'); + }); + + it('should not encode range params', () => { + expect( + fromQuery({ + rangeFrom: '2019-03-03T12:00:00.000Z', + rangeTo: '2019-03-05T12:00:00.000Z', + }) + ).toEqual('rangeFrom=2019-03-03T12:00:00.000Z&rangeTo=2019-03-05T12:00:00.000Z'); + }); + + it('should handle undefined, boolean, and number values without throwing errors', () => { + expect( + fromQuery({ + flyoutDetailTab: undefined, + refreshPaused: true, + refreshInterval: 5000, + }) + ).toEqual('flyoutDetailTab=&refreshPaused=true&refreshInterval=5000'); + }); +}); + +describe('fromQuery and toQuery', () => { + it('should encode and decode correctly', () => { + expect( + fromQuery(toQuery('?name=john%20doe&path=a%2Fb&rangeFrom=2019-03-03T12:00:00.000Z')) + ).toEqual('name=john%20doe&path=a%2Fb&rangeFrom=2019-03-03T12:00:00.000Z'); + }); +}); From 452b81f0e78252691c4f6325daef9a25d443c221 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Wed, 9 Nov 2022 16:22:52 +0100 Subject: [PATCH 2/4] [Lens] Rearrange options (#144891) Fixes https://github.com/elastic/kibana/issues/138075 ## Group by this field This PR moves the "Group by this field" below the field selector. I renamed it to "Aggregate by this dimension first" (instead of field) because it's more accurate - this isn't really about the field and also a thing for the "Filters" function which doesn't have a field: Screenshot 2022-11-09 at 12 29 29 Screenshot 2022-11-09 at 12 29 33 Screenshot 2022-11-09 at 12 29 38 Screenshot 2022-11-09 at 12 29 44 Drive-by change: Move the "Include empty rows" up above the granularity slider for intervals to match the date histogram order ## Collapse by It also moves "Collapse by" into the data section (relevant for xy breakdown, table rows and partition slices) Screenshot 2022-11-09 at 12 36 53 Screenshot 2022-11-09 at 12 36 59 Screenshot 2022-11-09 at 12 36 40 Screenshot 2022-11-09 at 12 36 46 Also works for text based: Screenshot 2022-11-09 at 12 47 08 ## Technical details For the collapse by part I had to add another form render hook to the visualization `renderDimensionEditorDataExtra` which is passed to the datasource dimension editor so it can be rendered in the right place. On the datasource side the integration for text based is very straight forward, for form based there are two flavors - just render below the operation edit options or pass to the operation editor (used for terms) --- .../dimension_panel/bucket_nesting_editor.tsx | 40 ++++++------ .../dimension_panel/dimension_editor.tsx | 24 ++++--- .../operations/definitions/index.ts | 5 ++ .../definitions/ranges/range_editor.tsx | 34 +++++----- .../operations/definitions/terms/index.tsx | 8 +++ .../text_based/text_based_languages.tsx | 11 ++++ .../editor_frame/config_panel/layer_panel.tsx | 17 +++++ .../shared_components/collapse_setting.tsx | 62 ++++++++++--------- x-pack/plugins/lens/public/types.ts | 13 +++- .../datatable/components/dimension_editor.tsx | 39 ++++++++---- .../datatable/visualization.tsx | 13 +++- .../visualizations/partition/toolbar.tsx | 17 +++++ .../partition/visualization.tsx | 12 +++- .../visualizations/xy/visualization.tsx | 44 ++++++++++++- .../xy/xy_config_panel/dimension_editor.tsx | 37 +++++++++-- 15 files changed, 279 insertions(+), 97 deletions(-) diff --git a/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/bucket_nesting_editor.tsx b/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/bucket_nesting_editor.tsx index fb062e4231467..f8e7584662b0c 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/bucket_nesting_editor.tsx +++ b/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/bucket_nesting_editor.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiFormRow, EuiSwitch, EuiSelect } from '@elastic/eui'; +import { EuiFormRow, EuiSwitch, EuiSelect, EuiSpacer, EuiText } from '@elastic/eui'; import { FormBasedLayer } from '../types'; import { hasField } from '../pure_utils'; import { GenericIndexPatternColumn } from '../operations'; @@ -62,26 +62,28 @@ export function BucketNestingEditor({ if (aggColumns.length === 1) { const [target] = aggColumns; const useAsTopLevelAggCopy = i18n.translate('xpack.lens.indexPattern.useAsTopLevelAgg', { - defaultMessage: 'Group by this field first', + defaultMessage: 'Aggregate by this dimension first', }); return ( - - { - if (prevColumn) { - setColumns(nestColumn(layer.columnOrder, columnId, target.value)); - } else { - setColumns(nestColumn(layer.columnOrder, target.value, columnId)); - } - }} - /> - + <> + + + {useAsTopLevelAggCopy}} + data-test-subj="indexPattern-nesting-switch" + name="nestingSwitch" + checked={!prevColumn} + onChange={() => { + if (prevColumn) { + setColumns(nestColumn(layer.columnOrder, columnId, target.value)); + } else { + setColumns(nestColumn(layer.columnOrder, target.value, columnId)); + } + }} + compressed + /> + + ); } diff --git a/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/dimension_editor.tsx b/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/dimension_editor.tsx index 97dabaca05c03..a1a61c4b8447b 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/dimension_editor.tsx +++ b/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/dimension_editor.tsx @@ -606,6 +606,7 @@ export function DimensionEditor(props: DimensionEditorProps) { setIsCloseable, paramEditorCustomProps, ReferenceEditor, + dataSectionExtra: props.dataSectionExtra, ...services, }; @@ -834,8 +835,22 @@ export function DimensionEditor(props: DimensionEditorProps) { operationDefinitionMap={operationDefinitionMap} /> ) : null} + {!isFullscreen && !incompleteInfo && !hideGrouping && temporaryState === 'none' && ( + updateLayer({ columnOrder })} + getFieldByName={currentIndexPattern.getFieldByName} + /> + )} {shouldDisplayExtraOptions && } + {!selectedOperationDefinition?.handleDataSectionExtra && ( + <> + + {props.dataSectionExtra} + + )} ); @@ -1145,15 +1160,6 @@ export function DimensionEditor(props: DimensionEditorProps) { /> )} - {!isFullscreen && !incompleteInfo && !hideGrouping && temporaryState === 'none' && ( - updateLayer({ columnOrder })} - getFieldByName={currentIndexPattern.getFieldByName} - /> - )} - {enableFormatSelector && !isFullscreen && selectedColumn && diff --git a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/index.ts b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/index.ts index 1ed621b19b8bd..628a7be8b6752 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/index.ts +++ b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/index.ts @@ -199,6 +199,7 @@ export interface ParamEditorProps< operationDefinitionMap: Record; paramEditorCustomProps?: ParamEditorCustomProps; isReferenced?: boolean; + dataSectionExtra?: React.ReactNode; } export interface FieldInputProps { @@ -443,6 +444,10 @@ interface BaseOperationDefinitionProps< * more than 5 values returned or 6 if the "Other" bucket is enabled) */ getMaxPossibleNumValues?: (column: C) => number; + /** + * Boolean flag whether the data section extra element passed in from the visualization is handled by the param editor of the operation or whether the datasource general logic should be used. + */ + handleDataSectionExtra?: boolean; } interface BaseBuildColumnArgs { diff --git a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/ranges/range_editor.tsx b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/ranges/range_editor.tsx index 1f7565c9d31d6..d6941417713e2 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/ranges/range_editor.tsx +++ b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/ranges/range_editor.tsx @@ -121,6 +121,23 @@ const BaseRangeEditor = ({ return ( <> + + + + {i18n.translate('xpack.lens.indexPattern.ranges.includeEmptyRows', { + defaultMessage: 'Include empty rows', + })} + + } + checked={Boolean(includeEmptyRows)} + onChange={() => { + onChangeIncludeEmptyRows(!includeEmptyRows); + }} + compressed + /> + - - - - {i18n.translate('xpack.lens.indexPattern.ranges.includeEmptyRows', { - defaultMessage: 'Include empty rows', - })} - - } - checked={Boolean(includeEmptyRows)} - onChange={() => { - onChangeIncludeEmptyRows(!includeEmptyRows); - }} - compressed - /> - ); }; diff --git a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/terms/index.tsx b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/terms/index.tsx index b1ff05a6dbb78..8214aa45910b8 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/terms/index.tsx +++ b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/terms/index.tsx @@ -560,6 +560,7 @@ export const termsOperation: OperationDefinition< The top values of a specified field ranked by the chosen metric. `, }), + handleDataSectionExtra: true, paramEditor: function ParamEditor({ layer, paramEditorUpdater, @@ -570,6 +571,7 @@ The top values of a specified field ranked by the chosen metric. ReferenceEditor, paramEditorCustomProps, activeData, + dataSectionExtra, ...rest }) { const [incompleteColumn, setIncompleteColumn] = useState( @@ -929,6 +931,12 @@ The top values of a specified field ranked by the chosen metric. }} /> + {dataSectionExtra && ( + <> + + {dataSectionExtra} + + )} {!hasRestrictions && ( <> diff --git a/x-pack/plugins/lens/public/datasources/text_based/text_based_languages.tsx b/x-pack/plugins/lens/public/datasources/text_based/text_based_languages.tsx index 287feb17e4fa9..ff5ca989139e1 100644 --- a/x-pack/plugins/lens/public/datasources/text_based/text_based_languages.tsx +++ b/x-pack/plugins/lens/public/datasources/text_based/text_based_languages.tsx @@ -18,6 +18,7 @@ import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; import type { ExpressionsStart, DatatableColumnType } from '@kbn/expressions-plugin/public'; import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; import { DataPublicPluginStart } from '@kbn/data-plugin/public'; +import { euiThemeVars } from '@kbn/ui-theme'; import { DatasourceDimensionEditorProps, DatasourceDataPanelProps, @@ -482,6 +483,16 @@ export function getTextBasedDatasource({ }} /> + {props.dataSectionExtra && ( +
+ {props.dataSectionExtra} +
+ )} , domElement ); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx index 69f661e92fb43..6d95002bf9079 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx @@ -730,6 +730,23 @@ export function LayerPanel( layerType: activeVisualization.getLayerType(layerId, visualizationState), indexPatterns: dataViews.indexPatterns, activeData: layerVisualizationConfigProps.activeData, + dataSectionExtra: !isFullscreen && + !activeDimension.isNew && + activeVisualization.renderDimensionEditorDataExtra && ( + + ), }} /> )} diff --git a/x-pack/plugins/lens/public/shared_components/collapse_setting.tsx b/x-pack/plugins/lens/public/shared_components/collapse_setting.tsx index 113498798220b..bbaf5296a4e28 100644 --- a/x-pack/plugins/lens/public/shared_components/collapse_setting.tsx +++ b/x-pack/plugins/lens/public/shared_components/collapse_setting.tsx @@ -26,36 +26,38 @@ export function CollapseSetting({ onChange: (value: CollapseFunction) => void; }) { return ( - - - {i18n.translate('xpack.lens.collapse.label', { defaultMessage: 'Collapse by' })} - {''} - - - - } - display="columnCompressed" - fullWidth - > - + + + {i18n.translate('xpack.lens.collapse.label', { defaultMessage: 'Collapse by' })} + {''} + + + + } + display="rowCompressed" fullWidth - compressed - data-test-subj="indexPattern-collapse-by" - options={options} - value={value} - onChange={(e: React.ChangeEvent) => { - onChange(e.target.value as CollapseFunction); - }} - /> - + > + ) => { + onChange(e.target.value as CollapseFunction); + }} + /> + + ); } diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index bccc472c3f898..b42f3c4ae0411 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -617,6 +617,7 @@ export type DatasourceDimensionEditorProps = DatasourceDimensionPro supportStaticValue: boolean; paramEditorCustomProps?: ParamEditorCustomProps; enableFormatSelector: boolean; + dataSectionExtra?: React.ReactNode; formatSelectorOptions: FormatSelectorOptions | undefined; }; @@ -1131,7 +1132,7 @@ export interface Visualization { ) => ((cleanupElement: Element) => void) | void; /** - * Additional editor that gets rendered inside the dimension popover. + * Additional editor that gets rendered inside the dimension popover in the "appearance" section. * This can be used to configure dimension-specific options */ renderDimensionEditor?: ( @@ -1139,13 +1140,21 @@ export interface Visualization { props: VisualizationDimensionEditorProps ) => ((cleanupElement: Element) => void) | void; /** - * Additional editor that gets rendered inside the dimension popover. + * Additional editor that gets rendered inside the dimension popover in an additional section below "appearance". * This can be used to configure dimension-specific options */ renderDimensionEditorAdditionalSection?: ( domElement: Element, props: VisualizationDimensionEditorProps ) => ((cleanupElement: Element) => void) | void; + /** + * Additional editor that gets rendered inside the data section. + * This can be used to configure dimension-specific options + */ + renderDimensionEditorDataExtra?: ( + domElement: Element, + props: VisualizationDimensionEditorProps + ) => ((cleanupElement: Element) => void) | void; /** * Renders dimension trigger. Used only for noDatasource layers */ diff --git a/x-pack/plugins/lens/public/visualizations/datatable/components/dimension_editor.tsx b/x-pack/plugins/lens/public/visualizations/datatable/components/dimension_editor.tsx index 2c0f9f41cb35f..09f1bc93d6779 100644 --- a/x-pack/plugins/lens/public/visualizations/datatable/components/dimension_editor.tsx +++ b/x-pack/plugins/lens/public/visualizations/datatable/components/dimension_editor.tsx @@ -95,17 +95,6 @@ export function TableDimensionEditor( return ( <> - {props.groupId === 'rows' && ( - { - setState({ - ...state, - columns: updateColumnWith(state, accessor, { collapseFn }), - }); - }} - /> - )} ); } + +export function TableDimensionDataExtraEditor( + props: VisualizationDimensionEditorProps & { + paletteService: PaletteRegistry; + } +) { + const { state, setState, accessor } = props; + const column = state.columns.find(({ columnId }) => accessor === columnId); + + if (!column) return null; + if (column.isTransposed) return null; + + return ( + <> + {props.groupId === 'rows' && ( + { + setState({ + ...state, + columns: updateColumnWith(state, accessor, { collapseFn }), + }); + }} + /> + )} + + ); +} diff --git a/x-pack/plugins/lens/public/visualizations/datatable/visualization.tsx b/x-pack/plugins/lens/public/visualizations/datatable/visualization.tsx index 88bd970b49bb7..1b4511f65c86c 100644 --- a/x-pack/plugins/lens/public/visualizations/datatable/visualization.tsx +++ b/x-pack/plugins/lens/public/visualizations/datatable/visualization.tsx @@ -25,7 +25,7 @@ import type { DatasourceLayers, Suggestion, } from '../../types'; -import { TableDimensionEditor } from './components/dimension_editor'; +import { TableDimensionDataExtraEditor, TableDimensionEditor } from './components/dimension_editor'; import { TableDimensionEditorAdditionalSection } from './components/dimension_editor_addtional_section'; import type { LayerType } from '../../../common'; import { getDefaultSummaryLabel } from '../../../common/expressions/datatable/summary'; @@ -359,6 +359,17 @@ export const getDatatableVisualization = ({ ); }, + renderDimensionEditorDataExtra(domElement, props) { + render( + + + + + , + domElement + ); + }, + getSupportedLayers() { return [ { diff --git a/x-pack/plugins/lens/public/visualizations/partition/toolbar.tsx b/x-pack/plugins/lens/public/visualizations/partition/toolbar.tsx index 10d34a4f891ae..e92ce29c4d368 100644 --- a/x-pack/plugins/lens/public/visualizations/partition/toolbar.tsx +++ b/x-pack/plugins/lens/public/visualizations/partition/toolbar.tsx @@ -329,6 +329,23 @@ export function DimensionEditor( }} /> )} + + ); +} + +export function DimensionDataExtraEditor( + props: VisualizationDimensionEditorProps & { + paletteService: PaletteRegistry; + } +) { + const currentLayer = props.state.layers.find((layer) => layer.layerId === props.layerId); + + if (!currentLayer) { + return null; + } + + return ( + <> { diff --git a/x-pack/plugins/lens/public/visualizations/partition/visualization.tsx b/x-pack/plugins/lens/public/visualizations/partition/visualization.tsx index 57118c04ed721..25e9ec7070f75 100644 --- a/x-pack/plugins/lens/public/visualizations/partition/visualization.tsx +++ b/x-pack/plugins/lens/public/visualizations/partition/visualization.tsx @@ -36,7 +36,7 @@ import { } from '../../../common'; import { suggestions } from './suggestions'; import { PartitionChartsMeta } from './partition_charts_meta'; -import { DimensionEditor, PieToolbar } from './toolbar'; +import { DimensionDataExtraEditor, DimensionEditor, PieToolbar } from './toolbar'; import { checkTableForContainsSmallValues } from './render_helpers'; function newLayerState(layerId: string): PieLayerState { @@ -358,6 +358,16 @@ export const getPieVisualization = ({ domElement ); }, + renderDimensionEditorDataExtra(domElement, props) { + render( + + + + + , + domElement + ); + }, getSupportedLayers() { return [ diff --git a/x-pack/plugins/lens/public/visualizations/xy/visualization.tsx b/x-pack/plugins/lens/public/visualizations/xy/visualization.tsx index 47966ea309e3e..725f27aec96c4 100644 --- a/x-pack/plugins/lens/public/visualizations/xy/visualization.tsx +++ b/x-pack/plugins/lens/public/visualizations/xy/visualization.tsx @@ -29,7 +29,10 @@ import { } from '../../utils'; import { getSuggestions } from './xy_suggestions'; import { XyToolbar } from './xy_config_panel'; -import { DimensionEditor } from './xy_config_panel/dimension_editor'; +import { + DataDimensionEditorDataSectionExtra, + DimensionEditor, +} from './xy_config_panel/dimension_editor'; import { LayerHeader, LayerHeaderContent } from './xy_config_panel/layer_header'; import type { Visualization, AccessorConfig, FramePublicAPI, Suggestion } from '../../types'; import type { FormBasedPersistedState } from '../../datasources/form_based/types'; @@ -607,6 +610,45 @@ export const getXyVisualization = ({ ); }, + renderDimensionEditorDataExtra(domElement, props) { + const allProps = { + ...props, + datatableUtilities: data.datatableUtilities, + formatFactory: fieldFormats.deserialize, + paletteService, + }; + const layer = props.state.layers.find((l) => l.layerId === props.layerId)!; + if (isReferenceLayer(layer)) { + return; + } + if (isAnnotationsLayer(layer)) { + return; + } + + render( + + + + + + + , + domElement + ); + }, + toExpression: (state, layers, attributes, datasourceExpressionsByLayers = {}) => toExpression( state, diff --git a/x-pack/plugins/lens/public/visualizations/xy/xy_config_panel/dimension_editor.tsx b/x-pack/plugins/lens/public/visualizations/xy/xy_config_panel/dimension_editor.tsx index 37ecde6594936..d7a1a5b1fc105 100644 --- a/x-pack/plugins/lens/public/visualizations/xy/xy_config_panel/dimension_editor.tsx +++ b/x-pack/plugins/lens/public/visualizations/xy/xy_config_panel/dimension_editor.tsx @@ -130,12 +130,6 @@ export function DataDimensionEditor( if (props.groupId === 'breakdown') { return ( <> - { - setLocalState(updateLayer(localState, { ...layer, collapseFn }, index)); - }} - /> {!layer.collapseFn && ( ); } + +export function DataDimensionEditorDataSectionExtra( + props: VisualizationDimensionEditorProps & { + formatFactory: FormatFactory; + paletteService: PaletteRegistry; + } +) { + const { state, layerId } = props; + const index = state.layers.findIndex((l) => l.layerId === layerId); + const layer = state.layers[index] as XYDataLayerConfig; + + const { inputValue: localState, handleInputChange: setLocalState } = useDebouncedValue({ + value: props.state, + onChange: props.setState, + }); + + if (props.groupId === 'breakdown') { + return ( + <> + { + setLocalState(updateLayer(localState, { ...layer, collapseFn }, index)); + }} + /> + + ); + } + + return null; +} From 3d7b01e28bb1c42bb57c63e6ee00381acb720eea Mon Sep 17 00:00:00 2001 From: Yaroslav Kuznietsov Date: Wed, 9 Nov 2022 17:41:36 +0200 Subject: [PATCH 3/4] [Lens][TSVB] Ad-hoc dataViews for index pattern string mode in TSVB. (#143500) ## Summary Completes part of https://github.com/elastic/kibana/issues/138236. Added support of ad-hoc dataViews while converting TSVB visualizations, when index pattern string mode is turned on. Co-authored-by: Uladzislau Lasitsa Co-authored-by: Joe Reuter Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../timeseries/common/index_patterns_utils.ts | 2 +- .../field_text_select.tsx | 52 ++-- .../convert_to_lens/gauge/index.test.ts | 35 ++- .../public/convert_to_lens/gauge/index.ts | 185 +++++++------- .../public/convert_to_lens/index.test.ts | 11 - .../public/convert_to_lens/index.ts | 4 - .../lib/configurations/xy/layers.test.ts | 90 +++++-- .../lib/configurations/xy/layers.ts | 28 +- .../lib/datasource/datasource_info.test.ts | 170 ++++++++++++ .../lib/datasource/datasource_info.ts | 127 +++++++++ .../datasource/get_datasource_info.test.ts | 78 ------ .../lib/datasource/get_datasource_info.ts | 59 ----- .../convert_to_lens/lib/datasource/index.ts | 2 +- .../convert_to_lens/metric/index.test.ts | 16 +- .../public/convert_to_lens/metric/index.ts | 186 +++++++------- .../convert_to_lens/table/index.test.ts | 6 +- .../public/convert_to_lens/table/index.ts | 241 +++++++++--------- .../convert_to_lens/timeseries/index.test.ts | 18 +- .../convert_to_lens/timeseries/index.ts | 183 ++++++------- .../convert_to_lens/top_n/index.test.ts | 14 +- .../public/convert_to_lens/top_n/index.ts | 119 +++++---- .../public/convert_to_lens/types.ts | 3 +- .../lens/public/data_views_service/loader.ts | 31 +-- .../public/datasources/form_based/loader.ts | 4 +- .../editor_frame/state_helpers.ts | 36 ++- x-pack/plugins/lens/public/utils.ts | 5 + 26 files changed, 1006 insertions(+), 699 deletions(-) create mode 100644 src/plugins/vis_types/timeseries/public/convert_to_lens/lib/datasource/datasource_info.test.ts create mode 100644 src/plugins/vis_types/timeseries/public/convert_to_lens/lib/datasource/datasource_info.ts delete mode 100644 src/plugins/vis_types/timeseries/public/convert_to_lens/lib/datasource/get_datasource_info.test.ts delete mode 100644 src/plugins/vis_types/timeseries/public/convert_to_lens/lib/datasource/get_datasource_info.ts diff --git a/src/plugins/vis_types/timeseries/common/index_patterns_utils.ts b/src/plugins/vis_types/timeseries/common/index_patterns_utils.ts index 0328e28380bd2..9abf46bbcf93d 100644 --- a/src/plugins/vis_types/timeseries/common/index_patterns_utils.ts +++ b/src/plugins/vis_types/timeseries/common/index_patterns_utils.ts @@ -13,7 +13,7 @@ import type { Panel, IndexPatternValue, FetchedIndexPattern } from './types'; export const isStringTypeIndexPattern = ( indexPatternValue: IndexPatternValue -): indexPatternValue is string => typeof indexPatternValue === 'string'; +): indexPatternValue is string => typeof indexPatternValue === 'string' && indexPatternValue !== ''; export const isDataViewTypeIndexPattern = ( indexPatternValue: IndexPatternValue diff --git a/src/plugins/vis_types/timeseries/public/application/components/lib/index_pattern_select/field_text_select.tsx b/src/plugins/vis_types/timeseries/public/application/components/lib/index_pattern_select/field_text_select.tsx index 682279d5639e5..06c8c9f90e8cf 100644 --- a/src/plugins/vis_types/timeseries/public/application/components/lib/index_pattern_select/field_text_select.tsx +++ b/src/plugins/vis_types/timeseries/public/application/components/lib/index_pattern_select/field_text_select.tsx @@ -6,13 +6,16 @@ * Side Public License, v 1. */ import React, { useCallback, useState, useEffect } from 'react'; -import useDebounce from 'react-use/lib/useDebounce'; +import { i18n } from '@kbn/i18n'; -import { EuiFieldText, EuiFieldTextProps } from '@elastic/eui'; +import { EuiFieldText, EuiFieldTextProps, EuiButtonIcon } from '@elastic/eui'; import { SwitchModePopover } from './switch_mode_popover'; - import type { SelectIndexComponentProps } from './types'; +const updateIndexText = i18n.translate('visTypeTimeseries.indexPatternSelect.updateIndex', { + defaultMessage: 'Update visualization with entered data view', +}); + export const FieldTextSelect = ({ fetchedIndex, onIndexChange, @@ -35,15 +38,30 @@ export const FieldTextSelect = ({ } }, [indexPatternString, inputValue]); - useDebounce( - () => { - if ((inputValue ?? '') !== (indexPatternString ?? '')) { - onIndexChange(inputValue); - } - }, - 150, - [inputValue, onIndexChange] - ); + const updateIndex = useCallback(() => { + if ((inputValue ?? '') !== (indexPatternString ?? '')) { + onIndexChange(inputValue); + } + }, [onIndexChange, inputValue, indexPatternString]); + + const appends = [ + , + ]; + + if (allowSwitchMode) { + appends.push( + + ); + } return ( - ), - })} + append={appends} /> ); }; diff --git a/src/plugins/vis_types/timeseries/public/convert_to_lens/gauge/index.test.ts b/src/plugins/vis_types/timeseries/public/convert_to_lens/gauge/index.test.ts index 9943cfa627e23..a4d611748d343 100644 --- a/src/plugins/vis_types/timeseries/public/convert_to_lens/gauge/index.test.ts +++ b/src/plugins/vis_types/timeseries/public/convert_to_lens/gauge/index.test.ts @@ -22,7 +22,7 @@ const mockIsValidMetrics = jest.fn(); const mockGetDatasourceValue = jest .fn() .mockImplementation(() => Promise.resolve(stubLogstashDataView)); -const mockGetDataSourceInfo = jest.fn(); +const mockExtractOrGenerateDatasourceInfo = jest.fn(); const mockGetSeriesAgg = jest.fn(); jest.mock('../../services', () => ({ @@ -50,7 +50,7 @@ jest.mock('../lib/metrics', () => { }); jest.mock('../lib/datasource', () => ({ - getDataSourceInfo: jest.fn(() => mockGetDataSourceInfo()), + extractOrGenerateDatasourceInfo: jest.fn(() => mockExtractOrGenerateDatasourceInfo()), })); describe('convertToLens', () => { @@ -77,7 +77,7 @@ describe('convertToLens', () => { beforeEach(() => { mockIsValidMetrics.mockReturnValue(true); - mockGetDataSourceInfo.mockReturnValue({ + mockExtractOrGenerateDatasourceInfo.mockReturnValue({ indexPatternId: 'test-index-pattern', timeField: 'timeField', indexPattern: { id: 'test-index-pattern' }, @@ -126,7 +126,7 @@ describe('convertToLens', () => { }, } as Vis); expect(result).toBeNull(); - expect(mockGetDataSourceInfo).toBeCalledTimes(0); + expect(mockExtractOrGenerateDatasourceInfo).toBeCalledTimes(0); }); test('should return null if only series agg is specified', async () => { @@ -177,4 +177,31 @@ describe('convertToLens', () => { expect(result).toBeDefined(); expect(result?.type).toBe('lnsMetric'); }); + + test('should drop adhoc dataviews if action is required', async () => { + mockGetMetricsColumns.mockReturnValue([metricColumn]); + mockGetSeriesAgg.mockReturnValue({ metrics: [metric] }); + mockGetConfigurationForGauge.mockReturnValue({}); + + const result = await convertToLens( + { + params: createPanel({ + series: [ + createSeries({ + metrics: [{ id: 'some-id', type: METRIC_TYPES.AVG, field: 'test-field' }], + hidden: false, + }), + createSeries({ + metrics: [{ id: 'some-id', type: METRIC_TYPES.AVG, field: 'test-field' }], + hidden: false, + }), + ], + }), + } as Vis, + undefined, + true + ); + expect(result).toBeDefined(); + expect(result?.type).toBe('lnsMetric'); + }); }); diff --git a/src/plugins/vis_types/timeseries/public/convert_to_lens/gauge/index.ts b/src/plugins/vis_types/timeseries/public/convert_to_lens/gauge/index.ts index 87d5333d4be51..c6a2bd4429b7b 100644 --- a/src/plugins/vis_types/timeseries/public/convert_to_lens/gauge/index.ts +++ b/src/plugins/vis_types/timeseries/public/convert_to_lens/gauge/index.ts @@ -16,7 +16,7 @@ import { import { PANEL_TYPES, TSVB_METRIC_TYPES } from '../../../common/enums'; import { Metric } from '../../../common/types'; import { getDataViewsStart } from '../../services'; -import { getDataSourceInfo } from '../lib/datasource'; +import { extractOrGenerateDatasourceInfo } from '../lib/datasource'; import { getMetricsColumns, getBucketsColumns } from '../lib/series'; import { getConfigurationForGauge as getConfiguration } from '../lib/configurations/metric'; import { @@ -45,98 +45,107 @@ const getMaxFormula = (metric: Metric, column?: Column) => { }))`; }; +const invalidModelError = () => new Error('Invalid model'); + export const convertToLens: ConvertTsvbToLensVisualization = async ( { params: model }, timeRange ) => { const dataViews = getDataViewsStart(); - - const series = model.series[0]; - // not valid time shift - if (series.offset_time && parseTimeShift(series.offset_time) === 'invalid') { - return null; - } - - if (!isValidMetrics(series.metrics, PANEL_TYPES.GAUGE, series.time_range_mode)) { - return null; - } - - if (series.metrics[series.metrics.length - 1].type === TSVB_METRIC_TYPES.STATIC) { - return null; - } - - const reducedTimeRange = getReducedTimeRange(model, series, timeRange); - const datasourceInfo = await getDataSourceInfo( - model.index_pattern, - model.time_field, - Boolean(series.override_index_pattern), - series.series_index_pattern, - series.series_time_field, - dataViews - ); - - if (!datasourceInfo) { - return null; - } - - const { indexPatternId, indexPattern } = datasourceInfo; - - // handle multiple metrics - const metricsColumns = getMetricsColumns(series, indexPattern!, model.series.length, { - reducedTimeRange, - }); - if (metricsColumns === null) { - return null; - } - - const bucketsColumns = getBucketsColumns(model, series, metricsColumns, indexPattern!, false); - - if (bucketsColumns === null) { + try { + const series = model.series[0]; + // not valid time shift + if (series.offset_time && parseTimeShift(series.offset_time) === 'invalid') { + throw invalidModelError(); + } + + if (!isValidMetrics(series.metrics, PANEL_TYPES.GAUGE, series.time_range_mode)) { + throw invalidModelError(); + } + + if (series.metrics[series.metrics.length - 1].type === TSVB_METRIC_TYPES.STATIC) { + throw invalidModelError(); + } + + const reducedTimeRange = getReducedTimeRange(model, series, timeRange); + const datasourceInfo = await extractOrGenerateDatasourceInfo( + model.index_pattern, + model.time_field, + Boolean(series.override_index_pattern), + series.series_index_pattern, + series.series_time_field, + dataViews + ); + + if (!datasourceInfo) { + throw invalidModelError(); + } + + const { indexPatternId, indexPattern } = datasourceInfo; + + // handle multiple metrics + const metricsColumns = getMetricsColumns(series, indexPattern!, model.series.length, { + reducedTimeRange, + }); + if (metricsColumns === null) { + throw invalidModelError(); + } + + const bucketsColumns = getBucketsColumns(model, series, metricsColumns, indexPattern!, false); + + if (bucketsColumns === null) { + throw invalidModelError(); + } + + const [bucket] = bucketsColumns; + + const extendedLayer: ExtendedLayer = { + indexPatternId, + layerId: uuid(), + columns: [...metricsColumns, ...(bucket ? [bucket] : [])], + columnOrder: [], + }; + + const primarySeries = model.series[0]; + const primaryMetricWithCollapseFn = getMetricWithCollapseFn(primarySeries); + + if (!primaryMetricWithCollapseFn || !primaryMetricWithCollapseFn.metric) { + throw invalidModelError(); + } + + const primaryColumn = findMetricColumn( + primaryMetricWithCollapseFn.metric, + extendedLayer.columns + ); + if (!primaryColumn) { + throw invalidModelError(); + } + + let gaugeMaxColumn: StaticValueColumn | FormulaColumn | null = createFormulaColumnWithoutMeta( + getMaxFormula(primaryMetricWithCollapseFn.metric, primaryColumn) + ); + if (model.gauge_max !== undefined && model.gauge_max !== '') { + gaugeMaxColumn = createStaticValueColumn(model.gauge_max); + } + + const layer = { + ...extendedLayer, + columns: [...extendedLayer.columns, gaugeMaxColumn], + }; + const configuration = getConfiguration(model, layer, bucket, gaugeMaxColumn ?? undefined); + if (!configuration) { + throw invalidModelError(); + } + + const layers = Object.values(excludeMetaFromLayers({ 0: layer })); + + return { + type: 'lnsMetric', + layers, + configuration, + indexPatternIds: getIndexPatternIds(layers), + }; + } catch (e) { return null; } - - const [bucket] = bucketsColumns; - - const extendedLayer: ExtendedLayer = { - indexPatternId, - layerId: uuid(), - columns: [...metricsColumns, ...(bucket ? [bucket] : [])], - columnOrder: [], - }; - - const primarySeries = model.series[0]; - const primaryMetricWithCollapseFn = getMetricWithCollapseFn(primarySeries); - - if (!primaryMetricWithCollapseFn || !primaryMetricWithCollapseFn.metric) { - return null; - } - - const primaryColumn = findMetricColumn(primaryMetricWithCollapseFn.metric, extendedLayer.columns); - if (!primaryColumn) { - return null; - } - - let gaugeMaxColumn: StaticValueColumn | FormulaColumn | null = createFormulaColumnWithoutMeta( - getMaxFormula(primaryMetricWithCollapseFn.metric, primaryColumn) - ); - if (model.gauge_max !== undefined && model.gauge_max !== '') { - gaugeMaxColumn = createStaticValueColumn(model.gauge_max); - } - - const layer = { - ...extendedLayer, - columns: [...extendedLayer.columns, gaugeMaxColumn], - }; - const configuration = getConfiguration(model, layer, bucket, gaugeMaxColumn ?? undefined); - if (!configuration) { - return null; - } - - const layers = Object.values(excludeMetaFromLayers({ 0: layer })); - return { - type: 'lnsMetric', - layers, - configuration, - indexPatternIds: getIndexPatternIds(layers), - }; }; diff --git a/src/plugins/vis_types/timeseries/public/convert_to_lens/index.test.ts b/src/plugins/vis_types/timeseries/public/convert_to_lens/index.test.ts index a97395f64c113..6ef4d1daa2ce0 100644 --- a/src/plugins/vis_types/timeseries/public/convert_to_lens/index.test.ts +++ b/src/plugins/vis_types/timeseries/public/convert_to_lens/index.test.ts @@ -48,15 +48,4 @@ describe('convertTSVBtoLensConfiguration', () => { } as Vis); expect(triggerOptions).toBeNull(); }); - - test('should return null for a string index pattern', async () => { - const stringIndexPatternModel = { - ...model, - use_kibana_indexes: false, - }; - const triggerOptions = await convertTSVBtoLensConfiguration({ - params: stringIndexPatternModel, - } as Vis); - expect(triggerOptions).toBeNull(); - }); }); diff --git a/src/plugins/vis_types/timeseries/public/convert_to_lens/index.ts b/src/plugins/vis_types/timeseries/public/convert_to_lens/index.ts index 3e1982aa0903e..7fe152b838125 100644 --- a/src/plugins/vis_types/timeseries/public/convert_to_lens/index.ts +++ b/src/plugins/vis_types/timeseries/public/convert_to_lens/index.ts @@ -45,10 +45,6 @@ const getConvertFnByType = (type: PANEL_TYPES) => { * In case of null, the menu item is disabled and the user can't navigate to Lens. */ export const convertTSVBtoLensConfiguration = async (vis: Vis, timeRange?: TimeRange) => { - // Disables the option for not supported charts, for the string mode and for series with annotations - if (!vis.params.use_kibana_indexes) { - return null; - } // Disables if model is invalid if (vis.params.isModelInvalid) { return null; diff --git a/src/plugins/vis_types/timeseries/public/convert_to_lens/lib/configurations/xy/layers.test.ts b/src/plugins/vis_types/timeseries/public/convert_to_lens/lib/configurations/xy/layers.test.ts index 3bb4b743c2588..b9aa49e3be5c9 100644 --- a/src/plugins/vis_types/timeseries/public/convert_to_lens/lib/configurations/xy/layers.test.ts +++ b/src/plugins/vis_types/timeseries/public/convert_to_lens/lib/configurations/xy/layers.test.ts @@ -20,10 +20,16 @@ import { createPanel, createSeries } from '../../__mocks__'; import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; import type { DataView } from '@kbn/data-views-plugin/public'; +const mockExtractOrGenerateDatasourceInfo = jest.fn(); + jest.mock('uuid', () => ({ v4: () => 'test-id', })); +jest.mock('../../datasource', () => ({ + extractOrGenerateDatasourceInfo: jest.fn(() => mockExtractOrGenerateDatasourceInfo()), +})); + const mockedIndices = [ { id: 'test', @@ -31,6 +37,12 @@ const mockedIndices = [ timeFieldName: 'test_field', getFieldByName: (name: string) => ({ aggregatable: name !== 'host' }), }, + { + id: 'test2', + title: 'test2', + timeFieldName: 'test_field', + getFieldByName: (name: string) => ({ aggregatable: name !== 'host' }), + }, ] as unknown as DataView[]; const indexPatternsService = { @@ -356,6 +368,14 @@ describe('getLayers', () => { ], series: [createSeries({ metrics: staticValueMetric })], }); + beforeEach(() => { + jest.clearAllMocks(); + mockExtractOrGenerateDatasourceInfo.mockReturnValue({ + indexPattern: mockedIndices[0], + indexPatternId: mockedIndices[0].id, + timeField: mockedIndices[0].timeFieldName, + }); + }); test.each< [ @@ -568,9 +588,31 @@ describe('getLayers', () => { }, ], ], - [ - 'multiple annotations with different data views create separate layers', - [dataSourceLayersWithStatic, panelWithMultiAnnotations, indexPatternsService, false], + ])('should return %s', async (_, input, expected) => { + const layers = await getLayers(...input); + expect(layers).toEqual(expected.map(expect.objectContaining)); + }); + + test('should return multiple annotations with different data views create separate layers', async () => { + mockExtractOrGenerateDatasourceInfo.mockReturnValueOnce({ + indexPattern: mockedIndices[0], + indexPatternId: mockedIndices[0].id, + timeField: mockedIndices[0].timeFieldName, + }); + mockExtractOrGenerateDatasourceInfo.mockReturnValueOnce({ + indexPattern: mockedIndices[1], + indexPatternId: mockedIndices[1].id, + timeField: mockedIndices[1].timeFieldName, + }); + + const layers = await getLayers( + dataSourceLayersWithStatic, + panelWithMultiAnnotations, + indexPatternsService, + false + ); + + expect(layers).toEqual( [ { layerType: 'referenceLine', @@ -634,7 +676,7 @@ describe('getLayers', () => { type: 'query', }, ], - indexPatternId: 'test', + indexPatternId: 'test2', }, { layerId: 'test-id', @@ -659,18 +701,28 @@ describe('getLayers', () => { type: 'query', }, ], - indexPatternId: 'test2', + indexPatternId: 'test', }, - ], - ], - [ - 'annotation layer gets correct dataView when none is defined', - [ - dataSourceLayersWithStatic, - panelWithSingleAnnotationDefaultDataView, - indexPatternsService, - false, - ], + ].map(expect.objectContaining) + ); + expect(mockExtractOrGenerateDatasourceInfo).toBeCalledTimes(3); + }); + + test('should return annotation layer gets correct dataView when none is defined', async () => { + mockExtractOrGenerateDatasourceInfo.mockReturnValue({ + indexPattern: { ...mockedIndices[0], id: 'default' }, + indexPatternId: 'default', + timeField: mockedIndices[0].timeFieldName, + }); + + const layers = await getLayers( + dataSourceLayersWithStatic, + panelWithSingleAnnotationDefaultDataView, + indexPatternsService, + false + ); + + expect(layers).toEqual( [ { layerType: 'referenceLine', @@ -712,10 +764,8 @@ describe('getLayers', () => { ], indexPatternId: 'default', }, - ], - ], - ])('should return %s', async (_, input, expected) => { - const layers = await getLayers(...input); - expect(layers).toEqual(expected.map(expect.objectContaining)); + ].map(expect.objectContaining) + ); + expect(mockExtractOrGenerateDatasourceInfo).toBeCalledTimes(1); }); }); diff --git a/src/plugins/vis_types/timeseries/public/convert_to_lens/lib/configurations/xy/layers.ts b/src/plugins/vis_types/timeseries/public/convert_to_lens/lib/configurations/xy/layers.ts index 8784c2952807d..0757c9e7b5802 100644 --- a/src/plugins/vis_types/timeseries/public/convert_to_lens/lib/configurations/xy/layers.ts +++ b/src/plugins/vis_types/timeseries/public/convert_to_lens/lib/configurations/xy/layers.ts @@ -21,7 +21,6 @@ import { euiLightVars } from '@kbn/ui-theme'; import { groupBy } from 'lodash'; import { DataViewsPublicPluginStart, DataView } from '@kbn/data-plugin/public/data_views'; import { getDefaultQueryLanguage } from '../../../../application/components/lib/get_default_query_language'; -import { fetchIndexPattern } from '../../../../../common/index_patterns_utils'; import { ICON_TYPES_MAP } from '../../../../application/visualizations/constants'; import { SUPPORTED_METRICS } from '../../metrics'; import type { Annotation, Metric, Panel, Series } from '../../../../../common/types'; @@ -34,6 +33,7 @@ import { AnyColumnWithReferences, } from '../../convert'; import { getChartType } from './chart_type'; +import { extractOrGenerateDatasourceInfo } from '../../datasource'; export const isColumnWithReference = (column: Column): column is AnyColumnWithReferences => Boolean((column as AnyColumnWithReferences).references); @@ -144,22 +144,32 @@ export const getLayers = async ( } const annotationsByIndexPatternAndIgnoreFlag = groupBy(model.annotations, (a) => { - const id = typeof a.index_pattern === 'object' && 'id' in a.index_pattern && a.index_pattern.id; - return `${id}-${Boolean(a.ignore_global_filters)}`; + const id = + typeof a.index_pattern === 'object' && 'id' in a.index_pattern + ? a.index_pattern.id + : a.index_pattern; + return `${id}-${a.time_field ?? ''}-${Boolean(a.ignore_global_filters)}`; }); try { const annotationsLayers: Array = await Promise.all( Object.values(annotationsByIndexPatternAndIgnoreFlag).map(async (annotations) => { const [firstAnnotation] = annotations; - const indexPatternId = - typeof firstAnnotation.index_pattern === 'string' - ? firstAnnotation.index_pattern - : firstAnnotation.index_pattern?.id; const convertedAnnotations: EventAnnotationConfig[] = []; - const { indexPattern } = - (await fetchIndexPattern(indexPatternId && { id: indexPatternId }, dataViews)) || {}; + const result = await extractOrGenerateDatasourceInfo( + firstAnnotation.index_pattern, + firstAnnotation.time_field, + false, + undefined, + undefined, + dataViews + ); + + if (!result) { + throw new Error('Invalid annotation datasource'); + } + const { indexPattern } = result; if (indexPattern) { annotations.forEach((a: Annotation) => { const lensAnnotation = convertAnnotation(a, indexPattern); diff --git a/src/plugins/vis_types/timeseries/public/convert_to_lens/lib/datasource/datasource_info.test.ts b/src/plugins/vis_types/timeseries/public/convert_to_lens/lib/datasource/datasource_info.test.ts new file mode 100644 index 0000000000000..6fc1c3a3cad05 --- /dev/null +++ b/src/plugins/vis_types/timeseries/public/convert_to_lens/lib/datasource/datasource_info.test.ts @@ -0,0 +1,170 @@ +/* + * 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 { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; +import type { DataView } from '@kbn/data-plugin/common'; +import { extractOrGenerateDatasourceInfo } from './datasource_info'; + +const dataViewsMap: Record = { + test1: { id: 'test1', title: 'test1', timeFieldName: 'timeField1' } as DataView, + test2: { + id: 'test2', + title: 'test2', + timeFieldName: 'timeField2', + } as DataView, + test3: { + id: 'test3', + title: 'test3', + timeFieldName: 'timeField3', + name: 'index-pattern-3', + } as DataView, +}; + +const mockCreateDataView = jest.fn(); + +jest.mock('../../../../common/index_patterns_utils', () => { + const originalModule = jest.requireActual('../../../../common/index_patterns_utils'); + return { + isStringTypeIndexPattern: originalModule.isStringTypeIndexPattern, + }; +}); + +const getDataview = async (id: string): Promise => dataViewsMap[id]; + +describe('extractOrGenerateDatasourceInfo', () => { + let dataViews: DataViewsPublicPluginStart; + beforeAll(() => { + dataViews = { + getDefault: jest.fn(async () => { + return { id: '12345', title: 'default', timeFieldName: '@timestamp' }; + }), + get: getDataview, + create: mockCreateDataView, + } as unknown as DataViewsPublicPluginStart; + }); + + beforeEach(() => { + mockCreateDataView.mockReturnValue(getDataview('test3')); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('should return ad-hoc dataview if model_indexpattern is string and no corresponding dataview found by string', async () => { + const timeFieldName = 'timeField-3'; + const datasourceInfo = await extractOrGenerateDatasourceInfo( + 'test', + timeFieldName, + false, + undefined, + undefined, + dataViews + ); + const { indexPatternId, timeField, indexPattern } = datasourceInfo!; + expect(indexPatternId).toBe(dataViewsMap.test3.id); + expect(timeField).toBe(dataViewsMap.test3.timeFieldName); + expect(indexPattern).toBe(dataViewsMap.test3); + }); + + test('should return dataview if model_indexpattern is string and corresponding dataview is found by string', async () => { + const timeFieldName = 'timeField-3'; + const datasourceInfo = await extractOrGenerateDatasourceInfo( + dataViewsMap.test3.name, + timeFieldName, + false, + undefined, + undefined, + dataViews + ); + const { indexPatternId, timeField, indexPattern } = datasourceInfo!; + expect(indexPatternId).toBe(dataViewsMap.test3.id); + expect(timeField).toBe(dataViewsMap.test3.timeFieldName); + expect(indexPattern).toBe(dataViewsMap.test3); + }); + + test('should return the correct dataview if model_indexpattern is object', async () => { + const datasourceInfo = await extractOrGenerateDatasourceInfo( + { id: 'dataview-1-id' }, + 'timeField-1', + false, + undefined, + undefined, + dataViews + ); + const { indexPatternId, timeField } = datasourceInfo!; + + expect(indexPatternId).toBe('dataview-1-id'); + expect(timeField).toBe('timeField-1'); + }); + + test('should fetch the correct data if overwritten dataview is provided', async () => { + const datasourceInfo = await extractOrGenerateDatasourceInfo( + { id: 'dataview-1-id' }, + 'timeField-1', + true, + { id: 'test2' }, + undefined, + dataViews + ); + const { indexPatternId, timeField } = datasourceInfo!; + + expect(indexPatternId).toBe('test2'); + expect(timeField).toBe(dataViewsMap.test2.timeFieldName); + }); + + test('should return the correct dataview if overwritten dataview is string', async () => { + mockCreateDataView.mockReturnValue(dataViewsMap.test2); + + const datasourceInfo = await extractOrGenerateDatasourceInfo( + { id: 'dataview-1-id' }, + 'timeField-1', + true, + 'test2', + undefined, + dataViews + ); + const { indexPatternId, timeField } = datasourceInfo!; + + expect(indexPatternId).toBe('test2'); + expect(timeField).toBe('timeField2'); + }); + + test('should return null if dataview is string and invalid', async () => { + mockCreateDataView.mockImplementationOnce(() => { + throw new Error(); + }); + + const datasourceInfo = await extractOrGenerateDatasourceInfo( + 'dataview-1-i', + 'timeField-1', + false, + undefined, + undefined, + dataViews + ); + + expect(datasourceInfo).toBeNull(); + }); + + test('should return null if overritten dataview is string and invalid', async () => { + mockCreateDataView.mockImplementationOnce(() => { + throw new Error(); + }); + + const datasourceInfo = await extractOrGenerateDatasourceInfo( + { id: 'dataview-1-id' }, + 'timeField-1', + true, + 'test', + undefined, + dataViews + ); + + expect(datasourceInfo).toBeNull(); + }); +}); diff --git a/src/plugins/vis_types/timeseries/public/convert_to_lens/lib/datasource/datasource_info.ts b/src/plugins/vis_types/timeseries/public/convert_to_lens/lib/datasource/datasource_info.ts new file mode 100644 index 0000000000000..c26f495e217c1 --- /dev/null +++ b/src/plugins/vis_types/timeseries/public/convert_to_lens/lib/datasource/datasource_info.ts @@ -0,0 +1,127 @@ +/* + * 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 { DataView, DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; +import { isStringTypeIndexPattern } from '../../../../common/index_patterns_utils'; +import type { IndexPatternValue } from '../../../../common/types'; + +const getOverwrittenIndexPattern = async ( + overwrittenIndexPattern: IndexPatternValue, + overwrittenTimeField: string | undefined, + dataViews: DataViewsPublicPluginStart +) => { + if (isStringTypeIndexPattern(overwrittenIndexPattern)) { + const indexPattern = await dataViews.create( + { + id: `tsvb_ad_hoc_${overwrittenIndexPattern}${ + overwrittenTimeField ? '/' + overwrittenTimeField : '' + }`, + title: overwrittenIndexPattern, + timeFieldName: overwrittenTimeField, + }, + false, + false + ); + const indexPatternId = indexPattern.id ?? ''; + const timeField = indexPattern.timeFieldName; + return { indexPattern, indexPatternId, timeField }; + } else if (overwrittenIndexPattern) { + const indexPattern = await dataViews.get(overwrittenIndexPattern.id); + if (indexPattern) { + const indexPatternId = indexPattern.id ?? ''; + const timeField = overwrittenTimeField ?? indexPattern.timeFieldName; + return { indexPattern, indexPatternId, timeField }; + } + } + return null; +}; + +const getSelectedIndexPattern = async ( + selectedIndexPattern: IndexPatternValue, + selectedTimeField: string | undefined, + dataViews: DataViewsPublicPluginStart +) => { + if (isStringTypeIndexPattern(selectedIndexPattern)) { + if (!selectedTimeField) { + throw new Error('Time field is empty'); + } + const indexPattern = await dataViews.create( + { + id: `tsvb_ad_hoc_${selectedIndexPattern}${ + selectedTimeField ? '/' + selectedTimeField : '' + }`, + title: selectedIndexPattern, + timeFieldName: selectedTimeField, + }, + false, + false + ); + const indexPatternId = indexPattern.id ?? ''; + return { indexPattern, indexPatternId, timeField: indexPattern.timeFieldName }; + } + const indexPattern = await dataViews.getDefault(); + const indexPatternId = indexPattern?.id ?? ''; + const timeField = indexPattern?.timeFieldName; + return { indexPattern, indexPatternId, timeField }; +}; + +export const extractOrGenerateDatasourceInfo = async ( + currentIndexPattern: IndexPatternValue, + currentTimeField: string | undefined, + isOverwritten: boolean, + overwrittenIndexPattern: IndexPatternValue | undefined, + overwrittenTimeField: string | undefined, + dataViews: DataViewsPublicPluginStart +) => { + try { + let indexPatternId = + currentIndexPattern && !isStringTypeIndexPattern(currentIndexPattern) + ? currentIndexPattern.id + : ''; + + let timeField = currentTimeField; + let indexPattern: DataView | null | undefined; + // handle override index pattern + if (isOverwritten) { + const result = await getOverwrittenIndexPattern( + overwrittenIndexPattern, + overwrittenTimeField, + dataViews + ); + if (result) { + [indexPattern, indexPatternId, timeField] = [ + result.indexPattern, + result.indexPatternId, + result.timeField, + ]; + } + } + + if (!indexPatternId) { + const result = await getSelectedIndexPattern( + currentIndexPattern, + currentTimeField, + dataViews + ); + [indexPattern, indexPatternId, timeField] = [ + result.indexPattern, + result.indexPatternId, + result.timeField, + ]; + } else { + indexPattern = await dataViews.get(indexPatternId); + if (!timeField) { + timeField = indexPattern.timeFieldName; + } + } + + return { indexPatternId, timeField, indexPattern }; + } catch (e) { + return null; + } +}; diff --git a/src/plugins/vis_types/timeseries/public/convert_to_lens/lib/datasource/get_datasource_info.test.ts b/src/plugins/vis_types/timeseries/public/convert_to_lens/lib/datasource/get_datasource_info.test.ts deleted file mode 100644 index 975acfbbbcbb6..0000000000000 --- a/src/plugins/vis_types/timeseries/public/convert_to_lens/lib/datasource/get_datasource_info.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 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 { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; -import type { DataView } from '@kbn/data-plugin/common'; -import { getDataSourceInfo } from './get_datasource_info'; - -const dataViewsMap: Record = { - test1: { id: 'test1', title: 'test1', timeFieldName: 'timeField1' } as DataView, - test2: { - id: 'test2', - title: 'test2', - timeFieldName: 'timeField2', - } as DataView, - test3: { id: 'test3', title: 'test3', timeFieldName: 'timeField3' } as DataView, -}; - -const getDataview = async (id: string): Promise => dataViewsMap[id]; - -describe('getDataSourceInfo', () => { - let dataViews: DataViewsPublicPluginStart; - beforeAll(() => { - dataViews = { - getDefault: jest.fn(async () => { - return { id: '12345', title: 'default', timeFieldName: '@timestamp' }; - }), - get: getDataview, - } as unknown as DataViewsPublicPluginStart; - }); - - test('should return the default dataview if model_indexpattern is string', async () => { - const datasourceInfo = await getDataSourceInfo( - 'test', - undefined, - false, - undefined, - undefined, - dataViews - ); - const { indexPatternId, timeField } = datasourceInfo!; - expect(indexPatternId).toBe('12345'); - expect(timeField).toBe('@timestamp'); - }); - - test('should return the correct dataview if model_indexpattern is object', async () => { - const datasourceInfo = await getDataSourceInfo( - { id: 'dataview-1-id' }, - 'timeField-1', - false, - undefined, - undefined, - dataViews - ); - const { indexPatternId, timeField } = datasourceInfo!; - - expect(indexPatternId).toBe('dataview-1-id'); - expect(timeField).toBe('timeField-1'); - }); - - test('should fetch the correct data if overwritten dataview is provided', async () => { - const datasourceInfo = await getDataSourceInfo( - { id: 'dataview-1-id' }, - 'timeField-1', - true, - { id: 'test2' }, - undefined, - dataViews - ); - const { indexPatternId, timeField } = datasourceInfo!; - - expect(indexPatternId).toBe('test2'); - expect(timeField).toBe('timeField2'); - }); -}); diff --git a/src/plugins/vis_types/timeseries/public/convert_to_lens/lib/datasource/get_datasource_info.ts b/src/plugins/vis_types/timeseries/public/convert_to_lens/lib/datasource/get_datasource_info.ts deleted file mode 100644 index 615e1595c8529..0000000000000 --- a/src/plugins/vis_types/timeseries/public/convert_to_lens/lib/datasource/get_datasource_info.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 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 { DataView, DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; -import { - fetchIndexPattern, - isStringTypeIndexPattern, -} from '../../../../common/index_patterns_utils'; -import type { IndexPatternValue } from '../../../../common/types'; - -export const getDataSourceInfo = async ( - modelIndexPattern: IndexPatternValue, - modelTimeField: string | undefined, - isOverwritten: boolean, - overwrittenIndexPattern: IndexPatternValue | undefined, - seriesTimeField: string | undefined, - dataViews: DataViewsPublicPluginStart -) => { - try { - let indexPatternId = - modelIndexPattern && !isStringTypeIndexPattern(modelIndexPattern) ? modelIndexPattern.id : ''; - - let timeField = modelTimeField; - let indexPattern: DataView | null | undefined; - // handle override index pattern - if (isOverwritten) { - const fetchedIndexPattern = await fetchIndexPattern(overwrittenIndexPattern, dataViews); - indexPattern = fetchedIndexPattern.indexPattern; - - if (indexPattern) { - indexPatternId = indexPattern.id ?? ''; - timeField = seriesTimeField ?? indexPattern.timeFieldName; - } - } - - if (!indexPatternId) { - indexPattern = await dataViews.getDefault(); - indexPatternId = indexPattern?.id ?? ''; - timeField = indexPattern?.timeFieldName; - } else { - indexPattern = await dataViews.get(indexPatternId); - if (!timeField) { - timeField = indexPattern.timeFieldName; - } - } - - return { - indexPatternId, - timeField, - indexPattern, - }; - } catch (e) { - return null; - } -}; diff --git a/src/plugins/vis_types/timeseries/public/convert_to_lens/lib/datasource/index.ts b/src/plugins/vis_types/timeseries/public/convert_to_lens/lib/datasource/index.ts index a3a34212f48b6..b007a88313ba4 100644 --- a/src/plugins/vis_types/timeseries/public/convert_to_lens/lib/datasource/index.ts +++ b/src/plugins/vis_types/timeseries/public/convert_to_lens/lib/datasource/index.ts @@ -6,4 +6,4 @@ * Side Public License, v 1. */ -export * from './get_datasource_info'; +export * from './datasource_info'; diff --git a/src/plugins/vis_types/timeseries/public/convert_to_lens/metric/index.test.ts b/src/plugins/vis_types/timeseries/public/convert_to_lens/metric/index.test.ts index 6d62994be4447..fd0c07dc4ff94 100644 --- a/src/plugins/vis_types/timeseries/public/convert_to_lens/metric/index.test.ts +++ b/src/plugins/vis_types/timeseries/public/convert_to_lens/metric/index.test.ts @@ -20,7 +20,7 @@ const mockIsValidMetrics = jest.fn(); const mockGetDatasourceValue = jest .fn() .mockImplementation(() => Promise.resolve(stubLogstashDataView)); -const mockGetDataSourceInfo = jest.fn(); +const mockExtractOrGenerateDatasourceInfo = jest.fn(); jest.mock('../../services', () => ({ getDataViewsStart: jest.fn(() => mockGetDatasourceValue), @@ -41,7 +41,7 @@ jest.mock('../lib/metrics', () => ({ })); jest.mock('../lib/datasource', () => ({ - getDataSourceInfo: jest.fn(() => mockGetDataSourceInfo()), + extractOrGenerateDatasourceInfo: jest.fn(() => mockExtractOrGenerateDatasourceInfo()), })); describe('convertToLens', () => { @@ -125,7 +125,7 @@ describe('convertToLens', () => { beforeEach(() => { mockIsValidMetrics.mockReturnValue(true); - mockGetDataSourceInfo.mockReturnValue({ + mockExtractOrGenerateDatasourceInfo.mockReturnValue({ indexPatternId: 'test-index-pattern', timeField: 'timeField', indexPattern: { id: 'test-index-pattern' }, @@ -168,6 +168,14 @@ describe('convertToLens', () => { expect(mockGetConfigurationForMetric).toBeCalledTimes(1); }); + test('should drop adhoc dataviews if action is required', async () => { + const result = await convertToLens(vis, undefined, true); + expect(result).toBeDefined(); + expect(result?.type).toBe('lnsMetric'); + expect(mockGetBucketsColumns).toBeCalledTimes(model.series.length); + expect(mockGetConfigurationForMetric).toBeCalledTimes(1); + }); + test('should skip hidden series', async () => { const result = await convertToLens({ params: createPanel({ @@ -185,7 +193,7 @@ describe('convertToLens', () => { }); test('should return null if multiple indexPatterns are provided', async () => { - mockGetDataSourceInfo.mockReturnValueOnce({ + mockExtractOrGenerateDatasourceInfo.mockReturnValueOnce({ indexPatternId: 'test-index-pattern-1', timeField: 'timeField', indexPattern: { id: 'test-index-pattern-1' }, diff --git a/src/plugins/vis_types/timeseries/public/convert_to_lens/metric/index.ts b/src/plugins/vis_types/timeseries/public/convert_to_lens/metric/index.ts index 8577623b8bd93..f4eb2966f5e4f 100644 --- a/src/plugins/vis_types/timeseries/public/convert_to_lens/metric/index.ts +++ b/src/plugins/vis_types/timeseries/public/convert_to_lens/metric/index.ts @@ -11,7 +11,7 @@ import { DataView, parseTimeShift } from '@kbn/data-plugin/common'; import { getIndexPatternIds } from '@kbn/visualizations-plugin/common/convert_to_lens'; import { PANEL_TYPES } from '../../../common/enums'; import { getDataViewsStart } from '../../services'; -import { getDataSourceInfo } from '../lib/datasource'; +import { extractOrGenerateDatasourceInfo } from '../lib/datasource'; import { getMetricsColumns, getBucketsColumns } from '../lib/series'; import { getConfigurationForMetric as getConfiguration } from '../lib/configurations/metric'; import { getReducedTimeRange, isValidMetrics } from '../lib/metrics'; @@ -22,113 +22,121 @@ import { excludeMetaFromLayers, getUniqueBuckets } from '../utils'; const MAX_SERIES = 2; const MAX_BUCKETS = 2; +const invalidModelError = () => new Error('Invalid model'); + export const convertToLens: ConvertTsvbToLensVisualization = async ( { params: model }, timeRange ) => { const dataViews = getDataViewsStart(); - const seriesNum = model.series.filter((series) => !series.hidden).length; - - const indexPatternIds = new Set(); - // we should get max only 2 series - const visibleSeries = model.series.filter(({ hidden }) => !hidden).slice(0, 2); - let currentIndexPattern: DataView | null = null; - for (const series of visibleSeries) { - const datasourceInfo = await getDataSourceInfo( - model.index_pattern, - model.time_field, - Boolean(series.override_index_pattern), - series.series_index_pattern, - series.series_time_field, - dataViews - ); - - if (!datasourceInfo) { - return null; - } - - const { indexPatternId, indexPattern } = datasourceInfo; - indexPatternIds.add(indexPatternId); - currentIndexPattern = indexPattern; - } - - if (indexPatternIds.size > 1) { - return null; - } - - const [indexPatternId] = indexPatternIds.values(); - - const buckets = []; - const metrics = []; - - // handle multiple layers/series - for (const series of visibleSeries) { - // not valid time shift - if (series.offset_time && parseTimeShift(series.offset_time) === 'invalid') { - return null; + try { + const seriesNum = model.series.filter((series) => !series.hidden).length; + + const indexPatternIds = new Set(); + // we should get max only 2 series + const visibleSeries = model.series.filter(({ hidden }) => !hidden).slice(0, 2); + let currentIndexPattern: DataView | null = null; + for (const series of visibleSeries) { + const datasourceInfo = await extractOrGenerateDatasourceInfo( + model.index_pattern, + model.time_field, + Boolean(series.override_index_pattern), + series.series_index_pattern, + series.series_time_field, + dataViews + ); + + if (!datasourceInfo) { + throw invalidModelError(); + } + + const { indexPatternId, indexPattern } = datasourceInfo; + + indexPatternIds.add(indexPatternId); + currentIndexPattern = indexPattern; } - if (!isValidMetrics(series.metrics, PANEL_TYPES.METRIC, series.time_range_mode)) { - return null; + if (indexPatternIds.size > 1) { + throw invalidModelError(); } - const reducedTimeRange = getReducedTimeRange(model, series, timeRange); - - // handle multiple metrics - const metricsColumns = getMetricsColumns(series, currentIndexPattern!, seriesNum, { - reducedTimeRange, - }); - if (metricsColumns === null) { - return null; + const [indexPatternId] = indexPatternIds.values(); + + const buckets = []; + const metrics = []; + + // handle multiple layers/series + for (const series of visibleSeries) { + // not valid time shift + if (series.offset_time && parseTimeShift(series.offset_time) === 'invalid') { + throw invalidModelError(); + } + + if (!isValidMetrics(series.metrics, PANEL_TYPES.METRIC, series.time_range_mode)) { + throw invalidModelError(); + } + + const reducedTimeRange = getReducedTimeRange(model, series, timeRange); + + // handle multiple metrics + const metricsColumns = getMetricsColumns(series, currentIndexPattern!, seriesNum, { + reducedTimeRange, + }); + if (metricsColumns === null) { + throw invalidModelError(); + } + + const bucketsColumns = getBucketsColumns( + model, + series, + metricsColumns, + currentIndexPattern!, + false + ); + + if (bucketsColumns === null) { + throw invalidModelError(); + } + + buckets.push(...bucketsColumns); + metrics.push(...metricsColumns); } - const bucketsColumns = getBucketsColumns( - model, - series, - metricsColumns, - currentIndexPattern!, - false - ); + let uniqueBuckets = buckets; + if (visibleSeries.length === MAX_SERIES && buckets.length) { + if (buckets.length !== MAX_BUCKETS) { + throw invalidModelError(); + } - if (bucketsColumns === null) { - return null; + uniqueBuckets = getUniqueBuckets(buckets as ColumnsWithoutMeta[]); + if (uniqueBuckets.length !== 1) { + throw invalidModelError(); + } } - buckets.push(...bucketsColumns); - metrics.push(...metricsColumns); - } + const [bucket] = uniqueBuckets; - let uniqueBuckets = buckets; - if (visibleSeries.length === MAX_SERIES && buckets.length) { - if (buckets.length !== MAX_BUCKETS) { - return null; - } + const extendedLayer: ExtendedLayer = { + indexPatternId: indexPatternId as string, + layerId: uuid(), + columns: [...metrics, ...(bucket ? [bucket] : [])], + columnOrder: [], + }; - uniqueBuckets = getUniqueBuckets(buckets as ColumnsWithoutMeta[]); - if (uniqueBuckets.length !== 1) { - return null; + const configuration = getConfiguration(model, extendedLayer, bucket); + if (!configuration) { + throw invalidModelError(); } - } - const [bucket] = uniqueBuckets; + const layers = Object.values(excludeMetaFromLayers({ 0: extendedLayer })); - const extendedLayer: ExtendedLayer = { - indexPatternId: indexPatternId as string, - layerId: uuid(), - columns: [...metrics, ...(bucket ? [bucket] : [])], - columnOrder: [], - }; - - const configuration = getConfiguration(model, extendedLayer, bucket); - if (!configuration) { + return { + type: 'lnsMetric', + layers, + configuration, + indexPatternIds: getIndexPatternIds(layers), + }; + } catch (e) { return null; } - - const layers = Object.values(excludeMetaFromLayers({ 0: extendedLayer })); - return { - type: 'lnsMetric', - layers, - configuration, - indexPatternIds: getIndexPatternIds(layers), - }; }; diff --git a/src/plugins/vis_types/timeseries/public/convert_to_lens/table/index.test.ts b/src/plugins/vis_types/timeseries/public/convert_to_lens/table/index.test.ts index 37676b302fba1..c5989a288cd89 100644 --- a/src/plugins/vis_types/timeseries/public/convert_to_lens/table/index.test.ts +++ b/src/plugins/vis_types/timeseries/public/convert_to_lens/table/index.test.ts @@ -23,7 +23,7 @@ const mockIsValidMetrics = jest.fn(); const mockGetDatasourceValue = jest .fn() .mockImplementation(() => Promise.resolve(stubLogstashDataView)); -const mockGetDataSourceInfo = jest.fn(); +const mockExtractOrGenerateDatasourceInfo = jest.fn(); const mockGetColumnState = jest.fn(); jest.mock('../../services', () => ({ @@ -49,7 +49,7 @@ jest.mock('../lib/metrics', () => ({ })); jest.mock('../lib/datasource', () => ({ - getDataSourceInfo: jest.fn(() => mockGetDataSourceInfo()), + extractOrGenerateDatasourceInfo: jest.fn(() => mockExtractOrGenerateDatasourceInfo()), })); describe('convertToLens', () => { @@ -73,7 +73,7 @@ describe('convertToLens', () => { beforeEach(() => { mockIsValidMetrics.mockReturnValue(true); - mockGetDataSourceInfo.mockReturnValue({ + mockExtractOrGenerateDatasourceInfo.mockReturnValue({ indexPatternId: 'test-index-pattern', timeField: 'timeField', indexPattern: { id: 'test-index-pattern' }, diff --git a/src/plugins/vis_types/timeseries/public/convert_to_lens/table/index.ts b/src/plugins/vis_types/timeseries/public/convert_to_lens/table/index.ts index 0219d1080724b..3f5aaf65f2889 100644 --- a/src/plugins/vis_types/timeseries/public/convert_to_lens/table/index.ts +++ b/src/plugins/vis_types/timeseries/public/convert_to_lens/table/index.ts @@ -12,7 +12,7 @@ import { getIndexPatternIds, Layer } from '@kbn/visualizations-plugin/common/con import { PANEL_TYPES } from '../../../common/enums'; import { getDataViewsStart } from '../../services'; import { getColumnState } from '../lib/configurations/table'; -import { getDataSourceInfo } from '../lib/datasource'; +import { extractOrGenerateDatasourceInfo } from '../lib/datasource'; import { getMetricsColumns, getBucketsColumns } from '../lib/series'; import { getReducedTimeRange, isValidMetrics } from '../lib/metrics'; import { ConvertTsvbToLensVisualization } from '../types'; @@ -28,156 +28,163 @@ const excludeMetaFromLayers = (layers: Record): Record new Error('Invalid model'); + export const convertToLens: ConvertTsvbToLensVisualization = async ( { params: model, uiState }, timeRange ) => { const columnStates = []; const dataViews = getDataViewsStart(); - const seriesNum = model.series.filter((series) => !series.hidden).length; - const sortConfig = uiState.get('table')?.sort ?? {}; - - const datasourceInfo = await getDataSourceInfo( - model.index_pattern, - model.time_field, - false, - undefined, - undefined, - dataViews - ); - - if (!datasourceInfo) { - return null; - } - const { indexPatternId, indexPattern } = datasourceInfo; - - const commonBucketsColumns = getBucketsColumns( - undefined, - { - split_mode: 'terms', - terms_field: model.pivot_id, - terms_size: model.pivot_rows ? model.pivot_rows.toString() : undefined, - }, - [], - indexPattern!, - false, - model.pivot_label, - false - ); - - if (!commonBucketsColumns) { - return null; - } + try { + const seriesNum = model.series.filter((series) => !series.hidden).length; + const sortConfig = uiState.get('table')?.sort ?? {}; - const sortConfiguration = { - columnId: commonBucketsColumns[0].columnId, - direction: sortConfig.order, - }; - - columnStates.push(getColumnState(commonBucketsColumns[0].columnId)); + const datasourceInfo = await extractOrGenerateDatasourceInfo( + model.index_pattern, + model.time_field, + false, + undefined, + undefined, + dataViews + ); - let bucketsColumns: Column[] | null = []; + if (!datasourceInfo) { + throw invalidModelError(); + } - if ( - !model.series.every( - (s) => - ((!s.aggregate_by && !model.series[0].aggregate_by) || - s.aggregate_by === model.series[0].aggregate_by) && - ((!s.aggregate_function && !model.series[0].aggregate_function) || - s.aggregate_function === model.series[0].aggregate_function) - ) - ) { - return null; - } + const { indexPatternId, indexPattern } = datasourceInfo; - if (model.series[0].aggregate_by) { - if ( - !model.series[0].aggregate_function || - !['sum', 'mean', 'min', 'max'].includes(model.series[0].aggregate_function) - ) { - return null; - } - bucketsColumns = getBucketsColumns( + const commonBucketsColumns = getBucketsColumns( undefined, { split_mode: 'terms', - terms_field: model.series[0].aggregate_by, + terms_field: model.pivot_id, + terms_size: model.pivot_rows ? model.pivot_rows.toString() : undefined, }, [], indexPattern!, + false, + model.pivot_label, false ); - if (bucketsColumns === null) { - return null; + + if (!commonBucketsColumns) { + throw invalidModelError(); } - columnStates.push( - getColumnState( - bucketsColumns[0].columnId, - model.series[0].aggregate_function === 'mean' ? 'avg' : model.series[0].aggregate_function - ) - ); - } + const sortConfiguration = { + columnId: commonBucketsColumns[0].columnId, + direction: sortConfig.order, + }; - const metrics = []; + columnStates.push(getColumnState(commonBucketsColumns[0].columnId)); - // handle multiple layers/series - for (const [_, series] of model.series.entries()) { - if (series.hidden) { - continue; - } + let bucketsColumns: Column[] | null = []; - // not valid time shift - if (series.offset_time && parseTimeShift(series.offset_time) === 'invalid') { - return null; + if ( + !model.series.every( + (s) => + ((!s.aggregate_by && !model.series[0].aggregate_by) || + s.aggregate_by === model.series[0].aggregate_by) && + ((!s.aggregate_function && !model.series[0].aggregate_function) || + s.aggregate_function === model.series[0].aggregate_function) + ) + ) { + throw invalidModelError(); } - if (!isValidMetrics(series.metrics, PANEL_TYPES.TABLE, series.time_range_mode)) { - return null; + if (model.series[0].aggregate_by) { + if ( + !model.series[0].aggregate_function || + !['sum', 'mean', 'min', 'max'].includes(model.series[0].aggregate_function) + ) { + throw invalidModelError(); + } + bucketsColumns = getBucketsColumns( + undefined, + { + split_mode: 'terms', + terms_field: model.series[0].aggregate_by, + }, + [], + indexPattern!, + false + ); + if (bucketsColumns === null) { + throw invalidModelError(); + } + + columnStates.push( + getColumnState( + bucketsColumns[0].columnId, + model.series[0].aggregate_function === 'mean' ? 'avg' : model.series[0].aggregate_function + ) + ); } - const reducedTimeRange = getReducedTimeRange(model, series, timeRange); + const metrics = []; - // handle multiple metrics - const metricsColumns = getMetricsColumns(series, indexPattern!, seriesNum, { - reducedTimeRange, - }); - if (!metricsColumns) { - return null; - } + // handle multiple layers/series + for (const [_, series] of model.series.entries()) { + if (series.hidden) { + continue; + } + + // not valid time shift + if (series.offset_time && parseTimeShift(series.offset_time) === 'invalid') { + throw invalidModelError(); + } - columnStates.push(getColumnState(metricsColumns[0].columnId, undefined, series)); + if (!isValidMetrics(series.metrics, PANEL_TYPES.TABLE, series.time_range_mode)) { + throw invalidModelError(); + } - if (sortConfig.column === series.id) { - sortConfiguration.columnId = metricsColumns[0].columnId; + const reducedTimeRange = getReducedTimeRange(model, series, timeRange); + + // handle multiple metrics + const metricsColumns = getMetricsColumns(series, indexPattern!, seriesNum, { + reducedTimeRange, + }); + if (!metricsColumns) { + throw invalidModelError(); + } + + columnStates.push(getColumnState(metricsColumns[0].columnId, undefined, series)); + + if (sortConfig.column === series.id) { + sortConfiguration.columnId = metricsColumns[0].columnId; + } + + metrics.push(...metricsColumns); } - metrics.push(...metricsColumns); - } + if (!metrics.length || metrics.every((metric) => metric.operationType === 'static_value')) { + throw invalidModelError(); + } - if (!metrics.length || metrics.every((metric) => metric.operationType === 'static_value')) { + const extendedLayer: ExtendedLayer = { + indexPatternId: indexPatternId as string, + layerId: uuid(), + columns: [...metrics, ...commonBucketsColumns, ...bucketsColumns], + columnOrder: [], + }; + + const layers = Object.values(excludeMetaFromLayers({ 0: extendedLayer })); + + return { + type: 'lnsDatatable', + layers, + configuration: { + columns: columnStates, + layerId: extendedLayer.layerId, + layerType: 'data', + sorting: sortConfiguration, + }, + indexPatternIds: getIndexPatternIds(layers), + }; + } catch (e) { return null; } - - const extendedLayer: ExtendedLayer = { - indexPatternId: indexPatternId as string, - layerId: uuid(), - columns: [...metrics, ...commonBucketsColumns, ...bucketsColumns], - columnOrder: [], - }; - - const layers = Object.values(excludeMetaFromLayers({ 0: extendedLayer })); - - return { - type: 'lnsDatatable', - layers, - configuration: { - columns: columnStates, - layerId: extendedLayer.layerId, - layerType: 'data', - sorting: sortConfiguration, - }, - indexPatternIds: getIndexPatternIds(layers), - }; }; diff --git a/src/plugins/vis_types/timeseries/public/convert_to_lens/timeseries/index.test.ts b/src/plugins/vis_types/timeseries/public/convert_to_lens/timeseries/index.test.ts index 64fee3484b3b6..dc1c51474fd07 100644 --- a/src/plugins/vis_types/timeseries/public/convert_to_lens/timeseries/index.test.ts +++ b/src/plugins/vis_types/timeseries/public/convert_to_lens/timeseries/index.test.ts @@ -21,7 +21,7 @@ const mockIsValidMetrics = jest.fn(); const mockGetDatasourceValue = jest .fn() .mockImplementation(() => Promise.resolve(stubLogstashDataView)); -const mockGetDataSourceInfo = jest.fn(); +const mockExtractOrGenerateDatasourceInfo = jest.fn(); jest.mock('../../services', () => ({ getDataViewsStart: jest.fn(() => mockGetDatasourceValue), @@ -47,7 +47,7 @@ jest.mock('../lib/metrics', () => ({ })); jest.mock('../lib/datasource', () => ({ - getDataSourceInfo: jest.fn(() => mockGetDataSourceInfo()), + extractOrGenerateDatasourceInfo: jest.fn(() => mockExtractOrGenerateDatasourceInfo()), })); describe('convertToLens', () => { @@ -68,7 +68,7 @@ describe('convertToLens', () => { beforeEach(() => { mockIsValidMetrics.mockReturnValue(true); - mockGetDataSourceInfo.mockReturnValue({ + mockExtractOrGenerateDatasourceInfo.mockReturnValue({ indexPatternId: 'test-index-pattern', timeField: 'timeField', indexPattern: { id: 'test-index-pattern' }, @@ -91,10 +91,10 @@ describe('convertToLens', () => { }); test('should return null for empty time field', async () => { - mockGetDataSourceInfo.mockReturnValue({ timeField: null }); + mockExtractOrGenerateDatasourceInfo.mockReturnValue({ timeField: null }); const result = await convertToLens(vis); expect(result).toBeNull(); - expect(mockGetDataSourceInfo).toBeCalledTimes(1); + expect(mockExtractOrGenerateDatasourceInfo).toBeCalledTimes(1); }); test('should return null for invalid date histogram', async () => { @@ -139,6 +139,14 @@ describe('convertToLens', () => { expect(mockGetConfigurationForTimeseries).toBeCalledTimes(1); }); + test('should drop adhoc dataviews if action is required', async () => { + const result = await convertToLens(vis, undefined, true); + expect(result).toBeDefined(); + expect(result?.type).toBe('lnsXY'); + expect(mockGetBucketsColumns).toBeCalledTimes(model.series.length); + expect(mockGetConfigurationForTimeseries).toBeCalledTimes(1); + }); + test('should skip hidden series', async () => { const result = await convertToLens({ params: createPanel({ diff --git a/src/plugins/vis_types/timeseries/public/convert_to_lens/timeseries/index.ts b/src/plugins/vis_types/timeseries/public/convert_to_lens/timeseries/index.ts index a08b7113c4a7b..3bdb208da78ad 100644 --- a/src/plugins/vis_types/timeseries/public/convert_to_lens/timeseries/index.ts +++ b/src/plugins/vis_types/timeseries/public/convert_to_lens/timeseries/index.ts @@ -16,7 +16,7 @@ import uuid from 'uuid'; import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; import { PANEL_TYPES } from '../../../common/enums'; import { getDataViewsStart } from '../../services'; -import { getDataSourceInfo } from '../lib/datasource'; +import { extractOrGenerateDatasourceInfo } from '../lib/datasource'; import { getMetricsColumns, getBucketsColumns } from '../lib/series'; import { getConfigurationForTimeseries as getConfiguration, @@ -40,100 +40,107 @@ const excludeMetaFromLayers = (layers: Record): Record new Error('Invalid model'); + export const convertToLens: ConvertTsvbToLensVisualization = async ({ params: model }) => { const dataViews: DataViewsPublicPluginStart = getDataViewsStart(); - const extendedLayers: Record = {}; - const seriesNum = model.series.filter((series) => !series.hidden).length; - - // handle multiple layers/series - for (const [layerIdx, series] of model.series.entries()) { - if (series.hidden) { - continue; - } - - // not valid time shift - if (series.offset_time && parseTimeShift(series.offset_time) === 'invalid') { - return null; - } - - if (!isValidMetrics(series.metrics, PANEL_TYPES.TIMESERIES)) { - return null; - } - - const datasourceInfo = await getDataSourceInfo( - model.index_pattern, - model.time_field, - Boolean(series.override_index_pattern), - series.series_index_pattern, - series.series_time_field, - dataViews - ); - if (!datasourceInfo) { - return null; - } - - const { indexPatternId, indexPattern, timeField } = datasourceInfo; - if (!timeField) { - return null; + try { + const extendedLayers: Record = {}; + const seriesNum = model.series.filter((series) => !series.hidden).length; + + // handle multiple layers/series + for (const [layerIdx, series] of model.series.entries()) { + if (series.hidden) { + continue; + } + + // not valid time shift + if (series.offset_time && parseTimeShift(series.offset_time) === 'invalid') { + throw invalidModelError(); + } + + if (!isValidMetrics(series.metrics, PANEL_TYPES.TIMESERIES)) { + throw invalidModelError(); + } + + const datasourceInfo = await extractOrGenerateDatasourceInfo( + model.index_pattern, + model.time_field, + Boolean(series.override_index_pattern), + series.series_index_pattern, + series.series_time_field, + dataViews + ); + if (!datasourceInfo) { + throw invalidModelError(); + } + + const { indexPatternId, indexPattern, timeField } = datasourceInfo; + + if (!timeField) { + throw invalidModelError(); + } + + const dateHistogramColumn = convertToDateHistogramColumn(model, series, indexPattern!, { + fieldName: timeField, + isSplit: false, + }); + if (dateHistogramColumn === null) { + throw invalidModelError(); + } + // handle multiple metrics + const metricsColumns = getMetricsColumns(series, indexPattern!, seriesNum, { + isStaticValueColumnSupported: true, + }); + if (metricsColumns === null) { + throw invalidModelError(); + } + + const bucketsColumns = getBucketsColumns(model, series, metricsColumns, indexPattern!, true); + if (bucketsColumns === null) { + throw invalidModelError(); + } + + const isReferenceLine = + metricsColumns.length === 1 && metricsColumns[0].operationType === 'static_value'; + + // only static value without split is supported + if (isReferenceLine && bucketsColumns.length) { + throw invalidModelError(); + } + + const layerId = uuid(); + extendedLayers[layerIdx] = { + indexPatternId, + layerId, + columns: isReferenceLine + ? [...metricsColumns] + : [...metricsColumns, dateHistogramColumn, ...bucketsColumns], + columnOrder: [], + }; } - const dateHistogramColumn = convertToDateHistogramColumn(model, series, indexPattern!, { - fieldName: timeField, - isSplit: false, - }); - if (dateHistogramColumn === null) { - return null; - } - // handle multiple metrics - const metricsColumns = getMetricsColumns(series, indexPattern!, seriesNum, { - isStaticValueColumnSupported: true, - }); - if (metricsColumns === null) { - return null; - } - - const bucketsColumns = getBucketsColumns(model, series, metricsColumns, indexPattern!, true); - if (bucketsColumns === null) { - return null; - } - - const isReferenceLine = - metricsColumns.length === 1 && metricsColumns[0].operationType === 'static_value'; - - // only static value without split is supported - if (isReferenceLine && bucketsColumns.length) { - return null; + const configLayers = await getLayers(extendedLayers, model, dataViews); + if (configLayers === null) { + throw invalidModelError(); } - const layerId = uuid(); - extendedLayers[layerIdx] = { - indexPatternId, - layerId, - columns: isReferenceLine - ? [...metricsColumns] - : [...metricsColumns, dateHistogramColumn, ...bucketsColumns], - columnOrder: [], + const configuration = getConfiguration(model, configLayers); + const layers = Object.values(excludeMetaFromLayers(extendedLayers)); + const annotationIndexPatterns = configuration.layers.reduce((acc, layer) => { + if (isAnnotationsLayer(layer)) { + return [...acc, layer.indexPatternId]; + } + return acc; + }, []); + + return { + type: 'lnsXY', + layers, + configuration, + indexPatternIds: [...getIndexPatternIds(layers), ...annotationIndexPatterns], }; - } - - const configLayers = await getLayers(extendedLayers, model, dataViews); - if (configLayers === null) { + } catch (e) { return null; } - - const configuration = getConfiguration(model, configLayers); - const layers = Object.values(excludeMetaFromLayers(extendedLayers)); - const annotationIndexPatterns = configuration.layers.reduce((acc, layer) => { - if (isAnnotationsLayer(layer)) { - return [...acc, layer.indexPatternId]; - } - return acc; - }, []); - - return { - type: 'lnsXY', - layers, - configuration, - indexPatternIds: [...getIndexPatternIds(layers), ...annotationIndexPatterns], - }; }; diff --git a/src/plugins/vis_types/timeseries/public/convert_to_lens/top_n/index.test.ts b/src/plugins/vis_types/timeseries/public/convert_to_lens/top_n/index.test.ts index 646323a6691d5..404f3b42fb7ba 100644 --- a/src/plugins/vis_types/timeseries/public/convert_to_lens/top_n/index.test.ts +++ b/src/plugins/vis_types/timeseries/public/convert_to_lens/top_n/index.test.ts @@ -20,7 +20,7 @@ const mockIsValidMetrics = jest.fn(); const mockGetDatasourceValue = jest .fn() .mockImplementation(() => Promise.resolve(stubLogstashDataView)); -const mockGetDataSourceInfo = jest.fn(); +const mockExtractOrGenerateDatasourceInfo = jest.fn(); jest.mock('../../services', () => ({ getDataViewsStart: jest.fn(() => mockGetDatasourceValue), @@ -46,7 +46,7 @@ jest.mock('../lib/metrics', () => ({ })); jest.mock('../lib/datasource', () => ({ - getDataSourceInfo: jest.fn(() => mockGetDataSourceInfo()), + extractOrGenerateDatasourceInfo: jest.fn(() => mockExtractOrGenerateDatasourceInfo()), })); describe('convertToLens', () => { @@ -67,7 +67,7 @@ describe('convertToLens', () => { beforeEach(() => { mockIsValidMetrics.mockReturnValue(true); - mockGetDataSourceInfo.mockReturnValue({ + mockExtractOrGenerateDatasourceInfo.mockReturnValue({ indexPatternId: 'test-index-pattern', timeField: 'timeField', indexPattern: { id: 'test-index-pattern' }, @@ -110,6 +110,14 @@ describe('convertToLens', () => { expect(mockGetConfigurationForTopN).toBeCalledTimes(1); }); + test('should drop adhoc dataviews if action is required', async () => { + const result = await convertToLens(vis, undefined, true); + expect(result).toBeDefined(); + expect(result?.type).toBe('lnsXY'); + expect(mockGetBucketsColumns).toBeCalledTimes(model.series.length); + expect(mockGetConfigurationForTopN).toBeCalledTimes(1); + }); + test('should skip hidden series', async () => { const result = await convertToLens({ params: createPanel({ diff --git a/src/plugins/vis_types/timeseries/public/convert_to_lens/top_n/index.ts b/src/plugins/vis_types/timeseries/public/convert_to_lens/top_n/index.ts index 9505b7f5f0c7e..f4a4ed27b0024 100644 --- a/src/plugins/vis_types/timeseries/public/convert_to_lens/top_n/index.ts +++ b/src/plugins/vis_types/timeseries/public/convert_to_lens/top_n/index.ts @@ -11,7 +11,7 @@ import { parseTimeShift } from '@kbn/data-plugin/common'; import { getIndexPatternIds, Layer } from '@kbn/visualizations-plugin/common/convert_to_lens'; import { PANEL_TYPES } from '../../../common/enums'; import { getDataViewsStart } from '../../services'; -import { getDataSourceInfo } from '../lib/datasource'; +import { extractOrGenerateDatasourceInfo } from '../lib/datasource'; import { getMetricsColumns, getBucketsColumns } from '../lib/series'; import { getConfigurationForTopN as getConfiguration, getLayers } from '../lib/configurations/xy'; import { getReducedTimeRange, isValidMetrics } from '../lib/metrics'; @@ -28,77 +28,84 @@ const excludeMetaFromLayers = (layers: Record): Record new Error('Invalid model'); + export const convertToLens: ConvertTsvbToLensVisualization = async ( { params: model }, timeRange ) => { const dataViews = getDataViewsStart(); - const extendedLayers: Record = {}; - const seriesNum = model.series.filter((series) => !series.hidden).length; + try { + const extendedLayers: Record = {}; + const seriesNum = model.series.filter((series) => !series.hidden).length; - // handle multiple layers/series - for (const [layerIdx, series] of model.series.entries()) { - if (series.hidden) { - continue; - } + // handle multiple layers/series + for (const [layerIdx, series] of model.series.entries()) { + if (series.hidden) { + continue; + } - // not valid time shift - if (series.offset_time && parseTimeShift(series.offset_time) === 'invalid') { - return null; - } + // not valid time shift + if (series.offset_time && parseTimeShift(series.offset_time) === 'invalid') { + throw invalidModelError(); + } - if (!isValidMetrics(series.metrics, PANEL_TYPES.TOP_N, series.time_range_mode)) { - return null; - } + if (!isValidMetrics(series.metrics, PANEL_TYPES.TOP_N, series.time_range_mode)) { + throw invalidModelError(); + } - const datasourceInfo = await getDataSourceInfo( - model.index_pattern, - model.time_field, - Boolean(series.override_index_pattern), - series.series_index_pattern, - series.series_time_field, - dataViews - ); - - if (!datasourceInfo) { - return null; - } + const datasourceInfo = await extractOrGenerateDatasourceInfo( + model.index_pattern, + model.time_field, + Boolean(series.override_index_pattern), + series.series_index_pattern, + series.series_time_field, + dataViews + ); + + if (!datasourceInfo) { + throw invalidModelError(); + } - const { indexPatternId, indexPattern } = datasourceInfo; - const reducedTimeRange = getReducedTimeRange(model, series, timeRange); + const { indexPatternId, indexPattern } = datasourceInfo; + const reducedTimeRange = getReducedTimeRange(model, series, timeRange); - // handle multiple metrics - const metricsColumns = getMetricsColumns(series, indexPattern!, seriesNum, { - reducedTimeRange, - }); - if (!metricsColumns) { - return null; + // handle multiple metrics + const metricsColumns = getMetricsColumns(series, indexPattern!, seriesNum, { + reducedTimeRange, + }); + if (!metricsColumns) { + throw invalidModelError(); + } + + const bucketsColumns = getBucketsColumns(model, series, metricsColumns, indexPattern!, false); + if (bucketsColumns === null) { + throw invalidModelError(); + } + + const layerId = uuid(); + extendedLayers[layerIdx] = { + indexPatternId, + layerId, + columns: [...metricsColumns, ...bucketsColumns], + columnOrder: [], + }; } - const bucketsColumns = getBucketsColumns(model, series, metricsColumns, indexPattern!, false); - if (bucketsColumns === null) { - return null; + const configLayers = await getLayers(extendedLayers, model, dataViews, true); + if (configLayers === null) { + throw invalidModelError(); } - const layerId = uuid(); - extendedLayers[layerIdx] = { - indexPatternId, - layerId, - columns: [...metricsColumns, ...bucketsColumns], - columnOrder: [], - }; - } + const layers = Object.values(excludeMetaFromLayers(extendedLayers)); - const configLayers = await getLayers(extendedLayers, model, dataViews, true); - if (configLayers === null) { + return { + type: 'lnsXY', + layers, + configuration: getConfiguration(model, configLayers), + indexPatternIds: getIndexPatternIds(layers), + }; + } catch (e) { return null; } - - const layers = Object.values(excludeMetaFromLayers(extendedLayers)); - return { - type: 'lnsXY', - layers, - configuration: getConfiguration(model, configLayers), - indexPatternIds: getIndexPatternIds(layers), - }; }; diff --git a/src/plugins/vis_types/timeseries/public/convert_to_lens/types.ts b/src/plugins/vis_types/timeseries/public/convert_to_lens/types.ts index 9f00a669ea5c3..ddff3f1822b8c 100644 --- a/src/plugins/vis_types/timeseries/public/convert_to_lens/types.ts +++ b/src/plugins/vis_types/timeseries/public/convert_to_lens/types.ts @@ -18,7 +18,8 @@ import type { Panel } from '../../common/types'; export type ConvertTsvbToLensVisualization = ( vis: Vis, - timeRange?: TimeRange + timeRange?: TimeRange, + clearAdHocDataViews?: boolean ) => Promise | null>; diff --git a/x-pack/plugins/lens/public/data_views_service/loader.ts b/x-pack/plugins/lens/public/data_views_service/loader.ts index f33ba8f3d37a9..b7af8b5258a70 100644 --- a/x-pack/plugins/lens/public/data_views_service/loader.ts +++ b/x-pack/plugins/lens/public/data_views_service/loader.ts @@ -10,6 +10,7 @@ import type { DataViewsContract, DataView, DataViewSpec } from '@kbn/data-views- import { keyBy } from 'lodash'; import { IndexPattern, IndexPatternField, IndexPatternMap, IndexPatternRef } from '../types'; import { documentField } from '../datasources/form_based/document_field'; +import { sortDataViewRefs } from '../utils'; type ErrorHandler = (err: Error) => void; type MinimalDataViewsContract = Pick; @@ -111,34 +112,10 @@ export function convertDataViewIntoLensIndexPattern( } export async function loadIndexPatternRefs( - dataViews: MinimalDataViewsContract, - adHocDataViews?: Record, - contextDataViewSpec?: DataViewSpec + dataViews: MinimalDataViewsContract ): Promise { - const indexPatterns = await dataViews.getIdsWithTitle(); - const missedIndexPatterns = Object.values(adHocDataViews || {}); - - // add data view from context - if (contextDataViewSpec) { - const existingDataView = indexPatterns.find( - (indexPattern) => indexPattern.id === contextDataViewSpec.id - ); - if (!existingDataView) { - missedIndexPatterns.push(contextDataViewSpec); - } - } - - return indexPatterns - .concat( - missedIndexPatterns.map((dataViewSpec) => ({ - id: dataViewSpec.id!, - name: dataViewSpec.name, - title: dataViewSpec.title!, - })) - ) - .sort((a, b) => { - return a.title.localeCompare(b.title); - }); + const indexPatternsRefs = await dataViews.getIdsWithTitle(); + return sortDataViewRefs(indexPatternsRefs); } /** diff --git a/x-pack/plugins/lens/public/datasources/form_based/loader.ts b/x-pack/plugins/lens/public/datasources/form_based/loader.ts index 54c9efe6f1041..072aa3972f926 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/loader.ts +++ b/x-pack/plugins/lens/public/datasources/form_based/loader.ts @@ -109,9 +109,7 @@ function getUsedIndexPatterns({ const indexPatternIds = []; if (initialContext) { if ('isVisualizeAction' in initialContext) { - for (const { indexPatternId } of initialContext.layers) { - indexPatternIds.push(indexPatternId); - } + indexPatternIds.push(...initialContext.indexPatternIds); } else { indexPatternIds.push(initialContext.dataViewSpec.id!); } diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_helpers.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_helpers.ts index 1f0f6884d9719..e406417c2c2c2 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_helpers.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_helpers.ts @@ -29,7 +29,7 @@ import { import { buildExpression } from './expression_helpers'; import { showMemoizedErrorNotification } from '../../lens_ui_errors'; import { Document } from '../../persistence/saved_object_store'; -import { getActiveDatasourceIdFromDoc } from '../../utils'; +import { getActiveDatasourceIdFromDoc, sortDataViewRefs } from '../../utils'; import type { ErrorMessage } from '../types'; import { getMissingCurrentDatasource, @@ -81,6 +81,20 @@ const getLastUsedIndexPatternId = ( return indexPattern && indexPatternRefs.find((i) => i.id === indexPattern)?.id; }; +const getRefsForAdHocDataViewsFromContext = ( + indexPatternRefs: IndexPatternRef[], + usedIndexPatternsIds: string[], + indexPatterns: Record +) => { + const indexPatternIds = indexPatternRefs.map(({ id }) => id); + const adHocDataViewsIds = usedIndexPatternsIds.filter((id) => !indexPatternIds.includes(id)); + + const adHocDataViewsList = Object.values(indexPatterns).filter(({ id }) => + adHocDataViewsIds.includes(id) + ); + return adHocDataViewsList.map(({ id, title, name }) => ({ id, title, name })); +}; + export async function initializeDataViews( { dataViews, @@ -110,10 +124,10 @@ export async function initializeDataViews( }) ); const { isFullEditor } = options ?? {}; - const contextDataViewSpec = (initialContext as VisualizeFieldContext)?.dataViewSpec; + // make it explicit or TS will infer never[] and break few lines down const indexPatternRefs: IndexPatternRef[] = await (isFullEditor - ? loadIndexPatternRefs(dataViews, adHocDataViews, contextDataViewSpec) + ? loadIndexPatternRefs(dataViews) : []); // if no state is available, use the fallbackId @@ -127,7 +141,7 @@ export async function initializeDataViews( const adHocDataviewsIds: string[] = Object.keys(adHocDataViews || {}); - const usedIndexPatterns = getIndexPatterns( + const usedIndexPatternsIds = getIndexPatterns( references, initialContext, initialId, @@ -137,17 +151,25 @@ export async function initializeDataViews( // load them const availableIndexPatterns = new Set(indexPatternRefs.map(({ id }: IndexPatternRef) => id)); - const notUsedPatterns: string[] = difference([...availableIndexPatterns], usedIndexPatterns); + const notUsedPatterns: string[] = difference([...availableIndexPatterns], usedIndexPatternsIds); const indexPatterns = await loadIndexPatterns({ dataViews, - patterns: usedIndexPatterns, + patterns: usedIndexPatternsIds, notUsedPatterns, cache: {}, adHocDataViews, }); - return { indexPatternRefs, indexPatterns }; + const adHocDataViewsRefs = getRefsForAdHocDataViewsFromContext( + indexPatternRefs, + usedIndexPatternsIds, + indexPatterns + ); + return { + indexPatternRefs: sortDataViewRefs([...indexPatternRefs, ...adHocDataViewsRefs]), + indexPatterns, + }; } /** diff --git a/x-pack/plugins/lens/public/utils.ts b/x-pack/plugins/lens/public/utils.ts index 619c9f7d71f30..01591155c9dd0 100644 --- a/x-pack/plugins/lens/public/utils.ts +++ b/x-pack/plugins/lens/public/utils.ts @@ -284,6 +284,11 @@ export const isOperationFromTheSameGroup = (op1?: DraggingIdentifier, op2?: Drag ); }; +export const sortDataViewRefs = (dataViewRefs: IndexPatternRef[]) => + dataViewRefs.sort((a, b) => { + return a.title.localeCompare(b.title); + }); + export const getSearchWarningMessages = ( adapter: RequestAdapter, datasource: Datasource, From a9162f74814a138bd600d133c23e3c1dc83c5f3c Mon Sep 17 00:00:00 2001 From: Dmitry Tomashevich <39378793+dimaanj@users.noreply.github.com> Date: Wed, 9 Nov 2022 18:55:34 +0300 Subject: [PATCH 4/4] [Discover] Enable `esQuery` alert for adhoc data views (#140885) ## Summary Closes #142514 #142389 This PR does the following: - Enables to create `esQuery` (in KQL or Lucene mode) using adhoc data views from discover and management pages - Adds `explore matching indices` button to data view picker in alert flyout - Adding adhoc data views from alert flyout should propage them to a main discover picker ### 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) - [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 - [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] If a plugin configuration key changed, check if it needs to be allowlisted in the cloud and added to the [docker list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker) - [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)) Co-authored-by: Davis McPhee --- .../search_source/search_source.test.ts | 15 +- .../search/search_source/search_source.ts | 6 +- .../layout/discover_layout.test.tsx | 2 + .../components/layout/discover_layout.tsx | 4 + .../main/components/layout/types.ts | 4 +- .../top_nav/discover_topnav.test.tsx | 2 + .../components/top_nav/discover_topnav.tsx | 11 +- .../top_nav/get_top_nav_links.test.ts | 4 + .../components/top_nav/get_top_nav_links.tsx | 23 +- .../top_nav/open_alerts_popover.test.tsx | 10 +- .../top_nav/open_alerts_popover.tsx | 59 ++++- .../application/main/discover_main_app.tsx | 4 + .../main/discover_main_route.test.tsx | 6 +- .../main/hooks/use_adhoc_data_views.ts | 21 +- .../main/hooks/use_discover_state.ts | 40 ++- .../view_alert/view_alert_route.tsx | 47 ++-- src/plugins/discover/public/build_services.ts | 3 + .../dataview_picker/change_dataview.tsx | 94 ++----- .../dataview_picker/data_view_selector.tsx | 97 ++++++++ .../explore_matching_button.tsx | 67 +++++ .../public/dataview_picker/index.tsx | 8 +- src/plugins/unified_search/public/index.ts | 1 + .../public/search_bar/create_search_bar.tsx | 14 +- .../apps/discover/group2/_adhoc_data_views.ts | 4 +- test/functional/services/toasts.ts | 5 + .../components/search_panel/search_panel.tsx | 1 - .../findings/layout/findings_search_bar.tsx | 2 +- .../components/search_panel/search_panel.tsx | 1 - .../inventory/components/expression.tsx | 2 +- .../metric_anomaly/components/expression.tsx | 2 +- .../components/expression.tsx | 2 +- .../hosts/components/unified_search_bar.tsx | 1 - .../public/alerts/alert_form.test.tsx | 1 + x-pack/plugins/stack_alerts/common/config.ts | 12 - x-pack/plugins/stack_alerts/common/index.ts | 4 - x-pack/plugins/stack_alerts/public/plugin.tsx | 2 + .../data_view_select_popover.test.tsx | 50 +++- .../components/data_view_select_popover.tsx | 128 ++++++---- .../expression/es_query_expression.test.tsx | 1 + .../expression/es_query_expression.tsx | 4 +- .../es_query/expression/expression.test.tsx | 37 ++- .../es_query/expression/expression.tsx | 9 +- .../search_source_expression.test.tsx | 22 +- .../expression/search_source_expression.tsx | 13 +- .../search_source_expression_form.tsx | 42 ++-- .../public/rule_types/es_query/types.ts | 13 +- .../public/rule_types/es_query/util.ts | 2 +- .../public/rule_types/es_query/validation.ts | 12 + .../rule_types/threshold/expression.test.tsx | 1 + x-pack/plugins/stack_alerts/server/index.ts | 6 +- .../sections/rule_form/rule_add.test.tsx | 10 +- .../sections/rule_form/rule_add.tsx | 15 +- .../sections/rule_form/rule_edit.tsx | 16 +- .../sections/rule_form/rule_form.test.tsx | 4 + .../sections/rule_form/rule_form.tsx | 3 + .../triggers_actions_ui/public/types.ts | 9 +- .../apps/discover/search_source_alert.ts | 233 ++++++++++-------- 57 files changed, 811 insertions(+), 400 deletions(-) create mode 100644 src/plugins/unified_search/public/dataview_picker/data_view_selector.tsx create mode 100644 src/plugins/unified_search/public/dataview_picker/explore_matching_button.tsx delete mode 100644 x-pack/plugins/stack_alerts/common/config.ts diff --git a/src/plugins/data/common/search/search_source/search_source.test.ts b/src/plugins/data/common/search/search_source/search_source.test.ts index 4e6192d24e8eb..2ce045f1b6d66 100644 --- a/src/plugins/data/common/search/search_source/search_source.test.ts +++ b/src/plugins/data/common/search/search_source/search_source.test.ts @@ -983,7 +983,11 @@ describe('SearchSource', () => { }, ]; - const indexPattern123 = { id: '123', isPersisted: () => true } as DataView; + const indexPattern123 = { + id: '123', + isPersisted: jest.fn(() => true), + toSpec: jest.fn(), + } as unknown as DataView; test('should return serialized fields', () => { searchSource.setField('index', indexPattern123); @@ -991,6 +995,7 @@ describe('SearchSource', () => { return filter; }); const serializedFields = searchSource.getSerializedFields(); + expect(indexPattern123.toSpec).toHaveBeenCalledTimes(0); expect(serializedFields).toMatchSnapshot(); }); @@ -1000,11 +1005,19 @@ describe('SearchSource', () => { const childSearchSource = searchSource.createChild(); childSearchSource.setField('timeout', '100'); const serializedFields = childSearchSource.getSerializedFields(true); + expect(indexPattern123.toSpec).toHaveBeenCalledTimes(0); expect(serializedFields).toMatchObject({ timeout: '100', parent: { index: '123', from: 123 }, }); }); + + test('should use spec', () => { + indexPattern123.isPersisted = jest.fn(() => false); + searchSource.setField('index', indexPattern123); + searchSource.getSerializedFields(true, false); + expect(indexPattern123.toSpec).toHaveBeenCalledWith(false); + }); }); describe('fetch$', () => { diff --git a/src/plugins/data/common/search/search_source/search_source.ts b/src/plugins/data/common/search/search_source/search_source.ts index fad799c7915b1..717324c647d42 100644 --- a/src/plugins/data/common/search/search_source/search_source.ts +++ b/src/plugins/data/common/search/search_source/search_source.ts @@ -923,7 +923,7 @@ export class SearchSource { /** * serializes search source fields (which can later be passed to {@link ISearchStartSearchSource}) */ - public getSerializedFields(recurse = false): SerializedSearchSourceFields { + public getSerializedFields(recurse = false, includeFields = true): SerializedSearchSourceFields { const { filter: originalFilters, aggs: searchSourceAggs, @@ -938,7 +938,9 @@ export class SearchSource { ...searchSourceFields, }; if (index) { - serializedSearchSourceFields.index = index.isPersisted() ? index.id : index.toSpec(); + serializedSearchSourceFields.index = index.isPersisted() + ? index.id + : index.toSpec(includeFields); } if (sort) { serializedSearchSourceFields.sort = !Array.isArray(sort) ? [sort] : sort; diff --git a/src/plugins/discover/public/application/main/components/layout/discover_layout.test.tsx b/src/plugins/discover/public/application/main/components/layout/discover_layout.test.tsx index 0e9c7f8449520..94b2cec7e00f5 100644 --- a/src/plugins/discover/public/application/main/components/layout/discover_layout.test.tsx +++ b/src/plugins/discover/public/application/main/components/layout/discover_layout.test.tsx @@ -188,6 +188,8 @@ async function mountComponent( persistDataView: jest.fn(), updateAdHocDataViewId: jest.fn(), adHocDataViewList: [], + savedDataViewList: [], + updateDataViewList: jest.fn(), }; const component = mountWithIntl( diff --git a/src/plugins/discover/public/application/main/components/layout/discover_layout.tsx b/src/plugins/discover/public/application/main/components/layout/discover_layout.tsx index a7cde67d4869b..57a0a3c733e71 100644 --- a/src/plugins/discover/public/application/main/components/layout/discover_layout.tsx +++ b/src/plugins/discover/public/application/main/components/layout/discover_layout.tsx @@ -72,6 +72,8 @@ export function DiscoverLayout({ persistDataView, updateAdHocDataViewId, adHocDataViewList, + savedDataViewList, + updateDataViewList, }: DiscoverLayoutProps) { const { trackUiMetric, @@ -233,6 +235,8 @@ export function DiscoverLayout({ persistDataView={persistDataView} updateAdHocDataViewId={updateAdHocDataViewId} adHocDataViewList={adHocDataViewList} + savedDataViewList={savedDataViewList} + updateDataViewList={updateDataViewList} /> Promise; + updateDataViewList: (dataViews: DataView[]) => Promise; updateAdHocDataViewId: (dataView: DataView) => Promise; adHocDataViewList: DataView[]; + savedDataViewList: DataViewListItem[]; } diff --git a/src/plugins/discover/public/application/main/components/top_nav/discover_topnav.test.tsx b/src/plugins/discover/public/application/main/components/top_nav/discover_topnav.test.tsx index 13e3725177959..7ae11388ec3bb 100644 --- a/src/plugins/discover/public/application/main/components/top_nav/discover_topnav.test.tsx +++ b/src/plugins/discover/public/application/main/components/top_nav/discover_topnav.test.tsx @@ -47,6 +47,8 @@ function getProps(savePermissions = true): DiscoverTopNavProps { persistDataView: jest.fn(), updateAdHocDataViewId: jest.fn(), adHocDataViewList: [], + savedDataViewList: [], + updateDataViewList: jest.fn(), }; } diff --git a/src/plugins/discover/public/application/main/components/top_nav/discover_topnav.tsx b/src/plugins/discover/public/application/main/components/top_nav/discover_topnav.tsx index 98e7da1ddfa5f..5c5dc0266952a 100644 --- a/src/plugins/discover/public/application/main/components/top_nav/discover_topnav.tsx +++ b/src/plugins/discover/public/application/main/components/top_nav/discover_topnav.tsx @@ -8,7 +8,7 @@ import React, { useCallback, useEffect, useMemo, useRef } from 'react'; import { useHistory } from 'react-router-dom'; import type { Query, TimeRange, AggregateQuery } from '@kbn/es-query'; -import { DataViewType, type DataView } from '@kbn/data-views-plugin/public'; +import { DataViewListItem, DataViewType, type DataView } from '@kbn/data-views-plugin/public'; import type { DataViewPickerProps } from '@kbn/unified-search-plugin/public'; import { ENABLE_SQL } from '../../../../../common'; import { useDiscoverServices } from '../../../../hooks/use_discover_services'; @@ -38,6 +38,8 @@ export type DiscoverTopNavProps = Pick< persistDataView: (dataView: DataView) => Promise; updateAdHocDataViewId: (dataView: DataView) => Promise; adHocDataViewList: DataView[]; + savedDataViewList: DataViewListItem[]; + updateDataViewList: (DataViewEditorStart: DataView[]) => Promise; }; export const DiscoverTopNav = ({ @@ -58,6 +60,8 @@ export const DiscoverTopNav = ({ persistDataView, updateAdHocDataViewId, adHocDataViewList, + savedDataViewList, + updateDataViewList, }: DiscoverTopNavProps) => { const history = useHistory(); @@ -161,6 +165,8 @@ export const DiscoverTopNav = ({ searchSource, onOpenSavedSearch, isPlainRecord, + adHocDataViews: adHocDataViewList, + updateDataViewList, persistDataView, updateAdHocDataViewId, }), @@ -174,8 +180,10 @@ export const DiscoverTopNav = ({ searchSource, onOpenSavedSearch, isPlainRecord, + adHocDataViewList, persistDataView, updateAdHocDataViewId, + updateDataViewList, ] ); @@ -213,6 +221,7 @@ export const DiscoverTopNav = ({ onChangeDataView, textBasedLanguages: supportedTextBasedLanguages as DataViewPickerProps['textBasedLanguages'], adHocDataViews: adHocDataViewList, + savedDataViewList, }; const onTextBasedSavedAndExit = useCallback( diff --git a/src/plugins/discover/public/application/main/components/top_nav/get_top_nav_links.test.ts b/src/plugins/discover/public/application/main/components/top_nav/get_top_nav_links.test.ts index af9c868f0ce78..8a04a4a4e9ff3 100644 --- a/src/plugins/discover/public/application/main/components/top_nav/get_top_nav_links.test.ts +++ b/src/plugins/discover/public/application/main/components/top_nav/get_top_nav_links.test.ts @@ -38,6 +38,8 @@ test('getTopNavLinks result', () => { onOpenSavedSearch: () => {}, isPlainRecord: false, persistDataView: jest.fn(), + updateDataViewList: jest.fn(), + adHocDataViews: [], updateAdHocDataViewId: jest.fn(), }); expect(topNavLinks).toMatchInlineSnapshot(` @@ -102,6 +104,8 @@ test('getTopNavLinks result for sql mode', () => { onOpenSavedSearch: () => {}, isPlainRecord: true, persistDataView: jest.fn(), + updateDataViewList: jest.fn(), + adHocDataViews: [], updateAdHocDataViewId: jest.fn(), }); expect(topNavLinks).toMatchInlineSnapshot(` diff --git a/src/plugins/discover/public/application/main/components/top_nav/get_top_nav_links.tsx b/src/plugins/discover/public/application/main/components/top_nav/get_top_nav_links.tsx index 7228049287f81..c72c0539fc593 100644 --- a/src/plugins/discover/public/application/main/components/top_nav/get_top_nav_links.tsx +++ b/src/plugins/discover/public/application/main/components/top_nav/get_top_nav_links.tsx @@ -34,6 +34,8 @@ export const getTopNavLinks = ({ onOpenSavedSearch, isPlainRecord, persistDataView, + adHocDataViews, + updateDataViewList, updateAdHocDataViewId, }: { dataView: DataView; @@ -45,6 +47,8 @@ export const getTopNavLinks = ({ searchSource: ISearchSource; onOpenSavedSearch: (id: string) => void; isPlainRecord: boolean; + adHocDataViews: DataView[]; + updateDataViewList: (dataView: DataView[]) => Promise; persistDataView: (dataView: DataView) => Promise; updateAdHocDataViewId: (dataView: DataView) => Promise; }): TopNavMenuData[] => { @@ -75,16 +79,15 @@ export const getTopNavLinks = ({ defaultMessage: 'Alerts', }), run: async (anchorElement: HTMLElement) => { - const updatedDataView = await persistDataView(dataView); - if (updatedDataView) { - openAlertsPopover({ - I18nContext: services.core.i18n.Context, - anchorElement, - searchSource: savedSearch.searchSource, - services, - savedQueryId: state.appStateContainer.getState().savedQuery, - }); - } + openAlertsPopover({ + I18nContext: services.core.i18n.Context, + anchorElement, + searchSource: savedSearch.searchSource, + services, + adHocDataViews, + updateDataViewList, + savedQueryId: state.appStateContainer.getState().savedQuery, + }); }, testId: 'discoverAlertsButton', }; diff --git a/src/plugins/discover/public/application/main/components/top_nav/open_alerts_popover.test.tsx b/src/plugins/discover/public/application/main/components/top_nav/open_alerts_popover.test.tsx index 97ba072c2b38b..7412dfe599cbe 100644 --- a/src/plugins/discover/public/application/main/components/top_nav/open_alerts_popover.test.tsx +++ b/src/plugins/discover/public/application/main/components/top_nav/open_alerts_popover.test.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import React from 'react'; +import React, { ReactNode } from 'react'; import { mountWithIntl } from '@kbn/test-jest-helpers'; import { findTestSubject } from '@elastic/eui/lib/test'; import { createSearchSourceMock } from '@kbn/data-plugin/public/mocks'; @@ -16,6 +16,8 @@ import { discoverServiceMock } from '../../../../__mocks__/services'; import { dataViewWithTimefieldMock } from '../../../../__mocks__/data_view_with_timefield'; import { dataViewMock } from '../../../../__mocks__/data_view'; +const Context = ({ children }: { children: ReactNode }) => <>{children}; + const mount = (dataView = dataViewMock) => mountWithIntl( @@ -23,7 +25,11 @@ const mount = (dataView = dataViewMock) => searchSource={createSearchSourceMock({ index: dataView })} anchorElement={document.createElement('div')} savedQueryId={undefined} - onClose={() => {}} + adHocDataViews={[]} + services={discoverServiceMock} + updateDataViewList={jest.fn()} + onClose={jest.fn()} + I18nContext={Context} /> ); diff --git a/src/plugins/discover/public/application/main/components/top_nav/open_alerts_popover.tsx b/src/plugins/discover/public/application/main/components/top_nav/open_alerts_popover.tsx index eb7190a82b023..bf484452b731f 100644 --- a/src/plugins/discover/public/application/main/components/top_nav/open_alerts_popover.tsx +++ b/src/plugins/discover/public/application/main/components/top_nav/open_alerts_popover.tsx @@ -11,11 +11,10 @@ import ReactDOM from 'react-dom'; import { I18nStart } from '@kbn/core/public'; import { EuiWrappingPopover, EuiContextMenu } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; -import { ISearchSource } from '@kbn/data-plugin/common'; +import type { DataView, ISearchSource } from '@kbn/data-plugin/common'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; import { DiscoverServices } from '../../../../build_services'; import { updateSearchSource } from '../../utils/update_search_source'; -import { useDiscoverServices } from '../../../../hooks/use_discover_services'; const container = document.createElement('div'); let isOpen = false; @@ -27,16 +26,27 @@ interface AlertsPopoverProps { anchorElement: HTMLElement; searchSource: ISearchSource; savedQueryId?: string; + adHocDataViews: DataView[]; + I18nContext: I18nStart['Context']; + services: DiscoverServices; + updateDataViewList: (dataViews: DataView[]) => Promise; +} + +interface EsQueryAlertMetaData { + isManagementPage?: boolean; + adHocDataViewList: DataView[]; } export function AlertsPopover({ searchSource, anchorElement, savedQueryId, + adHocDataViews, + services, onClose: originalOnClose, + updateDataViewList, }: AlertsPopoverProps) { const dataView = searchSource.getField('index')!; - const services = useDiscoverServices(); const { triggersActionsUi } = services; const [alertFlyoutVisible, setAlertFlyoutVisibility] = useState(false); const onClose = useCallback(() => { @@ -63,20 +73,45 @@ export function AlertsPopover({ }; }, [savedQueryId, searchSource, services]); + const discoverMetadata: EsQueryAlertMetaData = useMemo( + () => ({ + isManagementPage: false, + adHocDataViewList: adHocDataViews, + }), + [adHocDataViews] + ); + const SearchThresholdAlertFlyout = useMemo(() => { if (!alertFlyoutVisible) { return; } + + const onFinishFlyoutInteraction = (metadata: EsQueryAlertMetaData) => { + updateDataViewList(metadata.adHocDataViewList); + }; + return triggersActionsUi?.getAddAlertFlyout({ + metadata: discoverMetadata, consumer: 'discover', - onClose, + onClose: (_, metadata) => { + onFinishFlyoutInteraction(metadata as EsQueryAlertMetaData); + onClose(); + }, + onSave: async (metadata) => { + onFinishFlyoutInteraction(metadata as EsQueryAlertMetaData); + }, canChangeTrigger: false, ruleTypeId: ALERT_TYPE_ID, - initialValues: { - params: getParams(), - }, + initialValues: { params: getParams() }, }); - }, [getParams, onClose, triggersActionsUi, alertFlyoutVisible]); + }, [ + alertFlyoutVisible, + triggersActionsUi, + discoverMetadata, + getParams, + updateDataViewList, + onClose, + ]); const hasTimeFieldName = dataView.timeFieldName; const panels = [ @@ -145,13 +180,17 @@ export function openAlertsPopover({ anchorElement, searchSource, services, + adHocDataViews, savedQueryId, + updateDataViewList, }: { I18nContext: I18nStart['Context']; anchorElement: HTMLElement; searchSource: ISearchSource; services: DiscoverServices; + adHocDataViews: DataView[]; savedQueryId?: string; + updateDataViewList: (dataViews: DataView[]) => Promise; }) { if (isOpen) { closeAlertsPopover(); @@ -169,6 +208,10 @@ export function openAlertsPopover({ anchorElement={anchorElement} searchSource={searchSource} savedQueryId={savedQueryId} + adHocDataViews={adHocDataViews} + I18nContext={I18nContext} + services={services} + updateDataViewList={updateDataViewList} /> diff --git a/src/plugins/discover/public/application/main/discover_main_app.tsx b/src/plugins/discover/public/application/main/discover_main_app.tsx index abd714fea8f07..8ae4bbea6e269 100644 --- a/src/plugins/discover/public/application/main/discover_main_app.tsx +++ b/src/plugins/discover/public/application/main/discover_main_app.tsx @@ -56,12 +56,14 @@ export function DiscoverMainApp(props: DiscoverMainProps) { onUpdateQuery, persistDataView, updateAdHocDataViewId, + updateDataViewList, refetch$, resetSavedSearch, searchSource, state, stateContainer, adHocDataViewList, + savedDataViewList, } = useDiscoverState({ services, history: usedHistory, @@ -120,7 +122,9 @@ export function DiscoverMainApp(props: DiscoverMainProps) { stateContainer={stateContainer} persistDataView={persistDataView} updateAdHocDataViewId={updateAdHocDataViewId} + updateDataViewList={updateDataViewList} adHocDataViewList={adHocDataViewList} + savedDataViewList={savedDataViewList} /> ); diff --git a/src/plugins/discover/public/application/main/discover_main_route.test.tsx b/src/plugins/discover/public/application/main/discover_main_route.test.tsx index 7ee1c7e4b134f..ae3034896c268 100644 --- a/src/plugins/discover/public/application/main/discover_main_route.test.tsx +++ b/src/plugins/discover/public/application/main/discover_main_route.test.tsx @@ -14,20 +14,20 @@ import { discoverServiceMock } from '../../__mocks__/services'; import { DiscoverMainRoute } from './discover_main_route'; import { MemoryRouter } from 'react-router-dom'; import { dataViewMock } from '../../__mocks__/data_view'; -import { SavedObject, ScopedHistory } from '@kbn/core/public'; +import { SavedObject } from '@kbn/core/public'; import { DataViewSavedObjectAttrs } from '@kbn/data-views-plugin/common'; import { DiscoverMainApp } from './discover_main_app'; import { SearchSource } from '@kbn/data-plugin/common'; import { searchSourceInstanceMock } from '@kbn/data-plugin/common/search/search_source/mocks'; import { findTestSubject } from '@elastic/eui/lib/test'; +import { scopedHistoryMock } from '@kbn/core/public/mocks'; jest.mock('./discover_main_app', () => { return { DiscoverMainApp: jest.fn().mockReturnValue(<>), }; }); -setScopedHistory({ location: {} } as ScopedHistory); - +setScopedHistory(scopedHistoryMock.create()); describe('DiscoverMainRoute', () => { test('renders the main app when hasESData=true & hasUserDataView=true ', async () => { const component = mountComponent(true, true); diff --git a/src/plugins/discover/public/application/main/hooks/use_adhoc_data_views.ts b/src/plugins/discover/public/application/main/hooks/use_adhoc_data_views.ts index 89fd4477da34c..fdd6624200711 100644 --- a/src/plugins/discover/public/application/main/hooks/use_adhoc_data_views.ts +++ b/src/plugins/discover/public/application/main/hooks/use_adhoc_data_views.ts @@ -69,11 +69,11 @@ export const useAdHocDataViews = ({ prev.filter((d) => d.id && dataViewToUpdate.id && d.id !== dataViewToUpdate.id) ); + // update filters references const uiActions = await getUiActions(); const trigger = uiActions.getTrigger(UPDATE_FILTER_REFERENCES_TRIGGER); const action = uiActions.getAction(UPDATE_FILTER_REFERENCES_ACTION); - // execute shouldn't be awaited, this is important for pending history push cancellation action?.execute({ trigger, fromDataView: dataViewToUpdate.id, @@ -81,11 +81,12 @@ export const useAdHocDataViews = ({ usedDataViews: [], } as ActionExecutionContext); + savedSearch.searchSource.setField('index', newDataView); stateContainer.replaceUrlAppState({ index: newDataView.id }); setUrlTracking(newDataView); return newDataView; }, - [dataViews, setUrlTracking, stateContainer] + [dataViews, setUrlTracking, stateContainer, savedSearch.searchSource] ); const { openConfirmSavePrompt, updateSavedSearch } = @@ -105,5 +106,19 @@ export const useAdHocDataViews = ({ return currentDataView; }, [stateContainer, openConfirmSavePrompt, savedSearch, updateSavedSearch]); - return { adHocDataViewList, persistDataView, updateAdHocDataViewId }; + const onAddAdHocDataViews = useCallback((newDataViews: DataView[]) => { + setAdHocDataViewList((prev) => { + const newAdHocDataViews = newDataViews.filter( + (newDataView) => !prev.find((d) => d.id === newDataView.id) + ); + return [...prev, ...newAdHocDataViews]; + }); + }, []); + + return { + adHocDataViewList, + persistDataView, + updateAdHocDataViewId, + onAddAdHocDataViews, + }; }; diff --git a/src/plugins/discover/public/application/main/hooks/use_discover_state.ts b/src/plugins/discover/public/application/main/hooks/use_discover_state.ts index ff448e59917a8..d050ce414de39 100644 --- a/src/plugins/discover/public/application/main/hooks/use_discover_state.ts +++ b/src/plugins/discover/public/application/main/hooks/use_discover_state.ts @@ -8,7 +8,7 @@ import { useMemo, useEffect, useState, useCallback } from 'react'; import { isEqual } from 'lodash'; import { History } from 'history'; -import { DataViewListItem, DataViewType } from '@kbn/data-views-plugin/public'; +import { type DataViewListItem, type DataView, DataViewType } from '@kbn/data-views-plugin/public'; import { SavedSearch, getSavedSearch } from '@kbn/saved-search-plugin/public'; import type { SortOrder } from '@kbn/saved-search-plugin/public'; import { useTextBasedQueryLanguage } from './use_text_based_query_language'; @@ -36,7 +36,7 @@ export function useDiscoverState({ history, savedSearch, setExpandedDoc, - dataViewList, + dataViewList: initialDataViewList, }: { services: DiscoverServices; savedSearch: SavedSearch; @@ -124,15 +124,29 @@ export function useDiscoverState({ /** * Adhoc data views functionality */ - const { adHocDataViewList, persistDataView, updateAdHocDataViewId } = useAdHocDataViews({ - dataView, - stateContainer, - savedSearch, - setUrlTracking, - dataViews, - toastNotifications, - filterManager, - }); + const { adHocDataViewList, persistDataView, updateAdHocDataViewId, onAddAdHocDataViews } = + useAdHocDataViews({ + dataView, + dataViews, + stateContainer, + savedSearch, + setUrlTracking, + filterManager, + toastNotifications, + }); + + const [savedDataViewList, setSavedDataViewList] = useState(initialDataViewList); + + /** + * Updates data views selector state + */ + const updateDataViewList = useCallback( + async (newAdHocDataViews: DataView[]) => { + setSavedDataViewList(await data.dataViews.getIdsWithTitle()); + onAddAdHocDataViews(newAdHocDataViews); + }, + [data.dataViews, onAddAdHocDataViews] + ); /** * Data fetching logic @@ -153,7 +167,7 @@ export function useDiscoverState({ documents$: data$.documents$, dataViews, stateContainer, - dataViewList, + dataViewList: savedDataViewList, savedSearch, }); @@ -306,7 +320,9 @@ export function useDiscoverState({ state, stateContainer, adHocDataViewList, + savedDataViewList, persistDataView, updateAdHocDataViewId, + updateDataViewList, }; } diff --git a/src/plugins/discover/public/application/view_alert/view_alert_route.tsx b/src/plugins/discover/public/application/view_alert/view_alert_route.tsx index 3efac8b504811..0fdd58c058237 100644 --- a/src/plugins/discover/public/application/view_alert/view_alert_route.tsx +++ b/src/plugins/discover/public/application/view_alert/view_alert_route.tsx @@ -78,23 +78,11 @@ export function ViewAlertRoute() { history.push(DISCOVER_MAIN_ROUTE); return; } - - const calculatedChecksum = getCurrentChecksum(fetchedAlert.params); - // rule params changed - if (openActualAlert && calculatedChecksum !== queryParams.checksum) { - displayRuleChangedWarn(); - } - // documents might be updated or deleted - else if (openActualAlert && calculatedChecksum === queryParams.checksum) { - displayPossibleDocsDiffInfoAlert(); - } - const fetchedSearchSource = await fetchSearchSource(fetchedAlert); if (!fetchedSearchSource) { history.push(DISCOVER_MAIN_ROUTE); return; } - const dataView = fetchedSearchSource.getField('index'); const timeFieldName = dataView?.timeFieldName; // data view fetch error @@ -104,15 +92,30 @@ export function ViewAlertRoute() { return; } - const dataViewSavedObject = await core.savedObjects.client.get('index-pattern', dataView.id!); - const alertUpdatedAt = fetchedAlert.updatedAt; - const dataViewUpdatedAt = dataViewSavedObject.updatedAt!; - // data view updated after the last update of the alert rule - if ( - openActualAlert && - new Date(dataViewUpdatedAt).valueOf() > new Date(alertUpdatedAt).valueOf() - ) { - showDataViewUpdatedWarning(); + if (dataView.isPersisted()) { + const dataViewSavedObject = await core.savedObjects.client.get( + 'index-pattern', + dataView.id! + ); + + const alertUpdatedAt = fetchedAlert.updatedAt; + const dataViewUpdatedAt = dataViewSavedObject.updatedAt!; + // data view updated after the last update of the alert rule + if ( + openActualAlert && + new Date(dataViewUpdatedAt).valueOf() > new Date(alertUpdatedAt).valueOf() + ) { + showDataViewUpdatedWarning(); + } + } + + const calculatedChecksum = getCurrentChecksum(fetchedAlert.params); + // rule params changed + if (openActualAlert && calculatedChecksum !== queryParams.checksum) { + displayRuleChangedWarn(); + } else if (openActualAlert && calculatedChecksum === queryParams.checksum) { + // documents might be updated or deleted + displayPossibleDocsDiffInfoAlert(); } const timeRange = openActualAlert @@ -120,7 +123,7 @@ export function ViewAlertRoute() { : buildTimeRangeFilter(dataView, fetchedAlert, timeFieldName); const state: DiscoverAppLocatorParams = { query: fetchedSearchSource.getField('query') || data.query.queryString.getDefaultQuery(), - dataViewId: dataView.id, + dataViewSpec: dataView.toSpec(false), timeRange, }; diff --git a/src/plugins/discover/public/build_services.ts b/src/plugins/discover/public/build_services.ts index 8271e7681f7da..4c25f466b0fdd 100644 --- a/src/plugins/discover/public/build_services.ts +++ b/src/plugins/discover/public/build_services.ts @@ -52,6 +52,9 @@ import { DiscoverStartPlugins } from './plugin'; import { DiscoverContextAppLocator } from './application/context/services/locator'; import { DiscoverSingleDocLocator } from './application/doc/locator'; +/** + * Location state of internal Discover history instance + */ export interface HistoryLocationState { referrer: string; } diff --git a/src/plugins/unified_search/public/dataview_picker/change_dataview.tsx b/src/plugins/unified_search/public/dataview_picker/change_dataview.tsx index 89132b5943d3c..e218f27dadc55 100644 --- a/src/plugins/unified_search/public/dataview_picker/change_dataview.tsx +++ b/src/plugins/unified_search/public/dataview_picker/change_dataview.tsx @@ -7,7 +7,7 @@ */ import { i18n } from '@kbn/i18n'; -import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'; +import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { css } from '@emotion/react'; import { EuiPopover, @@ -24,16 +24,16 @@ import { EuiFlexItem, EuiButtonEmpty, EuiToolTip, - EuiSpacer, } from '@elastic/eui'; import { useKibana } from '@kbn/kibana-react-plugin/public'; import type { IUnifiedSearchPluginServices } from '../types'; import type { DataViewPickerPropsExtended } from '.'; -import { type DataViewListItemEnhanced, DataViewsList } from './dataview_list'; +import type { DataViewListItemEnhanced } from './dataview_list'; import type { TextBasedLanguagesListProps } from './text_languages_list'; import type { TextBasedLanguagesTransitionModalProps } from './text_languages_transition_modal'; import adhoc from './assets/adhoc.svg'; import { changeDataViewStyles } from './change_dataview.styles'; +import { DataViewSelector } from './data_view_selector'; // local storage key for the text based languages transition modal const TEXT_LANG_TRANSITION_MODAL_KEY = 'data.textLangTransitionModal'; @@ -62,6 +62,7 @@ export function ChangeDataView({ isMissingCurrent, currentDataViewId, adHocDataViews, + savedDataViews, onChangeDataView, onAddField, onDataViewCreated, @@ -77,9 +78,6 @@ export function ChangeDataView({ }: DataViewPickerPropsExtended) { const { euiTheme } = useEuiTheme(); const [isPopoverOpen, setPopoverIsOpen] = useState(false); - const [noDataViewMatches, setNoDataViewMatches] = useState(false); - const [dataViewSearchString, setDataViewSearchString] = useState(''); - const [indexMatches, setIndexMatches] = useState(0); const [dataViewsList, setDataViewsList] = useState([]); const [triggerLabel, setTriggerLabel] = useState(''); const [isTextBasedLangSelected, setIsTextBasedLangSelected] = useState( @@ -100,7 +98,9 @@ export function ChangeDataView({ useEffect(() => { const fetchDataViews = async () => { - const dataViewsRefs: DataViewListItemEnhanced[] = await data.dataViews.getIdsWithTitle(); + const dataViewsRefs: DataViewListItemEnhanced[] = savedDataViews + ? savedDataViews + : await data.dataViews.getIdsWithTitle(); if (adHocDataViews?.length) { adHocDataViews.forEach((adHocDataView) => { if (adHocDataView.id) { @@ -116,25 +116,7 @@ export function ChangeDataView({ setDataViewsList(dataViewsRefs); }; fetchDataViews(); - }, [data, currentDataViewId, adHocDataViews]); - - const pendingIndexMatch = useRef(); - useEffect(() => { - async function checkIndices() { - if (dataViewSearchString !== '' && noDataViewMatches) { - const matches = await kibana.services.dataViews.getIndices({ - pattern: dataViewSearchString, - isRollupIndex: () => false, - showAllIndices: false, - }); - setIndexMatches(matches.length); - } - } - if (pendingIndexMatch.current) { - clearTimeout(pendingIndexMatch.current); - } - pendingIndexMatch.current = setTimeout(checkIndices, 250); - }, [dataViewSearchString, kibana.services.dataViews, noDataViewMatches]); + }, [data, currentDataViewId, adHocDataViews, savedDataViews]); useEffect(() => { if (trigger.label) { @@ -313,9 +295,13 @@ export function ChangeDataView({ )} - - { const dataView = await data.dataViews.get(newId); await data.dataViews.refreshFields(dataView); @@ -336,58 +322,8 @@ export function ChangeDataView({ onChangeDataView(newId); } }} - currentDataViewId={currentDataViewId} - selectableProps={{ - ...(selectableProps || {}), - // @ts-expect-error Some EUI weirdness - searchProps: { - ...(selectableProps?.searchProps || {}), - onChange: (value, matches) => { - selectableProps?.searchProps?.onChange?.(value, matches); - setNoDataViewMatches(matches.length === 0 && dataViewsList.length > 0); - setDataViewSearchString(value); - }, - }, - }} - searchListInputId={searchListInputId} - isTextBasedLangSelected={isTextBasedLangSelected} + onCreateDefaultAdHocDataView={onCreateDefaultAdHocDataView} /> - {onCreateDefaultAdHocDataView && noDataViewMatches && indexMatches > 0 && ( - - - { - setPopoverIsOpen(false); - onCreateDefaultAdHocDataView(dataViewSearchString); - }} - > - {i18n.translate( - 'unifiedSearch.query.queryBar.indexPattern.createForMatchingIndices', - { - defaultMessage: `Explore {indicesLength, plural, - one {# matching index} - other {# matching indices}}`, - values: { - indicesLength: indexMatches, - }, - } - )} - - - - - )} ); diff --git a/src/plugins/unified_search/public/dataview_picker/data_view_selector.tsx b/src/plugins/unified_search/public/dataview_picker/data_view_selector.tsx new file mode 100644 index 0000000000000..9a83a887de4cf --- /dev/null +++ b/src/plugins/unified_search/public/dataview_picker/data_view_selector.tsx @@ -0,0 +1,97 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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, { Fragment, useEffect, useRef, useState } from 'react'; +import type { EuiSelectableProps } from '@elastic/eui'; +import type { DataViewListItem } from '@kbn/data-views-plugin/public'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { DataViewsList } from './dataview_list'; +import { IUnifiedSearchPluginServices } from '../types'; +import { ExploreMatchingButton } from './explore_matching_button'; + +interface DataViewSelectorProps { + currentDataViewId?: string; + searchListInputId?: string; + dataViewsList: DataViewListItem[]; + selectableProps?: EuiSelectableProps; + isTextBasedLangSelected: boolean; + setPopoverIsOpen: (isOpen: boolean) => void; + onChangeDataView: (dataViewId: string) => void; + onCreateDefaultAdHocDataView?: (pattern: string) => void; +} + +export const DataViewSelector = ({ + currentDataViewId, + searchListInputId, + dataViewsList, + selectableProps, + isTextBasedLangSelected, + setPopoverIsOpen, + onChangeDataView, + onCreateDefaultAdHocDataView, +}: DataViewSelectorProps) => { + const kibana = useKibana(); + const { dataViews } = kibana.services; + + const [noDataViewMatches, setNoDataViewMatches] = useState(false); + const [dataViewSearchString, setDataViewSearchString] = useState(''); + const [indexMatches, setIndexMatches] = useState(0); + + const pendingIndexMatch = useRef(); + + useEffect(() => { + async function checkIndices() { + if (dataViewSearchString !== '' && noDataViewMatches) { + const matches = await dataViews.getIndices({ + pattern: dataViewSearchString, + isRollupIndex: () => false, + showAllIndices: false, + }); + setIndexMatches(matches.length); + } + } + + pendingIndexMatch.current = setTimeout(checkIndices, 250); + return () => { + if (pendingIndexMatch.current) { + clearTimeout(pendingIndexMatch.current); + } + }; + }, [dataViewSearchString, dataViews, noDataViewMatches]); + + return ( + + { + selectableProps?.searchProps?.onChange?.(value, matches); + setNoDataViewMatches(matches.length === 0 && dataViewsList.length > 0); + setDataViewSearchString(value); + }, + }, + }} + searchListInputId={searchListInputId} + isTextBasedLangSelected={isTextBasedLangSelected} + /> + + + ); +}; diff --git a/src/plugins/unified_search/public/dataview_picker/explore_matching_button.tsx b/src/plugins/unified_search/public/dataview_picker/explore_matching_button.tsx new file mode 100644 index 0000000000000..db98916b1a080 --- /dev/null +++ b/src/plugins/unified_search/public/dataview_picker/explore_matching_button.tsx @@ -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 React from 'react'; +import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiSpacer, useEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/css'; +import { i18n } from '@kbn/i18n'; + +interface ExploreMatchingButtonProps { + noDataViewMatches: boolean; + indexMatches: number; + dataViewSearchString: string; + onCreateDefaultAdHocDataView?: (pattern: string) => void; + setPopoverIsOpen: (isOpen: boolean) => void; +} + +export const ExploreMatchingButton = ({ + noDataViewMatches, + indexMatches, + dataViewSearchString, + setPopoverIsOpen, + onCreateDefaultAdHocDataView, +}: ExploreMatchingButtonProps) => { + const { euiTheme } = useEuiTheme(); + + if (onCreateDefaultAdHocDataView && noDataViewMatches && indexMatches > 0) { + return ( + + + { + setPopoverIsOpen(false); + onCreateDefaultAdHocDataView(dataViewSearchString); + }} + > + {i18n.translate('unifiedSearch.query.queryBar.indexPattern.createForMatchingIndices', { + defaultMessage: `Explore {indicesLength, plural, + one {# matching index} + other {# matching indices}}`, + values: { + indicesLength: indexMatches, + }, + })} + + + + + ); + } + return null; +}; diff --git a/src/plugins/unified_search/public/dataview_picker/index.tsx b/src/plugins/unified_search/public/dataview_picker/index.tsx index 8ed524b32ea12..9cb50d5e85a79 100644 --- a/src/plugins/unified_search/public/dataview_picker/index.tsx +++ b/src/plugins/unified_search/public/dataview_picker/index.tsx @@ -8,7 +8,7 @@ import React from 'react'; import type { EuiButtonProps, EuiSelectableProps } from '@elastic/eui'; -import type { DataView } from '@kbn/data-views-plugin/public'; +import type { DataView, DataViewListItem } from '@kbn/data-views-plugin/public'; import type { AggregateQuery, Query } from '@kbn/es-query'; import { ChangeDataView } from './change_dataview'; @@ -54,6 +54,10 @@ export interface DataViewPickerProps { * The adHocDataviews. */ adHocDataViews?: DataView[]; + /** + * Saved data views + */ + savedDataViews?: DataViewListItem[]; /** * EuiSelectable properties. */ @@ -102,6 +106,7 @@ export const DataViewPicker = ({ isMissingCurrent, currentDataViewId, adHocDataViews, + savedDataViews, onChangeDataView, onEditDataView, onAddField, @@ -126,6 +131,7 @@ export const DataViewPicker = ({ onCreateDefaultAdHocDataView={onCreateDefaultAdHocDataView} trigger={trigger} adHocDataViews={adHocDataViews} + savedDataViews={savedDataViews} selectableProps={selectableProps} textBasedLanguages={textBasedLanguages} onSaveTextLanguageQuery={onSaveTextLanguageQuery} diff --git a/src/plugins/unified_search/public/index.ts b/src/plugins/unified_search/public/index.ts index 731396014d835..afaaa537d6cf2 100755 --- a/src/plugins/unified_search/public/index.ts +++ b/src/plugins/unified_search/public/index.ts @@ -21,6 +21,7 @@ export { SearchBar } from './search_bar'; export type { FilterItemsProps } from './filter_bar'; export { FilterLabel, FilterItem, FilterItems } from './filter_bar'; export { DataViewsList } from './dataview_picker/dataview_list'; +export { DataViewSelector } from './dataview_picker/data_view_selector'; export { DataViewPicker } from './dataview_picker'; export type { DataViewPickerProps } from './dataview_picker'; diff --git a/src/plugins/unified_search/public/search_bar/create_search_bar.tsx b/src/plugins/unified_search/public/search_bar/create_search_bar.tsx index 0320626bb0244..d6e02f55dfa46 100644 --- a/src/plugins/unified_search/public/search_bar/create_search_bar.tsx +++ b/src/plugins/unified_search/public/search_bar/create_search_bar.tsx @@ -38,12 +38,20 @@ export type StatefulSearchBarProps = useDefaultBehaviors?: boolean; savedQueryId?: string; onSavedQueryIdChange?: (savedQueryId?: string) => void; + onFiltersUpdated?: (filters: Filter[]) => void; }; // Respond to user changing the filters -const defaultFiltersUpdated = (queryService: QueryStart) => { +const defaultFiltersUpdated = ( + queryService: QueryStart, + onFiltersUpdated?: (filters: Filter[]) => void +) => { return (filters: Filter[]) => { - queryService.filterManager.setFilters(filters); + if (onFiltersUpdated) { + onFiltersUpdated(filters); + } else { + queryService.filterManager.setFilters(filters); + } }; }; @@ -206,7 +214,7 @@ export function createSearchBar({ isRefreshPaused={refreshInterval.pause} filters={filters} query={query} - onFiltersUpdated={defaultFiltersUpdated(data.query)} + onFiltersUpdated={defaultFiltersUpdated(data.query, props.onFiltersUpdated)} onRefreshChange={defaultOnRefreshChange(data.query)} savedQuery={savedQuery} onQuerySubmit={defaultOnQuerySubmit(props, data.query, query)} diff --git a/test/functional/apps/discover/group2/_adhoc_data_views.ts b/test/functional/apps/discover/group2/_adhoc_data_views.ts index ebce26a4a4310..773471994237f 100644 --- a/test/functional/apps/discover/group2/_adhoc_data_views.ts +++ b/test/functional/apps/discover/group2/_adhoc_data_views.ts @@ -14,8 +14,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const toasts = getService('toasts'); const esArchiver = getService('esArchiver'); const filterBar = getService('filterBar'); - const dashboardAddPanel = getService('dashboardAddPanel'); const fieldEditor = getService('fieldEditor'); + const dashboardAddPanel = getService('dashboardAddPanel'); const kibanaServer = getService('kibanaServer'); const retry = getService('retry'); const queryBar = getService('queryBar'); @@ -132,7 +132,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('should update data view id when saving data view from hoc one', async () => { const prevDataViewId = await PageObjects.discover.getCurrentDataViewId(); - await testSubjects.click('discoverAlertsButton'); + await testSubjects.click('shareTopNavButton'); await testSubjects.click('confirmModalConfirmButton'); await PageObjects.header.waitUntilLoadingHasFinished(); diff --git a/test/functional/services/toasts.ts b/test/functional/services/toasts.ts index 9e7755c09b662..b40d30f5aa27d 100644 --- a/test/functional/services/toasts.ts +++ b/test/functional/services/toasts.ts @@ -67,6 +67,11 @@ export class ToastsService extends FtrService { return await list.findByCssSelector(`.euiToast:nth-child(${index})`); } + public async getToastContent(index: number) { + const elem = await this.getToastElement(index); + return await elem.getVisibleText(); + } + public async getAllToastElements() { const list = await this.getGlobalToastList(); return await list.findAllByCssSelector(`.euiToast`); diff --git a/x-pack/plugins/aiops/public/components/search_panel/search_panel.tsx b/x-pack/plugins/aiops/public/components/search_panel/search_panel.tsx index 9388ba27af0b8..240d4415647ce 100644 --- a/x-pack/plugins/aiops/public/components/search_panel/search_panel.tsx +++ b/x-pack/plugins/aiops/public/components/search_panel/search_panel.tsx @@ -117,7 +117,6 @@ export const SearchPanel: FC = ({ displayStyle={'inPage'} isClearable={true} customSubmitButton={
} - // @ts-expect-error onFiltersUpdated is a valid prop on SearchBar onFiltersUpdated={(filters: Filter[]) => searchHandler({ filters })} /> diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/layout/findings_search_bar.tsx b/x-pack/plugins/cloud_security_posture/public/pages/findings/layout/findings_search_bar.tsx index b2272b47c543b..ed7d896c8b907 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/findings/layout/findings_search_bar.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/layout/findings_search_bar.tsx @@ -10,6 +10,7 @@ import { EuiThemeComputed, useEuiTheme } from '@elastic/eui'; import { useKibana } from '@kbn/kibana-react-plugin/public'; import type { DataView } from '@kbn/data-plugin/common'; import { i18n } from '@kbn/i18n'; +import type { Filter } from '@kbn/es-query'; import { SecuritySolutionContext } from '../../../application/security_solution_context'; import * as TEST_SUBJECTS from '../test_subjects'; import type { FindingsBaseURLQuery } from '../types'; @@ -49,7 +50,6 @@ export const FindingsSearchBar = ({ isLoading={loading} indexPatterns={[dataView]} onQuerySubmit={setQuery} - // @ts-expect-error onFiltersUpdated is a valid prop on SearchBar onFiltersUpdated={(value: Filter[]) => setQuery({ filters: value })} placeholder={i18n.translate('xpack.csp.findings.searchBar.searchPlaceholder', { defaultMessage: 'Search findings (eg. rule.section : "API Server" )', diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/search_panel/search_panel.tsx b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/search_panel/search_panel.tsx index e7d5a94ec45d1..ac3d9c05c1b97 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/search_panel/search_panel.tsx +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/search_panel/search_panel.tsx @@ -136,7 +136,6 @@ export const SearchPanel: FC = ({ onQuerySubmit={(params: { dateRange: TimeRange; query?: Query | undefined }) => searchHandler({ query: params.query }) } - // @ts-expect-error onFiltersUpdated is a valid prop on SearchBar onFiltersUpdated={(filters: Filter[]) => searchHandler({ filters })} indexPatterns={[dataView]} placeholder={i18n.translate('xpack.dataVisualizer.searchPanel.queryBarPlaceholderText', { diff --git a/x-pack/plugins/infra/public/alerting/inventory/components/expression.tsx b/x-pack/plugins/infra/public/alerting/inventory/components/expression.tsx index 26fb14f3ad37a..aebab35131afa 100644 --- a/x-pack/plugins/infra/public/alerting/inventory/components/expression.tsx +++ b/x-pack/plugins/infra/public/alerting/inventory/components/expression.tsx @@ -86,7 +86,7 @@ type Props = Omit< }, AlertContextMeta >, - 'defaultActionGroupId' | 'actionGroups' | 'charts' | 'data' | 'unifiedSearch' + 'defaultActionGroupId' | 'actionGroups' | 'charts' | 'data' | 'unifiedSearch' | 'onChangeMetaData' >; export const defaultExpression = { diff --git a/x-pack/plugins/infra/public/alerting/metric_anomaly/components/expression.tsx b/x-pack/plugins/infra/public/alerting/metric_anomaly/components/expression.tsx index 74affe264027b..77337ab4ba588 100644 --- a/x-pack/plugins/infra/public/alerting/metric_anomaly/components/expression.tsx +++ b/x-pack/plugins/infra/public/alerting/metric_anomaly/components/expression.tsx @@ -39,7 +39,7 @@ type AlertParams = RuleTypeParams & type Props = Omit< RuleTypeParamsExpressionProps, - 'defaultActionGroupId' | 'actionGroups' | 'charts' | 'data' | 'unifiedSearch' + 'defaultActionGroupId' | 'actionGroups' | 'charts' | 'data' | 'unifiedSearch' | 'onChangeMetaData' >; export const defaultExpression = { diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.tsx b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.tsx index bfdf1d7eeefa6..5bc6cff8545e1 100644 --- a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.tsx +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.tsx @@ -42,7 +42,7 @@ const FILTER_TYPING_DEBOUNCE_MS = 500; type Props = Omit< RuleTypeParamsExpressionProps, - 'defaultActionGroupId' | 'actionGroups' | 'charts' | 'data' | 'unifiedSearch' + 'defaultActionGroupId' | 'actionGroups' | 'charts' | 'data' | 'unifiedSearch' | 'onChangeMetaData' >; const defaultExpression = { diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/components/unified_search_bar.tsx b/x-pack/plugins/infra/public/pages/metrics/hosts/components/unified_search_bar.tsx index ec9879579908e..f596300092dfe 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/components/unified_search_bar.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/components/unified_search_bar.tsx @@ -70,7 +70,6 @@ export const UnifiedSearchBar = ({ dataView }: Props) => { onClearSavedQuery={onClearSavedQuery} showSaveQuery showQueryInput - // @ts-expect-error onFiltersUpdated is a valid prop on SearchBar onFiltersUpdated={onFilterChange} /> ); diff --git a/x-pack/plugins/monitoring/public/alerts/alert_form.test.tsx b/x-pack/plugins/monitoring/public/alerts/alert_form.test.tsx index ef8ceff29f9f1..cefbc371e156d 100644 --- a/x-pack/plugins/monitoring/public/alerts/alert_form.test.tsx +++ b/x-pack/plugins/monitoring/public/alerts/alert_form.test.tsx @@ -143,6 +143,7 @@ describe('alert_form', () => { operation="create" actionTypeRegistry={actionTypeRegistry} ruleTypeRegistry={ruleTypeRegistry} + onChangeMetaData={() => {}} /> diff --git a/x-pack/plugins/stack_alerts/common/config.ts b/x-pack/plugins/stack_alerts/common/config.ts deleted file mode 100644 index e1eb28f092408..0000000000000 --- a/x-pack/plugins/stack_alerts/common/config.ts +++ /dev/null @@ -1,12 +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 { schema, TypeOf } from '@kbn/config-schema'; - -export const configSchema = schema.object({}); - -export type Config = TypeOf; diff --git a/x-pack/plugins/stack_alerts/common/index.ts b/x-pack/plugins/stack_alerts/common/index.ts index 1885cbb623b1d..c97dabdf446eb 100644 --- a/x-pack/plugins/stack_alerts/common/index.ts +++ b/x-pack/plugins/stack_alerts/common/index.ts @@ -5,8 +5,4 @@ * 2.0. */ -// TODO: https://github.com/elastic/kibana/issues/110895 -/* eslint-disable @kbn/eslint/no_export_all */ - -export * from './config'; export { STACK_ALERTS_FEATURE_ID } from './constants'; diff --git a/x-pack/plugins/stack_alerts/public/plugin.tsx b/x-pack/plugins/stack_alerts/public/plugin.tsx index 88b1f9e06723e..95870fc112e7f 100644 --- a/x-pack/plugins/stack_alerts/public/plugin.tsx +++ b/x-pack/plugins/stack_alerts/public/plugin.tsx @@ -19,6 +19,8 @@ export interface StackAlertsPublicSetupDeps { } export class StackAlertsPublicPlugin implements Plugin { + constructor() {} + public setup(core: CoreSetup, { triggersActionsUi, alerting }: StackAlertsPublicSetupDeps) { registerRuleTypes({ ruleTypeRegistry: triggersActionsUi.ruleTypeRegistry, diff --git a/x-pack/plugins/stack_alerts/public/rule_types/components/data_view_select_popover.test.tsx b/x-pack/plugins/stack_alerts/public/rule_types/components/data_view_select_popover.test.tsx index 75bbf0afbc77d..5812d61c7011f 100644 --- a/x-pack/plugins/stack_alerts/public/rule_types/components/data_view_select_popover.test.tsx +++ b/x-pack/plugins/stack_alerts/public/rule_types/components/data_view_select_popover.test.tsx @@ -10,46 +10,72 @@ import { mountWithIntl, nextTick } from '@kbn/test-jest-helpers'; import { DataViewSelectPopover, DataViewSelectPopoverProps } from './data_view_select_popover'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; +import type { DataView } from '@kbn/data-views-plugin/public'; +import { indexPatternEditorPluginMock as dataViewEditorPluginMock } from '@kbn/data-view-editor-plugin/public/mocks'; import { act } from 'react-dom/test-utils'; +const selectedDataView = { + id: 'mock-data-logs-id', + namespaces: ['default'], + title: 'kibana_sample_data_logs', + isTimeBased: jest.fn(), + isPersisted: jest.fn(() => true), + getName: () => 'kibana_sample_data_logs', +} as unknown as DataView; + const props: DataViewSelectPopoverProps = { onSelectDataView: () => {}, - dataViewName: 'kibana_sample_data_logs', - dataViewId: 'mock-data-logs-id', + onChangeMetaData: () => {}, + dataView: selectedDataView, }; +const dataViewIds = ['mock-data-logs-id', 'mock-ecommerce-id', 'mock-test-id']; + const dataViewOptions = [ - { - id: 'mock-data-logs-id', - namespaces: ['default'], - title: 'kibana_sample_data_logs', - }, + selectedDataView, { id: 'mock-flyghts-id', namespaces: ['default'], title: 'kibana_sample_data_flights', + isTimeBased: jest.fn(), + isPersisted: jest.fn(() => true), + getName: () => 'kibana_sample_data_flights', }, { id: 'mock-ecommerce-id', namespaces: ['default'], title: 'kibana_sample_data_ecommerce', typeMeta: {}, + isTimeBased: jest.fn(), + isPersisted: jest.fn(() => true), + getName: () => 'kibana_sample_data_ecommerce', }, { id: 'mock-test-id', namespaces: ['default'], title: 'test', typeMeta: {}, + isTimeBased: jest.fn(), + isPersisted: jest.fn(() => true), + getName: () => 'test', }, ]; const mount = () => { const dataViewsMock = dataViewPluginMocks.createStartContract(); - dataViewsMock.getIdsWithTitle.mockImplementation(() => Promise.resolve(dataViewOptions)); + dataViewsMock.getIds = jest.fn().mockImplementation(() => Promise.resolve(dataViewIds)); + dataViewsMock.get = jest + .fn() + .mockImplementation((id: string) => + Promise.resolve(dataViewOptions.find((current) => current.id === id)) + ); + const dataViewEditorMock = dataViewEditorPluginMock.createStartContract(); return { wrapper: mountWithIntl( - + ), @@ -66,10 +92,10 @@ describe('DataViewSelectPopover', () => { wrapper.update(); }); - expect(dataViewsMock.getIdsWithTitle).toHaveBeenCalled(); + expect(dataViewsMock.getIds).toHaveBeenCalled(); expect(wrapper.find('[data-test-subj="selectDataViewExpression"]').exists()).toBeTruthy(); - const getIdsWithTitleResult = await dataViewsMock.getIdsWithTitle.mock.results[0].value; - expect(getIdsWithTitleResult).toBe(dataViewOptions); + const getIdsResult = await dataViewsMock.getIds.mock.results[0].value; + expect(getIdsResult).toBe(dataViewIds); }); }); diff --git a/x-pack/plugins/stack_alerts/public/rule_types/components/data_view_select_popover.tsx b/x-pack/plugins/stack_alerts/public/rule_types/components/data_view_select_popover.tsx index fad3230e57f1e..0f7544e0ecd89 100644 --- a/x-pack/plugins/stack_alerts/public/rule_types/components/data_view_select_popover.tsx +++ b/x-pack/plugins/stack_alerts/public/rule_types/components/data_view_select_popover.tsx @@ -14,56 +14,97 @@ import { EuiExpression, EuiFlexGroup, EuiFlexItem, - EuiFormRow, EuiPopover, EuiPopoverFooter, EuiPopoverTitle, EuiText, useEuiPaddingCSS, } from '@elastic/eui'; -import { DataViewsList } from '@kbn/unified-search-plugin/public'; -import { DataViewListItem } from '@kbn/data-views-plugin/public'; -import { useTriggersAndActionsUiDeps } from '../es_query/util'; +import type { DataViewListItem, DataView } from '@kbn/data-views-plugin/public'; +import { DataViewSelector } from '@kbn/unified-search-plugin/public'; +import { useTriggerUiActionServices } from '../es_query/util'; +import { EsQueryRuleMetaData } from '../es_query/types'; export interface DataViewSelectPopoverProps { - onSelectDataView: (newDataViewId: string) => void; - dataViewName?: string; - dataViewId?: string; + dataView: DataView; + metadata?: EsQueryRuleMetaData; + onSelectDataView: (selectedDataView: DataView) => void; + onChangeMetaData: (metadata: EsQueryRuleMetaData) => void; } +const toDataViewListItem = (dataView: DataView): DataViewListItem => { + return { + id: dataView.id!, + title: dataView.title, + name: dataView.name, + }; +}; + export const DataViewSelectPopover: React.FunctionComponent = ({ + metadata = { adHocDataViewList: [], isManagementPage: true }, + dataView, onSelectDataView, - dataViewName, - dataViewId, + onChangeMetaData, }) => { - const { data, dataViewEditor } = useTriggersAndActionsUiDeps(); - const [dataViewItems, setDataViewsItems] = useState(); + const { dataViews, dataViewEditor } = useTriggerUiActionServices(); + const [dataViewItems, setDataViewsItems] = useState([]); const [dataViewPopoverOpen, setDataViewPopoverOpen] = useState(false); const closeDataViewEditor = useRef<() => void | undefined>(); - const loadDataViews = useCallback(async () => { - const fetchedDataViewItems = await data.dataViews.getIdsWithTitle(); - setDataViewsItems(fetchedDataViewItems); - }, [setDataViewsItems, data.dataViews]); + const allDataViewItems = useMemo( + () => [...dataViewItems, ...metadata.adHocDataViewList.map(toDataViewListItem)], + [dataViewItems, metadata.adHocDataViewList] + ); const closeDataViewPopover = useCallback(() => setDataViewPopoverOpen(false), []); + const onChangeDataView = useCallback( + async (selectedDataViewId: string) => { + const selectedDataView = await dataViews.get(selectedDataViewId); + onSelectDataView(selectedDataView); + closeDataViewPopover(); + }, + [closeDataViewPopover, dataViews, onSelectDataView] + ); + + const loadPersistedDataViews = useCallback(async () => { + const ids = await dataViews.getIds(); + const dataViewsList = await Promise.all(ids.map((id) => dataViews.get(id))); + + setDataViewsItems(dataViewsList.map(toDataViewListItem)); + }, [dataViews]); + + const onAddAdHocDataView = useCallback( + (adHocDataView: DataView) => { + onChangeMetaData({ + ...metadata, + adHocDataViewList: [...metadata.adHocDataViewList, adHocDataView], + }); + }, + [metadata, onChangeMetaData] + ); + const createDataView = useMemo( () => - dataViewEditor?.userPermissions.editDataView() + dataViewEditor.userPermissions.editDataView() ? () => { closeDataViewEditor.current = dataViewEditor.openEditor({ onSave: async (createdDataView) => { if (createdDataView.id) { - await onSelectDataView(createdDataView.id); - await loadDataViews(); + if (!createdDataView.isPersisted()) { + onAddAdHocDataView(createdDataView); + } + + await loadPersistedDataViews(); + await onChangeDataView(createdDataView.id); } }, + allowAdHocDataView: true, }); } : undefined, - [dataViewEditor, onSelectDataView, loadDataViews] + [dataViewEditor, loadPersistedDataViews, onChangeDataView, onAddAdHocDataView] ); useEffect(() => { @@ -76,12 +117,25 @@ export const DataViewSelectPopover: React.FunctionComponent { - loadDataViews(); - }, [loadDataViews]); + loadPersistedDataViews(); + }, [loadPersistedDataViews]); const createDataViewButtonPadding = useEuiPaddingCSS('left'); - if (!dataViewItems) { + const onCreateDefaultAdHocDataView = useCallback( + async (pattern: string) => { + const newDataView = await dataViews.create({ title: pattern }); + if (newDataView.fields.getByName('@timestamp')?.type === 'date') { + newDataView.timeFieldName = '@timestamp'; + } + + onAddAdHocDataView(newDataView); + onChangeDataView(newDataView.id!); + }, + [dataViews, onAddAdHocDataView, onChangeDataView] + ); + + if (!allDataViewItems) { return null; } @@ -96,7 +150,7 @@ export const DataViewSelectPopover: React.FunctionComponent { setDataViewPopoverOpen(true); }} - isInvalid={!dataViewId} + isInvalid={!dataView.id} /> } isOpen={dataViewPopoverOpen} @@ -136,24 +190,14 @@ export const DataViewSelectPopover: React.FunctionComponent - - { - onSelectDataView(newId); - closeDataViewPopover(); - }} - currentDataViewId={dataViewId} - /> - + {createDataView ? ( { defaultActionGroupId="" actionGroups={[]} charts={chartsStartMock} + onChangeMetaData={() => {}} /> ); diff --git a/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/es_query_expression.tsx b/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/es_query_expression.tsx index e98c547a6120c..359faa935346b 100644 --- a/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/es_query_expression.tsx +++ b/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/es_query_expression.tsx @@ -19,7 +19,7 @@ import { getFields, RuleTypeParamsExpressionProps } from '@kbn/triggers-actions- import { parseDuration } from '@kbn/alerting-plugin/common'; import { hasExpressionValidationErrors } from '../validation'; import { buildSortedEventsQuery } from '../../../../common/build_sorted_events_query'; -import { EsQueryRuleParams, SearchType } from '../types'; +import { EsQueryRuleParams, EsQueryRuleMetaData, SearchType } from '../types'; import { IndexSelectPopover } from '../../components/index_select_popover'; import { DEFAULT_VALUES } from '../constants'; import { RuleCommonExpressions } from '../rule_common_expressions'; @@ -33,7 +33,7 @@ interface KibanaDeps { } export const EsQueryExpression: React.FC< - RuleTypeParamsExpressionProps> + RuleTypeParamsExpressionProps, EsQueryRuleMetaData> > = ({ ruleParams, setRuleParams, setRuleProperty, errors, data }) => { const { index, diff --git a/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/expression.test.tsx b/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/expression.test.tsx index b21646496f14a..c141bdca33cee 100644 --- a/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/expression.test.tsx +++ b/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/expression.test.tsx @@ -13,7 +13,7 @@ import { httpServiceMock } from '@kbn/core/public/mocks'; import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; import { unifiedSearchPluginMock } from '@kbn/unified-search-plugin/public/mocks'; -import { CommonRuleParams, EsQueryRuleParams, SearchType } from '../types'; +import { CommonRuleParams, EsQueryRuleMetaData, EsQueryRuleParams, SearchType } from '../types'; import { EsQueryRuleTypeExpression } from './expression'; import { chartPluginMock } from '@kbn/charts-plugin/public/mocks'; import { Subject } from 'rxjs'; @@ -22,6 +22,7 @@ import { IUiSettingsClient } from '@kbn/core/public'; import { findTestSubject } from '@elastic/eui/lib/test'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; import { act } from 'react-dom/test-utils'; +import { indexPatternEditorPluginMock as dataViewEditorPluginMock } from '@kbn/data-view-editor-plugin/public/mocks'; import { ReactWrapper } from 'enzyme'; jest.mock('@kbn/kibana-react-plugin/public', () => { @@ -87,6 +88,8 @@ const searchSourceFieldsMock = { id: '90943e30-9a47-11e8-b64d-95841ca0b247', title: 'kibana_sample_data_logs', fields: [], + getName: () => 'kibana_sample_data_logs', + isPersisted: () => true, }, }; @@ -122,11 +125,15 @@ const savedQueryMock = { }; const dataMock = dataPluginMock.createStartContract(); +const dataViewsMock = dataViewPluginMocks.createStartContract(); +const dataViewEditorMock = dataViewEditorPluginMock.createStartContract(); + (dataMock.search.searchSource.create as jest.Mock).mockImplementation(() => Promise.resolve(searchSourceMock) ); -(dataMock.dataViews.getIdsWithTitle as jest.Mock).mockImplementation(() => Promise.resolve([])); -dataMock.dataViews.getDefaultDataView = jest.fn(() => Promise.resolve(null)); +(dataViewsMock.getIds as jest.Mock) = jest.fn().mockImplementation(() => Promise.resolve([])); +dataViewsMock.getDefaultDataView = jest.fn(() => Promise.resolve(null)); +dataViewsMock.get = jest.fn(); (dataMock.query.savedQueries.getSavedQuery as jest.Mock).mockImplementation(() => Promise.resolve(savedQueryMock) ); @@ -137,7 +144,8 @@ dataMock.query.savedQueries.findSavedQueries = jest.fn(() => const Wrapper: React.FC<{ ruleParams: EsQueryRuleParams | EsQueryRuleParams; -}> = ({ ruleParams }) => { + metadata?: EsQueryRuleMetaData; +}> = ({ ruleParams, metadata }) => { const [currentRuleParams, setCurrentRuleParams] = useState(ruleParams); const errors = { index: [], @@ -170,23 +178,29 @@ const Wrapper: React.FC<{ defaultActionGroupId="" actionGroups={[]} charts={chartsStartMock} + metadata={metadata} + onChangeMetaData={jest.fn()} /> ); }; const setup = ( - ruleParams: EsQueryRuleParams | EsQueryRuleParams + ruleParams: EsQueryRuleParams | EsQueryRuleParams, + metadata?: EsQueryRuleMetaData ) => { return mountWithIntl( - + ); }; @@ -236,10 +250,10 @@ describe('EsQueryRuleTypeExpression', () => { expect(findTestSubject(wrapper, 'queryFormTypeChooserTitle').exists()).toBeTruthy(); }); - test('should render QueryDSL view without the form type chooser if some rule params were passed', async () => { + test('should render QueryDSL view without the form type chooser', async () => { let wrapper: ReactWrapper; await act(async () => { - wrapper = setup(defaultEsQueryRuleParams); + wrapper = setup(defaultEsQueryRuleParams, { adHocDataViewList: [], isManagementPage: false }); wrapper = await wrapper.update(); }); expect(findTestSubject(wrapper!, 'queryFormTypeChooserTitle').exists()).toBeFalsy(); @@ -247,10 +261,13 @@ describe('EsQueryRuleTypeExpression', () => { expect(findTestSubject(wrapper!, 'selectIndexExpression').exists()).toBeTruthy(); }); - test('should render KQL and Lucene view without the form type chooser if some rule params were passed', async () => { + test('should render KQL and Lucene view without the form type chooser', async () => { let wrapper: ReactWrapper; await act(async () => { - wrapper = setup(defaultSearchSourceRuleParams); + wrapper = setup(defaultSearchSourceRuleParams, { + adHocDataViewList: [], + isManagementPage: false, + }); wrapper = await wrapper.update(); }); wrapper = await wrapper!.update(); diff --git a/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/expression.tsx b/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/expression.tsx index 4fde89d994c9e..65cd4b8e1a65c 100644 --- a/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/expression.tsx +++ b/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/expression.tsx @@ -5,13 +5,13 @@ * 2.0. */ -import React, { memo, PropsWithChildren, useCallback, useRef } from 'react'; +import React, { memo, PropsWithChildren, useCallback } from 'react'; import deepEqual from 'fast-deep-equal'; import 'brace/theme/github'; import { EuiCallOut, EuiHorizontalRule, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { RuleTypeParamsExpressionProps } from '@kbn/triggers-actions-ui-plugin/public'; -import { EsQueryRuleParams, SearchType } from '../types'; +import { EsQueryRuleParams, EsQueryRuleMetaData, SearchType } from '../types'; import { SearchSourceExpression, SearchSourceExpressionProps } from './search_source_expression'; import { EsQueryExpression } from './es_query_expression'; import { QueryFormTypeChooser } from './query_form_type_chooser'; @@ -33,11 +33,12 @@ const SearchSourceExpressionMemoized = memo( ); export const EsQueryRuleTypeExpression: React.FunctionComponent< - RuleTypeParamsExpressionProps + RuleTypeParamsExpressionProps > = (props) => { const { ruleParams, errors, setRuleProperty, setRuleParams } = props; const isSearchSource = isSearchSourceRule(ruleParams); - const isManagementPage = useRef(!Object.keys(ruleParams).length).current; + // metadata provided only when open alert from Discover page + const isManagementPage = props.metadata?.isManagementPage ?? true; const formTypeSelected = useCallback( (searchType: SearchType | null) => { diff --git a/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/search_source_expression.test.tsx b/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/search_source_expression.test.tsx index 924b93907b8b4..764848eaf9f13 100644 --- a/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/search_source_expression.test.tsx +++ b/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/search_source_expression.test.tsx @@ -20,6 +20,7 @@ import { IUiSettingsClient } from '@kbn/core/public'; import { findTestSubject } from '@elastic/eui/lib/test'; import { copyToClipboard, EuiLoadingSpinner } from '@elastic/eui'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; +import { indexPatternEditorPluginMock as dataViewEditorPluginMock } from '@kbn/data-view-editor-plugin/public/mocks'; import { ReactWrapper } from 'enzyme'; jest.mock('@elastic/eui', () => { @@ -81,6 +82,9 @@ const searchSourceFieldsMock = { id: '90943e30-9a47-11e8-b64d-95841ca0b247', title: 'kibana_sample_data_logs', fields: [], + isPersisted: () => true, + getName: () => 'kibana_sample_data_logs', + isTimeBased: () => true, }, }; @@ -182,8 +186,9 @@ const dataMock = dataPluginMock.createStartContract(); (dataMock.search.searchSource.create as jest.Mock).mockImplementation(() => Promise.resolve(searchSourceMock) ); -(dataMock.dataViews.getIdsWithTitle as jest.Mock).mockImplementation(() => Promise.resolve([])); -dataMock.dataViews.getDefaultDataView = jest.fn(() => Promise.resolve(null)); +(dataViewPluginMock.getIds as jest.Mock) = jest.fn().mockImplementation(() => Promise.resolve([])); +dataViewPluginMock.getDefaultDataView = jest.fn(() => Promise.resolve(null)); +dataViewPluginMock.get = jest.fn(); (dataMock.query.savedQueries.getSavedQuery as jest.Mock).mockImplementation(() => Promise.resolve(savedQueryMock) ); @@ -198,9 +203,18 @@ const setup = (alertParams: EsQueryRuleParams) => { timeWindowSize: [], searchConfiguration: [], }; + const dataViewEditorMock = dataViewEditorPluginMock.createStartContract(); return mountWithIntl( - + ) => { defaultActionGroupId="" actionGroups={[]} charts={chartsStartMock} + metadata={{ adHocDataViewList: [] }} + onChangeMetaData={jest.fn()} /> ); diff --git a/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/search_source_expression.tsx b/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/search_source_expression.tsx index 5c3fe1d0d4d28..fa08ceb8019e0 100644 --- a/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/search_source_expression.tsx +++ b/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/search_source_expression.tsx @@ -11,13 +11,14 @@ import { EuiSpacer, EuiLoadingSpinner, EuiEmptyPrompt, EuiCallOut } from '@elast import { ISearchSource } from '@kbn/data-plugin/common'; import { RuleTypeParamsExpressionProps } from '@kbn/triggers-actions-ui-plugin/public'; import { SavedQuery } from '@kbn/data-plugin/public'; -import { EsQueryRuleParams, SearchType } from '../types'; -import { useTriggersAndActionsUiDeps } from '../util'; +import { EsQueryRuleMetaData, EsQueryRuleParams, SearchType } from '../types'; import { SearchSourceExpressionForm } from './search_source_expression_form'; import { DEFAULT_VALUES } from '../constants'; +import { useTriggerUiActionServices } from '../util'; export type SearchSourceExpressionProps = RuleTypeParamsExpressionProps< - EsQueryRuleParams + EsQueryRuleParams, + EsQueryRuleMetaData >; export const SearchSourceExpression = ({ @@ -25,6 +26,8 @@ export const SearchSourceExpression = ({ errors, setRuleParams, setRuleProperty, + metadata, + onChangeMetaData, }: SearchSourceExpressionProps) => { const { thresholdComparator, @@ -36,7 +39,7 @@ export const SearchSourceExpression = ({ searchConfiguration, excludeHitsFromPreviousRun, } = ruleParams; - const { data } = useTriggersAndActionsUiDeps(); + const { data } = useTriggerUiActionServices(); const [searchSource, setSearchSource] = useState(); const [savedQuery, setSavedQuery] = useState(); @@ -112,6 +115,8 @@ export const SearchSourceExpression = ({ errors={errors} initialSavedQuery={savedQuery} setParam={setParam} + metadata={metadata} + onChangeMetaData={onChangeMetaData} /> ); }; diff --git a/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/search_source_expression_form.tsx b/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/search_source_expression_form.tsx index eacbd169dfed8..7717d5c3b6838 100644 --- a/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/search_source_expression_form.tsx +++ b/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/search_source_expression_form.tsx @@ -8,21 +8,26 @@ import React, { Fragment, useCallback, useEffect, useMemo, useReducer, useState } from 'react'; import deepEqual from 'fast-deep-equal'; import { lastValueFrom } from 'rxjs'; -import { Filter } from '@kbn/es-query'; +import type { Filter, Query } from '@kbn/es-query'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiSpacer, EuiTitle } from '@elastic/eui'; -import { DataView, Query, ISearchSource, getTime } from '@kbn/data-plugin/common'; import { IErrorObject } from '@kbn/triggers-actions-ui-plugin/public'; -import { SearchBar, SearchBarProps } from '@kbn/unified-search-plugin/public'; -import { mapAndFlattenFilters, SavedQuery, TimeHistory } from '@kbn/data-plugin/public'; -import { Storage } from '@kbn/kibana-utils-plugin/public'; -import { CommonRuleParams, EsQueryRuleParams, SearchType } from '../types'; +import type { SearchBarProps } from '@kbn/unified-search-plugin/public'; +import type { DataView } from '@kbn/data-views-plugin/public'; +import { + mapAndFlattenFilters, + getTime, + type SavedQuery, + type ISearchSource, +} from '@kbn/data-plugin/public'; +import { STACK_ALERTS_FEATURE_ID } from '../../../../common'; +import { CommonRuleParams, EsQueryRuleMetaData, EsQueryRuleParams, SearchType } from '../types'; import { DEFAULT_VALUES } from '../constants'; import { DataViewSelectPopover } from '../../components/data_view_select_popover'; -import { useTriggersAndActionsUiDeps } from '../util'; import { RuleCommonExpressions } from '../rule_common_expressions'; import { totalHitsToNumber } from '../test_query_row'; import { hasExpressionValidationErrors } from '../validation'; +import { useTriggerUiActionServices } from '../util'; const HIDDEN_FILTER_PANEL_OPTIONS: SearchBarProps['hiddenFilterPanelOptions'] = [ 'pinFilter', @@ -66,8 +71,10 @@ interface SearchSourceExpressionFormProps { searchSource: ISearchSource; ruleParams: EsQueryRuleParams; errors: IErrorObject; + metadata?: EsQueryRuleMetaData; initialSavedQuery?: SavedQuery; setParam: (paramField: string, paramValue: unknown) => void; + onChangeMetaData: (metadata: EsQueryRuleMetaData) => void; } const isSearchSourceParam = (action: LocalStateAction): action is SearchSourceParamsAction => { @@ -75,12 +82,11 @@ const isSearchSourceParam = (action: LocalStateAction): action is SearchSourcePa }; export const SearchSourceExpressionForm = (props: SearchSourceExpressionFormProps) => { - const { data } = useTriggersAndActionsUiDeps(); + const services = useTriggerUiActionServices(); + const unifiedSearch = services.unifiedSearch; const { searchSource, errors, initialSavedQuery, setParam, ruleParams } = props; const [savedQuery, setSavedQuery] = useState(); - const timeHistory = useMemo(() => new TimeHistory(new Storage(localStorage)), []); - useEffect(() => setSavedQuery(initialSavedQuery), [initialSavedQuery]); const [ruleConfiguration, dispatch] = useReducer( @@ -110,11 +116,8 @@ export const SearchSourceExpressionForm = (props: SearchSourceExpressionFormProp const dataViews = useMemo(() => (dataView ? [dataView] : []), [dataView]); const onSelectDataView = useCallback( - (newDataViewId) => - data.dataViews - .get(newDataViewId) - .then((newDataView) => dispatch({ type: 'index', payload: newDataView })), - [data.dataViews] + (newDataView: DataView) => dispatch({ type: 'index', payload: newDataView }), + [] ); const onUpdateFilters = useCallback((newFilters) => { @@ -228,9 +231,10 @@ export const SearchSourceExpressionForm = (props: SearchSourceExpressionFormProp {Boolean(dataView?.id) && ( @@ -245,7 +249,8 @@ export const SearchSourceExpressionForm = (props: SearchSourceExpressionFormProp - diff --git a/x-pack/plugins/stack_alerts/public/rule_types/es_query/types.ts b/x-pack/plugins/stack_alerts/public/rule_types/es_query/types.ts index 5de99ff16feb5..23cb6d8fc6e82 100644 --- a/x-pack/plugins/stack_alerts/public/rule_types/es_query/types.ts +++ b/x-pack/plugins/stack_alerts/public/rule_types/es_query/types.ts @@ -8,8 +8,10 @@ import { RuleTypeParams } from '@kbn/alerting-plugin/common'; import { SerializedSearchSourceFields } from '@kbn/data-plugin/common'; import { EuiComboBoxOptionOption } from '@elastic/eui'; -import { DataPublicPluginStart } from '@kbn/data-plugin/public'; -import { DataViewEditorStart } from '@kbn/data-view-editor-plugin/public'; +import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; +import type { DataViewEditorStart } from '@kbn/data-view-editor-plugin/public'; +import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public'; +import type { DataViewsPublicPluginStart, DataView } from '@kbn/data-views-plugin/public'; import { EXPRESSION_ERRORS } from './constants'; export interface Comparator { @@ -32,6 +34,11 @@ export interface CommonRuleParams extends RuleTypeParams { excludeHitsFromPreviousRun: boolean; } +export interface EsQueryRuleMetaData { + adHocDataViewList: DataView[]; + isManagementPage?: boolean; +} + export type EsQueryRuleParams = T extends SearchType.searchSource ? CommonRuleParams & OnlySearchSourceRuleParams : CommonRuleParams & OnlyEsQueryRuleParams; @@ -55,6 +62,8 @@ export type ExpressionErrors = typeof EXPRESSION_ERRORS; export type ErrorKey = keyof ExpressionErrors & unknown; export interface TriggersAndActionsUiDeps { + dataViews: DataViewsPublicPluginStart; + unifiedSearch: UnifiedSearchPublicPluginStart; data: DataPublicPluginStart; dataViewEditor: DataViewEditorStart; } diff --git a/x-pack/plugins/stack_alerts/public/rule_types/es_query/util.ts b/x-pack/plugins/stack_alerts/public/rule_types/es_query/util.ts index f38f1df137d13..cc4cfb2313d69 100644 --- a/x-pack/plugins/stack_alerts/public/rule_types/es_query/util.ts +++ b/x-pack/plugins/stack_alerts/public/rule_types/es_query/util.ts @@ -14,4 +14,4 @@ export const isSearchSourceRule = ( return ruleParams.searchType === 'searchSource'; }; -export const useTriggersAndActionsUiDeps = () => useKibana().services; +export const useTriggerUiActionServices = () => useKibana().services; diff --git a/x-pack/plugins/stack_alerts/public/rule_types/es_query/validation.ts b/x-pack/plugins/stack_alerts/public/rule_types/es_query/validation.ts index 7ae70ac891029..bbfd8cdd8e698 100644 --- a/x-pack/plugins/stack_alerts/public/rule_types/es_query/validation.ts +++ b/x-pack/plugins/stack_alerts/public/rule_types/es_query/validation.ts @@ -100,6 +100,18 @@ export const validateExpression = (ruleParams: EsQueryRuleParams): ValidationRes defaultMessage: 'Data view is required.', }) ); + } else if ( + typeof ruleParams.searchConfiguration.index === 'object' && + !Object.hasOwn(ruleParams.searchConfiguration.index, 'timeFieldName') + ) { + errors.index.push( + i18n.translate( + 'xpack.stackAlerts.esQuery.ui.validation.error.requiredDataViewTimeFieldText', + { + defaultMessage: 'Data view should have a time field.', + } + ) + ); } return validationResult; } diff --git a/x-pack/plugins/stack_alerts/public/rule_types/threshold/expression.test.tsx b/x-pack/plugins/stack_alerts/public/rule_types/threshold/expression.test.tsx index e960ff6b980d6..e1f05b2ea84d9 100644 --- a/x-pack/plugins/stack_alerts/public/rule_types/threshold/expression.test.tsx +++ b/x-pack/plugins/stack_alerts/public/rule_types/threshold/expression.test.tsx @@ -101,6 +101,7 @@ describe('IndexThresholdRuleTypeExpression', () => { defaultActionGroupId="" actionGroups={[]} charts={chartsStartMock} + onChangeMetaData={() => {}} /> ); diff --git a/x-pack/plugins/stack_alerts/server/index.ts b/x-pack/plugins/stack_alerts/server/index.ts index 36423a6c1acbc..d6ca5109007d7 100644 --- a/x-pack/plugins/stack_alerts/server/index.ts +++ b/x-pack/plugins/stack_alerts/server/index.ts @@ -5,11 +5,15 @@ * 2.0. */ import { get } from 'lodash'; +import { schema, TypeOf } from '@kbn/config-schema'; import { PluginConfigDescriptor, PluginInitializerContext } from '@kbn/core/server'; import { AlertingBuiltinsPlugin } from './plugin'; -import { configSchema, Config } from '../common/config'; export { ID as INDEX_THRESHOLD_ID } from './rule_types/index_threshold/rule_type'; +export const configSchema = schema.object({}); + +export type Config = TypeOf; + export const config: PluginConfigDescriptor = { exposeToBrowser: {}, schema: configSchema, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_add.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_add.test.tsx index 219d6e55a9903..6735bb5637e74 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_add.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_add.test.tsx @@ -207,7 +207,10 @@ describe('rule_add', () => { expect(wrapper.find('[data-test-subj="saveRuleButton"]').exists()).toBeTruthy(); wrapper.find('[data-test-subj="cancelSaveRuleButton"]').last().simulate('click'); - expect(onClose).toHaveBeenCalledWith(RuleFlyoutCloseReason.CANCELED); + expect(onClose).toHaveBeenCalledWith(RuleFlyoutCloseReason.CANCELED, { + fields: ['test'], + test: 'some value', + }); }); it('renders a confirm close modal if the flyout is closed after inputs have changed', async () => { @@ -301,7 +304,10 @@ describe('rule_add', () => { wrapper.update(); }); - expect(onClose).toHaveBeenCalledWith(RuleFlyoutCloseReason.SAVED); + expect(onClose).toHaveBeenCalledWith(RuleFlyoutCloseReason.SAVED, { + test: 'some value', + fields: ['test'], + }); }); it('should enforce any default interval', async () => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_add.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_add.tsx index 914d52de50299..b12a3c07c7875 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_add.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_add.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useReducer, useMemo, useState, useEffect } from 'react'; +import React, { useReducer, useMemo, useState, useEffect, useCallback } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiTitle, EuiFlyoutHeader, EuiFlyout, EuiFlyoutBody, EuiPortal } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; @@ -47,11 +47,13 @@ const RuleAdd = ({ initialValues, reloadRules, onSave, - metadata, + metadata: initialMetadata, filteredRuleTypes, ...props }: RuleAddProps) => { const onSaveHandler = onSave ?? reloadRules; + const [metadata, setMetadata] = useState(initialMetadata); + const onChangeMetaData = useCallback((newMetadata) => setMetadata(newMetadata), []); const initialRule: InitialRule = useMemo(() => { return { @@ -177,7 +179,7 @@ const RuleAdd = ({ ) { setIsConfirmRuleCloseModalOpen(true); } else { - onClose(RuleFlyoutCloseReason.CANCELED); + onClose(RuleFlyoutCloseReason.CANCELED, metadata); } }; @@ -185,9 +187,9 @@ const RuleAdd = ({ const savedRule = await onSaveRule(); setIsSaving(false); if (savedRule) { - onClose(RuleFlyoutCloseReason.SAVED); + onClose(RuleFlyoutCloseReason.SAVED, metadata); if (onSaveHandler) { - onSaveHandler(); + onSaveHandler(metadata); } } }; @@ -263,6 +265,7 @@ const RuleAdd = ({ ruleTypeRegistry={ruleTypeRegistry} metadata={metadata} filteredRuleTypes={filteredRuleTypes} + onChangeMetaData={onChangeMetaData} /> { setIsConfirmRuleCloseModalOpen(false); - onClose(RuleFlyoutCloseReason.CANCELED); + onClose(RuleFlyoutCloseReason.CANCELED, metadata); }} onCancel={() => { setIsConfirmRuleCloseModalOpen(false); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_edit.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_edit.tsx index bcf81d6c4f150..a03ba8dc92620 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_edit.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_edit.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useReducer, useState, useEffect } from 'react'; +import React, { useReducer, useState, useEffect, useCallback } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiTitle, @@ -52,7 +52,7 @@ export const RuleEdit = ({ onSave, ruleTypeRegistry, actionTypeRegistry, - metadata, + metadata: initialMetadata, ...props }: RuleEditProps) => { const onSaveHandler = onSave ?? reloadRules; @@ -71,6 +71,9 @@ export const RuleEdit = ({ ); const [config, setConfig] = useState({ isUsingSecurity: false }); + const [metadata, setMetadata] = useState(initialMetadata); + const onChangeMetaData = useCallback((newMetadata) => setMetadata(newMetadata), []); + const { http, notifications: { toasts }, @@ -119,7 +122,7 @@ export const RuleEdit = ({ if (hasRuleChanged(rule, initialRule, true)) { setIsConfirmRuleCloseModalOpen(true); } else { - onClose(RuleFlyoutCloseReason.CANCELED); + onClose(RuleFlyoutCloseReason.CANCELED, metadata); } }; @@ -140,9 +143,9 @@ export const RuleEdit = ({ }, }) ); - onClose(RuleFlyoutCloseReason.SAVED); + onClose(RuleFlyoutCloseReason.SAVED, metadata); if (onSaveHandler) { - onSaveHandler(); + onSaveHandler(metadata); } } else { setRule( @@ -214,6 +217,7 @@ export const RuleEdit = ({ } )} metadata={metadata} + onChangeMetaData={onChangeMetaData} /> @@ -280,7 +284,7 @@ export const RuleEdit = ({ { setIsConfirmRuleCloseModalOpen(false); - onClose(RuleFlyoutCloseReason.CANCELED); + onClose(RuleFlyoutCloseReason.CANCELED, metadata); }} onCancel={() => { setIsConfirmRuleCloseModalOpen(false); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form.test.tsx index 0fd1a964f7479..316bfbec19a54 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form.test.tsx @@ -211,6 +211,7 @@ describe('rule_form', () => { operation="create" actionTypeRegistry={actionTypeRegistry} ruleTypeRegistry={ruleTypeRegistry} + onChangeMetaData={jest.fn()} /> ); @@ -334,6 +335,7 @@ describe('rule_form', () => { actionTypeRegistry={actionTypeRegistry} ruleTypeRegistry={ruleTypeRegistry} connectorFeatureId={featureId} + onChangeMetaData={jest.fn()} /> ); @@ -578,6 +580,7 @@ describe('rule_form', () => { operation="create" actionTypeRegistry={actionTypeRegistry} ruleTypeRegistry={ruleTypeRegistry} + onChangeMetaData={jest.fn()} /> ); @@ -644,6 +647,7 @@ describe('rule_form', () => { operation="create" actionTypeRegistry={actionTypeRegistry} ruleTypeRegistry={ruleTypeRegistry} + onChangeMetaData={jest.fn()} /> ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form.tsx index 9a1162b89a43f..ed2b6c27a0604 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form.tsx @@ -99,6 +99,7 @@ interface RuleFormProps> { metadata?: MetaData; filteredRuleTypes?: string[]; connectorFeatureId?: string; + onChangeMetaData: (metadata: MetaData) => void; } export const RuleForm = ({ @@ -115,6 +116,7 @@ export const RuleForm = ({ metadata, filteredRuleTypes: ruleTypeToFilter, connectorFeatureId = AlertingConnectorFeatureId, + onChangeMetaData, }: RuleFormProps) => { const { notifications: { toasts }, @@ -522,6 +524,7 @@ export const RuleForm = ({ data={data} dataViews={dataViews} unifiedSearch={unifiedSearch} + onChangeMetaData={onChangeMetaData} /> diff --git a/x-pack/plugins/triggers_actions_ui/public/types.ts b/x-pack/plugins/triggers_actions_ui/public/types.ts index 18c408a2e77ad..4da07d45b9a40 100644 --- a/x-pack/plugins/triggers_actions_ui/public/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/types.ts @@ -322,6 +322,7 @@ export interface RuleTypeParamsExpressionProps< key: Prop, value: SanitizedRule[Prop] | null ) => void; + onChangeMetaData: (metadata: MetaData) => void; errors: IErrorObject; defaultActionGroupId: string; actionGroups: Array>; @@ -359,10 +360,10 @@ export interface RuleEditProps> { initialRule: Rule; ruleTypeRegistry: RuleTypeRegistryContract; actionTypeRegistry: ActionTypeRegistryContract; - onClose: (reason: RuleFlyoutCloseReason) => void; + onClose: (reason: RuleFlyoutCloseReason, metadata?: MetaData) => void; /** @deprecated use `onSave` as a callback after an alert is saved*/ reloadRules?: () => Promise; - onSave?: () => Promise; + onSave?: (metadata?: MetaData) => Promise; metadata?: MetaData; ruleType?: RuleType; } @@ -371,13 +372,13 @@ export interface RuleAddProps> { consumer: string; ruleTypeRegistry: RuleTypeRegistryContract; actionTypeRegistry: ActionTypeRegistryContract; - onClose: (reason: RuleFlyoutCloseReason) => void; + onClose: (reason: RuleFlyoutCloseReason, metadata?: MetaData) => void; ruleTypeId?: string; canChangeTrigger?: boolean; initialValues?: Partial; /** @deprecated use `onSave` as a callback after an alert is saved*/ reloadRules?: () => Promise; - onSave?: () => Promise; + onSave?: (metadata?: MetaData) => Promise; metadata?: MetaData; ruleTypeIndex?: RuleTypeIndex; filteredRuleTypes?: string[]; diff --git a/x-pack/test/functional_with_es_ssl/apps/discover/search_source_alert.ts b/x-pack/test/functional_with_es_ssl/apps/discover/search_source_alert.ts index 088ed138fba6e..1b6d7ad9c3187 100644 --- a/x-pack/test/functional_with_es_ssl/apps/discover/search_source_alert.ts +++ b/x-pack/test/functional_with_es_ssl/apps/discover/search_source_alert.ts @@ -7,7 +7,6 @@ import expect from '@kbn/expect'; import { asyncForEach } from '@kbn/std'; -import { last } from 'lodash'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { @@ -32,12 +31,14 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const security = getService('security'); const filterBar = getService('filterBar'); const find = getService('find'); + const toasts = getService('toasts'); const SOURCE_DATA_INDEX = 'search-source-alert'; const OUTPUT_DATA_INDEX = 'search-source-alert-output'; const ACTION_TYPE_ID = '.index'; const RULE_NAME = 'test-search-source-alert'; let sourceDataViewId: string; + let sourceAdHocDataViewId: string; let outputDataViewId: string; let connectorId: string; @@ -115,6 +116,22 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { .expect(200); }; + const deleteDataView = async (dataViewId: string) => { + return await supertest + .delete(`/api/data_views/data_view/${dataViewId}`) + .set('kbn-xsrf', 'foo') + .expect(200); + }; + + const deleteIndexes = (indexes: string[]) => { + indexes.forEach((current) => { + es.transport.request({ + path: `/${current}`, + method: 'DELETE', + }); + }); + }; + const createConnector = async (): Promise => { const { body: createdAction } = await supertest .post(`/api/actions/connector`) @@ -133,16 +150,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const deleteConnector = (id: string) => supertest.delete(`/api/actions/connector/${id}`).set('kbn-xsrf', 'foo').expect(204, ''); - const deleteDataViews = (dataViews: string[]) => - asyncForEach( - dataViews, - async (dataView: string) => - await supertest - .delete(`/api/data_views/data_view/${dataView}`) - .set('kbn-xsrf', 'foo') - .expect(200) - ); - const defineSearchSourceAlert = async (alertName: string) => { await testSubjects.click('discoverAlertsButton'); await testSubjects.click('discoverCreateAlertButton'); @@ -168,41 +175,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await testSubjects.click('saveRuleButton'); }; - const getLastToast = async () => { - const toastList = await testSubjects.find('globalToastList'); - const titles = await toastList.findAllByTestSubject('euiToastHeader'); - const lastTitleElement = last(titles)!; - const title = await lastTitleElement.getVisibleText(); - const messages = await toastList.findAllByTestSubject('euiToastBody'); - const lastMessageElement = last(messages)!; - const message = await lastMessageElement.getVisibleText(); - return { message, title }; - }; - - const getErrorToastTitle = async () => { - const toastList = await testSubjects.find('globalToastList'); - const title = await ( - await toastList.findByCssSelector( - '[class*="euiToast-danger"] > [data-test-subj="euiToastHeader"]' - ) - ).getVisibleText(); - return title; - }; - - const openOutputIndex = async () => { - await PageObjects.common.navigateToApp('discover'); - await PageObjects.header.waitUntilLoadingHasFinished(); - await PageObjects.discover.selectIndexPattern(OUTPUT_DATA_INDEX); - - const [{ id: alertId }] = await getAlertsByName(RULE_NAME); - await queryBar.setQuery(`alert_id:${alertId}`); - await retry.waitFor('document explorer contains alert', async () => { - await queryBar.submitQuery(); - await PageObjects.discover.waitUntilSearchingHasFinished(); - return (await dataGrid.getDocCount()) > 0; - }); - }; - const getResultsLink = async () => { // getting the link await dataGrid.clickRowToggle(); @@ -214,24 +186,32 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { return link; }; - const navigateToDiscover = async (link: string) => { - // following ling provided by alert to see documents triggered the alert + const openAlertResults = async (ruleName: string, dataViewId?: string) => { + await PageObjects.common.navigateToApp('discover'); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.clickNewSearchButton(); // reset params + + await PageObjects.discover.selectIndexPattern(OUTPUT_DATA_INDEX); + + const [{ id: alertId }] = await getAlertsByName(ruleName); + await filterBar.addFilter('alert_id', 'is', alertId); + await PageObjects.discover.waitUntilSearchingHasFinished(); + + const link = await getResultsLink(); + await filterBar.removeFilter('alert_id'); // clear filter bar + + // follow url provided by alert to see documents triggered the alert const baseUrl = deployment.getHostPort(); await browser.navigateTo(baseUrl + link); await PageObjects.discover.waitUntilSearchingHasFinished(); await retry.waitFor('navigate to discover', async () => { - const currentUrl = await browser.getCurrentUrl(); - return currentUrl.includes(sourceDataViewId); + const currentDataViewId = await PageObjects.discover.getCurrentDataViewId(); + return dataViewId ? currentDataViewId === dataViewId : true; }); }; - const navigateToResults = async () => { - const link = await getResultsLink(); - await navigateToDiscover(link); - }; - - const openAlertRuleInManagement = async () => { + const openAlertRuleInManagement = async (ruleName: string) => { await PageObjects.common.navigateToApp('management'); await PageObjects.header.waitUntilLoadingHasFinished(); @@ -239,7 +219,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.header.waitUntilLoadingHasFinished(); const rulesList = await testSubjects.find('rulesList'); - const alertRule = await rulesList.findByCssSelector('[title="test-search-source-alert"]'); + const alertRule = await rulesList.findByCssSelector(`[title="${ruleName}"]`); await alertRule.click(); await PageObjects.header.waitUntilLoadingHasFinished(); }; @@ -269,18 +249,15 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); after(async () => { - es.transport.request({ - path: `/${OUTPUT_DATA_INDEX}`, - method: 'DELETE', - }); - await deleteDataViews([sourceDataViewId, outputDataViewId]); + deleteIndexes([OUTPUT_DATA_INDEX, SOURCE_DATA_INDEX]); + await deleteDataView(outputDataViewId); await deleteConnector(connectorId); - const alertsToDelete = await getAlertsByName(RULE_NAME); + const alertsToDelete = await getAlertsByName('test'); await deleteAlerts(alertsToDelete.map((alertItem: { id: string }) => alertItem.id)); await security.testUser.restoreDefaults(); }); - it('should navigate to discover via view in app link', async () => { + it('should navigate to alert results via view in app link', async () => { await PageObjects.common.navigateToApp('discover'); await PageObjects.discover.waitUntilSearchingHasFinished(); await PageObjects.discover.selectIndexPattern(SOURCE_DATA_INDEX); @@ -290,33 +267,35 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await defineSearchSourceAlert(RULE_NAME); await PageObjects.header.waitUntilLoadingHasFinished(); - await openAlertRuleInManagement(); - + await openAlertRuleInManagement(RULE_NAME); await testSubjects.click('ruleDetails-viewInApp'); await PageObjects.header.waitUntilLoadingHasFinished(); await retry.waitFor('navigate to discover', async () => { - const currentUrl = await browser.getCurrentUrl(); - return currentUrl.includes(sourceDataViewId); + const currentDataViewId = await PageObjects.discover.getCurrentDataViewId(); + return sourceDataViewId ? currentDataViewId === sourceDataViewId : true; }); expect(await dataGrid.getDocCount()).to.be(5); }); - it('should open documents triggered the alert', async () => { - await openOutputIndex(); - await navigateToResults(); + it('should navigate to alert results via link provided in notification', async () => { + await openAlertResults(RULE_NAME, sourceDataViewId); - const { message, title } = await getLastToast(); - expect(await dataGrid.getDocCount()).to.be(5); - expect(title).to.be.equal('Displayed documents may vary'); - expect(message).to.be.equal( - 'The displayed documents might differ from the documents that triggered the alert. Some documents might have been added or deleted.' + expect(await toasts.getToastCount()).to.be.equal(1); + const content = await toasts.getToastContent(1); + expect(content).to.equal( + `Displayed documents may vary\nThe displayed documents might differ from the documents that triggered the alert. Some documents might have been added or deleted.` ); + + const selectedDataView = await PageObjects.discover.getCurrentlySelectedDataView(); + expect(selectedDataView).to.be.equal('search-source-alert'); + + expect(await dataGrid.getDocCount()).to.be(5); }); it('should display warning about updated alert rule', async () => { - await openAlertRuleInManagement(); + await openAlertRuleInManagement(RULE_NAME); // change rule configuration await testSubjects.click('openEditRuleFlyoutButton'); @@ -328,21 +307,20 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await testSubjects.click('saveEditedRuleButton'); await PageObjects.header.waitUntilLoadingHasFinished(); - await openOutputIndex(); - await navigateToResults(); + await openAlertResults(RULE_NAME, sourceDataViewId); - const { message, title } = await getLastToast(); const queryString = await queryBar.getQueryString(); const hasFilter = await filterBar.hasFilter('message.keyword', 'msg-1'); - expect(queryString).to.be.equal('message:msg-1'); expect(hasFilter).to.be.equal(true); - expect(await dataGrid.getDocCount()).to.be(1); - expect(title).to.be.equal('Alert rule has changed'); - expect(message).to.be.equal( - 'The displayed documents might not match the documents that triggered the alert because the rule configuration changed.' + expect(await toasts.getToastCount()).to.be.equal(1); + const content = await toasts.getToastContent(1); + expect(content).to.equal( + `Alert rule has changed\nThe displayed documents might not match the documents that triggered the alert because the rule configuration changed.` ); + + expect(await dataGrid.getDocCount()).to.be(1); }); it('should display warning about recently updated data view', async () => { @@ -356,45 +334,82 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await testSubjects.click('tab-sourceFilters'); await testSubjects.click('fieldFilterInput'); - await PageObjects.common.sleep(15000); - const input = await find.activeElement(); await input.type('message'); - await testSubjects.click('addFieldFilterButton'); - await openOutputIndex(); - await navigateToResults(); - - await openOutputIndex(); - await navigateToResults(); + await openAlertResults(RULE_NAME, sourceDataViewId); - const { message, title } = await getLastToast(); + expect(await toasts.getToastCount()).to.be(2); + const firstContent = await toasts.getToastContent(1); + expect(firstContent).to.equal( + `Data View has changed\nData view has been updated after the last update of the alert rule.` + ); + const secondContent = await toasts.getToastContent(2); + expect(secondContent).to.equal( + `Alert rule has changed\nThe displayed documents might not match the documents that triggered the alert because the rule configuration changed.` + ); expect(await dataGrid.getDocCount()).to.be(1); - expect(title).to.be.equal('Data View has changed'); - expect(message).to.be.equal( - 'Data view has been updated after the last update of the alert rule.' - ); }); it('should display not found index error', async () => { - await openOutputIndex(); - const link = await getResultsLink(); - await navigateToDiscover(link); + await PageObjects.discover.selectIndexPattern(OUTPUT_DATA_INDEX); - await es.transport.request({ - path: `/${SOURCE_DATA_INDEX}`, - method: 'DELETE', + await deleteDataView(sourceDataViewId); + + // rty to open alert results after index deletion + await openAlertResults(RULE_NAME); + + expect(await toasts.getToastCount()).to.be(1); + const firstContent = await toasts.getToastContent(1); + expect(firstContent).to.equal( + `Error fetching search source\nCould not locate that data view (id: ${sourceDataViewId}), click here to re-create it` + ); + }); + + it('should navigate to alert results via view in app link using adhoc data view', async () => { + await PageObjects.discover.createAdHocDataView('search-source-', true); + await PageObjects.header.waitUntilLoadingHasFinished(); + + await PageObjects.timePicker.setCommonlyUsedTime('Last_15 minutes'); + + await PageObjects.discover.addRuntimeField('runtime-message-field', `emit('mock-message')`); + + // create an alert + await defineSearchSourceAlert('test-adhoc-alert'); + await PageObjects.header.waitUntilLoadingHasFinished(); + sourceAdHocDataViewId = await PageObjects.discover.getCurrentDataViewId(); + + // navigate to discover using view in app link + await openAlertRuleInManagement('test-adhoc-alert'); + await testSubjects.click('ruleDetails-viewInApp'); + await PageObjects.header.waitUntilLoadingHasFinished(); + await retry.waitFor('navigate to discover', async () => { + const currentDataViewId = await PageObjects.discover.getCurrentDataViewId(); + return currentDataViewId === sourceAdHocDataViewId; }); - await browser.refresh(); - await navigateToDiscover(link); + const selectedDataView = await PageObjects.discover.getCurrentlySelectedDataView(); + expect(selectedDataView).to.be.equal('search-source-*'); - const title = await getErrorToastTitle(); - expect(title).to.be.equal( - 'No matching indices found: No indices match "search-source-alert"' + const documentCell = await dataGrid.getCellElement(0, 3); + const firstRowContent = await documentCell.getVisibleText(); + expect(firstRowContent.includes('runtime-message-fieldmock-message_id')).to.be.equal(true); + }); + + it('should navigate to alert results via link provided in notification using adhoc data view', async () => { + await openAlertResults('test-adhoc-alert', sourceAdHocDataViewId); + + expect(await toasts.getToastCount()).to.be.equal(1); + const content = await toasts.getToastContent(1); + expect(content).to.equal( + `Displayed documents may vary\nThe displayed documents might differ from the documents that triggered the alert. Some documents might have been added or deleted.` ); + expect(await dataGrid.getDocCount()).to.be(5); + + const selectedDataView = await PageObjects.discover.getCurrentlySelectedDataView(); + expect(selectedDataView).to.be.equal('search-source-*'); }); }); }