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"]})
|
json={"task_ids": ["WRONG-001"]})
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
assert r.json()["count"] == 0
|
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
|
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")
|
@app.get("/api/tasks/{task_id}/pipeline")
|
||||||
def get_task_pipeline(task_id: str):
|
def get_task_pipeline(task_id: str):
|
||||||
"""Get agent_logs for a task (pipeline steps)."""
|
"""Get agent_logs for a task (pipeline steps)."""
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,16 @@ async function get<T>(path: string): Promise<T> {
|
||||||
return res.json()
|
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> {
|
async function post<T>(path: string, body: unknown): Promise<T> {
|
||||||
const res = await fetch(`${BASE}${path}`, {
|
const res = await fetch(`${BASE}${path}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
@ -148,4 +158,6 @@ export const api = {
|
||||||
post<AuditResult>(`/projects/${projectId}/audit`, {}),
|
post<AuditResult>(`/projects/${projectId}/audit`, {}),
|
||||||
auditApply: (projectId: string, taskIds: string[]) =>
|
auditApply: (projectId: string, taskIds: string[]) =>
|
||||||
post<{ updated: string[]; count: number }>(`/projects/${projectId}/audit/apply`, { task_ids: taskIds }),
|
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 hasSteps = computed(() => (task.value?.pipeline_steps?.length ?? 0) > 0)
|
||||||
const isRunning = computed(() => task.value?.status === 'in_progress')
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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>
|
<h1 class="text-xl font-bold text-gray-100">{{ task.id }}</h1>
|
||||||
<span class="text-gray-400">{{ task.title }}</span>
|
<span class="text-gray-400">{{ task.title }}</span>
|
||||||
<Badge :text="task.status" :color="statusColor(task.status)" />
|
<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 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>
|
<span class="text-xs text-gray-600">pri {{ task.priority }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue