Skip to content
Merged
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
44 changes: 42 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,6 @@ This npm package provides a collection of reusable Patternfly React components t
- ImageComponent
- OneCardWrapper
- TableWrapper
- Dynamic Component Renderer
- DynamicComponents

## Installation

Expand Down Expand Up @@ -82,6 +80,48 @@ function App() {
}
```

### Table Component

```jsx
import { DynamicComponent } from "@rhngui/patternfly-react-renderer";

const tableConfig = {
component: "table",
title: "Movie Statistics",
id: "movie-stats-table",
fields: [
{
name: "Movie Title",
data_path: "movies.title",
data: ["Toy Story", "Finding Nemo", "The Incredibles"],
},
{
name: "Release Year",
data_path: "movies.year",
data: [1995, 2003, 2004],
},
{
name: "Genres",
data_path: "movies.genres",
data: [
["Animation", "Adventure"],
["Animation", "Adventure"],
["Animation", "Action"],
],
},
{
name: "Rating",
data_path: "movies.rating",
data: [8.3, 8.1, 8.0],
},
],
};

function App() {
return <DynamicComponent config={tableConfig} />;
}
```

## Links

- [Documentation](https://redhat-ux.github.io/next-gen-ui-agent/guide/renderer/patternfly_npm/)
Expand Down
172 changes: 66 additions & 106 deletions src/components/TableWrapper.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,4 @@
import {
Chart,
ChartBar,
ChartAxis,
ChartThemeColor,
} from "@patternfly/react-charts/victory";
import { Card, CardBody } from "@patternfly/react-core";
import {
Table,
Thead,
Expand All @@ -13,118 +8,83 @@ import {
Td,
Caption,
} from "@patternfly/react-table";
import { useState } from "react";

const TableWrapper = ({
columns,
rows,
caption,
variant,
graph,
selectable = false,
onRowSelect,
actions,
setCustomData,
}) => {
const [selectedRows, setSelectedRows] = useState([]);
interface FieldData {
name: string;
data_path: string;
data: (string | number | boolean | null | (string | number)[])[];
}

const toggleRow = (index) => {
const newSelected = selectedRows.includes(index)
? selectedRows.filter((i) => i !== index)
: [...selectedRows, index];
setSelectedRows(newSelected);
interface TableWrapperProps {
component: "table";
title: string;
id: string;
fields: FieldData[];
className?: string;
}

onRowSelect?.(newSelected.map((i) => rows[i]));
setCustomData(newSelected.map((i) => rows[i]));
};
const TableWrapper = (props: TableWrapperProps) => {
const { title, id, fields, className } = props;
// Transform fields data into table format
const transformFieldsToTableData = () => {
if (!fields || fields.length === 0) return { columns: [], rows: [] };

// 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
const transformedColumns = fields.map((field) => ({
key: field.name,
label: field.name,
}));

const toggleAllRows = () => {
const allSelected = selectedRows.length === rows.length;
const newSelected = allSelected ? [] : rows.map((_, i) => i);
setSelectedRows(newSelected);
// Create rows based on the maximum data length
const transformedRows = [];
for (let i = 0; i < maxDataLength; i++) {
const row: Record<string, string | number | null> = {};
fields.forEach((field) => {
const value = field.data[i];
if (value === null || value === undefined) {
row[field.name] = "";
} else if (Array.isArray(value)) {
row[field.name] = value.join(", ");
} else {
row[field.name] = String(value);
}
});
transformedRows.push(row);
}

onRowSelect?.(newSelected.map((i) => rows[i]));
setCustomData(newSelected.map((i) => rows[i]));
return { columns: transformedColumns, rows: transformedRows };
};

const graphData =
graph && graph.column
? rows.map((row) => ({ x: row[columns[0].key], y: row[graph.column] }))
: [];
const { columns, rows } = transformFieldsToTableData();

return (
<div>
<Table variant={variant} borders={variant !== "compactBorderless"}>
{caption && (
<Caption>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<span>{caption}</span>
{actions}
</div>
</Caption>
)}
<Thead>
<Tr>
{selectable && (
<Th>
<input
type="checkbox"
aria-label="Select all rows"
checked={selectedRows.length === rows.length}
onChange={toggleAllRows}
/>
</Th>
)}
{columns.map((col, index) => (
<Th key={index}>{col.label}</Th>
))}
</Tr>
</Thead>
<Tbody>
{rows.map((row, rowIndex) => (
<Tr key={rowIndex} data-testid={`row-${row.id ?? rowIndex}`}>
{selectable && (
<Td>
<input
type="checkbox"
aria-label={`Select row ${rowIndex}`}
checked={selectedRows.includes(rowIndex)}
onChange={() => toggleRow(rowIndex)}
/>
</Td>
)}
{columns.map((col, colIndex) => (
<Td key={colIndex}>{row[col.key]}</Td>
<Card id={id} className={className}>
<CardBody>
<Table variant="compact" borders>
<Caption>{title}</Caption>
<Thead>
<Tr>
{columns.map((col, index) => (
<Th key={index}>{col.label}</Th>
))}
</Tr>
))}
</Tbody>
</Table>

{graph && (
<div style={{ height: "300px" }}>
<Chart
ariaTitle={graph.title || "Chart"}
domainPadding={{ x: [30, 25] }}
height={300}
width={600}
themeColor={ChartThemeColor.multiUnordered}
>
<ChartAxis />
<ChartAxis dependentAxis />
<ChartBar data={graphData} barWidth={30} />
</Chart>
</div>
)}
</div>
</Thead>
<Tbody>
{rows.map((row, rowIndex) => (
<Tr key={rowIndex} data-testid={`row-${rowIndex}`}>
{columns.map((col, colIndex) => (
<Td key={colIndex}>{row[col.key]}</Td>
))}
</Tr>
))}
</Tbody>
</Table>
</CardBody>
</Card>
);
};

export default TableWrapper;

80 changes: 80 additions & 0 deletions src/test/components/DynamicComponent.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { render, screen } from "@testing-library/react";
import "@testing-library/jest-dom";

import DynamicComponent from "../../components/DynamicComponents";

describe("DynamicComponent", () => {
it("should render table component with fields", () => {
const tableConfig = {
component: "table",
title: "Test Table",
id: "test-table-id",
fields: [
{
name: "Name",
data_path: "user.name",
data: ["John Doe", "Jane Smith"],
},
{
name: "Age",
data_path: "user.age",
data: [28, 34],
},
],
};

render(<DynamicComponent config={tableConfig} />);

// Check that the title appears in the table caption
const tableCaption = screen.getByRole("grid").querySelector("caption");
expect(tableCaption).toHaveTextContent("Test Table");

expect(screen.getByText("Name")).toBeInTheDocument();
expect(screen.getByText("Age")).toBeInTheDocument();
expect(screen.getByText("John Doe")).toBeInTheDocument();
expect(screen.getByText("Jane Smith")).toBeInTheDocument();
expect(screen.getByText("28")).toBeInTheDocument();
expect(screen.getByText("34")).toBeInTheDocument();
});

it("should render one-card component", () => {
const oneCardConfig = {
component: "one-card",
title: "Test Card",
fields: [
{
name: "Field 1",
data_path: "test.field1",
data: ["Value 1"],
},
],
};

render(<DynamicComponent config={oneCardConfig} />);

expect(screen.getByText("Test Card")).toBeInTheDocument();
expect(screen.getByText("Field 1")).toBeInTheDocument();
expect(screen.getByText("Value 1")).toBeInTheDocument();
});

it("should handle empty config", () => {
const { container } = render(<DynamicComponent config={{}} />);
expect(container.firstChild).toBeNull();
});

it("should handle null config", () => {
const { container } = render(<DynamicComponent config={null} />);
expect(container.firstChild).toBeNull();
});

it("should handle unknown component", () => {
const unknownConfig = {
component: "unknown-component",
title: "Unknown",
};

render(<DynamicComponent config={unknownConfig} />);
// Should render FragmentWrapper (empty fragment)
expect(screen.queryByText("Unknown")).not.toBeInTheDocument();
});
});
Loading