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 => (