Compare commits
11 Commits
8208b3b27b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6432255203 | |||
| 0b55bc873e | |||
| 95972b329e | |||
| 1a20a1050b | |||
| f61c506fdb | |||
| 38ebd2bbd1 | |||
| 83c9cd8fb7 | |||
| 8014dcd602 | |||
| f39e7da33c | |||
| ea841d0d39 | |||
| a431711ff0 |
@@ -5,7 +5,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"build": "npx tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
|
||||
44
src/App.tsx
44
src/App.tsx
@@ -22,11 +22,13 @@ import SupportDetailPage from '@/pages/SupportDetailPage'
|
||||
import MeetingDetailPage from '@/pages/MeetingDetailPage'
|
||||
import axios from 'axios'
|
||||
|
||||
const WIZARD_PORT = Number(import.meta.env.VITE_WIZARD_PORT) || 18080
|
||||
const WIZARD_BASE = `http://127.0.0.1:${WIZARD_PORT}`
|
||||
const getStoredWizardPort = (): number | null => {
|
||||
const stored = Number(localStorage.getItem('HF_WIZARD_PORT'))
|
||||
return stored && stored > 0 ? stored : null
|
||||
}
|
||||
|
||||
const getApiBase = () => {
|
||||
return localStorage.getItem('HF_BACKEND_BASE_URL') || import.meta.env.VITE_API_BASE || 'http://127.0.0.1:8000'
|
||||
return localStorage.getItem('HF_BACKEND_BASE_URL') ?? undefined
|
||||
}
|
||||
|
||||
type AppState = 'checking' | 'setup' | 'ready'
|
||||
@@ -55,24 +57,26 @@ export default function App() {
|
||||
// Backend unreachable — fall through to wizard check
|
||||
}
|
||||
|
||||
// Fallback: try the wizard directly (needed during initial setup before backend starts)
|
||||
try {
|
||||
const res = await axios.get(`${WIZARD_BASE}/api/v1/config/harborforge.json`, {
|
||||
timeout: 5000,
|
||||
})
|
||||
const cfg = res.data || {}
|
||||
if (cfg.backend_url) {
|
||||
localStorage.setItem('HF_BACKEND_BASE_URL', cfg.backend_url)
|
||||
// Fallback: if a wizard port was previously saved during setup, try it directly
|
||||
const storedPort = getStoredWizardPort()
|
||||
if (storedPort) {
|
||||
try {
|
||||
const res = await axios.get(`http://127.0.0.1:${storedPort}/api/v1/config/harborforge.json`, {
|
||||
timeout: 5000,
|
||||
})
|
||||
const cfg = res.data || {}
|
||||
if (cfg.backend_url) {
|
||||
localStorage.setItem('HF_BACKEND_BASE_URL', cfg.backend_url)
|
||||
}
|
||||
if (cfg.initialized === true) {
|
||||
setAppState('ready')
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// ignore — fall through to setup
|
||||
}
|
||||
if (cfg.initialized === true) {
|
||||
setAppState('ready')
|
||||
} else {
|
||||
setAppState('setup')
|
||||
}
|
||||
} catch {
|
||||
// Neither backend nor wizard reachable → setup needed
|
||||
setAppState('setup')
|
||||
}
|
||||
setAppState('setup')
|
||||
}
|
||||
|
||||
if (appState === 'checking') {
|
||||
@@ -80,7 +84,7 @@ export default function App() {
|
||||
}
|
||||
|
||||
if (appState === 'setup') {
|
||||
return <SetupWizardPage wizardBase={WIZARD_BASE} onComplete={checkInitialized} />
|
||||
return <SetupWizardPage initialWizardPort={getStoredWizardPort()} onComplete={checkInitialized} />
|
||||
}
|
||||
|
||||
if (loading) return <div className="loading">Loading...</div>
|
||||
|
||||
@@ -26,6 +26,12 @@ interface TimeSlotResponse {
|
||||
updated_at: string | null
|
||||
}
|
||||
|
||||
interface DayViewResponse {
|
||||
date: string
|
||||
user_id: number
|
||||
slots: TimeSlotResponse[]
|
||||
}
|
||||
|
||||
interface SchedulePlanResponse {
|
||||
id: number
|
||||
slot_type: SlotType
|
||||
@@ -41,6 +47,13 @@ interface SchedulePlanResponse {
|
||||
updated_at: string | null
|
||||
}
|
||||
|
||||
interface PlanListResponse {
|
||||
plans: SchedulePlanResponse[]
|
||||
}
|
||||
|
||||
type Weekday = 'sun' | 'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat'
|
||||
type MonthName = 'jan' | 'feb' | 'mar' | 'apr' | 'may' | 'jun' | 'jul' | 'aug' | 'sep' | 'oct' | 'nov' | 'dec'
|
||||
|
||||
interface WorkloadWarning {
|
||||
period: string
|
||||
slot_type: string
|
||||
@@ -67,6 +80,31 @@ const EVENT_TYPES: { value: EventType; label: string }[] = [
|
||||
{ value: 'system_event', label: 'System Event' },
|
||||
]
|
||||
|
||||
const WEEKDAYS: { value: Weekday; label: string }[] = [
|
||||
{ value: 'sun', label: 'Sunday' },
|
||||
{ value: 'mon', label: 'Monday' },
|
||||
{ value: 'tue', label: 'Tuesday' },
|
||||
{ value: 'wed', label: 'Wednesday' },
|
||||
{ value: 'thu', label: 'Thursday' },
|
||||
{ value: 'fri', label: 'Friday' },
|
||||
{ value: 'sat', label: 'Saturday' },
|
||||
]
|
||||
|
||||
const MONTHS: { value: MonthName; label: string }[] = [
|
||||
{ value: 'jan', label: 'January' },
|
||||
{ value: 'feb', label: 'February' },
|
||||
{ value: 'mar', label: 'March' },
|
||||
{ value: 'apr', label: 'April' },
|
||||
{ value: 'may', label: 'May' },
|
||||
{ value: 'jun', label: 'June' },
|
||||
{ value: 'jul', label: 'July' },
|
||||
{ value: 'aug', label: 'August' },
|
||||
{ value: 'sep', label: 'September' },
|
||||
{ value: 'oct', label: 'October' },
|
||||
{ value: 'nov', label: 'November' },
|
||||
{ value: 'dec', label: 'December' },
|
||||
]
|
||||
|
||||
const SLOT_TYPE_ICONS: Record<string, string> = {
|
||||
work: '💼',
|
||||
on_call: '📞',
|
||||
@@ -106,13 +144,27 @@ export default function CalendarPage() {
|
||||
})
|
||||
const [slotSaving, setSlotSaving] = useState(false)
|
||||
const [warnings, setWarnings] = useState<WorkloadWarning[]>([])
|
||||
const [showPlanModal, setShowPlanModal] = useState(false)
|
||||
const [editingPlan, setEditingPlan] = useState<SchedulePlanResponse | null>(null)
|
||||
const [planSaving, setPlanSaving] = useState(false)
|
||||
const [planForm, setPlanForm] = useState({
|
||||
slot_type: 'work' as SlotType,
|
||||
estimated_duration: 25,
|
||||
at_time: '09:00',
|
||||
on_day: '' as Weekday | '',
|
||||
on_week: '' as string,
|
||||
on_month: '' as MonthName | '',
|
||||
event_type: '' as string,
|
||||
event_data_code: '',
|
||||
event_data_event: '',
|
||||
})
|
||||
|
||||
const fetchSlots = async (date: string) => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const { data } = await api.get<TimeSlotResponse[]>(`/calendar/day?date=${date}`)
|
||||
setSlots(data)
|
||||
const { data } = await api.get<DayViewResponse>(`/calendar/day?date=${date}`)
|
||||
setSlots(Array.isArray(data?.slots) ? data.slots : [])
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || 'Failed to load calendar')
|
||||
setSlots([])
|
||||
@@ -123,8 +175,8 @@ export default function CalendarPage() {
|
||||
|
||||
const fetchPlans = async () => {
|
||||
try {
|
||||
const { data } = await api.get<SchedulePlanResponse[]>('/calendar/plans')
|
||||
setPlans(data)
|
||||
const { data } = await api.get<PlanListResponse>('/calendar/plans')
|
||||
setPlans(Array.isArray(data?.plans) ? data.plans : [])
|
||||
} catch (err: any) {
|
||||
console.error('Failed to load plans:', err)
|
||||
}
|
||||
@@ -201,6 +253,84 @@ export default function CalendarPage() {
|
||||
return null
|
||||
}
|
||||
|
||||
const buildPlanEventData = () => {
|
||||
if (!planForm.event_type) return null
|
||||
if (planForm.event_type === 'job') {
|
||||
return planForm.event_data_code ? { type: 'Task', code: planForm.event_data_code } : null
|
||||
}
|
||||
if (planForm.event_type === 'system_event') {
|
||||
return planForm.event_data_event ? { event: planForm.event_data_event } : null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const openCreatePlanModal = () => {
|
||||
setEditingPlan(null)
|
||||
setPlanForm({
|
||||
slot_type: 'work',
|
||||
estimated_duration: 25,
|
||||
at_time: '09:00',
|
||||
on_day: '',
|
||||
on_week: '',
|
||||
on_month: '',
|
||||
event_type: '',
|
||||
event_data_code: '',
|
||||
event_data_event: '',
|
||||
})
|
||||
setError('')
|
||||
setShowPlanModal(true)
|
||||
}
|
||||
|
||||
const openEditPlanModal = (plan: SchedulePlanResponse) => {
|
||||
setEditingPlan(plan)
|
||||
setPlanForm({
|
||||
slot_type: plan.slot_type,
|
||||
estimated_duration: plan.estimated_duration,
|
||||
at_time: plan.at_time.slice(0, 5),
|
||||
on_day: (plan.on_day?.toLowerCase() as Weekday | undefined) || '',
|
||||
on_week: plan.on_week ? String(plan.on_week) : '',
|
||||
on_month: (plan.on_month?.toLowerCase() as MonthName | undefined) || '',
|
||||
event_type: plan.event_type || '',
|
||||
event_data_code: plan.event_data?.code || '',
|
||||
event_data_event: plan.event_data?.event || '',
|
||||
})
|
||||
setError('')
|
||||
setShowPlanModal(true)
|
||||
}
|
||||
|
||||
const handleSavePlan = async () => {
|
||||
setPlanSaving(true)
|
||||
setError('')
|
||||
try {
|
||||
const payload: any = {
|
||||
slot_type: planForm.slot_type,
|
||||
estimated_duration: planForm.estimated_duration,
|
||||
at_time: planForm.at_time,
|
||||
on_day: planForm.on_day || null,
|
||||
on_week: planForm.on_week ? Number(planForm.on_week) : null,
|
||||
on_month: planForm.on_month || null,
|
||||
event_type: planForm.event_type || null,
|
||||
event_data: buildPlanEventData(),
|
||||
}
|
||||
|
||||
if (editingPlan) {
|
||||
await api.patch(`/calendar/plans/${editingPlan.id}`, payload)
|
||||
} else {
|
||||
await api.post('/calendar/plans', payload)
|
||||
}
|
||||
|
||||
setShowPlanModal(false)
|
||||
fetchPlans()
|
||||
if (activeTab === 'daily') {
|
||||
fetchSlots(selectedDate)
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || 'Save plan failed')
|
||||
} finally {
|
||||
setPlanSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveSlot = async () => {
|
||||
setSlotSaving(true)
|
||||
setError('')
|
||||
@@ -218,12 +348,17 @@ export default function CalendarPage() {
|
||||
let response: any
|
||||
if (editingSlot) {
|
||||
// Edit existing slot
|
||||
payload.date = selectedDate
|
||||
response = await api.post(`/calendar/edit?date=${selectedDate}&slot_id=${editingSlot.slot_id}`, payload)
|
||||
if (editingSlot.is_virtual) {
|
||||
response = await api.patch(`/calendar/slots/virtual/${editingSlot.slot_id}`, payload)
|
||||
} else if (editingSlot.slot_id) {
|
||||
response = await api.patch(`/calendar/slots/${editingSlot.slot_id}`, payload)
|
||||
} else {
|
||||
throw new Error('Missing slot identifier for edit')
|
||||
}
|
||||
} else {
|
||||
// Create new slot
|
||||
payload.date = selectedDate
|
||||
response = await api.post('/calendar/schedule', payload)
|
||||
response = await api.post('/calendar/slots', payload)
|
||||
}
|
||||
|
||||
// Check for warnings in response
|
||||
@@ -237,6 +372,17 @@ export default function CalendarPage() {
|
||||
const detail = err.response?.data?.detail
|
||||
if (typeof detail === 'string' && detail.toLowerCase().includes('overlap')) {
|
||||
setError(`⚠️ Overlap conflict: ${detail}`)
|
||||
} else if (detail && typeof detail === 'object') {
|
||||
const message = typeof detail.message === 'string' ? detail.message : 'Save failed'
|
||||
const conflicts = Array.isArray(detail.conflicts) ? detail.conflicts : []
|
||||
if (conflicts.length > 0) {
|
||||
const summary = conflicts
|
||||
.map((conflict: any) => `${conflict.slot_type || 'slot'} at ${conflict.scheduled_at || 'unknown time'}`)
|
||||
.join(', ')
|
||||
setError(`⚠️ ${message}: ${summary}`)
|
||||
} else {
|
||||
setError(`⚠️ ${message}`)
|
||||
}
|
||||
} else {
|
||||
setError(detail || 'Save failed')
|
||||
}
|
||||
@@ -250,7 +396,13 @@ export default function CalendarPage() {
|
||||
if (!confirm(`Cancel this ${slot.slot_type} slot at ${slot.scheduled_at}?`)) return
|
||||
setError('')
|
||||
try {
|
||||
await api.post(`/calendar/cancel?date=${selectedDate}&slot_id=${slot.slot_id}`)
|
||||
if (slot.is_virtual) {
|
||||
await api.post(`/calendar/slots/virtual/${slot.slot_id}/cancel`)
|
||||
} else if (slot.slot_id) {
|
||||
await api.post(`/calendar/slots/${slot.slot_id}/cancel`)
|
||||
} else {
|
||||
throw new Error('Missing slot identifier for cancel')
|
||||
}
|
||||
fetchSlots(selectedDate)
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || 'Cancel failed')
|
||||
@@ -397,7 +549,12 @@ export default function CalendarPage() {
|
||||
)}
|
||||
|
||||
{activeTab === 'plans' && (
|
||||
<div className="milestone-grid">
|
||||
<>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 16 }}>
|
||||
<button className="btn-primary" onClick={openCreatePlanModal}>+ New Plan</button>
|
||||
</div>
|
||||
|
||||
<div className="milestone-grid">
|
||||
{plans.length === 0 ? (
|
||||
<div className="empty" style={{ padding: '40px 0', color: 'var(--text-secondary)', textAlign: 'center' }}>
|
||||
No schedule plans configured.
|
||||
@@ -417,7 +574,14 @@ export default function CalendarPage() {
|
||||
{plan.event_type && <div>📌 {plan.event_type.replace('_', ' ')}</div>}
|
||||
</div>
|
||||
{plan.is_active && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<div style={{ marginTop: 8, display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
className="btn-transition"
|
||||
style={{ padding: '4px 10px', fontSize: '0.8rem' }}
|
||||
onClick={() => openEditPlanModal(plan)}
|
||||
>
|
||||
✏️ Edit Plan
|
||||
</button>
|
||||
<button
|
||||
className="btn-danger"
|
||||
style={{ padding: '4px 10px', fontSize: '0.8rem' }}
|
||||
@@ -430,7 +594,8 @@ export default function CalendarPage() {
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Create/Edit Slot Modal */}
|
||||
@@ -545,6 +710,142 @@ export default function CalendarPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showPlanModal && (
|
||||
<div className="modal-overlay" onClick={() => setShowPlanModal(false)}>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>{editingPlan ? 'Edit Plan' : 'New Plan'}</h3>
|
||||
|
||||
<label style={{ display: 'block', marginBottom: 8 }}>
|
||||
<strong>Slot Type</strong>
|
||||
<select
|
||||
value={planForm.slot_type}
|
||||
onChange={(e) => setPlanForm({ ...planForm, slot_type: e.target.value as SlotType })}
|
||||
style={{ width: '100%', marginTop: 4 }}
|
||||
>
|
||||
{SLOT_TYPES.map((t) => (
|
||||
<option key={t.value} value={t.value}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'block', marginBottom: 8 }}>
|
||||
<strong>At Time</strong>
|
||||
<input
|
||||
type="time"
|
||||
value={planForm.at_time}
|
||||
onChange={(e) => setPlanForm({ ...planForm, at_time: e.target.value })}
|
||||
style={{ width: '100%', marginTop: 4 }}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'block', marginBottom: 8 }}>
|
||||
<strong>Estimated Duration (minutes, 1–50)</strong>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={50}
|
||||
value={planForm.estimated_duration}
|
||||
onChange={(e) => setPlanForm({ ...planForm, estimated_duration: Number(e.target.value) })}
|
||||
style={{ width: '100%', marginTop: 4 }}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'block', marginBottom: 8 }}>
|
||||
<strong>On Day (optional)</strong>
|
||||
<select
|
||||
value={planForm.on_day}
|
||||
onChange={(e) => setPlanForm({ ...planForm, on_day: e.target.value as Weekday | '' })}
|
||||
style={{ width: '100%', marginTop: 4 }}
|
||||
>
|
||||
<option value="">Every day</option>
|
||||
{WEEKDAYS.map((d) => (
|
||||
<option key={d.value} value={d.value}>{d.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'block', marginBottom: 8 }}>
|
||||
<strong>On Week (optional)</strong>
|
||||
<select
|
||||
value={planForm.on_week}
|
||||
onChange={(e) => setPlanForm({ ...planForm, on_week: e.target.value })}
|
||||
style={{ width: '100%', marginTop: 4 }}
|
||||
>
|
||||
<option value="">Every matching week</option>
|
||||
<option value="1">1</option>
|
||||
<option value="2">2</option>
|
||||
<option value="3">3</option>
|
||||
<option value="4">4</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'block', marginBottom: 8 }}>
|
||||
<strong>On Month (optional)</strong>
|
||||
<select
|
||||
value={planForm.on_month}
|
||||
onChange={(e) => setPlanForm({ ...planForm, on_month: e.target.value as MonthName | '' })}
|
||||
style={{ width: '100%', marginTop: 4 }}
|
||||
>
|
||||
<option value="">Every month</option>
|
||||
{MONTHS.map((m) => (
|
||||
<option key={m.value} value={m.value}>{m.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'block', marginBottom: 8 }}>
|
||||
<strong>Event Type</strong>
|
||||
<select
|
||||
value={planForm.event_type}
|
||||
onChange={(e) => setPlanForm({ ...planForm, event_type: e.target.value })}
|
||||
style={{ width: '100%', marginTop: 4 }}
|
||||
>
|
||||
<option value="">None</option>
|
||||
{EVENT_TYPES.map((t) => (
|
||||
<option key={t.value} value={t.value}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{planForm.event_type === 'job' && (
|
||||
<label style={{ display: 'block', marginBottom: 8 }}>
|
||||
<strong>Job Code</strong>
|
||||
<input
|
||||
type="text"
|
||||
value={planForm.event_data_code}
|
||||
onChange={(e) => setPlanForm({ ...planForm, event_data_code: e.target.value })}
|
||||
placeholder="e.g. TASK-42"
|
||||
style={{ width: '100%', marginTop: 4 }}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{planForm.event_type === 'system_event' && (
|
||||
<label style={{ display: 'block', marginBottom: 8 }}>
|
||||
<strong>System Event</strong>
|
||||
<select
|
||||
value={planForm.event_data_event}
|
||||
onChange={(e) => setPlanForm({ ...planForm, event_data_event: e.target.value })}
|
||||
style={{ width: '100%', marginTop: 4 }}
|
||||
>
|
||||
<option value="">Select event</option>
|
||||
<option value="ScheduleToday">Schedule Today</option>
|
||||
<option value="SummaryToday">Summary Today</option>
|
||||
<option value="ScheduledGatewayRestart">Scheduled Gateway Restart</option>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: 12, display: 'flex', gap: 8 }}>
|
||||
<button className="btn-primary" onClick={handleSavePlan} disabled={planSaving}>
|
||||
{planSaving ? 'Saving...' : 'Save Plan'}
|
||||
</button>
|
||||
<button className="btn-back" onClick={() => setShowPlanModal(false)}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState } from 'react'
|
||||
import axios from 'axios'
|
||||
|
||||
interface Props {
|
||||
wizardBase: string
|
||||
initialWizardPort: number | null
|
||||
onComplete: () => void
|
||||
}
|
||||
|
||||
@@ -11,55 +11,53 @@ interface SetupForm {
|
||||
admin_password: string
|
||||
admin_email: string
|
||||
admin_full_name: string
|
||||
db_host: string
|
||||
db_port: number
|
||||
db_user: string
|
||||
db_password: string
|
||||
db_database: string
|
||||
backend_base_url: string
|
||||
project_name: string
|
||||
project_description: string
|
||||
}
|
||||
|
||||
const STEPS = ['Welcome', 'Database', 'Admin', 'Backend', 'Finish']
|
||||
const STEPS = ['Wizard', 'Admin', 'Backend', 'Finish']
|
||||
|
||||
export default function SetupWizardPage({ wizardBase, onComplete }: Props) {
|
||||
export default function SetupWizardPage({ initialWizardPort, onComplete }: Props) {
|
||||
const [step, setStep] = useState(0)
|
||||
const [error, setError] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [wizardOk, setWizardOk] = useState<boolean | null>(null)
|
||||
const [connecting, setConnecting] = useState(false)
|
||||
const [wizardPortInput, setWizardPortInput] = useState<string>(
|
||||
initialWizardPort ? String(initialWizardPort) : ''
|
||||
)
|
||||
const [wizardBase, setWizardBase] = useState<string>('')
|
||||
const [form, setForm] = useState<SetupForm>({
|
||||
admin_username: 'admin',
|
||||
admin_password: '',
|
||||
admin_email: '',
|
||||
admin_full_name: 'Admin',
|
||||
db_host: 'mysql',
|
||||
db_port: 3306,
|
||||
db_user: 'harborforge',
|
||||
db_password: 'harborforge_pass',
|
||||
db_database: 'harborforge',
|
||||
backend_base_url: 'http://backend:8000',
|
||||
backend_base_url: '',
|
||||
project_name: '',
|
||||
project_description: '',
|
||||
})
|
||||
|
||||
const wizardApi = axios.create({
|
||||
baseURL: wizardBase,
|
||||
timeout: 5000,
|
||||
})
|
||||
|
||||
const set = (key: keyof SetupForm, value: string | number) =>
|
||||
setForm((f) => ({ ...f, [key]: value }))
|
||||
|
||||
const checkWizard = async () => {
|
||||
setError('')
|
||||
const port = Number(wizardPortInput)
|
||||
if (!port || port <= 0 || port > 65535) {
|
||||
setError('Please enter a valid wizard port (1-65535).')
|
||||
return
|
||||
}
|
||||
const base = `http://127.0.0.1:${port}`
|
||||
setConnecting(true)
|
||||
try {
|
||||
await wizardApi.get('/health')
|
||||
setWizardOk(true)
|
||||
await axios.get(`${base}/health`, { timeout: 5000 })
|
||||
setWizardBase(base)
|
||||
localStorage.setItem('HF_WIZARD_PORT', String(port))
|
||||
setStep(1)
|
||||
} catch {
|
||||
setWizardOk(false)
|
||||
setError(`Unable to connect to AbstractWizard (${wizardBase}).\nPlease ensure the SSH tunnel is configured:\nssh -L <wizard_port>:127.0.0.1:<wizard_port> user@server`)
|
||||
setError(`Unable to connect to AbstractWizard at ${base}.\nMake sure the SSH tunnel is up:\nssh -L ${port}:127.0.0.1:${port} user@server`)
|
||||
} finally {
|
||||
setConnecting(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,25 +73,19 @@ export default function SetupWizardPage({ wizardBase, onComplete }: Props) {
|
||||
email: form.admin_email,
|
||||
full_name: form.admin_full_name,
|
||||
},
|
||||
database: {
|
||||
host: form.db_host,
|
||||
port: form.db_port,
|
||||
user: form.db_user,
|
||||
password: form.db_password,
|
||||
database: form.db_database,
|
||||
},
|
||||
backend_url: form.backend_base_url || undefined,
|
||||
}
|
||||
|
||||
await wizardApi.put('/api/v1/config/harborforge.json', config, {
|
||||
await axios.put(`${wizardBase}/api/v1/config/harborforge.json`, config, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
timeout: 5000,
|
||||
})
|
||||
|
||||
if (form.backend_base_url) {
|
||||
localStorage.setItem('HF_BACKEND_BASE_URL', form.backend_base_url)
|
||||
}
|
||||
|
||||
setStep(4)
|
||||
setStep(3)
|
||||
} catch (err: any) {
|
||||
setError(`Failed to save configuration: ${err.message}`)
|
||||
} finally {
|
||||
@@ -117,45 +109,38 @@ export default function SetupWizardPage({ wizardBase, onComplete }: Props) {
|
||||
|
||||
{error && <div className="setup-error">{error}</div>}
|
||||
|
||||
{/* Step 0: Welcome */}
|
||||
{/* Step 0: Wizard connection */}
|
||||
{step === 0 && (
|
||||
<div className="setup-step-content">
|
||||
<h2>Welcome to HarborForge</h2>
|
||||
<p>Agent/Human collaborative task management platform</p>
|
||||
<h2>Connect to AbstractWizard</h2>
|
||||
<p className="text-dim">Enter the local port that forwards to AbstractWizard, then test the connection.</p>
|
||||
<div className="setup-info">
|
||||
<p>⚠️ The setup wizard connects to AbstractWizard via SSH tunnel. Ensure the port is forwarded:</p>
|
||||
<p>⚠️ AbstractWizard is reached over an SSH tunnel. Forward the port first:</p>
|
||||
<code>ssh -L <wizard_port>:127.0.0.1:<wizard_port> user@your-server</code>
|
||||
</div>
|
||||
<button className="btn-primary" onClick={checkWizard}>
|
||||
Connect to Wizard
|
||||
</button>
|
||||
{wizardOk === false && (
|
||||
<p className="setup-hint">Connection failed. Check the SSH tunnel.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 1: Database */}
|
||||
{step === 1 && (
|
||||
<div className="setup-step-content">
|
||||
<h2>Database configuration</h2>
|
||||
<p className="text-dim">Configure MySQL connection (docker-compose defaults are fine if using the bundled MySQL).</p>
|
||||
<div className="setup-form">
|
||||
<label>Host <input value={form.db_host} onChange={(e) => set('db_host', e.target.value)} /></label>
|
||||
<label>Port <input type="number" value={form.db_port} onChange={(e) => set('db_port', Number(e.target.value))} /></label>
|
||||
<label>Username <input value={form.db_user} onChange={(e) => set('db_user', e.target.value)} /></label>
|
||||
<label>Password <input type="password" value={form.db_password} onChange={(e) => set('db_password', e.target.value)} /></label>
|
||||
<label>Database <input value={form.db_database} onChange={(e) => set('db_database', e.target.value)} /></label>
|
||||
<label>
|
||||
Wizard port
|
||||
<input
|
||||
type="number"
|
||||
value={wizardPortInput}
|
||||
min={1}
|
||||
max={65535}
|
||||
onChange={(e) => setWizardPortInput(e.target.value)}
|
||||
placeholder="e.g. 8080"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="setup-nav">
|
||||
<button className="btn-back" onClick={() => setStep(0)}>Back</button>
|
||||
<button className="btn-primary" onClick={() => setStep(2)}>Next</button>
|
||||
<button className="btn-primary" onClick={checkWizard} disabled={connecting}>
|
||||
{connecting ? 'Connecting...' : 'Test connection & continue'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Admin */}
|
||||
{step === 2 && (
|
||||
{/* Step 1: Admin */}
|
||||
{step === 1 && (
|
||||
<div className="setup-step-content">
|
||||
<h2>Admin account</h2>
|
||||
<p className="text-dim">Create the first admin user</p>
|
||||
@@ -166,26 +151,26 @@ export default function SetupWizardPage({ wizardBase, onComplete }: Props) {
|
||||
<label>Full name <input value={form.admin_full_name} onChange={(e) => set('admin_full_name', e.target.value)} /></label>
|
||||
</div>
|
||||
<div className="setup-nav">
|
||||
<button className="btn-back" onClick={() => setStep(1)}>Back</button>
|
||||
<button className="btn-back" onClick={() => setStep(0)}>Back</button>
|
||||
<button className="btn-primary" onClick={() => {
|
||||
if (!form.admin_password) { setError('Please set an admin password'); return }
|
||||
setError('')
|
||||
setStep(3)
|
||||
setStep(2)
|
||||
}}>Next</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Backend */}
|
||||
{step === 3 && (
|
||||
{/* Step 2: Backend */}
|
||||
{step === 2 && (
|
||||
<div className="setup-step-content">
|
||||
<h2>Backend URL</h2>
|
||||
<p className="text-dim">Configure the HarborForge backend API URL</p>
|
||||
<p className="text-dim">Configure the HarborForge backend API URL (leave blank to use the frontend default).</p>
|
||||
<div className="setup-form">
|
||||
<label>Backend Base URL <input value={form.backend_base_url} onChange={(e) => set('backend_base_url', e.target.value)} placeholder="http://backend:8000" /></label>
|
||||
</div>
|
||||
<div className="setup-nav">
|
||||
<button className="btn-back" onClick={() => setStep(2)}>Back</button>
|
||||
<button className="btn-back" onClick={() => setStep(1)}>Back</button>
|
||||
<button className="btn-primary" onClick={saveConfig} disabled={saving}>
|
||||
{saving ? 'Saving...' : 'Finish setup'}
|
||||
</button>
|
||||
@@ -193,8 +178,8 @@ export default function SetupWizardPage({ wizardBase, onComplete }: Props) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 4: Done */}
|
||||
{step === 4 && (
|
||||
{/* Step 3: Done */}
|
||||
{step === 3 && (
|
||||
<div className="setup-step-content">
|
||||
<div className="setup-done">
|
||||
<h2>✅ Setup complete!</h2>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const getApiBase = () => {
|
||||
return localStorage.getItem('HF_BACKEND_BASE_URL') || import.meta.env.VITE_API_BASE || 'http://127.0.0.1:8000'
|
||||
const getApiBase = (): string | undefined => {
|
||||
return localStorage.getItem('HF_BACKEND_BASE_URL') ?? undefined
|
||||
}
|
||||
|
||||
const api = axios.create({
|
||||
|
||||
525
src/test/proposal-essential.test.tsx
Normal file
525
src/test/proposal-essential.test.tsx
Normal file
@@ -0,0 +1,525 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen, waitFor, fireEvent, cleanup } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import ProposalDetailPage from '@/pages/ProposalDetailPage'
|
||||
import { MemoryRouter, Routes, Route } from 'react-router-dom'
|
||||
|
||||
const mockGet = vi.fn()
|
||||
const mockPost = vi.fn()
|
||||
const mockPatch = vi.fn()
|
||||
const mockDelete = vi.fn()
|
||||
|
||||
vi.mock('@/services/api', () => ({
|
||||
default: {
|
||||
get: (...args: any[]) => mockGet(...args),
|
||||
post: (...args: any[]) => mockPost(...args),
|
||||
patch: (...args: any[]) => mockPatch(...args),
|
||||
delete: (...args: any[]) => mockDelete(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
const mockNavigate = vi.fn()
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual('react-router-dom')
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => mockNavigate,
|
||||
useParams: () => ({ id: '1' }),
|
||||
useSearchParams: () => [new URLSearchParams('?project_id=1'), vi.fn()],
|
||||
}
|
||||
})
|
||||
|
||||
type Essential = {
|
||||
id: number
|
||||
essential_code: string
|
||||
proposal_id: number
|
||||
type: 'feature' | 'improvement' | 'refactor'
|
||||
title: string
|
||||
description: string | null
|
||||
created_by_id: number | null
|
||||
created_at: string
|
||||
updated_at: string | null
|
||||
}
|
||||
|
||||
type Proposal = {
|
||||
id: number
|
||||
proposal_code: string | null
|
||||
title: string
|
||||
description: string | null
|
||||
status: 'open' | 'accepted' | 'rejected'
|
||||
project_id: number
|
||||
created_by_id: number | null
|
||||
created_by_username: string | null
|
||||
feat_task_id: string | null
|
||||
essentials: Essential[] | null
|
||||
generated_tasks: any[] | null
|
||||
created_at: string
|
||||
updated_at: string | null
|
||||
}
|
||||
|
||||
type Milestone = {
|
||||
id: number
|
||||
title: string
|
||||
status: 'open' | 'freeze' | 'undergoing' | 'completed' | 'closed'
|
||||
}
|
||||
|
||||
const mockProposal: Proposal = {
|
||||
id: 1,
|
||||
proposal_code: 'PROP-001',
|
||||
title: 'Test Proposal',
|
||||
description: 'Test description',
|
||||
status: 'open',
|
||||
project_id: 1,
|
||||
created_by_id: 1,
|
||||
created_by_username: 'admin',
|
||||
feat_task_id: null,
|
||||
essentials: [],
|
||||
generated_tasks: null,
|
||||
created_at: '2026-03-01T00:00:00Z',
|
||||
updated_at: null,
|
||||
}
|
||||
|
||||
const mockEssentials: Essential[] = [
|
||||
{
|
||||
id: 1,
|
||||
essential_code: 'ESS-001',
|
||||
proposal_id: 1,
|
||||
type: 'feature',
|
||||
title: 'Feature Essential',
|
||||
description: 'A feature essential',
|
||||
created_by_id: 1,
|
||||
created_at: '2026-03-01T00:00:00Z',
|
||||
updated_at: null,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
essential_code: 'ESS-002',
|
||||
proposal_id: 1,
|
||||
type: 'improvement',
|
||||
title: 'Improvement Essential',
|
||||
description: null,
|
||||
created_by_id: 1,
|
||||
created_at: '2026-03-01T00:00:00Z',
|
||||
updated_at: null,
|
||||
},
|
||||
]
|
||||
|
||||
const mockMilestones: Milestone[] = [
|
||||
{ id: 1, title: 'Milestone 1', status: 'open' },
|
||||
{ id: 2, title: 'Milestone 2', status: 'open' },
|
||||
]
|
||||
|
||||
function setupApi(options?: {
|
||||
proposal?: Proposal
|
||||
essentials?: Essential[]
|
||||
milestones?: Milestone[]
|
||||
error?: string
|
||||
}) {
|
||||
const proposal = options?.proposal ?? mockProposal
|
||||
const essentials = options?.essentials ?? mockEssentials
|
||||
const milestones = options?.milestones ?? mockMilestones
|
||||
|
||||
mockGet.mockImplementation((url: string) => {
|
||||
if (url.includes('/proposals/1') && !url.includes('/essentials')) {
|
||||
if (options?.error) {
|
||||
return Promise.reject({ response: { data: { detail: options.error } } })
|
||||
}
|
||||
return Promise.resolve({ data: proposal })
|
||||
}
|
||||
if (url.includes('/essentials')) {
|
||||
return Promise.resolve({ data: essentials })
|
||||
}
|
||||
if (url.includes('/milestones')) {
|
||||
return Promise.resolve({ data: milestones })
|
||||
}
|
||||
return Promise.reject(new Error(`Unhandled GET ${url}`))
|
||||
})
|
||||
|
||||
mockPost.mockResolvedValue({ data: {} })
|
||||
mockPatch.mockResolvedValue({ data: {} })
|
||||
mockDelete.mockResolvedValue({ data: {} })
|
||||
}
|
||||
|
||||
describe('ProposalDetailPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setupApi()
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('Essential List Display', () => {
|
||||
it('renders essentials section with count', async () => {
|
||||
render(<ProposalDetailPage />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Essentials (2)')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('displays essential cards with type badges', async () => {
|
||||
render(<ProposalDetailPage />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Feature Essential')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.getByText('Improvement Essential')).toBeInTheDocument()
|
||||
expect(screen.getByText('feature')).toBeInTheDocument()
|
||||
expect(screen.getByText('improvement')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays essential codes', async () => {
|
||||
render(<ProposalDetailPage />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('ESS-001')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.getByText('ESS-002')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays essential descriptions when available', async () => {
|
||||
render(<ProposalDetailPage />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('A feature essential')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows empty state when no essentials', async () => {
|
||||
setupApi({ essentials: [] })
|
||||
render(<ProposalDetailPage />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Essentials (0)')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.getByText(/No essentials yet/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Add one to define deliverables/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows loading state for essentials', async () => {
|
||||
mockGet.mockImplementation((url: string) => {
|
||||
if (url.includes('/proposals/1') && !url.includes('/essentials')) {
|
||||
return Promise.resolve({ data: mockProposal })
|
||||
}
|
||||
if (url.includes('/essentials')) {
|
||||
return new Promise(() => {}) // Never resolve
|
||||
}
|
||||
return Promise.reject(new Error(`Unhandled GET ${url}`))
|
||||
})
|
||||
|
||||
render(<ProposalDetailPage />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Loading/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Essential Create/Edit Forms', () => {
|
||||
it('opens create essential modal', async () => {
|
||||
render(<ProposalDetailPage />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('+ New Essential')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await userEvent.click(screen.getByText('+ New Essential'))
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'New Essential' })).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('Essential title')).toBeInTheDocument()
|
||||
expect(screen.getByText('Feature')).toBeInTheDocument()
|
||||
expect(screen.getByText('Improvement')).toBeInTheDocument()
|
||||
expect(screen.getByText('Refactor')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('creates new essential', async () => {
|
||||
render(<ProposalDetailPage />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('+ New Essential')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await userEvent.click(screen.getByText('+ New Essential'))
|
||||
|
||||
await userEvent.type(screen.getByPlaceholderText('Essential title'), 'New Test Essential')
|
||||
fireEvent.change(screen.getAllByRole('combobox')[0], { target: { value: 'refactor' } })
|
||||
await userEvent.type(screen.getByPlaceholderText('Description (optional)'), 'Test description')
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPost).toHaveBeenCalledWith(
|
||||
'/projects/1/proposals/1/essentials',
|
||||
expect.objectContaining({
|
||||
title: 'New Test Essential',
|
||||
type: 'refactor',
|
||||
description: 'Test description',
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('opens edit essential modal with pre-filled data', async () => {
|
||||
render(<ProposalDetailPage />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Feature Essential')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const editButtons = screen.getAllByRole('button', { name: 'Edit' })
|
||||
await userEvent.click(editButtons[0])
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Edit Essential' })).toBeInTheDocument()
|
||||
|
||||
const titleInput = screen.getByDisplayValue('Feature Essential')
|
||||
expect(titleInput).toBeInTheDocument()
|
||||
|
||||
const descriptionInput = screen.getByDisplayValue('A feature essential')
|
||||
expect(descriptionInput).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('updates essential', async () => {
|
||||
render(<ProposalDetailPage />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Feature Essential')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const editButtons = screen.getAllByRole('button', { name: 'Edit' })
|
||||
await userEvent.click(editButtons[0])
|
||||
|
||||
const titleInput = screen.getByDisplayValue('Feature Essential')
|
||||
await userEvent.clear(titleInput)
|
||||
await userEvent.type(titleInput, 'Updated Essential Title')
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPatch).toHaveBeenCalledWith(
|
||||
'/projects/1/proposals/1/essentials/1',
|
||||
expect.objectContaining({
|
||||
title: 'Updated Essential Title',
|
||||
type: 'feature',
|
||||
description: 'A feature essential',
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('deletes essential after confirmation', async () => {
|
||||
render(<ProposalDetailPage />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Feature Essential')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const deleteButtons = screen.getAllByRole('button', { name: 'Delete' })
|
||||
await userEvent.click(deleteButtons[0])
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.confirm).toHaveBeenCalledWith('Are you sure you want to delete this Essential?')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDelete).toHaveBeenCalledWith('/projects/1/proposals/1/essentials/1')
|
||||
})
|
||||
})
|
||||
|
||||
it('disables save button when title is empty', async () => {
|
||||
render(<ProposalDetailPage />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('+ New Essential')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await userEvent.click(screen.getByText('+ New Essential'))
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: 'Save' })
|
||||
expect(saveButton).toBeDisabled()
|
||||
|
||||
await userEvent.type(screen.getByPlaceholderText('Essential title'), 'Some Title')
|
||||
expect(saveButton).toBeEnabled()
|
||||
|
||||
await userEvent.clear(screen.getByPlaceholderText('Essential title'))
|
||||
expect(saveButton).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Accept with Milestone Selection', () => {
|
||||
it('opens accept modal with milestone selector', async () => {
|
||||
render(<ProposalDetailPage />)
|
||||
|
||||
const acceptButton = await screen.findByRole('button', { name: /accept/i })
|
||||
await userEvent.click(acceptButton)
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Accept Proposal' })).toBeInTheDocument()
|
||||
expect(screen.getByText(/milestone to generate story tasks/i)).toBeInTheDocument()
|
||||
expect(screen.getByRole('combobox')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('disables confirm button without milestone selection', async () => {
|
||||
render(<ProposalDetailPage />)
|
||||
|
||||
const acceptButton = await screen.findByRole('button', { name: /accept/i })
|
||||
await userEvent.click(acceptButton)
|
||||
|
||||
const confirmButton = screen.getByRole('button', { name: 'Confirm Accept' })
|
||||
expect(confirmButton).toBeDisabled()
|
||||
})
|
||||
|
||||
it('enables confirm button after milestone selection', async () => {
|
||||
render(<ProposalDetailPage />)
|
||||
|
||||
const acceptButton = await screen.findByRole('button', { name: /accept/i })
|
||||
await userEvent.click(acceptButton)
|
||||
|
||||
const select = screen.getByRole('combobox')
|
||||
fireEvent.change(select, { target: { value: '1' } })
|
||||
|
||||
const confirmButton = screen.getByRole('button', { name: 'Confirm Accept' })
|
||||
expect(confirmButton).toBeEnabled()
|
||||
})
|
||||
|
||||
it('calls accept API with selected milestone', async () => {
|
||||
render(<ProposalDetailPage />)
|
||||
|
||||
const acceptButton = await screen.findByRole('button', { name: /accept/i })
|
||||
await userEvent.click(acceptButton)
|
||||
|
||||
const select = screen.getByRole('combobox')
|
||||
fireEvent.change(select, { target: { value: '1' } })
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Confirm Accept' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPost).toHaveBeenCalledWith(
|
||||
'/projects/1/proposals/1/accept',
|
||||
{ milestone_id: 1 }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('shows warning when no open milestones available', async () => {
|
||||
setupApi({ milestones: [] })
|
||||
render(<ProposalDetailPage />)
|
||||
|
||||
const acceptButton = await screen.findByRole('button', { name: /accept/i })
|
||||
await userEvent.click(acceptButton)
|
||||
|
||||
expect(screen.getByText('No open milestones available.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays generated tasks after accept', async () => {
|
||||
const acceptedProposal: Proposal = {
|
||||
...mockProposal,
|
||||
status: 'accepted',
|
||||
generated_tasks: [
|
||||
{ task_id: 1, task_code: 'TASK-001', title: 'Story Task 1', task_type: 'story', task_subtype: 'feature' },
|
||||
{ task_id: 2, task_code: 'TASK-002', title: 'Story Task 2', task_type: 'story', task_subtype: 'improvement' },
|
||||
],
|
||||
}
|
||||
setupApi({ proposal: acceptedProposal })
|
||||
|
||||
render(<ProposalDetailPage />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Generated Tasks')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.getByText('Story Task 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Story Task 2')).toBeInTheDocument()
|
||||
expect(screen.getByText('story/feature')).toBeInTheDocument()
|
||||
expect(screen.getByText('story/improvement')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Story Creation Restriction UI', () => {
|
||||
it('hides essential controls for accepted proposal', async () => {
|
||||
const acceptedProposal: Proposal = {
|
||||
...mockProposal,
|
||||
status: 'accepted',
|
||||
essentials: mockEssentials,
|
||||
}
|
||||
setupApi({ proposal: acceptedProposal })
|
||||
|
||||
render(<ProposalDetailPage />)
|
||||
|
||||
await screen.findByText('Feature Essential')
|
||||
expect(screen.queryByText('+ New Essential')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides edit/delete buttons for essentials when proposal is accepted', async () => {
|
||||
const acceptedProposal: Proposal = {
|
||||
...mockProposal,
|
||||
status: 'accepted',
|
||||
essentials: mockEssentials,
|
||||
}
|
||||
setupApi({ proposal: acceptedProposal })
|
||||
|
||||
render(<ProposalDetailPage />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Feature Essential')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const deleteButtons = screen.queryAllByRole('button', { name: 'Delete' })
|
||||
expect(deleteButtons.length).toBe(0)
|
||||
})
|
||||
|
||||
it('hides edit/delete buttons for rejected proposal', async () => {
|
||||
const rejectedProposal: Proposal = {
|
||||
...mockProposal,
|
||||
status: 'rejected',
|
||||
essentials: mockEssentials,
|
||||
}
|
||||
setupApi({ proposal: rejectedProposal })
|
||||
|
||||
render(<ProposalDetailPage />)
|
||||
|
||||
await screen.findByRole('button', { name: /reopen/i })
|
||||
expect(screen.queryByText('+ New Essential')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('displays error message on essential create failure', async () => {
|
||||
mockPost.mockRejectedValue({ response: { data: { detail: 'Failed to create essential' } } })
|
||||
|
||||
render(<ProposalDetailPage />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('+ New Essential')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await userEvent.click(screen.getByText('+ New Essential'))
|
||||
await userEvent.type(screen.getByPlaceholderText('Essential title'), 'Test')
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Failed to create essential')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('displays error message on accept failure', async () => {
|
||||
mockPost.mockRejectedValue({ response: { data: { detail: 'Accept failed: milestone required' } } })
|
||||
|
||||
render(<ProposalDetailPage />)
|
||||
|
||||
const acceptButton = await screen.findByRole('button', { name: /accept/i })
|
||||
await userEvent.click(acceptButton)
|
||||
fireEvent.change(screen.getByRole('combobox'), { target: { value: '1' } })
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Confirm Accept' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Accept failed: milestone required')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
1
src/vite-env.d.ts
vendored
1
src/vite-env.d.ts
vendored
@@ -2,7 +2,6 @@
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE: string
|
||||
readonly VITE_WIZARD_PORT: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
||||
Reference in New Issue
Block a user