diff --git a/example/src/Onboarding.tsx b/example/src/Onboarding.tsx index 3be3691f..45666833 100644 --- a/example/src/Onboarding.tsx +++ b/example/src/Onboarding.tsx @@ -35,13 +35,6 @@ export const InviteSection = ({ ); }; -const STEPS = [ - 'Select Country', - 'Basic Information', - 'Contract Details', - 'Benefits', - 'Review & Invite', -]; type MultiStepFormProps = { onboardingBag: OnboardingRenderProps['onboardingBag']; @@ -207,13 +200,24 @@ const MultiStepForm = ({ components, onboardingBag }: MultiStepFormProps) => { } }; +/* const STEPS = [ + 'Select Country', + 'Basic Information', + 'Contract Details', + 'Benefits', + 'Review & Invite', +]; + */ 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

Loading...

; @@ -223,14 +227,24 @@ const OnBoardingRender = ({ <>
@@ -264,7 +278,7 @@ const OnboardingWithProps = ({ employmentId={employmentId} externalId={externalId} options={{ - features: ['onboarding_reserves'], + features: ['onboarding_reserves', 'dynamic_steps'], jsonSchemaVersion: { employment_basic_information: 3, }, diff --git a/src/flows/Onboarding/hooks.tsx b/src/flows/Onboarding/hooks.tsx index b7ee9d20..0f890479 100644 --- a/src/flows/Onboarding/hooks.tsx +++ b/src/flows/Onboarding/hooks.tsx @@ -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 { @@ -53,12 +53,10 @@ const jsonSchemaToEmployment: Partial< contract_details: 'contract_details', }; -const stepToFormSchemaMap: Record< - keyof typeof STEPS, - JSONSchemaFormType | null -> = { +const stepToFormSchemaMap: Record = { select_country: null, basic_information: 'employment_basic_information', + engagement_agreement_details: null, contract_details: 'contract_details', benefits: null, review: null, @@ -147,12 +145,23 @@ export const useOnboarding = ({ isUpdating: Boolean(employmentId), }, }); - const stepsToUse = skipSteps?.includes('select_country') - ? STEPS_WITHOUT_SELECT_COUNTRY - : STEPS; + + const [includeEngagementAgreementDetails] = useState(false); + + 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) => { + (step: Step) => { updateErrorContext({ step: step.name, }); @@ -168,10 +177,7 @@ export const useOnboarding = ({ nextStep, goToStep, setStepValues, - } = useStepState( - stepsToUse as Record>, - onStepChange, - ); + } = useStepState(steps, onStepChange); const fieldsMetaRef = useRef<{ select_country: Meta; @@ -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); @@ -459,10 +465,12 @@ export const useOnboarding = ({ fieldValues, ]); - const stepFields: Record = useMemo( + const stepFields: Record = 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: [], @@ -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, @@ -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: {}, }); @@ -789,7 +801,7 @@ export const useOnboarding = ({ nextStep(); } - function goTo(step: keyof typeof STEPS) { + function goTo(step: StepKeys) { goToStep(step); } @@ -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, }; }; diff --git a/src/flows/Onboarding/tests/utils.test.ts b/src/flows/Onboarding/tests/utils.test.ts index 0fc83c12..6d5c2302 100644 --- a/src/flows/Onboarding/tests/utils.test.ts +++ b/src/flows/Onboarding/tests/utils.test.ts @@ -1,4 +1,5 @@ import { + buildSteps, getBasicInformationSchemaVersion, getBenefitOffersSchemaVersion, getContractDetailsSchemaVersion, @@ -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'); + }); + }); +}); diff --git a/src/flows/Onboarding/types.ts b/src/flows/Onboarding/types.ts index 14d87b7d..78cc58ae 100644 --- a/src/flows/Onboarding/types.ts +++ b/src/flows/Onboarding/types.ts @@ -49,7 +49,7 @@ export type OnboardingRenderProps = { }; }; -type OnboardingFeatures = 'onboarding_reserves'; +type OnboardingFeatures = 'onboarding_reserves' | 'dynamic_steps'; /** * JSON schema version configuration for a specific country @@ -144,7 +144,8 @@ export type OnboardingFlowProps = { /** * The features to use for the onboarding. * This is used to enable or disable features for the onboarding. - * Currently only supports enabling the onboarding reserves feature. + * - 'onboarding_reserves': Enable onboarding reserves feature + * - 'dynamic_steps': Enable dynamic step generation with visibility control (opt-in, will be default in next major version) */ features?: OnboardingFeatures[]; }; diff --git a/src/flows/Onboarding/utils.ts b/src/flows/Onboarding/utils.ts index 4cf726e7..baa7f5f0 100644 --- a/src/flows/Onboarding/utils.ts +++ b/src/flows/Onboarding/utils.ts @@ -1,33 +1,97 @@ import { Employment, OnboardingFlowProps } from '@/src/flows/Onboarding/types'; import { Step } from '@/src/flows/useStepState'; -type StepKeys = +export type StepKeys = | 'select_country' | 'basic_information' + | 'engagement_agreement_details' | 'contract_details' | 'benefits' | 'review'; -export const STEPS: Record> = { - select_country: { - index: 0, - name: 'select_country', - }, - basic_information: { index: 1, name: 'basic_information' }, - contract_details: { index: 2, name: 'contract_details' }, - benefits: { index: 3, name: 'benefits' }, - review: { index: 4, name: 'review' }, -} as const; +type StepConfig = { + includeSelectCountry?: boolean; + includeEngagementAgreementDetails?: boolean; + useDynamicSteps?: boolean; +}; + +export function buildSteps(config: StepConfig = {}) { + const stepDefinitions: Array<{ + name: StepKeys; + label: string; + visible: boolean; + }> = [ + { + name: 'select_country', + label: 'Select Country', + visible: Boolean(config?.includeSelectCountry), + }, + { + name: 'basic_information', + label: 'Basic Information', + visible: true, + }, + { + name: 'engagement_agreement_details', + label: 'Engagement Agreement Details', + visible: Boolean(config?.includeEngagementAgreementDetails), + }, + { + name: 'contract_details', + label: 'Contract Details', + visible: true, + }, + { + name: 'benefits', + label: 'Benefits', + visible: true, + }, + { + name: 'review', + label: 'Review', + visible: true, + }, + ]; + + // When useDynamicSteps is false (default/legacy behavior): + // - Filter out hidden steps first + // - Assign sequential indices (0, 1, 2, 3...) + // - This maintains backwards compatibility with existing implementations + // + // When useDynamicSteps is true (new behavior): + // - Keep all steps including hidden ones + // - Preserve original indices even for hidden steps + // - Consumers must filter by `visible` property + const stepsArray = config.useDynamicSteps + ? stepDefinitions.map((step, index) => ({ + name: step.name, + index, + label: step.label, + visible: step.visible, + })) + : stepDefinitions + .filter((step) => step.visible) + .map((step, index) => ({ + name: step.name, + index, + label: step.label, + visible: step.visible, + })); + + const steps = stepsArray.reduce( + (acc, step) => { + acc[step.name] = { + index: step.index, + name: step.name, + visible: step.visible, + }; + return acc; + }, + {} as Record>, + ); -export const STEPS_WITHOUT_SELECT_COUNTRY: Record< - Exclude, - Step> -> = { - basic_information: { index: 0, name: 'basic_information' }, - contract_details: { index: 1, name: 'contract_details' }, - benefits: { index: 2, name: 'benefits' }, - review: { index: 3, name: 'review' }, -} as const; + return { steps, stepsArray }; +} /** * Array of employment statuses that are allowed to proceed to the review step.