How to handle In-depth Angular Reactive Forms

Chase Baker

Chase Baker

- Software Engineer

I’ve been in software development for around a year and a half, mostly in the front-end of development. In that time I have become very accustomed to Angular, one of the most popular front-end frameworks, and with Angular comes Reactive Forms. When I first heard about reactive forms compared to template-driven, I was pretty confused but after completing a complicated and very involved reactive form for a timesheet, I felt much more confident with them.

This post comes with the assumption that you have somewhat of an understanding of Angular and Reactive Forms.

Alright, so where to start?

First, we need to import everything needed for the form in the component.ts file.

import { FormGroup, FormBuilder, FormArray, FormControl, Form } from '@angular/forms';

Let’s create our forms.

export class TimesheetSubmitComponent implements OnInit {
adminTimesheet = [];
projectTimesheet = [];
timesheetForm: FormGroup;
adminForm: FormGroup;
projectForm: FormGroup;

You’ll notice that I’ve created three different forms. One ‘master form’ (timesheetForm) and two ‘child forms’ (projectForm and adminForm). I do this to make things easier down the road but more on that later.

Next,

createTimesheetForm() {
this.timesheetForm = this.fb.group({
comments: new FormControl(null),
projectTime: new FormControl(null), // project timesheet component
adminTime: new FormControl(null), // admin timesheet component
})
}

projectTime and adminTime will contain large objects with arrays inside. So to simplify things, I will separate them into their own components and send their form data back to the master form that resides in the parent component.

Adding data for the forms.

initDataSource(data: any) {
this.adminTimesheet = data.adminTime;
this.projectTimesheet = data.projectTime;
this.patchValues(data);
}

I call a service I’ve created to retrieve the data needed for the form through an API. Once the data is retrieved, I then use the method patchValues() to add the data directly into the created timesheetForm.

 

patchValues(timesheetData: any) {
this.timesheetForm.get('comments').patchValue(timesheetData.comments);
this.timesheetForm.get('projectTime').patchValue(timesheetData.projectTime);
this.timesheetForm.get('adminTime').patchValue(timesheetData.adminTime);
}

At this point, I’ve created the master or ‘parent form’ that will exist in the parent component. The three components will be the timesheet-submit component, admin-timesheet component, and project-timesheet component.

Here are 2 methods we will need to add the data from the child forms when they are updated by the user.

 

// gets form data for adminTime from child component admin timesheet
getAdminForm(adminFormData: FormGroup) {
this.adminForm = adminFormData.get('adminTime').value;
this.timesheetForm.get('adminTime').patchValue(this.adminForm);
}
// get form data for projectTime from child component project timesheet
getProjectForm(projectFormData: FormGroup) {
this.projectForm = projectFormData.get('projectTime').value;
this.timesheetForm.get('projectTime').patchValue(this.projectForm);
}
<app-project-timesheet (project)="getProjectForm($event)"></app-project-timesheet>
<app-admin-timesheet (admin)="getAdminForm($event)"></app-admin-timesheet>

Each of these are listening for the $event coming from their respective child components, so once an $event occurs it will run the function to grab the new form data collected in the child component form and update or insert it into the master form.

Now in the child component project-timesheet, I will have a similar process of creating a form.

— I won’t be going into detail about the adminForm due to the process being the same. — 

import { Component, OnInit, Input, Output, EventEmitter } from '@angular/core';
import { FormGroup, FormBuilder, FormArray, FormControl, Validators } from '@angular/forms';
@Component({
selector: 'app-project-timesheet',
templateUrl: './project-timesheet.component.html',
styleUrls: ['./project-timesheet.component.scss']
})
export class ProjectTimesheetComponent implements OnInit {
sunday: Date; monday: Date; tuesday: Date;
wednesday: Date; thursday: Date; friday: Date;
saturday: Date;
projectTimesheet = [];
projectWeekTotals = [];
projectForm: FormGroup;
tasksForm: FormGroup;
@Output() project = new EventEmitter();
constructor(
private fb: FormBuilder
) { }
ngOnInit(): void {
this.createForm();
}
createForm() {
this.projectForm = this.fb.group({
projectTime: this.fb.array([])
})
this.setWeekTotals();
this.onChanges();
}
initDataSource(data: any) {
this.projectTimesheet = data.projectTime;
this.addProjects();
}

projectTime will be created as an empty FormArray. We will set projectTimesheet to the data from projectTime. Now I can use the method addProjects() to loop through and add each individual ‘project’ to the form array. This way the user can access each different project as its own miniature form when they need to make changes.

addProjects() {
this.projectTimesheet.forEach(d => {
let group;
group = this.fb.group({
clientId: d.clientId,
clientName: d.clientName,
endDate: d.endDate,
hoursHashMap: this.initHoursHashMap(d.hoursHashMap),
projectCode: d.projectCode,
projectDesc: d.projectDesc,
startDate: d.startDate,
timesheetTaskHoursMatch: d.timesheetTaskHoursMatch
})
this.projectTime.push(group);
})
this.onChanges();
}

If you think it’s already deeply nested, we are about to go even deeper into the form. The form control hoursHashMap contains its own array of objects that represent each day containing another set of FormControls or FormGroups like comments, hours, day, open, and another array of objects named tasks to represent each day’s tasks. The tasks form control will contain another form but we will get into that later.

If you haven’t already noticed I like to use methods to do the work for me when creating each form control that is an array. That’s where initHoursHashMap() comes in. Otherwise, this form would be very long and taking up more room in the ts file, making it harder to read. Parameter 1 is the data that was retrieved from the API and parameter 2 is the day of the week that I have pre-set in another component.

// creates controls for each day of week in hoursHashMap form group
initHoursHashMap(data: HoursHashMap) {
return this.fb.group({
Sunday: this.initDayTaskInfo(data.Sunday, this.sunday),
Monday: this.initDayTaskInfo(data.Monday, this.monday),
Tuesday: this.initDayTaskInfo(data.Tuesday, this.tuesday),
Wednesday: this.initDayTaskInfo(data.Wednesday, this.wednesday),
Thursday: this.initDayTaskInfo(data.Thursday, this.thursday),
Friday: this.initDayTaskInfo(data.Friday, this.friday),
Saturday: this.initDayTaskInfo(data.Saturday, this.saturday)
})
}

Now I’ll use the method initDayTaskInfo() and pass in the needed data for each day of the week and then return the data back to the form.

// creates controls inside each day of week
initDayTaskInfo(day: DayInfoProject, date: Date) {
let dayTask = this.fb.group({
comments: new FormControl(day.comments),
day: new FormControl(date),
hours: new FormControl(day.hours, (Validators.min(0), Validators.max(24))),
open: new FormControl(day.open),
tasks: this.initTaskInfo(day.tasks)
})
return dayTask;
}

Each day of the week has its own tasks so we need to create the form controls for those. I’ve created a small form called taskArray that will belong to the tasks form control above. Using a simple forLoop, I loop through the tasks and add each as their own group back to the array tasksArray. Now each task can be edited on their own. Think of this part of the timesheet being where the user will create a 2-hour work-related task ex. (Deployed Application, 2 hours, comments about the task). Remember this is creating the form and adding any existing data if there is any.

// sets form array data for tasks
initTaskInfo(tasks: TaskInfoProject[]): FormArray {
const taskArray = this.fb.array([]);
tasks.forEach(d => {
taskArray.push(this.fb.group({
comments: new FormControl(d.comments),
hours: new FormControl(d.hours),
statusReportFlag: new FormControl(d.statusReportFlag),
taskCategoryId: new FormControl(d.taskCategoryId),
taskDesc: new FormControl(d.taskDesc),
taskSeq: new FormControl(d.taskSeq)
}))
})
return taskArray;
}

Here is what the html will like for the project timesheet. I have the formGroup ‘projectForm’ at the top level, inside of the projectForm element we can get into the formArray ‘projectTime’. For that we will need to loop through the controls, creating a new formGroupName of ‘I’ for each project. Then within that formGroup will contain another formGroup ‘hoursHashMap’ that contains the seven formGroups for each day of the week. the hoursHashMap object contains the info for each days tasks (comments, hours, task name ect.).

<div class="timesheet-project-container">
<form [formGroup]="projectForm">
<span id="projectForm">
<div class="project-header">
<div class="header-cells header-cell-one">Project</div>
<span class="row-block">
<div class="header-cells">SUN: {{ sunday | date:'MM/dd'}}</div>
<div class="header-cells">MON: {{ monday | date:'MM/dd'}}</div>
<div class="header-cells">TUE: {{ tuesday | date:'MM/dd'}}</div>
<div class="header-cells">WED: {{ wednesday | date:'MM/dd'}}</div>
<div class="header-cells">THU: {{ thursday | date:'MM/dd'}}</div>
<div class="header-cells">FRI: {{ friday | date:'MM/dd'}}</div>
<div class="header-cells">SAT: {{ saturday | date:'MM/dd'}}</div>
<div class="header-cells header-cell-nine">TOTAL</div>
</span>
</div>
<span formArrayName="projectTime">
<div *ngFor="let item of projectTime.controls; let i = index" [formGroupName]="i" class="project-rows">
<div class="project-cells cell-one">
{{item.get('projectDesc').value}}
</div>
<span class="row-block" formGroupName="hoursHashMap">
<div class="project-cells"
[matTooltip]="item.get('hoursHashMap.Sunday.hours').invalid ? 'Hours must be between 0 and 24' : ''">
<div class="time-cells" formGroupName="Sunday">
<input matInput formControlName="hours" class="hour-inputs" type="number" min="0"
max="24" [ngClass]="{'hours-error': item.get('hoursHashMap.Sunday.hours').invalid}">
<button (click)="openProjectInputDialog('Sunday',
i, item.get('hoursHashMap.Sunday').value, item.get('clientName').value,
item.get('projectDesc').value, sunday)" class="btn">
<span matRipple class="material-icons">
menu
</span>
</button>
</div>
</div>
<div class="project-cells" formGroupName="Monday"
[matTooltip]="item.get('hoursHashMap.Monday.hours').invalid ? 'Hours must be between 0 and 24' : ''">
<div class="time-cells">
<input formControlName="hours" class="hour-inputs" type="number" min="0" max="24"
[ngClass]="{'hours-error': item.get('hoursHashMap.Monday.hours').invalid}">
<button (click)="openProjectInputDialog('Monday',
i, item.get('hoursHashMap.Monday').value, item.get('clientName').value,
item.get('projectDesc').value, monday)" class="btn">
<span matRipple class="material-icons">
menu
</span>
</button>
</div>
</div>
<div class="project-cells" formGroupName="Tuesday"
[matTooltip]="item.get('hoursHashMap.Tuesday.hours').invalid ? 'Hours must be between 0 and 24' : ''">
<div class="time-cells">
<input formControlName="hours" class="hour-inputs" type="number" min="0" max="24"
[ngClass]="{'hours-error': item.get('hoursHashMap.Tuesday.hours').invalid}">
<button (click)="openProjectInputDialog('Tuesday',
i, item.get('hoursHashMap.Tuesday').value, item.get('clientName').value,
item.get('projectDesc').value, tuesday)" class="btn">
<span matRipple class="material-icons">
menu
</span>
</button>
</div>
</div>
<div class="project-cells" formGroupName="Wednesday"
[matTooltip]="item.get('hoursHashMap.Wednesday.hours').invalid ? 'Hours must be between 0 and 24' : ''">
<div class="time-cells">
<input formControlName="hours" class="hour-inputs" type="number" min="0" max="24"
[ngClass]="{'hours-error': item.get('hoursHashMap.Wednesday.hours').invalid}">
<button (click)="openProjectInputDialog('Wednesday',
i, item.get('hoursHashMap.Wednesday').value, item.get('clientName').value,
item.get('projectDesc').value, wednesday)" class="btn">
<span matRipple class="material-icons">
menu
</span>
</button>
</div>
</div>
<div class="project-cells" formGroupName="Thursday"
[matTooltip]="item.get('hoursHashMap.Thursday.hours').invalid ? 'Hours must be between 0 and 24' : ''">
<div class="time-cells">
<input formControlName="hours" class="hour-inputs" type="number" min="0" max="24"
[ngClass]="{'hours-error': item.get('hoursHashMap.Thursday.hours').invalid}">
<button (click)="openProjectInputDialog('Thursday',
i, item.get('hoursHashMap.Thursday').value, item.get('clientName').value,
item.get('projectDesc').value, thursday)" class="btn">
<span matRipple class="material-icons">
menu
</span>
</button>
</div>
</div>
<div class="project-cells" formGroupName="Friday"
[matTooltip]="item.get('hoursHashMap.Friday.hours').invalid ? 'Hours must be between 0 and 24' : ''">
<div class="time-cells">
<input formControlName="hours" class="hour-inputs" type="number" min="0" max="24"
[ngClass]="{'hours-error': item.get('hoursHashMap.Friday.hours').invalid}">
<button (click)="openProjectInputDialog('Friday',
i, item.get('hoursHashMap.Friday').value, item.get('clientName').value,
item.get('projectDesc').value, friday)" class="btn">
<span matRipple class="material-icons">
menu
</span>
</button>
</div>
</div>
<div class="project-cells" formGroupName="Saturday"
[matTooltip]="item.get('hoursHashMap.Saturday.hours').invalid ? 'Hours must be between 0 and 24' : ''">
<div class="time-cells">
<input formControlName="hours" class="hour-inputs" type="number" min="0" max="24"
[ngClass]="{'hours-error': item.get('hoursHashMap.Saturday.hours').invalid}">
<button (click)="openProjectInputDialog('Saturday',
i, item.get('hoursHashMap.Saturday').value, item.get('clientName').value,
item.get('projectDesc').value, saturday)" class="btn">
<span matRipple class="material-icons">
menu
</span>
</button>
</div>
</div>
<ng-container *ngFor="let weekTotal of projectWeekTotals let iTwo = index">
<span *ngIf="i == iTwo" class="project-cells cell-nine">
{{ weekTotal }}
</span>
</ng-container>
</span>
</div>
</span>
</span>
</form>

