From 2347932828c3a12d46cceb78a8693e7af66fa117 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 7 Sep 2020 15:24:36 +0000 Subject: [PATCH 01/23] Bump http-proxy from 1.17.0 to 1.18.1 Bumps [http-proxy](https://github.com/http-party/node-http-proxy) from 1.17.0 to 1.18.1. - [Release notes](https://github.com/http-party/node-http-proxy/releases) - [Changelog](https://github.com/http-party/node-http-proxy/blob/master/CHANGELOG.md) - [Commits](https://github.com/http-party/node-http-proxy/compare/1.17.0...1.18.1) Signed-off-by: dependabot[bot] --- yarn.lock | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/yarn.lock b/yarn.lock index d0a9b7911..b429c18f4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5616,9 +5616,10 @@ eve@~0.5.1: version "0.5.4" resolved "https://registry.yarnpkg.com/eve/-/eve-0.5.4.tgz#67d080b9725291d7e389e34c26860dd97f1debaa" -eventemitter3@^3.0.0: - version "3.1.2" - resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-3.1.2.tgz#2d3d48f9c346698fce83a85d7d664e98535df6e7" +eventemitter3@^4.0.0: + version "4.0.7" + resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f" + integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw== events@^3.0.0: version "3.0.0" @@ -6035,10 +6036,9 @@ flush-write-stream@^1.0.0: readable-stream "^2.3.6" follow-redirects@^1.0.0: - version "1.7.0" - resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.7.0.tgz#489ebc198dc0e7f64167bd23b03c4c19b5784c76" - dependencies: - debug "^3.2.6" + version "1.13.0" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.13.0.tgz#b42e8d93a2a7eea5ed88633676d6597bc8e384db" + integrity sha512-aq6gF1BEKje4a9i9+5jimNFIpq4Q1WiwBToeRK5NvZBd/TRsmW8BsJfOEGkr76TbOyPVD3OVDN910EcUNtRYEA== for-in@^0.1.3: version "0.1.8" @@ -6743,10 +6743,11 @@ http-proxy-middleware@0.19.1: micromatch "^3.1.10" http-proxy@^1.17.0: - version "1.17.0" - resolved "https://registry.yarnpkg.com/http-proxy/-/http-proxy-1.17.0.tgz#7ad38494658f84605e2f6db4436df410f4e5be9a" + version "1.18.1" + resolved "https://registry.yarnpkg.com/http-proxy/-/http-proxy-1.18.1.tgz#401541f0534884bbf95260334e72f88ee3976549" + integrity sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ== dependencies: - eventemitter3 "^3.0.0" + eventemitter3 "^4.0.0" follow-redirects "^1.0.0" requires-port "^1.0.0" @@ -11632,6 +11633,7 @@ require-main-filename@^2.0.0: requires-port@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" + integrity sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8= resize-observer-polyfill@^1.5.0: version "1.5.1" From e3dc03aaf06395f4b9798d8862b208b6e1549d84 Mon Sep 17 00:00:00 2001 From: Kelli Rotstan Date: Wed, 9 Sep 2020 13:46:54 -0700 Subject: [PATCH 02/23] Fix multiple datasources being sent on create challenge * When creating a challenge a user can enter multiple datasources by toggling between the options * When cloning a challenge do not copy original datasource --- .../EditChallenge/EditChallenge.js | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/components/AdminPane/Manage/ManageChallenges/EditChallenge/EditChallenge.js b/src/components/AdminPane/Manage/ManageChallenges/EditChallenge/EditChallenge.js index ab90d1fae..7b33f3a22 100644 --- a/src/components/AdminPane/Manage/ManageChallenges/EditChallenge/EditChallenge.js +++ b/src/components/AdminPane/Manage/ManageChallenges/EditChallenge/EditChallenge.js @@ -338,10 +338,34 @@ export class EditChallenge extends Component { delete challengeData.dataOriginDate } + if (_isEmpty(this.state.formData.overpassQL)) { + delete challengeData.overpassQL + } + + if (_isEmpty(this.state.formData.remoteGeoJson)) { + delete challengeData.remoteGeoJson + } + challengeData.checkinComment = AsEditableChallenge(challengeData).checkinCommentWithoutMaprouletteHashtag() } + if (this.state.formData.source === "Overpass Query") { + // Overpass Query so delete other options + delete challengeData.remoteGeoJson + delete challengeData.localGeoJSON + } + else if (this.state.formData.source === "Local File") { + // Local file so delete other options + delete challengeData.remoteGeoJson + delete challengeData.overpassQL + } + else if (this.state.formData.source === "Remote URL") { + // Remote Url so delete other options + delete challengeData.overpassQL + delete challengeData.localGeoJSON + } + // The server uses two fields to represent the default basemap: a legacy // numeric identifier and a new optional string identifier for layers from // the OSM Editor Layer Index. If we're editing a legacy challenge that From d9ac172bd7fdf1cc99a58db7515a822646f7de26 Mon Sep 17 00:00:00 2001 From: Kelli Rotstan Date: Tue, 8 Sep 2020 14:14:35 -0700 Subject: [PATCH 03/23] Add support for adding challenges by id to virtual projects * When searching for challenges to add to a virtual project, support searching by id. Short code in search box: i/ * support i/ also on find challenges page for visible challenges --- .../VirtualProjects/ManageChallengeList.js | 15 +++- .../WithCommandInterpreter.js | 71 +++++++++++++++---- .../WithSearchResults/WithSearchResults.js | 13 +++- src/lang/en-US.json | 1 + src/services/Challenge/Challenge.js | 5 ++ src/services/Error/AppErrors.js | 4 ++ src/services/Error/Messages.js | 4 ++ 7 files changed, 96 insertions(+), 17 deletions(-) diff --git a/src/components/AdminPane/Manage/VirtualProjects/ManageChallengeList.js b/src/components/AdminPane/Manage/VirtualProjects/ManageChallengeList.js index f9875945e..19a5929f3 100644 --- a/src/components/AdminPane/Manage/VirtualProjects/ManageChallengeList.js +++ b/src/components/AdminPane/Manage/VirtualProjects/ManageChallengeList.js @@ -9,6 +9,7 @@ import WithManageableProjects import WithCurrentProject from '../../HOCs/WithCurrentProject/WithCurrentProject' import WithSearch from '../../../HOCs/WithSearch/WithSearch' import WithSearchResults from '../../../HOCs/WithSearchResults/WithSearchResults' +import WithCommandInterpreter from '../../../HOCs/WithCommandInterpreter/WithCommandInterpreter' import WithPermittedChallenges from '../../HOCs/WithPermittedChallenges/WithPermittedChallenges' import WithPagedChallenges from '../../../HOCs/WithPagedChallenges/WithPagedChallenges' import { extendedFind } from '../../../../services/Challenge/Challenge' @@ -24,10 +25,18 @@ import messages from './Messages' // Setup child components with needed HOCs. const ChallengeSearch = WithSearch( - SearchBox, + WithCommandInterpreter(SearchBox, ['p', 'i']), 'adminChallengeList', - searchCriteria => - extendedFind({searchQuery: searchCriteria.query, onlyEnabled: false}, 1000), + searchCriteria => { + if (_get(searchCriteria, 'filters')) { + return extendedFind({filters: _get(searchCriteria, 'filters', {}), + onlyEnabled: false}, 1000) + } + else { + return extendedFind({searchQuery: searchCriteria.query, + onlyEnabled: false}, 1000) + } + }, ) const ChallengeSearchResults = diff --git a/src/components/HOCs/WithCommandInterpreter/WithCommandInterpreter.js b/src/components/HOCs/WithCommandInterpreter/WithCommandInterpreter.js index 408d4404a..65c1bfcd2 100644 --- a/src/components/HOCs/WithCommandInterpreter/WithCommandInterpreter.js +++ b/src/components/HOCs/WithCommandInterpreter/WithCommandInterpreter.js @@ -7,6 +7,7 @@ import _omit from 'lodash/omit' import _find from 'lodash/find' import _get from 'lodash/get' import _debounce from 'lodash/debounce' +import _trim from 'lodash/trim' import { fetchPlaceLocation } from '../../../services/Place/Place' import WithErrors from '../WithErrors/WithErrors' import AppErrors from '../../../services/Error/AppErrors' @@ -20,11 +21,12 @@ import { SEARCH_TYPE_PROJECT } from '../../SearchTypeFilter/SearchTypeFilter' * m/ => Execute a map bounds search with either a bounding box or a centerpoint * n/ => Execute a nominatim search and move map bounds * p/ => Execute project name search + * i/ => Execute a search by challenge id (exact matches only) * s/ or default => Execute a standard search query * * @author [Kelli Rotstan](https://github.com/krotstan) */ -const WithCommandInterpreter = function(WrappedComponent) { +const WithCommandInterpreter = function(WrappedComponent, acceptedCommands = null) { return class extends Component { state = { commandString: null, @@ -35,7 +37,8 @@ const WithCommandInterpreter = function(WrappedComponent) { executeSearch = (commandString, searchType) => { // executeCommmand either runs the command or runs a standard challenge // search. It returns true if it ran a search, false if it ran a command - const wasStandardSearch = executeCommand(this.props, commandString, searchType, false, false) + const wasStandardSearch = executeCommand(this.props, commandString, searchType, + false, false, acceptedCommands) this.setState({ commandString: wasStandardSearch ? null : commandString, searchType: searchType, @@ -49,6 +52,7 @@ const WithCommandInterpreter = function(WrappedComponent) { this.props.removeSearchFilters(['query']) this.props.removeSearchFilters(['project']) this.props.removeSearchFilters(['searchType']) + this.props.removeSearchFilters(['challengeId']) this.props.clearSearch() this.setState({commandString: null, searchType: null, searchActive: true}) @@ -56,7 +60,8 @@ const WithCommandInterpreter = function(WrappedComponent) { deactivate = () => { executeCommand(this.props, this.state.commandString, this.state.searchType, - (loading) => this.setState({mapLoading: loading}), true) + (loading) => this.setState({mapLoading: loading}), true, + acceptedCommands) } componentDidUpdate(prevProps) { @@ -92,6 +97,19 @@ const WithCommandInterpreter = function(WrappedComponent) { } } +/** + * Tests if the short code is supported and will add an error if it is not. + **/ +const isCommandSupported = (code, acceptedCommands, props) => { + if (acceptedCommands && acceptedCommands.indexOf(code) === -1) { + props.addErrorWithDetails(AppErrors.search.notSupported, code + "/") + return false + } + else { + return true + } +} + /** * Executes the appropriate search type based on the start of the query string. * If isComplete is set to true then the user has indicated the search string is @@ -100,7 +118,8 @@ const WithCommandInterpreter = function(WrappedComponent) { * * @return boolean - Whether this was a typical search or a command search */ -export const executeCommand = (props, commandString, searchType, setLoading, isComplete=false) => { +export const executeCommand = (props, commandString, searchType, setLoading, + isComplete=false, acceptedCommands) => { if (searchType === SEARCH_TYPE_PROJECT) { props.setSearchFilters({project: commandString}) return false @@ -117,21 +136,47 @@ export const executeCommand = (props, commandString, searchType, setLoading, isC switch(command) { case 'm/': - props.setSearch("") // We need to clear the initial 'm' from the query - if (isComplete && query.length > 0) { - debouncedMapSearch(props, query, setLoading) + props.setSearch("") // We need to clear the initial 'm' from the query + if (isCommandSupported('m', acceptedCommands, props)) { + if (isComplete && query.length > 0) { + debouncedMapSearch(props, query, setLoading) + } + } + else { + props.setSearchFilters({}) } return false case 'n/': props.setSearch("") // We need to clear the initial 'n' from the query - if (isComplete && query.length > 0) { - debouncedPlaceSearch(props, query, setLoading) + if (isCommandSupported('n', acceptedCommands, props)) { + if (isComplete && query.length > 0) { + debouncedPlaceSearch(props, query, setLoading) + } + } + else { + props.setSearchFilters({}) } return false case 'p/': props.setSearch("") // We need to clear the initial 'p' from the query - if (query.length > 0) { - props.setSearchFilters({project: query}) + if (isCommandSupported('p', acceptedCommands, props)) { + if (query.length > 0) { + props.setSearchFilters({project: query}) + } + } + else { + props.setSearchFilters({}) + } + return false + case 'i/': + props.setSearch("") // We need to clear the initial 'i' from the query + if (isCommandSupported('i', acceptedCommands, props)) { + if (query.length > 0) { + props.setSearchFilters({challengeId: _trim(query)}) + } + } + else { + props.setSearchFilters({}) } return false case 's/': @@ -247,5 +292,5 @@ WithCommandInterpreter.propTypes = { clearSearch: PropTypes.func.isRequired, } -export default WrappedComponent => - WithErrors(WithCommandInterpreter(WrappedComponent)) +export default (WrappedComponent, acceptedCommands) => + WithErrors(WithCommandInterpreter(WrappedComponent, acceptedCommands)) diff --git a/src/components/HOCs/WithSearchResults/WithSearchResults.js b/src/components/HOCs/WithSearchResults/WithSearchResults.js index 9f92f187e..698244491 100644 --- a/src/components/HOCs/WithSearchResults/WithSearchResults.js +++ b/src/components/HOCs/WithSearchResults/WithSearchResults.js @@ -62,7 +62,18 @@ export const WithSearchResults = function(WrappedComponent, searchName, let searchResults = this.props[itemsProp] let searchActive = false - if (_isString(query) && query.length > 0 && + if (_get(this.props.searchCriteria, 'filters.challengeId')) { + const challengeIdFilter = _get(this.props.searchCriteria, 'filters.challengeId') + searchResults = _filter(items, + (item) => item.id.toString() === challengeIdFilter.toString() + ) + } + else if (_get(this.props.searchCriteria, 'filters.project')) { + const projectFilter = _get(this.props.searchCriteria, 'filters.project', '').toLowerCase() + searchResults = _filter(items, + (item) => _get(item, 'parent.displayName', '').toLowerCase().indexOf(projectFilter) !== -1) + } + else if (_isString(query) && query.length > 0 && _isArray(items) && items.length > 0) { const queryParts = parseQueryString(query) diff --git a/src/lang/en-US.json b/src/lang/en-US.json index 791182822..baa93a24d 100644 --- a/src/lang/en-US.json +++ b/src/lang/en-US.json @@ -727,6 +727,7 @@ "Errors.reviewTask.alreadyClaimed": "This task is already being reviewed by someone else.", "Errors.reviewTask.fetchFailure": "Unable to fetch review needed tasks", "Errors.reviewTask.notClaimedByYou": "Unable to cancel review.", + "Errors.search.notSupported": "Short code search not supported{details}", "Errors.task.alreadyLocked": "Task has already been locked by someone else.", "Errors.task.bundleFailure": "Unable to bundle tasks together", "Errors.task.cooperativeFailure": "Failed to load cooperative task{details}", diff --git a/src/services/Challenge/Challenge.js b/src/services/Challenge/Challenge.js index d0dcba2a3..0342200b8 100644 --- a/src/services/Challenge/Challenge.js +++ b/src/services/Challenge/Challenge.js @@ -317,6 +317,7 @@ export const extendedFind = function(criteria, limit=RESULTS_PER_PAGE) { // cs: query string // cd: challenge difficulty // ct: keywords/tags (comma-separated string) + // cid: challenge id const queryParams = { limit, ce: onlyEnabled ? 'true' : 'false', @@ -333,6 +334,10 @@ export const extendedFind = function(criteria, limit=RESULTS_PER_PAGE) { queryParams.ps = filters.project } + if (_isString(filters.challengeId)) { + queryParams.cid = filters.challengeId + } + // Keywords/tags can come from both the the query and the filter, so we need to // combine them into a single keywords array. const keywords = diff --git a/src/services/Error/AppErrors.js b/src/services/Error/AppErrors.js index 6eef0b871..38de11ad7 100644 --- a/src/services/Error/AppErrors.js +++ b/src/services/Error/AppErrors.js @@ -108,4 +108,8 @@ export default { team: { failure: messages.teamFailure, }, + + search: { + notSupported: messages.searchNotSupported, + }, } diff --git a/src/services/Error/Messages.js b/src/services/Error/Messages.js index dfbb2fade..bc8bbb241 100644 --- a/src/services/Error/Messages.js +++ b/src/services/Error/Messages.js @@ -238,4 +238,8 @@ export default defineMessages({ id: "Errors.team.genericFailure", defaultMessage: "Failure{details}" }, + searchNotSupported: { + id: "Errors.search.notSupported", + defaultMessage: "Short code search not supported{details}" + } }) From 31f68297ac7f046a7b17a15e7c4f54036af2c439 Mon Sep 17 00:00:00 2001 From: Kelli Rotstan Date: Thu, 10 Sep 2020 10:49:13 -0700 Subject: [PATCH 04/23] Fixed minor usuability issues around task completion * From challenge task management when reviewing a task, after review, show next filtered review task from the same challenge. * Made it so task instructions overlay does not show initially when skipping a task. * When showing task instructions overlays if it showed automatically name close button 'continue' otherwise 'hide instructions' * If task instructions overlay does not include form questions, then have instructions fill the whole overlay box. --- src/components/MarkdownContent/MarkdownTemplate.js | 4 +++- src/components/TaskAnalysisTable/TaskAnalysisTable.js | 8 +++++++- .../TaskConfirmationModal/InstructionsOverlay.js | 2 +- src/components/TaskConfirmationModal/Messages.js | 4 ++++ .../TaskConfirmationModal/TaskConfirmationModal.js | 11 +++++++---- src/lang/en-US.json | 1 + src/tailwind.config.js | 1 + 7 files changed, 24 insertions(+), 7 deletions(-) diff --git a/src/components/MarkdownContent/MarkdownTemplate.js b/src/components/MarkdownContent/MarkdownTemplate.js index 13cd8853c..c16db251c 100644 --- a/src/components/MarkdownContent/MarkdownTemplate.js +++ b/src/components/MarkdownContent/MarkdownTemplate.js @@ -215,7 +215,9 @@ export default class MarkdownTemplate extends Component {
{this.props.header}
{!_isEmpty(this.state.questions) && diff --git a/src/components/TaskAnalysisTable/TaskAnalysisTable.js b/src/components/TaskAnalysisTable/TaskAnalysisTable.js index b5197b58c..a11d86f11 100644 --- a/src/components/TaskAnalysisTable/TaskAnalysisTable.js +++ b/src/components/TaskAnalysisTable/TaskAnalysisTable.js @@ -518,7 +518,13 @@ const setupColumnTypes = (props, taskBaseRoute, manager, data, openComments) => } {(!_isUndefined(row._original.reviewStatus)) && - + } diff --git a/src/components/TaskConfirmationModal/InstructionsOverlay.js b/src/components/TaskConfirmationModal/InstructionsOverlay.js index 75f0b0f6d..6ebd4d76f 100644 --- a/src/components/TaskConfirmationModal/InstructionsOverlay.js +++ b/src/components/TaskConfirmationModal/InstructionsOverlay.js @@ -24,7 +24,7 @@ export default class InstructionsOverlay extends Component { onClick={() => this.props.close()} className="mr-button mr-w-4/5 mr-mb-8" > - +
diff --git a/src/components/TaskConfirmationModal/Messages.js b/src/components/TaskConfirmationModal/Messages.js index 37e2a7e21..f5b878106 100644 --- a/src/components/TaskConfirmationModal/Messages.js +++ b/src/components/TaskConfirmationModal/Messages.js @@ -159,4 +159,8 @@ export default defineMessages({ defaultMessage: "Hide Instructions", }, + instructionsContiniueLabel: { + id: 'TaskConfirmationModal.instructionsContinue.label', + defaultMessage: "Continue", + }, }) diff --git a/src/components/TaskConfirmationModal/TaskConfirmationModal.js b/src/components/TaskConfirmationModal/TaskConfirmationModal.js index 22fe3dde5..5ff1d5610 100644 --- a/src/components/TaskConfirmationModal/TaskConfirmationModal.js +++ b/src/components/TaskConfirmationModal/TaskConfirmationModal.js @@ -69,8 +69,9 @@ export class TaskConfirmationModal extends Component { this.handleKeyboardShortcuts ) - if (this.props.needsResponses && _isEmpty(this.props.completionResponses)) { - this.setState({showInstructions: true}) + if (this.props.needsResponses && _isEmpty(this.props.completionResponses) && + this.props.status !== TaskStatus.skipped) { + this.setState({showInstructions: true, instructionsContinue: true}) } } @@ -302,7 +303,7 @@ export class TaskConfirmationModal extends Component {
-
this.setState({showInstructions: true})}> +
this.setState({showInstructions: true, instructionsContinue: false})}>
@@ -442,7 +443,9 @@ export class TaskConfirmationModal extends Component { {!this.props.inReview && this.state.showInstructions && _isUndefined(this.props.needsRevised) && this.setState({showInstructions: false})} + close={() => this.setState({showInstructions: false, instructionsContinue: false})} + closeMessage={this.state.instructionsContinue ? + messages.instructionsContiniueLabel : messages.closeInstructionsLabel} /> } diff --git a/src/lang/en-US.json b/src/lang/en-US.json index 791182822..017b1a3b6 100644 --- a/src/lang/en-US.json +++ b/src/lang/en-US.json @@ -1254,6 +1254,7 @@ "TaskConfirmationModal.inReviewHeader": "Please Confirm Review", "TaskConfirmationModal.instructions.header": "View Task Instructions", "TaskConfirmationModal.instructions.label": "Task Instructions", + "TaskConfirmationModal.instructionsContinue.label": "Continue", "TaskConfirmationModal.invert.label": "invert", "TaskConfirmationModal.inverted.label": "inverted", "TaskConfirmationModal.loadBy.label": "Next task:", diff --git a/src/tailwind.config.js b/src/tailwind.config.js index 4afd2f73d..c395c84fe 100644 --- a/src/tailwind.config.js +++ b/src/tailwind.config.js @@ -262,6 +262,7 @@ module.exports = { '40': '10rem', '48': '12rem', '64': '16rem', + '72': '18rem', '100': '25rem', '112': '28rem', hero: '43.75rem', From d3c64e45a899f2704fb25dc355f45d6e9264b21c Mon Sep 17 00:00:00 2001 From: Kelli Rotstan Date: Fri, 11 Sep 2020 11:13:25 -0700 Subject: [PATCH 05/23] Fixed typo in continue label --- src/components/TaskConfirmationModal/Messages.js | 2 +- src/components/TaskConfirmationModal/TaskConfirmationModal.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/TaskConfirmationModal/Messages.js b/src/components/TaskConfirmationModal/Messages.js index f5b878106..dcc5410a5 100644 --- a/src/components/TaskConfirmationModal/Messages.js +++ b/src/components/TaskConfirmationModal/Messages.js @@ -159,7 +159,7 @@ export default defineMessages({ defaultMessage: "Hide Instructions", }, - instructionsContiniueLabel: { + instructionsContinueLabel: { id: 'TaskConfirmationModal.instructionsContinue.label', defaultMessage: "Continue", }, diff --git a/src/components/TaskConfirmationModal/TaskConfirmationModal.js b/src/components/TaskConfirmationModal/TaskConfirmationModal.js index 5ff1d5610..604d5bdad 100644 --- a/src/components/TaskConfirmationModal/TaskConfirmationModal.js +++ b/src/components/TaskConfirmationModal/TaskConfirmationModal.js @@ -445,7 +445,7 @@ export class TaskConfirmationModal extends Component { {...this.props} close={() => this.setState({showInstructions: false, instructionsContinue: false})} closeMessage={this.state.instructionsContinue ? - messages.instructionsContiniueLabel : messages.closeInstructionsLabel} + messages.instructionsContinueLabel : messages.closeInstructionsLabel} /> } From a448a477ede9883c2c1aa7c31cbb12ce95a55c38 Mon Sep 17 00:00:00 2001 From: Kelli Rotstan Date: Fri, 11 Sep 2020 12:45:14 -0700 Subject: [PATCH 06/23] Hide admin start challenge contol on completed challenges * In admin challenge dashboard, don't show 'start challenge' control if the challenge has been completed as there is nothing to 'start' --- .../AdminPane/Manage/ChallengeCard/ChallengeControls.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/AdminPane/Manage/ChallengeCard/ChallengeControls.js b/src/components/AdminPane/Manage/ChallengeCard/ChallengeControls.js index 544a9c061..33d67223c 100644 --- a/src/components/AdminPane/Manage/ChallengeCard/ChallengeControls.js +++ b/src/components/AdminPane/Manage/ChallengeCard/ChallengeControls.js @@ -60,6 +60,7 @@ export default class ChallengeControls extends Component { return (
{hasTasks && isUsableChallengeStatus(status, true) && + status !== ChallengeStatus.finished && Date: Fri, 11 Sep 2020 14:47:04 -0700 Subject: [PATCH 07/23] Example basemap in user settings throws error when used as overlay * When using a custom overlay with an {s}, the subdomains were always being sent to react-leaflet as [""] --- src/services/VisibleLayer/LayerSources.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/services/VisibleLayer/LayerSources.js b/src/services/VisibleLayer/LayerSources.js index 8712973fb..61f21243a 100644 --- a/src/services/VisibleLayer/LayerSources.js +++ b/src/services/VisibleLayer/LayerSources.js @@ -91,7 +91,12 @@ export const normalizeBingLayer = function(layer) { export const normalizeTMSLayer = function(layer) { const normalizedLayer = Object.assign({}, layer) - normalizedLayer.subdomains = (normalizedLayer.url.match(/{switch:(.*?)}/) || ['',''])[1].split(',') + + // Only assign subdomains if we have some. + if (normalizedLayer.url.match(/{switch:(.*?)}/)) { + normalizedLayer.subdomains = normalizedLayer.url.match(/{switch:(.*?)}/)[1].split(',') + } + normalizedLayer.url = normalizedLayer.url.replace(/{switch:(.*?)}/, '{s}') normalizedLayer.url = normalizedLayer.url.replace('{zoom}', '{z}') normalizedLayer.maxZoom = normalizedLayer.max_zoom From 08a9757a09750fbcd8085b4cdabf79adcf1c1bf9 Mon Sep 17 00:00:00 2001 From: Kelli Rotstan Date: Mon, 21 Sep 2020 15:13:26 -0700 Subject: [PATCH 08/23] After bulk status change clear all challenge tasks from redux --- src/services/Task/Task.js | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/src/services/Task/Task.js b/src/services/Task/Task.js index 06b48310b..21dd5c4ac 100644 --- a/src/services/Task/Task.js +++ b/src/services/Task/Task.js @@ -12,6 +12,7 @@ import _isFinite from 'lodash/isFinite' import _isArray from 'lodash/isArray' import _isObject from 'lodash/isObject' import _values from 'lodash/values' +import _remove from 'lodash/remove' import { defaultRoutes as api, isSecurityError, websocketClient } from '../Server/Server' import Endpoint from '../Server/Endpoint' import RequestStatus from '../Server/RequestStatus' @@ -143,6 +144,7 @@ const dispatchTaskUpdateNotification = function(dispatch, task) { // redux actions const RECEIVE_TASKS = 'RECEIVE_TASKS' +const CLEAR_TASKS = 'CLEAR_TASKS' const REMOVE_TASK = 'REMOVE_TASK' // redux action creators @@ -159,6 +161,18 @@ export const receiveTasks = function(normalizedEntities) { } } +/** + * Clear task data for a given challenge from the redux store + */ +export const clearTasks = function(challengeId) { + return { + type: CLEAR_TASKS, + status: RequestStatus.success, + challengeId: challengeId, + receivedAt: Date.now() + } +} + /** * Remove a task from the redux store */ @@ -307,7 +321,13 @@ export const bulkUpdateTasks = function(updatedTasks, skipConversion=false) { return new Endpoint( api.tasks.bulkUpdate, {json: taskData} - ).execute().catch(error => { + ).execute().then(results => { + // Clear all tasks in challenge since we don't know exactly which tasks + // are impacted by these changes (as bundling could be affected) + if (taskData.length > 0) { + dispatch(clearTasks(taskData[0].parentId)) + } + }).catch(error => { if (isSecurityError(error)) { dispatch(ensureUserLoggedIn()).then(() => dispatch(addError(AppErrors.user.unauthorized)) @@ -341,7 +361,9 @@ export const bulkTaskStatusChange = function(newStatus, challengeId, criteria, e params: {...searchParameters, newStatus}, json: filters.taskPropertySearch ? {taskPropertySearch: filters.taskPropertySearch} : null, } - ).execute().catch(error => { + ).execute().then( results => { + dispatch(clearTasks(challengeId)) + }).catch(error => { if (isSecurityError(error)) { dispatch(ensureUserLoggedIn()).then(() => dispatch(addError(AppErrors.user.unauthorized)) @@ -1022,6 +1044,9 @@ export const taskEntities = function(state, action) { delete mergedState[action.taskId] return mergedState } + else if (action.type === CLEAR_TASKS) { + return _remove(_cloneDeep(state), x => (x ? x.parent === action.challengeId : false)) + } else { return genericEntityReducer(RECEIVE_TASKS, 'tasks', reduceTasksFurther)(state, action) } From 2e79c7686a354b81296edb68fda7234aafd6ca78 Mon Sep 17 00:00:00 2001 From: Kelli Rotstan Date: Tue, 22 Sep 2020 12:57:38 -0700 Subject: [PATCH 09/23] Add support for simple 'meta' reviewing * Add link on tasks review table to 'review further' on tasks that have been reviewed by someone else * Add toggle to task history widget to show those who have contributed as mappers and reviewers * Add new notification type to let a reviewer know that their review was revised by someone else --- src/components/TaskHistoryList/Messages.js | 20 ++++ .../TaskHistoryList/TaskHistoryList.js | 96 ++++++++++++++++++- src/lang/en-US.json | 7 ++ src/pages/Inbox/Messages.js | 5 + src/pages/Inbox/Notification.js | 19 ++++ src/pages/Review/TasksReview/Messages.js | 5 + .../Review/TasksReview/TasksReviewTable.js | 6 ++ .../Notification/NotificationType/Messages.js | 4 + .../NotificationType/NotificationType.js | 2 + 9 files changed, 159 insertions(+), 5 deletions(-) diff --git a/src/components/TaskHistoryList/Messages.js b/src/components/TaskHistoryList/Messages.js index 87298b628..200501041 100644 --- a/src/components/TaskHistoryList/Messages.js +++ b/src/components/TaskHistoryList/Messages.js @@ -17,5 +17,25 @@ export default defineMessages({ taskUpdatedLabel: { id: "TaskHistory.fields.taskUpdated.label", defaultMessage: "Task updated by challenge manager", + }, + + listByTime: { + id: "TaskHistory.controls.listByTime.label", + defaultMessage: "Entries" + }, + + listByUser: { + id: "TaskHistory.controls.listByUser.label", + defaultMessage: "Contributors" + }, + + reviewerType: { + id: "TaskHistory.fields.userType.reviewer", + defaultMessage: "Reviewer" + }, + + mapperType: { + id: "TaskHistory.fields.userType.mapper", + defaultMessage: "Mapper" } }) diff --git a/src/components/TaskHistoryList/TaskHistoryList.js b/src/components/TaskHistoryList/TaskHistoryList.js index 869b4f3d9..2ed0d3fc9 100644 --- a/src/components/TaskHistoryList/TaskHistoryList.js +++ b/src/components/TaskHistoryList/TaskHistoryList.js @@ -3,7 +3,8 @@ import PropTypes from 'prop-types' import classNames from 'classnames' import { FormattedMessage, FormattedDate, - FormattedTime } from 'react-intl' + FormattedTime, + injectIntl } from 'react-intl' import _map from 'lodash/map' import _get from 'lodash/get' import _kebabCase from 'lodash/kebabCase' @@ -12,6 +13,8 @@ import _isUndefined from 'lodash/isUndefined' import _indexOf from 'lodash/indexOf' import _sortBy from 'lodash/sortBy' import _reverse from 'lodash/reverse' +import _find from 'lodash/find' +import _noop from 'lodash/noop' import MarkdownContent from '../MarkdownContent/MarkdownContent' import SvgSymbol from '../SvgSymbol/SvgSymbol' import { keysByStatus, messagesByStatus, TASK_STATUS_CREATED } @@ -24,13 +27,25 @@ import { mapColors } from '../../interactions/User/AsEndUser' import messages from './Messages' +// Constants for userType +const REVIEWER_TYPE = "reviewer" +const MAPPER_TYPE = "mapper" + +// constants for toggle between time/entries and users/contributors +const USER_TOGGLE = "user" +const TIME_TOGGLE = "time" + /** * TaskHistoryList renders the given history as a list with some basic formatting, * starting with the most recent log entry. * * @author [Kelli Rotstan](https://github.com/krotstan) */ -export default class TaskHistoryList extends Component { +export class TaskHistoryList extends Component { + state = { + listType: TIME_TOGGLE, + } + render() { if (this.props.taskHistory.length === 0) { return
No History
@@ -44,6 +59,7 @@ export default class TaskHistoryList extends Component { let updatedStatus = null let startedAtEntry = null let duration = null + let userType = null _each(_sortBy(this.props.taskHistory, h => new Date(h.timestamp)), (log, index) => { // We are moving on to a new set of actions so let's push @@ -54,7 +70,8 @@ export default class TaskHistoryList extends Component { duration: duration, entry: entries, username: username, - status: updatedStatus}) + status: updatedStatus, + userType: userType}) if (startedAtEntry) { combinedLogs.push(startedAtEntry) startedAtEntry = null @@ -62,6 +79,7 @@ export default class TaskHistoryList extends Component { entries = [] updatedStatus = null duration = null + userType = null } lastTimestamp = new Date(log.timestamp) @@ -86,6 +104,7 @@ export default class TaskHistoryList extends Component { showDot /> username = _get(log, 'reviewedBy.username') + userType = REVIEWER_TYPE if (log.startedAt) { duration = new Date(log.timestamp) - new Date(log.startedAt) @@ -100,6 +119,7 @@ export default class TaskHistoryList extends Component { default: logEntry = null username = _get(log, 'user.username') + userType = MAPPER_TYPE updatedStatus = statusEntry(log, this.props, index) if (log.startedAt || log.oldStatus === TASK_STATUS_CREATED) { // Add a "Started At" entry into the history @@ -133,13 +153,43 @@ export default class TaskHistoryList extends Component { duration: duration, entry: entries, username: username, - status: updatedStatus}) + status: updatedStatus, + userType: userType}) if (startedAtEntry) { combinedLogs.push(startedAtEntry) startedAtEntry = null } } + const contributors = [] + _each(combinedLogs, (log) => { + // Don't add a contributor twice + if (log.userType && + !_find(contributors, c => (c.username === log.username && + c.userType === log.userType))) { + contributors.push(log) + } + }) + + const contributorEntries = +
+ {_map(contributors, (c, index) => ( +
+ {index + 1}. + + {c.username} + + + {c.userType === REVIEWER_TYPE ? + this.props.intl.formatMessage(messages.reviewerType) : + this.props.intl.formatMessage(messages.mapperType) + } + +
+ ))} +
+ combinedLogs = _reverse(_sortBy(combinedLogs, log => new Date(log.timestamp))) const historyEntries = _map(combinedLogs, (log, index) => { @@ -198,8 +248,42 @@ export default class TaskHistoryList extends Component { )} ) + const contributorToggle = +
+ + this.setState({listType: TIME_TOGGLE})} + onChange={_noop} + /> + + + + this.setState({listType: USER_TOGGLE})} + onChange={_noop} + /> + + +
+ return ( - {historyEntries} + + {contributorToggle} + {this.state.listType === TIME_TOGGLE && historyEntries} + {this.state.listType === USER_TOGGLE && contributorEntries} + ) } } @@ -284,3 +368,5 @@ TaskHistoryList.propTypes = { TaskHistoryList.defaultProps = { taskHistory: [], } + +export default injectIntl(TaskHistoryList) diff --git a/src/lang/en-US.json b/src/lang/en-US.json index d763621cc..a9094bf1c 100644 --- a/src/lang/en-US.json +++ b/src/lang/en-US.json @@ -824,6 +824,7 @@ "Inbox.reviewApprovedNotification.lead": "Good news! Your task work has been reviewed and approved.", "Inbox.reviewApprovedWithFixesNotification.lead": "Your task work has been approved (with some fixes made for you by the reviewer).", "Inbox.reviewRejectedNotification.lead": "Following a review of your task, the reviewer has determined that it needs some additional work.", + "Inbox.reviewRevisedNotification.lead": "Another reviewer has revised your review.", "Inbox.tableHeaders.challengeName": "Challenge", "Inbox.tableHeaders.controls": "Actions", "Inbox.tableHeaders.created": "Sent", @@ -936,6 +937,7 @@ "Notification.type.review.again": "Review", "Notification.type.review.approved": "Approved", "Notification.type.review.rejected": "Revise", + "Notification.type.review.revised": "Review Revised", "Notification.type.system": "System", "Notification.type.team": "Team", "PageNotFound.homePage": "Take me home", @@ -1031,6 +1033,7 @@ "Review.TaskAnalysisTable.columnHeaders.comments": "Comments", "Review.TaskAnalysisTable.configureColumns": "Configure columns", "Review.TaskAnalysisTable.controls.fixTask.label": "Fix", + "Review.TaskAnalysisTable.controls.metaReviewTask.label": "Review further", "Review.TaskAnalysisTable.controls.resolveTask.label": "Resolve", "Review.TaskAnalysisTable.controls.reviewAgainTask.label": "Review Revision", "Review.TaskAnalysisTable.controls.reviewTask.label": "Review", @@ -1272,9 +1275,13 @@ "TaskConfirmationModal.submit.label": "Submit", "TaskConfirmationModal.submitRevisionHeader": "Please Confirm Revision", "TaskConfirmationModal.useChallenge.label": "Use current challenge", + "TaskHistory.controls.listByTime.label": "Entries", + "TaskHistory.controls.listByUser.label": "Contributors", "TaskHistory.controls.viewAttic.label": "View Attic", "TaskHistory.fields.startedOn.label": "Started on task", "TaskHistory.fields.taskUpdated.label": "Task updated by challenge manager", + "TaskHistory.fields.userType.mapper": "Mapper", + "TaskHistory.fields.userType.reviewer": "Reviewer", "TaskPriorityFilter.label": "Filter by Priority", "TaskPropertyFilter.label": "Filter By Property", "TaskPropertyQueryBuilder.commaSeparateValues.label": "Comma separate values", diff --git a/src/pages/Inbox/Messages.js b/src/pages/Inbox/Messages.js index f1b594a91..b0b2c4365 100644 --- a/src/pages/Inbox/Messages.js +++ b/src/pages/Inbox/Messages.js @@ -104,6 +104,11 @@ export default defineMessages({ defaultMessage: "The mapper has revised their work and is requesting an additional review.", }, + reviewRevisedNotificationLead: { + id: "Inbox.reviewRevisedNotification.lead", + defaultMessage: "Another reviewer has revised your review.", + }, + challengeCompleteNotificationLead: { id: "Inbox.challengeCompleteNotification.lead", defaultMessage: "A challenge you manage has been completed.", diff --git a/src/pages/Inbox/Notification.js b/src/pages/Inbox/Notification.js index e39dfc1f8..67f726b6d 100644 --- a/src/pages/Inbox/Notification.js +++ b/src/pages/Inbox/Notification.js @@ -28,6 +28,8 @@ class Notification extends Component { case NotificationType.reviewRejected: case NotificationType.reviewAgain: return + case NotificationType.reviewRevised: + return case NotificationType.challengeCompleted: return case NotificationType.mapperChallengeCompleted: @@ -163,6 +165,23 @@ const ReviewBody = function(props) { ) } +const ReviewRevisedBody = function(props) { + const lead = + + return ( + +

{lead}

+ + + + +
+ ) +} + const ChallengeCompletionBody = function(props) { return ( diff --git a/src/pages/Review/TasksReview/Messages.js b/src/pages/Review/TasksReview/Messages.js index 4acb48954..d9a519434 100644 --- a/src/pages/Review/TasksReview/Messages.js +++ b/src/pages/Review/TasksReview/Messages.js @@ -149,6 +149,11 @@ export default defineMessages({ defaultMessage: "Review Revision", }, + metaReviewTaskLabel: { + id: "Review.TaskAnalysisTable.controls.metaReviewTask.label", + defaultMessage: "Review further", + }, + resolveTaskLabel: { id: "Review.TaskAnalysisTable.controls.resolveTask.label", defaultMessage: "Resolve", diff --git a/src/pages/Review/TasksReview/TasksReviewTable.js b/src/pages/Review/TasksReview/TasksReviewTable.js index 5140dbf5b..a1267871f 100644 --- a/src/pages/Review/TasksReview/TasksReviewTable.js +++ b/src/pages/Review/TasksReview/TasksReviewTable.js @@ -777,6 +777,12 @@ const setupColumnTypes = (props, openComments, data, criteria, pageSize) => { {message} + {row._original.reviewStatus !== TaskReviewStatus.needed && + row._original.reviewedBy && row._original.reviewedBy.id !== props.user.id && +
props.history.push(linkTo + "/review", criteria)} className="mr-text-green-lighter hover:mr-text-white mr-cursor-pointer mr-transition"> + +
+ }
} } diff --git a/src/services/Notification/NotificationType/Messages.js b/src/services/Notification/NotificationType/Messages.js index 7273a9a4c..19e7cb6af 100644 --- a/src/services/Notification/NotificationType/Messages.js +++ b/src/services/Notification/NotificationType/Messages.js @@ -24,6 +24,10 @@ export default defineMessages({ id: "Notification.type.review.again", defaultMessage: "Review" }, + reviewRevised: { + id: "Notification.type.review.revised", + defaultMessage: "Review Revised" + }, challengeCompleted: { id: "Notification.type.challengeCompleted", defaultMessage: "Completed" diff --git a/src/services/Notification/NotificationType/NotificationType.js b/src/services/Notification/NotificationType/NotificationType.js index 0b0653654..0b3ed80e9 100644 --- a/src/services/Notification/NotificationType/NotificationType.js +++ b/src/services/Notification/NotificationType/NotificationType.js @@ -13,6 +13,7 @@ export const NOTIFICATION_TYPE_CHALLENGE_COMPLETED = 5 export const NOTIFICATION_TYPE_TEAM = 6 export const NOTIFICATION_TYPE_FOLLOW = 7 export const NOTIFICATION_TYPE_MAPPER_CHALLENGE_COMPLETED = 8 +export const NOTIFICATION_TYPE_REVIEW_REVISED = 9 export const NotificationType = Object.freeze({ system: NOTIFICATION_TYPE_SYSTEM, @@ -20,6 +21,7 @@ export const NotificationType = Object.freeze({ reviewApproved: NOTIFICATION_TYPE_REVIEW_APPROVED, reviewRejected: NOTIFICATION_TYPE_REVIEW_REJECTED, reviewAgain: NOTIFICATION_TYPE_REVIEW_AGAIN, + reviewRevised: NOTIFICATION_TYPE_REVIEW_REVISED, challengeCompleted: NOTIFICATION_TYPE_CHALLENGE_COMPLETED, mapperChallengeCompleted: NOTIFICATION_TYPE_MAPPER_CHALLENGE_COMPLETED, team: NOTIFICATION_TYPE_TEAM, From 1bcc18aecc11d2c99633418ca56ff476d8bf56c7 Mon Sep 17 00:00:00 2001 From: Kelli Rotstan Date: Tue, 22 Sep 2020 13:45:47 -0700 Subject: [PATCH 10/23] Changed contributor display to ol/li --- src/components/TaskHistoryList/TaskHistoryList.js | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/components/TaskHistoryList/TaskHistoryList.js b/src/components/TaskHistoryList/TaskHistoryList.js index 2ed0d3fc9..25efd6398 100644 --- a/src/components/TaskHistoryList/TaskHistoryList.js +++ b/src/components/TaskHistoryList/TaskHistoryList.js @@ -172,11 +172,10 @@ export class TaskHistoryList extends Component { }) const contributorEntries = -
+
    {_map(contributors, (c, index) => ( -
    - {index + 1}. - + {c.username} @@ -186,9 +185,9 @@ export class TaskHistoryList extends Component { this.props.intl.formatMessage(messages.mapperType) } -
    + ))} -
+ combinedLogs = _reverse(_sortBy(combinedLogs, log => new Date(log.timestamp))) From 4738067b16f146101a94a0cb4e9b81907c097502 Mon Sep 17 00:00:00 2001 From: Kelli Rotstan Date: Tue, 15 Sep 2020 10:42:18 -0700 Subject: [PATCH 11/23] Fix weird behavior when searching by challenge id in search box * In search box when searching by challenge id and then reverting to normal query search, clear the prior filters * To prevent search box cursor from jumping to end when changing middle of string let the search box control the input * When executing virtual challenge search query do not search by filters if object is empty --- .../VirtualProjects/ManageChallengeList.js | 3 ++- .../WithCommandInterpreter.js | 15 ++++----------- src/components/SearchBox/SearchBox.js | 19 ++++++++++++++----- 3 files changed, 20 insertions(+), 17 deletions(-) diff --git a/src/components/AdminPane/Manage/VirtualProjects/ManageChallengeList.js b/src/components/AdminPane/Manage/VirtualProjects/ManageChallengeList.js index 19a5929f3..18082f8b5 100644 --- a/src/components/AdminPane/Manage/VirtualProjects/ManageChallengeList.js +++ b/src/components/AdminPane/Manage/VirtualProjects/ManageChallengeList.js @@ -2,6 +2,7 @@ import React, { Component } from 'react' import _get from 'lodash/get' import _isObject from 'lodash/isObject' import _omit from 'lodash/omit' +import _isEmpty from 'lodash/isEmpty' import { FormattedMessage, injectIntl } from 'react-intl' import { Link } from 'react-router-dom' import WithManageableProjects @@ -28,7 +29,7 @@ const ChallengeSearch = WithSearch( WithCommandInterpreter(SearchBox, ['p', 'i']), 'adminChallengeList', searchCriteria => { - if (_get(searchCriteria, 'filters')) { + if (!_isEmpty(_get(searchCriteria, 'filters'))) { return extendedFind({filters: _get(searchCriteria, 'filters', {}), onlyEnabled: false}, 1000) } diff --git a/src/components/HOCs/WithCommandInterpreter/WithCommandInterpreter.js b/src/components/HOCs/WithCommandInterpreter/WithCommandInterpreter.js index 65c1bfcd2..58f275bf5 100644 --- a/src/components/HOCs/WithCommandInterpreter/WithCommandInterpreter.js +++ b/src/components/HOCs/WithCommandInterpreter/WithCommandInterpreter.js @@ -49,10 +49,7 @@ const WithCommandInterpreter = function(WrappedComponent, acceptedCommands = nul clearSearch = () => { // Temporary: until we add an Advanced Search dialog where a user can // clear a project search filter, we need to do it explicitly here - this.props.removeSearchFilters(['query']) - this.props.removeSearchFilters(['project']) - this.props.removeSearchFilters(['searchType']) - this.props.removeSearchFilters(['challengeId']) + this.props.removeSearchFilters(['query', 'project', 'searchType', 'challengeId']) this.props.clearSearch() this.setState({commandString: null, searchType: null, searchActive: true}) @@ -128,15 +125,9 @@ export const executeCommand = (props, commandString, searchType, setLoading, const command = commandString && commandString.length >= 2 ? commandString.substring(0, 2) : null let query = commandString ? commandString.substring(2) : commandString - // Temporary: until we add an Advanced Search dialog where a user can clear a - // project search filter, we need to do it explicitly here if needed - if (command !== '/p') { - props.removeSearchFilters(['project']) - } - switch(command) { case 'm/': - props.setSearch("") // We need to clear the initial 'm' from the query + props.setSearch("") // We need to clear the initial 'm' from the query if (isCommandSupported('m', acceptedCommands, props)) { if (isComplete && query.length > 0) { debouncedMapSearch(props, query, setLoading) @@ -184,6 +175,8 @@ export const executeCommand = (props, commandString, searchType, setLoading, if (command !== 's/') { query = commandString } + // Remove any lingering search filters. + props.removeSearchFilters(['project', 'challengeId']) // Standard search query props.setSearch(query) diff --git a/src/components/SearchBox/SearchBox.js b/src/components/SearchBox/SearchBox.js index ebbf4238e..37b6fefd3 100644 --- a/src/components/SearchBox/SearchBox.js +++ b/src/components/SearchBox/SearchBox.js @@ -18,6 +18,8 @@ import './SearchBox.scss' * @author [Neil Rotstan](https://github.com/nrotstan) */ export default class SearchBox extends Component { + inputRef = React.createRef() + /** * Esc clears search, Enter signals completion * @@ -49,10 +51,18 @@ export default class SearchBox extends Component { _get(props, 'searchQuery.query')) || '' } + componentDidUpdate(prevProps) { + if (this.inputRef.current) { + if (this.getQuery(this.props) !== this.inputRef.current.value) { + // We have an uncotrolled input so our cursor can be managed as expected, + // so if the input isn't what we expect then we need to change it. + this.inputRef.current.value = this.getQuery(this.props) + } + } + } + render() { - const query = (this.props.searchGroup ? - _get(this.props, `searchQueries.${this.props.searchGroup}.searchQuery.query`) : - _get(this.props, 'searchQuery.query')) || '' + const query = this.getQuery(this.props) const isLoading = _get(this.props, 'searchQuery.meta.fetchingResults') const clearButton = @@ -72,7 +82,6 @@ export default class SearchBox extends Component { /> - return (
{doneButton} From 8a36923ccd8827c383e256cf4152b6b288146431 Mon Sep 17 00:00:00 2001 From: Kelli Rotstan Date: Fri, 18 Sep 2020 12:49:39 -0700 Subject: [PATCH 12/23] Add support for suggesting usernames when mentioning in comment * Change comment input box to be a AutosuggestMentionTextArea which shows a dropdown with suggestions when @ is typed * Add support to WithOSMSearch to also search with a task id and build a preferredResults of users who've participated in this task * Add support for Dropdown prop wrapperClassName --- .../AutosuggestMentionTextArea.js | 252 ++++++++++++++++++ src/components/Dropdown/Dropdown.js | 2 +- .../WithOSMUserSearch/WithOSMUserSearch.js | 36 ++- .../TaskCommentInput/TaskCommentInput.js | 24 +- .../TaskCommentsModal/TaskCommentsModal.js | 6 +- .../TaskConfirmationModal.js | 5 +- .../TaskHistoryWidget/TaskHistoryWidget.js | 4 + src/services/Server/APIRoutes.js | 1 + src/services/User/User.js | 10 + 9 files changed, 315 insertions(+), 25 deletions(-) create mode 100644 src/components/AutosuggestTextBox/AutosuggestMentionTextArea.js diff --git a/src/components/AutosuggestTextBox/AutosuggestMentionTextArea.js b/src/components/AutosuggestTextBox/AutosuggestMentionTextArea.js new file mode 100644 index 000000000..9999ac6f1 --- /dev/null +++ b/src/components/AutosuggestTextBox/AutosuggestMentionTextArea.js @@ -0,0 +1,252 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import { FormattedMessage } from 'react-intl' +import classNames from 'classnames' +import Downshift from 'downshift' +import _map from 'lodash/map' +import _get from 'lodash/get' +import _isEmpty from 'lodash/isEmpty' +import _differenceBy from 'lodash/differenceBy' +import _noop from 'lodash/noop' +import _omit from 'lodash/omit' +import Dropdown from '../Dropdown/Dropdown' +import BusySpinner from '../BusySpinner/BusySpinner' +import messages from './Messages' + +/** + * AutosuggestMentionTextArea combines a text area with a dropdown, executing a search + * when the @ character is typed. + * + * @author [Kelli Rotstan](https://github.com/krotstan) + */ +export default class AutosuggestMentionTextArea extends Component { + state = { + textBoxActive: false, + showSuggestions: false + } + + inputChanged = (inputText, downshift) => { + const searchOn = this.findMatch(inputText, + _get(this.props.inputRef, 'current.selectionStart')) + if (searchOn !== undefined && searchOn !== null) { + this.props.search(searchOn) + this.setState({showSuggestions: true}) + } + else if (this.state.showSuggestions) { + this.setState({showSuggestions: false}) + } + + // inputChanged() gets called with the item if an item is + // selected and also when our textarea goes out of focus it is + // called with "". We need to make sure we don't register those + // changes thereby messing up our textarea value. + if (this.props.onInputValueChange && !downshift.selectedItem && + this.state.textBoxActive) { + this.props.onInputValueChange(inputText) + } + } + + onChange = (item, downshift) => { + // Downshift does not automatically clear the selected menu item when it's + // chosen, so we need to do so ourselves. Otherwise if the user clicks off + // the component later, Downshift could choose to re-add the selected item. + // Note that this clearing will result in another call to onChange with a + // null item + if (item && downshift.selectedItem) { + downshift.clearSelection() + const cursor = _get(this.props.inputRef, 'current.selectionStart') + const newValue = this.replaceMatch(this.props.inputValue, item.displayName, cursor) + this.props.onInputValueChange(newValue) + } + } + + handleKeyDown = e => { + if (e.key === "Enter") { + // Don't let enter key potentially submit a form + e.preventDefault() + } + } + + regex = searchString => { + return searchString.match(/^()@([\w]*)$/) || // match "@..." + searchString.match(/([^[])@([\w]*)$/) || // match "hi @..." + searchString.match(/(\[)@([^\]]*)$/) // match "[@...]" + } + + findMatch = (input, cursorPosition) => { + const searchString = input.substring(0, cursorPosition) + const searchMatch = this.regex(searchString) + + if (searchMatch) { + return searchMatch[2] + } + + return null + } + + replaceMatch = (input, selectedValue, cursorPosition) => { + const searchString = input.substring(0, cursorPosition) + const searchMatch = this.regex(searchString) + + if (searchMatch) { + // If we matched a character before our @ that was not a [ we need + // to bump the index to leave it when we replace. + const index = searchMatch[1] && searchMatch[1] !== "[" ? + searchMatch.index + 1 : searchMatch.index + + return [input.slice(0, index), + "[@", selectedValue, "]", + input.slice(cursorPosition)].join('') + } + } + + /** + * Generates list of dropdown items from search results, or message indicating + * there are no results. + * + * @private + */ + dropdownItems(getItemProps, inputValue) { + const generateResult = (result, className = "") => ( + + {this.props.resultLabel(result)} + + ) + + let items = [] + const searchResults = this.getSearchResults() + const preferredResults = this.props.preferredResults + if (!_isEmpty(preferredResults)) { + let className = "mr-font-medium" + items = items.concat(_map(preferredResults, + (result, index) => { + // Add a border bottom to the last entry if there are more + // search results. + if (index === preferredResults.length - 1 && searchResults.length > 0) { + className += " mr-border-b-2 mr-border-white-50 mr-mb-2 mr-pb-2" + } + return generateResult(result, className) + })) + } + + items = items.concat(_map(searchResults, generateResult)) + return items + } + + getSearchResults = () => { + // Filter out any of our original preferredResults so they don't show + // in the list twice. + return _differenceBy(this.props.searchResults, + this.props.preferredResults, + 'displayName') + } + + render() { + return ( + result ? this.props.resultLabel(result) : ''} + > + {({getInputProps, getItemProps, getMenuProps, getRootProps, isOpen, inputValue}) => { + const searchOn = this.findMatch(inputValue, + _get(this.props.inputRef, 'current.selectionStart')) + const resultItems = (searchOn !== undefined && searchOn !== null) ? + this.dropdownItems(getItemProps, searchOn) : null + + const show = this.state.showSuggestions && resultItems + + return ( + _noop} + dropdownButton={dropdown => ( +