feat(kanban): DnD + PATCH – flytta tasks mellan kolumner med optimistisk uppdatering
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
Urban Modig
2025-10-15 15:15:57 +02:00
parent 9645a2e5eb
commit 7b60f4a074
9 changed files with 255 additions and 32 deletions

View File

@ -13,6 +13,9 @@
"test:run": "vitest --run"
},
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@hookform/resolvers": "^5.2.2",
"@tanstack/react-query": "^5.90.2",
"class-variance-authority": "^0.7.1",
@ -24,6 +27,7 @@
"react-dom": "^19.1.1",
"react-hook-form": "^7.64.0",
"react-router-dom": "^7.9.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"zod": "^4.1.12",
"zustand": "^5.0.8"

75
pnpm-lock.yaml generated
View File

@ -8,6 +8,15 @@ importers:
.:
dependencies:
'@dnd-kit/accessibility':
specifier: ^3.1.1
version: 3.1.1(react@19.2.0)
'@dnd-kit/core':
specifier: ^6.3.1
version: 6.3.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@dnd-kit/sortable':
specifier: ^10.0.0
version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)
'@hookform/resolvers':
specifier: ^5.2.2
version: 5.2.2(react-hook-form@7.64.0(react@19.2.0))
@ -41,6 +50,9 @@ importers:
react-router-dom:
specifier: ^7.9.4
version: 7.9.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
sonner:
specifier: ^2.0.7
version: 2.0.7(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
tailwind-merge:
specifier: ^3.3.1
version: 3.3.1
@ -260,6 +272,28 @@ packages:
resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==}
engines: {node: '>=18'}
'@dnd-kit/accessibility@3.1.1':
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
peerDependencies:
react: '>=16.8.0'
'@dnd-kit/core@6.3.1':
resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
'@dnd-kit/sortable@10.0.0':
resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==}
peerDependencies:
'@dnd-kit/core': ^6.3.0
react: '>=16.8.0'
'@dnd-kit/utilities@3.2.2':
resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==}
peerDependencies:
react: '>=16.8.0'
'@esbuild/aix-ppc64@0.25.10':
resolution: {integrity: sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw==}
engines: {node: '>=18'}
@ -1655,6 +1689,12 @@ packages:
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
sonner@2.0.7:
resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==}
peerDependencies:
react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@ -1759,6 +1799,9 @@ packages:
typescript:
optional: true
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
@ -2115,6 +2158,31 @@ snapshots:
'@csstools/css-tokenizer@3.0.4': {}
'@dnd-kit/accessibility@3.1.1(react@19.2.0)':
dependencies:
react: 19.2.0
tslib: 2.8.1
'@dnd-kit/core@6.3.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
'@dnd-kit/accessibility': 3.1.1(react@19.2.0)
'@dnd-kit/utilities': 3.2.2(react@19.2.0)
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
tslib: 2.8.1
'@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)':
dependencies:
'@dnd-kit/core': 6.3.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@dnd-kit/utilities': 3.2.2(react@19.2.0)
react: 19.2.0
tslib: 2.8.1
'@dnd-kit/utilities@3.2.2(react@19.2.0)':
dependencies:
react: 19.2.0
tslib: 2.8.1
'@esbuild/aix-ppc64@0.25.10':
optional: true
@ -3395,6 +3463,11 @@ snapshots:
siginfo@2.0.0: {}
sonner@2.0.7(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
dependencies:
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
source-map-js@1.2.1: {}
stackback@0.0.2: {}
@ -3476,6 +3549,8 @@ snapshots:
optionalDependencies:
typescript: 5.9.3
tslib@2.8.1: {}
type-check@0.4.0:
dependencies:
prelude-ls: 1.2.1

View File

@ -2,6 +2,7 @@
import { Outlet, NavLink } from 'react-router-dom'
import { MeBadge } from '../../features/me/MeBadge'
import { useAuth } from '@/auth/useAuth'
import { Toaster } from 'sonner'
export function RootLayout() {
const { isAuthenticated, signIn } = useAuth()
@ -24,6 +25,7 @@ export function RootLayout() {
</header>
<main className="mx-auto max-w-6xl px-4 py-6">
<Outlet />
<Toaster richColors closeButton />
</main>
</div>
)

View File

@ -2,27 +2,29 @@ import { api } from '@/lib/http'
import { useQuery } from '@tanstack/react-query'
import type { Task, TaskStatus } from '@/types/task'
export function projectTasksKey(projectId: string, params?: Record<string, string | number | undefined>) {
export function projectTasksKey(
projectId: string,
params?: Record<string, string | number | undefined>
) {
return ['projectTasks', projectId, params] as const
}
export async function fetchProjectTasks(projectId: string, params?: { status?: TaskStatus }) {
type Page<T> = { content: T[] }
export async function fetchProjectTasks(
projectId: string,
params?: { status?: TaskStatus }
): Promise<Task[]> {
const search = new URLSearchParams()
if (params?.status) search.set('status', params.status)
const url = `api/v1/projects/${projectId}/tasks${search.size ? `?${search.toString()}` : ''}`
// Läs råsvaret och normalisera till Task[]
const raw = await api.get(url).json<any>()
// Stöd både ren lista och Page<T>
const list: unknown =
Array.isArray(raw) ? raw
: Array.isArray(raw?.content) ? raw.content
: Array.isArray(raw?.items) ? raw.items
: []
return list as Task[]
const raw = await api.get(url).json<unknown>()
if (Array.isArray(raw)) return raw as Task[]
if (raw && typeof raw === 'object' && Array.isArray((raw as Page<Task>).content)) {
return (raw as Page<Task>).content
}
return []
}
export function useProjectTasksQuery(projectId: string) {

View File

@ -0,0 +1,12 @@
import { useDraggable } from '@dnd-kit/core'
import type { Task } from '@/types/task'
import { TaskCard } from './TaskCard'
export function DraggableTaskCard({ task }: { task: Task }) {
const { attributes, listeners, setNodeRef } = useDraggable({ id: task.id })
return (
<div ref={setNodeRef} {...listeners} {...attributes}>
<TaskCard task={task} />
</div>
)
}

View File

@ -0,0 +1,50 @@
import { useDroppable } from '@dnd-kit/core'
import type { Task, TaskStatus } from '@/types/task'
import { DraggableTaskCard } from './DraggableTaskCard'
export function DroppableColumn({
title,
status,
items,
}: {
title: string
status: TaskStatus
items: Task[]
}) {
const { isOver, setNodeRef } = useDroppable({ id: status })
const empty = items.length === 0
return (
<section aria-labelledby={`col-${status}`} className="flex-1 min-w-[260px]">
<header className="mb-2 flex items-center justify-between">
<h3
id={`col-${status}`}
className="text-sm font-semibold uppercase tracking-wide opacity-70"
>
{title}
</h3>
<span className="text-xs opacity-60">{items.length}</span>
</header>
<div
ref={setNodeRef}
role="list"
aria-describedby={`col-${status}`}
className={[
'space-y-2 rounded-lg p-2 transition',
'min-h-[240px] border-2 border-dashed',
empty ? 'bg-gray-50 dark:bg-zinc-900/40' : 'bg-white/50 dark:bg-zinc-900/50',
isOver ? 'border-indigo-500 bg-indigo-50 dark:bg-indigo-950/30' : 'border-gray-200',
].join(' ')}
>
{empty ? (
<div className="text-xs text-gray-500 italic">
Släpp här för att flytta till {title}
</div>
) : (
items.map((t) => <DraggableTaskCard key={t.id} task={t} />)
)}
</div>
</section>
)
}

View File

@ -2,10 +2,11 @@ import type { Task } from '@/types/task'
export function TaskCard({ task }: { task: Task }) {
return (
<div
<article
role="listitem"
tabIndex={0}
className="rounded-xl border bg-white px-3 py-2 shadow-sm outline-offset-2 focus:outline focus:outline-2 focus:outline-indigo-500"
className="rounded-xl border bg-white dark:bg-zinc-800 px-3 py-2 shadow-sm
hover:shadow-md transition outline-offset-2 focus:outline focus:outline-2 focus:outline-indigo-500"
>
<div className="flex items-start justify-between gap-3">
<h4 className="text-sm font-medium leading-5">{task.title}</h4>
@ -14,11 +15,13 @@ export function TaskCard({ task }: { task: Task }) {
)}
</div>
{task.dueDate && (
<div className="mt-1 text-xs opacity-70">Due: {new Date(task.dueDate).toLocaleDateString()}</div>
<div className="mt-1 text-xs opacity-70">
Due: {new Date(task.dueDate).toLocaleDateString()}
</div>
)}
{task.assigneeName && (
<div className="mt-1 text-xs opacity-70">Assignee: {task.assigneeName}</div>
)}
</div>
</article>
)
}
}

View File

@ -0,0 +1,47 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from '@/lib/http'
import { projectTasksKey } from './api'
import type { Task, TaskStatus } from '@/types/task'
import { toast } from 'sonner'
async function patchTaskStatus(taskId: string, status: TaskStatus) {
await api.patch(`api/v1/tasks/${taskId}`, { json: { status } })
}
export function useUpdateTaskStatusMutation(projectId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ taskId, status }: { taskId: string; status: TaskStatus }) =>
patchTaskStatus(taskId, status),
onMutate: async ({ taskId, status }) => {
const key = projectTasksKey(projectId)
await qc.cancelQueries({ queryKey: key })
const prev = qc.getQueryData<Task[]>(key)
if (Array.isArray(prev)) {
const next: Task[] = prev.map((t) => (t.id === taskId ? { ...t, status } : t))
qc.setQueryData<Task[]>(key, next) // ← optimistisk flytt i UI
}
toast.loading('Uppdaterar task…', { id: `task-${taskId}` })
return { prev, taskId }
},
onError: (_err, _vars, ctx) => {
const key = projectTasksKey(projectId)
if (ctx?.prev) qc.setQueryData<Task[]>(key, ctx.prev) // rollback
if (ctx?.taskId)
toast.error('Kunde inte uppdatera ändring ångrad', { id: `task-${ctx.taskId}` })
},
onSuccess: (_data, vars) => {
toast.success('Task uppdaterad', { id: `task-${vars.taskId}` })
},
onSettled: () => {
const key = projectTasksKey(projectId)
qc.invalidateQueries({ queryKey: key }) // sync med server
},
})
}

