Logo

0x5a.live

for different kinds of informations and explorations.

GitHub - simplifiedcourses/ngx-vest-forms: Simple form development for complex and scalable solutions

Simple form development for complex and scalable solutions - simplifiedcourses/ngx-vest-forms

Visit SiteGitHub - simplifiedcourses/ngx-vest-forms: Simple form development for complex and scalable solutions

GitHub - simplifiedcourses/ngx-vest-forms: Simple form development for complex and scalable solutions

Simple form development for complex and scalable solutions - simplifiedcourses/ngx-vest-forms

Powered by 0x5a.live 💗

ngx-vest-forms

Introduction

This is a very lightweight adapter for Angular template-driven forms and vestjs. This package gives us the ability to create unidirectional forms without any boilerplate. It is meant for complex forms with a high focus on complex validations and conditionals.

All the validations are asynchronous and use vestjs suites that can be re-used across different frameworks and technologies.

Installation

You can install the package by running:

npm i ngx-vest-forms

Creating a simple form

Let's start by explaining how to create a simple form. I want a form with a form group called general info that has 2 properties:

  • firstName
  • lastName

We need to import the vestForms const in the imports section of the @Component decorator. Now we can apply the scVestForm directive to the form tag and listen to the formValueChange output to feed our signal. In the form we create a form group for generalInfo with the ngModelGroup directive. And we crate 2 inputs with the name attribute and the [ngModel] input. Do note that we are not using the banana in the box syntax but only tha square brackets, resulting in a unidirectional dataflow

import { vestForms, DeepPartial } from 'ngx-vest-forms';

// A form model is always deep partial because angular will create it over time organically
type MyFormModel = DeepPartial<{
  generalInfo: {
    firstName: string;
    lastName: string;
  }
}>

@Component({
  imports: [vestForms],
  template: `
<form scVestForm 
      (formValueChange)="formValue.set($event)"
      (ngSubmit)="onSubmit()">
      <div ngModelGroup="generalInfo">
        <label>First name</label>
        <input type="text" name="firstName" [ngModel]="formValue().generalInformation?.firstName"/>
      
        <label>Last name</label>
        <input type="text" name="lastName" [ngModel]="formValue().generalInformation?.lastName"/>
      </div>
</form>
  `
})
export class MyComponent {
  // This signal will hold the state of our form
  protected readonly formValue = signal<MyFormModel>({});
}

Note: Template-driven forms are deep partial, so always use the ? operator in your templates.

That's it! This will feed the formValue signal and angular will create a form group and 2 form controls for us automatically. The object that will be fed in the formValue signal will look like this:

formValue = {
  generalInfo: {
    firstName: '',
    lastName: ''
  }
}

The ngForm will contain automatically created FormGroups and FormControls. This does not have anything to do with this package. It's just Angular:

form = {
  controls: {
    generalInformation: { // FormGroup
      controls: {
        firstName: {...}, // FormControl
        lastName: {...} //FormControl
      }
    }
  }
}

The scVestForm directive offers some basic outputs for us though:

Output Description
formValueChange Emits when the form value changes. But debounces the events since template-driven forms are created by theframework over time
dirtyChange Emits when the dirty state of the form changes
validChange Emits when the form becomes dirty or pristine
errorsChange Emits an entire list of the form and all its form groups and controls

Avoiding typo's

Template-driven forms are type-safe, but not in the name attributes or ngModelGroup attributes. Making a typo in those can result in a time-consuming endeavor. For this we have introduced shapes. A shape is an object where the scVestForm can validate to. It is a deep required of the form model:

import { DeepPartial, DeepRequired, vestForms } from 'ngx-vest-forms';

type MyFormModel = DeepPartial<{
  generalInfo: {
    firstName: string;
    lastName: string;
  }
}>

export const myFormModelShape: DeepRequired<MyFormModel> = {
  generalInfo: {
    firstName: '',
    lastName: '' 
  }
};

