Skip to content

Fix:Bug:API Key copy action lacks success feedback (Copied! text/icon/tooltip) after clicking <Copy API Key>#1564

Open
francmart514 wants to merge 4 commits intowe-promise:mainfrom
francmart514:fix/api-key-copy-feedback
Open

Fix:Bug:API Key copy action lacks success feedback (Copied! text/icon/tooltip) after clicking <Copy API Key>#1564
francmart514 wants to merge 4 commits intowe-promise:mainfrom
francmart514:fix/api-key-copy-feedback

Conversation

@francmart514
Copy link
Copy Markdown

@francmart514 francmart514 commented Apr 27, 2026

Description

This PR improves the API key copy-to-clipboard UX by adding immediate, temporary success feedback after clicking Copy API Key.
Previously, the key was copied correctly but the UI did not show a clear confirmation state.

Closes #1563

Changes made:
Updated clipboard_controller.js to support optional text targets (textDefault, textSuccess) and to toggle both icon and text success states.
Added reset handling (disconnect) and target-safe toggling so existing clipboard usages remain compatible.
Replaced API key copy buttons in:
app/views/settings/api_keys/show.html.erb
app/views/settings/api_keys/created.html.erb
app/views/settings/api_keys/created.turbo_stream.erb with markup that shows Copied! and a check icon briefly after successful copy.
Added system test coverage in test/system/settings/api_keys_test.rb for the temporary copied-feedback behavior.

Summary by CodeRabbit

  • New Features

    • Shared copy button component for API keys with localized labels and visible success text.
  • Bug Fixes

    • Success feedback now swaps both icon and text.
    • Feedback timeout is configurable (default 2.5s) and pending timers are cleared on teardown to prevent delayed UI updates.
  • Tests

    • Added a system test covering the copy-to-clipboard flow and success feedback.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 27, 2026

📝 Walkthrough

Walkthrough

The Stimulus clipboard controller was extended to support success text targets, a configurable success duration, robust reset timeout handling (cleared on new timers and on disconnect), and helper methods; the copy button markup was extracted to a shared partial; a system test verifies the temporary success feedback and locales for copy labels were added.

Changes

Cohort / File(s) Summary
Clipboard Controller
app/javascript/controllers/clipboard_controller.js
Adds textDefault/textSuccess targets, successDurationValue (Number, default 2500), stores/clears resetTimeout, adds _clearResetTimeout, _setSuccessState, _toggleIfTargetExists, and disconnect(); showSuccess() now toggles both icon and text success/default states.
API Key View Templates & Partial
app/views/settings/api_keys/_copy_button.html.erb, app/views/settings/api_keys/created.html.erb, app/views/settings/api_keys/created.turbo_stream.erb, app/views/settings/api_keys/show.html.erb
Extracts inline copy button into settings/api_keys/_copy_button.html.erb and updates views to render the partial; partial wires data-controller="clipboard", data-action="clipboard#copy", and includes source/icon/text default and success targets.
Locales
config/locales/views/settings/api_keys/en.yml
Adds i18n keys en.settings.api_keys.shared.copy_key and en.settings.api_keys.shared.copied.
System Test
test/system/settings/api_keys_test.rb
Adds end-to-end test stubbing navigator.clipboard.writeText, triggers copy button, and asserts temporary success UI (textSuccess with "Copied!" and iconSuccess) appears.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant Browser
    participant Stimulus as ClipboardController
    participant ClipboardAPI as navigator.clipboard
    participant DOM as View

    User->>Browser: click "Copy API Key" button
    Browser->>Stimulus: trigger clipboard#copy action
    Stimulus->>DOM: read `data-clipboard-target="source"` content
    Stimulus->>ClipboardAPI: writeText(key)
    ClipboardAPI-->>Stimulus: promise resolved
    Stimulus->>DOM: toggle iconDefault -> hidden
    Stimulus->>DOM: toggle iconSuccess -> shown
    Stimulus->>DOM: toggle textDefault -> hidden
    Stimulus->>DOM: toggle textSuccess -> shown
    Stimulus->>Stimulus: set resetTimeout(successDurationValue)
    Note over Stimulus,DOM: after timeout -> restore default icon/text (if still connected)
    Browser->>Stimulus: disconnect (navigation/unmount)
    Stimulus-->>Stimulus: clear resetTimeout
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Suggested reviewers

  • jjmata

Poem

