This commit is contained in:
simson 2026-06-30 07:03:28 -05:00 committed by GitHub
commit 6d897eccd8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
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

@ -954,7 +954,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) {