diff --git a/.env b/.env
index d29c3f6cf..97e7a5761 100644
--- a/.env
+++ b/.env
@@ -45,3 +45,4 @@ NON_BROWSABLE_COURSES=false
SHOW_UNENROLL_SURVEY=true
# Fallback in local style files
PARAGON_THEME_URLS={}
+ENABLE_PROGRAM_DASHBOARD=false
diff --git a/package-lock.json b/package-lock.json
index 2a8d38c08..455cf5b88 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -2718,13 +2718,14 @@
}
},
"node_modules/@emnapi/core": {
- "version": "1.8.1",
- "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz",
- "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==",
+ "version": "1.9.2",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz",
+ "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==",
+ "dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
- "@emnapi/wasi-threads": "1.1.0",
+ "@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0"
}
},
@@ -2739,9 +2740,10 @@
}
},
"node_modules/@emnapi/wasi-threads": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz",
- "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==",
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
+ "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
+ "dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
diff --git a/src/App.jsx b/src/App.jsx
index 96195f3bf..c49a8d648 100755
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -1,18 +1,12 @@
import React from 'react';
-import { Helmet } from 'react-helmet';
import { useIntl } from '@edx/frontend-platform/i18n';
import { ErrorPage } from '@edx/frontend-platform/react';
-import { FooterSlot } from '@edx/frontend-component-footer';
import { Alert } from '@openedx/paragon';
import Dashboard from 'containers/Dashboard';
-import AppWrapper from 'containers/AppWrapper';
-import LearnerDashboardHeader from 'containers/LearnerDashboardHeader';
-
-import { getConfig } from '@edx/frontend-platform';
import { useInitializeLearnerHome } from 'data/hooks';
import { useMasquerade } from 'data/context';
import messages from './messages';
@@ -26,28 +20,16 @@ export const App = () => {
const supportEmail = data?.platformSettings?.supportEmail || undefined;
return (
- <>
-
- {formatMessage(messages.pageTitle)}
-
-
-
-
{pageTitle}
- {!isPending && (
- <>
-
- {(hasCourses && showSelectSessionModal) &&
}
- >
- )}
-
- {isPending
- ? (
)
- : (
-
-
-
- )}
+ <>
+
+
+
{pageTitle}
+ {!isPending && (
+ <>
+
+ {(hasCourses && showSelectSessionModal) &&
}
+ >
+ )}
+
+ {isPending
+ ? ()
+ : (
+
+
+
+ )}
+
-
+ >
);
};
diff --git a/src/containers/Dashboard/index.test.jsx b/src/containers/Dashboard/index.test.jsx
index 7e2dbc80f..2d754adf9 100644
--- a/src/containers/Dashboard/index.test.jsx
+++ b/src/containers/Dashboard/index.test.jsx
@@ -24,8 +24,7 @@ jest.mock('containers/CoursesPanel', () => jest.fn(() =>
CoursesPanel
jest.mock('./LoadingView', () => jest.fn(() =>
LoadingView
));
jest.mock('containers/SelectSessionModal', () => jest.fn(() =>
SelectSessionModal
));
jest.mock('./DashboardLayout', () => jest.fn(() =>
DashboardLayout
));
-
-const pageTitle = 'test-page-title';
+jest.mock('containers/MasqueradeBar', () => jest.fn(() =>
MasqueradeBar
)); // Mock the MasqueradeBar
describe('Dashboard', () => {
const createWrapper = (props = {}) => {
@@ -34,7 +33,7 @@ describe('Dashboard', () => {
initIsPending = true,
showSelectSessionModal = true,
} = props;
- hooks.useDashboardMessages.mockReturnValue({ pageTitle });
+ hooks.useDashboardMessages.mockReturnValue({ pageTitle: 'Dashboard' });
const dataMocked = { data: hasCourses ? { courses: [1, 2] } : { courses: [] }, isPending: initIsPending };
useInitializeLearnerHome.mockReturnValue(dataMocked);
useSelectSessionModal.mockReturnValue({ selectSessionModal: showSelectSessionModal ? { cardId: 1 } : null });
@@ -42,11 +41,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 });
@@ -58,6 +52,11 @@ describe('Dashboard', () => {
const selectSessionModal = screen.getByText('SelectSessionModal');
expect(selectSessionModal).toBeInTheDocument();
});
+ it('should render MasqueradeBar', () => {
+ createWrapper({ initIsPending: false });
+ const masqueradeBar = screen.getByText('MasqueradeBar');
+ expect(masqueradeBar).toBeInTheDocument();
+ });
});
describe('courses still loading', () => {
it('should render LoadingView', () => {
@@ -72,6 +71,11 @@ describe('Dashboard', () => {
const dashboardLayout = screen.getByText('DashboardLayout');
expect(dashboardLayout).toBeInTheDocument();
});
+ it('should render MasqueradeBar', () => {
+ createWrapper({ initIsPending: false });
+ const masqueradeBar = screen.getByText('MasqueradeBar');
+ expect(masqueradeBar).toBeInTheDocument();
+ });
});
});
});
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..86736a61d 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 MasqueradeBar from 'containers/MasqueradeBar';
+import { getConfig } from '@edx/frontend-platform';
+import { useIntl } from '@edx/frontend-platform/i18n';
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 { pageTitle } = useDashboardMessages();
+ const location = useLocation();
+ const { pathname } = location;
+
const { data: learnerData } = useInitializeLearnerHome();
const courseSearchUrl = learnerData?.platformSettings?.courseSearchUrl || '';
@@ -25,17 +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..434917e3f 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,21 +24,37 @@ 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
));
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(
);
expect(screen.getByText('ConfirmEmailBanner')).toBeInTheDocument();
- expect(screen.getByText('MasqueradeBar')).toBeInTheDocument();
expect(screen.getByText('Header')).toBeInTheDocument();
const props = mockedHeaderProps.mock.calls[0][0];
const { mainMenuItems } = props;
@@ -60,6 +78,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..10156b7e3
--- /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 as jest.Mock).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..073bf7a0a
--- /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..7069dfeaf
--- /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 }) => {
+ window.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..ee76cfa8e
--- /dev/null
+++ b/src/containers/ProgramDashboard/ProgramsList/ProgramListCard.tsx
@@ -0,0 +1,90 @@
+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 (program.bannerImage && Object.keys(program.bannerImage).length > 0) {
+ 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 => {
+ 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..2aaffac4e
--- /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..54d0cec4f
--- /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