diff --git a/packages/views/projects/components/project-picker.open-state.test.tsx b/packages/views/projects/components/project-picker.open-state.test.tsx new file mode 100644 index 000000000..331b7ded5 --- /dev/null +++ b/packages/views/projects/components/project-picker.open-state.test.tsx @@ -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: () => , +})); + +/** 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(null); + return ( + + { + onUpdate(u); + setProjectId(u.project_id ?? null); + }} + triggerRender={} + align="start" + open={fieldPickerOpen === "project" ? true : undefined} + onOpenChange={(open) => setFieldPickerOpen(open ? "project" : null)} + /> + + ); +} + +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(); + + // 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(); + + 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(); + }); + }); +}); diff --git a/packages/views/projects/components/project-picker.tsx b/packages/views/projects/components/project-picker.tsx index 9020f9d8c..5096229a0 100644 --- a/packages/views/projects/components/project-picker.tsx +++ b/packages/views/projects/components/project-picker.tsx @@ -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 ( - +