Now, it is time to start coding the first View component. To help us through the process, we are going to lay two basic rules for View coding happiness:
So, let's see how they work individually.
As mentioned earlier, a View is the behavior associated with a DOM element, so it makes sense to have this element related to the View. A good pattern is to pass a CSS selector
in the View instantiation that indicates the element to which it should refer. Here is the spec for the NewInvestmentView
component:
describe("NewInvestmentView", function() { var view; beforeEach(function() { loadFixtures('NewInvestmentView.html'), view = new NewInvestmentView({ selector: '#new-investment' }); }); });
In the constructor function at the NewInvestmentView.js file, it uses jQuery to get the element for this selector and to store it in an instance variable $element
(source), as follows:
function NewInvestmentView (params) {
this.$element = $(params.selector);
}
To make sure this code works, we should write the following test for it in the NewInvestmentViewSpec.js
file:
it("should expose a property with its DOM element", function() { expect(view.$element).toExist(); });
The toExist
matcher is a custom matcher provided by the Jasmine jQuery extension to check whether an element exists in the document. It validates the existence of the property on the JavaScript object and also the successful association with the DOM element.
Passing the selector
pattern to the View allows it to be instantiated multiple times to different elements on the document.
Another advantage of having an explicit association is knowing that this View is not changing anything else on the document, as we will see next.
A View is the behavior associated with a DOM element, so it shouldn't be messing around everywhere on the page. It should only change or access the element associated with it.
To demonstrate this concept, let's implement another acceptance criterion regarding the default state of the View, as follows:
it("should have an empty stock symbol", function() { expect(view.getSymbolInput()).toHaveValue(''), });
A naive implementation of the getSymbolInput
method might use a global jQuery lookup to find the input and return its value:
NewInvestmentView.prototype = {
getSymbolInput: function () {
return $('.new-investment-stock-symbol')
}
};
However, that could lead to a problem; if there is another input with that class name somewhere else in the document, it might get the wrong result.
A better approach is to use the View's associated element to perform a scoped lookup, as follows:
NewInvestmentView.prototype = {
getSymbolInput: function () {
return this.$element.find('.new-investment-stock-symbol')
}
};
The find
function will only look for elements that are children of this.$element
. It is as if this.$element
represents the entire document for the View.
Since we will use this pattern everywhere inside the View code, we can create a function and use it instead, as shown in the following code:
NewInvestmentView.prototype = { $: function () { return this.$element.find.apply(this.$element, arguments); }, getSymbolInput: function () { return this.$('.new-investment-stock-symbol') } };
Now let's suppose that from somewhere else in the application, we want to change the value of a NewInvestmentView
form input. We know its class name, so it could be as simple as this:
$('.new-investment-stock-symbol').val('from outside the view'),
However, that simplicity hides a serious problem of encapsulation. This one line of code is creating a coupling with what should be an implementation detail of NewInvestmentView
.
If another developer changes NewInvestmentView
, renaming the input class name from .new-investment-stock-symbol
to .new-investment-symbol
, that one line would be broken.
To fix this, the developer would need to look at the entire code base for references to that class name.
A much safer approach is to respect the View and use its APIs, as shown in the following code:
newInvestmentView.setSymbol('from outside the view'),
When implemented, that would look like the following:
NewInvestmentView.prototype.setSymbol = function(value) { this.$('.new-investment-stock-symbol').val(value); };
That way, when the code gets refactored, there is only one point to perform the change—inside the NewInvestmentView
implementation.
Since there is no sandboxing in the browser's document, which means that from anywhere in the JavaScript code, we can make a change anywhere in the document, there is not much that we can do, besides good practice, to prevent these mistakes.
Following the development of the Investment Tracker application, we would eventually need to implement the list of investments. But how would you go about integrating NewInvestmentView
and InvestmentListView
?
You could write an acceptance criterion for NewInvestmentView
, as follows:
Given the new investment View, when its add button is clicked, then it should add an investment to the list of investments.
This is very straightforward thinking, and you can see by the writing that we are creating a direct relationship between the two Views. Translating this into a spec clarifies this perception, as follows:
describe("NewInvestmentView", function() { beforeEach(function() { loadFixtures('NewInvestmentView.html'), appendLoadFixtures('InvestmentListView.html'), listView = new InvestmentListView({ id: 'investment-list' }); view = new NewInvestmentView({ id: 'new-investment', listView: listView }); }); describe("when its add button is clicked", function() { beforeEach(function() { // fill form inputs // simulate the clicking of the button }); it("should add the investment to the list", function() { expect(listView.count()).toEqual(1); }); }); });
This solution creates a dependency between the two Views. The NewInvestmentView
constructor now receives an instance of InvestmentListView
as its listView
parameter.
On its implementation, NewInvestmentView
calls the addInvestment
method of the listView
object when its form is submitted:
function NewInvestmentView (params) { this.listView = params.listView; this.$element.on('submit', function () { this.listView.addInvestment(/* new investment */); }.bind(this)); }
To better clarify how this code works, here is a diagram of how the integration is done:
Although very simple, this solution introduces a number of architectural problems. The first, and most obvious, is the increased complexity of the NewInvestmentView
specs.
Secondly, it makes evolving these components even more difficult due to the tight coupling.
To better clarify this last problem, imagine that in the future, we want to list investments in a table too. This would impose a change in NewInvestmentView
to support both the list and table Views, as follows:
function NewInvestmentView (params) { this.listView = params.listView; this.tableView = params.tableView; this.$element.on('submit', function () { this.listView.addInvestment(/* new investment */); this.tableView.addInvestment(/* new investment */); }.bind(this)); }
Rethinking on the acceptance criterion, we can get into a much better, future-proof solution. Let's rewrite it as:
Given the Investment Tracker application, when a new investment is created, then it should add the investment to the list of investments.
We can see by the acceptance criterion that it has introduced a new subject to be tested: Investment Tracker. This implies a new source and spec file. After creating both the files accordingly and adding them to the runner, we can write this acceptance criterion as a spec, as shown in the following code:
describe("InvestmentTracker", function() { beforeEach(function() { loadFixtures('NewInvestmentView.html'), appendLoadFixtures('InvestmentListView.html'), listView = new InvestmentListView({ id: 'investment-list' }); newView = new NewInvestmentView({ id: 'new-investment' }); application = new InvestmentTracker({ listView: listView, newView: newView }); }); describe("when a new investment is created", function() { beforeEach(function() { // fill form inputs newView.create(); }); it("should add the investment to the list", function() { expect(listView.count()).toEqual(1); }); }); });
We can see the same setup code that once was inside the NewInvestmentView
spec. It loads the fixtures required by both Views, instantiates both InvestmentListView
and NewInvestmentView
, and creates a new instance of InvestmentTracker
, passing both Views as parameters.
Later on, while describing the behavior when a new investment is created
, we can see the function call to the newView.create
function to create a new investment.
Later, it checks that a new item was added to the listView
object by checking that listView.count()
is equal to 1
.
But how does the integration happen? We can see that by looking at the InvestmentTracker
implementation:
function InvestmentTracker (params) { this.listView = params.listView; this.newView = params.newView; this.newView.onCreate(function (investment) { this.listView.addInvestment(investment); }.bind(this)); }
It uses the onCreate
function to register an observer function as a callback at newView
. This observer function will be invoked later when a new investment is created.
The implementation inside NewInvestmentView
is quite simple. The onCreate
method stores the callback
parameter as an attribute of the object, as follows:
NewInvestmentView.prototype.onCreate = function(callback) { this._callback = callback; };
The naming convention of the _callback
attribute might sound strange, but it is a good convention to indicate it as a private member.
Although the prepended underline character won't actually change the visibility of the attribute, it at least informs a user of this object that the _callback
attribute might change or even be removed in the future.
Later, when the create
method is invoked, it invokes _callback
, passing the new investment as a parameter, as follows:
NewInvestmentView.prototype.create = function() { this._callback(/* new investment */); };
A more complete implementation would need to allow multiple calls to onCreate
, storing every passed callback.
Here is the solution illustrated for better understanding:
Later, in Chapter 7, Testing React.js Applications, we will see how the implementation of this NewInvestmentView
spec turned out to be.