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..051d6d3024 --- /dev/null +++ b/src/components/HrTools/MpdGoalAdmin/PrintCohortGoalsButton/printCohortGoalsPdf.test.ts @@ -0,0 +1,85 @@ +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'); + }); + + it('throws when the lines exceed the single-page capacity', () => { + expect(() => buildPlaceholderPdf(Array(45).fill('x'))).toThrow(); + }); +}); + +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; + + 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'); + + 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'); + }); +}); + +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..d9420f77a9 --- /dev/null +++ b/src/components/HrTools/MpdGoalAdmin/PrintCohortGoalsButton/printCohortGoalsPdf.ts @@ -0,0 +1,103 @@ +import { currencyFormat } from 'src/lib/intlFormat'; +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 => { + // 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( + (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; +}; + +// 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 => + currencyFormat(amount, 'USD', 'en-US'); + +/** + * 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. + * 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, +): 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 (a no-op when `url` is not a blob 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..361ad80428 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@example.com', + 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@example.com', + ministry: 'Campus Field Ministry', + geography: 'Geography 05 (11-12)', + mpdGoal: 5210, + goalStatus: GoalStatusEnum.Incomplete, + familyStatus: 'Single', + coach: null, + coordinator: 'Richard Smith', + }, + ], + }, ];