Custom runner with Angular
This guide shows how to build a custom runner using Angular.
1️⃣ Create the blocks component
The first step is to create the blocks component. This component handles the rendering of the UI for all the blocks (question types) used in the form. Tripetto uses dependency injection to register blocks to the runner. So, our Angular component needs to be able to dynamically serve the Angular components of each block. To let that work, we need to define a block factory class which is the base class for all the blocks.
- Component
- HTML
import { Component, Directive, Input, OnInit, ViewContainerRef, Type, NgZone } from '@angular/core';
import { IObservableNode, NodeBlock } from '@tripetto/runner';
@Directive()
export abstract class BlockComponentFactory<T extends NodeBlock = NodeBlock> {
@Input() node!: IObservableNode<T>;
get block(): T {
return this.node.block as T;
}
}
@Component({
selector: 'tripetto-block',
templateUrl: './block.component.html',
})
export class BlockComponent extends BlockComponentFactory implements OnInit {
constructor(private viewContainerRef: ViewContainerRef, private zone: NgZone) {
super();
}
ngOnInit() {
if (this.node.block) {
this.zone.run(() => {
const instance = this.viewContainerRef.createComponent<BlockComponentFactory>(
this.node.block!.type.ref as Type<any>
).instance;
instance.node = this.node;
});
}
}
}
<div *ngIf="!node.block">
<h3 *ngIf="name" markdown [content]="name" [context]="context"></h3>
<p *ngIf="description" class="text-secondary" markdown [content]="description" [context]="context"></p>
</div>
We first declare the abstract block factory class named BlockComponentFactory
. Next, we declare the BlockComponent
itself. It is derived from BlockComponentFactory
and it implements the OnInit
lifecycle hook. In the init method, the block component class of each node is retrieved and then a component instance is created using createComponent
. A node may have no block attached to it. In that case, the node is just a static text. Those nodes are rendered using the block.component.html
template.
2️⃣ Create the runner component
The next step is to define the actual runner component. This is the component you can use in your Angular app. It accepts a form definition as input, and emits a finished event when the form completes.
- Component
- HTML
- Module
import { Component, ChangeDetectionStrategy, Input, EventEmitter, OnDestroy } from '@angular/core';
import { Runner, IDefinition, Instance, IStoryline, Export } from '@tripetto/runner';
@Component({
selector: 'tripetto-runner',
templateUrl: './runner.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class RunnerComponent implements OnDestroy {
runner: Runner | undefined;
storyline!: IStoryline;
@Input() set definition(definition: IDefinition) {
this.zone.runOutsideAngular(() => {
this.runner = new Runner({
definition,
mode: 'paginated',
start: true,
});
this.runner.onChange = (ev) => {
this.storyline = ev.storyline;
this.changeDetector.detectChanges();
};
this.runner.onFinish = (instance: Instance) => {
this.finished.emit(Export.exportables(instance));
return true;
};
});
}
@Output() finished = new EventEmitter<Export.IExportables>();
ngOnDestroy() {
this.runner?.destroy();
}
}
<tripetto-block
*ngFor="let node of storyline.nodes;"
[node]="node"
[runner]="this"
></tripetto-block>
import { NgModule } from '@angular/core';
import { BlockComponent } from './block.component';
import { RunnerComponent } from './runner.component';
@NgModule({
declarations: [
BlockComponent,
RunnerComponent,
],
exports: [RunnerComponent],
})
export class RunnerModule {}
Here we declare the RunnerComponent
class. It contains the Runner
instance that holds the state of the form. Because that state is managed in the Runner
instance, we can disable the automatic change detection of the component and run the runner itself outside of Angular using runOutsideAngular
. This greatly improves the performance of the runner component since it now only updates/rerenders when there is an actual change in the form.
The class also contains an EventEmitter
called finished
that is invoked when the form completes.
To let the component work, we need to define a module for the runner as shown in runner.module.ts
.
3️⃣ Define blocks
Now we have implemented the runner, we can define our first block. For each block, you need to define two classes: A component class (derived from BlockComponentFactory
) that implements the actual rendering template of the block. And, a Tripetto block class (derived from NodeBlock
) that serves as the glue between the Angular component and the block used in the runner internals.
- Component
- HTML
import { tripetto, NodeBlock } from '@tripetto/runner';
import { BlockComponentFactory } from 'block.component';
import { Component } from '@angular/core';
@Component({
templateUrl: './hello-world.component.html',
})
export class HelloWorldComponent extends BlockComponentFactory<HelloWorldBlock> {}
@tripetto({
type: 'node',
identifier: 'hello-world',
ref: HelloWorldComponent,
})
export class HelloWorldBlock extends NodeBlock {}
<div>
Hello world!
</div>
Here we define a block named HelloWorldBlock
. It is registered using the @tripetto
decorator using the identifier hello-world
. This identifier is used in the form definition to select the right block for a node. In the decorator, the ref
property is used to supply a reference to the Angular component of the block. This reference is used by the block component factory to select the right Angular component for each block.
See the block implementation guide to learn how to develop blocks for Tripetto.
4️⃣ Use the component
Now we can use the component in an Angular application.
- Component
- HTML
- Module
import { Component } from '@angular/core';
import { RunnerComponent } from './runner/runner.component';
import { Export } from '@tripetto/runner';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
})
export class AppComponent {
// This is the form definition we want to run
definition = {
name: 'Example form',
sections: [
{
'id': '0abbc4d4aacecbb8f1aa13e77c6ab7a58c416aefcc42f4fd77d6a1a46a4e3afa',
'nodes': [
{
'id': '362221f5ed388ec503a186f66be7829b542cb307b4f43e91473838a5f8c5ac75',
'block': { 'type': 'hello-world', 'version': '1.0.0' }
}
]
}
]
};
// The runner was finished, output the collected data to the console
onFinished(data: Export.IExportables) {
console.log('Form completed!');
data.fields.forEach((field) => {
if (field.string) {
console.log(`${field.name}: ${field.string}`);
}
});
}
}
<h1>Angular runner example</h1>
<tripetto-runner [definition]="definition" (finished)="onFinished($event)"></tripetto-runner>
import { AppComponent } from './app.component';
import { RunnerModule } from './runner/runner.module';
import { NgModule } from '@angular/core';
@NgModule({
declarations: [AppComponent],
imports: [RunnerModule],
bootstrap: [AppComponent],
})
export class AppModule {}
🧑💻 Code examples
Have a look at the following fully working custom runner examples for Angular.
Angular + Bootstrap
Custom runner example that uses Angular and Bootstrap.
Angular + Angular Material
Custom runner example that uses Angular and Angular Material.