Skip to content

Commit 3ea5436

Browse files
feat: show duplicate emails and stats summary in B2B seat assignment modal (#3509)
1 parent 1cb1fa1 commit 3ea5436

10 files changed

Lines changed: 1214 additions & 232 deletions

File tree

frontends/main/src/app-pages/ContractAdminPage/AssignSeatsConfirmModal.test.tsx

Lines changed: 296 additions & 82 deletions
Large diffs are not rendered by default.

frontends/main/src/app-pages/ContractAdminPage/AssignSeatsConfirmModal.tsx

Lines changed: 575 additions & 76 deletions
Large diffs are not rendered by default.

frontends/main/src/app-pages/ContractAdminPage/AssignSeatsSection.test.tsx

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,9 @@ describe("AssignSeatsSection", () => {
237237
await user.click(screen.getByRole("button", { name: "Assign Seats" }))
238238

239239
expect(
240-
await screen.findByRole("heading", { name: /email.*ready to assign/i }),
240+
await screen.findByRole("heading", {
241+
name: /ready to send invitations/i,
242+
}),
241243
).toBeInTheDocument()
242244
})
243245

@@ -277,13 +279,13 @@ describe("AssignSeatsSection", () => {
277279
const textarea = screen.getByPlaceholderText(/enter employee emails/i)
278280
await user.type(textarea, "alice@example.com")
279281
await user.click(screen.getByRole("button", { name: "Assign Seats" }))
280-
await screen.findByRole("heading", { name: /email.*ready to assign/i })
282+
await screen.findByRole("heading", { name: /ready to send invitations/i })
281283

282284
await user.click(screen.getByRole("button", { name: /cancel/i }))
283285

284286
await waitFor(() =>
285287
expect(
286-
screen.queryByRole("heading", { name: /email.*ready to assign/i }),
288+
screen.queryByRole("heading", { name: /ready to send invitations/i }),
287289
).not.toBeInTheDocument(),
288290
)
289291
})
@@ -305,7 +307,9 @@ describe("AssignSeatsSection", () => {
305307
await user.upload(fileInput, file)
306308

307309
expect(
308-
await screen.findByRole("heading", { name: /2 emails ready to assign/i }),
310+
await screen.findByRole("heading", {
311+
name: /ready to send invitations/i,
312+
}),
309313
).toBeInTheDocument()
310314
expect(screen.getByPlaceholderText(/enter employee emails/i)).toHaveValue(
311315
"",
@@ -391,6 +395,29 @@ describe("AssignSeatsSection", () => {
391395
).not.toBeInTheDocument()
392396
})
393397

398+
test("assertive live region announces CSV error text for screen readers", async () => {
399+
renderWithProviders(
400+
<AssignSeatsSection orgId={1} contractId={2} availableSeats={50} />,
401+
)
402+
403+
const csvContent = "Email\nNot An Email\nbad@"
404+
mockFileContent = csvContent
405+
const file = new File([csvContent], "emails.csv", { type: "text/csv" })
406+
407+
const fileInput = document.querySelector(
408+
"input[type='file']",
409+
) as HTMLInputElement
410+
await user.upload(fileInput, file)
411+
412+
await screen.findByRole("alert")
413+
await waitFor(() => {
414+
const assertiveRegion = document.querySelector("[aria-live='assertive']")
415+
expect(assertiveRegion).toHaveTextContent(
416+
/no valid email addresses found in this file/i,
417+
)
418+
})
419+
})
420+
394421
test("accepts newline-separated emails", async () => {
395422
renderWithProviders(
396423
<AssignSeatsSection orgId={1} contractId={2} availableSeats={50} />,
@@ -414,7 +441,9 @@ describe("AssignSeatsSection", () => {
414441
await user.click(textarea)
415442
await user.paste(emails)
416443
await user.click(screen.getByRole("button", { name: "Assign Seats" }))
417-
await user.click(screen.getByRole("button", { name: /send 2 emails/i }))
444+
await user.click(
445+
screen.getByRole("button", { name: /send 2 invitations/i }),
446+
)
418447
}
419448

420449
test("assigns seats and shows a success alert, clearing the input", async () => {

frontends/main/src/app-pages/ContractAdminPage/AssignSeatsSection.tsx

Lines changed: 150 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -67,24 +67,26 @@ const DisabledLink = styled.span(({ theme }) => ({
6767
opacity: 0.5,
6868
}))
6969

70-
const ValidationBadge = styled.div(({ theme }) => ({
71-
display: "inline-flex",
72-
gap: "24px",
73-
backgroundColor: theme.custom.colors.lightGray1,
74-
border: `1px solid ${theme.custom.colors.lightGray2}`,
75-
borderRadius: "4px",
76-
padding: "8px 16px",
77-
...theme.typography.subtitle3,
78-
fontWeight: theme.typography.fontWeightMedium as number,
79-
}))
80-
81-
const ValidCount = styled.span(({ theme }) => ({
82-
color: theme.custom.colors.darkGreen,
83-
}))
84-
85-
const InvalidCount = styled.span(({ theme }) => ({
86-
color: theme.custom.colors.darkRed,
87-
}))
70+
const CountBadge = styled.div<{ $variant: "valid" | "warning" | "default" }>(
71+
({ theme, $variant }) => ({
72+
display: "inline-flex",
73+
backgroundColor:
74+
$variant === "warning"
75+
? theme.custom.colors.white
76+
: theme.custom.colors.lightGray1,
77+
border: `1px solid ${$variant === "warning" ? theme.custom.colors.darkRed : theme.custom.colors.lightGray2}`,
78+
borderRadius: "4px",
79+
padding: "8px 16px",
80+
...theme.typography.subtitle3,
81+
fontWeight: theme.typography.fontWeightMedium as number,
82+
color:
83+
$variant === "valid"
84+
? theme.custom.colors.darkGreen
85+
: $variant === "warning"
86+
? theme.custom.colors.darkRed
87+
: theme.custom.colors.darkGray2,
88+
}),
89+
)
8890

8991
/**
9092
* Wrapper for the textarea + highlight overlay. We use a custom textarea here
@@ -145,7 +147,7 @@ const EmailTextarea = styled("textarea")<{ $transparent: boolean }>(
145147
background: "transparent",
146148
border: "none",
147149
outline: "none",
148-
resize: "none",
150+
resize: "vertical",
149151
fontFamily: "inherit",
150152
fontSize: TA_FONT_SIZE,
151153
lineHeight: TA_LINE_HEIGHT,
@@ -195,7 +197,7 @@ const ResultErrorList = styled.ul(({ theme }) => ({
195197
type ModalData = {
196198
validEmails: string[]
197199
invalidEmails: string[]
198-
duplicateCount: number
200+
duplicateEmails: string[]
199201
skippedCount: number
200202
}
201203

@@ -227,6 +229,7 @@ const AssignSeatsSection: React.FC<AssignSeatsSectionProps> = ({
227229
const [modalData, setModalData] = useState<ModalData | null>(null)
228230
const [result, setResult] = useState<AssignResult | null>(null)
229231
const [debouncedAnnouncement, setDebouncedAnnouncement] = useState("")
232+
const [errorAnnouncement, setErrorAnnouncement] = useState("")
230233
const fileInputRef = useRef<HTMLInputElement>(null)
231234

232235
const bulkAssign = useBulkAssignSeats()
@@ -237,6 +240,7 @@ const AssignSeatsSection: React.FC<AssignSeatsSectionProps> = ({
237240
)
238241
const validCount = submitResult.valid.length
239242
const invalidCount = submitResult.invalid.length
243+
const duplicateCount = submitResult.duplicateEmails.length
240244
const hasEmails = emailInput.trim().length > 0
241245
const canSubmit = validCount > 0
242246

@@ -248,9 +252,36 @@ const AssignSeatsSection: React.FC<AssignSeatsSectionProps> = ({
248252
)
249253
const showOverlay = hasEmails
250254

251-
const announcement = hasEmails
252-
? `${validCount} valid ${pluralize("email", validCount)}${invalidCount > 0 ? `, ${invalidCount} invalid` : ""}`
253-
: ""
255+
const overCapacity = validCount > availableSeats
256+
const ignoreWarning =
257+
!overCapacity && (invalidCount > 0 || duplicateCount > 0)
258+
? `. ${[
259+
duplicateCount > 0
260+
? `${duplicateCount} duplicate ${pluralize("email address", duplicateCount, "email addresses")}`
261+
: "",
262+
invalidCount > 0
263+
? `${invalidCount} invalid ${pluralize("email address", invalidCount, "email addresses")}`
264+
: "",
265+
]
266+
.filter(Boolean)
267+
.join(" and ")} will be ignored`
268+
: ""
269+
let announcement = ""
270+
if (hasEmails) {
271+
const parts = [`${validCount} valid ${pluralize("email", validCount)}`]
272+
if (invalidCount > 0) parts.push(`${invalidCount} invalid`)
273+
if (duplicateCount > 0)
274+
parts.push(`${duplicateCount} ${pluralize("duplicate", duplicateCount)}`)
275+
announcement = parts.join(", ") + ignoreWarning
276+
if (overCapacity) {
277+
const excess = validCount - availableSeats
278+
const seatsClause =
279+
availableSeats > 1
280+
? `${availableSeats} unassigned ${pluralize("seat", availableSeats)} are available.`
281+
: `${availableSeats} unassigned seat is available.`
282+
announcement += `. Error: You entered ${validCount} ${pluralize("email", validCount)}, but only ${seatsClause} Remove ${excess} more email ${pluralize("address", excess, "addresses")} to continue.`
283+
}
284+
}
254285

255286
// Debounce the live-region text so screen readers aren't spammed on every keystroke.
256287
useEffect(() => {
@@ -284,6 +315,47 @@ const AssignSeatsSection: React.FC<AssignSeatsSectionProps> = ({
284315
}
285316
}, [result])
286317

318+
// Derive the assertive announcement from all error sources in one place so
319+
// the two concerns can't clear each other. Two separate effects sharing one
320+
// setState caused the second effect to wipe the first when resultContent
321+
// went null (e.g. user closes the success Alert).
322+
const errorAnnouncementText = useMemo(() => {
323+
if (csvReadError) return "Error: Could not read the file. Please try again."
324+
if (csvNoValid) return "Error: No valid email addresses found in this file."
325+
if (resultContent) {
326+
const prefix =
327+
resultContent.severity === "error" ||
328+
resultContent.severity === "warning"
329+
? `${resultContent.severity}: `
330+
: ""
331+
const errorDetails = resultContent.errors
332+
?.map((e) => `${e.email}${e.detail}`)
333+
.join(", ")
334+
return (
335+
prefix +
336+
resultContent.message +
337+
(errorDetails ? ` ${errorDetails}` : "")
338+
)
339+
}
340+
return ""
341+
}, [csvReadError, csvNoValid, resultContent])
342+
343+
// Reset-then-set with a short delay so the assertive live region fires after
344+
// smoot-design Alert's role="alert" has been processed by NVDA. Without the
345+
// delay, both fire in the same frame and NVDA drops the live-region update.
346+
useEffect(() => {
347+
if (!errorAnnouncementText) {
348+
setErrorAnnouncement("")
349+
return
350+
}
351+
setErrorAnnouncement("")
352+
const id = setTimeout(
353+
() => setErrorAnnouncement(errorAnnouncementText),
354+
100,
355+
)
356+
return () => clearTimeout(id)
357+
}, [errorAnnouncementText])
358+
287359
const handleCsvChange = (e: React.ChangeEvent<HTMLInputElement>) => {
288360
const file = e.target.files?.[0]
289361
if (!file) return
@@ -297,17 +369,17 @@ const AssignSeatsSection: React.FC<AssignSeatsSectionProps> = ({
297369
const { data } = Papa.parse<string[]>(text, {
298370
skipEmptyLines: true,
299371
})
300-
const { valid, invalid, duplicateCount, skippedCount } =
372+
const { valid, invalid, duplicateEmails, skippedCount } =
301373
extractEmailsFromCsvRows(data)
374+
setEmailInput("")
302375
if (valid.length === 0) {
303376
setCsvNoValid(true)
304377
return
305378
}
306-
setEmailInput("")
307379
setModalData({
308380
validEmails: valid,
309381
invalidEmails: invalid,
310-
duplicateCount,
382+
duplicateEmails,
311383
skippedCount,
312384
})
313385
}
@@ -319,7 +391,7 @@ const AssignSeatsSection: React.FC<AssignSeatsSectionProps> = ({
319391
setModalData({
320392
validEmails: submitResult.valid,
321393
invalidEmails: submitResult.invalid,
322-
duplicateCount: submitResult.duplicateCount,
394+
duplicateEmails: submitResult.duplicateEmails,
323395
skippedCount: submitResult.skippedCount,
324396
})
325397
}
@@ -337,10 +409,7 @@ const AssignSeatsSection: React.FC<AssignSeatsSectionProps> = ({
337409
AssignRevokeCodeRequestRequest: emails.map((email) => ({ email })),
338410
})
339411
setResult({ assignedCount: data.assigned.length, errors: data.errors })
340-
// Clear the input only on a fully successful assignment.
341-
if (data.errors.length === 0) {
342-
setEmailInput("")
343-
}
412+
setEmailInput("")
344413
} catch {
345414
setResult({ assignedCount: 0, errors: null })
346415
}
@@ -360,6 +429,11 @@ const AssignSeatsSection: React.FC<AssignSeatsSectionProps> = ({
360429
<VisuallyHidden aria-live="polite" aria-atomic="true">
361430
{debouncedAnnouncement}
362431
</VisuallyHidden>
432+
{/* Assertive region for errors — workaround for smoot-design Alert announcing
433+
only its aria-describedby ("error message") instead of the children text */}
434+
<VisuallyHidden aria-live="assertive" aria-atomic="true">
435+
{errorAnnouncement}
436+
</VisuallyHidden>
363437
<Stack
364438
direction={{ xs: "column", sm: "row" }}
365439
gap="24px"
@@ -415,24 +489,32 @@ const AssignSeatsSection: React.FC<AssignSeatsSectionProps> = ({
415489
/>
416490
</EmailInputRoot>
417491
{hasEmails && (
418-
<ValidationBadge id="assign-seats-validation" aria-hidden="true">
419-
<ValidCount>{validCount} valid</ValidCount>
492+
<Stack
493+
direction="row"
494+
alignItems="center"
495+
flexWrap="wrap"
496+
gap="8px"
497+
>
498+
<CountBadge $variant="valid" aria-hidden="true">
499+
{validCount} valid
500+
</CountBadge>
420501
{invalidCount > 0 && (
421-
<InvalidCount>{invalidCount} invalid</InvalidCount>
502+
<CountBadge $variant="warning" aria-hidden="true">
503+
{invalidCount} invalid
504+
</CountBadge>
422505
)}
423-
</ValidationBadge>
424-
)}
425-
{validCount > availableSeats && (
426-
<InvalidCount>
427-
Only {availableSeats} unassigned{" "}
428-
{pluralize("seat", availableSeats)} available.
429-
</InvalidCount>
506+
{duplicateCount > 0 && (
507+
<CountBadge $variant="warning" aria-hidden="true">
508+
{duplicateCount} {pluralize("duplicate", duplicateCount)}
509+
</CountBadge>
510+
)}
511+
</Stack>
430512
)}
431513
</Stack>
432514
<ButtonWrapper>
433515
<Button
434516
variant="primary"
435-
disabled={!canSubmit || validCount > availableSeats}
517+
disabled={!canSubmit || overCapacity}
436518
onClick={handleAssignSeats}
437519
>
438520
Assign Seats
@@ -470,6 +552,31 @@ const AssignSeatsSection: React.FC<AssignSeatsSectionProps> = ({
470552
</DisabledLink>
471553
</Tooltip>
472554
</Stack>
555+
{!overCapacity && (invalidCount > 0 || duplicateCount > 0) && (
556+
<Alert severity="warning">
557+
{[
558+
duplicateCount > 0
559+
? `${duplicateCount} duplicate ${pluralize("email address", duplicateCount, "email addresses")}`
560+
: "",
561+
invalidCount > 0
562+
? `${invalidCount} invalid ${pluralize("email address", invalidCount, "email addresses")}`
563+
: "",
564+
]
565+
.filter(Boolean)
566+
.join(" and ")}{" "}
567+
will be ignored
568+
</Alert>
569+
)}
570+
{overCapacity && (
571+
<Alert severity="error">
572+
{availableSeats > 1
573+
? `You entered ${validCount} ${pluralize("email", validCount)}, but only ${availableSeats} unassigned ${pluralize("seat", availableSeats)} are available.`
574+
: `You entered ${validCount} ${pluralize("email", validCount)}, but only ${availableSeats} unassigned seat is available.`}{" "}
575+
Remove {validCount - availableSeats} more email{" "}
576+
{pluralize("address", validCount - availableSeats, "addresses")} to
577+
continue.
578+
</Alert>
579+
)}
473580
{csvReadError && (
474581
<Alert severity="error" closable onClose={() => setCsvReadError(false)}>
475582
Could not read the file. Please try again.
@@ -506,7 +613,7 @@ const AssignSeatsSection: React.FC<AssignSeatsSectionProps> = ({
506613
validCount={modalData.validEmails.length}
507614
availableSeats={availableSeats}
508615
invalidEmails={modalData.invalidEmails}
509-
duplicateCount={modalData.duplicateCount}
616+
duplicateEmails={modalData.duplicateEmails}
510617
skippedCount={modalData.skippedCount}
511618
/>
512619
)}

0 commit comments

Comments
 (0)