Add task creation form.
This commit is contained in:
parent
a683a071d1
commit
f6e96f7f61
6 changed files with 286 additions and 16 deletions
|
|
@ -80,6 +80,8 @@ tasks (id, title, description, status, created_at, updated_at, completed_at)
|
||||||
- `just check` - Validate all changes (primary validation command)
|
- `just check` - Validate all changes (primary validation command)
|
||||||
- `just fmt` - Format code (resolve formatting errors)
|
- `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 ✅
|
## Current Phase: Core MVP Backend ✅
|
||||||
|
|
||||||
**Status**: Backend implementation completed
|
**Status**: Backend implementation completed
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,8 @@ import {
|
||||||
Toolbar,
|
Toolbar,
|
||||||
Typography,
|
Typography,
|
||||||
useTheme,
|
useTheme,
|
||||||
Fab,
|
|
||||||
LinearProgress,
|
LinearProgress,
|
||||||
|
Button,
|
||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
import {
|
import {
|
||||||
Menu as MenuIcon,
|
Menu as MenuIcon,
|
||||||
|
|
@ -22,6 +22,7 @@ import {
|
||||||
Add as AddIcon,
|
Add as AddIcon,
|
||||||
Settings as SettingsIcon,
|
Settings as SettingsIcon,
|
||||||
} from '@mui/icons-material'
|
} from '@mui/icons-material'
|
||||||
|
import { Link } from 'react-router'
|
||||||
|
|
||||||
const drawerWidth = 240
|
const drawerWidth = 240
|
||||||
|
|
||||||
|
|
@ -137,6 +138,28 @@ export default function Layout({ children, loading = false }: LayoutProps) {
|
||||||
>
|
>
|
||||||
⚓ Captain's Log
|
⚓ Captain's Log
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
component={Link}
|
||||||
|
to="/task/new"
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<AddIcon />}
|
||||||
|
sx={{
|
||||||
|
backgroundColor: theme.palette.secondary.main,
|
||||||
|
color: theme.palette.secondary.contrastText,
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: theme.palette.secondary.dark || '#b7791f',
|
||||||
|
},
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: '1rem',
|
||||||
|
px: 2,
|
||||||
|
py: 1.5,
|
||||||
|
minWidth: 'auto',
|
||||||
|
height: '44px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
New Task
|
||||||
|
</Button>
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
</AppBar>
|
</AppBar>
|
||||||
|
|
||||||
|
|
@ -198,19 +221,6 @@ export default function Layout({ children, loading = false }: LayoutProps) {
|
||||||
{children}
|
{children}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Quick capture FAB */}
|
|
||||||
<Fab
|
|
||||||
color="primary"
|
|
||||||
aria-label="add task"
|
|
||||||
sx={{
|
|
||||||
position: 'fixed',
|
|
||||||
bottom: 16,
|
|
||||||
right: 16,
|
|
||||||
zIndex: theme.zIndex.fab,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<AddIcon />
|
|
||||||
</Fab>
|
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
231
frontend/app/components/TaskForm.tsx
Normal file
231
frontend/app/components/TaskForm.tsx
Normal file
|
|
@ -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<FormData>({
|
||||||
|
title: task?.title || '',
|
||||||
|
description: task?.description || '',
|
||||||
|
status: task?.status || TaskStatus.Todo,
|
||||||
|
})
|
||||||
|
|
||||||
|
const [errors, setErrors] = useState<FormErrors>({})
|
||||||
|
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<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||||
|
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 = (
|
||||||
|
<Box component="form" onSubmit={handleSubmit} sx={{ width: '100%' }}>
|
||||||
|
<Typography variant="h4" component="h1" gutterBottom>
|
||||||
|
{task ? 'Edit Task' : 'Create New Task'}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 2 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Title"
|
||||||
|
value={formData.title}
|
||||||
|
onChange={handleInputChange('title')}
|
||||||
|
error={!!errors.title}
|
||||||
|
helperText={errors.title}
|
||||||
|
margin="normal"
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
disabled={isSubmitting || loading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Description"
|
||||||
|
value={formData.description}
|
||||||
|
onChange={handleInputChange('description')}
|
||||||
|
error={!!errors.description}
|
||||||
|
helperText={errors.description}
|
||||||
|
margin="normal"
|
||||||
|
multiline
|
||||||
|
rows={4}
|
||||||
|
disabled={isSubmitting || loading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{task && (
|
||||||
|
<FormControl fullWidth margin="normal">
|
||||||
|
<InputLabel>Status</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={formData.status}
|
||||||
|
onChange={handleStatusChange}
|
||||||
|
label="Status"
|
||||||
|
disabled={isSubmitting || loading}
|
||||||
|
>
|
||||||
|
<MenuItem value={TaskStatus.Todo}>Todo</MenuItem>
|
||||||
|
<MenuItem value={TaskStatus.Done}>Done</MenuItem>
|
||||||
|
<MenuItem value={TaskStatus.Backlog}>Backlog</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', gap: 2, mt: 3 }}>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
disabled={isSubmitting || loading}
|
||||||
|
sx={{ minWidth: 120 }}
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Saving...' : task ? 'Update' : 'Create'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outlined"
|
||||||
|
onClick={handleCancel}
|
||||||
|
disabled={isSubmitting || loading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (inline) {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper elevation={2} sx={{ p: 3, maxWidth: 600, mx: 'auto' }}>
|
||||||
|
{content}
|
||||||
|
</Paper>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -2,5 +2,6 @@ import { type RouteConfig, index, route } from '@react-router/dev/routes'
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
index('routes/home.tsx'),
|
index('routes/home.tsx'),
|
||||||
|
route('task/new', 'routes/task.new.tsx'),
|
||||||
route('*', 'routes/$.tsx'),
|
route('*', 'routes/$.tsx'),
|
||||||
] satisfies RouteConfig
|
] satisfies RouteConfig
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,20 @@
|
||||||
import { render, screen } from '@testing-library/react'
|
import { render, screen } from '@testing-library/react'
|
||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { MemoryRouter } from 'react-router'
|
||||||
import Home from './home'
|
import Home from './home'
|
||||||
|
|
||||||
describe('Home component', () => {
|
describe('Home component', () => {
|
||||||
it('should render task management interface', () => {
|
it('should render task management interface', () => {
|
||||||
const mockLoaderData = { tasks: [] }
|
const mockLoaderData = { tasks: [] }
|
||||||
render(<Home loaderData={mockLoaderData} />)
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<Home loaderData={mockLoaderData} />
|
||||||
|
</MemoryRouter>
|
||||||
|
)
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole('heading', { level: 1, name: /Tasks/i })
|
screen.getByRole('heading', { level: 1, name: /Tasks/i })
|
||||||
).toBeInTheDocument()
|
).toBeInTheDocument()
|
||||||
|
|
||||||
// TaskList component should be rendered with empty state
|
// TaskList component should be rendered with empty state
|
||||||
expect(screen.getByText(/No tasks found/i)).toBeInTheDocument()
|
expect(screen.getByText(/No tasks found/i)).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
|
||||||
20
frontend/app/routes/task.new.tsx
Normal file
20
frontend/app/routes/task.new.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<Container maxWidth="md">
|
||||||
|
<Box sx={{ py: 4 }}>
|
||||||
|
<TaskForm />
|
||||||
|
</Box>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue