Creating a Form Modal Component
In this lesson, we are going to create another dumb/presentational component and it is going to be one that is also shared with multiple features. For the home feature that we are currently working on we need the ability to display a form inside of the modal we are launching to allow the user to create a new checklist.
I feel kind of bad because I told you that it would get easier after our
unusually difficult first component. That is true, but the form-modal is
probably the second most difficult feature in the application. So again, we are
going to touch on some somewhat advanced concepts here. Don’t feel too worried
if things aren’t making complete sense.
We could just create a dumb component specifically for the home feature, but we
are also going to need to do the exact same thing when we get to adding items to
individual checklists in the checklist feature we will create later — we will
again need to display a form inside of a modal. We might decide to just manually
hard code forms for each of these features rather than having a single shared
form component, but since our forms are going to be so simple (we basically just
need to accept a single text input) it will be relatively easy to create
a single component that can be shared with both features.
Create the Form Modal Component
Now we will create our dumb/presentational form component.
import { KeyValuePipe } from '@angular/common';import { Component, input, output } from '@angular/core';import { FormGroup, ReactiveFormsModule } from '@angular/forms';
@Component({ selector: 'app-form-modal', template: ` <header> <h2>{{ title() }}</h2> <button (click)="close.emit()">close</button> </header> <section> <form [formGroup]="formGroup()" (ngSubmit)="save.emit(); close.emit()"> @for (control of formGroup().controls | keyvalue; track control.key){ <div> <label [for]="control.key">{{ control.key }}</label> <input [id]="control.key" type="text" [formControlName]="control.key" /> </div> } <button type="submit">Save</button> </form> </section> `, imports: [ReactiveFormsModule, KeyValuePipe],})export class FormModalComponent { formGroup = input.required<FormGroup>(); title = input.required<string>(); save = output(); close = output();}This is the component in its entirety. It is a somewhat complex component, but also reasonably within the realms of the concepts we have been learning so far.
The only thing we haven’t actually seen yet here is the keyvalue pipe which we
also add to the imports array through KeyValuePipe. The idea here is that
this component will be given a FormGroup which contains form controls (e.g. we
might have a username form control). The keyvalue pipe will allow us to
access the key and value in these control objects. The idea is that we want
to use the key, which is actually the name of the form control, and assign
that as the formControlName for the input. In this way, the specific inputs we
are dynamically rendering out will be correctly associated with their
corresponding form control — that means updating the input field will update the
form control’s value.
That is the most complex part here. We will talk through the rest in just a moment, but this is a good opportunity to just take a look at the code and see if you can understand generally what is happening.
Again, don’t worry if it isn’t all making sense. There is nothing you need to do right now, just see what you can figure out about the code before moving on.
Click here to reveal solution
Solution
There is a bit going on here, so let’s talk through what is going on. Let’s start with the class:
export class FormModalComponent { formGroup = input.required<FormGroup>(); title = input.required<string>(); save = output(); close = output();}Remember that this is a dumb component, so generally it is not going to inject any dependencies and it doesn’t know about anything that is happening in the broader application. It just gets its inputs, and sends outputs to communicate with whatever parent component is using it (the dumb child component doesn’t even know what component is using it).
In this case, we have two inputs. We want to be able to configure the title
to be displayed in the template, and we also allow the parent component to
supply a FormGroup as an input. This is what will allow the parent component
to configure what form fields to display. We will render out an input in the
template for each control defined in the FormGroup (using the technique we
talked about above).
We also have a save output that is used to indicate to the parent
component when the save button has been clicked, and another output that is used
to indicate when the close button has been clicked. Let’s take a closer look at
the template now:
<header> <h2>{{ title() }}</h2> <button (click)="close.emit()">close</button> </header> <section> <form [formGroup]="formGroup()" (ngSubmit)="save.emit(); close.emit()"> @for (control of formGroup().controls | keyvalue; track control.key){ <div> <label [for]="control.key">{{ control.key }}</label> <input [id]="control.key" type="text" [formControlName]="control.key" /> </div> } <button type="submit">Save</button> </form> </section>Angular hooks into the functionality of the standard HTML <form> element,
which we can activate by binding our FormGroup to it using the formGroup
directive (thanks to the ReactiveFormsModule import):
<form [formGroup]="formGroup()" (ngSubmit)="save.emit(); close.emit()">Remember how a @Directive works by supplying a selector that determines what
it attaches to? This is exactly how Angular makes these forms work — it’s just
a directive that has a selector of [formGroup] (i.e. it will attach to
anything that has the formGroup attribute).
We also bind to the ngSubmit event which is triggered when the form is
submitted. When this happens, we want to trigger both our save and emit
events. Something to notice here is that we do not do this:
<form [formGroup]="formGroup()" (ngSubmit)="handleSubmit()">We could create a separate method, and then in that method run the code we need. But, in general, we will generally try to avoid writing callback methods like this if we can. Triggering actions directly in the template, and keeping things neat, is going to require us to use a more declarative design.
Whilst there isn’t anything explicitly wrong about using a handleSubmit()
method here that triggers the same code — if you have a function like this it is
much easier and more tempting to sneak imperative code into it, e.g:
handleSubmit(){ this.save.emit(); this.close.emit();
// trigger some other thing here}The author of StateAdapt, Mike Pearson, describes callback functions like this as:
The curly braces of functions that don’t return anything are like open arms inviting imperative code.
I think this is a fantastic thing to keep in mind, and it is why you will find very few functions like this in our components.
The trickiest thing we are doing here, and this is something that is reasonably advanced for an introductory application, is this:
@for (control of formGroup().controls | keyvalue; track control.key){ <div> <label [for]="control.key">{{ control.key }}</label> <input [id]="control.key" type="text" [formControlName]="control.key" /> </div>}We have already discussed this a little, but it is worth going over some more.
This is how we render our dynamically created form. We loop through each of the
controls inside of the FormGroup and render an input for each of them.
When we create a FormGroup we will do something like this:
checklistForm = this.fb.group({ title: ['', Validators.required], });Notice that in the form above (we haven’t actually got to creating this yet) we
have a field with a key of title. When we render our form in the template we
need to bind the formControlName of the input we want to tie to that
particular field using that key of title:
<input [id]="control.key" type="text" [formControlName]="control.key" />This is why we use the keyvalue pipe on formGroup.controls:
@for (control of formGroup.controls | keyvalue; track control.key){Our form controls are structured something like this:
{ title: 'test', someOtherValue: 'hello'}So, what we really need are those title and someOtherValue keys to use as
our formControlName. The keyvalue pipe will convert that object into the
following array:
[ {key: 'title', value: 'test'}, {key: 'someOtherValue', value: 'hello'}]Which we can easily loop over in our @for and grab the key values. This
means we will get an <input> rendered out for each of the controls we
define in the FormGroup and each input will be bound to the appropriate
control in the FormGroup such that when the user updates the value, the value
in the associated control in the FormGroup will update as well.
Create the FormGroup
We have the ability to pass a FormGroup and title into our modal now, so let’s make use of it!
export default class HomeComponent { formBuilder = inject(FormBuilder);
checklistBeingEdited = signal<Partial<Checklist> | null>(null);
checklistForm = this.formBuilder.nonNullable.group({ title: [''], });}To create our form, we are injecting the FormBuilder which just makes it
easier to create a FormGroup than manually instantiating new FormGroup and
FormControl objects.
<app-modal [isOpen]="!!checklistBeingEdited()"> <ng-template> <app-form-modal [title]=" checklistBeingEdited()?.title ? checklistBeingEdited()!.title! : 'Add Checklist' " [formGroup]="checklistForm" (close)="checklistBeingEdited.set(null)" /> </ng-template> </app-modal>NOTE: Remember that you will need to add our standalone FormModalComponent
as an import in the imports array of the HomeComponent.
Now we can actually use our modal! It is ugly now because it still just pops in at the bottom of the template, but it works!
We can click Add Checklist and the modal will be displayed with a title of
Add Checklist. Later when we are using this for editing it will display the
title of the checklist being edited. Note our usage of the safe navigation
operator ? and non-null assertion ! for the title.
checklistBeingEdited()?.titleThis checks if checklistBeingEdited() is not null and that it has a title
property. If it does, we go to this line:
checklistBeingEdited()!.title!This uses the non-null assertion operator to tell TypeScript that
checklistBeingEdited() is not null and also that title is not null — we know
this because of the check we just did, but TypeScript does not.
The end result is basically: Add Checklist if the signal value does not have
a title, otherwise it will use the title of the checklist.
We are also handling our close:
(close)="checklistBeingEdited.set(null)"When close emits we set the checklistBeingEdited signal to null to close
the modal.
The only thing we are not handling here is the save — if we click “Save” then
the modal will still close, but nothing actually happens with the data we are
trying to save.
Creating a side effect
The last thing we are going to do in this lesson is handle resetting our form. Try this:
- Click Add Checklist
- Add some text
- Click close or save
- Click Add Checklist again
You will notice that the original text you entered is still there. We want whatever text was added to be cleared each time.
To achieve this, we are going to create our first side effect. We described a side effect before as arbitrary code that is executed in response to something else happening. This is inherently imperative, but sometimes this is necessary (or at least too hard to avoid).
To reset all of the values of a form we can call:
this.checklistForm.reset();This is imperative because we are directly telling the form what to do by
triggering this method. A declarative approach would be setting up
a situation such that the form would automatically reset whenever it needed to
based on the state of the application, rather than us manually calling
reset(). Technically, we could set something like this up, but there is an
effort/reward balance to this sort of thing, and at least for me making an
imperative exception here is the right choice.
export default class HomeComponent { formBuilder = inject(FormBuilder);
checklistBeingEdited = signal<Partial<Checklist> | null>(null);
checklistForm = this.formBuilder.nonNullable.group({ title: [''], });
constructor() { effect(() => { const checklist = this.checklistBeingEdited();
if (!checklist) { this.checklistForm.reset(); } }); }}We now have an effect that is using the value of the checklistBeingEdited
signal. Since it is using that value the effect will be triggered once
initially, and then again every time checklistBeingEdited is updated.
We want to clear the form when checklistBeingEdited is closed, which means it
will be null. If the value is null our reset() call will be triggered.
Try the form again now and you will see the value is cleared when you either save or close.
In the next lesson, we are going to handle doing something with our data when
that save event is triggered.