mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-04-24 22:25:15 +00:00
- 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>
324 lines
8.2 KiB
Vue
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>
|