diff --git a/x-pack/plugins/maps/public/routing/maps_router.js b/x-pack/plugins/maps/public/routing/maps_router.js index 840d4f2c6692..30b2137399c1 100644 --- a/x-pack/plugins/maps/public/routing/maps_router.js +++ b/x-pack/plugins/maps/public/routing/maps_router.js @@ -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(, element); + render(, 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 }) => { - - + ( + + )} + /> + } + /> // Redirect other routes to list, or if hash-containing, their non-hash equivalents { - 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); -}; diff --git a/x-pack/plugins/maps/public/routing/page_elements/top_nav_menu/top_nav_menu.js b/x-pack/plugins/maps/public/routing/page_elements/top_nav_menu/top_nav_menu.js index 762d61d6d33f..ac2dec0db59c 100644 --- a/x-pack/plugins/maps/public/routing/page_elements/top_nav_menu/top_nav_menu.js +++ b/x-pack/plugins/maps/public/routing/page_elements/top_nav_menu/top_nav_menu.js @@ -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', { diff --git a/x-pack/plugins/maps/public/routing/routes/list/maps_list_view.js b/x-pack/plugins/maps/public/routing/routes/list/maps_list_view.js index a32bd00dbae5..e9229883d708 100644 --- a/x-pack/plugins/maps/public/routing/routes/list/maps_list_view.js +++ b/x-pack/plugins/maps/public/routing/routes/list/maps_list_view.js @@ -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(); } diff --git a/x-pack/plugins/maps/public/routing/routes/maps_app/index.js b/x-pack/plugins/maps/public/routing/routes/maps_app/index.js index 6b47ac6e0352..9b0d52e4fe29 100644 --- a/x-pack/plugins/maps/public/routing/routes/maps_app/index.js +++ b/x-pack/plugins/maps/public/routing/routes/maps_app/index.js @@ -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); + }, }; } diff --git a/x-pack/plugins/maps/public/routing/routes/maps_app/load_map_and_render.js b/x-pack/plugins/maps/public/routing/routes/maps_app/load_map_and_render.js index a17b83502e04..c87f6eb33053 100644 --- a/x-pack/plugins/maps/public/routing/routes/maps_app/load_map_and_render.js +++ b/x-pack/plugins/maps/public/routing/routes/maps_app/load_map_and_render.js @@ -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 ; } - const currentPath = this.props.match.url; - return savedMap ? : null; + return savedMap ? : null; } }; diff --git a/x-pack/plugins/maps/public/routing/routes/maps_app/maps_app_view.js b/x-pack/plugins/maps/public/routing/routes/maps_app/maps_app_view.js index bf92f5a33712..29fbb5f46e29 100644 --- a/x-pack/plugins/maps/public/routing/routes/maps_app/maps_app_view.js +++ b/x-pack/plugins/maps/public/routing/routes/maps_app/maps_app_view.js @@ -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 ? ( { @@ -448,7 +455,7 @@ export class MapsAppView extends React.Component { this._updateStateFromSavedQuery(query); }} syncAppAndGlobalState={this._syncAppAndGlobalState} - currentPath={currentPath} + setBreadcrumbs={this._setBreadcrumbs} /> ) : null; } diff --git a/x-pack/plugins/maps/public/selectors/map_selectors.ts b/x-pack/plugins/maps/public/selectors/map_selectors.ts index f400e242b697..fe2cfec3c761 100644 --- a/x-pack/plugins/maps/public/selectors/map_selectors.ts +++ b/x-pack/plugins/maps/public/selectors/map_selectors.ts @@ -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); +}