Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions RELEASE.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
Release Notes
=============

Version 0.80.16
---------------

- fix: align Organizational Learning page with Figma design QA (#3971)
- feat: add hover explanation for Active learners, fix clipped month label in engagement chart (#3966)
- Track begin-checkout analytics for enrollments (#3959)

Version 0.80.15
---------------

Expand Down
2 changes: 1 addition & 1 deletion frontends/main/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 = {
Expand All @@ -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<{
Expand Down Expand Up @@ -152,7 +197,16 @@ const EngagementTrendChart: React.FC<{
<div aria-hidden>
<LineChart
height={CHART_HEIGHT}
margin={{ left: 8, right: 8, top: 8, bottom: 0 }}
/**
* `right: 20` works around a `shortenLabels` quirk in
* `@mui/x-charts`: for a point-scale axis, a tick's max label width
* is `2 * min(space to its left, space to its right)`. The last
* tick sits exactly at the drawing area's right edge, so that
* budget collapses to `2 * margin.right`. At the default 8, every
* month abbreviation gets ellipsized down to nothing; 20 leaves
* room for "Feb"/"Dec" to render in full.
*/
margin={{ left: 8, right: 20, top: 8, bottom: 0 }}
// Horizontal rules only: vertical ones would fight the marks for
// attention without helping anyone read a monthly value.
grid={{ horizontal: true }}
Expand Down Expand Up @@ -201,6 +255,20 @@ const EngagementTrendChart: React.FC<{
/>
</div>
<TableWrapper>
<MobileColumnHelp>
{SERIES.filter((series) => series.description).map((series) => (
<Tooltip key={series.key} title={series.description}>
{/* eslint-disable-next-line styled-components-a11y/no-noninteractive-tabindex */}
<InfoTrigger
aria-label={`${series.label}: ${series.description}`}
tabIndex={0}
>
{series.label}
<RiInformationLine aria-hidden="true" />
</InfoTrigger>
</Tooltip>
))}
</MobileColumnHelp>
<div role="table" aria-label="Monthly engagement">
<div role="rowgroup">
<TableHeaderRow role="row">
Expand All @@ -215,6 +283,14 @@ const EngagementTrendChart: React.FC<{
$numeric
>
{series.label}
{series.description ? (
<Tooltip title={series.description}>
{/* eslint-disable-next-line styled-components-a11y/no-noninteractive-tabindex */}
<InfoTrigger aria-label={series.description} tabIndex={0}>
<RiInformationLine aria-hidden="true" />
</InfoTrigger>
</Tooltip>
) : null}
</TableHeaderCell>
))}
</TableHeaderRow>
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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(<EngagementTrendChart rows={months} isLoading={false} />)

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(<EngagementTrendChart rows={months} isLoading={false} />)

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(<EngagementTrendChart rows={months} isLoading={false} />)

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(<EngagementTrendChart rows={[]} isLoading={false} />)
expect(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}))

/**
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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",
},
}))

Expand Down Expand Up @@ -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,
}))
Expand All @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
}))

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,11 @@ const FaqRow: React.FC<{ index: number; question: string; answer: string }> = ({
const panelId = `faq-panel-${index}`

return (
<FaqItem expanded={expanded} onChange={() => setExpanded(!expanded)}>
<FaqItem
disableGutters
expanded={expanded}
onChange={() => setExpanded(!expanded)}
>
<FaqSummary
id={headerId}
aria-controls={panelId}
Expand Down
Loading
Loading