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)} - - -
- - -
- {hasNetworkFailure - ? ( - - - - ) : ( - - )} -
-
- -
- +
+ {hasNetworkFailure + ? ( + + + + ) : ( + + )} +
); }; diff --git a/src/App.test.jsx b/src/App.test.jsx index 900e96d62..8f3e158ed 100644 --- a/src/App.test.jsx +++ b/src/App.test.jsx @@ -1,4 +1,4 @@ -import { render, screen, waitFor } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import { IntlProvider } from '@edx/frontend-platform/i18n'; import { getConfig } from '@edx/frontend-platform'; @@ -7,20 +7,8 @@ import { useInitializeLearnerHome } from 'data/hooks'; import { App } from './App'; import messages from './messages'; -jest.mock('data/hooks', () => ({ - useInitializeLearnerHome: jest.fn(), -})); - -jest.mock('data/context', () => ({ - useMasquerade: jest.fn(() => ({ masqueradeUser: null })), -})); - -jest.mock('@edx/frontend-component-footer', () => ({ - FooterSlot: jest.fn(() =>
FooterSlot
), -})); jest.mock('containers/Dashboard', () => jest.fn(() =>
Dashboard
)); jest.mock('containers/LearnerDashboardHeader', () => jest.fn(() =>
LearnerDashboardHeader
)); -jest.mock('containers/AppWrapper', () => jest.fn(({ children }) =>
{children}
)); jest.mock('@edx/frontend-platform', () => ({ getConfig: jest.fn(() => ({})), @@ -43,31 +31,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 +48,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 +59,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 +74,6 @@ describe('App router component', () => { getConfig.mockReturnValue({}); render(); }); - runBasicTests(); it('loads error page', () => { const alert = screen.getByRole('alert'); expect(alert).toBeInTheDocument(); @@ -120,7 +86,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/config/index.js b/src/config/index.js index 43b10afc2..a06bea4dd 100644 --- a/src/config/index.js +++ b/src/config/index.js @@ -22,6 +22,7 @@ const configuration = { ENABLE_PROGRAMS: process.env.ENABLE_PROGRAMS === 'true', NON_BROWSABLE_COURSES: process.env.NON_BROWSABLE_COURSES === 'true', SHOW_UNENROLL_SURVEY: process.env.SHOW_UNENROLL_SURVEY === 'true', + ENABLE_PROGRAM_DASHBOARD: process.env.ENABLE_PROGRAM_DASHBOARD === 'true', }; const features = {}; 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/AppWrapper/index.test.tsx b/src/containers/AppWrapper/index.test.tsx deleted file mode 100644 index 00e882979..000000000 --- a/src/containers/AppWrapper/index.test.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import React from 'react'; -import { render, screen } from '@testing-library/react'; -import AppWrapper from './index'; - -describe('AppWrapper', () => { - it('should render children without modification', () => { - render( - -
Test Child
-
, - ); - - expect(screen.getByText('Test Child')).toBeInTheDocument(); - }); -}); diff --git a/src/containers/Dashboard/index.jsx b/src/containers/Dashboard/index.jsx index 7d53e0468..0bf53f463 100644 --- a/src/containers/Dashboard/index.jsx +++ b/src/containers/Dashboard/index.jsx @@ -6,6 +6,7 @@ import SelectSessionModal from 'containers/SelectSessionModal'; import CoursesPanel from 'containers/CoursesPanel'; import DashboardModalSlot from 'plugin-slots/DashboardModalSlot'; +import MasqueradeBar from 'containers/MasqueradeBar'; import LoadingView from './LoadingView'; import DashboardLayout from './DashboardLayout'; import hooks from './hooks'; @@ -20,24 +21,27 @@ export const Dashboard = () => { const hasCourses = useMemo(() => data?.courses?.length > 0, [data]); return ( -
-

{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 = ({ notStarted, inProgress, completed }) => { + const { formatMessage } = useIntl(); + return ( + + + + {completed} + +
+ {formatMessage(messages.progressCategoryBubblesSuccess)} +
+
+ + + + {inProgress} + +
+ {formatMessage(messages.progressCategoryBubblesInProgress)} +
+
+ + + + {notStarted} + +
+ {formatMessage(messages.progressCategoryBubblesRemaining)} +
+
+
+ ); +}; + +export default ProgressCategoryBubbles; diff --git a/src/containers/ProgramDashboard/ProgramsList/index.scss b/src/containers/ProgramDashboard/ProgramsList/index.scss new file mode 100644 index 000000000..c5ea45b70 --- /dev/null +++ b/src/containers/ProgramDashboard/ProgramsList/index.scss @@ -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; +} diff --git a/src/containers/ProgramDashboard/ProgramsList/index.test.tsx b/src/containers/ProgramDashboard/ProgramsList/index.test.tsx new file mode 100644 index 000000000..38b1371e2 --- /dev/null +++ b/src/containers/ProgramDashboard/ProgramsList/index.test.tsx @@ -0,0 +1,105 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import ProgramsList from '.'; +import { useProgramsListData } from '../../../data/hooks/queryHooks'; +import ProgramListCard from './ProgramListCard'; +import messages from './messages'; + +jest.mock('../../../data/hooks/queryHooks', () => ({ + useProgramsListData: jest.fn(), +})); + +jest.mock('@edx/frontend-platform/logging', () => ({ + logError: jest.fn(), +})); + +jest.mock('@edx/frontend-platform', () => ({ + getConfig: jest.fn(() => ({ + CONTACT_URL: 'test-contact-url', + })), +})); + +jest.mock('./ProgramListCard', () => jest.fn(({ program }) => ( +
{program.title}
+))); +jest.mock('./ExploreProgramsCTA', () => jest.fn(() => ( +
+))); + +const mockApiData = [ + { uuid: '111-aaa', title: 'Data Science Program' }, + { uuid: '222-bbb', title: 'UX Design Program' }, +]; + +describe('ProgramsList', () => { + beforeEach(() => { + jest.clearAllMocks(); + // useProgramsListData is a synchronous hook — use mockReturnValue, NOT mockResolvedValue + (useProgramsListData as jest.Mock).mockReturnValue({ + data: mockApiData, + isLoading: false, + isError: false, + }); + }); + + const renderComponent = () => render( + + + , + ); + + it('renders header text and ExploreProgramsCTA', () => { + renderComponent(); + expect(screen.getByText(messages.programsListHeaderText.defaultMessage)).toBeInTheDocument(); + expect(screen.getByTestId('explore-programs-cta')).toBeInTheDocument(); + }); + + it('fetches program data on mount', () => { + renderComponent(); + expect(useProgramsListData).toHaveBeenCalledTimes(1); + }); + + it('renders a loading spinner while data is being fetched', () => { + (useProgramsListData as jest.Mock).mockReturnValue({ + data: undefined, + isLoading: true, + isError: false, + }); + renderComponent(); + expect(screen.getByRole('status')).toBeInTheDocument(); + expect(screen.queryAllByTestId('program-list-card')).toHaveLength(0); + }); + + it('renders ProgramListCard for each program when enrollments exist', async () => { + renderComponent(); + + await waitFor(() => { + expect(screen.queryAllByTestId('program-list-card').length).toBeGreaterThan(0); + }); + + expect(ProgramListCard).toHaveBeenCalledWith( + expect.objectContaining({ program: mockApiData[0] }), + {}, + ); + expect(ProgramListCard).toHaveBeenCalledWith( + expect.objectContaining({ program: mockApiData[1] }), + {}, + ); + }); + + it('renders an Alert and no program cards when the API request fails', async () => { + (useProgramsListData as jest.Mock).mockReturnValue({ + data: undefined, + isLoading: false, + isError: true, + }); + renderComponent(); + + await waitFor(() => { + expect(screen.getByRole('alert')).toBeInTheDocument(); + }); + + expect(screen.queryAllByTestId('program-list-card')).toHaveLength(0); + expect(screen.queryByTestId('explore-programs-cta')).not.toBeInTheDocument(); + }); +}); diff --git a/src/containers/ProgramDashboard/ProgramsList/index.tsx b/src/containers/ProgramDashboard/ProgramsList/index.tsx new file mode 100644 index 000000000..b6eddc385 --- /dev/null +++ b/src/containers/ProgramDashboard/ProgramsList/index.tsx @@ -0,0 +1,95 @@ +import React, { useEffect } from 'react'; +import { + Alert, CardGrid, Col, Container, Row, Spinner, +} from '@openedx/paragon'; +import { getConfig } from '@edx/frontend-platform'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { logError } from '@edx/frontend-platform/logging'; + +import appMessages from 'messages'; +import { useProgramsListData } from '../../../data/hooks'; +import ProgramListCard from './ProgramListCard'; +import ExploreProgramsCTA from './ExploreProgramsCTA'; +import messages from './messages'; + +import './index.scss'; + +const ProgramsList: React.FC = () => { + const { formatMessage } = useIntl(); + + const { + data: programsData = [], + isLoading, + isError: errorState, + error, + } = useProgramsListData(); + + useEffect(() => { + if (error) { + logError(error); + } + }, [error]); + + const renderPrograms = () => { + if (isLoading) { + return ( + + + + ); + } + if (programsData && programsData.length > 0) { + return ( + <> + + + {programsData.map(program => ( + + ))} + + + + + + + ); + } + return ( + + + + ); + }; + + const renderFailureAlert = () => { + const contactUrl = getConfig().CONTACT_URL; + return ( + + {formatMessage(messages.errorLoadingProgramEnrollments, { + contactSupportUrl: ( + + {contactUrl} + + ), + })} + + ); + }; + + return ( + +

+ {formatMessage(messages.programsListHeaderText)} +

+ + {errorState ? ( + renderFailureAlert() + ) : ( + renderPrograms() + )} + +
+ ); +}; + +export default ProgramsList; diff --git a/src/containers/ProgramDashboard/ProgramsList/messages.ts b/src/containers/ProgramDashboard/ProgramsList/messages.ts new file mode 100644 index 000000000..2078b54a8 --- /dev/null +++ b/src/containers/ProgramDashboard/ProgramsList/messages.ts @@ -0,0 +1,46 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + programsListHeaderText: { + defaultMessage: 'My Programs', + id: 'programs.list.header.text', + description: 'Header text for the programs list', + }, + exploreProgramsCTAText: { + defaultMessage: 'Browse recently launched courses and see what\'s new in your favorite subjects', + id: 'explore.courses.cta.text', + description: 'Call-to-action text for the explore courses component', + }, + exploreProgramsCTAButtonText: { + defaultMessage: 'Explore new programs', + id: 'explore.courses.cta.button.text', + description: 'Button text for that links to course search page', + }, + hasNoEnrollmentsText: { + defaultMessage: 'You are not enrolled in any programs yet.', + id: 'has.no.enrollments.text', + description: 'Text to display when a learner has not enrolled in any programs.', + }, + progressCategoryBubblesRemaining: { + id: 'dashboard.programs.program.listing.card.remaining.courses.count', + defaultMessage: 'Remaining', + description: 'Label for remaining courses count on program card', + }, + progressCategoryBubblesInProgress: { + id: 'dashboard.programs.program.listing.card.inProgress.courses.count', + defaultMessage: 'In progress', + description: 'Label for in progress courses count on program card', + }, + progressCategoryBubblesSuccess: { + id: 'dashboard.programs.program.listing.card.completed.courses.count', + defaultMessage: 'Completed', + description: 'Label for completed courses count on program card', + }, + errorLoadingProgramEnrollments: { + id: 'alert.error.loading.program.enrollments', + defaultMessage: 'An error occurred while attempting to retrieve program enrollments. Try refreshing page. If that doesn\'t solve the issue, contact support at {contactSupportUrl}.', + description: 'Alert message for failure to load program enrollments', + }, +}); + +export default messages; diff --git a/src/containers/ProgramDashboard/data/types.d.ts b/src/containers/ProgramDashboard/data/types.d.ts new file mode 100644 index 000000000..d0bba39bc --- /dev/null +++ b/src/containers/ProgramDashboard/data/types.d.ts @@ -0,0 +1,7 @@ +export interface ProgramCardProps { + program: ProgramData, +} + +export interface ExploreProgramsCTAProps { + hasEnrollments?: boolean, +} diff --git a/src/containers/ProgramDashboard/index.test.tsx b/src/containers/ProgramDashboard/index.test.tsx new file mode 100644 index 000000000..f2196ac8c --- /dev/null +++ b/src/containers/ProgramDashboard/index.test.tsx @@ -0,0 +1,14 @@ +import * as ProgramDashboard from '.'; +import ProgramsList from './ProgramsList'; + +jest.mock('./ProgramsList', () => jest.fn(() => null)); + +describe('ProgramDashboard', () => { + it('exports ProgramsList', () => { + expect(ProgramDashboard.ProgramsList).toBeDefined(); + }); + + it('exports the correct ProgramsList component', () => { + expect(ProgramDashboard.ProgramsList).toBe(ProgramsList); + }); +}); diff --git a/src/containers/ProgramDashboard/index.tsx b/src/containers/ProgramDashboard/index.tsx new file mode 100644 index 000000000..e7cf91d18 --- /dev/null +++ b/src/containers/ProgramDashboard/index.tsx @@ -0,0 +1,5 @@ +import ProgramsList from './ProgramsList'; + +export { + ProgramsList, +}; diff --git a/src/custom.d.ts b/src/custom.d.ts new file mode 100644 index 000000000..1c5923252 --- /dev/null +++ b/src/custom.d.ts @@ -0,0 +1,4 @@ +declare module '*.png' { + const value: string; + export default value; +} diff --git a/src/data/hooks/index.ts b/src/data/hooks/index.ts index f9e06ec45..3f9947375 100644 --- a/src/data/hooks/index.ts +++ b/src/data/hooks/index.ts @@ -1,4 +1,4 @@ -import { useInitializeLearnerHome } from './queryHooks'; +import { useInitializeLearnerHome, useProgramsListData } from './queryHooks'; import { useUnenrollFromCourse, useUpdateEntitlementEnrollment, @@ -16,4 +16,5 @@ export { useUpdateEmailSettings, useCreateCreditRequest, useSendConfirmEmail, + useProgramsListData, }; diff --git a/src/data/hooks/queryHooks.test.tsx b/src/data/hooks/queryHooks.test.tsx index 0d1172256..07cba9674 100644 --- a/src/data/hooks/queryHooks.test.tsx +++ b/src/data/hooks/queryHooks.test.tsx @@ -5,7 +5,8 @@ import { useInitializeLearnerHome, } from './index'; import * as api from '../services/lms/api'; - +import { useProgramsListData } from './queryHooks'; +import { fetchProgramsListData } from '../services/lms/api'; // Mock external dependencies jest.mock('@edx/frontend-platform/logging'); jest.mock('data/context'); @@ -137,4 +138,31 @@ describe('queryHooks', () => { expect(result.current.isRefetchError).toBe(false); }); }); + + describe('useProgramsListData', () => { + it('calls fetchProgramsListData as the query function', async () => { + (fetchProgramsListData as jest.Mock).mockResolvedValue({}); + renderHook(() => useProgramsListData(), { wrapper: createWrapper() }); + await waitFor(() => expect(fetchProgramsListData).toHaveBeenCalled()); + }); + + it('returns data on success', async () => { + const mockData = { results: [{ uuid: 'test-uuid' }] }; + (fetchProgramsListData as jest.Mock).mockResolvedValue(mockData); + + const { result } = renderHook(() => useProgramsListData(), { wrapper: createWrapper() }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual(mockData); + }); + + it('handles error state correctly', async () => { + (fetchProgramsListData as jest.Mock).mockRejectedValue(new Error('API Error')); + + const { result } = renderHook(() => useProgramsListData(), { wrapper: createWrapper() }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.error).toEqual(new Error('API Error')); + }); + }); }); diff --git a/src/data/hooks/queryHooks.ts b/src/data/hooks/queryHooks.ts index 2faa63bfb..64b689fdb 100644 --- a/src/data/hooks/queryHooks.ts +++ b/src/data/hooks/queryHooks.ts @@ -5,6 +5,8 @@ import { initializeList, } from 'data/services/lms/api'; import { learnerDashboardQueryKeys } from './queryKeys'; +import { fetchProgramsListData } from '../services/lms/api'; +import { ProgramData } from '../types'; const useInitializeLearnerHome = () => { const { masqueradeUser } = useMasquerade(); @@ -33,6 +35,14 @@ const useInitializeLearnerHome = () => { return { ...query, data }; }; +const useProgramsListData = () => useQuery({ + queryKey: ['programsList'], + queryFn: fetchProgramsListData, + retry: false, + refetchOnWindowFocus: false, +}); + export { useInitializeLearnerHome, + useProgramsListData, }; diff --git a/src/data/services/lms/api.test.tsx b/src/data/services/lms/api.test.tsx index fcfedbd61..172c8990c 100644 --- a/src/data/services/lms/api.test.tsx +++ b/src/data/services/lms/api.test.tsx @@ -15,6 +15,7 @@ import { logShare, createCreditRequest, sendConfirmEmail, + fetchProgramsListData, } from './api'; // Mock dependencies @@ -23,6 +24,12 @@ jest.mock('data/services/lms/constants'); jest.mock('data/services/lms/urls'); jest.mock('data/services/lms/utils'); jest.mock('tracking/constants'); +jest.mock('@edx/frontend-platform', () => ({ + getConfig: jest.fn(), +})); +jest.mock('@edx/frontend-platform/auth', () => ({ + getAuthenticatedHttpClient: jest.fn(), +})); const mockHttpClient = { get: jest.fn(), @@ -59,6 +66,7 @@ describe('API functions', () => { updateEmailSettings: jest.fn(() => '/api/email-settings'), event: jest.fn(() => '/api/event'), creditRequestUrl: jest.fn((providerId) => `/api/credit/${providerId}`), + programsApiUrl: jest.fn(() => '/api/dashboard/v0/programs/'), }; mockedStringifyUrl.mockImplementation((url, params) => { @@ -285,4 +293,36 @@ describe('API functions', () => { await expect(unenrollFromCourse({ courseId: 'invalid-course' })).rejects.toEqual(apiError); }); }); + + describe('fetchProgramsListData', () => { + const mockData = [{ uuid: 'test-uuid', title: 'Test Program' }]; + const mockGet = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + (getAuthenticatedHttpClient as jest.MockedFunction< + typeof getAuthenticatedHttpClient + >).mockReturnValue({ + get: mockGet, + } as any); + }); + + it('calls the correct URL', async () => { + mockGet.mockResolvedValue({ data: mockData }); + await fetchProgramsListData(); + expect(mockGet).toHaveBeenCalledWith(urls.programsApiUrl()); + }); + + it('returns data from the response', async () => { + mockGet.mockResolvedValue({ data: mockData }); + const result = await fetchProgramsListData(); + expect(result).toEqual(mockData); + }); + + it('throws an error when the request fails', async () => { + const mockError = new Error('Network Error'); + mockGet.mockRejectedValue(mockError); + await expect(fetchProgramsListData()).rejects.toThrow('Network Error'); + }); + }); }); diff --git a/src/data/services/lms/api.ts b/src/data/services/lms/api.ts index 6ed5e0065..b1c64ee45 100644 --- a/src/data/services/lms/api.ts +++ b/src/data/services/lms/api.ts @@ -4,6 +4,8 @@ import urls from 'data/services/lms/urls'; import { stringifyUrl } from 'data/services/lms/utils'; import eventNames from 'tracking/constants'; +import { ProgramData } from '../../types'; + const initializeList = async (user) => { const { data } = await getAuthenticatedHttpClient().get( stringifyUrl(urls.getInitApiUrl(), { [apiKeys.user]: user }), @@ -82,6 +84,12 @@ const sendConfirmEmail = async (sendEmailUrl: string) => { return response; }; +const fetchProgramsListData = async (): Promise => { + const url = urls.programsApiUrl(); + const { data } = await getAuthenticatedHttpClient().get(url); + return data; +}; + export { initializeList, unenrollFromCourse, @@ -92,4 +100,5 @@ export { logShare, createCreditRequest, sendConfirmEmail, + fetchProgramsListData, }; diff --git a/src/data/services/lms/urls.js b/src/data/services/lms/urls.js index 445e09d56..65b228b6f 100644 --- a/src/data/services/lms/urls.js +++ b/src/data/services/lms/urls.js @@ -30,6 +30,8 @@ export const creditPurchaseUrl = (courseId) => { }; export const creditRequestUrl = (providerId) => `${getApiUrl()}/credit/v1/providers/${providerId}/request/`; +export const programsApiUrl = () => `${getApiUrl()}/dashboard/v0/programs/`; + export default StrictDict({ getApiUrl, baseAppUrl, @@ -42,4 +44,5 @@ export default StrictDict({ learningMfeUrl, programsUrl, updateEmailSettings, + programsApiUrl, }); diff --git a/src/data/types/index.d.ts b/src/data/types/index.d.ts new file mode 100644 index 000000000..c93a7992a --- /dev/null +++ b/src/data/types/index.d.ts @@ -0,0 +1,41 @@ +export interface ProgramData { + uuid: string, + title: string, + type: string, + bannerImage: { + small: ImageData, + medium: ImageData, + large: ImageData, + xSmall: ImageData, + }, + authoringOrganizations?: AuthoringOrganization[], + progress: Progress, +} + +export interface ImageData { + height: number, + width: number, + url: string, +} + +export interface AuthoringOrganization { + uuid: string, + key: string, + name: string, + logoImageUrl: string, + certificateLogoImageUrl: string | null, +} + +export interface Progress { + inProgress: number, + notStarted: number, + completed: number, +} + +export interface ProgramCardProps { + program: ProgramData, +} + +export interface ExploreProgramsCTAProps { + hasEnrollments?: boolean, +} diff --git a/src/index.jsx b/src/index.jsx index 93bfec6b7..3501624b4 100755 --- a/src/index.jsx +++ b/src/index.jsx @@ -2,10 +2,10 @@ import 'core-js/stable'; import 'regenerator-runtime/runtime'; -import React, { StrictMode } from 'react'; +import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { - Route, Navigate, Routes, + Navigate, Route, Routes, } from 'react-router-dom'; import { @@ -18,11 +18,15 @@ import { APP_INIT_ERROR, initialize, subscribe, + getConfig, mergeConfig, } from '@edx/frontend-platform'; +import { FooterSlot } from '@edx/frontend-component-footer'; +import LearnerDashboardHeader from 'containers/LearnerDashboardHeader'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import ContextProviders from 'data/context'; +import { ProgramsList } from './containers/ProgramDashboard'; import { configuration } from './config'; import messages from './i18n'; @@ -45,10 +49,15 @@ subscribe(APP_READY, () => { + } /> + {getConfig().ENABLE_PROGRAM_DASHBOARD && ( + } /> + )} } /> + diff --git a/src/index.test.jsx b/src/index.test.jsx index 0b826e3b7..18f62dbad 100644 --- a/src/index.test.jsx +++ b/src/index.test.jsx @@ -25,6 +25,9 @@ jest.mock('react-dom/client', () => { }); jest.mock('@edx/frontend-platform', () => ({ + getConfig: jest.fn(() => ({ + ENABLE_PROGRAM_DASHBOARD: true, + })), mergeConfig: jest.fn(), ensureConfig: jest.fn(), APP_READY: 'app-is-ready-key', @@ -35,6 +38,13 @@ jest.mock('@edx/frontend-platform', () => ({ jest.mock('./App', () => 'App'); +jest.mock('@edx/frontend-component-footer', () => ({ + FooterSlot: jest.fn(() =>
FooterSlot
), +})); + +jest.mock('containers/LearnerDashboardHeader', () => 'LearnerDashboardHeader'); +jest.mock('containers/ProgramDashboard', () => 'ProgramDashboard'); + describe('app registry', () => { let getElement;