Candidates: inline rename (fix a name typo without reject + re-add)

A staged candidate could only be renamed by rejecting and re-adding it, which
churns the queue and discards the preview just to fix a typo. Add an inline
Rename on each candidate: a "Rename" pill swaps the name for an input
(Enter saves · Esc cancels), POST /api/admin/candidates/{id}/rename →
sources.rename_candidate(). Empty clears the name (promote then derives one
from the feed host). Preview is preserved; the fixed name carries into promotion.

Tests: test_candidate_rename (rename in place keeps preview, promotes with the
new name, gated + 404). 225 pytest + 11 vitest green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-11 21:39:13 -04:00
parent 3afc1ed37e
commit 070b40584e
4 changed files with 68 additions and 3 deletions
+23 -3
View File
@@ -293,6 +293,12 @@
Object.assign(c, res, { _deep: false });
} catch (e) { c._err = e?.message || 'Deep preview failed.'; c._deep = false; }
}
function startRename(c) { c._editName = c.name || ''; c._editing = true; c._err = ''; }
async function saveRename(c) {
try {
Object.assign(c, await postJSON(`/api/admin/candidates/${c.id}/rename`, { name: (c._editName || '').trim() }), { _editing: false });
} catch (e) { c._err = e?.message || 'Rename failed.'; }
}
async function promoteCandidate(c) {
c._err = '';
try {
@@ -570,8 +576,16 @@
{#each pendingCandidates as c (c.id)}
<li>
<div class="chead">
<span class="cname">{c.name || c.feed_url}</span>
<span class="cstatus">{c.status}</span>
{#if c._editing}
<input class="crename" bind:value={c._editName} placeholder="Source name"
onkeydown={(e) => (e.key === 'Enter' ? saveRename(c) : e.key === 'Escape' ? (c._editing = false) : null)} />
<button class="act" onclick={() => saveRename(c)}>Save</button>
<button class="act" onclick={() => (c._editing = false)}>Cancel</button>
{:else}
<span class="cname">{c.name || c.feed_url}</span>
<button class="act mini" onclick={() => startRename(c)}>Rename</button>
<span class="cstatus">{c.status}</span>
{/if}
</div>
<div class="curl">{c.feed_url}</div>
{#if c.preview}
@@ -1102,9 +1116,15 @@
.addrow input:focus { outline: none; border-color: var(--accent); }
ul.candlist { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
ul.candlist li { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px; }
.chead { display: flex; align-items: baseline; gap: 10px; }
.chead { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
.chead .cname { font-weight: 600; color: var(--ink); }
.chead .cstatus { font-size: 0.72rem; color: var(--muted); text-transform: capitalize; }
.chead .crename { font: inherit; font-size: 0.9rem; font-weight: 600; padding: 4px 9px;
border: 1px solid var(--accent); border-radius: 8px; background: var(--bg); color: var(--ink); }
.chead .crename:focus { outline: none; }
.act.mini { font-size: 0.72rem; padding: 2px 8px; color: var(--accent-deep);
background: none; border: 1px solid var(--line); border-radius: 999px; cursor: pointer; }
.act.mini:hover { border-color: var(--accent); }
.curl { font-size: 0.76rem; color: var(--muted); word-break: break-all; margin-top: 2px; }
.cprev { font-size: 0.84rem; color: var(--ink); margin-top: 7px; }
.cprev .cex { color: var(--muted); font-size: 0.8rem; margin-top: 2px; font-style: italic; }
+14
View File
@@ -444,6 +444,10 @@ class CandidatePromoteBody(BaseModel):
poll_interval_minutes: int = 180
class CandidateRenameBody(BaseModel):
name: str = ""
class DigestBody(BaseModel):
enabled: bool = True
@@ -1197,6 +1201,16 @@ def create_app() -> FastAPI:
row = sources.save_candidate(conn, url, preview=preview)
return _candidate_dict(row)
@app.post("/api/admin/candidates/{cid}/rename")
def admin_candidate_rename(cid: int, body: CandidateRenameBody, request: Request) -> dict:
with get_conn() as conn:
_require_admin(conn, request)
try:
row = sources.rename_candidate(conn, cid, (body.name or "").strip() or None)
except ValueError:
raise HTTPException(status_code=404, detail="candidate not found")
return _candidate_dict(row)
@app.post("/api/admin/candidates/{cid}/promote")
def admin_candidate_promote(cid: int, body: CandidatePromoteBody, request: Request) -> dict:
with get_conn() as conn:
+13
View File
@@ -153,6 +153,19 @@ def list_candidates(conn: sqlite3.Connection, status: str | None = None) -> list
return conn.execute("SELECT * FROM source_candidates ORDER BY updated_at DESC").fetchall()
def rename_candidate(conn: sqlite3.Connection, candidate_id: int, name: str | None) -> sqlite3.Row:
"""Fix a staged candidate's display name without re-fetching it. An empty
name clears it (promote then derives one from the feed host)."""
cur = conn.execute(
"UPDATE source_candidates SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
(name or None, candidate_id),
)
conn.commit()
if cur.rowcount == 0:
raise ValueError(f"no candidate with id {candidate_id}")
return conn.execute("SELECT * FROM source_candidates WHERE id = ?", (candidate_id,)).fetchone()
def reject_candidate(conn: sqlite3.Connection, candidate_id: int) -> bool:
cur = conn.execute(
"UPDATE source_candidates SET status = 'rejected', updated_at = CURRENT_TIMESTAMP WHERE id = ?",
+18
View File
@@ -169,6 +169,24 @@ def test_candidate_deep_preview_and_dedup(tmp_path, monkeypatch):
assert tc.post(f"/api/admin/candidates/{cand['id']}/promote", json={}).status_code == 409
def test_candidate_rename(tmp_path, monkeypatch):
app, api = _make(tmp_path, monkeypatch, admin_email="boss@x.com")
monkeypatch.setattr(api.feeds, "preview_feed", lambda url, **k: {"url": url, "sampled": 3, "accepted": 2})
tc = _signin(app, api, "boss@x.com")
cand = tc.post("/api/admin/candidates", json={"feed_url": "http://site/feed", "name": "Wrong Naem"}).json()
cid = cand["id"]
# Fix the typo in place — no reject/re-add, preview is preserved.
fixed = tc.post(f"/api/admin/candidates/{cid}/rename", json={"name": "Right Name"}).json()
assert fixed["name"] == "Right Name" and fixed["preview"]["accepted"] == 2
assert any(c["id"] == cid and c["name"] == "Right Name" for c in tc.get("/api/admin/candidates").json())
# The fixed name carries through to promotion.
res = tc.post(f"/api/admin/candidates/{cid}/promote", json={}).json()
assert res["source"]["name"] == "Right Name"
# Gated; unknown id → 404.
assert TestClient(app).post(f"/api/admin/candidates/{cid}/rename", json={"name": "x"}).status_code == 401
assert tc.post("/api/admin/candidates/99999/rename", json={"name": "x"}).status_code == 404
def test_candidate_reject_and_gating(tmp_path, monkeypatch):
app, api = _make(tmp_path, monkeypatch, admin_email="boss@x.com")
monkeypatch.setattr(api.feeds, "preview_feed", lambda url, **k: {"url": url, "sampled": 1, "accepted": 0})