+
+ Include Tasks which don't have a value set
+
+
+ Require all filters to be true for a task to show up
+
@@ -21,6 +30,62 @@
/>
+
+
+
+
+
+ Enable Filter By Priority
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Enable Filter By Percent Done
+
+
+
@@ -30,11 +95,17 @@ import Fancycheckbox from '../../input/fancycheckbox'
import flatPickr from 'vue-flatpickr-component'
import 'flatpickr/dist/flatpickr.css'
+import {formatISO} from 'date-fns'
+import PrioritySelect from '@/components/tasks/partials/prioritySelect'
+import PercentDoneSelect from '@/components/tasks/partials/percentDoneSelect'
+
export default {
name: 'filters',
components: {
+ PrioritySelect,
Fancycheckbox,
flatPickr,
+ PercentDoneSelect,
},
data() {
return {
@@ -44,10 +115,19 @@ export default {
filter_by: [],
filter_value: [],
filter_comparator: [],
+ filter_include_nulls: true,
+ filter_concat: 'or',
},
filters: {
done: false,
dueDate: '',
+ requireAllFilters: false,
+ priority: 0,
+ usePriority: false,
+ startDate: '',
+ endDate: '',
+ percentDone: 0,
+ usePercentDone: false,
},
flatPickerConfig: {
altFormat: 'j M Y H:i',
@@ -61,7 +141,8 @@ export default {
},
mounted() {
this.params = this.value
- this.prepareDone()
+ this.filters.requireAllFilters = this.params.filter_concat === 'and'
+ this.prepareFilters()
},
props: {
value: {
@@ -71,7 +152,7 @@ export default {
watch: {
value(newVal) {
this.$set(this, 'params', newVal)
- this.prepareDone()
+ this.prepareFilters()
},
},
methods: {
@@ -79,42 +160,29 @@ export default {
this.$emit('input', this.params)
this.$emit('change', this.params)
},
- prepareDone() {
- // Set filters.done based on params
- if (typeof this.params.filter_by !== 'undefined') {
- let foundDone = false
- this.params.filter_by.forEach((f, i) => {
- if (f === 'done') {
- foundDone = i
- }
- })
- if (foundDone === false) {
- this.filters.done = true
+ prepareFilters() {
+ this.prepareDone()
+ this.prepareDueDate()
+ this.prepareStartDate()
+ this.prepareEndDate()
+ this.preparePriority()
+ this.preparePercentDone()
+ },
+ removePropertyFromFilter(propertyName) {
+ for (const i in this.params.filter_by) {
+ if (this.params.filter_by[i] === propertyName) {
+ this.params.filter_by.splice(i, 1)
+ this.params.filter_comparator.splice(i, 1)
+ this.params.filter_value.splice(i, 1)
+ break
}
}
},
- setDoneFilter() {
- if (this.filters.done) {
- for (const i in this.params.filter_by) {
- if (this.params.filter_by[i] === 'done') {
- this.params.filter_by.splice(i, 1)
- this.params.filter_comparator.splice(i, 1)
- this.params.filter_value.splice(i, 1)
- break
- }
- }
- } else {
- this.params.filter_by.push('done')
- this.params.filter_comparator.push('equals')
- this.params.filter_value.push('false')
- }
- this.change()
- },
- setDueDateFilter() {
+ setDateFilter(filterName, variableName) {
// Only filter if we have a start and end due date
- if (this.filters.dueDate !== '') {
+ if (this.filters[variableName] !== '') {
- const parts = this.filters.dueDate.split(' to ')
+ const parts = this.filters[variableName].split(' to ')
if (parts.length < 2) {
return
@@ -124,29 +192,173 @@ export default {
let foundStart = false
let foundEnd = false
this.params.filter_by.forEach((f, i) => {
- if (f === 'due_date' && this.params.filter_comparator[i] === 'greater_equals') {
+ if (f === filterName && this.params.filter_comparator[i] === 'greater_equals') {
foundStart = true
- this.params.filter_value[i] = +new Date(parts[0]) / 1000
+ this.$set(this.params.filter_value, i, formatISO(new Date(parts[0])))
}
- if (f === 'due_date' && this.params.filter_comparator[i] === 'less_equals') {
+ if (f === filterName && this.params.filter_comparator[i] === 'less_equals') {
foundEnd = true
- this.params.filter_value[i] = +new Date(parts[1]) / 1000
+ this.$set(this.params.filter_value, i, formatISO(new Date(parts[1])))
}
})
if (!foundStart) {
- this.params.filter_by.push('due_date')
+ this.params.filter_by.push(filterName)
this.params.filter_comparator.push('greater_equals')
- this.params.filter_value.push(+new Date(parts[0]) / 1000)
+ this.params.filter_value.push(formatISO(new Date(parts[0])))
}
if (!foundEnd) {
- this.params.filter_by.push('due_date')
+ this.params.filter_by.push(filterName)
this.params.filter_comparator.push('less_equals')
- this.params.filter_value.push(+new Date(parts[1]) / 1000)
+ this.params.filter_value.push(formatISO(new Date(parts[1])))
}
this.change()
}
},
+ prepareDate(filterName, variableName) {
+ if (typeof this.params.filter_by === 'undefined') {
+ return
+ }
+
+ let foundDateStart = false
+ let foundDateEnd = false
+ for (const i in this.params.filter_by) {
+ if (this.params.filter_by[i] === filterName && this.params.filter_comparator[i] === 'greater_equals') {
+ foundDateStart = i
+ }
+ if (this.params.filter_by[i] === filterName && this.params.filter_comparator[i] === 'less_equals') {
+ foundDateEnd = i
+ }
+
+ if (foundDateStart !== false && foundDateEnd !== false) {
+ break
+ }
+ }
+
+ if (foundDateStart !== false && foundDateEnd !== false) {
+ const start = new Date(this.params.filter_value[foundDateStart])
+ const end = new Date(this.params.filter_value[foundDateEnd])
+ this.filters[variableName] = `${start.getFullYear()}-${start.getMonth() + 1}-${start.getDate()} to ${end.getFullYear()}-${end.getMonth() + 1}-${end.getDate()}`
+ }
+ },
+ setSingleValueFilter(filterName, variableName, useVariableName) {
+ if (!this.filters[useVariableName]) {
+ this.removePropertyFromFilter(filterName)
+ return
+ }
+
+ let found = false
+ this.params.filter_by.forEach((f, i) => {
+ if (f === filterName) {
+ found = true
+ this.$set(this.params.filter_value, i, this.filters[variableName])
+ }
+ })
+
+ if (!found) {
+ this.params.filter_by.push(filterName)
+ this.params.filter_comparator.push('equals')
+ this.params.filter_value.push(this.filters[variableName])
+ }
+
+ this.change()
+ },
+ prepareSingleValue(filterName, variableName, useVariableName, isNumber = false) {
+ let found = false
+ for (const i in this.params.filter_by) {
+ if (this.params.filter_by[i] === filterName) {
+ found = i
+ break
+ }
+ }
+
+ if (found === false) {
+ this.filters[useVariableName] = false
+ return
+ }
+
+ if (isNumber) {
+ this.filters[variableName] = Number(this.params.filter_value[found])
+ } else {
+ this.filters[variableName] = this.params.filter_value[found]
+ }
+
+ this.filters[useVariableName] = true
+ },
+ prepareDone() {
+ // Set filters.done based on params
+ if (typeof this.params.filter_by === 'undefined') {
+ return
+ }
+
+ let foundDone = false
+ this.params.filter_by.forEach((f, i) => {
+ if (f === 'done') {
+ foundDone = i
+ }
+ })
+ if (foundDone === false) {
+ this.$set(this.filters, 'done', true)
+ }
+ },
+ setDoneFilter() {
+ if (this.filters.done) {
+ this.removePropertyFromFilter('done')
+ } else {
+ this.params.filter_by.push('done')
+ this.params.filter_comparator.push('equals')
+ this.params.filter_value.push('false')
+ }
+ this.change()
+ },
+ setFilterConcat() {
+ if (this.filters.requireAllFilters) {
+ this.params.filter_concat = 'and'
+ } else {
+ this.params.filter_concat = 'or'
+ }
+ },
+ setDueDateFilter() {
+ this.setDateFilter('due_date', 'dueDate')
+ },
+ setPriority() {
+ this.setSingleValueFilter('priority', 'priority', 'usePriority')
+ },
+ setStartDateFilter() {
+ this.setDateFilter('start_date', 'startDate')
+ },
+ setEndDateFilter() {
+ this.setDateFilter('end_date', 'endDate')
+ },
+ setPercentDoneFilter() {
+ this.setSingleValueFilter('percent_done', 'percentDone', 'usePercentDone')
+ },
+ prepareDueDate() {
+ this.prepareDate('due_date', 'dueDate')
+ },
+ preparePriority() {
+ this.prepareSingleValue('priority', 'priority', 'usePriority', true)
+ },
+ prepareStartDate() {
+ this.prepareDate('start_date', 'startDate')
+ },
+ prepareEndDate() {
+ this.prepareDate('end_date', 'endDate')
+ },
+ preparePercentDone() {
+ this.prepareSingleValue('percent_done', 'percentDone', 'usePercentDone', true)
+ },
},
}
+
+
diff --git a/src/helpers/savedFilter.js b/src/helpers/savedFilter.js
new file mode 100644
index 00000000..93e1f7a7
--- /dev/null
+++ b/src/helpers/savedFilter.js
@@ -0,0 +1,9 @@
+
+export function getSavedFilterIdFromListId(listId) {
+ let filterId = listId * -1 - 1
+ // FilterIds from listIds are always positive
+ if (filterId < 0) {
+ filterId = 0
+ }
+ return filterId
+}
\ No newline at end of file
diff --git a/src/models/list.js b/src/models/list.js
index 8e395e87..cbae65cf 100644
--- a/src/models/list.js
+++ b/src/models/list.js
@@ -1,6 +1,7 @@
import AbstractModel from './abstractModel'
import TaskModel from './task'
import UserModel from './user'
+import {getSavedFilterIdFromListId} from '@/helpers/savedFilter'
export default class ListModel extends AbstractModel {
@@ -41,4 +42,12 @@ export default class ListModel extends AbstractModel {
updated: null,
}
}
+
+ isSavedFilter() {
+ return this.getSavedFilterId() > 0
+ }
+
+ getSavedFilterId() {
+ return getSavedFilterIdFromListId(this.id)
+ }
}
\ No newline at end of file
diff --git a/src/models/savedFilter.js b/src/models/savedFilter.js
new file mode 100644
index 00000000..f0896b08
--- /dev/null
+++ b/src/models/savedFilter.js
@@ -0,0 +1,47 @@
+import AbstractModel from '@/models/abstractModel'
+import UserModel from '@/models/user'
+
+export default class SavedFilterModel extends AbstractModel {
+ constructor(data) {
+ super(data)
+
+ this.owner = new UserModel(this.owner)
+
+ this.created = new Date(this.created)
+ this.updated = new Date(this.updated)
+ }
+
+ defaults() {
+ return {
+ id: 0,
+ title: '',
+ description: '',
+ filters: {
+ sortBy: ['done', 'id'],
+ orderBy: ['asc', 'desc'],
+ filterBy: ['done'],
+ filterValue: ['false'],
+ filterComparator: ['equals'],
+ filterConcat: 'and',
+ filterIncludeNulls: true,
+ },
+
+ owner: {},
+ created: null,
+ updated: null,
+ }
+ }
+
+ /**
+ * Calculates the corresponding list id to this saved filter.
+ * This function matches the one in the api.
+ * @returns {number}
+ */
+ getListId() {
+ let listId = this.id * -1 - 1
+ if (listId > 0) {
+ listId = 0
+ }
+ return listId
+ }
+}
diff --git a/src/router/index.js b/src/router/index.js
index 283f34b7..96904222 100644
--- a/src/router/index.js
+++ b/src/router/index.js
@@ -27,6 +27,8 @@ import Kanban from '../views/list/views/Kanban'
import List from '../views/list/views/List'
import Gantt from '../views/list/views/Gantt'
import Table from '../views/list/views/Table'
+// Saved Filters
+import CreateSavedFilter from '@/views/filters/CreateSavedFilter'
const PasswordResetComponent = () => ({
component: import(/* webpackPrefetch: true *//* webpackChunkName: "user-settings" */'../views/user/PasswordReset'),
@@ -54,7 +56,7 @@ const NewListComponent = () => ({
timeout: 60000,
})
const EditListComponent = () => ({
- component: import(/* webpackPrefetch: true *//* webpackChunkName: "settings" */'../views/list/EditList'),
+ component: import(/* webpackPrefetch: true *//* webpackChunkName: "settings" */'../views/list/EditListView'),
loading: LoadingComponent,
error: ErrorComponent,
timeout: 60000,
@@ -260,5 +262,10 @@ export default new Router({
name: 'migrate.service',
component: MigrateServiceComponent,
},
+ {
+ path: '/filters/new',
+ name: 'filters.create',
+ component: CreateSavedFilter,
+ },
],
})
\ No newline at end of file
diff --git a/src/services/savedFilter.js b/src/services/savedFilter.js
new file mode 100644
index 00000000..e9c85f6f
--- /dev/null
+++ b/src/services/savedFilter.js
@@ -0,0 +1,38 @@
+import AbstractService from '@/services/abstractService'
+import SavedFilterModel from '@/models/savedFilter'
+import {objectToCamelCase} from '@/helpers/case'
+
+export default class SavedFilterService extends AbstractService {
+ constructor() {
+ super({
+ get: '/filters/{id}',
+ create: '/filters',
+ update: '/filters/{id}',
+ delete: '/filters/{id}',
+ })
+ }
+
+ modelFactory(data) {
+ return new SavedFilterModel(data)
+ }
+
+ processModel(model) {
+ // Make filters from this.filters camelCase and set them to the model property:
+ // That's easier than making the whole filter component configurable since that still needs to provide
+ // the filter values in snake_sćase for url parameters.
+ model.filters = objectToCamelCase(model.filters)
+
+ // Make sure all filterValues are passes as strings. This is a requirement of the api.
+ model.filters.filterValue = model.filters.filterValue.map(v => String(v))
+
+ return model
+ }
+
+ beforeUpdate(model) {
+ return this.processModel(model)
+ }
+
+ beforeCreate(model) {
+ return this.processModel(model)
+ }
+}
diff --git a/src/styles/components/namespaces.scss b/src/styles/components/namespaces.scss
index 1c68d3bf..a8597405 100644
--- a/src/styles/components/namespaces.scss
+++ b/src/styles/components/namespaces.scss
@@ -3,6 +3,7 @@ $lists-per-row: 5;
.namespaces-list {
.button.new-namespace {
float: right;
+ margin-left: 1rem;
@media screen and (max-width: $mobile) {
float: none;
diff --git a/src/views/filters/CreateSavedFilter.vue b/src/views/filters/CreateSavedFilter.vue
new file mode 100644
index 00000000..9aad96b8
--- /dev/null
+++ b/src/views/filters/CreateSavedFilter.vue
@@ -0,0 +1,121 @@
+
+
+
+
+
+
+
Create A Saved Filter
+
+
+
+ A saved filter is a virtual list which is computed from a set of filters each time it is
+ accessed. Once created, it will appear in a special namespace.
+
state.currentList.maxRight > Rights.READ,
+ list: state => state.currentList,
}),
methods: {
// This function initializes the tasks page and loads the first page of tasks
diff --git a/src/views/namespaces/ListNamespaces.vue b/src/views/namespaces/ListNamespaces.vue
index b691dbae..2190eb06 100644
--- a/src/views/namespaces/ListNamespaces.vue
+++ b/src/views/namespaces/ListNamespaces.vue
@@ -6,6 +6,12 @@
Create new namespace
+
+
+
+
+ Create a new saved filter
+
Show Archived