In a real-world application, data for websites would be stored and retrieved from a database of some sort. To use the data within a JavaScript web page, these data structures would be serialized to JSON format. Marionette uses standard Backbone models and collections for loading and serializing data structures. For the purpose of this sample application, our data structure will look like this:
The source of our data is the ManufacturerCollection
, which will have a url
property to load data from our site. This ManufacturerCollection
holds a collection of ManufacturerModels
, that are available via the models
property. The ManufacturerCollection
also implements two interfaces: IManufacturerCollection
and IFilterProvider
. We will discuss these two interfaces later on.
The properties of the ManufacturerModel
will be used to render a single manufacturer's name and logo to the DOM. Each ManufacturerModel
also has an array named boards
, which holds an array of BoardModels
.
Each BoardModel
has properties that are necessary for rendering, as well as an array named board_types
, which holds an array of BoardType
classes. A BoardType
is a simple string, and will hold a value of either "Wave", "Freestyle", or "Slalom".
Each BoardModel
will also have an array of sizes
, holding a BoardSize
class, containing detailed information on the available sizes.
As an example, the JSON data structure that is used to serialize the preceding object structure, would be as follows:
{ "manufacturer": "JP Australia", "manufacturer_logo": "jp_australia_logo.png", "logo_class" : "", "boards": [ { "name": "Radical Quad", "board_types": [ { "board_type": "Wave" } ], "description": "Radical Wave Board", "image": "jp_windsurf_radicalquad_ov.png", "long_description": "long desc goes here", "sizes": [ { "volume": 68, "length": 227, "width": 53, "sail_min": "< 5.0", "sail_max": "< 5.2" } ] }] }
In our sample application, a full JSON dataset can be found at /tscode/tests/boards.json
.
In order to use this JSON data structure within TypeScript, we will need to define a set of interfaces to describe the above data structure, as follows:
export interface IBoardType { board_type: string; } export interface IBoardSize { volume: number; length: number; width: number; sail_min: string; sail_max: string; } export interface IBoardModel { name: string; board_types: IBoardType[]; description: string; image: string; long_description: string; sizes: IBoardSize[]; } export interface IManufacturerModel { manufacturer: string; manufacturer_logo: string; logo_class: string; boards: IBoardModel[]; }
These interfaces simply match the model properties in the previous diagram, and we can then build the corresponding Backbone.Model
classes that implement these interfaces. Note that for brevity, we have not listed each individual property of each model here, so be sure to refer to the accompanying source code for a full listing. Our Backbone models are as follows:
export class BoardType extends Backbone.Model implements IBoardType { get board_type() { return this.get('board_type'), } set board_type(val: string) { this.set('board_type', val); } } export class BoardSize extends Backbone.Model implements IBoardSize { get volume() { return this.get('volume'),} set volume(val: number) { this.set('volume', val); } // more properties } export class BoardModel extends Backbone.Model implements IBoardModel { get name() { return this.get('name'), } set name(val: string) { this.set('name', val); } // more properties get sizes() { return this.get('sizes'), } set sizes(val: IBoardSize[]) { this.set('sizes', val); } } export class ManufacturerModel extends Backbone.Model implements IManufacturerModel { get manufacturer() { return this.get('manufacturer'), } set manufacturer(val: string) { this.set('manufacturer', val); } // more properties get boards() { return this.get('boards'), } set boards(val: IBoardModel[]) { this.set('boards', val); } }
Each class extends Backbone.Model
, and implements one of the interfaces that we have defined earlier. There is not much to these classes, except for defining a get
and set
method for each property, and using the correct property type.
At this stage, our models are in place, and we can write a few unit tests, just to make sure that we can create our models correctly:
it("should build a BoardType", () => { var boardType = new bm.BoardType( { board_type: "testBoardType" }); expect(boardType.board_type).toBe("testBoardType"); });
We start with a simple test that creates a BoardType
model, and then test that the board_type
property has been set correctly. Similarly, we can create a test for the BoardSize
model:
describe("BoardSize tests", () => { var boardSize: bm.IBoardSize; beforeAll(() => { boardSize = new bm.BoardSize( { "volume": 74, "length": 227, "width": 55, "sail_min": "4.0", "sail_max": "5.2" }); }); it("should build a board size object",() => { expect(boardSize.volume).toBe(74); }); });
This test is also just creating an instance of the BoardSize
model, but it is using the beforeAll
Jasmine method. For brevity, we are only showing one test, which checks the volume
property, but in a real-world application we would test each of the BoardSize
properties. Finally, we can write a test of the BoardModel
as follows:
describe("BoardModel tests",() => { var board: bm.IBoardModel; beforeAll(() => { board = new bm.BoardModel({ "name": "Thruster Quad", "board_types": [{ "board_type": "Wave" }], "description": "Allround Wave Board", "image": "windsurf_thrusterquad_ov.png", "long_description": "Shaper Werner Gnigler and pro riders Robby Swift", "sizes": [ { "volume": 73, "length": 228, "width": 55.5, "sail_min": "4.0", "sail_max": "5.2" } ] }); }); it("should find name property",() => { expect(board.name).toBe("Thruster Quad"); }); it("should find sizes[0].volume property",() => { expect(board.sizes[0].volume).toBe(73); }); it("should find sizes[0].sail_max property",() => { expect(board.sizes[0].sail_max).toBe("5.2"); }); it("should find board_types[0].sail_max property",() => { expect(board.board_types[0].board_type).toBe("Wave"); }); });
Again, we are creating a BoardModel
instance in our beforeAll
function, and then testing that the properties are set correctly. Note the tests near the bottom of this code snippet: we are checking whether the sizes
property and board_types
properties have been built correctly, and that they are in fact arrays that can be referenced with []
array notation.
In the accompanying source code, you will find further tests for these models, as well as tests for the ManufacturerModel
.
At this stage, you may wonder why we are writing these sort of tests, as they might seem trivial, and are just checking whether certain properties have been constructed correctly. In real-world applications, models change quite frequently, especially in the beginning stages of a project. It is quite common to have one developer, or a portion of the team, who are responsible for the backend databases and server-side code that deliver JSON to the frontend. Another another team may be responsible for working on the frontend JavaScript code. By writing tests like these, you are clearly defining what your data structures should look like, and what properties you are expecting in your models. If a change is made server side that modifies a data structure, your team will be able to quickly identify where the cause of the problem lies.
Another reason to write property-based tests is that Backbone, Marionette, and just about any other JavaScript library will use these property names to render HTML to the frontend. If you have a template that is expecting a property called manufacturer_logo
, and you change this property name to logo_image
, then your rendering code will break. These errors are quite often difficult to track down at runtime. Following the Test Driven Development mantra of "fail early, and fail loudly", our model property tests will quickly highlight these potential errors, should they occur.
Once a series of property-based tests are in place, we can now focus on an integration test that will actually call the server-side code. This will ensure that our RESTful services are working correctly, and that the JSON data structure that our site is generating matches the JSON data structure that our Backbone models expect. Again, if two separate teams are responsible for client-side and server-side code, this sort of integration test will ensure that the data exchange is consistent.
We will be loading our data for this application through a Backbone.Collection
class, and this collection will need to load multiple manufacturers. To this end, we can now build a ManufacturerCollection
class as follows:
export class ManufacturerCollection extends Backbone.Collection<ManufacturerModel> { model = ManufacturerModel; url = "/tscode/boards.json"; }
This is a very simple Backbone.Collection
class, which just sets the model
property to our ManufacturerModel
, and the url
property to /tscode/boards.json
. As our sample application does not have a backend database or REST services, so we will just load our JSON from disk at this stage. Note that even though we are using a static JSON file in this test, Backbone will still issue an HTTP request back to our server in order to load this file, meaning that any test of this ManufacturerCollection
is, in fact, an integration test. We can now write some integration tests to ensure that this model can be loaded correctly from the url
property, as follows:
describe("ManufacturerCollection tests", () => { var manufacturers: bm.ManufacturerCollection; beforeAll(() => { manufacturers = new bm.ManufacturerCollection(); manufacturers.fetch({ async: false }); }); it("should load 3 manufacturers", () => { expect(manufacturers.length).toBe(3); }); it("should find manufacturers.at(2)",() => { expect(manufacturers.at(2).manufacturer) .toBe("Starboard"); }); }
We are again using the Jasmine beforeAll
syntax to set up our ManufacturerCollection
instance, and then calling fetch({ async: false })
to wait for the collection to be loaded. We then have two tests, one to check that we are loading three manufacturers into our collection, and another to check the Manufacturer
model at index 2
.
Now that we have a full ManufacturerCollection
loaded, we can turn our attention to processing the data that it contains. We will need to search this collection to find two things: a list of manufacturers, and a list of board types. These two lists will be used by our filtering panel on the left-hand side panel. In a real-world application, these two lists may be provided by server-side code, returning simple JSON data structures to represent these two lists. In our sample application, however, we will show how to traverse the main manufacturer Backbone collection that we have already loaded. The filtering data structure is as follows:
Rather than listing the full implementation of Backbone models shown in the preceding diagram, we will take a look at the TypeScript interfaces instead. Our interfaces for these filtering models are as follows:
export enum FilterType { Manufacturer, BoardType, None } export interface IFilterValue { filterValue: string; } export interface IFilterModel { filterType: FilterType; filterName: string; filterValues: IFilterValue[]; }
We start with a FilterType
enum, which we will use to define each of the types of filters we have available. We can filter our board list by either manufacturer name, board type, or clear all filters by using the None
filter type.
The IFilterValue
interface simply holds a string value that will be used for filtering. When we are filtering by board type, this string value would be one of "Wave", "Freestyle", or "Slalom", and when we are filtering by manufacturer, this string value will be the name of the manufacturer.
The IFilterModel
interface will hold the FilterType
, a name for the filter, and array of filterValues
.
We will create a Backbone model for each of these interfaces, meaning that we will end up with two Backbone models, named FilterValue
(which implements the IFilterValue
interface), and FilterModel
(which implements the IFilterModel
interface). To house a collection of FilterModel
instances, we will also create a Backbone collection named FilterCollection
. This collection has a single method named buildFilterCollection
, which will use an IFilterProvider
interface to build its internal array of FilterModels
. This IFilterProvider
interface is as follows:
export interface IFilterProvider { findManufacturerNames(): bm.IManufacturerName[]; findBoardTypes(): string[] }
Our IFilterProvider
interface has two functions. The findManufacturerNames
function will return a list of manufacturer names (and their associated logos), and the findBoardTypes
function will return a list of strings of all board types. This information is all that is needed to build up our FilterCollection
internal data structures.
All of the values needed to populate this FilterCollection
will come from data that is already contained within our ManufacturerCollection
. The ManufacturerCollection
will, therefore, need to implement this IFilterProvider
interface.
Let's continue working within our test suite to flesh out the functionality of the findManufacturerNames
function that the ManufacturerCollection
will need to implement, as part of the IFilterProvider
interface. This function returns an array of type IManufacturerName
, which is defined as follows:
export interface IManufacturerName { manufacturer: string; manufacturer_logo: string; }
We can now build a test using this interface:
it("should return manufacturer names ",() => { var results: bm.IManufacturerName[] = manufacturers.findManufacturerNames(); expect(results.length).toBe(3); expect(results[0].manufacturer).toBe("JP Australia"); });
This test is reusing the manufacturers
variable that we set up in our previous test suite. It then calls the findManufacturerNames
function, and expects the results to be an array of three manufacturer names, i.e. "JP Australia"
, "RRD",
and "Starboard"
.
Now, we can update the actual ManufacturerCollection
class, in order to provide an implementation of the findManufacturerNames
function:
public findManufacturerNames(): IManufacturerName[] { var items = _(this.models).map((iterator) => { return { 'manufacturer': iterator.manufacturer, 'manufacturer_logo': iterator.manufacturer_logo }; }); return items; }
In this function, we are using the Underscore utility function named map
to loop through our collection. Each Backbone collection class has an internal array named models
. The map
function will loop through this models
property, and call the anonymous function for each item in the collection, passing the current model into our anonymous function via the iterator
argument. Our code then builds a JSON object with the required properties of the IManufacturer
interface.
We can now focus on the second function of the IFilterProvider
interface, named findBoardTypes
that the ManufacturerCollection
will need to implement. Here is the unit test:
it("should find board types ",() => { var results: string[] = manufacturers.findBoardTypes(); expect(results.length).toBe(3); expect(results).toContain("Wave"); expect(results).toContain("Freestyle"); expect(results).toContain("Slalom"); });
This test calls the findBoardTypes
function, which will return an array of strings. We are expecting the returned array to contain three strings: "Wave"
, "Freestyle"
, and "Slalom"
.
The corresponding function in our ManufacturerCollection
class is then implemented as follows:
public findBoardTypes(): string[] { var boardTypes = new Array<string>(); _(this.models).each((manufacturer) => { _(manufacturer.boards).each((board) => { _(board.board_types).each((boardType) => { if (! _.contains( boardTypes, boardType.board_type)) { boardTypes.push(boardType.board_type); } }); }); }); return boardTypes; }
The implementation of the findBoardTypes
function starts by creating a new string array named boardTypes
, which will hold our results. We then use the Underscore each
function to loop through each manufacturer. The Underscore each
function is similar to the map
function, and will iterate through each item in our collection. We then loop through each board in the manufacturer's arsenal, and through each board type listed per board. Finally, we are testing to see whether the board type collection contains an item already, using the underscore _.contains
function. If it does not already have the board type in the array, we push the board_type
string into our boardTypes
array.
The Underscore library has numerous utility functions available for searching, manipulating, and modifying arrays and collections—so be sure to consult the documentation to find suitable functions for use in your code. These functions are not limited to Backbone collections only, and can be used on any type of array.
This completes our work on the IFilterProvider
interface, and its implementation within the ManufacturerCollection
class.
When a user clicks on a filter option on the left-hand side panel, we will need to apply the selected filter to the data contained within our manufacturer collection. In order to do this, we will need to implement two functions, named filterByManufacturer
, and filterByBoardType
within the ManufacturerCollection
class. Let's start with a test to filter our collection by manufacturer name:
it("should filter by manufacturer name ",() => { var results = manufacturers.filterByManufacturer("RRD"); expect(results.length).toBe(1); });
This test calls the filterByManufacturer
function, expecting only a single manufacturer to be returned. With this test in place, we can create the real filterByManufacturer
function on the ManufacturerCollection
as follows:
public filterByManufacturer(manufacturer_name: string) { return _(this.models).filter((item) => { return item.manufacturer === manufacturer_name; }); }
Here, we are using the Underscore function named filter
to apply a filter to our collection.
The second filtering function is by board type, and is a little more complicated. We will need to loop through each manufacturer in our collection, then through each board, and then through each board type. If we find a match for the board type, we will flag this board to be included in the result set. Before we tackle the filterByBoardType
function, let's write a test:
it("should only return Slalom boards ",() => { var results = manufacturers.filterByBoardType("Slalom"); expect(results.length).toBe(2); _(results).each((manufacturer) => { _(manufacturer.boards).each((board) => { expect(_(board.board_types).some((boardType) => { return boardType.board_type == 'Slalom'; })).toBeTruthy(); }); }); });
Our test calls the filterByBoardType
function, using the string "Slalom"
as a filter. Remember that this function will return a collection of ManufacturerModel
objects at the top level, with the boards
array within each of these objects filtered by board type. Our test then loops through each manufacturer, and each board in the result set, and then uses the Underscore function called some
to test whether the board_types
array has the correct board type.
Our code to implement this function on the ManufacturerCollection
is also a little tricky, as follows:
public filterByBoardType(board_type: string) { var manufWithBoard = new Array(); _(this.models).each((manuf) => { var hasBoardtype = false; var boardMatches = new Array(); _(manuf.boards).each((board) => { var match = _(board.board_types).some((item) => { return item.board_type == board_type; }); if (match) { boardMatches.push(new BoardModel(board)); hasBoardtype = true; } }); if (hasBoardtype) { var manufFiltered = new ManufacturerModel(manuf); manufFiltered.set('boards', boardMatches); manufWithBoard.push(manufFiltered); } }); return manufWithBoard; }
Our ManufacturerCollection
class instance holds the entire collection that was loaded via the JSON file from the site. In order to keep this data for repeated filters, we will need to construct a new ManufacturerModel
array to return from this function – so that we do not to modify the underlying "global" data. Once we have constructed this new array, we can then loop through each manufacturer. If we find a board matching the required filter, we will set a flag named hasBoardType
to true, to indicate that this manufacturer must be added to our filtered array.
Each manufacturer in this filtered array will also need to list only the board types that match our filter criteria, so we will need another array—called boardMatches
—to hold these matching boards. Our code will then loop through each board, and check whether it has the required board_type
. If so, we will add it to the boardMatches
array and set the hasBoardType
flag to true
.
Once we have looped through each board for a manufacturer, we can check the hasBoardType
flag. If our manufacturer has this board type, we will construct a new ManufacturerModel
, and then set the boards
property on this model to our in-memory array of the matching boards.
Our work with the underlying Backbone collections and models is now complete. We have also written a set of unit and integration tests to ensure that we can load our collection from the site, build our filtering lists from this collection, and then apply a particular filter to this data.
3.144.215.212