diff --git a/docs/reference/Translations/index.md b/docs/reference/Translations/index.md index 143da02f06..8f0e673543 100644 --- a/docs/reference/Translations/index.md +++ b/docs/reference/Translations/index.md @@ -5273,6 +5273,7 @@ Translation keys for the `Payroll.PayrollOverview` i18n namespace. | `dataViews.taxesTable` | `"Taxes breakdown"` | | `declineCancelCta` | `"No, go back"` | | `directDepositLabel` | `"Direct deposits"` | +| `downloadLoadingMessage` | `"Generating paystub…"` | | `downloadPaystubLabel` | `"Download paystub pdf"` | | `editCta` | `"Edit"` | | `exitFlowCta` | `"Save and exit"` | diff --git a/src/components/Employee/Dashboard/Dashboard.test.tsx b/src/components/Employee/Dashboard/Dashboard.test.tsx index b31e45f134..257fbf19a9 100644 --- a/src/components/Employee/Dashboard/Dashboard.test.tsx +++ b/src/components/Employee/Dashboard/Dashboard.test.tsx @@ -192,10 +192,11 @@ describe('Dashboard', () => { it('renders dashboard and loads employee data', async () => { renderWithProviders() - await waitFor(() => expect(screen.getByText('Legal name')).toBeInTheDocument()) - - expect(screen.getByText('Home address')).toBeInTheDocument() - expect(screen.getByText('Work address')).toBeInTheDocument() + await waitFor(() => { + expect(screen.getByText('Legal name')).toBeInTheDocument() + expect(screen.getByText('Home address')).toBeInTheDocument() + expect(screen.getByText('Work address')).toBeInTheDocument() + }) }) it('displays employee basic details', async () => { diff --git a/src/components/Employee/Paystubs/management/PaystubsCard/PaystubsCard.tsx b/src/components/Employee/Paystubs/management/PaystubsCard/PaystubsCard.tsx index 72288c4034..8942237489 100644 --- a/src/components/Employee/Paystubs/management/PaystubsCard/PaystubsCard.tsx +++ b/src/components/Employee/Paystubs/management/PaystubsCard/PaystubsCard.tsx @@ -10,6 +10,7 @@ import { DataView, EmptyData, useDataView, Loading } from '@/components/Common' import { BaseBoundaries, BaseLayout } from '@/components/Base/Base' import { useComponentContext } from '@/contexts/ComponentAdapter/useComponentContext' import { useNonce } from '@/contexts/NonceProvider' +import { openPdfInNewTab } from '@/helpers/openPdfInNewTab' import { composeErrorHandler } from '@/partner-hook-utils/composeErrorHandler' import { usePaymentMethodList, @@ -124,31 +125,7 @@ function PaystubsCardReady({ payrollUuid, }) - // Omit `noopener` — it makes window.open return null in modern browsers, - // which would leave us unable to navigate the new tab to the blob URL. - const newWindow = window.open('', '_blank') - const loadingMessage = t('downloadLoadingMessage') - if (newWindow) { - // Avoid the user staring at about:blank while we fetch the PDF. The - // navigation to the Blob URL below replaces this document. - const doc = newWindow.document - doc.title = loadingMessage - const style = doc.createElement('style') - if (nonce) style.nonce = nonce - style.textContent = - 'body{font-family:system-ui,-apple-system,sans-serif;display:flex;align-items:center;' + - 'justify-content:center;height:100vh;margin:0;color:#444;gap:12px}' + - '.spinner{width:20px;height:20px;border:2px solid #ccc;border-top-color:#444;' + - 'border-radius:50%;animation:spin .8s linear infinite}' + - '@keyframes spin{to{transform:rotate(360deg)}}' - doc.head.appendChild(style) - const spinner = doc.createElement('div') - spinner.className = 'spinner' - spinner.setAttribute('aria-hidden', 'true') - const label = doc.createElement('span') - label.textContent = loadingMessage - doc.body.replaceChildren(spinner, label) - } + const tab = openPdfInNewTab({ loadingMessage: t('downloadLoadingMessage'), nonce }) setDownloadingPayrollUuids(prev => { const next = new Set(prev) next.add(payrollUuid) @@ -157,26 +134,16 @@ function PaystubsCardReady({ try { const result = await paystubsList.actions.downloadPayStub(payrollUuid) if (!result) { - if (newWindow) newWindow.close() + tab.close() return } - const url = URL.createObjectURL(result.data) - if (newWindow) { - // Revoke after the new tab has loaded the blob; revoking synchronously - // would race the navigation and leave the tab blank. - newWindow.addEventListener('load', () => { - URL.revokeObjectURL(url) - }) - newWindow.location.href = url - } else { - URL.revokeObjectURL(url) - } + tab.navigate(result.data) onEvent(componentEvents.EMPLOYEE_MANAGEMENT_PAYSTUBS_CARD_DOWNLOADED, { employeeId, payrollUuid, }) } catch (err) { - if (newWindow) newWindow.close() + tab.close() showBoundary(err instanceof Error ? err : new Error(String(err))) } finally { setDownloadingPayrollUuids(prev => { diff --git a/src/components/Payroll/PayrollOverview/PayrollOverview.tsx b/src/components/Payroll/PayrollOverview/PayrollOverview.tsx index 8737f95201..514a449cfd 100644 --- a/src/components/Payroll/PayrollOverview/PayrollOverview.tsx +++ b/src/components/Payroll/PayrollOverview/PayrollOverview.tsx @@ -34,6 +34,8 @@ import { import { BaseComponent, useBase, type BaseComponentInterface } from '@/components/Base' import { useComponentDictionary, useI18n } from '@/i18n' import { readableStreamToBlob } from '@/helpers/readableStreamToBlob' +import { openPdfInNewTab } from '@/helpers/openPdfInNewTab' +import { useNonce } from '@/contexts/NonceProvider' import useNumberFormatter from '@/hooks/useNumberFormatter' import { useDateFormatter } from '@/hooks/useDateFormatter' import { useComponentContext } from '@/contexts/ComponentAdapter/useComponentContext' @@ -176,9 +178,10 @@ const Root = ({ { refetchInterval: isPolling ? 5_000 : false, placeholderData: keepPreviousData, - // Always refetch on mount so a partner QueryClient with a non-zero `staleTime` - // can't serve a stale pre-calculation snapshot without refetching. SDK-1018. refetchOnMount: 'always', + // Discard the cache entry the moment this component unmounts so re-navigation + // never serves a stale pre-calculation snapshot (calculatedAt: null). SDK-1018. + gcTime: 0, }, ) const payrollData = data?.payrollShow @@ -330,6 +333,11 @@ const Root = ({ const { mutateAsync: cancelPayroll } = usePayrollsCancelMutation() const gustoEmbedded = useGustoEmbeddedContext() + const nonce = useNonce() + + const [downloadingEmployeeIds, setDownloadingEmployeeIds] = useState>( + () => new Set(), + ) if (!payrollData) { return @@ -385,30 +393,31 @@ const Root = ({ } const onPaystubDownload = async (employeeId: string) => { - // Open a blank window *synchronously* with the click - const newWindow = window.open('', '_blank') - + const tab = openPdfInNewTab({ loadingMessage: t('downloadLoadingMessage'), nonce }) + setDownloadingEmployeeIds(prev => { + const next = new Set(prev) + next.add(employeeId) + return next + }) try { - // Fetch the PDF from your API const response = await payrollsGetPayStub(gustoEmbedded, { payrollId, employeeId }) if (!response.value?.responseStream) { + tab.close() throw new Error(t('alerts.paystubPdfError')) } const pdfBlob = await readableStreamToBlob(response.value.responseStream, 'application/pdf') - - const url = URL.createObjectURL(pdfBlob) - - // Load the PDF into the new window - if (newWindow) { - newWindow.location.href = url - } + tab.navigate(pdfBlob) onEvent(componentEvents.RUN_PAYROLL_PDF_PAYSTUB_VIEWED, { employeeId }) - URL.revokeObjectURL(url) // Clean up the URL object after use } catch (err) { - if (newWindow) { - newWindow.close() - } + tab.close() showBoundary(err instanceof Error ? err : new Error(String(err))) + } finally { + setDownloadingEmployeeIds(prev => { + if (!prev.has(employeeId)) return prev + const next = new Set(prev) + next.delete(employeeId) + return next + }) } } const onSubmit = async () => { @@ -487,6 +496,7 @@ const Root = ({ withReimbursements={withReimbursements} paymentSpeed={paymentSpeed} pagination={pagination} + downloadingEmployeeIds={downloadingEmployeeIds} /> ) } diff --git a/src/components/Payroll/PayrollOverview/PayrollOverviewPresentation.tsx b/src/components/Payroll/PayrollOverview/PayrollOverviewPresentation.tsx index 94c2880c97..12bd597274 100644 --- a/src/components/Payroll/PayrollOverview/PayrollOverviewPresentation.tsx +++ b/src/components/Payroll/PayrollOverview/PayrollOverviewPresentation.tsx @@ -58,6 +58,7 @@ interface PayrollOverviewProps { onUnblockOptionChange?: (blockerType: string, value: string) => void withReimbursements?: boolean paymentSpeed?: PaymentSpeed + downloadingEmployeeIds?: ReadonlySet } const getPayrollOverviewTitle = ( @@ -93,6 +94,7 @@ export const PayrollOverviewPresentation = ({ withReimbursements = true, paymentSpeed, pagination, + downloadingEmployeeIds = new Set(), }: PayrollOverviewProps) => { const { Alert, Badge, Button, ButtonIcon, Dialog, Heading, Text, Tabs } = useComponentContext() useI18n('Payroll.PayrollOverview') @@ -272,21 +274,27 @@ export const PayrollOverviewPresentation = ({ companyPaysColumns.push({ key: 'paystubs', title: t('tableHeaders.paystub'), - render: (employeeCompensations: EmployeeCompensations) => ( - - { - if (employeeCompensations.employeeUuid) { - onPaystubDownload(employeeCompensations.employeeUuid) - } - }} - > - - - - ), + render: (employeeCompensations: EmployeeCompensations) => { + const isDownloading = + !!employeeCompensations.employeeUuid && + downloadingEmployeeIds.has(employeeCompensations.employeeUuid) + return ( + + { + if (employeeCompensations.employeeUuid) { + onPaystubDownload(employeeCompensations.employeeUuid) + } + }} + > + + + + ) + }, }) } const tabs = [ @@ -301,19 +309,25 @@ export const PayrollOverviewPresentation = ({ pagination={pagination} itemMenu={ isProcessed && !isDesktop - ? (employeeCompensations: EmployeeCompensations) => ( - { - if (employeeCompensations.employeeUuid) { - onPaystubDownload(employeeCompensations.employeeUuid) - } - }} - > - - - ) + ? (employeeCompensations: EmployeeCompensations) => { + const isDownloading = + !!employeeCompensations.employeeUuid && + downloadingEmployeeIds.has(employeeCompensations.employeeUuid) + return ( + { + if (employeeCompensations.employeeUuid) { + onPaystubDownload(employeeCompensations.employeeUuid) + } + }} + > + + + ) + } : undefined } footer={() => ({ diff --git a/src/helpers/openPdfInNewTab.ts b/src/helpers/openPdfInNewTab.ts new file mode 100644 index 0000000000..cd9bfd6354 --- /dev/null +++ b/src/helpers/openPdfInNewTab.ts @@ -0,0 +1,51 @@ +interface PdfTabHandle { + navigate(blob: Blob): void + close(): void +} + +/** + * Opens a new browser tab with a loading spinner, returning a handle to + * navigate it to a PDF blob or close it on error. + * + * @internal + */ +export function openPdfInNewTab(options: { loadingMessage: string; nonce?: string }): PdfTabHandle { + const newWindow = window.open('', '_blank') + + if (newWindow) { + const doc = newWindow.document + doc.title = options.loadingMessage + const style = doc.createElement('style') + if (options.nonce) style.nonce = options.nonce + style.textContent = + 'body{font-family:system-ui,-apple-system,sans-serif;display:flex;align-items:center;' + + 'justify-content:center;height:100vh;margin:0;color:#444;gap:12px}' + + '.spinner{width:20px;height:20px;border:2px solid #ccc;border-top-color:#444;' + + 'border-radius:50%;animation:spin .8s linear infinite}' + + '@keyframes spin{to{transform:rotate(360deg)}}' + doc.head.appendChild(style) + const spinner = doc.createElement('div') + spinner.className = 'spinner' + spinner.setAttribute('aria-hidden', 'true') + const label = doc.createElement('span') + label.textContent = options.loadingMessage + doc.body.replaceChildren(spinner, label) + } + + return { + navigate(blob: Blob) { + const url = URL.createObjectURL(blob) + if (newWindow) { + newWindow.addEventListener('load', () => { + URL.revokeObjectURL(url) + }) + newWindow.location.href = url + } else { + URL.revokeObjectURL(url) + } + }, + close() { + if (newWindow) newWindow.close() + }, + } +} diff --git a/src/i18n/en/Payroll.PayrollOverview.json b/src/i18n/en/Payroll.PayrollOverview.json index 21c0eda2a0..7959a58924 100644 --- a/src/i18n/en/Payroll.PayrollOverview.json +++ b/src/i18n/en/Payroll.PayrollOverview.json @@ -17,6 +17,7 @@ "declineCancelCta": "No, go back", "payrollReceiptCta": "View payroll receipt", "downloadPaystubLabel": "Download paystub pdf", + "downloadLoadingMessage": "Generating paystub…", "loadingTitle": "Submitting payroll...", "loadingDescription": "This may take a minute or two. You can navigate away while this happens.", "dataLoadingTitle": "Loading payroll...", diff --git a/src/i18n/types.d.ts b/src/i18n/types.d.ts index 86da0385c7..481903d734 100644 --- a/src/i18n/types.d.ts +++ b/src/i18n/types.d.ts @@ -7735,6 +7735,8 @@ export namespace Translations { payrollReceiptCta: string /** @defaultValue `"Download paystub pdf"` */ downloadPaystubLabel: string + /** @defaultValue `"Generating paystub…"` */ + downloadLoadingMessage: string /** @defaultValue `"Submitting payroll..."` */ loadingTitle: string /** @defaultValue `"This may take a minute or two. You can navigate away while this happens."` */