-
Notifications
You must be signed in to change notification settings - Fork 0
5. Forms
-
The
MoviesDetailComponentcontains 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.tsthat 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.tsfile 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
GENRESarray in themovies.validators.tsfile and create agenreValidatorfunction 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
MovieDetailComponentand 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
@ifand@else ifto properly show/hide errors.
NOTE: for the
genrefield 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.
Next we want to validate the genre property against the genre array we retrieve from the backend.
- We start by creating a method in
MovieServiceto get the genres from the server, calledgetGenres
export class MovieService {
#genreApi = `${environment.apiUrl}/genres`;
...
getGenres(): Observable<string[]> {
return this.http.get<string[]>(this.#genreApi);
}
}- In the
movies.validators.tsfile:`- add an Async Validator called
genreAsyncValidator- we'll make this an arrow function so we can preserve the
thisto the scope of this service, and use theMovieService - also, we need to use the
MovieServiceso we'll provide this as a parameter and return a validation function that takes anAbstractControland returns anObservable<ValidationErrors | null>
- we'll make this an arrow function so we can preserve the
- add an Async Validator called
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',
...
}),Reactive forms async validators
Next we want to introduce a cross field validation that checks the following condition:
-
if the movie has a
science fictiongenre, its year can't be earlier than1902, 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.tsfile calledsciFiGenreYearValidator. This will be aValidatorFnand will take anAbstractControlas a parameter only this time (as opposed to the previous examples) it will receive aFormGroupsince the validator will be applied on theFormGrouplevel (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 returnnull).
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
FormGroupdeclared in theMoviesDetailComponent, 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 1902in the template by verifying the presence of errors on the control and also making sure that the form state isdirty
movieForm.errors?.wrongSciFiYearReactive forms cors field validators
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
getmethod 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
pushandremovemethods 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
genrefield of the form usingformArrayName:
...
<div formArrayName="genre" class="mb-2">
</div>- Because
genreis an form array, we can iterate through the controlcontrolsproperty using@forin 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 toformControlName.
@for (genreCtrl of genreArray.controls; track $index)- To get the genre values that we display in the dropdown, we can import the
GENREScollection 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 theremoveGenre(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
addGenremethod 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?.wrongGenreTODO: 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 thegenreValidator(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
sciFiGenreYearValidatorto accommodate for the change from string to array of strings:
const hasSciFi = (genreCtrl.value as string[])
.map((g) => g.trim().toLowerCase())
.includes('science fiction');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-controlcomponent
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
keyis the genre name and thevalueis 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
keyvaluepipe provided by the Angular framework in order to transform the object to an iterable that contains thekeyandvaluepair 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
keyand the selected state of each key will be thevalueproperty. We can use thisvalueproperty 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
keyto thetoggleGenremethod 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
ControlValueAccessorinterface, and define theNG_VALUE_ACCESSORprovider 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
onChangeand 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
MovieDetailComponentreplace the existing genre controls with the newngm-genre-controland bind it to thegenreformControlName. 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
genresince we'll receive the string in the correct format from the control:
onSubmit() {
...
const modifiedMovie = {
...this.#movie(),
...value,
};
}- And update the
sciFiGenreYearValidatorfor 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)
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
componentsfolder using the following cli command:
ng generate component movies/components/rating-control --flat=false
- In the newly created
RatingComponentclass, our rating component should hold the rating in amodelinput:
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
@fordirective 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](orngStyle) binding to give the star a color if the value for the certain index in the array istrue(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
MovieItemComponenttemplate, just below the comment section (don't forget to add it to theimportsarray):
<div class="mt-2">
<p class="fw-bold">Rating:</p>
<ngm-rating-control />
</div>- We need to tell the
MovieListComponentto update the rating on the server when the rating changes. We can do that by linking anoutputratingChangeevent of theRatingComponent:
rateMovie = output<RatingUpdate>();- And in order to make two-way binding work, we need to bind the
ratingproperty of theRatingComponentto theratingproperty of theMovieItemComponent(binding class -> template) and emit theratingChangeevent when the rating changes (binding template -> class):
<ngm-rating-control
[rating]="movie().rating"
(ratingChange)="rateMovie.emit({ id: movie().id, newRating: $event })"
/>- And, in the
MovieListComponenttemplate, we can bind therateMovieevent to therateMoviemethod on the component:
(rateMovie)="handleRateMovie($event)"- Finally, create a method in the
MovieServicethat updates the ratings and call it in thehandleRateMoviemethod.