From b0916990fbee8c7e5b4e27c65a3b25e6135531d2 Mon Sep 17 00:00:00 2001 From: Drew Galbraith Date: Tue, 23 Sep 2025 05:00:48 +0000 Subject: [PATCH] Add task creation form. (#9) Reviewed-on: https://git.tiramisu.one/drew/captains-log/pulls/9 Co-authored-by: Drew Galbraith Co-committed-by: Drew Galbraith --- CLAUDE.md | 4 +- frontend/app/components/Layout.tsx | 38 +++-- frontend/app/components/TaskForm.tsx | 231 +++++++++++++++++++++++++++ frontend/app/routes.ts | 1 + frontend/app/routes/home.test.tsx | 8 +- frontend/app/routes/task.new.tsx | 20 +++ 6 files changed, 286 insertions(+), 16 deletions(-) create mode 100644 frontend/app/components/TaskForm.tsx create mode 100644 frontend/app/routes/task.new.tsx diff --git a/CLAUDE.md b/CLAUDE.md index a84b622..7f3a193 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -80,6 +80,8 @@ tasks (id, title, description, status, created_at, updated_at, completed_at) - `just check` - Validate all changes (primary validation command) - `just fmt` - Format code (resolve formatting errors) +ALWAYS USE `just check` TO VALIDATE YOUR CHANGES INSTEAD OF LANGUAGE SPECIFIC COMMANDS. + ## Current Phase: Core MVP Backend ✅ **Status**: Backend implementation completed @@ -98,4 +100,4 @@ See `/plan/future_improvements.md` for architectural enhancement opportunities. 2. **Phase 3**: Project organization and hierarchical structure 3. **Phase 4**: Smart views and anti-overwhelm features 4. **Phase 5**: Recurring tasks and advanced scheduling -5. **Phase 6**: PWA features and offline capability \ No newline at end of file +5. **Phase 6**: PWA features and offline capability diff --git a/frontend/app/components/Layout.tsx b/frontend/app/components/Layout.tsx index 169e9f5..9117881 100644 --- a/frontend/app/components/Layout.tsx +++ b/frontend/app/components/Layout.tsx @@ -13,8 +13,8 @@ import { Toolbar, Typography, useTheme, - Fab, LinearProgress, + Button, } from '@mui/material' import { Menu as MenuIcon, @@ -22,6 +22,7 @@ import { Add as AddIcon, Settings as SettingsIcon, } from '@mui/icons-material' +import { Link } from 'react-router' const drawerWidth = 240 @@ -137,6 +138,28 @@ export default function Layout({ children, loading = false }: LayoutProps) { > ⚓ Captain's Log + + @@ -198,19 +221,6 @@ export default function Layout({ children, loading = false }: LayoutProps) { {children} - {/* Quick capture FAB */} - - - ) } diff --git a/frontend/app/components/TaskForm.tsx b/frontend/app/components/TaskForm.tsx new file mode 100644 index 0000000..8da2698 --- /dev/null +++ b/frontend/app/components/TaskForm.tsx @@ -0,0 +1,231 @@ +import { useState } from 'react' +import { + Box, + TextField, + Button, + FormControl, + InputLabel, + Select, + MenuItem, + Paper, + Typography, + Alert, +} from '@mui/material' +import { useNavigate } from 'react-router' +import type { CreateTaskRequest, Task } from '~/types/task' +import { TaskStatus } from '~/types/task' +import { useTasks } from '~/hooks/useTasks' + +interface TaskFormProps { + task?: Task + onSuccess?: (task: Task) => void + onCancel?: () => void + inline?: boolean +} + +interface FormData { + title: string + description: string + status: TaskStatus +} + +interface FormErrors { + title?: string + description?: string +} + +export function TaskForm({ + task, + onSuccess, + onCancel, + inline = false, +}: TaskFormProps) { + const navigate = useNavigate() + const { createTask, loading, error } = useTasks({ autoFetch: false }) + + const [formData, setFormData] = useState({ + title: task?.title || '', + description: task?.description || '', + status: task?.status || TaskStatus.Todo, + }) + + const [errors, setErrors] = useState({}) + const [isSubmitting, setIsSubmitting] = useState(false) + + const validateForm = (): boolean => { + const newErrors: FormErrors = {} + + if (!formData.title.trim()) { + newErrors.title = 'Title is required' + } else if (formData.title.trim().length < 3) { + newErrors.title = 'Title must be at least 3 characters long' + } else if (formData.title.trim().length > 200) { + newErrors.title = 'Title must be less than 200 characters' + } + + if (formData.description.length > 1000) { + newErrors.description = 'Description must be less than 1000 characters' + } + + setErrors(newErrors) + return Object.keys(newErrors).length === 0 + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + if (!validateForm()) { + return + } + + setIsSubmitting(true) + + try { + if (task) { + // Update existing task - for now we only support create + // This would use updateTask hook when implemented + console.log('Update not implemented yet') + } else { + // Create new task + const createData: CreateTaskRequest = { + title: formData.title.trim(), + description: formData.description.trim() || undefined, + } + + const newTask = await createTask(createData) + + if (newTask) { + if (onSuccess) { + onSuccess(newTask) + } else { + navigate('/') + } + } + } + } catch (err) { + console.error('Form submission error:', err) + } finally { + setIsSubmitting(false) + } + } + + const handleCancel = () => { + if (onCancel) { + onCancel() + } else { + navigate('/') + } + } + + const handleInputChange = + (field: keyof FormData) => + (e: React.ChangeEvent) => { + setFormData(prev => ({ + ...prev, + [field]: e.target.value, + })) + + // Clear error for this field when user starts typing + if (errors[field as keyof FormErrors]) { + setErrors(prev => ({ + ...prev, + [field]: undefined, + })) + } + } + + const handleStatusChange = ( + e: React.ChangeEvent<{ name?: string; value: unknown }> + ) => { + setFormData(prev => ({ + ...prev, + status: e.target.value as TaskStatus, + })) + } + + const content = ( + + + {task ? 'Edit Task' : 'Create New Task'} + + + {error && ( + + {error} + + )} + + + + + + {task && ( + + Status + + + )} + + + + + + + + ) + + if (inline) { + return content + } + + return ( + + {content} + + ) +} diff --git a/frontend/app/routes.ts b/frontend/app/routes.ts index c74f179..a60a27f 100644 --- a/frontend/app/routes.ts +++ b/frontend/app/routes.ts @@ -2,5 +2,6 @@ import { type RouteConfig, index, route } from '@react-router/dev/routes' export default [ index('routes/home.tsx'), + route('task/new', 'routes/task.new.tsx'), route('*', 'routes/$.tsx'), ] satisfies RouteConfig diff --git a/frontend/app/routes/home.test.tsx b/frontend/app/routes/home.test.tsx index e15502f..e5fd6ca 100644 --- a/frontend/app/routes/home.test.tsx +++ b/frontend/app/routes/home.test.tsx @@ -1,14 +1,20 @@ import { render, screen } from '@testing-library/react' import { describe, it, expect } from 'vitest' +import { MemoryRouter } from 'react-router' import Home from './home' describe('Home component', () => { it('should render task management interface', () => { const mockLoaderData = { tasks: [] } - render() + render( + + + + ) expect( screen.getByRole('heading', { level: 1, name: /Tasks/i }) ).toBeInTheDocument() + // TaskList component should be rendered with empty state expect(screen.getByText(/No tasks found/i)).toBeInTheDocument() }) diff --git a/frontend/app/routes/task.new.tsx b/frontend/app/routes/task.new.tsx new file mode 100644 index 0000000..2d15c4a --- /dev/null +++ b/frontend/app/routes/task.new.tsx @@ -0,0 +1,20 @@ +import type { Route } from './+types/task.new' +import { Container, Box } from '@mui/material' +import { TaskForm } from '~/components/TaskForm' + +export function meta(_: Route.MetaArgs) { + return [ + { title: "Captain's Log - New Task" }, + { name: 'description', content: 'Create a new task' }, + ] +} + +export default function NewTask() { + return ( + + + + + + ) +}