Repository: osmlab/maproulette3
Pull request #917 (2019-10-01 21:20:06)

add RapiD as an option for editors
Tested through UI and see the RapiD option work as expected, with OSM entity selected upon window pop-up: https://fb-public.box.com/s/76jdzg3z4lyth622s7lgpuogspa8gvbq

Also updated the test `constructIdURI` according to logic in code and added a test `constructRapidURI`; they both passed when tested with `yarn test -t <test-name>`.

I haven't updated the snapshots yet because the snapshot tests seem to fail currently even without changes in this PR. We can do another PR for snapshot updates later, once the snapshot testing system is fixed.


Patch:
diff --git a/.env b/.env
index e9566496..479b14d7 100644
--- a/.env
+++ b/.env
@@ -56,6 +56,9 @@ REACT_APP_GEOGRAPHIC_INDEXING_DELAY=2
 # iD editor base URL
 REACT_APP_ID_EDITOR_SERVER_URL='https://www.openstreetmap.org/edit'
 
+# RapiD editor base URL
+REACT_APP_RAPID_EDITOR_SERVER_URL='https://mapwith.ai/rapid'
+
 # Level0 editor base URL
 REACT_APP_LEVEL0_EDITOR_SERVER_URL='http://level0.osmz.ru/index.php'
 
diff --git a/src/components/TaskPane/ActiveTaskDetails/ActiveTaskControls/TaskEditControl/TaskEditControl.js b/src/components/TaskPane/ActiveTaskDetails/ActiveTaskControls/TaskEditControl/TaskEditControl.js
index a2d45e64..10aae0e2 100644
--- a/src/components/TaskPane/ActiveTaskDetails/ActiveTaskControls/TaskEditControl/TaskEditControl.js
+++ b/src/components/TaskPane/ActiveTaskDetails/ActiveTaskControls/TaskEditControl/TaskEditControl.js
@@ -46,6 +46,9 @@ export default class TaskEditControl extends Component {
       case editShortcuts.editLevel0.key:
         this.props.pickEditor({value: Editor.level0})
         break
+      case editShortcuts.editRapid.key:
+        this.props.pickEditor({value: Editor.rapid})
+        break
       default:
     }
   }
diff --git a/src/lang/en-US.json b/src/lang/en-US.json
index 6c158189..60394a93 100644
--- a/src/lang/en-US.json
+++ b/src/lang/en-US.json
@@ -724,6 +724,8 @@
   "Widgets.TaskBundleWidget.currentTask": "(current task)",
   "Widgets.TaskBundleWidget.simultaneousTasks": "Working on {taskCount, number} tasks together",
   "Widgets.TaskBundleWidget.disallowBundling": "You are working on a single task. Task bundles cannot be created on this step.",
+  "Widgets.TaskBundleWidget.noSuggestedFixes": "Quick-fix tasks cannot be bundled together",
+  "Widgets.TaskBundleWidget.noVirtualChallenges": "Tasks in \"virtual\" challenges cannot be bundled together",
   "Widgets.TaskCompletionWidget.label": "Completion",
   "Widgets.TaskCompletionWidget.title": "Completion",
   "Widgets.TaskCompletionWidget.inspectTitle": "Inspect",
@@ -938,6 +940,7 @@
   "Editor.josmLayer.label": "Edit in new JOSM layer",
   "Editor.josmFeatures.label": "Edit just features in JOSM",
   "Editor.level0.label": "Edit in Level0",
+  "Editor.rapid.label": "Edit in RapiD",
   "Errors.user.missingHomeLocation": "No home location found. Please either allow permission from your browser or set your home location in your openstreetmap.org settings (you may to sign out and sign back in to MapRoulette afterwards to pick up fresh changes to your OpenStreetMap settings).",
   "Errors.user.unauthenticated": "Please sign in to continue.",
   "Errors.user.unauthorized": "Sorry, you are not authorized to perform that action.",
