feat: add task deletion
This commit is contained in:
@ -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 })
|
||||
}
|
||||
|
||||
@ -24,6 +24,7 @@ type Task = {
|
||||
}
|
||||
|
||||
type ApiError = {
|
||||
code?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
@ -44,6 +45,8 @@ function TaskBoard({ activeUserId, activeUserName, users, onLogOut }: TaskBoardP
|
||||
const [tasks, setTasks] = useState<Task[]>([])
|
||||
const [loadState, setLoadState] = useState<'loading' | 'ready' | 'error'>('loading')
|
||||
const [showCreateTask, setShowCreateTask] = useState(false)
|
||||
const [deletingTask, setDeletingTask] = useState<Task | null>(null)
|
||||
const [deleteError, setDeleteError] = useState('')
|
||||
const [editingAssigneeTaskId, setEditingAssigneeTaskId] = useState<string | null>(null)
|
||||
const [pendingTaskIds, setPendingTaskIds] = useState<Set<string>>(new Set())
|
||||
const [taskErrors, setTaskErrors] = useState<Record<string, string>>({})
|
||||
@ -194,6 +197,55 @@ function TaskBoard({ activeUserId, activeUserName, users, onLogOut }: TaskBoardP
|
||||
void updateStatus(task, status, 'optimistic')
|
||||
}
|
||||
|
||||
const openDeleteTask = (task: Task) => {
|
||||
if (pendingTaskIdsRef.current.has(task.id)) {
|
||||
return
|
||||
}
|
||||
|
||||
setDeleteError('')
|
||||
setDeletingTask(task)
|
||||
}
|
||||
|
||||
const closeDeleteTask = () => {
|
||||
if (deletingTask && pendingTaskIdsRef.current.has(deletingTask.id)) {
|
||||
return
|
||||
}
|
||||
|
||||
setDeleteError('')
|
||||
setDeletingTask(null)
|
||||
}
|
||||
|
||||
const deleteTask = async (task: Task) => {
|
||||
if (!beginTaskRequest(task.id)) {
|
||||
return
|
||||
}
|
||||
|
||||
setDeleteError('')
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/tasks/${task.id}`, { method: 'DELETE' })
|
||||
|
||||
if (response.status === 204) {
|
||||
setTasks((current) => current.filter((candidate) => candidate.id !== task.id))
|
||||
setDeletingTask(null)
|
||||
return
|
||||
}
|
||||
|
||||
const apiError = (await response.json().catch(() => ({}))) as ApiError
|
||||
if (response.status === 404 && apiError.code === 'TASK_NOT_FOUND') {
|
||||
setTasks((current) => current.filter((candidate) => candidate.id !== task.id))
|
||||
setDeletingTask(null)
|
||||
return
|
||||
}
|
||||
|
||||
setDeleteError('Det gick inte att radera uppgiften. Försök igen.')
|
||||
} catch {
|
||||
setDeleteError('Det gick inte att radera uppgiften. Försök igen.')
|
||||
} finally {
|
||||
finishTaskRequest(task.id)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="task-app">
|
||||
<header className="app-header">
|
||||
@ -241,6 +293,7 @@ function TaskBoard({ activeUserId, activeUserName, users, onLogOut }: TaskBoardP
|
||||
onChangeStatus={(task, status) =>
|
||||
void updateStatus(task, status, 'server-confirmed')
|
||||
}
|
||||
onDelete={openDeleteTask}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
@ -256,6 +309,16 @@ function TaskBoard({ activeUserId, activeUserName, users, onLogOut }: TaskBoardP
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{deletingTask && (
|
||||
<DeleteTaskModal
|
||||
task={deletingTask}
|
||||
pending={pendingTaskIds.has(deletingTask.id)}
|
||||
error={deleteError}
|
||||
onClose={closeDeleteTask}
|
||||
onConfirm={() => void deleteTask(deletingTask)}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@ -270,6 +333,7 @@ type TaskColumnProps = {
|
||||
onEditAssignee: (taskId: string) => void
|
||||
onChangeAssignee: (task: Task, assigneeId: string) => void
|
||||
onChangeStatus: (task: Task, status: TaskStatus) => void
|
||||
onDelete: (task: Task) => void
|
||||
}
|
||||
|
||||
function TaskColumn({
|
||||
@ -282,6 +346,7 @@ function TaskColumn({
|
||||
onEditAssignee,
|
||||
onChangeAssignee,
|
||||
onChangeStatus,
|
||||
onDelete,
|
||||
}: TaskColumnProps) {
|
||||
const { ref, isDropTarget } = useTaskColumnDropTarget(column.status)
|
||||
|
||||
@ -304,6 +369,7 @@ function TaskColumn({
|
||||
onEditAssignee={() => onEditAssignee(task.id)}
|
||||
onChangeAssignee={(assigneeId) => onChangeAssignee(task, assigneeId)}
|
||||
onChangeStatus={(status) => onChangeStatus(task, status)}
|
||||
onDelete={() => onDelete(task)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@ -320,6 +386,7 @@ type TaskCardProps = {
|
||||
onEditAssignee: () => void
|
||||
onChangeAssignee: (assigneeId: string) => void
|
||||
onChangeStatus: (status: TaskStatus) => void
|
||||
onDelete: () => void
|
||||
}
|
||||
|
||||
function TaskCard({
|
||||
@ -331,6 +398,7 @@ function TaskCard({
|
||||
onEditAssignee,
|
||||
onChangeAssignee,
|
||||
onChangeStatus,
|
||||
onDelete,
|
||||
}: TaskCardProps) {
|
||||
const { ref, isDragging } = useTaskDraggable(task.id, pending)
|
||||
|
||||
@ -345,7 +413,19 @@ function TaskCard({
|
||||
>
|
||||
<div className="task-card-header">
|
||||
<h3>{task.title}</h3>
|
||||
<span className="points-badge">{task.points} p</span>
|
||||
<div className="task-card-actions">
|
||||
<span className="points-badge">{task.points} p</span>
|
||||
<button
|
||||
type="button"
|
||||
className="task-delete-button"
|
||||
aria-label={`Radera ${task.title}`}
|
||||
disabled={pending}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={onDelete}
|
||||
>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{task.description && <p>{task.description}</p>}
|
||||
<AssigneeControl
|
||||
@ -366,6 +446,103 @@ function TaskCard({
|
||||
)
|
||||
}
|
||||
|
||||
function TrashIcon() {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="19"
|
||||
height="19"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<path
|
||||
d="M4 7h16M9 7V4h6v3m-8 0 1 13h8l1-13M10 11v5m4-5v5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
type DeleteTaskModalProps = {
|
||||
task: Task
|
||||
pending: boolean
|
||||
error: string
|
||||
onClose: () => void
|
||||
onConfirm: () => void
|
||||
}
|
||||
|
||||
function DeleteTaskModal({
|
||||
task,
|
||||
pending,
|
||||
error,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: DeleteTaskModalProps) {
|
||||
useEffect(() => {
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && !pending) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', closeOnEscape)
|
||||
return () => window.removeEventListener('keydown', closeOnEscape)
|
||||
}, [onClose, pending])
|
||||
|
||||
const closeFromBackdrop = (event: MouseEvent<HTMLDivElement>) => {
|
||||
if (event.target === event.currentTarget && !pending) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" onMouseDown={closeFromBackdrop}>
|
||||
<section
|
||||
className="modal delete-task-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="delete-task-title"
|
||||
>
|
||||
<div className="modal-header">
|
||||
<h2 id="delete-task-title">Radera uppgift?</h2>
|
||||
</div>
|
||||
<p>
|
||||
Är du säker på att du vill radera <strong>{task.title}</strong>? Uppgiften
|
||||
raderas permanent och kan inte återställas.
|
||||
</p>
|
||||
{error && (
|
||||
<p className="error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div className="delete-task-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="secondary compact"
|
||||
autoFocus
|
||||
disabled={pending}
|
||||
onClick={onClose}
|
||||
>
|
||||
Avbryt
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="danger"
|
||||
disabled={pending}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
Radera
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type AssigneeControlProps = {
|
||||
task: Task
|
||||
users: UserSummary[]
|
||||
|
||||
@ -260,6 +260,34 @@ textarea {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.task-card-actions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.task-delete-button {
|
||||
display: inline-grid;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
padding: 0;
|
||||
place-items: center;
|
||||
color: #64748b;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.task-delete-button:hover,
|
||||
.task-delete-button:focus-visible {
|
||||
color: #991b1b;
|
||||
background: #fee2e2;
|
||||
}
|
||||
|
||||
.task-delete-button:focus-visible {
|
||||
outline: 2px solid #dc2626;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.points-badge {
|
||||
flex: 0 0 auto;
|
||||
padding: 0.2rem 0.5rem;
|
||||
@ -327,6 +355,29 @@ textarea {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.delete-task-modal p {
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.delete-task-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.delete-task-actions .secondary {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.danger {
|
||||
background: #b91c1c;
|
||||
}
|
||||
|
||||
.danger:hover,
|
||||
.danger:focus-visible {
|
||||
background: #991b1b;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
padding: 0.2rem 0.55rem;
|
||||
color: #475569;
|
||||
|
||||
Reference in New Issue
Block a user