Skip to content

Commit 2419041

Browse files
feat: implement filtering by tags
Signed-off-by: Raimund Schlüßler <[email protected]>
1 parent b0290df commit 2419041

File tree

9 files changed

+201
-35
lines changed

9 files changed

+201
-35
lines changed

src/components/FilterDropdown.vue

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
<!--
2+
Nextcloud - Tasks
3+
4+
@author Raimund Schlüßler
5+
@copyright 2024 Raimund Schlüßler <[email protected]>
6+
7+
This library is free software; you can redistribute it and/or
8+
modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE
9+
License as published by the Free Software Foundation; either
10+
version 3 of the License, or any later version.
11+
12+
This library is distributed in the hope that it will be useful,
13+
but WITHOUT ANY WARRANTY; without even the implied warranty of
14+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15+
GNU AFFERO GENERAL PUBLIC LICENSE for more details.
16+
17+
You should have received a copy of the GNU Affero General Public
18+
License along with this library. If not, see <http://www.gnu.org/licenses/>.
19+
20+
-->
21+
22+
<template>
23+
<NcActions class="filter reactive"
24+
force-menu
25+
:type="isFilterActive ? 'primary' : 'tertiary'"
26+
:title="t('tasks', 'Active filter')">
27+
<template #icon>
28+
<span class="material-design-icon">
29+
<FilterIcon v-if="isFilterActive" :size="20" />
30+
<FilterOffIcon v-else :size="20" />
31+
</span>
32+
</template>
33+
<NcActionInput type="multiselect"
34+
:label="t('tasks', 'Filter by tags')"
35+
track-by="id"
36+
:multiple="true"
37+
append-to-body
38+
:options="tags"
39+
:value="filter.tags"
40+
@input="setTags">
41+
<template #icon>
42+
<TagMultiple :size="20" />
43+
</template>
44+
{{ t('tasks', 'Select tags to filter by') }}
45+
</NcActionInput>
46+
<NcActionButton class="reactive"
47+
:close-after-click="true"
48+
@click="resetFilter">
49+
<template #icon>
50+
<Close :size="20" />
51+
</template>
52+
{{ t('tasks', 'Reset filter') }}
53+
</NcActionButton>
54+
</NcActions>
55+
</template>
56+
57+
<script>
58+
import { translate as t } from '@nextcloud/l10n'
59+
import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
60+
import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
61+
import NcActionInput from '@nextcloud/vue/dist/Components/NcActionInput.js'
62+
63+
import Close from 'vue-material-design-icons/Close.vue'
64+
import FilterIcon from 'vue-material-design-icons/Filter.vue'
65+
import FilterOffIcon from 'vue-material-design-icons/FilterOff.vue'
66+
import TagMultiple from 'vue-material-design-icons/TagMultiple.vue'
67+
68+
import { mapGetters, mapMutations } from 'vuex'
69+
70+
export default {
71+
name: 'FilterDropdown',
72+
components: {
73+
NcActions,
74+
NcActionButton,
75+
NcActionInput,
76+
Close,
77+
FilterIcon,
78+
FilterOffIcon,
79+
TagMultiple,
80+
},
81+
computed: {
82+
...mapGetters({
83+
tags: 'tags',
84+
filter: 'filter',
85+
}),
86+
isFilterActive() {
87+
return this.filter.tags.length
88+
},
89+
},
90+
methods: {
91+
t,
92+
...mapMutations(['setFilter']),
93+
94+
setTags(tags) {
95+
const filter = this.filter
96+
filter.tags = tags
97+
this.setFilter(filter)
98+
},
99+
100+
resetFilter() {
101+
this.setFilter({ tags: [] })
102+
},
103+
},
104+
}
105+
</script>
106+
107+
<style lang="scss" scoped>
108+
// overlay the sort direction icon with the sort order icon
109+
.material-design-icon {
110+
width: 44px;
111+
height: 44px;
112+
}
113+
</style>

src/components/HeaderBar.vue

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ License along with this library. If not, see <http://www.gnu.org/licenses/>.
3838
<Plus :size="20" />
3939
</NcTextField>
4040
</div>
41+
<FilterDropdown />
4142
<SortorderDropdown />
4243
<CreateMultipleTasksDialog v-if="showCreateMultipleTasksModal"
4344
:calendar="calendar"
@@ -49,6 +50,7 @@ License along with this library. If not, see <http://www.gnu.org/licenses/>.
4950
</template>
5051

