diff --git a/package-lock.json b/package-lock.json
index 9f97db7c4..5d5c8eed8 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,7 +9,7 @@
"version": "0.0.1",
"license": "AGPL-3.0",
"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": "^8.0.0",
"@edx/frontend-enterprise-hotjar": "7.2.0",
diff --git a/package.json b/package.json
index 821c43be5..07a1fb53b 100755
--- a/package.json
+++ b/package.json
@@ -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": "^8.0.0",
"@edx/frontend-enterprise-hotjar": "7.2.0",
diff --git a/src/App.jsx b/src/App.jsx
index c8d914f9c..739819e06 100755
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -1,16 +1,18 @@
import React from 'react';
-import { Helmet } from 'react-helmet';
import { useIntl } from '@edx/frontend-platform/i18n';
import { logError } from '@edx/frontend-platform/logging';
import { initializeHotjar } from '@edx/frontend-enterprise-hotjar';
-import { ErrorPage } from '@edx/frontend-platform/react';
+import { ErrorPage, AppContext } from '@edx/frontend-platform/react';
import { FooterSlot } from '@edx/frontend-component-footer';
import { Alert } from '@openedx/paragon';
import Dashboard from 'containers/Dashboard';
+import track from 'tracking';
+
+import fakeData from 'data/services/lms/fakeData/courses';
import AppWrapper from 'containers/AppWrapper';
import LearnerDashboardHeader from 'containers/LearnerDashboardHeader';
@@ -42,28 +44,16 @@ export const App = () => {
}
}, []);
return (
- <>
-
- {formatMessage(messages.pageTitle)}
-
-
-
-
-
-
- {hasNetworkFailure
- ? (
-
-
-
- ) : (
-
- )}
-
-
-
-
- >
+
+ {hasNetworkFailure
+ ? (
+
+
+
+ ) : (
+
+ )}
+
);
};
diff --git a/src/App.test.jsx b/src/App.test.jsx
index 900e96d62..51ae878f7 100644
--- a/src/App.test.jsx
+++ b/src/App.test.jsx
@@ -7,8 +7,18 @@ import { useInitializeLearnerHome } from 'data/hooks';
import { App } from './App';
import messages from './messages';
-jest.mock('data/hooks', () => ({
- useInitializeLearnerHome: jest.fn(),
+jest.mock('containers/Dashboard', () => jest.fn(() => Dashboard
));
+jest.mock('data/redux', () => ({
+ selectors: 'redux.selectors',
+ actions: 'redux.actions',
+ thunkActions: 'redux.thunkActions',
+}));
+jest.mock('hooks', () => ({
+ reduxHooks: {
+ useRequestIsFailed: jest.fn(),
+ usePlatformSettingsData: jest.fn(),
+ useLoadData: jest.fn(),
+ },
}));
jest.mock('data/context', () => ({
@@ -43,31 +53,12 @@ useInitializeLearnerHome.mockReturnValue({
describe('App router component', () => {
describe('component', () => {
- const runBasicTests = () => {
- it('displays title in helmet component', async () => {
- await waitFor(() => expect(document.title).toEqual(messages.pageTitle.defaultMessage));
- });
- it('displays learner dashboard header', () => {
- const learnerDashboardHeader = screen.getByText('LearnerDashboardHeader');
- expect(learnerDashboardHeader).toBeInTheDocument();
- });
- it('wraps the header and main components in an AppWrapper widget container', () => {
- const appWrapper = screen.getByText('LearnerDashboardHeader').parentElement;
- expect(appWrapper).toHaveClass('AppWrapper');
- expect(appWrapper.children[1].id).toEqual('main');
- });
- it('displays footer slot', () => {
- const footerSlot = screen.getByText('FooterSlot');
- expect(footerSlot).toBeInTheDocument();
- });
- };
describe('no network failure', () => {
beforeEach(() => {
jest.clearAllMocks();
getConfig.mockReturnValue({});
render();
});
- runBasicTests();
it('loads dashboard', () => {
const dashboard = screen.getByText('Dashboard');
expect(dashboard).toBeInTheDocument();
@@ -79,7 +70,6 @@ describe('App router component', () => {
getConfig.mockReturnValue({ OPTIMIZELY_URL: 'fake.url' });
render();
});
- runBasicTests();
it('loads dashboard', () => {
const dashboard = screen.getByText('Dashboard');
expect(dashboard).toBeInTheDocument();
@@ -91,7 +81,6 @@ describe('App router component', () => {
getConfig.mockReturnValue({ OPTIMIZELY_PROJECT_ID: 'fakeId' });
render();
});
- runBasicTests();
it('loads dashboard', () => {
const dashboard = screen.getByText('Dashboard');
expect(dashboard).toBeInTheDocument();
@@ -107,7 +96,6 @@ describe('App router component', () => {
getConfig.mockReturnValue({});
render();
});
- runBasicTests();
it('loads error page', () => {
const alert = screen.getByRole('alert');
expect(alert).toBeInTheDocument();
@@ -120,7 +108,6 @@ describe('App router component', () => {
getConfig.mockReturnValue({});
render();
});
- runBasicTests();
it('loads error page', () => {
const alert = screen.getByRole('alert');
expect(alert).toBeInTheDocument();
diff --git a/src/containers/AppWrapper/index.jsx b/src/containers/AppWrapper/index.jsx
deleted file mode 100644
index 72f4a9260..000000000
--- a/src/containers/AppWrapper/index.jsx
+++ /dev/null
@@ -1,13 +0,0 @@
-import PropTypes from 'prop-types';
-
-export const AppWrapper = ({
- children,
-}) => children;
-AppWrapper.propTypes = {
- children: PropTypes.oneOfType([
- PropTypes.node,
- PropTypes.arrayOf(PropTypes.node),
- ]).isRequired,
-};
-
-export default AppWrapper;
diff --git a/src/containers/Dashboard/index.test.jsx b/src/containers/Dashboard/index.test.jsx
index 7e2dbc80f..e692978e2 100644
--- a/src/containers/Dashboard/index.test.jsx
+++ b/src/containers/Dashboard/index.test.jsx
@@ -25,8 +25,6 @@ jest.mock('./LoadingView', () => jest.fn(() => LoadingView
));
jest.mock('containers/SelectSessionModal', () => jest.fn(() => SelectSessionModal
));
jest.mock('./DashboardLayout', () => jest.fn(() => DashboardLayout
));
-const pageTitle = 'test-page-title';
-
describe('Dashboard', () => {
const createWrapper = (props = {}) => {
const {
@@ -42,11 +40,6 @@ describe('Dashboard', () => {
};
describe('render', () => {
- it('page title is displayed in sr-only h1 tag', () => {
- createWrapper();
- const heading = screen.getByText(pageTitle);
- expect(heading).toHaveClass('sr-only');
- });
describe('initIsPending false', () => {
it('should render DashboardModalSlot', () => {
createWrapper({ initIsPending: false });
diff --git a/src/containers/LearnerDashboardHeader/LearnerDashboardMenu.jsx b/src/containers/LearnerDashboardHeader/LearnerDashboardMenu.jsx
index f13177ffa..1af991898 100644
--- a/src/containers/LearnerDashboardHeader/LearnerDashboardMenu.jsx
+++ b/src/containers/LearnerDashboardHeader/LearnerDashboardMenu.jsx
@@ -9,18 +9,20 @@ const getLearnerHeaderMenu = (
courseSearchUrl,
authenticatedUser,
exploreCoursesClick,
+ pathname,
) => ({
mainMenu: [
{
type: 'item',
href: '/',
content: formatMessage(messages.course),
- isActive: true,
+ isActive: pathname === '/',
},
...(getConfig().ENABLE_PROGRAMS ? [{
type: 'item',
- href: `${urls.programsUrl()}`,
+ href: getConfig().ENABLE_PROGRAM_DASHBOARD ? '/programs' : `${urls.programsUrl()}`,
content: formatMessage(messages.program),
+ isActive: pathname === '/programs',
}] : []),
...(!getConfig().NON_BROWSABLE_COURSES ? [{
type: 'item',
diff --git a/src/containers/LearnerDashboardHeader/hooks.js b/src/containers/LearnerDashboardHeader/hooks.js
index 5367ab3b5..d585b2d75 100644
--- a/src/containers/LearnerDashboardHeader/hooks.js
+++ b/src/containers/LearnerDashboardHeader/hooks.js
@@ -15,10 +15,10 @@ export const findCoursesNavClicked = (href) => track.findCourses.findCoursesClic
});
export const useLearnerDashboardHeaderMenu = ({
- courseSearchUrl, authenticatedUser, exploreCoursesClick,
+ courseSearchUrl, authenticatedUser, exploreCoursesClick, pathname,
}) => {
const { formatMessage } = useIntl();
- return getLearnerHeaderMenu(formatMessage, courseSearchUrl, authenticatedUser, exploreCoursesClick);
+ return getLearnerHeaderMenu(formatMessage, courseSearchUrl, authenticatedUser, exploreCoursesClick, pathname);
};
export default {
diff --git a/src/containers/LearnerDashboardHeader/index.jsx b/src/containers/LearnerDashboardHeader/index.jsx
index 6f3b31d9c..8d65c2fe8 100644
--- a/src/containers/LearnerDashboardHeader/index.jsx
+++ b/src/containers/LearnerDashboardHeader/index.jsx
@@ -1,19 +1,28 @@
import React from 'react';
+import { Helmet } from 'react-helmet';
+import { getConfig } from '@edx/frontend-platform';
+import { useIntl } from '@edx/frontend-platform/i18n';
import MasqueradeBar from 'containers/MasqueradeBar';
import { AppContext } from '@edx/frontend-platform/react';
import Header from '@edx/frontend-component-header';
import { useInitializeLearnerHome } from 'data/hooks';
import urls from 'data/services/lms/urls';
+import { useLocation } from 'react-router-dom';
+import { useDashboardMessages } from 'containers/Dashboard/hooks';
import ConfirmEmailBanner from './ConfirmEmailBanner';
-
+import appMessages from '../../messages';
import { useLearnerDashboardHeaderMenu, findCoursesNavClicked } from './hooks';
-
import './index.scss';
export const LearnerDashboardHeader = () => {
const { authenticatedUser } = React.useContext(AppContext);
+ const { formatMessage } = useIntl();
+ const { courseSearchUrl } = reduxHooks.usePlatformSettingsData();
+ const { pageTitle } = useDashboardMessages();
+ const location = useLocation();
+ const { pathname } = location;
const { data: learnerData } = useInitializeLearnerHome();
const courseSearchUrl = learnerData?.platformSettings?.courseSearchUrl || '';
@@ -25,16 +34,22 @@ export const LearnerDashboardHeader = () => {
courseSearchUrl,
authenticatedUser,
exploreCoursesClick,
+ pathname,
});
return (
<>
+
+ {formatMessage(appMessages.pageTitle)}
+
+
+ {pageTitle}
>
);
diff --git a/src/containers/LearnerDashboardHeader/index.test.jsx b/src/containers/LearnerDashboardHeader/index.test.jsx
index a8cd80bea..0b2809bc1 100644
--- a/src/containers/LearnerDashboardHeader/index.test.jsx
+++ b/src/containers/LearnerDashboardHeader/index.test.jsx
@@ -1,8 +1,10 @@
import { mergeConfig } from '@edx/frontend-platform';
import { render, screen } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
+import { useLocation } from 'react-router-dom';
import urls from 'data/services/lms/urls';
+import { useDashboardMessages } from 'containers/Dashboard/hooks';
import LearnerDashboardHeader from '.';
import { findCoursesNavClicked } from './hooks';
@@ -22,6 +24,12 @@ jest.mock('./hooks', () => ({
findCoursesNavClicked: jest.fn(),
}));
+jest.mock('react-router-dom', () => ({
+ useLocation: jest.fn(() => ({
+ pathname: '/',
+ })),
+}));
+
const mockedHeaderProps = jest.fn();
jest.mock('containers/MasqueradeBar', () => jest.fn(() => MasqueradeBar
));
jest.mock('./ConfirmEmailBanner', () => jest.fn(() => ConfirmEmailBanner
));
@@ -29,9 +37,21 @@ jest.mock('@edx/frontend-component-header', () => jest.fn((props) => {
mockedHeaderProps(props);
return Header
;
}));
+jest.mock('containers/Dashboard/hooks', () => ({
+ useDashboardMessages: jest.fn(),
+}));
+
+const pageTitle = 'test-page-title';
describe('LearnerDashboardHeader', () => {
beforeEach(() => jest.clearAllMocks());
+
+ it('page title is displayed in sr-only h1 tag', () => {
+ useDashboardMessages.mockReturnValue({ pageTitle });
+ render();
+ const heading = screen.getByText(pageTitle);
+ expect(heading).toHaveClass('sr-only');
+ });
it('renders and discover url is correct', () => {
mergeConfig({ ORDER_HISTORY_URL: 'test-url' });
render();
@@ -60,6 +80,26 @@ describe('LearnerDashboardHeader', () => {
const { mainMenuItems } = props;
expect(mainMenuItems.length).toBe(3);
});
+
+ it('should highlight the active tab depending on the pathname', () => {
+ render();
+ const props = mockedHeaderProps.mock.calls[0][0];
+ const { mainMenuItems } = props;
+ expect(mainMenuItems[0].isActive).toBe(true);
+ });
+
+ it('should highlight the programs tab if dashboard is enabled and on the programs page', () => {
+ mergeConfig({ ENABLE_PROGRAMS: true, ENABLE_PROGRAM_DASHBOARD: true });
+ useLocation.mockReturnValueOnce({
+ pathname: '/programs',
+ });
+ render();
+ const props = mockedHeaderProps.mock.calls[0][0];
+ const { mainMenuItems } = props;
+ expect(mainMenuItems[0].isActive).toBe(false);
+ expect(mainMenuItems[1].isActive).toBe(true);
+ });
+
it('should not display Discover New tab if it is disabled by configuration', () => {
mergeConfig({ NON_BROWSABLE_COURSES: true });
render();
diff --git a/src/containers/ProgramDashboard/ProgramsList/ExploreProgramsCTA.test.tsx b/src/containers/ProgramDashboard/ProgramsList/ExploreProgramsCTA.test.tsx
new file mode 100644
index 000000000..97ed8a2e6
--- /dev/null
+++ b/src/containers/ProgramDashboard/ProgramsList/ExploreProgramsCTA.test.tsx
@@ -0,0 +1,64 @@
+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,
+ })),
+}));
+
+describe('ExploreProgramsCTA', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ const renderComponent = (props = {}) => render(
+
+
+ ,
+ );
+
+ it('renders the expected CTA text when there are enrollments', () => {
+ renderComponent();
+
+ expect(screen.getByText(messages.exploreProgramsCTAText.defaultMessage)).toBeInTheDocument();
+ });
+
+ it('renders the expected CTA when there are no enrollments', () => {
+ renderComponent({ hasEnrollments: false });
+
+ expect(screen.getByText(messages.hasNoEnrollmentsText.defaultMessage)).toBeInTheDocument();
+ });
+
+ it('renders the button with the expected text', () => {
+ renderComponent();
+
+ expect(screen.getByRole('link', { name: messages.exploreProgramsCTAButtonText.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.exploreProgramsCTAButtonText.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.exploreProgramsCTAButtonText.defaultMessage });
+ const expectedFallbackUrl = `${getConfig().LMS_BASE_URL}/courses`;
+ expect(button).toHaveAttribute('href', expectedFallbackUrl);
+ });
+});
diff --git a/src/containers/ProgramDashboard/ProgramsList/ExploreProgramsCTA.tsx b/src/containers/ProgramDashboard/ProgramsList/ExploreProgramsCTA.tsx
new file mode 100644
index 000000000..bca5446c3
--- /dev/null
+++ b/src/containers/ProgramDashboard/ProgramsList/ExploreProgramsCTA.tsx
@@ -0,0 +1,39 @@
+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 { ExploreProgramsCTAProps } from '../data/types';
+import messages from './messages';
+
+const ExploreProgramsCTA: React.FC = ({
+ hasEnrollments = true,
+}) => {
+ const { formatMessage } = useIntl();
+
+ const href = getConfig().EXPLORE_PROGRAMS_URL || `${getConfig().LMS_BASE_URL}/courses`;
+ return (
+
+
+ {hasEnrollments ? (
+ formatMessage(messages.exploreProgramsCTAText)
+ ) : (
+
+ {formatMessage(messages.hasNoEnrollmentsText)}
+
+ )}
+
+
+
+
+
+ );
+};
+
+export default ExploreProgramsCTA;
diff --git a/src/containers/ProgramDashboard/ProgramsList/ProgramListCard.test.tsx b/src/containers/ProgramDashboard/ProgramsList/ProgramListCard.test.tsx
new file mode 100644
index 000000000..9493abb47
--- /dev/null
+++ b/src/containers/ProgramDashboard/ProgramsList/ProgramListCard.test.tsx
@@ -0,0 +1,131 @@
+import { render, RenderResult, 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 }) => {children}),
+}));
+
+jest.mock('@edx/frontend-platform', () => ({
+ getConfig: jest.fn(() => ({
+ LMS_BASE_URL: 'test-base-url',
+ })),
+}));
+
+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: [
+ {
+ uuid: 'org-uuid-1',
+ key: 'test-key',
+ name: 'test-org-1',
+ logoImageUrl: 'test-logo.png',
+ certificateLogoImageUrl: 'test-cert-logo.png',
+ },
+ ],
+ progress: {
+ inProgress: 1,
+ notStarted: 2,
+ completed: 3,
+ },
+};
+
+const mockMultipleOrgProgram = {
+ ...mockBaseProgram,
+ authoringOrganizations: [
+ {
+ uuid: 'org-uuid-1',
+ name: 'MIT',
+ key: 'MITx',
+ logoImageUrl: 'mit-logo.png',
+ certificateLogoImageUrl: 'mit-cert-logo-1.png',
+ },
+ {
+ uuid: 'org-uuid-2',
+ name: 'Harvard',
+ key: 'Harvardx',
+ logoImageUrl: 'harvard-logo.png',
+ certificateLogoImageUrl: 'harvard-cert-logo-2.png',
+ },
+ ],
+};
+
+describe('ProgramListCard', () => {
+ const renderComponent = (programData: ProgramData = mockBaseProgram): RenderResult => render(
+
+
+ ,
+ );
+
+ 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-base-url/dashboard/programs/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`);
+ });
+});
diff --git a/src/containers/ProgramDashboard/ProgramsList/ProgramListCard.tsx b/src/containers/ProgramDashboard/ProgramsList/ProgramListCard.tsx
new file mode 100644
index 000000000..4d0c20e2b
--- /dev/null
+++ b/src/containers/ProgramDashboard/ProgramsList/ProgramListCard.tsx
@@ -0,0 +1,89 @@
+import React from 'react';
+import { Link } from 'react-router-dom';
+import { getConfig } from '@edx/frontend-platform';
+import cardFallbackImg from '@edx/brand/paragon/images/card-imagecap-fallback.png';
+import {
+ breakpoints,
+ useWindowSize,
+ Card,
+ Row,
+} from '@openedx/paragon';
+import { ProgramCardProps } from '../data/types';
+import ProgressCategoryBubbles from './ProgressCategoryBubbles';
+
+const ProgramListCard: React.FC = ({
+ program,
+}) => {
+ const { width: windowWidth } = useWindowSize();
+
+ const getBannerImageURL = (): string => {
+ 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 (!windowWidth) {
+ return program.bannerImage.medium.url;
+ }
+
+ 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;
+ };
+
+ const getOrgImageUrl = (): string => {
+ // Otherwise use the logoImageUrl and key for the organization
+ if (program.authoringOrganizations?.length === 1 && program.authoringOrganizations[0].logoImageUrl) {
+ return program.authoringOrganizations[0].logoImageUrl;
+ }
+ return '';
+ };
+
+ return (
+
+
+
+
+ {program.authoringOrganizations && (
+
+ {program.authoringOrganizations.map(org => org.key).join(', ')}
+
+ )}
+
+ {program.type}
+
+
+
+
+ {program.title}
+
+
+
+
+
+ );
+};
+
+export default ProgramListCard;
diff --git a/src/containers/ProgramDashboard/ProgramsList/ProgressCategoryBubbles.test.tsx b/src/containers/ProgramDashboard/ProgramsList/ProgressCategoryBubbles.test.tsx
new file mode 100644
index 000000000..1bc5c5bd9
--- /dev/null
+++ b/src/containers/ProgramDashboard/ProgramsList/ProgressCategoryBubbles.test.tsx
@@ -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(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId('completed-count')).toHaveTextContent('0');
+ expect(screen.getByTestId('in-progress-count')).toHaveTextContent('1');
+ expect(screen.getByTestId('remaining-count')).toHaveTextContent('2');
+ });
+});
diff --git a/src/containers/ProgramDashboard/ProgramsList/ProgressCategoryBubbles.tsx b/src/containers/ProgramDashboard/ProgramsList/ProgressCategoryBubbles.tsx
new file mode 100644
index 000000000..3a9c0b17a
--- /dev/null
+++ b/src/containers/ProgramDashboard/ProgramsList/ProgressCategoryBubbles.tsx
@@ -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