mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-15 22:29:04 +02:00
feat(mobile): add due_date / project to create-issue, drop agent toggle
Wire the last two CreateIssueRequest fields that have a meaningful UX on mobile (due_date, project_id) to the new-issue form via two new chips sharing the existing CreateFormAttributeRow + picker-sheet pattern. Fixes a silent 400 on the existing detail-page due_date update: the picker was emitting YYYY-MM-DD but server/internal/handler/issue.go parses with time.Parse(time.RFC3339, ...) which rejects date-only. Now sends full ISO, matching web's due-date-picker.tsx. Removes the placeholder agent-mode toggle from new-issue — it was a dead UI surface (logged to console on submit, never wired). Mobile's create-issue is now manual-only, aligned with web's form semantics. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,9 @@
|
||||
/**
|
||||
* New issue creation modal.
|
||||
* New issue creation modal — manual only.
|
||||
*
|
||||
* Modes (mirrors web's `useCreateModeStore`):
|
||||
* - Manual: title + description + status/priority/assignee chips.
|
||||
* Fully wired to `apiClient.issues.create` via useCreateIssue().
|
||||
* Description supports inline `@mention` (members + agents).
|
||||
* - Agent: natural-language prompt + agent picker (placeholder; Phase 3
|
||||
* wires the real picker + apiClient.quickCreateIssue).
|
||||
* title + description + status/priority/assignee/due-date/project chips.
|
||||
* Fully wired to `apiClient.issues.create` via useCreateIssue().
|
||||
* Description supports inline `@mention` (members + agents).
|
||||
*
|
||||
* Mention pipeline is shared with `comment-composer.tsx` via the
|
||||
* `useMentionInput` hook — both surfaces produce the same canonical
|
||||
@@ -21,25 +18,18 @@ import {
|
||||
Alert,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
TextInput,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Stack, router } from "expo-router";
|
||||
import type { IssuePriority, IssueStatus } from "@multica/core/types";
|
||||
import { SubmitIssueButton } from "@/components/issue/submit-issue-button";
|
||||
import { CreateFormAttributeRow } from "@/components/issue/create-form-attribute-row";
|
||||
import {
|
||||
CreateModeToggle,
|
||||
type CreateMode,
|
||||
} from "@/components/issue/create-mode-toggle";
|
||||
import type { AssigneeValue } from "@/components/issue/pickers/assignee-picker-sheet";
|
||||
import { MentionSuggestionBar } from "@/components/issue/mention-suggestion-bar";
|
||||
import { MarkdownToolbar } from "@/components/editor/markdown-toolbar";
|
||||
import { useFileAttach } from "@/components/editor/use-file-attach";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import {
|
||||
MIN_BODY_INPUT_HEIGHT_PX,
|
||||
MOBILE_PLACEHOLDER_COLOR,
|
||||
@@ -52,68 +42,50 @@ import {
|
||||
} from "@/lib/use-mention-input";
|
||||
|
||||
export default function NewIssueModal() {
|
||||
const [mode, setMode] = useState<CreateMode>("manual");
|
||||
|
||||
// Manual mode fields
|
||||
const [title, setTitle] = useState("");
|
||||
const description = useMentionInput();
|
||||
const [status, setStatus] = useState<IssueStatus>("todo");
|
||||
const [priority, setPriority] = useState<IssuePriority>("none");
|
||||
const [assignee, setAssignee] = useState<AssigneeValue>(null);
|
||||
|
||||
// Agent mode fields (Phase 3 wires the picker)
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [agentId] = useState<string | null>(null);
|
||||
const [dueDate, setDueDate] = useState<string | null>(null);
|
||||
const [projectId, setProjectId] = useState<string | null>(null);
|
||||
|
||||
const createIssue = useCreateIssue();
|
||||
const isSubmitting = createIssue.isPending;
|
||||
|
||||
const canSubmit =
|
||||
!isSubmitting &&
|
||||
(mode === "manual"
|
||||
? title.trim().length > 0
|
||||
: prompt.trim().length > 0);
|
||||
const canSubmit = !isSubmitting && title.trim().length > 0;
|
||||
|
||||
const onSubmit = useCallback(async () => {
|
||||
if (mode === "manual") {
|
||||
const trimmedTitle = title.trim();
|
||||
if (trimmedTitle.length === 0) return;
|
||||
const finalDescription = description.serialize().trim();
|
||||
try {
|
||||
await createIssue.mutateAsync({
|
||||
title: trimmedTitle,
|
||||
description: finalDescription || undefined,
|
||||
status,
|
||||
priority,
|
||||
...(assignee
|
||||
? { assignee_type: assignee.type, assignee_id: assignee.id }
|
||||
: {}),
|
||||
});
|
||||
router.back();
|
||||
} catch (err) {
|
||||
Alert.alert(
|
||||
"Failed to create issue",
|
||||
err instanceof Error ? err.message : "Unknown error",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Agent mode — Phase 3 swaps this for apiClient.quickCreateIssue.
|
||||
if (prompt.trim().length === 0) return;
|
||||
console.log("[new-issue] submit (agent)", {
|
||||
prompt: prompt.trim(),
|
||||
agent_id: agentId,
|
||||
const trimmedTitle = title.trim();
|
||||
if (trimmedTitle.length === 0) return;
|
||||
const finalDescription = description.serialize().trim();
|
||||
try {
|
||||
await createIssue.mutateAsync({
|
||||
title: trimmedTitle,
|
||||
description: finalDescription || undefined,
|
||||
status,
|
||||
priority,
|
||||
...(assignee
|
||||
? { assignee_type: assignee.type, assignee_id: assignee.id }
|
||||
: {}),
|
||||
...(dueDate ? { due_date: dueDate } : {}),
|
||||
...(projectId ? { project_id: projectId } : {}),
|
||||
});
|
||||
router.back();
|
||||
} catch (err) {
|
||||
Alert.alert(
|
||||
"Failed to create issue",
|
||||
err instanceof Error ? err.message : "Unknown error",
|
||||
);
|
||||
}
|
||||
}, [
|
||||
mode,
|
||||
title,
|
||||
description,
|
||||
status,
|
||||
priority,
|
||||
assignee,
|
||||
prompt,
|
||||
agentId,
|
||||
dueDate,
|
||||
projectId,
|
||||
createIssue,
|
||||
]);
|
||||
|
||||
@@ -130,36 +102,29 @@ export default function NewIssueModal() {
|
||||
return HeaderRight;
|
||||
}, [canSubmit, isSubmitting, onSubmit]);
|
||||
|
||||
const headerTitle = useMemo(() => {
|
||||
function HeaderTitle() {
|
||||
return <CreateModeToggle mode={mode} onChange={setMode} />;
|
||||
}
|
||||
return HeaderTitle;
|
||||
}, [mode]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ headerRight, headerTitle }} />
|
||||
<Stack.Screen options={{ headerRight }} />
|
||||
<KeyboardAvoidingView
|
||||
className="flex-1 bg-background"
|
||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||
>
|
||||
{mode === "manual" ? (
|
||||
<ManualPanel
|
||||
title={title}
|
||||
onTitleChange={setTitle}
|
||||
description={description}
|
||||
status={status}
|
||||
onStatusChange={setStatus}
|
||||
priority={priority}
|
||||
onPriorityChange={setPriority}
|
||||
assignee={assignee}
|
||||
onAssigneeChange={setAssignee}
|
||||
submitting={isSubmitting}
|
||||
/>
|
||||
) : (
|
||||
<AgentPanel prompt={prompt} onPromptChange={setPrompt} />
|
||||
)}
|
||||
<ManualPanel
|
||||
title={title}
|
||||
onTitleChange={setTitle}
|
||||
description={description}
|
||||
status={status}
|
||||
onStatusChange={setStatus}
|
||||
priority={priority}
|
||||
onPriorityChange={setPriority}
|
||||
assignee={assignee}
|
||||
onAssigneeChange={setAssignee}
|
||||
dueDate={dueDate}
|
||||
onDueDateChange={setDueDate}
|
||||
projectId={projectId}
|
||||
onProjectIdChange={setProjectId}
|
||||
submitting={isSubmitting}
|
||||
/>
|
||||
</KeyboardAvoidingView>
|
||||
</>
|
||||
);
|
||||
@@ -175,6 +140,10 @@ function ManualPanel({
|
||||
onPriorityChange,
|
||||
assignee,
|
||||
onAssigneeChange,
|
||||
dueDate,
|
||||
onDueDateChange,
|
||||
projectId,
|
||||
onProjectIdChange,
|
||||
submitting,
|
||||
}: {
|
||||
title: string;
|
||||
@@ -186,6 +155,10 @@ function ManualPanel({
|
||||
onPriorityChange: (next: IssuePriority) => void;
|
||||
assignee: AssigneeValue;
|
||||
onAssigneeChange: (next: AssigneeValue) => void;
|
||||
dueDate: string | null;
|
||||
onDueDateChange: (next: string | null) => void;
|
||||
projectId: string | null;
|
||||
onProjectIdChange: (next: string | null) => void;
|
||||
submitting: boolean;
|
||||
}) {
|
||||
const fileAttach = useFileAttach();
|
||||
@@ -246,64 +219,16 @@ function ManualPanel({
|
||||
onPriorityChange={onPriorityChange}
|
||||
assignee={assignee}
|
||||
onAssigneeChange={onAssigneeChange}
|
||||
dueDate={dueDate}
|
||||
onDueDateChange={onDueDateChange}
|
||||
projectId={projectId}
|
||||
onProjectIdChange={onProjectIdChange}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentPanel({
|
||||
prompt,
|
||||
onPromptChange,
|
||||
}: {
|
||||
prompt: string;
|
||||
onPromptChange: (next: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<ScrollView
|
||||
className="flex-1"
|
||||
contentContainerClassName="px-4 pt-4 pb-2"
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<TextInput
|
||||
value={prompt}
|
||||
onChangeText={onPromptChange}
|
||||
placeholder="Describe what you want done…"
|
||||
placeholderTextColor={MOBILE_PLACEHOLDER_COLOR}
|
||||
className="text-base text-foreground py-2 min-h-[160px]"
|
||||
autoFocus
|
||||
multiline
|
||||
textAlignVertical="top"
|
||||
/>
|
||||
</ScrollView>
|
||||
|
||||
<View className="border-t border-border bg-background px-4 py-3">
|
||||
{/* Phase 3 will replace this with a real agent picker sheet. */}
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
console.log("[new-issue] agent picker — Phase 3");
|
||||
}}
|
||||
className="flex-row items-center gap-2 px-3 py-2 rounded-full border border-dashed border-muted-foreground/30 self-start active:bg-secondary"
|
||||
hitSlop={4}
|
||||
>
|
||||
<Ionicons
|
||||
name="sparkles-outline"
|
||||
size={14}
|
||||
color={MOBILE_PLACEHOLDER_COLOR}
|
||||
/>
|
||||
<Text className="text-xs text-muted-foreground">Agent: Select</Text>
|
||||
<Ionicons
|
||||
name="chevron-down"
|
||||
size={12}
|
||||
color={MOBILE_PLACEHOLDER_COLOR}
|
||||
/>
|
||||
</Pressable>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Description field with a focus-tinted rounded-2xl container, visually
|
||||
* matching `CommentComposer`'s input so the two "write markdown body"
|
||||
* surfaces feel like the same product. */
|
||||
|
||||
@@ -3,34 +3,37 @@
|
||||
* `attribute-row.tsx`'s visual pattern but operates on form state
|
||||
* (controlled props + setters) instead of an `issue` object + mutation.
|
||||
*
|
||||
* Phase 1: status / priority / assignee. Phase 2 adds label, project,
|
||||
* due_date, parent as additional chips appended to the same row — the
|
||||
* horizontal ScrollView already handles overflow.
|
||||
*
|
||||
* Reuses (zero-modification):
|
||||
* - StatusPickerSheet / PriorityPickerSheet / AssigneePickerSheet
|
||||
* - StatusPickerSheet / PriorityPickerSheet / AssigneePickerSheet /
|
||||
* DueDatePickerSheet / ProjectPickerSheet
|
||||
* - AttributeChip
|
||||
* - StatusIcon / PriorityIcon / ActorAvatar
|
||||
* - StatusIcon / PriorityIcon / ActorAvatar / ProjectIcon
|
||||
*
|
||||
* Chip "value present" rule: a chip is `filled` when the form value
|
||||
* differs from the default (todo / none / null). When at default it
|
||||
* renders `dimmed` with a placeholder label ("Priority", "Assignee").
|
||||
* renders `dimmed` with a placeholder label.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { ScrollView, View } from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { IssuePriority, IssueStatus } from "@multica/core/types";
|
||||
import { AttributeChip } from "@/components/issue/attribute-chip";
|
||||
import {
|
||||
AssigneePickerSheet,
|
||||
type AssigneeValue,
|
||||
} from "@/components/issue/pickers/assignee-picker-sheet";
|
||||
import { DueDatePickerSheet } from "@/components/issue/pickers/due-date-picker-sheet";
|
||||
import { PriorityPickerSheet } from "@/components/issue/pickers/priority-picker-sheet";
|
||||
import { ProjectPickerSheet } from "@/components/issue/pickers/project-picker-sheet";
|
||||
import { StatusPickerSheet } from "@/components/issue/pickers/status-picker-sheet";
|
||||
import { ActorAvatar } from "@/components/ui/actor-avatar";
|
||||
import { PriorityIcon } from "@/components/ui/priority-icon";
|
||||
import { ProjectIcon } from "@/components/ui/project-icon";
|
||||
import { StatusIcon } from "@/components/ui/status-icon";
|
||||
import { useActorLookup } from "@/data/use-actor-name";
|
||||
import { projectListOptions } from "@/data/queries/projects";
|
||||
import { useWorkspaceStore } from "@/data/workspace-store";
|
||||
import { PRIORITY_LABEL, STATUS_LABEL } from "@/lib/issue-status";
|
||||
|
||||
interface Props {
|
||||
@@ -40,6 +43,10 @@ interface Props {
|
||||
onPriorityChange: (next: IssuePriority) => void;
|
||||
assignee: AssigneeValue;
|
||||
onAssigneeChange: (next: AssigneeValue) => void;
|
||||
dueDate: string | null;
|
||||
onDueDateChange: (next: string | null) => void;
|
||||
projectId: string | null;
|
||||
onProjectIdChange: (next: string | null) => void;
|
||||
}
|
||||
|
||||
export function CreateFormAttributeRow({
|
||||
@@ -49,10 +56,16 @@ export function CreateFormAttributeRow({
|
||||
onPriorityChange,
|
||||
assignee,
|
||||
onAssigneeChange,
|
||||
dueDate,
|
||||
onDueDateChange,
|
||||
projectId,
|
||||
onProjectIdChange,
|
||||
}: Props) {
|
||||
const [statusOpen, setStatusOpen] = useState(false);
|
||||
const [priorityOpen, setPriorityOpen] = useState(false);
|
||||
const [assigneeOpen, setAssigneeOpen] = useState(false);
|
||||
const [dueOpen, setDueOpen] = useState(false);
|
||||
const [projectOpen, setProjectOpen] = useState(false);
|
||||
|
||||
const { getName } = useActorLookup();
|
||||
const assigneeLabel = assignee
|
||||
@@ -61,6 +74,15 @@ export function CreateFormAttributeRow({
|
||||
const priorityLabel =
|
||||
priority === "none" ? "Priority" : PRIORITY_LABEL[priority];
|
||||
|
||||
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
|
||||
const { data: projects } = useQuery(projectListOptions(wsId));
|
||||
const project = projectId
|
||||
? projects?.find((p) => p.id === projectId)
|
||||
: undefined;
|
||||
// While the list fetches, show "…" so the filled chip isn't visually
|
||||
// ambiguous with the unselected "Project" placeholder.
|
||||
const projectLabel = projectId ? project?.title ?? "…" : "Project";
|
||||
|
||||
return (
|
||||
<View>
|
||||
<ScrollView
|
||||
@@ -100,6 +122,30 @@ export function CreateFormAttributeRow({
|
||||
variant={assignee ? "filled" : "dimmed"}
|
||||
onPress={() => setAssigneeOpen(true)}
|
||||
/>
|
||||
<AttributeChip
|
||||
icon={
|
||||
<Ionicons
|
||||
name="calendar-outline"
|
||||
size={14}
|
||||
color={dueDate ? undefined : "#a1a1aa"}
|
||||
/>
|
||||
}
|
||||
label={dueDate ? formatDueDate(dueDate) : "Due date"}
|
||||
variant={dueDate ? "filled" : "dimmed"}
|
||||
onPress={() => setDueOpen(true)}
|
||||
/>
|
||||
<AttributeChip
|
||||
icon={
|
||||
project ? (
|
||||
<ProjectIcon icon={project.icon} size="sm" />
|
||||
) : (
|
||||
<Ionicons name="folder-outline" size={14} color="#a1a1aa" />
|
||||
)
|
||||
}
|
||||
label={projectLabel}
|
||||
variant={projectId ? "filled" : "dimmed"}
|
||||
onPress={() => setProjectOpen(true)}
|
||||
/>
|
||||
</ScrollView>
|
||||
|
||||
<StatusPickerSheet
|
||||
@@ -120,6 +166,24 @@ export function CreateFormAttributeRow({
|
||||
onChange={onAssigneeChange}
|
||||
onClose={() => setAssigneeOpen(false)}
|
||||
/>
|
||||
<DueDatePickerSheet
|
||||
visible={dueOpen}
|
||||
value={dueDate}
|
||||
onChange={onDueDateChange}
|
||||
onClose={() => setDueOpen(false)}
|
||||
/>
|
||||
<ProjectPickerSheet
|
||||
visible={projectOpen}
|
||||
value={projectId}
|
||||
onChange={onProjectIdChange}
|
||||
onClose={() => setProjectOpen(false)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDueDate(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return "Due date";
|
||||
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
/**
|
||||
* Manual / Agent segmented control for the new-issue modal header. Same
|
||||
* dichotomy as web's `useCreateModeStore` (Manual = traditional form,
|
||||
* Agent = natural-language prompt dispatched to an agent).
|
||||
*
|
||||
* Rendered via `headerTitle` in the Stack.Screen options, replacing the
|
||||
* static "New Issue" title — iOS standard for two-mode screens (Mail
|
||||
* Inbox/VIP, Reminders lists, etc).
|
||||
*
|
||||
* Phase 1: mode state lives in local useState. Phase 3 will sync to a
|
||||
* mobile-local `useCreateModeStore` (mirroring web) so the choice
|
||||
* persists across modal opens.
|
||||
*/
|
||||
import { Pressable, View } from "react-native";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type CreateMode = "manual" | "agent";
|
||||
|
||||
interface Props {
|
||||
mode: CreateMode;
|
||||
onChange: (next: CreateMode) => void;
|
||||
}
|
||||
|
||||
export function CreateModeToggle({ mode, onChange }: Props) {
|
||||
return (
|
||||
<View className="flex-row rounded-full bg-secondary p-0.5">
|
||||
<Segment
|
||||
label="Manual"
|
||||
active={mode === "manual"}
|
||||
onPress={() => onChange("manual")}
|
||||
/>
|
||||
<Segment
|
||||
label="Agent"
|
||||
active={mode === "agent"}
|
||||
onPress={() => onChange("agent")}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function Segment({
|
||||
label,
|
||||
active,
|
||||
onPress,
|
||||
}: {
|
||||
label: string;
|
||||
active: boolean;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
hitSlop={4}
|
||||
className={cn(
|
||||
"px-3 py-1 rounded-full",
|
||||
active ? "bg-background shadow-sm" : "active:opacity-60",
|
||||
)}
|
||||
>
|
||||
<Text
|
||||
className={cn(
|
||||
"text-xs font-medium",
|
||||
active ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,18 @@
|
||||
/**
|
||||
* Due-date picker. Wraps `@react-native-community/datetimepicker` (native
|
||||
* UIDatePicker on iOS, Material spinner on Android). Two affordances:
|
||||
* - "Done" — sends the currently displayed date as ISO date string
|
||||
* - "Done" — sends the currently displayed date as ISO 8601 / RFC 3339
|
||||
* - "Clear due date" — sends null (only shown when value is set)
|
||||
*
|
||||
* Date is stored as ISO date-string (YYYY-MM-DD) on the server. The
|
||||
* native picker hands us a JS `Date`; we slice the ISO string at the day.
|
||||
* Backend (`server/internal/handler/issue.go` CreateIssue / UpdateIssue)
|
||||
* parses with `time.Parse(time.RFC3339, ...)` — strict. Mirrors web's
|
||||
* `packages/views/issues/components/pickers/due-date-picker.tsx:57` which
|
||||
* sends `d.toISOString()`.
|
||||
*
|
||||
* Note: full ISO means UTC. Users in negative or large positive offsets
|
||||
* may see a one-day shift after round-trip (e.g. local "May 14" stored as
|
||||
* "2026-05-13T16:00:00Z" for UTC+8 if backend truncates day). This
|
||||
* matches web's behavior — diverging here would break parity.
|
||||
*/
|
||||
import { useState, useEffect } from "react";
|
||||
import { Modal, Pressable, View } from "react-native";
|
||||
@@ -25,8 +32,8 @@ function isoToDate(iso: string | null): Date {
|
||||
return Number.isNaN(d.getTime()) ? new Date() : d;
|
||||
}
|
||||
|
||||
function dateToIsoDay(d: Date): string {
|
||||
return d.toISOString().slice(0, 10);
|
||||
function dateToIso(d: Date): string {
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
export function DueDatePickerSheet({
|
||||
@@ -43,7 +50,7 @@ export function DueDatePickerSheet({
|
||||
}, [visible, value]);
|
||||
|
||||
const submit = () => {
|
||||
onChange(dateToIsoDay(draft));
|
||||
onChange(dateToIso(draft));
|
||||
onClose();
|
||||
};
|
||||
const clear = () => {
|
||||
|
||||
157
apps/mobile/components/issue/pickers/project-picker-sheet.tsx
Normal file
157
apps/mobile/components/issue/pickers/project-picker-sheet.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Project picker — single-select over workspace projects. Tap a row to set,
|
||||
* tap the "No project" row to clear. Mirrors web's `ProjectPicker`
|
||||
* (`packages/views/projects/components/project-picker.tsx`) in behavior;
|
||||
* sheet shell is copied from `label-picker-sheet.tsx` and trimmed for
|
||||
* single-select.
|
||||
*
|
||||
* Phase 1 does NOT support inline project creation — mobile users who want
|
||||
* a new project create it on web.
|
||||
*/
|
||||
import { useMemo, useState } from "react";
|
||||
import { ActivityIndicator, FlatList, Modal, Pressable, TextInput, View } from "react-native";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { Project } from "@multica/core/types";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { ProjectIcon } from "@/components/ui/project-icon";
|
||||
import { MOBILE_PLACEHOLDER_COLOR } from "@/components/ui/input-tokens";
|
||||
import { projectListOptions } from "@/data/queries/projects";
|
||||
import { useWorkspaceStore } from "@/data/workspace-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
value: string | null;
|
||||
onChange: (next: string | null) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ProjectPickerSheet({ visible, value, onChange, onClose }: Props) {
|
||||
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
|
||||
const { data: projects, isLoading } = useQuery(projectListOptions(wsId));
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
const all = projects ?? [];
|
||||
const sorted = [...all].sort((a, b) => a.title.localeCompare(b.title));
|
||||
if (!q) return sorted;
|
||||
return sorted.filter((p) => p.title.toLowerCase().includes(q));
|
||||
}, [projects, query]);
|
||||
|
||||
const pick = (next: string | null) => {
|
||||
onChange(next);
|
||||
onClose();
|
||||
};
|
||||
|
||||
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-6">
|
||||
<Pressable onPress={() => {}} className="w-full max-w-sm">
|
||||
<View className="bg-popover rounded-2xl overflow-hidden">
|
||||
<View className="px-3 pt-3 pb-2 border-b border-border">
|
||||
<TextInput
|
||||
value={query}
|
||||
onChangeText={setQuery}
|
||||
placeholder="Search projects"
|
||||
placeholderTextColor={MOBILE_PLACEHOLDER_COLOR}
|
||||
className="text-sm text-foreground bg-secondary/50 rounded-md px-3 py-2"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
</View>
|
||||
{isLoading ? (
|
||||
<View className="px-3 py-8 items-center">
|
||||
<ActivityIndicator />
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={filtered}
|
||||
keyExtractor={(item) => item.id}
|
||||
style={{ maxHeight: 380 }}
|
||||
ListHeaderComponent={
|
||||
<NoProjectRow
|
||||
checked={value === null}
|
||||
onPress={() => pick(null)}
|
||||
/>
|
||||
}
|
||||
renderItem={({ item }) => (
|
||||
<ProjectRow
|
||||
project={item}
|
||||
checked={item.id === value}
|
||||
onPress={() => pick(item.id)}
|
||||
/>
|
||||
)}
|
||||
ListEmptyComponent={
|
||||
<View className="px-3 py-6 items-center">
|
||||
<Text className="text-xs text-muted-foreground text-center">
|
||||
{query
|
||||
? "No matches."
|
||||
: "No projects in this workspace yet.\nCreate them on web."}
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
</View>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function NoProjectRow({
|
||||
checked,
|
||||
onPress,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
className={cn(
|
||||
"flex-row items-center gap-3 px-3 py-2.5 border-b border-border active:bg-secondary",
|
||||
checked && "bg-secondary",
|
||||
)}
|
||||
>
|
||||
<Ionicons name="close-circle-outline" size={16} color={MOBILE_PLACEHOLDER_COLOR} />
|
||||
<Text className="flex-1 text-sm text-muted-foreground">No project</Text>
|
||||
{checked ? <Text className="text-xs text-muted-foreground">✓</Text> : null}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectRow({
|
||||
project,
|
||||
checked,
|
||||
onPress,
|
||||
}: {
|
||||
project: Project;
|
||||
checked: boolean;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
className={cn(
|
||||
"flex-row items-center gap-3 px-3 py-2.5 active:bg-secondary",
|
||||
checked && "bg-secondary",
|
||||
)}
|
||||
>
|
||||
<ProjectIcon icon={project.icon} size="md" />
|
||||
<Text className="flex-1 text-sm text-foreground" numberOfLines={1}>
|
||||
{project.title}
|
||||
</Text>
|
||||
{checked ? <Text className="text-xs text-muted-foreground">✓</Text> : null}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,6 @@
|
||||
/**
|
||||
* Workspace project list. Currently used only to look up `project.title` and
|
||||
* `project.icon` for the read-only project chip on issue detail.
|
||||
*
|
||||
* No project picker yet — defer until web ships one (per plan
|
||||
* `~/.claude/plans/plan-polymorphic-hickey.md`). When that lands, this same
|
||||
* query is what the picker would consume.
|
||||
* Workspace project list. Consumed by the read-only project chip on issue
|
||||
* detail and by `ProjectPickerSheet` in the new-issue / issue-detail flows.
|
||||
*/
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import type { Project } from "@multica/core/types";
|
||||
|
||||
Reference in New Issue
Block a user