Part 5: The Screen & Form Elements

Overview

Completed code: live example / download example

Step 1: ScreenComponent

The ScreenComponent serves as the primary component for our form interactions. While ProcessMaker permits several forms within a single Screen, each task should associate with one Screen that can encompass multiple forms. Any task should have a single Screen, but can have multiple forms. They will be contained within an array in the response payload when making the API request.

When we get the form part, we will use the FormComponent as well as others to ultimately render the form.

The ScreenComponent is passed the required data that will be passed down through to the individual elements on the form.

Template

app-screen.component.html
<!-- Use app-form-element to render form elements -->
<app-form
	*ngFor="let screen of screens?.config"
	[form]="screen"
	[request]="request"
	[css]="screens.custom_css"
	[computed]="screens.computed">
</app-form>

Typescript

app-screen.component.ts
import { Component, Input, OnInit, Output, EventEmitter } from '@angular/core';
import { Router, ActivatedRoute } from '@angular/router';
import { TasksService, ProcessRequestsService } from 'api';
import { DbService } from 'src/app/services/db.service';

@Component({
	selector: 'app-screen',
	templateUrl: './app-screen.component.html',
})
export class ScreenComponent implements OnInit {
	// Define the request object, type can be further specified
	request: any;

	// Define properties with appropriate types
	exists: any; // Define the type as per your requirements
	processRequestId: number | null = null; // Define the type as number or null
	taskId: number | null = null; // Define the type as number or null
	@Output() screens: any; // Define the type as per your requirements
	data: any; // Define the type as per your requirements
	response: any; // Define the type as per your requirements
	screenConfig: any;

	@Output() screenEvent: EventEmitter<any> = new EventEmitter(); // Define the type as per your requirements

	constructor(
		private route: ActivatedRoute,
		private router: Router,
		public requestApi: ProcessRequestsService,
		public tasksApi: TasksService,
		private db: DbService
	) {}

	ngOnInit(): void {
		console.log(this);
		// Parse the values as numbers, and handle the possibility of null values
		// Convert processRequestId and taskId from string to number, handle null values
		this.processRequestId =
			Number(this.route.snapshot.paramMap.get('processRequestId')) || null;
		this.taskId = Number(this.route.snapshot.paramMap.get('taskId')) || null;

		// Check if taskId is not null before proceeding
		if (this.taskId !== null) {
			// Set the credentials for the tasks API
			this.tasksApi.configuration.credentials['pm_api_bearer'] =
				this.db.load('access_token') || '';

			// Fetch the task by ID and include specific related data
			this.tasksApi
				.getTasksById(
					this.taskId,
					'processRequest,user,data,screen,definition,screenRef'
				)
				.subscribe(
					(response) => {
						// Assign the response to the request object
						this.screens = response.screen;
						this.request = response.data;
						//console.log(this);
					},
					(error) => {
						// Log any errors
					}
				);
		}
	}

	handleScreenEvent(event: any) {
		// Handle any screen-related event here
		this.screenEvent.emit(event);
	}
}

Step 2: FormComponent

Given that a Screen can associate with multiple forms, our design ensures minimal code adjustments to accommodate this.

The FormComponent passes through to the form element the request data and definitions for the different elements.

Template

app-form.component.html
<div class="container-fluid">
	<div class="table-container mt-4">
		<!-- create an html stylesheet block -->
		<style>
			{{ css }}
		</style>
		<form (ngSubmit)="submitForm()">
			<!-- Use app-form-element to render form elements -->
			<h4>{{ form.name }}</h4>
			<div class="container">
				<ng-container *ngFor="let element of form.items">
					<app-form-element
						[element]="element"
						[request]="request"
						[calcPropsValues]="calcPropsValues"></app-form-element>
				</ng-container>
			</div>
		</form>
	</div>
</div>

Typescript

app-form.component.html
import { Component, Input, OnInit } from '@angular/core';
import { Router, ActivatedRoute } from '@angular/router';
import { TasksService, ProcessRequestsService } from 'api';
import { DbService } from 'src/app/services/db.service';
import { environment } from 'src/environments/environment';

