Add task creation form. (#9)
Reviewed-on: #9 Co-authored-by: Drew Galbraith <drew@tiramisu.one> Co-committed-by: Drew Galbraith <drew@tiramisu.one>
This commit is contained in:
parent
a683a071d1
commit
b0916990fb
6 changed files with 286 additions and 16 deletions
|
|
@ -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
|
||||
</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>
|
||||
</AppBar>
|
||||
|
||||
|
|
@ -198,19 +221,6 @@ export default function Layout({ children, loading = false }: LayoutProps) {
|
|||
{children}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue