feat(hierarchical): Add option to view hierarchical presentation

Task and subbstask could be showed in hierachical manner.
This modification allows to drag and drop tasks and subtask to add
easily tasks relation
This commit is contained in:
Simeon Marijon 2026-05-12 10:16:18 +02:00
parent 57a0b8fee4
commit 5e00d78ebd
5 changed files with 597 additions and 71 deletions

View File

@ -48,46 +48,25 @@
</ButtonLink>
</Nothing>
<draggable
<TaskTreeDraggable
v-if="tasks && tasks.length > 0"
v-model="tasks"
:group="{name: 'tasks', put: false}"
:disabled="!canDragTasks || !isPositionSorting"
item-key="id"
tag="ul"
:component-data="{
class: {
tasks: true,
'dragging-disabled': !canDragTasks || !isPositionSorting
},
type: 'transition-group'
}"
:animation="100"
:tasks="tasks"
:all-tasks="allTasksWithSubtasks"
:hierarchical="true"
:disabled="!canDragTasks"
:can-mark-task-as-done="canMarkTaskAsDone"
:show-drag-handle="canDragTasks"
:dragging="drag"
:task-ref-setter="setTaskTreeRef"
:handle="dragHandle"
:delay-on-touch-only="!isTouchDevice"
:delay="isTouchDevice ? 0 : 1000"
ghost-class="task-ghost"
@start="handleDragStart"
@end="saveTaskPosition"
>
<template #item="{element: t, index}">
<SingleTaskInProject
:ref="(el) => setTaskRef(el, index)"
:show-list-color="false"
:can-mark-as-done="canWrite || isPseudoProject"
:the-task="t"
:all-tasks="allTasks"
@taskUpdated="updateTasks"
>
<span
v-if="canDragTasks && isPositionSorting"
class="icon handle"
>
<Icon icon="grip-lines" />
</span>
</SingleTaskInProject>
</template>
</draggable>
:transition-group="true"
@dragStart="handleDragStart"
@drop="saveTaskTreeDrop"
@updateList="updateTaskTreeList"
@taskUpdated="updateTasks"
/>
<Pagination
:total-pages="totalPages"
@ -102,12 +81,15 @@
<script setup lang="ts">
import {ref, computed, nextTick, onMounted, onBeforeUnmount, watch, toRef} from 'vue'
import draggable from 'zhyswan-vuedraggable'
import ProjectWrapper from '@/components/project/ProjectWrapper.vue'
import ButtonLink from '@/components/misc/ButtonLink.vue'
import AddTask from '@/components/tasks/AddTask.vue'
import SingleTaskInProject from '@/components/tasks/partials/SingleTaskInProject.vue'
import TaskTreeDraggable, {
type TaskTreeDropEvent,
type TaskTreeListUpdateEvent,
} from '@/components/tasks/partials/TaskTreeDraggable.vue'
import FilterPopup from '@/components/project/partials/FilterPopup.vue'
import Nothing from '@/components/misc/Nothing.vue'
import Pagination from '@/components/misc/Pagination.vue'
@ -128,6 +110,10 @@ import type {IProject} from '@/modelTypes/IProject'
import type {IProjectView} from '@/modelTypes/IProjectView'
import TaskPositionService from '@/services/taskPosition'
import TaskPositionModel from '@/models/taskPosition'
import TaskRelationService from '@/services/taskRelation'
import TaskRelationModel from '@/models/taskRelation'
import {RELATION_KIND} from '@/types/IRelationKind'
import {error} from '@/message'
const props = defineProps<{
isLoadingProject: boolean,
@ -161,6 +147,7 @@ const {
)
const taskPositionService = ref(new TaskPositionService())
const taskRelationService = ref(new TaskRelationService())
// Saved filter composable for accessing filter data
const _savedFilter = useSavedFilter(() => isSavedFilter({id: projectId.value}) ? projectId.value : undefined).filter
@ -176,6 +163,24 @@ watch(
const isPositionSorting = computed(() => 'position' in sortByParam.value)
const allTasksWithSubtasks = computed((): ITask[] => {
const map = new Map<number, ITask>()
allTasks.value.forEach(t => addEmbeddedSubtasks(t, map))
allTasks.value.forEach(t => map.set(t.id, t))
return [...map.values()]
})
function addEmbeddedSubtasks(task: ITask, map: Map<number, ITask>) {
(task.relatedTasks?.subtask ?? []).forEach(subtask => {
if (map.has(subtask.id)) {
return
}
map.set(subtask.id, subtask)
addEmbeddedSubtasks(subtask, map)
})
}
const firstNewPosition = computed(() => {
if (tasks.value.length === 0) {
return 0
@ -195,6 +200,10 @@ const canWrite = computed(() => {
const isPseudoProject = computed(() => (project.value && isSavedFilter(project.value)) || project.value?.id === -1)
function canMarkTaskAsDone() {
return canWrite.value || Boolean(isPseudoProject.value)
}
onMounted(async () => {
await nextTick()
ctaVisible.value = true
@ -246,44 +255,120 @@ function updateTasks(updatedTask: ITask) {
function handleDragStart(e: { item: HTMLElement }) {
drag.value = true
const taskId = parseInt(e.item.dataset.taskId ?? '', 10)
const task = tasks.value.find(t => t.id === taskId)
const task = findTaskById(taskId)
if (task) {
taskStore.setDraggedTask(task)
} else {
taskStore.setDraggedTask(null)
}
}
async function saveTaskPosition(e: { originalEvent?: MouseEvent, to: HTMLElement, from: HTMLElement, newIndex: number }) {
drag.value = false
function findTaskById(taskId: ITask['id'], taskList: ITask[] = allTasksWithSubtasks.value): ITask | undefined {
for (const task of taskList) {
if (task.id === taskId) {
return task
}
// Check if dropped on a sidebar project
const {moved} = await handleTaskDropToProject(e, (task) => {
tasks.value = tasks.value.filter(t => t.id !== task.id)
const found = findTaskById(taskId, task.relatedTasks?.subtask ?? [])
if (found) {
return found
}
}
}
function updateTaskTreeList({parentTaskId, tasks: updatedTasks}: TaskTreeListUpdateEvent) {
if (parentTaskId === null) {
tasks.value = updatedTasks
return
}
const parent = findTaskById(parentTaskId)
if (parent) {
parent.relatedTasks.subtask = updatedTasks
}
}
function getTaskTreeSiblings(parentTaskId: ITask['id'] | null): ITask[] {
if (parentTaskId === null) {
return tasks.value
}
return findTaskById(parentTaskId)?.relatedTasks?.subtask ?? []
}
function removeTaskFromTree(task: ITask) {
tasks.value = tasks.value.filter(t => t.id !== task.id)
allTasksWithSubtasks.value.forEach(t => {
if (typeof t.relatedTasks?.subtask !== 'undefined') {
t.relatedTasks.subtask = t.relatedTasks.subtask.filter(subtask => subtask.id !== task.id)
}
})
}
if (moved) {
async function updateTaskParent(task: ITask, oldParentTaskId: ITask['id'] | null, newParentTaskId: ITask['id'] | null) {
if (oldParentTaskId === newParentTaskId) {
return
}
// If dropped outside this list
if (e.to !== e.from) {
return
if (oldParentTaskId !== null) {
await taskRelationService.value.delete(new TaskRelationModel({
taskId: oldParentTaskId,
otherTaskId: task.id,
relationKind: RELATION_KIND.SUBTASK,
}))
}
const task = tasks.value[e.newIndex]
const taskBefore = tasks.value[e.newIndex - 1] ?? null
const taskAfter = tasks.value[e.newIndex + 1] ?? null
if (newParentTaskId !== null) {
await taskRelationService.value.create(new TaskRelationModel({
taskId: newParentTaskId,
otherTaskId: task.id,
relationKind: RELATION_KIND.SUBTASK,
}))
task.relatedTasks.parenttask = [findTaskById(newParentTaskId)].filter((task): task is ITask => Boolean(task))
} else {
task.relatedTasks.parenttask = []
}
}
const position = calculateItemPosition(taskBefore !== null ? taskBefore.position : null, taskAfter !== null ? taskAfter.position : null)
async function updateTaskPosition(task: ITask, siblings: ITask[], index: number) {
const taskBefore = siblings[index - 1] ?? null
const taskAfter = siblings[index + 1] ?? null
const position = calculateItemPosition(taskBefore?.position ?? null, taskAfter?.position ?? null)
await taskPositionService.value.update(new TaskPositionModel({
position,
projectViewId: props.viewId,
taskId: task.id,
}))
tasks.value[e.newIndex] = {
...task,
position,
task.position = position
}
async function saveTaskTreeDrop(e: TaskTreeDropEvent) {
drag.value = false
// Check if dropped on a sidebar project
const {moved} = await handleTaskDropToProject(e, removeTaskFromTree)
if (moved) {
return
}
const task = findTaskById(e.taskId)
if (!task) {
return
}
try {
await updateTaskParent(task, e.oldParentTaskId, e.newParentTaskId)
await updateTaskPosition(task, getTaskTreeSiblings(e.newParentTaskId), e.newIndex)
if (!isPositionSorting.value) {
sortByParam.value = {position: 'asc'}
}
} catch (e) {
error(e)
await loadTasks(false)
}
}
@ -298,6 +383,17 @@ function setTaskRef(el: InstanceType<typeof SingleTaskInProject> | null, index:
}
}
function isSingleTaskComponent(el: unknown): el is InstanceType<typeof SingleTaskInProject> {
return el !== null &&
typeof el === 'object' &&
'focus' in el &&
'click' in el
}
function setTaskTreeRef(el: unknown, index: number) {
setTaskRef(isSingleTaskComponent(el) ? el : null, index)
}
function focusTask(index: number) {
if (index < 0 || index >= tasks.value.length) {
return

View File

@ -0,0 +1,235 @@
<template>
<draggable
:model-value="tasks"
:group="{name: 'tasks', put: !disabled}"
:disabled="disabled"
:sort="!disabled"
:item-key="(task: ITask) => task.id"
tag="ul"
:component-data="{
class: {
tasks: true,
'task-tree': true,
'task-tree--nested': nested,
'task-tree--empty': dragging && tasks.length === 0,
'dragging-disabled': disabled,
},
'data-parent-task-id': parentTaskId ?? '',
type: transitionGroup ? 'transition-group' : undefined,
}"
:animation="100"
:handle="handle"
:delay-on-touch-only="delayOnTouchOnly"
:delay="delay"
ghost-class="task-ghost"
:move="canMoveTask"
@update:modelValue="updateList"
@start="$emit('dragStart', $event)"
@end="emitDrop"
>
<template #item="{element: task, index}">
<div
class="task-tree-item"
:data-task-id="task.id"
>
<SingleTaskInProject
:ref="(el) => taskRefSetter?.(el, index)"
:show-list-color="showListColor"
:show-project="showProject && !nested"
:can-mark-as-done="canMarkTaskAsDone(task)"
:the-task="task"
:all-tasks="[]"
@taskUpdated="$emit('taskUpdated', $event)"
>
<span
v-if="showDragHandle && !disabled"
class="icon handle"
>
<Icon icon="grip-lines" />
</span>
</SingleTaskInProject>
<TaskTreeDraggable
v-if="hierarchical"
:tasks="getSubtasks(task)"
:all-tasks="allTasks"
:hierarchical="hierarchical"
:parent-task-id="task.id"
:nested="true"
:disabled="disabled"
:show-project="showProject"
:show-list-color="showListColor"
:show-drag-handle="showDragHandle"
:dragging="dragging"
:handle="handle"
:delay="delay"
:delay-on-touch-only="delayOnTouchOnly"
:can-mark-task-as-done="canMarkTaskAsDone"
@taskUpdated="$emit('taskUpdated', $event)"
@dragStart="$emit('dragStart', $event)"
@drop="$emit('drop', $event)"
@updateList="$emit('updateList', $event)"
/>
</div>
</template>
</draggable>
</template>
<script setup lang="ts">
import draggable from 'zhyswan-vuedraggable'
import Icon from '@/components/misc/Icon'
import SingleTaskInProject from '@/components/tasks/partials/SingleTaskInProject.vue'
import type {ITask} from '@/modelTypes/ITask'
const props = withDefaults(defineProps<{
tasks: ITask[],
allTasks: ITask[],
hierarchical: boolean,
parentTaskId?: ITask['id'] | null,
nested?: boolean,
disabled?: boolean,
showProject?: boolean,
showListColor?: boolean,
showDragHandle?: boolean,
dragging?: boolean,
handle?: string,
delay?: number,
delayOnTouchOnly?: boolean,
transitionGroup?: boolean,
canMarkTaskAsDone: (task: ITask) => boolean,
taskRefSetter?: (el: unknown, index: number) => void,
}>(), {
parentTaskId: null,
nested: false,
disabled: false,
showProject: false,
showListColor: false,
showDragHandle: false,
dragging: false,
handle: undefined,
delay: 0,
delayOnTouchOnly: false,
transitionGroup: false,
taskRefSetter: undefined,
})
const emit = defineEmits<{
taskUpdated: [task: ITask],
dragStart: [event: { item: HTMLElement }],
drop: [event: TaskTreeDropEvent],
updateList: [event: TaskTreeListUpdateEvent],
}>()
defineOptions({name: 'TaskTreeDraggable'})
export interface TaskTreeDropEvent {
taskId: ITask['id']
oldParentTaskId: ITask['id'] | null
newParentTaskId: ITask['id'] | null
oldIndex: number
newIndex: number
originalEvent?: MouseEvent
from: HTMLElement
to: HTMLElement
}
export interface TaskTreeListUpdateEvent {
parentTaskId: ITask['id'] | null
tasks: ITask[]
}
function parseParentTaskId(element: HTMLElement): ITask['id'] | null {
const value = element.dataset.parentTaskId
if (!value) {
return null
}
const parsed = parseInt(value, 10)
return Number.isNaN(parsed) ? null : parsed
}
function getSubtasks(task: ITask): ITask[] {
if (!props.hierarchical) {
return []
}
return (task.relatedTasks?.subtask ?? [])
.map(subtask => props.allTasks.find(t => t.id === subtask.id) ?? subtask)
}
function isDescendant(task: ITask, possibleDescendantId: ITask['id']): boolean {
return getSubtasks(task).some(subtask => (
subtask.id === possibleDescendantId || isDescendant(subtask, possibleDescendantId)
))
}
function canMoveTask(event: { draggedContext?: { element?: ITask }, to: HTMLElement }) {
const draggedTask = event.draggedContext?.element
if (!draggedTask) {
return true
}
const targetParentId = parseParentTaskId(event.to)
return targetParentId === null ||
(targetParentId !== draggedTask.id && !isDescendant(draggedTask, targetParentId))
}
function updateList(updatedTasks: ITask[]) {
emit('updateList', {
parentTaskId: props.parentTaskId,
tasks: updatedTasks,
})
}
function emitDrop(event: {
item: HTMLElement,
from: HTMLElement,
to: HTMLElement,
oldIndex: number,
newIndex: number,
originalEvent?: MouseEvent,
}) {
const taskId = parseInt(event.item.dataset.taskId ?? '', 10)
if (Number.isNaN(taskId)) {
return
}
emit('drop', {
taskId,
oldParentTaskId: parseParentTaskId(event.from),
newParentTaskId: parseParentTaskId(event.to),
oldIndex: event.oldIndex,
newIndex: event.newIndex,
originalEvent: event.originalEvent,
from: event.from,
to: event.to,
})
}
</script>
<style lang="scss" scoped>
.task-tree {
padding: .5rem;
}
.task-tree--nested {
margin-inline-start: 1.75rem;
padding-block: 0;
}
.task-tree--empty {
min-block-size: .5rem;
padding-block: .25rem;
}
.task-ghost {
border-radius: $radius;
background: var(--grey-100);
border: 2px dashed var(--grey-300);
* {
opacity: 0;
}
}
</style>

View File

@ -23,10 +23,7 @@ function findProjectIdAtPosition(mouseX: number, mouseY: number): number | null
continue
}
const withProjectId =
el.dataset?.projectId != null
? el
: el.closest('[data-project-id]') as HTMLElement | null
const withProjectId = el.closest('.menu-list [data-project-id]') as HTMLElement | null
const projectId = withProjectId?.dataset.projectId
if (projectId) {

View File

@ -942,7 +942,8 @@
"noTasks": "Nothing to do — Have a nice day!",
"filterByLabel": "Filtering by label {label}",
"clearLabelFilter": "Clear label filter",
"savedFilterIgnored": "Your saved homepage filter is not applied while viewing tasks by label."
"savedFilterIgnored": "Your saved homepage filter is not applied while viewing tasks by label.",
"hierarchical": "Hierarchical view"
},
"detail": {
"chooseDueDate": "Click here to set a due date",

View File

@ -67,6 +67,14 @@
{{ $t('task.show.overdue') }}
</FancyCheckbox>
</p>
<p class="show-tasks-options">
<FancyCheckbox
v-model="hierarchical"
@update:modelValue="saveHierarchical"
>
{{ $t('task.show.hierarchical') }}
</FancyCheckbox>
</p>
<template v-if="!loading && (!tasks || tasks.length === 0) && showNothingToDo">
<h3 class="has-text-centered mbs-6">
{{ $t('task.show.noTasks') }}
@ -81,16 +89,19 @@
:has-content="false"
:loading="loading"
>
<div class="p-2">
<SingleTaskInProject
v-for="task in tasks"
:key="task.id"
:show-project="true"
:the-task="task"
:can-mark-as-done="(projectStore.projects[task.projectId]?.maxPermission ?? 0) > PERMISSIONS.READ"
@taskUpdated="updateTasks"
/>
</div>
<TaskTreeDraggable
:tasks="displayedTasks"
:all-tasks="allTasksWithSubtasks"
:hierarchical="hierarchical"
:can-mark-task-as-done="canMarkTaskAsDone"
:disabled="!canDragTasks"
:show-project="true"
:dragging="drag"
@taskUpdated="updateTasks"
@dragStart="handleDragStart"
@drop="handleTaskTreeDrop"
@updateList="updateTaskTreeList"
/>
</Card>
<div
v-else
@ -112,12 +123,16 @@ import BaseButton from '@/components/base/BaseButton.vue'
import Icon from '@/components/misc/Icon'
import Message from '@/components/misc/Message.vue'
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 TaskTreeDraggable, {
type TaskTreeDropEvent,
type TaskTreeListUpdateEvent,
} from '@/components/tasks/partials/TaskTreeDraggable.vue'
import {DATE_RANGES} from '@/components/date/dateRanges'
import LlamaCool from '@/assets/llama-cool.svg?component'
import type {ITask} from '@/modelTypes/ITask'
import {PROJECT_VIEW_KINDS} from '@/modelTypes/IProjectView'
import {useAuthStore} from '@/stores/auth'
import {useTaskStore} from '@/stores/tasks'
import {useProjectStore} from '@/stores/projects'
@ -125,6 +140,14 @@ import {useLabelStore} from '@/stores/labels'
import type {TaskFilterParams} from '@/services/taskCollection'
import TaskCollectionService from '@/services/taskCollection'
import {PERMISSIONS} from '@/constants/permissions'
import {useTaskDragToProject} from '@/composables/useTaskDragToProject'
import {calculateItemPosition} from '@/helpers/calculateItemPosition'
import TaskPositionService from '@/services/taskPosition'
import TaskPositionModel from '@/models/taskPosition'
import TaskRelationService from '@/services/taskRelation'
import TaskRelationModel from '@/models/taskRelation'
import {RELATION_KIND} from '@/types/IRelationKind'
import {error} from '@/message'
const props = withDefaults(defineProps<{
dateFrom?: Date | string,
@ -149,6 +172,7 @@ const authStore = useAuthStore()
const taskStore = useTaskStore()
const projectStore = useProjectStore()
const labelStore = useLabelStore()
const {handleTaskDropToProject} = useTaskDragToProject()
const route = useRoute()
const router = useRouter()
@ -157,6 +181,10 @@ const {t} = useI18n({useScope: 'global'})
const tasks = ref<ITask[]>([])
const showNothingToDo = ref<boolean>(false)
const taskCollectionService = ref(new TaskCollectionService())
const taskPositionService = ref(new TaskPositionService())
const taskRelationService = ref(new TaskRelationService())
const hierarchical = ref(localStorage.getItem('showTasksHierarchical') === 'true')
const drag = ref(false)
setTimeout(() => showNothingToDo.value = true, 100)
@ -194,9 +222,47 @@ const pageTitle = computed(() => {
})
})
const hasTasks = computed(() => tasks.value && tasks.value.length > 0)
// Build a flat list including embedded subtasks for hierarchical rendering.
// Tasks from tasks.value take priority over embedded stubs (full data wins).
const allTasksWithSubtasks = computed((): ITask[] => {
if (!hierarchical.value) return tasks.value
const map = new Map<number, ITask>()
tasks.value.forEach(t => addEmbeddedSubtasks(t, map))
tasks.value.forEach(t => map.set(t.id, t))
return [...map.values()]
})
function addEmbeddedSubtasks(task: ITask, map: Map<number, ITask>) {
(task.relatedTasks?.subtask ?? []).forEach(subtask => {
if (map.has(subtask.id)) {
return
}
map.set(subtask.id, subtask)
addEmbeddedSubtasks(subtask, map)
})
}
// Top-level tasks: a task is hidden if any of its parents exists in the full task tree.
// relatedTasks.parenttask is always populated by the API for subtasks.
// We check against allTasksWithSubtasks (not just tasks.value) to cover all levels.
const displayedTasks = computed((): ITask[] => {
if (!hierarchical.value) return tasks.value
const allTaskIds = new Set(allTasksWithSubtasks.value.map(t => t.id))
return tasks.value.filter(t => {
const parentIds = (t.relatedTasks?.parenttask ?? []).map((p: ITask) => p.id)
return parentIds.length === 0 || !parentIds.some(pid => allTaskIds.has(pid))
})
})
const userAuthenticated = computed(() => authStore.authenticated)
const loading = computed(() => taskStore.isLoading || taskCollectionService.value.loading)
const filterIdUsedOnOverview = computed(() => authStore.settings?.frontendSettings?.filterIdUsedOnOverview)
const canDragTasks = computed(() => tasks.value.some(canMarkTaskAsDone))
function canMarkTaskAsDone(task: ITask) {
return (projectStore.projects[task.projectId]?.maxPermission ?? 0) > PERMISSIONS.READ
}
interface dateStrings {
dateFrom: string,
@ -235,10 +301,141 @@ function setShowNulls(show: boolean) {
})
}
function saveHierarchical(value: boolean) {
localStorage.setItem('showTasksHierarchical', String(value))
}
function clearLabelFilter() {
emit('clearLabelFilter')
}
function handleDragStart(e: { item: HTMLElement }) {
drag.value = true
const taskId = parseInt(e.item.dataset.taskId ?? '', 10)
const task = allTasksWithSubtasks.value.find(t => t.id === taskId)
if (task) {
taskStore.setDraggedTask(task)
} else {
taskStore.setDraggedTask(null)
drag.value = false
}
}
function removeTaskFromOverview(task: ITask) {
tasks.value = tasks.value.filter(t => t.id !== task.id)
tasks.value.forEach(t => {
if (typeof t.relatedTasks?.subtask !== 'undefined') {
t.relatedTasks.subtask = t.relatedTasks.subtask.filter(subtask => subtask.id !== task.id)
}
})
}
function findTaskById(taskId: ITask['id'], taskList: ITask[] = allTasksWithSubtasks.value): ITask | undefined {
for (const task of taskList) {
if (task.id === taskId) {
return task
}
const found = findTaskById(taskId, task.relatedTasks?.subtask ?? [])
if (found) {
return found
}
}
}
function updateTaskTreeList({parentTaskId, tasks: updatedTasks}: TaskTreeListUpdateEvent) {
if (parentTaskId === null) {
const updatedTaskIds = new Set(updatedTasks.map(({id}) => id))
tasks.value = [
...updatedTasks,
...tasks.value.filter(({id}) => !updatedTaskIds.has(id)),
]
return
}
const parent = findTaskById(parentTaskId)
if (parent) {
parent.relatedTasks.subtask = updatedTasks
}
}
function getTaskTreeSiblings(parentTaskId: ITask['id'] | null): ITask[] {
if (parentTaskId === null) {
return displayedTasks.value
}
return findTaskById(parentTaskId)?.relatedTasks?.subtask ?? []
}
function resolveListViewId(task: ITask) {
return projectStore.projects[task.projectId]?.views.find(({viewKind}) => viewKind === PROJECT_VIEW_KINDS.LIST)?.id
}
async function updateTaskPosition(task: ITask, siblings: ITask[], index: number) {
const projectViewId = resolveListViewId(task)
if (typeof projectViewId === 'undefined') {
return
}
const taskBefore = siblings[index - 1] ?? null
const taskAfter = siblings[index + 1] ?? null
const position = calculateItemPosition(taskBefore?.position ?? null, taskAfter?.position ?? null)
await taskPositionService.value.update(new TaskPositionModel({
position,
projectViewId,
taskId: task.id,
}))
task.position = position
}
async function updateTaskParent(task: ITask, oldParentTaskId: ITask['id'] | null, newParentTaskId: ITask['id'] | null) {
if (oldParentTaskId === newParentTaskId) {
return
}
if (oldParentTaskId !== null) {
await taskRelationService.value.delete(new TaskRelationModel({
taskId: oldParentTaskId,
otherTaskId: task.id,
relationKind: RELATION_KIND.SUBTASK,
}))
}
if (newParentTaskId !== null) {
await taskRelationService.value.create(new TaskRelationModel({
taskId: newParentTaskId,
otherTaskId: task.id,
relationKind: RELATION_KIND.SUBTASK,
}))
task.relatedTasks.parenttask = [findTaskById(newParentTaskId)].filter((task): task is ITask => Boolean(task))
} else {
task.relatedTasks.parenttask = []
}
}
async function handleTaskTreeDrop(e: TaskTreeDropEvent) {
drag.value = false
const {moved} = await handleTaskDropToProject(e, removeTaskFromOverview)
if (moved) {
return
}
const task = findTaskById(e.taskId)
if (!task) {
return
}
try {
await updateTaskParent(task, e.oldParentTaskId, e.newParentTaskId)
await updateTaskPosition(task, getTaskTreeSiblings(e.newParentTaskId), e.newIndex)
} catch (e) {
error(e)
await loadPendingTasks(props.dateFrom, props.dateTo, filterIdUsedOnOverview.value)
}
}
async function loadPendingTasks(from: Date|string, to: Date|string, filterId: number | null | undefined) {
// FIXME: HACK! This should never happen.
// Since this route is authentication only, users would get an error message if they access the page unauthenticated.
@ -254,7 +451,7 @@ async function loadPendingTasks(from: Date|string, to: Date|string, filterId: nu
filter: 'done = false',
filter_include_nulls: props.showNulls,
s: '',
expand: ['comment_count', 'is_unread'],
expand: ['subtasks', 'comment_count', 'is_unread'],
}
if (!showAll.value) {