The Modern Application Stack – Part 4b: Building a Client UI Using Angular 2 (formerly AngularJS) and TypeScript
Welcome back! Today, we'll finish going over how to build a complete, working, MEAN stack application using Angular 2 and Typescript.
Join the DZone community and get the full member experience.
Join For FreeA Simple Component That Accepts Data From Its Parent
Recall that the application consists of five components: the top-level application which contains each of the add, count, update, and sample components.
When building a new application, you would typically start by designing the top-level container and then work downwards. As the top-level container is the most complex one to understand, we'll start at the bottom and then work up.
A simple sub-component to start with is the count component:
public/app/count.component.html
defines the elements that define what's rendered for this component:
<h2>Count Documents</h2>
<p>
Collection name:
<input #CountCollName id="count-collection-name" type="text" value="{{MongoDBCollectionName}}">
</p>
<p>
<button (click)="countDocs(CountCollName.value)">Count Docs</button>
</p>
<p>
<span class="successMessage">{{DocumentCount}}</span>
<span class="errorMessage">{{CountDocError}}</span>
</p>
You'll recognize most of this as standard HTML code.
The first Angular extension is for the single input
element, where the initial value
(what's displayed in the input box) is set to {{MongoDBCollectionName}}
. Any name contained within a double pair of braces refers to a data member of the component's class (public/app/count.component.ts
).
When the button is clicked, countDocs
(a method of the component's class) is invoked with CountCollName.value
(the current contents of the input field) passed as a parameter.
Below the button, the class data members of DocumentCount
and CountDocError
are displayed – nothing is actually rendered unless one of these has been given a non-empty value. Note that these are placed below the button in the code, but they would still display the resulting values if they were moved higher up – position within the HTML file doesn't impact logic flow. Each of those messages is given a class so that they can be styled differently within the component's CSS file:
.errorMessage {
font-weight: bold;
color: red;
}
.warningMessage {
color: brown;
}
.successMessage {
color: green;
}
The data and processing behind the component is defined in public/app/count.component.ts
:
import {
Component,
OnInit,
Injectable,
EventEmitter,
Input,
Output
} from '@angular/core';
import {
Response
} from '@angular/http';
import {
Observable,
Subscription
} from 'rxjs/Rx';
import 'rxjs/add/operator/map';
import 'rxjs/add/operator/catch';
import {
DataService
} from './data.service';
// This component will be loaded into the <my-count> element of `app/app.component.html`
@Component({
selector: 'my-count',
templateUrl: 'app/count.component.html',
styleUrls: ['stylesheets/style.css']
})
@Injectable()
export class CountComponent implements OnInit {
CountDocError: string = "";
DocumentCount: string = "";
// Parameters sent down from the parent component (AppComponent)
@Input() dataService: DataService;
@Input() MongoDBCollectionName: string;
// Event emitters to pass changes back up to the parent component
@Output() onCollection = new EventEmitter < string > ();
ngOnInit() {}
// Invoked from the component's html code
countDocs(CollName: string) {
this.DocumentCount = "";
this.CountDocError = "";
this.dataService.sendCountDocs(CollName)
.subscribe(
results => {
// Invoked if/when the observable is succesfully resolved
if (results.success) {
this.DocumentCount = "Collection '" + CollName +
"' contains " + results.count.toLocaleString() + " documents";
this.MongoDBCollectionName = CollName;
this.onCollection.emit(this.MongoDBCollectionName);
} else {
// Invoked if/when the back-end sucessfully sends a response
// but that response indicates an application-level error
this.CountDocError = "Application Error: " + results.error;
}
},
error => {
// Invoked if/when the observable throws an error
this.CountDocError = "Network Error: " + error;
})
}
}
Starting with the @component
decoration for the class:
@Component({
selector: 'my-count',
templateUrl: 'app/count.component.html',
styleUrls: ['stylesheets/style.css']
})
This provides metadata for the component:
selector
: The position of the component within the parent's HTML should be defined by a<my-count></my-count>
element.templateUrl
: The HMTL source file for the template (public/app/count.component.ts
in this case –public
is dropped as the path is relative).styleUrls
: The CSS file for this component – all components in this application reference the same file:public/stylesheets/style.css
The class definition declares that it implements the OnInit
interface; this means that itsngOnInit()
method will be called after the browser has loaded the component; it's a good place to perform any initialization steps. In this component, it's empty and could be removed.
The two data members used for displaying success/failure messages are initialized to empty strings:
this.DocumentCount = "";
this.CountDocError = "";
Recall that data is passed back and forth between the count component and its parent:
Child component | Data passed down | Data changes pased back up |
---|---|---|
|
Data service | Collection name |
Collection name |
To that end, two class members are inherited from the parent component – indicated by the @Input()
decoration:
// Parameters sent down from the parent component (AppComponent)
@Input() dataService: DataService;
@Input() MongoDBCollectionName: string;
The first is an instance of the data service (which will be used to request the document count); the second is the collection name that we used in the component's HTML code. Note that if either of these is changed in the parent component then the instance within this component will automatically be updated.
When the name of the collection is changed within this component, the change needs to be pushed back up to the parent component. This is achieved by declaring an event emitter (onCollection
):
@Output() onCollection = new EventEmitter<string>();
...
this.onCollection.emit(this.MongoDBCollectionName);
Recall that the HTML for this component invokes a member function: countDocs(CountCollName.value)
when the button is clicked; that function is implemented in the component class:
countDocs(CollName: string) {
this.DocumentCount = "";
this.CountDocError = "";
this.dataService.sendCountDocs(CollName)
.subscribe(
results => {
// Invoked if/when the observable is succesfully resolved
if (results.success) {
this.DocumentCount = "Collection '" + CollName +
"' contains " + results.count.toLocaleString() + " documents";
this.MongoDBCollectionName = CollName;
this.onCollection.emit(this.MongoDBCollectionName);
} else {
// Invoked if/when the back-end sucessfully sends a response
// but that response indicates an application-level error
this.CountDocError = "Application Error: " + results.error;
}
},
error => {
// Invoked if/when the observable throws an error
this.CountDocError = "Network Error: " + error;
})
}
After using the data service to request the document count, either the success or error messages are sent – depending on the success/failure of the requested operation. Note that there are two layers to the error checking:
- Was the network request successful? Errors such as a bad URL, out of service back-end, or loss of a network connection would cause this check to fail.
- Was the back-end application able to execute the request successfully? Errors such as a non-existent collection would cause this check to fail.
Note that when this.CountDocError
or this.DocumentCount
are written, Angular will automatically render the new values in the browser.
Passing Data Down to a Sub-Component (and Receiving Changes Back)
We've seen how CountComponent
can accept data from its parent and so the next step is to look at that parent – AppComponent
.
The HTML template app.component.html
includes some of its own content, such as collecting database connection information, but most of it is a delegated to other components. For example, this is the section that adds in CountComponent
:
<div>
<my-count
[dataService]="dataService"
[MongoDBCollectionName]="MongoDBCollectionName"
(onCollection)="onCollection($event)">
</my-count>
</div>
Angular will replace the <my-count></my-count>
element with CountComponent
; the extra code within that element passes data down to that sub-component. For passing data members down, the syntax is:
[name-of-data-member-in-child-component]="name-of-data-member-in-this-component"
As well as the two data members, a reference to the onCollection
event handler is passed down (to allow CountComponent
to propagate changes to the collection name back up to this component). The syntax for this is:
(name-of-event-emitter-in-child-component)="name-of-event-handler-in-this-component($event)"
As with the count component, the main app component has a Typescript class – defined in app.component.ts
– in addition to the HTML file. The two items that must be passed down are the data service (so that the count component can make requests of the back-end) and the collection name – these are both members of the AppComponent
class.
The dataService
object is implicitly created and initialized because it is a parameter of the class's constructor, and because the class is decorated with @Injectable
:
@Injectable()
export class AppComponent implements OnInit {
...
constructor(private dataService: DataService) {}
''
'
}
MongoDBCollectionName
is set during component initialization within the ngOnInit()
method by using the data service to fetch the default client configuration information from the back-end:
export class AppComponent implements OnInit {
...
MongoDBCollectionName: string;
...
ngOnInit() {
// Fetch the default client config from the back-end
this.dataService.fetchClientConfig().subscribe(
results => {
// This code is invoked if/when the observable is resolved sucessfully
...
this.MongoDBCollectionName = results.mongodb.defaultCollection;
...
},
error => {
// This code is executed if/when the observable throws an error.
console.log("Failed to fetch client content data. Reason: " + error.toString);
});
}
...
}
Finally, when the collection name is changed in the count component, the event that it emits gets handled by the event handler called, onCollection
, which uses the new value to update its own data member:
// This is invoked when a sub-component emits an onCollection event to indicate
// that the user has changes the collection within its form. The binding is
// created in app.component.html
onCollection(CollName: string) {
this.MongoDBCollectionName = CollName;
}
Conditionally Including a Component
It's common that a certain component should only be included if a particular condition is met. Mongopop includes a feature to allow the user to apply a bulk change to a set of documents - selected using a pattern specified by the user. If they don't know the typical document structure for the collection then it's unlikely that they'll make a sensible change. Mongopop forces them to first retrieve a sample of the documents before they're given the option to make any changes.
The ngIf
directive can be placed within the opening part of an element (in this case a <div>
) to make that element conditional. This approach is used within app.component.html
to only include the update component if the DataToPlayWith
data member is TRUE:
<div *ngIf="DataToPlayWith">
<my-update [dataService]="dataService" [MongoDBCollectionName]="MongoDBCollectionName" (onCollection)="onCollection($event)">
</my-update>
</div>
Note that, as with the count component, if the update component is included then it's passed the data service and collection name and that it also passes back changes to the collection name.
Angular includes other directives that can be used to control content; ngFor
being a common one as it allows you to iterate through items such as arrays:
<ul>
<li *ngFor="let item of items; let i = index">
{{i}} {{item}}
</li>
</ul>
Returning to app.component.html
, an extra handler (onSample
) is passed down to the sample component:
<div>
<my-sample [dataService]="dataService" [MongoDBCollectionName]="MongoDBCollectionName" (onSample)="onSample($event)" (onCollection)="onCollection($event)">
</my-sample>
</div>
sample.component.html
is similar to the HTML code for the count component but there is an extra input for how many documents should be sampled from the collection:
<h2>Sample Data</h2>
<p>
Collection name:
<input #SampleCollName id="sample-collection-name" type="text" value="{{MongoDBCollectionName}}">
</p>
<p>
Sample size:
<input #SampleSize id="sample-size" type="number" min="1" max="10" value="1" />
</p>
<p>
<button (click)="sampleDocs(SampleCollName.value, SampleSize.value)">Sample Docs</button>
</p>
<p>
<span class="errorMessage"> {{SampleDocError}}</span>
</p>
<div class="json">
<pre>
{{SampleDocResult}}
</pre>
</div>
On clicking the button, the collection name and sample size are passed to the sampleDocs
method in sample.component.ts
which (among other things) emits an event back to the AppComponent
's event handler using the onSample
event emitter:
@Injectable()
export class SampleComponent implements OnInit {
...
// Parameters sent down from the parent component (AppComponent)
@Input() dataService: DataService;
@Input() MongoDBCollectionName: string;
// Event emitters to pass changes back up to the parent component
@Output() onSample = new EventEmitter < boolean > ();
@Output() onCollection = new EventEmitter < string > ();
...
sampleDocs(CollName: string, NumberDocs: number) {
this.SampleDocResult = "";
this.SampleDocError = "";
this.onSample.emit(false);
this.dataService.sendSampleDoc(CollName, NumberDocs)
.subscribe(
results => {
// Invoked if/when the observable is succesfully resolved
if (results.success) {
this.SampleDocResult = this.syntaxHighlight(results.documents);
this.MongoDBCollectionName = CollName;
this.onSample.emit(true);
this.onCollection.emit(this.MongoDBCollectionName);
} else {
this.SampleDocError = "Application Error: " + results.error;
}
},
error => {
// Invoked if/when the observable throws an error
this.SampleDocError = "Network Error: " + error.toString;
}
);
}
}
Other Code Highlights
Returning to app.component.html
; there is some content there in addition to the sub-components:
<h1>Welcome to MongoPop</h1>
<div>
<p>
The IP address of the server running MongoPop is {{serverIP}}, if using <a href="https://cloud.mongodb.com" name="MongoDB Atlas" target="_blank">MongoDB Atlas</a>, please make sure you've added this to your IP Whitelist unless you have VPC peering configured.
</p>
</div>
<div>
<p>
Connect String provided by MongoDB Atlas:
<input #MongoDBBaseString id="MongoDB-base-string" value="{{dBInputs.MongoDBBaseURI}}" (keyup)="setBaseURI(MongoDBBaseString.value)" (change)="setBaseURI(MongoDBBaseString.value)" />
</p>
<!-- Only ask for the password if the MongoDB URI has been changed from localhost -->
<div *ngIf="dBInputs.MongoDBUser">
<p>
Password for user {{dBInputs.MongoDBUser}}:
<input #MongoDBPassword id="MongoDB-password" value="{{dBInputs.MongoDBUserPassword}}" type="password" (keyup)="setPassword(MongoDBPassword.value)" (change)="setPassword(MongoDBPassword.value)" />
</p>
</div>
<p>
Preferred database name:
<input #MongoDBDBName id="MongoDB-db-name" value="{{dBInputs.MongoDBDatabaseName}}" (keyup)="setDBName(MongoDBDBName.value)" (change)="setDBName(MongoDBDBName.value)" />
</p>
<p>
Socket (operation) timeout in seconds:
<input #SocketTimeout id="socket-timeout" value="{{dBInputs.MongoDBSocketTimeout}}" type="number" min="1" max="1000" (change)="setMongoDBSocketTimeout(SocketTimeout.value)" />
</p>
<p>
Connection Pool size:
<input #ConnectionPoolSize id="connection-pool-size" value="{{dBInputs.MongoDBConnectionPoolSize}}" type="number" min="1" max="1000" (change)="setMongoDBConnectionPoolSize(ConnectionPoolSize.value)" />
</p>
<p>
MongoDB URI: {{dBURI.MongoDBURIRedacted}}
<button (click)="showPassword(true)">Show Password</button>
</p>
...
</div>
Most of this code is there to allow a full MongoDB URI/connection string to be built based on some user-provided attributes. Within the input elements, two event types (keyup
& change
) make immediate changes to other values (without the need for a page refresh or pressing a button):
The actions attached to each of these events call methods from the AppComponent class to set the data members – for example the setDBName
method (from app.component.ts
):
setDBName(dbName: string) {
this.dBInputs.MongoDBDatabaseName = dbName;
this.dBURI = this.dataService.calculateMongoDBURI(this.dBInputs);
}
In addition to setting the dBInputs.MongoDBDatabaseName
value, it also invokes the data service method calculateMongoDBURI
(taken from data.service.ts
):
calculateMongoDBURI(dbInputs: any): {
"MongoDBURI": string,
"MongoDBURIRedacted": string
} {
/*
Returns the URI for accessing the database; if it's for MongoDB Atlas then include the password and
use the chosen database name rather than 'admin'. Also returns the redacted URI (with the password
masked).
*/
let MongoDBURI: string;
let MongoDBURIRedacted: string;
if (dbInputs.MongoDBBaseURI == "mongodb://localhost:27017") {
MongoDBURI = dbInputs.MongoDBBaseURI +
"/" + dbInputs.MongoDBDatabaseName +
"?authSource=admin&socketTimeoutMS=" +
dbInputs.MongoDBSocketTimeout * 1000 +
"&maxPoolSize=" +
dbInputs.MongoDBConnectionPoolSize;
MongoDBURIRedacted = dbInputs.MongoDBBaseURI;
} else {
// Can now assume that the URI is in the format provided by MongoDB Atlas
dbInputs.MongoDBUser = dbInputs.MongoDBBaseURI.split('mongodb://')[1].split(':')[0];
MongoDBURI = dbInputs.MongoDBBaseURI
.replace('<DATABASE>', dbInputs.MongoDBDatabaseName)
.replace('<PASSWORD>', dbInputs.MongoDBUserPassword) +
"&socketTimeoutMS=" +
dbInputs.MongoDBSocketTimeout * 1000 +
"&maxPoolSize=" +
dbInputs.MongoDBConnectionPoolSize;
MongoDBURIRedacted = dbInputs.MongoDBBaseURI
.replace('<DATABASE>', dbInputs.MongoDBDatabaseName)
.replace('<PASSWORD>', "**********") +
"&socketTimeoutMS=" +
dbInputs.MongoDBSocketTimeout * 1000 +
"&maxPoolSize=" +
dbInputs.MongoDBConnectionPoolSize;
}
this.setMongoDBURI(MongoDBURI);
return ({
"MongoDBURI": MongoDBURI,
"MongoDBURIRedacted": MongoDBURIRedacted
});
}
This method is run by the handler associated with any data member that affects the MongoDB URI (base URI, database name, socket timeout, connection pool size, or password). Its purpose is to build a full URI which will then be used for accessing MongoDB; if the URI contains a password then a second form of the URI, MongoDBURIRedacted
has the password replaced with **********
.
It starts with a test as to whether the URI has been left to the default localhost:27017
– in which case it's assumed that there's no need for a username or password (obviously, this shouldn't be used in production). If not, it assumes that the URI has been provided by the MongoDB Atlas GUI and applies these changes:
- Change the database name from
<DATATBASE>
to the one chosen by the user. - Replace
<PASSWORD>
with the real password (and with**********
for the redacted URI). - Add the socket timeout parameter.
- Add the connection pool size parameter.
Testing & Debugging the Angular Application
Now that the full MEAN stack application has been implemented, you can test it from within your browser:
Debugging the Angular 2 client is straightforward using the Google Chrome Developer Tools which are built into the Chrome browser. Despite the browser executing the transpiled JavaScript, the Dev Tools allows you to browse and set breakpoints in your Typescript code:
Summary and What's Next in the Series
Previous posts stepped through building the Mongopop application back-end. This post describes how to build a front-end client using Angular 2. At this point, we have a complete, working, MEAN stack application.
The coupling between the front and back-end is loose; the client simply makes remote, HTTP requests to the back-end service – using the interface created in Part 3: Building a REST API with Express.js.
This series will finish out by demonstrating alternate methods to implement front-ends; using ReactJS for another browser-based UI (completing the MERN stack) and then more alternative methods.
Continue following this blog series to step through building the remaining stages of the Mongopop application:
- Part 1: Introducing The MEAN Stack (and the young MERN upstart)
- Part 2: Using MongoDB With Node.js
- Part 3: Building a REST API with Express.js
- Part 4a: Building a Client UI Using Angular 2 (formerly AngularJS) & TypeScript
- Part 4b: Building a Client UI Using Angular 2 (formerly AngularJS) & TypeScript
- Part 5: Using ReactJS, ES6 & JSX to Build a UI (the rise of MERN)
- Part 6: Browsers Aren't the Only UI
Published at DZone with permission of Andrew Morgan, DZone MVB. See the original article here.
Opinions expressed by DZone contributors are their own.
Comments