From 3fe904ba51911adfbc998c8cbd8823699f3537fd Mon Sep 17 00:00:00 2001 From: cp-at-mit Date: Wed, 23 Sep 2026 11:55:49 -0400 Subject: [PATCH 1/7] Track begin-checkout analytics for enrollments (#3959) * Track begin-checkout analytics for enrollments Adds `trackBeginCheckout` when a user starts checkout from both the dashboard enrollment flow and the course enrollment dialog. This complements the existing enrollment and cart analytics so purchase funnel tracking reflects the full enrollment journey. * Add trackBeginCheckout and trackAddToCart assertions to enrollment tests Co-Authored-By: Claude Sonnet 4.6 * Enrich begin checkout analytics event Update begin-checkout GTM tracking to send GA4-friendly checkout data, including course ID, value, currency, and item details. The enrollment flow and related tests now pass structured checkout params instead of only the course name. * Pass course metadata to checkout tracking Update course enrollment to call `trackBeginCheckout` with structured analytics data instead of only the course title. The payload now includes the course name, readable ID, and parsed product price so checkout events capture the metadata expected by downstream tracking. --------- Co-authored-by: Claude Sonnet 4.6 --- .../UnenrolledCourseCard.test.tsx | 11 ++++- .../hooks/useEnrollmentHandler.ts | 9 +++- .../ProductPages/useCourseEnrollment.test.tsx | 8 +++- .../ProductPages/useCourseEnrollment.ts | 6 ++- .../main/src/common/analytics/gtm.test.ts | 41 ++++++++++++++++--- frontends/main/src/common/analytics/gtm.ts | 28 ++++++++++++- .../CourseEnrollmentDialog.test.tsx | 17 ++++++++ .../CourseEnrollmentDialog.tsx | 11 ++++- 8 files changed, 119 insertions(+), 12 deletions(-) diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/UnenrolledCourseCard.test.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/UnenrolledCourseCard.test.tsx index 8b23ff115b..c18b921b84 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/UnenrolledCourseCard.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/UnenrolledCourseCard.test.tsx @@ -16,11 +16,12 @@ import { faker } from "@faker-js/faker/locale/en" import moment from "moment" import { cartesianProduct } from "ol-test-utilities" import { UnenrolledCourseCard } from "./UnenrolledCourseCard" -import { trackCourseEnrolled } from "@/common/analytics/gtm" +import { trackCourseEnrolled, trackBeginCheckout } from "@/common/analytics/gtm" jest.mock("@/common/analytics/gtm", () => ({ ...jest.requireActual("@/common/analytics/gtm"), trackCourseEnrolled: jest.fn(), + trackBeginCheckout: jest.fn(), })) /** @@ -883,6 +884,14 @@ describe.each([ ) }) + expect(trackBeginCheckout).toHaveBeenCalledWith( + expect.objectContaining({ + courseName: course.title, + courseId: course.readable_id, + value: parseFloat(product.price), + }), + ) + expect( screen.queryByRole("dialog", { name: course.title }), ).not.toBeInTheDocument() diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/hooks/useEnrollmentHandler.ts b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/hooks/useEnrollmentHandler.ts index 57779b752b..30ef35c792 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/hooks/useEnrollmentHandler.ts +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/hooks/useEnrollmentHandler.ts @@ -13,7 +13,7 @@ import NiceModal from "@ebay/nice-modal-react" import { getCourseEnrollmentAction } from "@/common/mitxonline" import { useComplianceGate } from "@/common/mitxonline/useComplianceGate" import CourseEnrollmentDialog from "@/page-components/EnrollmentDialogs/CourseEnrollmentDialog" -import { trackCourseEnrolled } from "@/common/analytics/gtm" +import { trackCourseEnrolled, trackBeginCheckout } from "@/common/analytics/gtm" import { canOpenCourseware } from "../courseDateUtils" import { mitxUserQueries } from "api/mitxonline-hooks/user" import { useQuery } from "@tanstack/react-query" @@ -170,6 +170,13 @@ export const useEnrollmentHandler = () => { } if (enrollmentAction.type === "checkout") { + trackBeginCheckout({ + courseName: course.title, + courseId: course.readable_id, + value: enrollmentAction.product.price + ? parseFloat(enrollmentAction.product.price) + : 0, + }) replaceBasketItem.mutate(enrollmentAction.product.id) return } diff --git a/frontends/main/src/app-pages/ProductPages/useCourseEnrollment.test.tsx b/frontends/main/src/app-pages/ProductPages/useCourseEnrollment.test.tsx index 11a04b621b..6e021e538e 100644 --- a/frontends/main/src/app-pages/ProductPages/useCourseEnrollment.test.tsx +++ b/frontends/main/src/app-pages/ProductPages/useCourseEnrollment.test.tsx @@ -391,7 +391,13 @@ describe("useCourseEnrollment — actions", () => { paidOption!.onClick!(fakeEvent) expect(trackStartEnrollment).toHaveBeenCalledWith(course.title) - expect(trackBeginCheckout).toHaveBeenCalledWith(course.title) + expect(trackBeginCheckout).toHaveBeenCalledWith( + expect.objectContaining({ + courseName: course.title, + courseId: course.readable_id, + value: parseFloat(product.price), + }), + ) await waitFor(() => expect(makeRequest).toHaveBeenCalledWith( diff --git a/frontends/main/src/app-pages/ProductPages/useCourseEnrollment.ts b/frontends/main/src/app-pages/ProductPages/useCourseEnrollment.ts index 5ea91ba570..8a0f815a31 100644 --- a/frontends/main/src/app-pages/ProductPages/useCourseEnrollment.ts +++ b/frontends/main/src/app-pages/ProductPages/useCourseEnrollment.ts @@ -105,7 +105,11 @@ export const useCourseEnrollment = ( if (kind === "paid") { const product = selectedRun?.products?.[0] if (product) { - trackBeginCheckout(course.title) + trackBeginCheckout({ + courseName: course.title, + courseId: course.readable_id, + value: product.price ? parseFloat(product.price) : 0, + }) replaceBasketItem.mutate(product.id) } } else if (kind === "free") { diff --git a/frontends/main/src/common/analytics/gtm.test.ts b/frontends/main/src/common/analytics/gtm.test.ts index fb966e120d..e18be11262 100644 --- a/frontends/main/src/common/analytics/gtm.test.ts +++ b/frontends/main/src/common/analytics/gtm.test.ts @@ -282,17 +282,48 @@ describe("trackReturnVisit", () => { }) describe("trackBeginCheckout", () => { - it("pushes a begin-checkout event with course name", () => { - trackBeginCheckout("Data Science Fundamentals") + it("pushes a begin-checkout event with all fields", () => { + trackBeginCheckout({ + courseName: "Data Science Fundamentals", + courseId: "course-v1:MITx+6.86x", + value: 149, + }) expect(window.dataLayer).toContainEqual({ event: "begin-checkout", "course-name": "Data Science Fundamentals", + "course-id": "course-v1:MITx+6.86x", + currency: "USD", + value: 149, + items: [ + { + item_id: "course-v1:MITx+6.86x", + item_name: "Data Science Fundamentals", + price: 149, + quantity: 1, + currency: "USD", + }, + ], }) }) - it("pushes a begin-checkout event without course name when null", () => { - trackBeginCheckout(null) - expect(window.dataLayer).toContainEqual({ event: "begin-checkout" }) + it("defaults value to 0 and currency to USD when not provided", () => { + trackBeginCheckout({ courseName: "Data Science Fundamentals" }) + expect(window.dataLayer).toContainEqual( + expect.objectContaining({ + event: "begin-checkout", + currency: "USD", + value: 0, + }), + ) + }) + + it("omits course-name and course-id when not provided", () => { + trackBeginCheckout({ value: 99 }) + expect(window.dataLayer).toContainEqual( + expect.objectContaining({ event: "begin-checkout", value: 99 }), + ) + expect(window.dataLayer![0]).not.toHaveProperty("course-name") + expect(window.dataLayer![0]).not.toHaveProperty("course-id") }) }) diff --git a/frontends/main/src/common/analytics/gtm.ts b/frontends/main/src/common/analytics/gtm.ts index 5b1f07c6f5..c66ee26142 100644 --- a/frontends/main/src/common/analytics/gtm.ts +++ b/frontends/main/src/common/analytics/gtm.ts @@ -220,14 +220,37 @@ const trackReturnVisit = () => { pushGtmEvent("return-visit") } +type BeginCheckoutParams = { + courseName?: string | null + courseId?: string | null + value?: number | null + currency?: string | null +} + /** * Fired when a user begins the paid checkout flow. * Maps to "Begin Checkout" in the marketing event plan. * More specific than trackStartEnrollment — fires only for the checkout path. + * Includes currency, value, and items so GA4 built-in funnel and revenue + * reports are populated between add_to_cart and purchase. */ -const trackBeginCheckout = (courseName?: string | null) => { +const trackBeginCheckout = (params: BeginCheckoutParams) => { + const currency = params.currency ?? "USD" + const value = params.value ?? 0 pushGtmEvent("begin-checkout", { - ...(courseName ? { "course-name": courseName } : {}), + ...(params.courseName ? { "course-name": params.courseName } : {}), + ...(params.courseId ? { "course-id": params.courseId } : {}), + currency, + value, + items: [ + { + item_id: params.courseId ?? "", + item_name: params.courseName ?? "", + price: value, + quantity: 1, + currency, + }, + ], }) } @@ -311,6 +334,7 @@ export { export type { AddToCartParams, + BeginCheckoutParams, CatalogFilterParams, CourseProgramViewParams, CheckoutCompletedParams, diff --git a/frontends/main/src/page-components/EnrollmentDialogs/CourseEnrollmentDialog.test.tsx b/frontends/main/src/page-components/EnrollmentDialogs/CourseEnrollmentDialog.test.tsx index c2348e0a2a..a8f8e9b44f 100644 --- a/frontends/main/src/page-components/EnrollmentDialogs/CourseEnrollmentDialog.test.tsx +++ b/frontends/main/src/page-components/EnrollmentDialogs/CourseEnrollmentDialog.test.tsx @@ -27,6 +27,13 @@ import { faker } from "@faker-js/faker/locale/en" import invariant from "tiny-invariant" import { mitxonlineLegacyUrl } from "@/common/mitxonline" import * as routes from "@/common/urls" +import { trackAddToCart, trackBeginCheckout } from "@/common/analytics/gtm" + +jest.mock("@/common/analytics/gtm", () => ({ + ...jest.requireActual("@/common/analytics/gtm"), + trackAddToCart: jest.fn(), + trackBeginCheckout: jest.fn(), +})) const makeCourseRun = mitxFactories.courses.courseRun const makeProduct = mitxFactories.courses.product @@ -440,6 +447,16 @@ describe("CourseEnrollmentDialog", () => { // Verify redirect to cart page expect(assign).toHaveBeenCalledWith(mitxonlineLegacyUrl("/cart/")) + + expect(trackAddToCart).toHaveBeenCalledWith( + expect.objectContaining({ courseName: course.title }), + ) + expect(trackBeginCheckout).toHaveBeenCalledWith( + expect.objectContaining({ + courseName: course.title, + value: parseFloat(product.price), + }), + ) }) test("Default behavior: redirects to the dashboard success URL with title in params after enrollment", async () => { diff --git a/frontends/main/src/page-components/EnrollmentDialogs/CourseEnrollmentDialog.tsx b/frontends/main/src/page-components/EnrollmentDialogs/CourseEnrollmentDialog.tsx index 8753aba7bb..3142cb963b 100644 --- a/frontends/main/src/page-components/EnrollmentDialogs/CourseEnrollmentDialog.tsx +++ b/frontends/main/src/page-components/EnrollmentDialogs/CourseEnrollmentDialog.tsx @@ -31,7 +31,11 @@ import { useComplianceGate } from "@/common/mitxonline/useComplianceGate" import { useRouter } from "next-nprogress-bar" import { useQuery } from "@tanstack/react-query" import { productQueries } from "api/mitxonline-hooks/products" -import { trackCourseEnrolled, trackAddToCart } from "@/common/analytics/gtm" +import { + trackCourseEnrolled, + trackAddToCart, + trackBeginCheckout, +} from "@/common/analytics/gtm" interface CourseEnrollmentDialogProps { course: CourseWithCourseRunsSerializerV2 @@ -310,6 +314,11 @@ const CertificateUpsell: React.FC<{ courseName: course?.title, coursePrice: product.price ? parseFloat(product.price) : 0, }) + trackBeginCheckout({ + courseName: course?.title, + courseId: course?.readable_id ?? String(product.id), + value: product.price ? parseFloat(product.price) : 0, + }) replaceBasketItem.mutate(product.id) }} /> From 35a5aba85a480b027af63da5451f05fdd5e38c08 Mon Sep 17 00:00:00 2001 From: Danielle Frappier Date: Wed, 23 Sep 2026 13:33:09 -0400 Subject: [PATCH 2/7] feat: add hover explanation for Active learners, fix clipped month label in engagement chart (#3966) --- .../Analytics/EngagementTrendChart.tsx | 80 ++++++++++++++++++- .../DashboardPage/Analytics/charts.test.tsx | 72 ++++++++++++++++- 2 files changed, 149 insertions(+), 3 deletions(-) diff --git a/frontends/main/src/app-pages/DashboardPage/Analytics/EngagementTrendChart.tsx b/frontends/main/src/app-pages/DashboardPage/Analytics/EngagementTrendChart.tsx index 647df5d51d..498b99d657 100644 --- a/frontends/main/src/app-pages/DashboardPage/Analytics/EngagementTrendChart.tsx +++ b/frontends/main/src/app-pages/DashboardPage/Analytics/EngagementTrendChart.tsx @@ -2,7 +2,8 @@ import React from "react" import { LineChart } from "@mui/x-charts/LineChart" -import { Skeleton, styled, useTheme } from "ol-components" +import { RiInformationLine } from "@remixicon/react" +import { Skeleton, styled, Tooltip, useTheme } from "ol-components" import type { MonthlyEngagementTrend } from "api/analytics-hooks/organizations" import { EmptyTableMessage, @@ -66,6 +67,34 @@ const TableWrapper = styled.div(({ theme }) => ({ borderTop: `1px solid ${theme.custom.colors.lightGray2}`, })) +const InfoTrigger = styled.span(({ theme }) => ({ + display: "inline-flex", + alignItems: "center", + verticalAlign: "middle", + marginLeft: "4px", + color: theme.custom.colors.silverGrayDark, + cursor: "help", + "& svg": { width: "16px", height: "16px", marginLeft: "4px" }, +})) + +/** + * The desktop header housing the same trigger is `display: none` below `md` + * (`TableHeaderRow`), so this repeats it once, outside the table, in the one + * layout where that header is hidden — never per row, which would turn one + * definition into a repeated focus stop for every month. + */ +const MobileColumnHelp = styled.div(({ theme }) => ({ + display: "none", + [theme.breakpoints.down("md")]: { + display: "flex", + flexWrap: "wrap", + gap: "16px", + marginBottom: "12px", + ...theme.typography.subtitle2, + color: theme.custom.colors.black, + }, +})) + const CHART_HEIGHT = 320 const COLUMN_FLEX = { @@ -82,24 +111,40 @@ const SERIES = [ column: "active", label: "Active learners", color: CATEGORICAL[0], + /** + * Copied from the `monthly_active_learners` field description in + * ol-analytics-api's b2b_dashboard models (mitodl/ol-analytics-api#57), + * itself derived from the backing dbt SQL in ol-data-platform. That + * description lives only in the OpenAPI schema (/openapi.json, /docs) — + * the actual row data this component fetches never carries it, and this + * client is hand-written rather than generated from the schema (see + * the header comment in analytics/types.ts), so there is no fetch-and- + * parse step that could keep this in sync automatically. If the backend + * description changes, this string has to be updated by hand to match. + */ + description: + "Learners who did anything in a course this month: watched a video, attempted a problem, posted in a discussion, used the chatbot, moved through course pages or earned a certificate. Enrolling alone doesn't count. If too few learners were active, the whole month is withheld to avoid identifying them.", }, { key: "new_enrollments", column: "enrollments", label: "New enrollments", color: CATEGORICAL[1], + description: undefined, }, { key: "certificates_earned", column: "certificates", label: "Certificates earned", color: CATEGORICAL[2], + description: undefined, }, ] as const satisfies ReadonlyArray<{ key: keyof MonthlyEngagementTrend column: keyof typeof COLUMN_FLEX label: string color: string + description?: string }> const EngagementTrendChart: React.FC<{ @@ -152,7 +197,16 @@ const EngagementTrendChart: React.FC<{
+ + {SERIES.filter((series) => series.description).map((series) => ( + + {/* eslint-disable-next-line styled-components-a11y/no-noninteractive-tabindex */} + + {series.label} + + + ))} +
@@ -215,6 +283,14 @@ const EngagementTrendChart: React.FC<{ $numeric > {series.label} + {series.description ? ( + + {/* eslint-disable-next-line styled-components-a11y/no-noninteractive-tabindex */} + + + + ) : null} ))} diff --git a/frontends/main/src/app-pages/DashboardPage/Analytics/charts.test.tsx b/frontends/main/src/app-pages/DashboardPage/Analytics/charts.test.tsx index a394a42376..d315010527 100644 --- a/frontends/main/src/app-pages/DashboardPage/Analytics/charts.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/Analytics/charts.test.tsx @@ -1,5 +1,11 @@ import React from "react" -import { render, screen, within } from "@testing-library/react" +import { + render, + screen, + waitForElementToBeRemoved, + within, +} from "@testing-library/react" +import userEvent from "@testing-library/user-event" import { ThemeProvider, useTheme } from "ol-components" import type { Theme } from "ol-components" import { factories } from "api/analytics-test-utils" @@ -144,6 +150,70 @@ describe("EngagementTrendChart", () => { ).toBeInTheDocument() }) + /** + * "Active learners" alone doesn't say what counts as active — the hover + * icon's accessible label carries the definition for keyboard and screen + * reader users, not just pointer hover. Scoped to the table: `SERIES` with + * the trigger's description also drives a second, mobile-only copy of this + * same trigger outside the table (see the test below), so an unscoped query + * would match both. + */ + test("explains what counts as an active learner from the column header", () => { + renderWithTheme() + + const table = screen.getByRole("table", { name: "Monthly engagement" }) + expect( + within(table).getByLabelText( + /Learners who did anything in a course this month/, + ), + ).toBeInTheDocument() + }) + + /** + * The column header carrying that trigger is hidden below the `md` + * breakpoint (`TableHeaderRow`), so mobile/tablet users need an equivalent + * — once, outside the table, not repeated for every month's row. + */ + test("repeats the active-learner definition once for mobile, outside the table", () => { + renderWithTheme() + + const table = screen.getByRole("table", { name: "Monthly engagement" }) + const triggers = screen.getAllByLabelText( + /Learners who did anything in a course this month/, + ) + expect(triggers).toHaveLength(2) + expect(triggers.some((trigger) => !table.contains(trigger))).toBe(true) + }) + + /** + * A static aria-label proves nothing about whether the `Tooltip` itself + * works — this test would still pass if `Tooltip` were deleted entirely. + * Driving real hover and asserting the rendered popper text catches that; + * keyboard focus isn't asserted here because MUI only opens on focus when + * `:focus-visible` matches (`isFocusVisible`), which this test environment + * doesn't set for a programmatic `.focus()` call — see the same caveat in + * ContractAdminPage.test.tsx's tooltip test. + */ + test("shows the active-learner definition on hover", async () => { + const user = userEvent.setup() + renderWithTheme() + + const table = screen.getByRole("table", { name: "Monthly engagement" }) + const trigger = within(table).getByLabelText( + /Learners who did anything in a course this month/, + ) + + await user.hover(trigger) + expect( + await screen.findByRole("tooltip", { + name: /Learners who did anything in a course this month/, + }), + ).toBeInTheDocument() + + await user.unhover(trigger) + await waitForElementToBeRemoved(() => screen.queryByRole("tooltip")) + }) + test("shows an empty state rather than an empty chart", () => { renderWithTheme() expect( From 6ee856834df768a5c8bafb0aa9c9aa94503b9e28 Mon Sep 17 00:00:00 2001 From: Danielle Frappier Date: Wed, 23 Sep 2026 14:34:21 -0400 Subject: [PATCH 3/7] fix: align Organizational Learning page with Figma design QA (#3971) --- frontends/main/package.json | 2 +- .../CaseStudiesSection.tsx | 11 +- .../ClientLogosSection.tsx | 4 +- .../OrganizationalLearningPage/FaqSection.tsx | 6 +- .../FeaturedProgramSection.tsx | 10 +- .../GetInTouchSection.tsx | 6 +- .../HeroSection.tsx | 106 +++++++++--------- .../OfferingsSection.tsx | 3 + .../OrgLeadForm.tsx | 15 ++- .../OrganizationalLearningPage.test.tsx | 6 +- .../SectionLayout.tsx | 5 +- .../OrganizationalLearningPage/copy.ts | 28 +++-- frontends/ol-components/package.json | 2 +- .../src/components/ReCaptcha/ReCaptcha.tsx | 12 +- .../src/components/ReCaptcha/index.ts | 2 +- yarn.lock | 12 +- 16 files changed, 134 insertions(+), 96 deletions(-) diff --git a/frontends/main/package.json b/frontends/main/package.json index 1001ef5e62..1fbf28aabe 100644 --- a/frontends/main/package.json +++ b/frontends/main/package.json @@ -19,7 +19,7 @@ "@mitodl/course-search-utils": "^3.8.1", "@mitodl/hacksnack": "^0.1.2", "@mitodl/mitxonline-api-axios": "2026.9.21", - "@mitodl/smoot-design": "6.36.0", + "@mitodl/smoot-design": "6.38.0", "@mui/base": "5.0.0-beta.70", "@mui/material": "^6.4.5", "@mui/material-nextjs": "^6.4.3", diff --git a/frontends/main/src/app-pages/OrganizationalLearningPage/CaseStudiesSection.tsx b/frontends/main/src/app-pages/OrganizationalLearningPage/CaseStudiesSection.tsx index 3a7cc0f473..376a867885 100644 --- a/frontends/main/src/app-pages/OrganizationalLearningPage/CaseStudiesSection.tsx +++ b/frontends/main/src/app-pages/OrganizationalLearningPage/CaseStudiesSection.tsx @@ -2,7 +2,7 @@ import React, { useCallback, useRef, useState } from "react" import Image from "next/image" -import { styled, pxToRem } from "ol-components" +import { styled } from "ol-components" import { CarouselV2 } from "ol-components/CarouselV2" import { VisuallyHidden } from "@mitodl/smoot-design" import { @@ -28,10 +28,10 @@ const Inner = styled(SectionInner)(({ theme }) => ({ display: "flex", flexDirection: "column", gap: "48px", - padding: "96px 24px 40px", + padding: "96px 0 40px", [theme.breakpoints.down("md")]: { gap: "32px", - padding: "32px 24px 16px", + padding: "32px 0 16px", }, })) @@ -225,7 +225,7 @@ const PillarTitle = styled.h4(({ theme }) => ({ })) const PillarBody = styled.p(({ theme }) => ({ - ...theme.typography.body2, + ...theme.typography.body2Loose, color: theme.custom.colors.darkGray2, margin: 0, })) @@ -251,8 +251,7 @@ const QuoteMark = styled.span(({ theme }) => ({ })) const QuoteText = styled.p(({ theme }) => ({ - ...theme.typography.body2, - lineHeight: pxToRem(22), + ...theme.typography.body2Loose, color: theme.custom.colors.darkGray2, margin: 0, marginTop: "-16px", diff --git a/frontends/main/src/app-pages/OrganizationalLearningPage/ClientLogosSection.tsx b/frontends/main/src/app-pages/OrganizationalLearningPage/ClientLogosSection.tsx index 8c773af743..99dbbcc456 100644 --- a/frontends/main/src/app-pages/OrganizationalLearningPage/ClientLogosSection.tsx +++ b/frontends/main/src/app-pages/OrganizationalLearningPage/ClientLogosSection.tsx @@ -15,9 +15,9 @@ const Inner = styled(SectionInner)(({ theme }) => ({ flexDirection: "column", alignItems: "center", gap: "40px", - padding: "40px 24px 96px", + padding: "40px 0 96px", [theme.breakpoints.down("md")]: { - padding: "16px 24px 32px", + padding: "16px 0 32px", }, })) diff --git a/frontends/main/src/app-pages/OrganizationalLearningPage/FaqSection.tsx b/frontends/main/src/app-pages/OrganizationalLearningPage/FaqSection.tsx index 75882fcbc4..bd80485a72 100644 --- a/frontends/main/src/app-pages/OrganizationalLearningPage/FaqSection.tsx +++ b/frontends/main/src/app-pages/OrganizationalLearningPage/FaqSection.tsx @@ -89,7 +89,11 @@ const FaqRow: React.FC<{ index: number; question: string; answer: string }> = ({ const panelId = `faq-panel-${index}` return ( - setExpanded(!expanded)}> + setExpanded(!expanded)} + > ({ const Inner = styled(SectionInner)(({ theme }) => ({ display: "flex", alignItems: "center", - gap: "48px", + gap: "100px", [theme.breakpoints.down("lg")]: { flexDirection: "column", alignItems: "stretch", + gap: "48px", }, })) const Copy = styled.div(({ theme }) => ({ display: "flex", flexDirection: "column", - gap: "32px", + gap: "40px", flex: "0 0 608px", [theme.breakpoints.down("lg")]: { flex: "initial", @@ -64,13 +65,14 @@ const Tagline = styled.p(({ theme }) => ({ ...theme.typography.h3, color: theme.custom.colors.darkGray2, margin: 0, + textWrap: "balance", [theme.breakpoints.down("md")]: { ...theme.typography.h4, }, })) const Body = styled.p(({ theme }) => ({ - ...theme.typography.body1, + ...theme.typography.body1Loose, color: theme.custom.colors.silverGrayDark, margin: 0, })) @@ -110,6 +112,7 @@ const Highlight = styled.li(({ theme }) => ({ const Actions = styled.div(({ theme }) => ({ display: "flex", + marginTop: "24px", [theme.breakpoints.down("sm")]: { button: { width: "100%" }, }, @@ -131,6 +134,7 @@ const Curriculum = styled.div(({ theme }) => ({ const CurriculumEyebrow = styled.p(({ theme }) => ({ ...theme.typography.subtitle3, + fontWeight: theme.typography.fontWeightRegular, color: theme.custom.colors.silverGrayLight, margin: 0, })) diff --git a/frontends/main/src/app-pages/OrganizationalLearningPage/GetInTouchSection.tsx b/frontends/main/src/app-pages/OrganizationalLearningPage/GetInTouchSection.tsx index 385efc1264..9165e6dc7b 100644 --- a/frontends/main/src/app-pages/OrganizationalLearningPage/GetInTouchSection.tsx +++ b/frontends/main/src/app-pages/OrganizationalLearningPage/GetInTouchSection.tsx @@ -19,14 +19,14 @@ const Inner = styled(SectionInner)(({ theme }) => ({ display: "flex", alignItems: "center", gap: "48px", - padding: "120px 24px", + padding: "120px 0", [theme.breakpoints.down("lg")]: { alignItems: "stretch", flexDirection: "column", - padding: "48px 24px", + padding: "48px 0", }, [theme.breakpoints.down("md")]: { - padding: "32px 24px", + padding: "32px 0", gap: "32px", }, })) diff --git a/frontends/main/src/app-pages/OrganizationalLearningPage/HeroSection.tsx b/frontends/main/src/app-pages/OrganizationalLearningPage/HeroSection.tsx index 36f55c4d2c..1e488c4fd2 100644 --- a/frontends/main/src/app-pages/OrganizationalLearningPage/HeroSection.tsx +++ b/frontends/main/src/app-pages/OrganizationalLearningPage/HeroSection.tsx @@ -11,7 +11,7 @@ const FIGURE_RADIUS = "8px 80px 8px 50px" const OUTLINE_RADIUS = "8px 50px 8px 50px" const HeroBand = styled(Section)(({ theme }) => ({ - backgroundColor: theme.custom.colors.lightGray1, + backgroundColor: theme.custom.colors.backgroundGray, backgroundImage: "url('/images/organizational_learning/hero-illustration.svg')", backgroundSize: "210% auto", @@ -26,15 +26,15 @@ const Inner = styled(SectionInner)(({ theme }) => ({ display: "flex", alignItems: "center", gap: "100px", - padding: "144px 24px", + padding: "144px 0", [theme.breakpoints.down("lg")]: { flexDirection: "column", alignItems: "stretch", gap: "48px", - padding: "64px 24px", + padding: "64px 0", }, [theme.breakpoints.down("md")]: { - padding: "32px 24px", + padding: "32px 0", }, })) @@ -63,15 +63,18 @@ const Title = styled.h1(({ theme }) => ({ })) const Emphasis = styled.span(({ theme }) => ({ - borderBottom: `4px solid ${theme.custom.colors.brightRed}`, - paddingBottom: "2px", + textDecorationLine: "underline", + textDecorationColor: theme.custom.colors.brightRed, + textDecorationThickness: "4px", + textUnderlineOffset: "0", + textDecorationSkipInk: "none", [theme.breakpoints.down("md")]: { - borderBottomWidth: "3px", + textDecorationThickness: "3px", }, })) const Body = styled.p(({ theme }) => ({ - ...theme.typography.body1, + ...theme.typography.body1Loose, color: theme.custom.colors.darkGray2, margin: 0, })) @@ -126,6 +129,7 @@ const Figure = styled.div(({ theme }) => ({ inset: "-21px 19px 21px -21px", border: `1px solid ${theme.custom.colors.red}`, borderRadius: OUTLINE_RADIUS, + zIndex: 1, }, [theme.breakpoints.down("lg")]: { minHeight: "320px", @@ -141,50 +145,46 @@ const HeroImage = styled(Image)({ borderRadius: FIGURE_RADIUS, }) -const HeroSection: React.FC = () => { - const lastSpace = copy.title.lastIndexOf(" ") - const titleStart = copy.title.slice(0, lastSpace) - const titleEnd = copy.title.slice(lastSpace + 1) - - return ( - - - - - {titleStart} <Emphasis>{titleEnd}</Emphasis> - - {copy.body} - - {copy.stats.map((stat) => ( - - - {stat.label.map((line, index) => ( - - {index > 0 ?
: null} - {line} -
- ))} -
- {stat.value} -
- ))} -
- - {copy.ctaLabel} - -
-
- -
-
-
- ) -} +const HeroSection: React.FC = () => ( + + + + + {copy.title.start} + <Emphasis>{copy.title.emphasis}</Emphasis> + {copy.title.end} + + {copy.body} + + {copy.stats.map((stat) => ( + + + {stat.label.map((line, index) => ( + + {index > 0 ?
: null} + {line} +
+ ))} +
+ {stat.value} +
+ ))} +
+ + {copy.ctaLabel} + +
+
+ +
+
+
+) export default HeroSection diff --git a/frontends/main/src/app-pages/OrganizationalLearningPage/OfferingsSection.tsx b/frontends/main/src/app-pages/OrganizationalLearningPage/OfferingsSection.tsx index 1c6809a202..2521c2141d 100644 --- a/frontends/main/src/app-pages/OrganizationalLearningPage/OfferingsSection.tsx +++ b/frontends/main/src/app-pages/OrganizationalLearningPage/OfferingsSection.tsx @@ -106,6 +106,9 @@ const CardTitle = styled.h3(({ theme }) => ({ ...theme.typography.h4, color: theme.custom.colors.darkGray2, margin: 0, + [theme.breakpoints.up("md")]: { + minHeight: `calc(${theme.typography.h4.lineHeight} * 2)`, + }, })) const CardTagline = styled.p(({ theme }) => ({ diff --git a/frontends/main/src/app-pages/OrganizationalLearningPage/OrgLeadForm.tsx b/frontends/main/src/app-pages/OrganizationalLearningPage/OrgLeadForm.tsx index 69054ce403..f2381dca77 100644 --- a/frontends/main/src/app-pages/OrganizationalLearningPage/OrgLeadForm.tsx +++ b/frontends/main/src/app-pages/OrganizationalLearningPage/OrgLeadForm.tsx @@ -1,7 +1,13 @@ "use client" import React from "react" -import { HubspotForm, Link, styled, type HubspotFormValue } from "ol-components" +import { + HubspotForm, + Link, + RECAPTCHA_MARGIN_VAR, + styled, + type HubspotFormValue, +} from "ol-components" import { Button, ButtonLink, @@ -121,6 +127,13 @@ const StyledHubspotForm = styled(HubspotForm)(({ theme }) => ({ minWidth: 0, }, }, + /** + * ReCaptcha's own vertical margin stacks on top of this form's 24px gap and + * makes the space around it uneven with every other gap in the form. + * Cancel it via its margin custom property so the form's own gap is the + * only spacing mechanism. + */ + [RECAPTCHA_MARGIN_VAR]: 0, [theme.breakpoints.down("md")]: { fieldset: { flexDirection: "column", diff --git a/frontends/main/src/app-pages/OrganizationalLearningPage/OrganizationalLearningPage.test.tsx b/frontends/main/src/app-pages/OrganizationalLearningPage/OrganizationalLearningPage.test.tsx index cbe9d2f2aa..e38e92081b 100644 --- a/frontends/main/src/app-pages/OrganizationalLearningPage/OrganizationalLearningPage.test.tsx +++ b/frontends/main/src/app-pages/OrganizationalLearningPage/OrganizationalLearningPage.test.tsx @@ -23,6 +23,8 @@ jest.mock("ol-utilities", () => ({ const mockScrollToElement = jest.mocked(scrollToElement) +const heroTitleText = `${hero.title.start}${hero.title.emphasis}${hero.title.end}` + const setupApis = () => { setMockResponse.get(urls.userMe.get(), {}) } @@ -39,7 +41,7 @@ describe("OrganizationalLearningPage", () => { // Asserted by heading rather than by copy string so a marketing rewrite // does not break the test — what matters is that no section went missing. const sectionHeadings = [ - hero.title, + heroTitleText, featuredProgram.title, offerings.title, deliveryFormats.title, @@ -73,7 +75,7 @@ describe("OrganizationalLearningPage", () => { renderWithProviders() assertHeadings([ - { level: 1, name: hero.title }, + { level: 1, name: heroTitleText }, { level: 2, name: featuredProgram.title }, { level: 2, name: offerings.title }, ...offerings.cards.map((card) => ({ level: 3, name: card.title })), diff --git a/frontends/main/src/app-pages/OrganizationalLearningPage/SectionLayout.tsx b/frontends/main/src/app-pages/OrganizationalLearningPage/SectionLayout.tsx index 05771b051b..bb1c9b4e11 100644 --- a/frontends/main/src/app-pages/OrganizationalLearningPage/SectionLayout.tsx +++ b/frontends/main/src/app-pages/OrganizationalLearningPage/SectionLayout.tsx @@ -19,14 +19,15 @@ export const Section = styled.section({ display: "flex", justifyContent: "center", width: "100%", + padding: "0 24px", }) export const SectionInner = styled.div(({ theme }) => ({ width: "100%", maxWidth: "1276px", - padding: "96px 24px", + padding: "96px 0", [theme.breakpoints.down("md")]: { - padding: "32px 24px", + padding: "32px 0", }, })) diff --git a/frontends/main/src/app-pages/OrganizationalLearningPage/copy.ts b/frontends/main/src/app-pages/OrganizationalLearningPage/copy.ts index 53b5cfd803..509ffb8009 100644 --- a/frontends/main/src/app-pages/OrganizationalLearningPage/copy.ts +++ b/frontends/main/src/app-pages/OrganizationalLearningPage/copy.ts @@ -6,6 +6,7 @@ * it, rather than an edit spread across eight section components. */ +export type EmphasizedTitle = { start: string; emphasis: string; end: string } export type StatItem = { value: string; label: string[] } export type IconBoxItem = { title: string; body: string } export type OfferingCardItem = { @@ -52,18 +53,21 @@ export type CaseStudyItem = { } export const hero = { - title: - "Transform your organization with scalable education from MIT Open Learning", + title: { + start: "Transform your organization with ", + emphasis: "scalable education", + end: " from MIT Open Learning", + } satisfies EmphasizedTitle, body: "We work with businesses, schools, and government agencies to educate diverse workforces on the most impactful technologies of the moment. Connect with our team to learn how our portfolio of MIT Open Learning courses and programs can be matched to your organization’s training goals.", ctaLabel: "Talk with our team", image: "/images/uai_landing/uai-landing-hero-2.jpg", imageAlt: "", stats: [ - { value: "700+", label: ["Corporate", "and University Clients"] }, - { value: "150+", label: ["MIT Faculty", "& Expert Contributors"] }, + { value: "700+", label: ["Corporate", "& university clients"] }, + { value: "150+", label: ["MIT faculty", "& expert contributors"] }, { value: "500+", - label: ["Unique courses and programs"], + label: ["Unique courses & programs"], }, ] satisfies StatItem[], } @@ -77,7 +81,7 @@ export const featuredProgram = { "Self-paced program", "MIT faculty & experts", "Stackable modules", - "AI Tutor", + "AI tutor", "Translation available", ], curriculum: { @@ -96,7 +100,7 @@ export const featuredProgram = { title: "EXAMPLE INDUSTRY APPLICATION MODULES", modules: [ "AI + Healthcare", - "AI + Sustainability and Energy", + "AI + Sustainability & Energy", "AI + Finance", ], }, @@ -107,7 +111,7 @@ export const featuredProgram = { export const offerings = { eyebrow: "FOR YOUR ORGANIZATION", - title: "Learning Solutions for Every Role and Responsibility", + title: "Learning solutions for every role and responsibility", body: "Flexible learning options designed around your goals, whether you're adopting AI, strengthening technical capabilities, or building leadership skills.", cards: [ { @@ -155,15 +159,15 @@ export const deliveryFormats = { "Our team will learn about your priorities and needs to find the right combination of learning modalities.", items: [ { - title: "Courses & Long Programs", + title: "Courses & long programs", body: "Long-form digital instruction designed to build foundational or advanced-level understanding over multiple weeks or months.", }, { - title: "Short Programs & Microcredentials", + title: "Short programs & microcredentials", body: "Shorter, digital introductions designed to elevate learners’ conversational abilities on complex topics in a matter of hours.", }, { - title: "Workshops & Webinars", + title: "Workshops & webinars", body: "In-person or virtual programs on technical and leadership topics, tailored to your team’s needs, delivered live by MIT faculty and experts.", }, { @@ -197,7 +201,7 @@ export const continuum = { }, { eyebrow: "03 . SPECIALIZATION", - body: "Employ expertise for impact at-scale.", + body: "Employ expertise for impact at scale.", }, ] satisfies ContinuumStepItem[], } diff --git a/frontends/ol-components/package.json b/frontends/ol-components/package.json index 588b8d1295..e8fcc9e7a8 100644 --- a/frontends/ol-components/package.json +++ b/frontends/ol-components/package.json @@ -67,7 +67,7 @@ "typescript": "^5.5.4" }, "peerDependencies": { - "@mitodl/smoot-design": "6.36.0", + "@mitodl/smoot-design": "6.38.0", "next": "^16.2.7" } } diff --git a/frontends/ol-components/src/components/ReCaptcha/ReCaptcha.tsx b/frontends/ol-components/src/components/ReCaptcha/ReCaptcha.tsx index 05e4a7ddfc..74c49ba656 100644 --- a/frontends/ol-components/src/components/ReCaptcha/ReCaptcha.tsx +++ b/frontends/ol-components/src/components/ReCaptcha/ReCaptcha.tsx @@ -2,10 +2,18 @@ import React from "react" import GoogleReCAPTCHA from "react-google-recaptcha" import styled from "@emotion/styled" +const RECAPTCHA_MARGIN_VAR = "--recaptcha-margin" + const ReCaptchaContainer = styled.div({ display: "flex", justifyContent: "flex-start", - margin: "12px 0", + /** + * Reads from a CSS custom property so a consumer whose own layout already + * provides spacing (e.g. a flex form with a `gap`) can zero this out for + * just its subtree without needing a selector into this component's + * internals: `[RECAPTCHA_MARGIN_VAR]: 0` on any ancestor. + */ + margin: `var(${RECAPTCHA_MARGIN_VAR}, 12px 0)`, "& .g_id_signIn": { maxWidth: "100%", }, @@ -61,5 +69,5 @@ const ReCaptcha = ({ ) } -export { ReCaptcha } +export { ReCaptcha, RECAPTCHA_MARGIN_VAR } export type { ReCaptchaProps } diff --git a/frontends/ol-components/src/components/ReCaptcha/index.ts b/frontends/ol-components/src/components/ReCaptcha/index.ts index 966199d698..7df56bc967 100644 --- a/frontends/ol-components/src/components/ReCaptcha/index.ts +++ b/frontends/ol-components/src/components/ReCaptcha/index.ts @@ -1,2 +1,2 @@ -export { ReCaptcha } from "./ReCaptcha" +export { ReCaptcha, RECAPTCHA_MARGIN_VAR } from "./ReCaptcha" export type { ReCaptchaProps } from "./ReCaptcha" diff --git a/yarn.lock b/yarn.lock index cd179936a1..e4c62c5dec 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3641,9 +3641,9 @@ __metadata: languageName: node linkType: hard -"@mitodl/smoot-design@npm:6.36.0": - version: 6.36.0 - resolution: "@mitodl/smoot-design@npm:6.36.0" +"@mitodl/smoot-design@npm:6.38.0": + version: 6.38.0 + resolution: "@mitodl/smoot-design@npm:6.38.0" dependencies: "@ai-sdk/react": "npm:1.2.12" "@emotion/cache": "npm:^11.14.0" @@ -3668,7 +3668,7 @@ __metadata: "@remixicon/react": ^4.2.0 react: ^18 || ^19 react-dom: ^18 || ^19 - checksum: 10/515bff3758b289e9f72064608a65c2914f557f034b02b3fc289b811f25bd62972ac8c50a2b62d6a3f54ba41273baebd3eff9a28db173225d375410a4850b12ea + checksum: 10/506da4b1e7b9d84a8e5911b1399ba47edfcd28f56e54ccaf59960d8cbaf24410995ed3a41a69ec43b62e49f80dd80d786fe07e47e4bae3b84237e789502fecc4 languageName: node linkType: hard @@ -16919,7 +16919,7 @@ __metadata: "@mitodl/course-search-utils": "npm:^3.8.1" "@mitodl/hacksnack": "npm:^0.1.2" "@mitodl/mitxonline-api-axios": "npm:2026.9.21" - "@mitodl/smoot-design": "npm:6.36.0" + "@mitodl/smoot-design": "npm:6.38.0" "@mui/base": "npm:5.0.0-beta.70" "@mui/material": "npm:^6.4.5" "@mui/material-nextjs": "npm:^6.4.3" @@ -18572,7 +18572,7 @@ __metadata: typescript: "npm:^5.5.4" wheel-indicator: "npm:^1.3.0" peerDependencies: - "@mitodl/smoot-design": 6.36.0 + "@mitodl/smoot-design": 6.38.0 next: ^16.2.7 languageName: unknown linkType: soft From 848040ddad764e8eadd63d0aabafdf1274335786 Mon Sep 17 00:00:00 2001 From: Danielle Frappier Date: Thu, 24 Sep 2026 10:06:18 -0400 Subject: [PATCH 4/7] feat: add B2B contract learner directory page (#3958) --- frontends/api/src/analytics/clients.ts | 24 + .../analytics/hooks/organizations/index.ts | 6 + .../analytics/hooks/organizations/queries.ts | 42 +- .../api/src/analytics/test-utils/factories.ts | 66 ++ .../api/src/analytics/test-utils/urls.ts | 11 +- frontends/api/src/analytics/types.ts | 86 ++ frontends/api/src/test-utils/mockAxios.ts | 4 + .../ContractLearnersPage.test.tsx | 681 +++++++++++++ .../ContractLearnersPage.tsx | 919 ++++++++++++++++++ .../ContractLearnersPage/LearnerRow.tsx | 277 ++++++ .../ContractLearnersPage/ProgressBar.tsx | 41 + .../app-pages/ContractLearnersPage/columns.ts | 13 + .../ContractLearnersPage/placeholders.ts | 140 +++ .../statusDisplay.test.ts | 68 ++ .../ContractLearnersPage/statusDisplay.ts | 59 ++ .../DashboardPage/AnalyticsContent.test.tsx | 44 +- .../DashboardPage/AnalyticsContent.tsx | 59 +- .../DashboardPage/ContractContent.tsx | 39 +- .../[contractSlug]/learners/layout.tsx | 25 + .../contract/[contractSlug]/learners/page.tsx | 17 + frontends/main/src/common/errors.ts | 14 +- frontends/main/src/common/urls.ts | 11 + .../main/src/components/B2BTable/B2BTable.tsx | 35 + frontends/ol-components/src/index.ts | 9 + 24 files changed, 2668 insertions(+), 22 deletions(-) create mode 100644 frontends/main/src/app-pages/ContractLearnersPage/ContractLearnersPage.test.tsx create mode 100644 frontends/main/src/app-pages/ContractLearnersPage/ContractLearnersPage.tsx create mode 100644 frontends/main/src/app-pages/ContractLearnersPage/LearnerRow.tsx create mode 100644 frontends/main/src/app-pages/ContractLearnersPage/ProgressBar.tsx create mode 100644 frontends/main/src/app-pages/ContractLearnersPage/columns.ts create mode 100644 frontends/main/src/app-pages/ContractLearnersPage/placeholders.ts create mode 100644 frontends/main/src/app-pages/ContractLearnersPage/statusDisplay.test.ts create mode 100644 frontends/main/src/app-pages/ContractLearnersPage/statusDisplay.ts create mode 100644 frontends/main/src/app/(site)/organization/[orgSlug]/contract/[contractSlug]/learners/layout.tsx create mode 100644 frontends/main/src/app/(site)/organization/[orgSlug]/contract/[contractSlug]/learners/page.tsx diff --git a/frontends/api/src/analytics/clients.ts b/frontends/api/src/analytics/clients.ts index 03c7a17f35..9e03665855 100644 --- a/frontends/api/src/analytics/clients.ts +++ b/frontends/api/src/analytics/clients.ts @@ -7,6 +7,8 @@ import type { ContractMonthlyEngagementTrend, ContractUtilization, EnrollmentCompletionFunnel, + LearnerProgressParams, + LearnerProgressResponse, MonthlyEngagementTrend, OrgAnalyticsResponse, } from "./types" @@ -184,6 +186,28 @@ const analyticsContractsApi = { page, signal, ), + + /** + * Individual learners rather than a rollup, so it has its own envelope + * (`outcomes_withheld_count`) and its own filter set — hence a hand-rolled + * call instead of `getContractResource`. + * + * `indexes: null` makes axios repeat a bare key per array element + * (`completion_status=passed&completion_status=certified`) instead of its + * default `completion_status[]=`. FastAPI's `Query(list[...])` reads the + * former, and it is what `queryify` produces in test-utils/urls.ts, so the + * client and the test URL builders stay in lockstep. + */ + learnerProgress: ( + organizationId: string, + contractId: string, + params?: LearnerProgressParams, + signal?: AbortSignal, + ): Promise> => + axiosInstance.get( + `${contractRoot(organizationId, contractId)}/learner-progress`, + { params, signal, paramsSerializer: { indexes: null } }, + ), } export { analyticsOrganizationsApi, analyticsContractsApi, B2B_DASHBOARD_ROOT } diff --git a/frontends/api/src/analytics/hooks/organizations/index.ts b/frontends/api/src/analytics/hooks/organizations/index.ts index f4730e1a3e..2be83287d6 100644 --- a/frontends/api/src/analytics/hooks/organizations/index.ts +++ b/frontends/api/src/analytics/hooks/organizations/index.ts @@ -7,11 +7,17 @@ export { export type { AnalyticsPageParams, + CompletionStatus, + CompletionStatusFilter, ContentEngagementDepth, ContractContentEngagementDepth, ContractMonthlyEngagementTrend, ContractUtilization, EnrollmentCompletionFunnel, + LearnerProgress, + LearnerProgressParams, + LearnerProgressResponse, + LearnerProgressSort, MonthlyEngagementTrend, OrgAnalyticsResponse, } from "../../types" diff --git a/frontends/api/src/analytics/hooks/organizations/queries.ts b/frontends/api/src/analytics/hooks/organizations/queries.ts index b09610c138..94e41f7ab9 100644 --- a/frontends/api/src/analytics/hooks/organizations/queries.ts +++ b/frontends/api/src/analytics/hooks/organizations/queries.ts @@ -1,6 +1,6 @@ import { queryOptions } from "@tanstack/react-query" import { analyticsContractsApi, analyticsOrganizationsApi } from "../../clients" -import type { AnalyticsPageParams } from "../../types" +import type { AnalyticsPageParams, LearnerProgressParams } from "../../types" /** * `orgId` in every key is the Keycloak organization UUID — see @@ -24,6 +24,9 @@ const analyticsOrganizationKeys = { */ const ANALYTICS_STALE_TIME = 5 * 60 * 1000 +/** See `analyticsContractQueries.learnerProgress`. */ +const LEARNER_PROGRESS_STALE_TIME = 60 * 1000 + const analyticsOrganizationQueries = { contractUtilization: (orgId: string, page?: AnalyticsPageParams) => queryOptions({ @@ -107,6 +110,20 @@ const analyticsContractKeys = { resource, page, ] as const, + /** + * Its own builder rather than widening `resource`, whose `page` is typed as + * `AnalyticsPageParams` and is relied on by the four sections above. + */ + learnerProgress: ( + orgId: string, + contractId: string, + params?: LearnerProgressParams, + ) => + [ + ...analyticsContractKeys.contract(orgId, contractId), + "learner-progress", + params, + ] as const, } const analyticsContractQueries = { @@ -185,6 +202,29 @@ const analyticsContractQueries = { .contentEngagement(orgId, contractId, page, signal) .then((res) => res.data), }), + + /** + * Shorter-lived than the sections above: this is the enrollment and consent + * state a manager acts on directly, not an hours-cadence rollup, so a stale + * page here is more costly than the extra query. + */ + learnerProgress: ( + orgId: string, + contractId: string, + params?: LearnerProgressParams, + ) => + queryOptions({ + queryKey: analyticsContractKeys.learnerProgress( + orgId, + contractId, + params, + ), + staleTime: LEARNER_PROGRESS_STALE_TIME, + queryFn: async ({ signal }) => + analyticsContractsApi + .learnerProgress(orgId, contractId, params, signal) + .then((res) => res.data), + }), } export { diff --git a/frontends/api/src/analytics/test-utils/factories.ts b/frontends/api/src/analytics/test-utils/factories.ts index 847dcad43f..8e95cdd280 100644 --- a/frontends/api/src/analytics/test-utils/factories.ts +++ b/frontends/api/src/analytics/test-utils/factories.ts @@ -5,6 +5,8 @@ import type { ContractMonthlyEngagementTrend, ContractUtilization, EnrollmentCompletionFunnel, + LearnerProgress, + LearnerProgressResponse, MonthlyEngagementTrend, OrgAnalyticsResponse, } from "../types" @@ -157,6 +159,67 @@ const contractContentEngagementDepth = ( ...overrides, }) +/** + * Defaults to a learner who HAS consented, so a test that cares about consent + * opts in with `outcomesShared: false` rather than every other test opting out. + * `last_active_on` defaults to null because the API hardcodes it so today. + */ +const learnerProgress = ( + overrides: Partial = {}, +): LearnerProgress => ({ + learner_id: faker.string.uuid(), + email: faker.internet.email(), + full_name: faker.person.fullName(), + courserun_readable_id: `course-v1:MITxT+${faker.string.alphanumeric(6)}+2T2026`, + courserun_title: faker.company.catchPhrase(), + courserun_start_on: "2026-02-01T00:00:00Z", + courserun_end_on: "2026-08-01T00:00:00Z", + enrolled_on: "2026-02-15T00:00:00Z", + enrollment_is_active: true, + enrollment_mode: "verified", + outcomes_shared: true, + completion_status: "in_progress", + is_passing: false, + grade: 0.42, + letter_grade: null, + certificate_issued_on: null, + certificate_is_revoked: null, + last_active_on: null, + ...overrides, +}) + +/** + * A learner who has not consented. The API nulls every outcome field server + * side, so this factory does too — a fixture that left them populated would + * let a component pass its test while rendering data the API never sends. + */ +const withheldLearnerProgress = ( + overrides: Partial = {}, +): LearnerProgress => + learnerProgress({ + outcomes_shared: false, + completion_status: null, + is_passing: null, + grade: null, + letter_grade: null, + certificate_issued_on: null, + certificate_is_revoked: null, + last_active_on: null, + ...overrides, + }) + +const learnerProgressEnvelope = ( + data: LearnerProgress[], + overrides: Partial = {}, +): LearnerProgressResponse => ({ + organization_id: organizationId(), + as_of: "2026-07-01T04:00:00Z", + total_count: data.length, + outcomes_withheld_count: data.filter((row) => !row.outcomes_shared).length, + data, + ...overrides, +}) + export { contentEngagementDepth, contractContentEngagementDepth, @@ -164,6 +227,9 @@ export { contractUtilization, enrollmentCompletionFunnel, envelope, + learnerProgress, + learnerProgressEnvelope, monthlyEngagementTrend, organizationId, + withheldLearnerProgress, } diff --git a/frontends/api/src/analytics/test-utils/urls.ts b/frontends/api/src/analytics/test-utils/urls.ts index b97d1dcb97..8a68fb1813 100644 --- a/frontends/api/src/analytics/test-utils/urls.ts +++ b/frontends/api/src/analytics/test-utils/urls.ts @@ -1,7 +1,7 @@ import { queryify } from "ol-test-utilities" import analyticsAxios from "../axios" import { B2B_DASHBOARD_ROOT } from "../clients" -import type { AnalyticsPageParams } from "../types" +import type { AnalyticsPageParams, LearnerProgressParams } from "../types" // Absolute, and read back from the configured axios instance, so the shared // request mock can tell analytics requests apart from Learn and MITx ones by @@ -22,7 +22,7 @@ const contractResource = ( organizationId: string, contractId: string, resource: string, - params?: AnalyticsPageParams, + params?: AnalyticsPageParams | LearnerProgressParams, ) => `${getApiBaseUrl()}${B2B_DASHBOARD_ROOT}/organizations/${encodeURIComponent( organizationId, @@ -68,6 +68,13 @@ const contracts = { params?: AnalyticsPageParams, ) => contractResource(organizationId, contractId, "content-engagement", params), + // queryify explodes arrays into repeated bare keys, matching the client's + // `indexes: null` serializer. + learnerProgress: ( + organizationId: string, + contractId: string, + params?: LearnerProgressParams, + ) => contractResource(organizationId, contractId, "learner-progress", params), } export { organizations, contracts } diff --git a/frontends/api/src/analytics/types.ts b/frontends/api/src/analytics/types.ts index ef808da428..e45f8e3927 100644 --- a/frontends/api/src/analytics/types.ts +++ b/frontends/api/src/analytics/types.ts @@ -173,3 +173,89 @@ export type AnalyticsPageParams = { limit?: number offset?: number } + +/** + * `mv_b2b_learner_enrollment` — grain: learner x course run, under one + * contract. The only endpoint in this tenant that returns individual learners, + * so unlike every type above it carries no k-anonymity floor. + * + * # Why the outcome fields are nullable + * + * Outcomes are gated on per-learner consent, not on a suppression floor. When + * `outcomes_shared` is false the API nulls `completion_status`, `is_passing`, + * `grade`, `letter_grade`, `certificate_issued_on`, `certificate_is_revoked` + * and `last_active_on` — a `null` there means "the learner has not agreed to + * share this", which is a different thing from zero, from absent, and from the + * k-anonymity suppression the aggregate types above use. Render it as such. + * + * `learner_id` is the Keycloak user id (MITx Online's `global_id`). It is + * stable across email changes, so join on it and never on `email`. + */ +export type LearnerProgress = { + learner_id: string + email: string | null + full_name: string | null + courserun_readable_id: string + courserun_title: string + courserun_start_on: string | null + courserun_end_on: string | null + enrolled_on: string + enrollment_is_active: boolean + enrollment_mode: string | null + outcomes_shared: boolean + completion_status: CompletionStatus | null + is_passing: boolean | null + grade: number | null + letter_grade: string | null + certificate_issued_on: string | null + certificate_is_revoked: boolean | null + last_active_on: string | null +} + +/** + * `passed` without `certified` is normal rather than an error state: + * certificates are issued on a schedule after grading, and audit-mode + * enrollments never certify at all. + */ +export type CompletionStatus = + | "not_started" + | "in_progress" + | "passed" + | "certified" + +/** + * Accepted by the `completion_status` filter, which additionally takes + * `unknown` to select the rows whose outcomes are withheld. Not a value any + * row's `completion_status` can hold. + */ +export type CompletionStatusFilter = CompletionStatus | "unknown" + +export type LearnerProgressSort = + | "full_name" + | "email" + | "enrolled_on" + | "courserun_readable_id" + +export type LearnerProgressParams = AnalyticsPageParams & { + search?: string + completion_status?: CompletionStatusFilter[] + include_inactive?: boolean + sort?: LearnerProgressSort + descending?: boolean + // Disabled: courserun_readable_id?: string — silently dropped by the + // real API. See ContractLearnersPage.tsx's file header (module filter). +} + +/** + * The org envelope plus `outcomes_withheld_count`, so a client can say how many + * rows carry withheld outcomes without paging through all of them. Deliberately + * not `OrgAnalyticsResponse`: that extra field is specific to + * this endpoint's consent semantics and does not belong on every section. + */ +export type LearnerProgressResponse = { + organization_id: string + as_of: string | null + total_count: number + outcomes_withheld_count: number + data: LearnerProgress[] +} diff --git a/frontends/api/src/test-utils/mockAxios.ts b/frontends/api/src/test-utils/mockAxios.ts index e39c0fb7f4..0bcb86334f 100644 --- a/frontends/api/src/test-utils/mockAxios.ts +++ b/frontends/api/src/test-utils/mockAxios.ts @@ -78,6 +78,10 @@ const mockAdapter: AxiosAdapter = async (config) => { baseURL: config.baseURL, url: config.url, params: config.params, + // Forwarded so a client that customizes array serialization is mocked at + // the URL it really requests. Without it every array param would resolve + // here as axios's default `key[]=a&key[]=b`, whatever the client sent. + paramsSerializer: config.paramsSerializer, }) const method = (config.method ?? "get").toLowerCase() as Method // OpenAPI Generator pre-serializes request bodies; deserialize so tests can diff --git a/frontends/main/src/app-pages/ContractLearnersPage/ContractLearnersPage.test.tsx b/frontends/main/src/app-pages/ContractLearnersPage/ContractLearnersPage.test.tsx new file mode 100644 index 0000000000..8e8e561c96 --- /dev/null +++ b/frontends/main/src/app-pages/ContractLearnersPage/ContractLearnersPage.test.tsx @@ -0,0 +1,681 @@ +import React from "react" +import { renderWithProviders, screen, user, within } from "@/test-utils" +import { waitFor } from "@testing-library/react" +import { setMockResponse } from "api/test-utils" +import { + factories as mitxFactories, + urls as mitxUrls, +} from "api/mitxonline-test-utils" +import { + factories as analyticsFactories, + urls as analyticsUrls, +} from "api/analytics-test-utils" +import { useFeatureFlagEnabled } from "posthog-js/react" +import { allowConsoleErrors } from "ol-test-utilities" +import { ForbiddenError } from "@/common/errors" +import { useFeatureFlagsLoaded } from "@/common/useFeatureFlagsLoaded" +import ContractLearnersPage from "./ContractLearnersPage" + +jest.mock("posthog-js/react", () => ({ + ...jest.requireActual("posthog-js/react"), + useFeatureFlagEnabled: jest.fn(), +})) +jest.mock("@/common/useFeatureFlagsLoaded") +const mockedUseFeatureFlagsLoaded = jest.mocked(useFeatureFlagsLoaded) +const mockedUseFeatureFlagEnabled = jest.mocked(useFeatureFlagEnabled) + +const ORG_UUID = "11111111-2222-3333-4444-555555555555" +const PAGE_SIZE = 25 + +/** managerOrganizationsList reads `res.data.results`, so a bare array is not enough. */ +const paginate = (orgs: unknown[]) => ({ + count: orgs.length, + next: null, + previous: null, + results: orgs, +}) + +/** `closest` returns Element; testing-library's `within` wants an HTMLElement. */ +const rowOf = (el: HTMLElement): HTMLElement => + el.closest('[role="row"]')! + +const setup = () => { + const contract = mitxFactories.contracts.contract() + const org = mitxFactories.organizations.organization({ + contracts: [contract], + sso_organization_id: ORG_UUID, + }) + return { org, contract, orgSlug: org.slug.replace(/^org-/, "") } +} + +/** + * The page fires one list query plus one unfiltered total-count query, used + * for the "X of Y enrollments" summary text. Mocking by exact URL keeps the + * assertion pinned to the request it is about. + */ +const mockTotal = (contractId: string, total: number) => { + setMockResponse.get( + analyticsUrls.contracts.learnerProgress(ORG_UUID, contractId, { + limit: 1, + }), + analyticsFactories.learnerProgressEnvelope([], { total_count: total }), + ) +} + +const mockList = ( + contractId: string, + rows: ReturnType[], + extraParams: Record = {}, + envelopeOverrides: Record = {}, +) => { + setMockResponse.get( + analyticsUrls.contracts.learnerProgress(ORG_UUID, contractId, { + limit: PAGE_SIZE, + offset: 0, + sort: "full_name", + ...extraParams, + }), + analyticsFactories.learnerProgressEnvelope(rows, envelopeOverrides), + ) +} + +const mockFunnel = (contractId: string) => { + setMockResponse.get( + analyticsUrls.contracts.enrollmentFunnel(ORG_UUID, contractId, { + limit: 1000, + }), + analyticsFactories.envelope([ + analyticsFactories.enrollmentCompletionFunnel({ + courserun_readable_id: "course-v1:MITx+M5+2026", + courserun_title: "Module 5", + }), + analyticsFactories.enrollmentCompletionFunnel({ + courserun_readable_id: "course-v1:MITx+M6+2026", + courserun_title: "Module 6", + }), + ]), + ) +} + +describe("ContractLearnersPage", () => { + beforeEach(() => { + mockedUseFeatureFlagsLoaded.mockReturnValue(true) + mockedUseFeatureFlagEnabled.mockReturnValue(true) + setMockResponse.get( + mitxUrls.userMe.get(), + mitxFactories.user.user({ email: "manager@test.com" }), + ) + }) + + test("throws ForbiddenError when the analytics flag is off", () => { + mockedUseFeatureFlagEnabled.mockReturnValue(false) + allowConsoleErrors() + + expect(() => + renderWithProviders( + , + ), + ).toThrow(ForbiddenError) + }) + + test("denies access when the org is not one the user manages", async () => { + setMockResponse.get( + mitxUrls.organization.managerOrganizationsList(), + paginate([]), + ) + + renderWithProviders( + , + ) + + await screen.findByText("Access denied") + }) + + test("404s when the contract slug does not resolve", async () => { + const { org, orgSlug } = setup() + setMockResponse.get( + mitxUrls.organization.managerOrganizationsList(), + paginate([org]), + ) + + renderWithProviders( + , + ) + + await screen.findByText("Contract not found") + }) + + test("disables Export and skips the request when the org has no analytics org ID", async () => { + const contract = mitxFactories.contracts.contract() + const org = mitxFactories.organizations.organization({ + contracts: [contract], + sso_organization_id: null, + }) + const orgSlug = org.slug.replace(/^org-/, "") + setMockResponse.get( + mitxUrls.organization.managerOrganizationsList(), + paginate([org]), + ) + + renderWithProviders( + , + ) + + const unavailableMessage = await screen.findByText( + "Learner analytics is not available in this environment.", + ) + const exportButton = screen.getByRole("button", { + name: "Export learners", + }) + expect(exportButton).toHaveAttribute("aria-disabled", "true") + // Ties the disabled button to the reason it's disabled, so a screen + // reader user tabbing to it hears why, not just that it's dimmed. + expect(exportButton).toHaveAttribute( + "aria-describedby", + unavailableMessage.id, + ) + + // No analytics endpoint is mocked here. If the click handler ignored + // `canQuery` the way it ignored it before this fix, it would still call + // `fetchQuery`, hit the unmocked endpoint, and surface a misleading + // generic failure instead of silently no-opping. + await user.click(exportButton) + expect( + screen.queryByText("Could not export learners. Please try again."), + ).not.toBeInTheDocument() + }) + + test("renders a learner row with its real status", async () => { + const { org, contract, orgSlug } = setup() + const contractId = String(contract.id) + setMockResponse.get( + mitxUrls.organization.managerOrganizationsList(), + paginate([org]), + ) + mockTotal(contractId, 1) + mockList(contractId, [ + analyticsFactories.learnerProgress({ + full_name: "Anton Petrov", + courserun_title: "Module 5", + completion_status: "certified", + enrollment_mode: "verified", + }), + ]) + + renderWithProviders( + , + ) + + const name = await screen.findByText("Anton Petrov") + const row = rowOf(name) + expect(within(row).getByText("Module 5")).toBeInTheDocument() + expect(within(row).getByText("Certificate")).toBeInTheDocument() + }) + + test("a learner who withheld consent shows No consent given", async () => { + const { org, contract, orgSlug } = setup() + const contractId = String(contract.id) + setMockResponse.get( + mitxUrls.organization.managerOrganizationsList(), + paginate([org]), + ) + mockTotal(contractId, 1) + mockList( + contractId, + [ + analyticsFactories.withheldLearnerProgress({ + full_name: "Private Learner", + }), + ], + {}, + { outcomes_withheld_count: 1, total_count: 1 }, + ) + + renderWithProviders( + , + ) + + const name = await screen.findByText("Private Learner") + const row = rowOf(name) + expect(within(row).getByText("No consent given")).toBeInTheDocument() + }) + + test("the consent banner counts withheld rows", async () => { + const { org, contract, orgSlug } = setup() + const contractId = String(contract.id) + setMockResponse.get( + mitxUrls.organization.managerOrganizationsList(), + paginate([org]), + ) + mockTotal(contractId, 10) + mockList( + contractId, + [analyticsFactories.learnerProgress()], + {}, + { + total_count: 10, + outcomes_withheld_count: 3, + }, + ) + + renderWithProviders( + , + ) + + await screen.findByText(/3 of these 10 enrollments/) + }) + + describe("CSV export", () => { + const mockAnchorClick = jest.fn() + const mockCreateObjectURL = jest.fn().mockReturnValue("blob:fake-url") + const mockRevokeObjectURL = jest.fn() + + beforeEach(() => { + mockAnchorClick.mockClear() + mockCreateObjectURL.mockClear() + mockRevokeObjectURL.mockClear() + URL.createObjectURL = mockCreateObjectURL + URL.revokeObjectURL = mockRevokeObjectURL + jest + .spyOn(HTMLAnchorElement.prototype, "click") + .mockImplementation(mockAnchorClick) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + test("exports the same status label the table shows, not the raw completion_status enum", async () => { + const { org, contract, orgSlug } = setup() + const contractId = String(contract.id) + setMockResponse.get( + mitxUrls.organization.managerOrganizationsList(), + paginate([org]), + ) + mockTotal(contractId, 2) + mockList( + contractId, + [ + analyticsFactories.learnerProgress({ + full_name: "Certified Learner", + }), + ], + {}, + { total_count: 2 }, + ) + setMockResponse.get( + analyticsUrls.contracts.learnerProgress(ORG_UUID, contractId, { + sort: "full_name", + limit: 500, + offset: 0, + }), + analyticsFactories.learnerProgressEnvelope([ + analyticsFactories.learnerProgress({ + full_name: "Certified Learner", + completion_status: "certified", + }), + analyticsFactories.withheldLearnerProgress({ + full_name: "Private Learner", + }), + ]), + ) + + renderWithProviders( + , + ) + + await user.click( + await screen.findByRole("button", { name: "Export learners" }), + ) + + await waitFor(() => { + expect(mockCreateObjectURL).toHaveBeenCalledWith(expect.any(Blob)) + }) + const blob = mockCreateObjectURL.mock.calls[0][0] as Blob + const csv = await new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => resolve(reader.result as string) + reader.onerror = reject + reader.readAsText(blob) + }) + + // A "certified" row reads "Certificate" on screen (statusDisplay.ts) + // and should export the same label, not the raw API enum. + expect(csv).toContain("Certificate") + expect(csv).not.toMatch(/,certified,/) + expect(csv).toContain("No consent given") + }) + + test("carries the active status filter, not just pagination", async () => { + const { org, contract, orgSlug } = setup() + const contractId = String(contract.id) + setMockResponse.get( + mitxUrls.organization.managerOrganizationsList(), + paginate([org]), + ) + mockTotal(contractId, 2) + mockList(contractId, [ + analyticsFactories.learnerProgress({ full_name: "Everyone" }), + ]) + mockList( + contractId, + [analyticsFactories.learnerProgress({ full_name: "Only Not Started" })], + { completion_status: ["not_started"] }, + ) + // No mock for the unfiltered `{ limit: 500, offset: 0 }` export + // request: if the export ever drops the filter again, this request + // goes unmocked and the export fails instead of silently exporting + // the whole contract. + setMockResponse.get( + analyticsUrls.contracts.learnerProgress(ORG_UUID, contractId, { + sort: "full_name", + completion_status: ["not_started"], + limit: 500, + offset: 0, + }), + analyticsFactories.learnerProgressEnvelope([ + analyticsFactories.learnerProgress({ + full_name: "Exported Not Started Learner", + }), + ]), + ) + + renderWithProviders( + , + ) + + await screen.findByText("Everyone") + await user.click(await screen.findByRole("combobox", { name: /status/i })) + await user.click( + within(await screen.findByRole("listbox")).getByText("Not started"), + ) + await screen.findByText("Only Not Started") + + await user.click( + await screen.findByRole("button", { name: "Export learners" }), + ) + + await waitFor(() => { + expect(mockCreateObjectURL).toHaveBeenCalledWith(expect.any(Blob)) + }) + const blob = mockCreateObjectURL.mock.calls[0][0] as Blob + const csv = await new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => resolve(reader.result as string) + reader.onerror = reject + reader.readAsText(blob) + }) + + expect(csv).toContain("Exported Not Started Learner") + }) + }) + + test("shows an error state instead of a false empty result when a query fails", async () => { + const { org, contract, orgSlug } = setup() + const contractId = String(contract.id) + setMockResponse.get( + mitxUrls.organization.managerOrganizationsList(), + paginate([org]), + ) + // The total-count query 500s while the list query succeeds. A single + // failed query should still surface a combined error rather than a false + // empty state. + setMockResponse.get( + analyticsUrls.contracts.learnerProgress(ORG_UUID, contractId, { + limit: 1, + }), + "Internal Server Error", + { code: 500 }, + ) + mockList( + contractId, + [analyticsFactories.learnerProgress()], + {}, + { total_count: 5 }, + ) + + renderWithProviders( + , + ) + + await screen.findByText("Something went wrong loading learner data.") + expect( + screen.getByRole("button", { name: "Try again" }), + ).toBeInTheDocument() + expect(screen.queryByText("No learners found.")).not.toBeInTheDocument() + }) + + /** + * Disabled: module filter — see ContractLearnersPage.tsx's file header + * comment. `test.skip` rather than deleting, so these stay real, + * type-checked code and re-enable by dropping `.skip` once the block they + * cover is restored. + */ + test.skip("the module dropdown lists every course run on the contract", async () => { + const { org, contract, orgSlug } = setup() + const contractId = String(contract.id) + setMockResponse.get( + mitxUrls.organization.managerOrganizationsList(), + paginate([org]), + ) + mockTotal(contractId, 1) + mockList(contractId, [analyticsFactories.learnerProgress()]) + mockFunnel(contractId) + + renderWithProviders( + , + ) + + const moduleSelect = await screen.findByRole("combobox", { + name: /module/i, + }) + await user.click(moduleSelect) + + const listbox = await screen.findByRole("listbox") + expect(within(listbox).getByText("All modules")).toBeInTheDocument() + // Sourced from enrollment-funnel, so it covers runs with no row on the + // current page. + expect(within(listbox).getByText("Module 5")).toBeInTheDocument() + expect(within(listbox).getByText("Module 6")).toBeInTheDocument() + }) + + test.skip("selecting a module sends courserun_readable_id", async () => { + const { org, contract, orgSlug } = setup() + const contractId = String(contract.id) + setMockResponse.get( + mitxUrls.organization.managerOrganizationsList(), + paginate([org]), + ) + mockTotal(contractId, 2) + mockList(contractId, [ + analyticsFactories.learnerProgress({ full_name: "Unfiltered Learner" }), + ]) + mockFunnel(contractId) + mockList( + contractId, + [analyticsFactories.learnerProgress({ full_name: "Module Six Learner" })], + { courserun_readable_id: "course-v1:MITx+M6+2026" }, + ) + + renderWithProviders( + , + ) + + await screen.findByText("Unfiltered Learner") + + await user.click(await screen.findByRole("combobox", { name: /module/i })) + await user.click( + within(await screen.findByRole("listbox")).getByText("Module 6"), + ) + + await screen.findByText("Module Six Learner") + }) + + test("the status filter sends completion_status", async () => { + const { org, contract, orgSlug } = setup() + const contractId = String(contract.id) + setMockResponse.get( + mitxUrls.organization.managerOrganizationsList(), + paginate([org]), + ) + mockTotal(contractId, 2) + mockList(contractId, [ + analyticsFactories.learnerProgress({ full_name: "Everyone" }), + ]) + mockList( + contractId, + [analyticsFactories.learnerProgress({ full_name: "Only Not Started" })], + { completion_status: ["not_started"] }, + ) + + renderWithProviders( + , + ) + + await screen.findByText("Everyone") + + await user.click(await screen.findByRole("combobox", { name: /status/i })) + await user.click( + within(await screen.findByRole("listbox")).getByText("Not started"), + ) + + await screen.findByText("Only Not Started") + }) + + test("the Completed filter matches passed and certified, with no separate Certificate option", async () => { + const { org, contract, orgSlug } = setup() + const contractId = String(contract.id) + setMockResponse.get( + mitxUrls.organization.managerOrganizationsList(), + paginate([org]), + ) + mockTotal(contractId, 3) + mockList(contractId, [ + analyticsFactories.learnerProgress({ full_name: "Everyone" }), + ]) + mockList( + contractId, + [ + analyticsFactories.learnerProgress({ + full_name: "Passed", + completion_status: "passed", + }), + analyticsFactories.learnerProgress({ + full_name: "Certified", + completion_status: "certified", + }), + ], + { completion_status: ["passed", "certified"] }, + ) + + renderWithProviders( + , + ) + + await screen.findByText("Everyone") + + await user.click(await screen.findByRole("combobox", { name: /status/i })) + const listbox = await screen.findByRole("listbox") + expect(within(listbox).queryByText("Certificate")).not.toBeInTheDocument() + await user.click(within(listbox).getByText("Completed")) + + await screen.findByText("Passed") + await screen.findByText("Certified") + }) + + test("a status filter with no matches reads as a filter, not an empty contract", async () => { + const { org, contract, orgSlug } = setup() + const contractId = String(contract.id) + setMockResponse.get( + mitxUrls.organization.managerOrganizationsList(), + paginate([org]), + ) + mockTotal(contractId, 5) + mockList(contractId, [ + analyticsFactories.learnerProgress({ full_name: "Everyone" }), + ]) + mockList(contractId, [], { completion_status: ["not_started"] }) + + renderWithProviders( + , + ) + + await screen.findByText("Everyone") + + await user.click(await screen.findByRole("combobox", { name: /status/i })) + await user.click( + within(await screen.findByRole("listbox")).getByText("Not started"), + ) + + // Not "No learners found." — that would read as if the contract has no + // learners at all, when really none match the selected filter. The text + // appears twice (the visible cell and its role="status" echo), so scope + // to the cell. + await within(await screen.findByRole("cell")).findByText( + "No learners match this filter.", + ) + }) + + /** + * Disabled: row/bulk selection + Send reminder — see + * ContractLearnersPage.tsx's file header comment. `test.skip` rather than + * deleting; re-enable by dropping `.skip` once that block and the matching + * one in LearnerRow.tsx are restored. + */ + test.skip("row checkboxes are individually named for screen readers", async () => { + const { org, contract, orgSlug } = setup() + const contractId = String(contract.id) + setMockResponse.get( + mitxUrls.organization.managerOrganizationsList(), + paginate([org]), + ) + mockTotal(contractId, 1) + mockList(contractId, [ + analyticsFactories.learnerProgress({ + full_name: "Anton Petrov", + courserun_title: "Module 5", + }), + ]) + + renderWithProviders( + , + ) + + // Guards the MUI-over-smoot-design Checkbox choice: smoot's takes no + // aria-label, so a regression back to it would leave this unnamed. + await screen.findByRole("checkbox", { + name: "Select Anton Petrov, Module 5", + }) + await screen.findByRole("checkbox", { + name: "Select all learners on this page", + }) + }) + + test.skip("bulk reminder is disabled until a row is selected", async () => { + const { org, contract, orgSlug } = setup() + const contractId = String(contract.id) + setMockResponse.get( + mitxUrls.organization.managerOrganizationsList(), + paginate([org]), + ) + mockTotal(contractId, 1) + mockList(contractId, [ + analyticsFactories.learnerProgress({ full_name: "Anton Petrov" }), + ]) + + renderWithProviders( + , + ) + + const selectAll = await screen.findByRole("checkbox", { + name: "Select all learners on this page", + }) + const bulkButton = screen + .getAllByRole("button", { name: "Send reminder" }) + .at(-1)! + expect(bulkButton).toBeDisabled() + + await user.click(selectAll) + expect(bulkButton).toBeEnabled() + }) +}) diff --git a/frontends/main/src/app-pages/ContractLearnersPage/ContractLearnersPage.tsx b/frontends/main/src/app-pages/ContractLearnersPage/ContractLearnersPage.tsx new file mode 100644 index 0000000000..079126b6d2 --- /dev/null +++ b/frontends/main/src/app-pages/ContractLearnersPage/ContractLearnersPage.tsx @@ -0,0 +1,919 @@ +"use client" + +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react" +import NextLink from "next/link" +import { + keepPreviousData, + useQuery, + useQueryClient, +} from "@tanstack/react-query" +import { useFeatureFlagEnabled } from "posthog-js/react" +import { RiArrowLeftLine } from "@remixicon/react" +import { + Container, + Pagination, + SearchInput, + SimpleSelectField, + Skeleton, + Stack, + styled, + Typography, +} from "ol-components" +import { Alert, Button, VisuallyHidden } from "@mitodl/smoot-design" +import { + analyticsContractQueries, + type CompletionStatusFilter, + type LearnerProgress, +} from "api/analytics-hooks/organizations" +import { managerOrganizationQueries } from "api/mitxonline-hooks/organizations" +import { isAnalyticsConfigured } from "api/runtime" +import { + AriaDisabledButtonWrapper, + buildCsvRow, + EmptyTableMessage, + TableBody, + TableCard, + TableFooter, + TableFootnote, + TableHeaderCell, + TableHeaderRow, + TableRow, +} from "@/components/B2BTable/B2BTable" +import { matchOrganizationBySlug } from "@/common/utils" +import { ForbiddenError, isForbiddenResponse } from "@/common/errors" +import { FeatureFlags } from "@/common/feature_flags" +import { useFeatureFlagsLoaded } from "@/common/useFeatureFlagsLoaded" +import { contractAnalyticsView } from "@/common/urls" +import SectionHeader from "../DashboardPage/Analytics/SectionHeader" +import { ErrorContent } from "../ErrorPage/ErrorPageTemplate" +import { LearnerRow } from "./LearnerRow" +import { COLUMN_FLEX } from "./columns" +import { DISPLAY_STATUS_LABEL, getDisplayStatus } from "./statusDisplay" + +/** + * The B2B learner directory: one row per learner per course run under a + * contract, with the status a manager needs to see who is falling behind. + * + * # Where this sits + * + * A third page alongside `ContractAdminPage` (seat administration) and + * `AnalyticsContent` (aggregate reporting), sharing their table primitives so + * the three read as one product. It is routed outside `/dashboard` — see + * `CONTRACT_LEARNERS_VIEW` — so it gets no sidebar and can use the full width + * this table needs. + * + * # What is real, and what is disabled + * + * The status pill, search, the status filter and CSV export are backed by + * `learner-progress` and are real. `courserun_title` under each learner's + * name is also real — enrollment metadata, not consent-gated. + * + * Everything else this feature was designed to show is implemented but + * commented out, not deleted, so nothing fabricated ships while the table + * stays ready to turn each one back on — search this file and + * `LearnerRow.tsx` for "Disabled:" to find each block: + * - Selection + Send reminder: no endpoint exists to nudge an enrolled + * learner (MITx Online's remind mutation only covers an unredeemed seat + * code), so both were hidden rather than shipped as dead buttons. + * - Module filter: sends `courserun_readable_id`, a parameter the real + * `ol-analytics-api` does not implement (confirmed against its router + * source — an unrecognized query param is silently dropped, not + * rejected). Left visible it would look like it filters and wouldn't. + * - Progress (table cell and CSV columns alike) and Last activity: both + * fabricate a value with no real field behind them yet — see + * `placeholders.ts`'s header comment for what each is blocked on. + */ + +const Page = styled(Container)(({ theme }) => ({ + maxWidth: "1400px", + padding: "40px 24px", + [theme.breakpoints.down("md")]: { + padding: "24px 16px", + }, +})) + +const BackLink = styled(NextLink)(({ theme }) => ({ + ...theme.typography.subtitle3, + display: "inline-flex", + alignItems: "center", + gap: "8px", + color: theme.custom.colors.mitRed, + textDecoration: "none", + ":hover": { textDecoration: "underline" }, +})) + +const HeaderSection = styled.div(({ theme }) => ({ + display: "flex", + alignItems: "flex-end", + justifyContent: "space-between", + gap: "24px", + paddingBottom: "16px", + borderBottom: `2px solid ${theme.custom.colors.black}`, + [theme.breakpoints.down("md")]: { + flexDirection: "column", + alignItems: "stretch", + }, +})) + +const Eyebrow = styled(Typography)(({ theme }) => ({ + ...theme.typography.subtitle4, + color: theme.custom.colors.mitRed, + textTransform: "uppercase", + letterSpacing: "0.06em", +})) as typeof Typography + +const PageTitle = styled(Typography)(({ theme }) => ({ + ...theme.typography.h2, + color: theme.custom.colors.black, +})) as typeof Typography + +const PageSubtitle = styled(Typography)(({ theme }) => ({ + ...theme.typography.body1, + color: theme.custom.colors.silverGrayDark, +})) as typeof Typography + +const ExportWrapper = styled(AriaDisabledButtonWrapper)(({ theme }) => ({ + flexShrink: 0, + [theme.breakpoints.down("md")]: { + "> button": { width: "100%" }, + }, +})) + +const ResultsSection = styled.div({ + display: "flex", + flexDirection: "column", + gap: "16px", +}) + +const ControlsRow = styled.div(({ theme }) => ({ + display: "flex", + alignItems: "flex-end", + justifyContent: "space-between", + gap: "16px", + [theme.breakpoints.down("md")]: { + flexDirection: "column", + alignItems: "stretch", + }, +})) + +const ControlsRight = styled.div(({ theme }) => ({ + display: "flex", + alignItems: "flex-end", + gap: "12px", + [theme.breakpoints.down("md")]: { + flexDirection: "column", + alignItems: "stretch", + width: "100%", + }, +})) + +const StyledSearchInput = styled(SearchInput)(({ theme }) => ({ + minWidth: "280px", + [theme.breakpoints.down("md")]: { minWidth: "auto", width: "100%" }, +})) + +const FilterField = styled(SimpleSelectField)(({ theme }) => ({ + "& .MuiSelect-root": { + minWidth: "280px", + }, + [theme.breakpoints.down("md")]: { + width: "100%", + "& .MuiSelect-root": { + minWidth: "auto", + width: "100%", + }, + }, +})) + +const ConsentNotice = styled(Typography)(({ theme }) => ({ + ...theme.typography.body3, + color: theme.custom.colors.silverGrayDark, +})) as typeof Typography + +const ErrorRow = styled.div({ + display: "flex", + alignItems: "center", + justifyContent: "space-between", + flexWrap: "wrap", + gap: "16px", +}) + +// --- Disabled: placeholder-data footnote ---------------------------------- +// +// Unused while nothing fabricated renders on screen — see its JSX comment +// further down for why. +// +// const PlaceholderNotice = styled(Typography)(({ theme }) => ({ +// ...theme.typography.body3, +// color: theme.custom.colors.silverGrayDark, +// })) as typeof Typography +// -------------------------------------------------------------------------- + +// --- Disabled: bulk selection bar (Select all + Send reminder) ----------- +// +// Exists only to drive the bulk "Send reminder" action — see the file header +// comment. Restore alongside LearnerRow.tsx's matching block. +// +// /** Same card treatment as the results table below it, so the two read as one surface. */ +// const BulkBar = styled(TableCard)({ +// display: "flex", +// alignItems: "center", +// justifyContent: "space-between", +// gap: "16px", +// }) +// +// const BulkLabel = styled.label(({ theme }) => ({ +// display: "flex", +// alignItems: "center", +// gap: "8px", +// ...theme.typography.subtitle2, +// color: theme.custom.colors.black, +// cursor: "pointer", +// })) +// +// const SelectHeaderCell = styled.div({ width: "40px", flexShrink: 0 }) +// const ActionHeaderCell = styled.div({ width: "140px", flexShrink: 0 }) +// -------------------------------------------------------------------------- + +const PAGE_SIZE = 25 +/** Below the API's max_page_size rather than pinned to it — that setting is env-overridable, and this only costs one extra round trip on a large contract. */ +const CSV_EXPORT_PAGE_SIZE = 500 +const SEARCH_DEBOUNCE_MS = 300 +/** Matches the API's cap on `search`; truncated below rather than sent as-is, since a long paste would otherwise 422 and read as "Something went wrong loading learner data" — 422 isn't in the error-boundary set below. */ +const SEARCH_MAX_LENGTH = 254 +const ALL = "all" +const UNAVAILABLE_MESSAGE_ID = "learner-analytics-unavailable-message" + +/** + * The status dropdown's options. The progress bands the prototype also lists + * (1-24%, 25-49%, 50-99%) are deliberately absent: they filter on progress + * data that does not exist, so they could only ever filter placeholder values. + */ +const STATUS_OPTIONS: { value: string; label: string }[] = [ + { value: ALL, label: "All learners" }, + { value: "not_started", label: "Not started" }, + { value: "in_progress", label: "In progress" }, + { value: "passed", label: "Completed" }, + /** Inert until a real consent field exists — OL_ANALYTICS_API_B2B_DASHBOARD_CONSENT_FAIL_OPEN=true keeps outcomes_shared always true, so this matches zero rows everywhere today. Not a bug; keep it. */ + { value: "unknown", label: "No consent given" }, +] + +/** + * "Completed" queries both `passed` and `certified`, matching the count + * tile above it: a learner who passed but hasn't certified yet is still + * "Completed" to a manager, and a standalone "Certificate" filter option + * previously returned fewer rows than the tile it was supposed to explain. + * The row-level status pill (`getDisplayStatus`) still distinguishes the + * two outcomes; only the filter groups them. + */ +const STATUS_FILTER_COMPLETION_STATUS: Record< + string, + CompletionStatusFilter[] +> = { + not_started: ["not_started"], + in_progress: ["in_progress"], + passed: ["passed", "certified"], + unknown: ["unknown"], +} + +const rowIdOf = (row: LearnerProgress) => + `${row.learner_id}:${row.courserun_readable_id}` + +type ContractLearnersPageProps = { + orgSlug: string + contractSlug: string +} + +const ContractLearnersPageInternal: React.FC = ({ + orgSlug, + contractSlug, +}) => { + const [searchQuery, setSearchQuery] = useState("") + const [debouncedSearch, setDebouncedSearch] = useState("") + const [statusFilter, setStatusFilter] = useState(ALL) + const [page, setPage] = useState(1) + const [isExporting, setIsExporting] = useState(false) + const [actionResult, setActionResult] = useState<{ + message: string + severity: "success" | "error" + } | null>(null) + const [announcement, setAnnouncement] = useState("") + const queryClient = useQueryClient() + + const applyFilterChange = useCallback((apply: () => void) => { + apply() + setPage(1) + // Selection reset (`setSelected(new Set())`) lived here while row + // selection was enabled — restore it alongside that block. + }, []) + + useEffect(() => { + const id = setTimeout(() => { + setDebouncedSearch(searchQuery) + setPage(1) + }, SEARCH_DEBOUNCE_MS) + return () => clearTimeout(id) + }, [searchQuery]) + + const { + data: managerOrgs, + isLoading: isLoadingOrgs, + error: orgsError, + } = useQuery({ + ...managerOrganizationQueries.managerOrganizationsList(), + throwOnError: false, + }) + + const org = managerOrgs?.find(matchOrganizationBySlug(orgSlug)) + const contract = org?.contracts.find((item) => item.slug === contractSlug) + const orgUuid = org?.sso_organization_id ?? null + const contractId = contract ? String(contract.id) : null + const canQuery = isAnalyticsConfigured() && !!orgUuid && !!contractId + + const completionStatus = useMemo( + () => + statusFilter === ALL + ? undefined + : STATUS_FILTER_COMPLETION_STATUS[statusFilter], + [statusFilter], + ) + + const listParams = useMemo( + () => ({ + limit: PAGE_SIZE, + offset: (page - 1) * PAGE_SIZE, + sort: "full_name" as const, + ...(debouncedSearch ? { search: debouncedSearch } : {}), + ...(completionStatus ? { completion_status: completionStatus } : {}), + // courserun_readable_id (module filter) dropped here — see file header. + }), + [page, debouncedSearch, completionStatus], + ) + + const rowsQuery = useQuery({ + ...analyticsContractQueries.learnerProgress( + orgUuid ?? "", + contractId ?? "", + listParams, + ), + enabled: canQuery, + placeholderData: keepPreviousData, + }) + + /** + * One row is enough: only `total_count` is read. Unfiltered on purpose, so + * the "X of Y enrollments" summary below stays a fixed total while the + * table narrows with search/status filters. + */ + const totalQuery = useQuery({ + ...analyticsContractQueries.learnerProgress( + orgUuid ?? "", + contractId ?? "", + { limit: 1 }, + ), + enabled: canQuery, + }) + + // --- Disabled: module filter -------------------------------------------- + // + // Sends `courserun_readable_id`, a parameter the real ol-analytics-api does + // not implement — see the file header comment. `enrollmentFunnel` was + // fetched here only to populate this dropdown's options. + // + // const funnelQuery = useQuery({ + // ...analyticsContractQueries.enrollmentFunnel( + // orgUuid ?? "", + // contractId ?? "", + // { limit: 1000 }, + // ), + // enabled: canQuery, + // }) + // + // const moduleOptions = useMemo(() => { + // const seen = new Map() + // for (const row of funnelQuery.data?.data ?? []) { + // if (!seen.has(row.courserun_readable_id)) { + // seen.set(row.courserun_readable_id, row.courserun_title) + // } + // } + // return [ + // { value: ALL, label: "All modules" }, + // ...[...seen.entries()] + // .map(([value, label]) => ({ value, label })) + // .sort((a, b) => a.label.localeCompare(b.label)), + // ] + // }, [funnelQuery.data]) + // ------------------------------------------------------------------------- + + const rows = rowsQuery.data?.data ?? [] + const filteredCount = rowsQuery.data?.total_count ?? 0 + const withheldCount = rowsQuery.data?.outcomes_withheld_count ?? 0 + const totalEnrollments = totalQuery.data?.total_count ?? null + const totalPages = Math.ceil(filteredCount / PAGE_SIZE) + const isStale = rowsQuery.isPlaceholderData || rowsQuery.isFetching + const isBusy = rowsQuery.isLoading || isStale + + /** + * Only 400/401/403 responses throw to an error boundary (see + * `makeBrowserQueryClient`); a 5xx or network failure just settles into + * `isError` with `data` left undefined. Without this check, that failure + * reads as "no learners" instead of surfacing the error state below. + */ + const hasLoadError = rowsQuery.isError || totalQuery.isError + + const retryFailedQueries = () => { + rowsQuery.refetch() + totalQuery.refetch() + } + + // --- Disabled: row/bulk selection --------------------------------------- + // + // Only consumer is "Send reminder" — see the file header comment. Restore + // together with LearnerRow.tsx's matching block, the BulkBar JSX below, and + // the `setSelected(new Set())` calls noted in applyFilterChange and the + // search-debounce effect above. + // + // const [selected, setSelected] = useState>(new Set()) + // + // const visibleIds = rows.map(rowIdOf) + // const selectedVisible = visibleIds.filter((id) => selected.has(id)) + // const allVisibleSelected = + // visibleIds.length > 0 && selectedVisible.length === visibleIds.length + // const someVisibleSelected = selectedVisible.length > 0 && !allVisibleSelected + // + // const toggleSelect = useCallback((rowId: string) => { + // setSelected((current) => { + // const next = new Set(current) + // if (next.has(rowId)) next.delete(rowId) + // else next.add(rowId) + // return next + // }) + // }, []) + // + // const toggleSelectAll = useCallback(() => { + // setSelected((current) => { + // const next = new Set(current) + // const everySelected = visibleIds.every((id) => next.has(id)) + // for (const id of visibleIds) { + // if (everySelected) next.delete(id) + // else next.add(id) + // } + // return next + // }) + // }, [visibleIds]) + // + // /** + // * PLACEHOLDER. No endpoint can nudge an enrolled learner: MITx Online's + // * remind mutation resends the claim email for an *unredeemed* seat code, and + // * every row here belongs to someone who already redeemed one. Wired to the + // * real UI so only this handler changes when an endpoint exists. + // */ + // const sendReminder = useCallback((rowIds: string[]) => { + // const message = `Reminders are not available yet. ${rowIds.length} learner${ + // rowIds.length === 1 ? "" : "s" + // } would have been sent one.` + // setActionResult({ message, severity: "error" }) + // setAnnouncement("") + // setTimeout(() => setAnnouncement(message), 100) + // }, []) + // ------------------------------------------------------------------------- + + const handleExport = useCallback(async () => { + if (isExporting || !canQuery || !orgUuid || !contractId) return + setIsExporting(true) + try { + const all: LearnerProgress[] = [] + let offset = 0 + let total = Number.POSITIVE_INFINITY + while (all.length < total) { + const data = await queryClient.fetchQuery( + analyticsContractQueries.learnerProgress(orgUuid, contractId, { + ...listParams, + limit: CSV_EXPORT_PAGE_SIZE, + offset, + }), + ) + all.push(...data.data) + total = data.total_count + // A page that comes back empty while the reported total says otherwise + // would otherwise spin forever. + if (data.data.length === 0) break + offset += CSV_EXPORT_PAGE_SIZE + } + // Last activity is omitted while it is a placeholder: a CSV outlives + // the screen and carries no "preview" marking with it. + const header = buildCsvRow([ + "Name", + "Email", + "Course", + "Course ID", + "Status", + "Enrolled on", + // Disabled: fabricated Progress — see LearnerRow.tsx's and + // placeholders.ts's "Disabled:" comments. Built and kept here rather + // than shipped with invented numbers. + // "Progress %", + // "Lessons Completed", + // "Lessons Total", + ]) + const body = all.map((row) => { + // Disabled: fabricated Progress — see the header comment above. + // const progress = placeholderProgress(row) + return buildCsvRow([ + row.full_name, + row.email, + row.courserun_title, + row.courserun_readable_id, + DISPLAY_STATUS_LABEL[getDisplayStatus(row)], + row.enrolled_on, + // progress ? String(progress.percent) : "", + // progress ? String(progress.lessonsCompleted) : "", + // progress ? String(progress.lessonsTotal) : "", + ]) + }) + const csv = [header, ...body].join("\n") + const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" }) + const url = URL.createObjectURL(blob) + const anchor = document.createElement("a") + anchor.href = url + anchor.download = `learners-${contractSlug}.csv` + document.body.appendChild(anchor) + anchor.click() + document.body.removeChild(anchor) + URL.revokeObjectURL(url) + setActionResult({ + message: "CSV download started.", + severity: "success", + }) + } catch { + setActionResult({ + message: "Could not export learners. Please try again.", + severity: "error", + }) + } finally { + setIsExporting(false) + } + }, [ + isExporting, + canQuery, + orgUuid, + contractId, + contractSlug, + queryClient, + listParams, + ]) + + // Announce the result count once a filter or search settles, but not on + // first load and not mid-flight. + const lastAnnounced = useRef(null) + useEffect(() => { + if (isBusy || !rowsQuery.data) return + const key = `${statusFilter}:${debouncedSearch}` + if (lastAnnounced.current === null) { + lastAnnounced.current = key + return + } + if (lastAnnounced.current === key) return + lastAnnounced.current = key + setAnnouncement( + `${filteredCount} ${filteredCount === 1 ? "result" : "results"}`, + ) + }, [isBusy, rowsQuery.data, statusFilter, debouncedSearch, filteredCount]) + + if (isLoadingOrgs) { + return ( + + + + + + + + ) + } + + if (orgsError) { + return ( + + + + ) + } + + if (!org) { + return ( + + + + ) + } + + if (!contract) { + return ( + + + + ) + } + + const emptyMessage = debouncedSearch + ? "No learners match your search." + : statusFilter !== ALL + ? "No learners match this filter." + : "No learners found." + + return ( + + +
+ + +
+ + +
+ Learner Directory + Learners + {contract.name} +
+ + + +
+ + {!canQuery ? ( + + Learner analytics is not available in this environment. + + ) : hasLoadError ? ( + + + Something went wrong loading learner data. + + + + ) : ( + + + + + + setSearchQuery( + event.target.value.slice(0, SEARCH_MAX_LENGTH), + ) + } + onClear={() => applyFilterChange(() => setSearchQuery(""))} + onSubmit={() => {}} + /> + + applyFilterChange(() => + setStatusFilter(String(event.target.value)), + ) + } + /> + {/* Disabled: module filter — see file header comment. + + applyFilterChange(() => + setModuleFilter(String(event.target.value)), + ) + } + /> + */} + + + + {actionResult ? ( + setActionResult(null)} + > + {actionResult.message} + + ) : null} + + + {announcement} + + + {/* Disabled: bulk selection bar — see file header comment. + + + + Select all + + + + */} + + {withheldCount > 0 ? ( + + {withheldCount} of these {filteredCount} enrollments belong to + learners who have not agreed to share their progress. Their + status, grade and activity read “No consent given”. + + ) : null} + + + + {rowsQuery.isLoading + ? "Loading learners" + : filteredCount === 0 + ? emptyMessage + : `Showing page ${page} of ${Math.max(totalPages, 1)}`} + +
+
+ + {/* Disabled: selection column header — see file header comment. + + */} + + Learner + + + Status + + {/* Disabled: fabricated Progress column header — see + LearnerRow.tsx's "Disabled:" comment. + + Progress + + */} + {/* Disabled: fabricated Last activity column header — + see LearnerRow.tsx's "Disabled:" comment. + + Last activity + + */} + {/* Disabled: action column header — see file header comment. + + */} + +
+ + {rowsQuery.isLoading ? ( + [1, 2, 3].map((key) => ( + +
+ +
+
+ )) + ) : rows.length === 0 ? ( + + + {emptyMessage} + + + ) : ( + rows.map((row) => ( + + )) + )} +
+
+ + + {filteredCount > 0 + ? `Page ${page} of ${Math.max(totalPages, 1)}` + : ""} + + {totalPages > 1 ? ( + setPage(value)} + /> + ) : null} + +
+ + {/* Disabled: nothing fabricated currently renders on screen — + Progress and Last activity are both commented out above — + so this footnote has nothing left to disclose. Restore + alongside whichever placeholder returns to view first. + + Last activity is a preview value and is not yet real data. + + */} +
+ )} +
+
+ ) +} + +const ContractLearnersPage: React.FC = (props) => { + const flagsLoaded = useFeatureFlagsLoaded() + const enabled = useFeatureFlagEnabled(FeatureFlags.B2BAnalyticsDashboard) + + if (!flagsLoaded) { + return ( + + + + + + + ) + } + if (!enabled) throw new ForbiddenError("Not enabled.") + + return +} + +export default ContractLearnersPage +export type { ContractLearnersPageProps } diff --git a/frontends/main/src/app-pages/ContractLearnersPage/LearnerRow.tsx b/frontends/main/src/app-pages/ContractLearnersPage/LearnerRow.tsx new file mode 100644 index 0000000000..8d9faaa0a1 --- /dev/null +++ b/frontends/main/src/app-pages/ContractLearnersPage/LearnerRow.tsx @@ -0,0 +1,277 @@ +"use client" + +import React from "react" +import { styled, Typography } from "ol-components" +import { initials } from "ol-utilities" +import type { LearnerProgress } from "api/analytics-hooks/organizations" +import { + CellText, + MobileLabel, + TableCell, + TableRow, +} from "@/components/B2BTable/B2BTable" +import { DISPLAY_STATUS_LABEL, getDisplayStatus } from "./statusDisplay" +import { COLUMN_FLEX } from "./columns" + +// --- Disabled: selection + Send reminder --------------------------------- +// +// Row selection exists only to feed the bulk "Send reminder" action, and no +// endpoint can nudge an enrolled learner: MITx Online's remind mutation +// resends the claim email for an *unredeemed* seat code, and every row here +// belongs to someone who already redeemed one (see hq-XXXX, filed for a real +// endpoint). Commented out rather than removed so the UI is ready to turn +// back on once that endpoint exists — restore this block and the matching +// one in ContractLearnersPage.tsx together. +// +// import { MuiCheckbox } from "ol-components" +// import { Button } from "@mitodl/smoot-design" +// +// /** +// * ol-components' MUI re-export rather than smoot-design's Checkbox, which +// * takes neither an `aria-label` nor an `id` — its props are `{label, value, +// * name, checked, onChange, className, disabled}`. A row checkbox has no +// * visible label, so through that component it would reach assistive tech +// * unnamed. Swap back once smoot-design can name one. +// */ +// const SelectCheckbox = styled(MuiCheckbox)(({ theme }) => ({ +// padding: "8px", +// color: theme.custom.colors.silverGrayDark, +// "&.Mui-checked": { +// color: theme.custom.colors.mitRed, +// }, +// })) +// +// const SelectCell = styled.div(({ theme }) => ({ +// width: "40px", +// flexShrink: 0, +// display: "flex", +// alignItems: "center", +// [theme.breakpoints.down("md")]: { +// position: "absolute", +// top: "12px", +// left: 0, +// width: "auto", +// }, +// })) +// +// const ActionCell = styled.div(({ theme }) => ({ +// width: "140px", +// flexShrink: 0, +// display: "flex", +// justifyContent: "flex-end", +// [theme.breakpoints.down("md")]: { +// width: "100%", +// justifyContent: "flex-start", +// paddingTop: "8px", +// }, +// })) +// -------------------------------------------------------------------------- + +/** + * Initials, not a photo: the analytics API returns no avatar image, and a name + * is the only identity it carries. + */ +const Avatar = styled.div(({ theme }) => ({ + display: "flex", + alignItems: "center", + justifyContent: "center", + width: "32px", + height: "32px", + flexShrink: 0, + borderRadius: "4px", + backgroundColor: theme.custom.colors.lightGray2, + color: theme.custom.colors.darkGray2, + ...theme.typography.subtitle3, +})) + +const LearnerCell = styled(TableCell)({ + display: "flex", + alignItems: "center", + gap: "12px", +}) + +const LearnerName = styled(Typography)(({ theme }) => ({ + ...theme.typography.subtitle2, + color: theme.custom.colors.black, + overflow: "hidden", + textOverflow: "ellipsis", +})) as typeof Typography + +const CourseTitle = styled(Typography)(({ theme }) => ({ + ...theme.typography.body3, + color: theme.custom.colors.silverGrayDark, + overflow: "hidden", + textOverflow: "ellipsis", +})) as typeof Typography + +const StatusDot = styled.span<{ $muted: boolean }>(({ $muted, theme }) => ({ + display: "inline-block", + width: "8px", + height: "8px", + flexShrink: 0, + marginRight: "8px", + backgroundColor: $muted + ? theme.custom.colors.silverGray + : theme.custom.colors.darkGray2, +})) + +// --- Disabled: fabricated "Needs attention" row flag ---------------------- +// +// `placeholderNeedsAttention` hashes learner_id/courserun_readable_id into a +// pseudo-random verdict — it is not a real signal, and unlike Progress/Last +// activity it rendered with no visual distinction from a genuine warning. +// Restore once the backend's real `needs_attention` field ships (see +// placeholders.ts's header comment for the planned definition) — swap the +// import back to `placeholderNeedsAttention` and reinstate the JSX below. +// +// import { placeholderNeedsAttention } from "./placeholders" +// +// const NeedsAttention = styled(Typography)(({ theme }) => ({ +// ...theme.typography.subtitle4, +// color: theme.custom.colors.mitRed, +// display: "block", +// })) as typeof Typography +// -------------------------------------------------------------------------- + +// --- Disabled: fabricated Progress column --------------------------------- +// +// `placeholderProgress` fabricates a percent-complete and lesson count for +// every row. Real progress data is coming (see placeholders.ts's header +// comment for what blocks it), but until then this column is built and kept +// here rather than shipped with invented numbers — same treatment as +// selection/Send reminder above. +// +// import { ProgressBar } from "./ProgressBar" +// import { STUB } from "@/components/B2BTable/B2BTable" +// import { placeholderProgress } from "./placeholders" +// +// const ProgressGroup = styled.span({ +// display: "flex", +// alignItems: "center", +// gap: "12px", +// }) +// -------------------------------------------------------------------------- + +// --- Disabled: fabricated Last activity column ---------------------------- +// +// `placeholderLastActiveOn` fabricates a date; the real `last_active_on` +// field is hardcoded null by the API itself (activity data doesn't exist +// upstream yet — see placeholders.ts's header comment). Built and kept here +// rather than shipped with an invented date. +// +// import { PLACEHOLDER_ATTR, placeholderLastActiveOn } from "./placeholders" +// +// const formatDay = (iso: string | null): string | null => { +// if (!iso) return null +// const date = new Date(iso) +// return Number.isNaN(date.getTime()) +// ? null +// : date.toLocaleDateString(undefined, { month: "short", day: "numeric" }) +// } +// -------------------------------------------------------------------------- + +const MutedText = styled.span(({ theme }) => ({ + color: theme.custom.colors.silverGrayDark, +})) + +type LearnerRowProps = { + row: LearnerProgress + // rowId, selected, onToggleSelect, onSendReminder: dropped along with + // selection + Send reminder above. Restore together. +} + +const LearnerRow: React.FC = ({ row }) => { + const status = getDisplayStatus(row) + const statusLabel = DISPLAY_STATUS_LABEL[status] + const isWithheld = status === "not-shared" + const name = row.full_name ?? row.email ?? "Unknown learner" + + return ( + + {/* + + onToggleSelect(rowId)} + inputProps={{ + "aria-label": `Select ${name}, ${row.courserun_title}`, + }} + /> + + */} + + + + + {name} + {row.courserun_title} + + + + + Status: + + + + + {/* Disabled: fabricated Progress column — see file header comment. + + Progress: + {progress ? ( + + + {progress.percent}% + + ) : ( + {STUB} + )} + + */} + + {/* Disabled: fabricated Last activity column — see file header comment. + + Last activity: + + {formatDay(lastActiveOn) ?? No activity} + {progress ? ( + + + {progress.lessonsCompleted} / {progress.lessonsTotal} lessons + + + ) : null} + + + */} + + {/* + + + + */} + + ) +} + +export { LearnerRow } diff --git a/frontends/main/src/app-pages/ContractLearnersPage/ProgressBar.tsx b/frontends/main/src/app-pages/ContractLearnersPage/ProgressBar.tsx new file mode 100644 index 0000000000..0af25eac50 --- /dev/null +++ b/frontends/main/src/app-pages/ContractLearnersPage/ProgressBar.tsx @@ -0,0 +1,41 @@ +import React from "react" +import { styled } from "ol-components" + +/** + * The bar itself is decorative: it is always rendered beside the same figure + * in text, and the cell sits under a "Progress" column header that names what + * the figure measures. Giving it `role="progressbar"` as well would announce + * the same number twice, so it is hidden from assistive tech instead and the + * text is left to carry the value. + */ +const Track = styled.div(({ theme }) => ({ + width: "80px", + height: "6px", + flexShrink: 0, + borderRadius: "3px", + backgroundColor: theme.custom.colors.lightGray2, + overflow: "hidden", +})) + +const Fill = styled.div<{ $percent: number }>(({ $percent, theme }) => ({ + width: `${$percent}%`, + height: "100%", + borderRadius: "3px", + backgroundColor: theme.custom.colors.darkGray2, +})) + +type ProgressBarProps = { + percent: number + className?: string +} + +const ProgressBar: React.FC = ({ percent, className }) => { + const clamped = Math.max(0, Math.min(100, percent)) + return ( + + + + ) +} + +export { ProgressBar } diff --git a/frontends/main/src/app-pages/ContractLearnersPage/columns.ts b/frontends/main/src/app-pages/ContractLearnersPage/columns.ts new file mode 100644 index 0000000000..8afbcf9e5b --- /dev/null +++ b/frontends/main/src/app-pages/ContractLearnersPage/columns.ts @@ -0,0 +1,13 @@ +/** + * Each column's share of a desktop row. Every cell in a column must be given + * the same value, which is why these live in one map rather than being + * repeated at each header and cell — see B2BTable's doc comment. + */ +const COLUMN_FLEX = { + learner: 2.5, + status: 1.5, + progress: 1.5, + lastActivity: 1.5, +} as const + +export { COLUMN_FLEX } diff --git a/frontends/main/src/app-pages/ContractLearnersPage/placeholders.ts b/frontends/main/src/app-pages/ContractLearnersPage/placeholders.ts new file mode 100644 index 0000000000..8a8f5184f1 --- /dev/null +++ b/frontends/main/src/app-pages/ContractLearnersPage/placeholders.ts @@ -0,0 +1,140 @@ +import type { LearnerProgress } from "api/analytics-hooks/organizations" + +/** + * Every fabricated value on this page, in one file. + * + * Nothing else in ContractLearnersPage invents data — each column below has a + * real field coming, and each export here names the field that will replace + * it. Removing a placeholder is therefore: delete its function, follow the + * type errors to its call sites, read the real field instead. + * + * Every cell rendered from one of these also carries a `data-placeholder` + * attribute (see PLACEHOLDER_ATTR) so the fake ones can be found in the DOM + * during review, and so a removal PR can grep for the render sites rather + * than reasoning about them. + * + * ## What is fake, and what replaces it + * + * - `placeholderProgress` — lesson counts and percent complete. The function + * stays exported and tested, like `placeholderNeedsAttention` below, but + * nothing currently calls it: both LearnerRow.tsx's Progress cell and + * ContractLearnersPage.tsx's CSV columns for it are built and commented out + * (see each file's "Disabled:" comment) rather than shipped with invented + * numbers. Blocked on a data-platform aggregation over + * `stg__mitxonline__openedx__blockcompletion`, which also needs a decision + * on what a "lesson" is (subsection or unit) and whether progress counts + * all blocks or only graded ones. + * - `placeholderLastActiveOn` — `LearnerProgress.last_active_on` exists in the + * response but the API hardcodes it null for every row. Replaced by reading + * that field once mitodl/ol-data-platform#2672 is wired into the views. + * - `placeholderNeedsAttention` — becomes a backend `needs_attention` field, + * defined as `not_enrolled OR now() - last_activity_at > 30 days`. Only the + * inactive half is computable here, since the activity half is the field + * above. Note the "not enrolled" half cannot be represented at all from this + * endpoint: it returns enrollments, so someone who never redeemed a seat has + * no row to flag. + * + * Values are derived from each row's own identity rather than `Math.random()`, + * so a learner's numbers stay put across re-renders, refetches and paging + * instead of reshuffling on every render. + */ + +/** Marks a cell whose value is fabricated. Not exposed to assistive tech. */ +const PLACEHOLDER_ATTR = "data-placeholder" + +const INACTIVE_DAYS_THRESHOLD = 30 + +/** Small deterministic hash; only needs to be stable, not well-distributed. */ +const seed = (key: string): number => { + let hash = 0 + for (let index = 0; index < key.length; index++) { + hash = (hash * 31 + key.charCodeAt(index)) | 0 + } + return Math.abs(hash) +} + +const rowKey = ( + row: Pick, +) => `${row.learner_id}:${row.courserun_readable_id}` + +const PLACEHOLDER_LESSON_TOTAL = 6 + +type PlaceholderProgress = { + percent: number + lessonsCompleted: number + lessonsTotal: number +} + +/** + * Returns null for a learner who has not consented: their real status is + * withheld, so inventing a progress figure next to a "No consent given" pill + * would both contradict it and leak a shape of the data they declined to + * share. + */ +const placeholderProgress = ( + row: LearnerProgress, +): PlaceholderProgress | null => { + if (!row.outcomes_shared) return null + + // Anchored to the real completion_status so the bar never contradicts the + // pill beside it — a "Not started" row must read 0%. + if (row.completion_status === "not_started") { + return { + percent: 0, + lessonsCompleted: 0, + lessonsTotal: PLACEHOLDER_LESSON_TOTAL, + } + } + if ( + row.completion_status === "passed" || + row.completion_status === "certified" + ) { + return { + percent: 100, + lessonsCompleted: PLACEHOLDER_LESSON_TOTAL, + lessonsTotal: PLACEHOLDER_LESSON_TOTAL, + } + } + + // in_progress: strictly between 0 and 100, so it can't be mistaken for + // either terminal state. + const lessonsCompleted = + 1 + (seed(rowKey(row)) % (PLACEHOLDER_LESSON_TOTAL - 1)) + return { + percent: Math.round((lessonsCompleted / PLACEHOLDER_LESSON_TOTAL) * 100), + lessonsCompleted, + lessonsTotal: PLACEHOLDER_LESSON_TOTAL, + } +} + +const placeholderDaysInactive = (row: LearnerProgress): number => + seed(`${rowKey(row)}:activity`) % 45 + +/** Null for a never-active learner, mirroring the real field's nullability. */ +const placeholderLastActiveOn = (row: LearnerProgress): string | null => { + if (!row.outcomes_shared) return null + if (row.completion_status === "not_started") return null + + const date = new Date() + date.setDate(date.getDate() - placeholderDaysInactive(row)) + return date.toISOString() +} + +/** + * The inactive half of the planned rule. A deactivated enrollment stands in for + * the "not enrolled" half, which this endpoint cannot express (see file + * header). + */ +const placeholderNeedsAttention = (row: LearnerProgress): boolean => { + if (!row.outcomes_shared) return false + if (!row.enrollment_is_active) return true + return placeholderDaysInactive(row) > INACTIVE_DAYS_THRESHOLD +} + +export { + PLACEHOLDER_ATTR, + placeholderProgress, + placeholderLastActiveOn, + placeholderNeedsAttention, +} +export type { PlaceholderProgress } diff --git a/frontends/main/src/app-pages/ContractLearnersPage/statusDisplay.test.ts b/frontends/main/src/app-pages/ContractLearnersPage/statusDisplay.test.ts new file mode 100644 index 0000000000..1a5ae0b65e --- /dev/null +++ b/frontends/main/src/app-pages/ContractLearnersPage/statusDisplay.test.ts @@ -0,0 +1,68 @@ +import { factories } from "api/analytics-test-utils" +import type { CompletionStatus } from "api/analytics-hooks/organizations" +import { DISPLAY_STATUS_LABEL, getDisplayStatus } from "./statusDisplay" + +describe("getDisplayStatus", () => { + test("certified reads Certificate", () => { + const row = factories.learnerProgress({ completion_status: "certified" }) + expect(getDisplayStatus(row)).toBe("certificate") + }) + + test.each(["verified", "audit", "Verified", null])( + "passed reads Completed regardless of enrollment_mode (%s)", + (mode) => { + const row = factories.learnerProgress({ + completion_status: "passed", + enrollment_mode: mode, + }) + expect(getDisplayStatus(row)).toBe("completed") + }, + ) + + test.each([ + { status: "in_progress" as const, expected: "in-progress" as const }, + { status: "not_started" as const, expected: "not-started" as const }, + ])("$status reads $expected", ({ status, expected }) => { + const row = factories.learnerProgress({ completion_status: status }) + expect(getDisplayStatus(row)).toBe(expected) + }) + + test("consent is checked before the status", () => { + // A row the API should never send — outcomes populated while consent is + // withheld. Consent still wins, so a query regression upstream cannot + // leak a status through this function. + const row = factories.learnerProgress({ + outcomes_shared: false, + completion_status: "certified", + }) + expect(getDisplayStatus(row)).toBe("not-shared") + }) + + test("a withheld row reads No consent given", () => { + expect(getDisplayStatus(factories.withheldLearnerProgress())).toBe( + "not-shared", + ) + }) + + test("a consenting row with an unrecognized completion_status reads Unknown, not No consent given", () => { + // A value outside the four known statuses — e.g. one the API added after + // this union was written. Consent was given, so this must not collapse + // into the withheld-consent state. + const row = factories.learnerProgress({ + outcomes_shared: true, + completion_status: "some_future_status" as unknown as CompletionStatus, + }) + expect(getDisplayStatus(row)).toBe("unknown") + }) + + test("every display status has a label", () => { + expect(Object.values(DISPLAY_STATUS_LABEL)).toEqual([ + "Not started", + "In progress", + "Completed", + "Certificate", + "No consent given", + "Unknown", + ]) + }) +}) diff --git a/frontends/main/src/app-pages/ContractLearnersPage/statusDisplay.ts b/frontends/main/src/app-pages/ContractLearnersPage/statusDisplay.ts new file mode 100644 index 0000000000..fa1505d9b0 --- /dev/null +++ b/frontends/main/src/app-pages/ContractLearnersPage/statusDisplay.ts @@ -0,0 +1,59 @@ +import type { LearnerProgress } from "api/analytics-hooks/organizations" + +/** + * What the status pill shows, mapped directly from the API's + * `completion_status`: `passed` reads "Completed", `certified` reads + * "Certificate". + * + * This used to collapse `passed` into "Certificate" for verified enrollments, + * proxying "will this course issue a certificate" off enrollment track. That + * proxy over-fired — a verified learner in a course with no certificate track + * still read "Certificate" — and it also meant the Status filter's "Completed" + * option could return a results table where every row said "Certificate", + * which read as broken. Restore the proxy only once the real signal (MITx + * Online's `Course.certificate_page`) lands via `ol-analytics-api` — see + * mitxonline#3958 and ol-analytics-api#58. + */ +type DisplayStatus = + | "not-started" + | "in-progress" + | "completed" + | "certificate" + | "not-shared" + | "unknown" + +const DISPLAY_STATUS_LABEL: Record = { + "not-started": "Not started", + "in-progress": "In progress", + completed: "Completed", + certificate: "Certificate", + "not-shared": "No consent given", + unknown: "Unknown", +} + +const getDisplayStatus = (row: LearnerProgress): DisplayStatus => { + // Consent is checked before the status itself: the API nulls the outcome + // fields when it is withheld, so a null here is ambiguous on its own. + if (!row.outcomes_shared) return "not-shared" + + switch (row.completion_status) { + case "certified": + return "certificate" + case "passed": + return "completed" + case "in_progress": + return "in-progress" + case "not_started": + return "not-started" + default: + // A row that consented but carries a `completion_status` outside the + // four known values — e.g. a value the API added after this type was + // written. Distinct from "not-shared": that's a consent state, this is + // an unrecognized one, and conflating them would misreport a consenting + // learner as having withheld consent. + return "unknown" + } +} + +export { getDisplayStatus, DISPLAY_STATUS_LABEL } +export type { DisplayStatus } diff --git a/frontends/main/src/app-pages/DashboardPage/AnalyticsContent.test.tsx b/frontends/main/src/app-pages/DashboardPage/AnalyticsContent.test.tsx index 9aa6dc9f2e..512a3d01b4 100644 --- a/frontends/main/src/app-pages/DashboardPage/AnalyticsContent.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/AnalyticsContent.test.tsx @@ -14,7 +14,11 @@ import { useFeatureFlagEnabled } from "posthog-js/react" import { allowConsoleErrors } from "ol-test-utilities" import { ForbiddenError } from "@/common/errors" import { FeatureFlags } from "@/common/feature_flags" -import { contractAdminView, organizationAnalyticsView } from "@/common/urls" +import { + contractAdminView, + contractLearnersView, + organizationAnalyticsView, +} from "@/common/urls" import { useFeatureFlagsLoaded } from "@/common/useFeatureFlagsLoaded" import { SUPPRESSED_LEGEND } from "./Analytics/format" import AnalyticsContent from "./AnalyticsContent" @@ -894,6 +898,44 @@ describe("AnalyticsContent, contract-scoped", () => { ) }) + test("'Learner analytics' targets the contract being viewed", async () => { + const [first, second] = [ + factories.contracts.contract(), + factories.contracts.contract(), + ] + const org = orgWithUuid({ contracts: [first, second] }) + setManagerOrgs([org]) + + setContractAnalyticsResponses(String(second.id)) + + const orgSlug = org.slug.replace(/^org-/, "") + renderWithProviders( + , + ) + + const link = await screen.findByRole("link", { name: "Learner analytics" }) + expect(link).toHaveAttribute( + "href", + contractLearnersView(orgSlug, second.slug), + ) + }) + + test("hides 'Learner analytics' on the org-wide aggregate page", async () => { + // learner-progress is contract-scoped only, so there is nowhere for this + // button to point without a contract in view. + const org = orgWithUuid() + setManagerOrgs([org]) + setAnalyticsResponses() + const orgSlug = org.slug.replace(/^org-/, "") + + renderWithProviders() + + await screen.findByText("Analytics") + expect( + screen.queryByRole("link", { name: "Learner analytics" }), + ).not.toBeInTheDocument() + }) + test("hides the Manage seats button when the manager-dashboard flag is off", async () => { // The button links to ContractAdminPage, which throws ForbiddenError // without this flag — surfacing the button here without it would send a diff --git a/frontends/main/src/app-pages/DashboardPage/AnalyticsContent.tsx b/frontends/main/src/app-pages/DashboardPage/AnalyticsContent.tsx index 5e6325daa7..cc0bf8c6bb 100644 --- a/frontends/main/src/app-pages/DashboardPage/AnalyticsContent.tsx +++ b/frontends/main/src/app-pages/DashboardPage/AnalyticsContent.tsx @@ -31,7 +31,11 @@ import { matchOrganizationBySlug } from "@/common/utils" import { ForbiddenError } from "@/common/errors" import { FeatureFlags } from "@/common/feature_flags" import { useFeatureFlagsLoaded } from "@/common/useFeatureFlagsLoaded" -import { contractAdminView, organizationAnalyticsView } from "@/common/urls" +import { + contractAdminView, + contractLearnersView, + organizationAnalyticsView, +} from "@/common/urls" import { ErrorContent } from "../ErrorPage/ErrorPageTemplate" import graduateLogo from "@/public/images/dashboard/graduate.png" import ContentEngagementTable from "./Analytics/ContentEngagementTable" @@ -73,6 +77,28 @@ const HeaderSection = styled.div(({ theme }) => ({ }, })) +const HeaderActions = styled.div(({ theme }) => ({ + display: "flex", + gap: "12px", + flexShrink: 0, + flexWrap: "wrap", + justifyContent: "flex-end", + "> a": { + whiteSpace: "nowrap", + }, + [theme.breakpoints.down("md")]: { + width: "100%", + justifyContent: "flex-start", + }, + [theme.breakpoints.down("sm")]: { + flexDirection: "column", + width: "100%", + "> a": { + width: "100%", + }, + }, +})) + const OrgDetailsContainer = styled.div({ display: "flex", alignItems: "center", @@ -469,15 +495,28 @@ const AnalyticsContentInternal: React.FC = ({
- {manageSeatsSlug && managerDashboardFlag ? ( - - Manage seats - - ) : null} + {(contract || (manageSeatsSlug && managerDashboardFlag)) && ( + + {contract ? ( + + Learner analytics + + ) : null} + {manageSeatsSlug && managerDashboardFlag ? ( + + Manage seats + + ) : null} + + )} ) diff --git a/frontends/main/src/app-pages/DashboardPage/ContractContent.tsx b/frontends/main/src/app-pages/DashboardPage/ContractContent.tsx index 00fb92db36..e9e9081636 100644 --- a/frontends/main/src/app-pages/DashboardPage/ContractContent.tsx +++ b/frontends/main/src/app-pages/DashboardPage/ContractContent.tsx @@ -28,7 +28,11 @@ import { useFeatureFlagEnabled } from "posthog-js/react" import { ErrorContent } from "../ErrorPage/ErrorPageTemplate" import { matchOrganizationBySlug, stripOrgPrefix } from "@/common/utils" import { FeatureFlags } from "@/common/feature_flags" -import { contractAdminView, contractAnalyticsView } from "@/common/urls" +import { + contractAdminView, + contractAnalyticsView, + contractLearnersView, +} from "@/common/urls" import { ResourceType, getKey } from "./CoursewareDisplay/helpers" import type { DashboardCourseEntry } from "./CoursewareDisplay/model/dashboardViewModel" import { useContractDashboardData } from "./CoursewareDisplay/hooks/useContractDashboardData" @@ -371,22 +375,31 @@ const ContractHeaderSection = styled.div(({ theme }) => ({ })) /** - * Two buttons where there used to be one, which is why `flex-shrink` and - * `white-space` are set rather than left to default. `ContractHeaderSection` - * only stacks below `sm`, but the dashboard grid stays single-column until - * `md`, so between those two breakpoints these buttons share a row with the - * org logo, the org name and the contract name. Left shrinkable, flexbox takes - * them down toward min-content and breaks the labels across lines + * Three buttons where there used to be one, which is why `flex-shrink` and + * `white-space` are set rather than left to default. Left shrinkable, flexbox + * takes them toward min-content and breaks the labels across lines * ("View / analytics"); pinned, the header text reflows instead, which it can * afford to do. + * + * They wrap to their own row below `md` rather than `sm`. `md` is where the + * dashboard grid becomes single-column, and it was already the point at which + * two pinned buttons crowded the org logo, name and contract name; a third + * label as long as "View learner analytics" makes that row unworkable well + * before `sm`. */ const HeaderActions = styled.div(({ theme }) => ({ display: "flex", gap: "12px", flexShrink: 0, + flexWrap: "wrap", + justifyContent: "flex-end", "> a": { whiteSpace: "nowrap", }, + [theme.breakpoints.down("md")]: { + width: "100%", + justifyContent: "flex-start", + }, [theme.breakpoints.down("sm")]: { flexDirection: "column", width: "100%", @@ -482,6 +495,18 @@ const ContractContentInternal: React.FC = ({ View analytics )} + {analyticsEnabled && ( + + View learner analytics + + )} {managerDashboardFlag && ( = ({ children }) => { + return ( + + {children} + + ) +} + +export default Layout diff --git a/frontends/main/src/app/(site)/organization/[orgSlug]/contract/[contractSlug]/learners/page.tsx b/frontends/main/src/app/(site)/organization/[orgSlug]/contract/[contractSlug]/learners/page.tsx new file mode 100644 index 0000000000..03c769b56e --- /dev/null +++ b/frontends/main/src/app/(site)/organization/[orgSlug]/contract/[contractSlug]/learners/page.tsx @@ -0,0 +1,17 @@ +import type { AppPageProps } from "@/common/searchParams" +import React from "react" +import ContractLearnersPage from "@/app-pages/ContractLearnersPage/ContractLearnersPage" + +const Page: React.FC< + AppPageProps<"/organization/[orgSlug]/contract/[contractSlug]/learners"> +> = async ({ params }) => { + const resolved = await params + return ( + + ) +} + +export default Page diff --git a/frontends/main/src/common/errors.ts b/frontends/main/src/common/errors.ts index 5f5ac6ad39..44f1e0b168 100644 --- a/frontends/main/src/common/errors.ts +++ b/frontends/main/src/common/errors.ts @@ -3,4 +3,16 @@ */ class ForbiddenError extends Error {} -export { ForbiddenError } +/** + * Whether a rejected request was refused rather than broken. 401 and 403 are + * both treated as "not yours to see": the B2B pages reach their data through + * APISIX, which answers 401 for a session it cannot resolve and 403 for one it + * can, and either way the reader is shown the same access-denied page. + */ +const isForbiddenResponse = (error: unknown): boolean => { + const status = (error as { response?: { status?: number } } | null)?.response + ?.status + return status === 401 || status === 403 +} + +export { ForbiddenError, isForbiddenResponse } diff --git a/frontends/main/src/common/urls.ts b/frontends/main/src/common/urls.ts index 7874013e0d..3212b42972 100644 --- a/frontends/main/src/common/urls.ts +++ b/frontends/main/src/common/urls.ts @@ -117,6 +117,17 @@ export const CONTRACT_ANALYTICS_VIEW = "/dashboard/organization/[orgSlug]/contract/[contractSlug]/analytics" export const contractAnalyticsView = (orgSlug: string, contractSlug: string) => generatePath(CONTRACT_ANALYTICS_VIEW, { orgSlug, contractSlug }) +/** + * Outside `/dashboard` — and so without its sidebar — for the same reason as + * CONTRACT_ADMIN_VIEW: the learner table is too wide for the dashboard grid's + * content column, and this page reads as a console rather than a dashboard + * section. Contract-scoped only; the learner-progress endpoint has no org-wide + * form. + */ +export const CONTRACT_LEARNERS_VIEW = + "/organization/[orgSlug]/contract/[contractSlug]/learners" +export const contractLearnersView = (orgSlug: string, contractSlug: string) => + generatePath(CONTRACT_LEARNERS_VIEW, { orgSlug, contractSlug }) export const PROGRAM_VIEW = "/dashboard/program/[id]" export const programView = (id: number) => generatePath(PROGRAM_VIEW, { id: String(id) }) diff --git a/frontends/main/src/components/B2BTable/B2BTable.tsx b/frontends/main/src/components/B2BTable/B2BTable.tsx index 2ffe8ddcc1..fc03b94c57 100644 --- a/frontends/main/src/components/B2BTable/B2BTable.tsx +++ b/frontends/main/src/components/B2BTable/B2BTable.tsx @@ -161,11 +161,46 @@ const EmptyTableMessage = styled(Typography)(({ theme }) => ({ /** Placeholder for a value the API did not return. */ const STUB = "—" +const TableBody = styled("div", { + shouldForwardProp: (prop) => prop !== "$stale", +})<{ $stale: boolean }>(({ $stale }) => ({ + opacity: $stale ? 0.5 : 1, + transition: $stale ? "opacity 150ms ease 150ms" : "none", +})) + +const AriaDisabledButtonWrapper = styled.div(({ theme }) => ({ + "> button[aria-disabled='true']": { + cursor: "default", + backgroundColor: theme.custom.colors.lightGray2, + border: `1px solid ${theme.custom.colors.lightGray2}`, + color: theme.custom.colors.silverGrayDark, + ":hover": { + backgroundColor: theme.custom.colors.lightGray2, + color: theme.custom.colors.silverGrayDark, + }, + }, +})) + +const csvCell = (value: string | null | undefined): string => { + const text = value ?? "" + const safeText = /^[=+\-@\t\r\n]/.test(text) ? `'${text}` : text + return /[",\r\n]/.test(safeText) + ? `"${safeText.replace(/"/g, '""')}"` + : safeText +} + +const buildCsvRow = (values: (string | null | undefined)[]): string => + values.map(csvCell).join(",") + export { + AriaDisabledButtonWrapper, + buildCsvRow, CellText, + csvCell, EmptyTableMessage, MobileLabel, STUB, + TableBody, TableCard, tableCardInnerWidth, TableCell, diff --git a/frontends/ol-components/src/index.ts b/frontends/ol-components/src/index.ts index 62ed4dad15..39c5eb083c 100644 --- a/frontends/ol-components/src/index.ts +++ b/frontends/ol-components/src/index.ts @@ -93,6 +93,15 @@ export type { TooltipProps } from "@mui/material/Tooltip" export { default as Avatar } from "@mui/material/Avatar" // Mui Form Inputs +/** + * Prefer `Checkbox` from `@mitodl/smoot-design`, which carries the design + * system's styling. This one exists for the cases that component's props do + * not cover: it takes no `aria-label`/`id`, so it cannot name a checkbox that + * has no visible label, and it has no `indeterminate` for a partial + * select-all. + */ +export { default as MuiCheckbox } from "@mui/material/Checkbox" +export type { CheckboxProps as MuiCheckboxProps } from "@mui/material/Checkbox" export { default as Autocomplete } from "@mui/material/Autocomplete" export type { AutocompleteProps } from "@mui/material/Autocomplete" export { default as ToggleButton } from "@mui/material/ToggleButton" From 8ab8b5de7989596dd7cab0f02c86c3a098b07e2d Mon Sep 17 00:00:00 2001 From: Sar <1447295+shaidar@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:59:00 -0500 Subject: [PATCH 5/7] fix(news): escape interpolated text/attrs when extracting news summaries (#3972) * fix(news): escape interpolated text/attrs when extracting news summaries _extract_text_from_paragraph built anchor tags (and returned plain text) by directly interpolating editor-supplied text, href, target, and rel into an f-string, with no escaping and no restriction on href's scheme. Since this text is stored in FeedItem.summary and rendered on the public /news page via dangerouslySetInnerHTML, any account in the website_content_editors group (a real, non-admin role) could inject arbitrary HTML/JS -- either by breaking out of the href attribute (e.g. `" onmouseover="...`), by using a javascript: URI, or simply via plain paragraph text containing a literal `', + "rel": '" onmouseover="alert(2)', + }, + } + ], + } + ], + } + ], + } + ], + } + + result = articles_news.extract_summary_from_banner(content_json) + + # The exact-match below is the real assertion: it confirms the hostile + # target/rel values are safely quoted inside their own attribute values + # rather than breaking out to add a real onmouseover attribute -- the + # literal word "onmouseover" legitimately still appears in the escaped + # text, so checking for its absence would be wrong. + assert "", + } + ], + } + ], + } + ], + } + + result = articles_news.extract_summary_from_banner(content_json) + + assert "