Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions RELEASE.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
Release Notes
=============

Version 0.64.3
--------------

- feat: Adds Google Tag Manager (GTM) support (#3236)
- Fix program dashboard "completed x of y" counts (#3217)
- submit course / program as product_of_interest if the field is on the form (#3231)
- vector learning resources sortby/sorting support (#3228)

Version 0.64.2 (Released April 23, 2026)
--------------

Expand Down
6 changes: 6 additions & 0 deletions env/frontend.env
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ NEXT_PUBLIC_VERSION="local-dev"
# Hubspot tracking - xprodev account for dev/RC environments
NEXT_PUBLIC_HUBSPOT_PORTAL_ID=${HUBSPOT_PORTAL_ID}

# Google Tag Manager configured server-side (no NEXT_PUBLIC_*); values will be included in the GTM script/iframe URLs
GTM_TRACKING_ID=${GTM_TRACKING_ID}
GTM_AUTH=${GTM_AUTH} # NOTE: This is not a secret.
GTM_PREVIEW=${GTM_PREVIEW}
GTM_COOKIES_WIN=${GTM_COOKIES_WIN}

# OpenTelemetry tracing (server-side only — no NEXT_PUBLIC_ prefix needed)
# These are read at runtime by the OTEL NodeSDK and injected by Kubernetes for
# deployed environments. Sampling is disabled locally (0.0); set
Expand Down
6 changes: 6 additions & 0 deletions env/frontend.local.example.env
Original file line number Diff line number Diff line change
@@ -1,2 +1,8 @@
NEXT_PUBLIC_EMBEDLY_KEY=""
NEXT_PUBLIC_STAY_UPDATED_HUBSPOT_FORM_ID=""

# Optional local GTM overrides (server-side runtime)
# GTM_TRACKING_ID=
# GTM_AUTH=
# GTM_PREVIEW=
# GTM_COOKIES_WIN=
7 changes: 7 additions & 0 deletions env/shared.local.example.env
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,10 @@ MITOL_API_DOMAIN=api.open.odl.local # dev only, should match domain of above
# https://developers.google.com/recaptcha/docs/faq#id-like-to-run-automated-tests-with-recaptcha.-what-should-i-do
# RECAPTCHA_SITE_KEY=6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI
# RECAPTCHA_SECRET_KEY=6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe

# Google Tag Manager (server-side frontend runtime variables)
# GTM_TRACKING_ID=
# GTM_AUTH=
# GTM_PREVIEW=
# GTM_COOKIES_WIN=
# DEV values should be copied from GTM Environments UI.
71 changes: 38 additions & 33 deletions frontends/api/src/generated/v0/api.ts

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -1739,6 +1739,233 @@ describe("EnrollmentDisplay", () => {
},
)

test("Overall completion count caps elective completions at min_number_of value", async () => {
/**
* Program has 2 required courses (all_of) and 3 electives (min_number_of=1).
* User completes 1 required course + 2 electives.
* Bug: counts all 3 completions → "3 of 3 courses" (wrong).
* Fix: caps elective contribution at 1 → "2 of 3 courses" (correct).
*/
const mitxOnlineUser = mitxonline.factories.user.user()
setMockResponse.get(mitxonline.urls.userMe.get(), mitxOnlineUser)

const reqTree =
new mitxonline.factories.requirements.RequirementTreeBuilder()
const required = reqTree.addOperator({
operator: "all_of",
title: "Required Courses",
})
required.addCourse({ course: 1 })
required.addCourse({ course: 2 })

const electives = reqTree.addOperator({
operator: "min_number_of",
operator_value: "1",
title: "Electives",
})
electives.addCourse({ course: 3 })
electives.addCourse({ course: 4 })
electives.addCourse({ course: 5 })

const program = mitxonline.factories.programs.program({
id: 5555,
courses: [1, 2, 3, 4, 5],
req_tree: reqTree.serialize(),
})

const run1 = mitxonline.factories.courses.courseRun({ id: 101 })
const run3 = mitxonline.factories.courses.courseRun({ id: 103 })
const run4 = mitxonline.factories.courses.courseRun({ id: 104 })

const courses = {
count: 5,
next: null,
previous: null,
results: [
mitxonline.factories.courses.course({
id: 1,
courseruns: [run1],
}),
mitxonline.factories.courses.course({
id: 2,
courseruns: [mitxonline.factories.courses.courseRun({ id: 102 })],
}),
mitxonline.factories.courses.course({
id: 3,
courseruns: [run3],
}),
mitxonline.factories.courses.course({
id: 4,
courseruns: [run4],
}),
mitxonline.factories.courses.course({
id: 5,
courseruns: [mitxonline.factories.courses.courseRun({ id: 105 })],
}),
],
}

// Course 1 (required) completed, courses 3 and 4 (electives) completed
const completedGrade = mitxonline.factories.enrollment.grade({
passed: true,
})
const enrollments = [
mitxonline.factories.enrollment.courseEnrollment({
run: { ...run1, course: courses.results[0] },
grades: [completedGrade],
}),
mitxonline.factories.enrollment.courseEnrollment({
run: { ...run3, course: courses.results[2] },
grades: [completedGrade],
}),
mitxonline.factories.enrollment.courseEnrollment({
run: { ...run4, course: courses.results[3] },
grades: [completedGrade],
}),
]

mockedUseFeatureFlagEnabled.mockReturnValue(true)
setMockResponse.get(
mitxonline.urls.enrollment.enrollmentsListV3(),
enrollments,
)
setMockResponse.get(
mitxonline.urls.programEnrollments.enrollmentsListV3(),
[
mitxonline.factories.enrollment.programEnrollmentV3({
program: {
id: program.id,
title: program.title,
live: program.live,
program_type: program.program_type,
readable_id: program.readable_id,
},
}),
],
)
setMockResponse.get(mitxonline.urls.programs.programDetail(5555), program)
setMockResponse.get(
mitxonline.urls.courses.coursesList({
id: program.courses,
page_size: program.courses.length,
}),
courses,
)

renderWithProviders(<EnrollmentDisplay programId={5555} />)

// 1 required completed + min(2 electives completed, 1 required) = 2 total completed
// total = 2 required + 1 elective min = 3
await screen.findByText(/2 of 3 courses/)
})

test("Section header caps displayed count at operator_value for min_number_of sections", async () => {
/**
* Electives section with min_number_of=1 and 3 completed courses.
* The section header should show "Completed 1 of 1", not "Completed 3 of 1".
*/
const mitxOnlineUser = mitxonline.factories.user.user()
setMockResponse.get(mitxonline.urls.userMe.get(), mitxOnlineUser)

const reqTree =
new mitxonline.factories.requirements.RequirementTreeBuilder()
const electives = reqTree.addOperator({
operator: "min_number_of",
operator_value: "1",
title: "Electives",
})
electives.addCourse({ course: 1 })
electives.addCourse({ course: 2 })
electives.addCourse({ course: 3 })

const program = mitxonline.factories.programs.program({
id: 6666,
courses: [1, 2, 3],
req_tree: reqTree.serialize(),
})

const run1 = mitxonline.factories.courses.courseRun({ id: 201 })
const run2 = mitxonline.factories.courses.courseRun({ id: 202 })
const run3 = mitxonline.factories.courses.courseRun({ id: 203 })

const courses = {
count: 3,
next: null,
previous: null,
results: [
mitxonline.factories.courses.course({
id: 1,
courseruns: [run1],
}),
mitxonline.factories.courses.course({
id: 2,
courseruns: [run2],
}),
mitxonline.factories.courses.course({
id: 3,
courseruns: [run3],
}),
],
}

// All 3 electives completed
const completedGrade = mitxonline.factories.enrollment.grade({
passed: true,
})
const enrollments = [
mitxonline.factories.enrollment.courseEnrollment({
run: { ...run1, course: courses.results[0] },
grades: [completedGrade],
}),
mitxonline.factories.enrollment.courseEnrollment({
run: { ...run2, course: courses.results[1] },
grades: [completedGrade],
}),
mitxonline.factories.enrollment.courseEnrollment({
run: { ...run3, course: courses.results[2] },
grades: [completedGrade],
}),
]

mockedUseFeatureFlagEnabled.mockReturnValue(true)
setMockResponse.get(
mitxonline.urls.enrollment.enrollmentsListV3(),
enrollments,
)
setMockResponse.get(
mitxonline.urls.programEnrollments.enrollmentsListV3(),
[
mitxonline.factories.enrollment.programEnrollmentV3({
program: {
id: program.id,
title: program.title,
live: program.live,
program_type: program.program_type,
readable_id: program.readable_id,
},
}),
],
)
setMockResponse.get(mitxonline.urls.programs.programDetail(6666), program)
setMockResponse.get(
mitxonline.urls.courses.coursesList({
id: program.courses,
page_size: program.courses.length,
}),
courses,
)

renderWithProviders(<EnrollmentDisplay programId={6666} />)

await screen.findByText("Electives")

// Section header should show "Completed 1 of 1", capped at operator_value
const sectionCount = screen.getByTestId("section-completion-count")
expect(sectionCount).toHaveTextContent("Completed 1 of 1")
// Overall header should also show 1 of 1 (only electives section)
expect(screen.getByText(/1 of 1 courses/)).toBeInTheDocument()
})

test("Returns 404 page when user is not enrolled in the program", async () => {
const mitxOnlineUser = mitxonline.factories.user.user()
setMockResponse.get(mitxonline.urls.userMe.get(), mitxOnlineUser)
Expand Down
Loading
Loading