Skip to content

5. Forms

Andrei Antal edited this page Oct 24, 2024 · 2 revisions

Challenge 6 - Validation

  • The MoviesDetailComponent contains a form that allows us to edit the movie details. We want to add validation to the form and make sure that the user can't submit the form unless all the required fields are filled in. For that we've already added validation on the following fields:

  • Title - required

  • Year - required

  • Genre - required

  • Plot - required

  • We've also disabled submit button based on the form validity.

[disabled]="movieForm.invalid"

For this challenge we'll add an extra validator to test that the genre field value is from a set list of genres. In the next exercise we'll also add an async validator that will test if the genre is present in the list of genres we retrieve from the server.

  • First we'll create a validation file called movies/services/movies.validators.ts that will provide de validator methods. We can use simple functions for validators and pass in any required dependencies.

  • Have a look at the existing array in the model/movie-data.ts file that we'll check against:

export const GENRES = [
  'action',
  'adventure',
  'comedy',
  'crime',
  'drama',
  'fantasy',
  'historical',
  'horror',
  'mystery',
  'romance',
  'satire',
  'science fiction',
  'thriller',
  'western',
];
  • we import the GENRES array in the movies.validators.ts file and create a genreValidator function that will test if the supplied genres, separated by comma, are present in the genres array:
export function genreValidator(formControl: AbstractControl): ValidationErrors | null {
    // test if genre values (separated by comma) are in the genre list
    // return { wrongGenre: true }; <- if validation error
    // return null; <- if validation ok
}
  • import the method in the MovieDetailComponent and add the validator in the form group creation:
genre: this.fb.control('', {
  nonNullable: true,
  validators: [Validators.required, genreValidator],
})
  • And in order to validate the two types of errors (required and appropriate genre), we need to add two separate errors:
<!-- required error -->
<div class="invalid-feedback" >Genre required</div>
<!-- wrongGenre error -->
<div class="invalid-feedback" >Not a genre category</div>
  • Use @if and @else if to properly show/hide errors.

NOTE: for the genre field we need to show the error only if the field has been filled with some text (otherwise the required error will show). Make sure you add the correct conditions.

Diffs

DOCUMENTATION

Reactive forms validators

Challenge 7 - Async validation

Next we want to validate the genre property against the genre array we retrieve from the backend.

  • We start by creating a method in MovieService to get the genres from the server, called getGenres
export class MovieService {
  #genreApi = `${environment.apiUrl}/genres`;

  ...

