Repository: osmlab/maproulette3
Pull request #1111 (2020-02-11 16:33:41)

Support task property styles on challenges.
* When editing challenge add ability to specify style
  rules based on specific task feature property rules.
  Task style rules look like:
  ```
  {styles: [{styleName: 'marker-color', styleValue: 'red'}],
 propertySearch:....}
  ```

* Apply conditional task styling property rules when working
  on the task to the the task features.

* Add challenge feature styling legend widget to explain
  why the specific styling was applied


Patch:
diff --git a/src/components/AdminPane/HOCs/WithTaskPropertyStyleRules/WithTaskPropertyStyleRules.js b/src/components/AdminPane/HOCs/WithTaskPropertyStyleRules/WithTaskPropertyStyleRules.js
new file mode 100644
index 00000000..c5e0b911
--- /dev/null
+++ b/src/components/AdminPane/HOCs/WithTaskPropertyStyleRules/WithTaskPropertyStyleRules.js
@@ -0,0 +1,164 @@
+import React, { Component } from 'react'
+import _cloneDeep from 'lodash/cloneDeep'
+import _pullAt from 'lodash/pullAt'
+import _get from 'lodash/get'
+import _isEqual from 'lodash/isEqual'
+import _each from 'lodash/each'
+import _isEmpty from 'lodash/isEmpty'
+import _fill from 'lodash/fill'
+import _filter from 'lodash/filter'
+import { PROPERTY_RULE_ERRORS } from '../../../TaskPropertyQueryBuilder/TaskPropertyRules'
+
+export const EMPTY_STYLE_RULE = {styles:[{}]}
+
+/**
+ * WithTaskPropertyStyleRules keeps track of taskPropertyStyleRules with methods
+ * to manipulate the object.
+ *
+ * @author [Kelli Rotstan](https://github.com/krotstan)
+ */
+export const WithTaskPropertyStyleRules = function(WrappedComponent) {
+  return class extends Component {
+    state = {
+      styleRules: [_cloneDeep(EMPTY_STYLE_RULE)],   //Setup with one initial style rule
+      validationErrors:[[]], // And one empty errors.
+    }
+
+    removeStyleRule = (index) => {
+      const newRules = _cloneDeep(this.state.styleRules)
+      const newErrors = _cloneDeep(this.state.validationErrors)
+      _pullAt(newRules, index)
+      _pullAt(newErrors, index)
+      if (newRules.length < 1) {
+        newRules.push(_cloneDeep(EMPTY_STYLE_RULE))
+        newErrors.push([])
+      }
+      this.setState({styleRules: newRules, validationErrors: newErrors})
+    }
+
+    setStyleName = (name, index, position) => {
+      const newRules = _cloneDeep(this.state.styleRules)
+      const newErrors = _cloneDeep(this.state.validationErrors)
+      newRules[index].styles[position].styleName = name
+      newErrors[index] =
+        _filter(newErrors[index],
+          (e) => e !== PROPERTY_RULE_ERRORS.missingStyleValue)
+
+      this.setState({styleRules: newRules, validationErrors: newErrors})
+    }
+
+    setStyleValue = (value, index, position) => {
+      const newRules = _cloneDeep(this.state.styleRules)
+      const newErrors = _cloneDeep(this.state.validationErrors)
+      newRules[index].styles[position].styleValue = value
+      newErrors[index] =
+        _filter(newErrors[index],
+          (e) => e !== PROPERTY_RULE_ERRORS.missingStyleValue)
+
+      this.setState({styleRules: newRules, validationErrors: newErrors})
+    }
+
+    updateStyleRule = (data, index, errors) => {
+      const newRules = _cloneDeep(this.state.styleRules)
+      const newErrors = _cloneDeep(this.state.validationErrors)
+
+      newRules[index].propertySearch = data
+      newErrors[index] = errors || []
+      this.setState({styleRules: newRules, validationErrors: newErrors})
+    }
+
+    addNewStyleRule = () => {
+      const newRules = _cloneDeep(this.state.styleRules)
+      const newErrors = _cloneDeep(this.state.validationErrors)
+      newRules.push(_cloneDeep(EMPTY_STYLE_RULE))
+      newErrors.push([])
+      this.setState({styleRules: newRules, validationErrors: newErrors})
+    }
+
+    addNewStyle = (index) => {
+      const newRules = _cloneDeep(this.state.styleRules)
+      newRules[index].styles.push({})
+      this.setState({styleRules: newRules})
+    }
+
+    removeStyle = (index, position) => {
+      const newRules = _cloneDeep(this.state.styleRules)
+      _pullAt(newRules[index].styles, position)
+      if (newRules[index].styles.length < 1) {
+        newRules[index].styles.push({})
+      }
+      this.setState({styleRules: newRules})
+    }
+
+    clearStyleRules = () => {
+      this.setState({
+        styleRules: [_cloneDeep(EMPTY_STYLE_RULE)],
+        validationErrors:[[]]
+      })
+    }
+
+    componentDidMount() {
+      if (_get(this.props.challenge, 'taskStyles')) {
+        let taskStyles = _cloneDeep(this.props.challenge.taskStyles)
+        if (_isEmpty(taskStyles)) {
+          taskStyles.push(_cloneDeep(EMPTY_STYLE_RULE))
+        }
+
+        this.setState({styleRules: taskStyles,
+                       validationErrors: _fill(Array(taskStyles.length), [])})
+      }
+    }
+
+    componentDidUpdate(prevProps) {
+      if (!_isEqual(_get(this.props.challenge, 'taskStyles'),
+                    _get(prevProps.challenge, 'taskStyles'))) {
+        let taskStyles = _cloneDeep(this.props.challenge.taskStyles)
+        if (_isEmpty(taskStyles)) {
+          taskStyles.push(_cloneDeep(EMPTY_STYLE_RULE))
+        }
+        this.setState({styleRules: taskStyles,
+                       validationErrors: _fill(Array(taskStyles.length), [])})
+      }
+    }
+
+    render() {
+      const errors = this.state.validationErrors
+      _each(this.state.styleRules, (rule, index) => {
+        _each(rule.styles, style => {
+          if (style.styleName && !style.styleValue) {
+            // Missing style value
+            errors[index].push(PROPERTY_RULE_ERRORS.missingStyleValue)
+          }
+          else if (style.styleValue && !style.styleName) {
+            // Missing syle name
+            errors[index].push(PROPERTY_RULE_ERRORS.missingStyleName)
+          }
+        })
+      })
+
+      let hasAnyStyleErrors = false
+      _each(errors, (e) => {
+        if (!_isEmpty(e)) {
+          hasAnyStyleErrors = true
+        }
+      })
+
+      return <WrappedComponent
+              {...this.props}
+              taskPropertyStyleRules={this.state.styleRules}
+              styleRuleErrors={errors}
+              hasAnyStyleRuleErrors = {hasAnyStyleErrors}
+              clearStyleRules={this.clearStyleRules}
+              removeStyleRule={this.removeStyleRule}
+              updateStyleRule={this.updateStyleRule}
+              addNewStyleRule={this.addNewStyleRule}
+              setStyleName={this.setStyleName}
+              setStyleValue={this.setStyleValue}
+              addNewStyle={this.addNewStyle}
+              removeStyle={this.removeStyle}
+             />
+    }
+  }
+}
+
+export default WrappedComponent => WithTaskPropertyStyleRules(WrappedComponent)
diff --git a/src/components/AdminPane/Manage/ManageChallenges/EditChallenge/EditChallenge.js b/src/components/AdminPane/Manage/ManageChallenges/EditChallenge/EditChallenge.js
index 6353bfa4..f90b047c 100644
--- a/src/components/AdminPane/Manage/ManageChallenges/EditChallenge/EditChallenge.js
+++ b/src/components/AdminPane/Manage/ManageChallenges/EditChallenge/EditChallenge.js
@@ -11,8 +11,12 @@ import _filter from 'lodash/filter'
 import _first from 'lodash/first'
 import _difference from 'lodash/difference'
 import _get from 'lodash/get'
+import _remove from 'lodash/remove'
+import _isEqual from 'lodash/isEqual'
 import { FormattedMessage, injectIntl } from 'react-intl'
 import { Link } from 'react-router-dom'
+import External from '../../../../External/External'
+import Modal from '../../../../Modal/Modal'
 import AsLineReadableFile
        from '../../../../../interactions/File/AsLineReadableFile'
 import Steps from '../../../../Bulma/Steps'
@@ -31,6 +35,10 @@ import WithCurrentChallenge
        from '../../../HOCs/WithCurrentChallenge/WithCurrentChallenge'
 import WithCurrentUser
        from '../../../../HOCs/WithCurrentUser/WithCurrentUser'
+import WithTaskPropertyStyleRules
+      from '../../../HOCs/WithTaskPropertyStyleRules/WithTaskPropertyStyleRules'
+import { EMPTY_STYLE_RULE }
+      from '../../../HOCs/WithTaskPropertyStyleRules/WithTaskPropertyStyleRules'
 import { ChallengeCategoryKeywords,
          categoryMatchingKeywords,
          rawCategoryKeywords }
@@ -46,6 +54,8 @@ import AsValidatableOverpass
 import TaskUploadingProgress
        from '../../TaskUploadingProgress/TaskUploadingProgress'
 import BusySpinner from '../../../../BusySpinner/BusySpinner'
