feat(09-01): add classification API route, client hook, badge component, and setup detail wiring
- Add PATCH /:id/items/:itemId/classification endpoint with Zod validation - Add apiPatch helper to client API library - Add useUpdateItemClassification mutation hook - Add classification field to SetupItemWithCategory interface - Create ClassificationBadge click-to-cycle component (base/worn/consumable) - Wire ClassificationBadge into setup detail page item grid - Add integration tests for PATCH classification route (valid + invalid) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
30
src/client/components/ClassificationBadge.tsx
Normal file
30
src/client/components/ClassificationBadge.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
const CLASSIFICATION_LABELS: Record<string, string> = {
|
||||||
|
base: "Base Weight",
|
||||||
|
worn: "Worn",
|
||||||
|
consumable: "Consumable",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ClassificationBadgeProps {
|
||||||
|
classification: string;
|
||||||
|
onCycle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClassificationBadge({
|
||||||
|
classification,
|
||||||
|
onCycle,
|
||||||
|
}: ClassificationBadgeProps) {
|
||||||
|
const label = CLASSIFICATION_LABELS[classification] ?? "Base Weight";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onCycle();
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600 hover:bg-gray-200 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { apiDelete, apiGet, apiPost, apiPut } from "../lib/api";
|
import { apiDelete, apiGet, apiPatch, apiPost, apiPut } from "../lib/api";
|
||||||
|
|
||||||
interface SetupListItem {
|
interface SetupListItem {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -24,6 +24,7 @@ interface SetupItemWithCategory {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
categoryName: string;
|
categoryName: string;
|
||||||
categoryIcon: string;
|
categoryIcon: string;
|
||||||
|
classification: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SetupWithItems {
|
interface SetupWithItems {
|
||||||
@@ -105,3 +106,20 @@ export function useRemoveSetupItem(setupId: number) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useUpdateItemClassification(setupId: number) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
itemId,
|
||||||
|
classification,
|
||||||
|
}: { itemId: number; classification: string }) =>
|
||||||
|
apiPatch<{ success: boolean }>(
|
||||||
|
`/api/setups/${setupId}/items/${itemId}/classification`,
|
||||||
|
{ classification },
|
||||||
|
),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["setups", setupId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -45,6 +45,15 @@ export async function apiPut<T>(url: string, body: unknown): Promise<T> {
|
|||||||
return handleResponse<T>(res);
|
return handleResponse<T>(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function apiPatch<T>(url: string, body: unknown): Promise<T> {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
return handleResponse<T>(res);
|
||||||
|
}
|
||||||
|
|
||||||
export async function apiDelete<T>(url: string): Promise<T> {
|
export async function apiDelete<T>(url: string): Promise<T> {
|
||||||
const res = await fetch(url, { method: "DELETE" });
|
const res = await fetch(url, { method: "DELETE" });
|
||||||
return handleResponse<T>(res);
|
return handleResponse<T>(res);
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { CategoryHeader } from "../../components/CategoryHeader";
|
import { CategoryHeader } from "../../components/CategoryHeader";
|
||||||
|
import { ClassificationBadge } from "../../components/ClassificationBadge";
|
||||||
import { ItemCard } from "../../components/ItemCard";
|
import { ItemCard } from "../../components/ItemCard";
|
||||||
import { ItemPicker } from "../../components/ItemPicker";
|
import { ItemPicker } from "../../components/ItemPicker";
|
||||||
import {
|
import {
|
||||||
useDeleteSetup,
|
useDeleteSetup,
|
||||||
useRemoveSetupItem,
|
useRemoveSetupItem,
|
||||||
useSetup,
|
useSetup,
|
||||||
|
useUpdateItemClassification,
|
||||||
} from "../../hooks/useSetups";
|
} from "../../hooks/useSetups";
|
||||||
import { useWeightUnit } from "../../hooks/useWeightUnit";
|
import { useWeightUnit } from "../../hooks/useWeightUnit";
|
||||||
import { formatPrice, formatWeight } from "../../lib/formatters";
|
import { formatPrice, formatWeight } from "../../lib/formatters";
|
||||||
@@ -24,6 +26,7 @@ function SetupDetailPage() {
|
|||||||
const { data: setup, isLoading } = useSetup(numericId);
|
const { data: setup, isLoading } = useSetup(numericId);
|
||||||
const deleteSetup = useDeleteSetup();
|
const deleteSetup = useDeleteSetup();
|
||||||
const removeItem = useRemoveSetupItem(numericId);
|
const removeItem = useRemoveSetupItem(numericId);
|
||||||
|
const updateClassification = useUpdateItemClassification(numericId);
|
||||||
|
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
@@ -86,6 +89,12 @@ function SetupDetailPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function nextClassification(current: string): string {
|
||||||
|
const order = ["base", "worn", "consumable"];
|
||||||
|
const idx = order.indexOf(current);
|
||||||
|
return order[(idx + 1) % order.length];
|
||||||
|
}
|
||||||
|
|
||||||
function handleDelete() {
|
function handleDelete() {
|
||||||
deleteSetup.mutate(numericId, {
|
deleteSetup.mutate(numericId, {
|
||||||
onSuccess: () => navigate({ to: "/setups" }),
|
onSuccess: () => navigate({ to: "/setups" }),
|
||||||
@@ -208,18 +217,32 @@ function SetupDetailPage() {
|
|||||||
/>
|
/>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{categoryItems.map((item) => (
|
{categoryItems.map((item) => (
|
||||||
<ItemCard
|
<div key={item.id}>
|
||||||
key={item.id}
|
<ItemCard
|
||||||
id={item.id}
|
id={item.id}
|
||||||
name={item.name}
|
name={item.name}
|
||||||
weightGrams={item.weightGrams}
|
weightGrams={item.weightGrams}
|
||||||
priceCents={item.priceCents}
|
priceCents={item.priceCents}
|
||||||
categoryName={categoryName}
|
categoryName={categoryName}
|
||||||
categoryIcon={categoryIcon}
|
categoryIcon={categoryIcon}
|
||||||
imageFilename={item.imageFilename}
|
imageFilename={item.imageFilename}
|
||||||
productUrl={item.productUrl}
|
productUrl={item.productUrl}
|
||||||
onRemove={() => removeItem.mutate(item.id)}
|
onRemove={() => removeItem.mutate(item.id)}
|
||||||
/>
|
/>
|
||||||
|
<div className="px-4 pb-3 -mt-1">
|
||||||
|
<ClassificationBadge
|
||||||
|
classification={item.classification}
|
||||||
|
onCycle={() =>
|
||||||
|
updateClassification.mutate({
|
||||||
|
itemId: item.id,
|
||||||
|
classification: nextClassification(
|
||||||
|
item.classification,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Hono } from "hono";
|
|||||||
import {
|
import {
|
||||||
createSetupSchema,
|
createSetupSchema,
|
||||||
syncSetupItemsSchema,
|
syncSetupItemsSchema,
|
||||||
|
updateClassificationSchema,
|
||||||
updateSetupSchema,
|
updateSetupSchema,
|
||||||
} from "../../shared/schemas.ts";
|
} from "../../shared/schemas.ts";
|
||||||
import {
|
import {
|
||||||
@@ -12,6 +13,7 @@ import {
|
|||||||
getSetupWithItems,
|
getSetupWithItems,
|
||||||
removeSetupItem,
|
removeSetupItem,
|
||||||
syncSetupItems,
|
syncSetupItems,
|
||||||
|
updateItemClassification,
|
||||||
updateSetup,
|
updateSetup,
|
||||||
} from "../services/setup.service.ts";
|
} from "../services/setup.service.ts";
|
||||||
|
|
||||||
@@ -73,6 +75,19 @@ app.put("/:id/items", zValidator("json", syncSetupItemsSchema), (c) => {
|
|||||||
return c.json({ success: true });
|
return c.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.patch(
|
||||||
|
"/:id/items/:itemId/classification",
|
||||||
|
zValidator("json", updateClassificationSchema),
|
||||||
|
(c) => {
|
||||||
|
const db = c.get("db");
|
||||||
|
const setupId = Number(c.req.param("id"));
|
||||||
|
const itemId = Number(c.req.param("itemId"));
|
||||||
|
const { classification } = c.req.valid("json");
|
||||||
|
updateItemClassification(db, setupId, itemId, classification);
|
||||||
|
return c.json({ success: true });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
app.delete("/:id/items/:itemId", (c) => {
|
app.delete("/:id/items/:itemId", (c) => {
|
||||||
const db = c.get("db");
|
const db = c.get("db");
|
||||||
const setupId = Number(c.req.param("id"));
|
const setupId = Number(c.req.param("id"));
|
||||||
|
|||||||
@@ -205,6 +205,67 @@ describe("Setup Routes", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("PATCH /api/setups/:id/items/:itemId/classification", () => {
|
||||||
|
it("updates item classification and persists it", async () => {
|
||||||
|
const setup = await createSetupViaAPI(app, "Kit");
|
||||||
|
const item = await createItemViaAPI(app, {
|
||||||
|
name: "Jacket",
|
||||||
|
categoryId: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sync item to setup
|
||||||
|
await app.request(`/api/setups/${setup.id}/items`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ itemIds: [item.id] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Patch classification to "worn"
|
||||||
|
const res = await app.request(
|
||||||
|
`/api/setups/${setup.id}/items/${item.id}/classification`,
|
||||||
|
{
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ classification: "worn" }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
|
||||||
|
// Verify classification persisted
|
||||||
|
const getRes = await app.request(`/api/setups/${setup.id}`);
|
||||||
|
const getBody = await getRes.json();
|
||||||
|
expect(getBody.items[0].classification).toBe("worn");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 for invalid classification value", async () => {
|
||||||
|
const setup = await createSetupViaAPI(app, "Kit");
|
||||||
|
const item = await createItemViaAPI(app, {
|
||||||
|
name: "Tent",
|
||||||
|
categoryId: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.request(`/api/setups/${setup.id}/items`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ itemIds: [item.id] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.request(
|
||||||
|
`/api/setups/${setup.id}/items/${item.id}/classification`,
|
||||||
|
{
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ classification: "invalid-value" }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("DELETE /api/setups/:id/items/:itemId", () => {
|
describe("DELETE /api/setups/:id/items/:itemId", () => {
|
||||||
it("removes single item from setup", async () => {
|
it("removes single item from setup", async () => {
|
||||||
const setup = await createSetupViaAPI(app, "Kit");
|
const setup = await createSetupViaAPI(app, "Kit");
|
||||||
|
|||||||
Reference in New Issue
Block a user