Skip to content

Advanced: Client Onboarding Wizard

A production-style wizard demonstrating the full feature set: multi-step navigation with per-page validation, conditional required/hidden fields, dynamic option loading, repeatable contacts, a choice-based launch path, and a read-only summary page before submit.

What It Demonstrates

  • Multi-step wizard type with validatePage called before advancing
  • computedProps making fields conditionally required or hidden at runtime
  • choice branches with a custom change handler and icon labels
  • Repeatable array pages (minOccurs, maxOccurs, arrayItemName)
  • Dynamic options loaded from an external source, cascaded between fields
  • wizardSummaryPage for a pre-submit review with edit links
  • Animated submission timeline after a successful submit

Example

Client Onboarding

A concrete demo for a SaaS team collecting everything needed to onboard a new client. It highlights reusable templates, grouped fields, repeatable contacts, mutually exclusive launch paths, and computed fields that become required only when they matter.

Client Onboarding

New client setup

Company details

Core account information for the rollout.

Options are requested from an external source.

Choose an industry to see team size options

Available options for the undefined industry are loaded by an external source using an extra parameter.

Project contacts

Anyone who should be looped in on the rollout.

1 contact
1

New contact

Launch approach

Pick one. You can switch later if plans change — we'll keep the fields you've filled in.

Self-serve launch

The client drives the rollout themselves. Fastest path to go-live.

Systems to connect

Any external tools we'll integrate with during onboarding.

0 systems

No systems added yet

Skip this step if there's nothing to integrate.

IsDirty: true
Touched: false
Valid: true

// form values:
{
  "company": {},
  "projectContacts": [
    {}
  ],
  "launchApproach": {
    "selfServe": {
      "field-0": true
    },
    "guidedRollout": {}
  }
}
    

Conditional Required / Hidden Field

computedProps can flip minOccurs and hide at runtime based on the value of another field. Here the migration deadline becomes mandatory only when the user enables the migration checkbox:

ts
{
  name: 'migrationDeadline',
  fieldOptions: {
    label: 'Migration deadline',
  },
  description: 'When must all historical records be live in the new system?',
  minOccurs: 0,
  fullWidth: true,
  validation: dateValidation,
  placeholder: 'dd/mm/yyyy',
  computedProps: [(field) => {
    if (requiresMigration.value)
      field.minOccurs = 1; // make the field mandatory
    if (!requiresMigration.value)
      field.hide = true;
  }],
},

Full Metadata

