mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 13:29:44 +02:00
feat(search): extend cmd+k palette (theme toggle, new issue/project, copy link, switch workspace) (#1208)
* feat(search): add light/dark/system theme toggle actions to cmd+k The command palette now surfaces an "Actions" section with theme toggle items (Light / Dark / System), searchable via keywords like "theme", "light", "dark", "appearance", or "mode". The active theme is marked with a check icon. * feat(search): add quick-win commands to cmd+k palette Extends the command palette with a "Commands" group that consolidates theme toggles plus four new actions: - New Issue / New Project — trigger the global create modals - Copy Issue Link / Copy Identifier (MUL-xxx) — only when the current route is an issue detail page; mirrors the copy-link dropdown logic from issue-detail Adds a "Switch Workspace" group that lists the user's other workspaces (filtered by name/slug, or by typing "workspace"/"switch") and navigates to the selected workspace's issues page. To make "New Project" work from anywhere, the inline CreateProjectDialog on ProjectsPage is extracted into a global CreateProjectModal mounted via the existing ModalRegistry + modal store (same pattern as create-issue / create-workspace). The modal store type gains a "create-project" variant. * feat(search): show Commands by default so they're discoverable Before, cmd+k actions (New Issue / New Project / Copy link / Copy ID / theme toggles) only appeared when the user typed a matching keyword, leaving them invisible unless the user already knew they existed. Now the Commands group renders as soon as the palette opens (no query), with the whole command list shown; typing narrows it down as before. Also trims the redundant "⌘K to open this anytime" hint from the empty state — the palette is already open.
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
import { create } from "zustand";
|
||||
|
||||
type ModalType = "create-workspace" | "create-issue" | null;
|
||||
type ModalType = "create-workspace" | "create-issue" | "create-project" | null;
|
||||
|
||||
interface ModalStore {
|
||||
modal: ModalType;
|
||||
|
||||
352
packages/views/modals/create-project.tsx
Normal file
352
packages/views/modals/create-project.tsx
Normal file
@@ -0,0 +1,352 @@
|
||||
"use client";
|
||||
|
||||
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 {
|
||||
PROJECT_STATUS_CONFIG,
|
||||
PROJECT_STATUS_ORDER,
|
||||
PROJECT_PRIORITY_CONFIG,
|
||||
PROJECT_PRIORITY_ORDER,
|
||||
} from "@multica/core/projects/config";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { useCurrentWorkspace, useWorkspacePaths } from "@multica/core/paths";
|
||||
import { memberListOptions, agentListOptions } from "@multica/core/workspace/queries";
|
||||
import { useActorName } from "@multica/core/workspace/hooks";
|
||||
import type { ProjectStatus, ProjectPriority } from "@multica/core/types";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@multica/ui/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@multica/ui/components/ui/dropdown-menu";
|
||||
import { Popover, PopoverTrigger, PopoverContent } from "@multica/ui/components/ui/popover";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { EmojiPicker } from "@multica/ui/components/common/emoji-picker";
|
||||
import { ContentEditor, type ContentEditorRef, TitleEditor } from "../editor";
|
||||
import { PriorityIcon } from "../issues/components/priority-icon";
|
||||
import { ActorAvatar } from "../common/actor-avatar";
|
||||
import { useNavigation } from "../navigation";
|
||||
|
||||
function PillButton({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs",
|
||||
"hover:bg-accent/60 transition-colors cursor-pointer",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function CreateProjectModal({ onClose }: { onClose: () => void }) {
|
||||
const router = useNavigation();
|
||||
const workspace = useCurrentWorkspace();
|
||||
const workspaceName = workspace?.name;
|
||||
const wsPaths = useWorkspacePaths();
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
||||
const { data: agents = [] } = useQuery(agentListOptions(wsId));
|
||||
const { getActorName } = useActorName();
|
||||
|
||||
const [title, setTitle] = useState("");
|
||||
const descEditorRef = useRef<ContentEditorRef>(null);
|
||||
const [status, setStatus] = useState<ProjectStatus>("planned");
|
||||
const [priority, setPriority] = useState<ProjectPriority>("none");
|
||||
const [leadType, setLeadType] = useState<"member" | "agent" | undefined>();
|
||||
const [leadId, setLeadId] = useState<string | undefined>();
|
||||
const [icon, setIcon] = useState<string | undefined>();
|
||||
const [iconPickerOpen, setIconPickerOpen] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const [leadOpen, setLeadOpen] = useState(false);
|
||||
const [leadFilter, setLeadFilter] = useState("");
|
||||
|
||||
const leadQuery = leadFilter.toLowerCase();
|
||||
const filteredMembers = members.filter((m) => m.name.toLowerCase().includes(leadQuery));
|
||||
const filteredAgents = agents.filter(
|
||||
(a) => !a.archived_at && a.name.toLowerCase().includes(leadQuery),
|
||||
);
|
||||
|
||||
const leadLabel = leadType && leadId ? getActorName(leadType, leadId) : "Lead";
|
||||
|
||||
const createProject = useCreateProject();
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!title.trim() || submitting) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const project = await createProject.mutateAsync({
|
||||
title: title.trim(),
|
||||
description: descEditorRef.current?.getMarkdown()?.trim() || undefined,
|
||||
icon,
|
||||
status,
|
||||
priority,
|
||||
lead_type: leadType,
|
||||
lead_id: leadId,
|
||||
});
|
||||
onClose();
|
||||
toast.success("Project created");
|
||||
router.push(wsPaths.projectDetail(project.id));
|
||||
} catch {
|
||||
toast.error("Failed to create project");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(v) => { if (!v) onClose(); }}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className={cn(
|
||||
"p-0 gap-0 flex flex-col overflow-hidden",
|
||||
"!top-1/2 !left-1/2 !-translate-x-1/2",
|
||||
"!transition-all !duration-300 !ease-out",
|
||||
isExpanded
|
||||
? "!max-w-4xl !w-full !h-5/6 !-translate-y-1/2"
|
||||
: "!max-w-2xl !w-full !h-96 !-translate-y-1/2",
|
||||
)}
|
||||
>
|
||||
<DialogTitle className="sr-only">New Project</DialogTitle>
|
||||
|
||||
<div className="flex items-center justify-between px-5 pt-3 pb-2 shrink-0">
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<span className="text-muted-foreground">{workspaceName}</span>
|
||||
<ChevronRight className="size-3 text-muted-foreground/50" />
|
||||
<span className="font-medium">New project</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="rounded-sm p-1.5 opacity-70 hover:opacity-100 hover:bg-accent/60 transition-all cursor-pointer"
|
||||
>
|
||||
{isExpanded ? <Minimize2 className="size-4" /> : <Maximize2 className="size-4" />}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="bottom">{isExpanded ? "Collapse" : "Expand"}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-sm p-1.5 opacity-70 hover:opacity-100 hover:bg-accent/60 transition-all cursor-pointer"
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="bottom">Close</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-5 pb-2 shrink-0">
|
||||
<Popover open={iconPickerOpen} onOpenChange={setIconPickerOpen}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
className="text-2xl cursor-pointer rounded-lg p-1 -ml-1 hover:bg-accent/60 transition-colors"
|
||||
title="Choose icon"
|
||||
>
|
||||
{icon || "📁"}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent align="start" className="w-auto p-0">
|
||||
<EmojiPicker
|
||||
onSelect={(emoji) => {
|
||||
setIcon(emoji);
|
||||
setIconPickerOpen(false);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<TitleEditor
|
||||
autoFocus
|
||||
defaultValue=""
|
||||
placeholder="Project title"
|
||||
className="text-lg font-semibold"
|
||||
onChange={(v) => setTitle(v)}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-5">
|
||||
<ContentEditor
|
||||
ref={descEditorRef}
|
||||
defaultValue=""
|
||||
placeholder="Add description..."
|
||||
debounceMs={500}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 px-4 py-2 shrink-0 flex-wrap">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<PillButton>
|
||||
<span className={cn("size-2 rounded-full", PROJECT_STATUS_CONFIG[status].dotColor)} />
|
||||
<span>{PROJECT_STATUS_CONFIG[status].label}</span>
|
||||
</PillButton>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="start" className="w-44">
|
||||
{PROJECT_STATUS_ORDER.map((s) => (
|
||||
<DropdownMenuItem key={s} onClick={() => setStatus(s)}>
|
||||
<span className={cn("size-2 rounded-full", PROJECT_STATUS_CONFIG[s].dotColor)} />
|
||||
<span>{PROJECT_STATUS_CONFIG[s].label}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<PillButton>
|
||||
<PriorityIcon priority={priority} />
|
||||
<span>{PROJECT_PRIORITY_CONFIG[priority].label}</span>
|
||||
</PillButton>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="start" className="w-44">
|
||||
{PROJECT_PRIORITY_ORDER.map((pr) => (
|
||||
<DropdownMenuItem key={pr} onClick={() => setPriority(pr)}>
|
||||
<PriorityIcon priority={pr} />
|
||||
<span>{PROJECT_PRIORITY_CONFIG[pr].label}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Popover
|
||||
open={leadOpen}
|
||||
onOpenChange={(v) => {
|
||||
setLeadOpen(v);
|
||||
if (!v) setLeadFilter("");
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<PillButton>
|
||||
{leadType && leadId ? (
|
||||
<>
|
||||
<ActorAvatar actorType={leadType} actorId={leadId} size={16} />
|
||||
<span>{leadLabel}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Lead</span>
|
||||
)}
|
||||
</PillButton>
|
||||
}
|
||||
/>
|
||||
<PopoverContent align="start" className="w-52 p-0">
|
||||
<div className="px-2 py-1.5 border-b">
|
||||
<input
|
||||
type="text"
|
||||
value={leadFilter}
|
||||
onChange={(e) => setLeadFilter(e.target.value)}
|
||||
placeholder="Assign lead..."
|
||||
className="w-full bg-transparent text-sm placeholder:text-muted-foreground outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-1 max-h-60 overflow-y-auto">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setLeadType(undefined);
|
||||
setLeadId(undefined);
|
||||
setLeadOpen(false);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
|
||||
>
|
||||
<UserMinus className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">No lead</span>
|
||||
</button>
|
||||
{filteredMembers.length > 0 && (
|
||||
<>
|
||||
<div className="px-2 pt-2 pb-1 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Members
|
||||
</div>
|
||||
{filteredMembers.map((m) => (
|
||||
<button
|
||||
type="button"
|
||||
key={m.user_id}
|
||||
onClick={() => {
|
||||
setLeadType("member");
|
||||
setLeadId(m.user_id);
|
||||
setLeadOpen(false);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
|
||||
>
|
||||
<ActorAvatar actorType="member" actorId={m.user_id} size={16} />
|
||||
<span>{m.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{filteredAgents.length > 0 && (
|
||||
<>
|
||||
<div className="px-2 pt-2 pb-1 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Agents
|
||||
</div>
|
||||
{filteredAgents.map((a) => (
|
||||
<button
|
||||
type="button"
|
||||
key={a.id}
|
||||
onClick={() => {
|
||||
setLeadType("agent");
|
||||
setLeadId(a.id);
|
||||
setLeadOpen(false);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
|
||||
>
|
||||
<ActorAvatar actorType="agent" actorId={a.id} size={16} />
|
||||
<span>{a.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{filteredMembers.length === 0 &&
|
||||
filteredAgents.length === 0 &&
|
||||
leadFilter && (
|
||||
<div className="px-2 py-3 text-center text-sm text-muted-foreground">
|
||||
No results
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end px-4 py-3 border-t shrink-0">
|
||||
<Button size="sm" onClick={handleSubmit} disabled={!title.trim() || submitting}>
|
||||
{submitting ? "Creating..." : "Create Project"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useModalStore } from "@multica/core/modals";
|
||||
import { CreateWorkspaceModal } from "./create-workspace";
|
||||
import { CreateIssueModal } from "./create-issue";
|
||||
import { CreateProjectModal } from "./create-project";
|
||||
|
||||
export function ModalRegistry() {
|
||||
const modal = useModalStore((s) => s.modal);
|
||||
@@ -14,6 +15,8 @@ export function ModalRegistry() {
|
||||
return <CreateWorkspaceModal onClose={close} />;
|
||||
case "create-issue":
|
||||
return <CreateIssueModal onClose={close} data={data} />;
|
||||
case "create-project":
|
||||
return <CreateProjectModal onClose={close} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import { Plus, FolderKanban, ChevronRight, Maximize2, Minimize2, X as XIcon, UserMinus, Check } from "lucide-react";
|
||||
import { useState, useCallback } from "react";
|
||||
import { Plus, FolderKanban, UserMinus, Check } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { projectListOptions } from "@multica/core/projects/queries";
|
||||
import { useCreateProject, useUpdateProject } from "@multica/core/projects/mutations";
|
||||
import { PROJECT_STATUS_CONFIG, PROJECT_STATUS_ORDER, PROJECT_PRIORITY_CONFIG, PROJECT_PRIORITY_ORDER } from "@multica/core/projects/config";
|
||||
import { useUpdateProject } from "@multica/core/projects/mutations";
|
||||
import {
|
||||
PROJECT_STATUS_CONFIG,
|
||||
PROJECT_STATUS_ORDER,
|
||||
PROJECT_PRIORITY_CONFIG,
|
||||
PROJECT_PRIORITY_ORDER,
|
||||
} from "@multica/core/projects/config";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { useCurrentWorkspace, useWorkspacePaths } from "@multica/core/paths";
|
||||
import { useWorkspacePaths } from "@multica/core/paths";
|
||||
import { memberListOptions, agentListOptions } from "@multica/core/workspace/queries";
|
||||
import { AppLink, useNavigation } from "../../navigation";
|
||||
import { useModalStore } from "@multica/core/modals";
|
||||
import { AppLink } from "../../navigation";
|
||||
import { ActorAvatar } from "../../common/actor-avatar";
|
||||
import { useActorName } from "@multica/core/workspace/hooks";
|
||||
import { Skeleton } from "@multica/ui/components/ui/skeleton";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from "@multica/ui/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -33,9 +33,6 @@ import {
|
||||
PopoverContent,
|
||||
} from "@multica/ui/components/ui/popover";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
|
||||
import { ContentEditor, type ContentEditorRef } from "../../editor";
|
||||
import { TitleEditor } from "../../editor";
|
||||
import { EmojiPicker } from "@multica/ui/components/common/emoji-picker";
|
||||
import type { Project, ProjectStatus, ProjectPriority, UpdateProjectRequest } from "@multica/core/types";
|
||||
import { PageHeader } from "../../layout/page-header";
|
||||
import { PriorityIcon } from "../../issues/components/priority-icon";
|
||||
@@ -229,316 +226,11 @@ function ProjectRow({ project }: { project: Project }) {
|
||||
);
|
||||
}
|
||||
|
||||
function PillButton({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs",
|
||||
"hover:bg-accent/60 transition-colors cursor-pointer",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateProjectDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
|
||||
const router = useNavigation();
|
||||
const workspace = useCurrentWorkspace();
|
||||
const workspaceName = workspace?.name;
|
||||
const wsPaths = useWorkspacePaths();
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
||||
const { data: agents = [] } = useQuery(agentListOptions(wsId));
|
||||
const { getActorName } = useActorName();
|
||||
|
||||
const [title, setTitle] = useState("");
|
||||
const descEditorRef = useRef<ContentEditorRef>(null);
|
||||
const [status, setStatus] = useState<ProjectStatus>("planned");
|
||||
const [priority, setPriority] = useState<ProjectPriority>("none");
|
||||
const [leadType, setLeadType] = useState<"member" | "agent" | undefined>();
|
||||
const [leadId, setLeadId] = useState<string | undefined>();
|
||||
const [icon, setIcon] = useState<string | undefined>();
|
||||
const [iconPickerOpen, setIconPickerOpen] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
// Lead popover
|
||||
const [leadOpen, setLeadOpen] = useState(false);
|
||||
const [leadFilter, setLeadFilter] = useState("");
|
||||
|
||||
const leadQuery = leadFilter.toLowerCase();
|
||||
const filteredMembers = members.filter((m) => m.name.toLowerCase().includes(leadQuery));
|
||||
const filteredAgents = agents.filter((a) => !a.archived_at && a.name.toLowerCase().includes(leadQuery));
|
||||
|
||||
const leadLabel =
|
||||
leadType && leadId ? getActorName(leadType, leadId) : "Lead";
|
||||
|
||||
const createProject = useCreateProject();
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!title.trim() || submitting) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const project = await createProject.mutateAsync({
|
||||
title: title.trim(),
|
||||
description: descEditorRef.current?.getMarkdown()?.trim() || undefined,
|
||||
icon,
|
||||
status,
|
||||
priority,
|
||||
lead_type: leadType,
|
||||
lead_id: leadId,
|
||||
});
|
||||
onOpenChange(false);
|
||||
setTitle("");
|
||||
setIcon(undefined);
|
||||
setStatus("planned");
|
||||
setPriority("none");
|
||||
setLeadType(undefined);
|
||||
setLeadId(undefined);
|
||||
toast.success("Project created");
|
||||
router.push(wsPaths.projectDetail(project.id));
|
||||
} catch {
|
||||
toast.error("Failed to create project");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className={cn(
|
||||
"p-0 gap-0 flex flex-col overflow-hidden",
|
||||
"!top-1/2 !left-1/2 !-translate-x-1/2",
|
||||
"!transition-all !duration-300 !ease-out",
|
||||
isExpanded
|
||||
? "!max-w-4xl !w-full !h-5/6 !-translate-y-1/2"
|
||||
: "!max-w-2xl !w-full !h-96 !-translate-y-1/2",
|
||||
)}
|
||||
>
|
||||
<DialogTitle className="sr-only">New Project</DialogTitle>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 pt-3 pb-2 shrink-0">
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<span className="text-muted-foreground">{workspaceName}</span>
|
||||
<ChevronRight className="size-3 text-muted-foreground/50" />
|
||||
<span className="font-medium">New project</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="rounded-sm p-1.5 opacity-70 hover:opacity-100 hover:bg-accent/60 transition-all cursor-pointer"
|
||||
>
|
||||
{isExpanded ? <Minimize2 className="size-4" /> : <Maximize2 className="size-4" />}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="bottom">{isExpanded ? "Collapse" : "Expand"}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="rounded-sm p-1.5 opacity-70 hover:opacity-100 hover:bg-accent/60 transition-all cursor-pointer"
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="bottom">Close</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Icon + Title */}
|
||||
<div className="px-5 pb-2 shrink-0">
|
||||
<Popover open={iconPickerOpen} onOpenChange={setIconPickerOpen}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
className="text-2xl cursor-pointer rounded-lg p-1 -ml-1 hover:bg-accent/60 transition-colors"
|
||||
title="Choose icon"
|
||||
>
|
||||
{icon || "📁"}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent align="start" className="w-auto p-0">
|
||||
<EmojiPicker
|
||||
onSelect={(emoji) => {
|
||||
setIcon(emoji);
|
||||
setIconPickerOpen(false);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<TitleEditor
|
||||
autoFocus
|
||||
defaultValue=""
|
||||
placeholder="Project title"
|
||||
className="text-lg font-semibold"
|
||||
onChange={(v) => setTitle(v)}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-5">
|
||||
<ContentEditor
|
||||
ref={descEditorRef}
|
||||
defaultValue=""
|
||||
placeholder="Add description..."
|
||||
debounceMs={500}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Property toolbar */}
|
||||
<div className="flex items-center gap-1.5 px-4 py-2 shrink-0 flex-wrap">
|
||||
{/* Status */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<PillButton>
|
||||
<span className={cn("size-2 rounded-full", PROJECT_STATUS_CONFIG[status].dotColor)} />
|
||||
<span>{PROJECT_STATUS_CONFIG[status].label}</span>
|
||||
</PillButton>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="start" className="w-44">
|
||||
{PROJECT_STATUS_ORDER.map((s) => (
|
||||
<DropdownMenuItem key={s} onClick={() => setStatus(s)}>
|
||||
<span className={cn("size-2 rounded-full", PROJECT_STATUS_CONFIG[s].dotColor)} />
|
||||
<span>{PROJECT_STATUS_CONFIG[s].label}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Priority */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<PillButton>
|
||||
<PriorityIcon priority={priority} />
|
||||
<span>{PROJECT_PRIORITY_CONFIG[priority].label}</span>
|
||||
</PillButton>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="start" className="w-44">
|
||||
{PROJECT_PRIORITY_ORDER.map((p) => (
|
||||
<DropdownMenuItem key={p} onClick={() => setPriority(p)}>
|
||||
<PriorityIcon priority={p} />
|
||||
<span>{PROJECT_PRIORITY_CONFIG[p].label}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Lead */}
|
||||
<Popover open={leadOpen} onOpenChange={(v) => { setLeadOpen(v); if (!v) setLeadFilter(""); }}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<PillButton>
|
||||
{leadType && leadId ? (
|
||||
<>
|
||||
<ActorAvatar actorType={leadType} actorId={leadId} size={16} />
|
||||
<span>{leadLabel}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Lead</span>
|
||||
)}
|
||||
</PillButton>
|
||||
}
|
||||
/>
|
||||
<PopoverContent align="start" className="w-52 p-0">
|
||||
<div className="px-2 py-1.5 border-b">
|
||||
<input
|
||||
type="text"
|
||||
value={leadFilter}
|
||||
onChange={(e) => setLeadFilter(e.target.value)}
|
||||
placeholder="Assign lead..."
|
||||
className="w-full bg-transparent text-sm placeholder:text-muted-foreground outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-1 max-h-60 overflow-y-auto">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setLeadType(undefined); setLeadId(undefined); setLeadOpen(false); }}
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
|
||||
>
|
||||
<UserMinus className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">No lead</span>
|
||||
</button>
|
||||
{filteredMembers.length > 0 && (
|
||||
<>
|
||||
<div className="px-2 pt-2 pb-1 text-xs font-medium text-muted-foreground uppercase tracking-wider">Members</div>
|
||||
{filteredMembers.map((m) => (
|
||||
<button
|
||||
type="button"
|
||||
key={m.user_id}
|
||||
onClick={() => { setLeadType("member"); setLeadId(m.user_id); setLeadOpen(false); }}
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
|
||||
>
|
||||
<ActorAvatar actorType="member" actorId={m.user_id} size={16} />
|
||||
<span>{m.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{filteredAgents.length > 0 && (
|
||||
<>
|
||||
<div className="px-2 pt-2 pb-1 text-xs font-medium text-muted-foreground uppercase tracking-wider">Agents</div>
|
||||
{filteredAgents.map((a) => (
|
||||
<button
|
||||
type="button"
|
||||
key={a.id}
|
||||
onClick={() => { setLeadType("agent"); setLeadId(a.id); setLeadOpen(false); }}
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
|
||||
>
|
||||
<ActorAvatar actorType="agent" actorId={a.id} size={16} />
|
||||
<span>{a.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{filteredMembers.length === 0 && filteredAgents.length === 0 && leadFilter && (
|
||||
<div className="px-2 py-3 text-center text-sm text-muted-foreground">No results</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end px-4 py-3 border-t shrink-0">
|
||||
<Button size="sm" onClick={handleSubmit} disabled={!title.trim() || submitting}>
|
||||
{submitting ? "Creating..." : "Create Project"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectsPage() {
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: projects = [], isLoading } = useQuery(projectListOptions(wsId));
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const openCreateProject = () => useModalStore.getState().open("create-project");
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
@@ -551,7 +243,7 @@ export function ProjectsPage() {
|
||||
<span className="text-xs text-muted-foreground tabular-nums">{projects.length}</span>
|
||||
)}
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={() => setCreateOpen(true)}>
|
||||
<Button size="sm" variant="outline" onClick={openCreateProject}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||
New project
|
||||
</Button>
|
||||
@@ -569,7 +261,7 @@ export function ProjectsPage() {
|
||||
<div className="flex flex-col items-center justify-center py-24 text-muted-foreground">
|
||||
<FolderKanban className="h-10 w-10 mb-3 opacity-30" />
|
||||
<p className="text-sm">No projects yet</p>
|
||||
<Button size="sm" variant="outline" className="mt-3" onClick={() => setCreateOpen(true)}>
|
||||
<Button size="sm" variant="outline" className="mt-3" onClick={openCreateProject}>
|
||||
Create your first project
|
||||
</Button>
|
||||
</div>
|
||||
@@ -593,8 +285,6 @@ export function ProjectsPage() {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CreateProjectDialog open={createOpen} onOpenChange={setCreateOpen} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,12 +5,40 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SearchCommand } from "./search-command";
|
||||
import { useSearchStore } from "./search-store";
|
||||
|
||||
const { mockPush, mockSearchIssues, mockSearchProjects, mockRecentItems, mockAllIssues } = vi.hoisted(() => ({
|
||||
const {
|
||||
mockPush,
|
||||
mockSearchIssues,
|
||||
mockSearchProjects,
|
||||
mockRecentItems,
|
||||
mockAllIssues,
|
||||
mockSetTheme,
|
||||
mockTheme,
|
||||
mockPathname,
|
||||
mockGetShareableUrl,
|
||||
mockWorkspaces,
|
||||
mockCurrentWorkspace,
|
||||
mockOpenModal,
|
||||
mockToastSuccess,
|
||||
mockClipboardWrite,
|
||||
} = vi.hoisted(() => ({
|
||||
mockPush: vi.fn(),
|
||||
mockSearchIssues: vi.fn(),
|
||||
mockSearchProjects: vi.fn(),
|
||||
mockRecentItems: { current: [] as Array<{ id: string; visitedAt: number }> },
|
||||
mockAllIssues: { current: [] as Array<Record<string, unknown>> },
|
||||
mockSetTheme: vi.fn(),
|
||||
mockTheme: { current: "system" as "light" | "dark" | "system" },
|
||||
mockPathname: { current: "/ws-test/issues" as string },
|
||||
mockGetShareableUrl: vi.fn((p: string) => `https://app.multica/${p}`),
|
||||
mockWorkspaces: {
|
||||
current: [] as Array<{ id: string; name: string; slug: string }>,
|
||||
},
|
||||
mockCurrentWorkspace: {
|
||||
current: null as { id: string; name: string; slug: string } | null,
|
||||
},
|
||||
mockOpenModal: vi.fn(),
|
||||
mockToastSuccess: vi.fn(),
|
||||
mockClipboardWrite: vi.fn(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/api", () => ({
|
||||
@@ -32,6 +60,12 @@ vi.mock("@multica/core", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/paths", () => ({
|
||||
paths: {
|
||||
workspace: (slug: string) => ({
|
||||
issues: () => `/${slug}/issues`,
|
||||
}),
|
||||
},
|
||||
useCurrentWorkspace: () => mockCurrentWorkspace.current,
|
||||
useWorkspacePaths: () => ({
|
||||
inbox: () => "/ws-test/inbox",
|
||||
myIssues: () => "/ws-test/my-issues",
|
||||
@@ -50,16 +84,40 @@ vi.mock("@multica/core/issues/queries", () => ({
|
||||
issueListOptions: () => ({ queryKey: ["issues", "ws-test", "list"], enabled: false }),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/workspace/queries", () => ({
|
||||
workspaceListOptions: () => ({ queryKey: ["workspaces", "list"], enabled: false }),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/modals", () => ({
|
||||
useModalStore: Object.assign(vi.fn(), {
|
||||
getState: () => ({ open: mockOpenModal }),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({ data: mockAllIssues.current }),
|
||||
useQuery: (opts: { queryKey: readonly unknown[] }) => {
|
||||
const key = opts.queryKey;
|
||||
if (key[0] === "workspaces") return { data: mockWorkspaces.current };
|
||||
return { data: mockAllIssues.current };
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../navigation", () => ({
|
||||
useNavigation: () => ({
|
||||
push: mockPush,
|
||||
pathname: mockPathname.current,
|
||||
getShareableUrl: mockGetShareableUrl,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/ui/components/common/theme-provider", () => ({
|
||||
useTheme: () => ({ theme: mockTheme.current, setTheme: mockSetTheme }),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: { success: mockToastSuccess, error: vi.fn() },
|
||||
}));
|
||||
|
||||
describe("SearchCommand", () => {
|
||||
beforeEach(() => {
|
||||
mockPush.mockReset();
|
||||
@@ -67,6 +125,15 @@ describe("SearchCommand", () => {
|
||||
mockSearchProjects.mockReset().mockResolvedValue({ projects: [] });
|
||||
mockRecentItems.current = [];
|
||||
mockAllIssues.current = [];
|
||||
mockSetTheme.mockReset();
|
||||
mockTheme.current = "system";
|
||||
mockPathname.current = "/ws-test/issues";
|
||||
mockGetShareableUrl.mockReset().mockImplementation((p: string) => `https://app.multica/${p}`);
|
||||
mockWorkspaces.current = [];
|
||||
mockCurrentWorkspace.current = null;
|
||||
mockOpenModal.mockReset();
|
||||
mockToastSuccess.mockReset();
|
||||
mockClipboardWrite.mockReset().mockResolvedValue(undefined);
|
||||
|
||||
// cmdk calls scrollIntoView on the first selected item, which jsdom doesn't implement
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
@@ -94,10 +161,19 @@ describe("SearchCommand", () => {
|
||||
expect(screen.queryByPlaceholderText("Type a command or search...")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show pages when no query is entered", () => {
|
||||
it("shows Commands by default but hides Pages and Switch Workspace until query", () => {
|
||||
render(<SearchCommand />);
|
||||
|
||||
expect(screen.queryByText("Pages")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Switch Workspace")).not.toBeInTheDocument();
|
||||
// Commands surface by default for discoverability.
|
||||
expect(screen.getByText("Commands")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText((_, el) => el?.textContent === "New Issue" && el?.tagName === "SPAN"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText((_, el) => el?.textContent === "New Project" && el?.tagName === "SPAN"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("filters navigation pages by query", async () => {
|
||||
@@ -112,7 +188,6 @@ describe("SearchCommand", () => {
|
||||
expect(screen.getByText((_, el) => el?.textContent === "Settings" && el?.tagName === "SPAN")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText("Inbox")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Projects")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("navigates to page on selection", async () => {
|
||||
@@ -148,6 +223,198 @@ describe("SearchCommand", () => {
|
||||
expect(screen.getByText("MUL-2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows New Issue / New Project under Commands and triggers the modal store", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SearchCommand />);
|
||||
|
||||
const input = screen.getByPlaceholderText("Type a command or search...");
|
||||
await user.type(input, "new");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Commands")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText((_, el) => el?.textContent === "New Issue" && el?.tagName === "SPAN"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText((_, el) => el?.textContent === "New Project" && el?.tagName === "SPAN"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const newIssue = await screen.findByText(
|
||||
(_, el) => el?.textContent === "New Issue" && el?.tagName === "SPAN",
|
||||
);
|
||||
await user.click(newIssue);
|
||||
|
||||
expect(mockOpenModal).toHaveBeenCalledWith("create-issue");
|
||||
expect(useSearchStore.getState().open).toBe(false);
|
||||
});
|
||||
|
||||
it("hides copy-link commands when not on an issue detail route", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockPathname.current = "/ws-test/projects";
|
||||
render(<SearchCommand />);
|
||||
|
||||
const input = screen.getByPlaceholderText("Type a command or search...");
|
||||
await user.type(input, "copy");
|
||||
|
||||
// Commands section may still be empty / absent.
|
||||
expect(screen.queryByText("Copy Issue Link")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("copies issue link and identifier when on an issue detail route", async () => {
|
||||
const user = userEvent.setup();
|
||||
// userEvent.setup() installs its own navigator.clipboard; spy on it so we
|
||||
// intercept the writeText call without clobbering userEvent's internals.
|
||||
const writeSpy = vi
|
||||
.spyOn(navigator.clipboard, "writeText")
|
||||
.mockImplementation(mockClipboardWrite);
|
||||
mockPathname.current = "/ws-test/issues/issue-1";
|
||||
mockAllIssues.current = [
|
||||
{ id: "issue-1", identifier: "MUL-42", title: "Demo", status: "todo" },
|
||||
];
|
||||
render(<SearchCommand />);
|
||||
|
||||
const input = screen.getByPlaceholderText("Type a command or search...");
|
||||
await user.type(input, "copy");
|
||||
|
||||
const linkItem = await screen.findByText(
|
||||
(_, el) => el?.textContent === "Copy Issue Link" && el?.tagName === "SPAN",
|
||||
);
|
||||
await user.click(linkItem);
|
||||
|
||||
expect(mockGetShareableUrl).toHaveBeenCalledWith("/ws-test/issues/issue-1");
|
||||
expect(mockClipboardWrite).toHaveBeenCalledWith("https://app.multica//ws-test/issues/issue-1");
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith("Link copied");
|
||||
|
||||
// Reopen palette and test identifier copy
|
||||
act(() => {
|
||||
useSearchStore.setState({ open: true });
|
||||
});
|
||||
const input2 = screen.getByPlaceholderText("Type a command or search...");
|
||||
await user.type(input2, "copy");
|
||||
const idItem = await screen.findByText(
|
||||
(_, el) =>
|
||||
el?.textContent === "Copy Identifier (MUL-42)" && el?.tagName === "SPAN",
|
||||
);
|
||||
await user.click(idItem);
|
||||
expect(mockClipboardWrite).toHaveBeenCalledWith("MUL-42");
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith("Copied MUL-42");
|
||||
|
||||
writeSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("filters theme commands by query keywords", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SearchCommand />);
|
||||
|
||||
const input = screen.getByPlaceholderText("Type a command or search...");
|
||||
await user.type(input, "dark");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Commands")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText((_, el) => el?.textContent === "Switch to Dark Theme" && el?.tagName === "SPAN"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText("Switch to Light Theme")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Use System Theme")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("applies the selected theme and closes the palette", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockTheme.current = "light";
|
||||
render(<SearchCommand />);
|
||||
|
||||
const input = screen.getByPlaceholderText("Type a command or search...");
|
||||
await user.type(input, "dark");
|
||||
|
||||
const darkItem = await screen.findByText(
|
||||
(_, el) => el?.textContent === "Switch to Dark Theme" && el?.tagName === "SPAN",
|
||||
);
|
||||
await user.click(darkItem);
|
||||
|
||||
expect(mockSetTheme).toHaveBeenCalledWith("dark");
|
||||
expect(useSearchStore.getState().open).toBe(false);
|
||||
});
|
||||
|
||||
it("matches theme action via generic 'theme' keyword and marks current theme", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockTheme.current = "dark";
|
||||
render(<SearchCommand />);
|
||||
|
||||
const input = screen.getByPlaceholderText("Type a command or search...");
|
||||
await user.type(input, "theme");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText((_, el) => el?.textContent === "Switch to Light Theme" && el?.tagName === "SPAN"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText((_, el) => el?.textContent === "Switch to Dark Theme" && el?.tagName === "SPAN"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText((_, el) => el?.textContent === "Use System Theme" && el?.tagName === "SPAN"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByLabelText("Current theme")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lists other workspaces under Switch Workspace and navigates on select", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockCurrentWorkspace.current = { id: "ws-current", name: "Current", slug: "current" };
|
||||
mockWorkspaces.current = [
|
||||
{ id: "ws-current", name: "Current", slug: "current" },
|
||||
{ id: "ws-alpha", name: "Alpha Co", slug: "alpha" },
|
||||
{ id: "ws-beta", name: "Beta Co", slug: "beta" },
|
||||
];
|
||||
render(<SearchCommand />);
|
||||
|
||||
const input = screen.getByPlaceholderText("Type a command or search...");
|
||||
await user.type(input, "alpha");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Switch Workspace")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText((_, el) => el?.textContent === "Alpha Co" && el?.tagName === "SPAN"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText("Beta Co")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Current")).not.toBeInTheDocument();
|
||||
|
||||
const alphaItem = await screen.findByText(
|
||||
(_, el) => el?.textContent === "Alpha Co" && el?.tagName === "SPAN",
|
||||
);
|
||||
await user.click(alphaItem);
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith("/alpha/issues");
|
||||
expect(useSearchStore.getState().open).toBe(false);
|
||||
});
|
||||
|
||||
it("shows all other workspaces when typing 'workspace'", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockCurrentWorkspace.current = { id: "ws-current", name: "Current", slug: "current" };
|
||||
mockWorkspaces.current = [
|
||||
{ id: "ws-current", name: "Current", slug: "current" },
|
||||
{ id: "ws-alpha", name: "Alpha Co", slug: "alpha" },
|
||||
{ id: "ws-beta", name: "Beta Co", slug: "beta" },
|
||||
];
|
||||
render(<SearchCommand />);
|
||||
|
||||
const input = screen.getByPlaceholderText("Type a command or search...");
|
||||
await user.type(input, "workspace");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Switch Workspace")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText((_, el) => el?.textContent === "Alpha Co" && el?.tagName === "SPAN"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText((_, el) => el?.textContent === "Beta Co" && el?.tagName === "SPAN"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText("Current")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("filters out recent items not present in query cache", () => {
|
||||
mockRecentItems.current = [
|
||||
{ id: "issue-1", visitedAt: 1000 },
|
||||
|
||||
@@ -2,9 +2,13 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Check,
|
||||
Clock,
|
||||
Copy,
|
||||
Link2,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
Plus,
|
||||
SearchIcon,
|
||||
Inbox,
|
||||
CircleUser,
|
||||
@@ -12,19 +16,25 @@ import {
|
||||
FolderKanban,
|
||||
Bot,
|
||||
Monitor,
|
||||
Moon,
|
||||
Sun,
|
||||
BookOpenText,
|
||||
Settings,
|
||||
Building2,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { Command as CommandPrimitive } from "cmdk";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import type { SearchIssueResult, SearchProjectResult } from "@multica/core/types";
|
||||
import { api } from "@multica/core/api";
|
||||
import { useRecentIssuesStore } from "@multica/core/issues/stores";
|
||||
import { issueListOptions } from "@multica/core/issues/queries";
|
||||
import { useWorkspaceId } from "@multica/core";
|
||||
import { useWorkspacePaths } from "@multica/core/paths";
|
||||
import { paths, useCurrentWorkspace, useWorkspacePaths } from "@multica/core/paths";
|
||||
import type { WorkspacePaths } from "@multica/core/paths";
|
||||
import { useModalStore } from "@multica/core/modals";
|
||||
import { workspaceListOptions } from "@multica/core/workspace/queries";
|
||||
import { StatusIcon } from "../issues/components";
|
||||
import { STATUS_CONFIG } from "@multica/core/issues/config";
|
||||
import { PROJECT_STATUS_CONFIG } from "@multica/core/projects/config";
|
||||
@@ -36,6 +46,7 @@ import {
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@multica/ui/components/ui/dialog";
|
||||
import { useTheme } from "@multica/ui/components/common/theme-provider";
|
||||
import { useNavigation } from "../navigation";
|
||||
import { useSearchStore } from "./search-store";
|
||||
|
||||
@@ -106,19 +117,33 @@ const navPages: NavPage[] = [
|
||||
{ key: "settings", label: "Settings", icon: Settings, keywords: ["settings", "config", "preferences"] },
|
||||
];
|
||||
|
||||
type ThemeValue = "light" | "dark" | "system";
|
||||
|
||||
interface CommandItem {
|
||||
key: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
keywords: string[];
|
||||
trailing?: React.ReactNode;
|
||||
onSelect: () => void;
|
||||
}
|
||||
|
||||
interface SearchResults {
|
||||
issues: SearchIssueResult[];
|
||||
projects: SearchProjectResult[];
|
||||
}
|
||||
|
||||
export function SearchCommand() {
|
||||
const { push } = useNavigation();
|
||||
const { push, pathname, getShareableUrl } = useNavigation();
|
||||
const open = useSearchStore((s) => s.open);
|
||||
const setOpen = useSearchStore((s) => s.setOpen);
|
||||
const recentItems = useRecentIssuesStore((s) => s.items);
|
||||
const wsId = useWorkspaceId();
|
||||
const p: WorkspacePaths = useWorkspacePaths();
|
||||
const { data: allIssues = [] } = useQuery(issueListOptions(wsId));
|
||||
const { theme, setTheme } = useTheme();
|
||||
const currentWorkspace = useCurrentWorkspace();
|
||||
const { data: workspaces = [] } = useQuery(workspaceListOptions());
|
||||
|
||||
const recentIssues = useMemo(() => {
|
||||
const issueMap = new Map(allIssues.map((i) => [i.id, i]));
|
||||
@@ -144,6 +169,144 @@ export function SearchCommand() {
|
||||
);
|
||||
}, [query]);
|
||||
|
||||
// Detect if current route is an issue detail page — /{slug}/issues/{id}.
|
||||
// Falls back to null on any other route; used to gate issue-specific commands.
|
||||
const currentIssue = useMemo(() => {
|
||||
const match = pathname.match(/\/issues\/([^/]+)$/);
|
||||
const raw = match?.[1];
|
||||
if (!raw) return null;
|
||||
const id = decodeURIComponent(raw);
|
||||
return allIssues.find((i) => i.id === id) ?? null;
|
||||
}, [pathname, allIssues]);
|
||||
|
||||
const commands = useMemo<CommandItem[]>(() => {
|
||||
const activeThemeCheck = (value: ThemeValue) =>
|
||||
theme === value ? (
|
||||
<Check
|
||||
aria-label="Current theme"
|
||||
className="ml-auto size-4 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
) : undefined;
|
||||
|
||||
const items: CommandItem[] = [
|
||||
{
|
||||
key: "new-issue",
|
||||
label: "New Issue",
|
||||
icon: Plus,
|
||||
keywords: ["new", "issue", "create", "add"],
|
||||
onSelect: () => {
|
||||
useModalStore.getState().open("create-issue");
|
||||
setOpen(false);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "new-project",
|
||||
label: "New Project",
|
||||
icon: Plus,
|
||||
keywords: ["new", "project", "create", "add"],
|
||||
onSelect: () => {
|
||||
useModalStore.getState().open("create-project");
|
||||
setOpen(false);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
if (currentIssue) {
|
||||
const identifier = currentIssue.identifier;
|
||||
items.push(
|
||||
{
|
||||
key: "copy-issue-link",
|
||||
label: "Copy Issue Link",
|
||||
icon: Link2,
|
||||
keywords: ["copy", "link", "share", "url", identifier.toLowerCase()],
|
||||
onSelect: () => {
|
||||
const url = getShareableUrl ? getShareableUrl(pathname) : window.location.href;
|
||||
void navigator.clipboard.writeText(url);
|
||||
toast.success("Link copied");
|
||||
setOpen(false);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "copy-issue-identifier",
|
||||
label: `Copy Identifier (${identifier})`,
|
||||
icon: Copy,
|
||||
keywords: ["copy", "id", "identifier", identifier.toLowerCase()],
|
||||
onSelect: () => {
|
||||
void navigator.clipboard.writeText(identifier);
|
||||
toast.success(`Copied ${identifier}`);
|
||||
setOpen(false);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
items.push(
|
||||
{
|
||||
key: "theme-light",
|
||||
label: "Switch to Light Theme",
|
||||
icon: Sun,
|
||||
keywords: ["light", "theme", "appearance", "mode", "bright"],
|
||||
trailing: activeThemeCheck("light"),
|
||||
onSelect: () => {
|
||||
setTheme("light");
|
||||
setOpen(false);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "theme-dark",
|
||||
label: "Switch to Dark Theme",
|
||||
icon: Moon,
|
||||
keywords: ["dark", "theme", "appearance", "mode", "night"],
|
||||
trailing: activeThemeCheck("dark"),
|
||||
onSelect: () => {
|
||||
setTheme("dark");
|
||||
setOpen(false);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "theme-system",
|
||||
label: "Use System Theme",
|
||||
icon: Monitor,
|
||||
keywords: ["system", "theme", "appearance", "mode", "auto"],
|
||||
trailing: activeThemeCheck("system"),
|
||||
onSelect: () => {
|
||||
setTheme("system");
|
||||
setOpen(false);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return items;
|
||||
}, [currentIssue, getShareableUrl, pathname, setOpen, setTheme, theme]);
|
||||
|
||||
const filteredCommands = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
// No query: surface the whole Commands list so users can discover what's
|
||||
// available without having to guess keywords (Linear/Raycast pattern).
|
||||
if (!q) return commands;
|
||||
return commands.filter(
|
||||
(c) =>
|
||||
c.label.toLowerCase().includes(q) ||
|
||||
c.keywords.some((kw) => kw.includes(q)),
|
||||
);
|
||||
}, [commands, query]);
|
||||
|
||||
// Only show workspaces different from the current one, and only after the
|
||||
// user types >=2 chars — one char would match everything (e.g. "w").
|
||||
const filteredWorkspaces = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return [];
|
||||
const others = workspaces.filter((w) => w.id !== currentWorkspace?.id);
|
||||
const wantsAll =
|
||||
q.length >= 2 && ("workspace".startsWith(q) || "switch".startsWith(q));
|
||||
return others.filter(
|
||||
(w) =>
|
||||
wantsAll ||
|
||||
w.name.toLowerCase().includes(q) ||
|
||||
w.slug.toLowerCase().includes(q),
|
||||
);
|
||||
}, [workspaces, currentWorkspace?.id, query]);
|
||||
|
||||
const hasResults = results.issues.length > 0 || results.projects.length > 0;
|
||||
|
||||
// Global Cmd+K / Ctrl+K shortcut
|
||||
@@ -262,6 +425,14 @@ export function SearchCommand() {
|
||||
[push, setOpen, p],
|
||||
);
|
||||
|
||||
const handleSwitchWorkspace = useCallback(
|
||||
(slug: string) => {
|
||||
push(paths.workspace(slug).issues());
|
||||
setOpen(false);
|
||||
},
|
||||
[push, setOpen],
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent
|
||||
@@ -317,17 +488,70 @@ export function SearchCommand() {
|
||||
</CommandPrimitive.Group>
|
||||
)}
|
||||
|
||||
{/* Commands section — New Issue / New Project / Copy link / Theme, only shown when query matches */}
|
||||
{filteredCommands.length > 0 && (
|
||||
<CommandPrimitive.Group className="p-2">
|
||||
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
Commands
|
||||
</div>
|
||||
{filteredCommands.map((cmd) => (
|
||||
<CommandPrimitive.Item
|
||||
key={cmd.key}
|
||||
value={`command:${cmd.key}`}
|
||||
onSelect={cmd.onSelect}
|
||||
className="flex cursor-default select-none items-center gap-2.5 rounded-lg px-3 py-2.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-accent"
|
||||
>
|
||||
<cmd.icon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">
|
||||
<HighlightText text={cmd.label} query={query} />
|
||||
</span>
|
||||
{cmd.trailing}
|
||||
</CommandPrimitive.Item>
|
||||
))}
|
||||
</CommandPrimitive.Group>
|
||||
)}
|
||||
|
||||
{/* Workspaces section — switch to a different workspace, only shown when query matches */}
|
||||
{filteredWorkspaces.length > 0 && (
|
||||
<CommandPrimitive.Group className="p-2">
|
||||
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
Switch Workspace
|
||||
</div>
|
||||
{filteredWorkspaces.map((ws) => (
|
||||
<CommandPrimitive.Item
|
||||
key={ws.id}
|
||||
value={`workspace:${ws.id}`}
|
||||
onSelect={() => handleSwitchWorkspace(ws.slug)}
|
||||
className="flex cursor-default select-none items-center gap-2.5 rounded-lg px-3 py-2.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-accent"
|
||||
>
|
||||
<Building2 className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">
|
||||
<HighlightText text={ws.name} query={query} />
|
||||
</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground truncate">
|
||||
{ws.slug}
|
||||
</span>
|
||||
</CommandPrimitive.Item>
|
||||
))}
|
||||
</CommandPrimitive.Group>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && query.trim() && !hasResults && filteredPages.length === 0 && (
|
||||
<CommandPrimitive.Empty className="py-10 text-center text-sm text-muted-foreground">
|
||||
No results found.
|
||||
</CommandPrimitive.Empty>
|
||||
)}
|
||||
{!isLoading &&
|
||||
query.trim() &&
|
||||
!hasResults &&
|
||||
filteredPages.length === 0 &&
|
||||
filteredCommands.length === 0 &&
|
||||
filteredWorkspaces.length === 0 && (
|
||||
<CommandPrimitive.Empty className="py-10 text-center text-sm text-muted-foreground">
|
||||
No results found.
|
||||
</CommandPrimitive.Empty>
|
||||
)}
|
||||
|
||||
{!isLoading && results.projects.length > 0 && (
|
||||
<CommandPrimitive.Group
|
||||
@@ -448,9 +672,8 @@ export function SearchCommand() {
|
||||
)}
|
||||
|
||||
{!isLoading && !query.trim() && recentIssues.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-2 py-10 text-sm text-muted-foreground">
|
||||
<span>Type to search issues and projects...</span>
|
||||
<span className="text-xs">Press <kbd className="rounded bg-muted px-1.5 py-0.5 font-medium">⌘K</kbd> to open this anytime</span>
|
||||
<div className="px-5 py-4 text-center text-xs text-muted-foreground">
|
||||
Type to search issues and projects
|
||||
</div>
|
||||
)}
|
||||
</CommandPrimitive.List>
|
||||
|
||||
Reference in New Issue
Block a user