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
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:
@@ -0,0 +1,25 @@
|
|||||||
|
"""Add is_on_hold, hold_reason, held_at to tests.
|
||||||
|
|
||||||
|
Revision ID: b051
|
||||||
|
Revises: b050
|
||||||
|
Create Date: 2026-06-19
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "b051"
|
||||||
|
down_revision = "b050"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("tests", sa.Column("is_on_hold", sa.Boolean(), nullable=False, server_default="false"))
|
||||||
|
op.add_column("tests", sa.Column("hold_reason", sa.Text(), nullable=True))
|
||||||
|
op.add_column("tests", sa.Column("held_at", sa.DateTime(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("tests", "held_at")
|
||||||
|
op.drop_column("tests", "hold_reason")
|
||||||
|
op.drop_column("tests", "is_on_hold")
|
||||||
@@ -134,6 +134,11 @@ class Test(Base):
|
|||||||
red_tech_assignee = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
red_tech_assignee = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||||
blue_tech_assignee = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
blue_tech_assignee = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||||
|
|
||||||
|
# ── On-hold fields ─────────────────────────────────────────────
|
||||||
|
is_on_hold = Column(Boolean, default=False, nullable=False, server_default="false")
|
||||||
|
hold_reason = Column(Text, nullable=True)
|
||||||
|
held_at = Column(DateTime, nullable=True)
|
||||||
|
|
||||||
red_tech_assigned_user = relationship("User", foreign_keys=[red_tech_assignee])
|
red_tech_assigned_user = relationship("User", foreign_keys=[red_tech_assignee])
|
||||||
blue_tech_assigned_user = relationship("User", foreign_keys=[blue_tech_assignee])
|
blue_tech_assigned_user = relationship("User", foreign_keys=[blue_tech_assignee])
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ from app.schemas.test import (
|
|||||||
TestBlueValidate,
|
TestBlueValidate,
|
||||||
TestClassificationUpdate,
|
TestClassificationUpdate,
|
||||||
TestCreate,
|
TestCreate,
|
||||||
|
TestHold,
|
||||||
TestOut,
|
TestOut,
|
||||||
TestRedUpdate,
|
TestRedUpdate,
|
||||||
TestRedValidate,
|
TestRedValidate,
|
||||||
@@ -1120,6 +1121,79 @@ def assign_test_operators(
|
|||||||
return test
|
return test
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /tests/{id}/hold — place a test on hold (red/blue techs and leads)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{test_id}/hold", response_model=TestOut)
|
||||||
|
def hold_test(
|
||||||
|
test_id: uuid.UUID,
|
||||||
|
payload: TestHold,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_any_role("red_tech", "blue_tech", "red_lead", "blue_lead", "admin")),
|
||||||
|
):
|
||||||
|
"""Place a test on hold with a mandatory reason. Posts comment + transitions Jira."""
|
||||||
|
from datetime import datetime as _dt
|
||||||
|
from app.services.jira_service import push_hold_event
|
||||||
|
|
||||||
|
test = crud_get_test_or_raise(db, test_id)
|
||||||
|
|
||||||
|
HOLDABLE_STATES = ("draft", "red_executing", "blue_evaluating")
|
||||||
|
if test.state not in HOLDABLE_STATES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Cannot hold a test in state '{test.state}'. Only pre-validation states can be held.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if test.is_on_hold:
|
||||||
|
raise HTTPException(status_code=400, detail="Test is already on hold")
|
||||||
|
|
||||||
|
test.is_on_hold = True
|
||||||
|
test.hold_reason = payload.reason
|
||||||
|
test.held_at = _dt.utcnow()
|
||||||
|
|
||||||
|
log_action(db, current_user.id, "hold_test", str(test_id), {"reason": payload.reason})
|
||||||
|
db.commit()
|
||||||
|
db.refresh(test)
|
||||||
|
|
||||||
|
push_hold_event(db, test, current_user, resuming=False, reason=payload.reason)
|
||||||
|
|
||||||
|
return test
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /tests/{id}/resume — resume a test that was on hold
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{test_id}/resume", response_model=TestOut)
|
||||||
|
def resume_test(
|
||||||
|
test_id: uuid.UUID,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_any_role("red_tech", "blue_tech", "red_lead", "blue_lead", "admin")),
|
||||||
|
):
|
||||||
|
"""Resume a test that was placed on hold."""
|
||||||
|
from app.services.jira_service import push_hold_event
|
||||||
|
|
||||||
|
test = crud_get_test_or_raise(db, test_id)
|
||||||
|
|
||||||
|
if not test.is_on_hold:
|
||||||
|
raise HTTPException(status_code=400, detail="Test is not on hold")
|
||||||
|
|
||||||
|
test.is_on_hold = False
|
||||||
|
test.hold_reason = None
|
||||||
|
test.held_at = None
|
||||||
|
|
||||||
|
log_action(db, current_user.id, "resume_test", str(test_id), {})
|
||||||
|
db.commit()
|
||||||
|
db.refresh(test)
|
||||||
|
|
||||||
|
push_hold_event(db, test, current_user, resuming=True)
|
||||||
|
|
||||||
|
return test
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# PATCH /tests/{id}/remediation — update remediation fields
|
# PATCH /tests/{id}/remediation — update remediation fields
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -141,6 +141,12 @@ class TestAssign(BaseModel):
|
|||||||
blue_tech_assignee: uuid.UUID | None = None
|
blue_tech_assignee: uuid.UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TestHold(BaseModel):
|
||||||
|
"""Payload for placing a test on hold."""
|
||||||
|
|
||||||
|
reason: str
|
||||||
|
|
||||||
|
|
||||||
# ── Legacy validate (kept for backwards compat) ────────────────────
|
# ── Legacy validate (kept for backwards compat) ────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -232,6 +238,11 @@ class TestOut(BaseModel):
|
|||||||
red_tech_assignee: uuid.UUID | None = None
|
red_tech_assignee: uuid.UUID | None = None
|
||||||
blue_tech_assignee: uuid.UUID | None = None
|
blue_tech_assignee: uuid.UUID | None = None
|
||||||
|
|
||||||
|
# On-hold fields
|
||||||
|
is_on_hold: bool = False
|
||||||
|
hold_reason: str | None = None
|
||||||
|
held_at: datetime | None = None
|
||||||
|
|
||||||
# Re-test fields
|
# Re-test fields
|
||||||
retest_of: uuid.UUID | None = None
|
retest_of: uuid.UUID | None = None
|
||||||
# Assign retest_count = 0
|
# Assign retest_count = 0
|
||||||
|
|||||||
@@ -633,6 +633,73 @@ def push_test_event(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# On-hold Jira notification
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def push_hold_event(
|
||||||
|
db: Session,
|
||||||
|
test,
|
||||||
|
actor,
|
||||||
|
*,
|
||||||
|
resuming: bool = False,
|
||||||
|
reason: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Post an on-hold / resume comment to the Jira issue linked to *test*.
|
||||||
|
|
||||||
|
Non-fatal — any Jira error is logged and swallowed.
|
||||||
|
"""
|
||||||
|
if not has_jira_configured(actor, db):
|
||||||
|
return
|
||||||
|
|
||||||
|
link = (
|
||||||
|
db.query(JiraLink)
|
||||||
|
.filter(
|
||||||
|
JiraLink.entity_type == JiraLinkEntityType.test,
|
||||||
|
JiraLink.entity_id == test.id,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not link:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
jira = get_user_jira_client(actor, db)
|
||||||
|
ts = datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC")
|
||||||
|
|
||||||
|
if resuming:
|
||||||
|
comment = (
|
||||||
|
f"h3. ▶ Test Resumed\n\n"
|
||||||
|
f"*Resumed by:* {actor.username}\n"
|
||||||
|
f"*At:* {ts}\n\n"
|
||||||
|
f"_The test has been taken off hold and is active again._"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
jira.set_issue_status(link.jira_issue_key, "In Progress")
|
||||||
|
except Exception as exc_t:
|
||||||
|
logger.warning("Could not transition %s off hold: %s", link.jira_issue_key, exc_t)
|
||||||
|
else:
|
||||||
|
comment = (
|
||||||
|
f"h3. ⏸ Test Placed On Hold\n\n"
|
||||||
|
f"*Placed on hold by:* {actor.username}\n"
|
||||||
|
f"*At:* {ts}\n"
|
||||||
|
f"*Reason:* {reason or 'No reason provided'}\n\n"
|
||||||
|
f"_This test has been paused. No action required until it is resumed._"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
jira.set_issue_status(link.jira_issue_key, "On Hold")
|
||||||
|
except Exception as exc_t:
|
||||||
|
logger.warning("Could not transition %s to On Hold: %s", link.jira_issue_key, exc_t)
|
||||||
|
|
||||||
|
jira.issue_add_comment(link.jira_issue_key, comment)
|
||||||
|
link.last_synced_at = datetime.utcnow()
|
||||||
|
db.flush()
|
||||||
|
logger.info("Posted hold event to Jira %s (resuming=%s)", link.jira_issue_key, resuming)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to push hold event for test %s: %s", test.id, exc, exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Legacy / generic helpers (kept for existing routes)
|
# Legacy / generic helpers (kept for existing routes)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -236,6 +236,20 @@ export async function assignTest(
|
|||||||
return data;
|
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 ───────────────────────────────────────────────────────
|
// ── Timeline ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Get the audit-log timeline for a test. */
|
/** Get the audit-log timeline for a test. */
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import {
|
|||||||
MessageSquare,
|
MessageSquare,
|
||||||
X,
|
X,
|
||||||
UserCheck,
|
UserCheck,
|
||||||
|
PauseCircle,
|
||||||
|
PlayCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { requestDiscussion } from "../../api/tests";
|
import { requestDiscussion } from "../../api/tests";
|
||||||
@@ -66,6 +68,9 @@ interface TestDetailHeaderProps {
|
|||||||
onPauseTimer: () => void;
|
onPauseTimer: () => void;
|
||||||
onResumeTimer: () => void;
|
onResumeTimer: () => void;
|
||||||
isTogglingTimer: boolean;
|
isTogglingTimer: boolean;
|
||||||
|
onHold: () => void;
|
||||||
|
onResume: () => void;
|
||||||
|
isTogglingHold: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Component ──────────────────────────────────────────────────────
|
// ── Component ──────────────────────────────────────────────────────
|
||||||
@@ -83,6 +88,9 @@ export default function TestDetailHeader({
|
|||||||
onPauseTimer,
|
onPauseTimer,
|
||||||
onResumeTimer,
|
onResumeTimer,
|
||||||
isTogglingTimer,
|
isTogglingTimer,
|
||||||
|
onHold,
|
||||||
|
onResume,
|
||||||
|
isTogglingHold,
|
||||||
}: TestDetailHeaderProps) {
|
}: TestDetailHeaderProps) {
|
||||||
const role = user?.role ?? "";
|
const role = user?.role ?? "";
|
||||||
const currentIdx = STATE_INDEX[test.state];
|
const currentIdx = STATE_INDEX[test.state];
|
||||||
@@ -116,9 +124,30 @@ export default function TestDetailHeader({
|
|||||||
|
|
||||||
// ── Contextual action buttons ────────────────────────────────────
|
// ── 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 renderActions = () => {
|
||||||
const buttons: React.ReactNode[] = [];
|
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
|
// Red Team in draft -> Start Execution
|
||||||
if (
|
if (
|
||||||
test.state === "draft" &&
|
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 ? (
|
return buttons.length > 0 ? (
|
||||||
<div className="flex flex-wrap items-center gap-2">{buttons}</div>
|
<div className="flex flex-wrap items-center gap-2">{buttons}</div>
|
||||||
) : null;
|
) : null;
|
||||||
@@ -469,6 +513,24 @@ export default function TestDetailHeader({
|
|||||||
</div>
|
</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 */}
|
{/* Progress bar */}
|
||||||
{test.state !== "rejected" && (
|
{test.state !== "rejected" && (
|
||||||
<div className="pt-2">
|
<div className="pt-2">
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import {
|
|||||||
reopenTest,
|
reopenTest,
|
||||||
pauseTimer,
|
pauseTimer,
|
||||||
resumeTimer,
|
resumeTimer,
|
||||||
|
holdTest,
|
||||||
|
resumeTest,
|
||||||
getTestTimeline,
|
getTestTimeline,
|
||||||
getRetestChain,
|
getRetestChain,
|
||||||
} from "../api/tests";
|
} from "../api/tests";
|
||||||
@@ -48,6 +50,8 @@ export default function TestDetailPage() {
|
|||||||
}>({ open: false, side: "red" });
|
}>({ open: false, side: "red" });
|
||||||
|
|
||||||
const [confirmReopen, setConfirmReopen] = useState(false);
|
const [confirmReopen, setConfirmReopen] = useState(false);
|
||||||
|
const [holdModal, setHoldModal] = useState(false);
|
||||||
|
const [holdReason, setHoldReason] = useState("");
|
||||||
|
|
||||||
const [redDraft, setRedDraft] = useState({
|
const [redDraft, setRedDraft] = useState({
|
||||||
procedure_text: "",
|
procedure_text: "",
|
||||||
@@ -257,6 +261,26 @@ export default function TestDetailPage() {
|
|||||||
onError: (err: unknown) => showToast(extractError(err), "error"),
|
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
|
// Evidence upload
|
||||||
const uploadMutation = useMutation({
|
const uploadMutation = useMutation({
|
||||||
mutationFn: ({ file, team }: { file: File; team: TeamSide }) =>
|
mutationFn: ({ file, team }: { file: File; team: TeamSide }) =>
|
||||||
@@ -394,6 +418,9 @@ export default function TestDetailPage() {
|
|||||||
onPauseTimer={() => pauseTimerMutation.mutate()}
|
onPauseTimer={() => pauseTimerMutation.mutate()}
|
||||||
onResumeTimer={() => resumeTimerMutation.mutate()}
|
onResumeTimer={() => resumeTimerMutation.mutate()}
|
||||||
isTogglingTimer={pauseTimerMutation.isPending || resumeTimerMutation.isPending}
|
isTogglingTimer={pauseTimerMutation.isPending || resumeTimerMutation.isPending}
|
||||||
|
onHold={() => setHoldModal(true)}
|
||||||
|
onResume={() => resumeHoldMutation.mutate()}
|
||||||
|
isTogglingHold={holdMutation.isPending || resumeHoldMutation.isPending}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Content: Tabs + Sidebar */}
|
{/* Content: Tabs + Sidebar */}
|
||||||
@@ -584,6 +611,49 @@ export default function TestDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Confirm Reopen Dialog */}
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={confirmReopen}
|
open={confirmReopen}
|
||||||
|
|||||||
@@ -114,6 +114,11 @@ export interface Test {
|
|||||||
red_tech_assignee: string | null;
|
red_tech_assignee: string | null;
|
||||||
blue_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
|
// Re-test fields
|
||||||
retest_of: string | null;
|
retest_of: string | null;
|
||||||
retest_count: number;
|
retest_count: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user