Fix clipboard copy and org settings navigation

- Replace phx:copy event with CopyToClipboard LiveView hook for more
  reliable clipboard functionality
- Add visual feedback (checkmark) when text is copied to clipboard
- Apply hook to both agent token and docker compose copy buttons
- Add readonly attribute to docker compose textarea
- Fix org settings page to stay on settings after save instead of
  redirecting to dashboard

The clipboard copy now works correctly by using a dedicated LiveView
hook that directly handles the click event and clipboard API, rather
than relying on custom event dispatching which was causing issues.
This commit is contained in:
Graham McIntire 2026-01-17 12:18:49 -06:00
parent eff7d0b55a
commit f9a18fa423
No known key found for this signature in database
3 changed files with 70 additions and 18 deletions

View file

@ -284,7 +284,7 @@ if (!csrfToken) {
const liveSocket = new LiveSocket("/live", Socket, {
longPollFallbackMs: 2500,
params: { _csrf_token: csrfToken },
hooks: { ...colocatedHooks, SensorChart, WebAuthnRegister, WebAuthnLogin },
hooks: { ...colocatedHooks, SensorChart, WebAuthnRegister, WebAuthnLogin, CopyToClipboard },
})
// Show progress bar on live navigation and form submits
@ -293,7 +293,9 @@ window.addEventListener("phx:page-loading-start", _info => topbar.show(300))
window.addEventListener("phx:page-loading-stop", _info => topbar.hide())
// Handle clipboard copy events
window.addEventListener("phx:copy", (event) => {
window.addEventListener("phx:copy", (event: any) => {
// When using JS.dispatch with 'to:', the event is dispatched to that element
// and event.target will be that element
const el = event.target as HTMLElement
let text = ""
@ -304,14 +306,64 @@ window.addEventListener("phx:copy", (event) => {
text = el.innerText || el.textContent || ""
}
navigator.clipboard.writeText(text).then(() => {
// Optional: Show a success message or visual feedback
console.log('Copied to clipboard')
}).catch(err => {
console.error('Failed to copy text: ', err)
})
if (text) {
navigator.clipboard.writeText(text).then(() => {
console.log('Copied to clipboard:', text.substring(0, 50) + (text.length > 50 ? '...' : ''))
}).catch(err => {
console.error('Failed to copy text:', err)
})
} else {
console.warn('No text found to copy from element:', el)
}
})
// LiveView hook for copying to clipboard
const CopyToClipboard = {
mounted() {
this.el.addEventListener("click", (e) => {
e.preventDefault()
const targetId = this.el.dataset.target
if (!targetId) {
console.error("No data-target attribute found on copy button")
return
}
const targetEl = document.querySelector(targetId) as HTMLInputElement | HTMLTextAreaElement | HTMLElement
if (!targetEl) {
console.error(`Target element not found: ${targetId}`)
return
}
let text = ""
if (targetEl.tagName === "INPUT" || targetEl.tagName === "TEXTAREA") {
text = (targetEl as HTMLInputElement | HTMLTextAreaElement).value
} else {
text = targetEl.innerText || targetEl.textContent || ""
}
if (text) {
navigator.clipboard.writeText(text).then(() => {
console.log('Copied to clipboard successfully')
// Optional: Show visual feedback
const originalText = this.el.innerHTML
this.el.innerHTML = '<svg class="h-3 w-3 inline" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" /></svg> Copied!'
setTimeout(() => {
this.el.innerHTML = originalText
}, 2000)
}).catch(err => {
console.error('Failed to copy:', err)
})
} else {
console.error('No text found to copy')
}
})
}
}
const Hooks = {
CopyToClipboard
}
// connect if there are any LiveViews on the page
liveSocket.connect()

View file

@ -215,10 +215,9 @@
/>
<button
type="button"
phx-click={
JS.dispatch("phx:copy", to: "#agent-token-input")
|> JS.push("token_copied")
}
phx-hook="CopyToClipboard"
data-target="#agent-token-input"
id={"copy-token-#{@new_token.id}"}
class="absolute right-2 top-1/2 -translate-y-1/2 px-3 py-1 text-xs font-medium text-zinc-700 dark:text-zinc-300 bg-zinc-100 dark:bg-zinc-800 border border-zinc-300 dark:border-zinc-700 rounded hover:bg-zinc-200 dark:hover:bg-zinc-700"
>
<.icon name="hero-clipboard" class="h-4 w-4" />
@ -263,12 +262,14 @@
- /var/run/docker.sock:/var/run/docker.sock</code></pre>
<button
type="button"
phx-click={JS.dispatch("phx:copy", to: "#docker-compose-example")}
class="mt-2 text-xs text-blue-600 dark:text-blue-400 hover:underline"
phx-hook="CopyToClipboard"
data-target="#docker-compose-example"
id={"copy-docker-compose-#{@new_token.id}"}
class="mt-2 text-xs text-blue-600 dark:text-blue-400 hover:underline flex items-center gap-1"
>
Copy to clipboard
<.icon name="hero-clipboard" class="h-3 w-3" /> Copy to clipboard
</button>
<textarea id="docker-compose-example" class="sr-only">services:
<textarea id="docker-compose-example" class="sr-only" readonly>services:
towerops-agent:
image: {@agent_image}
container_name: towerops-agent

View file

@ -40,8 +40,7 @@ defmodule ToweropsWeb.Org.SettingsLive do
{:noreply,
socket
|> assign(:organization, organization)
|> put_flash(:info, "Organization settings updated successfully")
|> push_navigate(to: ~p"/orgs/#{organization.slug}")}
|> put_flash(:info, "Organization settings updated successfully")}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, :form, to_form(changeset))}