@Component({
  imports: [vestForms],
  template: `
<form scVestForm 
      [formShape]="shape"
      (formValueChange)="formValue.set($event)"
      (ngSubmit)="onSubmit()">
      
      <div ngModelGroup="generalInfo">
        <label>First name</label>
        <input type="text" name="firstName" [ngModel]="formValue().generalInformation?.firstName"/>
      
        <label>Last name</label>
        <input type="text" name="lastName" [ngModel]="formValue().generalInformation?.lastName"/>
      </div>
</form>
  `
})
export class MyComponent {
  protected readonly formValue = signal<MyFormModel>({});
  protected readonly shape = myFormModelShape;
}

By passing the shape to the formShape input the scVestForm will validate the actual form value against the form shape every time the form changes, but only when Angular is in devMode.

Making a typo in the name attribute or an ngModelGroup attribute would result in runtime errors. The console would look like this:

Error: Shape mismatch:

[ngModel] Mismatch 'firstame'
[ngModelGroup] Mismatch: 'addresses.billingddress'
[ngModel] Mismatch 'addresses.billingddress.steet'
[ngModel] Mismatch 'addresses.billingddress.number'
[ngModel] Mismatch 'addresses.billingddress.city'
[ngModel] Mismatch 'addresses.billingddress.zipcode'
[ngModel] Mismatch 'addresses.billingddress.country'


    at validateShape (shape-validation.ts:28:19)
    at Object.next (form.directive.ts:178:17)
    at ConsumerObserver.next (Subscriber.js:91:33)
    at SafeSubscriber._next (Subscriber.js:60:26)
    at SafeSubscriber.next (Subscriber.js:31:18)
    at subscribe.innerSubscriber (switchMap.js:14:144)
    at OperatorSubscriber._next (OperatorSubscriber.js:13:21)
    at OperatorSubscriber.next (Subscriber.js:31:18)
    at map.js:7:24

Conditional fields

What if we want to remove a form control or form group? With reactive forms that would require a lot of work but since Template driven forms do all the hard work for us, we can simply create a computed signal for that and bind that in the template. Having logic in the template is considered a bad practice, so we can do all the calculations in our class.

Let's hide lastName if firstName is not filled in:

<div ngModelGroup="generalInfo">
  <label>First name</label>
  <input type="text" name="firstName" [ngModel]="formValue().generalInformation?.firstName"/>
  
  @if(lastNameAvailable()){
    <label>Last name</label>
    <input type="text" name="lastName" [ngModel]="formValue().generalInformation?.lastName"/>
  }
</div>
class MyComponent {
  ...
  protected readonly lastNameAvailable = 
    computed(() => !!this.formValue().generalInformation?.firstName);
}

This will automatically add and remove the form control from our form model. This also works for a form group:

@if(showGeneralInfo()){
  <div ngModelGroup="generalInfo">
    <label>First name</label>
    <input type="text" name="firstName" [ngModel]="formValue().generalInformation?.firstName"/>
    
    <label>Last name</label>
    <input type="text" name="lastName" [ngModel]="formValue().generalInformation?.lastName"/>
  </div>
}

Reactive disabling

To achieve reactive disabling, we just have to take advantage of computed signals as well:

class MyComponent {
  protected readonly lastNameDisabled = 
    computed(() => !this.formValue().generalInformation?.firstName);
}

We can bind the computed signal to the disabled directive of Angular.

<input type="text" name="lastName" 
       [disabled]="lastNameDisabled()" 
       [ngModel]="formValue().generalInformation?.lastName"/>

Validations

The absolute gem in ngx-vest-forms is the flexibility in validations without writing any boilerplate. The only dependency this lib has is vest.js. An awesome lightweight validation framework. You can use it on the backend/frontend/Angular/react etc...

We use vest because it introduces the concept of vest suites. These are suites that kind of look like unit-tests but that are highly flexible:

  • Write validations on forms
  • Write validations on form groups
  • Write validations on form controls
  • Composable/reuse-able different validation suites
  • Write conditional validations

This is how you write a simple Vest suite:

