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 <k@knt.li>
Reviewed-on: https://kolaente.dev/vikunja/frontend/pulls/239
Co-Authored-By: konrad <konrad@kola-entertainments.de>
Co-Committed-By: konrad <konrad@kola-entertainments.de>
This commit is contained in:
konrad 2020-09-26 21:02:37 +00:00
parent 06524b5cc9
commit 6b1ebbabb7
12 changed files with 676 additions and 44 deletions

View file

@ -1,6 +1,15 @@
<template>
<div class="card filters">
<div class="card-content">
<fancycheckbox v-model="params.filter_include_nulls">
Include Tasks which don't have a value set
</fancycheckbox>
<fancycheckbox
v-model="filters.requireAllFilters"
@change="setFilterConcat()"
>
Require all filters to be true for a task to show up
</fancycheckbox>
<div class="field">
<label class="label">Show Done Tasks</label>
<div class="control">
@ -21,6 +30,62 @@
/>
</div>
</div>
<div class="field">
<label class="label">Priority</label>
<div class="control single-value-control">
<priority-select
:disabled="!filters.usePriority"
v-model.number="filters.priority"
@change="setPriority"
/>
<fancycheckbox
v-model="filters.usePriority"
@change="setPriority"
>
Enable Filter By Priority
</fancycheckbox>
</div>
</div>
<div class="field">
<label class="label">Start Date</label>
<div class="control">
<flat-pickr
:config="flatPickerConfig"
@on-close="setStartDateFilter"
class="input"
placeholder="Start Date Range"
v-model="filters.startDate"
/>
</div>
</div>
<div class="field">
<label class="label">End Date</label>
<div class="control">
<flat-pickr
:config="flatPickerConfig"
@on-close="setEndDateFilter"
class="input"
placeholder="End Date Range"
v-model="filters.endDate"
/>
</div>
</div>
<div class="field">
<label class="label">Percent Done</label>
<div class="control single-value-control">
<percent-done-select
v-model.number="filters.percentDone"
@change="setPercentDoneFilter"
:disabled="!filters.usePercentDone"
/>
<fancycheckbox
v-model="filters.usePercentDone"
@change="setPercentDoneFilter"
>
Enable Filter By Percent Done
</fancycheckbox>
</div>
</div>
</div>
</div>
</template>
@ -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)
},
},
}
</script>
<style lang="scss">
.single-value-control {
display: flex;
align-items: center;
.fancycheckbox {
margin-left: .5rem;
}
}
</style>

View file

@ -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
}

View file

@ -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)
}
}

47
src/models/savedFilter.js Normal file
View file

@ -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
}
}

View file

@ -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,
},
],
})

View file

@ -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)
}
}

View file

@ -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;

View file

@ -0,0 +1,121 @@
<template>
<div class="modal-mask keyboard-shortcuts-modal">
<div @click.self="$router.back()" class="modal-container">
<div class="modal-content">
<div class="card has-background-white has-no-shadow">
<header class="card-header">
<p class="card-header-title">Create A Saved Filter</p>
</header>
<div class="card-content content">
<p>
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.
</p>
<div class="field">
<label class="label" for="title">Title</label>
<div class="control">
<input
v-model="savedFilter.title"
:class="{ 'disabled': savedFilterService.loading}"
:disabled="savedFilterService.loading"
class="input"
id="Title"
placeholder="The saved filter title goes here..."
type="text"
v-focus
/>
</div>
</div>
<div class="field">
<label class="label" for="description">Description</label>
<div class="control">
<editor
v-model="savedFilter.description"
:class="{ 'disabled': savedFilterService.loading}"
:disabled="savedFilterService.loading"
:preview-is-default="false"
id="description"
placeholder="The description goes here..."
v-if="editorActive"
/>
</div>
</div>
<div class="field">
<label class="label" for="filters">Filters</label>
<div class="control">
<filters
:class="{ 'disabled': savedFilterService.loading}"
:disabled="savedFilterService.loading"
class="has-no-shadow has-no-border"
v-model="filters"
/>
</div>
</div>
<button
:class="{ 'disabled': savedFilterService.loading}"
:disabled="savedFilterService.loading"
@click="create()"
class="button is-primary is-fullwidth">
Create new saved filter
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import LoadingComponent from '@/components/misc/loading'
import ErrorComponent from '@/components/misc/error'
import Filters from '@/components/list/partials/filters'
import SavedFilterService from '@/services/savedFilter'
import SavedFilterModel from '@/models/savedFilter'
export default {
name: 'CreateSavedFilter',
data() {
return {
editorActive: false,
filters: {
sort_by: ['done', 'id'],
order_by: ['asc', 'desc'],
filter_by: ['done'],
filter_value: ['false'],
filter_comparator: ['equals'],
filter_concat: 'and',
filter_include_nulls: true,
},
savedFilterService: SavedFilterService,
savedFilter: SavedFilterModel,
}
},
components: {
Filters,
editor: () => ({
component: import(/* webpackPrefetch: true *//* webpackChunkName: "editor" */ '../../components/input/editor'),
loading: LoadingComponent,
error: ErrorComponent,
timeout: 60000,
}),
},
created() {
this.editorActive = false
this.$nextTick(() => this.editorActive = true)
this.savedFilterService = new SavedFilterService()
this.savedFilter = new SavedFilterModel()
},
methods: {
create() {
this.savedFilter.filters = this.filters
this.savedFilterService.create(this.savedFilter)
.then(r => {
this.$store.dispatch('namespaces/loadNamespaces')
this.$router.push({name: 'list.index', params: {listId: r.getListId()}})
})
.catch(e => this.error(e, this))
},
},
}
</script>

View file

@ -0,0 +1,157 @@
<template>
<div :class="{ 'is-loading': filterService.loading}" class="loader-container edit-list is-max-width-desktop">
<div class="card">
<header class="card-header">
<p class="card-header-title">
Edit Saved Filter
</p>
</header>
<div class="card-content">
<div class="content">
<form @submit.prevent="save()">
<div class="field">
<label class="label" for="listtext">Filter Name</label>
<div class="control">
<input
:class="{ 'disabled': filterService.loading}"
:disabled="filterService.loading"
@keyup.enter="save"
class="input"
id="listtext"
placeholder="The list title goes here..."
type="text"
v-focus
v-model="filter.title"/>
</div>
</div>
<div class="field">
<label class="label" for="listdescription">Description</label>
<div class="control">
<editor
:class="{ 'disabled': filterService.loading}"
:disabled="filterService.loading"
:preview-is-default="false"
id="listdescription"
placeholder="The lists description goes here..."
v-model="filter.description"
/>
</div>
</div>
<div class="field">
<label class="label" for="filters">Filters</label>
<div class="control">
<filters
:class="{ 'disabled': filterService.loading}"
:disabled="filterService.loading"
class="has-no-shadow has-no-border"
v-model="filters"
/>
</div>
</div>
</form>
<div class="columns bigbuttons">
<div class="column">
<button :class="{ 'is-loading': filterService.loading}" @click="save()"
class="button is-primary is-fullwidth">
Save
</button>
</div>
<div class="column is-1">
<button :class="{ 'is-loading': filterService.loading}" @click="showDeleteModal = true"
class="button is-danger is-fullwidth">
<span class="icon is-small">
<icon icon="trash-alt"/>
</span>
</button>
</div>
</div>
</div>
</div>
</div>
<modal
@close="showDeleteModal = false"
@submit="deleteList()"
v-if="showDeleteModal">
<span slot="header">Delete this saved filter</span>
<p slot="text">
Are you sure you want to delete this saved filter?
</p>
</modal>
</div>
</template>
<script>
import ErrorComponent from '../../components/misc/error'
import LoadingComponent from '../../components/misc/loading'
import SavedFilterModel from '@/models/savedFilter'
import SavedFilterService from '@/services/savedFilter'
import ListModel from '@/models/list'
import Filters from '@/components/list/partials/filters'
import {objectToSnakeCase} from '@/helpers/case'
export default {
name: 'EditFilter',
data() {
return {
filter: SavedFilterModel,
filterService: SavedFilterService,
filters: {
sort_by: ['done', 'id'],
order_by: ['asc', 'desc'],
filter_by: ['done'],
filter_value: ['false'],
filter_comparator: ['equals'],
filter_concat: 'and',
filter_include_nulls: true,
},
showDeleteModal: false,
}
},
components: {
Filters,
editor: () => ({
component: import(/* webpackPrefetch: true *//* webpackChunkName: "editor" */ '../../components/input/editor'),
loading: LoadingComponent,
error: ErrorComponent,
timeout: 60000,
}),
},
created() {
this.filterService = new SavedFilterService()
this.loadSavedFilter()
},
watch: {
// call again the method if the route changes
'$route': 'loadSavedFilter',
},
methods: {
loadSavedFilter() {
// We assume the listId in the route is the pseudolist
const list = new ListModel({id: this.$route.params.id})
this.filter = new SavedFilterModel({id: list.getSavedFilterId()})
this.filterService.get(this.filter)
.then(r => {
this.filter = r
this.filters = objectToSnakeCase(this.filter.filters)
})
.catch(e => this.error(e, this))
},
save() {
this.filter.filters = this.filters
this.filterService.update(this.filter)
.then(r => {
this.$store.dispatch('namespaces/loadNamespaces')
this.success({message: 'The filter was saved successfully.'}, this)
this.filter = r
this.filters = objectToSnakeCase(this.filter.filters)
})
.catch(e => this.error(e, this))
},
},
}
</script>

View file

@ -0,0 +1,25 @@
<template>
<div>
<edit-filter v-if="isSavedFilter"/>
<edit-list v-else/>
</div>
</template>
<script>
import EditList from '@/views/list/EditList'
import EditFilter from '@/views/filters/EditSavedFilter'
import {mapState} from 'vuex'
import {getSavedFilterIdFromListId} from '@/helpers/savedFilter'
export default {
name: 'EditListView',
components: {
EditFilter,
EditList,
},
computed: mapState({
isSavedFilter: state => getSavedFilterIdFromListId(state.currentList.id) > 0
})
}
</script>

View file

@ -48,7 +48,7 @@
</transition>
</div>
<div class="field task-add" v-if="!list.isArchived && canWrite">
<div class="field task-add" v-if="!list.isArchived && canWrite && list.id > 0">
<div class="field is-grouped">
<p :class="{ 'is-loading': taskService.loading}" class="control has-icons-left is-expanded">
<input
@ -183,7 +183,6 @@ export default {
data() {
return {
taskService: TaskService,
list: {},
isTaskEdit: false,
taskEditTask: TaskModel,
newTaskText: '',
@ -212,6 +211,7 @@ export default {
},
computed: mapState({
canWrite: state => state.currentList.maxRight > Rights.READ,
list: state => state.currentList,
}),
methods: {
// This function initializes the tasks page and loads the first page of tasks

View file

@ -6,6 +6,12 @@
</span>
Create new namespace
</router-link>
<router-link :to="{name: 'filters.create'}" class="button is-primary new-namespace">
<span class="icon is-small">
<icon icon="filter"/>
</span>
Create a new saved filter
</router-link>
<fancycheckbox class="show-archived-check" v-model="showArchived">
Show Archived