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
36 changes: 25 additions & 11 deletions example/src/Onboarding.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,6 @@ export const InviteSection = ({
</div>
);
};
const STEPS = [
'Select Country',
'Basic Information',
'Contract Details',
'Benefits',
'Review & Invite',
];

type MultiStepFormProps = {
onboardingBag: OnboardingRenderProps['onboardingBag'];
Expand Down Expand Up @@ -207,13 +200,24 @@ const MultiStepForm = ({ components, onboardingBag }: MultiStepFormProps) => {
}
};

/* const STEPS = [
'Select Country',
'Basic Information',
'Contract Details',
'Benefits',
'Review & Invite',
];
Comment thread
gabrielseco marked this conversation as resolved.
*/
const OnBoardingRender = ({
onboardingBag,
components,
}: MultiStepFormProps) => {
const currentStepIndex = onboardingBag.stepState.currentStep.index;

const stepTitle = STEPS[currentStepIndex];
// When using dynamic_steps feature, you need to filter and use step.index for comparison
// Otherwise, you can use the steps array directly with sequential indices
//const stepTitle = STEPS[currentStepIndex];
const stepTitle = onboardingBag.steps[currentStepIndex].label;

if (onboardingBag.isLoading) {
return <p>Loading...</p>;
Expand All @@ -223,14 +227,24 @@ const OnBoardingRender = ({
<>
<div className='steps-navigation'>
<ul>
{STEPS.map((step, index) => (
{/* {STEPS.map((step, index) => (
<li
key={index}
className={`step-item ${index === currentStepIndex ? 'active' : ''}`}
>
{step}
</li>
))}
))} */}
{onboardingBag.steps
.filter((step) => step.visible)
.map((step, index) => (
<li
key={step.name}
className={`step-item ${step.index === currentStepIndex ? 'active' : ''}`}
>
{index + 1}. {step.label}
</li>
))}
</ul>
</div>

Expand Down Expand Up @@ -264,7 +278,7 @@ const OnboardingWithProps = ({
employmentId={employmentId}
externalId={externalId}
options={{
features: ['onboarding_reserves'],
features: ['onboarding_reserves', 'dynamic_steps'],
jsonSchemaVersion: {
employment_basic_information: 3,
},
Expand Down
60 changes: 42 additions & 18 deletions src/flows/Onboarding/hooks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ import {
getContractDetailsSchemaVersion,
getBasicInformationSchemaVersion,
reviewStepAllowedEmploymentStatus,
STEPS,
STEPS_WITHOUT_SELECT_COUNTRY,
buildSteps,
StepKeys,
} from '@/src/flows/Onboarding/utils';
import { prettifyFormValues } from '@/src/lib/utils';
import {
Expand Down Expand Up @@ -53,12 +53,10 @@ const jsonSchemaToEmployment: Partial<
contract_details: 'contract_details',
};

const stepToFormSchemaMap: Record<
keyof typeof STEPS,
JSONSchemaFormType | null
> = {
const stepToFormSchemaMap: Record<StepKeys, JSONSchemaFormType | null> = {
select_country: null,
basic_information: 'employment_basic_information',
engagement_agreement_details: null,
contract_details: 'contract_details',
benefits: null,
review: null,
Expand Down Expand Up @@ -147,12 +145,23 @@ export const useOnboarding = ({
isUpdating: Boolean(employmentId),
},
});
const stepsToUse = skipSteps?.includes('select_country')
? STEPS_WITHOUT_SELECT_COUNTRY
: STEPS;

const [includeEngagementAgreementDetails] = useState<boolean>(false);
Comment thread
gabrielseco marked this conversation as resolved.

const useDynamicSteps = options?.features?.includes('dynamic_steps') ?? false;

const { steps, stepsArray } = useMemo(
() =>
buildSteps({
includeSelectCountry: !skipSteps?.includes('select_country'),
includeEngagementAgreementDetails,
useDynamicSteps,
}),
[includeEngagementAgreementDetails, skipSteps, useDynamicSteps],
);

const onStepChange = useCallback(
(step: Step<keyof typeof STEPS>) => {
(step: Step<StepKeys>) => {
updateErrorContext({
step: step.name,
});
Expand All @@ -168,10 +177,7 @@ export const useOnboarding = ({
nextStep,
goToStep,
setStepValues,
} = useStepState(
stepsToUse as Record<keyof typeof STEPS, Step<keyof typeof STEPS>>,
onStepChange,
);
} = useStepState(steps, onStepChange);

const fieldsMetaRef = useRef<{
select_country: Meta;
Expand Down Expand Up @@ -446,7 +452,7 @@ export const useOnboarding = ({
const initialValuesBenefitOffers = useMemo(() => {
if (stepState.currentStep.name === 'benefits') {
const benefitsFormValues = {
...stepState.values?.[stepState.currentStep.name as keyof typeof STEPS], // Restore values for the current step
...stepState.values?.[stepState.currentStep.name as StepKeys], // Restore values for the current step
...fieldValues,
};
return mergeWith({}, benefitOffers, benefitsFormValues);
Expand All @@ -459,10 +465,12 @@ export const useOnboarding = ({
fieldValues,
]);

const stepFields: Record<keyof typeof STEPS, JSFFields> = useMemo(
const stepFields: Record<StepKeys, JSFFields> = useMemo(
() => ({
select_country: selectCountryForm?.fields || [],
basic_information: basicInformationForm?.fields || [],
// TODO: Fix later when we have the engagement agreement details form
engagement_agreement_details: [],
contract_details: contractDetailsForm?.fields || [],
benefits: benefitOffersSchema?.fields || [],
review: [],
Expand All @@ -476,11 +484,13 @@ export const useOnboarding = ({
);

const stepFieldsWithFlatFieldsets: Record<
keyof typeof STEPS,
StepKeys,
JSFFieldset | null | undefined
> = {
select_country: null,
basic_information: basicInformationForm?.meta['x-jsf-fieldsets'],
// TODO: Fix later when we have the engagement agreement details form
engagement_agreement_details: null,
contract_details: contractDetailsForm?.meta['x-jsf-fieldsets'],
benefits: null,
review: null,
Expand Down Expand Up @@ -638,6 +648,8 @@ export const useOnboarding = ({
basic_information: basicInformationInitialValues,
contract_details: contractDetailsInitialValues,
benefits: benefitsInitialValues,
// TODO: Fix later when we have the engagement agreement details form
engagement_agreement_details: {},
review: {},
});

Expand Down Expand Up @@ -789,7 +801,7 @@ export const useOnboarding = ({
nextStep();
}

function goTo(step: keyof typeof STEPS) {
function goTo(step: StepKeys) {
goToStep(step);
}

Expand Down Expand Up @@ -959,5 +971,17 @@ export const useOnboarding = ({
* @returns {boolean}
*/
canInvite,

/**
* Steps array for dynamic step navigation
* When 'dynamic_steps' feature is enabled:
* - Returns all steps including hidden ones with their original indices
* - Consumers must filter by `visible` property
* When disabled (default):
* - Returns only visible steps with sequential indices
* - Maintains backwards compatibility with existing implementations
* @returns {Array<{name: string, visible: boolean, index: number, label: string}>}
*/
steps: stepsArray,
};
};
167 changes: 167 additions & 0 deletions src/flows/Onboarding/tests/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
buildSteps,
getBasicInformationSchemaVersion,
getBenefitOffersSchemaVersion,
getContractDetailsSchemaVersion,
Expand Down Expand Up @@ -65,3 +66,169 @@ describe('getBasicInformationSchemaVersion', () => {
expect(result).toEqual(2);
});
});

describe('buildSteps', () => {
describe('legacy behavior (useDynamicSteps: false)', () => {
it('should return sequential indices when select_country is included', () => {
const { steps, stepsArray } = buildSteps({
includeSelectCountry: true,
useDynamicSteps: false,
});

// All visible steps should have sequential indices
expect(stepsArray).toHaveLength(5);
expect(stepsArray[0]).toMatchObject({
name: 'select_country',
index: 0,
visible: true,
});
expect(stepsArray[1]).toMatchObject({
name: 'basic_information',
index: 1,
visible: true,
});
expect(stepsArray[2]).toMatchObject({
name: 'contract_details',
index: 2,
visible: true,
});
expect(stepsArray[3]).toMatchObject({
name: 'benefits',
index: 3,
visible: true,
});
expect(stepsArray[4]).toMatchObject({
name: 'review',
index: 4,
visible: true,
});

// Steps object should match
expect(steps.select_country.index).toBe(0);
expect(steps.basic_information.index).toBe(1);
expect(steps.review.index).toBe(4);
});

it('should return sequential indices when select_country is excluded', () => {
const { steps, stepsArray } = buildSteps({
includeSelectCountry: false,
useDynamicSteps: false,
});

// Only visible steps, with sequential indices starting at 0
expect(stepsArray).toHaveLength(4);
expect(stepsArray[0]).toMatchObject({
name: 'basic_information',
index: 0, // Sequential from 0
visible: true,
});
expect(stepsArray[1]).toMatchObject({
name: 'contract_details',
index: 1,
visible: true,
});
expect(stepsArray[2]).toMatchObject({
name: 'benefits',
index: 2,
visible: true,
});
expect(stepsArray[3]).toMatchObject({
name: 'review',
index: 3,
visible: true,
});

// Steps object should use sequential indices
expect(steps.basic_information.index).toBe(0);
expect(steps.contract_details.index).toBe(1);
expect(steps.review.index).toBe(3);
});
});

describe('new dynamic behavior (useDynamicSteps: true)', () => {
it('should preserve original indices for all steps including hidden ones', () => {
const { steps, stepsArray } = buildSteps({
includeSelectCountry: false,
useDynamicSteps: true,
});

// All steps should be present, including hidden ones
expect(stepsArray).toHaveLength(6);

// Hidden step should still be in the array with original index
expect(stepsArray[0]).toMatchObject({
name: 'select_country',
index: 0, // Original index preserved
visible: false,
});

// Visible steps keep their original indices
expect(stepsArray[1]).toMatchObject({
name: 'basic_information',
index: 1, // Original index, not renumbered
visible: true,
});

expect(stepsArray[2]).toMatchObject({
name: 'engagement_agreement_details',
index: 2,
visible: false,
});

expect(stepsArray[3]).toMatchObject({
name: 'contract_details',
index: 3,
visible: true,
});

// Steps object should preserve original indices
expect(steps.select_country.index).toBe(0);
expect(steps.basic_information.index).toBe(1);
expect(steps.contract_details.index).toBe(3);
expect(steps.review.index).toBe(5);
});

it('should include engagement_agreement_details when enabled', () => {
const { stepsArray } = buildSteps({
includeSelectCountry: true,
includeEngagementAgreementDetails: true,
useDynamicSteps: true,
});

expect(stepsArray).toHaveLength(6);
expect(stepsArray[2]).toMatchObject({
name: 'engagement_agreement_details',
index: 2,
visible: true,
});
});
});

describe('backwards compatibility', () => {
it('should default to legacy behavior when useDynamicSteps is not specified', () => {
const { stepsArray } = buildSteps({
includeSelectCountry: false,
});

// Should filter out hidden steps and use sequential indices
expect(stepsArray).toHaveLength(4);
expect(stepsArray[0].name).toBe('basic_information');
expect(stepsArray[0].index).toBe(0); // Sequential
});

it('should not break existing step index logic', () => {
const { steps } = buildSteps({
includeSelectCountry: true,
useDynamicSteps: false,
});

// Simulate what existing code does: accessing steps by index
const currentStepIndex = 2; // e.g., contract_details
const stepAtIndex = Object.values(steps).find(
(s) => s.index === currentStepIndex,
);

expect(stepAtIndex?.name).toBe('contract_details');
});
});
});
Loading
Loading