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 { useWorkspacePresencePrefetch } from "@/lib/use-workspace-presence-prefetch";
|
||||||
import { ModalCloseButton } from "@/components/ui/modal-close-button";
|
import { ModalCloseButton } from "@/components/ui/modal-close-button";
|
||||||
import { useNewIssueDraftResetOnWorkspaceChange } from "@/data/stores/new-issue-draft-store";
|
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";
|
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
|
// changes — a draft picked under workspace A (assignee id, draft
|
||||||
// session id, etc.) is invalid in workspace B and must not leak.
|
// session id, etc.) is invalid in workspace B and must not leak.
|
||||||
useNewIssueDraftResetOnWorkspaceChange(matched?.id ?? null);
|
useNewIssueDraftResetOnWorkspaceChange(matched?.id ?? null);
|
||||||
|
useNewProjectDraftResetOnWorkspaceChange(matched?.id ?? null);
|
||||||
useChatSessionPickerResetOnWorkspaceChange(matched?.id ?? null);
|
useChatSessionPickerResetOnWorkspaceChange(matched?.id ?? null);
|
||||||
|
|
||||||
// Wait for the workspaces list before deciding membership — otherwise a
|
// Wait for the workspaces list before deciding membership — otherwise a
|
||||||
@@ -235,6 +237,16 @@ export default function WorkspaceLayout() {
|
|||||||
name="new-issue-picker/due-date"
|
name="new-issue-picker/due-date"
|
||||||
options={SHEET_OPTIONS}
|
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 —
|
{/* Shared filter sheet for My Issues and the workspace Issues page —
|
||||||
chooses the right view-store via `?scope=my|all` URL param. */}
|
chooses the right view-store via `?scope=my|all` URL param. */}
|
||||||
<Stack.Screen name="issues-filter" options={SHEET_OPTIONS} />
|
<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
|
* 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.
|
* 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
|
* On success: dismiss modal → navigate to the new project's detail page so
|
||||||
* the user can immediately add a lead / attach issues / configure properties.
|
* the user can immediately add a lead / attach issues / configure properties.
|
||||||
*/
|
*/
|
||||||
@@ -16,7 +20,6 @@ import {
|
|||||||
Alert,
|
Alert,
|
||||||
InteractionManager,
|
InteractionManager,
|
||||||
KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
Modal,
|
|
||||||
Platform,
|
Platform,
|
||||||
Pressable,
|
Pressable,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
@@ -24,7 +27,6 @@ import {
|
|||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { Stack, router } from "expo-router";
|
import { Stack, router } from "expo-router";
|
||||||
import type { ProjectPriority, ProjectStatus } from "@multica/core/types";
|
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import { AutosizeTextArea } from "@/components/ui/autosize-textarea";
|
import { AutosizeTextArea } from "@/components/ui/autosize-textarea";
|
||||||
import {
|
import {
|
||||||
@@ -33,15 +35,25 @@ import {
|
|||||||
} from "@/components/ui/input-tokens";
|
} from "@/components/ui/input-tokens";
|
||||||
import { ProjectStatusIcon } from "@/components/ui/project-status-icon";
|
import { ProjectStatusIcon } from "@/components/ui/project-status-icon";
|
||||||
import { ProjectPriorityIcon } from "@/components/ui/project-priority-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 {
|
import {
|
||||||
projectPriorityLabel,
|
projectPriorityLabel,
|
||||||
projectStatusLabel,
|
projectStatusLabel,
|
||||||
} from "@/lib/project-status";
|
} from "@/lib/project-status";
|
||||||
import { useCreateProject } from "@/data/mutations/projects";
|
import { useCreateProject } from "@/data/mutations/projects";
|
||||||
|
import { useNewProjectDraftStore } from "@/data/stores/new-project-draft-store";
|
||||||
import { useWorkspaceStore } from "@/data/workspace-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() {
|
export default function NewProject() {
|
||||||
const wsSlug = useWorkspaceStore((s) => s.currentWorkspaceSlug);
|
const wsSlug = useWorkspaceStore((s) => s.currentWorkspaceSlug);
|
||||||
const create = useCreateProject();
|
const create = useCreateProject();
|
||||||
@@ -49,11 +61,9 @@ export default function NewProject() {
|
|||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
const [icon, setIcon] = useState("");
|
const [icon, setIcon] = useState("");
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
const [status, setStatus] = useState<ProjectStatus>("planned");
|
const status = useNewProjectDraftStore((s) => s.status);
|
||||||
const [priority, setPriority] = useState<ProjectPriority>("none");
|
const priority = useNewProjectDraftStore((s) => s.priority);
|
||||||
|
const resetDraft = useNewProjectDraftStore((s) => s.reset);
|
||||||
const [statusOpen, setStatusOpen] = useState(false);
|
|
||||||
const [priorityOpen, setPriorityOpen] = useState(false);
|
|
||||||
|
|
||||||
const dirty =
|
const dirty =
|
||||||
title.length > 0 ||
|
title.length > 0 ||
|
||||||
@@ -64,8 +74,20 @@ export default function NewProject() {
|
|||||||
|
|
||||||
const canCreate = title.trim().length > 0 && !create.isPending;
|
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(() => {
|
const onCancel = useCallback(() => {
|
||||||
if (!dirty) {
|
if (!dirty) {
|
||||||
|
resetDraft();
|
||||||
router.back();
|
router.back();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -77,11 +99,14 @@ export default function NewProject() {
|
|||||||
{
|
{
|
||||||
text: "Discard",
|
text: "Discard",
|
||||||
style: "destructive",
|
style: "destructive",
|
||||||
onPress: () => router.back(),
|
onPress: () => {
|
||||||
|
resetDraft();
|
||||||
|
router.back();
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}, [dirty]);
|
}, [dirty, resetDraft]);
|
||||||
|
|
||||||
const onCreate = useCallback(() => {
|
const onCreate = useCallback(() => {
|
||||||
if (!canCreate) return;
|
if (!canCreate) return;
|
||||||
@@ -95,6 +120,7 @@ export default function NewProject() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
onSuccess: (project) => {
|
onSuccess: (project) => {
|
||||||
|
resetDraft();
|
||||||
router.back();
|
router.back();
|
||||||
// Wait for the modal dismiss animation to finish before pushing
|
// Wait for the modal dismiss animation to finish before pushing
|
||||||
// the detail screen. `InteractionManager` resolves once iOS
|
// 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(() => {
|
const headerLeft = useCallback(() => {
|
||||||
return (
|
return (
|
||||||
@@ -186,7 +222,7 @@ export default function NewProject() {
|
|||||||
<View className="flex-1">
|
<View className="flex-1">
|
||||||
<Field label="Status">
|
<Field label="Status">
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => setStatusOpen(true)}
|
onPress={() => openPicker("status")}
|
||||||
className="flex-row items-center gap-2 bg-secondary/50 rounded-md px-3 py-2.5"
|
className="flex-row items-center gap-2 bg-secondary/50 rounded-md px-3 py-2.5"
|
||||||
>
|
>
|
||||||
<ProjectStatusIcon status={status} size={16} />
|
<ProjectStatusIcon status={status} size={16} />
|
||||||
@@ -199,7 +235,7 @@ export default function NewProject() {
|
|||||||
<View className="flex-1">
|
<View className="flex-1">
|
||||||
<Field label="Priority">
|
<Field label="Priority">
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => setPriorityOpen(true)}
|
onPress={() => openPicker("priority")}
|
||||||
className="flex-row items-center gap-2 bg-secondary/50 rounded-md px-3 py-2.5"
|
className="flex-row items-center gap-2 bg-secondary/50 rounded-md px-3 py-2.5"
|
||||||
>
|
>
|
||||||
<ProjectPriorityIcon priority={priority} size={16} />
|
<ProjectPriorityIcon priority={priority} size={16} />
|
||||||
@@ -212,70 +248,10 @@ export default function NewProject() {
|
|||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</KeyboardAvoidingView>
|
</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({
|
function Field({
|
||||||
label,
|
label,
|
||||||
children,
|
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