From c2926fcbaeee0779da6230f39552a73d2c712661 Mon Sep 17 00:00:00 2001 From: "Qingyang(Abby) Hu" Date: Wed, 21 Jun 2023 13:08:45 -0700 Subject: [PATCH] Add visualization (#4257) Add and save visualization to dashboard Signed-off-by: abbyhu2000 --- package.json | 4 +- .../components/dashboard_top_nav.tsx | 63 ++++--- .../embeddable/dashboard_container.tsx | 1 + .../utils/dashboard_embeddable_editor.tsx | 44 ----- .../application/utils/get_nav_actions.tsx | 10 - .../utils/use/use_dashboard_container.tsx | 178 +++++++++++++++--- .../utils/use/use_editor_updates.ts | 18 +- .../utils/use/use_saved_dashboard_instance.ts | 12 +- src/plugins/dashboard/public/plugin.tsx | 4 +- src/plugins/dashboard/public/types.ts | 2 + 10 files changed, 225 insertions(+), 111 deletions(-) delete mode 100644 src/plugins/dashboard/public/application/utils/dashboard_embeddable_editor.tsx diff --git a/package.json b/package.json index 0ba377a55566..4b3ea979d471 100644 --- a/package.json +++ b/package.json @@ -171,7 +171,6 @@ "dns-sync": "^0.2.1", "elastic-apm-node": "^3.43.0", "elasticsearch": "^16.7.0", - "http-aws-es": "npm:@zhongnansu/http-aws-es@6.0.1", "execa": "^4.0.2", "expiry-js": "0.1.7", "fast-deep-equal": "^3.1.1", @@ -182,6 +181,7 @@ "globby": "^11.1.0", "handlebars": "4.7.7", "hjson": "3.2.1", + "http-aws-es": "npm:@zhongnansu/http-aws-es@6.0.1", "http-proxy-agent": "^2.1.0", "https-proxy-agent": "^5.0.0", "inline-style": "^2.0.0", @@ -290,6 +290,7 @@ "@types/has-ansi": "^3.0.0", "@types/history": "^4.7.3", "@types/hjson": "^2.4.2", + "@types/http-aws-es": "6.0.2", "@types/jest": "^27.4.0", "@types/joi": "^13.4.2", "@types/jquery": "^3.3.31", @@ -342,7 +343,6 @@ "@types/zen-observable": "^0.8.0", "@typescript-eslint/eslint-plugin": "^3.10.0", "@typescript-eslint/parser": "^3.10.0", - "@types/http-aws-es": "6.0.2", "angular-aria": "^1.8.0", "angular-mocks": "^1.8.2", "angular-recursion": "^1.0.5", diff --git a/src/plugins/dashboard/public/application/components/dashboard_top_nav.tsx b/src/plugins/dashboard/public/application/components/dashboard_top_nav.tsx index 6b2e66cacfa3..6ce4e394baf7 100644 --- a/src/plugins/dashboard/public/application/components/dashboard_top_nav.tsx +++ b/src/plugins/dashboard/public/application/components/dashboard_top_nav.tsx @@ -3,19 +3,16 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { uniqBy } from 'lodash'; import React, { memo, useState, useEffect } from 'react'; -import { Filter } from 'src/plugins/data/public'; +import { Filter, IndexPattern } from 'src/plugins/data/public'; import { useCallback } from 'react'; import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; import { getTopNavConfig } from '../top_nav/get_top_nav_config'; -import { - DashboardAppStateContainer, - DashboardAppState, - DashboardServices, - NavAction, -} from '../../types'; +import { DashboardAppStateContainer, DashboardAppState, DashboardServices } from '../../types'; import { getNavActions } from '../utils/get_nav_actions'; import { DashboardContainer } from '../embeddable'; +import { isErrorEmbeddable } from '../../embeddable_plugin'; interface DashboardTopNavProps { isChromeVisible: boolean; @@ -45,12 +42,22 @@ const TopNav = ({ const [filters, setFilters] = useState([]); const [topNavMenu, setTopNavMenu] = useState(); const [isFullScreenMode, setIsFullScreenMode] = useState(); + const [indexPatterns, setIndexPatterns] = useState(); const { services } = useOpenSearchDashboards(); const { TopNavMenu } = services.navigation.ui; const { data, dashboardConfig, setHeaderActionMenu } = services; const { query: queryService } = data; + const handleRefresh = useCallback( + (_payload: any, isUpdate?: boolean) => { + if (!isUpdate && dashboardContainer) { + dashboardContainer.reload(); + } + }, + [dashboardContainer] + ); + // TODO: this should base on URL const isEmbeddedExternally = false; @@ -97,6 +104,33 @@ const TopNav = ({ setIsFullScreenMode(currentAppState?.fullScreenMode); }, [currentAppState, services]); + useEffect(() => { + const asyncSetIndexPattern = async () => { + if (dashboardContainer) { + let panelIndexPatterns: IndexPattern[] = []; + dashboardContainer.getChildIds().forEach((id) => { + const embeddableInstance = dashboardContainer.getChild(id); + if (isErrorEmbeddable(embeddableInstance)) return; + const embeddableIndexPatterns = (embeddableInstance.getOutput() as any).indexPatterns; + if (!embeddableIndexPatterns) return; + panelIndexPatterns.push(...embeddableIndexPatterns); + }); + panelIndexPatterns = uniqBy(panelIndexPatterns, 'id'); + + if (panelIndexPatterns.length > 0) { + setIndexPatterns(panelIndexPatterns); + } else { + const defaultIndex = await services.data.indexPatterns.getDefault(); + if (defaultIndex) { + setIndexPatterns([defaultIndex]); + } + } + } + }; + + asyncSetIndexPattern(); + }, [dashboardContainer, stateContainer, currentAppState, services.data.indexPatterns]); + const shouldShowFilterBar = (forceHide: boolean): boolean => !forceHide && (filters!.length > 0 || !currentAppState?.fullScreenMode); @@ -111,19 +145,6 @@ const TopNav = ({ const showFilterBar = shouldShowFilterBar(forceHideFilterBar); const showSearchBar = showQueryBar || showFilterBar; - // TODO: implement handleRefresh - const handleRefresh = useCallback((_payload: any, isUpdate?: boolean) => { - /* if (isUpdate === false) { - // The user can still request a reload in the query bar, even if the - // query is the same, and in that case, we have to explicitly ask for - // a reload, since no state changes will cause it. - lastReloadRequestTime = new Date().getTime(); - const changes = getChangesFromAppStateForContainerState(); - if (changes && dashboardContainer) { - dashboardContainer.updateInput(changes); - }*/ - }, []); - return isChromeVisible ? ( {}} diff --git a/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx b/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx index ffd50edbe119..8fe5fbd7974d 100644 --- a/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx +++ b/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx @@ -111,6 +111,7 @@ export class DashboardContainer extends Container React.ReactNode); + public getChangesFromAppStateForContainerState?: (containerInput: any) => any; private embeddablePanel: EmbeddableStart['EmbeddablePanel']; diff --git a/src/plugins/dashboard/public/application/utils/dashboard_embeddable_editor.tsx b/src/plugins/dashboard/public/application/utils/dashboard_embeddable_editor.tsx deleted file mode 100644 index 2a39ac6f3717..000000000000 --- a/src/plugins/dashboard/public/application/utils/dashboard_embeddable_editor.tsx +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import React from 'react'; -import { useEffect } from 'react'; - -function DashboardEmbeddableEditor({ - timeRange, - filters, - query, - dom, - savedDashboardInstance, - eventEmitter, - dashboardContainer, -}: any) { - useEffect(() => { - if (!dom) { - return; - } - - dashboardContainer.render(dom); - setTimeout(() => { - eventEmitter.emit('embeddableRendered'); - }); - - return () => dashboardContainer.destroy(); - }, [dashboardContainer, eventEmitter, dom]); - - useEffect(() => { - dashboardContainer.updateInput({ - timeRange, - filters, - query, - }); - }, [dashboardContainer, timeRange, filters, query]); - - return
; -} - -// default export required for React.Lazy -// eslint-disable-next-line import/no-default-export -export { DashboardEmbeddableEditor as default }; diff --git a/src/plugins/dashboard/public/application/utils/get_nav_actions.tsx b/src/plugins/dashboard/public/application/utils/get_nav_actions.tsx index 333d2675388a..d3ae06c4ba6a 100644 --- a/src/plugins/dashboard/public/application/utils/get_nav_actions.tsx +++ b/src/plugins/dashboard/public/application/utils/get_nav_actions.tsx @@ -79,12 +79,10 @@ export const getNavActions = ( const appState = stateContainer.getState(); navActions[TopNavIds.FULL_SCREEN] = () => { stateContainer.transitions.set('fullScreenMode', true); - // updateNavBar(); }; navActions[TopNavIds.EXIT_EDIT_MODE] = () => onChangeViewMode(ViewMode.VIEW); navActions[TopNavIds.ENTER_EDIT_MODE] = () => onChangeViewMode(ViewMode.EDIT); navActions[TopNavIds.SAVE] = () => { - console.log('inside save top nav!'); const currentTitle = appState.title; const currentDescription = appState.description; const currentTimeRestore = appState.timeRestore; @@ -362,12 +360,9 @@ export const getNavActions = ( revertChangesAndExitEditMode(); } }); - - // updateNavBar(); } async function save(saveOptions: SavedObjectSaveOpts) { - console.log('in the save function!'); const timefilter = queryService.timefilter.timefilter; try { const id = await saveDashboard(timefilter, stateContainer, savedDashboard, saveOptions); @@ -381,11 +376,6 @@ export const getNavActions = ( 'data-test-subj': 'saveDashboardSuccess', }); - const appPath = `${createDashboardEditUrl(id)}`; - - // Manually insert a new url so the back button will open the saved visualization. - history.replace(appPath); - // setActiveUrl(appPath); chrome.docTitle.change(savedDashboard.lastSavedTitle); stateContainer.transitions.set('viewMode', ViewMode.VIEW); } diff --git a/src/plugins/dashboard/public/application/utils/use/use_dashboard_container.tsx b/src/plugins/dashboard/public/application/utils/use/use_dashboard_container.tsx index effb081d864b..fbea2e5e61a8 100644 --- a/src/plugins/dashboard/public/application/utils/use/use_dashboard_container.tsx +++ b/src/plugins/dashboard/public/application/utils/use/use_dashboard_container.tsx @@ -4,12 +4,22 @@ */ import React, { useState } from 'react'; -import { EMPTY, Subscription, merge } from 'rxjs'; -import { catchError, distinctUntilChanged, map, mapTo, startWith, switchMap } from 'rxjs/operators'; +import { cloneDeep, isEqual } from 'lodash'; +import { EMPTY, Observable, Subscription, merge, of, pipe } from 'rxjs'; +import { + catchError, + distinctUntilChanged, + filter, + map, + mapTo, + startWith, + switchMap, +} from 'rxjs/operators'; import deepEqual from 'fast-deep-equal'; import { EventEmitter } from 'stream'; import { useEffect } from 'react'; -import { opensearchFilters } from '../../../../../data/public'; +import { i18n } from '@osd/i18n'; +import { IndexPattern, opensearchFilters } from '../../../../../data/public'; import { DASHBOARD_CONTAINER_TYPE, DashboardContainer, @@ -18,13 +28,19 @@ import { } from '../../embeddable'; import { ContainerOutput, + EmbeddableInput, ErrorEmbeddable, ViewMode, isErrorEmbeddable, } from '../../../embeddable_plugin'; -import { convertSavedDashboardPanelToPanelState } from '../../lib/embeddable_saved_object_converters'; +import { + convertPanelStateToSavedDashboardPanel, + convertSavedDashboardPanelToPanelState, +} from '../../lib/embeddable_saved_object_converters'; import { DashboardEmptyScreen, DashboardEmptyScreenProps } from '../../dashboard_empty_screen'; import { DashboardAppStateContainer, DashboardServices, SavedDashboardPanel } from '../../../types'; +import { migrateLegacyQuery } from '../../lib/migrate_legacy_query'; +import { DashboardConstants } from '../../../dashboard_constants'; export const useDashboardContainer = ( services: DashboardServices, @@ -39,30 +55,47 @@ export const useDashboardContainer = ( const getDashboardContainer = async () => { try { if (savedDashboardInstance && appState) { - let dashboardContainerEmbeddable: DashboardContainer | undefined; - try { - dashboardContainerEmbeddable = await createDashboardEmbeddable( - savedDashboardInstance, - services, - appState - ); - } catch (error) { - console.log(error); - } + const dashboardContainerEmbeddable = await createDashboardEmbeddable( + savedDashboardInstance, + services, + appState + ); + setDashboardContainer(dashboardContainerEmbeddable); } } catch (error) { - console.log(error); + services.toastNotifications.addWarning({ + title: i18n.translate('dashboard.createDashboard.failedToLoadErrorMessage', { + defaultMessage: 'Failed to load the dashboard', + }), + }); + services.history.replace(DashboardConstants.LANDING_PAGE_PATH); } }; getDashboardContainer(); - }, [savedDashboardInstance, appState]); + }, [savedDashboardInstance, appState, services]); + + useEffect(() => { + const incomingEmbeddable = services.embeddable + .getStateTransfer(services.scopedHistory()) + .getIncomingEmbeddablePackage(); + + if ( + incomingEmbeddable && + !dashboardContainer?.getInput().panels[incomingEmbeddable.embeddableId!] + ) { + dashboardContainer?.addNewEmbeddable( + incomingEmbeddable.type, + incomingEmbeddable.input + ); + } + }, [dashboardContainer, services]); return { dashboardContainer }; }; -const createDashboardEmbeddable = async ( +const createDashboardEmbeddable = ( savedDash: any, dashboardServices: DashboardServices, appState: DashboardAppStateContainer @@ -141,6 +174,7 @@ const createDashboardEmbeddable = async ( const getDashboardInput = () => { const appStateData = appState.getState(); + const embeddablesMap: { [key: string]: DashboardPanelState; } = {}; @@ -153,7 +187,7 @@ const createDashboardEmbeddable = async ( id: savedDash.id || '', filters: data.query.filterManager.getFilters(), hidePanelTitles: appStateData.options.hidePanelTitles, - query: savedDash.query, + query: data.query.queryString.getQuery(), timeRange: data.query.timefilter.timefilter.getTime(), refreshConfig: data.query.timefilter.timefilter.getRefreshInterval(), viewMode: appStateData.viewMode, @@ -189,6 +223,42 @@ const createDashboardEmbeddable = async ( ) : null; }; + dashboardContainer.getChangesFromAppStateForContainerState = (currentContainer: any) => { + const appStateDashboardInput = getDashboardInput(); + if (!dashboardContainer || isErrorEmbeddable(dashboardContainer)) { + return appStateDashboardInput; + } + + const containerInput = currentContainer.getInput(); + const differences: Partial = {}; + + // Filters shouldn't be compared using regular isEqual + if ( + !opensearchFilters.compareFilters( + containerInput.filters, + appStateDashboardInput.filters, + opensearchFilters.COMPARE_ALL_OPTIONS + ) + ) { + differences.filters = appStateDashboardInput.filters; + } + + Object.keys(containerInput).forEach((key) => { + if (key === 'filters') return; + const containerValue = (containerInput as { [key: string]: unknown })[key]; + const appStateValue = ((appStateDashboardInput as unknown) as { + [key: string]: unknown; + })[key]; + if (!isEqual(containerValue, appStateValue)) { + (differences as { [key: string]: unknown })[key] = appStateValue; + } + }); + + // cloneDeep hack is needed, as there are multiple place, where container's input mutated, + // but values from appStateValue are deeply frozen, as they can't be mutated directly + return Object.values(differences).length === 0 ? undefined : cloneDeep(differences); + }; + // TODO: handle dashboard container input and output subsciptions // issue: outputSubscription = merge( @@ -214,11 +284,10 @@ const createDashboardEmbeddable = async ( .pipe( mapTo(dashboardContainer), startWith(dashboardContainer) // to trigger initial index pattern update - // updateIndexPatternsOperator //TODO ) .subscribe(); - inputSubscription = dashboardContainer.getInput$().subscribe((foo) => { + inputSubscription = dashboardContainer.getInput$().subscribe(() => { // This has to be first because handleDashboardContainerChanges causes // appState.save which will cause refreshDashboardContainer to be called. @@ -230,17 +299,18 @@ const createDashboardEmbeddable = async ( ) ) { // Add filters modifies the object passed to it, hence the clone deep. - filterManager.addFilters(_.cloneDeep(container.getInput().filters)); + filterManager.addFilters(cloneDeep(container.getInput().filters)); + // TODO: investigate if this is needed /* dashboardStateManager.applyFilters( $scope.model.query, container.getInput().filters - );*/ + );*/ appState.transitions.set('query', queryStringManager.getQuery()); } - // TODO: triggered when dashboard embeddable container has changes, and update the appState - // handleDashboardContainerChanges(container, appState, dashboardServices); + // triggered when dashboard embeddable container has changes, and update the appState + handleDashboardContainerChanges(container, appState, dashboardServices); }); return dashboardContainer; } @@ -248,3 +318,63 @@ const createDashboardEmbeddable = async ( } return undefined; }; + +const handleDashboardContainerChanges = ( + dashboardContainer: DashboardContainer, + appState: DashboardAppStateContainer, + dashboardServices: DashboardServices +) => { + let dirty = false; + let dirtyBecauseOfInitialStateMigration = false; + const appStateData = appState.getState(); + const savedDashboardPanelMap: { [key: string]: SavedDashboardPanel } = {}; + const { opensearchDashboardsVersion } = dashboardServices; + const input = dashboardContainer.getInput(); + appStateData.panels.forEach((savedDashboardPanel) => { + if (input.panels[savedDashboardPanel.panelIndex] !== undefined) { + savedDashboardPanelMap[savedDashboardPanel.panelIndex] = savedDashboardPanel; + } else { + // A panel was deleted. + dirty = true; + } + }); + const convertedPanelStateMap: { [key: string]: SavedDashboardPanel } = {}; + Object.values(input.panels).forEach((panelState) => { + if (savedDashboardPanelMap[panelState.explicitInput.id] === undefined) { + dirty = true; + } + convertedPanelStateMap[panelState.explicitInput.id] = convertPanelStateToSavedDashboardPanel( + panelState, + opensearchDashboardsVersion + ); + if ( + !isEqual( + convertedPanelStateMap[panelState.explicitInput.id], + savedDashboardPanelMap[panelState.explicitInput.id] + ) + ) { + // A panel was changed + dirty = true; + const oldVersion = savedDashboardPanelMap[panelState.explicitInput.id]?.version; + const newVersion = convertedPanelStateMap[panelState.explicitInput.id]?.version; + if (oldVersion && newVersion && oldVersion !== newVersion) { + dirtyBecauseOfInitialStateMigration = true; + } + } + }); + if (dirty) { + appState.transitions.set('panels', Object.values(convertedPanelStateMap)); + if (dirtyBecauseOfInitialStateMigration) { + // this.saveState({ replace: true }); + } + } + if (input.isFullScreenMode !== appStateData.fullScreenMode) { + appState.transitions.set('fullScreenMode', input.isFullScreenMode); + } + if (input.expandedPanelId !== appStateData.expandedPanelId) { + appState.transitions.set('expandedPanelId', input.expandedPanelId); + } + if (!isEqual(input.query, migrateLegacyQuery(appState.get().query))) { + appState.transitions.set('query', input.query); + } +}; diff --git a/src/plugins/dashboard/public/application/utils/use/use_editor_updates.ts b/src/plugins/dashboard/public/application/utils/use/use_editor_updates.ts index 60dfb9ba927a..11dd0e4a45ac 100644 --- a/src/plugins/dashboard/public/application/utils/use/use_editor_updates.ts +++ b/src/plugins/dashboard/public/application/utils/use/use_editor_updates.ts @@ -17,7 +17,7 @@ export const useEditorUpdates = ( ) => { const [isEmbeddableRendered, setIsEmbeddableRendered] = useState(false); const [currentAppState, setCurrentAppState] = useState(); - const dom = document.getElementById('dashboardViewport'); + const dashboardDom = document.getElementById('dashboardViewport'); const { timefilter: { timefilter }, @@ -33,7 +33,14 @@ export const useEditorUpdates = ( const unsubscribeStateUpdates = appState.subscribe((state) => { setCurrentAppState(state); - dashboardContainer.reload(); + if (dashboardContainer.getChangesFromAppStateForContainerState) { + const changes = dashboardContainer.getChangesFromAppStateForContainerState( + dashboardContainer + ); + if (changes) { + dashboardContainer.updateInput(changes); + } + } }); return () => { @@ -47,20 +54,19 @@ export const useEditorUpdates = ( services, dashboardContainer, isEmbeddableRendered, - currentAppState, ]); useEffect(() => { - if (!dom || !dashboardContainer) { + if (!dashboardDom || !dashboardContainer) { return; } - dashboardContainer.render(dom); + dashboardContainer.render(dashboardDom); setIsEmbeddableRendered(true); return () => { setIsEmbeddableRendered(false); }; - }, [appState, dashboardInstance, currentAppState, dashboardContainer, state$, dom]); + }, [dashboardContainer, dashboardDom]); return { isEmbeddableRendered, currentAppState }; }; diff --git a/src/plugins/dashboard/public/application/utils/use/use_saved_dashboard_instance.ts b/src/plugins/dashboard/public/application/utils/use/use_saved_dashboard_instance.ts index d8336d74fc1f..e24527faf242 100644 --- a/src/plugins/dashboard/public/application/utils/use/use_saved_dashboard_instance.ts +++ b/src/plugins/dashboard/public/application/utils/use/use_saved_dashboard_instance.ts @@ -34,11 +34,11 @@ export const useSavedDashboardInstance = ( http: { basePath }, notifications, savedDashboards, + toastNotifications, } = services; const getSavedDashboardInstance = async () => { try { - console.log('trying to get saved dashboard'); let savedDashboard: any; if (history.location.pathname === '/create') { try { @@ -62,7 +62,6 @@ export const useSavedDashboardInstance = ( savedDashboard.title, dashboardIdFromUrl ); - console.log('saved dashboard', savedDashboard); } catch (error) { // Preserve BWC of v5.3.0 links for new, unsaved dashboards. // See https://github.com/elastic/kibana/issues/10951 for more context. @@ -90,7 +89,14 @@ export const useSavedDashboardInstance = ( } setSavedDashboardInstance(savedDashboard); - } catch (error) {} + } catch (error) { + toastNotifications.addWarning({ + title: i18n.translate('dashboard.createDashboard.failedToLoadErrorMessage', { + defaultMessage: 'Failed to load the dashboard', + }), + }); + history.replace(DashboardConstants.LANDING_PAGE_PATH); + } }; if (isChromeVisible === undefined) { diff --git a/src/plugins/dashboard/public/plugin.tsx b/src/plugins/dashboard/public/plugin.tsx index 6cf18c0fcab9..32ccf5475705 100644 --- a/src/plugins/dashboard/public/plugin.tsx +++ b/src/plugins/dashboard/public/plugin.tsx @@ -422,6 +422,7 @@ export class DashboardPlugin setHeaderActionMenu: params.setHeaderActionMenu, savedObjectsPublic: savedObjects, restorePreviousUrl, + toastNotifications: coreStart.notifications.toasts, }; // make sure the index pattern list is up to date await dataStart.indexPatterns.clearCache(); @@ -435,7 +436,8 @@ export class DashboardPlugin }, }; - initAngularBootstrap(); + // TODO: need to add UI bootstrap + // initAngularBootstrap(); core.application.register(app); urlForwarding.forwardApp( diff --git a/src/plugins/dashboard/public/types.ts b/src/plugins/dashboard/public/types.ts index cb4770c2a001..8d95fe25f921 100644 --- a/src/plugins/dashboard/public/types.ts +++ b/src/plugins/dashboard/public/types.ts @@ -39,6 +39,7 @@ import { ChromeStart, ScopedHistory, AppMountParameters, + ToastsStart, } from 'src/core/public'; import { IOsdUrlStateStorage, @@ -274,4 +275,5 @@ export interface DashboardServices extends CoreStart { savedObjectsPublic: SavedObjectsStart; restorePreviousUrl: () => void; addBasePath?: (url: string) => string; + toastNotifications: ToastsStart; }