I’ve created a modal that opens when the user wants to add a task to a certain day. So with that, I need to send the data to the modal that correlates with that day. Once the user adds/updates new data to the modal and closes it, the results will be sent to the taskForm.

 

// sends data to project input dialog modal
// when closed the projectForm updates the day selected tasks
openProjectInputDialog(day: string, index: number, taskData: any, clientName: string, project: string, date: Date) {
const dialogRef = this.dialog.open(ProjectInputModalComponent, {
data: {
dayOfWeek: day,
taskData: taskData,
client: clientName,
currentProject: project,
date: date
}
});
dialogRef.afterClosed().subscribe(results => {
if (results) {
let dayOfWeek = results.event;
this.tasksForm = results.data;
this.projectForm.get("projectTime").value[index].hoursHashMap[dayOfWeek] = this.tasksForm.value;
this.project.emit(this.projectForm);
}
})
}
}

Here is the input modal html for each days task data using *ngFor to loop through the form controls.

 

<div mat-dialog-content class="dialog-container">
<header>
<div>Tasks for {{dayOfWeek}}</div>
<div>{{ client }}: {{ projectDesc }}</div>
<div class="timesheet-hours">Timesheet Hours: <span
[ngClass]="{'red-text': hoursExceedTimesheet}">{{ enteredHours }}</span> / {{ timesheetHours }}</div>
</header>
<div class="input-container">
<form [formGroup]="taskForm">
<span class="header-row">
<div class="header-cells">Task/Description</div>
<div class="header-cells">Hours</div>
<div class="header-cells">Comments</div>
<div class="header-cells">Include In Report</div>
</span>
<span formArrayName="tasks" *ngFor="let item of taskCtrl.controls; let i = index;">
<span [formGroupName]="i" class="data-rows border-animation" [ngClass]="{'background-row': isEven(i)}">
<div class="data-cells">
<mat-form-field>
<textarea (keyup.enter)="addTask()" formControlName="taskDesc" matInput type="text"></textarea>
</mat-form-field>
</div>
<div class="data-cells">
<mat-form-field class="hours">
<input (keyup.enter)="addTask()" formControlName="hours"
matInput type="number" class="hour-input" min="0" max="24">
<mat-error *ngIf="item.get('hours').invalid">Must be within 24 hours</mat-error>
</mat-form-field>
</div>
<div class="data-cells">
<mat-form-field>
<textarea (keyup.enter)="addTask()" formControlName="comments" matInput></textarea>
</mat-form-field>
</div>
<div class="data-cells">
<section class="checkbox">
<mat-checkbox formControlName="statusReportFlag"></mat-checkbox>
</section>
</div>
</span>
</span>
</form>
</div>
<p>Press ENTER to add a new row</p>
<p>(leave Task/Description empty to delete the task)</p>
</div>
<footer>
<button class="close-OK-btns" mat-raised-button color="warn" mat-dialog-close>
Close
</button>
<button (click)="saveTasks()" class="close-OK-btns" mat-raised-button color="primary" mat-dialog-close>
OK
</button>
</footer>

