refactor(mobile): new-project draft store + formSheet pickers

Replaces the one-off DraftPickerModal (RN <Modal transparent fade> +
centered card) in project/new.tsx with the same cross-route draft-store +
formSheet picker route pattern as new-issue. Status / priority chips now
push /new-project-picker/<field> like the new-issue chips do, and the
picker bodies are reused as-is.

Removes the last hand-rolled modal sheet introduced after the Lesson 6
formSheet migration — keeping the rule "every sheet is a formSheet route"
intact across the codebase.
This commit is contained in:
Naiyuan Qing
2026-05-20 12:10:46 +08:00
parent d77d13f23b
commit 7337206f9e
5 changed files with 169 additions and 74 deletions

View File

@@ -14,6 +14,7 @@ import { usePresenceRealtime } from "@/data/realtime/use-presence-realtime";
import { useWorkspacePresencePrefetch } from "@/lib/use-workspace-presence-prefetch";
import { ModalCloseButton } from "@/components/ui/modal-close-button";
import { useNewIssueDraftResetOnWorkspaceChange } from "@/data/stores/new-issue-draft-store";
import { useNewProjectDraftResetOnWorkspaceChange } from "@/data/stores/new-project-draft-store";
import { useChatSessionPickerResetOnWorkspaceChange } from "@/data/stores/chat-session-picker-store";
/**
@@ -107,6 +108,7 @@ export default function WorkspaceLayout() {
// changes — a draft picked under workspace A (assignee id, draft
// session id, etc.) is invalid in workspace B and must not leak.
useNewIssueDraftResetOnWorkspaceChange(matched?.id ?? null);
useNewProjectDraftResetOnWorkspaceChange(matched?.id ?? null);
useChatSessionPickerResetOnWorkspaceChange(matched?.id ?? null);
// Wait for the workspaces list before deciding membership — otherwise a
@@ -235,6 +237,16 @@ export default function WorkspaceLayout() {
name="new-issue-picker/due-date"
options={SHEET_OPTIONS}
/>
{/* New-project draft formSheet pickers — same pattern as
new-issue-picker/*. Stacked on top of `project/new` (a modal). */}
<Stack.Screen
name="new-project-picker/status"
options={SHEET_OPTIONS}
/>
<Stack.Screen
name="new-project-picker/priority"
options={SHEET_OPTIONS}
/>
{/* Shared filter sheet for My Issues and the workspace Issues page —
chooses the right view-store via `?scope=my|all` URL param. */}
<Stack.Screen name="issues-filter" options={SHEET_OPTIONS} />

View File

@@ -0,0 +1,21 @@
/**
* Priority picker route for the in-progress new-project draft. See ./status.tsx.
*/
import { router } from "expo-router";
import { ProjectPriorityPickerBody } from "@/components/project/pickers/project-priority-picker-body";
import { useNewProjectDraftStore } from "@/data/stores/new-project-draft-store";
export default function NewProjectPriorityPickerRoute() {
const priority = useNewProjectDraftStore((s) => s.priority);
const setPriority = useNewProjectDraftStore((s) => s.setPriority);
return (
<ProjectPriorityPickerBody
value={priority}
onChange={(next) => {
setPriority(next);
router.back();
}}
/>
);
}

View File

@@ -0,0 +1,24 @@
/**
* Status picker route for the in-progress new-project draft. Reads/writes
* `useNewProjectDraftStore` — the project/new.tsx modal owns the draft and
* reads from the same store. See ../project/new.tsx for the lifecycle, and
* ../new-issue-picker/status.tsx for the mirror pattern.
*/
import { router } from "expo-router";
import { ProjectStatusPickerBody } from "@/components/project/pickers/project-status-picker-body";
import { useNewProjectDraftStore } from "@/data/stores/new-project-draft-store";
export default function NewProjectStatusPickerRoute() {
const status = useNewProjectDraftStore((s) => s.status);
const setStatus = useNewProjectDraftStore((s) => s.setStatus);
return (
<ProjectStatusPickerBody
value={status}
onChange={(next) => {
setStatus(next);
router.back();
}}
/>
);
}

View File

