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
50 changes: 47 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,14 @@ This npm package provides a collection of reusable Patternfly React components t
- ImageComponent
- TableWrapper
- VideoPlayerWrapper
- SetOfCardsWrapper

* Dynamic Component Renderer
- DynamicComponents
* Supported Components
- `one-card`, `image`, `table`, `video-player`
- `one-card`, `image`, `table`, `video-player`, `set-of-cards`
- `video-player` supports YouTube video URLs and direct video file URLs
- `set-of-cards` displays multiple OneCard components in an auto-aligned grid layout

## Installation

Expand Down Expand Up @@ -131,20 +134,61 @@ function App() {
### VideoPlayer Component

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

const videoData = {
component: "video-player",
video: "https://www.youtube.com/embed/v-PjgYDrg70",
video_img: "https://img.youtube.com/vi/v-PjgYDrg70/maxresdefault.jpg",
title: "Toy Story Trailer"
title: "Toy Story Trailer",
};

function App() {
return <VideoPlayerWrapper {...videoData} />;
}
```

### SetOfCards Component

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

const setOfCardsConfig = {
component: "set-of-cards",
id: "test-id",
title: "My Favorite Movies",
fields: [
{
data: ["Toy Story", "My Name is Khan"],
data_path: "movie.title",
name: "Title",
},
{
data: [1995, 2003],
data_path: "movie.year",
name: "Year",
},
{
data: [8.3, 8.5],
data_path: "movie.imdbRating",
name: "IMDB Rating",
},
{
data: [
["Jim Varney", "Tim Allen", "Tom Hanks", "Don Rickles"],
["Shah Rukh Khan", "Kajol Devgan"],
],
data_path: "actors[*]",
name: "Actors",
},
],
};

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

## Links

- [Documentation](https://redhat-ux.github.io/next-gen-ui-agent/guide/renderer/patternfly_npm/)
Expand Down
69 changes: 69 additions & 0 deletions src/components/SetOfCardsWrapper.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import OneCardWrapper from "./OneCardWrapper";

interface FieldData {
name: string;
data_path: string;
data: (string | number | boolean | null | (string | number)[])[];
}

interface SetOfCardsWrapperProps {
component: "set-of-cards";
id: string;
title: string;
fields: FieldData[];
className?: string;
}

const SetOfCardsWrapper = (props: SetOfCardsWrapperProps) => {
const { title, id, fields, className } = props;

// Transform fields data into individual card data
const transformFieldsToCardsData = () => {
if (!fields || fields.length === 0) return [];

// Find the maximum number of data items across all fields
const maxDataLength = Math.max(...fields.map((field) => field.data.length));

// Create individual card data for each row
const cardsData = [];
for (let i = 0; i < maxDataLength; i++) {
const cardFields = fields.map((field) => {
const item = field.data[i];
// If the item is an array, use it directly; otherwise wrap it in an array
const data = Array.isArray(item) ? item : [item];
return {
name: field.name,
data_path: field.data_path,
data: data,
};
});

cardsData.push({
title: `${title} ${i + 1}`,
fields: cardFields,
id: `${id}-card-${i}`,
});
}

return cardsData;
};

const cardsData = transformFieldsToCardsData();

return (
<div id={id} className={`set-of-cards-container ${className || ""}`}>
<h2 className="set-of-cards-title">{title}</h2>
<div className="set-of-cards-grid">
{cardsData.map((cardData, index) => (
<OneCardWrapper
key={index}
{...cardData}
className="set-of-cards-item"
/>
))}
</div>
</div>
);
};

export default SetOfCardsWrapper;
6 changes: 4 additions & 2 deletions src/constants/componentsMap.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import ImageComponent from "../components/ImageComponent";
import OneCardWrapper from "../components/OneCardWrapper";
import SetOfCardsWrapper from "../components/SetOfCardsWrapper";
import TableWrapper from "../components/TableWrapper";
import VideoPlayerWrapper from "../components/VideoPlayerWrapper";

export const componentsMap = {
"one-card": OneCardWrapper,
"table": TableWrapper,
"image": ImageComponent,
table: TableWrapper,
image: ImageComponent,
"video-player": VideoPlayerWrapper,
"set-of-cards": SetOfCardsWrapper,
};
46 changes: 46 additions & 0 deletions src/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,52 @@
object-fit: cover;
}

/* Set of Cards Component Styles */
.set-of-cards-container {
max-width: var(--ngui-container-max-width);
margin: 0 auto;
width: 100%;
padding: var(--ngui-spacing-medium);
}

.set-of-cards-title {
margin-bottom: var(--ngui-spacing-medium);
font-size: var(--pf-global--FontSize--xl);
font-weight: var(--pf-global--FontWeight--bold);
color: var(--pf-global--Color--100);
}

.set-of-cards-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: var(--ngui-spacing-medium);
align-items: start;
}

.set-of-cards-item {
width: 100%;
height: fit-content;
}

/* Responsive adjustments for set-of-cards */
@media (max-width: 768px) {
.set-of-cards-grid {
grid-template-columns: 1fr;
gap: calc(var(--ngui-spacing-medium) * 0.75);
}

.set-of-cards-container {
padding: calc(var(--ngui-spacing-medium) * 0.75);
}
}

@media (min-width: 1200px) {
.set-of-cards-grid {
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: calc(var(--ngui-spacing-medium) * 1.25);
}
}

/* Common Error Handling Styles */
.error-placeholder {
width: 100%;
Expand Down
154 changes: 154 additions & 0 deletions src/test/components/SetOfCardsWrapper.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { render, screen } from "@testing-library/react";

import SetOfCardsWrapper from "../../components/SetOfCardsWrapper";

describe("SetOfCardsWrapper", () => {
const mockProps = {
component: "set-of-cards" as const,
id: "test-set-of-cards",
title: "Test Movies",
fields: [
{
name: "Title",
data_path: "movie.title",
data: ["Toy Story", "Finding Nemo"],
},
{
name: "Year",
data_path: "movie.year",
data: [1995, 2003],
},
{
name: "Rating",
data_path: "movie.rating",
data: [8.3, 8.1],
},
],
};

it("renders the component with correct title", () => {
render(<SetOfCardsWrapper {...mockProps} />);
expect(screen.getByText("Test Movies")).toBeInTheDocument();
});

it("renders the correct number of cards", () => {
render(<SetOfCardsWrapper {...mockProps} />);
// Should render 2 cards based on the data length
expect(screen.getByText("Test Movies 1")).toBeInTheDocument();
expect(screen.getByText("Test Movies 2")).toBeInTheDocument();
});

it("renders cards with correct field data", () => {
render(<SetOfCardsWrapper {...mockProps} />);

// Check first card data
expect(screen.getByText("Toy Story")).toBeInTheDocument();
expect(screen.getByText("1995")).toBeInTheDocument();
expect(screen.getByText("8.3")).toBeInTheDocument();

// Check second card data
expect(screen.getByText("Finding Nemo")).toBeInTheDocument();
expect(screen.getByText("2003")).toBeInTheDocument();
expect(screen.getByText("8.1")).toBeInTheDocument();
});

it("handles empty fields array", () => {
const emptyProps = {
...mockProps,
fields: [],
};
render(<SetOfCardsWrapper {...emptyProps} />);
expect(screen.getByText("Test Movies")).toBeInTheDocument();
// Should not render any cards
expect(screen.queryByText("Test Movies 1")).not.toBeInTheDocument();
});

it("handles fields with different data lengths", () => {
const unevenProps = {
...mockProps,
fields: [
{
name: "Title",
data_path: "movie.title",
data: ["Movie 1", "Movie 2", "Movie 3"],
},
{
name: "Year",
data_path: "movie.year",
data: [1995, 2003], // Shorter array
},
],
};
render(<SetOfCardsWrapper {...unevenProps} />);

// Should render 3 cards based on the longest data array
expect(screen.getByText("Test Movies 1")).toBeInTheDocument();
expect(screen.getByText("Test Movies 2")).toBeInTheDocument();
expect(screen.getByText("Test Movies 3")).toBeInTheDocument();
});

it("handles array data correctly", () => {
const arrayProps = {
...mockProps,
fields: [
{
name: "Actors",
data_path: "movie.actors",
data: [
["Tom Hanks", "Tim Allen"],
["Albert Brooks", "Ellen DeGeneres"],
],
},
],
};
render(<SetOfCardsWrapper {...arrayProps} />);

// Arrays should be joined with commas
expect(screen.getByText("Tom Hanks, Tim Allen")).toBeInTheDocument();
expect(
screen.getByText("Albert Brooks, Ellen DeGeneres")
).toBeInTheDocument();
});

it("handles null/undefined values", () => {
const nullProps = {
...mockProps,
fields: [
{
name: "Title",
data_path: "movie.title",
data: ["Movie 1", null, "Movie 3"],
},
],
};
render(<SetOfCardsWrapper {...nullProps} />);

// Should render 3 cards, with empty content for null value
expect(screen.getByText("Test Movies 1")).toBeInTheDocument();
expect(screen.getByText("Test Movies 2")).toBeInTheDocument();
expect(screen.getByText("Test Movies 3")).toBeInTheDocument();
});

it("applies custom className", () => {
const customProps = {
...mockProps,
className: "custom-class",
};
const { container } = render(<SetOfCardsWrapper {...customProps} />);
expect(container.firstChild).toHaveClass("custom-class");
});

it("renders with correct container structure", () => {
const { container } = render(<SetOfCardsWrapper {...mockProps} />);

// Check container structure
const containerElement = container.querySelector("#test-set-of-cards");
expect(containerElement).toBeInTheDocument();
expect(containerElement).toHaveClass("set-of-cards-container");

// Check grid structure
const gridElement = container.querySelector(".set-of-cards-grid");
expect(gridElement).toBeInTheDocument();
});
});