Setting up the logic for the modal will start the same as any other component. We will need to add our imports and create any needed variables, with this being a modal I’ve injected the data through the constructor using Angular’s built in MAT_DIALOG DATA service.

 

import { Component, OnInit, Inject } from '@angular/core';
import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
import { FormGroup, FormBuilder, FormArray, FormControl } from '@angular/forms';
@Component({
selector: 'app-project-modal',
templateUrl: './project-modal.component.html',
styleUrls: ['./project-modal.component.scss']
})
export class ProjectInputModalComponent implements OnInit {
taskForm: FormGroup;
dayOfWeek: string;
timesheetHours: number;
projectDesc: string;
client: string;
enteredHours: number = 0;
hoursExceedTimesheet: boolean = false;
tasks = [];
storageKey: string;
taskSeq: number = 0;
taskData: any;
taskDate: Date;
constructor(
public dialogRef: MatDialogRef<ProjectInputModalComponent>,
@Inject(MAT_DIALOG_DATA) public data: any,
private fb: FormBuilder,
) { }
ngOnInit(): void {
this.setDataFromParent();
this.createForm();
this.patchValues();
this.checkForData();
}
}

Notice in ngOnIt() I have four functions, as the names suggest, I will set the data from the parent, create the form, patch the values and check for any existing data in the session storage or backend database.

