diff --git a/src/components/list/partials/filters.vue b/src/components/list/partials/filters.vue index d4a5ce066f..546e2f8501 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 0000000000..93e1f7a7c7 --- /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 8e395e8716..cbae65cfff 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 0000000000..f0896b08e5 --- /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 283f34b788..9690422299 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 0000000000..e9c85f6f46 --- /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 1c68d3bf2e..a85974054b 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 0000000000..9aad96b81d --- /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 0000000000..5a7097092f --- /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 0000000000..e7c4506f05 --- /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 fe7076cd1e..51ea7ecac1 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 b691dbae42..2190eb0698 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