Imagine this situation:
Your team lead assigns you the task to create a list view (e.g.
a list of orders) and a form view to edit the list entries. You find out, that the product area which is also part of the software provides the same logic. You start with copying the code of the list view (which is fortunately just an separated component which only needs a data source passed in). Then you start copying step by step the form template. A textfield here, a checkbox there. Oh, I need a select field and the other template doesn't contain it - no prob, HTML is nice AF and I can write the markup on my own. After a while you're done and the form works as expected.
Recognize yourself in that workflow? Me too.
What's wrong with it?
Let's check what are the negative aspects of this:
- If you copy code you're violating the D.R.Y. principle
- The style of fields may vary since you need to write some markup on your own.
- If you want to add a tooltip to all text fields you've to edit all files containing a text field. (You will forget one, believe me 😅)
- Your edit form is violating the Single-responsibility principle since it must validate all fields against general business logic (Not empty, min, max etc), contains listeners of field selection etc.
How to make it better?
First of all you can create simple components for each field. For example a TextFieldComponent
or a CheckboxComponent
. This would solve problem 1, 3 and partially 2. Why partially 2? Because it's still possible to modify the outer markup in the html where you use the component.
What else? Forms without writing HTML? No way!
You're right. Without writing any HTML it's not possible but you can reduce the HTML to a minimum.
I created a Angular library which is called @mintware-de/form-builder
.
It provides all functionality you need to create reusable, maintainable and extensible forms in Angular.
Real world example
Create a new minimal Angular project using
ng new form-example --minimal --skip-tests --inline-style --inline-template
Install the package
npm i -d @mintware-de/form-builder@^2.0.0
Create a form fields module
In a previous section I mentioned that's an good idea to separate fields into it's own components. First, create a new module called form-fields
inside your src/app
directory and cd
into the module directory.
ng g m form-fields
cd src/app/form-fields
As described in the getting started import and export the FormBuilderModule
and the ReactiveFormsModule
in your FormFieldsModule
.
// ...
imports: [
CommonModule,
FormBuilderModule, // New
ReactiveFormsModule // New
],
exports: [
FormBuilderModule, // New
ReactiveFormsModule // New
]
// ...
Import the FormFieldsModule
in your AppModule
.
Creating form field components
Let's start with creating a text field component which has a inline template, inline styles and no tests.
ng g c text-field --inline-template --inline-style --skip-tests
Why using inline template?
The template of the form components are really small in the most cases.
Additionally to this, mostly you don't need to write TypeScript code in the component itself.
That's the reason why I prefer inline templates.
Create the options interface and the form type
A form component for the form builder consists of 3 parts:
- The Angular component
- An options interface which is used for configuring the component
- The form type which connects the component and the options. The form type also defines the validation rules.
Now create the options and the type next to the text-field.component.
|- src/app/form-fields
| |- text-field
| | |- text-field.component.ts
| | |- text-field.options.ts <-- New
| | |- text-field.type.ts <-- New
Create an empty interface for the text field options. We will add needed properties later.
// text-field.options.ts
export interface TextFieldOptions {
}
The form type must extend the AbstractType<TOptions>
class. The naming convention for the class name is the PascalCased file name without the suffix. In this case simply TextField
.
As TOptions
you need to pass the created TextFieldOptions
type and implement the abstract member component
. Set the value to TextFieldComponent
.
The referenced component will be used to render the form field.
The complete file should look like this:
// text-field.type.ts
import {AbstractType, Constructor} from '@mintware-de/form-builder';
import {TextFieldOptions} from './text-field.options';
import {TextFieldComponent} from './text-field.component';
export class TextField extends AbstractType<TextFieldOptions> {
public readonly component: Constructor = TextFieldComponent;
}
Write the component code
First of all, add the TextFieldComponent
to the FormFieldsModule
inside the entryComponents
section.
This is necessary since the form builder render the components
with a ComponentFactory
.
// form-fields.module.ts
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { TextFieldComponent } from './text-field/text-field.component';
@NgModule({
declarations: [
TextFieldComponent,
],
entryComponents: [
TextFieldComponent, // New
],
imports: [
CommonModule
]
})
export class FormFieldsModule {
}
Open the TextFieldComponent
and replace the implements OnInit
with
extends AbstractFormFieldComponent<AbstractType<TextFieldOptions>>
// text-field.component.ts
import { Component } from '@angular/core'; // modified
import {
AbstractFormFieldComponent,
AbstractType
} from '@mintware-de/form-builder'; // new
import { TextFieldOptions } from './text-field.options'; // new
@Component({
selector: 'app-text-field',
template: `
<p>
text-field works!
</p>
`,
// modified
})
export class TextFieldComponent
extends AbstractFormFieldComponent<AbstractType<TextFieldOptions>> { // modified
// modified
}
Why not using extends TextField
directly?
Since the TextField
imports the TextFieldComponent
a direct use of TextField
inside the TextFieldComponent
would cause a circular reference.
Add the input field
Now we need to add some HTML code which contains the input element. We use the [formControl]
attribute on the input to link the input element with the FormControl
in the Angular FormGroup
.
The AbstractFormGroupComponent
a property mwElement
which contains the form control.
Update the template of the TextFieldComponent
:
<input type="text" [formControl]="mwElement">
Congrats, you just created your first form field 👏. Let's create a form to use and reuse the the form field.
Create a form and use the form field
Open the AppComponent
and replace the content with this:
import {Component} from '@angular/core';
import {FormModel} from '@mintware-de/form-builder';
import {TextField} from './form-fields/text-field/text-field.type';
@Component({
selector: 'app-root',
// Display the form by using the MwFormBuilder Component
// Pass the formModel and formData and set a submit action
// The action is only called if the form is valid
template: `
<mw-form-builder #myForm
[mwFormModel]="formModel"
[mwFormData]="formData"
(mwFormSubmit)="submit($event)">
</mw-form-builder>
<button type="button" (click)="myForm.submit()">Submit</button>
`
})
export class AppComponent {
// Create a form model.
// The naming and nesting is equal to the formData
public formModel: FormModel = {
firstName: new TextField({}),
lastName: new TextField({})
};
// Set the initial form data
public formData: { firstName: string, lastName: string } = {
firstName: 'John',
lastName: 'Doe',
};
// Create a submit handler
public submit(data: { firstName: string, lastName: string }): void {
console.log("Form was submitted: %o", data);
}
}
Run ng serve
to start the app.
Press the button and something like
Form was submitted: {firstName: "John", lastName: "Doe"}
is written to the console.
Adding options to the text field
Cool, text fields without labels. That's what I call usability 😬
Ok, let's add a few options to our text field:
- Label: The string which is used as the label
- Required: A boolean which defaults to true and marks the field as required or not.
Edit the TextFieldOptions
interface and add the fields:
// text-field.options.ts
export interface TextFieldOptions {
label: string; // new
required?: boolean; // new
}
Now update the HTML code of the TextFieldComponent and use the properties. You can access the options object in the mwFieldType
property, which comes from AbstractFormFieldComponent
.
<div>
<label [for]="mwPath">{{ mwFieldType.options.label }}</label>
<input type="text"
[formControl]="mwElement"
[id]="mwPath"
[required]="mwFieldType.options.required">
<div *ngIf="mwElement.errors && mwElement.errors.required">
{{mwFieldType.options.label}} is required.
</div>
</div>
Since the label
property is not nullable, you've to set it in the form model in the AppComponent
.
public formModel: FormModel = {
firstName: new TextField({
label: 'First name', // new
}),
lastName: new TextField({
label: 'Last name', // new
})
};
Reload the page and voilà, the form fields has labels:
Almost done. We just need to add the required validation and set the default state to true.
Option defaults & validation
To set default values for optional options, you need to add a constructor to the TextField
type. In the constructor you can use Object.assign({}, ...);
to set the defaults in the options object. Validators can be added by overriding the validators
getter.
import {AbstractType, Constructor} from '@mintware-de/form-builder';
import {TextFieldOptions} from './text-field.options';
import {TextFieldComponent} from './text-field.component';
import {ValidatorFn, Validators} from '@angular/forms';
export class TextField extends AbstractType<TextFieldOptions> {
public readonly component: Constructor = TextFieldComponent;
constructor(opts: TextFieldOptions) {
// Set the field defaults
super(Object.assign({
required: true,
}, opts));
}
public get validators(): ValidatorFn[] {
const validators: ValidatorFn[] = [];
// Only add the required validator if the field is required
if (this.options.required) {
validators.push(Validators.required);
}
return validators;
}
}
Reload the page and clear the inputs. You should see the error message and if you try to submit the form, the submit
method is not called since both fields are required by default.
FAQ
Is the form builder compatible with Angular Material?
Yep
Can I create collection fields and nested forms?
Collections or Array fields and nested forms are both supported.
Guides:
- Collections
- Form groups for nested forms.
...Complex layouts?
Yes, there is a special FormType called AbstractLayoutType
. Guide: Layout types
Example: https://gist.github.com/devtronic/807e8bfc712330ef13a5c9b8bf5a71cf
I hope everything was clear and you enjoyed reading my post.
Questions, Suggestions etc. ➡️ Comments
Top comments (0)