feat(kanban): DnD + PATCH – flytta tasks mellan kolumner med optimistisk uppdatering
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
This commit is contained in:
@ -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
75
pnpm-lock.yaml
generated
@ -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
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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) {
|
||||
|
||||
12
src/features/tasks/components/DraggableTaskCard.tsx
Normal file
12
src/features/tasks/components/DraggableTaskCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
50
src/features/tasks/components/DroppableColumn.tsx
Normal file
50
src/features/tasks/components/DroppableColumn.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
47
src/features/tasks/mutations.ts
Normal file
47
src/features/tasks/mutations.ts
Normal 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
|
||||
},
|
||||
})
|
||||
}
|
||||
@ -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} />
|
||||
<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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user