Sep 4, 2017

As a part of Building an Angular 4 CMS with Flat Files I knew it would be important to generate forms to enable administrators to edit website content. You could make individual forms for every page or piece of content, but I find if you put the right work in upfront you can create much more dynamic and reusable code.

In this post we will create a set of Angular 4 Components with a Service that can automatically build a ReactiveForm with model and template linked. In other words, given some very basic JSON content, a form is automatically created for simple CRUD operations:

Requirements

These two components and service combined can only handle a specific type of JSON content. It should be a single JSON object with properties that have either string values or an array of string values. You can see an example in the Demo section. This sort of content is perfect for representing content in a semi-static website. You can create Angular components that load in this data via a HttpClient service and render specific views. This automatic form generator saves the time in converting that data to a form view.

Overview

An overview of what we will cover is below:

  1. Loading JSON via HttpClient
  2. Textarea Class
  3. ReactiveForms
    • FormControl
    • FormGroup
  4. TextareaGenerator Component
  5. FormGenerator Component
  6. Future Work

Demo

You can take a look at a live demo here. The <form-generator> component tag takes a JSON file as input called form-content.json. This file has the following JSON content:

{
	"title": "Adventures of Tacos",
	"summary": "Once upon a time, there was a lime",
	"arr":["test","ddf"]
}

This content is automatically parsed and converted into this very simple Angular 4 ReactiveForm:

This form is a ReactiveForm because it actually is created using the FormControl and FormGroup objects to link the data between the template and textarea model generated from JSON. The array is split via newlines to make editing really easy – imagine a new array entry creates a new bullet when the JSON content is outputted on your website, that means that a new line will automatically format what you write to a bullet or whatever else you need. You can create a form with as little code as this:

<form-generator [file]="'form-content.json'"></form-generator>

I can add an extra element to the array or change the title and this form-generator component saves the JSON content of the form to a local payLoad variable and outputs it to the screen:

For this demo, I just manually adjusted the size of the textareas. It could be possible to implement something that would guess the area required for the text based on the text size and content length and then adjust the textareas accordingly. Note that this does not post the data to any location, my next post will be updating this code to allow a post_url variable Input that will tell the form-generator where to send the data via HttpClient and a new PostService. This demo simply saves the payLoad to the form-generator local variable “payLoad” and outputs it to the screen in the proper JSON after generating the forms automatically for you.

Full Code

You can find these components and services bundled into a FormGeneratorModule on Github. I will go through the details of their creation below. Something to keep in mind is that while we load the JSON via a file in this Demo, in reality that JSON will more likely be directly passed from a different component. Imagine you could have a piece of rendered content with a pencil icon and when you click it, a Modal pops up with a form for that same exact content. In that case, the JSON would have already been grabbed from a file to render the view content, so that same variable of JSON content could be passed to the form generator component with some small modifications (next post).

Loading JSON via HttpClient

I recently made an Instagram Gallery Component that grabbed the JSON using HttpClient. I used a Promise in that component to resolve the asynchronous request. Since then, I’ve done quite a bit more work with Observables and think it is a bit cleaner to just use those rather than converting to Promise, since HttpClient returns an Observable<request>. Go ahead and put the following content in json.service.ts:

import { Injectable } from '@angular/core'
import { HttpClient } from '@angular/common/http';
import {Observable} from 'rxjs/Observable';
import 'rxjs/add/operator/map';
import {Textarea} from './textarea';
import { FormGroup, FormControl } from '@angular/forms';
@Injectable()
export class JsonService {
	constructor(private http: HttpClient) {};
	json: any;
	getInputs(file: string): Observable<Textarea[]> {
		return this.http.get(file).map(data => {
			let inputs: Textarea[] = [];
			this.json = data;
			for(let ind in this.json) {
				if(typeof(this.json[ind]) != "string") {
					this.json[ind] = this.json[ind].join("\n");
				}
				let _input: Textarea = {name:ind,value:this.json[ind]};
				inputs.push(_input);
			}
			return inputs;
		});
				
	}
}

As you can see this JsonService has a method getInputs(file: string) that returns type Observable<Textarea[]>, that is an Observable with an array of Textarea objects. One of the amazing things about TypeScript is you can declare these objects easily, making it easier to detect bugs early and also enabling more modular code. You’ll notice there are also imports for FormControl and FormGroup, which are important for linking the model to the form template and will be explained more in a later section. You can also see I made a decision to take any arrays and join them together with the newline character \n, this makes it really easy to edit arrays by just using a newline.

Textarea Class

The definition for the Textarea object is extremely simple. Remember, not every class needs to be complex for it to enhance your development process. Put the following in textarea.ts:

export class Textarea {
	name:string;
	value:string;
}

That’s all we need – a name to identify the Textarea based on the JSON Object property key and the value of that JSON entry. In the instance of {“title”:”test”}, name=title and value=test. Similarly, arrays are joined within json.service.ts with the newline characer, so value just becomes the collection of elements joined.

ReactiveForms

