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 = ({
<>
- {STEPS.map((step, index) => (
+ {/* {STEPS.map((step, index) => (
-
{step}
- ))}
+ ))} */}
+ {onboardingBag.steps
+ .filter((step) => step.visible)
+ .map((step, index) => (
+ -
+ {index + 1}. {step.label}
+
+ ))}
@@ -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.