diff --git a/src/components/ui/__tests__/provider-group-tag-input.test.tsx b/src/components/ui/__tests__/provider-group-tag-input.test.tsx index a2a73a2e7..d5de942a5 100644 --- a/src/components/ui/__tests__/provider-group-tag-input.test.tsx +++ b/src/components/ui/__tests__/provider-group-tag-input.test.tsx @@ -4,13 +4,15 @@ import type { ReactNode } from "react"; import { act } from "react"; -import { createRoot } from "react-dom/client"; +import { type Root, createRoot } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { ProviderGroupSelect } from "@/app/[locale]/dashboard/_components/user/forms/provider-group-select"; import { TagInput } from "@/components/ui/tag-input"; -const providerActionsMocks = vi.hoisted(() => ({ - getProviderGroupsWithCount: vi.fn(async () => ({ ok: true, data: [] })), +// 该组件从 v1 api-client 导入 getProviderGroupsWithCount,必须 mock 这个路径, +// 否则测试会命中真实的 REST 客户端(历史上 mock 错路径掩盖了真实数据流)。 +const providerApiMocks = vi.hoisted(() => ({ + getProviderGroupsWithCount: vi.fn(async () => ({ ok: true, data: [] as unknown[] })), })); const sonnerMocks = vi.hoisted(() => ({ @@ -19,27 +21,47 @@ const sonnerMocks = vi.hoisted(() => ({ }, })); -vi.mock("@/actions/providers", () => providerActionsMocks); +vi.mock("@/lib/api-client/v1/actions/providers", () => providerApiMocks); vi.mock("sonner", () => sonnerMocks); +// 追踪所有挂载的 React root,确保即使某个用例断言失败提前抛出,afterEach 也能卸载, +// 避免残留已挂载的 root 污染后续用例的 document.activeElement / DOM。 +const mountedRoots: Array<{ container: HTMLElement; root: Root; unmounted: boolean }> = []; + function render(node: ReactNode) { const container = document.createElement("div"); document.body.appendChild(container); const root = createRoot(container); - act(() => { root.render(node); }); - + const entry = { container, root, unmounted: false }; + mountedRoots.push(entry); return { container, unmount: () => { + if (entry.unmounted) return; + entry.unmounted = true; act(() => root.unmount()); container.remove(); }, }; } +async function flush() { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +function createDeferred() { + let resolve!: (value: T) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + async function typeAndSubmit(input: HTMLInputElement, value: string) { await act(async () => { input.focus(); @@ -56,7 +78,37 @@ async function typeAndSubmit(input: HTMLInputElement, value: string) { }); } +/** 下拉建议项通过 Portal 渲染(无 Dialog 祖先时落到 document.body),按分组名匹配建议按钮。 */ +function suggestionButtonsFor(group: string) { + return Array.from(document.querySelectorAll("button")).filter((btn) => + (btn.textContent || "").includes(group) + ); +} + +const PROVIDER_GROUP_TRANSLATIONS = { + label: "Provider group", + placeholder: "Enter group", + description: "desc", + providersSuffix: "providers", + errors: { + loadFailed: "Load failed", + }, + tagInputErrors: { + empty: "empty", + duplicate: "duplicate", + too_long: "too long", + invalid_format: "invalid format", + max_tags: "max tags", + }, +}; + afterEach(() => { + for (const entry of mountedRoots.splice(0)) { + if (entry.unmounted) continue; + entry.unmounted = true; + act(() => entry.root.unmount()); + entry.container.remove(); + } while (document.body.firstChild) { document.body.removeChild(document.body.firstChild); } @@ -64,6 +116,7 @@ afterEach(() => { beforeEach(() => { vi.clearAllMocks(); + providerApiMocks.getProviderGroupsWithCount.mockResolvedValue({ ok: true, data: [] }); }); describe("provider-group tag inputs", () => { @@ -87,23 +140,12 @@ describe("provider-group tag inputs", () => { test("ProviderGroupSelect 应允许输入中文分组", async () => { const onChange = vi.fn(); - const translations = { - label: "Provider group", - placeholder: "Enter group", - description: "desc", - errors: { - loadFailed: "Load failed", - }, - tagInputErrors: { - empty: "empty", - duplicate: "duplicate", - too_long: "too long", - invalid_format: "invalid format", - max_tags: "max tags", - }, - }; const { container, unmount } = render( - + ); const input = container.querySelector("input"); @@ -116,4 +158,110 @@ describe("provider-group tag inputs", () => { unmount(); }); + + // 数据流回归:覆盖 mock 路径、ActionResult 解包与建议渲染链路(经 focus -> handleFocus 展开)。 + test("数据加载后点击输入框应展开下拉并列出已有的供应商分组", async () => { + providerApiMocks.getProviderGroupsWithCount.mockResolvedValue({ + ok: true, + data: [ + { group: "team-alpha", providerCount: 3 }, + { group: "team-beta", providerCount: 1 }, + ], + }); + const onChange = vi.fn(); + const { container, unmount } = render( + + ); + + // 等待异步加载完成 + await flush(); + + const input = container.querySelector("input") as HTMLInputElement; + await act(async () => { + // 点击容器会聚焦输入框,进而触发 onFocus -> handleFocus 展开下拉 + input.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(suggestionButtonsFor("team-alpha").length).toBeGreaterThan(0); + expect(suggestionButtonsFor("team-beta").length).toBeGreaterThan(0); + + unmount(); + }); + + // #1212 核心回归:建议数据在用户聚焦「之后」才异步返回时,下拉应自动展开。 + // 这是 tag-input.tsx 中「首次加载自动展开」effect 的专属守护用例(移除该 effect 则失败)。 + test("回归 #1212:建议数据在聚焦之后异步返回时下拉应自动展开", async () => { + const deferred = createDeferred<{ + ok: true; + data: Array<{ group: string; providerCount: number }>; + }>(); + providerApiMocks.getProviderGroupsWithCount.mockReturnValue(deferred.promise); + + const onChange = vi.fn(); + const { container, unmount } = render( + + ); + + const input = container.querySelector("input") as HTMLInputElement; + + // 用户在数据返回前就聚焦了输入框:此时建议为空,下拉不应展开 + await act(async () => { + // happy-dom 下 input.focus() 即可触发 React 的 onFocus 并设置 document.activeElement + input.focus(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + expect(suggestionButtonsFor("team-alpha").length).toBe(0); + + // 数据异步返回,输入框仍处于聚焦状态:下拉应自动展开 + await act(async () => { + deferred.resolve({ ok: true, data: [{ group: "team-alpha", providerCount: 2 }] }); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(suggestionButtonsFor("team-alpha").length).toBeGreaterThan(0); + + unmount(); + }); + + // #1212 实际场景:字段位于创建用户的 Dialog 内,下拉通过 Portal 渲染进 dialog-content。 + test("在 Dialog 中点击输入框时下拉应渲染到 dialog-content 容器内", async () => { + providerApiMocks.getProviderGroupsWithCount.mockResolvedValue({ + ok: true, + data: [{ group: "team-alpha", providerCount: 5 }], + }); + const onChange = vi.fn(); + const { container, unmount } = render( +
+ +
+ ); + await flush(); + + const input = container.querySelector("input") as HTMLInputElement; + await act(async () => { + input.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + const dialogContent = container.querySelector('[data-slot="dialog-content"]') as HTMLElement; + const buttonsInDialog = Array.from(dialogContent.querySelectorAll("button")).filter((btn) => + (btn.textContent || "").includes("team-alpha") + ); + expect(buttonsInDialog.length).toBeGreaterThan(0); + + unmount(); + }); }); diff --git a/src/components/ui/tag-input.tsx b/src/components/ui/tag-input.tsx index f5c656a77..b013cf4f4 100644 --- a/src/components/ui/tag-input.tsx +++ b/src/components/ui/tag-input.tsx @@ -175,6 +175,18 @@ export function TagInput({ return () => document.removeEventListener("mousedown", handleClickOutside, true); }, [showSuggestions]); + // 建议列表「首次」异步加载完成时自动展开下拉。建议数据经网络请求获取时, + // 用户可能在数据返回前就已聚焦输入框;此时 focus 事件不会再次触发,下拉无法展开。 + // 只处理首次由空变为非空,避免后续刷新(清空再填充)覆盖用户已手动关闭(Escape/点击外部)的下拉。 + const didAutoOpenRef = React.useRef(false); + React.useEffect(() => { + if (didAutoOpenRef.current || suggestions.length === 0) return; + didAutoOpenRef.current = true; + if (!disabled && inputRef.current === document.activeElement) { + setShowSuggestions(true); + } + }, [disabled, suggestions.length]); + const inputMinWidthClass = normalizedMaxVisible === undefined ? "min-w-[120px]" : "min-w-[60px]"; // Normalize suggestions so callers can provide either strings or { value, label } objects.