feat(ST-1112): refactor task-list to open TaskDialog via URL params
Replaces inline create/edit forms with router.push navigation: - Clicking a task row → ?editTask=<id> - "+ Taak" button → ?newTask=1&storyId=<storyId> Removes CreateTaskForm, EditSubmitButton, updateTaskAction, and createTaskAction from the component. Status toggle and DnD remain unchanged. Rows now have cursor-pointer and keyboard a11y. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
59e214fc12
commit
45adaa2f76
1 changed files with 79 additions and 108 deletions
|
|
@ -1,7 +1,7 @@
|
|||
'use client'
|
||||
|
||||
import { useState, useTransition, useEffect, useActionState } from 'react'
|
||||
import { useFormStatus } from 'react-dom'
|
||||
import { useState, useTransition, useEffect } from 'react'
|
||||
import { useRouter, usePathname } from 'next/navigation'
|
||||
import {
|
||||
DndContext, DragEndEvent, DragOverlay,
|
||||
KeyboardSensor, PointerSensor, useSensor, useSensors, closestCenter,
|
||||
|
|
@ -13,17 +13,13 @@ import {
|
|||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { CodeBadge } from '@/components/shared/code-badge'
|
||||
import { PanelNavBar } from '@/components/shared/panel-nav-bar'
|
||||
import { PRIORITY_BORDER } from '@/components/backlog/backlog-card'
|
||||
import { deriveTaskCode } from '@/lib/code'
|
||||
import { useSprintStore } from '@/stores/sprint-store'
|
||||
import {
|
||||
createTaskAction, updateTaskStatusAction, updateTaskAction,
|
||||
deleteTaskAction, reorderTasksAction,
|
||||
} from '@/actions/tasks'
|
||||
import { updateTaskStatusAction, reorderTasksAction } from '@/actions/tasks'
|
||||
import { DemoTooltip } from '@/components/shared/demo-tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
|
|
@ -60,69 +56,69 @@ interface TaskListProps {
|
|||
}
|
||||
|
||||
function SortableTaskRow({
|
||||
task, code, isDemo, onStatusToggle, onDelete,
|
||||
}: { task: Task; code: string | null; isDemo: boolean; onStatusToggle: () => void; onDelete: () => void }) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
task, code, isDemo, onStatusToggle, onEdit,
|
||||
}: {
|
||||
task: Task
|
||||
code: string | null
|
||||
isDemo: boolean
|
||||
onStatusToggle: () => void
|
||||
onEdit: () => void
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: task.id })
|
||||
const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.4 : 1 }
|
||||
|
||||
const [, formAction] = useActionState(
|
||||
async (_prev: unknown, fd: FormData) => {
|
||||
const result = await updateTaskAction(_prev, fd)
|
||||
if (result?.success) setEditing(false)
|
||||
return result
|
||||
},
|
||||
undefined
|
||||
)
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} className="px-2 py-1">
|
||||
<div className={cn('rounded border border-border px-3 py-2 bg-surface-container', PRIORITY_BORDER[task.priority])}>
|
||||
<form action={formAction} className="space-y-2">
|
||||
<input type="hidden" name="id" value={task.id} />
|
||||
<input type="hidden" name="priority" value={task.priority} />
|
||||
<Input name="title" defaultValue={task.title} className="h-7 text-sm" required autoFocus />
|
||||
<div className="flex gap-2">
|
||||
<EditSubmitButton />
|
||||
<Button type="button" variant="ghost" size="sm" className="h-7" onClick={() => setEditing(false)}>Annuleren</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} className="group px-2 py-1">
|
||||
<div className={cn(
|
||||
'flex items-start gap-2 rounded border border-border px-3 py-2 transition-colors bg-surface-container hover:bg-surface-container-high',
|
||||
PRIORITY_BORDER[task.priority]
|
||||
)}>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-start gap-2 rounded border border-border px-3 py-2 transition-colors bg-surface-container hover:bg-surface-container-high cursor-pointer',
|
||||
PRIORITY_BORDER[task.priority],
|
||||
)}
|
||||
onClick={() => onEdit()}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`Bewerk taak: ${task.title}`}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onEdit()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{!isDemo && (
|
||||
<span {...attributes} {...listeners} className="text-muted-foreground cursor-grab active:cursor-grabbing shrink-0 text-sm select-none mt-0.5" aria-hidden="true">⠿</span>
|
||||
<span
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-muted-foreground cursor-grab active:cursor-grabbing shrink-0 text-sm select-none mt-0.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
⠿
|
||||
</span>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className={cn('text-sm leading-snug line-clamp-2 flex-1', task.status === 'DONE' && 'line-through text-muted-foreground')}>
|
||||
<p className={cn(
|
||||
'text-sm leading-snug line-clamp-2 flex-1',
|
||||
task.status === 'DONE' && 'line-through text-muted-foreground',
|
||||
)}>
|
||||
{task.title}
|
||||
</p>
|
||||
{code && <CodeBadge code={code} className="shrink-0 mt-0.5" />}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 mt-1.5">
|
||||
<button onClick={onStatusToggle} disabled={isDemo} aria-label={`Status: ${STATUS_LABELS[task.status]}`}>
|
||||
<Badge className={cn('text-[10px] px-1.5 py-0 border cursor-pointer hover:opacity-80 transition-opacity', STATUS_COLORS[task.status])}>
|
||||
<div className="mt-1.5">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onStatusToggle() }}
|
||||
disabled={isDemo}
|
||||
aria-label={`Status: ${STATUS_LABELS[task.status]}`}
|
||||
>
|
||||
<Badge className={cn(
|
||||
'text-[10px] px-1.5 py-0 border cursor-pointer hover:opacity-80 transition-opacity',
|
||||
STATUS_COLORS[task.status],
|
||||
)}>
|
||||
{STATUS_LABELS[task.status]}
|
||||
</Badge>
|
||||
</button>
|
||||
<div className="opacity-0 group-hover:opacity-100 flex gap-1 shrink-0">
|
||||
<DemoTooltip show={isDemo}>
|
||||
<button onClick={() => !isDemo && setEditing(true)} disabled={isDemo} className="text-xs text-muted-foreground hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed">Bewerk</button>
|
||||
</DemoTooltip>
|
||||
<DemoTooltip show={isDemo}>
|
||||
<button onClick={() => !isDemo && onDelete()} disabled={isDemo} aria-label="Verwijder taak" className="text-xs text-muted-foreground hover:text-error disabled:opacity-40 disabled:cursor-not-allowed">×</button>
|
||||
</DemoTooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -130,48 +126,12 @@ function SortableTaskRow({
|
|||
)
|
||||
}
|
||||
|
||||
function EditSubmitButton() {
|
||||
const { pending } = useFormStatus()
|
||||
return <Button type="submit" size="sm" className="h-7" disabled={pending}>{pending ? '…' : 'Opslaan'}</Button>
|
||||
}
|
||||
|
||||
function CreateTaskForm({ storyId, sprintId, onDone }: { storyId: string; sprintId: string; onDone: () => void }) {
|
||||
const [state, formAction] = useActionState(
|
||||
async (_prev: unknown, fd: FormData) => {
|
||||
const result = await createTaskAction(_prev, fd)
|
||||
if (result?.success) { onDone(); return result }
|
||||
if (result?.error) toast.error(typeof result.error === 'string' ? result.error : 'Aanmaken mislukt')
|
||||
return result
|
||||
},
|
||||
undefined
|
||||
)
|
||||
return (
|
||||
<form action={formAction} className="flex flex-col gap-1.5 px-4 py-2 border-b border-border">
|
||||
<input type="hidden" name="storyId" value={storyId} />
|
||||
<input type="hidden" name="sprintId" value={sprintId} />
|
||||
<input type="hidden" name="priority" value="2" />
|
||||
<div className="flex gap-2">
|
||||
<Input name="title" autoFocus placeholder="Taaknaam…" className="h-7 text-sm flex-1" required />
|
||||
<CreateSubmitButton />
|
||||
<Button type="button" variant="ghost" size="sm" className="h-7" aria-label="Annuleer" onClick={onDone}>×</Button>
|
||||
</div>
|
||||
{state && 'error' in state && typeof state.error === 'string' && (
|
||||
<p className="text-xs text-destructive">{state.error}</p>
|
||||
)}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateSubmitButton() {
|
||||
const { pending } = useFormStatus()
|
||||
return <Button type="submit" size="sm" className="h-7" disabled={pending}>{pending ? '…' : 'Toevoegen'}</Button>
|
||||
}
|
||||
|
||||
export function TaskList({ storyId, storyCode, sprintId, productId: _productId, tasks, isDemo }: TaskListProps) {
|
||||
export function TaskList({ storyId, storyCode, sprintId: _sprintId, productId: _productId, tasks, isDemo }: TaskListProps) {
|
||||
const { taskOrder, initTasks, reorderTasks, rollbackTasks } = useSprintStore()
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [activeDragId, setActiveDragId] = useState<string | null>(null)
|
||||
const [, startTransition] = useTransition()
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
|
||||
const idKey = tasks.map(t => t.id).join(',')
|
||||
useEffect(() => {
|
||||
|
|
@ -187,7 +147,7 @@ export function TaskList({ storyId, storyCode, sprintId, productId: _productId,
|
|||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
)
|
||||
|
||||
function handleDragEnd(event: DragEndEvent) {
|
||||
|
|
@ -209,11 +169,12 @@ export function TaskList({ storyId, storyCode, sprintId, productId: _productId,
|
|||
})
|
||||
}
|
||||
|
||||
function handleDelete(id: string) {
|
||||
startTransition(async () => {
|
||||
const result = await deleteTaskAction(id)
|
||||
if (result && 'error' in result) toast.error(result.error ?? 'Verwijderen mislukt')
|
||||
})
|
||||
function openCreateDialog() {
|
||||
router.push(`${pathname}?newTask=1&storyId=${storyId}`)
|
||||
}
|
||||
|
||||
function openEditDialog(taskId: string) {
|
||||
router.push(`${pathname}?editTask=${taskId}`)
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -224,22 +185,32 @@ export function TaskList({ storyId, storyCode, sprintId, productId: _productId,
|
|||
<>
|
||||
<span className="text-xs text-muted-foreground">{doneCount}/{orderedTasks.length} klaar</span>
|
||||
<DemoTooltip show={isDemo}>
|
||||
<Button size="sm" className="h-7 text-xs" disabled={isDemo} onClick={() => !isDemo && setCreating(true)}>+ Taak</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
disabled={isDemo}
|
||||
onClick={() => !isDemo && openCreateDialog()}
|
||||
>
|
||||
+ Taak
|
||||
</Button>
|
||||
</DemoTooltip>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{creating && (
|
||||
<CreateTaskForm storyId={storyId} sprintId={sprintId} onDone={() => setCreating(false)} />
|
||||
)}
|
||||
|
||||
{orderedTasks.length === 0 && !creating ? (
|
||||
{orderedTasks.length === 0 ? (
|
||||
<div className="text-center mt-8 space-y-3">
|
||||
<p className="text-sm text-muted-foreground">Geen taken voor deze story.</p>
|
||||
<DemoTooltip show={isDemo}>
|
||||
<Button size="sm" variant="outline" disabled={isDemo} onClick={() => !isDemo && setCreating(true)}>Maak eerste taak aan</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={isDemo}
|
||||
onClick={() => !isDemo && openCreateDialog()}
|
||||
>
|
||||
Maak eerste taak aan
|
||||
</Button>
|
||||
</DemoTooltip>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -258,7 +229,7 @@ export function TaskList({ storyId, storyCode, sprintId, productId: _productId,
|
|||
code={deriveTaskCode(storyCode, idx + 1)}
|
||||
isDemo={isDemo}
|
||||
onStatusToggle={() => handleStatusToggle(task)}
|
||||
onDelete={() => handleDelete(task.id)}
|
||||
onEdit={() => openEditDialog(task.id)}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
|
|
@ -266,7 +237,7 @@ export function TaskList({ storyId, storyCode, sprintId, productId: _productId,
|
|||
{activeDragId && taskMap[activeDragId] && (
|
||||
<div className={cn(
|
||||
'rounded border border-primary px-3 py-2 bg-surface-container shadow-lg opacity-90 text-sm',
|
||||
PRIORITY_BORDER[taskMap[activeDragId].priority]
|
||||
PRIORITY_BORDER[taskMap[activeDragId].priority],
|
||||
)}>
|
||||
{taskMap[activeDragId].title}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue