Skip to content

Commit

Permalink
feat: implement filtering by tags
Browse files Browse the repository at this point in the history
Signed-off-by: Raimund Schlüßler <raimund.schluessler@mailbox.org>
  • Loading branch information
raimund-schluessler committed Jan 1, 2024
1 parent 1745e48 commit cbc313c
Show file tree
Hide file tree
Showing 9 changed files with 196 additions and 33 deletions.
114 changes: 114 additions & 0 deletions src/components/FilterDropdown.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
<!--
Nextcloud - Tasks
@author Raimund Schlüßler
@copyright 2024 Raimund Schlüßler <raimund.schluessler@mailbox.org>
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE
License as published by the Free Software Foundation; either
version 3 of the License, or any later version.
This library 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 library. If not, see <http://www.gnu.org/licenses/>.
-->

<template>
<NcActions class="filter reactive"
force-menu
:type="isFilterActive ? 'primary' : 'tertiary'"
:title="t('tasks', 'Active filter')">
<template #icon>
<span class="material-design-icon">
<FilterIcon v-if="isFilterActive" :size="20" />
<FilterOffIcon v-else :size="20" />
</span>
</template>
<NcActionInput
type="multiselect"

Check failure on line 34 in src/components/FilterDropdown.vue

View workflow job for this annotation

GitHub Actions / NPM lint

Expected no linebreak before this attribute
:label="t('tasks', 'Filter by tags')"
track-by="id"
:multiple="true"
append-to-body
:options="tags"
:value="filter.tags"
@input="setTags">
<template #icon>
<TagMultiple :size="20" />
</template>
{{ t('tasks', 'Select tags to filter by') }}
</NcActionInput>
<NcActionButton class="reactive"
:close-after-click="true"
@click="resetFilter">
<template #icon>
<Close :size="20" />
</template>
{{ t('tasks', 'Reset filter') }}
</NcActionButton>
</NcActions>
</template>

<script>
import { translate as t } from '@nextcloud/l10n'
import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
import NcActionInput from '@nextcloud/vue/dist/Components/NcActionInput.js'
import Close from 'vue-material-design-icons/Close.vue'
import FilterIcon from 'vue-material-design-icons/Filter.vue'
import FilterOffIcon from 'vue-material-design-icons/FilterOff.vue'
import TagMultiple from 'vue-material-design-icons/TagMultiple.vue'
import { mapGetters, mapMutations } from 'vuex'
export default {
name: 'FilterDropdown',
components: {
NcActions,
NcActionButton,
NcActionInput,
Close,
FilterIcon,
FilterOffIcon,
TagMultiple,
},
computed: {
...mapGetters({
tags: 'tags',
filter: 'filter',
}),
isFilterActive() {
return this.filter.tags.length
},
},
methods: {
t,
...mapMutations(['setFilter']),
setTags(tags) {
const filter = this.filter
filter.tags = tags
this.setFilter(filter)
},
resetFilter() {
this.setFilter({ tags: [] })
},
},
}
</script>

<style lang="scss" scoped>
// overlay the sort direction icon with the sort order icon
.material-design-icon {
width: 44px;
height: 44px;
}
</style>
13 changes: 10 additions & 3 deletions src/components/HeaderBar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ License along with this library. If not, see <http://www.gnu.org/licenses/>.
<Plus :size="20" />
</NcTextField>
</div>
<FilterDropdown />
<SortorderDropdown />
<CreateMultipleTasksDialog v-if="showCreateMultipleTasksModal"
:calendar="calendar"
Expand All @@ -49,6 +50,7 @@ License along with this library. If not, see <http://www.gnu.org/licenses/>.
</template>

