Repository: osmlab/maproulette3 (js)

Issue:
Every page has the same title
Every page has the same HTML page title, “MapRoulette”. This makes it difficult to distinguish multiple tabs open to various MapRoulette pages or find a certain challenge in browser history. Most pages have an `<h1>` tag that would be suitable for `<title>` as well.

Pull request description:
add dynamic page titles based on url
fixes #1807 


Details:
 - react-helmet is used for head tag management as suggested.
 - Following the comment,  page title is generated based on url path, challenge/project/country code ids are swapped with names.

Patch:
diff --git a/README.md b/README.md
index 75c55eb6..8377b712 100644
--- a/README.md
+++ b/README.md
@@ -2,6 +2,8 @@
 
 MapRoulette is the best known and most widely used microtasking platform for [OpenStreetMap](https://osm.org/). The live website is at [maproulette.org](https://maproulette.org). 
 
+Help build a sustainable MapRoulette by [donating](https://liberapay.com/MapRoulette/donate) as little as USD 1. <img src="https://img.shields.io/liberapay/receives/MapRoulette.svg?logo=liberapay">
+
 ## Using MapRoulette
 
 If you're looking to improve OSM using MapRoulette, you can get started at [maproulette.org](https://maproulette.org) right away. You can log in to MapRoulette with your existing OSM account. If you don't have an OSM account, you probably want to learn the basics of mapping with OSM first. The best way to do this is to [create your very own OSM account](https://www.openstreetmap.org/user/new), move the map to your home town, and click "Edit" on the OSM web site. The OSM web editor has a built in walk-though that teaches you the basics. Once you have mastered basic OSM editing, you can use your new OSM account to log on to MapRoulette and start finding some easy tasks!
diff --git a/package.json b/package.json
index 1b60db87..1001a340 100644
--- a/package.json
+++ b/package.json
@@ -59,6 +59,7 @@
     "react-dropzone": "^11.0.1",
     "react-elastic-carousel": "^0.9.0",
     "react-grid-layout": "^0.18.3",
+    "react-helmet": "^6.1.0",
     "react-intl": "^4.6.9",
     "react-intl-formatted-duration": "^4.0.0",
     "react-leaflet": "^2.7.0",
diff --git a/src/App.js b/src/App.js
index 5a17ffba..8df7a8b4 100644
--- a/src/App.js
+++ b/src/App.js
@@ -34,6 +34,7 @@ import LoadRandomChallengeTask
        from './components/LoadRandomChallengeTask/LoadRandomChallengeTask'
 import LoadRandomVirtualChallengeTask
        from './components/LoadRandomVirtualChallengeTask/LoadRandomVirtualChallengeTask'
+import HeadTitle from './components/Head/Head'
 import Navbar from './components/Navbar/Navbar'
 import SystemNotices from './components/SystemNotices/SystemNotices'
 import Footer from './components/Footer/Footer'
@@ -143,12 +144,20 @@ export class App extends Component {
 /**
  * Simple wrapper for Route that clears the request cache prior to rendering.
  */
-export const CachedRoute=({component: Component, ...rest}) => (
-  <Route {...rest}
-         render={props => {
-           resetCache()
-           return <Component {...props} />
-         }} />
-)
+export const CachedRoute = ({ component: Component, ...rest }) => {
+  return (
+    <Route {...rest}
+      render={props => {
+        resetCache()
+        return (
+          <>
+            <HeadTitle />
+            <Component {...props} />
+          </>
+
+        )
+      }} />
+  )
+}
 
 export default withRouter(App)
diff --git a/src/components/AdminPane/AdminPane.js b/src/components/AdminPane/AdminPane.js
index 37b3ff57..5d392523 100644
--- a/src/components/AdminPane/AdminPane.js
+++ b/src/components/AdminPane/AdminPane.js
@@ -18,6 +18,7 @@ import ProjectDashboard from "./Manage/ProjectDashboard/ProjectDashboard";
 import ChallengeDashboard from "./Manage/ChallengeDashboard/ChallengeDashboard";
 import BusySpinner from "../BusySpinner/BusySpinner";
 import EmailRequirementNotice from "./Manage/EmailRequirementNotice/EmailRequirementNotice";
+import HeadTitle from "../Head/Head";
 import "./Manage/Widgets/widget_registry.js";
 import "./AdminPane.scss";
 
@@ -58,17 +59,17 @@ export class AdminPane extends Component {
         <div className="admin mr-bg-gradient-r-green-dark-blue mr-text-white">
           <div className="admin-pane">
             <Switch>
-              <Route
+              <CustomRoute
                 exact
                 path="/admin/project/:projectId/challenge/:challengeId"
                 component={ChallengeDashboard}
               />
-              <Route
+              <CustomRoute
                 exact
                 path={["/admin/projects/new", "/admin/project/:projectId/edit"]}
                 component={EditProject}
               />
-              <Route
+              <CustomRoute
                 exact
                 path={[
                   "/admin/project/:projectId/challenges/new",
@@ -77,37 +78,37 @@ export class AdminPane extends Component {
                 ]}
                 component={EditChallenge}
               />
-              <Route
+              <CustomRoute
                 exact
                 path="/admin/project/:projectId/challenges/edit"
                 component={EditChallenges}
               />
-              <Route
+              <CustomRoute
                 exact
                 path="/admin/project/:projectId/challenge/:challengeId/task/:taskId/edit"
                 component={EditTask}
               />
-              <Route
+              <CustomRoute
                 exact
                 path="/admin/project/:projectId/challenge/:challengeId/task/:taskId/inspect"
                 component={InspectTask}
               />
-              <Route
+              <CustomRoute
                 exact
                 path="/admin/virtual/project/:projectId/challenges/manage"
                 component={ManageChallengeList}
               />
-              <Route
+              <CustomRoute
                 exact
                 path="/admin/projects"
                 component={ProjectsDashboard}
               />
-              <Route
+              <CustomRoute
                 exact
                 path="/admin/project/:projectId"
                 component={ProjectDashboard}
               />
-              <Route component={ProjectsDashboard} />
+              <CustomRoute component={ProjectsDashboard} />
             </Switch>
           </div>
         </div>
@@ -124,4 +125,19 @@ AdminPane.propTypes = {
   location: PropTypes.object.isRequired,
 };
 
+export const CustomRoute = ({ component: Component, ...rest }) => {
+  return (
+    <Route {...rest}
+      render={props => {
+        return (
+          <>
+            <HeadTitle />
+            <Component {...props} />
+          </>
+
+        )
+      }} />
+  )
+}
+
 export default WithStatus(WithCurrentUser(withRouter(AdminPane)));
diff --git a/src/components/Head/Head.js b/src/components/Head/Head.js
new file mode 100644
index 00000000..92381c5e
--- /dev/null
+++ b/src/components/Head/Head.js
@@ -0,0 +1,85 @@
+import React from 'react'
+import { useEffect } from 'react';
+import { Helmet } from 'react-helmet';
+import { withRouter } from 'react-router';
+import WithCurrentUser from '../HOCs/WithCurrentUser/WithCurrentUser';
+import WithCurrentProject from '../AdminPane/HOCs/WithCurrentProject/WithCurrentProject';
+import WithCurrentChallenge from '../AdminPane/HOCs/WithCurrentChallenge/WithCurrentChallenge';
+import { injectIntl } from 'react-intl';
+import _get from 'lodash/get'
+import _isEmpty from 'lodash/isEmpty'
+
+const REACT_APP_TITLE = 'MapRoulette'
+
+export const HeadTitle = (props) => {
+
+  useEffect(() => {
+    pathToTitleFormat(props.match.path)
+  }, [props.location.pathname, props.project, props.challenge, props.user])
+
+  const findCurrentChallengeName = () => {
+    return props.challenge?.name
+  }
+
+  const findCurrentProjectName = () => {
+    return props.project?.displayName
+  }
+
+  const findCurrentUserName = () => {
+    return props.user?.osmProfile.displayName
+  }
+
+  const findCurrentCountryCode = () => {
+    return _get(props, 'match.params.countryCode')
+  }
+
+  const findCurrentTaskId = () => {
+    return _get(props, 'match.params.taskId')
+  }
+
+  const findReviewType = () => {
+    return _get(props, 'match.params.showType')
+  }
+
+  /* parse names from url path into array, replace id params with names, and then concatenate with - for title */
+  const pathToTitleFormat = (path) => {
+    let pathArr = path.split('/').filter(element => element)
+    pathArr = pathArr.map(params => {
+      if (params === ':challengeId') {
+        return findCurrentChallengeName()
+      }
+      else if (params === ':projectId') {
+        return findCurrentProjectName()
+      }
+      else if (params === ':countryCode') {
+        return findCurrentCountryCode()
+      }
+      else if (params === ':userId') {
+        return findCurrentUserName()
+      }
+      else if (params === ':taskId') {
+        return findCurrentTaskId()
+      }
+      else if (params === ':showType') {
+        return findReviewType()
+      }
+      else {
+        return params
+      }
+    })
+    let newTitle = _isEmpty(pathArr) ? REACT_APP_TITLE : REACT_APP_TITLE + '-' + pathArr.join('-')
+    return newTitle
+  }
+
+  return (
+    <Helmet>
+      <title>{pathToTitleFormat(props.match.path)}</title>
+    </Helmet>
+  )
+}
+
+export default withRouter(WithCurrentUser(WithCurrentProject(
+  WithCurrentChallenge(
+    injectIntl(HeadTitle)))
+)
+)
diff --git a/src/components/TaskPane/ActiveTaskDetails/ActiveTaskControls/TaskNextControl/TaskNextControl.js b/src/components/TaskPane/ActiveTaskDetails/ActiveTaskControls/TaskNextControl/TaskNextControl.js
index 410f7cd7..761904b6 100644
--- a/src/components/TaskPane/ActiveTaskDetails/ActiveTaskControls/TaskNextControl/TaskNextControl.js
+++ b/src/components/TaskPane/ActiveTaskDetails/ActiveTaskControls/TaskNextControl/TaskNextControl.js
@@ -93,24 +93,26 @@ export default class TaskNextControl extends Component {
                 <input
                   type="radio"
                   name="randomnessPreference"
+                  id="randomnessPreference-random"
                   className="mr-radio mr-mr-1"
                   checked={this.props.loadBy === TaskLoadMethod.random}
                   onClick={() => this.props.chooseLoadBy(TaskLoadMethod.random)}
                   onChange={_noop}
                 />
-                <label className="mr-ml-1 mr-mr-4">
+                <label className="mr-ml-1 mr-mr-4" htmlFor="randomnessPreference-random">
                   <FormattedMessage {...messagesByLoadMethod[TaskLoadMethod.random]} />
                 </label>
 
                 <input
                   type="radio"
                   name="randomnessPreference"
+                  id="randomnessPreference-proximity"
                   className="mr-radio mr-mr-1"
                   checked={this.props.loadBy === TaskLoadMethod.proximity}
                   onClick={() => this.props.chooseLoadBy(TaskLoadMethod.proximity)}
                   onChange={_noop}
                 />
-                <label className="mr-ml-1">
+                <label className="mr-ml-1" htmlFor="randomnessPreference-proximity">
                   <FormattedMessage {...messagesByLoadMethod[TaskLoadMethod.proximity]} />
                 </label>
               </div>
diff --git a/src/pages/Leaderboard/CardLeaderboard.js b/src/pages/Leaderboard/CardLeaderboard.js
index b1f7ba19..fe60efd9 100644
--- a/src/pages/Leaderboard/CardLeaderboard.js
+++ b/src/pages/Leaderboard/CardLeaderboard.js
@@ -1,4 +1,4 @@
-import React, { Component } from 'react'
+import React, { Component} from 'react'
 import PropTypes from 'prop-types'
 import classNames from 'classnames'
 import { FormattedMessage, FormattedNumber } from 'react-intl'
@@ -7,8 +7,21 @@ import _map from 'lodash/map'
 import _truncate from 'lodash/truncate'
 import messages from './Messages'
 import AsAvatarUser from '../../interactions/User/AsAvatarUser'
+import './Leaderboard.scss'
 
 class CardLeaderboard extends Component {
+  state = {
+    isHover: false
+  }
+
+  onHover = () => {
+    this.setState({isHover: true})
+  }
+
+  onLeave = () => {
+    this.setState({isHover: false}) 
+  }
+
   render() {
     const leader = this.props.leader
 
@@ -29,16 +42,26 @@ class CardLeaderboard extends Component {
           this.props.className
         )}
       >
-        <header className="mr-max-w-xs mr-mx-auto mr-mb-2">
-          <div
-            className="mr-block mr-w-24 mr-h-24 mr-bg-black mr-bg-cover mr-bg-center mr-mx-auto mr-mb-4 mr-rounded-full"
+        <header className={"mr-max-w-xs mr-mx-auto mr-mb-2 " + (this.state.isHover ? 'hover-style': '')}>
+          <a
+            href={'https://www.openstreetmap.org/user/' + leader.name} target="_blank" rel="noreferrer"
+            className="mr-block mr-w-24 mr-h-24 mr-bg-black mr-bg-cover mr-bg-center mr-mx-auto mr-mb-4 mr-rounded-full hover-pic"
             style={{ backgroundImage: `url(${AsAvatarUser(leader).profilePic(256)})` }}
+            onMouseOver={this.onHover}
+            onMouseLeave={this.onLeave}
           />
           <h2 className="mr-h4 mr-mb-1">
             <span className="mr-text-4xl mr-font-bold mr-absolute mr-left-0 mr-top-0 mr-mt-6 mr-ml-6">
               <FormattedNumber value={leader.rank} />
             </span>
-            {leader.name}
+            <a 
+              href={'https://www.openstreetmap.org/user/' + leader.name} target="_blank" rel="noreferrer" 
+              className="mr-text-white card-name hover-name"
+              onMouseOver={this.onHover}
+              onMouseLeave={this.onLeave}
+            >
+              {leader.name}
+            </a>
           </h2>
         </header>
         <h3 className="mr-h2 mr-mb-4 mr-text-yellow">
diff --git a/src/pages/Leaderboard/Leaderboard.scss b/src/pages/Leaderboard/Leaderboard.scss
new file mode 100644
index 00000000..ef1ad0c3
--- /dev/null
+++ b/src/pages/Leaderboard/Leaderboard.scss
@@ -0,0 +1,21 @@
+
+.hover-pic {
+    transition: outline-offset 200ms ease;
+    outline-offset: -8px;
+}
+
+.hover-name {
+    transition: all 200ms ease;
+}
+.hover-style {
+    .hover-pic {
+        border: 2px solid #7EBC89;
+        outline: 2px solid #7EBC89;
+        outline-offset: 4px;
+    }
+
+    .hover-name {
+        color: #7EBC89;
+    }
+}
+
diff --git a/src/pages/Leaderboard/RowLeaderboard.js b/src/pages/Leaderboard/RowLeaderboard.js
index 1e7f3ba1..c6e27109 100644
--- a/src/pages/Leaderboard/RowLeaderboard.js
+++ b/src/pages/Leaderboard/RowLeaderboard.js
@@ -7,8 +7,21 @@ import _map from 'lodash/map'
 import _truncate from 'lodash/truncate'
 import messages from './Messages'
 import AsAvatarUser from '../../interactions/User/AsAvatarUser'
+import './Leaderboard.scss'
 
 class RowLeaderboard extends Component {
+  state = {
+    isHover: false
+  }
+
+  onHover = () => {
+    this.setState({isHover: true})
+  }
+
+  onLeave = () => {
+    this.setState({isHover: false}) 
+  }
+
   render() {
     const leader = this.props.leader
 
@@ -31,15 +44,23 @@ class RowLeaderboard extends Component {
                 <FormattedNumber value={leader.rank} />
               </h3>
             </div>
-            <div className="md:mr-flex mr-items-center">
-              <div
-                className="mr-block mr-w-20 mr-h-20 mr-bg-black mr-bg-cover mr-bg-center mr-mx-auto mr-rounded-full"
+            <div className={"md:mr-flex mr-items-center " + (this.state.isHover ? 'hover-style': '')}>
+              <a
+                href={'https://www.openstreetmap.org/user/' + leader.name} target="_blank" rel="noreferrer"
+                className="mr-block mr-w-20 mr-h-20 mr-bg-black mr-bg-cover mr-bg-center mr-mx-auto mr-rounded-full hover-pic"
                 style={{ backgroundImage: `url(${AsAvatarUser(leader).profilePic(256)})` }}
+                onMouseOver={this.onHover}
+                onMouseLeave={this.onLeave}
               />
               <div className="md:mr-pl-8">
-                <h2 className="mr-text-lg mr-font-normal mr-mb-2">
+                <a 
+                  href={'https://www.openstreetmap.org/user/' + leader.name} target="_blank" rel="noreferrer"
+                  className="mr-text-lg mr-font-normal mr-mb-2 mr-text-white hover-name"
+                  onMouseOver={this.onHover}
+                  onMouseLeave={this.onLeave}
+                >
                   {leader.name}
-                </h2>
+                </a>
                 <h4 className="mr-text-md mr-text-yellow">
                   <strong className="mr-text-yellow">
                     <FormattedNumber value={leader.score} />
diff --git a/yarn.lock b/yarn.lock
index 01d52239..fe7d8fca 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -11769,7 +11769,7 @@ react-error-overlay@^6.0.9:
   resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.11.tgz#92835de5841c5cf08ba00ddd2d677b6d17ff9adb"
   integrity sha512-/6UZ2qgEyH2aqzYZgQPxEnz33NJ2gNsnHA2o5+o4wW9bLM/JYQitNP9xPhsXwC08hMMovfGe/8retsdDsczPRg==
 
-react-fast-compare@^3.0.1:
+react-fast-compare@^3.0.1, react-fast-compare@^3.1.1:
   version "3.2.0"
   resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.0.tgz#641a9da81b6a6320f270e89724fb45a0b39e43bb"
   integrity sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA==
@@ -11784,6 +11784,16 @@ react-grid-layout@^0.18.3:
     react-draggable "^4.0.0"
     react-resizable "^1.9.0"
 
+react-helmet@^6.1.0:
+  version "6.1.0"
+  resolved "https://registry.yarnpkg.com/react-helmet/-/react-helmet-6.1.0.tgz#a750d5165cb13cf213e44747502652e794468726"
+  integrity sha512-4uMzEY9nlDlgxr61NL3XbKRy1hEkXmKNXhjbAIOVw5vcFrsdYbH2FEwcNyWvWinl103nXgzYNlns9ca+8kFiWw==
+  dependencies:
+    object-assign "^4.1.1"
+    prop-types "^15.7.2"
+    react-fast-compare "^3.1.1"
+    react-side-effect "^2.1.0"
+
 react-intl-formatted-duration@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/react-intl-formatted-duration/-/react-intl-formatted-duration-4.0.0.tgz#b63b5dea3b379efa575ed11302e17e7905c134ba"
@@ -12030,6 +12040,11 @@ react-share@^4.2.0:
     classnames "^2.2.5"
     jsonp "^0.2.1"
 
+react-side-effect@^2.1.0:
+  version "2.1.2"
+  resolved "https://registry.yarnpkg.com/react-side-effect/-/react-side-effect-2.1.2.tgz#dc6345b9e8f9906dc2eeb68700b615e0b4fe752a"
+  integrity sha512-PVjOcvVOyIILrYoyGEpDN3vmYNLdy1CajSFNt4TDsVQC5KpTijDvWVoR+/7Rz2xT978D8/ZtFceXxzsPwZEDvw==
+
 react-swipeable@^5.5.1:
   version "5.5.1"
   resolved "https://registry.yarnpkg.com/react-swipeable/-/react-swipeable-5.5.1.tgz#48ae6182deaf62f21d4b87469b60281dbd7c4a76"


Test patch:
diff --git a/src/components/Head/HeadTest.js b/src/components/Head/HeadTest.js
new file mode 100644
index 00000000..9fce8e78
--- /dev/null
+++ b/src/components/Head/HeadTest.js
@@ -0,0 +1,68 @@
+const _get = require('lodash/get');
+const _isEmpty = require('lodash/isEmpty')
+
+/* Unit tests for Head.js, export functions from Head.js and run tests with node HeadTest.js */
+let path = '/'
+let props = {
+  challenge: {
+    id: 1,
+    name: 'challenge1'
+  },
+  project: {
+    id: 1,
+    displayName: 'project1'
+  },
+  match: {
+    path: path,
+    params: {
+      'challengeId': 1,
+      'projectId': 1,
+      'taskId': 10,
+      'countryCode': 'AL',
+      'showType': 'tag'
+    },
+  },
+  user: {
+    osmProfile: {
+      displayName: 'user1'
+    }
+  }
+}
+const titleTest = () => {
+
+  const pathWithoutParams = 'browse/challenges'
+  const pathWithParams = 'browse/challenge/:challengeId'
+
+  if (findCurrentChallengeName() === 'challenge1') {
+    console.log('findCurrentChallengeName succeed!')
+  }
+  if (findCurrentProjectName() === 'project1') {
+    console.log('findCurrentProjectName succeed!')
+  }
+  if (findCurrentUserName() === 'user1') {
+    console.log('findCurrentUserName succeed!')
+  }
+  if (findCurrentTaskId() === 10) {
+    console.log('findCurrentTask succeed!')
+  }
+  if (findCurrentCountryCode() === 'AL') {
+    console.log('findCurrentCountryCode succeed!')
+  }
+  if (findReviewType() === 'tag') {
+    console.log('findReviewType succeed!')
+  }
+  if (pathToTitleFormat(props.match.path) === 'MapRoulette') {
+    console.log('title can be generated for homepage!')
+  }
+  props.match.path = pathWithoutParams
+  if (pathToTitleFormat(props.match.path) === 'MapRoulette-browse-challenges') {
+    console.log('title can be generated for path without params!')
+  }
+  props.match.path = pathWithParams
+  if (pathToTitleFormat(props.match.path) === 'MapRoulette-browse-challenge-challenge1') {
+    console.log('title can be generated for path with params!')
+  }
+}
+titleTest()
+
+module.exports = 'titleTest'
\ No newline at end of file
diff --git a/src/pages/Leaderboard/Leaderboard.test.js b/src/pages/Leaderboard/Leaderboard.test.js
new file mode 100644
index 00000000..9c98116b
--- /dev/null
+++ b/src/pages/Leaderboard/Leaderboard.test.js
@@ -0,0 +1,58 @@
+import React, { Component }  from 'react';
+import { shallow } from 'enzyme';
+import CardLeaderboard from './CardLeaderboard'
+import RowLeaderboard from './RowLeaderboard'
+
+describe("hoverUser", () => {
+    let wrapper;
+    
+    beforeEach(() => {
+        const user = {
+            id: 123,
+            name: 'test',
+            topChallenges: [],
+            rank: 1,
+            score: 3,
+        }
+        wrapper = shallow(<CardLeaderboard leader={user}/>)
+    })
+
+    test('isHover is false on load', () => {
+        expect(wrapper.state().isHover).toEqual(false)
+    })
+
+    test('isHover is true on hover & false on leave', () => {
+        wrapper.instance().onHover()
+        expect(wrapper.state().isHover).toEqual(true)
+
+        wrapper.instance().onLeave()
+        expect(wrapper.state().isHover).toEqual(false)
+    })
+}) 
+
+describe("hoverUser", () => {
+    let wrapper;
+    
+    beforeEach(() => {
+        const user = {
+            id: 123,
+            name: 'test',
+            topChallenges: [],
+            rank: 1,
+            score: 3,
+        }
+        wrapper = shallow(<RowLeaderboard leader={user}/>)
+    })
+
+    test('isHover is false on load', () => {
+        expect(wrapper.state().isHover).toEqual(false)
+    })
+
+    test('isHover is true on hover & false on leave', () => {
+        wrapper.instance().onHover()
+        expect(wrapper.state().isHover).toEqual(true)
+
+        wrapper.instance().onLeave()
+        expect(wrapper.state().isHover).toEqual(false)
+    })
+}) 
\ No newline at end of file