From e15993416655473eecf94e42a5255103a39de6fd Mon Sep 17 00:00:00 2001
From: Andrew Philbin <45773707+AndrewPhilbin@users.noreply.github.com>
Date: Thu, 10 Aug 2023 17:02:47 -0700
Subject: [PATCH] Added modified HOCs to handle filter persist. Added
WithSavedFilters as wrapper for TaskBundleWidget to facilitate saves
---
.../WithFilterCriteria/WithFilterCriteria.js | 32 --
.../WithFilteredClusteredTasks.js | 5 +-
.../WithPersistedFilterCriteria.js | 293 +++++++++++++
.../WithPersistedFilteredClusteredTasks.js | 387 ++++++++++++++++++
.../TaskFilters/TaskStatusFilter.js | 17 +-
.../TaskBundleWidget/TaskBundleWidget.js | 293 +++++++------
6 files changed, 869 insertions(+), 158 deletions(-)
create mode 100644 src/components/HOCs/WithPersistedFilterCriteria/WithPersistedFilterCriteria.js
create mode 100644 src/components/HOCs/WithPersistedFilteredClusteredTasks/WithPersistedFilteredClusteredTasks.js
diff --git a/src/components/HOCs/WithFilterCriteria/WithFilterCriteria.js b/src/components/HOCs/WithFilterCriteria/WithFilterCriteria.js
index 36a6165ef..699ff4fee 100644
--- a/src/components/HOCs/WithFilterCriteria/WithFilterCriteria.js
+++ b/src/components/HOCs/WithFilterCriteria/WithFilterCriteria.js
@@ -38,7 +38,6 @@ export const WithFilterCriteria = function(WrappedComponent, ignoreURL = true,
updateCriteria = (newCriteria) => {
const criteria = _cloneDeep(this.state.criteria)
- console.log('filter criteria in updateCriteria', criteria)
criteria.sortCriteria = newCriteria.sortCriteria
criteria.page = newCriteria.page
criteria.filters = newCriteria.filters
@@ -59,7 +58,6 @@ export const WithFilterCriteria = function(WrappedComponent, ignoreURL = true,
updateTaskPropertyCriteria = (propertySearch) => {
const criteria = _cloneDeep(this.state.criteria)
- console.log('filter added in updateTaskPropertyCriteria', propertySearch)
criteria.filters.taskPropertySearch = propertySearch
this.setState({criteria})
}
@@ -81,12 +79,10 @@ export const WithFilterCriteria = function(WrappedComponent, ignoreURL = true,
clearAllFilters = () => {
if (this.props.clearAllFilters) {
- console.log('is upstream clear filters running?')
this.props.clearAllFilters()
}
const newCriteria = _cloneDeep(DEFAULT_CRITERIA)
-
newCriteria.boundingBox = null
newCriteria.zoom = this.state.zoom
newCriteria.filters["status"] = _keys(_pickBy(this.props.includeTaskStatuses, (s) => s))
@@ -94,7 +90,6 @@ export const WithFilterCriteria = function(WrappedComponent, ignoreURL = true,
newCriteria.filters["metaReviewStatus"] = _keys(_pickBy(this.props.includeMetaReviewStatuses, (r) => r))
newCriteria.filters["priorities"] = _keys(_pickBy(this.props.includeTaskPriorities, (p) => p))
-
if (!ignoreURL) {
this.props.history.push({
pathname: this.props.history.location.pathname,
@@ -105,30 +100,6 @@ export const WithFilterCriteria = function(WrappedComponent, ignoreURL = true,
this.setState({criteria: newCriteria, loading: true})
}
- // Alternate clear function to maintain map zoom and bounding box when clearing filters
-
- clearAllFiltersAndMaintainMapState = () => {
-
- // Runs upstream clear function, specifically in HOCs that manage filter state and pass filter statuses to this component.
-
- if (this.props.clearAllFilters) {
- console.log('is this running?')
- this.props.clearAllFilters()
- }
-
- const newCriteria = _cloneDeep(DEFAULT_CRITERIA)
-
- newCriteria.boundingBox = this.state.criteria.boundingBox
- newCriteria.zoom = this.state.criteria.zoom
- newCriteria.filters["status"] = _keys(_pickBy(this.props.includeTaskStatuses, (s) => s))
- newCriteria.filters["reviewStatus"] = _keys(_pickBy(this.props.includeReviewStatuses, (r) => r))
- newCriteria.filters["metaReviewStatus"] = _keys(_pickBy(this.props.includeMetaReviewStatuses, (r) => r))
- newCriteria.filters["priorities"] = _keys(_pickBy(this.props.includeTaskPriorities, (p) => p))
-
- this.setState({criteria: newCriteria, loading: true})
-
- }
-
changePageSize = (pageSize) => {
const typedCriteria = _cloneDeep(this.state.criteria)
typedCriteria.pageSize = pageSize
@@ -146,7 +117,6 @@ export const WithFilterCriteria = function(WrappedComponent, ignoreURL = true,
updateIncludedFilters(props, criteria = {}) {
const typedCriteria = _merge({}, criteria, _cloneDeep(this.state.criteria))
- console.log('typedCriteria in updateIncludedFilters', typedCriteria)
typedCriteria.filters["status"] = _keys(_pickBy(props.includeTaskStatuses, (s) => s))
typedCriteria.filters["reviewStatus"] = _keys(_pickBy(props.includeTaskReviewStatuses, (r) => r))
typedCriteria.filters["metaReviewStatus"] = _keys(_pickBy(props.includeMetaReviewStatuses, (r) => r))
@@ -256,7 +226,6 @@ export const WithFilterCriteria = function(WrappedComponent, ignoreURL = true,
}
componentDidUpdate(prevProps, prevState) {
- console.log('update ran in withfiltercriteria')
const challengeId = _get(this.props, 'challenge.id') || this.props.challengeId
if (!challengeId) {
return
@@ -316,7 +285,6 @@ export const WithFilterCriteria = function(WrappedComponent, ignoreURL = true,
updateCriteria={this.updateCriteria}
refreshTasks={this.refreshTasks}
clearAllFilters={this.clearAllFilters}
- clearAllFiltersAndMaintainMapState={this.clearAllFiltersAndMaintainMapState}
{..._omit(this.props, ['loadingChallenge', 'clearAllFilters'])} />)
}
}
diff --git a/src/components/HOCs/WithFilteredClusteredTasks/WithFilteredClusteredTasks.js b/src/components/HOCs/WithFilteredClusteredTasks/WithFilteredClusteredTasks.js
index 53b20eff4..391060140 100644
--- a/src/components/HOCs/WithFilteredClusteredTasks/WithFilteredClusteredTasks.js
+++ b/src/components/HOCs/WithFilteredClusteredTasks/WithFilteredClusteredTasks.js
@@ -40,7 +40,7 @@ export default function WithFilteredClusteredTasks(WrappedComponent,
initialFilters) {
return class extends Component {
defaultFilters = () => {
- console.log('initialfilters in defaultFilters', initialFilters)
+
return {
includeStatuses: _get(initialFilters, 'statuses',
_fromPairs(_map(TaskStatus, status => [status, true]))),
@@ -282,8 +282,6 @@ export default function WithFilteredClusteredTasks(WrappedComponent,
setupFilters = () => {
let useURLFilters = false
- console.log('history location search prop in setupFilters', this.props.history.location.search)
- console.log('history location state prop in setupFilters', this.props.history.location.state)
const criteria =
this.props.history.location.search ?
buildSearchCriteriafromURL(this.props.history.location.search) :
@@ -343,7 +341,6 @@ export default function WithFilteredClusteredTasks(WrappedComponent,
}
componentDidMount() {
- console.log('does this run in other workflow contexts?')
this.setupFilters()
}
diff --git a/src/components/HOCs/WithPersistedFilterCriteria/WithPersistedFilterCriteria.js b/src/components/HOCs/WithPersistedFilterCriteria/WithPersistedFilterCriteria.js
new file mode 100644
index 000000000..0ca205b5e
--- /dev/null
+++ b/src/components/HOCs/WithPersistedFilterCriteria/WithPersistedFilterCriteria.js
@@ -0,0 +1,293 @@
+import React, { Component } from 'react'
+import _get from 'lodash/get'
+import _cloneDeep from 'lodash/cloneDeep'
+import _isEqual from 'lodash/isEqual'
+import _keys from 'lodash/keys'
+import _pickBy from 'lodash/pickBy'
+import _omit from 'lodash/omit'
+import _merge from 'lodash/merge'
+import _toInteger from 'lodash/toInteger'
+import _each from 'lodash/each'
+import _isUndefined from 'lodash/isUndefined'
+import _debounce from 'lodash/debounce'
+import { fromLatLngBounds, GLOBAL_MAPBOUNDS } from '../../../services/MapBounds/MapBounds'
+import { buildSearchCriteriafromURL} from '../../../services/SearchCriteria/SearchCriteria'
+
+const DEFAULT_PAGE_SIZE = 20
+const DEFAULT_CRITERIA = {sortCriteria: {sortBy: 'name', direction: 'DESC'},
+ pageSize: DEFAULT_PAGE_SIZE, filters:{},
+ invertFields: {}}
+
+/**
+ * WithPersistedFilterCriteria keeps track of the current criteria being used
+ * to filter, sort and page the tasks. It is a streamlined version of
+ * WithFilterCriteria used in the context of the TaskBundleWidget to persisted
+ * filter state beyond task resolution. An upstream WithSavedFilters HOC provides
+ * the method props for coding and decoding filters formatted as URL search parameters
+ * (as these methods are used elsewhere in the app in other contexts), and also
+ * for saving the filter string as a user app setting in the database.
+ *
+ * @author [Kelli Rotstan](https://github.com/krotstan)
+ * @author [Andrew Philbin](https://github.com/AndrewPhilbin)
+ */
+export const WithFilterCriteria = function(WrappedComponent, ignoreURL = true,
+ ignoreLocked = true, skipInitialFetch = false) {
+ return class extends Component {
+ state = {
+ loading: false,
+ criteria: DEFAULT_CRITERIA,
+ pageSize: DEFAULT_PAGE_SIZE,
+ }
+
+ updateCriteria = (newCriteria) => {
+ const criteria = _cloneDeep(this.state.criteria)
+ criteria.sortCriteria = newCriteria.sortCriteria
+ criteria.page = newCriteria.page
+ criteria.filters = newCriteria.filters
+ criteria.includeTags = newCriteria.includeTags
+ this.setState({criteria})
+ if (this.props.setSearchFilters) {
+ this.props.setSearchFilters(criteria)
+ }
+
+ // We need to update the saved filter string with the new criteria
+ // along with local state.
+ if(this.props.saveCurrentSearchFilters) {
+ console.log('savecurrentsearchfilters ran')
+ this.props.saveCurrentSearchFilters('taskBundleFilters', criteria)
+ }
+ }
+
+ updateTaskFilterBounds = (bounds, zoom) => {
+ const newCriteria = _cloneDeep(this.state.criteria)
+ newCriteria.boundingBox = fromLatLngBounds(bounds)
+ newCriteria.zoom = zoom
+ this.setState({criteria: newCriteria})
+ }
+
+ updateTaskPropertyCriteria = (propertySearch) => {
+ const criteria = _cloneDeep(this.state.criteria)
+ criteria.filters.taskPropertySearch = propertySearch
+ this.setState({criteria})
+ }
+
+ invertField = (fieldName) => {
+ const criteria = _cloneDeep(this.state.criteria)
+ criteria.invertFields[fieldName] = !criteria.invertFields[fieldName]
+ this.setState({criteria})
+ if (this.props.setSearchFilters) {
+ this.props.setSearchFilters(criteria)
+ }
+ }
+
+ clearTaskPropertyCriteria = () => {
+ const criteria = _cloneDeep(this.state.criteria)
+ criteria.filters.taskPropertySearch = null
+ this.setState({criteria})
+ }
+
+ // This will clear all filters but also update the map, which is less useful for task
+ // completion than other applications.
+ clearAllFilters = () => {
+ if (this.props.clearAllFilters) {
+ this.props.clearAllFilters()
+ }
+
+ const newCriteria = _cloneDeep(DEFAULT_CRITERIA)
+
+ newCriteria.boundingBox = null
+ newCriteria.zoom = this.state.zoom
+ newCriteria.filters["status"] = _keys(_pickBy(this.props.includeTaskStatuses, (s) => s))
+ newCriteria.filters["reviewStatus"] = _keys(_pickBy(this.props.includeReviewStatuses, (r) => r))
+ newCriteria.filters["metaReviewStatus"] = _keys(_pickBy(this.props.includeMetaReviewStatuses, (r) => r))
+ newCriteria.filters["priorities"] = _keys(_pickBy(this.props.includeTaskPriorities, (p) => p))
+
+
+ if (!ignoreURL) {
+ this.props.history.push({
+ pathname: this.props.history.location.pathname,
+ state: {refresh: true}
+ })
+ }
+
+ this.setState({criteria: newCriteria, loading: true})
+
+ // If using saved filters, the clear function needs to also reset them to default
+ if(this.props.saveCurrentSearchFilters && this.props.savedFilters['taskBundleFilters']) {
+ this.props.removeSavedFilters('taskBundleFilters')
+ }
+ }
+
+ // Alternate clear function to maintain map zoom and bounding box when clearing filters
+ clearAllFiltersAndMaintainMapState = () => {
+
+ // Runs upstream clear function, specifically in HOCs that manage filter state and pass filter statuses to this component.
+ if (this.props.clearAllFilters) {
+ this.props.clearAllFilters()
+ }
+
+ const newCriteria = _cloneDeep(DEFAULT_CRITERIA)
+
+ newCriteria.boundingBox = this.state.criteria.boundingBox
+ newCriteria.zoom = this.state.criteria.zoom
+ newCriteria.filters["status"] = _keys(_pickBy(this.props.includeTaskStatuses, (s) => s))
+ newCriteria.filters["reviewStatus"] = _keys(_pickBy(this.props.includeReviewStatuses, (r) => r))
+ newCriteria.filters["metaReviewStatus"] = _keys(_pickBy(this.props.includeMetaReviewStatuses, (r) => r))
+ newCriteria.filters["priorities"] = _keys(_pickBy(this.props.includeTaskPriorities, (p) => p))
+ newCriteria.sortCriteria = {sortBy: 'name', direction: 'DESC'}
+
+ this.setState({criteria: newCriteria, loading: true})
+
+ // If using saved filters, the clear function needs to also reset them to default
+ if(this.props.saveCurrentSearchFilters && this.props.savedFilters['taskBundleFilters']) {
+ this.props.removeSavedFilters('taskBundleFilters')
+ }
+ }
+
+ changePageSize = (pageSize) => {
+ const typedCriteria = _cloneDeep(this.state.criteria)
+ typedCriteria.pageSize = pageSize
+ this.setState({criteria: typedCriteria})
+ }
+
+ setFiltered = (column, value) => {
+ const typedCriteria = _cloneDeep(this.state.criteria)
+ typedCriteria.filters[column] = value
+
+ //Reset Page so it goes back to 0
+ typedCriteria.page = 0
+ this.setState({criteria: typedCriteria})
+ }
+
+ updateIncludedFilters(props, criteria = {}) {
+ const typedCriteria = _merge({}, criteria, _cloneDeep(this.state.criteria))
+ typedCriteria.filters["status"] = _keys(_pickBy(props.includeTaskStatuses, (s) => s))
+ typedCriteria.filters["reviewStatus"] = _keys(_pickBy(props.includeTaskReviewStatuses, (r) => r))
+ typedCriteria.filters["metaReviewStatus"] = _keys(_pickBy(props.includeMetaReviewStatuses, (r) => r))
+ typedCriteria.filters["priorities"] = _keys(_pickBy(props.includeTaskPriorities, (p) => p))
+ this.setState({criteria: typedCriteria})
+ return typedCriteria
+ }
+
+ refreshTasks = (typedCriteria) => {
+ const challengeId = _get(this.props, 'challenge.id') || this.props.challengeId
+
+ this.setState({loading: true})
+ const criteria = typedCriteria || _cloneDeep(this.state.criteria)
+ criteria.filters.archived = true;
+
+ // If we don't have bounds yet, we still want results so let's fetch all
+ // tasks globally for this challenge.
+ if (!criteria.boundingBox) {
+ if (skipInitialFetch || !challengeId) {
+ return
+ }
+ criteria.boundingBox = GLOBAL_MAPBOUNDS
+ }
+
+ this.debouncedTasksFetch(challengeId, criteria, this.state.criteria.pageSize)
+ }
+
+ // Debouncing to give a chance for filters and bounds to all be applied before
+ // making the server call.
+ debouncedTasksFetch = _debounce(
+ (challengeId, criteria, pageSize) => {
+ this.props.augmentClusteredTasks(challengeId, false,
+ criteria,
+ pageSize,
+ false, ignoreLocked).then(() => {
+ this.setState({loading: false})
+ })
+ }, 800)
+
+ updateCriteriaFromSavedFilters(filterString) {
+ const criteria = buildSearchCriteriafromURL(filterString)
+
+ // These values will come in as comma-separated strings and need to be turned
+ // into number arrays
+ _each(["status", "reviewStatus", "metaReviewStatus", "priorities", "boundingBox"], key => {
+ if (!_isUndefined(criteria[key]) && key === "boundingBox") {
+ if (typeof criteria[key] === "string") {
+ criteria[key] = criteria[key].split(',').map(x => parseFloat(x))
+ }
+ }
+ else if (!_isUndefined(_get(criteria, `filters.${key}`))) {
+ if (typeof criteria.filters[key] === "string") {
+ criteria.filters[key] = criteria.filters[key].split(',').map(x => _toInteger(x))
+ }
+ }
+ })
+
+ if (!_get(criteria, 'filters.status')) {
+ this.updateIncludedFilters(this.props)
+ }
+ else {
+ this.setState({criteria})
+ }
+ }
+
+ componentDidMount() {
+
+ if(this.props.savedFilters && this.props.savedFilters['taskBundleFilters']) {
+ this.updateCriteriaFromSavedFilters(this.props.savedFilters['taskBundleFilters'])
+ }
+ else {
+ this.updateIncludedFilters(this.props)
+ }
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ const challengeId = _get(this.props, 'challenge.id') || this.props.challengeId
+ if (!challengeId) {
+ return
+ }
+
+ let typedCriteria = _cloneDeep(this.state.criteria)
+
+ if (prevProps.includeTaskStatuses !== this.props.includeTaskStatuses ||
+ prevProps.includeTaskReviewStatuses !== this.props.includeTaskReviewStatuses ||
+ prevProps.includeMetaReviewStatuses !== this.props.includeMetaReviewStatuses ||
+ prevProps.includeTaskPriorities !== this.props.includeTaskPriorities) {
+ typedCriteria = this.updateIncludedFilters(this.props)
+ return
+ }
+
+ if (!_isEqual(prevState.criteria, this.state.criteria) && !this.props.skipRefreshTasks) {
+ this.refreshTasks(typedCriteria)
+ }
+ else if (_get(prevProps, 'challenge.id') !== _get(this.props, 'challenge.id') ||
+ this.props.challengeId !== prevProps.challengeId) {
+ this.refreshTasks(typedCriteria)
+ this.clearAllFilters()
+ }
+ else if (_get(this.props.history.location, 'state.refreshAfterSave')) {
+ this.refreshTasks(typedCriteria)
+ }
+ }
+
+ render() {
+ const criteria = _cloneDeep(this.state.criteria) || DEFAULT_CRITERIA
+
+ return (
+ )
+ }
+ }
+ }
+
+export default (WrappedComponent, ignoreURL, ignoreLocked, skipInitialFetch) =>
+ WithFilterCriteria(WrappedComponent, ignoreURL, ignoreLocked, skipInitialFetch)
diff --git a/src/components/HOCs/WithPersistedFilteredClusteredTasks/WithPersistedFilteredClusteredTasks.js b/src/components/HOCs/WithPersistedFilteredClusteredTasks/WithPersistedFilteredClusteredTasks.js
new file mode 100644
index 000000000..daf1a6262
--- /dev/null
+++ b/src/components/HOCs/WithPersistedFilteredClusteredTasks/WithPersistedFilteredClusteredTasks.js
@@ -0,0 +1,387 @@
+import React, { Component } from 'react'
+import _get from 'lodash/get'
+import _map from 'lodash/map'
+import _filter from 'lodash/filter'
+import _fromPairs from 'lodash/fromPairs'
+import _isEmpty from 'lodash/isEmpty'
+import _isArray from 'lodash/isArray'
+import _omit from 'lodash/omit'
+import _isUndefined from 'lodash/isUndefined'
+import _isEqual from 'lodash/isEqual'
+import _isFinite from 'lodash/isFinite'
+import _cloneDeep from 'lodash/cloneDeep'
+import _assignWith from 'lodash/assignWith'
+import _each from 'lodash/each'
+import _toInteger from 'lodash/toInteger'
+import { TaskStatus } from '../../../services/Task/TaskStatus/TaskStatus'
+import { TaskReviewStatusWithUnset, REVIEW_STATUS_NOT_SET, META_REVIEW_STATUS_NOT_SET,
+ TaskMetaReviewStatusWithUnset }
+ from '../../../services/Task/TaskReview/TaskReviewStatus'
+import { TaskPriority } from '../../../services/Task/TaskPriority/TaskPriority'
+import { buildSearchCriteriafromURL } from '../../../services/SearchCriteria/SearchCriteria'
+
+
+/**
+ * WithPersistedFilteredClusteredTasks applies local filters to the given clustered
+ * tasks, along with a `toggleIncludedTaskStatus` and `toggleIncludedPriority`
+ * functions for toggling filtering on and off for a given status or priority,
+ * and a 'toggleTaskSelection' for toggling whether a specific task should be
+ * considered as selected. It is similar to WithFilteredClusteredTasks except that
+ * instead of setting up filters based on the URL itself, it uses saved filters
+ * from user app settings if present.
+ *
+ * The filter and selection settings are passed down in the `includeTaskStatuses`,
+ * `includeTaskPriorities`, and `selectedTasks` props. By default, all statuses
+ * and priorities are enabled (so tasks in any status and priority will pass through)
+ * and no tasks are selected.
+ *
+ * @author [Neil Rotstan](https://github.com/nrotstan)
+ * @author [Andrew Philbin](https://github.com/AndrewPhilbin)
+ */
+export default function WithFilteredClusteredTasks(WrappedComponent,
+ tasksProp='clusteredTasks',
+ outputProp,
+ initialFilters) {
+ return class extends Component {
+ defaultFilters = () => {
+ return {
+ includeStatuses: _get(initialFilters, 'statuses',
+ _fromPairs(_map(TaskStatus, status => [status, true]))),
+ includeReviewStatuses: _get(initialFilters, 'reviewStatuses',
+ _fromPairs(_map(TaskReviewStatusWithUnset, status => [status, true]))),
+ includeMetaReviewStatuses: _get(initialFilters, 'metaReviewStatuses',
+ _fromPairs(_map(TaskMetaReviewStatusWithUnset, status => [status, true]))),
+ includePriorities: _get(initialFilters, 'priorities',
+ _fromPairs(_map(TaskPriority, priority => [priority, true]))),
+ includeLocked: _get(initialFilters, 'includeLocked', true),
+ }
+ }
+
+ state = Object.assign({}, this.defaultFilters(), {
+ filteredTasks: {tasks: []},
+ })
+
+ /**
+ * Toggle filtering on or off for the given task status
+ */
+ toggleIncludedStatus = (status, exclusiveSelect = false) => {
+ const includeStatuses = exclusiveSelect ?
+ _assignWith(
+ {},
+ this.state.includeStatuses,
+ (objValue, srcValue, key) => key === status.toString()
+ ) :
+ Object.assign(
+ {},
+ this.state.includeStatuses,
+ {[status]: !this.state.includeStatuses[status]}
+ )
+
+ const filteredTasks = this.filterTasks(includeStatuses,
+ this.state.includeReviewStatuses,
+ this.state.includeMetaReviewStatuses,
+ this.state.includePriorities,
+ this.state.includeLocked)
+ this.setState({includeStatuses, filteredTasks})
+
+ // If task selection is active, prune any selections that no longer pass filters
+ this.props.pruneSelectedTasks && this.props.pruneSelectedTasks(task =>
+ !this.taskPassesFilters(
+ task,
+ includeStatuses,
+ this.state.includeReviewStatuses,
+ this.state.includeMetaReviewStatuses,
+ this.state.includePriorities,
+ this.state.includeLocked
+ )
+ )
+ }
+
+ /**
+ * Toggle filtering on or off for the given task review status
+ */
+ toggleIncludedReviewStatus = (status, exclusiveSelect = false) => {
+ const includeReviewStatuses = exclusiveSelect ?
+ _assignWith(
+ {},
+ this.state.includeReviewStatuses,
+ (objValue, srcValue, key) => key === status.toString()
+ ) :
+ Object.assign(
+ {},
+ this.state.includeReviewStatuses,
+ {[status]: !this.state.includeReviewStatuses[status]}
+ )
+
+ const filteredTasks = this.filterTasks(this.state.includeStatuses,
+ includeReviewStatuses,
+ this.state.includeMetaReviewStatuses,
+ this.state.includePriorities,
+ this.state.includeLocked)
+
+ this.setState({includeReviewStatuses, filteredTasks})
+
+ // If task selection is active, prune any selections that no longer pass filters
+ this.props.pruneSelectedTasks && this.props.pruneSelectedTasks(task =>
+ !this.taskPassesFilters(
+ task,
+ this.state.includeStatuses,
+ includeReviewStatuses,
+ this.state.includeMetaReviewStatuses,
+ this.state.includePriorities,
+ this.state.includeLocked
+ )
+ )
+ }
+
+ /**
+ * Toggle filtering on or off for the given meta review status
+ */
+ toggleIncludedMetaReviewStatus = (status, exclusiveSelect = false) => {
+ const includeMetaReviewStatuses = exclusiveSelect ?
+ _assignWith(
+ {},
+ this.state.includeMetaReviewStatuses,
+ (objValue, srcValue, key) => key === status.toString()
+ ) :
+ Object.assign(
+ {},
+ this.state.includeMetaReviewStatuses,
+ {[status]: !this.state.includeMetaReviewStatuses[status]}
+ )
+
+ const filteredTasks = this.filterTasks(this.state.includeStatuses,
+ this.state.includeReviewStatuses,
+ includeMetaReviewStatuses,
+ this.state.includePriorities,
+ this.state.includeLocked)
+
+ this.setState({includeMetaReviewStatuses, filteredTasks})
+
+ // If task selection is active, prune any selections that no longer pass filters
+ this.props.pruneSelectedTasks && this.props.pruneSelectedTasks(task =>
+ !this.taskPassesFilters(
+ task,
+ this.state.includeStatuses,
+ this.state.includeReviewStatuses,
+ includeMetaReviewStatuses,
+ this.state.includePriorities,
+ this.state.includeLocked
+ )
+ )
+ }
+
+ /**
+ * Toggle filtering on or off for the given task priority
+ */
+ toggleIncludedPriority = (priority, exclusiveSelect) => {
+ const includePriorities = exclusiveSelect ?
+ _assignWith(
+ {},
+ this.state.includePriorities,
+ (objValue, srcValue, key) => key === priority.toString()
+ ) :
+ Object.assign(
+ {},
+ this.state.includePriorities,
+ {[priority]: !this.state.includePriorities[priority]}
+ )
+
+ const filteredTasks = this.filterTasks(this.state.includeStatuses,
+ this.state.includeReviewStatuses,
+ this.state.includeMetaReviewStatuses,
+ includePriorities,
+ this.state.includeLocked)
+
+ this.setState({includePriorities, filteredTasks})
+
+ // If task selection is active, prune any selections that no longer pass filters
+ this.props.pruneSelectedTasks && this.props.pruneSelectedTasks(task =>
+ !this.taskPassesFilters(
+ task,
+ this.state.includeStatuses,
+ this.state.includeReviewStatuses,
+ this.state.includeMetaReviewStatuses,
+ includePriorities,
+ this.state.includeLocked
+ )
+ )
+ }
+
+ /**
+ * Filters the tasks, returning only those that match both the given
+ * statuses and priorites.
+ */
+ filterTasks = (includeStatuses, includeReviewStatuses, includeMetaReviewStatuses,
+ includePriorities, includeLocked) => {
+ let results = {tasks: []}
+ let tasks = _cloneDeep(_get(this.props[tasksProp], 'tasks'))
+ if (_isArray(tasks)) {
+ results = Object.assign({}, this.props[tasksProp], {
+ tasks: _filter(tasks, task =>
+ this.taskPassesFilters(task, includeStatuses, includeReviewStatuses,
+ includeMetaReviewStatuses, includePriorities, includeLocked)
+ )
+ })
+ }
+
+ return results
+ }
+
+ /**
+ * Determines if the given task passes all active filters, returning true if it does
+ */
+ taskPassesFilters = (task, includeStatuses, includeReviewStatuses, includeMetaReviewStatuses,
+ includePriorities, includeLocked) => {
+ return (
+ includeStatuses[task.status] && includePriorities[task.priority] &&
+ ((_isUndefined(task.reviewStatus) && includeReviewStatuses[REVIEW_STATUS_NOT_SET]) ||
+ includeReviewStatuses[task.reviewStatus]) &&
+ ((_isUndefined(task.metaReviewStatus) && includeMetaReviewStatuses[META_REVIEW_STATUS_NOT_SET]) ||
+ includeMetaReviewStatuses[task.metaReviewStatus]) &&
+ (includeLocked || !_isFinite(task.lockedBy) || task.lockedBy === _get(this.props, 'user.id'))
+ )
+ }
+
+ clearAllFilters = () => {
+ const freshFilters = this.defaultFilters()
+ const filteredTasks = this.filterTasks(freshFilters.includeStatuses,
+ freshFilters.includeReviewStatuses,
+ freshFilters.includeMetaReviewStatuses,
+ freshFilters.includePriorities,
+ freshFilters.includeLocked)
+
+ this.setState(Object.assign({filteredTasks}, freshFilters))
+
+ // Reset any task selections as well
+ this.props.resetSelectedTasks && this.props.resetSelectedTasks()
+ }
+
+ /**
+ * Refresh the currently filtered tasks, intended to be used after task
+ * statuses or other filterable criteria have been changed
+ */
+ refreshFilteredTasks = () => {
+ const filteredTasks = this.filterTasks(this.state.includeStatuses,
+ this.state.includeReviewStatuses,
+ this.state.includeMetaReviewStatuses,
+ this.state.includePriorities,
+ this.state.includeLocked)
+
+ this.setState({filteredTasks})
+
+ // If task selection is active, prune any selections that no longer pass filters
+ this.props.pruneSelectedTasks && this.props.pruneSelectedTasks(task =>
+ !this.taskPassesFilters(
+ task,
+ this.state.includeStatuses,
+ this.state.includeReviewStatuses,
+ this.state.includeMetaReviewStatuses,
+ this.state.includePriorities,
+ this.state.includeLocked
+ )
+ )
+ }
+
+ setupFilters = () => {
+ let useSavedFilters = false
+
+ // Instead of using a URL to populate persisted filters, we get a string from
+ // the WithSavedFilters HOC if present and build the filter values from it.
+ // If there are no saved filters present, we set criteria to undefined so that
+ // we use initial state to filter tasks instead.
+ const criteria =
+ this.props.savedFilters && this.props.savedFilters['taskBundleFilters'] ?
+ buildSearchCriteriafromURL(this.props.savedFilters['taskBundleFilters']) :
+ undefined
+
+ // These values will come in as comma-separated strings and need to be turned
+ // into number arrays
+ _each(["status", "reviewStatus", "metaReviewStatus", "priorities"], key => {
+ if (!_isUndefined(_get(criteria, `filters.${key}`))) {
+ if (typeof criteria.filters[key] === "string") {
+ criteria.filters[key] = criteria.filters[key].split(',').map(x => _toInteger(x))
+ }
+ else if (_isFinite(criteria.filters[key])) {
+ criteria.filters[key] = [criteria.filters[key]]
+ }
+ useSavedFilters = true
+ }
+ })
+
+ if (useSavedFilters) {
+ const filteredTasks =
+ this.filterTasks(criteria.filters.status || this.state.includeStatuses,
+ criteria.filters.reviewStatus || this.state.includeReviewStatuses,
+ criteria.filters.metaReviewStatus || this.state.includeMetaReviewStatuses,
+ criteria.filters.priorities || this.state.includePriorities,
+ this.state.includeLocked)
+
+ // Statuses to be shown in drop down need to appear in this list,
+ // so we include all the initialFilters.statuses but mark them false
+ // (unchecked) and then only mark the ones from our criteria as true.
+ // If saved filters are present we use the criteria status filters built
+ // from the saved filter string instead.
+ const includeStatuses =
+ this.props.savedFilters && this.props.savedFilters['taskBundleFilters'] ?
+ _fromPairs(_map(criteria.filters.status, status => [status, true])) :
+ _get(initialFilters, 'statuses',
+ _fromPairs(_map(TaskStatus, status => [status, false])))
+
+ _each(criteria.filters.status, status => {
+ includeStatuses[status] = true
+ })
+
+ this.setState(Object.assign({filteredTasks},
+ {
+ includeStatuses,
+ includeReviewStatuses: _fromPairs(_map(criteria.filters.reviewStatus, status => [status, true])),
+ includeMetaReviewStatuses: _fromPairs(_map(criteria.filters.metaReviewStatus, status => [status, true])),
+ includePriorities: _fromPairs(_map(criteria.filters.priorities, priority => [priority, true])),
+ }
+ ))
+ }
+ else {
+ const filteredTasks =
+ this.filterTasks(this.state.includeStatuses,
+ this.state.includeReviewStatuses,
+ this.state.includeMetaReviewStatuses,
+ this.state.includePriorities,
+ this.state.includeLocked)
+ this.setState({filteredTasks})
+ }
+ }
+
+ componentDidMount() {
+ this.setupFilters()
+ }
+
+ componentDidUpdate(prevProps) {
+ if (!_isEqual(_get(prevProps[tasksProp], 'tasks'), _get(this.props[tasksProp], 'tasks')) ||
+ _get(prevProps[tasksProp], 'fetchId') !== _get(this.props[tasksProp], 'fetchId')) {
+ this.refreshFilteredTasks()
+ }
+ }
+
+ render() {
+ if (_isEmpty(outputProp)) {
+ outputProp = tasksProp
+ }
+
+ return
+ }
+ }
+}
diff --git a/src/components/TaskFilters/TaskStatusFilter.js b/src/components/TaskFilters/TaskStatusFilter.js
index eb7dd2766..b27e350d0 100644
--- a/src/components/TaskFilters/TaskStatusFilter.js
+++ b/src/components/TaskFilters/TaskStatusFilter.js
@@ -3,10 +3,15 @@ import { FormattedMessage } from 'react-intl'
import FilterDropdown from './FilterDropdown'
import _map from 'lodash/map'
import _keys from 'lodash/keys'
-import { messagesByStatus } from '../../services/Task/TaskStatus/TaskStatus'
+import { TaskStatus, messagesByStatus } from '../../services/Task/TaskStatus/TaskStatus'
import messages from './Messages'
-
+// Allowed task status options are more limited in the context of the task bundling widget
+const VALID_TASK_BUNDLE_TASK_STATUSES = {
+ created: [TaskStatus.created],
+ skipped: [TaskStatus.skipped],
+ tooHard: [TaskStatus.tooHard]
+}
/**
* TaskStatusFilter builds a dropdown for searching by task status
*
@@ -14,11 +19,17 @@ import messages from './Messages'
*/
export default class TaskStatusFilter extends Component {
render() {
+ const {isUsedInTaskBundleContext} = this.props
+ const taskStatusOptions =
+ isUsedInTaskBundleContext ?
+ VALID_TASK_BUNDLE_TASK_STATUSES :
+ _keys(this.props.includeTaskStatuses)
+
return (
}
filters={
- _map(_keys(this.props.includeTaskStatuses), status => (
+ _map(taskStatusOptions, status => (