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
134 changes: 63 additions & 71 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
"access": "public"
},
"dependencies": {
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.3",
"@edx/frontend-component-footer": "^14.6.0",
"@edx/frontend-component-header": "^6.6.0",
"@edx/frontend-enterprise-hotjar": "7.2.0",
Expand All @@ -44,7 +44,7 @@
"@redux-devtools/extension": "3.3.0",
"@reduxjs/toolkit": "^2.0.0",
"classnames": "^2.3.1",
"core-js": "3.45.1",
"core-js": "3.46.0",
"filesize": "^10.0.0",
"font-awesome": "4.7.0",
"history": "5.3.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { render, screen } from '@testing-library/react';
import { getConfig } from '@edx/frontend-platform';
import { IntlProvider } from '@edx/frontend-platform/i18n';

import ExploreProgramsCTA from './ExploreProgramsCTA';
import messages from './messages';

jest.mock('@edx/frontend-platform', () => ({
getConfig: jest.fn(() => ({
LMS_BASE_URL: 'https://courses.example.com',
EXPLORE_PROGRAMS_URL: null, // Default to null for testing fallbacks
})),
}));

describe('ExploreProgramsCTA', () => {
beforeEach(() => {
jest.clearAllMocks();
});

const renderComponent = () => render(
<IntlProvider>
<ExploreProgramsCTA />
</IntlProvider>,
);

it('renders the expected CTA text using i18n', () => {
renderComponent();

expect(screen.getByText(messages.exploreCoursesCTAText.defaultMessage)).toBeInTheDocument();
});

it('renders the button with the expected text using i18n', () => {
renderComponent();

expect(screen.getByRole('link', { name: messages.exploreCoursesCTAButtonText.defaultMessage })).toBeInTheDocument();
});

it('uses EXPLORE_PROGRAMS_URL when it is defined', () => {
const customUrl = 'https://custom.explore.url/programs';
getConfig.mockReturnValueOnce({
LMS_BASE_URL: 'https://courses.example.com',
EXPLORE_PROGRAMS_URL: customUrl,
});

renderComponent();

const button = screen.getByRole('link', { name: messages.exploreCoursesCTAButtonText.defaultMessage });
expect(button).toHaveAttribute('href', customUrl);
});

it('falls back to LMS_BASE_URL/courses when EXPLORE_PROGRAMS_URL is not defined', () => {
renderComponent();

const button = screen.getByRole('link', { name: messages.exploreCoursesCTAButtonText.defaultMessage });
const expectedFallbackUrl = `${getConfig().LMS_BASE_URL}/courses`;
expect(button).toHaveAttribute('href', expectedFallbackUrl);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Card, Button } from '@openedx/paragon';
import { Search } from '@openedx/paragon/icons';
import messages from './messages';

const ExploreProgramsCTA: React.FC = () => {
const { formatMessage } = useIntl();

const href = getConfig().EXPLORE_PROGRAMS_URL || `${getConfig().LMS_BASE_URL}/courses`;
return (
<Card>
<Card.Section>
{formatMessage(messages.exploreCoursesCTAText)}
</Card.Section>
<Card.Footer className="justify-content-center">
<Button
as="a"
href={href}
iconBefore={Search}
>
{formatMessage(messages.exploreCoursesCTAButtonText)}
</Button>
</Card.Footer>
</Card>
);
};

export default ExploreProgramsCTA;
107 changes: 107 additions & 0 deletions src/containers/ProgramDashboard/ProgramsList/ProgramListCard.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { render, screen } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import ProgramListCard from './ProgramListCard';
import { ProgramData } from '../data/types';

jest.mock('react-router-dom', () => ({
Link: jest.fn(({ children, ...props }) => <a {...props}>{children}</a>),
}));

const mockBaseProgram = {
uuid: 'test-uuid',
title: 'test-title',
type: 'test-type',
bannerImage: {
xSmall: { url: 'banner-xSmall.jpg', width: 348, height: 116 },
small: { url: 'banner-small.jpg', width: 435, height: 145 },
medium: { url: 'banner-medium.jpg', width: 726, height: 242 },
large: { url: 'banner-large.jpg', width: 1440, height: 480 },
},
authoringOrganizations: [
{ key: 'test-key', logoImageUrl: 'test-logo.png' },
],
progress: {
inProgress: 1,
notStarted: 2,
completed: 3,
},
};

const mockMultipleOrgProgram = {
...mockBaseProgram,
authoringOrganizations: [
{ key: 'MIT', logoImageUrl: 'mit-logo.png' },
{ key: 'HU', logoImageUrl: 'harvard-logo.png' },
],
};

describe('ProgramListCard', () => {
const renderComponent = (programData: ProgramData = mockBaseProgram) => render(
<IntlProvider>
<ProgramListCard program={programData} />
</IntlProvider>,
);

it('renders all data for program', () => {
renderComponent();
expect(screen.getByText(mockBaseProgram.title)).toBeInTheDocument();
expect(screen.getByText(mockBaseProgram.type)).toBeInTheDocument();
expect(screen.getByText(mockBaseProgram.authoringOrganizations[0].key)).toBeInTheDocument();
const logoImageNode = screen.getByAltText(mockBaseProgram.authoringOrganizations[0].key);
expect(logoImageNode).toHaveAttribute('src', mockBaseProgram.authoringOrganizations[0].logoImageUrl);
expect(screen.getByText(mockBaseProgram.progress.inProgress)).toBeInTheDocument();
expect(screen.getByText('In progress')).toBeInTheDocument();
expect(screen.getByText(mockBaseProgram.progress.completed)).toBeInTheDocument();
expect(screen.getByText('Completed')).toBeInTheDocument();
expect(screen.getByText(mockBaseProgram.progress.notStarted)).toBeInTheDocument();
expect(screen.getByText('Remaining')).toBeInTheDocument();
});

it('renders names of all organizations when more than one', () => {
renderComponent(mockMultipleOrgProgram);
const aggregatedOrganizations = mockMultipleOrgProgram.authoringOrganizations.map(org => org.key).join(', ');
expect(screen.getByText(aggregatedOrganizations)).toBeInTheDocument();
});

it('doesnt render logo of organizations when more than one', () => {
const { queryByAltText } = renderComponent(mockMultipleOrgProgram);
const logoImageNode = queryByAltText(mockMultipleOrgProgram.authoringOrganizations[0].key);
expect(logoImageNode).toBeNull();
});

it('each card links to a progress page using the program uuid', async () => {
const { getByTestId } = renderComponent();
const programCard = getByTestId('program-list-card');
expect(programCard).toHaveAttribute('to', 'test-uuid');
});

it.each([{
width: 1450,
size: 'large',
},
{
width: 1300,
size: 'large',
},
{
width: 1000,
size: 'large',
},
{
width: 800,
size: 'medium',
},
{
width: 600,
size: 'small',
},
{
width: 500,
size: 'xSmall',
}])('tests window size', ({ width, size }) => {
global.innerWidth = width;
const { getByAltText } = renderComponent();
const imageCap = getByAltText('program card image for test-title');
expect(imageCap).toHaveAttribute('src', `banner-${size}.jpg`);
});
});
100 changes: 100 additions & 0 deletions src/containers/ProgramDashboard/ProgramsList/ProgramListCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import cardFallbackImg from '@edx/brand/paragon/images/card-imagecap-fallback.png';
import {
breakpoints,
Card,
Row,
} from '@openedx/paragon';
import { ProgramCardProps, AuthoringOrganization } from '../data/types';
import ProgressCategoryBubbles from './ProgressCategoryBubbles';

const ProgramListCard: React.FC<ProgramCardProps> = ({
program,
}) => {
const [windowWidth, setWindowWidth] = useState(window.innerWidth);

useEffect(() => {
const handleWindowResize = () => {
setWindowWidth(window.innerWidth);
};

window.addEventListener('resize', handleWindowResize);

return () => {
window.removeEventListener('resize', handleWindowResize);
};
}, []);

const getBannerImageURL = () => {
let imageURL = '';
// We need to check that the breakpoint value exists before using it
// Otherwise TypeScript will flag it as it can potentially be undefined in Paragon
if (typeof breakpoints.large.minWidth === 'number' && windowWidth >= breakpoints.large.minWidth) {
imageURL = program.bannerImage.large.url;
} else if (typeof breakpoints.medium.minWidth === 'number' && windowWidth >= breakpoints.medium.minWidth) {
imageURL = program.bannerImage.medium.url;
} else if (typeof breakpoints.small.minWidth === 'number' && windowWidth >= breakpoints.small.minWidth) {
imageURL = program.bannerImage.small.url;
} else {
imageURL = program.bannerImage.xSmall.url;
}
return imageURL;
};

// Set key and logoImageUrl to empty strings for fallback image or instances where there are multiple organizations
let authoringOrganization : AuthoringOrganization = {
key: '',
logoImageUrl: '',
};
// Otherwise use the logoImageUrl and key for the organization
if (program.authoringOrganizations?.length === 1 && program.authoringOrganizations[0].logoImageUrl) {
authoringOrganization = {
logoImageUrl: program.authoringOrganizations[0].logoImageUrl,
key: program.authoringOrganizations[0].key,
};
}

return (
<Card
className="program-list-card"
isClickable
as={Link}
to={program.uuid}
data-testid="program-list-card"
>
<Card.ImageCap
src={getBannerImageURL() || cardFallbackImg}
srcAlt={`program card image for ${program.title}`}
fallbackSrc={cardFallbackImg}
logoSrc={authoringOrganization?.logoImageUrl}
logoAlt={authoringOrganization?.key}
className="banner-image"
/>
<Card.Section className="pb-0 small">
<Row className="justify-content-between px-2.5">
{program.authoringOrganizations && (
<p className="truncate-text-1">
{program.authoringOrganizations.map(org => org.key).join(', ')}
</p>
)}
<p>
{program.type}
</p>
</Row>
</Card.Section>
<Card.Section>
<h3 className="truncate-text-2">{program.title}</h3>
</Card.Section>
<Card.Section>
<ProgressCategoryBubbles
inProgress={program.progress.inProgress}
notStarted={program.progress.notStarted}
completed={program.progress.completed}
/>
</Card.Section>
</Card>
);
};

export default ProgramListCard;
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { render, screen } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';

import ProgressCategoryBubbles from './ProgressCategoryBubbles';

describe('ProgressCategoryBubbles', () => {
it('renders the correct values for each category', () => {
render(
<IntlProvider>
<ProgressCategoryBubbles inProgress={1} notStarted={2} completed={0} />
</IntlProvider>,
);

expect(screen.getByTestId('completed-count')).toHaveTextContent('0');
expect(screen.getByTestId('in-progress-count')).toHaveTextContent('1');
expect(screen.getByTestId('remaining-count')).toHaveTextContent('2');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import React from 'react';
import { Bubble, Stack } from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import messages from './messages';

import { Progress } from '../data/types';

const ProgressCategoryBubbles: React.FC<Progress> = ({ notStarted, inProgress, completed }) => {
const { formatMessage } = useIntl();
return (
<Stack direction="horizontal" gap={2} className="flex-wrap">
<Stack direction="vertical" className="align-items-center" gap={1}>
<Bubble variant="success" data-testid="completed-count">
{completed}
</Bubble>
<div>
{formatMessage(messages.progressCategoryBubblesSuccess)}
</div>
</Stack>

<Stack direction="vertical" className="align-items-center" gap={1}>
<Bubble data-testid="in-progress-count">
{inProgress}
</Bubble>
<div>
{formatMessage(messages.progressCategoryBubblesInProgress)}
</div>
</Stack>

<Stack direction="vertical" className="align-items-center" gap={1}>
<Bubble className="text-gray-900" variant="warning" data-testid="remaining-count">
{notStarted}
</Bubble>
<div>
{formatMessage(messages.progressCategoryBubblesRemaining)}
</div>
</Stack>
</Stack>
);
};

export default ProgressCategoryBubbles;
16 changes: 16 additions & 0 deletions src/containers/ProgramDashboard/ProgramsList/index.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// The current Truncate component in Paragon is deprecated and soon to be removed
// See https://github.com/openedx/paragon/issues/3311 for developments on this issue

.truncate-text-1 {
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
}

.truncate-text-2 {
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
Loading