From 8dafafc59b78f135dbfa2a3506ad9d685d409c52 Mon Sep 17 00:00:00 2001 From: Hamzah Ullah Date: Wed, 14 Aug 2024 14:49:07 -0400 Subject: [PATCH] feat: add date metadata for course run assignments on coures about page (#1145) --- src/components/app/data/utils.js | 36 ++++ .../course/course-header/CourseHeader.jsx | 14 +- .../course-header/CourseImportantDates.jsx | 133 +++++++++++++ .../course/course-header/CoursePreview.jsx | 6 +- .../course-header/tests/CourseHeader.test.jsx | 33 ++++ .../tests/CourseImportantDates.test.jsx | 182 ++++++++++++++++++ src/components/course/data/constants.js | 1 + src/components/course/data/utils.jsx | 78 +++++++- .../course/styles/_CourseHeader.scss | 22 ++- 9 files changed, 480 insertions(+), 25 deletions(-) create mode 100644 src/components/course/course-header/CourseImportantDates.jsx create mode 100644 src/components/course/course-header/tests/CourseImportantDates.test.jsx diff --git a/src/components/app/data/utils.js b/src/components/app/data/utils.js index 97bac7d120..f71f237236 100644 --- a/src/components/app/data/utils.js +++ b/src/components/app/data/utils.js @@ -750,6 +750,26 @@ export function isEnrollmentUpgradeable(enrollment) { return canUpgradeToVerifiedEnrollment; } +/** + * Determines if allocatedAssignments are courseRun based + * + * @param redeemableLearnerCreditPolicies + * @param courseKey + * @returns { + * { + * hasAssignedCourseRuns: boolean, + * allocatedCourseRunAssignmentKeys: *, + * allocatedCourseRunAssignments: *, + * hasMultipleAssignedCourseRuns: boolean + * } | + * { + * hasAssignedCourseRuns: boolean, + * allocatedCourseRunAssignmentKeys: *[], + * allocatedCourseRunAssignments: *[], + * hasMultipleAssignedCourseRuns: boolean + * } + * } + */ export function determineAllocatedCourseRunAssignmentsForCourse({ redeemableLearnerCreditPolicies, courseKey, @@ -778,6 +798,22 @@ export function determineAllocatedCourseRunAssignmentsForCourse({ }; } +/** + * Transform course metadata to display available runs with multiple allocated course runs + * + * @param hasMultipleAssignedCourseRuns + * @param courseMetadata + * @param allocatedCourseRunAssignmentKeys + * @returns { + * * | + * (* & + * { + * courseRuns: *, + * availableCourseRuns: * + * } + * ) + * } + */ export function transformCourseMetadataByAllocatedCourseRunAssignments({ hasMultipleAssignedCourseRuns, courseMetadata, diff --git a/src/components/course/course-header/CourseHeader.jsx b/src/components/course/course-header/CourseHeader.jsx index 31d9201aaa..25008f771b 100644 --- a/src/components/course/course-header/CourseHeader.jsx +++ b/src/components/course/course-header/CourseHeader.jsx @@ -1,12 +1,7 @@ import { useMemo } from 'react'; import classNames from 'classnames'; import { - Breadcrumb, - Container, - Row, - Col, - Badge, - Hyperlink, + Badge, Breadcrumb, Col, Container, Hyperlink, Row, } from '@openedx/paragon'; import { Link, useLocation, useParams } from 'react-router-dom'; import { FormattedMessage } from '@edx/frontend-platform/i18n'; @@ -15,10 +10,7 @@ import CourseSkills from '../CourseSkills'; import CourseEnrollmentFailedAlert, { ENROLLMENT_SOURCE } from '../CourseEnrollmentFailedAlert'; import CourseRunCards from './CourseRunCards'; -import { - getDefaultProgram, - formatProgramType, -} from '../data/utils'; +import { formatProgramType, getDefaultProgram } from '../data/utils'; import { useCoursePartners, useIsCourseAssigned } from '../data'; import LicenseRequestedAlert from '../LicenseRequestedAlert'; import SubsidyRequestButton from '../SubsidyRequestButton'; @@ -35,6 +27,7 @@ import { useEnterpriseCustomerContainsContent, useIsAssignmentsOnlyLearner, } from '../../app/data'; +import CourseImportantDates from './CourseImportantDates'; const CourseHeader = () => { const location = useLocation(); @@ -136,6 +129,7 @@ const CourseHeader = () => { previewImage={courseMetadata.image?.src || courseMetadata.video?.image} previewVideoURL={courseMetadata.video?.src} /> + {containsContentItems && ( diff --git a/src/components/course/course-header/CourseImportantDates.jsx b/src/components/course/course-header/CourseImportantDates.jsx new file mode 100644 index 0000000000..dba1a26235 --- /dev/null +++ b/src/components/course/course-header/CourseImportantDates.jsx @@ -0,0 +1,133 @@ +import { + Col, Icon, Row, Stack, +} from '@openedx/paragon'; +import { Calendar } from '@openedx/paragon/icons'; +import dayjs from 'dayjs'; +import { useParams, useSearchParams } from 'react-router-dom'; +import { defineMessages, useIntl } from '@edx/frontend-platform/i18n'; +import PropTypes from 'prop-types'; +import { + DATE_FORMAT, DATETIME_FORMAT, getSoonestEarliestPossibleExpirationData, hasCourseStarted, +} from '../data'; +import { + determineAllocatedCourseRunAssignmentsForCourse, + useCourseMetadata, + useRedeemablePolicies, +} from '../../app/data'; + +const messages = defineMessages({ + importantDates: { + id: 'enterprise.course.about.page.important-dates.title', + defaultMessage: 'Important dates', + description: 'Title for the important dates section on the course about page', + }, + enrollByDate: { + id: 'enterprise.course.about.page.important-dates.enroll-by-date', + defaultMessage: 'Enroll-by date', + description: 'Enroll-by date for the important dates section on the course about page', + }, + courseStarts: { + id: 'enterprise.course.about.page.important-dates.course-starts', + defaultMessage: 'Course starts', + description: 'Course starts for the important dates section on the course about page in future tense', + }, + courseStarted: { + id: 'enterprise.course.about.page.important-dates.course-started', + defaultMessage: 'Course started', + description: 'Course started the important dates section on the course about page in past tense', + }, +}); + +const CourseImportantDate = ({ + label, + children, +}) => ( + + + + + {label} + + + + {children} + + +); + +CourseImportantDate.propTypes = { + label: PropTypes.string.isRequired, + children: PropTypes.node.isRequired, +}; + +const CourseImportantDates = () => { + const { courseKey } = useParams(); + const { data: redeemableLearnerCreditPolicies } = useRedeemablePolicies(); + const { data: courseMetadata } = useCourseMetadata(); + const intl = useIntl(); + const { + allocatedCourseRunAssignments, + allocatedCourseRunAssignmentKeys, + hasAssignedCourseRuns, + } = determineAllocatedCourseRunAssignmentsForCourse({ + redeemableLearnerCreditPolicies, + courseKey, + }); + + const [searchParams] = useSearchParams(); + const courseRunKey = searchParams.get('course_run_key')?.replaceAll(' ', '+'); + // Check if the corresponding course run key from query parameters matches an allocated assignment course run key + const doesNotHaveCourseRunAssignmentForCourseRunKey = !!courseRunKey && !allocatedCourseRunAssignmentKeys.includes( + courseRunKey, + ); + if (!hasAssignedCourseRuns || doesNotHaveCourseRunAssignmentForCourseRunKey) { + return null; + } + + // Retrieve soonest expiring enroll-by date + const { soonestExpirationDate, soonestExpiringAssignment } = getSoonestEarliestPossibleExpirationData({ + assignments: allocatedCourseRunAssignments, + dateFormat: DATETIME_FORMAT, + }); + + // Match soonest expiring assignment to the corresponding course start date from course metadata + let soonestExpiringAllocatedAssignmentCourseStartDate = null; + if (soonestExpiringAssignment) { + soonestExpiringAllocatedAssignmentCourseStartDate = courseMetadata.availableCourseRuns.find( + (courseRun) => courseRun.key === soonestExpiringAssignment?.contentKey, + )?.start; + } + + // Parse logic of date existence and labels + const enrollByDate = soonestExpirationDate ?? null; + const courseStartDate = soonestExpiringAllocatedAssignmentCourseStartDate + ? dayjs(soonestExpiringAllocatedAssignmentCourseStartDate).format(DATE_FORMAT) + : null; + const courseHasStartedLabel = hasCourseStarted(courseStartDate) + ? intl.formatMessage(messages.courseStarted) + : intl.formatMessage(messages.courseStarts); + + if (!enrollByDate && !courseStartDate) { + return null; + } + + return ( +
+

+ {intl.formatMessage(messages.importantDates)} +

+ {enrollByDate && ( + + {enrollByDate} + + )} + {courseStartDate && ( + + {courseStartDate} + + )} +
+ ); +}; + +export default CourseImportantDates; diff --git a/src/components/course/course-header/CoursePreview.jsx b/src/components/course/course-header/CoursePreview.jsx index d99ca68d06..f82ce09559 100644 --- a/src/components/course/course-header/CoursePreview.jsx +++ b/src/components/course/course-header/CoursePreview.jsx @@ -3,7 +3,7 @@ import PropTypes from 'prop-types'; import loadable from '@loadable/component'; import { PlayCircleFilled } from '@openedx/paragon/icons'; -import { useToggle, Image, Skeleton } from '@openedx/paragon'; +import { Image, Skeleton, useToggle } from '@openedx/paragon'; import { FormattedMessage } from '@edx/frontend-platform/i18n'; import DelayedFallbackContainer from '../../DelayedFallback/DelayedFallbackContainer'; @@ -34,11 +34,11 @@ const CoursePreview = ({ previewImage, previewVideoURL }) => { ) : (