feat: add item duplication with copy-and-edit workflow
Adds POST /api/items/:id/duplicate endpoint, useDuplicateItem hook, and a Duplicate button on ItemCard (collection view only) that opens the new item for editing immediately after creation. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { useFormatters } from "../hooks/useFormatters";
|
||||
import { useDuplicateItem } from "../hooks/useItems";
|
||||
import { LucideIcon } from "../lib/iconData";
|
||||
import { useUIStore } from "../stores/uiStore";
|
||||
import { ClassificationBadge } from "./ClassificationBadge";
|
||||
@@ -35,6 +36,7 @@ export function ItemCard({
|
||||
const { weight, price } = useFormatters();
|
||||
const openEditPanel = useUIStore((s) => s.openEditPanel);
|
||||
const openExternalLink = useUIStore((s) => s.openExternalLink);
|
||||
const duplicateItem = useDuplicateItem();
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -42,6 +44,46 @@ export function ItemCard({
|
||||
onClick={() => openEditPanel(id)}
|
||||
className="relative w-full text-left bg-white rounded-xl border border-gray-100 hover:border-gray-200 hover:shadow-sm transition-all overflow-hidden group"
|
||||
>
|
||||
{!onRemove && (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
duplicateItem.mutate(id, {
|
||||
onSuccess: (newItem) => {
|
||||
openEditPanel(newItem.id);
|
||||
},
|
||||
});
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.stopPropagation();
|
||||
duplicateItem.mutate(id, {
|
||||
onSuccess: (newItem) => {
|
||||
openEditPanel(newItem.id);
|
||||
},
|
||||
});
|
||||
}
|
||||
}}
|
||||
className={`absolute top-2 ${productUrl ? "right-10" : "right-2"} z-10 w-6 h-6 flex items-center justify-center rounded-full bg-gray-100/80 text-gray-400 hover:bg-blue-100 hover:text-blue-500 opacity-0 group-hover:opacity-100 transition-all cursor-pointer`}
|
||||
title="Duplicate item"
|
||||
>
|
||||
<svg
|
||||
className="w-3.5 h-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
{productUrl && (
|
||||
<span
|
||||
role="button"
|
||||
|
||||
@@ -7,6 +7,7 @@ import { parseId } from "../lib/params.ts";
|
||||
import {
|
||||
createItem,
|
||||
deleteItem,
|
||||
duplicateItem,
|
||||
getAllItems,
|
||||
getItemById,
|
||||
updateItem,
|
||||
@@ -52,6 +53,15 @@ app.put(
|
||||
},
|
||||
);
|
||||
|
||||
app.post("/:id/duplicate", (c) => {
|
||||
const db = c.get("db");
|
||||
const id = parseId(c.req.param("id"));
|
||||
if (!id) return c.json({ error: "Invalid item ID" }, 400);
|
||||
const newItem = duplicateItem(db, id);
|
||||
if (!newItem) return c.json({ error: "Item not found" }, 404);
|
||||
return c.json(newItem, 201);
|
||||
});
|
||||
|
||||
app.delete("/:id", async (c) => {
|
||||
const db = c.get("db");
|
||||
const id = parseId(c.req.param("id"));
|
||||
|
||||
@@ -104,6 +104,28 @@ export function updateItem(
|
||||
.get();
|
||||
}
|
||||
|
||||
export function duplicateItem(db: Db = prodDb, id: number) {
|
||||
const source = db.select().from(items).where(eq(items.id, id)).get();
|
||||
|
||||
if (!source) return null;
|
||||
|
||||
return db
|
||||
.insert(items)
|
||||
.values({
|
||||
name: `${source.name} (copy)`,
|
||||
weightGrams: source.weightGrams,
|
||||
priceCents: source.priceCents,
|
||||
categoryId: source.categoryId,
|
||||
notes: source.notes,
|
||||
productUrl: source.productUrl,
|
||||
imageFilename: source.imageFilename,
|
||||
imageSourceUrl: source.imageSourceUrl,
|
||||
quantity: source.quantity,
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
}
|
||||
|
||||
export function deleteItem(db: Db = prodDb, id: number) {
|
||||
// Get item first (for image cleanup info)
|
||||
const item = db.select().from(items).where(eq(items.id, id)).get();
|
||||
|
||||
@@ -118,4 +118,30 @@ describe("Item Routes", () => {
|
||||
const res = await app.request("/api/items/9999");
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("POST /api/items/:id/duplicate returns 201 with the copy", async () => {
|
||||
const createRes = await app.request("/api/items", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: "Tent", categoryId: 1, weightGrams: 1200 }),
|
||||
});
|
||||
const created = await createRes.json();
|
||||
|
||||
const res = await app.request(`/api/items/${created.id}/duplicate`, {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
const body = await res.json();
|
||||
expect(body.name).toBe("Tent (copy)");
|
||||
expect(body.weightGrams).toBe(1200);
|
||||
expect(body.id).not.toBe(created.id);
|
||||
});
|
||||
|
||||
it("POST /api/items/999/duplicate returns 404", async () => {
|
||||
const res = await app.request("/api/items/999/duplicate", {
|
||||
method: "POST",
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it } from "bun:test";
|
||||
import {
|
||||
createItem,
|
||||
deleteItem,
|
||||
duplicateItem,
|
||||
getAllItems,
|
||||
getItemById,
|
||||
updateItem,
|
||||
@@ -98,6 +99,41 @@ describe("Item Service", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("duplicateItem", () => {
|
||||
it("creates a copy with '(copy)' suffix in name", () => {
|
||||
const original = createItem(db, {
|
||||
name: "Tent",
|
||||
weightGrams: 1200,
|
||||
priceCents: 35000,
|
||||
categoryId: 1,
|
||||
notes: "Ultralight",
|
||||
productUrl: "https://example.com/tent",
|
||||
});
|
||||
|
||||
const copy = duplicateItem(db, original?.id);
|
||||
|
||||
expect(copy).toBeDefined();
|
||||
expect(copy?.name).toBe("Tent (copy)");
|
||||
expect(copy?.weightGrams).toBe(1200);
|
||||
expect(copy?.priceCents).toBe(35000);
|
||||
expect(copy?.categoryId).toBe(1);
|
||||
expect(copy?.notes).toBe("Ultralight");
|
||||
expect(copy?.productUrl).toBe("https://example.com/tent");
|
||||
});
|
||||
|
||||
it("copy has a different ID from the original", () => {
|
||||
const original = createItem(db, { name: "Helmet", categoryId: 1 });
|
||||
const copy = duplicateItem(db, original?.id);
|
||||
|
||||
expect(copy?.id).not.toBe(original?.id);
|
||||
});
|
||||
|
||||
it("returns null for non-existent item", () => {
|
||||
const result = duplicateItem(db, 9999);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteItem", () => {
|
||||
it("removes item from DB, returns deleted item", () => {
|
||||
const created = createItem(db, {
|
||||
|
||||
Reference in New Issue
Block a user