Repository: osmlab/maproulette3
Pull request #1376 (2020-09-02 20:50:01)

Support multiple user defined custom basemaps
* Change user profile settings to allow user to specify multiple
  custom basemaps/overlays

* Offer custom basemaps and overlays on appropriate maps

Relies on maproulette/maproulette2#831


Patch:
diff --git a/src/components/ActivityMap/ActivityMap.js b/src/components/ActivityMap/ActivityMap.js
index b5fd0c42..c3f07bb9 100644
--- a/src/components/ActivityMap/ActivityMap.js
+++ b/src/components/ActivityMap/ActivityMap.js
@@ -11,7 +11,7 @@ import _get from 'lodash/get'
 import _map from 'lodash/map'
 import { latLng } from 'leaflet'
 import { toLatLngBounds, GLOBAL_MAPBOUNDS } from '../../services/MapBounds/MapBounds'
-import { layerSourceWithId } from '../../services/VisibleLayer/LayerSources'
+import { buildLayerSources } from '../../services/VisibleLayer/LayerSources'
 import { TaskStatusColors } from '../../services/Task/TaskStatus/TaskStatus'
 import WithVisibleLayer from '../HOCs/WithVisibleLayer/WithVisibleLayer'
 import EnhancedMap from '../EnhancedMap/EnhancedMap'
@@ -65,8 +65,10 @@ export const ActivityMap = props => {
     })
   }
 