+import TaskPropertyStyleRules
+       from '../../TaskPropertyStyleRules/TaskPropertyStyleRules'
 import { preparePriorityRuleGroupForForm,
          preparePriorityRuleGroupForSaving } from './PriorityRuleGroup'
 import { jsSchema as step1jsSchema,
@@ -241,6 +251,11 @@ export class EditChallenge extends Component {
 
   /** Complete the workflow, saving the challenge data */
   finish = () => {
+    // We cannot continue if the style rules have errors.
+    if (this.hasTaskStyleRuleErrors()) {
+      return
+    }
+
     window.scrollTo(0, 0)
     this.setState({isSaving: true})
 
@@ -369,6 +384,10 @@ export class EditChallenge extends Component {
 
     challengeData.taskTags = challengeData.taskTags || challengeData.preferredTags
 
+    if (_isUndefined(challengeData.customTaskStyles)) {
+      challengeData.customTaskStyles = !_isEmpty(challengeData.taskStyles)
+    }
+
     return challengeData
   }
 
@@ -481,9 +500,31 @@ export class EditChallenge extends Component {
       }
     }
 
+    if (challengeData.customTaskStyles) {
+      const styleRules = this.props.taskPropertyStyleRules
+      // Remove all empty style rules
+      _remove(styleRules, (rule) => (_isEmpty(rule) || _isEqual(rule, {}) ||
+        _isEqual(rule, EMPTY_STYLE_RULE) ||
+        (!rule.propertySearch.key && !rule.propertySearch.value &&
+         !rule.propertySearch.left && !rule.propertySearch.right)
+      ))
+      challengeData.taskStyles = styleRules
+    }
+    else {
+      challengeData.taskStyles = []
+    }
+
     return challengeData
   }
 
+  hasTaskStyleRuleErrors = () => {
+    const useCustom = !_isUndefined(_get(this.state.formData, 'customTaskStyles')) ?
+      _get(this.state.formData, 'customTaskStyles') :
+     !_isEmpty(_get(this.props.challenge, 'taskStyles'))
+
+    return useCustom && this.props.hasAnyStyleRuleErrors
+  }
+
   render() {
     const isUploadingTasks = _get(this.props, 'progress.creatingTasks.inProgress', false)
     if (isUploadingTasks) {
@@ -529,7 +570,11 @@ export class EditChallenge extends Component {
             />
           </React.Fragment>
         )
-      })
+      }),
+      configureCustomTaskStyles: (props) => {
+        return configureCustomTaskStyles(props,
+          () => this.setState({showTaskStyleRules: true}))
+      }
     }
 
     return (
@@ -575,9 +620,37 @@ export class EditChallenge extends Component {
             </div>
 
             <Steps steps={challengeSteps} activeStep={this.state.activeStep}
-                   onStepClick={AsEditableChallenge(challengeData).isNew() ? undefined : this.jumpToStep}
+                   onStepClick={
+                     (AsEditableChallenge(challengeData).isNew() ||
+                      this.hasTaskStyleRuleErrors()) ? undefined : this.jumpToStep}
             />
             <div className="mr-max-w-2xl mr-mx-auto mr-bg-white mr-mt-8 mr-p-4 md:mr-p-8 mr-rounded">
+              {this.state.showTaskStyleRules &&
+                <External>
+                  <Modal className=""
+                         isActive wide
+                         onClose={() => this.setState({showTaskStyleRules: false})}>
+                    <div className="mr-overflow-y-auto mr-max-h-screen80 mr-bg-white mr-m-2 mr-p-4">
+                      <TaskPropertyStyleRules {...this.props}>
+                        <button className="mr-button mr-button--green mr-mr-4"
+                          onClick={() => {
+                            this.props.clearStyleRules()
+                          }}>
+                          <FormattedMessage {...messages.taskPropertyStylesClear} />
+                        </button>
+                        {!this.props.hasAnyStyleRuleErrors &&
+                          <button className="mr-button mr-button--green"
+                            onClick={() => {
+                              this.setState({showTaskStyleRules: false})
+                            }}>
+                            <FormattedMessage {...messages.taskPropertyStylesClose} />
+                          </button>
+                        }
+                      </TaskPropertyStyleRules>
+                    </div>
+                  </Modal>
+                </External>
+              }
               <Form schema={currentStep.jsSchema(this.props.intl, this.props.user, challengeData)}
                     className="form"
                     onAsyncValidate={this.validateGeoJSONSource}
@@ -594,6 +667,13 @@ export class EditChallenge extends Component {
                     onSubmit={this.asyncSubmit}
                     onError={this.errorHandler}
               >
+                {this.hasTaskStyleRuleErrors() &&
+                 challengeSteps[this.state.activeStep].name === "Extra" &&
+                  <div className="mr-text-red mr-mb-4">
+                    <FormattedMessage {...messages.customTaskStylesError} />
+                  </div>
+                }
+
                 <StepNavigation steps={challengeSteps} activeStep={this.state.activeStep}
                                 prevStep={this.prevStep} cancel={this.cancel}
                                 finish={() => this.isFinishing = true}
@@ -607,8 +687,59 @@ export class EditChallenge extends Component {
   }
 }
 
+function configureCustomTaskStyles(props, configureTaskStyleRules) {
+  return (
+    <React.Fragment>
+      <label className="control-label">Task Property Styling</label>
+      <div>
+        <div>
+          <input
+            type="radio"
+            name="no-styles"
+            className="mr-mr-1"
+            checked={!props.formData}
+            onChange={(e) => props.onChange(false)}
+          />
+          <label className="mr-ml-1 mr-mr-4 mr-text-grey">
+            Default
+          </label>
+
+          <input
+            type="radio"
+            name="custom-styles"
+            className="mr-mr-1"
+            checked={!!props.formData}
+            onChange={(e) => {
+              props.onChange(true)
+              //this.setState({showTaskStyleRules: true})
+            }}
+          />
+          <label className="mr-ml-1 mr-text-grey">
+            Custom
+          </label>
+        </div>
+        {!!props.formData &&
+          <button className="mr-mt-4 mr-button mr-button--small mr-button--grey"
+                  onClick={(e) => {
+                    configureTaskStyleRules()
+                    e.stopPropagation()
+                    e.preventDefault()
+                  }}>
+            <FormattedMessage {...messages.customTaskStyleButton} />
+          </button>
+        }
+      </div>
+    </React.Fragment>
+  )
+}
+
+
 export default WithCurrentUser(
   WithCurrentProject(
-    WithCurrentChallenge(injectIntl(EditChallenge))
+    WithCurrentChallenge(
+      WithTaskPropertyStyleRules(
+        injectIntl(EditChallenge)
+      )
+    )
   )
 )
diff --git a/src/components/AdminPane/Manage/ManageChallenges/EditChallenge/Messages.js b/src/components/AdminPane/Manage/ManageChallenges/EditChallenge/Messages.js
index e991ec5f..3643ac6e 100644
--- a/src/components/AdminPane/Manage/ManageChallenges/EditChallenge/Messages.js
+++ b/src/components/AdminPane/Manage/ManageChallenges/EditChallenge/Messages.js
@@ -480,4 +480,44 @@ will not be able to make sense of it.
       "[Learn more on the wiki]" +
       "(https://github.com/osmlab/maproulette3/wiki/Challenge-Managers:-setting-external-task-IDs-(e.g.-OSM-IDs))."
   },
+
+  customTaskStyleLabel: {
+    id: "Admin.EditChallenge.form.customTaskStyles.label",
+    defaultMessage: "Customize Task Property Styles"
+  },
+
+  customTaskStylesDescription: {
+    id: "Admin.EditChallenge.form.customTaskStyles.description",
+    defaultMessage: "Enable custom task styling based on specific task feature properties."
+  },
+
+  customTaskStylesError: {
+    id: "Admin.EditChallenge.form.customTaskStyles.error",
+    defaultMessage: "Task property style rules are invalid. Please fix before continuing."
+  },
+
+  customTaskStyleButton: {
+    id: "Admin.EditChallenge.form.customTaskStyles.button",
+    defaultMessage: "Configure"
+  },
+
+  taskPropertyStylesLabel: {
+    id: "Admin.EditChallenge.form.taskPropertyStyles.label",
+    defaultMessage: "Task Property Style Rules"
+  },
+
+  taskPropertyStylesClose: {
+    id: "Admin.EditChallenge.form.taskPropertyStyles.close",
+    defaultMessage: "Done"
+  },
+
+  taskPropertyStylesClear: {
+    id: "Admin.EditChallenge.form.taskPropertyStyles.clear",
+    defaultMessage: "Clear"
+  },
+
+  taskPropertyStylesDescription: {
+    id: "Admin.EditChallenge.form.taskPropertyStyles.description",
+    defaultMessage: "Sets up task property style rules......"
+  }
 })
diff --git a/src/components/AdminPane/Manage/ManageChallenges/EditChallenge/Step4Schema.js b/src/components/AdminPane/Manage/ManageChallenges/EditChallenge/Step4Schema.js
index a2923e1e..8d902663 100644
--- a/src/components/AdminPane/Manage/ManageChallenges/EditChallenge/Step4Schema.js
+++ b/src/components/AdminPane/Manage/ManageChallenges/EditChallenge/Step4Schema.js
@@ -90,6 +90,11 @@ export const jsSchema = intl => {
         title: intl.formatMessage(messages.osmIdPropertyLabel),
         type: "string",
       },