import { enforce, only, staticSuite, test } from 'vest';
import { MyFormModel } from '../models/my-form.model'

export const myFormModelSuite = staticSuite(
    (model: MyformModel, field?: string) => {
      if (field) {
        // Needed to not run every validation every time
        only(field);
      }
      test('firstName', 'First name is required', () => {
        enforce(model.firstName).isNotBlank();
      });
      test('lastName', 'Last name is required', () => {
        enforce(model.lastName).isNotBlank();
      });
    }
  );
};

In the test function the first parameter is the field, the second is the validation error. The field is separated with the . syntax. So if we would have an addresses form group with an billingAddress form group inside and a form control street the field would be: addresses.billingAddress.street.

This syntax should be self-explanatory and the entire enforcements guidelines can be found on vest.js.

Now let's connect this to our form. This is the biggest pain that ngx-vest-forms will fix for you: Connecting Vest suites to Angular

class MyComponent {
  protected readonly formValue = signal<MyFormModel>({});
  protected readonly suite = myFormModelSuite;
}

<form scVestForm
      [formShape]="shape"
      [formValue]="formValue"
      [suite]="suite"
      (formValueChange)="formValue.set($event)"
      (ngSubmit)="onSubmit()">
  ...
</form>

That's it. Validations are completely wired now. Because ngx-vest-forms will hook into the [ngModel] and ngModelGroup attributes, and create ngValidators automatically.

It goes like this:

  • Control gets created, Angular recognizes the ngModel and ngModelGroup directives
  • These directives implement AsyncValidator and will connect to a vest suite
  • User types into control
  • The validate function gets called
  • Vest gets called for one field
  • Vest returns the errors
  • @simpilfied/forms puts those errors on the angular form control

This means that valid, invalid, errors, statusChanges etc will keep on working just like it would with a regular angular form.

Showing validation errors

Now we want to show the validation errors in a consistent way. For that we have provided the sc-control-wrapper attribute component.

You can use it on:

  • elements that hold ngModelGroup
  • elements that have an ngModel (or form control) inside of them.

This will show errors automatically on:

  • form submit
  • blur

Note: If those requirements don't fill your need, you can write a custom control-wrapper by copy-pasting the control-wrapper and adjusting the code.

Let's update our form:


<div ngModelGroup="generalInfo" sc-control-wrapper>
  <div sc-control-wrapper>
    <label>First name</label
    <input type="text" name="firstName" [ngModel]="formValue().generalInformation?.firstName"/>
  </div>

  <div sc-control-wrapper>
    <label>Last name</label>
    <input type="text" name="lastName" [ngModel]="formValue().generalInformation?.lastName"/>
  </div>
</div>

This is the only thing we need to do to create a form that is completely wired with vest.

  • Automatic creation of form controls and form groups
  • Automatic connection to vest suites
  • Automatic typo validation
  • Automatic adding of css error classes and showing validation messages
    • On blur
    • On submit

Conditional validations

Vest makes it extremely easy to create conditional validations. Assume we have a form model that has age and emergencyContact. The emergencyContact is required, but only when the person is not of legal age.

We can use the omitWhen so that when the person is below 18, the assertion will not be done.

import { enforce, omitWhen, only, staticSuite, test } from 'vest';

...
omitWhen((model.age || 0) >= 18, () => {
  test('emergencyContact', 'Emergency contact is required', () => {
    enforce(model.emergencyContact).isNotBlank();
  });
});

You can put those validations on every field that you want. On form group fields and on form control fields. Check this interesting example below:

  • Password is always required
  • Confirm password is only required when there is a password
  • The passwords should match, but only when they are both filled in
test('passwords.password', 'Password is not filled in', () => {
  enforce(model.passwords?.password).isNotBlank();
});
omitWhen(!model.passwords?.password, () => {
  test('passwords.confirmPassword', 'Confirm password is not filled in', () => {
    enforce(model.passwords?.confirmPassword).isNotBlank();
  });
});
omitWhen(!model.passwords?.password || !model.passwords?.confirmPassword, () => {
  test('passwords', 'Passwords do not match', () => {
    enforce(model.passwords?.confirmPassword).equals(model.passwords?.password);
  });
});

