feat(modals): persist drafts for create-project and feedback modals (#1894)

Add Zustand persisted draft stores for the create-project and feedback
modals, following the same pattern as the existing issue draft store.
Drafts are saved to localStorage on every field change and restored
when the modal reopens, preventing accidental data loss on close.
Draft is cleared on successful submit.
This commit is contained in:
Jiayuan Zhang
2026-04-29 17:58:19 +02:00
committed by GitHub
parent d5611d550a
commit f508190065
6 changed files with 138 additions and 21 deletions

View File

@@ -0,0 +1,41 @@
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../platform/workspace-storage";
import { defaultStorage } from "../platform/storage";
interface FeedbackDraft {
message: string;
}
const EMPTY_DRAFT: FeedbackDraft = {
message: "",
};
interface FeedbackDraftStore {
draft: FeedbackDraft;
setDraft: (patch: Partial<FeedbackDraft>) => void;
clearDraft: () => void;
hasDraft: () => boolean;
}
export const useFeedbackDraftStore = create<FeedbackDraftStore>()(
persist(
(set, get) => ({
draft: { ...EMPTY_DRAFT },
setDraft: (patch) =>
set((s) => ({ draft: { ...s.draft, ...patch } })),
clearDraft: () =>
set({ draft: { ...EMPTY_DRAFT } }),
hasDraft: () => {
const { draft } = get();
return !!draft.message;
},
}),
{
name: "multica_feedback_draft",
storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)),
},
),
);
registerForWorkspaceRehydration(() => useFeedbackDraftStore.persist.rehydrate());

View File

@@ -1 +1,2 @@
export * from "./mutations";
export { useFeedbackDraftStore } from "./draft-store";

View File

@@ -0,0 +1,54 @@
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import type { ProjectStatus, ProjectPriority } from "../types";
import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../platform/workspace-storage";
import { defaultStorage } from "../platform/storage";
interface ProjectDraft {
title: string;
description: string;
status: ProjectStatus;
priority: ProjectPriority;
leadType?: "member" | "agent";
leadId?: string;
icon?: string;
}
const EMPTY_DRAFT: ProjectDraft = {
title: "",
description: "",
status: "planned",
priority: "none",
leadType: undefined,
leadId: undefined,
icon: undefined,
};
interface ProjectDraftStore {
draft: ProjectDraft;
setDraft: (patch: Partial<ProjectDraft>) => void;
clearDraft: () => void;
hasDraft: () => boolean;
}
export const useProjectDraftStore = create<ProjectDraftStore>()(
persist(
(set, get) => ({
draft: { ...EMPTY_DRAFT },
setDraft: (patch) =>
set((s) => ({ draft: { ...s.draft, ...patch } })),
clearDraft: () =>
set({ draft: { ...EMPTY_DRAFT } }),
hasDraft: () => {
const { draft } = get();
return !!(draft.title || draft.description);
},
}),
{
name: "multica_project_draft",
storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)),
},
),
);
registerForWorkspaceRehydration(() => useProjectDraftStore.persist.rehydrate());

View File

@@ -1,2 +1,3 @@
export { projectKeys, projectListOptions, projectDetailOptions } from "./queries";
export { useCreateProject, useUpdateProject, useDeleteProject } from "./mutations";
export { useProjectDraftStore } from "./draft-store";

View File

