From f35913c92f81272fad40612279fa1a98436025ef Mon Sep 17 00:00:00 2001 From: Dmitriy Abragamov Date: Fri, 21 Aug 2026 16:06:02 -0400 Subject: [PATCH 1/2] feat: add readOnly prop to PayrollOverview Lets a partner render a submit-only view of a calculated payroll with Edit and Cancel hidden, matching gws-flows' RUN_PAYROLL_READ_ONLY deep-link behavior for a single payroll. Co-Authored-By: Claude Sonnet 5 --- docs/reference/payroll/blocks.md | 4 +- .../PayrollOverview.stories.tsx | 94 +++++++++++++++++++ .../PayrollOverview/PayrollOverview.test.tsx | 68 ++++++++++++++ .../PayrollOverview/PayrollOverview.tsx | 22 ++++- .../PayrollOverviewPresentation.test.tsx | 9 ++ .../PayrollOverviewPresentation.tsx | 10 +- 6 files changed, 198 insertions(+), 9 deletions(-) diff --git a/docs/reference/payroll/blocks.md b/docs/reference/payroll/blocks.md index 7837a46ade..7075c17f7b 100644 --- a/docs/reference/payroll/blocks.md +++ b/docs/reference/payroll/blocks.md @@ -637,7 +637,8 @@ The payroll referenced by `payrollId` must already be calculated; rendering with uncalculated payroll throws. Unresolved submission blockers (e.g. fast-ACH threshold, wire-in funding) are surfaced inline and the submit action stays disabled until each blocker has a selected unblock option. While the payroll is processing, the component -polls until success or failure and emits the corresponding event. +polls until success or failure and emits the corresponding event. Pass `readOnly` to hide +the edit and cancel actions while keeping submit available.
@@ -655,6 +656,7 @@ Props for [PayrollOverview](#payrolloverview). | `alerts?` | [`PayrollFlowAlert`](#payrollflowalert)[] | Alert banners to display above the payroll summary. | | `ConfirmWireDetailsComponent?` | [`ConfirmWireDetailsComponentType`](#confirmwiredetailscomponenttype) | Custom component to replace the default wire details confirmation UI. | | `dictionary?` | `Record`\<`"en"`, [`DeepPartial`](../Translations/index.md#deeppartial)\<[`PayrollPayrollOverview`](../Translations/index.md#payrollpayrolloverview)\>\> | Overrides for the component's i18n strings. Supply a partial object whose keys match the component's resource namespace — any omitted keys fall back to SDK defaults. See the [Translation guide](https://docs.gusto.com/embedded-payroll/docs/translation) for details. | +| `readOnly?` | `boolean` | Hides the edit and cancel actions, leaving submit and receipt/paystub actions available. Use for a deep link to a specific payroll where editing shouldn't be offered. Defaults to `false`. | | `withReimbursements?` | `boolean` | Whether reimbursement fields are shown in the totals and per-employee tables. Defaults to `true`. | _Inherits `children`, `className`, `defaultValues`, `FallbackComponent`, `LoaderComponent` from [BaseComponentInterface](../blocks.md#basecomponentinterface)._ diff --git a/src/components/Payroll/PayrollOverview/PayrollOverview.stories.tsx b/src/components/Payroll/PayrollOverview/PayrollOverview.stories.tsx index 6391e6eea6..b834e4d773 100644 --- a/src/components/Payroll/PayrollOverview/PayrollOverview.stories.tsx +++ b/src/components/Payroll/PayrollOverview/PayrollOverview.stories.tsx @@ -1109,6 +1109,100 @@ export const PayrollOverviewStory = () => { ) } +export const PayrollOverviewReadOnly = () => { + return ( + + ) +} + export const PayrollOverviewWithWireFunds = () => { return ( ({ payrollsGetPayStub: vi.fn(), })) +vi.mock('../helpers', async importOriginal => { + const actual = await importOriginal() + return { + ...(actual as Record), + canCancelPayroll: vi.fn(), + } +}) + describe('PayrollOverview polling', () => { const mockOnEvent = vi.fn() @@ -369,3 +378,62 @@ describe('PayrollOverview print checks modal', () => { expect(await screen.findByText('Choose check stock')).toBeInTheDocument() }) }) + +describe('PayrollOverview readOnly mode', () => { + const mockOnEvent = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + mockPayrollData = { ...basePayrollData } + mockIsFetching = false + vi.mocked(canCancelPayroll).mockReturnValue(false) + }) + + it('hides Edit but keeps Submit enabled and functional on an unprocessed payroll', async () => { + const user = userEvent.setup() + mockSubmitPayroll.mockResolvedValue({ payrollUuid: 'payroll-uuid' }) + + renderWithProviders( + , + ) + + expect(await screen.findByRole('button', { name: 'Submit' })).toBeEnabled() + expect(screen.queryByRole('button', { name: 'Edit' })).toBeNull() + + await user.click(screen.getByRole('button', { name: 'Submit' })) + + await waitFor(() => { + expect(mockSubmitPayroll).toHaveBeenCalled() + expect(mockOnEvent).toHaveBeenCalledWith( + componentEvents.RUN_PAYROLL_SUBMITTED, + expect.anything(), + ) + }) + }) + + it('hides Cancel on a processed payroll even when the payroll is otherwise cancellable', async () => { + vi.mocked(canCancelPayroll).mockReturnValue(true) + mockPayrollData = { + ...basePayrollData, + processed: true, + processingRequest: { status: 'submit_success', errors: [] }, + } + + renderWithProviders( + , + ) + + expect(await screen.findByRole('button', { name: 'View payroll receipt' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Cancel payroll' })).toBeNull() + }) +}) diff --git a/src/components/Payroll/PayrollOverview/PayrollOverview.tsx b/src/components/Payroll/PayrollOverview/PayrollOverview.tsx index 1ac354b7c5..8d53d06e5b 100644 --- a/src/components/Payroll/PayrollOverview/PayrollOverview.tsx +++ b/src/components/Payroll/PayrollOverview/PayrollOverview.tsx @@ -57,6 +57,12 @@ export interface PayrollOverviewProps extends BaseComponentInterface<'Payroll.Pa withReimbursements?: boolean /** Custom component to replace the default wire details confirmation UI. */ ConfirmWireDetailsComponent?: ConfirmWireDetailsComponentType + /** + * Hides the edit and cancel actions, leaving submit and receipt/paystub actions available. + * Use for a deep link to a specific payroll where editing shouldn't be offered. Defaults to + * `false`. + */ + readOnly?: boolean } const findUnresolvedBlockersWithOptions = ( @@ -96,7 +102,8 @@ const findWireInRequestUuid = ( * uncalculated payroll throws. Unresolved submission blockers (e.g. fast-ACH threshold, * wire-in funding) are surfaced inline and the submit action stays disabled until each * blocker has a selected unblock option. While the payroll is processing, the component - * polls until success or failure and emits the corresponding event. + * polls until success or failure and emits the corresponding event. Pass `readOnly` to hide + * the edit and cancel actions while keeping submit available. * * @events * | Event | Description | Data | @@ -138,6 +145,7 @@ const Root = ({ alerts, withReimbursements = true, ConfirmWireDetailsComponent = ConfirmWireDetails, + readOnly = false, }: PayrollOverviewProps) => { useComponentDictionary('Payroll.PayrollOverview', dictionary) useI18n('Payroll.PayrollOverview') @@ -283,9 +291,11 @@ const Root = ({ content: ( - + {!readOnly && ( + + )} ), }, @@ -305,6 +315,7 @@ const Root = ({ payrollData?.totals?.companyDebit, payrollData?.payrollStatusMeta?.expectedDebitTime, payrollData?.payrollDeadline, + readOnly, ]) const { data: bankAccountData } = useBankAccountsGetSuspense({ @@ -460,7 +471,8 @@ const Root = ({ payrollData.processed === true || payrollData.processingRequest?.status === PAYROLL_PROCESSING_STATUS.submit_success } - canCancel={canCancelPayroll(payrollData)} + canCancel={canCancelPayroll(payrollData) && !readOnly} + canEdit={!readOnly} payrollData={payrollData} bankAccount={bankAccount} taxes={taxes} diff --git a/src/components/Payroll/PayrollOverview/PayrollOverviewPresentation.test.tsx b/src/components/Payroll/PayrollOverview/PayrollOverviewPresentation.test.tsx index 5688907a34..42e16ddad7 100644 --- a/src/components/Payroll/PayrollOverview/PayrollOverviewPresentation.test.tsx +++ b/src/components/Payroll/PayrollOverview/PayrollOverviewPresentation.test.tsx @@ -665,5 +665,14 @@ describe('PayrollOverviewPresentation', () => { expect(await screen.findByRole('button', { name: /^Edit$/i })).toBeInTheDocument() expect(screen.getByRole('button', { name: /^Submit$/i })).toBeInTheDocument() }) + + it('hides Edit but keeps Submit when canEdit is false on an unprocessed payroll', async () => { + renderWithProviders( + , + ) + + expect(await screen.findByRole('button', { name: /^Submit$/i })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /^Edit$/i })).not.toBeInTheDocument() + }) }) }) diff --git a/src/components/Payroll/PayrollOverview/PayrollOverviewPresentation.tsx b/src/components/Payroll/PayrollOverview/PayrollOverviewPresentation.tsx index 29dfa7c428..94c2880c97 100644 --- a/src/components/Payroll/PayrollOverview/PayrollOverviewPresentation.tsx +++ b/src/components/Payroll/PayrollOverview/PayrollOverviewPresentation.tsx @@ -43,6 +43,7 @@ interface PayrollOverviewProps { status?: PayrollOverviewStatus isProcessed: boolean canCancel?: boolean + canEdit?: boolean alerts?: PayrollFlowAlert[] submissionBlockers?: PayrollSubmissionBlockerType[] selectedUnblockOptions?: Record @@ -82,6 +83,7 @@ export const PayrollOverviewPresentation = ({ status = PayrollOverviewStatus.Viewing, isProcessed, canCancel = false, + canEdit = true, alerts = [], submissionBlockers = [], selectedUnblockOptions = {}, @@ -582,9 +584,11 @@ export const PayrollOverviewPresentation = ({ ) : ( <> - + {canEdit && ( + + )}