feat: add task creation board
This commit is contained in:
249
frontend/src/TaskBoard.tsx
Normal file
249
frontend/src/TaskBoard.tsx
Normal file
@ -0,0 +1,249 @@
|
||||
import { FormEvent, MouseEvent, useEffect, useRef, useState } from 'react'
|
||||
|
||||
type TaskStatus = 'WAITING' | 'IN_PROGRESS' | 'COMPLETED'
|
||||
|
||||
type Task = {
|
||||
id: string
|
||||
title: string
|
||||
description: string | null
|
||||
status: TaskStatus
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
type ApiError = {
|
||||
message?: string
|
||||
}
|
||||
|
||||
type TaskBoardProps = {
|
||||
activeUserName: string
|
||||
onLogOut: () => void
|
||||
}
|
||||
|
||||
const columns: { status: TaskStatus; title: string }[] = [
|
||||
{ status: 'WAITING', title: 'Väntande' },
|
||||
{ status: 'IN_PROGRESS', title: 'Pågående' },
|
||||
{ status: 'COMPLETED', title: 'Klart' },
|
||||
]
|
||||
|
||||
function TaskBoard({ activeUserName, onLogOut }: TaskBoardProps) {
|
||||
const [tasks, setTasks] = useState<Task[]>([])
|
||||
const [loadState, setLoadState] = useState<'loading' | 'ready' | 'error'>('loading')
|
||||
const [showCreateTask, setShowCreateTask] = useState(false)
|
||||
|
||||
const loadTasks = async () => {
|
||||
setLoadState('loading')
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/tasks')
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Kunde inte hämta uppgifter')
|
||||
}
|
||||
|
||||
setTasks((await response.json()) as Task[])
|
||||
setLoadState('ready')
|
||||
} catch {
|
||||
setLoadState('error')
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadTasks()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<main className="task-app">
|
||||
<header className="app-header">
|
||||
<div>
|
||||
<p className="eyebrow">HemHub</p>
|
||||
<h1>Uppgifter</h1>
|
||||
</div>
|
||||
<div className="user-controls">
|
||||
<span>{activeUserName}</span>
|
||||
<button type="button" className="secondary compact" onClick={onLogOut}>
|
||||
Logga ut
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="board-toolbar">
|
||||
<div aria-live="polite">
|
||||
{loadState === 'loading' && 'Laddar uppgifter…'}
|
||||
{loadState === 'error' && (
|
||||
<>
|
||||
Kunde inte hämta uppgifter.
|
||||
<button type="button" className="link-button" onClick={() => void loadTasks()}>
|
||||
Försök igen
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<button type="button" onClick={() => setShowCreateTask(true)}>
|
||||
Ny uppgift
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section className="board" aria-label="Uppgiftsbräda">
|
||||
{columns.map((column) => (
|
||||
<section className="board-column" key={column.status} aria-labelledby={column.status}>
|
||||
<h2 id={column.status}>{column.title}</h2>
|
||||
<div className="task-list">
|
||||
{tasks
|
||||
.filter((task) => task.status === column.status)
|
||||
.map((task) => (
|
||||
<article className="task-card" key={task.id}>
|
||||
<h3>{task.title}</h3>
|
||||
{task.description && <p>{task.description}</p>}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</section>
|
||||
|
||||
{showCreateTask && (
|
||||
<CreateTaskModal
|
||||
onClose={() => setShowCreateTask(false)}
|
||||
onCreated={(task) => {
|
||||
setTasks((currentTasks) => [...currentTasks, task])
|
||||
setShowCreateTask(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
type CreateTaskModalProps = {
|
||||
onClose: () => void
|
||||
onCreated: (task: Task) => void
|
||||
}
|
||||
|
||||
function CreateTaskModal({ onClose, onCreated }: CreateTaskModalProps) {
|
||||
const [title, setTitle] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const isSubmittingRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && !isSubmittingRef.current) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', closeOnEscape)
|
||||
return () => window.removeEventListener('keydown', closeOnEscape)
|
||||
}, [onClose])
|
||||
|
||||
const closeFromBackdrop = (event: MouseEvent<HTMLDivElement>) => {
|
||||
if (event.target === event.currentTarget && !isSubmittingRef.current) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
const submit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
|
||||
if (isSubmittingRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const trimmedTitle = title.trim()
|
||||
const trimmedDescription = description.trim()
|
||||
|
||||
if (!trimmedTitle || [...trimmedTitle].length > 100) {
|
||||
setError('Titeln måste innehålla mellan 1 och 100 tecken.')
|
||||
return
|
||||
}
|
||||
|
||||
if ([...trimmedDescription].length > 500) {
|
||||
setError('Beskrivningen får innehålla högst 500 tecken.')
|
||||
return
|
||||
}
|
||||
|
||||
setError('')
|
||||
isSubmittingRef.current = true
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/tasks', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: trimmedTitle,
|
||||
description: trimmedDescription || null,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const apiError = (await response.json().catch(() => ({}))) as ApiError
|
||||
setError(apiError.message ?? 'Det gick inte att skapa uppgiften. Försök igen.')
|
||||
return
|
||||
}
|
||||
|
||||
onCreated((await response.json()) as Task)
|
||||
} catch {
|
||||
setError('Det gick inte att skapa uppgiften. Försök igen.')
|
||||
} finally {
|
||||
isSubmittingRef.current = false
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" onMouseDown={closeFromBackdrop}>
|
||||
<section
|
||||
className="modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="create-task-title"
|
||||
>
|
||||
<div className="modal-header">
|
||||
<h2 id="create-task-title">Skapa ny uppgift</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="close-button"
|
||||
aria-label="Stäng"
|
||||
disabled={isSubmitting}
|
||||
onClick={onClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={(event) => void submit(event)}>
|
||||
<label htmlFor="task-title">Titel</label>
|
||||
<input
|
||||
id="task-title"
|
||||
autoFocus
|
||||
value={title}
|
||||
disabled={isSubmitting}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
/>
|
||||
|
||||
<label htmlFor="task-description">Beskrivning (valfri)</label>
|
||||
<textarea
|
||||
id="task-description"
|
||||
rows={5}
|
||||
value={description}
|
||||
disabled={isSubmitting}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<p className="error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Skapar…' : 'Skapa uppgift'}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TaskBoard
|
||||
Reference in New Issue
Block a user