diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.test.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.test.tsx
index d01b729fd8..1b8841fb19 100644
--- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.test.tsx
+++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.test.tsx
@@ -7,16 +7,13 @@ import {
within,
} from "@/test-utils"
import * as mitxonline from "api/mitxonline-test-utils"
-import {
- urls as testUrls,
- factories as testFactories,
- mockAxiosInstance,
-} from "api/test-utils"
+import { mockAxiosInstance } from "api/test-utils"
import { DashboardCard, getDefaultContextMenuItems } from "./DashboardCard"
import { dashboardCourse } from "./test-utils"
import { faker } from "@faker-js/faker/locale/en"
import moment from "moment"
import { EnrollmentMode, EnrollmentStatus } from "./types"
+import { cartesianProduct } from "ol-test-utilities"
const pastDashboardCourse: typeof dashboardCourse = (...overrides) => {
return dashboardCourse(
@@ -52,23 +49,45 @@ const futureDashboardCourse: typeof dashboardCourse = (...overrides) => {
)
}
-beforeEach(() => {
- // Mock user API call
- const user = testFactories.user.user()
+const mitxUser = mitxonline.factories.user.user
+
+const setupUserApis = () => {
const mitxUser = mitxonline.factories.user.user()
- setMockResponse.get(testUrls.userMe.get(), user)
setMockResponse.get(mitxonline.urls.userMe.get(), mitxUser)
-})
+}
describe.each([
{ display: "desktop", testId: "enrollment-card-desktop" },
{ display: "mobile", testId: "enrollment-card-mobile" },
-])("EnrollmentCard $display", ({ testId }) => {
+])("DashboardCard $display", ({ testId }) => {
const getCard = () => screen.getByTestId(testId)
- test("It shows course title with link to marketing url", () => {
- const course = dashboardCourse()
- renderWithProviders()
+ const originalLocation = window.location
+
+ beforeAll(() => {
+ Object.defineProperty(window, "location", {
+ configurable: true,
+ enumerable: true,
+ value: { ...originalLocation, assign: jest.fn() },
+ })
+ })
+
+ afterAll(() => {
+ Object.defineProperty(window, "location", {
+ configurable: true,
+ enumerable: true,
+ value: originalLocation,
+ })
+ })
+
+ test("It shows course title and links to marketingUrl if titleAction is marketing", async () => {
+ setupUserApis()
+ const course = dashboardCourse({
+ marketingUrl: "?some-marketing-url",
+ })
+ renderWithProviders(
+ ,
+ )
const card = getCard()
@@ -78,7 +97,23 @@ describe.each([
expect(courseLink).toHaveAttribute("href", course.marketingUrl)
})
+ test("It shows course title and links to courseware if titleAction is courseware", async () => {
+ setupUserApis()
+ const course = dashboardCourse()
+ renderWithProviders(
+ ,
+ )
+
+ const card = getCard()
+
+ const courseLink = within(card).getByRole("link", {
+ name: course.title,
+ })
+ expect(courseLink).toHaveAttribute("href", course.run.coursewareUrl)
+ })
+
test("Accepts a classname", () => {
+ setupUserApis()
const course = dashboardCourse()
const TheComponent = faker.helpers.arrayElement([
"li",
@@ -88,6 +123,7 @@ describe.each([
])
renderWithProviders(
{
- renderWithProviders()
+ setupUserApis()
+ renderWithProviders(
+ ,
+ )
const card = getCard()
const coursewareCTA = within(card).getByTestId("courseware-button")
@@ -154,8 +193,9 @@ describe.each([
])(
"Courseware CTA shows correct label based on courseNoun prop and dates (case $case)",
({ course, expected }) => {
+ setupUserApis()
const { view } = renderWithProviders(
- ,
+ ,
)
const card = getCard()
const coursewareCTA = within(card).getByTestId("courseware-button")
@@ -173,7 +213,11 @@ describe.each([
const courseNoun = faker.word.noun()
view.rerender(
- ,
+ ,
)
if (
@@ -221,8 +265,11 @@ describe.each([
])(
"Shows upgrade banner based on run.canUpgrade and not already upgraded (canUpgrade: $overrides.canUpgrade)",
({ overrides, expectation }) => {
+ setupUserApis()
const course = dashboardCourse(overrides)
- renderWithProviders()
+ renderWithProviders(
+ ,
+ )
const card = getCard()
const upgradeRoot = within(card).queryByTestId("upgrade-root")
@@ -236,6 +283,7 @@ describe.each([
])(
"Never shows upgrade banner if `offerUpgrade` is false",
({ offerUpgrade, expected }) => {
+ setupUserApis()
const course = dashboardCourse({
run: {
canUpgrade: true,
@@ -247,6 +295,7 @@ describe.each([
renderWithProviders(
,
@@ -259,6 +308,7 @@ describe.each([
)
test("Upgrade banner shows correct price and deadline", () => {
+ setupUserApis()
const certificateUpgradePrice = faker.commerce.price()
const certificateUpgradeDeadline = moment()
.startOf("day")
@@ -275,7 +325,9 @@ describe.each([
enrollment: { mode: EnrollmentMode.Audit },
})
- renderWithProviders()
+ renderWithProviders(
+ ,
+ )
const card = getCard()
const upgradeRoot = within(card).getByTestId("upgrade-root")
@@ -287,13 +339,16 @@ describe.each([
})
test("Shows number of days until course starts", () => {
+ setupUserApis()
const startDate = moment()
.startOf("day")
.add(5, "days")
.add(3, "hours")
.toISOString()
const enrollment = dashboardCourse({ run: { startDate } })
- renderWithProviders()
+ renderWithProviders(
+ ,
+ )
const card = getCard()
expect(card).toHaveTextContent(/starts in 5 days/i)
@@ -302,6 +357,7 @@ describe.each([
test.each([{ showNotComplete: true }, { showNotComplete: false }])(
"Shows incomplete status when showNotComplete is true",
({ showNotComplete }) => {
+ setupUserApis()
const enrollment = faker.helpers.arrayElement([
{ status: EnrollmentStatus.NotEnrolled },
{ status: EnrollmentStatus.Enrolled },
@@ -313,6 +369,7 @@ describe.each([
}
const { view } = renderWithProviders(
,
@@ -324,6 +381,7 @@ describe.each([
view.rerender(
{
+ setupUserApis()
renderWithProviders(
,
)
@@ -389,6 +449,7 @@ describe.each([
])(
"getDefaultContextMenuItems returns correct items",
async ({ contextMenuItems }) => {
+ setupUserApis()
const course = dashboardCourse()
course.enrollment = {
id: faker.number.int(),
@@ -397,6 +458,7 @@ describe.each([
}
renderWithProviders(
,
@@ -431,8 +493,10 @@ describe.each([
])(
"Context menu button is not shown when enrollment status is not Completed or Enrolled",
({ status }) => {
+ setupUserApis()
renderWithProviders(
,
)
@@ -457,13 +521,16 @@ describe.each([
])(
"CoursewareButton switches to Enroll functionality when enrollment status is not enrolled or undefined",
({ status }) => {
+ setupUserApis()
const course = dashboardCourse()
course.enrollment = {
id: faker.number.int(),
status: status,
mode: EnrollmentMode.Audit,
}
- renderWithProviders()
+ renderWithProviders(
+ ,
+ )
const card = getCard()
const coursewareButton = within(card).getByTestId("courseware-button")
@@ -479,58 +546,91 @@ describe.each([
},
)
- test("CoursewareButton hits enroll endpoint appropriately", async () => {
- const course = dashboardCourse({
- coursewareId: faker.string.uuid(),
- enrollment: {
- id: faker.number.int(),
- status: EnrollmentStatus.NotEnrolled,
- },
- })
+ const setupEnrollmentApis = (opts: {
+ user: ReturnType
+ course: ReturnType
+ }) => {
+ setMockResponse.get(mitxonline.urls.userMe.get(), opts.user)
- // Mock user without country and year_of_birth to trigger JustInTimeDialog
- const baseUser = mitxonline.factories.user.user()
- const mitxUserWithoutRequiredFields = {
- ...baseUser,
- legal_address: { ...baseUser.legal_address, country: undefined },
- user_profile: { ...baseUser.user_profile, year_of_birth: undefined },
- }
- setMockResponse.get(
- mitxonline.urls.userMe.get(),
- mitxUserWithoutRequiredFields,
+ const enrollmentUrl = mitxonline.urls.b2b.courseEnrollment(
+ opts.course.coursewareId ?? undefined,
)
+ setMockResponse.post(enrollmentUrl, {
+ result: "b2b-enroll-success",
+ order: 1,
+ })
- setMockResponse.post(
- mitxonline.urls.b2b.courseEnrollment(course.coursewareId ?? undefined),
- { result: "b2b-enroll-success", order: 1 },
- )
- // Mock countries data needed by JustInTimeDialog
- setMockResponse.get(mitxonline.urls.countries.list(), [
+ const countries = [
{ code: "US", name: "United States" },
{ code: "CA", name: "Canada" },
- ])
+ ]
+ if (opts.user.legal_address?.country) {
+ countries.push({
+ code: opts.user.legal_address.country,
+ name: "User's Country",
+ })
+ }
+ // Mock countries data needed by JustInTimeDialog
+ setMockResponse.get(mitxonline.urls.countries.list(), countries)
+ return { enrollmentUrl }
+ }
+
+ const ENROLLMENT_TRIGGERS = [
+ { trigger: "button" as const },
+ { trigger: "title-link" as const },
+ ]
+ test.each(ENROLLMENT_TRIGGERS)(
+ "Enrollment for complete profile bypasses just-in-time dialog",
+ async ({ trigger }) => {
+ const userData = mitxUser()
+ const course = dashboardCourse({
+ enrollment: { status: EnrollmentStatus.NotEnrolled },
+ })
+ const { enrollmentUrl } = setupEnrollmentApis({ user: userData, course })
+ renderWithProviders(
+ ,
+ )
+ const card = getCard()
+ const triggerElement =
+ trigger === "button"
+ ? within(card).getByTestId("courseware-button")
+ : within(card).getByRole("link", { name: course.title })
- renderWithProviders()
- const card = getCard()
- const coursewareButton = within(card).getByTestId("courseware-button")
+ await user.click(triggerElement)
- // Now the button should show the JustInTimeDialog instead of directly enrolling
- await user.click(coursewareButton)
+ expect(mockAxiosInstance.request).toHaveBeenCalledWith(
+ expect.objectContaining({ method: "POST", url: enrollmentUrl }),
+ )
+ },
+ )
- // Verify the JustInTimeDialog appeared
- const dialog = await screen.findByRole("dialog", {
- name: "Just a Few More Details",
- })
- expect(dialog).toBeInTheDocument()
-
- // The enrollment API should NOT be called yet (until dialog is completed)
- expect(mockAxiosInstance.request).not.toHaveBeenCalledWith(
- expect.objectContaining({
- method: "POST",
- url: mitxonline.urls.b2b.courseEnrollment(
- course.coursewareId ?? undefined,
- ),
- }),
- )
- })
+ test.each(
+ cartesianProduct(ENROLLMENT_TRIGGERS, [
+ { userData: mitxUser({ legal_address: { country: "" } }) },
+ { userData: mitxUser({ user_profile: { year_of_birth: null } }) },
+ ]),
+ )(
+ "Enrollment for complete profile bypasses just-in-time dialog",
+ async ({ trigger, userData }) => {
+ const course = dashboardCourse({
+ enrollment: { status: EnrollmentStatus.NotEnrolled },
+ })
+ setupEnrollmentApis({ user: userData, course })
+ renderWithProviders(
+ ,
+ )
+ const card = getCard()
+ const triggerElement =
+ trigger === "button"
+ ? within(card).getByTestId("courseware-button")
+ : within(card).getByRole("link", { name: course.title })
+
+ await user.click(triggerElement)
+
+ await screen.findByRole("dialog", { name: "Just a Few More Details" })
+ expect(mockAxiosInstance.request).not.toHaveBeenCalledWith(
+ expect.objectContaining({ method: "POST" }),
+ )
+ },
+ )
})
diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.tsx
index 32da8e1df9..1772f2f18d 100644
--- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.tsx
+++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.tsx
@@ -32,7 +32,8 @@ import {
} from "./DashboardDialogs"
import NiceModal from "@ebay/nice-modal-react"
import { useCreateEnrollment } from "api/mitxonline-hooks/enrollment"
-import { useMitxOnlineUserMe } from "api/mitxonline-hooks/user"
+import { mitxUserQueries } from "api/mitxonline-hooks/user"
+import { useQuery } from "@tanstack/react-query"
const CardRoot = styled.div<{
screenSize: "desktop" | "mobile"
@@ -66,10 +67,6 @@ const CardRoot = styled.div<{
},
])
-const SpinnerContainer = styled.div({
- marginLeft: "8px",
-})
-
const TitleLink = styled(Link)(({ theme }) => ({
[theme.breakpoints.down("md")]: {
maxWidth: "calc(100% - 16px)",
@@ -117,6 +114,40 @@ const getDefaultContextMenuItems = (
]
}
+const useOneClickEnroll = () => {
+ const mitxOnlineUser = useQuery(mitxUserQueries.me())
+ const createEnrollment = useCreateEnrollment()
+ const userCountry = mitxOnlineUser.data?.legal_address?.country
+ const userYearOfBirth = mitxOnlineUser.data?.user_profile?.year_of_birth
+ const showJustInTimeDialog = !userCountry || !userYearOfBirth
+
+ const mutate = ({
+ href,
+ coursewareId,
+ }: {
+ href: string
+ coursewareId: string
+ }) => {
+ if (showJustInTimeDialog) {
+ NiceModal.show(JustInTimeDialog, {
+ href: href,
+ readableId: coursewareId,
+ })
+ return
+ } else {
+ createEnrollment.mutate(
+ { readable_id: coursewareId },
+ {
+ onSuccess: () => {
+ window.location.assign(href)
+ },
+ },
+ )
+ }
+ }
+ return { mutate, isPending: createEnrollment.isPending }
+}
+
type CoursewareButtonProps = {
coursewareId?: string | null
startDate?: string | null
@@ -161,62 +192,53 @@ const CoursewareButton = styled(
courseNoun,
enrollmentStatus,
})
- const mitxOnlineUser = useMitxOnlineUserMe()
const hasStarted = startDate && isInPast(startDate)
const hasEnrolled =
enrollmentStatus && enrollmentStatus !== EnrollmentStatus.NotEnrolled
- const createEnrollment = useCreateEnrollment()
- const userCountry = mitxOnlineUser.data?.legal_address?.country
- const userYearOfBirth = mitxOnlineUser.data?.user_profile?.year_of_birth
- const showJustInTimeDialog = !userCountry || !userYearOfBirth
- return (hasStarted && href) || !hasEnrolled ? (
- hasEnrolled && href ? (
- }
- href={href}
className={className}
+ disabled={oneClickEnroll.isPending || !coursewareId}
+ onClick={() => {
+ if (!href || !coursewareId) return
+ oneClickEnroll.mutate({ href, coursewareId })
+ }}
+ endIcon={
+ oneClickEnroll.isPending ? (
+
+ ) : undefined
+ }
{...others}
>
{coursewareText}
-
- ) : (
-
+
)
- ) : (
+ }
+ // Disabled
+ return (
}
@@ -169,17 +161,13 @@ const UnenrollDialogInner: React.FC = ({
variant="primary"
type="submit"
disabled={destroyEnrollment.isPending}
+ endIcon={
+ destroyEnrollment.isPending ? (
+
+ ) : undefined
+ }
>
Unenroll
- {destroyEnrollment.isPending && (
-
-
-
- )}
}
@@ -275,13 +263,13 @@ const JustInTimeDialogInner: React.FC<{ href: string; readableId: string }> = ({
variant="primary"
type="submit"
disabled={formik.isSubmitting}
+ endIcon={
+ formik.isSubmitting ? (
+
+ ) : undefined
+ }
>
Submit
- {formik.isSubmitting && (
-
-
-
- )}
}
diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.tsx
index a8b390335c..c5709ee8f8 100644
--- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.tsx
+++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.tsx
@@ -139,6 +139,7 @@ const EnrollmentExpandCollapse: React.FC = ({
{shownEnrollments.map((course) => (
= ({
{hiddenEnrollments.map((course) => (
))}
@@ -326,7 +326,7 @@ const OrgProgramDisplay: React.FC<{
dashboardResource={course}
courseNoun="Module"
offerUpgrade={false}
- titleHref={course.run?.coursewareUrl}
+ titleAction="courseware"
buttonHref={course.run?.coursewareUrl}
/>
))}
diff --git a/frontends/ol-test-utilities/src/cartesianProduct.ts b/frontends/ol-test-utilities/src/cartesianProduct.ts
new file mode 100644
index 0000000000..b4b189117e
--- /dev/null
+++ b/frontends/ol-test-utilities/src/cartesianProduct.ts
@@ -0,0 +1,56 @@
+// Provide several overloads for better type inference with up to 5 arrays.
+function cartesianProduct(a: A[], b: B[]): (A & B)[]
+function cartesianProduct(a: A[], b: B[], c: C[]): (A & B & C)[]
+function cartesianProduct(
+ a: A[],
+ b: B[],
+ c: C[],
+ d: D[],
+): (A & B & C & D)[]
+function cartesianProduct(
+ a: A[],
+ b: B[],
+ c: C[],
+ d: D[],
+ e: E[],
+): (A & B & C & D & E)[]
+function cartesianProduct(...arrays: T[][]): T[]
+
+/**
+ * Generates the cartesian product of multiple arrays of objects, merging the
+ * objects. This can be used with jest.each for an effect similar to multiple
+ * pytest.mark.parametrize calls.
+ *
+ * For example:
+ * ```ts
+ * cartesianProduct(
+ * [{ x: 1 }, { x: 2 }],
+ * [{ y: 'a' }, { y: 'b' }],
+ * [{ z: 3 }, { z: 4 }]
+ * )
+ * ```
+ *
+ * would yield:
+ * ```
+ * [
+ * { x: 1, y: 'a', z: 3 },
+ * { x: 1, y: 'a', z: 4 },
+ * { x: 1, y: 'b', z: 3 },
+ * { x: 1, y: 'b', z: 4 },
+ * { x: 2, y: 'a', z: 3 },
+ * { x: 2, y: 'a', z: 4 },
+ * { x: 2, y: 'b', z: 3 },
+ * { x: 2, y: 'b', z: 4 }
+ * ]
+ * ```
+ */
+function cartesianProduct(...arrays: T[][]): T[] {
+ return arrays.reduce(
+ (acc, curr) => {
+ return acc.flatMap((a) => curr.map((b) => ({ ...a, ...b })))
+ },
+ [{}] as T[],
+ )
+}
+
+export default cartesianProduct
diff --git a/frontends/ol-test-utilities/src/cartestianProduct.test.ts b/frontends/ol-test-utilities/src/cartestianProduct.test.ts
new file mode 100644
index 0000000000..3ba05d97f8
--- /dev/null
+++ b/frontends/ol-test-utilities/src/cartestianProduct.test.ts
@@ -0,0 +1,38 @@
+import cartesianProduct from "./cartesianProduct"
+
+describe("cartesianProduct", () => {
+ it("should return an empty array when given empty arrays", () => {
+ const result = cartesianProduct([], [])
+ expect(result).toEqual([])
+ })
+
+ it("should handle single array", () => {
+ const result = cartesianProduct([{ a: 1 }, { a: 2 }])
+ expect(result).toEqual([{ a: 1 }, { a: 2 }])
+ })
+
+ it("should generate cartesian product of two arrays", () => {
+ const result = cartesianProduct(
+ [
+ { a: 0, x: 10 },
+ { a: 1, x: 20 },
+ ],
+ [{ y: "a" }, { y: "b" }, { y: "c" }],
+ [{ z: true }, { z: false }],
+ )
+ expect(result).toEqual([
+ { a: 0, x: 10, y: "a", z: true },
+ { a: 0, x: 10, y: "a", z: false },
+ { a: 0, x: 10, y: "b", z: true },
+ { a: 0, x: 10, y: "b", z: false },
+ { a: 0, x: 10, y: "c", z: true },
+ { a: 0, x: 10, y: "c", z: false },
+ { a: 1, x: 20, y: "a", z: true },
+ { a: 1, x: 20, y: "a", z: false },
+ { a: 1, x: 20, y: "b", z: true },
+ { a: 1, x: 20, y: "b", z: false },
+ { a: 1, x: 20, y: "c", z: true },
+ { a: 1, x: 20, y: "c", z: false },
+ ])
+ })
+})
diff --git a/frontends/ol-test-utilities/src/index.ts b/frontends/ol-test-utilities/src/index.ts
index 4b28cc5aea..0570104320 100644
--- a/frontends/ol-test-utilities/src/index.ts
+++ b/frontends/ol-test-utilities/src/index.ts
@@ -6,6 +6,7 @@ export * from "./assertions"
export * from "./domQueries/byImageSrc"
export * from "./domQueries/byTerm"
export * from "./domQueries/forms"
+export { default as cartesianProduct } from "./cartesianProduct"
/**
* This is moment-timezone.