From b6982d7e7189af280b5c04d9a6016445c55fc532 Mon Sep 17 00:00:00 2001 From: Troy Sankey Date: Tue, 23 Jul 2024 16:48:43 -0700 Subject: [PATCH] fix: late enrollment should be less sensitive to courserun state ENT-9259 --- src/components/app/data/utils.js | 53 ++++++++++++++++++--------- src/components/app/data/utils.test.js | 51 ++++++++++++++++++++++++-- 2 files changed, 84 insertions(+), 20 deletions(-) diff --git a/src/components/app/data/utils.js b/src/components/app/data/utils.js index f4080bf87..97e6847a3 100644 --- a/src/components/app/data/utils.js +++ b/src/components/app/data/utils.js @@ -3,7 +3,7 @@ import { logError } from '@edx/frontend-platform/logging'; import { ASSIGNMENT_TYPES, POLICY_TYPES } from '../../enterprise-user-subsidy/enterprise-offers/data/constants'; import { LICENSE_STATUS } from '../../enterprise-user-subsidy/data/constants'; -import { getBrandColorsFromCSSVariables, isTodayWithinDateThreshold } from '../../../utils/common'; +import { getBrandColorsFromCSSVariables, isDefinedAndNotNull, isTodayWithinDateThreshold } from '../../../utils/common'; import { COURSE_STATUSES, SUBSIDY_TYPE } from '../../../constants'; import { LATE_ENROLLMENTS_BUFFER_DAYS } from '../../../config/constants'; import { @@ -461,37 +461,56 @@ export function isArchived(courseRun) { } /** - * Returns list of available that are marketable, enrollable, and not archived. + * Returns list of available runs that are marketable, enrollable, and not archived. * - * @param {object} course + * This function is used by logic that determines which runs should be visible on the course about page. + * + * @param {object} course - The course containing runs which will be a superset of the returned runs. + * @param {number} isEnrollableBufferDays - number of days to buffer the enrollment end date, or undefined. * @returns List of course runs. */ export function getAvailableCourseRuns({ course, isEnrollableBufferDays }) { if (!course?.courseRuns) { return []; } - const availableCourseRunsFilter = (courseRun) => { - if (!courseRun.isMarketable || isArchived(courseRun)) { - return false; - } - if (isEnrollableBufferDays === undefined) { - return courseRun.isEnrollable; - } + // These are the standard rules used for determining whether a run is "available". + const standardAvailableCourseRunsFilter = (courseRun) => ( + courseRun.isMarketable && !isArchived(courseRun) && courseRun.isEnrollable + ); - const today = dayjs(); - if (courseRun.enrollmentStart && today.isBefore(dayjs(courseRun.enrollmentStart))) { - // In cases where we don't expect the buffer to change behavior, fallback to the backend-provided value. - return courseRun.isEnrollable; + // These are more relaxed availability rules when late enrollment is applicable. We still never show archived courses, + // but the rules around the following fields are relaxed: + // + // * courseRun.isEnrollable: This field represents the enrollment window actually stored in the database. However, + // during late enrollment we expand the end date of the enrollment window by isEnrollableBufferDays. + // * courseRun.isMarketable: This field is True when the run is published, has seats, and has a marketing URL. Since + // late enrollment potentially means enrolling into an unpublished run, we must ignore the run state. + const lateEnrollmentAvailableCourseRunsFilter = (courseRun) => { + if ( + isArchived(courseRun) + // The next two checks are in lieu of isMarketable which is otherwise overly sensitive to courserun state. + || !courseRun.seats?.length + || !courseRun.marketingUrl + ) { + return false; } - if (!courseRun.enrollmentEnd) { + // Finally, check against an expanded enrollment window. + const today = dayjs(); + if (!courseRun.enrollmentEnd || (courseRun.enrollmentStart && today.isBefore(dayjs(courseRun.enrollmentStart)))) { // In cases where we don't expect the buffer to change behavior, fallback to the backend-provided value. - return courseRun.isEnrollable; + return standardAvailableCourseRunsFilter(courseRun); } const bufferedEnrollDeadline = dayjs(courseRun.enrollmentEnd).add(isEnrollableBufferDays, 'day'); return today.isBefore(bufferedEnrollDeadline); }; - return course.courseRuns.filter(availableCourseRunsFilter); + + // isEnrollableBufferDays is used as a heuristic to determine if the late enrollment feature is enabled. + return course.courseRuns.filter( + isDefinedAndNotNull(isEnrollableBufferDays) + ? lateEnrollmentAvailableCourseRunsFilter + : standardAvailableCourseRunsFilter, + ); } export function getCatalogsForSubsidyRequests({ diff --git a/src/components/app/data/utils.test.js b/src/components/app/data/utils.test.js index 1faf25b62..39538ae74 100644 --- a/src/components/app/data/utils.test.js +++ b/src/components/app/data/utils.test.js @@ -533,6 +533,8 @@ describe('getAvailableCourseRuns', () => { title: 'Demo Course', availability: COURSE_AVAILABILITY_MAP.STARTING_SOON, isMarketable: true, + seats: [{ sku: '835BEA7' }], + marketingUrl: 'https://foo.bar/', isEnrollable: true, enrollmentStart: '2023-07-01T00:00:00Z', enrollmentEnd: '2023-08-01T00:00:00Z', @@ -543,30 +545,73 @@ describe('getAvailableCourseRuns', () => { title: 'Demo Course', availability: COURSE_AVAILABILITY_MAP.STARTING_SOON, isMarketable: true, + seats: [{ sku: '835BEA7' }], + marketingUrl: 'https://foo.bar/', isEnrollable: false, enrollmentStart: '2023-06-01T00:00:00Z', enrollmentEnd: '2023-07-01T00:00:00Z', }, - // Run with long-ago closed enrollment, but somehow still "Starting Soon". This is very edge-casey. + // Run with recently closed enrollment, but is not marketable because the course became unpublished. This should + // still be redeemable under late enrollment. { key: 'course-v1:edX+DemoX+Demo_Course3', title: 'Demo Course', availability: COURSE_AVAILABILITY_MAP.STARTING_SOON, + isMarketable: false, + seats: [{ sku: '835BEA7' }], + marketingUrl: 'https://foo.bar/', + isEnrollable: false, + enrollmentStart: '2023-06-01T00:00:00Z', + enrollmentEnd: '2023-07-01T00:00:00Z', + }, + // Run with recently closed enrollment, but is not really not marketable. + { + key: 'course-v1:edX+DemoX+Demo_Course4', + title: 'Demo Course', + availability: COURSE_AVAILABILITY_MAP.STARTING_SOON, + isMarketable: false, + seats: [], + marketingUrl: undefined, + isEnrollable: false, + enrollmentStart: '2023-06-01T00:00:00Z', + enrollmentEnd: '2023-07-01T00:00:00Z', + }, + // Run with long-ago closed enrollment, but somehow still "Starting Soon". This is very edge-casey. + { + key: 'course-v1:edX+DemoX+Demo_Course5', + title: 'Demo Course', + availability: COURSE_AVAILABILITY_MAP.STARTING_SOON, isMarketable: true, + seats: [{ sku: '835BEA7' }], + marketingUrl: 'https://foo.bar/', isEnrollable: false, enrollmentStart: '2023-01-01T00:00:00Z', enrollmentEnd: '2023-02-01T00:00:00Z', }, // Run with long-ago closed enrollment, and now running. { - key: 'course-v1:edX+DemoX+Demo_Course4', + key: 'course-v1:edX+DemoX+Demo_Course6', title: 'Demo Course', availability: COURSE_AVAILABILITY_MAP.CURRENT, isMarketable: true, + seats: [{ sku: '835BEA7' }], + marketingUrl: 'https://foo.bar/', isEnrollable: false, enrollmentStart: '2023-01-01T00:00:00Z', enrollmentEnd: '2023-02-01T00:00:00Z', }, + // Run with the enrollment window still in the future. + { + key: 'course-v1:edX+DemoX+Demo_Course7', + title: 'Demo Course', + availability: COURSE_AVAILABILITY_MAP.STARTING_SOON, + isMarketable: true, + seats: [{ sku: '835BEA7' }], + marketingUrl: 'https://foo.bar/', + isEnrollable: false, // enrollment hasn't officially opened yet. + enrollmentStart: '2023-07-10T00:00:00Z', // enrollment hasn't officially opened yet. + enrollmentEnd: '2023-08-01T00:00:00Z', + }, ], }, }; @@ -575,7 +620,7 @@ describe('getAvailableCourseRuns', () => { expect(getAvailableCourseRuns({ course: sampleCourseRunDataWithRecentRuns.courseData })) .toEqual(sampleCourseRunDataWithRecentRuns.courseData.courseRuns.slice(0, 1)); expect(getAvailableCourseRuns({ course: sampleCourseRunDataWithRecentRuns.courseData, isEnrollableBufferDays: 60 })) - .toEqual(sampleCourseRunDataWithRecentRuns.courseData.courseRuns.slice(0, 2)); + .toEqual(sampleCourseRunDataWithRecentRuns.courseData.courseRuns.slice(0, 3)); }); it('returns empty array if course runs are not available', () => { sampleCourseRunData.courseData.courseRuns = [];