Creating a SharePoint SPA using AngularJS and Breeze

Today, we will look at creating a SharePoint SPA using AngularJS and Breeze in a SharePoint hosted app. Single Page Apps or SPA does not mean the application is limited to one page. The experience of navigating between pages is seamless for the user without the postback which happens when navigating to a page.

I have used the HotTowel.Angular module developed by John Papa and used concepts explained by Andrew Connell in his PluralSight course Building SharePoint Apps as Single Page Apps with AngularJS .

First, create a SharePoint hosted app using Visual Studio.

Right click on the project and click manage nuget packages. Search and install the HotTowel.Angular package.

HotTowel.Angular package

This nuget package will install a lot of AngularJS files along with index.html at the root of the project.

Open the AppManifest.xml and change the start page from Pages/Default.aspx to index.html

In the added files, the html references will be like ‘/app/layout/shell.html’. This will throw a file not found exception in SharePoint hosted app as it will try to look for the file in the root of the web. Change it to ‘app/layout/shell.html’ without the leading /. Do this for all file references.

Some of the image files are loaded to the Content module. When they are referenced within the style sheets, the reference should be changed from ‘url(content/images/icon.png)’ to ‘url(images/icon.png)’ since the style sheet is also within the content module, if we have it as the original it will try to access ‘content/content/images/icon.png which is not present.

For the other two images referred within the page, it should be of the format <img src=”content/images/breezelogo.png” />

Now, if you deploy and lauch the app, you should be able to see the default app provided by HotTowel Angular

Basic SPA App

We are going to see how to do CRUD operations on a list in the app web. For this example, lets use a Tasks list. Right click on the project and add a new item of type List give the name as Tasks and select type as Tasks.

Tasks List Creation

Adding Breeze for data access

Go to Tools -> NuGet Package Manager -> Package Manager Console and execute the commands

install-package “Breeze.Angular.SharePoint”
install-package “Angularjs.cookies”

Add references to the breeze scripts

<script src="Scripts/breeze.min.js"></script>
<script src="Scripts/breeze.bridge.angular.js"></script>
<script src="Scripts/breeze.metadata-helper.js"></script>
<script src="Scripts/breeze.labs.dataservice.abstractrest.js"></script>
<script src="Scripts/breeze.labs.dataservice.sharepoint.js"></script>

In the app.js add a dependency to the ‘breeze.angular’ while creating the angular module

If you get the error
Uncaught Error: [$injector:unpr] Unknown provider: $$asyncCallbackProvider

It could be because the AngularJS.Animate and AngularJS versions are not in sync. Get the latest stable versions of both.

Retrieving data from a SharePoint list

Right click on the project and create a new html page called applauncher.html

<body data-ng-controller="appLauncher as vm">
    <script src="Scripts/jquery-2.1.1.js"></script>
    <script src="Scripts/angular.js"></script>
    <script src="Scripts/angular-cookies.js"></script>
    <script src="Scripts/angular-resource.js"></script>
    <script src="app/util/jquery-extensions.js"></script>
    <script src="app/common/common.js"></script>
    <script src="app/common/logger.js"></script>
    <script src="app/config.js"></script>
    <script src="app/appLauncher.js"></script>
    <script src="app/services/spContext.js"></script>
</body>

In the app folder add a JavaScript file appLauncher.js . This has a dependency on the spContext service which we will define next.

(function () {
  'use strict';
  var app = angular.module('app', ['common', 'ngResource', 'ngCookies']);
  app.config(['$logProvider', function ($logProvider) {
    if ($logProvider.debugEnabled) {
      $logProvider.debugEnabled(true);
    }
 }]);

  // create controller
  var controllerId = 'appLauncher';
  var loggerSource = '[' + controllerId + '] ';
  app.controller(controllerId,
    ['$log', 'common', 'spContext', appLauncher]);

  function appLauncher($log, common, spContext) {
    init();

    function init() {
      $log.log(loggerSource, "controller loaded", null, controllerId);
      common.activateController([], controllerId);
    }
  }
})();

In the services folder add a JavaScript file spContext.js . This file loads all the SharePoint context information such as urls into a cookie. It also loads the security validation. Then it redirects back to the index.html without the query string values normally present in the app url.