@Component({
	selector: 'app-form',
	templateUrl: './app-form.component.html',
})
export class FormComponent implements OnInit {
	// Define the request object, type can be further specified
	@Input() request: any;
	// Define properties with appropriate types
	exists: any; // Define the type as per your requirements
	processRequestId: number | null = null; // Define the type as number or null
	taskId: number | null = null; // Define the type as number or null
	@Input() screens: any; // Define the type as per your requirements
	@Input() data: any; // Define the type as per your requirements
	@Input() response: any; // Define the type as per your requirements
	@Input() element: any; // Define the type as per your requirements
	isMultiColumn: boolean = false;
	@Input() cols: any;
	@Input() form: any;
	@Input() css: any;
	@Input() computed: any;
	calcPropsValues: any = {};
	// Constructor with necessary dependencies
	constructor(
		private route: ActivatedRoute,
		private router: Router,
		public requestApi: ProcessRequestsService,
		public tasksApi: TasksService,
		private db: DbService
	) {}

	ngOnInit() {
		//console.log(this);
		this.calcPropsValues = this.executeJavascripts(this.computed);
		this.processRequestId =
			Number(this.route.snapshot.paramMap.get('processRequestId')) || null;
		this.taskId = Number(this.route.snapshot.paramMap.get('taskId')) || null;
		if (environment.customCss === true) {
			const styleEl = document.createElement('style');
			styleEl.innerHTML = this.css;
			document.head.appendChild(styleEl);
		}
		// add any custom css
		// if (this.css.length > 0) {
		// 	const sanitizedCSS = this.sanitizeCSS(this.css);
		// 	if (sanitizedCSS) {
		// 		// Inject CSS into the page
		// 		const styleEl = document.createElement('style');
		// 		styleEl.innerHTML = sanitizedCSS;
		// 		document.head.appendChild(styleEl);
		// 	} else {
		// 		console.warn('CSS was sanitized out, nothing was injected.');
		// 	}
		// }
	}

