diff --git a/src/flows/Onboarding/components/OnboardingInvite.tsx b/src/flows/Onboarding/components/OnboardingInvite.tsx index 0f1d8dacb..5b0c166bc 100644 --- a/src/flows/Onboarding/components/OnboardingInvite.tsx +++ b/src/flows/Onboarding/components/OnboardingInvite.tsx @@ -5,6 +5,7 @@ import { FieldError, mutationToPromise } from '@/src/lib/mutations'; import { SuccessResponse } from '@/src/client'; import { useOnboardingContext } from '@/src/flows/Onboarding/context'; import { useFormFields } from '@/src/context'; +import { statusesWithReserveAlreadyHandled } from '@/src/flows/Onboarding/utils'; export type OnboardingInviteProps = Omit< ButtonHTMLAttributes, @@ -56,15 +57,20 @@ export function OnboardingInvite({ onboardingBag.creditRiskStatus === 'deposit_required' || onboardingBag.onboardingReservesStatus === 'deposit_required'; + const isReserveFlow = Boolean( + isDepositRequired && + onboardingBag.employment?.status && + !statusesWithReserveAlreadyHandled.includes( + onboardingBag.employment.status, + ), + ); + + const shouldCreateReserve = Boolean(isReserveFlow && onboardingBag.canInvite); + const handleSubmit = async () => { try { await onSubmit?.(); - if ( - isDepositRequired && - onboardingBag.employmentId && - onboardingBag.employment?.status && - !onboardingBag.isEmploymentReadOnly - ) { + if (shouldCreateReserve && onboardingBag.employmentId) { const response = await createReserveInvoiceMutationAsync({ employment_slug: onboardingBag.employmentId, }); @@ -121,11 +127,6 @@ export function OnboardingInvite({ } }; - const isReserveFlow = - isDepositRequired && - onboardingBag.employment?.status && - !onboardingBag.isEmploymentReadOnly; - const CustomButton = components?.button; if (!CustomButton) { throw new Error(`Button component not found`); diff --git a/src/flows/Onboarding/hooks.tsx b/src/flows/Onboarding/hooks.tsx index 5bdb94dce..940921ab0 100644 --- a/src/flows/Onboarding/hooks.tsx +++ b/src/flows/Onboarding/hooks.tsx @@ -90,6 +90,8 @@ const getLoadingStates = ({ contractDetailsFields, arePreOnboardingRequirementsFulfilled, isLoadingOnboardingReservesStatus, + isLoadingPreOnboardingRequirements, + shouldFreezeEmploymentData, }: { isLoadingBasicInformationForm: boolean; isLoadingContractDetailsForm: boolean; @@ -102,6 +104,7 @@ const getLoadingStates = ({ isLoadingCompany: boolean; isLoadingCountries: boolean; isLoadingEmploymentAgreementPreview: boolean; + isLoadingPreOnboardingRequirements: boolean; employmentStatus?: Employment['status']; employmentId?: string; currentStepName: string; @@ -109,6 +112,7 @@ const getLoadingStates = ({ contractDetailsFields: JSFFields; arePreOnboardingRequirementsFulfilled: boolean; isLoadingOnboardingReservesStatus: boolean; + shouldFreezeEmploymentData: boolean; }) => { const initialLoading = isLoadingBasicInformationForm || @@ -121,17 +125,22 @@ const getLoadingStates = ({ isLoadingCompany || isLoadingCountries || isLoadingEmploymentAgreementPreview || - isLoadingContractDetailsFormV1; + isLoadingContractDetailsFormV1 || + isLoadingPreOnboardingRequirements || + isLoadingOnboardingReservesStatus; + // employment needs to be readonly if its one of the following conditions is met: + // - the employment status is in the review step allowed employment status list + // - the employment data is frozen by a pre-onboarding requirement const isEmploymentReadOnly = - employmentStatus && - reviewStepAllowedEmploymentStatus.includes(employmentStatus); + (employmentStatus && + reviewStepAllowedEmploymentStatus.includes(employmentStatus)) || + shouldFreezeEmploymentData; const canInvite = employmentStatus && !disabledInviteButtonEmploymentStatus.includes(employmentStatus) && - arePreOnboardingRequirementsFulfilled && - !isLoadingOnboardingReservesStatus; + arePreOnboardingRequirementsFulfilled; const shouldHandleReadOnlyEmployment = Boolean( employmentId && isEmploymentReadOnly && currentStepName !== 'review', @@ -391,6 +400,10 @@ export const useOnboarding = ({ }, }); + const shouldFreezeEmploymentData = Boolean( + requirements?.some((requirement) => requirement.freeze_employment_data), + ); + const arePreOnboardingRequirementsFulfilled = useMemo(() => { // While loading, block the invite if (isLoadingPreOnboardingRequirements) { @@ -923,6 +936,7 @@ export const useOnboarding = ({ isLoadingEmployment, isLoadingBenefitsOffersSchema, isLoadingBenefitOffers, + isLoadingPreOnboardingRequirements, isLoadingCompany, isLoadingCountries, isLoadingEmploymentAgreementPreview, @@ -933,6 +947,7 @@ export const useOnboarding = ({ currentStepName: currentStepName, arePreOnboardingRequirementsFulfilled, isLoadingOnboardingReservesStatus, + shouldFreezeEmploymentData, }), [ isLoadingBasicInformationForm, @@ -942,6 +957,7 @@ export const useOnboarding = ({ isLoadingEmployment, isLoadingBenefitsOffersSchema, isLoadingBenefitOffers, + isLoadingPreOnboardingRequirements, isLoadingCompany, isLoadingCountries, isLoadingEmploymentAgreementPreview, @@ -953,6 +969,7 @@ export const useOnboarding = ({ arePreOnboardingRequirementsFulfilled, isLoadingOnboardingReservesStatus, isLoadingContractDetailsFormV1, + shouldFreezeEmploymentData, ], ); diff --git a/src/flows/Onboarding/tests/OnboardingFlow.test.tsx b/src/flows/Onboarding/tests/OnboardingFlow.test.tsx index 89bf27d35..6550355a5 100644 --- a/src/flows/Onboarding/tests/OnboardingFlow.test.tsx +++ b/src/flows/Onboarding/tests/OnboardingFlow.test.tsx @@ -1198,6 +1198,97 @@ describe('OnboardingFlow', () => { }, ); + it('should automatically navigate to review step when freeze_employment_data is true in pre-onboarding requirement', async () => { + const employmentId = generateUniqueEmploymentId(); + + server.use( + // Mock employment endpoint with 'created' status (editable status normally) + http.get(`*/v1/employments/${employmentId}`, () => { + return HttpResponse.json({ + ...employmentDefaultResponse, + data: { + ...employmentDefaultResponse.data, + employment: { + ...employmentDefaultResponse.data.employment, + id: employmentId, + status: 'created', // Normally editable status + }, + }, + }); + }), + + // Mock pre-onboarding requirements with freeze_employment_data: true + http.get( + `*/v1/onboarding/employments/${employmentId}/pre-onboarding-requirements`, + () => { + return HttpResponse.json({ + data: [ + { + name: 'Individual Labor Agreement', + status: 'awaiting', + type: 'document', + description: 'Individual Labor Agreement required', + slug: '5e39159e-96ef-40ea-82bc-b054917fc82f', + depends_on_requirement: null, + freeze_employment_data: true, + redlining_help_email: null, + supports_redlining: false, + }, + ], + }); + }, + ), + ); + + mockRender.mockImplementation( + ({ onboardingBag, components }: OnboardingRenderProps) => { + const currentStepIndex = onboardingBag.stepState.currentStep.index; + + const steps: Record = { + [0]: 'Basic Information', + [1]: 'Contract Details', + [2]: 'Benefits', + [3]: 'Review', + }; + + return ( + <> +

Step: {steps[currentStepIndex]}

+ + + ); + }, + ); + + render( + , + { + wrapper: TestProviders, + }, + ); + + await waitForElementToBeRemoved(() => screen.getByTestId('spinner')); + + // Should automatically go to review step due to freeze_employment_data: true + await screen.findByText(/Step: Review/i); + + // Verify basic information data is displayed in the Review component + expect(screen.getByText('name: Gabriel')).toBeInTheDocument(); + + // Verify contract details data is displayed in the Review component + expect(screen.getByText('annual_gross_salary: 20000')).toBeInTheDocument(); + }); + it('should not show intermediate steps when automatically navigating to review (no flickering)', async () => { const renderSequence: Array<{ isLoading: boolean; step?: string }> = []; const employmentId = generateUniqueEmploymentId(); // Use a fixed ID for consistency diff --git a/src/flows/Onboarding/tests/OnboardingInvite.test.tsx b/src/flows/Onboarding/tests/OnboardingInvite.test.tsx index 9f3ecb189..9147faac6 100644 --- a/src/flows/Onboarding/tests/OnboardingInvite.test.tsx +++ b/src/flows/Onboarding/tests/OnboardingInvite.test.tsx @@ -1319,16 +1319,10 @@ describe('OnboardingInvite', () => { }); it('should keep button disabled during requirements loading to prevent race condition', async () => { - let resolveRequirements: (value: unknown) => void; - const requirementsPromise = new Promise((resolve) => { - resolveRequirements = resolve; - }); - server.use( http.get( - '*/v1/onboarding/employments/:employmentId/pre-onboarding-requirements', + '*/v1/onboarding/employments/*/pre-onboarding-requirements', async () => { - await requirementsPromise; return HttpResponse.json({ data: [ { @@ -1352,11 +1346,6 @@ describe('OnboardingInvite', () => { await waitForElementToBeRemoved(() => screen.getByTestId('spinner')); const inviteButton = screen.getByTestId('onboarding-invite'); - expect(inviteButton).toBeDisabled(); - - await act(async () => { - resolveRequirements!({}); - }); await waitFor(() => { expect(inviteButton).not.toBeDisabled(); diff --git a/src/flows/Onboarding/utils.ts b/src/flows/Onboarding/utils.ts index 4da1dadb3..3cc0cecae 100644 --- a/src/flows/Onboarding/utils.ts +++ b/src/flows/Onboarding/utils.ts @@ -121,6 +121,18 @@ export const disabledInviteButtonEmploymentStatus: Employment['status'][] = [ 'active', ]; +/** + * Array of employment statuses that have already handled the reserve flow. + * @type {Employment['status'][]} + * @constant + */ +export const statusesWithReserveAlreadyHandled: Employment['status'][] = [ + 'created_awaiting_reserve', + 'created_reserve_paid', + 'invited', + 'active', +]; + export const DEFAULT_VERSION = 1; /**