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
18 changes: 5 additions & 13 deletions src/components/editor/ExportBar.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useState, useCallback, useEffect, useRef } from 'react';
import { generateMetaTags } from '@/lib/meta-tag-generator';
import { copyToClipboard as copyTextToClipboard } from '@/lib/clipboard';
import { containsLocalImage, svgToPngBlob } from './export-utils';

interface ExportBarProps {
Expand Down Expand Up @@ -74,22 +75,13 @@ export function ExportBar({
}, []);

const copyToClipboard = useCallback(async (text: string, label: string) => {
try {
await navigator.clipboard.writeText(text);
setCopied(label);
window.showToast?.(label === 'url' ? 'URL copied to clipboard!' : 'Meta tags copied!', 'success');
setTimeout(() => setCopied(null), 2000);
} catch {
// Fallback
const ta = document.createElement('textarea');
ta.value = text;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
const success = await copyTextToClipboard(text);
if (success) {
setCopied(label);
window.showToast?.(label === 'url' ? 'URL copied to clipboard!' : 'Meta tags copied!', 'success');
setTimeout(() => setCopied(null), 2000);
} else {
window.showToast?.(label === 'url' ? 'Could not copy URL' : 'Could not copy meta tags', 'error');
}
}, []);

Expand Down
19 changes: 5 additions & 14 deletions src/components/preview/FixItButton.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { MetaTags } from '@/lib/meta-fetcher';
import { generateMetaTags } from '@/lib/meta-tag-generator';
import { copyToClipboard } from '@/lib/clipboard';

interface FixItButtonProps {
meta: MetaTags;
Expand All @@ -26,21 +27,11 @@ export function FixItButton({ meta }: FixItButtonProps) {
});

const handleCopy = async () => {
try {
await navigator.clipboard.writeText(tags);
const success = await copyToClipboard(tags);
if (success) {
window.showToast?.('Recommended meta tags copied!', 'success');
} catch {
try {
const ta = document.createElement('textarea');
ta.value = tags;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
window.showToast?.('Recommended meta tags copied!', 'success');
} catch {
window.showToast?.('Could not copy meta tags', 'error');
}
} else {
window.showToast?.('Could not copy meta tags', 'error');
}
};

Expand Down
35 changes: 35 additions & 0 deletions src/lib/clipboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* Copies text to the clipboard using the modern Clipboard API,
* falling back to document.execCommand('copy') with a temporary textarea
* if the modern API is unavailable or rejects.
*
* Returns true if copying succeeded, false otherwise.
*/
export async function copyToClipboard(text: string): Promise<boolean> {
if (typeof navigator !== 'undefined' && typeof navigator.clipboard?.writeText === 'function') {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
// Modern Clipboard API rejected; proceed to fallback.
}
}

if (typeof document !== 'undefined' && document.body) {
let successful = false;
const ta = document.createElement('textarea');
ta.value = text;
document.body.appendChild(ta);
try {
ta.select?.();
successful = Boolean(document.execCommand?.('copy'));
} catch {
successful = false;
} finally {
document.body.removeChild(ta);
}
return successful;
}

return false;
}
81 changes: 81 additions & 0 deletions tests/components/fix-it-button.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { FixItButton } from '@/components/preview/FixItButton';

describe('FixItButton copy notification behavior', () => {
let showToastMock: ReturnType<typeof vi.fn>;
let mockExecCommand: ReturnType<typeof vi.fn>;
let mockAppendChild: ReturnType<typeof vi.fn>;
let mockRemoveChild: ReturnType<typeof vi.fn>;
let createdTextarea: { value: string; select: ReturnType<typeof vi.fn> } | null;

beforeEach(() => {
createdTextarea = null;
showToastMock = vi.fn();
mockExecCommand = vi.fn();
mockAppendChild = vi.fn((el) => el);
mockRemoveChild = vi.fn((el) => el);

vi.stubGlobal('window', { showToast: showToastMock });
vi.stubGlobal('navigator', {}); // Clipboard API unavailable by default
vi.stubGlobal('document', {
createElement: vi.fn((tag: string) => {
if (tag === 'textarea') {
createdTextarea = { value: '', select: vi.fn() };
return createdTextarea;
}
return {};
}),
body: {
appendChild: mockAppendChild,
removeChild: mockRemoveChild,
},
execCommand: mockExecCommand,
});
});

afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});

function getCopyButton(meta = { ogTitle: 'Test Title', ogDescription: 'Test Desc' }) {
const tree = FixItButton({ meta });
// Structure: div.fix-it-section -> [h3, p, div.fix-it-actions]
const actionsDiv = tree.props.children[2];
// actionsDiv children: [a.fix-it-btn-primary, button.fix-it-btn-ghost]
const copyBtn = actionsDiv.props.children[1];
return copyBtn;
}

it('shows error notification and never reports success when execCommand returns false', async () => {
mockExecCommand.mockReturnValue(false);
const copyBtn = getCopyButton();

await copyBtn.props.onClick();

expect(showToastMock).toHaveBeenCalledWith('Could not copy meta tags', 'error');
expect(showToastMock).not.toHaveBeenCalledWith(expect.anything(), 'success');
});

it('shows error notification and never reports success when execCommand throws', async () => {
mockExecCommand.mockImplementation(() => {
throw new Error('execCommand copy disabled');
});
const copyBtn = getCopyButton();

await copyBtn.props.onClick();

expect(showToastMock).toHaveBeenCalledWith('Could not copy meta tags', 'error');
expect(showToastMock).not.toHaveBeenCalledWith(expect.anything(), 'success');
});

it('shows success notification only when copy succeeds', async () => {
mockExecCommand.mockReturnValue(true);
const copyBtn = getCopyButton();

await copyBtn.props.onClick();

expect(showToastMock).toHaveBeenCalledWith('Recommended meta tags copied!', 'success');
expect(showToastMock).not.toHaveBeenCalledWith(expect.anything(), 'error');
});
});
171 changes: 171 additions & 0 deletions tests/editor/export-bar-copy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';

