feat: status dropdown on task detail page
This commit is contained in:
parent
9cbb3cec37
commit
6e872121eb
4 changed files with 102 additions and 0 deletions
|
|
@ -233,3 +233,44 @@ def test_audit_apply_wrong_project(client):
|
|||
json={"task_ids": ["WRONG-001"]})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["count"] == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PATCH /api/tasks/{task_id} — смена статуса
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_patch_task_status(client):
|
||||
"""PATCH должен обновить статус и вернуть задачу."""
|
||||
r = client.patch("/api/tasks/P1-001", json={"status": "review"})
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["status"] == "review"
|
||||
assert data["id"] == "P1-001"
|
||||
|
||||
|
||||
def test_patch_task_status_persisted(client):
|
||||
"""После PATCH повторный GET должен возвращать новый статус."""
|
||||
client.patch("/api/tasks/P1-001", json={"status": "blocked"})
|
||||
r = client.get("/api/tasks/P1-001")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["status"] == "blocked"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", ["pending", "in_progress", "review", "done", "blocked"])
|
||||
def test_patch_task_all_valid_statuses(client, status):
|
||||
"""Все 5 допустимых статусов должны приниматься."""
|
||||
r = client.patch("/api/tasks/P1-001", json={"status": status})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["status"] == status
|
||||
|
||||
|
||||
def test_patch_task_invalid_status(client):
|
||||
"""Недопустимый статус → 400."""
|
||||
r = client.patch("/api/tasks/P1-001", json={"status": "flying"})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_patch_task_not_found(client):
|
||||
"""Несуществующая задача → 404."""
|
||||
r = client.patch("/api/tasks/NOPE-999", json={"status": "done"})
|
||||
assert r.status_code == 404
|
||||
|
|
|
|||
22
web/api.py
22
web/api.py
|
|
@ -137,6 +137,28 @@ def create_task(body: TaskCreate):
|
|||
return t
|
||||
|
||||
|
||||
class TaskPatch(BaseModel):
|
||||
status: str
|
||||
|
||||
|
||||
VALID_STATUSES = {"pending", "in_progress", "review", "done", "blocked"}
|
||||
|
||||
|
||||
@app.patch("/api/tasks/{task_id}")
|
||||
def patch_task(task_id: str, body: TaskPatch):
|
||||
if body.status not in VALID_STATUSES:
|
||||
raise HTTPException(400, f"Invalid status '{body.status}'. Must be one of: {', '.join(VALID_STATUSES)}")
|
||||
conn = get_conn()
|
||||
t = models.get_task(conn, task_id)
|
||||
if not t:
|
||||
conn.close()
|
||||
raise HTTPException(404, f"Task '{task_id}' not found")
|
||||
models.update_task(conn, task_id, status=body.status)
|
||||
t = models.get_task(conn, task_id)
|
||||
conn.close()
|
||||
return t
|
||||
|
||||
|
||||
@app.get("/api/tasks/{task_id}/pipeline")
|
||||
def get_task_pipeline(task_id: str):
|
||||
"""Get agent_logs for a task (pipeline steps)."""
|
||||
|
|
|
|||
|
|
@ -6,6 +6,16 @@ async function get<T>(path: string): Promise<T> {
|
|||
return res.json()
|
||||
}
|
||||
|
||||
async function patch<T>(path: string, body: unknown): Promise<T> {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
async function post<T>(path: string, body: unknown): Promise<T> {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
method: 'POST',
|
||||
|
|
@ -148,4 +158,6 @@ export const api = {
|
|||
post<AuditResult>(`/projects/${projectId}/audit`, {}),
|
||||
auditApply: (projectId: string, taskIds: string[]) =>
|
||||
post<{ updated: string[]; count: number }>(`/projects/${projectId}/audit/apply`, { task_ids: taskIds }),
|
||||
patchTask: (id: string, data: { status: string }) =>
|
||||
patch<Task>(`/tasks/${id}`, data),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -185,6 +185,21 @@ async function runPipeline() {
|
|||
|
||||
const hasSteps = computed(() => (task.value?.pipeline_steps?.length ?? 0) > 0)
|
||||
const isRunning = computed(() => task.value?.status === 'in_progress')
|
||||
|
||||
const statusChanging = ref(false)
|
||||
|
||||
async function changeStatus(newStatus: string) {
|
||||
if (!task.value || newStatus === task.value.status) return
|
||||
statusChanging.value = true
|
||||
try {
|
||||
const updated = await api.patchTask(props.id, { status: newStatus })
|
||||
task.value = { ...task.value, ...updated }
|
||||
} catch (e: any) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
statusChanging.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -202,6 +217,18 @@ const isRunning = computed(() => task.value?.status === 'in_progress')
|
|||
<h1 class="text-xl font-bold text-gray-100">{{ task.id }}</h1>
|
||||
<span class="text-gray-400">{{ task.title }}</span>
|
||||
<Badge :text="task.status" :color="statusColor(task.status)" />
|
||||
<select
|
||||
:value="task.status"
|
||||
@change="changeStatus(($event.target as HTMLSelectElement).value)"
|
||||
:disabled="statusChanging"
|
||||
class="text-xs bg-gray-800 border border-gray-700 text-gray-300 rounded px-2 py-0.5 disabled:opacity-50"
|
||||
>
|
||||
<option value="pending">pending</option>
|
||||
<option value="in_progress">in_progress</option>
|
||||
<option value="review">review</option>
|
||||
<option value="done">done</option>
|
||||
<option value="blocked">blocked</option>
|
||||
</select>
|
||||
<span v-if="isRunning" class="inline-block w-2 h-2 bg-blue-500 rounded-full animate-pulse"></span>
|
||||
<span class="text-xs text-gray-600">pri {{ task.priority }}</span>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue