Over the past week, we tried to make our AngularJS model layer more powerful and reduce complexity in our controllers and template by using the object-oriented programming pattern and the CoffeeScript class keyword. You can see the result here: http://plnkr.co/edit/c1uxN6ZorQzOVizlzU5Y?p=preview
This is our Base class that handles most of that complexity:
'use strict'
angular.module('myApp')
.factory 'Base', ($q, $http) ->
class Base
###
A list of all the properties that an object of this class can have
###
@properties: ->
p = {}
p.id = null
p.name = null
p.description = null
p.errors = []
p
###
The base API path used to call REST APIs for the current class
This is to be overridden by child classes
###
@baseApiPath: ":apiPath/organizations/:organizationId"
###
Extra fields, not part of the default API response format, that we want included
###
@requestedFields: []
###
Get data and instantiate new objects for existing records from a REST API
###
@find: (params = {}) ->
params.fields = if Array.isArray(params.fields) then params.fields.concat(@requestedFields) else @requestedFields
deferred = $q.defer()
$http.get(@baseApiPath, {params: params})
.success (data, status, headers, config) =>
# create a new object of the current class (or an array of them) and return it (or them)
if Array.isArray data.data
response = for result in data.data then new @(result, true)
# add "delete" method to results object to quickly delete objects and remove them from the results array
response.delete = (object) ->
object.delete().then ->
response.splice response.indexOf(object), 1
else
response = new @(data.data, true)
deferred.resolve response
.error (data, status, headers, config) =>
deferred.reject data.errors
deferred.promise
constructor: (propValues, convertKeys = false) ->
# Prevent the Base class itself from being instantiated
if @constructor.name is "Base"
throw "The Base class cannot be instantiated and is only meant to be extended by other classes."
@assignProperties propValues, convertKeys
###
Persist the current object's data by passing it to a REST API
Dynamically switch between POST and PUT verbs if the current object has a populated id property
###
save: (data = @getDataForApi(), params = {}) ->
deferred = $q.defer()
if @validate()
params.fields = if Array.isArray(params.fields) then params.fields.concat(@requestedFields) else @requestedFields
if @id?
promise = $http.put "#{@constructor.baseApiPath}/#{@id}", data, {params: params}
else
promise = $http.post @constructor.baseApiPath, data, {params: params}
promise
.success (data, status, headers, config) =>
deferred.resolve @successCallback(data, status, headers, config)
.error (data, status, headers, config) =>
deferred.reject @failureCallback(data, status, headers, config)
else
deferred.reject()
deferred.promise
###
Validate that the current object is valid and ready to be saved
Child classes should override this method and provide their own validation rules
###
validate: -> true
###
Delete the current object
###
delete: (params = {}) ->
deferred = $q.defer()
$http.delete("#{@constructor.baseApiPath}/#{@id}", {params: params})
.success (data, status, headers, config) =>
deferred.resolve @successCallback(data, status, headers, config)
.error (data, status, headers, config) =>
deferred.reject @failureCallback(data, status, headers, config)
deferred.promise
###
Assigns incoming data values to the current object's properties
e.g. we might receive data such as {"id": 1, "full_name": "foobar"}; this will get assigned to the current
object's id and fullName properties
This method is useful for parsing API responses and updating an object's properties after a GET, POST or PUT
###
assignProperties: (data = {}, convertKeys = false) ->
# let's loop over all properties that an object can have and assign the corresponding values that we received, one by one
for name, property of @constructor.properties()
# sometimes, the incoming data use a different case for their keys
dataKey = if convertKeys then Base.toUnderScore name else name
@[name] =
# if a value exists in the incoming data
if data[dataKey] isnt undefined
# if property is a constructor, instantiate a new object or an array of objects
if property? and typeof property is "function"
if Array.isArray data[dataKey]
for nestedValues in data[dataKey]
new property(nestedValues, convertKeys)
else
new property(data[dataKey], convertKeys)
# otherwise, just use the data value as it is
else
data[dataKey]
# if there is no incoming value for this property, assign the default value
else
property
# return the incoming data in case some other function wants to play with it next
return data
###
Extract data from current object and format it to be sent along with a persist API request
Collect values of all properties of current object, and potential nested objects
###
getDataForApi: (object = @) ->
data = {}
# let's loop over all properties that an object can have
for name, property of @constructor.properties()
# see if the current object has a value for this property
if object[name]?
data[Base.toUnderScore(name)] =
# if the current property is a constructor, dig into the nested object
if typeof property is "function"
if Array.isArray object[name]
for nestedObject in object[name] then prepareData nestedObject
else
@getDataForApi object[name]
# otherwise, get the value as it is
else
object[name]
return data
###
Callbacks for $http response promise
###
successCallback: (data, status, headers, config) =>
@assignProperties data.data, true
failureCallback: (data, status, headers, config) =>
@assignErrors data.errors
assignErrors: (errorData) ->
@errors = errorData
###
Convert string case from "user_score" to "camelCase" format
###
@toCamelCase: (string) ->
string.replace /_([a-z])/g, (g) -> g[1].toUpperCase()
###
Convert string case from "camelCase" to "under_score" format
###
@toUnderScore: (string) ->
string.replace /([a-z][A-Z])/g, (g) -> g[0] + '_' + g[1].toLowerCase()
Then it's really easy to extend it in a useful child class:
angular.module('myApp')
.factory 'Animal', (Base, $q) ->
class Animal extends Base
@properties: ->
p = Base.properties()
p.color = null
p.fooBar = null
p
@baseApiPath: "#{Base.baseApiPath}/animals"
validate: ->
if not @color? or @color is ""
@errors.push {message: "The field cannot be empty"}
return false
super
save: ->
if @validate()
super
color: @color
foo_bar: @fooBar
This child class can now be used in a controller this way:
angular.module('myApp')
.controller 'IndexCtrl', ($scope, Animal) ->
Animal.find().then (response) ->
$scope.animals = response
It is then really easy to, for instance, loop over those items and have action buttons on them, like this:
<div ng-controller="IndexCtrl">
<table>
<thead>
<tr>
<th>Id</th>
<th>Name</th>
<th>Color</th>
<th>Foo Bar</th>
</tr>
</thead>
<tbody>
<tr ng-repeat="animal in animals">
<td>{{animal.id}}</td>
<td>{{animal.name}}</td>
<td>{{animal.color}}</td>
<td>{{animal.fooBar}}</td>
<td><button ng-click="animal.save()">Save</button></td>
<td><button ng-click="animal.delete()">Delete</button></td>
</tr>
</tbody>
</table>
</div>
And finally, this is the kind of format that is used for our incoming data:
{
"data": [
{
"id": 1,
"name": "Tiger",
"color": "yellow",
"foo_bar": "foo"
},
{
"id": 2,
"name": "Dog",
"color": "brown",
"foo_bar": "blah"
}
],
"errors": []
}
As you can see, it's using "under_score" format, but JavaScript convention is to use camelCase variable names, so we just convert those names on the fly when receiving the data.
We're going to continue making progress on this pattern next week but I was wondering if anyone sees any big pitfall that we should avoid, or any good optimization we could do here.
I tried to put all the complexity in the Base class so that child classes are kept very small and easy to create.