Skip to content
Merged
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
10 changes: 10 additions & 0 deletions RELEASE.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
Release Notes
=============

Version 0.80.17
---------------

- fix: more design QA fixes for Organizational Learning page (#3986)
- fix(news): escape interpolated text/attrs when extracting news summaries (#3972)
- feat: add B2B contract learner directory page (#3958)
- 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
24 changes: 24 additions & 0 deletions frontends/api/src/analytics/clients.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import type {
ContractMonthlyEngagementTrend,
ContractUtilization,
EnrollmentCompletionFunnel,
LearnerProgressParams,
LearnerProgressResponse,
MonthlyEngagementTrend,
OrgAnalyticsResponse,
} from "./types"
Expand Down Expand Up @@ -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<AxiosResponse<LearnerProgressResponse>> =>
axiosInstance.get<LearnerProgressResponse>(
`${contractRoot(organizationId, contractId)}/learner-progress`,
{ params, signal, paramsSerializer: { indexes: null } },
),
}

export { analyticsOrganizationsApi, analyticsContractsApi, B2B_DASHBOARD_ROOT }
6 changes: 6 additions & 0 deletions frontends/api/src/analytics/hooks/organizations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,17 @@ export {

export type {
AnalyticsPageParams,
CompletionStatus,
CompletionStatusFilter,
ContentEngagementDepth,
ContractContentEngagementDepth,
ContractMonthlyEngagementTrend,
ContractUtilization,
EnrollmentCompletionFunnel,
LearnerProgress,
LearnerProgressParams,
LearnerProgressResponse,
LearnerProgressSort,
MonthlyEngagementTrend,
OrgAnalyticsResponse,
} from "../../types"
42 changes: 41 additions & 1 deletion frontends/api/src/analytics/hooks/organizations/queries.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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({
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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 {
Expand Down
66 changes: 66 additions & 0 deletions frontends/api/src/analytics/test-utils/factories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import type {
ContractMonthlyEngagementTrend,
ContractUtilization,
EnrollmentCompletionFunnel,
LearnerProgress,
LearnerProgressResponse,
MonthlyEngagementTrend,
OrgAnalyticsResponse,
} from "../types"
Expand Down Expand Up @@ -157,13 +159,77 @@ 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> = {},
): 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 =>
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> = {},
): 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,
contractMonthlyEngagementTrend,
contractUtilization,
enrollmentCompletionFunnel,
envelope,
learnerProgress,
learnerProgressEnvelope,
monthlyEngagementTrend,
organizationId,
withheldLearnerProgress,
}
11 changes: 9 additions & 2 deletions frontends/api/src/analytics/test-utils/urls.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -22,7 +22,7 @@ const contractResource = (
organizationId: string,
contractId: string,
resource: string,
params?: AnalyticsPageParams,
params?: AnalyticsPageParams | LearnerProgressParams,
) =>
`${getApiBaseUrl()}${B2B_DASHBOARD_ROOT}/organizations/${encodeURIComponent(
organizationId,
Expand Down Expand Up @@ -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 }
86 changes: 86 additions & 0 deletions frontends/api/src/analytics/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<LearnerProgress>`: 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[]
}
4 changes: 4 additions & 0 deletions frontends/api/src/test-utils/mockAxios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
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
Loading
Loading