@@ -989,6 +992,7 @@
   "KeyMapping.openEditor.editJosmLayer": "Edit in new JOSM layer",
   "KeyMapping.openEditor.editJosmFeatures": "Edit just features in JOSM",
   "KeyMapping.openEditor.editLevel0": "Edit in Level0",
+  "KeyMapping.openEditor.editRapid": "Edit in RapiD",
   "KeyMapping.layers.layerOSMData": "Toggle OSM Data Layer",
   "KeyMapping.layers.layerTaskFeatures": "Toggle Features Layer",
   "KeyMapping.layers.layerMapillary": "Toggle Mapillary Layer",
diff --git a/src/services/Editor/Editor.js b/src/services/Editor/Editor.js
index 4e7c69e1..a71181e5 100644
--- a/src/services/Editor/Editor.js
+++ b/src/services/Editor/Editor.js
@@ -21,6 +21,7 @@ export const JOSM = 1
 export const JOSM_LAYER = 2
 export const LEVEL0 = 3
 export const JOSM_FEATURES = 4
+export const RAPID = 5
 
 // Default editor choice if user has not selected an editor
 export const DEFAULT_EDITOR = ID
@@ -41,6 +42,7 @@ export const Editor = Object.freeze({
   josmLayer: JOSM_LAYER,
   josmFeatures: JOSM_FEATURES,
   level0: LEVEL0,
+  rapid: RAPID,
 })
 
 
@@ -82,6 +84,9 @@ export const editTask = function(editor, task, mapBounds, options, taskBundle) {
       else if (editor === LEVEL0) {
         editorWindowReference = window.open(constructLevel0URI(task, mapBounds, options, taskBundle))
       }
+      else if (editor === RAPID) {
+        editorWindowReference = window.open(constructRapidURI(task, mapBounds, options))
+      }
 
       dispatch(editorOpened(editor, task.id, RequestStatus.success))
     }
