[7.x] [Time to Visualize] Remove Panels from URL (#86939) (#90247)

Removed panels from dashboard URLs

Co-authored-by: Ryan Keairns <contactryank@gmail.com>
This commit is contained in:
Devon Thomson 2021-02-04 13:32:02 -05:00 committed by GitHub
parent 7478b45ee6
commit c78a5a2645
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
36 changed files with 1270 additions and 191 deletions

View file

@ -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;
}
}

View file

@ -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<DashboardFeatureFlagConfig>()
.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);

View file

@ -41,6 +41,7 @@ describe('DashboardState', function () {
dashboardState = new DashboardStateManager({
savedDashboard,
hideWriteControls: false,
allowByValueEmbeddables: false,
kibanaVersion: '7.0.0',
kbnUrlStateStorage: createKbnUrlStateStorage(),
history: createBrowserHistory(),

View file

@ -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({
savedDashboard,
hideWriteControls,
kibanaVersion,
kbnUrlStateStorage,
history,
kibanaVersion,
savedDashboard,
usageCollection,
hideWriteControls,
kbnUrlStateStorage,
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<DashboardAppState>(STATE_STORAGE_KEY);
const initialState = migrateAppState(
{
...this.stateDefaults,
...this.kbnUrlStateStorage.get<DashboardAppState>(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<DashboardAppState, DashboardAppStateTransitions>(
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<DashboardAppStateInUrl>({
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<DashboardAppState>(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<DashboardAppStateInUrl>(
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<DashboardAppState>(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;
}
}

View file

@ -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' });
},
},
{

View file

@ -52,8 +52,10 @@ export const useDashboardStateManager = (
uiSettings,
usageCollection,
initializerContext,
dashboardCapabilities,
savedObjectsTagging,
dashboardCapabilities,
dashboardPanelStorage,
allowByValueEmbeddables,
} = useKibana<DashboardAppServices>().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,
]);

View file

@ -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;

View file

@ -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
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { 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;
}
}

View file

@ -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';

View file

@ -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
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import {
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(
<EuiModal onClose={() => session.close()}>
<EuiModalHeader data-test-subj="dashboardCreateConfirm">
<EuiModalHeaderTitle>{createConfirmStrings.getCreateTitle()}</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<EuiText>{createConfirmStrings.getCreateSubtitle()}</EuiText>
</EuiModalBody>
<EuiModalFooter>
<EuiButtonEmpty
data-test-subj="dashboardCreateConfirmCancel"
onClick={() => session.close()}
>
{createConfirmStrings.getCancelButtonText()}
</EuiButtonEmpty>
<EuiButtonEmpty
color="danger"
data-test-subj="dashboardCreateConfirmStartOver"
onClick={() => {
startBlankCallback();
session.close();
}}
>
{createConfirmStrings.getStartOverButtonText()}
</EuiButtonEmpty>
<EuiButton
fill
data-test-subj="dashboardCreateConfirmContinue"
onClick={() => {
contineCallback();
session.close();
}}
>
{createConfirmStrings.getContinueButtonText()}
</EuiButton>
</EuiModalFooter>
</EuiModal>
),
{
'data-test-subj': 'dashboardCreateConfirmModal',
}
);
};

View file

@ -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,
};

View file

@ -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<DashboardAppServices>();
@ -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 (
<TableListView
createItem={hideWriteControls ? undefined : () => 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,
}}
/>
>
<DashboardUnsavedListing redirectTo={redirectTo} />
</TableListView>
);
};

View file

@ -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
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { 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 (
<I18nProvider>
<KibanaContextProvider services={services}>{children}</KibanaContextProvider>
</I18nProvider>
);
};
const component = mount(<DashboardUnsavedListing {...props} />, { 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'
);
});
});
});

View file

@ -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
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import {
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 (
<div className="dshUnsavedListingItem">
<EuiFlexGroup
alignItems="center"
gutterSize="none"
className="dshUnsavedListingItem__heading"
responsive={false}
>
<EuiFlexItem grow={false}>
<EuiIcon
color="text"
className="dshUnsavedListingItem__icon"
type={title ? 'dashboardApp' : 'clock'}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiTitle size="xxs">
<h4
className={`dshUnsavedListingItem__title ${
title ? '' : 'dshUnsavedListingItem__loading'
}`}
>
{title || dashboardUnsavedListingStrings.getLoadingTitle()}
</h4>
</EuiTitle>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexGroup
alignItems="flexStart"
gutterSize="none"
className="dshUnsavedListingItem__actions"
responsive={false}
>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
flush="left"
size="s"
color="primary"
disabled={!title}
onClick={onOpenClick}
data-test-subj={title ? `edit-unsaved-${title.split(' ').join('-')}` : undefined}
aria-label={dashboardUnsavedListingStrings.getEditAriaLabel(title ?? id)}
>
{dashboardUnsavedListingStrings.getEditTitle()}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
flush="left"
size="s"
color="danger"
disabled={!title}
onClick={onDiscardClick}
data-test-subj={title ? `discard-unsaved-${title.split(' ').join('-')}` : undefined}
aria-label={dashboardUnsavedListingStrings.getDiscardAriaLabel(title ?? id)}
>
{dashboardUnsavedListingStrings.getDiscardTitle()}
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
</div>
);
};
interface UnsavedItemMap {
[key: string]: DashboardSavedObject;
}
export const DashboardUnsavedListing = ({ redirectTo }: { redirectTo: DashboardRedirect }) => {
const {
services: {
dashboardPanelStorage,
savedDashboards,
core: { overlays },
},
} = useKibana<DashboardAppServices>();
const [items, setItems] = useState<UnsavedItemMap>({});
const [dashboardIds, setDashboardIds] = useState<string[]>(
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 : (
<>
<EuiCallOut
heading="h3"
title={dashboardUnsavedListingStrings.getUnsavedChangesTitle(dashboardIds.length > 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 (
<DashboardUnsavedItem
key={dashboardId}
id={dashboardId}
title={title}
onOpenClick={() => onOpen(redirectId)}
onDiscardClick={() => onDiscard(redirectId)}
/>
);
})}
</EuiCallOut>
<EuiSpacer size="m" />
</>
);
};

View file

@ -6,8 +6,6 @@
* Side 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<DashboardAppServices>().services;
const [state, setState] = useState<DashboardTopNavState>({ 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,

View file

@ -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}
*/

View file

@ -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',

View file

@ -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'];

View file

@ -6,6 +6,8 @@
* Side 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) {

View file

@ -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',
}),
};

View file

@ -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,
});
},

View file

@ -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

View file

@ -7,6 +7,7 @@
*/
export {
Storage,
unhashUrl,
syncState,
ISyncStateRef,

View file

@ -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<DashboardAppState, 'panels'> & {
panels?: SavedDashboardPanel[];

View file

@ -518,6 +518,7 @@ class TableListView extends React.Component<TableListViewProps, TableListViewSta
</EuiFlexGroup>
<EuiSpacer size="m" />
{this.props.children}
{this.renderListingLimitWarning()}
{this.renderFetchError()}

View file

@ -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();
});

View file

@ -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 populateDashboard();
await addFilterAndRefresh();
});
await dashboardAddPanel.closeAddPanel();
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();

View file

@ -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);
});

View file

@ -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
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import 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);
});
});
}

View file

@ -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
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import 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);
});
});
}

View file

@ -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'));

View file

@ -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 () => {

View file

@ -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 });

View file

@ -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<boolean>}
@ -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();
}

View file

@ -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) {

View file

@ -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": "编辑",