From 648b55e97e977443933da7d4cd4904dd7468568e Mon Sep 17 00:00:00 2001 From: Ejaz Ahmad <86868918+jajjibhai008@users.noreply.github.com> Date: Mon, 29 Apr 2024 15:00:48 +0500 Subject: [PATCH] feat: integrate new pathway section in academy detail page (#1068) --- .../academies/AcademyContentCard.jsx | 61 ++++---------- .../academies/AcademyDetailPage.jsx | 71 +++++++++------- src/components/academies/PathwaysSection.jsx | 83 ++++++++++++++----- src/components/academies/data/hooks.js | 37 +++++++++ .../academies/data/tests/hooks.test.js | 74 ++++++++++++++++- .../tests/AcademyDetailPage.test.jsx | 15 ++++ .../academies/tests/PathwaysSection.test.jsx | 43 +++++++++- src/components/app/data/utils.js | 9 ++ 8 files changed, 291 insertions(+), 102 deletions(-) diff --git a/src/components/academies/AcademyContentCard.jsx b/src/components/academies/AcademyContentCard.jsx index 7accf36a6..7e63efef7 100644 --- a/src/components/academies/AcademyContentCard.jsx +++ b/src/components/academies/AcademyContentCard.jsx @@ -8,9 +8,8 @@ import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import { camelCaseObject } from '@edx/frontend-platform/utils'; import { v4 as uuidv4 } from 'uuid'; import PropTypes from 'prop-types'; -import { LEARNING_TYPE_COURSE, LEARNING_TYPE_EXECUTIVE_EDUCATION, LEARNING_TYPE_PATHWAY } from '@edx/frontend-enterprise-catalog-search/data/constants'; +import { LEARNING_TYPE_COURSE, LEARNING_TYPE_EXECUTIVE_EDUCATION } from '@edx/frontend-enterprise-catalog-search/data/constants'; import SearchCourseCard from '../search/SearchCourseCard'; -import SearchPathwayCard from '../pathway/SearchPathwayCard'; import { useEnterpriseCustomer } from '../app/data'; const AcademyContentCard = ({ @@ -26,7 +25,6 @@ const AcademyContentCard = ({ const intl = useIntl(); const ocmCourses = []; const execEdCourses = []; - const pathways = []; const maxCoursesToShow = 4; useEffect( @@ -66,8 +64,6 @@ const AcademyContentCard = ({ ocmCourses.push(course); } else if (course.learningType === LEARNING_TYPE_EXECUTIVE_EDUCATION) { execEdCourses.push(course); - } else if (course.learningType === LEARNING_TYPE_PATHWAY) { - pathways.push(course); } }); @@ -131,7 +127,7 @@ const AcademyContentCard = ({

{title}

- {contentType !== LEARNING_TYPE_PATHWAY && contentLength > 4 && ( + {contentLength > 4 && (
); @@ -179,7 +166,7 @@ const AcademyContentCard = ({ }; return ( <> -
+
{tags.map(tag => (
) : ( <> @@ -249,24 +236,6 @@ const AcademyContentCard = ({ titleTestId: 'academy-ocm-courses-title', subtitleTestId: 'academy-ocm-courses-subtitle', })} - {renderableContent({ - content: pathways, - contentLength: pathways?.length, - contentType: LEARNING_TYPE_PATHWAY, - title: intl.formatMessage({ - id: 'academy.detail.page.pathways.section.title', - defaultMessage: 'Pathways', - description: 'Title for the pathways section on the academy detail page.', - }), - subtitle: intl.formatMessage({ - id: 'academy.detail.page.pathways.section.subtitle', - defaultMessage: 'Not sure where to start? Try one of our recommended learning tracks.', - description: 'Subtitle for the pathways section on the academy detail page.', - }), - additionalClass: 'academy-pathways-container', - titleTestId: 'academy-pathway-title', - subtitleTestId: 'academy-pathway-subtitle', - })} ) } diff --git a/src/components/academies/AcademyDetailPage.jsx b/src/components/academies/AcademyDetailPage.jsx index 74496cbf5..487e465cc 100644 --- a/src/components/academies/AcademyDetailPage.jsx +++ b/src/components/academies/AcademyDetailPage.jsx @@ -11,8 +11,10 @@ import { getConfig } from '@edx/frontend-platform/config'; import { ArrowDownward } from '@openedx/paragon/icons'; import NotFoundPage from '../NotFoundPage'; import './styles/Academy.scss'; +import { isObjEmpty, useAcademyDetails, useEnterpriseCustomer } from '../app/data'; +import PathwaysSection from './PathwaysSection'; import AcademyContentCard from './AcademyContentCard'; -import { useAcademyDetails, useEnterpriseCustomer } from '../app/data'; +import { useAcademyPathwayData } from './data/hooks'; const AcademyDetailPage = () => { const config = getConfig(); @@ -34,6 +36,8 @@ const AcademyDetailPage = () => { [config.ALGOLIA_APP_ID, config.ALGOLIA_INDEX_NAME, config.ALGOLIA_SEARCH_API_KEY], ); + const [pathway] = useAcademyPathwayData(academyUUID, courseIndex); + if (!academy) { return ( { /> ); } - return ( <> @@ -73,37 +76,41 @@ const AcademyDetailPage = () => { values={{ academyTitle: academy?.title || 'Academy' }} /> -
-

- -

-

- -

-
-
- - - - -
+ {!isObjEmpty(pathway) && ( + <> +
+

+ +

+

+ +

+
+
+ + + + +
+ + )}
- {/* new pathway sectoin will come here */} - + {!isObjEmpty(pathway) && } +

{ values={{ academyTitle: academy?.title }} />

+
+ ( - - -
-
-

Pathway

-

Ai for Leaders

-

Lead with AI. This pathway will introduce you to basics of AI, as well as cover core - concepts for how to use and apply AI responsibly to support the strategy and growth - of your business. -

-
-
- -
-
-
-
-); +const PathwaysSection = ({ pathwayData }) => { + const [isLearnerPathwayModalOpen, openLearnerPathwayModal, onClose] = useToggle(false); + const handleCardClick = () => { + openLearnerPathwayModal(); + }; + return ( + <> + + + +
+
+

+ +

+

{pathwayData.title}

+
+
+
+ +
+
+ + + + ); +}; + +PathwaysSection.propTypes = { + pathwayData: PropTypes.shape({ + title: PropTypes.string, + overview: PropTypes.string, + pathwayUuid: PropTypes.string, + }).isRequired, +}; export default PathwaysSection; diff --git a/src/components/academies/data/hooks.js b/src/components/academies/data/hooks.js index 83826e059..4e953963b 100644 --- a/src/components/academies/data/hooks.js +++ b/src/components/academies/data/hooks.js @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { logError } from '@edx/frontend-platform/logging'; import { getAcademies, getAcademyMetadata } from './service'; +import LearnerPathwayService from '../../pathway/data/service'; export function useAcademyMetadata(academyUUID) { const [academyMetadata, setAcademyMetadata] = useState({}); @@ -51,3 +52,39 @@ export const useAcademies = (enterpriseCustomerUUID) => { return [academies, isLoading, fetchError]; }; + +export const useAcademyPathwayData = (academyUUID, courseIndex) => { + const [pathway, setPathway] = useState({}); + const [fetchError, setFetchError] = useState(null); + const [isLoading, setIsLoading] = useState(true); + useEffect(() => { + const fetchPathway = async () => { + setIsLoading(true); + try { + const { hits: pathwayHits, nbHits: nbPathwayHits } = await courseIndex.search('', { + filters: `(content_type:learnerpathway) AND academy_uuids:${academyUUID}`, + hitsPerPage: 1, + page: 0, + }); + // for now we have only one pathway per academy + if (nbPathwayHits > 0 && pathwayHits[0]?.uuid) { + const learnerPathwayUuid = pathwayHits[0].uuid; + const learnerPathwayService = new LearnerPathwayService({ learnerPathwayUuid }); + const data = await learnerPathwayService.fetchLearnerPathwayData(); + setPathway({ title: data?.title, overview: data?.overview, pathwayUuid: learnerPathwayUuid }); + } else { + setPathway({}); + } + } catch (error) { + setFetchError(error); + logError(error); + } finally { + setIsLoading(false); + } + }; + + fetchPathway(); + }, [academyUUID, courseIndex]); + + return [pathway, isLoading, fetchError]; +}; diff --git a/src/components/academies/data/tests/hooks.test.js b/src/components/academies/data/tests/hooks.test.js index dd37728f1..516492f86 100644 --- a/src/components/academies/data/tests/hooks.test.js +++ b/src/components/academies/data/tests/hooks.test.js @@ -1,7 +1,11 @@ import { renderHook } from '@testing-library/react-hooks'; -import { useAcademyMetadata, useAcademies } from '../hooks'; +import axios from 'axios'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { useAcademyMetadata, useAcademies, useAcademyPathwayData } from '../hooks'; import { getAcademies, getAcademyMetadata } from '../service'; +jest.mock('axios'); + const ACADEMY_UUID = 'b48ff396-03b4-467f-a4cc-da4327156984'; const ACADEMY_MOCK_DATA = { uuid: ACADEMY_UUID, @@ -26,7 +30,9 @@ jest.mock('@edx/frontend-platform/logging', () => ({ logError: jest.fn(), })); jest.mock('@edx/frontend-platform/auth', () => ({ - getAuthenticatedUser: jest.fn(() => ({ id: 12345 })), + ...jest.requireActual('@edx/frontend-platform/auth'), + getAuthenticatedUser: jest.fn(), + getAuthenticatedHttpClient: jest.fn(), })); jest.mock('@edx/frontend-platform/config', () => ({ ...jest.requireActual('@edx/frontend-platform/config'), @@ -40,6 +46,17 @@ jest.mock('../service.js', () => ({ getAcademies: jest.fn(), })); +jest.mock('@edx/frontend-platform/auth'); +getAuthenticatedHttpClient.mockReturnValue(axios); + +jest.mock('../../../pathway/data/service', () => jest.fn().mockImplementation(() => ({ + fetchLearnerPathwayData: jest.fn().mockResolvedValue({ + title: 'Pathway Title', + overview: 'Pathway Overview', + pathwayUuid: '1234', + }), +}))); + describe('useAcademyMetadata', () => { it('returns academy metadata', async () => { getAcademyMetadata.mockReturnValue(ACADEMY_MOCK_DATA); @@ -63,3 +80,56 @@ describe('useAcademyMetadata', () => { expect(result.current[0][0].uuid).toEqual(ACADEMY_UUID); }); }); + +describe('useAcademyPathwayData', () => { + const academyUUID = '123456'; + const courseIndex = { + search: jest.fn(), + }; + + beforeEach(() => { + axios.mockClear(); + courseIndex.search.mockClear(); + }); + + it('fetches pathway data successfully', async () => { + courseIndex.search.mockResolvedValue({ + hits: [{ uuid: '1234' }], + nbHits: 1, + }); + + const { result, waitForNextUpdate } = renderHook(() => useAcademyPathwayData(academyUUID, courseIndex)); + expect(result.current).toEqual([{ title: undefined, overview: undefined, pathwayUuid: undefined }, true, null]); + + await waitForNextUpdate(); + expect(result.current).toEqual([{ title: 'Pathway Title', overview: 'Pathway Overview', pathwayUuid: '1234' }, false, null]); + }); + it('handles error during data fetching', async () => { + const errorMessage = 'Failed to fetch data'; + courseIndex.search.mockRejectedValue(new Error(errorMessage)); + + const { result, waitForNextUpdate } = renderHook(() => useAcademyPathwayData(academyUUID, courseIndex)); + expect(result.current).toEqual([{ title: undefined, overview: undefined, pathwayUuid: undefined }, true, null]); + + await waitForNextUpdate(); + expect(result.current).toEqual([ + { title: undefined, overview: undefined, pathwayUuid: undefined }, + false, new Error(errorMessage), + ]); + }); + it('handle case when we not get any pathway data from algolia', async () => { + courseIndex.search.mockResolvedValue({ + hits: [], + nbHits: 0, + }); + + const { result, waitForNextUpdate } = renderHook(() => useAcademyPathwayData(academyUUID, courseIndex)); + expect(result.current).toEqual([{ title: undefined, overview: undefined, pathwayUuid: undefined }, true, null]); + + await waitForNextUpdate(); + expect(result.current).toEqual([ + {}, + false, null, + ]); + }); +}); diff --git a/src/components/academies/tests/AcademyDetailPage.test.jsx b/src/components/academies/tests/AcademyDetailPage.test.jsx index c611e0610..2468f12a9 100644 --- a/src/components/academies/tests/AcademyDetailPage.test.jsx +++ b/src/components/academies/tests/AcademyDetailPage.test.jsx @@ -12,6 +12,7 @@ import { renderWithRouter } from '../../../utils/tests'; import AcademyDetailPage from '../AcademyDetailPage'; import { useAcademyDetails, useEnterpriseCustomer } from '../../app/data'; import { enterpriseCustomerFactory } from '../../app/data/services/data/__factories__'; +import { useAcademyPathwayData } from '../data/hooks'; // config const APP_CONFIG = { @@ -57,6 +58,15 @@ const ALOGLIA_MOCK_DATA = { nbHits: 2, }; +const mockPathwayResponse = [ + { + title: 'Pathway Title', + overview: 'Pathway Overview', + pathwayUuid: '9d7c7c42-682d-4fa4-a133-2913e939f771', + }, + false, + null, +]; // endpoints jest.mock('react-router-dom', () => ({ @@ -105,6 +115,10 @@ jest.mock('../../app/data', () => ({ useEnterpriseCustomer: jest.fn(), useAcademyDetails: jest.fn(), })); +jest.mock('../data/hooks', () => ({ + ...jest.requireActual('../data/hooks'), + useAcademyPathwayData: jest.fn(), +})); const AcademyDetailPageWrapper = () => ( @@ -119,6 +133,7 @@ describe('', () => { jest.clearAllMocks(); useEnterpriseCustomer.mockReturnValue({ data: mockEnterpriseCustomer }); useAcademyDetails.mockReturnValue({ data: ACADEMY_MOCK_DATA }); + useAcademyPathwayData.mockReturnValue(mockPathwayResponse); }); it('renders academy detail page', async () => { renderWithRouter(); diff --git a/src/components/academies/tests/PathwaysSection.test.jsx b/src/components/academies/tests/PathwaysSection.test.jsx index b00afcb54..10bdf2b0b 100644 --- a/src/components/academies/tests/PathwaysSection.test.jsx +++ b/src/components/academies/tests/PathwaysSection.test.jsx @@ -1,16 +1,53 @@ import React from 'react'; -import { render, screen } from '@testing-library/react'; +import { render, screen, fireEvent } from '@testing-library/react'; import '@testing-library/jest-dom'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; import PathwaysSection from '../PathwaysSection'; +import { useEnterpriseCustomer } from '../../app/data'; +import { enterpriseCustomerFactory } from '../../app/data/services/data/__factories__'; + +jest.mock('react-router-dom', () => ({ + useParams: jest.fn().mockReturnValue({ academyUUID: '123' }), +})); +jest.mock('../../app/data', () => ({ + ...jest.requireActual('../../app/data'), + useEnterpriseCustomer: jest.fn(), +})); +const pathwayData = { + title: 'Ai for Leaders', + overview: '

Pathway overview

', +}; +const mockEnterpriseCustomer = enterpriseCustomerFactory(); describe('PathwaysSection', () => { it('renders pathway title and description correctly', () => { - render(); + useEnterpriseCustomer.mockReturnValue({ data: mockEnterpriseCustomer }); + render( + + + , + ); expect(screen.getByText('Ai for Leaders')).toBeInTheDocument(); + expect(screen.getByText('Pathway overview')).toBeInTheDocument(); }); it('renders launch button correctly', () => { - render(); + render( + + + , + ); expect(screen.getByRole('button', { name: 'Launch Pathway' })).toBeInTheDocument(); }); + + it('opens the learner pathway modal when launch button is clicked', () => { + render( + + + , + ); + const launchButton = screen.getByRole('button', { name: 'Launch Pathway' }); + fireEvent.click(launchButton); + expect(screen.getByText('Ai for Leaders')).toBeInTheDocument(); + }); }); diff --git a/src/components/app/data/utils.js b/src/components/app/data/utils.js index f0fea106b..f38bd8553 100644 --- a/src/components/app/data/utils.js +++ b/src/components/app/data/utils.js @@ -575,3 +575,12 @@ export function findHighestLevelEntitlementSku(entitlements) { } return findHighestLevelSkuByEntityModeType(entitlements, entitlement => entitlement.mode); } + +/** + * check if an object is empty + * @param {Object} obj + * @returns {boolean} + */ +export function isObjEmpty(obj) { + return Object.keys(obj).length === 0; +}