The setDataFromParent() function is just taking the data injected when the modal was opened and assigning them to new variables, it also creates a key I will use for sessionStorage. The patchValues() function is what will actually be taking the data and setting it at the form controls of taskForm.

 

setDataFromParent() {
this.dayOfWeek = this.data.dayOfWeek
this.timesheetHours = this.data.taskData.hours
this.projectDesc = this.data.currentProject
this.client = this.data.client
this.tasks = this.data.taskData.tasks
this.taskSeq = this.data.taskData.tasks.length
this.storageKey = this.projectDesc.concat(this.dayOfWeek);
this.taskData = this.data.taskData;
this.taskDate = this.data.date;
}
patchValues() {
this.taskForm.get('comments').patchValue(this.taskData.comments);
this.taskForm.get('day').patchValue(this.taskData.day);
this.taskForm.get('hours').patchValue(this.taskData.hours);
this.taskForm.get('oldHours').patchValue(this.taskData.oldHours);
this.taskForm.get('open').patchValue(this.taskData.open);
this.taskForm.get('tasks').patchValue(this.taskData.tasks);
}

Then what I did was create a function for checking for data wether it is coming from the backend database or session storage from the user. I created a storageKey from the projectDesc and the dayOfWeek making each days task unique.

 

// checks if day selected has exisiting task data (backend or sessionStorage)
// then adds those tasks to the FormArray
checkForData() {
let storageCheck = sessionStorage.getItem(this.storageKey);
let sessionData = JSON.parse(storageCheck);
if (storageCheck === null) {
this.taskSeq = this.tasks.length;
if (this.tasks.length > 0) {
for (let i = 0; this.tasks.length > i; i++) {
this.taskSeq++;
this.addTask();
this.taskSeq = this.tasks.length;
}
this.taskForm.patchValue({
tasks: this.tasks
})
}
} else if (sessionData.tasks.length > 0) {
let last = sessionData.tasks.length;
let check = sessionData.tasks[last - 1].taskDesc
for (let i = 0; sessionData.tasks.length > i; i++) {
this.taskSeq++;
this.addTask();
this.taskSeq = sessionData.tasks.length;
}
if (check === '') {
this.taskCtrl.removeAt(last);
}
this.taskForm.patchValue({
tasks: sessionData.tasks
})
}
}

