Skip to content
Draft
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
4 changes: 2 additions & 2 deletions app/public/examples/standalone-example.html
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@
<link rel="stylesheet" href="https://unpkg.com/@patternfly/patternfly@6/patternfly.min.css">

<!-- Include RHNGUI component styles -->
<link rel="stylesheet" href="../standalone/rhngui-standalone.css?v=1762380200983">
<link rel="stylesheet" href="../standalone/rhngui-standalone.css?v=1762442020093">

<!-- Include the standalone bundle -->
<!-- Note: Update the ?v= parameter when rebuilding to bust browser cache -->
<script src="../standalone/rhngui-standalone.iife.js?v=1762380200983"></script>
<script src="../standalone/rhngui-standalone.iife.js?v=1762442020093"></script>

<!-- Include example styles -->
<link rel="stylesheet" href="examples.css">
Expand Down
4 changes: 2 additions & 2 deletions app/public/examples/webcomponents-example.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@
<link rel="stylesheet" href="https://unpkg.com/@patternfly/patternfly@6/patternfly.min.css">

<!-- Include RHNGUI component styles -->
<link rel="stylesheet" href="../webcomponents/rhngui-webcomponents.css?v=1762380200983">
<link rel="stylesheet" href="../webcomponents/rhngui-webcomponents.css?v=1762442020093">

<!-- Include the web components bundle -->
<!-- Note: Update the ?v= parameter when rebuilding to bust browser cache -->
<script type="module" src="../webcomponents/rhngui-webcomponents.js?v=1762380200983"></script>
<script type="module" src="../webcomponents/rhngui-webcomponents.js?v=1762442020093"></script>

<!-- Include example styles -->
<link rel="stylesheet" href="examples.css">
Expand Down
27 changes: 27 additions & 0 deletions src/components/TableWrapper.css
Original file line number Diff line number Diff line change
@@ -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;
}
73 changes: 64 additions & 9 deletions src/components/TableWrapper.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Card, CardBody } from "@patternfly/react-core";
import { Card, CardBody, ClipboardCopy } from "@patternfly/react-core";
import {
Table,
Thead,
Expand All @@ -11,6 +11,8 @@ import {

import ErrorPlaceholder from "./ErrorPlaceholder";

import "./TableWrapper.css";

interface FieldData {
name: string;
data_path: string;
Expand All @@ -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<string>() };

// 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++) {
Expand All @@ -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
Expand Down Expand Up @@ -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) => (
<Td key={colIndex}>{row[col.key]}</Td>
))}
{columns.map((col, colIndex) => {
const cellValue = row[col.key];
const isCopyableColumn = copyableColumns.has(col.key);

return (
<Td key={colIndex}>
{isCopyableColumn ? (
<div onClick={(e) => e.stopPropagation()}>
<ClipboardCopy
hoverTip="Copy"
clickTip="Copied"
variant="inline-compact"
className="ngui-clipboard-copy"
>
{String(cellValue)}
</ClipboardCopy>
</div>
) : (
cellValue
)}
</Td>
);
})}
</Tr>
))}
</Tbody>
Expand Down
197 changes: 197 additions & 0 deletions src/test/components/TableWrapper.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<TableWrapper {...copyableData} />);

// 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(<TableWrapper {...primitiveData} />);

// 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(<TableWrapper {...mixedData} />);

// 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(
<TableWrapper {...copyableData} onRowClick={mockOnRowClick} />
);

// 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(<TableWrapper {...mixedTypes} />);

// 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(<TableWrapper {...multiRowCopyable} />);

// 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(<TableWrapper {...arrayData} />);

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