Since the query string values are stored in the cookie, we no longer need to retain it in the url. The call to $timeout ensures the token is refreshed 10 seconds before it expires.

(function () {
  'use strict';
  
  var serviceId = 'spContext';
  var loggerSource = '[' + serviceId + '] ';
  angular.module('app').service(serviceId, [
    '$log', '$cookieStore', '$window', '$location', '$resource', '$timeout', 'common', 'commonConfig', spContext]);

  function spContext($log, $cookieStore, $window, $location, $resource, $timeout, common, commonConfig)    {
    var service = this;
    var spWeb = {
      appWebUrl: '',
      url: '',
      title: '',
      logoUrl: ''
    };
    service.hostWeb = spWeb;

    init();

    function init() {
      if (decodeURIComponent($.getQueryStringValue("SPHostUrl")) === "undefined") {
        loadSpAppContext();
        refreshSecurityValidation();
      } else {
         createSpAppContext();
         refreshSecurityValidation();
      }
    }

    function createSpAppContext() {
      var appWebUrl = decodeURIComponent($.getQueryStringValue("SPAppWebUrl"));
      $cookieStore.put('SPAppWebUrl', appWebUrl);

      var url = decodeURIComponent($.getQueryStringValue("SPHostUrl"));
      $cookieStore.put('SPHostUrl', url);

      var title = decodeURIComponent($.getQueryStringValue("SPHostTitle"));
      $cookieStore.put('SPHostTitle', title);

      var logoUrl = decodeURIComponent($.getQueryStringValue("SPHostLogoUrl"));
      $cookieStore.put('SPHostLogoUrl', logoUrl);

      $log.log(loggerSource, 'redirecting to app', null);
      $window.location.href = appWebUrl + '/index.html';
    }

    // init the sharepoint app context by loding the app's cookie contents
    function loadSpAppContext() {
      $log.log(loggerSource, 'loading spContext cookie', null);
      service.hostWeb.appWebUrl = $cookieStore.get('SPAppWebUrl');
      service.hostWeb.url = $cookieStore.get('SPHostUrl');
      service.hostWeb.title = $cookieStore.get('SPHostTitle');
      service.hostWeb.logoUrl = $cookieStore.get('SPHostLogoUrl');
    }

     function refreshSecurityValidation() {
      common.logger.log("refreshing security validation", service.securityValidation, serviceId);

      var siteContextInfoResource = $resource('_api/contextinfo?$select=FormDigestValue', {}, {
        post: {
          method: 'POST',
          headers: {
            'Accept': 'application/json;odata=verbose;',
            'Content-Type': 'application/json;odata=verbose;'
          }
        }
      });

      siteContextInfoResource.post({}, function (data) {
        var validationRefreshTimeout = data.d.GetContextWebInformation.FormDigestTimeoutSeconds - 10;
        service.securityValidation = data.d.GetContextWebInformation.FormDigestValue;
        common.logger.log("refreshed security validation", service.securityValidation, serviceId);
        common.logger.log("next refresh of security validation: " + validationRefreshTimeout + " seconds", null, serviceId);

        $timeout(function () {
          refreshSecurityValidation();
        }, validationRefreshTimeout * 1000);
      }, function (error) {
        common.logger.logError("response from contextinfo", error, serviceId);
      });
    }
  }
})();

Create a folder called util and add a JavaScript file jquery-extensions.js. This is the jQuery extension used to retrieve the query string values above.

jQuery.extend({
  getQueryStringValues: function () {
    var vars = [], hash;
    var hashes = window.location.href.slice(window.location.href.indexOf('?') + 1).split('&');
    for (var i = 0; i < hashes.length; i++) {
      hash = hashes[i].split('=');
      vars.push(hash[0]);
      vars[hash[0]] = hash[1];
    }
    return vars;
  },

  getQueryStringValue: function (name) {
    return jQuery.getQueryStringValues()[name];
  }
});

Add a folder called models and add a JavaScript file called breeze.entities.js . This defines the columns we want to retrieve and their datatypes. This also configures breeze to query SharePoint using REST.

(function () {
  'use strict';

  var serviceId = 'breeze.entities';
  angular.module('app').factory(serviceId,
    ['common', breezeEntities]);

  function breezeEntities(common) {
    var metadataStore = new breeze.MetadataStore();
    init();

    return {
      metadataStore: metadataStore
    };

    function init() {
      fillMetadataStore();
    }

    function fillMetadataStore() {
      var namespace = '';
      var helper = new breeze.config.MetadataHelper(namespace, breeze.AutoGeneratedKeyType.Identity);

      var addType = function (typeDef) {
        var entityType = helper.addTypeToStore(metadataStore, typeDef);
        addDefaultSelect(entityType);
        return entityType;
    };

    addTaskType();

    function addDefaultSelect(type) {
      var custom = type.custom;
      if (custom && custom.defaultSelect != null) { return; }

      var select = [];
      type.dataProperties.forEach(function (prop) {
        if (!prop.isUnmapped) { select.push(prop.name); }
      });
      if (select.length) {
        if (!custom) { type.custom = custom = {}; }
        custom.defaultSelect = select.join(',');
      }
      return type;
    }

    function addTaskType() {
       addType({
         name: 'Tasks',
         defaultResourceName: 'getbytitle(\'Tasks\')/items',
         dataProperties: {
           Id: { type: breeze.DataType.Int32 },
           Title: { nullable: false },
           Priority: {},
           Created: { type: breeze.DataType.DateTime },
           Modified: { type: breeze.DataType.DateTime }
         }
      });
    }
  }
}
})();

In the services folder add a JavaScript file datacontext.breeze.js . This along with config.breeze.js forms the service which returns an object containing methods to retrieve data. So, breeze gives a convenient way to access our data and forms the data access layer of our app.

(function () {
  'use strict';

  var serviceId = 'datacontext';
  angular.module('app').factory(serviceId, ['common', 'breeze.config', 'breeze.entities', 'spContext', datacontext]);

  function datacontext(common, breezeConfig, breezeEntities, spContext) {
    var metadataStore, taskType, manager;
    var $q = common.$q;

     init();

    var service = {
      getTasks: getTasks,
    };
    return service;

     function init() {
       metadataStore = breezeEntities.metadataStore;

       taskType = metadataStore.getEntityType('Tasks');

       manager = new breeze.EntityManager({
        dataService: breezeConfig.dataservice,
        metadataStore: metadataStore
      });
    }

     function getTasks() {
      return breeze.EntityQuery
      .from(taskType.defaultResourceName)
      .using(manager)
      .execute().then(function(data) {
        return data.results;
      });
    }
  }
})();

Add a JavaScript file config.breeze.js

(function () {
  'use strict';

  var serviceId = 'breeze.config';
  angular.module('app').factory(serviceId,
    ['breeze', 'common', 'spContext', configBreeze]);

  function configBreeze(breeze, common, spContext) {
    init();

    return {
      dataservice: getDataService()
    };

    function init() {

      var dsAdapter = breeze.config.initializeAdapterInstance('dataService', 'SharePointOData', true);

      // when breeze needs the request digest for sharepoint, 
      //  this is how it will get it, from our sharepoint context
      dsAdapter.getRequestDigest = function () {
        common.logger.log('getRequestDigest', dsAdapter, serviceId);
        return spContext.securityValidation;
      };
    }

    function getDataService() {
      return new breeze.DataService({
        serviceName: spContext.hostWeb.appWebUrl + '/_api/web/lists/',
        hasServerMetadata: false
      });
    }
  }
})();

In the dashboard folder, update the dashboard.html as

<section id="dashboard-view" class="mainbar" data-ng-controller="dashboard as vm">
<section class="matter">
<div class="container">
  <div class="row">
    <div class="col-md-8">
      <div class="widget wviolet">
        <div data-cc-widget-header title="Tasks" allow-collapse="true"></div>
          <div class="widget-content text-center text-info">
            <table class="table table-condensed table-striped">
              <thead>
                <tr>
                  <th>Id</th>
                  <th>Title</th>
                  <th>Priority</th>
                  <th>Created</th>
                  <th>Modified</th>
                </tr>
              </thead>
              <tbody>
                <tr data-ng-repeat="t in vm.tasks">
                  <td>{{t.Id}}</td>
                  <td>{{t.Title}}</td>
                  <td>{{t.Priority}}</td>
                  <td>{{t.Created}}</td>
                  <td>{{t.Modified}}</td>
                 </tr>
              </tbody>
            </table>
          </div>
        </div>
      </div>
    </div>
  </div>
