Skip to content

Commit 279b214

Browse files
authored
Merge pull request #7 from RedHat-UX/NGUI-381
feat(NGUI-381): Set of Cards Component
2 parents 8152fc7 + 95fa7a0 commit 279b214

File tree

5 files changed

+320
-5
lines changed

5 files changed

+320
-5
lines changed

README.md

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,14 @@ This npm package provides a collection of reusable Patternfly React components t
1414
- ImageComponent
1515
- TableWrapper
1616
- VideoPlayerWrapper
17+
- SetOfCardsWrapper
18+
1719
* Dynamic Component Renderer
1820
- DynamicComponents
1921
* Supported Components
20-
- `one-card`, `image`, `table`, `video-player`
22+
- `one-card`, `image`, `table`, `video-player`, `set-of-cards`
2123
- `video-player` supports YouTube video URLs and direct video file URLs
24+
- `set-of-cards` displays multiple OneCard components in an auto-aligned grid layout
2225

2326
## Installation
2427

@@ -131,20 +134,61 @@ function App() {
131134
### VideoPlayer Component
132135

133136
```jsx
134-
import { VideoPlayerWrapper } from '@rhngui/patternfly-react-renderer';
137+
import { VideoPlayerWrapper } from "@rhngui/patternfly-react-renderer";
135138

136139
const videoData = {
137140
component: "video-player",
138141
video: "https://www.youtube.com/embed/v-PjgYDrg70",
139142
video_img: "https://img.youtube.com/vi/v-PjgYDrg70/maxresdefault.jpg",
140-
title: "Toy Story Trailer"
143+
title: "Toy Story Trailer",
141144
};
142145

143146
function App() {
144147
return <VideoPlayerWrapper {...videoData} />;
145148
}
146149
```
147150

151+
### SetOfCards Component
152+
153+
```jsx
154+
import { DynamicComponent } from "@rhngui/patternfly-react-renderer";
155+
156+
const setOfCardsConfig = {
157+
component: "set-of-cards",
158+
id: "test-id",
159+
title: "My Favorite Movies",
160+
fields: [
161+
{
162+
data: ["Toy Story", "My Name is Khan"],
163+
data_path: "movie.title",
164+
name: "Title",
165+
},
166+
{
167+
data: [1995, 2003],
168+
data_path: "movie.year",
169+
name: "Year",
170+
},
171+
{
172+
data: [8.3, 8.5],
173+
data_path: "movie.imdbRating",
174+
name: "IMDB Rating",
175+
},
176+
{
177+
data: [
178+
["Jim Varney", "Tim Allen", "Tom Hanks", "Don Rickles"],
179+
["Shah Rukh Khan", "Kajol Devgan"],
180+
],
181+
data_path: "actors[*]",
182+
name: "Actors",
183+
},
184+
],
185+
};
186+
187+
function App() {
188+
return <DynamicComponent config={setOfCardsConfig} />;
189+
}
190+
```
191+
148192
## Links
149193

150194
- [Documentation](https://redhat-ux.github.io/next-gen-ui-agent/guide/renderer/patternfly_npm/)
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import OneCardWrapper from "./OneCardWrapper";
2+
3+
interface FieldData {
4+
name: string;
5+
data_path: string;
6+
data: (string | number | boolean | null | (string | number)[])[];
7+
}
8+
9+
interface SetOfCardsWrapperProps {
10+
component: "set-of-cards";
11+
id: string;
12+
title: string;
13+
fields: FieldData[];
14+
className?: string;
15+
}
16+
17+
const SetOfCardsWrapper = (props: SetOfCardsWrapperProps) => {
18+
const { title, id, fields, className } = props;
19+
20+
// Transform fields data into individual card data
21+
const transformFieldsToCardsData = () => {
22+
if (!fields || fields.length === 0) return [];
23+
24+
// Find the maximum number of data items across all fields
25+
const maxDataLength = Math.max(...fields.map((field) => field.data.length));
26+
27+
// Create individual card data for each row
28+
const cardsData = [];
29+
for (let i = 0; i < maxDataLength; i++) {
30+
const cardFields = fields.map((field) => {
31+
const item = field.data[i];
32+
// If the item is an array, use it directly; otherwise wrap it in an array
33+
const data = Array.isArray(item) ? item : [item];
34+
return {
35+
name: field.name,
36+
data_path: field.data_path,
37+
data: data,
38+
};
39+
});
40+
41+
cardsData.push({
42+
title: `${title} ${i + 1}`,
43+
fields: cardFields,
44+
id: `${id}-card-${i}`,
45+
});
46+
}
47+
48+
return cardsData;
49+
};
50+
51+
const cardsData = transformFieldsToCardsData();
52+
53+
return (
54+
<div id={id} className={`set-of-cards-container ${className || ""}`}>
55+
<h2 className="set-of-cards-title">{title}</h2>
56+
<div className="set-of-cards-grid">
57+
{cardsData.map((cardData, index) => (
58+
<OneCardWrapper
59+
key={index}
60+
{...cardData}
61+
className="set-of-cards-item"
62+
/>
63+
))}
64+
</div>
65+
</div>
66+
);
67+
};
68+
69+
export default SetOfCardsWrapper;

src/constants/componentsMap.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import ImageComponent from "../components/ImageComponent";
22
import OneCardWrapper from "../components/OneCardWrapper";
3+
import SetOfCardsWrapper from "../components/SetOfCardsWrapper";
34
import TableWrapper from "../components/TableWrapper";
45
import VideoPlayerWrapper from "../components/VideoPlayerWrapper";
56

67
export const componentsMap = {
78
"one-card": OneCardWrapper,
8-
"table": TableWrapper,
9-
"image": ImageComponent,
9+
table: TableWrapper,
10+
image: ImageComponent,
1011
"video-player": VideoPlayerWrapper,
12+
"set-of-cards": SetOfCardsWrapper,
1113
};

src/global.css

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,52 @@
9797
object-fit: cover;
9898
}
9999

100+
/* Set of Cards Component Styles */
101+
.set-of-cards-container {
102+
max-width: var(--ngui-container-max-width);
103+
margin: 0 auto;
104+
width: 100%;
105+
padding: var(--ngui-spacing-medium);
106+
}
107+
108+
.set-of-cards-title {
109+
margin-bottom: var(--ngui-spacing-medium);
110+
font-size: var(--pf-global--FontSize--xl);
111+
font-weight: var(--pf-global--FontWeight--bold);
112+
color: var(--pf-global--Color--100);
113+
}
114+
115+
.set-of-cards-grid {
116+
display: grid;
117+
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
118+
gap: var(--ngui-spacing-medium);
119+
align-items: start;
120+
}
121+
122+
.set-of-cards-item {
123+
width: 100%;
124+
height: fit-content;
125+
}
126+
127+
/* Responsive adjustments for set-of-cards */
128+
@media (max-width: 768px) {
129+
.set-of-cards-grid {
130+
grid-template-columns: 1fr;
131+
gap: calc(var(--ngui-spacing-medium) * 0.75);
132+
}
133+
134+
.set-of-cards-container {
135+
padding: calc(var(--ngui-spacing-medium) * 0.75);
136+
}
137+
}
138+
139+
@media (min-width: 1200px) {
140+
.set-of-cards-grid {
141+
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
142+
gap: calc(var(--ngui-spacing-medium) * 1.25);
143+
}
144+
}
145+
100146
/* Common Error Handling Styles */
101147
.error-placeholder {
102148
width: 100%;
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import { render, screen } from "@testing-library/react";
2+
3+
import SetOfCardsWrapper from "../../components/SetOfCardsWrapper";
4+
5+
describe("SetOfCardsWrapper", () => {
6+
const mockProps = {
7+
component: "set-of-cards" as const,
8+
id: "test-set-of-cards",
9+
title: "Test Movies",
10+
fields: [
11+
{
12+
name: "Title",
13+
data_path: "movie.title",
14+
data: ["Toy Story", "Finding Nemo"],
15+
},
16+
{
17+
name: "Year",
18+
data_path: "movie.year",
19+
data: [1995, 2003],
20+
},
21+
{
22+
name: "Rating",
23+
data_path: "movie.rating",
24+
data: [8.3, 8.1],
25+
},
26+
],
27+
};
28+
29+
it("renders the component with correct title", () => {
30+
render(<SetOfCardsWrapper {...mockProps} />);
31+
expect(screen.getByText("Test Movies")).toBeInTheDocument();
32+
});
33+
34+
it("renders the correct number of cards", () => {
35+
render(<SetOfCardsWrapper {...mockProps} />);
36+
// Should render 2 cards based on the data length
37+
expect(screen.getByText("Test Movies 1")).toBeInTheDocument();
38+
expect(screen.getByText("Test Movies 2")).toBeInTheDocument();
39+
});
40+
41+
it("renders cards with correct field data", () => {
42+
render(<SetOfCardsWrapper {...mockProps} />);
43+
44+
// Check first card data
45+
expect(screen.getByText("Toy Story")).toBeInTheDocument();
46+
expect(screen.getByText("1995")).toBeInTheDocument();
47+
expect(screen.getByText("8.3")).toBeInTheDocument();
48+
49+
// Check second card data
50+
expect(screen.getByText("Finding Nemo")).toBeInTheDocument();
51+
expect(screen.getByText("2003")).toBeInTheDocument();
52+
expect(screen.getByText("8.1")).toBeInTheDocument();
53+
});
54+
55+
it("handles empty fields array", () => {
56+
const emptyProps = {
57+
...mockProps,
58+
fields: [],
59+
};
60+
render(<SetOfCardsWrapper {...emptyProps} />);
61+
expect(screen.getByText("Test Movies")).toBeInTheDocument();
62+
// Should not render any cards
63+
expect(screen.queryByText("Test Movies 1")).not.toBeInTheDocument();
64+
});
65+
66+
it("handles fields with different data lengths", () => {
67+
const unevenProps = {
68+
...mockProps,
69+
fields: [
70+
{
71+
name: "Title",
72+
data_path: "movie.title",
73+
data: ["Movie 1", "Movie 2", "Movie 3"],
74+
},
75+
{
76+
name: "Year",
77+
data_path: "movie.year",
78+
data: [1995, 2003], // Shorter array
79+
},
80+
],
81+
};
82+
render(<SetOfCardsWrapper {...unevenProps} />);
83+
84+
// Should render 3 cards based on the longest data array
85+
expect(screen.getByText("Test Movies 1")).toBeInTheDocument();
86+
expect(screen.getByText("Test Movies 2")).toBeInTheDocument();
87+
expect(screen.getByText("Test Movies 3")).toBeInTheDocument();
88+
});
89+
90+
it("handles array data correctly", () => {
91+
const arrayProps = {
92+
...mockProps,
93+
fields: [
94+
{
95+
name: "Actors",
96+
data_path: "movie.actors",
97+
data: [
98+
["Tom Hanks", "Tim Allen"],
99+
["Albert Brooks", "Ellen DeGeneres"],
100+
],
101+
},
102+
],
103+
};
104+
render(<SetOfCardsWrapper {...arrayProps} />);
105+
106+
// Arrays should be joined with commas
107+
expect(screen.getByText("Tom Hanks, Tim Allen")).toBeInTheDocument();
108+
expect(
109+
screen.getByText("Albert Brooks, Ellen DeGeneres")
110+
).toBeInTheDocument();
111+
});
112+
113+
it("handles null/undefined values", () => {
114+
const nullProps = {
115+
...mockProps,
116+
fields: [
117+
{
118+
name: "Title",
119+
data_path: "movie.title",
120+
data: ["Movie 1", null, "Movie 3"],
121+
},
122+
],
123+
};
124+
render(<SetOfCardsWrapper {...nullProps} />);
125+
126+
// Should render 3 cards, with empty content for null value
127+
expect(screen.getByText("Test Movies 1")).toBeInTheDocument();
128+
expect(screen.getByText("Test Movies 2")).toBeInTheDocument();
129+
expect(screen.getByText("Test Movies 3")).toBeInTheDocument();
130+
});
131+
132+
it("applies custom className", () => {
133+
const customProps = {
134+
...mockProps,
135+
className: "custom-class",
136+
};
137+
const { container } = render(<SetOfCardsWrapper {...customProps} />);
138+
expect(container.firstChild).toHaveClass("custom-class");
139+
});
140+
141+
it("renders with correct container structure", () => {
142+
const { container } = render(<SetOfCardsWrapper {...mockProps} />);
143+
144+
// Check container structure
145+
const containerElement = container.querySelector("#test-set-of-cards");
146+
expect(containerElement).toBeInTheDocument();
147+
expect(containerElement).toHaveClass("set-of-cards-container");
148+
149+
// Check grid structure
150+
const gridElement = container.querySelector(".set-of-cards-grid");
151+
expect(gridElement).toBeInTheDocument();
152+
});
153+
});
154+

0 commit comments

Comments
 (0)