ts
const metadata: Metadata[] = [
  {
    name: 'wizard',
    path: '',
    type: 'wizard',
    fieldOptions: { label: formName },
    description: 'New client setup',
    submitButtonText: 'Submit onboarding',
    validatePage,
    submitForm: async (resolve: LoadingResolve) => {
      handleSubmit(
        (values) => {
          submitted.value = values;
          resolve(true);
        },
        () => resolve(false),
      )();
    },
    children: [
      {
        name: 'company',
        type: 'wizardPage',
        fieldOptions: { label: 'Company details' },
        description: 'Core account information for the rollout.',
        helpText: 'Who are we onboarding?',
        computedProps: [registerWizardPagePath],
        children: [
          {
            name: 'companyName',
            fieldOptions: { label: 'Company name' },
            restriction: { minLength: 2 },
            fullWidth: true,
          },
          {
            name: 'industry',
            type: 'select',
            fieldOptions: { label: 'Industry' },
            description: 'Options are requested from an external source.',
            computedProps: [(field, value) => {
              field.options = props.optionStore.industry;

              // Load the teamSize options, depending on the selected industry
              requestOptions('teamSize', { industry: value.value as string });
            }],
          },
          {
            name: 'teamSize',
            type: 'select',
            fieldOptions: { label: 'Team size' },
            minOccurs: 0,
            computedProps: [(field) => {
              const industry = industryValue.value;
              field.options = props.optionStore.teamSize;

              if (industry)
                field.minOccurs = 1; // Make the field mandatory
              if (!industry) {
                field.dependentOnMessage = 'Choose an industry to see team size options';
              }
              field.description = `Available options for the ${industry} industry are loaded by an external source using an extra parameter.`;
            }],
          },
          {
            name: 'requiresMigration',
            type: 'checkbox',
            minOccurs: 0,
            fieldOptions: { label: 'Migrate existing customer data' },
            description: 'Turn this on when records need to be imported from another system.',
            fullWidth: true,
          },
          {
            name: 'migrationDeadline',
            fieldOptions: {
              label: 'Migration deadline',
            },
            description: 'When must all historical records be live in the new system?',
            minOccurs: 0,
            fullWidth: true,
            validation: dateValidation,
            placeholder: 'dd/mm/yyyy',
            computedProps: [(field) => {
              if (requiresMigration.value)
                field.minOccurs = 1; // make the field mandatory
              if (!requiresMigration.value)
                field.hide = true;
            }],
          },
        ],
      },
      {
        name: 'projectContacts',
        type: 'wizardPage',
        fieldOptions: { label: 'Project contacts' },
        description: 'Anyone who should be looped in on the rollout.',
        helpText: 'Who should we work with?',
        arrayItemName: 'contact',
        arrayItemNamePlural: 'contacts',
        arrayItemFieldForTitle: 'fullName',
        computedProps: [registerWizardPagePath],
        minOccurs: 1,
        maxOccurs: 3,
        fullWidth: true,
        children: [
          {
            name: 'fullName',
            fieldOptions: { label: 'Full name' },
          },
          {
            name: 'email',
            fieldOptions: { label: 'Work email' },
            restriction: { pattern: '^.+@.+\\..+$' },
          },
          {
            name: 'role',
            type: 'select',
            fullWidth: true,
            fieldOptions: { label: 'Role' },
            computedProps: [(field) => {
              field.options = props.optionStore.role;
            }],
          },
        ],
      },
      {
        name: 'launchApproach',
        type: 'wizardPage',
        fieldOptions: { label: 'Launch approach' },
        description: `Pick one. You can switch later if plans change — we'll keep the fields you've filled in.`,
        helpText: 'How will we go live?',
        fullWidth: true,
        computedProps: [registerWizardPagePath],
        choiceShowChoiceSelect: true,
        changeChoice: key => selectedLaunchApproach.value = key as LaunchApproach,
        choice: [
          {
            name: 'selfServe',
            fieldOptions: { label: 'Self-serve launch' },
            description: 'The client drives the rollout themselves. Fastest path to go-live.',
            iconName: 'bolt',
            fullWidth: true,
            computedProps: [
              (thisField, thisValue) => {
                if (selectedLaunchApproach.value !== 'selfServe') {
                  thisField.hide = true;
                  thisValue.value = {};
                }
              },
            ],
            children: [
              {
                hide: true,
                computedProps: [
                  (_, thisValue) => {
                    // The choice fields are "enabled" once 1 of the values is filled in, we simulate this here
                    thisValue.value = selectedLaunchApproach.value === 'selfServe' ? true : undefined;
                  },
                ],
              },
              {
                name: 'goLiveDate',
                fieldOptions: { label: 'Target go-live date' },
                validation: dateValidation,
                placeholder: 'dd/mm/yyyy',
              },
              {
                name: 'internalOwner',
                fieldOptions: { label: 'Client-side owner' },
              },
            ],
          },
          {
            name: 'guidedRollout',
            fieldOptions: { label: 'Guided rollout' },
            description: 'We run kickoff, training, and launch alongside the client team.',
            iconName: 'users',
            fullWidth: true,
            computedProps: [
              (thisField, thisValue) => {
                if (selectedLaunchApproach.value !== 'guidedRollout') {
                  thisField.hide = true;
                  thisValue.value = {};
                }
              },
            ],
            children: [
              {
                hide: true,
                computedProps: [
                  (_, thisValue) => {
                    // The choice fields are "enabled" once 1 of the values is filled in, we simulate this here
                    thisValue.value = selectedLaunchApproach.value === 'guidedRollout' ? true : undefined;
                  },
                ],
              },
              {
                name: 'kickoffDate',
                fieldOptions: { label: 'Kickoff call date' },
                fullWidth: true,
                validation: dateValidation,
                placeholder: 'dd/mm/yyyy',
              },
              {
                name: 'needsTraining',
                type: 'checkbox',
                minOccurs: 0,
                fieldOptions: { label: 'Include team training' },
                description: 'Turn on to schedule a training session during onboarding.',
                fullWidth: true,
                falseAsUndefined: true,
              },
              {
                name: 'trainingFormat',
                type: 'select',
                minOccurs: 0,
                fieldOptions: {
                  label: 'Training format',
                },
                description: 'Required because guided rollout includes team training.',
                fullWidth: true,
                computedProps: [(field) => {
                  field.options = props.optionStore.trainingFormat;
                  if (needsTraining.value)
                    field.minOccurs = 1; // Make the field mandatory
                  if (!needsTraining.value)
                    field.hide = true;
                }],
              },
            ],
          },
        ],
      },
      {
        name: 'systems',
        type: 'wizardPage',
        fieldOptions: { label: 'Systems to connect' },
        description: `Any external tools we'll integrate with during onboarding.`,
        helpText: 'What needs to connect?',
        arrayNoItemsMessage: `Skip this step if there's nothing to integrate.`,
        arrayItemName: 'system',
        arrayItemNamePlural: 'systems',
        arrayItemFieldForTitle: 'systemName',
        minOccurs: 0,
        maxOccurs: 4,
        autoAddMinOccurs: false,
        fullWidth: true,
        computedProps: [registerWizardPagePath],
        children: [
          {
            name: 'systemName',
            fieldOptions: { label: 'System name' },
            placeholder: 'e.g. Salesforce, HubSpot',
          },
          {
            name: 'purpose',
            fieldOptions: { label: 'Purpose in the rollout' },
            placeholder: 'e.g. source of CRM records',
          },
        ],
      },
      {
        name: 'summaryPage',
        type: 'wizardSummaryPage',
        fieldOptions: { label: 'Review & submit' },
        description: 'Quick recap before we kick this off. Anything needs tweaking? Jump back to the step.',
        helpText: 'Check and submit',
        minOccurs: 0,
        computedProps: [registerWizardPagePath, (field) => { field.wizardSummary = wizardSummary.value; }],
      },
    ],
  },
];

Released under the MIT License.