feat(22-02): build AddToThreadModal with thread picker and new thread flow
- Create AddToThreadModal with pick/create modes for thread selection - Support existing thread selection with category display - Support new thread creation with candidate in one step - Pre-select session thread via catalogSessionThreadId - Auto-switch to create mode when no active threads exist - Wire AddToThreadModal at root layout level
This commit is contained in:
289
src/client/components/AddToThreadModal.tsx
Normal file
289
src/client/components/AddToThreadModal.tsx
Normal file
@@ -0,0 +1,289 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
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";
|
||||
|
||||
export function AddToThreadModal() {
|
||||
const { open, globalItemId, globalItemName } = useUIStore(
|
||||
(s) => s.addToThreadModal,
|
||||
);
|
||||
const closeAddToThread = useUIStore((s) => s.closeAddToThread);
|
||||
const catalogSessionThreadId = useUIStore(
|
||||
(s) => s.catalogSessionThreadId,
|
||||
);
|
||||
const setCatalogSessionThreadId = useUIStore(
|
||||
(s) => s.setCatalogSessionThreadId,
|
||||
);
|
||||
|
||||
const { data: threads } = useThreads();
|
||||
const { data: categories } = useCategories();
|
||||
const { data: globalItem } = useGlobalItem(globalItemId);
|
||||
const createThread = useCreateThread();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [mode, setMode] = useState<"pick" | "create">("pick");
|
||||
const [selectedThreadId, setSelectedThreadId] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
const [newThreadName, setNewThreadName] = useState("");
|
||||
const [newThreadCategoryId, setNewThreadCategoryId] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const activeThreads = threads?.filter((t) => t.status === "active") ?? [];
|
||||
|
||||
// Pre-select category for new thread creation
|
||||
useEffect(() => {
|
||||
if (categories && categories.length > 0 && newThreadCategoryId === null) {
|
||||
setNewThreadCategoryId(categories[0].id);
|
||||
}
|
||||
}, [categories, newThreadCategoryId]);
|
||||
|
||||
// Initialize selection when modal opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (activeThreads.length === 0) {
|
||||
setMode("create");
|
||||
} else {
|
||||
setMode("pick");
|
||||
if (
|
||||
catalogSessionThreadId &&
|
||||
activeThreads.some((t) => t.id === catalogSessionThreadId)
|
||||
) {
|
||||
setSelectedThreadId(catalogSessionThreadId);
|
||||
} else if (activeThreads.length > 0) {
|
||||
setSelectedThreadId(activeThreads[0].id);
|
||||
}
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
// Reset form state when modal closes
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setMode("pick");
|
||||
setSelectedThreadId(null);
|
||||
setNewThreadName("");
|
||||
setNewThreadCategoryId(categories?.[0]?.id ?? null);
|
||||
setError(null);
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [open, categories]);
|
||||
|
||||
if (!open || !globalItemId) return null;
|
||||
|
||||
function handleClose() {
|
||||
closeAddToThread();
|
||||
}
|
||||
|
||||
function handleSelectChange(value: string) {
|
||||
if (value === "new") {
|
||||
setMode("create");
|
||||
} else {
|
||||
setSelectedThreadId(Number(value));
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (mode === "pick") {
|
||||
handleAddToExistingThread();
|
||||
} else {
|
||||
handleCreateThreadAndAdd();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
||||
onClick={handleClose}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") handleClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
role="document"
|
||||
className="w-full max-w-md bg-white rounded-xl shadow-xl p-6"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={() => {}}
|
||||
>
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-1">
|
||||
{mode === "pick" ? "Add to Thread" : "New Thread + Candidate"}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 mb-4">{globalItemName}</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{mode === "pick" ? (
|
||||
<div>
|
||||
<label
|
||||
htmlFor="thread-select"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Thread
|
||||
</label>
|
||||
<select
|
||||
id="thread-select"
|
||||
value={selectedThreadId ?? ""}
|
||||
onChange={(e) => handleSelectChange(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent bg-white"
|
||||
>
|
||||
{activeThreads.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name} ({t.categoryName})
|
||||
</option>
|
||||
))}
|
||||
<option value="new">+ New Thread...</option>
|
||||
</select>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="new-thread-name"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Thread name
|
||||
</label>
|
||||
<input
|
||||
id="new-thread-name"
|
||||
type="text"
|
||||
value={newThreadName}
|
||||
onChange={(e) => setNewThreadName(e.target.value)}
|
||||
placeholder="e.g. Lightweight sleeping bag"
|
||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="new-thread-category"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Category
|
||||
</label>
|
||||
<select
|
||||
id="new-thread-category"
|
||||
value={newThreadCategoryId ?? ""}
|
||||
onChange={(e) =>
|
||||
setNewThreadCategoryId(Number(e.target.value))
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent bg-white"
|
||||
>
|
||||
{categories?.map((cat) => (
|
||||
<option key={cat.id} value={cat.id}>
|
||||
{cat.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{activeThreads.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode("pick")}
|
||||
className="text-sm text-gray-500 hover:text-gray-700 underline"
|
||||
>
|
||||
Back to thread picker
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={
|
||||
mode === "create" && activeThreads.length > 0
|
||||
? () => setMode("pick")
|
||||
: handleClose
|
||||
}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 disabled:opacity-50 rounded-lg transition-colors"
|
||||
>
|
||||
{isSubmitting
|
||||
? "Adding..."
|
||||
: mode === "pick"
|
||||
? "Add as Candidate"
|
||||
: "Create & Add"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { useState } from "react";
|
||||
import { Toaster } from "sonner";
|
||||
import "../app.css";
|
||||
import { AddToCollectionModal } from "../components/AddToCollectionModal";
|
||||
import { AddToThreadModal } from "../components/AddToThreadModal";
|
||||
import { CatalogSearchOverlay } from "../components/CatalogSearchOverlay";
|
||||
import { ConfirmDialog } from "../components/ConfirmDialog";
|
||||
import { ExternalLinkDialog } from "../components/ExternalLinkDialog";
|
||||
@@ -208,6 +209,8 @@ function RootLayout() {
|
||||
|
||||
{/* Add to Collection Modal */}
|
||||
<AddToCollectionModal />
|
||||
{/* Add to Thread Modal */}
|
||||
<AddToThreadModal />
|
||||
<Toaster position="bottom-right" richColors />
|
||||
|
||||
{/* Onboarding Wizard */}
|
||||
|
||||
Reference in New Issue
Block a user