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(); + }); +});