From be231ffd7f0441a1eeba57664ef306240996060c Mon Sep 17 00:00:00 2001
From: ding113 <h.ding.262@gmail.com>
Date: Tue, 2 Jun 2026 18:20:04 +0800
Subject: [PATCH] fix(ui): auto-open TagInput suggestions when async data
 arrives while focused

When TagInput receives suggestions asynchronously (e.g. ProviderGroupSelect
fetching provider groups over HTTP), a user who focuses the field before the
data arrives never saw the dropdown: focus fired while suggestions were empty,
and an already-focused input never re-fires focus. This made it impossible to
select existing provider groups in the create-user dialog.

Add a fire-once, disabled-guarded effect that opens the dropdown the first time
suggestions become non-empty while the input is focused. Firing only on the
initial load (and never on later refreshes) avoids re-opening a dropdown the
user has dismissed via Escape / click-outside when a consumer repopulates its
suggestions.

Also fix the test mock to target the module the component actually imports
(@/lib/api-client/v1/actions/providers) and add regression tests, including the
dialog/portal path.

Fixes #1212

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 .../provider-group-tag-input.test.tsx         | 192 ++++++++++++++++--
 src/components/ui/tag-input.tsx               |  12 ++
 2 files changed, 182 insertions(+), 22 deletions(-)

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<T>() {
+  let resolve!: (value: T) => void;
+  const promise = new Promise<T>((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(
-      <ProviderGroupSelect value="" onChange={onChange} translations={translations} />
+      <ProviderGroupSelect
+        value=""
+        onChange={onChange}
+        translations={PROVIDER_GROUP_TRANSLATIONS}
+      />
     );
 
     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(
+      <ProviderGroupSelect
+        value=""
+        onChange={onChange}
+        translations={PROVIDER_GROUP_TRANSLATIONS}
+      />
+    );
+
+    // 等待异步加载完成
+    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(
+      <ProviderGroupSelect
+        value=""
+        onChange={onChange}
+        translations={PROVIDER_GROUP_TRANSLATIONS}
+      />
+    );
+
+    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(
+      <div data-slot="dialog-content">
+        <ProviderGroupSelect
+          value=""
+          onChange={onChange}
+          translations={PROVIDER_GROUP_TRANSLATIONS}
+        />
+      </div>
+    );
+    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.
