From 5103e2c7c3b27fca808c78bf951a0c6aa01945f0 Mon Sep 17 00:00:00 2001 From: Devon Thomson Date: Wed, 3 Feb 2021 17:23:01 -0500 Subject: [PATCH] [Time to Visualize] Remove Panels from URL (#86939) Removed panels from dashboard URLs Co-authored-by: Ryan Keairns # Conflicts: # test/functional/apps/dashboard/view_edit.ts --- .../public/application/_dashboard_app.scss | 34 +++ .../public/application/dashboard_router.tsx | 14 +- .../application/dashboard_state.test.ts | 1 + .../application/dashboard_state_manager.ts | 164 ++++++++++----- .../hooks/use_dashboard_breadcrumbs.ts | 29 +-- .../hooks/use_dashboard_state_manager.ts | 13 +- .../application/hooks/use_saved_dashboard.ts | 11 +- .../lib/dashboard_panel_storage.ts | 78 +++++++ .../dashboard/public/application/lib/index.ts | 1 + .../application/listing/confirm_overlays.tsx | 94 +++++++++ .../listing/dashboard_listing.test.tsx | 3 + .../application/listing/dashboard_listing.tsx | 34 ++- .../dashboard_unsaved_listing.test.tsx | 153 ++++++++++++++ .../listing/dashboard_unsaved_listing.tsx | 197 ++++++++++++++++++ .../application/top_nav/dashboard_top_nav.tsx | 89 ++++---- .../application/top_nav/get_top_nav_config.ts | 20 +- .../public/application/top_nav/top_nav_ids.ts | 1 + .../dashboard/public/application/types.ts | 5 +- .../dashboard/public/dashboard_constants.ts | 10 +- .../dashboard/public/dashboard_strings.ts | 82 +++++++- src/plugins/dashboard/public/plugin.tsx | 4 +- .../saved_dashboards/saved_dashboard.ts | 7 +- .../dashboard/public/services/kibana_utils.ts | 1 + src/plugins/dashboard/public/types.ts | 3 +- .../table_list_view/table_list_view.tsx | 1 + test/accessibility/apps/dashboard.ts | 2 +- .../apps/dashboard/dashboard_filtering.ts | 50 +++-- .../apps/dashboard/dashboard_state.ts | 23 +- .../dashboard/dashboard_unsaved_listing.ts | 160 ++++++++++++++ .../apps/dashboard/dashboard_unsaved_state.ts | 86 ++++++++ test/functional/apps/dashboard/index.ts | 2 + .../apps/dashboard/panel_context_menu.ts | 6 +- test/functional/apps/dashboard/view_edit.ts | 16 +- .../functional/page_objects/dashboard_page.ts | 53 ++++- .../services/dashboard/panel_actions.ts | 10 +- .../translations/translations/zh-CN.json | 2 +- 36 files changed, 1269 insertions(+), 190 deletions(-) create mode 100644 src/plugins/dashboard/public/application/lib/dashboard_panel_storage.ts create mode 100644 src/plugins/dashboard/public/application/listing/confirm_overlays.tsx create mode 100644 src/plugins/dashboard/public/application/listing/dashboard_unsaved_listing.test.tsx create mode 100644 src/plugins/dashboard/public/application/listing/dashboard_unsaved_listing.tsx create mode 100644 test/functional/apps/dashboard/dashboard_unsaved_listing.ts create mode 100644 test/functional/apps/dashboard/dashboard_unsaved_state.ts diff --git a/src/plugins/dashboard/public/application/_dashboard_app.scss b/src/plugins/dashboard/public/application/_dashboard_app.scss index f969f936ddebc..30253afff391f 100644 --- a/src/plugins/dashboard/public/application/_dashboard_app.scss +++ b/src/plugins/dashboard/public/application/_dashboard_app.scss @@ -33,3 +33,37 @@ margin-left: $euiSizeS; text-align: center; } + +.dshUnsavedListingItem { + margin-top: $euiSizeM; +} + +.dshUnsavedListingItem__icon { + margin-right: $euiSizeM; +} + +.dshUnsavedListingItem__title { + margin-bottom: 0 !important; +} + +.dshUnsavedListingItem__loading { + color: $euiTextSubduedColor !important; +} + +.dshUnsavedListingItem__actions { + margin-left: $euiSizeL + $euiSizeXS; +} + +@include euiBreakpoint('xs', 's') { + .dshUnsavedListingItem { + margin-top: $euiSize; + } + + .dshUnsavedListingItem__heading { + margin-bottom: $euiSizeXS; + } + + .dshUnsavedListingItem__actions { + flex-direction: column; + } +} \ No newline at end of file diff --git a/src/plugins/dashboard/public/application/dashboard_router.tsx b/src/plugins/dashboard/public/application/dashboard_router.tsx index 5206c76f50be2..d49871b853731 100644 --- a/src/plugins/dashboard/public/application/dashboard_router.tsx +++ b/src/plugins/dashboard/public/application/dashboard_router.tsx @@ -15,12 +15,17 @@ import { Switch, Route, RouteComponentProps, HashRouter, Redirect } from 'react- import { DashboardListing } from './listing'; import { DashboardApp } from './dashboard_app'; -import { addHelpMenuToAppChrome } from './lib'; +import { addHelpMenuToAppChrome, DashboardPanelStorage } from './lib'; import { createDashboardListingFilterUrl } from '../dashboard_constants'; import { getDashboardPageTitle, dashboardReadonlyBadge } from '../dashboard_strings'; import { createDashboardEditUrl, DashboardConstants } from '../dashboard_constants'; import { DashboardAppServices, DashboardEmbedSettings, RedirectToProps } from './types'; -import { DashboardSetupDependencies, DashboardStart, DashboardStartDependencies } from '../plugin'; +import { + DashboardFeatureFlagConfig, + DashboardSetupDependencies, + DashboardStart, + DashboardStartDependencies, +} from '../plugin'; import { createKbnUrlStateStorage, withNotifyOnErrors } from '../services/kibana_utils'; import { KibanaContextProvider } from '../services/kibana_react'; @@ -94,8 +99,11 @@ export async function mountApp({ indexPatterns: dataStart.indexPatterns, savedQueryService: dataStart.query.savedQueries, savedObjectsClient: coreStart.savedObjects.client, + dashboardPanelStorage: new DashboardPanelStorage(core.notifications.toasts), savedDashboards: dashboardStart.getSavedDashboardLoader(), savedObjectsTagging: savedObjectsTaggingOss?.getTaggingApi(), + allowByValueEmbeddables: initializerContext.config.get() + .allowByValueEmbeddables, dashboardCapabilities: { hideWriteControls: dashboardConfig.getHideWriteControls(), show: Boolean(coreStart.application.capabilities.dashboard.show), @@ -122,7 +130,7 @@ export async function mountApp({ let destination; if (redirectTo.destination === 'dashboard') { destination = redirectTo.id - ? createDashboardEditUrl(redirectTo.id) + ? createDashboardEditUrl(redirectTo.id, redirectTo.editMode) : DashboardConstants.CREATE_NEW_DASHBOARD_URL; } else { destination = createDashboardListingFilterUrl(redirectTo.filter); diff --git a/src/plugins/dashboard/public/application/dashboard_state.test.ts b/src/plugins/dashboard/public/application/dashboard_state.test.ts index e973a0180cab8..a96788532def5 100644 --- a/src/plugins/dashboard/public/application/dashboard_state.test.ts +++ b/src/plugins/dashboard/public/application/dashboard_state.test.ts @@ -41,6 +41,7 @@ describe('DashboardState', function () { dashboardState = new DashboardStateManager({ savedDashboard, hideWriteControls: false, + allowByValueEmbeddables: false, kibanaVersion: '7.0.0', kbnUrlStateStorage: createKbnUrlStateStorage(), history: createBrowserHistory(), diff --git a/src/plugins/dashboard/public/application/dashboard_state_manager.ts b/src/plugins/dashboard/public/application/dashboard_state_manager.ts index c52bd1b4d47b8..562fbc7aab3ba 100644 --- a/src/plugins/dashboard/public/application/dashboard_state_manager.ts +++ b/src/plugins/dashboard/public/application/dashboard_state_manager.ts @@ -16,7 +16,12 @@ import { FilterUtils } from './lib/filter_utils'; import { DashboardContainer } from './embeddable'; import { DashboardSavedObject } from '../saved_dashboards'; import { migrateLegacyQuery } from './lib/migrate_legacy_query'; -import { getAppStateDefaults, migrateAppState, getDashboardIdFromUrl } from './lib'; +import { + getAppStateDefaults, + migrateAppState, + getDashboardIdFromUrl, + DashboardPanelStorage, +} from './lib'; import { convertPanelStateToSavedDashboardPanel } from '../../common/embeddable/embeddable_saved_object_converters'; import { DashboardAppState, @@ -37,6 +42,7 @@ import { ReduxLikeStateContainer, syncState, } from '../services/kibana_utils'; +import { STATE_STORAGE_KEY } from '../url_generator'; /** * Dashboard state manager handles connecting angular and redux state between the angular and react portions of the @@ -71,10 +77,11 @@ export class DashboardStateManager { DashboardAppStateTransitions >; private readonly stateContainerChangeSub: Subscription; - private readonly STATE_STORAGE_KEY = '_a'; + private readonly dashboardPanelStorage?: DashboardPanelStorage; public readonly kbnUrlStateStorage: IKbnUrlStateStorage; private readonly stateSyncRef: ISyncStateRef; - private readonly history: History; + private readonly allowByValueEmbeddables: boolean; + private readonly usageCollection: UsageCollectionSetup | undefined; public readonly hasTaggingCapabilities: SavedObjectTagDecoratorTypeGuard; @@ -86,28 +93,32 @@ export class DashboardStateManager { * @param */ constructor({ + history, + kibanaVersion, savedDashboard, + usageCollection, hideWriteControls, - kibanaVersion, kbnUrlStateStorage, - history, - usageCollection, + dashboardPanelStorage, hasTaggingCapabilities, + allowByValueEmbeddables, }: { - savedDashboard: DashboardSavedObject; - hideWriteControls: boolean; - kibanaVersion: string; - kbnUrlStateStorage: IKbnUrlStateStorage; history: History; + kibanaVersion: string; + hideWriteControls: boolean; + allowByValueEmbeddables: boolean; + savedDashboard: DashboardSavedObject; usageCollection?: UsageCollectionSetup; + kbnUrlStateStorage: IKbnUrlStateStorage; + dashboardPanelStorage?: DashboardPanelStorage; hasTaggingCapabilities: SavedObjectTagDecoratorTypeGuard; }) { - this.history = history; this.kibanaVersion = kibanaVersion; this.savedDashboard = savedDashboard; this.hideWriteControls = hideWriteControls; this.usageCollection = usageCollection; this.hasTaggingCapabilities = hasTaggingCapabilities; + this.allowByValueEmbeddables = allowByValueEmbeddables; // get state defaults from saved dashboard, make sure it is migrated this.stateDefaults = migrateAppState( @@ -115,20 +126,29 @@ export class DashboardStateManager { kibanaVersion, usageCollection ); - + this.dashboardPanelStorage = dashboardPanelStorage; this.kbnUrlStateStorage = kbnUrlStateStorage; - // setup initial state by merging defaults with state from url + // setup initial state by merging defaults with state from url & panels storage // also run migration, as state in url could be of older version + const initialUrlState = this.kbnUrlStateStorage.get(STATE_STORAGE_KEY); const initialState = migrateAppState( { ...this.stateDefaults, - ...this.kbnUrlStateStorage.get(this.STATE_STORAGE_KEY), + ...this.getUnsavedPanelState(), + ...initialUrlState, }, kibanaVersion, usageCollection ); + this.isDirty = false; + + if (initialUrlState?.panels && !_.isEqual(initialUrlState.panels, this.stateDefaults.panels)) { + this.isDirty = true; + this.setUnsavedPanels(initialState.panels); + } + // setup state container using initial state both from defaults and from url this.stateContainer = createStateContainer( initialState, @@ -144,8 +164,6 @@ export class DashboardStateManager { } ); - this.isDirty = false; - // We can't compare the filters stored on this.appState to this.savedDashboard because in order to apply // the filters to the visualizations, we need to save it on the dashboard. We keep track of the original // filter state in order to let the user know if their filters changed and provide this specific information @@ -159,16 +177,16 @@ export class DashboardStateManager { this.changeListeners.forEach((listener) => listener({ dirty: this.isDirty })); }); - // setup state syncing utils. state container will be synced with url into `this.STATE_STORAGE_KEY` query param + // setup state syncing utils. state container will be synced with url into `STATE_STORAGE_KEY` query param this.stateSyncRef = syncState({ - storageKey: this.STATE_STORAGE_KEY, + storageKey: STATE_STORAGE_KEY, stateContainer: { ...this.stateContainer, get: () => this.toUrlState(this.stateContainer.get()), - set: (state: DashboardAppStateInUrl | null) => { + set: (stateFromUrl: DashboardAppStateInUrl | null) => { // sync state required state container to be able to handle null // overriding set() so it could handle null coming from url - if (state) { + if (stateFromUrl) { // Skip this update if current dashboardId in the url is different from what we have in the current instance of state manager // As dashboard is driven by angular at the moment, the destroy cycle happens async, // If the dashboardId has changed it means this instance @@ -177,9 +195,15 @@ export class DashboardStateManager { const currentDashboardIdInUrl = getDashboardIdFromUrl(history.location.pathname); if (currentDashboardIdInUrl !== this.savedDashboard.id) return; + // set View mode before the rest of the state so unsaved panels can be added correctly. + if (this.appState.viewMode !== stateFromUrl.viewMode) { + this.switchViewMode(stateFromUrl.viewMode); + } + this.stateContainer.set({ ...this.stateDefaults, - ...state, + ...this.getUnsavedPanelState(), + ...stateFromUrl, }); } else { // Do nothing in case when state from url is empty, @@ -261,6 +285,13 @@ export class DashboardStateManager { if (dirtyBecauseOfInitialStateMigration) { this.saveState({ replace: true }); } + + // If a panel has been changed, and the state is now equal to the state in the saved object, remove the unsaved panels + if (!this.isDirty && this.getIsEditMode()) { + this.clearUnsavedPanels(); + } else { + this.setUnsavedPanels(this.getPanels()); + } } if (input.isFullScreenMode !== this.getFullScreenMode()) { @@ -483,7 +514,16 @@ export class DashboardStateManager { } public getViewMode() { - return this.hideWriteControls ? ViewMode.VIEW : this.appState.viewMode; + if (this.hideWriteControls) { + return ViewMode.VIEW; + } + if (this.stateContainer) { + return this.appState.viewMode; + } + // get viewMode should work properly even before the state container is created + return this.savedDashboard.id + ? this.kbnUrlStateStorage.get(STATE_STORAGE_KEY)?.viewMode ?? ViewMode.VIEW + : ViewMode.EDIT; } public getIsViewMode() { @@ -592,29 +632,13 @@ export class DashboardStateManager { private saveState({ replace }: { replace: boolean }): boolean { // schedules setting current state to url this.kbnUrlStateStorage.set( - this.STATE_STORAGE_KEY, + STATE_STORAGE_KEY, this.toUrlState(this.stateContainer.get()) ); // immediately forces scheduled updates and changes location return !!this.kbnUrlStateStorage.kbnUrlControls.flush(replace); } - // TODO: find nicer solution for this - // this function helps to make just 1 browser history update, when we imperatively changing the dashboard url - // It could be that there is pending *dashboardStateManager* updates, which aren't flushed yet to the url. - // So to prevent 2 browser updates: - // 1. Force flush any pending state updates (syncing state to query) - // 2. If url was updated, then apply path change with replace - public changeDashboardUrl(pathname: string) { - // synchronously persist current state to url with push() - const updated = this.saveState({ replace: false }); - // change pathname - this.history[updated ? 'replace' : 'push']({ - ...this.history.location, - pathname, - }); - } - public setQuery(query: Query) { this.stateContainer.transitions.set('query', query); } @@ -644,6 +668,59 @@ export class DashboardStateManager { } } + public restorePanels() { + const unsavedState = this.getUnsavedPanelState(); + if (!unsavedState || unsavedState.panels?.length === 0) { + return; + } + this.stateContainer.set( + migrateAppState( + { + ...this.stateDefaults, + ...unsavedState, + ...this.kbnUrlStateStorage.get(STATE_STORAGE_KEY), + }, + this.kibanaVersion, + this.usageCollection + ) + ); + } + + public clearUnsavedPanels() { + if (!this.allowByValueEmbeddables || !this.dashboardPanelStorage) { + return; + } + this.dashboardPanelStorage.clearPanels(this.savedDashboard?.id); + } + + private getUnsavedPanelState(): { panels?: SavedDashboardPanel[] } { + if (!this.allowByValueEmbeddables || this.getIsViewMode() || !this.dashboardPanelStorage) { + return {}; + } + const panels = this.dashboardPanelStorage.getPanels(this.savedDashboard?.id); + return panels ? { panels } : {}; + } + + private setUnsavedPanels(newPanels: SavedDashboardPanel[]) { + if ( + !this.allowByValueEmbeddables || + this.getIsViewMode() || + !this.getIsDirty() || + !this.dashboardPanelStorage + ) { + return; + } + this.dashboardPanelStorage.setPanels(this.savedDashboard?.id, newPanels); + } + + private toUrlState(state: DashboardAppState): DashboardAppStateInUrl { + if (this.getIsEditMode() && !this.allowByValueEmbeddables) { + return state; + } + const { panels, ...stateWithoutPanels } = state; + return stateWithoutPanels; + } + private checkIsDirty() { // Filters need to be compared manually because they sometimes have a $$hashkey stored on the object. // Query needs to be compared manually because saved legacy queries get migrated in app state automatically @@ -653,13 +730,4 @@ export class DashboardStateManager { const current = _.omit(this.stateContainer.get(), propsToIgnore); return !_.isEqual(initial, current); } - - private toUrlState(state: DashboardAppState): DashboardAppStateInUrl { - if (state.viewMode === ViewMode.VIEW) { - const { panels, ...stateWithoutPanels } = state; - return stateWithoutPanels; - } - - return state; - } } diff --git a/src/plugins/dashboard/public/application/hooks/use_dashboard_breadcrumbs.ts b/src/plugins/dashboard/public/application/hooks/use_dashboard_breadcrumbs.ts index d9e8991373448..76fbebb04acac 100644 --- a/src/plugins/dashboard/public/application/hooks/use_dashboard_breadcrumbs.ts +++ b/src/plugins/dashboard/public/application/hooks/use_dashboard_breadcrumbs.ts @@ -8,16 +8,11 @@ import { useEffect } from 'react'; import _ from 'lodash'; -import { EUI_MODAL_CANCEL_BUTTON } from '@elastic/eui'; import { useKibana } from '../../services/kibana_react'; import { DashboardStateManager } from '../dashboard_state_manager'; -import { - getDashboardBreadcrumb, - getDashboardTitle, - leaveConfirmStrings, -} from '../../dashboard_strings'; +import { getDashboardBreadcrumb, getDashboardTitle } from '../../dashboard_strings'; import { DashboardAppServices, DashboardRedirect } from '../types'; export const useDashboardBreadcrumbs = ( @@ -38,32 +33,12 @@ export const useDashboardBreadcrumbs = ( return; } - const { - getConfirmButtonText, - getCancelButtonText, - getLeaveTitle, - getLeaveSubtitle, - } = leaveConfirmStrings; - setBreadcrumbs([ { text: getDashboardBreadcrumb(), 'data-test-subj': 'dashboardListingBreadcrumb', onClick: () => { - if (dashboardStateManager.getIsDirty()) { - openConfirm(getLeaveSubtitle(), { - confirmButtonText: getConfirmButtonText(), - cancelButtonText: getCancelButtonText(), - defaultFocusedButton: EUI_MODAL_CANCEL_BUTTON, - title: getLeaveTitle(), - }).then((isConfirmed) => { - if (isConfirmed) { - redirectTo({ destination: 'listing' }); - } - }); - } else { - redirectTo({ destination: 'listing' }); - } + redirectTo({ destination: 'listing' }); }, }, { diff --git a/src/plugins/dashboard/public/application/hooks/use_dashboard_state_manager.ts b/src/plugins/dashboard/public/application/hooks/use_dashboard_state_manager.ts index 93fbb50950850..98401beca39ef 100644 --- a/src/plugins/dashboard/public/application/hooks/use_dashboard_state_manager.ts +++ b/src/plugins/dashboard/public/application/hooks/use_dashboard_state_manager.ts @@ -52,8 +52,10 @@ export const useDashboardStateManager = ( uiSettings, usageCollection, initializerContext, - dashboardCapabilities, savedObjectsTagging, + dashboardCapabilities, + dashboardPanelStorage, + allowByValueEmbeddables, } = useKibana().services; // Destructure and rename services; makes the Effect hook more specific, makes later @@ -86,12 +88,14 @@ export const useDashboardStateManager = ( const stateManager = new DashboardStateManager({ hasTaggingCapabilities, + dashboardPanelStorage, hideWriteControls, history, kbnUrlStateStorage, kibanaVersion, savedDashboard, usageCollection, + allowByValueEmbeddables, }); // sync initial app filters from state to filterManager @@ -178,6 +182,10 @@ export const useDashboardStateManager = ( } ); + if (stateManager.getIsEditMode()) { + stateManager.restorePanels(); + } + setDashboardStateManager(stateManager); setViewMode(stateManager.getViewMode()); @@ -191,6 +199,8 @@ export const useDashboardStateManager = ( dataPlugin, filterManager, hasTaggingCapabilities, + initializerContext.config, + dashboardPanelStorage, hideWriteControls, history, kibanaVersion, @@ -202,6 +212,7 @@ export const useDashboardStateManager = ( toasts, uiSettings, usageCollection, + allowByValueEmbeddables, dashboardCapabilities.storeSearchSession, ]); diff --git a/src/plugins/dashboard/public/application/hooks/use_saved_dashboard.ts b/src/plugins/dashboard/public/application/hooks/use_saved_dashboard.ts index fae98b8fed306..9ef8fef909ac3 100644 --- a/src/plugins/dashboard/public/application/hooks/use_saved_dashboard.ts +++ b/src/plugins/dashboard/public/application/hooks/use_saved_dashboard.ts @@ -14,7 +14,7 @@ import { useKibana } from '../../services/kibana_react'; import { DashboardConstants } from '../..'; import { DashboardSavedObject } from '../../saved_dashboards'; -import { getDashboard60Warning } from '../../dashboard_strings'; +import { getDashboard60Warning, getNewDashboardTitle } from '../../dashboard_strings'; import { DashboardAppServices } from '../types'; export const useSavedDashboard = (savedDashboardId: string | undefined, history: History) => { @@ -43,12 +43,7 @@ export const useSavedDashboard = (savedDashboardId: string | undefined, history: try { const dashboard = (await savedDashboards.get(savedDashboardId)) as DashboardSavedObject; - const { title, getFullPath } = dashboard; - if (savedDashboardId) { - recentlyAccessedPaths.add(getFullPath(), title, savedDashboardId); - } - - docTitle.change(title); + docTitle.change(dashboard.title || getNewDashboardTitle()); setSavedDashboard(dashboard); } catch (error) { // E.g. a corrupt or deleted dashboard @@ -58,13 +53,13 @@ export const useSavedDashboard = (savedDashboardId: string | undefined, history: })(); return () => setSavedDashboard(null); }, [ + toasts, docTitle, history, indexPatterns, recentlyAccessedPaths, savedDashboardId, savedDashboards, - toasts, ]); return savedDashboard; diff --git a/src/plugins/dashboard/public/application/lib/dashboard_panel_storage.ts b/src/plugins/dashboard/public/application/lib/dashboard_panel_storage.ts new file mode 100644 index 0000000000000..ab1842048abd0 --- /dev/null +++ b/src/plugins/dashboard/public/application/lib/dashboard_panel_storage.ts @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { Storage } from '../../services/kibana_utils'; +import { NotificationsStart } from '../../services/core'; +import { panelStorageErrorStrings } from '../../dashboard_strings'; +import { SavedDashboardPanel } from '..'; + +export const DASHBOARD_PANELS_UNSAVED_ID = 'unsavedDashboard'; +const DASHBOARD_PANELS_SESSION_KEY = 'dashboardStateManagerPanels'; + +export class DashboardPanelStorage { + private sessionStorage: Storage; + + constructor(private toasts: NotificationsStart['toasts']) { + this.sessionStorage = new Storage(sessionStorage); + } + + public clearPanels(id = DASHBOARD_PANELS_UNSAVED_ID) { + try { + const sessionStoragePanels = this.sessionStorage.get(DASHBOARD_PANELS_SESSION_KEY) || {}; + if (sessionStoragePanels[id]) { + delete sessionStoragePanels[id]; + this.sessionStorage.set(DASHBOARD_PANELS_SESSION_KEY, sessionStoragePanels); + } + } catch (e) { + this.toasts.addDanger({ + title: panelStorageErrorStrings.getPanelsClearError(e.message), + 'data-test-subj': 'dashboardPanelsClearFailure', + }); + } + } + + public getPanels(id = DASHBOARD_PANELS_UNSAVED_ID): SavedDashboardPanel[] | undefined { + try { + return this.sessionStorage.get(DASHBOARD_PANELS_SESSION_KEY)?.[id]; + } catch (e) { + this.toasts.addDanger({ + title: panelStorageErrorStrings.getPanelsGetError(e.message), + 'data-test-subj': 'dashboardPanelsGetFailure', + }); + } + } + + public setPanels(id = DASHBOARD_PANELS_UNSAVED_ID, newPanels: SavedDashboardPanel[]) { + try { + const sessionStoragePanels = this.sessionStorage.get(DASHBOARD_PANELS_SESSION_KEY) || {}; + sessionStoragePanels[id] = newPanels; + this.sessionStorage.set(DASHBOARD_PANELS_SESSION_KEY, sessionStoragePanels); + } catch (e) { + this.toasts.addDanger({ + title: panelStorageErrorStrings.getPanelsSetError(e.message), + 'data-test-subj': 'dashboardPanelsSetFailure', + }); + } + } + + public getDashboardIdsWithUnsavedChanges() { + try { + return Object.keys(this.sessionStorage.get(DASHBOARD_PANELS_SESSION_KEY) || {}); + } catch (e) { + this.toasts.addDanger({ + title: panelStorageErrorStrings.getPanelsGetError(e.message), + 'data-test-subj': 'dashboardPanelsGetFailure', + }); + return []; + } + } + + public dashboardHasUnsavedEdits(id = DASHBOARD_PANELS_UNSAVED_ID) { + return this.getDashboardIdsWithUnsavedChanges().indexOf(id) !== -1; + } +} diff --git a/src/plugins/dashboard/public/application/lib/index.ts b/src/plugins/dashboard/public/application/lib/index.ts index fc2e17298b3f2..e803b783f3bad 100644 --- a/src/plugins/dashboard/public/application/lib/index.ts +++ b/src/plugins/dashboard/public/application/lib/index.ts @@ -13,3 +13,4 @@ export { getDashboardIdFromUrl } from './url'; export { createSessionRestorationDataProvider } from './session_restoration'; export { addHelpMenuToAppChrome } from './help_menu_util'; export { attemptLoadDashboardByTitle } from './load_dashboard_by_title'; +export { DashboardPanelStorage } from './dashboard_panel_storage'; diff --git a/src/plugins/dashboard/public/application/listing/confirm_overlays.tsx b/src/plugins/dashboard/public/application/listing/confirm_overlays.tsx new file mode 100644 index 0000000000000..519978741dedf --- /dev/null +++ b/src/plugins/dashboard/public/application/listing/confirm_overlays.tsx @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { + EuiButton, + EuiButtonEmpty, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiText, + EUI_MODAL_CANCEL_BUTTON, +} from '@elastic/eui'; +import React from 'react'; +import { OverlayStart } from '../../../../../core/public'; +import { createConfirmStrings, leaveConfirmStrings } from '../../dashboard_strings'; +import { toMountPoint } from '../../services/kibana_react'; + +export const confirmDiscardUnsavedChanges = ( + overlays: OverlayStart, + discardCallback: () => void, + cancelButtonText = leaveConfirmStrings.getCancelButtonText() +) => + overlays + .openConfirm(leaveConfirmStrings.getDiscardSubtitle(), { + confirmButtonText: leaveConfirmStrings.getConfirmButtonText(), + cancelButtonText, + buttonColor: 'danger', + defaultFocusedButton: EUI_MODAL_CANCEL_BUTTON, + title: leaveConfirmStrings.getDiscardTitle(), + }) + .then((isConfirmed) => { + if (isConfirmed) { + discardCallback(); + } + }); + +export const confirmCreateWithUnsaved = ( + overlays: OverlayStart, + startBlankCallback: () => void, + contineCallback: () => void +) => { + const session = overlays.openModal( + toMountPoint( + session.close()}> + + {createConfirmStrings.getCreateTitle()} + + + + {createConfirmStrings.getCreateSubtitle()} + + + + session.close()} + > + {createConfirmStrings.getCancelButtonText()} + + { + startBlankCallback(); + session.close(); + }} + > + {createConfirmStrings.getStartOverButtonText()} + + { + contineCallback(); + session.close(); + }} + > + {createConfirmStrings.getContinueButtonText()} + + + + ), + { + 'data-test-subj': 'dashboardCreateConfirmModal', + } + ); +}; diff --git a/src/plugins/dashboard/public/application/listing/dashboard_listing.test.tsx b/src/plugins/dashboard/public/application/listing/dashboard_listing.test.tsx index ef4d4e693d0e3..f4943c9679f4a 100644 --- a/src/plugins/dashboard/public/application/listing/dashboard_listing.test.tsx +++ b/src/plugins/dashboard/public/application/listing/dashboard_listing.test.tsx @@ -29,6 +29,7 @@ import { chromeServiceMock, coreMock } from '../../../../../core/public/mocks'; import { I18nProvider } from '@kbn/i18n/react'; import React from 'react'; import { UrlForwardingStart } from '../../../../url_forwarding/public'; +import { DashboardPanelStorage } from '../lib'; function makeDefaultServices(): DashboardAppServices { const core = coreMock.createStart(); @@ -52,6 +53,7 @@ function makeDefaultServices(): DashboardAppServices { savedObjects: savedObjectsPluginMock.createStartContract(), embeddable: embeddablePluginMock.createInstance().doStart(), dashboardCapabilities: {} as DashboardCapabilities, + dashboardPanelStorage: {} as DashboardPanelStorage, initializerContext: {} as PluginInitializerContext, chrome: chromeServiceMock.createStartContract(), navigation: {} as NavigationPublicPluginStart, @@ -65,6 +67,7 @@ function makeDefaultServices(): DashboardAppServices { uiSettings: {} as IUiSettingsClient, restorePreviousUrl: () => {}, onAppLeave: (handler) => {}, + allowByValueEmbeddables: true, savedDashboards, core, }; diff --git a/src/plugins/dashboard/public/application/listing/dashboard_listing.tsx b/src/plugins/dashboard/public/application/listing/dashboard_listing.tsx index 07de4cd52bba6..2accf0183da14 100644 --- a/src/plugins/dashboard/public/application/listing/dashboard_listing.tsx +++ b/src/plugins/dashboard/public/application/listing/dashboard_listing.tsx @@ -17,6 +17,8 @@ import { syncQueryStateWithUrl } from '../../services/data'; import { IKbnUrlStateStorage } from '../../services/kibana_utils'; import { TableListView, useKibana } from '../../services/kibana_react'; import { SavedObjectsTaggingApi } from '../../services/saved_objects_tagging_oss'; +import { DashboardUnsavedListing } from './dashboard_unsaved_listing'; +import { confirmCreateWithUnsaved } from './confirm_overlays'; import { getDashboardListItemLink } from './get_dashboard_list_item_link'; export interface DashboardListingProps { @@ -41,6 +43,7 @@ export const DashboardListing = ({ savedObjectsClient, savedObjectsTagging, dashboardCapabilities, + dashboardPanelStorage, chrome: { setBreadcrumbs }, }, } = useKibana(); @@ -91,12 +94,24 @@ export const DashboardListing = ({ [core.application, core.uiSettings, kbnUrlStateStorage, savedObjectsTagging] ); + const createItem = useCallback(() => { + if (!dashboardPanelStorage.dashboardHasUnsavedEdits()) { + redirectTo({ destination: 'dashboard' }); + } else { + confirmCreateWithUnsaved( + core.overlays, + () => { + dashboardPanelStorage.clearPanels(); + redirectTo({ destination: 'dashboard' }); + }, + () => redirectTo({ destination: 'dashboard' }) + ); + } + }, [dashboardPanelStorage, redirectTo, core.overlays]); + const noItemsFragment = useMemo( - () => - getNoItemsMessage(hideWriteControls, core.application, () => - redirectTo({ destination: 'dashboard' }) - ), - [redirectTo, core.application, hideWriteControls] + () => getNoItemsMessage(hideWriteControls, core.application, createItem), + [createItem, core.application, hideWriteControls] ); const fetchItems = useCallback( @@ -125,7 +140,8 @@ export const DashboardListing = ({ ); const editItem = useCallback( - ({ id }: { id: string | undefined }) => redirectTo({ destination: 'dashboard', id }), + ({ id }: { id: string | undefined }) => + redirectTo({ destination: 'dashboard', id, editMode: true }), [redirectTo] ); @@ -143,7 +159,7 @@ export const DashboardListing = ({ } = dashboardListingTable; return ( redirectTo({ destination: 'dashboard' })} + createItem={hideWriteControls ? undefined : createItem} deleteItems={hideWriteControls ? undefined : deleteItems} initialPageSize={savedObjects.settings.getPerPage()} editItem={hideWriteControls ? undefined : editItem} @@ -162,7 +178,9 @@ export const DashboardListing = ({ listingLimit, tableColumns, }} - /> + > + + ); }; diff --git a/src/plugins/dashboard/public/application/listing/dashboard_unsaved_listing.test.tsx b/src/plugins/dashboard/public/application/listing/dashboard_unsaved_listing.test.tsx new file mode 100644 index 0000000000000..3717ea79dc060 --- /dev/null +++ b/src/plugins/dashboard/public/application/listing/dashboard_unsaved_listing.test.tsx @@ -0,0 +1,153 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { I18nProvider } from '@kbn/i18n/react'; +import { findTestSubject } from '@elastic/eui/lib/test'; +import { waitFor } from '@testing-library/react'; +import { mount } from 'enzyme'; +import React from 'react'; +import { DashboardSavedObject } from '../..'; +import { coreMock } from '../../../../../core/public/mocks'; +import { KibanaContextProvider } from '../../services/kibana_react'; +import { SavedObjectLoader } from '../../services/saved_objects'; +import { DashboardPanelStorage } from '../lib'; +import { DASHBOARD_PANELS_UNSAVED_ID } from '../lib/dashboard_panel_storage'; +import { DashboardAppServices, DashboardRedirect } from '../types'; +import { DashboardUnsavedListing } from './dashboard_unsaved_listing'; + +const mockedDashboards: { [key: string]: DashboardSavedObject } = { + dashboardUnsavedOne: { + id: `dashboardUnsavedOne`, + title: `Dashboard Unsaved One`, + } as DashboardSavedObject, + dashboardUnsavedTwo: { + id: `dashboardUnsavedTwo`, + title: `Dashboard Unsaved Two`, + } as DashboardSavedObject, + dashboardUnsavedThree: { + id: `dashboardUnsavedThree`, + title: `Dashboard Unsaved Three`, + } as DashboardSavedObject, +}; + +function makeDefaultServices(): DashboardAppServices { + const core = coreMock.createStart(); + core.overlays.openConfirm = jest.fn().mockResolvedValue(true); + const savedDashboards = {} as SavedObjectLoader; + savedDashboards.get = jest.fn().mockImplementation((id: string) => mockedDashboards[id]); + const dashboardPanelStorage = {} as DashboardPanelStorage; + dashboardPanelStorage.clearPanels = jest.fn(); + dashboardPanelStorage.getDashboardIdsWithUnsavedChanges = jest + .fn() + .mockImplementation(() => [ + 'dashboardUnsavedOne', + 'dashboardUnsavedTwo', + 'dashboardUnsavedThree', + ]); + return ({ + dashboardPanelStorage, + savedDashboards, + core, + } as unknown) as DashboardAppServices; +} + +const makeDefaultProps = () => ({ redirectTo: jest.fn() }); + +function mountWith({ + services: incomingServices, + props: incomingProps, +}: { + services?: DashboardAppServices; + props?: { redirectTo: DashboardRedirect }; +}) { + const services = incomingServices ?? makeDefaultServices(); + const props = incomingProps ?? makeDefaultProps(); + const wrappingComponent: React.FC<{ + children: React.ReactNode; + }> = ({ children }) => { + return ( + + {children} + + ); + }; + const component = mount(, { wrappingComponent }); + return { component, props, services }; +} + +describe('Unsaved listing', () => { + it('Gets information for each unsaved dashboard', async () => { + const { services } = mountWith({}); + await waitFor(() => { + expect(services.savedDashboards.get).toHaveBeenCalledTimes(3); + }); + }); + + it('Does not attempt to get unsaved dashboard id', async () => { + const services = makeDefaultServices(); + services.dashboardPanelStorage.getDashboardIdsWithUnsavedChanges = jest + .fn() + .mockImplementation(() => ['dashboardUnsavedOne', DASHBOARD_PANELS_UNSAVED_ID]); + mountWith({ services }); + await waitFor(() => { + expect(services.savedDashboards.get).toHaveBeenCalledTimes(1); + }); + }); + + it('Redirects to the requested dashboard in edit mode when continue editing clicked', async () => { + const { props, component } = mountWith({}); + const getEditButton = () => findTestSubject(component, 'edit-unsaved-Dashboard-Unsaved-One'); + await waitFor(() => { + component.update(); + expect(getEditButton().length).toEqual(1); + }); + getEditButton().simulate('click'); + expect(props.redirectTo).toHaveBeenCalledWith({ + destination: 'dashboard', + id: 'dashboardUnsavedOne', + editMode: true, + }); + }); + + it('Redirects to new dashboard when continue editing clicked', async () => { + const services = makeDefaultServices(); + services.dashboardPanelStorage.getDashboardIdsWithUnsavedChanges = jest + .fn() + .mockImplementation(() => [DASHBOARD_PANELS_UNSAVED_ID]); + const { props, component } = mountWith({ services }); + const getEditButton = () => findTestSubject(component, `edit-unsaved-New-Dashboard`); + await waitFor(() => { + component.update(); + expect(getEditButton().length).toBe(1); + }); + getEditButton().simulate('click'); + expect(props.redirectTo).toHaveBeenCalledWith({ + destination: 'dashboard', + id: undefined, + editMode: true, + }); + }); + + it('Shows a warning then clears changes when delete unsaved changes is pressed', async () => { + const { services, component } = mountWith({}); + const getDiscardButton = () => + findTestSubject(component, 'discard-unsaved-Dashboard-Unsaved-One'); + await waitFor(() => { + component.update(); + expect(getDiscardButton().length).toBe(1); + }); + getDiscardButton().simulate('click'); + waitFor(() => { + component.update(); + expect(services.core.overlays.openConfirm).toHaveBeenCalled(); + expect(services.dashboardPanelStorage.clearPanels).toHaveBeenCalledWith( + 'dashboardUnsavedOne' + ); + }); + }); +}); diff --git a/src/plugins/dashboard/public/application/listing/dashboard_unsaved_listing.tsx b/src/plugins/dashboard/public/application/listing/dashboard_unsaved_listing.tsx new file mode 100644 index 0000000000000..9b78f290acbeb --- /dev/null +++ b/src/plugins/dashboard/public/application/listing/dashboard_unsaved_listing.tsx @@ -0,0 +1,197 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { + EuiButtonEmpty, + EuiCallOut, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiSpacer, + EuiTitle, +} from '@elastic/eui'; +import React, { useCallback, useEffect, useState } from 'react'; +import { DashboardSavedObject } from '../..'; +import { + createConfirmStrings, + dashboardUnsavedListingStrings, + getNewDashboardTitle, +} from '../../dashboard_strings'; +import { useKibana } from '../../services/kibana_react'; +import { DASHBOARD_PANELS_UNSAVED_ID } from '../lib/dashboard_panel_storage'; +import { DashboardAppServices, DashboardRedirect } from '../types'; +import { confirmDiscardUnsavedChanges } from './confirm_overlays'; + +const DashboardUnsavedItem = ({ + id, + title, + onOpenClick, + onDiscardClick, +}: { + id: string; + title?: string; + onOpenClick: () => void; + onDiscardClick: () => void; +}) => { + return ( +
+ + + + + + +

+ {title || dashboardUnsavedListingStrings.getLoadingTitle()} +

+
+
+
+ + + + {dashboardUnsavedListingStrings.getEditTitle()} + + + + + {dashboardUnsavedListingStrings.getDiscardTitle()} + + + +
+ ); +}; + +interface UnsavedItemMap { + [key: string]: DashboardSavedObject; +} + +export const DashboardUnsavedListing = ({ redirectTo }: { redirectTo: DashboardRedirect }) => { + const { + services: { + dashboardPanelStorage, + savedDashboards, + core: { overlays }, + }, + } = useKibana(); + + const [items, setItems] = useState({}); + const [dashboardIds, setDashboardIds] = useState( + dashboardPanelStorage.getDashboardIdsWithUnsavedChanges() + ); + + const onOpen = useCallback( + (id?: string) => { + redirectTo({ destination: 'dashboard', id, editMode: true }); + }, + [redirectTo] + ); + + const onDiscard = useCallback( + (id?: string) => { + confirmDiscardUnsavedChanges( + overlays, + () => { + dashboardPanelStorage.clearPanels(id); + setDashboardIds(dashboardPanelStorage.getDashboardIdsWithUnsavedChanges()); + }, + createConfirmStrings.getCancelButtonText() + ); + }, + [overlays, dashboardPanelStorage] + ); + + useEffect(() => { + if (dashboardIds?.length === 0) { + return; + } + let canceled = false; + const dashPromises = dashboardIds + .filter((id) => id !== DASHBOARD_PANELS_UNSAVED_ID) + .map((dashboardId) => savedDashboards.get(dashboardId)); + Promise.all(dashPromises).then((dashboards: DashboardSavedObject[]) => { + const dashboardMap = {}; + if (canceled) { + return; + } + setItems( + dashboards.reduce((map, dashboard) => { + return { + ...map, + [dashboard.id || DASHBOARD_PANELS_UNSAVED_ID]: dashboard, + }; + }, dashboardMap) + ); + }); + return () => { + canceled = true; + }; + }, [dashboardIds, savedDashboards]); + + return dashboardIds.length === 0 ? null : ( + <> + 1)} + > + {dashboardIds.map((dashboardId: string) => { + const title: string | undefined = + dashboardId === DASHBOARD_PANELS_UNSAVED_ID + ? getNewDashboardTitle() + : items[dashboardId]?.title; + const redirectId = dashboardId === DASHBOARD_PANELS_UNSAVED_ID ? undefined : dashboardId; + return ( + onOpen(redirectId)} + onDiscardClick={() => onDiscard(redirectId)} + /> + ); + })} + + + + ); +}; diff --git a/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx b/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx index 4cf565a12be22..47aae123cc0c2 100644 --- a/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx +++ b/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx @@ -6,8 +6,6 @@ * Public License, v 1. */ -import { EUI_MODAL_CANCEL_BUTTON } from '@elastic/eui'; - import { i18n } from '@kbn/i18n'; import angular from 'angular'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; @@ -31,7 +29,6 @@ import { import { NavAction } from '../../types'; import { DashboardSavedObject } from '../..'; import { DashboardStateManager } from '../dashboard_state_manager'; -import { leaveConfirmStrings } from '../../dashboard_strings'; import { saveDashboard } from '../lib'; import { DashboardAppServices, @@ -46,7 +43,10 @@ import { showOptionsPopover } from './show_options_popover'; import { TopNavIds } from './top_nav_ids'; import { ShowShareModal } from './show_share_modal'; import { PanelToolbar } from './panel_toolbar'; +import { confirmDiscardUnsavedChanges } from '../listing/confirm_overlays'; import { OverlayRef } from '../../../../../core/public'; +import { getNewDashboardTitle } from '../../dashboard_strings'; +import { DASHBOARD_PANELS_UNSAVED_ID } from '../lib/dashboard_panel_storage'; import { DashboardContainer } from '..'; export interface DashboardTopNavState { @@ -91,6 +91,8 @@ export function DashboardTopNav({ setHeaderActionMenu, savedObjectsTagging, dashboardCapabilities, + dashboardPanelStorage, + allowByValueEmbeddables, } = useKibana().services; const [state, setState] = useState({ chromeIsVisible: false }); @@ -99,8 +101,16 @@ export function DashboardTopNav({ const visibleSubscription = chrome.getIsVisible$().subscribe((chromeIsVisible) => { setState((s) => ({ ...s, chromeIsVisible })); }); + const { id, title, getFullEditPath } = savedDashboard; + if (id || allowByValueEmbeddables) { + chrome.recentlyAccessed.add( + getFullEditPath(dashboardStateManager.getIsEditMode()), + title || getNewDashboardTitle(), + id || DASHBOARD_PANELS_UNSAVED_ID + ); + } return () => visibleSubscription.unsubscribe(); - }, [chrome]); + }, [chrome, allowByValueEmbeddables, dashboardStateManager, savedDashboard]); const addFromLibrary = useCallback(() => { if (!isErrorEmbeddable(dashboardContainer)) { @@ -142,47 +152,40 @@ export function DashboardTopNav({ } }, [state.addPanelOverlay]); + const onDiscardChanges = useCallback(() => { + function revertChangesAndExitEditMode() { + dashboardStateManager.resetState(); + dashboardStateManager.clearUnsavedPanels(); + + // 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); + } + dashboardStateManager.switchViewMode(ViewMode.VIEW); + } + confirmDiscardUnsavedChanges(core.overlays, revertChangesAndExitEditMode); + }, [core.overlays, dashboardStateManager, timefilter]); + const onChangeViewMode = useCallback( (newMode: ViewMode) => { clearAddPanel(); - const isPageRefresh = newMode === dashboardStateManager.getViewMode(); - const isLeavingEditMode = !isPageRefresh && newMode === ViewMode.VIEW; - const willLoseChanges = isLeavingEditMode && dashboardStateManager.getIsDirty(timefilter); - - if (!willLoseChanges) { - dashboardStateManager.switchViewMode(newMode); - return; + if (savedDashboard?.id && allowByValueEmbeddables) { + const { getFullEditPath, title, id } = savedDashboard; + chrome.recentlyAccessed.add(getFullEditPath(newMode === ViewMode.EDIT), title, id); } - - function revertChangesAndExitEditMode() { - dashboardStateManager.resetState(); - // This is only necessary for new dashboards, which will default to Edit mode. - dashboardStateManager.switchViewMode(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); - } - redirectTo({ destination: 'dashboard', id: savedDashboard.id }); - } - - core.overlays - .openConfirm(leaveConfirmStrings.getDiscardSubtitle(), { - confirmButtonText: leaveConfirmStrings.getConfirmButtonText(), - cancelButtonText: leaveConfirmStrings.getCancelButtonText(), - defaultFocusedButton: EUI_MODAL_CANCEL_BUTTON, - title: leaveConfirmStrings.getDiscardTitle(), - }) - .then((isConfirmed) => { - if (isConfirmed) { - revertChangesAndExitEditMode(); - } - }); + dashboardStateManager.switchViewMode(newMode); + dashboardStateManager.restorePanels(); }, - [redirectTo, timefilter, core.overlays, savedDashboard.id, dashboardStateManager, clearAddPanel] + [ + clearAddPanel, + savedDashboard, + dashboardStateManager, + allowByValueEmbeddables, + chrome.recentlyAccessed, + ] ); /** @@ -210,8 +213,9 @@ export function DashboardTopNav({ 'data-test-subj': 'saveDashboardSuccess', }); + dashboardPanelStorage.clearPanels(lastDashboardId); if (id !== lastDashboardId) { - redirectTo({ destination: 'dashboard', id }); + redirectTo({ destination: 'dashboard', id, useReplace: !lastDashboardId }); } else { chrome.docTitle.change(dashboardStateManager.savedDashboard.lastSavedTitle); dashboardStateManager.switchViewMode(ViewMode.VIEW); @@ -236,6 +240,7 @@ export function DashboardTopNav({ [ core.notifications.toasts, dashboardStateManager, + dashboardPanelStorage, lastDashboardId, chrome.docTitle, redirectTo, @@ -349,6 +354,7 @@ export function DashboardTopNav({ }, [TopNavIds.EXIT_EDIT_MODE]: () => onChangeViewMode(ViewMode.VIEW), [TopNavIds.ENTER_EDIT_MODE]: () => onChangeViewMode(ViewMode.EDIT), + [TopNavIds.DISCARD_CHANGES]: onDiscardChanges, [TopNavIds.SAVE]: runSave, [TopNavIds.CLONE]: runClone, [TopNavIds.ADD_EXISTING]: addFromLibrary, @@ -385,6 +391,7 @@ export function DashboardTopNav({ }, [ dashboardCapabilities, dashboardStateManager, + onDiscardChanges, onChangeViewMode, savedDashboard, addFromLibrary, diff --git a/src/plugins/dashboard/public/application/top_nav/get_top_nav_config.ts b/src/plugins/dashboard/public/application/top_nav/get_top_nav_config.ts index 3364c2d2ff768..2bbccdccd2eac 100644 --- a/src/plugins/dashboard/public/application/top_nav/get_top_nav_config.ts +++ b/src/plugins/dashboard/public/application/top_nav/get_top_nav_config.ts @@ -41,6 +41,7 @@ export function getTopNavConfig( getShareConfig(actions[TopNavIds.SHARE]), getAddConfig(actions[TopNavIds.ADD_EXISTING]), getViewConfig(actions[TopNavIds.EXIT_EDIT_MODE]), + getDiscardConfig(actions[TopNavIds.DISCARD_CHANGES]), getSaveConfig(actions[TopNavIds.SAVE]), getCreateNewConfig(actions[TopNavIds.VISUALIZE]), ]; @@ -112,13 +113,30 @@ function getViewConfig(action: NavAction) { defaultMessage: 'cancel', }), description: i18n.translate('dashboard.topNave.viewConfigDescription', { - defaultMessage: 'Cancel editing and switch to view-only mode', + defaultMessage: 'Switch to view-only mode', }), testId: 'dashboardViewOnlyMode', run: action, }; } +/** + * @returns {kbnTopNavConfig} + */ +function getDiscardConfig(action: NavAction) { + return { + id: 'discard', + label: i18n.translate('dashboard.topNave.discardlButtonAriaLabel', { + defaultMessage: 'discard', + }), + description: i18n.translate('dashboard.topNave.discardConfigDescription', { + defaultMessage: 'Discard unsaved changes', + }), + testId: 'dashboardDiscardChanges', + run: action, + }; +} + /** * @returns {kbnTopNavConfig} */ diff --git a/src/plugins/dashboard/public/application/top_nav/top_nav_ids.ts b/src/plugins/dashboard/public/application/top_nav/top_nav_ids.ts index fc31d67c12a2f..16cbb0d2d85c8 100644 --- a/src/plugins/dashboard/public/application/top_nav/top_nav_ids.ts +++ b/src/plugins/dashboard/public/application/top_nav/top_nav_ids.ts @@ -12,6 +12,7 @@ export const TopNavIds = { SAVE: 'save', EXIT_EDIT_MODE: 'exitEditMode', ENTER_EDIT_MODE: 'enterEditMode', + DISCARD_CHANGES: 'discard', CLONE: 'clone', FULL_SCREEN: 'fullScreenMode', VISUALIZE: 'visualize', diff --git a/src/plugins/dashboard/public/application/types.ts b/src/plugins/dashboard/public/application/types.ts index e4f9388a919d1..f7d64eebe3332 100644 --- a/src/plugins/dashboard/public/application/types.ts +++ b/src/plugins/dashboard/public/application/types.ts @@ -23,11 +23,12 @@ import { NavigationPublicPluginStart } from '../services/navigation'; import { SavedObjectsTaggingApi } from '../services/saved_objects_tagging_oss'; import { DataPublicPluginStart, IndexPatternsContract } from '../services/data'; import { SavedObjectLoader, SavedObjectsStart } from '../services/saved_objects'; +import { DashboardPanelStorage } from './lib'; import { UrlForwardingStart } from '../../../url_forwarding/public'; export type DashboardRedirect = (props: RedirectToProps) => void; export type RedirectToProps = - | { destination: 'dashboard'; id?: string; useReplace?: boolean } + | { destination: 'dashboard'; id?: string; useReplace?: boolean; editMode?: boolean } | { destination: 'listing'; filter?: string; useReplace?: boolean }; export interface DashboardEmbedSettings { @@ -67,12 +68,14 @@ export interface DashboardAppServices { uiSettings: IUiSettingsClient; restorePreviousUrl: () => void; savedObjects: SavedObjectsStart; + allowByValueEmbeddables: boolean; urlForwarding: UrlForwardingStart; savedDashboards: SavedObjectLoader; scopedHistory: () => ScopedHistory; indexPatterns: IndexPatternsContract; usageCollection?: UsageCollectionSetup; navigation: NavigationPublicPluginStart; + dashboardPanelStorage: DashboardPanelStorage; dashboardCapabilities: DashboardCapabilities; initializerContext: PluginInitializerContext; onAppLeave: AppMountParameters['onAppLeave']; diff --git a/src/plugins/dashboard/public/dashboard_constants.ts b/src/plugins/dashboard/public/dashboard_constants.ts index 3788a42029431..f809eaa2914ed 100644 --- a/src/plugins/dashboard/public/dashboard_constants.ts +++ b/src/plugins/dashboard/public/dashboard_constants.ts @@ -6,6 +6,8 @@ * Public License, v 1. */ +const DASHBOARD_STATE_STORAGE_KEY = '_a'; + export const DashboardConstants = { LANDING_PAGE_PATH: '/list', CREATE_NEW_DASHBOARD_URL: '/create', @@ -17,8 +19,12 @@ export const DashboardConstants = { SEARCH_SESSION_ID: 'searchSessionId', }; -export function createDashboardEditUrl(id: string) { - return `${DashboardConstants.VIEW_DASHBOARD_URL}/${id}`; +export function createDashboardEditUrl(id?: string, editMode?: boolean) { + if (!id) { + return `${DashboardConstants.CREATE_NEW_DASHBOARD_URL}`; + } + const edit = editMode ? `?${DASHBOARD_STATE_STORAGE_KEY}=(viewMode:edit)` : ''; + return `${DashboardConstants.VIEW_DASHBOARD_URL}/${id}${edit}`; } export function createDashboardListingFilterUrl(filter: string | undefined) { diff --git a/src/plugins/dashboard/public/dashboard_strings.ts b/src/plugins/dashboard/public/dashboard_strings.ts index 6c23aab4ebddb..68681826c27fb 100644 --- a/src/plugins/dashboard/public/dashboard_strings.ts +++ b/src/plugins/dashboard/public/dashboard_strings.ts @@ -24,10 +24,7 @@ export function getDashboardTitle( ): string { const isEditMode = viewMode === ViewMode.EDIT; let displayTitle: string; - const newDashboardTitle = i18n.translate('dashboard.savedDashboard.newDashboardTitle', { - defaultMessage: 'New Dashboard', - }); - const dashboardTitle = isNew ? newDashboardTitle : title; + const dashboardTitle = isNew ? getNewDashboardTitle() : title; if (isEditMode && isDirty) { displayTitle = i18n.translate('dashboard.strings.dashboardUnsavedEditTitle', { @@ -176,6 +173,11 @@ export const dashboardReplacePanelAction = { /* Dashboard Editor */ +export const getNewDashboardTitle = () => + i18n.translate('dashboard.savedDashboard.newDashboardTitle', { + defaultMessage: 'New Dashboard', + }); + export const shareModalStrings = { getTopMenuCheckbox: () => i18n.translate('dashboard.embedUrlParamExtension.topMenu', { @@ -242,6 +244,44 @@ export const leaveConfirmStrings = { }), }; +export const createConfirmStrings = { + getCreateTitle: () => + i18n.translate('dashboard.createConfirmModal.unsavedChangesTitle', { + defaultMessage: 'New dashboard already in progress', + }), + getCreateSubtitle: () => + i18n.translate('dashboard.createConfirmModal.unsavedChangesSubtitle', { + defaultMessage: 'You can continue editing or start with a blank dashboard.', + }), + getStartOverButtonText: () => + i18n.translate('dashboard.createConfirmModal.confirmButtonLabel', { + defaultMessage: 'Start over', + }), + getContinueButtonText: () => leaveConfirmStrings.getCancelButtonText(), + getCancelButtonText: () => + i18n.translate('dashboard.createConfirmModal.cancelButtonLabel', { + defaultMessage: 'Cancel', + }), +}; + +export const panelStorageErrorStrings = { + getPanelsGetError: (message: string) => + i18n.translate('dashboard.panelStorageError.getError', { + defaultMessage: 'Error encountered while fetching unsaved changes: {message}', + values: { message }, + }), + getPanelsSetError: (message: string) => + i18n.translate('dashboard.panelStorageError.setError', { + defaultMessage: 'Error encountered while setting unsaved changes: {message}', + values: { message }, + }), + getPanelsClearError: (message: string) => + i18n.translate('dashboard.panelStorageError.clearError', { + defaultMessage: 'Error encountered while clearing unsaved changes: {message}', + values: { message }, + }), +}; + /* Empty Screen */ @@ -307,3 +347,37 @@ export const dashboardListingTable = { defaultMessage: 'Description', }), }; + +export const dashboardUnsavedListingStrings = { + getUnsavedChangesTitle: (plural = false) => + i18n.translate('dashboard.listing.unsaved.unsavedChangesTitle', { + defaultMessage: 'You have unsaved changes in the following {dash}.', + values: { + dash: plural + ? dashboardListingTable.getEntityNamePlural() + : dashboardListingTable.getEntityName(), + }, + }), + getLoadingTitle: () => + i18n.translate('dashboard.listing.unsaved.loading', { + defaultMessage: 'Loading', + }), + getEditAriaLabel: (title: string) => + i18n.translate('dashboard.listing.unsaved.editAria', { + defaultMessage: 'Continue editing {title}', + values: { title }, + }), + getEditTitle: () => + i18n.translate('dashboard.listing.unsaved.editTitle', { + defaultMessage: 'Continue editing', + }), + getDiscardAriaLabel: (title: string) => + i18n.translate('dashboard.listing.unsaved.discardAria', { + defaultMessage: 'Discard changes to {title}', + values: { title }, + }), + getDiscardTitle: () => + i18n.translate('dashboard.listing.unsaved.discardTitle', { + defaultMessage: 'Discard changes', + }), +}; diff --git a/src/plugins/dashboard/public/plugin.tsx b/src/plugins/dashboard/public/plugin.tsx index e6a251fd4c51c..37f751f6c19f5 100644 --- a/src/plugins/dashboard/public/plugin.tsx +++ b/src/plugins/dashboard/public/plugin.tsx @@ -282,11 +282,11 @@ export class DashboardPlugin core, appUnMounted, usageCollection, - onAppLeave: params.onAppLeave, - initializerContext: this.initializerContext, restorePreviousUrl, element: params.element, + onAppLeave: params.onAppLeave, scopedHistory: this.currentHistory!, + initializerContext: this.initializerContext, setHeaderActionMenu: params.setHeaderActionMenu, }); }, diff --git a/src/plugins/dashboard/public/saved_dashboards/saved_dashboard.ts b/src/plugins/dashboard/public/saved_dashboards/saved_dashboard.ts index d0b4d63e4fcde..6fa86acefd067 100644 --- a/src/plugins/dashboard/public/saved_dashboards/saved_dashboard.ts +++ b/src/plugins/dashboard/public/saved_dashboards/saved_dashboard.ts @@ -30,6 +30,7 @@ export interface DashboardSavedObject extends SavedObject { searchSource: ISearchSource; getQuery(): Query; getFilters(): Filter[]; + getFullEditPath: (editMode?: boolean) => string; } // Used only by the savedDashboards service, usually no reason to change this @@ -106,7 +107,7 @@ export function createSavedDashboardClass( refreshInterval: undefined, }, }); - this.getFullPath = () => `/app/dashboards#${createDashboardEditUrl(String(this.id))}`; + this.getFullPath = () => `/app/dashboards#${createDashboardEditUrl(this.id)}`; } getQuery() { @@ -116,6 +117,10 @@ export function createSavedDashboardClass( getFilters() { return this.searchSource!.getOwnField('filter') || []; } + + getFullEditPath = (editMode?: boolean) => { + return `/app/dashboards#${createDashboardEditUrl(this.id, editMode)}`; + }; } // Unfortunately this throws a typescript error without the casting. I think it's due to the diff --git a/src/plugins/dashboard/public/services/kibana_utils.ts b/src/plugins/dashboard/public/services/kibana_utils.ts index 72b8a427effca..353f1bcf31301 100644 --- a/src/plugins/dashboard/public/services/kibana_utils.ts +++ b/src/plugins/dashboard/public/services/kibana_utils.ts @@ -7,6 +7,7 @@ */ export { + Storage, unhashUrl, syncState, ISyncStateRef, diff --git a/src/plugins/dashboard/public/types.ts b/src/plugins/dashboard/public/types.ts index 63aea0130e071..29094270e98da 100644 --- a/src/plugins/dashboard/public/types.ts +++ b/src/plugins/dashboard/public/types.ts @@ -81,8 +81,7 @@ export type DashboardAppStateDefaults = DashboardAppState & { }; /** - * In URL panels are optional, - * Panels are not added to the URL when in "view" mode + * Panels are not added to the URL */ export type DashboardAppStateInUrl = Omit & { panels?: SavedDashboardPanel[]; diff --git a/src/plugins/kibana_react/public/table_list_view/table_list_view.tsx b/src/plugins/kibana_react/public/table_list_view/table_list_view.tsx index e2061487bfcee..8bc6c130d318f 100644 --- a/src/plugins/kibana_react/public/table_list_view/table_list_view.tsx +++ b/src/plugins/kibana_react/public/table_list_view/table_list_view.tsx @@ -518,6 +518,7 @@ class TableListView extends React.Component + {this.props.children} {this.renderListingLimitWarning()} {this.renderFetchError()} diff --git a/test/accessibility/apps/dashboard.ts b/test/accessibility/apps/dashboard.ts index 2a9c5b22380f8..a906219326dcd 100644 --- a/test/accessibility/apps/dashboard.ts +++ b/test/accessibility/apps/dashboard.ts @@ -110,7 +110,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('Exit out of edit mode', async () => { - await PageObjects.dashboard.clickCancelOutOfEditMode(); + await PageObjects.dashboard.clickDiscardChanges(); await a11y.testAppSnapshot(); }); diff --git a/test/functional/apps/dashboard/dashboard_filtering.ts b/test/functional/apps/dashboard/dashboard_filtering.ts index 1dd84460314b9..cef3b11ab2cb0 100644 --- a/test/functional/apps/dashboard/dashboard_filtering.ts +++ b/test/functional/apps/dashboard/dashboard_filtering.ts @@ -31,6 +31,27 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('dashboard filtering', function () { this.tags('includeFirefox'); + const populateDashboard = async () => { + await PageObjects.dashboard.clickNewDashboard(); + await PageObjects.timePicker.setDefaultDataRange(); + await dashboardAddPanel.addEveryVisualization('"Filter Bytes Test"'); + await dashboardAddPanel.addEverySavedSearch('"Filter Bytes Test"'); + + await dashboardAddPanel.closeAddPanel(); + }; + + const addFilterAndRefresh = async () => { + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.dashboard.waitForRenderComplete(); + await filterBar.addFilter('bytes', 'is', '12345678'); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.dashboard.waitForRenderComplete(); + // first round of requests sometimes times out, refresh all visualizations to fetch again + await queryBar.clickQuerySubmitButton(); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.dashboard.waitForRenderComplete(); + }; + before(async () => { await esArchiver.load('dashboard/current/kibana'); await security.testUser.setRoles(['kibana_admin', 'test_logstash_reader', 'animals']); @@ -48,22 +69,12 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('adding a filter that excludes all data', () => { before(async () => { - await PageObjects.dashboard.clickNewDashboard(); - await PageObjects.timePicker.setDefaultDataRange(); - await dashboardAddPanel.addEveryVisualization('"Filter Bytes Test"'); - await dashboardAddPanel.addEverySavedSearch('"Filter Bytes Test"'); - - await dashboardAddPanel.closeAddPanel(); + await populateDashboard(); + await addFilterAndRefresh(); + }); - await PageObjects.header.waitUntilLoadingHasFinished(); - await PageObjects.dashboard.waitForRenderComplete(); - await filterBar.addFilter('bytes', 'is', '12345678'); - await PageObjects.header.waitUntilLoadingHasFinished(); - await PageObjects.dashboard.waitForRenderComplete(); - // first round of requests sometimes times out, refresh all visualizations to fetch again - await queryBar.clickQuerySubmitButton(); - await PageObjects.header.waitUntilLoadingHasFinished(); - await PageObjects.dashboard.waitForRenderComplete(); + after(async () => { + await PageObjects.dashboard.gotoDashboardLandingPage(); }); it('filters on pie charts', async () => { @@ -118,6 +129,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('using a pinned filter that excludes all data', () => { before(async () => { + // Functional tests clear session storage after each suite, so it is important to repopulate unsaved panels + await populateDashboard(); + await addFilterAndRefresh(); + await filterBar.toggleFilterPinned('bytes'); await PageObjects.header.waitUntilLoadingHasFinished(); await PageObjects.dashboard.waitForRenderComplete(); @@ -125,6 +140,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { after(async () => { await filterBar.toggleFilterPinned('bytes'); + await PageObjects.dashboard.gotoDashboardLandingPage(); }); it('filters on pie charts', async () => { @@ -175,6 +191,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('disabling a filter unfilters the data on', function () { before(async () => { + // Functional tests clear session storage after each suite, so it is important to repopulate unsaved panels + await populateDashboard(); + await addFilterAndRefresh(); + await filterBar.toggleFilterEnabled('bytes'); await PageObjects.header.waitUntilLoadingHasFinished(); await PageObjects.dashboard.waitForRenderComplete(); diff --git a/test/functional/apps/dashboard/dashboard_state.ts b/test/functional/apps/dashboard/dashboard_state.ts index 88823baf32a07..3c9b96d7277ef 100644 --- a/test/functional/apps/dashboard/dashboard_state.ts +++ b/test/functional/apps/dashboard/dashboard_state.ts @@ -20,6 +20,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { 'discover', 'tileMap', 'visChart', + 'share', 'timePicker', ]); const testSubjects = getService('testSubjects'); @@ -127,8 +128,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('Saved search with column changes will not update when the saved object changes', async () => { - await PageObjects.discover.removeHeaderColumn('bytes'); await PageObjects.dashboard.switchToEditMode(); + await PageObjects.discover.removeHeaderColumn('bytes'); await PageObjects.dashboard.saveDashboard('Has local edits'); await PageObjects.header.clickDiscover(); @@ -191,6 +192,13 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(changedTileMapData.length).to.not.equal(tileMapData.length); }); + const getUrlFromShare = async () => { + await PageObjects.share.clickShareTopNavButton(); + const sharedUrl = await PageObjects.share.getSharedUrl(); + await PageObjects.share.clickShareTopNavButton(); + return sharedUrl; + }; + describe('Directly modifying url updates dashboard state', () => { it('for query parameter', async function () { await PageObjects.dashboard.gotoDashboardLandingPage(); @@ -209,7 +217,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('for panel size parameters', async function () { await dashboardAddPanel.addVisualization(PIE_CHART_VIS_NAME); - const currentUrl = await browser.getCurrentUrl(); + const currentUrl = await getUrlFromShare(); const currentPanelDimensions = await PageObjects.dashboard.getPanelDimensions(); const newUrl = currentUrl.replace( `w:${DEFAULT_PANEL_WIDTH}`, @@ -235,7 +243,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('when removing a panel', async function () { - const currentUrl = await browser.getCurrentUrl(); + await PageObjects.dashboard.waitForRenderComplete(); + const currentUrl = await getUrlFromShare(); const newUrl = currentUrl.replace(/panels:\!\(.*\),query/, 'panels:!(),query'); await browser.get(newUrl.toString(), false); @@ -253,7 +262,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { `[data-title="${PIE_CHART_VIS_NAME}"]` ); await PageObjects.visChart.selectNewLegendColorChoice('#F9D9F9'); - const currentUrl = await browser.getCurrentUrl(); + const currentUrl = await getUrlFromShare(); const newUrl = currentUrl.replace('F9D9F9', 'FFFFFF'); await browser.get(newUrl.toString(), false); await PageObjects.header.waitUntilLoadingHasFinished(); @@ -279,13 +288,13 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('resets a pie slice color to the original when removed', async function () { - const currentUrl = await browser.getCurrentUrl(); - const newUrl = currentUrl.replace('vis:(colors:(%2780,000%27:%23FFFFFF))', ''); + const currentUrl = await getUrlFromShare(); + const newUrl = currentUrl.replace(`vis:(colors:('80,000':%23FFFFFF))`, ''); await browser.get(newUrl.toString(), false); await PageObjects.header.waitUntilLoadingHasFinished(); await retry.try(async () => { - const pieSliceStyle = await pieChart.getPieSliceStyle('80,000'); + const pieSliceStyle = await pieChart.getPieSliceStyle(`80,000`); // The default green color that was stored with the visualization before any dashboard overrides. expect(pieSliceStyle.indexOf('rgb(87, 193, 123)')).to.be.greaterThan(0); }); diff --git a/test/functional/apps/dashboard/dashboard_unsaved_listing.ts b/test/functional/apps/dashboard/dashboard_unsaved_listing.ts new file mode 100644 index 0000000000000..19e532f0af213 --- /dev/null +++ b/test/functional/apps/dashboard/dashboard_unsaved_listing.ts @@ -0,0 +1,160 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import expect from '@kbn/expect'; + +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const PageObjects = getPageObjects(['dashboard', 'header', 'visualize', 'settings', 'common']); + const esArchiver = getService('esArchiver'); + const kibanaServer = getService('kibanaServer'); + const dashboardAddPanel = getService('dashboardAddPanel'); + const dashboardPanelActions = getService('dashboardPanelActions'); + + let existingDashboardPanelCount = 0; + const dashboardTitle = 'few panels'; + const unsavedDashboardTitle = 'New Dashboard'; + const newDashboartTitle = 'A Wild Dashboard'; + + describe('dashboard unsaved listing', () => { + const addSomePanels = async () => { + // add an area chart by value + await dashboardAddPanel.clickCreateNewLink(); + await PageObjects.visualize.clickAggBasedVisualizations(); + await PageObjects.visualize.clickAreaChart(); + await PageObjects.visualize.clickNewSearch(); + await PageObjects.visualize.saveVisualizationAndReturn(); + + // add a metric by reference + await dashboardAddPanel.addVisualization('Rendering-Test: metric'); + }; + + before(async () => { + await esArchiver.load('dashboard/current/kibana'); + await kibanaServer.uiSettings.replace({ + defaultIndex: '0bf35f60-3dc9-11e8-8660-4d65aa086b3c', + }); + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.preserveCrossAppState(); + }); + + it('lists unsaved changes to existing dashboards', async () => { + await PageObjects.dashboard.loadSavedDashboard(dashboardTitle); + await PageObjects.dashboard.switchToEditMode(); + await addSomePanels(); + existingDashboardPanelCount = await PageObjects.dashboard.getPanelCount(); + await PageObjects.dashboard.gotoDashboardLandingPage(); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.dashboard.expectUnsavedChangesListingExists(dashboardTitle); + }); + + it('restores unsaved changes to existing dashboards', async () => { + await PageObjects.dashboard.clickUnsavedChangesContinueEditing(dashboardTitle); + await PageObjects.header.waitUntilLoadingHasFinished(); + const currentPanelCount = await PageObjects.dashboard.getPanelCount(); + expect(currentPanelCount).to.eql(existingDashboardPanelCount); + await PageObjects.dashboard.gotoDashboardLandingPage(); + await PageObjects.header.waitUntilLoadingHasFinished(); + }); + + it('lists unsaved changes to new dashboards', async () => { + await PageObjects.dashboard.clickNewDashboard(); + await addSomePanels(); + await PageObjects.dashboard.gotoDashboardLandingPage(); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.dashboard.expectUnsavedChangesListingExists(unsavedDashboardTitle); + }); + + it('restores unsaved changes to new dashboards', async () => { + await PageObjects.dashboard.clickUnsavedChangesContinueEditing(unsavedDashboardTitle); + await PageObjects.header.waitUntilLoadingHasFinished(); + expect(await PageObjects.dashboard.getPanelCount()).to.eql(2); + await PageObjects.dashboard.gotoDashboardLandingPage(); + await PageObjects.header.waitUntilLoadingHasFinished(); + }); + + it('shows a warning on create new, and restores panels if continue is selected', async () => { + await PageObjects.dashboard.clickNewDashboardExpectWarning(true); + await PageObjects.header.waitUntilLoadingHasFinished(); + expect(await PageObjects.dashboard.getPanelCount()).to.eql(2); + await PageObjects.dashboard.gotoDashboardLandingPage(); + await PageObjects.header.waitUntilLoadingHasFinished(); + }); + + it('shows a warning on create new, and clears unsaved panels if discard is selected', async () => { + await PageObjects.dashboard.clickNewDashboardExpectWarning(); + await PageObjects.header.waitUntilLoadingHasFinished(); + expect(await PageObjects.dashboard.getPanelCount()).to.eql(0); + await PageObjects.dashboard.gotoDashboardLandingPage(); + await PageObjects.header.waitUntilLoadingHasFinished(); + }); + + it('does not show unsaved changes on new dashboard when no panels have been added', async () => { + await PageObjects.dashboard.expectUnsavedChangesDoesNotExist(unsavedDashboardTitle); + }); + + it('can discard unsaved changes using the discard link', async () => { + await PageObjects.dashboard.clickUnsavedChangesDiscard(dashboardTitle); + await PageObjects.dashboard.expectUnsavedChangesDoesNotExist(dashboardTitle); + await PageObjects.dashboard.loadSavedDashboard(dashboardTitle); + await PageObjects.dashboard.switchToEditMode(); + const currentPanelCount = await PageObjects.dashboard.getPanelCount(); + expect(currentPanelCount).to.eql(existingDashboardPanelCount - 2); + await PageObjects.dashboard.gotoDashboardLandingPage(); + await PageObjects.header.waitUntilLoadingHasFinished(); + }); + + it('loses unsaved changes to new dashboard upon saving', async () => { + await PageObjects.dashboard.clickNewDashboard(); + await addSomePanels(); + + // ensure that the unsaved listing exists first + await PageObjects.dashboard.gotoDashboardLandingPage(); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.dashboard.clickUnsavedChangesContinueEditing(unsavedDashboardTitle); + await PageObjects.header.waitUntilLoadingHasFinished(); + + // Save the dashboard, and check that it now does not exist + await PageObjects.dashboard.saveDashboard(newDashboartTitle); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.dashboard.gotoDashboardLandingPage(); + await PageObjects.dashboard.expectUnsavedChangesDoesNotExist(unsavedDashboardTitle); + }); + + it('does not list unsaved changes when unsaved version of the dashboard is the same', async () => { + await PageObjects.dashboard.loadSavedDashboard(newDashboartTitle); + await PageObjects.dashboard.switchToEditMode(); + + // add another panel so we can delete it later + await dashboardAddPanel.clickCreateNewLink(); + await PageObjects.visualize.clickAggBasedVisualizations(); + await PageObjects.visualize.clickAreaChart(); + await PageObjects.visualize.clickNewSearch(); + await PageObjects.visualize.saveVisualizationExpectSuccess('Wildvis', { + redirectToOrigin: true, + }); + + // ensure that the unsaved listing exists + await PageObjects.dashboard.gotoDashboardLandingPage(); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.dashboard.expectUnsavedChangesListingExists(newDashboartTitle); + await PageObjects.dashboard.clickUnsavedChangesContinueEditing(newDashboartTitle); + await PageObjects.header.waitUntilLoadingHasFinished(); + + // Remove the panel that was just added + await dashboardPanelActions.removePanelByTitle('Wildvis'); + await PageObjects.header.waitUntilLoadingHasFinished(); + + // Check that it now does not exist + await PageObjects.dashboard.gotoDashboardLandingPage(); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.dashboard.expectUnsavedChangesDoesNotExist(newDashboartTitle); + }); + }); +} diff --git a/test/functional/apps/dashboard/dashboard_unsaved_state.ts b/test/functional/apps/dashboard/dashboard_unsaved_state.ts new file mode 100644 index 0000000000000..21ff5be925db5 --- /dev/null +++ b/test/functional/apps/dashboard/dashboard_unsaved_state.ts @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import expect from '@kbn/expect'; + +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const PageObjects = getPageObjects(['dashboard', 'header', 'visualize', 'settings', 'common']); + const esArchiver = getService('esArchiver'); + const kibanaServer = getService('kibanaServer'); + const dashboardAddPanel = getService('dashboardAddPanel'); + + let originalPanelCount = 0; + let unsavedPanelCount = 0; + + describe('dashboard unsaved panels', () => { + before(async () => { + await esArchiver.load('dashboard/current/kibana'); + await kibanaServer.uiSettings.replace({ + defaultIndex: '0bf35f60-3dc9-11e8-8660-4d65aa086b3c', + }); + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.preserveCrossAppState(); + await PageObjects.dashboard.loadSavedDashboard('few panels'); + await PageObjects.dashboard.switchToEditMode(); + + originalPanelCount = await PageObjects.dashboard.getPanelCount(); + + // add an area chart by value + await dashboardAddPanel.clickCreateNewLink(); + await PageObjects.visualize.clickAggBasedVisualizations(); + await PageObjects.visualize.clickAreaChart(); + await PageObjects.visualize.clickNewSearch(); + await PageObjects.visualize.saveVisualizationAndReturn(); + + // add a metric by reference + await dashboardAddPanel.addVisualization('Rendering-Test: metric'); + }); + + it('has correct number of panels', async () => { + unsavedPanelCount = await PageObjects.dashboard.getPanelCount(); + expect(unsavedPanelCount).to.eql(originalPanelCount + 2); + }); + + it('retains unsaved panel count after navigating to listing page and back', async () => { + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.dashboard.gotoDashboardLandingPage(); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.dashboard.loadSavedDashboard('few panels'); + await PageObjects.dashboard.switchToEditMode(); + const currentPanelCount = await PageObjects.dashboard.getPanelCount(); + expect(currentPanelCount).to.eql(unsavedPanelCount); + }); + + it('retains unsaved panel count after navigating to another app and back', async () => { + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.visualize.gotoVisualizationLandingPage(); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.common.navigateToApp('dashboards'); + await PageObjects.dashboard.loadSavedDashboard('few panels'); + await PageObjects.dashboard.switchToEditMode(); + const currentPanelCount = await PageObjects.dashboard.getPanelCount(); + expect(currentPanelCount).to.eql(unsavedPanelCount); + }); + + it('resets to original panel count upon entering view mode', async () => { + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.dashboard.clickCancelOutOfEditMode(); + const currentPanelCount = await PageObjects.dashboard.getPanelCount(); + expect(currentPanelCount).to.eql(originalPanelCount); + }); + + it('retains unsaved panel count after returning to edit mode', async () => { + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.dashboard.switchToEditMode(); + const currentPanelCount = await PageObjects.dashboard.getPanelCount(); + expect(currentPanelCount).to.eql(unsavedPanelCount); + }); + }); +} diff --git a/test/functional/apps/dashboard/index.ts b/test/functional/apps/dashboard/index.ts index 86b12da791752..29f4180019b11 100644 --- a/test/functional/apps/dashboard/index.ts +++ b/test/functional/apps/dashboard/index.ts @@ -46,6 +46,8 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./embeddable_data_grid')); loadTestFile(require.resolve('./create_and_add_embeddables')); loadTestFile(require.resolve('./edit_embeddable_redirects')); + loadTestFile(require.resolve('./dashboard_unsaved_state')); + loadTestFile(require.resolve('./dashboard_unsaved_listing')); loadTestFile(require.resolve('./edit_visualizations')); loadTestFile(require.resolve('./time_zones')); loadTestFile(require.resolve('./dashboard_options')); diff --git a/test/functional/apps/dashboard/panel_context_menu.ts b/test/functional/apps/dashboard/panel_context_menu.ts index 17072731555bd..0f1577ca49188 100644 --- a/test/functional/apps/dashboard/panel_context_menu.ts +++ b/test/functional/apps/dashboard/panel_context_menu.ts @@ -105,6 +105,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.header.waitUntilLoadingHasFinished(); await PageObjects.header.clickDashboard(); + // The following tests require a fresh dashboard. + await PageObjects.dashboard.gotoDashboardLandingPage(); + await PageObjects.dashboard.clickNewDashboard(); + const inViewMode = await PageObjects.dashboard.getIsInViewMode(); if (inViewMode) await PageObjects.dashboard.switchToEditMode(); await dashboardAddPanel.addSavedSearch(searchName); @@ -140,7 +144,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { before('and add one panel and save to put dashboard in "view" mode', async () => { await dashboardAddPanel.addVisualization(PIE_CHART_VIS_NAME); - await PageObjects.dashboard.saveDashboard(dashboardName); + await PageObjects.dashboard.saveDashboard(dashboardName + '2'); }); before('expand panel to "full screen"', async () => { diff --git a/test/functional/apps/dashboard/view_edit.ts b/test/functional/apps/dashboard/view_edit.ts index 17b7e786bff0b..5dae3b708268c 100644 --- a/test/functional/apps/dashboard/view_edit.ts +++ b/test/functional/apps/dashboard/view_edit.ts @@ -72,7 +72,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { 'Sep 19, 2013 @ 06:31:44.000', 'Sep 19, 2013 @ 06:31:44.000' ); - await PageObjects.dashboard.clickCancelOutOfEditMode(); + await PageObjects.dashboard.clickDiscardChanges(); // confirm lose changes await PageObjects.common.clickConfirmOnModal(); @@ -88,7 +88,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await queryBar.setQuery(`${originalQuery}and extra stuff`); await queryBar.submitQuery(); - await PageObjects.dashboard.clickCancelOutOfEditMode(); + await PageObjects.dashboard.clickDiscardChanges(); // confirm lose changes await PageObjects.common.clickConfirmOnModal(); @@ -111,7 +111,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { hasFilter = await filterBar.hasFilter('animal', 'dog'); expect(hasFilter).to.be(false); - await PageObjects.dashboard.clickCancelOutOfEditMode(); + await PageObjects.dashboard.clickDiscardChanges(); // confirm lose changes await PageObjects.common.clickConfirmOnModal(); @@ -133,9 +133,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { redirectToOrigin: true, }); - await PageObjects.dashboard.clickCancelOutOfEditMode(); - - // confirm lose changes + await PageObjects.dashboard.clickDiscardChanges(); await PageObjects.common.clickConfirmOnModal(); const panelCount = await PageObjects.dashboard.getPanelCount(); @@ -146,7 +144,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const originalPanelCount = await PageObjects.dashboard.getPanelCount(); await dashboardAddPanel.addVisualization('new viz panel'); - await PageObjects.dashboard.clickCancelOutOfEditMode(); + await PageObjects.dashboard.clickDiscardChanges(); // confirm lose changes await PageObjects.common.clickConfirmOnModal(); @@ -169,7 +167,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { 'Sep 19, 2015 @ 06:31:44.000', 'Sep 19, 2015 @ 06:31:44.000' ); - await PageObjects.dashboard.clickCancelOutOfEditMode(); + await PageObjects.dashboard.clickDiscardChanges(); await PageObjects.common.clickCancelOnModal(); await PageObjects.dashboard.saveDashboard(dashboardName, { @@ -198,7 +196,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { ); const newTime = await PageObjects.timePicker.getTimeConfig(); - await PageObjects.dashboard.clickCancelOutOfEditMode(); + await PageObjects.dashboard.clickDiscardChanges(); await PageObjects.common.clickCancelOnModal(); await PageObjects.dashboard.saveDashboard(dashboardName, { storeTimeWithDashboard: true }); diff --git a/test/functional/page_objects/dashboard_page.ts b/test/functional/page_objects/dashboard_page.ts index 7be8603887bdf..c115100653729 100644 --- a/test/functional/page_objects/dashboard_page.ts +++ b/test/functional/page_objects/dashboard_page.ts @@ -111,6 +111,33 @@ export function DashboardPageProvider({ getService, getPageObjects }: FtrProvide return id; } + public async expectUnsavedChangesListingExists(title: string) { + log.debug(`Expect Unsaved Changes Listing Exists for `, title); + await testSubjects.existOrFail(`edit-unsaved-${title.split(' ').join('-')}`); + } + + public async expectUnsavedChangesDoesNotExist(title: string) { + log.debug(`Expect Unsaved Changes Listing Does Not Exist for `, title); + await testSubjects.missingOrFail(`edit-unsaved-${title.split(' ').join('-')}`); + } + + public async clickUnsavedChangesContinueEditing(title: string) { + log.debug(`Click Unsaved Changes Continue Editing `, title); + await testSubjects.existOrFail(`edit-unsaved-${title.split(' ').join('-')}`); + await testSubjects.click(`edit-unsaved-${title.split(' ').join('-')}`); + } + + public async clickUnsavedChangesDiscard(title: string, confirmDiscard = true) { + log.debug(`Click Unsaved Changes Discard for `, title); + await testSubjects.existOrFail(`discard-unsaved-${title.split(' ').join('-')}`); + await testSubjects.click(`discard-unsaved-${title.split(' ').join('-')}`); + if (confirmDiscard) { + await PageObjects.common.clickConfirmOnModal(); + } else { + await PageObjects.common.clickCancelOnModal(); + } + } + /** * Returns true if already on the dashboard landing page (that page doesn't have a link to itself). * @returns {Promise} @@ -216,8 +243,32 @@ export function DashboardPageProvider({ getService, getPageObjects }: FtrProvide await testSubjects.click('dashboardViewOnlyMode'); } - public async clickNewDashboard() { + public async clickDiscardChanges() { + log.debug('clickDiscardChanges'); + await testSubjects.click('dashboardDiscardChanges'); + } + + public async clickNewDashboard(continueEditing = false) { + await listingTable.clickNewButton('createDashboardPromptButton'); + if (await testSubjects.exists('dashboardCreateConfirm')) { + if (continueEditing) { + await testSubjects.click('dashboardCreateConfirmContinue'); + } else { + await testSubjects.click('dashboardCreateConfirmStartOver'); + } + } + // make sure the dashboard page is shown + await this.waitForRenderComplete(); + } + + public async clickNewDashboardExpectWarning(continueEditing = false) { await listingTable.clickNewButton('createDashboardPromptButton'); + await testSubjects.existOrFail('dashboardCreateConfirm'); + if (continueEditing) { + await testSubjects.click('dashboardCreateConfirmContinue'); + } else { + await testSubjects.click('dashboardCreateConfirmStartOver'); + } // make sure the dashboard page is shown await this.waitForRenderComplete(); } diff --git a/test/functional/services/dashboard/panel_actions.ts b/test/functional/services/dashboard/panel_actions.ts index d5bd4ab566883..ad6a70c2c97e5 100644 --- a/test/functional/services/dashboard/panel_actions.ts +++ b/test/functional/services/dashboard/panel_actions.ts @@ -99,9 +99,9 @@ export function DashboardPanelActionsProvider({ getService, getPageObjects }: Ft await testSubjects.click(TOGGLE_EXPAND_PANEL_DATA_TEST_SUBJ); } - async removePanel() { + async removePanel(parent?: WebElementWrapper) { log.debug('removePanel'); - await this.openContextMenu(); + await this.openContextMenu(parent); const isActionVisible = await testSubjects.exists(REMOVE_PANEL_DATA_TEST_SUBJ); if (!isActionVisible) await this.clickContextMenuMoreItem(); const isPanelActionVisible = await testSubjects.exists(REMOVE_PANEL_DATA_TEST_SUBJ); @@ -111,10 +111,8 @@ export function DashboardPanelActionsProvider({ getService, getPageObjects }: Ft async removePanelByTitle(title: string) { const header = await this.getPanelHeading(title); - await this.openContextMenu(header); - const isActionVisible = await testSubjects.exists(REMOVE_PANEL_DATA_TEST_SUBJ); - if (!isActionVisible) await this.clickContextMenuMoreItem(); - await testSubjects.click(REMOVE_PANEL_DATA_TEST_SUBJ); + log.debug('found header? ', Boolean(header)); + await this.removePanel(header); } async customizePanel(parent?: WebElementWrapper) { diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 1fc8314417585..cbad6e1c3cc5d 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -662,9 +662,9 @@ "dashboard.topNav.showCloneModal.dashboardCopyTitle": "{title} 副本", "dashboard.topNave.addButtonAriaLabel": "库", "dashboard.topNave.addConfigDescription": "将现有可视化添加到仪表板", + "dashboard.topNave.cancelButtonAriaLabel": "取消", "dashboard.topNave.addNewButtonAriaLabel": "创建面板", "dashboard.topNave.addNewConfigDescription": "在此仪表板上创建新的面板", - "dashboard.topNave.cancelButtonAriaLabel": "取消", "dashboard.topNave.cloneButtonAriaLabel": "克隆", "dashboard.topNave.cloneConfigDescription": "创建仪表板的副本", "dashboard.topNave.editButtonAriaLabel": "编辑",