And of course we create the form for the task.

// creates a form after checking the tasks length in order to start setting
// the taskSeq number properly
createForm() {
if (this.tasks.length === 0) {
this.taskSeq = 1;
}
this.taskForm = this.fb.group({
comments: new FormControl(null),
day: new FormControl(null),
hours: new FormControl(null),
oldHours: new FormControl(null),
open: new FormControl(null),
tasks: this.fb.array([
this.fb.group({
taskDesc: '',
taskSeq: this.taskSeq,
hours: 0,
comments: '',
statusReportFlag: true,
taskCategoryId: 0
})
])
})
this.onChanges();
}

In regard to the onChanges method, it will update whenever changes are made to the form. What it is doing is updating the total hours the user is adding/removing from each project and it sends the form data to the parent component which will contain the master form.

 
// updates the timesheet hours everytime the inputs are changed
onChanges() {
this.projectForm.get('projectTime').valueChanges.subscribe(val => {
let x = val.length;
for (let i = 0; x > i;) {
let total = (Number(val[i].hoursHashMap.Sunday.hours)
+ Number(val[i].hoursHashMap.Monday.hours)
+ Number(val[i].hoursHashMap.Tuesday.hours)
+ Number(val[i].hoursHashMap.Wednesday.hours)
+ Number(val[i].hoursHashMap.Thursday.hours)
+ Number(val[i].hoursHashMap.Friday.hours)
+ Number(val[i].hoursHashMap.Saturday.hours));
this.projectWeekTotals.splice(i, 1, total);
i++;
this.getTotalHours();
this.project.emit(this.projectForm);
}
})
}