<script>
import FilterDropdown from './FilterDropdown.vue'
import SortorderDropdown from './SortorderDropdown.vue'
import openNewTask from '../mixins/openNewTask.js'
Expand All @@ -67,6 +69,7 @@ export default {
components: {
CreateMultipleTasksDialog,
NcTextField,
FilterDropdown,
SortorderDropdown,
Plus,
},
Expand Down Expand Up @@ -194,12 +197,16 @@ $breakpoint-mobile: 1024px;
&__input {
position: relative;
width: calc(100% - 44px);
width: calc(100% - 88px);
}
.sortorder {
margin-left: auto;
.sortorder,
.filter {
margin-top: 6px;
}
.filter {
margin-left: auto;
}
}
</style>
19 changes: 15 additions & 4 deletions src/components/TaskBody.vue
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ License along with this library. If not, see <http://www.gnu.org/licenses/>.
<span v-linkify="{text: task.summary, linkify: true}" />
</div>
<div v-if="task.tags.length > 0" class="tags-list">
<span v-for="(tag, index) in task.tags" :key="index" class="tag">
<span v-for="(tag, index) in task.tags" :key="index" class="tag no-nav" @click="addTagToFilter(tag)">

Check failure on line 56 in src/components/TaskBody.vue

View workflow job for this annotation

GitHub Actions / NPM lint

'@click' should be on a new line
<span :title="tag" class="tag-label">
{{ tag }}
</span>
Expand Down Expand Up @@ -260,6 +260,7 @@ export default {
computed: {
...mapGetters({
searchQuery: 'searchQuery',
filter: 'filter',
}),
dueDateShort() {
Expand Down Expand Up @@ -428,11 +429,11 @@ export default {
*/
showTask() {
// If the task directly matches the search, we show it.
if (this.task.matches(this.searchQuery)) {
if (this.task.matches(this.searchQuery, this.filter)) {
return true
}
// We also have to show tasks for which one sub(sub...)task matches.
return this.searchSubTasks(this.task, this.searchQuery)
return this.searchSubTasks(this.task, this.searchQuery, this.filter)
},
/**
Expand Down Expand Up @@ -481,7 +482,7 @@ export default {
'clearTaskDeletion',
'fetchFullTask',
]),
...mapMutations(['resetStatus']),
...mapMutations(['resetStatus', 'setFilter']),
sort,
/**
* Checks if a date is overdue
Expand All @@ -494,6 +495,14 @@ export default {
}
},
addTagToFilter(tag) {
const filter = this.filter
if (!this.filter?.tags.includes(tag)) {
filter.tags.push(tag)
this.setFilter(filter)
}
},
/**
* Set task uri in the data transfer object
* so we can get it when dropped on an
Expand Down Expand Up @@ -870,13 +879,15 @@ $breakpoint-mobile: 1024px;
border-radius: 18px !important;
margin: 4px 2px;
align-items: center;
cursor: pointer;
.tag-label {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
width: 100%;
text-align: center;
cursor: pointer;
}
}
}
Expand Down
34 changes: 15 additions & 19 deletions src/models/task.js
Original file line number Diff line number Diff line change
Expand Up @@ -122,9 +122,6 @@ export default class Task {
sortOrder = this.getSortOrder()
}
this._sortOrder = +sortOrder

this._searchQuery = ''
this._matchesSearchQuery = true
}

/**
Expand Down Expand Up @@ -680,19 +677,21 @@ export default class Task {
* Checks if the task matches the search query
*
* @param {string} searchQuery The search string
* @param {Object} filter Object containing the filter parameters

Check warning on line 680 in src/models/task.js

View workflow job for this annotation

GitHub Actions / NPM lint

Invalid JSDoc @param "filter" type "Object"; prefer: "object"
* @return {boolean} If the task matches
*/
matches(searchQuery) {
// If the search query maches the previous search, we don't have to search again.
if (this._searchQuery === searchQuery) {
return this._matchesSearchQuery
matches(searchQuery, filter) {
// Check whether the filter matches
// Needs to match all tags
for (const tag of filter?.tags) {

Check failure on line 686 in src/models/task.js

View workflow job for this annotation

GitHub Actions / NPM lint

Unsafe usage of optional chaining. If it short-circuits with 'undefined' the evaluation will throw TypeError
if (!this.tags.includes(tag)) {
return false
}
}
// We cache the current search query for faster future comparison.
this._searchQuery = searchQuery

// If the search query is empty, the task matches by default.
if (!searchQuery) {
this._matchesSearchQuery = true
return this._matchesSearchQuery
return true
}
// We search in these task properties
const keys = ['summary', 'note', 'tags']
Expand All @@ -702,20 +701,17 @@ export default class Task {
// For the tags search the array
if (key === 'tags') {
for (const tag of this[key]) {
if (tag.toLowerCase().indexOf(searchQuery) > -1) {
this._matchesSearchQuery = true
return this._matchesSearchQuery
if (tag.toLowerCase().includes(searchQuery)) {
return true
}
}
} else {
if (this[key].toLowerCase().indexOf(searchQuery) > -1) {
this._matchesSearchQuery = true
return this._matchesSearchQuery
if (this[key].toLowerCase().includes(searchQuery)) {
return true
}
}
}
this._matchesSearchQuery = false
return this._matchesSearchQuery
return false
}

}
4 changes: 2 additions & 2 deletions src/store/calendars.js
Original file line number Diff line number Diff line change
Expand Up @@ -259,11 +259,11 @@ const getters = {
})
if (rootState.tasks.searchQuery) {
tasks = tasks.filter(task => {
if (task.matches(rootState.tasks.searchQuery)) {
if (task.matches(rootState.tasks.searchQuery, rootState.tasks.filter)) {
return true
}
// We also have to show tasks for which one sub(sub...)task matches.
return searchSubTasks(task, rootState.tasks.searchQuery)
return searchSubTasks(task, rootState.tasks.searchQuery, rootState.tasks.filter)
})
}
return tasks.length
Expand Down
4 changes: 2 additions & 2 deletions src/store/collections.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,11 @@ const getters = {
})
if (rootState.tasks.searchQuery) {
tasks = tasks.filter(task => {
if (task.matches(rootState.tasks.searchQuery)) {
if (task.matches(rootState.tasks.searchQuery, rootState.tasks.filter)) {
return true
}
// We also have to show tasks for which one sub(sub...)task matches.
return searchSubTasks(task, rootState.tasks.searchQuery)
return searchSubTasks(task, rootState.tasks.searchQuery, rootState.tasks.filter)
})
}
count += tasks.length
Expand Down
6 changes: 3 additions & 3 deletions src/store/storeHelper.js
Original file line number Diff line number Diff line change
Expand Up @@ -440,12 +440,12 @@ function momentToICALTime(moment, asDate) {
* @param {string} searchQuery The string to find
* @return {boolean} If the task matches
*/
function searchSubTasks(task, searchQuery) {
function searchSubTasks(task, searchQuery, filter) {
return Object.values(task.subTasks).some((subTask) => {
if (subTask.matches(searchQuery)) {
if (subTask.matches(searchQuery, filter)) {
return true
}
return searchSubTasks(subTask, searchQuery)
return searchSubTasks(subTask, searchQuery, filter)
})
}

Expand Down
26 changes: 26 additions & 0 deletions src/store/tasks.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ Vue.use(Vuex)
const state = {
tasks: {},
searchQuery: '',
filter: {
tags: [],
},
deletedTasks: {},
deleteInterval: null,
}
Expand Down Expand Up @@ -263,6 +266,18 @@ const getters = {
return state.searchQuery
},

/**
* Returns the current filter
*
* @param {object} state The store data
* @param {object} getters The store getters
* @param {object} rootState The store root state
* @return {string} The current filter
*/
filter: (state, getters, rootState) => {
return state.filter
},

/**
* Returns all tags of all tasks
*
Expand Down Expand Up @@ -660,6 +675,17 @@ const mutations = {
state.searchQuery = searchQuery
},

/**
* Sets the filter
*
* @param {object} state The store data
* @param {string} filter The filter
*/
setFilter(state, filter) {
Vue.set(state.filter, 'tags', filter.tags)
state.filter = filter
},

addTaskForDeletion(state, { task }) {
Vue.set(state.deletedTasks, task.key, task)
},
Expand Down
Loading

0 comments on commit cbc313c

Please sign in to comment.