Files
Todo/components/ItemForm.tsx
Paul Bailey 796bde22e7 feat: Add comprehensive input validation
- Created lib/validation.ts with validation utilities for all form inputs
- Added validation for list titles, item titles, colors, dates, tags, descriptions, and notes
- Implemented inline validation with real-time error feedback in ListForm
- Enhanced ItemForm with comprehensive validation for all fields
- Updated InlineItemCreate with validation
- Added input sanitization to prevent XSS attacks
- Updated server actions to use validation utilities
- Added maxLength attributes to prevent excessive input
- Improved accessibility with aria-invalid and aria-describedby attributes
- Added visual feedback for validation errors with red borders
- Validates Requirements 1.1 and 2.1
2025-12-01 21:26:26 +00:00

458 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { updateItem, createItem } from '@/lib/actions'
import { useToast } from './ToastContainer'
import {
validateItemTitle,
validateDescription,
validateDateString,
validateTag,
validateTags,
validateNotes,
sanitizeString,
} from '@/lib/validation'
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 { showToast } = useToast()
const [isSubmitting, setIsSubmitting] = useState(false)
const [errors, setErrors] = useState<Record<string, string>>({})
const [touched, setTouched] = useState<Record<string, boolean>>({})
// 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 || '')
// Inline validation for title
useEffect(() => {
if (touched.title) {
const result = validateItemTitle(title)
setErrors((prev) => ({
...prev,
title: result.isValid ? '' : result.error || '',
}))
}
}, [title, touched.title])
// Inline validation for description
useEffect(() => {
if (touched.description) {
const result = validateDescription(description)
setErrors((prev) => ({
...prev,
description: result.isValid ? '' : result.error || '',
}))
}
}, [description, touched.description])
// Inline validation for due date
useEffect(() => {
if (touched.dueDate && dueDate) {
const result = validateDateString(dueDate)
setErrors((prev) => ({
...prev,
dueDate: result.isValid ? '' : result.error || '',
}))
} else if (touched.dueDate && !dueDate) {
setErrors((prev) => ({ ...prev, dueDate: '' }))
}
}, [dueDate, touched.dueDate])
// Inline validation for notes
useEffect(() => {
if (touched.notes) {
const result = validateNotes(notes)
setErrors((prev) => ({
...prev,
notes: result.isValid ? '' : result.error || '',
}))
}
}, [notes, touched.notes])
// Validate tags whenever they change
useEffect(() => {
const result = validateTags(tags)
setErrors((prev) => ({
...prev,
tags: result.isValid ? '' : result.error || '',
}))
}, [tags])
const handleBlur = (field: string) => {
setTouched((prev) => ({ ...prev, [field]: true }))
}
const validateForm = () => {
const newErrors: Record<string, string> = {}
// Validate title
const titleResult = validateItemTitle(title)
if (!titleResult.isValid) {
newErrors.title = titleResult.error || ''
}
// Validate description
const descResult = validateDescription(description)
if (!descResult.isValid) {
newErrors.description = descResult.error || ''
}
// Validate due date
if (dueDate) {
const dateResult = validateDateString(dueDate)
if (!dateResult.isValid) {
newErrors.dueDate = dateResult.error || ''
}
}
// Validate tags
const tagsResult = validateTags(tags)
if (!tagsResult.isValid) {
newErrors.tags = tagsResult.error || ''
}
// Validate notes
const notesResult = validateNotes(notes)
if (!notesResult.isValid) {
newErrors.notes = notesResult.error || ''
}
setErrors(newErrors)
setTouched({
title: true,
description: true,
dueDate: true,
notes: true,
})
return Object.keys(newErrors).length === 0
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!validateForm()) {
showToast('Please fix validation errors', 'error')
return
}
setIsSubmitting(true)
try {
const data = {
title: sanitizeString(title),
description: sanitizeString(description) || null,
priority,
dueDate: dueDate ? new Date(dueDate) : null,
tags: tags.map(sanitizeString),
notes: sanitizeString(notes) || null,
}
if (mode === 'create') {
await createItem(listId, data.title, data.description || undefined, {
priority: data.priority,
dueDate: data.dueDate,
tags: data.tags,
notes: data.notes,
})
showToast('Task created successfully', 'success')
router.push(`/list/${listId}`)
} else if (item) {
await updateItem(item.id, data)
showToast('Task updated successfully', 'success')
router.push(`/list/${listId}/item/${item.id}`)
}
} catch (error) {
console.error('Error saving item:', error)
const errorMessage = error instanceof Error ? error.message : 'Failed to save item. Please try again.'
setErrors({ submit: errorMessage })
showToast(errorMessage, 'error')
} finally {
setIsSubmitting(false)
}
}
const handleAddTag = () => {
const tag = sanitizeString(tagInput)
// Validate tag
const tagResult = validateTag(tag)
if (!tagResult.isValid) {
setErrors((prev) => ({ ...prev, tagInput: tagResult.error || '' }))
return
}
// Check for duplicates
if (tags.includes(tag)) {
setErrors((prev) => ({ ...prev, tagInput: 'Tag already exists' }))
return
}
// Check max tags
if (tags.length >= 20) {
setErrors((prev) => ({ ...prev, tagInput: 'Maximum 20 tags allowed' }))
return
}
setTags([...tags, tag])
setTagInput('')
setErrors((prev) => ({ ...prev, tagInput: '' }))
}
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-5 sm:space-y-6">
{/* Title */}
<div>
<label htmlFor="title" className="block text-sm font-medium text-gray-700 mb-2">
Title *
</label>
<input
type="text"
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
onBlur={() => handleBlur('title')}
className={`w-full px-3 sm:px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 text-sm sm:text-base ${
errors.title && touched.title
? 'border-red-500 focus:ring-red-500'
: 'border-gray-300 focus:ring-blue-500'
}`}
placeholder="Enter task title"
maxLength={200}
aria-invalid={errors.title && touched.title ? 'true' : 'false'}
aria-describedby={errors.title && touched.title ? 'title-error' : undefined}
/>
{errors.title && touched.title && (
<p id="title-error" className="mt-1.5 text-xs sm:text-sm text-red-600">
{errors.title}
</p>
)}
</div>
{/* Description */}
<div>
<label htmlFor="description" className="block text-sm font-medium text-gray-700 mb-2">
Description
</label>
<textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
onBlur={() => handleBlur('description')}
rows={3}
className={`w-full px-3 sm:px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 text-sm sm:text-base resize-y ${
errors.description && touched.description
? 'border-red-500 focus:ring-red-500'
: 'border-gray-300 focus:ring-blue-500'
}`}
placeholder="Add a description"
maxLength={2000}
aria-invalid={errors.description && touched.description ? 'true' : 'false'}
aria-describedby={errors.description && touched.description ? 'description-error' : undefined}
/>
{errors.description && touched.description && (
<p id="description-error" className="mt-1.5 text-xs sm:text-sm text-red-600">
{errors.description}
</p>
)}
</div>
{/* Priority */}
<div>
<label htmlFor="priority" className="block text-sm font-medium text-gray-700 mb-2">
Priority
</label>
<select
id="priority"
value={priority}
onChange={(e) => setPriority(e.target.value as 'low' | 'medium' | 'high')}
className="w-full px-3 sm:px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm sm:text-base tap-target"
>
<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-2">
Due Date
</label>
<input
type="date"
id="dueDate"
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
onBlur={() => handleBlur('dueDate')}
className={`w-full px-3 sm:px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 text-sm sm:text-base tap-target ${
errors.dueDate && touched.dueDate
? 'border-red-500 focus:ring-red-500'
: 'border-gray-300 focus:ring-blue-500'
}`}
min="1900-01-01"
max="2100-12-31"
aria-invalid={errors.dueDate && touched.dueDate ? 'true' : 'false'}
aria-describedby={errors.dueDate && touched.dueDate ? 'dueDate-error' : undefined}
/>
{errors.dueDate && touched.dueDate && (
<p id="dueDate-error" className="mt-1.5 text-xs sm:text-sm text-red-600">
{errors.dueDate}
</p>
)}
</div>
{/* Tags */}
<div>
<label htmlFor="tags" className="block text-sm font-medium text-gray-700 mb-2">
Tags {tags.length > 0 && <span className="text-gray-500">({tags.length}/20)</span>}
</label>
<div className="flex gap-2 mb-2 sm:mb-3">
<input
type="text"
id="tags"
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={handleKeyDown}
className={`flex-1 px-3 sm:px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 text-sm sm:text-base ${
errors.tagInput
? 'border-red-500 focus:ring-red-500'
: 'border-gray-300 focus:ring-blue-500'
}`}
placeholder="Add a tag"
maxLength={50}
aria-invalid={errors.tagInput ? 'true' : 'false'}
aria-describedby={errors.tagInput ? 'tagInput-error' : undefined}
/>
<button
type="button"
onClick={handleAddTag}
disabled={tags.length >= 20}
className="px-4 sm:px-5 py-3 bg-gray-200 hover:bg-gray-300 active:bg-gray-400 disabled:bg-gray-100 disabled:text-gray-400 text-gray-700 rounded-lg transition-colors tap-target text-sm sm:text-base font-medium"
>
Add
</button>
</div>
{errors.tagInput && (
<p id="tagInput-error" className="mb-2 text-xs sm:text-sm text-red-600">
{errors.tagInput}
</p>
)}
{errors.tags && (
<p className="mb-2 text-xs sm:text-sm text-red-600">{errors.tags}</p>
)}
{tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{tags.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-blue-100 text-blue-800 rounded-full text-xs sm:text-sm"
>
{tag}
<button
type="button"
onClick={() => handleRemoveTag(tag)}
className="hover:text-blue-600 active:text-blue-700 tap-target-sm text-lg leading-none"
aria-label={`Remove ${tag} tag`}
>
×
</button>
</span>
))}
</div>
)}
</div>
{/* Notes */}
<div>
<label htmlFor="notes" className="block text-sm font-medium text-gray-700 mb-2">
Notes
</label>
<textarea
id="notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
onBlur={() => handleBlur('notes')}
rows={5}
className={`w-full px-3 sm:px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 text-sm sm:text-base resize-y ${
errors.notes && touched.notes
? 'border-red-500 focus:ring-red-500'
: 'border-gray-300 focus:ring-blue-500'
}`}
placeholder="Add additional notes"
maxLength={5000}
aria-invalid={errors.notes && touched.notes ? 'true' : 'false'}
aria-describedby={errors.notes && touched.notes ? 'notes-error' : undefined}
/>
{errors.notes && touched.notes && (
<p id="notes-error" className="mt-1.5 text-xs sm:text-sm text-red-600">
{errors.notes}
</p>
)}
</div>
{/* Error message */}
{errors.submit && (
<div className="p-3 sm:p-4 bg-red-50 border border-red-200 rounded-lg">
<p className="text-xs sm:text-sm text-red-600">{errors.submit}</p>
</div>
)}
{/* Actions */}
<div className="flex flex-col-reverse sm:flex-row gap-2 sm:gap-3 pt-2">
<button
type="button"
onClick={() => router.back()}
className="w-full sm:w-auto px-4 sm:px-5 py-3 bg-gray-200 hover:bg-gray-300 active:bg-gray-400 text-gray-700 rounded-lg transition-colors tap-target font-medium text-sm sm:text-base"
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting}
className="w-full sm:flex-1 bg-blue-500 hover:bg-blue-600 active:bg-blue-700 disabled:bg-blue-300 text-white px-4 sm:px-5 py-3 rounded-lg transition-colors tap-target font-medium text-sm sm:text-base"
>
{isSubmitting ? 'Saving...' : mode === 'create' ? 'Create Task' : 'Save Changes'}
</button>
</div>
</form>
)
}