Skip to content
23 changes: 12 additions & 11 deletions src/flows/Onboarding/components/OnboardingInvite.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLButtonElement>,
Expand Down Expand Up @@ -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,
),
);
Comment thread
cursor[bot] marked this conversation as resolved.

const shouldCreateReserve = Boolean(isReserveFlow && onboardingBag.canInvite);

const handleSubmit = async () => {
try {
await onSubmit?.();
if (
isDepositRequired &&
onboardingBag.employmentId &&
onboardingBag.employment?.status &&
!onboardingBag.isEmploymentReadOnly

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

we cannot rely anymore on the employment being readonly because that would mean that you cannot create a reserve if a preonboarding requirement has shouldFreezeEmploymentData

) {
if (shouldCreateReserve && onboardingBag.employmentId) {
const response = await createReserveInvoiceMutationAsync({
employment_slug: onboardingBag.employmentId,
});
Expand Down Expand Up @@ -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`);
Expand Down
27 changes: 22 additions & 5 deletions src/flows/Onboarding/hooks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ const getLoadingStates = ({
contractDetailsFields,
arePreOnboardingRequirementsFulfilled,
isLoadingOnboardingReservesStatus,
isLoadingPreOnboardingRequirements,
shouldFreezeEmploymentData,
}: {
isLoadingBasicInformationForm: boolean;
isLoadingContractDetailsForm: boolean;
Expand All @@ -102,13 +104,15 @@ const getLoadingStates = ({
isLoadingCompany: boolean;
isLoadingCountries: boolean;
isLoadingEmploymentAgreementPreview: boolean;
isLoadingPreOnboardingRequirements: boolean;
employmentStatus?: Employment['status'];
employmentId?: string;
currentStepName: string;
basicInformationFields: JSFFields;
contractDetailsFields: JSFFields;
arePreOnboardingRequirementsFulfilled: boolean;
isLoadingOnboardingReservesStatus: boolean;
shouldFreezeEmploymentData: boolean;
}) => {
const initialLoading =
isLoadingBasicInformationForm ||
Expand All @@ -121,17 +125,22 @@ const getLoadingStates = ({
isLoadingCompany ||
isLoadingCountries ||
isLoadingEmploymentAgreementPreview ||
isLoadingContractDetailsFormV1;
isLoadingContractDetailsFormV1 ||
isLoadingPreOnboardingRequirements ||

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

we were missing some loadings here

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;
Comment thread
cursor[bot] marked this conversation as resolved.

const canInvite =
employmentStatus &&
!disabledInviteButtonEmploymentStatus.includes(employmentStatus) &&
arePreOnboardingRequirementsFulfilled &&
!isLoadingOnboardingReservesStatus;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

loading not needed here as we put it in the initialLoading

arePreOnboardingRequirementsFulfilled;

const shouldHandleReadOnlyEmployment = Boolean(
employmentId && isEmploymentReadOnly && currentStepName !== 'review',
Expand Down Expand Up @@ -391,6 +400,10 @@ export const useOnboarding = ({
},
});

const shouldFreezeEmploymentData = Boolean(
requirements?.some((requirement) => requirement.freeze_employment_data),
);
Comment thread
cursor[bot] marked this conversation as resolved.

const arePreOnboardingRequirementsFulfilled = useMemo(() => {
// While loading, block the invite
if (isLoadingPreOnboardingRequirements) {
Expand Down Expand Up @@ -923,6 +936,7 @@ export const useOnboarding = ({
isLoadingEmployment,
isLoadingBenefitsOffersSchema,
isLoadingBenefitOffers,
isLoadingPreOnboardingRequirements,
isLoadingCompany,
isLoadingCountries,
isLoadingEmploymentAgreementPreview,
Expand All @@ -933,6 +947,7 @@ export const useOnboarding = ({
currentStepName: currentStepName,
arePreOnboardingRequirementsFulfilled,
isLoadingOnboardingReservesStatus,
shouldFreezeEmploymentData,
}),
[
isLoadingBasicInformationForm,
Expand All @@ -942,6 +957,7 @@ export const useOnboarding = ({
isLoadingEmployment,
isLoadingBenefitsOffersSchema,
isLoadingBenefitOffers,
isLoadingPreOnboardingRequirements,
isLoadingCompany,
isLoadingCountries,
isLoadingEmploymentAgreementPreview,
Expand All @@ -953,6 +969,7 @@ export const useOnboarding = ({
arePreOnboardingRequirementsFulfilled,
isLoadingOnboardingReservesStatus,
isLoadingContractDetailsFormV1,
shouldFreezeEmploymentData,
],
);

Expand Down
91 changes: 91 additions & 0 deletions src/flows/Onboarding/tests/OnboardingFlow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<number, string> = {
[0]: 'Basic Information',
[1]: 'Contract Details',
[2]: 'Benefits',
[3]: 'Review',
};

return (
<>
<h1>Step: {steps[currentStepIndex]}</h1>
<MultiStepFormWithoutCountry
onboardingBag={onboardingBag}
components={components}
/>
</>
);
},
);

render(
<OnboardingFlow
employmentId={employmentId}
skipSteps={['select_country']}
{...defaultProps}
options={{
features: ['pre_onboarding_requirements'],
}}
/>,
{
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
Expand Down
13 changes: 1 addition & 12 deletions src/flows/Onboarding/tests/OnboardingInvite.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1319,16 +1319,10 @@ describe('OnboardingInvite', () => {
});

it('should keep button disabled during requirements loading to prevent race condition', async () => {
let resolveRequirements: (value: unknown) => void;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

simplified the test

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;
Comment thread
cursor[bot] marked this conversation as resolved.
return HttpResponse.json({
data: [
{
Expand All @@ -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();
Expand Down
12 changes: 12 additions & 0 deletions src/flows/Onboarding/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down
Loading