Forget about manually adding, removing validators on reactive forms and not being able to re-use them. This code is easy to test, easy to re-use on frontend, backend, angular, react, etc... Oh, it's also pretty readable

Composable validations

We can compose validations suites with sub suites. After all, we want to re-use certain pieces of our validation logic and we don't want one huge unreadable suite. This is quite straightforward with Vest.

Let's take this simple function that validates an address:

export function addressValidations(model: AddressModel | undefined, field: string): void {
  test(`${field}.street`, 'Street is required', () => {
    enforce(model?.street).isNotBlank();
  });
  test(`${field}.city`, 'City is required', () => {
    enforce(model?.city).isNotBlank();
  });
  test(`${field}.zipcode`, 'Zipcode is required', () => {
    enforce(model?.zipcode).isNotBlank();
  });
  test(`${field}.number`, 'Number is required', () => {
    enforce(model?.number).isNotBlank();
  });
  test(`${field}.country`, 'Country is required', () => {
    enforce(model?.country).isNotBlank();
  });
}

Our suite would consume it like this:

import { enforce, omitWhen, only, staticSuite, test } from 'vest';
import { PurchaseFormModel } from '../models/purchaseFormModel';

export const mySuite = staticSuite(
  (model: PurchaseFormModel, field?: string) => {
    if (field) {
      only(field);
    }
    addressValidations(model.addresses?.billingAddress, 'addresses.billingAddress');
    addressValidations(model.addresses?.shippingAddress, 'addresses.shippingAddress');
  }
);

We achieved decoupling, readability and reuse of our addressValidations.

A more complex example

Let's combine the conditional part with the reusable part. We have 2 addresses, but the shippingAddress is only required when the shippingAddressIsDifferentFromBillingAddress Checkbox is checked. But if it is checked, all fields are required. And if both addresses are filled in, they should be different.

