From 532f35d7519b67426861d745f6c4b99e17660923 Mon Sep 17 00:00:00 2001 From: wjames111 Date: Tue, 18 Aug 2026 15:20:34 -0400 Subject: [PATCH 01/12] MPDX-9702 - Add Print All export of cohort goals as PDF MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enable the Active Goals toolbar's Print All button: it generates a single printable PDF of the selected cohort's goals (for NSO hard copies) and downloads it via the CSV-export anchor pattern, gated on the cohort's training costs being entered like Run & Send. The printCohortGoals mutation (MPDX-9691) doesn't exist yet — the whole MpdGoalAdmin tool is still a mock prototype — so the generate step builds a placeholder PDF client-side behind the same generate-then-download seam the mutation will slot into. Co-Authored-By: Claude Fable 5 --- .../GoalsTableToolbar/GoalsTableToolbar.tsx | 7 +- .../PrintCohortGoalsButton.test.tsx | 108 ++++++++++++++++++ .../PrintCohortGoalsButton.tsx | 61 ++++++++++ .../printCohortGoalsPdf.test.ts | 67 +++++++++++ .../printCohortGoalsPdf.ts | 86 ++++++++++++++ .../HrTools/MpdGoalAdmin/mockData.ts | 35 ++++++ 6 files changed, 359 insertions(+), 5 deletions(-) create mode 100644 src/components/HrTools/MpdGoalAdmin/PrintCohortGoalsButton/PrintCohortGoalsButton.test.tsx create mode 100644 src/components/HrTools/MpdGoalAdmin/PrintCohortGoalsButton/PrintCohortGoalsButton.tsx create mode 100644 src/components/HrTools/MpdGoalAdmin/PrintCohortGoalsButton/printCohortGoalsPdf.test.ts create mode 100644 src/components/HrTools/MpdGoalAdmin/PrintCohortGoalsButton/printCohortGoalsPdf.ts diff --git a/src/components/HrTools/MpdGoalAdmin/GoalsTableToolbar/GoalsTableToolbar.tsx b/src/components/HrTools/MpdGoalAdmin/GoalsTableToolbar/GoalsTableToolbar.tsx index 30a866ff2b..e5b871dccf 100644 --- a/src/components/HrTools/MpdGoalAdmin/GoalsTableToolbar/GoalsTableToolbar.tsx +++ b/src/components/HrTools/MpdGoalAdmin/GoalsTableToolbar/GoalsTableToolbar.tsx @@ -11,6 +11,7 @@ import { import { useSnackbar } from 'notistack'; import { useTranslation } from 'react-i18next'; import { useMpdGoalAdmin } from '../MpdGoalAdminContext'; +import { PrintCohortGoalsButton } from '../PrintCohortGoalsButton/PrintCohortGoalsButton'; import { RunAndSendModal } from '../RunAndSendModal/RunAndSendModal'; import { StaffGoalRow } from '../mpdGoalAdminHelpers'; @@ -93,11 +94,7 @@ export const GoalsTableToolbar: React.FC = () => { ) : ( <> - {/* Disabled until wired up so assistive tech announces the - inert state instead of a dead control (MPDX-9696). */} - + + + + ); +}; diff --git a/src/components/HrTools/MpdGoalAdmin/PrintCohortGoalsButton/printCohortGoalsPdf.test.ts b/src/components/HrTools/MpdGoalAdmin/PrintCohortGoalsButton/printCohortGoalsPdf.test.ts new file mode 100644 index 0000000000..05fc9eefd2 --- /dev/null +++ b/src/components/HrTools/MpdGoalAdmin/PrintCohortGoalsButton/printCohortGoalsPdf.test.ts @@ -0,0 +1,67 @@ +import { mockCohorts } from '../mockData'; +import { + buildPlaceholderPdf, + downloadPdf, + generateCohortGoalsPdf, +} from './printCohortGoalsPdf'; + +describe('buildPlaceholderPdf', () => { + it('produces a well-formed PDF document', () => { + const pdf = buildPlaceholderPdf(['Hello', 'World']); + expect(pdf).toMatch(/^%PDF-1\.4\n/); + expect(pdf).toMatch(/%%EOF$/); + expect(pdf).toContain('(Hello) Tj'); + expect(pdf).toContain('(World) Tj'); + // The xref offset in startxref must point at the xref keyword. + const xrefOffset = Number(pdf.match(/startxref\n(\d+)/)?.[1]); + expect(pdf.slice(xrefOffset, xrefOffset + 4)).toBe('xref'); + }); + + it('escapes characters that would terminate a PDF string', () => { + const pdf = buildPlaceholderPdf(['John (Jack) Doe \\ Co']); + expect(pdf).toContain('(John \\(Jack\\) Doe \\\\ Co) Tj'); + }); +}); + +describe('generateCohortGoalsPdf', () => { + it('builds a blob URL for a PDF listing every goal in the cohort', async () => { + const createObjectURL = jest.fn().mockReturnValue('blob:cohort-pdf'); + window.URL.createObjectURL = createObjectURL; + + await expect(generateCohortGoalsPdf(mockCohorts[0])).resolves.toBe( + 'blob:cohort-pdf', + ); + const blob = createObjectURL.mock.calls[0][0] as Blob; + expect(blob.type).toBe('application/pdf'); + }); +}); + +describe('downloadPdf', () => { + it('downloads via a temporary anchor and releases the URL', () => { + const revokeObjectURL = jest.fn(); + window.URL.revokeObjectURL = revokeObjectURL; + const click = jest + .spyOn(HTMLAnchorElement.prototype, 'click') + .mockImplementation(() => {}); + let anchor: HTMLAnchorElement | undefined; + const realCreateElement = document.createElement.bind(document); + const createElement = jest + .spyOn(document, 'createElement') + .mockImplementation((tagName) => { + const element = realCreateElement(tagName); + if (element instanceof HTMLAnchorElement) { + anchor = element; + } + return element; + }); + + downloadPdf('blob:cohort-pdf', 'MPD Goals - Fall NSO 2026.pdf'); + + expect(click).toHaveBeenCalled(); + expect(anchor?.download).toBe('MPD Goals - Fall NSO 2026.pdf'); + expect(anchor?.href).toContain('blob:cohort-pdf'); + expect(revokeObjectURL).toHaveBeenCalledWith('blob:cohort-pdf'); + click.mockRestore(); + createElement.mockRestore(); + }); +}); diff --git a/src/components/HrTools/MpdGoalAdmin/PrintCohortGoalsButton/printCohortGoalsPdf.ts b/src/components/HrTools/MpdGoalAdmin/PrintCohortGoalsButton/printCohortGoalsPdf.ts new file mode 100644 index 0000000000..42d74d3263 --- /dev/null +++ b/src/components/HrTools/MpdGoalAdmin/PrintCohortGoalsButton/printCohortGoalsPdf.ts @@ -0,0 +1,86 @@ +import { Cohort } from '../mpdGoalAdminHelpers'; + +// Escape the characters that terminate or escape a PDF literal string. +const escapePdfText = (text: string): string => + text.replace(/[\\()]/g, (char) => `\\${char}`); + +/** + * Builds a minimal single-page PDF document listing one line of text per + * entry in `lines`. Exported for tests; use `generateCohortGoalsPdf` instead. + * + * This is placeholder output for the mock MpdGoalAdmin tool only — the real + * worksheet PDF is rendered server-side (MPDX-9690) and this entire builder + * goes away when the printCohortGoals mutation ships (MPDX-9691). + */ +export const buildPlaceholderPdf = (lines: string[]): string => { + const encoder = new TextEncoder(); + const content = lines + .map( + (line, index) => + `BT /F1 12 Tf 72 ${720 - index * 18} Td (${escapePdfText(line)}) Tj ET`, + ) + .join('\n'); + const objects = [ + '<< /Type /Catalog /Pages 2 0 R >>', + '<< /Type /Pages /Kids [3 0 R] /Count 1 >>', + '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>', + `<< /Length ${encoder.encode(content).length} >>\nstream\n${content}\nendstream`, + '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>', + ]; + + let pdf = '%PDF-1.4\n'; + // The xref table needs the byte offset of every object, so measure the + // document as it grows. Byte lengths, not string lengths — names in the + // content stream can be multi-byte. + const offsets: number[] = []; + objects.forEach((object, index) => { + offsets.push(encoder.encode(pdf).length); + pdf += `${index + 1} 0 obj\n${object}\nendobj\n`; + }); + const xrefOffset = encoder.encode(pdf).length; + pdf += + `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n` + + offsets + .map((offset) => `${String(offset).padStart(10, '0')} 00000 n \n`) + .join(''); + pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF`; + return pdf; +}; + +const formatUsd = (amount: number): string => + amount.toLocaleString('en-US', { style: 'currency', currency: 'USD' }); + +/** + * Generates the printable PDF of every goal in the cohort and resolves with a + * URL the browser can download it from. + * + * TODO(MPDX-9691): replace this mock with the printCohortGoals mutation. The + * server (MPDX-9690) renders the real Support Goals Worksheet — one page per + * goal — and the mutation exposes a download URL for the concatenated + * document; this function should then reduce to mutate → return that URL. + */ +export const generateCohortGoalsPdf = async ( + cohort: Cohort, +): Promise => { + const pdf = buildPlaceholderPdf([ + `MPD Goals - ${cohort.name}`, + '', + ...cohort.rows.map((row) => `${row.name}: ${formatUsd(row.mpdGoal)}`), + ]); + return URL.createObjectURL(new Blob([pdf], { type: 'application/pdf' })); +}; + +/** + * Triggers a browser download of `url` via a temporary anchor (the same + * mechanism as the contacts CSV export in + * `src/components/Contacts/MassActions/Exports/exportRest.tsx`), then + * releases the URL. + */ +export const downloadPdf = (url: string, filename: string): void => { + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = filename; + anchor.click(); + URL.revokeObjectURL(url); + anchor.remove(); +}; diff --git a/src/components/HrTools/MpdGoalAdmin/mockData.ts b/src/components/HrTools/MpdGoalAdmin/mockData.ts index 187eb51561..28f28ebaab 100644 --- a/src/components/HrTools/MpdGoalAdmin/mockData.ts +++ b/src/components/HrTools/MpdGoalAdmin/mockData.ts @@ -166,4 +166,39 @@ export const mockCohorts: Cohort[] = [ }, ], }, + // Training costs deliberately not entered yet: exercises the disabled state + // of actions gated on training costs (Print All, and eventually Run & Send). + { + id: 'spring-nso-2027', + name: 'Spring NSO 2027', + trainingSize: 2, + nsoDate: '01/11/2027', + trainingCostEntered: false, + rows: [ + { + id: 'spring-row-1', + name: 'Amara & Tobias Fields', + email: 'amara.fields@cru.org', + ministry: 'Cru High School', + geography: 'Geography 02 (04-05)', + mpdGoal: 8420.5, + goalStatus: GoalStatusEnum.Complete, + familyStatus: 'Married', + coach: 'Phillip Song', + coordinator: 'Richard Smith', + }, + { + id: 'spring-row-2', + name: 'Noah Okafor', + email: 'noah.okafor@cru.org', + ministry: 'Campus Field Ministry', + geography: 'Geography 05 (11-12)', + mpdGoal: 5210, + goalStatus: GoalStatusEnum.Incomplete, + familyStatus: 'Single', + coach: null, + coordinator: 'Richard Smith', + }, + ], + }, ]; From 2e24992b4129b64eb06cae7c92c32ead98431b36 Mon Sep 17 00:00:00 2001 From: wjames111 Date: Wed, 19 Aug 2026 12:46:08 -0400 Subject: [PATCH 02/12] fix: address review comment on PrintCohortGoalsButton.tsx:11 Reword JSDoc: Run & Send does not yet enforce the training-costs gate Co-Authored-By: Claude Fable 5 --- .../PrintCohortGoalsButton/PrintCohortGoalsButton.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/HrTools/MpdGoalAdmin/PrintCohortGoalsButton/PrintCohortGoalsButton.tsx b/src/components/HrTools/MpdGoalAdmin/PrintCohortGoalsButton/PrintCohortGoalsButton.tsx index 6aaca0374e..01b1f5bf04 100644 --- a/src/components/HrTools/MpdGoalAdmin/PrintCohortGoalsButton/PrintCohortGoalsButton.tsx +++ b/src/components/HrTools/MpdGoalAdmin/PrintCohortGoalsButton/PrintCohortGoalsButton.tsx @@ -8,9 +8,9 @@ import { downloadPdf, generateCohortGoalsPdf } from './printCohortGoalsPdf'; /** * "Print All" — exports every goal in the selected cohort as a single * printable PDF (used for NSO hard copies). Disabled until the cohort's - * training costs have been entered, the same gate Run & Send uses: without - * training costs the goal amounts aren't final, so neither action may act on - * them. + * training costs have been entered: without training costs the goal amounts + * aren't final, so printing may not act on them. (Run & Send is expected to + * adopt the same gate when it is wired up — it does not enforce it yet.) */ export const PrintCohortGoalsButton: React.FC = () => { const { t } = useTranslation(); From 55d5e241d59c261fae8cf6a5e3a39adcbd2538f2 Mon Sep 17 00:00:00 2001 From: wjames111 Date: Wed, 19 Aug 2026 12:47:41 -0400 Subject: [PATCH 03/12] fix: address review comment on PrintCohortGoalsButton.tsx:29 Communicate cohort-wide print scope via the enabled-state tooltip Co-Authored-By: Claude Fable 5 --- .../PrintCohortGoalsButton/PrintCohortGoalsButton.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/HrTools/MpdGoalAdmin/PrintCohortGoalsButton/PrintCohortGoalsButton.tsx b/src/components/HrTools/MpdGoalAdmin/PrintCohortGoalsButton/PrintCohortGoalsButton.tsx index 01b1f5bf04..53cd6782c9 100644 --- a/src/components/HrTools/MpdGoalAdmin/PrintCohortGoalsButton/PrintCohortGoalsButton.tsx +++ b/src/components/HrTools/MpdGoalAdmin/PrintCohortGoalsButton/PrintCohortGoalsButton.tsx @@ -41,7 +41,9 @@ export const PrintCohortGoalsButton: React.FC = () => { From 97435695de5dc669aba85f6eb3645c835506d2fd Mon Sep 17 00:00:00 2001 From: wjames111 Date: Wed, 19 Aug 2026 12:48:18 -0400 Subject: [PATCH 04/12] fix: address review comment on PrintCohortGoalsButton.tsx:32 Breadcrumb the catch snackbar for double-toast removal when MPDX-9691 ships Co-Authored-By: Claude Fable 5 --- .../PrintCohortGoalsButton/PrintCohortGoalsButton.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/components/HrTools/MpdGoalAdmin/PrintCohortGoalsButton/PrintCohortGoalsButton.tsx b/src/components/HrTools/MpdGoalAdmin/PrintCohortGoalsButton/PrintCohortGoalsButton.tsx index 53cd6782c9..a95e17dc5c 100644 --- a/src/components/HrTools/MpdGoalAdmin/PrintCohortGoalsButton/PrintCohortGoalsButton.tsx +++ b/src/components/HrTools/MpdGoalAdmin/PrintCohortGoalsButton/PrintCohortGoalsButton.tsx @@ -29,6 +29,9 @@ export const PrintCohortGoalsButton: React.FC = () => { const url = await generateCohortGoalsPdf(selectedCohort); downloadPdf(url, `MPD Goals - ${selectedCohort.name}.pdf`); } catch { + // TODO(MPDX-9691): once generation is a GraphQL mutation, the global + // Apollo error link will already toast failures — remove this snackbar + // (or suppress the generic one) so the user isn't double-toasted. enqueueSnackbar(t('Unable to export the MPD Goals PDF.'), { variant: 'error', }); From fc8938702afd1b75fd4994dc4381171f0e772ed0 Mon Sep 17 00:00:00 2001 From: wjames111 Date: Wed, 19 Aug 2026 12:45:57 -0400 Subject: [PATCH 05/12] fix: address review comment on PrintCohortGoalsButton.tsx:56 Add aria-busy and inherit spinner color per HrTools in-flight-button idiom Co-Authored-By: Claude Fable 5 --- .../PrintCohortGoalsButton/PrintCohortGoalsButton.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/HrTools/MpdGoalAdmin/PrintCohortGoalsButton/PrintCohortGoalsButton.tsx b/src/components/HrTools/MpdGoalAdmin/PrintCohortGoalsButton/PrintCohortGoalsButton.tsx index a95e17dc5c..cb32230ae1 100644 --- a/src/components/HrTools/MpdGoalAdmin/PrintCohortGoalsButton/PrintCohortGoalsButton.tsx +++ b/src/components/HrTools/MpdGoalAdmin/PrintCohortGoalsButton/PrintCohortGoalsButton.tsx @@ -56,9 +56,12 @@ export const PrintCohortGoalsButton: React.FC = () => { variant="outlined" disabled={!hasTrainingCosts || printing} onClick={handlePrint} + aria-busy={printing} > {t('Print All')} - {printing && } + {printing && ( + + )} From c60a0d6ca951e4a805f0cc00c5c083bf35b4f208 Mon Sep 17 00:00:00 2001 From: wjames111 Date: Wed, 19 Aug 2026 12:51:12 -0400 Subject: [PATCH 06/12] fix: address review comment on PrintCohortGoalsButton.test.tsx:81 Test the in-flight printing state with a deferred generation promise Co-Authored-By: Claude Fable 5 --- .../PrintCohortGoalsButton.test.tsx | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/components/HrTools/MpdGoalAdmin/PrintCohortGoalsButton/PrintCohortGoalsButton.test.tsx b/src/components/HrTools/MpdGoalAdmin/PrintCohortGoalsButton/PrintCohortGoalsButton.test.tsx index 651e185e4f..2f3d19f714 100644 --- a/src/components/HrTools/MpdGoalAdmin/PrintCohortGoalsButton/PrintCohortGoalsButton.test.tsx +++ b/src/components/HrTools/MpdGoalAdmin/PrintCohortGoalsButton/PrintCohortGoalsButton.test.tsx @@ -94,6 +94,22 @@ describe('PrintCohortGoalsButton', () => { expect(getByRole('button', { name: 'Print All' })).toBeEnabled(); }); + it('disables the button and shows a spinner while generating', async () => { + let resolvePdf!: (url: string) => void; + generateMock.mockReturnValue( + new Promise((resolve) => (resolvePdf = resolve)), + ); + const { getByRole, findByRole } = renderButton(); + userEvent.click(getByRole('button', { name: 'Print All' })); + + expect(await findByRole('progressbar')).toBeInTheDocument(); + expect(getByRole('button', { name: 'Print All' })).toBeDisabled(); + + resolvePdf('blob:mock-pdf'); + await waitFor(() => expect(downloadMock).toHaveBeenCalled()); + expect(getByRole('button', { name: 'Print All' })).toBeEnabled(); + }); + it('shows an error and re-enables the button when generation fails', async () => { generateMock.mockRejectedValue(new Error('boom')); const { getByRole, findByText } = renderButton(); From a8b841f99eece29db920d6e3a0880cb6091379b3 Mon Sep 17 00:00:00 2001 From: wjames111 Date: Wed, 19 Aug 2026 12:48:01 -0400 Subject: [PATCH 07/12] fix: address review comment on PrintCohortGoalsButton.test.tsx:83 Drop no-op awaits on synchronous userEvent v13 calls per repo checklist Co-Authored-By: Claude Fable 5 --- .../PrintCohortGoalsButton/PrintCohortGoalsButton.test.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/HrTools/MpdGoalAdmin/PrintCohortGoalsButton/PrintCohortGoalsButton.test.tsx b/src/components/HrTools/MpdGoalAdmin/PrintCohortGoalsButton/PrintCohortGoalsButton.test.tsx index 2f3d19f714..9504b287dd 100644 --- a/src/components/HrTools/MpdGoalAdmin/PrintCohortGoalsButton/PrintCohortGoalsButton.test.tsx +++ b/src/components/HrTools/MpdGoalAdmin/PrintCohortGoalsButton/PrintCohortGoalsButton.test.tsx @@ -46,7 +46,7 @@ describe('PrintCohortGoalsButton', () => { const button = getByRole('button', { name: 'Print All' }); expect(button).toBeDisabled(); - await userEvent.hover(button.parentElement as HTMLElement); + userEvent.hover(button.parentElement as HTMLElement); expect( await findByText( 'Enter training costs for this cohort to print its goals.', @@ -80,7 +80,7 @@ describe('PrintCohortGoalsButton', () => { it('generates the cohort PDF and downloads it', async () => { const { getByRole } = renderButton(); - await userEvent.click(getByRole('button', { name: 'Print All' })); + userEvent.click(getByRole('button', { name: 'Print All' })); await waitFor(() => expect(downloadMock).toHaveBeenCalledWith( @@ -113,7 +113,7 @@ describe('PrintCohortGoalsButton', () => { it('shows an error and re-enables the button when generation fails', async () => { generateMock.mockRejectedValue(new Error('boom')); const { getByRole, findByText } = renderButton(); - await userEvent.click(getByRole('button', { name: 'Print All' })); + userEvent.click(getByRole('button', { name: 'Print All' })); expect( await findByText('Unable to export the MPD Goals PDF.'), From f1e83a2cdce13b785fc4b678344a45e08d972924 Mon Sep 17 00:00:00 2001 From: wjames111 Date: Wed, 19 Aug 2026 12:49:15 -0400 Subject: [PATCH 08/12] fix: address review comment on printCohortGoalsPdf.test.ts:27 Assert the generated PDF lists the cohort title and formatted goal rows Co-Authored-By: Claude Fable 5 --- .../printCohortGoalsPdf.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/components/HrTools/MpdGoalAdmin/PrintCohortGoalsButton/printCohortGoalsPdf.test.ts b/src/components/HrTools/MpdGoalAdmin/PrintCohortGoalsButton/printCohortGoalsPdf.test.ts index 05fc9eefd2..c30fbf9700 100644 --- a/src/components/HrTools/MpdGoalAdmin/PrintCohortGoalsButton/printCohortGoalsPdf.test.ts +++ b/src/components/HrTools/MpdGoalAdmin/PrintCohortGoalsButton/printCohortGoalsPdf.test.ts @@ -24,6 +24,15 @@ describe('buildPlaceholderPdf', () => { }); describe('generateCohortGoalsPdf', () => { + // jsdom does not implement Blob.text(), so read the blob with a FileReader. + const readBlobText = (blob: Blob) => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = () => reject(reader.error); + reader.readAsText(blob); + }); + it('builds a blob URL for a PDF listing every goal in the cohort', async () => { const createObjectURL = jest.fn().mockReturnValue('blob:cohort-pdf'); window.URL.createObjectURL = createObjectURL; @@ -33,6 +42,11 @@ describe('generateCohortGoalsPdf', () => { ); const blob = createObjectURL.mock.calls[0][0] as Blob; expect(blob.type).toBe('application/pdf'); + + const pdf = await readBlobText(blob); + expect(pdf).toContain('(MPD Goals - Fall NSO 2026) Tj'); + expect(pdf).toContain('(John & Jane Doe: $6,430.25) Tj'); + expect(pdf).toContain('(Carlos & Michaela Everts: $5,280.77) Tj'); }); }); From d5c6c41717f1293c1be2b0e15695e377615ed8f0 Mon Sep 17 00:00:00 2001 From: wjames111 Date: Wed, 19 Aug 2026 12:49:39 -0400 Subject: [PATCH 09/12] fix: address review comment on printCohortGoalsPdf.ts:20 Fail loud past the single-page line capacity instead of silently truncating Co-Authored-By: Claude Fable 5 --- .../PrintCohortGoalsButton/printCohortGoalsPdf.test.ts | 4 ++++ .../PrintCohortGoalsButton/printCohortGoalsPdf.ts | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/src/components/HrTools/MpdGoalAdmin/PrintCohortGoalsButton/printCohortGoalsPdf.test.ts b/src/components/HrTools/MpdGoalAdmin/PrintCohortGoalsButton/printCohortGoalsPdf.test.ts index c30fbf9700..051d6d3024 100644 --- a/src/components/HrTools/MpdGoalAdmin/PrintCohortGoalsButton/printCohortGoalsPdf.test.ts +++ b/src/components/HrTools/MpdGoalAdmin/PrintCohortGoalsButton/printCohortGoalsPdf.test.ts @@ -21,6 +21,10 @@ describe('buildPlaceholderPdf', () => { const pdf = buildPlaceholderPdf(['John (Jack) Doe \\ Co']); expect(pdf).toContain('(John \\(Jack\\) Doe \\\\ Co) Tj'); }); + + it('throws when the lines exceed the single-page capacity', () => { + expect(() => buildPlaceholderPdf(Array(45).fill('x'))).toThrow(); + }); }); describe('generateCohortGoalsPdf', () => { diff --git a/src/components/HrTools/MpdGoalAdmin/PrintCohortGoalsButton/printCohortGoalsPdf.ts b/src/components/HrTools/MpdGoalAdmin/PrintCohortGoalsButton/printCohortGoalsPdf.ts index 42d74d3263..b6daeabe55 100644 --- a/src/components/HrTools/MpdGoalAdmin/PrintCohortGoalsButton/printCohortGoalsPdf.ts +++ b/src/components/HrTools/MpdGoalAdmin/PrintCohortGoalsButton/printCohortGoalsPdf.ts @@ -13,6 +13,11 @@ const escapePdfText = (text: string): string => * goes away when the printCohortGoals mutation ships (MPDX-9691). */ export const buildPlaceholderPdf = (lines: string[]): string => { + // y = 720 - index * 18 falls below the 792pt MediaBox around line 40; fail + // loud rather than silently truncate a printed goals document. + if (lines.length > 38) { + throw new Error('Placeholder PDF supports at most 38 lines per page'); + } const encoder = new TextEncoder(); const content = lines .map( From b6ca9e08f9b397f86f34a1ba776eb73dd7600495 Mon Sep 17 00:00:00 2001 From: wjames111 Date: Wed, 19 Aug 2026 12:49:56 -0400 Subject: [PATCH 10/12] fix: address review comment on printCohortGoalsPdf.ts:51 Use shared currencyFormat (en-US pinned) to match on-screen goal formatting Co-Authored-By: Claude Fable 5 --- .../PrintCohortGoalsButton/printCohortGoalsPdf.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/HrTools/MpdGoalAdmin/PrintCohortGoalsButton/printCohortGoalsPdf.ts b/src/components/HrTools/MpdGoalAdmin/PrintCohortGoalsButton/printCohortGoalsPdf.ts index b6daeabe55..f3ad58d8cc 100644 --- a/src/components/HrTools/MpdGoalAdmin/PrintCohortGoalsButton/printCohortGoalsPdf.ts +++ b/src/components/HrTools/MpdGoalAdmin/PrintCohortGoalsButton/printCohortGoalsPdf.ts @@ -1,3 +1,4 @@ +import { currencyFormat } from 'src/lib/intlFormat'; import { Cohort } from '../mpdGoalAdminHelpers'; // Escape the characters that terminate or escape a PDF literal string. @@ -52,8 +53,10 @@ export const buildPlaceholderPdf = (lines: string[]): string => { return pdf; }; +// The en-US pin is deliberate: it matches the server worksheet's :en/USD pin +// (MPDX-9690) and keeps the PDF literal string ASCII-safe. const formatUsd = (amount: number): string => - amount.toLocaleString('en-US', { style: 'currency', currency: 'USD' }); + currencyFormat(amount, 'USD', 'en-US'); /** * Generates the printable PDF of every goal in the cohort and resolves with a From a5fe8402f73adb80eba772be98231457d0d3e21f Mon Sep 17 00:00:00 2001 From: wjames111 Date: Wed, 19 Aug 2026 12:46:53 -0400 Subject: [PATCH 11/12] fix: address review comment on printCohortGoalsPdf.ts:60 Record seam constraints in the MPDX-9691 TODO (blob URL, revoke, cohort scope) Co-Authored-By: Claude Fable 5 --- .../PrintCohortGoalsButton/printCohortGoalsPdf.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/components/HrTools/MpdGoalAdmin/PrintCohortGoalsButton/printCohortGoalsPdf.ts b/src/components/HrTools/MpdGoalAdmin/PrintCohortGoalsButton/printCohortGoalsPdf.ts index f3ad58d8cc..d9420f77a9 100644 --- a/src/components/HrTools/MpdGoalAdmin/PrintCohortGoalsButton/printCohortGoalsPdf.ts +++ b/src/components/HrTools/MpdGoalAdmin/PrintCohortGoalsButton/printCohortGoalsPdf.ts @@ -66,6 +66,15 @@ const formatUsd = (amount: number): string => * server (MPDX-9690) renders the real Support Goals Worksheet — one page per * goal — and the mutation exposes a download URL for the concatenated * document; this function should then reduce to mutate → return that URL. + * Constraints for that implementation: + * - The resolved URL must be same-origin or a blob URL — `anchor.download` in + * `downloadPdf` is ignored for cross-origin URLs, so a signed S3/API URL + * means mutate → fetch → createObjectURL (as in `exportRest.tsx`), not a + * bare redirect. + * - `downloadPdf`'s `URL.revokeObjectURL(url)` is blob-era cleanup and a + * harmless no-op on non-blob URLs. + * - Scope: Print All prints the whole cohort, so the mutation contract only + * needs `cohortId`. */ export const generateCohortGoalsPdf = async ( cohort: Cohort, @@ -82,7 +91,7 @@ export const generateCohortGoalsPdf = async ( * Triggers a browser download of `url` via a temporary anchor (the same * mechanism as the contacts CSV export in * `src/components/Contacts/MassActions/Exports/exportRest.tsx`), then - * releases the URL. + * releases the URL (a no-op when `url` is not a blob URL). */ export const downloadPdf = (url: string, filename: string): void => { const anchor = document.createElement('a'); From 7b7ae9612d4a061826db89a79ce886809ff25654 Mon Sep 17 00:00:00 2001 From: wjames111 Date: Wed, 19 Aug 2026 12:46:52 -0400 Subject: [PATCH 12/12] fix: address review comment on mockData.ts:181 Use example.com for the new mock rows to match the file's fixture convention Co-Authored-By: Claude Fable 5 --- src/components/HrTools/MpdGoalAdmin/mockData.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/HrTools/MpdGoalAdmin/mockData.ts b/src/components/HrTools/MpdGoalAdmin/mockData.ts index 28f28ebaab..361ad80428 100644 --- a/src/components/HrTools/MpdGoalAdmin/mockData.ts +++ b/src/components/HrTools/MpdGoalAdmin/mockData.ts @@ -178,7 +178,7 @@ export const mockCohorts: Cohort[] = [ { id: 'spring-row-1', name: 'Amara & Tobias Fields', - email: 'amara.fields@cru.org', + email: 'amara.fields@example.com', ministry: 'Cru High School', geography: 'Geography 02 (04-05)', mpdGoal: 8420.5, @@ -190,7 +190,7 @@ export const mockCohorts: Cohort[] = [ { id: 'spring-row-2', name: 'Noah Okafor', - email: 'noah.okafor@cru.org', + email: 'noah.okafor@example.com', ministry: 'Campus Field Ministry', geography: 'Geography 05 (11-12)', mpdGoal: 5210,