From 7b60f4a074798bea31116e1a0e3db1c1cc8ea832 Mon Sep 17 00:00:00 2001 From: Urban Modig Date: Wed, 15 Oct 2025 15:15:57 +0200 Subject: [PATCH] =?UTF-8?q?feat(kanban):=20DnD=20+=20PATCH=20=E2=80=93=20f?= =?UTF-8?q?lytta=20tasks=20mellan=20kolumner=20med=20optimistisk=20uppdate?= =?UTF-8?q?ring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 4 + pnpm-lock.yaml | 75 +++++++++++++++++++ src/components/layout/RootLayout.tsx | 2 + src/features/tasks/api.ts | 30 ++++---- .../tasks/components/DraggableTaskCard.tsx | 12 +++ .../tasks/components/DroppableColumn.tsx | 50 +++++++++++++ src/features/tasks/components/TaskCard.tsx | 13 ++-- src/features/tasks/mutations.ts | 47 ++++++++++++ src/pages/ProjectBoardPage.tsx | 54 +++++++++---- 9 files changed, 255 insertions(+), 32 deletions(-) create mode 100644 src/features/tasks/components/DraggableTaskCard.tsx create mode 100644 src/features/tasks/components/DroppableColumn.tsx create mode 100644 src/features/tasks/mutations.ts diff --git a/package.json b/package.json index 9e951e8..ea6eb95 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 449db4c..0c77d8e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/components/layout/RootLayout.tsx b/src/components/layout/RootLayout.tsx index 0503792..769dd6a 100644 --- a/src/components/layout/RootLayout.tsx +++ b/src/components/layout/RootLayout.tsx @@ -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() {
+
) diff --git a/src/features/tasks/api.ts b/src/features/tasks/api.ts index f81f766..0086310 100644 --- a/src/features/tasks/api.ts +++ b/src/features/tasks/api.ts @@ -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) { +export function projectTasksKey( + projectId: string, + params?: Record +) { return ['projectTasks', projectId, params] as const } -export async function fetchProjectTasks(projectId: string, params?: { status?: TaskStatus }) { +type Page = { content: T[] } + +export async function fetchProjectTasks( + projectId: string, + params?: { status?: TaskStatus } +): Promise { 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() - - // Stöd både ren lista och Page - 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() + if (Array.isArray(raw)) return raw as Task[] + if (raw && typeof raw === 'object' && Array.isArray((raw as Page).content)) { + return (raw as Page).content + } + return [] } export function useProjectTasksQuery(projectId: string) { diff --git a/src/features/tasks/components/DraggableTaskCard.tsx b/src/features/tasks/components/DraggableTaskCard.tsx new file mode 100644 index 0000000..3bc4032 --- /dev/null +++ b/src/features/tasks/components/DraggableTaskCard.tsx @@ -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 ( +
+ +
+ ) +} diff --git a/src/features/tasks/components/DroppableColumn.tsx b/src/features/tasks/components/DroppableColumn.tsx new file mode 100644 index 0000000..eae0b6f --- /dev/null +++ b/src/features/tasks/components/DroppableColumn.tsx @@ -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 ( +
+
+

+ {title} +

+ {items.length} +
+ +
+ {empty ? ( +
+ Släpp här för att flytta till {title} +
+ ) : ( + items.map((t) => ) + )} +
+
+ ) +} diff --git a/src/features/tasks/components/TaskCard.tsx b/src/features/tasks/components/TaskCard.tsx index 8bdb562..440044f 100644 --- a/src/features/tasks/components/TaskCard.tsx +++ b/src/features/tasks/components/TaskCard.tsx @@ -2,10 +2,11 @@ import type { Task } from '@/types/task' export function TaskCard({ task }: { task: Task }) { return ( -

{task.title}

@@ -14,11 +15,13 @@ export function TaskCard({ task }: { task: Task }) { )}
{task.dueDate && ( -
Due: {new Date(task.dueDate).toLocaleDateString()}
+
+ Due: {new Date(task.dueDate).toLocaleDateString()} +
)} {task.assigneeName && (
Assignee: {task.assigneeName}
)} -
+ ) -} \ No newline at end of file +} diff --git a/src/features/tasks/mutations.ts b/src/features/tasks/mutations.ts new file mode 100644 index 0000000..cf5586b --- /dev/null +++ b/src/features/tasks/mutations.ts @@ -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(key) + if (Array.isArray(prev)) { + const next: Task[] = prev.map((t) => (t.id === taskId ? { ...t, status } : t)) + qc.setQueryData(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(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 + }, + }) +} diff --git a/src/pages/ProjectBoardPage.tsx b/src/pages/ProjectBoardPage.tsx index efd7584..7f03095 100644 --- a/src/pages/ProjectBoardPage.tsx +++ b/src/pages/ProjectBoardPage.tsx @@ -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

Laddar tasks…

if (isError) return

Något gick fel när tasks skulle hämtas.

@@ -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 ( -
- - - -
+ +
{/* håll 3 kolumner medan du testar */} + + + +
+
) -} \ No newline at end of file +}