From 60aafe8192389d97a5fb27325b880726380c26c7 Mon Sep 17 00:00:00 2001 From: Danielle Frappier Date: Fri, 25 Sep 2026 09:38:29 -0400 Subject: [PATCH 1/2] fix(b2b): show learner email and icon avatar when name is blank The learner-progress API returns full_name as "" (not null) for learners who never set a profile name. LearnerRow's `??` fallback only caught null, so those rows rendered an empty name and a "?" avatar. - Treat null, empty, and whitespace-only names as missing - Always show the learner's email under the name - Show a generic person icon instead of "?" when there is no name Follow-up to #3958. Co-Authored-By: Claude Opus 5.5 --- .../ContractLearnersPage.test.tsx | 61 +++++++++++++++++++ .../ContractLearnersPage/LearnerRow.tsx | 24 ++++++-- 2 files changed, 79 insertions(+), 6 deletions(-) diff --git a/frontends/main/src/app-pages/ContractLearnersPage/ContractLearnersPage.test.tsx b/frontends/main/src/app-pages/ContractLearnersPage/ContractLearnersPage.test.tsx index 8e8e561c96..c56740a3e9 100644 --- a/frontends/main/src/app-pages/ContractLearnersPage/ContractLearnersPage.test.tsx +++ b/frontends/main/src/app-pages/ContractLearnersPage/ContractLearnersPage.test.tsx @@ -212,6 +212,67 @@ describe("ContractLearnersPage", () => { expect(within(row).getByText("Certificate")).toBeInTheDocument() }) + test("a learner row shows the email alongside the name", 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", + email: "anton@example.com", + courserun_title: "Module 5", + }), + ]) + + renderWithProviders( + , + ) + + const name = await screen.findByText("Anton Petrov") + const row = rowOf(name) + expect(within(row).getByText("anton@example.com")).toBeInTheDocument() + expect(within(row).getByText("AP")).toBeInTheDocument() + }) + + test.each([ + { fullName: null, label: "null" }, + { fullName: "", label: "empty" }, + { fullName: " ", label: "whitespace-only" }, + ])( + "a learner with a $label name shows only the email and an icon avatar", + async ({ fullName }) => { + 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: fullName, + email: "x7k2m@example.com", + courserun_title: "Module 5", + }), + ]) + + renderWithProviders( + , + ) + + const email = await screen.findByText("x7k2m@example.com") + const row = rowOf(email) + expect(within(row).getByText("Module 5")).toBeInTheDocument() + expect(within(row).queryByText("?")).not.toBeInTheDocument() + expect(within(row).queryByText("Unknown learner")).not.toBeInTheDocument() + expect(row.querySelector("[aria-hidden='true'] svg")).not.toBeNull() + }, + ) + test("a learner who withheld consent shows No consent given", async () => { const { org, contract, orgSlug } = setup() const contractId = String(contract.id) diff --git a/frontends/main/src/app-pages/ContractLearnersPage/LearnerRow.tsx b/frontends/main/src/app-pages/ContractLearnersPage/LearnerRow.tsx index 8d9faaa0a1..91f5c90c54 100644 --- a/frontends/main/src/app-pages/ContractLearnersPage/LearnerRow.tsx +++ b/frontends/main/src/app-pages/ContractLearnersPage/LearnerRow.tsx @@ -1,6 +1,7 @@ "use client" import React from "react" +import { RiUserLine } from "@remixicon/react" import { styled, Typography } from "ol-components" import { initials } from "ol-utilities" import type { LearnerProgress } from "api/analytics-hooks/organizations" @@ -68,8 +69,9 @@ import { COLUMN_FLEX } from "./columns" // -------------------------------------------------------------------------- /** - * Initials, not a photo: the analytics API returns no avatar image, and a name - * is the only identity it carries. + * Initials, not a photo: the analytics API returns no avatar image. Without a + * name, a generic person icon — email-derived initials are wrong for addresses + * like `jdoe@` or `x7k2m@`. */ const Avatar = styled.div(({ theme }) => ({ display: "flex", @@ -97,6 +99,13 @@ const LearnerName = styled(Typography)(({ theme }) => ({ textOverflow: "ellipsis", })) as typeof Typography +const LearnerEmail = styled(Typography)(({ theme }) => ({ + ...theme.typography.body3, + color: theme.custom.colors.darkGray2, + overflow: "hidden", + textOverflow: "ellipsis", +})) as typeof Typography + const CourseTitle = styled(Typography)(({ theme }) => ({ ...theme.typography.body3, color: theme.custom.colors.silverGrayDark, @@ -184,7 +193,7 @@ 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" + const name = row.full_name?.trim() || null return ( @@ -195,7 +204,7 @@ const LearnerRow: React.FC = ({ row }) => { checked={selected} onChange={() => onToggleSelect(rowId)} inputProps={{ - "aria-label": `Select ${name}, ${row.courserun_title}`, + "aria-label": `Select ${name ?? row.email}, ${row.courserun_title}`, }} /> @@ -203,10 +212,13 @@ const LearnerRow: React.FC = ({ row }) => { - {name} + {name && {name}} + {row.email && ( + {row.email} + )} {row.courserun_title} From 287c1e5df5ba9e0d60fbfcf18fe373fc6e7a708a Mon Sep 17 00:00:00 2001 From: Danielle Frappier Date: Fri, 25 Sep 2026 10:07:23 -0400 Subject: [PATCH 2/2] Update aria-label for learner selection Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../main/src/app-pages/ContractLearnersPage/LearnerRow.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontends/main/src/app-pages/ContractLearnersPage/LearnerRow.tsx b/frontends/main/src/app-pages/ContractLearnersPage/LearnerRow.tsx index 91f5c90c54..729cb1bc0e 100644 --- a/frontends/main/src/app-pages/ContractLearnersPage/LearnerRow.tsx +++ b/frontends/main/src/app-pages/ContractLearnersPage/LearnerRow.tsx @@ -204,7 +204,7 @@ const LearnerRow: React.FC = ({ row }) => { checked={selected} onChange={() => onToggleSelect(rowId)} inputProps={{ - "aria-label": `Select ${name ?? row.email}, ${row.courserun_title}`, + "aria-label": `Select ${name ?? row.email ?? "learner"}, ${row.courserun_title}`, }} />