363 lines
16 KiB
Markdown
363 lines
16 KiB
Markdown
---
|
|
phase: 22-add-from-catalog-thread-integration
|
|
plan: 02
|
|
type: execute
|
|
wave: 2
|
|
depends_on: ["22-01"]
|
|
files_modified:
|
|
- src/client/components/AddToThreadModal.tsx
|
|
- src/client/routes/__root.tsx
|
|
autonomous: false
|
|
requirements: [CATFLOW-05, CATFLOW-06]
|
|
|
|
must_haves:
|
|
truths:
|
|
- "Clicking Add on a catalog search card in thread mode opens the AddToThreadModal with a thread picker"
|
|
- "User can select an existing active thread and the catalog item is added as a candidate"
|
|
- "User can choose New Thread which shows thread name + category fields and creates thread + candidate in one step"
|
|
- "After creating a new thread, subsequent adds in same session default to that thread"
|
|
- "Clicking Add to Thread on the global item detail page opens the same thread picker modal"
|
|
- "Resolving a thread with a catalog-linked candidate creates a reference item (already works)"
|
|
artifacts:
|
|
- path: "src/client/components/AddToThreadModal.tsx"
|
|
provides: "Thread picker modal with new thread creation flow"
|
|
min_lines: 120
|
|
- path: "src/client/routes/__root.tsx"
|
|
provides: "AddToThreadModal rendered at root level"
|
|
contains: "AddToThreadModal"
|
|
key_links:
|
|
- from: "src/client/components/AddToThreadModal.tsx"
|
|
to: "/api/threads"
|
|
via: "useCreateThread for new thread creation"
|
|
pattern: "useCreateThread"
|
|
- from: "src/client/components/AddToThreadModal.tsx"
|
|
to: "/api/threads/:threadId/candidates"
|
|
via: "apiPost for candidate creation after thread selection/creation"
|
|
pattern: "apiPost.*candidates"
|
|
- from: "src/client/components/AddToThreadModal.tsx"
|
|
to: "src/client/stores/uiStore.ts"
|
|
via: "setCatalogSessionThreadId to remember thread selection"
|
|
pattern: "setCatalogSessionThreadId"
|
|
---
|
|
|
|
<objective>
|
|
Build the AddToThreadModal: thread picker with existing active threads, "New Thread..." option for combined thread+candidate creation, and session thread memory. Wire to root layout. Verify CATFLOW-06 (thread resolution with catalog-linked candidates).
|
|
|
|
Purpose: CATFLOW-05 -- users can add catalog items as thread candidates from search. CATFLOW-06 -- resolution already works, verify it.
|
|
Output: Working add-to-thread flow, complete Phase 22 catalog integration.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<context>
|
|
@.planning/PROJECT.md
|
|
@.planning/ROADMAP.md
|
|
@.planning/STATE.md
|
|
@.planning/phases/22-add-from-catalog-thread-integration/22-CONTEXT.md
|
|
@.planning/phases/22-add-from-catalog-thread-integration/22-RESEARCH.md
|
|
@.planning/phases/22-add-from-catalog-thread-integration/22-01-SUMMARY.md
|
|
|
|
<interfaces>
|
|
<!-- Types and contracts from Plan 01 and existing code -->
|
|
|
|
From src/client/stores/uiStore.ts (after Plan 01):
|
|
```typescript
|
|
addToThreadModal: { open: boolean; globalItemId: number | null; globalItemName: string | null };
|
|
openAddToThread: (globalItemId: number, globalItemName: string) => void;
|
|
closeAddToThread: () => void;
|
|
catalogSessionThreadId: number | null;
|
|
setCatalogSessionThreadId: (id: number | null) => void;
|
|
```
|
|
|
|
From src/client/hooks/useThreads.ts:
|
|
```typescript
|
|
export function useThreads(includeResolved = false)
|
|
// Returns ThreadListItem[] with: id, name, status, categoryId, categoryName, candidateCount
|
|
|
|
export function useCreateThread()
|
|
// mutationFn: (data: { name: string; categoryId: number }) => apiPost<ThreadListItem>("/api/threads", data)
|
|
// onSuccess invalidates ["threads"]
|
|
```
|
|
|
|
From src/client/hooks/useCandidates.ts:
|
|
```typescript
|
|
export function useCreateCandidate(threadId: number)
|
|
// mutationFn: (data: CreateCandidate & { imageFilename?: string }) => apiPost(...)
|
|
// NOTE: threadId is a hook parameter, not in mutation payload
|
|
// For dynamic threadId (new thread flow), use apiPost directly instead
|
|
```
|
|
|
|
From src/shared/schemas.ts:
|
|
```typescript
|
|
export const createCandidateSchema = z.object({
|
|
name: z.string().min(1, "Name is required"),
|
|
weightGrams: z.number().nonnegative().optional(),
|
|
priceCents: z.number().int().nonnegative().optional(),
|
|
categoryId: z.number().int().positive(),
|
|
notes: z.string().optional(),
|
|
productUrl: z.string().url().optional().or(z.literal("")),
|
|
imageFilename: z.string().optional(),
|
|
imageSourceUrl: z.string().url().optional().or(z.literal("")),
|
|
status: candidateStatusSchema.optional(),
|
|
pros: z.string().optional(),
|
|
cons: z.string().optional(),
|
|
globalItemId: z.number().int().positive().optional(),
|
|
});
|
|
```
|
|
|
|
From src/client/lib/api.ts:
|
|
```typescript
|
|
export async function apiPost<T>(url: string, body: unknown): Promise<T>
|
|
```
|
|
|
|
From src/client/hooks/useGlobalItems.ts:
|
|
```typescript
|
|
export function useGlobalItem(id: number)
|
|
// Returns globalItem with: id, brand, model, category, weightGrams, priceCents, imageUrl, description, ownerCount
|
|
```
|
|
</interfaces>
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto">
|
|
<name>Task 1: Build AddToThreadModal with thread picker and new thread flow</name>
|
|
<files>
|
|
src/client/components/AddToThreadModal.tsx
|
|
src/client/routes/__root.tsx
|
|
</files>
|
|
<read_first>
|
|
src/client/stores/uiStore.ts
|
|
src/client/components/CreateThreadModal.tsx
|
|
src/client/components/AddToCollectionModal.tsx
|
|
src/client/hooks/useThreads.ts
|
|
src/client/hooks/useCandidates.ts
|
|
src/client/hooks/useGlobalItems.ts
|
|
src/client/hooks/useCategories.ts
|
|
src/shared/schemas.ts
|
|
src/client/lib/api.ts
|
|
src/client/routes/__root.tsx
|
|
</read_first>
|
|
<action>
|
|
**Create `src/client/components/AddToThreadModal.tsx`**
|
|
|
|
This modal has two modes: "pick" (select existing thread) and "create" (new thread + candidate).
|
|
|
|
**Component structure:**
|
|
|
|
```typescript
|
|
import { useState, useEffect } from "react";
|
|
import { toast } from "sonner";
|
|
import { useCategories } from "../hooks/useCategories";
|
|
import { useGlobalItem } from "../hooks/useGlobalItems";
|
|
import { useCreateThread, useThreads } from "../hooks/useThreads";
|
|
import { apiPost } from "../lib/api";
|
|
import { useUIStore } from "../stores/uiStore";
|
|
import { useQueryClient } from "@tanstack/react-query";
|
|
```
|
|
|
|
**State management:**
|
|
- Read from UIStore: `addToThreadModal` (open, globalItemId, globalItemName), `closeAddToThread`, `catalogSessionThreadId`, `setCatalogSessionThreadId`
|
|
- Local state: `mode` ("pick" | "create"), `selectedThreadId` (number | null), `newThreadName` (string), `newThreadCategoryId` (number | null), `isSubmitting` (boolean), `error` (string | null)
|
|
- Fetch: `useThreads()` for thread list, `useCategories()` for new thread category dropdown, `useGlobalItem(globalItemId)` for the global item data needed to create the candidate
|
|
- `useCreateThread()` for thread creation, `useQueryClient()` for manual invalidation
|
|
|
|
**Initialization logic (per D-12, D-19):**
|
|
- When modal opens (`open` becomes true), if `catalogSessionThreadId` is set, pre-select it in the thread picker (`selectedThreadId = catalogSessionThreadId`)
|
|
- Filter threads to active only: `threads?.filter((t) => t.status === "active") ?? []`
|
|
- If no active threads exist, auto-switch to "create" mode (per D-09 empty state)
|
|
|
|
**"Pick" mode UI (per D-06, D-07):**
|
|
- Header: "Add to Thread"
|
|
- Subheading: show `globalItemName` in `<p className="text-sm text-gray-500 mb-4">`
|
|
- Thread dropdown `<select>`:
|
|
- Each active thread as `<option value={t.id}>{t.name} ({t.categoryName})</option>` -- show category alongside name per discretion
|
|
- Last option: `<option value="new">+ New Thread...</option>`
|
|
- When "new" is selected, switch `mode` to "create"
|
|
- Submit button: "Add as Candidate", disabled during `isSubmitting`
|
|
- Cancel button: calls `closeAddToThread()`
|
|
|
|
**"Create" mode UI (per D-11, D-13):**
|
|
- Header: "New Thread + Candidate"
|
|
- Subheading: show `globalItemName`
|
|
- Thread name input: same as CreateThreadModal
|
|
- Category dropdown: same as CreateThreadModal (from `useCategories()`)
|
|
- Submit button: "Create & Add", disabled during `isSubmitting`
|
|
- Cancel button: goes back to "pick" mode if active threads exist, otherwise calls `closeAddToThread()`
|
|
- Back link: "Back to thread picker" if active threads exist
|
|
|
|
**Submit handler for "pick" mode:**
|
|
```typescript
|
|
async function handleAddToExistingThread() {
|
|
if (!selectedThreadId || !globalItemId) return;
|
|
setIsSubmitting(true);
|
|
setError(null);
|
|
try {
|
|
const thread = activeThreads.find((t) => t.id === selectedThreadId);
|
|
await apiPost(`/api/threads/${selectedThreadId}/candidates`, {
|
|
name: globalItemName ?? "Unknown Item",
|
|
globalItemId,
|
|
categoryId: thread?.categoryId ?? categories?.[0]?.id ?? 1,
|
|
weightGrams: globalItem?.weightGrams ?? undefined,
|
|
priceCents: globalItem?.priceCents ?? undefined,
|
|
});
|
|
queryClient.invalidateQueries({ queryKey: ["threads"] });
|
|
queryClient.invalidateQueries({ queryKey: ["threads", selectedThreadId] });
|
|
setCatalogSessionThreadId(selectedThreadId);
|
|
toast.success(`Added to "${thread?.name ?? "thread"}"`);
|
|
closeAddToThread();
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Failed to add candidate");
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
}
|
|
```
|
|
|
|
Use `apiPost` directly instead of `useCreateCandidate` hook because `useCreateCandidate(threadId)` requires threadId at hook initialization time. For the "pick" flow the threadId changes with selection. Using `apiPost` directly avoids this pitfall (per RESEARCH.md Pitfall 1).
|
|
|
|
**Submit handler for "create" mode (per D-11):**
|
|
```typescript
|
|
async function handleCreateThreadAndAdd() {
|
|
const trimmedName = newThreadName.trim();
|
|
if (!trimmedName || !newThreadCategoryId || !globalItemId) return;
|
|
setIsSubmitting(true);
|
|
setError(null);
|
|
try {
|
|
const thread = await createThread.mutateAsync({ name: trimmedName, categoryId: newThreadCategoryId });
|
|
await apiPost(`/api/threads/${thread.id}/candidates`, {
|
|
name: globalItemName ?? "Unknown Item",
|
|
globalItemId,
|
|
categoryId: newThreadCategoryId,
|
|
weightGrams: globalItem?.weightGrams ?? undefined,
|
|
priceCents: globalItem?.priceCents ?? undefined,
|
|
});
|
|
queryClient.invalidateQueries({ queryKey: ["threads"] });
|
|
setCatalogSessionThreadId(thread.id);
|
|
toast.success(`Created "${trimmedName}" with first candidate`);
|
|
closeAddToThread();
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Failed to create thread");
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
}
|
|
```
|
|
|
|
**Form reset:** `useEffect` watching `open` -- when it becomes false, reset all local state (mode back to "pick", clear name, selectedThreadId, error).
|
|
|
|
**Modal rendering:** Follow CreateThreadModal pattern:
|
|
- `fixed inset-0 z-50 flex items-center justify-center bg-black/50`
|
|
- onClick backdrop = close
|
|
- Escape key = close
|
|
- Inner: `w-full max-w-md bg-white rounded-xl shadow-xl p-6`, `e.stopPropagation()`
|
|
|
|
**Add to root layout** (`src/client/routes/__root.tsx`):
|
|
Add import:
|
|
```typescript
|
|
import { AddToThreadModal } from "../components/AddToThreadModal";
|
|
```
|
|
Add `<AddToThreadModal />` right after `<AddToCollectionModal />` in the JSX.
|
|
</action>
|
|
<verify>
|
|
<automated>cd /home/jean-luc-makiola/Development/projects/GearBox && bun run build 2>&1 | tail -5</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- `src/client/components/AddToThreadModal.tsx` exists with `export function AddToThreadModal`
|
|
- `AddToThreadModal.tsx` contains `useThreads` import
|
|
- `AddToThreadModal.tsx` contains `useCreateThread` import
|
|
- `AddToThreadModal.tsx` contains `useGlobalItem` import
|
|
- `AddToThreadModal.tsx` contains `apiPost` import from `../lib/api`
|
|
- `AddToThreadModal.tsx` contains `setCatalogSessionThreadId`
|
|
- `AddToThreadModal.tsx` contains `catalogSessionThreadId`
|
|
- `AddToThreadModal.tsx` contains `toast.success`
|
|
- `AddToThreadModal.tsx` contains `globalItemId` in the apiPost call body
|
|
- `AddToThreadModal.tsx` contains `mode` state with "pick" and "create" values
|
|
- `AddToThreadModal.tsx` contains `+ New Thread...` option text
|
|
- `src/client/routes/__root.tsx` contains `import { AddToThreadModal }`
|
|
- `src/client/routes/__root.tsx` contains `<AddToThreadModal />`
|
|
- `bun run build` exits with code 0
|
|
</acceptance_criteria>
|
|
<done>AddToThreadModal supports picking an existing active thread and creating a new thread with first candidate. Session thread tracking persists across adds. Both catalog search overlay and global item detail page wire to this modal via UIStore. Build passes.</done>
|
|
</task>
|
|
|
|
<task type="checkpoint:human-verify" gate="blocking">
|
|
<name>Task 2: Verify complete add-from-catalog flows</name>
|
|
<what-built>
|
|
Complete add-from-catalog and add-to-thread flows wired through catalog search overlay and global item detail pages. Two modal components (AddToCollectionModal, AddToThreadModal) with toast notifications.
|
|
</what-built>
|
|
<how-to-verify>
|
|
Run `bun run dev` and test in browser at http://localhost:5173:
|
|
|
|
**Flow 1: Add to Collection from catalog search**
|
|
1. Click the FAB (floating action button)
|
|
2. Select "Add to Collection"
|
|
3. Catalog search overlay opens with "Adding to Collection" context
|
|
4. Search for an item and click "Add" on a card
|
|
5. Verify: AddToCollectionModal opens with category dropdown, notes field, purchase price field
|
|
6. Select a category, optionally fill notes/price, click "Add to Collection"
|
|
7. Verify: toast "Added to Collection" appears, modal closes, overlay stays open
|
|
8. Verify: item appears in collection (navigate to /collection to check)
|
|
|
|
**Flow 2: Add to Collection from global item detail page**
|
|
1. Click a catalog card to navigate to its detail page (`/global-items/:id`)
|
|
2. Verify: both "Add to Collection" and "Add to Thread" buttons are visible
|
|
3. Click "Add to Collection"
|
|
4. Verify: same modal opens, submit works, toast appears
|
|
|
|
**Flow 3: Add to Thread from catalog search (existing thread)**
|
|
1. Ensure at least one active thread exists (create one via Planning tab if needed)
|
|
2. Click FAB > "Start Thread"
|
|
3. Search for an item and click "Add"
|
|
4. Verify: thread picker modal opens listing active threads with category names
|
|
5. Select a thread and click "Add as Candidate"
|
|
6. Verify: toast "Added to [Thread Name]" appears
|
|
7. Click "Add" on another item -- verify the previously selected thread is pre-selected
|
|
|
|
**Flow 4: New Thread creation from thread picker**
|
|
1. In thread mode, click "Add" on a catalog card
|
|
2. In the thread picker dropdown, select "+ New Thread..."
|
|
3. Verify: form switches to show thread name + category fields
|
|
4. Fill in thread name, select category, click "Create & Add"
|
|
5. Verify: toast "Created [name] with first candidate" appears
|
|
6. Click "Add" on another card -- verify the new thread is pre-selected (session memory)
|
|
|
|
**Flow 5: Add to Thread from global item detail page**
|
|
1. Navigate to a global item detail page
|
|
2. Click "Add to Thread"
|
|
3. Verify: thread picker modal opens, same as Flow 3
|
|
|
|
**Flow 6: Thread resolution (CATFLOW-06 -- existing behavior)**
|
|
1. Go to a thread that has a catalog-linked candidate (from Flow 3/4)
|
|
2. Resolve the thread by selecting that candidate
|
|
3. Verify: a new item appears in collection with the global item data
|
|
</how-to-verify>
|
|
<resume-signal>Type "approved" or describe any issues found</resume-signal>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<verification>
|
|
1. `bun run build` passes with no type errors
|
|
2. `bun test` passes (no backend changes, existing tests should remain green)
|
|
3. Add-to-collection flow works from both entry points
|
|
4. Add-to-thread flow works with existing threads and new thread creation
|
|
5. Session thread memory works within a search session
|
|
6. Thread resolution with catalog-linked candidate creates reference item
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
- CATFLOW-05 is functional: user can add catalog items as thread candidates from search
|
|
- CATFLOW-06 is verified: resolving a catalog-linked candidate creates a reference item
|
|
- AddToThreadModal supports existing thread selection AND new thread + candidate creation
|
|
- Session thread tracking remembers selection within a catalog search session
|
|
- All flows accessible from both catalog search overlay and global item detail page
|
|
</success_criteria>
|
|
|
|
<output>
|
|
After completion, create `.planning/phases/22-add-from-catalog-thread-integration/22-02-SUMMARY.md`
|
|
</output>
|