diff --git a/.env b/.env index 479b14d76..fffbc5886 100644 --- a/.env +++ b/.env @@ -10,7 +10,6 @@ REACT_APP_TITLE='MapRoulette' # Features flags. Set each to 'enabled' or 'disabled'. REACT_APP_FEATURE_SOCIAL_SHARING='enabled' -REACT_APP_FEATURE_BOUNDED_TASK_BROWSING='disabled' REACT_APP_FEATURE_LEADERBOARD='enabled' REACT_APP_FEATURE_CHALLENGE_ANALYSIS_TABLE='disabled' REACT_APP_FEATURE_MOBILE_DEVICES='disabled' @@ -45,7 +44,7 @@ REACT_APP_NEARBY_LATITUDE_LENGTH=0.75 # box in order for map-bounded task browsing to become available on the locator # map. The larger this value, the greater the load put on the server when users # are browsing map-bounded tasks. -REACT_APP_BOUNDED_TASKS_MAX_DIMENSION=2.5 +REACT_APP_BOUNDED_TASKS_MAX_DIMENSION=70 # Default time, in hours, until a newly-created virtual challenge expires. REACT_APP_VIRTUAL_CHALLENGE_DURATION=36 diff --git a/CHANGELOG.md b/CHANGELOG.md index dd9ffc3a8..eb8be813b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ The format is based on This project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## [v3.5] - 2019-12-06 +### Added +- Task browsing at much lower zoom levels on Find Challenges page +- Overlay on Find Challenges map with "Near Me" option and nominatum search +- Relocated location filters on Find Challenges page above search results +- Automatic refreshing of task locks while mapper is actively working on a task +- New option to explictly "unlock" (abandon) a task on task-completion page + +### Fixed +- Potential wrong timezone on origin/sourcing date recorded for task data +- Occasional incorrect challenge-completion status resulting from stale checks +- Incorrect OSM entity ids sometimes sent to iD and JOSM editors + +### Removed +- "Within Map Bounds" filter now that task browsing is offered at lower zoom + + ## [v3.4.6] - 2019-11-14 ### Added - Option to change task data source date when rebuilding tasks diff --git a/package.json b/package.json index 095cd1064..2db34347e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "maproulette3", - "version": "3.4.6", + "version": "3.5", "private": true, "dependencies": { "@mapbox/geo-viewport": "^0.4.0", diff --git a/src/PersistedStore.js b/src/PersistedStore.js index 6ed7ac127..2809c0b83 100644 --- a/src/PersistedStore.js +++ b/src/PersistedStore.js @@ -9,6 +9,7 @@ import { virtualChallengeEntities } from './services/VirtualChallenge/VirtualChallenge' import { taskEntities } from './services/Task/Task' import { currentClusteredTasks } from './services/Task/ClusteredTask' +import { currentTaskClusters } from './services/Task/TaskClusters' import { currentBoundedTasks } from './services/Task/BoundedTask' import { currentReviewTasks } from './services/Task/TaskReview/TaskReview' import { commentEntities } from './services/Comment/Comment' @@ -55,6 +56,7 @@ export const initializeStore = function() { adminContext, currentPreferences, currentClusteredTasks, + currentTaskClusters, currentBoundedTasks, currentReviewTasks, entities, diff --git a/src/components/AdminPane/HOCs/WithCurrentChallenge/WithCurrentChallenge.js b/src/components/AdminPane/HOCs/WithCurrentChallenge/WithCurrentChallenge.js index 916f1e43e..5eef77e49 100644 --- a/src/components/AdminPane/HOCs/WithCurrentChallenge/WithCurrentChallenge.js +++ b/src/components/AdminPane/HOCs/WithCurrentChallenge/WithCurrentChallenge.js @@ -14,8 +14,6 @@ import { addError } from '../../../../services/Error/Error' import AppErrors from '../../../../services/Error/AppErrors' import AsManageableChallenge from '../../../../interactions/Challenge/AsManageableChallenge' -import { isUsableChallengeStatus } - from '../../../../services/Challenge/ChallengeStatus/ChallengeStatus' import WithClusteredTasks from '../../../HOCs/WithClusteredTasks/WithClusteredTasks' import WithChallengeManagement @@ -27,12 +25,10 @@ import WithChallengeManagement * * @author [Neil Rotstan](https://github.com/nrotstan) */ -const WithCurrentChallenge = function(WrappedComponent, - includeTasks=false) { +const WithCurrentChallenge = function(WrappedComponent) { return class extends Component { state = { loadingChallenge: true, - loadingTasks: includeTasks, } currentChallengeId = () => @@ -53,24 +49,10 @@ const WithCurrentChallenge = function(WrappedComponent, this.props.fetchChallengeActivity(challengeId, new Date(challenge.created)), this.props.fetchChallengeActions(challengeId), ]).then(() => this.setState({loadingChallenge: false})) - - if (includeTasks) { - // Only fetch tasks if the challenge is in a usable status. Otherwise - // we risk errors if the tasks are still building or failed to build. - if (isUsableChallengeStatus(challenge.status, true)) { - this.setState({loadingTasks: true}) - this.props.fetchClusteredTasks(challengeId).then(() => - this.setState({loadingTasks: false}) - ) - } - else { - this.setState({loadingTasks: false}) - } - } }) } else { - this.setState({loadingChallenge: false, loadingTasks: false}) + this.setState({loadingChallenge: false}) } } @@ -89,23 +71,16 @@ const WithCurrentChallenge = function(WrappedComponent, challengeDenormalizationSchema(), this.props.entities) ) - - if (includeTasks && - _get(this.props, 'clusteredTasks.challengeId') === challengeId) { - clusteredTasks = this.props.clusteredTasks - } } return } @@ -143,11 +118,11 @@ const mapDispatchToProps = (dispatch, ownProps) => ({ }, }) -export default (WrappedComponent, includeTasks) => +export default (WrappedComponent) => connect(mapStateToProps, mapDispatchToProps)( WithClusteredTasks( WithChallengeManagement( - WithCurrentChallenge(WrappedComponent, includeTasks) + WithCurrentChallenge(WrappedComponent) ) ) ) diff --git a/src/components/AdminPane/Manage/ChallengeDashboard/ChallengeDashboard.js b/src/components/AdminPane/Manage/ChallengeDashboard/ChallengeDashboard.js index c53b381b1..bcaa7975d 100644 --- a/src/components/AdminPane/Manage/ChallengeDashboard/ChallengeDashboard.js +++ b/src/components/AdminPane/Manage/ChallengeDashboard/ChallengeDashboard.js @@ -273,8 +273,7 @@ WithManageableProjects( WidgetDataTarget.challenge, DASHBOARD_NAME, defaultDashboardSetup - ), - false + ) ), 'challengeOwner' ) diff --git a/src/components/AdminPane/Manage/ViewChallengeTasks/ViewChallengeTasks.js b/src/components/AdminPane/Manage/ViewChallengeTasks/ViewChallengeTasks.js index 4be0d79e0..ca896d0d6 100644 --- a/src/components/AdminPane/Manage/ViewChallengeTasks/ViewChallengeTasks.js +++ b/src/components/AdminPane/Manage/ViewChallengeTasks/ViewChallengeTasks.js @@ -10,6 +10,7 @@ import { TaskStatus, from '../../../../services/Task/TaskStatus/TaskStatus' import { messagesByPriority } from '../../../../services/Task/TaskPriority/TaskPriority' +import { toLatLngBounds } from '../../../../services/MapBounds/MapBounds' import AsManager from '../../../../interactions/User/AsManager' import WithBoundedTasks from '../../../HOCs/WithBoundedTasks/WithBoundedTasks' @@ -47,6 +48,7 @@ const ClusterMap = WithChallengeTaskClusters( export class ViewChallengeTasks extends Component { state = { bulkUpdating: false, + boundsReset: false, } takeTaskSelectionAction = action => { @@ -81,12 +83,14 @@ export class ViewChallengeTasks extends Component { } resetMapBounds = () => { + this.setState({boundsReset: true}) this.props.clearMapBounds(this.props.searchGroup) } mapBoundsUpdated = (challengeId, bounds, zoom) => { this.props.setChallengeOwnerMapBounds(challengeId, bounds, zoom) this.props.updateTaskFilterBounds(bounds, zoom) + this.setState({boundsReset: false}) } showMarkerPopup = markerData => { @@ -156,9 +160,12 @@ export class ViewChallengeTasks extends Component { loadingTasks={this.props.loadingTasks} showMarkerPopup={this.showMarkerPopup} allowClusterToggle + initialBounds={this.state.boundsReset ? + toLatLngBounds(_get(this.props, 'criteria.boundingBox')) : null} {...this.props} /> + this.boundsReset = false return (
diff --git a/src/components/ChallengeBrowseMap/ChallengeBrowseMap.js b/src/components/ChallengeBrowseMap/ChallengeBrowseMap.js deleted file mode 100644 index 58a7575ee..000000000 --- a/src/components/ChallengeBrowseMap/ChallengeBrowseMap.js +++ /dev/null @@ -1,187 +0,0 @@ -import React, { Component } from 'react' -import PropTypes from 'prop-types' -import classNames from 'classnames' -import { Marker, ZoomControl } from 'react-leaflet' -import MarkerClusterGroup from 'react-leaflet-markercluster' -import { point, featureCollection } from '@turf/helpers' -import bbox from '@turf/bbox' -import bboxPolygon from '@turf/bbox-polygon' -import _get from 'lodash/get' -import _map from 'lodash/map' -import _isEqual from 'lodash/isEqual' -import { latLng } from 'leaflet' -import { layerSourceWithId } from '../../services/VisibleLayer/LayerSources' -import EnhancedMap from '../EnhancedMap/EnhancedMap' -import SourcedTileLayer from '../EnhancedMap/SourcedTileLayer/SourcedTileLayer' -import LayerToggle from '../EnhancedMap/LayerToggle/LayerToggle' -import WithVisibleLayer from '../HOCs/WithVisibleLayer/WithVisibleLayer' -import WithSearch from '../HOCs/WithSearch/WithSearch' -import WithIntersectingOverlays - from '../HOCs/WithIntersectingOverlays/WithIntersectingOverlays' -import WithStatus from '../HOCs/WithStatus/WithStatus' -import BusySpinner from '../BusySpinner/BusySpinner' - -// Setup child components with necessary HOCs -const VisibleTileLayer = WithVisibleLayer(SourcedTileLayer) - -/** - * ChallengeBrowseMap allows a user to browse a challenge and its tasks - * geographically. Tasks are shown in clusters when appropriate, and - * a bounding box is displayed while the tasks load. - * - * As the map is moved, its current bounds are updated in the redux store so - * that other components can make use of the bounds if desired (e.g., - * starting a challenge will begin with a task that is currently visible to - * the user on the challenge map). - * - * @author [Neil Rotstan](https://github.com/nrotstan) - */ -export class ChallengeBrowseMap extends Component { - currentBounds = null - - shouldComponentUpdate(nextProps, nextState) { - // We want to be careful about not constantly re-rendering, so we only - // re-render if something meaningful changes: - - // the base layer has changed, or - if (_get(nextProps, 'source.id') !== _get(this.props, 'source.id')) { - return true - } - - // the available overlays have changed, or - if (!_isEqual(nextProps.intersectingOverlays, this.props.intersectingOverlays)) { - return true - } - - // the visible overlays have changed, or - if (nextProps.visibleOverlays.length !== this.props.visibleOverlays.length) { - return true - } - - // the browsed challenge has changed, or - if (_get(nextProps, 'browsedChallenge.id') !== - _get(this.props, 'browsedChallenge.id')) { - return true - } - - // the task markers have changed - if (_get(nextProps, 'taskMarkers.length') !== - _get(this.props, 'taskMarkers.length')) { - return true - } - - // the loading status of tasks change - if (!!nextProps.tasksLoading !== !!this.props.tasksLoading) { - return true - } - - return false - } - - /** - * Signal a change to the current challenge map bounds in response to a - * change to the map (panning or zooming). - * - * @private - */ - updateBounds = (bounds, zoom) => { - // If the new bounds are the same as the old, do nothing. - if (this.currentBounds && this.currentBounds.equals(bounds)) { - return - } - - this.currentBounds = bounds - this.props.setChallengeBrowseMapBounds(this.props.browsedChallenge.id, - bounds, zoom) - } - - /** - * Invoked when an individual task marker is clicked by the user. - */ - markerClicked = marker => { - if (this.props.onTaskClick) { - this.props.onTaskClick(marker.options.challengeId, - marker.options.isVirtualChallenge, - marker.options.taskId) - } - } - - render() { - if (!this.props.browsedChallenge) { - return null - } - - let bounding = null - // Get the challenge bounding so we know which part of the map to display. - // Right now API double-nests bounding, but that will likely change. - bounding = _get(this.props, 'browsedChallenge.bounding.bounding') || - _get(this.props, 'browsedChallenge.bounding') - - - const hasTaskMarkers = _get(this.props, 'taskMarkers.length', 0) > 0 - - // If the challenge doesn't have a bounding polygon, build one from the - // markers instead. This is extra work and requires waiting for the task - // data to arrive, so not ideal. - if (!bounding && hasTaskMarkers) { - bounding = bboxPolygon( - bbox(featureCollection( - _map(this.props.taskMarkers, - marker => point([marker.position[1], marker.position[0]])) - )) - ) - } - - const overlayLayers = _map(this.props.visibleOverlays, (layerId, index) => - - ) - - const renderedMarkers = _map(this.props.taskMarkers, markerData => ( - this.markerClicked(markerData)} /> - )) - - return ( -
- - - - - {overlayLayers} - {hasTaskMarkers && {renderedMarkers}} - - - {!!this.props.tasksLoading && } -
- ) - } -} - -ChallengeBrowseMap.propTypes = { - /** The current challenge being browsed */ - browsedChallenge: PropTypes.object.isRequired, - /** Map markers for the tasks to display */ - taskMarkers: PropTypes.array.isRequired, - /** Set to true if tasks are still loading */ - tasksLoading: PropTypes.bool, - /** Invoked when the user moves the map */ - setChallengeBrowseMapBounds: PropTypes.func.isRequired, - /** Invoked when the user clicks on an individual task marker */ - onTaskClick: PropTypes.func, -} - -export default WithSearch( - WithStatus( - WithVisibleLayer( - WithIntersectingOverlays(ChallengeBrowseMap, 'challengeBrowse') - ) - ), - 'challengeBrowse' -) diff --git a/src/components/ChallengeBrowseMap/ChallengeBrowseMap.test.js b/src/components/ChallengeBrowseMap/ChallengeBrowseMap.test.js deleted file mode 100644 index 76e9cb7fd..000000000 --- a/src/components/ChallengeBrowseMap/ChallengeBrowseMap.test.js +++ /dev/null @@ -1,120 +0,0 @@ -import React from 'react' -import _cloneDeep from 'lodash/cloneDeep' -import { toLatLngBounds } from '../../services/MapBounds/MapBounds' -import { ChallengeLocation } - from '../../services/Challenge/ChallengeLocation/ChallengeLocation' -import { ChallengeBrowseMap } from './ChallengeBrowseMap' - -let basicProps = null -let challenge = null - -beforeEach(() => { - challenge = {id: 123} - - basicProps = { - browsedChallenge: challenge, - mapBounds: { - challengeBrowse: { - challengeId: challenge.id, - bounds: toLatLngBounds([0, 0, 0, 0]), - } - }, - source: {id: 'foo'}, - visibleOverlays: [], - taskMarkers: [], - setChallengeBrowseMapBounds: jest.fn(), - } -}) - -test("it renders a full screen map", () => { - const wrapper = shallow( - - ) - - expect(wrapper.find('.full-screen-map').exists()).toBe(true) - - expect(wrapper).toMatchSnapshot() -}) - -test("doesn't rerender simply because the map bounds change", () => { - const wrapper = shallow( - - ) - - const newProps = _cloneDeep(basicProps) - newProps.mapBounds.bounds = toLatLngBounds([-20, -20, 20, 20]) - - expect(wrapper.instance().shouldComponentUpdate(newProps)).toBe(false) -}) - -test("rerenders if the default layer id changes", () => { - const wrapper = shallow( - - ) - - const newProps = _cloneDeep(basicProps) - newProps.source = {id: 'bar'} - - expect(wrapper.instance().shouldComponentUpdate(newProps)).toBe(true) -}) - -test("rerenders if the challenge being browsed changes", () => { - const wrapper = shallow( - - ) - - const newProps = _cloneDeep(basicProps) - newProps.browsedChallenge = {id: 456} - - expect(wrapper.instance().shouldComponentUpdate(newProps)).toBe(true) -}) - -test("rerenders if the challenge's tasks have loaded", () => { - basicProps.tasksLoading = true - const wrapper = shallow( - - ) - - const newProps = _cloneDeep(basicProps) - newProps.tasksLoading = false - - expect(wrapper.instance().shouldComponentUpdate(newProps)).toBe(true) -}) - -test("moving the map signals that the challenge bounds are to be updated", () => { - const bounds = [0, 0, 0, 0] - const zoom = 3 - - const wrapper = shallow( - - ) - - wrapper.instance().updateBounds(bounds, zoom, false) - expect( - basicProps.setChallengeBrowseMapBounds - ).toBeCalledWith(basicProps.browsedChallenge.id, bounds, zoom) -}) - -test("a busy indicator is displayed if tasks are loading", () => { - basicProps.tasksLoading = true - - const wrapper = shallow( - - ) - - expect(wrapper.find('BusySpinner').exists()).toBe(true) - - expect(wrapper).toMatchSnapshot() -}) - -test("the busy indicator is removed once tasks are done loading", () => { - basicProps.tasksLoading = false - - const wrapper = shallow( - - ) - - expect(wrapper.find('BusySpinner').exists()).toBe(false) - - expect(wrapper).toMatchSnapshot() -}) diff --git a/src/components/ChallengeBrowseMap/__snapshots__/ChallengeBrowseMap.test.js.snap b/src/components/ChallengeBrowseMap/__snapshots__/ChallengeBrowseMap.test.js.snap deleted file mode 100644 index 2ff26ce58..000000000 --- a/src/components/ChallengeBrowseMap/__snapshots__/ChallengeBrowseMap.test.js.snap +++ /dev/null @@ -1,1716 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`a busy indicator is displayed if tasks are loading 1`] = ` -ShallowWrapper { - "length": 1, - Symbol(enzyme.__root__): [Circular], - Symbol(enzyme.__unrendered__): , - Symbol(enzyme.__renderer__): Object { - "batchedUpdates": [Function], - "getNode": [Function], - "render": [Function], - "simulateEvent": [Function], - "unmount": [Function], - }, - Symbol(enzyme.__node__): Object { - "instance": null, - "key": "123", - "nodeType": "host", - "props": Object { - "children": Array [ - , - - - - , - , - ], - "className": "full-screen-map", - }, - "ref": null, - "rendered": Array [ - Object { - "instance": null, - "key": undefined, - "nodeType": "class", - "props": Object { - "browsedChallenge": Object { - "id": 123, - }, - "mapBounds": Object { - "challengeBrowse": Object { - "bounds": Object { - "_northEast": Object { - "lat": 0, - "lng": 0, - }, - "_southWest": Object { - "lat": 0, - "lng": 0, - }, - }, - "challengeId": 123, - }, - }, - "setChallengeBrowseMapBounds": [MockFunction], - "source": Object { - "id": "foo", - }, - "taskMarkers": Array [], - "tasksLoading": true, - "visibleOverlays": Array [], - }, - "ref": null, - "rendered": null, - "type": [Function], - }, - Object { - "instance": null, - "key": undefined, - "nodeType": "class", - "props": Object { - "animate": true, - "animateFeatures": false, - "center": Object { - "lat": 0, - "lng": 45, - }, - "children": Array [ - , - , - Array [], - false, - ], - "features": undefined, - "initialBounds": null, - "justFitFeatures": false, - "maxZoom": 18, - "minZoom": 2, - "onBoundsChange": [Function], - "setInitialBounds": false, - "worldCopyJump": true, - "zoom": 3, - "zoomControl": false, - }, - "ref": null, - "rendered": Array [ - Object { - "instance": null, - "key": undefined, - "nodeType": "class", - "props": Object { - "position": "topright", - }, - "ref": null, - "rendered": null, - "type": [Function], - }, - Object { - "instance": null, - "key": undefined, - "nodeType": "class", - "props": Object { - "browsedChallenge": Object { - "id": 123, - }, - "mapBounds": Object { - "challengeBrowse": Object { - "bounds": Object { - "_northEast": Object { - "lat": 0, - "lng": 0, - }, - "_southWest": Object { - "lat": 0, - "lng": 0, - }, - }, - "challengeId": 123, - }, - }, - "setChallengeBrowseMapBounds": [MockFunction], - "source": Object { - "id": "foo", - }, - "taskMarkers": Array [], - "tasksLoading": true, - "visibleOverlays": Array [], - "zIndex": 1, - }, - "ref": null, - "rendered": null, - "type": [Function], - }, - false, - ], - "type": [Function], - }, - Object { - "instance": null, - "key": undefined, - "nodeType": "class", - "props": Object {}, - "ref": null, - "rendered": null, - "type": [Function], - }, - ], - "type": "div", - }, - Symbol(enzyme.__nodes__): Array [ - Object { - "instance": null, - "key": "123", - "nodeType": "host", - "props": Object { - "children": Array [ - , - - - - , - , - ], - "className": "full-screen-map", - }, - "ref": null, - "rendered": Array [ - Object { - "instance": null, - "key": undefined, - "nodeType": "class", - "props": Object { - "browsedChallenge": Object { - "id": 123, - }, - "mapBounds": Object { - "challengeBrowse": Object { - "bounds": Object { - "_northEast": Object { - "lat": 0, - "lng": 0, - }, - "_southWest": Object { - "lat": 0, - "lng": 0, - }, - }, - "challengeId": 123, - }, - }, - "setChallengeBrowseMapBounds": [MockFunction], - "source": Object { - "id": "foo", - }, - "taskMarkers": Array [], - "tasksLoading": true, - "visibleOverlays": Array [], - }, - "ref": null, - "rendered": null, - "type": [Function], - }, - Object { - "instance": null, - "key": undefined, - "nodeType": "class", - "props": Object { - "animate": true, - "animateFeatures": false, - "center": Object { - "lat": 0, - "lng": 45, - }, - "children": Array [ - , - , - Array [], - false, - ], - "features": undefined, - "initialBounds": null, - "justFitFeatures": false, - "maxZoom": 18, - "minZoom": 2, - "onBoundsChange": [Function], - "setInitialBounds": false, - "worldCopyJump": true, - "zoom": 3, - "zoomControl": false, - }, - "ref": null, - "rendered": Array [ - Object { - "instance": null, - "key": undefined, - "nodeType": "class", - "props": Object { - "position": "topright", - }, - "ref": null, - "rendered": null, - "type": [Function], - }, - Object { - "instance": null, - "key": undefined, - "nodeType": "class", - "props": Object { - "browsedChallenge": Object { - "id": 123, - }, - "mapBounds": Object { - "challengeBrowse": Object { - "bounds": Object { - "_northEast": Object { - "lat": 0, - "lng": 0, - }, - "_southWest": Object { - "lat": 0, - "lng": 0, - }, - }, - "challengeId": 123, - }, - }, - "setChallengeBrowseMapBounds": [MockFunction], - "source": Object { - "id": "foo", - }, - "taskMarkers": Array [], - "tasksLoading": true, - "visibleOverlays": Array [], - "zIndex": 1, - }, - "ref": null, - "rendered": null, - "type": [Function], - }, - false, - ], - "type": [Function], - }, - Object { - "instance": null, - "key": undefined, - "nodeType": "class", - "props": Object {}, - "ref": null, - "rendered": null, - "type": [Function], - }, - ], - "type": "div", - }, - ], - Symbol(enzyme.__options__): Object { - "adapter": ReactSixteenAdapter { - "options": Object { - "enableComponentDidUpdateOnSetState": true, - }, - }, - }, -} -`; - -exports[`it renders a full screen map 1`] = ` -ShallowWrapper { - "length": 1, - Symbol(enzyme.__root__): [Circular], - Symbol(enzyme.__unrendered__): , - Symbol(enzyme.__renderer__): Object { - "batchedUpdates": [Function], - "getNode": [Function], - "render": [Function], - "simulateEvent": [Function], - "unmount": [Function], - }, - Symbol(enzyme.__node__): Object { - "instance": null, - "key": "123", - "nodeType": "host", - "props": Object { - "children": Array [ - , - - - - , - false, - ], - "className": "full-screen-map", - }, - "ref": null, - "rendered": Array [ - Object { - "instance": null, - "key": undefined, - "nodeType": "class", - "props": Object { - "browsedChallenge": Object { - "id": 123, - }, - "mapBounds": Object { - "challengeBrowse": Object { - "bounds": Object { - "_northEast": Object { - "lat": 0, - "lng": 0, - }, - "_southWest": Object { - "lat": 0, - "lng": 0, - }, - }, - "challengeId": 123, - }, - }, - "setChallengeBrowseMapBounds": [MockFunction], - "source": Object { - "id": "foo", - }, - "taskMarkers": Array [], - "visibleOverlays": Array [], - }, - "ref": null, - "rendered": null, - "type": [Function], - }, - Object { - "instance": null, - "key": undefined, - "nodeType": "class", - "props": Object { - "animate": true, - "animateFeatures": false, - "center": Object { - "lat": 0, - "lng": 45, - }, - "children": Array [ - , - , - Array [], - false, - ], - "features": undefined, - "initialBounds": null, - "justFitFeatures": false, - "maxZoom": 18, - "minZoom": 2, - "onBoundsChange": [Function], - "setInitialBounds": false, - "worldCopyJump": true, - "zoom": 3, - "zoomControl": false, - }, - "ref": null, - "rendered": Array [ - Object { - "instance": null, - "key": undefined, - "nodeType": "class", - "props": Object { - "position": "topright", - }, - "ref": null, - "rendered": null, - "type": [Function], - }, - Object { - "instance": null, - "key": undefined, - "nodeType": "class", - "props": Object { - "browsedChallenge": Object { - "id": 123, - }, - "mapBounds": Object { - "challengeBrowse": Object { - "bounds": Object { - "_northEast": Object { - "lat": 0, - "lng": 0, - }, - "_southWest": Object { - "lat": 0, - "lng": 0, - }, - }, - "challengeId": 123, - }, - }, - "setChallengeBrowseMapBounds": [MockFunction], - "source": Object { - "id": "foo", - }, - "taskMarkers": Array [], - "visibleOverlays": Array [], - "zIndex": 1, - }, - "ref": null, - "rendered": null, - "type": [Function], - }, - false, - ], - "type": [Function], - }, - false, - ], - "type": "div", - }, - Symbol(enzyme.__nodes__): Array [ - Object { - "instance": null, - "key": "123", - "nodeType": "host", - "props": Object { - "children": Array [ - , - - - - , - false, - ], - "className": "full-screen-map", - }, - "ref": null, - "rendered": Array [ - Object { - "instance": null, - "key": undefined, - "nodeType": "class", - "props": Object { - "browsedChallenge": Object { - "id": 123, - }, - "mapBounds": Object { - "challengeBrowse": Object { - "bounds": Object { - "_northEast": Object { - "lat": 0, - "lng": 0, - }, - "_southWest": Object { - "lat": 0, - "lng": 0, - }, - }, - "challengeId": 123, - }, - }, - "setChallengeBrowseMapBounds": [MockFunction], - "source": Object { - "id": "foo", - }, - "taskMarkers": Array [], - "visibleOverlays": Array [], - }, - "ref": null, - "rendered": null, - "type": [Function], - }, - Object { - "instance": null, - "key": undefined, - "nodeType": "class", - "props": Object { - "animate": true, - "animateFeatures": false, - "center": Object { - "lat": 0, - "lng": 45, - }, - "children": Array [ - , - , - Array [], - false, - ], - "features": undefined, - "initialBounds": null, - "justFitFeatures": false, - "maxZoom": 18, - "minZoom": 2, - "onBoundsChange": [Function], - "setInitialBounds": false, - "worldCopyJump": true, - "zoom": 3, - "zoomControl": false, - }, - "ref": null, - "rendered": Array [ - Object { - "instance": null, - "key": undefined, - "nodeType": "class", - "props": Object { - "position": "topright", - }, - "ref": null, - "rendered": null, - "type": [Function], - }, - Object { - "instance": null, - "key": undefined, - "nodeType": "class", - "props": Object { - "browsedChallenge": Object { - "id": 123, - }, - "mapBounds": Object { - "challengeBrowse": Object { - "bounds": Object { - "_northEast": Object { - "lat": 0, - "lng": 0, - }, - "_southWest": Object { - "lat": 0, - "lng": 0, - }, - }, - "challengeId": 123, - }, - }, - "setChallengeBrowseMapBounds": [MockFunction], - "source": Object { - "id": "foo", - }, - "taskMarkers": Array [], - "visibleOverlays": Array [], - "zIndex": 1, - }, - "ref": null, - "rendered": null, - "type": [Function], - }, - false, - ], - "type": [Function], - }, - false, - ], - "type": "div", - }, - ], - Symbol(enzyme.__options__): Object { - "adapter": ReactSixteenAdapter { - "options": Object { - "enableComponentDidUpdateOnSetState": true, - }, - }, - }, -} -`; - -exports[`the busy indicator is removed once tasks are done loading 1`] = ` -ShallowWrapper { - "length": 1, - Symbol(enzyme.__root__): [Circular], - Symbol(enzyme.__unrendered__): , - Symbol(enzyme.__renderer__): Object { - "batchedUpdates": [Function], - "getNode": [Function], - "render": [Function], - "simulateEvent": [Function], - "unmount": [Function], - }, - Symbol(enzyme.__node__): Object { - "instance": null, - "key": "123", - "nodeType": "host", - "props": Object { - "children": Array [ - , - - - - , - false, - ], - "className": "full-screen-map", - }, - "ref": null, - "rendered": Array [ - Object { - "instance": null, - "key": undefined, - "nodeType": "class", - "props": Object { - "browsedChallenge": Object { - "id": 123, - }, - "mapBounds": Object { - "challengeBrowse": Object { - "bounds": Object { - "_northEast": Object { - "lat": 0, - "lng": 0, - }, - "_southWest": Object { - "lat": 0, - "lng": 0, - }, - }, - "challengeId": 123, - }, - }, - "setChallengeBrowseMapBounds": [MockFunction], - "source": Object { - "id": "foo", - }, - "taskMarkers": Array [], - "tasksLoading": false, - "visibleOverlays": Array [], - }, - "ref": null, - "rendered": null, - "type": [Function], - }, - Object { - "instance": null, - "key": undefined, - "nodeType": "class", - "props": Object { - "animate": true, - "animateFeatures": false, - "center": Object { - "lat": 0, - "lng": 45, - }, - "children": Array [ - , - , - Array [], - false, - ], - "features": undefined, - "initialBounds": null, - "justFitFeatures": false, - "maxZoom": 18, - "minZoom": 2, - "onBoundsChange": [Function], - "setInitialBounds": false, - "worldCopyJump": true, - "zoom": 3, - "zoomControl": false, - }, - "ref": null, - "rendered": Array [ - Object { - "instance": null, - "key": undefined, - "nodeType": "class", - "props": Object { - "position": "topright", - }, - "ref": null, - "rendered": null, - "type": [Function], - }, - Object { - "instance": null, - "key": undefined, - "nodeType": "class", - "props": Object { - "browsedChallenge": Object { - "id": 123, - }, - "mapBounds": Object { - "challengeBrowse": Object { - "bounds": Object { - "_northEast": Object { - "lat": 0, - "lng": 0, - }, - "_southWest": Object { - "lat": 0, - "lng": 0, - }, - }, - "challengeId": 123, - }, - }, - "setChallengeBrowseMapBounds": [MockFunction], - "source": Object { - "id": "foo", - }, - "taskMarkers": Array [], - "tasksLoading": false, - "visibleOverlays": Array [], - "zIndex": 1, - }, - "ref": null, - "rendered": null, - "type": [Function], - }, - false, - ], - "type": [Function], - }, - false, - ], - "type": "div", - }, - Symbol(enzyme.__nodes__): Array [ - Object { - "instance": null, - "key": "123", - "nodeType": "host", - "props": Object { - "children": Array [ - , - - - - , - false, - ], - "className": "full-screen-map", - }, - "ref": null, - "rendered": Array [ - Object { - "instance": null, - "key": undefined, - "nodeType": "class", - "props": Object { - "browsedChallenge": Object { - "id": 123, - }, - "mapBounds": Object { - "challengeBrowse": Object { - "bounds": Object { - "_northEast": Object { - "lat": 0, - "lng": 0, - }, - "_southWest": Object { - "lat": 0, - "lng": 0, - }, - }, - "challengeId": 123, - }, - }, - "setChallengeBrowseMapBounds": [MockFunction], - "source": Object { - "id": "foo", - }, - "taskMarkers": Array [], - "tasksLoading": false, - "visibleOverlays": Array [], - }, - "ref": null, - "rendered": null, - "type": [Function], - }, - Object { - "instance": null, - "key": undefined, - "nodeType": "class", - "props": Object { - "animate": true, - "animateFeatures": false, - "center": Object { - "lat": 0, - "lng": 45, - }, - "children": Array [ - , - , - Array [], - false, - ], - "features": undefined, - "initialBounds": null, - "justFitFeatures": false, - "maxZoom": 18, - "minZoom": 2, - "onBoundsChange": [Function], - "setInitialBounds": false, - "worldCopyJump": true, - "zoom": 3, - "zoomControl": false, - }, - "ref": null, - "rendered": Array [ - Object { - "instance": null, - "key": undefined, - "nodeType": "class", - "props": Object { - "position": "topright", - }, - "ref": null, - "rendered": null, - "type": [Function], - }, - Object { - "instance": null, - "key": undefined, - "nodeType": "class", - "props": Object { - "browsedChallenge": Object { - "id": 123, - }, - "mapBounds": Object { - "challengeBrowse": Object { - "bounds": Object { - "_northEast": Object { - "lat": 0, - "lng": 0, - }, - "_southWest": Object { - "lat": 0, - "lng": 0, - }, - }, - "challengeId": 123, - }, - }, - "setChallengeBrowseMapBounds": [MockFunction], - "source": Object { - "id": "foo", - }, - "taskMarkers": Array [], - "tasksLoading": false, - "visibleOverlays": Array [], - "zIndex": 1, - }, - "ref": null, - "rendered": null, - "type": [Function], - }, - false, - ], - "type": [Function], - }, - false, - ], - "type": "div", - }, - ], - Symbol(enzyme.__options__): Object { - "adapter": ReactSixteenAdapter { - "options": Object { - "enableComponentDidUpdateOnSetState": true, - }, - }, - }, -} -`; diff --git a/src/components/ChallengeDetail/ChallengeDetail.js b/src/components/ChallengeDetail/ChallengeDetail.js index 819a543c2..95ebb8fc1 100644 --- a/src/components/ChallengeDetail/ChallengeDetail.js +++ b/src/components/ChallengeDetail/ChallengeDetail.js @@ -4,7 +4,6 @@ import { FormattedMessage, FormattedRelative, injectIntl } from 'react-intl' import _isObject from 'lodash/isObject' import _get from 'lodash/get' import _findIndex from 'lodash/findIndex' -import _isNumber from 'lodash/isNumber' import parse from 'date-fns/parse' import MapPane from '../EnhancedMap/MapPane/MapPane' import TaskClusterMap from '../TaskClusterMap/TaskClusterMap' @@ -25,9 +24,8 @@ import WithChallengeTaskClusters from '../HOCs/WithChallengeTaskClusters/WithCha import WithTaskClusterMarkers from '../HOCs/WithTaskClusterMarkers/WithTaskClusterMarkers' import { fromLatLngBounds } from '../../services/MapBounds/MapBounds' -const ClusterMap = - WithChallengeTaskClusters( - WithTaskClusterMarkers(TaskClusterMap('challengeDetail'))) +const ClusterMap = WithChallengeTaskClusters( + WithTaskClusterMarkers(TaskClusterMap('challengeDetail'))) /** @@ -58,14 +56,7 @@ export class ChallengeDetail extends Component { let saveControl = null let startControl = null - let startableChallenge = true - const tasksComplete = _isNumber(_get(challenge, 'actions.available')) ? - challenge.actions.available === 0 : false - - if (challenge.deleted || tasksComplete || - !isUsableChallengeStatus(challenge.status)) { - startableChallenge = false - } + const startableChallenge = !challenge.deleted && isUsableChallengeStatus(challenge.status) if (_isObject(this.props.user) && !challenge.isVirtual) { if ( @@ -129,8 +120,8 @@ export class ChallengeDetail extends Component { const dataOriginDateText = !challenge.dataOriginDate ? null : this.props.intl.formatMessage(messages.dataOriginDateLabel, - {refreshDate: this.props.intl.formatDate(new Date(challenge.lastTaskRefresh)), - sourceDate: this.props.intl.formatDate(new Date(challenge.dataOriginDate))}) + {refreshDate: this.props.intl.formatDate(parse(challenge.lastTaskRefresh)), + sourceDate: this.props.intl.formatDate(parse(challenge.dataOriginDate))}) const map = - - } - selection={ - notFiltering ? - localizedLocationLabels.any : - localizedLocationLabels[this.props.searchFilters.location] - } - onClick={dropdown.toggleDropdownVisible} - selectionClassName={notFiltering ? null : 'mr-text-yellow'} +
+ + : + + + this.updateFilter(ChallengeLocation.intersectingMapBounds)} + onChange={_noop} /> - } - dropdownContent={dropdown => - + {localizedLocationLabels[ChallengeLocation.intersectingMapBounds]} + + + + this.updateFilter(null)} + onChange={_noop} /> - } - /> + + +
) } } -const ListLocationItems = function(props) { - const menuItems = _map(ChallengeLocation, (location, name) => ( -
  • - {/* eslint-disable-next-line jsx-a11y/anchor-is-valid */} - props.updateFilter(location, props.closeDropdown)}> - {props.locationLabels[name]} - -
  • - )) - - // Add 'Any' option to start of dropdown - menuItems.unshift( -
  • - {/* eslint-disable-next-line jsx-a11y/anchor-is-valid */} - props.updateFilter(null, props.closeDropdown)}> - {props.locationLabels.any} - -
  • - ) - - return ( -
      - {menuItems} -
    - ) -} - FilterByLocation.propTypes = { /** The current map bounds */ mapBounds: PropTypes.object, diff --git a/src/components/ChallengePane/ChallengePane.js b/src/components/ChallengePane/ChallengePane.js index 1b0d122c0..cb228e705 100644 --- a/src/components/ChallengePane/ChallengePane.js +++ b/src/components/ChallengePane/ChallengePane.js @@ -1,10 +1,12 @@ import React, { Component } from 'react' +import { injectIntl } from 'react-intl' import _isEqual from 'lodash/isEqual' import _get from 'lodash/get' +import { Popup } from 'react-leaflet' import ChallengeFilterSubnav from './ChallengeFilterSubnav/ChallengeFilterSubnav' +import FilterByLocation from './ChallengeFilterSubnav/FilterByLocation' import MapPane from '../EnhancedMap/MapPane/MapPane' -import ChallengeSearchMap from '../ChallengeSearchMap/ChallengeSearchMap' -import ChallengeBrowseMap from '../ChallengeBrowseMap/ChallengeBrowseMap' +import TaskClusterMap from '../TaskClusterMap/TaskClusterMap' import CongratulateModal from '../CongratulateModal/CongratulateModal' import ChallengeEndModal from '../ChallengeEndModal/ChallengeEndModal' import ChallengeResultList from './ChallengeResultList/ChallengeResultList' @@ -16,24 +18,21 @@ import WithChallengeSearch from '../HOCs/WithSearch/WithChallengeSearch' import WithSearchResults from '../HOCs/WithSearchResults/WithSearchResults' import WithBrowsedChallenge from '../HOCs/WithBrowsedChallenge/WithBrowsedChallenge' import WithClusteredTasks from '../HOCs/WithClusteredTasks/WithClusteredTasks' -import WithTaskMarkers from '../HOCs/WithTaskMarkers/WithTaskMarkers' import WithMapBoundedTasks from '../HOCs/WithMapBoundedTasks/WithMapBoundedTasks' import WithStatus from '../HOCs/WithStatus/WithStatus' +import WithChallengeTaskClusters from '../HOCs/WithChallengeTaskClusters/WithChallengeTaskClusters' +import WithTaskClusterMarkers from '../HOCs/WithTaskClusterMarkers/WithTaskClusterMarkers' +import WithCurrentUser from '../HOCs/WithCurrentUser/WithCurrentUser' +import { fromLatLngBounds } from '../../services/MapBounds/MapBounds' +import { ChallengeStatus } from '../../services/Challenge/ChallengeStatus/ChallengeStatus' +import TaskChallengeMarkerContent from './TaskChallengeMarkerContent' // Setup child components with necessary HOCs const ChallengeResults = WithStatus(ChallengeResultList) -const BrowseMap = WithTaskMarkers(ChallengeBrowseMap) -let SearchMap = null - -// If the map-bounded task browsing feature is enabled, set up the ChallengeSearchMap -// to use it. -if (_get(process.env, - 'REACT_APP_FEATURE_BOUNDED_TASK_BROWSING') === 'enabled') { - SearchMap = WithTaskMarkers(ChallengeSearchMap, 'mapBoundedTasks') -} -else { - SearchMap = ChallengeSearchMap -} +const ClusterMap = WithChallengeTaskClusters( + WithTaskClusterMarkers(TaskClusterMap('challenges')), + true) +const LocationFilter = WithCurrentUser(FilterByLocation) /** * ChallengePane represents the top-level view when the user is browsing, @@ -58,12 +57,34 @@ export class ChallengePane extends Component { this.setState({sidebarMinimized: !this.state.sidebarMinimized}) } + componentDidUpdate(prevProps) { + if (!_isEqual(this.state.bounds, _get(this.props, 'mapBounds.bounds'))) { + this.setState({bounds: _get(this.props, 'mapBounds.bounds'), + fromUserAction: _get(this.props, 'mapBounds.fromUserAction')}) + } + } + shouldComponentUpdate(nextProps, nextState) { return !_isEqual(this.props, nextProps) || !_isEqual(this.state, nextState) } render() { - const Map = this.props.browsedChallenge ? BrowseMap : SearchMap + const challengeStatus = [ChallengeStatus.ready, + ChallengeStatus.partiallyLoaded, + ChallengeStatus.none, + ChallengeStatus.empty] + + const showMarkerPopup = (markerData) => { + return ( + + + + ) + } + return (
    {_get(this.props, 'history.location.state.congratulate', false) && @@ -77,12 +98,26 @@ export class ChallengePane extends Component {
    - +
    + + +
    - + { + this.props.updateChallengeSearchMapBounds(bounds, fromUserAction) + }} + allowClusterToggle + showTaskCount + {...this.props} />
    @@ -94,17 +129,17 @@ export class ChallengePane extends Component { export default WithChallenges( WithChallengeSearch( - WithFilteredChallenges( - WithSearchResults( - WithMapBoundedTasks( - WithClusteredTasks( + WithClusteredTasks( + WithMapBoundedTasks( + WithFilteredChallenges( + WithSearchResults( WithStartChallenge( - WithBrowsedChallenge(ChallengePane) - ) + WithBrowsedChallenge(injectIntl(ChallengePane)) + ), + 'challenges', + 'challenges' ) - ), - 'challenges', - 'challenges' + ) ) ) ) diff --git a/src/components/ChallengeSearchMap/Messages.js b/src/components/ChallengePane/Messages.js similarity index 54% rename from src/components/ChallengeSearchMap/Messages.js rename to src/components/ChallengePane/Messages.js index 487421c03..1c8ae5f54 100644 --- a/src/components/ChallengeSearchMap/Messages.js +++ b/src/components/ChallengePane/Messages.js @@ -1,11 +1,11 @@ import { defineMessages } from 'react-intl' /** - * Internationalized messages for use with ChallengeSearchMap + * Internationalized messages for use with ChallengePane */ export default defineMessages({ startChallengeLabel: { - id: "ChallengeSearchMap.controls.startChallenge.label", + id: "ChallengePane.controls.startChallenge.label", defaultMessage: "Start Challenge" - }, + } }) diff --git a/src/components/ChallengePane/TaskChallengeMarkerContent.js b/src/components/ChallengePane/TaskChallengeMarkerContent.js new file mode 100644 index 000000000..3255f1231 --- /dev/null +++ b/src/components/ChallengePane/TaskChallengeMarkerContent.js @@ -0,0 +1,41 @@ +import React, { Component } from 'react' +import _get from 'lodash/get' +import WithLoadedTask from '../HOCs/WithLoadedTask/WithLoadedTask' +import messages from './Messages' + +/** + * The content to show in the popup when a task marker is clicked. + */ +class TaskChallengeMarkerContent extends Component { + render() { + const markerData = this.props.marker + return ( + + ) + } +} + +export default WithLoadedTask(TaskChallengeMarkerContent) diff --git a/src/components/ChallengeSearchMap/ChallengeSearchMap.js b/src/components/ChallengeSearchMap/ChallengeSearchMap.js deleted file mode 100644 index 4f93db276..000000000 --- a/src/components/ChallengeSearchMap/ChallengeSearchMap.js +++ /dev/null @@ -1,200 +0,0 @@ -import React, { Component } from 'react' -import PropTypes from 'prop-types' -import { injectIntl } from 'react-intl' -import { Marker, Popup, ZoomControl } from 'react-leaflet' -import MarkerClusterGroup from 'react-leaflet-markercluster' -import _get from 'lodash/get' -import _map from 'lodash/map' -import _isEqual from 'lodash/isEqual' -import { latLng } from 'leaflet' -import { layerSourceWithId } from '../../services/VisibleLayer/LayerSources' -import EnhancedMap from '../EnhancedMap/EnhancedMap' -import SourcedTileLayer from '../EnhancedMap/SourcedTileLayer/SourcedTileLayer' -import LayerToggle from '../EnhancedMap/LayerToggle/LayerToggle' -import SearchControl from '../EnhancedMap/SearchControl/SearchControl' -import WithVisibleLayer from '../HOCs/WithVisibleLayer/WithVisibleLayer' -import WithIntersectingOverlays - from '../HOCs/WithIntersectingOverlays/WithIntersectingOverlays' -import BusySpinner from '../BusySpinner/BusySpinner' -import messages from './Messages' - -/** - * ChallengeSearchMap presents a specially configured EnhancedMap that can be used to - * search for challenges geographically (i.e., challenges with tasks within the - * map bounds). It also can be used to visually represent geographic boundaries - * while deciding on a challenge, such as when the user applies the "Near Me" - * challenge filter. - * - * Initial map bounds can be provided, and the ChallengeSearchMap will also update the - * current bounds in the redux store as the map is moved so that other components - * (like challenge results) can apply the bounds appropriately. - * - * > Note: because this map both updates bounds and accepts bounds, it must be - * > careful to avoid an infinite loop. To do this, it treats the accepted - * > mapBounds as initial bounds and only honors new bounds if they have the - * > fromUserAction flag set to true. This component never sets that flag - * > itself when dispatching new map bounds. - * - * @see See EnhancedMap - * - * @author [Neil Rotstan](https://github.com/nrotstan) - */ -export class ChallengeSearchMap extends Component { - currentBounds = null - - shouldComponentUpdate(nextProps, nextState) { - // We want to be careful about not constantly re-rendering, so we only - // re-render if something meaningful changes: - - // the base layer has changed, or - if (_get(nextProps, 'source.id') !== _get(this.props, 'source.id')) { - return true - } - - // the available overlays have changed, or - if (!_isEqual(nextProps.intersectingOverlays, this.props.intersectingOverlays)) { - return true - } - - // the visible overlays have changed, or - if (nextProps.visibleOverlays.length !== this.props.visibleOverlays.length) { - return true - } - - // the task markers have changed - if (_get(nextProps, 'taskMarkers.length') !== - _get(this.props, 'taskMarkers.length')) { - return true - } - - // the loading status of tasks has been changed - if (!!nextProps.tasksLoading !== !!this.props.tasksLoading) { - return true - } - - // it's the first time we've been given specific map bounds, or - if (this.props.mapBounds === null && nextProps.mapBounds !== null) { - return true - } - - // if mapbounds change we need to also update map - if ((!this.props.mapBounds.bounds && nextProps.mapBounds.bounds) || - (nextProps.mapBounds.bounds && !this.props.mapBounds.bounds.equals(nextProps.mapBounds.bounds))) { - return true - } - - // a change in map bounds was initiated by a user action, as opposed - // to simply navigating around in the map. - if (_get(nextProps, 'mapBounds.fromUserAction')) { - return true - } - - return false - } - - /** - * Signal a change to the current challenge search map bounds in response to a - * change to the map (panning or zooming). - * - * @private - */ - updateBounds = (bounds, zoom, fromUserAction=false) => { - // If the new bounds are the same as the old, do nothing. - if (this.currentBounds && this.currentBounds.equals(bounds)) { - return - } - - this.currentBounds = bounds - this.props.updateChallengeSearchMapBounds(bounds, fromUserAction) - } - - - render() { - const hasMarkers = _get(this.props, 'taskMarkers.length', 0) > 0 - - const overlayLayers = _map(this.props.visibleOverlays, (layerId, index) => - - ) - - // If the app is still loading then we have no initialBounds - const initialBounds = !this.props.loadedFromRouteDone ? null : _get(this.props, 'mapBounds.bounds') - - const renderedMarkers = _map(this.props.taskMarkers, markerData => ( - - - - )) - - return ( -
    - - this.props.updateChallengeSearchMapBounds(bounds, true)} - /> - - - - {overlayLayers} - {hasMarkers && {renderedMarkers}} - - - {!!this.props.tasksLoading && } -
    - ) - } -} - -const TaskMarkerPopup = props => ( - - - -) - -ChallengeSearchMap.propTypes = { - /** - * Initial bounds at which to render the map. To avoid an infinite loop, this - * will only be honored once, unless the fromUserAction flag is set to true - * on updated mapBounds values. - */ - mapBounds: PropTypes.object, - /** Invoked when the user moves the challenge Search map */ - updateChallengeSearchMapBounds: PropTypes.func.isRequired, - /** The currently enabled challenge filter, if any */ - searchFilters: PropTypes.object, - /** Task markers to display */ - taskMarkers: PropTypes.array, -} - -export default WithVisibleLayer( - WithIntersectingOverlays( - injectIntl(ChallengeSearchMap), - 'challenges' - ) - ) diff --git a/src/components/ChallengeSearchMap/ChallengeSearchMap.test.js b/src/components/ChallengeSearchMap/ChallengeSearchMap.test.js deleted file mode 100644 index 5f082bf1b..000000000 --- a/src/components/ChallengeSearchMap/ChallengeSearchMap.test.js +++ /dev/null @@ -1,77 +0,0 @@ -import React from 'react' -import _cloneDeep from 'lodash/cloneDeep' -import { toLatLngBounds } from '../../services/MapBounds/MapBounds' -import { ChallengeLocation } - from '../../services/Challenge/ChallengeLocation/ChallengeLocation' -import { ChallengeSearchMap } from './ChallengeSearchMap' - -let basicProps = null - -beforeEach(() => { - basicProps = { - mapBounds: { - bounds: toLatLngBounds([0, 0, 0, 0]), - }, - source: {id: 'foo'}, - visibleOverlays: [], - updateChallengeSearchMapBounds: jest.fn(), - } -}) - -test("it renders a full screen map", () => { - const wrapper = shallow( - - ) - - expect(wrapper.find('.full-screen-map').exists()).toBe(true) - - expect(wrapper).toMatchSnapshot() -}) - -test("rerenders if the map bounds change is response to an external user action", () => { - const wrapper = shallow( - - ) - - const newProps = _cloneDeep(basicProps) - newProps.mapBounds.bounds = toLatLngBounds([-20, -20, 20, 20]) - newProps.mapBounds.fromUserAction = true - - expect(wrapper.instance().shouldComponentUpdate(newProps)).toBe(true) -}) - -test("rerenders if the default layer id changes", () => { - const wrapper = shallow( - - ) - - const newProps = _cloneDeep(basicProps) - newProps.source = {id: 'bar'} - - expect(wrapper.instance().shouldComponentUpdate(newProps)).toBe(true) -}) - -test("moving the map signals that the challenge search map bounds should be updated", () => { - const bounds = [0, 0, 0, 0] - const zoom = 3 - - const wrapper = shallow( - - ) - - wrapper.instance().updateBounds(bounds, zoom, false) - expect(basicProps.updateChallengeSearchMapBounds).toBeCalledWith(bounds, false) -}) - -test("moving the map signals that the challenges should be updated if filtering on map bounds", () => { - basicProps.searchFilters = {location: ChallengeLocation.withinMapBounds} - const bounds = [0, 0, 0, 0] - const zoom = 3 - - const wrapper = shallow( - - ) - - wrapper.instance().updateBounds(bounds, zoom, false) - expect(basicProps.updateChallengeSearchMapBounds).toBeCalledWith(bounds, false) -}) diff --git a/src/components/ChallengeSearchMap/__snapshots__/ChallengeSearchMap.test.js.snap b/src/components/ChallengeSearchMap/__snapshots__/ChallengeSearchMap.test.js.snap deleted file mode 100644 index a58422ca2..000000000 --- a/src/components/ChallengeSearchMap/__snapshots__/ChallengeSearchMap.test.js.snap +++ /dev/null @@ -1,467 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`it renders a full screen map 1`] = ` -ShallowWrapper { - "length": 1, - Symbol(enzyme.__root__): [Circular], - Symbol(enzyme.__unrendered__): , - Symbol(enzyme.__renderer__): Object { - "batchedUpdates": [Function], - "getNode": [Function], - "render": [Function], - "simulateEvent": [Function], - "unmount": [Function], - }, - Symbol(enzyme.__node__): Object { - "instance": null, - "key": "ChallengeSearchMap", - "nodeType": "host", - "props": Object { - "children": Array [ - , - - - - , - false, - ], - "className": "full-screen-map", - }, - "ref": null, - "rendered": Array [ - Object { - "instance": null, - "key": undefined, - "nodeType": "class", - "props": Object { - "mapBounds": Object { - "bounds": Object { - "_northEast": Object { - "lat": 0, - "lng": 0, - }, - "_southWest": Object { - "lat": 0, - "lng": 0, - }, - }, - }, - "source": Object { - "id": "foo", - }, - "updateChallengeSearchMapBounds": [MockFunction], - "visibleOverlays": Array [], - }, - "ref": null, - "rendered": null, - "type": [Function], - }, - Object { - "instance": null, - "key": undefined, - "nodeType": "class", - "props": Object { - "animate": true, - "animateFeatures": false, - "center": Object { - "lat": 0, - "lng": 45, - }, - "children": Array [ - , - , - Array [], - false, - ], - "initialBounds": null, - "justFitFeatures": false, - "maxZoom": 18, - "minZoom": 2, - "onBoundsChange": [Function], - "setInitialBounds": false, - "worldCopyJump": true, - "zoom": 3, - "zoomControl": false, - }, - "ref": null, - "rendered": Array [ - Object { - "instance": null, - "key": undefined, - "nodeType": "class", - "props": Object { - "position": "topright", - }, - "ref": null, - "rendered": null, - "type": [Function], - }, - Object { - "instance": null, - "key": undefined, - "nodeType": "class", - "props": Object { - "mapBounds": Object { - "bounds": Object { - "_northEast": Object { - "lat": 0, - "lng": 0, - }, - "_southWest": Object { - "lat": 0, - "lng": 0, - }, - }, - }, - "source": Object { - "id": "foo", - }, - "updateChallengeSearchMapBounds": [MockFunction], - "visibleOverlays": Array [], - "zIndex": 1, - }, - "ref": null, - "rendered": null, - "type": [Function], - }, - false, - ], - "type": [Function], - }, - false, - ], - "type": "div", - }, - Symbol(enzyme.__nodes__): Array [ - Object { - "instance": null, - "key": "ChallengeSearchMap", - "nodeType": "host", - "props": Object { - "children": Array [ - , - - - - , - false, - ], - "className": "full-screen-map", - }, - "ref": null, - "rendered": Array [ - Object { - "instance": null, - "key": undefined, - "nodeType": "class", - "props": Object { - "mapBounds": Object { - "bounds": Object { - "_northEast": Object { - "lat": 0, - "lng": 0, - }, - "_southWest": Object { - "lat": 0, - "lng": 0, - }, - }, - }, - "source": Object { - "id": "foo", - }, - "updateChallengeSearchMapBounds": [MockFunction], - "visibleOverlays": Array [], - }, - "ref": null, - "rendered": null, - "type": [Function], - }, - Object { - "instance": null, - "key": undefined, - "nodeType": "class", - "props": Object { - "animate": true, - "animateFeatures": false, - "center": Object { - "lat": 0, - "lng": 45, - }, - "children": Array [ - , - , - Array [], - false, - ], - "initialBounds": null, - "justFitFeatures": false, - "maxZoom": 18, - "minZoom": 2, - "onBoundsChange": [Function], - "setInitialBounds": false, - "worldCopyJump": true, - "zoom": 3, - "zoomControl": false, - }, - "ref": null, - "rendered": Array [ - Object { - "instance": null, - "key": undefined, - "nodeType": "class", - "props": Object { - "position": "topright", - }, - "ref": null, - "rendered": null, - "type": [Function], - }, - Object { - "instance": null, - "key": undefined, - "nodeType": "class", - "props": Object { - "mapBounds": Object { - "bounds": Object { - "_northEast": Object { - "lat": 0, - "lng": 0, - }, - "_southWest": Object { - "lat": 0, - "lng": 0, - }, - }, - }, - "source": Object { - "id": "foo", - }, - "updateChallengeSearchMapBounds": [MockFunction], - "visibleOverlays": Array [], - "zIndex": 1, - }, - "ref": null, - "rendered": null, - "type": [Function], - }, - false, - ], - "type": [Function], - }, - false, - ], - "type": "div", - }, - ], - Symbol(enzyme.__options__): Object { - "adapter": ReactSixteenAdapter { - "options": Object { - "enableComponentDidUpdateOnSetState": true, - }, - }, - }, -} -`; diff --git a/src/components/Dropdown/Dropdown.js b/src/components/Dropdown/Dropdown.js index c7853f956..cfdfc56c6 100644 --- a/src/components/Dropdown/Dropdown.js +++ b/src/components/Dropdown/Dropdown.js @@ -41,7 +41,7 @@ class Dropdown extends Component {
    diff --git a/src/components/EnhancedMap/SearchControl/LocationSearchBox.js b/src/components/EnhancedMap/SearchControl/LocationSearchBox.js new file mode 100644 index 000000000..bbf1f65ae --- /dev/null +++ b/src/components/EnhancedMap/SearchControl/LocationSearchBox.js @@ -0,0 +1,95 @@ +import React, { Component } from 'react' +import _map from 'lodash/map' +import { FormattedMessage } from 'react-intl' +import WithNominatimSearch from '../../HOCs/WithNominatimSearch/WithNominatimSearch' +import SvgSymbol from '../../SvgSymbol/SvgSymbol' +import Dropdown from '../../Dropdown/Dropdown' +import messages from './Messages' + +/** + * Location SearchBox presents a map search box that can be used to execute + * geographic searches via Nominatim (name searches, lon/lat searches, etc.) + * + * @author [Kelli Rotstan](https://github.com/krotstan) + */ +export class LocationSearchBox extends Component { + state = { + showDropdown: false + } + + render() { + const resultItems = _map(this.props.nominatimResults, result => ( +
  • + +
  • + )) + + return ( +
    +
    +
    +
    + this.props.updateNominatimQuery(e.target.value)} + /> + +
    +
    + + {this.props.nominatimResults && this.state.showDropdown && + null} + dropdownContent={dropdown => + +
    + +
    + {resultItems.length === 0 ? : +
      this.setState({showDropdown: false})}> + {resultItems} +
    + } +
    + } + /> + } +
    +
    + ) + } +} + +export default WithNominatimSearch(LocationSearchBox) diff --git a/src/components/EnhancedMap/SearchControl/Messages.js b/src/components/EnhancedMap/SearchControl/Messages.js index 85f1251e1..92fbb254e 100644 --- a/src/components/EnhancedMap/SearchControl/Messages.js +++ b/src/components/EnhancedMap/SearchControl/Messages.js @@ -13,4 +13,9 @@ export default defineMessages({ id: 'EnhancedMap.SearchControl.noResults', defaultMessage: "No Results", }, + + nominatimQuery: { + id: 'EnhancedMap.SearchControl.nominatimQuery.placeholder', + defaultMessage: "Nominatim Query", + }, }) diff --git a/src/components/EnhancedMap/SearchControl/SearchControl.js b/src/components/EnhancedMap/SearchControl/SearchControl.js index 873428e21..232b4d673 100644 --- a/src/components/EnhancedMap/SearchControl/SearchControl.js +++ b/src/components/EnhancedMap/SearchControl/SearchControl.js @@ -45,7 +45,7 @@ export class SearchControl extends Component { this.props.updateNominatimQuery(e.target.value)} /> @@ -72,7 +72,7 @@ export class SearchControl extends Component {
    - {this.props.nominatimResults && + {this.props.nominatimResults &&
    {resultItems.length === 0 ? : diff --git a/src/components/HOCs/WithBrowsedChallenge/WithBrowsedChallenge.js b/src/components/HOCs/WithBrowsedChallenge/WithBrowsedChallenge.js index 9ec30f32d..baea64cfc 100644 --- a/src/components/HOCs/WithBrowsedChallenge/WithBrowsedChallenge.js +++ b/src/components/HOCs/WithBrowsedChallenge/WithBrowsedChallenge.js @@ -193,8 +193,7 @@ export const WithBrowsedChallenge = function(WrappedComponent) { } _WithBrowsedChallenge.propTypes = { - clusteredTasks: PropTypes.object, - fetchClusteredTasks: PropTypes.func.isRequired, + clusteredTasks: PropTypes.object } return _WithBrowsedChallenge diff --git a/src/components/HOCs/WithBrowsedChallenge/WithBrowsedChallenge.test.js b/src/components/HOCs/WithBrowsedChallenge/WithBrowsedChallenge.test.js index c057680a2..f36a18c66 100644 --- a/src/components/HOCs/WithBrowsedChallenge/WithBrowsedChallenge.test.js +++ b/src/components/HOCs/WithBrowsedChallenge/WithBrowsedChallenge.test.js @@ -75,7 +75,6 @@ test("the browsed challenge from the route match is passed down", () => { entities={basicState.entities} loadChallenge={loadChallenge} loadChallengeActions={loadChallengeActions} - fetchClusteredTasks={fetchTasks} history={history} /> ) @@ -88,7 +87,6 @@ test("clustered task loading is kicked off for a new browsed challenge", async ( entities={basicState.entities} loadChallenge={loadChallenge} loadChallengeActions={loadChallengeActions} - fetchClusteredTasks={fetchTasks} history={history} /> ) @@ -104,7 +102,6 @@ test("virtual challenges get virtual=true when fetching tasks", async () => { entities={basicState.entities} loadChallenge={loadChallenge} loadChallengeActions={loadChallengeActions} - fetchClusteredTasks={fetchTasks} history={history} virtualChallenge={challenge} /> ) diff --git a/src/components/HOCs/WithChallengeTaskClusters/WithChallengeTaskClusters.js b/src/components/HOCs/WithChallengeTaskClusters/WithChallengeTaskClusters.js index d07a1a578..5867b3631 100644 --- a/src/components/HOCs/WithChallengeTaskClusters/WithChallengeTaskClusters.js +++ b/src/components/HOCs/WithChallengeTaskClusters/WithChallengeTaskClusters.js @@ -5,13 +5,16 @@ import _omit from 'lodash/omit' import _cloneDeep from 'lodash/cloneDeep' import _get from 'lodash/get' import _isEqual from 'lodash/isEqual' -import _set from 'lodash/set' import _uniqueId from 'lodash/uniqueId' import _sum from 'lodash/sum' import _map from 'lodash/map' -import { fromLatLngBounds } from '../../../services/MapBounds/MapBounds' -import { fetchTaskClusters } from '../../../services/Task/Task' +import _set from 'lodash/set' +import _debounce from 'lodash/debounce' +import { fromLatLngBounds, + boundsWithinAllowedMaxDegrees } from '../../../services/MapBounds/MapBounds' +import { fetchTaskClusters } from '../../../services/Task/TaskClusters' import { fetchBoundedTasks } from '../../../services/Task/BoundedTask' +import { maxAllowedDegrees } from '../WithMapBoundedTasks/WithMapBoundedTasks' import { MAX_ZOOM, UNCLUSTER_THRESHOLD } from '../../TaskClusterMap/TaskClusterMap' @@ -21,7 +24,7 @@ import { MAX_ZOOM, UNCLUSTER_THRESHOLD } from '../../TaskClusterMap/TaskClusterM * * @author [Kelli Rotstan](https://github.com/krotstan) */ -export const WithChallengeTaskClusters = function(WrappedComponent) { +export const WithChallengeTaskClusters = function(WrappedComponent, storeTasks=false) { return class extends Component { state = { loading: false, @@ -31,41 +34,68 @@ export const WithChallengeTaskClusters = function(WrappedComponent) { taskCount: 0 } - updateBounds = (bounds, zoom) => { + updateBounds = (bounds, zoom, fromUserAction=false) => { if (this.props.criteria.boundingBox !== fromLatLngBounds(bounds).join(',')) { const criteria = _cloneDeep(this.props.criteria) criteria.boundingBox = fromLatLngBounds(bounds).join(',') - this.props.updateTaskFilterBounds(bounds, zoom) + this.props.updateTaskFilterBounds(bounds, zoom, fromUserAction) } } toggleShowAsClusters = () => { - this.setState({showAsClusters: !this.state.showAsClusters}) + this.fetchUpdatedClusters(!this.state.showAsClusters) } - fetchUpdatedClusters() { + fetchUpdatedClusters(wantToShowAsClusters) { if (!!_get(this.props, 'nearbyTasks.loading')) { return } const challengeId = _get(this.props, 'challenge.id', this.props.challengeId) - const showAsClusters = ((_get(this.props, 'criteria.zoom', 0) < MAX_ZOOM && - this.state.showAsClusters) || - !this.props.criteria.boundingBox) || - this.state.taskCount > UNCLUSTER_THRESHOLD + + // We need to fetch as clusters if any of the following: + // 1. not at max zoom in and + // user wants to see clusters or our task count is greater than our + // threshold (eg. 1000 tasks) + // 2. we have no bounding box + const showAsClusters = (_get(this.props, 'criteria.zoom', 0) < MAX_ZOOM && + (wantToShowAsClusters || this.state.taskCount > UNCLUSTER_THRESHOLD)) || + !this.props.criteria.boundingBox const currentFetchId = _uniqueId() - this.setState({loading: true, fetchId: currentFetchId, showAsClusters: showAsClusters}) - if (!showAsClusters) { - const criteria = _set(this.props.criteria, - 'filters.challengeId', - challengeId) + // If we have no challengeId and no bounding box we need to make sure + // we aren't searching the entire map. + if (!challengeId) { + const bounds = _get(this.props.criteria, 'boundingBox') + if (!bounds || !boundsWithinAllowedMaxDegrees(bounds, maxAllowedDegrees())) { + this.setState({clusters: {}, loading: false, taskCount: 0, showAsClusters: true, + mapZoomedOut: true}) + return + } + } + + this.setState({loading: true, fetchId: currentFetchId, showAsClusters: showAsClusters, mapZoomedOut: false}) - this.props.fetchBoundedTasks(criteria, UNCLUSTER_THRESHOLD + 1, true).then(results => { + if (!showAsClusters) { + const searchCriteria = _cloneDeep(this.props.criteria) + if (challengeId) { + _set(searchCriteria, + 'filters.challengeId', + challengeId) + } + searchCriteria.page = 0 + + // Fetch up to threshold+1 individual tasks (eg. 1001 tasks) + this.props.fetchBoundedTasks(searchCriteria, UNCLUSTER_THRESHOLD + 1, !storeTasks).then(results => { if (currentFetchId >= this.state.fetchId) { - if (results.totalCount > UNCLUSTER_THRESHOLD) { - this.props.fetchTaskClusters(challengeId, this.props.criteria - ).then(clusters => { + // If we retrieved 1001 tasks then there might be more tasks and + // they should be clustered. So fetch as clusters + // (unless we are zoomed all the way in already) + if (results.totalCount > UNCLUSTER_THRESHOLD && + _get(this.props, 'criteria.zoom', 0) < MAX_ZOOM) { + this.props.fetchTaskClusters(challengeId, searchCriteria + ).then(results => { + const clusters = results.clusters if (currentFetchId >= this.state.fetchId) { const taskCount = _sum(_map(clusters, c => c.numberOfPoints)) this.setState({clusters, loading: false, @@ -85,7 +115,8 @@ export const WithChallengeTaskClusters = function(WrappedComponent) { } else { this.props.fetchTaskClusters(challengeId, this.props.criteria - ).then(clusters => { + ).then(results => { + const clusters = results.clusters if (currentFetchId >= this.state.fetchId) { const taskCount = _sum(_map(clusters, c => c.numberOfPoints)) this.setState({clusters, loading: false, @@ -100,18 +131,20 @@ export const WithChallengeTaskClusters = function(WrappedComponent) { componentDidMount() { if (!this.props.skipInitialFetch) { - this.fetchUpdatedClusters() + this.fetchUpdatedClusters(this.state.showAsClusters) } } + debouncedFetchClusters = + _debounce((showAsClusters) => this.fetchUpdatedClusters(showAsClusters), 400) + componentDidUpdate(prevProps, prevState) { - if (!_isEqual(_omit(prevProps.criteria, ['page', 'pageSize']), - _omit(this.props.criteria, ['page', 'pageSize']))) { - this.fetchUpdatedClusters() + if (!_isEqual(_get(prevProps.criteria, 'searchQuery'), _get(this.props.criteria, 'searchQuery'))) { + this.debouncedFetchClusters(this.state.showAsClusters) } - - if (this.state.showAsClusters !== prevState.showAsClusters) { - this.fetchUpdatedClusters() + else if (!_isEqual(_omit(prevProps.criteria, ['page', 'pageSize']), + _omit(this.props.criteria, ['page', 'pageSize']))) { + this.fetchUpdatedClusters(this.state.showAsClusters) } } @@ -134,6 +167,7 @@ export const WithChallengeTaskClusters = function(WrappedComponent) { toggleShowAsClusters = {this.toggleShowAsClusters} showAsClusters = {this.state.showAsClusters} totalTaskCount = {this.state.taskCount} + mapZoomedOut = {this.state.mapZoomedOut} /> ) } @@ -143,5 +177,5 @@ export const WithChallengeTaskClusters = function(WrappedComponent) { export const mapDispatchToProps = dispatch => bindActionCreators({ fetchTaskClusters, fetchBoundedTasks }, dispatch) -export default WrappedComponent => - connect(null, mapDispatchToProps)(WithChallengeTaskClusters(WrappedComponent)) +export default (WrappedComponent, storeTasks) => + connect(null, mapDispatchToProps)(WithChallengeTaskClusters(WrappedComponent, storeTasks)) diff --git a/src/components/HOCs/WithChallenges/WithChallenges.js b/src/components/HOCs/WithChallenges/WithChallenges.js index e02d2f8d6..3c3a12cc2 100644 --- a/src/components/HOCs/WithChallenges/WithChallenges.js +++ b/src/components/HOCs/WithChallenges/WithChallenges.js @@ -5,7 +5,6 @@ import { isUsableChallengeStatus } from '../../../services/Challenge/ChallengeStatus/ChallengeStatus' import _values from 'lodash/values' import _get from 'lodash/get' -import _isNumber from 'lodash/isNumber' import _filter from 'lodash/filter' /** @@ -28,15 +27,10 @@ export const mapStateToProps = (state, ownProps) => { let usableChallenges = challenges if (ownProps.allStatuses !== true) { usableChallenges = _filter(challenges, challenge => { - // Don't treat as complete if we're simply missing completion data. - const tasksComplete = _isNumber(_get(challenge, 'actions.available')) ? - challenge.actions.available === 0 : false - const parent = _get(state, `entities.projects.${challenge.parent}`) return challenge.enabled && _get(parent, 'enabled', false) && !challenge.deleted && - !tasksComplete && isUsableChallengeStatus(challenge.status) }) } diff --git a/src/components/HOCs/WithClusteredTasks/WithClusteredTasks.js b/src/components/HOCs/WithClusteredTasks/WithClusteredTasks.js index cb9591907..4baf599ce 100644 --- a/src/components/HOCs/WithClusteredTasks/WithClusteredTasks.js +++ b/src/components/HOCs/WithClusteredTasks/WithClusteredTasks.js @@ -1,12 +1,10 @@ import { connect } from 'react-redux' import { bindActionCreators } from 'redux' -import { fetchClusteredTasks, augmentClusteredTasks } - from '../../../services/Task/ClusteredTask' +import { augmentClusteredTasks } from '../../../services/Task/ClusteredTask' /** * WithClusteredTasks provides a clusteredTasks prop containing the current - * clustered task data from the redux store, as well as a fetchClusteredTasks - * function for retrieving the clustered tasks for a given challenge. + * clustered task data from the redux store. * * @author [Neil Rotstan](https://github.com/nrotstan) */ @@ -15,10 +13,10 @@ const WithClusteredTasks = WrappedComponent => export const mapStateToProps = state => ({ clusteredTasks: state.currentClusteredTasks, + taskClusters: state.currentTaskClusters, }) export const mapDispatchToProps = dispatch => bindActionCreators({ - fetchClusteredTasks, augmentClusteredTasks, }, dispatch) diff --git a/src/components/HOCs/WithCommandInterpreter/WithCommandInterpreter.js b/src/components/HOCs/WithCommandInterpreter/WithCommandInterpreter.js index 6b4d27642..417c2d66c 100644 --- a/src/components/HOCs/WithCommandInterpreter/WithCommandInterpreter.js +++ b/src/components/HOCs/WithCommandInterpreter/WithCommandInterpreter.js @@ -82,8 +82,8 @@ const WithCommandInterpreter = function(WrappedComponent) { * @return boolean - Whether this was a typical search or a command search */ export const executeCommand = (props, commandString, setLoading, isComplete=false) => { - const command = commandString.length >= 2 ? commandString.substring(0, 2) : null - let query = commandString.substring(2) + const command = commandString && commandString.length >= 2 ? commandString.substring(0, 2) : null + let query = commandString ? commandString.substring(2) : commandString // Temporary: until we add an Advanced Search dialog where a user can clear a // project search filter, we need to do it explicitly here if needed diff --git a/src/components/HOCs/WithCurrentTask/WithCurrentTask.js b/src/components/HOCs/WithCurrentTask/WithCurrentTask.js index 14ff6398c..db84bb47a 100644 --- a/src/components/HOCs/WithCurrentTask/WithCurrentTask.js +++ b/src/components/HOCs/WithCurrentTask/WithCurrentTask.js @@ -13,6 +13,7 @@ import { taskDenormalizationSchema, loadRandomTaskFromChallenge, loadRandomTaskFromVirtualChallenge, startTask, + refreshTaskLock, addTaskComment, addTaskBundleComment, completeTask, @@ -258,6 +259,19 @@ export const mapDispatchToProps = (dispatch, ownProps) => { dispatch(addError(error)) }) }, + + /** + * Refresh the lock on the task, extending its allowed duration + */ + refreshTaskLock: task => { + if (!task) { + return Promise.reject("Invalid task") + } + + return dispatch(refreshTaskLock(task.id)).catch(err => { + dispatch(addError(AppErrors.task.lockRefreshFailure)) + }) + }, } } diff --git a/src/components/HOCs/WithLockedTask/WithLockedTask.js b/src/components/HOCs/WithLockedTask/WithLockedTask.js index 5e3118d38..fea532e04 100644 --- a/src/components/HOCs/WithLockedTask/WithLockedTask.js +++ b/src/components/HOCs/WithLockedTask/WithLockedTask.js @@ -40,7 +40,7 @@ export const mapDispatchToProps = (dispatch, ownProps) => ({ startTask: (task) => { dispatch(startTask(task.id)).catch(error => { dispatch(addError(AppErrors.task.locked)) - ownProps.history.push('/browse/challenges') + ownProps.history.push(`/browse/challenges/${_get(task, 'parent.id', task.parent)}`) }) }, releaseTask: (task) => dispatch(releaseTask(task.id)), diff --git a/src/components/HOCs/WithMapBoundedTasks/WithMapBoundedTasks.js b/src/components/HOCs/WithMapBoundedTasks/WithMapBoundedTasks.js index 5387aecf0..b7feb2c6e 100644 --- a/src/components/HOCs/WithMapBoundedTasks/WithMapBoundedTasks.js +++ b/src/components/HOCs/WithMapBoundedTasks/WithMapBoundedTasks.js @@ -4,11 +4,8 @@ import _get from 'lodash/get' import _each from 'lodash/each' import _filter from 'lodash/filter' import _omit from 'lodash/omit' -import _debounce from 'lodash/debounce' import _map from 'lodash/map' -import _noop from 'lodash/noop' -import { fetchBoundedTasks } from '../../../services/Task/BoundedTask' -import { extendedFind } from '../../../services/Challenge/Challenge' +import _find from 'lodash/find' import { createVirtualChallenge } from '../../../services/VirtualChallenge/VirtualChallenge' import { loadRandomTaskFromVirtualChallenge } @@ -24,29 +21,12 @@ import AppErrors from '../../../services/Error/AppErrors' * task-browsing to be enabled. Uses the REACT_APP_BOUNDED_TASKS_MAX_DIMENSION * .env setting or a system default if that hasn't been set. * - * @private */ -const maxAllowedDegrees = function() { +export const maxAllowedDegrees = function() { return _get(process.env, 'REACT_APP_BOUNDED_TASKS_MAX_DIMENSION', - 2.5) // degrees + 70) // degrees } -/** - * Debounced dispatch of fetchBoundedTasks - * - * @private - */ -const doUpdateBoundedTasks = - _get(process.env, 'REACT_APP_FEATURE_BOUNDED_TASK_BROWSING') === 'enabled' ? - _debounce((dispatch, bounds) => { - if (boundsWithinAllowedMaxDegrees(bounds, maxAllowedDegrees())) { - dispatch(fetchBoundedTasks({boundingBox: bounds}, 1000)) - // We also need to make sure we have the parent challenges - // TODO only fetch parents of retrieved tasks - dispatch(extendedFind({bounds})) - } - }, 500) : _noop - /** * WithMapBoundedTasks retrieves map-bounded task clusters (regardless of * challenge) within the given mapBounds bounding box when it's of an @@ -79,6 +59,20 @@ export const WithMapBoundedTasks = function(WrappedComponent, if (bounds && boundsWithinAllowedMaxDegrees(bounds, maxAllowedDegrees())) { mapBoundedTasks = this.props.mapBoundedTasks + + // If we have no mapBoundsTasks then we might be dealing with clusters. + // If the clusters all represent individual tasks then these should be ok + // to work on as a virtual challenge as well. + if (_get(mapBoundedTasks, 'tasks.length', 0) === 0) { + const clusters = this.props.mapBoundedTaskClusters.clusters + const areClustersAllTasks = !(_find(clusters, c => !c.taskId)) + + if (areClustersAllTasks && _get(clusters, 'length') > 0) { + mapBoundedTasks = {tasks: _map(clusters, cluster => { + return {id: cluster.taskId, parentId: cluster.challengeIds[0]} + })} + } + } } if (!matchChallenges || _get(mapBoundedTasks, 'tasks.length', 0) === 0) { @@ -112,32 +106,12 @@ export const WithMapBoundedTasks = function(WrappedComponent, } } - componentWillMount() { - const bounds = this.normalizedBounds(this.props) - - if (bounds) { - this.props.updateBoundedTasks(bounds) - } - } - - componentWillReceiveProps(nextProps) { - const nextBounds = this.normalizedBounds(nextProps) - const currentBounds = this.normalizedBounds(this.props) - - if (nextBounds) { - if (!currentBounds || !nextBounds.equals(currentBounds)) { - this.props.updateBoundedTasks(nextBounds) - } - } - } - render() { return ( ) } } @@ -145,11 +119,10 @@ export const WithMapBoundedTasks = function(WrappedComponent, const mapStateToProps = state => ({ mapBoundedTasks: state.currentBoundedTasks, + mapBoundedTaskClusters: state.currentTaskClusters, }) const mapDispatchToProps = (dispatch, ownProps) => ({ - updateBoundedTasks: bounds => doUpdateBoundedTasks(dispatch, bounds), - startBoundedTasks: (name, taskIds) => { return dispatch( createVirtualChallenge(name, taskIds) diff --git a/src/components/HOCs/WithSearch/WithSearch.js b/src/components/HOCs/WithSearch/WithSearch.js index ef46b0af5..e837b764e 100644 --- a/src/components/HOCs/WithSearch/WithSearch.js +++ b/src/components/HOCs/WithSearch/WithSearch.js @@ -11,15 +11,13 @@ import { SORT_NAME, SORT_CREATED, SORT_POPULARITY, SORT_SUGGESTED_FIX, setSort, removeSort, setPage, setFilters, removeFilters, clearFilters, setSearch, clearSearch, - setChallengeSearchMapBounds, setChallengeBrowseMapBounds, + setChallengeSearchMapBounds, setTaskMapBounds, setChallengeOwnerMapBounds, clearMapBounds, performSearch } from '../../../services/Search/Search' import { addError } from '../../../services/Error/Error' import { toLatLngBounds, DEFAULT_MAP_BOUNDS } from '../../../services/MapBounds/MapBounds' -import { CHALLENGE_LOCATION_WITHIN_MAPBOUNDS } - from '../../../services/Challenge/ChallengeLocation/ChallengeLocation' import WithUserLocation from '../WithUserLocation/WithUserLocation' /** @@ -69,7 +67,7 @@ export const _WithSearch = function(WrappedComponent, searchGroup, searchFunctio let prevSearch = _omit(_get(prevProps, `currentSearch.${searchGroup}`), ['meta']) let currentSearch = _omit(_get(this.props, `currentSearch.${searchGroup}`), ['meta']) - if (_get(this.props, 'searchFilters.location') !== CHALLENGE_LOCATION_WITHIN_MAPBOUNDS) { + if (!_get(this.props, 'searchFilters.location')) { currentSearch = _omit(currentSearch, 'mapBounds') prevSearch = _omit(prevSearch, 'mapBounds') } @@ -210,10 +208,6 @@ export const mapDispatchToProps = (dispatch, ownProps, searchGroup) => ({ dispatch(setChallengeSearchMapBounds(searchGroup, bounds, fromUserAction)) }, - setChallengeBrowseMapBounds: (challengeId, bounds, zoom) => { - dispatch(setChallengeBrowseMapBounds(searchGroup, challengeId, bounds, zoom)) - }, - setChallengeOwnerMapBounds: (challengeId, bounds, zoom) => { dispatch(setChallengeOwnerMapBounds(searchGroup, challengeId, bounds, zoom)) }, @@ -227,7 +221,7 @@ export const mapDispatchToProps = (dispatch, ownProps, searchGroup) => ({ }, locateMapToUser: user => { - ownProps.getUserBounds(user).then(userBounds => { + return ownProps.getUserBounds(user).then(userBounds => { dispatch(setChallengeSearchMapBounds(searchGroup, userBounds, true)) }).catch(locationError => { dispatch(addError(locationError)) diff --git a/src/components/HOCs/WithStartChallenge/WithStartChallenge.test.js b/src/components/HOCs/WithStartChallenge/WithStartChallenge.test.js index 4aa95e096..d579f5982 100644 --- a/src/components/HOCs/WithStartChallenge/WithStartChallenge.test.js +++ b/src/components/HOCs/WithStartChallenge/WithStartChallenge.test.js @@ -5,8 +5,7 @@ import _find from 'lodash/find' import { chooseVisibleTask, mapDispatchToProps, } from './WithStartChallenge' import AsEndUser from '../../../interactions/User/AsEndUser' import { denormalize } from 'normalizr' -import { loadRandomTaskFromChallenge, - fetchClusteredTasks } from '../../../services/Task/Task' +import { loadRandomTaskFromChallenge } from '../../../services/Task/Task' import { changeVisibleLayer } from '../../../services/VisibleLayer/VisibleLayer' import { TaskStatus } from '../../../services/Task/TaskStatus/TaskStatus' diff --git a/src/components/HOCs/WithTaskClusterMarkers/WithTaskClusterMarkers.js b/src/components/HOCs/WithTaskClusterMarkers/WithTaskClusterMarkers.js index 95da43cc3..6eea389e9 100644 --- a/src/components/HOCs/WithTaskClusterMarkers/WithTaskClusterMarkers.js +++ b/src/components/HOCs/WithTaskClusterMarkers/WithTaskClusterMarkers.js @@ -21,11 +21,8 @@ export const WithTaskClusterMarkers = function(WrappedComponent) { } componentDidUpdate(prevProps) { - if (!_isEqual(this.props.taskClusters, prevProps.taskClusters)) { - this.updateMapMarkers() - } - - if (!_isEqual(this.props.chosenTasks, prevProps.chosenTasks)) { + if (!_isEqual(this.props.taskClusters, prevProps.taskClusters) || + !_isEqual(this.props.chosenTasks, prevProps.chosenTasks)) { this.updateMapMarkers() } } diff --git a/src/components/Sprites/Sprites.js b/src/components/Sprites/Sprites.js index 3e104c745..fc2f1b2c6 100644 --- a/src/components/Sprites/Sprites.js +++ b/src/components/Sprites/Sprites.js @@ -10,6 +10,9 @@ export default function() { return (
    {this.state.mapMarkers} + {!this.props.mapZoomedOut && + {this.state.mapMarkers} + } return ( @@ -364,11 +387,11 @@ export class TaskClusterMap extends Component { - + } - {!this.props.hideSearchControl && + {!this.props.hideSearchControl && { @@ -377,8 +400,20 @@ export class TaskClusterMap extends Component { }} /> } + {!!this.props.mapZoomedOut && !this.state.locatingToUser && + + } + {!!this.props.showTaskCount && this.state.displayTaskCount && !this.props.mapZoomedOut && +
    +
    +
    + +
    +
    +
    + } {map} - {(!!this.props.loading || !!this.props.loadingChallenge) && } + {(!!this.props.loading || this.state.locatingToUser || !!this.props.loadingChallenge) && }
    ) } diff --git a/src/components/TaskClusterMap/ZoomInMessage.js b/src/components/TaskClusterMap/ZoomInMessage.js new file mode 100644 index 000000000..11d62a097 --- /dev/null +++ b/src/components/TaskClusterMap/ZoomInMessage.js @@ -0,0 +1,85 @@ +import React, { Component } from 'react' +import { FormattedMessage } from 'react-intl' +import SvgSymbol from '../SvgSymbol/SvgSymbol' +import _isUndefined from 'lodash/isUndefined' +import LocationSearchBox from '../EnhancedMap/SearchControl/LocationSearchBox' +import { ChallengeLocation} + from '../../services/Challenge/ChallengeLocation/ChallengeLocation' +import { toLatLngBounds } from '../../services/MapBounds/MapBounds' +import { MIN_ZOOM } from './TaskClusterMap' +import messages from './Messages' + +/** + * ZoomInMessage presents a message to the user saying they need to zoom in + * to see tasks. It also presents them with a 'near me' button and a box + * to perform a nominatum query. + * + * @author [Kelli Rotstan](https://github.com/krotstan) + */ +export class ZoomInMessage extends Component { + state = { + } + + componentDidUpdate(prevProps) { + // If user has zoomed in then we want to minimized + if (prevProps.zoom < this.props.zoom && + prevProps.zoom <= MIN_ZOOM && !this.state.minimized) { + this.setState({minimized: true}) + } + // If user has zoomed out then we want to un-minimize + else if (prevProps.zoom > this.props.zoom && + this.props.zoom <= MIN_ZOOM && this.state.minimized) { + this.setState({minimized: false}) + } + // If minimized is undefined this is the first time this is loading + else if (_isUndefined(this.state.minimized)) { + this.setState({minimized: (this.props.zoom > MIN_ZOOM)}) + } + } + + render() { + return ( +
    +
    +
    + +
    this.setState({minimized: !this.state.minimized})}> + +
    +
    + {!this.state.minimized && +
    + + + { + this.currentBounds = toLatLngBounds(bounds) + this.props.updateBounds(bounds, zoom, true) + }} + /> +
    + } +
    +
    + ) + } +} + +export default ZoomInMessage diff --git a/src/components/TaskPane/Messages.js b/src/components/TaskPane/Messages.js index 6bfea62f7..cc9f0c2c4 100644 --- a/src/components/TaskPane/Messages.js +++ b/src/components/TaskPane/Messages.js @@ -8,4 +8,14 @@ export default defineMessages({ id: "Task.pane.controls.inspect.label", defaultMessage: "Inspect", }, + + taskLockedLabel: { + id: "Task.pane.indicators.locked.label", + defaultMessage: "Task locked", + }, + + taskUnlockLabel: { + id: "Task.pane.controls.unlock.label", + defaultMessage: "Unlock", + }, }) diff --git a/src/components/TaskPane/TaskPane.js b/src/components/TaskPane/TaskPane.js index 2e0338a92..4940b8137 100644 --- a/src/components/TaskPane/TaskPane.js +++ b/src/components/TaskPane/TaskPane.js @@ -2,6 +2,7 @@ import React, { Component } from 'react' import PropTypes from 'prop-types' import { FormattedMessage, injectIntl } from 'react-intl' import MediaQuery from 'react-responsive' +import { Link } from 'react-router-dom' import _get from 'lodash/get' import { generateWidgetId, WidgetDataTarget, widgetDescriptor } from '../../services/Widget/Widget' @@ -23,6 +24,7 @@ import VirtualChallengeNameLink import ChallengeNameLink from '../ChallengeNameLink/ChallengeNameLink' import OwnerContactLink from '../ChallengeOwnerContactLink/ChallengeOwnerContactLink' import BusySpinner from '../BusySpinner/BusySpinner' +import SvgSymbol from '../SvgSymbol/SvgSymbol' import MobileTaskDetails from './MobileTaskDetails/MobileTaskDetails' import messages from './Messages' import './TaskPane.scss' @@ -32,6 +34,9 @@ const MobileTabBar = WithCurrentUser(MobileTaskDetails) const WIDGET_WORKSPACE_NAME = "taskCompletion" +// How frequently the task lock should be refreshed +const LOCK_REFRESH_INTERVAL = 600000 // 10 minutes + export const defaultWorkspaceSetup = function() { return { dataModelVersion: 2, @@ -73,6 +78,8 @@ export const defaultWorkspaceSetup = function() { * @author [Neil Rotstan](https://github.com/nrotstan) */ export class TaskPane extends Component { + lockRefreshInterval = null + state = { /** * id of task once user initiates completion. This is used to help our @@ -82,6 +89,16 @@ export class TaskPane extends Component { completionResponses: null } + /** + * Clear the lock-refresh timer if one is set + */ + clearLockRefreshInterval = () => { + if (this.lockRefreshInterval !== null) { + clearInterval(this.lockRefreshInterval) + this.lockRefreshInterval = null + } + } + /** * Invoked by various completion controls to signal the user is completing * the task with a specific status. Normally this would just go straight to @@ -110,6 +127,19 @@ export class TaskPane extends Component { this.setState({completionResponses: responses}) } + componentDidMount() { + // Setup an interval to refresh the task lock every so often so that it + // doesn't expire while the mapper is actively working on the task + this.clearLockRefreshInterval() + this.lockRefreshInterval = setInterval(() => { + this.props.refreshTaskLock(this.props.task) + }, LOCK_REFRESH_INTERVAL) + } + + componentWillUnmount() { + this.clearLockRefreshInterval() + } + componentDidUpdate(prevProps) { if (this.props.location.pathname !== prevProps.location.pathname && this.props.location.search !== prevProps.location.search) { @@ -153,23 +183,43 @@ export class TaskPane extends Component { } workspaceInfo={ -
      -
    • - {_get(this.props.task, 'parent.parent.displayName')} -
    • - -
    • - -
    • - - {isManageable && !this.props.inspectTask && ( -
    • - -
    • - )} -
    + +
    +
      +
    • + {_get(this.props.task, 'parent.parent.displayName')} +
    • + +
    • + +
    • + + {isManageable && !this.props.inspectTask && ( +
    • + +
    • + )} +
    +
    +
    + + + + + + + +
    +
    } completeTask={this.completeTask} completingTask={this.state.completingTask} diff --git a/src/components/Widgets/ReviewMapWidget/ReviewMapWidget.js b/src/components/Widgets/ReviewMapWidget/ReviewMapWidget.js index 33ec73c0e..87f0f1ca2 100644 --- a/src/components/Widgets/ReviewMapWidget/ReviewMapWidget.js +++ b/src/components/Widgets/ReviewMapWidget/ReviewMapWidget.js @@ -1,4 +1,5 @@ import React, { Component } from 'react' +import _omit from 'lodash/omit' import { WidgetDataTarget, registerWidgetType } from '../../../services/Widget/Widget' import MapPane from '../../EnhancedMap/MapPane/MapPane' @@ -29,8 +30,8 @@ export default class ReviewMapWidget extends Component { className="review-map-widget" noMain > - - + + ) diff --git a/src/components/Widgets/TaskBundleWidget/TaskBundleWidget.js b/src/components/Widgets/TaskBundleWidget/TaskBundleWidget.js index d787d80fd..e461d274b 100644 --- a/src/components/Widgets/TaskBundleWidget/TaskBundleWidget.js +++ b/src/components/Widgets/TaskBundleWidget/TaskBundleWidget.js @@ -290,7 +290,7 @@ const BuildBundle = props => { chosenTasks={selectedTasks} boundingBox={_get(props, 'criteria.boundingBox')} onBulkTaskSelection={props.selectTasksById} - allowClusterToggle + allowClusterToggle={false} hideSearchControl {..._omit(props, 'selectedTasks', 'className')} /> diff --git a/src/interactions/TaskFeature/AsIdentifiableFeature.js b/src/interactions/TaskFeature/AsIdentifiableFeature.js index 8988e9c00..4f9310da5 100644 --- a/src/interactions/TaskFeature/AsIdentifiableFeature.js +++ b/src/interactions/TaskFeature/AsIdentifiableFeature.js @@ -26,12 +26,14 @@ export class AsIdentifiableFeature { } // Look on the feature itself first, then on its properties - let idProp = _find(featureIdFields, name => this[name]) + let idProp = _find(featureIdFields, name => this[name] && + this.isValidId(this[name])) if (idProp) { return this[idProp] } - idProp = _find(featureIdFields, name => this.properties[name]) + idProp = _find(featureIdFields, name => this.properties[name] && + this.isValidId(this.properties[name])) return idProp ? this.properties[idProp] : null } @@ -51,6 +53,20 @@ export class AsIdentifiableFeature { const match = /(\d+)/.exec(featureId) return (match && match.length > 1) ? match[1] : null } + + isValidId(id) { + if (!id) { + return false + } + + // Ids that are only numeric or start with node/way/relation (eg. 'node/12345') + // or start with just a character n/r/w (eg. 'n12345') + if (id.match(/^(node|way|relation|n|r|w)?\/?\d+$/)) { + return true + } + + return false + } } export default feature => new AsIdentifiableFeature(feature) diff --git a/src/lang/en-US.json b/src/lang/en-US.json index 219809688..a878c7eb2 100644 --- a/src/lang/en-US.json +++ b/src/lang/en-US.json @@ -291,11 +291,11 @@ "VirtualChallenge.controls.create.label": "Work on {taskCount, number} Mapped Tasks", "VirtualChallenge.fields.name.label": "Name your \"virtual\" challenge", "Challenge.controls.loadMore.label": "More Results", + "ChallengePane.controls.startChallenge.label": "Start Challenge", "Task.fauxStatus.available": "Available", "ChallengeProgress.tooltip.label": "Tasks", "ChallengeProgress.tasks.remaining": "Tasks Remaining: {taskCount, number}", "ChallengeProgress.tasks.totalCount": "of {totalCount, number}", - "ChallengeSearchMap.controls.startChallenge.label": "Start Challenge", "Challenge.indicators.newest.label": "Newest", "Challenge.indicators.popular.label": "Popular", "Challenge.indicators.featured.label": "Featured", @@ -494,6 +494,7 @@ "PropertyList.noProperties": "No Properties", "EnhancedMap.SearchControl.searchLabel": "Search", "EnhancedMap.SearchControl.noResults": "No Results", + "EnhancedMap.SearchControl.nominatimQuery.placeholder": "Nominatim Query", "FeaturedChallenges.header": "Featured Challenges", "Footer.versionLabel": "MapRoulette", "Footer.getHelp": "Get Help", @@ -611,6 +612,10 @@ "Admin.TaskAnalysisTable.bundleMember.tooltip": "Member of a task bundle", "ReviewMap.metrics.title": "Review Map", "TaskClusterMap.controls.clusterTasks.label": "Cluster", + "TaskClusterMap.message.zoomInForTasks.label": "Zoom in to view tasks", + "TaskClusterMap.message.nearMe.label": "Near Me", + "TaskClusterMap.message.or.label": "or", + "TaskClusterMap.message.taskCount.label": "{count, plural, =0 {No tasks found} one {# task found} other {# tasks found}}", "Task.controls.completionComment.placeholder": "Your comment", "Task.comments.comment.controls.submit.label": "Submit", "TaskCommentsModal.header": "Comments", @@ -683,6 +688,8 @@ "ActiveTask.subheading.progress": "Challenge Progress", "ActiveTask.subheading.social": "Share", "Task.pane.controls.inspect.label": "Inspect", + "Task.pane.indicators.locked.label": "Task locked", + "Task.pane.controls.unlock.label": "Unlock", "MobileTask.subheading.instructions": "Instructions", "Task.management.heading": "Management Options", "Task.management.controls.inspect.label": "Inspect", @@ -935,7 +942,7 @@ "Challenge.keywords.any": "Anything", "Challenge.location.nearMe": "Near Me", "Challenge.location.withinMapBounds": "Within Map Bounds", - "Challenge.location.intersectingMapBounds": "Intersecting Map Bounds", + "Challenge.location.intersectingMapBounds": "Shown on Map", "Challenge.location.any": "Anywhere", "Challenge.status.none": "Not Applicable", "Challenge.status.building": "Building", @@ -967,6 +974,7 @@ "Errors.task.fetchFailure": "Unable to fetch a task to work on.", "Errors.task.doesNotExist": "That task does not exist.", "Errors.task.alreadyLocked": "Task has already been locked by someone else.", + "Errors.task.lockRefreshFailure": "Unable to extend your task lock. Your lock may have expired. We recommend refreshing the page to try establishing a fresh lock.", "Errors.task.bundleFailure": "Unable to bundling tasks together", "Errors.osm.requestTooLarge": "OpenStreetMap data request too large", "Errors.osm.bandwidthExceeded": "OpenStreetMap allowed bandwidth exceeded", diff --git a/src/lang/ja.json b/src/lang/ja.json index 3fa803b0c..eafa669f1 100644 --- a/src/lang/ja.json +++ b/src/lang/ja.json @@ -419,7 +419,6 @@ "Challenge.keywords.any": "すべて", "Challenge.location.nearMe": "自分の近所", "Challenge.location.withinMapBounds": "マップの矩形範囲", - "Challenge.location.intersectingMapBounds": "交差するマップの矩形", "Challenge.location.any": "どこでも", "Challenge.status.none": "利用不可", "Challenge.status.building": "建物", diff --git a/src/lang/ko.json b/src/lang/ko.json index 1bbae2e0c..f70b19526 100644 --- a/src/lang/ko.json +++ b/src/lang/ko.json @@ -628,7 +628,7 @@ "Widgets.TaskMoreOptionsWidget.label": "더 많은 옵션", "Widgets.TaskMoreOptionsWidget.title": "더 많은 옵션", "Widgets.TaskReviewWidget.label": "작업 검토", - "Widgets.TaskReviewWidget.reviewTaskTitle": "검토", + "Widgets.TaskReviewWidget.reviewTaskTitle": "검토", "Widgets.TaskStatusWidget.label": "작업 상태", "Widgets.TaskStatusWidget.title": "작업 상태", "WidgetWorkspace.controls.editConfiguration.label": "레이아웃 편집", @@ -760,7 +760,6 @@ "Challenge.keywords.any": "전부", "Challenge.location.nearMe": "내 근처", "Challenge.location.withinMapBounds": "지도 경계 안쪽만", - "Challenge.location.intersectingMapBounds": "지도 경계 안쪽을 지나감", "Challenge.location.any": "전체", "Challenge.status.none": "적용할 수 없음", "Challenge.status.building": "구축 중", diff --git a/src/lang/pt-BR.json b/src/lang/pt-BR.json index b5e4e6f3d..959971774 100644 --- a/src/lang/pt-BR.json +++ b/src/lang/pt-BR.json @@ -646,7 +646,6 @@ "Challenge.keywords.any": "Qualquer coisa", "Challenge.location.nearMe": "Perto de mim", "Challenge.location.withinMapBounds": "Dentro dos limites do mapa", - "Challenge.location.intersectingMapBounds": "Interseção de limites do mapa", "Challenge.location.any": "Em qualquer lugar", "Challenge.status.none": "Não aplicável", "Challenge.status.building": "Edifício", diff --git a/src/services/Challenge/Challenge.js b/src/services/Challenge/Challenge.js index 051669046..3d8cba461 100644 --- a/src/services/Challenge/Challenge.js +++ b/src/services/Challenge/Challenge.js @@ -640,6 +640,11 @@ export const saveChallenge = function(originalChallengeData, storeResponse=true) 'remoteGeoJson', 'status', 'tags', 'updateTasks', 'virtualParents', 'exportableProperties', 'dataOriginDate']) + if (challengeData.dataOriginDate) { + // Set the timestamp on the dataOriginDate so we get proper timezone info. + challengeData.dataOriginDate = parse(challengeData.dataOriginDate).toISOString() + } + // Setup the save function to either edit or create the challenge // depending on whether it has an id. const saveEndpoint = new Endpoint( @@ -695,6 +700,12 @@ export const uploadChallengeGeoJSON = function(challengeId, geoJSON, lineByLine= new File([geoJSON], `challenge_${challengeId}_tasks_${Date.now()}.geojson`) ) + if (dataOriginDate) { + // Set the timestamp on the dataOriginDate so we get proper timezone info. + dataOriginDate = parse(dataOriginDate).toISOString() + } + + return new Endpoint( api.challenge.uploadGeoJSON, { variables: {id: challengeId}, diff --git a/src/services/Challenge/ChallengeLocation/ChallengeLocation.js b/src/services/Challenge/ChallengeLocation/ChallengeLocation.js index 035a6de84..6e6ce26d9 100644 --- a/src/services/Challenge/ChallengeLocation/ChallengeLocation.js +++ b/src/services/Challenge/ChallengeLocation/ChallengeLocation.js @@ -3,16 +3,20 @@ import _map from 'lodash/map' import _fromPairs from 'lodash/fromPairs' import _isEmpty from 'lodash/isEmpty' import _get from 'lodash/get' -import { latLng } from 'leaflet' -import { toLatLngBounds } from '../../MapBounds/MapBounds' +import _each from 'lodash/each' +import _concat from 'lodash/concat' +import _indexOf from 'lodash/indexOf' +import { maxAllowedDegrees } from '../../../components/HOCs/WithMapBoundedTasks/WithMapBoundedTasks' +import { toLatLngBounds, + boundsWithinAllowedMaxDegrees } from '../../MapBounds/MapBounds' import messages from './Messages' + export const CHALLENGE_LOCATION_NEAR_USER = 'nearMe' export const CHALLENGE_LOCATION_WITHIN_MAPBOUNDS = 'withinMapBounds' export const CHALLENGE_LOCATION_INTERSECTING_MAPBOUNDS = 'intersectingMapBounds' export const ChallengeLocation = Object.freeze({ - [CHALLENGE_LOCATION_WITHIN_MAPBOUNDS]: CHALLENGE_LOCATION_WITHIN_MAPBOUNDS, [CHALLENGE_LOCATION_INTERSECTING_MAPBOUNDS]: CHALLENGE_LOCATION_INTERSECTING_MAPBOUNDS, [CHALLENGE_LOCATION_NEAR_USER]: CHALLENGE_LOCATION_NEAR_USER, }) @@ -36,36 +40,44 @@ export const locationLabels = intl => _fromPairs( */ export const challengePassesLocationFilter = function(challengeFilters, challenge, - searchCriteria) { + props) { if (challengeFilters.location !== CHALLENGE_LOCATION_WITHIN_MAPBOUNDS && challengeFilters.location !== CHALLENGE_LOCATION_INTERSECTING_MAPBOUNDS && challengeFilters.location !== CHALLENGE_LOCATION_NEAR_USER ) { return true } - if (_isEmpty(_get(searchCriteria, 'mapBounds.bounds'))) { + if (_isEmpty(_get(props.searchCriteria, 'mapBounds.bounds'))) { return true } - const challengeSearchMapBounds = toLatLngBounds(searchCriteria.mapBounds.bounds) + const challengeSearchMapBounds = toLatLngBounds(props.searchCriteria.mapBounds.bounds) - // if the challenge is located within the bounds, it passes. - if (!_isEmpty(challenge.location)) { - const challengeLocation = latLng(challenge.location.coordinates[1], - challenge.location.coordinates[0]) - if (challengeSearchMapBounds.contains(challengeLocation)) { - return true - } + // Or if the challenge is listed in the TaskClusters or in the Map Bounded Tasks + let validChallenges = [] + _each(_get(props, 'mapBoundedTasks.tasks'), (task) => { + validChallenges = _concat(validChallenges, task.parentId) + }) + + _each(_get(props, 'taskClusters.clusters'), (cluster) => { + validChallenges = _concat(validChallenges, cluster.challengeIds) + }) + + if (_indexOf(validChallenges, challenge.id) > -1) { + return true } - // If user wants challenges that simply intersect the bounds, then let those - // pass too. - if (challengeFilters.location === CHALLENGE_LOCATION_INTERSECTING_MAPBOUNDS && - !_isEmpty(challenge.bounding)) { - const challengeBounds = toLatLngBounds(bbox(challenge.bounding)) + if (!challengeSearchMapBounds || + !boundsWithinAllowedMaxDegrees(challengeSearchMapBounds, maxAllowedDegrees())) { + // If user wants challenges that simply intersect the bounds, then let those + // pass if we are not analyzing individual tasks. + if (challengeFilters.location === CHALLENGE_LOCATION_INTERSECTING_MAPBOUNDS && + !_isEmpty(challenge.bounding)) { + const challengeBounds = toLatLngBounds(bbox(challenge.bounding)) - if (challengeSearchMapBounds.intersects(challengeBounds)) { - return true + if (challengeSearchMapBounds.intersects(challengeBounds)) { + return true + } } } diff --git a/src/services/Challenge/ChallengeLocation/Messages.js b/src/services/Challenge/ChallengeLocation/Messages.js index 3c6b9e9d3..e3d2f15a4 100644 --- a/src/services/Challenge/ChallengeLocation/Messages.js +++ b/src/services/Challenge/ChallengeLocation/Messages.js @@ -14,7 +14,7 @@ export default defineMessages({ }, intersectingMapBounds: { id: "Challenge.location.intersectingMapBounds", - defaultMessage: "Intersecting Map Bounds", + defaultMessage: "Shown on Map", }, any: { id: "Challenge.location.any", diff --git a/src/services/Error/AppErrors.js b/src/services/Error/AppErrors.js index ee6d36f70..dd919dbd2 100644 --- a/src/services/Error/AppErrors.js +++ b/src/services/Error/AppErrors.js @@ -28,6 +28,7 @@ export default { fetchFailure: messages.taskFetchFailure, doesNotExist: messages.taskDoesNotExist, locked: messages.taskLocked, + lockRefreshFailure: messages.taskLockRefreshFailure, bundleFailure: messages.taskBundleFailure, }, diff --git a/src/services/Error/Messages.js b/src/services/Error/Messages.js index d5ac3bfb2..8ca42eb09 100644 --- a/src/services/Error/Messages.js +++ b/src/services/Error/Messages.js @@ -66,6 +66,10 @@ export default defineMessages({ id: 'Errors.task.alreadyLocked', defaultMessage: "Task has already been locked by someone else.", }, + taskLockRefreshFailure: { + id: 'Errors.task.lockRefreshFailure', + defaultMessage: "Unable to extend your task lock. Your lock may have expired. We recommend refreshing the page to try establishing a fresh lock.", + }, taskBundleFailure: { id: 'Errors.task.bundleFailure', defaultMessage: "Unable to bundling tasks together", diff --git a/src/services/Search/Search.js b/src/services/Search/Search.js index dc5d7fc85..cfb84b7de 100644 --- a/src/services/Search/Search.js +++ b/src/services/Search/Search.js @@ -8,9 +8,13 @@ import _isArray from 'lodash/isArray' import _omit from 'lodash/omit' import _fromPairs from 'lodash/fromPairs' import _map from 'lodash/map' +import _isFinite from 'lodash/isFinite' import messages from './Messages' import { fromLatLngBounds } from '../MapBounds/MapBounds' +import { CHALLENGE_LOCATION_WITHIN_MAPBOUNDS } + from '../Challenge/ChallengeLocation/ChallengeLocation' + // redux actions export const SET_SEARCH = 'SET_SEARCH' @@ -106,7 +110,8 @@ export const parseQueryString = function(rawQueryText) { * Generates, from the given criteria, a search parameters string that the * server accepts for various API endpoints */ -export const generateSearchParametersString = (filters, boundingBox, savedChallengesOnly) => { +export const generateSearchParametersString = (filters, boundingBox, savedChallengesOnly, + queryString) => { const searchParameters = {} if (filters.reviewRequestedBy) { searchParameters.o = filters.reviewRequestedBy @@ -167,22 +172,53 @@ export const generateSearchParametersString = (filters, boundingBox, savedChalle } } + if (_isFinite(filters.difficulty)) { + searchParameters.cd = filters.difficulty + } + if (boundingBox) { - //tbb => [left, bottom, right, top] W/S/E/N - if (_isArray(boundingBox)) { - searchParameters.tbb = boundingBox.join(',') + // If we are searching within map bounds we need to ensure the parent + // challenge is also within those bounds + if (filters.location === CHALLENGE_LOCATION_WITHIN_MAPBOUNDS) { + if (_isArray(boundingBox)) { + searchParameters.bb = boundingBox.join(',') + } + else { + searchParameters.bb = boundingBox + } } else { - searchParameters.tbb = boundingBox + //tbb => [left, bottom, right, top] W/S/E/N + if (_isArray(boundingBox)) { + searchParameters.tbb = boundingBox.join(',') + } + else { + searchParameters.tbb = boundingBox + } } - - } if (savedChallengesOnly) { searchParameters.onlySaved = savedChallengesOnly } + if (queryString || filters.keywords) { + const queryParts = parseQueryString(queryString) + + // Keywords/tags can come from both the the query and the filter, so we need to + // combine them into a single keywords array. + const keywords = + queryParts.tagTokens.concat(_isArray(filters.keywords) ? filters.keywords : []) + + if (keywords.length > 0) { + searchParameters.ct = keywords.join(',') + } + + if (queryParts.query.length > 0) { + searchParameters.cs = queryParts.query + } + } + return searchParameters } @@ -287,23 +323,6 @@ export const setChallengeSearchMapBounds = function(searchName, bounds, fromUser } } -/** - * Update the redux store with the given bounds of the challenge (browsing) - * map. - * - * @param bounds - either a LatLngBounds instance or an array of - * [west, south, east, north] - */ -export const setChallengeBrowseMapBounds = function(searchName, challengeId, bounds, zoom) { - return { - type: SET_CHALLENGE_BROWSE_MAP_BOUNDS, - searchName, - challengeId, - bounds: fromLatLngBounds(bounds), - zoom, - } -} - /** * Set the given bounds of the task map in the redux store as the current * bounds. If the bounds are being altered programatically in direct response diff --git a/src/services/Server/APIRoutes.js b/src/services/Server/APIRoutes.js index b3c8d893b..d154a1af5 100644 --- a/src/services/Server/APIRoutes.js +++ b/src/services/Server/APIRoutes.js @@ -48,7 +48,6 @@ const apiRoutes = factory => { 'challenge': { 'single': factory.get('/challenge/:id'), 'tasks': factory.get('/challenge/:id/tasks'), - 'clusteredTasks': factory.get('/challenge/clustered/:id'), 'taskClusters': factory.get('/taskCluster'), 'nearbyTasks': factory.get('/challenge/:challengeId/tasksNearby/:taskId'), 'deleteTasks': factory.delete('/challenge/:id/tasks'), @@ -99,6 +98,7 @@ const apiRoutes = factory => { 'single': factory.get('/task/:id'), 'start': factory.get('/task/:id/start'), 'release': factory.get('/task/:id/release'), + 'refreshLock': factory.get('/task/:id/refreshLock'), 'startReview': factory.get('/task/:id/review/start'), 'cancelReview': factory.get('/task/:id/review/cancel'), 'updateStatus': factory.put('/task/:id/:status'), diff --git a/src/services/Task/BoundedTask.js b/src/services/Task/BoundedTask.js index 53c2c7057..fee89dc93 100644 --- a/src/services/Task/BoundedTask.js +++ b/src/services/Task/BoundedTask.js @@ -10,11 +10,17 @@ import AppErrors from '../Error/AppErrors' import _get from 'lodash/get' import _values from 'lodash/values' import _isArray from 'lodash/isArray' +import _isUndefined from 'lodash/isUndefined' import _map from 'lodash/map' import { generateSearchParametersString } from '../Search/Search' +import { clearTaskClusters } from './TaskClusters' +import { CHALLENGE_LOCATION_WITHIN_MAPBOUNDS } + from '../Challenge/ChallengeLocation/ChallengeLocation' + // redux actions const RECEIVE_BOUNDED_TASKS = 'RECEIVE_BOUNDED_TASKS' +const CLEAR_BOUNDED_TASKS = 'CLEAR_BOUNDED_TASKS' // redux action creators @@ -44,6 +50,13 @@ export const receiveBoundedTasks = function(tasks, */ export const fetchBoundedTasks = function(criteria, limit=50, skipDispatch=false, excludeLocked=true) { return function(dispatch) { + if (!skipDispatch) { + // The map is either showing task clusters or bounded tasks so we shouldn't + // have both in redux. + // (ChallengeLocation needs to know which challenge tasks pass the location) + dispatch(clearTaskClusters()) + } + const normalizedBounds = toLatLngBounds(criteria.boundingBox) if (!normalizedBounds) { return null @@ -53,10 +66,37 @@ export const fetchBoundedTasks = function(criteria, limit=50, skipDispatch=false const sortBy = _get(criteria, 'sortCriteria.sortBy') const direction = (_get(criteria, 'sortCriteria.direction') || 'ASC').toUpperCase() - const searchParameters = generateSearchParametersString(_get(criteria, 'filters', {}), + const filters = _get(criteria, 'filters', {}) + const searchParameters = generateSearchParametersString(filters, null, _get(criteria, 'savedChallengesOnly')) + // If we don't have a challenge Id then we need to do some limiting. + if (!filters.challengeId) { + const onlyEnabled = _isUndefined(criteria.onlyEnabled) ? + true : criteria.onlyEnabled + const challengeStatus = criteria.challengeStatus + if (challengeStatus) { + searchParameters.cStatus = challengeStatus.join(',') + } + + // ce: limit to enabled challenges + // pe: limit to enabled projects + searchParameters.ce = onlyEnabled ? 'true' : 'false' + searchParameters.pe = onlyEnabled ? 'true' : 'false' + } + + // If we are searching within map bounds we need to ensure the parent + // challenge is also within those bounds + if (filters.location === CHALLENGE_LOCATION_WITHIN_MAPBOUNDS) { + if (_isArray(criteria.boundingBox)) { + searchParameters.bb = criteria.boundingBox.join(',') + } + else { + searchParameters.bb = criteria.boundingBox + } + } + const fetchId = uuidv1() !skipDispatch && dispatch(receiveBoundedTasks(null, RequestStatus.inProgress, fetchId)) @@ -83,7 +123,7 @@ export const fetchBoundedTasks = function(criteria, limit=50, skipDispatch=false !skipDispatch && dispatch(receiveBoundedTasks(tasks, RequestStatus.success, fetchId, totalCount)) return {tasks, totalCount} - }).catch(error => { + }).catch(error => { dispatch(receiveBoundedTasks([], RequestStatus.error, fetchId)) dispatch(addError(AppErrors.boundedTask.fetchFailure)) console.log(error.response || error) @@ -91,6 +131,16 @@ export const fetchBoundedTasks = function(criteria, limit=50, skipDispatch=false } } +/** + * Clear the bounded tasks from the redux store + */ +export const clearBoundedTasks = function() { + return { + type: CLEAR_BOUNDED_TASKS, + receivedAt: Date.now() + } +} + // redux reducers export const currentBoundedTasks = function(state={}, action) { if (action.type === RECEIVE_BOUNDED_TASKS) { @@ -123,6 +173,9 @@ export const currentBoundedTasks = function(state={}, action) { return state } + else if (action.type === CLEAR_BOUNDED_TASKS) { + return {} + } else { return state } diff --git a/src/services/Task/ClusteredTask.js b/src/services/Task/ClusteredTask.js index 1dced2e72..3d94e0cbe 100644 --- a/src/services/Task/ClusteredTask.js +++ b/src/services/Task/ClusteredTask.js @@ -1,21 +1,11 @@ import uuidv1 from 'uuid/v1' import uuidTime from 'uuid-time' -import { defaultRoutes as api } from '../Server/Server' -import Endpoint from '../Server/Endpoint' import RequestStatus from '../Server/RequestStatus' -import { taskSchema } from './Task' -import { addError } from '../Error/Error' -import AppErrors from '../Error/AppErrors' -import _get from 'lodash/get' -import _map from 'lodash/map' import _each from 'lodash/each' -import _values from 'lodash/values' import _isArray from 'lodash/isArray' import _uniqBy from 'lodash/uniqBy' import _cloneDeep from 'lodash/cloneDeep' import _set from 'lodash/set' -import _omit from 'lodash/omit' -import _join from 'lodash/join' import { fetchBoundedTasks } from './BoundedTask' // redux actions @@ -62,54 +52,6 @@ export const clearClusteredTasks = function() { // async action creators -/** - * Retrieve clustered task data belonging to the given challenge - */ -export const fetchClusteredTasks = function(challengeId, isVirtualChallenge=false, criteria, limit=15000, mergeTasks=false, - excludeLocked=false, mergeOrIgnore=false) { - return function(dispatch) { - const fetchId = uuidv1() - - if (!mergeOrIgnore) { - dispatch(receiveClusteredTasks( - challengeId, isVirtualChallenge, [], RequestStatus.inProgress, fetchId - )) - } - - const statuses = _get(criteria, 'filters.status',[]) - const bounds = _join(_get(criteria, 'boundingBox'), ',') - - return new Endpoint( - (isVirtualChallenge ? api.virtualChallenge : api.challenge).clusteredTasks, { - schema: [ taskSchema() ], - variables: {id: challengeId}, - params: {limit, excludeLocked, tStatus: statuses.join(','), tbb: bounds}, - } - ).execute().then(normalizedResults => { - // Add parent field, and copy pointReview fields to top-level for - // backward compatibility (except reviewRequestedBy and reviewedBy) - let tasks = _values(_get(normalizedResults, 'entities.tasks', {})) - tasks = _map(tasks, task => - Object.assign(task, {parent: challengeId}, _omit(task.pointReview, ["reviewRequestedBy", "reviewedBy"])) - ) - - if (!mergeOrIgnore) { - dispatch(receiveClusteredTasks( - challengeId, isVirtualChallenge, tasks, RequestStatus.success, fetchId, mergeTasks - )) - } - - return tasks - }).catch((error) => { - dispatch(receiveClusteredTasks( - challengeId, isVirtualChallenge, [], RequestStatus.error, fetchId - )) - dispatch(addError(AppErrors.clusteredTask.fetchFailure)) - console.log(error.response || error) - }) - } -} - /** * Augment clustered task data with a bounded task fetch for the given * challenge (see fetchBoundedTasks for details), merging returned tasks with diff --git a/src/services/Task/Task.js b/src/services/Task/Task.js index cb0c53cd1..ae91ab2bf 100644 --- a/src/services/Task/Task.js +++ b/src/services/Task/Task.js @@ -21,7 +21,6 @@ import { placeSchema, fetchPlace } from '../Place/Place' import { commentSchema, receiveComments } from '../Comment/Comment' import { addServerError, addError } from '../Error/Error' import AppErrors from '../Error/AppErrors' -import { generateSearchParametersString } from '../Search/Search' import { ensureUserLoggedIn } from '../User/User' import { markReviewDataStale } from './TaskReview/TaskReview' import { receiveClusteredTasks } from './ClusteredTask' @@ -229,6 +228,18 @@ export const releaseTask = function(taskId) { } } +/** + * Refreshes an active task lock owned by the current user + */ +export const refreshTaskLock = function(taskId) { + return function(dispatch) { + return new Endpoint(api.task.refreshLock, { + schema: taskSchema(), + variables: {id: taskId} + }).execute() + } +} + /** * Mark the given task as completed with the given status. */ @@ -511,26 +522,6 @@ export const fetchChallengeTasks = function(challengeId, limit=50) { } } -/** - * Retrieve task clusters (up to the given number of points) matching the given - * search criteria. Criteria should contains a filters object and optional - * boundingBox string -- see Search.generateSearchParametersString for details - * of supported filters - */ -export const fetchTaskClusters = function(challengeId, criteria, points=25) { - return function(dispatch) { - const searchParameters = generateSearchParametersString(_get(criteria, 'filters', {}), - criteria.boundingBox, - _get(criteria, 'savedChallengesOnly')) - searchParameters.cid = challengeId - return new Endpoint( - api.challenge.taskClusters, { - params: {points, ...searchParameters}, - } - ).execute() - } -} - /* * Retrieve tasks geographically closest to the given task (up to the given * limit) belonging to the given challenge or virtual challenge. Returns an diff --git a/src/services/Task/TaskClusters.js b/src/services/Task/TaskClusters.js new file mode 100644 index 000000000..77ec83347 --- /dev/null +++ b/src/services/Task/TaskClusters.js @@ -0,0 +1,109 @@ +import uuidv1 from 'uuid/v1' +import uuidTime from 'uuid-time' +import RequestStatus from '../Server/RequestStatus' +import _isArray from 'lodash/isArray' +import _get from 'lodash/get' +import _isUndefined from 'lodash/isUndefined' +import { clearBoundedTasks } from './BoundedTask' +import { generateSearchParametersString } from '../Search/Search' +import { defaultRoutes as api } from '../Server/Server' +import Endpoint from '../Server/Endpoint' + +// redux actions +const RECEIVE_TASK_CLUSTERS = 'RECEIVE_TASK_CLUSTERS' +const CLEAR_TASK_CLUSTERS = 'CLEAR_TASK_CLUSTERS' + +// redux action creators +export const receiveTaskClusters = function(clusters, + status=RequestStatus.success, + fetchId) { + return { + type: RECEIVE_TASK_CLUSTERS, + status, + clusters, + fetchId, + receivedAt: Date.now(), + } +} + +/** + * Clear the task clusters from the redux store + */ +export const clearTaskClusters = function() { + return { + type: CLEAR_TASK_CLUSTERS, + receivedAt: Date.now() + } +} + +/** + * Retrieve task clusters (up to the given number of points) matching the given + * search criteria. Criteria should contains a filters object and optional + * boundingBox string -- see Search.generateSearchParametersString for details + * of supported filters + */ +export const fetchTaskClusters = function(challengeId, criteria, points=25) { + return function(dispatch) { + // The map is either showing task clusters or bounded tasks so we can't + // have both in redux. + dispatch(clearBoundedTasks()) + + const fetchId = uuidv1() + const filters = _get(criteria, 'filters', {}) + const searchParameters = generateSearchParametersString(filters, + criteria.boundingBox, + _get(criteria, 'savedChallengesOnly'), + criteria.searchQuery) + searchParameters.cid = challengeId + + // If we don't have a challenge Id then we need to do some limiting. + if (!challengeId) { + const onlyEnabled = _isUndefined(criteria.onlyEnabled) ? + true : criteria.onlyEnabled + const challengeStatus = criteria.challengeStatus + if (challengeStatus) { + searchParameters.cStatus = challengeStatus.join(',') + } + + // ce: limit to enabled challenges + // pe: limit to enabled projects + searchParameters.ce = onlyEnabled ? 'true' : 'false' + searchParameters.pe = onlyEnabled ? 'true' : 'false' + } + + return new Endpoint( + api.challenge.taskClusters, { + params: {points, ...searchParameters}, + } + ).execute() + .then(results => { + return dispatch(receiveTaskClusters( + results, RequestStatus.success, fetchId, + )) + }) + } +} + +// redux reducers +export const currentTaskClusters = function(state={}, action) { + if (action.type === RECEIVE_TASK_CLUSTERS) { + const fetchTime = parseInt(uuidTime.v1(action.fetchId)) + const lastFetch = state.fetchId ? parseInt(uuidTime.v1(state.fetchId)) : 0 + + if (fetchTime >= lastFetch) { + const merged = { + clusters: _isArray(action.clusters) ? action.clusters : [], + fetchId: action.fetchId, + } + return merged + } + + return state + } + else if (action.type === CLEAR_TASK_CLUSTERS) { + return {} + } + else { + return state + } +} diff --git a/src/tailwind.js b/src/tailwind.js index e4ffb8043..ed6b5cdde 100644 --- a/src/tailwind.js +++ b/src/tailwind.js @@ -54,6 +54,7 @@ let colors = { 'black-5': 'rgba(0, 0, 0, .05)', 'black-10': 'rgba(0, 0, 0, .1)', 'black-15': 'rgba(0, 0, 0, .15)', + 'black-40': 'rgba(0, 0, 0, .4)', 'black-50': 'rgba(0, 0, 0, .5)', grey: '#737373', 'grey-light': '#BDB8AE',