mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-02-01 22:47:40 +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) {
|
||||
drag.value = false
|
||||
|
||||
if (!e.newIndex && e.newIndex !== 0) return
|
||||
|
||||
const projectsActive = availableProjects.value
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
<template>
|
||||
<li
|
||||
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">
|
||||
<BaseButton
|
||||
@@ -86,9 +90,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed} from 'vue'
|
||||
import {computed, ref, onUnmounted, watch} from 'vue'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
import {useTaskStore} from '@/stores/tasks'
|
||||
import {useStorage} from '@vueuse/core'
|
||||
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
@@ -107,6 +112,55 @@ const props = defineProps<{
|
||||
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 baseStore = useBaseStore()
|
||||
const currentProject = computed(() => baseStore.currentProject)
|
||||
@@ -132,6 +186,10 @@ const childProjects = computed(() => {
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.list-menu {
|
||||
transition: background-color $transition;
|
||||
}
|
||||
|
||||
.list-setting-spacer {
|
||||
inline-size: 5rem;
|
||||
flex-shrink: 0;
|
||||
@@ -221,6 +279,7 @@ const childProjects = computed(() => {
|
||||
|
||||
.navigation-item {
|
||||
position: relative;
|
||||
transition: background-color $transition, box-shadow $transition;
|
||||
}
|
||||
|
||||
.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.
|
||||
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>
|
||||
|
||||
@@ -156,7 +156,7 @@
|
||||
:item-key="(task: ITask) => `bucket${bucket.id}-task${task.id}`"
|
||||
:component-data="getTaskDraggableTaskComponentData(bucket)"
|
||||
@update:modelValue="(tasks) => updateTasks(bucket.id, tasks)"
|
||||
@start="() => dragstart(bucket)"
|
||||
@start="handleTaskDragStart"
|
||||
@end="updateTaskPosition"
|
||||
>
|
||||
<template #footer>
|
||||
@@ -210,7 +210,10 @@
|
||||
</template>
|
||||
|
||||
<template #item="{element: task}">
|
||||
<div class="task-item">
|
||||
<div
|
||||
class="task-item"
|
||||
:data-task-id="task.id"
|
||||
>
|
||||
<KanbanCard
|
||||
class="kanban-card"
|
||||
:task="task"
|
||||
@@ -307,6 +310,7 @@ import {
|
||||
import {calculateItemPosition} from '@/helpers/calculateItemPosition'
|
||||
|
||||
import {isSavedFilter, useSavedFilter} from '@/services/savedFilter'
|
||||
import {useTaskDragToProject} from '@/composables/useTaskDragToProject'
|
||||
import {success} from '@/message'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
import type {TaskFilterParams} from '@/services/taskCollection'
|
||||
@@ -344,6 +348,7 @@ const baseStore = useBaseStore()
|
||||
const kanbanStore = useKanbanStore()
|
||||
const taskStore = useTaskStore()
|
||||
const projectStore = useProjectStore()
|
||||
const {handleTaskDropToProject} = useTaskDragToProject()
|
||||
const taskPositionService = ref(new TaskPositionService())
|
||||
const taskBucketService = ref(new TaskBucketService())
|
||||
|
||||
@@ -491,6 +496,20 @@ function updateTasks(bucketId: IBucket['id'], tasks: IBucket['tasks']) {
|
||||
async function updateTaskPosition(e) {
|
||||
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
|
||||
// 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.
|
||||
@@ -771,6 +790,18 @@ function dragstart(bucket: IBucket) {
|
||||
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) {
|
||||
const defaultBucketId = view.value?.defaultBucketId === bucket.id
|
||||
? 0
|
||||
|
||||
@@ -48,8 +48,7 @@
|
||||
<draggable
|
||||
v-if="tasks && tasks.length > 0"
|
||||
v-model="tasks"
|
||||
group="tasks"
|
||||
handle=".handle"
|
||||
:group="{name: 'tasks', put: false}"
|
||||
:disabled="!canDragTasks"
|
||||
item-key="id"
|
||||
tag="ul"
|
||||
@@ -62,7 +61,7 @@
|
||||
}"
|
||||
:animation="100"
|
||||
ghost-class="task-ghost"
|
||||
@start="() => drag = true"
|
||||
@start="handleDragStart"
|
||||
@end="saveTaskPosition"
|
||||
>
|
||||
<template #item="{element: t, index}">
|
||||
@@ -74,13 +73,7 @@
|
||||
:the-task="t"
|
||||
:all-tasks="allTasks"
|
||||
@taskUpdated="updateTasks"
|
||||
>
|
||||
<template v-if="canDragTasks">
|
||||
<span class="icon handle">
|
||||
<Icon icon="grip-lines" />
|
||||
</span>
|
||||
</template>
|
||||
</SingleTaskInProject>
|
||||
/>
|
||||
</template>
|
||||
</draggable>
|
||||
|
||||
@@ -109,6 +102,7 @@ import Pagination from '@/components/misc/Pagination.vue'
|
||||
import {ALPHABETICAL_SORT} from '@/components/project/partials/Filters.vue'
|
||||
|
||||
import {useTaskList} from '@/composables/useTaskList'
|
||||
import {useTaskDragToProject} from '@/composables/useTaskDragToProject'
|
||||
import {shouldShowTaskInListView} from '@/composables/useTaskListFiltering'
|
||||
import {PERMISSIONS as Permissions} from '@/constants/permissions'
|
||||
import {calculateItemPosition} from '@/helpers/calculateItemPosition'
|
||||
@@ -116,6 +110,7 @@ import type {ITask} from '@/modelTypes/ITask'
|
||||
import {isSavedFilter, useSavedFilter} from '@/services/savedFilter'
|
||||
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
import {useTaskStore} from '@/stores/tasks'
|
||||
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
import type {IProjectView} from '@/modelTypes/IProjectView'
|
||||
@@ -179,6 +174,8 @@ const firstNewPosition = computed(() => {
|
||||
})
|
||||
|
||||
const baseStore = useBaseStore()
|
||||
const taskStore = useTaskStore()
|
||||
const {handleTaskDropToProject} = useTaskDragToProject()
|
||||
const project = computed(() => baseStore.currentProject)
|
||||
|
||||
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
|
||||
|
||||
// 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 taskBefore = tasks.value[e.newIndex - 1] ?? null
|
||||
const taskAfter = tasks.value[e.newIndex + 1] ?? null
|
||||
@@ -350,22 +371,11 @@ onBeforeUnmount(() => {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
:deep(.single-task) {
|
||||
.handle {
|
||||
opacity: 1;
|
||||
transition: opacity $transition;
|
||||
margin-inline-end: .25rem;
|
||||
cursor: grab;
|
||||
}
|
||||
:deep(.tasks:not(.dragging-disabled) .single-task) {
|
||||
cursor: grab;
|
||||
|
||||
@media(hover: hover) and (pointer: fine) {
|
||||
& .handle {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&:hover .handle {
|
||||
opacity: 1;
|
||||
}
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
'has-custom-background-color': color ?? undefined,
|
||||
}"
|
||||
:style="{'background-color': color ?? undefined}"
|
||||
:data-task-id="task.id"
|
||||
:data-project-id="task.projectId"
|
||||
@click.exact="openTaskDetail()"
|
||||
@click.ctrl="() => toggleTaskDone(task)"
|
||||
@click.meta="() => toggleTaskDone(task)"
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
:data-task-id="task.id"
|
||||
:data-project-id="task.projectId"
|
||||
>
|
||||
<div
|
||||
ref="taskRoot"
|
||||
: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…",
|
||||
"doneSuccess": "The task was successfully marked as done.",
|
||||
"undoneSuccess": "The task was successfully un-marked as done.",
|
||||
"movedToProject": "The task was moved to {project}.",
|
||||
"undo": "Undo",
|
||||
"openDetail": "Open task detail view",
|
||||
"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 isLoading = ref(false)
|
||||
const draggedTask = ref<ITask | null>(null)
|
||||
|
||||
const hasTasks = computed(() => Object.keys(tasks.value).length > 0)
|
||||
|
||||
@@ -120,6 +121,10 @@ export const useTaskStore = defineStore('task', () => {
|
||||
isLoading.value = newIsLoading
|
||||
}
|
||||
|
||||
function setDraggedTask(task: ITask | null) {
|
||||
draggedTask.value = task
|
||||
}
|
||||
|
||||
function setTasks(newTasks: ITask[]) {
|
||||
newTasks.forEach(task => {
|
||||
tasks.value[task.id] = task
|
||||
@@ -535,10 +540,12 @@ export const useTaskStore = defineStore('task', () => {
|
||||
return {
|
||||
tasks,
|
||||
isLoading,
|
||||
draggedTask,
|
||||
|
||||
hasTasks,
|
||||
|
||||
setTasks,
|
||||
setDraggedTask,
|
||||
loadTasks,
|
||||
update,
|
||||
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