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.