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
2 changes: 1 addition & 1 deletion e2e/tests/contractor/03-onboarding-edit.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,9 @@
.getByRole('row')
.filter({ has: page.getByText(/Acme Consulting/i) })
.first()
await expect(businessRow).toBeVisible({ timeout: 15000 })

Check failure on line 30 in e2e/tests/contractor/03-onboarding-edit.spec.ts

View workflow job for this annotation

GitHub Actions / e2e (contractor)

[chromium] › e2e/tests/contractor/03-onboarding-edit.spec.ts:12:3 › ContractorOnboardingFlow - edit re-entry from list lifecycle › opens edit on the existing Business contractor and pre-fills the profile form

3) [chromium] › e2e/tests/contractor/03-onboarding-edit.spec.ts:12:3 › ContractorOnboardingFlow - edit re-entry from list lifecycle › opens edit on the existing Business contractor and pre-fills the profile form Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── Error: expect(locator).toBeVisible() failed Locator: getByRole('row').filter({ has: getByText(/Acme Consulting/i) }).first() Expected: visible Timeout: 15000ms Error: element(s) not found Call log: - Expect "toBeVisible" with timeout 15000ms - waiting for getByRole('row').filter({ has: getByText(/Acme Consulting/i) }).first() 28 | .filter({ has: page.getByText(/Acme Consulting/i) }) 29 | .first() > 30 | await expect(businessRow).toBeVisible({ timeout: 15000 }) | ^ 31 | 32 | const menuTrigger = businessRow.getByRole('button', { name: /open menu/i }).first() 33 | await expect(menuTrigger).toBeVisible() at /home/runner/work/embedded-react-sdk/embedded-react-sdk/e2e/tests/contractor/03-onboarding-edit.spec.ts:30:31

const menuTrigger = businessRow.getByRole('button', { name: /^edit$/i }).first()
const menuTrigger = businessRow.getByRole('button', { name: /open menu/i }).first()
await expect(menuTrigger).toBeVisible()
await menuTrigger.click()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ describe('ContractorList hamburger menu edit/review CTA', () => {
renderWithProviders(<ContractorList companyId="company-123" onEvent={() => {}} />)

await screen.findByText('Ada Lovelace')
await user.click(screen.getByRole('button', { name: 'Edit' }))
await user.click(screen.getByRole('button', { name: 'Open menu' }))

expect(await screen.findByRole('menuitem', { name: 'Edit' })).toBeTruthy()
})
Expand All @@ -57,7 +57,7 @@ describe('ContractorList hamburger menu edit/review CTA', () => {
renderWithProviders(<ContractorList companyId="company-123" onEvent={() => {}} />)

await screen.findByText('Ada Lovelace')
await user.click(screen.getByRole('button', { name: 'Review' }))
await user.click(screen.getByRole('button', { name: 'Open menu' }))

expect(await screen.findByRole('menuitem', { name: 'Review' })).toBeTruthy()
})
Expand All @@ -69,7 +69,7 @@ describe('ContractorList hamburger menu edit/review CTA', () => {
renderWithProviders(<ContractorList companyId="company-123" onEvent={() => {}} />)

await screen.findByText('Ada Lovelace')
await user.click(screen.getByRole('button', { name: 'Edit' }))
await user.click(screen.getByRole('button', { name: 'Open menu' }))

expect(await screen.findByRole('menuitem', { name: 'Edit' })).toBeTruthy()
})
Expand All @@ -81,25 +81,28 @@ describe('ContractorList hamburger menu edit/review CTA', () => {
renderWithProviders(<ContractorList companyId="company-123" onEvent={() => {}} />)

await screen.findByText('Ada Lovelace')
await user.click(screen.getByRole('button', { name: 'Edit' }))
await user.click(screen.getByRole('button', { name: 'Open menu' }))

expect(await screen.findByRole('menuitem', { name: 'Edit' })).toBeTruthy()
})
})

