Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/reference/Translations/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -5273,6 +5273,7 @@ Translation keys for the `Payroll.PayrollOverview` i18n namespace.
| `dataViews.taxesTable` | `"Taxes breakdown"` |
| <a id="property-payrollpayrolloverviewdeclinecancelcta"></a> `declineCancelCta` | `"No, go back"` |
| <a id="property-payrollpayrolloverviewdirectdepositlabel"></a> `directDepositLabel` | `"Direct deposits"` |
| <a id="property-payrollpayrolloverviewdownloadloadingmessage"></a> `downloadLoadingMessage` | `"Generating paystub…"` |
| <a id="property-payrollpayrolloverviewdownloadpaystublabel"></a> `downloadPaystubLabel` | `"Download paystub pdf"` |
| <a id="property-payrollpayrollovervieweditcta"></a> `editCta` | `"Edit"` |
| <a id="property-payrollpayrolloverviewexitflowcta"></a> `exitFlowCta` | `"Save and exit"` |
Expand Down
9 changes: 5 additions & 4 deletions src/components/Employee/Dashboard/Dashboard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -192,10 +192,11 @@ describe('Dashboard', () => {
it('renders dashboard and loads employee data', async () => {
renderWithProviders(<Dashboard employeeId="employee-123" onEvent={onEvent} />)

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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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 => {
Expand Down
44 changes: 27 additions & 17 deletions src/components/Payroll/PayrollOverview/PayrollOverview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -330,6 +333,11 @@ const Root = ({
const { mutateAsync: cancelPayroll } = usePayrollsCancelMutation()

const gustoEmbedded = useGustoEmbeddedContext()
const nonce = useNonce()

const [downloadingEmployeeIds, setDownloadingEmployeeIds] = useState<ReadonlySet<string>>(
() => new Set(),
)

if (!payrollData) {
return <PayrollLoading title={t('dataLoadingTitle')} />
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -487,6 +496,7 @@ const Root = ({
withReimbursements={withReimbursements}
paymentSpeed={paymentSpeed}
pagination={pagination}
downloadingEmployeeIds={downloadingEmployeeIds}
/>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ interface PayrollOverviewProps {
onUnblockOptionChange?: (blockerType: string, value: string) => void
withReimbursements?: boolean
paymentSpeed?: PaymentSpeed
downloadingEmployeeIds?: ReadonlySet<string>
}

const getPayrollOverviewTitle = (
Expand Down Expand Up @@ -93,6 +94,7 @@ export const PayrollOverviewPresentation = ({
withReimbursements = true,
paymentSpeed,
pagination,
downloadingEmployeeIds = new Set<string>(),
}: PayrollOverviewProps) => {
const { Alert, Badge, Button, ButtonIcon, Dialog, Heading, Text, Tabs } = useComponentContext()
useI18n('Payroll.PayrollOverview')
Expand Down Expand Up @@ -272,21 +274,27 @@ export const PayrollOverviewPresentation = ({
companyPaysColumns.push({
key: 'paystubs',
title: t('tableHeaders.paystub'),
render: (employeeCompensations: EmployeeCompensations) => (
<Flex justifyContent="flex-end">
<ButtonIcon
aria-label={t('downloadPaystubLabel')}
variant="tertiary"
onClick={() => {
if (employeeCompensations.employeeUuid) {
onPaystubDownload(employeeCompensations.employeeUuid)
}
}}
>
<DownloadIcon />
</ButtonIcon>
</Flex>
),
render: (employeeCompensations: EmployeeCompensations) => {
const isDownloading =
!!employeeCompensations.employeeUuid &&
downloadingEmployeeIds.has(employeeCompensations.employeeUuid)
return (
<Flex justifyContent="flex-end">
<ButtonIcon
aria-label={t('downloadPaystubLabel')}
variant="tertiary"
isLoading={isDownloading}
onClick={() => {
if (employeeCompensations.employeeUuid) {
onPaystubDownload(employeeCompensations.employeeUuid)
}
}}
>
<DownloadIcon />
</ButtonIcon>
</Flex>
)
},
})
}
const tabs = [
Expand All @@ -301,19 +309,25 @@ export const PayrollOverviewPresentation = ({
pagination={pagination}
itemMenu={
isProcessed && !isDesktop
? (employeeCompensations: EmployeeCompensations) => (
<ButtonIcon
aria-label={t('downloadPaystubLabel')}
variant="tertiary"
onClick={() => {
if (employeeCompensations.employeeUuid) {
onPaystubDownload(employeeCompensations.employeeUuid)
}
}}
>
<DownloadIcon />
</ButtonIcon>
)
? (employeeCompensations: EmployeeCompensations) => {
const isDownloading =
!!employeeCompensations.employeeUuid &&
downloadingEmployeeIds.has(employeeCompensations.employeeUuid)
return (
<ButtonIcon
aria-label={t('downloadPaystubLabel')}
variant="tertiary"
isLoading={isDownloading}
onClick={() => {
if (employeeCompensations.employeeUuid) {
onPaystubDownload(employeeCompensations.employeeUuid)
}
}}
>
<DownloadIcon />
</ButtonIcon>
)
}
: undefined
}
footer={() => ({
Expand Down
51 changes: 51 additions & 0 deletions src/helpers/openPdfInNewTab.ts
Original file line number Diff line number Diff line change
@@ -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()
},
}
}
1 change: 1 addition & 0 deletions src/i18n/en/Payroll.PayrollOverview.json
Original file line number Diff line number Diff line change
Expand Up @@ -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...",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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."` */
Expand Down
Loading