diff --git a/frontend/src/components/IssueCard.jsx b/frontend/src/components/IssueCard.jsx
index a0cfcea..55dcf24 100644
--- a/frontend/src/components/IssueCard.jsx
+++ b/frontend/src/components/IssueCard.jsx
@@ -31,6 +31,7 @@ import {
PencilAltIcon,
LockIcon,
ArrowRightIcon,
+ OutlinedCopyIcon,
} from '@patternfly/react-icons';
import { getDaysSince, formatDate } from '../utils/dateUtils';
import {
@@ -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'];
@@ -1695,19 +1697,36 @@ const IssueCard = ({
{/* Column 2: Issue number link */}
-
- #{issue.number}
-
+
+ #{issue.number}
+
+
+
|
{renderHierarchyType()}
diff --git a/frontend/src/components/IssueCard.test.jsx b/frontend/src/components/IssueCard.test.jsx
index 9574cc7..b2b57f3 100644
--- a/frontend/src/components/IssueCard.test.jsx
+++ b/frontend/src/components/IssueCard.test.jsx
@@ -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 = {
@@ -42,6 +44,7 @@ describe('IssueCard', () => {
api.renderMarkdown.mockResolvedValue({
html: 'Previewed close note
',
});
+ clipboard.copyIssueReferenceToClipboard.mockResolvedValue(undefined);
// Clear assignees cache before each test
assigneesCache.data = [];
assigneesCache.loading = false;
@@ -82,6 +85,21 @@ describe('IssueCard', () => {
});
});
+ it('copies the issue number and title as rich clipboard content', async () => {
+ const user = userEvent.setup();
+ render();
+
+ 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();
diff --git a/frontend/src/utils/clipboard.js b/frontend/src/utils/clipboard.js
index 4f025c7..a35ad25 100644
--- a/frontend/src/utils/clipboard.js
+++ b/frontend/src/utils/clipboard.js
@@ -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}
*/
-export async function copyStatusSectionToClipboard(items) {
- const plain = formatStatusSectionClipboardMarkdown(items);
- if (!plain) {
- return;
- }
- const innerHtml = formatStatusSectionClipboardHtml(items);
+async function copyRichClipboardContent(plain, innerHtml) {
const htmlDoc = `${innerHtml}`;
if (
@@ -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
+ ? `#${issue.number}`
+ : `#${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}
+ */
+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}
+ */
+export async function copyStatusSectionToClipboard(items) {
+ const plain = formatStatusSectionClipboardMarkdown(items);
+ if (!plain) {
+ return;
+ }
+ const innerHtml = formatStatusSectionClipboardHtml(items);
+ await copyRichClipboardContent(plain, innerHtml);
+}
diff --git a/frontend/src/utils/clipboard.test.js b/frontend/src/utils/clipboard.test.js
index 120859b..0c94eed 100644
--- a/frontend/src/utils/clipboard.test.js
+++ b/frontend/src/utils/clipboard.test.js
@@ -2,7 +2,10 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
copyTextToClipboard,
+ copyIssueReferenceToClipboard,
copyStatusSectionToClipboard,
+ formatIssueReferenceClipboardHtml,
+ formatIssueReferenceClipboardText,
formatStatusSectionClipboardHtml,
formatStatusSectionClipboardMarkdown,
} from './clipboard';
@@ -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(
+ '#42 A & B'
+ );
+ });
+});
+
describe('copyTextToClipboard', () => {
beforeEach(() => {
const writeText = vi.fn().mockResolvedValue(undefined);
@@ -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();
+ });
+});