Skip to content

1. Forms

Andrei Antal edited this page Sep 25, 2021 · 1 revision

Challenge 1 - Custom two-way binding

Let’s create a movie rating component and make it possible to use two way data binding for its.

  • First, let's create the rating component, in the components folder using the following cli command:
ng generate component movies/components/rating --module movies

TIP: you can use the --dry-run flag in the CLI command to see what files will get created/updated without actually running the command.

  • If we inspect the newly created component we see that the selector for the rating component is ngi-rating. Thus, in order to use the new rating component, in the MovieItem template, just above the comment section:
<div>
  <p><b>Rating:</b></p>
  <ngi-rating></ngi-rating>
</div>
  • In the RatingComponent class, our rating component should hold 5 stars each with a binary state (colored or not). We can hold the state of the stars in an array:
starStates = [ false, false, false, false, false ];
  • Add a first start icon to the template
<i class="fas fa-star"></i>
  • Apply an *ngFor directive on the star and iterate the starStates array, keeping track of the index in a variable as well.
  • 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: red;
}
  • Create a click handler on each star called handleRatingClick() and pass it the index of the iteration. The click handler should iterate through the starStates array and give true values to those of lower index and false of higher index (keep in mind that the array is 0 indexed). Example:
Index = 2 => ratings = [ true, true, true, false, false ]; 

HINT: for fast manipulation, use Array.map() on the starStates array;

  • Extract this functionality in a function called updateRating(newRating) so that we can reuse it.

  • Check to see if everything is working ok in the browser.