🐰 I nibbled at a copy key with glee,
It blinked a check and whispered "Copied!" to me,
A hop, a wink, a tiny cheer,
Then back to burrow — short and clear. 🥕📋

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main change: adding success feedback to the API key copy action, which matches the core objective of the PR.
Linked Issues check ✅ Passed All coding requirements from issue #1563 are met: clipboard controller updated with text targets and state toggling, copy button partial created with success feedback UI, locale keys added, and system tests verify the temporary feedback behavior.
Out of Scope Changes check ✅ Passed All changes are directly related to implementing success feedback for the API key copy action; no unrelated modifications are present.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@brin-security-scanner brin-security-scanner Bot added the contributor:flagged Contributor flagged for review by trust analysis. label Apr 27, 2026
@brin-security-scanner
Copy link
Copy Markdown

brin-security-scanner Bot commented Apr 27, 2026

⚠️ Contributor Trust Check — Review Recommended

This contributor's profile shows patterns that may warrant additional review. This is based on their GitHub activity, not the contents of this PR.

francmart514 · Score: 77/100

Dimension breakdown
Dimension Score What it measures
Identity 45 Account age, contribution history, GPG keys, org memberships
Behavior 90 PR patterns, unsolicited contribution ratio, activity cadence
Content 100 PR body substance, issue linkage, contribution quality
Graph 30 Cross-repo trust, co-contributor relationships

Analyzed by Brin · Full profile

@brin-security-scanner brin-security-scanner Bot added the pr:verified PR passed security analysis. label Apr 27, 2026
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
app/javascript/controllers/clipboard_controller.js (1)

4-44: LGTM — clean, backward-compatible refactor.

The toggleTarget helper plus the has{Name}Target guard means existing call sites that only define iconDefault/iconSuccess (e.g. app/views/mfa/new.html.erb, app/views/invite_codes/_invite_code.html.erb, app/views/settings/profiles/show.html.erb) keep working without changes. Clearing resetTimeout in disconnect() mirrors the pattern in app/javascript/controllers/turbo_frame_timeout_controller.js. Target count (5) stays under the 7-target guideline.

One tiny optional nit: the 2000 ms timeout is a magic number — consider promoting it to a static or a data-*-value-driven duration so views/tests can override it.