  getGenres(): Observable<string[]> {
    return this.http.get<string[]>(this.#genreApi);
  }
}
  • In the movies.validators.ts file:`
    • add an Async Validator called genreAsyncValidator
      • we'll make this an arrow function so we can preserve the this to the scope of this service, and use the MovieService
      • also, we need to use the MovieService so we'll provide this as a parameter and return a validation function that takes an AbstractControl and returns an Observable<ValidationErrors | null>
export const genreAsyncValidator =
  (movieService: MovieService) =>
  (formControl: AbstractControl): Observable<ValidationErrors | null> => {
  // Validator code  
  // get genres from server 
  
  return movieService.getGenres().pipe(
      switchMap(genres => {
        ...
        // compare against form control value
        // must return observable. => You can use of()
        // return validation errors, if any or null
      }))
};
  • Add async validator to the form builder:
genre: this.fb.control('', {
  nonNullable: true,
  validators: Validators.required,
  asyncValidators: genreAsyncValidator(this.movieService),
})
  • You can limit http calls by running validation only when we exit the form element:
genre: this.fb.control('', {
  updateOn: 'blur',
  ...
}),

Diffs

DOCUMENTATION

Reactive forms async validators

Challenge 8 - Cross field validation

Next we want to introduce a cross field validation that checks the following condition:

  • if the movie has a science fiction genre, its year can't be earlier than 1902, the year A trip to the moon was released, which is considered the first Sci-fi movie ever made.

  • First we create the validator function in the movies.validators.ts file called sciFiGenreYearValidator. This will be a ValidatorFn and will take an AbstractControl as a parameter only this time (as opposed to the previous examples) it will receive a FormGroup since the validator will be applied on the FormGroup level (more on that in a moment). This allows us to get reference to the group controls using the .get(/*form control name*/) method (which can return null).

export const sciFiGenreYearValidator: ValidatorFn = (
  ctrl: AbstractControl
): ValidationErrors | null => {
  // the ctrl is the form -> store the genre and year controls to get their values (make sure you check for null values)
  // check if genre contains 'science fiction' (you might want to apply trim and toLowerCase to the value in order to eliminate false negatives)
  // if condition is ok, return null else return 'wrongSciFiYear' error
};
  • Next we add the validator in the FormGroup declared in the MoviesDetailComponent, but at the form level:
  movieForm = this.fb.group(
      // form controls declarations
    },
    { validators: sciFiGenreYearValidator, updateOn: 'blur' }
  );
  • Finally we show the error with the text Sci-Fi movies were introduced after 1902 in the template by verifying the presence of errors on the control and also making sure that the form state is dirty
movieForm.errors?.wrongSciFiYear

Diffs

DOCUMENTATION

Reactive forms cors field validators

Challenge 9 - Dynamic forms

At the moment we can add genres to a movie by filling in a free-text input, and we have to make sure that we both spell the genre correctly and that genre is in the accepted array of genres, otherwise the form will be invalid (thanks to the validators we wrote for this field). We can improve the experience by providing a way for the user to select one or more genre from a set of dropdowns with a fixed set of options, correctly spelled and in the genre array. Here is a sample of how that would look:

This is a typical use case for the FormArray. Each time we click on Add genre we will add a new control that we render as a dropdown option in the UI. The result will be an array of genres strings, that we need to concatenate in order to save it to the "database" because that is the expected format:

 RECEIVED FROM THE FORM            |   FORMAT TO SAVE
-----------------------------------------------------------------
 ['action', 'adventure', 'drama']  |  'action, adventure, drama'
  • We start by changing the control type in the form declaration, in the MovieDetailsComponent:
genre: this.fb.nonNullable.array([] as string[], { // <-- we use the nonNullable validator to make sure the array is not empty
  validators: genreValidator,
  updateOn: 'change',
}),
  • We're going to reference this control a few times, so we can create a getter method to get a reference and a quick access method to the form array. Use the get method on the form to get the control:
get genreArray(): FormArray {
  // return genre control from the form
}
  • Next, we need to create ways to add/remove genres in the array (actions that we'll later attach to the add/remove buttons in the template). Create the following methods in the class file and fill in the functionality using the push and remove methods on the control:
addGenre(): void {
  // push a new control in genreArray 
}

removeGenre(index: number): void {
  // remove the control from genreArray located at the index 
}
  • We also need to alter the code that sets the initial value, by transforming the genre string to an array with the genre values, so we can feed them to its own controls.
effect((onCleanup) => {
  if (id) {
    ...
    .subscribe((movie) => {
      // transform the genre string received on the model in an array:
      // ex: 'action, science fiction, drama' => ['action', 'science fiction', 'drama']
      // for each of the elements in the array, push a control in the genre form array
      // finally fill the form with the initial values of the movie plus the genres in the new format
      this.movieForm.patchValue({ ...movie, genre });
      ...
    });
    ...
  }
})
  • Finally we need to make the final transformation to the genres property, from array to string in order to correctly persist it to the database:
onSubmit() {
  ...
  const modifiedMovie = {
    ...this.#movie(),
    ...value,
    genre: value.genre.filter((g: string) => g).join(', '), // transform the array into a string, removing empty values first (dropdowns without a selection)
  };
  ...
}
  • In the template, we remove the existing genre markup and replace it with a div that binds to the genre field of the form using formArrayName:
...
<div formArrayName="genre" class="mb-2">
</div>
  • Because genre is an form array, we can iterate through the control controls property using @for in order to display template elements and bind to them. We also need to keep track of the index we're iteration on, since we'll use that and binding it to formControlName.
@for (genreCtrl of genreArray.controls; track $index)
  • To get the genre values that we display in the dropdown, we can import the GENRES collection and set it to a property we can later use and iterate on in the template:
import { GENRES } from '../model/movie-data';
import {
  genreValidator,
  sciFiGenreYearValidator,
} from '../../services/movies-validators.service';
...

export class MovieDetailComponent implements OnInit {
...
  readonly genres = GENRES;
...
}
  • Inside the form array item, we create the <select> element that will display the genres and a button that removes the genre (using the removeGenre(index) method we created earlier):
<select class="form-select w-50 d-inline me-2" [formControlName]="i">
  <option disabled value="">Choose a new genre</option>
  @for (genre of genres; track genre) {
    <option [ngValue]="genre">
      {{genre}}
    </option>
  }
</select>
<i class="far fa-times-circle" role="button" (click)="removeGenre(i)"></i>
  • And finally, outside the form array element, we add a button that will be responsible for adding more genres (basically adding a new form control in the array using the addGenre method we defined earlier and triggering a template re-render with the new set of controls):
<a class="btn btn-dark btn-sm" (click)="addGenre()">Add genre</a>
  • Since we also attach a validator to the form array, we can show a validation if the genre has not been filled in (at least one dropdown with a value set):
genreArray.errors?.wrongGenre

TODO: what is the condition that we need to add in order for the error to not show initially, but only after we've interacted with the control?

  • Finally we need to fix the validators. Since we don't need to check for correctness of genre names because they come for a specific list of accepted value, we can just check if the list is empty or not, making this just a more sophisticated version of Required. Update the initial condition in the genreValidator (we'll keep the rest of the condition as a way to code defensively):
if (
    !ctrl.value // no actual value for the field
    || 
    !ctrl.value.join() // not a single field with a value (dropdown with option selected)
) {
    return { wrongGenre: true };
  }
  • We also need to update the sciFiGenreYearValidator to accommodate for the change from string to array of strings:
const hasSciFi = (genreCtrl.value as string[])
  .map((g) => g.trim().toLowerCase())
  .includes('science fiction');

Diffs

DOCUMENTATION

Dynamic forms

Challenge 10 - Custom form controls

For our final exercise we'll create a custom form control for genre where the user can toggle selected genres form a visual tag list, like so:

  • First we create the genre-control component
ng generate component movies/components/genre-control --flat=false
  • The main idea of the component will be that we will render a list of elements and toggle their selected state. So inside the component class we need to create a map of the genres - an object where the key is the genre name and the value is the selected state:
genres = signal<Genres>({
  action: false,
  adventure: false,
  comedy: false,
  crime: false,
  drama: false,
  fantasy: false,
  historical: false,
  horror: false,
  mystery: false,
  romance: false,
  satire: false,
  'science fiction': false,
  thriller: false,
  western: false,
};
  • Next we iterate the property in order to display the values. Since the property is not an array or an iterable type, we can use the keyvalue pipe provided by the Angular framework in order to transform the object to an iterable that contains the key and value pair for each element:
@for (genre of genres() | keyvalue; track $index) {
  <span class="badge bg-primary me-2">
    {{genre.key}}
  </span>
}
  • The display value will be key and the selected state of each key will be the value property. We can use this value property to style the element accordingly:
[class.bg-primary]="genre.value"
[class.bg-light]="!genre.value"
[class.text-dark]="!genre.value"
  • Finally each element should have a callback when clicked by sending the selected key to the toggleGenre method that will modify the corresponding value on the property.
(click)="toggleGenre(genre.key)"
  • In the class, add the method that handles the genre click:
toggleGenre(genreKey: string): void {
  // toggle the value in the genre object for the given key
}
  • Next, in order to make the component usable in a form, we need to implement the ControlValueAccessor interface, and define the NG_VALUE_ACCESSOR provider at the component level:
@Component({
  ...
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      multi: true,
      useExisting: GenreControlComponent,
    },
  ],
})
export class GenreControlComponent implements ControlValueAccessor{ 
  onChange!: (genres: string) => {};
  onTouched!: () => {};

  registerOnChange(fn: any): void {
    this.onChange = fn;
  }
  registerOnTouched(fn: any): void {
    this.onTouched = fn;
  }

  writeValue(obj: string): void {
    // this method will be called when the form sets the value (either by initialization or calling setValue)

    // the received genres string value should be split into singular genres so we can set each value in the local genre property to 'true'

    // ex: if 'action, science fiction, drama' is the genre string, then 'action', 'sci-fi' and 'drama' should be set to true 
  }
}
  • Finally, in order to make the component forward changed values to the form, add a call to onChange and pass the string in the correct format:
public toggleGenre(genreKey: string): void {
  ...
  // get all the keys that have a 'true' value form the genre object and crete a coma separated string with those values 
  // ex: if 'action', 'sci-fi' and 'drama' are true then the resulting string should be -> 'action, sci-fi, drama'
  this.onChange(/*resulting string*/);
}
  • Next in the MovieDetailComponent replace the existing genre controls with the new ngm-genre-control and bind it to the genre formControlName. Also add validation:
<ngm-genre-control formControlName="genre" />
@if (movieForm.controls.genre.invalid && movieForm.controls.genre.dirty) {
  <div class="invalid-feedback">Genre required</div>
}
  • Update the code in the class and replace the existing form array with a regular form control:
movieForm: FormGroup = this.fb.group(
  {
    ...
    genre: this.fb.control('', {
        nonNullable: true,
        validators: Validators.required,
        updateOn: 'change',
      }),
    ...
  },
  { validators: sciFiGenreYearValidator }
);
  • Finally, update the submit code, by removing the genre since we'll receive the string in the correct format from the control:
onSubmit() {
  ...
  const modifiedMovie = {
    ...this.#movie(),
    ...value,
  };
}
  • And update the sciFiGenreYearValidator for the same reason:
const hasSciFi = (genreCtrl.value as string)
  .split(',')
  .map((g) => g.trim().toLowerCase())
  .includes('sci-fi');

TODO: Make sure the validation error on the genre field doesn't appear unless the control has ben interacted with (TIP: make use of the onTouched callback and the dirty property on the control)

Diffs

DOCUMENTATION

ControlValueAccessor

Challenge 11 - Custom two-way binding

For the final exercise in this section we'll create one last custom form control, this time for the rating. The rating control will be a set of 5 stars that will be filled in based on the rating value. The user will be able to click on the stars to change the rating value. Here is a sample of how that would look:

  • First, let's create the rating component, in the components folder using the following cli command:
ng generate component movies/components/rating-control --flat=false
  • In the newly created RatingComponent class, our rating component should hold the rating in a model input:
rating = model<number>(0);
  • Add a first start icon to the template
<i
  role="button"
  class="fa-solid fa-star"
  [class.filled-star]="state <= rating()"
></i>

and style it:

i {
  cursor: pointer;
  user-select: none;
}
  • Apply an @for directive on the star and iterate on an array containing five values, keeping track of the index in a variable as well.
@for (state of [1,2,3,4,5]; track $index)
  • Use ngClass/[class] (or ngStyle) binding to give the star a color if the value for the certain index in the array is true (keep in mind that the values you're iterating over are booleans). You can create a class that should be applied if the star should be filled:
.filled-star {
  color: orangered;
}
  • Create a click handler on each star and call a method that will set the rating to the index of the star clicked:
(click)="rating.set($index + 1)"
  • Check to see if everything is working ok in the browser.

Now that we have our rating component working, we need to use it in the MovieItemComponent. But before that, we need to update the Movie data model to accept a new rating property:

export interface Movie {
  ...
  rating: number;
}

Update the db files with default ratings for the movies.

  • Next, we'll use the component in the MovieItemComponent template, just below the comment section (don't forget to add it to the imports array):
<div class="mt-2">
  <p class="fw-bold">Rating:</p>
  <ngm-rating-control />
</div>
  • We need to tell the MovieListComponent to update the rating on the server when the rating changes. We can do that by linking an output ratingChange event of the RatingComponent:
rateMovie = output<RatingUpdate>();
  • And in order to make two-way binding work, we need to bind the rating property of the RatingComponent to the rating property of the MovieItemComponent (binding class -> template) and emit the ratingChange event when the rating changes (binding template -> class):
<ngm-rating-control
  [rating]="movie().rating"
  (ratingChange)="rateMovie.emit({ id: movie().id, newRating: $event })"
/>
  • And, in the MovieListComponent template, we can bind the rateMovie event to the rateMovie method on the component:
(rateMovie)="handleRateMovie($event)"
  • Finally, create a method in the MovieService that updates the ratings and call it in the handleRateMovie method.

Diffs

DOCUMENTATION

Two-way binding

Clone this wiki locally