@@ -4,6 +4,7 @@ import { useState, useRef } from "react";
import { ChevronRight, Maximize2, Minimize2, X as XIcon, UserMinus } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { useCreateProject } from "@multica/core/projects/mutations";
import { useProjectDraftStore } from "@multica/core/projects";
import {
PROJECT_STATUS_CONFIG,
PROJECT_STATUS_ORDER,
@@ -63,17 +64,31 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const { getActorName } = useActorName();
const [title, setTitle] = useState("");
const draft = useProjectDraftStore((s) => s.draft);
const setDraft = useProjectDraftStore((s) => s.setDraft);
const clearDraft = useProjectDraftStore((s) => s.clearDraft);
const [title, setTitle] = useState(draft.title);
const descEditorRef = useRef<ContentEditorRef>(null);
const [status, setStatus] = useState<ProjectStatus>("planned");
const [priority, setPriority] = useState<ProjectPriority>("none");
const [leadType, setLeadType] = useState<"member" | "agent" | undefined>();
const [leadId, setLeadId] = useState<string | undefined>();
const [icon, setIcon] = useState<string | undefined>();
const [status, setStatus] = useState<ProjectStatus>(draft.status);
const [priority, setPriority] = useState<ProjectPriority>(draft.priority);
const [leadType, setLeadType] = useState<"member" | "agent" | undefined>(draft.leadType);
const [leadId, setLeadId] = useState<string | undefined>(draft.leadId);
const [icon, setIcon] = useState<string | undefined>(draft.icon);
const [iconPickerOpen, setIconPickerOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
// Sync field changes to draft store
const updateTitle = (v: string) => { setTitle(v); setDraft({ title: v }); };
const updateStatus = (v: ProjectStatus) => { setStatus(v); setDraft({ status: v }); };
const updatePriority = (v: ProjectPriority) => { setPriority(v); setDraft({ priority: v }); };
const updateLead = (type?: "member" | "agent", id?: string) => {
setLeadType(type); setLeadId(id);
setDraft({ leadType: type, leadId: id });
};
const updateIcon = (v: string | undefined) => { setIcon(v); setDraft({ icon: v }); };
const [leadOpen, setLeadOpen] = useState(false);
const [leadFilter, setLeadFilter] = useState("");
@@ -100,6 +115,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
lead_type: leadType,
lead_id: leadId,
});
clearDraft();
onClose();
toast.success("Project created");
router.push(wsPaths.projectDetail(project.id));
@@ -177,7 +193,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
<PopoverContent align="start" className="w-auto p-0">
<EmojiPicker
onSelect={(emoji) => {
setIcon(emoji);
updateIcon(emoji);
setIconPickerOpen(false);
}}
/>
@@ -185,10 +201,10 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
</Popover>
<TitleEditor
autoFocus
defaultValue=""
defaultValue={draft.title}
placeholder="Project title"
className="text-lg font-semibold"
onChange={(v) => setTitle(v)}
onChange={(v) => updateTitle(v)}
onSubmit={handleSubmit}
/>
</div>
@@ -196,8 +212,9 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
<div className="flex-1 min-h-0 overflow-y-auto px-5">
<ContentEditor
ref={descEditorRef}
defaultValue=""
defaultValue={draft.description}
placeholder="Add description..."
onUpdate={(md) => setDraft({ description: md })}
debounceMs={500}
/>
</div>
@@ -214,7 +231,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
/>
<DropdownMenuContent align="start" className="w-44">
{PROJECT_STATUS_ORDER.map((s) => (
<DropdownMenuItem key={s} onClick={() => setStatus(s)}>
<DropdownMenuItem key={s} onClick={() => updateStatus(s)}>
<span className={cn("size-2 rounded-full", PROJECT_STATUS_CONFIG[s].dotColor)} />
<span>{PROJECT_STATUS_CONFIG[s].label}</span>
</DropdownMenuItem>
@@ -233,7 +250,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
/>
<DropdownMenuContent align="start" className="w-44">
{PROJECT_PRIORITY_ORDER.map((pr) => (
<DropdownMenuItem key={pr} onClick={() => setPriority(pr)}>
<DropdownMenuItem key={pr} onClick={() => updatePriority(pr)}>
<PriorityIcon priority={pr} />
<span>{PROJECT_PRIORITY_CONFIG[pr].label}</span>
</DropdownMenuItem>
@@ -276,8 +293,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
<button
type="button"
onClick={() => {
setLeadType(undefined);
setLeadId(undefined);
updateLead(undefined, undefined);
setLeadOpen(false);
}}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
@@ -295,8 +311,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
type="button"
key={m.user_id}
onClick={() => {
setLeadType("member");
setLeadId(m.user_id);
updateLead("member", m.user_id);
setLeadOpen(false);
}}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
@@ -317,8 +332,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
type="button"
key={a.id}
onClick={() => {
setLeadType("agent");
setLeadId(a.id);
updateLead("agent", a.id);
setLeadOpen(false);
}}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"

View File

@@ -16,7 +16,7 @@ import {
useFileDropZone,
FileDropOverlay,
} from "../editor";
import { useCreateFeedback } from "@multica/core/feedback";
import { useCreateFeedback, useFeedbackDraftStore } from "@multica/core/feedback";
import { useCurrentWorkspace } from "@multica/core/paths";
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
import { api } from "@multica/core/api";
@@ -26,8 +26,12 @@ const MAX_MESSAGE_LEN = 10000;
export function FeedbackModal({ onClose }: { onClose: () => void }) {
const workspace = useCurrentWorkspace();
const draft = useFeedbackDraftStore((s) => s.draft);
const setDraft = useFeedbackDraftStore((s) => s.setDraft);
const clearDraft = useFeedbackDraftStore((s) => s.clearDraft);
const editorRef = useRef<ContentEditorRef>(null);
const [message, setMessage] = useState("");
const [message, setMessage] = useState(draft.message);
const { isDragOver, dropZoneProps } = useFileDropZone({
onDrop: (files) => files.forEach((f) => editorRef.current?.uploadFile(f)),
});
@@ -69,6 +73,7 @@ export function FeedbackModal({ onClose }: { onClose: () => void }) {
url: typeof window !== "undefined" ? window.location.href : undefined,
workspace_id: workspace?.id,
});
clearDraft();
toast.success("Thanks for the feedback!");
onClose();
} catch (err) {
@@ -98,8 +103,9 @@ export function FeedbackModal({ onClose }: { onClose: () => void }) {
>
<ContentEditor
ref={editorRef}
defaultValue={draft.message}
placeholder="Tell us about your experience, bugs you've found, or features you'd like to see…"
onUpdate={(md) => setMessage(md)}
onUpdate={(md) => { setMessage(md); setDraft({ message: md }); }}
onUploadFile={uploadWithToast}
onSubmit={handleSubmit}
debounceMs={150}