From 5e00d78ebd54a64a493a25a18fded78bfd8add60 Mon Sep 17 00:00:00 2001 From: Simeon Marijon Date: Tue, 12 May 2026 10:16:18 +0200 Subject: [PATCH] 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 --- .../components/project/views/ProjectList.vue | 204 +++++++++++---- .../tasks/partials/TaskTreeDraggable.vue | 235 ++++++++++++++++++ .../src/composables/useTaskDragToProject.ts | 5 +- frontend/src/i18n/lang/en.json | 3 +- frontend/src/views/tasks/ShowTasks.vue | 221 +++++++++++++++- 5 files changed, 597 insertions(+), 71 deletions(-) create mode 100644 frontend/src/components/tasks/partials/TaskTreeDraggable.vue diff --git a/frontend/src/components/project/views/ProjectList.vue b/frontend/src/components/project/views/ProjectList.vue index ae8dd56ed..55c13ec1e 100644 --- a/frontend/src/components/project/views/ProjectList.vue +++ b/frontend/src/components/project/views/ProjectList.vue @@ -48,46 +48,25 @@ - - - + :transition-group="true" + @dragStart="handleDragStart" + @drop="saveTaskTreeDrop" + @updateList="updateTaskTreeList" + @taskUpdated="updateTasks" + /> 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() + 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) { + (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 | null, index: } } +function isSingleTaskComponent(el: unknown): el is InstanceType { + 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 diff --git a/frontend/src/components/tasks/partials/TaskTreeDraggable.vue b/frontend/src/components/tasks/partials/TaskTreeDraggable.vue new file mode 100644 index 000000000..c985ad6f6 --- /dev/null +++ b/frontend/src/components/tasks/partials/TaskTreeDraggable.vue @@ -0,0 +1,235 @@ + + + + + diff --git a/frontend/src/composables/useTaskDragToProject.ts b/frontend/src/composables/useTaskDragToProject.ts index 32a00db2f..c471432fb 100644 --- a/frontend/src/composables/useTaskDragToProject.ts +++ b/frontend/src/composables/useTaskDragToProject.ts @@ -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) { diff --git a/frontend/src/i18n/lang/en.json b/frontend/src/i18n/lang/en.json index 8af6ae590..23ba08757 100644 --- a/frontend/src/i18n/lang/en.json +++ b/frontend/src/i18n/lang/en.json @@ -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", diff --git a/frontend/src/views/tasks/ShowTasks.vue b/frontend/src/views/tasks/ShowTasks.vue index 192393e77..c170e49a4 100644 --- a/frontend/src/views/tasks/ShowTasks.vue +++ b/frontend/src/views/tasks/ShowTasks.vue @@ -67,6 +67,14 @@ {{ $t('task.show.overdue') }}

+

+ + {{ $t('task.show.hierarchical') }} + +