diff --git a/packages/kbn-esql-utils/src/utils/get_esql_with_safe_limit.ts b/packages/kbn-esql-utils/src/utils/get_esql_with_safe_limit.ts index 793292909c68f..dbf11fabbffbf 100644 --- a/packages/kbn-esql-utils/src/utils/get_esql_with_safe_limit.ts +++ b/packages/kbn-esql-utils/src/utils/get_esql_with_safe_limit.ts @@ -26,7 +26,7 @@ export function getESQLWithSafeLimit(esql: string, limit: number): string { return parts .map((part, i) => { if (i === index) { - return `${part.trim()} \n| LIMIT ${limit}`; + return `${part.trim()} | limit ${limit}`; } return part; }) diff --git a/packages/presentation/presentation_containers/interfaces/presentation_container.ts b/packages/presentation/presentation_containers/interfaces/presentation_container.ts index d201a0d346132..8cb2b58dc66d6 100644 --- a/packages/presentation/presentation_containers/interfaces/presentation_container.ts +++ b/packages/presentation/presentation_containers/interfaces/presentation_container.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import { Reference } from '@kbn/content-management-utils'; import { apiHasParentApi, apiHasUniqueId, PublishingSubject } from '@kbn/presentation-publishing'; import { BehaviorSubject, combineLatest, isObservable, map, Observable, of, switchMap } from 'rxjs'; import { apiCanAddNewPanel, CanAddNewPanel } from './can_add_new_panel'; @@ -13,6 +14,7 @@ import { apiCanAddNewPanel, CanAddNewPanel } from './can_add_new_panel'; export interface PanelPackage { panelType: string; initialState?: SerializedState; + references?: Reference[]; } export interface PresentationContainer extends CanAddNewPanel { diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/create/unified_search/sync_dashboard_unified_search_state.ts b/src/plugins/dashboard/public/dashboard_container/embeddable/create/unified_search/sync_dashboard_unified_search_state.ts index 65e9622bd4873..0a539f7f889c0 100644 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/create/unified_search/sync_dashboard_unified_search_state.ts +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/create/unified_search/sync_dashboard_unified_search_state.ts @@ -44,6 +44,7 @@ export function syncUnifiedSearchState( const { explicitInput: { filters, query }, } = this.getState(); + if (this.ignoreUnifiedSearch) return; OnFiltersChange$.next({ filters: filters ?? [], query: query ?? queryString.getDefaultQuery(), @@ -66,6 +67,7 @@ export function syncUnifiedSearchState( set: ({ filters: newFilters, query: newQuery }) => { intermediateFilterState.filters = cleanFiltersForSerialize(newFilters); intermediateFilterState.query = newQuery; + if (this.ignoreUnifiedSearch) return; this.dispatch.setFiltersAndQuery(intermediateFilterState); }, state$: OnFiltersChange$.pipe(distinctUntilChanged()), diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx b/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx index 45f3b2535ff6d..e6469c5706065 100644 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx @@ -163,6 +163,9 @@ export class DashboardContainer private domNode?: HTMLElement; private overlayRef?: OverlayRef; private allDataViews: DataView[] = []; + public dataViews: BehaviorSubject = new BehaviorSubject< + DataView[] | undefined + >(undefined); // performance monitoring public lastLoadStartTime?: number; @@ -172,6 +175,8 @@ export class DashboardContainer private hadContentfulRender = false; private scrollPosition?: number; + public ignoreUnifiedSearch: boolean = false; + // cleanup public stopSyncingWithUnifiedSearch?: () => void; private cleanupStateTools: () => void; @@ -522,6 +527,7 @@ export class DashboardContainer ...panelPackage.initialState, id: newId, }, + references: panelPackage.references, }; this.updateInput({ panels: { ...otherPanels, [newId]: newPanel } }); onSuccess(newId, newPanel.explicitInput.title); @@ -712,6 +718,7 @@ export class DashboardContainer public setAllDataViews = (newDataViews: DataView[]) => { this.allDataViews = newDataViews; this.onDataViewsUpdate$.next(newDataViews); + this.dataViews.next(newDataViews); }; public getExpandedPanelId = () => { diff --git a/src/plugins/dashboard/public/dashboard_container/state/dashboard_container_reducers.ts b/src/plugins/dashboard/public/dashboard_container/state/dashboard_container_reducers.ts index e642cf2afe976..47849a4946ff3 100644 --- a/src/plugins/dashboard/public/dashboard_container/state/dashboard_container_reducers.ts +++ b/src/plugins/dashboard/public/dashboard_container/state/dashboard_container_reducers.ts @@ -242,4 +242,16 @@ export const dashboardContainerReducers = { ) => { state.componentState.animatePanelTransforms = action.payload; }, + + setDisableQueryInput: (state: DashboardReduxState, action: PayloadAction) => { + state.componentState.disableQueryInput = action.payload; + }, + + setDisableAutoRefresh: (state: DashboardReduxState, action: PayloadAction) => { + state.componentState.disableAutoRefresh = action.payload; + }, + + setDisableFilters: (state: DashboardReduxState, action: PayloadAction) => { + state.componentState.disableFilters = action.payload; + }, }; diff --git a/src/plugins/dashboard/public/dashboard_container/state/diffing/dashboard_diffing_integration.ts b/src/plugins/dashboard/public/dashboard_container/state/diffing/dashboard_diffing_integration.ts index 4fe3184f619c9..8bf60bbd4d43d 100644 --- a/src/plugins/dashboard/public/dashboard_container/state/diffing/dashboard_diffing_integration.ts +++ b/src/plugins/dashboard/public/dashboard_container/state/diffing/dashboard_diffing_integration.ts @@ -30,6 +30,9 @@ export const reducersToIgnore: Array = 'setFullScreenMode', 'setExpandedPanelId', 'setHasUnsavedChanges', + 'setDisableQueryInput', + 'setDisableAutoRefresh', + 'setDisableFilters', ]; /** diff --git a/src/plugins/dashboard/public/dashboard_container/types.ts b/src/plugins/dashboard/public/dashboard_container/types.ts index c2c7cfb8aa083..68c89d9c269b7 100644 --- a/src/plugins/dashboard/public/dashboard_container/types.ts +++ b/src/plugins/dashboard/public/dashboard_container/types.ts @@ -52,6 +52,10 @@ export interface DashboardPublicState { scrollToPanelId?: string; highlightPanelId?: string; focusedPanelId?: string; + + disableQueryInput?: boolean; + disableAutoRefresh?: boolean; + disableFilters?: boolean; } export type DashboardLoadType = diff --git a/src/plugins/dashboard/public/dashboard_top_nav/internal_dashboard_top_nav.tsx b/src/plugins/dashboard/public/dashboard_top_nav/internal_dashboard_top_nav.tsx index d3e0ac3459049..33ab4153fe37f 100644 --- a/src/plugins/dashboard/public/dashboard_top_nav/internal_dashboard_top_nav.tsx +++ b/src/plugins/dashboard/public/dashboard_top_nav/internal_dashboard_top_nav.tsx @@ -93,6 +93,9 @@ export function InternalDashboardTopNav({ const hasRunMigrations = dashboard.select( (state) => state.componentState.hasRunClientsideMigrations ); + const disableQueryInput = dashboard.select((state) => state.componentState.disableQueryInput); + const disableAutoRefresh = dashboard.select((state) => state.componentState.disableAutoRefresh); + const disableFilters = dashboard.select((state) => state.componentState.disableFilters); const hasUnsavedChanges = dashboard.select((state) => state.componentState.hasUnsavedChanges); const fullScreenMode = dashboard.select((state) => state.componentState.fullScreenMode); const savedQueryId = dashboard.select((state) => state.componentState.savedQueryId); @@ -104,6 +107,8 @@ export function InternalDashboardTopNav({ const query = dashboard.select((state) => state.explicitInput.query); const title = dashboard.select((state) => state.explicitInput.title); + // const disableQueryBar = dashboard.select((state) => state.componentState.disableQueryBar); + // store data views in state & subscribe to dashboard data view changes. const [allDataViews, setAllDataViews] = useState([]); useEffect(() => { @@ -321,6 +326,7 @@ export function InternalDashboardTopNav({ >{`${getDashboardBreadcrumb()} - ${dashboardTitle}`} => { + const { apiCanAddNewPanel } = await import('@kbn/presentation-containers'); + // we cannot have an async type check, so return the casted parentApi rather than a boolean + return apiCanAddNewPanel(parentApi) ? (parentApi as CanAddNewPanel) : undefined; +}; + +export const registerCreateSavedSearchAction = (discoverServices: DiscoverServices) => { + discoverServices.uiActions.registerAction({ + id: ADD_SEARCH_EMBEDDABLE_ACTION_ID, + getIconType: () => 'discoverApp', + isCompatible: async ({ embeddable: parentApi }) => { + return Boolean(await parentApiIsCompatible(parentApi)); + }, + execute: async ({ embeddable: parentApi }) => { + const canAddNewPanelParent = await parentApiIsCompatible(parentApi); + if (!canAddNewPanelParent) throw new IncompatibleActionError(); + const { openSavedSearchEditFlyout } = await import( + '../components/editor/open_saved_search_edit_flyout' + ); + try { + const savedSearch = discoverServices.savedSearch.getNew(); + const defaultIndexPattern = await discoverServices.data.dataViews.getDefault(); + if (defaultIndexPattern) { + const queryString = getESQLWithSafeLimit( + `from ${defaultIndexPattern?.getIndexPattern()}`, + 10 + ); + savedSearch.searchSource.setField('index', defaultIndexPattern); + savedSearch.searchSource.setField('query', { esql: queryString }); + } + const { searchSourceJSON, references } = savedSearch.searchSource.serialize(); + + const embeddable = await canAddNewPanelParent.addNewPanel({ + panelType: SEARCH_EMBEDDABLE_TYPE, + initialState: { + attributes: { + isTextBasedQuery: true, + kibanaSavedObjectMeta: { + searchSourceJSON, + }, + references, + }, + }, + }); + + // open the flyout if embeddable has been created successfully + if (embeddable) { + await openSavedSearchEditFlyout({ + isEditing: false, + services: discoverServices, + api: embeddable as SearchEmbeddableApi, + }); + } + } catch { + // swallow the rejection, since this just means the user closed without saving + } + }, + getDisplayName: () => + i18n.translate('discover.embeddable.search.displayName', { + defaultMessage: 'Saved search', + }), + }); + + discoverServices.uiActions.attachAction('ADD_PANEL_TRIGGER', ADD_SEARCH_EMBEDDABLE_ACTION_ID); +}; diff --git a/src/plugins/discover/public/embeddable/components/editor/open_saved_search_edit_flyout.tsx b/src/plugins/discover/public/embeddable/components/editor/open_saved_search_edit_flyout.tsx new file mode 100644 index 0000000000000..7f214927e21b9 --- /dev/null +++ b/src/plugins/discover/public/embeddable/components/editor/open_saved_search_edit_flyout.tsx @@ -0,0 +1,102 @@ +/* + * 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, { lazy, Suspense } from 'react'; + +import { EuiLoadingSpinner } from '@elastic/eui'; +import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; +import { apiIsPresentationContainer, tracksOverlays } from '@kbn/presentation-containers'; +import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render'; +import { toMountPoint } from '@kbn/react-kibana-mount'; + +import { DiscoverServices } from '../../../build_services'; +import { isEsqlMode } from '../../initialize_fetch'; +import { SearchEmbeddableApi } from '../../types'; + +const SavedSearchEditorFlyout = lazy(() => import('./saved_search_edit_flyout')); + +export const openSavedSearchEditFlyout = async ({ + isEditing, + services, + api, + navigateToEditor, +}: { + isEditing: boolean; + services: DiscoverServices; + api: SearchEmbeddableApi; + navigateToEditor?: () => Promise; +}) => { + const overlayTracker = tracksOverlays(api.parentApi) ? api.parentApi : undefined; + const initialState = api.snapshotRuntimeState(); + const isEsql = isEsqlMode(api.savedSearch$.getValue()); + + return new Promise(async (resolve, reject) => { + try { + const onCancel = async () => { + if (!isEditing && apiIsPresentationContainer(api.parentApi)) { + api.parentApi.removePanel(api.uuid); + } else { + // Reset to initialState + const stateManager = api.getStateManager(); + const initialSearchSource = await services.data.search.searchSource.create( + initialState.serializedSearchSource + ); + stateManager.searchSource.next(initialSearchSource); + stateManager.columns.next(initialState.columns); + api.setTimeRange(initialState.timeRange); + } + flyoutSession.close(); + overlayTracker?.clearOverlays(); + }; + + const onSave = async () => { + flyoutSession.close(); + overlayTracker?.clearOverlays(); + }; + + const flyoutSession = services.core.overlays.openFlyout( + toMountPoint( + + + }> + + + + , + services.core + ), + { + ownFocus: true, + size: 's', + type: 'push', + 'data-test-subj': 'fieldStatisticsInitializerFlyout', + onClose: onCancel, + paddingSize: 'm', + hideCloseButton: true, + pushMinBreakpoint: 'xs', // TODO: Better handling of overlay mode + className: 'lnsConfigPanel__overlay savedSearchFlyout', + } + ); + + if (tracksOverlays(api.parentApi)) { + api.parentApi.openOverlay(flyoutSession, { focusedPanelId: api.uuid }); + } + } catch (error) { + reject(error); + } + }); +}; diff --git a/src/plugins/discover/public/embeddable/components/editor/saved_search_edit_flyout.scss b/src/plugins/discover/public/embeddable/components/editor/saved_search_edit_flyout.scss new file mode 100644 index 0000000000000..efcf8d01da4c0 --- /dev/null +++ b/src/plugins/discover/public/embeddable/components/editor/saved_search_edit_flyout.scss @@ -0,0 +1,41 @@ +.savedSearchFlyout { + .euiFlyoutBody { + >* { + pointer-events: auto; + } + + .esqlEditor { + margin: (-$euiSizeS * 2) (-$euiSizeS * 2) 0 ; + } + + .editorPanel { + overflow: auto; + max-height: 500px; + } + } + + .unifiedFieldListSidebar__list { + padding-left: 0px !important; + padding-right: 0px !important; + + >* { + padding-left: 0px !important; + padding-right: 0px !important; + } + + .unifiedFieldList__fieldListGrouped { + margin-left: -8px !important; + } + + .unifiedFieldList__fieldListGrouped__container { + padding-top: $euiSizeS; + left: 0; + right: 0; + position: relative !important; + } + } + + .euiFlyoutFooter { + border-top: $euiBorderThin; + } +} \ No newline at end of file diff --git a/src/plugins/discover/public/embeddable/components/editor/saved_search_edit_flyout.tsx b/src/plugins/discover/public/embeddable/components/editor/saved_search_edit_flyout.tsx new file mode 100644 index 0000000000000..ce4b6b47081e7 --- /dev/null +++ b/src/plugins/discover/public/embeddable/components/editor/saved_search_edit_flyout.tsx @@ -0,0 +1,135 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useState } from 'react'; + +import { + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiLink, + EuiTitle, +} from '@elastic/eui'; +import { css } from '@emotion/react'; +import { i18n } from '@kbn/i18n'; +import { euiThemeVars } from '@kbn/ui-theme'; + +import { DiscoverServices } from '../../../build_services'; +import { SearchEmbeddableApi, SearchEmbeddableStateManager } from '../../types'; +import { SavedSearchDataviewEditor } from './saved_search_editor_dataview'; +import { SavedSearchEsqlEditor } from './saved_search_editor_esql'; +import './saved_search_edit_flyout.scss'; + +// eslint-disable-next-line import/no-default-export +export default function SavedSearchEditorFlyout({ + api, + isEsql, + isEditing, + navigateToEditor, + onCancel, + onSave, + stateManager, + services, +}: { + api: SearchEmbeddableApi; + isEsql: boolean; + isEditing: boolean; + navigateToEditor?: () => Promise; + onCancel: () => Promise; + onSave: () => Promise; + stateManager: SearchEmbeddableStateManager; + services: DiscoverServices; +}) { + const [isValid, setIsValid] = useState(true); + + return ( + <> + + + + +

+ {isEditing + ? i18n.translate('discover.embeddable.search.editor.editLabel', { + defaultMessage: 'Edit saved search', + }) + : i18n.translate('discover.embeddable.search.editor.createLabel', { + defaultMessage: 'Create saved search', + })} +

+
+
+ + {navigateToEditor && ( + + + {i18n.translate('discover.embeddable.search.editor.editLinkLabel', { + defaultMessage: 'Edit in Discover', + })} + + + )} +
+
+ + {isEsql ? ( + + ) : ( + + )} + + + + + + {i18n.translate('discover.embeddable.search.editor.cancelFlyoutLabel', { + defaultMessage: 'Cancel', + })} + + + + + {i18n.translate('discover.embeddable.search.editor.applyFlyoutLabel', { + defaultMessage: 'Apply and close', + })} + + + + + + ); +} diff --git a/src/plugins/discover/public/embeddable/components/editor/saved_search_editor_dataview.tsx b/src/plugins/discover/public/embeddable/components/editor/saved_search_editor_dataview.tsx new file mode 100644 index 0000000000000..35323f5c2931e --- /dev/null +++ b/src/plugins/discover/public/embeddable/components/editor/saved_search_editor_dataview.tsx @@ -0,0 +1,182 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import deepEqual from 'react-fast-compare'; +import { debounceTime } from 'rxjs'; + +import { EuiPanel } from '@elastic/eui'; +import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; +import { DataViewListItem } from '@kbn/data-views-plugin/common'; +import { Filter, Query } from '@kbn/es-query'; +import { i18n } from '@kbn/i18n'; +import { useBatchedPublishingSubjects } from '@kbn/presentation-publishing'; +import { LazyDataViewPicker, withSuspense } from '@kbn/presentation-util-plugin/public'; +import { + UnifiedFieldListSidebarContainer, + type UnifiedFieldListSidebarContainerProps, +} from '@kbn/unified-field-list'; + +import { useDiscoverServices } from '../../../hooks/use_discover_services'; +import { SearchEmbeddableApi, SearchEmbeddableStateManager } from '../../types'; + +const DataViewPicker = withSuspense(LazyDataViewPicker, null); + +const getCreationOptions: UnifiedFieldListSidebarContainerProps['getCreationOptions'] = () => { + return { + originatingApp: '', // TODO + localStorageKeyPrefix: 'savedSearch', + compressed: true, + showSidebarToggleButton: false, + // disableFieldListItemDragAndDrop: true, + }; +}; + +export function SavedSearchDataviewEditor({ + api, + stateManager, +}: { + api: SearchEmbeddableApi; + stateManager: SearchEmbeddableStateManager; +}) { + const services = useDiscoverServices(); + + const initialState = useRef({ + columns: stateManager.columns.getValue(), + dataViewId: stateManager.searchSource.getValue().getField('index')?.id, + }); + const [savedSearch, columns] = useBatchedPublishingSubjects( + api.savedSearch$, + stateManager.columns + ); + const selectedDataView = savedSearch.searchSource.getField('index'); + const [dataViews, setDataViews] = useState([]); + + useEffect(() => { + (api.parentApi as DashboardContainer).ignoreUnifiedSearch = true; + (api.parentApi as DashboardContainer).dispatch.setDisableAutoRefresh(true); + + /** Handle query */ + const originalQuery = services.data.query.queryString.getQuery(); + services.data.query.queryString.setQuery( + savedSearch.searchSource.getOwnField('query') as Query + ); + const querySubscription = services.data.query.queryString + .getUpdates$() + .pipe(debounceTime(1)) + .subscribe((newQuery) => { + stateManager.searchSource.next(savedSearch.searchSource.setField('query', newQuery)); + }); + + /** Handle filters */ + const originalFilters = services.filterManager.getFilters(); + const customFilters = (savedSearch.searchSource.getOwnField('filter') ?? []) as Filter[]; + if (customFilters.length > 0) { + services.filterManager.setFilters(customFilters); + } + const filtersSubscription = services.filterManager + .getUpdates$() + .pipe(debounceTime(1)) + .subscribe(() => { + const newFilters = services.filterManager.getFilters(); + stateManager.searchSource.next(savedSearch.searchSource.setField('filter', newFilters)); + }); + + /** Handle time range */ + const originalTime = services.timefilter.getTime(); + const customTimeRange = api.timeRange$?.getValue(); + if (customTimeRange) { + services.timefilter.setTime(customTimeRange); + } + const timeRangeSubscription = services.timefilter + .getTimeUpdate$() + .pipe(debounceTime(1)) + .subscribe(() => { + const newTimeRange = services.timefilter.getTime(); + api.setTimeRange(deepEqual(originalTime, newTimeRange) ? undefined : newTimeRange); + }); + + return () => { + services.data.query.queryString.setQuery(originalQuery); + services.filterManager.setFilters(originalFilters); + services.timefilter.setTime(originalTime); + + (api.parentApi as DashboardContainer).ignoreUnifiedSearch = false; + (api.parentApi as DashboardContainer).dispatch.setDisableAutoRefresh(false); + querySubscription.unsubscribe(); + filtersSubscription.unsubscribe(); + timeRangeSubscription.unsubscribe(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + let mounted = true; + const fetchDataViews = async () => { + const dataViewListItems = await services.data.dataViews.getIdsWithTitle(); + if (mounted) setDataViews(dataViewListItems); + }; + fetchDataViews(); + return () => { + mounted = false; + }; + }, [services.data.dataViews]); + + const onSelectDataView = useCallback( + async (nextSelection: string) => { + const dataView = await services.data.dataViews.get(nextSelection); + stateManager.searchSource.next(savedSearch.searchSource.setField('index', dataView)); + }, + [services.data.dataViews, savedSearch.searchSource, stateManager.searchSource] + ); + + return ( + <> + + { + await onSelectDataView(nextSelection); + if (nextSelection === initialState.current.dataViewId) { + stateManager.columns.next(initialState.current.columns); + } else { + stateManager.columns.next([]); + } + }} + trigger={{ + label: + selectedDataView?.getName() ?? + i18n.translate('embeddableExamples.unifiedFieldList.selectDataViewMessage', { + defaultMessage: 'Please select a data view', + }), + }} + /> + + {selectedDataView && ( + + stateManager.columns.next([...(columns ?? []), field.name]) + } + onRemoveFieldFromWorkspace={(field) => { + stateManager.columns.next((columns ?? []).filter((name) => name !== field.name)); + }} + /> + )} + + + ); +} diff --git a/src/plugins/discover/public/embeddable/components/editor/saved_search_editor_esql.tsx b/src/plugins/discover/public/embeddable/components/editor/saved_search_editor_esql.tsx new file mode 100644 index 0000000000000..87690fa2c995f --- /dev/null +++ b/src/plugins/discover/public/embeddable/components/editor/saved_search_editor_esql.tsx @@ -0,0 +1,151 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import deepEqual from 'react-fast-compare'; +import { debounceTime } from 'rxjs'; + +import { EuiPanel, EuiSpacer } from '@elastic/eui'; +import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; +import { AggregateQuery, isOfAggregateQueryType } from '@kbn/es-query'; +import { useBatchedPublishingSubjects } from '@kbn/presentation-publishing'; +import { TextBasedLangEditor } from '@kbn/text-based-languages/public'; +import { + UnifiedFieldListSidebarContainer, + type UnifiedFieldListSidebarContainerProps, +} from '@kbn/unified-field-list'; + +import { useDiscoverServices } from '../../../hooks/use_discover_services'; +import { SearchEmbeddableApi, SearchEmbeddableStateManager } from '../../types'; +import { getEsqlQueryFieldList } from '../../../application/main/components/sidebar/lib/get_field_list'; + +const getCreationOptions: UnifiedFieldListSidebarContainerProps['getCreationOptions'] = () => { + return { + originatingApp: '', // TODO + localStorageKeyPrefix: 'savedSearch', + compressed: true, + showSidebarToggleButton: false, + // disableFieldListItemDragAndDrop: true, + }; +}; + +export function SavedSearchEsqlEditor({ + api, + stateManager, + setIsValid, +}: { + api: SearchEmbeddableApi; + stateManager: SearchEmbeddableStateManager; + setIsValid: (valid: boolean) => void; +}) { + const services = useDiscoverServices(); + + const [savedSearch, loading, esqlQueryColumns] = useBatchedPublishingSubjects( + api.savedSearch$, + api.dataLoading, + stateManager.esqlQueryColumns + ); + const [query, setQuery] = useState( + savedSearch.searchSource.getOwnField('query') as AggregateQuery + ); + const prevQuery = useRef(query); + + useEffect(() => { + (api.parentApi as DashboardContainer).ignoreUnifiedSearch = true; + (api.parentApi as DashboardContainer).dispatch.setDisableQueryInput(true); + (api.parentApi as DashboardContainer).dispatch.setDisableAutoRefresh(true); + (api.parentApi as DashboardContainer).dispatch.setDisableFilters(true); + + /** Handle time range */ + const originalTime = services.timefilter.getTime(); + const customTimeRange = api.timeRange$?.getValue(); + if (customTimeRange) { + services.timefilter.setTime(customTimeRange); + } + const timeRangeSubscription = services.timefilter + .getTimeUpdate$() + .pipe(debounceTime(1)) + .subscribe(() => { + const newTimeRange = services.timefilter.getTime(); + api.setTimeRange(deepEqual(originalTime, newTimeRange) ? undefined : newTimeRange); + }); + + return () => { + services.timefilter.setTime(originalTime); + + (api.parentApi as DashboardContainer).ignoreUnifiedSearch = false; + (api.parentApi as DashboardContainer).dispatch.setDisableQueryInput(false); + (api.parentApi as DashboardContainer).dispatch.setDisableAutoRefresh(false); + (api.parentApi as DashboardContainer).dispatch.setDisableFilters(false); + timeRangeSubscription.unsubscribe(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const dataView = useMemo(() => { + return savedSearch.searchSource.getField('index'); + }, [savedSearch]); + + const onTextLangQuerySubmit = useCallback( + async (q) => { + if (q) { + stateManager.searchSource.next(savedSearch.searchSource.setField('query', q)); + setIsValid(isOfAggregateQueryType(q) && q.esql !== ''); + } + }, + [savedSearch.searchSource, stateManager.searchSource, setIsValid] + ); + + return ( + <> + {query && ( +
+ { + setQuery(q); + prevQuery.current = q; + }} + expandCodeEditor={(status: boolean) => {}} + isCodeEditorExpanded + hideMinimizeButton + editorIsInline + hideRunQueryText + onTextLangQuerySubmit={onTextLangQuerySubmit} + isDisabled={false} + allowQueryCancellation + isLoading={loading} + /> +
+ )} + + {dataView && ( + + + stateManager.columns.next([...(savedSearch.columns ?? []), field.name]) + } + onRemoveFieldFromWorkspace={(field) => { + stateManager.columns.next( + (savedSearch.columns ?? []).filter((name) => name !== field.name) + ); + }} + /> + + )} + + ); +} diff --git a/src/plugins/discover/public/embeddable/components/search_embeddable_grid_component.tsx b/src/plugins/discover/public/embeddable/components/search_embeddable_grid_component.tsx index 219ac6988a551..e9e41dc78a5de 100644 --- a/src/plugins/discover/public/embeddable/components/search_embeddable_grid_component.tsx +++ b/src/plugins/discover/public/embeddable/components/search_embeddable_grid_component.tsx @@ -163,7 +163,7 @@ export function SearchEmbeddableGridComponent({ > = [ - 'sort', - 'columns', - 'rowHeight', - 'sampleSize', - 'rowsPerPage', - 'headerRowHeight', -] as const; +export const EDITABLE_SAVED_SEARCH_KEYS: Readonly< + Array< + keyof Pick< + SavedSearchAttributes, + 'sort' | 'columns' | 'rowHeight' | 'sampleSize' | 'rowsPerPage' | 'headerRowHeight' + > + > +> = ['sort', 'columns', 'rowHeight', 'sampleSize', 'rowsPerPage', 'headerRowHeight'] as const; /** This constant refers to the dashboard panel specific state */ export const EDITABLE_PANEL_KEYS = [ 'title', // panel title 'description', // panel description - 'timeRange', // panel custom time range ] as const; diff --git a/src/plugins/discover/public/embeddable/get_search_embeddable_factory.tsx b/src/plugins/discover/public/embeddable/get_search_embeddable_factory.tsx index cb71167a5dd94..aaaad31f71b2f 100644 --- a/src/plugins/discover/public/embeddable/get_search_embeddable_factory.tsx +++ b/src/plugins/discover/public/embeddable/get_search_embeddable_factory.tsx @@ -90,22 +90,6 @@ export const getSearchEmbeddableFactory = ({ const searchEmbeddable = await initializeSearchEmbeddableApi(initialState, { discoverServices, }); - const unsubscribeFromFetch = initializeFetch({ - api: { - parentApi, - ...titlesApi, - ...timeRange.api, - savedSearch$: searchEmbeddable.api.savedSearch$, - dataViews: searchEmbeddable.api.dataViews, - savedObjectId: savedObjectId$, - dataLoading: dataLoading$, - blockingError: blockingError$, - fetchContext$, - fetchWarnings$, - }, - discoverServices, - stateManager: searchEmbeddable.stateManager, - }); const api: SearchEmbeddableApi = buildApi( { @@ -115,9 +99,9 @@ export const getSearchEmbeddableFactory = ({ ...initializeEditApi({ uuid, parentApi, - partialApi: { ...searchEmbeddable.api, fetchContext$, savedObjectId: savedObjectId$ }, discoverServices, isEditable: startServices.isEditable, + getApi: () => ({ ...api, fetchContext$ }), }), dataLoading: dataLoading$, blockingError: blockingError$, @@ -133,6 +117,7 @@ export const getSearchEmbeddableFactory = ({ serializedSearchSource: savedSearch.searchSource.getSerializedFields(), }; }, + getStateManager: () => searchEmbeddable.stateManager, hasTimeRange: () => { const fetchContext = fetchContext$.getValue(); return fetchContext?.timeslice !== undefined || fetchContext?.timeRange !== undefined; @@ -174,15 +159,27 @@ export const getSearchEmbeddableFactory = ({ defaultPanelTitle$.next(undefined); defaultPanelDescription$.next(undefined); }, - serializeState: () => - serializeState({ + serializeState: async () => { + const savedObjectId = savedObjectId$.getValue(); + const updatedSavedSearch = searchEmbeddable.api.savedSearch$.getValue(); + if (savedObjectId && api.unsavedChanges.getValue()) { + // update the saved object **only** if something changed + await save({ + ...omit(updatedSavedSearch, 'timeRange'), + ...timeRange.serialize(), + id: savedObjectId, + title: defaultPanelTitle$.getValue(), + }); + } + return serializeState({ uuid, initialState, savedSearch: searchEmbeddable.api.savedSearch$.getValue(), serializeTitles, serializeTimeRange: timeRange.serialize, savedObjectId: savedObjectId$.getValue(), - }), + }); + }, }, { ...titleComparators, @@ -197,13 +194,22 @@ export const getSearchEmbeddableFactory = ({ } ); + const unsubscribeFromFetch = initializeFetch({ + api: { + ...api, + blockingError: blockingError$, + dataLoading: dataLoading$, + fetchContext$, + fetchWarnings$, + }, + discoverServices, + stateManager: searchEmbeddable.stateManager, + }); + return { api, Component: () => { - const [savedSearch, dataViews] = useBatchedPublishingSubjects( - api.savedSearch$, - api.dataViews - ); + const [savedSearch] = useBatchedPublishingSubjects(api.savedSearch$); useEffect(() => { return () => { @@ -220,29 +226,9 @@ export const getSearchEmbeddableFactory = ({ }); }, [savedSearch]); - const dataView = useMemo(() => { - const hasDataView = (dataViews ?? []).length > 0; - if (!hasDataView) { - blockingError$.next( - new Error( - i18n.translate('discover.embeddable.search.dataViewError', { - defaultMessage: 'Missing data view {indexPatternId}', - values: { - indexPatternId: - typeof initialState.serializedSearchSource?.index === 'string' - ? initialState.serializedSearchSource.index - : initialState.serializedSearchSource?.index?.id ?? '', - }, - }) - ) - ); - return; - } - return dataViews![0]; - }, [dataViews]); - const onAddFilter = useCallback( async (field, value, operator) => { + const dataView = savedSearch.searchSource.getField('index'); if (!dataView) return; let newFilters = generateFilters( @@ -262,16 +248,16 @@ export const getSearchEmbeddableFactory = ({ filters: newFilters, }); }, - [dataView] + [savedSearch] ); const renderAsFieldStatsTable = useMemo( () => discoverServices.uiSettings.get(SHOW_FIELD_STATISTICS) && viewMode === VIEW_MODE.AGGREGATED_LEVEL && - dataView && + savedSearch.searchSource.getField('index') && Array.isArray(savedSearch.columns), - [savedSearch, dataView, viewMode] + [savedSearch, viewMode] ); return ( @@ -283,7 +269,7 @@ export const getSearchEmbeddableFactory = ({ ...api, fetchContext$, }} - dataView={dataView!} + dataView={savedSearch.searchSource.getField('index')!} onAddFilter={onAddFilter} stateManager={searchEmbeddable.stateManager} /> @@ -295,7 +281,7 @@ export const getSearchEmbeddableFactory = ({ > diff --git a/src/plugins/discover/public/embeddable/initialize_edit_api.ts b/src/plugins/discover/public/embeddable/initialize_edit_api.ts index 118607e46a46e..b768e259227ce 100644 --- a/src/plugins/discover/public/embeddable/initialize_edit_api.ts +++ b/src/plugins/discover/public/embeddable/initialize_edit_api.ts @@ -7,28 +7,16 @@ */ import { i18n } from '@kbn/i18n'; -import { - apiHasAppContext, - FetchContext, - PublishesDataViews, - PublishesSavedObjectId, - PublishingSubject, -} from '@kbn/presentation-publishing'; +import { apiHasAppContext, FetchContext, PublishingSubject } from '@kbn/presentation-publishing'; import { DiscoverServices } from '../build_services'; -import { PublishesSavedSearch } from './types'; +import { openSavedSearchEditFlyout } from './components/editor/open_saved_search_edit_flyout'; +import { SearchEmbeddableApi } from './types'; import { getDiscoverLocatorParams } from './utils/get_discover_locator_params'; -type SavedSearchPartialApi = PublishesSavedSearch & - PublishesSavedObjectId & - PublishesDataViews & { fetchContext$: PublishingSubject }; - -export async function getAppTarget( - partialApi: SavedSearchPartialApi, - discoverServices: DiscoverServices -) { - const savedObjectId = partialApi.savedObjectId.getValue(); - const dataViews = partialApi.dataViews.getValue(); - const locatorParams = getDiscoverLocatorParams(partialApi); +export async function getAppTarget(api: SearchEmbeddableApi, discoverServices: DiscoverServices) { + const savedObjectId = api.savedObjectId.getValue(); + const dataViews = api.dataViews.getValue(); + const locatorParams = getDiscoverLocatorParams(api); // We need to use a redirect URL if this is a by value saved search using // an ad hoc data view to ensure the data view spec gets encoded in the URL @@ -44,16 +32,16 @@ export async function getAppTarget( export function initializeEditApi({ uuid, + getApi, parentApi, - partialApi, isEditable, discoverServices, }: { uuid: string; + getApi: () => SearchEmbeddableApi & { + fetchContext$: PublishingSubject; + }; parentApi?: unknown; - partialApi: PublishesSavedSearch & - PublishesSavedObjectId & - PublishesDataViews & { fetchContext$: PublishingSubject }; isEditable: () => boolean; discoverServices: DiscoverServices; }) { @@ -61,33 +49,56 @@ export function initializeEditApi({ * If the parent is providing context, then the embeddable state transfer service can be used * and editing should be allowed; otherwise, do not provide editing capabilities */ - if (!parentApi || !apiHasAppContext(parentApi)) { + if (!isEditable || !parentApi || !apiHasAppContext(parentApi)) { return {}; } const parentApiContext = parentApi.getAppContext(); + const navigateToEditor = async () => { + const api = getApi(); + const stateTransfer = discoverServices.embeddable.getStateTransfer(); + const appTarget = await getAppTarget(api, discoverServices); + await stateTransfer.navigateToEditor(appTarget.app, { + path: appTarget.path, + state: { + embeddableId: uuid, + valueInput: api.savedSearch$.getValue(), + originatingApp: parentApiContext.currentAppId, + searchSessionId: api.fetchContext$.getValue()?.searchSessionId, + originatingPath: parentApiContext.getCurrentPath?.(), + }, + }); + }; + return { getTypeDisplayName: () => i18n.translate('discover.embeddable.search.displayName', { defaultMessage: 'search', }), onEdit: async () => { - const stateTransfer = discoverServices.embeddable.getStateTransfer(); - const appTarget = await getAppTarget(partialApi, discoverServices); - await stateTransfer.navigateToEditor(appTarget.app, { - path: appTarget.path, - state: { - embeddableId: uuid, - valueInput: partialApi.savedSearch$.getValue(), - originatingApp: parentApiContext.currentAppId, - searchSessionId: partialApi.fetchContext$.getValue()?.searchSessionId, - originatingPath: parentApiContext.getCurrentPath?.(), - }, + const api = getApi(); + await openSavedSearchEditFlyout({ + api, + isEditing: true, + navigateToEditor, + services: discoverServices, }); + // const stateTransfer = discoverServices.embeddable.getStateTransfer(); + // const appTarget = await getAppTarget(partialApi, discoverServices); + // await stateTransfer.navigateToEditor(appTarget.app, { + // path: appTarget.path, + // state: { + // embeddableId: uuid, + // valueInput: partialApi.savedSearch$.getValue(), + // originatingApp: parentApiContext.currentAppId, + // searchSessionId: partialApi.fetchContext$.getValue()?.searchSessionId, + // originatingPath: parentApiContext.getCurrentPath?.(), + // }, + // }); }, isEditingEnabled: isEditable, getEditHref: async () => { - return (await getAppTarget(partialApi, discoverServices))?.path; + return (await getAppTarget(getApi(), discoverServices))?.path; }, }; } diff --git a/src/plugins/discover/public/embeddable/initialize_fetch.tsx b/src/plugins/discover/public/embeddable/initialize_fetch.tsx index 9223c17642543..995f47b247070 100644 --- a/src/plugins/discover/public/embeddable/initialize_fetch.tsx +++ b/src/plugins/discover/public/embeddable/initialize_fetch.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { BehaviorSubject, combineLatest, lastValueFrom, switchMap } from 'rxjs'; +import { BehaviorSubject, combineLatest, debounceTime, lastValueFrom, switchMap } from 'rxjs'; import { KibanaExecutionContext } from '@kbn/core/types'; import { @@ -24,12 +24,7 @@ import { apiHasParentApi, fetch$, FetchContext, - HasParentApi, - PublishesDataViews, - PublishesPanelTitle, - PublishesSavedObjectId, } from '@kbn/presentation-publishing'; -import { PublishesWritableTimeRange } from '@kbn/presentation-publishing/interfaces/fetch/publishes_unified_search'; import { SavedSearch } from '@kbn/saved-search-plugin/public'; import { SearchResponseWarning } from '@kbn/search-response-warnings'; import { SearchResponseIncompleteWarning } from '@kbn/search-response-warnings/src/types'; @@ -40,27 +35,23 @@ import { fetchEsql } from '../application/main/data_fetching/fetch_esql'; import { DiscoverServices } from '../build_services'; import { getAllowedSampleSize } from '../utils/get_allowed_sample_size'; import { getAppTarget } from './initialize_edit_api'; -import { PublishesSavedSearch, SearchEmbeddableStateManager } from './types'; +import { SearchEmbeddableApi, SearchEmbeddableStateManager } from './types'; import { getTimeRangeFromFetchContext, updateSearchSource } from './utils/update_search_source'; -type SavedSearchPartialFetchApi = PublishesSavedSearch & - PublishesSavedObjectId & - PublishesDataViews & - PublishesPanelTitle & - PublishesWritableTimeRange & { - fetchContext$: BehaviorSubject; - dataLoading: BehaviorSubject; - blockingError: BehaviorSubject; - fetchWarnings$: BehaviorSubject; - } & HasParentApi; +type SavedSearchPrivateFetchApi = SearchEmbeddableApi & { + fetchContext$: BehaviorSubject; + dataLoading: BehaviorSubject; + blockingError: BehaviorSubject; + fetchWarnings$: BehaviorSubject; +}; export const isEsqlMode = (savedSearch: Pick): boolean => { - const query = savedSearch.searchSource.getField('query'); + const query = savedSearch.searchSource.getOwnField('query'); return isOfAggregateQueryType(query); }; const getExecutionContext = async ( - api: SavedSearchPartialFetchApi, + api: SavedSearchPrivateFetchApi, discoverServices: DiscoverServices ) => { const { editUrl } = await getAppTarget(api, discoverServices); @@ -86,17 +77,18 @@ export function initializeFetch({ stateManager, discoverServices, }: { - api: SavedSearchPartialFetchApi; + api: SavedSearchPrivateFetchApi; stateManager: SearchEmbeddableStateManager; discoverServices: DiscoverServices; }) { const requestAdapter = new RequestAdapter(); let abortController = new AbortController(); - const fetchSubscription = combineLatest([fetch$(api), api.savedSearch$, api.dataViews]) + const fetchSubscription = combineLatest([fetch$(api), api.savedSearch$]) .pipe( - switchMap(async ([fetchContext, savedSearch, dataViews]) => { - const dataView = dataViews?.length ? dataViews[0] : undefined; + debounceTime(1), + switchMap(async ([fetchContext, savedSearch]) => { + const dataView = savedSearch.searchSource.getField('index'); api.blockingError.next(undefined); if (!dataView || !savedSearch.searchSource) { return; @@ -120,7 +112,7 @@ export function initializeFetch({ ); const searchSessionId = fetchContext.searchSessionId; - const searchSourceQuery = savedSearch.searchSource.getField('query'); + const searchSourceQuery = savedSearch.searchSource.getOwnField('query'); try { api.dataLoading.next(true); @@ -163,6 +155,7 @@ export function initializeFetch({ rows: result.records, hitCount: result.records.length, fetchContext, + esqlQueryColumns: result.esqlQueryColumns, }; } @@ -218,9 +211,14 @@ export function initializeFetch({ stateManager.totalHitCount.next(next.hitCount); api.fetchWarnings$.next(next.warnings ?? []); api.fetchContext$.next(next.fetchContext); + + /** ESQL stuff */ if (next.hasOwnProperty('columnsMeta')) { stateManager.columnsMeta.next(next.columnsMeta); } + if (next.hasOwnProperty('esqlQueryColumns')) { + stateManager.esqlQueryColumns.next(next.esqlQueryColumns); + } }); return () => { diff --git a/src/plugins/discover/public/embeddable/initialize_search_embeddable_api.tsx b/src/plugins/discover/public/embeddable/initialize_search_embeddable_api.tsx index 40b4351328b74..b535576dc8601 100644 --- a/src/plugins/discover/public/embeddable/initialize_search_embeddable_api.tsx +++ b/src/plugins/discover/public/embeddable/initialize_search_embeddable_api.tsx @@ -8,20 +8,28 @@ import { pick } from 'lodash'; import deepEqual from 'react-fast-compare'; -import { BehaviorSubject, combineLatest, map, Observable, skip } from 'rxjs'; +import { BehaviorSubject, combineLatest, debounceTime, Observable, skip, switchMap } from 'rxjs'; import { ISearchSource, SerializedSearchSourceFields } from '@kbn/data-plugin/common'; import { DataView } from '@kbn/data-views-plugin/common'; import { ROW_HEIGHT_OPTION, SAMPLE_SIZE_SETTING } from '@kbn/discover-utils'; import { DataTableRecord } from '@kbn/discover-utils/types'; -import type { PublishesDataViews, StateComparators } from '@kbn/presentation-publishing'; +import { AggregateQuery, Filter, Query } from '@kbn/es-query'; +import { DatatableColumn } from '@kbn/expressions-plugin/common'; +import type { + PublishesDataViews, + PublishesUnifiedSearch, + StateComparators, +} from '@kbn/presentation-publishing'; import { SavedSearch } from '@kbn/saved-search-plugin/common'; import { SortOrder, VIEW_MODE } from '@kbn/saved-search-plugin/public'; import { DataTableColumnsMeta } from '@kbn/unified-data-table'; import { getDefaultRowsPerPage } from '../../common/constants'; +import { getEsqlDataView } from '../application/main/state_management/utils/get_esql_data_view'; import { DiscoverServices } from '../build_services'; import { DEFAULT_HEADER_ROW_HEIGHT_LINES, EDITABLE_SAVED_SEARCH_KEYS } from './constants'; +import { isEsqlMode } from './initialize_fetch'; import { PublishesSavedSearch, SearchEmbeddableRuntimeState, @@ -29,7 +37,7 @@ import { SearchEmbeddableStateManager, } from './types'; -const initializeSearchSource = async ( +export const initializeSearchSource = async ( dataService: DiscoverServices['data'], serializedSearchSource?: SerializedSearchSourceFields ) => { @@ -65,7 +73,7 @@ export const initializeSearchEmbeddableApi = async ( discoverServices: DiscoverServices; } ): Promise<{ - api: PublishesSavedSearch & PublishesDataViews; + api: PublishesSavedSearch & PublishesDataViews & Partial; stateManager: SearchEmbeddableStateManager; comparators: StateComparators; cleanup: () => void; @@ -77,6 +85,14 @@ export const initializeSearchEmbeddableApi = async ( initialState.serializedSearchSource ); const searchSource$ = new BehaviorSubject(searchSource); + + /** Initialize the stuff that is tied to the search source; time range comes from timeRangeApi */ + const filters$ = new BehaviorSubject( + searchSource.getField('filter') as Filter[] + ); + const query$ = new BehaviorSubject( + searchSource.getField('query') + ); const dataViews = new BehaviorSubject(dataView ? [dataView] : undefined); /** This is the state that can be initialized from the saved initial state */ @@ -92,6 +108,7 @@ export const initializeSearchEmbeddableApi = async ( /** This is the state that has to be fetched */ const rows$ = new BehaviorSubject([]); const columnsMeta$ = new BehaviorSubject(undefined); + const esqlQueryColumns$ = new BehaviorSubject(undefined); const totalHitCount$ = new BehaviorSubject(undefined); const defaultRowHeight = discoverServices.uiSettings.get(ROW_HEIGHT_OPTION); @@ -106,6 +123,7 @@ export const initializeSearchEmbeddableApi = async ( breakdownField: breakdownField$, columns: columns$, columnsMeta: columnsMeta$, + esqlQueryColumns: esqlQueryColumns$, headerRowHeight: headerRowHeight$, rows: rows$, rowHeight: rowHeight$, @@ -114,47 +132,81 @@ export const initializeSearchEmbeddableApi = async ( sort: sort$, totalHitCount: totalHitCount$, viewMode: savedSearchViewMode$, + searchSource: searchSource$, }; /** The saved search should be the source of truth for all state */ const savedSearch$ = new BehaviorSubject(initializedSavedSearch(stateManager, searchSource)); /** This will fire when any of the **editable** state changes */ - const onAnyStateChange: Observable> = combineLatest( + const onAnyStateChange: Observable> = combineLatest( pick(stateManager, EDITABLE_SAVED_SEARCH_KEYS) ); + const searchSourceParent = searchSource.getParent(); // this should be the dashboard /** Keep the saved search in sync with any state changes */ - const syncSavedSearch = combineLatest([onAnyStateChange, searchSource$]) + const syncSavedSearch = combineLatest([onAnyStateChange, serializedSearchSource$]) .pipe( skip(1), - map( - ([newState, newSearchSource]) => - ({ - ...newState, + switchMap( + async ([partialSavedSearch, serializedSearchSource]): Promise<{ + savedSearch: SavedSearch; + dataView?: DataView; + }> => { + const newSearchSource = await discoverServices.data.search.searchSource.create( + serializedSearchSource + ); + newSearchSource.setParent(searchSourceParent); + const newSavedSearch: SavedSearch = { + ...savedSearch$.getValue(), + ...partialSavedSearch, searchSource: newSearchSource, - } as SavedSearch) - ) + }; + if (isEsqlMode(newSavedSearch)) { + const currentDataView = newSearchSource.getField('index'); + const query = newSearchSource.getField('query') as AggregateQuery; + const nextDataView = await getEsqlDataView(query, currentDataView, discoverServices); + return { savedSearch: newSavedSearch, dataView: nextDataView }; + } + return { savedSearch: newSavedSearch }; + } + ), + debounceTime(1) ) - .subscribe((newSavedSearch) => { + .subscribe(({ savedSearch: newSavedSearch, dataView: newDataView }) => { + if (newDataView) { + newSavedSearch.searchSource.setField('index', newDataView); + } + filters$.next(newSavedSearch.searchSource.getOwnField('filter') as Filter[]); + query$.next(newSavedSearch.searchSource.getOwnField('query')); savedSearch$.next(newSavedSearch); }); + const syncDataView = dataViews.pipe(skip(1)).subscribe((newDataViews) => { + if (!(newDataViews ?? []).length) return; + const newDataView = newDataViews![0]; + searchSource$.next(searchSource$.getValue().setField('index', newDataView)); + }); + + const syncSerializedSearchSource = searchSource$ + .pipe(skip(1), debounceTime(60)) + .subscribe(async (newSearchSource) => { + serializedSearchSource$.next(newSearchSource.getSerializedFields()); + }); + return { cleanup: () => { syncSavedSearch.unsubscribe(); + syncDataView.unsubscribe(); + syncSerializedSearchSource.unsubscribe(); }, - api: { - dataViews, - savedSearch$, - }, + api: { filters$, query$, dataViews, savedSearch$ }, stateManager, comparators: { serializedSearchSource: [ serializedSearchSource$, - (value) => { - return; // the search source can't currently be changed from dashboard, so the setter is not necessary - }, + (value) => serializedSearchSource$.next(value), + (a, b) => deepEqual(a, b), ], viewMode: [ savedSearchViewMode$, diff --git a/src/plugins/discover/public/embeddable/types.ts b/src/plugins/discover/public/embeddable/types.ts index d7b4e8afd5210..a5042ef5cfc21 100644 --- a/src/plugins/discover/public/embeddable/types.ts +++ b/src/plugins/discover/public/embeddable/types.ts @@ -6,8 +6,10 @@ * Side Public License, v 1. */ +import { ISearchSource } from '@kbn/data-plugin/common'; import { DataTableRecord } from '@kbn/discover-utils/types'; import type { DefaultEmbeddableApi } from '@kbn/embeddable-plugin/public'; +import { DatatableColumn } from '@kbn/expressions-plugin/common'; import { EmbeddableApiContext, HasEditCapabilities, @@ -16,10 +18,12 @@ import { PublishesDataLoading, PublishesDataViews, PublishesSavedObjectId, + PublishesUnifiedSearch, PublishingSubject, SerializedTimeRange, SerializedTitles, } from '@kbn/presentation-publishing'; +import { PublishesWritableTimeRange } from '@kbn/presentation-publishing/interfaces/fetch/publishes_unified_search'; import { SavedSearch, SavedSearchAttributes, @@ -31,27 +35,23 @@ import { EDITABLE_SAVED_SEARCH_KEYS } from './constants'; export type SearchEmbeddableState = Pick< SerializableSavedSearch, - | 'rowHeight' - | 'rowsPerPage' - | 'headerRowHeight' - | 'columns' - | 'sort' - | 'sampleSize' - | 'breakdownField' - | 'viewMode' + typeof EDITABLE_SAVED_SEARCH_KEYS[number] | 'breakdownField' | 'viewMode' > & { rows: DataTableRecord[]; columnsMeta: DataTableColumnsMeta | undefined; totalHitCount: number | undefined; + esqlQueryColumns: DatatableColumn[] | undefined; }; export type SearchEmbeddableStateManager = { [key in keyof Required]: BehaviorSubject; +} & { + searchSource: BehaviorSubject; }; export type SearchEmbeddableSerializedAttributes = Omit< SearchEmbeddableState, - 'rows' | 'columnsMeta' | 'totalHitCount' | 'searchSource' + 'rows' | 'columnsMeta' | 'totalHitCount' | 'searchSource' | 'esqlQueryColumns' > & Pick; @@ -59,7 +59,7 @@ export type SearchEmbeddableSerializedState = SerializedTitles & SerializedTimeRange & Partial> & { // by value - attributes?: SavedSearchAttributes & { references: SavedSearch['references'] }; + attributes?: Partial & { references: SavedSearch['references'] }; // by reference savedObjectId?: string; }; @@ -84,7 +84,11 @@ export type SearchEmbeddableApi = DefaultEmbeddableApi< PublishesDataViews & HasInPlaceLibraryTransforms & HasTimeRange & - Partial; + PublishesWritableTimeRange & + Partial & { + // PublishesUnifiedSearch represents the parts of the search source that should be exposed + getStateManager: () => SearchEmbeddableStateManager; // probably not best to expose this but makes creation easier ¯\_(ツ)_/¯ + }; export interface PublishesSavedSearch { savedSearch$: PublishingSubject; diff --git a/src/plugins/discover/public/embeddable/utils/serialization_utils.ts b/src/plugins/discover/public/embeddable/utils/serialization_utils.ts index a67a02d4988c9..b9725bd695c55 100644 --- a/src/plugins/discover/public/embeddable/utils/serialization_utils.ts +++ b/src/plugins/discover/public/embeddable/utils/serialization_utils.ts @@ -6,8 +6,7 @@ * Side Public License, v 1. */ -import { omit, pick } from 'lodash'; -import deepEqual from 'react-fast-compare'; +import { pick } from 'lodash'; import { EmbeddableStateWithType } from '@kbn/embeddable-plugin/common'; import { SerializedPanelState } from '@kbn/presentation-containers'; @@ -21,11 +20,7 @@ import { SavedSearchUnwrapResult } from '@kbn/saved-search-plugin/public'; import { extract, inject } from '../../../common/embeddable/search_inject_extract'; import { DiscoverServices } from '../../build_services'; -import { - EDITABLE_PANEL_KEYS, - EDITABLE_SAVED_SEARCH_KEYS, - SEARCH_EMBEDDABLE_TYPE, -} from '../constants'; +import { EDITABLE_PANEL_KEYS, SEARCH_EMBEDDABLE_TYPE } from '../constants'; import { SearchEmbeddableRuntimeState, SearchEmbeddableSerializedState } from '../types'; export const deserializeState = async ({ @@ -41,16 +36,13 @@ export const deserializeState = async ({ // by reference const { get } = discoverServices.savedSearch; const so = await get(savedObjectId, true); - const savedObjectOverride = pick(serializedState.rawState, EDITABLE_SAVED_SEARCH_KEYS); return { - // ignore the time range from the saved object - only global time range + panel time range matter - ...omit(so, 'timeRange'), + ...so, savedObjectId, savedObjectTitle: so.title, savedObjectDescription: so.description, // Overwrite SO state with dashboard state for title, description, columns, sort, etc. ...panelState, - ...savedObjectOverride, }; } else { // by value @@ -59,7 +51,7 @@ export const deserializeState = async ({ undefined, inject( serializedState.rawState as unknown as EmbeddableStateWithType, - serializedState.references ?? [] + serializedState.rawState.attributes?.references ?? [] ) as SavedSearchUnwrapResult, true ); @@ -70,7 +62,7 @@ export const deserializeState = async ({ } }; -export const serializeState = async ({ +export const serializeState = ({ uuid, initialState, savedSearch, @@ -84,32 +76,16 @@ export const serializeState = async ({ serializeTitles: () => SerializedTitles; serializeTimeRange: () => SerializedTimeRange; savedObjectId?: string; -}): Promise> => { +}): SerializedPanelState => { const searchSource = savedSearch.searchSource; const { searchSourceJSON, references: originalReferences } = searchSource.serialize(); const savedSearchAttributes = toSavedSearchAttributes(savedSearch, searchSourceJSON); if (savedObjectId) { - // only save the current state that is **different** than the initial state - const overwriteState = EDITABLE_SAVED_SEARCH_KEYS.reduce((prev, key) => { - if ( - deepEqual( - savedSearchAttributes[key], - initialState[key as keyof SearchEmbeddableRuntimeState] - ) - ) { - return prev; - } - return { ...prev, [key]: savedSearchAttributes[key] }; - }, {}); - return { rawState: { savedObjectId, - // Serialize the current dashboard state into the panel state **without** updating the saved object ...serializeTitles(), - ...serializeTimeRange(), - ...overwriteState, }, // No references to extract for by-reference embeddable since all references are stored with by-reference saved object references: [], diff --git a/src/plugins/discover/public/plugin.tsx b/src/plugins/discover/public/plugin.tsx index f58840062becf..73823ff8246af 100644 --- a/src/plugins/discover/public/plugin.tsx +++ b/src/plugins/discover/public/plugin.tsx @@ -59,6 +59,7 @@ import { RootProfileService, } from './context_awareness'; import { DiscoverSetup, DiscoverSetupPlugins, DiscoverStart, DiscoverStartPlugins } from './types'; +import { registerCreateSavedSearchAction } from './embeddable/actions/create_saved_search_action'; /** * Contains Discover, one of the oldest parts of Kibana @@ -286,6 +287,8 @@ export class DiscoverPlugin return this.getDiscoverServices(core, plugins, this.createEmptyProfilesManager()); }; + registerCreateSavedSearchAction(getDiscoverServicesInternal()); + return { locator: this.locator, DiscoverContainer: (props: DiscoverContainerProps) => ( diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx index 56daf31fb0703..6bbe7c428606a 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx @@ -26,6 +26,7 @@ export type TopNavMenuProps = Omit< badges?: TopNavMenuBadgeProps[]; showSearchBar?: boolean; showQueryInput?: boolean; + disableQueryInput?: boolean; showDatePicker?: boolean; showFilterBar?: boolean; unifiedSearch?: UnifiedSearchPublicPluginStart; diff --git a/src/plugins/saved_search/common/content_management/v1/cm_services.ts b/src/plugins/saved_search/common/content_management/v1/cm_services.ts index ef9d24bb8722d..e15b410acea89 100644 --- a/src/plugins/saved_search/common/content_management/v1/cm_services.ts +++ b/src/plugins/saved_search/common/content_management/v1/cm_services.ts @@ -110,6 +110,7 @@ const savedSearchCreateOptionsSchema = schema.maybe( const savedSearchUpdateOptionsSchema = schema.maybe( schema.object({ references: updateOptionsSchema.references, + mergeAttributes: updateOptionsSchema.mergeAttributes, }) ); const savedSearchSearchOptionsSchema = schema.maybe( diff --git a/src/plugins/saved_search/common/content_management/v1/types.ts b/src/plugins/saved_search/common/content_management/v1/types.ts index e0983c77aeb14..1ff9944ed3754 100644 --- a/src/plugins/saved_search/common/content_management/v1/types.ts +++ b/src/plugins/saved_search/common/content_management/v1/types.ts @@ -23,6 +23,7 @@ interface SavedSearchCreateOptions { interface SavedSearchUpdateOptions { references?: SavedObjectUpdateOptions['references']; + mergeAttributes?: SavedObjectUpdateOptions['mergeAttributes']; } interface SavedSearchSearchOptions { diff --git a/src/plugins/saved_search/common/service/saved_searches_utils.ts b/src/plugins/saved_search/common/service/saved_searches_utils.ts index 866b9876d4dd4..6af9e1823bf1c 100644 --- a/src/plugins/saved_search/common/service/saved_searches_utils.ts +++ b/src/plugins/saved_search/common/service/saved_searches_utils.ts @@ -8,7 +8,6 @@ import type { SavedObjectReference } from '@kbn/core-saved-objects-server'; import { SerializedSearchSourceFields } from '@kbn/data-plugin/common'; -import { pick } from 'lodash'; import type { SavedSearch, SavedSearchAttributes } from '..'; import { fromSavedSearchAttributes as fromSavedSearchAttributesCommon } from '..'; import { SerializableSavedSearch } from '../types'; @@ -48,7 +47,7 @@ export const toSavedSearchAttributes = ( isTextBasedQuery: savedSearch.isTextBasedQuery ?? false, usesAdHocDataView: savedSearch.usesAdHocDataView, timeRestore: savedSearch.timeRestore ?? false, - timeRange: savedSearch.timeRange ? pick(savedSearch.timeRange, ['from', 'to']) : undefined, + timeRange: savedSearch.timeRange, refreshInterval: savedSearch.refreshInterval, rowsPerPage: savedSearch.rowsPerPage, sampleSize: savedSearch.sampleSize, diff --git a/src/plugins/saved_search/public/services/saved_searches/save_saved_searches.ts b/src/plugins/saved_search/public/services/saved_searches/save_saved_searches.ts index 6594dd3696053..51d020087562a 100644 --- a/src/plugins/saved_search/public/services/saved_searches/save_saved_searches.ts +++ b/src/plugins/saved_search/public/services/saved_searches/save_saved_searches.ts @@ -28,6 +28,14 @@ export const saveSearchSavedObject = async ( references: Reference[] | undefined, contentManagement: ContentManagementPublicStart['client'] ) => { + const definedAttributes = Object.keys(attributes).reduce( + (prev: SavedSearchAttributes, key: string) => { + return attributes[key as keyof SavedSearchAttributes] || key === 'description' + ? { ...prev, [key]: attributes[key as keyof SavedSearchAttributes] } + : prev; + }, + {} as SavedSearchAttributes + ); const resp = id ? await contentManagement.update< SavedSearchCrudTypes['UpdateIn'], @@ -35,9 +43,10 @@ export const saveSearchSavedObject = async ( >({ contentTypeId: SAVED_SEARCH_TYPE, id, - data: attributes, + data: definedAttributes, options: { references, + mergeAttributes: false, }, }) : await contentManagement.create< @@ -50,7 +59,6 @@ export const saveSearchSavedObject = async ( references, }, }); - return resp.item.id; }; diff --git a/src/plugins/unified_search/public/query_string_input/query_bar_top_row.tsx b/src/plugins/unified_search/public/query_string_input/query_bar_top_row.tsx index 941040dfd30f8..9bf0e5d3b975b 100644 --- a/src/plugins/unified_search/public/query_string_input/query_bar_top_row.tsx +++ b/src/plugins/unified_search/public/query_string_input/query_bar_top_row.tsx @@ -158,6 +158,8 @@ export interface QueryBarTopRowProps showAddFilter?: boolean; showDatePicker?: boolean; isDisabled?: boolean; + disableQueryInput?: boolean; + disableAutoRefresh?: boolean; showAutoRefreshOnly?: boolean; timeHistory?: TimeHistoryContract; timeRangeForSuggestionsOverride?: boolean; @@ -504,7 +506,7 @@ export const QueryBarTopRow = React.memo( refreshInterval={props.refreshInterval} onTimeChange={onTimeChange} onRefresh={onRefresh} - onRefreshChange={props.onRefreshChange} + onRefreshChange={props.disableAutoRefresh ? undefined : props.onRefreshChange} showUpdateButton={false} recentlyUsedRanges={recentlyUsedRanges} locale={i18n.getLocale()} @@ -700,7 +702,7 @@ export const QueryBarTopRow = React.memo( prepend={renderFilterMenuOnly() && renderFilterButtonGroup()} size={props.suggestionsSize} suggestionsAbstraction={props.suggestionsAbstraction} - isDisabled={props.isDisabled} + isDisabled={props.isDisabled || props.disableQueryInput} appName={appName} submitOnBlur={props.submitOnBlur} deps={{ 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 9415f09515fbc..0c251ca2efed6 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 @@ -43,6 +43,8 @@ export type StatefulSearchBarProps = saveQueryMenuVisibility?: SavedQueryMenuVisibility; onSavedQueryIdChange?: (savedQueryId?: string) => void; onFiltersUpdated?: (filters: Filter[]) => void; + disableQueryInput?: boolean; + disableAutoRefresh?: boolean; }; // Respond to user changing the filters @@ -226,6 +228,8 @@ export function createSearchBar({ showSubmitButton={props.showSubmitButton} submitButtonStyle={props.submitButtonStyle} isDisabled={props.isDisabled} + disableQueryInput={props.disableQueryInput} + disableAutoRefresh={props.disableAutoRefresh} screenTitle={props.screenTitle} indexPatterns={props.indexPatterns} indicateNoData={props.indicateNoData} diff --git a/src/plugins/unified_search/public/search_bar/search_bar.tsx b/src/plugins/unified_search/public/search_bar/search_bar.tsx index a2b8ade82b3f7..2818fadcb1cb8 100644 --- a/src/plugins/unified_search/public/search_bar/search_bar.tsx +++ b/src/plugins/unified_search/public/search_bar/search_bar.tsx @@ -126,6 +126,8 @@ export interface SearchBarOwnProps { * Disables all inputs and interactive elements, */ isDisabled?: boolean; + disableQueryInput?: boolean; + disableAutoRefresh?: boolean; submitOnBlur?: boolean; @@ -615,6 +617,8 @@ class SearchBarUI extends C showAutoRefreshOnly={this.props.showAutoRefreshOnly} showQueryInput={this.props.showQueryInput} showAddFilter={this.props.showFilterBar} + disableQueryInput={this.props.disableQueryInput} + disableAutoRefresh={this.props.disableAutoRefresh} isDisabled={this.props.isDisabled} onRefresh={this.props.onRefresh} onRefreshChange={this.props.onRefreshChange}