4
\$\begingroup\$

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.

\$\endgroup\$
3
  • \$\begingroup\$ It's been a few days. We've improved this class a lot. The current code is missing a lot of functionalities still. I should be able to post an updated version soon. \$\endgroup\$
    – Blaiz
    Commented May 7, 2014 at 21:29
  • \$\begingroup\$ I expect this question will get answered if you update your code. Do you still want that code reviewed? \$\endgroup\$
    – nrw
    Commented May 22, 2014 at 22:07
  • \$\begingroup\$ Thanks for the reminder. Sorry about the delay. I just updated the code, both in the post and in the plunkr. We added automatic "serializing" of objects (loop over object properties and save to a simpler objects with "under_score" format property names) to post to APIs and a proper delete method in the Base class. If anyone wants to give their opinion on our code, it would be greatly appreciated. \$\endgroup\$
    – Blaiz
    Commented May 24, 2014 at 18:23

0

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.