diff --git a/RELEASE.rst b/RELEASE.rst index 5a84d3cbf1..1c3a3ab92b 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -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) -------------- diff --git a/env/frontend.env b/env/frontend.env index 06e4af6639..0ca5e8be54 100644 --- a/env/frontend.env +++ b/env/frontend.env @@ -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 diff --git a/env/frontend.local.example.env b/env/frontend.local.example.env index f68006d4e8..22925ca0ae 100644 --- a/env/frontend.local.example.env +++ b/env/frontend.local.example.env @@ -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= diff --git a/env/shared.local.example.env b/env/shared.local.example.env index 4fc78a8295..04405a6c3a 100644 --- a/env/shared.local.example.env +++ b/env/shared.local.example.env @@ -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. diff --git a/frontends/api/src/generated/v0/api.ts b/frontends/api/src/generated/v0/api.ts index 46c5b27485..ccc0941c53 100644 --- a/frontends/api/src/generated/v0/api.ts +++ b/frontends/api/src/generated/v0/api.ts @@ -11552,7 +11552,6 @@ export const VectorContentFilesSearchApiAxiosParamCreator = function ( * @param {string} [q] The search text * @param {Array} [resource_readable_id] The readable_id value of the parent learning resource for the content file * @param {Array} [run_readable_id] The readable_id value of the run that the content file belongs to - * @param {VectorContentFilesSearchRetrieveSortbyEnum} [sortby] if the parameter starts with \'-\' the sort is in descending order * `id` - id * `-id` - -id * `resource_readable_id` - resource_readable_id * `-resource_readable_id` - -resource_readable_id * @param {boolean | null} [title__isnull] Filter to content files where title is null/not null * @param {boolean | null} [url__isnull] Filter to content files where url is null/not null * @param {*} [options] Override http request option. @@ -11573,7 +11572,6 @@ export const VectorContentFilesSearchApiAxiosParamCreator = function ( q?: string, resource_readable_id?: Array, run_readable_id?: Array, - sortby?: VectorContentFilesSearchRetrieveSortbyEnum, title__isnull?: boolean | null, url__isnull?: boolean | null, options: RawAxiosRequestConfig = {}, @@ -11650,10 +11648,6 @@ export const VectorContentFilesSearchApiAxiosParamCreator = function ( localVarQueryParameter["run_readable_id"] = run_readable_id } - if (sortby !== undefined) { - localVarQueryParameter["sortby"] = sortby - } - if (title__isnull !== undefined) { localVarQueryParameter["title__isnull"] = title__isnull } @@ -11706,7 +11700,6 @@ export const VectorContentFilesSearchApiFp = function ( * @param {string} [q] The search text * @param {Array} [resource_readable_id] The readable_id value of the parent learning resource for the content file * @param {Array} [run_readable_id] The readable_id value of the run that the content file belongs to - * @param {VectorContentFilesSearchRetrieveSortbyEnum} [sortby] if the parameter starts with \'-\' the sort is in descending order * `id` - id * `-id` - -id * `resource_readable_id` - resource_readable_id * `-resource_readable_id` - -resource_readable_id * @param {boolean | null} [title__isnull] Filter to content files where title is null/not null * @param {boolean | null} [url__isnull] Filter to content files where url is null/not null * @param {*} [options] Override http request option. @@ -11727,7 +11720,6 @@ export const VectorContentFilesSearchApiFp = function ( q?: string, resource_readable_id?: Array, run_readable_id?: Array, - sortby?: VectorContentFilesSearchRetrieveSortbyEnum, title__isnull?: boolean | null, url__isnull?: boolean | null, options?: RawAxiosRequestConfig, @@ -11753,7 +11745,6 @@ export const VectorContentFilesSearchApiFp = function ( q, resource_readable_id, run_readable_id, - sortby, title__isnull, url__isnull, options, @@ -11812,7 +11803,6 @@ export const VectorContentFilesSearchApiFactory = function ( requestParameters.q, requestParameters.resource_readable_id, requestParameters.run_readable_id, - requestParameters.sortby, requestParameters.title__isnull, requestParameters.url__isnull, options, @@ -11926,13 +11916,6 @@ export interface VectorContentFilesSearchApiVectorContentFilesSearchRetrieveRequ */ readonly run_readable_id?: Array - /** - * if the parameter starts with \'-\' the sort is in descending order * `id` - id * `-id` - -id * `resource_readable_id` - resource_readable_id * `-resource_readable_id` - -resource_readable_id - * @type {'id' | '-id' | 'resource_readable_id' | '-resource_readable_id'} - * @memberof VectorContentFilesSearchApiVectorContentFilesSearchRetrieve - */ - readonly sortby?: VectorContentFilesSearchRetrieveSortbyEnum - /** * Filter to content files where title is null/not null * @type {boolean} @@ -11983,7 +11966,6 @@ export class VectorContentFilesSearchApi extends BaseAPI { requestParameters.q, requestParameters.resource_readable_id, requestParameters.run_readable_id, - requestParameters.sortby, requestParameters.title__isnull, requestParameters.url__isnull, options, @@ -12017,17 +11999,6 @@ export const VectorContentFilesSearchRetrieveAggregationsEnum = { } as const export type VectorContentFilesSearchRetrieveAggregationsEnum = (typeof VectorContentFilesSearchRetrieveAggregationsEnum)[keyof typeof VectorContentFilesSearchRetrieveAggregationsEnum] -/** - * @export - */ -export const VectorContentFilesSearchRetrieveSortbyEnum = { - Id: "id", - Id2: "-id", - ResourceReadableId: "resource_readable_id", - ResourceReadableId2: "-resource_readable_id", -} as const -export type VectorContentFilesSearchRetrieveSortbyEnum = - (typeof VectorContentFilesSearchRetrieveSortbyEnum)[keyof typeof VectorContentFilesSearchRetrieveSortbyEnum] /** * VectorLearningResourcesSearchApi - axios parameter creator @@ -12040,7 +12011,7 @@ export const VectorLearningResourcesSearchApiAxiosParamCreator = function ( /** * Vector Search for learning resources * @summary Vector Search - * @param {Array} [aggregations] aggregations for facet counts * `readable_id` - Readable Id * `resource_type` - Resource Type * `certification` - Certification * `certification_type` - Certification Type * `professional` - Professional * `free` - Free * `course_feature` - Course Feature * `topic` - Topic * `ocw_topic` - Ocw Topic * `level` - Level * `department` - Department * `platform` - Platform * `offered_by` - Offered By * `delivery` - Delivery * `title` - Title * `url` - Url * `resource_type_group` - Resource Type Group * `resource_category` - Resource Category * `published` - Published + * @param {Array} [aggregations] aggregations for facet counts * `readable_id` - Readable Id * `resource_type` - Resource Type * `certification` - Certification * `certification_type` - Certification Type * `professional` - Professional * `free` - Free * `course_feature` - Course Feature * `topic` - Topic * `ocw_topic` - Ocw Topic * `level` - Level * `department` - Department * `platform` - Platform * `offered_by` - Offered By * `delivery` - Delivery * `title` - Title * `url` - Url * `resource_type_group` - Resource Type Group * `resource_category` - Resource Category * `published` - Published * `next_start_date` - Next Start Date * `views` - Views * `created_on` - Created On * @param {boolean | null} [certification] True if the learning resource offers a certificate * @param {Array} [certification_type] The type of certificate * `micromasters` - MicroMasters Credential * `professional` - Professional Certificate * `completion` - Certificate of Completion * `none` - No Certificate * @param {Array} [course_feature] The course feature. Possible options are at api/v1/course_features/ @@ -12060,6 +12031,7 @@ export const VectorLearningResourcesSearchApiAxiosParamCreator = function ( * @param {string} [readable_id] The readable id of the resource * @param {Array} [resource_type] The type of learning resource * `course` - course * `program` - program * `learning_path` - learning path * `podcast` - podcast * `podcast_episode` - podcast episode * `video` - video * `video_playlist` - video playlist * `document` - document * @param {Array} [resource_type_group] The category of learning resource * `course` - Course * `program` - Program * `learning_material` - Learning Material + * @param {VectorLearningResourcesSearchRetrieveSortbyEnum} [sortby] if the parameter starts with \'-\' the sort is in descending order * `next_start_date` - next_start_date * `views` - views * `created_on` - created_on * `-next_start_date` - -next_start_date * `-views` - -views * `-created_on` - -created_on * @param {boolean | null} [title__isnull] Filter to learning resources where title is null/not null * @param {Array} [topic] The topic name. To see a list of options go to api/v1/topics/ * @param {boolean | null} [url__isnull] Filter to learning resources where url is null/not null @@ -12087,6 +12059,7 @@ export const VectorLearningResourcesSearchApiAxiosParamCreator = function ( readable_id?: string, resource_type?: Array, resource_type_group?: Array, + sortby?: VectorLearningResourcesSearchRetrieveSortbyEnum, title__isnull?: boolean | null, topic?: Array, url__isnull?: boolean | null, @@ -12188,6 +12161,10 @@ export const VectorLearningResourcesSearchApiAxiosParamCreator = function ( localVarQueryParameter["resource_type_group"] = resource_type_group } + if (sortby !== undefined) { + localVarQueryParameter["sortby"] = sortby + } + if (title__isnull !== undefined) { localVarQueryParameter["title__isnull"] = title__isnull } @@ -12230,7 +12207,7 @@ export const VectorLearningResourcesSearchApiFp = function ( /** * Vector Search for learning resources * @summary Vector Search - * @param {Array} [aggregations] aggregations for facet counts * `readable_id` - Readable Id * `resource_type` - Resource Type * `certification` - Certification * `certification_type` - Certification Type * `professional` - Professional * `free` - Free * `course_feature` - Course Feature * `topic` - Topic * `ocw_topic` - Ocw Topic * `level` - Level * `department` - Department * `platform` - Platform * `offered_by` - Offered By * `delivery` - Delivery * `title` - Title * `url` - Url * `resource_type_group` - Resource Type Group * `resource_category` - Resource Category * `published` - Published + * @param {Array} [aggregations] aggregations for facet counts * `readable_id` - Readable Id * `resource_type` - Resource Type * `certification` - Certification * `certification_type` - Certification Type * `professional` - Professional * `free` - Free * `course_feature` - Course Feature * `topic` - Topic * `ocw_topic` - Ocw Topic * `level` - Level * `department` - Department * `platform` - Platform * `offered_by` - Offered By * `delivery` - Delivery * `title` - Title * `url` - Url * `resource_type_group` - Resource Type Group * `resource_category` - Resource Category * `published` - Published * `next_start_date` - Next Start Date * `views` - Views * `created_on` - Created On * @param {boolean | null} [certification] True if the learning resource offers a certificate * @param {Array} [certification_type] The type of certificate * `micromasters` - MicroMasters Credential * `professional` - Professional Certificate * `completion` - Certificate of Completion * `none` - No Certificate * @param {Array} [course_feature] The course feature. Possible options are at api/v1/course_features/ @@ -12250,6 +12227,7 @@ export const VectorLearningResourcesSearchApiFp = function ( * @param {string} [readable_id] The readable id of the resource * @param {Array} [resource_type] The type of learning resource * `course` - course * `program` - program * `learning_path` - learning path * `podcast` - podcast * `podcast_episode` - podcast episode * `video` - video * `video_playlist` - video playlist * `document` - document * @param {Array} [resource_type_group] The category of learning resource * `course` - Course * `program` - Program * `learning_material` - Learning Material + * @param {VectorLearningResourcesSearchRetrieveSortbyEnum} [sortby] if the parameter starts with \'-\' the sort is in descending order * `next_start_date` - next_start_date * `views` - views * `created_on` - created_on * `-next_start_date` - -next_start_date * `-views` - -views * `-created_on` - -created_on * @param {boolean | null} [title__isnull] Filter to learning resources where title is null/not null * @param {Array} [topic] The topic name. To see a list of options go to api/v1/topics/ * @param {boolean | null} [url__isnull] Filter to learning resources where url is null/not null @@ -12277,6 +12255,7 @@ export const VectorLearningResourcesSearchApiFp = function ( readable_id?: string, resource_type?: Array, resource_type_group?: Array, + sortby?: VectorLearningResourcesSearchRetrieveSortbyEnum, title__isnull?: boolean | null, topic?: Array, url__isnull?: boolean | null, @@ -12309,6 +12288,7 @@ export const VectorLearningResourcesSearchApiFp = function ( readable_id, resource_type, resource_type_group, + sortby, title__isnull, topic, url__isnull, @@ -12374,6 +12354,7 @@ export const VectorLearningResourcesSearchApiFactory = function ( requestParameters.readable_id, requestParameters.resource_type, requestParameters.resource_type_group, + requestParameters.sortby, requestParameters.title__isnull, requestParameters.topic, requestParameters.url__isnull, @@ -12391,8 +12372,8 @@ export const VectorLearningResourcesSearchApiFactory = function ( */ export interface VectorLearningResourcesSearchApiVectorLearningResourcesSearchRetrieveRequest { /** - * aggregations for facet counts * `readable_id` - Readable Id * `resource_type` - Resource Type * `certification` - Certification * `certification_type` - Certification Type * `professional` - Professional * `free` - Free * `course_feature` - Course Feature * `topic` - Topic * `ocw_topic` - Ocw Topic * `level` - Level * `department` - Department * `platform` - Platform * `offered_by` - Offered By * `delivery` - Delivery * `title` - Title * `url` - Url * `resource_type_group` - Resource Type Group * `resource_category` - Resource Category * `published` - Published - * @type {Array<'readable_id' | 'resource_type' | 'certification' | 'certification_type' | 'professional' | 'free' | 'course_feature' | 'topic' | 'ocw_topic' | 'level' | 'department' | 'platform' | 'offered_by' | 'delivery' | 'title' | 'url' | 'resource_type_group' | 'resource_category' | 'published'>} + * aggregations for facet counts * `readable_id` - Readable Id * `resource_type` - Resource Type * `certification` - Certification * `certification_type` - Certification Type * `professional` - Professional * `free` - Free * `course_feature` - Course Feature * `topic` - Topic * `ocw_topic` - Ocw Topic * `level` - Level * `department` - Department * `platform` - Platform * `offered_by` - Offered By * `delivery` - Delivery * `title` - Title * `url` - Url * `resource_type_group` - Resource Type Group * `resource_category` - Resource Category * `published` - Published * `next_start_date` - Next Start Date * `views` - Views * `created_on` - Created On + * @type {Array<'readable_id' | 'resource_type' | 'certification' | 'certification_type' | 'professional' | 'free' | 'course_feature' | 'topic' | 'ocw_topic' | 'level' | 'department' | 'platform' | 'offered_by' | 'delivery' | 'title' | 'url' | 'resource_type_group' | 'resource_category' | 'published' | 'next_start_date' | 'views' | 'created_on'>} * @memberof VectorLearningResourcesSearchApiVectorLearningResourcesSearchRetrieve */ readonly aggregations?: Array @@ -12530,6 +12511,13 @@ export interface VectorLearningResourcesSearchApiVectorLearningResourcesSearchRe */ readonly resource_type_group?: Array + /** + * if the parameter starts with \'-\' the sort is in descending order * `next_start_date` - next_start_date * `views` - views * `created_on` - created_on * `-next_start_date` - -next_start_date * `-views` - -views * `-created_on` - -created_on + * @type {'next_start_date' | 'views' | 'created_on' | '-next_start_date' | '-views' | '-created_on'} + * @memberof VectorLearningResourcesSearchApiVectorLearningResourcesSearchRetrieve + */ + readonly sortby?: VectorLearningResourcesSearchRetrieveSortbyEnum + /** * Filter to learning resources where title is null/not null * @type {boolean} @@ -12593,6 +12581,7 @@ export class VectorLearningResourcesSearchApi extends BaseAPI { requestParameters.readable_id, requestParameters.resource_type, requestParameters.resource_type_group, + requestParameters.sortby, requestParameters.title__isnull, requestParameters.topic, requestParameters.url__isnull, @@ -12625,6 +12614,9 @@ export const VectorLearningResourcesSearchRetrieveAggregationsEnum = { ResourceTypeGroup: "resource_type_group", ResourceCategory: "resource_category", Published: "published", + NextStartDate: "next_start_date", + Views: "views", + CreatedOn: "created_on", } as const export type VectorLearningResourcesSearchRetrieveAggregationsEnum = (typeof VectorLearningResourcesSearchRetrieveAggregationsEnum)[keyof typeof VectorLearningResourcesSearchRetrieveAggregationsEnum] @@ -12775,6 +12767,19 @@ export const VectorLearningResourcesSearchRetrieveResourceTypeGroupEnum = { } as const export type VectorLearningResourcesSearchRetrieveResourceTypeGroupEnum = (typeof VectorLearningResourcesSearchRetrieveResourceTypeGroupEnum)[keyof typeof VectorLearningResourcesSearchRetrieveResourceTypeGroupEnum] +/** + * @export + */ +export const VectorLearningResourcesSearchRetrieveSortbyEnum = { + NextStartDate: "next_start_date", + Views: "views", + CreatedOn: "created_on", + NextStartDate2: "-next_start_date", + Views2: "-views", + CreatedOn2: "-created_on", +} as const +export type VectorLearningResourcesSearchRetrieveSortbyEnum = + (typeof VectorLearningResourcesSearchRetrieveSortbyEnum)[keyof typeof VectorLearningResourcesSearchRetrieveSortbyEnum] /** * VideoShortsApi - axios parameter creator diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.test.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.test.tsx index 3dff6a572a..1ff5215871 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.test.tsx @@ -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() + + // 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() + + 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) diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.tsx index c357d3f719..e89d7a66ed 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.tsx @@ -18,7 +18,7 @@ import { keepPreviousData, useQuery } from "@tanstack/react-query" import { EnrollmentStatus, getEnrollmentStatus, - getProgramEnrollmentStatus, + getRequirementsProgress, getKey, ResourceType, selectBestEnrollment, @@ -308,6 +308,35 @@ interface ResourceItem { type: "course" | "program" } +type CourseRequirementItem = { + resourceType: "course" + course: CourseWithCourseRunsSerializerV2 +} + +type ProgramAsCourseRequirementItem = { + resourceType: "program-as-course" + courseProgramId: number + courseProgram: V2ProgramDetail + courseProgramEnrollment: V3UserProgramEnrollment | undefined +} + +type ProgramEnrollmentRequirementItem = { + resourceType: "program-enrollment" + enrollment: V3UserProgramEnrollment +} + +type RequirementSectionItem = + | CourseRequirementItem + | ProgramAsCourseRequirementItem + | ProgramEnrollmentRequirementItem + +type RequirementSection = { + key: string | number | null | undefined + title: string + items: RequirementSectionItem[] + node: V2ProgramRequirement +} + const extractResourcesFromNode = ( node: V2ProgramRequirement, ): ResourceItem[] => { @@ -436,7 +465,15 @@ const ProgramEnrollmentDisplay: React.FC = ({ {} as Record, ) - const requirementSections = + const programEnrollmentsById = (programEnrollments ?? []).reduce( + (acc, enrollment) => { + acc[enrollment.program.id] = enrollment + return acc + }, + {} as Record, + ) + + const requirementSections: RequirementSection[] = program?.req_tree .filter((node) => node.data.node_type === "operator") .map((node) => { @@ -444,12 +481,6 @@ const ProgramEnrollmentDisplay: React.FC = ({ (programCourses?.results ?? []).map((c) => [c.id, c]), ) const programsById = new Map(requiredProgramList.map((p) => [p.id, p])) - const programEnrollmentsById = new Map( - (programEnrollments ?? []).map((enrollment) => [ - enrollment.program.id, - enrollment, - ]), - ) const sectionItems = extractResourcesFromNode(node) .map((resource) => { @@ -473,13 +504,12 @@ const ProgramEnrollmentDisplay: React.FC = ({ resourceType: "program-as-course" as const, courseProgramId: requiredProgram.id, courseProgram: requiredProgram, - courseProgramEnrollment: programEnrollmentsById.get( - requiredProgram.id, - ), + courseProgramEnrollment: + programEnrollmentsById[requiredProgram.id], } } - const enrollment = programEnrollmentsById.get(requiredProgram.id) + const enrollment = programEnrollmentsById[requiredProgram.id] if (!enrollment) return null return { @@ -513,44 +543,12 @@ const ProgramEnrollmentDisplay: React.FC = ({ ) }, [requiredProgramList, requiredProgramCourses?.results]) - const completedCount = requirementSections - .flatMap((section) => section.items) - .filter((item) => { - if (item.resourceType === "course") { - const bestEnrollment = selectBestEnrollment( - item.course, - enrollmentsByCourseId[item.course.id] || [], - ) - return ( - getEnrollmentStatus(bestEnrollment) === EnrollmentStatus.Completed - ) - } - if (item.resourceType === "program-as-course") { - return ( - getProgramEnrollmentStatus(item.courseProgramEnrollment, 0, 0) === - EnrollmentStatus.Completed - ) - } - return false - }).length - - const totalCount = requirementSections.reduce((sum, section) => { - if ( - section.node.data.operator === "min_number_of" && - section.node.data.operator_value - ) { - return sum + parseInt(section.node.data.operator_value, 10) - } - return ( - sum + - section.items.filter((item) => { - return ( - item.resourceType === "course" || - item.resourceType === "program-as-course" - ) - }).length + const { completed: completedCount, total: totalCount } = + getRequirementsProgress( + requirementSections.map((s) => s.node), + enrollmentsByCourseId, + programEnrollmentsById, ) - }, 0) if (isLoading) { return ( @@ -590,35 +588,12 @@ const ProgramEnrollmentDisplay: React.FC = ({ {requirementSections.map((section, index) => { - const sectionCompletedCount = section.items.filter((item) => { - if (item.resourceType === "course") { - const bestEnrollment = selectBestEnrollment( - item.course, - enrollmentsByCourseId[item.course.id] || [], - ) - return ( - getEnrollmentStatus(bestEnrollment) === EnrollmentStatus.Completed - ) - } - if (item.resourceType === "program-as-course") { - return ( - getProgramEnrollmentStatus(item.courseProgramEnrollment, 0, 0) === - EnrollmentStatus.Completed - ) - } - return false - }).length - - const sectionRequiredCount = - section.node.data.operator === "min_number_of" && - section.node.data.operator_value - ? parseInt(section.node.data.operator_value, 10) - : section.items.filter((item) => { - return ( - item.resourceType === "course" || - item.resourceType === "program-as-course" - ) - }).length + const { completed: sectionCompleted, total: sectionTotal } = + getRequirementsProgress( + [section.node], + enrollmentsByCourseId, + programEnrollmentsById, + ) return ( @@ -635,13 +610,13 @@ const ProgramEnrollmentDisplay: React.FC = ({ > {section.title} - {sectionRequiredCount > 0 ? ( + {sectionTotal > 0 ? ( - Completed {sectionCompletedCount} of {sectionRequiredCount} + Completed {sectionCompleted} of {sectionTotal} ) : null} diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/helpers.test.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/helpers.test.tsx index 4d69478f01..369283c9e4 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/helpers.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/helpers.test.tsx @@ -1,14 +1,21 @@ import { factories } from "api/mitxonline-test-utils" +import { + CourseRunEnrollmentV3, + V2ProgramRequirement, + V3UserProgramEnrollment, +} from "@mitodl/mitxonline-api-axios/v2" import { EnrollmentStatus, filterEnrollmentsByOrganization, getBestRun, - selectBestEnrollment, - getEnrollmentStatus, + getRequirementsProgress, getProgramEnrollmentStatus, getKey, + selectBestEnrollment, + getEnrollmentStatus, ResourceType, } from "./helpers" +import { allowConsoleErrors } from "ol-test-utilities" describe("helpers", () => { describe("getKey", () => { @@ -450,4 +457,161 @@ describe("helpers", () => { ) }) }) + + describe("getRequirementsProgress", () => { + const courseLeaf = (id: number): V2ProgramRequirement => ({ + data: { node_type: "course", course: id }, // eslint-disable-line camelcase + }) + + const programLeaf = (id: number): V2ProgramRequirement => ({ + data: { node_type: "program", required_program: id }, // eslint-disable-line camelcase + }) + + const operator = ( + op: string, + children: V2ProgramRequirement[], + operatorValue: string | null = null, + ): V2ProgramRequirement => ({ + data: { + node_type: "operator", + operator: op, + operator_value: operatorValue, // eslint-disable-line camelcase + }, + children, + }) + + const courseEnrollment = ( + courseId: number, + passed: boolean, + ): CourseRunEnrollmentV3 => { + const run = factories.courses.courseRun({ id: courseId }) + const course = factories.courses.course({ + id: courseId, + courseruns: [run], + }) + return factories.enrollment.courseEnrollment({ + run: { ...run, course }, + grades: [factories.enrollment.grade({ passed })], + }) + } + + test("all_of counts completed courses against total children", () => { + const nodes = [operator("all_of", [courseLeaf(1), courseLeaf(2)])] + const courseEnrollments = { + 1: [courseEnrollment(1, true)], + 2: [courseEnrollment(2, false)], + } + + expect(getRequirementsProgress(nodes, courseEnrollments, {})).toEqual({ + completed: 1, + total: 2, + }) + }) + + test("min_number_of caps completed and total at operator_value", () => { + const nodes = [ + operator( + "min_number_of", + [courseLeaf(1), courseLeaf(2), courseLeaf(3)], + "1", + ), + ] + const courseEnrollments = { + 1: [courseEnrollment(1, true)], + 2: [courseEnrollment(2, true)], + 3: [courseEnrollment(3, true)], + } + + expect(getRequirementsProgress(nodes, courseEnrollments, {})).toEqual({ + completed: 1, + total: 1, + }) + }) + + test("sums progress across multiple top-level operators", () => { + const nodes = [ + operator("all_of", [courseLeaf(1), courseLeaf(2)]), + operator( + "min_number_of", + [courseLeaf(3), courseLeaf(4), courseLeaf(5)], + "1", + ), + ] + const courseEnrollments = { + 1: [courseEnrollment(1, true)], + 3: [courseEnrollment(3, true)], + 4: [courseEnrollment(4, true)], + } + + expect(getRequirementsProgress(nodes, courseEnrollments, {})).toEqual({ + completed: 2, + total: 3, + }) + }) + + test("program leaf is completed when enrollment has certificate", () => { + const enrollments: Record = { + 10: factories.enrollment.programEnrollmentV3({ + certificate: { uuid: "cert-uuid" }, + }), + } + const nodes = [operator("all_of", [programLeaf(10), programLeaf(11)])] + + expect(getRequirementsProgress(nodes, {}, enrollments)).toEqual({ + completed: 1, + total: 2, + }) + }) + + test("ignores non-operator and non-leaf children (nested operators)", () => { + // Nested operators are not counted — only direct course/program children + const nested = operator("all_of", [courseLeaf(99)]) + const nodes = [operator("all_of", [courseLeaf(1), nested])] + + const { consoleWarn } = allowConsoleErrors() + expect( + getRequirementsProgress(nodes, { 1: [courseEnrollment(1, true)] }, {}), + ).toEqual({ completed: 1, total: 1 }) + expect(consoleWarn).toHaveBeenCalled() + }) + + test("min_number_of with operator_value=0 contributes nothing", () => { + const nodes = [operator("min_number_of", [courseLeaf(1)], "0")] + + expect( + getRequirementsProgress(nodes, { 1: [courseEnrollment(1, true)] }, {}), + ).toEqual({ completed: 0, total: 0 }) + }) + + test("min_number_of with malformed operator_value contributes nothing and warns", () => { + const warn = jest.spyOn(console, "warn").mockImplementation(() => {}) + const nodes = [operator("min_number_of", [courseLeaf(1)], "not-a-number")] + + expect( + getRequirementsProgress(nodes, { 1: [courseEnrollment(1, true)] }, {}), + ).toEqual({ completed: 0, total: 0 }) + expect(warn).toHaveBeenCalled() + warn.mockRestore() + }) + + test("unknown operator contributes nothing and warns", () => { + const warn = jest.spyOn(console, "warn").mockImplementation(() => {}) + const nodes = [ + operator("at_least_one_of", [courseLeaf(1), courseLeaf(2)]), + ] + + expect( + getRequirementsProgress(nodes, { 1: [courseEnrollment(1, true)] }, {}), + ).toEqual({ completed: 0, total: 0 }) + expect(warn).toHaveBeenCalled() + warn.mockRestore() + }) + + test("empty nodes returns zeroes", () => { + expect(getRequirementsProgress([], {}, {})).toEqual({ + completed: 0, + total: 0, + }) + }) + }) }) diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/helpers.ts b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/helpers.ts index 06ed0f8ce8..1e26fe98af 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/helpers.ts +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/helpers.ts @@ -1,6 +1,7 @@ import { CourseRunEnrollmentV3, CourseWithCourseRunsSerializerV2, + V2ProgramRequirement, V3UserProgramEnrollment, } from "@mitodl/mitxonline-api-axios/v2" import { getBestRun } from "@/common/mitxonline" @@ -110,3 +111,95 @@ export { getEnrollmentStatus, getProgramEnrollmentStatus, } + +const isLeafRequirementNodeCompleted = ( + node: V2ProgramRequirement, + courseEnrollments: Record, + programEnrollments: Record, +): boolean => { + if ( + node.data.node_type === "course" && + typeof node.data.course === "number" + ) { + const enrollments = courseEnrollments[node.data.course] ?? [] + return enrollments.some((e) => e.grades.some((g) => g.passed)) + } + if (node.data.node_type === "program" && node.data.required_program) { + return !!programEnrollments[node.data.required_program]?.certificate + } + return false +} + +/** + * Computes `{ completed, total }` across the given operator nodes. + * + * Assumes a flat req_tree: each operator's direct children are leaves + * (`node_type: "course"` or `"program"`). Nesting operators inside + * operators is not supported — there is no single well-defined reduction + * for nested progress (e.g., with `min_number_of=1` parent over two + * `min_number_of=4` children, "max child progress" and "sum of all work" + * give different answers, and picking one is a product question). + * + * Only `all_of` and `min_number_of` (with a valid integer `operator_value`) + * are counted. Unknown or malformed operators contribute nothing and log + * a warning — we'd rather under-report than guess. + * + * For `min_number_of` operators, `completed` is capped at `operator_value` + * so extra electives don't inflate the overall total. + * + * Pass all top-level operators for overall program progress, or a single + * operator node for per-section progress. + */ +const getRequirementsProgress = ( + nodes: V2ProgramRequirement[], + courseEnrollments: Record, + programEnrollments: Record, +): { completed: number; total: number } => { + return nodes.reduce( + (acc, node) => { + if (node.data.node_type !== "operator") return acc + const children = node.children ?? [] + if (children.some((c) => c.data.node_type === "operator")) { + console.warn( + "getRequirementsProgress: nested operators are not supported and will be skipped", + ) + } + + const leaves = children.filter( + (c) => c.data.node_type === "course" || c.data.node_type === "program", + ) + const completed = leaves.filter((leaf) => + isLeafRequirementNodeCompleted( + leaf, + courseEnrollments, + programEnrollments, + ), + ).length + + if (node.data.operator === "all_of") { + return { + completed: acc.completed + completed, + total: acc.total + leaves.length, + } + } + + if (node.data.operator === "min_number_of") { + const minRequired = parseInt(node.data.operator_value ?? "", 10) + if (!isNaN(minRequired)) { + return { + completed: acc.completed + Math.min(completed, minRequired), + total: acc.total + minRequired, + } + } + } + + console.warn( + `getRequirementsProgress: unsupported operator "${node.data.operator}" (operator_value=${JSON.stringify(node.data.operator_value)}); skipping.`, + ) + return acc + }, + { completed: 0, total: 0 }, + ) +} + +export { getRequirementsProgress } diff --git a/frontends/main/src/app-pages/ProductPages/ProductPageTemplate.test.tsx b/frontends/main/src/app-pages/ProductPages/ProductPageTemplate.test.tsx index 5e04b557ae..9fd9351921 100644 --- a/frontends/main/src/app-pages/ProductPages/ProductPageTemplate.test.tsx +++ b/frontends/main/src/app-pages/ProductPages/ProductPageTemplate.test.tsx @@ -2,6 +2,7 @@ import React from "react" import { setMockResponse, urls, factories } from "api/test-utils" import { renderWithProviders, screen } from "@/test-utils" import ProductPageTemplate from "./ProductPageTemplate" +import { StayUpdatedModal } from "./StayUpdatedModal" import { useHubspotFormDetail } from "api/hooks/hubspot" import NiceModal from "@ebay/nice-modal-react" import { STAY_UPDATED_FORM_ID } from "./test-utils/stayUpdated" @@ -106,7 +107,9 @@ describe("ProductPageTemplate stay-updated trigger", () => { expect(button).toBeEnabled() button.click() - expect(mockedNiceModalShow).toHaveBeenCalled() + expect(mockedNiceModalShow).toHaveBeenCalledWith(StayUpdatedModal, { + productReadableId: DEFAULT_RESOURCE.readable_id, + }) }) it("disables the trigger button when form fetch errors", () => { @@ -140,7 +143,9 @@ describe("ProductPageTemplate stay-updated trigger", () => { expect(button).toBeEnabled() button.click() - expect(mockedNiceModalShow).toHaveBeenCalled() + expect(mockedNiceModalShow).toHaveBeenCalledWith(StayUpdatedModal, { + productReadableId: DEFAULT_RESOURCE.readable_id, + }) }) describe("PostHog tracking", () => { diff --git a/frontends/main/src/app-pages/ProductPages/ProductPageTemplate.tsx b/frontends/main/src/app-pages/ProductPages/ProductPageTemplate.tsx index 6774db14af..9c950555e3 100644 --- a/frontends/main/src/app-pages/ProductPages/ProductPageTemplate.tsx +++ b/frontends/main/src/app-pages/ProductPages/ProductPageTemplate.tsx @@ -309,7 +309,9 @@ const ProductPageTemplate: React.FC = ({ platform: PlatformEnum.Mitxonline, }) } - NiceModal.show(StayUpdatedModal) + NiceModal.show(StayUpdatedModal, { + productReadableId: resource.readable_id, + }) } return ( diff --git a/frontends/main/src/app-pages/ProductPages/StayUpdatedModal.test.tsx b/frontends/main/src/app-pages/ProductPages/StayUpdatedModal.test.tsx index 102cc2204d..d4282fbbca 100644 --- a/frontends/main/src/app-pages/ProductPages/StayUpdatedModal.test.tsx +++ b/frontends/main/src/app-pages/ProductPages/StayUpdatedModal.test.tsx @@ -1,7 +1,11 @@ import React from "react" import * as NiceModal from "@ebay/nice-modal-react" -import { HubspotForm, type HubspotFormProps } from "ol-components" -import { setMockResponse, urls, factories } from "api/test-utils" +import { + HubspotForm, + HubspotFormInput, + type HubspotFormProps, +} from "ol-components" +import { setMockResponse, urls, factories, makeRequest } from "api/test-utils" import { renderWithProviders, screen, user, act, waitFor } from "@/test-utils" import { StayUpdatedModal } from "./StayUpdatedModal" import { STAY_UPDATED_FORM_ID } from "./test-utils/stayUpdated" @@ -13,15 +17,23 @@ jest.mock("ol-components", () => ({ const mockedHubspotForm = jest.mocked(HubspotForm) const TEST_EMAIL = "user@test.edu" +const TEST_PRODUCT_TITLE = "Sample Program" +const TEST_PRODUCT_READABLE_ID = "sample-program" -const setupApis = () => { - setMockResponse.get( - urls.hubspot.details({ form_id: STAY_UPDATED_FORM_ID }), - factories.hubspot.form({ - id: STAY_UPDATED_FORM_ID, - name: "Stay Updated", - }), - ) +type HubspotFormOverride = + | Parameters[0] + | HubspotFormInput + +const setupApis = (formOverrides: HubspotFormOverride = {}) => { + const form = factories.hubspot.form({ + id: STAY_UPDATED_FORM_ID, + name: "Stay Updated", + }) + + setMockResponse.get(urls.hubspot.details({ form_id: STAY_UPDATED_FORM_ID }), { + ...form, + ...formOverrides, + }) setMockResponse.post(urls.hubspot.submit(STAY_UPDATED_FORM_ID), {}) } @@ -29,6 +41,7 @@ describe("StayUpdatedModal", () => { beforeEach(() => { process.env.NEXT_PUBLIC_STAY_UPDATED_HUBSPOT_FORM_ID = STAY_UPDATED_FORM_ID process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY = "test-site-key" + makeRequest.mockClear() mockedHubspotForm.mockImplementation((props: HubspotFormProps) => (
@@ -157,6 +170,145 @@ describe("StayUpdatedModal", () => { }) }) + it("submits product_of_interest when the readable id matches a field option value", async () => { + setupApis({ + fieldGroups: [ + { + fields: [ + { + name: "product_of_interest", + label: "Product of Interest", + field_type: "multiple_checkboxes", + options: [ + { + label: TEST_PRODUCT_TITLE, + value: TEST_PRODUCT_READABLE_ID, + }, + ], + }, + ], + }, + ], + }) + renderWithProviders(null) + act(() => { + NiceModal.show(StayUpdatedModal, { + productReadableId: TEST_PRODUCT_READABLE_ID, + }) + }) + + await screen.findByRole("dialog", { name: "Stay Updated" }) + await user.click(screen.getByRole("button", { name: "Notify Me" })) + + expect(makeRequest).toHaveBeenCalledWith( + "post", + urls.hubspot.submit(STAY_UPDATED_FORM_ID), + expect.objectContaining({ + fields: expect.arrayContaining([ + { name: "email", value: TEST_EMAIL }, + { + name: "product_of_interest", + value: [TEST_PRODUCT_READABLE_ID], + }, + ]), + }), + ) + }) + + it("submits product_of_interest when the HubSpot response uses field_groups", async () => { + setupApis({ + field_groups: [ + { + fields: [ + { + name: "product_of_interest", + label: "Product of Interest", + field_type: "multiple_checkboxes", + options: [ + { + label: TEST_PRODUCT_TITLE, + value: TEST_PRODUCT_READABLE_ID, + }, + ], + }, + ], + }, + ], + }) + renderWithProviders(null) + act(() => { + NiceModal.show(StayUpdatedModal, { + productReadableId: TEST_PRODUCT_READABLE_ID, + }) + }) + + await screen.findByRole("dialog", { name: "Stay Updated" }) + await user.click(screen.getByRole("button", { name: "Notify Me" })) + + expect(makeRequest).toHaveBeenCalledWith( + "post", + urls.hubspot.submit(STAY_UPDATED_FORM_ID), + expect.objectContaining({ + fields: expect.arrayContaining([ + { name: "email", value: TEST_EMAIL }, + { + name: "product_of_interest", + value: [TEST_PRODUCT_READABLE_ID], + }, + ]), + }), + ) + }) + + it("omits product_of_interest when the readable id has no matching option value", async () => { + setupApis({ + fieldGroups: [ + { + fields: [ + { + name: "product_of_interest", + label: "Product of Interest", + field_type: "multiple_checkboxes", + options: [ + { + label: "Different Product", + value: "different_product", + }, + ], + }, + ], + }, + ], + }) + renderWithProviders(null) + act(() => { + NiceModal.show(StayUpdatedModal, { + productReadableId: TEST_PRODUCT_READABLE_ID, + }) + }) + + await screen.findByRole("dialog", { name: "Stay Updated" }) + await user.click(screen.getByRole("button", { name: "Notify Me" })) + + const postCall = makeRequest.mock.calls.find( + ([method, url]) => + method === "post" && url === urls.hubspot.submit(STAY_UPDATED_FORM_ID), + ) + expect(postCall).toBeDefined() + + const requestBody = postCall?.[2] as { + fields?: Array<{ name: string; value: unknown }> + } + expect(requestBody.fields).toEqual( + expect.arrayContaining([{ name: "email", value: TEST_EMAIL }]), + ) + expect(requestBody.fields).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "product_of_interest" }), + ]), + ) + }) + it("shows error message when form submission fails", async () => { setMockResponse.get( urls.hubspot.details({ form_id: STAY_UPDATED_FORM_ID }), diff --git a/frontends/main/src/app-pages/ProductPages/StayUpdatedModal.tsx b/frontends/main/src/app-pages/ProductPages/StayUpdatedModal.tsx index c11f273e31..763df2728b 100644 --- a/frontends/main/src/app-pages/ProductPages/StayUpdatedModal.tsx +++ b/frontends/main/src/app-pages/ProductPages/StayUpdatedModal.tsx @@ -7,6 +7,7 @@ import { HubspotForm, Dialog, DialogActions, + type HubspotFormInput, type HubspotFormValue, } from "ol-components" import { Button, styled } from "@mitodl/smoot-design" @@ -43,6 +44,12 @@ const DialogSuccessCheck = styled(Image)({ marginBottom: "24px", }) +const PRODUCT_OF_INTEREST_FIELD_NAME = "product_of_interest" + +type StayUpdatedDialogProps = { + productReadableId?: string +} + const mapValuesToFields = ( values: Record, ): HubspotSubmitField[] => { @@ -54,7 +61,31 @@ const mapValuesToFields = ( .map(([name, value]) => ({ name, value })) } -const StayUpdatedDialogInner: React.FC = () => { +const findProductOfInterestValue = ( + hubspotForm: HubspotFormInput | undefined, + productReadableId: string | undefined, +): string | undefined => { + const normalizedProductReadableId = productReadableId?.trim() + if (!normalizedProductReadableId) { + return undefined + } + + const fieldGroups = hubspotForm?.fieldGroups ?? hubspotForm?.field_groups + + const productOfInterestField = fieldGroups + ?.flatMap((fieldGroup) => fieldGroup.fields ?? []) + .find((field) => field.name === PRODUCT_OF_INTEREST_FIELD_NAME) + + const matchingOption = productOfInterestField?.options?.find( + (option) => option.value?.trim() === normalizedProductReadableId, + ) + + return matchingOption?.value?.trim() +} + +const StayUpdatedDialogInner: React.FC = ({ + productReadableId, +}) => { const modalState = NiceModal.useModal() const modal = muiDialogV5(modalState) const stayUpdatedFormId = getStayUpdatedHubspotFormId() @@ -110,11 +141,25 @@ const StayUpdatedDialogInner: React.FC = () => { } onSubmit={(values, _event, recaptchaToken) => { - const fields = mapValuesToFields(values) + const fields = mapValuesToFields(values).filter( + (field) => field.name !== PRODUCT_OF_INTEREST_FIELD_NAME, + ) const emailField = fields.find((field) => field.name === "email") if (emailField && typeof emailField.value === "string") { setEmail(emailField.value) } + + const productOfInterestValue = findProductOfInterestValue( + hubspotForm, + productReadableId, + ) + if (productOfInterestValue) { + fields.push({ + name: PRODUCT_OF_INTEREST_FIELD_NAME, + value: [productOfInterestValue], + }) + } + hubspotFormSubmit.mutate({ formId: stayUpdatedFormId, fields, diff --git a/frontends/main/src/app/layout.tsx b/frontends/main/src/app/layout.tsx index db56a0c36f..cf45c40a02 100644 --- a/frontends/main/src/app/layout.tsx +++ b/frontends/main/src/app/layout.tsx @@ -10,6 +10,44 @@ import "./GlobalStyles" const NEXT_PUBLIC_ORIGIN = process.env.NEXT_PUBLIC_ORIGIN +const getGtmConfig = () => { + const trackingId = process.env.GTM_TRACKING_ID?.trim() + const auth = process.env.GTM_AUTH?.trim() + const preview = process.env.GTM_PREVIEW?.trim() + const cookiesWin = process.env.GTM_COOKIES_WIN?.trim() || "x" + + if (!trackingId || !auth || !preview) { + return null + } + + return { + trackingId, + auth, + preview, + cookiesWin, + } +} + +const buildGtmUrlQuery = ({ + trackingId, + auth, + preview, + cookiesWin, +}: { + trackingId: string + auth: string + preview: string + cookiesWin: string +}) => { + const params = new URLSearchParams({ + id: trackingId, + gtm_auth: auth, + gtm_preview: preview, + gtm_cookies_win: cookiesWin, + }) + return params.toString() +} + /** * As part of the root layout, this metadata object is site-wide defaults */ @@ -32,9 +70,25 @@ export default function RootLayout({ }: Readonly<{ children: React.ReactNode }>) { + const gtmConfig = getGtmConfig() + const gtmQuery = gtmConfig ? buildGtmUrlQuery(gtmConfig) : null + const gtmHeadScript = + gtmQuery !== null + ? `(function(w,d,s,l,q){w[l]=w[l]||[];w[l].push({'gtm.start': +new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0], +j=d.createElement(s),dl=l!=='dataLayer'?'&l='+l:'';j.async=true;j.src= +'https://www.googletagmanager.com/gtm.js?'+q+dl;f.parentNode.insertBefore(j,f); +})(window,document,'script','dataLayer',${JSON.stringify(gtmQuery)});` + : null + return ( + {gtmHeadScript ? ( + + ) : null} {/* Font files for Adobe neue haas grotesk. WARNING: This is linked to chudzick@mit.edu's Adobe account. @@ -51,6 +105,17 @@ export default function RootLayout({ /> + {gtmQuery ? ( +