Removed panels from dashboard URLs Co-authored-by: Ryan Keairns <contactryank@gmail.com>
This commit is contained in:
parent
7478b45ee6
commit
c78a5a2645
36 changed files with 1270 additions and 191 deletions
|
@ -33,3 +33,37 @@
|
||||||
margin-left: $euiSizeS;
|
margin-left: $euiSizeS;
|
||||||
text-align: center;
|
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 { DashboardListing } from './listing';
|
||||||
import { DashboardApp } from './dashboard_app';
|
import { DashboardApp } from './dashboard_app';
|
||||||
import { addHelpMenuToAppChrome } from './lib';
|
import { addHelpMenuToAppChrome, DashboardPanelStorage } from './lib';
|
||||||
import { createDashboardListingFilterUrl } from '../dashboard_constants';
|
import { createDashboardListingFilterUrl } from '../dashboard_constants';
|
||||||
import { getDashboardPageTitle, dashboardReadonlyBadge } from '../dashboard_strings';
|
import { getDashboardPageTitle, dashboardReadonlyBadge } from '../dashboard_strings';
|
||||||
import { createDashboardEditUrl, DashboardConstants } from '../dashboard_constants';
|
import { createDashboardEditUrl, DashboardConstants } from '../dashboard_constants';
|
||||||
import { DashboardAppServices, DashboardEmbedSettings, RedirectToProps } from './types';
|
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 { createKbnUrlStateStorage, withNotifyOnErrors } from '../services/kibana_utils';
|
||||||
import { KibanaContextProvider } from '../services/kibana_react';
|
import { KibanaContextProvider } from '../services/kibana_react';
|
||||||
|
@ -94,8 +99,11 @@ export async function mountApp({
|
||||||
indexPatterns: dataStart.indexPatterns,
|
indexPatterns: dataStart.indexPatterns,
|
||||||
savedQueryService: dataStart.query.savedQueries,
|
savedQueryService: dataStart.query.savedQueries,
|
||||||
savedObjectsClient: coreStart.savedObjects.client,
|
savedObjectsClient: coreStart.savedObjects.client,
|
||||||
|
dashboardPanelStorage: new DashboardPanelStorage(core.notifications.toasts),
|
||||||
savedDashboards: dashboardStart.getSavedDashboardLoader(),
|
savedDashboards: dashboardStart.getSavedDashboardLoader(),
|
||||||
savedObjectsTagging: savedObjectsTaggingOss?.getTaggingApi(),
|
savedObjectsTagging: savedObjectsTaggingOss?.getTaggingApi(),
|
||||||
|
allowByValueEmbeddables: initializerContext.config.get<DashboardFeatureFlagConfig>()
|
||||||
|
.allowByValueEmbeddables,
|
||||||
dashboardCapabilities: {
|
dashboardCapabilities: {
|
||||||
hideWriteControls: dashboardConfig.getHideWriteControls(),
|
hideWriteControls: dashboardConfig.getHideWriteControls(),
|
||||||
show: Boolean(coreStart.application.capabilities.dashboard.show),
|
show: Boolean(coreStart.application.capabilities.dashboard.show),
|
||||||
|
@ -122,7 +130,7 @@ export async function mountApp({
|
||||||
let destination;
|
let destination;
|
||||||
if (redirectTo.destination === 'dashboard') {
|
if (redirectTo.destination === 'dashboard') {
|
||||||
destination = redirectTo.id
|
destination = redirectTo.id
|
||||||
? createDashboardEditUrl(redirectTo.id)
|
? createDashboardEditUrl(redirectTo.id, redirectTo.editMode)
|
||||||
: DashboardConstants.CREATE_NEW_DASHBOARD_URL;
|
: DashboardConstants.CREATE_NEW_DASHBOARD_URL;
|
||||||
} else {
|
} else {
|
||||||
destination = createDashboardListingFilterUrl(redirectTo.filter);
|
destination = createDashboardListingFilterUrl(redirectTo.filter);
|
||||||
|
|
|
@ -41,6 +41,7 @@ describe('DashboardState', function () {
|
||||||
dashboardState = new DashboardStateManager({
|
dashboardState = new DashboardStateManager({
|
||||||
savedDashboard,
|
savedDashboard,
|
||||||
hideWriteControls: false,
|
hideWriteControls: false,
|
||||||
|
allowByValueEmbeddables: false,
|
||||||
kibanaVersion: '7.0.0',
|
kibanaVersion: '7.0.0',
|
||||||
kbnUrlStateStorage: createKbnUrlStateStorage(),
|
kbnUrlStateStorage: createKbnUrlStateStorage(),
|
||||||
history: createBrowserHistory(),
|
history: createBrowserHistory(),
|
||||||
|
|
|
@ -16,7 +16,12 @@ import { FilterUtils } from './lib/filter_utils';
|
||||||
import { DashboardContainer } from './embeddable';
|
import { DashboardContainer } from './embeddable';
|
||||||
import { DashboardSavedObject } from '../saved_dashboards';
|
import { DashboardSavedObject } from '../saved_dashboards';
|
||||||
import { migrateLegacyQuery } from './lib/migrate_legacy_query';
|
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 { convertPanelStateToSavedDashboardPanel } from '../../common/embeddable/embeddable_saved_object_converters';
|
||||||
import {
|
import {
|
||||||
DashboardAppState,
|
DashboardAppState,
|
||||||
|
@ -37,6 +42,7 @@ import {
|
||||||
ReduxLikeStateContainer,
|
ReduxLikeStateContainer,
|
||||||
syncState,
|
syncState,
|
||||||
} from '../services/kibana_utils';
|
} 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
|
* 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
|
DashboardAppStateTransitions
|
||||||
>;
|
>;
|
||||||
private readonly stateContainerChangeSub: Subscription;
|
private readonly stateContainerChangeSub: Subscription;
|
||||||
private readonly STATE_STORAGE_KEY = '_a';
|
private readonly dashboardPanelStorage?: DashboardPanelStorage;
|
||||||
public readonly kbnUrlStateStorage: IKbnUrlStateStorage;
|
public readonly kbnUrlStateStorage: IKbnUrlStateStorage;
|
||||||
private readonly stateSyncRef: ISyncStateRef;
|
private readonly stateSyncRef: ISyncStateRef;
|
||||||
private readonly history: History;
|
private readonly allowByValueEmbeddables: boolean;
|
||||||
|
|
||||||
private readonly usageCollection: UsageCollectionSetup | undefined;
|
private readonly usageCollection: UsageCollectionSetup | undefined;
|
||||||
public readonly hasTaggingCapabilities: SavedObjectTagDecoratorTypeGuard;
|
public readonly hasTaggingCapabilities: SavedObjectTagDecoratorTypeGuard;
|
||||||
|
|
||||||
|
@ -86,28 +93,32 @@ export class DashboardStateManager {
|
||||||
* @param
|
* @param
|
||||||
*/
|
*/
|
||||||
constructor({
|
constructor({
|
||||||
savedDashboard,
|
|
||||||
hideWriteControls,
|
|
||||||
kibanaVersion,
|
|
||||||
kbnUrlStateStorage,
|
|
||||||
history,
|
history,
|
||||||
|
kibanaVersion,
|
||||||
|
savedDashboard,
|
||||||
usageCollection,
|
usageCollection,
|
||||||
|
hideWriteControls,
|
||||||
|
kbnUrlStateStorage,
|
||||||
|
dashboardPanelStorage,
|
||||||
hasTaggingCapabilities,
|
hasTaggingCapabilities,
|
||||||
|
allowByValueEmbeddables,
|
||||||
}: {
|
}: {
|
||||||
savedDashboard: DashboardSavedObject;
|
|
||||||
hideWriteControls: boolean;
|
|
||||||
kibanaVersion: string;
|
|
||||||
kbnUrlStateStorage: IKbnUrlStateStorage;
|
|
||||||
history: History;
|
history: History;
|
||||||
|
kibanaVersion: string;
|
||||||
|
hideWriteControls: boolean;
|
||||||
|
allowByValueEmbeddables: boolean;
|
||||||
|
savedDashboard: DashboardSavedObject;
|
||||||
usageCollection?: UsageCollectionSetup;
|
usageCollection?: UsageCollectionSetup;
|
||||||
|
kbnUrlStateStorage: IKbnUrlStateStorage;
|
||||||
|
dashboardPanelStorage?: DashboardPanelStorage;
|
||||||
hasTaggingCapabilities: SavedObjectTagDecoratorTypeGuard;
|
hasTaggingCapabilities: SavedObjectTagDecoratorTypeGuard;
|
||||||
}) {
|
}) {
|
||||||
this.history = history;
|
|
||||||
this.kibanaVersion = kibanaVersion;
|
this.kibanaVersion = kibanaVersion;
|
||||||
this.savedDashboard = savedDashboard;
|
this.savedDashboard = savedDashboard;
|
||||||
this.hideWriteControls = hideWriteControls;
|
this.hideWriteControls = hideWriteControls;
|
||||||
this.usageCollection = usageCollection;
|
this.usageCollection = usageCollection;
|
||||||
this.hasTaggingCapabilities = hasTaggingCapabilities;
|
this.hasTaggingCapabilities = hasTaggingCapabilities;
|
||||||
|
this.allowByValueEmbeddables = allowByValueEmbeddables;
|
||||||
|
|
||||||
// get state defaults from saved dashboard, make sure it is migrated
|
// get state defaults from saved dashboard, make sure it is migrated
|
||||||
this.stateDefaults = migrateAppState(
|
this.stateDefaults = migrateAppState(
|
||||||
|
@ -115,20 +126,29 @@ export class DashboardStateManager {
|
||||||
kibanaVersion,
|
kibanaVersion,
|
||||||
usageCollection
|
usageCollection
|
||||||
);
|
);
|
||||||
|
this.dashboardPanelStorage = dashboardPanelStorage;
|
||||||
this.kbnUrlStateStorage = kbnUrlStateStorage;
|
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
|
// also run migration, as state in url could be of older version
|
||||||
|
const initialUrlState = this.kbnUrlStateStorage.get<DashboardAppState>(STATE_STORAGE_KEY);
|
||||||
const initialState = migrateAppState(
|
const initialState = migrateAppState(
|
||||||
{
|
{
|
||||||
...this.stateDefaults,
|
...this.stateDefaults,
|
||||||
...this.kbnUrlStateStorage.get<DashboardAppState>(this.STATE_STORAGE_KEY),
|
...this.getUnsavedPanelState(),
|
||||||
|
...initialUrlState,
|
||||||
},
|
},
|
||||||
kibanaVersion,
|
kibanaVersion,
|
||||||
usageCollection
|
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
|
// setup state container using initial state both from defaults and from url
|
||||||
this.stateContainer = createStateContainer<DashboardAppState, DashboardAppStateTransitions>(
|
this.stateContainer = createStateContainer<DashboardAppState, DashboardAppStateTransitions>(
|
||||||
initialState,
|
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
|
// 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
|
// 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
|
// 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 }));
|
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>({
|
this.stateSyncRef = syncState<DashboardAppStateInUrl>({
|
||||||
storageKey: this.STATE_STORAGE_KEY,
|
storageKey: STATE_STORAGE_KEY,
|
||||||
stateContainer: {
|
stateContainer: {
|
||||||
...this.stateContainer,
|
...this.stateContainer,
|
||||||
get: () => this.toUrlState(this.stateContainer.get()),
|
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
|
// sync state required state container to be able to handle null
|
||||||
// overriding set() so it could handle null coming from url
|
// 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
|
// 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,
|
// As dashboard is driven by angular at the moment, the destroy cycle happens async,
|
||||||
// If the dashboardId has changed it means this instance
|
// If the dashboardId has changed it means this instance
|
||||||
|
@ -177,9 +195,15 @@ export class DashboardStateManager {
|
||||||
const currentDashboardIdInUrl = getDashboardIdFromUrl(history.location.pathname);
|
const currentDashboardIdInUrl = getDashboardIdFromUrl(history.location.pathname);
|
||||||
if (currentDashboardIdInUrl !== this.savedDashboard.id) return;
|
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.stateContainer.set({
|
||||||
...this.stateDefaults,
|
...this.stateDefaults,
|
||||||
...state,
|
...this.getUnsavedPanelState(),
|
||||||
|
...stateFromUrl,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Do nothing in case when state from url is empty,
|
// Do nothing in case when state from url is empty,
|
||||||
|
@ -261,6 +285,13 @@ export class DashboardStateManager {
|
||||||
if (dirtyBecauseOfInitialStateMigration) {
|
if (dirtyBecauseOfInitialStateMigration) {
|
||||||
this.saveState({ replace: true });
|
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()) {
|
if (input.isFullScreenMode !== this.getFullScreenMode()) {
|
||||||
|
@ -483,7 +514,16 @@ export class DashboardStateManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
public getViewMode() {
|
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() {
|
public getIsViewMode() {
|
||||||
|
@ -592,29 +632,13 @@ export class DashboardStateManager {
|
||||||
private saveState({ replace }: { replace: boolean }): boolean {
|
private saveState({ replace }: { replace: boolean }): boolean {
|
||||||
// schedules setting current state to url
|
// schedules setting current state to url
|
||||||
this.kbnUrlStateStorage.set<DashboardAppStateInUrl>(
|
this.kbnUrlStateStorage.set<DashboardAppStateInUrl>(
|
||||||
this.STATE_STORAGE_KEY,
|
STATE_STORAGE_KEY,
|
||||||
this.toUrlState(this.stateContainer.get())
|
this.toUrlState(this.stateContainer.get())
|
||||||
);
|
);
|
||||||
// immediately forces scheduled updates and changes location
|
// immediately forces scheduled updates and changes location
|
||||||
return !!this.kbnUrlStateStorage.kbnUrlControls.flush(replace);
|
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) {
|
public setQuery(query: Query) {
|
||||||
this.stateContainer.transitions.set('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() {
|
private checkIsDirty() {
|
||||||
// Filters need to be compared manually because they sometimes have a $$hashkey stored on the object.
|
// 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
|
// 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);
|
const current = _.omit(this.stateContainer.get(), propsToIgnore);
|
||||||
return !_.isEqual(initial, current);
|
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 { useEffect } from 'react';
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import { EUI_MODAL_CANCEL_BUTTON } from '@elastic/eui';
|
|
||||||
|
|
||||||
import { useKibana } from '../../services/kibana_react';
|
import { useKibana } from '../../services/kibana_react';
|
||||||
|
|
||||||
import { DashboardStateManager } from '../dashboard_state_manager';
|
import { DashboardStateManager } from '../dashboard_state_manager';
|
||||||
import {
|
import { getDashboardBreadcrumb, getDashboardTitle } from '../../dashboard_strings';
|
||||||
getDashboardBreadcrumb,
|
|
||||||
getDashboardTitle,
|
|
||||||
leaveConfirmStrings,
|
|
||||||
} from '../../dashboard_strings';
|
|
||||||
import { DashboardAppServices, DashboardRedirect } from '../types';
|
import { DashboardAppServices, DashboardRedirect } from '../types';
|
||||||
|
|
||||||
export const useDashboardBreadcrumbs = (
|
export const useDashboardBreadcrumbs = (
|
||||||
|
@ -38,32 +33,12 @@ export const useDashboardBreadcrumbs = (
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
|
||||||
getConfirmButtonText,
|
|
||||||
getCancelButtonText,
|
|
||||||
getLeaveTitle,
|
|
||||||
getLeaveSubtitle,
|
|
||||||
} = leaveConfirmStrings;
|
|
||||||
|
|
||||||
setBreadcrumbs([
|
setBreadcrumbs([
|
||||||
{
|
{
|
||||||
text: getDashboardBreadcrumb(),
|
text: getDashboardBreadcrumb(),
|
||||||
'data-test-subj': 'dashboardListingBreadcrumb',
|
'data-test-subj': 'dashboardListingBreadcrumb',
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
if (dashboardStateManager.getIsDirty()) {
|
redirectTo({ destination: 'listing' });
|
||||||
openConfirm(getLeaveSubtitle(), {
|
|
||||||
confirmButtonText: getConfirmButtonText(),
|
|
||||||
cancelButtonText: getCancelButtonText(),
|
|
||||||
defaultFocusedButton: EUI_MODAL_CANCEL_BUTTON,
|
|
||||||
title: getLeaveTitle(),
|
|
||||||
}).then((isConfirmed) => {
|
|
||||||
if (isConfirmed) {
|
|
||||||
redirectTo({ destination: 'listing' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
redirectTo({ destination: 'listing' });
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
@ -52,8 +52,10 @@ export const useDashboardStateManager = (
|
||||||
uiSettings,
|
uiSettings,
|
||||||
usageCollection,
|
usageCollection,
|
||||||
initializerContext,
|
initializerContext,
|
||||||
dashboardCapabilities,
|
|
||||||
savedObjectsTagging,
|
savedObjectsTagging,
|
||||||
|
dashboardCapabilities,
|
||||||
|
dashboardPanelStorage,
|
||||||
|
allowByValueEmbeddables,
|
||||||
} = useKibana<DashboardAppServices>().services;
|
} = useKibana<DashboardAppServices>().services;
|
||||||
|
|
||||||
// Destructure and rename services; makes the Effect hook more specific, makes later
|
// Destructure and rename services; makes the Effect hook more specific, makes later
|
||||||
|
@ -86,12 +88,14 @@ export const useDashboardStateManager = (
|
||||||
|
|
||||||
const stateManager = new DashboardStateManager({
|
const stateManager = new DashboardStateManager({
|
||||||
hasTaggingCapabilities,
|
hasTaggingCapabilities,
|
||||||
|
dashboardPanelStorage,
|
||||||
hideWriteControls,
|
hideWriteControls,
|
||||||
history,
|
history,
|
||||||
kbnUrlStateStorage,
|
kbnUrlStateStorage,
|
||||||
kibanaVersion,
|
kibanaVersion,
|
||||||
savedDashboard,
|
savedDashboard,
|
||||||
usageCollection,
|
usageCollection,
|
||||||
|
allowByValueEmbeddables,
|
||||||
});
|
});
|
||||||
|
|
||||||
// sync initial app filters from state to filterManager
|
// sync initial app filters from state to filterManager
|
||||||
|
@ -178,6 +182,10 @@ export const useDashboardStateManager = (
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (stateManager.getIsEditMode()) {
|
||||||
|
stateManager.restorePanels();
|
||||||
|
}
|
||||||
|
|
||||||
setDashboardStateManager(stateManager);
|
setDashboardStateManager(stateManager);
|
||||||
setViewMode(stateManager.getViewMode());
|
setViewMode(stateManager.getViewMode());
|
||||||
|
|
||||||
|
@ -191,6 +199,8 @@ export const useDashboardStateManager = (
|
||||||
dataPlugin,
|
dataPlugin,
|
||||||
filterManager,
|
filterManager,
|
||||||
hasTaggingCapabilities,
|
hasTaggingCapabilities,
|
||||||
|
initializerContext.config,
|
||||||
|
dashboardPanelStorage,
|
||||||
hideWriteControls,
|
hideWriteControls,
|
||||||
history,
|
history,
|
||||||
kibanaVersion,
|
kibanaVersion,
|
||||||
|
@ -202,6 +212,7 @@ export const useDashboardStateManager = (
|
||||||
toasts,
|
toasts,
|
||||||
uiSettings,
|
uiSettings,
|
||||||
usageCollection,
|
usageCollection,
|
||||||
|
allowByValueEmbeddables,
|
||||||
dashboardCapabilities.storeSearchSession,
|
dashboardCapabilities.storeSearchSession,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
|
@ -14,7 +14,7 @@ import { useKibana } from '../../services/kibana_react';
|
||||||
|
|
||||||
import { DashboardConstants } from '../..';
|
import { DashboardConstants } from '../..';
|
||||||
import { DashboardSavedObject } from '../../saved_dashboards';
|
import { DashboardSavedObject } from '../../saved_dashboards';
|
||||||
import { getDashboard60Warning } from '../../dashboard_strings';
|
import { getDashboard60Warning, getNewDashboardTitle } from '../../dashboard_strings';
|
||||||
import { DashboardAppServices } from '../types';
|
import { DashboardAppServices } from '../types';
|
||||||
|
|
||||||
export const useSavedDashboard = (savedDashboardId: string | undefined, history: History) => {
|
export const useSavedDashboard = (savedDashboardId: string | undefined, history: History) => {
|
||||||
|
@ -43,12 +43,7 @@ export const useSavedDashboard = (savedDashboardId: string | undefined, history:
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const dashboard = (await savedDashboards.get(savedDashboardId)) as DashboardSavedObject;
|
const dashboard = (await savedDashboards.get(savedDashboardId)) as DashboardSavedObject;
|
||||||
const { title, getFullPath } = dashboard;
|
docTitle.change(dashboard.title || getNewDashboardTitle());
|
||||||
if (savedDashboardId) {
|
|
||||||
recentlyAccessedPaths.add(getFullPath(), title, savedDashboardId);
|
|
||||||
}
|
|
||||||
|
|
||||||
docTitle.change(title);
|
|
||||||
setSavedDashboard(dashboard);
|
setSavedDashboard(dashboard);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// E.g. a corrupt or deleted dashboard
|
// E.g. a corrupt or deleted dashboard
|
||||||
|
@ -58,13 +53,13 @@ export const useSavedDashboard = (savedDashboardId: string | undefined, history:
|
||||||
})();
|
})();
|
||||||
return () => setSavedDashboard(null);
|
return () => setSavedDashboard(null);
|
||||||
}, [
|
}, [
|
||||||
|
toasts,
|
||||||
docTitle,
|
docTitle,
|
||||||
history,
|
history,
|
||||||
indexPatterns,
|
indexPatterns,
|
||||||
recentlyAccessedPaths,
|
recentlyAccessedPaths,
|
||||||
savedDashboardId,
|
savedDashboardId,
|
||||||
savedDashboards,
|
savedDashboards,
|
||||||
toasts,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return savedDashboard;
|
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
|
||||||
|
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||||
|
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||||
|
* Side Public License, v 1.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Storage } from '../../services/kibana_utils';
|
||||||
|
import { NotificationsStart } from '../../services/core';
|
||||||
|
import { panelStorageErrorStrings } from '../../dashboard_strings';
|
||||||
|
import { SavedDashboardPanel } from '..';
|
||||||
|
|
||||||
|
export const DASHBOARD_PANELS_UNSAVED_ID = 'unsavedDashboard';
|
||||||
|
const DASHBOARD_PANELS_SESSION_KEY = 'dashboardStateManagerPanels';
|
||||||
|
|
||||||
|
export class DashboardPanelStorage {
|
||||||
|
private sessionStorage: Storage;
|
||||||
|
|
||||||
|
constructor(private toasts: NotificationsStart['toasts']) {
|
||||||
|
this.sessionStorage = new Storage(sessionStorage);
|
||||||
|
}
|
||||||
|
|
||||||
|
public clearPanels(id = DASHBOARD_PANELS_UNSAVED_ID) {
|
||||||
|
try {
|
||||||
|
const sessionStoragePanels = this.sessionStorage.get(DASHBOARD_PANELS_SESSION_KEY) || {};
|
||||||
|
if (sessionStoragePanels[id]) {
|
||||||
|
delete sessionStoragePanels[id];
|
||||||
|
this.sessionStorage.set(DASHBOARD_PANELS_SESSION_KEY, sessionStoragePanels);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.toasts.addDanger({
|
||||||
|
title: panelStorageErrorStrings.getPanelsClearError(e.message),
|
||||||
|
'data-test-subj': 'dashboardPanelsClearFailure',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public getPanels(id = DASHBOARD_PANELS_UNSAVED_ID): SavedDashboardPanel[] | undefined {
|
||||||
|
try {
|
||||||
|
return this.sessionStorage.get(DASHBOARD_PANELS_SESSION_KEY)?.[id];
|
||||||
|
} catch (e) {
|
||||||
|
this.toasts.addDanger({
|
||||||
|
title: panelStorageErrorStrings.getPanelsGetError(e.message),
|
||||||
|
'data-test-subj': 'dashboardPanelsGetFailure',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public setPanels(id = DASHBOARD_PANELS_UNSAVED_ID, newPanels: SavedDashboardPanel[]) {
|
||||||
|
try {
|
||||||
|
const sessionStoragePanels = this.sessionStorage.get(DASHBOARD_PANELS_SESSION_KEY) || {};
|
||||||
|
sessionStoragePanels[id] = newPanels;
|
||||||
|
this.sessionStorage.set(DASHBOARD_PANELS_SESSION_KEY, sessionStoragePanels);
|
||||||
|
} catch (e) {
|
||||||
|
this.toasts.addDanger({
|
||||||
|
title: panelStorageErrorStrings.getPanelsSetError(e.message),
|
||||||
|
'data-test-subj': 'dashboardPanelsSetFailure',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public getDashboardIdsWithUnsavedChanges() {
|
||||||
|
try {
|
||||||
|
return Object.keys(this.sessionStorage.get(DASHBOARD_PANELS_SESSION_KEY) || {});
|
||||||
|
} catch (e) {
|
||||||
|
this.toasts.addDanger({
|
||||||
|
title: panelStorageErrorStrings.getPanelsGetError(e.message),
|
||||||
|
'data-test-subj': 'dashboardPanelsGetFailure',
|
||||||
|
});
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public dashboardHasUnsavedEdits(id = DASHBOARD_PANELS_UNSAVED_ID) {
|
||||||
|
return this.getDashboardIdsWithUnsavedChanges().indexOf(id) !== -1;
|
||||||
|
}
|
||||||
|
}
|
|
@ -13,3 +13,4 @@ export { getDashboardIdFromUrl } from './url';
|
||||||
export { createSessionRestorationDataProvider } from './session_restoration';
|
export { createSessionRestorationDataProvider } from './session_restoration';
|
||||||
export { addHelpMenuToAppChrome } from './help_menu_util';
|
export { addHelpMenuToAppChrome } from './help_menu_util';
|
||||||
export { attemptLoadDashboardByTitle } from './load_dashboard_by_title';
|
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
|
||||||
|
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||||
|
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||||
|
* Side Public License, v 1.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
EuiButton,
|
||||||
|
EuiButtonEmpty,
|
||||||
|
EuiModal,
|
||||||
|
EuiModalBody,
|
||||||
|
EuiModalFooter,
|
||||||
|
EuiModalHeader,
|
||||||
|
EuiModalHeaderTitle,
|
||||||
|
EuiText,
|
||||||
|
EUI_MODAL_CANCEL_BUTTON,
|
||||||
|
} from '@elastic/eui';
|
||||||
|
import React from 'react';
|
||||||
|
import { OverlayStart } from '../../../../../core/public';
|
||||||
|
import { createConfirmStrings, leaveConfirmStrings } from '../../dashboard_strings';
|
||||||
|
import { toMountPoint } from '../../services/kibana_react';
|
||||||
|
|
||||||
|
export const confirmDiscardUnsavedChanges = (
|
||||||
|
overlays: OverlayStart,
|
||||||
|
discardCallback: () => void,
|
||||||
|
cancelButtonText = leaveConfirmStrings.getCancelButtonText()
|
||||||
|
) =>
|
||||||
|
overlays
|
||||||
|
.openConfirm(leaveConfirmStrings.getDiscardSubtitle(), {
|
||||||
|
confirmButtonText: leaveConfirmStrings.getConfirmButtonText(),
|
||||||
|
cancelButtonText,
|
||||||
|
buttonColor: 'danger',
|
||||||
|
defaultFocusedButton: EUI_MODAL_CANCEL_BUTTON,
|
||||||
|
title: leaveConfirmStrings.getDiscardTitle(),
|
||||||
|
})
|
||||||
|
.then((isConfirmed) => {
|
||||||
|
if (isConfirmed) {
|
||||||
|
discardCallback();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const confirmCreateWithUnsaved = (
|
||||||
|
overlays: OverlayStart,
|
||||||
|
startBlankCallback: () => void,
|
||||||
|
contineCallback: () => void
|
||||||
|
) => {
|
||||||
|
const session = overlays.openModal(
|
||||||
|
toMountPoint(
|
||||||
|
<EuiModal onClose={() => session.close()}>
|
||||||
|
<EuiModalHeader data-test-subj="dashboardCreateConfirm">
|
||||||
|
<EuiModalHeaderTitle>{createConfirmStrings.getCreateTitle()}</EuiModalHeaderTitle>
|
||||||
|
</EuiModalHeader>
|
||||||
|
|
||||||
|
<EuiModalBody>
|
||||||
|
<EuiText>{createConfirmStrings.getCreateSubtitle()}</EuiText>
|
||||||
|
</EuiModalBody>
|
||||||
|
|
||||||
|
<EuiModalFooter>
|
||||||
|
<EuiButtonEmpty
|
||||||
|
data-test-subj="dashboardCreateConfirmCancel"
|
||||||
|
onClick={() => session.close()}
|
||||||
|
>
|
||||||
|
{createConfirmStrings.getCancelButtonText()}
|
||||||
|
</EuiButtonEmpty>
|
||||||
|
<EuiButtonEmpty
|
||||||
|
color="danger"
|
||||||
|
data-test-subj="dashboardCreateConfirmStartOver"
|
||||||
|
onClick={() => {
|
||||||
|
startBlankCallback();
|
||||||
|
session.close();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{createConfirmStrings.getStartOverButtonText()}
|
||||||
|
</EuiButtonEmpty>
|
||||||
|
<EuiButton
|
||||||
|
fill
|
||||||
|
data-test-subj="dashboardCreateConfirmContinue"
|
||||||
|
onClick={() => {
|
||||||
|
contineCallback();
|
||||||
|
session.close();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{createConfirmStrings.getContinueButtonText()}
|
||||||
|
</EuiButton>
|
||||||
|
</EuiModalFooter>
|
||||||
|
</EuiModal>
|
||||||
|
),
|
||||||
|
{
|
||||||
|
'data-test-subj': 'dashboardCreateConfirmModal',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
|
@ -29,6 +29,7 @@ import { chromeServiceMock, coreMock } from '../../../../../core/public/mocks';
|
||||||
import { I18nProvider } from '@kbn/i18n/react';
|
import { I18nProvider } from '@kbn/i18n/react';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { UrlForwardingStart } from '../../../../url_forwarding/public';
|
import { UrlForwardingStart } from '../../../../url_forwarding/public';
|
||||||
|
import { DashboardPanelStorage } from '../lib';
|
||||||
|
|
||||||
function makeDefaultServices(): DashboardAppServices {
|
function makeDefaultServices(): DashboardAppServices {
|
||||||
const core = coreMock.createStart();
|
const core = coreMock.createStart();
|
||||||
|
@ -52,6 +53,7 @@ function makeDefaultServices(): DashboardAppServices {
|
||||||
savedObjects: savedObjectsPluginMock.createStartContract(),
|
savedObjects: savedObjectsPluginMock.createStartContract(),
|
||||||
embeddable: embeddablePluginMock.createInstance().doStart(),
|
embeddable: embeddablePluginMock.createInstance().doStart(),
|
||||||
dashboardCapabilities: {} as DashboardCapabilities,
|
dashboardCapabilities: {} as DashboardCapabilities,
|
||||||
|
dashboardPanelStorage: {} as DashboardPanelStorage,
|
||||||
initializerContext: {} as PluginInitializerContext,
|
initializerContext: {} as PluginInitializerContext,
|
||||||
chrome: chromeServiceMock.createStartContract(),
|
chrome: chromeServiceMock.createStartContract(),
|
||||||
navigation: {} as NavigationPublicPluginStart,
|
navigation: {} as NavigationPublicPluginStart,
|
||||||
|
@ -65,6 +67,7 @@ function makeDefaultServices(): DashboardAppServices {
|
||||||
uiSettings: {} as IUiSettingsClient,
|
uiSettings: {} as IUiSettingsClient,
|
||||||
restorePreviousUrl: () => {},
|
restorePreviousUrl: () => {},
|
||||||
onAppLeave: (handler) => {},
|
onAppLeave: (handler) => {},
|
||||||
|
allowByValueEmbeddables: true,
|
||||||
savedDashboards,
|
savedDashboards,
|
||||||
core,
|
core,
|
||||||
};
|
};
|
||||||
|
|
|
@ -17,6 +17,8 @@ import { syncQueryStateWithUrl } from '../../services/data';
|
||||||
import { IKbnUrlStateStorage } from '../../services/kibana_utils';
|
import { IKbnUrlStateStorage } from '../../services/kibana_utils';
|
||||||
import { TableListView, useKibana } from '../../services/kibana_react';
|
import { TableListView, useKibana } from '../../services/kibana_react';
|
||||||
import { SavedObjectsTaggingApi } from '../../services/saved_objects_tagging_oss';
|
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';
|
import { getDashboardListItemLink } from './get_dashboard_list_item_link';
|
||||||
|
|
||||||
export interface DashboardListingProps {
|
export interface DashboardListingProps {
|
||||||
|
@ -41,6 +43,7 @@ export const DashboardListing = ({
|
||||||
savedObjectsClient,
|
savedObjectsClient,
|
||||||
savedObjectsTagging,
|
savedObjectsTagging,
|
||||||
dashboardCapabilities,
|
dashboardCapabilities,
|
||||||
|
dashboardPanelStorage,
|
||||||
chrome: { setBreadcrumbs },
|
chrome: { setBreadcrumbs },
|
||||||
},
|
},
|
||||||
} = useKibana<DashboardAppServices>();
|
} = useKibana<DashboardAppServices>();
|
||||||
|
@ -91,12 +94,24 @@ export const DashboardListing = ({
|
||||||
[core.application, core.uiSettings, kbnUrlStateStorage, savedObjectsTagging]
|
[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(
|
const noItemsFragment = useMemo(
|
||||||
() =>
|
() => getNoItemsMessage(hideWriteControls, core.application, createItem),
|
||||||
getNoItemsMessage(hideWriteControls, core.application, () =>
|
[createItem, core.application, hideWriteControls]
|
||||||
redirectTo({ destination: 'dashboard' })
|
|
||||||
),
|
|
||||||
[redirectTo, core.application, hideWriteControls]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const fetchItems = useCallback(
|
const fetchItems = useCallback(
|
||||||
|
@ -125,7 +140,8 @@ export const DashboardListing = ({
|
||||||
);
|
);
|
||||||
|
|
||||||
const editItem = useCallback(
|
const editItem = useCallback(
|
||||||
({ id }: { id: string | undefined }) => redirectTo({ destination: 'dashboard', id }),
|
({ id }: { id: string | undefined }) =>
|
||||||
|
redirectTo({ destination: 'dashboard', id, editMode: true }),
|
||||||
[redirectTo]
|
[redirectTo]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -143,7 +159,7 @@ export const DashboardListing = ({
|
||||||
} = dashboardListingTable;
|
} = dashboardListingTable;
|
||||||
return (
|
return (
|
||||||
<TableListView
|
<TableListView
|
||||||
createItem={hideWriteControls ? undefined : () => redirectTo({ destination: 'dashboard' })}
|
createItem={hideWriteControls ? undefined : createItem}
|
||||||
deleteItems={hideWriteControls ? undefined : deleteItems}
|
deleteItems={hideWriteControls ? undefined : deleteItems}
|
||||||
initialPageSize={savedObjects.settings.getPerPage()}
|
initialPageSize={savedObjects.settings.getPerPage()}
|
||||||
editItem={hideWriteControls ? undefined : editItem}
|
editItem={hideWriteControls ? undefined : editItem}
|
||||||
|
@ -162,7 +178,9 @@ export const DashboardListing = ({
|
||||||
listingLimit,
|
listingLimit,
|
||||||
tableColumns,
|
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
|
||||||
|
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||||
|
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||||
|
* Side Public License, v 1.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { I18nProvider } from '@kbn/i18n/react';
|
||||||
|
import { findTestSubject } from '@elastic/eui/lib/test';
|
||||||
|
import { waitFor } from '@testing-library/react';
|
||||||
|
import { mount } from 'enzyme';
|
||||||
|
import React from 'react';
|
||||||
|
import { DashboardSavedObject } from '../..';
|
||||||
|
import { coreMock } from '../../../../../core/public/mocks';
|
||||||
|
import { KibanaContextProvider } from '../../services/kibana_react';
|
||||||
|
import { SavedObjectLoader } from '../../services/saved_objects';
|
||||||
|
import { DashboardPanelStorage } from '../lib';
|
||||||
|
import { DASHBOARD_PANELS_UNSAVED_ID } from '../lib/dashboard_panel_storage';
|
||||||
|
import { DashboardAppServices, DashboardRedirect } from '../types';
|
||||||
|
import { DashboardUnsavedListing } from './dashboard_unsaved_listing';
|
||||||
|
|
||||||
|
const mockedDashboards: { [key: string]: DashboardSavedObject } = {
|
||||||
|
dashboardUnsavedOne: {
|
||||||
|
id: `dashboardUnsavedOne`,
|
||||||
|
title: `Dashboard Unsaved One`,
|
||||||
|
} as DashboardSavedObject,
|
||||||
|
dashboardUnsavedTwo: {
|
||||||
|
id: `dashboardUnsavedTwo`,
|
||||||
|
title: `Dashboard Unsaved Two`,
|
||||||
|
} as DashboardSavedObject,
|
||||||
|
dashboardUnsavedThree: {
|
||||||
|
id: `dashboardUnsavedThree`,
|
||||||
|
title: `Dashboard Unsaved Three`,
|
||||||
|
} as DashboardSavedObject,
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeDefaultServices(): DashboardAppServices {
|
||||||
|
const core = coreMock.createStart();
|
||||||
|
core.overlays.openConfirm = jest.fn().mockResolvedValue(true);
|
||||||
|
const savedDashboards = {} as SavedObjectLoader;
|
||||||
|
savedDashboards.get = jest.fn().mockImplementation((id: string) => mockedDashboards[id]);
|
||||||
|
const dashboardPanelStorage = {} as DashboardPanelStorage;
|
||||||
|
dashboardPanelStorage.clearPanels = jest.fn();
|
||||||
|
dashboardPanelStorage.getDashboardIdsWithUnsavedChanges = jest
|
||||||
|
.fn()
|
||||||
|
.mockImplementation(() => [
|
||||||
|
'dashboardUnsavedOne',
|
||||||
|
'dashboardUnsavedTwo',
|
||||||
|
'dashboardUnsavedThree',
|
||||||
|
]);
|
||||||
|
return ({
|
||||||
|
dashboardPanelStorage,
|
||||||
|
savedDashboards,
|
||||||
|
core,
|
||||||
|
} as unknown) as DashboardAppServices;
|
||||||
|
}
|
||||||
|
|
||||||
|
const makeDefaultProps = () => ({ redirectTo: jest.fn() });
|
||||||
|
|
||||||
|
function mountWith({
|
||||||
|
services: incomingServices,
|
||||||
|
props: incomingProps,
|
||||||
|
}: {
|
||||||
|
services?: DashboardAppServices;
|
||||||
|
props?: { redirectTo: DashboardRedirect };
|
||||||
|
}) {
|
||||||
|
const services = incomingServices ?? makeDefaultServices();
|
||||||
|
const props = incomingProps ?? makeDefaultProps();
|
||||||
|
const wrappingComponent: React.FC<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}> = ({ children }) => {
|
||||||
|
return (
|
||||||
|
<I18nProvider>
|
||||||
|
<KibanaContextProvider services={services}>{children}</KibanaContextProvider>
|
||||||
|
</I18nProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const component = mount(<DashboardUnsavedListing {...props} />, { wrappingComponent });
|
||||||
|
return { component, props, services };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Unsaved listing', () => {
|
||||||
|
it('Gets information for each unsaved dashboard', async () => {
|
||||||
|
const { services } = mountWith({});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(services.savedDashboards.get).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Does not attempt to get unsaved dashboard id', async () => {
|
||||||
|
const services = makeDefaultServices();
|
||||||
|
services.dashboardPanelStorage.getDashboardIdsWithUnsavedChanges = jest
|
||||||
|
.fn()
|
||||||
|
.mockImplementation(() => ['dashboardUnsavedOne', DASHBOARD_PANELS_UNSAVED_ID]);
|
||||||
|
mountWith({ services });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(services.savedDashboards.get).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Redirects to the requested dashboard in edit mode when continue editing clicked', async () => {
|
||||||
|
const { props, component } = mountWith({});
|
||||||
|
const getEditButton = () => findTestSubject(component, 'edit-unsaved-Dashboard-Unsaved-One');
|
||||||
|
await waitFor(() => {
|
||||||
|
component.update();
|
||||||
|
expect(getEditButton().length).toEqual(1);
|
||||||
|
});
|
||||||
|
getEditButton().simulate('click');
|
||||||
|
expect(props.redirectTo).toHaveBeenCalledWith({
|
||||||
|
destination: 'dashboard',
|
||||||
|
id: 'dashboardUnsavedOne',
|
||||||
|
editMode: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Redirects to new dashboard when continue editing clicked', async () => {
|
||||||
|
const services = makeDefaultServices();
|
||||||
|
services.dashboardPanelStorage.getDashboardIdsWithUnsavedChanges = jest
|
||||||
|
.fn()
|
||||||
|
.mockImplementation(() => [DASHBOARD_PANELS_UNSAVED_ID]);
|
||||||
|
const { props, component } = mountWith({ services });
|
||||||
|
const getEditButton = () => findTestSubject(component, `edit-unsaved-New-Dashboard`);
|
||||||
|
await waitFor(() => {
|
||||||
|
component.update();
|
||||||
|
expect(getEditButton().length).toBe(1);
|
||||||
|
});
|
||||||
|
getEditButton().simulate('click');
|
||||||
|
expect(props.redirectTo).toHaveBeenCalledWith({
|
||||||
|
destination: 'dashboard',
|
||||||
|
id: undefined,
|
||||||
|
editMode: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Shows a warning then clears changes when delete unsaved changes is pressed', async () => {
|
||||||
|
const { services, component } = mountWith({});
|
||||||
|
const getDiscardButton = () =>
|
||||||
|
findTestSubject(component, 'discard-unsaved-Dashboard-Unsaved-One');
|
||||||
|
await waitFor(() => {
|
||||||
|
component.update();
|
||||||
|
expect(getDiscardButton().length).toBe(1);
|
||||||
|
});
|
||||||
|
getDiscardButton().simulate('click');
|
||||||
|
waitFor(() => {
|
||||||
|
component.update();
|
||||||
|
expect(services.core.overlays.openConfirm).toHaveBeenCalled();
|
||||||
|
expect(services.dashboardPanelStorage.clearPanels).toHaveBeenCalledWith(
|
||||||
|
'dashboardUnsavedOne'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,197 @@
|
||||||
|
/*
|
||||||
|
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||||
|
* or more contributor license agreements. Licensed under the Elastic License
|
||||||
|
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||||
|
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||||
|
* Side Public License, v 1.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
EuiButtonEmpty,
|
||||||
|
EuiCallOut,
|
||||||
|
EuiFlexGroup,
|
||||||
|
EuiFlexItem,
|
||||||
|
EuiIcon,
|
||||||
|
EuiSpacer,
|
||||||
|
EuiTitle,
|
||||||
|
} from '@elastic/eui';
|
||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { DashboardSavedObject } from '../..';
|
||||||
|
import {
|
||||||
|
createConfirmStrings,
|
||||||
|
dashboardUnsavedListingStrings,
|
||||||
|
getNewDashboardTitle,
|
||||||
|
} from '../../dashboard_strings';
|
||||||
|
import { useKibana } from '../../services/kibana_react';
|
||||||
|
import { DASHBOARD_PANELS_UNSAVED_ID } from '../lib/dashboard_panel_storage';
|
||||||
|
import { DashboardAppServices, DashboardRedirect } from '../types';
|
||||||
|
import { confirmDiscardUnsavedChanges } from './confirm_overlays';
|
||||||
|
|
||||||
|
const DashboardUnsavedItem = ({
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
onOpenClick,
|
||||||
|
onDiscardClick,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
title?: string;
|
||||||
|
onOpenClick: () => void;
|
||||||
|
onDiscardClick: () => void;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="dshUnsavedListingItem">
|
||||||
|
<EuiFlexGroup
|
||||||
|
alignItems="center"
|
||||||
|
gutterSize="none"
|
||||||
|
className="dshUnsavedListingItem__heading"
|
||||||
|
responsive={false}
|
||||||
|
>
|
||||||
|
<EuiFlexItem grow={false}>
|
||||||
|
<EuiIcon
|
||||||
|
color="text"
|
||||||
|
className="dshUnsavedListingItem__icon"
|
||||||
|
type={title ? 'dashboardApp' : 'clock'}
|
||||||
|
/>
|
||||||
|
</EuiFlexItem>
|
||||||
|
<EuiFlexItem grow={false}>
|
||||||
|
<EuiTitle size="xxs">
|
||||||
|
<h4
|
||||||
|
className={`dshUnsavedListingItem__title ${
|
||||||
|
title ? '' : 'dshUnsavedListingItem__loading'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{title || dashboardUnsavedListingStrings.getLoadingTitle()}
|
||||||
|
</h4>
|
||||||
|
</EuiTitle>
|
||||||
|
</EuiFlexItem>
|
||||||
|
</EuiFlexGroup>
|
||||||
|
<EuiFlexGroup
|
||||||
|
alignItems="flexStart"
|
||||||
|
gutterSize="none"
|
||||||
|
className="dshUnsavedListingItem__actions"
|
||||||
|
responsive={false}
|
||||||
|
>
|
||||||
|
<EuiFlexItem grow={false}>
|
||||||
|
<EuiButtonEmpty
|
||||||
|
flush="left"
|
||||||
|
size="s"
|
||||||
|
color="primary"
|
||||||
|
disabled={!title}
|
||||||
|
onClick={onOpenClick}
|
||||||
|
data-test-subj={title ? `edit-unsaved-${title.split(' ').join('-')}` : undefined}
|
||||||
|
aria-label={dashboardUnsavedListingStrings.getEditAriaLabel(title ?? id)}
|
||||||
|
>
|
||||||
|
{dashboardUnsavedListingStrings.getEditTitle()}
|
||||||
|
</EuiButtonEmpty>
|
||||||
|
</EuiFlexItem>
|
||||||
|
<EuiFlexItem grow={false}>
|
||||||
|
<EuiButtonEmpty
|
||||||
|
flush="left"
|
||||||
|
size="s"
|
||||||
|
color="danger"
|
||||||
|
disabled={!title}
|
||||||
|
onClick={onDiscardClick}
|
||||||
|
data-test-subj={title ? `discard-unsaved-${title.split(' ').join('-')}` : undefined}
|
||||||
|
aria-label={dashboardUnsavedListingStrings.getDiscardAriaLabel(title ?? id)}
|
||||||
|
>
|
||||||
|
{dashboardUnsavedListingStrings.getDiscardTitle()}
|
||||||
|
</EuiButtonEmpty>
|
||||||
|
</EuiFlexItem>
|
||||||
|
</EuiFlexGroup>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface UnsavedItemMap {
|
||||||
|
[key: string]: DashboardSavedObject;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DashboardUnsavedListing = ({ redirectTo }: { redirectTo: DashboardRedirect }) => {
|
||||||
|
const {
|
||||||
|
services: {
|
||||||
|
dashboardPanelStorage,
|
||||||
|
savedDashboards,
|
||||||
|
core: { overlays },
|
||||||
|
},
|
||||||
|
} = useKibana<DashboardAppServices>();
|
||||||
|
|
||||||
|
const [items, setItems] = useState<UnsavedItemMap>({});
|
||||||
|
const [dashboardIds, setDashboardIds] = useState<string[]>(
|
||||||
|
dashboardPanelStorage.getDashboardIdsWithUnsavedChanges()
|
||||||
|
);
|
||||||
|
|
||||||
|
const onOpen = useCallback(
|
||||||
|
(id?: string) => {
|
||||||
|
redirectTo({ destination: 'dashboard', id, editMode: true });
|
||||||
|
},
|
||||||
|
[redirectTo]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onDiscard = useCallback(
|
||||||
|
(id?: string) => {
|
||||||
|
confirmDiscardUnsavedChanges(
|
||||||
|
overlays,
|
||||||
|
() => {
|
||||||
|
dashboardPanelStorage.clearPanels(id);
|
||||||
|
setDashboardIds(dashboardPanelStorage.getDashboardIdsWithUnsavedChanges());
|
||||||
|
},
|
||||||
|
createConfirmStrings.getCancelButtonText()
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[overlays, dashboardPanelStorage]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (dashboardIds?.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let canceled = false;
|
||||||
|
const dashPromises = dashboardIds
|
||||||
|
.filter((id) => id !== DASHBOARD_PANELS_UNSAVED_ID)
|
||||||
|
.map((dashboardId) => savedDashboards.get(dashboardId));
|
||||||
|
Promise.all(dashPromises).then((dashboards: DashboardSavedObject[]) => {
|
||||||
|
const dashboardMap = {};
|
||||||
|
if (canceled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setItems(
|
||||||
|
dashboards.reduce((map, dashboard) => {
|
||||||
|
return {
|
||||||
|
...map,
|
||||||
|
[dashboard.id || DASHBOARD_PANELS_UNSAVED_ID]: dashboard,
|
||||||
|
};
|
||||||
|
}, dashboardMap)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
canceled = true;
|
||||||
|
};
|
||||||
|
}, [dashboardIds, savedDashboards]);
|
||||||
|
|
||||||
|
return dashboardIds.length === 0 ? null : (
|
||||||
|
<>
|
||||||
|
<EuiCallOut
|
||||||
|
heading="h3"
|
||||||
|
title={dashboardUnsavedListingStrings.getUnsavedChangesTitle(dashboardIds.length > 1)}
|
||||||
|
>
|
||||||
|
{dashboardIds.map((dashboardId: string) => {
|
||||||
|
const title: string | undefined =
|
||||||
|
dashboardId === DASHBOARD_PANELS_UNSAVED_ID
|
||||||
|
? getNewDashboardTitle()
|
||||||
|
: items[dashboardId]?.title;
|
||||||
|
const redirectId = dashboardId === DASHBOARD_PANELS_UNSAVED_ID ? undefined : dashboardId;
|
||||||
|
return (
|
||||||
|
<DashboardUnsavedItem
|
||||||
|
key={dashboardId}
|
||||||
|
id={dashboardId}
|
||||||
|
title={title}
|
||||||
|
onOpenClick={() => onOpen(redirectId)}
|
||||||
|
onDiscardClick={() => onDiscard(redirectId)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</EuiCallOut>
|
||||||
|
<EuiSpacer size="m" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
|
@ -6,8 +6,6 @@
|
||||||
* Side Public License, v 1.
|
* Side Public License, v 1.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { EUI_MODAL_CANCEL_BUTTON } from '@elastic/eui';
|
|
||||||
|
|
||||||
import { i18n } from '@kbn/i18n';
|
import { i18n } from '@kbn/i18n';
|
||||||
import angular from 'angular';
|
import angular from 'angular';
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
@ -31,7 +29,6 @@ import {
|
||||||
import { NavAction } from '../../types';
|
import { NavAction } from '../../types';
|
||||||
import { DashboardSavedObject } from '../..';
|
import { DashboardSavedObject } from '../..';
|
||||||
import { DashboardStateManager } from '../dashboard_state_manager';
|
import { DashboardStateManager } from '../dashboard_state_manager';
|
||||||
import { leaveConfirmStrings } from '../../dashboard_strings';
|
|
||||||
import { saveDashboard } from '../lib';
|
import { saveDashboard } from '../lib';
|
||||||
import {
|
import {
|
||||||
DashboardAppServices,
|
DashboardAppServices,
|
||||||
|
@ -46,7 +43,10 @@ import { showOptionsPopover } from './show_options_popover';
|
||||||
import { TopNavIds } from './top_nav_ids';
|
import { TopNavIds } from './top_nav_ids';
|
||||||
import { ShowShareModal } from './show_share_modal';
|
import { ShowShareModal } from './show_share_modal';
|
||||||
import { PanelToolbar } from './panel_toolbar';
|
import { PanelToolbar } from './panel_toolbar';
|
||||||
|
import { confirmDiscardUnsavedChanges } from '../listing/confirm_overlays';
|
||||||
import { OverlayRef } from '../../../../../core/public';
|
import { OverlayRef } from '../../../../../core/public';
|
||||||
|
import { getNewDashboardTitle } from '../../dashboard_strings';
|
||||||
|
import { DASHBOARD_PANELS_UNSAVED_ID } from '../lib/dashboard_panel_storage';
|
||||||
import { DashboardContainer } from '..';
|
import { DashboardContainer } from '..';
|
||||||
|
|
||||||
export interface DashboardTopNavState {
|
export interface DashboardTopNavState {
|
||||||
|
@ -91,6 +91,8 @@ export function DashboardTopNav({
|
||||||
setHeaderActionMenu,
|
setHeaderActionMenu,
|
||||||
savedObjectsTagging,
|
savedObjectsTagging,
|
||||||
dashboardCapabilities,
|
dashboardCapabilities,
|
||||||
|
dashboardPanelStorage,
|
||||||
|
allowByValueEmbeddables,
|
||||||
} = useKibana<DashboardAppServices>().services;
|
} = useKibana<DashboardAppServices>().services;
|
||||||
|
|
||||||
const [state, setState] = useState<DashboardTopNavState>({ chromeIsVisible: false });
|
const [state, setState] = useState<DashboardTopNavState>({ chromeIsVisible: false });
|
||||||
|
@ -99,8 +101,16 @@ export function DashboardTopNav({
|
||||||
const visibleSubscription = chrome.getIsVisible$().subscribe((chromeIsVisible) => {
|
const visibleSubscription = chrome.getIsVisible$().subscribe((chromeIsVisible) => {
|
||||||
setState((s) => ({ ...s, 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();
|
return () => visibleSubscription.unsubscribe();
|
||||||
}, [chrome]);
|
}, [chrome, allowByValueEmbeddables, dashboardStateManager, savedDashboard]);
|
||||||
|
|
||||||
const addFromLibrary = useCallback(() => {
|
const addFromLibrary = useCallback(() => {
|
||||||
if (!isErrorEmbeddable(dashboardContainer)) {
|
if (!isErrorEmbeddable(dashboardContainer)) {
|
||||||
|
@ -142,47 +152,40 @@ export function DashboardTopNav({
|
||||||
}
|
}
|
||||||
}, [state.addPanelOverlay]);
|
}, [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(
|
const onChangeViewMode = useCallback(
|
||||||
(newMode: ViewMode) => {
|
(newMode: ViewMode) => {
|
||||||
clearAddPanel();
|
clearAddPanel();
|
||||||
const isPageRefresh = newMode === dashboardStateManager.getViewMode();
|
if (savedDashboard?.id && allowByValueEmbeddables) {
|
||||||
const isLeavingEditMode = !isPageRefresh && newMode === ViewMode.VIEW;
|
const { getFullEditPath, title, id } = savedDashboard;
|
||||||
const willLoseChanges = isLeavingEditMode && dashboardStateManager.getIsDirty(timefilter);
|
chrome.recentlyAccessed.add(getFullEditPath(newMode === ViewMode.EDIT), title, id);
|
||||||
|
|
||||||
if (!willLoseChanges) {
|
|
||||||
dashboardStateManager.switchViewMode(newMode);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
dashboardStateManager.switchViewMode(newMode);
|
||||||
function revertChangesAndExitEditMode() {
|
dashboardStateManager.restorePanels();
|
||||||
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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[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',
|
'data-test-subj': 'saveDashboardSuccess',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
dashboardPanelStorage.clearPanels(lastDashboardId);
|
||||||
if (id !== lastDashboardId) {
|
if (id !== lastDashboardId) {
|
||||||
redirectTo({ destination: 'dashboard', id });
|
redirectTo({ destination: 'dashboard', id, useReplace: !lastDashboardId });
|
||||||
} else {
|
} else {
|
||||||
chrome.docTitle.change(dashboardStateManager.savedDashboard.lastSavedTitle);
|
chrome.docTitle.change(dashboardStateManager.savedDashboard.lastSavedTitle);
|
||||||
dashboardStateManager.switchViewMode(ViewMode.VIEW);
|
dashboardStateManager.switchViewMode(ViewMode.VIEW);
|
||||||
|
@ -236,6 +240,7 @@ export function DashboardTopNav({
|
||||||
[
|
[
|
||||||
core.notifications.toasts,
|
core.notifications.toasts,
|
||||||
dashboardStateManager,
|
dashboardStateManager,
|
||||||
|
dashboardPanelStorage,
|
||||||
lastDashboardId,
|
lastDashboardId,
|
||||||
chrome.docTitle,
|
chrome.docTitle,
|
||||||
redirectTo,
|
redirectTo,
|
||||||
|
@ -349,6 +354,7 @@ export function DashboardTopNav({
|
||||||
},
|
},
|
||||||
[TopNavIds.EXIT_EDIT_MODE]: () => onChangeViewMode(ViewMode.VIEW),
|
[TopNavIds.EXIT_EDIT_MODE]: () => onChangeViewMode(ViewMode.VIEW),
|
||||||
[TopNavIds.ENTER_EDIT_MODE]: () => onChangeViewMode(ViewMode.EDIT),
|
[TopNavIds.ENTER_EDIT_MODE]: () => onChangeViewMode(ViewMode.EDIT),
|
||||||
|
[TopNavIds.DISCARD_CHANGES]: onDiscardChanges,
|
||||||
[TopNavIds.SAVE]: runSave,
|
[TopNavIds.SAVE]: runSave,
|
||||||
[TopNavIds.CLONE]: runClone,
|
[TopNavIds.CLONE]: runClone,
|
||||||
[TopNavIds.ADD_EXISTING]: addFromLibrary,
|
[TopNavIds.ADD_EXISTING]: addFromLibrary,
|
||||||
|
@ -385,6 +391,7 @@ export function DashboardTopNav({
|
||||||
}, [
|
}, [
|
||||||
dashboardCapabilities,
|
dashboardCapabilities,
|
||||||
dashboardStateManager,
|
dashboardStateManager,
|
||||||
|
onDiscardChanges,
|
||||||
onChangeViewMode,
|
onChangeViewMode,
|
||||||
savedDashboard,
|
savedDashboard,
|
||||||
addFromLibrary,
|
addFromLibrary,
|
||||||
|
|
|
@ -41,6 +41,7 @@ export function getTopNavConfig(
|
||||||
getShareConfig(actions[TopNavIds.SHARE]),
|
getShareConfig(actions[TopNavIds.SHARE]),
|
||||||
getAddConfig(actions[TopNavIds.ADD_EXISTING]),
|
getAddConfig(actions[TopNavIds.ADD_EXISTING]),
|
||||||
getViewConfig(actions[TopNavIds.EXIT_EDIT_MODE]),
|
getViewConfig(actions[TopNavIds.EXIT_EDIT_MODE]),
|
||||||
|
getDiscardConfig(actions[TopNavIds.DISCARD_CHANGES]),
|
||||||
getSaveConfig(actions[TopNavIds.SAVE]),
|
getSaveConfig(actions[TopNavIds.SAVE]),
|
||||||
getCreateNewConfig(actions[TopNavIds.VISUALIZE]),
|
getCreateNewConfig(actions[TopNavIds.VISUALIZE]),
|
||||||
];
|
];
|
||||||
|
@ -112,13 +113,30 @@ function getViewConfig(action: NavAction) {
|
||||||
defaultMessage: 'cancel',
|
defaultMessage: 'cancel',
|
||||||
}),
|
}),
|
||||||
description: i18n.translate('dashboard.topNave.viewConfigDescription', {
|
description: i18n.translate('dashboard.topNave.viewConfigDescription', {
|
||||||
defaultMessage: 'Cancel editing and switch to view-only mode',
|
defaultMessage: 'Switch to view-only mode',
|
||||||
}),
|
}),
|
||||||
testId: 'dashboardViewOnlyMode',
|
testId: 'dashboardViewOnlyMode',
|
||||||
run: action,
|
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}
|
* @returns {kbnTopNavConfig}
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -12,6 +12,7 @@ export const TopNavIds = {
|
||||||
SAVE: 'save',
|
SAVE: 'save',
|
||||||
EXIT_EDIT_MODE: 'exitEditMode',
|
EXIT_EDIT_MODE: 'exitEditMode',
|
||||||
ENTER_EDIT_MODE: 'enterEditMode',
|
ENTER_EDIT_MODE: 'enterEditMode',
|
||||||
|
DISCARD_CHANGES: 'discard',
|
||||||
CLONE: 'clone',
|
CLONE: 'clone',
|
||||||
FULL_SCREEN: 'fullScreenMode',
|
FULL_SCREEN: 'fullScreenMode',
|
||||||
VISUALIZE: 'visualize',
|
VISUALIZE: 'visualize',
|
||||||
|
|
|
@ -23,11 +23,12 @@ import { NavigationPublicPluginStart } from '../services/navigation';
|
||||||
import { SavedObjectsTaggingApi } from '../services/saved_objects_tagging_oss';
|
import { SavedObjectsTaggingApi } from '../services/saved_objects_tagging_oss';
|
||||||
import { DataPublicPluginStart, IndexPatternsContract } from '../services/data';
|
import { DataPublicPluginStart, IndexPatternsContract } from '../services/data';
|
||||||
import { SavedObjectLoader, SavedObjectsStart } from '../services/saved_objects';
|
import { SavedObjectLoader, SavedObjectsStart } from '../services/saved_objects';
|
||||||
|
import { DashboardPanelStorage } from './lib';
|
||||||
import { UrlForwardingStart } from '../../../url_forwarding/public';
|
import { UrlForwardingStart } from '../../../url_forwarding/public';
|
||||||
|
|
||||||
export type DashboardRedirect = (props: RedirectToProps) => void;
|
export type DashboardRedirect = (props: RedirectToProps) => void;
|
||||||
export type RedirectToProps =
|
export type RedirectToProps =
|
||||||
| { destination: 'dashboard'; id?: string; useReplace?: boolean }
|
| { destination: 'dashboard'; id?: string; useReplace?: boolean; editMode?: boolean }
|
||||||
| { destination: 'listing'; filter?: string; useReplace?: boolean };
|
| { destination: 'listing'; filter?: string; useReplace?: boolean };
|
||||||
|
|
||||||
export interface DashboardEmbedSettings {
|
export interface DashboardEmbedSettings {
|
||||||
|
@ -67,12 +68,14 @@ export interface DashboardAppServices {
|
||||||
uiSettings: IUiSettingsClient;
|
uiSettings: IUiSettingsClient;
|
||||||
restorePreviousUrl: () => void;
|
restorePreviousUrl: () => void;
|
||||||
savedObjects: SavedObjectsStart;
|
savedObjects: SavedObjectsStart;
|
||||||
|
allowByValueEmbeddables: boolean;
|
||||||
urlForwarding: UrlForwardingStart;
|
urlForwarding: UrlForwardingStart;
|
||||||
savedDashboards: SavedObjectLoader;
|
savedDashboards: SavedObjectLoader;
|
||||||
scopedHistory: () => ScopedHistory;
|
scopedHistory: () => ScopedHistory;
|
||||||
indexPatterns: IndexPatternsContract;
|
indexPatterns: IndexPatternsContract;
|
||||||
usageCollection?: UsageCollectionSetup;
|
usageCollection?: UsageCollectionSetup;
|
||||||
navigation: NavigationPublicPluginStart;
|
navigation: NavigationPublicPluginStart;
|
||||||
|
dashboardPanelStorage: DashboardPanelStorage;
|
||||||
dashboardCapabilities: DashboardCapabilities;
|
dashboardCapabilities: DashboardCapabilities;
|
||||||
initializerContext: PluginInitializerContext;
|
initializerContext: PluginInitializerContext;
|
||||||
onAppLeave: AppMountParameters['onAppLeave'];
|
onAppLeave: AppMountParameters['onAppLeave'];
|
||||||
|
|
|
@ -6,6 +6,8 @@
|
||||||
* Side Public License, v 1.
|
* Side Public License, v 1.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
const DASHBOARD_STATE_STORAGE_KEY = '_a';
|
||||||
|
|
||||||
export const DashboardConstants = {
|
export const DashboardConstants = {
|
||||||
LANDING_PAGE_PATH: '/list',
|
LANDING_PAGE_PATH: '/list',
|
||||||
CREATE_NEW_DASHBOARD_URL: '/create',
|
CREATE_NEW_DASHBOARD_URL: '/create',
|
||||||
|
@ -17,8 +19,12 @@ export const DashboardConstants = {
|
||||||
SEARCH_SESSION_ID: 'searchSessionId',
|
SEARCH_SESSION_ID: 'searchSessionId',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createDashboardEditUrl(id: string) {
|
export function createDashboardEditUrl(id?: string, editMode?: boolean) {
|
||||||
return `${DashboardConstants.VIEW_DASHBOARD_URL}/${id}`;
|
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) {
|
export function createDashboardListingFilterUrl(filter: string | undefined) {
|
||||||
|
|
|
@ -24,10 +24,7 @@ export function getDashboardTitle(
|
||||||
): string {
|
): string {
|
||||||
const isEditMode = viewMode === ViewMode.EDIT;
|
const isEditMode = viewMode === ViewMode.EDIT;
|
||||||
let displayTitle: string;
|
let displayTitle: string;
|
||||||
const newDashboardTitle = i18n.translate('dashboard.savedDashboard.newDashboardTitle', {
|
const dashboardTitle = isNew ? getNewDashboardTitle() : title;
|
||||||
defaultMessage: 'New Dashboard',
|
|
||||||
});
|
|
||||||
const dashboardTitle = isNew ? newDashboardTitle : title;
|
|
||||||
|
|
||||||
if (isEditMode && isDirty) {
|
if (isEditMode && isDirty) {
|
||||||
displayTitle = i18n.translate('dashboard.strings.dashboardUnsavedEditTitle', {
|
displayTitle = i18n.translate('dashboard.strings.dashboardUnsavedEditTitle', {
|
||||||
|
@ -176,6 +173,11 @@ export const dashboardReplacePanelAction = {
|
||||||
/*
|
/*
|
||||||
Dashboard Editor
|
Dashboard Editor
|
||||||
*/
|
*/
|
||||||
|
export const getNewDashboardTitle = () =>
|
||||||
|
i18n.translate('dashboard.savedDashboard.newDashboardTitle', {
|
||||||
|
defaultMessage: 'New Dashboard',
|
||||||
|
});
|
||||||
|
|
||||||
export const shareModalStrings = {
|
export const shareModalStrings = {
|
||||||
getTopMenuCheckbox: () =>
|
getTopMenuCheckbox: () =>
|
||||||
i18n.translate('dashboard.embedUrlParamExtension.topMenu', {
|
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
|
Empty Screen
|
||||||
*/
|
*/
|
||||||
|
@ -307,3 +347,37 @@ export const dashboardListingTable = {
|
||||||
defaultMessage: 'Description',
|
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,
|
core,
|
||||||
appUnMounted,
|
appUnMounted,
|
||||||
usageCollection,
|
usageCollection,
|
||||||
onAppLeave: params.onAppLeave,
|
|
||||||
initializerContext: this.initializerContext,
|
|
||||||
restorePreviousUrl,
|
restorePreviousUrl,
|
||||||
element: params.element,
|
element: params.element,
|
||||||
|
onAppLeave: params.onAppLeave,
|
||||||
scopedHistory: this.currentHistory!,
|
scopedHistory: this.currentHistory!,
|
||||||
|
initializerContext: this.initializerContext,
|
||||||
setHeaderActionMenu: params.setHeaderActionMenu,
|
setHeaderActionMenu: params.setHeaderActionMenu,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
|
@ -30,6 +30,7 @@ export interface DashboardSavedObject extends SavedObject {
|
||||||
searchSource: ISearchSource;
|
searchSource: ISearchSource;
|
||||||
getQuery(): Query;
|
getQuery(): Query;
|
||||||
getFilters(): Filter[];
|
getFilters(): Filter[];
|
||||||
|
getFullEditPath: (editMode?: boolean) => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Used only by the savedDashboards service, usually no reason to change this
|
// Used only by the savedDashboards service, usually no reason to change this
|
||||||
|
@ -106,7 +107,7 @@ export function createSavedDashboardClass(
|
||||||
refreshInterval: undefined,
|
refreshInterval: undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
this.getFullPath = () => `/app/dashboards#${createDashboardEditUrl(String(this.id))}`;
|
this.getFullPath = () => `/app/dashboards#${createDashboardEditUrl(this.id)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
getQuery() {
|
getQuery() {
|
||||||
|
@ -116,6 +117,10 @@ export function createSavedDashboardClass(
|
||||||
getFilters() {
|
getFilters() {
|
||||||
return this.searchSource!.getOwnField('filter') || [];
|
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
|
// Unfortunately this throws a typescript error without the casting. I think it's due to the
|
||||||
|
|
|
@ -7,6 +7,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
Storage,
|
||||||
unhashUrl,
|
unhashUrl,
|
||||||
syncState,
|
syncState,
|
||||||
ISyncStateRef,
|
ISyncStateRef,
|
||||||
|
|
|
@ -81,8 +81,7 @@ export type DashboardAppStateDefaults = DashboardAppState & {
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* In URL panels are optional,
|
* Panels are not added to the URL
|
||||||
* Panels are not added to the URL when in "view" mode
|
|
||||||
*/
|
*/
|
||||||
export type DashboardAppStateInUrl = Omit<DashboardAppState, 'panels'> & {
|
export type DashboardAppStateInUrl = Omit<DashboardAppState, 'panels'> & {
|
||||||
panels?: SavedDashboardPanel[];
|
panels?: SavedDashboardPanel[];
|
||||||
|
|
|
@ -518,6 +518,7 @@ class TableListView extends React.Component<TableListViewProps, TableListViewSta
|
||||||
</EuiFlexGroup>
|
</EuiFlexGroup>
|
||||||
|
|
||||||
<EuiSpacer size="m" />
|
<EuiSpacer size="m" />
|
||||||
|
{this.props.children}
|
||||||
|
|
||||||
{this.renderListingLimitWarning()}
|
{this.renderListingLimitWarning()}
|
||||||
{this.renderFetchError()}
|
{this.renderFetchError()}
|
||||||
|
|
|
@ -110,7 +110,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Exit out of edit mode', async () => {
|
it('Exit out of edit mode', async () => {
|
||||||
await PageObjects.dashboard.clickCancelOutOfEditMode();
|
await PageObjects.dashboard.clickDiscardChanges();
|
||||||
await a11y.testAppSnapshot();
|
await a11y.testAppSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -31,6 +31,27 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
||||||
describe('dashboard filtering', function () {
|
describe('dashboard filtering', function () {
|
||||||
this.tags('includeFirefox');
|
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 () => {
|
before(async () => {
|
||||||
await esArchiver.load('dashboard/current/kibana');
|
await esArchiver.load('dashboard/current/kibana');
|
||||||
await security.testUser.setRoles(['kibana_admin', 'test_logstash_reader', 'animals']);
|
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', () => {
|
describe('adding a filter that excludes all data', () => {
|
||||||
before(async () => {
|
before(async () => {
|
||||||
await PageObjects.dashboard.clickNewDashboard();
|
await populateDashboard();
|
||||||
await PageObjects.timePicker.setDefaultDataRange();
|
await addFilterAndRefresh();
|
||||||
await dashboardAddPanel.addEveryVisualization('"Filter Bytes Test"');
|
});
|
||||||
await dashboardAddPanel.addEverySavedSearch('"Filter Bytes Test"');
|
|
||||||
|
|
||||||
await dashboardAddPanel.closeAddPanel();
|
after(async () => {
|
||||||
|
await PageObjects.dashboard.gotoDashboardLandingPage();
|
||||||
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();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('filters on pie charts', async () => {
|
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', () => {
|
describe('using a pinned filter that excludes all data', () => {
|
||||||
before(async () => {
|
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 filterBar.toggleFilterPinned('bytes');
|
||||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||||
await PageObjects.dashboard.waitForRenderComplete();
|
await PageObjects.dashboard.waitForRenderComplete();
|
||||||
|
@ -125,6 +140,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
||||||
|
|
||||||
after(async () => {
|
after(async () => {
|
||||||
await filterBar.toggleFilterPinned('bytes');
|
await filterBar.toggleFilterPinned('bytes');
|
||||||
|
await PageObjects.dashboard.gotoDashboardLandingPage();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('filters on pie charts', async () => {
|
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 () {
|
describe('disabling a filter unfilters the data on', function () {
|
||||||
before(async () => {
|
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 filterBar.toggleFilterEnabled('bytes');
|
||||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||||
await PageObjects.dashboard.waitForRenderComplete();
|
await PageObjects.dashboard.waitForRenderComplete();
|
||||||
|
|
|
@ -20,6 +20,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
||||||
'discover',
|
'discover',
|
||||||
'tileMap',
|
'tileMap',
|
||||||
'visChart',
|
'visChart',
|
||||||
|
'share',
|
||||||
'timePicker',
|
'timePicker',
|
||||||
]);
|
]);
|
||||||
const testSubjects = getService('testSubjects');
|
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 () => {
|
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.dashboard.switchToEditMode();
|
||||||
|
await PageObjects.discover.removeHeaderColumn('bytes');
|
||||||
await PageObjects.dashboard.saveDashboard('Has local edits');
|
await PageObjects.dashboard.saveDashboard('Has local edits');
|
||||||
|
|
||||||
await PageObjects.header.clickDiscover();
|
await PageObjects.header.clickDiscover();
|
||||||
|
@ -191,6 +192,13 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
||||||
expect(changedTileMapData.length).to.not.equal(tileMapData.length);
|
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', () => {
|
describe('Directly modifying url updates dashboard state', () => {
|
||||||
it('for query parameter', async function () {
|
it('for query parameter', async function () {
|
||||||
await PageObjects.dashboard.gotoDashboardLandingPage();
|
await PageObjects.dashboard.gotoDashboardLandingPage();
|
||||||
|
@ -209,7 +217,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
||||||
|
|
||||||
it('for panel size parameters', async function () {
|
it('for panel size parameters', async function () {
|
||||||
await dashboardAddPanel.addVisualization(PIE_CHART_VIS_NAME);
|
await dashboardAddPanel.addVisualization(PIE_CHART_VIS_NAME);
|
||||||
const currentUrl = await browser.getCurrentUrl();
|
const currentUrl = await getUrlFromShare();
|
||||||
const currentPanelDimensions = await PageObjects.dashboard.getPanelDimensions();
|
const currentPanelDimensions = await PageObjects.dashboard.getPanelDimensions();
|
||||||
const newUrl = currentUrl.replace(
|
const newUrl = currentUrl.replace(
|
||||||
`w:${DEFAULT_PANEL_WIDTH}`,
|
`w:${DEFAULT_PANEL_WIDTH}`,
|
||||||
|
@ -235,7 +243,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('when removing a panel', async function () {
|
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');
|
const newUrl = currentUrl.replace(/panels:\!\(.*\),query/, 'panels:!(),query');
|
||||||
await browser.get(newUrl.toString(), false);
|
await browser.get(newUrl.toString(), false);
|
||||||
|
|
||||||
|
@ -253,7 +262,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
||||||
`[data-title="${PIE_CHART_VIS_NAME}"]`
|
`[data-title="${PIE_CHART_VIS_NAME}"]`
|
||||||
);
|
);
|
||||||
await PageObjects.visChart.selectNewLegendColorChoice('#F9D9F9');
|
await PageObjects.visChart.selectNewLegendColorChoice('#F9D9F9');
|
||||||
const currentUrl = await browser.getCurrentUrl();
|
const currentUrl = await getUrlFromShare();
|
||||||
const newUrl = currentUrl.replace('F9D9F9', 'FFFFFF');
|
const newUrl = currentUrl.replace('F9D9F9', 'FFFFFF');
|
||||||
await browser.get(newUrl.toString(), false);
|
await browser.get(newUrl.toString(), false);
|
||||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
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 () {
|
it('resets a pie slice color to the original when removed', async function () {
|
||||||
const currentUrl = await browser.getCurrentUrl();
|
const currentUrl = await getUrlFromShare();
|
||||||
const newUrl = currentUrl.replace('vis:(colors:(%2780,000%27:%23FFFFFF))', '');
|
const newUrl = currentUrl.replace(`vis:(colors:('80,000':%23FFFFFF))`, '');
|
||||||
await browser.get(newUrl.toString(), false);
|
await browser.get(newUrl.toString(), false);
|
||||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||||
|
|
||||||
await retry.try(async () => {
|
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.
|
// 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);
|
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
|
||||||
|
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||||
|
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||||
|
* Side Public License, v 1.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import expect from '@kbn/expect';
|
||||||
|
|
||||||
|
import { FtrProviderContext } from '../../ftr_provider_context';
|
||||||
|
|
||||||
|
export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
||||||
|
const PageObjects = getPageObjects(['dashboard', 'header', 'visualize', 'settings', 'common']);
|
||||||
|
const esArchiver = getService('esArchiver');
|
||||||
|
const kibanaServer = getService('kibanaServer');
|
||||||
|
const dashboardAddPanel = getService('dashboardAddPanel');
|
||||||
|
const dashboardPanelActions = getService('dashboardPanelActions');
|
||||||
|
|
||||||
|
let existingDashboardPanelCount = 0;
|
||||||
|
const dashboardTitle = 'few panels';
|
||||||
|
const unsavedDashboardTitle = 'New Dashboard';
|
||||||
|
const newDashboartTitle = 'A Wild Dashboard';
|
||||||
|
|
||||||
|
describe('dashboard unsaved listing', () => {
|
||||||
|
const addSomePanels = async () => {
|
||||||
|
// add an area chart by value
|
||||||
|
await dashboardAddPanel.clickCreateNewLink();
|
||||||
|
await PageObjects.visualize.clickAggBasedVisualizations();
|
||||||
|
await PageObjects.visualize.clickAreaChart();
|
||||||
|
await PageObjects.visualize.clickNewSearch();
|
||||||
|
await PageObjects.visualize.saveVisualizationAndReturn();
|
||||||
|
|
||||||
|
// add a metric by reference
|
||||||
|
await dashboardAddPanel.addVisualization('Rendering-Test: metric');
|
||||||
|
};
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
await esArchiver.load('dashboard/current/kibana');
|
||||||
|
await kibanaServer.uiSettings.replace({
|
||||||
|
defaultIndex: '0bf35f60-3dc9-11e8-8660-4d65aa086b3c',
|
||||||
|
});
|
||||||
|
await PageObjects.common.navigateToApp('dashboard');
|
||||||
|
await PageObjects.dashboard.preserveCrossAppState();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists unsaved changes to existing dashboards', async () => {
|
||||||
|
await PageObjects.dashboard.loadSavedDashboard(dashboardTitle);
|
||||||
|
await PageObjects.dashboard.switchToEditMode();
|
||||||
|
await addSomePanels();
|
||||||
|
existingDashboardPanelCount = await PageObjects.dashboard.getPanelCount();
|
||||||
|
await PageObjects.dashboard.gotoDashboardLandingPage();
|
||||||
|
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||||
|
await PageObjects.dashboard.expectUnsavedChangesListingExists(dashboardTitle);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores unsaved changes to existing dashboards', async () => {
|
||||||
|
await PageObjects.dashboard.clickUnsavedChangesContinueEditing(dashboardTitle);
|
||||||
|
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||||
|
const currentPanelCount = await PageObjects.dashboard.getPanelCount();
|
||||||
|
expect(currentPanelCount).to.eql(existingDashboardPanelCount);
|
||||||
|
await PageObjects.dashboard.gotoDashboardLandingPage();
|
||||||
|
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists unsaved changes to new dashboards', async () => {
|
||||||
|
await PageObjects.dashboard.clickNewDashboard();
|
||||||
|
await addSomePanels();
|
||||||
|
await PageObjects.dashboard.gotoDashboardLandingPage();
|
||||||
|
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||||
|
await PageObjects.dashboard.expectUnsavedChangesListingExists(unsavedDashboardTitle);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores unsaved changes to new dashboards', async () => {
|
||||||
|
await PageObjects.dashboard.clickUnsavedChangesContinueEditing(unsavedDashboardTitle);
|
||||||
|
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||||
|
expect(await PageObjects.dashboard.getPanelCount()).to.eql(2);
|
||||||
|
await PageObjects.dashboard.gotoDashboardLandingPage();
|
||||||
|
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a warning on create new, and restores panels if continue is selected', async () => {
|
||||||
|
await PageObjects.dashboard.clickNewDashboardExpectWarning(true);
|
||||||
|
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||||
|
expect(await PageObjects.dashboard.getPanelCount()).to.eql(2);
|
||||||
|
await PageObjects.dashboard.gotoDashboardLandingPage();
|
||||||
|
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a warning on create new, and clears unsaved panels if discard is selected', async () => {
|
||||||
|
await PageObjects.dashboard.clickNewDashboardExpectWarning();
|
||||||
|
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||||
|
expect(await PageObjects.dashboard.getPanelCount()).to.eql(0);
|
||||||
|
await PageObjects.dashboard.gotoDashboardLandingPage();
|
||||||
|
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show unsaved changes on new dashboard when no panels have been added', async () => {
|
||||||
|
await PageObjects.dashboard.expectUnsavedChangesDoesNotExist(unsavedDashboardTitle);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can discard unsaved changes using the discard link', async () => {
|
||||||
|
await PageObjects.dashboard.clickUnsavedChangesDiscard(dashboardTitle);
|
||||||
|
await PageObjects.dashboard.expectUnsavedChangesDoesNotExist(dashboardTitle);
|
||||||
|
await PageObjects.dashboard.loadSavedDashboard(dashboardTitle);
|
||||||
|
await PageObjects.dashboard.switchToEditMode();
|
||||||
|
const currentPanelCount = await PageObjects.dashboard.getPanelCount();
|
||||||
|
expect(currentPanelCount).to.eql(existingDashboardPanelCount - 2);
|
||||||
|
await PageObjects.dashboard.gotoDashboardLandingPage();
|
||||||
|
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loses unsaved changes to new dashboard upon saving', async () => {
|
||||||
|
await PageObjects.dashboard.clickNewDashboard();
|
||||||
|
await addSomePanels();
|
||||||
|
|
||||||
|
// ensure that the unsaved listing exists first
|
||||||
|
await PageObjects.dashboard.gotoDashboardLandingPage();
|
||||||
|
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||||
|
await PageObjects.dashboard.clickUnsavedChangesContinueEditing(unsavedDashboardTitle);
|
||||||
|
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||||
|
|
||||||
|
// Save the dashboard, and check that it now does not exist
|
||||||
|
await PageObjects.dashboard.saveDashboard(newDashboartTitle);
|
||||||
|
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||||
|
await PageObjects.dashboard.gotoDashboardLandingPage();
|
||||||
|
await PageObjects.dashboard.expectUnsavedChangesDoesNotExist(unsavedDashboardTitle);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not list unsaved changes when unsaved version of the dashboard is the same', async () => {
|
||||||
|
await PageObjects.dashboard.loadSavedDashboard(newDashboartTitle);
|
||||||
|
await PageObjects.dashboard.switchToEditMode();
|
||||||
|
|
||||||
|
// add another panel so we can delete it later
|
||||||
|
await dashboardAddPanel.clickCreateNewLink();
|
||||||
|
await PageObjects.visualize.clickAggBasedVisualizations();
|
||||||
|
await PageObjects.visualize.clickAreaChart();
|
||||||
|
await PageObjects.visualize.clickNewSearch();
|
||||||
|
await PageObjects.visualize.saveVisualizationExpectSuccess('Wildvis', {
|
||||||
|
redirectToOrigin: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ensure that the unsaved listing exists
|
||||||
|
await PageObjects.dashboard.gotoDashboardLandingPage();
|
||||||
|
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||||
|
await PageObjects.dashboard.expectUnsavedChangesListingExists(newDashboartTitle);
|
||||||
|
await PageObjects.dashboard.clickUnsavedChangesContinueEditing(newDashboartTitle);
|
||||||
|
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||||
|
|
||||||
|
// Remove the panel that was just added
|
||||||
|
await dashboardPanelActions.removePanelByTitle('Wildvis');
|
||||||
|
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||||
|
|
||||||
|
// Check that it now does not exist
|
||||||
|
await PageObjects.dashboard.gotoDashboardLandingPage();
|
||||||
|
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||||
|
await PageObjects.dashboard.expectUnsavedChangesDoesNotExist(newDashboartTitle);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
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
|
||||||
|
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||||
|
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||||
|
* Side Public License, v 1.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import expect from '@kbn/expect';
|
||||||
|
|
||||||
|
import { FtrProviderContext } from '../../ftr_provider_context';
|
||||||
|
|
||||||
|
export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
||||||
|
const PageObjects = getPageObjects(['dashboard', 'header', 'visualize', 'settings', 'common']);
|
||||||
|
const esArchiver = getService('esArchiver');
|
||||||
|
const kibanaServer = getService('kibanaServer');
|
||||||
|
const dashboardAddPanel = getService('dashboardAddPanel');
|
||||||
|
|
||||||
|
let originalPanelCount = 0;
|
||||||
|
let unsavedPanelCount = 0;
|
||||||
|
|
||||||
|
describe('dashboard unsaved panels', () => {
|
||||||
|
before(async () => {
|
||||||
|
await esArchiver.load('dashboard/current/kibana');
|
||||||
|
await kibanaServer.uiSettings.replace({
|
||||||
|
defaultIndex: '0bf35f60-3dc9-11e8-8660-4d65aa086b3c',
|
||||||
|
});
|
||||||
|
await PageObjects.common.navigateToApp('dashboard');
|
||||||
|
await PageObjects.dashboard.preserveCrossAppState();
|
||||||
|
await PageObjects.dashboard.loadSavedDashboard('few panels');
|
||||||
|
await PageObjects.dashboard.switchToEditMode();
|
||||||
|
|
||||||
|
originalPanelCount = await PageObjects.dashboard.getPanelCount();
|
||||||
|
|
||||||
|
// add an area chart by value
|
||||||
|
await dashboardAddPanel.clickCreateNewLink();
|
||||||
|
await PageObjects.visualize.clickAggBasedVisualizations();
|
||||||
|
await PageObjects.visualize.clickAreaChart();
|
||||||
|
await PageObjects.visualize.clickNewSearch();
|
||||||
|
await PageObjects.visualize.saveVisualizationAndReturn();
|
||||||
|
|
||||||
|
// add a metric by reference
|
||||||
|
await dashboardAddPanel.addVisualization('Rendering-Test: metric');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has correct number of panels', async () => {
|
||||||
|
unsavedPanelCount = await PageObjects.dashboard.getPanelCount();
|
||||||
|
expect(unsavedPanelCount).to.eql(originalPanelCount + 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retains unsaved panel count after navigating to listing page and back', async () => {
|
||||||
|
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||||
|
await PageObjects.dashboard.gotoDashboardLandingPage();
|
||||||
|
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||||
|
await PageObjects.dashboard.loadSavedDashboard('few panels');
|
||||||
|
await PageObjects.dashboard.switchToEditMode();
|
||||||
|
const currentPanelCount = await PageObjects.dashboard.getPanelCount();
|
||||||
|
expect(currentPanelCount).to.eql(unsavedPanelCount);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retains unsaved panel count after navigating to another app and back', async () => {
|
||||||
|
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||||
|
await PageObjects.visualize.gotoVisualizationLandingPage();
|
||||||
|
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||||
|
await PageObjects.common.navigateToApp('dashboards');
|
||||||
|
await PageObjects.dashboard.loadSavedDashboard('few panels');
|
||||||
|
await PageObjects.dashboard.switchToEditMode();
|
||||||
|
const currentPanelCount = await PageObjects.dashboard.getPanelCount();
|
||||||
|
expect(currentPanelCount).to.eql(unsavedPanelCount);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets to original panel count upon entering view mode', async () => {
|
||||||
|
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||||
|
await PageObjects.dashboard.clickCancelOutOfEditMode();
|
||||||
|
const currentPanelCount = await PageObjects.dashboard.getPanelCount();
|
||||||
|
expect(currentPanelCount).to.eql(originalPanelCount);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retains unsaved panel count after returning to edit mode', async () => {
|
||||||
|
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||||
|
await PageObjects.dashboard.switchToEditMode();
|
||||||
|
const currentPanelCount = await PageObjects.dashboard.getPanelCount();
|
||||||
|
expect(currentPanelCount).to.eql(unsavedPanelCount);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
|
@ -46,6 +46,8 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) {
|
||||||
loadTestFile(require.resolve('./embeddable_data_grid'));
|
loadTestFile(require.resolve('./embeddable_data_grid'));
|
||||||
loadTestFile(require.resolve('./create_and_add_embeddables'));
|
loadTestFile(require.resolve('./create_and_add_embeddables'));
|
||||||
loadTestFile(require.resolve('./edit_embeddable_redirects'));
|
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('./edit_visualizations'));
|
||||||
loadTestFile(require.resolve('./time_zones'));
|
loadTestFile(require.resolve('./time_zones'));
|
||||||
loadTestFile(require.resolve('./dashboard_options'));
|
loadTestFile(require.resolve('./dashboard_options'));
|
||||||
|
|
|
@ -105,6 +105,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
||||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||||
await PageObjects.header.clickDashboard();
|
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();
|
const inViewMode = await PageObjects.dashboard.getIsInViewMode();
|
||||||
if (inViewMode) await PageObjects.dashboard.switchToEditMode();
|
if (inViewMode) await PageObjects.dashboard.switchToEditMode();
|
||||||
await dashboardAddPanel.addSavedSearch(searchName);
|
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 () => {
|
before('and add one panel and save to put dashboard in "view" mode', async () => {
|
||||||
await dashboardAddPanel.addVisualization(PIE_CHART_VIS_NAME);
|
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 () => {
|
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',
|
||||||
'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
|
// confirm lose changes
|
||||||
await PageObjects.common.clickConfirmOnModal();
|
await PageObjects.common.clickConfirmOnModal();
|
||||||
|
@ -88,7 +88,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
||||||
await queryBar.setQuery(`${originalQuery}and extra stuff`);
|
await queryBar.setQuery(`${originalQuery}and extra stuff`);
|
||||||
await queryBar.submitQuery();
|
await queryBar.submitQuery();
|
||||||
|
|
||||||
await PageObjects.dashboard.clickCancelOutOfEditMode();
|
await PageObjects.dashboard.clickDiscardChanges();
|
||||||
|
|
||||||
// confirm lose changes
|
// confirm lose changes
|
||||||
await PageObjects.common.clickConfirmOnModal();
|
await PageObjects.common.clickConfirmOnModal();
|
||||||
|
@ -111,7 +111,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
||||||
hasFilter = await filterBar.hasFilter('animal', 'dog');
|
hasFilter = await filterBar.hasFilter('animal', 'dog');
|
||||||
expect(hasFilter).to.be(false);
|
expect(hasFilter).to.be(false);
|
||||||
|
|
||||||
await PageObjects.dashboard.clickCancelOutOfEditMode();
|
await PageObjects.dashboard.clickDiscardChanges();
|
||||||
|
|
||||||
// confirm lose changes
|
// confirm lose changes
|
||||||
await PageObjects.common.clickConfirmOnModal();
|
await PageObjects.common.clickConfirmOnModal();
|
||||||
|
@ -133,9 +133,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
||||||
redirectToOrigin: true,
|
redirectToOrigin: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
await PageObjects.dashboard.clickCancelOutOfEditMode();
|
await PageObjects.dashboard.clickDiscardChanges();
|
||||||
|
|
||||||
// confirm lose changes
|
|
||||||
await PageObjects.common.clickConfirmOnModal();
|
await PageObjects.common.clickConfirmOnModal();
|
||||||
|
|
||||||
const panelCount = await PageObjects.dashboard.getPanelCount();
|
const panelCount = await PageObjects.dashboard.getPanelCount();
|
||||||
|
@ -146,7 +144,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
||||||
const originalPanelCount = await PageObjects.dashboard.getPanelCount();
|
const originalPanelCount = await PageObjects.dashboard.getPanelCount();
|
||||||
|
|
||||||
await dashboardAddPanel.addVisualization('new viz panel');
|
await dashboardAddPanel.addVisualization('new viz panel');
|
||||||
await PageObjects.dashboard.clickCancelOutOfEditMode();
|
await PageObjects.dashboard.clickDiscardChanges();
|
||||||
|
|
||||||
// confirm lose changes
|
// confirm lose changes
|
||||||
await PageObjects.common.clickConfirmOnModal();
|
await PageObjects.common.clickConfirmOnModal();
|
||||||
|
@ -169,7 +167,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
||||||
'Sep 19, 2015 @ 06:31:44.000',
|
'Sep 19, 2015 @ 06:31:44.000',
|
||||||
'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.common.clickCancelOnModal();
|
||||||
await PageObjects.dashboard.saveDashboard(dashboardName, {
|
await PageObjects.dashboard.saveDashboard(dashboardName, {
|
||||||
|
@ -198,7 +196,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
||||||
);
|
);
|
||||||
const newTime = await PageObjects.timePicker.getTimeConfig();
|
const newTime = await PageObjects.timePicker.getTimeConfig();
|
||||||
|
|
||||||
await PageObjects.dashboard.clickCancelOutOfEditMode();
|
await PageObjects.dashboard.clickDiscardChanges();
|
||||||
|
|
||||||
await PageObjects.common.clickCancelOnModal();
|
await PageObjects.common.clickCancelOnModal();
|
||||||
await PageObjects.dashboard.saveDashboard(dashboardName, { storeTimeWithDashboard: true });
|
await PageObjects.dashboard.saveDashboard(dashboardName, { storeTimeWithDashboard: true });
|
||||||
|
|
|
@ -111,6 +111,33 @@ export function DashboardPageProvider({ getService, getPageObjects }: FtrProvide
|
||||||
return id;
|
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 true if already on the dashboard landing page (that page doesn't have a link to itself).
|
||||||
* @returns {Promise<boolean>}
|
* @returns {Promise<boolean>}
|
||||||
|
@ -216,8 +243,32 @@ export function DashboardPageProvider({ getService, getPageObjects }: FtrProvide
|
||||||
await testSubjects.click('dashboardViewOnlyMode');
|
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');
|
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
|
// make sure the dashboard page is shown
|
||||||
await this.waitForRenderComplete();
|
await this.waitForRenderComplete();
|
||||||
}
|
}
|
||||||
|
|
|
@ -99,9 +99,9 @@ export function DashboardPanelActionsProvider({ getService, getPageObjects }: Ft
|
||||||
await testSubjects.click(TOGGLE_EXPAND_PANEL_DATA_TEST_SUBJ);
|
await testSubjects.click(TOGGLE_EXPAND_PANEL_DATA_TEST_SUBJ);
|
||||||
}
|
}
|
||||||
|
|
||||||
async removePanel() {
|
async removePanel(parent?: WebElementWrapper) {
|
||||||
log.debug('removePanel');
|
log.debug('removePanel');
|
||||||
await this.openContextMenu();
|
await this.openContextMenu(parent);
|
||||||
const isActionVisible = await testSubjects.exists(REMOVE_PANEL_DATA_TEST_SUBJ);
|
const isActionVisible = await testSubjects.exists(REMOVE_PANEL_DATA_TEST_SUBJ);
|
||||||
if (!isActionVisible) await this.clickContextMenuMoreItem();
|
if (!isActionVisible) await this.clickContextMenuMoreItem();
|
||||||
const isPanelActionVisible = await testSubjects.exists(REMOVE_PANEL_DATA_TEST_SUBJ);
|
const isPanelActionVisible = await testSubjects.exists(REMOVE_PANEL_DATA_TEST_SUBJ);
|
||||||
|
@ -111,10 +111,8 @@ export function DashboardPanelActionsProvider({ getService, getPageObjects }: Ft
|
||||||
|
|
||||||
async removePanelByTitle(title: string) {
|
async removePanelByTitle(title: string) {
|
||||||
const header = await this.getPanelHeading(title);
|
const header = await this.getPanelHeading(title);
|
||||||
await this.openContextMenu(header);
|
log.debug('found header? ', Boolean(header));
|
||||||
const isActionVisible = await testSubjects.exists(REMOVE_PANEL_DATA_TEST_SUBJ);
|
await this.removePanel(header);
|
||||||
if (!isActionVisible) await this.clickContextMenuMoreItem();
|
|
||||||
await testSubjects.click(REMOVE_PANEL_DATA_TEST_SUBJ);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async customizePanel(parent?: WebElementWrapper) {
|
async customizePanel(parent?: WebElementWrapper) {
|
||||||
|
|
|
@ -662,9 +662,9 @@
|
||||||
"dashboard.topNav.showCloneModal.dashboardCopyTitle": "{title} 副本",
|
"dashboard.topNav.showCloneModal.dashboardCopyTitle": "{title} 副本",
|
||||||
"dashboard.topNave.addButtonAriaLabel": "库",
|
"dashboard.topNave.addButtonAriaLabel": "库",
|
||||||
"dashboard.topNave.addConfigDescription": "将现有可视化添加到仪表板",
|
"dashboard.topNave.addConfigDescription": "将现有可视化添加到仪表板",
|
||||||
|
"dashboard.topNave.cancelButtonAriaLabel": "取消",
|
||||||
"dashboard.topNave.addNewButtonAriaLabel": "创建面板",
|
"dashboard.topNave.addNewButtonAriaLabel": "创建面板",
|
||||||
"dashboard.topNave.addNewConfigDescription": "在此仪表板上创建新的面板",
|
"dashboard.topNave.addNewConfigDescription": "在此仪表板上创建新的面板",
|
||||||
"dashboard.topNave.cancelButtonAriaLabel": "取消",
|
|
||||||
"dashboard.topNave.cloneButtonAriaLabel": "克隆",
|
"dashboard.topNave.cloneButtonAriaLabel": "克隆",
|
||||||
"dashboard.topNave.cloneConfigDescription": "创建仪表板的副本",
|
"dashboard.topNave.cloneConfigDescription": "创建仪表板的副本",
|
||||||
"dashboard.topNave.editButtonAriaLabel": "编辑",
|
"dashboard.topNave.editButtonAriaLabel": "编辑",
|
||||||
|
|
Loading…
Reference in a new issue