From f50819006547220d885f5e786a8f098377898dfd Mon Sep 17 00:00:00 2001 From: Jiayuan Zhang Date: Wed, 29 Apr 2026 17:58:19 +0200 Subject: [PATCH] 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. --- packages/core/feedback/draft-store.ts | 41 ++++++++++++++++++ packages/core/feedback/index.ts | 1 + packages/core/projects/draft-store.ts | 54 ++++++++++++++++++++++++ packages/core/projects/index.ts | 1 + packages/views/modals/create-project.tsx | 50 ++++++++++++++-------- packages/views/modals/feedback.tsx | 12 ++++-- 6 files changed, 138 insertions(+), 21 deletions(-) create mode 100644 packages/core/feedback/draft-store.ts create mode 100644 packages/core/projects/draft-store.ts diff --git a/packages/core/feedback/draft-store.ts b/packages/core/feedback/draft-store.ts new file mode 100644 index 000000000..91587b37b --- /dev/null +++ b/packages/core/feedback/draft-store.ts @@ -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) => void; + clearDraft: () => void; + hasDraft: () => boolean; +} + +export const useFeedbackDraftStore = create()( + 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()); diff --git a/packages/core/feedback/index.ts b/packages/core/feedback/index.ts index f8dd99d03..105523c16 100644 --- a/packages/core/feedback/index.ts +++ b/packages/core/feedback/index.ts @@ -1 +1,2 @@ export * from "./mutations"; +export { useFeedbackDraftStore } from "./draft-store"; diff --git a/packages/core/projects/draft-store.ts b/packages/core/projects/draft-store.ts new file mode 100644 index 000000000..8b10b9768 --- /dev/null +++ b/packages/core/projects/draft-store.ts @@ -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) => void; + clearDraft: () => void; + hasDraft: () => boolean; +} + +export const useProjectDraftStore = create()( + 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()); diff --git a/packages/core/projects/index.ts b/packages/core/projects/index.ts index 1e63d3c13..9e5c466cd 100644 --- a/packages/core/projects/index.ts +++ b/packages/core/projects/index.ts @@ -1,2 +1,3 @@ export { projectKeys, projectListOptions, projectDetailOptions } from "./queries"; export { useCreateProject, useUpdateProject, useDeleteProject } from "./mutations"; +export { useProjectDraftStore } from "./draft-store"; diff --git a/packages/views/modals/create-project.tsx b/packages/views/modals/create-project.tsx index 8483fcbf6..e0d33ff97 100644 --- a/packages/views/modals/create-project.tsx +++ b/packages/views/modals/create-project.tsx @@ -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(null); - const [status, setStatus] = useState("planned"); - const [priority, setPriority] = useState("none"); - const [leadType, setLeadType] = useState<"member" | "agent" | undefined>(); - const [leadId, setLeadId] = useState(); - const [icon, setIcon] = useState(); + const [status, setStatus] = useState(draft.status); + const [priority, setPriority] = useState(draft.priority); + const [leadType, setLeadType] = useState<"member" | "agent" | undefined>(draft.leadType); + const [leadId, setLeadId] = useState(draft.leadId); + const [icon, setIcon] = useState(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 }) { { - setIcon(emoji); + updateIcon(emoji); setIconPickerOpen(false); }} /> @@ -185,10 +201,10 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) { setTitle(v)} + onChange={(v) => updateTitle(v)} onSubmit={handleSubmit} /> @@ -196,8 +212,9 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
setDraft({ description: md })} debounceMs={500} />
@@ -214,7 +231,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) { /> {PROJECT_STATUS_ORDER.map((s) => ( - setStatus(s)}> + updateStatus(s)}> {PROJECT_STATUS_CONFIG[s].label} @@ -233,7 +250,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) { /> {PROJECT_PRIORITY_ORDER.map((pr) => ( - setPriority(pr)}> + updatePriority(pr)}> {PROJECT_PRIORITY_CONFIG[pr].label} @@ -276,8 +293,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {