Almost every AnguarJS project I use involves some sort of list view. Take this simple note taking application for example:

image

However, I have also used AngularJS in a more professional environment:

  • List of files that the user can view, edit, and delete.
  • List of messages that the user can read and delete or reply to.
  • LIst of time entries where the user can add a new one or edit existing ones.

It's a common pattern and I often find myself re-writing logic for managing these lists:

  • Fetching the items in the list.
  • Fetching the next page of items.
  • Determining if there is a next page.
  • Removing items from the list after deleting them on the server.
  • Updating an item on the list after fetching a new version from the server.
  • Letting the user edit an item, then updating it on the server and in the list.

Take a look at all of this code just to load an API, get a list of items from the server, and paginate through a list of results:

controller('defaultCtrl', function($gapi){  
  var ctrl = this,
    api,
    nextPageToken;

  ctrl.notes = {
    items: []
  };

  var load_list = function(params){
    api.notes.list(params).then(function(r){
      ctrl.notes.items = r.result.items;
      nextPageToken = r.result.nextPageToken;
    });
  };

  ctrl.next_page = function(){
    load_list({nextPageToken: nextPageToken});
  };

  $gapi.load('ferris', 'v1', true).then(function(_api){
    api = _api;
    load_list();
  });
});

This is madness and it doesn't even cover the other use cases of adding, updating, and deleting items. I shouldn't be spending so much time writing this code in my controllers and services. It's hard to test and very easy to get wrong.

Even pulling the logic for each list into its own service doesn't completely solve the issue. You still have to write the same code repeatedly for different APIs - copying, pasting, and changing some identifiers until things work.

A Generic List Service

I work with Google APIs and Google Cloud Endpoints quite a lot. I've even written a nice wrapper for the Google API client for AngularJS. The great thing about working with a really standardized set of APIs is that you know what methods to expect on resources. I can almost always count on having list, get, insert, update, and delete for each resource, if applicable. I can also depend on a common implementations of things like item ids, keys, and pagination. This makes it really easy for us to implement a generic service that can work with any API and resource that uses the Google API client. This can be applied to any set of consistent APIs that you have access to. You could easily apply the logic to something that uses $resource or $http.

Here's my idea for a better controller:

controller('defaultCtrl', function(eydisList){  
  var ctrl = this;

  this.notes = eydisList({
    library: 'ferris',
    version: 'v1',
    resource: 'notes',
    api_root: true
  });

  this.notes.ready.then(function(){
    ctrl.notes.list();
  });

  this.add_new_note = function(note){
    this.notes.insert(note, {position: 'start'}).then(function(){
      note.content = '';
    });
  };
});

This looks a bit easier to digest. Here we're using eydisList to handle loading the API. But what about pagination, insertion, etc? this.notes encapsulates all of that for us, so now we can just call that from the template:

<div class="container">

  <!-- This is form to add a new item -->
  <div class="panel panel-default">
    <div class="panel-heading">New note</div>
    <div class="panel-body">
      <form role="form">
        <div class="form-group">
          <textarea ng-model="new_note.content" class="form-control" placeholder="Enter your note"></textarea>
        </div>
        <button type="button" class="btn btn-primary" ng-click="default.add_new_note(new_note)">Add</button>
      </form>
    </div>
  </div>

  <!-- existing items -->
  <div class="panel panel-default" ng-repeat="note in default.notes.items">
    <div class="panel-heading">
      {{note.created|date:'MM/dd/yyyy @ h:mma'}}
    </div>

    <!-- Display note -->
    <div class="panel-body" ng-if="!note.editing">
      {{note.content}}
    </div>

    <!-- Edit existing note -->
    <div class="panel-body" ng-if="note.editing">
      <form role="form">
        <div class="form-group">
          <textarea ng-model="note.content" class="form-control" placeholder="Enter your note"></textarea>
        </div>
        <button type="button" class="btn btn-primary" ng-click="default.notes.update(note)">Save</button>
      </form>
    </div>

    <!-- Action buttons -->
    <div class="panel-footer text-right">
      <button type="button" ng-click="note.editing = !note.editing" class="btn btn-info">Edit</button>
      <button type="button" ng-click="default.notes.delete(note)" class="btn btn-danger">Delete</button>
    </div>
  </div>

  <!-- pagination -->
  <div class="text-center">
    <button type="button" ng-show="default.notes.list.more" ng-click="default.notes.list.next({append: true});" class="btn btn-default">Load More</button>
  </div>
</div>  

Note the calls to various methods on default.notes.

  • default.notes.list.next({append: true}) is all we need to do for pagination to work. This lets us append to the end of the existing items instead of outright replacing the current list.
  • default.notes.delete(note) will not only remove it from the list, but will make the call to the API to delete it from the server as well.
  • default.notes.update(note) similar to delete, this call will update the item in-place in the list and make the appropriate call to the API.
  • In the controller we're using this.notes.insert(note, {position: 'start'). This calls the insert method on the API and inserts the item at the top of the list. We could also insert at the bottom.

This is much easier and reduces the surface area of our controller, and the amount of possible errors. We can also make things even easier by using ngRoute and resolve:

config(function($routeProvider){  
  $routeProvider
    .when('/default', {
      templateUrl: 'default/default.html',
      controller: 'defaultCtrl',
      controllerAs: 'default',
      resolve: {
        notesList: function(eydisList) {
          return eydisList({
              library: 'ferris',
              version: 'v1',
              resource: 'notes',
              api_root: true}).ready;
        }
      }
    });
})

/* Controller */
.controller('defaultCtrl', function(notesList){
  this.notes = notesList;
  this.notes.list();

  this.add_new_note = function(note){
    this.notes.insert(note, {position: 'start'}).then(function(){
      note.content = '';
    });
  };
});

Now that's a controller that makes me happy.

Check it out

There's a live demo here. Note that Cloud Endpoints has a bit of a "warmup" period.

The source for the example application is here on github. Eydis-Gapi and Eydis-List have their own github repositories as well.

Content is © 2017. All Rights Reserved.

All code is licensed under the Apache License, Version 2.0 and is © Google Inc unless otherwise noted.

All opinions here are my own, and do not necessarily reflect the opinions of my employer.

Any code does not constitute an official Google product (experimental or otherwise), it just happens to be owned by Google.

Ghostium Theme by @oswaldoacauan

Proudly published with Ghost