The setWeekTotals method is needed to set the original total amount of hours for each project when the page loads and has that initial set of data.

// sets initial weekly hour totals until user changes value
setWeekTotals() {
let array = this.projectForm.get('projectTime').value
for (let i = 0; i < array.length; i++) {
let total = (Number(array[i].hoursHashMap.Sunday.hours)
+ Number(array[i].hoursHashMap.Monday.hours)
+ Number(array[i].hoursHashMap.Tuesday.hours)
+ Number(array[i].hoursHashMap.Wednesday.hours)
+ Number(array[i].hoursHashMap.Thursday.hours)
+ Number(array[i].hoursHashMap.Friday.hours)
+ Number(array[i].hoursHashMap.Saturday.hours));
this.projectWeekTotals.splice(i, 1, total);
this.getTotalHours();
this.project.emit(this.projectForm);
}
}

Finally our Timesheet,

Project One and Project Two will populate with any existing data if there is any, otherwise they will start as an empty form. Each day has it’s own input for the amount of hours worked that day, then once the button is clicked the modal will pop-up for the user to input the task data for that day.

In summary I’ve created a in depth Timesheet form with a lot of moving parts. Reactive forms make it much easier to handle the logic on the component side of things. If you have a form that is filled with multiple arrays and objects like this one I highly recommend using Reactive Forms. I hope that my demonstration has helped and thank you for reading.