feat: add B2B contract learner directory page - #3958
Conversation
Adds the per-contract learner directory at /organization/:orgSlug/contract/:contractSlug/learners, listing one row per learner per course run with status, search, filtering, and CSV export backed by the learner-progress analytics endpoint. Progress, last activity, and bulk "Send reminder" are built but left disabled pending real backend fields and a reminder endpoint for enrolled learners. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
OpenAPI ChangesNo changes detected Unexpected changes? Ensure your branch is up-to-date with |
There was a problem hiding this comment.
🟡 Changes recommended
CSV security and several filtering, status, export, and error-state behaviors remain incorrect.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Adds a feature-flagged, contract-scoped learner directory for B2B managers.
Changes:
- Adds learner metrics, filtering, pagination, and CSV export.
- Extends the analytics client with learner-progress APIs.
- Links contract dashboards to the new route and adds tests.
File summaries
| File | Description |
|---|---|
frontends/ol-components/src/index.ts |
Exports MUI checkbox utilities. |
frontends/main/src/components/B2BTable/B2BTable.tsx |
Adds shared table and CSV helpers. |
frontends/main/src/common/urls.ts |
Defines the learner-directory route. |
frontends/main/src/common/errors.ts |
Adds authorization-response detection. |
frontends/main/src/app/(site)/organization/[orgSlug]/contract/[contractSlug]/learners/page.tsx |
Registers the learner page. |
frontends/main/src/app/(site)/organization/[orgSlug]/contract/[contractSlug]/learners/layout.tsx |
Restricts route access. |
frontends/main/src/app-pages/DashboardPage/ContractContent.tsx |
Links contract cards to learners. |
frontends/main/src/app-pages/DashboardPage/AnalyticsContent.tsx |
Links analytics to learners. |
frontends/main/src/app-pages/DashboardPage/AnalyticsContent.test.tsx |
Tests the analytics link. |
frontends/main/src/app-pages/ContractLearnersPage/statusDisplay.ts |
Maps learner statuses for display. |
frontends/main/src/app-pages/ContractLearnersPage/statusDisplay.test.ts |
Tests status mapping. |
frontends/main/src/app-pages/ContractLearnersPage/ProgressBar.tsx |
Adds a future progress indicator. |
frontends/main/src/app-pages/ContractLearnersPage/placeholders.ts |
Defines disabled placeholder data. |
frontends/main/src/app-pages/ContractLearnersPage/LearnerRow.tsx |
Renders learner table rows. |
frontends/main/src/app-pages/ContractLearnersPage/ContractLearnersPage.tsx |
Implements the learner directory. |
frontends/main/src/app-pages/ContractLearnersPage/ContractLearnersPage.test.tsx |
Tests directory behavior. |
frontends/main/src/app-pages/ContractLearnersPage/columns.ts |
Defines table column sizing. |
frontends/api/src/test-utils/mockAxios.ts |
Preserves parameter serialization in mocks. |
frontends/api/src/analytics/types.ts |
Adds learner-progress types. |
frontends/api/src/analytics/test-utils/urls.ts |
Adds learner-progress mock URLs. |
frontends/api/src/analytics/test-utils/factories.ts |
Adds learner test factories. |
frontends/api/src/analytics/hooks/organizations/queries.ts |
Adds learner-progress queries. |
frontends/api/src/analytics/hooks/organizations/index.ts |
Exports learner analytics types. |
frontends/api/src/analytics/clients.ts |
Adds the learner-progress API client. |
Review details
Suppressed comments (3)
frontends/main/src/app-pages/ContractLearnersPage/ContractLearnersPage.tsx:710
canQueryis also false when analytics is configured but this organization lackssso_organization_id, so this message incorrectly blames the environment in that case. Match the sibling analytics page by distinguishing an unavailable organization from an unconfigured environment.
{!canQuery ? (
<Typography variant="body1">
Learner analytics is not available in this environment.
</Typography>
frontends/main/src/app-pages/ContractLearnersPage/ContractLearnersPage.tsx:685
- This link points to the contract-scoped analytics route, whose heading is
Analytics · <contract name>; calling it “Program analytics” misidentifies the destination. Use “Contract analytics” (or simply “Analytics”) so the link purpose is accurate.
Program analytics
frontends/main/src/app-pages/ContractLearnersPage/LearnerRow.tsx:227
- This disabled block is not actually one-uncomment-away:
needsAttentionhas no declaration or prop inLearnerRow, and the adjacent disabled cells likewise reference undeclaredprogressandlastActiveOn; the page's disabled module JSX also references undeclaredmoduleFilter/setModuleFilter. Uncommenting the documented blocks therefore does not compile, contrary to the PR description. Either add the missing scaffolding or describe these as partial placeholders rather than fully wired features.
{/*
{needsAttention ? (
<NeedsAttention
component="span"
{...{ [PLACEHOLDER_ATTR]: "needs-attention" }}
>
Needs attention
</NeedsAttention>
) : null}
- Files reviewed: 24/24 changed files
- Comments generated: 8
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Enhance CSV cell formatting to handle special characters and prevent formula injection. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
…able The CSV export wrote the raw completion_status enum instead of the same display label LearnerRow shows on screen, so e.g. a verified "passed" row read "Certificate" in the table but exported as "passed". Export now reuses getDisplayStatus/DISPLAY_STATUS_LABEL. Export also ignored canQuery, so in an environment without analytics configured the button stayed clickable and surfaced a misleading generic failure instead of doing nothing. handleExport and the button's aria-disabled state now both gate on canQuery. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… filter empty state
|
Also, regardless of the filter I use on this Dashboard, the Export Learners button always seems to produce a CSV with the total amount of learners, not the filtered list. |
Totally fair feedback - I was following the prototype here https://learn.mit.dev/organization/test-university/contract/test-university-contract/learners but I can see how this could be confusing for the user. The table status is confusing so I'm going to stop collapsing passed+verified into Certificate in learner status. I like to tackle the tile mismatch in the next round as not to hold this up. @Ferdi two suggestions that come to mind is 1. break out certificate into it's own tile or 2. Add a information icon with a hover explaining that this has been rolled up into one value. Maybe changing the label to Completed/Certificate |
This was intentional the thought was if they are exporting the CSV they will likely want to do the filtering in excel or whatever system they might be uploading this data to. |
Alright, in that case, if we don't intend to match the CSV to the filtered list, the checkbox in your PR (the one that says "Export learners downloads a matching CSV") should be updated to reflect this. |
|
@daniellefrappier18 whenever I see an export to CSV interface, it makes me wonder what it is that we're solving for? It also raises a lot of questions around data governance, because as soon as that data is exported we're completely blind to it. Separately, we're adding an integration API for being able to retrieve the per-learner records, with a batched CSV export to S3 so I just want to make sure that we're not adding extra data export surfaces beyond what we want to support. |
blarghmatey
left a comment
There was a problem hiding this comment.
Read this against ol-analytics-api's b2b_dashboard tenant and the service's deployed Pulumi config. The shape is right, and the discipline about not shipping a field with nothing behind it is worth keeping. Two things below are wrong rather than incomplete; the rest are inline.
The summary tiles cost five requests per page load where the envelope should carry the counts. The API side is mitodl/ol-analytics-api#68, open now. Nothing here blocks on it landing, but the tile code should be written expecting to collapse to one request, and there is a behaviour decision in it for you (inline below).
@blarghmatey Splitting this into replies.
|
|
So, the integration API is gated behind a machine-to-machine auth path. I guess my question is to Ferdi about what the goal of a CSV export is from the dashboard and how it should be managed from an authorization perspective. Not a hard blocker from me, just something that I like to get clarity on because it often comes back to bite us in terms of performance issues and the potential for data exposure. |
|
|
@Ferdi After talking with @blarghmatey, I realized I was wrong about why he asked about the CSV export. A parallel effort already exposes this data to LLMs through the b2b_learner_records API, so a CSV export here would be redundant. Should we remove this button and the underlying logic entirely? |


What are the relevant tickets?
N/A — no mit-learn issue tracks this directly.
learner-progressendpoint this page queries. Stacked on mitodl/ol-analytics-api#56, which added thecompletion_statusfield derived from certificate/grade data.Description (What does it do?)
Adds a per-contract learner directory at
/organization/:orgSlug/contract/:contractSlug/learners, linked from a new View learner analytics button on each contract's card in the org dashboard.One row per learner per course run: name, course, status pill, search, status filter, CSV export, and four summary tiles (Enrollments / Not started / In progress / Completed).
This is additive to the aggregate analytics dashboard (
AnalyticsContent), not a replacement.This matches the shape of Ferdi's prototype at mit-learn-prototypes.vercel.app/b2b-stats/learners, but ships a deliberately smaller surface — only what a real backend field supports today. Compared to the prototype, this PR does not render:
blockcompletiondata-platform aggregation exists yet, and "what counts as a lesson" isn't decided.LearnerProgress.last_active_onis hard-codednullby the API today.needs_attentionfield exists yet.?module=param) —ol-analytics-apidoesn't implement filtering bycourserun_readable_id; the param would be silently dropped, so wiring it up would look like it filters and wouldn't.Every disabled item above is fully built and wired, just commented out rather than deleted, so nothing fabricated ships while each stays one uncomment away from shipping. See ContractLearnersPage.tsx's file header and placeholders.ts for exactly what unblocks each one.
Screen Recording
Screen.Recording.2026-09-17.at.4.06.45.PM.mov
Screenshots
Desktop


Mobile
How can this be tested?
This assumes your are running Local dev for this branch runs on the Tilt/k3d stack (
~/Desktop/work/ol-infrastructure), notdocker compose.Prerequisite: mitxonline manager access. This part is real and required — ContractLearnersPage won't resolve without a mitxonline user that has is_manager=True on some org with at least one contract.
1. Confirm the analytics-api stub is running.
ContractLearnersPage's learner data comes from a local-dev-only stub (realol-analytics-apireads dbt-materialized views out of StarRocks, which isn't realistic to run in k3d) — not from anything you set up in mitxonline. The stub ignores the org UUID/contract ID entirely and always returns the same 48 fixture learners, so you're not "generating" analytics data in step 2, just satisfying the real authorization check that gates the page.It lives on a throwaway (not-for-merge) branch: mitodl/ol-infrastructure#5788 ("add analytics-api stub for testing B2B dashboard PRs", branch
daniellef/analytics-api-stub), kept open just so it's easy to pull in for local testing like this.tilt_config.json'senabled_appsalready lists"analytics-api", so once you've checked out that branch (or applied its Tiltfile app to yours),tilt updeploys it automatically.If nothing's running, either check out
daniellef/analytics-api-stuband re-runtilt up, or apply the manifest below directly (same source as that branch, just as plain k8s YAML instead of a Tilt-managed app):analytics-api-stub.yaml (kubectl apply -f -)
b2b-analytics-dashboardPostHog feature flag for your account, sign in athttps://learn.mit.dev, open the org, and click View learner analytics on the contract's card (or go directly to/organization/pr-test-org/contract/<contract-slug>/learners).