Add issue workflow progress tracker
This commit is contained in:
@@ -258,6 +258,97 @@ def _stage_label_for_workflow(request_status: str, media_status: str) -> str:
|
|||||||
return "Approved"
|
return "Approved"
|
||||||
|
|
||||||
|
|
||||||
|
ISSUE_WORKFLOW_STAGES = (
|
||||||
|
("reported", "Reported"),
|
||||||
|
("review", "Under review"),
|
||||||
|
("planned", "Fix planned"),
|
||||||
|
("repair", "Fix underway"),
|
||||||
|
("confirmation", "Confirm fix"),
|
||||||
|
("resolved", "Resolved"),
|
||||||
|
)
|
||||||
|
|
||||||
|
ISSUE_STATUS_TO_STAGE: Dict[str, Tuple[int, str, str, str]] = {
|
||||||
|
"new": (
|
||||||
|
0,
|
||||||
|
"Issue received",
|
||||||
|
"Your report has been logged and is waiting for the support team to review it.",
|
||||||
|
"active",
|
||||||
|
),
|
||||||
|
"triaging": (
|
||||||
|
1,
|
||||||
|
"Being investigated",
|
||||||
|
"The support team is checking the report and identifying the right fix.",
|
||||||
|
"active",
|
||||||
|
),
|
||||||
|
"planned": (
|
||||||
|
2,
|
||||||
|
"Fix ready to begin",
|
||||||
|
"The problem has been reviewed and the next action has been selected.",
|
||||||
|
"active",
|
||||||
|
),
|
||||||
|
"in_progress": (
|
||||||
|
3,
|
||||||
|
"Fix in progress",
|
||||||
|
"Work is underway on the affected content or service.",
|
||||||
|
"active",
|
||||||
|
),
|
||||||
|
"blocked": (
|
||||||
|
3,
|
||||||
|
"Fix needs attention",
|
||||||
|
"Work has paused because the support team needs another service, resource, or decision before continuing.",
|
||||||
|
"attention",
|
||||||
|
),
|
||||||
|
"awaiting_confirmation": (
|
||||||
|
4,
|
||||||
|
"Waiting for confirmation",
|
||||||
|
"A fix has been applied. Magent is waiting for the reporter to confirm that the problem is gone.",
|
||||||
|
"active",
|
||||||
|
),
|
||||||
|
"done": (
|
||||||
|
5,
|
||||||
|
"Issue resolved",
|
||||||
|
"The reported problem has been fixed and the issue is complete.",
|
||||||
|
"complete",
|
||||||
|
),
|
||||||
|
"closed": (
|
||||||
|
5,
|
||||||
|
"Issue resolved",
|
||||||
|
"The reported problem has been fixed and the issue is closed.",
|
||||||
|
"complete",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _issue_workflow_payload(status: Any) -> Dict[str, Any]:
|
||||||
|
normalized_status = str(status or "new").strip().lower()
|
||||||
|
stage_index, headline, message, state = ISSUE_STATUS_TO_STAGE.get(
|
||||||
|
normalized_status,
|
||||||
|
ISSUE_STATUS_TO_STAGE["new"],
|
||||||
|
)
|
||||||
|
steps = []
|
||||||
|
for index, (key, label) in enumerate(ISSUE_WORKFLOW_STAGES):
|
||||||
|
step_state = (
|
||||||
|
"complete"
|
||||||
|
if index < stage_index or (index == stage_index and state == "complete")
|
||||||
|
else "active"
|
||||||
|
if index == stage_index
|
||||||
|
else "waiting"
|
||||||
|
)
|
||||||
|
if index == stage_index and state == "attention":
|
||||||
|
step_state = "attention"
|
||||||
|
steps.append({"key": key, "label": label, "state": step_state})
|
||||||
|
return {
|
||||||
|
"current_step": stage_index + 1,
|
||||||
|
"total_steps": len(ISSUE_WORKFLOW_STAGES),
|
||||||
|
"stage": ISSUE_WORKFLOW_STAGES[stage_index][0],
|
||||||
|
"stage_label": ISSUE_WORKFLOW_STAGES[stage_index][1],
|
||||||
|
"headline": headline,
|
||||||
|
"message": message,
|
||||||
|
"state": state,
|
||||||
|
"steps": steps,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _normalize_request_pipeline(
|
def _normalize_request_pipeline(
|
||||||
request_status: Optional[str],
|
request_status: Optional[str],
|
||||||
media_status: Optional[str],
|
media_status: Optional[str],
|
||||||
@@ -419,6 +510,7 @@ def _serialize_item(item: Dict[str, Any], user: Dict[str, Any]) -> Dict[str, Any
|
|||||||
"related_item_id": _normalize_int(item.get("related_item_id"), "related_item_id"),
|
"related_item_id": _normalize_int(item.get("related_item_id"), "related_item_id"),
|
||||||
"is_resolved": bool(_clean_text(item.get("issue_resolved_at"))),
|
"is_resolved": bool(_clean_text(item.get("issue_resolved_at"))),
|
||||||
"resolved_at": _clean_text(item.get("issue_resolved_at")),
|
"resolved_at": _clean_text(item.get("issue_resolved_at")),
|
||||||
|
"workflow": _issue_workflow_payload(item.get("status")),
|
||||||
"confirmation": {
|
"confirmation": {
|
||||||
"status": resolution.get("status"),
|
"status": resolution.get("status"),
|
||||||
"attempts_sent": int(resolution.get("attemptsSent") or 0),
|
"attempts_sent": int(resolution.get("attemptsSent") or 0),
|
||||||
|
|||||||
@@ -1592,6 +1592,29 @@ class PortalWorkflowTests(TempDatabaseMixin, unittest.TestCase):
|
|||||||
self.assertEqual(workflow.get("request_status"), "approved")
|
self.assertEqual(workflow.get("request_status"), "approved")
|
||||||
self.assertEqual(workflow.get("media_status"), "processing")
|
self.assertEqual(workflow.get("media_status"), "processing")
|
||||||
|
|
||||||
|
def test_issue_status_maps_to_public_workflow_progress(self) -> None:
|
||||||
|
item = {
|
||||||
|
"kind": "issue",
|
||||||
|
"status": "blocked",
|
||||||
|
"issue_type": "playback",
|
||||||
|
"created_by_username": "tester",
|
||||||
|
}
|
||||||
|
serialized = portal_router._serialize_item(item, {"username": "tester", "role": "user"})
|
||||||
|
workflow = (serialized.get("issue") or {}).get("workflow") or {}
|
||||||
|
|
||||||
|
self.assertEqual(workflow.get("current_step"), 4)
|
||||||
|
self.assertEqual(workflow.get("stage"), "repair")
|
||||||
|
self.assertEqual(workflow.get("state"), "attention")
|
||||||
|
self.assertEqual(len(workflow.get("steps") or []), 6)
|
||||||
|
self.assertEqual((workflow.get("steps") or [])[3].get("state"), "attention")
|
||||||
|
|
||||||
|
def test_resolved_issue_completes_the_public_workflow(self) -> None:
|
||||||
|
workflow = portal_router._issue_workflow_payload("closed")
|
||||||
|
|
||||||
|
self.assertEqual(workflow.get("current_step"), 6)
|
||||||
|
self.assertEqual(workflow.get("state"), "complete")
|
||||||
|
self.assertTrue(all(step.get("state") == "complete" for step in workflow.get("steps") or []))
|
||||||
|
|
||||||
def test_invalid_pipeline_transition_is_rejected(self) -> None:
|
def test_invalid_pipeline_transition_is_rejected(self) -> None:
|
||||||
with self.assertRaises(HTTPException) as context:
|
with self.assertRaises(HTTPException) as context:
|
||||||
portal_router._validate_pipeline_transition(
|
portal_router._validate_pipeline_transition(
|
||||||
|
|||||||
@@ -2657,6 +2657,58 @@ button:disabled,
|
|||||||
font-size: 0.62rem;
|
font-size: 0.62rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.issue-card-progress {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-card-progress > span {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-card-progress strong {
|
||||||
|
color: var(--ops-cyan);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-card-progress small {
|
||||||
|
color: var(--ops-muted);
|
||||||
|
font-family: "JetBrains Mono", Consolas, monospace;
|
||||||
|
font-size: 0.58rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-card-progress > i {
|
||||||
|
display: block;
|
||||||
|
height: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(126, 215, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-card-progress > i > b {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: linear-gradient(90deg, var(--ops-cyan), var(--request-green));
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-card-progress.is-attention strong {
|
||||||
|
color: var(--request-amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-card-progress.is-attention > i > b {
|
||||||
|
background: var(--request-amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-card-progress.is-complete strong {
|
||||||
|
color: var(--request-green);
|
||||||
|
}
|
||||||
|
|
||||||
.issue-modal-backdrop {
|
.issue-modal-backdrop {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 998;
|
z-index: 998;
|
||||||
@@ -2709,6 +2761,131 @@ button:disabled,
|
|||||||
gap: 2px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.issue-pipeline-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid rgba(126, 215, 255, 0.3);
|
||||||
|
border-radius: var(--ops-radius-lg);
|
||||||
|
background: linear-gradient(120deg, rgba(14, 165, 233, 0.07), rgba(126, 215, 255, 0.025));
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-pipeline-card.is-attention {
|
||||||
|
border-color: rgba(255, 192, 84, 0.42);
|
||||||
|
background: linear-gradient(120deg, rgba(255, 192, 84, 0.08), rgba(255, 192, 84, 0.025));
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-pipeline-card.is-complete {
|
||||||
|
border-color: rgba(72, 224, 178, 0.38);
|
||||||
|
background: linear-gradient(120deg, rgba(72, 224, 178, 0.075), rgba(72, 224, 178, 0.025));
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-pipeline-card > header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-pipeline-card > header > div {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-pipeline-card h3,
|
||||||
|
.issue-pipeline-card p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-pipeline-card p {
|
||||||
|
color: var(--ops-muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-pipeline-card > ol {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||||
|
gap: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-pipeline-card li {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
justify-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
min-width: 0;
|
||||||
|
color: var(--ops-muted);
|
||||||
|
font-size: 0.62rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-pipeline-card li::before {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 0;
|
||||||
|
top: 12px;
|
||||||
|
right: 50%;
|
||||||
|
left: -50%;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--ops-line-soft);
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-pipeline-card li:first-child::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-pipeline-card li > i {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: grid;
|
||||||
|
width: 25px;
|
||||||
|
height: 25px;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid var(--ops-line);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--ops-panel);
|
||||||
|
font-family: "JetBrains Mono", Consolas, monospace;
|
||||||
|
font-size: 0.58rem;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-pipeline-card li.is-complete,
|
||||||
|
.issue-pipeline-card li.is-active {
|
||||||
|
color: var(--ops-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-pipeline-card li.is-complete::before,
|
||||||
|
.issue-pipeline-card li.is-active::before,
|
||||||
|
.issue-pipeline-card li.is-attention::before {
|
||||||
|
background: var(--ops-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-pipeline-card li.is-complete > i {
|
||||||
|
border-color: var(--request-green);
|
||||||
|
color: #061813;
|
||||||
|
background: var(--request-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-pipeline-card li.is-active > i {
|
||||||
|
border-color: var(--ops-cyan);
|
||||||
|
color: var(--ops-cyan);
|
||||||
|
box-shadow: 0 0 14px rgba(126, 215, 255, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-pipeline-card li.is-attention {
|
||||||
|
color: var(--request-amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-pipeline-card li.is-attention > i {
|
||||||
|
border-color: var(--request-amber);
|
||||||
|
color: var(--request-amber);
|
||||||
|
box-shadow: 0 0 14px rgba(255, 192, 84, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
.issue-modal-toolbar strong {
|
.issue-modal-toolbar strong {
|
||||||
font-size: 0.86rem;
|
font-size: 0.86rem;
|
||||||
}
|
}
|
||||||
@@ -3608,4 +3785,8 @@ button:disabled,
|
|||||||
padding-right: 13px;
|
padding-right: 13px;
|
||||||
padding-left: 13px;
|
padding-left: 13px;
|
||||||
}
|
}
|
||||||
|
.issue-pipeline-card > header { flex-direction: column; }
|
||||||
|
.issue-pipeline-card > ol { grid-template-columns: 1fr; gap: 8px; }
|
||||||
|
.issue-pipeline-card li { grid-template-columns: 25px minmax(0, 1fr); align-items: center; justify-items: start; text-align: left; }
|
||||||
|
.issue-pipeline-card li::before { top: -9px; right: auto; left: 12px; width: 2px; height: 10px; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,20 @@ type PortalItem = {
|
|||||||
related_item_id?: number | null
|
related_item_id?: number | null
|
||||||
is_resolved?: boolean
|
is_resolved?: boolean
|
||||||
resolved_at?: string | null
|
resolved_at?: string | null
|
||||||
|
workflow?: {
|
||||||
|
current_step?: number
|
||||||
|
total_steps?: number
|
||||||
|
stage?: string
|
||||||
|
stage_label?: string
|
||||||
|
headline?: string
|
||||||
|
message?: string
|
||||||
|
state?: 'active' | 'attention' | 'complete'
|
||||||
|
steps?: Array<{
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
state: 'waiting' | 'active' | 'attention' | 'complete'
|
||||||
|
}>
|
||||||
|
}
|
||||||
confirmation?: {
|
confirmation?: {
|
||||||
status?: string | null
|
status?: string | null
|
||||||
attempts_sent?: number
|
attempts_sent?: number
|
||||||
@@ -345,6 +359,53 @@ const formatIssueStatus = (value?: string | null) => {
|
|||||||
return labels[String(value ?? '').toLowerCase()] ?? String(value ?? 'Unknown').replaceAll('_', ' ')
|
return labels[String(value ?? '').toLowerCase()] ?? String(value ?? 'Unknown').replaceAll('_', ' ')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function IssuePipeline({ item, compact = false }: { item: PortalItem; compact?: boolean }) {
|
||||||
|
const workflow = item.issue?.workflow
|
||||||
|
const steps = workflow?.steps ?? []
|
||||||
|
const currentStep = workflow?.current_step ?? 1
|
||||||
|
const totalSteps = workflow?.total_steps ?? 6
|
||||||
|
const state = workflow?.state ?? 'active'
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
|
return (
|
||||||
|
<div className={`issue-card-progress is-${state}`}>
|
||||||
|
<span>
|
||||||
|
<strong>{workflow?.stage_label ?? formatIssueStatus(item.status)}</strong>
|
||||||
|
<small>Step {currentStep} of {totalSteps}</small>
|
||||||
|
</span>
|
||||||
|
<i aria-hidden="true">
|
||||||
|
<b style={{ width: `${Math.max(0, Math.min(100, (currentStep / totalSteps) * 100))}%` }} />
|
||||||
|
</i>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className={`issue-pipeline-card is-${state}`} aria-label="Issue progress">
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<span className="section-kicker">Issue progress</span>
|
||||||
|
<h3>{workflow?.headline ?? formatIssueStatus(item.status)}</h3>
|
||||||
|
<p>{workflow?.message ?? 'The support team will update this issue as work progresses.'}</p>
|
||||||
|
</div>
|
||||||
|
<span className="small-pill">Step {currentStep} of {totalSteps}</span>
|
||||||
|
</header>
|
||||||
|
<ol>
|
||||||
|
{steps.map((step, index) => (
|
||||||
|
<li
|
||||||
|
key={step.key}
|
||||||
|
className={`is-${step.state}`}
|
||||||
|
aria-current={step.state === 'active' || step.state === 'attention' ? 'step' : undefined}
|
||||||
|
>
|
||||||
|
<i aria-hidden="true">{step.state === 'complete' ? '✓' : index + 1}</i>
|
||||||
|
<span>{step.label}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const toPositiveInt = (value: string) => {
|
const toPositiveInt = (value: string) => {
|
||||||
const parsed = Number.parseInt(value, 10)
|
const parsed = Number.parseInt(value, 10)
|
||||||
if (Number.isNaN(parsed) || parsed <= 0) return null
|
if (Number.isNaN(parsed) || parsed <= 0) return null
|
||||||
@@ -1999,14 +2060,12 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
|||||||
<span className="small-pill is-muted">{item.priority}</span>
|
<span className="small-pill is-muted">{item.priority}</span>
|
||||||
</div>
|
</div>
|
||||||
<p>{item.description}</p>
|
<p>{item.description}</p>
|
||||||
|
{item.kind === 'issue' ? <IssuePipeline item={item} compact /> : null}
|
||||||
<div className="portal-item-row-meta">
|
<div className="portal-item-row-meta">
|
||||||
<span>#{item.id}</span>
|
<span>#{item.id}</span>
|
||||||
<span>
|
{item.kind === 'request' ? (
|
||||||
Status:{' '}
|
<span>Status: {item.workflow?.stage_label ?? item.status}</span>
|
||||||
{item.kind === 'request'
|
) : null}
|
||||||
? item.workflow?.stage_label ?? item.status
|
|
||||||
: formatIssueStatus(item.status)}
|
|
||||||
</span>
|
|
||||||
{isAdmin ? <span>By: {item.created_by_username}</span> : null}
|
{isAdmin ? <span>By: {item.created_by_username}</span> : null}
|
||||||
<span>Updated: {formatDate(item.last_activity_at)}</span>
|
<span>Updated: {formatDate(item.last_activity_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -2086,6 +2145,8 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{selectedItem.kind === 'issue' ? <IssuePipeline item={selectedItem} /> : null}
|
||||||
|
|
||||||
{selectedItem.kind === 'issue' && selectedItem.external_ref?.startsWith('/requests/') ? (
|
{selectedItem.kind === 'issue' && selectedItem.external_ref?.startsWith('/requests/') ? (
|
||||||
<div className="issue-linked-request">
|
<div className="issue-linked-request">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
Reference in New Issue
Block a user