Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions src/components/FilterDropdown.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
<!--
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"
: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>
22 changes: 18 additions & 4 deletions src/components/TaskBody.vue
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,10 @@ 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)">
<span :title="tag" class="tag-label">
{{ tag }}
</span>
Expand Down Expand Up @@ -260,6 +263,7 @@ export default {
computed: {
...mapGetters({
searchQuery: 'searchQuery',
filter: 'filter',
}),

dueDateShort() {
Expand Down Expand Up @@ -428,11 +432,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 +485,7 @@ export default {
'clearTaskDeletion',
'fetchFullTask',
]),
...mapMutations(['resetStatus']),
...mapMutations(['resetStatus', 'setFilter']),
sort,
/**
* Checks if a date is overdue
Expand All @@ -494,6 +498,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 +882,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
* @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 || {})) {
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
}

}
6 changes: 3 additions & 3 deletions src/store/calendars.js
Original file line number Diff line number Diff line change
Expand Up @@ -257,13 +257,13 @@ const getters = {
.filter(task => {
return task.closed === false && (!task.related || !isParentInList(task, calendar.tasks))
})
if (rootState.tasks.searchQuery) {
if (rootState.tasks.searchQuery || rootState.tasks.filter.tags.length) {
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
6 changes: 3 additions & 3 deletions src/store/collections.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,13 @@ const getters = {
let tasks = Object.values(calendar.tasks).filter(task => {
return isTaskInList(task, collectionId, false)
})
if (rootState.tasks.searchQuery) {
if (rootState.tasks.searchQuery || rootState.tasks.filter.tags.length) {
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
7 changes: 4 additions & 3 deletions src/store/storeHelper.js
Original file line number Diff line number Diff line change
Expand Up @@ -438,14 +438,15 @@ function momentToICALTime(moment, asDate) {
*
* @param {Task} task The task to search in
* @param {string} searchQuery The string to find
* @param {object} filter The filter to apply to the task
* @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