research sidebar: delete-with-confirm per topic
Mirror the row-level delete affordance the loops sidebar already
has. Loops was wired earlier; research had a bare title-only card
with no way to remove a stale topic.
- cm-db: research_topics::delete cascades via existing FK rules
(research_topic_agents, research_publish_approvals, and the new
research_outcomes all CASCADE on topic_id; topology_runs's
research_topic_id back-ref is SET NULL so historical runs stay).
- cm-api: DELETE /api/research/{id} → 204. Idempotent.
- Frontend: deleteTopic helper. ResearchList row is now a card
with the existing title/status/outcome header plus a trash icon
that flips the card into an inline "Delete topic + all outcomes?"
confirm strip. Confirm → red Delete / gray Cancel. If the
deleted topic was selected, selection clears; local counter
bumps the list refetch without waiting on a parent.
This commit is contained in:
+15
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "DELETE FROM research_topics WHERE id = $1 AND workspace_id = $2",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "60c65d67613db839401ebab66eb28710e292d7bec1debef316634ef735bc48ff"
|
||||||
|
}
|
||||||
@@ -385,7 +385,9 @@ pub fn router(state: AppState) -> Router {
|
|||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/research/{id}",
|
"/api/research/{id}",
|
||||||
get(routes::research::get_topic).patch(routes::research::patch_topic),
|
get(routes::research::get_topic)
|
||||||
|
.patch(routes::research::patch_topic)
|
||||||
|
.delete(routes::research::delete_topic),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/research/{id}/agents",
|
"/api/research/{id}/agents",
|
||||||
|
|||||||
@@ -324,6 +324,20 @@ pub async fn attach_agent(
|
|||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `DELETE /api/research/:id` — hard-delete a topic and cascade every
|
||||||
|
/// dependent row. FK cascades on research_topic_agents,
|
||||||
|
/// research_publish_approvals, and research_outcomes; topology_runs's
|
||||||
|
/// research_topic_id back-ref is SET NULL so historical runs survive.
|
||||||
|
/// Returns 204 whether the topic existed or not (idempotent).
|
||||||
|
pub async fn delete_topic(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Authed(user): Authed,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
cm_db::repo::research_topics::delete(&state.pool, id, user.workspace_id.as_uuid()).await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn detach_agent(
|
pub async fn detach_agent(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Authed(user): Authed,
|
Authed(user): Authed,
|
||||||
|
|||||||
@@ -98,6 +98,21 @@ pub async fn get(
|
|||||||
Ok(row)
|
Ok(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Hard-delete a topic and cascade every dependent row. FK cascades on
|
||||||
|
/// research_topic_agents, research_publish_approvals, and research_outcomes
|
||||||
|
/// clean themselves up; topology_runs.research_topic_id is SET NULL so
|
||||||
|
/// historical runs survive with the back-ref cleared.
|
||||||
|
pub async fn delete(pool: &PgPool, id: Uuid, workspace_id: Uuid) -> Result<(), DbError> {
|
||||||
|
sqlx::query!(
|
||||||
|
"DELETE FROM research_topics WHERE id = $1 AND workspace_id = $2",
|
||||||
|
id,
|
||||||
|
workspace_id,
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Non-status fields; the state machine transitions are their own endpoints.
|
/// Non-status fields; the state machine transitions are their own endpoints.
|
||||||
pub async fn update_fields(
|
pub async fn update_fields(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
// Sidebar for the Research tier — real topic list, +New opens the wizard.
|
// Sidebar for the Research tier — real topic list, +New opens the wizard.
|
||||||
|
// Each row also carries a delete affordance with inline confirm; delete is
|
||||||
|
// hard on the backend (research_topics DELETE cascades to agents/approvals/
|
||||||
|
// outcomes; topology_runs.research_topic_id is SET NULL).
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Plus } from "lucide-react";
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
import type { Agent } from "@/lib/api/schemas";
|
import type { Agent } from "@/lib/api/schemas";
|
||||||
import {
|
import {
|
||||||
|
deleteTopic,
|
||||||
listTopics,
|
listTopics,
|
||||||
type TopicListItem,
|
type TopicListItem,
|
||||||
type TopicStatus,
|
type TopicStatus,
|
||||||
@@ -42,6 +46,10 @@ export function ResearchList({
|
|||||||
const [topics, setTopics] = useState<TopicListItem[]>([]);
|
const [topics, setTopics] = useState<TopicListItem[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [wizardOpen, setWizardOpen] = useState(false);
|
const [wizardOpen, setWizardOpen] = useState(false);
|
||||||
|
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
||||||
|
const [busyId, setBusyId] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [localBump, setLocalBump] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
@@ -60,7 +68,23 @@ export function ResearchList({
|
|||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
}, [refreshKey]);
|
}, [refreshKey, localBump]);
|
||||||
|
|
||||||
|
async function doDelete(id: string) {
|
||||||
|
if (busyId) return;
|
||||||
|
setBusyId(id);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await deleteTopic(id);
|
||||||
|
setConfirmDeleteId(null);
|
||||||
|
if (selectedId === id) onSelect("");
|
||||||
|
setLocalBump((n) => n + 1);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "delete failed");
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -156,25 +180,32 @@ export function ResearchList({
|
|||||||
topics.map((t) => {
|
topics.map((t) => {
|
||||||
const on = t.id === selectedId;
|
const on = t.id === selectedId;
|
||||||
const dot = STATUS_COLOR[t.status];
|
const dot = STATUS_COLOR[t.status];
|
||||||
|
const confirming = confirmDeleteId === t.id;
|
||||||
return (
|
return (
|
||||||
<button
|
<div
|
||||||
key={t.id}
|
key={t.id}
|
||||||
type="button"
|
|
||||||
onClick={() => onSelect(t.id)}
|
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
|
||||||
textAlign: "left",
|
|
||||||
padding: "10px 12px",
|
padding: "10px 12px",
|
||||||
marginBottom: 4,
|
marginBottom: 4,
|
||||||
borderRadius: 9,
|
borderRadius: 9,
|
||||||
border: `1px solid ${on ? "rgba(255,111,97,.5)" : "rgba(255,255,255,.06)"}`,
|
border: `1px solid ${on ? "rgba(255,111,97,.5)" : "rgba(255,255,255,.06)"}`,
|
||||||
background: on ? "rgba(255,111,97,.08)" : "#101014",
|
background: on ? "rgba(255,111,97,.08)" : "#101014",
|
||||||
color: "#eaeaee",
|
|
||||||
cursor: "pointer",
|
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
gap: 6,
|
gap: 6,
|
||||||
}}
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(t.id)}
|
||||||
|
style={{
|
||||||
|
all: "unset",
|
||||||
|
cursor: "pointer",
|
||||||
|
color: "#eaeaee",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 4,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -210,9 +241,94 @@ export function ResearchList({
|
|||||||
<span>{t.outcome_kind.replace("_", " ")}</span>
|
<span>{t.outcome_kind.replace("_", " ")}</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{confirming ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
marginTop: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: mono,
|
||||||
|
fontSize: 10.5,
|
||||||
|
color: "#ff8a7a",
|
||||||
|
flex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete topic + all outcomes?
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => doDelete(t.id)}
|
||||||
|
disabled={busyId === t.id}
|
||||||
|
style={{
|
||||||
|
...dangerBtn,
|
||||||
|
opacity: busyId === t.id ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{busyId === t.id ? "…" : "Delete"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setConfirmDeleteId(null)}
|
||||||
|
style={ghostBtn}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 4,
|
||||||
|
marginTop: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Delete topic"
|
||||||
|
aria-label="Delete topic"
|
||||||
|
onClick={() => setConfirmDeleteId(t.id)}
|
||||||
|
disabled={busyId === t.id}
|
||||||
|
style={{
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
borderRadius: 6,
|
||||||
|
border: "1px solid rgba(255,255,255,.08)",
|
||||||
|
background: "transparent",
|
||||||
|
color: "#ff8a7a",
|
||||||
|
cursor: busyId === t.id ? "not-allowed" : "pointer",
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
opacity: busyId === t.id ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 aria-hidden size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
|
{error ? (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontFamily: mono,
|
||||||
|
fontSize: 11,
|
||||||
|
color: "#ff8a7a",
|
||||||
|
padding: "8px 12px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{wizardOpen && (
|
{wizardOpen && (
|
||||||
<ResearchWizard
|
<ResearchWizard
|
||||||
@@ -227,3 +343,24 @@ export function ResearchList({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const dangerBtn: React.CSSProperties = {
|
||||||
|
padding: "4px 10px",
|
||||||
|
borderRadius: 6,
|
||||||
|
border: 0,
|
||||||
|
background: "linear-gradient(135deg,#ff8a7a,#ff5f57)",
|
||||||
|
color: "#2a0d0a",
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
cursor: "pointer",
|
||||||
|
};
|
||||||
|
const ghostBtn: React.CSSProperties = {
|
||||||
|
padding: "4px 10px",
|
||||||
|
borderRadius: 6,
|
||||||
|
border: "1px solid rgba(255,255,255,.14)",
|
||||||
|
background: "transparent",
|
||||||
|
color: "#cfcfd5",
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: "pointer",
|
||||||
|
};
|
||||||
|
|||||||
@@ -114,6 +114,9 @@ export const attachAgent = (
|
|||||||
export const detachAgent = (topicId: string, agentId: string) =>
|
export const detachAgent = (topicId: string, agentId: string) =>
|
||||||
api<void>(`/api/research/${topicId}/agents/${agentId}`, { method: "DELETE" });
|
api<void>(`/api/research/${topicId}/agents/${agentId}`, { method: "DELETE" });
|
||||||
|
|
||||||
|
export const deleteTopic = (id: string) =>
|
||||||
|
api<void>(`/api/research/${id}`, { method: "DELETE" });
|
||||||
|
|
||||||
export const startTopic = (id: string) =>
|
export const startTopic = (id: string) =>
|
||||||
api<void>(`/api/research/${id}/start`, { method: "POST" });
|
api<void>(`/api/research/${id}/start`, { method: "POST" });
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user