Task FIlters (#149)

Set done filter based on passed params

Make due date filter actually work

Move filters into seperate config

Merge branch 'master' into feature/task-filters

Change done task filter text

Make sure done tasks are always shown in table view

Table view filter improvements

Add done filter to table view

Fix indent

Add filter icon

Move search and filter container

Add filter for done tasks

Hide done tasks by default

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: vikunja/frontend#149
This commit is contained in:
konrad 2020-06-11 15:34:13 +00:00
parent 55afb7adc4
commit ef01e8807e
8 changed files with 353 additions and 101 deletions

View File

@ -24,7 +24,7 @@
},
props: {
value: {
required: true,
required: false,
},
disabled: {
type: Boolean,
@ -52,7 +52,3 @@
},
}
</script>
<style scoped>
</style>

View File

@ -0,0 +1,152 @@
<template>
<div class="card filters">
<div class="card-content">
<div class="field">
<label class="label">Show Done Tasks</label>
<div class="control">
<fancycheckbox @change="setDoneFilter" v-model="filters.done">
Show Done Tasks
</fancycheckbox>
</div>
</div>
<div class="field">
<label class="label">Due Date</label>
<div class="control">
<flat-pickr
class="input"
:config="flatPickerConfig"
placeholder="Due Date Range"
v-model="filters.dueDate"
@on-close="setDueDateFilter"
/>
</div>
</div>
</div>
</div>
</template>
<script>
import Fancycheckbox from '../../global/fancycheckbox'
import flatPickr from 'vue-flatpickr-component'
import 'flatpickr/dist/flatpickr.css'
export default {
name: 'filters',
components: {
Fancycheckbox,
flatPickr,
},
data() {
return {
params: {
sort_by: [],
order_by: [],
filter_by: [],
filter_value: [],
filter_comparator: [],
},
filters: {
done: false,
dueDate: '',
},
flatPickerConfig: {
altFormat: 'j M Y H:i',
altInput: true,
dateFormat: 'Y-m-d H:i',
enableTime: true,
time_24hr: true,
mode: 'range',
},
}
},
mounted() {
this.params = this.value
this.prepareDone()
},
props: {
value: {
required: true,
}
},
watch: {
value(newVal) {
this.$set(this, 'params', newVal)
this.prepareDone()
}
},
methods: {
change() {
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
}
}
},
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() {
// Only filter if we have a start and end due date
if (this.filters.dueDate !== '') {
const parts = this.filters.dueDate.split(' to ')
if(parts.length < 2) {
return
}
// Check if we already have values in params and only update them if we do
let foundStart = false
let foundEnd = false
this.params.filter_by.forEach((f, i) => {
if (f === 'due_date' && this.params.filter_comparator[i] === 'greater_equals') {
foundStart = true
this.params.filter_value[i] = +new Date(parts[0]) / 1000
}
if (f === 'due_date' && this.params.filter_comparator[i] === 'less_equals') {
foundEnd = true
this.params.filter_value[i] = +new Date(parts[1]) / 1000
}
})
if (!foundStart) {
this.params.filter_by.push('due_date')
this.params.filter_comparator.push('greater_equals')
this.params.filter_value.push(+new Date(parts[0]) / 1000)
}
if (!foundEnd) {
this.params.filter_by.push('due_date')
this.params.filter_comparator.push('less_equals')
this.params.filter_value.push(+new Date(parts[1]) / 1000)
}
this.change()
}
},
},
}
</script>

View File

