Simple E-mail Footer Generator in Angular 9 using Flotiq
야 오왕
Posted on July 30, 2020
Concept
I wanted to create a simple e-mail footer builder application with the usage of Flotiq Headless CMS.
Application is split into 3 parts:
- Modules - a list of available modules user can drag & drop to Workspace
- Workspace - a catalogue of selected modules user can configure or order in a preferred way.
- Preview - a preview of user work. It displays prepared HTML, that can be used as footer.
Modules
Modules (elements that are used to build footer) are stored in Flotiq as an MJML template along with its properties.
Modules list:
- Spacer
- Button
- Text
- Hero
- Image
- Divider
- Social
- Text + Logo - 2 columns
- Text + Image - 2 columns
- Raw
- Text + Text - 2 columns
Workspace
Every selected module contains settings that are set as properties in Flotiq. User can reorder modules and configure them. For example:
- Change content of the module
- Change font size, colours, module align
- Reverse column display (for 2 column modules)
- Change image and logo
- Insert target URL (for buttons, and social modules)
Preview
User can review its work in the preview section. Every change in a module configuration and drop of the module into the Workspace regenerates view. User can test mobile, and desktop resolutions, as well as download, prepared HTML that can be inserted as a footer in used mail client.
Application screen
Tech stack
- Angular 9
- Angular Material - icons, drag & drop
- Tailwind CSS - visual styling
- Handlebars - template compiling before sending to MJML API
- JSZip - generated footer download
Why Flotiq?
I wanted to simplify as much as possible in this project. By storing modules and its configurations in Flotiq, I don't have to implement Dynamic Component Loader logic and store all the template components in my project.
Also, I don't have to rebuild my application every time I add or update module, because its data is stored externally.
Flotiq is very flexible in this case and user friendly, so implementing this concept in their product was really easy and time-saving. The user interface is really comfy to work with, so getting on board was really fast.
Module body in Flotiq
In Flotiq CMS I have created Modules
Content Type Definition, which contains:
- template
type: string
- MJML template of component. - icons
type:string
- one or many, split by comma for more than one in row (ex.text,plus,text
) - image
type: relation(media)
- can be displayed instead of icons - properties
type:relation(properties)
- component settings ex. font-size, align, background image etc.
Properties
Properties describe details of the module. Single property consists:
- Key
type: string
- variable used in template (example:{{ borderColor }}
) - Value
tyle: string
- default property value - InputType
type: select
- type of input. Available: text, text editor, color picker, align select, direction select.
Retrieving module data from Flotiq
I have created a service, which is responsible for getting module data from Flotiq:
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { environment } from '../../environments/environment';
@Injectable({
providedIn: 'root'
})
export class FlotiqService {
constructor(private http: HttpClient) { }
getModules() {
return this.http.get(
environment.flotiqApiUrl +
'/api/v1/content/modules?limit=100&page=1&hydrate=1&auth_token=' +
environment.flotiqApiKey
);
}
}
So now, in the modules.component.ts
file I can retrieve them:
[...imports...]
export class ModulesComponent implements OnInit {
modules: Module[];
pending = true;
constructor(private flotiqService: FlotiqService) { }
ngOnInit() {
this.flotiqService.getModules()
.subscribe((data: Response) => {
this.modules = data.data;
this.pending = false;
});
}
}
and display:
<app-module class="rounded overflow-hidden shadow-lg bg-white cursor-move"
cdkDrag
*ngFor="let item of modules" [module]="item">
</app-module>
Managing Drag&Drop functionality between components
Everything is split into components, so for drag & drop functionality to work correctly, the connector service is required:
[...imports...]
@Injectable({
providedIn: 'root'
})
export class BuilderService {
htmlChanged = new Subject<SafeHtml>();
drop(event: CdkDragDrop<string[]>) {
if (event.previousContainer === event.container) {
moveItemInArray(event.container.data, event.previousIndex, event.currentIndex);
} else {
copyArrayItem(cloneDeep(event.previousContainer.data),
event.container.data,
event.previousIndex,
event.currentIndex);
}
}
}
This changes the way we connect D&D lists. We omit []
brackets in cdkDropListConnectedTo
property. We pass a string value now, which is the id
of the list in another component
cdkDropListConnectedTo
must have the same value as cdkDropList
element id
in another component. Look at the code fragments below as a reference:
Part of modules.component.html
file:
<div class="grid grid-cols-1 gap-6"
cdkDropList
#availableList="cdkDropList"
[cdkDropListData]="modules"
cdkDropListConnectedTo="selectedList"
[cdkDropListSortingDisabled]="true">
<div *ngIf="pending"
class="block hover:bg-gray-50 focus:outline-none focus:bg-gray-50 transition duration-150 ease-in-out">
Loading...
</div>
<app-module class="rounded overflow-hidden shadow-lg bg-white cursor-move"
cdkDrag
*ngFor="let item of modules" [module]="item">
</app-module>
</div>
Part of workspace.component.html
file:
<div
class="bg-white relative workspace"
cdkDropList
id="selectedList"
[ngClass]="{'workspace-empty': !selectedModules.length}"
[cdkDropListData]="selectedModules"
(cdkDropListDropped)="drop($event)">
.....
Module settings in the Workspace section
The user can configure specific module settings like content, colour, align, line height etc. Every module settings save, will trigger a refresh in the preview section.
Fragment of settings.component.html
file:
[....]
<div class="w-8/12 mt-1 relative rounded-md shadow-sm">
<input
*ngIf="property.inputType === 'text'"
class="form-input block w-full sm:text-sm sm:leading-5"
type="text"
placeholder=""
[(ngModel)]="property.value"
name="{{ property.key}}">
<ckeditor
*ngIf="property.inputType === 'text-editor'"
[editor]="editor"
[data]="property.value"
[(ngModel)]="property.value"
[config]="editorConfig">
</ckeditor>
[....]
Compiling templates with Handlebars
Before sending prepared MJML template to its API, it has to be compiled by Handlebars. Every variable enclosed in {{ }}
brackets is replaced by the value set in the module settings.
This function takes two parameters:
- template (MJML Template)
- context (module properties values)
In the first step, the MJML template is prepared by using Handlebars compile
function. It returns a function that requires module properties values to return a fully compiled template.
Module properties values are passed to a temporary array and then passed to compiledTemplate
function that is returned.
/**
* Handlebars template compiler
*/
compile(template: string, context: Property[]): string {
const compiledTemplate = Handlebars.compile(template, {noEscape: true});
const parameters = [];
context.forEach((element: Property) => {
parameters[element.key] = element.value;
});
return compiledTemplate(parameters);
}
Retrieving HTML from MJML API
When the module is added, or its settings are changed, the request is sent to MJML API to generate fresh HTML. This is what function refresh
does. Firstly, it generates compiled MJML template - generateMjml
. Generated MJML is passed to mjmlService
to retrieve HTML file readable for mail clients.
refresh(selectedModules: Module[]) {
const mjml = this.generateMjml(selectedModules);
return this.mjmlService.render(mjml);
}
generateMjml
function in preview.service.ts
file:
generateMjml(selectedModules: Module[]) {
let tmpMjml = '<mjml>' +
'<mj-body>';
selectedModules.forEach(module => {
tmpMjml = tmpMjml + this.compile(module.template, module.properties);
});
tmpMjml = tmpMjml +
'</mj-body>' +
'</mjml>';
return tmpMjml;
}
Body of mjml.service.ts
file:
[...imports...]
@Injectable({
providedIn: 'root'
})
export class MjmlService {
constructor(private http: HttpClient) { }
render(mjml) {
const httpOptions = {
headers: new HttpHeaders({
'Content-Type': 'application/json',
'Authorization': 'Basic ' + btoa(environment.mjmlApplicationKey + ':' + environment.mjmlPublicKey)
})
};
return this.http.post(environment.mjmlApi + '/v1/render', {mjml}, httpOptions);
}
}
Preview Section & SafePipe
This section displays the current work of the user. As mentioned earlier, every change in the Workspace regenerates footer template. Generated HTML is bound to the srcdoc
iframe property.
Part of preview.component.html
:
<iframe #preview class="preview"
[ngStyle]="{'max-width': previewMaxWidth ? previewMaxWidth+'px' : '100%'}"
[srcdoc]="html| safe: 'html'"></iframe>
Angular does not allow rendering HTML code after compilation by default. It can be omitted by implementing SafePipe
. It tells Angular whatever we want to display is safe and trusted.
@Pipe({
name: 'safe'
})
export class SafePipe implements PipeTransform {
constructor(protected sanitizer: DomSanitizer) {
}
transform(value: any, type: string): SafeHtml | SafeStyle | SafeScript | SafeUrl | SafeResourceUrl {
switch (type) {
case 'html': return this.sanitizer.bypassSecurityTrustHtml(value);
case 'style': return this.sanitizer.bypassSecurityTrustStyle(value);
case 'script': return this.sanitizer.bypassSecurityTrustScript(value);
case 'url': return this.sanitizer.bypassSecurityTrustUrl(value);
case 'resourceUrl': return this.sanitizer.bypassSecurityTrustResourceUrl(value);
default: throw new Error(`Invalid safe type specified: ${type}`);
}
}
}
Final Preview
Simple footer built with this application:
Summary
Connecting Angular application with Flotiq Headless CMS was really nice. Their documentation was clear and made no problems with implementing my idea of simple footer builder. They have a self-explanatory onboarding process, so it just took a little time to create object schema there, and I began transforming my visions into code. Cheers!
Resources
Posted on July 30, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.