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
39 changes: 29 additions & 10 deletions frontend/src/components/IssueCard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
PencilAltIcon,
LockIcon,
ArrowRightIcon,
OutlinedCopyIcon,
} from '@patternfly/react-icons';
import { getDaysSince, formatDate } from '../utils/dateUtils';
import {
Expand Down Expand Up @@ -68,6 +69,7 @@ import LabelColorInput from './LabelColorInput';
import labelsCache, { clearLabelsCache } from '../utils/labelsCache';
import milestonesCache from '../utils/milestonesCache';
import assigneesCache from '../utils/assigneesCache';
import { copyIssueReferenceToClipboard } from '../utils/clipboard';

// Assisted-by: openai-code-assist
const ISSUE_TYPES = ['Bug', 'Feature', 'Task'];
Expand Down Expand Up @@ -1695,19 +1697,36 @@ const IssueCard = ({

{/* Column 2: Issue number link */}
<td style={{ ...cellStyle, paddingLeft: `${0.5 + indentPx / 16}rem` }}>
<a
href={issue.html_url}
target="_blank"
rel="noopener noreferrer"
<div
style={{
textDecoration: 'none',
color: '#0066cc',
fontWeight: '500',
whiteSpace: 'nowrap',
display: 'inline-flex',
alignItems: 'center',
gap: '0.25rem',
}}
>
#{issue.number}
</a>
<a
href={issue.html_url}
target="_blank"
rel="noopener noreferrer"
style={{
textDecoration: 'none',
color: '#0066cc',
fontWeight: '500',
whiteSpace: 'nowrap',
}}
>
#{issue.number}
</a>
<Button
variant="plain"
onClick={() => void copyIssueReferenceToClipboard(issue)}
aria-label={`Copy issue #${issue.number} reference to clipboard`}
title="Copy issue reference"
style={{ padding: '0.125rem' }}
>
<OutlinedCopyIcon />
</Button>
</div>
</td>

{renderHierarchyType()}
Expand Down
18 changes: 18 additions & 0 deletions frontend/src/components/IssueCard.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ import { render, screen, waitFor, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import IssueCard from './IssueCard';
import * as api from '../services/api';
import * as clipboard from '../utils/clipboard';
import assigneesCache from '../utils/assigneesCache';
import milestonesCache from '../utils/milestonesCache';

vi.mock('../services/api');
vi.mock('../utils/clipboard');

describe('IssueCard', () => {
const mockIssue = {
Expand Down Expand Up @@ -42,6 +44,7 @@ describe('IssueCard', () => {
api.renderMarkdown.mockResolvedValue({
html: '<p><strong>Previewed close note</strong></p>',
});
clipboard.copyIssueReferenceToClipboard.mockResolvedValue(undefined);
// Clear assignees cache before each test
assigneesCache.data = [];
assigneesCache.loading = false;
Expand Down Expand Up @@ -82,6 +85,21 @@ describe('IssueCard', () => {
});
});

it('copies the issue number and title as rich clipboard content', async () => {
const user = userEvent.setup();
render(<IssueCard issue={mockIssue} />);

await user.click(
screen.getByRole('button', {
name: 'Copy issue #459 reference to clipboard',
})
);

expect(clipboard.copyIssueReferenceToClipboard).toHaveBeenCalledWith(
mockIssue
);
});

it('shows expansion icon when body exists', async () => {
await act(async () => {
render(<IssueCard issue={mockIssue} />);
Expand Down
64 changes: 55 additions & 9 deletions frontend/src/utils/clipboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,17 +112,11 @@ export async function copyTextToClipboard(text) {
}

/**
* Copy section rows with ``#number`` hyperlinked in HTML and Markdown link syntax in plain text.
*
* @param {Array<{ number: number, title?: string, html_url?: string, days_since_update?: number, reviewer_login?: string }>} items
* @param {string} plain
* @param {string} innerHtml
* @returns {Promise<void>}
*/
export async function copyStatusSectionToClipboard(items) {
const plain = formatStatusSectionClipboardMarkdown(items);
if (!plain) {
return;
}
const innerHtml = formatStatusSectionClipboardHtml(items);
async function copyRichClipboardContent(plain, innerHtml) {
const htmlDoc = `<!DOCTYPE html><html><head><meta charset="utf-8" /></head><body>${innerHtml}</body></html>`;

if (
Expand All @@ -144,3 +138,55 @@ export async function copyStatusSectionToClipboard(items) {
}
await copyTextToClipboard(plain);
}

/**
* @param {{ number: number, title?: string, html_url?: string }} issue
* @returns {string}
*/
export function formatIssueReferenceClipboardText(issue) {
const title = (issue.title != null ? String(issue.title) : '').trim();
return `#${issue.number} ${title}`.trim();
}

/**
* @param {{ number: number, title?: string, html_url?: string }} issue
* @returns {string}
*/
export function formatIssueReferenceClipboardHtml(issue) {
const title = escapeHtmlText(
(issue.title != null ? String(issue.title) : '').trim()
);
const url = (issue.html_url != null ? String(issue.html_url) : '').trim();
const number = url
? `<a href="${escapeHtmlAttr(url)}">#${issue.number}</a>`
: `#${issue.number}`;
return `${number} ${title}`.trim();
}

/**
* Copy an issue reference as rich HTML for document editors such as Google Docs.
*
* @param {{ number: number, title?: string, html_url?: string }} issue
* @returns {Promise<void>}
*/
export async function copyIssueReferenceToClipboard(issue) {
await copyRichClipboardContent(
formatIssueReferenceClipboardText(issue),
formatIssueReferenceClipboardHtml(issue)
);
}

/**
* Copy section rows with ``#number`` hyperlinked in HTML and Markdown link syntax in plain text.
*
* @param {Array<{ number: number, title?: string, html_url?: string, days_since_update?: number, reviewer_login?: string }>} items
* @returns {Promise<void>}
*/
export async function copyStatusSectionToClipboard(items) {
const plain = formatStatusSectionClipboardMarkdown(items);
if (!plain) {
return;
}
const innerHtml = formatStatusSectionClipboardHtml(items);
await copyRichClipboardContent(plain, innerHtml);
}
59 changes: 59 additions & 0 deletions frontend/src/utils/clipboard.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
copyTextToClipboard,
copyIssueReferenceToClipboard,
copyStatusSectionToClipboard,
formatIssueReferenceClipboardHtml,
formatIssueReferenceClipboardText,
formatStatusSectionClipboardHtml,
formatStatusSectionClipboardMarkdown,
} from './clipboard';
Expand Down Expand Up @@ -135,6 +138,30 @@ describe('formatStatusSectionClipboardHtml', () => {
});
});

describe('formatIssueReferenceClipboard', () => {
it('uses plain text without Markdown syntax', () => {
expect(
formatIssueReferenceClipboardText({
number: 42,
title: 'Hello world',
html_url: 'https://github.com/o/r/issues/42',
})
).toBe('#42 Hello world');
});

it('uses an HTML link for the issue number', () => {
expect(
formatIssueReferenceClipboardHtml({
number: 42,
title: 'A & B',
html_url: 'https://github.com/o/r/issues/42?x=1&y=2',
})
).toBe(
'<a href="https://github.com/o/r/issues/42?x=1&amp;y=2">#42</a> A &amp; B'
);
});
});

describe('copyTextToClipboard', () => {
beforeEach(() => {
const writeText = vi.fn().mockResolvedValue(undefined);
Expand Down Expand Up @@ -197,3 +224,35 @@ describe('copyStatusSectionToClipboard', () => {
warn.mockRestore();
});
});

describe('copyIssueReferenceToClipboard', () => {
beforeEach(() => {
globalThis.ClipboardItem = class {
constructor(data) {
this._data = data;
}
};
});

it('writes rich HTML and plain text for document editors', async () => {
const write = vi.fn().mockResolvedValue(undefined);
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', {
value: { write, writeText },
configurable: true,
writable: true,
});

await copyIssueReferenceToClipboard({
number: 42,
title: 'Hello world',
html_url: 'https://github.com/o/r/issues/42',
});

expect(write).toHaveBeenCalledTimes(1);
const [clipboardItem] = write.mock.calls[0][0];
expect(clipboardItem._data['text/plain'].type).toBe('text/plain');
expect(clipboardItem._data['text/html'].type).toBe('text/html');
expect(writeText).not.toHaveBeenCalled();
});
});
Loading