diff --git a/env/shared.env b/env/shared.env index 178503bf27..5583523cd9 100644 --- a/env/shared.env +++ b/env/shared.env @@ -13,3 +13,8 @@ APISIX_PORT=8065 KEYCLOAK_PORT=8066 KEYCLOAK_SSL_PORT=8067 REDIS_VIEW_CACHE_DURATION=0 + +# APISIX session cookie defaults (can be overridden in local env) +APISIX_COOKIE_SECURE=false +APISIX_COOKIE_SAMESITE=Lax +APISIX_REDIRECT_URI=http://host.docker.internal:8777/.apisix/redirect diff --git a/frontends/api/src/mitxonline/hooks/enrollment/queries.ts b/frontends/api/src/mitxonline/hooks/enrollment/queries.ts index 67f0a9855b..2110e1206e 100644 --- a/frontends/api/src/mitxonline/hooks/enrollment/queries.ts +++ b/frontends/api/src/mitxonline/hooks/enrollment/queries.ts @@ -1,7 +1,7 @@ import { queryOptions } from "@tanstack/react-query" import type { CourseRunEnrollmentRequestV2, - UserProgramEnrollmentDetail, + V2UserProgramEnrollmentDetail, } from "@mitodl/mitxonline-api-axios/v2" import { courseRunEnrollmentsApi, programEnrollmentsApi } from "../../clients" @@ -35,9 +35,9 @@ const enrollmentQueries = { programEnrollmentsList: (opts?: RawAxiosRequestConfig) => queryOptions({ queryKey: enrollmentKeys.programEnrollmentsList(opts), - queryFn: async (): Promise => { + queryFn: async (): Promise => { return programEnrollmentsApi - .programEnrollmentsList(opts) + .v2ProgramEnrollmentsList(opts) .then((res) => res.data) }, }), diff --git a/frontends/api/src/mitxonline/test-utils/factories/enrollment.ts b/frontends/api/src/mitxonline/test-utils/factories/enrollment.ts index 418cfdba1e..aac16f82f6 100644 --- a/frontends/api/src/mitxonline/test-utils/factories/enrollment.ts +++ b/frontends/api/src/mitxonline/test-utils/factories/enrollment.ts @@ -6,6 +6,7 @@ import type { CourseRunEnrollmentRequestV2, CourseRunGrade, UserProgramEnrollmentDetail, + V2UserProgramEnrollmentDetail, } from "@mitodl/mitxonline-api-axios/v2" import { UniqueEnforcer } from "enforce-unique" import { factories } from ".." @@ -193,9 +194,33 @@ const programEnrollment: PartialFactory = ( return mergeOverrides(defaults, overrides) } +const programEnrollmentV2: PartialFactory = ( + overrides = {}, +): V2UserProgramEnrollmentDetail => { + const program = factories.programs.program() + const hasCertificate = faker.datatype.boolean() + const defaults: V2UserProgramEnrollmentDetail = { + certificate: hasCertificate + ? { + uuid: faker.string.uuid(), + link: `/certificate/program/${faker.string.uuid()}/`, + } + : null, + program: program, + enrollments: [courseEnrollment()], + } + return mergeOverrides(defaults, overrides) +} + // Not paginated const courseEnrollments = (count: number): CourseRunEnrollmentRequestV2[] => { return new Array(count).fill(null).map(() => courseEnrollment()) } -export { courseEnrollment, courseEnrollments, grade, programEnrollment } +export { + courseEnrollment, + courseEnrollments, + grade, + programEnrollment, + programEnrollmentV2, +} diff --git a/frontends/api/src/mitxonline/test-utils/urls.ts b/frontends/api/src/mitxonline/test-utils/urls.ts index b8d08e6985..6cd42e74e0 100644 --- a/frontends/api/src/mitxonline/test-utils/urls.ts +++ b/frontends/api/src/mitxonline/test-utils/urls.ts @@ -26,6 +26,7 @@ const enrollment = { const programEnrollments = { enrollmentsList: () => `${API_BASE_URL}/api/v1/program_enrollments/`, + enrollmentsListV2: () => `${API_BASE_URL}/api/v2/program_enrollments/`, } const b2b = { 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 ec993bd752..a603a25193 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.test.tsx @@ -9,7 +9,7 @@ import { import * as mitxonline from "api/mitxonline-test-utils" import { mockAxiosInstance } from "api/test-utils" import { DashboardCard, getDefaultContextMenuItems } from "./DashboardCard" -import { dashboardCourse } from "./test-utils" +import { dashboardCourse, dashboardProgram } from "./test-utils" import { faker } from "@faker-js/faker/locale/en" import moment from "moment" import { EnrollmentMode, EnrollmentStatus } from "./types" @@ -80,10 +80,13 @@ describe.each([ }) }) - test("It shows course title and links to marketingUrl if titleAction is marketing", async () => { + test("It shows course title and links to marketingUrl if titleAction is marketing and enrolled", async () => { setupUserApis() const course = dashboardCourse({ marketingUrl: "?some-marketing-url", + enrollment: { + status: EnrollmentStatus.Enrolled, + }, }) renderWithProviders( , @@ -97,9 +100,39 @@ describe.each([ expect(courseLink).toHaveAttribute("href", course.marketingUrl) }) - test("It shows course title and links to courseware if titleAction is courseware", async () => { + test("It shows course title as clickable text (not link) if titleAction is marketing and not enrolled (non-B2B)", async () => { setupUserApis() - const course = dashboardCourse() + const course = dashboardCourse({ + marketingUrl: "?some-marketing-url", + enrollment: { + status: EnrollmentStatus.NotEnrolled, + }, + run: { + b2bContractId: null, + }, + }) + renderWithProviders( + , + ) + + const card = getCard() + + // Should not be a link + expect( + within(card).queryByRole("link", { name: course.title }), + ).not.toBeInTheDocument() + // Should be clickable text + const titleText = within(card).getByText(course.title) + expect(titleText).toBeInTheDocument() + }) + + test("It shows course title and links to courseware if titleAction is courseware and enrolled", async () => { + setupUserApis() + const course = dashboardCourse({ + enrollment: { + status: EnrollmentStatus.Enrolled, + }, + }) renderWithProviders( , ) @@ -112,6 +145,56 @@ describe.each([ expect(courseLink).toHaveAttribute("href", course.run.coursewareUrl) }) + test("It shows course title as clickable text (not link) if titleAction is courseware and not enrolled (non-B2B)", async () => { + setupUserApis() + const course = dashboardCourse({ + enrollment: { + status: EnrollmentStatus.NotEnrolled, + }, + run: { + b2bContractId: null, + }, + }) + renderWithProviders( + , + ) + + const card = getCard() + + // Should not be a link + expect( + within(card).queryByRole("link", { name: course.title }), + ).not.toBeInTheDocument() + // Should be clickable text + const titleText = within(card).getByText(course.title) + expect(titleText).toBeInTheDocument() + }) + + test("It shows course title as link if not enrolled but has B2B contract", async () => { + setupUserApis() + const b2bContractId = faker.number.int() + const course = dashboardCourse({ + enrollment: { + status: EnrollmentStatus.NotEnrolled, + b2b_contract_id: b2bContractId, + }, + run: { + b2bContractId: b2bContractId, + }, + }) + renderWithProviders( + , + ) + + const card = getCard() + + // Should be a link for B2B courses + const courseLink = within(card).getByRole("link", { + name: course.title, + }) + expect(courseLink).toHaveAttribute("href", course.run.coursewareUrl) + }) + test("Accepts a classname", () => { setupUserApis() const course = dashboardCourse() @@ -137,12 +220,22 @@ describe.each([ test.each([ { - course: pastDashboardCourse(), + course: pastDashboardCourse({ + enrollment: { + status: EnrollmentStatus.Enrolled, + mode: EnrollmentMode.Audit, + }, + }), expected: { enabled: true }, case: "past", }, { - course: currentDashboardCourse(), + course: currentDashboardCourse({ + enrollment: { + status: EnrollmentStatus.Enrolled, + mode: EnrollmentMode.Audit, + }, + }), expected: { enabled: true }, case: "current", }, @@ -221,7 +314,7 @@ describe.each([ view.rerender( , ) @@ -236,7 +329,7 @@ describe.each([ `${expected.label} ${courseNoun}`, ) } else { - // "Continue" doesn't use courseNoun + // "Continue" doesn't use noun expect(coursewareCTA).toHaveTextContent(expected.label) } }, @@ -605,8 +698,15 @@ describe.each([ "Enrollment for complete profile bypasses just-in-time dialog", async ({ trigger }) => { const userData = mitxUser() + const b2bContractId = faker.number.int() const course = dashboardCourse({ - enrollment: { status: EnrollmentStatus.NotEnrolled }, + enrollment: { + status: EnrollmentStatus.NotEnrolled, + b2b_contract_id: b2bContractId, + }, + run: { + b2bContractId: b2bContractId, + }, }) const { enrollmentUrl } = setupEnrollmentApis({ user: userData, course }) renderWithProviders( @@ -634,8 +734,15 @@ describe.each([ )( "Enrollment for complete profile bypasses just-in-time dialog", async ({ trigger, userData }) => { + const b2bContractId = faker.number.int() const course = dashboardCourse({ - enrollment: { status: EnrollmentStatus.NotEnrolled }, + enrollment: { + status: EnrollmentStatus.NotEnrolled, + b2b_contract_id: b2bContractId, + }, + run: { + b2bContractId: b2bContractId, + }, }) setupEnrollmentApis({ user: userData, course }) renderWithProviders( @@ -655,4 +762,90 @@ describe.each([ ) }, ) + + describe("Stacked Variant", () => { + test("applies stacked variant styling", () => { + setupUserApis() + const course = dashboardCourse() + renderWithProviders( + , + ) + + const card = getCard() + expect(card).toBeInTheDocument() + // Successfully renders a stacked card - the variant prop controls styling via styled-components + }) + + test("renders multiple stacked cards correctly", () => { + setupUserApis() + const courses = [ + dashboardCourse({ title: "First Stacked Course" }), + dashboardCourse({ title: "Second Stacked Course" }), + dashboardCourse({ title: "Third Stacked Course" }), + ] + + renderWithProviders( +
+ {courses.map((course) => ( + + ))} +
, + ) + + const allCards = screen.getAllByTestId(testId) + expect(allCards).toHaveLength(3) + expect( + within(allCards[0]).getByText("First Stacked Course"), + ).toBeInTheDocument() + expect( + within(allCards[1]).getByText("Second Stacked Course"), + ).toBeInTheDocument() + expect( + within(allCards[2]).getByText("Third Stacked Course"), + ).toBeInTheDocument() + }) + }) + + describe("Program Cards", () => { + test("renders program card with title", () => { + setupUserApis() + const program = dashboardProgram({ + title: "Test Program Title", + }) + + renderWithProviders( + , + ) + + const card = getCard() + expect(within(card).getByText("Test Program Title")).toBeInTheDocument() + }) + + test("program card does not show course-specific elements", () => { + setupUserApis() + const program = dashboardProgram({ + title: "Test Program", + }) + + renderWithProviders( + , + ) + + const card = getCard() + // Programs don't show enrollment status or courseware buttons + expect( + within(card).queryByTestId("courseware-button"), + ).not.toBeInTheDocument() + expect(within(card).queryByTestId("upgrade-root")).not.toBeInTheDocument() + }) + }) }) diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.tsx index 1d0787cb60..69860b36b8 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.tsx @@ -9,10 +9,16 @@ import { LoadingSpinner, } from "ol-components" import NextLink from "next/link" -import { EnrollmentStatus, EnrollmentMode } from "./types" +import { + EnrollmentStatus, + EnrollmentMode, + DashboardResourceType, +} from "./types" import type { DashboardResource, DashboardCourse, + DashboardProgram, + DashboardProgramCollection, DashboardCourseEnrollment, } from "./types" import { ActionButton, Button, ButtonLink } from "@mitodl/smoot-design" @@ -34,10 +40,31 @@ import NiceModal from "@ebay/nice-modal-react" import { useCreateEnrollment } from "api/mitxonline-hooks/enrollment" import { mitxUserQueries } from "api/mitxonline-hooks/user" import { useQuery } from "@tanstack/react-query" +import { programView } from "@/common/urls" + +// Type guard functions +const isDashboardCourse = ( + resource: DashboardResource, +): resource is DashboardCourse => { + return resource.type === DashboardResourceType.Course +} + +const isDashboardProgram = ( + resource: DashboardResource, +): resource is DashboardProgram => { + return resource.type === DashboardResourceType.Program +} + +const isDashboardProgramCollection = ( + resource: DashboardResource, +): resource is DashboardProgramCollection => { + return resource.type === DashboardResourceType.ProgramCollection +} const CardRoot = styled.div<{ screenSize: "desktop" | "mobile" -}>(({ theme, screenSize }) => [ + variant?: "default" | "stacked" +}>(({ theme, screenSize, variant = "default" }) => [ { position: "relative", border: `1px solid ${theme.custom.colors.lightGray2}`, @@ -46,6 +73,9 @@ const CardRoot = styled.div<{ display: "flex", gap: "8px", alignItems: "center", + }, + // Mobile styles for default variant + variant === "default" && { [theme.breakpoints.down("md")]: { border: "none", borderBottom: `1px solid ${theme.custom.colors.lightGray2}`, @@ -55,6 +85,26 @@ const CardRoot = styled.div<{ gap: "16px", }, }, + // Stacked variant styles + variant === "stacked" && { + border: "none", + borderBottom: `1px solid ${theme.custom.colors.lightGray2}`, + borderRadius: "0px !important", + boxShadow: "none", + "&:first-of-type": { + borderTopLeftRadius: "8px !important", + borderTopRightRadius: "8px !important", + }, + "&:last-of-type": { + borderBottomLeftRadius: "8px !important", + borderBottomRightRadius: "8px !important", + borderBottom: "none", + }, + [theme.breakpoints.down("md")]: { + flexDirection: "column", + gap: "16px", + }, + }, screenSize === "desktop" && { [theme.breakpoints.down("md")]: { display: "none", @@ -73,6 +123,17 @@ const TitleLink = styled(Link)(({ theme }) => ({ }, })) +const TitleText = styled.div<{ clickable?: boolean }>( + ({ theme, clickable }) => ({ + ...theme.typography.subtitle2, + color: theme.custom.colors.darkGray2, + cursor: clickable ? "pointer" : "default", + [theme.breakpoints.down("md")]: { + maxWidth: "calc(100% - 16px)", + }, + }), +) + const MenuButton = styled(ActionButton)<{ status?: EnrollmentStatus }>(({ theme, status }) => [ @@ -150,32 +211,45 @@ const useOneClickEnroll = () => { type CoursewareButtonProps = { coursewareId?: string | null + readableId?: string | null startDate?: string | null endDate?: string | null enrollmentStatus?: EnrollmentStatus | null href?: string | null className?: string - courseNoun: string + noun: string + resourceType?: DashboardResourceType + b2bContractId?: number | null "data-testid"?: string + onClick?: React.MouseEventHandler } const getCoursewareTextAndIcon = ({ endDate, enrollmentStatus, - courseNoun, + noun, + resourceType, }: { endDate?: string | null enrollmentStatus?: EnrollmentStatus | null - courseNoun: string + noun: string + resourceType?: DashboardResourceType }) => { if (!enrollmentStatus || enrollmentStatus === EnrollmentStatus.NotEnrolled) { - return { text: `Start ${courseNoun}`, endIcon: null } + return { text: `Start ${noun}`, endIcon: null } } if ( (endDate && isInPast(endDate)) || enrollmentStatus === EnrollmentStatus.Completed ) { - return { text: `View ${courseNoun}`, endIcon: null } + return { text: `View ${noun}`, endIcon: null } + } + // Programs show "View Program" when enrolled, courses show "Continue" + if ( + resourceType === DashboardResourceType.Program && + enrollmentStatus === EnrollmentStatus.Enrolled + ) { + return { text: `View ${noun}`, endIcon: null } } return { text: "Continue", endIcon: } } @@ -183,18 +257,23 @@ const getCoursewareTextAndIcon = ({ const CoursewareButton = styled( ({ coursewareId, + readableId, startDate, endDate, enrollmentStatus, href, className, - courseNoun, + noun, + resourceType, + b2bContractId, + onClick, ...others }: CoursewareButtonProps) => { const coursewareText = getCoursewareTextAndIcon({ endDate, - courseNoun, + noun, enrollmentStatus, + resourceType, }) const hasStarted = startDate && isInPast(startDate) const hasEnrolled = @@ -202,32 +281,81 @@ const CoursewareButton = styled( const oneClickEnroll = useOneClickEnroll() + if (resourceType === DashboardResourceType.Program) { + return ( + + {coursewareText.text} + + ) + } + + if (onClick) { + return ( + + ) + } + if (!hasEnrolled /* enrollment flow */) { + // For B2B courses, use one-click enrollment + if (b2bContractId) { + return ( + + ) + } + + // For non-B2B courses, show alert return ( ) - } else if (hasStarted && href /* Link to course */) { + } else if ( + (hasStarted || !startDate) && + href /* Link to course or program */ + ) { return ( ) } - // Disabled + // Disabled (course not started yet) return (