- Created ItemForm component with fields for all metadata (priority, due date, tags, notes) - Created ItemDetail component to display all item information - Built /list/[id]/item/[itemId] page for viewing full item details - Built /list/[id]/item/[itemId]/edit page for editing items - Added form validation for item inputs (title required, length limits) - Created DeleteItemButton and ToggleItemButton helper components - Enhanced createItem action to support full metadata on creation - Added getItem action to fetch individual items with list data Requirements: 1.1, 1.2, 5.1, 5.2, 5.3, 5.4
268 lines
7.9 KiB
TypeScript
268 lines
7.9 KiB
TypeScript
'use client'
|
||
|
||
import { useState } from 'react'
|
||
import { useRouter } from 'next/navigation'
|
||
import { updateItem, createItem } from '@/lib/actions'
|
||
|
||
interface ItemFormProps {
|
||
listId: string
|
||
item?: {
|
||
id: string
|
||
title: string
|
||
description: string | null
|
||
priority: string
|
||
dueDate: Date | null
|
||
tags: any
|
||
notes: string | null
|
||
}
|
||
mode?: 'create' | 'edit'
|
||
}
|
||
|
||
export default function ItemForm({ listId, item, mode = 'edit' }: ItemFormProps) {
|
||
const router = useRouter()
|
||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||
|
||
// Form state
|
||
const [title, setTitle] = useState(item?.title || '')
|
||
const [description, setDescription] = useState(item?.description || '')
|
||
const [priority, setPriority] = useState<'low' | 'medium' | 'high'>(
|
||
(item?.priority as 'low' | 'medium' | 'high') || 'medium'
|
||
)
|
||
const [dueDate, setDueDate] = useState(
|
||
item?.dueDate ? new Date(item.dueDate).toISOString().split('T')[0] : ''
|
||
)
|
||
const [tags, setTags] = useState<string[]>(
|
||
Array.isArray(item?.tags) ? item.tags : []
|
||
)
|
||
const [tagInput, setTagInput] = useState('')
|
||
const [notes, setNotes] = useState(item?.notes || '')
|
||
|
||
const validateForm = () => {
|
||
const newErrors: Record<string, string> = {}
|
||
|
||
if (!title.trim()) {
|
||
newErrors.title = 'Title is required'
|
||
}
|
||
|
||
if (title.length > 200) {
|
||
newErrors.title = 'Title must be less than 200 characters'
|
||
}
|
||
|
||
setErrors(newErrors)
|
||
return Object.keys(newErrors).length === 0
|
||
}
|
||
|
||
const handleSubmit = async (e: React.FormEvent) => {
|
||
e.preventDefault()
|
||
|
||
if (!validateForm()) {
|
||
return
|
||
}
|
||
|
||
setIsSubmitting(true)
|
||
|
||
try {
|
||
const data = {
|
||
title: title.trim(),
|
||
description: description.trim() || null,
|
||
priority,
|
||
dueDate: dueDate ? new Date(dueDate) : null,
|
||
tags,
|
||
notes: notes.trim() || null,
|
||
}
|
||
|
||
if (mode === 'create') {
|
||
await createItem(listId, data.title, data.description || undefined, {
|
||
priority: data.priority,
|
||
dueDate: data.dueDate,
|
||
tags: data.tags,
|
||
notes: data.notes,
|
||
})
|
||
router.push(`/list/${listId}`)
|
||
} else if (item) {
|
||
await updateItem(item.id, data)
|
||
router.push(`/list/${listId}/item/${item.id}`)
|
||
}
|
||
} catch (error) {
|
||
console.error('Error saving item:', error)
|
||
setErrors({ submit: 'Failed to save item. Please try again.' })
|
||
} finally {
|
||
setIsSubmitting(false)
|
||
}
|
||
}
|
||
|
||
const handleAddTag = () => {
|
||
const tag = tagInput.trim()
|
||
if (tag && !tags.includes(tag)) {
|
||
setTags([...tags, tag])
|
||
setTagInput('')
|
||
}
|
||
}
|
||
|
||
const handleRemoveTag = (tagToRemove: string) => {
|
||
setTags(tags.filter((tag) => tag !== tagToRemove))
|
||
}
|
||
|
||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault()
|
||
handleAddTag()
|
||
}
|
||
}
|
||
|
||
return (
|
||
<form onSubmit={handleSubmit} className="space-y-6">
|
||
{/* Title */}
|
||
<div>
|
||
<label htmlFor="title" className="block text-sm font-medium text-gray-700 mb-1">
|
||
Title *
|
||
</label>
|
||
<input
|
||
type="text"
|
||
id="title"
|
||
value={title}
|
||
onChange={(e) => setTitle(e.target.value)}
|
||
className={`w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||
errors.title ? 'border-red-500' : 'border-gray-300'
|
||
}`}
|
||
placeholder="Enter task title"
|
||
/>
|
||
{errors.title && (
|
||
<p className="mt-1 text-sm text-red-600">{errors.title}</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* Description */}
|
||
<div>
|
||
<label htmlFor="description" className="block text-sm font-medium text-gray-700 mb-1">
|
||
Description
|
||
</label>
|
||
<textarea
|
||
id="description"
|
||
value={description}
|
||
onChange={(e) => setDescription(e.target.value)}
|
||
rows={3}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
placeholder="Add a description"
|
||
/>
|
||
</div>
|
||
|
||
{/* Priority */}
|
||
<div>
|
||
<label htmlFor="priority" className="block text-sm font-medium text-gray-700 mb-1">
|
||
Priority
|
||
</label>
|
||
<select
|
||
id="priority"
|
||
value={priority}
|
||
onChange={(e) => setPriority(e.target.value as 'low' | 'medium' | 'high')}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
>
|
||
<option value="low">Low</option>
|
||
<option value="medium">Medium</option>
|
||
<option value="high">High</option>
|
||
</select>
|
||
</div>
|
||
|
||
{/* Due Date */}
|
||
<div>
|
||
<label htmlFor="dueDate" className="block text-sm font-medium text-gray-700 mb-1">
|
||
Due Date
|
||
</label>
|
||
<input
|
||
type="date"
|
||
id="dueDate"
|
||
value={dueDate}
|
||
onChange={(e) => setDueDate(e.target.value)}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
/>
|
||
</div>
|
||
|
||
{/* Tags */}
|
||
<div>
|
||
<label htmlFor="tags" className="block text-sm font-medium text-gray-700 mb-1">
|
||
Tags
|
||
</label>
|
||
<div className="flex gap-2 mb-2">
|
||
<input
|
||
type="text"
|
||
id="tags"
|
||
value={tagInput}
|
||
onChange={(e) => setTagInput(e.target.value)}
|
||
onKeyDown={handleKeyDown}
|
||
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
placeholder="Add a tag"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={handleAddTag}
|
||
className="px-4 py-2 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-lg transition-colors"
|
||
>
|
||
Add
|
||
</button>
|
||
</div>
|
||
{tags.length > 0 && (
|
||
<div className="flex flex-wrap gap-2">
|
||
{tags.map((tag) => (
|
||
<span
|
||
key={tag}
|
||
className="inline-flex items-center gap-1 px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm"
|
||
>
|
||
{tag}
|
||
<button
|
||
type="button"
|
||
onClick={() => handleRemoveTag(tag)}
|
||
className="hover:text-blue-600"
|
||
>
|
||
×
|
||
</button>
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Notes */}
|
||
<div>
|
||
<label htmlFor="notes" className="block text-sm font-medium text-gray-700 mb-1">
|
||
Notes
|
||
</label>
|
||
<textarea
|
||
id="notes"
|
||
value={notes}
|
||
onChange={(e) => setNotes(e.target.value)}
|
||
rows={5}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
placeholder="Add additional notes"
|
||
/>
|
||
</div>
|
||
|
||
{/* Error message */}
|
||
{errors.submit && (
|
||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg">
|
||
<p className="text-sm text-red-600">{errors.submit}</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* Actions */}
|
||
<div className="flex gap-3">
|
||
<button
|
||
type="submit"
|
||
disabled={isSubmitting}
|
||
className="flex-1 bg-blue-500 hover:bg-blue-600 disabled:bg-blue-300 text-white px-4 py-2 rounded-lg transition-colors"
|
||
>
|
||
{isSubmitting ? 'Saving...' : mode === 'create' ? 'Create Task' : 'Save Changes'}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => router.back()}
|
||
className="px-4 py-2 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-lg transition-colors"
|
||
>
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
</form>
|
||
)
|
||
}
|