Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: implement filtering by tags #2427

Merged
merged 1 commit into from
Jan 1, 2024
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 <[email protected]>

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 @@
sortOrder = this.getSortOrder()
}
this._sortOrder = +sortOrder

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

/**
Expand All @@ -143,7 +140,7 @@
* Update linked calendar of this task
*
* @param {object} calendar the calendar
* @memberof Contact

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

View workflow job for this annotation

GitHub Actions / NPM lint

The type 'Contact' is undefined
*/
updateCalendar(calendar) {
this.calendar = calendar
Expand Down Expand Up @@ -680,19 +677,21 @@
* 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 @@
// 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 @@
.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 Expand Up @@ -511,7 +511,7 @@
if (list[task.uid]) {
console.debug('Duplicate task overridden', list[task.uid], task)
}
Vue.set(list, task.uid, task)

Check warning on line 514 in src/store/calendars.js

View workflow job for this annotation

GitHub Actions / NPM lint

Caution: `Vue` also has a named export `set`. Check if you meant to write `import {set} from 'vue'` instead
return list
}, calendar.tasks)

Expand All @@ -524,7 +524,7 @@
* @param {Task} task The task to add
*/
addTaskToCalendar(state, task) {
Vue.set(task.calendar.tasks, task.uid, task)

Check warning on line 527 in src/store/calendars.js

View workflow job for this annotation

GitHub Actions / NPM lint

Caution: `Vue` also has a named export `set`. Check if you meant to write `import {set} from 'vue'` instead
},

/**
Expand Down Expand Up @@ -601,7 +601,7 @@
* @param {number} data.order The sort order
*/
setCalendarOrder(state, { calendar, order }) {
Vue.set(calendar, 'order', order)

Check warning on line 604 in src/store/calendars.js

View workflow job for this annotation

GitHub Actions / NPM lint

Caution: `Vue` also has a named export `set`. Check if you meant to write `import {set} from 'vue'` instead
},
}

Expand Down Expand Up @@ -848,7 +848,7 @@
// so we need to parse one by one
const tasks = response.map(item => {
const task = new Task(item.data, calendar)
Vue.set(task, 'dav', item)

Check warning on line 851 in src/store/calendars.js

View workflow job for this annotation

GitHub Actions / NPM lint

Caution: `Vue` also has a named export `set`. Check if you meant to write `import {set} from 'vue'` instead
return task
})

Expand All @@ -865,7 +865,7 @@
if (list[task.uid]) {
console.debug('Duplicate task overridden', list[task.uid], task)
}
Vue.set(list, task.uid, task)

Check warning on line 868 in src/store/calendars.js

View workflow job for this annotation

GitHub Actions / NPM lint

Caution: `Vue` also has a named export `set`. Check if you meant to write `import {set} from 'vue'` instead
return list
}, parent.subTasks)

Expand All @@ -890,7 +890,7 @@
const parent = Object.values(calendar.tasks).find(search => search.uid === related)
if (parent) {
parent.loadedCompleted = true
tasks.map(task => Vue.set(parent.subTasks, task.uid, task))

Check warning on line 893 in src/store/calendars.js

View workflow job for this annotation

GitHub Actions / NPM lint

Caution: `Vue` also has a named export `set`. Check if you meant to write `import {set} from 'vue'` instead
}
}

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 @@
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 Expand Up @@ -94,7 +94,7 @@
*/
setVisibility(state, newCollection) {
const collection = state.collections.find(search => search.id === newCollection.id)
Vue.set(collection, 'show', newCollection.show)

Check warning on line 97 in src/store/collections.js

View workflow job for this annotation

GitHub Actions / NPM lint

Caution: `Vue` also has a named export `set`. Check if you meant to write `import {set} from 'vue'` instead
},
}

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 @@
const state = {
tasks: {},
searchQuery: '',
filter: {
tags: [],
},
deletedTasks: {},
deleteInterval: null,
}
Expand Down Expand Up @@ -263,6 +266,18 @@
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 @@ -295,7 +310,7 @@
appendTasks(state, tasks = []) {
state.tasks = tasks.reduce(function(list, task) {
if (task instanceof Task) {
Vue.set(list, task.key, task)

Check warning on line 313 in src/store/tasks.js

View workflow job for this annotation

GitHub Actions / NPM lint

Caution: `Vue` also has a named export `set`. Check if you meant to write `import {set} from 'vue'` instead
} else {
console.error('Wrong task object', task)
}
Expand Down Expand Up @@ -660,6 +675,17 @@
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
Loading