@ -1,40 +1,64 @@
<template>
<div class="loader-container" :class="{ 'is-loading': taskCollectionService.loading}">
<div class="search">
<div class="field has-addons" :class="{ 'hidden': !showTaskSearch }">
<div class="control has-icons-left has-icons-right">
<input
class="input"
type="text"
placeholder="Search"
v-focus
v-model="searchTerm"
@keyup.enter="searchTasks"
@blur="hideSearchBar()"/>
<span class="icon is-left">
<icon icon="search"/>
</span>
</div>
<div class="control">
<button
class="button noshadow is-primary"
@click="searchTasks"
:class="{'is-loading': taskCollectionService.loading}">
Search
<div class="filter-container">
<div class="items">
<div class="search">
<div class="field has-addons" :class="{ 'hidden': !showTaskSearch }">
<div class="control has-icons-left has-icons-right">
<input
class="input"
type="text"
placeholder="Search"
v-focus
v-model="searchTerm"
@keyup.enter="searchTasks"
@blur="hideSearchBar()"/>
<span class="icon is-left">
<icon icon="search"/>
</span>
</div>
<div class="control">
<button
class="button noshadow is-primary"
@click="searchTasks"
:class="{'is-loading': taskCollectionService.loading}">
Search
</button>
</div>
</div>
<button class="button" @click="showTaskSearch = !showTaskSearch" v-if="!showTaskSearch">
<span class="icon">
<icon icon="search"/>
</span>
</button>
</div>
<button class="button" @click="showTaskFilter = !showTaskFilter">
<span class="icon is-small">
<icon icon="filter"/>
</span>
Filters
</button>
</div>
<button class="button" @click="showTaskSearch = !showTaskSearch" v-if="!showTaskSearch">
<span class="icon">
<icon icon="search"/>
</span>
</button>
<transition name="fade">
<filters
v-if="showTaskFilter"
v-model="params"
@change="loadTasks(1)"
/>
</transition>
</div>
<div class="field task-add" v-if="!list.isArchived">
<div class="field is-grouped">
<p class="control has-icons-left is-expanded" :class="{ 'is-loading': taskService.loading}">
<input v-focus class="input" :class="{ 'disabled': taskService.loading}" v-model="newTaskText" type="text" placeholder="Add a new task..." @keyup.enter="addTask()"/>
<input
v-focus
class="input"
:class="{ 'disabled': taskService.loading}"
v-model="newTaskText"
type="text"
placeholder="Add a new task..."
@keyup.enter="addTask()"/>
<span class="icon is-small is-left">
<icon icon="tasks"/>
</span>
@ -85,14 +109,36 @@
</div>
</div>
<nav class="pagination is-centered" role="navigation" aria-label="pagination" v-if="taskCollectionService.totalPages > 1">
<router-link class="pagination-previous" :to="getRouteForPagination(currentPage - 1)" tag="button" :disabled="currentPage === 1">Previous</router-link>
<router-link class="pagination-next" :to="getRouteForPagination(currentPage + 1)" tag="button" :disabled="currentPage === taskCollectionService.totalPages">Next page</router-link>
<nav
class="pagination is-centered"
role="navigation"
aria-label="pagination"
v-if="taskCollectionService.totalPages > 1">
<router-link
class="pagination-previous"
:to="getRouteForPagination(currentPage - 1)"
tag="button"
:disabled="currentPage === 1">
Previous
</router-link>
<router-link
class="pagination-next"
:to="getRouteForPagination(currentPage + 1)"
tag="button"
:disabled="currentPage === taskCollectionService.totalPages">
Next page
</router-link>
<ul class="pagination-list">
<template v-for="(p, i) in pages">
<li :key="'page'+i" v-if="p.isEllipsis"><span class="pagination-ellipsis">&hellip;</span></li>
<li :key="'page'+i" v-else>
<router-link :to="getRouteForPagination(p.number)" :class="{'is-current': p.number === currentPage}" class="pagination-link" :aria-label="'Goto page ' + p.number">{{ p.number }}</router-link>
<router-link
:to="getRouteForPagination(p.number)"
:class="{'is-current': p.number === currentPage}"
class="pagination-link"
:aria-label="'Goto page ' + p.number">
{{ p.number }}
</router-link>
</li>
</template>
</ul>
@ -113,6 +159,7 @@
import SingleTaskInList from '../../tasks/reusable/singleTaskInList'
import taskList from '../../tasks/helpers/taskList'
import {saveListView} from '../../../helpers/saveListView'
import Filters from '../reusable/filters'
export default {
name: 'List',
@ -131,6 +178,7 @@
taskList,
],
components: {
Filters,
SingleTaskInList,
EditTask,
},

View File

