Skip to content

Commit 4ca703e

Browse files
authored
Program certificate title from CMS "Certificate Title" (behind flag) (#3512)
Program certificates on MIT Learn now display the CMS certificate page's product_name ("Certificate Title") as the title, gated behind the `cms-certificate-title` PostHog flag. When the flag is off (or product_name is empty) it falls back to the program title — today's behaviour. Part of mitodl/hq#11907.
1 parent b505dce commit 4ca703e

5 files changed

Lines changed: 130 additions & 15 deletions

File tree

frontends/main/src/app-pages/CertificatePage/CertificatePage.test.tsx

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,23 @@ import CertificatePage from "./CertificatePage"
66
import { CertificateType } from "@/common/certificateUtils"
77
import SharePopover from "@/components/SharePopover/SharePopover"
88
import * as mitxonline from "api/mitxonline-test-utils"
9+
import { useFeatureFlagEnabled } from "posthog-js/react"
910
import {
1011
FACEBOOK_SHARE_BASE_URL,
1112
TWITTER_SHARE_BASE_URL,
1213
LINKEDIN_SHARE_BASE_URL,
1314
} from "@/common/urls"
1415

16+
jest.mock("posthog-js/react", () => ({
17+
...jest.requireActual("posthog-js/react"),
18+
useFeatureFlagEnabled: jest.fn(),
19+
}))
20+
const mockedUseFeatureFlagEnabled = jest.mocked(useFeatureFlagEnabled)
21+
1522
describe("CertificatePage", () => {
1623
beforeEach(() => {
24+
// Default the CMS-title flag ON; individual tests override as needed.
25+
mockedUseFeatureFlagEnabled.mockReturnValue(true)
1726
const mitxUser = mitxonline.factories.user.user()
1827
setMockResponse.get(mitxonline.urls.userMe.get(), mitxUser)
1928
})
@@ -84,9 +93,11 @@ describe("CertificatePage", () => {
8493
await screen.findAllByText(certificate.uuid)
8594
})
8695

87-
it("renders a program certificate", async () => {
96+
it("renders a program certificate with the CMS product name as the title", async () => {
8897
const certificate = factories.mitxonline.programCertificate()
8998
certificate.program.program_type = "Program"
99+
certificate.certificate_page.product_name =
100+
"Custom Program Certificate Title"
90101
setMockResponse.get(
91102
mitxonline.urls.certificates.programCertificatesRetrieve({
92103
uuid: certificate.uuid,
@@ -101,7 +112,7 @@ describe("CertificatePage", () => {
101112
/>,
102113
)
103114

104-
await screen.findAllByText(certificate.program.title)
115+
await screen.findAllByText("Custom Program Certificate Title")
105116
const badge = await screen.findByTestId("certificate-badge-label")
106117
expect(within(badge).getByText("Program")).toBeInTheDocument()
107118
expect(within(badge).getByText("Certificate")).toBeInTheDocument()
@@ -117,6 +128,50 @@ describe("CertificatePage", () => {
117128
await screen.findAllByText(certificate.uuid)
118129
})
119130

131+
it("falls back to the program title when the certificate page has no product name", async () => {
132+
const certificate = factories.mitxonline.programCertificate()
133+
certificate.program.program_type = "Program"
134+
certificate.certificate_page.product_name = ""
135+
setMockResponse.get(
136+
mitxonline.urls.certificates.programCertificatesRetrieve({
137+
uuid: certificate.uuid,
138+
}),
139+
certificate,
140+
)
141+
renderWithProviders(
142+
<CertificatePage
143+
certificateType={CertificateType.Program}
144+
uuid={certificate.uuid}
145+
pageUrl={`https://${process.env.NEXT_PUBLIC_ORIGIN}/certificate/program/${certificate.uuid}`}
146+
/>,
147+
)
148+
149+
await screen.findAllByText(certificate.program.title)
150+
})
151+
152+
it("shows the program title (not the CMS title) when the flag is off", async () => {
153+
mockedUseFeatureFlagEnabled.mockReturnValue(false)
154+
const certificate = factories.mitxonline.programCertificate()
155+
certificate.program.program_type = "Program"
156+
certificate.certificate_page.product_name = "Custom CMS Title"
157+
setMockResponse.get(
158+
mitxonline.urls.certificates.programCertificatesRetrieve({
159+
uuid: certificate.uuid,
160+
}),
161+
certificate,
162+
)
163+
renderWithProviders(
164+
<CertificatePage
165+
certificateType={CertificateType.Program}
166+
uuid={certificate.uuid}
167+
pageUrl={`https://${process.env.NEXT_PUBLIC_ORIGIN}/certificate/program/${certificate.uuid}`}
168+
/>,
169+
)
170+
171+
await screen.findAllByText(certificate.program.title)
172+
expect(screen.queryByText("Custom CMS Title")).not.toBeInTheDocument()
173+
})
174+
120175
it("renders a MicroMasters program certificate badge with the registered mark", async () => {
121176
const certificate = factories.mitxonline.programCertificate()
122177
certificate.program.program_type = "MicroMasters®"
@@ -166,7 +221,7 @@ describe("CertificatePage", () => {
166221
/>,
167222
)
168223

169-
await screen.findAllByText(certificate.program.title)
224+
await screen.findAllByText(certificate.user.name!)
170225

171226
expect(
172227
screen.queryByRole("button", { name: "Download PDF" }),
@@ -204,7 +259,7 @@ describe("CertificatePage", () => {
204259
/>,
205260
)
206261

207-
await screen.findAllByText(certificate.program.title)
262+
await screen.findAllByText(certificate.user.name!)
208263

209264
await screen.findByRole("button", { name: "Download PDF" })
210265
await screen.findByRole("button", { name: "Share" })

frontends/main/src/app-pages/CertificatePage/CertificatePage.tsx

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,16 @@ import SharePopover from "@/components/SharePopover/SharePopover"
2424
import { DigitalCredentialDialog } from "./DigitalCredentialDialog"
2525
import {
2626
getCertificateInfo,
27+
getCertificateTitle,
2728
getCertificateBadgeLines,
2829
getCertificateBadgeTypography,
2930
getVerifiableCredentialLinkedInURL,
3031
getCertificateLinkedInUrl,
3132
getVerifiableCredentialDownloadAPIURL,
3233
CertificateType,
3334
} from "@/common/certificateUtils"
35+
import { useFeatureFlagEnabled } from "posthog-js/react"
36+
import { FeatureFlags } from "@/common/feature_flags"
3437

3538
const Page = styled.div(({ theme }) => ({
3639
backgroundImage: `url(${backgroundImage.src})`,
@@ -626,11 +629,11 @@ const Certificate = ({
626629

627630
const CourseCertificate = ({
628631
certificate,
632+
title,
629633
}: {
630634
certificate: V2CourseRunCertificate
635+
title: string
631636
}) => {
632-
const title = certificate.course_run.course.title
633-
634637
const userName = certificate.user.name
635638

636639
const signatories = certificate.certificate_page.signatory_items
@@ -649,11 +652,11 @@ const CourseCertificate = ({
649652

650653
const ProgramCertificate = ({
651654
certificate,
655+
title,
652656
}: {
653657
certificate: V2ProgramCertificate
658+
title: string
654659
}) => {
655-
const title = certificate.program.title
656-
657660
const userName = certificate.user.name
658661

659662
const ceus = certificate.certificate_page.CEUs
@@ -685,6 +688,11 @@ const CertificatePage: React.FC<{
685688

686689
const { data: userData } = useQuery(mitxUserQueries.me())
687690

691+
// Gates whether the certificate title comes from the CMS "Certificate Title"
692+
// (product_name) field or the program/course title. Defaults to the
693+
// program/course title until the flag loads / is enabled.
694+
const useCmsTitle = !!useFeatureFlagEnabled(FeatureFlags.CmsCertificateTitle)
695+
688696
const {
689697
data: courseCertificateData,
690698
isLoading: isCourseLoading,
@@ -750,6 +758,18 @@ const CertificatePage: React.FC<{
750758
return notFound()
751759
}
752760

761+
let title = ""
762+
if (certificateType === CertificateType.Course) {
763+
title = courseCertificateData?.course_run.course.title ?? ""
764+
} else if (useCmsTitle) {
765+
title = getCertificateTitle(
766+
programCertificateData?.certificate_page?.product_name,
767+
programCertificateData?.program.title ?? "",
768+
)
769+
} else {
770+
title = programCertificateData?.program.title ?? ""
771+
}
772+
753773
const download = async () => {
754774
const res = await fetch(`/certificate/${certificateType}/${uuid}/pdf`)
755775
const blob = await res.blob()
@@ -763,11 +783,6 @@ const CertificatePage: React.FC<{
763783
window.URL.revokeObjectURL(url)
764784
}
765785

766-
const title =
767-
certificateType === CertificateType.Course
768-
? courseCertificateData?.course_run.course.title
769-
: programCertificateData?.program.title
770-
771786
const { displayType } = getCertificateInfo(
772787
programCertificateData?.program?.program_type,
773788
)
@@ -849,9 +864,15 @@ const CertificatePage: React.FC<{
849864
) : null}
850865
<PrintContainer ref={contentRef}>
851866
{certificateType === CertificateType.Course ? (
852-
<CourseCertificate certificate={courseCertificateData!} />
867+
<CourseCertificate
868+
certificate={courseCertificateData!}
869+
title={title}
870+
/>
853871
) : (
854-
<ProgramCertificate certificate={programCertificateData!} />
872+
<ProgramCertificate
873+
certificate={programCertificateData!}
874+
title={title}
875+
/>
855876
)}
856877
</PrintContainer>
857878
<Note>

frontends/main/src/common/certificateUtils.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
getCertificateBadgeLines,
33
getCertificateBadgeTypography,
44
getCertificateInfo,
5+
getCertificateTitle,
56
} from "./certificateUtils"
67

78
describe("getCertificateInfo", () => {
@@ -45,6 +46,38 @@ describe("getCertificateInfo", () => {
4546
})
4647
})
4748

49+
describe("getCertificateTitle", () => {
50+
it("prefers the CMS product name when present", () => {
51+
expect(getCertificateTitle("Universal AI", "Fundamentals of ML")).toBe(
52+
"Universal AI",
53+
)
54+
})
55+
56+
it("falls back to the program/course title when product name is missing", () => {
57+
expect(getCertificateTitle(null, "Fundamentals of ML")).toBe(
58+
"Fundamentals of ML",
59+
)
60+
expect(getCertificateTitle(undefined, "Fundamentals of ML")).toBe(
61+
"Fundamentals of ML",
62+
)
63+
})
64+
65+
it("falls back when product name is empty or whitespace only", () => {
66+
expect(getCertificateTitle("", "Fundamentals of ML")).toBe(
67+
"Fundamentals of ML",
68+
)
69+
expect(getCertificateTitle(" ", "Fundamentals of ML")).toBe(
70+
"Fundamentals of ML",
71+
)
72+
})
73+
74+
it("trims surrounding whitespace from the product name", () => {
75+
expect(getCertificateTitle(" Universal AI ", "fallback")).toBe(
76+
"Universal AI",
77+
)
78+
})
79+
})
80+
4881
describe("getCertificateBadgeLines", () => {
4982
it("returns a single line for course certificates", () => {
5083
expect(getCertificateBadgeLines()).toEqual({ primary: "Certificate" })

frontends/main/src/common/certificateUtils.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,11 @@ export const getCertificateInfo = (
8282
displayType: resolveCertificateLabel(programType).displayType,
8383
})
8484

85+
export const getCertificateTitle = (
86+
productName: string | null | undefined,
87+
fallbackTitle: string,
88+
): string => productName?.trim() || fallbackTitle
89+
8590
const BADGE_REGISTERED_MARK_SCALE = 0.645
8691

8792
export type CertificateBadgeTypography = {

frontends/main/src/common/feature_flags.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export enum FeatureFlags {
1717
PodcastDetailPage = "podcast-detail-page",
1818
B2BContractManagerDashboard = "b2b-contract-manager-dashboard",
1919
Arithmix = "arithmix",
20+
CmsCertificateTitle = "cms-certificate-title",
2021
}
2122

2223
/**

0 commit comments

Comments
 (0)