mirror of
https://github.com/MeshCore-Beacon/beacon-web.git
synced 2026-09-09 07:03:43 +00:00
Add a copy button beside node and observer public keys
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { VARIANT_CLASSES } from "./badge-utils";
|
||||
|
||||
// Copy-to-clipboard pill, styled to match the analyzer's "Copy Link" button: flips to a green
|
||||
// "Copied" state for 1.5s after a click. aria-label defaults to the visible label.
|
||||
export function CopyButton({
|
||||
value,
|
||||
label = "Copy",
|
||||
copiedLabel = "Copied",
|
||||
ariaLabel,
|
||||
className,
|
||||
}: {
|
||||
value: string;
|
||||
label?: string;
|
||||
copiedLabel?: string;
|
||||
ariaLabel?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`inline-flex items-center font-mono text-[11px] font-semibold px-2 py-0.5 rounded-sm border tracking-wider uppercase cursor-pointer transition-colors ${copied ? VARIANT_CLASSES.live : VARIANT_CLASSES.text} ${className ?? ""}`}
|
||||
onClick={handleCopy}
|
||||
aria-label={ariaLabel ?? label}
|
||||
>
|
||||
{copied ? copiedLabel : label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { getNode, getNodeObservations, getNodeNeighbors } from "../../api/client";
|
||||
import { Badge } from "../../components/Badge";
|
||||
import { DetailPanel, Section, Field } from "../../components/DetailPanel";
|
||||
import { CopyButton } from "../../components/CopyButton";
|
||||
import { IataChip } from "../../components/IataChip";
|
||||
import { formatHex, formatSnr, snrLevel, formatRadio, SIGNAL_LEVEL_CLASSES } from "../../lib/formatters";
|
||||
import { Timestamp } from "../../components/Timestamp";
|
||||
@@ -116,8 +117,11 @@ export function NodeDetailPanel({ nodeId, onClose, onViewObserver, onViewNode, o
|
||||
</span>
|
||||
<Badge variant="default">{node.nodeTypeName}</Badge>
|
||||
</div>
|
||||
<div className="font-mono text-[13px] text-text-muted truncate" title={node.publicKey}>
|
||||
{node.publicKey}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="font-mono text-[13px] text-text-muted truncate min-w-0 flex-1" title={node.publicKey}>
|
||||
{node.publicKey}
|
||||
</div>
|
||||
<CopyButton value={node.publicKey} ariaLabel="Copy public key" className="shrink-0" />
|
||||
</div>
|
||||
{node.observerId && (
|
||||
<button
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { getObserver, getObserverAdverts } from "../../api/client";
|
||||
import { Badge } from "../../components/Badge";
|
||||
import { DetailPanel, Section, Field } from "../../components/DetailPanel";
|
||||
import { CopyButton } from "../../components/CopyButton";
|
||||
import { formatUptime, formatBattery, formatHex, formatSnr, snrLevel, SIGNAL_LEVEL_CLASSES } from "../../lib/formatters";
|
||||
import { Timestamp } from "../../components/Timestamp";
|
||||
import { useTick } from "../../hooks/useTick";
|
||||
@@ -145,8 +146,11 @@ export function ObserverDetailPanel({ observerId, onClose, onAnalyzePacket, onVi
|
||||
{status}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="font-mono text-[13px] text-text-muted truncate mb-1.5" title={observer.publicKey}>
|
||||
{observer.publicKey}
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<div className="font-mono text-[13px] text-text-muted truncate min-w-0 flex-1" title={observer.publicKey}>
|
||||
{observer.publicKey}
|
||||
</div>
|
||||
<CopyButton value={observer.publicKey} ariaLabel="Copy public key" className="shrink-0" />
|
||||
</div>
|
||||
<div className="flex items-center gap-3 font-mono text-[13px]">
|
||||
<Field label="Observations" value={observer.observationCount.toLocaleString()} />
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||
import { CopyButton } from "../../src/components/CopyButton";
|
||||
|
||||
const writeText = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: { writeText },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
writeText.mockClear();
|
||||
});
|
||||
|
||||
describe("CopyButton", () => {
|
||||
it("shows the default 'Copy' label", () => {
|
||||
render(<CopyButton value="deadbeef" />);
|
||||
expect(screen.getByRole("button")).toHaveTextContent("Copy");
|
||||
});
|
||||
|
||||
it("writes the full value to the clipboard on click", () => {
|
||||
const key = "0123456789abcdef0123456789abcdef";
|
||||
render(<CopyButton value={key} />);
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
expect(writeText).toHaveBeenCalledWith(key);
|
||||
});
|
||||
|
||||
it("swaps to 'Copied' after clicking, then reverts", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
render(<CopyButton value="deadbeef" />);
|
||||
const button = screen.getByRole("button");
|
||||
fireEvent.click(button);
|
||||
expect(button).toHaveTextContent("Copied");
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1500);
|
||||
});
|
||||
expect(button).toHaveTextContent("Copy");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the provided aria-label for the accessible name", () => {
|
||||
render(<CopyButton value="deadbeef" ariaLabel="Copy public key" />);
|
||||
expect(screen.getByRole("button", { name: "Copy public key" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user