feat(phase-20): navigation, error handling, integration tests, and V2 docs (T-132 to T-135)

This commit is contained in:
2026-02-09 14:19:42 +01:00
parent 9ea6ce1326
commit 29eab4ef77
9 changed files with 1401 additions and 244 deletions

View File

@@ -22,6 +22,7 @@ import type { TestResult, TeamSide, TestTimelineEntry } from "../types/models";
import TestDetailHeader from "../components/test-detail/TestDetailHeader";
import TeamTabs from "../components/test-detail/TeamTabs";
import ValidationModal from "../components/test-detail/ValidationModal";
import ConfirmDialog from "../components/ConfirmDialog";
// ── Page Component ─────────────────────────────────────────────────
@@ -38,6 +39,8 @@ export default function TestDetailPage() {
side: "red" | "blue";
}>({ open: false, side: "red" });
const [confirmReopen, setConfirmReopen] = useState(false);
const [redDraft, setRedDraft] = useState({
procedure_text: "",
tool_used: "",
@@ -96,7 +99,19 @@ export default function TestDetailPage() {
const showToast = useCallback((message: string, type: "success" | "error") => {
setToast({ message, type });
setTimeout(() => setToast(null), 3500);
setTimeout(() => setToast(null), 5000);
}, []);
/** Extract a user-friendly error message from Axios or generic errors. */
const extractError = useCallback((err: unknown): string => {
if (err && typeof err === "object" && "response" in err) {
const resp = (err as { response?: { data?: { detail?: string | { message?: string } } } }).response;
const detail = resp?.data?.detail;
if (typeof detail === "string") return detail;
if (detail && typeof detail === "object" && "message" in detail) return (detail as { message: string }).message;
}
if (err instanceof Error) return err.message;
return "An unexpected error occurred";
}, []);
const invalidateAll = useCallback(() => {
@@ -120,7 +135,7 @@ export default function TestDetailPage() {
invalidateAll();
showToast("Red Team fields saved", "success");
},
onError: (err: Error) => showToast(err.message, "error"),
onError: (err: unknown) => showToast(extractError(err), "error"),
});
const saveBlueMutation = useMutation({
@@ -133,7 +148,7 @@ export default function TestDetailPage() {
invalidateAll();
showToast("Blue Team fields saved", "success");
},
onError: (err: Error) => showToast(err.message, "error"),
onError: (err: unknown) => showToast(extractError(err), "error"),
});
// State transitions
@@ -143,7 +158,7 @@ export default function TestDetailPage() {
invalidateAll();
showToast("Test execution started", "success");
},
onError: (err: Error) => showToast(err.message, "error"),
onError: (err: unknown) => showToast(extractError(err), "error"),
});
const submitRedMutation = useMutation({
@@ -152,7 +167,7 @@ export default function TestDetailPage() {
invalidateAll();
showToast("Submitted to Blue Team", "success");
},
onError: (err: Error) => showToast(err.message, "error"),
onError: (err: unknown) => showToast(extractError(err), "error"),
});
const submitBlueMutation = useMutation({
@@ -161,7 +176,7 @@ export default function TestDetailPage() {
invalidateAll();
showToast("Submitted for review", "success");
},
onError: (err: Error) => showToast(err.message, "error"),
onError: (err: unknown) => showToast(extractError(err), "error"),
});
const validateRedLeadMutation = useMutation({
@@ -172,7 +187,7 @@ export default function TestDetailPage() {
setValidationModal({ open: false, side: "red" });
showToast("Red Lead validation submitted", "success");
},
onError: (err: Error) => showToast(err.message, "error"),
onError: (err: unknown) => showToast(extractError(err), "error"),
});
const validateBlueLeadMutation = useMutation({
@@ -183,16 +198,20 @@ export default function TestDetailPage() {
setValidationModal({ open: false, side: "blue" });
showToast("Blue Lead validation submitted", "success");
},
onError: (err: Error) => showToast(err.message, "error"),
onError: (err: unknown) => showToast(extractError(err), "error"),
});
const reopenMutation = useMutation({
mutationFn: () => reopenTest(testId!),
onSuccess: () => {
invalidateAll();
setConfirmReopen(false);
showToast("Test reopened", "success");
},
onError: (err: Error) => showToast(err.message, "error"),
onError: (err: unknown) => {
setConfirmReopen(false);
showToast(extractError(err), "error");
},
});
// Evidence upload
@@ -203,7 +222,7 @@ export default function TestDetailPage() {
invalidateAll();
showToast("Evidence uploaded", "success");
},
onError: (err: Error) => showToast(err.message, "error"),
onError: (err: unknown) => showToast(extractError(err), "error"),
});
// ── Handlers ───────────────────────────────────────────────────
@@ -322,7 +341,7 @@ export default function TestDetailPage() {
onSubmitRed={() => submitRedMutation.mutate()}
onSubmitBlue={() => submitBlueMutation.mutate()}
onOpenValidateModal={(side) => setValidationModal({ open: true, side })}
onReopen={() => reopenMutation.mutate()}
onReopen={() => setConfirmReopen(true)}
/>
{/* Content: Tabs + Sidebar */}
@@ -426,6 +445,18 @@ export default function TestDetailPage() {
</div>
</div>
{/* Confirm Reopen Dialog */}
<ConfirmDialog
open={confirmReopen}
title="Reopen Test"
message="This will move the test back to Draft state and clear all validation decisions. The Red/Blue workflow will need to be restarted. Are you sure?"
confirmLabel="Reopen"
variant="warning"
isLoading={reopenMutation.isPending}
onConfirm={() => reopenMutation.mutate()}
onCancel={() => setConfirmReopen(false)}
/>
{/* Validation Modal */}
{validationModal.open && (
<ValidationModal