const hooks = vi.hoisted(() => ({
copied: null as string | null,
setCopied: vi.fn(),
effects: [] as (() => void)[],
}));

vi.mock('react', () => ({
default: {
createElement: (type: any, props: any, ...children: any[]) => ({ type, props: { ...props, children } }),
},
useState: (initial: any) => [initial, hooks.setCopied],
useRef: (current: unknown) => ({ current }),
useCallback: (callback: unknown) => callback,
useEffect: (effect: () => void) => {
hooks.effects.push(effect);
},
}));

const { ExportBar } = await import('@/components/editor/ExportBar');

describe('ExportBar copy notifications and fallback failure', () => {
let showToastMock: ReturnType<typeof vi.fn>;
let mockExecCommand: ReturnType<typeof vi.fn>;
let mockAppendChild: ReturnType<typeof vi.fn>;
let mockRemoveChild: ReturnType<typeof vi.fn>;
let keydownListeners: ((e: any) => void)[];
let createdTextarea: { value: string; select: ReturnType<typeof vi.fn> } | null;

beforeEach(() => {
createdTextarea = null;
showToastMock = vi.fn();
mockExecCommand = vi.fn();
mockAppendChild = vi.fn((el) => el);
mockRemoveChild = vi.fn((el) => el);
keydownListeners = [];
hooks.setCopied.mockClear();
hooks.effects.length = 0;

vi.stubGlobal('window', { showToast: showToastMock });
vi.stubGlobal('navigator', {}); // Modern clipboard unavailable
vi.stubGlobal('document', {
createElement: vi.fn((tag: string) => {
if (tag === 'textarea') {
createdTextarea = { value: '', select: vi.fn() };
return createdTextarea;
}
return {};
}),
body: {
appendChild: mockAppendChild,
removeChild: mockRemoveChild,
},
execCommand: mockExecCommand,
addEventListener: vi.fn((event: string, listener: (e: any) => void) => {
if (event === 'keydown') keydownListeners.push(listener);
}),
removeEventListener: vi.fn(),
});
});

afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});

