Skip to content

Commit dd38aff

Browse files
authored
feat: add ProgramList page (#3)
1 parent 745210b commit dd38aff

17 files changed

Lines changed: 703 additions & 114 deletions

package-lock.json

Lines changed: 63 additions & 71 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
"access": "public"
3030
},
3131
"dependencies": {
32-
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
32+
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.3",
3333
"@edx/frontend-component-footer": "^14.6.0",
3434
"@edx/frontend-component-header": "^6.6.0",
3535
"@edx/frontend-enterprise-hotjar": "7.2.0",
@@ -44,7 +44,7 @@
4444
"@redux-devtools/extension": "3.3.0",
4545
"@reduxjs/toolkit": "^2.0.0",
4646
"classnames": "^2.3.1",
47-
"core-js": "3.45.1",
47+
"core-js": "3.46.0",
4848
"filesize": "^10.0.0",
4949
"font-awesome": "4.7.0",
5050
"history": "5.3.0",
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { render, screen } from '@testing-library/react';
2+
import { getConfig } from '@edx/frontend-platform';
3+
import { IntlProvider } from '@edx/frontend-platform/i18n';
4+
5+
import ExploreProgramsCTA from './ExploreProgramsCTA';
6+
import messages from './messages';
7+
8+
jest.mock('@edx/frontend-platform', () => ({
9+
getConfig: jest.fn(() => ({
10+
LMS_BASE_URL: 'https://courses.example.com',
11+
EXPLORE_PROGRAMS_URL: null, // Default to null for testing fallbacks
12+
})),
13+
}));
14+
15+
describe('ExploreProgramsCTA', () => {
16+
beforeEach(() => {
17+
jest.clearAllMocks();
18+
});
19+
20+
const renderComponent = () => render(
21+
<IntlProvider>
22+
<ExploreProgramsCTA />
23+
</IntlProvider>,
24+
);
25+
26+
it('renders the expected CTA text using i18n', () => {
27+
renderComponent();
28+
29+
expect(screen.getByText(messages.exploreCoursesCTAText.defaultMessage)).toBeInTheDocument();
30+
});
31+
32+
it('renders the button with the expected text using i18n', () => {
33+
renderComponent();
34+
35+
expect(screen.getByRole('link', { name: messages.exploreCoursesCTAButtonText.defaultMessage })).toBeInTheDocument();
36+
});
37+
38+
it('uses EXPLORE_PROGRAMS_URL when it is defined', () => {
39+
const customUrl = 'https://custom.explore.url/programs';
40+
getConfig.mockReturnValueOnce({
41+
LMS_BASE_URL: 'https://courses.example.com',
42+
EXPLORE_PROGRAMS_URL: customUrl,
43+
});
44+
45+
renderComponent();
46+
47+
const button = screen.getByRole('link', { name: messages.exploreCoursesCTAButtonText.defaultMessage });
48+
expect(button).toHaveAttribute('href', customUrl);
49+
});
50+
51+
it('falls back to LMS_BASE_URL/courses when EXPLORE_PROGRAMS_URL is not defined', () => {
52+
renderComponent();
53+
54+
const button = screen.getByRole('link', { name: messages.exploreCoursesCTAButtonText.defaultMessage });
55+
const expectedFallbackUrl = `${getConfig().LMS_BASE_URL}/courses`;
56+
expect(button).toHaveAttribute('href', expectedFallbackUrl);
57+
});
58+
});
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import React from 'react';
2+
import { getConfig } from '@edx/frontend-platform';
3+
import { useIntl } from '@edx/frontend-platform/i18n';
4+
import { Card, Button } from '@openedx/paragon';
5+
import { Search } from '@openedx/paragon/icons';
6+
import messages from './messages';
7+
8+
const ExploreProgramsCTA: React.FC = () => {
9+
const { formatMessage } = useIntl();
10+
11+
const href = getConfig().EXPLORE_PROGRAMS_URL || `${getConfig().LMS_BASE_URL}/courses`;
12+
return (
13+
<Card>
14+
<Card.Section>
15+
{formatMessage(messages.exploreCoursesCTAText)}
16+
</Card.Section>
17+
<Card.Footer className="justify-content-center">
18+
<Button
19+
as="a"
20+
href={href}
21+
iconBefore={Search}
22+
>
23+
{formatMessage(messages.exploreCoursesCTAButtonText)}
24+
</Button>
25+
</Card.Footer>
26+
</Card>
27+
);
28+
};
29+
30+
export default ExploreProgramsCTA;
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { render, screen } from '@testing-library/react';
2+
import { IntlProvider } from '@edx/frontend-platform/i18n';
3+
import ProgramListCard from './ProgramListCard';
4+
import { ProgramData } from '../data/types';
5+
6+
jest.mock('react-router-dom', () => ({
7+
Link: jest.fn(({ children, ...props }) => <a {...props}>{children}</a>),
8+
}));
9+
10+
const mockBaseProgram = {
11+
uuid: 'test-uuid',
12+
title: 'test-title',
13+
type: 'test-type',
14+
bannerImage: {
15+
xSmall: { url: 'banner-xSmall.jpg', width: 348, height: 116 },
16+
small: { url: 'banner-small.jpg', width: 435, height: 145 },
17+
medium: { url: 'banner-medium.jpg', width: 726, height: 242 },
18+
large: { url: 'banner-large.jpg', width: 1440, height: 480 },
19+
},
20+
authoringOrganizations: [
21+
{ key: 'test-key', logoImageUrl: 'test-logo.png' },
22+
],
23+
progress: {
24+
inProgress: 1,
25+
notStarted: 2,
26+
completed: 3,
27+
},
28+
};
29+
30+
const mockMultipleOrgProgram = {
31+
...mockBaseProgram,
32+
authoringOrganizations: [
33+
{ key: 'MIT', logoImageUrl: 'mit-logo.png' },
34+
{ key: 'HU', logoImageUrl: 'harvard-logo.png' },
35+
],
36+
};
37+
38+
describe('ProgramListCard', () => {
39+
const renderComponent = (programData: ProgramData = mockBaseProgram) => render(
40+
<IntlProvider>
41+
<ProgramListCard program={programData} />
42+
</IntlProvider>,
43+
);
44+
45+
it('renders all data for program', () => {
46+
renderComponent();
47+
expect(screen.getByText(mockBaseProgram.title)).toBeInTheDocument();
48+
expect(screen.getByText(mockBaseProgram.type)).toBeInTheDocument();
49+
expect(screen.getByText(mockBaseProgram.authoringOrganizations[0].key)).toBeInTheDocument();
50+
const logoImageNode = screen.getByAltText(mockBaseProgram.authoringOrganizations[0].key);
51+
expect(logoImageNode).toHaveAttribute('src', mockBaseProgram.authoringOrganizations[0].logoImageUrl);
52+
expect(screen.getByText(mockBaseProgram.progress.inProgress)).toBeInTheDocument();
53+
expect(screen.getByText('In progress')).toBeInTheDocument();
54+
expect(screen.getByText(mockBaseProgram.progress.completed)).toBeInTheDocument();
55+
expect(screen.getByText('Completed')).toBeInTheDocument();
56+
expect(screen.getByText(mockBaseProgram.progress.notStarted)).toBeInTheDocument();
57+
expect(screen.getByText('Remaining')).toBeInTheDocument();
58+
});
59+
60+
it('renders names of all organizations when more than one', () => {
61+
renderComponent(mockMultipleOrgProgram);
62+
const aggregatedOrganizations = mockMultipleOrgProgram.authoringOrganizations.map(org => org.key).join(', ');
63+
expect(screen.getByText(aggregatedOrganizations)).toBeInTheDocument();
64+
});
65+
66+
it('doesnt render logo of organizations when more than one', () => {
67+
const { queryByAltText } = renderComponent(mockMultipleOrgProgram);
68+
const logoImageNode = queryByAltText(mockMultipleOrgProgram.authoringOrganizations[0].key);
69+
expect(logoImageNode).toBeNull();
70+
});
71+
72+
it('each card links to a progress page using the program uuid', async () => {
73+
const { getByTestId } = renderComponent();
74+
const programCard = getByTestId('program-list-card');
75+
expect(programCard).toHaveAttribute('to', 'test-uuid');
76+
});
77+
78+
it.each([{
79+
width: 1450,
80+
size: 'large',
81+
},
82+
{
83+
width: 1300,
84+
size: 'large',
85+
},
86+
{
87+
width: 1000,
88+
size: 'large',
89+
},
90+
{
91+
width: 800,
92+
size: 'medium',
93+
},
94+
{
95+
width: 600,
96+
size: 'small',
97+
},
98+
{
99+
width: 500,
100+
size: 'xSmall',
101+
}])('tests window size', ({ width, size }) => {
102+
global.innerWidth = width;
103+
const { getByAltText } = renderComponent();
104+
const imageCap = getByAltText('program card image for test-title');
105+
expect(imageCap).toHaveAttribute('src', `banner-${size}.jpg`);
106+
});
107+
});
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import React, { useState, useEffect } from 'react';
2+
import { Link } from 'react-router-dom';
3+
import cardFallbackImg from '@edx/brand/paragon/images/card-imagecap-fallback.png';
4+
import {
5+
breakpoints,
6+
Card,
7+
Row,
8+
} from '@openedx/paragon';
9+
import { ProgramCardProps, AuthoringOrganization } from '../data/types';
10+
import ProgressCategoryBubbles from './ProgressCategoryBubbles';
11+
12+
const ProgramListCard: React.FC<ProgramCardProps> = ({
13+
program,
14+
}) => {
15+
const [windowWidth, setWindowWidth] = useState(window.innerWidth);
16+
17+
useEffect(() => {
18+
const handleWindowResize = () => {
19+
setWindowWidth(window.innerWidth);
20+
};
21+
22+
window.addEventListener('resize', handleWindowResize);
23+
24+
return () => {
25+
window.removeEventListener('resize', handleWindowResize);
26+
};
27+
}, []);
28+
29+
const getBannerImageURL = () => {
30+
let imageURL = '';
31+
// We need to check that the breakpoint value exists before using it
32+
// Otherwise TypeScript will flag it as it can potentially be undefined in Paragon
33+
if (typeof breakpoints.large.minWidth === 'number' && windowWidth >= breakpoints.large.minWidth) {
34+
imageURL = program.bannerImage.large.url;
35+
} else if (typeof breakpoints.medium.minWidth === 'number' && windowWidth >= breakpoints.medium.minWidth) {
36+
imageURL = program.bannerImage.medium.url;
37+
} else if (typeof breakpoints.small.minWidth === 'number' && windowWidth >= breakpoints.small.minWidth) {
38+
imageURL = program.bannerImage.small.url;
39+
} else {
40+
imageURL = program.bannerImage.xSmall.url;
41+
}
42+
return imageURL;
43+
};
44+
45+
// Set key and logoImageUrl to empty strings for fallback image or instances where there are multiple organizations
46+
let authoringOrganization : AuthoringOrganization = {
47+
key: '',
48+
logoImageUrl: '',
49+
};
50+
// Otherwise use the logoImageUrl and key for the organization
51+
if (program.authoringOrganizations?.length === 1 && program.authoringOrganizations[0].logoImageUrl) {
52+
authoringOrganization = {
53+
logoImageUrl: program.authoringOrganizations[0].logoImageUrl,
54+
key: program.authoringOrganizations[0].key,
55+
};
56+
}
57+
58+
return (
59+
<Card
60+
className="program-list-card"
61+
isClickable
62+
as={Link}
63+
to={program.uuid}
64+
data-testid="program-list-card"
65+
>
66+
<Card.ImageCap
67+
src={getBannerImageURL() || cardFallbackImg}
68+
srcAlt={`program card image for ${program.title}`}
69+
fallbackSrc={cardFallbackImg}
70+
logoSrc={authoringOrganization?.logoImageUrl}
71+
logoAlt={authoringOrganization?.key}
72+
className="banner-image"
73+
/>
74+
<Card.Section className="pb-0 small">
75+
<Row className="justify-content-between px-2.5">
76+
{program.authoringOrganizations && (
77+
<p className="truncate-text-1">
78+
{program.authoringOrganizations.map(org => org.key).join(', ')}
79+
</p>
80+
)}
81+
<p>
82+
{program.type}
83+
</p>
84+
</Row>
85+
</Card.Section>
86+
<Card.Section>
87+
<h3 className="truncate-text-2">{program.title}</h3>
88+
</Card.Section>
89+
<Card.Section>
90+
<ProgressCategoryBubbles
91+
inProgress={program.progress.inProgress}
92+
notStarted={program.progress.notStarted}
93+
completed={program.progress.completed}
94+
/>
95+
</Card.Section>
96+
</Card>
97+
);
98+
};
99+
100+
export default ProgramListCard;
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { render, screen } from '@testing-library/react';
2+
import { IntlProvider } from '@edx/frontend-platform/i18n';
3+
4+
import ProgressCategoryBubbles from './ProgressCategoryBubbles';
5+
6+
describe('ProgressCategoryBubbles', () => {
7+
it('renders the correct values for each category', () => {
8+
render(
9+
<IntlProvider>
10+
<ProgressCategoryBubbles inProgress={1} notStarted={2} completed={0} />
11+
</IntlProvider>,
12+
);
13+
14+
expect(screen.getByTestId('completed-count')).toHaveTextContent('0');
15+
expect(screen.getByTestId('in-progress-count')).toHaveTextContent('1');
16+
expect(screen.getByTestId('remaining-count')).toHaveTextContent('2');
17+
});
18+
});
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import React from 'react';
2+
import { Bubble, Stack } from '@openedx/paragon';
3+
import { useIntl } from '@edx/frontend-platform/i18n';
4+
import messages from './messages';
5+
6+
import { Progress } from '../data/types';
7+
8+
const ProgressCategoryBubbles: React.FC<Progress> = ({ notStarted, inProgress, completed }) => {
9+
const { formatMessage } = useIntl();
10+
return (
11+
<Stack direction="horizontal" gap={2} className="flex-wrap">
12+
<Stack direction="vertical" className="align-items-center" gap={1}>
13+
<Bubble variant="success" data-testid="completed-count">
14+
{completed}
15+
</Bubble>
16+
<div>
17+
{formatMessage(messages.progressCategoryBubblesSuccess)}
18+
</div>
19+
</Stack>
20+
21+
<Stack direction="vertical" className="align-items-center" gap={1}>
22+
<Bubble data-testid="in-progress-count">
23+
{inProgress}
24+
</Bubble>
25+
<div>
26+
{formatMessage(messages.progressCategoryBubblesInProgress)}
27+
</div>
28+
</Stack>
29+
30+
<Stack direction="vertical" className="align-items-center" gap={1}>
31+
<Bubble className="text-gray-900" variant="warning" data-testid="remaining-count">
32+
{notStarted}
33+
</Bubble>
34+
<div>
35+
{formatMessage(messages.progressCategoryBubblesRemaining)}
36+
</div>
37+
</Stack>
38+
</Stack>
39+
);
40+
};
41+
42+
export default ProgressCategoryBubbles;
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// The current Truncate component in Paragon is deprecated and soon to be removed
2+
// See https://github.com/openedx/paragon/issues/3311 for developments on this issue
3+
4+
.truncate-text-1 {
5+
overflow: hidden;
6+
display: -webkit-box;
7+
-webkit-box-orient: vertical;
8+
-webkit-line-clamp: 1;
9+
}
10+
11+
.truncate-text-2 {
12+
overflow: hidden;
13+
display: -webkit-box;
14+
-webkit-box-orient: vertical;
15+
-webkit-line-clamp: 2;
16+
}

0 commit comments

Comments
 (0)