feat: add task drag and drop

This commit is contained in:
Urban Modig
2026-07-27 13:15:39 +02:00
parent ddd706536e
commit c3c64482c0
12 changed files with 1155 additions and 63 deletions

View File

@ -1,7 +1,33 @@
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import type { ReactNode } from 'react'
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterEach, beforeEach, expect, test, vi } from 'vitest'
import App from './App'
const dragAndDrop = vi.hoisted(() => ({
onTaskDrop: null as ((taskId: string, status: string) => void) | null,
}))
vi.mock('./TaskDragAndDrop', () => ({
TaskDragDropProvider: ({
children,
onTaskDrop,
}: {
children: ReactNode
onTaskDrop: (taskId: string, status: string) => void
}) => {
dragAndDrop.onTaskDrop = onTaskDrop
return children
},
useTaskDraggable: () => ({
ref: () => {},
isDragging: false,
}),
useTaskColumnDropTarget: () => ({
ref: () => {},
isDropTarget: false,
}),
}))
const users = [
{
id: 'd56b54dd-31b0-4d71-8a10-82464be59a61',
@ -47,6 +73,7 @@ const tasks = [
beforeEach(() => {
window.localStorage.clear()
dragAndDrop.onTaskDrop = null
})
afterEach(() => {
@ -513,6 +540,174 @@ test('statusfel behåller tidigare status och ansvarig och visas på kortet', as
expect(within(card).getByText('Ta uppgift')).toBeInTheDocument()
})
test('drag flyttar optimistiskt, låser kortet och använder hela serverresponsen', async () => {
const otherTask = {
...tasks[0],
id: '00000000-0000-0000-0000-000000000010',
title: 'Putsa fönster',
}
const serverTask = {
...tasks[0],
status: 'COMPLETED',
assignee: { id: users[1].id, name: users[1].name },
}
let resolveStatus!: (response: Response) => void
const statusResponse = new Promise<Response>((resolve) => {
resolveStatus = resolve
})
window.localStorage.setItem('hemhub.activeUserId', users[0].id)
const fetchMock = vi.spyOn(globalThis, 'fetch')
fetchMock.mockResolvedValueOnce(jsonResponse(users))
fetchMock.mockResolvedValueOnce(jsonResponse([tasks[0], otherTask]))
fetchMock.mockReturnValueOnce(statusResponse)
render(<App />)
await screen.findByText('Dammsuga')
act(() => dropTask(tasks[0].id, 'IN_PROGRESS'))
const inProgress = screen.getByRole('region', { name: 'Pågående' })
const optimisticCard = (await within(inProgress).findByText('Dammsuga')).closest('article')!
const waiting = screen.getByRole('region', { name: 'Väntande' })
const otherCard = within(waiting).getByText('Putsa fönster').closest('article')!
expect(within(optimisticCard).getByText('Urban')).toBeInTheDocument()
expect(optimisticCard).toHaveAttribute('aria-busy', 'true')
expect(optimisticCard).toHaveClass('task-card-pending')
expect(within(optimisticCard).getByRole('button', { name: 'Markera klar' })).toBeDisabled()
expect(
within(optimisticCard).getByRole('button', { name: 'Ändra ansvarig för Dammsuga' }),
).toBeDisabled()
expect(within(otherCard).getByRole('button', { name: 'Påbörja' })).toBeEnabled()
expect(fetchMock).toHaveBeenLastCalledWith(`/api/tasks/${tasks[0].id}/status`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
status: 'IN_PROGRESS',
activeUserId: users[0].id,
}),
})
act(() => dropTask(tasks[0].id, 'COMPLETED'))
expect(fetchMock).toHaveBeenCalledTimes(3)
await act(async () => resolveStatus(jsonResponse(serverTask)))
const completed = screen.getByRole('region', { name: 'Klart' })
const confirmedCard = (await within(completed).findByText('Dammsuga')).closest('article')!
expect(within(confirmedCard).getByText('Anna')).toBeInTheDocument()
expect(confirmedCard).not.toHaveAttribute('aria-busy')
})
test('dragfel återställer hela uppgiften och visar lokalt fel', async () => {
let resolveStatus!: (response: Response) => void
const statusResponse = new Promise<Response>((resolve) => {
resolveStatus = resolve
})
window.localStorage.setItem('hemhub.activeUserId', users[0].id)
const fetchMock = vi.spyOn(globalThis, 'fetch')
fetchMock.mockResolvedValueOnce(jsonResponse(users))
fetchMock.mockResolvedValueOnce(jsonResponse([tasks[0]]))
fetchMock.mockReturnValueOnce(statusResponse)
render(<App />)
await screen.findByText('Dammsuga')
act(() => dropTask(tasks[0].id, 'IN_PROGRESS'))
const inProgress = screen.getByRole('region', { name: 'Pågående' })
expect(await within(inProgress).findByText('Urban')).toBeInTheDocument()
await act(async () =>
resolveStatus(
jsonResponse(
{
code: 'TASK_REQUIRES_ASSIGNEE',
message: 'En pågående uppgift måste ha en ansvarig.',
},
409,
),
),
)
const waiting = screen.getByRole('region', { name: 'Väntande' })
const restoredCard = (await within(waiting).findByText('Dammsuga')).closest('article')!
expect(within(restoredCard).getByText('Ta uppgift')).toBeInTheDocument()
expect(within(restoredCard).queryByText('Urban')).not.toBeInTheDocument()
expect(await within(restoredCard).findByRole('alert')).toHaveTextContent(
'En pågående uppgift måste ha en ansvarig.',
)
expect(restoredCard).not.toHaveAttribute('aria-busy')
})
test('drag till Pågående behåller en befintlig ansvarig optimistiskt', async () => {
const assignedTask = {
...tasks[0],
assignee: { id: users[1].id, name: users[1].name },
}
let resolveStatus!: (response: Response) => void
const statusResponse = new Promise<Response>((resolve) => {
resolveStatus = resolve
})
window.localStorage.setItem('hemhub.activeUserId', users[0].id)
const fetchMock = vi.spyOn(globalThis, 'fetch')
fetchMock.mockResolvedValueOnce(jsonResponse(users))
fetchMock.mockResolvedValueOnce(jsonResponse([assignedTask]))
fetchMock.mockReturnValueOnce(statusResponse)
render(<App />)
await screen.findByText('Dammsuga')
act(() => dropTask(assignedTask.id, 'IN_PROGRESS'))
const inProgress = screen.getByRole('region', { name: 'Pågående' })
expect(await within(inProgress).findByText('Anna')).toBeInTheDocument()
expect(within(inProgress).queryByText('Urban')).not.toBeInTheDocument()
await act(async () =>
resolveStatus(jsonResponse({ ...assignedTask, status: 'IN_PROGRESS' })),
)
})
test('drop i samma kolumn är no-op', async () => {
window.localStorage.setItem('hemhub.activeUserId', users[0].id)
const fetchMock = mockUsersAndTasks(users, [tasks[0]])
render(<App />)
await screen.findByText('Dammsuga')
act(() => dropTask(tasks[0].id, 'WAITING'))
expect(fetchMock).toHaveBeenCalledTimes(2)
expect(screen.getByText('Dammsuga').closest('article')).not.toHaveAttribute('aria-busy')
})
test('olika kort kan ha samtidiga optimistiska statusanrop', async () => {
const otherTask = {
...tasks[0],
id: '00000000-0000-0000-0000-000000000010',
title: 'Putsa fönster',
}
const firstResponse = new Promise<Response>(() => {})
const secondResponse = new Promise<Response>(() => {})
window.localStorage.setItem('hemhub.activeUserId', users[0].id)
const fetchMock = vi.spyOn(globalThis, 'fetch')
fetchMock.mockResolvedValueOnce(jsonResponse(users))
fetchMock.mockResolvedValueOnce(jsonResponse([tasks[0], otherTask]))
fetchMock.mockReturnValueOnce(firstResponse)
fetchMock.mockReturnValueOnce(secondResponse)
render(<App />)
await screen.findByText('Dammsuga')
act(() => {
dropTask(tasks[0].id, 'IN_PROGRESS')
dropTask(otherTask.id, 'COMPLETED')
})
expect(screen.getByText('Dammsuga').closest('article')).toHaveAttribute('aria-busy', 'true')
expect(screen.getByText('Putsa fönster').closest('article')).toHaveAttribute(
'aria-busy',
'true',
)
expect(fetchMock).toHaveBeenCalledTimes(4)
})
test('ansvarig kan bytas i Pågående och tas bort i Klart', async () => {
const changedInProgress = { ...tasks[1], assignee: { id: users[0].id, name: users[0].name } }
const unassignedCompleted = { ...tasks[2], assignee: null }
@ -690,6 +885,14 @@ function mockUsersAndTasks(userResponse: unknown, taskResponse: unknown) {
return fetchMock
}
function dropTask(taskId: string, status: string) {
if (!dragAndDrop.onTaskDrop) {
throw new Error('Drag-and-drop-providern är inte monterad')
}
dragAndDrop.onTaskDrop(taskId, status)
}
function mockJsonResponse(body: unknown, status = 200) {
return vi.spyOn(globalThis, 'fetch').mockResolvedValue(jsonResponse(body, status))
}

View File

@ -1,6 +1,10 @@
import { FormEvent, MouseEvent, useEffect, useRef, useState } from 'react'
type TaskStatus = 'WAITING' | 'IN_PROGRESS' | 'COMPLETED'
import {
TaskDragDropProvider,
TaskStatus,
useTaskColumnDropTarget,
useTaskDraggable,
} from './TaskDragAndDrop'
type UserSummary = {
id: string
@ -122,11 +126,27 @@ function TaskBoard({ activeUserId, activeUserName, users, onLogOut }: TaskBoardP
}
}
const updateStatus = async (task: Task, status: TaskStatus) => {
const updateStatus = async (
task: Task,
status: TaskStatus,
presentation: 'server-confirmed' | 'optimistic',
) => {
if (!beginTaskRequest(task.id)) {
return
}
const previousTask = task
if (presentation === 'optimistic') {
replaceTask({
...task,
status,
assignee:
status === 'IN_PROGRESS' && !task.assignee
? { id: activeUserId, name: activeUserName }
: task.assignee,
})
}
try {
const response = await fetch(`/api/tasks/${task.id}/status`, {
method: 'PUT',
@ -139,6 +159,9 @@ function TaskBoard({ activeUserId, activeUserName, users, onLogOut }: TaskBoardP
if (!response.ok) {
const apiError = (await response.json().catch(() => ({}))) as ApiError
if (presentation === 'optimistic') {
replaceTask(previousTask)
}
setTaskErrors((current) => ({
...current,
[task.id]: apiError.message ?? 'Det gick inte att ändra status. Försök igen.',
@ -149,6 +172,9 @@ function TaskBoard({ activeUserId, activeUserName, users, onLogOut }: TaskBoardP
replaceTask((await response.json()) as Task)
setEditingAssigneeTaskId(null)
} catch {
if (presentation === 'optimistic') {
replaceTask(previousTask)
}
setTaskErrors((current) => ({
...current,
[task.id]: 'Det gick inte att ändra status. Försök igen.',
@ -158,6 +184,16 @@ function TaskBoard({ activeUserId, activeUserName, users, onLogOut }: TaskBoardP
}
}
const dropTask = (taskId: string, status: TaskStatus) => {
const task = tasks.find((candidate) => candidate.id === taskId)
if (!task || task.status === status) {
return
}
void updateStatus(task, status, 'optimistic')
}
return (
<main className="task-app">
<header className="app-header">
@ -190,48 +226,25 @@ function TaskBoard({ activeUserId, activeUserName, users, onLogOut }: TaskBoardP
</button>
</div>
<section className="board" aria-label="Uppgiftsbräda">
{columns.map((column) => (
<section className="board-column" key={column.status} aria-labelledby={column.status}>
<h2 id={column.status}>{column.title}</h2>
<div className="task-list">
{tasks
.filter((task) => task.status === column.status)
.map((task) => {
const pending = pendingTaskIds.has(task.id)
return (
<article className="task-card" key={task.id}>
<div className="task-card-header">
<h3>{task.title}</h3>
<span className="points-badge">{task.points} p</span>
</div>
{task.description && <p>{task.description}</p>}
<AssigneeControl
task={task}
users={users}
editing={editingAssigneeTaskId === task.id}
pending={pending}
onEdit={() => setEditingAssigneeTaskId(task.id)}
onChange={(assigneeId) => void updateAssignee(task, assigneeId)}
/>
<TaskStatusControls
task={task}
disabled={pending}
onChange={(status) => void updateStatus(task, status)}
/>
{taskErrors[task.id] && (
<p className="task-error error" role="alert">
{taskErrors[task.id]}
</p>
)}
</article>
)
})}
</div>
</section>
))}
</section>
<TaskDragDropProvider onTaskDrop={dropTask}>
<section className="board" aria-label="Uppgiftsbräda">
{columns.map((column) => (
<TaskColumn
column={column}
tasks={tasks.filter((task) => task.status === column.status)}
users={users}
editingAssigneeTaskId={editingAssigneeTaskId}
pendingTaskIds={pendingTaskIds}
taskErrors={taskErrors}
onEditAssignee={setEditingAssigneeTaskId}
onChangeAssignee={(task, assigneeId) => void updateAssignee(task, assigneeId)}
onChangeStatus={(task, status) =>
void updateStatus(task, status, 'server-confirmed')
}
/>
))}
</section>
</TaskDragDropProvider>
{showCreateTask && (
<CreateTaskModal
@ -247,6 +260,112 @@ function TaskBoard({ activeUserId, activeUserName, users, onLogOut }: TaskBoardP
)
}
type TaskColumnProps = {
column: { status: TaskStatus; title: string }
tasks: Task[]
users: UserSummary[]
editingAssigneeTaskId: string | null
pendingTaskIds: Set<string>
taskErrors: Record<string, string>
onEditAssignee: (taskId: string) => void
onChangeAssignee: (task: Task, assigneeId: string) => void
onChangeStatus: (task: Task, status: TaskStatus) => void
}
function TaskColumn({
column,
tasks,
users,
editingAssigneeTaskId,
pendingTaskIds,
taskErrors,
onEditAssignee,
onChangeAssignee,
onChangeStatus,
}: TaskColumnProps) {
const { ref, isDropTarget } = useTaskColumnDropTarget(column.status)
return (
<section
ref={ref}
className={`board-column${isDropTarget ? ' board-column-drop-target' : ''}`}
aria-labelledby={column.status}
>
<h2 id={column.status}>{column.title}</h2>
<div className="task-list">
{tasks.map((task) => (
<TaskCard
key={task.id}
task={task}
users={users}
editingAssignee={editingAssigneeTaskId === task.id}
pending={pendingTaskIds.has(task.id)}
error={taskErrors[task.id]}
onEditAssignee={() => onEditAssignee(task.id)}
onChangeAssignee={(assigneeId) => onChangeAssignee(task, assigneeId)}
onChangeStatus={(status) => onChangeStatus(task, status)}
/>
))}
</div>
</section>
)
}
type TaskCardProps = {
task: Task
users: UserSummary[]
editingAssignee: boolean
pending: boolean
error?: string
onEditAssignee: () => void
onChangeAssignee: (assigneeId: string) => void
onChangeStatus: (status: TaskStatus) => void
}
function TaskCard({
task,
users,
editingAssignee,
pending,
error,
onEditAssignee,
onChangeAssignee,
onChangeStatus,
}: TaskCardProps) {
const { ref, isDragging } = useTaskDraggable(task.id, pending)
return (
<article
ref={ref}
role="article"
className={`task-card${pending ? ' task-card-pending' : ''}${
isDragging ? ' task-card-dragging' : ''
}`}
aria-busy={pending || undefined}
>
<div className="task-card-header">
<h3>{task.title}</h3>
<span className="points-badge">{task.points} p</span>
</div>
{task.description && <p>{task.description}</p>}
<AssigneeControl
task={task}
users={users}
editing={editingAssignee}
pending={pending}
onEdit={onEditAssignee}
onChange={onChangeAssignee}
/>
<TaskStatusControls task={task} disabled={pending} onChange={onChangeStatus} />
{error && (
<p className="task-error error" role="alert">
{error}
</p>
)}
</article>
)
}
type AssigneeControlProps = {
task: Task
users: UserSummary[]

View File

@ -0,0 +1,18 @@
import { expect, test } from 'vitest'
import { resolveTaskDrop } from './TaskDragAndDrop'
test.each(['WAITING', 'IN_PROGRESS', 'COMPLETED'] as const)(
'mappar målkolumnen %s till motsvarande status',
(status) => {
expect(resolveTaskDrop('task-1', status, false)).toEqual({
taskId: 'task-1',
targetStatus: status,
})
},
)
test('avbruten dragning och ogiltig målkolumn är no-op', () => {
expect(resolveTaskDrop('task-1', 'WAITING', true)).toBeNull()
expect(resolveTaskDrop('task-1', undefined, false)).toBeNull()
expect(resolveTaskDrop('task-1', 'UNKNOWN', false)).toBeNull()
})

View File

@ -0,0 +1,90 @@
import { ReactNode } from 'react'
import { DragDropProvider, useDraggable, useDroppable } from '@dnd-kit/react'
import { PointerActivationConstraints, PointerSensor } from '@dnd-kit/dom'
export type TaskStatus = 'WAITING' | 'IN_PROGRESS' | 'COMPLETED'
type TaskDragDropProviderProps = {
children: ReactNode
onTaskDrop: (taskId: string, targetStatus: TaskStatus) => void
}
const taskStatuses = new Set<TaskStatus>(['WAITING', 'IN_PROGRESS', 'COMPLETED'])
const pointerSensor = PointerSensor.configure({
activationConstraints(event) {
if (event.pointerType === 'touch') {
return [new PointerActivationConstraints.Delay({ value: 250, tolerance: 8 })]
}
return [new PointerActivationConstraints.Distance({ value: 6 })]
},
})
export function resolveTaskDrop(
sourceId: string | number | undefined,
targetId: string | number | undefined,
canceled: boolean,
) {
if (
canceled ||
sourceId === undefined ||
typeof targetId !== 'string' ||
!taskStatuses.has(targetId as TaskStatus)
) {
return null
}
return {
taskId: String(sourceId),
targetStatus: targetId as TaskStatus,
}
}
export function TaskDragDropProvider({
children,
onTaskDrop,
}: TaskDragDropProviderProps) {
return (
<DragDropProvider
sensors={(defaults) => [
...defaults.filter((sensor) => sensor !== PointerSensor),
pointerSensor,
]}
onDragEnd={(event) => {
const drop = resolveTaskDrop(
event.operation.source?.id,
event.operation.target?.id,
event.canceled,
)
if (!drop) {
return
}
onTaskDrop(drop.taskId, drop.targetStatus)
}}
>
{children}
</DragDropProvider>
)
}
export function useTaskDraggable(taskId: string, disabled: boolean) {
const { ref, isDragging } = useDraggable({
id: taskId,
type: 'task',
disabled,
})
return { ref, isDragging }
}
export function useTaskColumnDropTarget(status: TaskStatus) {
const { ref, isDropTarget } = useDroppable({
id: status,
accept: 'task',
})
return { ref, isDropTarget }
}

View File

@ -158,8 +158,15 @@ textarea {
.board-column {
min-height: 20rem;
padding: 1rem;
border: 1px solid transparent;
border-radius: 0.75rem;
background: #e5e7eb;
transition: border-color 120ms ease, background-color 120ms ease;
}
.board-column-drop-target {
border-color: #93c5fd;
background: #e0e7ff;
}
.board-column h2 {
@ -180,6 +187,14 @@ textarea {
box-shadow: 0 0.125rem 0.4rem rgb(0 0 0 / 8%);
}
.task-card-pending {
opacity: 0.65;
}
.task-card-dragging {
cursor: grabbing;
}
.task-card h3,
.task-card p {
margin: 0;

View File

@ -1,5 +1,15 @@
import '@testing-library/jest-dom/vitest'
class ResizeObserverStub implements ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
globalThis.ResizeObserver = ResizeObserverStub
const storedValues = new Map<string, string>()
Object.defineProperty(window, 'localStorage', {