Now it is time to start coding the first View component. To help you 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, it uses jQuery to get the element for this selector, and store it in an instance variable $el
(source):
function NewInvestmentView (params) {
this.$el = $(params.selector);
}
To make sure this code works, we should write a test for it:
it("should expose a property with its DOM element", function() { expect(view.$el).toExist(); });
The toExist
is a custom matcher provided by the Jasmine jQuery extension to check if an element exists in the document. It not only validates the existence of the property on the JavaScript object, but also that the association with the DOM element worked.
Passing the selector to the View allows it to be instantiated multiple times to different elements on the document.
Another advantage of having the 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.
it("should have an empty stock symbol", function() { expect(view.getSymbolInput()).toHaveValue(''), });
A naive implementation of the getSymbol
method, might use a global jQuery lookup to find the input and return its value:
NewInvestmentView.prototype = {
getSymbolInput: function () {
return $('.new-investment-stock-symbol')
}
};
But 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:
getSymbolInput: function () { return $this.$el.find('.new-investment-stock-symbol') }
The find
function will only look for elements that are children of this.$el
. It is as if this.$el
represents the entire document for the View.
Since we will be using this pattern everywhere inside the View code, we can create a function and use it instead:
NewInvestmentView.prototype = { $: function () { return this.$el.find.apply(this.$el, arguments); }, getSymbolInput: function () { return this.$('.new-investment-stock-symbol') } };
Now 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:
$('.new-investment-stock-symbol').val('from outside the view'),
But 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 the NewInvestmentView
.
If tomorrow, another developer changes the NewInvestmentView
, renaming the input class name from .new-investment-stock-symbol
to .new-investment-symbol
, that one line would be broken.
To fix it, the developer would need to look at the entire codebase for references to that class name.
A much safer approach is to respect the View and use its APIs:
newInvestmentView.setSymbol('from outside the view'),
Which when implemented would look like:
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.
And since there is no sandboxing in the browser's document, which means that from anywhere in the JavaScript code you can make a change anywhere in the document, there is not much you 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 on integrating the NewInvestmentView
and the InvestmentListView
?
You could write an acceptance criterion for the NewInvestmentView
as: Given the new investment View, when its add button is clicked, then it should add an investment to the list of investments.
This is a 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:
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 the InvestmentListView
as its listView
parameter.
And on its implementation, the NewInvestmentView
calls the addInvestment
method of the listView
object when its form is submitted:
function NewInvestmentView (params) { this.listView = params.listView; this.$el.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.
And 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 you want to list investments also in a table. This would impose a change in the NewInvestmentView
to support both the list and table Views:
function NewInvestmentView (params) { this.listView = params.listView; this.tableView = params.tableView; this.$el.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 and future proof solution. Let's rewrite it as: Given the Investment Tracker, when a new investment is created, then it should add the investment to the list of investments.
You can see by the acceptance criterion that it has introduced a new subject to be tested: the 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:
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); }); }); });
You can see the same setup code that once was inside the NewInvestmentView
spec. It loads the fixtures required by both Views, instantiates both an 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
, you can see the function call to the newView.create
function to create a new investment.
And later it checks that a new item was added to the
listView
object by checking that the listView.count()
is equal to 1
.
But how does the integration happen? We can see it 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 the newView
. This observer function will be invoked later when a new investment is created.
The implementation inside the NewInvestmentView
is quite simple. The onCreate
method stores the callback
parameter as an attribute of the object:
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 be even removed in the future.
And later when the create
method is invoked, it invokes _callback
passing the new investment as a parameter.
NewInvestmentView.prototype.create = function() { this._callback(/* new investment */); };
Here is the solution illustrated for better understanding:
Later in Chapter 7, Testing Backbone.js Applications, we will see how the implementation of this NewInvestmentView
spec turned out to be.