[Maps] fix notifying user about losing unsaved changes when navigating away from map (#72003)
* [Maps] fix notifying user about losing unsaved changes when navigating away from map * clean up * tslint fixes Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
parent
394e7ba8ba
commit
46eba14669
|
@ -17,18 +17,18 @@ import { LoadMapAndRender } from './routes/maps_app/load_map_and_render';
|
|||
export let goToSpecifiedPath;
|
||||
export let kbnUrlStateStorage;
|
||||
|
||||
export async function renderApp(context, { appBasePath, element, history }) {
|
||||
export async function renderApp(context, { appBasePath, element, history, onAppLeave }) {
|
||||
goToSpecifiedPath = (path) => history.push(path);
|
||||
kbnUrlStateStorage = createKbnUrlStateStorage({ useHash: false, history });
|
||||
|
||||
render(<App history={history} appBasePath={appBasePath} />, element);
|
||||
render(<App history={history} appBasePath={appBasePath} onAppLeave={onAppLeave} />, element);
|
||||
|
||||
return () => {
|
||||
unmountComponentAtNode(element);
|
||||
};
|
||||
}
|
||||
|
||||
const App = ({ history, appBasePath }) => {
|
||||
const App = ({ history, appBasePath, onAppLeave }) => {
|
||||
const store = getStore();
|
||||
const I18nContext = getCoreI18n().Context;
|
||||
|
||||
|
@ -37,8 +37,20 @@ const App = ({ history, appBasePath }) => {
|
|||
<Provider store={store}>
|
||||
<Router basename={appBasePath} history={history}>
|
||||
<Switch>
|
||||
<Route path={`/map/:savedMapId`} component={LoadMapAndRender} />
|
||||
<Route exact path={`/map`} component={LoadMapAndRender} />
|
||||
<Route
|
||||
path={`/map/:savedMapId`}
|
||||
render={(props) => (
|
||||
<LoadMapAndRender
|
||||
savedMapId={props.match.params.savedMapId}
|
||||
onAppLeave={onAppLeave}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path={`/map`}
|
||||
render={() => <LoadMapAndRender onAppLeave={onAppLeave} />}
|
||||
/>
|
||||
// Redirect other routes to list, or if hash-containing, their non-hash equivalents
|
||||
<Route
|
||||
path={``}
|
||||
|
|
|
@ -1,60 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { getCoreChrome } from '../../kibana_services';
|
||||
import { MAP_PATH } from '../../../common/constants';
|
||||
import _ from 'lodash';
|
||||
import { getLayerListRaw } from '../../selectors/map_selectors';
|
||||
import { copyPersistentState } from '../../reducers/util';
|
||||
import { getStore } from '../store_operations';
|
||||
import { goToSpecifiedPath } from '../maps_router';
|
||||
|
||||
function hasUnsavedChanges(savedMap, initialLayerListConfig) {
|
||||
const state = getStore().getState();
|
||||
const layerList = getLayerListRaw(state);
|
||||
const layerListConfigOnly = copyPersistentState(layerList);
|
||||
|
||||
const savedLayerList = savedMap.getLayerList();
|
||||
|
||||
return !savedLayerList
|
||||
? !_.isEqual(layerListConfigOnly, initialLayerListConfig)
|
||||
: // savedMap stores layerList as a JSON string using JSON.stringify.
|
||||
// JSON.stringify removes undefined properties from objects.
|
||||
// savedMap.getLayerList converts the JSON string back into Javascript array of objects.
|
||||
// Need to perform the same process for layerListConfigOnly to compare apples to apples
|
||||
// and avoid undefined properties in layerListConfigOnly triggering unsaved changes.
|
||||
!_.isEqual(JSON.parse(JSON.stringify(layerListConfigOnly)), savedLayerList);
|
||||
}
|
||||
|
||||
export const updateBreadcrumbs = (savedMap, initialLayerListConfig, currentPath = '') => {
|
||||
const isOnMapNow = currentPath.startsWith(`/${MAP_PATH}`);
|
||||
const breadCrumbs = isOnMapNow
|
||||
? [
|
||||
{
|
||||
text: i18n.translate('xpack.maps.mapController.mapsBreadcrumbLabel', {
|
||||
defaultMessage: 'Maps',
|
||||
}),
|
||||
onClick: () => {
|
||||
if (hasUnsavedChanges(savedMap, initialLayerListConfig)) {
|
||||
const navigateAway = window.confirm(
|
||||
i18n.translate('xpack.maps.breadCrumbs.unsavedChangesWarning', {
|
||||
defaultMessage: `Your unsaved changes might not be saved`,
|
||||
})
|
||||
);
|
||||
if (navigateAway) {
|
||||
goToSpecifiedPath('/');
|
||||
}
|
||||
} else {
|
||||
goToSpecifiedPath('/');
|
||||
}
|
||||
},
|
||||
},
|
||||
{ text: savedMap.title },
|
||||
]
|
||||
: [];
|
||||
getCoreChrome().setBreadcrumbs(breadCrumbs);
|
||||
};
|
|
@ -21,7 +21,6 @@ import {
|
|||
showSaveModal,
|
||||
} from '../../../../../../../src/plugins/saved_objects/public';
|
||||
import { MAP_SAVED_OBJECT_TYPE } from '../../../../common/constants';
|
||||
import { updateBreadcrumbs } from '../breadcrumbs';
|
||||
import { goToSpecifiedPath } from '../../maps_router';
|
||||
|
||||
export function MapsTopNavMenu({
|
||||
|
@ -35,7 +34,6 @@ export function MapsTopNavMenu({
|
|||
refreshConfig,
|
||||
setRefreshConfig,
|
||||
setRefreshStoreConfig,
|
||||
initialLayerListConfig,
|
||||
indexPatterns,
|
||||
updateFiltersAndDispatch,
|
||||
isSaveDisabled,
|
||||
|
@ -44,7 +42,7 @@ export function MapsTopNavMenu({
|
|||
openMapSettings,
|
||||
inspectorAdapters,
|
||||
syncAppAndGlobalState,
|
||||
currentPath,
|
||||
setBreadcrumbs,
|
||||
isOpenSettingsDisabled,
|
||||
}) {
|
||||
const { TopNavMenu } = getNavigation().ui;
|
||||
|
@ -64,14 +62,13 @@ export function MapsTopNavMenu({
|
|||
// Nav settings
|
||||
const config = getTopNavConfig(
|
||||
savedMap,
|
||||
initialLayerListConfig,
|
||||
isOpenSettingsDisabled,
|
||||
isSaveDisabled,
|
||||
closeFlyout,
|
||||
enableFullScreen,
|
||||
openMapSettings,
|
||||
inspectorAdapters,
|
||||
currentPath
|
||||
setBreadcrumbs
|
||||
);
|
||||
|
||||
const submitQuery = function ({ dateRange, query }) {
|
||||
|
@ -121,14 +118,13 @@ export function MapsTopNavMenu({
|
|||
|
||||
function getTopNavConfig(
|
||||
savedMap,
|
||||
initialLayerListConfig,
|
||||
isOpenSettingsDisabled,
|
||||
isSaveDisabled,
|
||||
closeFlyout,
|
||||
enableFullScreen,
|
||||
openMapSettings,
|
||||
inspectorAdapters,
|
||||
currentPath
|
||||
setBreadcrumbs
|
||||
) {
|
||||
return [
|
||||
{
|
||||
|
@ -210,19 +206,15 @@ function getTopNavConfig(
|
|||
isTitleDuplicateConfirmed,
|
||||
onTitleDuplicate,
|
||||
};
|
||||
return doSave(
|
||||
savedMap,
|
||||
saveOptions,
|
||||
initialLayerListConfig,
|
||||
closeFlyout,
|
||||
currentPath
|
||||
).then((response) => {
|
||||
return doSave(savedMap, saveOptions, closeFlyout, setBreadcrumbs).then(
|
||||
(response) => {
|
||||
// If the save wasn't successful, put the original values back.
|
||||
if (!response.id || response.error) {
|
||||
savedMap.title = currentTitle;
|
||||
}
|
||||
return response;
|
||||
});
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const saveModal = (
|
||||
|
@ -243,7 +235,7 @@ function getTopNavConfig(
|
|||
];
|
||||
}
|
||||
|
||||
async function doSave(savedMap, saveOptions, initialLayerListConfig, closeFlyout, currentPath) {
|
||||
async function doSave(savedMap, saveOptions, closeFlyout, setBreadcrumbs) {
|
||||
closeFlyout();
|
||||
savedMap.syncWithStore();
|
||||
let id;
|
||||
|
@ -265,7 +257,7 @@ async function doSave(savedMap, saveOptions, initialLayerListConfig, closeFlyout
|
|||
|
||||
if (id) {
|
||||
goToSpecifiedPath(`/map/${id}${window.location.hash}`);
|
||||
updateBreadcrumbs(savedMap, initialLayerListConfig, currentPath);
|
||||
setBreadcrumbs();
|
||||
|
||||
getToasts().addSuccess({
|
||||
title: i18n.translate('xpack.maps.mapController.saveSuccessMessage', {
|
||||
|
|
|
@ -33,7 +33,6 @@ import { i18n } from '@kbn/i18n';
|
|||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import { addHelpMenuToAppChrome } from '../../../help_menu_util';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { updateBreadcrumbs } from '../../page_elements/breadcrumbs';
|
||||
import { goToSpecifiedPath } from '../../maps_router';
|
||||
|
||||
export const EMPTY_FILTER = '';
|
||||
|
@ -53,17 +52,13 @@ export class MapsListView extends React.Component {
|
|||
listingLimit: getUiSettings().get('savedObjects:listingLimit'),
|
||||
};
|
||||
|
||||
UNSAFE_componentWillMount() {
|
||||
this._isMounted = true;
|
||||
updateBreadcrumbs();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this._isMounted = false;
|
||||
this.debouncedFetch.cancel();
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this._isMounted = true;
|
||||
this.initMapList();
|
||||
}
|
||||
|
||||
|
|
|
@ -11,6 +11,7 @@ import {
|
|||
getFilters,
|
||||
getQueryableUniqueIndexPatternIds,
|
||||
getRefreshConfig,
|
||||
hasUnsavedChanges,
|
||||
} from '../../../selectors/map_selectors';
|
||||
import {
|
||||
replaceLayerList,
|
||||
|
@ -34,6 +35,9 @@ function mapStateToProps(state = {}) {
|
|||
flyoutDisplay: getFlyoutDisplay(state),
|
||||
refreshConfig: getRefreshConfig(state),
|
||||
filters: getFilters(state),
|
||||
hasUnsavedChanges: (savedMap, initialLayerListConfig) => {
|
||||
return hasUnsavedChanges(state, savedMap, initialLayerListConfig);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -27,9 +27,8 @@ export const LoadMapAndRender = class extends React.Component {
|
|||
}
|
||||
|
||||
async _loadSavedMap() {
|
||||
const { savedMapId } = this.props.match.params;
|
||||
try {
|
||||
const savedMap = await getMapsSavedObjectLoader().get(savedMapId);
|
||||
const savedMap = await getMapsSavedObjectLoader().get(this.props.savedMapId);
|
||||
if (this._isMounted) {
|
||||
this.setState({ savedMap });
|
||||
}
|
||||
|
@ -48,11 +47,11 @@ export const LoadMapAndRender = class extends React.Component {
|
|||
|
||||
render() {
|
||||
const { savedMap, failedToLoad } = this.state;
|
||||
|
||||
if (failedToLoad) {
|
||||
return <Redirect to="/" />;
|
||||
}
|
||||
|
||||
const currentPath = this.props.match.url;
|
||||
return savedMap ? <MapsAppView savedMap={savedMap} currentPath={currentPath} /> : null;
|
||||
return savedMap ? <MapsAppView savedMap={savedMap} onAppLeave={this.props.onAppLeave} /> : null;
|
||||
}
|
||||
};
|
||||
|
|
|
@ -30,12 +30,15 @@ import {
|
|||
} from '../../state_syncing/global_sync';
|
||||
import { AppStateManager } from '../../state_syncing/app_state_manager';
|
||||
import { useAppStateSyncing } from '../../state_syncing/app_sync';
|
||||
import { updateBreadcrumbs } from '../../page_elements/breadcrumbs';
|
||||
import { esFilters } from '../../../../../../../src/plugins/data/public';
|
||||
import { GisMap } from '../../../connected_components/gis_map';
|
||||
import { goToSpecifiedPath } from '../../maps_router';
|
||||
|
||||
const unsavedChangesWarning = i18n.translate('xpack.maps.breadCrumbs.unsavedChangesWarning', {
|
||||
defaultMessage: 'Your map has unsaved changes. Are you sure you want to leave?',
|
||||
});
|
||||
|
||||
export class MapsAppView extends React.Component {
|
||||
_visibleSubscription = null;
|
||||
_globalSyncUnsubscribe = null;
|
||||
_globalSyncChangeMonitorSubscription = null;
|
||||
_appSyncUnsubscribe = null;
|
||||
|
@ -47,16 +50,13 @@ export class MapsAppView extends React.Component {
|
|||
indexPatterns: [],
|
||||
prevIndexPatternIds: [],
|
||||
initialized: false,
|
||||
isVisible: true,
|
||||
savedQuery: '',
|
||||
currentPath: '',
|
||||
initialLayerListConfig: null,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { savedMap, currentPath } = this.props;
|
||||
this.setState({ currentPath });
|
||||
const { savedMap } = this.props;
|
||||
|
||||
getCoreChrome().docTitle.change(savedMap.title);
|
||||
getCoreChrome().recentlyAccessed.add(savedMap.getFullPath(), savedMap.title, savedMap.id);
|
||||
|
@ -77,29 +77,72 @@ export class MapsAppView extends React.Component {
|
|||
this._updateStateFromSavedQuery(initAppState.savedQuery);
|
||||
}
|
||||
|
||||
// Monitor visibility
|
||||
this._visibleSubscription = getCoreChrome()
|
||||
.getIsVisible$()
|
||||
.subscribe((isVisible) => this.setState({ isVisible }));
|
||||
this._initMap();
|
||||
|
||||
this._setBreadcrumbs();
|
||||
|
||||
this.props.onAppLeave((actions) => {
|
||||
if (this._hasUnsavedChanges()) {
|
||||
if (!window.confirm(unsavedChangesWarning)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
return actions.default();
|
||||
});
|
||||
}
|
||||
|
||||
_initBreadcrumbUpdater = () => {
|
||||
const { initialLayerListConfig, currentPath } = this.state;
|
||||
updateBreadcrumbs(this.props.savedMap, initialLayerListConfig, currentPath);
|
||||
};
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
const { currentPath: prevCurrentPath } = prevState;
|
||||
const { currentPath, initialLayerListConfig } = this.state;
|
||||
const { savedMap } = this.props;
|
||||
if (savedMap && initialLayerListConfig && currentPath !== prevCurrentPath) {
|
||||
updateBreadcrumbs(savedMap, initialLayerListConfig, currentPath);
|
||||
}
|
||||
componentDidUpdate() {
|
||||
// TODO: Handle null when converting to TS
|
||||
this._handleStoreChanges();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this._globalSyncUnsubscribe) {
|
||||
this._globalSyncUnsubscribe();
|
||||
}
|
||||
if (this._appSyncUnsubscribe) {
|
||||
this._appSyncUnsubscribe();
|
||||
}
|
||||
if (this._globalSyncChangeMonitorSubscription) {
|
||||
this._globalSyncChangeMonitorSubscription.unsubscribe();
|
||||
}
|
||||
|
||||
// Clean up app state filters
|
||||
const { filterManager } = getData().query;
|
||||
filterManager.filters.forEach((filter) => {
|
||||
if (filter.$state.store === esFilters.FilterStateStore.APP_STATE) {
|
||||
filterManager.removeFilter(filter);
|
||||
}
|
||||
});
|
||||
|
||||
getCoreChrome().setBreadcrumbs([]);
|
||||
}
|
||||
|
||||
_hasUnsavedChanges() {
|
||||
return this.props.hasUnsavedChanges(this.props.savedMap, this.state.initialLayerListConfig);
|
||||
}
|
||||
|
||||
_setBreadcrumbs = () => {
|
||||
getCoreChrome().setBreadcrumbs([
|
||||
{
|
||||
text: i18n.translate('xpack.maps.mapController.mapsBreadcrumbLabel', {
|
||||
defaultMessage: 'Maps',
|
||||
}),
|
||||
onClick: () => {
|
||||
if (this._hasUnsavedChanges()) {
|
||||
const navigateAway = window.confirm(unsavedChangesWarning);
|
||||
if (navigateAway) {
|
||||
goToSpecifiedPath('/');
|
||||
}
|
||||
} else {
|
||||
goToSpecifiedPath('/');
|
||||
}
|
||||
},
|
||||
},
|
||||
{ text: this.props.savedMap.title },
|
||||
]);
|
||||
};
|
||||
|
||||
_updateFromGlobalState = ({ changes, state: globalState }) => {
|
||||
if (!changes || !globalState) {
|
||||
return;
|
||||
|
@ -120,29 +163,6 @@ export class MapsAppView extends React.Component {
|
|||
});
|
||||
};
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this._globalSyncUnsubscribe) {
|
||||
this._globalSyncUnsubscribe();
|
||||
}
|
||||
if (this._appSyncUnsubscribe) {
|
||||
this._appSyncUnsubscribe();
|
||||
}
|
||||
if (this._visibleSubscription) {
|
||||
this._visibleSubscription.unsubscribe();
|
||||
}
|
||||
if (this._globalSyncChangeMonitorSubscription) {
|
||||
this._globalSyncChangeMonitorSubscription.unsubscribe();
|
||||
}
|
||||
|
||||
// Clean up app state filters
|
||||
const { filterManager } = getData().query;
|
||||
filterManager.filters.forEach((filter) => {
|
||||
if (filter.$state.store === esFilters.FilterStateStore.APP_STATE) {
|
||||
filterManager.removeFilter(filter);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_getInitialLayersFromUrlParam() {
|
||||
const locationSplit = window.location.href.split('?');
|
||||
if (locationSplit.length <= 1) {
|
||||
|
@ -301,13 +321,9 @@ export class MapsAppView extends React.Component {
|
|||
this._getInitialLayersFromUrlParam()
|
||||
);
|
||||
this.props.replaceLayerList(layerList);
|
||||
this.setState(
|
||||
{
|
||||
this.setState({
|
||||
initialLayerListConfig: copyPersistentState(layerList),
|
||||
savedMap,
|
||||
},
|
||||
this._initBreadcrumbUpdater
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
_updateFiltersAndDispatch = (filters) => {
|
||||
|
@ -407,18 +423,10 @@ export class MapsAppView extends React.Component {
|
|||
}
|
||||
|
||||
_renderTopNav() {
|
||||
const {
|
||||
query,
|
||||
time,
|
||||
savedQuery,
|
||||
initialLayerListConfig,
|
||||
isVisible,
|
||||
indexPatterns,
|
||||
currentPath,
|
||||
} = this.state;
|
||||
const { savedMap, refreshConfig } = this.props;
|
||||
const { query, time, savedQuery, indexPatterns } = this.state;
|
||||
const { savedMap, refreshConfig, isFullScreen } = this.props;
|
||||
|
||||
return isVisible ? (
|
||||
return !isFullScreen ? (
|
||||
<MapsTopNavMenu
|
||||
savedMap={savedMap}
|
||||
query={query}
|
||||
|
@ -434,7 +442,6 @@ export class MapsAppView extends React.Component {
|
|||
callback
|
||||
);
|
||||
}}
|
||||
initialLayerListConfig={initialLayerListConfig}
|
||||
indexPatterns={indexPatterns}
|
||||
updateFiltersAndDispatch={this._updateFiltersAndDispatch}
|
||||
onQuerySaved={(query) => {
|
||||
|
@ -448,7 +455,7 @@ export class MapsAppView extends React.Component {
|
|||
this._updateStateFromSavedQuery(query);
|
||||
}}
|
||||
syncAppAndGlobalState={this._syncAppAndGlobalState}
|
||||
currentPath={currentPath}
|
||||
setBreadcrumbs={this._setBreadcrumbs}
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
|
|
|
@ -416,3 +416,23 @@ export const areLayersLoaded = createSelector(
|
|||
return true;
|
||||
}
|
||||
);
|
||||
|
||||
export function hasUnsavedChanges(
|
||||
state: MapStoreState,
|
||||
savedMap: unknown,
|
||||
initialLayerListConfig: LayerDescriptor[]
|
||||
) {
|
||||
const layerListConfigOnly = copyPersistentState(getLayerListRaw(state));
|
||||
|
||||
// @ts-expect-error
|
||||
const savedLayerList = savedMap.getLayerList();
|
||||
|
||||
return !savedLayerList
|
||||
? !_.isEqual(layerListConfigOnly, initialLayerListConfig)
|
||||
: // savedMap stores layerList as a JSON string using JSON.stringify.
|
||||
// JSON.stringify removes undefined properties from objects.
|
||||
// savedMap.getLayerList converts the JSON string back into Javascript array of objects.
|
||||
// Need to perform the same process for layerListConfigOnly to compare apples to apples
|
||||
// and avoid undefined properties in layerListConfigOnly triggering unsaved changes.
|
||||
!_.isEqual(JSON.parse(JSON.stringify(layerListConfigOnly)), savedLayerList);
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue