feat(FN-4578): complete Step 8 — add milestone acceptance criteria UI editing

Fusion-Task-Id: FN-4578
Fusion-Task-Lineage: 1be0fa08-4d83-4988-a218-326225dfe4b5
This commit is contained in:
Fusion
2026-05-15 02:59:13 -07:00
committed by gsxdsm
parent 3db986b97d
commit 2cc6ea261c
3 changed files with 81 additions and 0 deletions

View File

@@ -257,6 +257,7 @@ interface MissionFormData {
interface MilestoneFormData { interface MilestoneFormData {
title: string; title: string;
description: string; description: string;
acceptanceCriteria: string;
status: MilestoneStatus; status: MilestoneStatus;
dependencies: string[]; dependencies: string[];
} }
@@ -284,6 +285,7 @@ const EMPTY_MISSION_FORM: MissionFormData = {
const EMPTY_MILESTONE_FORM: MilestoneFormData = { const EMPTY_MILESTONE_FORM: MilestoneFormData = {
title: "", title: "",
description: "", description: "",
acceptanceCriteria: "",
status: "planning", status: "planning",
dependencies: [], dependencies: [],
}; };
@@ -1370,6 +1372,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
setMilestoneForm({ setMilestoneForm({
title: milestone.title, title: milestone.title,
description: milestone.description || "", description: milestone.description || "",
acceptanceCriteria: milestone.acceptanceCriteria || "",
status: milestone.status, status: milestone.status,
dependencies: milestone.dependencies, dependencies: milestone.dependencies,
}); });
@@ -1393,6 +1396,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
await createMilestone(selectedMission.id, { await createMilestone(selectedMission.id, {
title: milestoneForm.title.trim(), title: milestoneForm.title.trim(),
description: milestoneForm.description.trim() || undefined, description: milestoneForm.description.trim() || undefined,
acceptanceCriteria: milestoneForm.acceptanceCriteria.trim() || undefined,
dependencies: milestoneForm.dependencies, dependencies: milestoneForm.dependencies,
}, projectId); }, projectId);
addToast("Milestone created", "success"); addToast("Milestone created", "success");
@@ -1400,6 +1404,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
await updateMilestone(editingMilestoneId, { await updateMilestone(editingMilestoneId, {
title: milestoneForm.title.trim(), title: milestoneForm.title.trim(),
description: milestoneForm.description.trim() || undefined, description: milestoneForm.description.trim() || undefined,
acceptanceCriteria: milestoneForm.acceptanceCriteria.trim() || undefined,
status: milestoneForm.status, status: milestoneForm.status,
dependencies: milestoneForm.dependencies, dependencies: milestoneForm.dependencies,
}, projectId); }, projectId);
@@ -2458,6 +2463,12 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
{expandedMilestones.has(milestone.id) && ( {expandedMilestones.has(milestone.id) && (
<div className="mission-milestone__body"> <div className="mission-milestone__body">
{milestone.acceptanceCriteria && (
<p className="mission-feature__criteria">
<strong>Acceptance:</strong> {milestone.acceptanceCriteria}
</p>
)}
{/* Create milestone form (inline edit) */} {/* Create milestone form (inline edit) */}
{(isCreatingMilestone || editingMilestoneId === milestone.id) && ( {(isCreatingMilestone || editingMilestoneId === milestone.id) && (
<div className="mission-form-card"> <div className="mission-form-card">
@@ -2475,6 +2486,12 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
onChange={(e) => setMilestoneForm({ ...milestoneForm, description: e.target.value })} onChange={(e) => setMilestoneForm({ ...milestoneForm, description: e.target.value })}
rows={2} rows={2}
/> />
<textarea
placeholder="Acceptance criteria (optional)"
value={milestoneForm.acceptanceCriteria}
onChange={(e) => setMilestoneForm({ ...milestoneForm, acceptanceCriteria: e.target.value })}
rows={2}
/>
<div className="mission-form-card__actions"> <div className="mission-form-card__actions">
<button className="mission-btn mission-btn--primary" onClick={handleSaveMilestone} disabled={saving}> <button className="mission-btn mission-btn--primary" onClick={handleSaveMilestone} disabled={saving}>
{saving ? <Loader2 size={14} className="spinner" /> : <Check size={14} />} {saving ? <Loader2 size={14} className="spinner" /> : <Check size={14} />}
@@ -3442,6 +3459,12 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
onChange={(e) => setMilestoneForm({ ...milestoneForm, description: e.target.value })} onChange={(e) => setMilestoneForm({ ...milestoneForm, description: e.target.value })}
rows={2} rows={2}
/> />
<textarea
placeholder="Acceptance criteria (optional)"
value={milestoneForm.acceptanceCriteria}
onChange={(e) => setMilestoneForm({ ...milestoneForm, acceptanceCriteria: e.target.value })}
rows={2}
/>
<div className="mission-form-card__actions"> <div className="mission-form-card__actions">
<button className="mission-btn mission-btn--primary" onClick={handleSaveMilestone} disabled={saving}> <button className="mission-btn mission-btn--primary" onClick={handleSaveMilestone} disabled={saving}>
{saving ? <Loader2 size={14} className="spinner" /> : <Check size={14} />} {saving ? <Loader2 size={14} className="spinner" /> : <Check size={14} />}

View File

@@ -125,6 +125,7 @@ const mockMissionDetail = {
id: "MS-001", id: "MS-001",
title: "Database Schema", title: "Database Schema",
description: "Set up auth tables", description: "Set up auth tables",
acceptanceCriteria: "Schema validated and migration succeeds",
status: "planning", status: "planning",
interviewState: "not_started", interviewState: "not_started",
dependencies: [] as string[], dependencies: [] as string[],
@@ -2697,6 +2698,62 @@ describe("MissionManager", () => {
}); });
}); });
it("shows milestone acceptance criteria and submits milestone acceptanceCriteria updates", async () => {
const addToast = vi.fn();
const fetchMock = vi.fn().mockImplementation((url: string, init?: RequestInit) => {
if (url.includes("/missions/health")) {
return Promise.resolve(mockApiResponse(mockMissionHealthById));
}
if (url.includes("/health")) {
const missionId = extractMissionId(url) ?? "M-001";
return Promise.resolve(mockApiResponse(getMockMissionHealth(missionId)));
}
if (url.includes("/autopilot")) {
return Promise.resolve(mockApiResponse(mockAutopilotStatus));
}
const validationResponse = getValidationApiMock(url);
if (validationResponse !== null) {
return Promise.resolve(mockApiResponse(validationResponse));
}
if (url.includes("/api/missions/M-001/milestones/MS-001") && init?.method === "PATCH") {
return Promise.resolve(mockApiResponse({ ...mockMissionDetail.milestones[0], acceptanceCriteria: "Updated milestone acceptance" }));
}
if (url.includes("/api/missions/") && !url.includes("/milestones") && !url.includes("/status")) {
return Promise.resolve(mockApiResponse(mockMissionDetail));
}
return Promise.resolve(mockApiResponse(mockMissions));
});
globalThis.fetch = fetchMock;
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={addToast} />);
await waitFor(() => {
expect(screen.getByText("Build Auth System")).toBeDefined();
});
fireEvent.click(screen.getByText("Build Auth System"));
await waitFor(() => {
expect(screen.getByText(/Schema validated and migration succeeds/)).toBeInTheDocument();
});
fireEvent.click(screen.getByTitle("Edit milestone"));
const acceptanceField = await screen.findByPlaceholderText("Acceptance criteria (optional)");
fireEvent.change(acceptanceField, { target: { value: " Updated milestone acceptance " } });
fireEvent.click(screen.getByRole("button", { name: "Update" }));
await waitFor(() => {
const patchCall = fetchMock.mock.calls.find(
(call) =>
typeof call[0] === "string" &&
call[0].includes("/api/missions/M-001/milestones/MS-001") &&
call[1]?.method === "PATCH",
);
expect(patchCall).toBeDefined();
const body = JSON.parse((patchCall![1] as RequestInit).body as string);
expect(body.acceptanceCriteria).toBe("Updated milestone acceptance");
});
});
it("shows edit and delete mission buttons in detail header", async () => { it("shows edit and delete mission buttons in detail header", async () => {
globalThis.fetch = createDetailFetchMock(); globalThis.fetch = createDetailFetchMock();
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />); render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);

View File

@@ -238,6 +238,7 @@ export interface Milestone {
dependencies: string[]; dependencies: string[];
planningNotes?: string; planningNotes?: string;
verification?: string; verification?: string;
acceptanceCriteria?: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
slices: Slice[]; slices: Slice[];