mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
[Time to Visualize] Remove Panels from URL (#86939)
Removed panels from dashboard URLs Co-authored-by: Ryan Keairns <contactryank@gmail.com>
This commit is contained in:
parent
9996923309
commit
9232a5a26a
36 changed files with 1270 additions and 189 deletions
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -41,6 +41,7 @@ describe('DashboardState', function () {
|
|||
dashboardState = new DashboardStateManager({
|
||||
savedDashboard,
|
||||
hideWriteControls: false,
|
||||
allowByValueEmbeddables: false,
|
||||
kibanaVersion: '7.0.0',
|
||||
kbnUrlStateStorage: createKbnUrlStateStorage(),
|
||||
history: createBrowserHistory(),
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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' });
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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,
|
||||
]);
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -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(
|
||||
<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',
|
||||
}
|
||||
);
|
||||
};
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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 (
|
||||
<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'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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 (
|
||||
<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" />
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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<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,
|
||||
|
|
|
@ -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}
|
||||
*/
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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'];
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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',
|
||||
}),
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
*/
|
||||
|
||||
export {
|
||||
Storage,
|
||||
unhashUrl,
|
||||
syncState,
|
||||
ISyncStateRef,
|
||||
|
|
|
@ -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[];
|
||||
|
|
|
@ -518,6 +518,7 @@ class TableListView extends React.Component<TableListViewProps, TableListViewSta
|
|||
</EuiFlexGroup>
|
||||
|
||||
<EuiSpacer size="m" />
|
||||
{this.props.children}
|
||||
|
||||
{this.renderListingLimitWarning()}
|
||||
{this.renderFetchError()}
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
160
test/functional/apps/dashboard/dashboard_unsaved_listing.ts
Normal file
160
test/functional/apps/dashboard/dashboard_unsaved_listing.ts
Normal 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
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
}
|
86
test/functional/apps/dashboard/dashboard_unsaved_state.ts
Normal file
86
test/functional/apps/dashboard/dashboard_unsaved_state.ts
Normal 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
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
}
|
|
@ -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'));
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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,7 +133,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
|||
redirectToOrigin: true,
|
||||
});
|
||||
|
||||
await PageObjects.dashboard.clickCancelOutOfEditMode();
|
||||
await PageObjects.dashboard.clickDiscardChanges();
|
||||
// for this sleep see https://github.com/elastic/kibana/issues/22299
|
||||
await PageObjects.common.sleep(500);
|
||||
|
||||
|
@ -148,7 +148,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();
|
||||
|
@ -171,7 +171,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, {
|
||||
|
@ -200,7 +200,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 });
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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": "编辑",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue