Files
Todo/components/ItemForm.tsx
Paul Bailey 54612a4782 feat: update components for multi-user support
- Add sharing indicator to ListCard showing collaborator count
- Add permission badge and share button to ListDetailView
- Disable editing in ItemForm for viewers with visual feedback
- Add permission checks to item detail and edit pages
- Hide edit/delete buttons for viewers on item detail page
- Pass permission and collaborator data through component hierarchy
- Update navigation to include profile and invitations links (already done in previous tasks)

Implements task 15 from user-auth-and-sharing spec
2025-12-02 01:14:21 +00:00

481 lines
16 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'
canEdit?: boolean
}
export default function ItemForm({ listId, item, mode = 'edit', canEdit = true }: 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 (!canEdit) {
showToast('You do not have permission to edit this item', 'error')
return
}
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">
{!canEdit && (
<div className="p-3 sm:p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<p className="text-xs sm:text-sm text-yellow-800">
You have view-only access to this list. You cannot edit items.
</p>
</div>
)}
{/* 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')}
disabled={!canEdit}
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'
} ${!canEdit ? 'bg-gray-100 cursor-not-allowed' : ''}`}
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')}
disabled={!canEdit}
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'
} ${!canEdit ? 'bg-gray-100 cursor-not-allowed' : ''}`}
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')}
disabled={!canEdit}
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 ${!canEdit ? 'bg-gray-100 cursor-not-allowed' : ''}`}
>
<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')}
disabled={!canEdit}
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'
} ${!canEdit ? 'bg-gray-100 cursor-not-allowed' : ''}`}
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}
disabled={!canEdit}
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'
} ${!canEdit ? 'bg-gray-100 cursor-not-allowed' : ''}`}
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 || !canEdit}
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)}
disabled={!canEdit}
className="hover:text-blue-600 active:text-blue-700 tap-target-sm text-lg leading-none disabled:opacity-50 disabled:cursor-not-allowed"
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')}
disabled={!canEdit}
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'
} ${!canEdit ? 'bg-gray-100 cursor-not-allowed' : ''}`}
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"
>
{canEdit ? 'Cancel' : 'Back'}
</button>
{canEdit && (
<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>
)
}