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..729cb1bc0e 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 ?? "learner"}, ${row.courserun_title}`,
}}
/>
@@ -203,10 +212,13 @@ const LearnerRow: React.FC = ({ row }) => {
- {row.full_name ? initials(row.full_name) : "?"}
+ {name ? initials(name) : }
- {name}
+ {name && {name}}
+ {row.email && (
+ {row.email}
+ )}
{row.courserun_title}