</div>
</section>
</section>

and in dashboard.js we call the getTasks from the datacontext service and populate the tasks array.

(function () {
    'use strict';
    var controllerId = 'dashboard';
    angular.module('app').controller(controllerId, ['common', 'datacontext', dashboard]);

    function dashboard(common, datacontext) {
        var getLogFn = common.logger.getLogFn;
        var log = getLogFn(controllerId);

        var vm = this;

        vm.title = 'Dashboard';
        vm.tasks = [];

        activate();

        function activate() {
          var promises = [getTasks()];
            common.activateController(promises, controllerId)
                .then(function () { log('Activated Dashboard View'); });
        }

        function getTasks() {
          var promise;
          promise = datacontext.getTasks();

          return promise.then(function (data) {
            if (data) {
              return vm.tasks = data;
            }
            else {
              throw new Error('error obtaining data');
            }
          }).catch(function (error) {
            common.logger.logError('error obtaining tasks', error, controllerId);
          });
        }
    }
})();

Open the AppManifest.xml and change the start page from index.html to applauncher.html

Now if you run the application, you should be able to see a list of tasks in the Tasks list on the dashboard page.

Tasks Dashboard Page

Updating the list item

Add a Edit button to the grid

<tr data-ng-repeat="t in vm.tasks">
  <td>{{t.Id}}</td>
  <td>{{t.Title}}</td>
  <td>{{t.Priority}}</td>
  <td>{{t.Created}}</td>
  <td>{{t.Modified}}</td>
  <td><button ng-click="gotoItem(t)">Edit</button></td>
</tr>

In the dashboard.js add the function as below. This will redirect to /Tasks/id

$scope.gotoItem = function(t) {
  if (t && t.Id) {
    $location.path('/Tasks/' + t.Id);
  }
}

In the config.route.js add

{
  url: '/Tasks/:id',
  config: {
    templateUrl: 'app/tasks/taskdetail.html',
    title: 'task',
    settings: {
      nav: 1.1,
      content: '<i class="fa fa-dashboard"></i> Task'
    }
  }
}

Now, we need to add the template taskdetail.html in the tasks folder

<section id="dashboard-view" class="mainbar" data-ng-controller="taskDetail as vm">
<section class="matter">
<form class="form-horizontal">
  <div class="form-group">
    <label for="title">Title:</label>
    <input required id="title" class="form-control" type="text" ng-model="vm.taskItem.Title" placeholder="task title.." />
  </div>
  <div class="form-group">
    <label for="priority">Priority:</label>
    <select class="form-control" id="priority" data-ng-model="vm.taskItem.Priority">
      <option value="">-- choose item type --</option>
      <option>(1) High</option>
      <option>(2) Normal</option>
      <option>(3) Low</option>
    </select>
  </div>
  <div class="form-group">
    <button type="submit" ng-click="vm.goSave()" class="btn btn-primary">Save</button>
    <button type="button" ng-click="vm.goCancel()" class="btn btn-default">Cancel</button>
  </div>
</form>
</section>
</section>

In the taskDetail.js, we retrieve the id from the url. If id is present, it retrieve the task and populates the form. Updating the task is as simple as calling the saveChanges method in datacontext.

(function () {
  'use strict';

  // define controller
  var controllerId = "taskDetail";
  angular.module('app').controller(controllerId,
    ['$window', '$location', '$routeParams', 'common', 'datacontext', taskDetail]);

  // create controller
  function taskDetail($window, $location, $routeParams, common, datacontext) {
    var vm = this;

    vm.goCancel = goCancel;
    vm.goSave = goSave;

    // initalize controller
    init();

    // initalize controller
    function init() {
      // if an ID is passed in, load the item
      var taskItemId = +$routeParams.id;
      if (taskItemId && taskItemId > 0) {
        getItem(taskItemId);
      } else {
        createItem();
      }

      common.logger.log("controller loaded", null, controllerId);
      common.activateController([], controllerId);
    }

    // navigate backwards
    function goBack() {
      $window.history.back();
    }

    // handle revert pending item change and navigate back 
    function goCancel() {
      datacontext.revertChanges(vm.taskItem);
      goBack();
    }

    // handle save action
    function goSave() {
      return datacontext.saveChanges()
      .then(function () {
        goBack();
      });
    }

    // create a new task
    function createItem() {
      var newtaskItem = datacontext.createTaskItem();
      vm.taskItem = newtaskItem;
    }

    // load the item specified in the route
    function getItem(taskId) {
      datacontext.getTaskItem(taskId)
        .then(function (data) {
          vm.taskItem = data;
        });
    }
  }
})();