@ -1,12 +1,20 @@
<template>
<div class="table-view loader-container" :class="{'is-loading': taskCollectionService.loading}">
<div class="column-filter">
<button class="button" @click="showActiveColumnsFilter = !showActiveColumnsFilter">
<span class="icon is-small">
<icon icon="th"/>
</span>
Columns
</button>
<div class="filter-container">
<div class="items">
<button class="button" @click="() => {showActiveColumnsFilter = !showActiveColumnsFilter; showTaskFilter = false}">
<span class="icon is-small">
<icon icon="th"/>
</span>
Columns
</button>
<button class="button" @click="() => {showTaskFilter = !showTaskFilter; showActiveColumnsFilter = false}">
<span class="icon is-small">
<icon icon="filter"/>
</span>
Filters
</button>
</div>
<transition name="fade">
<div class="card" v-if="showActiveColumnsFilter">
<div class="card-content">
@ -25,6 +33,11 @@
<fancycheckbox @change="saveTaskColumns" v-model="activeColumns.createdBy">Created By</fancycheckbox>
</div>
</div>
<filters
v-if="showTaskFilter"
v-model="params"
@change="loadTasks(1)"
/>
</transition>
</div>
@ -155,10 +168,12 @@
import Fancycheckbox from '../../global/fancycheckbox'
import Sort from '../../tasks/reusable/sort'
import {saveListView} from '../../../helpers/saveListView'
import Filters from '../reusable/filters'
export default {
name: 'Table',
components: {
Filters,
Sort,
Fancycheckbox,
DateTableCell,
@ -202,6 +217,10 @@
this.$set(this, 'sortBy', JSON.parse(savedSortBy))
}
this.$set(this.params, 'filter_by', [])
this.$set(this.params, 'filter_value', [])
this.$set(this.params, 'filter_comparator', [])
this.initTasks(1)
// Save the current list view to local storage
@ -210,7 +229,9 @@
},
methods: {
initTasks(page, search = '') {
let params = {sort_by: [], order_by: []}
const params = this.params
params.sort_by = []
params.order_by = []
Object.keys(this.sortBy).map(s => {
params.sort_by.push(s)
params.order_by.push(this.sortBy[s])

View File

@ -14,6 +14,16 @@ export default {
showTaskSearch: false,
searchTerm: '',
showTaskFilter: false,
params: {
sort_by: ['done', 'id'],
order_by: ['asc', 'desc'],
filter_by: ['done'],
filter_value: ['false'],
filter_comparator: ['equals'],
filter_concat: 'and',
},
}
},
watch: {
@ -28,7 +38,11 @@ export default {
this.taskCollectionService = new TaskCollectionService()
},
methods: {
loadTasks(page, search = '', params = {sort_by: ['done', 'id'], order_by: ['asc', 'desc']}) {
loadTasks(
page,
search = '',
params = null,
) {
// Because this function is triggered every time on navigation, we're putting a condition here to only load it when we actually want to show tasks
// FIXME: This is a bit hacky -> Cleanup.
@ -41,6 +55,10 @@ export default {
this.$set(this, 'tasks', [])
if (params === null) {
params = this.params
}
if (search !== '') {
params.s = search
}
@ -50,10 +68,10 @@ export default {
this.$set(this, 'pages', [])
this.currentPage = page
for (let i = 0; i < this.taskCollectionService.totalPages; i++) {
for (let i = 0; i < this.taskCollectionService.totalPages; i++) {
// Show ellipsis instead of all pages
if(
if (
i > 0 && // Always at least the first page
(i + 1) < this.taskCollectionService.totalPages && // And the last page
(
@ -63,7 +81,7 @@ export default {
)
) {
// Only add an ellipsis if the last page isn't already one
if(this.pages[i - 1] && !this.pages[i - 1].isEllipsis) {
if (this.pages[i - 1] && !this.pages[i - 1].isEllipsis) {
this.pages.push({
number: 0,
isEllipsis: true,
@ -98,7 +116,7 @@ export default {
if (this.tasks === null || this.tasks === []) {
return
}
return this.tasks.sort(function(a,b) {
return this.tasks.sort(function (a, b) {
if (a.done < b.done)
return -1
if (a.done > b.done)
@ -142,6 +160,6 @@ export default {
page: page,
},
}
}
},
}
}

View File

@ -66,6 +66,7 @@ import { faSort } from '@fortawesome/free-solid-svg-icons'
import { faSortUp } from '@fortawesome/free-solid-svg-icons'
import { faList } from '@fortawesome/free-solid-svg-icons'
import { faEllipsisV } from '@fortawesome/free-solid-svg-icons'
import { faFilter } from '@fortawesome/free-solid-svg-icons'
import { faComments } from '@fortawesome/free-regular-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
@ -111,6 +112,7 @@ library.add(faSort)
library.add(faSortUp)
library.add(faList)
library.add(faEllipsisV)
library.add(faFilter)
Vue.component('icon', FontAwesomeIcon)

View File

@ -37,35 +37,6 @@
height: 40px;
}
.search {
max-width: 300px;
margin-top: -58px;
float: right;
display: flex;
align-items: center;
justify-content: space-between;
.button, .input {
height: $switch-view-height;
}
.field {
transition: width $transition;
width: 100%;
&.hidden {
width: 0;
height: 0;
margin: 0;
overflow: hidden;
}
.button {
height: 100%;
}
}
}
.list-title {
display: flex;
align-items: center;
@ -85,3 +56,68 @@
.edit-list {
padding-bottom: 1em;
}
.filter-container {
text-align: right;
width: 100%;
min-width: 400px;
max-width: 180px;
position: absolute;
right: 1.5em;
margin-top: -58px;
z-index: 99;
.items {
display: flex;
justify-content: flex-end;
.button:not(:last-child) {
margin-right: .5em;
}
}
.button {
height: $switch-view-height;
}
.card {
text-align: left;
margin-top: calc(1rem - 1px);
float: right;
}
.fancycheckbox {
display: block;
}
.search {
display: flex;
align-items: center;
justify-content: space-between;
margin-right: .5em;
.button, .input {
height: $switch-view-height;
}
.field {
transition: width $transition;
width: 100%;
&.hidden {
width: 0;
height: 0;
margin: 0;
overflow: hidden;
}
.button {
height: 100%;
}
}
}
.filters input {
font-size: .9em;
}
}

View File

@ -13,26 +13,5 @@
margin: 0;
}
}
}
.column-filter {
text-align: right;
width: 100%;
max-width: 180px;
position: absolute;
right: 1.5em;
margin-top: -58px;
.button {
height: $switch-view-height;
}
.card {
text-align: left;
margin-top: 1em;
}
.fancycheckbox {
display: block;
}
}
}