♻️ Optional nit
 export default class extends Controller {
   static targets = ["source", "iconDefault", "iconSuccess", "textDefault", "textSuccess"];
+  static values = { resetMs: { type: Number, default: 2000 } };
@@
-    this.resetTimeout = setTimeout(() => {
+    this.resetTimeout = setTimeout(() => {
       this.toggleTarget("iconDefault", false);
       this.toggleTarget("iconSuccess", true);
       this.toggleTarget("textDefault", false);
       this.toggleTarget("textSuccess", true);
-    }, 2000);
+    }, this.resetMsValue);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/javascript/controllers/clipboard_controller.js` around lines 4 - 44, The
2000ms magic number in showSuccess should be configurable; add a Stimulus value
(e.g. static values = { duration: Number }) or a static property (e.g. static
DURATION = 2000) to clipboard_controller and replace the hard-coded 2000 with
this value (use this.durationValue || clipboard_controller.DURATION or similar),
and ensure disconnect still clears this.resetTimeout; update showSuccess to use
the configurable duration when calling setTimeout and keep the rest of the logic
unchanged so views/tests can override the duration via data-duration-value.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/views/settings/api_keys/created.turbo_stream.erb`:
- Around line 32-37: The duplicated copy-button markup (the button with
data-action="clipboard#copy" and data-clipboard-target spans) should be
extracted into a single partial named settings/api_keys/_copy_button.html.erb;
replace raw hover classes hover:bg-gray-100 theme-dark:hover:bg-gray-700 with
the design token hover:bg-surface-inset-hover and replace hardcoded strings
"Copy API Key" and "Copied!" with i18n lookups
t("settings.api_keys.copy_button.default") and
t("settings.api_keys.copy_button.success"); then remove the inline button blocks
from created.turbo_stream.erb, created.html.erb, and show.html.erb and render
the partial via <%= render "settings/api_keys/copy_button" %> so any future
changes (targets, icons, classes) are centralized.

In `@test/system/settings/api_keys_test.rb`:
- Around line 86-101: The success state can disappear before Capybara asserts
because the controller resets after 2000ms; update the test "should show
temporary copied feedback when copying api key" to avoid flakiness by wrapping
the two assertions for 'span[data-clipboard-target="textSuccess"]' and
'span[data-clipboard-target="iconSuccess"]' inside a transient longer wait
(e.g., using using_wait_time) or by modifying the component to accept a
data-attribute for the reset duration and set that longer only for tests; locate
the test in settings/api_keys_test.rb and either temporarily increase Capybara's
wait around the click/assert block or add a test-only data-reset-value to the
copy button/component and set it longer for this test, then restore default
behavior for production.

---

Nitpick comments:
In `@app/javascript/controllers/clipboard_controller.js`:
- Around line 4-44: The 2000ms magic number in showSuccess should be
configurable; add a Stimulus value (e.g. static values = { duration: Number })
or a static property (e.g. static DURATION = 2000) to clipboard_controller and
replace the hard-coded 2000 with this value (use this.durationValue ||
clipboard_controller.DURATION or similar), and ensure disconnect still clears
this.resetTimeout; update showSuccess to use the configurable duration when
calling setTimeout and keep the rest of the logic unchanged so views/tests can
override the duration via data-duration-value.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ee216cdd-01c7-47b9-81df-a0425840c687

📥 Commits

Reviewing files that changed from the base of the PR and between 3a4371f and 47be401.

📒 Files selected for processing (5)
  • app/javascript/controllers/clipboard_controller.js
  • app/views/settings/api_keys/created.html.erb
  • app/views/settings/api_keys/created.turbo_stream.erb
  • app/views/settings/api_keys/show.html.erb
  • test/system/settings/api_keys_test.rb

Comment thread app/views/settings/api_keys/created.turbo_stream.erb Outdated
Comment thread test/system/settings/api_keys_test.rb
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
app/javascript/controllers/clipboard_controller.js (1)

36-45: Optional: tighten timeout cleanup and mark toggleTarget as private.

Two small alignment nits with conventions/codebase patterns:

  1. toggleTarget is a controller-internal helper but is exposed as a public method. Per coding guidelines, Stimulus controllers should "use private methods and expose a clear public API." Renaming with # (or a leading _) signals it isn't part of the controller's public surface.
  2. Other controllers in this repo (e.g. turbo_frame_timeout_controller.js) null out the stored timeout id after clearing, which avoids any chance of acting on a stale handle.
♻️ Proposed refactor
   showSuccess() {
-    this.toggleTarget("iconDefault", true);
-    this.toggleTarget("iconSuccess", false);
-    this.toggleTarget("textDefault", true);
-    this.toggleTarget("textSuccess", false);
+    this.#toggleTarget("iconDefault", true);
+    this.#toggleTarget("iconSuccess", false);
+    this.#toggleTarget("textDefault", true);
+    this.#toggleTarget("textSuccess", false);
 
-    clearTimeout(this.resetTimeout);
+    if (this.resetTimeout) clearTimeout(this.resetTimeout);
     this.resetTimeout = setTimeout(() => {
-      this.toggleTarget("iconDefault", false);
-      this.toggleTarget("iconSuccess", true);
-      this.toggleTarget("textDefault", false);
-      this.toggleTarget("textSuccess", true);
+      this.#toggleTarget("iconDefault", false);
+      this.#toggleTarget("iconSuccess", true);
+      this.#toggleTarget("textDefault", false);
+      this.#toggleTarget("textSuccess", true);
+      this.resetTimeout = null;
     }, this.successDurationValue);
   }
 
   disconnect() {
-    clearTimeout(this.resetTimeout);
+    if (this.resetTimeout) {
+      clearTimeout(this.resetTimeout);
+      this.resetTimeout = null;
+    }
   }
 
-  toggleTarget(targetName, hide) {
+  `#toggleTarget`(targetName, hide) {
     const hasTarget = this[`has${targetName[0].toUpperCase()}${targetName.slice(1)}Target`];
     if (!hasTarget) return;
 
     this[`${targetName}Target`].classList.toggle("hidden", hide);
   }

As per coding guidelines: "Use private methods in Stimulus controllers and expose a clear public API."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/javascript/controllers/clipboard_controller.js` around lines 36 - 45, The
controller exposes an internal helper and leaves a stale timeout handle; make
toggleTarget private (rename toggleTarget to a private method like `#toggleTarget`
or _toggleTarget) and update disconnect() to both
clearTimeout(this.resetTimeout) and set this.resetTimeout = null to match other
controllers (refer to the existing toggleTarget method and disconnect function
names to locate the changes); ensure any internal calls referencing toggleTarget
are updated to use the new private name.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@app/javascript/controllers/clipboard_controller.js`:
- Around line 36-45: The controller exposes an internal helper and leaves a
stale timeout handle; make toggleTarget private (rename toggleTarget to a
private method like `#toggleTarget` or _toggleTarget) and update disconnect() to
both clearTimeout(this.resetTimeout) and set this.resetTimeout = null to match
other controllers (refer to the existing toggleTarget method and disconnect
function names to locate the changes); ensure any internal calls referencing
toggleTarget are updated to use the new private name.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4ab2bcf3-d4e3-481a-a6fa-d763937a3870

📥 Commits

Reviewing files that changed from the base of the PR and between 47be401 and 050ddfa.

📒 Files selected for processing (7)
  • app/javascript/controllers/clipboard_controller.js
  • app/views/settings/api_keys/_copy_button.html.erb
  • app/views/settings/api_keys/created.html.erb
  • app/views/settings/api_keys/created.turbo_stream.erb
  • app/views/settings/api_keys/show.html.erb
  • config/locales/views/settings/api_keys/en.yml
  • test/system/settings/api_keys_test.rb
✅ Files skipped from review due to trivial changes (2)
  • app/views/settings/api_keys/_copy_button.html.erb
  • config/locales/views/settings/api_keys/en.yml
🚧 Files skipped from review as they are similar to previous changes (2)
  • app/views/settings/api_keys/created.turbo_stream.erb
  • app/views/settings/api_keys/created.html.erb

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
app/javascript/controllers/clipboard_controller.js (2)

21-35: Toggle logic is correct, but the success state isn't reasserted on the DOM the second time around.

If showSuccess() is invoked again while a reset is already pending, lines 22–25 will be a no-op on classList.toggle("hidden", boolean) (idempotent) — that's fine. However, the timer is correctly cleared and rescheduled via _clearResetTimeout() on line 27, so the visible "Copied!" window is extended as a user expects. Behavior verified.

Minor optional cleanup: the four _toggleTarget calls in each branch could be condensed to reduce repetition, e.g.:

♻️ Optional refactor
   showSuccess() {
-    this._toggleTarget("iconDefault", true);
-    this._toggleTarget("iconSuccess", false);
-    this._toggleTarget("textDefault", true);
-    this._toggleTarget("textSuccess", false);
-
-    this._clearResetTimeout();
-    this.resetTimeout = setTimeout(() => {
-      this._toggleTarget("iconDefault", false);
-      this._toggleTarget("iconSuccess", true);
-      this._toggleTarget("textDefault", false);
-      this._toggleTarget("textSuccess", true);
-      this.resetTimeout = null;
-    }, this.successDurationValue);
+    this._setSuccessState(true);
+    this._clearResetTimeout();
+    this.resetTimeout = setTimeout(() => {
+      this._setSuccessState(false);
+      this.resetTimeout = null;
+    }, this.successDurationValue);
   }
+
+  _setSuccessState(success) {
+    this._toggleTarget("iconDefault", success);
+    this._toggleTarget("iconSuccess", !success);
+    this._toggleTarget("textDefault", success);
+    this._toggleTarget("textSuccess", !success);
+  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/javascript/controllers/clipboard_controller.js` around lines 21 - 35,
showSuccess() currently relies on idempotent toggles so calling it while a reset
is pending can leave the DOM unchanged; change the four _toggleTarget(...) calls
in both the immediate branch and the timeout callback to explicitly set the
hidden state instead of toggling (e.g. implement/use a helper like
_setTargetHidden(targetName, hidden) or modify _toggleTarget to always call
element.classList.toggle('hidden', Boolean(force)) so it forces the desired
visible/hidden state); update calls for "iconDefault", "iconSuccess",
"textDefault", and "textSuccess" and keep _clearResetTimeout(), resetTimeout
handling and successDurationValue unchanged.

46-51: Use Stimulus's typed hasIconDefaultTarget and similar accessors directly.

The dynamic string construction works, but since targets are predefined in static targets, Stimulus already creates typed accessors like hasIconDefaultTarget, hasIconSuccessTarget, etc. Using these directly would be more readable and greppable than indexing into this via a computed string.

Refactor the _toggleTarget() method to accept the target element directly, or call the specific accessors:

Example refactor
_toggleTarget(target, hide) {
  target.classList.toggle("hidden", hide);
}

// Then in showSuccess():
this._toggleTarget(this.iconDefaultTarget, true);
this._toggleTarget(this.iconSuccessTarget, false);
// ...
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/javascript/controllers/clipboard_controller.js` around lines 46 - 51,
Refactor _toggleTarget to stop computing property names and instead accept a
target element (or call Stimulus's typed accessors) directly: change
_toggleTarget(target, hide) to operate on the passed DOM element
(target.classList.toggle("hidden", hide)), then update callers (e.g. in
showSuccess) to pass this.iconDefaultTarget, this.iconSuccessTarget, etc., or
use the typed boolean accessors like this.hasIconDefaultTarget before calling;
ensure any places that previously referenced has{Name}Target via computed
strings now use the Stimulus-generated accessors such as hasIconDefaultTarget
and iconDefaultTarget.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@app/javascript/controllers/clipboard_controller.js`:
- Around line 21-35: showSuccess() currently relies on idempotent toggles so
calling it while a reset is pending can leave the DOM unchanged; change the four
_toggleTarget(...) calls in both the immediate branch and the timeout callback
to explicitly set the hidden state instead of toggling (e.g. implement/use a
helper like _setTargetHidden(targetName, hidden) or modify _toggleTarget to
always call element.classList.toggle('hidden', Boolean(force)) so it forces the
desired visible/hidden state); update calls for "iconDefault", "iconSuccess",
"textDefault", and "textSuccess" and keep _clearResetTimeout(), resetTimeout
handling and successDurationValue unchanged.
- Around line 46-51: Refactor _toggleTarget to stop computing property names and
instead accept a target element (or call Stimulus's typed accessors) directly:
change _toggleTarget(target, hide) to operate on the passed DOM element
(target.classList.toggle("hidden", hide)), then update callers (e.g. in
showSuccess) to pass this.iconDefaultTarget, this.iconSuccessTarget, etc., or
use the typed boolean accessors like this.hasIconDefaultTarget before calling;
ensure any places that previously referenced has{Name}Target via computed
strings now use the Stimulus-generated accessors such as hasIconDefaultTarget
and iconDefaultTarget.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 73bc060a-9631-4456-a5e6-ef1207a447fc

📥 Commits

Reviewing files that changed from the base of the PR and between 050ddfa and 47c0f39.

📒 Files selected for processing (1)
  • app/javascript/controllers/clipboard_controller.js

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
app/javascript/controllers/clipboard_controller.js (1)

21-29: Consider making showSuccess private.

It's only invoked internally from copy() and isn't part of the controller's declarative public API (no data-action references it). Renaming to _showSuccess would keep the public surface limited to copy, consistent with the underscore-prefixed private helpers introduced in this PR.

As per coding guidelines: "Use private methods in Stimulus controllers and expose a clear public API".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/javascript/controllers/clipboard_controller.js` around lines 21 - 29,
Rename the internal helper showSuccess to _showSuccess and update all internal
references (notably the call from copy()) so the method follows the controller's
private naming convention; keep the implementation identical (it should still
call _setSuccessState(true), clear/reset this.resetTimeout, and use
this.successDurationValue) and ensure any existing references to showSuccess
(e.g., in copy() or other helper methods) are updated to _showSuccess so the
public Stimulus API remains limited to copy().
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@app/javascript/controllers/clipboard_controller.js`:
- Around line 21-29: Rename the internal helper showSuccess to _showSuccess and
update all internal references (notably the call from copy()) so the method
follows the controller's private naming convention; keep the implementation
identical (it should still call _setSuccessState(true), clear/reset
this.resetTimeout, and use this.successDurationValue) and ensure any existing
references to showSuccess (e.g., in copy() or other helper methods) are updated
to _showSuccess so the public Stimulus API remains limited to copy().

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 94847e3c-ab29-4a28-b2ee-2779e0ac9e0c

📥 Commits

Reviewing files that changed from the base of the PR and between 47c0f39 and 3d3c94a.

📒 Files selected for processing (1)
  • app/javascript/controllers/clipboard_controller.js

@jjmata jjmata removed the contributor:flagged Contributor flagged for review by trust analysis. label Apr 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr:verified PR passed security analysis.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

API Key copy action lacks success feedback (Copied! text/icon/tooltip) after clicking “Copy API Key”

2 participants