At the company I’m working for, we’re developing a large scale application with multiple forms, that the user needs to fill in in order to register for our program. When all questions have been answered, then the user reaches a section that sums up all their answers, highlights invalid answers and gives the user the chance to revisit any of the preceding form steps and revise their answers. This logic will be repeated across a range of top-level sections, each having multiple steps/pages and a summary page.
To accomplish this, we have created a component for each separate form step (they are categories like “Personal Details” or “Qualifications” etc.) along with their respective routes and a component for the Summary Page.
In order to keep it as DRY as possible, we started creating a “master” service which holds the information for all the different form steps (values, validity etc.).
import { Injectable } from '@angular/core';
import { Validators } from '@angular/forms';
import { ValidationService } from '../components/validation/index';
@Injectable()
export class FormControlsService {
static getFormControls() {
return [
{
name: 'personalDetailsForm$',
groups: {
name$: [
{
name: 'firstname$',
validations: [
Validators.required,
Validators.minLength(2)
]
},
{
name: 'lastname$',
validations: [
Validators.required,
Validators.minLength(2)
]
}
],
gender$: [
{
name: 'gender$',
validations: [
Validators.required
]
}
],
address$: [
{
name: 'streetaddress$',
validations: [
Validators.required
]
},
{
name: 'city$',
validations: [
Validators.required
]
},
{
name: 'state$',
validations: [
Validators.required
]
},
{
name: 'zip$',
validations: [
Validators.required
]
},
{
name: 'country$',
validations: [
Validators.required
]
}
],
phone$: [
{
name: 'phone$',
validations: [
Validators.required
]
},
{
name: 'countrycode$',
validations: [
Validators.required
]
}
],
}
},
{
name: 'parentForm$',
groups: {
all: [
{
name: 'parentName$',
validations: [
Validators.required
]
},
{
name: 'parentEmail$',
validations: [
ValidationService.emailValidator
]
},
{
name: 'parentOccupation$'
},
{
name: 'parentTelephone$'
}
]
}
},
{
name: 'responsibilitiesForm$',
groups: {
all: [
{
name: 'hasDrivingLicense$',
validations: [
Validators.required,
]
},
{
name: 'drivingMonth$',
validations: [
ValidationService.monthValidator
]
},
{
name: 'drivingYear$',
validations: [
ValidationService.yearValidator
]
},
{
name: 'driveTimesPerWeek$',
validations: [
Validators.required
]
},
]
}
}
];
}
}
That service is being used by all the components in order to set up the HTML form bindings for each, by accessing the corresponding object key and creating nested form groups, as well as by the Summary page, whose presentation layer is only 1way bound (Model -> View).
export class FormManagerService {
mainForm: FormGroup;
constructor(private fb: FormBuilder) {
}
setupFormControls() {
let allForms = {};
this.forms = FormControlsService.getFormControls();
for (let form of this.forms) {
let resultingForm = {};
Object.keys(form['groups']).forEach(group => {
let formGroup = {};
for (let field of form['groups'][group]) {
formGroup[field.name] = ['', this.getFieldValidators(field)];
}
resultingForm[group] = this.fb.group(formGroup);
});
allForms[form.name] = this.fb.group(resultingForm);
}
this.mainForm = this.fb.group(allForms);
}
getFieldValidators(field): Validators[] {
let result = [];
for (let validation of field.validations) {
result.push(validation);
}
return (result.length > 0) ? [Validators.compose(result)] : [];
}
}
After, we started using the following syntax in the components in order to reach the form controls specified in the master form service:
personalDetailsForm$: AbstractControl;
streetaddress$: AbstractControl;
constructor(private fm: FormManagerService) {
this.personalDetailsForm$ = this.fm.mainForm.controls['personalDetailsForm$'];
this.streetaddress$ = this.personalDetailsForm$['controls']['address$']['controls']['streetaddress$'];
}
which seems like a code smell in our inexperienced eyes. We have strong concerns how an application like this will scale, given the amount of sections we'll have in the end.
We’ve been discussing different solutions but we can’t come up with one that leverages Angular’s form engine, allows us to keep our validation hierarchy intact and is also simple.
Is there a better way to achieve what we’re trying to do?
Is it really necessary to keep the form controls in the service? Why not just leave the service as the keeper of data, and have the form controls in the components? You could use the
CanDeactivate
guard to prevent the user from navigating away from a component with invalid data.https://angular.io/docs/ts/latest/api/router/index/CanDeactivate-interface.html
Your approach and Ovangle's one seem to be pretty good but even though this SO question is solved, I want to share my solution because it's a really different approach that I think you might like or might be useful to someone else.
We've faced that exact same issue and after months of struggling with huge, nested and sometimes polymorphic forms, we've come up with a solution that pleases us, which is simple to use and which gives us "super powers" (like type safety within both TS and HTML), access to nested errors and others.
We've decided to extract that into a separated library and open source it.
Source code is available here: https://github.com/cloudnc/ngx-sub-form
And the npm package can be installed like that
npm i ngx-sub-form
Behind the scenes, our library uses
ControlValueAccessor
and that allows us to use it on template forms AND reactive forms (you'll get the best out of it by using reactive forms though).So what is it all about?
Before I start explaining, if you prefer to follow along with a proper editor I've made a Stackblitz example: https://stackblitz.com/edit/so-question-angular-2-large-scale-application-forms-handling
Well an example is worth a 1000 words I guess so let's redo one part of your form (the hardest one with nested data):
personalDetailsForm$
First thing to do is make sure everything is going to be type safe. Let's create the interfaces for that:
Then, we can create a component that extends
NgxSubFormComponent
.Let's call it
personal-details-form.component
.Few things to notice here:
NgxSubFormComponent<PersonalDetails>
is going to give us type safetygetFormControls
methods which expects a dictionary of the top level keys matching an abstract control (herename
,gender
,address
,phone
)providers: subformComponentProviders(PersonalDetailsFormComponent)
is a small utility function to create the providers necessary to use aControlValueAccessor
(cf Angular doc), you just need to pass as argument the current componentNow, for every entry of
name
,gender
,address
,phone
that is an object, we create a sub form for it (so in this case everything butgender
).Here's an example with phone:
Now, let's write the template for it:
Notice that:
<div [formGroup]="formGroup">
, theformGroup
here is provided byNgxSubFormComponent
you don't have to create it yourself[formControlName]="formControlNames.phone"
we use property binding to have a dynamicformControlName
and then useformControlNames
. This type safety mechanism is offered byNgxSubFormComponent
too and if your interface changes at some point (we all know about refactors...), not only your TS will error for missing properties in the form but also the HTML (when you compile with AOT)!Next step: Let's build the
PersonalDetailsFormComponent
template but first just add that line into the TS:public Gender: typeof Gender = Gender;
so we can safely access the enum from the viewNotice how we delegate the responsibility to a sub component?
<app-name-form [formControlName]="formControlNames.name"></app-name-form>
that's the key point here!Final step: built the top form component
Good news, we can also use
NgxSubFormComponent
to enjoy type safety!And the template:
Takeaway from all of that: - Type safe forms - Reusable! Needs to reuse the address one for the
parents
? Sure, no worries - Nice utilities to build nested forms, access form control names, form values, form errors (+nested!) - Have you noticed any complex logic at all? No observables, no service to inject... Just defining interfaces, extending a class, pass an object with the form controls and create the view. That's itBy the way, here's a live demo of everything I've been talking about:
https://stackblitz.com/edit/so-question-angular-2-large-scale-application-forms-handling
Also, it was not necessary in that case but for forms a little bit more complex, for example when you need to handle a polymorphic object like
type Animal = Cat | Dog
we've got another class for that which isNgxSubFormRemapComponent
but you can read the README if you need more info.Hope it helps you scale your forms!
Edit:
If you want to go further, I've just published a blog post to explain a lot of things about forms and ngx-sub-form here https://dev.to/maxime1992/building-scalable-robust-and-type-safe-forms-with-angular-3nf9
I commented elsewhere about
@ngrx/store
, and while I still recommend it, I believe I was misunderstanding your problem slightly.Anyway, your
FormsControlService
is basically a global const. Seriously, replace theexport class FormControlService ...
withand what difference does it make? Instead of getting a service, you just import the object. And since we're now thinking of it as a typed const global, we can define the interfaces we use...
and since we've done that, we can move the definitions of the individual form groups out of the single monolithic module and define the form group where we define the model. Much cleaner.
But now we have all these individual form group definitions scattered throughout our modules and no way to collect them all :( We need some way to know all the form groups in our application.
But we don't know how many modules we'll have in future, and we might want to lazy load them, so their model groups might not be registered at application start.
Inversion of control to the rescue! Let's make a service, with a single injected dependency -- a multi-provider which can be injected with all our scattered form groups when we distribute them throughout our modules.
then create a manifest module somewhere (which is injected into the "core" app module), building your FormService
We've now got a solution which is:
I did a similar application. The problem is that you are creating all your inputs at the same time, which is not likely scalable.
In my case, I did a FormManagerService who manages an array of FormGroup. Each step has a FormGroup that is initialized once in the execution on the ngOnInit of the step component by sending his FormGroup config to the FormManagerService. Something like that:
You'll need an id to know which FormGroup corresponds to the step. But after that, you'll be able to split your Forms config in each step (so small config files that are easier for maintenance than a huge file). It will minimize the initial load time since the FormGroups are only create when needed.
Finally before submitting, you just need to map your FormGroup array and validate if they're all valid. Just make sure all the steps has been visited (otherwise some FormGroup won't be created).
This may not be the best solution but it was a good fit in my project since I'm forcing the user to follow my steps. Give me your feedback. :)