View File

@ -1,20 +1,36 @@
import { useParams } from 'react-router-dom'
import { useProjectTasksQuery } from '@/features/tasks/api'
import type { Task } from '@/types/task'
import { KanbanColumn } from '@/features/tasks/components/KanbanColumn'
import type { Task, TaskStatus } from '@/types/task'
import {
DndContext,
type DragEndEvent,
KeyboardSensor,
PointerSensor,
closestCorners,
useSensor,
useSensors,
} from '@dnd-kit/core'
import { DroppableColumn } from '@/features/tasks/components/DroppableColumn'
import { useUpdateTaskStatusMutation } from '@/features/tasks/mutations'
function splitByStatus(items: Task[] | unknown) {
const arr = Array.isArray(items) ? items as Task[] : []
function splitByStatus(items: Task[]) {
return {
OPEN: arr.filter(t => t.status === 'OPEN'),
IN_PROGRESS: arr.filter(t => t.status === 'IN_PROGRESS'),
DONE: arr.filter(t => t.status === 'DONE'),
OPEN: items.filter((t) => t.status === 'OPEN'),
IN_PROGRESS: items.filter((t) => t.status === 'IN_PROGRESS'),
DONE: items.filter((t) => t.status === 'DONE'),
}
}
function isTaskStatus(x: unknown): x is TaskStatus {
return x === 'OPEN' || x === 'IN_PROGRESS' || x === 'DONE'
}
export function ProjectBoardPage() {
const { projectId = '' } = useParams()
const { data, isLoading, isError } = useProjectTasksQuery(projectId)
const { mutate } = useUpdateTaskStatusMutation(projectId)
const sensors = useSensors(useSensor(PointerSensor), useSensor(KeyboardSensor))
if (isLoading) return <p>Laddar tasks</p>
if (isError) return <p>Något gick fel när tasks skulle hämtas.</p>
@ -22,11 +38,23 @@ export function ProjectBoardPage() {
const tasks = data ?? []
const cols = splitByStatus(tasks)
const onDragEnd = (e: DragEndEvent) => {
console.log('onDragEnd', { active: e.active.id, over: e.over?.id })
const taskId = String(e.active.id)
const dest = e.over?.id
if (!dest || typeof dest !== 'string') return
const toStatus = dest.toUpperCase()
if (!isTaskStatus(toStatus)) return
mutate({ taskId, status: toStatus })
}
return (
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<KanbanColumn title="Open" status="OPEN" items={cols.OPEN ?? []} />
<KanbanColumn title="In progress" status="IN_PROGRESS" items={cols.IN_PROGRESS} />
<KanbanColumn title="Done" status="DONE" items={cols.DONE} />
</div>
<DndContext sensors={sensors} onDragEnd={onDragEnd} collisionDetection={closestCorners}>
<div className="grid grid-cols-3 gap-4"> {/* håll 3 kolumner medan du testar */}
<DroppableColumn title="Open" status="OPEN" items={cols.OPEN} />
<DroppableColumn title="In progress" status="IN_PROGRESS" items={cols.IN_PROGRESS} />
<DroppableColumn title="Done" status="DONE" items={cols.DONE} />
</div>
</DndContext>
)
}
}