feat: unify task creation with shared modal
This commit is contained in:
237
src/components/CreateTaskModal.tsx
Normal file
237
src/components/CreateTaskModal.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import api from '@/services/api'
|
||||
import type { Milestone, Project, Task } from '@/types'
|
||||
|
||||
const TASK_TYPES = [
|
||||
{ value: 'story', label: 'Story', subtypes: ['feature', 'improvement', 'refactor'] },
|
||||
{ value: 'issue', label: 'Issue', subtypes: ['infrastructure', 'performance', 'regression', 'security', 'user_experience', 'defect'] },
|
||||
{ value: 'task', label: 'Task', subtypes: ['defect'] },
|
||||
{ value: 'test', label: 'Test', subtypes: ['regression', 'security', 'smoke', 'stress'] },
|
||||
{ value: 'maintenance', label: 'Maintenance', subtypes: ['deploy', 'release'] },
|
||||
{ value: 'research', label: 'Research', subtypes: [] },
|
||||
{ value: 'review', label: 'Review', subtypes: ['code_review', 'decision_review', 'function_review'] },
|
||||
{ value: 'resolution', label: 'Resolution', subtypes: [] },
|
||||
]
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onCreated?: (task: Task) => void | Promise<void>
|
||||
initialProjectId?: number
|
||||
initialMilestoneId?: number
|
||||
lockProject?: boolean
|
||||
lockMilestone?: boolean
|
||||
}
|
||||
|
||||
const makeInitialForm = (projectId = 0, milestoneId = 0) => ({
|
||||
title: '',
|
||||
description: '',
|
||||
project_id: projectId,
|
||||
milestone_id: milestoneId,
|
||||
task_type: 'task',
|
||||
task_subtype: '',
|
||||
priority: 'medium',
|
||||
tags: '',
|
||||
reporter_id: 1,
|
||||
})
|
||||
|
||||
export default function CreateTaskModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onCreated,
|
||||
initialProjectId,
|
||||
initialMilestoneId,
|
||||
lockProject = false,
|
||||
lockMilestone = false,
|
||||
}: Props) {
|
||||
const [projects, setProjects] = useState<Project[]>([])
|
||||
const [milestones, setMilestones] = useState<Milestone[]>([])
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [form, setForm] = useState(makeInitialForm(initialProjectId, initialMilestoneId))
|
||||
|
||||
const currentType = useMemo(
|
||||
() => TASK_TYPES.find((t) => t.value === form.task_type) || TASK_TYPES[2],
|
||||
[form.task_type]
|
||||
)
|
||||
const subtypes = currentType.subtypes || []
|
||||
|
||||
const loadMilestones = async (projectId: number, preferredMilestoneId?: number) => {
|
||||
if (!projectId) {
|
||||
setMilestones([])
|
||||
setForm((f) => ({ ...f, milestone_id: 0 }))
|
||||
return
|
||||
}
|
||||
|
||||
const { data } = await api.get<Milestone[]>(`/milestones?project_id=${projectId}`)
|
||||
setMilestones(data)
|
||||
const hasPreferred = preferredMilestoneId && data.some((m) => m.id === preferredMilestoneId)
|
||||
const nextMilestoneId = hasPreferred ? preferredMilestoneId! : (data[0]?.id || 0)
|
||||
setForm((f) => ({ ...f, milestone_id: nextMilestoneId }))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
|
||||
const init = async () => {
|
||||
const { data } = await api.get<Project[]>('/projects')
|
||||
setProjects(data)
|
||||
|
||||
const chosenProjectId = initialProjectId || data[0]?.id || 0
|
||||
setForm(makeInitialForm(chosenProjectId, initialMilestoneId || 0))
|
||||
await loadMilestones(chosenProjectId, initialMilestoneId)
|
||||
}
|
||||
|
||||
init().catch(console.error)
|
||||
}, [isOpen, initialProjectId, initialMilestoneId])
|
||||
|
||||
const handleProjectChange = async (projectId: number) => {
|
||||
setForm((f) => ({ ...f, project_id: projectId, milestone_id: 0 }))
|
||||
await loadMilestones(projectId)
|
||||
}
|
||||
|
||||
const handleTypeChange = (taskType: string) => {
|
||||
setForm((f) => ({ ...f, task_type: taskType, task_subtype: '' }))
|
||||
}
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!form.milestone_id) {
|
||||
alert('Please select a milestone')
|
||||
return
|
||||
}
|
||||
|
||||
const payload: any = { ...form, tags: form.tags || null }
|
||||
if (!form.task_subtype) delete payload.task_subtype
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
const { data } = await api.post<Task>('/tasks', payload)
|
||||
await onCreated?.(data)
|
||||
onClose()
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal task-create-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3>Create Task</h3>
|
||||
<button type="button" className="btn-secondary" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<form className="task-create-form" onSubmit={submit}>
|
||||
<label>
|
||||
Title
|
||||
<input
|
||||
data-testid="task-title-input"
|
||||
required
|
||||
value={form.title}
|
||||
onChange={(e) => setForm({ ...form, title: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Description
|
||||
<textarea
|
||||
data-testid="task-description-input"
|
||||
value={form.description}
|
||||
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="task-create-grid">
|
||||
<label>
|
||||
Project
|
||||
<select
|
||||
data-testid="project-select"
|
||||
value={form.project_id}
|
||||
onChange={(e) => handleProjectChange(Number(e.target.value))}
|
||||
disabled={lockProject}
|
||||
>
|
||||
{projects.map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Milestone
|
||||
<select
|
||||
data-testid="milestone-select"
|
||||
value={form.milestone_id}
|
||||
onChange={(e) => setForm({ ...form, milestone_id: Number(e.target.value) })}
|
||||
disabled={lockMilestone}
|
||||
>
|
||||
{milestones.length === 0 && <option value={0}>No milestones available</option>}
|
||||
{milestones.map((m) => (
|
||||
<option key={m.id} value={m.id}>{m.title}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Type
|
||||
<select
|
||||
data-testid="task-type-select"
|
||||
value={form.task_type}
|
||||
onChange={(e) => handleTypeChange(e.target.value)}
|
||||
>
|
||||
{TASK_TYPES.map((t) => (
|
||||
<option key={t.value} value={t.value}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Priority
|
||||
<select
|
||||
value={form.priority}
|
||||
onChange={(e) => setForm({ ...form, priority: e.target.value })}
|
||||
>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
<option value="critical">Critical</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{subtypes.length > 0 && (
|
||||
<label>
|
||||
Subtype
|
||||
<select
|
||||
value={form.task_subtype}
|
||||
onChange={(e) => setForm({ ...form, task_subtype: e.target.value })}
|
||||
>
|
||||
<option value="">Select subtype</option>
|
||||
{subtypes.map((s) => (
|
||||
<option key={s} value={s}>{s.replace(/_/g, ' ')}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<label>
|
||||
Tags
|
||||
<input
|
||||
value={form.tags}
|
||||
onChange={(e) => setForm({ ...form, tags: e.target.value })}
|
||||
placeholder="Comma separated"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="modal-actions">
|
||||
<button data-testid="create-task-button" type="submit" className="btn-primary" disabled={saving}>
|
||||
{saving ? 'Creating...' : 'Create'}
|
||||
</button>
|
||||
<button type="button" className="btn-secondary" onClick={onClose} disabled={saving}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user