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:
parent
162ec33613
commit
6903bb67c7
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue