<template> <div :class="{ 'is-loading': taskService.loading, 'visible': visible}" class="loader-container task-view-container"> <div class="task-view"> <heading v-model:task="task" :can-write="canWrite" ref="heading"/> <h6 class="subtitle" v-if="parent && parent.namespace && parent.list"> {{ getNamespaceTitle(parent.namespace) }} > <router-link :to="{ name: 'list.index', params: { listId: parent.list.id } }"> {{ getListTitle(parent.list) }} </router-link> </h6> <checklist-summary :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 class="column assignees" v-if="activeFields.assignees"> <!-- Assignees --> <div class="detail-title"> <icon icon="users"/> {{ $t('task.attributes.assignees') }} </div> <edit-assignees :disabled="!canWrite" :list-id="task.listId" :task-id="task.id" ref="assignees" v-model="task.assignees" /> </div> <transition name="flash-background" appear> <div class="column" v-if="activeFields.priority"> <!-- Priority --> <div class="detail-title"> <icon icon="exclamation"/> {{ $t('task.attributes.priority') }} </div> <priority-select :disabled="!canWrite" @change="saveTask" ref="priority" v-model="task.priority"/> </div> </transition> <transition name="flash-background" appear> <div class="column" v-if="activeFields.dueDate"> <!-- Due Date --> <div class="detail-title"> <icon icon="calendar"/> {{ $t('task.attributes.dueDate') }} </div> <div class="date-input"> <datepicker v-model="task.dueDate" @close-on-change="() => saveTask()" :choose-date-label="$t('task.detail.chooseDueDate')" :disabled="taskService.loading || !canWrite" ref="dueDate" /> <a @click="() => {task.dueDate = null;saveTask()}" v-if="task.dueDate && canWrite" class="remove"> <span class="icon is-small"> <icon icon="times"></icon> </span> </a> </div> </div> </transition> <transition name="flash-background" appear> <div class="column" v-if="activeFields.percentDone"> <!-- Progress --> <div class="detail-title"> <icon icon="percent"/> {{ $t('task.attributes.percentDone') }} </div> <percent-done-select :disabled="!canWrite" @change="saveTask" ref="percentDone" v-model="task.percentDone"/> </div> </transition> <transition name="flash-background" appear> <div class="column" v-if="activeFields.startDate"> <!-- Start Date --> <div class="detail-title"> <icon icon="play"/> {{ $t('task.attributes.startDate') }} </div> <div class="date-input"> <datepicker v-model="task.startDate" @close-on-change="() => saveTask()" :choose-date-label="$t('task.detail.chooseStartDate')" :disabled="taskService.loading || !canWrite" ref="startDate" /> <a @click="() => {task.startDate = null;saveTask()}" v-if="task.startDate && canWrite" class="remove" > <span class="icon is-small"> <icon icon="times"></icon> </span> </a> </div> </div> </transition> <transition name="flash-background" appear> <div class="column" v-if="activeFields.endDate"> <!-- End Date --> <div class="detail-title"> <icon icon="stop"/> {{ $t('task.attributes.endDate') }} </div> <div class="date-input"> <datepicker v-model="task.endDate" @close-on-change="() => saveTask()" :choose-date-label="$t('task.detail.chooseEndDate')" :disabled="taskService.loading || !canWrite" ref="endDate" /> <a @click="() => {task.endDate = null;saveTask()}" v-if="task.endDate && canWrite" class="remove"> <span class="icon is-small"> <icon icon="times"></icon> </span> </a> </div> </div> </transition> <transition name="flash-background" appear> <div class="column" v-if="activeFields.reminders"> <!-- Reminders --> <div class="detail-title"> <icon :icon="['far', 'clock']"/> {{ $t('task.attributes.reminders') }} </div> <reminders :disabled="!canWrite" @change="saveTask" ref="reminders" v-model="task.reminderDates"/> </div> </transition> <transition name="flash-background" appear> <div class="column" v-if="activeFields.repeatAfter"> <!-- Repeat after --> <div class="detail-title"> <icon icon="history"/> {{ $t('task.attributes.repeat') }} </div> <repeat-after :disabled="!canWrite" @change="saveTask" ref="repeatAfter" v-model="task"/> </div> </transition> <transition name="flash-background" appear> <div class="column" v-if="activeFields.color"> <!-- Color --> <div class="detail-title"> <icon icon="fill-drip"/> {{ $t('task.attributes.color') }} </div> <color-picker @change="saveTask" menu-position="bottom" ref="color" v-model="taskColor"/> </div> </transition> </div> <!-- Labels --> <div class="labels-list details" v-if="activeFields.labels"> <div class="detail-title"> <span class="icon is-grey"> <icon icon="tags"/> </span> {{ $t('task.attributes.labels') }} </div> <edit-labels :disabled="!canWrite" :task-id="taskId" ref="labels" v-model="task.labels"/> </div> <!-- Description --> <div class="details content description"> <description v-model="task" :can-write="canWrite" :attachment-upload="attachmentUpload" /> </div> <!-- Attachments --> <div class="content attachments" v-if="activeFields.attachments || hasAttachments"> <attachments :edit-enabled="canWrite" :task-id="taskId" ref="attachments" /> </div> <!-- Related Tasks --> <div class="content details mb-0" v-if="activeFields.relatedTasks"> <h3> <span class="icon is-grey"> <icon icon="sitemap"/> </span> {{ $t('task.attributes.relatedTasks') }} </h3> <related-tasks :edit-enabled="canWrite" :initial-related-tasks="task.relatedTasks" :list-id="task.listId" :show-no-relations-notice="true" :task-id="taskId" ref="relatedTasks" /> </div> <!-- Move Task --> <div class="content details" v-if="activeFields.moveList"> <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"> <list-search @update:modelValue="changeList" ref="moveList"/> </div> </div> </div> <!-- Comments --> <comments :can-write="canWrite" :task-id="taskId"/> </div> <div class="column is-one-third action-buttons d-print-none" v-if="canWrite || shouldShowClosePopup"> <BaseButton @click="$router.back()" class="is-fullwidth is-block has-text-centered mb-4 has-text-primary" v-if="shouldShowClosePopup"> <icon icon="arrow-left"/> {{ $t('task.detail.closePopup') }} </BaseButton> <template v-if="canWrite"> <x-button :class="{'is-success': !task.done}" :shadow="task.done" @click="toggleTaskDone()" class="is-outlined has-no-border" icon="check-double" variant="secondary" v-shortcut="'t'" > {{ task.done ? $t('task.detail.undone') : $t('task.detail.done') }} </x-button> <task-subscription entity="task" :entity-id="task.id" :subscription="task.subscription" @change="sub => task.subscription = sub" /> <x-button @click="setFieldActive('assignees')" variant="secondary" v-shortcut="'a'" v-cy="'taskDetail.assign'" > <span class="icon is-small"><icon icon="users"/></span> {{ $t('task.detail.actions.assign') }} </x-button> <x-button @click="setFieldActive('labels')" variant="secondary" icon="tags" v-shortcut="'l'" > {{ $t('task.detail.actions.label') }} </x-button> <x-button @click="setFieldActive('priority')" variant="secondary" icon="exclamation" > {{ $t('task.detail.actions.priority') }} </x-button> <x-button @click="setFieldActive('dueDate')" variant="secondary" icon="calendar" v-shortcut="'d'" > {{ $t('task.detail.actions.dueDate') }} </x-button> <x-button @click="setFieldActive('startDate')" variant="secondary" icon="play" > {{ $t('task.detail.actions.startDate') }} </x-button> <x-button @click="setFieldActive('endDate')" variant="secondary" icon="stop" > {{ $t('task.detail.actions.endDate') }} </x-button> <x-button @click="setFieldActive('reminders')" variant="secondary" :icon="['far', 'clock']" v-shortcut="'Alt+r'" > {{ $t('task.detail.actions.reminders') }} </x-button> <x-button @click="setFieldActive('repeatAfter')" variant="secondary" icon="history" > {{ $t('task.detail.actions.repeatAfter') }} </x-button> <x-button @click="setFieldActive('percentDone')" variant="secondary" icon="percent" > {{ $t('task.detail.actions.percentDone') }} </x-button> <x-button @click="setFieldActive('attachments')" variant="secondary" icon="paperclip" v-shortcut="'f'" > {{ $t('task.detail.actions.attachments') }} </x-button> <x-button @click="setFieldActive('relatedTasks')" variant="secondary" icon="sitemap" v-shortcut="'r'" > {{ $t('task.detail.actions.relatedTasks') }} </x-button> <x-button @click="setFieldActive('moveList')" variant="secondary" icon="list" v-shortcut="'m'" > {{ $t('task.detail.actions.moveList') }} </x-button> <x-button @click="setFieldActive('color')" variant="secondary" icon="fill-drip" v-shortcut="'c'" > {{ $t('task.detail.actions.color') }} </x-button> <x-button @click="toggleFavorite" variant="secondary" :icon="task.isFavorite ? 'star' : ['far', 'star']" > {{ task.isFavorite ? $t('task.detail.actions.unfavorite') : $t('task.detail.actions.favorite') }} </x-button> <x-button @click="showDeleteModal = true" icon="trash-alt" :shadow="false" class="is-danger is-outlined has-no-border" > {{ $t('task.detail.actions.delete') }} </x-button> </template> <!-- Created / Updated [by] --> <created-updated :task="task"/> </div> </div> <!-- Created / Updated [by] --> <created-updated :task="task" v-if="!canWrite && !shouldShowClosePopup"/> </div> <transition name="modal"> <modal @close="showDeleteModal = false" @submit="deleteTask()" v-if="showDeleteModal" > <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> </transition> </div> </template> <script lang="ts"> import {defineComponent} from 'vue' import TaskService from '../../services/task' import TaskModel from '../../models/task' import priorites from '../../models/constants/priorities.json' import rights from '../../models/constants/rights.json' import PrioritySelect from '../../components/tasks/partials/prioritySelect' import PercentDoneSelect from '../../components/tasks/partials/percentDoneSelect' import EditLabels from '../../components/tasks/partials/editLabels' import EditAssignees from '../../components/tasks/partials/editAssignees' import Attachments from '../../components/tasks/partials/attachments' import RelatedTasks from '../../components/tasks/partials/relatedTasks' import RepeatAfter from '../../components/tasks/partials/repeatAfter' import Reminders from '../../components/tasks/partials/reminders' import Comments from '../../components/tasks/partials/comments' import ListSearch from '../../components/tasks/partials/listSearch' import description from '@/components/tasks/partials/description.vue' import ColorPicker from '../../components/input/colorPicker' import heading from '@/components/tasks/partials/heading.vue' import Datepicker from '@/components/input/datepicker.vue' import BaseButton from '@/components/base/BaseButton' import {playPop} from '@/helpers/playPop' import TaskSubscription from '@/components/misc/subscription.vue' import {CURRENT_LIST} from '@/store/mutation-types' import {uploadFile} from '@/helpers/attachments' import ChecklistSummary from '../../components/tasks/partials/checklist-summary' import CreatedUpdated from '@/components/tasks/partials/createdUpdated' import { setTitle } from '@/helpers/setTitle' function scrollIntoView(el) { if (!el) { return } const boundingRect = el.getBoundingClientRect() const scrollY = window.scrollY if ( boundingRect.top > (scrollY + window.innerHeight) || boundingRect.top < scrollY ) { el.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest', }) } } export default defineComponent({ name: 'TaskDetailView', components: { BaseButton, CreatedUpdated, ChecklistSummary, TaskSubscription, Datepicker, ColorPicker, ListSearch, Reminders, RepeatAfter, RelatedTasks, Attachments, EditAssignees, EditLabels, PercentDoneSelect, PrioritySelect, Comments, description, heading, }, props: { taskId: { type: Number, required: true, }, }, data() { return { taskService: new TaskService(), task: new TaskModel(), // 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. taskColor: '', showDeleteModal: false, // Used to avoid flashing of empty elements if the task content is not yet loaded. visible: false, priorities: priorites, activeFields: { assignees: false, priority: false, dueDate: false, percentDone: false, startDate: false, endDate: false, reminders: false, repeatAfter: false, labels: false, attachments: false, relatedTasks: false, moveList: false, color: false, }, } }, watch: { taskId: { handler: 'loadTask', immediate: true, }, parent: { handler(parent) { const parentList = parent !== null ? parent.list : null if (parentList !== null) { this.$store.dispatch(CURRENT_LIST, {list: parentList}) } }, immediate: true, }, // Using a watcher here because the header component handles saving the task with the api but we want to decouple // it from the page title. 'task.title': { handler(title) { this.setTitle(title) }, }, }, computed: { currentList() { return this.$store.state[CURRENT_LIST] }, parent() { if (!this.task.listId) { return { namespace: null, list: null, } } if (!this.$store.getters['namespaces/getListAndNamespaceById']) { return null } return this.$store.getters['namespaces/getListAndNamespaceById'](this.task.listId) }, canWrite() { return typeof this.task !== 'undefined' && typeof this.task.maxRight !== 'undefined' && this.task.maxRight > rights.READ }, hasAttachments() { return this.$store.state.attachments.attachments.length > 0 }, shouldShowClosePopup() { return this.$route.name.includes('kanban') }, }, methods: { attachmentUpload(...args) { return uploadFile(this.taskId, ...args) }, async loadTask(taskId) { if (taskId === undefined) { return } try { this.task = await this.taskService.get({id: taskId}) this.$store.commit('attachments/set', this.task.attachments) this.taskColor = this.task.hexColor this.setActiveFields() await this.$nextTick() setTitle(this.task.title) } finally { this.scrollToHeading() await this.$nextTick() this.visible = true } }, scrollToHeading() { if(!this.$refs?.heading?.$el) { return } this.$refs.heading.$el.scrollIntoView({block: 'center'}) }, setActiveFields() { this.task.startDate = this.task.startDate ? this.task.startDate : null this.task.endDate = this.task.endDate ? this.task.endDate : null // Set all active fields based on values in the model this.activeFields.assignees = this.task.assignees.length > 0 this.activeFields.priority = this.task.priority !== priorites.UNSET this.activeFields.dueDate = this.task.dueDate !== null this.activeFields.percentDone = this.task.percentDone > 0 this.activeFields.startDate = this.task.startDate !== null this.activeFields.endDate = this.task.endDate !== null this.activeFields.reminders = this.task.reminderDates.length > 0 this.activeFields.repeatAfter = this.task.repeatAfter.amount > 0 this.activeFields.labels = this.task.labels.length > 0 this.activeFields.attachments = this.task.attachments.length > 0 this.activeFields.relatedTasks = Object.keys(this.task.relatedTasks).length > 0 }, async saveTask(showNotification = true, undoCallback = null) { if (!this.canWrite) { return } // We're doing the whole update in a nextTick because sometimes race conditions can occur when // setting the due date on mobile which leads to no due date change being saved. await this.$nextTick() this.task.hexColor = this.taskColor // If no end date is being set, but a start date and due date, // use the due date as the end date if (this.task.endDate === null && this.task.startDate !== null && this.task.dueDate !== null) { this.task.endDate = this.task.dueDate } this.task = await this.$store.dispatch('tasks/update', this.task) if (!showNotification) { return } let actions = [] if (undoCallback !== null) { actions = [{ title: 'Undo', callback: undoCallback, }] } this.$message.success({message: this.$t('task.detail.updateSuccess')}, actions) }, setFieldActive(fieldName) { this.activeFields[fieldName] = true this.$nextTick(() => { const el = this.$refs[fieldName]?.$el if (!el) { return } el.focus() // scroll the field to the center of the screen if not in viewport already scrollIntoView(el) }) }, async deleteTask() { await this.$store.dispatch('tasks/delete', this.task) this.$message.success({message: this.$t('task.detail.deleteSuccess')}) this.$router.push({name: 'list.index', params: {listId: this.task.listId}}) }, toggleTaskDone() { this.task.done = !this.task.done if (this.task.done) { playPop() } this.saveTask(true, this.toggleTaskDone) }, async changeList(list) { this.$store.commit('kanban/removeTaskInBucket', this.task) this.task.listId = list.id await this.saveTask() }, async toggleFavorite() { this.task.isFavorite = !this.task.isFavorite this.task = await this.taskService.update(this.task) this.$store.dispatch('namespaces/loadNamespacesIfFavoritesDontExist') }, }, }) </script> <style lang="scss" scoped> $flash-background-duration: 750ms; .task-view { // This is a workaround to hide the llama background from the top on the task detail page margin-top: -1.5rem; padding: 1rem; background-color: var(--site-background); @media screen and (max-width: $desktop) { padding-bottom: 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); } :deep(.heading) { display: flex; justify-content: space-between; text-transform: none; align-items: center; @media screen and (max-width: $tablet) { flex-direction: column; align-items: start; } .title { margin-bottom: 0; } .title.input { // 1.8rem is the font-size, 1.125 is the line-height, .3rem padding everywhere, 1px border around the whole thing. min-height: calc(1.8rem * 1.125 + .6rem + 2px); @media screen and (max-width: $tablet) { margin: 0 -.3rem .5rem -.3rem; // the title has 0.3rem padding - this make the text inside of it align with the rest } } .title.task-id { color: var(--grey-400); white-space: nowrap; } } .date-input { display: flex; align-items: center; a.remove { color: var(--danger); vertical-align: middle; padding-left: .5rem; line-height: 1; } } :deep(.datepicker) { width: 100%; a.show { color: var(--text); padding: .25rem .5rem; transition: background-color $transition; border-radius: $radius; display: block; margin: .1rem 0; &:hover { background: var(--white); } } &.disabled a.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 } &.labels-list, .assignees { :deep(.multiselect) { .input-wrapper { &:not(:focus-within):not(:hover) { background: transparent !important; border-color: transparent !important; } } } } } :deep(.details), :deep(.heading) { .input:not(.has-defaults), .textarea, .select:not(.has-defaults) select { border-color: transparent; background: transparent; 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 { .button { width: 100%; margin-bottom: .5rem; justify-content: left; } } .created { font-size: .75rem; color: var(--grey-500); text-align: right; } .checklist-summary { padding-left: .25rem; } } .task-view-container { padding-bottom: 1rem; @media screen and (max-width: $desktop) { padding-bottom: 0; } .task-view * { opacity: 0; transition: opacity 50ms ease; } &.is-loading { opacity: 1; .task-view * { opacity: 0; } } &.visible:not(.is-loading) .task-view * { opacity: 1; } } .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)); } .flash-background-enter-from, .flash-background-enter-active { animation: flash-background $flash-background-duration ease 1; } @keyframes flash-background { 0% { background: var(--primary-light); } 100% { background: transparent; } } @media (prefers-reduced-motion: reduce) { @keyframes flash-background { 0% { background: transparent; } } } @include modal-transition(); .detail-content { @media print { width: 100% !important; } } </style>