From ef42231697afd6c2053e475bd8d808bad8d8a9e3 Mon Sep 17 00:00:00 2001 From: zhi Date: Mon, 16 Mar 2026 16:32:09 +0000 Subject: [PATCH 1/2] feat: unify task creation with shared modal --- Dockerfile | 7 +- src/App.tsx | 2 - src/components/CreateTaskModal.tsx | 237 +++++++++++++++++++++++++++++ src/index.css | 22 ++- src/pages/MilestoneDetailPage.tsx | 60 ++++---- src/pages/TasksPage.tsx | 13 +- vite.config.ts | 1 + 7 files changed, 300 insertions(+), 42 deletions(-) create mode 100644 src/components/CreateTaskModal.tsx diff --git a/Dockerfile b/Dockerfile index 75ff17e..b9bd4a2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,10 +6,11 @@ RUN npm install COPY . . RUN npm run build -# Production stage — lightweight static server, no nginx +# Runtime stage FROM node:20-alpine RUN npm install -g serve@14 WORKDIR /app -COPY --from=build /app/dist ./dist +COPY --from=build /app ./ +ENV FRONTEND_DEV_MODE=0 EXPOSE 3000 -CMD ["serve", "-s", "dist", "-l", "3000"] +CMD ["sh", "-c", "if [ \"$FRONTEND_DEV_MODE\" = \"1\" ]; then npm run dev -- --host 0.0.0.0 --port 3000 --strictPort; else serve -s dist -l 3000; fi"] diff --git a/src/App.tsx b/src/App.tsx index d6a5963..6d38b45 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,7 +7,6 @@ import SetupWizardPage from '@/pages/SetupWizardPage' import DashboardPage from '@/pages/DashboardPage' import TasksPage from '@/pages/TasksPage' import TaskDetailPage from '@/pages/TaskDetailPage' -import CreateTaskPage from '@/pages/CreateTaskPage' import ProjectsPage from '@/pages/ProjectsPage' import ProjectDetailPage from '@/pages/ProjectDetailPage' import MilestonesPage from '@/pages/MilestonesPage' @@ -86,7 +85,6 @@ export default function App() { } /> } /> - } /> } /> } /> } /> diff --git a/src/components/CreateTaskModal.tsx b/src/components/CreateTaskModal.tsx new file mode 100644 index 0000000..2af39ac --- /dev/null +++ b/src/components/CreateTaskModal.tsx @@ -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 + 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([]) + const [milestones, setMilestones] = useState([]) + 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(`/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('/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('/tasks', payload) + await onCreated?.(data) + onClose() + } finally { + setSaving(false) + } + } + + if (!isOpen) return null + + return ( +
+
e.stopPropagation()}> +
+

Create Task

+ +
+ +
+ + +