mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-26 12:35:35 +02:00
fix(views): close project picker dropdown after selecting a project (#5663)
The create-issue dialogs wire pickers with open={cond ? true : undefined}. Base UI latches a controlled open={true} and does not treat a later undefined as closed, so the project dropdown remains visible after selection. Normalize the picker to an always-boolean open state, matching the other field pickers.
Co-authored-by: 余剑剑 <clydeyu@lilith.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
// Regression test: selecting a project in the create-issue
|
||||
// dialog left the dropdown stuck open. The dialog wires the picker with
|
||||
// `open={cond ? true : undefined}`; Base UI's Menu latches a controlled
|
||||
// `open={true}` and does NOT treat a later `undefined` as "close", so the
|
||||
// picker must normalize to an always-boolean controlled value. This test
|
||||
// uses the REAL dropdown-menu (Base UI) — do not mock it here.
|
||||
import { useState } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { I18nProvider } from "@multica/core/i18n/react";
|
||||
import enProjects from "../../locales/en/projects.json";
|
||||
import { ProjectPicker } from "./project-picker";
|
||||
import { PillButton } from "../../common/pill-button";
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({
|
||||
data: [
|
||||
{ id: "project-1", title: "Launch Command Center", icon: null },
|
||||
{ id: "project-2", title: "Mobile Web", icon: null },
|
||||
],
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/hooks", () => ({
|
||||
useWorkspaceId: () => "workspace-1",
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/projects/queries", () => ({
|
||||
projectListOptions: () => ({ queryKey: ["projects"] }),
|
||||
}));
|
||||
|
||||
vi.mock("./project-icon", () => ({
|
||||
ProjectIcon: () => <span data-testid="project-icon" />,
|
||||
}));
|
||||
|
||||
/** Mirrors the create-issue dialog wiring from packages/views/modals/create-issue.tsx. */
|
||||
function CreateDialogHarness({ onUpdate }: { onUpdate: (u: object) => void }) {
|
||||
const [fieldPickerOpen, setFieldPickerOpen] = useState<"project" | null>(null);
|
||||
const [projectId, setProjectId] = useState<string | null>(null);
|
||||
return (
|
||||
<I18nProvider locale="en" resources={{ en: { projects: enProjects } }}>
|
||||
<ProjectPicker
|
||||
projectId={projectId}
|
||||
onUpdate={(u) => {
|
||||
onUpdate(u);
|
||||
setProjectId(u.project_id ?? null);
|
||||
}}
|
||||
triggerRender={<PillButton />}
|
||||
align="start"
|
||||
open={fieldPickerOpen === "project" ? true : undefined}
|
||||
onOpenChange={(open) => setFieldPickerOpen(open ? "project" : null)}
|
||||
/>
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe("ProjectPicker open state under create-dialog wiring", () => {
|
||||
it("closes the dropdown after selecting a project", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onUpdate = vi.fn();
|
||||
|
||||
render(<CreateDialogHarness onUpdate={onUpdate} />);
|
||||
|
||||
// Open the picker via its trigger.
|
||||
await user.click(screen.getByRole("button", { name: /no project/i }));
|
||||
const item = await screen.findByRole("menuitem", { name: /mobile web/i });
|
||||
|
||||
// Select a project — the selection must register AND the popup must close.
|
||||
await user.click(item);
|
||||
expect(onUpdate).toHaveBeenCalledWith({ project_id: "project-2" });
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("menuitem", { name: /mobile web/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("can be reopened and closed again after a selection", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<CreateDialogHarness onUpdate={vi.fn()} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /no project/i }));
|
||||
await user.click(await screen.findByRole("menuitem", { name: /launch command center/i }));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("menuitem", { name: /launch command center/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Reopen from the (now selected) trigger and close by selecting again.
|
||||
await user.click(screen.getByRole("button", { name: /launch command center/i }));
|
||||
await user.click(await screen.findByRole("menuitem", { name: /mobile web/i }));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("menuitem", { name: /mobile web/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Check, FolderKanban, X } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { projectListOptions } from "@multica/core/projects/queries";
|
||||
@@ -21,7 +22,7 @@ export function ProjectPicker({
|
||||
triggerRender,
|
||||
align = "start",
|
||||
defaultOpen = false,
|
||||
open,
|
||||
open: controlledOpen,
|
||||
onOpenChange,
|
||||
}: {
|
||||
projectId: string | null;
|
||||
@@ -38,9 +39,17 @@ export function ProjectPicker({
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: projects = [] } = useQuery(projectListOptions(wsId));
|
||||
const current = projects.find((p) => p.id === projectId);
|
||||
// Normalize to an always-boolean controlled `open`, matching the other
|
||||
// pickers (status/priority/assignee/labels). Base UI's Menu latches a
|
||||
// controlled `open={true}` — a later `undefined` does NOT close it — so
|
||||
// callers wiring `open={cond ? true : undefined}` (create-issue dialog)
|
||||
// would leave the popup stuck open after selecting a project.
|
||||
const [internalOpen, setInternalOpen] = useState(defaultOpen);
|
||||
const open = controlledOpen ?? internalOpen;
|
||||
const setOpen = onOpenChange ?? setInternalOpen;
|
||||
|
||||
return (
|
||||
<DropdownMenu defaultOpen={defaultOpen} open={open} onOpenChange={onOpenChange}>
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<div className="group/project relative inline-flex min-w-0">
|
||||
<DropdownMenuTrigger
|
||||
className={triggerRender ? undefined : "flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors overflow-hidden"}
|
||||
|
||||
Reference in New Issue
Block a user