feat(38-02): tag edit page + enable Tags sidebar link
- Create admin/tags.$tagId.tsx with rename, reparent (cycle-safe), and delete - getDescendantIds excludes self + all descendants from parent picker - getDeleteConfirmText builds impact-aware confirmation (item count + child count) - Delete confirmation modal with This cannot be undone - Enable Tags sidebar Link in admin.tsx (remove disabled div + Soon badge)
This commit is contained in:
@@ -39,17 +39,16 @@ function AdminLayout() {
|
|||||||
<span>Items</span>
|
<span>Items</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Tags — disabled (phase 38) */}
|
{/* Tags — active (phase 38) */}
|
||||||
<div
|
<Link
|
||||||
className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-gray-300 cursor-not-allowed"
|
to="/admin/tags"
|
||||||
title="Coming in a future release"
|
activeProps={{ className: "bg-gray-100 text-gray-900 font-medium" }}
|
||||||
|
inactiveProps={{ className: "text-gray-600 hover:bg-gray-50" }}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors"
|
||||||
>
|
>
|
||||||
<LucideIcon name="tag" size={16} />
|
<LucideIcon name="tag" size={16} />
|
||||||
<span>Tags</span>
|
<span>Tags</span>
|
||||||
<span className="ml-auto text-xs bg-gray-100 text-gray-400 px-1.5 py-0.5 rounded">
|
</Link>
|
||||||
Soon
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
|
|||||||
229
src/client/routes/admin/tags.$tagId.tsx
Normal file
229
src/client/routes/admin/tags.$tagId.tsx
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
useAdminTag,
|
||||||
|
useAdminTags,
|
||||||
|
useDeleteAdminTag,
|
||||||
|
useUpdateAdminTag,
|
||||||
|
type AdminTag,
|
||||||
|
} from "../../hooks/useAdminTags";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/admin/tags/$tagId")({
|
||||||
|
component: AdminTagEditPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Helper functions ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function getDescendantIds(allTags: AdminTag[], tagId: number): Set<number> {
|
||||||
|
const result = new Set<number>();
|
||||||
|
const children = allTags.filter((t) => t.parentId === tagId);
|
||||||
|
for (const child of children) {
|
||||||
|
result.add(child.id);
|
||||||
|
for (const id of getDescendantIds(allTags, child.id)) result.add(id);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDeleteConfirmText(tag: AdminTag, childCount: number): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (tag.itemCount > 0) {
|
||||||
|
parts.push(
|
||||||
|
`${tag.itemCount} ${tag.itemCount === 1 ? "item uses" : "items use"} this tag.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (childCount > 0) {
|
||||||
|
parts.push(
|
||||||
|
`Its ${childCount} child ${childCount === 1 ? "tag" : "tags"} will become top-level.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
parts.push("This cannot be undone.");
|
||||||
|
return parts.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CSS constants ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
"w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-300";
|
||||||
|
const labelClass = "block text-sm font-medium text-gray-700 mb-1";
|
||||||
|
|
||||||
|
// ── Edit page component ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
function AdminTagEditPage() {
|
||||||
|
const { tagId } = Route.useParams();
|
||||||
|
const id = Number(tagId);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const { data: tag, isLoading, isError } = useAdminTag(isNaN(id) ? null : id);
|
||||||
|
const { data: allTags } = useAdminTags();
|
||||||
|
const updateMutation = useUpdateAdminTag();
|
||||||
|
const deleteMutation = useDeleteAdminTag();
|
||||||
|
|
||||||
|
const [form, setForm] = useState({ name: "", parentId: null as number | null });
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (tag) {
|
||||||
|
setForm({ name: tag.name, parentId: tag.parentId });
|
||||||
|
}
|
||||||
|
}, [tag]);
|
||||||
|
|
||||||
|
function handleChange(field: keyof typeof form, value: string | number | null) {
|
||||||
|
setForm((prev) => ({ ...prev, [field]: value }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
await updateMutation.mutateAsync({
|
||||||
|
id,
|
||||||
|
data: { name: form.name || undefined, parentId: form.parentId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
await deleteMutation.mutateAsync(id);
|
||||||
|
navigate({ to: "/admin/tags" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Computed: exclude self + all descendants from parent picker
|
||||||
|
const excludedIds = new Set([id, ...getDescendantIds(allTags ?? [], id)]);
|
||||||
|
const parentOptions = (allTags ?? []).filter((t) => !excludedIds.has(t.id));
|
||||||
|
const childCount = (allTags ?? []).filter((t) => t.parentId === id).length;
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto">
|
||||||
|
<div className="h-4 w-16 bg-gray-100 rounded animate-pulse mb-6" />
|
||||||
|
<div className="space-y-4">
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<div key={i} className="h-10 bg-gray-100 rounded-lg animate-pulse" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !tag) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto text-center py-12">
|
||||||
|
<p className="text-sm text-red-500">Failed to load tag. Please try again.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto">
|
||||||
|
{/* Back link */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate({ to: "/admin/tags" })}
|
||||||
|
className="text-sm text-gray-400 hover:text-gray-600 transition-colors mb-6 block"
|
||||||
|
>
|
||||||
|
← Tags
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Page heading */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-lg font-semibold text-gray-900">{tag.name}</h1>
|
||||||
|
<p className="text-sm text-gray-400 mt-0.5">
|
||||||
|
{tag.itemCount > 0
|
||||||
|
? `${tag.itemCount} items use this tag`
|
||||||
|
: "Not used by any items"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSave}>
|
||||||
|
{/* Name field */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className={labelClass}>Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => handleChange("name", e.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Parent field */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className={labelClass}>Parent Tag</label>
|
||||||
|
<select
|
||||||
|
value={form.parentId ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleChange("parentId", e.target.value ? Number(e.target.value) : null)
|
||||||
|
}
|
||||||
|
className={`${inputClass} appearance-none bg-white`}
|
||||||
|
>
|
||||||
|
<option value="">No parent (top-level)</option>
|
||||||
|
{parentOptions.map((t) => (
|
||||||
|
<option key={t.id} value={t.id}>
|
||||||
|
{t.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions row */}
|
||||||
|
<div className="flex items-center justify-between mt-8 pt-6 border-t border-gray-100">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowDeleteConfirm(true)}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
className="px-4 py-2 rounded-lg border border-red-200 text-red-600 hover:bg-red-50 text-sm font-medium transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Delete Tag
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={updateMutation.isPending}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{updateMutation.isPending ? "Saving..." : "Save Changes"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Save error */}
|
||||||
|
{updateMutation.isError && (
|
||||||
|
<p className="text-sm text-red-500 mt-2 text-right">
|
||||||
|
Failed to save. Please try again.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Delete confirmation dialog */}
|
||||||
|
{showDeleteConfirm && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/30"
|
||||||
|
onClick={() => setShowDeleteConfirm(false)}
|
||||||
|
/>
|
||||||
|
<div className="relative bg-white rounded-xl shadow-lg p-6 max-w-sm mx-4 w-full">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
|
Delete "{tag.name}"?
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-600 mb-6">
|
||||||
|
{getDeleteConfirmText(tag, childCount)}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-3 justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowDeleteConfirm(false)}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-lg disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{deleteMutation.isPending ? "Deleting..." : "Delete"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user