Compare commits
2 Commits
main
...
feat/impro
| Author | SHA1 | Date |
|---|---|---|
|
|
da695de78e | |
|
|
2783df1918 |
|
|
@ -76,38 +76,32 @@
|
||||||
v-if="projectStore.isLoading"
|
v-if="projectStore.isLoading"
|
||||||
variant="small"
|
variant="small"
|
||||||
/>
|
/>
|
||||||
<template v-else>
|
<nav
|
||||||
<nav
|
v-else
|
||||||
v-if="favoriteProjects"
|
class="menu"
|
||||||
class="menu"
|
>
|
||||||
>
|
<ProjectsNavigation
|
||||||
<ProjectsNavigation
|
v-if="favoriteProjects?.length"
|
||||||
:model-value="favoriteProjects"
|
:model-value="favoriteProjects"
|
||||||
:can-edit-order="false"
|
:can-edit-order="false"
|
||||||
:can-collapse="false"
|
:can-collapse="false"
|
||||||
/>
|
/>
|
||||||
</nav>
|
|
||||||
|
|
||||||
<nav
|
|
||||||
v-if="savedFilterProjects"
|
|
||||||
class="menu"
|
|
||||||
>
|
|
||||||
<ProjectsNavigation
|
|
||||||
:model-value="savedFilterProjects"
|
|
||||||
:can-edit-order="false"
|
|
||||||
:can-collapse="false"
|
|
||||||
/>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<nav class="menu">
|
<ProjectsNavigation
|
||||||
<ProjectsNavigation
|
v-if="savedFilterProjects?.length"
|
||||||
:model-value="projects"
|
:model-value="savedFilterProjects"
|
||||||
:can-edit-order="true"
|
:can-edit-order="false"
|
||||||
:can-collapse="true"
|
:can-collapse="false"
|
||||||
:level="1"
|
/>
|
||||||
/>
|
|
||||||
</nav>
|
<ProjectsNavigation
|
||||||
</template>
|
v-if="projects?.length"
|
||||||
|
:model-value="projects"
|
||||||
|
:can-edit-order="true"
|
||||||
|
:can-collapse="true"
|
||||||
|
:level="1"
|
||||||
|
/>
|
||||||
|
</nav>
|
||||||
|
|
||||||
<PoweredByLink
|
<PoweredByLink
|
||||||
class="mt-auto"
|
class="mt-auto"
|
||||||
|
|
@ -117,7 +111,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {computed} from 'vue'
|
import { storeToRefs } from 'pinia'
|
||||||
|
|
||||||
import PoweredByLink from '@/components/home/PoweredByLink.vue'
|
import PoweredByLink from '@/components/home/PoweredByLink.vue'
|
||||||
import Logo from '@/components/home/Logo.vue'
|
import Logo from '@/components/home/Logo.vue'
|
||||||
|
|
@ -129,16 +123,15 @@ import ProjectsNavigation from '@/components/home/ProjectsNavigation.vue'
|
||||||
|
|
||||||
const baseStore = useBaseStore()
|
const baseStore = useBaseStore()
|
||||||
const projectStore = useProjectStore()
|
const projectStore = useProjectStore()
|
||||||
|
const {
|
||||||
const projects = computed(() => projectStore.notArchivedRootProjects)
|
notArchivedRootProjects: projects,
|
||||||
const favoriteProjects = computed(() => projectStore.favoriteProjects)
|
favoriteProjects,
|
||||||
const savedFilterProjects = computed(() => projectStore.savedFilterProjects)
|
savedFilterProjects,
|
||||||
|
} = storeToRefs(projectStore)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.logo {
|
.logo {
|
||||||
display: block;
|
|
||||||
|
|
||||||
padding-left: 1rem;
|
padding-left: 1rem;
|
||||||
margin-right: 1rem;
|
margin-right: 1rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
|
|
@ -192,7 +185,7 @@ const savedFilterProjects = computed(() => projectStore.savedFilterProjects)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu + .menu {
|
.menu-list + .menu-list {
|
||||||
padding-top: math.div($navbar-padding, 2);
|
padding-top: math.div($navbar-padding, 2);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -84,12 +84,14 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {computed} from 'vue'
|
import {computed} from 'vue'
|
||||||
import {useProjectStore} from '@/stores/projects'
|
import {storeToRefs} from 'pinia'
|
||||||
import {useBaseStore} from '@/stores/base'
|
|
||||||
import {useStorage} from '@vueuse/core'
|
import {useStorage} from '@vueuse/core'
|
||||||
|
|
||||||
import type {IProject} from '@/modelTypes/IProject'
|
import type {IProject} from '@/modelTypes/IProject'
|
||||||
|
|
||||||
|
import {useProjectStore} from '@/stores/projects'
|
||||||
|
import {useBaseStore} from '@/stores/base'
|
||||||
|
|
||||||
import BaseButton from '@/components/base/BaseButton.vue'
|
import BaseButton from '@/components/base/BaseButton.vue'
|
||||||
import ProjectSettingsDropdown from '@/components/project/ProjectSettingsDropdown.vue'
|
import ProjectSettingsDropdown from '@/components/project/ProjectSettingsDropdown.vue'
|
||||||
import {getProjectTitle} from '@/helpers/getProjectTitle'
|
import {getProjectTitle} from '@/helpers/getProjectTitle'
|
||||||
|
|
@ -105,18 +107,17 @@ const props = defineProps<{
|
||||||
|
|
||||||
const projectStore = useProjectStore()
|
const projectStore = useProjectStore()
|
||||||
const baseStore = useBaseStore()
|
const baseStore = useBaseStore()
|
||||||
const currentProject = computed(() => baseStore.currentProject)
|
const {currentProject} = storeToRefs(baseStore)
|
||||||
|
|
||||||
// Persist open state across browser reloads. Using a separate ref for the state
|
// Persist open state across browser reloads. Using a separate ref for the state
|
||||||
// allows us to use only one entry in local storage instead of one for every project id.
|
// allows us to use only one entry in local storage instead of one for every project id.
|
||||||
type OpenState = { [key: number]: boolean }
|
const childProjectsCollapsed = useStorage<{ [key: number]: boolean }>('navigation-child-projects-collapsed', {})
|
||||||
const childProjectsOpenState = useStorage<OpenState>('navigation-child-projects-open', {})
|
|
||||||
const childProjectsOpen = computed({
|
const childProjectsOpen = computed({
|
||||||
get() {
|
get() {
|
||||||
return childProjectsOpenState.value[props.project.id] ?? true
|
return childProjectsCollapsed.value[props.project.id] ?? true
|
||||||
},
|
},
|
||||||
set(open) {
|
set(open) {
|
||||||
childProjectsOpenState.value[props.project.id] = open
|
childProjectsCollapsed.value[props.project.id] = open
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -416,10 +416,7 @@ watch(
|
||||||
collapsedBuckets.value = getCollapsedBucketState(projectId)
|
collapsedBuckets.value = getCollapsedBucketState(projectId)
|
||||||
kanbanStore.loadBucketsForProject(projectId, viewId, params)
|
kanbanStore.loadBucketsForProject(projectId, viewId, params)
|
||||||
},
|
},
|
||||||
{
|
{ immediate: true },
|
||||||
immediate: true,
|
|
||||||
deep: true,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
function setTaskContainerRef(id: IBucket['id'], el: HTMLElement) {
|
function setTaskContainerRef(id: IBucket['id'], el: HTMLElement) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,223 @@
|
||||||
|
import { ref, reactive, computed, type MaybeRefOrGetter, toValue, watch } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { klona } from 'klona/lite'
|
||||||
|
|
||||||
|
import type { ITask } from '@/modelTypes/ITask'
|
||||||
|
import type { IProject } from '@/modelTypes/IProject'
|
||||||
|
import type { Priority } from '@/constants/priorities'
|
||||||
|
import type {Action as MessageAction} from '@/message'
|
||||||
|
|
||||||
|
import TaskModel from '@/models/task'
|
||||||
|
|
||||||
|
import { useTaskStore } from '@/stores/tasks'
|
||||||
|
import { useProjectStore } from '@/stores/projects'
|
||||||
|
import { useKanbanStore } from '@/stores/kanban'
|
||||||
|
import { useBaseStore } from '@/stores/base'
|
||||||
|
|
||||||
|
import { RIGHTS } from '@/constants/rights'
|
||||||
|
import { success } from '@/message'
|
||||||
|
import { playPopSound } from '@/helpers/playPop'
|
||||||
|
import { TASK_REPEAT_MODES } from '@/types/IRepeatMode'
|
||||||
|
import { shallowReactive } from 'vue'
|
||||||
|
import TaskService from '@/services/task'
|
||||||
|
import { useAttachmentStore } from '@/stores/attachments'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { getProjectTitle } from '@/helpers/getProjectTitle'
|
||||||
|
import { uploadFile } from '@/helpers/attachments'
|
||||||
|
|
||||||
|
export function useTask(taskId: MaybeRefOrGetter<ITask['id']>) {
|
||||||
|
const {t} = useI18n()
|
||||||
|
const router = useRouter()
|
||||||
|
const taskStore = useTaskStore()
|
||||||
|
|
||||||
|
const taskTitle = ref('')
|
||||||
|
|
||||||
|
// 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']>('')
|
||||||
|
|
||||||
|
const task = reactive({
|
||||||
|
...new TaskModel(),
|
||||||
|
title: taskTitle,
|
||||||
|
}) as ITask
|
||||||
|
|
||||||
|
const taskService = shallowReactive(new TaskService())
|
||||||
|
|
||||||
|
/** Avoid flashing of empty elements if the task content is not yet loaded. */
|
||||||
|
const isReady = ref(false)
|
||||||
|
|
||||||
|
const isLoading = computed(() => taskService.loading)
|
||||||
|
|
||||||
|
// load task
|
||||||
|
watch(
|
||||||
|
() => toValue(taskId),
|
||||||
|
async (newTaskId) => {
|
||||||
|
if (newTaskId === undefined) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const loaded = await taskService.get({ id: newTaskId })
|
||||||
|
Object.assign(task, loaded)
|
||||||
|
attachmentStore.set(task.attachments)
|
||||||
|
taskColor.value = task.hexColor
|
||||||
|
setActiveFields()
|
||||||
|
} finally {
|
||||||
|
isReady.value = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{immediate: true},
|
||||||
|
)
|
||||||
|
|
||||||
|
const canWrite = computed(() => (
|
||||||
|
task.maxRight !== null &&
|
||||||
|
task.maxRight > RIGHTS.READ
|
||||||
|
))
|
||||||
|
|
||||||
|
const projectStore = useProjectStore()
|
||||||
|
|
||||||
|
const project = computed(() => projectStore.projects[task.projectId])
|
||||||
|
|
||||||
|
const ancestorProjects = computed(() => projectStore.getAncestors(project.value))
|
||||||
|
|
||||||
|
const ancestorProjectTitles = computed(() => ancestorProjects.value.map(project => getProjectTitle(project)))
|
||||||
|
|
||||||
|
const attachmentStore = useAttachmentStore()
|
||||||
|
const {hasAttachments} = storeToRefs(attachmentStore)
|
||||||
|
|
||||||
|
async function saveTask(
|
||||||
|
currentTask: ITask | null = null,
|
||||||
|
undoCallback?: () => void,
|
||||||
|
) {
|
||||||
|
if (currentTask === null) {
|
||||||
|
currentTask = klona(task)
|
||||||
|
}
|
||||||
|
|
||||||
|
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, updatedTask)
|
||||||
|
setActiveFields()
|
||||||
|
|
||||||
|
let actions: MessageAction[] = []
|
||||||
|
if (undoCallback) {
|
||||||
|
actions = [{
|
||||||
|
title: t('task.undo'),
|
||||||
|
callback: undoCallback,
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
success({message: t('task.detail.updateSuccess')}, actions)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteTask() {
|
||||||
|
await taskStore.delete(task)
|
||||||
|
success({message: t('task.detail.deleteSuccess')})
|
||||||
|
return router.push({name: 'project.index', params: {projectId: task.projectId}})
|
||||||
|
}
|
||||||
|
|
||||||
|
function uploadAttachment(file: File, onSuccess?: (url: string) => void) {
|
||||||
|
return uploadFile(toValue(taskId), file, onSuccess)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleTaskDone() {
|
||||||
|
const newTask = {
|
||||||
|
...klona(task),
|
||||||
|
done: !task.done,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newTask.done) {
|
||||||
|
playPopSound()
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveTask(
|
||||||
|
newTask,
|
||||||
|
toggleTaskDone,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changeProject(project: IProject) {
|
||||||
|
const kanbanStore = useKanbanStore()
|
||||||
|
const baseStore = useBaseStore()
|
||||||
|
kanbanStore.removeTaskInBucket(task)
|
||||||
|
await saveTask({
|
||||||
|
...task,
|
||||||
|
projectId: project.id,
|
||||||
|
})
|
||||||
|
baseStore.setCurrentProject(project)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleFavorite() {
|
||||||
|
const newTask = await taskStore.toggleFavorite(task)
|
||||||
|
Object.assign(task, newTask)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setPriority(priority: Priority) {
|
||||||
|
const newTask: ITask = {
|
||||||
|
...task,
|
||||||
|
priority,
|
||||||
|
}
|
||||||
|
|
||||||
|
return saveTask(newTask)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setPercentDone(percentDone: number) {
|
||||||
|
const newTask: ITask = {
|
||||||
|
...task,
|
||||||
|
percentDone,
|
||||||
|
}
|
||||||
|
|
||||||
|
return saveTask(newTask)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeRepeatAfter() {
|
||||||
|
task.repeatAfter.amount = 0
|
||||||
|
task.repeatMode = TASK_REPEAT_MODES.REPEAT_MODE_DEFAULT
|
||||||
|
await saveTask()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
project,
|
||||||
|
ancestorProjects,
|
||||||
|
ancestorProjectTitles,
|
||||||
|
|
||||||
|
isReady,
|
||||||
|
isLoading,
|
||||||
|
|
||||||
|
task,
|
||||||
|
taskTitle,
|
||||||
|
taskColor,
|
||||||
|
|
||||||
|
canWrite,
|
||||||
|
hasAttachments,
|
||||||
|
|
||||||
|
saveTask,
|
||||||
|
deleteTask,
|
||||||
|
uploadAttachment,
|
||||||
|
|
||||||
|
toggleTaskDone,
|
||||||
|
changeProject,
|
||||||
|
toggleFavorite,
|
||||||
|
setPriority,
|
||||||
|
setPercentDone,
|
||||||
|
removeRepeatAfter,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -32,7 +32,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {computed, ref, watchEffect} from 'vue'
|
import {computed, shallowReactive, watchEffect} from 'vue'
|
||||||
import {useTitle} from '@/composables/useTitle'
|
import {useTitle} from '@/composables/useTitle'
|
||||||
import {useI18n} from 'vue-i18n'
|
import {useI18n} from 'vue-i18n'
|
||||||
import {useRoute, useRouter} from 'vue-router'
|
import {useRoute, useRouter} from 'vue-router'
|
||||||
|
|
@ -46,36 +46,45 @@ const projectStore = useProjectStore()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const totalTasks = ref<number | null>(null)
|
const projectId = computed(() => Number(route.params.projectId))
|
||||||
|
|
||||||
const project = computed(() => projectStore.projects[route.params.projectId])
|
const project = computed(() => projectStore.projects[projectId.value])
|
||||||
const projectIdsToDelete = ref<number[]>([])
|
|
||||||
|
|
||||||
|
const projectIdsToDelete = computed(() => {
|
||||||
|
if (!projectId.value) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
...projectStore
|
||||||
|
.getChildProjects(projectId.value)
|
||||||
|
.map(p => p.id),
|
||||||
|
projectId.value,
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const taskService = shallowReactive(new TaskService())
|
||||||
watchEffect(
|
watchEffect(
|
||||||
async () => {
|
async () => {
|
||||||
if (!route.params.projectId) {
|
if (!projectIdsToDelete.value.length) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
projectIdsToDelete.value = projectStore
|
|
||||||
.getChildProjects(parseInt(route.params.projectId))
|
|
||||||
.map(p => p.id)
|
|
||||||
|
|
||||||
projectIdsToDelete.value.push(parseInt(route.params.projectId))
|
|
||||||
|
|
||||||
const taskService = new TaskService()
|
|
||||||
await taskService.getAll({}, {filter: `project in ${projectIdsToDelete.value.join(',')}`})
|
await taskService.getAll({}, {filter: `project in ${projectIdsToDelete.value.join(',')}`})
|
||||||
totalTasks.value = taskService.totalPages * taskService.resultCount
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
const totalTasks = computed(() => taskService.totalPages * taskService.resultCount)
|
||||||
|
|
||||||
useTitle(() => t('project.delete.title', {project: project?.value?.title}))
|
useTitle(() => t('project.delete.title', {project: project?.value?.title}))
|
||||||
|
|
||||||
const deleteNotice = computed(() => {
|
const deleteNotice = computed(() => {
|
||||||
if(totalTasks.value && totalTasks.value > 0) {
|
if (totalTasks.value && totalTasks.value > 0) {
|
||||||
if (projectIdsToDelete.value.length <= 1) {
|
if (projectIdsToDelete.value.length <= 1) {
|
||||||
return t('project.delete.tasksToDelete', {count: totalTasks.value})
|
return t('project.delete.tasksToDelete', {count: totalTasks.value})
|
||||||
} else if (projectIdsToDelete.value.length > 1) {
|
}
|
||||||
|
|
||||||
|
if (projectIdsToDelete.value.length > 1) {
|
||||||
return t('project.delete.tasksAndChildProjectsToDelete', {tasks: totalTasks.value, projects: projectIdsToDelete.value.length})
|
return t('project.delete.tasksAndChildProjectsToDelete', {tasks: totalTasks.value, projects: projectIdsToDelete.value.length})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,13 @@
|
||||||
<div
|
<div
|
||||||
class="loader-container task-view-container"
|
class="loader-container task-view-container"
|
||||||
:class="{
|
:class="{
|
||||||
'is-loading': taskService.loading || !visible,
|
'is-loading': isLoading || !isReady,
|
||||||
'is-modal': isModal,
|
'is-modal': isModal,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<!-- Removing everything until the task is loaded to prevent empty initialization of other components -->
|
<!-- Removing everything until the task is loaded to prevent empty initialization of other components -->
|
||||||
<div
|
<div
|
||||||
v-if="visible"
|
v-if="isReady"
|
||||||
class="task-view"
|
class="task-view"
|
||||||
>
|
>
|
||||||
<Heading
|
<Heading
|
||||||
|
|
@ -24,7 +24,7 @@
|
||||||
class="subtitle"
|
class="subtitle"
|
||||||
>
|
>
|
||||||
<template
|
<template
|
||||||
v-for="p in projectStore.getAncestors(project)"
|
v-for="p in ancestorProjects"
|
||||||
:key="p.id"
|
:key="p.id"
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
|
|
@ -117,7 +117,7 @@
|
||||||
:ref="e => setFieldRef('dueDate', e)"
|
:ref="e => setFieldRef('dueDate', e)"
|
||||||
v-model="task.dueDate"
|
v-model="task.dueDate"
|
||||||
:choose-date-label="$t('task.detail.chooseDueDate')"
|
:choose-date-label="$t('task.detail.chooseDueDate')"
|
||||||
:disabled="taskService.loading || !canWrite"
|
:disabled="isLoading || !canWrite"
|
||||||
@closeOnChange="saveTask()"
|
@closeOnChange="saveTask()"
|
||||||
/>
|
/>
|
||||||
<BaseButton
|
<BaseButton
|
||||||
|
|
@ -171,7 +171,7 @@
|
||||||
:ref="e => setFieldRef('startDate', e)"
|
:ref="e => setFieldRef('startDate', e)"
|
||||||
v-model="task.startDate"
|
v-model="task.startDate"
|
||||||
:choose-date-label="$t('task.detail.chooseStartDate')"
|
:choose-date-label="$t('task.detail.chooseStartDate')"
|
||||||
:disabled="taskService.loading || !canWrite"
|
:disabled="isLoading || !canWrite"
|
||||||
@closeOnChange="saveTask()"
|
@closeOnChange="saveTask()"
|
||||||
/>
|
/>
|
||||||
<BaseButton
|
<BaseButton
|
||||||
|
|
@ -204,7 +204,7 @@
|
||||||
:ref="e => setFieldRef('endDate', e)"
|
:ref="e => setFieldRef('endDate', e)"
|
||||||
v-model="task.endDate"
|
v-model="task.endDate"
|
||||||
:choose-date-label="$t('task.detail.chooseEndDate')"
|
:choose-date-label="$t('task.detail.chooseEndDate')"
|
||||||
:disabled="taskService.loading || !canWrite"
|
:disabled="isLoading || !canWrite"
|
||||||
@closeOnChange="saveTask()"
|
@closeOnChange="saveTask()"
|
||||||
/>
|
/>
|
||||||
<BaseButton
|
<BaseButton
|
||||||
|
|
@ -320,7 +320,7 @@
|
||||||
<Description
|
<Description
|
||||||
:model-value="task"
|
:model-value="task"
|
||||||
:can-write="canWrite"
|
:can-write="canWrite"
|
||||||
:attachment-upload="attachmentUpload"
|
:attachment-upload="uploadAttachment"
|
||||||
@update:modelValue="Object.assign(task, $event)"
|
@update:modelValue="Object.assign(task, $event)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -415,10 +415,9 @@
|
||||||
{{ task.done ? $t('task.detail.undone') : $t('task.detail.done') }}
|
{{ task.done ? $t('task.detail.undone') : $t('task.detail.done') }}
|
||||||
</x-button>
|
</x-button>
|
||||||
<TaskSubscription
|
<TaskSubscription
|
||||||
|
v-model="task.subscription"
|
||||||
entity="task"
|
entity="task"
|
||||||
:entity-id="task.id"
|
:entity-id="task.id"
|
||||||
:model-value="task.subscription"
|
|
||||||
@update:modelValue="sub => task.subscription = sub"
|
|
||||||
/>
|
/>
|
||||||
<x-button
|
<x-button
|
||||||
v-shortcut="'s'"
|
v-shortcut="'s'"
|
||||||
|
|
@ -585,22 +584,14 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import {ref, reactive, shallowReactive, computed, watch, nextTick, onMounted, onBeforeUnmount} from 'vue'
|
import {ref, reactive, computed, nextTick, watchPostEffect, watch} from 'vue'
|
||||||
import {useRouter, type RouteLocation} from 'vue-router'
|
import {useRouter, type RouteLocation} from 'vue-router'
|
||||||
import {storeToRefs} from 'pinia'
|
import {unrefElement, useEventListener} from '@vueuse/core'
|
||||||
import {useI18n} from 'vue-i18n'
|
|
||||||
import {unrefElement} from '@vueuse/core'
|
|
||||||
import {klona} from 'klona/lite'
|
|
||||||
import {eventToHotkeyString} from '@github/hotkey'
|
import {eventToHotkeyString} from '@github/hotkey'
|
||||||
|
|
||||||
import TaskService from '@/services/task'
|
|
||||||
import TaskModel from '@/models/task'
|
|
||||||
|
|
||||||
import type {ITask} from '@/modelTypes/ITask'
|
import type {ITask} from '@/modelTypes/ITask'
|
||||||
import type {IProject} from '@/modelTypes/IProject'
|
|
||||||
|
|
||||||
import {PRIORITIES, type Priority} from '@/constants/priorities'
|
import {PRIORITIES} from '@/constants/priorities'
|
||||||
import {RIGHTS} from '@/constants/rights'
|
|
||||||
|
|
||||||
import BaseButton from '@/components/base/BaseButton.vue'
|
import BaseButton from '@/components/base/BaseButton.vue'
|
||||||
|
|
||||||
|
|
@ -626,23 +617,14 @@ import CustomTransition from '@/components/misc/CustomTransition.vue'
|
||||||
import AssigneeList from '@/components/tasks/partials/AssigneeList.vue'
|
import AssigneeList from '@/components/tasks/partials/AssigneeList.vue'
|
||||||
import Reactions from '@/components/input/Reactions.vue'
|
import Reactions from '@/components/input/Reactions.vue'
|
||||||
|
|
||||||
import {uploadFile} from '@/helpers/attachments'
|
|
||||||
import {getProjectTitle} from '@/helpers/getProjectTitle'
|
import {getProjectTitle} from '@/helpers/getProjectTitle'
|
||||||
import {scrollIntoView} from '@/helpers/scrollIntoView'
|
import {scrollIntoView} from '@/helpers/scrollIntoView'
|
||||||
import {TASK_REPEAT_MODES} from '@/types/IRepeatMode'
|
import {TASK_REPEAT_MODES} from '@/types/IRepeatMode'
|
||||||
import {playPopSound} from '@/helpers/playPop'
|
|
||||||
|
|
||||||
import {useAttachmentStore} from '@/stores/attachments'
|
|
||||||
import {useTaskStore} from '@/stores/tasks'
|
|
||||||
import {useKanbanStore} from '@/stores/kanban'
|
|
||||||
import {useProjectStore} from '@/stores/projects'
|
|
||||||
import {useAuthStore} from '@/stores/auth'
|
import {useAuthStore} from '@/stores/auth'
|
||||||
import {useBaseStore} from '@/stores/base'
|
|
||||||
|
|
||||||
import {useTitle} from '@/composables/useTitle'
|
import {useTitle} from '@/composables/useTitle'
|
||||||
|
import {useTask} from '@/composables/useTask'
|
||||||
import {success} from '@/message'
|
|
||||||
import type {Action as MessageAction} from '@/message'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
taskId: ITask['id'],
|
taskId: ITask['id'],
|
||||||
|
|
@ -654,98 +636,59 @@ defineEmits<{
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const {t} = useI18n({useScope: 'global'})
|
|
||||||
|
|
||||||
const projectStore = useProjectStore()
|
|
||||||
const attachmentStore = useAttachmentStore()
|
|
||||||
const {hasAttachments} = storeToRefs(attachmentStore)
|
|
||||||
const taskStore = useTaskStore()
|
|
||||||
const kanbanStore = useKanbanStore()
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const baseStore = useBaseStore()
|
|
||||||
|
|
||||||
const task = ref<ITask>(new TaskModel())
|
const {
|
||||||
const taskTitle = computed(() => task.value.title)
|
project,
|
||||||
|
ancestorProjects,
|
||||||
|
|
||||||
|
isReady,
|
||||||
|
isLoading,
|
||||||
|
|
||||||
|
task,
|
||||||
|
taskTitle,
|
||||||
|
taskColor,
|
||||||
|
|
||||||
|
canWrite,
|
||||||
|
hasAttachments,
|
||||||
|
|
||||||
|
saveTask,
|
||||||
|
deleteTask,
|
||||||
|
uploadAttachment,
|
||||||
|
|
||||||
|
toggleTaskDone,
|
||||||
|
changeProject,
|
||||||
|
toggleFavorite,
|
||||||
|
setPriority,
|
||||||
|
setPercentDone,
|
||||||
|
removeRepeatAfter,
|
||||||
|
} = useTask(() => props.taskId)
|
||||||
|
|
||||||
useTitle(taskTitle)
|
useTitle(taskTitle)
|
||||||
|
|
||||||
|
|
||||||
// See https://github.com/github/hotkey/discussions/85#discussioncomment-5214660
|
// See https://github.com/github/hotkey/discussions/85#discussioncomment-5214660
|
||||||
function saveTaskViaHotkey(event) {
|
useEventListener('keydown', (event) => {
|
||||||
const hotkeyString = eventToHotkeyString(event)
|
const hotkeyString = eventToHotkeyString(event)
|
||||||
if (!hotkeyString) return
|
if (!hotkeyString) return
|
||||||
if (hotkeyString !== 'Control+s' && hotkeyString !== 'Meta+s') return
|
if (hotkeyString !== 'Control+s' && hotkeyString !== 'Meta+s') return
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|
||||||
saveTask()
|
saveTask()
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
document.addEventListener('keydown', saveTaskViaHotkey)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
const color = computed(() => task?.getHexColor())
|
||||||
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 isModal = computed(() => Boolean(props.backdropView))
|
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)
|
const heading = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
async function scrollToHeading() {
|
watchPostEffect(() => {
|
||||||
scrollIntoView(unrefElement(heading))
|
if (isReady.value) {
|
||||||
}
|
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 =
|
type FieldType =
|
||||||
| 'assignees'
|
| 'assignees'
|
||||||
|
|
@ -784,17 +727,19 @@ function setActiveFields() {
|
||||||
// task.endDate = task.endDate || null
|
// task.endDate = task.endDate || null
|
||||||
|
|
||||||
// Set all active fields based on values in the model
|
// Set all active fields based on values in the model
|
||||||
activeFields.assignees = task.value.assignees.length > 0
|
Object.assign(activeFields, {
|
||||||
activeFields.attachments = task.value.attachments.length > 0
|
assignees: task.assignees.length > 0,
|
||||||
activeFields.dueDate = task.value.dueDate !== null
|
attachments: task.attachments.length > 0,
|
||||||
activeFields.endDate = task.value.endDate !== null
|
dueDate: task.dueDate !== null,
|
||||||
activeFields.labels = task.value.labels.length > 0
|
endDate: task.endDate !== null,
|
||||||
activeFields.percentDone = task.value.percentDone > 0
|
labels: task.labels.length > 0,
|
||||||
activeFields.priority = task.value.priority !== PRIORITIES.UNSET
|
percentDone: task.percentDone > 0,
|
||||||
activeFields.relatedTasks = Object.keys(task.value.relatedTasks).length > 0
|
priority: task.priority !== PRIORITIES.UNSET,
|
||||||
activeFields.reminders = task.value.reminders.length > 0
|
relatedTasks: Object.keys(task.relatedTasks).length > 0,
|
||||||
activeFields.repeatAfter = task.value.repeatAfter?.amount > 0 || task.value.repeatMode !== TASK_REPEAT_MODES.REPEAT_MODE_DEFAULT
|
reminders: task.reminders.length > 0,
|
||||||
activeFields.startDate = task.value.startDate !== null
|
repeatAfter: task.repeatAfter?.amount > 0 || task.repeatMode !== TASK_REPEAT_MODES.REPEAT_MODE_DEFAULT,
|
||||||
|
startDate: task.startDate !== null,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeFieldElements: { [id in FieldType]: HTMLElement | null } = reactive({
|
const activeFieldElements: { [id in FieldType]: HTMLElement | null } = reactive({
|
||||||
|
|
@ -833,106 +778,8 @@ function setFieldActive(fieldName: keyof typeof activeFields) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
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,
|
|
||||||
})
|
|
||||||
baseStore.setCurrentProject(project)
|
|
||||||
}
|
|
||||||
|
|
||||||
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() {
|
function setRelatedTasksActive() {
|
||||||
setFieldActive('relatedTasks')
|
setFieldActive('relatedTasks')
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue