250 lines
6.7 KiB
TypeScript
250 lines
6.7 KiB
TypeScript
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
|