It took me a bit to understand how to properly do this, because in Angular there are both Model Driven Forms and Template Driven Forms. Previously, for the Angular 4 Authentication Login, I used a template driven form and linked the model to the template using ngModel within the template. This time, because the form is being generated with Angular components from a JSON object, we need to be able to link each Textarea generated with the TextareaGenerator component to each other and also identify each form element relative to the model.

A great reference on how to use ReactiveForms in a dynamic way where forms are dynamically generated, take a look at this post called Dynamic Forms on the Angular website. That is the main post I referenced in the creation of this automatic form generator.

Go ahead and add the following method to json.service.ts, which I previously left out:

toFormGroup(inputs: Textarea[]): FormGroup {
	let group: any = {};
	inputs.forEach(input => {
		group[input.name] = new FormControl(input.value);
	});
	return new FormGroup(group);
}

This method converts an array of Textareas to a FormGroup by looping through each Textarea object and creating a new FormControl for each Textarea, indexed at input.name. Similarly, the template generated will display the input.name so that this FormGroup is linked to the template.

TextareaGenerator Component

Now it is time to create the base component, the TextareaGenerator which will have a label and textarea with value in it. Save the following into textarea.generator.ts

import { Component, Input} from '@angular/core';
import { FormGroup, FormControl } from '@angular/forms';
import { Textarea } from './textarea';

@Component({
  selector: 'textarea-generator',
  templateUrl: 'textarea.generator.html',
  styleUrls: ['form.generator.css'],
  providers: []
})
export class TextareaGenerator {
  @Input('input') input: Textarea;
  @Input() form: FormGroup;
	
}

You can see there are two Inputs to this component, a Textarea and a FormGroup. We will pass the FormGroup from the parent FormGenerator component to each TextareaGenerator component via this Input. See below for the template, paste this into textarea.generator.html:

<div [formGroup]="form">
	<div class="row">
  <label [attr.for]="input.name">{{input.name}}</label>
  </div>
  <div class="row">
  
  <textarea [formControlName]="input.name">{{input.value}}

    </textarea> 
	</div>
</div>

This template tells the component that the template formGroup is equal to the local form variable within the component. Similarly, it links the formControlName of the textarea to the input.name found within the TextareaGenerator component which is passed in as an Input.

FormGenerator Component

Most of the hard work is done, now it is just about collecting an array of Textarea objects, generating a FormGroup from that array of Textarea objects (each Textarea having it’s own FormControl with a unique name) and displaying the form with an onSubmit event handler. Let’s see how to do that below in form.generator.ts:

import { Component, OnInit, Input } from '@angular/core';
import {Observable} from 'rxjs/Observable';
import {JsonService} from './json.service';
import { FormGroup, FormControl } from '@angular/forms';
import { Textarea } from './textarea';
import { TextareaGenerator } from './textarea.generator';

@Component({
  selector: 'form-generator',
  templateUrl: 'form.generator.html',
  styleUrls: ['form.generator.css'],
  providers: [JsonService]
})
export class FormGenerator implements OnInit {
  @Input() file: string;
  inputs: Textarea[] = [];
  form: FormGroup = new FormGroup({});
  payLoad: any;
  constructor(private jsonService: JsonService) {};
	onSubmit(): void {
		for(let i in this.form.value) {
			let temp = this.form.value[i].split("\n");
			
			if(temp.length > 1) this.form.value[i] = temp;
		}
		
		this.payLoad = JSON.stringify(this.form.value, null, 2);
	}

	ngOnInit(): void {
		this.jsonService.getInputs(this.file)
		.subscribe(inputs => {
			this.inputs = inputs;
			this.form = this.jsonService.toFormGroup(inputs);
		});
	}
}

As you can see, the FormGenerator takes a file as the Input and passes it to JsonService on initialization (ngOnInit) to grab the JSON content on that page. It then takes that array of Textareas and turns it into a FormGroup using the JsonService method described previously. A payLoad variable is created just like in the Angular guide to show what the output of the form is, which should be an identical format but varying content to what was inputted. Let’s take a look at the template, form.generator.html:

  <div class="col-xs-10 col-xs-offset-1 col-sm-6 col-sm-offset-3 text-center">
  <form [formGroup]="form" (ngSubmit)="onSubmit()">
 
    <div *ngFor="let input of inputs" class="row">
      <textarea-generator [input]="input" [form]="form"></textarea-generator>
    </div>
 
    <div class="row">
      <button type="submit">Save</button>
    </div>
  </form>
  <div *ngIf="payLoad">
    <strong>Saved the following values</strong>
	<pre class="text-left">{{payLoad}}</pre>
  
  </div>
  </div>

As you can see, it creates the form and uses *ngFor to loop and create TextareaGenerator components, passing in the Textarea and FormGroup as Inputs. Since all of the Textareas are linked together by this same FormGroup, you can get their values within the TypeScript component with this.form.value. That’s it, now all you need to do is call the FormGenerator:

<form-generator [file]="'form-content.json'"></form-generator>

Future Work

Future work will be taking this same concept and using it for a flat file content management system. Imagine you store your semi-static content within JSON files like above, you could render it to the user with an edit button that generates one of these forms automatically for CRUD operations. In that case, you wouldn’t want to directly pass a JSON file to these generators, but rather a single JSON object for the content you want to edit based on an event.