From e06dc1abf3d69391703ac0039cf50b15bb25834c Mon Sep 17 00:00:00 2001 From: Steve Jensen Date: Tue, 14 Jul 2026 16:43:21 -0600 Subject: [PATCH 1/4] feat: require cancelling self-onboarding before editing a contractor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While a contractor is mid self-onboarding (invited or started), the list menu no longer offers "Edit" — only "Cancel self-onboarding" and "Delete" remain, so the admin must cancel self-onboarding to regain edit access. Also drop the Edit/Review-specific hamburger trigger label in favor of the default "Open menu" accessible label, since the trigger only opens the menu. Co-authored-by: Cursor --- .../ContractorList/ContractorList.test.tsx | 19 +++++++++------- .../Contractor/ContractorList/index.tsx | 22 +++++++++---------- 2 files changed, 22 insertions(+), 19 deletions(-) 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: { From c64fa5414f80ce8c2099944c7c0d45cbf0eb96bb Mon Sep 17 00:00:00 2001 From: Steve Jensen Date: Tue, 14 Jul 2026 16:59:33 -0600 Subject: [PATCH 2/4] feat: hide W-9 instructions when contractor has signed W-9 When contractors complete self-onboarding and sign their W-9, hide the manual W-9 collection instructions on the Submit screen since the document is already on file. Introduces useContractorHasSignedW9 hook to check if a contractor has a signed W-9 document, then conditionally renders the documents section based on that check. Co-authored-by: Cursor --- .../Contractor/Submit/Submit.test.tsx | 129 ++++++++++++++++++ src/components/Contractor/Submit/Submit.tsx | 100 +++++++++----- .../shared/useContractorHasSignedW9.ts | 14 ++ src/test/mocks/apis/contractors.ts | 32 +++++ 4 files changed, 242 insertions(+), 33 deletions(-) create mode 100644 src/components/Contractor/Submit/Submit.test.tsx create mode 100644 src/components/Contractor/shared/useContractorHasSignedW9.ts diff --git a/src/components/Contractor/Submit/Submit.test.tsx b/src/components/Contractor/Submit/Submit.test.tsx new file mode 100644 index 000000000..1490c601a --- /dev/null +++ b/src/components/Contractor/Submit/Submit.test.tsx @@ -0,0 +1,129 @@ +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 { 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({ + contractor: { + uuid: 'contractor-uuid', + type: 'Individual', + first_name: 'Test', + last_name: 'Contractor', + }, + }), + ), + handleGetContractorOnboardingStatus(() => + HttpResponse.json({ + contractor_onboarding_status: { + uuid: 'status-uuid', + onboarding_status: 'admin_onboarding_review', + onboarding_steps: [], + }, + }), + ), + handleGetContractorDocuments(() => + HttpResponse.json({ + documents: [ + { + 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(/Document Requirements/i)).not.toBeInTheDocument() + }) + + test('shows documents section when contractor has not signed W-9', async () => { + server.use( + handleGetContractor(() => + HttpResponse.json({ + contractor: { + uuid: 'contractor-uuid', + type: 'Individual', + first_name: 'Test', + last_name: 'Contractor', + }, + }), + ), + handleGetContractorOnboardingStatus(() => + HttpResponse.json({ + contractor_onboarding_status: { + uuid: 'status-uuid', + onboarding_status: 'admin_onboarding_review', + onboarding_steps: [], + }, + }), + ), + handleGetContractorDocuments(() => + HttpResponse.json({ + documents: [ + { + uuid: 'doc-uuid', + name: 'taxpayer_identification_form_w_9', + requires_signing: true, + signed_at: null, + }, + ], + }), + ), + ) + + renderWithProviders() + + // Documents section SHOULD render when W-9 is not signed + expect(await screen.findByText(/Document Requirements/i)).toBeInTheDocument() + }) + + test('hides documents section when there are no documents to collect', () => { + server.use( + handleGetContractor(() => + HttpResponse.json({ + contractor: { + uuid: 'contractor-uuid', + type: 'Individual', + first_name: 'Test', + last_name: 'Contractor', + }, + }), + ), + handleGetContractorOnboardingStatus(() => + HttpResponse.json({ + contractor_onboarding_status: { + uuid: 'status-uuid', + onboarding_status: 'admin_onboarding_review', + onboarding_steps: [], + }, + }), + ), + handleGetContractorDocuments(() => + HttpResponse.json({ + documents: [], + }), + ), + ) + + renderWithProviders() + + // No documents section when there are no documents to collect + expect(screen.queryByText(/Document Requirements/i)).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] From 9f109c978346c9c368fe02751770e2d859589b25 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 14 Jul 2026 23:16:40 +0000 Subject: [PATCH 3/4] fix: correct API response mocks in Submit component tests - Remove incorrect wrapper keys (contractor, contractor_onboarding_status, documents) - API responses should be flat objects or arrays, not wrapped - Add PDF document mock handler for document fetching - Update test assertions to match actual rendered text ('Documents' not 'Document Requirements') --- .../Contractor/Submit/Submit.test.tsx | 105 ++++++++---------- 1 file changed, 46 insertions(+), 59 deletions(-) diff --git a/src/components/Contractor/Submit/Submit.test.tsx b/src/components/Contractor/Submit/Submit.test.tsx index 1490c601a..f146a5ae2 100644 --- a/src/components/Contractor/Submit/Submit.test.tsx +++ b/src/components/Contractor/Submit/Submit.test.tsx @@ -6,6 +6,7 @@ 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', () => { @@ -15,74 +16,68 @@ describe('ContractorSubmit', () => { server.use( handleGetContractor(() => HttpResponse.json({ - contractor: { - uuid: 'contractor-uuid', - type: 'Individual', - first_name: 'Test', - last_name: 'Contractor', - }, + uuid: 'contractor-uuid', + type: 'Individual', + first_name: 'Test', + last_name: 'Contractor', }), ), handleGetContractorOnboardingStatus(() => HttpResponse.json({ - contractor_onboarding_status: { - uuid: 'status-uuid', - onboarding_status: 'admin_onboarding_review', - onboarding_steps: [], - }, + uuid: 'status-uuid', + onboarding_status: 'admin_onboarding_review', + onboarding_steps: [], }), ), handleGetContractorDocuments(() => - HttpResponse.json({ - documents: [ - { - uuid: 'doc-uuid', - name: 'taxpayer_identification_form_w_9', - requires_signing: true, - signed_at: '2025-01-15T12:00:00Z', - }, - ], - }), + 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(/Document Requirements/i)).not.toBeInTheDocument() + expect(screen.queryByText('Documents')).not.toBeInTheDocument() }) test('shows documents section when contractor has not signed W-9', async () => { server.use( handleGetContractor(() => HttpResponse.json({ - contractor: { - uuid: 'contractor-uuid', - type: 'Individual', - first_name: 'Test', - last_name: 'Contractor', - }, + uuid: 'contractor-uuid', + type: 'Individual', + first_name: 'Test', + last_name: 'Contractor', }), ), handleGetContractorOnboardingStatus(() => HttpResponse.json({ - contractor_onboarding_status: { - uuid: 'status-uuid', - onboarding_status: 'admin_onboarding_review', - onboarding_steps: [], - }, + 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({ - documents: [ - { - uuid: 'doc-uuid', - name: 'taxpayer_identification_form_w_9', - requires_signing: true, - signed_at: null, - }, - ], + uuid: 'doc-uuid', + document_url: 'https://example.com/w9.pdf', }), ), ) @@ -90,40 +85,32 @@ describe('ContractorSubmit', () => { renderWithProviders() // Documents section SHOULD render when W-9 is not signed - expect(await screen.findByText(/Document Requirements/i)).toBeInTheDocument() + expect(await screen.findByText('Documents')).toBeInTheDocument() }) test('hides documents section when there are no documents to collect', () => { server.use( handleGetContractor(() => HttpResponse.json({ - contractor: { - uuid: 'contractor-uuid', - type: 'Individual', - first_name: 'Test', - last_name: 'Contractor', - }, + uuid: 'contractor-uuid', + type: 'Individual', + first_name: 'Test', + last_name: 'Contractor', }), ), handleGetContractorOnboardingStatus(() => HttpResponse.json({ - contractor_onboarding_status: { - uuid: 'status-uuid', - onboarding_status: 'admin_onboarding_review', - onboarding_steps: [], - }, - }), - ), - handleGetContractorDocuments(() => - HttpResponse.json({ - documents: [], + 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(/Document Requirements/i)).not.toBeInTheDocument() + expect(screen.queryByText('Documents')).not.toBeInTheDocument() }) }) From e7abba6f0fc17ef4cf6ca304700edbbcea562960 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 14 Jul 2026 23:32:42 +0000 Subject: [PATCH 4/4] fix: update E2E test to use 'Open menu' button label The contractor list menu trigger now uses 'Open menu' as the accessible label instead of 'Edit', matching the PR changes that dropped the Edit/Review-specific trigger labels. --- e2e/tests/contractor/03-onboarding-edit.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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()