function renderBar() {
const tree = ExportBar({
apiUrl: 'https://example.com/api/og?title=Test',
downloadUrl: 'https://example.com/api/og?title=Test',
params: { title: 'Test' },
templateId: 'saas-maintenance',
svg: null,
});
hooks.effects.forEach((effect) => effect());
// Structure: div.export-bar -> div.export-buttons -> [downloadBtn, copyUrlBtn, copyMetaBtn]
const buttonsContainer = tree.props.children;
const buttons = buttonsContainer.props.children;
return {
copyUrlBtn: buttons[1],
copyMetaBtn: buttons[2],
};
}

it('shows error notification and never reports success when execCommand returns false on Copy Image URL', async () => {
mockExecCommand.mockReturnValue(false);
const { copyUrlBtn } = renderBar();

await copyUrlBtn.props.onClick();

expect(showToastMock).toHaveBeenCalledWith('Could not copy URL', 'error');
expect(showToastMock).not.toHaveBeenCalledWith(expect.anything(), 'success');
expect(hooks.setCopied).not.toHaveBeenCalledWith('url');
});

it('shows error notification and never reports success when execCommand throws on Copy Image URL', async () => {
mockExecCommand.mockImplementation(() => {
throw new Error('execCommand copy disabled');
});
const { copyUrlBtn } = renderBar();

await copyUrlBtn.props.onClick();

expect(showToastMock).toHaveBeenCalledWith('Could not copy URL', 'error');
expect(showToastMock).not.toHaveBeenCalledWith(expect.anything(), 'success');
expect(hooks.setCopied).not.toHaveBeenCalledWith('url');
});

it('shows error notification and never reports success when execCommand returns false on Copy Meta Tags', async () => {
mockExecCommand.mockReturnValue(false);
const { copyMetaBtn } = renderBar();

await copyMetaBtn.props.onClick();

expect(showToastMock).toHaveBeenCalledWith('Could not copy meta tags', 'error');
expect(showToastMock).not.toHaveBeenCalledWith(expect.anything(), 'success');
expect(hooks.setCopied).not.toHaveBeenCalledWith('meta');
});

it('shows error notification and never reports success when execCommand throws on Copy Meta Tags', async () => {
mockExecCommand.mockImplementation(() => {
throw new Error('execCommand copy disabled');
});
const { copyMetaBtn } = renderBar();

await copyMetaBtn.props.onClick();

expect(showToastMock).toHaveBeenCalledWith('Could not copy meta tags', 'error');
expect(showToastMock).not.toHaveBeenCalledWith(expect.anything(), 'success');
expect(hooks.setCopied).not.toHaveBeenCalledWith('meta');
});

it('handles Ctrl/Cmd+Shift+C keyboard shortcut when fallback fails', async () => {
mockExecCommand.mockReturnValue(false);
renderBar();

const event = {
key: 'C',
shiftKey: true,
ctrlKey: true,
metaKey: false,
preventDefault: vi.fn(),
};
for (const listener of keydownListeners) {
listener(event);
}
await Promise.resolve();

expect(event.preventDefault).toHaveBeenCalled();
expect(showToastMock).toHaveBeenCalledWith('Could not copy URL', 'error');
expect(showToastMock).not.toHaveBeenCalledWith(expect.anything(), 'success');
expect(hooks.setCopied).not.toHaveBeenCalledWith('url');
});

it('reports success when copy succeeds', async () => {
mockExecCommand.mockReturnValue(true);
const { copyUrlBtn, copyMetaBtn } = renderBar();

await copyUrlBtn.props.onClick();
expect(showToastMock).toHaveBeenCalledWith('URL copied to clipboard!', 'success');
expect(hooks.setCopied).toHaveBeenCalledWith('url');

showToastMock.mockClear();
hooks.setCopied.mockClear();

await copyMetaBtn.props.onClick();
expect(showToastMock).toHaveBeenCalledWith('Meta tags copied!', 'success');
expect(hooks.setCopied).toHaveBeenCalledWith('meta');
});
});
Loading