diff --git a/src/components/TableWrapper.tsx b/src/components/TableWrapper.tsx index 74e4c57..6dd413f 100644 --- a/src/components/TableWrapper.tsx +++ b/src/components/TableWrapper.tsx @@ -8,6 +8,8 @@ import { Td, Caption, } from "@patternfly/react-table"; +import { CopyIcon, CheckIcon } from "@patternfly/react-icons"; +import React, { useState } from "react"; import ErrorPlaceholder from "./ErrorPlaceholder"; @@ -26,6 +28,33 @@ interface TableWrapperProps { onRowClick?: (rowData: Record) => void; } +// Copy button component with visual feedback +const CopyButton: React.FC<{ text: string }> = ({ text }) => { + const [copied, setCopied] = useState(false); + + const handleCopy = async (e: React.MouseEvent) => { + e.stopPropagation(); // Prevent row click + try { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error('Failed to copy:', err); + } + }; + + return ( + + ); +}; + const TableWrapper = (props: TableWrapperProps) => { const { title, id, fields, className, onRowClick } = props; @@ -110,9 +139,27 @@ const TableWrapper = (props: TableWrapperProps) => { style={onRowClick ? { cursor: 'pointer' } : undefined} isHoverable={!!onRowClick} > - {columns.map((col, colIndex) => ( - {row[col.key]} - ))} + {columns.map((col, colIndex) => { + const cellValue = row[col.key]; + + // Add copy button for ID, Name, URL, Email columns + const isCopyableColumn = ['id', 'name', 'url', 'email', 'cluster'].some( + keyword => col.key.toLowerCase().includes(keyword) + ); + + return ( + + {isCopyableColumn ? ( + + {cellValue} + + + ) : ( + cellValue + )} + + ); + })} ))} diff --git a/src/global.css b/src/global.css index 2ca3eff..e3f973e 100644 --- a/src/global.css +++ b/src/global.css @@ -163,3 +163,64 @@ color: var(--pf-global--Color--danger-200); border: 1px solid var(--pf-global--Color--danger-200); } + +/* Copy Button Styles */ +.cell-with-copy { + display: inline-flex; + align-items: center; + gap: 0.5rem; + width: 100%; +} + +.cell-value { + flex: 1; +} + +/* Copy button - hidden by default */ +.copy-button { + opacity: 0; + background: transparent; + border: none; + padding: 0.25rem 0.5rem; + cursor: pointer; + font-size: 1rem; + line-height: 1; + transition: all 0.2s ease; + border-radius: 4px; + flex-shrink: 0; + color: #6b7280; /* PatternFly neutral gray */ +} + +.copy-button:hover { + background: #f3f4f6; + transform: scale(1.1); + color: #374151; +} + +.copy-button:active { + transform: scale(0.95); +} + +/* Show copy button on row hover */ +.pf-v6-c-table tbody tr:hover .copy-button { + opacity: 1; +} + +/* When copied, always show and make it green */ +.copy-button[title="Copied!"] { + opacity: 1 !important; + color: #10b981; + animation: copySuccess 0.3s ease; +} + +@keyframes copySuccess { + 0% { + transform: scale(1); + } + 50% { + transform: scale(1.3); + } + 100% { + transform: scale(1); + } +} diff --git a/src/test/components/TableWrapper.test.tsx b/src/test/components/TableWrapper.test.tsx index 87cb222..5e6048d 100644 --- a/src/test/components/TableWrapper.test.tsx +++ b/src/test/components/TableWrapper.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, fireEvent } from "@testing-library/react"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import TableWrapper from "../../components/TableWrapper"; @@ -496,4 +496,359 @@ describe("TableWrapper Component", () => { } }); }); + + // ========== Copy Button Feature Tests ========== + + describe("Copy Button functionality", () => { + // Mock clipboard API + const originalClipboard = { ...global.navigator.clipboard }; + const mockWriteText = vitest.fn(); + + beforeEach(() => { + mockWriteText.mockClear(); + mockWriteText.mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { + value: { writeText: mockWriteText }, + writable: true, + configurable: true, + }); + }); + + afterEach(() => { + Object.defineProperty(navigator, 'clipboard', { + value: originalClipboard, + writable: true, + configurable: true, + }); + }); + + it("should render copy buttons for copyable columns (ID)", () => { + const dataWithID = { + ...mockFieldsData, + fields: [ + { + name: "Cluster ID", + data_path: "cluster.id", + data: ["cluster-123"], + }, + { + name: "Status", + data_path: "cluster.status", + data: ["Active"], + }, + ], + }; + + const { container } = render(); + + // Copy button should exist for ID column + const copyButtons = container.querySelectorAll('.copy-button'); + expect(copyButtons.length).toBeGreaterThan(0); + }); + + it("should render copy buttons for copyable columns (Name)", () => { + const dataWithName = { + ...mockFieldsData, + fields: [ + { + name: "Cluster Name", + data_path: "cluster.name", + data: ["Production Cluster"], + }, + ], + }; + + const { container } = render(); + + const copyButtons = container.querySelectorAll('.copy-button'); + expect(copyButtons.length).toBeGreaterThan(0); + }); + + it("should render copy buttons for copyable columns (URL)", () => { + const dataWithURL = { + ...mockFieldsData, + fields: [ + { + name: "Console URL", + data_path: "cluster.url", + data: ["https://console.redhat.com"], + }, + ], + }; + + const { container } = render(); + + const copyButtons = container.querySelectorAll('.copy-button'); + expect(copyButtons.length).toBeGreaterThan(0); + }); + + it("should render copy buttons for copyable columns (Email)", () => { + const dataWithEmail = { + ...mockFieldsData, + fields: [ + { + name: "Owner Email", + data_path: "owner.email", + data: ["admin@example.com"], + }, + ], + }; + + const { container } = render(); + + const copyButtons = container.querySelectorAll('.copy-button'); + expect(copyButtons.length).toBeGreaterThan(0); + }); + + it("should NOT render copy buttons for non-copyable columns", () => { + const nonCopyableData = { + ...mockFieldsData, + fields: [ + { + name: "Status", + data_path: "status", + data: ["Active"], + }, + { + name: "Count", + data_path: "count", + data: [42], + }, + ], + }; + + const { container } = render(); + + const copyButtons = container.querySelectorAll('.copy-button'); + expect(copyButtons.length).toBe(0); + }); + + it("should copy cell value to clipboard when copy button is clicked", async () => { + const dataWithID = { + ...mockFieldsData, + fields: [ + { + name: "ID", + data_path: "id", + data: ["test-id-123"], + }, + ], + }; + + const { container } = render(); + + const copyButton = container.querySelector('.copy-button') as HTMLElement; + expect(copyButton).toBeInTheDocument(); + + fireEvent.click(copyButton); + + await waitFor(() => { + expect(mockWriteText).toHaveBeenCalledWith("test-id-123"); + }); + }); + + it("should show success icon (CheckIcon) after successful copy", async () => { + const dataWithID = { + ...mockFieldsData, + fields: [ + { + name: "ID", + data_path: "id", + data: ["test-id-123"], + }, + ], + }; + + const { container } = render(); + + const copyButton = container.querySelector('.copy-button') as HTMLElement; + fireEvent.click(copyButton); + + await waitFor(() => { + expect(copyButton.getAttribute('title')).toBe('Copied!'); + }); + }); + + it("should stop event propagation to prevent row click", async () => { + const mockOnRowClick = vitest.fn(); + const dataWithID = { + ...mockFieldsData, + fields: [ + { + name: "ID", + data_path: "id", + data: ["test-id"], + }, + ], + }; + + const { container } = render(); + + const copyButton = container.querySelector('.copy-button') as HTMLElement; + + // Create a spy on stopPropagation + const clickEvent = new MouseEvent('click', { bubbles: true }); + const stopPropagationSpy = vitest.spyOn(clickEvent, 'stopPropagation'); + + copyButton.dispatchEvent(clickEvent); + + expect(stopPropagationSpy).toHaveBeenCalled(); + }); + + it("should render multiple copy buttons for multiple copyable columns", () => { + const multiCopyableData = { + ...mockFieldsData, + fields: [ + { + name: "Cluster ID", + data_path: "cluster.id", + data: ["id-1", "id-2"], + }, + { + name: "Cluster Name", + data_path: "cluster.name", + data: ["name-1", "name-2"], + }, + { + name: "Status", + data_path: "status", + data: ["Active", "Inactive"], + }, + ], + }; + + const { container } = render(); + + const copyButtons = container.querySelectorAll('.copy-button'); + // 2 rows × 2 copyable columns = 4 copy buttons + expect(copyButtons.length).toBe(4); + }); + + it("should handle case-insensitive column detection", () => { + const mixedCaseData = { + ...mockFieldsData, + fields: [ + { + name: "CLUSTER_ID", + data_path: "cluster.id", + data: ["id-1"], + }, + { + name: "ClusterName", + data_path: "cluster.name", + data: ["name-1"], + }, + { + name: "email_ADDRESS", + data_path: "email", + data: ["test@example.com"], + }, + ], + }; + + const { container } = render(); + + const copyButtons = container.querySelectorAll('.copy-button'); + expect(copyButtons.length).toBe(3); + }); + + it("should render copy button with cell value in wrapper", () => { + const dataWithID = { + ...mockFieldsData, + fields: [ + { + name: "ID", + data_path: "id", + data: ["test-value"], + }, + ], + }; + + const { container } = render(); + + const cellWrapper = container.querySelector('.cell-with-copy'); + expect(cellWrapper).toBeInTheDocument(); + + const cellValue = container.querySelector('.cell-value'); + expect(cellValue).toBeInTheDocument(); + expect(cellValue?.textContent).toBe("test-value"); + }); + + it("should have correct aria-label for accessibility", () => { + const dataWithID = { + ...mockFieldsData, + fields: [ + { + name: "ID", + data_path: "id", + data: ["test-id"], + }, + ], + }; + + const { container } = render(); + + const copyButton = container.querySelector('.copy-button') as HTMLElement; + expect(copyButton.getAttribute('aria-label')).toBe('Copy to clipboard'); + }); + + it("should work with 'cluster' keyword in column name", () => { + const clusterData = { + ...mockFieldsData, + fields: [ + { + name: "Cluster", + data_path: "cluster", + data: ["my-cluster"], + }, + ], + }; + + const { container } = render(); + + const copyButtons = container.querySelectorAll('.copy-button'); + expect(copyButtons.length).toBeGreaterThan(0); + }); + + it("should render copy button for empty cell values", () => { + const emptyData = { + ...mockFieldsData, + fields: [ + { + name: "ID", + data_path: "id", + data: [""], + }, + ], + }; + + const { container } = render(); + + // Copy button should still render for empty values + const copyButton = container.querySelector('.copy-button') as HTMLElement; + expect(copyButton).toBeInTheDocument(); + }); + + it("should render copy button for numeric values", () => { + const numberData = { + ...mockFieldsData, + fields: [ + { + name: "User ID", + data_path: "user.id", + data: [12345], + }, + ], + }; + + const { container } = render(); + + // Copy button should render for numeric values + const copyButton = container.querySelector('.copy-button') as HTMLElement; + expect(copyButton).toBeInTheDocument(); + + // Verify the cell displays the number + expect(screen.getByText("12345")).toBeInTheDocument(); + }); + }); });