-  const overlayLayers = _map(props.visibleOverlays, (layerId, index) =>
-    <SourcedTileLayer key={layerId} source={layerSourceWithId(layerId)} zIndex={index + 2} />
+  const overlayLayers = buildLayerSources(
+    props.visibleOverlays, _get(props, 'user.settings.customBasemaps'),
+    (layerId, index, layerSource) =>
+      <SourcedTileLayer key={layerId} source={layerSource} zIndex={index + 2} />
   )
 
   if (!coloredMarkers) {
diff --git a/src/components/AdminPane/HOCs/WithChallengeManagement/WithChallengeManagement.js b/src/components/AdminPane/HOCs/WithChallengeManagement/WithChallengeManagement.js
index fe812838..283ef0e9 100644
--- a/src/components/AdminPane/HOCs/WithChallengeManagement/WithChallengeManagement.js
+++ b/src/components/AdminPane/HOCs/WithChallengeManagement/WithChallengeManagement.js
@@ -211,12 +211,12 @@ const mapDispatchToProps = (dispatch, ownProps) => ({
     return dispatch(bulkUpdateTasks(alteredTasks, true))
   },
 
-  applyBulkTaskStatusChange: (newStatus, challengeId, searchCriteria, excludeTaskIds) => {
-    return dispatch(bulkTaskStatusChange(newStatus, challengeId, searchCriteria, excludeTaskIds))
+  applyBulkTaskStatusChange: (newStatus, challengeId, searchCriteria) => {
+    return dispatch(bulkTaskStatusChange(newStatus, challengeId, searchCriteria))
   },
 
-  removeReviewRequest: (challengeId, taskIds, searchCriteria, excludeTaskIds) => {
-    return dispatch(removeReviewRequest(challengeId, taskIds, searchCriteria, excludeTaskIds))
+  removeReviewRequest: (challengeId, taskIds, searchCriteria) => {
+    return dispatch(removeReviewRequest(challengeId, taskIds, searchCriteria))
   }
 })
 
diff --git a/src/components/AdminPane/HOCs/WithComputedMetrics/WithComputedMetrics.js b/src/components/AdminPane/HOCs/WithComputedMetrics/WithComputedMetrics.js
index 0be0efaf..c5a85abc 100644
--- a/src/components/AdminPane/HOCs/WithComputedMetrics/WithComputedMetrics.js
+++ b/src/components/AdminPane/HOCs/WithComputedMetrics/WithComputedMetrics.js
@@ -4,7 +4,7 @@ import _get from 'lodash/get'
 import _isEmpty from 'lodash/isEmpty'
 import _isArray from 'lodash/isArray'
 import _isObject from 'lodash/isObject'
-import _isFinite from 'lodash/isFinite'
+import _isNumber from 'lodash/isNumber'
 import _each from 'lodash/each'
 import _fromPairs from 'lodash/fromPairs'
 import _map from 'lodash/map'
@@ -25,21 +25,14 @@ export default function(WrappedComponent) {
 
     updateTotals = (actions, totalTasks, taskMetrics) => {
       _each(actions, (value, label) => {
-        if (label === "avgTimeSpent") {
-          taskMetrics.totalTimeSpent = _isFinite(taskMetrics.totalTimeSpent) ?
-            taskMetrics.totalTimeSpent + (value * actions.tasksWithTime) :
-            value * actions.tasksWithTime
-        }
-        else {
-          taskMetrics[label] = _isFinite(taskMetrics[label]) ?
-                              taskMetrics[label] + value :
-                              value
-
-          const percentage = (1.0 * value / totalTasks) * 100.0
-          taskMetrics.percentages[label] =  _isFinite(taskMetrics.percentages[label]) ?
-                                            taskMetrics.percentages[label] + percentage :
-                                            percentage
-        }
+        taskMetrics[label] = _isNumber(taskMetrics[label]) ?
+                            taskMetrics[label] + value :
+                            value
+
+        const percentage = (1.0 * value / totalTasks) * 100.0
+        taskMetrics.percentages[label] =  _isNumber(taskMetrics.percentages[label]) ?
+                                          taskMetrics.percentages[label] + percentage :
+                                          percentage
       })
     }
 
diff --git a/src/components/AdminPane/Manage/ChallengeDashboard/ChallengeDashboard.js b/src/components/AdminPane/Manage/ChallengeDashboard/ChallengeDashboard.js
index ceda559e..2f80247d 100644
--- a/src/components/AdminPane/Manage/ChallengeDashboard/ChallengeDashboard.js
+++ b/src/components/AdminPane/Manage/ChallengeDashboard/ChallengeDashboard.js
@@ -13,8 +13,6 @@ import WithCurrentChallenge
        from '../../HOCs/WithCurrentChallenge/WithCurrentChallenge'
 import WithWidgetWorkspaces
        from '../../../HOCs/WithWidgetWorkspaces/WithWidgetWorkspaces'
-import WithSelectedClusteredTasks
-       from '../../../HOCs/WithSelectedClusteredTasks/WithSelectedClusteredTasks'
 import WithFilteredClusteredTasks
        from '../../../HOCs/WithFilteredClusteredTasks/WithFilteredClusteredTasks'
 import WithClusteredTasks
@@ -156,15 +154,13 @@ WithManageableProjects(
     WithSearch(
       WithCurrentChallenge(
         WithWidgetWorkspaces(
-          WithSelectedClusteredTasks(
-            WithClusteredTasks(
-              WithFilteredClusteredTasks(
-                WithChallengeMetrics(
-                  injectIntl(ChallengeDashboard),
-                ),
-                'clusteredTasks',
-                'filteredClusteredTasks'
-              )
+          WithClusteredTasks(
+            WithFilteredClusteredTasks(
+              WithChallengeMetrics(
+                injectIntl(ChallengeDashboard),
+              ),
+              'clusteredTasks',
+              'filteredClusteredTasks'
             )
           ),
           WidgetDataTarget.challenge,
diff --git a/src/components/AdminPane/Manage/ViewChallengeTasks/ViewChallengeTasks.js b/src/components/AdminPane/Manage/ViewChallengeTasks/ViewChallengeTasks.js
index ac1af590..7b3c71e7 100644
--- a/src/components/AdminPane/Manage/ViewChallengeTasks/ViewChallengeTasks.js
+++ b/src/components/AdminPane/Manage/ViewChallengeTasks/ViewChallengeTasks.js
@@ -3,6 +3,7 @@ import PropTypes from 'prop-types'
 import { FormattedMessage } from 'react-intl'
 import { Popup } from 'react-leaflet'
 import _get from 'lodash/get'
+import _map from 'lodash/map'
 import { ChallengeStatus }
        from '../../../../services/Challenge/ChallengeStatus/ChallengeStatus'
 import { TaskStatus,
@@ -54,34 +55,43 @@ export class ViewChallengeTasks extends Component {
     showPriorityBounds: false,
   }
 
-  changeStatus = (selectedTasks, newStatus = TaskStatus.created) => {
-    if (!selectedTasks.allSelected && selectedTasks.selected.size === 0) {
-      return // Nothing to do
+  takeTaskSelectionAction = action => {
+    if (action.statusAction) {
+      this.props.selectTasksWithStatus(action.status)
     }
+    else if (action.priorityAction) {
+      this.props.selectTasksWithPriority(action.priority)
+    }
+  }
+
+  changeStatus = (selectedIds, newStatus = TaskStatus.created) => {
+    if (selectedIds.length === 0) {
+      return
+    }
+    const tasks = _map(selectedIds, id => this.props.selectedTasks.get(id))
 
     this.setState({bulkUpdating: true})
     // If all tasks are selected (so beyond what is being viewed on current page)
-    if (selectedTasks.allSelected) {
+    if (this.props.allTasksAreSelected()) {
       this.props.applyBulkTaskStatusChange(
-        parseInt(newStatus),
-        this.props.challenge.id,
-        this.props.criteria,
-        [...selectedTasks.deselected.keys()]
+        parseInt(newStatus), this.props.challenge.id, this.props.criteria
       ).then(() => {
         this.props.refreshChallenge()
         this.props.refreshTasks()
         this.setState({bulkUpdating: false})
       })
     }
-    else { // Otherwise only apply to selected tasks
-      this.props.applyBulkTaskChanges([...this.props.selectedTasks.selected.values()], {
-        status: parseInt(newStatus),
-        mappedOn: null,
-        reviewStatus: null,
-        reviewRequestedBy: null,
-        reviewedBy: null,
-        reviewedAt: null
-      }).then(() => {
+    // Otherwise only apply to selected tasks
+    else {
+      this.props.applyBulkTaskChanges(
+        tasks, {status: parseInt(newStatus),
+                mappedOn: null,
+                reviewStatus: null,
+                reviewRequestedBy: null,
+                reviewedBy: null,
+                reviewedAt: null}
+
+      ).then(() => {
         this.props.refreshChallenge()
         this.props.refreshTasks()
         this.setState({bulkUpdating: false})
@@ -89,18 +99,14 @@ export class ViewChallengeTasks extends Component {
     }
   }
 
-  removeReviewRequests = selectedTasks => {
-    if (!selectedTasks.allSelected && selectedTasks.selected.size === 0) {
-      return // Nothing to do
+  removeReviewRequests = (selectedIds) => {
+    if (selectedIds.length === 0) {
+      return
     }
+    const selectedTasks = this.props.allTasksAreSelected() ? null : selectedIds
 
     this.setState({bulkUpdating: true})
-    this.props.removeReviewRequest(
-      this.props.challenge.id,
-      selectedTasks.allSelected ? null : [...selectedTasks.selected.keys()],
-      this.props.criteria,
-      selectedTasks.allSelected ? [...selectedTasks.deselected.keys()] : null
-    ).then(() => {
+    this.props.removeReviewRequest(this.props.challenge.id, selectedTasks, this.props.criteria).then(() => {
       this.props.refreshChallenge()
       this.props.refreshTasks()
       this.setState({bulkUpdating: false})
@@ -204,8 +210,7 @@ export class ViewChallengeTasks extends Component {
         <ClusterMap
           showLasso
           updateBounds={this.mapBoundsUpdated}
-          onBulkTaskSelection={this.props.selectTasks}
-          onBulkTaskDeselection={this.props.deselectTasks}
+          onBulkTaskSelection={this.props.selectTasksById}
           loadingTasks={this.props.loadingTasks}
           showMarkerPopup={this.showMarkerPopup}
           togglePriorityBounds={() => this.setState({showPriorityBounds: !this.state.showPriorityBounds})}
@@ -362,11 +367,10 @@ ViewChallengeTasks.defaultProps = {
   loadingChallenge: false,
 }
 
-export default
-WithBoundedTasks(
-  WithTaskPropertyKeys(
-    WithFilterCriteria(ViewChallengeTasks)
-  ),
-  'filteredClusteredTasks',
-  'taskInfo'
-)
+export default WithBoundedTasks(
+                 WithTaskPropertyKeys(
+                   WithFilterCriteria(ViewChallengeTasks)
+                 ),
+                 'filteredClusteredTasks',
+                 'taskInfo'
+               )
diff --git a/src/components/Bulma/RJSFFormFieldAdapter/RJSFFormFieldAdapter.js b/src/components/Bulma/RJSFFormFieldAdapter/RJSFFormFieldAdapter.js
index c2fe9eda..4ffa4ee5 100644
--- a/src/components/Bulma/RJSFFormFieldAdapter/RJSFFormFieldAdapter.js
+++ b/src/components/Bulma/RJSFFormFieldAdapter/RJSFFormFieldAdapter.js
@@ -59,6 +59,10 @@ export const NoFieldsetObjectFieldTemplate = function(props) {
 }
 
 export const CustomArrayFieldTemplate = props => {
+  const addLabel = props.uiSchema["ui:addLabel"] ||
+    <FormattedMessage {...messages.addPriorityRuleLabel} />
+  const deleteLabel = props.uiSchema["ui:deleteLabel"]
+
   const itemFields = _map(props.items, element =>
     <div
       key={element.index}
@@ -68,14 +72,17 @@ export const CustomArrayFieldTemplate = props => {
         className={classNames({"inline": _get(props, 'uiSchema.items.ui:options.inline')})}
       >
         {element.children}
-
         {element.hasRemove &&
-        <button className="button is-clear array-field__item__control remove-item-button"
-                onClick={element.onDropIndexClick(element.index)}>
-          <span className="icon is-danger">
-            <SvgSymbol sym="trash-icon" viewBox='0 0 20 20' />
-          </span>
-        </button>
+          <button className={classNames(
+            "is-clear array-field__item__control remove-item-button",
+            !deleteLabel ? "button" : "mr-button mr-button mr-button--small")}
+            onClick={element.onDropIndexClick(element.index)}>
+           {deleteLabel ||
+             <span className="icon is-danger">
+               <SvgSymbol sym="trash-icon" viewBox='0 0 20 20' className="mr-w-5 mr-h-5"/>
+             </span>
+           }
+          </button>
         }
       </div>
     </div>
@@ -83,6 +90,9 @@ export const CustomArrayFieldTemplate = props => {
 
   return (
     <div className="array-field">
+      {props.title &&
+        <label className="control-label">{props.title}</label>
+      }
       {itemFields}
       {props.canAdd &&
        <div className="array-field__block-controls">
@@ -90,7 +100,7 @@ export const CustomArrayFieldTemplate = props => {
            className="mr-button mr-button mr-button--small"
            onClick={props.onAddClick}
          >
-           <FormattedMessage {...messages.addPriorityRuleLabel} />
+           {addLabel}
          </button>
        </div>
       }
diff --git a/src/components/ChallengeProgress/ChallengeProgress.js b/src/components/ChallengeProgress/ChallengeProgress.js
index a7014eef..73f921e6 100644
--- a/src/components/ChallengeProgress/ChallengeProgress.js
+++ b/src/components/ChallengeProgress/ChallengeProgress.js
@@ -240,22 +240,14 @@ export class ChallengeProgress extends Component {
       return null
     }
 
-    let calculatedSeconds = null
     let averageTime = null
-    if (_get(taskActions, 'tasksWithTime', 0) > 0 &&
-        _get(taskActions, 'totalTimeSpent', 0) > 0) {
-      calculatedSeconds = taskActions.totalTimeSpent / taskActions.tasksWithTime / 1000
-    }
-    else if (_get(taskActions, 'avgTimeSpent', 0) > 0) {
-      calculatedSeconds = taskActions.avgTimeSpent / 1000
-    }
-
-    if (calculatedSeconds) {
+    if (_get(taskActions, 'avgTimeSpent', 0) > 0) {
+      const seconds = taskActions.avgTimeSpent / 1000
       averageTime =
         <div className="">
           <FormattedMessage {...messages.avgTimeSpent} />
           <span className="mr-pl-2">
-            {Math.floor(calculatedSeconds / 60)}m {Math.floor(calculatedSeconds) % 60}s
+            {Math.floor(seconds / 60)}m {Math.floor(seconds) % 60}s
           </span>
           {this.props.noteAvgExcludesSkip &&
             <span className="mr-pl-2">
diff --git a/src/components/EnhancedMap/LassoSelectionControl/LassoSelectionControl.js b/src/components/EnhancedMap/LassoSelectionControl/LassoSelectionControl.js
index 40aa79d5..18a0bc66 100644
--- a/src/components/EnhancedMap/LassoSelectionControl/LassoSelectionControl.js
+++ b/src/components/EnhancedMap/LassoSelectionControl/LassoSelectionControl.js
@@ -1,6 +1,5 @@
 import React from 'react'
 import ReactDOM from 'react-dom'
-import classNames from 'classnames'
 import L from 'leaflet'
 import 'leaflet-lasso'
 import { injectIntl } from 'react-intl'
@@ -18,49 +17,23 @@ import SvgSymbol from '../../SvgSymbol/SvgSymbol'
 const LassoSelectionLeafletControl = L.Control.extend({
   onAdd: function(map) {
     const lasso = L.lasso(map, {})
-    let deselecting = false
 
     map.on('lasso.finished', (event) => {
-      deselecting ?
-      this.options.onLassoDeselection(event.layers) :
       this.options.onLassoSelection(event.layers)
     })
 
     // build the control button, render it, and return it
     const controlContent = (
-      <React.Fragment>
-        <button
-          onClick={() => {
-            deselecting = false
-            lasso.toggle()
-          }}
-          className={classNames(
-            "mr-leading-none mr-p-2 mr-bg-black-50 mr-text-white mr-w-8 mr-h-8 mr-flex mr-items-center mr-transition-normal-in-out-quad hover:mr-text-green-lighter",
-            this.options.onLassoDeselection ? "mr-rounded-t-sm mr-border-b mr-border-white-15" : "mr-rounded-sm"
-          )}
-        >
-          <SvgSymbol
-            sym={this.options.onLassoDeselection ? "lasso-add-icon" : "lasso-icon"}
-            className="mr-w-4 mr-h-4 mr-fill-current mr-stroke-current"
-            viewBox="0 0 512 512"
-          />
-        </button>
-        {this.options.onLassoDeselection &&
-        <button
-          onClick={() => {
-            deselecting = true
-            lasso.toggle()
-          }}
-          className="mr-leading-none mr-p-2 mr-bg-black-50 mr-text-white mr-w-8 mr-h-8 mr-flex mr-items-center mr-shadow mr-rounded-b-sm mr-transition-normal-in-out-quad hover:mr-text-green-lighter"
-        >
-          <SvgSymbol
-            sym="lasso-remove-icon"
-            className="mr-w-4 mr-h-4 mr-fill-current mr-stroke-current"
-            viewBox="0 0 512 512"
-          />
-        </button>
-        }
-      </React.Fragment>
+      <button
+        onClick={() => lasso.toggle()}
+        className="mr-leading-none mr-p-2 mr-bg-black-50 mr-text-white mr-w-8 mr-h-8 mr-flex mr-items-center mr-shadow mr-rounded-sm mr-transition-normal-in-out-quad hover:mr-text-green-lighter"
+      >
+        <SvgSymbol
+          sym="lasso-icon"
+          className="mr-w-4 mr-h-4 mr-fill-current"
+          viewBox="0 0 512 512"
+        />
+      </button>
     )
 
     const controlContainer = L.DomUtil.create('div')
diff --git a/src/components/EnhancedMap/SearchControl/SearchControl.js b/src/components/EnhancedMap/SearchControl/SearchControl.js
index 50575c00..1421c159 100644
--- a/src/components/EnhancedMap/SearchControl/SearchControl.js
+++ b/src/components/EnhancedMap/SearchControl/SearchControl.js
@@ -1,9 +1,6 @@
-import React from 'react'
-import ReactDOM from 'react-dom'
-import L from 'leaflet'
-import { MapControl, withLeaflet } from 'react-leaflet'
+import React, { Component } from 'react'
 import _map from 'lodash/map'
-import { FormattedMessage, injectIntl } from 'react-intl'
+import { FormattedMessage } from 'react-intl'
 import WithNominatimSearch from '../../HOCs/WithNominatimSearch/WithNominatimSearch'
 import SvgSymbol from '../../SvgSymbol/SvgSymbol'
 import BusySpinner from '../../BusySpinner/BusySpinner'
@@ -11,126 +8,104 @@ import Dropdown from '../../Dropdown/Dropdown'
 import messages from './Messages'
 
 /**
- * SearchControl presents a Leaflet control that can be used to execute
- * geographic searches via Nominatim (name searches, lon/lat searches, etc.)
+ * SearchControl presents a map control that can be used to execute geographic
+ * searches via Nominatim (name searches, lon/lat searches, etc.)
  *
  * @author [Neil Rotstan](https://github.com/nrotstan)
  */
-export class SearchControl extends MapControl {
-  // props will be available as `options` field in the leaflet control
-  createLeafletElement(props) {
-    return new SearchLeafletControl(props)
-  }
-
-  // Re-render the control content when this component is rerendered
-  updateLeafletElement(fromProps, toProps) {
-    ReactDOM.render(ControlContent(toProps), this.leafletElement.getContainer())
-    return this.leafletElement
-  }
-}
-
-/**
- * The actual Leaflet control, which simply performs an initial rendering of
- * the control content when the control is added to the map
- */
-const SearchLeafletControl = L.Control.extend({
-  onAdd: function(map) {
-    const controlContainer = L.DomUtil.create('div')
-    ReactDOM.render(ControlContent(this.options), controlContainer)
-    return controlContainer
-  }
-})
-
-/**
- * Component that renders the actual control content
- */
-const ControlContent = props => {
-  const checkForSpecialKeys = e => {
-    // Esc clears search, Enter signals completion
+export class SearchControl extends Component {
+  /**
+   * Esc clears search, Enter signals completion
+   *
+   * @private
+   */
+  checkForSpecialKeys = (e) => {
     if (e.key === "Escape") {
-      props.clearNominatimSearch()
+      this.props.clearNominatimSearch()
     }
     else if (e.key === "Enter") {
-      props.searchNominatim()
+      this.props.searchNominatim()
     }
   }
 
-  const resultItems = _map(props.nominatimResults, result => (
-    <li key={result.osmId}>
-      <button
-        className='mr-text-current hover:mr-text-yellow'
-        onClick={() => props.chooseNominatimResult(result)}
-      >
-        {result.name}
-      </button>
-    </li>
-  ))
-
-  return (
-    <Dropdown
-      className="mr-dropdown--right"
-      dropdownButton={dropdown =>
+  render() {
+    const resultItems = _map(this.props.nominatimResults, result => (
+      <li key={result.osmId}>
         <button
-          onClick={dropdown.toggleDropdownVisible}
-          className="mr-leading-none mr-p-2 mr-bg-black-50 mr-text-white mr-w-8 mr-h-8 mr-flex mr-items-center mr-shadow mr-rounded-sm mr-transition-normal-in-out-quad hover:mr-text-green-lighter"
-          aria-haspopup="true"
-          aria-controls="dropdown-menu"
+          className='mr-text-current hover:mr-text-yellow'
+          onClick={() => this.props.chooseNominatimResult(result)}
         >
-          <SvgSymbol sym="search-icon" className="mr-w-4 mr-h-4 mr-fill-current" viewBox="0 0 20 20" />
+          {result.name}
         </button>
-      }
-      dropdownContent={dropdown =>
-        <div className="mr-min-w-102">
-          <div className="mr-flex mr-justify-between mr-items-center">
-            <div className="mr-flex mr-items-center">
-              <input
-                className="mr-input mr-input--green-lighter-outline mr-bg-black-25"
-                type="text"
-                placeholder={props.intl.formatMessage(messages.nominatimQuery)}
-                value={props.nominatimQuery}
-                onChange={(e) => props.updateNominatimQuery(e.target.value)}
-                onKeyDown={checkForSpecialKeys}
-              />
-              {props.nominatumSearching ?
-                <BusySpinner inline className="mr-static" /> :
-                <button
-                  className="mr-button mr-h-10 mr-py-0 mr-ml-4"
-                  onClick={props.searchNominatim}
-                >
-                  <FormattedMessage {...messages.searchLabel } />
-                </button>
-              }
+      </li>
+    ))
+
+    return (
+      <Dropdown
+        className="mr-dropdown--right mr-absolute mr-z-5 mr-right-0 mr-top-0 mr-mr-2 mr-mt-41"
+        dropdownButton={dropdown =>
+          <button
+            onClick={dropdown.toggleDropdownVisible}
+            className="mr-leading-none mr-p-2 mr-bg-black-50 mr-text-white mr-w-8 mr-h-8 mr-flex mr-items-center mr-shadow mr-rounded-sm mr-transition-normal-in-out-quad hover:mr-text-green-lighter"
+            aria-haspopup="true"
+            aria-controls="dropdown-menu"
+          >
+            <SvgSymbol sym="search-icon" className="mr-w-4 mr-h-4 mr-fill-current" viewBox="0 0 20 20" />
+          </button>
+        }
+        dropdownContent={dropdown =>
+          <div className="mr-min-w-102">
+            <div className="mr-flex mr-justify-between mr-items-center">
+              <div className="mr-flex mr-items-center">
+                <input
+                  className="mr-input mr-input--green-lighter-outline"
+                  type="text"
+                  placeholder={this.props.intl.formatMessage(messages.nominatimQuery)}
+                  value={this.props.nominatimQuery}
+                  onChange={(e) => this.props.updateNominatimQuery(e.target.value)}
+                  onKeyDown={this.checkForSpecialKeys}
+                />
+                {this.props.nominatumSearching ?
+                 <BusySpinner inline className="mr-static" /> :
+                 <button
+                   className="mr-button mr-h-10 mr-py-0 mr-ml-4"
+                   onClick={this.props.searchNominatim}
+                 >
+                   <FormattedMessage {...messages.searchLabel } />
+                 </button>
+                }
+              </div>
+              <button
+                className="mr-ml-4"
+                onClick={() => {
+                  dropdown.closeDropdown()
+                  this.props.clearNominatimSearch()
+                }}
+              >
+                <SvgSymbol
+                  sym="icon-close"
+                  viewBox="0 0 20 20"
+                  className="mr-fill-white mr-w-4 mr-h-4"
+                  aria-hidden
+                />
+              </button>
             </div>
-            <button
-              className="mr-ml-4"
-              onClick={() => {
-                dropdown.closeDropdown()
-                props.clearNominatimSearch()
-              }}
-            >
-              <SvgSymbol
-                sym="icon-close"
-                viewBox="0 0 20 20"
-                className="mr-fill-white mr-w-4 mr-h-4"
-                aria-hidden
-              />
-            </button>
-          </div>
 
-          {props.nominatimResults &&
-            <React.Fragment>
-              <hr className="mr-h-px mr-my-4 mr-bg-blue" />
-              {resultItems.length === 0 ? <FormattedMessage {...messages.noResults } /> :
-              <ol className="mr-o-2" onClick={() => dropdown.closeDropdown()}>
-                {resultItems}
-              </ol>
-              }
-            </React.Fragment>
-          }
-        </div>
-      }
-    />
-  )
+            {this.props.nominatimResults &&
+             <React.Fragment>
+               <hr className="mr-h-px mr-my-4 mr-bg-blue" />
+               {resultItems.length === 0 ? <FormattedMessage {...messages.noResults } /> :
+                <ol className="mr-o-2" onClick={() => dropdown.closeDropdown()}>
+                  {resultItems}
+                </ol>
+               }
+             </React.Fragment>
+            }
+          </div>
+        }
+      />
+    )
+  }
 }
 
-export default WithNominatimSearch(withLeaflet(injectIntl(SearchControl)))
+export default WithNominatimSearch(SearchControl)
diff --git a/src/components/HOCs/WithChallengeTaskClusters/WithChallengeTaskClusters.js b/src/components/HOCs/WithChallengeTaskClusters/WithChallengeTaskClusters.js
index 19617a6e..0aec38cf 100644
--- a/src/components/HOCs/WithChallengeTaskClusters/WithChallengeTaskClusters.js
+++ b/src/components/HOCs/WithChallengeTaskClusters/WithChallengeTaskClusters.js
@@ -1,8 +1,6 @@
 import React, { Component } from 'react'
 import { connect } from 'react-redux'
 import { bindActionCreators } from 'redux'
-import bboxPolygon from '@turf/bbox-polygon'
-import booleanDisjoint from '@turf/boolean-disjoint'
 import _omit from 'lodash/omit'
 import _cloneDeep from 'lodash/cloneDeep'
 import _get from 'lodash/get'
@@ -10,18 +8,15 @@ import _isEqual from 'lodash/isEqual'
 import _uniqueId from 'lodash/uniqueId'
 import _sum from 'lodash/sum'
 import _map from 'lodash/map'
-import _filter from 'lodash/filter'
 import _set from 'lodash/set'
 import _debounce from 'lodash/debounce'
-import _isEmpty from 'lodash/isEmpty'
-import _isFinite from 'lodash/isFinite'
 import { fromLatLngBounds, boundsWithinAllowedMaxDegrees }
        from '../../../services/MapBounds/MapBounds'
 import { fetchTaskClusters, clearTaskClusters }
        from '../../../services/Task/TaskClusters'
 import { fetchBoundedTasks, clearBoundedTasks }
        from '../../../services/Task/BoundedTask'
-import AsMappableTask from '../../../interactions/Task/AsMappableTask'
+
 import { MAX_ZOOM, UNCLUSTER_THRESHOLD } from '../../TaskClusterMap/TaskClusterMap'
 
 /**
@@ -30,28 +25,21 @@ import { MAX_ZOOM, UNCLUSTER_THRESHOLD } from '../../TaskClusterMap/TaskClusterM
  *
  * @author [Kelli Rotstan](https://github.com/krotstan)
  */
-export const WithChallengeTaskClusters = function(WrappedComponent, storeTasks=false, showClusters=true) {
+export const WithChallengeTaskClusters = function(WrappedComponent, storeTasks=false) {
   return class extends Component {
     state = {
       loading: false,
       fetchId: null,
       clusters: {},
-      showAsClusters: showClusters,
+      showAsClusters: true,
       taskCount: 0
     }
 
     updateBounds = (bounds, zoom, fromUserAction=false) => {
-      const arrayBounds = fromLatLngBounds(bounds)
-      if (this.props.criteria.boundingBox !== arrayBounds.join(',')) {
+      if (this.props.criteria.boundingBox !== fromLatLngBounds(bounds).join(',')) {
         const criteria = _cloneDeep(this.props.criteria)
-        criteria.boundingBox = arrayBounds.join(',')
+        criteria.boundingBox = fromLatLngBounds(bounds).join(',')
         this.props.updateTaskFilterBounds(bounds, zoom, fromUserAction)
-
-        // If task selection is active, prune any selections that no longer
-        // intersect with the new map bounds
-        this.props.pruneSelectedTasks && this.props.pruneSelectedTasks(task =>
-          booleanDisjoint(AsMappableTask(task).normalizedGeometries(), bboxPolygon(arrayBounds))
-        )
       }
     }
 
@@ -162,34 +150,8 @@ export const WithChallengeTaskClusters = function(WrappedComponent, storeTasks=f
       }
     }
 
-    clustersAsTasks = () => {
-      if (_isEmpty(this.state.clusters)) {
-        return this.state.clusters
-      }
-
-      // Sometimes we have tasks, sometimes single-point clusters depending on
-      // whether tasks have been unclustered. Either way, represent as tasks
-      return _isFinite(this.state.clusters[0].clusterId) ? // clusters
-             _map(this.state.clusters, cluster => ({
-               id: cluster.taskId,
-               status: cluster.taskStatus,
-               priority: cluster.taskPriority,
-               parentId: cluster.challengeIds[0],
-               geometries: cluster.geometries,
-             })) :
-             this.state.clusters // tasks
-    }
-
     onBulkTaskSelection = taskIds => {
-      const tasks =
-        _filter(this.clustersAsTasks(), task => taskIds.indexOf(task.id) !== -1)
-      this.props.onBulkTaskSelection(tasks)
-    }
-
-    onBulkTaskDeselection = taskIds => {
-      const tasks =
-        _filter(this.clustersAsTasks(), task => taskIds.indexOf(task.id) !== -1)
-      this.props.onBulkTaskDeselection(tasks)
+      this.props.onBulkTaskSelection(taskIds, this.state.clusters)
     }
 
     render() {
@@ -203,7 +165,6 @@ export const WithChallengeTaskClusters = function(WrappedComponent, storeTasks=f
           boundingBox={criteriaBounds}
           updateBounds = {this.updateBounds}
           onBulkTaskSelection = {this.onBulkTaskSelection}
-          onBulkTaskDeselection = {this.onBulkTaskDeselection}
           loading = {this.state.loading}
           toggleShowAsClusters = {this.toggleShowAsClusters}
           showAsClusters = {this.state.showAsClusters}
@@ -226,5 +187,5 @@ export const mapDispatchToProps = dispatch => Object.assign(
   }
 )
 
-export default (WrappedComponent, storeTasks, showClusters) =>
-  connect(null, mapDispatchToProps)(WithChallengeTaskClusters(WrappedComponent, storeTasks, showClusters))
+export default (WrappedComponent, storeTasks) =>
+  connect(null, mapDispatchToProps)(WithChallengeTaskClusters(WrappedComponent, storeTasks))
diff --git a/src/components/HOCs/WithFilteredClusteredTasks/WithFilteredClusteredTasks.js b/src/components/HOCs/WithFilteredClusteredTasks/WithFilteredClusteredTasks.js
index e31924de..641c2ef8 100644
--- a/src/components/HOCs/WithFilteredClusteredTasks/WithFilteredClusteredTasks.js
+++ b/src/components/HOCs/WithFilteredClusteredTasks/WithFilteredClusteredTasks.js
@@ -5,10 +5,14 @@ import _filter from 'lodash/filter'
 import _fromPairs from 'lodash/fromPairs'
 import _isEmpty from 'lodash/isEmpty'
 import _isArray from 'lodash/isArray'
+import _differenceBy from 'lodash/differenceBy'
 import _omit from 'lodash/omit'
 import _isUndefined from 'lodash/isUndefined'
+import _find from 'lodash/find'
 import _isEqual from 'lodash/isEqual'
 import _isFinite from 'lodash/isFinite'
+import _each from 'lodash/each'
+import _merge from 'lodash/merge'
 import _cloneDeep from 'lodash/cloneDeep'
 import _assignWith from 'lodash/assignWith'
 import { TaskStatus } from '../../../services/Task/TaskStatus/TaskStatus'
@@ -47,9 +51,12 @@ export default function WithFilteredClusteredTasks(WrappedComponent,
     }
 
     state = Object.assign({}, this.defaultFilters(), {
+      selectedTasks: new Map(),
       filteredTasks: {tasks: []},
+      allSelected: false,
     })
 
+
     /**
      * Toggle filtering on or off for the given task status
      */
@@ -70,18 +77,19 @@ export default function WithFilteredClusteredTasks(WrappedComponent,
                                              this.state.includeReviewStatuses,
                                              this.state.includePriorities,
                                              this.state.includeLocked)
-      this.setState({includeStatuses, filteredTasks})
 
-      // If task selection is active, prune any selections that no longer pass filters
-      this.props.pruneSelectedTasks && this.props.pruneSelectedTasks(task =>
-        !this.taskPassesFilters(
-          task,
-          includeStatuses,
-          this.state.includeReviewStatuses,
-          this.state.includePriorities,
-          this.state.includeLocked
-        )
-      )
+      const allFilteredTasks = this.filterTasks(includeStatuses,
+                                             this.state.includeReviewStatuses,
+                                             this.state.includePriorities,
+                                             this.state.includeLocked, true)
+      const selectedTasks = this.unselectExcludedTasks(allFilteredTasks)
+
+      this.setState({
+        includeStatuses,
+        selectedTasks,
+        filteredTasks,
+        allSelected: false,
+      })
     }
 
     /**
@@ -105,18 +113,18 @@ export default function WithFilteredClusteredTasks(WrappedComponent,
                                              this.state.includePriorities,
                                              this.state.includeLocked)
 
-      this.setState({includeReviewStatuses, filteredTasks})
+      const allFilteredTasks = this.filterTasks(this.state.includeStatuses,
+                                             includeReviewStatuses,
+                                             this.state.includePriorities,
+                                             this.state.includeLocked, true)
+      const selectedTasks = this.unselectExcludedTasks(allFilteredTasks)
 
-      // If task selection is active, prune any selections that no longer pass filters
-      this.props.pruneSelectedTasks && this.props.pruneSelectedTasks(task =>
-        !this.taskPassesFilters(
-          task,
-          this.state.includeStatuses,
-          includeReviewStatuses,
-          this.state.includePriorities,
-          this.state.includeLocked
-        )
-      )
+      this.setState({
+        includeReviewStatuses,
+        selectedTasks,
+        filteredTasks,
+        allSelected: false
+      })
     }
 
     /**
@@ -140,32 +148,42 @@ export default function WithFilteredClusteredTasks(WrappedComponent,
                                              includePriorities,
                                              this.state.includeLocked)
 
-      this.setState({includePriorities, filteredTasks})
-
-      // If task selection is active, prune any selections that no longer pass filters
-      this.props.pruneSelectedTasks && this.props.pruneSelectedTasks(task =>
-        !this.taskPassesFilters(
-          task,
-          this.state.includeStatuses,
-          this.state.includeReviewStatuses,
-          includePriorities,
-          this.state.includeLocked
-        )
-      )
+      const allFilteredTasks = this.filterTasks(this.state.includeStatuses,
+                                             this.state.includeReviewStatuses,
+                                             includePriorities,
+                                             this.state.includeLocked, true)
+      const selectedTasks = this.unselectExcludedTasks(allFilteredTasks)
+      this.setState({
+        includePriorities,
+        selectedTasks,
+        filteredTasks,
+        allSelected: false
+      })
     }
 
     /**
      * Filters the tasks, returning only those that match both the given
      * statuses and priorites.
      */
-    filterTasks = (includeStatuses, includeReviewStatuses, includePriorities, includeLocked) => {
-      let results = {tasks: []}
+    filterTasks = (includeStatuses, includeReviewStatuses, includePriorities, includeLocked,
+                   includeAll = false) => {
+      let results = null
       let tasks = _cloneDeep(_get(this.props[tasksProp], 'tasks'))
       if (_isArray(tasks)) {
+        if (includeAll) {
+          // In order to include all, we need to include any that are in the
+          // selectedTasks that were not in props[tasksProps]
+          // (props[taskProps] is limited to only current page of results)
+          _merge(tasks, Array.from(this.state.selectedTasks.values()))
+        }
+
         results = Object.assign({}, this.props[tasksProp], {
           tasks: _filter(tasks, task =>
-            this.taskPassesFilters(task, includeStatuses, includeReviewStatuses, includePriorities, includeLocked)
-          )
+            includeStatuses[task.status] && includePriorities[task.priority] &&
+            ((_isUndefined(task.reviewStatus) && includeReviewStatuses[REVIEW_STATUS_NOT_SET]) ||
+              includeReviewStatuses[task.reviewStatus]) &&
+            (includeLocked || !_isFinite(task.lockedBy) || task.lockedBy === _get(this.props, 'user.id'))
+          ),
         })
       }
 
@@ -173,52 +191,234 @@ export default function WithFilteredClusteredTasks(WrappedComponent,
     }
 
     /**
-     * Determines if the given task passes all active filters, returning true if it does
+     * Toggle selection of the given task on or off
      */
-    taskPassesFilters = (task, includeStatuses, includeReviewStatuses, includePriorities, includeLocked) => {
-      return (
-        includeStatuses[task.status] && includePriorities[task.priority] &&
-        ((_isUndefined(task.reviewStatus) && includeReviewStatuses[REVIEW_STATUS_NOT_SET]) ||
-          includeReviewStatuses[task.reviewStatus]) &&
-        (includeLocked || !_isFinite(task.lockedBy) || task.lockedBy === _get(this.props, 'user.id'))
-      )
+    toggleTaskSelection = task => {
+      const selected = new Map(this.state.selectedTasks)
+      if (selected.has(task.id)) {
+        selected.delete(task.id)
+      }
+      else {
+        selected.set(task.id, task)
+      }
+
+      this.setState({selectedTasks: selected, allSelected: false})
     }
 
-    clearAllFilters = () => {
-      const freshFilters = this.defaultFilters()
-      const filteredTasks = this.filterTasks(freshFilters.includeStatuses,
-                                             freshFilters.includeReviewStatuses,
-                                             freshFilters.includePriorities,
-                                             freshFilters.includeLocked)
+    /**
+     * Toggle selection of the task with the given id on or off. Requires a
+     * search of the tasks, so less optimal than toggleTaskSelection
+     */
+    toggleTaskSelectionById = taskId => {
+      const selected = new Map(this.state.selectedTasks)
+      if (selected.has(taskId)) {
+        selected.delete(taskId)
+      }
+      else {
+        const task = _find(_get(this.props[tasksProp], 'tasks', []), {id: taskId})
+        if (task) {
+          selected.set(task.id, task)
+        }
+      }
+
+      this.setState({selectedTasks: selected, allSelected: false})
+    }
+
+    /**
+     * Select multiple tasks matching the given task ids
+     */
+    selectTasksById = (taskIds, allTasksOrClusters) => {
+      // No need to select if nothing to select, or if everything's already
+      // been selected
+      if (_isEmpty(taskIds) || this.state.allSelected) {
+        return
+      }
+
+      // If we're actually selecting from single-task clusters, we need to
+      // represent those as tasks
+      const allTasks =
+        _isArray(_get(allTasksOrClusters, '0.challengeIds')) ?
+        _map(allTasksOrClusters, cluster => ({
+          id: cluster.taskId,
+          status: cluster.taskStatus,
+          priority: cluster.taskPriority,
+          parentId: cluster.challengeIds[0],
+          geometries: cluster.geometries,
+        })) :
+        allTasksOrClusters
+
+      const selected = new Map(this.state.selectedTasks)
+      const tasks = _filter(allTasks,
+                            task => taskIds.indexOf(task.id) !== -1)
+
+      _each(tasks, task => selected.set(task.id, task))
+      this.setState({selectedTasks: selected, allSelected: false})
+    }
 
-      this.setState(Object.assign({filteredTasks}, freshFilters))
+    /**
+     * Returns true if all tasks are selected, false if not. This method
+     * simply compares the number of selected tasks and doesn't actually
+     * check each task individually.
+     */
+    allTasksAreSelected = (excludeAllSelected = false) => {
+      if (this.state.selectedTasks.size === 0) {
+        return false
+      }
+
+      const totalTaskCount = _get(this.state, 'filteredTasks.tasks.length', 0)
+      return this.state.selectedTasks.size === totalTaskCount && this.state.allSelected
+    }
+
+    /**
+     * Returns true if some, but not all, tasks are selected.
+     */
+    someTasksAreSelected = () => {
+      return this.state.selectedTasks.size > 0 && !this.allTasksAreSelected()
+    }
+
+    /**
+     * Removes from the currently selected tasks any tasks that are no longer
+     * present in the given filtered tasks.
+     *
+     * @private
+     */
+    unselectExcludedTasks = filteredTasks => {
+      const selected = new Map(this.state.selectedTasks)
+      const excludedTasks = _differenceBy([...selected.values()],
+                                          filteredTasks.tasks,
+                                          task => task.id)
+
+      for (let i = 0; i < excludedTasks.length; i++) {
+        selected.delete(excludedTasks[i].id)
+      }
 
-      // Reset any task selections as well
-      this.props.resetSelectedTasks && this.props.resetSelectedTasks()
+      return selected
     }
 
     /**
-     * Refresh the currently filtered tasks, intended to be used after task
-     * statuses or other filterable criteria have been changed
+     * Refresh the selected tasks to ensure they don't include tasks that don't
+     * pass the current filters. This is intended for wrapped components to use
+     * if they do something that alters the status of tasks.
      */
-    refreshFilteredTasks = () => {
+    refreshSelectedTasks = () => {
       const filteredTasks = this.filterTasks(this.state.includeStatuses,
                                              this.state.includeReviewStatuses,
                                              this.state.includePriorities,
                                              this.state.includeLocked)
 
-      this.setState({filteredTasks})
+      const allfilteredTasks = this.filterTasks(this.state.includeStatuses,
+                                                this.state.includeReviewStatuses,
+                                                this.state.includePriorities,
+                                                this.state.includeLocked,
+                                                true)
+      const selectedTasks = this.unselectExcludedTasks(allfilteredTasks)
+      const allSelected = this.state.allSelected &&
+        selectedTasks.size === _get(this.state, 'filteredTasks.tasks.length', 0)
 
-      // If task selection is active, prune any selections that no longer pass filters
-      this.props.pruneSelectedTasks && this.props.pruneSelectedTasks(task =>
-        !this.taskPassesFilters(
-          task,
-          this.state.includeStatuses,
-          this.state.includeReviewStatuses,
-          this.state.includePriorities,
-          this.state.includeLocked
-        )
-      )
+      this.setState({filteredTasks, selectedTasks, allSelected})
+    }
+
+    /**
+     * Toggle selection of all the tasks on or off. If not all tasks are
+     * currently selected, then all will be selected; if all were selected then
+     * all will be unselected.
+     */
+    toggleAllTasksSelection = () => {
+      const selected = new Map(this.state.selectedTasks)
+      let allSelected = false
+
+      if (this.allTasksAreSelected()) {
+        selected.clear()
+      }
+      else {
+        let task = null
+
+        selected.clear()
+        for (let i = 0; i < this.state.filteredTasks.tasks.length; i++) {
+          task = this.state.filteredTasks.tasks[i]
+          selected.set(task.id, task)
+        }
+        allSelected = true
+      }
+
+      this.setState({selectedTasks: selected, allSelected})
+    }
+
+    /**
+     * All will be unselected.
+     */
+    unselectAllTasks = () => {
+      const selected = new Map(this.state.selectedTasks)
+      selected.clear()
+      this.setState({selectedTasks: selected, allSelected: false})
+    }
+
+    /**
+     * Select all filtered tasks that match the given status.
+     */
+    selectTasksWithStatus = status => {
+      const selected = new Map(this.state.selectedTasks)
+      let task = null
+
+      for (let i = 0; i < this.state.filteredTasks.tasks.length; i++) {
+        task = this.state.filteredTasks.tasks[i]
+        if (task.status === status) {
+          selected.set(task.id, task)
+        }
+      }
+
+      this.setState({selectedTasks: selected})
+    }
+
+    /**
+     * Select all filtered tasks that match the given status.
+     */
+    selectTasksWithPriority = priority => {
+      const selected = new Map(this.state.selectedTasks)
+      let task = null
+
+      for (let i = 0; i < this.state.filteredTasks.tasks.length; i++) {
+        task = this.state.filteredTasks.tasks[i]
+        if (task.priority === priority) {
+          selected.set(task.id, task)
+        }
+      }
+
+      this.setState({selectedTasks: selected, allSelected: false})
+    }
+
+    /**
+     * Select the given tasks. No checks are made to ensure the tasks are
+     * actually included in the filtered results, so take care with this method
+     */
+    selectTasks = tasks => {
+      const selected = new Map(this.state.selectedTasks)
+      for (let i = 0; i < tasks.length; i++) {
+        selected.set(tasks[i].id, tasks[i])
+      }
+
+      this.setState({selectedTasks: selected, allSelected: false})
+    }
+
+    clearAllFilters = () => {
+      const freshFilters = this.defaultFilters()
+      const filteredTasks = this.filterTasks(freshFilters.includeStatuses,
+                                             freshFilters.includeReviewStatuses,
+                                             freshFilters.includePriorities,
+                                             freshFilters.includeLocked)
+
+      const allFilteredTasks = this.filterTasks(freshFilters.includeStatuses,
+                                             freshFilters.includeReviewStatuses,
+                                             freshFilters.includePriorities,
+                                             freshFilters.includeLocked, true)
+      const selectedTasks = this.unselectExcludedTasks(allFilteredTasks)
+
+      this.setState(Object.assign({filteredTasks, selectedTasks}, freshFilters))
+    }
+
+    resetSelectedTasks = () => {
+      this.clearAllFilters()
+      this.setState({selectedTasks: new Map(), allSelected: false})
     }
 
     componentDidMount() {
@@ -227,12 +427,25 @@ export default function WithFilteredClusteredTasks(WrappedComponent,
                                              this.state.includePriorities,
                                              this.state.includeLocked)
       this.setState({filteredTasks})
+
+      // If we have a property that wants the accessor to unselect all tasks
+      // then set it up.
+      if (this.props.setResetSelectedTasksAccessor) {
+        this.props.setResetSelectedTasksAccessor(() => this.resetSelectedTasks())
+      }
     }
 
     componentDidUpdate(prevProps, prevState) {
       if (!_isEqual(_get(prevProps[tasksProp], 'tasks'), _get(this.props[tasksProp], 'tasks')) ||
           _get(prevProps[tasksProp], 'fetchId') !== _get(this.props[tasksProp], 'fetchId')) {
-        this.refreshFilteredTasks()
+        this.refreshSelectedTasks()
+      }
+
+      if (!_isEqual(this.state.selectedTasks, prevState.selectedTasks)) {
+        // If we have a listener for selected tasks let's pass up our changed tasks
+        if (this.props.setSelectedTasks) {
+          this.props.setSelectedTasks(this.state.selectedTasks)
+        }
       }
     }
 
@@ -241,17 +454,27 @@ export default function WithFilteredClusteredTasks(WrappedComponent,
         outputProp = tasksProp
       }
 
-      return <WrappedComponent
-        {...{[outputProp]: this.state.filteredTasks}}
-        includeTaskStatuses={this.state.includeStatuses}
-        includeTaskReviewStatuses={this.state.includeReviewStatuses}
-        includeTaskPriorities={this.state.includePriorities}
-        toggleIncludedTaskStatus={this.toggleIncludedStatus}
-        toggleIncludedTaskReviewStatus={this.toggleIncludedReviewStatus}
-        toggleIncludedTaskPriority={this.toggleIncludedPriority}
-        clearAllFilters={this.clearAllFilters}
-        {..._omit(this.props, outputProp)}
-      />
+      return <WrappedComponent {...{[outputProp]: this.state.filteredTasks}}
+                               includeTaskStatuses={this.state.includeStatuses}
+                               includeTaskReviewStatuses={this.state.includeReviewStatuses}
+                               includeTaskPriorities={this.state.includePriorities}
+                               selectedTasks={this.state.selectedTasks}
+                               toggleIncludedTaskStatus={this.toggleIncludedStatus}
+                               toggleIncludedTaskReviewStatus={this.toggleIncludedReviewStatus}
+                               toggleIncludedTaskPriority={this.toggleIncludedPriority}
+                               toggleTaskSelection={this.toggleTaskSelection}
+                               toggleTaskSelectionById={this.toggleTaskSelectionById}
+                               toggleAllTasksSelection={this.toggleAllTasksSelection}
+                               refreshSelectedTasks={this.refreshSelectedTasks}
+                               selectTasksWithStatus={this.selectTasksWithStatus}
+                               selectTasksWithPriority={this.selectTasksWithPriority}
+                               selectTasks={this.selectTasks}
+                               selectTasksById={this.selectTasksById}
+                               allTasksAreSelected={this.allTasksAreSelected}
+                               someTasksAreSelected={this.someTasksAreSelected}
+                               clearAllFilters={this.clearAllFilters}
+                               resetSelectedTasks={this.resetSelectedTasks}
+                               {..._omit(this.props, outputProp)} />
     }
   }
 }
diff --git a/src/components/HOCs/WithLayerSources/WithLayerSources.js b/src/components/HOCs/WithLayerSources/WithLayerSources.js
index 46cc1062..38388870 100644
--- a/src/components/HOCs/WithLayerSources/WithLayerSources.js
+++ b/src/components/HOCs/WithLayerSources/WithLayerSources.js
@@ -1,7 +1,5 @@
 import React, { Component } from 'react'
 import { injectIntl } from 'react-intl'
-import _clone from 'lodash/clone'
-import { LayerSources } from '../../../services/VisibleLayer/LayerSources'
 import AsMappableChallenge
        from '../../../interactions/Challenge/AsMappableChallenge'
 import AsMappingUser
@@ -11,7 +9,7 @@ import messages from './Messages'
 export default function WithLayerSources(WrappedComponent) {
   class _WithLayerSources extends Component {
     render() {
-      const allLayerSources = _clone(LayerSources)
+      const allLayerSources = AsMappingUser(this.props.user).allLayerSources()
 
       const challengeBasemapLayer =
         AsMappableChallenge(this.props.challenge).defaultLayerSource()
@@ -21,14 +19,6 @@ export default function WithLayerSources(WrappedComponent) {
         allLayerSources.push(challengeBasemapLayer)
       }
 
-      const userBasemapLayer =
-        AsMappingUser(this.props.user).defaultLayerSource()
-
-      if (userBasemapLayer && userBasemapLayer.isDynamic) {
-        userBasemapLayer.name = this.props.intl.formatMessage(messages.userDefault)
-        allLayerSources.push(userBasemapLayer)
-      }
-
       return <WrappedComponent layerSources={allLayerSources} {...this.props} />
     }
   }
diff --git a/src/components/HOCs/WithSelectedClusteredTasks/WithSelectedClusteredTasks.js b/src/components/HOCs/WithSelectedClusteredTasks/WithSelectedClusteredTasks.js
deleted file mode 100644
index 503ce405..00000000
--- a/src/components/HOCs/WithSelectedClusteredTasks/WithSelectedClusteredTasks.js
+++ /dev/null
@@ -1,225 +0,0 @@
-import React, { Component } from 'react'
-import _isEmpty from 'lodash/isEmpty'
-import _each from 'lodash/each'
-
-/**
- * WithSelectedClusteredTasks handles management of selection of individual
- * tasks, including an option for selecting all tasks even if not all tasks are
- * locally available (with subsequent deselection of individual tasks)
- *
- * @author [Neil Rotstan](https://github.com/nrotstan)
- */
-export default function WithSelectedClusteredTasks(WrappedComponent) {
-  return class extends Component {
-    state = {
-      allSelected: false,         // has user toggled selection of all tasks
-      selectedTasks: new Map(),   // used when allSelected flag is false
-      deselectedTasks: new Map(), // used when allSelected flag is true
-    }
-
-    /**
-     * Toggle selection of the given task on or off
-     */
-    toggleTaskSelection = task => {
-      // If allSelected is false, then we work off the selectedTasks map;
-      // otherwise we work off the deselectedTasks map
-      if (!this.state.allSelected) {
-        const selected = new Map(this.state.selectedTasks)
-        selected.has(task.id) ? selected.delete(task.id) : selected.set(task.id, task)
-        this.setState({selectedTasks: selected})
-      }
-      else {
-        const deselected = new Map(this.state.deselectedTasks)
-        deselected.has(task.id) ? deselected.delete(task.id) : deselected.set(task.id, task)
-        this.setState({deselectedTasks: deselected})
-      }
-    }
-
-    /**
-     * Select multiple tasks, matching the given task ids, from the given array
-     * of tasks (or single-task clusters)
-     */
-    selectTasks = tasks => {
-      if (_isEmpty(tasks) ||
-          (this.state.allSelected && this.state.deselectedTasks.size === 0)) {
-        // Nothing to do
-        return
-      }
-
-      // If allSelected is false, we need to add to the selectedTasks map;
-      // otherwise we need to remove from the deselectedTasks map
-      if (!this.state.allSelected) {
-        const selectedTasks = new Map(this.state.selectedTasks)
-        _each(tasks, task => selectedTasks.set(task.id, task))
-        this.setState({selectedTasks})
-      }
-      else {
-        const deselectedTasks = new Map(this.state.deselectedTasks)
-        _each(tasks, task => {
-          if (deselectedTasks.has(task.id)) {
-            deselectedTasks.delete(task.id)
-          }
-        })
-        this.setState({deselectedTasks})
-      }
-    }
-
-    /**
-     * Deselect multiple tasks, matching the given task ids, from the given
-     * array of tasks (or single-task clusters)
-     */
-    deselectTasks = tasks => {
-      if (_isEmpty(tasks) ||
-          (!this.state.allSelected && this.state.selectedTasks.size === 0)) {
-        // Nothing to do
-        return
-      }
-
-      // If allSelected is true, we need to add to the deselectedTasks map;
-      // otherwise we need to remove from the selectedTasks map
-      if (this.state.allSelected) {
-        const deselectedTasks = new Map(this.state.deselectedTasks)
-        _each(tasks, task => deselectedTasks.set(task.id, task))
-        this.setState({deselectedTasks})
-      }
-      else {
-        const selectedTasks = new Map(this.state.selectedTasks)
-        _each(tasks, task => {
-          if (selectedTasks.has(task.id)) {
-            selectedTasks.delete(task.id)
-          }
-        })
-        this.setState({selectedTasks})
-      }
-    }
-
-    /**
-     * Returns true if all tasks are actually selected, false if not. If
-     * allSelected is true, that means the deselectedTasks map is empty; if
-     * allSelected is false, then that means the selectedTasks map size equals
-     * the number of total available tasks
-     */
-    allTasksAreSelected = totalTasksAvailable => {
-      if (this.state.allSelected) {
-        return this.state.deselectedTasks.size === 0
-      }
-
-      return totalTasksAvailable > 0 &&
-             this.state.selectedTasks.size === totalTasksAvailable
-    }
-
-    /**
-     * Returns true if the given taskId is actively selected, false if not
-     */
-    isTaskSelected = taskId => {
-      return this.state.selectedTasks.has(taskId) ||
-             (this.state.allSelected && !this.state.deselectedTasks.has(taskId))
-    }
-
-    /**
-     * Returns true if some, but not all, tasks are selected
-     */
-    someTasksAreSelected = totalTasksAvailable => {
-      if (this.state.allSelected) {
-        return this.state.deselectedTasks.size > 0
-      }
-
-      return this.state.selectedTasks.size > 0 && !this.allTasksAreSelected(totalTasksAvailable)
-    }
-
-    /**
-     * Returns the total number of selected tasks
-     */
-    selectedTaskCount = totalTasksAvailable => {
-      if (this.state.allSelected) {
-        return totalTasksAvailable - this.state.deselectedTasks.size
-      }
-
-      return this.state.selectedTasks.size
-    }
-
-    /**
-     * Toggle selection of all the tasks on or off. If not all tasks are
-     * currently selected, then all will be selected; if all were selected then
-     * all will be unselected.
-     */
-    toggleAllTasksSelection = totalTasksAvailable => {
-      this.setState({
-        selectedTasks: new Map(),
-        deselectedTasks: new Map(),
-        allSelected: !this.allTasksAreSelected(totalTasksAvailable),
-      })
-    }
-
-    /**
-     * Reset task selections, unselecting all tasks
-     */
-    resetSelectedTasks = () => {
-      this.setState({
-        selectedTasks: new Map(),
-        deselectedTasks: new Map(),
-        allSelected: false,
-      })
-    }
-
-    /**
-     * Prune tasks that no longer needed to be tracked from the selectedTasks
-     * and deselectedTasks maps. The given filter function will be called with
-     * each task and, if it returns truthy, the task will be pruned
-     */
-    pruneSelectedTasks = filterFunction => {
-      this.trimMap("selectedTasks", filterFunction)
-      this.trimMap("deselectedTasks", filterFunction)
-    }
-
-    /**
-     * Trims from a named map (present in state) any tasks that pass the given
-     * filter function
-     *
-     * @private
-     */
-    trimMap = (mapName, filterFunction) => {
-      let trimmedMap = null
-      for (let [taskId, task] of this.state[mapName]) {
-        if (filterFunction(task)) {
-          if (!trimmedMap) {
-            trimmedMap = new Map(this.state[mapName])
-          }
-          trimmedMap.delete(taskId)
-        }
-      }
-
-      if (trimmedMap) {
-        this.setState({[mapName]: trimmedMap})
-      }
-    }
-
-    componentDidMount() {
-      // Provide parent with way to reset task selections, if desired
-      if (this.props.setResetSelectedTasksAccessor) {
-        this.props.setResetSelectedTasksAccessor(() => this.resetSelectedTasks())
-      }
-    }
-
-    render() {
-      return <WrappedComponent
-        {...this.props}
-        selectedTasks={Object.freeze({
-          allSelected: this.state.allSelected,
-          selected: this.state.selectedTasks,
-          deselected: this.state.deselectedTasks
-        })}
-        selectTasks={this.selectTasks}
-        deselectTasks={this.deselectTasks}
-        toggleTaskSelection={this.toggleTaskSelection}
-        toggleAllTasksSelection={this.toggleAllTasksSelection}
-        allTasksAreSelected={this.allTasksAreSelected}
-        someTasksAreSelected={this.someTasksAreSelected}
-        selectedTaskCount={this.selectedTaskCount}
-        isTaskSelected={this.isTaskSelected}
-        resetSelectedTasks={this.resetSelectedTasks}
-        pruneSelectedTasks={this.pruneSelectedTasks}
-      />
-    }
-  }
-}
diff --git a/src/components/HOCs/WithTaskClusterMarkers/WithTaskClusterMarkers.js b/src/components/HOCs/WithTaskClusterMarkers/WithTaskClusterMarkers.js
index 2bb88ea8..cdb7c9a6 100644
--- a/src/components/HOCs/WithTaskClusterMarkers/WithTaskClusterMarkers.js
+++ b/src/components/HOCs/WithTaskClusterMarkers/WithTaskClusterMarkers.js
@@ -36,6 +36,7 @@ export const WithTaskClusterMarkers = function(WrappedComponent) {
           this.props.monochromaticClusters,
           this.props.selectedTasks,
           this.props.highlightPrimaryTask,
+          this.props.allTasksAreSelected && this.props.allTasksAreSelected(),
         )
       })
 
diff --git a/src/components/HOCs/WithVisibleLayer/WithVisibleLayer.js b/src/components/HOCs/WithVisibleLayer/WithVisibleLayer.js
index 52bc9086..9ebf4727 100644
--- a/src/components/HOCs/WithVisibleLayer/WithVisibleLayer.js
+++ b/src/components/HOCs/WithVisibleLayer/WithVisibleLayer.js
@@ -67,7 +67,7 @@ export const dynamicLayerWithId = (layerId, ownProps) => {
     return challengeLayer
   }
 
-  const userLayer = AsMappingUser(ownProps.user).defaultLayerSource()
+  const userLayer = AsMappingUser(ownProps.user).findLayerSource(layerId)
   if (userLayer && userLayer.id === layerId) {
     return userLayer
   }
@@ -82,7 +82,6 @@ export const mapStateToProps = (state, ownProps) => {
   if (_isFinite(challengeId)) {
     if (_isString(ownProps.visibleMapLayer)) {
       source = layerSourceWithId(ownProps.visibleMapLayer)
-
       if (!source) {
         // Try a dynamic layer
         source = dynamicLayerWithId(ownProps.visibleMapLayer, ownProps)
diff --git a/src/components/MarkdownContent/MarkdownTemplate.js b/src/components/MarkdownContent/MarkdownTemplate.js
index 13cd8853..758e9690 100644
--- a/src/components/MarkdownContent/MarkdownTemplate.js
+++ b/src/components/MarkdownContent/MarkdownTemplate.js
@@ -13,18 +13,15 @@ import Handlebars from 'handlebars'
 import { renderToStaticMarkup } from 'react-dom/server'
 
 /**
- * MarkdownTemplate will handle converting markdown content to html, as well as
- * basic mustache tag replacement for task properties (eg. {{propName}}).
- * Handlebars is used in combination with custom handlers to convert input
- * fields that are outputed in a basic form following the normal textual
- * content.
+ * MarkdownTemplate will handle converting markdown content to html.
+ * It was also handle basic mustache tag replacement (eg. {{propName}}).
+ * It also handles template tags which are removed from the text and
+ * converted into input fields that are outputed in a list following the
+ * normal textual content.
  *
- * Current supported template/form tags are as follows (note that they use
- * three mustache tags instead of the usual two used for property
- * substitution):
- *
- * {{{checkbox "Label for checkbox" name="propertyName"}}}
- * {{{select "Label for select" name="propertyName" values="value1,value2,etc."}}}
+ * Current supported template tags are as follows:
+ * {{checkbox "Label for checkbox" name="propertyName"}}
+ * {{select "Label for select" name="propertyName" values="value1,value2,etc."}}
  *
  * @author [Kelli Rotstan](https://github.com/lrotstan)
  */
@@ -109,17 +106,14 @@ export default class MarkdownTemplate extends Component {
   }
 
   substitutePropertyTags = (text, properties) => {
-    // Handlebars does not handle @ properties (eg. @id), so we handle property
-    // substitution ourselves. We also run each property through linkify to turn
-    // URLs into links since property substitution happens after Markdown parsing,
-    // so that wouldn't otherwise happen
+    // Handlebars does not handle @ properties (eg. @id). So we will do a
+    // simple substitution first.
     let substituted = _cloneDeep(text)
     _keys(properties).forEach(key => {
       let safe = properties[key]
       if (safe) {
         safe = safe.toString().replace(/</g, '&lt;')
         safe = safe.toString().replace(/>/g, '&gt;')
-        safe = this.linkify(safe)
       }
 
       substituted = substituted.replace(RegExp(`{{\\s*${key}\\s*}}`, "g"), safe)
@@ -198,14 +192,6 @@ export default class MarkdownTemplate extends Component {
     )
   }
 
-  linkify(text) {
-    const urlPattern = /(?:https?:)?\/\/(?:(?:[\w-]+\.)+[\w/#@~.-]*)(?:\?(?:[\w&=.!,;$#%-]+)?)?/gi
-
-    return (text || '').replace(urlPattern, url =>
-      '<a href="' + url + '" target="_blank" rel="noopener noreferrer">' + url + '</a>'
-    )
-  }
-
   render() {
     if (_isEmpty(this.state.content)) {
       return null
diff --git a/src/components/Sprites/Sprites.js b/src/components/Sprites/Sprites.js
index 81576630..79949c67 100644
--- a/src/components/Sprites/Sprites.js
+++ b/src/components/Sprites/Sprites.js
@@ -861,12 +861,6 @@ export default function() {
       C175.299,88.872,242.799,70.422,303.2,70.422c112.008,0,168.8,30.016,168.8,89.213C472,207.471,419.006,241.523,387.417,257.563z"
       />
         </symbol>
-        <symbol id="lasso-add-icon" viewBox="0 0 512 512">
-          <path fill="none" d="M-1-1h802v602H-1z"/><g><path d="M443.938 55.155c-34.079-16.412-81.43-24.733-140.737-24.733-67.208 0-142.276 20.507-200.808 54.855-30.429 17.857-54.75 38.254-72.289 60.625C10.128 171.382 0 198.117 0 225.365c0 34.792 20.897 65.455 57.886 85.851A60.038 60.038 0 0057 321.422c0 33.084 26.916 60 60 60 7.527 0 14.766-1.134 21.63-3.044 3.444 4.59 7.37 13.093 7.37 28.044 0 25.437-35.48 36.76-35.747 36.843l11.494 38.313c1.602-.481 16.076-4.998 30.831-15.474C174.443 450.58 186 429.943 186 406.422c0-19.833-4.884-34.967-11.464-46.071 9.843-7.178 17.546-14.704 22.438-19.963 1.927.02 3.86.034 5.8.034 68.761 0 144.557-17.642 202.753-47.193C474.188 258.365 512 210.92 512 159.635c0-46.908-23.535-83.037-68.062-104.48zM117 341.422c-11.028 0-20-8.973-20-20 0-11.028 8.972-20 20-20 15.314 0 31.084 10.385 42.075 19.992-10.952 9.544-26.795 20.008-42.075 20.008zm270.417-83.859c-52.851 26.837-121.876 42.859-184.643 42.859-2.61 0-5.21-.021-7.786-.061-13.414-13.891-42.521-38.939-77.989-38.939-15.142 0-28.981 5.65-39.55 14.937C53.199 263.158 40 245.328 40 225.365c0-36.285 30.12-74.771 82.638-105.59C175.299 88.872 242.799 70.422 303.2 70.422c112.008 0 168.8 30.016 168.8 89.213 0 47.836-52.994 81.888-84.583 97.928z"/><path strokeWidth="30" d="M401 329.453l-1 165M483 412.453l-165-1"/></g>
-        </symbol>
-        <symbol id="lasso-remove-icon" viewBox="0 0 512 512">
-          <path fill="none" d="M-1-1h802v602H-1z"/><g><path d="M443.938 55.155c-34.079-16.412-81.43-24.733-140.737-24.733-67.208 0-142.276 20.507-200.808 54.855-30.429 17.857-54.75 38.254-72.289 60.625C10.128 171.382 0 198.117 0 225.365c0 34.792 20.897 65.455 57.886 85.851A60.038 60.038 0 0057 321.422c0 33.084 26.916 60 60 60 7.527 0 14.766-1.134 21.63-3.044 3.444 4.59 7.37 13.093 7.37 28.044 0 25.437-35.48 36.76-35.747 36.843l11.494 38.313c1.602-.481 16.076-4.998 30.831-15.474C174.443 450.58 186 429.943 186 406.422c0-19.833-4.884-34.967-11.464-46.071 9.843-7.178 17.546-14.704 22.438-19.963 1.927.02 3.86.034 5.8.034 68.761 0 144.557-17.642 202.753-47.193C474.188 258.365 512 210.92 512 159.635c0-46.908-23.535-83.037-68.062-104.48zM117 341.422c-11.028 0-20-8.973-20-20 0-11.028 8.972-20 20-20 15.314 0 31.084 10.385 42.075 19.992-10.952 9.544-26.795 20.008-42.075 20.008zm270.417-83.859c-52.851 26.837-121.876 42.859-184.643 42.859-2.61 0-5.21-.021-7.786-.061-13.414-13.891-42.521-38.939-77.989-38.939-15.142 0-28.981 5.65-39.55 14.937C53.199 263.158 40 245.328 40 225.365c0-36.285 30.12-74.771 82.638-105.59C175.299 88.872 242.799 70.422 303.2 70.422c112.008 0 168.8 30.016 168.8 89.213 0 47.836-52.994 81.888-84.583 97.928z"/><path strokeWidth="30" d="M483 412.453l-165-1"/></g>
-        </symbol>
         <symbol id="datasource-icon" viewBox="0 0 106 122">
           <path d="M105.209 87.308l-.02.147.02-.147zM8.066 13.352zm-2.245 1.27c.35-.256.706-.5 1.08-.718a8.32 8.32 0 01.875-.427c.402-.166.808-.318 1.208-.485a43.901 43.901 0 001.596-.702c.892-.414 1.772-.854 2.655-1.286a126.02 126.02 0 015.43-2.492c.475-.194.953-.38 1.431-.567.492-.193.977-.403 1.462-.612.997-.399 1.987-.819 3.022-1.11.595-.167 1.2-.29 1.804-.42a50.94 50.94 0 011.73-.345c1.228-.223 2.456-.447 3.68-.694 1.239-.25 2.478-.508 3.727-.712l1.327-.217a65.168 65.168 0 012.023-.231c.78-.072 1.554-.176 2.33-.285 1.137-.144 2.273-.31 3.415-.408a29.871 29.871 0 013.697-.104c1.214.048 2.424.143 3.639.165 1.184.022 2.367.075 3.547.174.675.056 1.35.09 2.024.137.642.045 1.278.122 1.916.197 1.314.154 2.624.319 3.936.484.89.131 1.777.268 2.662.416.817.137 1.63.297 2.45.427l1.57.248c.583.093 1.174.15 1.76.224.77.096 1.542.188 2.312.286 2.435.4 4.845.971 7.232 1.596.663.174 1.33.34 1.992.522.619.17 1.247.324 1.86.513.65.2 1.296.4 1.938.617.619.209 1.234.425 1.851.639.357.124.707.272 1.058.413 1.477.68 2.959 1.355 4.404 2.098.741.381 1.474.777 2.21 1.17.622.331 1.253.66 1.904.936.105.348.166.707.23 1.067.09.498.2.992.28 1.491.128 1.132.164 2.275.232 3.41.035.585.03 1.172.045 1.756.016.673.038 1.345.069 2.018.03.668.097 1.335.186 1.998.082.6.195 1.192.303 1.786.222 1.21.461 2.417.652 3.631.024.148.046.295.067.442.143 1.206.26 2.415.369 3.624.11 1.216.242 2.429.324 3.647.043.647.1 1.292.161 1.937.06.614.1 1.23.157 1.843.113 1.196.25 2.39.378 3.584.129 1.182.27 2.362.36 3.547.044.57.097 1.139.133 1.709.04.644.07 1.288.104 1.932.068 1.288.076 2.579.122 3.869.041 1.175.166 2.342.224 3.515.032.646.068 1.291.094 1.937.027.625.014 1.253.014 1.878v3.709c0 2.506.003 5.013-.006 7.52-.008 1.847-.016 3.694-.096 5.54-.04.923-.134 1.842-.18 2.764a36.844 36.844 0 01-.222 2.725l.015-.11c-.05.367-.096.736-.144 1.105-.177 1.181-.397 2.356-.613 3.53-.215 1.17-.412 2.344-.62 3.516-.21 1.172-.449 2.338-.652 3.51-.124.713-.26 1.428-.335 2.147a43.634 43.634 0 00-.145 1.806c-.034.547-.053 1.094-.087 1.641a43.653 43.653 0 00-.08 1.72c-.023 1.142-.022 2.291-.137 3.428-.03.163-.065.323-.116.48a3.39 3.39 0 01-.163.303 3.673 3.673 0 01-.248.25c-.324.228-.668.422-1.003.637a7.528 7.528 0 01-1.102.56c-.525.214-1.061.4-1.593.596-.489.18-.97.376-1.458.557-.557.208-1.123.385-1.696.544-.605.166-1.216.309-1.823.466-.543.14-1.073.355-1.6.544-.519.186-1.038.37-1.565.532-1.176.36-2.371.655-3.568.94a51.737 51.737 0 01-3.117.62c-.475.065-.95.133-1.427.179-.65.062-1.3.124-1.95.183-1.3.119-2.598.26-3.893.435-1.273.171-2.545.356-3.822.493-1.246.135-2.493.254-3.74.388-2.69.288-5.371.621-8.054.959-.594.075-1.19.129-1.786.185l-.927.088c-.194.018-.39.018-.586.017l-.284.001c-.618.013-1.232.026-1.85.01-.646-.015-1.292-.028-1.938-.056-1.352-.06-2.704-.101-4.056-.15-1.268-.048-2.534-.136-3.801-.193a58.44 58.44 0 01-3.786-.29c-1.173-.13-2.343-.28-3.514-.433-1.157-.165-2.304-.384-3.454-.588-1.185-.21-2.371-.42-3.551-.658a50.123 50.123 0 01-3.516-.87 38.311 38.311 0 01-1.643-.507c-.57-.188-1.125-.423-1.69-.629-.753-.274-1.506-.543-2.253-.836a63.294 63.294 0 01-1.26-.572c-.46-.213-.923-.416-1.377-.64-.477-.237-.962-.456-1.439-.692a21.05 21.05 0 01-1.41-.782 59.897 59.897 0 00-3.158-1.789 25.728 25.728 0 00-1.846-.893c-.526-.222-1.062-.414-1.59-.63a17.594 17.594 0 01-.582-.268c-.257-.124-.496-.286-.738-.438a21.451 21.451 0 01-1.492-1.057 6.613 6.613 0 01-.757-.677 2.783 2.783 0 01-.034-.062 2.495 2.495 0 01-.053-.216c-.046-.522-.088-1.044-.134-1.565-.056-.633-.076-1.266-.12-1.899-.04-.604-.111-1.205-.155-1.81-.045-.616-.068-1.235-.104-1.851-.034-.602-.03-1.205-.03-1.808V90.8c0-4.949-.006-9.898.002-14.846.003-2.482 0-4.966.101-7.446.024-.609.081-1.216.095-1.824.013-.591.01-1.183.012-1.774.002-1.28.012-2.558.039-3.836.027-1.26.093-2.518.143-3.777.053-1.313.026-2.63.026-3.945 0-2.526-.004-5.052.036-7.576a81.9 81.9 0 01.154-3.74c.037-.58.023-1.163.026-1.743.003-.639.007-1.278.014-1.916.012-1.17.064-2.336.14-3.503a40.03 40.03 0 00.081-1.733c.012-.571.035-1.143.071-1.713.038-.602.095-1.203.125-1.805.03-.613.045-1.227.07-1.841.024-.613.069-1.225.095-1.837.025-.578.023-1.158.023-1.736 0-2.538-.013-5.077.062-7.615l.02-.619a8.308 8.308 0 01.64-.59c.311-.248.61-.51.918-.763zM44.616.213c-1.189.102-2.372.277-3.556.426-.245.031-.488.067-.732.101-.56.073-1.12.135-1.682.187-.651.061-1.3.147-1.95.229-1.362.17-2.714.44-4.064.695-1.202.229-2.4.482-3.605.708-.645.122-1.292.237-1.938.355-.569.103-1.132.229-1.698.347a22.98 22.98 0 00-1.524.361 17.71 17.71 0 00-1.41.465c-.875.326-1.737.694-2.595 1.063-.389.159-.78.312-1.174.457-.59.219-1.168.486-1.747.734-.95.408-1.898.821-2.83 1.266a358.5 358.5 0 00-2.932 1.411c-.89.435-1.781.872-2.684 1.28-.905.37-1.818.714-2.696 1.145-.474.234-.92.54-1.343.857-.247.187-.483.385-.72.584a1.338 1.338 0 00-.75-.238c-.75 0-1.317.614-1.34 1.349-.047 1.44-.099 2.882-.123 4.323-.02 1.27-.02 2.539-.02 3.808-.002 1.19.015 2.382-.014 3.573-.015.635-.068 1.27-.1 1.905-.029.58-.033 1.16-.062 1.74-.06 1.213-.173 2.421-.206 3.635a43.534 43.534 0 01-.075 1.747 79.718 79.718 0 00-.1 1.737C.893 37.64.89 38.816.883 39.993c-.003.635.007 1.272-.02 1.906-.024.575-.074 1.148-.095 1.724-.096 2.55-.097 5.102-.1 7.654-.002 1.28-.002 2.562-.002 3.842 0 .625.012 1.252-.013 1.876-.027.646-.061 1.292-.089 1.938A136.905 136.905 0 00.47 62.78c-.018 1.203.01 2.408-.016 3.611C.426 67.61.326 68.828.3 70.05c-.028 1.319-.038 2.64-.045 3.96-.023 4.914-.007 9.83-.007 14.745v7.481c0 .583-.003 1.167.022 1.75.027.599.07 1.197.1 1.797.031.598.102 1.192.149 1.79.051.647.067 1.297.121 1.944.036.431.076.862.112 1.294.016.195.03.39.049.586.016.175.044.35.069.524.038.27.14.521.253.766.25.539.664.925 1.107 1.298.413.348.846.673 1.284.99.62.452 1.254.892 1.95 1.216.77.359 1.569.64 2.353.963 1.082.48 2.106 1.093 3.143 1.662.973.534 1.898 1.15 2.893 1.644.917.455 1.848.882 2.776 1.312.596.276 1.193.562 1.803.802.582.23 1.166.448 1.754.66.58.212 1.156.427 1.733.646.54.206 1.094.374 1.647.54 1.16.347 2.33.67 3.517.913 1.179.242 2.362.466 3.548.672.578.1 1.155.213 1.733.317.578.104 1.158.217 1.74.297 1.397.192 2.795.377 4.196.539a70.26 70.26 0 001.78.186c.677.06 1.354.1 2.034.14.566.033 1.134.049 1.701.08.708.041 1.417.08 2.125.117 1.178.062 2.359.072 3.537.125.645.028 1.29.06 1.935.076.634.015 1.27.048 1.904.034l1.27-.027c.216-.005.433-.002.65-.022l2.098-.198c.702-.067 1.402-.177 2.103-.265.64-.08 1.281-.163 1.922-.244 1.234-.157 2.47-.293 3.706-.425 1.194-.128 2.39-.242 3.583-.37.637-.069 1.276-.13 1.911-.213.648-.087 1.295-.171 1.943-.259 1.29-.173 2.582-.357 3.879-.475 1.355-.123 2.708-.239 4.058-.414a37.992 37.992 0 003.912-.703c1.228-.293 2.456-.593 3.666-.957 1.08-.325 2.123-.759 3.201-1.088.574-.176 1.17-.286 1.748-.447a20.335 20.335 0 001.66-.515c.51-.19 1.012-.403 1.525-.585.56-.197 1.125-.392 1.672-.621.6-.252 1.23-.492 1.78-.847.231-.15.466-.291.703-.43.3-.177.595-.412.855-.638.099-.084.186-.186.273-.282.179-.198.35-.418.469-.658.105-.21.199-.419.287-.637.1-.245.151-.494.193-.754.105-.661.142-1.332.164-2 .022-.676.036-1.352.05-2.028.013-.611.05-1.22.087-1.83.066-1.085.108-2.169.216-3.252.149-1.013.331-2.024.522-3.03.212-1.117.419-2.233.615-3.353.195-1.111.385-2.223.596-3.332.1-.52.183-1.044.272-1.566.107-.623.187-1.248.268-1.873l-.012.088.013-.09.007-.058c.085-.627.168-1.253.214-1.883.047-.629.074-1.259.114-1.888.04-.622.093-1.243.129-1.865.035-.6.055-1.202.07-1.803.065-2.482.05-4.966.053-7.45.002-2.502 0-5.007 0-7.511 0-.619.009-1.238-.003-1.857-.013-.649-.05-1.297-.082-1.945-.062-1.183-.177-2.362-.229-3.546-.055-1.254-.059-2.509-.112-3.763a101.677 101.677 0 00-.578-7.29c-.067-.588-.138-1.176-.202-1.763-.066-.617-.118-1.236-.184-1.853a63.013 63.013 0 01-.156-1.778c-.05-.646-.12-1.29-.17-1.935-.046-.593-.079-1.187-.132-1.779-.061-.675-.132-1.35-.196-2.024-.115-1.215-.216-2.431-.362-3.642l-.04-.315a22.09 22.09 0 01-.23-1.05c-.155-.812-.25-1.636-.348-2.456a24.824 24.824 0 00-.02-.112c-.155-.882-.342-1.76-.473-2.646a42.35 42.35 0 01-.195-3.388c-.02-.276-.028-.553-.051-.828-.03-.357-.062-.712-.03-1.069l.021-.216c-.023-.42-.051-.838-.075-1.257-.03-.55-.059-1.1-.111-1.65a18.584 18.584 0 00-.31-2c-.103-.502-.175-1.009-.297-1.508-.026-.11-.069-.217-.107-.323-.125-.34-.263-.65-.505-.921a1.879 1.879 0 00-.654-.469l-.346-.144c-1.006-.47-1.98-1.013-2.957-1.541-1.09-.59-2.218-1.125-3.342-1.647-1.25-.58-2.494-1.178-3.798-1.631-1.207-.42-2.414-.862-3.648-1.201a317.67 317.67 0 00-3.906-1.048c-1.262-.33-2.529-.627-3.8-.913-1.252-.281-2.506-.549-3.78-.714-1.228-.16-2.458-.306-3.687-.458-.547-.086-1.096-.164-1.64-.26-.618-.11-1.234-.225-1.852-.328a105.331 105.331 0 00-4.278-.644c-1.25-.158-2.5-.311-3.752-.459A37.98 37.98 0 0057.34.572C56.725.53 56.112.5 55.499.448a54.763 54.763 0 00-3.547-.174C50.737.252 49.527.157 48.313.11a27.24 27.24 0 00-.99-.018c-.903 0-1.806.045-2.707.122z" />
           <path d="M102.765 28.33l-.023-.18v-.004l.023.185m2.408 59.246v-.002.002m-4.965-63.847l-.077.1.077-.1zM2.67 15.275c.048 1.058.295 2.143.763 3.096.245.5.597.925.935 1.364.312.404.678.755 1.053 1.098.977.895 2.009 1.721 3.056 2.53.88.733 1.747 1.48 2.653 2.182a38.112 38.112 0 002.57 1.805c.873.57 1.748 1.142 2.645 1.673.428.254.874.475 1.317.701.493.252.988.498 1.485.74 1.014.496 2.074.893 3.119 1.319.951.388 1.893.8 2.87 1.119.461.15.927.295 1.397.41.492.12.989.212 1.486.303 1.03.19 2.047.425 3.068.654 1.007.224 2.013.458 3.023.665 1.056.217 2.121.375 3.183.56.984.17 1.97.316 2.956.481 1.08.181 2.168.288 3.256.406 1.01.11 2.019.17 3.034.186 1.013.018 2.028-.075 3.04-.103 1.019-.03 2.033-.1 3.05-.161.517-.031 1.035-.047 1.552-.079.539-.034 1.077-.088 1.615-.124 1.022-.07 2.047-.081 3.07-.101 1.045-.02 2.09-.151 3.127-.27 1.152-.131 2.3-.322 3.431-.579 1-.227 1.993-.47 2.989-.711 2.183-.528 4.364-1.061 6.56-1.535.99-.214 1.993-.368 2.987-.56 1.134-.222 2.269-.44 3.401-.67 2.151-.438 4.295-.904 6.433-1.404 2.135-.501 4.261-1.049 6.386-1.595 1.387-.357 2.777-.706 4.162-1.07.327-.085.654-.17.977-.268.41-.125.804-.296 1.196-.468.412-.182.799-.476 1.128-.777.266-.243.494-.51.715-.79-.023-.22-.043-.441-.065-.661-.042-.401-.054-.803-.087-1.205-.033-.39-.084-.776-.13-1.164-.03-.247-.041-.497-.055-.746-.144.172-.29.344-.435.514-.217.256-.445.501-.656.764-.235.293-.465.59-.694.887l.103-.134a6.05 6.05 0 01-.64.666 4.202 4.202 0 01-.56.324c-.823.318-1.694.503-2.547.721-1.021.261-2.041.527-3.062.79-2.118.544-4.237 1.092-6.366 1.59-2.14.502-4.287.97-6.44 1.41-2.147.436-4.303.829-6.455 1.243-.997.192-1.986.44-2.974.673-1.053.248-2.105.503-3.156.757-1.003.242-2.004.489-3.008.723-.96.224-1.921.399-2.894.547-.948.118-1.903.233-2.857.278-1.044.049-2.09.044-3.133.097-1.05.053-2.095.159-3.146.205-1.012.045-2.02.14-3.033.17-1.413.042-2.823.143-4.236.102a29.226 29.226 0 01-2.061-.14c-.674-.068-1.349-.137-2.021-.214-.883-.128-1.762-.289-2.644-.423-.509-.077-1.014-.174-1.522-.256a107.45 107.45 0 01-1.616-.269c-2.062-.361-4.094-.885-6.14-1.328-.77-.166-1.548-.283-2.318-.449-.73-.157-1.452-.394-2.157-.641-.753-.266-1.486-.589-2.225-.89-.67-.274-1.342-.542-2.01-.82a74.1 74.1 0 01-2.351-1.155c-.36-.184-.72-.368-1.07-.574-.402-.239-.796-.49-1.191-.74a81.51 81.51 0 01-2.143-1.389 39.08 39.08 0 01-1.87-1.343c-.949-.765-1.859-1.577-2.83-2.314a39.118 39.118 0 01-1.608-1.32c-.285-.25-.57-.501-.845-.761a12.952 12.952 0 01-.685-.68c-.198-.216-.37-.458-.548-.69l.188.244a4.646 4.646 0 01-.435-.735 8.169 8.169 0 01-.356-1.34 7.052 7.052 0 01-.024-.35c-.016-.364-.133-.693-.393-.954a1.34 1.34 0 00-.947-.395c-.706 0-1.373.613-1.34 1.348zm99.665 10.059l.024-.031-.024.03M1.993 44.597c-.24.242-.376.573-.376.914 0 .307.123.716.376.914.9.707 1.797 1.418 2.704 2.116.382.294.766.586 1.152.875.372.279.785.504 1.198.715.794.404 1.588.817 2.395 1.194.814.381 1.64.728 2.467 1.078.25.11.501.217.752.323.91.407 1.798.864 2.69 1.311.914.459 1.821.928 2.734 1.39.918.465 1.862.862 2.803 1.277.296.13.596.274.899.384l.865.313c.263.095.538.162.805.243.217.065.433.132.65.192 1.086.299 2.173.589 3.27.846.536.126 1.073.261 1.617.354.532.09 1.065.17 1.598.254.592.093 1.185.18 1.782.235.54.05 1.082.096 1.623.15 1.083.11 2.161.252 3.24.39.853.11 1.708.207 2.56.319.932.132 1.857.293 2.787.427a35.86 35.86 0 001.9.242 33.87 33.87 0 001.713.111c2.032.087 4.07-.01 6.1.13.527.038 1.054.071 1.582.09.526.02 1.055.044 1.581.017.473-.025.944-.061 1.416-.086.538-.027 1.076-.035 1.614-.07 1.02-.065 2.047-.13 3.064-.244 1.16-.13 2.306-.34 3.46-.508l.127-.018c.57-.075 1.14-.152 1.711-.23l-.356.049c1.314-.177 2.624-.38 3.935-.581.576-.089 1.153-.169 1.73-.258.565-.088 1.129-.184 1.695-.269.568-.07 1.139-.133 1.703-.224l1.858-.298c.994-.16 1.983-.366 2.966-.59l1.533-.348c.53-.12 1.06-.252 1.592-.357 1.202-.236 2.403-.47 3.606-.7 1.006-.191 2.01-.401 3.01-.62a39.281 39.281 0 003.178-.86 93.757 93.757 0 003.167-1.043c.457-.158.908-.337 1.36-.506a487.71 487.71 0 001.69-.63c.593-.224 1.185-.449 1.776-.68.58-.225 1.148-.48 1.717-.729.499-.202 1.007-.397 1.487-.637.3-.15.527-.487.614-.803a1.346 1.346 0 00-.933-1.655l-.356-.05a1.326 1.326 0 00-.674.185l-.15.066c-.622.255-1.237.526-1.854.794-.965.396-1.944.758-2.92 1.125-.983.371-1.967.745-2.96 1.089-2.045.71-4.123 1.373-6.242 1.818-1.058.222-2.118.439-3.18.641-.507.098-1.015.194-1.523.294-.562.11-1.13.206-1.69.333l-3.471.788c-1.022.232-2.057.41-3.09.576l-1.161.186c-.707.091-1.414.183-2.12.285-.703.102-1.402.229-2.104.333-1.36.204-2.719.41-4.08.612l-.926.126.356-.049c-1.32.179-2.635.37-3.95.583-.939.112-1.88.179-2.824.248-.54.04-1.079.081-1.621.099a50.12 50.12 0 00-1.507.073c-.51.033-1.014.06-1.524.049a46.06 46.06 0 01-1.529-.062c-.5-.026-1-.07-1.5-.098-.514-.029-1.027-.024-1.54-.025a130.154 130.154 0 01-3.29-.039 35.933 35.933 0 01-1.627-.076 31.11 31.11 0 01-1.552-.16c-.907-.134-1.81-.297-2.72-.42a189.16 189.16 0 00-3.194-.41c-.978-.116-1.954-.249-2.932-.363-.973-.114-1.95-.182-2.922-.289-.7-.1-1.4-.21-2.098-.329-.824-.14-1.637-.357-2.448-.553-.836-.202-1.664-.43-2.493-.658-.406-.112-.808-.241-1.211-.363-.354-.106-.698-.24-1.045-.366l-1.21-.52a35.093 35.093 0 01-1.903-.884c-.67-.337-1.334-.683-2.005-1.017-.665-.33-1.331-.66-1.998-.988a42.286 42.286 0 00-2.05-.93l-.03-.014-.065-.027-.037-.015-.075-.033.011.005c-.46-.195-.923-.385-1.386-.576-1.002-.44-1.98-.92-2.963-1.4-.541-.264-1.071-.531-1.571-.87-.481-.327-.937-.697-1.398-1.05a691.29 691.29 0 01-2.02-1.583l-.259-.202a1.284 1.284 0 00-1.556.202M.345 73.667a1.125 1.125 0 00-.326.791c0 .315.129.558.326.79.398.472.817.926 1.255 1.36.186.185.371.372.56.555.214.207.42.427.646.619.28.236.555.48.847.701l.76.574c.194.146.402.273.604.408.221.147.441.293.668.432.965.587 1.945 1.153 2.964 1.642 1.06.508 2.177.906 3.277 1.32.515.193 1.032.383 1.545.585.49.193.98.386 1.475.567.505.184 1.039.304 1.557.443.492.133.985.262 1.477.39 1.042.272 2.087.529 3.128.807 1.045.279 2.086.578 3.119.903 1.99.626 4 1.21 6.036 1.67 1.053.237 2.106.47 3.156.723.499.12 1 .234 1.5.348.612.14 1.236.222 1.857.302 1.061.136 2.122.261 3.19.33a60.01 60.01 0 003.115.108c.513.006 1.026.008 1.539.015.515.007 1.032.044 1.548.07 1.093.055 2.19.07 3.281.147 1.043.074 2.084.128 3.13.159 1.1.032 2.2.033 3.302.038 1.112.005 2.225.014 3.337-.032a52.933 52.933 0 003.227-.236c1.139-.117 2.276-.24 3.412-.383 1.107-.139 2.207-.327 3.311-.489 1.035-.127 2.072-.247 3.108-.37 1.115-.131 2.234-.216 3.353-.314a34.99 34.99 0 003.57-.529c1.118-.217 2.243-.4 3.366-.588 1.708-.286 3.423-.528 5.13-.819.808-.137 1.618-.269 2.426-.407.834-.141 1.67-.257 2.508-.375 1.046-.142 2.088-.299 3.117-.542.583-.137 1.143-.356 1.692-.593.235-.102.462-.213.684-.34.193-.111.371-.246.558-.367.35-.225.698-.458 1.038-.698.407-.288.799-.596 1.207-.883.366-.257.722-.53 1.081-.795.412-.303.828-.608 1.198-.963.409-.393.828-.774 1.24-1.163.173-.163.334-.34.497-.511.206-.216.441-.403.66-.605.538-.497.5-1.405 0-1.908a1.338 1.338 0 00-.948-.395c-.366 0-.681.15-.947.395-.246.227-.503.44-.734.684-.23.241-.469.472-.709.702-.253.242-.516.474-.768.717-.19.182-.382.36-.585.526-.742.563-1.502 1.102-2.25 1.656-.392.29-.8.561-1.208.831-.192.127-.382.26-.577.382a5.58 5.58 0 01-.623.33c-1.201.47-2.473.674-3.743.858a90.654 90.654 0 00-3.163.467c-1.046.178-2.093.354-3.14.525-2.138.352-4.281.662-6.414 1.04-1.019.18-2.032.385-3.054.541a60.39 60.39 0 01-2.438.242c-.91.073-1.814.177-2.72.285-.976.116-1.956.215-2.929.355-.865.124-1.728.261-2.591.393-.973.127-1.946.237-2.922.339a64.39 64.39 0 01-3.285.277c-1.092.058-2.181.07-3.274.065-1.133-.004-2.267-.006-3.4-.026a61.327 61.327 0 01-3.18-.131c-.527-.037-1.053-.078-1.582-.095-.503-.016-1.007-.031-1.51-.056-1.068-.051-2.133-.107-3.203-.114a78.48 78.48 0 01-3.148-.077c-1.254-.06-2.499-.2-3.743-.357a20.493 20.493 0 01-1.473-.274 148.7 148.7 0 01-1.509-.352c-1.01-.24-2.03-.446-3.04-.688a99.71 99.71 0 01-6.1-1.684 73.325 73.325 0 00-3.198-.893c-1.04-.27-2.084-.514-3.126-.775-1.023-.256-2.058-.475-3.058-.813-.987-.334-1.96-.708-2.947-1.044a57.992 57.992 0 01-2.448-.902 25.004 25.004 0 01-2.46-1.225c-.494-.287-.98-.577-1.46-.885-.381-.244-.743-.511-1.108-.779-.197-.158-.398-.313-.58-.486-.243-.23-.488-.456-.726-.69-.248-.243-.51-.478-.744-.734-.31-.338-.624-.672-.92-1.022-.183-.216-.512-.328-.785-.328a1.11 1.11 0 00-.786.328" />
diff --git a/src/components/TaskAnalysisTable/TaskAnalysisTable.js b/src/components/TaskAnalysisTable/TaskAnalysisTable.js
index b5197b58..84290124 100644
--- a/src/components/TaskAnalysisTable/TaskAnalysisTable.js
+++ b/src/components/TaskAnalysisTable/TaskAnalysisTable.js
@@ -193,11 +193,12 @@ export class TaskAnalysisTable extends Component {
       }
     }
 
+    const selectedDataTasks = [...this.props.selectedTasks.keys()]
+    const selectedTaskCount = this.props.selectedTasks.size
+
     if (_get(this.props, 'criteria.filters')) {
-      defaultFiltered = _map(
-        this.props.criteria.filters,
-        (value, key) => ({id: key, value})
-      )
+      defaultFiltered = _map(this.props.criteria.filters,
+                             (value, key) => {return {id: key, value}})
     }
 
     const manager = AsManager(this.props.user)
@@ -212,6 +213,8 @@ export class TaskAnalysisTable extends Component {
                {...this.props}
                countShown={data.length}
                configureColumns={this.configureColumns.bind(this)}
+               selectedTasks={selectedDataTasks}
+               selectedTaskCount={selectedTaskCount}
              />
            </header>
           }
@@ -268,7 +271,7 @@ const setupColumnTypes = (props, taskBaseRoute, manager, data, openComments) =>
 
   columns.selected = {id: 'selected',
     Header: null,
-    accessor: task => props.isTaskSelected(task.id),
+    accessor: task => (props.selectedTasks.has(task.id) || props.allTasksAreSelected()),
     Cell: ({value, original}) => (
       props.highlightPrimaryTask && original.id === props.task.id ?
       <span className="mr-text-green-lighter">✓</span> :
@@ -587,7 +590,7 @@ TaskAnalysisTable.propTypes = {
   challenge: PropTypes.object,
   /** Total tasks available (we may receive a subset) */
   totalTaskCount: PropTypes.number,
-  /** Currently selected tasks */
+  /** Map of currently selected tasks */
   selectedTasks: PropTypes.object.isRequired,
   /** Invoked to toggle selection of a task */
   toggleTaskSelection: PropTypes.func.isRequired,
diff --git a/src/components/TaskAnalysisTable/TaskAnalysisTableHeader.js b/src/components/TaskAnalysisTable/TaskAnalysisTableHeader.js
index fd72134a..68938670 100644
--- a/src/components/TaskAnalysisTable/TaskAnalysisTableHeader.js
+++ b/src/components/TaskAnalysisTable/TaskAnalysisTableHeader.js
@@ -6,17 +6,25 @@ import _noop from 'lodash/noop'
 import _get from 'lodash/get'
 import _map from 'lodash/map'
 import _omit from 'lodash/omit'
+import _isArray from 'lodash/isArray'
+import _filter from 'lodash/filter'
 import AsManager from '../../interactions/User/AsManager'
 import Dropdown from '../Dropdown/Dropdown'
 import SvgSymbol from '../SvgSymbol/SvgSymbol'
 import TriStateCheckbox from '../Bulma/TriStateCheckbox'
 import ConfirmAction from '../ConfirmAction/ConfirmAction'
+import DropdownButton from '../Bulma/DropdownButton'
 import TimezonePicker from '../TimezonePicker/TimezonePicker'
-import { TaskStatus, statusLabels } from '../../services/Task/TaskStatus/TaskStatus'
+import WithDeactivateOnOutsideClick from '../HOCs/WithDeactivateOnOutsideClick/WithDeactivateOnOutsideClick'
+import { TaskStatus, statusLabels, keysByStatus } from '../../services/Task/TaskStatus/TaskStatus'
+import { TaskReviewStatusWithUnset, reviewStatusLabels, keysByReviewStatus } from '../../services/Task/TaskReview/TaskReviewStatus'
 import { buildLinkToMapperExportCSV } from '../../services/Task/TaskReview/TaskReview'
+import { TaskPriority, taskPriorityLabels, keysByPriority } from '../../services/Task/TaskPriority/TaskPriority'
 import { buildLinkToExportCSV, buildLinkToExportGeoJSON } from '../../services/Challenge/Challenge'
 import messages from './Messages'
 
+const DeactivatableDropdownButton = WithDeactivateOnOutsideClick(DropdownButton)
+
 /**
  * TaskAnalysisTableHeader renders a header for the task analysis table.
  *
@@ -25,33 +33,109 @@ import messages from './Messages'
 export class TaskAnalysisTableHeader extends Component {
     state = {}
 
+    takeTaskSelectionAction = action => {
+        if (action.statusAction) {
+          this.props.selectTasksWithStatus(action.status)
+        }
+        else if (action.priorityAction) {
+          this.props.selectTasksWithPriority(action.priority)
+        }
+        else if (action.shownAction) {
+          this.props.selectTasks(this.props.taskInfo.tasks)
+        }
+    }
+
     render() {
         const {countShown, configureColumns} = this.props
 
+        const selectedCount = this.props.selectedTaskCount
         const totalTaskCount = _get(this.props, 'totalTaskCount') || countShown || 0
         const totalTasksInChallenge = _get(this.props, 'totalTasksInChallenge', 0)
-        const percentShown =
-          totalTasksInChallenge > 0 ?
-          Math.round(totalTaskCount / totalTasksInChallenge * 100.0) :
-          0
+        const percentShown = Math.round(totalTaskCount / totalTasksInChallenge * 100.0)
         const manager = AsManager(this.props.user)
         const localizedStatusLabels = statusLabels(this.props.intl)
+        const localizedReviewStatusLabels = reviewStatusLabels(this.props.intl)
+        const localizedPriorityLabels = taskPriorityLabels(this.props.intl)
+
+        const taskSelectionStatuses = _isArray(this.props.taskSelectionStatuses) ?
+                                      this.props.taskSelectionStatuses :
+                                      TaskStatus
+
+        const taskSelectionReviewStatuses = _isArray(this.props.taskSelectionReviewStatuses) ?
+                                            this.props.taskSelectionReviewStatuses :
+                                            TaskReviewStatusWithUnset
+
+        const taskSelectionPriorities = _isArray(this.props.taskSelectionPriorities) ?
+                                        this.props.taskSelectionPriorities :
+                                        TaskPriority
+
+        const taskSelectionActions = [{
+          key: 'shown-tasks',
+          text: this.props.intl.formatMessage(messages.shownLabel),
+          shownAction: true,
+        }].concat(
+          _map(taskSelectionStatuses, status => ({
+            key: `status-${status}`,
+            text: localizedStatusLabels[keysByStatus[status]],
+            status,
+            statusAction: true,
+          }))
+        ).concat(
+          _map(taskSelectionReviewStatuses, status => ({
+            key: `review-status-${status}`,
+            text: localizedReviewStatusLabels[keysByReviewStatus[status]],
+            status,
+            statusAction: true,
+          }))
+        ).concat(
+          _map(taskSelectionPriorities, priority => ({
+            key: `priority-${priority}`,
+            text: `${localizedPriorityLabels[keysByPriority[priority]]} ${this.props.intl.formatMessage(messages.priorityLabel)}`,
+            priority,
+            priorityAction: true,
+          }))
+        )
+
+        // Determine if we have tasks that can be marked as review unnecessary
+        let hasReviewRequests = this.props.allTasksAreSelected()
+        let tasks = this.props.taskData || []
+        const selectedTasks = this.props.selectedTasks || []
+        if (!hasReviewRequests) {
+          tasks = _filter(tasks, task =>
+            task.reviewStatus === TaskReviewStatusWithUnset.needed &&
+            selectedTasks.find(id => id === task.id))
+          hasReviewRequests = tasks.length > 0
+        }
+
 
         return (
             <div className="mr-flex mr-justify-between">
                 <div className="mr-flex mr-items-center">
                     {_get(this.props, 'taskInfo.tasks.length', 0) > 0 &&
-                        <div className="mr-mr-4 mr-ml-12.8">
-                          {!this.props.suppressTriState &&
-                           <div title={this.props.intl.formatMessage(messages.bulkSelectionTooltip)}>
-                             <TriStateCheckbox
-                                 checked={this.props.allTasksAreSelected(totalTaskCount)}
-                                 indeterminate={this.props.someTasksAreSelected(totalTaskCount)}
-                                 onClick={() => this.props.toggleAllTasksSelection()}
-                                 onChange={_noop}
-                             />
-                           </div>
-                          }
+                        <div className="mr-mr-4">
+                          <div
+                            className="mr-m-0 mr-flex mr-py-2 mr-pl-4 mr-pr-8 mr-items-center mr-bg-black-15 mr-rounded mr-relative"
+                            title={this.props.intl.formatMessage(messages.bulkSelectionTooltip)}
+                          >
+                            <TriStateCheckbox
+                                checked={this.props.allTasksAreSelected() || (selectedCount === totalTaskCount)}
+                                indeterminate={this.props.someTasksAreSelected() && (selectedCount !== totalTaskCount)}
+                                onClick={() => this.props.toggleAllTasksSelection()}
+                                onChange={_noop}
+                            />
+                            <div className="mr-absolute mr-top-0 mr-right-0 mr-mt-1 mr-mr-2">
+                              <DeactivatableDropdownButton
+                                options={taskSelectionActions}
+                                onSelect={this.takeTaskSelectionAction}
+                              >
+                                <SvgSymbol
+                                  sym="icon-cheveron-down"
+                                  viewBox="0 0 20 20"
+                                  className="mr-fill-green-lighter mr-w-5 mr-h-5"
+                                />
+                              </DeactivatableDropdownButton>
+                            </div>
+                          </div>
                         </div>
                     }
 
@@ -59,7 +143,7 @@ export class TaskAnalysisTableHeader extends Component {
                         <span className="mr-mr-2">
                           <FormattedMessage
                             {...messages.taskCountSelectedStatus}
-                            values={{selectedCount: this.props.selectedTaskCount(totalTaskCount)}}
+                            values={{selectedCount: (this.props.allTasksAreSelected() ? totalTaskCount : selectedCount)}}
                           />
                         </span>
                         <span className="mr-mr-6">
@@ -134,7 +218,10 @@ export class TaskAnalysisTableHeader extends Component {
                                           skipConfirmation={e => e.target.value === ""}
                                         >
                                           <button
-                                            className="mr-text-current mr-pr-1 mr-text-green-lighter"
+                                            className={classNames("mr-text-current mr-pr-1",
+                                              !hasReviewRequests ? "mr-text-grey-light mr-cursor-default" : "mr-text-green-lighter"
+                                            )}
+                                            disabled={!hasReviewRequests}
                                             onClick={() => this.props.removeReviewRequests(this.props.selectedTasks)}
                                           >
                                             <FormattedMessage {...messages.changeReviewStatusLabel} />
diff --git a/src/components/TaskClusterMap/TaskClusterMap.js b/src/components/TaskClusterMap/TaskClusterMap.js
index e2db4bb5..d6fa3016 100644
--- a/src/components/TaskClusterMap/TaskClusterMap.js
+++ b/src/components/TaskClusterMap/TaskClusterMap.js
@@ -19,7 +19,7 @@ import _compact from 'lodash/compact'
 import _cloneDeep from 'lodash/cloneDeep'
 import _isObject from 'lodash/isObject'
 import _omit from 'lodash/omit'
-import { layerSourceWithId } from '../../services/VisibleLayer/LayerSources'
+import { buildLayerSources } from '../../services/VisibleLayer/LayerSources'
 import { TaskPriorityColors } from '../../services/Task/TaskPriority/TaskPriority'
 import AsMappableCluster from '../../interactions/TaskCluster/AsMappableCluster'
 import AsMappableTask from '../../interactions/Task/AsMappableTask'
@@ -358,16 +358,11 @@ export class TaskClusterMap extends Component {
     }
   }
 
-  deselectTasksInLayers = layers => {
-    if (this.props.onBulkTaskDeselection) {
-      const taskIds = _compact(_map(layers, layer => _get(layer, 'options.icon.options.taskData.taskId')))
-      this.props.onBulkTaskDeselection(taskIds)
-    }
-  }
-
   render() {
-    const overlayLayers = _map(this.props.visibleOverlays, (layerId, index) =>
-      <SourcedTileLayer key={layerId} source={layerSourceWithId(layerId)} zIndex={index + 2} />
+    const overlayLayers = buildLayerSources(
+      this.props.visibleOverlays, _get(this.props, 'user.settings.customBasemaps'),
+      (layerId, index, layerSource) =>
+        <SourcedTileLayer key={layerId} source={layerSource} zIndex={index + 2} />
     )
 
     const canClusterToggle = !!this.props.allowClusterToggle &&
@@ -412,19 +407,7 @@ export class TaskClusterMap extends Component {
         <ZoomControl className="mr-z-10" position='topright' />
         {this.props.showLasso && this.props.onBulkTaskSelection &&
           (!this.props.showAsClusters || this.props.totalTaskCount <= CLUSTER_POINTS) &&
-          <LassoSelectionControl
-            onLassoSelection={this.selectTasksInLayers}
-            onLassoDeselection={this.deselectTasksInLayers}
-          />
-        }
-        {!this.props.hideSearchControl &&
-          <SearchControl
-            {...this.props}
-            onResultSelected={bounds => {
-              this.currentBounds = toLatLngBounds(bounds)
-              this.props.updateBounds(bounds)
-            }}
-          />
+         <LassoSelectionControl onLassoSelection={this.selectTasksInLayers} />
         }
         <VisibleTileLayer {...this.props} zIndex={1} />
         {overlayLayers}
@@ -445,6 +428,15 @@ export class TaskClusterMap extends Component {
           </label>
         }
         <LayerToggle {...this.props} />
+        {!this.props.hideSearchControl &&
+          <SearchControl
+            {...this.props}
+            onResultSelected={bounds => {
+              this.currentBounds = toLatLngBounds(bounds)
+              this.props.updateBounds(bounds)
+            }}
+          />
+        }
         {!!this.props.mapZoomedOut && !this.state.locatingToUser &&
           <ZoomInMessage {...this.props} zoom={this.currentZoom}/>
         }
diff --git a/src/components/TaskPane/TaskMap/TaskMap.js b/src/components/TaskPane/TaskMap/TaskMap.js
index e13e1f4a..8ec807bb 100644
--- a/src/components/TaskPane/TaskMap/TaskMap.js
+++ b/src/components/TaskPane/TaskMap/TaskMap.js
@@ -17,7 +17,7 @@ import _compact from 'lodash/compact'
 import _flatten from 'lodash/flatten'
 import _isEmpty from 'lodash/isEmpty'
 import _clone from 'lodash/clone'
-import { layerSourceWithId } from '../../../services/VisibleLayer/LayerSources'
+import { buildLayerSources } from '../../../services/VisibleLayer/LayerSources'
 import EnhancedMap from '../../EnhancedMap/EnhancedMap'
 import DirectionalIndicationMarker
        from '../../EnhancedMap/DirectionalIndicationMarker/DirectionalIndicationMarker'
@@ -491,8 +491,10 @@ export class TaskMap extends Component {
       return <BusySpinner />
     }
 
-    const overlayLayers = _map(this.props.visibleOverlays, (layerId, index) =>
-      <SourcedTileLayer key={layerId} source={layerSourceWithId(layerId)} zIndex={index + 2} />
+    const overlayLayers = buildLayerSources(
+      this.props.visibleOverlays, _get(this.props, 'user.settings.customBasemaps'),
+      (layerId, index, layerSource) =>
+        <SourcedTileLayer key={layerId} source={layerSource} zIndex={index + 2} />
     )
 
     const mapillaryMarkers = this.props.showMapillaryLayer ?
diff --git a/src/components/TaskPane/TaskNearbyList/TaskNearbyMap.js b/src/components/TaskPane/TaskNearbyList/TaskNearbyMap.js
index a6241f14..078431af 100644
--- a/src/components/TaskPane/TaskNearbyList/TaskNearbyMap.js
+++ b/src/components/TaskPane/TaskNearbyList/TaskNearbyMap.js
@@ -12,7 +12,7 @@ import _get from 'lodash/get'
 import _map from 'lodash/map'
 import _cloneDeep from 'lodash/cloneDeep'
 import { latLng } from 'leaflet'
-import { layerSourceWithId } from '../../../services/VisibleLayer/LayerSources'
+import { buildLayerSources } from '../../../services/VisibleLayer/LayerSources'
 import { TaskStatusColors, messagesByStatus } from '../../../services/Task/TaskStatus/TaskStatus'
 import { messagesByPriority } from '../../../services/Task/TaskPriority/TaskPriority'
 import AsMappableTask from '../../../interactions/Task/AsMappableTask'
@@ -149,8 +149,10 @@ export class TaskNearbyMap extends Component {
       })
     }
 
-    const overlayLayers = _map(this.props.visibleOverlays, (layerId, index) =>
-      <SourcedTileLayer key={layerId} source={layerSourceWithId(layerId)} zIndex={index + 2} />
+    const overlayLayers = buildLayerSources(
+      this.props.visibleOverlays, _get(this.props, 'user.settings.customBasemaps'),
+      (layerId, index, layerSource) =>
+        <SourcedTileLayer key={layerId} source={layerSource} zIndex={index + 2} />
     )
 
     if (!coloredMarkers) {
diff --git a/src/components/Widgets/TaskBundleWidget/TaskBundleWidget.js b/src/components/Widgets/TaskBundleWidget/TaskBundleWidget.js
index ecb15c20..76119390 100644
--- a/src/components/Widgets/TaskBundleWidget/TaskBundleWidget.js
+++ b/src/components/Widgets/TaskBundleWidget/TaskBundleWidget.js
@@ -4,6 +4,7 @@ import { Popup } from 'react-leaflet'
 import _get from 'lodash/get'
 import _isFinite from 'lodash/isFinite'
 import _isEqual from 'lodash/isEqual'
+import _uniq from 'lodash/uniq'
 import _sum from 'lodash/sum'
 import _values from 'lodash/values'
 import _pick from 'lodash/pick'
@@ -17,8 +18,6 @@ import TaskClusterMap from '../../TaskClusterMap/TaskClusterMap'
 import TaskPropertyFilter from '../../TaskFilters/TaskPropertyFilter'
 import TaskPriorityFilter from '../../TaskFilters/TaskPriorityFilter'
 import TaskStatusFilter from '../../TaskFilters/TaskStatusFilter'
-import WithSelectedClusteredTasks
-       from '../../HOCs/WithSelectedClusteredTasks/WithSelectedClusteredTasks'
 import WithBrowsedChallenge from '../../HOCs/WithBrowsedChallenge/WithBrowsedChallenge'
 import WithNearbyTasks from '../../HOCs/WithNearbyTasks/WithNearbyTasks'
 import WithTaskClusterMarkers from '../../HOCs/WithTaskClusterMarkers/WithTaskClusterMarkers'
@@ -35,7 +34,6 @@ import WithWebSocketSubscriptions
        from '../../HOCs/WithWebSocketSubscriptions/WithWebSocketSubscriptions'
 import { TaskStatus } from '../../../services/Task/TaskStatus/TaskStatus'
 import { TaskAction } from '../../../services/Task/TaskAction/TaskAction'
-import { toLatLngBounds } from '../../../services/MapBounds/MapBounds'
 import QuickWidget from '../../QuickWidget/QuickWidget'
 import BusySpinner from '../../BusySpinner/BusySpinner'
 import TaskAnalysisTable from '../../TaskAnalysisTable/TaskAnalysisTable'
@@ -61,18 +59,14 @@ const descriptor = {
 }
 
 const ClusterMap = WithChallengeTaskClusters(
-  WithTaskClusterMarkers(TaskClusterMap('taskBundling')),
-  false,
-  false
-)
+                     WithTaskClusterMarkers(TaskClusterMap('taskBundling')))
 
 
 export default class TaskBundleWidget extends Component {
   bundleTasks = () => {
-    // Because there's no way to select all tasks (TriState checkbox is
-    // suppressed on the TaskAnalysisTables), we only need to worry about
-    // explicitly selected tasks
-    this.props.createTaskBundle([...this.props.selectedTasks.selected.keys()])
+    this.props.createTaskBundle(
+      _uniq([this.props.task.id].concat([...this.props.selectedTasks.keys()]))
+    )
   }
 
   /**
@@ -99,7 +93,6 @@ export default class TaskBundleWidget extends Component {
   unbundleTasks = async () => {
     this.props.removeTaskBundle(this.props.taskBundle.bundleId, this.props.task.id)
     this.props.resetSelectedTasks()
-    this.setBoundsToNearbyTask()
   }
 
   updateBounds = (challengeId, bounds, zoom) => {
@@ -135,10 +128,6 @@ export default class TaskBundleWidget extends Component {
       this.initializeClusterFilters()
       this.initializeWebsocketSubscription()
     }
-
-    if (this.props.task && this.props.selectedTasks && !this.props.isTaskSelected(this.props.task.id)) {
-      this.props.selectTasks([this.props.task])
-    }
   }
 
   componentDidUpdate(prevProps) {
@@ -152,9 +141,6 @@ export default class TaskBundleWidget extends Component {
         this.props.task.id !== prevProps.task.id) {
       this.props.resetSelectedTasks()
     }
-    else if (this.props.task && this.props.selectedTasks && !this.props.isTaskSelected(this.props.task.id)) {
-      this.props.selectTasks([this.props.task])
-    }
   }
 
   componentWillUnmount() {
@@ -206,7 +192,7 @@ const ActiveBundle = props => {
   return (
     <div className="mr-p-4 mr-h-full mr-rounded">
       <div className="mr-flex mr-justify-between mr-content-center mr-mb-8">
-        <h3 className="mr-text-lg mr-text-pink-light">
+        <h3 className="mr-text-lg mr-text-blue">
           <FormattedMessage
             {...messages.simultaneousTasks}
             values={{taskCount: props.taskBundle.taskIds.length}}
@@ -240,7 +226,6 @@ const ActiveBundle = props => {
         taskSelectionReviewStatuses={[]}
         suppressHeader
         suppressManagement
-        suppressTriState
         defaultPageSize={5}
       />
     </div>
@@ -279,8 +264,11 @@ const BuildBundle = props => {
     )
   }
 
-  const totalTaskCount = _get(props, 'taskInfo.totalCount') || _get(props, 'taskInfo.tasks.length')
-  const bundleButton = props.selectedTaskCount(totalTaskCount) > 1 ? (
+  // Force the current task to always show as selected
+  const selectedTasks = new Map(props.selectedTasks)
+  selectedTasks.set(props.task.id, props.task)
+
+  const bundleButton = selectedTasks.size > 1 ? (
     <button
       className="mr-button mr-button--green-lighter mr-button--small"
       onClick={props.bundleTasks}
@@ -293,11 +281,8 @@ const BuildBundle = props => {
     return (
       <Popup key={markerData.options.taskId}>
         <div className="marker-popup-content">
-          <TaskMarkerContent
-            {...props}
-            marker={markerData}
-            taskId={markerData.options.taskId}
-          />
+          <TaskMarkerContent marker={markerData} taskId={markerData.options.taskId}
+                    selectedTasks={selectedTasks} {..._omit(props, 'selectedTasks')} />
         </div>
       </Popup>
     )
@@ -308,13 +293,12 @@ const BuildBundle = props => {
       loadingTasks={props.loadingTasks}
       showMarkerPopup={showMarkerPopup}
       highlightPrimaryTask={props.task.id}
+      selectedTasks={selectedTasks}
       boundingBox={_get(props, 'criteria.boundingBox')}
-      initialBounds={toLatLngBounds(_get(props, 'criteria.boundingBox', []))}
-      onBulkTaskSelection={props.selectTasks}
-      onBulkTaskDeselection={props.deselectTasks}
+      onBulkTaskSelection={props.selectTasksById}
       allowClusterToggle={false}
       hideSearchControl
-      {..._omit(props, 'className')}
+      {..._omit(props, 'selectedTasks', 'className')}
     />
 
   return (
@@ -341,8 +325,9 @@ const BuildBundle = props => {
       <div className="mr-px-4 mr-h-half mr-overflow-y-auto">
         <TaskAnalysisTable
           {...props}
+          selectedTasks={selectedTasks}
           taskData={_get(props, 'taskInfo.tasks')}
-          totalTaskCount={totalTaskCount}
+          totalTaskCount={_get(props, 'taskInfo.totalCount') || _get(props, 'taskInfo.tasks.length')}
           totalTasksInChallenge={ calculateTasksInChallenge(props) }
           showColumns={['selected', 'featureId', 'id', 'status', 'priority']}
           taskSelectionStatuses={[TaskStatus.created, TaskStatus.skipped, TaskStatus.tooHard]}
@@ -353,7 +338,6 @@ const BuildBundle = props => {
           highlightPrimaryTask
           defaultPageSize={5}
           forBundling
-          suppressTriState
         />
       </div>
     </div>
@@ -361,30 +345,28 @@ const BuildBundle = props => {
 }
 
 registerWidgetType(
-  WithSelectedClusteredTasks(
-    WithNearbyTasks(
-      WithClusteredTasks(
-        WithFilteredClusteredTasks(
-          WithTaskPropertyKeys(
-            WithFilterCriteria(
-              WithBoundedTasks(
-                WithBrowsedChallenge(
-                  WithWebSocketSubscriptions(
-                    TaskBundleWidget,
-                  )
-                ),
-                'filteredClusteredTasks',
-                'taskInfo'
-              )
+  WithNearbyTasks(
+    WithClusteredTasks(
+      WithFilteredClusteredTasks(
+        WithTaskPropertyKeys(
+          WithFilterCriteria(
+            WithBoundedTasks(
+              WithBrowsedChallenge(
+                WithWebSocketSubscriptions(
+                  TaskBundleWidget,
+                )
+              ),
+              'filteredClusteredTasks',
+              'taskInfo'
             )
-          ),
-          'clusteredTasks',
-          'filteredClusteredTasks',
-          {
-            statuses: VALID_STATUSES,
-            includeLocked: false,
-          }
-        )
+          )
+        ),
+        'clusteredTasks',
+        'filteredClusteredTasks',
+        {
+          statuses: VALID_STATUSES,
+          includeLocked: false,
+        }
       )
     )
   ), descriptor
diff --git a/src/components/Widgets/TaskBundleWidget/TaskMarkerContent.js b/src/components/Widgets/TaskBundleWidget/TaskMarkerContent.js
index 4dcbc1f4..baff5080 100644
--- a/src/components/Widgets/TaskBundleWidget/TaskMarkerContent.js
+++ b/src/components/Widgets/TaskBundleWidget/TaskMarkerContent.js
@@ -10,11 +10,11 @@ import messages from './Messages'
  */
 class TaskMarkerContent extends Component {
   toggleSelection = () => {
-    this.props.toggleTaskSelection(this.props.marker.options)
+    this.props.toggleTaskSelectionById(this.props.marker.options.taskId)
   }
 
   render() {
-    const selected = this.props.isTaskSelected(this.props.marker.options.taskId)
+    const selected = this.props.selectedTasks.has(this.props.marker.options.taskId)
     const statusMessage = messagesByStatus[
       _isFinite(this.props.marker.options.taskStatus) ?
       this.props.marker.options.taskStatus :
diff --git a/src/components/Widgets/TaskCompletionWidget/TaskCompletionWidget.js b/src/components/Widgets/TaskCompletionWidget/TaskCompletionWidget.js
index 866fb918..830af00d 100644
--- a/src/components/Widgets/TaskCompletionWidget/TaskCompletionWidget.js
+++ b/src/components/Widgets/TaskCompletionWidget/TaskCompletionWidget.js
@@ -27,7 +27,7 @@ const descriptor = {
 export default class TaskCompletionWidget extends Component {
   bundleTasks = () => {
     this.props.createTaskBundle(
-      _uniq([this.props.task.id].concat([...this.props.selectedTasks.selected.keys()]))
+      _uniq([this.props.task.id].concat([...this.props.selectedTasks.keys()]))
     )
   }
 
@@ -45,9 +45,9 @@ export default class TaskCompletionWidget extends Component {
     // If we have selected Tasks but no tasks in bundle, a bundle was started
     // but not created with server
     if (!this.props.taskReadOnly &&
-        _get(this.props, 'selectedTasks.selected.size') > 0 && taskCount === 0) {
-      taskCount = this.props.selectedTasks.selected.size
-      if (!this.props.selectedTasks.selected.has(this.props.task.id)) {
+        _get(this.props, 'selectedTasks.size') > 0 && taskCount === 0) {
+      taskCount = this.props.selectedTasks.size
+      if (!this.props.selectedTasks.has(this.props.task.id)) {
         taskCount += 1 // Count the current task if needed
       }
 
diff --git a/src/interactions/Challenge/AsMappableChallenge.js b/src/interactions/Challenge/AsMappableChallenge.js
index fe8b724c..9b8e5376 100644
--- a/src/interactions/Challenge/AsMappableChallenge.js
+++ b/src/interactions/Challenge/AsMappableChallenge.js
@@ -14,11 +14,8 @@ export class AsMappableChallenge {
     if (!_isFinite(this.id)) {
       return null
     }
-
     return basemapLayerSource(this.defaultBasemap,
-                              this.defaultBasemapId,
-                              this.customBasemap,
-                              `challenge_${this.id}`)
+                              this.defaultBasemapId)
   }
 }
 
diff --git a/src/interactions/TaskCluster/AsMappableCluster.js b/src/interactions/TaskCluster/AsMappableCluster.js
index cbd9ba19..c31179a7 100644
--- a/src/interactions/TaskCluster/AsMappableCluster.js
+++ b/src/interactions/TaskCluster/AsMappableCluster.js
@@ -1,7 +1,6 @@
 import L from 'leaflet'
 import 'leaflet-vectoricon'
 import _isFunction from 'lodash/isFunction'
-import _isFinite from 'lodash/isFinite'
 import _merge from 'lodash/merge'
 import _cloneDeep from 'lodash/cloneDeep'
 import _fromPairs from 'lodash/fromPairs'
@@ -53,14 +52,14 @@ export class AsMappableCluster {
    * Generates a map marker object suitable for use with a Leaflet map, with
    * optionally customized appearance for the given map layer
    */
-  mapMarker(monochromatic, selectedTasks, highlightPrimaryTask) {
+  mapMarker(monochromatic, selectedTasks, highlightPrimaryTask, allTasksSelected) {
     return {
       position: [this.point.lat, this.point.lng],
       options: {...(_merge(this.rawData, {taskStatus: this.rawData.status,
                                           taskPriority: this.rawData.priority,
                                           name: this.rawData.title,
                                           taskId: this.rawData.id}))},
-      icon: this.leafletMarkerIcon(monochromatic, selectedTasks, highlightPrimaryTask),
+      icon: this.leafletMarkerIcon(monochromatic, selectedTasks, highlightPrimaryTask, allTasksSelected),
     }
   }
 
@@ -68,7 +67,7 @@ export class AsMappableCluster {
    * Generates a Leaflet Icon object appropriate for the given cluster based on
    * its size, including using a standard marker for a single point
    */
-  leafletMarkerIcon(monochromatic=false, selectedTasks, highlightPrimaryTask=false) {
+  leafletMarkerIcon(monochromatic=false, selectedTasks, highlightPrimaryTask=false, allTasksSelected=false) {
     const count = _isFunction(this.rawData.getChildCount) ?
                   this.rawData.getChildCount() :
                   _get(this.options, 'numberOfPoints', this.numberOfPoints)
@@ -89,20 +88,13 @@ export class AsMappableCluster {
       })
     }
     else {
-      const markerData = _merge(this.rawData, {
-        taskStatus: this.rawData.status,
-        taskPriority: this.rawData.priority,
-        taskId: this.rawData.id
-      })
-
-      const isSelected = selectedTasks && (
-        (selectedTasks.allSelected && !selectedTasks.deselected.has(markerData.taskId)) ||
-        (!selectedTasks.allSelected && selectedTasks.selected.has(markerData.taskId))
-      )
+      const markerData = _merge(this.rawData, {taskStatus: this.rawData.status,
+                                               taskPriority: this.rawData.priority,
+                                               taskId: this.rawData.id})
 
       let icon = _cloneDeep(statusIcons[markerData.taskStatus] || statusIcons[0])
 
-      if (_isFinite(highlightPrimaryTask) && highlightPrimaryTask === markerData.taskId) {
+      if (highlightPrimaryTask && highlightPrimaryTask === markerData.taskId) {
         // Make marker for current task larger
         icon = _cloneDeep(primaryTaskStatusIcons[markerData.taskStatus])
         icon.options.svgHeight = 40
@@ -110,7 +102,7 @@ export class AsMappableCluster {
         icon.options.style.fill = colors.yellow
         icon.options.iconAnchor = [5, 25] // adjust position of marker tip for larger size
       }
-      else if (isSelected) {
+      else if (allTasksSelected || (selectedTasks && selectedTasks.has(markerData.taskId))) {
         icon = _cloneDeep(selectedTaskStatusIcons[markerData.taskStatus])
         icon.options.style.stroke = colors.yellow
         icon.options.style.strokeWidth = 2
diff --git a/src/interactions/User/AsEditableUser.js b/src/interactions/User/AsEditableUser.js
index 638f8553..78a2f95e 100644
--- a/src/interactions/User/AsEditableUser.js
+++ b/src/interactions/User/AsEditableUser.js
@@ -1,5 +1,6 @@
 import _isFinite from 'lodash/isFinite'
 import _isString from 'lodash/isString'
+import _find from 'lodash/find'
 import { ChallengeBasemap }
        from '../../services/Challenge/ChallengeBasemap/ChallengeBasemap'
 
@@ -21,14 +22,23 @@ export class AsEditableUser {
    * identifier if appropriate.
    *
    */
-  normalizeDefaultBasemap() {
+  normalizeDefaultBasemap(layerSources, customBasemaps) {
     if (_isFinite(Number(this.defaultBasemap))) {
       this.defaultBasemapId = ''
       this.defaultBasemap = Number(this.defaultBasemap)
     }
     else if (_isString(this.defaultBasemap) && this.defaultBasemap.length > 0) {
-      this.defaultBasemapId = this.defaultBasemap
-      this.defaultBasemap = ChallengeBasemap.identified
+      // Check to make sure our defaultBasemap is in our valid list of basemaps
+      // If not found then set the default basemap to none.
+      if (!_find(customBasemaps, (basemap) => basemap.name === this.defaultBasemap) &&
+          !_find(layerSources, (basemap) => basemap.id === this.defaultBasemap)) {
+        this.defaultBasemapId = ''
+        this.defaultBasemap = ChallengeBasemap.none
+      }
+      else {
+        this.defaultBasemapId = this.defaultBasemap
+        this.defaultBasemap = ChallengeBasemap.identified
+      }
     }
     else {
       this.defaultBasemapId = ''
diff --git a/src/interactions/User/AsMappingUser.js b/src/interactions/User/AsMappingUser.js
index 8cf49193..4bb95ada 100644
--- a/src/interactions/User/AsMappingUser.js
+++ b/src/interactions/User/AsMappingUser.js
@@ -1,6 +1,10 @@
 import _isFinite from 'lodash/isFinite'
 import _isObject from 'lodash/isObject'
-import { basemapLayerSource }
+import _clone from 'lodash/clone'
+import _map from 'lodash/map'
+import _isEmpty from 'lodash/isEmpty'
+import _find from 'lodash/find'
+import { basemapLayerSource, LayerSources }
        from '../../services/VisibleLayer/LayerSources'
 
 /**
@@ -11,15 +15,31 @@ export class AsMappingUser {
     Object.assign(this, user)
   }
 
+  allLayerSources() {
+    const customBasemaps = this.settings ?
+      _map(this.settings.customBasemaps,
+        basemap => basemapLayerSource(basemap, basemap.name)
+      ) : []
+
+    return _clone(LayerSources).concat(customBasemaps)
+  }
+
   defaultLayerSource() {
     if (!_isFinite(this.id) || !_isObject(this.settings)) {
       return null
     }
 
+    // Try to find a custom basemap if the user has one selected.
+    if (!_isEmpty(this.settings.defaultBasemapId)) {
+      return this.findLayerSource(this.settings.defaultBasemapId)
+    }
+
     return basemapLayerSource(this.settings.defaultBasemap,
-                              this.settings.defaultBasemapId,
-                              this.settings.customBasemap,
-                              `user_${this.id}`)
+                              this.settings.defaultBasemapId)
+  }
+
+  findLayerSource(layerId) {
+    return _find(this.allLayerSources(), (source) => source.id === layerId)
   }
 }
 
diff --git a/src/lang/en-US.json b/src/lang/en-US.json
index c45a2b80..1615cd7d 100644
--- a/src/lang/en-US.json
+++ b/src/lang/en-US.json
@@ -728,7 +728,7 @@
   "Errors.reviewTask.fetchFailure": "Unable to fetch review needed tasks",
   "Errors.reviewTask.notClaimedByYou": "Unable to cancel review.",
   "Errors.task.alreadyLocked": "Task has already been locked by someone else.",
-  "Errors.task.bundleFailure": "Unable to bundle tasks together",
+  "Errors.task.bundleFailure": "Unable to bundling tasks together",
   "Errors.task.cooperativeFailure": "Failed to load cooperative task{details}",
   "Errors.task.deleteFailure": "Unable to delete task.",
   "Errors.task.doesNotExist": "That task does not exist.",
@@ -954,14 +954,19 @@
   "Profile.apiKey.controls.copy.label": "Copy",
   "Profile.apiKey.controls.reset.label": "Reset",
   "Profile.apiKey.header": "API Key",
+  "Profile.form.addCustomBasemap.label": "Add Custom Basemap",
   "Profile.form.allowFollowing.description": "If no, users will not be able to follow your MapRoulette activity.",
   "Profile.form.allowFollowing.label": "Allow Following",
   "Profile.form.customBasemap.description": "Insert a custom base map here. E.g. `https://'{s}'.tile.openstreetmap.org/'{z}'/'{x}'/'{y}'.png`",
-  "Profile.form.customBasemap.label": "Custom Basemap",
+  "Profile.form.customBasemap.name.label": "Name",
+  "Profile.form.customBasemap.overlay.label": "is overlay?",
+  "Profile.form.customBasemap.url.label": "URL",
+  "Profile.form.customBasemaps.label": "Custom Basemaps",
   "Profile.form.defaultBasemap.description": "Select the default basemap to display on the map. Only a default challenge basemap will override the option selected here.",
   "Profile.form.defaultBasemap.label": "Default Basemap",
   "Profile.form.defaultEditor.description": "Select the default editor that you want to use when fixing tasks. By selecting this option you will be able to skip the editor selection dialog after clicking on edit in a task.",
   "Profile.form.defaultEditor.label": "Default Editor",
+  "Profile.form.deleteCustomBasemap.label": "Delete",
   "Profile.form.email.description": "If you request emails in your Notification Subscriptions, they will be sent here.\n\nDecide which MapRoulette notifications you would like to receive, along with whether you would like to be sent an email informing you of the notification (either immediately or as a daily digest)",
   "Profile.form.email.label": "Email address",
   "Profile.form.isReviewer.description": "Volunteer to review tasks for which a review has been requested",
@@ -977,6 +982,7 @@
   "Profile.form.notification.label": "Notification",
   "Profile.form.notificationSubscriptions.description": "Decide which MapRoulette notifications you would like to receive, along with whether you would like to be sent an email informing you of the notification (either immediately or as a daily digest)",
   "Profile.form.notificationSubscriptions.label": "Notification Subscriptions",
+  "Profile.form.uniqueCustomBasemap.error": "Custom Basemap names must be unique",
   "Profile.form.yes.label": "Yes",
   "Profile.noUser": "User not found or you are unauthorized to view this user.",
   "Profile.page.title": "User Settings",
diff --git a/src/pages/Profile/Messages.js b/src/pages/Profile/Messages.js
index 31d67cfa..79d03833 100644
--- a/src/pages/Profile/Messages.js
+++ b/src/pages/Profile/Messages.js
@@ -44,9 +44,9 @@ export default defineMessages({
     defaultMessage: "Select the default basemap to display on the map. Only a default challenge basemap will override the option selected here.",
   },
 
-  customBasemapLabel: {
-    id: "Profile.form.customBasemap.label",
-    defaultMessage: "Custom Basemap",
+  customBasemapsLabel: {
+    id: "Profile.form.customBasemaps.label",
+    defaultMessage: "Custom Basemaps",
   },
 
   customBasemapDescription: {
@@ -54,6 +54,36 @@ export default defineMessages({
     defaultMessage: "Insert a custom base map here. E.g. `https://'{s}'.tile.openstreetmap.org/'{z}'/'{x}'/'{y}'.png`",
   },
 
+  addCustomBasemapLabel: {
+    id: "Profile.form.addCustomBasemap.label",
+    defaultMessage: "Add Custom Basemap",
+  },
+
+  deleteCustomBasemapLabel: {
+    id: "Profile.form.deleteCustomBasemap.label",
+    defaultMessage: "Delete",
+  },
+
+  customBasemapNameLabel: {
+    id: "Profile.form.customBasemap.name.label",
+    defaultMessage: "Name",
+  },
+
+  customBasemapURLLabel: {
+    id: "Profile.form.customBasemap.url.label",
+    defaultMessage: "URL",
+  },
+
+  customBasemapOverlayLabel: {
+    id: "Profile.form.customBasemap.overlay.label",
+    defaultMessage: "is overlay?",
+  },
+
+  uniqueCustomBasemapError: {
+    id: "Profile.form.uniqueCustomBasemap.error",
+    defaultMessage: "Custom Basemap names must be unique",
+  },
+
   localeLabel: {
     id: "Profile.form.locale.label",
     defaultMessage: "Locale",
diff --git a/src/pages/Profile/UserSettings/UserSettings.js b/src/pages/Profile/UserSettings/UserSettings.js
index 77a2e66a..5ec19b07 100644
--- a/src/pages/Profile/UserSettings/UserSettings.js
+++ b/src/pages/Profile/UserSettings/UserSettings.js
@@ -12,15 +12,24 @@ import _isUndefined from 'lodash/isUndefined'
 import _isEmpty from 'lodash/isEmpty'
 import _isFinite from 'lodash/isFinite'
 import _debounce from 'lodash/debounce'
+import _remove from 'lodash/remove'
+import _find from 'lodash/find'
+import _cloneDeep from 'lodash/cloneDeep'
+import _uniq from 'lodash/uniq'
+import _countBy from 'lodash/countBy'
+import _findLastIndex from 'lodash/findLastIndex'
+import _trim from 'lodash/trim'
 import { basemapLayerSources }
        from '../../../services/Challenge/ChallengeBasemap/ChallengeBasemap'
+import { LayerSources }
+      from '../../../services/VisibleLayer/LayerSources'
 import { NotificationSubscriptionType, keysByNotificationType }
        from '../../../services/Notification/NotificationType/NotificationType'
 import AsEditableUser from '../../../interactions/User/AsEditableUser'
 import WithStatus from '../../../components/HOCs/WithStatus/WithStatus'
 import BusySpinner from '../../../components/BusySpinner/BusySpinner'
 import SvgSymbol from '../../../components/SvgSymbol/SvgSymbol'
-import { CustomSelectWidget, NoFieldsetObjectFieldTemplate }
+import { CustomSelectWidget, NoFieldsetObjectFieldTemplate, CustomArrayFieldTemplate }
        from '../../../components/Bulma/RJSFFormFieldAdapter/RJSFFormFieldAdapter'
 import { jsSchema as settingsJsSchema, uiSchema as settingsUiSchema }
        from './UserSettingsSchema'
@@ -38,14 +47,58 @@ class UserSettings extends Component {
 
   /** Save the latest user settings modified by the user */
   saveUserSettings = _debounce((settings) => {
+    // We cannot save the user settings if the basemap names are not unique.
+    // Otherwise, this will mess up our attempts to match the server generated
+    // id with the matching custom basemap with that name.
+    if (!this.areBasemapNamesUnique(settings.customBasemaps)) {
+      return
+    }
+
     this.setState({isSaving: true, saveComplete: false})
 
-    const editableUser = AsEditableUser(settings)
-    editableUser.normalizeDefaultBasemap()
+    const editableUser = AsEditableUser(_cloneDeep(settings))
+
+    // Massage customBasemaps json:
+    // 1.don't send if name or url has not been completed yet
+    // 2. insert id: -1 if it's a new customBasemap
+    _remove(editableUser.customBasemaps,
+      (data) => (_isEmpty(_trim(data.name)) || _isEmpty(_trim(data.url))))
+    editableUser.customBasemaps.forEach( (data) => {
+      if (!data.id) {
+        data.id = -1
+      }
+    })
+
+    editableUser.normalizeDefaultBasemap(LayerSources, editableUser.customBasemaps)
 
     this.props.updateUserSettings(
       this.props.user.id, editableUser
-    ).then(() => this.setState({isSaving: false, saveComplete: true}))
+    ).then((results) => {
+      // Make sure the correct defaultBasemapId is set on the form.
+      // If a custom basemap was removed that was also set as the default,
+      // then this would be set back to None by the normalizeDefaultBasemap().
+      const updatedUser = _get(results, 'entities.users', [])[this.props.user.id]
+      const settingsFormData = _cloneDeep(this.state.settingsFormData)
+      settingsFormData.defaultBasemap = updatedUser.settings.defaultBasemapId || "-1"
+
+      // If we have new customBasemaps data in our state then we need to update any
+      // matching new mappings with the server generated id.
+      if (_get(updatedUser, 'settings.customBasemaps')) {
+        const serverBasemaps = _get(updatedUser, 'settings.customBasemaps')
+        _each(settingsFormData.customBasemaps, basemap => {
+          if (!basemap.id && !_isEmpty(basemap.url) && !_isEmpty(basemap.name) &&
+              _find(serverBasemaps, m => m.name === basemap.name)) {
+            basemap.id = _find(serverBasemaps, m => m.name === basemap.name).id
+          }
+        })
+      }
+
+       // Save the customBasemaps frmo the server in state so we we can match
+       // the newly generated
+      this.setState({isSaving: false,
+        saveComplete: true,
+        settingsFormData: settingsFormData})
+    })
   }, 750)
 
   /** Save the latest notification settings modified by the user */
@@ -66,10 +119,35 @@ class UserSettings extends Component {
     this.saveUserSettings(formData)
   }
 
+  areBasemapNamesUnique = (basemaps) => {
+    return _uniq(_map(basemaps, 'name')).length === basemaps.length
+  }
+
+  validate = (formData, errors) => {
+    // Validates that all custom basemap names are unique.
+    const basemapNames = _countBy(formData.customBasemaps, bm => bm.name)
+    _each(basemapNames, (count, name) => {
+      if (count > 1) {
+        const badIndex = _findLastIndex(formData.customBasemaps, bm => bm.name === name)
+        if (errors.customBasemaps[badIndex]) {
+          errors.customBasemaps[badIndex].addError(
+            this.props.intl.formatMessage(messages.uniqueCustomBasemapError)
+          )
+        }
+      }
+    })
+
+    return errors
+  }
+
   notificationsChangeHandler = (userSettings, {formData}) => {
     // The user's email address comes in from the notifications data even
     // though its technically a user setting
-    this.settingsChangeHandler({formData: _merge(userSettings, _pick(formData, 'email'))})
+    const toUpdateSettings = _merge({}, userSettings, _pick(formData, 'email'))
+    if (this.state.settingsFormData.customBasemaps) {
+      toUpdateSettings.customBasemaps = this.state.settingsFormData.customBasemaps
+    }
+    this.settingsChangeHandler({formData: toUpdateSettings})
 
     this.setState({
       notificationsFormData: formData,
@@ -130,7 +208,7 @@ class UserSettings extends Component {
         />
     }
 
-    const userSettings = _merge(this.props.user.settings, this.state.settingsFormData)
+    const userSettings = _merge({}, this.props.user.settings, this.state.settingsFormData)
 
     // The server uses two fields to represent the default basemap: a legacy
     // numeric identifier and a new optional string identifier for layers from
@@ -154,6 +232,13 @@ class UserSettings extends Component {
       }
     }
 
+    // Make sure the userSettings picks up the customBasemaps changes.
+    // If a customBasemap is removed then then merge above will still include it
+    // in the list.
+    if (this.state.settingsFormData.customBasemaps) {
+      userSettings.customBasemaps = this.state.settingsFormData.customBasemaps
+    }
+
     const notificationSettings = _merge(
       this.prepareNotificationsDataForForm(userSettings, {
         notificationSubscriptions: this.props.user.notificationSubscriptions,
@@ -178,9 +263,11 @@ class UserSettings extends Component {
           liveValidate
           noHtml5Validate
           showErrorList={false}
+          validate={this.validate}
           formData={userSettings}
           onChange={this.settingsChangeHandler}
           ObjectFieldTemplate={NoFieldsetObjectFieldTemplate}
+          ArrayFieldTemplate={CustomArrayFieldTemplate}
         >
           <div className="form-controls" />
         </Form>
diff --git a/src/pages/Profile/UserSettings/UserSettingsSchema.js b/src/pages/Profile/UserSettings/UserSettingsSchema.js
index acaadbb8..49f25bfa 100644
--- a/src/pages/Profile/UserSettings/UserSettingsSchema.js
+++ b/src/pages/Profile/UserSettings/UserSettingsSchema.js
@@ -1,7 +1,6 @@
 import React from 'react'
 import _map from 'lodash/map'
 import _values from 'lodash/values'
-import _without from 'lodash/without'
 import _filter from 'lodash/filter'
 import AsManager from '../../../interactions/User/AsManager'
 import { Locale, localeLabels, defaultLocale }
@@ -30,16 +29,41 @@ export const jsSchema = (intl, user, editor) => {
   const localizedEditorLabels = editorLabels(intl)
   const localizedBasemapLabels = basemapLayerLabels(intl)
 
+  const customBasemaps =
+    _filter(_map(user.settings.customBasemaps, basemap =>
+      ({id: basemap.name, name: basemap.name, overlay: basemap.overlay})),
+      basemap => !basemap.overlay
+    )
+
   const defaultBasemapChoices = [
     { id: ChallengeBasemap.none.toString(), name: localizedBasemapLabels.none }
   ].concat(_map(_filter(LayerSources, source => !source.overlay),
-                source => ({id: source.id, name: source.name}))).concat([
-    { id: ChallengeBasemap.custom.toString(), name: localizedBasemapLabels.custom }
-  ])
+                source => ({id: source.id, name: source.name})))
 
   const schemaFields = {
     "$schema": "http://json-schema.org/draft-07/schema#",
     type: "object",
+    definitions: {
+      customBasemapRule: {
+        type: "object",
+        properties: {
+          name: {
+            title: intl.formatMessage(messages.customBasemapNameLabel),
+            type: "string"
+          },
+          url: {
+            title: intl.formatMessage(messages.customBasemapURLLabel),
+            type: "string"
+          },
+          overlay: {
+            title: intl.formatMessage(messages.customBasemapOverlayLabel),
+            type: "boolean",
+            default: false,
+          },
+        },
+        required: ["name", "url"],
+      },
+    },
     properties: {
       defaultEditor: {
         title: intl.formatMessage(messages.defaultEditorLabel),
@@ -58,10 +82,15 @@ export const jsSchema = (intl, user, editor) => {
       defaultBasemap: {
         title: intl.formatMessage(messages.defaultBasemapLabel),
         type: "string",
-        enum: _map(defaultBasemapChoices, 'id'),
-        enumNames: _map(defaultBasemapChoices, 'name'),
+        enum: _map(defaultBasemapChoices.concat(customBasemaps), 'id'),
+        enumNames: _map(defaultBasemapChoices.concat(customBasemaps), 'name'),
         default: ChallengeBasemap.none.toString(),
       },
+      customBasemaps: {
+        title: intl.formatMessage(messages.customBasemapsLabel),
+        type: "array",
+        items: { "$ref": "#/definitions/customBasemapRule" }
+      },
       leaderboardOptOut: {
         title: intl.formatMessage(messages.leaderboardOptOutLabel),
         type: "boolean",
@@ -78,31 +107,6 @@ export const jsSchema = (intl, user, editor) => {
         default: false,
       },
     },
-    dependencies: { // Only show customBasemap if defaultBasemap set to Custom
-      defaultBasemap: {
-        oneOf: [
-          {
-            properties: {
-              defaultBasemap: {
-                enum: _without(_map(defaultBasemapChoices, 'id'), ChallengeBasemap.custom.toString()),
-              }
-            }
-          },
-          {
-            properties: {
-              defaultBasemap: {
-                enum: [ChallengeBasemap.custom.toString()],
-              },
-              customBasemap: {
-                title: intl.formatMessage(messages.customBasemapLabel),
-                type: "string",
-              },
-            },
-            required: ['customBasemap']
-          }
-        ]
-      }
-    }
   }
 
   // Show 'needsReview' option if value is not REVIEW_MANDATORY or to superusers
@@ -149,9 +153,15 @@ export const uiSchema = (intl, user, editor) => {
       "ui:widget": "select",
       "ui:help": intl.formatMessage(messages.defaultBasemapDescription),
     },
-    customBasemap: {
+    customBasemaps: {
+      "ui:addLabel": intl.formatMessage(messages.addCustomBasemapLabel),
+      "ui:deleteLabel": intl.formatMessage(messages.deleteCustomBasemapLabel),
       "ui:emptyValue": "",
       "ui:help": <MarkdownContent markdown={intl.formatMessage(messages.customBasemapDescription)} />,
+      items: {
+        "ui:options": { inline: false },
+        "classNames": ["custom-basemaps-item"]
+      },
     },
     locale: {
       "ui:widget": "select",
@@ -174,7 +184,8 @@ export const uiSchema = (intl, user, editor) => {
       "ui:help": intl.formatMessage(messages.allowFollowingDescription),
     },
     "ui:order": [
-      "defaultEditor", "locale", "defaultBasemap", "leaderboardOptOut", "customBasemap", "isReviewer","allowFollowing"
+      "locale", "allowFollowing", "defaultEditor", "leaderboardOptOut",
+      "defaultBasemap", "isReviewer", "customBasemaps"
     ],
   }
 
diff --git a/src/services/Error/Messages.js b/src/services/Error/Messages.js
index dfbb2fad..4fa5ef3f 100644
--- a/src/services/Error/Messages.js
+++ b/src/services/Error/Messages.js
@@ -80,7 +80,7 @@ export default defineMessages({
   },
   taskBundleFailure: {
     id: 'Errors.task.bundleFailure',
-    defaultMessage: "Unable to bundle tasks together",
+    defaultMessage: "Unable to bundling tasks together",
   },
   taskCooperativeFailure: {
     id: 'Errors.task.cooperativeFailure',
diff --git a/src/services/Search/Search.js b/src/services/Search/Search.js
index 3fd81f90..6633b4c0 100644
--- a/src/services/Search/Search.js
+++ b/src/services/Search/Search.js
@@ -76,8 +76,7 @@ export const PARAMS_MAP = {
   reviewStatus: 'trStatus',
   id: 'tid',
   difficulty: 'cd',
-  tags: 'tt',
-  excludeTasks: 'tExcl',
+  tags: 'tt'
 }
 
 
@@ -133,7 +132,7 @@ export const parseQueryString = function(rawQueryText) {
  */
 export const generateSearchParametersString = (filters, boundingBox, savedChallengesOnly,
                                                excludeOtherReviewers, queryString,
-                                               invertFields = {}, excludeTasks) => {
+                                               invertFields = {}) => {
   const searchParameters = {}
   const invf = []
 
@@ -287,12 +286,7 @@ export const generateSearchParametersString = (filters, boundingBox, savedChalle
     searchParameters[PARAMS_MAP.tags] = filters.tags
   }
 
-  if (excludeTasks && excludeTasks.length > 0) {
-    searchParameters[PARAMS_MAP.excludeTasks] = excludeTasks.join(',')
-  }
-
   searchParameters.invf = invf.join(',')
-
   return searchParameters
 }
 
diff --git a/src/services/Task/Task.js b/src/services/Task/Task.js
index 06b48310..928090d2 100644
--- a/src/services/Task/Task.js
+++ b/src/services/Task/Task.js
@@ -324,7 +324,7 @@ export const bulkUpdateTasks = function(updatedTasks, skipConversion=false) {
 /**
  * Bulk update task status on tasks that match the given criteria.
  */
-export const bulkTaskStatusChange = function(newStatus, challengeId, criteria, excludeTaskIds) {
+export const bulkTaskStatusChange = function(newStatus, challengeId, criteria) {
   return function(dispatch) {
     const filters = _get(criteria, 'filters', {})
     const searchParameters = generateSearchParametersString(filters,
@@ -332,8 +332,7 @@ export const bulkTaskStatusChange = function(newStatus, challengeId, criteria, e
                                                             _get(criteria, 'savedChallengesOnly'),
                                                             null,
                                                             criteria.searchQuery,
-                                                            _get(criteria, 'invertFields'),
-                                                            excludeTaskIds)
+                                                            _get(criteria, 'invertFields'))
     searchParameters.cid = challengeId
 
     return new Endpoint(
diff --git a/src/services/Task/TaskReview/TaskReview.js b/src/services/Task/TaskReview/TaskReview.js
index 2013e113..07d33d90 100644
--- a/src/services/Task/TaskReview/TaskReview.js
+++ b/src/services/Task/TaskReview/TaskReview.js
@@ -356,7 +356,7 @@ export const cancelReviewClaim = function(taskId) {
   }
 }
 
-export const removeReviewRequest = function(challengeId, taskIds, criteria, excludeTaskIds) {
+export const removeReviewRequest = function(challengeId, taskIds, criteria = null) {
   return function(dispatch) {
     const filters = _get(criteria, 'filters', {})
     const searchParameters = !criteria ? {} :
@@ -365,12 +365,9 @@ export const removeReviewRequest = function(challengeId, taskIds, criteria, excl
                                      null,
                                      null,
                                      criteria.searchQuery,
-                                     criteria.invertFields,
-                                     excludeTaskIds)
+                                     criteria.invertFields)
     searchParameters.cid = challengeId
-    if (taskIds) {
-      searchParameters.ids = taskIds.join(',')
-    }
+    searchParameters.ids = taskIds ? taskIds.join(',') : null
 
     return new Endpoint(
       api.tasks.removeReviewRequest, {
diff --git a/src/services/User/User.js b/src/services/User/User.js
index 88ab53d0..6bf80c63 100644
--- a/src/services/User/User.js
+++ b/src/services/User/User.js
@@ -798,6 +798,11 @@ const reduceUsersFurther = function(mergedState, oldState, userEntities) {
       mergedState[entity.id].properties = entity.properties
     }
 
+    // Always completely replace customBasemaps
+    if (_isArray(_get(entity, 'settings.customBasemaps'))) {
+      mergedState[entity.id].settings.customBasemaps = entity.settings.customBasemaps
+    }
+
     // Normalize server's default `en` locale to `en-US`
     if (_get(entity, 'settings.locale') === 'en') {
       mergedState[entity.id].settings.locale = Locale.enUS
diff --git a/src/services/VisibleLayer/LayerSources.js b/src/services/VisibleLayer/LayerSources.js
index f2b6be07..8712973f 100644
--- a/src/services/VisibleLayer/LayerSources.js
+++ b/src/services/VisibleLayer/LayerSources.js
@@ -1,10 +1,10 @@
 import PropTypes from 'prop-types'
 import QueryString from 'query-string'
-import _isEmpty from 'lodash/isEmpty'
 import _find from 'lodash/find'
 import _get from 'lodash/get'
 import _sortBy from 'lodash/sortBy'
 import _map from 'lodash/map'
+import _isFinite from 'lodash/isFinite'
 import { ChallengeBasemap, basemapLayerSources }
        from '../Challenge/ChallengeBasemap/ChallengeBasemap'
 import defaultLayers from '../../defaultLayers.json'
@@ -127,24 +127,55 @@ export const defaultLayerSource = function() {
   return configuredDefault ? configuredDefault : LayerSources[0]
 }
 
+/**
+ * Builds layers with the given function.
+ */
+export const buildLayerSources = function(visibleLayers, userCustomSources, buildLayer) {
+  return _map(visibleLayers, (layerId, index) => {
+    const layerSource = layerSourceWithId(layerId, userCustomSources)
+    if (layerSource) {
+      return buildLayer(layerId, index, layerSource)
+    }
+    else {
+      return null
+    }
+  })
+}
+
 /**
  * Retrieves the (static) layer source with a matching id. Dynamic layer
  * sources are not searched.
  */
-export const layerSourceWithId = function(id) {
-  return _find(LayerSources, {id})
+export const layerSourceWithId = function(id, userCustomSources) {
+  if (_find(LayerSources, {id})) {
+    return _find(LayerSources, {id})
+  }
+
+  const customSource = _find(userCustomSources, source => source.name === id)
+  if (customSource) {
+    return {
+      id: customSource.name,
+      name: customSource.name,
+      url: customSource.url,
+      overlay: customSource.overlay,
+      type: "tms"
+    }
+  }
+
+  return undefined
 }
 
 /**
  * Create and return a dynamic layer source with the given layerId
  * and url. Primarily intended for use with custom basemaps.
  */
-export const createDynamicLayerSource = function(layerId, url) {
+export const createDynamicLayerSource = function(layerId, layerName, url, overlay = false) {
   return {
     id: layerId,
-    name: 'Custom Imagery',
+    name: layerName,
     url,
     isDynamic: true,
+    overlay
   }
 }
 
@@ -153,19 +184,22 @@ export const createDynamicLayerSource = function(layerId, url) {
  * customBasemap settings, including generating a dynamic layer source with the
  * given customBasemap and customLayerId if appropriate.
  */
-export const basemapLayerSource = function(defaultBasemap, defaultBasemapId, customBasemap, customLayerId) {
+export const basemapLayerSource = function(defaultBasemap, defaultBasemapId) {
   if (defaultBasemap === ChallengeBasemap.none) {
     return null
   }
   else if (defaultBasemap === ChallengeBasemap.identified) {
     return layerSourceWithId(defaultBasemapId)
   }
-  else if (defaultBasemap !== ChallengeBasemap.custom) {
+  else if (_isFinite(defaultBasemap)) {
     return layerSourceWithId(basemapLayerSources()[defaultBasemap])
   }
-  else if (!_isEmpty(customBasemap)) {
-    return createDynamicLayerSource(customLayerId, customBasemap)
+  else if (defaultBasemap && defaultBasemap.url) {
+    return createDynamicLayerSource(
+      defaultBasemap.name, defaultBasemap.name, defaultBasemap.url,
+      defaultBasemap.overlay)
+  }
+  else {
+    return null
   }
-
-  return null
 }
diff --git a/src/styles/components/forms.css b/src/styles/components/forms.css
index 97e754fa..beaca7ec 100644
--- a/src/styles/components/forms.css
+++ b/src/styles/components/forms.css
@@ -45,6 +45,20 @@
       }
     }
   }
+
+  .custom-basemaps-item {
+    .fieldset {
+      display: block;
+
+      .field {
+        padding-bottom: 10px;
+      }
+
+      .checkbox {
+        padding-top: 10px;
+      }
+    }
+  }
 }
 
 .control-label, .form-group.field-boolean label span, .form-group.field-number label span {
diff --git a/src/tailwind.config.js b/src/tailwind.config.js
index 4afd2f73..128d22ae 100644
--- a/src/tailwind.config.js
+++ b/src/tailwind.config.js
@@ -406,7 +406,6 @@ module.exports = {
       '8': '2rem',
       '10': '2.5rem',
       '12': '3rem',
-      '12.8': '3.2rem',
       '14': '3.5rem',
       '16': '4rem',
       '20': '5rem',


Test patch:
diff --git a/src/services/VisibleLayer/LayerSources.test.js b/src/services/VisibleLayer/LayerSources.test.js
index 2d18e738..03f1ac76 100644
--- a/src/services/VisibleLayer/LayerSources.test.js
+++ b/src/services/VisibleLayer/LayerSources.test.js
@@ -11,10 +11,12 @@ import { BING,
 
 let layerId = null
 let layerUrl = null
+let layerName = null
 
 beforeEach(() => {
   layerId = "fooLayer"
   layerUrl = "http://www.example.com/foo"
+  layerName = "fooLayerName"
 })
 
 describe("layerSourceWithId", () => {
@@ -35,9 +37,10 @@ describe("layerSourceWithId", () => {
 
 describe("createDynamicLayerSource", () => {
   test("Generates layer source with the given id and url", () => {
-    const layer = createDynamicLayerSource(layerId, layerUrl)
+    const layer = createDynamicLayerSource(layerId, layerName, layerUrl)
 
     expect(layer.id).toEqual(layerId)
+    expect(layer.name).toEqual(layerName)
     expect(layer.url).toEqual(layerUrl)
     expect(layer.isDynamic).toBe(true)
   })
@@ -45,22 +48,22 @@ describe("createDynamicLayerSource", () => {
 
 describe("basemapLayerSource", () => {
   test("Returns a constant layer if defaultBasemap setting matches", () => {
-    const layer = basemapLayerSource(ChallengeBasemap.openStreetMap, null, null, layerId)
+    const layer = basemapLayerSource(ChallengeBasemap.openStreetMap, layerId)
     expect(layer.id).toEqual(OPEN_STREET_MAP)
   })
 
   test("Returns an identified layer if defaultBasemap setting matches", () => {
-    const layer = basemapLayerSource(ChallengeBasemap.identified, 'osm-mapnik-black_and_white', null, layerId)
+    const layer = basemapLayerSource(ChallengeBasemap.identified, 'osm-mapnik-black_and_white')
     expect(layer.id).toEqual('osm-mapnik-black_and_white')
   })
 
   test("Returns null if basemap set to none", () => {
-    const layer = basemapLayerSource(ChallengeBasemap.none, null, null, layerId)
+    const layer = basemapLayerSource(ChallengeBasemap.none, layerId)
     expect(layer).toBeNull()
   })
 
   test("Returns custom layer if set to custom and url provided", () => {
-    const layer = basemapLayerSource(ChallengeBasemap.custom, null, layerUrl, layerId)
+    const layer = basemapLayerSource({id: layerId, name: layerId, url: layerUrl}, layerId)
 
     expect(layer.id).toEqual(layerId)
     expect(layer.url).toEqual(layerUrl)
@@ -68,7 +71,7 @@ describe("basemapLayerSource", () => {
   })
 
   test("Returns null for custom layer if no url provided", () => {
-    const layer = basemapLayerSource(ChallengeBasemap.custom, null, null, layerId)
+    const layer = basemapLayerSource({id: layerId, name: layerId}, layerId)
 
     expect(layer).toBeNull()
   })