feat: add clickable labels on Labels page for task filtering (#1825)

Resolves https://community.vikunja.io/t/click-label-to-show-all-matching-tasks/3082

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: kolaente <13721712+kolaente@users.noreply.github.com>
Co-authored-by: kolaente <k@knt.li>
This commit is contained in:
Copilot 2025-11-15 17:10:32 +00:00 committed by GitHub
parent 162ec33613
commit 6903bb67c7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 127 additions and 20 deletions

View File

@ -807,7 +807,9 @@
"overdue": "Show overdue tasks",
"fromuntil": "Tasks from {from} until {until}",
"select": "Select a date range",
"noTasks": "Nothing to do — Have a nice day!"
"noTasks": "Nothing to do — Have a nice day!",
"filterByLabel": "Filtering by label {label}",
"clearLabelFilter": "Clear label filter"
},
"detail": {
"chooseDueDate": "Click here to set a due date",

View File

@ -38,14 +38,17 @@
<ShowTasks
v-if="projectStore.hasProjects"
:key="showTasksKey"
:label-ids="labelIds"
class="show-tasks"
@tasksLoaded="tasksLoaded = true"
@clearLabelFilter="handleClearLabelFilter"
/>
</div>
</template>
<script lang="ts" setup>
import {ref, computed} from 'vue'
import {useRoute, useRouter} from 'vue-router'
import Message from '@/components/misc/Message.vue'
import ShowTasks from '@/views/tasks/ShowTasks.vue'
@ -65,6 +68,8 @@ const salutation = useDaytimeSalutation()
const authStore = useAuthStore()
const projectStore = useProjectStore()
const route = useRoute()
const router = useRouter()
const projectHistory = computed(() => {
// If we don't check this, it tries to load the project background right after logging out
@ -81,6 +86,15 @@ const tasksLoaded = ref(false)
const deletionScheduledAt = computed(() => parseDateOrNull(authStore.info?.deletionScheduledAt))
// Extract label IDs from query parameter
const labelIds = computed(() => {
const labelsParam = route.query.labels
if (!labelsParam) {
return undefined
}
return Array.isArray(labelsParam) ? labelsParam : [labelsParam]
})
// This is to reload the tasks list after adding a new task through the global task add.
// FIXME: Should use pinia (somehow?)
const showTasksKey = ref(0)
@ -88,6 +102,15 @@ const showTasksKey = ref(0)
function updateTaskKey() {
showTasksKey.value++
}
function handleClearLabelFilter() {
const query = {...route.query}
delete query.labels
router.push({
name: route.name as string,
query,
})
}
</script>
<style scoped lang="scss">

View File

@ -29,32 +29,22 @@
<div class="columns">
<div class="labels-list column">
<span
<RouterLink
v-for="label in labelStore.labelsArray"
:key="label.id"
:class="{'disabled': userInfo.id !== label.createdBy.id}"
:to="{name: 'home', query: {labels: label.id.toString()}}"
:style="getLabelStyles(label)"
class="tag"
>
<span
v-if="userInfo.id !== label.createdBy.id"
v-tooltip.bottom="$t('label.edit.forbidden')"
>
{{ label.title }}
</span>
<BaseButton
v-else
:style="{'color': label.textColor}"
@click="editLabel(label)"
>
{{ label.title }}
</BaseButton>
<span>{{ label.title }}</span>
<BaseButton
v-if="userInfo.id === label.createdBy.id"
class="delete is-small"
@click="showDeleteDialoge(label)"
/>
</span>
class="label-edit-button is-small"
@click.stop.prevent="editLabel(label)"
>
<Icon icon="pen" class="icon"/>
</BaseButton>
</RouterLink>
</div>
<div
v-if="isLabelEdit"
@ -212,3 +202,21 @@ function showDeleteDialoge(label: ILabel) {
showDeleteModal.value = true
}
</script>
<style lang="scss" scoped>
.label-edit-button {
border-radius: 100%;
background-color: rgba(0,0,0,0.2);
width: 1rem;
height: 1rem;
display: flex;
align-items: center;
justify-content: center;
color: #fff; // always white
margin-inline-start: .25rem;
.icon {
height: .5rem;
}
}
</style>

View File

@ -6,6 +6,31 @@
<h3 class="mbe-2 title">
{{ pageTitle }}
</h3>
<Message
v-if="filteredLabels.length > 0"
class="label-filter-info mbe-2"
>
<i18n-t
keypath="task.show.filterByLabel"
tag="span"
class="filter-label-text"
>
<template #label>
<XLabel
v-for="label in filteredLabels"
:key="label.id"
:label="label"
/>
</template>
</i18n-t>
<BaseButton
v-tooltip="$t('task.show.clearLabelFilter')"
class="clear-filter-button"
@click="clearLabelFilter"
>
<Icon icon="times" />
</BaseButton>
</Message>
<p
v-if="!showAll"
class="show-tasks-options"
@ -76,15 +101,20 @@ import {useI18n} from 'vue-i18n'
import {formatDate} from '@/helpers/time/formatDate'
import {setTitle} from '@/helpers/setTitle'
import BaseButton from '@/components/base/BaseButton.vue'
import Icon from '@/components/misc/Icon'
import Message from '@/components/misc/Message.vue'
import FancyCheckbox from '@/components/input/FancyCheckbox.vue'
import SingleTaskInProject from '@/components/tasks/partials/SingleTaskInProject.vue'
import DatepickerWithRange from '@/components/date/DatepickerWithRange.vue'
import XLabel from '@/components/tasks/partials/Label.vue'
import {DATE_RANGES} from '@/components/date/dateRanges'
import LlamaCool from '@/assets/llama-cool.svg?component'
import type {ITask} from '@/modelTypes/ITask'
import {useAuthStore} from '@/stores/auth'
import {useTaskStore} from '@/stores/tasks'
import {useProjectStore} from '@/stores/projects'
import {useLabelStore} from '@/stores/labels'
import type {TaskFilterParams} from '@/services/taskCollection'
import TaskCollectionService from '@/services/taskCollection'
@ -93,20 +123,24 @@ const props = withDefaults(defineProps<{
dateTo?: Date | string,
showNulls?: boolean,
showOverdue?: boolean,
labelIds?: string[],
}>(), {
showNulls: false,
showOverdue: false,
dateFrom: undefined,
dateTo: undefined,
labelIds: undefined,
})
const emit = defineEmits<{
'tasksLoaded': true,
'clearLabelFilter': void,
}>()
const authStore = useAuthStore()
const taskStore = useTaskStore()
const projectStore = useProjectStore()
const labelStore = useLabelStore()
const route = useRoute()
const router = useRouter()
@ -120,6 +154,15 @@ setTimeout(() => showNothingToDo.value = true, 100)
const showAll = computed(() => typeof props.dateFrom === 'undefined' || typeof props.dateTo === 'undefined')
const filteredLabels = computed(() => {
if (!props.labelIds || props.labelIds.length === 0) {
return []
}
return props.labelIds
.map(id => labelStore.getLabelById(Number(id)))
.filter(label => label !== null && label !== undefined)
})
const pageTitle = computed(() => {
// We need to define "key" because it is the first parameter in the array and we need the second
const predefinedRange = Object.entries(DATE_RANGES)
@ -177,6 +220,10 @@ function setShowNulls(show: boolean) {
})
}
function clearLabelFilter() {
emit('clearLabelFilter')
}
async function loadPendingTasks(from: Date|string, to: Date|string) {
// FIXME: HACK! This should never happen.
// Since this route is authentication only, users would get an error message if they access the page unauthenticated.
@ -207,6 +254,12 @@ async function loadPendingTasks(from: Date|string, to: Date|string) {
}
}
// Add label filtering
if (props.labelIds && props.labelIds.length > 0) {
const labelFilter = `labels in ${props.labelIds.join(', ')}`
params.filter += params.filter ? ` && ${labelFilter}` : labelFilter
}
let projectId = null
const filterId = authStore.settings.frontendSettings.filterIdUsedOnOverview
if (showAll.value && filterId && typeof projectStore.projects[filterId] !== 'undefined') {
@ -246,4 +299,25 @@ watchEffect(() => setTitle(pageTitle.value))
margin: 3rem auto 0;
display: block;
}
.label-filter-info {
margin-block-end: 1rem;
.clear-filter-button {
margin-inline-start: auto;
padding: 0.25rem 0.5rem;
&:hover {
color: var(--danger);
}
}
:deep(.message.info) {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
}
</style>