Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions packages/web/src/components/CopyableId.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { useState } from 'react';
import { Copy, Check } from 'lucide-react';
import { shortId, copyText } from '../lib/clipboard';

interface CopyableIdProps {
id: string;
/** Characters of the id to show before truncating (default 8). */
length?: number;
label?: string;
testId?: string;
}

/**
* Click-to-copy id chip (#23): shows a short id + copy icon; clicking copies the
* FULL id and flashes a transient "Copied!" confirmation. Reusable anywhere an
* id is shown (inspector, modals, lists).
*/
export function CopyableId({ id, length = 8, label = 'ID', testId = 'copyable-id' }: CopyableIdProps) {
const [copied, setCopied] = useState(false);
if (!id) return null;
const onCopy = async () => {
const ok = await copyText(id);
if (ok) {
setCopied(true);
setTimeout(() => setCopied(false), 1400);
}
};
return (
<button
type="button"
data-testid={testId}
onClick={onCopy}
title={`Copy ${label}: ${id}`}
aria-label={`Copy ${label}`}
className="group inline-flex items-center gap-1.5 font-mono text-[11px] text-gray-400 hover:text-gray-200 rounded px-1.5 py-0.5 hover:bg-gray-700/50 transition-colors"
>
<span>{shortId(id, length)}</span>
{copied
? <span className="inline-flex items-center gap-1 text-emerald-400"><Check className="h-3 w-3" /> Copied!</span>
: <Copy className="h-3 w-3 opacity-60 group-hover:opacity-100" />}
</button>
);
}
2 changes: 2 additions & 0 deletions packages/web/src/components/NodeInspector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useGraph } from '../contexts/GraphContext';
import { getTypeConfig, getStatusConfig } from '../constants/workItemConstants';
import type { WorkItemType } from '../constants/workItemConstants';
import { NodeSubgraphPreview } from './NodeSubgraphPreview';
import { CopyableId } from './CopyableId';

// Heavy (markdown + Prism) — lazy so it's out of the main bundle until a node's
// contents are first opened.
Expand Down Expand Up @@ -50,6 +51,7 @@ export function NodeInspector({ node, onClose, compact = false, rootTestId = 'no
<div className="flex-1 min-w-0">
<div className="text-[10px] uppercase tracking-wide" style={{ color: typeCfg.hexColor }}>{typeCfg.label}</div>
<div className="text-sm font-semibold text-white truncate" title={node.title}>{node.title}</div>
<div className="mt-0.5"><CopyableId id={node.id} /></div>
</div>
<button onClick={onClose} className="p-1 text-gray-400 hover:text-white rounded hover:bg-gray-700/50" title="Close">
<X className="h-4 w-4" />
Expand Down
36 changes: 36 additions & 0 deletions packages/web/src/lib/__tests__/clipboard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { shortId, copyText } from '../clipboard';

describe('shortId', () => {
it('takes the first 8 chars by default', () => {
expect(shortId('5134fc28-7ac2-4e33-acbf-e13c2962b8f1')).toBe('5134fc28');
});
it('respects a custom length', () => {
expect(shortId('abcdefghij', 4)).toBe('abcd');
});
it('returns short ids unchanged and handles empty/nullish', () => {
expect(shortId('abc')).toBe('abc');
expect(shortId('')).toBe('');
expect(shortId(undefined as any)).toBe('');
});
});

describe('copyText', () => {
beforeEach(() => { vi.restoreAllMocks(); });

it('uses navigator.clipboard.writeText when available and resolves true', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
vi.stubGlobal('navigator', { clipboard: { writeText } });
const ok = await copyText('hello');
expect(writeText).toHaveBeenCalledWith('hello');
expect(ok).toBe(true);
});

it('returns false when no clipboard mechanism is available', async () => {
vi.stubGlobal('navigator', {});
const doc: any = { queryCommandSupported: () => false };
vi.stubGlobal('document', doc);
const ok = await copyText('x');
expect(ok).toBe(false);
});
});
39 changes: 39 additions & 0 deletions packages/web/src/lib/clipboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* Clipboard helpers — copy text with a graceful fallback, and a short-id
* formatter for displaying long UUIDs compactly (click-to-copy, #23).
*/

/** First `len` chars of an id (UUIDs are unwieldy in the UI). Safe on empty/nullish. */
export function shortId(id: string | null | undefined, len = 8): string {
if (!id) return '';
return id.length > len ? id.slice(0, len) : id;
}

/**
* Copy `text` to the clipboard. Prefers the async Clipboard API; falls back to
* a hidden textarea + execCommand for older/insecure contexts. Resolves true on
* success, false if no mechanism worked.
*/
export async function copyText(text: string): Promise<boolean> {
try {
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
} catch { /* fall through to legacy path */ }

try {
if (typeof document === 'undefined' || !document.body) return false;
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
const ok = typeof document.execCommand === 'function' ? document.execCommand('copy') : false;
document.body.removeChild(ta);
return ok;
} catch {
return false;
}
}
30 changes: 30 additions & 0 deletions tests/e2e/copy-id.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { test, expect } from '@playwright/test';
import { login, TEST_USERS, getBaseURL } from '../helpers/auth';

/**
* Click-to-copy node id (@copyid, #23): the node inspector shows a short id chip;
* clicking it copies the full id and flashes a transient "Copied!" confirmation.
*/
test.use({ permissions: ['clipboard-read', 'clipboard-write'] });

test('node inspector id chip copies and confirms @copyid', async ({ page }) => {
test.setTimeout(90_000);
await page.addInitScript(() => localStorage.setItem('graphdone:viewMode', 'graph'));
await login(page, TEST_USERS.ADMIN);
await page.goto(`${getBaseURL()}/`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.graph-container svg .node', { timeout: 15_000 });
await page.waitForTimeout(3500);

// Select a node → docked inspector opens.
await page.locator('.graph-container svg .node').first().click();
const chip = page.locator('[data-testid="copyable-id"]').first();
await expect(chip).toBeVisible({ timeout: 8000 });

await chip.click();
// Flashes "Copied!" on success.
await expect(chip.getByText('Copied!')).toBeVisible({ timeout: 4000 });

// And the clipboard actually holds a non-trivial id.
const clip = await page.evaluate(() => navigator.clipboard.readText().catch(() => ''));
expect(clip.length).toBeGreaterThan(8);
});
Loading