A Standalone Dynamic Form – an Angular 17 implementation
240307
A simple yet distinguished Angular 17 (17.2) project of mine. The code is provided in this repo.
Abstract
This is a simple yet distinguished Angular 17 (17.2) project of mine, for a dynamic form implementation using dynamic components.
The form fields/controls metadata are provided as an array of metadata objects. This array is provided dynamically via the instantiation of a respective domain service. The domain service is instantiated via a factory service provider class. The form fields array can be kept updated dynamically with initial values obtained as a row set from the backend.
The dynamic form is a reactive form using independent standalone components that are dynamically loaded at runtime via a helper directive. The dynamic form is also built as a typed form, and the type of each control is obtained from the type of the value of a property of a metadata object.
Furthermore, the form, as well as the candidate dynamic components are based on the Material library, but this can be easily changed, by changing even, just the respective templates.
Angular 17
The project is based on Angular Version 17.2 with Angular Material support. This means that it uses:
- standalone components (no modules),
- signals (for non-backend async operations such as those based on HttpClient), as well as for some cases that replace the @Input decorator, and
- new @-based flow control syntax in component templates
The overview diagram of the project
The project structure and key-components description
`-- src
`-- app
|-- about
|-- app.component.html
|-- app.component.scss
|-- app.component.ts
|-- app.config.ts
|-- app.routes.ts
|-- dataObjects
| |-- IFormField.ts
| |-- dbDataFormFields.ts
| |-- icatecory.ts
| `-- iitem.ts
|-- dynamic-form
| |-- apply-form-control.directive.ts
| |-- button
| |-- checkbox
| |-- datetime
| |-- form
| |-- input
| |-- radio
| `-- select
|-- home
|-- request-data
`-- services
|-- base-form-fields.service.ts
|-- category-form-fields.service.ts
|-- data.service.ts
|-- form-fields-facrory-service-provider.ts
`-- item-form-fields.service.ts
Apart from the AppComponent that is automatically created, the following components on the top src/app folder are used:
- HomeComponent
- RequestDataComponent
- AboutComponent
The HomeComponent offers the Header of the page, and it also provides a simple 2nd toolbar with the necessary navigation link buttons for our demo purposes. The respective route path (defined in the app.routes.ts file) is activated providing a static data token to the RequestComponent, that is responsible for triggering the process of the instantiation of an appropriate domain service (see below).
The RequestDataComponent provides just a form input field that allows the user to enter the id (also by using spinners). The changing id values are captured asynchronously by subscribing to the valueChanges into the ngOnInit life-cycle hook of the component. This initiates the process of instantiation of a domain service for fetching the respective data row set from the backend and filling the dynamic form control fields with initial data.
The AboutComponent is just a page for providing info about the project.
There are also 3 project subfolders:
- dataObjects
- dynamic-form, and
- services
1. The dataObjects subfolder includes all the domain and data-related objects. There are 2 interfaces for the 2 demo data objects. For instance, they can concern 2 tables: the iitem.ts and icategory.ts.
The IFormField.ts file includes the types and interfaces necessary to construct our objects to avoid errors in the app/project variables, and objects, provide increased consistency, and facilitate app maintenance.
- The IFormField interface provides the necessary properties that should be used for form fields/controls (they can also be optional),
- The IFormOptions interface defines the properties for the optional IFormField property ‘options’, that can be used in specific fields (dynamic components) of type ‘select’ or a ‘radio’ button group.
- The IFormFieldValidator interface provides the validation properties (for standard Validators) for any field that uses validators.
- The dynControlType defines the allowed types of controls (dynamic components) and of course, more can be added later on.
- The standardInputType defines some of the most used types of the native input form field.
- And the dtTokenType defines the allowed (so far) token values for the token being used to instantiate the appropriate domain service. Of course, more can be added later on.
The dbDataFormFields.ts file is mainly used to host the object arrays with the necessary metadata (properties) for each form field/control created dynamically by the respective candidate dynamic component. So far, it contains just a couple of such arrays, the ItemFormFields and the CategoryFormFields arrays (IFormField[]), each one corresponding to a respective row set data from the data layer (database) objects. The properties defined in those arrays can be changed and adapted to particular case needs. Also, more IFormField[] arrays can be added to meet the needs of any additional data set. Apart from those 2 arrays, the dbDataFormFields.ts file contains:
The DbEntities array of objects provides some basic properties for the 2 data layer objects, that are used throughout the whole project.
Finally, the DynComponents object is used to match a form control type to the respective candidate dynamic component.
2. The dynamic-form subfolder contains the following candidate dynamic components:
- ButtonComponent,
- CheckboxComponent
- DatetimeComponent,
- FormComponent,
- InputComponent,
- SelectComponent,
- RadioComponent,
These components correspond to the most widely used form controls. Each one requires just 2 parameters to be passed in: the 1st is for the formGroup that the component should belong to, and the 2nd one is the specific field metadata object obtained from the form fields array. In this demo, all the components are based on the Angular Material library.
The helper directive ApplyFormControlDirectiveis is in charge of dynamically loading and instantiating the respective candidate dynamic component.
The key component, inside the dynamic-form subfolder, is the FormComponent. This is our dynamic form-host component that creates (renders) by using the helper directive, any number of the candidate dynamic components from those provided in the subfolder.
3. The services subfolder contains the following services and classes:
- BaseFormFieldsService,
- CategoryFormFieldsService,
- ItemFormFieldsService,
- DataService,
- FormFieldsFactoryServiceProvider
The DataService is responsible for dealing only with the backend, so it fetches data from the “data layer”. (As we’ve said for demo purposes, we use local JSON files instead of a real database).
The rest of the files in the services subfolder, form one “family”. This “family” consists of:
– an abstract class: the BaseFormFieldsService that serves as a base class for the next two “real” services,
– two “real” services: the ItemFormFieldsService and the CategoryFormFieldsService, that are derived from the previous abstract class
– and a factory class: the FormFieldsFactoryServiceProvider, that is responsible for the instantiation of the proper “real” service.
The BaseFormFieldsService class is an abstract class. Using an abstract class is a common OOP pattern in TypeScript and Angular for sharing common functionality while still allowing for class-specific implementations. It helps to reduce code duplication and increase maintainability. So, the abstract class BaseFormFieldsService defines the contract for its subclasses to follow. In our case, the key contracts are (so far): the abstract property ‘$formFields’ (a signal for the form fields array), and the abstract “setId()” method that allows “consumers” to set the requested id for fetching the respective row set from the backend. Both of them should be followed/implemented by the derived services (any class that extends the BaseFormFieldsService class). It also provides some protected members and methods that implement common functionality for any of the subclasses/services. This common functionality concerns mainly the methods used to update formFields array with initial values, and especially some fields of a specific type, e.g.: of type of ‘select’, ‘radio’, ‘datetime’ etc.
The ItemFormFieldsService and the CategoryFormFieldsService are the implementation subclasses that extend the abstract BaseFormFieldsService class. They can be considered as domain-specific services. Both of them use the appropriate data service (the DataService in our case) for accessing data from the backend. A “consumer” can use any of them, to ask to fetch a row set of data from the appropriate data object (e.g. a table), by providing a user-requested id. A “consumer” actually, calls the “setId()” method of the instantiated and injected domain service, and passes it the requested id number. Each one of those derived domain services is actually responsible for preparing and formulating a “ready” formFields array, that can be used from our dynamic form -the FormComponent. Moreover, a domain service is also responsible for keeping the formFields array updated, by responding to the id passed in via the setId() method, fetching the respective data row set, and setting the initial values for each field of the formFields array. Thus, in every change of the id, all the fields of the formFields array are kept updated.
The FormFieldsFactoryServiceProvider is responsible for instantiating a “real” service that is a subclass of the BaseFormFieldsService. As we have seen, there are 2 such services so far: the ItemFormFieldsService and the CategoryFormFieldsService. The instantiation of one of those services is based on the token value passed to the FormFieldsFactoryServiceProvider constructor. This token value is provided via a “consumer” that plays the role of an “initiator”.
In our case, the RequestDataComponent plays the role of an “initiator”. Recall, that the real token value is obtained into the RequestDataComponent as a static data object via the router. When a token value is obtained, the RequestDataComponent instantiates an object of the FormFieldsFactoryServiceProvider by passing into its constructor that token value. This should be done inside the RequestDataComponent constructor method since the return object concerns a service (one of the domain services instantiated), that is actually injected into the RequestDataComponent during its creation. The RequestDataComponent does not know which of the domain services is instantiated, and it shouldn’t. The RequestDataComponent job is to instantiate (once) the appropriate domain service, and then to pass the id value to the setId() method of the instantiated domain service, every time the user enters/changes the id value. Nothing more.
The magic job is done via the FormFieldsFactoryServiceProvider and its public static method “getFormFieldsService()” that provides the “ffService” object of the instantiated domain service, together with all of its functionalities. However, the RequestDataComponent is interested only in obtaining this instantiated object, and then, using its setId() method, to trigger the process of fetching the data row set from the backend.
On the other hand, the same public static method is also used by our dynamic form -the FormComponent.
The dynamic form: the FormComponent
The dynamic reactive form is implemented on the basis of a number of independent dynamic components, (as they have been defined in the DynComponents const in the dbDataFormFields.ts file). These are actually, the candidate dynamic form controls:
- button: ButtonComponent,
- input: InputComponent,
- select: SelectComponent,
- radio: RadioComponent,
- datetime: DatetimeComponent,
- checkbox: CheckboxComponent,
Especially, for the DatetimeComponent, the @ng-matero/extensions DateTimePicker has been used, supported by the Luxon library (Luxon adapter). For more details, you can take a look at my post:
and the related repo here.
The dynamic components are dynamically instantiated as reactive form controls, and rendered using the helper directive (ApplyFormControlDirective) in the ‘host’ dynamic form component (FormComponent) template. Using a helper directive for dynamic component loading is a well-known practice and you can also find more in my post here.
The initial data are updated by a new row set fetched, each time the user enters an id in the field of the simple form in the RequestDataComponent. Actually, the initial values are fetched via a matching domain service (based on the BaseFormFieldsService) that is instantiated via a class service provider (the FormFieldsFacroryServiceProvider), for the appropriate metadata array. This happens whenever the user clicks on the respective button in the toolbar of the home component, which activates also the respective router link. The activated link provides a token as static data, that triggers the selection/instantiation of the service-in-charge.
As we’ve said, the metadata of the dynamic components that are created as form controls inside the FormComponent, are imported as an array of form fields (the formFields variable). The FormComponent knows nothing about the fields of the formFields array, e.g.: from where those fields are coming from, or how they have been formed. The only concern of the FormComponent is to render the dynamic controls from the formFields array.
Also, we have mentioned, that those fields are initially defined via an array of metadata objects (JSON objects). Actually, we have prepared 2 such arrays: the ItemFormFields and the CategoryFormFields array. The FormComponent upon its instantiation, deals with just 1 formFields array, that is obtained from a service actually with one of our known domain services that has been already instantiated.
The FormComponent also knows nothing about which domain service instance is active. What the FormComponent does, is access the public static method “getFormFieldsService()” of the FormFieldsFacroryServiceProvider and obtain the currently instantiated domain service (the “ffService” object). Any service that subclasses the abstract BaseFormFieldsService class is suitable.
The instantiated domain service is obtained into the FormComponent constructor, so the instance of the service is actually injected into the FormComponent. Then it senses every change of the formFields array by monitoring the changes of the $formFields signal of the domain service inside an effect() function. Every change of this results in an update of the initial values of the form controls.
The RequestDataComponent and FormComponent relation, and the “singleton exclusivity” of a shared domain service providing the dynamic fields metadata
As you can see below, tracing the templates:
<app-root>
<app-homet>
<outer-outlett> -> <request-datat>
<dyn-formt>
reveals the relation between the app components.
Even if they are not tightly coupled, the dyn-form (the FormComponent) is a child component of the request-data (RequestDataComponent). So, upon the RequestDataComponent instantiation/creation, the FormComponent is instantiated/created as well.
They do not know they share a common service: a domain service (actually a subclass of the BaseFormFieldsService).
When the RequestDataComponent is instantiating it also instantiates (within its constructor) a BaseFormFieldsService domain service through the FormFieldsFacroryServiceProvider.
Moreover, it keeps updating the instantiated service (whenever the user changes the requested id) by accessing the setId() directly via the static method: FormFieldsFacroryServiceProvider.getFormFieldsService() -which returns the instance of the instantiated service.
So, when the FormComponent (within its constructor), accesses the static FormFieldsFacroryServiceProvider.getFormFieldsService(), it accesses the same service. The instantiated service lives as long as the RequestDataComponent lives and the same is true for the FormComponent.
However, there is a “pain point” here that requires some attention. The instantiated service in the FormFieldsFacroryServiceProvider is not destroyed automatically, because it is not managed by Angular. So, we have to do it a bit “manually”. A solution to this is to use the static method FormFieldsFacroryServiceProvider.destroyService() that sets the instantiated service instance to null. To use it, we have to call it from the ngOnDestroy() life-hook method in both the RequestDataComponent and the FormComponent.
Thus, when a user presses a different button (for a different data object) the RequestDataComponent is destroyed and recreated again, and it also zeros out and recreates a new domain service (defined by the router’s token).
The above implementation assures that an instantiated domain service will have “singleton exclusivity” between the parent and child components (the RequestDataComponent and the FormComponent) that “consume” it. This means that our dynamic form remains reusable for different form field arrays, for any other part of our application, and for any other “parent” component that could be used to instantiate any of the available domain services. Moreover, in the future, more domain services can be implemented and added to the FormFieldsFacroryServiceProvider for instantiation (together with the necessary update to other involved objects, such as the entities and objects inside the IFormField.ts file, the dbOjects route paths with the respective tokens), etc., without affecting the code for our dynamic form in the FormComponent.
Final notes
In final words, I would like just to mention some of the benefits of the project implementation:
- The implementation of the dynamic form remains extensible, flexible, and reusable.
- New dynamic components can be added.
- Any dynamic component can be customized further.
- Different form field arrays with various metadata properties can be also added, accompanied by the respective domain services, which can be easily used with the FormFieldsFacroryServiceProvider and instantiated upon request. They can be also obtained from external sources, e.g.: a JSON file, or asynchronously from a real DB.
All the above enhancements can not necessarily affect the core code of the dynamic form.
The repo
You can find the project’s GitHub repo here. If you wish you can follow the commits one by one to see the project’s development. Each commit message provides enough information for the changes made.
Key References
Note: The project has been initially inspired by Todd Motto’s post here.
Below are also some other references:
- Angular — Building dynamic forms
- Angular — Dynamic component loader
- Angular: any component 👉⇢ dynamic component
- Create a dynamic form with configurable fields and validations using Angular 6
- Building a Dynamic Form in Angular: A Step-by-Step Guide with Code
Enjoy!