[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:
Nathan Reese 2020-07-16 12:16:29 -06:00 committed by GitHub
parent 394e7ba8ba
commit 46eba14669
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 130 additions and 161 deletions

View file

@ -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={``}

View file

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

View file

@ -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) => {
// If the save wasn't successful, put the original values back.
if (!response.id || response.error) {
savedMap.title = currentTitle;
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;
}
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', {

View file

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

View file

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

View file

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

View file

@ -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(
{
initialLayerListConfig: copyPersistentState(layerList),
savedMap,
},
this._initBreadcrumbUpdater
);
this.setState({
initialLayerListConfig: copyPersistentState(layerList),
});
}
_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;
}

View file

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