feat(tests): on-hold button with reason modal, Jira comment + On Hold transition
Aegis CI / lint-and-test (push) Waiting to run
Snyk Security Scan / Python vulnerabilities (backend) (push) Waiting to run
Snyk Security Scan / npm vulnerabilities (frontend) (push) Waiting to run
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Waiting to run

This commit is contained in:
kitos
2026-06-19 09:53:05 +02:00
parent 6147f15238
commit 4e1f35c250
9 changed files with 333 additions and 0 deletions
+14
View File
@@ -236,6 +236,20 @@ export async function assignTest(
return data;
}
// ── On Hold ────────────────────────────────────────────────────────
/** Place a test on hold with a mandatory reason. */
export async function holdTest(testId: string, reason: string): Promise<Test> {
const { data } = await client.post<Test>(`/tests/${testId}/hold`, { reason });
return data;
}
/** Resume a test that was placed on hold. */
export async function resumeTest(testId: string): Promise<Test> {
const { data } = await client.post<Test>(`/tests/${testId}/resume`);
return data;
}
// ── Timeline ───────────────────────────────────────────────────────
/** Get the audit-log timeline for a test. */
@@ -13,6 +13,8 @@ import {
MessageSquare,
X,
UserCheck,
PauseCircle,
PlayCircle,
} from "lucide-react";
import { useMutation } from "@tanstack/react-query";
import { requestDiscussion } from "../../api/tests";
@@ -66,6 +68,9 @@ interface TestDetailHeaderProps {
onPauseTimer: () => void;
onResumeTimer: () => void;
isTogglingTimer: boolean;
onHold: () => void;
onResume: () => void;
isTogglingHold: boolean;
}
// ── Component ──────────────────────────────────────────────────────
@@ -83,6 +88,9 @@ export default function TestDetailHeader({
onPauseTimer,
onResumeTimer,
isTogglingTimer,
onHold,
onResume,
isTogglingHold,
}: TestDetailHeaderProps) {
const role = user?.role ?? "";
const currentIdx = STATE_INDEX[test.state];
@@ -116,9 +124,30 @@ export default function TestDetailHeader({
// ── Contextual action buttons ────────────────────────────────────
const HOLDABLE_STATES: TestState[] = ["draft", "red_executing", "blue_evaluating"];
const canHold =
HOLDABLE_STATES.includes(test.state) &&
(role === "red_tech" || role === "blue_tech" || role === "red_lead" || role === "blue_lead" || role === "admin");
const renderActions = () => {
const buttons: React.ReactNode[] = [];
// On Hold banner + Resume button (shown first when test is on hold)
if (test.is_on_hold && canHold) {
buttons.push(
<button
key="resume"
onClick={onResume}
disabled={isTogglingHold}
className="flex items-center gap-1.5 rounded-lg bg-green-700 px-4 py-2 text-sm font-medium text-white hover:bg-green-600 transition-colors disabled:opacity-50"
>
{isTogglingHold ? <Loader2 className="h-4 w-4 animate-spin" /> : <PlayCircle className="h-4 w-4" />}
Resume Test
</button>,
);
return <div className="flex flex-wrap items-center gap-2">{buttons}</div>;
}
// Red Team in draft -> Start Execution
if (
test.state === "draft" &&
@@ -327,6 +356,21 @@ export default function TestDetailHeader({
);
}
// On Hold button — appears alongside action buttons in pre-validation states
if (canHold && !test.is_on_hold) {
buttons.push(
<button
key="hold"
onClick={onHold}
disabled={isTogglingHold}
className="flex items-center gap-1.5 rounded-lg border border-amber-500/40 bg-amber-500/10 px-4 py-2 text-sm font-medium text-amber-400 hover:bg-amber-500/20 transition-colors disabled:opacity-50"
>
{isTogglingHold ? <Loader2 className="h-4 w-4 animate-spin" /> : <PauseCircle className="h-4 w-4" />}
On Hold
</button>,
);
}
return buttons.length > 0 ? (
<div className="flex flex-wrap items-center gap-2">{buttons}</div>
) : null;
@@ -469,6 +513,24 @@ export default function TestDetailHeader({
</div>
)}
{/* On Hold banner */}
{test.is_on_hold && (
<div className="flex items-start gap-3 rounded-xl border border-amber-500/40 bg-amber-500/8 p-4">
<PauseCircle className="mt-0.5 h-5 w-5 shrink-0 text-amber-400" />
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-amber-300">Test On Hold</p>
{test.hold_reason && (
<p className="mt-0.5 text-xs text-amber-400/80">
<span className="font-medium">Reason:</span> {test.hold_reason}
</p>
)}
<p className="mt-1 text-[10px] text-amber-400/60">
This test is paused. No action required until it is resumed.
</p>
</div>
</div>
)}
{/* Progress bar */}
{test.state !== "rejected" && (
<div className="pt-2">
+70
View File
@@ -17,6 +17,8 @@ import {
reopenTest,
pauseTimer,
resumeTimer,
holdTest,
resumeTest,
getTestTimeline,
getRetestChain,
} from "../api/tests";
@@ -48,6 +50,8 @@ export default function TestDetailPage() {
}>({ open: false, side: "red" });
const [confirmReopen, setConfirmReopen] = useState(false);
const [holdModal, setHoldModal] = useState(false);
const [holdReason, setHoldReason] = useState("");
const [redDraft, setRedDraft] = useState({
procedure_text: "",
@@ -257,6 +261,26 @@ export default function TestDetailPage() {
onError: (err: unknown) => showToast(extractError(err), "error"),
});
const holdMutation = useMutation({
mutationFn: (reason: string) => holdTest(testId!, reason),
onSuccess: () => {
invalidateAll();
setHoldModal(false);
setHoldReason("");
showToast("Test placed on hold", "success");
},
onError: (err: unknown) => showToast(extractError(err), "error"),
});
const resumeHoldMutation = useMutation({
mutationFn: () => resumeTest(testId!),
onSuccess: () => {
invalidateAll();
showToast("Test resumed", "success");
},
onError: (err: unknown) => showToast(extractError(err), "error"),
});
// Evidence upload
const uploadMutation = useMutation({
mutationFn: ({ file, team }: { file: File; team: TeamSide }) =>
@@ -394,6 +418,9 @@ export default function TestDetailPage() {
onPauseTimer={() => pauseTimerMutation.mutate()}
onResumeTimer={() => resumeTimerMutation.mutate()}
isTogglingTimer={pauseTimerMutation.isPending || resumeTimerMutation.isPending}
onHold={() => setHoldModal(true)}
onResume={() => resumeHoldMutation.mutate()}
isTogglingHold={holdMutation.isPending || resumeHoldMutation.isPending}
/>
{/* Content: Tabs + Sidebar */}
@@ -584,6 +611,49 @@ export default function TestDetailPage() {
</div>
</div>
{/* On Hold Modal */}
{holdModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="w-full max-w-md rounded-xl border border-amber-500/30 bg-gray-900 p-6 shadow-2xl">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-amber-300">Place Test On Hold</h2>
<button
onClick={() => { setHoldModal(false); setHoldReason(""); }}
className="text-gray-400 hover:text-white transition-colors"
>
<X className="h-5 w-5" />
</button>
</div>
<p className="mb-4 text-sm text-gray-400">
Provide a reason for placing this test on hold. This will be sent as a comment to the linked Jira ticket and the ticket will be transitioned to <span className="text-amber-400 font-medium">On Hold</span>.
</p>
<textarea
value={holdReason}
onChange={(e) => setHoldReason(e.target.value)}
placeholder="e.g. Assigned to another project until next sprint..."
rows={4}
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 placeholder-gray-500 focus:border-amber-500 focus:outline-none resize-none"
/>
<div className="mt-4 flex justify-end gap-3">
<button
onClick={() => { setHoldModal(false); setHoldReason(""); }}
className="rounded-lg border border-gray-700 px-4 py-2 text-sm text-gray-300 hover:border-gray-600 transition-colors"
>
Cancel
</button>
<button
onClick={() => holdMutation.mutate(holdReason)}
disabled={!holdReason.trim() || holdMutation.isPending}
className="flex items-center gap-1.5 rounded-lg bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{holdMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
Confirm On Hold
</button>
</div>
</div>
</div>
)}
{/* Confirm Reopen Dialog */}
<ConfirmDialog
open={confirmReopen}
+5
View File
@@ -114,6 +114,11 @@ export interface Test {
red_tech_assignee: string | null;
blue_tech_assignee: string | null;
// On-hold fields
is_on_hold: boolean;
hold_reason: string | null;
held_at: string | null;
// Re-test fields
retest_of: string | null;
retest_count: number;