271 lines
8.7 KiB
TypeScript
271 lines
8.7 KiB
TypeScript
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: ['improvement', 'refactor'] }, // P9.6: 'feature' removed — must come from propose accept
|
|
{ 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'] }, // P9.6: 'release' removed — controlled via milestone flow
|
|
{ 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>
|
|
onSaved?: (task: Task) => void | Promise<void>
|
|
initialProjectId?: number
|
|
initialMilestoneId?: number
|
|
lockProject?: boolean
|
|
lockMilestone?: boolean
|
|
task?: Task | null
|
|
}
|
|
|
|
type FormState = {
|
|
title: string
|
|
description: string
|
|
project_id: number
|
|
milestone_id: number
|
|
task_type: string
|
|
task_subtype: string
|
|
priority: string
|
|
tags: string
|
|
reporter_id: number
|
|
}
|
|
|
|
const makeInitialForm = (projectId = 0, milestoneId = 0): FormState => ({
|
|
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,
|
|
onSaved,
|
|
initialProjectId,
|
|
initialMilestoneId,
|
|
lockProject = false,
|
|
lockMilestone = false,
|
|
task,
|
|
}: Props) {
|
|
const [projects, setProjects] = useState<Project[]>([])
|
|
const [milestones, setMilestones] = useState<Milestone[]>([])
|
|
const [saving, setSaving] = useState(false)
|
|
const [form, setForm] = useState<FormState>(makeInitialForm(initialProjectId, initialMilestoneId))
|
|
|
|
const isEdit = Boolean(task)
|
|
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 = task?.project_id || initialProjectId || data[0]?.id || 0
|
|
const chosenMilestoneId = task?.milestone_id || initialMilestoneId || 0
|
|
|
|
setForm(task ? {
|
|
title: task.title,
|
|
description: task.description || '',
|
|
project_id: task.project_id,
|
|
milestone_id: task.milestone_id || 0,
|
|
task_type: task.task_type,
|
|
task_subtype: task.task_subtype || '',
|
|
priority: task.priority,
|
|
tags: task.tags || '',
|
|
reporter_id: task.reporter_id,
|
|
} : makeInitialForm(chosenProjectId, chosenMilestoneId))
|
|
|
|
await loadMilestones(chosenProjectId, chosenMilestoneId)
|
|
}
|
|
|
|
init().catch(console.error)
|
|
}, [isOpen, initialProjectId, initialMilestoneId, task])
|
|
|
|
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 } = isEdit
|
|
? await api.patch<Task>(`/tasks/${task!.id}`, payload)
|
|
: await api.post<Task>('/tasks', payload)
|
|
await onCreated?.(data)
|
|
await onSaved?.(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>{isEdit ? 'Edit Task' : '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 ? (isEdit ? 'Saving...' : 'Creating...') : (isEdit ? 'Save' : 'Create')}
|
|
</button>
|
|
<button type="button" className="btn-secondary" onClick={onClose} disabled={saving}>Cancel</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|