Files
multica/packages/views/projects/components/project-picker.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

114 lines
4.0 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } 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 }],
}),
}));
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" />,
}));
// Real PropertyPicker (Popover) — do not mock it: the inline clear control is
// rendered by ProjectPicker outside the popover, so it is present without
// opening the picker.
function renderPicker(props: Partial<React.ComponentProps<typeof ProjectPicker>> = {}) {
return render(
<I18nProvider locale="en" resources={{ en: { projects: enProjects, issues: enIssues } }}>
<ProjectPicker
projectId="project-1"
onUpdate={props.onUpdate ?? vi.fn()}
triggerRender={<PillButton />}
{...props}
/>
</I18nProvider>,
);
}
function findInlineClear() {
return screen
.getAllByRole("button", { name: "Remove from project" })
.find((button) => button.className.includes("group-hover/project:opacity-100"));
}
describe("ProjectPicker", () => {
it("shows a hover clear action for the selected project", async () => {
const user = userEvent.setup();
const onUpdate = vi.fn();
renderPicker({ onUpdate });
const clear = findInlineClear();
expect(clear).toBeDefined();
expect(clear!.className).toContain("group-hover/project:opacity-100");
expect(clear!.className).toContain("size-3.5");
expect(clear!.className).toContain("hover:bg-muted-foreground/20");
expect(clear!.className).not.toContain("bg-background/95");
expect(clear!.className).not.toContain("inset-y-0");
expect(clear!.className).not.toContain("w-7");
await user.click(clear!);
expect(onUpdate).toHaveBeenCalledWith({ project_id: null });
});
it("clears via keyboard activation when enabled", async () => {
// Other callers (issue/create/autopilot) rely on the clear control staying
// reachable by keyboard, not just hover. Enabling must not regress that.
const user = userEvent.setup();
const onUpdate = vi.fn();
renderPicker({ onUpdate });
const clear = findInlineClear();
expect(clear).toBeDefined();
expect(clear).not.toBeDisabled();
clear!.focus();
expect(clear).toHaveFocus();
await user.keyboard("{Enter}");
expect(onUpdate).toHaveBeenCalledWith({ project_id: null });
});
it("locks the inline clear control against pointer and keyboard when disabled", () => {
// Regression (MUL-5150): the outer wrapper's `pointer-events-none` only
// blocks the mouse. The inline clear button stayed in the tab order, so a
// keyboard user could Tab to it and press Enter to detach the project while
// a chat send was in flight — retargeting the lazily-created session. The
// explicit `disabled` capability must remove it from the tab order and make
// both pointer and keyboard activation inert.
const onUpdate = vi.fn();
renderPicker({ onUpdate, disabled: true });
const clear = findInlineClear();
expect(clear).toBeDefined();
expect(clear).toBeDisabled();
// A disabled control cannot receive focus, so it is not tabbable.
clear!.focus();
expect(clear).not.toHaveFocus();
// Neither keyboard activation nor a direct click may mutate the selection.
fireEvent.keyDown(clear!, { key: "Enter" });
fireEvent.keyDown(clear!, { key: " " });
fireEvent.click(clear!);
expect(onUpdate).not.toHaveBeenCalled();
});
});