Files
vikunja/frontend/src/views/tasks/ShowTasks.vue
copilot-swe-agent[bot] ae19962f59 fix: implement review feedback on ListLabels and ShowTasks
- Move edit button to replace delete button in ListLabels
- Remove separate delete button (edit button now uses delete icon styling)
- Remove Icon import and unused styles from ListLabels
- Add tooltip to clear label filter button
- Add translation key for clear filter tooltip
- Remove bold font-weight from filter label text

Co-authored-by: kolaente <13721712+kolaente@users.noreply.github.com>
2025-11-15 16:27:58 +00:00

324 lines
8.2 KiB
Vue

<template>
<div
v-cy="'showTasks'"
class="is-max-width-desktop has-text-start"
>
<h3 class="mbe-2 title">
{{ pageTitle }}
</h3>
<div
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>
</div>
<p
v-if="!showAll"
class="show-tasks-options"
>
<DatepickerWithRange @update:modelValue="setDate">
<template #trigger="{toggle}">
<XButton
variant="primary"
:shadow="false"
class="mbe-2"
@click.prevent.stop="toggle()"
>
{{ $t('task.show.select') }}
</XButton>
</template>
</DatepickerWithRange>
<FancyCheckbox
:model-value="showNulls"
class="mie-2"
@update:modelValue="setShowNulls"
>
{{ $t('task.show.noDates') }}
</FancyCheckbox>
<FancyCheckbox
:model-value="showOverdue"
@update:modelValue="setShowOverdue"
>
{{ $t('task.show.overdue') }}
</FancyCheckbox>
</p>
<template v-if="!loading && (!tasks || tasks.length === 0) && showNothingToDo">
<h3 class="has-text-centered mbs-6">
{{ $t('task.show.noTasks') }}
</h3>
<LlamaCool class="llama-cool" />
</template>
<Card
v-if="hasTasks"
:padding="false"
class="has-overflow"
:has-content="false"
:loading="loading"
>
<div class="p-2">
<SingleTaskInProject
v-for="task in tasks"
:key="task.id"
:show-project="true"
:the-task="task"
@taskUpdated="updateTasks"
/>
</div>
</Card>
<div
v-else
:class="{ 'is-loading': loading}"
class="spinner"
/>
</div>
</template>
<script setup lang="ts">
import {computed, ref, watchEffect} from 'vue'
import {useRoute, useRouter} from 'vue-router'
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 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'
const props = withDefaults(defineProps<{
dateFrom?: Date | string,
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()
const {t} = useI18n({useScope: 'global'})
const tasks = ref<ITask[]>([])
const showNothingToDo = ref<boolean>(false)
const taskCollectionService = ref(new TaskCollectionService())
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)
.find(([, value]) => props.dateFrom === value[0] && props.dateTo === value[1])
?.[0]
if (typeof predefinedRange !== 'undefined') {
return t(`input.datepickerRange.ranges.${predefinedRange}`)
}
return showAll.value
? t('task.show.titleCurrent')
: t('task.show.fromuntil', {
from: formatDate(props.dateFrom, 'LL'),
until: formatDate(props.dateTo, 'LL'),
})
})
const hasTasks = computed(() => tasks.value && tasks.value.length > 0)
const userAuthenticated = computed(() => authStore.authenticated)
const loading = computed(() => taskStore.isLoading || taskCollectionService.value.loading)
interface dateStrings {
dateFrom: string,
dateTo: string,
}
function setDate(dates: dateStrings) {
router.push({
name: route.name as string,
query: {
from: dates.dateFrom ?? props.dateFrom,
to: dates.dateTo ?? props.dateTo,
showOverdue: props.showOverdue ? 'true' : 'false',
showNulls: props.showNulls ? 'true' : 'false',
},
})
}
function setShowOverdue(show: boolean) {
router.push({
name: route.name as string,
query: {
...route.query,
showOverdue: show ? 'true' : 'false',
},
})
}
function setShowNulls(show: boolean) {
router.push({
name: route.name as string,
query: {
...route.query,
showNulls: show ? 'true' : 'false',
},
})
}
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.
// Since this component is mounted as the home page before unauthenticated users get redirected
// to the login page, they will almost always see the error message.
if (!userAuthenticated.value) {
return
}
const params: TaskFilterParams = {
sort_by: ['due_date', 'id'],
order_by: ['asc', 'desc'],
filter: 'done = false',
filter_include_nulls: props.showNulls,
s: '',
expand: 'comment_count',
}
if (!showAll.value) {
params.filter += ` && due_date < '${to instanceof Date ? to.toISOString() : to}'`
// NOTE: Ideally we could also show tasks with a start or end date in the specified range, but the api
// is not capable (yet) of combining multiple filters with 'and' and 'or'.
if (!props.showOverdue) {
params.filter += ` && due_date > '${from instanceof Date ? from.toISOString() : from}'`
}
}
// 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') {
projectId = filterId
}
tasks.value = await taskStore.loadTasks(params, projectId)
emit('tasksLoaded', true)
}
// FIXME: this modification should happen in the store
function updateTasks(updatedTask: ITask) {
for (const t in tasks.value) {
if (tasks.value[t].id === updatedTask.id) {
tasks.value[t] = updatedTask
// Move the task to the end of the done tasks if it is now done
if (updatedTask.done) {
tasks.value.splice(t, 1)
tasks.value.push(updatedTask)
}
break
}
}
}
watchEffect(() => loadPendingTasks(props.dateFrom, props.dateTo))
watchEffect(() => setTitle(pageTitle.value))
</script>
<style lang="scss" scoped>
.show-tasks-options {
display: flex;
flex-direction: column;
}
.llama-cool {
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 {
color: var(--grey-700);
}
.clear-filter-button {
margin-inline-start: auto;
padding: 0.25rem 0.5rem;
&:hover {
color: var(--danger);
}
}
}
</style>