
This fixes a bug where tasks which had their done status changed were not moved in the correct bucket. This affected both frontend and api. The move of the task between buckets is now correctly done in the api and frontend - with a bit of duplicated logic between the two. This could be optimized further in the future. Resolves https://kolaente.dev/vikunja/vikunja/issues/2610
1166 lines
28 KiB
Vue
1166 lines
28 KiB
Vue
<template>
|
|
<div
|
|
class="loader-container task-view-container"
|
|
:class="{
|
|
'is-loading': taskService.loading || !visible,
|
|
'is-modal': isModal,
|
|
}"
|
|
>
|
|
<!-- Removing everything until the task is loaded to prevent empty initialization of other components -->
|
|
<div
|
|
v-if="visible"
|
|
class="task-view"
|
|
>
|
|
<Heading
|
|
ref="heading"
|
|
:task="task"
|
|
:can-write="canWrite"
|
|
@update:task="Object.assign(task, $event)"
|
|
/>
|
|
<h6
|
|
v-if="project?.id"
|
|
class="subtitle"
|
|
>
|
|
<template
|
|
v-for="p in projectStore.getAncestors(project)"
|
|
:key="p.id"
|
|
>
|
|
<a
|
|
v-if="router.options.history.state.back?.includes('/projects/'+p.id+'/') || false"
|
|
@click="router.back()"
|
|
>
|
|
{{ getProjectTitle(p) }}
|
|
</a>
|
|
<RouterLink
|
|
v-else
|
|
:to="{ name: 'project.index', params: { projectId: p.id } }"
|
|
>
|
|
{{ getProjectTitle(p) }}
|
|
</RouterLink>
|
|
<span
|
|
v-if="p.id !== project?.id"
|
|
class="has-text-grey-light"
|
|
> > </span>
|
|
</template>
|
|
</h6>
|
|
|
|
<ChecklistSummary :task="task" />
|
|
|
|
<!-- Content and buttons -->
|
|
<div class="columns mt-2">
|
|
<!-- Content -->
|
|
<div
|
|
:class="{'is-two-thirds': canWrite}"
|
|
class="column detail-content"
|
|
>
|
|
<div class="columns details">
|
|
<div
|
|
v-if="activeFields.assignees"
|
|
class="column assignees"
|
|
>
|
|
<!-- Assignees -->
|
|
<div class="detail-title">
|
|
<Icon icon="users" />
|
|
{{ $t('task.attributes.assignees') }}
|
|
</div>
|
|
<EditAssignees
|
|
v-if="canWrite"
|
|
:ref="e => setFieldRef('assignees', e)"
|
|
v-model="task.assignees"
|
|
:project-id="task.projectId"
|
|
:task-id="task.id"
|
|
/>
|
|
<AssigneeList
|
|
v-else
|
|
:assignees="task.assignees"
|
|
class="mt-2"
|
|
/>
|
|
</div>
|
|
<CustomTransition
|
|
name="flash-background"
|
|
appear
|
|
>
|
|
<div
|
|
v-if="activeFields.priority"
|
|
class="column"
|
|
>
|
|
<!-- Priority -->
|
|
<div class="detail-title">
|
|
<Icon icon="exclamation" />
|
|
{{ $t('task.attributes.priority') }}
|
|
</div>
|
|
<PrioritySelect
|
|
:ref="e => setFieldRef('priority', e)"
|
|
v-model="task.priority"
|
|
:disabled="!canWrite"
|
|
@update:modelValue="setPriority"
|
|
/>
|
|
</div>
|
|
</CustomTransition>
|
|
<CustomTransition
|
|
name="flash-background"
|
|
appear
|
|
>
|
|
<div
|
|
v-if="activeFields.dueDate"
|
|
class="column"
|
|
>
|
|
<!-- Due Date -->
|
|
<div class="detail-title">
|
|
<Icon icon="calendar" />
|
|
{{ $t('task.attributes.dueDate') }}
|
|
</div>
|
|
<div class="date-input">
|
|
<Datepicker
|
|
:ref="e => setFieldRef('dueDate', e)"
|
|
v-model="task.dueDate"
|
|
:choose-date-label="$t('task.detail.chooseDueDate')"
|
|
:disabled="taskService.loading || !canWrite"
|
|
@closeOnChange="saveTask()"
|
|
/>
|
|
<BaseButton
|
|
v-if="task.dueDate && canWrite"
|
|
class="remove"
|
|
@click="() => {task.dueDate = null;saveTask()}"
|
|
>
|
|
<span class="icon is-small">
|
|
<Icon icon="times" />
|
|
</span>
|
|
</BaseButton>
|
|
</div>
|
|
</div>
|
|
</CustomTransition>
|
|
<CustomTransition
|
|
name="flash-background"
|
|
appear
|
|
>
|
|
<div
|
|
v-if="activeFields.percentDone"
|
|
class="column"
|
|
>
|
|
<!-- Progress -->
|
|
<div class="detail-title">
|
|
<Icon icon="percent" />
|
|
{{ $t('task.attributes.percentDone') }}
|
|
</div>
|
|
<PercentDoneSelect
|
|
:ref="e => setFieldRef('percentDone', e)"
|
|
v-model="task.percentDone"
|
|
:disabled="!canWrite"
|
|
@update:modelValue="setPercentDone"
|
|
/>
|
|
</div>
|
|
</CustomTransition>
|
|
<CustomTransition
|
|
name="flash-background"
|
|
appear
|
|
>
|
|
<div
|
|
v-if="activeFields.startDate"
|
|
class="column"
|
|
>
|
|
<!-- Start Date -->
|
|
<div class="detail-title">
|
|
<Icon icon="play" />
|
|
{{ $t('task.attributes.startDate') }}
|
|
</div>
|
|
<div class="date-input">
|
|
<Datepicker
|
|
:ref="e => setFieldRef('startDate', e)"
|
|
v-model="task.startDate"
|
|
:choose-date-label="$t('task.detail.chooseStartDate')"
|
|
:disabled="taskService.loading || !canWrite"
|
|
@closeOnChange="saveTask()"
|
|
/>
|
|
<BaseButton
|
|
v-if="task.startDate && canWrite"
|
|
class="remove"
|
|
@click="() => {task.startDate = null;saveTask()}"
|
|
>
|
|
<span class="icon is-small">
|
|
<Icon icon="times" />
|
|
</span>
|
|
</BaseButton>
|
|
</div>
|
|
</div>
|
|
</CustomTransition>
|
|
<CustomTransition
|
|
name="flash-background"
|
|
appear
|
|
>
|
|
<div
|
|
v-if="activeFields.endDate"
|
|
class="column"
|
|
>
|
|
<!-- End Date -->
|
|
<div class="detail-title">
|
|
<Icon icon="stop" />
|
|
{{ $t('task.attributes.endDate') }}
|
|
</div>
|
|
<div class="date-input">
|
|
<Datepicker
|
|
:ref="e => setFieldRef('endDate', e)"
|
|
v-model="task.endDate"
|
|
:choose-date-label="$t('task.detail.chooseEndDate')"
|
|
:disabled="taskService.loading || !canWrite"
|
|
@closeOnChange="saveTask()"
|
|
/>
|
|
<BaseButton
|
|
v-if="task.endDate && canWrite"
|
|
class="remove"
|
|
@click="() => {task.endDate = null;saveTask()}"
|
|
>
|
|
<span class="icon is-small">
|
|
<Icon icon="times" />
|
|
</span>
|
|
</BaseButton>
|
|
</div>
|
|
</div>
|
|
</CustomTransition>
|
|
<CustomTransition
|
|
name="flash-background"
|
|
appear
|
|
>
|
|
<div
|
|
v-if="activeFields.reminders"
|
|
class="column"
|
|
>
|
|
<!-- Reminders -->
|
|
<div class="detail-title">
|
|
<Icon :icon="['far', 'clock']" />
|
|
{{ $t('task.attributes.reminders') }}
|
|
</div>
|
|
<Reminders
|
|
:ref="e => setFieldRef('reminders', e)"
|
|
v-model="task"
|
|
:disabled="!canWrite"
|
|
@update:modelValue="saveTask()"
|
|
/>
|
|
</div>
|
|
</CustomTransition>
|
|
<CustomTransition
|
|
name="flash-background"
|
|
appear
|
|
>
|
|
<div
|
|
v-if="activeFields.repeatAfter"
|
|
class="column"
|
|
>
|
|
<!-- Repeat after -->
|
|
<div class="is-flex is-justify-content-space-between">
|
|
<div class="detail-title">
|
|
<Icon icon="history" />
|
|
{{ $t('task.attributes.repeat') }}
|
|
</div>
|
|
<BaseButton
|
|
v-if="canWrite"
|
|
class="remove"
|
|
@click="removeRepeatAfter"
|
|
>
|
|
<span class="icon is-small">
|
|
<Icon icon="times" />
|
|
</span>
|
|
</BaseButton>
|
|
</div>
|
|
<RepeatAfter
|
|
:ref="e => setFieldRef('repeatAfter', e)"
|
|
v-model="task"
|
|
:disabled="!canWrite"
|
|
@update:modelValue="saveTask()"
|
|
/>
|
|
</div>
|
|
</CustomTransition>
|
|
<CustomTransition
|
|
name="flash-background"
|
|
appear
|
|
>
|
|
<div
|
|
v-if="activeFields.color"
|
|
class="column"
|
|
>
|
|
<!-- Color -->
|
|
<div class="detail-title">
|
|
<Icon icon="fill-drip" />
|
|
{{ $t('task.attributes.color') }}
|
|
</div>
|
|
<ColorPicker
|
|
:ref="e => setFieldRef('color', e)"
|
|
v-model="taskColor"
|
|
menu-position="bottom"
|
|
@update:modelValue="saveTask()"
|
|
/>
|
|
</div>
|
|
</CustomTransition>
|
|
</div>
|
|
|
|
<!-- Labels -->
|
|
<div
|
|
v-if="activeFields.labels"
|
|
class="labels-list details"
|
|
>
|
|
<div class="detail-title">
|
|
<span class="icon is-grey">
|
|
<Icon icon="tags" />
|
|
</span>
|
|
{{ $t('task.attributes.labels') }}
|
|
</div>
|
|
<EditLabels
|
|
:ref="e => setFieldRef('labels', e)"
|
|
v-model="task.labels"
|
|
:disabled="!canWrite"
|
|
:task-id="taskId"
|
|
:creatable="!authStore.isLinkShareAuth"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Description -->
|
|
<div class="details content description">
|
|
<Description
|
|
:model-value="task"
|
|
:can-write="canWrite"
|
|
:attachment-upload="attachmentUpload"
|
|
@update:modelValue="Object.assign(task, $event)"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Reactions -->
|
|
<Reactions
|
|
v-model="task.reactions"
|
|
entity-kind="tasks"
|
|
:entity-id="task.id"
|
|
class="details"
|
|
:disabled="!canWrite"
|
|
/>
|
|
|
|
<!-- Attachments -->
|
|
<div
|
|
v-if="activeFields.attachments || hasAttachments"
|
|
class="content attachments"
|
|
>
|
|
<Attachments
|
|
:ref="e => setFieldRef('attachments', e)"
|
|
:edit-enabled="canWrite"
|
|
:task="task"
|
|
@taskChanged="({coverImageAttachmentId}) => task.coverImageAttachmentId = coverImageAttachmentId"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Related Tasks -->
|
|
<div
|
|
v-if="activeFields.relatedTasks"
|
|
class="content details mb-0"
|
|
>
|
|
<h3>
|
|
<span class="icon is-grey">
|
|
<Icon icon="sitemap" />
|
|
</span>
|
|
{{ $t('task.attributes.relatedTasks') }}
|
|
</h3>
|
|
<RelatedTasks
|
|
:ref="e => setFieldRef('relatedTasks', e)"
|
|
:edit-enabled="canWrite"
|
|
:initial-related-tasks="task.relatedTasks"
|
|
:project-id="task.projectId"
|
|
:show-no-relations-notice="true"
|
|
:task-id="taskId"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Move Task -->
|
|
<div
|
|
v-if="activeFields.moveProject"
|
|
class="content details"
|
|
>
|
|
<h3>
|
|
<span class="icon is-grey">
|
|
<Icon icon="list" />
|
|
</span>
|
|
{{ $t('task.detail.move') }}
|
|
</h3>
|
|
<div class="field has-addons">
|
|
<div class="control is-expanded">
|
|
<ProjectSearch
|
|
:ref="e => setFieldRef('moveProject', e)"
|
|
@update:modelValue="changeProject"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Comments -->
|
|
<Comments
|
|
:can-write="canWrite"
|
|
:task-id="taskId"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Task Actions -->
|
|
<div
|
|
v-if="canWrite || isModal"
|
|
class="column is-one-third action-buttons d-print-none"
|
|
>
|
|
<template v-if="canWrite">
|
|
<x-button
|
|
v-shortcut="'t'"
|
|
:class="{'is-success': !task.done}"
|
|
:shadow="task.done"
|
|
class="is-outlined has-no-border"
|
|
icon="check-double"
|
|
variant="secondary"
|
|
@click="toggleTaskDone()"
|
|
>
|
|
{{ task.done ? $t('task.detail.undone') : $t('task.detail.done') }}
|
|
</x-button>
|
|
<TaskSubscription
|
|
entity="task"
|
|
:entity-id="task.id"
|
|
:model-value="task.subscription"
|
|
@update:modelValue="sub => task.subscription = sub"
|
|
/>
|
|
<x-button
|
|
v-shortcut="'s'"
|
|
variant="secondary"
|
|
:icon="task.isFavorite ? 'star' : ['far', 'star']"
|
|
@click="toggleFavorite"
|
|
>
|
|
{{
|
|
task.isFavorite ? $t('task.detail.actions.unfavorite') : $t('task.detail.actions.favorite')
|
|
}}
|
|
</x-button>
|
|
|
|
<span class="action-heading">{{ $t('task.detail.organization') }}</span>
|
|
|
|
<x-button
|
|
v-shortcut="'l'"
|
|
variant="secondary"
|
|
icon="tags"
|
|
@click="setFieldActive('labels')"
|
|
>
|
|
{{ $t('task.detail.actions.label') }}
|
|
</x-button>
|
|
<x-button
|
|
v-shortcut="'p'"
|
|
variant="secondary"
|
|
icon="exclamation"
|
|
@click="setFieldActive('priority')"
|
|
>
|
|
{{ $t('task.detail.actions.priority') }}
|
|
</x-button>
|
|
<x-button
|
|
variant="secondary"
|
|
icon="percent"
|
|
@click="setFieldActive('percentDone')"
|
|
>
|
|
{{ $t('task.detail.actions.percentDone') }}
|
|
</x-button>
|
|
<x-button
|
|
v-shortcut="'c'"
|
|
variant="secondary"
|
|
icon="fill-drip"
|
|
:icon-color="color"
|
|
@click="setFieldActive('color')"
|
|
>
|
|
{{ $t('task.detail.actions.color') }}
|
|
</x-button>
|
|
|
|
<span class="action-heading">{{ $t('task.detail.management') }}</span>
|
|
|
|
<x-button
|
|
v-shortcut="'a'"
|
|
v-cy="'taskDetail.assign'"
|
|
variant="secondary"
|
|
icon="users"
|
|
@click="setFieldActive('assignees')"
|
|
>
|
|
{{ $t('task.detail.actions.assign') }}
|
|
</x-button>
|
|
<x-button
|
|
v-shortcut="'f'"
|
|
variant="secondary"
|
|
icon="paperclip"
|
|
@click="setFieldActive('attachments')"
|
|
>
|
|
{{ $t('task.detail.actions.attachments') }}
|
|
</x-button>
|
|
<x-button
|
|
v-shortcut="'r'"
|
|
variant="secondary"
|
|
icon="sitemap"
|
|
@click="setRelatedTasksActive()"
|
|
>
|
|
{{ $t('task.detail.actions.relatedTasks') }}
|
|
</x-button>
|
|
<x-button
|
|
v-shortcut="'m'"
|
|
variant="secondary"
|
|
icon="list"
|
|
@click="setFieldActive('moveProject')"
|
|
>
|
|
{{ $t('task.detail.actions.moveProject') }}
|
|
</x-button>
|
|
|
|
<span class="action-heading">{{ $t('task.detail.dateAndTime') }}</span>
|
|
|
|
<x-button
|
|
v-shortcut="'d'"
|
|
variant="secondary"
|
|
icon="calendar"
|
|
@click="setFieldActive('dueDate')"
|
|
>
|
|
{{ $t('task.detail.actions.dueDate') }}
|
|
</x-button>
|
|
<x-button
|
|
variant="secondary"
|
|
icon="play"
|
|
@click="setFieldActive('startDate')"
|
|
>
|
|
{{ $t('task.detail.actions.startDate') }}
|
|
</x-button>
|
|
<x-button
|
|
variant="secondary"
|
|
icon="stop"
|
|
@click="setFieldActive('endDate')"
|
|
>
|
|
{{ $t('task.detail.actions.endDate') }}
|
|
</x-button>
|
|
<x-button
|
|
v-shortcut="'Alt+r'"
|
|
variant="secondary"
|
|
:icon="['far', 'clock']"
|
|
@click="setFieldActive('reminders')"
|
|
>
|
|
{{ $t('task.detail.actions.reminders') }}
|
|
</x-button>
|
|
<x-button
|
|
variant="secondary"
|
|
icon="history"
|
|
@click="setFieldActive('repeatAfter')"
|
|
>
|
|
{{ $t('task.detail.actions.repeatAfter') }}
|
|
</x-button>
|
|
<x-button
|
|
v-shortcut="'Shift+Delete'"
|
|
icon="trash-alt"
|
|
:shadow="false"
|
|
class="is-danger is-outlined has-no-border"
|
|
@click="showDeleteModal = true"
|
|
>
|
|
{{ $t('task.detail.actions.delete') }}
|
|
</x-button>
|
|
</template>
|
|
|
|
<!-- Created / Updated [by] -->
|
|
<CreatedUpdated :task="task" />
|
|
</div>
|
|
</div>
|
|
<!-- Created / Updated [by] -->
|
|
<CreatedUpdated
|
|
v-if="!canWrite && !isModal"
|
|
:task="task"
|
|
/>
|
|
</div>
|
|
|
|
<Modal
|
|
:enabled="showDeleteModal"
|
|
@close="showDeleteModal = false"
|
|
@submit="deleteTask()"
|
|
>
|
|
<template #header>
|
|
<span>{{ $t('task.detail.delete.header') }}</span>
|
|
</template>
|
|
|
|
<template #text>
|
|
<p>
|
|
{{ $t('task.detail.delete.text1') }}<br>
|
|
{{ $t('task.detail.delete.text2') }}
|
|
</p>
|
|
</template>
|
|
</Modal>
|
|
</div>
|
|
</template>
|
|
|
|
<script lang="ts" setup>
|
|
import {ref, reactive, shallowReactive, computed, watch, nextTick, onMounted, onBeforeUnmount} from 'vue'
|
|
import {useRouter, type RouteLocation} from 'vue-router'
|
|
import {useI18n} from 'vue-i18n'
|
|
import {unrefElement} from '@vueuse/core'
|
|
import {klona} from 'klona/lite'
|
|
import {eventToHotkeyString} from '@github/hotkey'
|
|
|
|
import TaskService from '@/services/task'
|
|
import TaskModel from '@/models/task'
|
|
|
|
import type {ITask} from '@/modelTypes/ITask'
|
|
import type {IProject} from '@/modelTypes/IProject'
|
|
|
|
import {PRIORITIES, type Priority} from '@/constants/priorities'
|
|
import {RIGHTS} from '@/constants/rights'
|
|
|
|
import BaseButton from '@/components/base/BaseButton.vue'
|
|
|
|
// partials
|
|
import Attachments from '@/components/tasks/partials/Attachments.vue'
|
|
import ChecklistSummary from '@/components/tasks/partials/ChecklistSummary.vue'
|
|
import ColorPicker from '@/components/input/ColorPicker.vue'
|
|
import Comments from '@/components/tasks/partials/Comments.vue'
|
|
import CreatedUpdated from '@/components/tasks/partials/CreatedUpdated.vue'
|
|
import Datepicker from '@/components/input/Datepicker.vue'
|
|
import Description from '@/components/tasks/partials/Description.vue'
|
|
import EditAssignees from '@/components/tasks/partials/EditAssignees.vue'
|
|
import EditLabels from '@/components/tasks/partials/EditLabels.vue'
|
|
import Heading from '@/components/tasks/partials/Heading.vue'
|
|
import ProjectSearch from '@/components/tasks/partials/ProjectSearch.vue'
|
|
import PercentDoneSelect from '@/components/tasks/partials/PercentDoneSelect.vue'
|
|
import PrioritySelect from '@/components/tasks/partials/PrioritySelect.vue'
|
|
import RelatedTasks from '@/components/tasks/partials/RelatedTasks.vue'
|
|
import Reminders from '@/components/tasks/partials/Reminders.vue'
|
|
import RepeatAfter from '@/components/tasks/partials/RepeatAfter.vue'
|
|
import TaskSubscription from '@/components/misc/Subscription.vue'
|
|
import CustomTransition from '@/components/misc/CustomTransition.vue'
|
|
|
|
import {uploadFile} from '@/helpers/attachments'
|
|
import {getProjectTitle} from '@/helpers/getProjectTitle'
|
|
import {scrollIntoView} from '@/helpers/scrollIntoView'
|
|
|
|
import {useAttachmentStore} from '@/stores/attachments'
|
|
import {useTaskStore} from '@/stores/tasks'
|
|
import {useKanbanStore} from '@/stores/kanban'
|
|
|
|
import {useTitle} from '@/composables/useTitle'
|
|
|
|
import {success} from '@/message'
|
|
import type {Action as MessageAction} from '@/message'
|
|
import {useProjectStore} from '@/stores/projects'
|
|
import {TASK_REPEAT_MODES} from '@/types/IRepeatMode'
|
|
import {useAuthStore} from '@/stores/auth'
|
|
import {playPopSound} from '@/helpers/playPop'
|
|
import AssigneeList from '@/components/tasks/partials/AssigneeList.vue'
|
|
import Reactions from '@/components/input/Reactions.vue'
|
|
|
|
const props = defineProps<{
|
|
taskId: ITask['id'],
|
|
backdropView?: RouteLocation['fullPath'],
|
|
}>()
|
|
|
|
defineEmits<{
|
|
'close': [],
|
|
}>()
|
|
|
|
const router = useRouter()
|
|
const {t} = useI18n({useScope: 'global'})
|
|
|
|
const projectStore = useProjectStore()
|
|
const attachmentStore = useAttachmentStore()
|
|
const taskStore = useTaskStore()
|
|
const kanbanStore = useKanbanStore()
|
|
const authStore = useAuthStore()
|
|
|
|
const task = ref<ITask>(new TaskModel())
|
|
const taskTitle = computed(() => task.value.title)
|
|
useTitle(taskTitle)
|
|
|
|
// See https://github.com/github/hotkey/discussions/85#discussioncomment-5214660
|
|
function saveTaskViaHotkey(event) {
|
|
const hotkeyString = eventToHotkeyString(event)
|
|
if (!hotkeyString) return
|
|
if (hotkeyString !== 'Control+s' && hotkeyString !== 'Meta+s') return
|
|
event.preventDefault()
|
|
|
|
saveTask()
|
|
}
|
|
|
|
onMounted(() => {
|
|
document.addEventListener('keydown', saveTaskViaHotkey)
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
document.removeEventListener('keydown', saveTaskViaHotkey)
|
|
})
|
|
|
|
// We doubled the task color property here because verte does not have a real change property, leading
|
|
// to the color property change being triggered when the # is removed from it, leading to an update,
|
|
// which leads in turn to a change... This creates an infinite loop in which the task is updated, changed,
|
|
// updated, changed, updated and so on.
|
|
// To prevent this, we put the task color property in a seperate value which is set to the task color
|
|
// when it is saved and loaded.
|
|
const taskColor = ref<ITask['hexColor']>('')
|
|
|
|
// Used to avoid flashing of empty elements if the task content is not yet loaded.
|
|
const visible = ref(false)
|
|
|
|
const project = computed(() => projectStore.projects[task.value.projectId])
|
|
|
|
const canWrite = computed(() => (
|
|
task.value.maxRight !== null &&
|
|
task.value.maxRight > RIGHTS.READ
|
|
))
|
|
|
|
const color = computed(() => {
|
|
const color = task.value.getHexColor
|
|
? task.value.getHexColor()
|
|
: undefined
|
|
|
|
return color
|
|
})
|
|
|
|
const hasAttachments = computed(() => attachmentStore.attachments.length > 0)
|
|
|
|
const isModal = computed(() => Boolean(props.backdropView))
|
|
|
|
function attachmentUpload(file: File, onSuccess?: (url: string) => void) {
|
|
return uploadFile(props.taskId, file, onSuccess)
|
|
}
|
|
|
|
const heading = ref<HTMLElement | null>(null)
|
|
|
|
async function scrollToHeading() {
|
|
scrollIntoView(unrefElement(heading))
|
|
}
|
|
|
|
const taskService = shallowReactive(new TaskService())
|
|
|
|
// load task
|
|
watch(
|
|
() => props.taskId,
|
|
async (id) => {
|
|
if (id === undefined) {
|
|
return
|
|
}
|
|
|
|
try {
|
|
const loaded = await taskService.get({id})
|
|
Object.assign(task.value, loaded)
|
|
attachmentStore.set(task.value.attachments)
|
|
taskColor.value = task.value.hexColor
|
|
setActiveFields()
|
|
} finally {
|
|
await nextTick()
|
|
scrollToHeading()
|
|
visible.value = true
|
|
}
|
|
}, {immediate: true})
|
|
|
|
type FieldType =
|
|
| 'assignees'
|
|
| 'attachments'
|
|
| 'color'
|
|
| 'dueDate'
|
|
| 'endDate'
|
|
| 'labels'
|
|
| 'moveProject'
|
|
| 'percentDone'
|
|
| 'priority'
|
|
| 'relatedTasks'
|
|
| 'reminders'
|
|
| 'repeatAfter'
|
|
| 'startDate'
|
|
|
|
const activeFields: { [type in FieldType]: boolean } = reactive({
|
|
assignees: false,
|
|
attachments: false,
|
|
color: false,
|
|
dueDate: false,
|
|
endDate: false,
|
|
labels: false,
|
|
moveProject: false,
|
|
percentDone: false,
|
|
priority: false,
|
|
relatedTasks: false,
|
|
reminders: false,
|
|
repeatAfter: false,
|
|
startDate: false,
|
|
})
|
|
|
|
function setActiveFields() {
|
|
// FIXME: are these lines necessary?
|
|
// task.startDate = task.startDate || null
|
|
// task.endDate = task.endDate || null
|
|
|
|
// Set all active fields based on values in the model
|
|
activeFields.assignees = task.value.assignees.length > 0
|
|
activeFields.attachments = task.value.attachments.length > 0
|
|
activeFields.dueDate = task.value.dueDate !== null
|
|
activeFields.endDate = task.value.endDate !== null
|
|
activeFields.labels = task.value.labels.length > 0
|
|
activeFields.percentDone = task.value.percentDone > 0
|
|
activeFields.priority = task.value.priority !== PRIORITIES.UNSET
|
|
activeFields.relatedTasks = Object.keys(task.value.relatedTasks).length > 0
|
|
activeFields.reminders = task.value.reminders.length > 0
|
|
activeFields.repeatAfter = task.value.repeatAfter?.amount > 0 || task.value.repeatMode !== TASK_REPEAT_MODES.REPEAT_MODE_DEFAULT
|
|
activeFields.startDate = task.value.startDate !== null
|
|
}
|
|
|
|
const activeFieldElements: { [id in FieldType]: HTMLElement | null } = reactive({
|
|
assignees: null,
|
|
attachments: null,
|
|
color: null,
|
|
dueDate: null,
|
|
endDate: null,
|
|
labels: null,
|
|
moveProject: null,
|
|
percentDone: null,
|
|
priority: null,
|
|
relatedTasks: null,
|
|
reminders: null,
|
|
repeatAfter: null,
|
|
startDate: null,
|
|
})
|
|
|
|
function setFieldRef(name, e) {
|
|
activeFieldElements[name] = unrefElement(e)
|
|
}
|
|
|
|
function setFieldActive(fieldName: keyof typeof activeFields) {
|
|
activeFields[fieldName] = true
|
|
nextTick(() => {
|
|
const el = activeFieldElements[fieldName]
|
|
|
|
if (!el) {
|
|
return
|
|
}
|
|
|
|
el.focus()
|
|
|
|
// scroll the field to the center of the screen if not in viewport already
|
|
scrollIntoView(el)
|
|
})
|
|
}
|
|
|
|
async function saveTask(
|
|
currentTask: ITask | null = null,
|
|
undoCallback?: () => void,
|
|
) {
|
|
if (currentTask === null) {
|
|
currentTask = klona(task.value)
|
|
}
|
|
|
|
if (!canWrite.value) {
|
|
return
|
|
}
|
|
|
|
currentTask.hexColor = taskColor.value
|
|
|
|
// If no end date is being set, but a start date and due date,
|
|
// use the due date as the end date
|
|
if (
|
|
currentTask.endDate === null &&
|
|
currentTask.startDate !== null &&
|
|
currentTask.dueDate !== null
|
|
) {
|
|
currentTask.endDate = currentTask.dueDate
|
|
}
|
|
|
|
const updatedTask = await taskStore.update(currentTask) // TODO: markraw ?
|
|
Object.assign(task.value, updatedTask)
|
|
setActiveFields()
|
|
|
|
let actions: MessageAction[] = []
|
|
if (undoCallback) {
|
|
actions = [{
|
|
title: t('task.undo'),
|
|
callback: undoCallback,
|
|
}]
|
|
}
|
|
success({message: t('task.detail.updateSuccess')}, actions)
|
|
}
|
|
|
|
const showDeleteModal = ref(false)
|
|
|
|
async function deleteTask() {
|
|
await taskStore.delete(task.value)
|
|
success({message: t('task.detail.deleteSuccess')})
|
|
router.push({name: 'project.index', params: {projectId: task.value.projectId}})
|
|
}
|
|
|
|
async function toggleTaskDone() {
|
|
const newTask = {
|
|
...task.value,
|
|
done: !task.value.done,
|
|
}
|
|
|
|
if (newTask.done) {
|
|
playPopSound()
|
|
}
|
|
|
|
await saveTask(
|
|
newTask,
|
|
toggleTaskDone,
|
|
)
|
|
}
|
|
|
|
async function changeProject(project: IProject) {
|
|
kanbanStore.removeTaskInBucket(task.value)
|
|
await saveTask({
|
|
...task.value,
|
|
projectId: project.id,
|
|
})
|
|
}
|
|
|
|
async function toggleFavorite() {
|
|
const newTask = await taskStore.toggleFavorite(task.value)
|
|
Object.assign(task.value, newTask)
|
|
}
|
|
|
|
async function setPriority(priority: Priority) {
|
|
const newTask: ITask = {
|
|
...task.value,
|
|
priority,
|
|
}
|
|
|
|
return saveTask(newTask)
|
|
}
|
|
|
|
async function setPercentDone(percentDone: number) {
|
|
const newTask: ITask = {
|
|
...task.value,
|
|
percentDone,
|
|
}
|
|
|
|
return saveTask(newTask)
|
|
}
|
|
|
|
async function removeRepeatAfter() {
|
|
task.value.repeatAfter.amount = 0
|
|
task.value.repeatMode = TASK_REPEAT_MODES.REPEAT_MODE_DEFAULT
|
|
await saveTask()
|
|
}
|
|
|
|
function setRelatedTasksActive() {
|
|
setFieldActive('relatedTasks')
|
|
|
|
// If the related tasks are already available, show the form again
|
|
const el = activeFieldElements['relatedTasks']
|
|
for (const child in el?.children) {
|
|
if (el?.children[child]?.id === 'showRelatedTasksFormButton') {
|
|
el?.children[child]?.click()
|
|
break
|
|
}
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style lang="scss" scoped>
|
|
.task-view-container {
|
|
// simulate sass lighten($primary, 30) by increasing lightness 30% to 73%
|
|
--primary-light: hsla(var(--primary-h), var(--primary-s), 73%, var(--primary-a));
|
|
padding-bottom: 0;
|
|
|
|
@media screen and (min-width: $desktop) {
|
|
padding-bottom: 1rem;
|
|
}
|
|
}
|
|
|
|
.task-view {
|
|
padding-top: 1rem;
|
|
padding-inline: .5rem;
|
|
background-color: var(--site-background);
|
|
|
|
@media screen and (min-width: $desktop) {
|
|
padding: 1rem;
|
|
}
|
|
}
|
|
|
|
.is-modal .task-view {
|
|
border-radius: $radius;
|
|
padding: 1rem;
|
|
color: var(--text);
|
|
background-color: var(--site-background) !important;
|
|
|
|
@media screen and (max-width: 800px) {
|
|
border-radius: 0;
|
|
padding-top: 2rem;
|
|
}
|
|
}
|
|
|
|
.task-view * {
|
|
transition: opacity 50ms ease;
|
|
}
|
|
|
|
.is-loading .task-view * {
|
|
opacity: 0;
|
|
}
|
|
|
|
|
|
.subtitle {
|
|
color: var(--grey-500);
|
|
margin-bottom: 1rem;
|
|
|
|
a {
|
|
color: var(--grey-800);
|
|
}
|
|
}
|
|
|
|
h3 .button {
|
|
vertical-align: middle;
|
|
}
|
|
|
|
.icon.is-grey {
|
|
color: var(--grey-400);
|
|
}
|
|
|
|
.date-input {
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
|
|
.remove {
|
|
color: var(--danger);
|
|
vertical-align: middle;
|
|
padding-left: .5rem;
|
|
line-height: 1;
|
|
}
|
|
|
|
:deep(.datepicker) {
|
|
width: 100%;
|
|
|
|
.show {
|
|
color: var(--text);
|
|
padding: .25rem .5rem;
|
|
transition: background-color $transition;
|
|
border-radius: $radius;
|
|
display: block;
|
|
margin: .1rem 0;
|
|
width: 100%;
|
|
text-align: left;
|
|
|
|
&:hover {
|
|
background: var(--white);
|
|
}
|
|
}
|
|
|
|
&.disabled .show:hover {
|
|
background: transparent;
|
|
}
|
|
}
|
|
|
|
.details {
|
|
padding-bottom: 0.75rem;
|
|
flex-flow: row wrap;
|
|
margin-bottom: 0;
|
|
|
|
.detail-title {
|
|
display: block;
|
|
color: var(--grey-400);
|
|
}
|
|
|
|
.none {
|
|
font-style: italic;
|
|
}
|
|
|
|
// Break after the 2nd element
|
|
.column:nth-child(2n) {
|
|
page-break-after: always; // CSS 2.1 syntax
|
|
break-after: always; // New syntax
|
|
}
|
|
|
|
}
|
|
|
|
.details.labels-list,
|
|
.assignees {
|
|
:deep(.multiselect) {
|
|
.input-wrapper {
|
|
&:not(:focus-within):not(:hover) {
|
|
background: transparent;
|
|
border-color: transparent;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
:deep(.details),
|
|
:deep(.heading) {
|
|
.input:not(.has-defaults),
|
|
.textarea,
|
|
.select:not(.has-defaults) select {
|
|
cursor: pointer;
|
|
transition: all $transition-duration;
|
|
|
|
&::placeholder {
|
|
color: var(--text-light);
|
|
opacity: 1;
|
|
font-style: italic;
|
|
}
|
|
|
|
&:not(:disabled) {
|
|
&:hover,
|
|
&:active,
|
|
&:focus {
|
|
background: var(--scheme-main);
|
|
border-color: var(--border);
|
|
cursor: text;
|
|
}
|
|
|
|
&:hover,
|
|
&:active {
|
|
cursor: text;
|
|
border-color: var(--link)
|
|
}
|
|
}
|
|
}
|
|
|
|
.select:not(.has-defaults):after {
|
|
opacity: 0;
|
|
}
|
|
|
|
.select:not(.has-defaults):hover:after {
|
|
opacity: 1;
|
|
}
|
|
}
|
|
|
|
.attachments {
|
|
margin-bottom: 0;
|
|
|
|
table tr:last-child td {
|
|
border-bottom: none;
|
|
}
|
|
}
|
|
|
|
.action-buttons {
|
|
@media screen and (min-width: $tablet) {
|
|
position: sticky;
|
|
top: $navbar-height + 1.5rem;
|
|
align-self: flex-start;
|
|
}
|
|
|
|
.button {
|
|
width: 100%;
|
|
margin-bottom: .5rem;
|
|
justify-content: left;
|
|
|
|
&.has-light-text {
|
|
color: var(--white);
|
|
}
|
|
}
|
|
}
|
|
|
|
.is-modal .action-buttons {
|
|
// we need same top margin for the modal close button
|
|
@media screen and (min-width: $tablet) {
|
|
top: 6.5rem;
|
|
}
|
|
// this is the moment when the fixed close button is outside the modal
|
|
// => we can fill up the space again
|
|
@media screen and (min-width: calc(#{$desktop} + 84px)) {
|
|
top: 0;
|
|
}
|
|
}
|
|
|
|
.checklist-summary {
|
|
padding-left: .25rem;
|
|
}
|
|
|
|
.detail-content {
|
|
@media print {
|
|
width: 100% !important;
|
|
}
|
|
}
|
|
|
|
.action-heading {
|
|
text-transform: uppercase;
|
|
color: var(--grey-700);
|
|
font-size: .75rem;
|
|
font-weight: 700;
|
|
margin: .5rem 0;
|
|
display: inline-block;
|
|
}
|
|
</style> |