Repository: remoteoss/remote-flows
Pull request #97: feat(multi-step) - Multi steps json schemas
Author: login: gabrielseco
Created: 2025-04-26T15:37:36Z | Merged: 2025-04-29T09:39:25Z

Build the form with steps required different changes

* separate each step in a different schema
* maintain the full json schema to still be able to parse all fields
* the form includes next and back buttons and the form state is saved


https://github.com/user-attachments/assets/2c64e2e8-70a0-4bc5-bc85-b1bd93ff75ff






Diff:
diff --git a/example/src/Termination.tsx b/example/src/Termination.tsx
index 3c0dc3649..ea764cd2f 100644
--- a/example/src/Termination.tsx
+++ b/example/src/Termination.tsx
@@ -24,36 +24,27 @@ export const Termination = () => {
               return <div>Loading...</div>;
             }
 
-            const { Form, SubmitButton, TimeOff } = components;
+            const { Form, SubmitButton, Back } = components;
+
+            const currentStepIndex = terminationBag.stepState.currentStep.index;
 
             return (
               <>
-                <TimeOff
-                  render={({ timeoff, employment }) => {
-                    const username = employment.data?.data.employment
-                      ?.basic_information?.name as string;
-                    const days = timeoff?.data?.data?.total_count || 0;
-
-                    // if days is 0 or > 1 'days' else 'day
-                    const daysLiteral = days > 1 || days === 0 ? 'days' : 'day';
-                    return (
-                      <>
-                        <p>
-                          We have recorded {days} {daysLiteral} of paid time off
-                          for {username}
-                        </p>
-                        <a href="#">See {username}'s timeoff breakdown</a>
-                      </>
-                    );
-                  }}
-                />
                 <Form
                   username="ze"
                   onSubmit={(payload) => console.log('payload', payload)}
                   onError={(error) => console.log('error', error)}
                   onSuccess={(data) => console.log('data', data)}
                 />
-                <SubmitButton>Send termination</SubmitButton>
+                {currentStepIndex > 0 && <Back>Back</Back>}
+                {currentStepIndex <=
+                  terminationBag.stepState.totalSteps - 1 && (
+                  <SubmitButton>
+                    {currentStepIndex < terminationBag.stepState.totalSteps - 1
+                      ? 'Next Step'
+                      : 'Send termination'}
+                  </SubmitButton>
+                )}
               </>
             );
           }}