@@ -135,7 +140,7 @@ export const openEditor = function(state=null, action) {
  * otherwise
  */
 export const isWebEditor = function(editor) {
-  return editor === ID || editor === LEVEL0
+  return editor === ID || editor === LEVEL0 || editor === RAPID
 }
 
 /**
@@ -195,6 +200,31 @@ export const constructIdURI = function(task, mapBounds, options, taskBundle) {
   )
 }
 
+/**
+ * Builds a RapiD editor URI for editing of the given task
+ */
+export const constructRapidURI = function(task, mapBounds, options) {
+  const baseUriComponent =
+    `${process.env.REACT_APP_RAPID_EDITOR_SERVER_URL}#`
+
+  const centerPoint = taskCenterPoint(mapBounds, task)
+  const mapUriComponent =
+    "map=" + [mapBounds.zoom, centerPoint.lat, centerPoint.lng].join('/')
+
+  const selectedEntityComponent = "id=" + osmObjectParams(task, true)
+  const commentUriComponent = "comment=" +
+                              encodeURIComponent(task.parent.checkinComment)
+  const sourceComponent = "source=" + encodeURIComponent(task.parent.checkinSource)
+
+  const photoOverlayComponent =
+    _get(options, 'photoOverlay') ? "photo_overlay=" + options.photoOverlay : null;
+
+  return baseUriComponent + _compact(
+    [selectedEntityComponent, commentUriComponent, sourceComponent,
+      photoOverlayComponent, mapUriComponent]
+  ).join('&')
+}
+
 /**
  * Builds a Level0 editor URI for editing of the given task
  */
diff --git a/src/services/Editor/Messages.js b/src/services/Editor/Messages.js
index 1efa2d3e..9d027221 100644
--- a/src/services/Editor/Messages.js
+++ b/src/services/Editor/Messages.js
@@ -27,5 +27,9 @@ export default defineMessages({
   level0: {
     id: "Editor.level0.label",
     defaultMessage: "Edit in Level0"
+  },
+  rapid: {
+    id: "Editor.rapid.label",
+    defaultMessage: "Edit in RapiD"
   }
 })
diff --git a/src/services/KeyboardShortcuts/KeyMappings.js b/src/services/KeyboardShortcuts/KeyMappings.js
index d5e02dd3..02d3c56c 100644
--- a/src/services/KeyboardShortcuts/KeyMappings.js
+++ b/src/services/KeyboardShortcuts/KeyMappings.js
@@ -19,6 +19,7 @@ export default {
     editJosmLayer: {key: 't', label: messages.editJosmLayer},
     editJosmFeatures: {key: 'y', label: messages.editJosmFeatures},
     editLevel0: {key: 'v', label: messages.editLevel0},
+    editRapid: {key: 'a', label: messages.editRapid},
   },
   taskEditing: {
     cancel: {key: 'Escape', label: messages.cancel, keyLabel: messages.escapeLabel},
diff --git a/src/services/KeyboardShortcuts/Messages.js b/src/services/KeyboardShortcuts/Messages.js
index f5bb6c51..02f5d47b 100644
--- a/src/services/KeyboardShortcuts/Messages.js
+++ b/src/services/KeyboardShortcuts/Messages.js
@@ -29,6 +29,11 @@ export default defineMessages({
     defaultMessage: "Edit in Level0",
   },
 
+  editRapid: {
+    id: "KeyMapping.openEditor.editRapid",
+    defaultMessage: "Edit in RapiD",
+  },
+
   layerOSMData: {
     id: "KeyMapping.layers.layerOSMData",
     defaultMessage: "Toggle OSM Data Layer",


Test patch:
diff --git a/src/services/Editor/Editor.test.js b/src/services/Editor/Editor.test.js
index 77a0cf5a..8dd10225 100644
--- a/src/services/Editor/Editor.test.js
+++ b/src/services/Editor/Editor.test.js
@@ -3,7 +3,8 @@ import { Editor,
          featureOSMId,
          josmLoadAndZoomURI,
          constructIdURI,
-         constructLevel0URI } from './Editor'
+         constructLevel0URI,
+         constructRapidURI } from './Editor'
 import _cloneDeep from 'lodash/cloneDeep'
 
 let dispatch = null
@@ -273,7 +274,7 @@ describe('josmLoadAndZoomURI', () => {
   })
 })
 
-describe('constructURI', () => {
+describe('constructIdURI', () => {
   test("the uri specifies the Id editor", () => {
     const uri = constructIdURI(task, mapBounds)
     expect(uri).toEqual(expect.stringContaining("editor=id#"))
@@ -303,7 +304,7 @@ describe('constructURI', () => {
     const uri = constructIdURI(task, mapBounds)
 
     expect(uri).toEqual(
-      expect.stringContaining(`id=n123`)
+      expect.stringContaining(`node=123`)
     )
   })
 
@@ -313,7 +314,7 @@ describe('constructURI', () => {
     const uri = constructIdURI(task, mapBounds)
 
     expect(uri).toEqual(
-      expect.stringContaining(`id=w456`)
+      expect.stringContaining(`way=456`)
     )
   })
 
@@ -323,7 +324,7 @@ describe('constructURI', () => {
     const uri = constructIdURI(task, mapBounds)
 
     expect(uri).toEqual(
-      expect.stringContaining(`id=w789`)
+      expect.stringContaining(`way=789`)
     )
   })
 
@@ -333,7 +334,7 @@ describe('constructURI', () => {
     const uri = constructIdURI(task, mapBounds)
 
     expect(uri).toEqual(
-      expect.stringContaining(`id=r135`)
+      expect.stringContaining(`relation=135`)
     )
   })
 
@@ -343,7 +344,7 @@ describe('constructURI', () => {
     const uri = constructIdURI(task, mapBounds)
 
     expect(uri).not.toEqual(
-      expect.stringContaining('id=n')
+      expect.stringContaining('node=')
     )
   })
 
@@ -353,7 +354,7 @@ describe('constructURI', () => {
     const uri = constructIdURI(task, mapBounds)
 
     expect(uri).toEqual(
-      expect.stringContaining(`id=n123`)
+      expect.stringContaining(`node=123`)
     )
   })
 
@@ -364,6 +365,103 @@ describe('constructURI', () => {
     taskGeometries.features = [ pointFeature, lineStringFeature, multiPolygonFeature ]
     const uri = constructIdURI(task, mapBounds)
 
+    expect(uri).toEqual(
+      expect.stringContaining(`node=123&way=456&relation=135`)
+    )
+  })
+})
+
+describe('constructRapidURI', () => {
+  test("the uri specifies the RapiD editor", () => {
+    const uri = constructRapidURI(task, mapBounds)
+    expect(uri).toEqual(expect.stringContaining("rapid"))
+  })
+
+  test("the uri includes slash-separated centerpoint and zoom from mapbounds", () => {
+    const uri = constructRapidURI(task, mapBounds)
+
+    expect(uri).toEqual(
+      expect.stringContaining(`map=${mapBounds.zoom}/${centerPoint.lat}/${centerPoint.lng}`)
+    )
+  })
+
+  test("uri includes a URI-encoded checkin comment from the task challenge", () => {
+    const uri = constructRapidURI(task, mapBounds)
+
+    expect(uri).toEqual(
+      expect.stringContaining(
+        `comment=${encodeURI(challenge.checkinComment)}`
+      )
+    )
+  })
+
+  test("uri includes a node id for Point features with an OSM id", () => {
+    pointFeature.properties.osmid = '123'
+    taskGeometries.features = [ pointFeature ]
+    const uri = constructRapidURI(task, mapBounds)
+
+    expect(uri).toEqual(
+      expect.stringContaining(`id=n123`)
+    )
+  })
+
+  test("uri includes a way id for LineString features with an OSM id", () => {
+    lineStringFeature.properties.osmid = '456'
+    taskGeometries.features = [ lineStringFeature ]
+    const uri = constructRapidURI(task, mapBounds)
+
+    expect(uri).toEqual(
+      expect.stringContaining(`id=w456`)
+    )
+  })
+
+  test("uri includes a way id for Polygon features with an OSM id", () => {
+    polygonFeature.properties.osmid = '789'
+    taskGeometries.features = [ polygonFeature ]
+    const uri = constructRapidURI(task, mapBounds)
+
+    expect(uri).toEqual(
+      expect.stringContaining(`id=w789`)
+    )
+  })
+
+  test("uri includes a relation id for MultiPolygon features with an OSM id", () => {
+    multiPolygonFeature.properties.osmid = '135'
+    taskGeometries.features = [ multiPolygonFeature ]
+    const uri = constructRapidURI(task, mapBounds)
+
+    expect(uri).toEqual(
+      expect.stringContaining(`id=r135`)
+    )
+  })
+
+  test("features lacking an OSM id are not selected", () => {
+    delete pointFeature.properties.osmid
+    taskGeometries.features = [ pointFeature ]
+    const uri = constructRapidURI(task, mapBounds)
+
+    expect(uri).not.toEqual(
+      expect.stringContaining('id=n')
+    )
+  })
+
+  test("features using the alternate @id property are still identified", () => {
+    pointFeature.properties['@id'] = '123'
+    taskGeometries.features = [ pointFeature ]
+    const uri = constructRapidURI(task, mapBounds)
+
+    expect(uri).toEqual(
+      expect.stringContaining(`id=n123`)
+    )
+  })
+
+  test("multiple features are comma-separated in the id", () => {
+    pointFeature.properties.osmid = '123'
+    lineStringFeature.properties.osmid = '456'
+    multiPolygonFeature.properties.osmid = '135'
+    taskGeometries.features = [ pointFeature, lineStringFeature, multiPolygonFeature ]
+    const uri = constructRapidURI(task, mapBounds)
+
     expect(uri).toEqual(
       expect.stringContaining(`id=n123,w456,r135`)
     )