From 6b1ebbabb7f4a0838f3d0208a35d8cde58d12dbe Mon Sep 17 00:00:00 2001 From: konrad Date: Sat, 26 Sep 2020 21:02:37 +0000 Subject: [PATCH] Saved filters (#239) Fix saving Cleanup Fix single value prepare Add prepare percent done stub Fix populating filters with saved values when editing for single values Fix populating filters with saved values when editing Add edit filter view page Hide adding new tasks to pseudolists Make sure all filter values are passed as strings as per requirement from the api Add redirect to list after creating it Add creating saved filter Add filter by percent done Add end date filter Add start date filter Add extra checkbox to enable/disable priority filter Add changing priority Add more filter stubs Fix dates for filters Add saved filter create form Add include nulls and concat to filter options Add new saved filter component Co-authored-by: kolaente Reviewed-on: https://kolaente.dev/vikunja/frontend/pulls/239 Co-Authored-By: konrad Co-Committed-By: konrad --- src/components/list/partials/filters.vue | 294 +++++++++++++++++++---- src/helpers/savedFilter.js | 9 + src/models/list.js | 9 + src/models/savedFilter.js | 47 ++++ src/router/index.js | 9 +- src/services/savedFilter.js | 38 +++ src/styles/components/namespaces.scss | 1 + src/views/filters/CreateSavedFilter.vue | 121 ++++++++++ src/views/filters/EditSavedFilter.vue | 157 ++++++++++++ src/views/list/EditListView.vue | 25 ++ src/views/list/views/List.vue | 4 +- src/views/namespaces/ListNamespaces.vue | 6 + 12 files changed, 676 insertions(+), 44 deletions(-) create mode 100644 src/helpers/savedFilter.js create mode 100644 src/models/savedFilter.js create mode 100644 src/services/savedFilter.js create mode 100644 src/views/filters/CreateSavedFilter.vue create mode 100644 src/views/filters/EditSavedFilter.vue create mode 100644 src/views/list/EditListView.vue diff --git a/src/components/list/partials/filters.vue b/src/components/list/partials/filters.vue index d4a5ce066..546e2f850 100644 --- a/src/components/list/partials/filters.vue +++ b/src/components/list/partials/filters.vue @@ -1,6 +1,15 @@ @@ -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 000000000..93e1f7a7c --- /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 8e395e871..cbae65cff 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 000000000..f0896b08e --- /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 283f34b78..969042229 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 000000000..e9c85f6f4 --- /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 1c68d3bf2..a85974054 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 000000000..9aad96b81 --- /dev/null +++ b/src/views/filters/CreateSavedFilter.vue @@ -0,0 +1,121 @@ + + + diff --git a/src/views/filters/EditSavedFilter.vue b/src/views/filters/EditSavedFilter.vue new file mode 100644 index 000000000..5a7097092 --- /dev/null +++ b/src/views/filters/EditSavedFilter.vue @@ -0,0 +1,157 @@ + + + diff --git a/src/views/list/EditListView.vue b/src/views/list/EditListView.vue new file mode 100644 index 000000000..e7c4506f0 --- /dev/null +++ b/src/views/list/EditListView.vue @@ -0,0 +1,25 @@ + + + + diff --git a/src/views/list/views/List.vue b/src/views/list/views/List.vue index fe7076cd1..51ea7ecac 100644 --- a/src/views/list/views/List.vue +++ b/src/views/list/views/List.vue @@ -48,7 +48,7 @@ -
+

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 b691dbae4..2190eb069 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