mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-04-25 06:35:32 +00:00
feat(tasks): move tasks between projects with drag and drop (#1945)
Drag and drop tasks between projects from list and kanban views, with cross-project move handling and success notification. With visual drop-target highlighting when hovering a project during a drag.
This commit is contained in:
@@ -73,7 +73,6 @@ const projectUpdating = ref<{ [id: IProject['id']]: boolean }>({})
|
|||||||
|
|
||||||
async function saveProjectPosition(e: SortableEvent) {
|
async function saveProjectPosition(e: SortableEvent) {
|
||||||
drag.value = false
|
drag.value = false
|
||||||
|
|
||||||
if (!e.newIndex && e.newIndex !== 0) return
|
if (!e.newIndex && e.newIndex !== 0) return
|
||||||
|
|
||||||
const projectsActive = availableProjects.value
|
const projectsActive = availableProjects.value
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<li
|
<li
|
||||||
class="list-menu loader-container is-loading-small"
|
class="list-menu loader-container is-loading-small"
|
||||||
:class="{'is-loading': isLoading}"
|
:class="{
|
||||||
|
'is-loading': isLoading,
|
||||||
|
'is-drop-target': isDropTarget,
|
||||||
|
}"
|
||||||
|
:data-project-id="project.id"
|
||||||
>
|
>
|
||||||
<div class="navigation-item">
|
<div class="navigation-item">
|
||||||
<BaseButton
|
<BaseButton
|
||||||
@@ -86,9 +90,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {computed} from 'vue'
|
import {computed, ref, onUnmounted, watch} from 'vue'
|
||||||
import {useProjectStore} from '@/stores/projects'
|
import {useProjectStore} from '@/stores/projects'
|
||||||
import {useBaseStore} from '@/stores/base'
|
import {useBaseStore} from '@/stores/base'
|
||||||
|
import {useTaskStore} from '@/stores/tasks'
|
||||||
import {useStorage} from '@vueuse/core'
|
import {useStorage} from '@vueuse/core'
|
||||||
|
|
||||||
import type {IProject} from '@/modelTypes/IProject'
|
import type {IProject} from '@/modelTypes/IProject'
|
||||||
@@ -107,6 +112,55 @@ const props = defineProps<{
|
|||||||
canEditOrder?: boolean,
|
canEditOrder?: boolean,
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const taskStore = useTaskStore()
|
||||||
|
const isHoveredDuringDrag = ref(false)
|
||||||
|
|
||||||
|
// Track mouse position during drag to detect hover (mouseenter doesn't fire during drag)
|
||||||
|
function handleMouseMove(e: MouseEvent) {
|
||||||
|
if (!taskStore.draggedTask) {
|
||||||
|
isHoveredDuringDrag.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const elementsUnderMouse = document.elementsFromPoint(e.clientX, e.clientY)
|
||||||
|
const isOverThisProject = elementsUnderMouse.some(el => {
|
||||||
|
const projectId = (el as HTMLElement).dataset?.projectId
|
||||||
|
return projectId && parseInt(projectId, 10) === props.project.id
|
||||||
|
})
|
||||||
|
|
||||||
|
isHoveredDuringDrag.value = isOverThisProject
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only add the listener when a task is being dragged
|
||||||
|
// Use capture phase to receive events before Sortable.js can prevent them
|
||||||
|
watch(() => taskStore.draggedTask, (draggedTask) => {
|
||||||
|
if (draggedTask) {
|
||||||
|
document.addEventListener('mousemove', handleMouseMove, true)
|
||||||
|
document.addEventListener('dragover', handleMouseMove, true)
|
||||||
|
} else {
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove, true)
|
||||||
|
document.removeEventListener('dragover', handleMouseMove, true)
|
||||||
|
isHoveredDuringDrag.value = false
|
||||||
|
}
|
||||||
|
}, {immediate: true})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove, true)
|
||||||
|
document.removeEventListener('dragover', handleMouseMove, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Show drop target highlight when a task is being dragged and this project is hovered
|
||||||
|
const isDropTarget = computed(() => {
|
||||||
|
if (!taskStore.draggedTask || !isHoveredDuringDrag.value) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Highlight any valid project (not a pseudo project, has write permission)
|
||||||
|
// The actual drop logic will handle the case when it's the same project (no-op)
|
||||||
|
return props.project.id > 0
|
||||||
|
&& props.project.maxPermission !== null
|
||||||
|
&& props.project.maxPermission > PERMISSIONS.READ
|
||||||
|
})
|
||||||
|
|
||||||
const projectStore = useProjectStore()
|
const projectStore = useProjectStore()
|
||||||
const baseStore = useBaseStore()
|
const baseStore = useBaseStore()
|
||||||
const currentProject = computed(() => baseStore.currentProject)
|
const currentProject = computed(() => baseStore.currentProject)
|
||||||
@@ -132,6 +186,10 @@ const childProjects = computed(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
.list-menu {
|
||||||
|
transition: background-color $transition;
|
||||||
|
}
|
||||||
|
|
||||||
.list-setting-spacer {
|
.list-setting-spacer {
|
||||||
inline-size: 5rem;
|
inline-size: 5rem;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -221,6 +279,7 @@ const childProjects = computed(() => {
|
|||||||
|
|
||||||
.navigation-item {
|
.navigation-item {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
transition: background-color $transition, box-shadow $transition;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navigation-item:has(*:focus-visible) {
|
.navigation-item:has(*:focus-visible) {
|
||||||
@@ -236,4 +295,15 @@ const childProjects = computed(() => {
|
|||||||
// The focus ring is already added to the navigation-item, so we don't need to add it again.
|
// The focus ring is already added to the navigation-item, so we don't need to add it again.
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.is-drop-target {
|
||||||
|
background-color: hsla(var(--primary-hsl), 0.15);
|
||||||
|
border-radius: $radius;
|
||||||
|
|
||||||
|
.navigation-item {
|
||||||
|
background-color: hsla(var(--primary-hsl), 0.1);
|
||||||
|
box-shadow: inset 0 0 0 2px var(--primary);
|
||||||
|
border-radius: $radius;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -156,7 +156,7 @@
|
|||||||
:item-key="(task: ITask) => `bucket${bucket.id}-task${task.id}`"
|
:item-key="(task: ITask) => `bucket${bucket.id}-task${task.id}`"
|
||||||
:component-data="getTaskDraggableTaskComponentData(bucket)"
|
:component-data="getTaskDraggableTaskComponentData(bucket)"
|
||||||
@update:modelValue="(tasks) => updateTasks(bucket.id, tasks)"
|
@update:modelValue="(tasks) => updateTasks(bucket.id, tasks)"
|
||||||
@start="() => dragstart(bucket)"
|
@start="handleTaskDragStart"
|
||||||
@end="updateTaskPosition"
|
@end="updateTaskPosition"
|
||||||
>
|
>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
@@ -210,7 +210,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #item="{element: task}">
|
<template #item="{element: task}">
|
||||||
<div class="task-item">
|
<div
|
||||||
|
class="task-item"
|
||||||
|
:data-task-id="task.id"
|
||||||
|
>
|
||||||
<KanbanCard
|
<KanbanCard
|
||||||
class="kanban-card"
|
class="kanban-card"
|
||||||
:task="task"
|
:task="task"
|
||||||
@@ -307,6 +310,7 @@ import {
|
|||||||
import {calculateItemPosition} from '@/helpers/calculateItemPosition'
|
import {calculateItemPosition} from '@/helpers/calculateItemPosition'
|
||||||
|
|
||||||
import {isSavedFilter, useSavedFilter} from '@/services/savedFilter'
|
import {isSavedFilter, useSavedFilter} from '@/services/savedFilter'
|
||||||
|
import {useTaskDragToProject} from '@/composables/useTaskDragToProject'
|
||||||
import {success} from '@/message'
|
import {success} from '@/message'
|
||||||
import {useProjectStore} from '@/stores/projects'
|
import {useProjectStore} from '@/stores/projects'
|
||||||
import type {TaskFilterParams} from '@/services/taskCollection'
|
import type {TaskFilterParams} from '@/services/taskCollection'
|
||||||
@@ -344,6 +348,7 @@ const baseStore = useBaseStore()
|
|||||||
const kanbanStore = useKanbanStore()
|
const kanbanStore = useKanbanStore()
|
||||||
const taskStore = useTaskStore()
|
const taskStore = useTaskStore()
|
||||||
const projectStore = useProjectStore()
|
const projectStore = useProjectStore()
|
||||||
|
const {handleTaskDropToProject} = useTaskDragToProject()
|
||||||
const taskPositionService = ref(new TaskPositionService())
|
const taskPositionService = ref(new TaskPositionService())
|
||||||
const taskBucketService = ref(new TaskBucketService())
|
const taskBucketService = ref(new TaskBucketService())
|
||||||
|
|
||||||
@@ -491,6 +496,20 @@ function updateTasks(bucketId: IBucket['id'], tasks: IBucket['tasks']) {
|
|||||||
async function updateTaskPosition(e) {
|
async function updateTaskPosition(e) {
|
||||||
drag.value = false
|
drag.value = false
|
||||||
|
|
||||||
|
// Check if dropped on a sidebar project
|
||||||
|
const {moved} = await handleTaskDropToProject(e, (task) => {
|
||||||
|
kanbanStore.removeTaskInBucket(task)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (moved) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If dropped outside kanban
|
||||||
|
if (!e.to.dataset.bucketIndex) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// While we could just pass the bucket index in through the function call, this would not give us the
|
// While we could just pass the bucket index in through the function call, this would not give us the
|
||||||
// new bucket id when a task has been moved between buckets, only the new bucket. Using the data-bucket-id
|
// new bucket id when a task has been moved between buckets, only the new bucket. Using the data-bucket-id
|
||||||
// of the drop target works all the time.
|
// of the drop target works all the time.
|
||||||
@@ -771,6 +790,18 @@ function dragstart(bucket: IBucket) {
|
|||||||
sourceBucket.value = bucket.id
|
sourceBucket.value = bucket.id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleTaskDragStart(e) {
|
||||||
|
const taskId = parseInt(e.item.dataset.taskId, 10)
|
||||||
|
const bucketIndex = parseInt(e.from.dataset.bucketIndex, 10)
|
||||||
|
const bucket = buckets.value[bucketIndex]
|
||||||
|
const task = bucket?.tasks.find(t => t.id === taskId)
|
||||||
|
|
||||||
|
if (task) {
|
||||||
|
taskStore.setDraggedTask(task)
|
||||||
|
}
|
||||||
|
dragstart(bucket)
|
||||||
|
}
|
||||||
|
|
||||||
async function toggleDefaultBucket(bucket: IBucket) {
|
async function toggleDefaultBucket(bucket: IBucket) {
|
||||||
const defaultBucketId = view.value?.defaultBucketId === bucket.id
|
const defaultBucketId = view.value?.defaultBucketId === bucket.id
|
||||||
? 0
|
? 0
|
||||||
|
|||||||
@@ -48,8 +48,7 @@
|
|||||||
<draggable
|
<draggable
|
||||||
v-if="tasks && tasks.length > 0"
|
v-if="tasks && tasks.length > 0"
|
||||||
v-model="tasks"
|
v-model="tasks"
|
||||||
group="tasks"
|
:group="{name: 'tasks', put: false}"
|
||||||
handle=".handle"
|
|
||||||
:disabled="!canDragTasks"
|
:disabled="!canDragTasks"
|
||||||
item-key="id"
|
item-key="id"
|
||||||
tag="ul"
|
tag="ul"
|
||||||
@@ -62,7 +61,7 @@
|
|||||||
}"
|
}"
|
||||||
:animation="100"
|
:animation="100"
|
||||||
ghost-class="task-ghost"
|
ghost-class="task-ghost"
|
||||||
@start="() => drag = true"
|
@start="handleDragStart"
|
||||||
@end="saveTaskPosition"
|
@end="saveTaskPosition"
|
||||||
>
|
>
|
||||||
<template #item="{element: t, index}">
|
<template #item="{element: t, index}">
|
||||||
@@ -74,13 +73,7 @@
|
|||||||
:the-task="t"
|
:the-task="t"
|
||||||
:all-tasks="allTasks"
|
:all-tasks="allTasks"
|
||||||
@taskUpdated="updateTasks"
|
@taskUpdated="updateTasks"
|
||||||
>
|
/>
|
||||||
<template v-if="canDragTasks">
|
|
||||||
<span class="icon handle">
|
|
||||||
<Icon icon="grip-lines" />
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
</SingleTaskInProject>
|
|
||||||
</template>
|
</template>
|
||||||
</draggable>
|
</draggable>
|
||||||
|
|
||||||
@@ -109,6 +102,7 @@ import Pagination from '@/components/misc/Pagination.vue'
|
|||||||
import {ALPHABETICAL_SORT} from '@/components/project/partials/Filters.vue'
|
import {ALPHABETICAL_SORT} from '@/components/project/partials/Filters.vue'
|
||||||
|
|
||||||
import {useTaskList} from '@/composables/useTaskList'
|
import {useTaskList} from '@/composables/useTaskList'
|
||||||
|
import {useTaskDragToProject} from '@/composables/useTaskDragToProject'
|
||||||
import {shouldShowTaskInListView} from '@/composables/useTaskListFiltering'
|
import {shouldShowTaskInListView} from '@/composables/useTaskListFiltering'
|
||||||
import {PERMISSIONS as Permissions} from '@/constants/permissions'
|
import {PERMISSIONS as Permissions} from '@/constants/permissions'
|
||||||
import {calculateItemPosition} from '@/helpers/calculateItemPosition'
|
import {calculateItemPosition} from '@/helpers/calculateItemPosition'
|
||||||
@@ -116,6 +110,7 @@ import type {ITask} from '@/modelTypes/ITask'
|
|||||||
import {isSavedFilter, useSavedFilter} from '@/services/savedFilter'
|
import {isSavedFilter, useSavedFilter} from '@/services/savedFilter'
|
||||||
|
|
||||||
import {useBaseStore} from '@/stores/base'
|
import {useBaseStore} from '@/stores/base'
|
||||||
|
import {useTaskStore} from '@/stores/tasks'
|
||||||
|
|
||||||
import type {IProject} from '@/modelTypes/IProject'
|
import type {IProject} from '@/modelTypes/IProject'
|
||||||
import type {IProjectView} from '@/modelTypes/IProjectView'
|
import type {IProjectView} from '@/modelTypes/IProjectView'
|
||||||
@@ -179,6 +174,8 @@ const firstNewPosition = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const baseStore = useBaseStore()
|
const baseStore = useBaseStore()
|
||||||
|
const taskStore = useTaskStore()
|
||||||
|
const {handleTaskDropToProject} = useTaskDragToProject()
|
||||||
const project = computed(() => baseStore.currentProject)
|
const project = computed(() => baseStore.currentProject)
|
||||||
|
|
||||||
const canWrite = computed(() => {
|
const canWrite = computed(() => {
|
||||||
@@ -229,9 +226,33 @@ function updateTasks(updatedTask: ITask) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveTaskPosition(e) {
|
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)
|
||||||
|
|
||||||
|
if (task) {
|
||||||
|
taskStore.setDraggedTask(task)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveTaskPosition(e: { originalEvent?: MouseEvent, to: HTMLElement, from: HTMLElement, newIndex: number }) {
|
||||||
drag.value = false
|
drag.value = false
|
||||||
|
|
||||||
|
// Check if dropped on a sidebar project
|
||||||
|
const {moved} = await handleTaskDropToProject(e, (task) => {
|
||||||
|
tasks.value = tasks.value.filter(t => t.id !== task.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (moved) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If dropped outside this list
|
||||||
|
if (e.to !== e.from) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const task = tasks.value[e.newIndex]
|
const task = tasks.value[e.newIndex]
|
||||||
const taskBefore = tasks.value[e.newIndex - 1] ?? null
|
const taskBefore = tasks.value[e.newIndex - 1] ?? null
|
||||||
const taskAfter = tasks.value[e.newIndex + 1] ?? null
|
const taskAfter = tasks.value[e.newIndex + 1] ?? null
|
||||||
@@ -350,22 +371,11 @@ onBeforeUnmount(() => {
|
|||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.single-task) {
|
:deep(.tasks:not(.dragging-disabled) .single-task) {
|
||||||
.handle {
|
cursor: grab;
|
||||||
opacity: 1;
|
|
||||||
transition: opacity $transition;
|
|
||||||
margin-inline-end: .25rem;
|
|
||||||
cursor: grab;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media(hover: hover) and (pointer: fine) {
|
&:active {
|
||||||
& .handle {
|
cursor: grabbing;
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover .handle {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
'has-custom-background-color': color ?? undefined,
|
'has-custom-background-color': color ?? undefined,
|
||||||
}"
|
}"
|
||||||
:style="{'background-color': color ?? undefined}"
|
:style="{'background-color': color ?? undefined}"
|
||||||
|
:data-task-id="task.id"
|
||||||
|
:data-project-id="task.projectId"
|
||||||
@click.exact="openTaskDetail()"
|
@click.exact="openTaskDetail()"
|
||||||
@click.ctrl="() => toggleTaskDone(task)"
|
@click.ctrl="() => toggleTaskDone(task)"
|
||||||
@click.meta="() => toggleTaskDone(task)"
|
@click.meta="() => toggleTaskDone(task)"
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div
|
||||||
|
:data-task-id="task.id"
|
||||||
|
:data-project-id="task.projectId"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
ref="taskRoot"
|
ref="taskRoot"
|
||||||
:class="{'is-loading': taskService.loading}"
|
:class="{'is-loading': taskService.loading}"
|
||||||
|
|||||||
112
frontend/src/composables/useTaskDragToProject.ts
Normal file
112
frontend/src/composables/useTaskDragToProject.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import {useI18n} from 'vue-i18n'
|
||||||
|
|
||||||
|
import type {ITask} from '@/modelTypes/ITask'
|
||||||
|
import {useTaskStore} from '@/stores/tasks'
|
||||||
|
import {useProjectStore} from '@/stores/projects'
|
||||||
|
import {success, error} from '@/message'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds a project ID from elements at a given mouse position.
|
||||||
|
* Searches through elements under the mouse and their parents for data-project-id attribute.
|
||||||
|
*/
|
||||||
|
function findProjectIdAtPosition(mouseX: number, mouseY: number): number | null {
|
||||||
|
const elementsUnderMouse = document.elementsFromPoint(mouseX, mouseY)
|
||||||
|
|
||||||
|
for (const el of elementsUnderMouse) {
|
||||||
|
if (!(el instanceof HTMLElement)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const withProjectId =
|
||||||
|
el.dataset?.projectId != null
|
||||||
|
? el
|
||||||
|
: el.closest('[data-project-id]') as HTMLElement | null
|
||||||
|
|
||||||
|
const projectId = withProjectId?.dataset.projectId
|
||||||
|
if (projectId) {
|
||||||
|
const parsed = parseInt(projectId, 10)
|
||||||
|
if (!Number.isNaN(parsed)) {
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskDragToProjectResult {
|
||||||
|
moved: boolean
|
||||||
|
targetProjectId: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable for handling task drag-and-drop to sidebar projects.
|
||||||
|
*
|
||||||
|
* Provides functionality to:
|
||||||
|
* - Detect when a task is dropped over a sidebar project
|
||||||
|
* - Move the task to the target project
|
||||||
|
* - Show success/error notifications
|
||||||
|
*
|
||||||
|
* @returns Functions for handling drag start and checking for project drops
|
||||||
|
*/
|
||||||
|
export function useTaskDragToProject() {
|
||||||
|
const {t} = useI18n({useScope: 'global'})
|
||||||
|
const taskStore = useTaskStore()
|
||||||
|
const projectStore = useProjectStore()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempts to move a dragged task to a project at the given mouse position.
|
||||||
|
* Should be called in the drag end handler.
|
||||||
|
*
|
||||||
|
* @param e - The drag event with originalEvent containing mouse coordinates
|
||||||
|
* @param onSuccess - Optional callback called after successful move (e.g., to update local state)
|
||||||
|
* @returns Result indicating if task was moved and to which project
|
||||||
|
*/
|
||||||
|
async function handleTaskDropToProject(
|
||||||
|
e: { originalEvent?: MouseEvent },
|
||||||
|
onSuccess?: (task: ITask, targetProjectId: number) => void,
|
||||||
|
): Promise<TaskDragToProjectResult> {
|
||||||
|
const draggedTask = taskStore.draggedTask
|
||||||
|
|
||||||
|
if (!draggedTask || !e.originalEvent) {
|
||||||
|
taskStore.setDraggedTask(null)
|
||||||
|
return {moved: false, targetProjectId: null}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mouseX = e.originalEvent.clientX
|
||||||
|
const mouseY = e.originalEvent.clientY
|
||||||
|
const targetProjectId = findProjectIdAtPosition(mouseX, mouseY)
|
||||||
|
|
||||||
|
if (!targetProjectId || targetProjectId <= 0 || targetProjectId === draggedTask.projectId) {
|
||||||
|
taskStore.setDraggedTask(null)
|
||||||
|
return {moved: false, targetProjectId}
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetProject = projectStore.projects[targetProjectId]
|
||||||
|
|
||||||
|
try {
|
||||||
|
await taskStore.update({
|
||||||
|
...draggedTask,
|
||||||
|
projectId: targetProjectId,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (onSuccess) {
|
||||||
|
onSuccess(draggedTask, targetProjectId)
|
||||||
|
}
|
||||||
|
|
||||||
|
success({message: t('task.movedToProject', {project: targetProject?.title || t('project.title')})})
|
||||||
|
|
||||||
|
return {moved: true, targetProjectId}
|
||||||
|
} catch (e) {
|
||||||
|
error(e)
|
||||||
|
return {moved: false, targetProjectId}
|
||||||
|
} finally {
|
||||||
|
// Always clears drag state - callers should not clear again
|
||||||
|
taskStore.setDraggedTask(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleTaskDropToProject,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -799,6 +799,7 @@
|
|||||||
"addReminder": "Add a reminder…",
|
"addReminder": "Add a reminder…",
|
||||||
"doneSuccess": "The task was successfully marked as done.",
|
"doneSuccess": "The task was successfully marked as done.",
|
||||||
"undoneSuccess": "The task was successfully un-marked as done.",
|
"undoneSuccess": "The task was successfully un-marked as done.",
|
||||||
|
"movedToProject": "The task was moved to {project}.",
|
||||||
"undo": "Undo",
|
"undo": "Undo",
|
||||||
"openDetail": "Open task detail view",
|
"openDetail": "Open task detail view",
|
||||||
"checklistTotal": "{checked} of {total} tasks",
|
"checklistTotal": "{checked} of {total} tasks",
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ export const useTaskStore = defineStore('task', () => {
|
|||||||
|
|
||||||
const tasks = ref<{ [id: ITask['id']]: ITask }>({}) // TODO: or is this ITask[]
|
const tasks = ref<{ [id: ITask['id']]: ITask }>({}) // TODO: or is this ITask[]
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
|
const draggedTask = ref<ITask | null>(null)
|
||||||
|
|
||||||
const hasTasks = computed(() => Object.keys(tasks.value).length > 0)
|
const hasTasks = computed(() => Object.keys(tasks.value).length > 0)
|
||||||
|
|
||||||
@@ -120,6 +121,10 @@ export const useTaskStore = defineStore('task', () => {
|
|||||||
isLoading.value = newIsLoading
|
isLoading.value = newIsLoading
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setDraggedTask(task: ITask | null) {
|
||||||
|
draggedTask.value = task
|
||||||
|
}
|
||||||
|
|
||||||
function setTasks(newTasks: ITask[]) {
|
function setTasks(newTasks: ITask[]) {
|
||||||
newTasks.forEach(task => {
|
newTasks.forEach(task => {
|
||||||
tasks.value[task.id] = task
|
tasks.value[task.id] = task
|
||||||
@@ -535,10 +540,12 @@ export const useTaskStore = defineStore('task', () => {
|
|||||||
return {
|
return {
|
||||||
tasks,
|
tasks,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
draggedTask,
|
||||||
|
|
||||||
hasTasks,
|
hasTasks,
|
||||||
|
|
||||||
setTasks,
|
setTasks,
|
||||||
|
setDraggedTask,
|
||||||
loadTasks,
|
loadTasks,
|
||||||
update,
|
update,
|
||||||
delete: deleteTask, // since delete is a reserved word we have to alias here
|
delete: deleteTask, // since delete is a reserved word we have to alias here
|
||||||
|
|||||||
333
frontend/tests/e2e/task/drag-to-project.spec.ts
Normal file
333
frontend/tests/e2e/task/drag-to-project.spec.ts
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
import {test, expect} from '../../support/fixtures'
|
||||||
|
import {TaskFactory} from '../../factories/task'
|
||||||
|
import {ProjectFactory} from '../../factories/project'
|
||||||
|
import {ProjectViewFactory} from '../../factories/project_view'
|
||||||
|
import {BucketFactory} from '../../factories/bucket'
|
||||||
|
import {TaskBucketFactory} from '../../factories/task_buckets'
|
||||||
|
import {SavedFilterFactory} from '../../factories/saved_filter'
|
||||||
|
import {UserFactory} from '../../factories/user'
|
||||||
|
import {UserProjectFactory} from '../../factories/users_project'
|
||||||
|
|
||||||
|
async function createProjectsWithTasks() {
|
||||||
|
// Create two projects
|
||||||
|
const projects = await ProjectFactory.create(2, {
|
||||||
|
title: i => i === 0 ? 'Source Project' : 'Target Project',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create views for both projects
|
||||||
|
await ProjectViewFactory.truncate()
|
||||||
|
|
||||||
|
// List view for source project
|
||||||
|
const sourceListView = await ProjectViewFactory.create(1, {
|
||||||
|
id: 1,
|
||||||
|
project_id: projects[0].id,
|
||||||
|
view_kind: 0,
|
||||||
|
}, false)
|
||||||
|
|
||||||
|
// Kanban view for source project
|
||||||
|
const sourceKanbanView = await ProjectViewFactory.create(1, {
|
||||||
|
id: 2,
|
||||||
|
project_id: projects[0].id,
|
||||||
|
view_kind: 3,
|
||||||
|
bucket_configuration_mode: 1,
|
||||||
|
}, false)
|
||||||
|
|
||||||
|
// List view for target project
|
||||||
|
await ProjectViewFactory.create(1, {
|
||||||
|
id: 3,
|
||||||
|
project_id: projects[1].id,
|
||||||
|
view_kind: 0,
|
||||||
|
}, false)
|
||||||
|
|
||||||
|
// Create bucket for kanban view
|
||||||
|
const buckets = await BucketFactory.create(1, {
|
||||||
|
project_view_id: 2,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create tasks in source project
|
||||||
|
await TaskFactory.truncate()
|
||||||
|
const tasks = await TaskFactory.create(3, {
|
||||||
|
id: '{increment}',
|
||||||
|
title: i => `Task ${i + 1}`,
|
||||||
|
project_id: projects[0].id,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Assign tasks to bucket for kanban view
|
||||||
|
await TaskBucketFactory.truncate()
|
||||||
|
for (const task of tasks) {
|
||||||
|
await TaskBucketFactory.create(1, {
|
||||||
|
task_id: task.id,
|
||||||
|
bucket_id: buckets[0].id,
|
||||||
|
project_view_id: 2,
|
||||||
|
}, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sourceProject: projects[0],
|
||||||
|
targetProject: projects[1],
|
||||||
|
sourceListView: sourceListView[0],
|
||||||
|
sourceKanbanView: sourceKanbanView[0],
|
||||||
|
tasks,
|
||||||
|
bucket: buckets[0],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Drag Task to Project in Sidebar', () => {
|
||||||
|
test.describe('From List View', () => {
|
||||||
|
test('Can drag a task to another project in the sidebar', async ({authenticatedPage: page}) => {
|
||||||
|
const {sourceProject, targetProject, sourceListView, tasks} = await createProjectsWithTasks()
|
||||||
|
|
||||||
|
await page.goto(`/projects/${sourceProject.id}/${sourceListView.id}`)
|
||||||
|
|
||||||
|
// Wait for tasks to load
|
||||||
|
await expect(page.locator('.tasks')).toContainText(tasks[0].title)
|
||||||
|
|
||||||
|
// Find the task and the target project in sidebar (use li selector to avoid matching task elements)
|
||||||
|
const task = page.locator('.tasks .single-task').filter({hasText: tasks[0].title})
|
||||||
|
const targetProjectInSidebar = page.locator('li[data-project-id="' + targetProject.id + '"]')
|
||||||
|
|
||||||
|
// Drag task to target project
|
||||||
|
await task.dragTo(targetProjectInSidebar)
|
||||||
|
|
||||||
|
// Verify success notification
|
||||||
|
await expect(page.locator('.global-notification')).toContainText('moved to')
|
||||||
|
|
||||||
|
// Verify task is removed from the list
|
||||||
|
await expect(page.locator('.tasks')).not.toContainText(tasks[0].title)
|
||||||
|
|
||||||
|
// Verify task appears in target project
|
||||||
|
await page.goto(`/projects/${targetProject.id}/3`)
|
||||||
|
await expect(page.locator('.tasks')).toContainText(tasks[0].title)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Does not move task when dropped on the same project', async ({authenticatedPage: page}) => {
|
||||||
|
const {sourceProject, sourceListView, tasks} = await createProjectsWithTasks()
|
||||||
|
|
||||||
|
await page.goto(`/projects/${sourceProject.id}/${sourceListView.id}`)
|
||||||
|
|
||||||
|
// Wait for tasks to load
|
||||||
|
await expect(page.locator('.tasks')).toContainText(tasks[0].title)
|
||||||
|
|
||||||
|
// Find the task and the source project in sidebar (use li selector to avoid matching task elements)
|
||||||
|
const task = page.locator('.tasks .single-task').filter({hasText: tasks[0].title})
|
||||||
|
const sourceProjectInSidebar = page.locator('li[data-project-id="' + sourceProject.id + '"]')
|
||||||
|
|
||||||
|
// Drag task to the same project
|
||||||
|
await task.dragTo(sourceProjectInSidebar)
|
||||||
|
|
||||||
|
// Task should still be in the list (no move occurred)
|
||||||
|
await expect(page.locator('.tasks')).toContainText(tasks[0].title)
|
||||||
|
|
||||||
|
// No success notification should appear for same-project drop
|
||||||
|
await expect(page.locator('.global-notification')).not.toContainText('moved to')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('From Kanban View', () => {
|
||||||
|
test('Can drag a task to another project in the sidebar', async ({authenticatedPage: page}) => {
|
||||||
|
const {sourceProject, targetProject, sourceKanbanView, tasks} = await createProjectsWithTasks()
|
||||||
|
|
||||||
|
await page.goto(`/projects/${sourceProject.id}/${sourceKanbanView.id}`)
|
||||||
|
|
||||||
|
// Wait for kanban to load
|
||||||
|
await expect(page.locator('.kanban .bucket .tasks')).toContainText(tasks[0].title)
|
||||||
|
|
||||||
|
// Find the task and the target project in sidebar (use li selector to avoid matching task elements)
|
||||||
|
const task = page.locator('.kanban .bucket .tasks .task').filter({hasText: tasks[0].title})
|
||||||
|
const targetProjectInSidebar = page.locator('li[data-project-id="' + targetProject.id + '"]')
|
||||||
|
|
||||||
|
// Drag task to target project
|
||||||
|
await task.dragTo(targetProjectInSidebar)
|
||||||
|
|
||||||
|
// Verify success notification
|
||||||
|
await expect(page.locator('.global-notification')).toContainText('moved to')
|
||||||
|
|
||||||
|
// Verify task is removed from the kanban board
|
||||||
|
await expect(page.locator('.kanban .bucket .tasks')).not.toContainText(tasks[0].title)
|
||||||
|
|
||||||
|
// Verify task appears in target project
|
||||||
|
await page.goto(`/projects/${targetProject.id}/3`)
|
||||||
|
await expect(page.locator('.tasks')).toContainText(tasks[0].title)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Does not move task when dropped on the same project', async ({authenticatedPage: page}) => {
|
||||||
|
const {sourceProject, sourceKanbanView, tasks} = await createProjectsWithTasks()
|
||||||
|
|
||||||
|
await page.goto(`/projects/${sourceProject.id}/${sourceKanbanView.id}`)
|
||||||
|
|
||||||
|
// Wait for kanban to load
|
||||||
|
await expect(page.locator('.kanban .bucket .tasks')).toContainText(tasks[0].title)
|
||||||
|
|
||||||
|
// Find the task and the source project in sidebar (use li selector to avoid matching task elements)
|
||||||
|
const task = page.locator('.kanban .bucket .tasks .task').filter({hasText: tasks[0].title})
|
||||||
|
const sourceProjectInSidebar = page.locator('li[data-project-id="' + sourceProject.id + '"]')
|
||||||
|
|
||||||
|
// Drag task to the same project
|
||||||
|
await task.dragTo(sourceProjectInSidebar)
|
||||||
|
|
||||||
|
// Task should still be in the kanban (no move occurred)
|
||||||
|
await expect(page.locator('.kanban .bucket .tasks')).toContainText(tasks[0].title)
|
||||||
|
|
||||||
|
// No success notification should appear for same-project drop
|
||||||
|
await expect(page.locator('.global-notification')).not.toContainText('moved to')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('Invalid Drop Targets', () => {
|
||||||
|
test('Does not move task when dropped on a saved filter', async ({authenticatedPage: page}) => {
|
||||||
|
// Create source project with tasks
|
||||||
|
const projects = await ProjectFactory.create(1, {
|
||||||
|
title: 'Source Project',
|
||||||
|
})
|
||||||
|
|
||||||
|
await ProjectViewFactory.truncate()
|
||||||
|
const sourceListView = await ProjectViewFactory.create(1, {
|
||||||
|
id: 1,
|
||||||
|
project_id: projects[0].id,
|
||||||
|
view_kind: 0,
|
||||||
|
}, false)
|
||||||
|
|
||||||
|
// Create a saved filter (shows as pseudo-project with negative ID in sidebar)
|
||||||
|
await SavedFilterFactory.create(1, {
|
||||||
|
id: 1,
|
||||||
|
title: 'My Saved Filter',
|
||||||
|
owner_id: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
await TaskFactory.truncate()
|
||||||
|
const tasks = await TaskFactory.create(1, {
|
||||||
|
id: 1,
|
||||||
|
title: 'Test Task',
|
||||||
|
project_id: projects[0].id,
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.goto(`/projects/${projects[0].id}/${sourceListView[0].id}`)
|
||||||
|
|
||||||
|
// Wait for tasks to load
|
||||||
|
await expect(page.locator('.tasks')).toContainText(tasks[0].title)
|
||||||
|
|
||||||
|
// Find the task and the saved filter in sidebar
|
||||||
|
// Saved filters have negative IDs: id * -1 - 1, so filter id=1 -> project id=-2
|
||||||
|
const task = page.locator('.tasks .single-task').filter({hasText: tasks[0].title})
|
||||||
|
const savedFilterInSidebar = page.locator('li[data-project-id="-2"]')
|
||||||
|
|
||||||
|
// Verify the saved filter is visible in sidebar
|
||||||
|
await expect(savedFilterInSidebar).toBeVisible()
|
||||||
|
|
||||||
|
// Drag task to the saved filter
|
||||||
|
await task.dragTo(savedFilterInSidebar)
|
||||||
|
|
||||||
|
// Task should still be in the list (saved filters cannot accept tasks)
|
||||||
|
await expect(page.locator('.tasks')).toContainText(tasks[0].title)
|
||||||
|
|
||||||
|
// No success notification should appear
|
||||||
|
await expect(page.locator('.global-notification')).not.toContainText('moved to')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Does not move task when dropped on a read-only shared project', async ({authenticatedPage: page}) => {
|
||||||
|
// Create a second user who will own the read-only project
|
||||||
|
await UserFactory.create(2)
|
||||||
|
|
||||||
|
// Create source project (owned by user 1)
|
||||||
|
const sourceProject = await ProjectFactory.create(1, {
|
||||||
|
id: 1,
|
||||||
|
title: 'Source Project',
|
||||||
|
owner_id: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create target project (owned by user 2, shared read-only to user 1)
|
||||||
|
const readOnlyProject = await ProjectFactory.create(1, {
|
||||||
|
id: 2,
|
||||||
|
title: 'Read Only Project',
|
||||||
|
owner_id: 2,
|
||||||
|
}, false)
|
||||||
|
|
||||||
|
// Share the project read-only to user 1 (permission 0 = read)
|
||||||
|
await UserProjectFactory.create(1, {
|
||||||
|
project_id: 2,
|
||||||
|
user_id: 1,
|
||||||
|
permission: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
await ProjectViewFactory.truncate()
|
||||||
|
const sourceListView = await ProjectViewFactory.create(1, {
|
||||||
|
id: 1,
|
||||||
|
project_id: sourceProject[0].id,
|
||||||
|
view_kind: 0,
|
||||||
|
}, false)
|
||||||
|
|
||||||
|
// Create a view for the read-only project so it shows in sidebar
|
||||||
|
await ProjectViewFactory.create(1, {
|
||||||
|
id: 2,
|
||||||
|
project_id: readOnlyProject[0].id,
|
||||||
|
view_kind: 0,
|
||||||
|
}, false)
|
||||||
|
|
||||||
|
await TaskFactory.truncate()
|
||||||
|
const tasks = await TaskFactory.create(1, {
|
||||||
|
id: 1,
|
||||||
|
title: 'Test Task',
|
||||||
|
project_id: sourceProject[0].id,
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.goto(`/projects/${sourceProject[0].id}/${sourceListView[0].id}`)
|
||||||
|
|
||||||
|
// Wait for tasks to load
|
||||||
|
await expect(page.locator('.tasks')).toContainText(tasks[0].title)
|
||||||
|
|
||||||
|
// Find the task and the read-only project in sidebar
|
||||||
|
const task = page.locator('.tasks .single-task').filter({hasText: tasks[0].title})
|
||||||
|
const readOnlyProjectInSidebar = page.locator('li[data-project-id="' + readOnlyProject[0].id + '"]')
|
||||||
|
|
||||||
|
// Verify the read-only project is visible in sidebar
|
||||||
|
await expect(readOnlyProjectInSidebar).toBeVisible()
|
||||||
|
|
||||||
|
// Drag task to the read-only project
|
||||||
|
await task.dragTo(readOnlyProjectInSidebar)
|
||||||
|
|
||||||
|
// Task should still be in the list (read-only projects cannot accept tasks)
|
||||||
|
await expect(page.locator('.tasks')).toContainText(tasks[0].title)
|
||||||
|
|
||||||
|
// No success notification should appear (visual highlight doesn't show for read-only)
|
||||||
|
await expect(page.locator('.global-notification')).not.toContainText('moved to')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Shows error notification when task move fails', async ({authenticatedPage: page}) => {
|
||||||
|
const {sourceProject, targetProject, sourceListView, tasks} = await createProjectsWithTasks()
|
||||||
|
|
||||||
|
await page.goto(`/projects/${sourceProject.id}/${sourceListView.id}`)
|
||||||
|
|
||||||
|
// Wait for tasks to load
|
||||||
|
await expect(page.locator('.tasks')).toContainText(tasks[0].title)
|
||||||
|
|
||||||
|
// Intercept the task update API call and return an error
|
||||||
|
await page.route('**/api/v1/tasks/*', async (route) => {
|
||||||
|
if (route.request().method() === 'POST') {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 500,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
code: 500,
|
||||||
|
message: 'Internal server error',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await route.continue()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Find the task and the target project in sidebar
|
||||||
|
const task = page.locator('.tasks .single-task').filter({hasText: tasks[0].title})
|
||||||
|
const targetProjectInSidebar = page.locator('li[data-project-id="' + targetProject.id + '"]')
|
||||||
|
|
||||||
|
// Drag task to target project
|
||||||
|
await task.dragTo(targetProjectInSidebar)
|
||||||
|
|
||||||
|
// Verify error notification appears
|
||||||
|
await expect(page.locator('.global-notification .vue-notification.error')).toBeVisible()
|
||||||
|
|
||||||
|
// Task should still be in the list (move failed)
|
||||||
|
await expect(page.locator('.tasks')).toContainText(tasks[0].title)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
20
frontend/tests/factories/saved_filter.ts
Normal file
20
frontend/tests/factories/saved_filter.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import {Factory} from '../support/factory'
|
||||||
|
|
||||||
|
export class SavedFilterFactory extends Factory {
|
||||||
|
static table = 'saved_filters'
|
||||||
|
|
||||||
|
static factory() {
|
||||||
|
const now = new Date()
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: '{increment}',
|
||||||
|
title: 'Test Filter',
|
||||||
|
description: '',
|
||||||
|
filters: '{"filter":"","filter_include_nulls":false,"s":""}',
|
||||||
|
owner_id: 1,
|
||||||
|
is_favorite: false,
|
||||||
|
created: now.toISOString(),
|
||||||
|
updated: now.toISOString(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user