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') }}
+
+
{{ $t('task.show.noTasks') }}
@@ -81,16 +89,19 @@
:has-content="false"
:loading="loading"
>
-
-
-
+
([])
const showNothingToDo = ref(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()
+ 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) {
+ (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) {