+      customTaskStyles: {
+        title: intl.formatMessage(messages.customTaskStyleLabel),
+        type: "boolean",
+        default: false,
+      },
     },
     dependencies: { // Only show customBasemap if defaultBasemap set to Custom
       defaultBasemap: {
@@ -162,6 +167,10 @@ export const uiSchema = intl => ({
     "ui:emptyValue": "",
     "ui:help": <MarkdownContent lightMode markdown={intl.formatMessage(messages.osmIdPropertyDescription)} />,
   },
+  customTaskStyles: {
+    "ui:field": "configureCustomTaskStyles",
+    "ui:help": intl.formatMessage(messages.customTaskStylesDescription),
+  }
 })
 
 export const numericEnvSetting = (settingName, defaultValue) => {
diff --git a/src/components/AdminPane/Manage/TaskPropertyStyleRules/Messages.js b/src/components/AdminPane/Manage/TaskPropertyStyleRules/Messages.js
new file mode 100644
index 00000000..b358a27c
--- /dev/null
+++ b/src/components/AdminPane/Manage/TaskPropertyStyleRules/Messages.js
@@ -0,0 +1,51 @@
+import { defineMessages } from 'react-intl'
+
+/**
+ * Internationalized messages for use with TaskPropertyStyleRules
+ */
+export default defineMessages({
+  stylesHeader: {
+    id: 'Admin.TaskPropertyStyleRules.styles.header',
+    defaultMessage: "Task Property Styling",
+  },
+
+  deleteRule: {
+    id: 'Admin.TaskPropertyStyleRules.deleteRule',
+    defaultMessage: "Delete Rule",
+  },
+
+  addRule: {
+    id: 'Admin.TaskPropertyStyleRules.addRule',
+    defaultMessage: "Add Another Rule",
+  },
+
+  addNewStyle: {
+    id: 'Admin.TaskPropertyStyleRules.addNewStyle.label',
+    defaultMessage: "Add",
+  },
+
+  addNewStyleTooltip: {
+    id: 'Admin.TaskPropertyStyleRules.addNewStyle.tooltip',
+    defaultMessage: "Add Another Style",
+  },
+
+  removeStyleTooltip: {
+    id: 'Admin.TaskPropertyStyleRules.removeStyle.tooltip',
+    defaultMessage: "Remove Style",
+  },
+
+  styleName: {
+    id: 'Admin.TaskPropertyStyleRules.styleName',
+    defaultMessage: "Style Name",
+  },
+
+  styleValue: {
+    id: 'Admin.TaskPropertyStyleRules.styleValue',
+    defaultMessage: "Style Value",
+  },
+
+  styleValuePlaceholder: {
+    id: 'Admin.TaskPropertyStyleRules.styleValue.placeholder',
+    defaultMessage: "value",
+  },
+})
diff --git a/src/components/AdminPane/Manage/TaskPropertyStyleRules/TaskPropertyStyleRules.js b/src/components/AdminPane/Manage/TaskPropertyStyleRules/TaskPropertyStyleRules.js
new file mode 100644
index 00000000..c1f9c64e
--- /dev/null
+++ b/src/components/AdminPane/Manage/TaskPropertyStyleRules/TaskPropertyStyleRules.js
@@ -0,0 +1,149 @@
+import React, { Component } from 'react'
+import { FormattedMessage } from 'react-intl'
+import _map from 'lodash/map'
+import _head from 'lodash/head'
+import _isEmpty from 'lodash/isEmpty'
+import SvgSymbol from '../../../SvgSymbol/SvgSymbol'
+import TaskPropertyQueryBuilder
+       from '../../../TaskPropertyQueryBuilder/TaskPropertyQueryBuilder'
+import { supportedSimplestyles } from
+  '../../../../interactions/TaskFeature/AsSimpleStyleableFeature'
+import errorMessages from '../../../TaskPropertyQueryBuilder/Messages'
+import messages from './Messages'
+
+/**
+ * TaskPropertyStyleRules provides a form for setting up task property styles.
+ *
+ * @author [Kelli Rotstan](https://github.com/krotstan)
+ */
+export default class TaskPropertyStyleRules extends Component {
+  render() {
+    const styleNameOptions = [
+        <option key="" value="">
+        </option>
+      ].concat(_map(supportedSimplestyles, (style) =>
+        <option key={style} value={style}>
+          {style}
+        </option>
+      ))
+
+    const buildStyleNameAndValues = (rule, index) => {
+      return _map(rule.styles, (style, position) =>
+        <div className="mr-flex mr-mt-2" key={`${index}-${position}`}>
+          <div className="mr-mr-4">
+            <div className="form-select">
+              <select
+                key={`name-${index}-${position}`}
+                className="form-select form-control mr-w-40"
+                onChange={e => this.props.setStyleName(e.target.value, index, position)}
+                value={style.styleName}
+              >
+                {styleNameOptions}
+              </select>
+              <div className="mr-pointer-events-none mr-absolute mr-inset-y-0 mr-right-0 mr-flex mr-items-center mr-px-2 mr-text-grey">
+                <SvgSymbol
+                  sym="icon-cheveron-down"
+                  viewBox="0 0 20 20"
+                  className="mr-fill-current mr-w-4 mr-h-4"
+                />
+              </div>
+            </div>
+          </div>
+          <div className="mr-font-medium mr-text-grey mr-mt-2 mr-mr-4"> = </div>
+          <div>
+            <input
+              key={`value-${index}-${position}`}
+              type="text"
+              className={"mr-input"}
+              placeholder={this.props.intl.formatMessage(messages.styleValuePlaceholder)}
+              maxLength="100"
+              value={style.styleValue || ""}
+              onChange={(e) => this.props.setStyleValue(e.target.value, index, position)}
+            />
+          </div>
+          { rule.styles.length > 1 &&
+            <div className="mr-pt-2 mr-pl-4">
+              <button
+                className="mr-ml-2 mr-text-red"
+                key={`delete-${index}-${position}`}
+                onClick={() => this.props.removeStyle(index, position)}
+                title={this.props.intl.formatMessage(messages.removeStyleTooltip)}
+              >
+                <SvgSymbol
+                  sym="trash-icon"
+                  viewBox="0 0 20 20"
+                  className="mr-transition mr-fill-current mr-w-4 mr-h-4"
+                />
+              </button>
+            </div>
+          }
+        </div>
+      )
+    }
+
+    const styleForms = _map(this.props.taskPropertyStyleRules, (rule, index) => {
+      const formSearch =
+        <TaskPropertyQueryBuilder
+          {...this.props}
+          updateAsChange
+          taskPropertyKeys={null}
+          taskPropertyQuery={rule.propertySearch || {}}
+          clearTaskPropertyQuery={() => this.props.removeStyleRule(index)}
+          updateTaskPropertyQuery={(data, errors) => this.props.updateStyleRule(data, index, errors)}
+        />
+
+      return (
+        <div className="style-rule mr-mb-10" key={`style-rule-${index}`}>
+          <div className="mr-float-right">
+            <button className="mr-button mr-button--green mr-mr-1 mr-mt-6"
+                    onClick={() => this.props.removeStyleRule(index)}>
+              <FormattedMessage {...messages.deleteRule} />
+            </button>
+          </div>
+          <div className="mr-flex mr-mt-4 mr-mb-1">
+            <div className="mr-mr-20 mr-pr-2">
+              <div className="mr-font-medium mr-text-grey mr-mb-2 mr-mr-8">
+                <FormattedMessage {...messages.styleName} />
+              </div>
+            </div>
+            <div>
+              <div className="mr-font-medium mr-text-grey mr-mb-2">
+                <FormattedMessage {...messages.styleValue} />
+              </div>
+            </div>
+          </div>
+
+          {buildStyleNameAndValues(rule, index)}
+          <button onClick={() => this.props.addNewStyle(index)}
+                  className="mr-button mr-button--small mr-button--green mr-mt-4 mr-mb-2"
+                  title={this.props.intl.formatMessage(messages.addNewStyleTooltip)}>
+            <FormattedMessage {...messages.addNewStyle} />
+          </button>
+          {formSearch}
+          {!_isEmpty(this.props.styleRuleErrors[index]) &&
+            <div className="mr-ml-4 mr-mb-4 mr-text-red">
+              {this.props.intl.formatMessage(errorMessages[_head(this.props.styleRuleErrors[index])])}
+            </div>
+          }
+        </div>
+      )
+    })
+
+    return (
+      <div className="">
+        <div className="mr-font-medium mr-text-matisse-blue mr-uppercase mr-mb-8 mr-text-xl">
+          <FormattedMessage {...messages.stylesHeader} />
+        </div>
+        <div className="mr-my-4">
+          {styleForms}
+          <div className="mr-my-4 mr-pb-8">
+            <button className="mr-button mr-button--green" onClick={this.props.addNewStyleRule}>
+              <FormattedMessage {...messages.addRule} />
+            </button>
+          </div>
+        </div>
+        {this.props.children}
+      </div>
+    )
+  }
+}
diff --git a/src/components/EnhancedMap/EnhancedMap.js b/src/components/EnhancedMap/EnhancedMap.js
index e1a05b45..fc92692c 100644
--- a/src/components/EnhancedMap/EnhancedMap.js
+++ b/src/components/EnhancedMap/EnhancedMap.js
@@ -136,7 +136,12 @@ export default class EnhancedMap extends Map {
           }
 
           // Support [simplestyle](https://github.com/mapbox/simplestyle-spec)
-          AsSimpleStyleableFeature(feature).styleLeafletLayer(layer)
+          if (this.props.conditionalStyles) {
+            AsSimpleStyleableFeature(feature).styleLeafletLayerConditionally(layer, this.props.conditionalStyles)
+          }
+          else {
+            AsSimpleStyleableFeature(feature).styleLeafletLayer(layer)
+          }
         }
       })
 
