Files
multica/packages/views/projects/components/project-picker.open-state.test.tsx
Bohan Jiang ef69c3d4a3 feat(projects): add search and max-height to the project picker (MUL-5344) (#5979)
* feat(projects): add search and max-height to the project picker (MUL-5344)

Migrate ProjectPicker off the bare DropdownMenu onto the shared
PropertyPicker (the same primitive assignee/label/status pickers use), so
the project dropdown now caps its height with a scrollable list and gains a
client-side search box. Search matches on title substring and pinyin, so
Chinese project names are reachable by latin input.

Preserves the full existing contract: controlled/uncontrolled open with the
Base UI open-latch normalization, the disabled read-only lock, the inline
hover/keyboard clear button, and every caller's custom trigger.

Co-authored-by: multica-agent <github@multica.ai>

* fix(pickers): reset picker search state on programmatic close (MUL-5344)

PropertyPicker cleared its search query inside the popover's open-change
handler. Every picker closes itself after a selection by calling its own
setOpen(false), which flips the `open` prop directly and never routes
through that handler — so the stale query survived into the next open and
kept the rest of the list filtered out.

Move the reset onto the open -> closed transition so it covers programmatic
closes too. This also fixes the same latent staleness in the assignee and
label pickers, which close on selection the same way.

Adds a regression test: search -> select -> reopen must show an empty input
and the full list.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-27 13:52:22 +08:00

173 lines
7.1 KiB
TypeScript

// Behavioural tests for the Popover-backed ProjectPicker. These use the REAL
// PropertyPicker / Base UI Popover — do not mock them here.
//
// Open-state regression: 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 latches a controlled `open={true}`
// and does NOT treat a later `undefined` as "close", so the picker normalizes
// to an always-boolean controlled value.
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 enIssues from "../../locales/en/issues.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 },
{ id: "project-3", title: "数据透明化", 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" />,
}));
function withI18n(children: React.ReactNode) {
return (
<I18nProvider locale="en" resources={{ en: { projects: enProjects, issues: enIssues } }}>
{children}
</I18nProvider>
);
}
/** 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 withI18n(
<ProjectPicker
projectId={projectId}
onUpdate={(u) => {
onUpdate(u);
setProjectId((u as { project_id?: string | null }).project_id ?? null);
}}
triggerRender={<PillButton />}
align="start"
open={fieldPickerOpen === "project" ? true : undefined}
onOpenChange={(open) => setFieldPickerOpen(open ? "project" : null)}
/>,
);
}
// The picker is closed iff its search input is unmounted. A closed selection
// can't be detected by the item's name because the trigger adopts the selected
// project's title, so a name query would keep matching the trigger.
function expectClosed() {
return waitFor(() => {
expect(screen.queryByPlaceholderText("Search projects...")).not.toBeInTheDocument();
});
}
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 (unselected → trigger reads "No project").
await user.click(screen.getByRole("button", { name: /no project/i }));
const item = await screen.findByRole("button", { 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 expectClosed();
});
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("button", { name: /launch command center/i }));
await expectClosed();
// 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("button", { name: /mobile web/i }));
await expectClosed();
});
});
describe("ProjectPicker search", () => {
it("filters the project list by title substring", async () => {
const user = userEvent.setup();
render(withI18n(<ProjectPicker projectId={null} onUpdate={vi.fn()} triggerRender={<PillButton />} />));
await user.click(screen.getByRole("button", { name: /no project/i }));
const search = await screen.findByPlaceholderText("Search projects...");
await user.type(search, "mobile");
expect(screen.getByRole("button", { name: /mobile web/i })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /launch command center/i })).not.toBeInTheDocument();
});
it("matches Chinese project names by pinyin", async () => {
const user = userEvent.setup();
render(withI18n(<ProjectPicker projectId={null} onUpdate={vi.fn()} triggerRender={<PillButton />} />));
await user.click(screen.getByRole("button", { name: /no project/i }));
const search = await screen.findByPlaceholderText("Search projects...");
// "数据透明化" → full pinyin "shujutouminghua"; a prefix must match.
await user.type(search, "shuju");
expect(screen.getByText("数据透明化")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /mobile web/i })).not.toBeInTheDocument();
});
it("shows an empty state when no project matches", async () => {
const user = userEvent.setup();
render(withI18n(<ProjectPicker projectId={null} onUpdate={vi.fn()} triggerRender={<PillButton />} />));
await user.click(screen.getByRole("button", { name: /no project/i }));
const search = await screen.findByPlaceholderText("Search projects...");
await user.type(search, "zzzznomatch");
expect(screen.getByText("No results")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /mobile web/i })).not.toBeInTheDocument();
});
// Regression: selecting a row closes the popover by calling `setOpen(false)`
// directly, which never routes through PropertyPicker's own open-change
// handler — the only place that used to reset the query. The stale search
// term survived into the next open and kept the rest of the list hidden.
it("resets the search term after selecting a match and reopening", async () => {
const user = userEvent.setup();
render(<CreateDialogHarness onUpdate={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /no project/i }));
await user.type(await screen.findByPlaceholderText("Search projects..."), "mobile");
await user.click(await screen.findByRole("button", { name: /mobile web/i }));
await expectClosed();
// Reopen: the input must be empty and the full list restored.
await user.click(screen.getByRole("button", { name: /mobile web/i }));
const reopened = await screen.findByPlaceholderText("Search projects...");
expect(reopened).toHaveValue("");
expect(screen.getByRole("button", { name: /launch command center/i })).toBeInTheDocument();
expect(screen.getByText("数据透明化")).toBeInTheDocument();
});
});