feat: unify project milestone and task editing with modals
This commit is contained in:
@@ -17,13 +17,27 @@ 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
|
||||
}
|
||||
|
||||
const makeInitialForm = (projectId = 0, milestoneId = 0) => ({
|
||||
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,
|
||||
@@ -39,16 +53,19 @@ 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(makeInitialForm(initialProjectId, initialMilestoneId))
|
||||
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]
|
||||
@@ -76,13 +93,26 @@ export default function CreateTaskModal({
|
||||
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)
|
||||
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])
|
||||
}, [isOpen, initialProjectId, initialMilestoneId, task])
|
||||
|
||||
const handleProjectChange = async (projectId: number) => {
|
||||
setForm((f) => ({ ...f, project_id: projectId, milestone_id: 0 }))
|
||||
@@ -105,8 +135,11 @@ export default function CreateTaskModal({
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
const { data } = await api.post<Task>('/tasks', payload)
|
||||
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)
|
||||
@@ -119,7 +152,7 @@ export default function CreateTaskModal({
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal task-create-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3>Create Task</h3>
|
||||
<h3>{isEdit ? 'Edit Task' : 'Create Task'}</h3>
|
||||
<button type="button" className="btn-secondary" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
@@ -226,7 +259,7 @@ export default function CreateTaskModal({
|
||||
|
||||
<div className="modal-actions">
|
||||
<button data-testid="create-task-button" type="submit" className="btn-primary" disabled={saving}>
|
||||
{saving ? 'Creating...' : 'Create'}
|
||||
{saving ? (isEdit ? 'Saving...' : 'Creating...') : (isEdit ? 'Save' : 'Create')}
|
||||
</button>
|
||||
<button type="button" className="btn-secondary" onClick={onClose} disabled={saving}>Cancel</button>
|
||||
</div>
|
||||
|
||||
154
src/components/MilestoneFormModal.tsx
Normal file
154
src/components/MilestoneFormModal.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import api from '@/services/api'
|
||||
import type { Milestone, Project } from '@/types'
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSaved?: (milestone: Milestone) => void | Promise<void>
|
||||
milestone?: Milestone | null
|
||||
initialProjectId?: number
|
||||
lockProject?: boolean
|
||||
}
|
||||
|
||||
type FormState = {
|
||||
title: string
|
||||
description: string
|
||||
project_id: number
|
||||
status: string
|
||||
due_date: string
|
||||
planned_release_date: string
|
||||
}
|
||||
|
||||
const emptyForm: FormState = {
|
||||
title: '',
|
||||
description: '',
|
||||
project_id: 0,
|
||||
status: 'open',
|
||||
due_date: '',
|
||||
planned_release_date: '',
|
||||
}
|
||||
|
||||
const toDateInput = (value?: string | null) => (value ? String(value).slice(0, 10) : '')
|
||||
|
||||
export default function MilestoneFormModal({ isOpen, onClose, onSaved, milestone, initialProjectId, lockProject = false }: Props) {
|
||||
const [projects, setProjects] = useState<Project[]>([])
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [form, setForm] = useState<FormState>(emptyForm)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
|
||||
const init = async () => {
|
||||
const { data } = await api.get<Project[]>('/projects')
|
||||
setProjects(data)
|
||||
const defaultProjectId = milestone?.project_id || initialProjectId || data[0]?.id || 0
|
||||
setForm({
|
||||
title: milestone?.title || '',
|
||||
description: milestone?.description || '',
|
||||
project_id: defaultProjectId,
|
||||
status: milestone?.status || 'open',
|
||||
due_date: toDateInput(milestone?.due_date),
|
||||
planned_release_date: toDateInput(milestone?.planned_release_date),
|
||||
})
|
||||
}
|
||||
|
||||
init().catch(console.error)
|
||||
}, [isOpen, milestone, initialProjectId])
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
try {
|
||||
const payload = {
|
||||
title: form.title,
|
||||
description: form.description || null,
|
||||
project_id: form.project_id,
|
||||
status: form.status,
|
||||
due_date: form.due_date || null,
|
||||
planned_release_date: form.planned_release_date || null,
|
||||
}
|
||||
if (milestone) {
|
||||
const { data } = await api.patch<Milestone>(`/milestones/${milestone.id}`, payload)
|
||||
await onSaved?.(data)
|
||||
} else {
|
||||
const { data } = await api.post<Milestone>('/milestones', payload)
|
||||
await onSaved?.(data)
|
||||
}
|
||||
onClose()
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3>{milestone ? 'Edit Milestone' : 'Create Milestone'}</h3>
|
||||
<button type="button" className="btn-secondary" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<form className="task-create-form" onSubmit={submit}>
|
||||
<label>
|
||||
Title
|
||||
<input
|
||||
data-testid="milestone-title-input"
|
||||
required
|
||||
placeholder="Milestone title"
|
||||
value={form.title}
|
||||
onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Description
|
||||
<textarea
|
||||
data-testid="milestone-description-input"
|
||||
placeholder="Description (optional)"
|
||||
value={form.description}
|
||||
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="task-create-grid">
|
||||
<label>
|
||||
Project
|
||||
<select value={form.project_id} onChange={(e) => setForm((f) => ({ ...f, project_id: Number(e.target.value) }))} disabled={lockProject}>
|
||||
{projects.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Status
|
||||
<select value={form.status} onChange={(e) => setForm((f) => ({ ...f, status: e.target.value }))}>
|
||||
<option value="open">Open</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="deferred">Deferred</option>
|
||||
<option value="progressing">Progressing</option>
|
||||
<option value="closed">Closed</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Due date
|
||||
<input type="date" value={form.due_date} onChange={(e) => setForm((f) => ({ ...f, due_date: e.target.value }))} />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Planned release date
|
||||
<input type="date" value={form.planned_release_date} onChange={(e) => setForm((f) => ({ ...f, planned_release_date: e.target.value }))} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="modal-actions">
|
||||
<button type="submit" className="btn-primary" disabled={saving}>{saving ? 'Saving...' : (milestone ? 'Save' : 'Create')}</button>
|
||||
<button type="button" className="btn-secondary" onClick={onClose} disabled={saving}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
204
src/components/ProjectFormModal.tsx
Normal file
204
src/components/ProjectFormModal.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import api from '@/services/api'
|
||||
import type { Project, User } from '@/types'
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSaved?: (project: Project) => void | Promise<void>
|
||||
project?: Project | null
|
||||
}
|
||||
|
||||
type FormState = {
|
||||
name: string
|
||||
owner_id: number
|
||||
owner_name: string
|
||||
description: string
|
||||
repo: string
|
||||
sub_projects: string[]
|
||||
related_projects: string[]
|
||||
}
|
||||
|
||||
const emptyForm: FormState = {
|
||||
name: '',
|
||||
owner_id: 1,
|
||||
owner_name: '',
|
||||
description: '',
|
||||
repo: '',
|
||||
sub_projects: [],
|
||||
related_projects: [],
|
||||
}
|
||||
|
||||
export default function ProjectFormModal({ isOpen, onClose, onSaved, project }: Props) {
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const [projects, setProjects] = useState<Project[]>([])
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [form, setForm] = useState<FormState>(emptyForm)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
|
||||
const init = async () => {
|
||||
const [{ data: userData }, { data: projectData }] = await Promise.all([
|
||||
api.get<User[]>('/users'),
|
||||
api.get<Project[]>('/projects'),
|
||||
])
|
||||
setUsers(userData)
|
||||
setProjects(projectData)
|
||||
|
||||
if (project) {
|
||||
setForm({
|
||||
name: project.name,
|
||||
owner_id: project.owner_id,
|
||||
owner_name: project.owner_name || '',
|
||||
description: project.description || '',
|
||||
repo: project.repo || '',
|
||||
sub_projects: project.sub_projects || [],
|
||||
related_projects: project.related_projects || [],
|
||||
})
|
||||
} else {
|
||||
const defaultOwner = userData[0]
|
||||
setForm({
|
||||
...emptyForm,
|
||||
owner_id: defaultOwner?.id || 1,
|
||||
owner_name: defaultOwner?.username || '',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
init().catch(console.error)
|
||||
}, [isOpen, project])
|
||||
|
||||
const selectableProjects = useMemo(
|
||||
() => projects.filter((p) => p.id !== project?.id && p.project_code),
|
||||
[projects, project?.id]
|
||||
)
|
||||
|
||||
const handleMulti = (e: React.ChangeEvent<HTMLSelectElement>, field: 'sub_projects' | 'related_projects') => {
|
||||
const values = Array.from(e.target.selectedOptions).map((o) => o.value)
|
||||
setForm((f) => ({ ...f, [field]: values }))
|
||||
}
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
try {
|
||||
if (project) {
|
||||
const { data } = await api.patch<Project>(`/projects/${project.id}`, {
|
||||
owner_name: form.owner_name,
|
||||
description: form.description || null,
|
||||
repo: form.repo || null,
|
||||
sub_projects: form.sub_projects,
|
||||
related_projects: form.related_projects,
|
||||
})
|
||||
await onSaved?.(data)
|
||||
} else {
|
||||
const { data } = await api.post<Project>('/projects', {
|
||||
name: form.name,
|
||||
owner_id: form.owner_id,
|
||||
owner_name: form.owner_name,
|
||||
description: form.description || null,
|
||||
repo: form.repo || null,
|
||||
sub_projects: form.sub_projects,
|
||||
related_projects: form.related_projects,
|
||||
})
|
||||
await onSaved?.(data)
|
||||
}
|
||||
onClose()
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3>{project ? 'Edit Project' : 'Create Project'}</h3>
|
||||
<button type="button" className="btn-secondary" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<form className="task-create-form" onSubmit={submit}>
|
||||
{!project && (
|
||||
<label>
|
||||
Name
|
||||
<input
|
||||
data-testid="project-name-input"
|
||||
required
|
||||
placeholder="Project name"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<label>
|
||||
Owner
|
||||
<select
|
||||
data-testid="project-owner-select"
|
||||
value={project ? form.owner_name : String(form.owner_id)}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
const selectedUser = users.find((u) => String(u.id) === value) || users.find((u) => u.username === value)
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
owner_id: selectedUser?.id || f.owner_id,
|
||||
owner_name: selectedUser?.username || value,
|
||||
}))
|
||||
}}
|
||||
>
|
||||
{users.map((u) => (
|
||||
<option key={u.id} value={project ? u.username : u.id}>{u.username} ({u.full_name || 'No name'})</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Description
|
||||
<textarea
|
||||
data-testid="project-description-input"
|
||||
placeholder="Description (optional)"
|
||||
value={form.description}
|
||||
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Repository URL
|
||||
<input
|
||||
data-testid="project-repo-input"
|
||||
placeholder="Repository URL (optional)"
|
||||
value={form.repo}
|
||||
onChange={(e) => setForm((f) => ({ ...f, repo: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Sub-projects
|
||||
<select multiple value={form.sub_projects} onChange={(e) => handleMulti(e, 'sub_projects')}>
|
||||
{selectableProjects.map((p) => (
|
||||
<option key={p.id} value={p.project_code || ''}>{p.project_code} - {p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Related projects
|
||||
<select multiple value={form.related_projects} onChange={(e) => handleMulti(e, 'related_projects')}>
|
||||
{selectableProjects.map((p) => (
|
||||
<option key={p.id} value={p.project_code || ''}>{p.project_code} - {p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="modal-actions">
|
||||
<button type="submit" className="btn-primary" disabled={saving}>{saving ? 'Saving...' : (project ? 'Save' : '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