diff --git a/src/components/FeatureStyleLegend/FeatureStyleLegend.js b/src/components/FeatureStyleLegend/FeatureStyleLegend.js
new file mode 100644
index 00000000..bdfb4fac
--- /dev/null
+++ b/src/components/FeatureStyleLegend/FeatureStyleLegend.js
@@ -0,0 +1,89 @@
+import React, { Component } from 'react'
+import { FormattedMessage, injectIntl } from 'react-intl'
+import classNames from 'classnames'
+import _get from 'lodash/get'
+import _isEmpty from 'lodash/isEmpty'
+import _map from 'lodash/map'
+import _join from 'lodash/join'
+import MarkdownContent from '../MarkdownContent/MarkdownContent'
+import messages from './Messages'
+
+/**
+ * FeatureStyleLegend displays a legend of custom feature styles setup on the
+ * current challenge
+ *
+ * @author [Neil Rotstan](https://github.com/nrotstan)
+ */
+export class FeatureStyleLegend extends Component {
+  compactedComparator = search => {
+    switch(search.searchType) {
+      case "equals":
+        return "="
+      case "contains":
+        return this.props.intl.formatMessage(messages.containsLabel)
+      case "not_equal":
+        return "≠"
+      case "greater_than":
+        return ">"
+      case "less_than":
+        return "<"
+      default:
+        return search.searchType
+    }
+  }
+
+  compactedFilter = search => {
+    if (!search.operationType) {
+      return `\`${search.key}\` ${this.compactedComparator(search)} \`${search.value}\``
+    }
+    else {
+      return `(${this.compactedFilter(search.left)} ${search.operationType.toUpperCase()} ${this.compactedFilter(search.right)})`
+    }
+  }
+
+  compactedStyle = style => {
+    return {
+      styling: _map(style.styles, (style) => `${style.styleName}: ${style.styleValue}`),
+      filter: this.compactedFilter(style.propertySearch),
+    }
+  }
+
+  render() {
+    const challenge = this.props.challenge || _get(this.props, 'task.parent')
+    if (!challenge) {
+      return null
+    }
+
+    if (_isEmpty(challenge.taskStyles)) {
+      return (
+        <div>
+          <FormattedMessage {...messages.noStyles} />
+        </div>
+      )
+    }
+
+    const styleDescriptions = _map(challenge.taskStyles, (style, index) => {
+      const compacted = this.compactedStyle(style)
+      // Trim any outer parenthesees from filter string
+      compacted.filter = compacted.filter.replace(/(^\()|(\)$)/g,'')
+
+      const markdownDescription = `### ${_join(compacted.styling, '\n### ')}\n\n${compacted.filter}`
+      return (
+        <div
+          key={index}
+          className={classNames({"mr-mt-4 mr-pt-4 mr-border-t mr-border-grey-light": index > 0})}
+        >
+          <MarkdownContent key={index} markdown={markdownDescription} />
+        </div>
+      )
+    })
+
+    return (
+      <div>
+        {styleDescriptions}
+      </div>
+    )
+  }
+}
+
+export default injectIntl(FeatureStyleLegend)
diff --git a/src/components/FeatureStyleLegend/Messages.js b/src/components/FeatureStyleLegend/Messages.js
new file mode 100644
index 00000000..4bce2841
--- /dev/null
+++ b/src/components/FeatureStyleLegend/Messages.js
@@ -0,0 +1,16 @@
+import { defineMessages } from 'react-intl'
+
+/**
+ * Internationalized messages for use with FeatureStyleLegend
+ */
+export default defineMessages({
+  noStyles: {
+    id: "FeatureStyleLegend.noStyles.label",
+    defaultMessage: "This challenge is not using custom styles",
+  },
+
+  containsLabel: {
+    id: "FeatureStyleLegend.comparators.contains.label",
+    defaultMessage: "contains",
+  },
+})
diff --git a/src/components/TaskFilters/Messages.js b/src/components/TaskFilters/Messages.js
index 421cb3cf..a730203d 100644
--- a/src/components/TaskFilters/Messages.js
+++ b/src/components/TaskFilters/Messages.js
@@ -4,21 +4,6 @@ import { defineMessages } from 'react-intl'
  * Internationalized messages for use with TaskFilters
  */
 export default defineMessages({
-  searchButton: {
-    id: "TaskPropertyFilter.controls.search",
-    defaultMessage: "Search",
-  },
-
-  clearButton: {
-    id: "TaskPropertyFilter.controls.clear",
-    defaultMessage: "Clear",
-  },
-
-  noneOption: {
-    id: "TaskPropertyFilter.options.none.label",
-    defaultMessage: "None",
-  },
-
   filterByPropertyLabel: {
     id: "TaskPropertyFilter.label",
     defaultMessage: "Filter By Property",
@@ -38,49 +23,4 @@ export default defineMessages({
     id: "TaskReviewStatusFilter.label",
     defaultMessage: "Filter by Review Status",
   },
-
-  missingRightRule: {
-    id: "TaskPropertyFilter.error.missingRightRule",
-    defaultMessage: "When using a compound rule both parts must be specified.",
-  },
-
-  missingLeftRule: {
-    id: "TaskPropertyFilter.error.missingLeftRule",
-    defaultMessage: "When using a compound rule both parts must be specified.",
-  },
-
-  missingKey: {
-    id: "TaskPropertyFilter.error.missingKey",
-    defaultMessage: "Please select a property name.",
-  },
-
-  missingValue: {
-    id: "TaskPropertyFilter.error.missingValue",
-    defaultMessage: "You must enter a value.",
-  },
-
-  missingPropertyType: {
-    id: "TaskPropertyFilter.error.missingPropertyType",
-    defaultMessage: "Please choose a property type.",
-  },
-
-  notNumericValue: {
-    id: "TaskPropertyFilter.error.notNumericValue",
-    defaultMessage: "Property value given is not a valid number.",
-  },
-
-  stringType: {
-    id: "TaskPropertyFilter.propertyType.stringType",
-    defaultMessage: "text",
-  },
-
-  numberType: {
-    id: "TaskPropertyFilter.propertyType.numberType",
-    defaultMessage: "number",
-  },
-
-  compoundRuleType: {
-    id: "TaskPropertyFilter.propertyType.compoundRuleType",
-    defaultMessage: "compound rule",
-  },
 })
diff --git a/src/components/TaskFilters/TaskPropertyFilter.js b/src/components/TaskFilters/TaskPropertyFilter.js
index 2a0ababf..4a5d9a87 100644
--- a/src/components/TaskFilters/TaskPropertyFilter.js
+++ b/src/components/TaskFilters/TaskPropertyFilter.js
@@ -1,20 +1,12 @@
 import React, { Component } from 'react'
 import { FormattedMessage } from 'react-intl'
-import Form from 'react-jsonschema-form-async';
 import Modal from '../Modal/Modal'
 import External from '../External/External'
 import SvgSymbol from '../SvgSymbol/SvgSymbol'
-import { CustomSelectWidget }
-       from '../Bulma/RJSFFormFieldAdapter/RJSFFormFieldAdapter'
 import _get from 'lodash/get'
-import _isEmpty from 'lodash/isEmpty'
-import _head from 'lodash/head'
-import _cloneDeep from 'lodash/cloneDeep'
-import { jsSchema, uiSchema } from './TaskPropertiesSchema'
-import { preparePropertyRulesForSaving,
-         validatePropertyRules } from './TaskPropertyRules'
+import TaskPropertyQueryBuilder
+       from '../TaskPropertyQueryBuilder/TaskPropertyQueryBuilder'
 import messages from './Messages'
-import './TaskPropertiesSchema.scss'
 
 /**
  * TaskPropertyFilter builds a dropdown for searching by task properties
@@ -26,117 +18,17 @@ export default class TaskPropertyFilter extends Component {
     showForm: false
   }
 
-  clearForm = (e) => {
-    this.props.clearTaskPropertyCriteria()
-    this.setState({formData:{}, errors: null})
-    e.preventDefault()
-    e.stopPropagation()
-  }
-
-  /** Receive updates to the form data, along with any validation errors */
-  changeHandler = ({formData}) => {
-    const rootRule = _cloneDeep(_get(formData, 'propertyRules.rootRule'))
-
-    const moveLeft = (data, prevData) => {
-      // We've changed value type to compound rule so
-      // let's move over any assigned key/values.
-      if (data.valueType === "compound rule" &&
-          (prevData && _get(prevData, 'valueType') !== "compound rule")) {
-
-        data.left = {
-          valueType: prevData.valueType,
-          key: prevData.key,
-          value: prevData.value,
-          operator: prevData.operator,
-        }
-        data.key = undefined
-        data.value = undefined
-        data.operator = undefined
-      }
-      else if (data.valueType !== "compound rule" &&
-               _get(prevData, 'valueType') === "compound rule")
-      {
-        data.key = _get(prevData, 'left.key')
-        data.value = _get(prevData, 'left.value')
-        data.operator = _get(prevData, 'left.operator')
-        data.left = undefined
-        data.right = undefined
-      }
-      else {
-        if (data.left) {
-          moveLeft(data.left, _get(prevData, 'left'))
-        }
-        if (data.right) {
-          moveLeft(data.right, _get(prevData, 'right'))
-        }
-      }
-    }
-
-    moveLeft(rootRule, _get(this.state.formData, 'propertyRules.rootRule'))
-    this.setState({formData: {propertyRules: {rootRule}}, errors: null})
-  }
-
-  /** Receive errors from form validation */
-  errorHandler = (errors, err, formData) => {
-  }
-
-  finish = (e) => {
-    e.preventDefault()
-    e.stopPropagation()
-
-    const rootRule = _get(this.state.formData, 'propertyRules.rootRule')
-    const errors = validatePropertyRules(rootRule)
-
-    if (errors.length === 0) {
-      const preparedData = preparePropertyRulesForSaving(rootRule)
-
-      this.setState({preparedData, errors: null, showForm: false})
-      this.props.updateTaskPropertyCriteria(preparedData)
-    }
-    else {
-      this.setState({errors})
-    }
-  }
-
-  componentDidUpdate(prevProps) {
-    // Clear form data if filters.taskPropertySearch has been cleared.
-    if (_isEmpty(_get(this.props, 'criteria.filters.taskPropertySearch')) &&
-        !_isEmpty(this.state.preparedData)) {
-      this.setState({formData: null, preparedData: null})
-    }
-  }
-
-  render() {
-    const data = this.state.formData
+  render() {    
     const formSearch =
-      <div className="task-properties-form mr-w-full mr-pt-4">
-        <Form schema={jsSchema(this.props.intl, this.props.taskPropertyKeys)}
-              className="mr-bg-white"
-              onAsyncValidate={this.validateGeoJSONSource}
-              uiSchema={uiSchema(this.props.intl, this.props.user, data)}
-              tagType={"taskProperties"}
-              widgets={{SelectWidget: CustomSelectWidget}}
-              noHtml5Validate
-              showErrorList={false}
-              formData={data}
-              onChange={this.changeHandler}
-              onError={this.errorHandler}
-        >
-          {this.state.errors &&
-            <div className="mr-ml-4 mr-mb-4 mr-text-red">
-              {this.props.intl.formatMessage(messages[_head(this.state.errors)])}
-            </div>
-          }
-          <button className="mr-button mr-button--green mr-ml-4 mr-mb-2"
-                  onClick={this.clearForm}>
-            <FormattedMessage {...messages.clearButton} />
-          </button>
-          <button className="mr-button mr-button--green mr-ml-4 mr-mb-2"
-                  onClick={this.finish}>
-            <FormattedMessage {...messages.searchButton} />
-          </button>
-        </Form>
-      </div>
+      <TaskPropertyQueryBuilder
+        {...this.props}
+        taskPropertyQuery={_get(this.props, 'criteria.filters.taskPropertySearch')}
+        clearTaskPropertyQuery={this.props.clearTaskPropertyCriteria}
+        updateTaskPropertyQuery={(data) => {
+          this.setState({showForm: false})
+          this.props.updateTaskPropertyCriteria(data)
+        }}
+      />
 
     return (
       <div className="mr-dropdown mr-dropdown--right">
diff --git a/src/components/TaskPane/TaskMap/TaskMap.js b/src/components/TaskPane/TaskMap/TaskMap.js
index fe279e78..dc4cef9b 100644
--- a/src/components/TaskPane/TaskMap/TaskMap.js
+++ b/src/components/TaskPane/TaskMap/TaskMap.js
@@ -321,22 +321,30 @@ export class TaskMap extends Component {
 
     return (
       <div className={classNames("task-map task", {"full-screen-map": this.props.isMobile})}>
-        <LayerToggle {...this.props}
-                     showTaskFeatures={this.state.showTaskFeatures}
-                     toggleTaskFeatures={this.toggleTaskFeatureVisibility}
-                     showOSMData={this.state.showOSMData}
-                     toggleOSMData={this.toggleOSMDataVisibility}
-                     osmDataLoading={this.state.osmDataLoading}
-                     toggleMapillary={this.props.isMapillaryEnabled() ? this.toggleMapillaryVisibility : undefined}
-                     showMapillary={this.props.showMapillaryLayer}
-                     mapillaryCount={_get(this.props, 'mapillaryImages.length', 0)} />
-        <EnhancedMap center={this.props.centerPoint} zoom={zoom} zoomControl={false}
-                     minZoom={minZoom} maxZoom={maxZoom} worldCopyJump={true}
-                     features={this.taskFeatures()}
-                     justFitFeatures={!this.state.showTaskFeatures}
-                     fitFeaturesOnlyAsNecessary
-                     animateFeatures
-                     onBoundsChange={this.updateTaskBounds}
+        <LayerToggle
+          {...this.props}
+          showTaskFeatures={this.state.showTaskFeatures}
+          toggleTaskFeatures={this.toggleTaskFeatureVisibility}
+          showOSMData={this.state.showOSMData}
+          toggleOSMData={this.toggleOSMDataVisibility}
+          osmDataLoading={this.state.osmDataLoading}
+          toggleMapillary={this.props.isMapillaryEnabled() ? this.toggleMapillaryVisibility : undefined}
+          showMapillary={this.props.showMapillaryLayer}
+          mapillaryCount={_get(this.props, 'mapillaryImages.length', 0)}
+        />
+        <EnhancedMap
+          center={this.props.centerPoint}
+          zoom={zoom}
+          zoomControl={false}
+          minZoom={minZoom}
+          maxZoom={maxZoom}
+          worldCopyJump={true}
+          features={this.taskFeatures()}
+          justFitFeatures={!this.state.showTaskFeatures}
+          fitFeaturesOnlyAsNecessary
+          animateFeatures
+          onBoundsChange={this.updateTaskBounds}
+          conditionalStyles={_get(this.props, 'challenge.taskStyles')}
         >
           <ZoomControl position='topright' />
           <FitBoundsControl />
diff --git a/src/components/TaskPropertyQueryBuilder/Messages.js b/src/components/TaskPropertyQueryBuilder/Messages.js
new file mode 100644
index 00000000..14b9e67c
--- /dev/null
+++ b/src/components/TaskPropertyQueryBuilder/Messages.js
@@ -0,0 +1,76 @@
+import { defineMessages } from 'react-intl'
+
+/**
+ * Internationalized messages for use with TaskFilters
+ */
+export default defineMessages({
+  searchButton: {
+    id: "TaskPropertyQueryBuilder.controls.search",
+    defaultMessage: "Search",
+  },
+
+  clearButton: {
+    id: "TaskPropertyQueryBuilder.controls.clear",
+    defaultMessage: "Clear",
+  },
+
+  noneOption: {
+    id: "TaskPropertyQueryBuilder.options.none.label",
+    defaultMessage: "None",
+  },
+
+  missingRightRule: {
+    id: "TaskPropertyQueryBuilder.error.missingRightRule",
+    defaultMessage: "When using a compound rule both parts must be specified.",
+  },
+
+  missingLeftRule: {
+    id: "TaskPropertyQueryBuilder.error.missingLeftRule",
+    defaultMessage: "When using a compound rule both parts must be specified.",
+  },
+
+  missingKey: {
+    id: "TaskPropertyQueryBuilder.error.missingKey",
+    defaultMessage: "Please select a property name.",
+  },
+
+  missingValue: {
+    id: "TaskPropertyQueryBuilder.error.missingValue",
+    defaultMessage: "You must enter a value.",
+  },
+
+  missingPropertyType: {
+    id: "TaskPropertyQueryBuilder.error.missingPropertyType",
+    defaultMessage: "Please choose a property type.",
+  },
+
+  notNumericValue: {
+    id: "TaskPropertyQueryBuilder.error.notNumericValue",
+    defaultMessage: "Property value given is not a valid number.",
+  },
+
+  stringType: {
+    id: "TaskPropertyQueryBuilder.propertyType.stringType",
+    defaultMessage: "text",
+  },
+
+  numberType: {
+    id: "TaskPropertyQueryBuilder.propertyType.numberType",
+    defaultMessage: "number",
+  },
+
+  compoundRuleType: {
+    id: "TaskPropertyQueryBuilder.propertyType.compoundRuleType",
+    defaultMessage: "compound rule",
+  },
+
+  missingStyleValue: {
+    id: "TaskPropertyQueryBuilder.error.missingStyleValue",
+    defaultMessage: "You must enter a style value.",
+  },
+
+  missingStyleName: {
+    id: "TaskPropertyQueryBuilder.error.missingStyleName",
+    defaultMessage: "You must choose a style name.",
+  },
+})
diff --git a/src/components/TaskFilters/TaskPropertiesSchema.js b/src/components/TaskPropertyQueryBuilder/TaskPropertiesSchema.js
similarity index 87%
rename from src/components/TaskFilters/TaskPropertiesSchema.js
rename to src/components/TaskPropertyQueryBuilder/TaskPropertiesSchema.js
index d6d139ba..8732d9f3 100644
--- a/src/components/TaskFilters/TaskPropertiesSchema.js
+++ b/src/components/TaskPropertyQueryBuilder/TaskPropertiesSchema.js
@@ -28,6 +28,15 @@ export const jsSchema = (intl, taskPropertyKeys) => {
       intl.formatMessage(messagesByPropertySearchType[type])
     )
 
+  let propertyKey = { enum: taskPropertyKeys }
+  // If no task property keys are provided then we offer a free-form text field
+  if (!taskPropertyKeys) {
+    propertyKey = {
+      title: "Key",
+      type: "string"
+    }
+  }
+
   return {
     "$schema": "http://json-schema.org/draft-07/schema#",
     type: "object",
@@ -69,9 +78,7 @@ export const jsSchema = (intl, taskPropertyKeys) => {
                   valueType: {
                     enum: ["string"],
                   },
-                  key: {
-                    enum: taskPropertyKeys,
-                  },
+                  key: propertyKey,
                   operator: {
                     type: "string",
                     enum: _values(TaskPropertySearchTypeString),
@@ -89,9 +96,7 @@ export const jsSchema = (intl, taskPropertyKeys) => {
                   valueType: {
                     enum: ["number"],
                   },
-                  key: {
-                    enum: taskPropertyKeys,
-                  },
+                  key: propertyKey,
                   operator: {
                     type: "string",
                     enum: _values(TaskPropertySearchTypeNumber),
@@ -121,11 +126,33 @@ export const jsSchema = (intl, taskPropertyKeys) => {
   }
 }
 
-function buildUISchema(deepness) {
+/**
+ * react-jsonschema-form doesn't currently support uiSchema entries for
+ * definitions, so we define a schema snippet here for the property rules
+ * definition that can be used in the uiSchema without duplicating it over and
+ * over for each field referencing a property rule.
+ *
+ * @private
+ */
+function buildUISchema(deepness, taskPropertyKeys) {
   if (deepness === 0 ) {
     return {}
   }
 
+  let keyType = {
+    classNames: "inline-selector mr-inline",
+    "ui:widget": "select",
+    "ui:options": { inline: true, label: false },
+  }
+
+  // If no task property keys are provided then we offer a free-form text field
+  if (!taskPropertyKeys) {
+    keyType = {
+      classNames: "inline-selector mr-inline",
+      "ui:options": { inline: true, label: false },
+    }
+  }
+
   return {
     classNames: "property-rule mr-border mr-border-green-light mr-p-2 mr-m-1 mr-pl-4 mr-flex",
     valueType: {
@@ -138,11 +165,7 @@ function buildUISchema(deepness) {
       "ui:widget": "select",
       "ui:options": { inline: true, label: false },
     },
-    key: {
-      classNames: "inline-selector mr-inline",
-      "ui:widget": "select",
-      "ui:options": { inline: true, label: false },
-    },
+    key: keyType,
     operator: {
       classNames: "inline-selector mr-inline",
       "ui:widget": "select",
@@ -157,15 +180,6 @@ function buildUISchema(deepness) {
   }
 }
 
-/**
- * react-jsonschema-form doesn't currently support uiSchema entries for
- * definitions, so we define a schema snippet here for the property rules
- * definition that can be used in the uiSchema without duplicating it over and
- * over for each field referencing a property rule.
- *
- * @private
- */
- const tagRuleUISchema = buildUISchema(7)
 
 /**
  * uiSchema configuration to assist react-jsonschema-form in determining
@@ -177,8 +191,8 @@ function buildUISchema(deepness) {
  * > the form configuration will help the Bulma/RJSFFormFieldAdapter generate the
  * > proper Bulma-compliant markup.
  */
-export const uiSchema = intl => ({
+export const uiSchema = (intl, taskPropertyKeys) => ({
   propertyRules: {
-    rootRule: tagRuleUISchema,
+    rootRule: buildUISchema(7, taskPropertyKeys),
   },
 })
diff --git a/src/components/TaskFilters/TaskPropertiesSchema.scss b/src/components/TaskPropertyQueryBuilder/TaskPropertiesSchema.scss
similarity index 100%
rename from src/components/TaskFilters/TaskPropertiesSchema.scss
rename to src/components/TaskPropertyQueryBuilder/TaskPropertiesSchema.scss
diff --git a/src/components/TaskPropertyQueryBuilder/TaskPropertyQueryBuilder.js b/src/components/TaskPropertyQueryBuilder/TaskPropertyQueryBuilder.js
new file mode 100644
index 00000000..12486518
--- /dev/null
+++ b/src/components/TaskPropertyQueryBuilder/TaskPropertyQueryBuilder.js
@@ -0,0 +1,169 @@
+import React, { Component } from 'react'
+import { FormattedMessage, injectIntl } from 'react-intl'
+import Form from 'react-jsonschema-form-async'
+import { CustomSelectWidget }
+       from '../Bulma/RJSFFormFieldAdapter/RJSFFormFieldAdapter'
+import _get from 'lodash/get'
+import _isEmpty from 'lodash/isEmpty'
+import _isEqual from 'lodash/isEqual'
+import _head from 'lodash/head'
+import _cloneDeep from 'lodash/cloneDeep'
+import { jsSchema, uiSchema } from './TaskPropertiesSchema'
+import { preparePropertyRulesForSaving,
+         preparePropertyRulesForForm,
+         validatePropertyRules } from './TaskPropertyRules'
+import messages from './Messages'
+import './TaskPropertiesSchema.scss'
+
+/**
+ * TaskPropertyQueryBuilder allows for assembling a query to filter task properties
+ *
+ * @author [Kelli Rotstan](https://github.com/krotstan)
+ */
+export class TaskPropertyQueryBuilder extends Component {
+  state = {
+  }
+
+  clearForm = (e) => {
+    this.props.clearTaskPropertyQuery()
+    this.setState({formData:{}, errors: null})
+    e.preventDefault()
+    e.stopPropagation()
+  }
+
+  /** Receive updates to the form data, along with any validation errors */
+  changeHandler = ({formData}) => {
+    const rootRule = _cloneDeep(_get(formData, 'propertyRules.rootRule'))
+
+    const moveLeft = (data, prevData) => {
+      // We've changed value type to compound rule so
+      // let's move over any assigned key/values.
+      if (data.valueType === "compound rule" &&
+          (prevData && _get(prevData, 'valueType') !== "compound rule")) {
+        data.left = {
+          valueType: prevData.valueType,
+          key: prevData.key,
+          value: prevData.value,
+          operator: prevData.operator,
+        }
+        data.key = undefined
+        data.value = undefined
+        data.operator = undefined
+      }
+      else if (data.valueType !== "compound rule" &&
+               _get(prevData, 'valueType') === "compound rule")
+      {
+        data.key = _get(prevData, 'left.key')
+        data.value = _get(prevData, 'left.value')
+        data.operator = _get(prevData, 'left.operator')
+        data.left = undefined
+        data.right = undefined
+      }
+      else {
+        if (data.left) {
+          moveLeft(data.left, _get(prevData, 'left'))
+        }
+        if (data.right) {
+          moveLeft(data.right, _get(prevData, 'right'))
+        }
+      }
+    }
+
+    moveLeft(rootRule, _get(this.state.formData, 'propertyRules.rootRule'))
+    this.setState({formData: {propertyRules: {rootRule}}, errors: null})
+
+    if (!!this.props.updateAsChange && _get(this.state.formData, 'propertyRules.rootRule')) {
+      const rootRule = _get(this.state.formData, 'propertyRules.rootRule')
+      const errors = validatePropertyRules(rootRule)
+      const preparedData = preparePropertyRulesForSaving(rootRule)
+      this.props.updateTaskPropertyQuery(preparedData, errors)
+    }
+  }
+
+  /** Receive errors from form validation */
+  errorHandler = (errors, err, formData) => {
+  }
+
+  finish = (e) => {
+    e.preventDefault()
+    e.stopPropagation()
+
+    const rootRule = _get(this.state.formData, 'propertyRules.rootRule')
+    const errors = validatePropertyRules(rootRule)
+
+    if (errors.length === 0) {
+      const preparedData = preparePropertyRulesForSaving(rootRule)
+
+      this.setState({preparedData, errors: null})
+      this.props.updateTaskPropertyQuery(preparedData)
+    }
+    else {
+      this.setState({errors})
+    }
+  }
+
+  setupFormData = (taskPropertyQuery) => {
+    this.setState({formData: {
+      propertyRules: {
+        rootRule: taskPropertyQuery ? preparePropertyRulesForForm(taskPropertyQuery) : {}
+      }
+    }})
+  }
+
+  componentDidMount() {
+    if (this.props.taskPropertyQuery) {
+      this.setupFormData(this.props.taskPropertyQuery)
+    }
+  }
+
+  componentDidUpdate(prevProps) {
+    // Clear form data if filters.taskPropertyQuery has been cleared.
+    if (_isEmpty(this.props.taskPropertyQuery) &&
+        !_isEmpty(this.state.preparedData)) {
+      this.setState({formData: null, preparedData: null})
+    }
+    else if (!_isEqual(this.props.taskPropertyQuery, prevProps.taskPropertyQuery)) {
+      this.setupFormData(this.props.taskPropertyQuery)
+    }
+  }
+
+  render() {
+    const data = this.state.formData
+    return (
+      <div className="task-properties-form mr-w-full mr-pt-4">
+        <Form schema={jsSchema(this.props.intl, this.props.taskPropertyKeys)}
+              className="mr-bg-white"
+              onAsyncValidate={this.validateGeoJSONSource}
+              uiSchema={uiSchema(this.props.intl, this.props.taskPropertyKeys)}
+              tagType={"taskProperties"}
+              widgets={{SelectWidget: CustomSelectWidget}}
+              noHtml5Validate
+              showErrorList={false}
+              formData={data}
+              onChange={this.changeHandler}
+              onError={this.errorHandler}
+        >
+          {this.state.errors &&
+            <div className="mr-ml-4 mr-mb-4 mr-text-red">
+              {this.props.intl.formatMessage(messages[_head(this.state.errors)])}
+            </div>
+          }
+          {!this.props.updateAsChange &&
+            <React.Fragment>
+              <button className="mr-button mr-button--green mr-ml-4 mr-mb-2"
+                      onClick={this.clearForm}>
+                <FormattedMessage {...messages.clearButton} />
+              </button>
+              <button className="mr-button mr-button--green mr-ml-4 mr-mb-2"
+                      onClick={this.finish}>
+                <FormattedMessage {...messages.searchButton} />
+              </button>
+            </React.Fragment>
+          }
+        </Form>
+      </div>
+    )
+  }
+}
+
+export default injectIntl(TaskPropertyQueryBuilder)
diff --git a/src/components/TaskFilters/TaskPropertyRules.js b/src/components/TaskPropertyQueryBuilder/TaskPropertyRules.js
similarity index 75%
rename from src/components/TaskFilters/TaskPropertyRules.js
rename to src/components/TaskPropertyQueryBuilder/TaskPropertyRules.js
index 31c11f3d..19a3ccab 100644
--- a/src/components/TaskFilters/TaskPropertyRules.js
+++ b/src/components/TaskPropertyQueryBuilder/TaskPropertyRules.js
@@ -10,6 +10,8 @@ export const PROPERTY_RULE_ERRORS = Object.freeze({
   missingValue: "missingValue",
   missingPropertyType: "missingPropertyType",
   notNumericValue: "notNumericValue",
+  missingStyleName: "missingStyleName",
+  missingStyleValue: "missingStyleValue"
 })
 
 /**
@@ -78,10 +80,47 @@ export const preparePropertyRulesForSaving = rule => {
   }
 }
 
+/**
+ * Tasks setup task property rules and turns them into
+ * the expected form format.
+ */
+export const preparePropertyRulesForForm = data => {
+  if (!data) {
+    return null
+  }
+
+  if (!data.key && !data.value && !data.left && !data.right &&
+           !data.valueType) {
+    // We have an empty rule.
+    return {}
+  }
+
+
+  return {
+    key: data.key ? data.key : undefined,
+    value: data.value ? data.value : undefined,
+    valueType: data.valueType ? data.valueType : (data.left ? "compound rule" : undefined),
+    operator: data.searchType ? data.searchType : undefined,
+    condition: data.operationType ? data.operationType : undefined,
+    left: data.left ? preparePropertyRulesForForm(data.left) : undefined,
+    right: data.right ? preparePropertyRulesForForm(data.right) : undefined
+  }
+}
+
 /**
  * Validates the property rules and returns any errors.
  */
 export const validatePropertyRules = (rule, errors=[]) => {
+  if (!rule) {
+    return errors
+  }
+
+  if (!rule.key && !rule.value && !rule.left && !rule.right &&
+           !rule.valueType) {
+    // We have an empty rule.
+    return errors
+  }
+
   if (!rule.valueType) {
     errors.push(PROPERTY_RULE_ERRORS.missingPropertyType)
   }
diff --git a/src/components/Widgets/FeatureStyleLegendWidget/FeatureStyleLegendWidget.js b/src/components/Widgets/FeatureStyleLegendWidget/FeatureStyleLegendWidget.js
new file mode 100644
index 00000000..1eb681dd
--- /dev/null
+++ b/src/components/Widgets/FeatureStyleLegendWidget/FeatureStyleLegendWidget.js
@@ -0,0 +1,33 @@
+import React, { Component } from 'react'
+import { FormattedMessage } from 'react-intl'
+import { WidgetDataTarget, registerWidgetType }
+       from '../../../services/Widget/Widget'
+import FeatureStyleLegend from '../../FeatureStyleLegend/FeatureStyleLegend'
+import QuickWidget from '../../QuickWidget/QuickWidget'
+import messages from './Messages'
+
+const descriptor = {
+  widgetKey: 'FeatureStyleLegendWidget',
+  label: messages.label,
+  targets: [WidgetDataTarget.task, WidgetDataTarget.challenge],
+  minWidth: 3,
+  defaultWidth: 4,
+  minHeight: 3,
+  defaultHeight: 8,
+}
+
+export default class FeatureStyleLegendWidget extends Component {
+  render() {
+    return (
+      <QuickWidget
+        {...this.props}
+        className=""
+        widgetTitle={<FormattedMessage {...messages.title} />}
+      >
+        <FeatureStyleLegend {...this.props} />
+      </QuickWidget>
+    )
+  }
+}
+
+registerWidgetType(FeatureStyleLegendWidget, descriptor)
diff --git a/src/components/Widgets/FeatureStyleLegendWidget/Messages.js b/src/components/Widgets/FeatureStyleLegendWidget/Messages.js
new file mode 100644
index 00000000..9290e7b2
--- /dev/null
+++ b/src/components/Widgets/FeatureStyleLegendWidget/Messages.js
@@ -0,0 +1,16 @@
+import { defineMessages } from 'react-intl'
+
+/**
+ * Internationalized messages for use with FeatureStyleLegendWidget
+ */
+export default defineMessages({
+  label: {
+    id: "Widgets.FeatureStyleLegendWidget.label",
+    defaultMessage: "Feature Style Legend",
+  },
+
+  title: {
+    id: "Widgets.FeatureStyleLegendWidget.title",
+    defaultMessage: "Feature Style Legend",
+  },
+})
diff --git a/src/components/Widgets/widget_registry.js b/src/components/Widgets/widget_registry.js
index bb7040db..9c0073ad 100644
--- a/src/components/Widgets/widget_registry.js
+++ b/src/components/Widgets/widget_registry.js
@@ -5,6 +5,8 @@
  */
 
 // Import first-party widgets shipped with MapRoulette
+export { default as FeatureStyleLegendWidget }
+       from './FeatureStyleLegendWidget/FeatureStyleLegendWidget'
 export { default as TaskBundleWidget }
        from './TaskBundleWidget/TaskBundleWidget'
 export { default as TagDiffWidget }
diff --git a/src/interactions/TaskFeature/AsFilterableFeature.js b/src/interactions/TaskFeature/AsFilterableFeature.js
new file mode 100644
index 00000000..4947eb34
--- /dev/null
+++ b/src/interactions/TaskFeature/AsFilterableFeature.js
@@ -0,0 +1,131 @@
+import _toFinite from 'lodash/toFinite'
+import _isFinite from 'lodash/isFinite'
+import _trim from 'lodash/trim'
+import _isString from 'lodash/isString'
+import _isUndefined from 'lodash/isUndefined'
+
+// value types (data types)
+const STRING = "string"
+const NUMBER = "number"
+
+// operation types (boolean operators)
+const AND = "and"
+const OR = "or"
+
+// search types (comparison operators)
+const EQUALS = "equals"
+const NOT_EQUAL = "not_equal"
+const CONTAINS = "contains"
+const GREATER_THAN = "greater_than"
+const LESS_THAN = "less_than"
+
+/**
+ * AsFilterableFeature adds functionality for determining if a feature matches
+ * filter rules
+ */
+export class AsFilterableFeature {
+  constructor(feature) {
+    Object.assign(this, feature)
+  }
+
+  /**
+   * Determines if this feature matches the given property filter rules
+   */
+  matchesPropertyFilter(rules) {
+    if (!rules) {
+      return false
+    }
+
+    // If there is an operationType then that means we're AND'ing or OR'ing
+    // the results of two sets of rules, a left and right
+    if (rules.operationType) {
+      switch (rules.operationType.toLowerCase()) {
+        case AND:
+          return this.matchesPropertyFilter(rules.left) && this.matchesPropertyFilter(rules.right)
+        case OR:
+          return this.matchesPropertyFilter(rules.left) || this.matchesPropertyFilter(rules.right)
+        default:
+          throw new Error(`Unrecognized filter rule operation type: ${rules.operationType}`)
+      }
+    }
+
+    // Otherwise evaluate the filter based on the value type (data type)
+    switch (rules.valueType.toLowerCase()) {
+      case STRING:
+        return this.matchesStringFilter(rules)
+      case NUMBER:
+        return this.matchesNumberFilter(rules)
+      default:
+        throw new Error(`Unsupported value type: ${rules.valueType}`)
+    }
+  }
+
+  /**
+   * Determine if these properties match the given string type filter
+   */
+  matchesStringFilter(rule) {
+    if (!_isString(rule.value)) {
+      return false
+    }
+
+    // searchType represents the comparison operation to perform
+    switch (rule.searchType.toLowerCase()) {
+      case EQUALS:
+        return this.properties[rule.key] === rule.value
+      case CONTAINS:
+        return _isString(this.properties[rule.key]) &&
+               rule.value.length > 0 &&
+               this.properties[rule.key].includes(rule.value) 
+      case NOT_EQUAL:
+        return this.properties[rule.key] !== rule.value
+      default:
+        throw new Error(`Unsupported string operator: ${rule.searchType}`)
+    }
+  }
+
+  /**
+   * Determine if these properties match the given number type filter
+   */
+  matchesNumberFilter(rule) {
+    // No empty rule values allowed for numeric comparison
+    if (_trim(rule.value) === "") {
+      return false
+    }
+
+    // If property is undefined or empty, return true for NOT_EQUAL comparator
+    // and false for everything else
+    if (_isUndefined(this.properties[rule.key]) ||
+        this.properties[rule.key] === null ||
+        _trim(this.properties[rule.key]) === "") {
+
+      return rule.searchType.toLowerCase() === NOT_EQUAL
+    }
+
+    // Make sure we're dealing with finite numbers
+    const propertyValue = _toFinite(this.properties[rule.key])
+    if (!_isFinite(propertyValue)) {
+      return false
+    }
+
+    const ruleValue = _toFinite(rule.value)
+    if (!_isFinite(ruleValue)) {
+      return false
+    }
+
+    // searchType represents the comparison operation to perform
+    switch (rule.searchType.toLowerCase()) {
+      case EQUALS:
+        return propertyValue === ruleValue
+      case NOT_EQUAL:
+        return propertyValue !== ruleValue
+      case GREATER_THAN:
+        return propertyValue > ruleValue
+      case LESS_THAN:
+        return propertyValue < ruleValue
+      default:
+        throw new Error(`Unsupported numeric operator: ${rule.searchType}`)
+    }
+  }
+}
+
+export default feature => new AsFilterableFeature(feature)
diff --git a/src/interactions/TaskFeature/AsSimpleStyleableFeature.js b/src/interactions/TaskFeature/AsSimpleStyleableFeature.js
index c2354388..b7d9f8e3 100644
--- a/src/interactions/TaskFeature/AsSimpleStyleableFeature.js
+++ b/src/interactions/TaskFeature/AsSimpleStyleableFeature.js
@@ -10,8 +10,11 @@ import _intersection from 'lodash/intersection'
 import _keys from 'lodash/keys'
 import _pick from 'lodash/pick'
 import _merge from 'lodash/merge'
+import _filter from 'lodash/filter'
+import _flatten from 'lodash/flatten'
 import resolveConfig from 'tailwindcss/resolveConfig'
 import tailwindConfig from '../../tailwind.config.js'
+import AsFilterableFeature from './AsFilterableFeature'
 
 const colors = resolveConfig(tailwindConfig).theme.colors
 
@@ -55,6 +58,31 @@ export class AsSimpleStyleableFeature {
     }
   }
 
+  /**
+   * Apply the given styles conditionally based on whether this feature matches
+   * the filters associated with each style. All matching styles (if any) are
+   * combined and applied together on top of any normal styling from
+   * simplestyle properties on this feature
+   */
+  styleLeafletLayerConditionally(layer, conditionalStyles) {
+    const filterableFeature = AsFilterableFeature(this)
+    const matchingStyles = _filter(conditionalStyles, style =>
+      filterableFeature.matchesPropertyFilter(style.propertySearch)
+    )
+
+    if (matchingStyles.length > 0) {
+      // flatten all matching styles into a single object
+      const styleObject = _fromPairs(_flatten(_map(matchingStyles, match =>
+        _map(match.styles, style => [style.styleName, style.styleValue])
+      )))
+
+      this.styleLeafletLayerWithStyles(layer, styleObject)
+    }
+    else {
+      this.styleLeafletLayerWithStyles(layer)
+    }
+  }
+
   /**
    * Styles the given Leaflet layer using the given simplestyles object of
    * simplestyle property names and desired values. If
diff --git a/src/lang/en-US.json b/src/lang/en-US.json
index a1fdb8b0..32223875 100644
--- a/src/lang/en-US.json
+++ b/src/lang/en-US.json
@@ -115,6 +115,14 @@
   "Admin.EditChallenge.form.exportableProperties.description": "Any properties included in this comma separated list will be exported as a column in the CSV export and populated with the first matching feature property from each task.",
   "Admin.EditChallenge.form.osmIdProperty.label": "OSM Id Property",
   "Admin.EditChallenge.form.osmIdProperty.description": "The name of the task feature property to treat as an OpenStreetMap element id for tasks. If left blank, MapRoulette will fall back to checking a series of common id properties, including those used by Overpass. If specified, **be sure that it has a unique value for each feature in your data**. Tasks missing the property will be assigned a random identifier even if the task contains other common id properties. [Learn more on the wiki](https://github.com/osmlab/maproulette3/wiki/Challenge-Managers:-setting-external-task-IDs-(e.g.-OSM-IDs)).",
+  "Admin.EditChallenge.form.customTaskStyles.label": "Customize Task Property Styles",
+  "Admin.EditChallenge.form.customTaskStyles.description": "Enable custom task styling based on specific task feature properties.",
+  "Admin.EditChallenge.form.customTaskStyles.error": "Task property style rules are invalid. Please fix before continuing.",
+  "Admin.EditChallenge.form.customTaskStyles.button": "Configure",
+  "Admin.EditChallenge.form.taskPropertyStyles.label": "Task Property Style Rules",
+  "Admin.EditChallenge.form.taskPropertyStyles.close": "Done",
+  "Admin.EditChallenge.form.taskPropertyStyles.clear": "Clear",
+  "Admin.EditChallenge.form.taskPropertyStyles.description": "Sets up task property style rules......",
   "Admin.ManageChallenges.header": "Challenges",
   "Admin.ManageChallenges.help.info": "Challenges consist of many tasks that all help address a specific problem or shortcoming with OpenStreetMap data. Tasks are typically generated automatically from an overpassQL query you provide when creating a new challenge, but can also be loaded from a local file or remote URL containing GeoJSON features. You can create as many challenges as you'd like.",
   "Admin.ManageChallenges.search.placeholder": "Name",
@@ -188,6 +196,15 @@
   "StepNavigation.controls.finish.label": "Finish",
   "Admin.TaskDeletingProgress.deletingTasks.header": "Deleting Tasks",
   "Admin.TaskDeletingProgress.tasksDeleting.label": "tasks deleted",
+  "Admin.TaskPropertyStyleRules.styles.header": "Task Property Styling",
+  "Admin.TaskPropertyStyleRules.deleteRule": "Delete Rule",
+  "Admin.TaskPropertyStyleRules.addRule": "Add Another Rule",
+  "Admin.TaskPropertyStyleRules.addNewStyle.label": "Add",
+  "Admin.TaskPropertyStyleRules.addNewStyle.tooltip": "Add Another Style",
+  "Admin.TaskPropertyStyleRules.removeStyle.tooltip": "Remove Style",
+  "Admin.TaskPropertyStyleRules.styleName": "Style Name",
+  "Admin.TaskPropertyStyleRules.styleValue": "Style Value",
+  "Admin.TaskPropertyStyleRules.styleValue.placeholder": "value",
   "Admin.TaskUploadProgress.uploadingTasks.header": "Building Tasks",
   "Admin.TaskUploadProgress.tasksUploaded.label": "tasks uploaded",
   "Admin.Challenge.tasksBuilding": "Tasks Building...",
@@ -518,6 +535,8 @@
   "FeaturedChallenges.header": "Featured",
   "FeaturedChallenges.noFeatured": "Nothing currently featured",
   "FeaturedChallenges.projectIndicator.label": "Project",
+  "FeatureStyleLegend.noStyles.label": "This challenge is not using custom styles",
+  "FeatureStyleLegend.comparators.contains.label": "contains",
   "Footer.versionLabel": "MapRoulette",
   "Footer.getHelp": "Get Help",
   "Footer.reportBug": "Report a Bug",
@@ -675,22 +694,10 @@
   "TaskConfirmationModal.comment.placeholder": "Your comment (optional)",
   "TaskConfirmationModal.nextNearby.label": "Select your next nearby task (optional)",
   "TaskConfirmationModal.addTags.placeholder": "Add MR Tags",
-  "TaskPropertyFilter.controls.search": "Search",
-  "TaskPropertyFilter.controls.clear": "Clear",
-  "TaskPropertyFilter.options.none.label": "None",
   "TaskPropertyFilter.label": "Filter By Property",
   "TaskPriorityFilter.label": "Filter by Priority",
   "TaskStatusFilter.label": "Filter by Status",
   "TaskReviewStatusFilter.label": "Filter by Review Status",
-  "TaskPropertyFilter.error.missingRightRule": "When using a compound rule both parts must be specified.",
-  "TaskPropertyFilter.error.missingLeftRule": "When using a compound rule both parts must be specified.",
-  "TaskPropertyFilter.error.missingKey": "Please select a property name.",
-  "TaskPropertyFilter.error.missingValue": "You must enter a value.",
-  "TaskPropertyFilter.error.missingPropertyType": "Please choose a property type.",
-  "TaskPropertyFilter.error.notNumericValue": "Property value given is not a valid number.",
-  "TaskPropertyFilter.propertyType.stringType": "text",
-  "TaskPropertyFilter.propertyType.numberType": "number",
-  "TaskPropertyFilter.propertyType.compoundRuleType": "compound rule",
   "TaskHistory.fields.startedOn.label": "Started on task",
   "TaskHistory.controls.viewAttic.label": "View Attic",
   "Task.markedAs.label": "Task marked as",
@@ -755,6 +762,20 @@
   "Challenge.controls.taskLoadBy.label": "Load tasks by:",
   "Task.controls.track.label": "Track this Task",
   "Task.controls.untrack.label": "Stop tracking this Task",
+  "TaskPropertyQueryBuilder.controls.search": "Search",
+  "TaskPropertyQueryBuilder.controls.clear": "Clear",
+  "TaskPropertyQueryBuilder.options.none.label": "None",
+  "TaskPropertyQueryBuilder.error.missingRightRule": "When using a compound rule both parts must be specified.",
+  "TaskPropertyQueryBuilder.error.missingLeftRule": "When using a compound rule both parts must be specified.",
+  "TaskPropertyQueryBuilder.error.missingKey": "Please select a property name.",
+  "TaskPropertyQueryBuilder.error.missingValue": "You must enter a value.",
+  "TaskPropertyQueryBuilder.error.missingPropertyType": "Please choose a property type.",
+  "TaskPropertyQueryBuilder.error.notNumericValue": "Property value given is not a valid number.",
+  "TaskPropertyQueryBuilder.propertyType.stringType": "text",
+  "TaskPropertyQueryBuilder.propertyType.numberType": "number",
+  "TaskPropertyQueryBuilder.propertyType.compoundRuleType": "compound rule",
+  "TaskPropertyQueryBuilder.error.missingStyleValue": "You must enter a style value.",
+  "TaskPropertyQueryBuilder.error.missingStyleName": "You must choose a style name.",
   "ActiveTask.subheading.status": "Existing Status",
   "ActiveTask.controls.status.tooltip": "Existing Status",
   "ActiveTask.controls.viewChangset.label": "View Changeset",
@@ -780,6 +801,8 @@
   "Widgets.CompletionProgressWidget.label": "Completion Progress",
   "Widgets.CompletionProgressWidget.title": "Completion Progress",
   "Widgets.CompletionProgressWidget.noTasks": "Challenge has no tasks",
+  "Widgets.FeatureStyleLegendWidget.label": "Feature Style Legend",
+  "Widgets.FeatureStyleLegendWidget.title": "Feature Style Legend",
   "Widgets.KeyboardShortcutsWidget.label": "Keyboard Shortcuts",
   "Widgets.KeyboardShortcutsWidget.title": "Keyboard Shortcuts",
   "Widgets.ReviewMap.label": "Review Map",
diff --git a/src/services/Challenge/Challenge.js b/src/services/Challenge/Challenge.js
index 3b566df6..1cba822d 100644
--- a/src/services/Challenge/Challenge.js
+++ b/src/services/Challenge/Challenge.js
@@ -670,7 +670,8 @@ export const saveChallenge = function(originalChallengeData, storeResponse=true)
         'instruction', 'localGeoJSON', 'lowPriorityRule', 'maxZoom',
         'mediumPriorityRule', 'minZoom', 'name', 'overpassQL', 'parent',
         'remoteGeoJson', 'status', 'tags', 'updateTasks', 'virtualParents',
-        'exportableProperties', 'osmIdProperty', 'dataOriginDate', 'preferredTags'])
+        'exportableProperties', 'osmIdProperty', 'dataOriginDate', 'preferredTags',
+        'taskStyles'])
 
       if (challengeData.dataOriginDate) {
         // Set the timestamp on the dataOriginDate so we get proper timezone info.
@@ -939,6 +940,10 @@ const reduceChallengesFurther = function(mergedState, oldState, challengeEntitie
     if (_isArray(entity.virtualParents)) {
       mergedState[entity.id].virtualParents = entity.virtualParents
     }
+
+    if (_isArray(entity.taskStyles)) {
+      mergedState[entity.id].taskStyles = entity.taskStyles
+    }
   })
 }
 
diff --git a/src/services/Task/TaskProperty/TaskProperty.js b/src/services/Task/TaskProperty/TaskProperty.js
index 7948658b..b5e121b8 100644
--- a/src/services/Task/TaskProperty/TaskProperty.js
+++ b/src/services/Task/TaskProperty/TaskProperty.js
@@ -1,5 +1,7 @@
 import _map from 'lodash/map'
 import _fromPairs from 'lodash/fromPairs'
+import _compact from 'lodash/compact'
+import _isUndefined from 'lodash/isUndefined'
 import messages from './Messages'
 
 /**
@@ -38,7 +40,12 @@ export const TaskPropertyOperationType = Object.freeze({
  * messages suitable for use with FormattedMessage or formatMessage.
  */
 export const messagesByPropertySearchType = _fromPairs(
-  _map(messages, (message, key) => [TaskPropertySearchTypeString[key], message])
+  _compact(_map(messages, (message, key) => {
+    if (_isUndefined(TaskPropertySearchTypeString[key])) {
+      return null
+    }
+    return [TaskPropertySearchTypeString[key], message]
+  }))
 )
 
 /**
@@ -46,5 +53,9 @@ export const messagesByPropertySearchType = _fromPairs(
  * messages suitable for use with FormattedMessage or formatMessage.
  */
 export const messagesByPropertyOperationType = _fromPairs(
-  _map(messages, (message, key) => [TaskPropertyOperationType[key], message])
+  _compact(_map(messages, (message, key) =>
+    !_isUndefined(TaskPropertyOperationType[key]) ?
+      [TaskPropertyOperationType[key], message] :
+      null
+  ))
 )


Test patch:
diff --git a/src/interactions/TaskFeature/AsFilterableFeature.test.js b/src/interactions/TaskFeature/AsFilterableFeature.test.js
new file mode 100644
index 00000000..03d5d1d1
--- /dev/null
+++ b/src/interactions/TaskFeature/AsFilterableFeature.test.js
@@ -0,0 +1,341 @@
+import AsFilterableFeature from './AsFilterableFeature'
+
+let basicFeature = null
+let stringRule = null
+let numberRule = null
+let compoundRule = null
+
+beforeEach(() => {
+  basicFeature = {
+    properties: {
+      foo: "bar",
+      age: 50,
+    }
+  }
+
+  stringRule = {
+    valueType: "string",
+    key: "foo",
+    searchType: "equals",
+    value: "bar",
+  }
+
+  numberRule = {
+    valueType: "number",
+    key: "age",
+    searchType: "equals",
+    value: "50",
+  }
+
+  compoundRule = {
+    operationType: "and",
+    left: stringRule,
+    right: numberRule,
+  }
+})
+
+describe('matchesStringFilter', () => {
+  test("returns false if the rule value is not a string", () => {
+    stringRule.value = 123
+
+    expect(AsFilterableFeature(basicFeature).matchesStringFilter(stringRule)).toBe(false)
+  })
+
+  test("throws an error if search type is not supported", () => {
+    stringRule.searchType = "unsupported_comparison"
+
+    expect(() => AsFilterableFeature(basicFeature).matchesStringFilter(stringRule)).toThrow()
+  })
+
+  describe('equals', () => {
+    test("returns true if values are equal", () => {
+      expect(AsFilterableFeature(basicFeature).matchesStringFilter(stringRule)).toBe(true)
+    })
+
+    test("returns false if values are not equal", () => {
+      basicFeature.properties.foo = "baz"
+
+      expect(AsFilterableFeature(basicFeature).matchesStringFilter(stringRule)).toBe(false)
+    })
+
+    test("returns false if the property doesn't exist", () => {
+      delete basicFeature.properties.foo
+
+      expect(AsFilterableFeature(basicFeature).matchesStringFilter(stringRule)).toBe(false)
+    })
+  })
+
+  describe('not_equal', () => {
+    beforeEach(() => {
+      stringRule.searchType = "not_equal"
+    })
+
+    test("returns false if values are equal", () => {
+      expect(AsFilterableFeature(basicFeature).matchesStringFilter(stringRule)).toBe(false)
+    })
+
+    test("returns true if values are not equal", () => {
+      basicFeature.properties.foo = "baz"
+
+      expect(AsFilterableFeature(basicFeature).matchesStringFilter(stringRule)).toBe(true)
+    })
+
+    test("returns true if the property doesn't exist", () => {
+      delete basicFeature.properties.foo
+
+      expect(AsFilterableFeature(basicFeature).matchesStringFilter(stringRule)).toBe(true)
+    })
+
+    test("returns true if the property is null", () => {
+      basicFeature.properties.foo = null
+
+      expect(AsFilterableFeature(basicFeature).matchesStringFilter(stringRule)).toBe(true)
+    })
+  })
+
+  describe('contains', () => {
+    beforeEach(() => {
+      stringRule.searchType = "contains"
+    })
+
+    test("returns true if values are equal", () => {
+      expect(AsFilterableFeature(basicFeature).matchesStringFilter(stringRule)).toBe(true)
+    })
+
+    test("returns true if property contains rule value", () => {
+      basicFeature.properties.foo = "hello world"
+      stringRule.value = "world"
+
+      expect(AsFilterableFeature(basicFeature).matchesStringFilter(stringRule)).toBe(true)
+    })
+
+    test("returns false if property does not contain rule value", () => {
+      basicFeature.properties.foo = "hello world"
+      stringRule.value = "bar"
+
+      expect(AsFilterableFeature(basicFeature).matchesStringFilter(stringRule)).toBe(false)
+    })
+
+    test("returns false if the rule value is an empty string", () => {
+      stringRule.value = ""
+
+      expect(AsFilterableFeature(basicFeature).matchesStringFilter(stringRule)).toBe(false)
+    })
+
+    test("returns false if the property doesn't exist", () => {
+      delete basicFeature.properties.foo
+
+      expect(AsFilterableFeature(basicFeature).matchesStringFilter(stringRule)).toBe(false)
+    })
+
+    test("returns false if the property is null", () => {
+      basicFeature.properties.foo = null
+
+      expect(AsFilterableFeature(basicFeature).matchesStringFilter(stringRule)).toBe(false)
+    })
+  })
+})
+
+describe('matchesNumberFilter', () => {
+  test("returns false if the rule value is null", () => {
+    numberRule.value = null
+
+    expect(AsFilterableFeature(basicFeature).matchesNumberFilter(numberRule)).toBe(false)
+  })
+
+  test("returns false if the rule value is empty", () => {
+    numberRule.value = ""
+
+    expect(AsFilterableFeature(basicFeature).matchesNumberFilter(numberRule)).toBe(false)
+  })
+
+  test("returns false if the rule value is not numeric", () => {
+    numberRule.value = "abc"
+
+    expect(AsFilterableFeature(basicFeature).matchesNumberFilter(numberRule)).toBe(false)
+  })
+
+  test("throws an error if search type is not supported", () => {
+    numberRule.searchType = "unsupported_comparison"
+
+    expect(() => AsFilterableFeature(basicFeature).matchesNumberFilter(numberRule)).toThrow()
+  })
+
+  describe('equals', () => {
+    test("returns true if values are equal", () => {
+      expect(AsFilterableFeature(basicFeature).matchesNumberFilter(numberRule)).toBe(true)
+    })
+
+    test("allows property to be a string if data is numeric", () => {
+      basicFeature.properties.age = "50"
+
+      expect(AsFilterableFeature(basicFeature).matchesNumberFilter(numberRule)).toBe(true)
+    })
+
+    test("returns false if values are not equal", () => {
+      basicFeature.properties.age = 10
+
+      expect(AsFilterableFeature(basicFeature).matchesNumberFilter(numberRule)).toBe(false)
+    })
+
+    test("returns false if the property doesn't exist", () => {
+      delete basicFeature.properties.age
+
+      expect(AsFilterableFeature(basicFeature).matchesNumberFilter(numberRule)).toBe(false)
+    })
+  })
+
+  describe('not_equal', () => {
+    beforeEach(() => {
+      numberRule.searchType = "not_equal"
+    })
+
+    test("returns false if values are equal", () => {
+      expect(AsFilterableFeature(basicFeature).matchesNumberFilter(numberRule)).toBe(false)
+    })
+
+    test("returns true if values are not equal", () => {
+      basicFeature.properties.age = 10
+
+      expect(AsFilterableFeature(basicFeature).matchesNumberFilter(numberRule)).toBe(true)
+    })
+
+    test("returns true if the property doesn't exist", () => {
+      delete basicFeature.properties.age
+
+      expect(AsFilterableFeature(basicFeature).matchesNumberFilter(numberRule)).toBe(true)
+    })
+
+    test("returns true if the property is null", () => {
+      basicFeature.properties.age = null
+
+      expect(AsFilterableFeature(basicFeature).matchesNumberFilter(numberRule)).toBe(true)
+    })
+  })
+
+  describe('greater_than', () => {
+    beforeEach(() => {
+      numberRule.searchType = "greater_than"
+    })
+
+    test("returns true if property is greater than value", () => {
+      basicFeature.properties.age = 60
+      expect(AsFilterableFeature(basicFeature).matchesNumberFilter(numberRule)).toBe(true)
+    })
+
+    test("allows property to be a string if data is numeric", () => {
+      basicFeature.properties.age = "60"
+
+      expect(AsFilterableFeature(basicFeature).matchesNumberFilter(numberRule)).toBe(true)
+    })
+
+    test("returns false property is less than value", () => {
+      basicFeature.properties.age = 40
+
+      expect(AsFilterableFeature(basicFeature).matchesNumberFilter(numberRule)).toBe(false)
+    })
+
+    test("returns false property is equal to value", () => {
+      basicFeature.properties.age = 50
+
+      expect(AsFilterableFeature(basicFeature).matchesNumberFilter(numberRule)).toBe(false)
+    })
+
+    test("returns false if the property doesn't exist", () => {
+      delete basicFeature.properties.age
+
+      expect(AsFilterableFeature(basicFeature).matchesNumberFilter(numberRule)).toBe(false)
+    })
+  })
+
+  describe('less_than', () => {
+    beforeEach(() => {
+      numberRule.searchType = "less_than"
+    })
+
+    test("returns true if property is less than value", () => {
+      basicFeature.properties.age = 40
+      expect(AsFilterableFeature(basicFeature).matchesNumberFilter(numberRule)).toBe(true)
+    })
+
+    test("allows property to be a string if data is numeric", () => {
+      basicFeature.properties.age = "40"
+
+      expect(AsFilterableFeature(basicFeature).matchesNumberFilter(numberRule)).toBe(true)
+    })
+
+    test("returns false property is greater than value", () => {
+      basicFeature.properties.age = 60
+
+      expect(AsFilterableFeature(basicFeature).matchesNumberFilter(numberRule)).toBe(false)
+    })
+
+    test("returns false property is equal to value", () => {
+      basicFeature.properties.age = 50
+
+      expect(AsFilterableFeature(basicFeature).matchesNumberFilter(numberRule)).toBe(false)
+    })
+
+    test("returns false if the property doesn't exist", () => {
+      delete basicFeature.properties.age
+
+      expect(AsFilterableFeature(basicFeature).matchesNumberFilter(numberRule)).toBe(false)
+    })
+  })
+})
+
+describe('matchesPropertyFilter', () => {
+  test("throws an error if operation type is not supported", () => {
+    compoundRule.operationType = "unsupported_operator"
+
+    expect(() => AsFilterableFeature(basicFeature).matchesPropertyFilter(compoundRule)).toThrow()
+  })
+
+  describe("AND'ed compound rules", () => {
+    test("returns true if both sides of AND'ed compound rule are true", () => {
+      expect(AsFilterableFeature(basicFeature).matchesPropertyFilter(compoundRule)).toBe(true)
+    })
+
+    test("returns false if left side of AND'ed compound rule is false", () => {
+      compoundRule.left.value = "baz"
+      expect(AsFilterableFeature(basicFeature).matchesPropertyFilter(compoundRule)).toBe(false)
+    })
+
+    test("returns false if right side of AND'ed compound rule is false", () => {
+      compoundRule.right.value = "100"
+      expect(AsFilterableFeature(basicFeature).matchesPropertyFilter(compoundRule)).toBe(false)
+    })
+
+    test("returns false if both sides of AND'ed compound rule is false", () => {
+      compoundRule.left.value = "baz"
+      compoundRule.right.value = "100"
+      expect(AsFilterableFeature(basicFeature).matchesPropertyFilter(compoundRule)).toBe(false)
+    })
+  })
+
+  describe("AND'ed compound rules", () => {
+    beforeEach(() => {
+      compoundRule.operationType = "or"
+    })
+
+    test("returns true if both sides of OR'ed compound rule are true", () => {
+      expect(AsFilterableFeature(basicFeature).matchesPropertyFilter(compoundRule)).toBe(true)
+    })
+
+    test("returns true if left side of OR'ed compound rule is false", () => {
+      compoundRule.left.value = "baz"
+      expect(AsFilterableFeature(basicFeature).matchesPropertyFilter(compoundRule)).toBe(true)
+    })
+
+    test("returns true if right side of OR'ed compound rule is false", () => {
+      compoundRule.right.value = "100"
+      expect(AsFilterableFeature(basicFeature).matchesPropertyFilter(compoundRule)).toBe(true)
+    })
+
+    test("returns false if both sides of OR'ed compound rule is false", () => {
+      compoundRule.left.value = "baz"
+      compoundRule.right.value = "100"
+      expect(AsFilterableFeature(basicFeature).matchesPropertyFilter(compoundRule)).toBe(false)
+    })
+  })
+})