feat: add task deletion

This commit is contained in:
Urban Modig
2026-07-27 21:26:19 +02:00
parent 5dea4c4027
commit f296d15446
10 changed files with 1009 additions and 19 deletions

View File

@ -5,6 +5,7 @@ import App from './App'
const dragAndDrop = vi.hoisted(() => ({
onTaskDrop: null as ((taskId: string, status: string) => void) | null,
disabledTaskIds: new Set<string>(),
}))
vi.mock('./TaskDragAndDrop', () => ({
@ -18,10 +19,18 @@ vi.mock('./TaskDragAndDrop', () => ({
dragAndDrop.onTaskDrop = onTaskDrop
return children
},
useTaskDraggable: () => ({
ref: () => {},
isDragging: false,
}),
useTaskDraggable: (taskId: string, disabled: boolean) => {
if (disabled) {
dragAndDrop.disabledTaskIds.add(taskId)
} else {
dragAndDrop.disabledTaskIds.delete(taskId)
}
return {
ref: () => {},
isDragging: false,
}
},
useTaskColumnDropTarget: () => ({
ref: () => {},
isDropTarget: false,
@ -74,6 +83,7 @@ const tasks = [
beforeEach(() => {
window.localStorage.clear()
dragAndDrop.onTaskDrop = null
dragAndDrop.disabledTaskIds.clear()
})
afterEach(() => {
@ -540,6 +550,153 @@ test('statusfel behåller tidigare status och ansvarig och visas på kortet', as
expect(within(card).getByText('Ta uppgift')).toBeInTheDocument()
})
test('sopkorgsknappen öppnar delete-modal med Avbryt i fokus och utan delete-anrop', async () => {
window.localStorage.setItem('hemhub.activeUserId', users[0].id)
const fetchMock = mockUsersAndTasks(users, [tasks[0]])
render(<App />)
const deleteButton = await screen.findByRole('button', { name: 'Radera Dammsuga' })
expect(deleteButton.querySelector('svg')).toBeInTheDocument()
fireEvent.pointerDown(deleteButton)
fireEvent.click(deleteButton)
const dialog = screen.getByRole('dialog', { name: 'Radera uppgift?' })
expect(within(dialog).getByText('Dammsuga')).toBeInTheDocument()
expect(within(dialog).getByText(/raderas permanent och kan inte återställas/i))
.toBeInTheDocument()
expect(within(dialog).getByRole('button', { name: 'Avbryt' })).toHaveFocus()
expect(within(dialog).getByRole('button', { name: 'Radera' })).not.toHaveFocus()
expect(within(dialog).queryByRole('button', { name: 'Stäng' })).not.toBeInTheDocument()
expect(fetchMock).toHaveBeenCalledTimes(2)
})
test('delete-modal kan stängas med Avbryt, Escape och bakgrundsklick före anrop', async () => {
window.localStorage.setItem('hemhub.activeUserId', users[0].id)
const fetchMock = mockUsersAndTasks(users, [tasks[0]])
const { container } = render(<App />)
const deleteButton = await screen.findByRole('button', { name: 'Radera Dammsuga' })
fireEvent.click(deleteButton)
fireEvent.click(screen.getByRole('button', { name: 'Avbryt' }))
expect(screen.queryByRole('dialog', { name: 'Radera uppgift?' })).not.toBeInTheDocument()
fireEvent.click(deleteButton)
fireEvent.keyDown(window, { key: 'Escape' })
expect(screen.queryByRole('dialog', { name: 'Radera uppgift?' })).not.toBeInTheDocument()
fireEvent.click(deleteButton)
fireEvent.mouseDown(container.querySelector('.modal-backdrop')!)
expect(screen.queryByRole('dialog', { name: 'Radera uppgift?' })).not.toBeInTheDocument()
expect(fetchMock).toHaveBeenCalledTimes(2)
})
test('delete är serverbekräftad och låser bara det berörda kortet och modalen', async () => {
const otherTask = {
...tasks[0],
id: '00000000-0000-0000-0000-000000000010',
title: 'Putsa fönster',
}
let resolveDelete!: (response: Response) => void
const deleteResponse = new Promise<Response>((resolve) => {
resolveDelete = 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(deleteResponse)
const { container } = render(<App />)
const deleteButton = await screen.findByRole('button', { name: 'Radera Dammsuga' })
fireEvent.click(deleteButton)
const dialog = screen.getByRole('dialog', { name: 'Radera uppgift?' })
const confirm = within(dialog).getByRole('button', { name: 'Radera' })
fireEvent.click(confirm)
fireEvent.click(confirm)
const card = screen.getByRole('button', { name: 'Radera Dammsuga' }).closest('article')!
const otherCard = screen.getByText('Putsa fönster').closest('article')!
expect(card).toBeInTheDocument()
expect(card).toHaveAttribute('aria-busy', 'true')
expect(within(card).getByRole('button', { name: 'Radera Dammsuga' })).toBeDisabled()
expect(dragAndDrop.disabledTaskIds.has(tasks[0].id)).toBe(true)
expect(dragAndDrop.disabledTaskIds.has(otherTask.id)).toBe(false)
expect(within(card).getByRole('button', { name: 'Påbörja' })).toBeDisabled()
expect(
within(card).getByRole('button', { name: 'Ändra ansvarig för Dammsuga' }),
).toBeDisabled()
expect(within(otherCard).getByRole('button', { name: 'Påbörja' })).toBeEnabled()
expect(within(otherCard).getByRole('button', { name: 'Radera Putsa fönster' })).toBeEnabled()
expect(within(dialog).getByRole('button', { name: 'Avbryt' })).toBeDisabled()
expect(confirm).toBeDisabled()
expect(fetchMock).toHaveBeenLastCalledWith(`/api/tasks/${tasks[0].id}`, {
method: 'DELETE',
})
expect(fetchMock).toHaveBeenCalledTimes(3)
fireEvent.keyDown(window, { key: 'Escape' })
fireEvent.mouseDown(container.querySelector('.modal-backdrop')!)
expect(screen.getByRole('dialog', { name: 'Radera uppgift?' })).toBeInTheDocument()
await act(async () => resolveDelete(emptyResponse(204)))
expect(screen.queryByRole('button', { name: 'Radera Dammsuga' })).not.toBeInTheDocument()
expect(screen.queryByRole('dialog', { name: 'Radera uppgift?' })).not.toBeInTheDocument()
expect(screen.getByText('Putsa fönster')).toBeInTheDocument()
})
test('vanligt delete-fel behåller kort och dialog och kan återförsökas', async () => {
window.localStorage.setItem('hemhub.activeUserId', users[0].id)
const fetchMock = vi.spyOn(globalThis, 'fetch')
fetchMock.mockResolvedValueOnce(jsonResponse(users))
fetchMock.mockResolvedValueOnce(jsonResponse([tasks[0]]))
fetchMock.mockResolvedValueOnce(jsonResponse({ message: 'Serverfel' }, 500))
fetchMock.mockResolvedValueOnce(emptyResponse(204))
render(<App />)
fireEvent.click(await screen.findByRole('button', { name: 'Radera Dammsuga' }))
fireEvent.click(screen.getByRole('button', { name: 'Radera' }))
const dialog = await screen.findByRole('dialog', { name: 'Radera uppgift?' })
expect(await within(dialog).findByRole('alert')).toHaveTextContent(
'Det gick inte att radera uppgiften. Försök igen.',
)
expect(screen.getByRole('button', { name: 'Radera Dammsuga' })).toBeInTheDocument()
expect(within(dialog).getByRole('button', { name: 'Avbryt' })).toBeEnabled()
fireEvent.click(within(dialog).getByRole('button', { name: 'Radera' }))
await waitFor(() =>
expect(screen.queryByRole('button', { name: 'Radera Dammsuga' })).not.toBeInTheDocument(),
)
expect(fetchMock).toHaveBeenCalledTimes(4)
})
test('404 TASK_NOT_FOUND tar bort inaktuellt kort men andra 404-fel gör det inte', async () => {
window.localStorage.setItem('hemhub.activeUserId', users[0].id)
const fetchMock = vi.spyOn(globalThis, 'fetch')
fetchMock.mockResolvedValueOnce(jsonResponse(users))
fetchMock.mockResolvedValueOnce(jsonResponse([tasks[0]]))
fetchMock.mockResolvedValueOnce(
jsonResponse({ code: 'OTHER_NOT_FOUND', message: 'Annat fel' }, 404),
)
fetchMock.mockResolvedValueOnce(
jsonResponse({ code: 'TASK_NOT_FOUND', message: 'Uppgiften finns inte.' }, 404),
)
render(<App />)
fireEvent.click(await screen.findByRole('button', { name: 'Radera Dammsuga' }))
fireEvent.click(screen.getByRole('button', { name: 'Radera' }))
expect(await screen.findByRole('alert')).toHaveTextContent(
'Det gick inte att radera uppgiften. Försök igen.',
)
expect(screen.getByRole('button', { name: 'Radera Dammsuga' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Radera' }))
await waitFor(() =>
expect(screen.queryByRole('button', { name: 'Radera Dammsuga' })).not.toBeInTheDocument(),
)
expect(screen.queryByRole('dialog', { name: 'Radera uppgift?' })).not.toBeInTheDocument()
})
test('drag flyttar optimistiskt, låser kortet och använder hela serverresponsen', async () => {
const otherTask = {
...tasks[0],
@ -903,3 +1060,7 @@ function jsonResponse(body: unknown, status = 200) {
headers: { 'Content-Type': 'application/json' },
})
}
function emptyResponse(status: number) {
return new Response(null, { status })
}