mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-16 19:29:26 +02:00
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:
@@ -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} />
|
||||
|
||||
@@ -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();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
62
apps/mobile/data/stores/new-project-draft-store.ts
Normal file
62
apps/mobile/data/stores/new-project-draft-store.ts
Normal 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]);
|
||||
}
|
||||
Reference in New Issue
Block a user