- + diff --git a/src/components/AdminPane/HOCs/WithChallengeMetrics/WithChallengeMetrics.js b/src/components/AdminPane/HOCs/WithChallengeMetrics/WithChallengeMetrics.js index d089d3dd6..951675a11 100644 --- a/src/components/AdminPane/HOCs/WithChallengeMetrics/WithChallengeMetrics.js +++ b/src/components/AdminPane/HOCs/WithChallengeMetrics/WithChallengeMetrics.js @@ -41,6 +41,7 @@ const WithChallengeMetrics = function(WrappedComponent, applyFilters = false) { if (challengeId && props.fetchChallengeActions) { this.setState({loading: true}) const criteria = {filters: _get(props.searchFilters, 'filters')} + criteria.invertFields = _get(props.searchCriteria, 'filters.invertFields') if (props.includeTaskStatuses && this.isFiltering(props.includeTaskStatuses)) { criteria.status = _keys(_pickBy(props.includeTaskStatuses)).join(',') @@ -80,24 +81,29 @@ const WithChallengeMetrics = function(WrappedComponent, applyFilters = false) { if (challengeId) { if (challengeId !== _get(this.props.challenge, 'id')) { - this.updateMetrics(this.props) + return this.updateMetrics(this.props) } if (this.props.includeTaskStatuses !== prevProps.includeTaskStatuses) { - this.updateMetrics(this.props) + return this.updateMetrics(this.props) } if (this.props.includeTaskReviewStatuses !== prevProps.includeTaskReviewStatuses) { - this.updateMetrics(this.props) + return this.updateMetrics(this.props) } if (this.props.includeTaskPriorities !== prevProps.includeTaskPriorities) { - this.updateMetrics(this.props) + return this.updateMetrics(this.props) } if (!_isEqual(_get(this.props.searchFilters, 'filters'), _get(prevProps.searchFilters, 'filters'))) { - this.updateMetrics(this.props) + return this.updateMetrics(this.props) + } + + if (!_isEqual(_get(this.props.searchCriteria, 'filters.invertFields'), + _get(prevProps.searchCriteria, 'filters.invertFields'))) { + return this.updateMetrics(this.props) } } } diff --git a/src/components/AdminPane/HOCs/WithChallengeReviewMetrics/WithChallengeReviewMetrics.js b/src/components/AdminPane/HOCs/WithChallengeReviewMetrics/WithChallengeReviewMetrics.js index 1dc8c4047..2aabdae37 100644 --- a/src/components/AdminPane/HOCs/WithChallengeReviewMetrics/WithChallengeReviewMetrics.js +++ b/src/components/AdminPane/HOCs/WithChallengeReviewMetrics/WithChallengeReviewMetrics.js @@ -27,6 +27,7 @@ export const WithChallengeReviewMetrics = function(WrappedComponent) { _merge(filters, _get(props.searchFilters, 'filters')) const criteria = {filters} + criteria.invertFields = _get(props.searchCriteria, 'filters.invertFields') if (props.includeTaskStatuses) { criteria.filters.status = _keys(_pickBy(props.includeTaskStatuses)).join(',') @@ -50,23 +51,23 @@ export const WithChallengeReviewMetrics = function(WrappedComponent) { componentDidUpdate(prevProps) { if (_get(prevProps.challenge, 'id') !== _get(this.props.challenge, 'id')) { - this.updateMetrics(this.props) + return this.updateMetrics(this.props) } if (this.props.includeTaskStatuses !== prevProps.includeTaskStatuses) { - this.updateMetrics(this.props) + return this.updateMetrics(this.props) } if (this.props.includeTaskReviewStatuses !== prevProps.includeTaskReviewStatuses) { - this.updateMetrics(this.props) + return this.updateMetrics(this.props) } if (this.props.includeTaskPriorities !== prevProps.includeTaskPriorities) { - this.updateMetrics(this.props) + return this.updateMetrics(this.props) } if (_get(this.props.searchFilters, 'filters') !== _get(prevProps.searchFilters, 'filters')) { - this.updateMetrics(this.props) + return this.updateMetrics(this.props) } } @@ -82,7 +83,8 @@ export const WithChallengeReviewMetrics = function(WrappedComponent) { const mapStateToProps = state => ( {reviewMetrics: _get(state, 'currentReviewTasks.metrics.reviewActions'), - reviewMetricsByPriority: _get(state, 'currentReviewTasks.metrics.priorityReviewActions')} + reviewMetricsByPriority: _get(state, 'currentReviewTasks.metrics.priorityReviewActions'), + reviewMetricsByTaskStatus: _get(state, 'currentReviewTasks.metrics.statusReviewActions') } ) const mapDispatchToProps = (dispatch, ownProps) => ({ diff --git a/src/components/AdminPane/HOCs/WithChallengeSnapshots/WithChallengeSnapshots.js b/src/components/AdminPane/HOCs/WithChallengeSnapshots/WithChallengeSnapshots.js index 8ef763c89..c33dda5a0 100644 --- a/src/components/AdminPane/HOCs/WithChallengeSnapshots/WithChallengeSnapshots.js +++ b/src/components/AdminPane/HOCs/WithChallengeSnapshots/WithChallengeSnapshots.js @@ -4,7 +4,8 @@ import _get from 'lodash/get' import _find from 'lodash/find' import _values from 'lodash/values' import { fetchChallengeSnapshotList, - recordChallengeSnapshot } from '../../../../services/Challenge/ChallengeSnapshot' + recordChallengeSnapshot, + removeChallengeSnapshot } from '../../../../services/Challenge/ChallengeSnapshot' import WithComputedMetrics from '../../HOCs/WithComputedMetrics/WithComputedMetrics' const WithChallengeSnapshots = function(WrappedComponent, applyFilters = false) { @@ -40,6 +41,15 @@ const WithChallengeSnapshots = function(WrappedComponent, applyFilters = false) } } + deleteSnapshot(props, snapshotId) { + if (snapshotId) { + this.setState({loading: true}) + removeChallengeSnapshot(snapshotId).then(() => { + this.updateSnapshots(props) + }) + } + } + setSelectedSnapshot = (snapshotId) => { if (snapshotId) { const snapshot = _find(this.state.snapshotList, s => s.id === snapshotId) @@ -79,6 +89,7 @@ const WithChallengeSnapshots = function(WrappedComponent, applyFilters = false) return this.recordSnapshot(this.props)} + deleteSnapshot={(snapshotId) => this.deleteSnapshot(this.props, snapshotId)} snapshotList={this.state.snapshotList} setSelectedSnapshot={this.setSelectedSnapshot} currentMetrics={this.props.taskMetrics} diff --git a/src/components/AdminPane/HOCs/WithManageableProjects/WithManageableProjects.js b/src/components/AdminPane/HOCs/WithManageableProjects/WithManageableProjects.js index aae3dbf8d..3198ec546 100644 --- a/src/components/AdminPane/HOCs/WithManageableProjects/WithManageableProjects.js +++ b/src/components/AdminPane/HOCs/WithManageableProjects/WithManageableProjects.js @@ -11,12 +11,14 @@ import { fetchManageableProjects, fetchProject, fetchProjectsById, addProjectManager, - setProjectManagerGroupType, + setProjectManagerRole, fetchProjectManagers, removeProjectManager, saveProject, removeProject, deleteProject} from '../../../../services/Project/Project' +import { setTeamProjectRole, removeTeamFromProject } + from '../../../../services/Team/Team' import { addChallenge, removeChallenge } from '../../../../services/Project/VirtualProject' import { fetchProjectChallengeListing } @@ -123,9 +125,11 @@ const mapDispatchToProps = dispatch => { fetchProjectChallengeListing, saveProject, addProjectManager, + setTeamProjectRole, fetchProjectManagers, - setProjectManagerGroupType, + setProjectManagerRole, removeProjectManager, + removeTeamFromProject, addChallenge, removeChallenge, }, dispatch) diff --git a/src/components/AdminPane/HOCs/WithProjectReviewMetrics/WithProjectReviewMetrics.js b/src/components/AdminPane/HOCs/WithProjectReviewMetrics/WithProjectReviewMetrics.js index dccae5930..8d94118cc 100644 --- a/src/components/AdminPane/HOCs/WithProjectReviewMetrics/WithProjectReviewMetrics.js +++ b/src/components/AdminPane/HOCs/WithProjectReviewMetrics/WithProjectReviewMetrics.js @@ -62,7 +62,8 @@ export const WithProjectReviewMetrics = function(WrappedComponent) { const mapStateToProps = state => ( {reviewMetrics: _get(state, 'currentReviewTasks.metrics.reviewActions'), - reviewMetricsByPriority: _get(state, 'currentReviewTasks.metrics.priorityReviewActions')} + reviewMetricsByPriority: _get(state, 'currentReviewTasks.metrics.priorityReviewActions'), + reviewMetricsByTaskStatus: _get(state, 'currentReviewTasks.metrics.statusReviewActions') } ) const mapDispatchToProps = (dispatch, ownProps) => ({ diff --git a/src/components/AdminPane/Manage/ChallengeCard/ChallengeControls.js b/src/components/AdminPane/Manage/ChallengeCard/ChallengeControls.js index 54e667482..544a9c061 100644 --- a/src/components/AdminPane/Manage/ChallengeCard/ChallengeControls.js +++ b/src/components/AdminPane/Manage/ChallengeCard/ChallengeControls.js @@ -1,7 +1,9 @@ import React, { Component } from 'react' import PropTypes from 'prop-types' +import classNames from 'classnames' import { FormattedMessage } from 'react-intl' import { Link } from 'react-router-dom' +import { CopyToClipboard } from 'react-copy-to-clipboard' import _get from 'lodash/get' import _isObject from 'lodash/isObject' import _isFinite from 'lodash/isFinite' @@ -65,7 +67,17 @@ export default class ChallengeControls extends Component { } - + {this.props.includeCopyURL && + +
+ +
+
+ } {!inVirtualProject && manager.canWriteProject(parent) &&
  • {/* eslint-disable-next-line jsx-a11y/anchor-is-valid */} - - {this.props.challenge.name} - {this.props.loadingChallenge && } - + + {this.props.loadingChallenge && }
  • diff --git a/src/components/AdminPane/Manage/ChallengeDashboard/Messages.js b/src/components/AdminPane/Manage/ChallengeDashboard/Messages.js index 5798c5963..3d1a2adf5 100644 --- a/src/components/AdminPane/Manage/ChallengeDashboard/Messages.js +++ b/src/components/AdminPane/Manage/ChallengeDashboard/Messages.js @@ -29,6 +29,11 @@ export default defineMessages({ defaultMessage: "Clone Challenge", }, + copyChallengeURLLabel: { + id: "Admin.ChallengeAnalysisTable.controls.copyChallengeURL.label", + defaultMessage: "Copy URL", + }, + deleteChallengeLabel: { id: "Admin.Challenge.controls.delete.label", defaultMessage: "Delete Challenge", diff --git a/src/components/AdminPane/Manage/ChallengeList/ChallengeList.js b/src/components/AdminPane/Manage/ChallengeList/ChallengeList.js index 873815798..87e7e0abc 100644 --- a/src/components/AdminPane/Manage/ChallengeList/ChallengeList.js +++ b/src/components/AdminPane/Manage/ChallengeList/ChallengeList.js @@ -33,6 +33,7 @@ export default class ChallengeList extends Component { hideTallyControl={this.props.hideTallyControl} showProjectName={this.props.project.isVirtual} link={link} + includeCopyURL /> ) }), diff --git a/src/components/AdminPane/Manage/ManageChallengeSnapshots/ManageChallengeSnapshots.js b/src/components/AdminPane/Manage/ManageChallengeSnapshots/ManageChallengeSnapshots.js new file mode 100644 index 000000000..3b41ab8a6 --- /dev/null +++ b/src/components/AdminPane/Manage/ManageChallengeSnapshots/ManageChallengeSnapshots.js @@ -0,0 +1,56 @@ +import React, { Component } from 'react' +import { FormattedMessage, FormattedDate, FormattedTime } from 'react-intl' +import _map from 'lodash/map' +import ConfirmAction from '../../../ConfirmAction/ConfirmAction' +import messages from './Messages' + +/** + * Presents a list of challenge snapshots and actions to perform on each + * snapshot such as delete. + * + * @author [Kelli Rotstan](https://github.com/krotstan) + */ +export class ManageChallengeSnapshots extends Component { + render() { + const snapshots = _map(this.props.snapshotList, snapshot => { + return ( +
  • + +
    + + {/* eslint-disable-next-line jsx-a11y/anchor-is-valid */} + this.props.deleteSnapshot(snapshot.id)}> + + + +
    +
  • + ) + }) + return ( +
    +
      {snapshots}
    +
    + ) + } +} + +export default ManageChallengeSnapshots diff --git a/src/components/AdminPane/Manage/ManageChallengeSnapshots/Messages.js b/src/components/AdminPane/Manage/ManageChallengeSnapshots/Messages.js new file mode 100644 index 000000000..07abe9091 --- /dev/null +++ b/src/components/AdminPane/Manage/ManageChallengeSnapshots/Messages.js @@ -0,0 +1,11 @@ +import { defineMessages } from 'react-intl' + +/** + * Internationalized messages for use with ManageChallenges. + */ +export default defineMessages({ + deleteSnapshot: { + id: "Admin.ManageChallengeSnapshots.deleteSnapshot.label", + defaultMessage: "Delete", + }, +}) diff --git a/src/components/AdminPane/Manage/ManageChallenges/EditChallenge/PriorityRuleGroup.js b/src/components/AdminPane/Manage/ManageChallenges/EditChallenge/PriorityRuleGroup.js index 9972cec2e..3865280de 100644 --- a/src/components/AdminPane/Manage/ManageChallenges/EditChallenge/PriorityRuleGroup.js +++ b/src/components/AdminPane/Manage/ManageChallenges/EditChallenge/PriorityRuleGroup.js @@ -25,6 +25,13 @@ export const preparePriorityRuleGroupForForm = (ruleObject, isNested=false) => { preparedGroup.ruleGroup.rules = combineRulesForForm( _map(ruleObject.rules, rule => preparePriorityRuleForForm(rule)) ) + + if (isNested && ruleObject.rules.length !== preparedGroup.ruleGroup.rules && + preparedGroup.ruleGroup.rules.length === 1) { + // We did some combining so let's submit the un-nested rule + return preparedGroup.ruleGroup.rules[0] + } + } } @@ -109,9 +116,21 @@ export const normalizeRuleForSaving = (rule, allowCSV=true) => { // If there are multiple, comma-separated values, split into separate rules // (ignoring commas in quoted strings) if (allowCSV && /,/.test(rule.value)) { - return _flatten(csvStringToArray(rule.value)).map(value => - normalizeRuleForSaving(Object.assign({}, rule, {value}), false) - ) + let condition = "OR" + + // Negative conditions need to be "AND" (eg. value not 1 AND not 2) + if (rule.operator === "not_equal" || + rule.operator === "not_contains") { + condition = "AND" + } + + let csvRule = { + condition, + rules: _flatten(csvStringToArray(rule.value)).map(value => + normalizeRuleForSaving(Object.assign({}, rule, {value}), false) + ) + } + return csvRule } // Due to react-jsonschema-form bug #768, the default operator values diff --git a/src/components/AdminPane/Manage/ManageChallenges/EditChallenge/PriorityRuleGroup.test.js b/src/components/AdminPane/Manage/ManageChallenges/EditChallenge/PriorityRuleGroup.test.js index 3bdf529b8..948020a61 100644 --- a/src/components/AdminPane/Manage/ManageChallenges/EditChallenge/PriorityRuleGroup.test.js +++ b/src/components/AdminPane/Manage/ManageChallenges/EditChallenge/PriorityRuleGroup.test.js @@ -133,9 +133,11 @@ describe("preparePriorityRuleGroupForSaving", () => { const result = JSON.parse(preparePriorityRuleGroupForSaving(basicFormGroup)) - expect(result.rules.length).toBe(2) - expect(result.rules[0].value).toEqual("firstTag.foo") - expect(result.rules[1].value).toEqual("firstTag.baz") + expect(result.rules.length).toBe(1) + expect(result.rules[0].rules.length).toBe(2) + expect(result.rules[0].condition).toEqual("OR") + expect(result.rules[0].rules[0].value).toEqual("firstTag.foo") + expect(result.rules[0].rules[1].value).toEqual("firstTag.baz") }) test("commas are ignored in quoted strings when splitting comma-separated values", () => { @@ -146,7 +148,8 @@ describe("preparePriorityRuleGroupForSaving", () => { JSON.parse(preparePriorityRuleGroupForSaving(basicFormGroup)) expect(result.rules.length).toBe(1) - expect(result.rules[0].value).toEqual("firstTag.foo,baz") + expect(result.rules[0].condition).toEqual("OR") + expect(result.rules[0].rules[0].value).toEqual("firstTag.foo,baz") }) }) diff --git a/src/components/AdminPane/Manage/ProjectsDashboard/ProjectsDashboard.js b/src/components/AdminPane/Manage/ProjectsDashboard/ProjectsDashboard.js index a01c16b3f..94ab45dec 100644 --- a/src/components/AdminPane/Manage/ProjectsDashboard/ProjectsDashboard.js +++ b/src/components/AdminPane/Manage/ProjectsDashboard/ProjectsDashboard.js @@ -74,19 +74,14 @@ export class ProjectsDashboard extends Component { return (
    - {!this.props.loadingProjects && this.props.projects.length === 0 ? -
    - -
    : - - } +
    ) } diff --git a/src/components/AdminPane/Manage/Widgets/ProjectManagersWidget/Messages.js b/src/components/AdminPane/Manage/Widgets/ProjectManagersWidget/Messages.js index 3ec7594fc..94b5fbc19 100644 --- a/src/components/AdminPane/Manage/Widgets/ProjectManagersWidget/Messages.js +++ b/src/components/AdminPane/Manage/Widgets/ProjectManagersWidget/Messages.js @@ -39,8 +39,28 @@ export default defineMessages({ defaultMessage: "Choose Role" }, - osmUsername: { + osmUsernamePlaceholder: { id: "Admin.ProjectManagers.controls.chooseOSMUser.placeholder", defaultMessage: "OpenStreetMap username" }, + + teamNamePlaceholder: { + id: "Admin.ProjectManagers.controls.chooseTeam.placeholder", + defaultMessage: "Team name" + }, + + teamOption: { + id: "Admin.ProjectManagers.options.teams.label", + defaultMessage: "Team" + }, + + userOption: { + id: "Admin.ProjectManagers.options.users.label", + defaultMessage: "User" + }, + + teamIndicator: { + id: "Admin.ProjectManagers.team.indicator", + defaultMessage: "Team" + }, }) diff --git a/src/components/AdminPane/Manage/Widgets/ProjectManagersWidget/ProjectManagersWidget.js b/src/components/AdminPane/Manage/Widgets/ProjectManagersWidget/ProjectManagersWidget.js index 30a22c167..da95e93bf 100644 --- a/src/components/AdminPane/Manage/Widgets/ProjectManagersWidget/ProjectManagersWidget.js +++ b/src/components/AdminPane/Manage/Widgets/ProjectManagersWidget/ProjectManagersWidget.js @@ -7,11 +7,12 @@ import _map from 'lodash/map' import _filter from 'lodash/filter' import _without from 'lodash/without' import _isFinite from 'lodash/isFinite' -import { GroupType, - mostPrivilegedGroupType, - messagesByGroupType } - from '../../../../../services/Project/GroupType/GroupType' -import WithOSMUserSearch from '../../../HOCs/WithOSMUserSearch/WithOSMUserSearch' +import { Role, + mostPrivilegedRole, + messagesByRole } + from '../../../../../services/Grant/Role' +import WithOSMUserSearch from '../../../../HOCs/WithOSMUserSearch/WithOSMUserSearch' +import WithTeamSearch from '../../../../HOCs/WithTeamSearch/WithTeamSearch' import AsManager from '../../../../../interactions/User/AsManager' import AsAvatarUser from '../../../../../interactions/User/AsAvatarUser' import BusySpinner from '../../../../BusySpinner/BusySpinner' @@ -29,14 +30,16 @@ const descriptor = { } const ChooseOSMUser = WithOSMUserSearch(AutosuggestTextBox) +const ChooseTeam = WithTeamSearch(AutosuggestTextBox) export default class ProjectManagersWidget extends Component { state = { loadingManagers: true, updatingManagers: [], - addManagerUsername: '', - addManagerOSMUser: null, + addManagerName: '', + addManagerObject: null, addingManager: false, + choosingTeam: false, } componentDidMount() { @@ -45,47 +48,74 @@ export default class ProjectManagersWidget extends Component { ) } - updateManagerRole = (managerOsmId, groupType) => { - if (groupType === 'remove') { - this.removeManager(managerOsmId) + switchManagerType = newType => { + this.setState({ + addManagerName: '', + addManagerObject: null, + choosingTeam: newType === 'team', + }) + } + + updateManagerRole = (manager, role) => { + if (role === 'remove') { + this.removeManager(manager) return } + const isTeam = _isFinite(manager.groupType) + const managerId = isTeam ? manager.id : manager.osmId this.setState({ - updatingManagers: this.state.updatingManagers.concat([managerOsmId]) + updatingManagers: this.state.updatingManagers.concat([managerId]) }) - this.props.setProjectManagerGroupType( - this.props.project.id, managerOsmId, true, groupType - ).then(() => this.setState({ - updatingManagers: _without(this.state.updatingManagers, managerOsmId) + const updateManager = + isTeam ? + this.props.setTeamProjectRole(this.props.project.id, managerId, role) : + this.props.setProjectManagerRole( + this.props.project.id, manager.osmId, true, role + ) + + updateManager.then(() => this.setState({ + updatingManagers: _without(this.state.updatingManagers, managerId) })) } - removeManager = (managerOsmId) => { + removeManager = (manager) => { + const isTeam = _isFinite(manager.groupType) + const managerId = isTeam ? manager.id : manager.osmId this.setState({ - updatingManagers: this.state.updatingManagers.concat([managerOsmId]) + updatingManagers: this.state.updatingManagers.concat([managerId]) }) - this.props.removeProjectManager( - this.props.project.id, managerOsmId, true - ).then(() => this.setState({ - updatingManagers: _without(this.state.updatingManagers, managerOsmId) + const removeManager = + isTeam ? + this.props.removeTeamFromProject(this.props.project.id, manager.id) : + this.props.removeProjectManager(this.props.project.id, manager.osmId, true) + + removeManager.then(() => this.setState({ + updatingManagers: _without(this.state.updatingManagers, managerId) })) } - addManager = groupType => { - if (!_isFinite(parseInt(groupType, 10))) { + addManager = role => { + if (!_isFinite(parseInt(role, 10))) { return } this.setState({addingManager: true}) + const addManager = this.state.choosingTeam ? + this.props.setTeamProjectRole( + this.props.project.id, + this.state.addManagerObject.id, + role + ) : + this.props.addProjectManager( + this.props.project.id, this.state.addManagerName, role + ) - this.props.addProjectManager( - this.props.project.id, this.state.addManagerUsername, groupType - ).then(() => this.setState({ - addManagerUsername: '', - addManagerOSMUser: null, + addManager.then(() => this.setState({ + addManagerName: '', + addManagerObject: null, addingManager: false, })) } @@ -97,82 +127,98 @@ export default class ProjectManagersWidget extends Component { const user = AsManager(this.props.user) - const groupTypeOptions = [ - , - , - , ] const adminManagers = - _filter(this.props.project.managers, - manager => mostPrivilegedGroupType(manager.groupTypes) === GroupType.admin) - - let managers = _map(this.props.project.managers, manager => { - const managerRole = mostPrivilegedGroupType(manager.groupTypes) - const isProjectOwner = manager.osmId === this.props.project.owner - const isLastAdmin = managerRole === GroupType.admin && adminManagers.length < 2 - - // Add remove-manager option to dropdown if appropriate - const dropdownOptions = - (user.canAdministrateProject(this.props.project) && !isLastAdmin && !isProjectOwner) ? - groupTypeOptions.concat([ - - ]) : - groupTypeOptions - - return ( -
    -
    -
    - -
    -
    + _filter(this.props.project.managers.concat(this.props.project.teamManagers), + manager => mostPrivilegedRole(manager.roles) === Role.admin) -
    - - {manager.displayName} - -
    + let managers = _map( + this.props.project.managers.concat(this.props.project.teamManagers), + manager => { + const isTeam = _isFinite(manager.groupType) + const managerRole = mostPrivilegedRole(manager.roles) + const isLastAdmin = managerRole === Role.admin && adminManagers.length < 2 + + // Add remove-manager option to dropdown if appropriate + const dropdownOptions = + user.canAdministrateProject(this.props.project) && !isLastAdmin ? + roleOptions.concat([ + + ]) : + roleOptions -
    - -
    - {this.state.updatingManagers.indexOf(manager.osmId) !== -1 && } - - {isLastAdmin || isProjectOwner ? -
    - {isProjectOwner ? - : - - } -
    : - + return ( +
    + {isTeam ? + +
    + + + +
    +
    + {manager.name} +
    +
    : + +
    +
    + +
    +
    +
    + + {manager.displayName} + +
    +
    } + +
    + +
    + {this.state.updatingManagers.indexOf(isTeam ? manager.id : manager.osmId) !== -1 ? + : + + {isLastAdmin || !user.canAdministrateProject(this.props.project) ? + : + + } + + } +
    -
    - ) - }) + ) + } + ) if (managers.length === 0) { managers = ( @@ -190,22 +236,51 @@ export default class ProjectManagersWidget extends Component {
    - this.setState({ - addManagerUsername: username - })} - onChange={osmUser => this.setState({ - addManagerOSMUser: osmUser - })} - placeholder={this.props.intl.formatMessage(messages.osmUsername)} - fixedMenu - /> + {this.state.choosingTeam ? + this.setState({ + addManagerName: username + })} + onChange={osmUser => this.setState({ + addManagerObject: osmUser + })} + placeholder={this.props.intl.formatMessage(messages.teamNamePlaceholder)} + fixedMenu + /> : + this.setState({ + addManagerName: username + })} + onChange={osmUser => this.setState({ + addManagerObject: osmUser + })} + placeholder={this.props.intl.formatMessage(messages.osmUsernamePlaceholder)} + fixedMenu + /> + }
    + {!this.state.addingManager && !this.state.addManagerObject && + + } {this.state.addingManager && } - {!this.state.addingManager && this.state.addManagerOSMUser && + {!this.state.addingManager && this.state.addManagerObject && }
    diff --git a/src/components/ChallengeDetail/ChallengeDetail.js b/src/components/ChallengeDetail/ChallengeDetail.js index 96ee5726d..611c21cea 100644 --- a/src/components/ChallengeDetail/ChallengeDetail.js +++ b/src/components/ChallengeDetail/ChallengeDetail.js @@ -75,8 +75,9 @@ export class ChallengeDetail extends Component { this.props.unsaveChallenge(this.props.user.id, challenge.id) } className="mr-button" + title={this.props.intl.formatMessage(messages.removeFromFavorites)} > - + ) } else { @@ -87,8 +88,9 @@ export class ChallengeDetail extends Component { this.props.saveChallenge(this.props.user.id, challenge.id) } className="mr-button" + title={this.props.intl.formatMessage(messages.saveToFavorites)} > - + ) } diff --git a/src/components/ChallengeDetail/Messages.js b/src/components/ChallengeDetail/Messages.js index e7b49830b..d25475727 100644 --- a/src/components/ChallengeDetail/Messages.js +++ b/src/components/ChallengeDetail/Messages.js @@ -9,19 +9,29 @@ export default defineMessages({ defaultMessage: 'Go Back', }, - unsave: { - id: 'ChallengeDetails.controls.unsave.label', - defaultMessage: 'Unsave', + start: { + id: 'ChallengeDetails.controls.start.label', + defaultMessage: 'Start', }, - save: { - id: 'ChallengeDetails.controls.save.label', - defaultMessage: 'Save', + favorite: { + id: 'ChallengeDetails.controls.favorite.label', + defaultMessage: 'Favorite', }, - start: { - id: 'ChallengeDetails.controls.start.label', - defaultMessage: 'Start', + saveToFavorites: { + id: 'ChallengeDetails.controls.favorite.tooltip', + defaultMessage: 'Save to favorites', + }, + + unfavorite: { + id: 'ChallengeDetails.controls.unfavorite.label', + defaultMessage: 'Unfavorite', + }, + + removeFromFavorites: { + id: 'ChallengeDetails.controls.unfavorite.tooltip', + defaultMessage: 'Remove from favorites', }, manageLabel: { diff --git a/src/components/ChallengeNameLink/ChallengeNameLink.js b/src/components/ChallengeNameLink/ChallengeNameLink.js index 3226325bc..fe5923fae 100644 --- a/src/components/ChallengeNameLink/ChallengeNameLink.js +++ b/src/components/ChallengeNameLink/ChallengeNameLink.js @@ -12,14 +12,15 @@ import ShareLink from '../ShareLink/ShareLink' */ export default class ChallengeNameLink extends Component { render() { + const challenge = _get(this.props.task, 'parent') || this.props.challenge || {} const challengeBrowseRoute = - `/browse/challenges/${_get(this.props.task, 'parent.id', '')}` + `/browse/challenges/${challenge.id}` return ( - + - {_get(this.props.task, 'parent.name')} + {challenge.name} diff --git a/src/components/ChallengeProgress/ChallengeProgress.js b/src/components/ChallengeProgress/ChallengeProgress.js index 97fc5843d..73f921e67 100644 --- a/src/components/ChallengeProgress/ChallengeProgress.js +++ b/src/components/ChallengeProgress/ChallengeProgress.js @@ -249,6 +249,11 @@ export class ChallengeProgress extends Component { {Math.floor(seconds / 60)}m {Math.floor(seconds) % 60}s + {this.props.noteAvgExcludesSkip && + + + + }
    } diff --git a/src/components/ChallengeProgress/Messages.js b/src/components/ChallengeProgress/Messages.js index e14b69df7..9f18a0d37 100644 --- a/src/components/ChallengeProgress/Messages.js +++ b/src/components/ChallengeProgress/Messages.js @@ -43,4 +43,9 @@ export default defineMessages({ id: "ChallengeProgress.metrics.averageTime.label", defaultMessage: "Avg time per task:" }, + + excludesSkip: { + id: "ChallengeProgress.metrics.excludesSkip.label", + defaultMessage: "(excluding skipped tasks)" + }, }) diff --git a/src/components/ConfirmAction/ConfirmAction.js b/src/components/ConfirmAction/ConfirmAction.js index d171d099f..24e2ca692 100644 --- a/src/components/ConfirmAction/ConfirmAction.js +++ b/src/components/ConfirmAction/ConfirmAction.js @@ -60,7 +60,7 @@ export default class ConfirmAction extends Component { {ControlWithConfirmation} } - > - + +
    +

    + +

    +
    +
    +
    + +
    +
    + +
    ) } } -const FeaturedList = function(props) { - const projectItems = - _compact(_map(props.featuredProjects, project => { - if (!_isFinite(_get(project, 'id'))) { - return null - } +const FeaturedList = props => { + const carouselRef = useRef(null) - return ( -
  • - - {project.displayName || project.name} - - - - -
  • - ) + const onNextStart = (currentItem, nextItem) => { + if (currentItem.index === nextItem.index) { + // we hit the last item, go to first item + carouselRef.current.goTo(0); } - )) + } - const challengeItems = - _compact(_map(props.featuredChallenges, challenge => { - if (!_isFinite(_get(challenge, 'id'))) { - return null + const projectCards = _map(props.featuredProjects.map, project => + } + /> + ) - return ( -
  • - - {challenge.name} - -
  • - ) - } - )) + const challengeCards = _map(props.featuredChallenges, challenge => + + } + /> + ) - const featuredItems = projectItems.concat(challengeItems) + const featuredItems = projectCards.concat(challengeCards) + if (featuredItems.length === 0) { + return ( +
    + +
    + ) + } return ( - featuredItems.length > 0 ? -
      + {featuredItems} -
    : -
    - -
    + ) } +const BrowseControl = props => { + return ( + + + + ) +} + +const ArrowControl = ({ type, onClick }) => ( +
    + +
    +) + +const PaginationControl = ({ pages, activePage, onClick }) => ( +
    + {pages.map(page => ( + onClick(page)} + /> + ))} +
    +) + registerWidgetType(WithFeatured(FeaturedChallengesWidget), descriptor) diff --git a/src/components/FeaturedChallenges/Messages.js b/src/components/FeaturedChallenges/Messages.js index 61985a24a..f3d582b74 100644 --- a/src/components/FeaturedChallenges/Messages.js +++ b/src/components/FeaturedChallenges/Messages.js @@ -6,7 +6,7 @@ import { defineMessages } from 'react-intl' export default defineMessages({ header: { id: "FeaturedChallenges.header", - defaultMessage: "Featured", + defaultMessage: "Challenge Highlights", }, nothingFeatured: { @@ -18,4 +18,9 @@ export default defineMessages({ id: "FeaturedChallenges.projectIndicator.label", defaultMessage: "Project" }, + + browseFeaturedLabel: { + id: "FeaturedChallenges.browse", + defaultMessage: "Explore", + }, }) diff --git a/src/components/HOCs/WithCurrentTask/WithCurrentTask.js b/src/components/HOCs/WithCurrentTask/WithCurrentTask.js index e86521080..75f0279ac 100644 --- a/src/components/HOCs/WithCurrentTask/WithCurrentTask.js +++ b/src/components/HOCs/WithCurrentTask/WithCurrentTask.js @@ -19,7 +19,8 @@ import { taskDenormalizationSchema, addTaskBundleComment, completeTask, completeTaskBundle, - updateTaskTags } from '../../../services/Task/Task' + updateTaskTags, + updateCompletionResponses } from '../../../services/Task/Task' import { fetchTaskForReview } from '../../../services/Task/TaskReview/TaskReview' import { fetchChallenge, fetchParentProject } from '../../../services/Challenge/Challenge' @@ -266,6 +267,10 @@ export const mapDispatchToProps = (dispatch, ownProps) => { } }, + saveCompletionResponses: (task, completionResponses) => { + dispatch(updateCompletionResponses(task.id, completionResponses)) + }, + fetchOSMUser, fetchOSMData: bbox => { return fetchOSMData(bbox).catch(error => { diff --git a/src/components/HOCs/WithFilterCriteria/WithFilterCriteria.js b/src/components/HOCs/WithFilterCriteria/WithFilterCriteria.js index 11a421e1a..10e3472c3 100644 --- a/src/components/HOCs/WithFilterCriteria/WithFilterCriteria.js +++ b/src/components/HOCs/WithFilterCriteria/WithFilterCriteria.js @@ -9,7 +9,8 @@ import { fromLatLngBounds, GLOBAL_MAPBOUNDS } from '../../../services/MapBounds/ const DEFAULT_PAGE_SIZE = 20 const DEFAULT_CRITERIA = {sortCriteria: {sortBy: 'name', direction: 'DESC'}, - pageSize: DEFAULT_PAGE_SIZE, filters:{}} + pageSize: DEFAULT_PAGE_SIZE, filters:{}, + invertFields: {}} /** * WithFilterCriteria keeps track of the current criteria being used @@ -30,6 +31,7 @@ export const WithFilterCriteria = function(WrappedComponent) { criteria.sortCriteria = newCriteria.sortCriteria criteria.page = newCriteria.page criteria.filters = newCriteria.filters + criteria.includeTags = newCriteria.includeTags this.setState({criteria}) if (this.props.setSearchFilters) { @@ -60,6 +62,15 @@ export const WithFilterCriteria = function(WrappedComponent) { this.setState({criteria}) } + invertField = (fieldName) => { + const criteria = _cloneDeep(this.state.criteria) + criteria.invertFields[fieldName] = !criteria.invertFields[fieldName] + this.setState({criteria}) + if (this.props.setSearchFilters) { + this.props.setSearchFilters(criteria) + } + } + clearTaskPropertyCriteria = () => { const criteria = _cloneDeep(this.state.criteria) criteria.filters.taskPropertySearch = null @@ -181,6 +192,7 @@ export const WithFilterCriteria = function(WrappedComponent) { updateReviewTasks={(criteria) => this.update(this.props, criteria)} updateTaskPropertyCriteria={this.updateTaskPropertyCriteria} clearTaskPropertyCriteria={this.clearTaskPropertyCriteria} + invertField={this.invertField} refresh={this.refresh} criteria={criteria} pageSize={criteria.pageSize} diff --git a/src/components/AdminPane/HOCs/WithOSMUserSearch/WithOSMUserSearch.js b/src/components/HOCs/WithOSMUserSearch/WithOSMUserSearch.js similarity index 97% rename from src/components/AdminPane/HOCs/WithOSMUserSearch/WithOSMUserSearch.js rename to src/components/HOCs/WithOSMUserSearch/WithOSMUserSearch.js index d74b95054..a12f7cf51 100644 --- a/src/components/AdminPane/HOCs/WithOSMUserSearch/WithOSMUserSearch.js +++ b/src/components/HOCs/WithOSMUserSearch/WithOSMUserSearch.js @@ -3,7 +3,7 @@ import _isEmpty from 'lodash/isEmpty' import _filter from 'lodash/filter' import _startsWith from 'lodash/startsWith' import _debounce from 'lodash/debounce' -import { findUser } from '../../../../services/User/User' +import { findUser } from '../../../services/User/User' /** * WithOSMUserSearch provides a findUser function to the wrapped component that allows diff --git a/src/components/HOCs/WithReviewMetrics/WithReviewMetrics.js b/src/components/HOCs/WithReviewMetrics/WithReviewMetrics.js index e23bb9851..a7dace6f8 100644 --- a/src/components/HOCs/WithReviewMetrics/WithReviewMetrics.js +++ b/src/components/HOCs/WithReviewMetrics/WithReviewMetrics.js @@ -45,6 +45,7 @@ export const WithReviewMetrics = function(WrappedComponent) { return ( ) } @@ -53,7 +54,8 @@ export const WithReviewMetrics = function(WrappedComponent) { const mapStateToProps = state => { return ({ reviewMetrics: _get(state, 'currentReviewTasks.metrics.reviewActions'), - reviewMetricsByPriority: _get(state, 'currentReviewTasks.metrics.priorityReviewActions') }) + reviewMetricsByPriority: _get(state, 'currentReviewTasks.metrics.priorityReviewActions'), + reviewMetricsByTaskStatus: _get(state, 'currentReviewTasks.metrics.statusReviewActions') }) } const mapDispatchToProps = (dispatch, ownProps) => ({ diff --git a/src/components/HOCs/WithReviewTasks/WithReviewTasks.js b/src/components/HOCs/WithReviewTasks/WithReviewTasks.js index 9b42f0470..f65307836 100644 --- a/src/components/HOCs/WithReviewTasks/WithReviewTasks.js +++ b/src/components/HOCs/WithReviewTasks/WithReviewTasks.js @@ -21,7 +21,8 @@ import { buildSearchCriteria } from '../../../services/SearchCriteria/SearchCrit const DEFAULT_PAGE_SIZE = 20 -const DEFAULT_CRITERIA = {sortCriteria: {sortBy: 'mappedOn', direction: 'ASC'}, pageSize: DEFAULT_PAGE_SIZE} +const DEFAULT_CRITERIA = {sortCriteria: {sortBy: 'mappedOn', direction: 'ASC'}, + pageSize: DEFAULT_PAGE_SIZE, invertFields: {}} /** * WithReviewTasks retrieves tasks that need to be Reviewed @@ -56,49 +57,65 @@ export const WithReviewTasks = function(WrappedComponent, reviewStatus=0) { this.setState({criteria: typedCriteria}) } + invertField = (fieldName) => { + const typedCriteria = _cloneDeep(this.state.criteria) + typedCriteria[this.props.reviewTasksType].invertFields = + typedCriteria[this.props.reviewTasksType].invertFields || {} + typedCriteria[this.props.reviewTasksType].invertFields[fieldName] = + !typedCriteria[this.props.reviewTasksType].invertFields[fieldName] + + this.setState({criteria: typedCriteria}) + this.update(this.props, typedCriteria[this.props.reviewTasksType]) + } + update(props, criteria) { + const searchOnCriteria = _cloneDeep(criteria) const userId = _get(props, 'user.id') const pageSize = _get(this.state.criteria[props.reviewTasksType], 'pageSize') || DEFAULT_PAGE_SIZE - if (_isUndefined(criteria.savedChallengesOnly)) { - criteria.savedChallengesOnly = _get(this.state.criteria[this.props.reviewTasksType], "savedChallengesOnly") + if (!criteria.invertFields) { + searchOnCriteria.invertFields = this.state.criteria[props.reviewTasksType].invertFields + } + + if (_isUndefined(searchOnCriteria.savedChallengesOnly)) { + searchOnCriteria.savedChallengesOnly = _get(this.state.criteria[this.props.reviewTasksType], "savedChallengesOnly") } - if (_isUndefined(criteria.excludeOtherReviewers)) { + if (_isUndefined(searchOnCriteria.excludeOtherReviewers)) { // Exclude reviews assigned to other reviewers by default - criteria.excludeOtherReviewers = _get(this.state.criteria[this.props.reviewTasksType], "excludeOtherReviewers", true) + searchOnCriteria.excludeOtherReviewers = _get(this.state.criteria[this.props.reviewTasksType], "excludeOtherReviewers", true) } // We need to update our list of challenges since some challenges may // have been excluded on initial fetch because the list was limited to // taskStatus 'fixed' and 'excludeOtherReviewers' by default. - if (criteria.excludeOtherReviewers === false || - criteria.filters.status !== + if (searchOnCriteria.excludeOtherReviewers === false || + searchOnCriteria.filters.status !== _get(this.state.criteria[this.props.reviewTasksType], "filters.status")) { this.props.updateReviewChallenges(this.props.reviewTasksType) } const typedCriteria = _cloneDeep(this.state.criteria) - typedCriteria[props.reviewTasksType] = criteria + typedCriteria[props.reviewTasksType] = searchOnCriteria typedCriteria[props.reviewTasksType].pageSize = pageSize this.setState({loading: true, criteria: typedCriteria}) switch(props.reviewTasksType) { case ReviewTasksType.reviewedByMe: - return props.updateUserReviewedTasks(userId, criteria, pageSize).then(() => { + return props.updateUserReviewedTasks(userId, searchOnCriteria, pageSize).then(() => { this.setState({loading: false}) }) case ReviewTasksType.toBeReviewed: - return props.updateReviewNeededTasks(criteria, pageSize).then(() => { + return props.updateReviewNeededTasks(searchOnCriteria, pageSize).then(() => { this.setState({loading: false}) }) case ReviewTasksType.allReviewedTasks: - return props.updateReviewedTasks(userId, criteria, pageSize).then(() => { + return props.updateReviewedTasks(userId, searchOnCriteria, pageSize).then(() => { this.setState({loading: false}) }) case ReviewTasksType.myReviewedTasks: default: - return props.updateMapperReviewedTasks(userId, criteria, pageSize).then(() => { + return props.updateMapperReviewedTasks(userId, searchOnCriteria, pageSize).then(() => { this.setState({loading: false}) }) } @@ -168,6 +185,7 @@ export const WithReviewTasks = function(WrappedComponent, reviewStatus=0) { loading={this.state.loading} reviewChallenges={reviewChallenges} reviewProjects={this.props.currentReviewTasks.reviewProjects} + invertField={this.invertField} {..._omit(this.props, ['updateReviewTasks'])} />) } } diff --git a/src/components/HOCs/WithTaskReview/WithTaskReview.js b/src/components/HOCs/WithTaskReview/WithTaskReview.js index 4d993cbcd..0062300f7 100644 --- a/src/components/HOCs/WithTaskReview/WithTaskReview.js +++ b/src/components/HOCs/WithTaskReview/WithTaskReview.js @@ -34,7 +34,7 @@ const mapDispatchToProps = (dispatch, ownProps) => { visitTaskForReview(loadBy, url, nextTask)) }).catch(error => { console.log(error) - url.push('/review') + url.push('/review/tasksToBeReviewed') }) }, diff --git a/src/components/HOCs/WithTeamSearch/WithTeamSearch.js b/src/components/HOCs/WithTeamSearch/WithTeamSearch.js new file mode 100644 index 000000000..ab83ecca4 --- /dev/null +++ b/src/components/HOCs/WithTeamSearch/WithTeamSearch.js @@ -0,0 +1,70 @@ +import React, { Component } from 'react' +import _isEmpty from 'lodash/isEmpty' +import _filter from 'lodash/filter' +import _startsWith from 'lodash/startsWith' +import _debounce from 'lodash/debounce' +import { findTeam } from '../../../services/Team/Team' + +/** + * WithTeamSearch provides a findTeam function to the wrapped component that + * allows it to initiate searches for MapRoulette teams by name or name + * fragment + * + * @author [Neil Rotstan](https://github.com/nrotstan) + */ +const WithTeamSearch = function(WrappedComponent) { + return class extends Component { + state = { + isSearchingTeams: false, + teamResults: [], + } + + /** + * @private + */ + performSearch = _debounce(teamName => { + this.setState({isSearchingTeams: true}) + + findTeam(teamName).then(results => { + this.setState({isSearchingTeams: false, teamResults: results}) + }) + }, 1000, {leading: true}) + + /** + * Initiates search for team with the given name or name fragment + */ + searchTeam = teamName => { + if (_isEmpty(teamName)) { + this.setState({teamResults: []}) + return + } + + // Start off by filtering our existing search results so that we don't continue + // to show results that no longer match the new team name + this.setState({ + isSearchingTeams: true, + teamResults: _filter(this.state.teamResults, result => + _startsWith(result.name.toLowerCase(), teamName.toLowerCase())) + }) + + this.performSearch(teamName) + } + + teamKey = team => team.id + + teamLabel = team => team.name + + render() { + return ( + + ) + } + } +} + +export default WithTeamSearch diff --git a/src/components/HOCs/WithUserMetrics/WithUserMetrics.js b/src/components/HOCs/WithUserMetrics/WithUserMetrics.js index a99141757..4b44a458a 100644 --- a/src/components/HOCs/WithUserMetrics/WithUserMetrics.js +++ b/src/components/HOCs/WithUserMetrics/WithUserMetrics.js @@ -13,7 +13,7 @@ import { CUSTOM_RANGE, ALL_TIME } * * @author [Kelli Rotstan](https://github.com/krotstan) */ -export const WithUserMetrics = function(WrappedComponent) { +export const WithUserMetrics = function(WrappedComponent, userProp) { return class extends Component { state = { loading: false, @@ -28,10 +28,10 @@ export const WithUserMetrics = function(WrappedComponent) { updateAllMetrics(props) { this.setState({loading: true}) - if ( this.props.targetUser && - (!_get(this.props.targetUser, 'settings.leaderboardOptOut') || - _get(this.props.targetUser, 'id') === _get(this.props.currentUser, 'userId'))) { - fetchLeaderboardForUser(this.props.targetUser.id, 0, -1).then(userLeaderboard => { + if ( this.props[userProp] && + (!_get(this.props[userProp], 'settings.leaderboardOptOut') || + _get(this.props[userProp], 'id') === _get(this.props.currentUser, 'userId'))) { + fetchLeaderboardForUser(this.props[userProp].id, 0, -1).then(userLeaderboard => { this.setState({loading: false, leaderboardMetrics: userLeaderboard[0]}) }) @@ -40,8 +40,8 @@ export const WithUserMetrics = function(WrappedComponent) { } updateUserMetrics(props) { - if (!_get(this.props.targetUser, 'settings.leaderboardOptOut') || - _get(this.props.targetUser, 'id') === _get(this.props.currentUser, 'userId')) { + if (!_get(this.props[userProp], 'settings.leaderboardOptOut') || + _get(this.props[userProp], 'id') === _get(this.props.currentUser, 'userId')) { const startDate = _get(this.state.tasksCompletedDateRange, 'length', 0) === 2 ? format(this.state.tasksCompletedDateRange[0], 'YYYY-MM-DD') : null @@ -61,7 +61,7 @@ export const WithUserMetrics = function(WrappedComponent) { const reviewerEnd = _get(this.state.tasksReviewerDateRange, 'length', 0) === 2 ? format(this.state.tasksReviewerDateRange[1], 'YYYY-MM-DD') : null - fetchUserMetrics(this.props.targetUser.id, + fetchUserMetrics(this.props[userProp].id, this.state.tasksCompletedMonthsPast, this.state.tasksReviewedMonthsPast, this.state.tasksReviewerMonthsPast, @@ -125,13 +125,13 @@ export const WithUserMetrics = function(WrappedComponent) { } componentDidMount() { - if (this.props.targetUser) { + if (this.props[userProp]) { this.updateAllMetrics(this.props) } } componentDidUpdate(prevProps, prevState) { - if (prevProps.targetUser !== this.props.targetUser) { + if (prevProps[userProp] !== this.props[userProp]) { this.updateAllMetrics(this.props) } @@ -192,5 +192,5 @@ const mapStateToProps = state => ({}) const mapDispatchToProps = (dispatch, ownProps) => ({ }) -export default WrappedComponent => - connect(mapStateToProps, mapDispatchToProps)(WithUserMetrics(WrappedComponent)) +export default (WrappedComponent, userProp="targetUser") => + connect(mapStateToProps, mapDispatchToProps)(WithUserMetrics(WrappedComponent, userProp)) diff --git a/src/components/HOCs/WithWidgetWorkspaces/WithWidgetWorkspaces.js b/src/components/HOCs/WithWidgetWorkspaces/WithWidgetWorkspaces.js index b5adef8c4..a0f31ba0e 100644 --- a/src/components/HOCs/WithWidgetWorkspaces/WithWidgetWorkspaces.js +++ b/src/components/HOCs/WithWidgetWorkspaces/WithWidgetWorkspaces.js @@ -16,6 +16,7 @@ import { exportWorkspaceConfiguration, importWorkspaceConfiguration, ensurePermanentWidgetsAdded, + widgetDescriptor, } from '../../../services/Widget/Widget' import SignIn from '../../../pages/SignIn/SignIn' import WithCurrentUser from '../WithCurrentUser/WithCurrentUser' @@ -125,15 +126,26 @@ export const WithWidgetWorkspaces = function(WrappedComponent, }) } else { - // A layout was provided. If heights and/or widths were omitted, fill - // them in using component defaults. + // A layout was provided. If heights and/or widths were omitted or don't meet + // current minimums, fill them in from the widget descriptors _each(configuration.layout, (widgetLayout, index) => { + const descriptor = widgetDescriptor(configuration.widgets[index].widgetKey) + if (!descriptor) { + return + } + if (!_isFinite(widgetLayout.w)) { - widgetLayout.w = configuration.widgets[index].defaultWidth + widgetLayout.w = descriptor.defaultWidth + } + else if ((_isFinite(descriptor.minWidth) && widgetLayout.w < descriptor.minWidth)) { + widgetLayout.w = descriptor.minWidth } if (!_isFinite(widgetLayout.h)) { - widgetLayout.h = configuration.widgets[index].defaultHeight + widgetLayout.h = descriptor.defaultHeight + } + else if ((_isFinite(descriptor.minHeight) && widgetLayout.h < descriptor.minHeight)) { + widgetLayout.h = descriptor.minHeight } }) } diff --git a/src/components/KeywordAutosuggestInput/InTableTagFilter.js b/src/components/KeywordAutosuggestInput/InTableTagFilter.js new file mode 100644 index 000000000..9556939be --- /dev/null +++ b/src/components/KeywordAutosuggestInput/InTableTagFilter.js @@ -0,0 +1,67 @@ +import React, { Component } from 'react' +import { FormattedMessage } from 'react-intl' +import KeywordAutosuggestInput from './KeywordAutosuggestInput' +import External from '../External/External' +import Modal from '../Modal/Modal' +import messages from './Messages' + +/** + * Builds an input field with the KeywordAutosuggestInput onFocus + * that works as in table column header filter. + * @author [Kelli Rotstan](https://github.com/krotstan) + */ +export default class InTableTagFilter extends Component { + state = { + showTagChooser: false, + currentValue: null + } + + performSearch = () => { + if (this.state.currentValue !== null) { + this.props.onChange(this.state.currentValue) + } + this.setState({showTagChooser: false, currentValue: null}) + } + + render() { + return ( +
    + { + if (!this.state.showTagChooser) { + this.setState({showTagChooser: true}) + } + }} + /> + + + +
    +

    + +

    + { + this.setState({currentValue: tags}) + }} + formData={this.state.currentValue === null ? + this.props.value : this.state.currentValue} + /> + +
    +
    +
    +
    + ) + } +} diff --git a/src/components/KeywordAutosuggestInput/Messages.js b/src/components/KeywordAutosuggestInput/Messages.js index 188706a56..2c9461f20 100644 --- a/src/components/KeywordAutosuggestInput/Messages.js +++ b/src/components/KeywordAutosuggestInput/Messages.js @@ -8,4 +8,19 @@ export default defineMessages({ id: "KeywordAutosuggestInput.controls.addKeyword.placeholder", defaultMessage: "Add keyword" }, + + filterTags: { + id: "KeywordAutosuggestInput.controls.filterTags.placeholder", + defaultMessage: "Filter Tags" + }, + + chooseTags: { + id: "KeywordAutosuggestInput.controls.chooseTags.placeholder", + defaultMessage: "Choose Tags" + }, + + search: { + id: "KeywordAutosuggestInput.controls.search.placeholder", + defaultMessage: "Search" + }, }) diff --git a/src/components/MarkdownContent/MarkdownTemplate.js b/src/components/MarkdownContent/MarkdownTemplate.js index 2086fd15a..11430bdbf 100644 --- a/src/components/MarkdownContent/MarkdownTemplate.js +++ b/src/components/MarkdownContent/MarkdownTemplate.js @@ -66,9 +66,16 @@ export default class MarkdownTemplate extends Component { * a select input field which can be rendered in form later. **/ selectHandler = (text, options) => { - const propertyName = options.hash.name + const propertyName = options.hash.name || `select` const body = this.compileTemplate(text, this.props.properties) + // Quotes get turned into html entities by markdown compiler + const quoted_entities = { + ''': "'", + ''': "'", + '"': '"' + }; + const select =
  • - +
  • const questions = this.state.questions diff --git a/src/components/Modal/Modal.js b/src/components/Modal/Modal.js index 77d04731f..5607e4052 100644 --- a/src/components/Modal/Modal.js +++ b/src/components/Modal/Modal.js @@ -26,11 +26,12 @@ class Modal extends Component { "md:mr-w-4/5 md:mr-top-5 md:mr-left-16": this.props.extraWide, "md:mr-w-2/3 md:mr-top-5 md:mr-left-16": this.props.wide, "md:mr-min-w-1/3 md:mr-w-1/3 md:mr-top-5 md:mr-left-33": this.props.narrow, + "md:mr-min-w-1/3 md:mr-w-1/3 md:mr-top-5 md:mr-left-16": this.props.narrowColumn, "mr-w-full md:mr-w-1/4 md:mr-top-5 md:mr-left-37": this.props.extraNarrow, "md:mr-min-w-2/5 md:mr-w-2/5 md:mr-top-15 md:mr-left-30": this.props.medium, "md:mr-min-w-1/2 lg:mr-max-w-screen60 mr-w-full lg:mr-top-50 lg:mr-left-50 lg:mr--translate-1/2": !this.props.extraWide && !this.props.wide && !this.props.narrow && - !this.props.extraNarrow && !this.props.medium + !this.props.narrowColumn && !this.props.extraNarrow && !this.props.medium })} >
    { + // Record in session storage that the user really does want to see the home + // page so that we don't redirect them to the Dashboard (if they're signed + // in) like we usually would + try { + sessionStorage.setItem('goHome', 'true') + } catch (e) { + console.log(e) + } + } + signout = () => { this.props.logoutUser(_get(this.props, 'user.id')) this.closeMobileMenu() @@ -49,7 +60,12 @@ export default class Navbar extends Component { return (
    @@ -380,7 +406,9 @@ const setupColumnTypes = (props, openComments, data, criteria, pageSize) => { columns.status = { id: 'status', - Header: props.intl.formatMessage(messages.statusLabel), + Header: makeInvertable(props.intl.formatMessage(messages.statusLabel), + () => props.invertField('status'), + _get(criteria, 'invertFields.status')), accessor: 'status', sortable: true, filterable: true, @@ -422,7 +450,9 @@ const setupColumnTypes = (props, openComments, data, criteria, pageSize) => { columns.priority = { id: 'priority', - Header: props.intl.formatMessage(messages.priorityLabel), + Header: makeInvertable(props.intl.formatMessage(messages.priorityLabel), + () => props.invertField('priority'), + _get(criteria, 'invertFields.priority')), accessor: 'priority', sortable: true, filterable: true, @@ -462,7 +492,9 @@ const setupColumnTypes = (props, openComments, data, criteria, pageSize) => { columns.reviewRequestedBy = { id: 'reviewRequestedBy', - Header: props.intl.formatMessage(messages.reviewRequestedByLabel), + Header: makeInvertable(props.intl.formatMessage(messages.reviewRequestedByLabel), + () => props.invertField('reviewRequestedBy'), + _get(criteria, 'invertFields.reviewRequestedBy')), accessor: 'reviewRequestedBy', filterable: true, sortable: false, @@ -475,12 +507,14 @@ const setupColumnTypes = (props, openComments, data, criteria, pageSize) => { > {_get(row._original.reviewRequestedBy, 'username')}
    - ) + ), } columns.challenge = { id: 'challenge', - Header: props.intl.formatMessage(messages.challengeLabel), + Header: makeInvertable(props.intl.formatMessage(messages.challengeLabel), + () => props.invertField('challenge'), + _get(criteria, 'invertFields.challenge')), accessor: 'parent', filterable: true, sortable: false, @@ -509,7 +543,9 @@ const setupColumnTypes = (props, openComments, data, criteria, pageSize) => { columns.project = { id: 'project', - Header: props.intl.formatMessage(messages.projectLabel), + Header: makeInvertable(props.intl.formatMessage(messages.projectLabel), + () => props.invertField('project'), + _get(criteria, 'invertFields.project')), filterable: true, sortable: false, exportable: t => _get(t.parent, 'parent.displayName'), @@ -590,7 +626,9 @@ const setupColumnTypes = (props, openComments, data, criteria, pageSize) => { columns.reviewedBy = { id: 'reviewedBy', - Header: props.intl.formatMessage(messages.reviewedByLabel), + Header: makeInvertable(props.intl.formatMessage(messages.reviewedByLabel), + () => props.invertField('reviewedBy'), + _get(criteria, 'invertFields.reviewedBy')), accessor: 'reviewedBy', filterable: true, sortable: false, @@ -603,12 +641,14 @@ const setupColumnTypes = (props, openComments, data, criteria, pageSize) => { > {row._original.reviewedBy ? row._original.reviewedBy.username : "N/A"}
    - ) + ), } columns.reviewStatus = { id: 'reviewStatus', - Header: props.intl.formatMessage(messages.reviewStatusLabel), + Header: makeInvertable(props.intl.formatMessage(messages.reviewStatusLabel), + () => props.invertField('reviewStatus'), + _get(criteria, 'invertFields.reviewStatus')), accessor: 'reviewStatus', sortable: true, filterable: true, @@ -750,33 +790,36 @@ const setupColumnTypes = (props, openComments, data, criteria, pageSize) => { openComments(props.row._original.id)} />, } - return columns -} + columns.tags = { + id: 'tags', + Header: props.intl.formatMessage(messages.tagsLabel), + accessor: 'tags', + filterable: true, + sortable: false, + minWidth: 120, + Cell: ({row}) => { + return ( +
    + {_map(row._original.tags, t => t.name === "" ? null : ( +
    + {t.name} +
    + ))} +
    + ) + }, + Filter: ({filter, onChange}) => { + return ( + + ) + } + } -const StatusLabel = props => ( - - - - - - -) - -const ViewCommentsButton = function(props) { - return ( - - ) + return columns } export default WithCurrentUser(WithConfigurableColumns( diff --git a/src/services/Activity/ActivityItemTypes/ActivityItemTypes.js b/src/services/Activity/ActivityItemTypes/ActivityItemTypes.js index 72d30f9ed..e7b10a126 100644 --- a/src/services/Activity/ActivityItemTypes/ActivityItemTypes.js +++ b/src/services/Activity/ActivityItemTypes/ActivityItemTypes.js @@ -11,6 +11,9 @@ export const ITEM_TYPE_TAG = 3 export const ITEM_TYPE_SURVEY = 4 export const ITEM_TYPE_USER = 5 export const ITEM_TYPE_GROUP = 6 +export const ITEM_TYPE_VIRTUAL_CHALLENGE = 7 +export const ITEM_TYPE_BUNDLE = 8 +export const ITEM_TYPE_GRANT = 9 export const ActivityItemType = Object.freeze({ project: ITEM_TYPE_PROJECT, @@ -20,6 +23,9 @@ export const ActivityItemType = Object.freeze({ survey: ITEM_TYPE_SURVEY, user: ITEM_TYPE_USER, group: ITEM_TYPE_GROUP, + virtualChallenge: ITEM_TYPE_VIRTUAL_CHALLENGE, + bundle: ITEM_TYPE_BUNDLE, + grant: ITEM_TYPE_GRANT, }) export const keysByType = Object.freeze(_invert(ActivityItemType)) diff --git a/src/services/Activity/ActivityItemTypes/Messages.js b/src/services/Activity/ActivityItemTypes/Messages.js index b301029fa..7ac8642d8 100644 --- a/src/services/Activity/ActivityItemTypes/Messages.js +++ b/src/services/Activity/ActivityItemTypes/Messages.js @@ -32,4 +32,16 @@ export default defineMessages({ id: "Activity.item.group", defaultMessage: "Group" }, + virtualChallenge: { + id: "Activity.item.virtualChallenge", + defaultMessage: "Virtual Challenge" + }, + bundle: { + id: "Activity.item.bundle", + defaultMessage: "Bundle" + }, + grant: { + id: "Activity.item.grant", + defaultMessage: "Grant" + }, }) diff --git a/src/services/Challenge/Challenge.js b/src/services/Challenge/Challenge.js index 7dc72b3b2..a058b2d32 100644 --- a/src/services/Challenge/Challenge.js +++ b/src/services/Challenge/Challenge.js @@ -35,7 +35,7 @@ import { RECEIVE_CHALLENGES, REMOVE_CHALLENGE } import { ChallengeStatus } from './ChallengeStatus/ChallengeStatus' import { zeroTaskActions } from '../Task/TaskAction/TaskAction' import { parseQueryString, RESULTS_PER_PAGE, SortOptions, - generateSearchParametersString } + generateSearchParametersString, PARAMS_MAP } from '../Search/Search' import startOfDay from 'date-fns/start_of_day' @@ -87,13 +87,18 @@ const buildQueryFilters = function(criteria) { const taskId = filters.id const reviewRequestedBy = filters.reviewRequestedBy const reviewedBy = filters.reviewedBy + const completedBy = filters.completedBy + const invf = _map(criteria.invertFields, (v, k) => v ? PARAMS_MAP[k] : undefined) + return ( `status=${_join(filters.status, ',')}&` + `priority=${_join(filters.priorities, ',')}&` + `reviewStatus=${_join(filters.reviewStatus, ',')}` + `${taskId ? `&tid=${taskId}` : ""}` + + `${completedBy ? `&m=${completedBy}` : ""}` + `${reviewRequestedBy ? `&o=${reviewRequestedBy}` : ""}` + - `${reviewedBy ? `&r=${reviewedBy}` : ""}`) + `${reviewedBy ? `&r=${reviewedBy}` : ""}` + + `&invf=${invf.join(',')}`) } /** @@ -364,7 +369,9 @@ export const fetchChallengeActions = function(challengeId = null, suppressReceiv let searchParameters = {} if (criteria) { searchParameters = generateSearchParametersString(_get(criteria, 'filters', {}), - criteria.boundingBox) + criteria.boundingBox, + false, false, null, + _get(criteria, 'invertFields', {})) } return function(dispatch) { diff --git a/src/services/Challenge/ChallengeSnapshot.js b/src/services/Challenge/ChallengeSnapshot.js index ca0c971c6..87af0f491 100644 --- a/src/services/Challenge/ChallengeSnapshot.js +++ b/src/services/Challenge/ChallengeSnapshot.js @@ -31,6 +31,20 @@ export const recordChallengeSnapshot = function(challengeId) { }) } +/** + * Removes a challenge snapshot. + */ +export const removeChallengeSnapshot = function(snapshotId) { + return new Endpoint( + api.challenge.removeSnapshot, + { + variables: {id: snapshotId}, + } + ).execute().catch((error) => { + console.log(error.response || error) + }) +} + /** * Fetch challenge snapshot by id. */ diff --git a/src/services/Error/AppErrors.js b/src/services/Error/AppErrors.js index 9575ef6fd..3c10b2cb4 100644 --- a/src/services/Error/AppErrors.js +++ b/src/services/Error/AppErrors.js @@ -101,4 +101,8 @@ export default { noResponse: messages.josmNoResponse, missingOSMIds: messages.josmMissingOSMIds, }, + + team: { + failure: messages.teamFailure, + }, } diff --git a/src/services/Error/Messages.js b/src/services/Error/Messages.js index 5bba1c493..058e3fd09 100644 --- a/src/services/Error/Messages.js +++ b/src/services/Error/Messages.js @@ -222,4 +222,8 @@ export default defineMessages({ "required to open them standalone in JOSM. Please choose " + "another editing option." }, + teamFailure: { + id: "Errors.team.genericFailure", + defaultMessage: "Failure{details}" + }, }) diff --git a/src/services/Grant/GranteeType.js b/src/services/Grant/GranteeType.js new file mode 100644 index 000000000..8a89034fc --- /dev/null +++ b/src/services/Grant/GranteeType.js @@ -0,0 +1,9 @@ +import { ActivityItemType } + from '../Activity/ActivityItemTypes/ActivityItemTypes' + +// Grantee types use the item type constants on the server +export const GRANTEE_TYPE_USER = ActivityItemType.user + +export const GranteeType = Object.freeze({ + user: GRANTEE_TYPE_USER, +}) diff --git a/src/services/Project/GroupType/Messages.js b/src/services/Grant/Messages.js similarity index 58% rename from src/services/Project/GroupType/Messages.js rename to src/services/Grant/Messages.js index 14933a8b0..fa6b7fd12 100644 --- a/src/services/Project/GroupType/Messages.js +++ b/src/services/Grant/Messages.js @@ -1,19 +1,19 @@ import { defineMessages } from 'react-intl' /** - * Internationalized messages for use with GroupType + * Internationalized messages for use with Role */ export default defineMessages({ admin: { - id: "Project.GroupType.admin", + id: "Grant.Role.admin", defaultMessage: "Admin", }, write: { - id: "Project.GroupType.write", + id: "Grant.Role.write", defaultMessage: "Write", }, read: { - id: "Project.GroupType.read", + id: "Grant.Role.read", defaultMessage: "Read", }, }) diff --git a/src/services/Grant/Role.js b/src/services/Grant/Role.js new file mode 100644 index 000000000..ab7feae69 --- /dev/null +++ b/src/services/Grant/Role.js @@ -0,0 +1,42 @@ +import _map from 'lodash/map' +import _fromPairs from 'lodash/fromPairs' +import _min from 'lodash/min' +import messages from './Messages' + +// These constants are defined on the server +export const ROLE_SUPERUSER = -1 +export const ROLE_ADMIN = 1 +export const ROLE_WRITE = 2 +export const ROLE_READ = 3 + +export const Role = Object.freeze({ + admin: ROLE_ADMIN, + write: ROLE_WRITE, + read: ROLE_READ, +}) + +/** + * Returns an object mapping role values to raw internationalized messages + * suitable for use with FormattedMessage or formatMessage + */ +export const messagesByRole = _fromPairs( + _map(messages, (message, key) => [Role[key], message]) +) + +/** Returns object containing localized labels */ +export const roleLabels = intl => _fromPairs( + _map(messages, (message, key) => [key, intl.formatMessage(message)]) +) + +/** Returns the most privileged of the given roles **/ +export const mostPrivilegedRole = function(roles) { + return _min(roles) +} + +/** + * Determines if the target role is implied by the given list of possessed + * roles + */ +export const rolesImply = function(targetRole, roles) { + return mostPrivilegedRole(roles) <= targetRole +} diff --git a/src/services/Grant/TargetType.js b/src/services/Grant/TargetType.js new file mode 100644 index 000000000..e94e9c91b --- /dev/null +++ b/src/services/Grant/TargetType.js @@ -0,0 +1,11 @@ +import { ActivityItemType } + from '../Activity/ActivityItemTypes/ActivityItemTypes' + +// Target types use the item type constants on the server +export const TARGET_TYPE_PROJECT = ActivityItemType.project +export const TARGET_TYPE_GROUP = ActivityItemType.group + +export const TargetType = Object.freeze({ + project: TARGET_TYPE_PROJECT, + group: TARGET_TYPE_GROUP, +}) diff --git a/src/services/Project/GroupType/GroupType.js b/src/services/Project/GroupType/GroupType.js deleted file mode 100644 index 0aa06937d..000000000 --- a/src/services/Project/GroupType/GroupType.js +++ /dev/null @@ -1,42 +0,0 @@ -import _map from 'lodash/map' -import _fromPairs from 'lodash/fromPairs' -import _min from 'lodash/min' -import messages from './Messages' - -// These constants are defined on the server -export const GROUP_TYPE_SUPERUSER = -1 -export const GROUP_TYPE_ADMIN = 1 -export const GROUP_TYPE_WRITE = 2 -export const GROUP_TYPE_READ = 3 - -export const GroupType = Object.freeze({ - admin: GROUP_TYPE_ADMIN, - write: GROUP_TYPE_WRITE, - read: GROUP_TYPE_READ, -}) - -/** - * Returns an object mapping group type values to raw internationalized - * messages suitable for use with FormattedMessage or formatMessage. - */ -export const messagesByGroupType = _fromPairs( - _map(messages, (message, key) => [GroupType[key], message]) -) - -/** Returns object containing localized labels */ -export const groupTypeLabels = intl => _fromPairs( - _map(messages, (message, key) => [key, intl.formatMessage(message)]) -) - -/** Returns the most privileged of the given group types **/ -export const mostPrivilegedGroupType = function(groupTypes) { - return _min(groupTypes) -} - -/** - * Determines if the target group type is implied by the given list of - * possessed group types. - */ -export const groupTypesImply = function(targetGroupType, groupTypes) { - return mostPrivilegedGroupType(groupTypes) <= targetGroupType -} diff --git a/src/services/Project/Project.js b/src/services/Project/Project.js index 3a5836bb1..9f2d50c2f 100644 --- a/src/services/Project/Project.js +++ b/src/services/Project/Project.js @@ -3,6 +3,7 @@ import _get from 'lodash/get' import _isArray from 'lodash/isArray' import _cloneDeep from 'lodash/cloneDeep' import _find from 'lodash/find' +import _map from 'lodash/map' import _isFinite from 'lodash/isFinite' import _isUndefined from 'lodash/isUndefined' import startOfDay from 'date-fns/start_of_day' @@ -12,7 +13,7 @@ import RequestStatus from '../Server/RequestStatus' import genericEntityReducer from '../Server/GenericEntityReducer' import { RECEIVE_CHALLENGES } from '../Challenge/ChallengeActions' import { RESULTS_PER_PAGE } from '../Search/Search' -import { GroupType } from './GroupType/GroupType' +import { Role } from '../Grant/Role' import { addServerError, addError } from '../Error/Error' import AppErrors from '../Error/AppErrors' @@ -213,8 +214,8 @@ export const saveProject = function(projectData) { // If we just created the project, add the owner as an admin. if (areCreating && project) { - return setProjectManagerGroupType( - project.id, project.owner, true, GroupType.admin + return setProjectManagerRole( + project.id, project.owner, true, Role.admin )(dispatch).then(() => project) } else { @@ -275,23 +276,36 @@ export const fetchProjectActivity = function(projectId, startDate, endDate) { } /** - * Fetch managers of the given project. + * Fetch managers of the given project, both users and teams */ export const fetchProjectManagers = function(projectId) { return function(dispatch) { - return new Endpoint( - api.project.managers, {variables: {projectId}} - ).execute().then(rawManagers => { - const normalizedResults = { - entities: { - projects: { - [projectId]: {id: projectId, managers: rawManagers}, - } + const normalizedResults = { + entities: { + projects: { + [projectId]: {id: projectId}, } } + } - return dispatch(receiveProjects(normalizedResults.entities)) - }).catch(error => { + return Promise.all([ + new Endpoint( + api.project.managers, {variables: {projectId}} + ).execute().then(rawManagers => + normalizedResults.entities.projects[projectId].managers = rawManagers + ), + + new Endpoint( + api.teams.projectManagers, {variables: {projectId}} + ).execute().then(rawManagers => + normalizedResults.entities.projects[projectId].teamManagers = _map( + rawManagers, + managingTeam => Object.assign({}, managingTeam.team, {roles: _map(managingTeam.grants, 'role')}) + ) + ), + ]).then( + () => dispatch(receiveProjects(normalizedResults.entities)) + ).catch(error => { if (isSecurityError(error)) { dispatch(ensureUserLoggedIn()).then(() => dispatch(addError(AppErrors.user.unauthorized)) @@ -306,13 +320,13 @@ export const fetchProjectManagers = function(projectId) { } /** - * Set group type (permissions) for user on project. + * Set role for user on project */ -export const setProjectManagerGroupType = function(projectId, userId, isOSMUserId, groupType) { +export const setProjectManagerRole = function(projectId, userId, isOSMUserId, role) { return function(dispatch) { return new Endpoint( api.project.setManagerPermission, { - variables: {userId, projectId, groupType}, + variables: {userId, projectId, role}, params: {isOSMUserId: isOSMUserId ? 'true' : 'false'}, } ).execute().then(rawManagers => { @@ -341,9 +355,9 @@ export const setProjectManagerGroupType = function(projectId, userId, isOSMUserI /** * Add a user with the given OSM username to the given project with the given - * group type (permissions). + * role */ -export const addProjectManager = function(projectId, username, groupType) { +export const addProjectManager = function(projectId, username, role) { return function(dispatch) { return findUser(username).then(matchingUsers => { // We want an exact username match @@ -351,7 +365,7 @@ export const addProjectManager = function(projectId, username, groupType) { _get(_find(matchingUsers, match => match.displayName === username), 'osmId') if (_isFinite(osmId)) { - return setProjectManagerGroupType(projectId, osmId, true, groupType)(dispatch) + return setProjectManagerRole(projectId, osmId, true, role)(dispatch) } else { dispatch(addError(AppErrors.user.notFound)) @@ -437,6 +451,10 @@ const reduceProjectsFurther = function(mergedState, oldState, projectEntities) { if (_isArray(entity.managers)) { mergedState[entity.id].managers = entity.managers } + + if (_isArray(entity.teamManagers)) { + mergedState[entity.id].teamManagers = entity.teamManagers + } }) } diff --git a/src/services/Search/Search.js b/src/services/Search/Search.js index 7fec5d9f2..6633b4c09 100644 --- a/src/services/Search/Search.js +++ b/src/services/Search/Search.js @@ -61,6 +61,24 @@ export const SortOptions = { default: SORT_DEFAULT, } +// Map for the search parameters expected by server +export const PARAMS_MAP = { + reviewRequestedBy: 'o', + reviewedBy: 'r', + completedBy: 'm', + challengeId: 'cid', + challenge: 'cs', + projectId: 'pid', + project: 'ps', + status: 'tStatus', + priority: 'tp', + priorities: 'priorities', + reviewStatus: 'trStatus', + id: 'tid', + difficulty: 'cd', + tags: 'tt' +} + /** Returns object containing localized labels */ export const sortLabels = intl => _fromPairs( @@ -113,77 +131,108 @@ export const parseQueryString = function(rawQueryText) { * server accepts for various API endpoints */ export const generateSearchParametersString = (filters, boundingBox, savedChallengesOnly, - excludeOtherReviewers, queryString) => { + excludeOtherReviewers, queryString, + invertFields = {}) => { const searchParameters = {} + const invf = [] + if (filters.reviewRequestedBy) { - searchParameters.o = filters.reviewRequestedBy + searchParameters[PARAMS_MAP.reviewRequestedBy] = filters.reviewRequestedBy + if (invertFields.reviewRequestedBy) { + invf.push(PARAMS_MAP.reviewRequestedBy) + } } if (filters.reviewedBy) { - searchParameters.r = filters.reviewedBy + searchParameters[PARAMS_MAP.reviewedBy] = filters.reviewedBy + if (invertFields.reviewedBy) { + invf.push(PARAMS_MAP.reviewedBy) + } } if (filters.completedBy) { - searchParameters.m = filters.completedBy + searchParameters[PARAMS_MAP.completedBy] = filters.completedBy + if (invertFields.completedBy) { + invf.push(PARAMS_MAP.completedBy) + } } if (filters.challengeId) { - searchParameters.cid = filters.challengeId + searchParameters.cid = !_isArray(filters.challengeId) ? + filters.challengeId : + searchParameters[PARAMS_MAP.challengeId] = filters.challengeId.join(',') + + if (invertFields.challenge) { + invf.push(PARAMS_MAP.challengeId) + } } else if (filters.challenge) { - searchParameters.cs = filters.challenge + searchParameters[PARAMS_MAP.challenge] = filters.challenge + if (invertFields.challenge) { + invf.push(PARAMS_MAP.challenge) + } } if (filters.projectId) { - searchParameters.pid = filters.projectId + searchParameters[PARAMS_MAP.projectId] = filters.projectId + if (invertFields.project) { + invf.push(PARAMS_MAP.projectId) + } } else if (filters.project) { - searchParameters.ps = filters.project + searchParameters[PARAMS_MAP.project] = filters.project + if (invertFields.project) { + invf.push(PARAMS_MAP.project) + } } if (filters.status && filters.status !== "all") { if (Array.isArray(filters.status)){ - searchParameters.tStatus = filters.status.join(',') + searchParameters[PARAMS_MAP.status] = filters.status.join(',') } else { - searchParameters.tStatus = filters.status + searchParameters[PARAMS_MAP.status] = filters.status + } + if (invertFields.status) { + invf.push(PARAMS_MAP.status) } } if (filters.priority && filters.priority !== "all") { - searchParameters.tp = filters.priority + searchParameters[PARAMS_MAP.priority] = filters.priority + if (invertFields.priority) { + invf.push(PARAMS_MAP.priority) + } } if (filters.priorities && filters.priorities !== "all") { if (Array.isArray(filters.priorities)){ - searchParameters.priorities = filters.priorities.join(',') + searchParameters[PARAMS_MAP.priorities] = filters.priorities.join(',') } else { - searchParameters.priorities = filters.priorities + searchParameters[PARAMS_MAP.priorities] = filters.priorities + } + if (invertFields.priorities) { + invf.push(PARAMS_MAP.priorities) } } if (filters.reviewStatus && filters.reviewStatus !== "all") { if (Array.isArray(filters.reviewStatus)){ - searchParameters.trStatus = filters.reviewStatus.join(',') + searchParameters[PARAMS_MAP.reviewStatus] = filters.reviewStatus.join(',') } else { - searchParameters.trStatus = filters.reviewStatus + searchParameters[PARAMS_MAP.reviewStatus] = filters.reviewStatus + } + if (invertFields.reviewStatus) { + invf.push(PARAMS_MAP.reviewStatus) } } if (filters.reviewedAt) { searchParameters.startDate = format(filters.reviewedAt, 'YYYY-MM-DD') searchParameters.endDate = format(filters.reviewedAt, 'YYYY-MM-DD') } - if (filters.challengeId) { - if (!_isArray(filters.challengeId)) { - searchParameters.cid = filters.challengeId - } - else { - searchParameters.cid = filters.challengeId.join(',') - } - } if (filters.id) { - searchParameters.tid = filters.id + searchParameters[PARAMS_MAP.id] = filters.id } if (_isFinite(filters.difficulty)) { - searchParameters.cd = filters.difficulty + searchParameters[PARAMS_MAP.difficulty] = filters.difficulty } if (boundingBox) { @@ -229,10 +278,15 @@ export const generateSearchParametersString = (filters, boundingBox, savedChalle } if (queryParts.query.length > 0) { - searchParameters.cs = queryParts.query + searchParameters[PARAMS_MAP.challenge] = queryParts.query } } + if (filters.tags) { + searchParameters[PARAMS_MAP.tags] = filters.tags + } + + searchParameters.invf = invf.join(',') return searchParameters } diff --git a/src/services/Server/APIRoutes.js b/src/services/Server/APIRoutes.js index 12eb733b6..96e2089b3 100644 --- a/src/services/Server/APIRoutes.js +++ b/src/services/Server/APIRoutes.js @@ -31,7 +31,7 @@ const apiRoutes = factory => { 'activity': factory.get('/data/project/activity'), 'managers': factory.get('/user/project/:projectId'), 'comments': factory.get('/project/:id/comments'), - 'setManagerPermission': factory.put('/user/:userId/project/:projectId/:groupType'), + 'setManagerPermission': factory.put('/user/:userId/project/:projectId/:role'), 'removeManager': factory.delete('/user/:userId/project/:projectId/-1'), 'delete': factory.delete('/project/:id'), 'addToVirtual': factory.post('/project/:projectId/challenge/:challengeId/add'), @@ -70,6 +70,7 @@ const apiRoutes = factory => { 'propertyKeys': factory.get('/data/challenge/:id/propertyKeys'), 'snapshotList': factory.get('/snapshot/challenge/:id/list'), 'recordSnapshot': factory.get('/snapshot/challenge/:id/record'), + 'removeSnapshot': factory.delete('/snapshot/:id'), 'snapshot': factory.get('/snapshot/:id'), }, 'virtualChallenge': { @@ -119,6 +120,7 @@ const apiRoutes = factory => { 'testTagFix': factory.post('/change/tag/test'), 'testCooperativeWork': factory.post('/change/test'), 'applyTagFix': factory.post('/task/:id/fix/apply'), + 'updateCompletionResponses': factory.put('/task/:id/responses'), }, 'keywords': { 'find': factory.get('/keywords'), @@ -150,6 +152,14 @@ const apiRoutes = factory => { 'markNotificationsRead': factory.put('/user/:userId/notifications'), 'deleteNotifications': factory.delete('/user/:userId/notifications'), }, + 'teams': { + 'find': factory.get('/teams/find'), + 'projectManagers': factory.get('/teams/projectManagers/:projectId'), + }, + 'team': { + 'setProjectRole': factory.put('/team/:teamId/project/:projectId/:role'), + 'removeFromProject': factory.delete('/team/:teamId/project/:projectId'), + } } } diff --git a/src/services/Task/BoundedTask.js b/src/services/Task/BoundedTask.js index 1bd1c6adf..7cb209141 100644 --- a/src/services/Task/BoundedTask.js +++ b/src/services/Task/BoundedTask.js @@ -73,7 +73,9 @@ export const fetchBoundedTasks = function(criteria, limit=50, skipDispatch=false const searchParameters = generateSearchParametersString(filters, null, _get(criteria, 'savedChallengesOnly'), - null) + null, null, + _get(criteria, 'invertFields')) + const includeTags = _get(criteria, 'includeTags', false) // If we don't have a challenge Id then we need to do some limiting. if (!filters.challengeId) { @@ -120,7 +122,8 @@ export const fetchBoundedTasks = function(criteria, limit=50, skipDispatch=false top: normalizedBounds.getNorth(), }, params: {limit, page: (page * limit), sort: sortBy, order: direction, - includeTotal: true, excludeLocked, ...searchParameters, includeGeometries}, + includeTotal: true, excludeLocked, ...searchParameters, includeGeometries, + includeTags}, json: filters.taskPropertySearch ? {taskPropertySearch: filters.taskPropertySearch} : null, } ).execute().then(normalizedResults => { diff --git a/src/services/Task/Task.js b/src/services/Task/Task.js index c832daddf..543fe301c 100644 --- a/src/services/Task/Task.js +++ b/src/services/Task/Task.js @@ -302,7 +302,8 @@ export const bulkTaskStatusChange = function(newStatus, challengeId, criteria) { criteria.boundingBox, _get(criteria, 'savedChallengesOnly'), null, - criteria.searchQuery) + criteria.searchQuery, + _get(criteria, 'invertFields')) searchParameters.cid = challengeId return new Endpoint( @@ -324,6 +325,32 @@ export const bulkTaskStatusChange = function(newStatus, challengeId, criteria) { } } +/** + * Updates the completion responses on a task. + */ +export const updateCompletionResponses = function(taskId, completionResponses) { + return function(dispatch) { + return new Endpoint( + api.task.updateCompletionResponses, + {variables: {id: taskId}, + json: completionResponses + } + ).execute().then(() => { + fetchTask(taskId)(dispatch) // Refresh task data + }).catch(error => { + if (isSecurityError(error)) { + dispatch(ensureUserLoggedIn()).then(() => + dispatch(addError(AppErrors.user.unauthorized)) + ) + } + else { + dispatch(addError(AppErrors.task.updateFailure)) + console.log(error.response || error) + } + }) + } +} + /** * Add a comment to the given task, associating the given task status if * provided. diff --git a/src/services/Task/TaskClusters.js b/src/services/Task/TaskClusters.js index fe57e884b..598056021 100644 --- a/src/services/Task/TaskClusters.js +++ b/src/services/Task/TaskClusters.js @@ -59,7 +59,8 @@ export const fetchTaskClusters = function(challengeId, criteria, points=25) { criteria.boundingBox, _get(criteria, 'savedChallengesOnly'), null, - criteria.searchQuery) + criteria.searchQuery, + _get(criteria, 'invertFields')) searchParameters.cid = challengeId // If we don't have a challenge Id then we need to do some limiting. diff --git a/src/services/Task/TaskReview/TaskReview.js b/src/services/Task/TaskReview/TaskReview.js index b7137bff8..5239364e8 100644 --- a/src/services/Task/TaskReview/TaskReview.js +++ b/src/services/Task/TaskReview/TaskReview.js @@ -6,6 +6,7 @@ import _isArray from 'lodash/isArray' import _cloneDeep from 'lodash/cloneDeep' import _snakeCase from 'lodash/snakeCase' import _isFinite from 'lodash/isFinite' +import queryString from 'query-string' import Endpoint from '../../Server/Endpoint' import { defaultRoutes as api, isSecurityError } from '../../Server/Server' import { RECEIVE_REVIEW_NEEDED_TASKS } from './TaskReviewNeeded' @@ -104,26 +105,44 @@ export const receiveReviewProjects = function(reviewProjects, status=RequestStat } } +// utility functions /** - * Retrieve metrics for a given review tasks type and filter criteria + * Builds a link to export CSV */ - export const fetchReviewMetrics = function(userId, reviewTasksType, criteria) { - const type = determineType(reviewTasksType) +export const buildLinkToMapperExportCSV = function(criteria) { + const queryFilters = generateReviewSearch(criteria) + + return `${process.env.REACT_APP_MAP_ROULETTE_SERVER_URL}/api/v2/tasks/review/mappers/export?${queryString.stringify(queryFilters)}` +} + +const generateReviewSearch = function(criteria, reviewTasksType = ReviewTasksType.allReviewedTasks, userId) { const searchParameters = generateSearchParametersString(_get(criteria, 'filters', {}), criteria.boundingBox, _get(criteria, 'savedChallengesOnly'), - _get(criteria, 'excludeOtherReviewers')) + _get(criteria, 'excludeOtherReviewers'), + null, + _get(criteria, 'invertFields', {})) const mappers = (reviewTasksType === ReviewTasksType.myReviewedTasks) ? [userId] : [] const reviewers = (reviewTasksType === ReviewTasksType.reviewedByMe) ? [userId] : [] + return {...searchParameters, mappers, reviewers} +} + +/** + * Retrieve metrics for a given review tasks type and filter criteria + */ + export const fetchReviewMetrics = function(userId, reviewTasksType, criteria) { + const type = determineType(reviewTasksType) + const params = generateReviewSearch(criteria, reviewTasksType, userId) + return function(dispatch) { return new Endpoint( api.tasks.reviewMetrics, { schema: null, - params: {reviewTasksType: type, ...searchParameters, mappers, reviewers, - includeByPriority: true}, + params: {reviewTasksType: type, ...params, + includeByPriority: true, includeByTaskStatus: true}, } ).execute().then(normalizedResults => { dispatch(receiveReviewMetrics(normalizedResults, RequestStatus.success)) @@ -141,7 +160,9 @@ export const fetchClusteredReviewTasks = function(reviewTasksType, criteria={}) const searchParameters = generateSearchParametersString(_get(criteria, 'filters', {}), criteria.boundingBox, _get(criteria, 'savedChallengesOnly'), - _get(criteria, 'excludeOtherReviewers')) + _get(criteria, 'excludeOtherReviewers'), + null, + _get(criteria, 'invertFields', {})) return function(dispatch) { const type = determineType(reviewTasksType) const fetchId = uuidv1() @@ -191,7 +212,9 @@ export const loadNextReviewTask = function(criteria={}, lastTaskId) { const searchParameters = generateSearchParametersString(_get(criteria, 'filters', {}), criteria.boundingBox, _get(criteria, 'savedChallengesOnly'), - _get(criteria, 'excludeOtherReviewers')) + _get(criteria, 'excludeOtherReviewers'), + null, + _get(criteria, 'invertFields', {})) return function(dispatch) { const params = {sort, order, ...searchParameters} @@ -265,7 +288,8 @@ export const removeReviewRequest = function(challengeId, taskIds, criteria = nul criteria.boundingBox, null, null, - criteria.searchQuery) + criteria.searchQuery, + criteria.invertFields) searchParameters.cid = challengeId searchParameters.ids = taskIds ? taskIds.join(',') : null diff --git a/src/services/Task/TaskReview/TaskReviewNeeded.js b/src/services/Task/TaskReview/TaskReviewNeeded.js index 98ab0e5aa..4155457c8 100644 --- a/src/services/Task/TaskReview/TaskReviewNeeded.js +++ b/src/services/Task/TaskReview/TaskReviewNeeded.js @@ -42,7 +42,10 @@ export const fetchReviewNeededTasks = function(criteria, limit=50) { const searchParameters = generateSearchParametersString(_get(criteria, 'filters', {}), criteria.boundingBox, _get(criteria, 'savedChallengesOnly'), - _get(criteria, 'excludeOtherReviewers')) + _get(criteria, 'excludeOtherReviewers'), + null, + _get(criteria, 'invertFields', {})) + const includeTags = criteria.includeTags return function(dispatch) { return new Endpoint( @@ -50,7 +53,8 @@ export const fetchReviewNeededTasks = function(criteria, limit=50) { { schema: {tasks: [taskSchema()]}, variables: {}, - params: {limit, sort, order, page: (page * limit), ...searchParameters}, + params: {limit, sort, order, page: (page * limit), ...searchParameters, + includeTags}, } ).execute().then(normalizedResults => { const unsortedTaskMap = _get(normalizedResults, 'entities.tasks', {}) diff --git a/src/services/Task/TaskReview/TaskReviewed.js b/src/services/Task/TaskReview/TaskReviewed.js index 4494e1c59..bc9f43230 100644 --- a/src/services/Task/TaskReview/TaskReviewed.js +++ b/src/services/Task/TaskReview/TaskReviewed.js @@ -43,10 +43,16 @@ export const fetchReviewedTasks = function(userId, criteria, asReviewer=false, a const sort = sortBy ? _snakeCase(sortBy) : null const page = _get(criteria, 'page', 0) - const searchParameters = generateSearchParametersString(_get(criteria, 'filters', {}), criteria.boundingBox) + const searchParameters = + generateSearchParametersString(_get(criteria, 'filters', {}), + criteria.boundingBox, + false, false, null, + _get(criteria, 'invertFields', {})) const mappers = asMapper ? [userId] : [] const reviewers = asReviewer ? [userId] : [] + const includeTags = criteria.includeTags + let dispatchType = RECEIVE_REVIEWED_TASKS if (asReviewer) { dispatchType = RECEIVE_REVIEWED_BY_USER_TASKS @@ -64,7 +70,8 @@ export const fetchReviewedTasks = function(userId, criteria, asReviewer=false, a { schema: {tasks: [taskSchema()]}, params: {mappers, reviewers, limit, sort, order, page: (page * limit), - allowReviewNeeded: (asReviewer ? false : true), ...searchParameters}, + allowReviewNeeded: !asReviewer, ...searchParameters, + includeTags}, } ).execute().then(normalizedResults => { const unsortedTaskMap = _get(normalizedResults, 'entities.tasks', {}) diff --git a/src/services/Task/TaskStatus/TaskStatus.js b/src/services/Task/TaskStatus/TaskStatus.js index 51ac7935c..c99a49f1a 100644 --- a/src/services/Task/TaskStatus/TaskStatus.js +++ b/src/services/Task/TaskStatus/TaskStatus.js @@ -83,7 +83,6 @@ export const allowedStatusProgressions = function(status, includeSelf = false) { progressions = new Set() break default: - console.log(`Unrecognized task status >>${status}<<`) throw new Error("unrecognized-task-status", `Unrecognized task status ${status}`) } diff --git a/src/services/Team/Messages.js b/src/services/Team/Messages.js new file mode 100644 index 000000000..ac4994461 --- /dev/null +++ b/src/services/Team/Messages.js @@ -0,0 +1,15 @@ +import { defineMessages } from 'react-intl' + +/** + * Internationalized messages for use with Team Status + */ +export default defineMessages({ + member: { + id: "Team.Status.member", + defaultMessage: "Member", + }, + invited: { + id: "Team.Status.invited", + defaultMessage: "Invited", + }, +}) diff --git a/src/services/Team/Status.js b/src/services/Team/Status.js new file mode 100644 index 000000000..a0ba73b84 --- /dev/null +++ b/src/services/Team/Status.js @@ -0,0 +1,25 @@ +import _map from 'lodash/map' +import _fromPairs from 'lodash/fromPairs' +import messages from './Messages' + +// These constants are defined on the server +export const STATUS_MEMBER = 0 +export const STATUS_INVITED = 1 + +export const TeamStatus = Object.freeze({ + member: STATUS_MEMBER, + invited: STATUS_INVITED, +}) + +/** + * Returns an object mapping team status values to raw internationalized + * messages suitable for use with FormattedMessage or formatMessage + */ +export const messagesByTeamStatus = _fromPairs( + _map(messages, (message, key) => [TeamStatus[key], message]) +) + +/** Returns object containing localized labels */ +export const teamStatusLabels = intl => _fromPairs( + _map(messages, (message, key) => [key, intl.formatMessage(message)]) +) diff --git a/src/services/Team/Team.js b/src/services/Team/Team.js new file mode 100644 index 000000000..d5a3f1cff --- /dev/null +++ b/src/services/Team/Team.js @@ -0,0 +1,51 @@ +import { defaultRoutes as api, websocketClient } from '../Server/Server' +import Endpoint from '../Server/Endpoint' +import { fetchProjectManagers } from '../Project/Project' + +export const subscribeToTeamUpdates = function(callback, handle) { + websocketClient.addServerSubscription( + "teams", + null, + handle, + messageObject => callback(messageObject) + ) +} + +export const unsubscribeFromTeamUpdates = function(handle) { + websocketClient.removeServerSubscription("teams", null, handle) +} + +// async action creators + +/** + * Search for teams by name. Resolves with a (possibly empty) list of results + */ +export const findTeam = function(teamName) { + return new Endpoint(api.teams.find, {params: {name: teamName}}).execute() +} + +/** + * Set a team's granted role on a project + */ +export const setTeamProjectRole = function(projectId, teamId, role) { + return function(dispatch) { + return new Endpoint(api.team.setProjectRole, { + variables: {teamId, projectId, role}, + }).execute().then(() => + fetchProjectManagers(projectId)(dispatch) + ) + } +} + +/** + * Set a team's granted role on a project + */ +export const removeTeamFromProject = function(projectId, teamId) { + return function(dispatch) { + return new Endpoint(api.team.removeFromProject, { + variables: {teamId, projectId}, + }).execute().then(() => + fetchProjectManagers(projectId)(dispatch) + ) + } +} diff --git a/src/services/User/Locale/Locale.js b/src/services/User/Locale/Locale.js index 394fbb05f..b6734a2ec 100644 --- a/src/services/User/Locale/Locale.js +++ b/src/services/User/Locale/Locale.js @@ -4,7 +4,11 @@ import _isString from 'lodash/isString' import _fromPairs from 'lodash/fromPairs' import messages from './Messages' -// Supported locales. +// To add support for a new locale, add it to both `Locale` and `LocaleImports` +// in this file, and then add a description of the new locale to the +// `Messages.js` file in this directory + +// Supported locales export const Locale = Object.freeze({ enUS: 'en-US', es: 'es', @@ -18,6 +22,7 @@ export const Locale = Object.freeze({ 'cs-CZ': 'cs-CZ', 'fa-IR': 'fa-IR', 'ru-RU': 'ru-RU', + uk: 'uk', }) // Dynamic imports to load locale data and translation files @@ -94,6 +99,12 @@ const LocaleImports = { return import('../../../lang/ru_RU.json') }) }, + [Locale.uk]: () => { + return import('react-intl/locale-data/uk').then(uk => { + addLocaleData([...uk.default]) + return import('../../../lang/uk.json') + }) + }, } /** diff --git a/src/services/User/Locale/Messages.js b/src/services/User/Locale/Messages.js index 4d5799273..ecee5fb3f 100644 --- a/src/services/User/Locale/Messages.js +++ b/src/services/User/Locale/Messages.js @@ -52,4 +52,8 @@ export default defineMessages({ id: "Locale.ru-RU.label", defaultMessage: "ru-RU (Russian - Russia)", }, + 'uk': { + id: "Locale.uk.label", + defaultMessage: "uk (Ukrainian)", + }, }) diff --git a/src/services/User/User.js b/src/services/User/User.js index c758dc2ef..1c5ad53be 100644 --- a/src/services/User/User.js +++ b/src/services/User/User.js @@ -228,9 +228,13 @@ export const ensureUserLoggedIn = function(squelchError=false) { return new Endpoint( api.user.whoami, {schema: userSchema()} ).execute().then(normalizedResults => { + const userId = normalizedResults.result + if (_isFinite(userId) && userId !== GUEST_USER_ID) { + localStorage.setItem('isLoggedIn', 'true') + } dispatch(receiveUsers(normalizedResults.entities)) - dispatch(setCurrentUser(normalizedResults.result)) - return normalizedResults.result + dispatch(setCurrentUser(userId)) + return userId }).catch(error => { // a 401 (unauthorized) indicates that the user is not logged in. Logout // the current user locally to reflect that fact and dispatch an error @@ -447,9 +451,12 @@ export const loadCompleteUser = function(userId, savedChallengesLimit=50, savedT fetchSavedTasks(userId, savedTasksLimit)(dispatch) fetchUserActivity(userId)(dispatch) fetchNotificationSubscriptions(userId)(dispatch) - }).then(() => + }).then(() => { + if (_isFinite(userId) && userId !== GUEST_USER_ID) { + localStorage.setItem('isLoggedIn', 'true') + } dispatch(setCurrentUser(userId)) - ).catch(error => { + }).catch(error => { if (isSecurityError(error)) { dispatch(ensureUserLoggedIn()).then(() => dispatch(addError(AppErrors.user.unauthorized)) @@ -666,6 +673,7 @@ export const unsaveTask = function(userId, taskId) { * Logout the current user on both the client and server. */ export const logoutUser = function(userId) { + localStorage.removeItem('isLoggedIn') const logoutURI = `${process.env.REACT_APP_MAP_ROULETTE_SERVER_URL}/auth/signout` if (_isFinite(userId) && userId !== GUEST_USER_ID) { diff --git a/src/static/images/globe.svg b/src/static/images/globe.svg new file mode 100644 index 000000000..3cd9a3a83 --- /dev/null +++ b/src/static/images/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/static/images/home.svg b/src/static/images/home.svg new file mode 100644 index 000000000..9b0462734 --- /dev/null +++ b/src/static/images/home.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/static/images/lines.svg b/src/static/images/lines.svg new file mode 100644 index 000000000..69dd169b9 --- /dev/null +++ b/src/static/images/lines.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/static/images/skyline.svg b/src/static/images/skyline.svg new file mode 100644 index 000000000..bfb2183c0 --- /dev/null +++ b/src/static/images/skyline.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/styles/components/cards/challenge.css b/src/styles/components/cards/challenge.css index 534c7f85d..212746670 100644 --- a/src/styles/components/cards/challenge.css +++ b/src/styles/components/cards/challenge.css @@ -58,7 +58,7 @@ } &__description { - @apply mr-text-base mr-my-4 mr-max-h-48 mr-overflow-auto mr-scrolling-touch mr-break-words; + @apply mr-text-base mr-my-4 mr-max-h-40 mr-overflow-auto mr-scrolling-touch mr-break-words; word-break: break-word; } diff --git a/src/styles/utilities/backgrounds.css b/src/styles/utilities/backgrounds.css index d36da2166..8428ba026 100644 --- a/src/styles/utilities/backgrounds.css +++ b/src/styles/utilities/backgrounds.css @@ -43,3 +43,31 @@ .mr-bg-cityscape { background-image: url('./static/images/bg-cityscape.svg'); } + +.mr-bg-home { + background-image: url('./static/images/home.svg'); + background-position: right bottom; + background-repeat: no-repeat; + background-size: contain; +} + +.mr-bg-lines { + background-image: url('./static/images/lines.svg'); + background-position: right bottom; + background-repeat: no-repeat; + background-size: cover; +} + +.mr-bg-globe { + background-image: url('./static/images/globe.svg'); + background-position: left bottom; + background-repeat: no-repeat; + background-size: cover; +} + +.mr-bg-skyline { + background-image: url('./static/images/skyline.svg'); + background-position: center bottom; + background-repeat: no-repeat; + background-size: contain; +} diff --git a/src/tailwind.config.js b/src/tailwind.config.js index 8f5654b35..351d15871 100644 --- a/src/tailwind.config.js +++ b/src/tailwind.config.js @@ -16,6 +16,7 @@ module.exports = { 'black-5': 'rgba(0, 0, 0, .05)', 'black-10': 'rgba(0, 0, 0, .1)', 'black-15': 'rgba(0, 0, 0, .15)', + 'black-25': 'rgba(0, 0, 0, .25)', 'black-40': 'rgba(0, 0, 0, .4)', 'black-50': 'rgba(0, 0, 0, .5)', 'black-75': 'rgba(0, 0, 0, .75)', @@ -262,6 +263,7 @@ module.exports = { full: '100%', half: '50%', '2/5': '40%', + '3/4': '75%', screen50: '50vh', screen: '100vh', content: 'calc(100vh - 102px)', @@ -337,6 +339,7 @@ module.exports = { maxHeight: { full: '100%', screen: '100vh', + '40': '10rem', '48': '12rem', '100': '25rem', '112': '28rem', @@ -366,6 +369,7 @@ module.exports = { '24': '6rem', '32': '8rem', '36': '9rem', + '1/2': '50%', }, margin: { @@ -386,6 +390,7 @@ module.exports = { '8': '2rem', '10': '2.5rem', '12': '3rem', + '14': '3.5rem', '16': '4rem', '20': '5rem', '24': '6rem', @@ -412,6 +417,7 @@ module.exports = { '-28': '-7rem', '-32': '-8rem', '-40': '-10rem', + '-100': '-25rem', }, boxShadow: { diff --git a/yarn.lock b/yarn.lock index 26a41ab18..44638abb4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,6 +2,21 @@ # yarn lockfile v1 +"@apollo/client@^3.0.0-beta.44": + version "3.0.0-beta.44" + resolved "https://registry.yarnpkg.com/@apollo/client/-/client-3.0.0-beta.44.tgz#79c6083dc2fe81725ad64398bc78e641ed1c1d84" + integrity sha512-udNiabIYs9WLHWvj2j6tlsaWPNhgMZsHrZyBTKOh+69fLpmZZ4Vv4mepWq/3WCzyHoWa3n3FPi/ajNljkO/Olg== + dependencies: + "@types/zen-observable" "^0.8.0" + "@wry/equality" "^0.1.9" + fast-json-stable-stringify "^2.0.0" + graphql-tag "^2.10.2" + optimism "^0.11.5" + symbol-observable "^1.2.0" + ts-invariant "^0.4.4" + tslib "^1.10.0" + zen-observable "^0.8.14" + "@babel/code-frame@7.8.3", "@babel/code-frame@^7.8.3": version "7.8.3" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.8.3.tgz#33e25903d7481181534e12ec0a25f16b6fcf419e" @@ -1650,6 +1665,28 @@ resolved "https://registry.yarnpkg.com/@csstools/normalize.css/-/normalize.css-10.1.0.tgz#f0950bba18819512d42f7197e56c518aa491cf18" integrity sha512-ij4wRiunFfaJxjB0BdrYHIH8FxBJpOwNPhhAcunlmPdXudL1WQV1qoP9un6JsEBAgQH+7UXyyjh0g7jTxXK6tg== +"@emotion/is-prop-valid@^0.8.8": + version "0.8.8" + resolved "https://registry.yarnpkg.com/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz#db28b1c4368a259b60a97311d6a952d4fd01ac1a" + integrity sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA== + dependencies: + "@emotion/memoize" "0.7.4" + +"@emotion/memoize@0.7.4": + version "0.7.4" + resolved "https://registry.yarnpkg.com/@emotion/memoize/-/memoize-0.7.4.tgz#19bf0f5af19149111c40d98bb0cf82119f5d9eeb" + integrity sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw== + +"@emotion/stylis@^0.8.4": + version "0.8.5" + resolved "https://registry.yarnpkg.com/@emotion/stylis/-/stylis-0.8.5.tgz#deacb389bd6ee77d1e7fcaccce9e16c5c7e78e04" + integrity sha512-h6KtPihKFn3T9fuIrwvXXUOwlx3rfUvfZIcP5a6rh8Y7zjE3O06hT5Ss4S/YI1AYhuZ1kjaE/5EaOOI2NqSylQ== + +"@emotion/unitless@^0.7.4": + version "0.7.5" + resolved "https://registry.yarnpkg.com/@emotion/unitless/-/unitless-0.7.5.tgz#77211291c1900a700b8a78cfafda3160d76949ed" + integrity sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg== + "@hapi/address@2.x.x": version "2.0.0" resolved "https://registry.yarnpkg.com/@hapi/address/-/address-2.0.0.tgz#9f05469c88cb2fd3dcd624776b54ee95c312126a" @@ -2417,6 +2454,11 @@ dependencies: "@types/yargs-parser" "*" +"@types/zen-observable@^0.8.0": + version "0.8.0" + resolved "https://registry.yarnpkg.com/@types/zen-observable/-/zen-observable-0.8.0.tgz#8b63ab7f1aa5321248aad5ac890a485656dcea4d" + integrity sha512-te5lMAWii1uEJ4FwLjzdlbw3+n0FZNOvFXHxQDKeT0dilh7HOzdMzV2TrJVUzq8ep7J4Na8OUYPRLSQkJHAlrg== + "@typescript-eslint/eslint-plugin@^2.10.0": version "2.18.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-2.18.0.tgz#f8cf272dfb057ecf1ea000fea1e0b3f06a32f9cb" @@ -2588,6 +2630,20 @@ "@webassemblyjs/wast-parser" "1.8.5" "@xtuc/long" "4.2.2" +"@wry/context@^0.5.0": + version "0.5.2" + resolved "https://registry.yarnpkg.com/@wry/context/-/context-0.5.2.tgz#f2a5d5ab9227343aa74c81e06533c1ef84598ec7" + integrity sha512-B/JLuRZ/vbEKHRUiGj6xiMojST1kHhu4WcreLfNN7q9DqQFrb97cWgf/kiYsPSUCAMVN0HzfFc8XjJdzgZzfjw== + dependencies: + tslib "^1.9.3" + +"@wry/equality@^0.1.9": + version "0.1.11" + resolved "https://registry.yarnpkg.com/@wry/equality/-/equality-0.1.11.tgz#35cb156e4a96695aa81a9ecc4d03787bc17f1790" + integrity sha512-mwEVBDUVODlsQQ5dfuLUS5/Tf7jqUKyhKYHmVi4fPB6bDMOfWvUPJmKgS1Z7Za/sOI3vzWt4+O7yCiL/70MogA== + dependencies: + tslib "^1.9.3" + "@xtuc/ieee754@^1.2.0": version "1.2.0" resolved "https://registry.yarnpkg.com/@xtuc/ieee754/-/ieee754-1.2.0.tgz#eef014a3145ae477a1cbc00cd1e552336dceb790" @@ -3358,6 +3414,16 @@ babel-plugin-react-intl@^2.3.1, babel-plugin-react-intl@^2.4.0: intl-messageformat-parser "^1.2.0" mkdirp "^0.5.1" +"babel-plugin-styled-components@>= 1": + version "1.10.7" + resolved "https://registry.yarnpkg.com/babel-plugin-styled-components/-/babel-plugin-styled-components-1.10.7.tgz#3494e77914e9989b33cc2d7b3b29527a949d635c" + integrity sha512-MBMHGcIA22996n9hZRf/UJLVVgkEOITuR2SvjHLb5dSTUyR4ZRGn+ngITapes36FI3WLxZHfRhkA1ffHxihOrg== + dependencies: + "@babel/helper-annotate-as-pure" "^7.0.0" + "@babel/helper-module-imports" "^7.0.0" + babel-plugin-syntax-jsx "^6.18.0" + lodash "^4.17.11" + babel-plugin-syntax-async-functions@^6.8.0: version "6.13.0" resolved "https://registry.yarnpkg.com/babel-plugin-syntax-async-functions/-/babel-plugin-syntax-async-functions-6.13.0.tgz#cad9cad1191b5ad634bf30ae0872391e0647be95" @@ -3402,7 +3468,7 @@ babel-plugin-syntax-function-bind@^6.8.0: version "6.13.0" resolved "https://registry.yarnpkg.com/babel-plugin-syntax-function-bind/-/babel-plugin-syntax-function-bind-6.13.0.tgz#48c495f177bdf31a981e732f55adc0bdd2601f46" -babel-plugin-syntax-jsx@^6.3.13, babel-plugin-syntax-jsx@^6.8.0: +babel-plugin-syntax-jsx@^6.18.0, babel-plugin-syntax-jsx@^6.3.13, babel-plugin-syntax-jsx@^6.8.0: version "6.18.0" resolved "https://registry.yarnpkg.com/babel-plugin-syntax-jsx/-/babel-plugin-syntax-jsx-6.18.0.tgz#0af32a9a6e13ca7a3fd5069e62d7b0f58d0d8946" @@ -5016,6 +5082,11 @@ css-box-model@^1.2.0: dependencies: tiny-invariant "^1.0.6" +css-color-keywords@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/css-color-keywords/-/css-color-keywords-1.0.0.tgz#fea2616dc676b2962686b3af8dbdbe180b244e05" + integrity sha1-/qJhbcZ2spYmhrOvjb2+GAskTgU= + css-color-names@0.0.4, css-color-names@^0.0.4: version "0.0.4" resolved "https://registry.yarnpkg.com/css-color-names/-/css-color-names-0.0.4.tgz#808adc2e79cf84738069b646cb20ec27beb629e0" @@ -5084,6 +5155,15 @@ css-select@^2.0.0: domutils "^1.7.0" nth-check "^1.0.2" +css-to-react-native@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/css-to-react-native/-/css-to-react-native-3.0.0.tgz#62dbe678072a824a689bcfee011fc96e02a7d756" + integrity sha512-Ro1yETZA813eoyUp2GDBhG2j+YggidUmzO1/v9eYBKR2EHVEniE2MI/NqpTQ954BMpTPZFsGNPm46qFB9dpaPQ== + dependencies: + camelize "^1.0.0" + css-color-keywords "^1.0.0" + postcss-value-parser "^4.0.2" + css-tree@1.0.0-alpha.28: version "1.0.0-alpha.28" resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-1.0.0-alpha.28.tgz#8e8968190d886c9477bc8d61e96f61af3f7ffa7f" @@ -7051,6 +7131,16 @@ graceful-fs@^4.2.0: version "4.2.2" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.2.tgz#6f0952605d0140c1cfdb138ed005775b92d67b02" +graphql-tag@^2.10.2: + version "2.10.3" + resolved "https://registry.yarnpkg.com/graphql-tag/-/graphql-tag-2.10.3.tgz#ea1baba5eb8fc6339e4c4cf049dabe522b0edf03" + integrity sha512-4FOv3ZKfA4WdOKJeHdz6B3F/vxBLSgmBcGeAFPf4n1F64ltJUvOOerNj0rsJxONQGdhUMynQIvd6LzB+1J5oKA== + +graphql@^15.0.0: + version "15.0.0" + resolved "https://registry.yarnpkg.com/graphql/-/graphql-15.0.0.tgz#042a5eb5e2506a2e2111ce41eb446a8e570b8be9" + integrity sha512-ZyVO1xIF9F+4cxfkdhOJINM+51B06Friuv4M66W7HzUOeFd+vNzUn4vtswYINPi6sysjf1M2Ri/rwZALqgwbaQ== + growly@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/growly/-/growly-1.3.0.tgz#f10748cbe76af964b7c96c93c6bcc28af120c081" @@ -7250,6 +7340,13 @@ hoist-non-react-statics@^2.3.1, hoist-non-react-statics@^2.5.0: version "2.5.5" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-2.5.5.tgz#c5903cf409c0dfd908f388e619d86b9c1174cb47" +hoist-non-react-statics@^3.0.0: + version "3.3.2" + resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" + integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw== + dependencies: + react-is "^16.7.0" + hoist-non-react-statics@^3.3.0: version "3.3.0" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.0.tgz#b09178f0122184fb95acf525daaecb4d8f45958b" @@ -10019,6 +10116,13 @@ opn@^5.5.0: dependencies: is-wsl "^1.1.0" +optimism@^0.11.5: + version "0.11.5" + resolved "https://registry.yarnpkg.com/optimism/-/optimism-0.11.5.tgz#4c5d45fa0fa1cc9dcf092729b5d6d661b53ff5c9" + integrity sha512-twCHmBb64DYzEZ8A3O+TLCuF/RmZPBhXPQYv4agoiALRLlW9SidMzd7lwUP9mL0jOZhzhnBmb8ajqA00ECo/7g== + dependencies: + "@wry/context" "^0.5.0" + optimist@^0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/optimist/-/optimist-0.6.1.tgz#da3ea74686fa21a19a111c326e90eb15a0196686" @@ -11625,6 +11729,11 @@ react-dropzone@^10.1.5: file-selector "^0.1.11" prop-types "^15.7.2" +react-elastic-carousel@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/react-elastic-carousel/-/react-elastic-carousel-0.4.1.tgz#6486fa7514f50fdc2af95277a6cfe860b0702020" + integrity sha512-lsaj7JD4ags55caRx8UlPpP+BvHEHbdT0RbawyzAJV/Aixsw6S3QkSBKi2v/utbtaVVRcr/euRr9UQOaK1wJXg== + react-error-overlay@^6.0.5: version "6.0.5" resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.5.tgz#55d59c2a3810e8b41922e0b4e5f85dcf239bd533" @@ -12790,6 +12899,11 @@ shallow-clone@^3.0.0: dependencies: kind-of "^6.0.2" +shallowequal@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/shallowequal/-/shallowequal-1.1.0.tgz#188d521de95b9087404fd4dcb68b13df0ae4e7f8" + integrity sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ== + shebang-command@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea" @@ -13366,6 +13480,22 @@ style-to-object@^0.2.1: dependencies: inline-style-parser "0.1.1" +styled-components@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/styled-components/-/styled-components-5.1.0.tgz#2e3985b54f461027e1c91af3229e1c2530872a4e" + integrity sha512-0Qs2wEkFBXHFlysz6CV831VG6HedcrFUwChjnWylNivsx14MtmqQsohi21rMHZxzuTba063dEyoe/SR6VGJI7Q== + dependencies: + "@babel/helper-module-imports" "^7.0.0" + "@babel/traverse" "^7.4.5" + "@emotion/is-prop-valid" "^0.8.8" + "@emotion/stylis" "^0.8.4" + "@emotion/unitless" "^0.7.4" + babel-plugin-styled-components ">= 1" + css-to-react-native "^3.0.0" + hoist-non-react-statics "^3.0.0" + shallowequal "^1.1.0" + supports-color "^5.5.0" + stylehacks@^4.0.0: version "4.0.3" resolved "https://registry.yarnpkg.com/stylehacks/-/stylehacks-4.0.3.tgz#6718fcaf4d1e07d8a1318690881e8d96726a71d5" @@ -13378,7 +13508,7 @@ supports-color@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-2.0.0.tgz#535d045ce6b6363fa40117084629995e9df324c7" -supports-color@^5.0.0, supports-color@^5.3.0, supports-color@^5.4.0: +supports-color@^5.0.0, supports-color@^5.3.0, supports-color@^5.4.0, supports-color@^5.5.0: version "5.5.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" dependencies: @@ -13763,6 +13893,13 @@ trough@^1.0.0: dependencies: glob "^7.1.2" +ts-invariant@^0.4.4: + version "0.4.4" + resolved "https://registry.yarnpkg.com/ts-invariant/-/ts-invariant-0.4.4.tgz#97a523518688f93aafad01b0e80eb803eb2abd86" + integrity sha512-uEtWkFM/sdZvRNNDL3Ehu4WVpwaulhwQszV8mrtcdeE8nN00BV9mAmQ88RkrBhFgl9gMgvjJLAQcZbnPXI9mlA== + dependencies: + tslib "^1.9.3" + ts-pnp@1.1.5: version "1.1.5" resolved "https://registry.yarnpkg.com/ts-pnp/-/ts-pnp-1.1.5.tgz#840e0739c89fce5f3abd9037bb091dbff16d9dec" @@ -13772,6 +13909,11 @@ ts-pnp@^1.1.2: version "1.1.4" resolved "https://registry.yarnpkg.com/ts-pnp/-/ts-pnp-1.1.4.tgz#ae27126960ebaefb874c6d7fa4729729ab200d90" +tslib@^1.10.0, tslib@^1.9.3: + version "1.11.2" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.11.2.tgz#9c79d83272c9a7aaf166f73915c9667ecdde3cc9" + integrity sha512-tTSkux6IGPnUGUd1XAZHcpu85MOkIl5zX49pO+jfsie3eP0B6pyhOlLXm3cAC6T7s+euSDDUUV+Acop5WmtkVg== + tslib@^1.8.1, tslib@^1.9.0: version "1.10.0" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.10.0.tgz#c3c19f95973fb0a62973fb09d90d961ee43e5c8a" @@ -14803,3 +14945,8 @@ yargs@^7.0.0: which-module "^1.0.0" y18n "^3.2.1" yargs-parser "^5.0.0" + +zen-observable@^0.8.14: + version "0.8.15" + resolved "https://registry.yarnpkg.com/zen-observable/-/zen-observable-0.8.15.tgz#96415c512d8e3ffd920afd3889604e30b9eaac15" + integrity sha512-PQ2PC7R9rslx84ndNBZB/Dkv8V8fZEpk83RLgXtYd0fwUgEjseMn1Dgajh2x6S8QbZAFa9p2qVCEuYZNgve0dQ==