From 75e307a45d36def060309d33476c12d269baba1b Mon Sep 17 00:00:00 2001 From: "Qingyang(Abby) Hu" Date: Tue, 20 Jun 2023 16:40:23 -0700 Subject: [PATCH] Render editor page with basic nav actions (#4213) Added dashboard embeddable container to render the dashboard editor page. Signed-off-by: abbyhu2000 --- .../dashboard/public/application/app.tsx | 5 +- .../components/dashboard_editor.tsx | 216 ++------- .../components/dashboard_top_nav.tsx | 66 ++- .../public/application/lib/save_dashboard.ts | 22 +- .../application/lib/update_saved_dashboard.ts | 7 +- .../utils/dashboard_embeddable_editor.tsx | 44 ++ .../application/utils/get_nav_actions.tsx | 411 ++++++++++++++++++ .../utils/use/use_dashboard_app_state.tsx | 97 ++--- .../utils/use/use_dashboard_container.tsx | 250 +++++++++++ .../utils/use/use_editor_updates.ts | 66 +++ .../utils/use/use_saved_dashboard_instance.ts | 46 +- src/plugins/dashboard/public/types.ts | 1 + 12 files changed, 945 insertions(+), 286 deletions(-) create mode 100644 src/plugins/dashboard/public/application/utils/dashboard_embeddable_editor.tsx create mode 100644 src/plugins/dashboard/public/application/utils/get_nav_actions.tsx create mode 100644 src/plugins/dashboard/public/application/utils/use/use_dashboard_container.tsx create mode 100644 src/plugins/dashboard/public/application/utils/use/use_editor_updates.ts diff --git a/src/plugins/dashboard/public/application/app.tsx b/src/plugins/dashboard/public/application/app.tsx index 439806bb8de3..710908233d98 100644 --- a/src/plugins/dashboard/public/application/app.tsx +++ b/src/plugins/dashboard/public/application/app.tsx @@ -29,7 +29,10 @@ export const DashboardApp = ({ onAppLeave }: DashboardAppProps) => { exact path={[DashboardConstants.CREATE_NEW_DASHBOARD_URL, createDashboardEditUrl(':id')]} > - +
+ +
+
diff --git a/src/plugins/dashboard/public/application/components/dashboard_editor.tsx b/src/plugins/dashboard/public/application/components/dashboard_editor.tsx index 2be15917e940..a70f5224ec1b 100644 --- a/src/plugins/dashboard/public/application/components/dashboard_editor.tsx +++ b/src/plugins/dashboard/public/application/components/dashboard_editor.tsx @@ -6,42 +6,22 @@ import React, { useEffect, useState } from 'react'; import { useParams } from 'react-router-dom'; import { EventEmitter } from 'events'; -import { EMPTY, Subscription, merge } from 'rxjs'; -import { catchError, distinctUntilChanged, map, mapTo, startWith, switchMap } from 'rxjs/operators'; -import deepEqual from 'fast-deep-equal'; import { DashboardTopNav } from '../components/dashboard_top_nav'; import { useChromeVisibility } from '../utils/use/use_chrome_visibility'; import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; import { useSavedDashboardInstance } from '../utils/use/use_saved_dashboard_instance'; - -import { DashboardServices, SavedDashboardPanel } from '../../types'; -import { - DASHBOARD_CONTAINER_TYPE, - DashboardContainer, - DashboardContainerInput, - DashboardPanelState, -} from '../embeddable'; -import { - ContainerOutput, - ErrorEmbeddable, - ViewMode, - isErrorEmbeddable, -} from '../../embeddable_plugin'; -import { DashboardEmptyScreen, DashboardEmptyScreenProps } from '../dashboard_empty_screen'; -import { convertSavedDashboardPanelToPanelState } from '../lib/embeddable_saved_object_converters'; +import { DashboardServices } from '../../types'; import { useDashboardAppState } from '../utils/use/use_dashboard_app_state'; +import { useDashboardContainer } from '../utils/use/use_dashboard_container'; +import { useEditorUpdates } from '../utils/use/use_editor_updates'; export const DashboardEditor = () => { const { id: dashboardIdFromUrl } = useParams<{ id: string }>(); const { services } = useOpenSearchDashboards(); - const { embeddable, data, dashboardConfig, embeddableCapabilities, uiSettings, http } = services; - const { query: queryService } = data; - const { visualizeCapabilities, mapsCapabilities } = embeddableCapabilities; - const timefilter = queryService.timefilter.timefilter; const isChromeVisible = useChromeVisibility(services.chrome); const [eventEmitter] = useState(new EventEmitter()); - const { savedDashboardInstance } = useSavedDashboardInstance( + const savedDashboardInstance = useSavedDashboardInstance( services, eventEmitter, isChromeVisible, @@ -50,165 +30,49 @@ export const DashboardEditor = () => { const { appState } = useDashboardAppState(services, eventEmitter, savedDashboardInstance); - const appStateData = appState?.get(); - if (!appStateData) { - return null; - } - let dashboardContainer: DashboardContainer | undefined; - let inputSubscription: Subscription | undefined; - let outputSubscription: Subscription | undefined; - - const dashboardDom = document.getElementById('dashboardViewport'); - const dashboardFactory = embeddable.getEmbeddableFactory< - DashboardContainerInput, - ContainerOutput, - DashboardContainer - >(DASHBOARD_CONTAINER_TYPE); - - const getShouldShowEditHelp = () => { - return ( - !appStateData.panels.length && - appStateData.viewMode === ViewMode.EDIT && - !dashboardConfig.getHideWriteControls() - ); - }; - - const getShouldShowViewHelp = () => { - return ( - !appStateData.panels.length && - appStateData.viewMode === ViewMode.VIEW && - !dashboardConfig.getHideWriteControls() - ); - }; - - const shouldShowUnauthorizedEmptyState = () => { - const readonlyMode = - !appStateData.panels.length && - !getShouldShowEditHelp() && - !getShouldShowViewHelp() && - dashboardConfig.getHideWriteControls(); - const userHasNoPermissions = - !appStateData.panels.length && !visualizeCapabilities.save && !mapsCapabilities.save; - return readonlyMode || userHasNoPermissions; - }; - - const getEmptyScreenProps = ( - shouldShowEditHelp: boolean, - isEmptyInReadOnlyMode: boolean - ): DashboardEmptyScreenProps => { - const emptyScreenProps: DashboardEmptyScreenProps = { - onLinkClick: () => {}, // TODO - showLinkToVisualize: shouldShowEditHelp, - uiSettings, - http, - }; - if (shouldShowEditHelp) { - emptyScreenProps.onVisualizeClick = () => { - alert('click'); // TODO - }; - } - if (isEmptyInReadOnlyMode) { - emptyScreenProps.isReadonlyMode = true; - } - return emptyScreenProps; - }; + const { dashboardContainer } = useDashboardContainer( + services, + isChromeVisible, + eventEmitter, + savedDashboardInstance, + appState + ); - const getDashboardInput = () => { - const embeddablesMap: { - [key: string]: DashboardPanelState; - } = {}; - appStateData.panels.forEach((panel: SavedDashboardPanel) => { - embeddablesMap[panel.panelIndex] = convertSavedDashboardPanelToPanelState(panel); - }); + const { isEmbeddableRendered, currentAppState } = useEditorUpdates( + services, + eventEmitter, + savedDashboardInstance, + dashboardContainer, + appState + ); - const lastReloadRequestTime = 0; - return { - id: savedDashboardInstance.id || '', - filters: appStateData.filters, - hidePanelTitles: appStateData?.options.hidePanelTitles, - query: appStateData.query, - timeRange: { - ..._.cloneDeep(timefilter.getTime()), - }, - refreshConfig: timefilter.getRefreshInterval(), - viewMode: appStateData.viewMode, - panels: embeddablesMap, - isFullScreenMode: appStateData?.fullScreenMode, - isEmbeddedExternally: false, // TODO - // isEmptyState: shouldShowEditHelp || shouldShowViewHelp || isEmptyInReadonlyMode, - isEmptyState: false, // TODO - useMargins: appStateData.options.useMargins, - lastReloadRequestTime, // TODO - title: appStateData.title, - description: appStateData.description, - expandedPanelId: appStateData.expandedPanelId, + useEffect(() => { + // clean up all registered listeners if any is left + return () => { + eventEmitter.removeAllListeners(); }; - }; - - if (dashboardFactory) { - dashboardFactory - .create(getDashboardInput()) - .then((container: DashboardContainer | ErrorEmbeddable | undefined) => { - if (container && !isErrorEmbeddable(container)) { - dashboardContainer = container; - - dashboardContainer.renderEmpty = () => { - const shouldShowEditHelp = getShouldShowEditHelp(); - const shouldShowViewHelp = getShouldShowViewHelp(); - const isEmptyInReadOnlyMode = shouldShowUnauthorizedEmptyState(); - const isEmptyState = shouldShowEditHelp || shouldShowViewHelp || isEmptyInReadOnlyMode; - return isEmptyState ? ( - - ) : null; - }; - - outputSubscription = merge( - // output of dashboard container itself - dashboardContainer.getOutput$(), - // plus output of dashboard container children, - // children may change, so make sure we subscribe/unsubscribe with switchMap - dashboardContainer.getOutput$().pipe( - map(() => dashboardContainer!.getChildIds()), - distinctUntilChanged(deepEqual), - switchMap((newChildIds: string[]) => - merge( - ...newChildIds.map((childId) => - dashboardContainer! - .getChild(childId) - .getOutput$() - .pipe(catchError(() => EMPTY)) - ) - ) - ) - ) - ) - .pipe( - mapTo(dashboardContainer), - startWith(dashboardContainer) // to trigger initial index pattern update - // updateIndexPatternsOperator //TODO - ) - .subscribe(); - - inputSubscription = dashboardContainer.getInput$().subscribe(() => {}); + }, [eventEmitter]); - if (dashboardDom && container) { - container.render(dashboardDom); - } - } - }); - } + console.log('savedDashboardInstance', savedDashboardInstance); + console.log('appState', appState); + console.log('currentAppState', currentAppState); + console.log('isEmbeddableRendered', isEmbeddableRendered); + console.log('dashboardContainer', dashboardContainer); return (
- {savedDashboardInstance && appState && ( - - )} +
+ {savedDashboardInstance && appState && dashboardContainer && currentAppState && ( + + )} +
); }; 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 4ee92567c590..6b2e66cacfa3 100644 --- a/src/plugins/dashboard/public/application/components/dashboard_top_nav.tsx +++ b/src/plugins/dashboard/public/application/components/dashboard_top_nav.tsx @@ -8,12 +8,22 @@ import { Filter } 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 { DashboardAppState, DashboardServices, NavAction } from '../../types'; +import { + DashboardAppStateContainer, + DashboardAppState, + DashboardServices, + NavAction, +} from '../../types'; +import { getNavActions } from '../utils/get_nav_actions'; +import { DashboardContainer } from '../embeddable'; interface DashboardTopNavProps { isChromeVisible: boolean; savedDashboardInstance: any; - appState: DashboardAppState; + stateContainer: DashboardAppStateContainer; + currentAppState: DashboardAppState; + isEmbeddableRendered: boolean; + dashboardContainer?: DashboardContainer; } enum UrlParams { @@ -24,7 +34,14 @@ enum UrlParams { HIDE_FILTER_BAR = 'hide-filter-bar', } -const TopNav = ({ isChromeVisible, savedDashboardInstance, appState }: DashboardTopNavProps) => { +const TopNav = ({ + isChromeVisible, + savedDashboardInstance, + stateContainer, + currentAppState, + isEmbeddableRendered, + dashboardContainer, +}: DashboardTopNavProps) => { const [filters, setFilters] = useState([]); const [topNavMenu, setTopNavMenu] = useState(); const [isFullScreenMode, setIsFullScreenMode] = useState(); @@ -44,27 +61,44 @@ const TopNav = ({ isChromeVisible, savedDashboardInstance, appState }: Dashboard }; const shouldShowNavBarComponent = (forceShow: boolean): boolean => - (forceShow || isChromeVisible) && !appState?.fullScreenMode; + (forceShow || isChromeVisible) && !currentAppState?.fullScreenMode; useEffect(() => { setFilters(queryService.filterManager.getFilters()); }, [services, queryService]); useEffect(() => { - const navActions: { - [key: string]: NavAction; - } = {}; // TODO: need to implement nav actions - setTopNavMenu( - getTopNavConfig(appState?.viewMode, navActions, dashboardConfig.getHideWriteControls()) - ); - }, [appState, services, dashboardConfig]); + if (isEmbeddableRendered) { + const navActions = getNavActions( + stateContainer, + savedDashboardInstance, + services, + dashboardContainer + ); + setTopNavMenu( + getTopNavConfig( + currentAppState?.viewMode, + navActions, + dashboardConfig.getHideWriteControls() + ) + ); + } + }, [ + currentAppState, + services, + dashboardConfig, + dashboardContainer, + savedDashboardInstance, + stateContainer, + isEmbeddableRendered, + ]); useEffect(() => { - setIsFullScreenMode(appState?.fullScreenMode); - }, [appState, services]); + setIsFullScreenMode(currentAppState?.fullScreenMode); + }, [currentAppState, services]); const shouldShowFilterBar = (forceHide: boolean): boolean => - !forceHide && (filters!.length > 0 || !appState?.fullScreenMode); + !forceHide && (filters!.length > 0 || !currentAppState?.fullScreenMode); const forceShowTopNavMenu = shouldForceDisplay(UrlParams.SHOW_TOP_MENU); const forceShowQueryInput = shouldForceDisplay(UrlParams.SHOW_QUERY_INPUT); @@ -93,10 +127,10 @@ const TopNav = ({ isChromeVisible, savedDashboardInstance, appState }: Dashboard return isChromeVisible ? ( string, timeFilter: TimefilterContract, - dashboardStateManager: DashboardStateManager, + stateContainer: DashboardAppStateContainer, + savedDashboard: any, saveOptions: SavedObjectSaveOpts ): Promise { - const savedDashboard = dashboardStateManager.savedDashboard; - const appState = dashboardStateManager.appState; + const appState = stateContainer.getState(); - updateSavedDashboard(savedDashboard, appState, timeFilter, toJson); + updateSavedDashboard(savedDashboard, appState, timeFilter); return savedDashboard.save(saveOptions).then((id: string) => { if (id) { - // reset state only when save() was successful - // e.g. save() could be interrupted if title is duplicated and not confirmed - dashboardStateManager.lastSavedDashboardFilters = dashboardStateManager.getFilterState(); - dashboardStateManager.resetState(); + return id; } - - return id; }); } diff --git a/src/plugins/dashboard/public/application/lib/update_saved_dashboard.ts b/src/plugins/dashboard/public/application/lib/update_saved_dashboard.ts index 0a52e8fbb94f..64ed0e86a6fd 100644 --- a/src/plugins/dashboard/public/application/lib/update_saved_dashboard.ts +++ b/src/plugins/dashboard/public/application/lib/update_saved_dashboard.ts @@ -38,14 +38,13 @@ import { opensearchFilters } from '../../../../data/public'; export function updateSavedDashboard( savedDashboard: SavedObjectDashboard, appState: DashboardAppState, - timeFilter: TimefilterContract, - toJson: (object: T) => string + timeFilter: TimefilterContract ) { savedDashboard.title = appState.title; savedDashboard.description = appState.description; savedDashboard.timeRestore = appState.timeRestore; - savedDashboard.panelsJSON = toJson(appState.panels); - savedDashboard.optionsJSON = toJson(appState.options); + savedDashboard.panelsJSON = JSON.stringify(appState.panels); + savedDashboard.optionsJSON = JSON.stringify(appState.options); savedDashboard.timeFrom = savedDashboard.timeRestore ? FilterUtils.convertTimeToUTCString(timeFilter.getTime().from) diff --git a/src/plugins/dashboard/public/application/utils/dashboard_embeddable_editor.tsx b/src/plugins/dashboard/public/application/utils/dashboard_embeddable_editor.tsx new file mode 100644 index 000000000000..2a39ac6f3717 --- /dev/null +++ b/src/plugins/dashboard/public/application/utils/dashboard_embeddable_editor.tsx @@ -0,0 +1,44 @@ +/* + * 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 new file mode 100644 index 000000000000..333d2675388a --- /dev/null +++ b/src/plugins/dashboard/public/application/utils/get_nav_actions.tsx @@ -0,0 +1,411 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { ReactElement, useState } from 'react'; +import { i18n } from '@osd/i18n'; +import { EUI_MODAL_CANCEL_BUTTON, EuiCheckboxGroup } from '@elastic/eui'; +import { EuiCheckboxGroupIdToSelectedMap } from '@elastic/eui/src/components/form/checkbox/checkbox_group'; +import { + SaveResult, + SavedObjectSaveOpts, + getSavedObjectFinder, + showSaveModal, +} from '../../../../saved_objects/public'; +import { DashboardAppStateContainer, DashboardServices, NavAction } from '../../types'; +import { DashboardSaveModal } from '../top_nav/save_modal'; +import { TopNavIds } from '../top_nav/top_nav_ids'; +import { + EmbeddableFactoryNotFoundError, + EmbeddableInput, + ViewMode, + isErrorEmbeddable, + openAddPanelFlyout, +} from '../../embeddable_plugin'; +import { showCloneModal } from '../top_nav/show_clone_modal'; +import { showOptionsPopover } from '../top_nav/show_options_popover'; +import { saveDashboard } from '../lib'; +import { DashboardContainer } from '../embeddable/dashboard_container'; +import { DashboardConstants, createDashboardEditUrl } from '../../dashboard_constants'; +import { unhashUrl } from '../../../../opensearch_dashboards_utils/public'; + +enum UrlParams { + SHOW_TOP_MENU = 'show-top-menu', + SHOW_QUERY_INPUT = 'show-query-input', + SHOW_TIME_FILTER = 'show-time-filter', + SHOW_FILTER_BAR = 'show-filter-bar', + HIDE_FILTER_BAR = 'hide-filter-bar', +} + +interface UrlParamsSelectedMap { + [UrlParams.SHOW_TOP_MENU]: boolean; + [UrlParams.SHOW_QUERY_INPUT]: boolean; + [UrlParams.SHOW_TIME_FILTER]: boolean; + [UrlParams.SHOW_FILTER_BAR]: boolean; +} + +interface UrlParamValues extends Omit { + [UrlParams.HIDE_FILTER_BAR]: boolean; +} + +export const getNavActions = ( + stateContainer: DashboardAppStateContainer, + savedDashboard: any, + services: DashboardServices, + dashboardContainer?: DashboardContainer +) => { + const { + history, + embeddable, + data: { query: queryService }, + notifications, + overlays, + i18n: { Context: I18nContext }, + savedObjects, + uiSettings, + chrome, + share, + dashboardConfig, + dashboardCapabilities, + } = services; + const navActions: { + [key: string]: NavAction; + } = {}; + + if (!stateContainer) { + return navActions; + } + 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; + const onSave = ({ + newTitle, + newDescription, + newCopyOnSave, + newTimeRestore, + isTitleDuplicateConfirmed, + onTitleDuplicate, + }: { + newTitle: string; + newDescription: string; + newCopyOnSave: boolean; + newTimeRestore: boolean; + isTitleDuplicateConfirmed: boolean; + onTitleDuplicate: () => void; + }) => { + stateContainer.transitions.set('title', newTitle); + stateContainer.transitions.set('description', newDescription); + stateContainer.transitions.set('timeRestore', newTimeRestore); + // dashboardStateManager.savedDashboard.copyOnSave = newCopyOnSave; + + const saveOptions = { + confirmOverwrite: false, + isTitleDuplicateConfirmed, + onTitleDuplicate, + }; + return save(saveOptions).then((response: SaveResult) => { + // If the save wasn't successful, put the original values back. + if (!(response as { id: string }).id) { + stateContainer.transitions.set('title', currentTitle); + stateContainer.transitions.set('description', currentDescription); + stateContainer.transitions.set('timeRestore', currentTimeRestore); + } + return response; + }); + }; + + const dashboardSaveModal = ( + {}} + title={currentTitle} + description={currentDescription} + timeRestore={currentTimeRestore} + showCopyOnSave={savedDashboard.id ? true : false} + /> + ); + showSaveModal(dashboardSaveModal, I18nContext); + }; + navActions[TopNavIds.CLONE] = () => { + const currentTitle = appState.title; + const onClone = ( + newTitle: string, + isTitleDuplicateConfirmed: boolean, + onTitleDuplicate: () => void + ) => { + savedDashboard.copyOnSave = true; + stateContainer.transitions.set('title', newTitle); + const saveOptions = { + confirmOverwrite: false, + isTitleDuplicateConfirmed, + onTitleDuplicate, + }; + return save(saveOptions).then((response: { id?: string } | { error: Error }) => { + // If the save wasn't successful, put the original title back. + if ((response as { error: Error }).error) { + stateContainer.transitions.set('title', currentTitle); + } + // updateNavBar(); + return response; + }); + }; + + showCloneModal(onClone, currentTitle); + }; + + navActions[TopNavIds.ADD_EXISTING] = () => { + if (dashboardContainer && !isErrorEmbeddable(dashboardContainer)) { + openAddPanelFlyout({ + embeddable: dashboardContainer, + getAllFactories: embeddable.getEmbeddableFactories, + getFactory: embeddable.getEmbeddableFactory, + notifications, + overlays, + SavedObjectFinder: getSavedObjectFinder(savedObjects, uiSettings), + }); + } + }; + + navActions[TopNavIds.VISUALIZE] = async () => { + const type = 'visualization'; + const factory = embeddable.getEmbeddableFactory(type); + if (!factory) { + throw new EmbeddableFactoryNotFoundError(type); + } + await factory.create({} as EmbeddableInput, dashboardContainer); + }; + + navActions[TopNavIds.OPTIONS] = (anchorElement) => { + showOptionsPopover({ + anchorElement, + useMargins: appState.options.useMargins === undefined ? false : appState.options.useMargins, + onUseMarginsChange: (isChecked: boolean) => { + stateContainer.transitions.setOption('useMargins', isChecked); + }, + hidePanelTitles: appState.options.hidePanelTitles, + onHidePanelTitlesChange: (isChecked: boolean) => { + stateContainer.transitions.setOption('hidePanelTitles', isChecked); + }, + }); + }; + + if (share) { + // the share button is only availabale if "share" plugin contract enabled + navActions[TopNavIds.SHARE] = (anchorElement) => { + const EmbedUrlParamExtension = ({ + setParamValue, + }: { + setParamValue: (paramUpdate: UrlParamValues) => void; + }): ReactElement => { + const [urlParamsSelectedMap, setUrlParamsSelectedMap] = useState({ + [UrlParams.SHOW_TOP_MENU]: false, + [UrlParams.SHOW_QUERY_INPUT]: false, + [UrlParams.SHOW_TIME_FILTER]: false, + [UrlParams.SHOW_FILTER_BAR]: true, + }); + + const checkboxes = [ + { + id: UrlParams.SHOW_TOP_MENU, + label: i18n.translate('dashboard.embedUrlParamExtension.topMenu', { + defaultMessage: 'Top menu', + }), + }, + { + id: UrlParams.SHOW_QUERY_INPUT, + label: i18n.translate('dashboard.embedUrlParamExtension.query', { + defaultMessage: 'Query', + }), + }, + { + id: UrlParams.SHOW_TIME_FILTER, + label: i18n.translate('dashboard.embedUrlParamExtension.timeFilter', { + defaultMessage: 'Time filter', + }), + }, + { + id: UrlParams.SHOW_FILTER_BAR, + label: i18n.translate('dashboard.embedUrlParamExtension.filterBar', { + defaultMessage: 'Filter bar', + }), + }, + ]; + + const handleChange = (param: string): void => { + const urlParamsSelectedMapUpdate = { + ...urlParamsSelectedMap, + [param]: !urlParamsSelectedMap[param as keyof UrlParamsSelectedMap], + }; + setUrlParamsSelectedMap(urlParamsSelectedMapUpdate); + + const urlParamValues = { + [UrlParams.SHOW_TOP_MENU]: urlParamsSelectedMap[UrlParams.SHOW_TOP_MENU], + [UrlParams.SHOW_QUERY_INPUT]: urlParamsSelectedMap[UrlParams.SHOW_QUERY_INPUT], + [UrlParams.SHOW_TIME_FILTER]: urlParamsSelectedMap[UrlParams.SHOW_TIME_FILTER], + [UrlParams.HIDE_FILTER_BAR]: !urlParamsSelectedMap[UrlParams.SHOW_FILTER_BAR], + [param === UrlParams.SHOW_FILTER_BAR ? UrlParams.HIDE_FILTER_BAR : param]: + param === UrlParams.SHOW_FILTER_BAR + ? urlParamsSelectedMap[UrlParams.SHOW_FILTER_BAR] + : !urlParamsSelectedMap[param as keyof UrlParamsSelectedMap], + }; + setParamValue(urlParamValues); + }; + + return ( + + ); + }; + + share.toggleShareContextMenu({ + anchorElement, + allowEmbed: true, + allowShortUrl: + !dashboardConfig.getHideWriteControls() || dashboardCapabilities.createShortUrl, + shareableUrl: unhashUrl(window.location.href), + objectId: savedDashboard.id, + objectType: 'dashboard', + sharingData: { + title: savedDashboard.title, + }, + isDirty: false, // TODO + embedUrlParamExtensions: [ + { + paramName: 'embed', + component: EmbedUrlParamExtension, + }, + ], + }); + }; + } + + function onChangeViewMode(newMode: ViewMode) { + const isPageRefresh = newMode === appState.viewMode; + const isLeavingEditMode = !isPageRefresh && newMode === ViewMode.VIEW; + // TODO: check if any query and filter changed + const willLoseChanges = isLeavingEditMode; + + if (!willLoseChanges) { + stateContainer.transitions.set('viewMode', newMode); + return; + } + + function revertChangesAndExitEditMode() { + stateContainer.transitions.set('viewMode', ViewMode.VIEW); + const pathname = savedDashboard.id + ? createDashboardEditUrl(savedDashboard.id) + : DashboardConstants.CREATE_NEW_DASHBOARD_URL; + history.push(pathname); + + /* dashboardStateManager.resetState(); + // This is only necessary for new dashboards, which will default to Edit mode. + updateViewMode(ViewMode.VIEW); + + // We need to do a hard reset of the timepicker. appState will not reload like + // it does on 'open' because it's been saved to the url and the getAppState.previouslyStored() check on + // reload will cause it not to sync. + if (dashboardStateManager.getIsTimeSavedWithDashboard()) { + dashboardStateManager.syncTimefilterWithDashboardTime(timefilter); + dashboardStateManager.syncTimefilterWithDashboardRefreshInterval(timefilter); + } + + // Angular's $location skips this update because of history updates from syncState which happen simultaneously + // when calling osdUrl.change() angular schedules url update and when angular finally starts to process it, + // the update is considered outdated and angular skips it + // so have to use implementation of dashboardStateManager.changeDashboardUrl, which workarounds those issues + dashboardStateManager.changeDashboardUrl( + dash.id ? createDashboardEditUrl(dash.id) : DashboardConstants.CREATE_NEW_DASHBOARD_URL + );*/ + } + + overlays + .openConfirm( + i18n.translate('dashboard.changeViewModeConfirmModal.discardChangesDescription', { + defaultMessage: `Once you discard your changes, there's no getting them back.`, + }), + { + confirmButtonText: i18n.translate( + 'dashboard.changeViewModeConfirmModal.confirmButtonLabel', + { defaultMessage: 'Discard changes' } + ), + cancelButtonText: i18n.translate( + 'dashboard.changeViewModeConfirmModal.cancelButtonLabel', + { defaultMessage: 'Continue editing' } + ), + defaultFocusedButton: EUI_MODAL_CANCEL_BUTTON, + title: i18n.translate('dashboard.changeViewModeConfirmModal.discardChangesTitle', { + defaultMessage: 'Discard changes to dashboard?', + }), + } + ) + .then((isConfirmed) => { + if (isConfirmed) { + 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); + + if (id) { + notifications.toasts.addSuccess({ + title: i18n.translate('dashboard.dashboardWasSavedSuccessMessage', { + defaultMessage: `Dashboard '{dashTitle}' was saved`, + values: { dashTitle: savedDashboard.title }, + }), + '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); + } + return { id }; + } catch (error) { + // eslint-disable-next-line + console.error(error); + notifications.toasts.addDanger({ + title: i18n.translate('dashboard.dashboardWasNotSavedDangerMessage', { + defaultMessage: `Dashboard '{dashTitle}' was not saved. Error: {errorMessage}`, + values: { + dashTitle: savedDashboard.title, + errorMessage: savedDashboard.message, + }, + }), + 'data-test-subj': 'saveDashboardFailure', + }); + return { error }; + } + } + + return navActions; +}; diff --git a/src/plugins/dashboard/public/application/utils/use/use_dashboard_app_state.tsx b/src/plugins/dashboard/public/application/utils/use/use_dashboard_app_state.tsx index e14e790125f0..2b67369d2623 100644 --- a/src/plugins/dashboard/public/application/utils/use/use_dashboard_app_state.tsx +++ b/src/plugins/dashboard/public/application/utils/use/use_dashboard_app_state.tsx @@ -24,64 +24,63 @@ export const useDashboardAppState = ( eventEmitter: EventEmitter, instance: any ) => { - const [appState, setAppState] = useState(null); + const [appState, setAppState] = useState(); useEffect(() => { - if (!instance) { - return; - } - const { dashboardConfig, usageCollection, opensearchDashboardsVersion } = services; - const hideWriteControls = dashboardConfig.getHideWriteControls(); - const stateDefaults = migrateAppState( - getAppStateDefaults(instance, hideWriteControls), - opensearchDashboardsVersion, - usageCollection - ); + if (instance) { + const { dashboardConfig, usageCollection, opensearchDashboardsVersion } = services; + const hideWriteControls = dashboardConfig.getHideWriteControls(); + const stateDefaults = migrateAppState( + getAppStateDefaults(instance, hideWriteControls), + opensearchDashboardsVersion, + usageCollection + ); - const { stateContainer, stopStateSync } = createDashboardAppState({ - stateDefaults, - osdUrlStateStorage: services.osdUrlStateStorage, - services, - instance, - }); + const { stateContainer, stopStateSync } = createDashboardAppState({ + stateDefaults, + osdUrlStateStorage: services.osdUrlStateStorage, + services, + instance, + }); - const { filterManager, queryString } = services.data.query; + const { filterManager, queryString } = services.data.query; - // sync initial app state from state container to managers - filterManager.setAppFilters(cloneDeep(stateContainer.getState().filters)); - queryString.setQuery(migrateLegacyQuery(stateContainer.getState().query)); + // sync initial app state from state container to managers + filterManager.setAppFilters(cloneDeep(stateContainer.getState().filters)); + queryString.setQuery(migrateLegacyQuery(stateContainer.getState().query)); - // setup syncing of app filters between app state and query services - const stopSyncingAppFilters = connectToQueryState( - services.data.query, - { - set: ({ filters, query }) => { - stateContainer.transitions.set('filters', filters || []); - stateContainer.transitions.set('query', query || queryString.getDefaultQuery()); + // setup syncing of app filters between app state and query services + const stopSyncingAppFilters = connectToQueryState( + services.data.query, + { + set: ({ filters, query }) => { + stateContainer.transitions.set('filters', filters || []); + stateContainer.transitions.set('query', query || queryString.getDefaultQuery()); + }, + get: () => ({ + filters: stateContainer.getState().filters, + query: migrateLegacyQuery(stateContainer.getState().query), + }), + state$: stateContainer.state$.pipe( + map((state) => ({ + filters: state.filters, + query: queryString.formatQuery(state.query), + })) + ), }, - get: () => ({ - filters: stateContainer.getState().filters, - query: migrateLegacyQuery(stateContainer.getState().query), - }), - state$: stateContainer.state$.pipe( - map((state) => ({ - filters: state.filters, - query: queryString.formatQuery(state.query), - })) - ), - }, - { - filters: opensearchFilters.FilterStateStore.APP_STATE, - query: true, - } - ); + { + filters: opensearchFilters.FilterStateStore.APP_STATE, + query: true, + } + ); - setAppState(stateContainer); + setAppState(stateContainer); - return () => { - stopStateSync(); - stopSyncingAppFilters(); - }; + return () => { + stopStateSync(); + stopSyncingAppFilters(); + }; + } }, [eventEmitter, instance, services]); return { appState }; 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 new file mode 100644 index 000000000000..effb081d864b --- /dev/null +++ b/src/plugins/dashboard/public/application/utils/use/use_dashboard_container.tsx @@ -0,0 +1,250 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState } from 'react'; +import { EMPTY, Subscription, merge } from 'rxjs'; +import { catchError, distinctUntilChanged, 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 { + DASHBOARD_CONTAINER_TYPE, + DashboardContainer, + DashboardContainerInput, + DashboardPanelState, +} from '../../embeddable'; +import { + ContainerOutput, + ErrorEmbeddable, + ViewMode, + isErrorEmbeddable, +} from '../../../embeddable_plugin'; +import { convertSavedDashboardPanelToPanelState } from '../../lib/embeddable_saved_object_converters'; +import { DashboardEmptyScreen, DashboardEmptyScreenProps } from '../../dashboard_empty_screen'; +import { DashboardAppStateContainer, DashboardServices, SavedDashboardPanel } from '../../../types'; + +export const useDashboardContainer = ( + services: DashboardServices, + isChromeVisible: boolean, + eventEmitter: EventEmitter, + savedDashboardInstance?: any, + appState?: DashboardAppStateContainer +) => { + const [dashboardContainer, setDashboardContainer] = useState(); + + useEffect(() => { + const getDashboardContainer = async () => { + try { + if (savedDashboardInstance && appState) { + let dashboardContainerEmbeddable: DashboardContainer | undefined; + try { + dashboardContainerEmbeddable = await createDashboardEmbeddable( + savedDashboardInstance, + services, + appState + ); + } catch (error) { + console.log(error); + } + setDashboardContainer(dashboardContainerEmbeddable); + } + } catch (error) { + console.log(error); + } + }; + + getDashboardContainer(); + }, [savedDashboardInstance, appState]); + + return { dashboardContainer }; +}; + +const createDashboardEmbeddable = async ( + savedDash: any, + dashboardServices: DashboardServices, + appState: DashboardAppStateContainer +) => { + let dashboardContainer: DashboardContainer; + let inputSubscription: Subscription | undefined; + let outputSubscription: Subscription | undefined; + + const { + embeddable, + data, + uiSettings, + http, + dashboardConfig, + embeddableCapabilities, + } = dashboardServices; + const { query: queryService } = data; + const filterManager = queryService.filterManager; + const timefilter = queryService.timefilter.timefilter; + const queryStringManager = queryService.queryString; + const { visualizeCapabilities, mapsCapabilities } = embeddableCapabilities; + // const dashboardDom = document.getElementById('dashboardViewport'); + const dashboardFactory = embeddable.getEmbeddableFactory< + DashboardContainerInput, + ContainerOutput, + DashboardContainer + >(DASHBOARD_CONTAINER_TYPE); + + const getShouldShowEditHelp = () => { + return ( + !savedDash.panels.length && + savedDash.viewMode === ViewMode.EDIT && + !dashboardConfig.getHideWriteControls() + ); + }; + + const getShouldShowViewHelp = () => { + return ( + !savedDash.panels.length && + savedDash.viewMode === ViewMode.VIEW && + !dashboardConfig.getHideWriteControls() + ); + }; + + const shouldShowUnauthorizedEmptyState = () => { + const readonlyMode = + !savedDash.panels.length && + !getShouldShowEditHelp() && + !getShouldShowViewHelp() && + dashboardConfig.getHideWriteControls(); + const userHasNoPermissions = + !savedDash.panels.length && !visualizeCapabilities.save && !mapsCapabilities.save; + return readonlyMode || userHasNoPermissions; + }; + + const getEmptyScreenProps = ( + shouldShowEditHelp: boolean, + isEmptyInReadOnlyMode: boolean + ): DashboardEmptyScreenProps => { + const emptyScreenProps: DashboardEmptyScreenProps = { + onLinkClick: () => {}, // TODO + showLinkToVisualize: shouldShowEditHelp, + uiSettings, + http, + }; + if (shouldShowEditHelp) { + emptyScreenProps.onVisualizeClick = () => { + alert('click'); // TODO + }; + } + if (isEmptyInReadOnlyMode) { + emptyScreenProps.isReadonlyMode = true; + } + return emptyScreenProps; + }; + + const getDashboardInput = () => { + const appStateData = appState.getState(); + const embeddablesMap: { + [key: string]: DashboardPanelState; + } = {}; + appStateData.panels.forEach((panel: SavedDashboardPanel) => { + embeddablesMap[panel.panelIndex] = convertSavedDashboardPanelToPanelState(panel); + }); + + const lastReloadRequestTime = 0; + return { + id: savedDash.id || '', + filters: data.query.filterManager.getFilters(), + hidePanelTitles: appStateData.options.hidePanelTitles, + query: savedDash.query, + timeRange: data.query.timefilter.timefilter.getTime(), + refreshConfig: data.query.timefilter.timefilter.getRefreshInterval(), + viewMode: appStateData.viewMode, + panels: embeddablesMap, + isFullScreenMode: appStateData.fullScreenMode, + isEmbeddedExternally: false, // TODO + // isEmptyState: shouldShowEditHelp || shouldShowViewHelp || isEmptyInReadonlyMode, + isEmptyState: false, // TODO + useMargins: appStateData.options.useMargins, + lastReloadRequestTime, // TODO + title: appStateData.title, + description: appStateData.description, + expandedPanelId: appStateData.expandedPanelId, + }; + }; + + if (dashboardFactory) { + return dashboardFactory + .create(getDashboardInput()) + .then((container: DashboardContainer | ErrorEmbeddable | undefined) => { + if (container && !isErrorEmbeddable(container)) { + dashboardContainer = container; + + dashboardContainer.renderEmpty = () => { + const shouldShowEditHelp = getShouldShowEditHelp(); + const shouldShowViewHelp = getShouldShowViewHelp(); + const isEmptyInReadOnlyMode = shouldShowUnauthorizedEmptyState(); + const isEmptyState = shouldShowEditHelp || shouldShowViewHelp || isEmptyInReadOnlyMode; + return isEmptyState ? ( + + ) : null; + }; + + // TODO: handle dashboard container input and output subsciptions + // issue: + outputSubscription = merge( + // output of dashboard container itself + dashboardContainer.getOutput$(), + // plus output of dashboard container children, + // children may change, so make sure we subscribe/unsubscribe with switchMap + dashboardContainer.getOutput$().pipe( + map(() => dashboardContainer!.getChildIds()), + distinctUntilChanged(deepEqual), + switchMap((newChildIds: string[]) => + merge( + ...newChildIds.map((childId) => + dashboardContainer! + .getChild(childId) + .getOutput$() + .pipe(catchError(() => EMPTY)) + ) + ) + ) + ) + ) + .pipe( + mapTo(dashboardContainer), + startWith(dashboardContainer) // to trigger initial index pattern update + // updateIndexPatternsOperator //TODO + ) + .subscribe(); + + inputSubscription = dashboardContainer.getInput$().subscribe((foo) => { + // This has to be first because handleDashboardContainerChanges causes + // appState.save which will cause refreshDashboardContainer to be called. + + if ( + !opensearchFilters.compareFilters( + container.getInput().filters, + filterManager.getFilters(), + opensearchFilters.COMPARE_ALL_OPTIONS + ) + ) { + // Add filters modifies the object passed to it, hence the clone deep. + filterManager.addFilters(_.cloneDeep(container.getInput().filters)); + + /* 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); + }); + return dashboardContainer; + } + }); + } + return undefined; +}; 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 new file mode 100644 index 000000000000..60dfb9ba927a --- /dev/null +++ b/src/plugins/dashboard/public/application/utils/use/use_editor_updates.ts @@ -0,0 +1,66 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import EventEmitter from 'events'; +import { useEffect, useState } from 'react'; +import { DashboardAppState, DashboardAppStateContainer, DashboardServices } from '../../../types'; +import { DashboardContainer } from '../../embeddable'; + +export const useEditorUpdates = ( + services: DashboardServices, + eventEmitter: EventEmitter, + dashboardInstance?: any, + dashboardContainer?: DashboardContainer, + appState?: DashboardAppStateContainer +) => { + const [isEmbeddableRendered, setIsEmbeddableRendered] = useState(false); + const [currentAppState, setCurrentAppState] = useState(); + const dom = document.getElementById('dashboardViewport'); + + const { + timefilter: { timefilter }, + filterManager, + queryString, + state$, + } = services.data.query; + + useEffect(() => { + if (appState && dashboardInstance && dashboardContainer) { + const initialState = appState.getState(); + setCurrentAppState(initialState); + + const unsubscribeStateUpdates = appState.subscribe((state) => { + setCurrentAppState(state); + dashboardContainer.reload(); + }); + + return () => { + unsubscribeStateUpdates(); + }; + } + }, [ + appState, + eventEmitter, + dashboardInstance, + services, + dashboardContainer, + isEmbeddableRendered, + currentAppState, + ]); + + useEffect(() => { + if (!dom || !dashboardContainer) { + return; + } + dashboardContainer.render(dom); + setIsEmbeddableRendered(true); + + return () => { + setIsEmbeddableRendered(false); + }; + }, [appState, dashboardInstance, currentAppState, dashboardContainer, state$, dom]); + + 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 e7e1633ac41b..d8336d74fc1f 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 @@ -23,28 +23,26 @@ export const useSavedDashboardInstance = ( isChromeVisible: boolean | undefined, dashboardIdFromUrl: string | undefined ) => { - const [state, setState] = useState<{ - savedDashboardInstance?: any; - }>({}); - + const [savedDashboardInstance, setSavedDashboardInstance] = useState(); const dashboardId = useRef(''); useEffect(() => { + const { + application: { navigateToApp }, + chrome, + history, + http: { basePath }, + notifications, + savedDashboards, + } = services; + const getSavedDashboardInstance = async () => { - const { - application: { navigateToApp }, - chrome, - history, - http: { basePath }, - notifications, - savedDashboards, - } = services; try { console.log('trying to get saved dashboard'); - let savedDashboardInstance: any; + let savedDashboard: any; if (history.location.pathname === '/create') { try { - savedDashboardInstance = await savedDashboards.get(); + savedDashboard = await savedDashboards.get(); } catch { redirectWhenMissing({ history, @@ -58,13 +56,13 @@ export const useSavedDashboardInstance = ( } } else if (dashboardIdFromUrl) { try { - savedDashboardInstance = await savedDashboards.get(dashboardIdFromUrl); + savedDashboard = await savedDashboards.get(dashboardIdFromUrl); chrome.recentlyAccessed.add( - savedDashboardInstance.getFullPath(), - savedDashboardInstance.title, + savedDashboard.getFullPath(), + savedDashboard.title, dashboardIdFromUrl ); - console.log('saved dashboard', savedDashboardInstance); + 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. @@ -91,7 +89,7 @@ export const useSavedDashboardInstance = ( } } - setState({ savedDashboardInstance }); + setSavedDashboardInstance(savedDashboard); } catch (error) {} }; @@ -106,15 +104,13 @@ export const useSavedDashboardInstance = ( } else if ( dashboardIdFromUrl && dashboardId.current !== dashboardIdFromUrl && - state.savedDashboardInstance?.id !== dashboardIdFromUrl + savedDashboardInstance?.id !== dashboardIdFromUrl ) { dashboardId.current = dashboardIdFromUrl; - setState({}); + setSavedDashboardInstance({}); getSavedDashboardInstance(); } - }, [eventEmitter, isChromeVisible, services, state.savedDashboardInstance, dashboardIdFromUrl]); + }, [eventEmitter, isChromeVisible, services, savedDashboardInstance, dashboardIdFromUrl]); - return { - ...state, - }; + return savedDashboardInstance; }; diff --git a/src/plugins/dashboard/public/types.ts b/src/plugins/dashboard/public/types.ts index 8a94a424b940..cb4770c2a001 100644 --- a/src/plugins/dashboard/public/types.ts +++ b/src/plugins/dashboard/public/types.ts @@ -60,6 +60,7 @@ export interface DashboardCapabilities { createNew: boolean; showSavedQuery: boolean; saveQuery: boolean; + createShortUrl: boolean; } // TODO: Replace Saved object interfaces by the ones Core will provide when it is ready.