feat: add clickable labels with filtering support

- Made labels clickable in Label.vue component
- Added label filtering to ShowTasks component via query parameters
- Updated Home.vue to pass label query parameters to ShowTasks
- Added visual indicator showing active label filter with clear button
- Added translation for "Filtering by label" message

Co-authored-by: kolaente <13721712+kolaente@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2025-11-15 15:36:23 +00:00
parent 8bb02408c7
commit c16ca20db5
4 changed files with 111 additions and 3 deletions

View File

@ -2,15 +2,29 @@
import type {ILabel} from '@/modelTypes/ILabel'
import {useLabelStyles} from '@/composables/useLabelStyles'
defineProps<{
const props = withDefaults(defineProps<{
label: ILabel
}>()
clickable?: boolean
}>(), {
clickable: true,
})
const {getLabelStyles} = useLabelStyles()
</script>
<template>
<RouterLink
v-if="clickable"
:key="label.id"
:to="{name: 'home', query: {labels: label.id.toString()}}"
:style="getLabelStyles(label)"
class="tag tag-clickable"
@click.stop
>
<span>{{ label.title }}</span>
</RouterLink>
<span
v-else
:key="label.id"
:style="getLabelStyles(label)"
class="tag"
@ -24,5 +38,14 @@ const {getLabelStyles} = useLabelStyles()
& + & {
margin-inline-start: 0.5rem;
}
&.tag-clickable {
cursor: pointer;
transition: opacity $transition;
&:hover {
opacity: 0.8;
}
}
}
</style>

View File

@ -807,7 +807,8 @@
"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"
},
"detail": {
"chooseDueDate": "Click here to set a due date",

View File

@ -38,6 +38,7 @@
<ShowTasks
v-if="projectStore.hasProjects"
:key="showTasksKey"
:label-ids="labelIds"
class="show-tasks"
@tasksLoaded="tasksLoaded = true"
/>
@ -46,6 +47,7 @@
<script lang="ts" setup>
import {ref, computed} from 'vue'
import {useRoute} from 'vue-router'
import Message from '@/components/misc/Message.vue'
import ShowTasks from '@/views/tasks/ShowTasks.vue'
@ -65,6 +67,7 @@ const salutation = useDaytimeSalutation()
const authStore = useAuthStore()
const projectStore = useProjectStore()
const route = useRoute()
const projectHistory = computed(() => {
// If we don't check this, it tries to load the project background right after logging out
@ -81,6 +84,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)

View File

@ -6,6 +6,24 @@
<h3 class="mbe-2 title">
{{ pageTitle }}
</h3>
<div
v-if="filteredLabels.length > 0"
class="label-filter-info mbe-2"
>
<span class="filter-label-text">{{ $t('task.show.filterByLabel') }}:</span>
<XLabel
v-for="label in filteredLabels"
:key="label.id"
:label="label"
:clickable="false"
/>
<BaseButton
class="clear-filter-button"
@click="clearLabelFilter"
>
<Icon icon="times" />
</BaseButton>
</div>
<p
v-if="!showAll"
class="show-tasks-options"
@ -76,15 +94,19 @@ 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.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,11 +115,13 @@ 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<{
@ -107,6 +131,7 @@ const emit = defineEmits<{
const authStore = useAuthStore()
const taskStore = useTaskStore()
const projectStore = useProjectStore()
const labelStore = useLabelStore()
const route = useRoute()
const router = useRouter()
@ -120,6 +145,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 +211,15 @@ function setShowNulls(show: boolean) {
})
}
function clearLabelFilter() {
const query = {...route.query}
delete query.labels
router.push({
name: route.name as string,
query,
})
}
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 +250,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 +295,27 @@ watchEffect(() => setTitle(pageTitle.value))
margin: 3rem auto 0;
display: block;
}
.label-filter-info {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
background-color: var(--grey-100);
border-radius: $radius;
.filter-label-text {
font-weight: 600;
color: var(--grey-700);
}
.clear-filter-button {
margin-inline-start: auto;
padding: 0.25rem 0.5rem;
&:hover {
color: var(--danger);
}
}
}
</style>