feat(tasks): skapa/redigera task med RHF+zod, POST/PATCH, och query-invalidation
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:
@ -1,19 +1,41 @@
|
||||
// src/app/router.tsx
|
||||
import { createBrowserRouter, RouterProvider } from 'react-router-dom'
|
||||
import { Suspense, lazy } from 'react'
|
||||
import { RootLayout } from '@/components/layout/RootLayout'
|
||||
import { DashboardPage } from '@/pages/DashboardPage'
|
||||
import { HouseholdBoardPage } from '@/pages/HouseholdBoardPage'
|
||||
import { ProjectBoardPage } from '@/pages/ProjectBoardPage'
|
||||
import { DueTomorrowPage } from '@/pages/DueTomorrowPage'
|
||||
import AuthCallbackPage from '@/pages/AuthCallbackPage'
|
||||
import SilentRenewPage from '@/pages/SilentRenewPage'
|
||||
import LogoutPage from '@/pages/LogoutPage'
|
||||
import { RequireAuth } from '@/auth/RequireAuth'
|
||||
|
||||
// Hjälpare: mappa namngiven export → default för React.lazy
|
||||
const load = <T, K extends keyof T>(importer: () => Promise<T>, key: K) =>
|
||||
lazy(() => importer().then((m) => ({ default: m[key] as unknown as React.ComponentType })))
|
||||
|
||||
// Dina sidkomponenter (named exports i respektive fil)
|
||||
const DashboardPage = load(() => import('@/pages/DashboardPage'), 'DashboardPage')
|
||||
const HouseholdBoardPage = load(() => import('@/pages/HouseholdBoardPage'), 'HouseholdBoardPage')
|
||||
const ProjectBoardPage = load(() => import('@/pages/ProjectBoardPage'), 'ProjectBoardPage')
|
||||
const DueTomorrowPage = load(() => import('@/pages/DueTomorrowPage'), 'DueTomorrowPage')
|
||||
|
||||
// OIDC-sidor (named eller default – anpassa 'key' om din fil exporterar annat)
|
||||
const AuthCallbackPage = load(() => import('@/pages/AuthCallbackPage'), 'default') // export default i vår version
|
||||
const SilentRenewPage = load(() => import('@/pages/SilentRenewPage'), 'default') // om du har en sådan
|
||||
|
||||
function Fallback() {
|
||||
return <div className="p-4 text-sm opacity-70">Laddar…</div>
|
||||
}
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{ path: '/auth/callback', element: <AuthCallbackPage /> },
|
||||
{ path: '/auth/silent-renew', element: <SilentRenewPage /> },
|
||||
{ path: '/logout', element: <LogoutPage /> },
|
||||
// 🔓 Publika OIDC-rutter – måste vara o-skyddade
|
||||
{ path: '/auth/callback', element: (
|
||||
<Suspense fallback={<Fallback />}>
|
||||
<AuthCallbackPage />
|
||||
</Suspense>
|
||||
)
|
||||
},
|
||||
{ path: '/auth/silent-renew', element: (
|
||||
<Suspense fallback={<Fallback />}>
|
||||
<SilentRenewPage />
|
||||
</Suspense>
|
||||
)
|
||||
},
|
||||
// 🔒 Allt annat kräver inloggning
|
||||
{
|
||||
path: '/',
|
||||
element: (
|
||||
@ -22,17 +44,42 @@ const router = createBrowserRouter([
|
||||
</RequireAuth>
|
||||
),
|
||||
children: [
|
||||
{ index: true, element: <DashboardPage /> },
|
||||
{ path: 'households/:householdId/board', element: <HouseholdBoardPage /> },
|
||||
{ path: 'projects/:projectId/board', element: <ProjectBoardPage /> },
|
||||
{ path: 'tasks/due-tomorrow', element: <DueTomorrowPage /> },
|
||||
{
|
||||
index: true,
|
||||
element: (
|
||||
<Suspense fallback={<Fallback />}>
|
||||
<DashboardPage />
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'households/:householdId/board',
|
||||
element: (
|
||||
<Suspense fallback={<Fallback />}>
|
||||
<HouseholdBoardPage />
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'projects/:projectId/board',
|
||||
element: (
|
||||
<Suspense fallback={<Fallback />}>
|
||||
<ProjectBoardPage />
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'tasks/due-tomorrow',
|
||||
element: (
|
||||
<Suspense fallback={<Fallback />}>
|
||||
<DueTomorrowPage />
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
],{
|
||||
basename: (import.meta.env.BASE_URL ?? '/').replace(/\/$/, ''), // t.ex. "/hemhub"
|
||||
})
|
||||
])
|
||||
|
||||
export function AppRouter() {
|
||||
return <RouterProvider router={router} />
|
||||
}
|
||||
|
||||
|
||||
@ -1,19 +1,21 @@
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { useAuth } from './useAuth'
|
||||
|
||||
import { type PropsWithChildren, useEffect } from 'react'
|
||||
import { useAuth } from '@/auth/useAuth'
|
||||
|
||||
/**
|
||||
* Hårdare skydd: om ej inloggad → trigga signIn och rendera inget.
|
||||
* När auth är klar kommer användaren tillbaka via /auth/callback.
|
||||
*/
|
||||
export function RequireAuth({ children }: PropsWithChildren) {
|
||||
const { isAuthenticated, signIn } = useAuth()
|
||||
const loc = useLocation()
|
||||
const { isAuthenticated, signIn } = useAuth()
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
// Spara nuvarande URL så vi kan komma tillbaka efter login
|
||||
const returnTo = window.location.pathname + window.location.search
|
||||
void signIn(returnTo)
|
||||
}
|
||||
}, [isAuthenticated, signIn])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) void signIn(loc.pathname + loc.search)
|
||||
}, [isAuthenticated, signIn, loc])
|
||||
|
||||
|
||||
if (!isAuthenticated) return null
|
||||
return <>{children}</>
|
||||
if (!isAuthenticated) return null
|
||||
return <>{children}</>
|
||||
}
|
||||
@ -2,11 +2,17 @@ import { useDraggable } from '@dnd-kit/core'
|
||||
import type { Task } from '@/types/task'
|
||||
import { TaskCard } from './TaskCard'
|
||||
|
||||
export function DraggableTaskCard({ task }: { task: Task }) {
|
||||
export function DraggableTaskCard({
|
||||
task,
|
||||
onClick,
|
||||
}: {
|
||||
task: Task
|
||||
onClick?: (id: string) => void
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef } = useDraggable({ id: task.id })
|
||||
return (
|
||||
<div ref={setNodeRef} {...listeners} {...attributes}>
|
||||
<TaskCard task={task} />
|
||||
<TaskCard task={task} onClick={onClick} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -6,10 +6,12 @@ export function DroppableColumn({
|
||||
title,
|
||||
status,
|
||||
items,
|
||||
onCardClick,
|
||||
}: {
|
||||
title: string
|
||||
status: TaskStatus
|
||||
items: Task[]
|
||||
onCardClick?: (id: string) => void
|
||||
}) {
|
||||
const { isOver, setNodeRef } = useDroppable({ id: status })
|
||||
const empty = items.length === 0
|
||||
@ -42,7 +44,7 @@ export function DroppableColumn({
|
||||
Släpp här för att flytta till {title}
|
||||
</div>
|
||||
) : (
|
||||
items.map((t) => <DraggableTaskCard key={t.id} task={t} />)
|
||||
items.map((t) => <DraggableTaskCard key={t.id} task={t} onClick={onCardClick} />)
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -1,11 +1,17 @@
|
||||
import type { Task } from '@/types/task'
|
||||
|
||||
export function TaskCard({ task }: { task: Task }) {
|
||||
type Props = {
|
||||
task: Task
|
||||
onClick?: (taskId: string) => void
|
||||
}
|
||||
|
||||
export function TaskCard({ task, onClick }: Props) {
|
||||
return (
|
||||
<article
|
||||
<button
|
||||
type="button"
|
||||
role="listitem"
|
||||
tabIndex={0}
|
||||
className="rounded-xl border bg-white dark:bg-zinc-800 px-3 py-2 shadow-sm
|
||||
onClick={() => onClick?.(task.id)}
|
||||
className="w-full text-left 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">
|
||||
@ -22,6 +28,6 @@ export function TaskCard({ task }: { task: Task }) {
|
||||
{task.assigneeName && (
|
||||
<div className="mt-1 text-xs opacity-70">Assignee: {task.assigneeName}</div>
|
||||
)}
|
||||
</article>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
237
src/features/tasks/components/TaskFormModal.tsx
Normal file
237
src/features/tasks/components/TaskFormModal.tsx
Normal file
@ -0,0 +1,237 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import {
|
||||
TaskCreateSchema,
|
||||
TaskUpdateSchema,
|
||||
TaskPriorityEnum,
|
||||
type TaskCreateInput,
|
||||
type TaskUpdateInput,
|
||||
} from '@/features/tasks/schemas'
|
||||
|
||||
/** Skapa-variant */
|
||||
export function CreateTaskFormModal(props: {
|
||||
open: boolean
|
||||
onOpenChange: (v: boolean) => void
|
||||
defaultValues: Omit<TaskCreateInput, 'projectId'> & { projectId: string }
|
||||
onSubmit: (values: TaskCreateInput) => void
|
||||
}) {
|
||||
const { open, onOpenChange, defaultValues, onSubmit } = props
|
||||
|
||||
const form = useForm<TaskCreateInput>({
|
||||
resolver: zodResolver(TaskCreateSchema),
|
||||
defaultValues,
|
||||
mode: 'onChange',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
form.reset(defaultValues)
|
||||
}, [defaultValues, form])
|
||||
|
||||
const submit = form.handleSubmit((values) => {
|
||||
onSubmit({ ...values, projectId: defaultValues.projectId })
|
||||
onOpenChange(false)
|
||||
})
|
||||
|
||||
return (
|
||||
<DialogShell title="Ny task" open={open} onOpenChange={onOpenChange}>
|
||||
<form onSubmit={submit} className="px-4 py-3 space-y-3">
|
||||
<FieldText
|
||||
label="Titel"
|
||||
error={form.formState.errors.title?.message}
|
||||
{...form.register('title')}
|
||||
/>
|
||||
<FieldTextarea
|
||||
label="Beskrivning"
|
||||
error={form.formState.errors.description?.message}
|
||||
{...form.register('description', { setValueAs: (v) => (v === '' ? null : v) })}
|
||||
/>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<FieldDate
|
||||
label="Förfallodatum"
|
||||
error={form.formState.errors.dueDate?.message}
|
||||
{...form.register('dueDate', { setValueAs: (v) => (v === '' ? null : v) })}
|
||||
/>
|
||||
<FieldSelect
|
||||
label="Prioritet"
|
||||
error={form.formState.errors.priority?.message}
|
||||
{...form.register('priority', { setValueAs: (v) => (v === '' ? null : v) })}
|
||||
options={[
|
||||
{ value: '', label: '—' },
|
||||
...TaskPriorityEnum.options.map((p) => ({ value: p, label: p })),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<FooterButtons onCancel={() => onOpenChange(false)} submitDisabled={!form.formState.isValid} />
|
||||
</form>
|
||||
</DialogShell>
|
||||
)
|
||||
}
|
||||
|
||||
/** Redigera-variant */
|
||||
export function EditTaskFormModal(props: {
|
||||
open: boolean
|
||||
onOpenChange: (v: boolean) => void
|
||||
taskId: string
|
||||
defaultValues: TaskUpdateInput
|
||||
onSubmit: (values: TaskUpdateInput) => void
|
||||
}) {
|
||||
const { open, onOpenChange, defaultValues, onSubmit } = props
|
||||
|
||||
const form = useForm<TaskUpdateInput>({
|
||||
resolver: zodResolver(TaskUpdateSchema),
|
||||
defaultValues,
|
||||
mode: 'onChange',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
form.reset(defaultValues)
|
||||
}, [defaultValues, form])
|
||||
|
||||
const submit = form.handleSubmit((values) => {
|
||||
onSubmit(values)
|
||||
onOpenChange(false)
|
||||
})
|
||||
|
||||
return (
|
||||
<DialogShell title="Redigera task" open={open} onOpenChange={onOpenChange}>
|
||||
<form onSubmit={submit} className="px-4 py-3 space-y-3">
|
||||
<FieldText
|
||||
label="Titel"
|
||||
error={form.formState.errors.title?.message}
|
||||
{...form.register('title')}
|
||||
/>
|
||||
<FieldTextarea
|
||||
label="Beskrivning"
|
||||
error={form.formState.errors.description?.message}
|
||||
{...form.register('description', { setValueAs: (v) => (v === '' ? null : v) })}
|
||||
/>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<FieldDate
|
||||
label="Förfallodatum"
|
||||
error={form.formState.errors.dueDate?.message}
|
||||
{...form.register('dueDate', { setValueAs: (v) => (v === '' ? null : v) })}
|
||||
/>
|
||||
<FieldSelect
|
||||
label="Prioritet"
|
||||
error={form.formState.errors.priority?.message}
|
||||
{...form.register('priority', { setValueAs: (v) => (v === '' ? null : v) })}
|
||||
options={[
|
||||
{ value: '', label: '—' },
|
||||
...TaskPriorityEnum.options.map((p) => ({ value: p, label: p })),
|
||||
]}
|
||||
/>
|
||||
<FieldSelect
|
||||
label="Status"
|
||||
error={form.formState.errors.status?.message}
|
||||
{...form.register('status')}
|
||||
options={[
|
||||
{ value: 'OPEN', label: 'OPEN' },
|
||||
{ value: 'IN_PROGRESS', label: 'IN_PROGRESS' },
|
||||
{ value: 'DONE', label: 'DONE' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<FooterButtons onCancel={() => onOpenChange(false)} submitDisabled={!form.formState.isValid} />
|
||||
</form>
|
||||
</DialogShell>
|
||||
)
|
||||
}
|
||||
|
||||
/* ----------------- UI-helpers ----------------- */
|
||||
|
||||
function DialogShell({
|
||||
title,
|
||||
open,
|
||||
onOpenChange,
|
||||
children,
|
||||
}: {
|
||||
title: string
|
||||
open: boolean
|
||||
onOpenChange: (v: boolean) => void
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div role="dialog" aria-modal="true" aria-labelledby="task-modal-title" className={`fixed inset-0 z-50 ${open ? '' : 'hidden'}`}>
|
||||
<div className="absolute inset-0 bg-black/40" onClick={() => onOpenChange(false)} />
|
||||
<div className="absolute inset-0 grid place-items-center p-4">
|
||||
<div className="w-full max-w-lg rounded-2xl bg-white dark:bg-zinc-900 border shadow-xl">
|
||||
<header className="border-b px-4 py-3">
|
||||
<h2 id="task-modal-title" className="text-lg font-semibold">{title}</h2>
|
||||
</header>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type FieldProps = {
|
||||
label: string
|
||||
error?: string
|
||||
} & React.InputHTMLAttributes<HTMLInputElement>
|
||||
|
||||
function FieldText(props: FieldProps) {
|
||||
const { label, error, ...rest } = props
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">{label}</label>
|
||||
<input {...rest} className="w-full rounded-md border px-3 py-2" />
|
||||
{error && <p className="mt-1 text-xs text-red-600">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type TextareaProps = {
|
||||
label: string
|
||||
error?: string
|
||||
} & React.TextareaHTMLAttributes<HTMLTextAreaElement>
|
||||
|
||||
function FieldTextarea(props: TextareaProps) {
|
||||
const { label, error, ...rest } = props
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">{label}</label>
|
||||
<textarea {...rest} className="w-full rounded-md border px-3 py-2" />
|
||||
{error && <p className="mt-1 text-xs text-red-600">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldDate(props: FieldProps) {
|
||||
return <FieldText {...props} type="date" />
|
||||
}
|
||||
|
||||
type SelectProps = {
|
||||
label: string
|
||||
error?: string
|
||||
options: Array<{ value: string; label: string }>
|
||||
} & React.SelectHTMLAttributes<HTMLSelectElement>
|
||||
|
||||
function FieldSelect(props: SelectProps) {
|
||||
const { label, error, options, ...rest } = props
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">{label}</label>
|
||||
<select {...rest} className="w-full rounded-md border px-3 py-2">
|
||||
{options.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
{error && <p className="mt-1 text-xs text-red-600">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FooterButtons({ onCancel, submitDisabled }: { onCancel: () => void; submitDisabled: boolean }) {
|
||||
return (
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button type="button" className="rounded-md border px-3 py-2" onClick={onCancel}>
|
||||
Avbryt
|
||||
</button>
|
||||
<button type="submit" className="rounded-md bg-indigo-600 text-white px-3 py-2 disabled:opacity-50" disabled={submitDisabled}>
|
||||
Spara
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -2,46 +2,141 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { api } from '@/lib/http'
|
||||
import { projectTasksKey } from './api'
|
||||
import type { Task, TaskStatus } from '@/types/task'
|
||||
import type { TaskCreateInput, TaskUpdateInput } from './schemas'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
/* ---------- PATCH STATUS (DnD) ---------- */
|
||||
|
||||
async function patchTaskStatus(taskId: string, status: TaskStatus) {
|
||||
await api.patch(`api/v1/tasks/${taskId}`, { json: { status } })
|
||||
}
|
||||
|
||||
type UpdateStatusVars = { taskId: string; status: TaskStatus }
|
||||
type UpdateStatusCtx = { prev?: Task[]; key: readonly unknown[]; taskId: string }
|
||||
|
||||
export function useUpdateTaskStatusMutation(projectId: string) {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ taskId, status }: { taskId: string; status: TaskStatus }) =>
|
||||
patchTaskStatus(taskId, status),
|
||||
const key = projectTasksKey(projectId)
|
||||
|
||||
return useMutation<void, unknown, UpdateStatusVars, UpdateStatusCtx>({
|
||||
mutationFn: ({ taskId, status }) => 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
|
||||
qc.setQueryData<Task[]>(key, next)
|
||||
}
|
||||
|
||||
toast.loading('Uppdaterar task…', { id: `task-${taskId}` })
|
||||
return { prev, taskId }
|
||||
return { prev, key, 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}` })
|
||||
if (ctx?.prev) qc.setQueryData<Task[]>(ctx.key, ctx.prev)
|
||||
if (ctx?.taskId) toast.error('Kunde inte uppdatera – ändring ångrad', { id: `task-${ctx.taskId}` })
|
||||
},
|
||||
|
||||
onSuccess: (_data, vars) => {
|
||||
onSuccess: (_d, vars) => {
|
||||
toast.success('Task uppdaterad', { id: `task-${vars.taskId}` })
|
||||
},
|
||||
|
||||
onSettled: () => {
|
||||
const key = projectTasksKey(projectId)
|
||||
qc.invalidateQueries({ queryKey: key }) // sync med server
|
||||
qc.invalidateQueries({ queryKey: key })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/* ---------- CREATE (projektbunden) ---------- */
|
||||
|
||||
async function createProjectTask(input: TaskCreateInput): Promise<Task> {
|
||||
return api.post('api/v1/tasks', { json: input }).json<Task>()
|
||||
}
|
||||
|
||||
type CreateCtx = { prev?: Task[]; key: readonly unknown[]; tempId: string }
|
||||
|
||||
export function useCreateTaskMutation(projectId: string) {
|
||||
const qc = useQueryClient()
|
||||
const key = projectTasksKey(projectId)
|
||||
|
||||
return useMutation<Task, unknown, TaskCreateInput, CreateCtx>({
|
||||
mutationFn: (input) => createProjectTask(input),
|
||||
|
||||
onMutate: async (input) => {
|
||||
await qc.cancelQueries({ queryKey: key })
|
||||
const prev = qc.getQueryData<Task[]>(key)
|
||||
const tempId = `temp-${Date.now()}`
|
||||
if (Array.isArray(prev)) {
|
||||
const temp: Task = {
|
||||
id: tempId,
|
||||
title: input.title,
|
||||
description: input.description ?? null,
|
||||
status: 'OPEN',
|
||||
priority: input.priority ?? null,
|
||||
dueDate: input.dueDate ?? null,
|
||||
assigneeName: null,
|
||||
}
|
||||
qc.setQueryData<Task[]>(key, [temp, ...prev])
|
||||
}
|
||||
toast.loading('Skapar task…', { id: `create-${tempId}` })
|
||||
return { prev, key, tempId }
|
||||
},
|
||||
|
||||
onError: (_err, _vars, ctx) => {
|
||||
if (ctx?.prev) qc.setQueryData<Task[]>(ctx.key, ctx.prev)
|
||||
toast.error('Kunde inte skapa task', { id: `create-error` })
|
||||
},
|
||||
|
||||
onSuccess: (created, _vars, ctx) => {
|
||||
// Byt ev. ut temp om du vill; vi invaliderar ändå nedan.
|
||||
toast.success('Task skapad', { id: `create-${ctx?.tempId ?? created.id}` })
|
||||
},
|
||||
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: key })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/* ---------- EDIT (titel/beskriv/prio/due/status) ---------- */
|
||||
|
||||
async function updateTask(taskId: string, patch: TaskUpdateInput): Promise<void> {
|
||||
await api.patch(`api/v1/tasks/${taskId}`, { json: patch })
|
||||
}
|
||||
type EditVars = { taskId: string; patch: TaskUpdateInput }
|
||||
type EditCtx = { prev?: Task[]; key: readonly unknown[]; taskId: string }
|
||||
|
||||
export function useEditTaskMutation(projectId: string) {
|
||||
const qc = useQueryClient()
|
||||
const key = projectTasksKey(projectId)
|
||||
|
||||
return useMutation<void, unknown, EditVars, EditCtx>({
|
||||
mutationFn: ({ taskId, patch }) => updateTask(taskId, patch),
|
||||
|
||||
onMutate: async ({ taskId, patch }) => {
|
||||
await qc.cancelQueries({ queryKey: key })
|
||||
const prev = qc.getQueryData<Task[]>(key)
|
||||
if (Array.isArray(prev)) {
|
||||
const next = prev.map((t) => (t.id === taskId ? { ...t, ...patch } : t))
|
||||
qc.setQueryData<Task[]>(key, next)
|
||||
}
|
||||
toast.loading('Sparar ändringar…', { id: `edit-${taskId}` })
|
||||
return { prev, key, taskId }
|
||||
},
|
||||
|
||||
onError: (_err, _vars, ctx) => {
|
||||
if (ctx?.prev) qc.setQueryData<Task[]>(ctx.key, ctx.prev)
|
||||
toast.error('Kunde inte spara ändringar', { id: `edit-${ctx?.taskId ?? ''}` })
|
||||
},
|
||||
|
||||
onSuccess: (_d, { taskId }) => {
|
||||
toast.success('Ändringar sparade', { id: `edit-${taskId}` })
|
||||
},
|
||||
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: key })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
26
src/features/tasks/schemas.ts
Normal file
26
src/features/tasks/schemas.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
// Dela samma enum som i types/task.ts
|
||||
export const TaskStatusEnum = z.enum(['OPEN', 'IN_PROGRESS', 'DONE'])
|
||||
export const TaskPriorityEnum = z.enum(['LOW', 'MEDIUM', 'HIGH'])
|
||||
|
||||
export const TaskCreateSchema = z.object({
|
||||
title: z.string().min(1, 'Titel krävs').max(200, 'Max 200 tecken'),
|
||||
description: z.string().max(5000, 'Max 5000 tecken').optional().nullable(),
|
||||
dueDate: z.string().date('Ogiltigt datum').optional().or(z.literal('')).nullable(),
|
||||
priority: TaskPriorityEnum.optional().nullable(),
|
||||
// projektbunden task:
|
||||
projectId: z.string().min(1, 'projectId krävs'),
|
||||
})
|
||||
|
||||
export type TaskCreateInput = z.infer<typeof TaskCreateSchema>
|
||||
|
||||
export const TaskUpdateSchema = z.object({
|
||||
title: z.string().min(1).max(200),
|
||||
description: z.string().max(5000).optional().nullable(),
|
||||
dueDate: z.string().date().optional().or(z.literal('')).nullable(),
|
||||
priority: TaskPriorityEnum.optional().nullable(),
|
||||
status: TaskStatusEnum.optional(),
|
||||
})
|
||||
|
||||
export type TaskUpdateInput = z.infer<typeof TaskUpdateSchema>
|
||||
@ -1,4 +1,5 @@
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useProjectTasksQuery } from '@/features/tasks/api'
|
||||
import type { Task, TaskStatus } from '@/types/task'
|
||||
import {
|
||||
@ -11,7 +12,12 @@ import {
|
||||
useSensors,
|
||||
} from '@dnd-kit/core'
|
||||
import { DroppableColumn } from '@/features/tasks/components/DroppableColumn'
|
||||
import { useUpdateTaskStatusMutation } from '@/features/tasks/mutations'
|
||||
import {
|
||||
useUpdateTaskStatusMutation,
|
||||
useCreateTaskMutation,
|
||||
useEditTaskMutation,
|
||||
} from '@/features/tasks/mutations'
|
||||
import { CreateTaskFormModal, EditTaskFormModal } from '@/features/tasks/components/TaskFormModal'
|
||||
|
||||
function splitByStatus(items: Task[]) {
|
||||
return {
|
||||
@ -28,33 +34,98 @@ function isTaskStatus(x: unknown): x is TaskStatus {
|
||||
export function ProjectBoardPage() {
|
||||
const { projectId = '' } = useParams()
|
||||
const { data, isLoading, isError } = useProjectTasksQuery(projectId)
|
||||
const { mutate } = useUpdateTaskStatusMutation(projectId)
|
||||
const { mutate: mutateStatus } = useUpdateTaskStatusMutation(projectId)
|
||||
const createTask = useCreateTaskMutation(projectId)
|
||||
const editTask = useEditTaskMutation(projectId)
|
||||
|
||||
const sensors = useSensors(useSensor(PointerSensor), useSensor(KeyboardSensor))
|
||||
|
||||
// ✅ Memoisera tasks utifrån data
|
||||
const tasks = useMemo(() => data ?? [], [data])
|
||||
// ✅ Låt kolumner bero på tasks (inte data direkt)
|
||||
const cols = useMemo(() => splitByStatus(tasks), [tasks])
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [editOpen, setEditOpen] = useState(false)
|
||||
const [editTaskId, setEditTaskId] = useState<string | null>(null)
|
||||
const currentTask = useMemo(
|
||||
() => tasks.find((t) => t.id === editTaskId) ?? null,
|
||||
[tasks, editTaskId]
|
||||
)
|
||||
|
||||
if (isLoading) return <p>Laddar tasks…</p>
|
||||
if (isError) return <p>Något gick fel när tasks skulle hämtas.</p>
|
||||
|
||||
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 })
|
||||
mutateStatus({ taskId, status: toStatus })
|
||||
}
|
||||
|
||||
const openCreate = () => setCreateOpen(true)
|
||||
const openEdit = (taskId: string) => {
|
||||
setEditTaskId(taskId)
|
||||
setEditOpen(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold">Projektboard</h1>
|
||||
<button onClick={openCreate} className="rounded-md bg-indigo-600 text-white px-3 py-2">
|
||||
Ny task
|
||||
</button>
|
||||
</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 className="grid grid-cols-3 gap-4">
|
||||
<DroppableColumn title="Open" status="OPEN" items={cols.OPEN} onCardClick={openEdit} />
|
||||
<DroppableColumn
|
||||
title="In progress"
|
||||
status="IN_PROGRESS"
|
||||
items={cols.IN_PROGRESS}
|
||||
onCardClick={openEdit}
|
||||
/>
|
||||
<DroppableColumn title="Done" status="DONE" items={cols.DONE} onCardClick={openEdit} />
|
||||
</div>
|
||||
</DndContext>
|
||||
|
||||
{/* Create */}
|
||||
<CreateTaskFormModal
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
defaultValues={{
|
||||
title: '',
|
||||
description: null,
|
||||
dueDate: null,
|
||||
priority: null, // ✅ inte tom sträng
|
||||
projectId,
|
||||
}}
|
||||
onSubmit={(values) => createTask.mutate(values)}
|
||||
/>
|
||||
|
||||
{/* Edit */}
|
||||
{currentTask && (
|
||||
<EditTaskFormModal
|
||||
open={editOpen}
|
||||
onOpenChange={(v) => {
|
||||
if (!v) setEditTaskId(null)
|
||||
setEditOpen(v)
|
||||
}}
|
||||
taskId={currentTask.id}
|
||||
defaultValues={{
|
||||
title: currentTask.title,
|
||||
description: currentTask.description ?? null,
|
||||
dueDate: currentTask.dueDate ?? null,
|
||||
priority: currentTask.priority ?? null, // ✅ inte tom sträng
|
||||
status: currentTask.status,
|
||||
}}
|
||||
onSubmit={(patch) => editTask.mutate({ taskId: currentTask.id, patch })}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,15 +1,36 @@
|
||||
// vite.config.ts
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tsconfigPaths from 'vite-tsconfig-paths'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
export default defineConfig({
|
||||
base: process.env.VITE_BASE_PATH || '/', // sätts i builden
|
||||
plugins: [react(), tsconfigPaths()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
build: {
|
||||
// Valfritt: höj varningsgränsen lite (ex. 1024 kB)
|
||||
chunkSizeWarningLimit: 1024,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
// bryt ut större bibliotek i separata återanvändbara chunks
|
||||
react: ['react', 'react-dom'],
|
||||
router: ['react-router-dom'],
|
||||
query: ['@tanstack/react-query'],
|
||||
dnd: ['@dnd-kit/core', '@dnd-kit/sortable', '@dnd-kit/accessibility'],
|
||||
oidc: ['oidc-client-ts'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
// Snabbare dev-optimering
|
||||
optimizeDeps: {
|
||||
include: [
|
||||
'react',
|
||||
'react-dom',
|
||||
'react-router-dom',
|
||||
'@tanstack/react-query',
|
||||
'@dnd-kit/core',
|
||||
'@dnd-kit/sortable',
|
||||
'@dnd-kit/accessibility',
|
||||
'oidc-client-ts',
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user