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