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 { Toaster } from "sonner";
|
||||||
import "../app.css";
|
import "../app.css";
|
||||||
import { AddToCollectionModal } from "../components/AddToCollectionModal";
|
import { AddToCollectionModal } from "../components/AddToCollectionModal";
|
||||||
|
import { AddToThreadModal } from "../components/AddToThreadModal";
|
||||||
import { CatalogSearchOverlay } from "../components/CatalogSearchOverlay";
|
import { CatalogSearchOverlay } from "../components/CatalogSearchOverlay";
|
||||||
import { ConfirmDialog } from "../components/ConfirmDialog";
|
import { ConfirmDialog } from "../components/ConfirmDialog";
|
||||||
import { ExternalLinkDialog } from "../components/ExternalLinkDialog";
|
import { ExternalLinkDialog } from "../components/ExternalLinkDialog";
|
||||||
@@ -208,6 +209,8 @@ function RootLayout() {
|
|||||||
|
|
||||||
{/* Add to Collection Modal */}
|
{/* Add to Collection Modal */}
|
||||||
<AddToCollectionModal />
|
<AddToCollectionModal />
|
||||||
|
{/* Add to Thread Modal */}
|
||||||
|
<AddToThreadModal />
|
||||||
<Toaster position="bottom-right" richColors />
|
<Toaster position="bottom-right" richColors />
|
||||||
|
|
||||||
{/* Onboarding Wizard */}
|
{/* Onboarding Wizard */}
|
||||||
|
|||||||
Reference in New Issue
Block a user