Next, we need to communicate with the MovieItemComponent and have the rating passed down from this component. We do this using Inputs and Outputs. Here is how the communication should work:

  • In the MovieRatingComponent add:

    • An Input called rating with a type of number
    • An Outputcalled ratingChange initialized with a new EventEmitter<number> (don't forget to import EventEmitter from @angular/core)
  • In order to set the color of the stars every time the rating Input changes, we use ngOnChanges(changes) and call the updateRating method with the changed value of the rating input. This will also run for the initialization of the component (instead of just using ngOnInit)

  • In the handleRatingClick method, replace the call to updateRating with a call to the emit function of the ratingChange output passing in the received new rating. This way, the component will not change the index internally, but rather tell the parent component that a new rating star has been clicked and send the new rating value. Since handleRatingClick is a one line function we can further optimize the code by removing it from the class and making the call to the ratingChange emitter directly from the template:

(click)="ratingChange.emit(i+1)"
  • In the MovieItemComponent, add a movieRating property initialized with the value 1 and add a two-way binding in the template at the rating component:
<ngi-rating [(rating)]="movieRating"></ngi-rating>

You can test that the binding works by debugging and temporarily displaying the value of movieRating ({{movieRating}}).

So at the moment, the two way binding works this way:

  • MovieItem -> Rating - set the initial value of the rating (you can test this by modifying the initial value of movieRating)
  • Rating -> MovieItem - each time a star is pressed, the value of movieRating is updated.

Diffs

DOCUMENTATION

Two way data binding

Challenge 2 - Refactor reactive search

  • Add ReactiveForms module to MoviesModule
import { FormsModule, ReactiveFormsModule } from '@angular/forms';

@NgModule({
  imports: [
    ...
    ReactiveFormsModule,
    ...
  ],
  ...
})
export class MoviesModule {}
  • In the MovieListComponent component
    • Remove the @ViewChild property
    • Add a searchField property of type FormControl:
  public searchField = new FormControl('');
  • Link the property to the input element by using the formControl directive (removing the template variable):
<input [formControl]="searchField" placeholder="Search movies">
  • In ngOnInit replace fromEvent with the valueChanges observable or the form control; also remove the map operator, since we don't need to transform the event object:
this.searchField.valueChanges
  .pipe(...)

Diffs

Challenge 3 - Add new movie and edit existing movie

  • We are going to refactor the MovieDetail component and create the movie edit form. First, we need to modify a few things in the movie service.

  • In the MovieService, add/modify the following methods:

    • createMovie(movie: Movie) - adds a movie to the movie array -> POST to ${this.apiUrl}
    • updateMovie(movie: Movie) - updates the movie with the specific id (found in the movie object) -> PUT to ${this.apiUrl}/${movie.id}
    • getMovie(movieId: string) - get a certain movie (with a specific id) -> GET ${this.apiUrl}/${movieId} - if no movieId is provided, you should return an observable that emits an empty Movie making the query more uniform (using the of function imported from 'rxjs')
if (!movieId) {
  return of({
    id: uuid(),
    title: '',
    genre: '',
    plot: '',
    year: '',
    comment: '',
    poster: ''
  });
} else {
  // return existing get movie by id call
}
  • In order to have access to the uuid method, we need to import the method form the library. Add the following import statement:
import { v4 as uuid } from 'uuid';

One optimization opportunity here is extracting the empty movie properties in a constant so we can use it in other places if we need it. Also, the service doesn't need to know the exact structure of the movies object.

  • create a new const variable in movies/model/movie.ts called EMPTY_MOVIE:
export const EMPTY_MOVIE: Omit<Movie, 'id'> = {
  title: '',
  genre: '',
  plot: '',
  year: '',
  comment: '',
  poster: '',
};

Read more about Omit here

  • Use this new constant in the getMovie method in the MovieService:
if (!movieId) {
  return of({
    ...EMPTY_MOVIE,
    id: uuid(),
  });
}
// return existing get movie by id call
  • In the MovieDetailComponent template, create a form for editing movie objects.
  • The form should contain the following components:
    • Title - text (input)
    • Year - number (input)
    • Genre - text (input)
    • Plot - long text (textarea)
    • Poster - text (input)
    • A SUBMIT and a CANCEL button

If you don't want to mess with writing the template, you can use the following sample:

Template:

<div class="card">
  <div class="card-header">
    Edit/Create movie form
  </div>
  <div class="card-body">
    <div class="row">
      <form class="col-9" (ngSubmit)="onSubmit()">
        <div class="form-group mb-2">
          <label for="movieTitle">Title</label>
          <input name="title" class="form-control" id="movieTitle" placeholder="Enter title">
        </div>
        <div class="form-group mb-2">
          <label for="movieGenre">Genre</label>
          <input name="genre" class="form-control" id="movieGenre" placeholder="Enter genre">
        </div>
        <div class="form-group mb-2">
          <label for="movieYear">Year</label>
          <input name="year" class="form-control" id="movieYear" placeholder="Enter year">
        </div>
        <div class="form-group mb-2">
          <label for="moviePlot">Plot</label>
          <input name="plot" class="form-control" id="moviePlot" placeholder="Enter plot">
        </div>
        <div class="form-group mb-4">
          <label for="moviePoster">Poster</label>
          <input name="poster" class="form-control" id="moviePoster" placeholder="Enter poster">
        </div>
        <button type="submit" class="btn btn-primary me-2">Save or create</button>
        <a [routerLink]="['/movies']" class="btn btn-outline-secondary">Cancel</a>
      </form>
      <div class="col-3">
        <img class='w-100'>
      </div>
    </div>
  </div>
</div>

Styles:

:host {
  display: block;
}

.card {
  width: 750px;
}
  • In the MovieDetailComponent class, create a new prop called movieForm of type FormGroup.

  • In the ngOnInit() lifecycle method:

    • create the FormGroup in the movieForm variable
    • create FormControl's for each property
  • In the component template:

    • add [formGroup] and formControlName directives to corresponding template elements
    • for image [src]="movieForm.controls.poster.value"
  • Now that we created our form elements we can switch to a more declarative method of creating the form by using the FormBuilder:

    • inject FormBuilder service in the component
    • in the movieForm creation, replace FormGroup and FormControl with calls to fb.group and fb.control
  • inject (and import) the ActivatedRoute, Router, MovieService services in the constructor.

  • in the ngOnInit lifecycle method

    • get the movie id passed through the paramsMap observable; then, using a switchMap call the getMovie method from the MovieService and subscribe
    • the existence of movieId will let us know if we are in edit or create mode, so we need to save it using a tap operator.
    • in the subscription
      • use patchValue(movie) to fill in values .
      • we also need to keep a copy of the retrieved movie, since we'll use it to override the form value
this.route.paramMap
  .pipe(
    map((paramsMap) => paramsMap.get('id')),
    tap((movieId) => (this.movieId = movieId)),
    switchMap((movieId) => this.movieService.getMovie(movieId))
  )
  .subscribe((movie) => {
    this.movie = movie;
    this.movieForm.patchValue(movie);
  });
  • submit form with (ngSubmit)="onSubmit()"
    • create a property called modifiedMovie and override the movie object initially retrieved with the form.value.
    • if a movie id exists (you are in edit mode - the movieId property is defined) call the updateMovie method on the MovieService, passing in the modifiedMovie.
    • if you are in create mode, call the addMovie method passing the modifiedMovie as a parameter.
    • in th esubscribe method, navigate back to '/movies' -> inject the Router service from @angular/router and use the .navigate() method.
onSubmit() {
    const { value } = this.movieForm;
    const modifiedMovie = {
        ...this.movie,
        ...value,
    };
    if (!this.movieId) {
        this.movieService.createMovie(modifiedMovie).subscribe(/* go back */);
    } else {
        this.movieService.updateMovie(modifiedMovie).subscribe(/* go back */);
    }
}
  • extra TODO: Make the title and the submit button display Create or Edit movie based on how the use navigated to this page.

Diffs

DOCUMENTATION

Reactive Forms

Challenge 4 - Validation

  • We need to add the following validation rules in our form. Add them to the form definition:

  • Title - required

  • Year - required

  • Genre - required

  • Plot - required

Example:

title: this.fb.control('', Validators.required),
  • Also add appropriate errors in the template:
<div class="invalid-feedback" *ngIf="movieForm.controls.title.invalid && movieForm.controls.title.dirty">
  Title required
</div>

TIP: you can save some space by saving the form controls into variables: movieForm.controls.title -> title

  • Also add this extra style:
.invalid-feedback {
  display: block;
}
  • Next, disable submit button based on the form validity.
[disabled]="movieForm.invalid"
  • We'll create a validation service that will provide de validator methods. We use a service because in the next chapter we'll need to use dependency injection to get the genre list asynchronously. But for this example we'll just create a function and export it.
ng generate service movies/services/movies-validators
  • First, in the service, add a genre array that we'll check against:
export const GENRES = [
  'action',
  'adventure',
  'comedy',
  'crime',
  'drama',
  'fantasy',
  'historical',
  'horror',
  'mystery',
  'romance',
  'satire',
  'science fiction',
  'thriller',
  'western',
];
  • Next we need to add a validator for genre. We can create a function called genre that will test if the supplied genres, separated by comma, are present in the genres array:
export const genreValidator: ValidatorFn = (
  ctrl: 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
}
  • Inject the service in the MovieDetailComponent and add the validator in the form group creation:
genre: this.fb.control('', [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 *ngIf to properly show/hide errors by checking the error property on the genre control fot the specific type of error. Also make sure to take into consideration if the form control is dirty so we won't show validation errors before the user interacts with the control.

Diffs

Challenge 5 - Async validation

Now we want to validate the genre property in the form against the genre and check if it is contained in a predefined set of values found in an array we retrieve from the backend.

  • We start by creating a method in MovieService to get the genres from the server, called getGenres that makes a GET call to the /genres endpoint on the backend.

  • Rename the MoviesValidatorsService into MovieGenreAsyncValidator top make it more explicit. Next we create an arrow function property called validateGenreAsync (we use an arrow function so we won't have trouble binding to this when called from the component) (don't forget to inject the MovieService in the service in order to make the call):

validateGenreAsync = () => (formControl: FormControl): Observable<ValidationErrors> => {
  // Validator code  
  // Get genres array form the backend and check the form control value if all the genres are found in that array.
  // must return observable.

  return this.movieService.getGenres().pipe(
      map(genres => {
        ...
        // compare against form control value
        // return validation errors, if any or null
      }))
};
  • Add async validator to the form builder:
genre: this.fb.control('', [Validators.required, this.movieValidator.validateGenreAsync]),
  • You can limit http calls by running validation only when we exit the form element:
genre: this.fb.control('', {
  updateOn: 'blur',
  validators: Validators.required,
  asyncValidators: this.movieValidator.validateGenreAsync,
}),,

Diffs

Challenge 6 - Cross field validation

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

  • if the movie has a sci-fi 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-validator.service.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 'sci-fi' (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:
public movieForm: FormGroup = 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

Challenge 7 - 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.array([], {
  validators: genreValidator,
  updateOn: 'change',
}),
  • We're going to reference this control a lot, 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 genreCtrls(): 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 atach to the add/remove buttons in the template). Create the following methods in the class file and fill in the functioality using the push and remove methods on the control:
public addGenre(): void {
  // push a new control in genreCtrls 
}

public removeGenre(index: number): void {
  // remove the control from genreCtrls 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.
ngOnInit(): void {
  this.route.paramMap
    ...
    .subscribe((movie) => {
      this.movie = movie;
      // transform the genre string received on the model in an array:
      // ex: 'action, sci-fi, drama' => ['action', 'sci-fi', '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 *ngFor 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.
*ngFor="let genreCtrl of genreCtrls.controls; index as i"
  • 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,
  genreValidator,
  sciFiGenreYearValidator,
} from '../../services/movies-validators.service';
...

export class MovieDetailComponent implements OnInit {
...
public 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>
  <option *ngFor="let genre of genres" [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):
genreCtrls.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('sci-fi');

Diffs

Challenge 8 - 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 --module movies
  • 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:
public genres: Genres = {
  action: false,
  adventure: false,
  comedy: false,
  crime: false,
  drama: false,
  fantasy: false,
  historical: false,
  horror: false,
  mystery: false,
  romance: false,
  satire: false,
  'sci-fi': 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:
<span
  class="badge bg-primary me-2"
  *ngFor="let genre of genres | keyvalue"
>
  {{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:
public 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, sci-fi, 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 ngi-genre-control and bind it to the genre formControlName. Also add validation:
<ngi-genre-control formControlName="genre"></ngi-genre-control>
<div class="invalid-feedback" *ngIf="movieForm.controls.genre.invalid">Genre required</div>
  • Update the code in the class and replace the existing form array with a regular form control:
public movieForm: FormGroup = this.fb.group(
  {
    ...
    genre: this.fb.control('', Validators.required),
    ...
  },
  { validators: sciFiGenreYearValidator }
);
  • Finally, update the submit code, since we'll receive the genre 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