Update the datacontext.breeze.js

// gets a specific task
    function getTaskItem(id) {
      // first try to get the data from the local cache, but if not present, grab from server
      return manager.fetchEntityByKey('Tasks', id, true)
        .then(function (data) {
          common.logger.log('fetched task item from ' + (data.fromCache ? 'cache' : 'server'), data);
          return data.entity;
        });
    }

    // saves all changes
    function saveChanges() {
      // save changes
      return manager.saveChanges()
        .then(function (result) {
          if (result.entities.length == 0) {
            common.logger.logWarning('Nothing saved.');
          } else {
            common.logger.logSuccess('Saved changes.');
          }
        })
        .catch(function (error) {
          $q.reject(error);
          common.logger.logError('Error saving changes', error, serviceId);
        });
    }

For the cancel functionality, just add the method

// reverts all changes back to their original state
function revertChanges() {
  return manager.rejectChanges();
}

Ensure that these methods are added to the return object

var service = {
      getTasks: getTasks,
      getTaskItem: getTaskItem,
      saveChanges: saveChanges,
      revertChanges: revertChanges
};

Add a new item

Add a new button on top of the tasks list table

<tr>
  <td><button ng-click="newTask()">New Task</button></td>
</tr>

In the dashboard.js, since we specify the path as /Tasks/new, the id will not be found so it creates a new item.

$scope.newTask = function() {
          $location.path('/Tasks/new');
}

In the taskDetail.js add

function createItem() {
      var newtaskItem = datacontext.createTaskItem();
      vm.taskItem = newtaskItem;
}

In the datacontext.breeze.js add

function createTaskItem(initialValues) {
      return manager.createEntity(taskType, initialValues);
}

and ensure it is included in the return object

 var service = {
      getTasks: getTasks,
      getTaskItem: getTaskItem,
      saveChanges: saveChanges,
      revertChanges: revertChanges,
      createTaskItem: createTaskItem
    };

Deleting a list item

Add a delete button next to the edit button

<td><button ng-click="goDelete(t)">Delete</button></td>

In the dashboard.js add the function

$scope.goDelete = function (task) {
          datacontext.deleteTask(task)
            .then(function () {
              common.logger.logSuccess("Deleted task.", null, controllerId);
              $location.path('/');
              $route.reload();
            });
        }

In the datacontext.breeze.js add the function

function deleteTask(task) {
      task.entityAspect.setDeleted();
      return saveChanges();
}

and ensure it is included in the return object

var service = {
      getTasks: getTasks,
      getTaskItem: getTaskItem,
      saveChanges: saveChanges,
      revertChanges: revertChanges,
      createTaskItem: createTaskItem,
      deleteTask: deleteTask
};

I have uploaded the complete source code for this project in https://github.com/spguide/SPHostedSPA

We saw how to get a basic SPA application up and running easily using the HotTowel. Then we set up the Breeze libraries to perform SharePoint CRUD operations.

Get Free Email Updates!

Signup now and receive an email once I publish new content.

I will never give away, trade or sell your email address. You can unsubscribe at any time.

  • Egidio Caleiro Santoro

    Awesome blog you got! I’m in a first real Sharepoint-hosted Add-in. I deeply understand what is possible to achieve in SharePoint platform, but as a first(real)timer, decisive coding posts like reinforces my beliefs. I’m improving as better as it gets. Thank you.

    • Thanks Egidio! glad you found it helpful..

  • Suman Desu

    I have used the same in SP 2013 On prem, I am getting 400 error at RefreshSecurityValidation FormDigestValue, how can I fix.