Repository: maproulette/maproulette3
Pull request #2097 (2023-09-08 15:48:59)

add filter dropdown for categorization keywords
Similar to the Keywords filter dropdown in the find challenges page, a new dropdown is created to find challenges with matching categorization keywords. A key difference is that the user can manually change what values are stored in the dropdown.

codecov-commenter: ## [Codecov](https://app.codecov.io/gh/maproulette/maproulette3/pull/2097?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=maproulette) Report
> Merging [#2097](https://app.codecov.io/gh/maproulette/maproulette3/pull/2097?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=maproulette) (ab7de65) into [develop](https://app.codecov.io/gh/maproulette/maproulette3/commit/67a08efa6f24081d7577a168c3a363e9113a99e5?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=maproulette) (67a08ef) will **decrease** coverage by `0.14%`.
> Report is 16 commits behind head on develop.
> The diff coverage is `20.89%`.

> :exclamation: Current head ab7de65 differs from pull request most recent head 8f1e77a. Consider uploading reports for the commit 8f1e77a to get more accurate results

:exclamation: Your organization needs to install the [Codecov GitHub app](https://github.com/apps/codecov/installations/select_target) to enable full functionality.

```diff
@@             Coverage Diff             @@
##           develop    #2097      +/-   ##
===========================================
- Coverage    24.96%   24.82%   -0.14%     
===========================================
  Files          638      643       +5     
  Lines        21587    22824    +1237     
  Branches      6567     7167     +600     
===========================================
+ Hits          5389     5666     +277     
- Misses       13574    14291     +717     
- Partials      2624     2867     +243     
```


| [Files Changed](https://app.codecov.io/gh/maproulette/maproulette3/pull/2097?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=maproulette) | Coverage Δ | |
|---|---|---|
| [...ane/ChallengeFilterSubnav/ChallengeFilterSubnav.js](https://app.codecov.io/gh/maproulette/maproulette3/pull/2097?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=maproulette#diff-c3JjL2NvbXBvbmVudHMvQ2hhbGxlbmdlUGFuZS9DaGFsbGVuZ2VGaWx0ZXJTdWJuYXYvQ2hhbGxlbmdlRmlsdGVyU3VibmF2Lmpz) | `66.66% <ø> (ø)` | |
| [...s/WithFilteredChallenges/WithFilteredChallenges.js](https://app.codecov.io/gh/maproulette/maproulette3/pull/2097?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=maproulette#diff-c3JjL2NvbXBvbmVudHMvSE9Dcy9XaXRoRmlsdGVyZWRDaGFsbGVuZ2VzL1dpdGhGaWx0ZXJlZENoYWxsZW5nZXMuanM=) | `30.76% <ø> (ø)` | |
| [src/components/HOCs/WithSearch/WithSearch.js](https://app.codecov.io/gh/maproulette/maproulette3/pull/2097?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=maproulette#diff-c3JjL2NvbXBvbmVudHMvSE9Dcy9XaXRoU2VhcmNoL1dpdGhTZWFyY2guanM=) | `57.14% <0.00%> (-1.11%)` | :arrow_down: |
| [...rc/components/MobileFilterMenu/MobileFilterMenu.js](https://app.codecov.io/gh/maproulette/maproulette3/pull/2097?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=maproulette#diff-c3JjL2NvbXBvbmVudHMvTW9iaWxlRmlsdGVyTWVudS9Nb2JpbGVGaWx0ZXJNZW51Lmpz) | `0.00% <ø> (ø)` | |
| [...engeFilterSubnav/FilterByCategorizationKeywords.js](https://app.codecov.io/gh/maproulette/maproulette3/pull/2097?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=maproulette#diff-c3JjL2NvbXBvbmVudHMvQ2hhbGxlbmdlUGFuZS9DaGFsbGVuZ2VGaWx0ZXJTdWJuYXYvRmlsdGVyQnlDYXRlZ29yaXphdGlvbktleXdvcmRzLmpz) | `21.05% <21.05%> (ø)` | |
| [...components/HOCs/WithSearchRoute/WithSearchRoute.js](https://app.codecov.io/gh/maproulette/maproulette3/pull/2097?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=maproulette#diff-c3JjL2NvbXBvbmVudHMvSE9Dcy9XaXRoU2VhcmNoUm91dGUvV2l0aFNlYXJjaFJvdXRlLmpz) | `49.45% <25.00%> (-1.13%)` | :arrow_down: |
| [...izationKeywords/ChallengeCategorizationKeywords.js](https://app.codecov.io/gh/maproulette/maproulette3/pull/2097?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=maproulette#diff-c3JjL3NlcnZpY2VzL0NoYWxsZW5nZS9DaGFsbGVuZ2VDYXRlZ29yaXphdGlvbktleXdvcmRzL0NoYWxsZW5nZUNhdGVnb3JpemF0aW9uS2V5d29yZHMuanM=) | `25.00% <25.00%> (ø)` | |

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

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


Patch:
diff --git a/src/components/ChallengePane/ChallengeFilterSubnav/ChallengeFilterSubnav.js b/src/components/ChallengePane/ChallengeFilterSubnav/ChallengeFilterSubnav.js
index 9a00a31f..5be04ed5 100644
--- a/src/components/ChallengePane/ChallengeFilterSubnav/ChallengeFilterSubnav.js
+++ b/src/components/ChallengePane/ChallengeFilterSubnav/ChallengeFilterSubnav.js
@@ -5,6 +5,7 @@ import WithChallengeSearch from '../../HOCs/WithSearch/WithChallengeSearch'
 import WithCommandInterpreter from '../../HOCs/WithCommandInterpreter/WithCommandInterpreter'
 import FilterByDifficulty from './FilterByDifficulty'
 import FilterByKeyword from './FilterByKeyword'
+import FilterByCategorizationKeywords from './FilterByCategorizationKeywords'
 import ClearFiltersControl from './ClearFiltersControl'
 import SortChallengesSelector from './SortChallengesSelector'
 import './ChallengeFilterSubnav.scss'
@@ -20,6 +21,7 @@ const CommandSearchBox = WithCommandInterpreter(SearchBox)
  *
  * @see See FilterByDifficulty
  * @see See FilterByKeyword
+ * @see See FilterByCategorizationKeywords
  * @see See FilterByLocation
  * @see See SearchBox
  *
@@ -46,6 +48,8 @@ export class ChallengeFilterSubnav extends Component {
             <SortChallengesSelector {...this.props} />
             <FilterByKeyword {...this.props} />
             <FilterByDifficulty {...this.props} />
+            <FilterByKeyword {...this.props} />
+            <FilterByCategorizationKeywords {...this.props} />
             <CommandSearchBox
               {...this.props}
               className="mr-h-12"
diff --git a/src/components/ChallengePane/ChallengeFilterSubnav/FilterByCategorizationKeywords.js b/src/components/ChallengePane/ChallengeFilterSubnav/FilterByCategorizationKeywords.js
new file mode 100644
index 00000000..027c3b76
--- /dev/null
+++ b/src/components/ChallengePane/ChallengeFilterSubnav/FilterByCategorizationKeywords.js
@@ -0,0 +1,169 @@
+import React, { Component } from 'react'
+import PropTypes from 'prop-types'
+import { injectIntl, FormattedMessage } from 'react-intl'
+import Dropdown from '../../Dropdown/Dropdown'
+import ButtonFilter from './ButtonFilter'
+import messages from './Messages'
+
+/**
+ * FilterByCategorizationKeywords displays a nav dropdown containing customizable options 
+ * for filtering challenges by category keyword. The redux
+ * store is updated to reflect the chosen keywords.
+ *
+ * @author [Collin Beczak](https://github.com/collinbeczak)
+ */
+class FilterByCategorizationKeywords extends Component {
+  updateFilter = (value, categorizationFilters) => {
+    const updatedFilters = new Set(categorizationFilters)
+
+    if (value === null) {
+      this.props.removeSearchFilters(['categorizationKeywords'])
+      return
+    } else {
+      if (updatedFilters.has(value)) {
+        updatedFilters.delete(value)
+      } else {
+        updatedFilters.add(value)
+      }
+    }
+
+    if(Array.from(updatedFilters).length === 0){
+      this.props.removeSearchFilters(['categorizationKeywords'])
+      return
+    }
+
+    // Update redux store or perform other necessary actions
+    this.props.setCategorizationFilters(Array.from(updatedFilters))
+  }
+
+  addKeyword = (value, categories) => {
+    value = value.trim()
+    const addKeyword = new Set(categories)
+
+    if (value === "" || categories.length === 6) {
+      return
+    }
+
+    addKeyword.add(value)
+
+    // Update redux store or perform other necessary actions
+    this.props.updateUserAppSetting(this.props.user.id, {
+      'categorizationKeys': Array.from(addKeyword),
+    })
+  }
+
+  removeKeyword = (value, categories, categorizationFilters) => {
+    const removeKeyword = new Set(categories)
+    
+    removeKeyword.delete(value)
+
+    if (categorizationFilters.includes(value)) {
+      this.updateFilter(value, categorizationFilters)
+    }
+
+    // Update redux store or perform other necessary actions
+    this.props.updateUserAppSetting(this.props.user.id, {
+      'categorizationKeys': Array.from(removeKeyword),
+    })
+  }
+
+  render() {
+    const categorizationFilters = this.props.searchFilters?.categorizationKeywords ?? [];
+    const categories = this.props.user?.properties.mr3Frontend.settings.categorizationKeys ?? [];
+
+    return (
+      <Dropdown
+        className="mr-dropdown--flush xl:mr-border-l xl:mr-border-white-10 mr-p-6 mr-pl-0 xl:mr-pl-6"
+        dropdownButton={(dropdown) => (
+          <ButtonFilter
+            type={<FormattedMessage {...messages.categorizeLabel} />}
+            selection={categorizationFilters.length > 0 ? `${categorizationFilters.length} Filters` : "Anything"}
+            onClick={dropdown.toggleDropdownVisible}
+            selectionClassName={categorizationFilters.length > 0 ? 'mr-text-yellow' : null}
+          />
+        )}
+        dropdownContent={(dropdown) => (
+          <ListFilterItems
+            categorizationFilters={categorizationFilters}
+            categories={categories}
+            updateFilter={this.updateFilter}
+            addKeyword={this.addKeyword}
+            removeKeyword={this.removeKeyword}
+            closeDropdown={dropdown.closeDropdown}
+          />
+        )}
+      />
+    )
+  }
+}
+
+const ListFilterItems = function (props) {
+  const { categorizationFilters, categories } = props
+
+  const menuItems = categories.map((keyword) => (
+    <li className="mr-flex" key={keyword}>
+      <a
+        className={`mr-flex-1 ${
+          categorizationFilters.includes(keyword) ? 'mr-text-yellow' : ''
+        }`}
+        onClick={() => props.updateFilter(keyword, categorizationFilters)}
+      >
+        <div className={
+          categorizationFilters.includes(keyword) ? 'mr-text-yellow' : ''}>{keyword}</div>
+      </a>
+      <button onClick={() => props.removeKeyword(keyword, categories, categorizationFilters)}>
+        <svg viewBox="0 0 40 40" className="mr-fill-current mr-w-5 mr-h-5 mr-my-auto mr-mx-2">
+          <use href="#close-outline-icon"></use>
+        </svg>
+      </button>
+    </li>
+  ))
+
+  // Add 'Anything' option to start of dropdown
+  menuItems.unshift(
+    <li key="anything">
+      <a onClick={() => props.updateFilter(null)}><FormattedMessage {...messages.anything}/></a>
+    </li>
+  )
+
+  // Add box for manually entering other keywords not included in the menu.
+  menuItems.push(
+    <li key="add">
+      {props.categories.length === 0 ? (
+        <div className="mr-text-grey-light mr-pt-2"> <FormattedMessage {...messages.set}/></div>
+      ) : null}
+      <div className="mr-flex mr-items-center mr-py-3">
+        <label className="mr-text-green-lighter mr-mr-4 mr-cursor-pointer"><FormattedMessage {...messages.add}/></label>
+        <form onSubmit={(e) => {
+          e.preventDefault() // Prevent the default form submission behavior
+          const value = e.target.elements.inputName.value // Replace 'inputName' with the actual name attribute of the input
+          props.addKeyword(value, categories)
+
+          // Clear the input field
+          e.target.elements.inputName.value = ''
+        }}>
+          <input
+            className="mr-flex mr-items-center mr-border-none mr-text-white mr-rounded mr-bg-black-15 mr-shadow-inner mr-px-2"
+            name="inputName"
+          />
+        </form>
+      </div>
+      <div className="mr-text-grey-light">
+        {props.categories.length === 6
+          ? <div><div><FormattedMessage {...messages.delete}/></div><FormattedMessage {...messages.new}/></div>
+          : "Add a new category"}
+      </div>
+    </li>
+  )
+
+  return <ol className="mr-list-dropdown mr-list-dropdown--ruled">{menuItems}</ol>
+}
+
+FilterByCategorizationKeywords.propTypes = {
+  /** Invoked to update the challenge keyword filter */
+  setCategorizationFilters: PropTypes.func.isRequired,
+  /** Invoked to clear the challenge keyword filter */
+  removeSearchFilters: PropTypes.func.isRequired,
+}
+
+export default injectIntl(FilterByCategorizationKeywords)
diff --git a/src/components/ChallengePane/ChallengeFilterSubnav/Messages.js b/src/components/ChallengePane/ChallengeFilterSubnav/Messages.js
index 6e2a0a39..13ea6a8d 100644
--- a/src/components/ChallengePane/ChallengeFilterSubnav/Messages.js
+++ b/src/components/ChallengePane/ChallengeFilterSubnav/Messages.js
@@ -19,6 +19,11 @@ export default defineMessages({
     defaultMessage: 'Work on',
   },
 
+  categorizeLabel: {
+    id: 'ChallengeFilterSubnav.filter.categorize.label',
+    defaultMessage: 'Categorize',
+  },
+
   locationLabel: {
     id: 'ChallengeFilterSubnav.filter.location.label',
     defaultMessage: 'Location',
@@ -43,4 +48,29 @@ export default defineMessages({
     id: "ChallengeFilterSubnav.filter.keywords.otherLabel",
     defaultMessage: "Other:",
   },
+
+  anything: {
+    id: "ChallengeFilterSubnav.filter.categorization.anything",
+    defaultMessage: "Anything",
+  },
+
+  set: {
+    id: "ChallengeFilterSubnav.filter.categorization.set",
+    defaultMessage: "You have not set any categories.",
+  },
+
+  add: {
+    id: "ChallengeFilterSubnav.filter.categorization.add",
+    defaultMessage: "Add:",
+  },
+
+  delete: {
+    id: "ChallengeFilterSubnav.filter.categorization.delete",
+    defaultMessage: "You must delete a category.",
+  },
+
+  new: {
+    id: "ChallengeFilterSubnav.filter.categorization.new",
+    defaultMessage: "to add a new one.",
+  },
 })
diff --git a/src/components/HOCs/WithFilteredChallenges/WithFilteredChallenges.js b/src/components/HOCs/WithFilteredChallenges/WithFilteredChallenges.js
index 16a2fad9..f587a4b5 100644
--- a/src/components/HOCs/WithFilteredChallenges/WithFilteredChallenges.js
+++ b/src/components/HOCs/WithFilteredChallenges/WithFilteredChallenges.js
@@ -7,6 +7,8 @@ import { challengePassesDifficultyFilter }
        from '../../../services/Challenge/ChallengeDifficulty/ChallengeDifficulty'
 import { challengePassesKeywordFilter }
        from '../../../services/Challenge/ChallengeKeywords/ChallengeKeywords'
+import { challengePassesCategorizationKeywordsFilter } 
+       from '../../../services/Challenge/ChallengeCategorizationKeywords/ChallengeCategorizationKeywords';
 import { challengePassesLocationFilter }
        from '../../../services/Challenge/ChallengeLocation/ChallengeLocation'
 import { challengePassesProjectFilter }
@@ -15,6 +17,7 @@ import { challengePassesProjectFilter }
 const allFilters = [
   challengePassesDifficultyFilter,
   challengePassesKeywordFilter,
+  challengePassesCategorizationKeywordsFilter,
   challengePassesLocationFilter,
   challengePassesProjectFilter,
 ]
diff --git a/src/components/HOCs/WithSearch/WithSearch.js b/src/components/HOCs/WithSearch/WithSearch.js
index 415b23c3..90c6ac0f 100644
--- a/src/components/HOCs/WithSearch/WithSearch.js
+++ b/src/components/HOCs/WithSearch/WithSearch.js
@@ -216,6 +216,10 @@ export const mapDispatchToProps = (dispatch, ownProps, searchGroup) => ({
     dispatch(setFilters(searchGroup, {keywords}))
   },
 
+  setCategorizationFilters: categorizationKeywords => {
+    dispatch(setFilters(searchGroup, {categorizationKeywords}))
+  },
+
   clearSearchFilters: () => {
     dispatch(clearFilters(searchGroup))
   },
diff --git a/src/components/HOCs/WithSearchRoute/WithSearchRoute.js b/src/components/HOCs/WithSearchRoute/WithSearchRoute.js
index 5e68b31a..73a61754 100644
--- a/src/components/HOCs/WithSearchRoute/WithSearchRoute.js
+++ b/src/components/HOCs/WithSearchRoute/WithSearchRoute.js
@@ -30,6 +30,7 @@ export const WithSearchRoute = function(WrappedComponent, searchGroup) {
     routeCriteria = {
        sort: param => this.props.setSearchSort({sortBy: param}),
        difficulty: param => this.props.setSearchFilters({difficulty: parseInt(param, 10)}),
+       categorizationKeywords: param => this.props.setCategorizationFilters(param.split(',')),
        keywords: param => this.props.setKeywordFilter(param.split(',')),
        location: param => this.props.setSearchFilters({location: param}),
        project: param => this.props.setSearchFilters({project: param, searchType: SEARCH_TYPE_PROJECT}),
@@ -101,6 +102,11 @@ export const WithSearchRoute = function(WrappedComponent, searchGroup) {
       addSearchCriteriaToRoute(this.props.history, {keywords: keywords ? keywords.join(',') : keywords})
     }
 
+    setCategorizationFilters = (categorization) => {
+      this.props.setCategorizationFilters(categorization)
+      addSearchCriteriaToRoute(this.props.history, {categorizationKeywords: categorization ? categorization.join(',') : categorization})
+    }
+
     clearSearchFilters = (clearRoute = true) => {
       this.props.clearSearchFilters(clearRoute)
       clearRoute && this.props.history.push(this.props.history.location.pathname)
@@ -124,6 +130,7 @@ export const WithSearchRoute = function(WrappedComponent, searchGroup) {
                             setSearchFilters={this.setSearchFilters}
                             removeSearchFilters={this.removeSearchFilters}
                             setKeywordFilter={this.setKeywordFilter}
+                            setCategorizationFilters={this.setCategorizationFilters}
                             clearSearchFilters={this.clearSearchFilters}
                             updateChallengeSearchMapBounds={this.updateChallengeSearchMapBounds} />
        )
diff --git a/src/components/MobileFilterMenu/MobileFilterMenu.js b/src/components/MobileFilterMenu/MobileFilterMenu.js
index 949f292d..ac588ca8 100644
--- a/src/components/MobileFilterMenu/MobileFilterMenu.js
+++ b/src/components/MobileFilterMenu/MobileFilterMenu.js
@@ -8,6 +8,8 @@ import FilterByLocation
        from '../ChallengePane/ChallengeFilterSubnav/FilterByLocation'
 import FilterByKeyword
        from '../ChallengePane/ChallengeFilterSubnav/FilterByKeyword'
+import FilterByCategorizationKeywords 
+       from '../ChallengePane/ChallengeFilterSubnav/FilterByCategorizationKeywords'
 import WithDeactivateOnOutsideClick from
        '../HOCs/WithDeactivateOnOutsideClick/WithDeactivateOnOutsideClick'
 import './MobileFilterMenu.scss'
@@ -27,6 +29,7 @@ export class MobileFilterMenu extends Component {
         <Menu {...this.props}>
           <FilterByDifficulty asMenuList {...this.props} />
           <LocationFilter asMenuList {...this.props} />
+          <FilterByCategorizationKeywords asMenuList {...this.props} />
           <FilterByKeyword asMenuList {...this.props} />
         </Menu>
       </SimpleDropdown>
diff --git a/src/services/Challenge/ChallengeCategorizationKeywords/ChallengeCategorizationKeywords.js b/src/services/Challenge/ChallengeCategorizationKeywords/ChallengeCategorizationKeywords.js
new file mode 100644
index 00000000..25c6f773
--- /dev/null
+++ b/src/services/Challenge/ChallengeCategorizationKeywords/ChallengeCategorizationKeywords.js
@@ -0,0 +1,17 @@
+import _isArray from 'lodash/isArray'
+
+/**
+ * Determines if the given challenge passes the given categorization keywords filter.
+ */
+export const challengePassesCategorizationKeywordsFilter = function(filter, challenge) {
+  let passing = true
+    if (_isArray(filter.categorizationKeywords)) {
+      filter.categorizationKeywords.map(key => {
+        if(!challenge.tags.includes(key)){
+          passing = false
+        }
+      })
+    }
+  
+    return passing
+  }
\ No newline at end of file


Test patch:
diff --git a/src/components/HOCs/WithSearch/__snapshots__/WithSearch.test.js.snap b/src/components/HOCs/WithSearch/__snapshots__/WithSearch.test.js.snap
index 40ce863a..1e4f9757 100644
--- a/src/components/HOCs/WithSearch/__snapshots__/WithSearch.test.js.snap
+++ b/src/components/HOCs/WithSearch/__snapshots__/WithSearch.test.js.snap
@@ -10,6 +10,7 @@ Object {
   "performSearch": [Function],
   "removeSearchFilters": [Function],
   "removeSearchSort": [Function],
+  "setCategorizationFilters": [Function],
   "setChallengeOwnerMapBounds": [Function],
   "setChallengeSearchMapBounds": [Function],
   "setKeywordFilter": [Function],
@@ -31,6 +32,7 @@ Object {
   "performSearch": [Function],
   "removeSearchFilters": [Function],
   "removeSearchSort": [Function],
+  "setCategorizationFilters": [Function],
   "setChallengeOwnerMapBounds": [Function],
   "setChallengeSearchMapBounds": [Function],
   "setKeywordFilter": [Function],