Repository: maproulette/maproulette3
Pull request #1985 (2023-03-14 18:53:38)

Added keyboard shortcut for completeTogetherbutton
Issue
Keyboard shortcut to make users select multiple tasks. Same functionality as the complete together button.

Approach / Solution
Created a new shortcut where users can bundle tasks together and complete them together with the 'c' key.

Resolves:
https://github.com/maproulette/maproulette3/issues/1793

Possible Requests
Currently the shortcut utilizes the 'c' key. If preferred I can change it to another key instead.

codecov-commenter: ## [Codecov](https://codecov.io/gh/maproulette/maproulette3/pull/1985?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=maproulette) Report
> Merging [#1985](https://codecov.io/gh/maproulette/maproulette3/pull/1985?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=maproulette) (75b3d43) into [develop](https://codecov.io/gh/maproulette/maproulette3/commit/3c64dea0cf27f7041cbb542fffad781944aa3e80?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=maproulette) (3c64dea) will **increase** coverage by `0.18%`.
> The diff coverage is `9.09%`.

:mega: This organization is not using Codecov’s [GitHub App Integration](https://github.com/apps/codecov). We recommend you install it so Codecov can continue to function properly for your repositories. [Learn more](https://about.codecov.io/blog/codecov-is-updating-its-github-integration/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=maproulette)

```diff
@@             Coverage Diff             @@
##           develop    #1985      +/-   ##
===========================================
+ Coverage    24.76%   24.95%   +0.18%     
===========================================
  Files          624      625       +1     
  Lines        20963    21450     +487     
  Branches      6357     6526     +169     
===========================================
+ Hits          5192     5353     +161     
- Misses       13236    13476     +240     
- Partials      2535     2621      +86     
```


| [Impacted Files](https://codecov.io/gh/maproulette/maproulette3/pull/1985?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=maproulette) | Coverage Δ | |
|---|---|---|
| [...nents/Widgets/TaskBundleWidget/TaskBundleWidget.js](https://codecov.io/gh/maproulette/maproulette3/pull/1985?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=maproulette#diff-c3JjL2NvbXBvbmVudHMvV2lkZ2V0cy9UYXNrQnVuZGxlV2lkZ2V0L1Rhc2tCdW5kbGVXaWRnZXQuanM=) | `9.00% <9.09%> (+0.01%)` | :arrow_up: |

... and [8 files with indirect coverage changes](https://codecov.io/gh/maproulette/maproulette3/pull/1985/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=maproulette)

:mega: We’re building smart automated test selection to slash your CI/CD build times. [Learn more](https://about.codecov.io/iterative-testing/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=maproulette)

MattEspoz: Just realized I connected it to the wrong ticket. This fix is for:

https://github.com/maproulette/maproulette3/issues/1793

Patch:
diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md
index 04cf2f3e..3c41f7fa 100644
--- a/DEVELOPMENT.md
+++ b/DEVELOPMENT.md
@@ -9,6 +9,8 @@ This is the repository that holds the front-end code. The back-end and API are d
 * [jq](https://stedolan.github.io/jq/)
 * [curl](https://curl.haxx.se/)
 
+Note: When installing Node LTS,  ensure that the version of node installed is <= 17.0.1 & conversely, the version of npm is <= 8.19.3. Otherwise, with higher versions of node, yarn would not be able to recognize Node and run the frontend portion of the codebase.
+
 ### Initial Setup
 
 1. Create a `.env.development.local` file and then look through `.env` at the
@@ -44,7 +46,7 @@ Note that this will create the development build and not the 'production' build.
 1. Install the back-end server using the instructions from the maproulette-backend
    project, if you haven't already
 
-2. Visit your [OpenStreetMap account](https://www.openstreetmap.org) and go
+2. Visit your [OpenStreetMap account](https://master.apis.dev.openstreetmap.org) and go
    to My Settings -> oauth settings -> Register your application and setup a
    new application for development. For the `Main Application URL` and
    `Callback URL` settings, put in `http://127.0.0.1:9000` (assuming your
diff --git a/src/components/AdminPane/Manage/ChallengeCard/ChallengeControls.js b/src/components/AdminPane/Manage/ChallengeCard/ChallengeControls.js
index 82335f9a..4841a49b 100644
--- a/src/components/AdminPane/Manage/ChallengeCard/ChallengeControls.js
+++ b/src/components/AdminPane/Manage/ChallengeCard/ChallengeControls.js
@@ -156,7 +156,10 @@ export default class ChallengeControls extends Component {
                 pathname:
                   `/admin/project/${projectId}/` +
                   `challenge/${this.props.challenge.id}/edit`,
-                state: _get(this.props.searchCriteria, "filters"),
+                state: _merge(
+                  { cloneChallenge: false },
+                  _get(this.props.searchCriteria, "filters"),
+                )
               }}
               className={this.props.controlClassName}
             >
diff --git a/src/components/AdminPane/Manage/ManageChallenges/EditChallenge/EditChallenge.js b/src/components/AdminPane/Manage/ManageChallenges/EditChallenge/EditChallenge.js
index 068e29eb..91c62484 100644
--- a/src/components/AdminPane/Manage/ManageChallenges/EditChallenge/EditChallenge.js
+++ b/src/components/AdminPane/Manage/ManageChallenges/EditChallenge/EditChallenge.js
@@ -106,8 +106,17 @@ export class EditChallenge extends Component {
    * Returns true if this challenge's data is being cloned from another
    * challenge.
    */
-  isCloningChallenge = () =>
-    !!_get(this.props, "location.state.cloneChallenge");
+  isCloningChallenge = () => {
+    return !!_get(this.props, "location.state.cloneChallenge");
+  }
+
+  /**
+   * Returns the project Id of which the challenge is being cloned into. Project Id
+   * is passed in from challengeDetail.js
+   */
+  getCloningProjectId = () => {
+    return _get(this.props, "location.state.projectId");
+  }
 
   /**
    * Returns true if all challenge fields should be displayed as a single,
@@ -314,6 +323,7 @@ export class EditChallenge extends Component {
           const nextState = _cloneDeep(this.challengeState);
           if (nextState) {
             nextState.refreshAfterSave = true;
+            nextState.cloneChallenge = false;
           }
 
           this.props.history.push({
@@ -380,8 +390,15 @@ export class EditChallenge extends Component {
         delete challengeData.dataOriginDate;
       }
 
-      if (_isEmpty(this.state.formData.overpassQL)) {
-        delete challengeData.overpassQL;
+      if (_isEmpty(this.state.formData.overpassQL) && !_isEmpty(challengeData.overpassQL)) {
+        challengeData.overpassQL =
+          `/*\nTHIS IS THE QUERY OF THE CHALLENGE YOU CLONED PLEASE ADAPT BEFORE USING TO AVOID CREATING DUPLICATE TASKS\n\n` +
+          challengeData.overpassQL +
+          `\n*/`
+      }
+
+      if (!_isEmpty(challengeData.overpassQL) && challengeData.overpassTargetType === '') {
+	      challengeData.overpassTargetType = 'none';
       }
 
       if (_isEmpty(this.state.formData.remoteGeoJson)) {
@@ -518,7 +535,8 @@ export class EditChallenge extends Component {
 
     // Parent field should just be id, not object.
     if (_isObject(challengeData.parent)) {
-      challengeData.parent = challengeData.parent.id;
+      const cloningProjectId = this.getCloningProjectId();
+      challengeData.parent = cloningProjectId ? cloningProjectId : challengeData.parent.id;
     }
 
     // For new challenges, append the #maproulette hashtag to the changeset comment
diff --git a/src/components/ChallengeDetail/ChallengeDetail.js b/src/components/ChallengeDetail/ChallengeDetail.js
index 65392970..8952496d 100644
--- a/src/components/ChallengeDetail/ChallengeDetail.js
+++ b/src/components/ChallengeDetail/ChallengeDetail.js
@@ -5,6 +5,7 @@ import classNames from "classnames";
 import _isObject from "lodash/isObject";
 import _get from "lodash/get";
 import _findIndex from "lodash/findIndex";
+import _merge from "lodash/merge";
 import parse from "date-fns/parse";
 import MapPane from "../EnhancedMap/MapPane/MapPane";
 import TaskClusterMap from "../TaskClusterMap/TaskClusterMap";
@@ -21,17 +22,22 @@ import WithStartChallenge from "../HOCs/WithStartChallenge/WithStartChallenge";
 import WithBrowsedChallenge from "../HOCs/WithBrowsedChallenge/WithBrowsedChallenge";
 import WithClusteredTasks from "../HOCs/WithClusteredTasks/WithClusteredTasks";
 import WithCurrentUser from "../HOCs/WithCurrentUser/WithCurrentUser";
+import WithCurrentChallenge from "../AdminPane/HOCs/WithCurrentChallenge/WithCurrentChallenge";
 import WithChallengeTaskClusters from "../HOCs/WithChallengeTaskClusters/WithChallengeTaskClusters";
 import WithTaskClusterMarkers from "../HOCs/WithTaskClusterMarkers/WithTaskClusterMarkers";
+import WithManageableProjects from "../AdminPane/HOCs/WithManageableProjects/WithManageableProjects";
 import { fromLatLngBounds } from "../../services/MapBounds/MapBounds";
 import { ChallengeCommentsPane } from "./ChallengeCommentsPane";
 import SvgSymbol from "../SvgSymbol/SvgSymbol";
 import FlagModal from "./FlagModal";
+import ProjectPickerModal from "../AdminPane/Manage/ProjectPickerModal/ProjectPickerModal";
 
 const ClusterMap = WithChallengeTaskClusters(
   WithTaskClusterMarkers(TaskClusterMap("challengeDetail"))
 );
 
+const ProjectPicker = WithManageableProjects(ProjectPickerModal);
+
 const FLAG_REPO_NAME = process.env.REACT_APP_GITHUB_ISSUES_API_REPO
 const FLAG_REPO_OWNER = process.env.REACT_APP_GITHUB_ISSUES_API_OWNER
 const FLAG_TOKEN = process.env.REACT_APP_GITHUB_ISSUES_API_TOKEN
@@ -58,6 +64,7 @@ export class ChallengeDetail extends Component {
     displayInputError: false,
     displayCheckboxError: false,
     submittingFlag: false,
+    pickingProject: false,
   };
 
   componentDidMount() {
@@ -131,6 +138,23 @@ export class ChallengeDetail extends Component {
     }
   }
 
+  projectPickerCanceled = () => {
+    this.setState({ pickingProject: false });
+  }
+
+  cloneToProject = (project) => {
+    this.setState({ pickingProject: false })
+    this.props.history.push({
+      pathname:
+        `/admin/project/${project.id}/` +
+        `challenge/${this.props.challenge.id}/clone`,
+      state: _merge(
+        { cloneChallenge: true, projectId: project.id },
+        _get(this.props.searchCriteria, 'filters')
+      ),
+    })
+  }
+
   renderDetailTabs = () => {
     const challenge = this.props.browsedChallenge;
     if (!challenge.isVirtual) {
@@ -168,6 +192,20 @@ export class ChallengeDetail extends Component {
               </Fragment>
             )
           }
+          {
+            _isObject(this.props.user) && challenge.enabled && (
+              <Fragment>
+                <span className="mr-px-3"> | </span>
+                <button
+                  onClick={() => this.setState({ pickingProject: true })}
+                  className="mr-text-green-lighter hover:mr-text-white"
+                >
+                  <FormattedMessage {...messages.cloneChallenge} />
+                </button>
+              </Fragment>
+            )
+          }
+          
         </li>
       )
     }
@@ -416,6 +454,15 @@ export class ChallengeDetail extends Component {
                   />
                 }
 
+                {_isObject(this.props.user) && challenge.enabled && this.state.pickingProject && (
+                  <ProjectPicker
+                    {...this.props}
+                    currentProjectId={challenge.parent.id}
+                    onCancel={this.projectPickerCanceled}
+                    onSelectProject={this.cloneToProject}
+                  />
+                )}
+
                 {challenge.parent && ( // virtual challenges don't have projects
                   <Link
                     className="mr-card-challenge__owner"
@@ -478,6 +525,8 @@ export class ChallengeDetail extends Component {
 
 export default WithCurrentUser(
   WithClusteredTasks(
-    WithStartChallenge(WithBrowsedChallenge(injectIntl(ChallengeDetail)))
+    WithStartChallenge(
+      WithBrowsedChallenge(WithCurrentChallenge(injectIntl(ChallengeDetail)))
+    )
   )
-);
+)
diff --git a/src/components/ChallengeDetail/Messages.js b/src/components/ChallengeDetail/Messages.js
index 074a5860..f2ce75df 100644
--- a/src/components/ChallengeDetail/Messages.js
+++ b/src/components/ChallengeDetail/Messages.js
@@ -127,5 +127,10 @@ export default defineMessages({
   email: {
     id: "ChallengeDetails.controls.email",
     defaultMessage: "Email"
+  },
+
+  cloneChallenge: {
+    id: "ChallengeDetails.controls.clone.challenge",
+    defaultMessage: "Clone Challenge"
   }
 })
diff --git a/src/components/HOCs/WithSearch/WithSearch.js b/src/components/HOCs/WithSearch/WithSearch.js
index bb3cf551..415b23c3 100644
--- a/src/components/HOCs/WithSearch/WithSearch.js
+++ b/src/components/HOCs/WithSearch/WithSearch.js
@@ -144,6 +144,10 @@ export const mapDispatchToProps = (dispatch, ownProps, searchGroup) => ({
     }
   },
 
+  clearSearchDispatch: (searchName) => {
+    dispatch(clearSearch(searchName))
+  },
+
   setSearchSort: (sortCriteria) => {
     const sortBy = _get(sortCriteria, 'sortBy')
     let sort = null
diff --git a/src/components/HOCs/WithSearchRoute/WithSearchRoute.js b/src/components/HOCs/WithSearchRoute/WithSearchRoute.js
index c84e9af3..5e68b31a 100644
--- a/src/components/HOCs/WithSearchRoute/WithSearchRoute.js
+++ b/src/components/HOCs/WithSearchRoute/WithSearchRoute.js
@@ -52,7 +52,7 @@ export const WithSearchRoute = function(WrappedComponent, searchGroup) {
             // Clear any redux values first before setting the route criteria
             const routeValues = this.props.history.location.search
             this.props.clearSearchFilters(false)
-            this.props.clearSearch(searchGroup)
+            this.props.clearSearchDispatch(searchGroup)
             this.props.clearMapBounds(searchGroup)
 
             executeRouteSearch(this.routeCriteria, routeValues)
diff --git a/src/components/Navbar/Messages.js b/src/components/Navbar/Messages.js
index a9e85101..cacd2632 100644
--- a/src/components/Navbar/Messages.js
+++ b/src/components/Navbar/Messages.js
@@ -4,6 +4,10 @@ import { defineMessages } from 'react-intl'
  * Internationalized messages for use with Navbar.
  */
 export default defineMessages({
+  blog: {
+    id: 'Navbar.links.blog',
+    defaultMessage: "Blog",
+  },
   dashboard: {
     id: 'Navbar.links.dashboard',
     defaultMessage: "Dashboard",
diff --git a/src/components/Navbar/Navbar.js b/src/components/Navbar/Navbar.js
index 2a6a83b1..c6caf77d 100644
--- a/src/components/Navbar/Navbar.js
+++ b/src/components/Navbar/Navbar.js
@@ -323,6 +323,16 @@ const Nav = props => (
         <FormattedMessage {...messages.help} />
       </a>
     </li>
+
+    <li className="mr-flex mr-flex-col mr-justify-center">
+      <a
+        href="https://blog.maproulette.org/"
+        target="_blank"
+        rel="noopener noreferrer"
+        onClick={props.closeMobileMenu}>
+        <FormattedMessage {...messages.blog} />
+      </a>
+    </li>
   </React.Fragment>
 )
 
diff --git a/src/components/Widgets/TaskBundleWidget/TaskBundleWidget.js b/src/components/Widgets/TaskBundleWidget/TaskBundleWidget.js
index 9d767089..08df42d2 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 _isEmpty from 'lodash/isEmpty'
 import _sum from 'lodash/sum'
 import _values from 'lodash/values'
 import _pick from 'lodash/pick'
@@ -41,6 +42,7 @@ import BusySpinner from '../../BusySpinner/BusySpinner'
 import TaskAnalysisTable from '../../TaskAnalysisTable/TaskAnalysisTable'
 import TaskMarkerContent from './TaskMarkerContent'
 import messages from './Messages'
+import WithKeyboardShortcuts from '../../HOCs/WithKeyboardShortcuts/WithKeyboardShortcuts'
 
 const VALID_STATUS_KEYS = [TaskAction.available, TaskAction.skipped, TaskAction.tooHard]
 const VALID_STATUSES =
@@ -68,6 +70,7 @@ const ClusterMap = WithChallengeTaskClusters(
   true
 )
 
+const shortcutGroup = 'taskEditing'
 
 export default class TaskBundleWidget extends Component {
   bundleTasks = () => {
@@ -77,6 +80,22 @@ export default class TaskBundleWidget extends Component {
     this.props.createTaskBundle([...this.props.selectedTasks.selected.keys()])
   }
 
+  handleKeyboardShortcuts = (event) => {
+    // Ignore if shortcut group is not active
+    if (_isEmpty(this.props.activeKeyboardShortcuts[shortcutGroup])) {
+      return
+    }
+
+    if (this.props.textInputActive(event)) { // ignore typing in inputs
+      return
+    }
+
+    const shortcuts = this.props.keyboardShortcutGroups.taskEditing
+    if (event.key === shortcuts.completeTogether.key) {
+      this.bundleTasks()
+    }
+  }
+
   /**
    * Initialize the cluster filters to include tasks from the current challenge
    * and initially within bounds of "nearby" tasks as a starting point for the
@@ -141,6 +160,11 @@ export default class TaskBundleWidget extends Component {
     if (this.props.task && this.props.selectedTasks && !this.props.isTaskSelected(this.props.task.id)) {
       this.props.selectTasks([this.props.task])
     }
+
+    this.props.activateKeyboardShortcut(
+      shortcutGroup,
+      _pick(this.props.keyboardShortcutGroups.taskEditing, 'c'),
+      this.handleKeyboardShortcuts)
   }
 
   componentDidUpdate(prevProps) {
@@ -164,6 +188,9 @@ export default class TaskBundleWidget extends Component {
     if (_isFinite(challengeId)) {
       this.props.unsubscribeFromChallengeTaskMessages(challengeId)
     }
+
+    this.props.deactivateKeyboardShortcut(shortcutGroup, 'c',
+                                          this.handleKeyboardShortcuts)
   }
 
   render() {
@@ -373,7 +400,7 @@ registerWidgetType(
               WithBoundedTasks(
                 WithBrowsedChallenge(
                   WithWebSocketSubscriptions(
-                    TaskBundleWidget,
+                    WithKeyboardShortcuts(TaskBundleWidget )
                   )
                 ),
                 'filteredClusteredTasks',
diff --git a/src/lang/af.json b/src/lang/af.json
index 4effa47b..ae24d2c7 100644
--- a/src/lang/af.json
+++ b/src/lang/af.json
@@ -1028,6 +1028,7 @@
   "Locale.de.label": "de (Deutsch)",
   "Locale.en-US.label": "en-US (U.S. English)",
   "Locale.es.label": "es (Español)",
+  "Locale.it.label": "it (Italiano)",
   "Locale.fa-IR.label": "fa-IR (Persian - Iran)",
   "Locale.fr.label": "fr (Français)",
   "Locale.ja.label": "ja (日本語)",
diff --git a/src/lang/cs_CZ.json b/src/lang/cs_CZ.json
index 53cdb22a..033010cd 100644
--- a/src/lang/cs_CZ.json
+++ b/src/lang/cs_CZ.json
@@ -1028,6 +1028,7 @@
   "Locale.de.label": "de (Deutsch)",
   "Locale.en-US.label": "en-US (U.S. English)",
   "Locale.es.label": "es (Español)",
+  "Locale.it.label": "it (Italiano)",
   "Locale.fa-IR.label": "fa-IR (Persian - Iran)",
   "Locale.fr.label": "fr (Français)",
   "Locale.ja.label": "ja (日本語)",
diff --git a/src/lang/de.json b/src/lang/de.json
index db7647c3..f9fa2bfe 100644
--- a/src/lang/de.json
+++ b/src/lang/de.json
@@ -1028,6 +1028,7 @@
   "Locale.de.label": "de (Deutsch)",
   "Locale.en-US.label": "en-US (U.S. English)",
   "Locale.es.label": "es (Español)",
+  "Locale.it.label": "it (Italiano)",
   "Locale.fa-IR.label": "fa-IR (Persisch - Iran)",
   "Locale.fr.label": "fr (Français)",
   "Locale.ja.label": "ja (日本語)",
diff --git a/src/lang/en-US.json b/src/lang/en-US.json
index 72d973b1..d2380b95 100644
--- a/src/lang/en-US.json
+++ b/src/lang/en-US.json
@@ -517,6 +517,7 @@
   "ChallengeCard.totalTasks": "Total Tasks",
   "ChallengeDetails.Task.fields.featured.label": "Featured",
   "ChallengeDetails.controls.checkbox.error": "Please ensure that checkbox is checked before continue",
+  "ChallengeDetails.controls.clone.challenge": "Clone Challenge",
   "ChallengeDetails.controls.email": "Email",
   "ChallengeDetails.controls.favorite.label": "Favorite",
   "ChallengeDetails.controls.favorite.tooltip": "Save to favorites",
@@ -1028,6 +1029,7 @@
   "Locale.de.label": "de (Deutsch)",
   "Locale.en-US.label": "en-US (U.S. English)",
   "Locale.es.label": "es (Español)",
+  "Locale.it.label": "it (Italiano)",
   "Locale.fa-IR.label": "fa-IR (Persian - Iran)",
   "Locale.fr.label": "fr (Français)",
   "Locale.ja.label": "ja (日本語)",
diff --git a/src/lang/es.json b/src/lang/es.json
index 30142778..867299bd 100644
--- a/src/lang/es.json
+++ b/src/lang/es.json
@@ -1028,6 +1028,7 @@
   "Locale.de.label": "de (Deutsch)",
   "Locale.en-US.label": "en-US (U.S. English)",
   "Locale.es.label": "es (Español)",
+  "Locale.it.label": "it (Italiano)",
   "Locale.fa-IR.label": "fa-IR (Persa - Irán)",
   "Locale.fr.label": "fr (Français)",
   "Locale.ja.label": "ja (日本語)",
diff --git a/src/lang/fa_IR.json b/src/lang/fa_IR.json
index ba6c78ab..71bdc145 100644
--- a/src/lang/fa_IR.json
+++ b/src/lang/fa_IR.json
@@ -1028,6 +1028,7 @@
   "Locale.de.label": "de (Deutsch)",
   "Locale.en-US.label": "en-US (U.S. English)",
   "Locale.es.label": "es (Español)",
+  "Locale.it.label": "it (Italiano)",
   "Locale.fa-IR.label": "fa-IR (Persian - Iran)",
   "Locale.fr.label": "fr (Français)",
   "Locale.ja.label": "ja (日本語)",
diff --git a/src/lang/fr.json b/src/lang/fr.json
index 8804ac75..67a29f38 100644
--- a/src/lang/fr.json
+++ b/src/lang/fr.json
@@ -1028,6 +1028,7 @@
   "Locale.de.label": "de (Deutsch)",
   "Locale.en-US.label": "en-US (U.S. English)",
   "Locale.es.label": "es (Español)",
+  "Locale.it.label": "it (Italiano)",
   "Locale.fa-IR.label": "fa-IR (Persan - Iran)",
   "Locale.fr.label": "fr (Français)",
   "Locale.ja.label": "ja (日本語)",
diff --git a/src/lang/it_IT.json b/src/lang/it_IT.json
index fb200dc8..f7a5d600 100644
--- a/src/lang/it_IT.json
+++ b/src/lang/it_IT.json
@@ -1028,6 +1028,7 @@
   "Locale.de.label": "de (Tedesco)",
   "Locale.en-US.label": "en-US (U.S. English)",
   "Locale.es.label": "es (Español)",
+  "Locale.it.label": "it (Italiano)",
   "Locale.fa-IR.label": "fa-IR (Persian - Iran)",
   "Locale.fr.label": "fr (Français)",
   "Locale.ja.label": "ja (日本語)",
diff --git a/src/lang/ja.json b/src/lang/ja.json
index 375d2cfc..eda19581 100644
--- a/src/lang/ja.json
+++ b/src/lang/ja.json
@@ -1028,6 +1028,7 @@
   "Locale.de.label": "de (Deutsch)",
   "Locale.en-US.label": "en-US (U.S. English)",
   "Locale.es.label": "es (Español)",
+  "Locale.it.label": "it (Italiano)",
   "Locale.fa-IR.label": "fa-IR (Persian - Iran)",
   "Locale.fr.label": "fr (Français)",
   "Locale.ja.label": "ja (日本語)",
diff --git a/src/lang/ko.json b/src/lang/ko.json
index 269181a3..e453d270 100644
--- a/src/lang/ko.json
+++ b/src/lang/ko.json
@@ -1028,6 +1028,7 @@
   "Locale.de.label": "de (Deutsch)",
   "Locale.en-US.label": "en-US (U.S. English)",
   "Locale.es.label": "es (Español)",
+  "Locale.it.label": "it (Italiano)",
   "Locale.fa-IR.label": "fa-IR (Persian - Iran)",
   "Locale.fr.label": "fr (Français)",
   "Locale.ja.label": "ja (日本語)",
diff --git a/src/lang/nl.json b/src/lang/nl.json
index 3bb38d68..0f72821f 100644
--- a/src/lang/nl.json
+++ b/src/lang/nl.json
@@ -1028,6 +1028,7 @@
   "Locale.de.label": "de (Duits)",
   "Locale.en-US.label": "en-US (U.S. Engels)",
   "Locale.es.label": "es (Spaans)",
+  "Locale.it.label": "it (Italiano)",
   "Locale.fa-IR.label": "fa-IR (Perzisch - Iran)",
   "Locale.fr.label": "fr (Frans)",
   "Locale.ja.label": "ja (日本語)",
diff --git a/src/lang/pl.json b/src/lang/pl.json
index 73cc720d..c724d0f7 100644
--- a/src/lang/pl.json
+++ b/src/lang/pl.json
@@ -1028,6 +1028,7 @@
   "Locale.de.label": "de (niemiecki)",
   "Locale.en-US.label": "en-US (angielski - US)",
   "Locale.es.label": "es (hiszpański)",
+  "Locale.it.label": "it (Italiano)",
   "Locale.fa-IR.label": "fa-IR (perski - Iran)",
   "Locale.fr.label": "fr (francuski)",
   "Locale.ja.label": "ja (japoński)",
diff --git a/src/lang/pt_BR.json b/src/lang/pt_BR.json
index 91fc1580..ae631b7d 100644
--- a/src/lang/pt_BR.json
+++ b/src/lang/pt_BR.json
@@ -1028,6 +1028,7 @@
   "Locale.de.label": "de (Deutsch)",
   "Locale.en-US.label": "en-US (U.S. English)",
   "Locale.es.label": "es (Español)",
+  "Locale.it.label": "it (Italiano)",
   "Locale.fa-IR.label": "fa-IR (Persian - Iran)",
   "Locale.fr.label": "fr (Français)",
   "Locale.ja.label": "ja (日本語)",
diff --git a/src/lang/ru_RU.json b/src/lang/ru_RU.json
index 6f47a092..3a8e3022 100644
--- a/src/lang/ru_RU.json
+++ b/src/lang/ru_RU.json
@@ -1028,6 +1028,7 @@
   "Locale.de.label": "нем (Немецкий)",
   "Locale.en-US.label": "en-US (U.S. English)",
   "Locale.es.label": "es (Испанский)",
+  "Locale.it.label": "it (Italiano)",
   "Locale.fa-IR.label": "fa-IR (Persian - Iran)",
   "Locale.fr.label": "fr (Французский)",
   "Locale.ja.label": "ja (Японский)",
diff --git a/src/lang/tr.json b/src/lang/tr.json
index aa8aa560..7eae6a18 100644
--- a/src/lang/tr.json
+++ b/src/lang/tr.json
@@ -1028,6 +1028,7 @@
   "Locale.de.label": "de (Almanca)",
   "Locale.en-US.label": "en-US (ABD İngilizcesi)",
   "Locale.es.label": "es (İspanyolca)",
+  "Locale.it.label": "it (Italiano)",
   "Locale.fa-IR.label": "fa-IR (Farsça - İran)",
   "Locale.fr.label": "fr (Fransızca)",
   "Locale.ja.label": "ja (Japonca)",
diff --git a/src/lang/uk.json b/src/lang/uk.json
index c474c285..013dafba 100644
--- a/src/lang/uk.json
+++ b/src/lang/uk.json
@@ -1028,6 +1028,7 @@
   "Locale.de.label": "de (Deutsch)",
   "Locale.en-US.label": "en-US (U.S. English)",
   "Locale.es.label": "es (Español)",
+  "Locale.it.label": "it (Italiano)",
   "Locale.fa-IR.label": "fa-IR (فارسی - ایران)",
   "Locale.fr.label": "fr (Français)",
   "Locale.ja.label": "ja (日本語)",
diff --git a/src/lang/vi.json b/src/lang/vi.json
index 31aa56b8..66ddfe36 100644
--- a/src/lang/vi.json
+++ b/src/lang/vi.json
@@ -1028,6 +1028,7 @@
   "Locale.de.label": "de (Deutsch)",
   "Locale.en-US.label": "en-US (U.S. English)",
   "Locale.es.label": "es (Español)",
+  "Locale.it.label": "it (Italiano)",
   "Locale.fa-IR.label": "fa-IR (Persian - Iran)",
   "Locale.fr.label": "fr (Français)",
   "Locale.ja.label": "ja (日本語)",
diff --git a/src/lang/zh_TW.json b/src/lang/zh_TW.json
index f3023aa2..86fd1f94 100644
--- a/src/lang/zh_TW.json
+++ b/src/lang/zh_TW.json
@@ -1028,6 +1028,7 @@
   "Locale.de.label": "de (Deutsch)",
   "Locale.en-US.label": "en-US (U.S. English)",
   "Locale.es.label": "es (Español)",
+  "Locale.it.label": "it (Italiano)",
   "Locale.fa-IR.label": "fa-IR (Persian - Iran)",
   "Locale.fr.label": "fr (Français)",
   "Locale.ja.label": "ja (日本語)",
diff --git a/src/services/KeyboardShortcuts/KeyMappings.js b/src/services/KeyboardShortcuts/KeyMappings.js
index a51fd68b..0b54c7b8 100644
--- a/src/services/KeyboardShortcuts/KeyMappings.js
+++ b/src/services/KeyboardShortcuts/KeyMappings.js
@@ -24,6 +24,7 @@ export default {
   taskEditing: {
     cancel: {key: 'Escape', label: messages.cancel, keyLabel: messages.escapeLabel},
     fitBounds: {key: '0', label: messages.fitBounds},
+    completeTogether: {key: 'c', label: messages.completeTogether}
   },
   layers: {
     layerOSMData: {key: 'o', label: messages.layerOSMData},
diff --git a/src/services/KeyboardShortcuts/Messages.js b/src/services/KeyboardShortcuts/Messages.js
index 381f1116..b6d3498d 100644
--- a/src/services/KeyboardShortcuts/Messages.js
+++ b/src/services/KeyboardShortcuts/Messages.js
@@ -59,6 +59,11 @@ export default defineMessages({
     defaultMessage: "Fit Map to Task Features",
   },
 
+  completeTogether: {
+    id: "KeyMapping.taskEditing.completeTogether",
+    defaultMessage: "Complete tasks together"
+  },
+
   escapeLabel: {
     id: "KeyMapping.taskEditing.escapeLabel",
     defaultMessage: "ESC",
diff --git a/src/services/User/Locale/Locale.js b/src/services/User/Locale/Locale.js
index ea9e58f0..b0919bf8 100644
--- a/src/services/User/Locale/Locale.js
+++ b/src/services/User/Locale/Locale.js
@@ -9,11 +9,12 @@ import messages from "./Messages";
 // `Messages.js` file in this directory
 
 // Supported locales
-export const Locale = Object.freeze({
+export const unsortedLocale = {
   enUS: "en-US",
   es: "es",
   fr: "fr",
   de: "de",
+  it: "it",
   af: "af",
   ja: "ja",
   ko: "ko",
@@ -27,7 +28,15 @@ export const Locale = Object.freeze({
   tr: "tr",
   pl: "pl",
   "zh-TW": "zh-TW"
-});
+};
+
+export const Locale = Object.freeze(Object.keys(unsortedLocale).sort().reduce(
+  (obj, key) => { 
+    obj[key] = unsortedLocale[key]; 
+    return obj;
+  }, 
+  {}
+));
 
 // Dynamic imports to load locale data and translation files
 const LocaleImports = {
@@ -51,6 +60,11 @@ const LocaleImports = {
       import("../../../lang/de.json"),
       import("@formatjs/intl-relativetimeformat/locale-data/de"),
     ]),
+  [Locale.it]: () =>
+    Promise.all([
+      import("../../../lang/it_IT.json"),
+      import("@formatjs/intl-relativetimeformat/locale-data/it"),
+    ]),
   [Locale.af]: () =>
     Promise.all([
       import("../../../lang/af.json"),
diff --git a/src/services/User/Locale/Messages.js b/src/services/User/Locale/Messages.js
index 16cb0575..d54bf9b6 100644
--- a/src/services/User/Locale/Messages.js
+++ b/src/services/User/Locale/Messages.js
@@ -16,6 +16,10 @@ export default defineMessages({
     id: "Locale.de.label",
     defaultMessage: "de (Deutsch)",
   },
+  it: {
+    id: "Locale.it.label",
+    defaultMessage: "it (Italiano)",
+  },
   fr: {
     id: "Locale.fr.label",
     defaultMessage: "fr (Français)",


Test patch:
diff --git a/src/components/ChallengeDetail/ChallengeDetail.test.js b/src/components/ChallengeDetail/ChallengeDetail.test.js
index f2d97068..d14eec5e 100644
--- a/src/components/ChallengeDetail/ChallengeDetail.test.js
+++ b/src/components/ChallengeDetail/ChallengeDetail.test.js
@@ -15,4 +15,56 @@ describe("ChallengeDetail", () => {
     const text = getByText("Test Passes");
     expect(text).toBeInTheDocument();
   });
+
+  it('renders public challenge page when user is not logged in', () => {
+    const { queryByText } = global.withProvider(
+      <ChallengeDetail
+        match={{ url: '', params: {} }}
+        history={{ location: { pathname: '', search: '' } }}
+        location={{ search: '' }}
+        browsedChallenge={{
+          id: 1,
+          parent: { id: 2 },
+          lastTaskRefresh: new Date(),
+          dataOriginDate: new Date(),
+        }}
+        intl={{
+          formatMessage: () => '',
+          formatDate: () => '',
+        }}
+      />
+    )
+    const text = queryByText('Sign in to participate')
+    expect(text).toBeInTheDocument()
+    const cloneText = queryByText('Clone Challenge')
+    expect(cloneText).not.toBeInTheDocument()
+  })
+
+  it('renders clone challenge button for discoverable challenge', () => {
+    const { getByText } = global.withProvider(
+      <ChallengeDetail
+        checkingLoginStatus={false}
+        user={{
+          isLoggedIn: true,
+          id: 1,
+        }}
+        match={{ url: '', params: {} }}
+        history={{ location: { pathname: '', search: '' } }}
+        location={{ search: '' }}
+        browsedChallenge={{
+          id: 1,
+          parent: { id: 2 },
+          lastTaskRefresh: new Date(),
+          dataOriginDate: new Date(),
+          enabled: true,
+        }}
+        intl={{
+          formatMessage: () => '',
+          formatDate: () => '',
+        }}
+      />
+    )
+    const cloneText = getByText('Clone Challenge')
+    expect(cloneText).toBeInTheDocument()
+  })
 });
diff --git a/src/components/HOCs/WithSearch/__snapshots__/WithSearch.test.js.snap b/src/components/HOCs/WithSearch/__snapshots__/WithSearch.test.js.snap
index 48a4a4b6..40ce863a 100644
--- a/src/components/HOCs/WithSearch/__snapshots__/WithSearch.test.js.snap
+++ b/src/components/HOCs/WithSearch/__snapshots__/WithSearch.test.js.snap
@@ -4,6 +4,7 @@ exports[`mapDispatchToProps maps function performSearch 1`] = `
 Object {
   "clearMapBounds": [Function],
   "clearSearch": [Function],
+  "clearSearchDispatch": [Function],
   "clearSearchFilters": [Function],
   "locateMapToUser": [Function],
   "performSearch": [Function],
@@ -24,6 +25,7 @@ exports[`mapDispatchToProps maps function setSearch 1`] = `
 Object {
   "clearMapBounds": [Function],
   "clearSearch": [Function],
+  "clearSearchDispatch": [Function],
   "clearSearchFilters": [Function],
   "locateMapToUser": [Function],
   "performSearch": [Function],
diff --git a/src/components/HOCs/WithSearchRoute/WithSearchRoute.test.js b/src/components/HOCs/WithSearchRoute/WithSearchRoute.test.js
index 1e1fef81..e2220acf 100644
--- a/src/components/HOCs/WithSearchRoute/WithSearchRoute.test.js
+++ b/src/components/HOCs/WithSearchRoute/WithSearchRoute.test.js
@@ -14,7 +14,7 @@ beforeEach(() => {
   basicProps.setSearchFilters = jest.fn()
   basicProps.setSearchSort = jest.fn()
   basicProps.setKeywordFilter = jest.fn()
-  basicProps.clearSearch = jest.fn()
+  basicProps.clearSearchDispatch = jest.fn()
   basicProps.clearSearchFilters = jest.fn()
   basicProps.clearMapBounds = jest.fn()
 })
@@ -28,7 +28,7 @@ test("executeRouteSearch clears old redux values first", () => {
   )
   expect(basicProps.clearSearchFilters).toHaveBeenCalled()
   expect(basicProps.clearMapBounds).toHaveBeenCalled()
-  expect(basicProps.clearSearch).toHaveBeenCalled()
+  expect(basicProps.clearSearchDispatch).toHaveBeenCalled()
 })
 
 test("executeRouteSearch executes setSearch when passed query=", () => {