Angular + JSData

js-data-angular - An Angular wrapper for JSData

js-data-angular Slack Status Latest Release Circle CI npm downloads Coverage Status

Since JSData was originally written specifically for Angular (angular-data), some Angular-specific things had to be taken out to make JSData work with any framework. Ember and React don't have $scope, $q or $http, so JSData can't use those. js-data-angular brings back to JSData the Angular-specific stuff that was originally in Angular-data.

Here's the math: js-data + js-data-angular = angular-data 2.0.

While JSData and its adapters provide constructor classes like DS and DSHttpAdapter that you have to instantiate yourself, js-data-angular sets everything up for you. So you can just inject DS (the result of new JSData.DS()) and DSHttpAdapter (the result of new DSHttpAdapter(). You can set their defaults via DSProvider.defaults and DSHttpAdapterProvider.defaults.

Quick Start

bower install --save js-data-angular js-data or npm install --save js-data-angular js-data

Load js-data-angular.js after js-data.js.

Create your Angular app:

angular.module('myApp', ['js-data'])

Set global configuration:

angular.module('myApp')
  .config(function (DSProvider, DSHttpAdapterProvider) {
    angular.extend(DSProvider.defaults, { ... });
    angular.extend(DSHttpAdapterProvider.defaults, { ... });
  })

Define your resources:

angular.module('myApp')
  .run(function (DS) {
    // DS is the result of `new JSData.DS()`
  
    // We don't register the "User" resource
    // as a service, so it can only be used
    // via DS.<method>('user', ...)
    // The advantage here is that this code
    // is guaranteed to be executed, and you
    // only ever have to inject "DS"
  	DS.defineResource('user');
  })
  .factory('Comment', function (DS) {
    // This code won't execute unless you actually
    // inject "Comment" somewhere in your code.
    // Thanks Angular...
    // Some like injecting actual Resource 
    // definitions, instead of just "DS"
    return DS.defineResource('comment');
  });

Use it:

angular.module('myApp')
  .controller('commentsCtrl', function ($scope, Comment) {
    Comment.findAll().then(function (comments) {
      $scope.comments = comments;
    });
  });

Adapters

If you're not using Angular, then you have to load js-data-http.js to get an http adapter for JSData. Users of js-data-angular don't have to, because js-data-angular provides a DSHttpAdapter implementation that uses $http.

Other adapters like DSLocalStorageAdapter, DSFirebaseAdapter, etc. have to be loaded separately.

Example:

npm install --save js-data js-data-firebase js-data-angular or bower install --save js-data js-data-firebase js-data-angular

Load js-data.js, js-data-firebase.js and then js-data-angular.js. File order is important!

angular.module('myApp', ['js-data'])
  .config(function (DSFirebaseAdapterProvider) {
    DSFirebaseAdapterProvider.defaults.basePath = 'https://myfirebaseurl.com';
  })
  .run(function (DS, DSFirebaseAdapter) {
    // the firebase adapter was already registered
    DS.adapters.firebase === DSFirebaseAdapter;
  
    // but we want to make it the default
  
    DS.registerAdapter('firebase', DSFirebaseAdapter, { default: true });
    
    // Now you can interact with Firebase like
    // it's a RESTful data layer. No backend required!
    DS.defineResource('book');
  })
  .controller('booksCtrl', function ($scope, DS) {
    DS.findAll('book').then(function (books) {
      // all the books you have in Firebase
      $scope.books = books;
    });
  });

js-data-angular adds a few methods to DS:

DS#bindOne

DS#bindOne(resourceName, id, scope, expr[, cb])

Shorthand for manually watching the lastModified timestamp of an item and manually updating the item on the $scope.

// shortest version
User.bindOne(1, $scope, 'user');

// short version
store.bindOne('user', 1, $scope, 'user');

// long version
$scope.$watch(function () {
  return store.lastModified('user', 1);
}, function () {
  $scope.user = _this.get(resourceName, id);
});

Because DS#bindOne uses Angular's $scope.$watch internally, its return value is a function which can be called to remove the binding:

// create binding and save the remove-binding function for later
var functionThatRemovesBinding = User.bindOne(1, $scope, 'user');

// later, to remove the binding
functionThatRemovesBinding();

DS#bindAll

DS#bindAll(resourceName, params, scope, expr[, cb])

Shorthand for manually watching the lastModified timestamp of a collection and manually updating the collection on the $scope.

var params = {
  where: {
    age: {
      '>': 30 
    }
  }
};

// shortest verions
User.bindAll(params, $scope, 'users');

// short version
store.bindAll('user', params, $scope, 'users');

// long version
$scope.$watch(function () {
  return store.lastModified('user');
}, function () {
  $scope.users = store.filter('user', params);
});

Like with DS#bindOne, the return value of DS#bindAll is a function which can be called to remove the binding:

// create binding and save the remove-binding function for later
var functionThatRemovesBinding = User.bindAll({}, $scope, 'users');

// later, to remove the binding
functionThatRemovesBinding();

Using JSData with Angular Guide

Couple of things to keep in mind when using JSData with Angular:

Angular's DI is lazy

This means that when you do app.service('MyService', ...), MyService doesn't actually get instantiated until the first time you try to inject MyService into your app. This can cause a problem with JSData when you define a resource inside of app.service or app.factory, but you don't actually inject the resource anywhere in your app. The resource never actually gets defined. Here is a common solution:

app.service('User', function (DS) {
  return DS.defineResource({...});
}).run(function (User) {}); // Make sure the User resource actually gets defined

app.service('Post', function (DS) {
  return DS.defineResource({...});
}).run(function (Post) {}); // Make sure the Post resource actually gets defined

JSData lives outside of Angular's $scope lifecycle

While JSData does its best to let Angular know when it needs to trigger $digest loops to check for changes to data contained in the data store, sometimes Angular might not get the message. You might find cases where you need to manually trigger a $digest.

ngRepeat and accessing hasMany relations

Let's say you have these definitions:

var Post = DS.defineResource({
  name: 'post',
  hasMany: {
    comment: {
      localField: 'comments',
      foreignKey: 'post_id'
    }
  }
});
var Comment = DS.defineResource({
  name: 'comment',
  belongsTo: {
    post: {
      localField: 'post',
      localKey: 'post_id'
    }
  }
});

And the following in your template:

<div ng-repeat="comment in post.comments track by comment.id">
  ...
</div>

This markup will trigger an infinite digest error because the comments array you get when you access post.comments is an array created by the relation's getter property accessors. It will actually be a different array every time you access post.comments, even if the comments haven't changed at all. With the way Angular checks for changes, Angular will think something has changed, so it will keep triggering the $digest loop, forever.

To avoid this when you need to access an entity's hasMany relation in a template, first attach that relation to the $scope:

Comment.bindAll({
  post_id: post.id
}, $scope, 'comments');
<!-- this will be fine -->
<div ng-repeat="comment in comments track by comment.id">
  ...
</div>

How do I load data for my View?

There are really two ways you can deal with it:

First way: Load all required data before the View is allowed to render

.when('/report/:id', {
  templateUrl: 'routes/report/report.html',
  controller: 'ReportCtrl',
  controllerAs: 'ReportCtrl',
  resolve: {
    report: function ($route, Report) {
      return Report.find($route.current.params.id).then(function (report) {
        return report.DSLoadRelations(['route', 'comment']);
      });
    }
  }
});
.controller('ReportCtrl', function ($route) {
  
  // data already loaded
  this.report = $route.current.locals.report;

  // relations already loaded too
  this.report.comments; // [...]
  this.report.route; // {...}
})

Second way: Allow View to render, lazily load the data, then View reacts to incoming data and re-renders. View must gracefully handle itself when the data hasn't arrived yet.

.when('/report/:id', {
  templateUrl: 'routes/report/report.html',
  controller: 'ReportCtrl',
  controllerAs: 'ReportCtrl',
  resolve: {
    report: function ($route, Report) {
      // don't return the promise, so the View won't wait
      // for its resolution to render
      Report.find($route.current.params.id).then(function (report) {
        report.DSLoadRelations(['route', 'comment']);
      });
    }
  }
});
.controller('ReportCtrl', function ($route, Report) {
  
  // data isn't loaded yet, so watch for it
  // "report" will show up on $scope.ReportCtrl.report
  Report.bindOne($routeParams.id, $scope, 'ReportCtrl.report');
});
<div id="report-view">
  <div class="report" ng-show="ReportCtrl.report">...</div>
  <div class="loading-spinner" ng-hide="ReportCtrl.report">...</div>
</div>

Hopefully that gives you the idea.