	executeJavascripts(computed: any[]): { [key: string]: any } {
		const result: { [key: string]: any } = {};

		computed.forEach((computed) => {
			if (computed.type === 'javascript') {
				try {
					const fn = new Function(computed.formula);
					result[computed.property] = fn();
				} catch (e) {
					console.error(e);
				}
			}
		});
		return result;
	}
	/**
	 * Given a raw CSS string, sanitize it by stripping out
	 * non-conforming characters and properties.
	 *
	 * This approach uses a whitelist methodology, allowing only
	 * specific properties, values, and characters.
	 *
	 * @param {string} css Raw CSS string
	 * @returns {string} Sanitized CSS string
	 */
	sanitizeCSS(css: string) {
		// 1. Strip out any comments
		let cleanedCSS = css.replace(/\/\*[^*]*\*+([^/*][^*]*\*+)*\//g, '');

		// 2. Split by braces to extract selectors and properties
		const chunks = cleanedCSS.split('}');
		cleanedCSS = chunks
			.map((chunk) => {
				const [selector, properties] = chunk.split('{');

				// 2.1. Sanitize the selector
				const sanitizedSelector = selector
					.replace(/[^\w\s\.,\[\]='-]+/g, '')
					.trim();

				// 2.2. Sanitize the properties
				let sanitizedProperties = '';
				if (properties) {
					const propList = properties.split(';');
					propList.forEach((prop) => {
						const [property, value] = prop.split(':').map((p) => p.trim());
						sanitizedProperties += `${property}: ${value}; `;
					});
				}

				return sanitizedSelector
					? `${sanitizedSelector} { ${sanitizedProperties}}`
					: '';
			})
			.join(' ');

		return cleanedCSS;
	}
	// Function to handle form submission
	submitForm() {
		// Load access token from the database
		const accessToken = this.db.load('access_token') as string | undefined;
		// If access token exists, set it in the task API configuration
		if (accessToken)
			this.requestApi.configuration.credentials['pm_api_bearer'] = accessToken;
		let payLoad = {
			data: this.request,
			status: 'COMPLETED',
		};
		// Call getTasks method from tasksApi with parameters null and 'ACTIVE'
		this.tasksApi.updateTask(Number(this.taskId), payLoad).subscribe(
			(response: any) => {
				// Handle successful response
				console.log(response); // Log the response to the console
				this.router.navigate(['tasks']);
			},
			(error) => {
				// Handle error response
				console.log(error); // Log the error to the console
			}
		);
		//console.log(this);
	}
}
interface Javascript {
	id: number;
	name: string;
	type: string;
	formula: string;
	property: string;
}

Step 3: FormElementComponent

Our application design emphasizes easy extensibility and customization. As of the time of this writing, the form elements are supported and shipped with the app. However, as you will see, it is relatively easy to add more elements.

Form Elements

The FormElementComponent is utilized to render varied components, managing logic via ngSwitch.

The main FormElementComponent class should be placed at src/app/components/form-element/app-form-element.component.ts and src/app/components/form-element/app-form-element.component.html, respectively.

The various form elements are contained with src/app/components/form-element/elements.

Example: src/app/components/form-element/elements/app-element-input.component.ts

Input

Template

app-element-input.component.html
<div class="input">
	<label>{{ element?.config?.label }}</label>
	<input
		type="text"
		class="form-control"
		[readonly]="element?.config?.readonly"
		[name]="element?.config?.name"
		[(ngModel)]="request[element?.config?.name]" />
</div>

Typescript

app-element-input.component.ts
import { Component, Input, OnInit } from '@angular/core';

@Component({
	selector: 'app-element-input',
	templateUrl: './app-element-input.component.html',
})
export class InputComponent implements OnInit {
	computedClasses: any;
	@Input() request: any;
	@Input() element: any;
	@Input() formElement: any;
	@Input() control: any;
	@Input() data: any;
	@Input() calcPropsValues: any;
	constructor() {}

	ngOnInit(): void {
		//console.log(this);
	}
}
interface Option {
	value: string;
	content: string;
}

Image

Template

app-element-image.component.html
<ng-container *ngIf="element.config.image">
	<img [src]="safeUrl(element.config.image)" />
</ng-container>

Typescript

app-element-image.component.ts
import { Component, Input, OnInit } from '@angular/core';
import { DomSanitizer, SafeUrl } from '@angular/platform-browser';

@Component({
	selector: 'app-element-image',
	templateUrl: './app-element-image.component.html',
})
export class AppElementImageComponent implements OnInit {
	computedClasses: any;
	@Input() request: any;
	@Input() element: any;
	@Input() formElement: any;
	@Input() control: any;
	@Input() data: any;
	@Input() calcPropsValues: any;
	constructor(private sanitizer: DomSanitizer) {}

	ngOnInit(): void {
		//console.log(this);
	}
	safeUrl(url: string): SafeUrl {
		// Sanitize the URL string
		return this.sanitizer.bypassSecurityTrustUrl(url);
	}
}

interface Option {
	value: string;
	content: string;
}

Multi Column

Template

app-multi-column.component.html
<div class="row">
	<ng-container *ngFor="let item of element.items; let outerIndex = index">
		<ng-container *ngIf="item.length == 0">
			<div class="form-group col-{{ getColSize(element, outerIndex) }}">
				&nbsp;
			</div>
		</ng-container>
		<ng-container *ngFor="let control of item; let innerIndex = index">
			<ng-container *ngIf="control">
				<app-form-element
					class="form-group col-{{ getColSize(element, outerIndex) }}"
					[element]="control"
					[request]="request"
					[calcPropsValues]="calcPropsValues"></app-form-element>
				<!-- </div> -->
			</ng-container>
		</ng-container>
	</ng-container>
</div>

```

Typescript

app-multi-column.component.ts
import { Component, Input, OnInit } from '@angular/core';
import { ChangeDetectorRef } from '@angular/core';

@Component({
	selector: 'app-multi-column',
	templateUrl: './app-multi-column.component.html',
})
export class MultiColumnComponent implements OnInit {
	computedClasses: any;
	@Input() request: any;
	@Input() element: any;
	@Input() control: any;
	@Input() cols: any;
	columnSize: any;
	shadow: any;
	@Input() options: any;
	@Input() calcPropsValues: any;

	constructor(private cdRef: ChangeDetectorRef) {}

	ngAfterViewChecked() {
		this.cdRef.detectChanges();
	}
	isArrayOfArrays(): boolean {
		if (!Array.isArray(this.element.items)) {
			return false;
		}
		for (const item of this.element.items) {
			if (!Array.isArray(item)) {
				return false;
			}
		}
		return true;
	}

	ngOnInit(): void {
		console.log(this.element.items);
		//console.log(this.isArrayOfArrays());
		//this.element.push(this.computedClasses);
		//console.log('computed classes: ', this.computedClasses);
	}
	getColSize(control: any, idx: number): number {
		// Make sure you're getting the options from the correct element
		let colSize = control.config?.options[idx]?.content || 12;
		//console.log('colSize: ', control);
		return colSize; // defaulting to 12 if no config found
	}
}
interface Option {
	value: string;
	content: string;
}

Upload File

Template

app-element-upload-file.component.html
<div class="input">
	<label>{{ element?.config?.label }}</label>
	<input
		type="file"
		[readonly]="element?.config?.readonly"
		[name]="element?.config?.name"
		[(ngModel)]="request[element?.config?.name]" />
</div>

Typescript

app-element-upload-file.component.ts
import { Component, Input, OnInit } from '@angular/core';

@Component({
	selector: 'app-element-upload-file',
	templateUrl: './app-element-upload-file.component.html',
})
export class AppElementUploadFileComponent implements OnInit {
	computedClasses: any;
	@Input() request: any;
	@Input() element: any;
	@Input() formElement: any;
	@Input() control: any;
	@Input() data: any;
	@Input() calcPropsValues: any;
	constructor() {}

	ngOnInit(): void {
		//console.log(this);
	}
}
interface Option {
	value: string;
	content: string;
}

Buttons

Template

app-element-button.component.html
<br />
<input
	type="submit"
	class="btn btn-primary w-100"
	value="{{ element.config.label }}" />

Typescript

app-element-button.component.ts
import { Component, Input, OnInit } from '@angular/core';

@Component({
	selector: 'app-element-button',
	templateUrl: './app-element-button.component.html',
})
export class ButtonComponent implements OnInit {
	computedClasses: any;
	@Input() request: any;
	@Input() element: any;
	@Input() formElement: any;
	@Input() control: any;
	@Input() calcPropsValues: any;
	constructor() {}

	ngOnInit(): void {
		//console.log(this);
	}
}
interface Option {
	value: string;
	content: string;
}

HTML Viewer

Template

app-element-html-viewer.component.html
<ng-container *ngIf="element.config.calculatedContent">
	<div [innerHTML]="element.config.calculatedContent"></div>
</ng-container>
<ng-container *ngIf="!element.config.calculatedContent">
	<div [innerHTML]="element.config.content"></div>
</ng-container>

Typescript

app-element-html-viewer.component.ts
import { Component, Input, OnInit } from '@angular/core';

@Component({
	selector: 'app-element-html-viewer',
	templateUrl: './app-element-html-viewer.component.html',
})
export class AppElementHtmlViewerComponent implements OnInit {
	computedClasses: any;
	@Input() request: any;
	@Input() element: any;
	@Input() formElement: any;
	@Input() control: any;
	@Input() data: any;
	@Input() calcPropsValues: any;
	constructor() {}

	ngOnInit(): void {
		try {
			this.element.config.calculatedContent = this.injectContent(
				this.element.config.content,
				this.calcPropsValues
			);
		} catch (e) {
			console.error(e);
		}
		//console.log(this);
	}
	injectContent(htmlString: string, dynamicData: any): string {
		return htmlString.replace(/\{\{(\w+)\}\}/g, (match, key) => {
			// Return the dynamic data if the key exists, else return the original match
			return dynamicData.hasOwnProperty(key) ? dynamicData[key] : match;
		});
	}
}
interface Option {
	value: string;
	content: string;
}

Default

Template

app-element-default.component.html
<ng-container>
	Unsupported Component <strong>{{ element.component }}</strong>
</ng-container>

Typescript

app-element-default.component.ts
import { Component, Input, OnInit } from '@angular/core';

@Component({
	selector: 'app-element-default',
	templateUrl: './app-element-default.component.html',
})
export class AppElementDefaultComponent implements OnInit {
	computedClasses: any;
	@Input() request: any;
	@Input() element: any;
	@Input() formElement: any;
	@Input() control: any;
	@Input() data: any;
	@Input() calcPropsValues: any;
	constructor() {}

	ngOnInit(): void {
		//console.log(this);
	}
}
interface Option {
	value: string;
	content: string;
}

Step 4: Updates

Now that our app is almost complete, we just need to update the router so it knows where to go when we click on the "View Task" button.

The Router

app-routing.module.ts
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';

import { LoginComponent } from '../components/login/login.component';
import { TasksComponent } from '../components/tasks/app-tasks.component';
import { ScreenComponent } from '../components/screen/app-screen.component';
import { AuthGuard } from '../guards/auth.guard';

const routes: Routes = [
	{
		path: 'login',
		component: LoginComponent,
		runGuardsAndResolvers: 'always',
		title: 'Login',
	},
	{
		path: 'oauth/callback',
		component: LoginComponent,
		runGuardsAndResolvers: 'always',
	},
	{
		path: 'tasks',
		component: TasksComponent,
		canActivate: [AuthGuard],
		runGuardsAndResolvers: 'always',
		title: 'Inbox',
	},
	{
		path: 'screen',
		component: ScreenComponent,
		canActivate: [AuthGuard],
		runGuardsAndResolvers: 'always',
		title: 'Screen',
		data: { title: 'Screen' },
	},
	{
		path: '',
		redirectTo: 'tasks',
		pathMatch: 'full',
		runGuardsAndResolvers: 'always',
	},
	{
		path: '**',
		redirectTo: 'tasks',
		runGuardsAndResolvers: 'always',
	},
];

@NgModule({
	imports: [RouterModule.forRoot(routes, { useHash: true })],
	exports: [RouterModule],
})
export class AppRoutingModule {}

AppModule

We need to add all the new components to our AppModule.

Typescript

app.module.ts
import { NgModule } from '@angular/core';
import '@angular/compiler';
import { AppRoutingModule } from './routing/app-routing.module';
import { BrowserModule } from '@angular/platform-browser';
import { HttpClientModule } from '@angular/common/http';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { NgxPaginationModule } from 'ngx-pagination';
import { CommonModule } from '@angular/common';
import { RootComponent } from './components/root/app-root.component';
import { LoginComponent } from './components/login/login.component';
import { TasksComponent } from './components/tasks/app-tasks.component';
import { NavigationComponent } from './components/nav/navigation.component';
import { FormComponent } from './components/form/app-form.component';
import { ApiModule } from 'api';
import { FormElementComponent } from './components/form-element/app-form-element.component';
import { ScreenComponent } from './components/screen/app-screen.component';
import { MultiColumnComponent } from './components/form-element/elements/muti-column/app-multi-column.component';
import { InputComponent } from './components/form-element/elements/input/app-element-input.component';
import { AppElementDefaultComponent } from './components/form-element/elements/default/app-element-default.component';
import { ButtonComponent } from './components/form-element/elements/buttons/app-element-button.component';
import { AppBreadcrumbsComponent } from './components/breadcrumbs/app-breadcrumbs.component';
import { AppElementHtmlViewerComponent } from './components/form-element/elements/html-viewer/app-element-html-viewer.component';
import { AppElementImageComponent } from './components/form-element/elements/image/app-element-image.component';
import { AppElementUploadFileComponent } from './components/form-element/elements/upload-file/app-element-upload-file.component';

@NgModule({
	declarations: [
		RootComponent,
		LoginComponent,
		TasksComponent,
		NavigationComponent,
		FormComponent,
		FormElementComponent,
		ScreenComponent,
		MultiColumnComponent,
		InputComponent,
		ButtonComponent,
		AppBreadcrumbsComponent,
		AppElementDefaultComponent,
		AppElementHtmlViewerComponent,
		AppElementImageComponent,
		AppElementUploadFileComponent,
	],
	imports: [
		BrowserModule,
		AppRoutingModule,
		HttpClientModule,
		NgxPaginationModule,
		FormsModule,
		ReactiveFormsModule,
		CommonModule,
		ApiModule,
	],
	providers: [],
	bootstrap: [RootComponent],
})
export class AppModule {}

Review

After your code finishes compiling, click on the "Next Task" button in the inbox (assuming you have open cases) and you should see your form, just as I have posted a screenshot below.

We recommend playing around with simple forms first, and then adding complexity as you get more comfortable.

If you liked this or found it helpful, please let us know by leaving a rating and sharing on social!

If not, please let us know!

PRs are always welcomed!

Last updated