In the previous article, I started describing how I had built a more complex TypeScript web application that retrieves data from a separate API and displays the data in a relatively visually pleasing manner. We saw how we could name interface elements to cope with more complicated names, as well as introduce index signatures. Finally, we looked at how we could use the fetch
API to retrieve data from the remote site, paying attention to the use of the Promise
type to cope with asynchronous code.
In this article, we are going to finish breaking the application down. The code for the application can be found here.
Building the view
In the last article, we laid the groundwork for creating a table that would be populated at runtime. As a recap, our table is built up like this.
<table class="table table-striped">
<thead class="table-dark">
<tr>
<th scope="col">Time</th>
<th scope="col">Open</th>
<th scope="col">High</th>
<th scope="col">Low</th>
<th scope="col">Close</th>
<th scope="col">Volume</th>
</tr>
</thead>
<tbody id="trading-table-body"></tbody>
</table>
The way that I am going to populate the table is to create a separate View
class that is responsible purely for adding the rows into the column.
The first method I want to cover here is the one that actually adds a row and populates the cells.
private addTableRow(tableBody: HTMLTableSectionElement, ...elements: string[]): void {
const tableRow = tableBody.insertRow(tableBody.rows.length);
elements.forEach(element => {
const columnElement = tableRow.insertCell(tableRow.cells.length);
columnElement.textContent = element;
});
}
This method accepts an instance of an HTMLTableSectionElement
. This is a roundabout way of saying it takes the table body as the area we are going to add a row onto. I am going to pass the items we want to display in each cell in a row using the spread operator (...elements: string[]
).
Now that I have everything I need to add the rows, I am going to add a row to my table body using the insertRow
function. This method does exactly what it says, it inserts a row at a certain index so it is tempting to think that there would also be an addRow
function to add a row at the end of the table body. While it might be tempting, the reality is, there is only an insertRow
function so, to add the row at the end, we have to insert the row using the number of rows we already have, using tableBody.rows.length
, as the guide where to put the row.
I now need to add each element in as a cell. To add a cell, I follow a similar pattern to the row using insertCell
. I set the textContent
to the relevant element and this will be displayed when the row is rendered in the able.
Having created the code I need to add a row to the table, I need a method that will take the output from the trading API and call the addTableRow
method.
Build(shareDetails: Trading): void {
const tableBody = document.getElementById('trading-table-body') as HTMLTableSectionElement;
Object.entries(shareDetails['Time Series (5min)']).forEach(([date, timeSeries]) => {
this.addTableRow(tableBody, date, timeSeries['1. open'],
timeSeries['2. high'],
timeSeries['3. low'],
timeSeries['4. close'],
timeSeries['5. volume']);
});
}
The first part of this method should be apparent by now. I get the HTML element that has the id trading-table-body
and cast it to an HTMLTableSectionElement
(this is what a tbody
is in TypeScript). I’m going to use something new here to get the entries out of the API. If you remember, from the last article, each trading element is given a unique name. In order to iterate over these entries, I need to use the Object.entries
operation which returns an array of key/value pairs from the object. The key/value pairs are used in the forEach
function and are represented a bit like this ([key, value])
. In my forEach
, I have tried to be a bit more meaningful and use descriptive names date
and timeSeries
to represent the key and value. With this capability, I can call the addTableRow
method, passing in the table body, the date, and the time-series values.
This is time for a confession. In my first iteration of the code for this article, I put the id
on the table itself. This meant that the code I was writing to populate the table became more convoluted than it needed to be because I had to get the table body after I got the table. This was an unnecessary step that was easily eliminated by moving the id
onto the tbody
element.
If you were trying to write this code, from scratch, and you were just using the default tsconfig.json file, you would not be able to use the Object.entries
option. To use this capability, you need to add a minimum of ES2017
in your lib entry.
A brief journey into tsconfig.json
In the last article, and this one, I have mentioned changes that need to be made to tsconfig in order for the code to build. As I have said previously, the tsconfig file controls how the code is built, and what it looks like. It is time to take a look at the tsconfig file for this code to see what effect it has on the build operations.
{
"compilerOptions": {
"target": "es2017",
"module": "amd",
"removeComments": true,
"noImplicitAny": true,
"lib": [
"es2017",
"dom"
],
"outDir": "./scripts",
}
}
Starting at the bottom, outDir
tells TypeScript where it will write the output js files to. The lib
entry tells TypeScript what libraries to load to compile the code. As I am making use of Output.entries
and HTML elements such as HTMLTableSectionElement
, I have to add the es2017
and dom
libraries.
One of the strengths of TypeScript is that it is a great support to you when you want to use types. By setting noImplicitAny
, I tell TypeScript that I want to be warned if I have code that it thinks can be of type any because I forgot to put the type in. Why use a type system if you are just going to ignore it?
Depending on the complexity of the system I am working on, I may have comments in my codebase. I do not want these to be added to the compiled code because that wastes bytes when I am serving up the JavaScript. I love the fact that removeComments
helps me here.
I’m going to skip to the top of the file for the moment. The target
entry is fascinating. What it does is tells TypeScript what version of JavaScript support should be used. This affects the output of the code because it allows us to write code that uses the latest language features and then writes the corresponding JavaScript that would apply at the target
version. The reason for this is because TypeScript is often ahead of features that are either being proposed for a future version of JavaScript or which have been adopted, but haven’t been implemented in the major browsers. Obviously, if all the browsers support the features of your code, then there is no need to use workarounds. The easiest way to visualise this is to change the target to es5
and then recompile the code. This is the output.
define(["require", "exports"], function (require, exports) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.View = void 0;
var View = (function () {
function View() {
}
View.prototype.Build = function (shareDetails) {
var _this = this;
var tableBody = document.getElementById('trading-table-body');
Object.entries(shareDetails['Time Series (5min)']).forEach(function (_a) {
var date = _a[0], timeSeries = _a[1];
_this.addTableRow(tableBody, date, timeSeries['1. open'], timeSeries['2. high'], timeSeries['3. low'], timeSeries['4. close'], timeSeries['5. volume']);
});
};
View.prototype.addTableRow = function (tableBody) {
var elements = [];
for (var _i = 1; _i < arguments.length; _i++) {
elements[_i - 1] = arguments[_i];
}
var tableRow = tableBody.insertRow(tableBody.rows.length);
elements.forEach(function (element) {
var columnElement = tableRow.insertCell(tableRow.cells.length);
columnElement.textContent = element;
});
};
return View;
}());
exports.View = View;
});
This is a lot of code, and a lot of workaround for features that were added in later versions of JavaScript. If I change the target
back to ES2017
, this is what the code looks like.
define(["require", "exports"], function (require, exports) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.View = void 0;
class View {
Build(shareDetails) {
const tableBody = document.getElementById('trading-table-body');
Object.entries(shareDetails['Time Series (5min)']).forEach(([date, timeSeries]) => {
this.addTableRow(tableBody, date, timeSeries['1. open'], timeSeries['2. high'], timeSeries['3. low'], timeSeries['4. close'], timeSeries['5. volume']);
});
}
addTableRow(tableBody, ...elements) {
const tableRow = tableBody.insertRow(tableBody.rows.length);
elements.forEach(element => {
const columnElement = tableRow.insertCell(0);
columnElement.textContent = element;
tableRow.appendChild(columnElement);
});
}
}
exports.View = View;
});
The final bit of the config file is the module
we are using. We can think of a module as simply a collection of files that are linked and that we can use in our code. Any items we export in our code can be used in other parts of our codebase so, as we want our View to be usable in other files in our codebase, it is exported in View.ts. The following picture shows the relationship here, with the browser not having to care about the individual files as TypeScript will have taken care of the relationship.
The reason we set the module
to AMD
is that we want to produce that can be loaded using RequireJS. If we didn’t do this, when we loaded our page we would get this error. Uncaught ReferenceError: exports is not defined
. Okay, that seems a little bit confusing so let’s go step by step.
- We want to load multiple scripts so we are going to use a module.
- If I set my script tag in HTML to this:
<script src="scripts/api.js"></script>
, I get the error Uncaught ReferenceError: define is not defined
in the console. - If I set my script tag to this:
<script type="module" src="scripts/api.js"></script>
and browse to my file locally, I get the error Access to script at 'file:///…/scripts/api.js' from origin 'null' has been blocked by CORS policy: Cross origin requests are only supported for protocol schemes: http, data, chrome-extension, edge, https, chrome-untrusted
. - If I use something like live-server to load my page as a hosted page, I get the following error
Uncaught ReferenceError: exports is not defined
. - In order to load the multiple scripts, I need to use RequireJS, so my
module
has to be AMD
. My HTML script tag now looks like this: <script src="https://cdnjs.cloudflare.com/ajax/libs/require.js/2.3.6/require.min.js" data-main="scripts/api"></script>
. With an AMD module, this works perfectly.
Bringing it all together
I have the code to get data from the API. I have code to display it in a table. All I need now is code to bring the two parts together. In the script, I refer to an api file, which contains the calls I want to make to my api and then bind to the display. This file is, cunningly enough, called api.ts. It doesn’t contain a class, simply relying on a couple of function calls to make everything work together.
The way that I am going to start my code here is to create instances of the View
and Intraday
classes.
import { Intraday } from "./Intraday";
import { Trading } from "./Models/Trading";
import { View } from "./View";
const view = new View();
const intraday = new Intraday();
Things get a little bit more interesting at that point. If you remember, from the last article, the Get
method is an asynchronous method, returning a Promise
. The code for a Promise
can be a little bit awkward so what I want to do is use a different way of working with promises. There is a feature called async
/await
that gives us the same capability as doing Promise().then()
. Let’s look at what our code looks like using async
/await
.
async function RefreshView(symbol: string): Promise<void> {
const shareDetails = await intraday.Get(symbol) as Trading;
view.Build(shareDetails);
}
In order to say I want this piece of code to be asynchronous, I use the async
keyword. Any async
function returns a Promise
because this is just wrapping up a promise. Having said that my code is going to be asynchronous, I use await
to say that I want to wait for the Get
call to finish before continuing on to the Build
call. If you think back to the description of promises, you can see that the code that comes after the await
is the code inside the then
part of the promise.
Finally, I need a piece of code to call RefreshView
. As the function is asynchronous, I am going to use the then
block to update the loading state header to say that the data has been loaded.
RefreshView('MSFT').then(x => {
const state = document.getElementById("loading-state") as HTMLHeadingElement;
state.textContent = `Data loaded for MSFT`;
});
You might wonder why I didn’t use the following code to refresh the view.
await RefreshView('MSFT')
const state = document.getElementById("loading-state") as HTMLHeadingElement;
state.textContent = `Data loaded for MSFT`;
The reason I use the promise format rather than the async
version is because of the module we are using. As we have set the module to amd, we would get the error Top-level 'await' expressions are only allowed when the 'module' option is set to 'esnext' or 'system', and the 'target' option is set to 'es2017' or higher
. With this minor limitation in mind, the simple promise format is enough for me.
Conclusion
There has been a lot to take in, in this article. We have covered changes to tsconfig and how to use async features of the language, as well as looking at how to dynamically add entries into tables. The next post sees us adding additional TypeScript language features into our arsenal.