Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
d939457
feat: add B2B contract learner directory page
daniellefrappier18 Sep 17, 2026
29fe2e6
Improve csvCell function for better CSV handling
daniellefrappier18 Sep 18, 2026
4e6ce0a
Update useQuery to include throwOnError option
daniellefrappier18 Sep 18, 2026
ac5c9c9
fix error boundary for a 5xx error
daniellefrappier18 Sep 18, 2026
bd6cb76
fix CSV export status label and disable export when analytics unavail…
daniellefrappier18 Sep 18, 2026
959753d
comment updates and formating fix
daniellefrappier18 Sep 18, 2026
dfb9ccd
Fix a11y feedback on disabled export, unknown-status mislabeling, and…
daniellefrappier18 Sep 18, 2026
f16b2b6
Stop collapsing passed+verified into Certificate in learner status
daniellefrappier18 Sep 22, 2026
15f0b65
Merge Certificate into Completed filter to match the tile count
daniellefrappier18 Sep 22, 2026
19b91b5
Drop include_inactive override to match API default
daniellefrappier18 Sep 22, 2026
6d2b9dc
Surface learner-progress freshness via shared SectionHeader
daniellefrappier18 Sep 22, 2026
fc36e35
Document why the consent filter option is currently inert
daniellefrappier18 Sep 22, 2026
4004079
Make CSV export honor the on-screen search and status filter
daniellefrappier18 Sep 22, 2026
da064c1
Add headroom to CSV export page size and cap search length
daniellefrappier18 Sep 22, 2026
ebefec3
remove courserun_readable_id from the live LearnerProgressParams type
daniellefrappier18 Sep 22, 2026
b271159
Drop unused mockFunnel
daniellefrappier18 Sep 22, 2026
90866cd
Merge branch 'main' into daniellef/feat-learners-analytics-dashboard
daniellefrappier18 Sep 22, 2026
66f882a
Remove summary tiles from the learners dashboard
daniellefrappier18 Sep 23, 2026
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
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
Loading
Loading