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:
kolaente
2025-12-10 19:59:38 +01:00
committed by GitHub
parent 6628acffce
commit 4aae270694
11 changed files with 620 additions and 32 deletions

View File

@@ -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

View File

@@ -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>

View File

@@ -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

View File

@@ -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;
}
}

View File

@@ -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)"

View File

@@ -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}"

View 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,
}
}

View File

@@ -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",

View File

@@ -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

View 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)
})
})
})

View 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(),
}
}
}