Board filter (#1507)
* filter field Signed-off-by: Jakob Röhrl <jakob.roehrl@web.de> * build filters Signed-off-by: Jakob Röhrl <jakob.roehrl@web.de> * Implement tag and assigned user filters Signed-off-by: Julius Härtl <jus@bitgrid.net> * small changes Signed-off-by: Jakob Röhrl <jakob.roehrl@web.de> * new icon Signed-off-by: Jakob Röhrl <jakob.roehrl@web.de> * Properly style filter popover Signed-off-by: Julius Härtl <jus@bitgrid.net> * Make sure that due is reactive Signed-off-by: Julius Härtl <jus@bitgrid.net> * filers are working now :) Signed-off-by: Jakob Röhrl <jakob.roehrl@web.de> Co-authored-by: Julius Härtl <jus@bitgrid.net>
This commit is contained in:
@@ -47,6 +47,102 @@
|
||||
</form>
|
||||
</div>
|
||||
<div class="board-action-buttons">
|
||||
<Popover>
|
||||
<Actions slot="trigger">
|
||||
<ActionButton icon="icon-filter" :title="t('deck', 'Apply filter')" />
|
||||
</Actions>
|
||||
|
||||
<template>
|
||||
<div class="filter">
|
||||
<h3>{{ t('deck', 'Filter by tag') }}</h3>
|
||||
<div v-for="label in board.labels" :key="label.id" class="filter--item">
|
||||
<input
|
||||
:id="label.id"
|
||||
v-model="filter.tags"
|
||||
type="checkbox"
|
||||
class="checkbox"
|
||||
:value="label.id"
|
||||
@change="setFilter">
|
||||
<label :for="label.id"><span class="label" :style="labelStyle(label)">{{ label.title }}</span></label>
|
||||
</div>
|
||||
|
||||
<h3>{{ t('deck', 'Filter by assigned user') }}</h3>
|
||||
<div v-for="user in board.users" :key="user.uid" class="filter--item">
|
||||
<input
|
||||
:id="user.uid"
|
||||
v-model="filter.users"
|
||||
type="checkbox"
|
||||
class="checkbox"
|
||||
:value="user.uid"
|
||||
@change="setFilter">
|
||||
<label :for="user.uid"><Avatar :user="user.uid" :size="24" :disable-menu="true" /> {{ user.displayname }}</label>
|
||||
</div>
|
||||
|
||||
<h3>{{ t('deck', 'Filter by duedate') }}</h3>
|
||||
|
||||
<div class="filter--item">
|
||||
<input
|
||||
id="overdue"
|
||||
v-model="filter.due"
|
||||
type="radio"
|
||||
class="radio"
|
||||
value="overdue"
|
||||
@change="setFilter"
|
||||
@click="beforeSetFilter">
|
||||
<label for="overdue">{{ t('deck', 'Overdue') }}</label>
|
||||
</div>
|
||||
|
||||
<div class="filter--item">
|
||||
<input
|
||||
id="dueToday"
|
||||
v-model="filter.due"
|
||||
type="radio"
|
||||
class="radio"
|
||||
value="dueToday"
|
||||
@change="setFilter"
|
||||
@click="beforeSetFilter">
|
||||
<label for="dueToday">{{ t('deck', 'Today') }}</label>
|
||||
</div>
|
||||
|
||||
<div class="filter--item">
|
||||
<input
|
||||
id="dueWeek"
|
||||
v-model="filter.due"
|
||||
type="radio"
|
||||
class="radio"
|
||||
value="dueWeek"
|
||||
@change="setFilter"
|
||||
@click="beforeSetFilter">
|
||||
<label for="dueWeek">{{ t('deck', 'Next 7 days') }}</label>
|
||||
</div>
|
||||
|
||||
<div class="filter--item">
|
||||
<input
|
||||
id="dueMonth"
|
||||
v-model="filter.due"
|
||||
type="radio"
|
||||
class="radio"
|
||||
value="dueMonth"
|
||||
@change="setFilter"
|
||||
@click="beforeSetFilter">
|
||||
<label for="dueMonth">{{ t('deck', 'Next 30 days') }}</label>
|
||||
</div>
|
||||
|
||||
<div class="filter--item">
|
||||
<input
|
||||
id="noDue"
|
||||
v-model="filter.due"
|
||||
type="radio"
|
||||
class="radio"
|
||||
value="noDue"
|
||||
@change="setFilter"
|
||||
@click="beforeSetFilter">
|
||||
<label for="noDue">{{ t('deck', 'No due date') }}</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
|
||||
<Actions style="opacity: .5;">
|
||||
<ActionButton v-if="showArchived"
|
||||
icon="icon-archive"
|
||||
@@ -69,7 +165,7 @@
|
||||
</Actions>
|
||||
<!-- FIXME: ActionRouter currently doesn't work as an inline action -->
|
||||
<Actions>
|
||||
<ActionButton icon="icon-share" @click="toggleDetailsView" />
|
||||
<ActionButton icon="icon-menu-sidebar" @click="toggleDetailsView" />
|
||||
</Actions>
|
||||
</div>
|
||||
</div>
|
||||
@@ -78,13 +174,15 @@
|
||||
|
||||
<script>
|
||||
import { mapState, mapGetters } from 'vuex'
|
||||
import { Actions, ActionButton } from '@nextcloud/vue'
|
||||
import { Actions, ActionButton, Popover, Avatar } from '@nextcloud/vue'
|
||||
import labelStyle from '../mixins/labelStyle'
|
||||
|
||||
export default {
|
||||
name: 'Controls',
|
||||
components: {
|
||||
Actions, ActionButton,
|
||||
Actions, ActionButton, Popover, Avatar,
|
||||
},
|
||||
mixins: [ labelStyle ],
|
||||
props: {
|
||||
board: {
|
||||
type: Object,
|
||||
@@ -98,8 +196,10 @@ export default {
|
||||
stack: '',
|
||||
showArchived: false,
|
||||
isAddStackVisible: false,
|
||||
filter: { tags: [], users: [], due: '' },
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapGetters([
|
||||
'canEdit',
|
||||
@@ -115,6 +215,15 @@ export default {
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
beforeSetFilter(e) {
|
||||
if (this.filter.due === e.target.value) {
|
||||
this.filter.due = ''
|
||||
this.$store.dispatch('setFilter', { ...this.filter })
|
||||
}
|
||||
},
|
||||
setFilter() {
|
||||
this.$nextTick(() => this.$store.dispatch('setFilter', { ...this.filter }))
|
||||
},
|
||||
toggleNav() {
|
||||
this.$store.dispatch('toggleNav')
|
||||
},
|
||||
@@ -210,5 +319,34 @@ export default {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.filter--item {
|
||||
input + label {
|
||||
display: block;
|
||||
padding: 6px 0;
|
||||
vertical-align: middle;
|
||||
.avatardiv {
|
||||
vertical-align: middle;
|
||||
margin-bottom: 2px;
|
||||
margin-right: 3px;
|
||||
}
|
||||
.label {
|
||||
padding: 5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.filter {
|
||||
width: 250px;
|
||||
max-height: 80vh;
|
||||
overflow: auto;
|
||||
}
|
||||
.filter h3 {
|
||||
margin-top: 0px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
</style>
|
||||
<style lang="scss">
|
||||
.tooltip-inner.popover-inner {
|
||||
text-align: left;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -105,6 +105,7 @@ import axios from '@nextcloud/axios'
|
||||
|
||||
import CardBadges from './CardBadges'
|
||||
import Color from '../../mixins/color'
|
||||
import labelStyle from '../../mixins/labelStyle'
|
||||
|
||||
export default {
|
||||
name: 'CardItem',
|
||||
@@ -112,7 +113,7 @@ export default {
|
||||
directives: {
|
||||
ClickOutside,
|
||||
},
|
||||
mixins: [Color],
|
||||
mixins: [Color, labelStyle],
|
||||
props: {
|
||||
id: {
|
||||
type: Number,
|
||||
@@ -150,14 +151,6 @@ export default {
|
||||
menu() {
|
||||
return []
|
||||
},
|
||||
labelStyle() {
|
||||
return (label) => {
|
||||
return {
|
||||
backgroundColor: '#' + label.color,
|
||||
color: this.textColor(label.color),
|
||||
}
|
||||
}
|
||||
},
|
||||
currentCard() {
|
||||
return this.$route.params.cardId === this.id
|
||||
},
|
||||
|
||||
37
src/mixins/labelStyle.js
Normal file
37
src/mixins/labelStyle.js
Normal file
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* @copyright Copyright (c) 2020 Julius Härtl <jus@bitgrid.net>
|
||||
*
|
||||
* @author Julius Härtl <jus@bitgrid.net>
|
||||
*
|
||||
* @license GNU AGPL version 3 or any later version
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
import Color from './color'
|
||||
|
||||
export default {
|
||||
mixins: [ Color ],
|
||||
computed: {
|
||||
labelStyle() {
|
||||
return (label) => {
|
||||
return {
|
||||
backgroundColor: '#' + label.color,
|
||||
color: this.textColor(label.color),
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -30,9 +30,56 @@ export default {
|
||||
cards: [],
|
||||
},
|
||||
getters: {
|
||||
cardsByStack: (state, getters) => (id) => {
|
||||
return state.cards.filter((card) => card.stackId === id && (getters.getSearchQuery === '' || (card.title.toLowerCase().includes(getters.getSearchQuery.toLowerCase()) || card.description.toLowerCase().includes(getters.getSearchQuery.toLowerCase())))
|
||||
).sort((a, b) => a.order - b.order)
|
||||
cardsByStack: (state, getters, rootState) => (id) => {
|
||||
return state.cards.filter((card) => {
|
||||
const { tags, users, due } = rootState.filter
|
||||
let allTagsMatch = true
|
||||
let allUsersMatch = true
|
||||
|
||||
if (tags.length > 0) {
|
||||
tags.forEach((tag) => {
|
||||
if (card.labels.findIndex((l) => l.id === tag) === -1) {
|
||||
allTagsMatch = false
|
||||
}
|
||||
})
|
||||
if (!allTagsMatch) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (users.length > 0) {
|
||||
users.forEach((user) => {
|
||||
if (card.assignedUsers.findIndex((u) => u.participant.uid === user) === -1) {
|
||||
allUsersMatch = false
|
||||
}
|
||||
})
|
||||
if (!allUsersMatch) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (due !== '') {
|
||||
const datediffHour = ((new Date(card.duedate) - new Date()) / 3600000)
|
||||
switch (due) {
|
||||
case 'noDue':
|
||||
return (card.duedate === null)
|
||||
case 'overdue':
|
||||
return (card.overdue === 3)
|
||||
case 'dueToday':
|
||||
return (card.overdue >= 2)
|
||||
case 'dueWeek':
|
||||
return (datediffHour <= 168 && card.duedate !== null)
|
||||
case 'dueMonth':
|
||||
return (datediffHour <= 5040 && card.duedate !== null)
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
.filter((card) => card.stackId === id && (getters.getSearchQuery === ''
|
||||
|| (card.title.toLowerCase().includes(getters.getSearchQuery.toLowerCase())
|
||||
|| card.description.toLowerCase().includes(getters.getSearchQuery.toLowerCase()))
|
||||
.sort((a, b) => a.order - b.order)))
|
||||
},
|
||||
cardById: state => (id) => {
|
||||
return state.cards.find((card) => card.id === id)
|
||||
|
||||
@@ -60,11 +60,17 @@ export default new Vuex.Store({
|
||||
assignableUsers: [],
|
||||
boardFilter: BOARD_FILTERS.ALL,
|
||||
searchQuery: '',
|
||||
activity: [],
|
||||
activityLoadMore: true,
|
||||
filter: { tags: [], users: [], due: '' },
|
||||
},
|
||||
getters: {
|
||||
getSearchQuery: state => {
|
||||
return state.searchQuery
|
||||
},
|
||||
getFilter: state => {
|
||||
return state.filter
|
||||
},
|
||||
boards: state => {
|
||||
return state.boards
|
||||
},
|
||||
@@ -112,6 +118,9 @@ export default new Vuex.Store({
|
||||
setSearchQuery(state, searchQuery) {
|
||||
state.searchQuery = searchQuery
|
||||
},
|
||||
setFilter(state, filter) {
|
||||
Object.assign(state.filter, filter)
|
||||
},
|
||||
toggleShowArchived(state) {
|
||||
state.showArchived = !state.showArchived
|
||||
},
|
||||
@@ -232,6 +241,9 @@ export default new Vuex.Store({
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
setFilter({ commit }, filter) {
|
||||
commit('setFilter', filter)
|
||||
},
|
||||
async loadBoardById({ commit }, boardId) {
|
||||
commit('setCurrentBoard', null)
|
||||
const board = await apiClient.loadById(boardId)
|
||||
|
||||
Reference in New Issue
Block a user