fix(admin): handle duplicate tag name with 409 + polish inline tag form
- admin-tags.ts: wrap createTag in try/catch, detect UNIQUE constraint violations and return 409 with friendly message - tags/index.tsx: surface server error message in catch block via err.message (ApiError carries the message from response body) - tags/index.tsx: replace bare form row with card-style wrapper — label for Name and Parent, card border/bg, shrink-0 submit button
This commit is contained in:
@@ -124,8 +124,10 @@ function AdminTagsPage() {
|
||||
});
|
||||
setNewName("");
|
||||
setNewParentId(null);
|
||||
} catch {
|
||||
setCreateError("Failed to create tag. Please try again.");
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Failed to create tag";
|
||||
setCreateError(message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,39 +158,50 @@ function AdminTagsPage() {
|
||||
</div>
|
||||
|
||||
{/* Quick-add form */}
|
||||
<form onSubmit={handleCreate} className="flex items-center gap-3 mb-4">
|
||||
<input
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder="Tag name..."
|
||||
className={`flex-1 ${inputClass}`}
|
||||
/>
|
||||
<select
|
||||
value={newParentId ?? ""}
|
||||
onChange={(e) =>
|
||||
setNewParentId(e.target.value ? Number(e.target.value) : null)
|
||||
}
|
||||
className="rounded-lg border border-gray-200 px-3 py-2 text-sm focus:outline-none bg-white appearance-none w-48"
|
||||
>
|
||||
<option value="">No parent (top-level)</option>
|
||||
{data?.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createMutation.isPending || !newName.trim()}
|
||||
className="px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
{createMutation.isPending ? "Adding..." : "Add Tag"}
|
||||
</button>
|
||||
</form>
|
||||
{createError && (
|
||||
<p className="text-sm text-red-500 mt-1 mb-4">{createError}</p>
|
||||
)}
|
||||
<div className="mb-6 rounded-xl border border-gray-100 bg-white p-4">
|
||||
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-3">
|
||||
Add Tag
|
||||
</p>
|
||||
<form onSubmit={handleCreate} className="flex items-end gap-3">
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs text-gray-500 mb-1">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder="e.g. Bikepacking"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-48">
|
||||
<label className="block text-xs text-gray-500 mb-1">Parent</label>
|
||||
<select
|
||||
value={newParentId ?? ""}
|
||||
onChange={(e) =>
|
||||
setNewParentId(e.target.value ? Number(e.target.value) : null)
|
||||
}
|
||||
className={`${inputClass} appearance-none bg-white`}
|
||||
>
|
||||
<option value="">No parent (top-level)</option>
|
||||
{data?.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createMutation.isPending || !newName.trim()}
|
||||
className="px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium transition-colors disabled:opacity-50 shrink-0"
|
||||
>
|
||||
{createMutation.isPending ? "Adding..." : "Add Tag"}
|
||||
</button>
|
||||
</form>
|
||||
{createError && (
|
||||
<p className="text-sm text-red-500 mt-2">{createError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error state */}
|
||||
{isError && (
|
||||
|
||||
@@ -45,8 +45,22 @@ app.get("/:id", async (c) => {
|
||||
app.post("/", zValidator("json", createTagSchema), async (c) => {
|
||||
const db = c.get("db");
|
||||
const data = c.req.valid("json");
|
||||
const tag = await createTag(db, data);
|
||||
return c.json(tag, 201);
|
||||
try {
|
||||
const tag = await createTag(db, data);
|
||||
return c.json(tag, 201);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Error &&
|
||||
(err.message.includes("UNIQUE constraint failed") ||
|
||||
err.message.includes("unique constraint"))
|
||||
) {
|
||||
return c.json(
|
||||
{ error: `A tag named "${data.name}" already exists` },
|
||||
409,
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/admin/tags/:id — rename and/or reparent a tag
|
||||
|
||||
Reference in New Issue
Block a user