This gives us validation on:

  • The addresses form field (they can't be equal)
  • The shipping Address field (only required when checkbox is checked)
  • validation on all the address fields (street, number, etc) on both addresses
addressValidations(
  model.addresses?.billingAddress,
  'addresses.billingAddress'
);
omitWhen(
  !model.addresses?.shippingAddressDifferentFromBillingAddress,
  () => {
    addressValidations(
      model.addresses?.shippingAddress,
      'addresses.shippingAddress'
    );
    test('addresses', 'The addresses appear to be the same', () => {
      enforce(JSON.stringify(model.addresses?.billingAddress)).notEquals(
        JSON.stringify(model.addresses?.shippingAddress)
      );
    });
  }
);

Validation options

The validation is triggered immediately when the input on the formModel changes.
In some cases you want to debounce the input (e.g. if you make an api call in the validation suite).

You can configure additional validationOptions at various levels like form, ngModelGroup or ngModel.


<form scVestForm
      ...
      [validationOptions]="{ debounceTime: 0 }">
    ...
    <div sc-control-wrapper>
        <label>UserId</label>
        <input type="text" name="userId" [ngModel]="formValue().userId?"
               [validationOptions]="{ debounceTime: 300 }"/>
    </div>
    ...
</form>

Validations on the root form

When we want to validate multiple fields that are depending on each other, it is a best practice to wrap them in a parent form group. If password and confirmPassword have to be equal the validation should not happen on password nor on confirmPassword, it should happen on passwords:

const form = {
  // validation happens here
  passwords: {
    password: '',
    confirmPassword: ''
  }
};

Sometimes we don't have the ability to create a form group for 2 depending fields, or sometimes we just want to create validation rules on portions of the form. For that we can use validateRootForm. Use the errorsChange output to keep the errors as state in a signal that we can use in the template wherever we want.

{{ errors()?.['rootForm'] }} <!-- render the errors on the rootForm -->
{{ errors() }} <!-- render all the errors -->
<form scVestForm 
      [formValue]="formValue()"
      [validateRootForm]="true"
      [formShape]="shape"
      [suite]="suite"
      (errorsChange)="errors.set($event)"
      ...>
</form>
export class MyformComponent {
  protected readonly formValue = signal<MyFormModel>({});
  protected readonly suite = myFormModelSuite;
  // Keep the errors in state
  protected readonly errors = signal<Record<string, string>>({ });
}

When setting the [validateRootForm] directive to true, the form will also create an ngValidator on root level, that listens to the ROOT_FORM field.

To make this work we need to use the field in the vest suite like this:

import { ROOT_FORM } from 'ngx-vest-forms';

test(ROOT_FORM, 'Brecht is not 30 anymore', () => {
  enforce(
    model.firstName === 'Brecht' && 
    model.lastName === 'Billiet' && 
    model.age === 30).isFalsy();
});

Validation of dependant controls and or groups

Sometimes, form validations are dependent on the values of other form controls or groups. This scenario is common when a field's validity relies on the input of another field. A typical example is the confirmPassword field, which should only be validated if the password field is filled in. When the password field value changes, it necessitates re-validating the confirmPassword field to ensure consistency.

Here's how you can handle validation dependencies with ngx-vest-forms and vest.js:

Use Vest to create a suite where you define the conditional validations. For example, the confirmPassword field should only be validated when the password field is not empty. Additionally, you need to ensure that both fields match.

import { enforce, omitWhen, staticSuite, test } from 'vest';
import { MyFormModel } from '../models/my-form.model';

export const myFormModelSuite = staticSuite((model: MyFormModel, field?: string) => {
    if (field) {
        only(field);
    }

    test('password', 'Password is required', () => {
        enforce(model.password).isNotBlank();
    });

    omitWhen(!model.password, () => {
        test('confirmPassword', 'Confirm password is required', () => {
            enforce(model.confirmPassword).isNotBlank();
        });
    });

    omitWhen(!model.password || !model.confirmPassword, () => {
        test('passwords', 'Passwords do not match', () => {
            enforce(model.confirmPassword).equals(model.password);
        });
    });
});

Creating a validation config. The scVestForm has an input called validationConfig, that we can use to let the system know when to retrigger validations.

protected validationConfig = {
    password: ['passwords.confirmPassword']
}

Here we see that when password changes, it needs to update the field passwords.confirmPassword. This validationConfig is completely dynamic, and can also be used for form arrays.


<form scVestForm
      ...
      [validationConfig]="validationConfig">
    <div ngModelGroup="passwords">
        <label>Password</label>
        <input type="password" name="password" [ngModel]="formValue().passwords?.password"/>

        <label>Confirm Password</label>
        <input type="password" name="confirmPassword" [ngModel]="formValue().passwords?.confirmPassword"/>
    </div>
</form>

Form array validations

An example can be found in this simplified courses article There is also a complex example of form arrays with complex validations in the examples.

Child form components

Big forms result in big files. It makes sense to split them up. For instance an address form can be reused, so we want to create a child component for that. We have to make sure that this child component can access the ngForm. For that we have to use the vestFormViewProviders from ngx-vest-forms

...
import { vestForms, vestFormsViewProviders } from 'ngx-vest-forms';

@Component({
  ...
  viewProviders: [vestFormsViewProviders]
})
export class AddressComponent {
  @Input() address?: AddressModel;
}

Examples

to check the examples, clone this repo and run:

npm i
npm start

There is an example of a complex form with a lot of conditionals and specifics, and there is an example of a form array with complex validations that is used to create a form to add business hours. A free tutorial will follow soon.

You can check the examples in the github repo here. Here{:target="_blank"} is a stackblitz example for you. It's filled with form complexities and also contains form array logic.

Want to learn more?

course.jpeg

This course teaches you to become a form expert in no time.

Angular Resources

are all listed below.

Resources

listed to get explored on!!

Made with ❤️

to provide different kinds of informations and resources.