From b70388fb0437e91d9a2df6880d3b34ea6167688f Mon Sep 17 00:00:00 2001 From: Zack LaVergne Date: Sun, 17 Apr 2022 01:34:33 -0500 Subject: [PATCH] Handle switching editors with unsaved edits (#5096) * Update RapiD tag to fix css issues * Add RapiD context to redux store and use in rapidEditor * Handle switching editors with unsaved edits * Display error popup if JOSM is not running Co-authored-by: Hel Nershing Thapa --- frontend/package.json | 5 +- frontend/src/components/rapidEditor.js | 176 +++++++++--------- .../src/components/taskSelection/action.js | 85 ++++++--- .../taskSelection/actionSidebars.js | 29 ++- .../src/components/taskSelection/messages.js | 8 + frontend/src/store/actions/editor.js | 1 + frontend/src/store/reducers/editor.js | 7 + frontend/yarn.lock | 7 +- 8 files changed, 195 insertions(+), 123 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index fd5274f5a1..d5284469a0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -28,6 +28,7 @@ "@turf/transform-scale": "^6.4.0", "@turf/truncate": "^6.4.0", "@webscopeio/react-textarea-autocomplete": "^4.7.3", + "RapiD": "facebookincubator/rapid#rapid-v1.1.8-tm.1", "axios": "^0.21.1", "chart.js": "^3.3.2", "date-fns": "^2.28.0", @@ -78,8 +79,8 @@ }, "scripts": { "build-locales": "combine-messages -i './src/**/messages.js' -o './src/locales/en.json'", - "copy-static": "bash -c \"mkdir -p public/static/id; mkdir -p public/static/rapid; if ! (test -a public/static/id/index.js); then cp -R node_modules/@hotosm/id/dist/* public/static/id; elif ! (test -a public/static/rapid/index.js); then cp -R node_modules/RapiD/dist/* public/static/rapid; fi\"", - "update-static": "bash -c \"mkdir -p public/static/id; mkdir -p public/static/rapid; cp -R node_modules/@hotosm/id/dist/* public/static/id; cp -R node_modules/RapiD/dist/* public/static/rapid;\"", + "copy-static": "bash -c \"mkdir -p public/static/id; mkdir -p public/static/rapid; if ! (test -a public/static/id/index.js); then cp -R node_modules/@hotosm/id/dist/* public/static/id; fi; if ! (test -a public/static/rapid/index.js); then if ! (test -a node_modules/RapiD/dist/RapiD.css) then mv node_modules/RapiD/dist/iD.css node_modules/RapiD/dist/RapiD.css; fi; cp -R node_modules/RapiD/dist/* public/static/rapid; fi\"", + "update-static": "bash -c \"mkdir -p public/static/id; mkdir -p public/static/rapid; cp -R node_modules/@hotosm/id/dist/* public/static/id; if ! (test -a node_modules/RapiD/dist/RapiD.css) then mv node_modules/RapiD/dist/iD.css node_modules/RapiD/dist/RapiD.css; fi; cp -R node_modules/RapiD/dist/* public/static/rapid;\"", "preparation": "bash -c \"if (test -a ../tasking-manager.env); then grep -hs ^ ../tasking-manager.env .env.expand > .env; else cp .env.expand .env; fi\"", "start": "npm run preparation && npm run copy-static && react-scripts start", "build": "npm run preparation && npm run update-static && react-scripts build", diff --git a/frontend/src/components/rapidEditor.js b/frontend/src/components/rapidEditor.js index fcb7e3e9a0..b89e3fbb6b 100644 --- a/frontend/src/components/rapidEditor.js +++ b/frontend/src/components/rapidEditor.js @@ -1,119 +1,115 @@ import React, { useEffect, useState } from 'react'; import { useSelector, useDispatch } from 'react-redux'; import * as RapiD from 'RapiD/dist/iD.legacy'; -import 'RapiD/dist/iD.css'; +import 'RapiD/dist/RapiD.css'; import { OSM_CONSUMER_KEY, OSM_CONSUMER_SECRET, OSM_SERVER_URL } from '../config'; export default function RapidEditor({ setDisable, comment, presets, imagery, gpxUrl, powerUser = false }) { const dispatch = useDispatch(); const session = useSelector((state) => state.auth.get('session')); - const iDContext = useSelector((state) => state.editor.context); + const RapiDContext = useSelector((state) => state.editor.rapidContext); const locale = useSelector((state) => state.preferences.locale); const [customImageryIsSet, setCustomImageryIsSet] = useState(false); const windowInit = typeof window !== undefined; const customSource = - iDContext && iDContext.background() && iDContext.background().findSource('custom'); + RapiDContext && RapiDContext.background() && RapiDContext.background().findSource('custom'); - useEffect(() => { - if (!customImageryIsSet && imagery && customSource) { - if (imagery.startsWith('http')) { - iDContext.background().baseLayerSource(customSource.template(imagery)); - setCustomImageryIsSet(true); - // this line is needed to update the value on the custom background dialog - window.iD.prefs('background-custom-template', imagery); - } else { - const imagerySource = iDContext.background().findSource(imagery); - if (imagerySource) { - iDContext.background().baseLayerSource(imagerySource); - } + useEffect(() => { + if (!customImageryIsSet && imagery && customSource) { + if (imagery.startsWith('http')) { + RapiDContext.background().baseLayerSource(customSource.template(imagery)); + setCustomImageryIsSet(true); + // this line is needed to update the value on the custom background dialog + window.iD.prefs('background-custom-template', imagery); + } else { + const imagerySource = RapiDContext.background().findSource(imagery); + if (imagerySource) { + RapiDContext.background().baseLayerSource(imagerySource); } } - }, [customImageryIsSet, imagery, iDContext, customSource]); + } + }, [customImageryIsSet, imagery, RapiDContext, customSource]); - useEffect(() => { - return () => { - dispatch({ type: 'SET_VISIBILITY', isVisible: true }); - }; - // eslint-disable-next-line - }, []); + useEffect(() => { + return () => { + dispatch({ type: 'SET_VISIBILITY', isVisible: true }); + }; + // eslint-disable-next-line + }, []); - useEffect(() => { - if (windowInit) { - dispatch({ type: 'SET_VISIBILITY', isVisible: false }); - if (iDContext === null) { - // we need to keep iD context on redux store because iD works better if - // the context is not restarted while running in the same browser session - dispatch({ type: 'SET_EDITOR', context: window.iD.coreContext()}) - // } else{ - // if (RapiDContext === null) { - // dispatch({ type: 'SET_EDITOR', context: iDContext, rapidContext: window.iD.coreRapidContext(())}) - // } - // } - } + useEffect(() => { + if (windowInit) { + dispatch({ type: 'SET_VISIBILITY', isVisible: false }); + if (RapiDContext === null) { + // we need to keep iD context on redux store because iD works better if + // the context is not restarted while running in the same browser session + dispatch({ type: 'SET_RAPIDEDITOR', context: window.iD.coreContext() }) } - }, [windowInit, iDContext, dispatch]); - useEffect(() => { - if (iDContext && comment) { - iDContext.defaultChangesetComment(comment); - } - }, [comment, iDContext]); + } + }, [windowInit, RapiDContext, dispatch]); - useEffect(() => { - if (session && locale && RapiD && iDContext) { - // if presets is not a populated list we need to set it as null - try { - if (presets.length) { - window.iD.presetManager.addablePresetIDs(presets); - } else { - window.iD.presetManager.addablePresetIDs(null); - } - } catch (e) { - window.iD.presetManager.addablePresetIDs(null); - } - // setup the context - iDContext - .embed(true) - .assetPath('/static/rapid/') - .locale(locale) - .setsDocumentTitle(false) - .containerNode(document.getElementById('id-container')); - // init the ui or restart if it was loaded previously - if (iDContext.ui() !== undefined) { - iDContext.reset(); - iDContext.ui().restart(); + useEffect(() => { + if (RapiDContext && comment) { + RapiDContext.defaultChangesetComment(comment); + } + }, [comment, RapiDContext]); + + useEffect(() => { + if (session && locale && RapiD && RapiDContext) { + // if presets is not a populated list we need to set it as null + try { + if (presets.length) { + window.iD.presetManager.addablePresetIDs(presets); } else { - iDContext.init(); - } - if (gpxUrl) { - iDContext.layers().layer('data').url(gpxUrl, '.gpx'); + window.iD.presetManager.addablePresetIDs(null); } + } catch (e) { + window.iD.presetManager.addablePresetIDs(null); + } + // setup the context + RapiDContext + .embed(true) + .assetPath('/static/rapid/') + .locale(locale) + .setsDocumentTitle(false) + .containerNode(document.getElementById('rapid-container')); + // init the ui or restart if it was loaded previously + if (RapiDContext.ui() !== undefined) { + RapiDContext.reset(); + RapiDContext.ui().restart(); + } else { + RapiDContext.init(); + } + if (gpxUrl) { + RapiDContext.layers().layer('data').url(gpxUrl, '.gpx'); + } - iDContext.rapidContext().showPowerUser = powerUser; + RapiDContext.rapidContext().showPowerUser = powerUser; - let osm = iDContext.connection(); - const auth = { - urlroot: OSM_SERVER_URL, - oauth_consumer_key: OSM_CONSUMER_KEY, - oauth_secret: OSM_CONSUMER_SECRET, - oauth_token: session.osm_oauth_token, - oauth_token_secret: session.osm_oauth_token_secret, - }; - osm.switch(auth); + let osm = RapiDContext.connection(); + const auth = { + urlroot: OSM_SERVER_URL, + oauth_consumer_key: OSM_CONSUMER_KEY, + oauth_secret: OSM_CONSUMER_SECRET, + oauth_token: session.osm_oauth_token, + oauth_token_secret: session.osm_oauth_token_secret, + }; + osm.switch(auth); - const thereAreChanges = (changes) => - changes.modified.length || changes.created.length || changes.deleted.length; + const thereAreChanges = (changes) => + changes.modified.length || changes.created.length || changes.deleted.length; - iDContext.history().on('change', () => { - if (thereAreChanges(iDContext.history().changes())) { - setDisable(true); - } else { - setDisable(false); - } - }); - } - }, [session, iDContext, setDisable, presets, locale, gpxUrl, powerUser]); + RapiDContext.history().on('change', () => { + if (thereAreChanges(RapiDContext.history().changes())) { + setDisable(true); + } else { + setDisable(false); + } + }); + } + }, [session, RapiDContext, setDisable, presets, locale, gpxUrl, powerUser]); - return
; + return
; } diff --git a/frontend/src/components/taskSelection/action.js b/frontend/src/components/taskSelection/action.js index 10e56a4e68..90836f3e8a 100644 --- a/frontend/src/components/taskSelection/action.js +++ b/frontend/src/components/taskSelection/action.js @@ -12,7 +12,7 @@ import { HeaderLine } from '../projectDetail/header'; import { Button } from '../button'; import Portal from '../portal'; import { SidebarIcon } from '../svgIcons'; -import { openEditor, getTaskGpxUrl, formatImageryUrl } from '../../utils/openEditor'; +import { openEditor, getTaskGpxUrl, formatImageryUrl, formatJosmUrl } from '../../utils/openEditor'; import { getTaskContributors } from '../../utils/getTaskContributors'; import { TaskHistory } from './taskActivity'; import { ChangesetCommentTags } from './changesetComment'; @@ -26,12 +26,13 @@ import { CompletionTabForValidation, SidebarToggle, ReopenEditor, + UnsavedMapChangesModalContent, } from './actionSidebars'; import { fetchLocalJSONAPI } from '../../network/genericJSONRequest'; import { MultipleTaskHistoriesAccordion } from './multipleTaskHistories'; import { ResourcesTab } from './resourcesTab'; import { ActionTabsNav } from './actionTabsNav'; - +import { LockedTaskModalContent } from './lockedTasks'; const Editor = React.lazy(() => import('../editor')); const RapiDEditor = React.lazy(() => import('../rapidEditor')); @@ -42,15 +43,16 @@ export function TaskMapAction({ project, projectIsReady, tasks, activeTasks, act const [activeSection, setActiveSection] = useState('completion'); const [activeEditor, setActiveEditor] = useState(editor); const [showSidebar, setShowSidebar] = useState(true); + const [isJosmError, setIsJosmError] = useState(false); const tasksIds = useMemo( () => activeTasks ? activeTasks - .map((task) => task.taskId) - .sort((n1, n2) => { - // in ascending order - return n1 - n2; - }) + .map((task) => task.taskId) + .sort((n1, n2) => { + // in ascending order + return n1 - n2; + }) : [], [activeTasks], ); @@ -61,6 +63,7 @@ export function TaskMapAction({ project, projectIsReady, tasks, activeTasks, act const [validationStatus, setValidationStatus] = useState({}); const [historyTabChecked, setHistoryTabChecked] = useState(false); const [multipleTasksInfo, setMultipleTasksInfo] = useState({}); + const [showMapChangesModal, setShowMapChangesModal] = useState(false); const intl = useIntl(); const activeTask = activeTasks && activeTasks[0]; @@ -129,25 +132,42 @@ export function TaskMapAction({ project, projectIsReady, tasks, activeTasks, act } }, [editor, project, projectIsReady, userDetails.defaultEditor, action, tasks, tasksIds]); - const callEditor = (arr) => { - setActiveEditor(arr[0].value); - const url = openEditor( - arr[0].value, - project, - tasks, - tasksIds, - [window.innerWidth, window.innerHeight], - null, - ); - if (url) { - navigate(`./${url}`); + const callEditor = async (arr) => { + setIsJosmError(false); + if (!disabled) { + setActiveEditor(arr[0].value); + const url = openEditor( + arr[0].value, + project, + tasks, + tasksIds, + [window.innerWidth, window.innerHeight], + null, + ); + if (url) { + navigate(`./${url}`); + if (arr[0].value === 'JOSM') { + try { + await fetch(formatJosmUrl('version', { jsonp: 'checkJOSM' })); + } catch (e) { + setIsJosmError(true); + return; + } + } + } else { + navigate(`./?editor=${arr[0].value}`); + } } else { - navigate(`./?editor=${arr[0].value}`); + // we need to return a promise in order to be called by useAsync + return new Promise((resolve, reject) => { + setShowMapChangesModal('reload editor'); + resolve(); + }); } - window.location.reload(); }; return ( + <>
@@ -300,6 +320,17 @@ export function TaskMapAction({ project, projectIsReady, tasks, activeTasks, act editor={activeEditor} callEditor={callEditor} /> + {disabled && showMapChangesModal && ( + setShowMapChangesModal(null)} + > + {(close) => } + + )} {(editor === 'ID' || editor === 'RAPID') && ( + {isJosmError && ( + setIsJosmError(false)} + > + {(close) => } + + )} + ); } diff --git a/frontend/src/components/taskSelection/actionSidebars.js b/frontend/src/components/taskSelection/actionSidebars.js index 2c53746da3..474653b501 100644 --- a/frontend/src/components/taskSelection/actionSidebars.js +++ b/frontend/src/components/taskSelection/actionSidebars.js @@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react'; import { useSelector } from 'react-redux'; import { navigate } from '@reach/router'; import Popup from 'reactjs-popup'; +import ReactTooltip from 'react-tooltip'; import { FormattedMessage } from 'react-intl'; import messages from './messages'; @@ -224,7 +225,7 @@ export function CompletionTabForMapping({ />

-
+
+ {disabled && + + + + }
-
+
+ {disabled && + + + + }