From 53939c2046478c9dc037ad04aa7ca01314098940 Mon Sep 17 00:00:00 2001 From: Joachim Schuler Date: Thu, 6 Nov 2025 11:19:41 -0500 Subject: [PATCH] clipboardcopy --- app/public/examples/standalone-example.html | 4 +- .../examples/webcomponents-example.html | 4 +- src/components/TableWrapper.css | 27 +++ src/components/TableWrapper.tsx | 73 ++++++- src/test/components/TableWrapper.test.tsx | 197 ++++++++++++++++++ 5 files changed, 292 insertions(+), 13 deletions(-) create mode 100644 src/components/TableWrapper.css diff --git a/app/public/examples/standalone-example.html b/app/public/examples/standalone-example.html index b75e85d..e818d52 100644 --- a/app/public/examples/standalone-example.html +++ b/app/public/examples/standalone-example.html @@ -13,11 +13,11 @@ - + - + diff --git a/app/public/examples/webcomponents-example.html b/app/public/examples/webcomponents-example.html index b856e3e..953b5dd 100644 --- a/app/public/examples/webcomponents-example.html +++ b/app/public/examples/webcomponents-example.html @@ -9,11 +9,11 @@ - + - + diff --git a/src/components/TableWrapper.css b/src/components/TableWrapper.css new file mode 100644 index 0000000..22d9eca --- /dev/null +++ b/src/components/TableWrapper.css @@ -0,0 +1,27 @@ +.pf-v6-c-clipboard-copy.pf-m-inline.ngui-clipboard-copy { + background-color: unset; +} + +.pf-v6-c-clipboard-copy.pf-m-inline.ngui-clipboard-copy + .pf-v6-c-clipboard-copy__actions { + opacity: 0; +} + +.pf-v6-c-table + tbody + td:hover + .pf-v6-c-clipboard-copy.pf-m-inline.ngui-clipboard-copy + .pf-v6-c-clipboard-copy__actions { + opacity: 1; +} + +.pf-v6-c-clipboard-copy.pf-m-inline.ngui-clipboard-copy:focus-within + .pf-v6-c-clipboard-copy__actions, +.pf-v6-c-clipboard-copy.pf-m-inline.ngui-clipboard-copy:active + .pf-v6-c-clipboard-copy__actions, +.pf-v6-c-clipboard-copy.pf-m-inline.ngui-clipboard-copy + .pf-v6-c-clipboard-copy__actions:focus, +.pf-v6-c-clipboard-copy.pf-m-inline.ngui-clipboard-copy + .pf-v6-c-clipboard-copy__actions:active { + opacity: 1; +} diff --git a/src/components/TableWrapper.tsx b/src/components/TableWrapper.tsx index d83919e..0d4e33d 100644 --- a/src/components/TableWrapper.tsx +++ b/src/components/TableWrapper.tsx @@ -1,4 +1,4 @@ -import { Card, CardBody } from "@patternfly/react-core"; +import { Card, CardBody, ClipboardCopy } from "@patternfly/react-core"; import { Table, Thead, @@ -11,6 +11,8 @@ import { import ErrorPlaceholder from "./ErrorPlaceholder"; +import "./TableWrapper.css"; + interface FieldData { name: string; data_path: string; @@ -33,19 +35,49 @@ const TableWrapper = (props: TableWrapperProps) => { const hasNoFields = !fields || fields.length === 0; const hasNoTitle = !title || title.trim() === ""; + // Helper function to determine if a field contains copyable text data + const isFieldCopyable = (field: FieldData): boolean => { + if (!field.data || field.data.length === 0) return false; + + // Check the first non-null/non-undefined value to determine type + const sampleValue = field.data.find( + (val) => val !== null && val !== undefined + ); + if (!sampleValue) return false; + + // Arrays are converted to comma-separated strings, so they're copyable + if (Array.isArray(sampleValue)) { + return true; + } + + // Make primitive values copyable (string, number, boolean) + // Exclude objects, React elements, functions, etc. + const valueType = typeof sampleValue; + return ( + valueType === "string" || + valueType === "number" || + valueType === "boolean" + ); + }; + // Transform fields data into table format const transformFieldsToTableData = () => { - if (hasNoFields) return { columns: [], rows: [] }; + if (hasNoFields) + return { columns: [], rows: [], copyableColumns: new Set() }; // Find the maximum number of data items across all fields const maxDataLength = Math.max(...fields.map((field) => field.data.length)); - // Create columns from field names + // Create columns from field names and track which are copyable const transformedColumns = fields.map((field) => ({ key: field.name, label: field.name, })); + const copyableColumns = new Set( + fields.filter(isFieldCopyable).map((field) => field.name) + ); + // Create rows based on the maximum data length const transformedRows = []; for (let i = 0; i < maxDataLength; i++) { @@ -63,10 +95,14 @@ const TableWrapper = (props: TableWrapperProps) => { transformedRows.push(row); } - return { columns: transformedColumns, rows: transformedRows }; + return { + columns: transformedColumns, + rows: transformedRows, + copyableColumns, + }; }; - const { columns, rows } = transformFieldsToTableData(); + const { columns, rows, copyableColumns } = transformFieldsToTableData(); const hasNoData = rows.length === 0; // If no title and no fields, show error @@ -108,11 +144,30 @@ const TableWrapper = (props: TableWrapperProps) => { data-testid={`row-${rowIndex}`} onClick={() => onRowClick?.(row)} style={onRowClick ? { cursor: "pointer" } : undefined} - isHoverable={!!onRowClick} > - {columns.map((col, colIndex) => ( - {row[col.key]} - ))} + {columns.map((col, colIndex) => { + const cellValue = row[col.key]; + const isCopyableColumn = copyableColumns.has(col.key); + + return ( + + {isCopyableColumn ? ( +
e.stopPropagation()}> + + {String(cellValue)} + +
+ ) : ( + cellValue + )} + + ); + })} ))} diff --git a/src/test/components/TableWrapper.test.tsx b/src/test/components/TableWrapper.test.tsx index 87cb222..7adb1a8 100644 --- a/src/test/components/TableWrapper.test.tsx +++ b/src/test/components/TableWrapper.test.tsx @@ -496,4 +496,201 @@ describe("TableWrapper Component", () => { } }); }); + + // ========== Copy to Clipboard Feature Tests ========== + + describe("copy to clipboard functionality", () => { + it("should render ClipboardCopy for string columns", () => { + const copyableData = { + ...mockFieldsData, + fields: [ + { name: "User ID", data_path: "user.id", data: ["12345"] }, + { name: "Name", data_path: "user.name", data: ["John Doe"] }, + { + name: "Email", + data_path: "user.email", + data: ["john@example.com"], + }, + { + name: "Profile URL", + data_path: "user.url", + data: ["https://example.com"], + }, + { + name: "Cluster Name", + data_path: "cluster.name", + data: ["prod-cluster-01"], + }, + ], + }; + + const { container } = render(); + + // ClipboardCopy components should be present for string columns + const clipboardCopies = container.querySelectorAll( + ".pf-v6-c-clipboard-copy" + ); + expect(clipboardCopies.length).toBeGreaterThan(0); + }); + + it("should render ClipboardCopy for primitive types (strings, numbers, booleans)", () => { + const primitiveData = { + ...mockFieldsData, + fields: [ + { name: "Year", data_path: "year", data: [2024] }, + { name: "Count", data_path: "count", data: [42] }, + { name: "Is Active", data_path: "active", data: [true] }, + { name: "Name", data_path: "name", data: ["Test"] }, + ], + }; + + const { container } = render(); + + // ClipboardCopy components should be present for all primitive types + const clipboardCopies = container.querySelectorAll( + ".pf-v6-c-clipboard-copy" + ); + // All 4 columns should have ClipboardCopy + expect(clipboardCopies.length).toBe(4); + + // Values should be displayed + expect(screen.getByText("2024")).toBeInTheDocument(); + expect(screen.getByText("42")).toBeInTheDocument(); + expect(screen.getByText("true")).toBeInTheDocument(); + expect(screen.getByText("Test")).toBeInTheDocument(); + }); + + it("should render mixed content - all text data copyable including arrays", () => { + const mixedData = { + ...mockFieldsData, + fields: [ + { name: "User ID", data_path: "user.id", data: ["12345"] }, + { name: "Age", data_path: "user.age", data: [30] }, + { + name: "Email", + data_path: "user.email", + data: ["user@example.com"], + }, + { name: "Status", data_path: "user.status", data: ["Active"] }, + { name: "Tags", data_path: "tags", data: [["tag1", "tag2"]] }, + ], + }; + + const { container } = render(); + + // All columns should have ClipboardCopy (including arrays converted to strings) + // User ID, Age, Email, Status, Tags = 5 copyable columns + const clipboardCopies = container.querySelectorAll( + ".pf-v6-c-clipboard-copy" + ); + expect(clipboardCopies.length).toBe(5); + + // All values should be visible + expect(screen.getByText("30")).toBeInTheDocument(); + expect(screen.getByText("Active")).toBeInTheDocument(); + expect(screen.getByText("tag1, tag2")).toBeInTheDocument(); // Array is displayed joined + }); + + it("should not trigger row click when clicking copy button", () => { + const mockOnRowClick = vitest.fn(); + const copyableData = { + ...mockFieldsData, + fields: [ + { name: "User ID", data_path: "user.id", data: ["12345"] }, + { name: "Name", data_path: "user.name", data: ["John Doe"] }, + ], + }; + + const { container } = render( + + ); + + // Find the copy button + const copyButton = container.querySelector( + ".pf-v6-c-clipboard-copy__group button" + ); + + if (copyButton) { + fireEvent.click(copyButton); + + // onRowClick should NOT be called when clicking the copy button + expect(mockOnRowClick).not.toHaveBeenCalled(); + } + }); + + it("should detect copyable columns based on data type, not column name", () => { + const mixedTypes = { + ...mockFieldsData, + fields: [ + { name: "SomeId", data_path: "id", data: [123] }, // number - copyable + { name: "SomeName", data_path: "name", data: ["john"] }, // string - copyable + { name: "Random", data_path: "random", data: ["test"] }, // string - copyable + { name: "Count", data_path: "count", data: [42] }, // number - copyable + { name: "Items", data_path: "items", data: [["a", "b"]] }, // array - copyable + ], + }; + + const { container } = render(); + + // All columns should have ClipboardCopy (primitives and arrays) + const clipboardCopies = container.querySelectorAll( + ".pf-v6-c-clipboard-copy" + ); + expect(clipboardCopies.length).toBe(5); + }); + + it("should handle multiple rows with copyable primitive columns", () => { + const multiRowCopyable = { + ...mockFieldsData, + fields: [ + { + name: "Email", + data_path: "email", + data: ["user1@test.com", "user2@test.com", "user3@test.com"], + }, + { + name: "Name", + data_path: "name", + data: ["Alice", "Bob", "Charlie"], + }, + { + name: "Age", + data_path: "age", + data: [25, 30, 35], + }, + ], + }; + + const { container } = render(); + + // Should have ClipboardCopy for each cell in primitive columns + const clipboardCopies = container.querySelectorAll( + ".pf-v6-c-clipboard-copy" + ); + // 3 rows * 3 primitive columns = 9 ClipboardCopy components + expect(clipboardCopies.length).toBe(9); + }); + + it("should show ClipboardCopy for array data (displayed as comma-separated strings)", () => { + const arrayData = { + ...mockFieldsData, + fields: [ + { name: "Tags", data_path: "tags", data: [["tag1", "tag2"]] }, + { name: "Name", data_path: "name", data: ["John"] }, + ], + }; + + const { container } = render(); + + // Both columns should have ClipboardCopy (string and array) + const clipboardCopies = container.querySelectorAll( + ".pf-v6-c-clipboard-copy" + ); + expect(clipboardCopies.length).toBe(2); + + // Verify the array is displayed as a joined string + expect(screen.getByText("tag1, tag2")).toBeInTheDocument(); + expect(screen.getByText("John")).toBeInTheDocument(); + }); + }); });