diff --git a/e2e/tests/contractor/03-onboarding-edit.spec.ts b/e2e/tests/contractor/03-onboarding-edit.spec.ts index 0181550a0..6f6f0fd91 100644 --- a/e2e/tests/contractor/03-onboarding-edit.spec.ts +++ b/e2e/tests/contractor/03-onboarding-edit.spec.ts @@ -29,7 +29,7 @@ test.describe('ContractorOnboardingFlow - edit re-entry from list lifecycle', () .first() await expect(businessRow).toBeVisible({ timeout: 15000 }) - 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() diff --git a/src/components/Contractor/ContractorList/ContractorList.test.tsx b/src/components/Contractor/ContractorList/ContractorList.test.tsx index de598ff72..f88e395f9 100644 --- a/src/components/Contractor/ContractorList/ContractorList.test.tsx +++ b/src/components/Contractor/ContractorList/ContractorList.test.tsx @@ -45,7 +45,7 @@ describe('ContractorList hamburger menu edit/review CTA', () => { renderWithProviders( {}} />) 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() }) @@ -57,7 +57,7 @@ describe('ContractorList hamburger menu edit/review CTA', () => { renderWithProviders( {}} />) 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() }) @@ -69,7 +69,7 @@ describe('ContractorList hamburger menu edit/review CTA', () => { renderWithProviders( {}} />) 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() }) @@ -81,7 +81,7 @@ describe('ContractorList hamburger menu edit/review CTA', () => { renderWithProviders( {}} />) 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() }) @@ -89,7 +89,7 @@ describe('ContractorList hamburger menu edit/review CTA', () => { 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) @@ -97,9 +97,12 @@ describe('ContractorList cancel self-onboarding action', () => { renderWithProviders( {}} />) 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() }, ) @@ -112,7 +115,7 @@ describe('ContractorList cancel self-onboarding action', () => { renderWithProviders( {}} />) 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( @@ -140,7 +143,7 @@ describe('ContractorList cancel self-onboarding action', () => { renderWithProviders() 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(() => { diff --git a/src/components/Contractor/ContractorList/index.tsx b/src/components/Contractor/ContractorList/index.tsx index d68bde0f4..0ee18ff67 100644 --- a/src/components/Contractor/ContractorList/index.tsx +++ b/src/components/Contractor/ContractorList/index.tsx @@ -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' @@ -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: , onClick: () => { handleEdit(contractor.uuid) }, - }, - ] + }) + } if (canCancelSelfOnboarding) { menuItems.push({ @@ -167,13 +173,7 @@ function Root({ companyId, className, dictionary, successMessage }: ContractorLi }, }) - return ( - - ) + return }, emptyState: () => , pagination: { diff --git a/src/components/Contractor/Submit/Submit.test.tsx b/src/components/Contractor/Submit/Submit.test.tsx new file mode 100644 index 000000000..f146a5ae2 --- /dev/null +++ b/src/components/Contractor/Submit/Submit.test.tsx @@ -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() + + // 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() + + // 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() + + // No documents section when there are no documents to collect + expect(screen.queryByText('Documents')).not.toBeInTheDocument() + }) +}) diff --git a/src/components/Contractor/Submit/Submit.tsx b/src/components/Contractor/Submit/Submit.tsx index 2d94bc586..b2a0f04ec 100644 --- a/src/components/Contractor/Submit/Submit.tsx +++ b/src/components/Contractor/Submit/Submit.tsx @@ -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}. @@ -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 })) @@ -123,38 +124,12 @@ const Root = ({ contractorId, selfOnboarding, dictionary }: ContractorSubmitProp {t('heading')} - {documentsToCollect.length > 0 && ( - - } - > - - {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 ( - - ) - })} - {hasW9 && } - - - )} + @@ -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 ( + + } + > + + {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 ( + + ) + })} + {hasW9 && } + + + ) +} + const DocumentRequirementItem = ({ title, description, diff --git a/src/components/Contractor/shared/useContractorHasSignedW9.ts b/src/components/Contractor/shared/useContractorHasSignedW9.ts new file mode 100644 index 000000000..b96933c4a --- /dev/null +++ b/src/components/Contractor/shared/useContractorHasSignedW9.ts @@ -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) +} diff --git a/src/test/mocks/apis/contractors.ts b/src/test/mocks/apis/contractors.ts index ec163fd1b..c2b4d32f0 100644 --- a/src/test/mocks/apis/contractors.ts +++ b/src/test/mocks/apis/contractors.ts @@ -28,10 +28,18 @@ export function handleGetContractorsList(resolver: HttpResponseResolver) { return http.get(`${API_BASE_URL}/v1/companies/:company_uuid/contractors`, resolver) } +export function handleGetContractorOnboardingStatus(resolver: HttpResponseResolver) { + return http.get(`${API_BASE_URL}/v1/contractors/:contractor_uuid/onboarding_status`, resolver) +} + export function handleUpdateContractorOnboardingStatus(resolver: HttpResponseResolver) { return http.put(`${API_BASE_URL}/v1/contractors/:contractor_uuid/onboarding_status`, resolver) } +export function handleGetContractorDocuments(resolver: HttpResponseResolver) { + return http.get(`${API_BASE_URL}/v1/contractors/:contractor_uuid/documents`, resolver) +} + const contractorFixture = { uuid: 'contractor-123', company_uuid: '123', @@ -99,4 +107,28 @@ export const updateContractor = handleUpdateContractor(async ({ request }) => { }) }) +/** + * Factory to create a GET onboarding status handler with custom response data. + */ +handleGetContractorOnboardingStatus.with = (data: Partial> = {}) => + handleGetContractorOnboardingStatus(() => + HttpResponse.json({ + contractor_onboarding_status: { + onboarding_status: 'admin_onboarding_incomplete', + ...data, + }, + }), + ) + +/** + * Factory to create a GET documents handler with custom response data. + */ +handleGetContractorDocuments.with = (data: Partial<{ documents: unknown[] }> = {}) => + handleGetContractorDocuments(() => + HttpResponse.json({ + documents: [], + ...data, + }), + ) + export default [getContractorsList, getContractor, createContractor, updateContractor]