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"