diff --git a/src/common/dates.ts b/src/common/dates.ts
new file mode 100644
index 000000000..d7bbed237
--- /dev/null
+++ b/src/common/dates.ts
@@ -0,0 +1,11 @@
+export function getYearMonthDate(date: Date) {
+  const year = date.getFullYear();
+  const month = String(date.getMonth() + 1).padStart(2, '0');
+  const day = date.getDay();
+
+  return {
+    year,
+    month,
+    day,
+  };
+}
diff --git a/src/common/testHelpers.ts b/src/common/testHelpers.ts
new file mode 100644
index 000000000..814f635d3
--- /dev/null
+++ b/src/common/testHelpers.ts
@@ -0,0 +1,61 @@
+import userEvent from '@testing-library/user-event';
+import { screen, waitFor, within } from '@testing-library/react';
+
+export async function fillRadio(radioName: string, radioValue: string) {
+  const user = userEvent.setup();
+  const radioGroup = screen.getByRole('radiogroup', {
+    name: new RegExp(radioName, 'i'),
+  });
+  expect(radioGroup).toBeInTheDocument();
+
+  const radioButton = within(radioGroup).getByRole('radio', {
+    name: new RegExp(radioValue, 'i'),
+  });
+  expect(radioButton).toBeInTheDocument();
+
+  await user.click(radioButton);
+}
+
+export async function fillSelect(selectName: string, selectValue: string) {
+  const user = userEvent.setup();
+  const select = screen.getByRole('combobox', {
+    name: new RegExp(selectName, 'i'),
+  });
+  expect(select).toBeInTheDocument();
+
+  await user.click(select);
+
+  const option = screen.getByRole('option', {
+    name: new RegExp(selectValue, 'i'),
+  });
+  expect(option).toBeInTheDocument();
+
+  await user.click(option);
+}
+
+export async function fillCheckbox(checkboxName: string) {
+  const user = userEvent.setup();
+  const checkbox = screen.getByRole('checkbox', {
+    name: new RegExp(checkboxName, 'i'),
+  });
+  expect(checkbox).toBeInTheDocument();
+  await user.click(checkbox);
+}
+
+export async function selectDayInCalendar(day: string) {
+  const user = userEvent.setup();
+  const datePickerButton = screen.getByTestId('date-picker-button');
+  await user.click(datePickerButton);
+  await waitFor(() => {
+    expect(screen.getByRole('dialog')).toBeInTheDocument();
+  });
+  const calendar = screen.getByRole('dialog');
+  expect(calendar).toBeInTheDocument();
+  const dateButton = screen.getByRole('button', {
+    name: new RegExp(day, 'i'),
+  });
+  await user.click(dateButton);
+  await waitFor(() => {
+    expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+  });
+}
diff --git a/src/components/form/fields/CheckBoxField.tsx b/src/components/form/fields/CheckBoxField.tsx
index 9fc971a67..a64861198 100644
--- a/src/components/form/fields/CheckBoxField.tsx
+++ b/src/components/form/fields/CheckBoxField.tsx
@@ -95,6 +95,8 @@ export function CheckBoxField({
           <FormItem className={cn(`RemoteFlows__CheckBoxField__Item__${name}`)}>
             <FormControl>
               <>
+                {options && multiple ? <FormLabel>{label}</FormLabel> : null}
+
                 {options && multiple ? (
                   options.map((option) => (
                     <div key={option.value} className="flex space-x-2">
diff --git a/src/components/form/fields/RadioGroupField.tsx b/src/components/form/fields/RadioGroupField.tsx
index 5ae9edd6c..c9fd39252 100644
--- a/src/components/form/fields/RadioGroupField.tsx
+++ b/src/components/form/fields/RadioGroupField.tsx
@@ -70,6 +70,7 @@ export function RadioGroupField({
             <FormLabel>{label}</FormLabel>
             <FormControl>
               <RadioGroup
+                aria-label={label}
                 onValueChange={(value: string) => {
                   field.onChange(value);
                   onChange?.(value);
diff --git a/src/components/form/fields/SelectField.tsx b/src/components/form/fields/SelectField.tsx
index b71b3aaeb..f8c5a95c9 100644
--- a/src/components/form/fields/SelectField.tsx
+++ b/src/components/form/fields/SelectField.tsx
@@ -77,7 +77,7 @@ export function SelectField({
             <FormLabel className="RemoteFlows__SelectField__Label">
               {label}
             </FormLabel>
-            <FormControl aria-label={label}>
+            <FormControl>
               <div className="relative">
                 <Select
                   value={field.value || ''}
@@ -89,6 +89,7 @@ export function SelectField({
                   <SelectTrigger
                     className="RemoteFlows__SelectField__Trigger"
                     aria-invalid={Boolean(fieldState.error)}
+                    aria-label={label}
                   >
                     <span className="absolute">
                       <SelectValue placeholder={label} />
diff --git a/src/flows/Termination/TerminationBack.tsx b/src/flows/Termination/TerminationBack.tsx
new file mode 100644
index 000000000..8da62bed2
--- /dev/null
+++ b/src/flows/Termination/TerminationBack.tsx
@@ -0,0 +1,23 @@
+import { Button } from '@/src/components/ui/button';
+import React, { ButtonHTMLAttributes, PropsWithChildren } from 'react';
+import { useTerminationContext } from '@/src/flows/Termination/context';
+
+export function TerminationBack({
+  children,
+  ...props
+}: PropsWithChildren<ButtonHTMLAttributes<HTMLButtonElement>>) {
+  const {
+    terminationBag: { back },
+  } = useTerminationContext();
+
+  return (
+    <Button
+      {...props}
+      onClick={() => {
+        back();
+      }}
+    >
+      {children}
+    </Button>
+  );
+}
diff --git a/src/flows/Termination/TerminationFlow.tsx b/src/flows/Termination/TerminationFlow.tsx
index 8bf0aa09e..113e2e4e8 100644
--- a/src/flows/Termination/TerminationFlow.tsx
+++ b/src/flows/Termination/TerminationFlow.tsx
@@ -5,6 +5,7 @@ import { JSFModify } from '@/src/flows/CostCalculator/types';
 import { TerminationForm } from '@/src/flows/Termination/TerminationForm';
 import { TerminationSubmit } from '@/src/flows/Termination/TerminationSubmit';
 import { TimeOff } from '@/src/flows/Termination/TimeOff';
+import { TerminationBack } from '@/src/flows/Termination/TerminationBack';
 
 export type RenderProps = {
   terminationBag: ReturnType<typeof useTermination>;
@@ -12,6 +13,7 @@ export type RenderProps = {
     Form: typeof TerminationForm;
     SubmitButton: typeof TerminationSubmit;
     TimeOff: typeof TimeOff;
+    Back: typeof TerminationBack;
   };
 };
 
@@ -44,6 +46,7 @@ export const TerminationFlow = ({
           Form: TerminationForm,
           SubmitButton: TerminationSubmit,
           TimeOff: TimeOff,
+          Back: TerminationBack,
         },
       })}
     </TerminationContext.Provider>
diff --git a/src/flows/Termination/TerminationForm.tsx b/src/flows/Termination/TerminationForm.tsx
index a325d00aa..722ffaf5a 100644
--- a/src/flows/Termination/TerminationForm.tsx
+++ b/src/flows/Termination/TerminationForm.tsx
@@ -9,7 +9,7 @@ import { useJsonSchemasValidationFormResolver } from '@/src/components/form/yupV
 
 type TerminationFormProps = {
   username: string;
-  onSubmit?: (values: TerminationFormValues) => Promise<void>;
+  onSubmit?: (payload: TerminationFormValues) => Promise<void>;
   onError?: (error: PostCreateOffboardingError) => void;
   onSuccess?: (data: OffboardingResponse) => void;
 };
@@ -30,7 +30,7 @@ export function TerminationForm({
   const form = useForm({
     resolver,
     defaultValues: terminationBag?.initialValues,
-    shouldUnregister: true,
+    shouldUnregister: false,
     mode: 'onBlur',
   });
 
@@ -38,9 +38,7 @@ export function TerminationForm({
     const subscription = form?.watch((values) => {
       if (Object.keys(form.formState.dirtyFields).length > 0) {
         // TODO: for some reason isDirty doesn't work the first time we touch the form
-        terminationBag?.checkFieldUpdates(
-          values as Partial<TerminationFormValues>,
-        );
+        terminationBag?.checkFieldUpdates(values);
       }
     });
     return () => subscription?.unsubscribe();
@@ -48,7 +46,13 @@ export function TerminationForm({
   }, []);
 
   const handleSubmit = async (values: TerminationFormValues) => {
-    await onSubmit?.(values);
+    const lastStep =
+      terminationBag?.stepState.currentStep.index ===
+      terminationBag?.stepState.totalSteps - 1;
+
+    if (lastStep) {
+      await onSubmit?.(values);
+    }
 
     const terminationResult = await terminationBag?.onSubmit(values);
 
diff --git a/src/flows/Termination/hooks.ts b/src/flows/Termination/hooks.ts
index 26ff39b06..af7762a13 100644
--- a/src/flows/Termination/hooks.ts
+++ b/src/flows/Termination/hooks.ts
@@ -1,16 +1,50 @@
-import { CreateOffboardingParams, postCreateOffboarding } from '@/src/client';
+import {
+  CreateOffboardingParams,
+  postCreateOffboarding,
+  TerminationDetailsParams,
+} from '@/src/client';
 import { mutationToPromise } from '@/src/lib/mutations';
 import { Client } from '@hey-api/client-fetch';
 import { createHeadlessForm, modify } from '@remoteoss/json-schema-form';
 import { useMutation, useQuery } from '@tanstack/react-query';
-import { jsonSchema } from './jsonSchema';
 import { parseJSFToValidate } from '@/src/components/form/utils';
-import { useState } from 'react';
 import { JSFModify } from '@/src/flows/CostCalculator/types';
 import { TerminationFormValues } from '@/src/flows/Termination/types';
 import { useClient } from '@/src/context';
 import omit from 'lodash/omit';
 import { parseFormRadioValues } from '@/src/flows/utils';
+import { useStepState } from '@/src/flows/useStepState';
+import { STEPS } from '@/src/flows/Termination/utils';
+import { defaultSchema } from '@/src/flows/Termination/json-schemas/defaultSchema';
+import { schema } from '@/src/flows/Termination/json-schemas/schema';
+import { jsonSchema } from '@/src/flows/Termination/jsonSchema';
+
+function buildInitialValues(
+  stepsInitialValues: Partial<TerminationFormValues>,
+): TerminationFormValues {
+  const initialValues: TerminationFormValues = {
+    confidential: '',
+    customer_informed_employee: '',
+    customer_informed_employee_date: '',
+    customer_informed_employee_description: '',
+    personal_email: '',
+    termination_reason: undefined,
+    reason_description: '',
+    termination_reason_files: [],
+    will_challenge_termination: '',
+    will_challenge_termination_description: null,
+    agrees_to_pto_amount: '',
+    agrees_to_pto_amount_notes: null,
+    acknowledge_termination_procedure: false,
+    additional_comments: '',
+    proposed_termination_date: '',
+    risk_assessment_reasons: [],
+    timesheet_file: undefined,
+    ...stepsInitialValues,
+  };
+
+  return initialValues;
+}
 
 const useCreateTermination = () => {
   const { client } = useClient();
@@ -30,14 +64,16 @@ const useCreateTermination = () => {
 const useTerminationSchema = ({
   formValues,
   jsfModify,
+  step,
 }: {
   formValues?: TerminationFormValues;
   jsfModify?: JSFModify;
+  step?: string;
 }) => {
   return useQuery({
-    queryKey: ['rmt-flows-termination-schema'],
+    queryKey: ['rmt-flows-termination-schema', step],
     queryFn: () => {
-      return jsonSchema;
+      return schema[step as keyof typeof schema] ?? defaultSchema;
     },
     select: ({ data }) => {
       const { schema } = modify(data.schema, jsfModify || {});
@@ -60,12 +96,17 @@ export const useTermination = ({
   employmentId,
   options,
 }: TerminationHookProps) => {
-  const [formValues, setFormValues] = useState<
-    TerminationFormValues | undefined
-  >(undefined);
+  const { fieldValues, setFieldValues, stepState, previousStep, nextStep } =
+    useStepState<keyof typeof STEPS, TerminationFormValues>(STEPS);
 
   const { data: terminationHeadlessForm, isLoading: isLoadingTermination } =
-    useTerminationSchema({ formValues, jsfModify: options?.jsfModify });
+    useTerminationSchema({
+      formValues: fieldValues,
+      jsfModify: options?.jsfModify,
+      step: stepState.currentStep.name,
+    });
+
+  const entireTerminationSchema = createHeadlessForm(jsonSchema.data.schema);
 
   const createTermination = useCreateTermination();
   const { mutateAsync } = mutationToPromise(createTermination);
@@ -75,10 +116,16 @@ export const useTermination = ({
       throw new Error('Employment id is missing');
     }
 
+    if (stepState.currentStep.index < stepState.totalSteps - 1) {
+      nextStep();
+      return;
+    }
+
     if (terminationHeadlessForm) {
+      // this is a hack because I need to validate all form values with the entire schema
       const parsedValues = parseJSFToValidate(
         values,
-        terminationHeadlessForm.fields,
+        entireTerminationSchema.fields,
       );
 
       const { customer_informed_employee: customerInformedEmployee } =
@@ -112,12 +159,14 @@ export const useTermination = ({
         'customer_informed_employee_description',
       );
 
+      const terminationDetails: TerminationDetailsParams = {
+        ...normalizedValues,
+        ...employeeAwareness,
+      } as TerminationDetailsParams;
+
       const terminationPayload: CreateOffboardingParams = {
         employment_id: employmentId,
-        termination_details: {
-          ...normalizedValues,
-          ...employeeAwareness,
-        } as TerminationFormValues,
+        termination_details: terminationDetails,
         type: 'termination',
       };
 
@@ -127,6 +176,17 @@ export const useTermination = ({
     return;
   }
 
+  function back() {
+    previousStep();
+  }
+
+  const initialValues = buildInitialValues({
+    ...stepState.values?.employee_communication,
+    ...stepState.values?.termination_details,
+    ...stepState.values?.paid_time_off,
+    ...stepState.values?.additional_information,
+  });
+
   return {
     /**
      * Employment id passed useful to be used between components
@@ -135,11 +195,7 @@ export const useTermination = ({
     /**
      * Current step state containing the current step and total number of steps
      */
-    stepState: {
-      current: 0,
-      total: 1,
-      isLastStep: true,
-    },
+    stepState,
     /**
      * Array of form fields from the contract amendment schema
      */
@@ -155,7 +211,7 @@ export const useTermination = ({
     /**
      * Initial form values
      */
-    initialValues: {},
+    initialValues: initialValues,
     /**
      * Function to validate form values against the contract amendment schema
      * @param values - Form values to validate
@@ -179,9 +235,9 @@ export const useTermination = ({
       if (terminationHeadlessForm) {
         const parsedValues = parseJSFToValidate(
           values,
-          terminationHeadlessForm?.fields,
+          entireTerminationSchema?.fields,
         );
-        setFormValues(parsedValues as TerminationFormValues);
+        setFieldValues(parsedValues as TerminationFormValues);
       }
     },
     /**
@@ -190,5 +246,11 @@ export const useTermination = ({
      * @returns Promise resolving to the mutation result
      */
     onSubmit,
+
+    /**
+     * Function to handle going back to the previous step
+     * @returns {void}
+     */
+    back,
   };
 };
diff --git a/src/flows/Termination/json-schemas/additionalInformation.ts b/src/flows/Termination/json-schemas/additionalInformation.ts
new file mode 100644
index 000000000..742779ebc
--- /dev/null
+++ b/src/flows/Termination/json-schemas/additionalInformation.ts
@@ -0,0 +1,24 @@
+export const additionalInformationSchema = {
+  data: {
+    version: 7,
+    schema: {
+      additionalProperties: false,
+      allOf: [],
+      properties: {
+        acknowledge_termination_procedure: {
+          description: '',
+          title:
+            'I, {{username}} have read and agree to the procedures as defined in the termination form.',
+          type: 'boolean',
+          'x-jsf-presentation': {
+            direction: 'column',
+            inputType: 'checkbox',
+          },
+        },
+      },
+      required: ['acknowledge_termination_procedure'],
+      type: 'object',
+      'x-jsf-order': ['acknowledge_termination_procedure'],
+    },
+  },
+};
diff --git a/src/flows/Termination/json-schemas/defaultSchema.ts b/src/flows/Termination/json-schemas/defaultSchema.ts
new file mode 100644
index 000000000..0b78d6833
--- /dev/null
+++ b/src/flows/Termination/json-schemas/defaultSchema.ts
@@ -0,0 +1,13 @@
+export const defaultSchema = {
+  data: {
+    version: 7,
+    schema: {
+      additionalProperties: false,
+      allOf: [],
+      properties: {},
+      required: [],
+      type: 'object',
+      'x-jsf-order': [],
+    },
+  },
+};
diff --git a/src/flows/Termination/json-schemas/employeeComunication.ts b/src/flows/Termination/json-schemas/employeeComunication.ts
new file mode 100644
index 000000000..4dd6ffdf9
--- /dev/null
+++ b/src/flows/Termination/json-schemas/employeeComunication.ts
@@ -0,0 +1,156 @@
+export const employeeComunicationSchema = {
+  data: {
+    version: 7,
+    schema: {
+      additionalProperties: false,
+      allOf: [
+        {
+          if: {
+            properties: {
+              personal_email: {
+                pattern: '^[a-zA-Z0-9 .]+$',
+              },
+            },
+          },
+          then: {
+            properties: {
+              personal_email: {
+                'x-jsf-presentation': {
+                  statement: {
+                    title:
+                      'Please make sure to add the employee personal email so we can get in contact with the employee after the termination process takes effect.',
+                    inputType: 'statement',
+                    severity: 'warning',
+                  },
+                },
+              },
+            },
+          },
+        },
+        {
+          if: {
+            properties: {
+              customer_informed_employee: {
+                const: 'yes',
+              },
+            },
+            required: ['customer_informed_employee'],
+          },
+          then: {
+            properties: {
+              customer_informed_employee_date: {
+                type: 'string',
+              },
+              customer_informed_employee_description: {
+                type: 'string',
+              },
+            },
+            required: [
+              'customer_informed_employee_date',
+              'customer_informed_employee_description',
+            ],
+          },
+          else: {
+            properties: {
+              customer_informed_employee_date: false,
+              customer_informed_employee_description: false,
+            },
+          },
+        },
+      ],
+      properties: {
+        confidential: {
+          description:
+            'Confidential requests are visible only to you. Non-confidential requests are visible to all admins in your company.',
+          oneOf: [
+            {
+              const: 'yes',
+              description: '',
+              title: 'Yes',
+            },
+            {
+              const: 'no',
+              description: '',
+              title: 'No',
+            },
+          ],
+          title: 'Is this request confidential?',
+          type: 'string',
+          'x-jsf-presentation': {
+            direction: 'column',
+            inputType: 'radio',
+            statement: {
+              title:
+                "Please do not inform the employee of their termination until we review your request for legal risks. When we approve your request, you can inform the employee and we'll take it from there.",
+              inputType: 'statement',
+              severity: 'warning',
+            },
+          },
+        },
+        customer_informed_employee: {
+          description: '',
+          oneOf: [
+            {
+              const: 'yes',
+              description: '',
+              title: 'Yes',
+            },
+            {
+              const: 'no',
+              description: '',
+              title: 'No',
+            },
+          ],
+          title: 'Have you informed the employee of the termination?',
+          type: 'string',
+          'x-jsf-presentation': {
+            direction: 'column',
+            inputType: 'radio',
+          },
+        },
+        customer_informed_employee_date: {
+          description: '',
+          format: 'date',
+          maxLength: 255,
+          title: 'When the employee was told about the termination',
+          type: ['string', 'null'],
+          'x-jsf-presentation': {
+            inputType: 'date',
+          },
+        },
+        customer_informed_employee_description: {
+          description: '',
+          maxLength: 1000,
+          title: 'How did you share this information?',
+          type: ['string', 'null'],
+          'x-jsf-presentation': {
+            inputType: 'textarea',
+          },
+        },
+        personal_email: {
+          description: '',
+          maxLength: 255,
+          title: "Employee's personal email",
+          format: 'email',
+          type: 'string',
+          'x-jsf-presentation': {
+            inputType: 'email',
+          },
+        },
+      },
+      required: [
+        'confidential',
+        'customer_informed_employee',
+        'personal_email',
+      ],
+      type: 'object',
+      'x-jsf-order': [
+        'is_confidential',
+        'customer_informed_employee',
+        'customer_informed_employee_date',
+        'customer_informed_employee_description',
+        'personal_email',
+      ],
+    },
+  },
+};
diff --git a/src/flows/Termination/json-schemas/paidTimeOff.ts b/src/flows/Termination/json-schemas/paidTimeOff.ts
new file mode 100644
index 000000000..11471afb8
--- /dev/null
+++ b/src/flows/Termination/json-schemas/paidTimeOff.ts
@@ -0,0 +1,84 @@
+export const paidTimeOffSchema = {
+  data: {
+    version: 7,
+    schema: {
+      additionalProperties: false,
+      allOf: [
+        {
+          if: {
+            properties: {
+              agrees_to_pto_amount: {
+                const: 'no',
+              },
+            },
+            required: ['agrees_to_pto_amount'],
+          },
+          then: {
+            properties: {
+              agrees_to_pto_amount_notes: {
+                type: 'string',
+              },
+            },
+            required: ['agrees_to_pto_amount_notes'],
+          },
+          else: {
+            properties: {
+              agrees_to_pto_amount_notes: false,
+              timesheet_file: false,
+            },
+          },
+        },
+      ],
+      properties: {
+        agrees_to_pto_amount: {
+          description: '',
+          oneOf: [
+            {
+              const: 'yes',
+              description: '',
+              title: 'Yes',
+            },
+            {
+              const: 'no',
+              description: '',
+              title: 'No',
+            },
+          ],
+          title: 'Are these paid time off records correct?',
+          type: 'string',
+          'x-jsf-presentation': {
+            direction: 'column',
+            inputType: 'radio',
+          },
+        },
+        agrees_to_pto_amount_notes: {
+          description:
+            'Please provide details regarding any additional days taken, including specific dates, or any other inaccuracy in the time off data.',
+          maxLength: 1000,
+          title: 'Provide time off details',
+          type: ['string', 'null'],
+          'x-jsf-presentation': {
+            inputType: 'textarea',
+          },
+        },
+        timesheet_file: {
+          description:
+            'Upload a timesheet exported from your HR software. This way we can compare and confirm the total number of Paid time off.',
+          title: 'Timesheet document',
+          type: ['string', 'null'],
+          'x-jsf-presentation': {
+            inputType: 'file',
+            accept: '.pdf',
+          },
+        },
+      },
+      required: ['agrees_to_pto_amount'],
+      type: 'object',
+      'x-jsf-order': [
+        'agrees_to_pto_amount',
+        'agrees_to_pto_amount_notes',
+        'timesheet_file',
+      ],
+    },
+  },
+};
diff --git a/src/flows/Termination/json-schemas/schema.ts b/src/flows/Termination/json-schemas/schema.ts
new file mode 100644
index 000000000..c440d4262
--- /dev/null
+++ b/src/flows/Termination/json-schemas/schema.ts
@@ -0,0 +1,13 @@
+import { additionalInformationSchema } from '@/src/flows/Termination/json-schemas/additionalInformation';
+import { employeeComunicationSchema } from '@/src/flows/Termination/json-schemas/employeeComunication';
+import { paidTimeOffSchema } from '@/src/flows/Termination/json-schemas/paidTimeOff';
+import { terminationDetailsSchema } from '@/src/flows/Termination/json-schemas/terminationDetails';
+import { StepTerminationKeys } from '@/src/flows/Termination/utils';
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export const schema: Record<StepTerminationKeys, Record<string, any>> = {
+  employee_communication: employeeComunicationSchema,
+  termination_details: terminationDetailsSchema,
+  paid_time_off: paidTimeOffSchema,
+  additional_information: additionalInformationSchema,
+};
diff --git a/src/flows/Termination/json-schemas/terminationDetails.ts b/src/flows/Termination/json-schemas/terminationDetails.ts
new file mode 100644
index 000000000..dfa1afbe1
--- /dev/null
+++ b/src/flows/Termination/json-schemas/terminationDetails.ts
@@ -0,0 +1,235 @@
+export const terminationDetailsSchema = {
+  data: {
+    version: 7,
+    schema: {
+      additionalProperties: false,
+      allOf: [
+        {
+          if: {
+            properties: {
+              will_challenge_termination: {
+                const: 'yes',
+              },
+            },
+            required: ['will_challenge_termination'],
+          },
+          then: {
+            properties: {
+              will_challenge_termination_description: {
+                type: 'string',
+              },
+            },
+            required: ['will_challenge_termination_description'],
+          },
+          else: {
+            properties: {
+              will_challenge_termination_description: false,
+            },
+          },
+        },
+      ],
+      properties: {
+        termination_reason: {
+          title: 'Termination reason',
+          description:
+            'Make sure you choose an accurate termination reason to avoid unfair or unlawful dismissal claims.',
+          type: 'string',
+          oneOf: [
+            {
+              const: 'gross_misconduct',
+              title: 'Gross misconduct',
+            },
+            {
+              const: 'performance',
+              title: 'Performance',
+            },
+            {
+              const: 'workforce_reduction',
+              title: 'Workforce Reduction',
+            },
+            {
+              const: 'values',
+              title: 'Values',
+            },
+            {
+              const: 'compliance_issue',
+              title: 'Compliance issue',
+            },
+            {
+              const: 'incapacity_to_perform_inherent_duties',
+              title: 'Incapacity To perform inherent duties',
+            },
+            {
+              const: 'mutual_agreement',
+              title: 'Mutual agreement',
+            },
+            {
+              const: 'cancellation_before_start_date',
+              title: 'Decision to cancel hiring before the employee starts',
+            },
+            {
+              const: 'job_abandonment',
+              title: 'Job abandonment',
+            },
+            {
+              const: 'dissatisfaction_with_remote_service',
+              title: 'Dissatisfaction with EOR service',
+            },
+            {
+              const: 'end_of_fixed_term_contract_compliance_issue',
+              title: 'End of fixed-term contract',
+            },
+            {
+              const: 'other',
+              title: 'Other',
+            },
+          ],
+          'x-jsf-presentation': {
+            inputType: 'select',
+          },
+        },
+        reason_description: {
+          description: '',
+          maxLength: 1000,
+          title: 'Termination reason details',
+          type: 'string',
+          'x-jsf-presentation': {
+            inputType: 'textarea',
+          },
+        },
+        additional_comments: {
+          description: '',
+          maxLength: 1000,
+          title: 'Additional details regarding this termination process.',
+          type: 'string',
+          'x-jsf-presentation': {
+            inputType: 'textarea',
+          },
+        },
+        termination_reason_files: {
+          description:
+            'Please upload any supporting documents regarding the termination reason.',
+          title: 'Termination reason files',
+          type: 'string',
+          'x-jsf-presentation': {
+            inputType: 'file',
+            accept: '.pdf',
+            multiple: true,
+          },
+        },
+        risk_assessment_reasons: {
+          description: '',
+          title: 'This employee is...',
+          type: 'array',
+          items: {
+            anyOf: [
+              {
+                const: 'sick_leave',
+                title: 'Currently on or recently returned from sick leave',
+              },
+              {
+                const: 'family_leave',
+                title:
+                  'Currently on or recently returned from maternity, paternity, or other family leave',
+              },
+              {
+                const: 'pregnant_or_breastfeeding',
+                title: 'Pregnant or breastfeeding',
+              },
+              {
+                const: 'requested_medical_or_family_leave',
+                title: 'Requested medical or family leave',
+              },
+              {
+                const: 'disabled_or_health_condition',
+                title: 'Disabled or has a health condition',
+              },
+              {
+                const: 'member_of_union_or_works_council',
+                title: 'A member of a union or works council',
+              },
+              {
+                const: 'caring_responsibilities',
+                title: 'Caring responsibilities',
+              },
+              {
+                const: 'reported_concerns_with_workplace',
+                title:
+                  'Reported any wrongdoing; raised any allegations of discrimination, harassment, retaliation, etc.; or asserted their employment rights or raised complaints about their working conditions',
+              },
+              {
+                const: 'none_of_these',
+                title:
+                  'To the best of my knowledge, I am not aware of any of these',
+              },
+            ],
+          },
+          'x-jsf-presentation': {
+            inputType: 'checkbox',
+            direction: 'column',
+          },
+        },
+        will_challenge_termination: {
+          description: '',
+          oneOf: [
+            {
+              const: 'yes',
+              description: '',
+              title: 'Yes',
+            },
+            {
+              const: 'no',
+              description: '',
+              title: 'No',
+            },
+          ],
+          title:
+            'Do you consider it is likely that the employee will challenge their termination?',
+          type: 'string',
+          'x-jsf-presentation': {
+            direction: 'column',
+            inputType: 'radio',
+          },
+        },
+        will_challenge_termination_description: {
+          description: '',
+          maxLength: 1000,
+          title:
+            'Please explain how the employee will challenge their termination',
+          type: ['string', 'null'],
+          'x-jsf-presentation': {
+            inputType: 'textarea',
+          },
+        },
+        proposed_termination_date: {
+          description: '',
+          format: 'date',
+          maxLength: 255,
+          title: 'Proposed termination date',
+          type: 'string',
+          'x-jsf-presentation': {
+            inputType: 'date',
+          },
+        },
+      },
+      required: [
+        'termination_reason',
+        'reason_description',
+        'risk_assessment_reasons',
+        'will_challenge_termination',
+        'proposed_termination_date',
+      ],
+      type: 'object',
+      'x-jsf-order': [
+        'termination_reason',
+        'reason_description',
+        'additional_comments',
+        'termination_reason_files',
+        'risk_assessment_reasons',
+        'will_challenge_termination',
+        'will_challenge_termination_description',
+        'proposed_termination_date',
+      ],
+    },
+  },
+};
diff --git a/src/flows/Termination/tests/TerminationFlow.test.tsx b/src/flows/Termination/tests/TerminationFlow.test.tsx
new file mode 100644
index 000000000..a35f46b70
--- /dev/null
+++ b/src/flows/Termination/tests/TerminationFlow.test.tsx
@@ -0,0 +1,393 @@
+import { FormFieldsProvider } from '@/src/RemoteFlowsProvider';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import React from 'react';
+import { PropsWithChildren } from 'react';
+import { beforeEach, describe, it, vi } from 'vitest';
+import { server } from '@/src/tests/server';
+import {
+  RenderProps,
+  TerminationFlow,
+} from '@/src/flows/Termination/TerminationFlow';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import {
+  fillCheckbox,
+  fillRadio,
+  fillSelect,
+  selectDayInCalendar,
+} from '@/src/common/testHelpers';
+import { http, HttpResponse } from 'msw';
+import { terminationResponse } from '@/src/flows/Termination/tests/fixtures';
+import { getYearMonthDate } from '@/src/common/dates';
+
+const queryClient = new QueryClient();
+
+const wrapper = ({ children }: PropsWithChildren) => (
+  <QueryClientProvider client={queryClient}>
+    <FormFieldsProvider components={{}}>{children}</FormFieldsProvider>
+  </QueryClientProvider>
+);
+
+const mockOnSubmit = vi.fn();
+const mockOnSuccess = vi.fn();
+const mockOnError = vi.fn();
+
+describe('TerminationFlow', () => {
+  const mockRender = vi.fn(({ terminationBag, components }: RenderProps) => {
+    const { Form, Back, SubmitButton } = components;
+
+    const currentStepIndex = terminationBag.stepState.currentStep.index;
+
+    const steps: Record<number, string> = {
+      [0]: 'Employee Communication',
+      [1]: 'Termination Details',
+      [2]: 'Paid Time Off',
+      [3]: 'Additional Information',
+    };
+
+    return (
+      <>
+        <h1>Step: {steps[currentStepIndex]}</h1>
+        <Form
+          username="ze"
+          onSubmit={mockOnSubmit}
+          onSuccess={mockOnSuccess}
+          onError={mockOnError}
+        />
+        {currentStepIndex > 0 && <Back>Back</Back>}
+        {currentStepIndex <= terminationBag.stepState.totalSteps - 1 && (
+          <SubmitButton>
+            {currentStepIndex < terminationBag.stepState.totalSteps - 1
+              ? 'Next Step'
+              : 'Send termination'}
+          </SubmitButton>
+        )}
+      </>
+    );
+  });
+  const defaultProps = {
+    employmentId: '2ef4068b-11c7-4942-bb3c-70606c83688e',
+    countryCode: 'PRT',
+    options: {},
+    render: mockRender,
+  };
+
+  let offboardingRequest: Record<string, unknown> | null = null;
+  beforeEach(() => {
+    vi.clearAllMocks();
+
+    server.use(
+      http.post('*/v1/offboardings', async (req) => {
+        offboardingRequest = (await req.request.json()) as Record<
+          string,
+          unknown
+        >;
+        return HttpResponse.json(terminationResponse);
+      }),
+    );
+  });
+
+  afterEach(() => {
+    vi.clearAllMocks();
+  });
+
+  async function fillEmployeeCommunication(
+    values: Partial<{
+      employeePersonalEmail: string;
+      isRequestConfidential: string;
+      isEmployeeInformed: string;
+    }> = {
+      employeePersonalEmail: 'ze@remote.com',
+      isRequestConfidential: 'No',
+      isEmployeeInformed: 'No',
+    },
+  ) {
+    const user = userEvent.setup();
+    if (values?.isRequestConfidential) {
+      await fillRadio(
+        'Is this request confidential?',
+        values?.isRequestConfidential,
+      );
+    }
+    if (values?.isEmployeeInformed) {
+      await fillRadio(
+        'Have you informed the employee of the termination?',
+        values?.isEmployeeInformed,
+      );
+    }
+    if (values?.employeePersonalEmail) {
+      const employeePersonalEmail = screen.getByLabelText(
+        /Employee's personal email/i,
+      );
+      await user.type(employeePersonalEmail, values?.employeePersonalEmail);
+    }
+  }
+
+  async function fillTerminationDetails(
+    values: Partial<{
+      willChangeTermination: string;
+      terminationReasonDetails: string;
+      terminationReason: string;
+      riskAssessmentReason: string;
+      proposedTerminationDate: string;
+    }> = {
+      willChangeTermination: 'No',
+      terminationReasonDetails: 'whatever text',
+      terminationReason: 'Gross misconduct',
+      riskAssessmentReason: 'Currently on or recently returned from sick leave',
+      proposedTerminationDate: '15',
+    },
+  ) {
+    const user = userEvent.setup();
+    if (values?.willChangeTermination) {
+      await fillRadio(
+        'Do you consider it is likely that the employee will challenge their termination?',
+        values?.willChangeTermination,
+      );
+    }
+
+    if (values?.terminationReasonDetails) {
+      const terminationReasonDetails = screen.getByLabelText(
+        /Termination reason details/i,
+      );
+      await user.type(
+        terminationReasonDetails,
+        values?.terminationReasonDetails,
+      );
+    }
+
+    if (values?.terminationReason) {
+      await fillSelect('Termination reason', values?.terminationReason);
+    }
+
+    if (values?.riskAssessmentReason) {
+      await fillCheckbox(values?.riskAssessmentReason);
+    }
+    if (values?.proposedTerminationDate) {
+      await selectDayInCalendar(values?.proposedTerminationDate);
+    }
+  }
+
+  async function fillPTO(
+    values: Partial<{ agreePTO: string }> = {
+      agreePTO: 'Yes',
+    },
+  ) {
+    if (values?.agreePTO) {
+      await fillRadio(
+        'Are these paid time off records correct?',
+        values?.agreePTO,
+      );
+    }
+  }
+
+  async function fillAdditionalDetails(
+    values: Partial<{
+      ackowledgeTermination: boolean;
+    }> = {
+      ackowledgeTermination: true,
+    },
+  ) {
+    if (values?.ackowledgeTermination) {
+      await fillCheckbox(
+        'I, ze have read and agree to the procedures as defined in the termination form.',
+      );
+    }
+  }
+
+  it('should render first step of the form', async () => {
+    render(<TerminationFlow {...defaultProps} />, { wrapper });
+
+    await screen.findByText(/Step: Employee Communication/i);
+  });
+
+  // this test needs to be before the multi step, something strange is happening when executed in parallel with the next one
+  it('should fill the first step and go back to the previous step', async () => {
+    render(<TerminationFlow {...defaultProps} />, { wrapper });
+
+    await screen.findByText(/Step: Employee Communication/i);
+
+    await fillEmployeeCommunication();
+
+    const nextButton = screen.getByText(/Next Step/i);
+    expect(nextButton).toBeInTheDocument();
+
+    nextButton.click();
+
+    await screen.findByText(/Step: Termination Details/i);
+
+    const backButton = screen.getByText(/Back/i);
+    expect(backButton).toBeInTheDocument();
+
+    backButton.click();
+
+    await screen.findByText(/Step: Employee Communication/i);
+
+    const employeePersonalEmail = screen.getByLabelText(
+      /Employee's personal email/i,
+    );
+    expect(employeePersonalEmail).toHaveValue('ze@remote.com');
+  });
+
+  it('should submit the termination flow', async () => {
+    render(<TerminationFlow {...defaultProps} />, { wrapper });
+
+    await screen.findByText(/Step: Employee Communication/i);
+
+    await fillEmployeeCommunication();
+
+    let nextButton = screen.getByText(/Next Step/i);
+    expect(nextButton).toBeInTheDocument();
+
+    nextButton.click();
+
+    await screen.findByText(/Step: Termination Details/i);
+
+    await fillTerminationDetails();
+
+    nextButton = screen.getByText(/Next Step/i);
+    expect(nextButton).toBeInTheDocument();
+
+    nextButton.click();
+
+    const currentDate = getYearMonthDate(new Date());
+    const dynamicDate = `${currentDate.year}-${currentDate.month}-15`;
+
+    await screen.findByText(/Step: Paid Time Off/i);
+
+    await fillPTO();
+
+    nextButton = screen.getByText(/Next Step/i);
+    expect(nextButton).toBeInTheDocument();
+
+    nextButton.click();
+
+    await screen.findByText(/Step: Additional Information/i);
+
+    await fillAdditionalDetails();
+
+    const submitButton = screen.getByText(/Send termination/i);
+    expect(submitButton).toBeInTheDocument();
+    submitButton.click();
+    await waitFor(() => {
+      expect(mockOnSubmit).toHaveBeenCalledTimes(1);
+    });
+
+    expect(mockOnSubmit).toHaveBeenCalledWith({
+      acknowledge_termination_procedure: true,
+      additional_comments: '',
+      agrees_to_pto_amount: 'yes',
+      agrees_to_pto_amount_notes: null,
+      confidential: 'no',
+      customer_informed_employee: 'no',
+      customer_informed_employee_date: '',
+      customer_informed_employee_description: '',
+      personal_email: 'ze@remote.com',
+      proposed_termination_date: dynamicDate,
+      reason_description: 'whatever text',
+      risk_assessment_reasons: ['sick_leave'],
+      termination_reason: 'gross_misconduct',
+      termination_reason_files: [],
+      timesheet_file: undefined,
+      will_challenge_termination: 'no',
+      will_challenge_termination_description: null,
+    });
+
+    await waitFor(() => {
+      expect(mockOnSuccess).toHaveBeenCalledTimes(1);
+    });
+
+    expect(mockOnSuccess).toHaveBeenCalledWith(terminationResponse);
+
+    await waitFor(() => {
+      expect(offboardingRequest).not.toBeNull();
+    });
+
+    expect(offboardingRequest).toEqual({
+      employment_id: '2ef4068b-11c7-4942-bb3c-70606c83688e',
+      termination_details: {
+        acknowledge_termination_procedure: true,
+        additional_comments: null,
+        agrees_to_pto_amount: true,
+        confidential: false,
+        customer_informed_employee: false,
+        personal_email: 'ze@remote.com',
+        proposed_termination_date: dynamicDate,
+        reason_description: 'whatever text',
+        risk_assessment_reasons: ['sick_leave'],
+        termination_reason: 'gross_misconduct',
+        termination_reason_files: [],
+        will_challenge_termination: false,
+      },
+      type: 'termination',
+    });
+  });
+
+  it("should trigger the 'onError' callback when the request fails", async () => {
+    server.use(
+      http.post('*/v1/offboardings', async () => {
+        return HttpResponse.json(
+          {
+            error: 'Something went wrong',
+          },
+          { status: 500 },
+        );
+      }),
+    );
+    render(<TerminationFlow {...defaultProps} />, { wrapper });
+    await screen.findByText(/Step: Employee Communication/i);
+    await fillEmployeeCommunication();
+    let nextButton = screen.getByText(/Next Step/i);
+    expect(nextButton).toBeInTheDocument();
+    nextButton.click();
+
+    await screen.findByText(/Step: Termination Details/i);
+
+    await fillTerminationDetails();
+
+    nextButton = screen.getByText(/Next Step/i);
+    expect(nextButton).toBeInTheDocument();
+
+    nextButton.click();
+
+    await screen.findByText(/Step: Paid Time Off/i);
+
+    await fillPTO();
+
+    nextButton = screen.getByText(/Next Step/i);
+    expect(nextButton).toBeInTheDocument();
+
+    nextButton.click();
+
+    await screen.findByText(/Step: Additional Information/i);
+
+    await fillAdditionalDetails();
+
+    const submitButton = screen.getByText(/Send termination/i);
+    expect(submitButton).toBeInTheDocument();
+    submitButton.click();
+
+    await waitFor(() => {
+      expect(mockOnError).toHaveBeenCalledTimes(1);
+    });
+  });
+
+  it('should click next step without filling the form and show error', async () => {
+    render(<TerminationFlow {...defaultProps} />, { wrapper });
+
+    await screen.findByText(/Step: Employee Communication/i);
+
+    const nextButton = screen.getByText(/Next Step/i);
+    expect(nextButton).toBeInTheDocument();
+
+    fillEmployeeCommunication({
+      employeePersonalEmail: 'ze@remote.com',
+    });
+
+    nextButton.click();
+
+    const errors = await screen.findAllByText(/Required field/i);
+
+    expect(errors.length).toBeGreaterThan(0);
+  });
+});
diff --git a/src/flows/Termination/tests/fixtures.ts b/src/flows/Termination/tests/fixtures.ts
index d5a2bc5f2..06e4a8bae 100644
--- a/src/flows/Termination/tests/fixtures.ts
+++ b/src/flows/Termination/tests/fixtures.ts
@@ -1,3 +1,31 @@
 export const timeoff = {
   data: { total_count: 0, current_page: 1, total_pages: 0, timeoffs: [] },
 };
+
+export const terminationResponse = {
+  data: {
+    offboarding: {
+      additional_comments: '',
+      agrees_to_pto_amount: 'true',
+      confidential: true,
+      employee_awareness: {
+        date: '2023-12-12',
+        note: 'optional text to add details',
+      },
+      employment_id: '1e74fdc2-7420-4eef-ab0a-b794cbbef4e1',
+      id: 'ba310525-9282-40c9-8977-14d844bf891a',
+      proposed_termination_date: '2023-12-20',
+      reason_description:
+        'They had some performance issues and mismatched expectations.',
+      requested_by: '5a31f3c1-d7a7-4311-89cb-928959d3d540',
+      risk_assessment_reasons: ['sick_leave'],
+      status: 'submitted',
+      submitted_at: '2023-04-13T13:35:06Z',
+      termination_date: '2023-12-20',
+      termination_reason: 'gross_misconduct',
+      type: 'termination',
+      will_challenge_termination: true,
+      will_challenge_termination_description: null,
+    },
+  },
+};
diff --git a/src/flows/Termination/types.ts b/src/flows/Termination/types.ts
index 83728e703..3879dcfcf 100644
--- a/src/flows/Termination/types.ts
+++ b/src/flows/Termination/types.ts
@@ -5,17 +5,19 @@ export type TerminationFormValues = {
   additional_comments: string;
   agrees_to_pto_amount: string;
   agrees_to_pto_amount_notes: string | null;
-  confidential: boolean;
+  confidential: string;
   customer_informed_employee: string;
   customer_informed_employee_date: string | null;
   customer_informed_employee_description: string | null;
-  employer_confirmed_email: string;
+  personal_email: string;
   proposed_termination_date: string;
   reason_description: string;
   risk_assessment_reasons: TerminationDetailsParams['risk_assessment_reasons'];
-  termination_reason: TerminationDetailsParams['termination_reason'];
+  termination_reason:
+    | TerminationDetailsParams['termination_reason']
+    | undefined;
   termination_reason_files: TerminationDetailsParams['termination_reason_files'];
   timesheet_file: TerminationDetailsParams['timesheet_file'];
-  will_challenge_termination: boolean;
-  will_challenge_termination_description: string | undefined;
+  will_challenge_termination: string;
+  will_challenge_termination_description: string | null;
 };
diff --git a/src/flows/Termination/utils.ts b/src/flows/Termination/utils.ts
new file mode 100644
index 000000000..264809c53
--- /dev/null
+++ b/src/flows/Termination/utils.ts
@@ -0,0 +1,14 @@
+import { Step } from '@/src/flows/useStepState';
+
+export type StepTerminationKeys =
+  | 'employee_communication'
+  | 'termination_details'
+  | 'paid_time_off'
+  | 'additional_information';
+
+export const STEPS: Record<StepTerminationKeys, Step> = {
+  employee_communication: { index: 0, name: 'employee_communication' },
+  termination_details: { index: 1, name: 'termination_details' },
+  paid_time_off: { index: 2, name: 'paid_time_off' },
+  additional_information: { index: 3, name: 'additional_information' },
+} as const;
diff --git a/src/flows/useStepState.ts b/src/flows/useStepState.ts
index fccbb593f..044993b84 100644
--- a/src/flows/useStepState.ts
+++ b/src/flows/useStepState.ts
@@ -16,14 +16,16 @@ type StepState<T extends string> = {
     | null;
 };
 
-export const useStepState = <T extends string>(steps: Record<T, Step>) => {
+export const useStepState = <T extends string, Fields = FieldValues>(
+  steps: Record<T, Step>,
+) => {
   const stepKeys = Object.keys(steps) as Array<keyof typeof steps>;
 
   if (stepKeys.length === 0) {
     throw new Error('No steps provided to useStepState');
   }
 
-  const [fieldValues, setFieldValues] = useState<FieldValues>({});
+  const [fieldValues, setFieldValues] = useState<Fields>({} as Fields);
   const [stepState, setStepState] = useState<StepState<T>>({
     currentStep: steps[stepKeys[0]],
     totalSteps: stepKeys.length,
@@ -47,7 +49,7 @@ export const useStepState = <T extends string>(steps: Record<T, Step>) => {
           },
         } as { [key in T]: FieldValues },
       }));
-      setFieldValues({}); // Reset field values for the next step
+      setFieldValues({} as Fields); // Reset field values for the next step
     }
   }
 
@@ -68,7 +70,7 @@ export const useStepState = <T extends string>(steps: Record<T, Step>) => {
           },
         } as { [key in T]: FieldValues },
       }));
-      setFieldValues({}); // Reset field values for the previous step
+      setFieldValues({} as Fields); // Reset field values for the previous step
     }
   }
 


Reviews:
user: login: gabrielseco
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-26T17:17:42Z
user: login: eng-almeida
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-28T10:11:34Z
user: login: gabrielseco
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-28T13:36:43Z
user: login: gabrielseco
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-28T13:39:23Z
user: login: dragidavid
body: Hey @gabrielseco - just did a high level review about the code. Not a 100% familiar with the context so I don't want to comment on that!

Would be nice to see some comments for more complex logic. This being OSS and all!
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-28T20:01:24Z
user: login: gabrielseco
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-29T07:08:35Z
user: login: eng-almeida
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-29T07:14:34Z
user: login: gabrielseco
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-29T07:15:52Z
user: login: gabrielseco
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-29T07:37:54Z
user: login: eng-almeida
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-29T08:43:00Z
user: login: gabrielseco
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-29T09:05:49Z
user: login: eng-almeida
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-29T09:37:04Z

Review comments:
diff_hunk: @@ -27,10 +34,12 @@ export function TerminationForm({
     terminationBag.handleValidation,
   );
 
+  const previousInitialValuesRef = useRef<TerminationFormValues | null>(null);
+
   const form = useForm({
     resolver,
     defaultValues: terminationBag?.initialValues,
-    shouldUnregister: true,
+    shouldUnregister: false,
path: src/flows/Termination/TerminationForm.tsx
user: login: gabrielseco
body: I found with multi step forms is better to put this as false.

When this was true and you called `form.reset` the input wasn't syncing correctly with the form values.
created_at: 2025-04-26T17:12:39Z
author_association: COLLABORATOR
diff_hunk: @@ -27,28 +34,51 @@ export function TerminationForm({
     terminationBag.handleValidation,
   );
 
+  const previousInitialValuesRef = useRef<TerminationFormValues | null>(null);
+
   const form = useForm({
     resolver,
     defaultValues: terminationBag?.initialValues,
-    shouldUnregister: true,
+    shouldUnregister: false,
     mode: 'onBlur',
   });
 
   useEffect(() => {
     const subscription = form?.watch((values) => {
       if (Object.keys(form.formState.dirtyFields).length > 0) {
         // TODO: for some reason isDirty doesn't work the first time we touch the form
-        terminationBag?.checkFieldUpdates(
-          values as Partial<TerminationFormValues>,
-        );
+        terminationBag?.checkFieldUpdates({
+          ...terminationBag.initialValues,
+          ...values,
+        });
path: src/flows/Termination/TerminationForm.tsx
user: login: eng-almeida
body: Why do we need to do this? In theory `values`  should contain all the fields with the current values, no? 
created_at: 2025-04-28T09:51:48Z
author_association: COLLABORATOR
diff_hunk: @@ -27,28 +34,51 @@ export function TerminationForm({
     terminationBag.handleValidation,
   );
 
+  const previousInitialValuesRef = useRef<TerminationFormValues | null>(null);
+
   const form = useForm({
     resolver,
     defaultValues: terminationBag?.initialValues,
-    shouldUnregister: true,
+    shouldUnregister: false,
     mode: 'onBlur',
   });
 
   useEffect(() => {
     const subscription = form?.watch((values) => {
       if (Object.keys(form.formState.dirtyFields).length > 0) {
         // TODO: for some reason isDirty doesn't work the first time we touch the form
-        terminationBag?.checkFieldUpdates(
-          values as Partial<TerminationFormValues>,
-        );
+        terminationBag?.checkFieldUpdates({
+          ...terminationBag.initialValues,
+          ...values,
+        });
       }
     });
     return () => subscription?.unsubscribe();
     // eslint-disable-next-line react-hooks/exhaustive-deps
   }, []);
 
+  useEffect(() => {
+    if (terminationBag?.initialValues) {
+      const previousInitialValues = previousInitialValuesRef.current;
+
+      // Compare current initialValues with the previous ones
+      if (!isEqual(previousInitialValues, terminationBag.initialValues)) {
+        form.reset(terminationBag.initialValues); // Reset the form if initialValues have changed
+        previousInitialValuesRef.current = terminationBag.initialValues; // Update the ref with the new initialValues
+      }
+    }
path: src/flows/Termination/TerminationForm.tsx
user: login: eng-almeida
body: I don't think I'm following, why do we need this useEffect?
created_at: 2025-04-28T10:11:05Z
author_association: COLLABORATOR
diff_hunk: @@ -27,28 +34,51 @@ export function TerminationForm({
     terminationBag.handleValidation,
   );
 
+  const previousInitialValuesRef = useRef<TerminationFormValues | null>(null);
+
   const form = useForm({
     resolver,
     defaultValues: terminationBag?.initialValues,
-    shouldUnregister: true,
+    shouldUnregister: false,
     mode: 'onBlur',
   });
 
   useEffect(() => {
     const subscription = form?.watch((values) => {
       if (Object.keys(form.formState.dirtyFields).length > 0) {
         // TODO: for some reason isDirty doesn't work the first time we touch the form
-        terminationBag?.checkFieldUpdates(
-          values as Partial<TerminationFormValues>,
-        );
+        terminationBag?.checkFieldUpdates({
+          ...terminationBag.initialValues,
+          ...values,
+        });
path: src/flows/Termination/TerminationForm.tsx
user: login: gabrielseco
body: old code, I need this before when I didn't initialized the values correctly
created_at: 2025-04-28T13:36:43Z
author_association: COLLABORATOR
diff_hunk: @@ -27,28 +34,51 @@ export function TerminationForm({
     terminationBag.handleValidation,
   );
 
+  const previousInitialValuesRef = useRef<TerminationFormValues | null>(null);
+
   const form = useForm({
     resolver,
     defaultValues: terminationBag?.initialValues,
-    shouldUnregister: true,
+    shouldUnregister: false,
     mode: 'onBlur',
   });
 
   useEffect(() => {
     const subscription = form?.watch((values) => {
       if (Object.keys(form.formState.dirtyFields).length > 0) {
         // TODO: for some reason isDirty doesn't work the first time we touch the form
-        terminationBag?.checkFieldUpdates(
-          values as Partial<TerminationFormValues>,
-        );
+        terminationBag?.checkFieldUpdates({
+          ...terminationBag.initialValues,
+          ...values,
+        });
       }
     });
     return () => subscription?.unsubscribe();
     // eslint-disable-next-line react-hooks/exhaustive-deps
   }, []);
 
+  useEffect(() => {
+    if (terminationBag?.initialValues) {
+      const previousInitialValues = previousInitialValuesRef.current;
+
+      // Compare current initialValues with the previous ones
+      if (!isEqual(previousInitialValues, terminationBag.initialValues)) {
+        form.reset(terminationBag.initialValues); // Reset the form if initialValues have changed
+        previousInitialValuesRef.current = terminationBag.initialValues; // Update the ref with the new initialValues
+      }
+    }
path: src/flows/Termination/TerminationForm.tsx
user: login: gabrielseco
body: mmm, the other day it seemed that the backButton wasn't resetting the form correctly, now it looks like we don't need it
created_at: 2025-04-28T13:39:23Z
author_association: COLLABORATOR
diff_hunk: @@ -1,16 +1,50 @@
-import { CreateOffboardingParams, postCreateOffboarding } from '@/src/client';
+import {
+  CreateOffboardingParams,
+  postCreateOffboarding,
+  TerminationDetailsParams,
+} from '@/src/client';
 import { mutationToPromise } from '@/src/lib/mutations';
 import { Client } from '@hey-api/client-fetch';
 import { createHeadlessForm, modify } from '@remoteoss/json-schema-form';
 import { useMutation, useQuery } from '@tanstack/react-query';
-import { jsonSchema } from './jsonSchema';
 import { parseJSFToValidate } from '@/src/components/form/utils';
-import { useState } from 'react';
 import { JSFModify } from '@/src/flows/CostCalculator/types';
 import { TerminationFormValues } from '@/src/flows/Termination/types';
 import { useClient } from '@/src/context';
 import omit from 'lodash/omit';
 import { parseFormRadioValues } from '@/src/flows/utils';
+import { useStepState } from '@/src/flows/useStepState';
+import { STEPS } from '@/src/flows/Termination/utils';
+import { defaultSchema } from '@/src/flows/Termination/json-schemas/defaultSchema';
+import { schema } from '@/src/flows/Termination/json-schemas/schema';
+import { jsonSchema } from '@/src/flows/Termination/jsonSchema';
+
+function buildInitialValues(
path: src/flows/Termination/hooks.ts
user: login: dragidavid
body: Question: Would these values/fields ever change or they're going to be static? Just thinking that we would need to update this every time we have a new value or if anything changes. Could derive these from the schema maybe? 
created_at: 2025-04-28T19:54:25Z
author_association: COLLABORATOR
diff_hunk: @@ -30,25 +36,31 @@ export function TerminationForm({
   const form = useForm({
     resolver,
     defaultValues: terminationBag?.initialValues,
-    shouldUnregister: true,
+    shouldUnregister: false,
     mode: 'onBlur',
   });
 
   useEffect(() => {
     const subscription = form?.watch((values) => {
       if (Object.keys(form.formState.dirtyFields).length > 0) {
         // TODO: for some reason isDirty doesn't work the first time we touch the form
-        terminationBag?.checkFieldUpdates(
-          values as Partial<TerminationFormValues>,
-        );
+        terminationBag?.checkFieldUpdates(values);
       }
     });
     return () => subscription?.unsubscribe();
     // eslint-disable-next-line react-hooks/exhaustive-deps
   }, []);
 
   const handleSubmit = async (values: TerminationFormValues) => {
-    await onSubmit?.(values);
+    const step = terminationBag?.stepState.currentStep.name;
+    const lastStep =
+      terminationBag?.stepState.currentStep.index ===
+      terminationBag?.stepState.totalSteps - 1;
+
+    await onSubmit?.({
+      status: lastStep ? 'lastStep' : `step-${step}`,
path: src/flows/Termination/TerminationForm.tsx
user: login: dragidavid
body: Question: Will we have more `status` values? Might be worth having them as const somewhere if we do so its easier to keep track.
created_at: 2025-04-28T19:57:19Z
author_association: COLLABORATOR
diff_hunk: @@ -30,25 +36,31 @@ export function TerminationForm({
   const form = useForm({
     resolver,
     defaultValues: terminationBag?.initialValues,
-    shouldUnregister: true,
+    shouldUnregister: false,
     mode: 'onBlur',
   });
 
   useEffect(() => {
     const subscription = form?.watch((values) => {
       if (Object.keys(form.formState.dirtyFields).length > 0) {
         // TODO: for some reason isDirty doesn't work the first time we touch the form
-        terminationBag?.checkFieldUpdates(
-          values as Partial<TerminationFormValues>,
-        );
+        terminationBag?.checkFieldUpdates(values);
       }
     });
     return () => subscription?.unsubscribe();
     // eslint-disable-next-line react-hooks/exhaustive-deps
   }, []);
 
   const handleSubmit = async (values: TerminationFormValues) => {
-    await onSubmit?.(values);
+    const step = terminationBag?.stepState.currentStep.name;
+    const lastStep =
+      terminationBag?.stepState.currentStep.index ===
+      terminationBag?.stepState.totalSteps - 1;
+
+    await onSubmit?.({
path: src/flows/Termination/TerminationForm.tsx
user: login: dragidavid
body: nit/question: Can this fail in any scenario? Wondering if a try-catch would bring any benefit for potential errors?
created_at: 2025-04-28T19:59:30Z
author_association: COLLABORATOR
diff_hunk: @@ -30,25 +36,31 @@ export function TerminationForm({
   const form = useForm({
     resolver,
     defaultValues: terminationBag?.initialValues,
-    shouldUnregister: true,
+    shouldUnregister: false,
     mode: 'onBlur',
   });
 
   useEffect(() => {
     const subscription = form?.watch((values) => {
       if (Object.keys(form.formState.dirtyFields).length > 0) {
         // TODO: for some reason isDirty doesn't work the first time we touch the form
-        terminationBag?.checkFieldUpdates(
-          values as Partial<TerminationFormValues>,
-        );
+        terminationBag?.checkFieldUpdates(values);
       }
     });
     return () => subscription?.unsubscribe();
     // eslint-disable-next-line react-hooks/exhaustive-deps
   }, []);
 
   const handleSubmit = async (values: TerminationFormValues) => {
-    await onSubmit?.(values);
+    const step = terminationBag?.stepState.currentStep.name;
+    const lastStep =
+      terminationBag?.stepState.currentStep.index ===
+      terminationBag?.stepState.totalSteps - 1;
+
+    await onSubmit?.({
+      status: lastStep ? 'lastStep' : `step-${step}`,
path: src/flows/Termination/TerminationForm.tsx
user: login: gabrielseco
body: right now we'll have 4 steps mimicking our termination flow
created_at: 2025-04-29T07:08:35Z
author_association: COLLABORATOR
diff_hunk: @@ -30,25 +36,31 @@ export function TerminationForm({
   const form = useForm({
     resolver,
     defaultValues: terminationBag?.initialValues,
-    shouldUnregister: true,
+    shouldUnregister: false,
     mode: 'onBlur',
   });
 
   useEffect(() => {
     const subscription = form?.watch((values) => {
       if (Object.keys(form.formState.dirtyFields).length > 0) {
         // TODO: for some reason isDirty doesn't work the first time we touch the form
-        terminationBag?.checkFieldUpdates(
-          values as Partial<TerminationFormValues>,
-        );
+        terminationBag?.checkFieldUpdates(values);
       }
     });
     return () => subscription?.unsubscribe();
     // eslint-disable-next-line react-hooks/exhaustive-deps
   }, []);
 
   const handleSubmit = async (values: TerminationFormValues) => {
-    await onSubmit?.(values);
+    const step = terminationBag?.stepState.currentStep.name;
+    const lastStep =
+      terminationBag?.stepState.currentStep.index ===
+      terminationBag?.stepState.totalSteps - 1;
+
+    await onSubmit?.({
+      status: lastStep ? 'lastStep' : `step-${step}`,
path: src/flows/Termination/TerminationForm.tsx
user: login: eng-almeida
body: I'm slightly confused, why do we need to pass `status` to the onSubmit callback? Partners have access to steps metadata in the TerminationFlow, so if they need to run specific logic, on submit, for a step they can do it already.
created_at: 2025-04-29T07:14:33Z
author_association: COLLABORATOR
diff_hunk: @@ -30,25 +36,31 @@ export function TerminationForm({
   const form = useForm({
     resolver,
     defaultValues: terminationBag?.initialValues,
-    shouldUnregister: true,
+    shouldUnregister: false,
     mode: 'onBlur',
   });
 
   useEffect(() => {
     const subscription = form?.watch((values) => {
       if (Object.keys(form.formState.dirtyFields).length > 0) {
         // TODO: for some reason isDirty doesn't work the first time we touch the form
-        terminationBag?.checkFieldUpdates(
-          values as Partial<TerminationFormValues>,
-        );
+        terminationBag?.checkFieldUpdates(values);
       }
     });
     return () => subscription?.unsubscribe();
     // eslint-disable-next-line react-hooks/exhaustive-deps
   }, []);
 
   const handleSubmit = async (values: TerminationFormValues) => {
-    await onSubmit?.(values);
+    const step = terminationBag?.stepState.currentStep.name;
+    const lastStep =
+      terminationBag?.stepState.currentStep.index ===
+      terminationBag?.stepState.totalSteps - 1;
+
+    await onSubmit?.({
path: src/flows/Termination/TerminationForm.tsx
user: login: gabrielseco
body: this onSubmit comes from the consumer, that's why we aren't catching it ourselves.

the onSubmit can be a function that returns anything, that's why we're awaiting.

The consumer can do the catching in their end as this is not part of the sdk, it's just a callback
created_at: 2025-04-29T07:15:52Z
author_association: COLLABORATOR
diff_hunk: @@ -30,25 +36,31 @@ export function TerminationForm({
   const form = useForm({
     resolver,
     defaultValues: terminationBag?.initialValues,
-    shouldUnregister: true,
+    shouldUnregister: false,
     mode: 'onBlur',
   });
 
   useEffect(() => {
     const subscription = form?.watch((values) => {
       if (Object.keys(form.formState.dirtyFields).length > 0) {
         // TODO: for some reason isDirty doesn't work the first time we touch the form
-        terminationBag?.checkFieldUpdates(
-          values as Partial<TerminationFormValues>,
-        );
+        terminationBag?.checkFieldUpdates(values);
       }
     });
     return () => subscription?.unsubscribe();
     // eslint-disable-next-line react-hooks/exhaustive-deps
   }, []);
 
   const handleSubmit = async (values: TerminationFormValues) => {
-    await onSubmit?.(values);
+    const step = terminationBag?.stepState.currentStep.name;
+    const lastStep =
+      terminationBag?.stepState.currentStep.index ===
+      terminationBag?.stepState.totalSteps - 1;
+
+    await onSubmit?.({
+      status: lastStep ? 'lastStep' : `step-${step}`,
path: src/flows/Termination/TerminationForm.tsx
user: login: gabrielseco
body: There are some options here only to call onSubmit when the termination is really executed at the end, maybe that's what we want instead of what I did.
created_at: 2025-04-29T07:37:54Z
author_association: COLLABORATOR
diff_hunk: @@ -30,25 +36,31 @@ export function TerminationForm({
   const form = useForm({
     resolver,
     defaultValues: terminationBag?.initialValues,
-    shouldUnregister: true,
+    shouldUnregister: false,
     mode: 'onBlur',
   });
 
   useEffect(() => {
     const subscription = form?.watch((values) => {
       if (Object.keys(form.formState.dirtyFields).length > 0) {
         // TODO: for some reason isDirty doesn't work the first time we touch the form
-        terminationBag?.checkFieldUpdates(
-          values as Partial<TerminationFormValues>,
-        );
+        terminationBag?.checkFieldUpdates(values);
       }
     });
     return () => subscription?.unsubscribe();
     // eslint-disable-next-line react-hooks/exhaustive-deps
   }, []);
 
   const handleSubmit = async (values: TerminationFormValues) => {
-    await onSubmit?.(values);
+    const step = terminationBag?.stepState.currentStep.name;
+    const lastStep =
+      terminationBag?.stepState.currentStep.index ===
+      terminationBag?.stepState.totalSteps - 1;
+
+    await onSubmit?.({
+      status: lastStep ? 'lastStep' : `step-${step}`,
path: src/flows/Termination/TerminationForm.tsx
user: login: eng-almeida
body: Like we discuss this morning let's just execute `onSubmit` only when the termination flow is submitted
created_at: 2025-04-29T08:42:59Z
author_association: COLLABORATOR
diff_hunk: @@ -30,25 +36,31 @@ export function TerminationForm({
   const form = useForm({
     resolver,
     defaultValues: terminationBag?.initialValues,
-    shouldUnregister: true,
+    shouldUnregister: false,
     mode: 'onBlur',
   });
 
   useEffect(() => {
     const subscription = form?.watch((values) => {
       if (Object.keys(form.formState.dirtyFields).length > 0) {
         // TODO: for some reason isDirty doesn't work the first time we touch the form
-        terminationBag?.checkFieldUpdates(
-          values as Partial<TerminationFormValues>,
-        );
+        terminationBag?.checkFieldUpdates(values);
       }
     });
     return () => subscription?.unsubscribe();
     // eslint-disable-next-line react-hooks/exhaustive-deps
   }, []);
 
   const handleSubmit = async (values: TerminationFormValues) => {
-    await onSubmit?.(values);
+    const step = terminationBag?.stepState.currentStep.name;
+    const lastStep =
+      terminationBag?.stepState.currentStep.index ===
+      terminationBag?.stepState.totalSteps - 1;
+
+    await onSubmit?.({
+      status: lastStep ? 'lastStep' : `step-${step}`,
path: src/flows/Termination/TerminationForm.tsx
user: login: gabrielseco
body: just did this
created_at: 2025-04-29T09:05:49Z
author_association: COLLABORATOR

PR comments:
user: login: eng-almeida
created_at: 2025-04-28T09:48:57Z
body: Not a blocker for this PR but when I try to upload a file the form validation kicks in, which is a bit odd. 

https://www.loom.com/share/e03bedeb54fe4f83b6536dce3d708e84
author_association: COLLABORATOR
user: login: gabrielseco
created_at: 2025-04-28T12:52:01Z
body: > Not a blocker for this PR but when I try to upload a file the form validation kicks in, which is a bit odd.
> 
> https://www.loom.com/share/e03bedeb54fe4f83b6536dce3d708e84

yes I noticed before there's an issue on our [tracker](https://linear.app/remote/issue/PBYR-2400)

cc: @eng-almeida 
author_association: COLLABORATOR
user: login: eng-almeida
created_at: 2025-04-29T08:35:50Z
body: Thanks for helping review this PR @dragidavid ! Regarding your comment:

> Would be nice to see some comments for more complex logic. This being OSS and all!

Anything in particular you think it would be useful to have comments or to document? I'm happy to work on it.
author_association: COLLABORATOR
user: login: dragidavid
created_at: 2025-04-29T09:12:20Z
body: > Thanks for helping review this PR @dragidavid ! Regarding your comment:
> 
> > Would be nice to see some comments for more complex logic. This being OSS and all!
> 
> Anything in particular you think it would be useful to have comments or to document? I'm happy to work on it.

Hey @eng-almeida - In this particular PR, I think it is not necessary. I meant it as a more general suggestion. We'll probably have some more complex logic here and there that can use an explanation. All good here! :) 
author_association: COLLABORATOR