Skip to content

Commit

Permalink
feat: add date metadata for course run assignments on coures about pa…
Browse files Browse the repository at this point in the history
…ge (#1145)
  • Loading branch information
brobro10000 authored Aug 14, 2024
1 parent 4f4566e commit 8dafafc
Show file tree
Hide file tree
Showing 9 changed files with 480 additions and 25 deletions.
36 changes: 36 additions & 0 deletions src/components/app/data/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
14 changes: 4 additions & 10 deletions src/components/course/course-header/CourseHeader.jsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand All @@ -35,6 +27,7 @@ import {
useEnterpriseCustomerContainsContent,
useIsAssignmentsOnlyLearner,
} from '../../app/data';
import CourseImportantDates from './CourseImportantDates';

const CourseHeader = () => {
const location = useLocation();
Expand Down Expand Up @@ -136,6 +129,7 @@ const CourseHeader = () => {
previewImage={courseMetadata.image?.src || courseMetadata.video?.image}
previewVideoURL={courseMetadata.video?.src}
/>
<CourseImportantDates />
</Col>
<Col xs={12}>
{containsContentItems && (
Expand Down
133 changes: 133 additions & 0 deletions src/components/course/course-header/CourseImportantDates.jsx
Original file line number Diff line number Diff line change
@@ -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,
}) => (
<Row className="course-important-date border-bottom mx-0 py-2.5">
<Col className="px-0">
<Stack direction="horizontal" gap={2}>
<Icon size="sm" src={Calendar} />
{label}
</Stack>
</Col>
<Col>
{children}
</Col>
</Row>
);

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 (
<section className="assignments-important-dates mt-4 small">
<h3>
{intl.formatMessage(messages.importantDates)}
</h3>
{enrollByDate && (
<CourseImportantDate label={intl.formatMessage(messages.enrollByDate)}>
{enrollByDate}
</CourseImportantDate>
)}
{courseStartDate && (
<CourseImportantDate label={courseHasStartedLabel}>
{courseStartDate}
</CourseImportantDate>
)}
</section>
);
};

export default CourseImportantDates;
6 changes: 3 additions & 3 deletions src/components/course/course-header/CoursePreview.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -34,11 +34,11 @@ const CoursePreview = ({ previewImage, previewVideoURL }) => {
</div>
) : (
<button
className="video-trigger mw-100"
className="video-trigger w-100"
onClick={() => playVideo(true)}
type="button"
>
<Image src={previewImage} className="video-thumb" alt="" />
<Image src={previewImage} className="w-100" alt="" />
<div className="video-trigger-cta btn btn-inverse-primary">
<PlayCircleFilled className="mr-1" />
<FormattedMessage
Expand Down
33 changes: 33 additions & 0 deletions src/components/course/course-header/tests/CourseHeader.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import '@testing-library/jest-dom/extend-expect';

import { IntlProvider } from '@edx/frontend-platform/i18n';
import { AppContext } from '@edx/frontend-platform/react';
import { useParams } from 'react-router-dom';
import CourseHeader from '../CourseHeader';

import { COURSE_PACING_MAP } from '../../data/constants';
Expand All @@ -18,6 +19,7 @@ import {
useEnterpriseCustomer,
useEnterpriseCustomerContainsContent,
useIsAssignmentsOnlyLearner,
useRedeemablePolicies,
} from '../../../app/data';
import { renderWithRouterProvider } from '../../../../utils/tests';
import { authenticatedUserFactory, enterpriseCustomerFactory } from '../../../app/data/services/data/__factories__';
Expand All @@ -33,6 +35,11 @@ jest.mock('../../LicenseRequestedAlert', () => function LicenseRequestedAlert()
return <div data-testid="license-requested-alert" />;
});

jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: jest.fn(),
}));

jest.mock('../../../app/data', () => ({
...jest.requireActual('../../../app/data'),
useEnterpriseCustomer: jest.fn(),
Expand All @@ -43,6 +50,7 @@ jest.mock('../../../app/data', () => ({
useIsAssignmentsOnlyLearner: jest.fn(),
useCourseReviews: jest.fn(),
usePassLearnerCsodParams: jest.fn(),
useRedeemablePolicies: jest.fn(),
}));

jest.mock('../../data', () => ({
Expand Down Expand Up @@ -118,6 +126,29 @@ const mockEnterpriseCourseEnrollment = {
linkToCourse: 'http://course.url',
mode: COURSE_MODES_MAP.VERIFIED,
};

const mockBaseRedeemablePolicies = {
redeemablePolicies: [],
expiredPolicies: [],
unexpiredPolicies: [],
learnerContentAssignments: {
assignments: [],
hasAssignments: false,
allocatedAssignments: [],
hasAllocatedAssignments: false,
acceptedAssignments: [],
hasAcceptedAssignments: false,
canceledAssignments: [],
hasCanceledAssignments: false,
expiredAssignments: [],
hasExpiredAssignments: false,
erroredAssignments: [],
hasErroredAssignments: false,
assignmentsForDisplay: [],
hasAssignmentsForDisplay: false,
},
};

const mockAuthenticatedUser = authenticatedUserFactory();
const CourseHeaderWrapper = () => (
<IntlProvider locale="en">
Expand All @@ -130,6 +161,7 @@ const CourseHeaderWrapper = () => (
describe('<CourseHeader />', () => {
beforeEach(() => {
jest.clearAllMocks();
useParams.mockReturnValue({ courseKey: 'edX+DemoX' });
useEnterpriseCustomer.mockReturnValue({ data: mockEnterpriseCustomer });
useEnterpriseCourseEnrollments.mockReturnValue({
data: defaultCourseEnrollmentsState,
Expand All @@ -145,6 +177,7 @@ describe('<CourseHeader />', () => {
useIsAssignmentsOnlyLearner.mockReturnValue(false);
useIsCourseAssigned.mockReturnValue(false);
useCourseReviews.mockReturnValue({ data: mockCourseReviews });
useRedeemablePolicies.mockReturnValue({ data: mockBaseRedeemablePolicies });
});

test('renders breadcrumb', () => {
Expand Down
Loading

0 comments on commit 8dafafc

Please sign in to comment.