diff --git a/packages/views/issues/components/pickers/property-picker.tsx b/packages/views/issues/components/pickers/property-picker.tsx index c3772e887f..9cdf0efc1d 100644 --- a/packages/views/issues/components/pickers/property-picker.tsx +++ b/packages/views/issues/components/pickers/property-picker.tsx @@ -104,17 +104,20 @@ export function PropertyPicker({ } }, [highlightedIndex, getItems, children]); // re-run when children change (filtered list updates) - const handleOpenChange = useCallback( - (v: boolean) => { - onOpenChange(v); - if (!v) { - setQuery(""); - setHighlightedIndex(-1); - onSearchChange?.(""); - } - }, - [onOpenChange, onSearchChange], - ); + // Reset the search state on the open -> closed transition rather than inside + // an open-change handler. Every picker closes itself after a selection by + // calling its own `setOpen(false)`, which flips this `open` prop directly and + // never routes through the popover's `onOpenChange` — so a handler-only reset + // left the stale query (and the filtered list) in place on the next open. + const wasOpen = useRef(open); + useEffect(() => { + if (wasOpen.current && !open) { + setQuery(""); + setHighlightedIndex(-1); + onSearchChange?.(""); + } + wasOpen.current = open; + }, [open, onSearchChange]); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { @@ -161,7 +164,7 @@ export function PropertyPicker({ ); return ( - + {tooltip ? ( diff --git a/packages/views/locales/en/projects.json b/packages/views/locales/en/projects.json index 595bfb3f0b..8bd897a52f 100644 --- a/packages/views/locales/en/projects.json +++ b/packages/views/locales/en/projects.json @@ -127,7 +127,8 @@ "picker": { "no_project": "No project", "remove": "Remove from project", - "empty": "No projects yet" + "empty": "No projects yet", + "search_placeholder": "Search projects..." }, "chip": { "fallback_label": "Project" diff --git a/packages/views/locales/ja/projects.json b/packages/views/locales/ja/projects.json index 1ffdff8559..745d6cebc8 100644 --- a/packages/views/locales/ja/projects.json +++ b/packages/views/locales/ja/projects.json @@ -126,7 +126,8 @@ "picker": { "no_project": "プロジェクトなし", "remove": "プロジェクトから削除", - "empty": "プロジェクトはまだありません" + "empty": "プロジェクトはまだありません", + "search_placeholder": "プロジェクトを検索..." }, "chip": { "fallback_label": "プロジェクト" diff --git a/packages/views/locales/ko/projects.json b/packages/views/locales/ko/projects.json index ccbc96a7db..3e5df5cf48 100644 --- a/packages/views/locales/ko/projects.json +++ b/packages/views/locales/ko/projects.json @@ -126,7 +126,8 @@ "picker": { "no_project": "프로젝트 없음", "remove": "프로젝트에서 제거", - "empty": "아직 프로젝트가 없습니다" + "empty": "아직 프로젝트가 없습니다", + "search_placeholder": "프로젝트 검색..." }, "chip": { "fallback_label": "프로젝트" diff --git a/packages/views/locales/zh-Hans/projects.json b/packages/views/locales/zh-Hans/projects.json index f8c6e1108e..23d3915898 100644 --- a/packages/views/locales/zh-Hans/projects.json +++ b/packages/views/locales/zh-Hans/projects.json @@ -126,7 +126,8 @@ "picker": { "no_project": "无项目", "remove": "从项目移除", - "empty": "还没有项目" + "empty": "还没有项目", + "search_placeholder": "搜索项目..." }, "chip": { "fallback_label": "项目" diff --git a/packages/views/projects/components/project-picker.open-state.test.tsx b/packages/views/projects/components/project-picker.open-state.test.tsx index 331b7ded59..819e15cd3f 100644 --- a/packages/views/projects/components/project-picker.open-state.test.tsx +++ b/packages/views/projects/components/project-picker.open-state.test.tsx @@ -1,15 +1,18 @@ -// 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. +// 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"; @@ -18,6 +21,7 @@ vi.mock("@tanstack/react-query", () => ({ data: [ { id: "project-1", title: "Launch Command Center", icon: null }, { id: "project-2", title: "Mobile Web", icon: null }, + { id: "project-3", title: "数据透明化", icon: null }, ], }), })); @@ -34,27 +38,42 @@ vi.mock("./project-icon", () => ({ ProjectIcon: () => , })); +function withI18n(children: React.ReactNode) { + return ( + + {children} + + ); +} + /** 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)} - /> - + return withI18n( + { + onUpdate(u); + setProjectId((u as { project_id?: string | null }).project_id ?? null); + }} + triggerRender={} + 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(); @@ -62,16 +81,14 @@ describe("ProjectPicker open state under create-dialog wiring", () => { render(); - // Open the picker via its trigger. + // 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("menuitem", { name: /mobile web/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 waitFor(() => { - expect(screen.queryByRole("menuitem", { name: /mobile web/i })).not.toBeInTheDocument(); - }); + await expectClosed(); }); it("can be reopened and closed again after a selection", async () => { @@ -80,16 +97,76 @@ describe("ProjectPicker open state under create-dialog wiring", () => { 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(); - }); + 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("menuitem", { name: /mobile web/i })); - await waitFor(() => { - expect(screen.queryByRole("menuitem", { name: /mobile web/i })).not.toBeInTheDocument(); - }); + 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(} />)); + + 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(} />)); + + 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(} />)); + + 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(); + + 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(); }); }); diff --git a/packages/views/projects/components/project-picker.test.tsx b/packages/views/projects/components/project-picker.test.tsx index aefd3d5879..f386077871 100644 --- a/packages/views/projects/components/project-picker.test.tsx +++ b/packages/views/projects/components/project-picker.test.tsx @@ -1,9 +1,9 @@ -import { cloneElement, isValidElement, type ReactElement, type ReactNode } from "react"; 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"; @@ -25,37 +25,36 @@ vi.mock("./project-icon", () => ({ ProjectIcon: () => , })); -vi.mock("@multica/ui/components/ui/dropdown-menu", () => ({ - DropdownMenu: ({ children }: { children: ReactNode }) => <>{children}, - DropdownMenuTrigger: ({ render: trigger, children }: { render?: ReactElement; children: ReactNode }) => - isValidElement(trigger) - ? cloneElement(trigger, {}, children) - : , - DropdownMenuContent: ({ children }: { children: ReactNode }) => <>{children}, - DropdownMenuItem: ({ children, onClick }: { children: ReactNode; onClick?: () => void }) => ( - - ), - DropdownMenuSeparator: () => null, -})); +// 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> = {}) { + return render( + + } + {...props} + /> + , + ); +} + +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(); - render( - - } - /> - , - ); + renderPicker({ onUpdate }); - const clear = screen - .getAllByRole("button", { name: "Remove from project" }) - .find((button) => button.className.includes("group-hover/project:opacity-100")); + const clear = findInlineClear(); expect(clear).toBeDefined(); expect(clear!.className).toContain("group-hover/project:opacity-100"); expect(clear!.className).toContain("size-3.5"); @@ -74,19 +73,9 @@ describe("ProjectPicker", () => { const user = userEvent.setup(); const onUpdate = vi.fn(); - render( - - } - /> - , - ); + renderPicker({ onUpdate }); - const clear = screen - .getAllByRole("button", { name: "Remove from project" }) - .find((button) => button.className.includes("group-hover/project:opacity-100")); + const clear = findInlineClear(); expect(clear).toBeDefined(); expect(clear).not.toBeDisabled(); @@ -105,20 +94,9 @@ describe("ProjectPicker", () => { // both pointer and keyboard activation inert. const onUpdate = vi.fn(); - render( - - } - /> - , - ); + renderPicker({ onUpdate, disabled: true }); - const clear = screen - .getAllByRole("button", { name: "Remove from project" }) - .find((button) => button.className.includes("group-hover/project:opacity-100")); + const clear = findInlineClear(); expect(clear).toBeDefined(); expect(clear).toBeDisabled(); diff --git a/packages/views/projects/components/project-picker.tsx b/packages/views/projects/components/project-picker.tsx index 253874f848..d091307026 100644 --- a/packages/views/projects/components/project-picker.tsx +++ b/packages/views/projects/components/project-picker.tsx @@ -1,20 +1,20 @@ "use client"; import { useState } from "react"; -import { Check, FolderKanban, X } from "lucide-react"; +import { FolderKanban, X } from "lucide-react"; import { useQuery } from "@tanstack/react-query"; import { projectListOptions } from "@multica/core/projects/queries"; import { useWorkspaceId } from "@multica/core/hooks"; import type { UpdateIssueRequest } from "@multica/core/types"; import { cn } from "@multica/ui/lib/utils"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, - DropdownMenuSeparator, -} from "@multica/ui/components/ui/dropdown-menu"; import { ProjectIcon } from "./project-icon"; +import { + PropertyPicker, + PickerItem, + PickerEmpty, + PICKER_TRIGGER_CLASS, +} from "../../issues/components/pickers/property-picker"; +import { matchesPinyin } from "../../editor/extensions/pinyin-match"; import { useT } from "../../i18n"; export function ProjectPicker({ @@ -48,73 +48,110 @@ export function ProjectPicker({ const wsId = useWorkspaceId(); const { data: projects = [] } = useQuery(projectListOptions(wsId)); const current = projects.find((p) => p.id === projectId); + const [filter, setFilter] = useState(""); // 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. + // pickers (status/priority/assignee/labels). Base UI latches a controlled + // `open={true}` — a later `undefined` does NOT close it — so callers wiring + // `open={cond ? true : undefined}` (create-issue dialog) would otherwise + // leave the popup stuck open after selecting a project. const [internalOpen, setInternalOpen] = useState(defaultOpen); // A disabled picker can never be open, and no interaction may reopen it. const open = disabled ? false : controlledOpen ?? internalOpen; const setOpen = disabled ? () => {} : onOpenChange ?? setInternalOpen; + // Client-side filter: substring match plus pinyin so Chinese project names + // are reachable by latin input (e.g. "sjtmh" → "数据透明化"). + const query = filter.trim().toLowerCase(); + const filtered = projects.filter( + (p) => p.title.toLowerCase().includes(query) || matchesPinyin(p.title, query), + ); + + // Default trigger built as a `triggerRender` so it can reserve right padding + // for the inline clear button. Callers that bring their own trigger (chat + // pill, autopilot card, table cell) take over the trigger entirely. + const resolvedTriggerRender = triggerRender ?? ( + + + {t(($) => $.picker.no_project)} + )} - - - {projects.map((p) => ( - onUpdate({ project_id: p.id })}> - + + {filtered.map((p) => ( + { + onUpdate({ project_id: p.id }); + setOpen(false); + }} + > + {p.title} - {p.id === projectId && } - + ))} - {projects.length > 0 && projectId && } - {projectId && ( - onUpdate({ project_id: null })}> - - {t(($) => $.picker.remove)} - - )} + {projects.length === 0 && (
{t(($) => $.picker.empty)}
)} -
- + {projects.length > 0 && filtered.length === 0 && query && } + + + {current && ( + + )} + ); }