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 { useFormatters } from "../hooks/useFormatters";
|
||||||
|
import { useDuplicateItem } from "../hooks/useItems";
|
||||||
import { LucideIcon } from "../lib/iconData";
|
import { LucideIcon } from "../lib/iconData";
|
||||||
import { useUIStore } from "../stores/uiStore";
|
import { useUIStore } from "../stores/uiStore";
|
||||||
import { ClassificationBadge } from "./ClassificationBadge";
|
import { ClassificationBadge } from "./ClassificationBadge";
|
||||||
@@ -35,6 +36,7 @@ export function ItemCard({
|
|||||||
const { weight, price } = useFormatters();
|
const { weight, price } = useFormatters();
|
||||||
const openEditPanel = useUIStore((s) => s.openEditPanel);
|
const openEditPanel = useUIStore((s) => s.openEditPanel);
|
||||||
const openExternalLink = useUIStore((s) => s.openExternalLink);
|
const openExternalLink = useUIStore((s) => s.openExternalLink);
|
||||||
|
const duplicateItem = useDuplicateItem();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -42,6 +44,46 @@ export function ItemCard({
|
|||||||
onClick={() => openEditPanel(id)}
|
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"
|
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 && (
|
{productUrl && (
|
||||||
<span
|
<span
|
||||||
role="button"
|
role="button"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { parseId } from "../lib/params.ts";
|
|||||||
import {
|
import {
|
||||||
createItem,
|
createItem,
|
||||||
deleteItem,
|
deleteItem,
|
||||||
|
duplicateItem,
|
||||||
getAllItems,
|
getAllItems,
|
||||||
getItemById,
|
getItemById,
|
||||||
updateItem,
|
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) => {
|
app.delete("/:id", async (c) => {
|
||||||
const db = c.get("db");
|
const db = c.get("db");
|
||||||
const id = parseId(c.req.param("id"));
|
const id = parseId(c.req.param("id"));
|
||||||
|
|||||||
@@ -104,6 +104,28 @@ export function updateItem(
|
|||||||
.get();
|
.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) {
|
export function deleteItem(db: Db = prodDb, id: number) {
|
||||||
// Get item first (for image cleanup info)
|
// Get item first (for image cleanup info)
|
||||||
const item = db.select().from(items).where(eq(items.id, id)).get();
|
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");
|
const res = await app.request("/api/items/9999");
|
||||||
expect(res.status).toBe(404);
|
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 {
|
import {
|
||||||
createItem,
|
createItem,
|
||||||
deleteItem,
|
deleteItem,
|
||||||
|
duplicateItem,
|
||||||
getAllItems,
|
getAllItems,
|
||||||
getItemById,
|
getItemById,
|
||||||
updateItem,
|
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", () => {
|
describe("deleteItem", () => {
|
||||||
it("removes item from DB, returns deleted item", () => {
|
it("removes item from DB, returns deleted item", () => {
|
||||||
const created = createItem(db, {
|
const created = createItem(db, {
|
||||||
|
|||||||
Reference in New Issue
Block a user