[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 goToSpecifiedPath;
export let kbnUrlStateStorage; 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); goToSpecifiedPath = (path) => history.push(path);
kbnUrlStateStorage = createKbnUrlStateStorage({ useHash: false, history }); kbnUrlStateStorage = createKbnUrlStateStorage({ useHash: false, history });
render(<App history={history} appBasePath={appBasePath} />, element); render(<App history={history} appBasePath={appBasePath} onAppLeave={onAppLeave} />, element);
return () => { return () => {
unmountComponentAtNode(element); unmountComponentAtNode(element);
}; };
} }
const App = ({ history, appBasePath }) => { const App = ({ history, appBasePath, onAppLeave }) => {
const store = getStore(); const store = getStore();
const I18nContext = getCoreI18n().Context; const I18nContext = getCoreI18n().Context;
@ -37,8 +37,20 @@ const App = ({ history, appBasePath }) => {
<Provider store={store}> <Provider store={store}>
<Router basename={appBasePath} history={history}> <Router basename={appBasePath} history={history}>
<Switch> <Switch>
<Route path={`/map/:savedMapId`} component={LoadMapAndRender} /> <Route
<Route exact path={`/map`} component={LoadMapAndRender} /> 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 // Redirect other routes to list, or if hash-containing, their non-hash equivalents
<Route <Route
path={``} 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, showSaveModal,
} from '../../../../../../../src/plugins/saved_objects/public'; } from '../../../../../../../src/plugins/saved_objects/public';
import { MAP_SAVED_OBJECT_TYPE } from '../../../../common/constants'; import { MAP_SAVED_OBJECT_TYPE } from '../../../../common/constants';
import { updateBreadcrumbs } from '../breadcrumbs';
import { goToSpecifiedPath } from '../../maps_router'; import { goToSpecifiedPath } from '../../maps_router';
export function MapsTopNavMenu({ export function MapsTopNavMenu({
@ -35,7 +34,6 @@ export function MapsTopNavMenu({
refreshConfig, refreshConfig,
setRefreshConfig, setRefreshConfig,
setRefreshStoreConfig, setRefreshStoreConfig,
initialLayerListConfig,
indexPatterns, indexPatterns,
updateFiltersAndDispatch, updateFiltersAndDispatch,
isSaveDisabled, isSaveDisabled,
@ -44,7 +42,7 @@ export function MapsTopNavMenu({
openMapSettings, openMapSettings,
inspectorAdapters, inspectorAdapters,
syncAppAndGlobalState, syncAppAndGlobalState,
currentPath, setBreadcrumbs,
isOpenSettingsDisabled, isOpenSettingsDisabled,
}) { }) {
const { TopNavMenu } = getNavigation().ui; const { TopNavMenu } = getNavigation().ui;
@ -64,14 +62,13 @@ export function MapsTopNavMenu({
// Nav settings // Nav settings
const config = getTopNavConfig( const config = getTopNavConfig(
savedMap, savedMap,
initialLayerListConfig,
isOpenSettingsDisabled, isOpenSettingsDisabled,
isSaveDisabled, isSaveDisabled,
closeFlyout, closeFlyout,
enableFullScreen, enableFullScreen,
openMapSettings, openMapSettings,
inspectorAdapters, inspectorAdapters,
currentPath setBreadcrumbs
); );
const submitQuery = function ({ dateRange, query }) { const submitQuery = function ({ dateRange, query }) {
@ -121,14 +118,13 @@ export function MapsTopNavMenu({
function getTopNavConfig( function getTopNavConfig(
savedMap, savedMap,
initialLayerListConfig,
isOpenSettingsDisabled, isOpenSettingsDisabled,
isSaveDisabled, isSaveDisabled,
closeFlyout, closeFlyout,
enableFullScreen, enableFullScreen,
openMapSettings, openMapSettings,
inspectorAdapters, inspectorAdapters,
currentPath setBreadcrumbs
) { ) {
return [ return [
{ {
@ -210,19 +206,15 @@ function getTopNavConfig(
isTitleDuplicateConfirmed, isTitleDuplicateConfirmed,
onTitleDuplicate, onTitleDuplicate,
}; };
return doSave( return doSave(savedMap, saveOptions, closeFlyout, setBreadcrumbs).then(
savedMap, (response) => {
saveOptions, // If the save wasn't successful, put the original values back.
initialLayerListConfig, if (!response.id || response.error) {
closeFlyout, savedMap.title = currentTitle;
currentPath }
).then((response) => { return response;
// If the save wasn't successful, put the original values back.
if (!response.id || response.error) {
savedMap.title = currentTitle;
} }
return response; );
});
}; };
const saveModal = ( const saveModal = (
@ -243,7 +235,7 @@ function getTopNavConfig(
]; ];
} }
async function doSave(savedMap, saveOptions, initialLayerListConfig, closeFlyout, currentPath) { async function doSave(savedMap, saveOptions, closeFlyout, setBreadcrumbs) {
closeFlyout(); closeFlyout();
savedMap.syncWithStore(); savedMap.syncWithStore();
let id; let id;
@ -265,7 +257,7 @@ async function doSave(savedMap, saveOptions, initialLayerListConfig, closeFlyout
if (id) { if (id) {
goToSpecifiedPath(`/map/${id}${window.location.hash}`); goToSpecifiedPath(`/map/${id}${window.location.hash}`);
updateBreadcrumbs(savedMap, initialLayerListConfig, currentPath); setBreadcrumbs();
getToasts().addSuccess({ getToasts().addSuccess({
title: i18n.translate('xpack.maps.mapController.saveSuccessMessage', { 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 { FormattedMessage } from '@kbn/i18n/react';
import { addHelpMenuToAppChrome } from '../../../help_menu_util'; import { addHelpMenuToAppChrome } from '../../../help_menu_util';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { updateBreadcrumbs } from '../../page_elements/breadcrumbs';
import { goToSpecifiedPath } from '../../maps_router'; import { goToSpecifiedPath } from '../../maps_router';
export const EMPTY_FILTER = ''; export const EMPTY_FILTER = '';
@ -53,17 +52,13 @@ export class MapsListView extends React.Component {
listingLimit: getUiSettings().get('savedObjects:listingLimit'), listingLimit: getUiSettings().get('savedObjects:listingLimit'),
}; };
UNSAFE_componentWillMount() {
this._isMounted = true;
updateBreadcrumbs();
}
componentWillUnmount() { componentWillUnmount() {
this._isMounted = false; this._isMounted = false;
this.debouncedFetch.cancel(); this.debouncedFetch.cancel();
} }
componentDidMount() { componentDidMount() {
this._isMounted = true;
this.initMapList(); this.initMapList();
} }

View file

@ -11,6 +11,7 @@ import {
getFilters, getFilters,
getQueryableUniqueIndexPatternIds, getQueryableUniqueIndexPatternIds,
getRefreshConfig, getRefreshConfig,
hasUnsavedChanges,
} from '../../../selectors/map_selectors'; } from '../../../selectors/map_selectors';
import { import {
replaceLayerList, replaceLayerList,
@ -34,6 +35,9 @@ function mapStateToProps(state = {}) {
flyoutDisplay: getFlyoutDisplay(state), flyoutDisplay: getFlyoutDisplay(state),
refreshConfig: getRefreshConfig(state), refreshConfig: getRefreshConfig(state),
filters: getFilters(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() { async _loadSavedMap() {
const { savedMapId } = this.props.match.params;
try { try {
const savedMap = await getMapsSavedObjectLoader().get(savedMapId); const savedMap = await getMapsSavedObjectLoader().get(this.props.savedMapId);
if (this._isMounted) { if (this._isMounted) {
this.setState({ savedMap }); this.setState({ savedMap });
} }
@ -48,11 +47,11 @@ export const LoadMapAndRender = class extends React.Component {
render() { render() {
const { savedMap, failedToLoad } = this.state; const { savedMap, failedToLoad } = this.state;
if (failedToLoad) { if (failedToLoad) {
return <Redirect to="/" />; return <Redirect to="/" />;
} }
const currentPath = this.props.match.url; return savedMap ? <MapsAppView savedMap={savedMap} onAppLeave={this.props.onAppLeave} /> : null;
return savedMap ? <MapsAppView savedMap={savedMap} currentPath={currentPath} /> : null;
} }
}; };

View file

@ -30,12 +30,15 @@ import {
} from '../../state_syncing/global_sync'; } from '../../state_syncing/global_sync';
import { AppStateManager } from '../../state_syncing/app_state_manager'; import { AppStateManager } from '../../state_syncing/app_state_manager';
import { useAppStateSyncing } from '../../state_syncing/app_sync'; import { useAppStateSyncing } from '../../state_syncing/app_sync';
import { updateBreadcrumbs } from '../../page_elements/breadcrumbs';
import { esFilters } from '../../../../../../../src/plugins/data/public'; import { esFilters } from '../../../../../../../src/plugins/data/public';
import { GisMap } from '../../../connected_components/gis_map'; 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 { export class MapsAppView extends React.Component {
_visibleSubscription = null;
_globalSyncUnsubscribe = null; _globalSyncUnsubscribe = null;
_globalSyncChangeMonitorSubscription = null; _globalSyncChangeMonitorSubscription = null;
_appSyncUnsubscribe = null; _appSyncUnsubscribe = null;
@ -47,16 +50,13 @@ export class MapsAppView extends React.Component {
indexPatterns: [], indexPatterns: [],
prevIndexPatternIds: [], prevIndexPatternIds: [],
initialized: false, initialized: false,
isVisible: true,
savedQuery: '', savedQuery: '',
currentPath: '',
initialLayerListConfig: null, initialLayerListConfig: null,
}; };
} }
componentDidMount() { componentDidMount() {
const { savedMap, currentPath } = this.props; const { savedMap } = this.props;
this.setState({ currentPath });
getCoreChrome().docTitle.change(savedMap.title); getCoreChrome().docTitle.change(savedMap.title);
getCoreChrome().recentlyAccessed.add(savedMap.getFullPath(), savedMap.title, savedMap.id); getCoreChrome().recentlyAccessed.add(savedMap.getFullPath(), savedMap.title, savedMap.id);
@ -77,29 +77,72 @@ export class MapsAppView extends React.Component {
this._updateStateFromSavedQuery(initAppState.savedQuery); this._updateStateFromSavedQuery(initAppState.savedQuery);
} }
// Monitor visibility
this._visibleSubscription = getCoreChrome()
.getIsVisible$()
.subscribe((isVisible) => this.setState({ isVisible }));
this._initMap(); this._initMap();
this._setBreadcrumbs();
this.props.onAppLeave((actions) => {
if (this._hasUnsavedChanges()) {
if (!window.confirm(unsavedChangesWarning)) {
return;
}
}
return actions.default();
});
} }
_initBreadcrumbUpdater = () => { componentDidUpdate() {
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);
}
// TODO: Handle null when converting to TS // TODO: Handle null when converting to TS
this._handleStoreChanges(); 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 }) => { _updateFromGlobalState = ({ changes, state: globalState }) => {
if (!changes || !globalState) { if (!changes || !globalState) {
return; 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() { _getInitialLayersFromUrlParam() {
const locationSplit = window.location.href.split('?'); const locationSplit = window.location.href.split('?');
if (locationSplit.length <= 1) { if (locationSplit.length <= 1) {
@ -301,13 +321,9 @@ export class MapsAppView extends React.Component {
this._getInitialLayersFromUrlParam() this._getInitialLayersFromUrlParam()
); );
this.props.replaceLayerList(layerList); this.props.replaceLayerList(layerList);
this.setState( this.setState({
{ initialLayerListConfig: copyPersistentState(layerList),
initialLayerListConfig: copyPersistentState(layerList), });
savedMap,
},
this._initBreadcrumbUpdater
);
} }
_updateFiltersAndDispatch = (filters) => { _updateFiltersAndDispatch = (filters) => {
@ -407,18 +423,10 @@ export class MapsAppView extends React.Component {
} }
_renderTopNav() { _renderTopNav() {
const { const { query, time, savedQuery, indexPatterns } = this.state;
query, const { savedMap, refreshConfig, isFullScreen } = this.props;
time,
savedQuery,
initialLayerListConfig,
isVisible,
indexPatterns,
currentPath,
} = this.state;
const { savedMap, refreshConfig } = this.props;
return isVisible ? ( return !isFullScreen ? (
<MapsTopNavMenu <MapsTopNavMenu
savedMap={savedMap} savedMap={savedMap}
query={query} query={query}
@ -434,7 +442,6 @@ export class MapsAppView extends React.Component {
callback callback
); );
}} }}
initialLayerListConfig={initialLayerListConfig}
indexPatterns={indexPatterns} indexPatterns={indexPatterns}
updateFiltersAndDispatch={this._updateFiltersAndDispatch} updateFiltersAndDispatch={this._updateFiltersAndDispatch}
onQuerySaved={(query) => { onQuerySaved={(query) => {
@ -448,7 +455,7 @@ export class MapsAppView extends React.Component {
this._updateStateFromSavedQuery(query); this._updateStateFromSavedQuery(query);
}} }}
syncAppAndGlobalState={this._syncAppAndGlobalState} syncAppAndGlobalState={this._syncAppAndGlobalState}
currentPath={currentPath} setBreadcrumbs={this._setBreadcrumbs}
/> />
) : null; ) : null;
} }

View file

@ -416,3 +416,23 @@ export const areLayersLoaded = createSelector(
return true; 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);
}