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.
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
We are going to see how to do CRUD operations on a list in the app web. For this example, let's 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.
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);
}
}]);
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';
}
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;
});
}
}
})();/pre>
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);
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.
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';
var controllerId = "taskDetail";
angular.module('app').controller(controllerId,
['$window', '$location', '$routeParams',
'common', 'datacontext', taskDetail]);
function taskDetail($window, $location, $routeParams, common, datacontext) {
var vm = this;
vm.goCancel = goCancel;
vm.goSave = goSave;
init();
function init() {
var taskItemId = +$routeParams.id;
if (taskItemId && taskItemId > 0) {
getItem(taskItemId);
} else {
createItem();
}
common.logger.log("controller loaded", null, controllerId);
common.activateController([], controllerId);
}
function goBack() {
$window.history.back();
}
function goCancel() {
datacontext.revertChanges(vm.taskItem);
goBack();
}
function goSave() {
return datacontext.saveChanges()
.then(function () {
goBack();
});
}
function createItem() {
var newtaskItem = datacontext.createTaskItem();
vm.taskItem = newtaskItem;
}
function getItem(taskId) {
datacontext.getTaskItem(taskId)
.then(function (data) {
vm.taskItem = data;
});
}
}
})();
Update the datacontext.breeze.js:
function getTaskItem(id) {
return manager.fetchEntityByKey('Tasks', id, true)
.then(function (data) {
common.logger.log('fetched task item from ' +
(data.fromCache ? 'cache' : 'server'), data);
return data.entity;
});
}
function saveChanges() {
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:
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.
The post Creating a SharePoint SPA using AngularJS and Breeze appeared first on The SharePoint Guide.