describe('ContractorList cancel self-onboarding action', () => {
it.each(['self_onboarding_invited', 'self_onboarding_started'])(
'offers "Cancel self-onboarding" for %s',
'offers "Cancel self-onboarding" but not "Edit" for %s',
async status => {
mockContractorWithStatus(status)

const user = userEvent.setup()
renderWithProviders(<ContractorList companyId="company-123" onEvent={() => {}} />)

await screen.findByText('Ada Lovelace')
await user.click(screen.getByRole('button', { name: 'Edit' }))
await user.click(screen.getByRole('button', { name: 'Open menu' }))

expect(await screen.findByRole('menuitem', { name: 'Cancel self-onboarding' })).toBeTruthy()
// Editing is blocked mid self-onboarding — the admin must cancel it first.
expect(screen.queryByRole('menuitem', { name: 'Edit' })).not.toBeInTheDocument()
expect(screen.getByRole('menuitem', { name: 'Delete' })).toBeInTheDocument()
},
)

Expand All @@ -112,7 +115,7 @@ describe('ContractorList cancel self-onboarding action', () => {
renderWithProviders(<ContractorList companyId="company-123" onEvent={() => {}} />)

await screen.findByText('Ada Lovelace')
await user.click(screen.getByRole('button', { name: /Edit|Review/ }))
await user.click(screen.getByRole('button', { name: 'Open menu' }))

await screen.findByRole('menuitem', { name: /Edit|Review/ })
expect(
Expand Down Expand Up @@ -140,7 +143,7 @@ describe('ContractorList cancel self-onboarding action', () => {
renderWithProviders(<ContractorList companyId="company-123" onEvent={onEvent} />)

await screen.findByText('Ada Lovelace')
await user.click(screen.getByRole('button', { name: 'Edit' }))
await user.click(screen.getByRole('button', { name: 'Open menu' }))
await user.click(await screen.findByRole('menuitem', { name: 'Cancel self-onboarding' }))

await waitFor(() => {
Expand Down
22 changes: 11 additions & 11 deletions src/components/Contractor/ContractorList/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useContractors } from './useContractorList'
import { ActionsLayout, DataView, EmptyData, Flex, useDataView } from '@/components/Common'
import { firstLastName } from '@/helpers/formattedStrings'
import { HamburgerMenu } from '@/components/Common/HamburgerMenu/HamburgerMenu'
import type { MenuItem } from '@/components/Common/UI/Menu/MenuTypes'
import PencilSvg from '@/assets/icons/pencil.svg?react'
import { useComponentContext } from '@/contexts/ComponentAdapter/useComponentContext'
import { ContractorOnboardingStatusBadge } from '@/components/Common/OnboardingStatusBadge'
Expand Down Expand Up @@ -139,15 +140,20 @@ function Root({ companyId, className, dictionary, successMessage }: ContractorLi
contractor.onboardingStatus === ContractorOnboardingStatus.SELF_ONBOARDING_INVITED ||
contractor.onboardingStatus === ContractorOnboardingStatus.SELF_ONBOARDING_STARTED

const menuItems = [
{
// While the contractor is mid self-onboarding, editing is blocked: the
// admin must first cancel self-onboarding. So the Edit item is omitted
// whenever cancelling is available.
const menuItems: MenuItem[] = []

if (!canCancelSelfOnboarding) {
menuItems.push({
label: editLabel,
icon: <PencilSvg aria-hidden />,
onClick: () => {
handleEdit(contractor.uuid)
},
},
]
})
}

if (canCancelSelfOnboarding) {
menuItems.push({
Expand All @@ -167,13 +173,7 @@ function Root({ companyId, className, dictionary, successMessage }: ContractorLi
},
})

return (
<HamburgerMenu
items={menuItems}
triggerLabel={editLabel}
isLoading={isPendingDelete || isPendingCancel}
/>
)
return <HamburgerMenu items={menuItems} isLoading={isPendingDelete || isPendingCancel} />
},
emptyState: () => <EmptyDataContractorsList handleAdd={handleAdd} />,
pagination: {
Expand Down
116 changes: 116 additions & 0 deletions src/components/Contractor/Submit/Submit.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { describe, test, expect, vi } from 'vitest'
import { screen } from '@testing-library/react'
import { HttpResponse } from 'msw'
import { ContractorSubmit } from './Submit'
import { server } from '@/test/mocks/server'
import { handleGetContractor } from '@/test/mocks/apis/contractors'
import { handleGetContractorOnboardingStatus } from '@/test/mocks/apis/contractors'
import { handleGetContractorDocuments } from '@/test/mocks/apis/contractors'
import { handleGetContractorDocumentPdf } from '@/test/mocks/apis/contractor_documents'
import { renderWithProviders } from '@/test-utils/renderWithProviders'

describe('ContractorSubmit', () => {
const mockOnEvent = vi.fn()

test('hides documents section when contractor has signed W-9', () => {
server.use(
handleGetContractor(() =>
HttpResponse.json({
uuid: 'contractor-uuid',
type: 'Individual',
first_name: 'Test',
last_name: 'Contractor',
}),
),
handleGetContractorOnboardingStatus(() =>
HttpResponse.json({
uuid: 'status-uuid',
onboarding_status: 'admin_onboarding_review',
onboarding_steps: [],
}),
),
handleGetContractorDocuments(() =>
HttpResponse.json([
{
uuid: 'doc-uuid',
name: 'taxpayer_identification_form_w_9',
requires_signing: true,
signed_at: '2025-01-15T12:00:00Z',
},
]),
),
)

renderWithProviders(<ContractorSubmit contractorId="contractor-uuid" onEvent={mockOnEvent} />)

// Documents section should NOT render when W-9 is signed
expect(screen.queryByText('Documents')).not.toBeInTheDocument()
})

test('shows documents section when contractor has not signed W-9', async () => {
server.use(
handleGetContractor(() =>
HttpResponse.json({
uuid: 'contractor-uuid',
type: 'Individual',
first_name: 'Test',
last_name: 'Contractor',
}),
),
handleGetContractorOnboardingStatus(() =>
HttpResponse.json({
uuid: 'status-uuid',
onboarding_status: 'admin_onboarding_review',
onboarding_steps: [],
}),
),
handleGetContractorDocuments(() =>
HttpResponse.json([
{
uuid: 'doc-uuid',
name: 'taxpayer_identification_form_w_9',
requires_signing: true,
signed_at: null,
},
]),
),
handleGetContractorDocumentPdf(() =>
HttpResponse.json({
uuid: 'doc-uuid',
document_url: 'https://example.com/w9.pdf',
}),
),
)

renderWithProviders(<ContractorSubmit contractorId="contractor-uuid" onEvent={mockOnEvent} />)

// Documents section SHOULD render when W-9 is not signed
expect(await screen.findByText('Documents')).toBeInTheDocument()
})

test('hides documents section when there are no documents to collect', () => {
server.use(
handleGetContractor(() =>
HttpResponse.json({
uuid: 'contractor-uuid',
type: 'Individual',
first_name: 'Test',
last_name: 'Contractor',
}),
),
handleGetContractorOnboardingStatus(() =>
HttpResponse.json({
uuid: 'status-uuid',
onboarding_status: 'admin_onboarding_review',
onboarding_steps: [],
}),
),
handleGetContractorDocuments(() => HttpResponse.json([])),
)

renderWithProviders(<ContractorSubmit contractorId="contractor-uuid" onEvent={mockOnEvent} />)

// No documents section when there are no documents to collect
expect(screen.queryByText('Documents')).not.toBeInTheDocument()
})
})
100 changes: 67 additions & 33 deletions src/components/Contractor/Submit/Submit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { BaseComponent, useBase, type BaseComponentInterface } from '@/component
import { componentEvents, ContractorOnboardingStatus } from '@/shared/constants'
import { firstLastName } from '@/helpers/formattedStrings'
import { W9_DOCUMENT_NAME } from '@/components/Contractor/Documents/SignatureForm/useContractorSignatureForm/w9Fields'
import { useContractorHasSignedW9 } from '@/components/Contractor/shared/useContractorHasSignedW9'

/**
* Props for {@link ContractorSubmit}.
Expand Down Expand Up @@ -51,7 +52,7 @@ export function ContractorSubmit(props: ContractorSubmitProps) {
const Root = ({ contractorId, selfOnboarding, dictionary }: ContractorSubmitProps) => {
useI18n('Contractor.Submit')
useComponentDictionary('Contractor.Submit', dictionary)
const { Alert, Box, BoxHeader, Button, Heading, UnorderedList } = useComponentContext()
const { Alert, Button, Heading, UnorderedList } = useComponentContext()
const { t } = useTranslation('Contractor.Submit')
const { onEvent, baseSubmitHandler } = useBase()
const items = Object.values(t('warningItems', { returnObjects: true }))
Expand Down Expand Up @@ -123,38 +124,12 @@ const Root = ({ contractorId, selfOnboarding, dictionary }: ContractorSubmitProp
<Heading as="h2">{t('heading')}</Heading>
</FlexItem>

{documentsToCollect.length > 0 && (
<Box
header={
<BoxHeader
title={t('documentRequirements.title')}
description={t('documentRequirements.description', { contractorName })}
/>
}
>
<Flex flexDirection="column" gap={16}>
{documentsToCollect.map(document => {
const isW9 = document.name === W9_DOCUMENT_NAME
const title = isW9
? t('documentRequirements.documents.taxpayer_identification_form_w_9.title')
: (document.title ?? '')
const description = isW9
? t('documentRequirements.documents.taxpayer_identification_form_w_9.description')
: (document.description ?? '')
return (
<DocumentRequirementItem
key={document.uuid}
title={title}
description={description}
document={document}
downloadLabel={t('documentRequirements.downloadCta')}
/>
)
})}
{hasW9 && <Alert status="info" label={t('documentRequirements.alertLabel')}></Alert>}
</Flex>
</Box>
)}
<ContractorSubmitDocuments
contractorId={contractorId}
documentsToCollect={documentsToCollect}
contractorName={contractorName}
hasW9={hasW9}
/>
<Flex flexDirection="column" gap={8}>
<Alert status="warning" label={t('title')}>
<UnorderedList items={items} />
Expand All @@ -169,6 +144,65 @@ const Root = ({ contractorId, selfOnboarding, dictionary }: ContractorSubmitProp
)
}

const ContractorSubmitDocuments = ({
contractorId,
documentsToCollect,
contractorName,
hasW9,
}: {
contractorId: string
documentsToCollect: Document[]
contractorName: string | null | undefined
hasW9: boolean
}) => {
const hasSignedW9 = useContractorHasSignedW9(contractorId)
const { Alert, Box, BoxHeader } = useComponentContext()
const { t } = useTranslation('Contractor.Submit')

// If the contractor already signed a W-9 during self-onboarding, we don't
// show manual collection instructions.
if (hasSignedW9) {
return null
}

if (documentsToCollect.length === 0) {
return null
}

return (
<Box
header={
<BoxHeader
title={t('documentRequirements.title')}
description={t('documentRequirements.description', { contractorName })}
/>
}
>
<Flex flexDirection="column" gap={16}>
{documentsToCollect.map(document => {
const isW9 = document.name === W9_DOCUMENT_NAME
const title = isW9
? t('documentRequirements.documents.taxpayer_identification_form_w_9.title')
: (document.title ?? '')
const description = isW9
? t('documentRequirements.documents.taxpayer_identification_form_w_9.description')
: (document.description ?? '')
return (
<DocumentRequirementItem
key={document.uuid}
title={title}
description={description}
document={document}
downloadLabel={t('documentRequirements.downloadCta')}
/>
)
})}
{hasW9 && <Alert status="info" label={t('documentRequirements.alertLabel')}></Alert>}
</Flex>
</Box>
)
}

const DocumentRequirementItem = ({
title,
description,
Expand Down
14 changes: 14 additions & 0 deletions src/components/Contractor/shared/useContractorHasSignedW9.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { useContractorDocumentsGetAllSuspense } from '@gusto/embedded-api/react-query/contractorDocumentsGetAll'
import { isW9Document } from '@/components/Contractor/Documents/SignatureForm/useContractorSignatureForm'

/**
* Returns `true` when the contractor has a W-9 with a `signedAt` timestamp on
* file. Fetches via Suspense — must be rendered inside a Suspense boundary
* (SDK `BaseBoundaries` supplies one).
*
* @internal
*/
export function useContractorHasSignedW9(contractorId: string): boolean {
const { data } = useContractorDocumentsGetAllSuspense({ contractorUuid: contractorId })
return (data.documents ?? []).some(doc => isW9Document(doc) && !!doc.signedAt)
}
Comment on lines +1 to +14

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: this is an abstraction that we're using in this PR #2394 whoever merges first will rebase and get it.

Loading
Loading