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:
Jakob
2020-02-06 17:47:01 +01:00
committed by GitHub
parent 0a003fe491
commit 2a94c53d4e
8 changed files with 327 additions and 21 deletions

View File

@@ -59,6 +59,7 @@
@include icon-black-white('archive', 'deck', 1); @include icon-black-white('archive', 'deck', 1);
@include icon-black-white('circles', 'deck', 1); @include icon-black-white('circles', 'deck', 1);
@include icon-black-white('clone', 'deck', 1); @include icon-black-white('clone', 'deck', 1);
@include icon-black-white('filter', 'deck', 1);
@include icon-black-white('attach', 'deck', 1); @include icon-black-white('attach', 'deck', 1);
.icon-toggle-compact-collapsed { .icon-toggle-compact-collapsed {

78
img/filter.svg Normal file
View File

@@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="16"
height="16"
viewBox="0 0 4.2333332 4.2333335"
version="1.1"
id="svg4524"
inkscape:version="0.92.4 5da689c313, 2019-01-14"
sodipodi:docname="filter.svg">
<defs
id="defs4518" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="11.2"
inkscape:cx="-13.015771"
inkscape:cy="15.433087"
inkscape:document-units="mm"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
showguides="true"
inkscape:guide-bbox="true"
inkscape:window-width="1920"
inkscape:window-height="1017"
inkscape:window-x="0"
inkscape:window-y="26"
inkscape:window-maximized="1">
<sodipodi:guide
position="3.1773623,1.9016928"
orientation="0,1"
id="guide5088"
inkscape:locked="false" />
</sodipodi:namedview>
<metadata
id="metadata4521">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Ebene 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-292.76665)">
<path
style="opacity:1;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.09337848;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke fill markers"
d="M 0.51971728,293.23203 H 3.8033853 l -1.1728849,1.45285 H 1.6418341 Z"
id="rect5069"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ccccc" />
<path
style="opacity:1;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.05817544;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke fill markers"
d="m 1.6418341,294.68488 h 0.9921874 v 1.86627 L 1.637658,296.09596 Z"
id="rect5069-7"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ccccc" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

10
package-lock.json generated
View File

@@ -13118,7 +13118,7 @@
}, },
"mkdirp": { "mkdirp": {
"version": "0.5.1", "version": "0.5.1",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", "resolved": "http://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
"integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=",
"dev": true, "dev": true,
"requires": { "requires": {
@@ -13778,7 +13778,7 @@
}, },
"os-homedir": { "os-homedir": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz", "resolved": "http://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz",
"integrity": "sha1-/7xJiDNuDoM94MFox+8VISGqf7M=", "integrity": "sha1-/7xJiDNuDoM94MFox+8VISGqf7M=",
"dev": true "dev": true
}, },
@@ -13795,7 +13795,7 @@
}, },
"os-tmpdir": { "os-tmpdir": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", "resolved": "http://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz",
"integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=", "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=",
"dev": true "dev": true
}, },
@@ -13967,7 +13967,7 @@
}, },
"path-is-absolute": { "path-is-absolute": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "resolved": "http://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
"integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=" "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18="
}, },
"path-key": { "path-key": {
@@ -16710,7 +16710,7 @@
}, },
"string_decoder": { "string_decoder": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", "resolved": "http://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"requires": { "requires": {
"safe-buffer": "~5.1.0" "safe-buffer": "~5.1.0"

View File

@@ -47,6 +47,102 @@
</form> </form>
</div> </div>
<div class="board-action-buttons"> <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;"> <Actions style="opacity: .5;">
<ActionButton v-if="showArchived" <ActionButton v-if="showArchived"
icon="icon-archive" icon="icon-archive"
@@ -69,7 +165,7 @@
</Actions> </Actions>
<!-- FIXME: ActionRouter currently doesn't work as an inline action --> <!-- FIXME: ActionRouter currently doesn't work as an inline action -->
<Actions> <Actions>
<ActionButton icon="icon-share" @click="toggleDetailsView" /> <ActionButton icon="icon-menu-sidebar" @click="toggleDetailsView" />
</Actions> </Actions>
</div> </div>
</div> </div>
@@ -78,13 +174,15 @@
<script> <script>
import { mapState, mapGetters } from 'vuex' 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 { export default {
name: 'Controls', name: 'Controls',
components: { components: {
Actions, ActionButton, Actions, ActionButton, Popover, Avatar,
}, },
mixins: [ labelStyle ],
props: { props: {
board: { board: {
type: Object, type: Object,
@@ -98,8 +196,10 @@ export default {
stack: '', stack: '',
showArchived: false, showArchived: false,
isAddStackVisible: false, isAddStackVisible: false,
filter: { tags: [], users: [], due: '' },
} }
}, },
computed: { computed: {
...mapGetters([ ...mapGetters([
'canEdit', 'canEdit',
@@ -115,6 +215,15 @@ export default {
}, },
}, },
methods: { 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() { toggleNav() {
this.$store.dispatch('toggleNav') this.$store.dispatch('toggleNav')
}, },
@@ -210,5 +319,34 @@ export default {
background-color: transparent; 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> </style>

View File

@@ -105,6 +105,7 @@ import axios from '@nextcloud/axios'
import CardBadges from './CardBadges' import CardBadges from './CardBadges'
import Color from '../../mixins/color' import Color from '../../mixins/color'
import labelStyle from '../../mixins/labelStyle'
export default { export default {
name: 'CardItem', name: 'CardItem',
@@ -112,7 +113,7 @@ export default {
directives: { directives: {
ClickOutside, ClickOutside,
}, },
mixins: [Color], mixins: [Color, labelStyle],
props: { props: {
id: { id: {
type: Number, type: Number,
@@ -150,14 +151,6 @@ export default {
menu() { menu() {
return [] return []
}, },
labelStyle() {
return (label) => {
return {
backgroundColor: '#' + label.color,
color: this.textColor(label.color),
}
}
},
currentCard() { currentCard() {
return this.$route.params.cardId === this.id return this.$route.params.cardId === this.id
}, },

37
src/mixins/labelStyle.js Normal file
View 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),
}
}
},
},
}

View File

@@ -30,9 +30,56 @@ export default {
cards: [], cards: [],
}, },
getters: { getters: {
cardsByStack: (state, getters) => (id) => { cardsByStack: (state, getters, rootState) => (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()))) return state.cards.filter((card) => {
).sort((a, b) => a.order - b.order) 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) => { cardById: state => (id) => {
return state.cards.find((card) => card.id === id) return state.cards.find((card) => card.id === id)

View File

@@ -60,11 +60,17 @@ export default new Vuex.Store({
assignableUsers: [], assignableUsers: [],
boardFilter: BOARD_FILTERS.ALL, boardFilter: BOARD_FILTERS.ALL,
searchQuery: '', searchQuery: '',
activity: [],
activityLoadMore: true,
filter: { tags: [], users: [], due: '' },
}, },
getters: { getters: {
getSearchQuery: state => { getSearchQuery: state => {
return state.searchQuery return state.searchQuery
}, },
getFilter: state => {
return state.filter
},
boards: state => { boards: state => {
return state.boards return state.boards
}, },
@@ -112,6 +118,9 @@ export default new Vuex.Store({
setSearchQuery(state, searchQuery) { setSearchQuery(state, searchQuery) {
state.searchQuery = searchQuery state.searchQuery = searchQuery
}, },
setFilter(state, filter) {
Object.assign(state.filter, filter)
},
toggleShowArchived(state) { toggleShowArchived(state) {
state.showArchived = !state.showArchived state.showArchived = !state.showArchived
}, },
@@ -232,6 +241,9 @@ export default new Vuex.Store({
}, },
}, },
actions: { actions: {
setFilter({ commit }, filter) {
commit('setFilter', filter)
},
async loadBoardById({ commit }, boardId) { async loadBoardById({ commit }, boardId) {
commit('setCurrentBoard', null) commit('setCurrentBoard', null)
const board = await apiClient.loadById(boardId) const board = await apiClient.loadById(boardId)