Skip to content

fix(ui): 修复创建用户对话框中供应商分组下拉无法点击展开 (#1212)#1237

Merged
ding113 merged 1 commit into
devfrom
fix/issue-1212-provider-group-dropdown
Jun 2, 2026
Merged

fix(ui): 修复创建用户对话框中供应商分组下拉无法点击展开 (#1212)#1237
ding113 merged 1 commit into
devfrom
fix/issue-1212-provider-group-dropdown

Conversation

@ding113

@ding113 ding113 commented Jun 2, 2026

Copy link
Copy Markdown
Owner

问题 (Fixes #1212)

在「创建用户」对话框中,「供应商分组」多选框只显示默认的 default点击无法展开下拉菜单去选择已有的供应商分组。

注:原 issue 还包含「获取供应商分组统计失败 / Load failed toast」的数据获取报错,该部分已由 #12355bb7ad51,把 getProviderGroupsWithCount 包进 toActionResult)单独修复。本 PR 只修复剩下的下拉无法点击展开问题。

根因

ProviderGroupSelect(创建用户对话框里经 KeyEditSection 使用)通过一次 异步 HTTP GET 加载建议列表,再传给 TagInput。而 TagInput 只在以下时机展开建议下拉:

  • 输入框 focus 时(handleFocus
  • 键入时(handleInputChange

如果用户在数据返回之前就点击/聚焦了该字段(真实部署有网络延迟时很常见),handleFocus 看到 suggestions.length === 0,什么都不做;而此后输入框已处于聚焦态,再次点击不会重新触发 focus,于是下拉永远打不开 —— 用户的体感就是「点了没反应」(只有键入才会出现下拉)。

修复(src/components/ui/tag-input.tsx

  1. 点击即展开:容器 onClick 在聚焦输入框的同时,若 suggestions.length > 0 则主动 setShowSuggestions(true),使得对已聚焦输入框的再次点击也能重新打开下拉。
  2. 异步到达自动展开:新增一个 effect,当建议从「空」变为「非空」且输入框仍是 document.activeElement 时自动展开下拉(用 hadSuggestionsRef 仅在 0 → N 跳变时触发),覆盖「先聚焦、数据后到」的竞态。

改动为 TagInput 通用行为,所有传入 suggestions 的调用方(约 11 处)均受益,且符合标准 combobox 交互(点击即展开),不影响既有用例。

测试(provider-group-tag-input.test.tsx

  • 修正 mock 路径:组件实际从 @/lib/api-client/v1/actions/providers 导入,旧测试却 mock 了 @/actions/providers(mock 未生效,掩盖了真实数据流)。
  • 新增回归用例:

经多智能体对抗式复核确认:两个显式命名的回归用例分别守护两处修复(移除任一修复 hunk 都会让对应用例失败)。

本地验证(全绿)

  • bun run build
  • bun run lint ✓(biome,0 错误)
  • bun run typecheck ✓(tsgo,0 错误)
  • bun run test ✓(6175 passed / 13 skipped)

🤖 Generated with Claude Code

Greptile Summary

This PR fixes the #1212 bug where the "Provider Group" multi-select in the Create User dialog could not be expanded. The root cause was a race condition: when the user focused the input before the async suggestions loaded, handleFocus saw an empty list and did nothing; once focused, no subsequent focus event fired to retry.

  • Core fix (tag-input.tsx): A one-shot useEffect guarded by didAutoOpenRef auto-opens the suggestions dropdown when the list transitions from empty to non-empty while the input is the active element, covering the focus-before-data race.
  • Test improvements (provider-group-tag-input.test.tsx): Corrects the mock import path from @/actions/providers to the actual @/lib/api-client/v1/actions/providers, adds root-tracking cleanup for reliable test isolation, and introduces three regression tests guarding the async-load path, the deferred-focus race, and portal rendering inside a Dialog.

Confidence Score: 5/5

Safe to merge; the core async-race fix is correct and well-tested, with one minor UX gap left from a described-but-absent onClick enhancement.

The useEffect logic is sound: it fires exactly once per mount (guarded by didAutoOpenRef), correctly checks activeElement, and handleFocus independently covers the non-race path. The only gap is that the PR description promises an onClick enhancement for re-clicking an already-focused input after manual dismissal, but that code is absent from the diff — leaving a secondary UX edge case unaddressed rather than any data-corrupting or security issue.

src/components/ui/tag-input.tsx — verify whether the onClick enhancement (setShowSuggestions when suggestions.length > 0) was intentionally deferred or accidentally omitted.

Important Files Changed

Filename Overview
src/components/ui/tag-input.tsx Adds a one-shot useEffect with didAutoOpenRef to auto-open the suggestions dropdown when async data arrives while the input is focused; the onClick enhancement described in the PR description as fix #1 is not present in the diff.
src/components/ui/tests/provider-group-tag-input.test.tsx Fixes the mock import path from @/actions/providers to the correct @/lib/api-client/v1/actions/providers, adds root-tracking cleanup for test isolation, and adds three regression tests covering the async-load path, the deferred-focus race, and portal rendering inside a Dialog.

Sequence Diagram

sequenceDiagram
    participant U as User
    participant Input as TagInput (focused)
    participant Effect as useEffect (didAutoOpenRef)
    participant API as getProviderGroupsWithCount

    U->>Input: "click/focus (suggestions=[])"
    Input->>Input: "handleFocus → suggestions.length===0 → no-op"
    API-->>Input: "suggestions arrive (length > 0)"
    Input->>Effect: suggestions.length changed
    Effect->>Effect: "didAutoOpenRef.current===false → set true"
    Effect->>Effect: "inputRef === document.activeElement?"
    alt input still focused
        Effect->>Input: setShowSuggestions(true) ✓
    else input not focused
        Effect->>Effect: skip (handleFocus covers next focus)
    end
    Note over U,Input: After auto-open, user dismisses (Escape)
    U->>Input: re-click (input already focused)
    Input->>Input: onClick → focus() no focus event fires
    Note over Input: didAutoOpenRef=true → effect skips, handleFocus not called → dropdown stays closed
Loading
Prompt To Fix All With AI
Fix the following 1 code review issue. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 1
src/components/ui/tag-input.tsx:438
**Missing onClick enhancement described in the PR**

The PR description explicitly lists fix #1 as: "容器 `onClick` 在聚焦输入框的同时,若 `suggestions.length > 0` 则主动 `setShowSuggestions(true)`". That change is absent from this diff — `onClick` still only calls `inputRef.current?.focus()`.

As a result, once the useEffect auto-opens the dropdown on async data arrival (fix #2), if the user then dismisses it via Escape or an outside click and re-clicks the already-focused input, no focus event fires, the useEffect's `didAutoOpenRef` guard is `true`, and the dropdown never reopens. The primary async-race scenario from #1212 is fixed, but this re-click path remains unaddressed.

Reviews (2): Last reviewed commit: "fix(ui): auto-open TagInput suggestions ..." | Re-trigger Greptile

@coderabbitai

coderabbitai Bot commented Jun 2, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

TagInput组件新增异步建议加载处理逻辑:suggestions从空变为非空时若输入框仍聚焦则自动展开下拉。容器点击事件增强为始终focus输入框并在有建议时强制打开下拉。测试文件完整更新mock路径、新增异步驱动工具函数、添加Portal渲染位置查询器,以多个回归用例覆盖异步时序与交互场景。

Changes

异步建议加载与下拉交互修复

Layer / File(s) Summary
TagInput组件异步加载与点击交互
src/components/ui/tag-input.tsx
新增useEffect监听suggestions状态变化,当建议数据从空异步加载为非空且输入框保持焦点时自动设置为展开下拉;通过 didAutoOpenRef 避免后续刷新覆盖用户手动关闭下拉的状态。
测试渲染与 mock 初始化
src/components/ui/__tests__/provider-group-tag-input.test.tsx
将 getProviderGroupsWithCount 的 mock 路径迁移至 @/lib/api-client/v1/actions/providers,重构 render 挂载以追踪 mountedRoots,并在 beforeEach 为 mock 设置默认 mockResolvedValue({ ok: true, data: [] });afterEach 统一卸载并清空 document.body。
测试工具函数与常量
src/components/ui/__tests__/provider-group-tag-input.test.tsx
新增 flush() 等待下一轮事件循环与 createDeferred() 控制 promise 时机;新增 suggestionButtonsFor(group) 用于在 Portal 场景中查找建议按钮;引入 PROVIDER_GROUP_TRANSLATIONS 并替换相关用例的内联 translations。
回归测试用例
src/components/ui/__tests__/provider-group-tag-input.test.tsx
新增与扩展多组回归测试:数据加载完成后点击输入应展开并列出分组;#1212 场景(先聚焦后返回数据)应在数据返回后自动展开;验证 dialog 场景下的 Portal 渲染目标为 data-slot="dialog-content" 容器内。

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed PR 标题清晰准确,直接指明问题和 issue 编号 #1212,明确描述了核心修复内容。
Linked Issues check ✅ Passed PR 完全满足 #1212 的编码需求:修复了聚焦前异步加载、重复点击等竞态条件,使下拉菜单可靠展开。
Out of Scope Changes check ✅ Passed 所有变更均围绕下拉展开逻辑和测试补正,未涉及无关功能改动,范围严格聚焦。
Description check ✅ Passed 拉取请求描述与变更集高度相关,详细说明了问题根因、修复方案和测试验证。

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/issue-1212-provider-group-dropdown

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions Bot added bug Something isn't working area:UI area:UX labels Jun 2, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request addresses issue #1212 in the TagInput component by ensuring that the suggestions dropdown automatically expands when suggestions are loaded asynchronously while the input is already focused. It also ensures that clicking on an already-focused input re-opens the dropdown. Additionally, the mock path for provider actions was corrected in the tests, and several regression tests were added to verify these behaviors under different scenarios, including inside a Dialog. There are no review comments to address, so no feedback is provided.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread src/components/ui/tag-input.tsx Outdated
Comment on lines +181 to +189
const hadSuggestionsRef = React.useRef(suggestions.length > 0);
React.useEffect(() => {
const hasSuggestions = suggestions.length > 0;
const wasEmpty = !hadSuggestionsRef.current;
hadSuggestionsRef.current = hasSuggestions;
if (hasSuggestions && wasEmpty && inputRef.current === document.activeElement) {
setShowSuggestions(true);
}
}, [suggestions.length]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 When a caller intentionally closes the dropdown via Escape, then later another update to the same suggestions array causes the length to cycle through 0 and back to non-zero (e.g. a background refresh clears then repopulates the list), the effect will auto-reopen the dropdown while the input is still focused — overriding the user's explicit dismiss. Adding a guard on showSuggestions prevents this surprise for any of the ~11 callers that pass dynamic suggestions.

Suggested change
const hadSuggestionsRef = React.useRef(suggestions.length > 0);
React.useEffect(() => {
const hasSuggestions = suggestions.length > 0;
const wasEmpty = !hadSuggestionsRef.current;
hadSuggestionsRef.current = hasSuggestions;
if (hasSuggestions && wasEmpty && inputRef.current === document.activeElement) {
setShowSuggestions(true);
}
}, [suggestions.length]);
const hadSuggestionsRef = React.useRef(suggestions.length > 0);
React.useEffect(() => {
const hasSuggestions = suggestions.length > 0;
const wasEmpty = !hadSuggestionsRef.current;
hadSuggestionsRef.current = hasSuggestions;
if (hasSuggestions && wasEmpty && !showSuggestions && inputRef.current === document.activeElement) {
setShowSuggestions(true);
}
}, [suggestions.length, showSuggestions]);
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/components/ui/tag-input.tsx
Line: 181-189

Comment:
When a caller intentionally closes the dropdown via Escape, then later another update to the same `suggestions` array causes the length to cycle through 0 and back to non-zero (e.g. a background refresh clears then repopulates the list), the effect will auto-reopen the dropdown while the input is still focused — overriding the user's explicit dismiss. Adding a guard on `showSuggestions` prevents this surprise for any of the ~11 callers that pass dynamic suggestions.

```suggestion
  const hadSuggestionsRef = React.useRef(suggestions.length > 0);
  React.useEffect(() => {
    const hasSuggestions = suggestions.length > 0;
    const wasEmpty = !hadSuggestionsRef.current;
    hadSuggestionsRef.current = hasSuggestions;
    if (hasSuggestions && wasEmpty && !showSuggestions && inputRef.current === document.activeElement) {
      setShowSuggestions(true);
    }
  }, [suggestions.length, showSuggestions]);
```

How can I resolve this? If you propose a fix, please make it concise.

@github-actions

github-actions Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

🧪 测试结果

测试类型 状态
代码质量
单元测试
集成测试
API 测试

总体结果: ✅ 所有测试通过

@github-actions github-actions Bot added the size/M Medium PR (< 500 lines) label Jun 2, 2026

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review Summary

No significant issues identified in this PR. The two targeted fixes in TagInput correctly address the race condition between async suggestion loading and user focus, and the click-to-reopen interaction is sound. The regression tests are well-scoped and each directly guards its corresponding fix hunk.

PR Size: M

  • Lines changed: 236
  • Files changed: 2

Review Coverage

  • Logic and correctness - Clean
  • Security (OWASP Top 10) - Clean
  • Error handling - Clean
  • Type safety - Clean
  • Documentation accuracy - Clean
  • Test coverage - Adequate
  • Code clarity - Good

Notes

Mock path fix (test file): The corrected mock path from @/actions/providers to @/lib/api-client/v1/actions/providers is an important fix. The old mock was silently no-op, meaning the test was exercising the real REST client rather than the mock. This was a latent test defect that the PR correctly resolves.

hadSuggestionsRef effect (tag-input.tsx): The useEffect that auto-opens on the first 0->N suggestions transition is correctly bounded. The useRef persists across renders, so the effect only fires once per component lifetime. Concerns about the effect re-firing after an explicit Escape dismissal are not applicable — once hadSuggestionsRef.current is set to true, subsequent length transitions do not trigger the effect.

onClick handler (tag-input.tsx): Adding setShowSuggestions(true) to the container click handler correctly enables re-opening the dropdown on re-click of an already-focused input, without affecting the handleFocus behavior for first-focus interactions.


Automated review by Claude AI

… 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>
@ding113
ding113 force-pushed the fix/issue-1212-provider-group-dropdown branch from 33bb34d to be231ff Compare June 2, 2026 11:03

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: be231ffd7f

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +182 to +186
React.useEffect(() => {
if (didAutoOpenRef.current || suggestions.length === 0) return;
didAutoOpenRef.current = true;
if (!disabled && inputRef.current === document.activeElement) {
setShowSuggestions(true);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reopen the focused input after dismissing suggestions

When async suggestions arrive while the input is focused, this one-shot effect opens the dropdown, but after the user dismisses it with Escape the input remains focused and a subsequent click still only refocuses the already-focused input, so handleFocus does not run and the list cannot be reopened without typing or blurring/refocusing. This leaves the same “click does nothing” behavior for the focused field after a normal dismiss; the click handler needs to explicitly show suggestions when suggestions are available.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
src/components/ui/__tests__/provider-group-tag-input.test.tsx (1)

259-263: 💤 Low value

可选:复用 suggestionButtonsFor 的查找逻辑以减少重复。

此处内联的按钮过滤与 suggestionButtonsFor(行 82-86)完全一致,仅查询根从 document 改为 dialogContent。可将该 helper 改为接受可选根节点,避免两处 textContent.includes 逻辑分叉。此外 includes 为子串匹配,若后续出现互为前缀的分组名(如 teamteam-alpha)断言可能误判,顺带可考虑精确匹配。

♻️ 建议:helper 接受可选根节点
-function suggestionButtonsFor(group: string) {
-  return Array.from(document.querySelectorAll("button")).filter((btn) =>
-    (btn.textContent || "").includes(group)
-  );
-}
+function suggestionButtonsFor(group: string, root: ParentNode = document) {
+  return Array.from(root.querySelectorAll("button")).filter((btn) =>
+    (btn.textContent || "").includes(group)
+  );
+}
-    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);
+    const dialogContent = container.querySelector('[data-slot="dialog-content"]') as HTMLElement;
+    expect(suggestionButtonsFor("team-alpha", dialogContent).length).toBeGreaterThan(0);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/ui/__tests__/provider-group-tag-input.test.tsx` around lines
259 - 263, Refactor the duplicated button-search logic by updating the existing
helper suggestionButtonsFor to accept an optional root HTMLElement (defaulting
to document) so the test can call suggestionButtonsFor("team-alpha",
dialogContent) instead of reimplementing the query; also change the matching
from substring includes(...) to an exact text match (e.g., compare trimmed
textContent === target) to avoid false positives when group names are prefixes
of one another. Ensure you update all call sites (the helper usage at lines
~82-86 and this test's inline query) to pass the optional root where needed.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/components/ui/__tests__/provider-group-tag-input.test.tsx`:
- Around line 259-263: Refactor the duplicated button-search logic by updating
the existing helper suggestionButtonsFor to accept an optional root HTMLElement
(defaulting to document) so the test can call suggestionButtonsFor("team-alpha",
dialogContent) instead of reimplementing the query; also change the matching
from substring includes(...) to an exact text match (e.g., compare trimmed
textContent === target) to avoid false positives when group names are prefixes
of one another. Ensure you update all call sites (the helper usage at lines
~82-86 and this test's inline query) to pass the optional root where needed.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 2b9ad746-ecc1-41c6-9b40-6bbc51549398

📥 Commits

Reviewing files that changed from the base of the PR and between 33bb34d and be231ff.

📒 Files selected for processing (2)
  • src/components/ui/__tests__/provider-group-tag-input.test.tsx
  • src/components/ui/tag-input.tsx

@github-actions

github-actions Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

🧪 测试结果

测试类型 状态
代码质量
单元测试
集成测试
API 测试

总体结果: ✅ 所有测试通过

@ding113
ding113 merged commit c5d02b9 into dev Jun 2, 2026
13 checks passed
@github-project-automation github-project-automation Bot moved this from Backlog to Done in Claude Code Hub Roadmap Jun 2, 2026
@ding113
ding113 deleted the fix/issue-1212-provider-group-dropdown branch June 2, 2026 12:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:UI area:UX bug Something isn't working size/M Medium PR (< 500 lines)

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

1 participant