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:
@@ -59,6 +59,7 @@
|
||||
@include icon-black-white('archive', 'deck', 1);
|
||||
@include icon-black-white('circles', 'deck', 1);
|
||||
@include icon-black-white('clone', 'deck', 1);
|
||||
@include icon-black-white('filter', 'deck', 1);
|
||||
@include icon-black-white('attach', 'deck', 1);
|
||||
|
||||
.icon-toggle-compact-collapsed {
|
||||
|
||||
78
img/filter.svg
Normal file
78
img/filter.svg
Normal 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
10
package-lock.json
generated
@@ -13118,7 +13118,7 @@
|
||||
},
|
||||
"mkdirp": {
|
||||
"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=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
@@ -13778,7 +13778,7 @@
|
||||
},
|
||||
"os-homedir": {
|
||||
"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=",
|
||||
"dev": true
|
||||
},
|
||||
@@ -13795,7 +13795,7 @@
|
||||
},
|
||||
"os-tmpdir": {
|
||||
"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=",
|
||||
"dev": true
|
||||
},
|
||||
@@ -13967,7 +13967,7 @@
|
||||
},
|
||||
"path-is-absolute": {
|
||||
"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="
|
||||
},
|
||||
"path-key": {
|
||||
@@ -16710,7 +16710,7 @@
|
||||
},
|
||||
"string_decoder": {
|
||||
"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==",
|
||||
"requires": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
|
||||
@@ -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