5152
<script>
53+
import FilterDropdown from './FilterDropdown.vue'
5254
import SortorderDropdown from './SortorderDropdown.vue'
5355
import openNewTask from '../mixins/openNewTask.js'
5456
@@ -67,6 +69,7 @@ export default {
6769
components: {
6870
CreateMultipleTasksDialog,
6971
NcTextField,
72+
FilterDropdown,
7073
SortorderDropdown,
7174
Plus,
7275
},
@@ -194,12 +197,16 @@ $breakpoint-mobile: 1024px;
194197
195198
&__input {
196199
position: relative;
197-
width: calc(100% - 44px);
200+
width: calc(100% - 88px);
198201
}
199202
200-
.sortorder {
201-
margin-left: auto;
203+
.sortorder,
204+
.filter {
202205
margin-top: 6px;
203206
}
207+
208+
.filter {
209+
margin-left: auto;
210+
}
204211
}
205212
</style>

src/components/TaskBody.vue

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,10 @@ License along with this library. If not, see <http://www.gnu.org/licenses/>.
5353
<span v-linkify="{text: task.summary, linkify: true}" />
5454
</div>
5555
<div v-if="task.tags.length > 0" class="tags-list">
56-
<span v-for="(tag, index) in task.tags" :key="index" class="tag">
56+
<span v-for="(tag, index) in task.tags"
57+
:key="index"
58+
class="tag no-nav"
59+
@click="addTagToFilter(tag)">
5760
<span :title="tag" class="tag-label">
5861
{{ tag }}
5962
</span>
@@ -260,6 +263,7 @@ export default {
260263
computed: {
261264
...mapGetters({
262265
searchQuery: 'searchQuery',
266+
filter: 'filter',
263267
}),
264268
265269
dueDateShort() {
@@ -428,11 +432,11 @@ export default {
428432
*/
429433
showTask() {
430434
// If the task directly matches the search, we show it.
431-
if (this.task.matches(this.searchQuery)) {
435+
if (this.task.matches(this.searchQuery, this.filter)) {
432436
return true
433437
}
434438
// We also have to show tasks for which one sub(sub...)task matches.
435-
return this.searchSubTasks(this.task, this.searchQuery)
439+
return this.searchSubTasks(this.task, this.searchQuery, this.filter)
436440
},
437441
438442
/**
@@ -481,7 +485,7 @@ export default {
481485
'clearTaskDeletion',
482486
'fetchFullTask',
483487
]),
484-
...mapMutations(['resetStatus']),
488+
...mapMutations(['resetStatus', 'setFilter']),
485489
sort,
486490
/**
487491
* Checks if a date is overdue
@@ -494,6 +498,14 @@ export default {
494498
}
495499
},
496500
501+
addTagToFilter(tag) {
502+
const filter = this.filter
503+
if (!this.filter?.tags.includes(tag)) {
504+
filter.tags.push(tag)
505+
this.setFilter(filter)
506+
}
507+
},
508+
497509
/**
498510
* Set task uri in the data transfer object
499511
* so we can get it when dropped on an
@@ -870,13 +882,15 @@ $breakpoint-mobile: 1024px;
870882
border-radius: 18px !important;
871883
margin: 4px 2px;
872884
align-items: center;
885+
cursor: pointer;
873886
874887
.tag-label {
875888
text-overflow: ellipsis;
876889
overflow: hidden;
877890
white-space: nowrap;
878891
width: 100%;
879892
text-align: center;
893+
cursor: pointer;
880894
}
881895
}
882896
}

src/models/task.js

Lines changed: 15 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -122,9 +122,6 @@ export default class Task {
122122
sortOrder = this.getSortOrder()
123123
}
124124
this._sortOrder = +sortOrder
125-
126-
this._searchQuery = ''
127-
this._matchesSearchQuery = true
128125
}
129126

130127
/**
@@ -680,19 +677,21 @@ export default class Task {
680677
* Checks if the task matches the search query
681678
*
682679
* @param {string} searchQuery The search string
680+
* @param {object} filter Object containing the filter parameters
683681
* @return {boolean} If the task matches
684682
*/
685-
matches(searchQuery) {
686-
// If the search query maches the previous search, we don't have to search again.
687-
if (this._searchQuery === searchQuery) {
688-
return this._matchesSearchQuery
683+
matches(searchQuery, filter) {
684+
// Check whether the filter matches
685+
// Needs to match all tags
686+
for (const tag of (filter?.tags || {})) {
687+
if (!this.tags.includes(tag)) {
688+
return false
689+
}
689690
}
690-
// We cache the current search query for faster future comparison.
691-
this._searchQuery = searchQuery
691+
692692
// If the search query is empty, the task matches by default.
693693
if (!searchQuery) {
694-
this._matchesSearchQuery = true
695-
return this._matchesSearchQuery
694+
return true
696695
}
697696
// We search in these task properties
698697
const keys = ['summary', 'note', 'tags']
@@ -702,20 +701,17 @@ export default class Task {
702701
// For the tags search the array
703702
if (key === 'tags') {
704703
for (const tag of this[key]) {
705-
if (tag.toLowerCase().indexOf(searchQuery) > -1) {
706-
this._matchesSearchQuery = true
707-
return this._matchesSearchQuery
704+
if (tag.toLowerCase().includes(searchQuery)) {
705+
return true
708706
}
709707
}
710708
} else {
711-
if (this[key].toLowerCase().indexOf(searchQuery) > -1) {
712-
this._matchesSearchQuery = true
713-
return this._matchesSearchQuery
709+
if (this[key].toLowerCase().includes(searchQuery)) {
710+
return true
714711
}
715712
}
716713
}
717-
this._matchesSearchQuery = false
718-
return this._matchesSearchQuery
714+
return false
719715
}
720716

721717
}

src/store/calendars.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -257,13 +257,13 @@ const getters = {
257257
.filter(task => {
258258
return task.closed === false && (!task.related || !isParentInList(task, calendar.tasks))
259259
})
260-
if (rootState.tasks.searchQuery) {
260+
if (rootState.tasks.searchQuery || rootState.tasks.filter.tags.length) {
261261
tasks = tasks.filter(task => {
262-
if (task.matches(rootState.tasks.searchQuery)) {
262+
if (task.matches(rootState.tasks.searchQuery, rootState.tasks.filter)) {
263263
return true
264264
}
265265
// We also have to show tasks for which one sub(sub...)task matches.
266-
return searchSubTasks(task, rootState.tasks.searchQuery)
266+
return searchSubTasks(task, rootState.tasks.searchQuery, rootState.tasks.filter)
267267
})
268268
}
269269
return tasks.length

src/store/collections.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,13 +60,13 @@ const getters = {
6060
let tasks = Object.values(calendar.tasks).filter(task => {
6161
return isTaskInList(task, collectionId, false)
6262
})
63-
if (rootState.tasks.searchQuery) {
63+
if (rootState.tasks.searchQuery || rootState.tasks.filter.tags.length) {
6464
tasks = tasks.filter(task => {
65-
if (task.matches(rootState.tasks.searchQuery)) {
65+
if (task.matches(rootState.tasks.searchQuery, rootState.tasks.filter)) {
6666
return true
6767
}
6868
// We also have to show tasks for which one sub(sub...)task matches.
69-
return searchSubTasks(task, rootState.tasks.searchQuery)
69+
return searchSubTasks(task, rootState.tasks.searchQuery, rootState.tasks.filter)
7070
})
7171
}
7272
count += tasks.length

src/store/storeHelper.js

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -438,14 +438,15 @@ function momentToICALTime(moment, asDate) {
438438
*
439439
* @param {Task} task The task to search in
440440
* @param {string} searchQuery The string to find
441+
* @param {object} filter The filter to apply to the task
441442
* @return {boolean} If the task matches
442443
*/
443-
function searchSubTasks(task, searchQuery) {
444+
function searchSubTasks(task, searchQuery, filter) {
444445
return Object.values(task.subTasks).some((subTask) => {
445-
if (subTask.matches(searchQuery)) {
446+
if (subTask.matches(searchQuery, filter)) {
446447
return true
447448
}
448-
return searchSubTasks(subTask, searchQuery)
449+
return searchSubTasks(subTask, searchQuery, filter)
449450
})
450451
}
451452

src/store/tasks.js

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ Vue.use(Vuex)
4141
const state = {
4242
tasks: {},
4343
searchQuery: '',
44+
filter: {
45+
tags: [],
46+
},
4447
deletedTasks: {},
4548
deleteInterval: null,
4649
}
@@ -263,6 +266,18 @@ const getters = {
263266
return state.searchQuery
264267
},
265268

269+
/**
270+
* Returns the current filter
271+
*
272+
* @param {object} state The store data
273+
* @param {object} getters The store getters
274+
* @param {object} rootState The store root state
275+
* @return {string} The current filter
276+
*/
277+
filter: (state, getters, rootState) => {
278+
return state.filter
279+
},
280+
266281
/**
267282
* Returns all tags of all tasks
268283
*
@@ -660,6 +675,17 @@ const mutations = {
660675
state.searchQuery = searchQuery
661676
},
662677

678+
/**
679+
* Sets the filter
680+
*
681+
* @param {object} state The store data
682+
* @param {string} filter The filter
683+
*/
684+
setFilter(state, filter) {
685+
Vue.set(state.filter, 'tags', filter.tags)
686+
state.filter = filter
687+
},
688+
663689
addTaskForDeletion(state, { task }) {
664690
Vue.set(state.deletedTasks, task.key, task)
665691
},

0 commit comments

Comments
 (0)