@@ -8,6 +8,10 @@
* project from a "I need to track this stream of work" intent and figure
* out who's leading it later. The picker lives on the detail screen.
*
* Status / priority cross-route through `useNewProjectDraftStore` so the
* formSheet picker routes can read/write them — same pattern as
* new-issue.tsx + new-issue-picker/* (see new-project-draft-store.ts).
*
* On success: dismiss modal → navigate to the new project's detail page so
* the user can immediately add a lead / attach issues / configure properties.
*/
@@ -16,7 +20,6 @@ import {
Alert,
InteractionManager,
KeyboardAvoidingView,
Modal,
Platform,
Pressable,
ScrollView,
@@ -24,7 +27,6 @@ import {
View,
} from "react-native";
import { Stack, router } from "expo-router";
import type { ProjectPriority, ProjectStatus } from "@multica/core/types";
import { Text } from "@/components/ui/text";
import { AutosizeTextArea } from "@/components/ui/autosize-textarea";
import {
@@ -33,15 +35,25 @@ import {
} from "@/components/ui/input-tokens";
import { ProjectStatusIcon } from "@/components/ui/project-status-icon";
import { ProjectPriorityIcon } from "@/components/ui/project-priority-icon";
import { ProjectStatusPickerBody } from "@/components/project/pickers/project-status-picker-body";
import { ProjectPriorityPickerBody } from "@/components/project/pickers/project-priority-picker-body";
import {
projectPriorityLabel,
projectStatusLabel,
} from "@/lib/project-status";
import { useCreateProject } from "@/data/mutations/projects";
import { useNewProjectDraftStore } from "@/data/stores/new-project-draft-store";
import { useWorkspaceStore } from "@/data/workspace-store";
/**
* Typed map of new-project picker route pathnames. Keeps `router.push` calls
* compile-checked rather than depending on free-form template strings —
* same approach as `create-form-attribute-row.tsx`.
*/
type NewProjectPickerField = "status" | "priority";
const NEW_PROJECT_PICKER_PATHNAMES = {
status: "/[workspace]/new-project-picker/status",
priority: "/[workspace]/new-project-picker/priority",
} as const satisfies Record<NewProjectPickerField, string>;
export default function NewProject() {
const wsSlug = useWorkspaceStore((s) => s.currentWorkspaceSlug);
const create = useCreateProject();
@@ -49,11 +61,9 @@ export default function NewProject() {
const [title, setTitle] = useState("");
const [icon, setIcon] = useState("");
const [description, setDescription] = useState("");
const [status, setStatus] = useState<ProjectStatus>("planned");
const [priority, setPriority] = useState<ProjectPriority>("none");
const [statusOpen, setStatusOpen] = useState(false);
const [priorityOpen, setPriorityOpen] = useState(false);
const status = useNewProjectDraftStore((s) => s.status);
const priority = useNewProjectDraftStore((s) => s.priority);
const resetDraft = useNewProjectDraftStore((s) => s.reset);
const dirty =
title.length > 0 ||
@@ -64,8 +74,20 @@ export default function NewProject() {
const canCreate = title.trim().length > 0 && !create.isPending;
const openPicker = useCallback(
(field: NewProjectPickerField) => {
if (!wsSlug) return;
router.push({
pathname: NEW_PROJECT_PICKER_PATHNAMES[field],
params: { workspace: wsSlug },
});
},
[wsSlug],
);
const onCancel = useCallback(() => {
if (!dirty) {
resetDraft();
router.back();
return;
}
@@ -77,11 +99,14 @@ export default function NewProject() {
{
text: "Discard",
style: "destructive",
onPress: () => router.back(),
onPress: () => {
resetDraft();
router.back();
},
},
],
);
}, [dirty]);
}, [dirty, resetDraft]);
const onCreate = useCallback(() => {
if (!canCreate) return;
@@ -95,6 +120,7 @@ export default function NewProject() {
},
{
onSuccess: (project) => {
resetDraft();
router.back();
// Wait for the modal dismiss animation to finish before pushing
// the detail screen. `InteractionManager` resolves once iOS
@@ -113,7 +139,17 @@ export default function NewProject() {
},
},
);
}, [canCreate, create, title, description, icon, status, priority, wsSlug]);
}, [
canCreate,
create,
title,
description,
icon,
status,
priority,
wsSlug,
resetDraft,
]);
const headerLeft = useCallback(() => {
return (
@@ -186,7 +222,7 @@ export default function NewProject() {
<View className="flex-1">
<Field label="Status">
<Pressable
onPress={() => setStatusOpen(true)}
onPress={() => openPicker("status")}
className="flex-row items-center gap-2 bg-secondary/50 rounded-md px-3 py-2.5"
>
<ProjectStatusIcon status={status} size={16} />
@@ -199,7 +235,7 @@ export default function NewProject() {
<View className="flex-1">
<Field label="Priority">
<Pressable
onPress={() => setPriorityOpen(true)}
onPress={() => openPicker("priority")}
className="flex-row items-center gap-2 bg-secondary/50 rounded-md px-3 py-2.5"
>
<ProjectPriorityIcon priority={priority} size={16} />
@@ -212,70 +248,10 @@ export default function NewProject() {
</View>
</ScrollView>
</KeyboardAvoidingView>
<DraftPickerModal visible={statusOpen} onClose={() => setStatusOpen(false)}>
<ProjectStatusPickerBody
value={status}
onChange={(next) => {
setStatus(next);
setStatusOpen(false);
}}
/>
</DraftPickerModal>
<DraftPickerModal
visible={priorityOpen}
onClose={() => setPriorityOpen(false)}
>
<ProjectPriorityPickerBody
value={priority}
onChange={(next) => {
setPriority(next);
setPriorityOpen(false);
}}
/>
</DraftPickerModal>
</>
);
}
/**
* Inline modal shell for the new-project draft pickers. The host screen is
* already a presentation="modal", and routes can't read draft local state
* (the project doesn't exist yet, nothing in the cache to push the picker
* to). So we keep a lightweight centered card here rather than wire a
* separate draft store for a single transient form — matches the carve-out
* in apps/mobile/CLAUDE.md "modal container selection" for short fixed
* picker lists with no neighbour pickers to be consistent with.
*/
function DraftPickerModal({
visible,
onClose,
children,
}: {
visible: boolean;
onClose: () => void;
children: React.ReactNode;
}) {
return (
<Modal
visible={visible}
transparent
animationType="fade"
onRequestClose={onClose}
>
<Pressable className="flex-1 bg-black/40" onPress={onClose}>
<View className="flex-1 items-center justify-center px-8">
<Pressable onPress={() => {}} className="w-full max-w-sm">
<View className="bg-popover rounded-2xl overflow-hidden">
{children}
</View>
</Pressable>
</View>
</Pressable>
</Modal>
);
}
function Field({
label,
children,

View File

@@ -0,0 +1,62 @@
/**
* Draft state for the New Project modal (`app/(app)/[workspace]/project/new.tsx`).
*
* Mirrors `new-issue-draft-store.ts` — same rationale: the formSheet picker
* routes (`new-project-picker/status.tsx`, `new-project-picker/priority.tsx`)
* live in a separate Stack screen and have no React parent-child relationship
* with the new-project modal. They need a way to read the current draft value
* and write the new selection back without prop-drilling through the router.
* A small Zustand store is the minimum-viable cross-screen channel — and the
* same pattern as new-issue.
*
* Lifecycle: `reset()` runs from `project/new.tsx` when the user dismisses
* the modal (either submit succeeds or they cancel) so the next open starts
* clean. Title / description / icon stay local useState because they're
* controlled inputs that never leave the new-project screen; only the
* attribute-chip values cross routes.
*
* Workspace lifecycle: workspace-scoped — reset is wired in
* `app/(app)/[workspace]/_layout.tsx` via
* `useNewProjectDraftResetOnWorkspaceChange()`, the only caller.
*/
import { useEffect, useRef } from "react";
import { create } from "zustand";
import type { ProjectPriority, ProjectStatus } from "@multica/core/types";
interface NewProjectDraftState {
status: ProjectStatus;
priority: ProjectPriority;
setStatus: (next: ProjectStatus) => void;
setPriority: (next: ProjectPriority) => void;
reset: () => void;
}
const INITIAL: Pick<NewProjectDraftState, "status" | "priority"> = {
status: "planned",
priority: "none",
};
export const useNewProjectDraftStore = create<NewProjectDraftState>((set) => ({
...INITIAL,
setStatus: (next) => set({ status: next }),
setPriority: (next) => set({ priority: next }),
reset: () => set({ ...INITIAL }),
}));
/**
* Clears the new-project draft store whenever the active workspace id
* changes. The previous draft is invalid in a different workspace (we may
* later add fields like `lead` whose id only resolves in the seeded
* workspace). The `useRef` gate ensures the first mount is a no-op — we
* only fire `reset()` when the id actually changes, not on the initial
* render where `prev === current`.
*/
export function useNewProjectDraftResetOnWorkspaceChange(wsId: string | null) {
const prevRef = useRef(wsId);
useEffect(() => {
if (prevRef.current !== wsId) {
useNewProjectDraftStore.getState().reset();
prevRef.current = wsId;
}
}, [wsId]);
}