Introduction
Accessing, manipulating and updating Sharepoint data can be done in several different ways. New Sharepoint Framework (SPFx) is currently in focus, but you can manipulate Sharepoint data using modern technologies relying on Sharepoint REST API.
Add-In which is a result of this article can access data either from Sharepoint Online (I'll be using that so bear in mind) either from Sharepoint on premise. Data comes from Sharepoint list and it can detect changing of data if it occurs. Displaying of data is done by using ag-Grid
control and Rxjs observables.
This article will show how to access Sharepoint list data by using Angular4, Typescript and NPM. Focus is mainly on how to create, setup and pack everything into one project and NOT teaching how to make an SharePoint Add-In, Angular or JS programming.
So, look at it as a blueprint which can be easily modified to have Vue or React SPA instead of Angular.
Background
Coming from Sharepoint 2010 webpart development, it took some time to adapt to fast moving front-end development that was very different to our server side code with a just light JS/JQuery helpers to speed things up.
So, the goal was to try and package all current front-end goodies in a Sharepoint Add-In. While there are some articles on this subject, none of them was complete enough and I had to dig deeper when I tried to package Angular app "properly". By properly, I mean using aspx page as a landing page, no manual copying/editing of files in project and so on.
I've picked up quite a few tricks from these two articles, so take a look at them also:
The Setup
There is a bit of setting up environment, so let's get that done.
Visual Studio
I'm assuming that you have Visual Studio 2017. I'm using Professional edition, but you're welcome to try the Community edition. It's required that you have Office templates installed so you can create Sharepoint Add-In project.
You'll need NPM Task Runner Tool for Visual Studio. You can get it at VS Marketplace. We'll use this tool later on to automate the entire build process.
NPM + Webpack
Front end guys love changing their tools and frameworks quite frequently and sometimes it's hard to keep track, at least for me. To make sure Angular4 part of Add-In can be transpiled and properly bundled, I've used Webpack module bundler which does all the heavy lifting for you.
To use both Angular and Webpack, you need to install NPM (Node Package Manager). You can download it from Node JS official site.
After installing NPM, it's time to install Webpack. You can run npm
commands both from console or you can do it from VS Pacakage Manager Console.
To download and install Webpack, run this command:
npm install webpack --save-dev
That's it!
SharePoint
To run this code, you need either fully setup SharePoint farm or you can use Office 365 environment. If you don't have access to either or you can't be bothered to request development site collection from your admins, you can always get Office free 365 demo environment for a month.
Lay the Project Foundations
It's time to get dirty. First, create Sharepoint Add-In project which we will use as a base.
First dialog requires you to enter location of your development site collection and which hosting model you're going to use. For this project, I'm using SharePoint hosted. Screen after that will ask you which SharePoint version you're deploying to. If you're using on-prem then select version you're using. As I said earlier, I'll be using Sharepoint Online.
Project "optimization"
When I say optimization, I think of removing files that we do not need. Right?
The first thing to go is JQuery NuGet which is added by default. In NuGet Package Manager, select JQuery and click on Uninstall button.
Next, we're going to add new SharePoint module and name it "app
" (Right click on project > Add > New Item > Office/Sharepoint > Module). Delete Sample.txt from newly created module.
Create two new folders in project root
: config and src. In src folder, create two new folders: app and assets. In assets, create css and image folders.
We also need to delete existing modules that Sharepoint Add-In template created.
- From Content module, move App.css (yes, it's empty) file to src/assets/css and rename it to styles.css. Then delete Content module
- From Image module, move AppIcon.png to app module. Edit manifest file and set new icon location by using browse button. Then, delete Image module.
- From Pages module, move Default.aspx to src folder. Then, delete
Pages
module. - Delete
Scripts
module
After doing these steps, you should have a project that looks like this:
Create Angular SPA
Depending on how proficient you are with Angular, you can use ng new command from Angular CLI and then copy src folder to the src folder inside project
. Having Angular CLI installed can speed up process when creating new components also.
You can use Visual Studio (Add > New Item > type "npm configuration
" in search) or use npm
from command line prompt which will ask you some questions about project and create file.
npm init
In any case, you'll need to add package.json file to project. For our project configuration needs to list everything this project is going to use, so edit package.json and make sure it looks like this:
{
"name": "sp-angular4-addin",
"version": "1.0.0",
"description": "Angular observable grid for Sharepoint list",
"scripts": {
"build": "webpack --config config/webpack.dev.js --progress --profile --bail"
},
"author": "Nemanja Sarovic",
"license": "ISC",
"dependencies": {
"@angular/animations": "~4.0.3",
"@angular/common": "~4.0.3",
"@angular/compiler": "~4.0.3",
"@angular/core": "~4.0.3",
"@angular/forms": "~4.0.3",
"@angular/http": "~4.0.3",
"@angular/platform-browser": "~4.0.3",
"@angular/platform-browser-dynamic": "~4.0.3",
"@angular/router": "~4.0.3",
"ag-grid": "^16.0.1",
"ag-grid-angular": "^16.0.0",
"camljs": "2.6.2",
"core-js": "^2.4.1",
"zone.js": "~0.8.5"
},
"devDependencies": {
"@angular/cli": "^1.6.7",
"@types/core-js": "^0.9.41",
"@types/node": "^6.0.45",
"@types/sharepoint": "2016.1.0",
"angular2-template-loader": "^0.6.2",
"awesome-typescript-loader": "^3.4.1",
"css-loader": "^0.26.1",
"extract-text-webpack-plugin": "^2.0.0-beta.5",
"file-loader": "^0.9.0",
"handlebars": "^4.0.11",
"handlebars-loader": "^1.6.0",
"html-loader": "^0.4.3",
"html-webpack-plugin": "^2.30.1",
"lodash": "^4.17.5",
"node-sass": "^4.7.2",
"null-loader": "^0.1.1",
"raw-loader": "^0.5.1",
"rxjs": "^5.0.2",
"sass-loader": "6.0.6",
"style-loader": "^0.13.1",
"typescript": "2.4.0",
"webpack": "^2.2.1",
"webpack-dev-server": "2.4.1",
"webpack-merge": "^3.0.0"
}
}
Configure TypeScript
We need to create Create tsconfig.json file in src folder. This will tell TypeScript how to transpile ts files to js.
{
"compilerOptions": {
"target": "es5",
"module": "commonjs",
"moduleResolution": "node",
"sourceMap": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"lib": [
"es2016",
"dom"
],
"noImplicitAny": false,
"suppressImplicitAnyIndexErrors": true,
"types": [
"node",
"core-js",
"sharepoint"
],
"typeRoots": [
"../node_modules/@types"
]
}
}
Configure WebPack
I'll explain some of webpack configuration later on, here we're going to create config files.
In root of a project, create webpack.config.js. This file should have only one line which points WebPack to the actual configuration file.
module.exports = require('./config/webpack.dev.js');
In config folder, we need to create three more files that ensure that WebPack can bundle necessary files.
First, create helpers.js file.
var path = require('path');
var _root = path.resolve(__dirname, '..');
function root(args) {
args = Array.prototype.slice.call(arguments, 0);
return path.join.apply(path, [_root].concat(args));
}
exports.root = root;
Create webpack.common.js. Entry points are defining js files in which webpack will bundle files, and modules define what transformation tools webpack is going to use when parsing project files.
var webpack = require('webpack');
var HtmlWebpackPlugin = require('html-webpack-plugin');
var ExtractTextPlugin = require('extract-text-webpack-plugin');
var helpers = require('./helpers');
module.exports = {
entry: {
'polyfills': './src/polyfills.ts',
'vendor': './src/vendor.ts',
'app': './src/main.ts'
},
resolve: {
extensions: ['.ts', '.js']
},
module: {
rules: [
{
test: /\.ts$/,
loaders: [
{
loader: 'awesome-typescript-loader',
options: { configFileName: helpers.root('src', 'tsconfig.json') }
}, 'angular2-template-loader'
]
},
{ test: /\.html$/, loader: 'html-loader' },
{
test: /\.(png|jpe?g|gif|svg|woff|woff2|ttf|eot|ico)$/,
loader: 'file-loader?name=assets/[name].[hash].[ext]'
},
{
test: /\.css$/,
exclude: helpers.root('src', 'app'),
loader: ExtractTextPlugin.extract
({ fallbackLoader: 'style-loader', loader: 'css-loader?sourceMap' })
},
{
test: /\.scss$/,
loaders: ["style-loader", "css-loader", "sass-loader"]
},
{
test: /\.css$/,
include: helpers.root('src', 'app'),
loader: 'raw-loader'
},
{
test: /\.hbs$/,
loader: 'handlebars-loader'
}
]
},
plugins: [
new webpack.ContextReplacementPlugin(
/angular(\\|\/)core(\\|\/)@angular/,
helpers.root('./src'),
{}
),
new webpack.optimize.CommonsChunkPlugin({
name: ['app', 'vendor', 'polyfills']
}),
new HtmlWebpackPlugin({
filename: 'Default.aspx',
template: '!!handlebars-loader!src/Default.aspx',
inject: false
})
]
};
Finally, create webpack.dev.js.
var webpackMerge = require('webpack-merge');
var ExtractTextPlugin = require('extract-text-webpack-plugin');
var commonConfig = require('./webpack.common.js');
var helpers = require('./helpers');
module.exports = webpackMerge(commonConfig, {
devtool: 'source-map',
output: {
path: helpers.root('app'),
publicPath: '',
filename: '[name].js',
chunkFilename: '[id].chunk.js'
},
plugins: [
new ExtractTextPlugin('[name].css')
],
devServer: {
historyApiFallback: true,
stats: 'minimal'
}
});
Configure Task Runner
In Task Runner Explorer (Ctrl+Alt+Bkspce), there should be your addin listed. Right click on build command in left pane and select Bindings/Before Build.
This way, we will ensure that before VS starts compile and build process of .NET code for Add-In, WebPack can initiate TypeScript transpilation, bundling and minification.
Let's Write Some Code!
Before we do that, let's run npm
to get all required js files that we listed in package.js. Open cmd
in project folder and run:
npm install
When npm
finishes downloading files, let's create our first TypeScript files.
Ok, now it's time to write the actual code. :)
In src folder, create main.ts. Its entry point for Angular SPA and all it does, apart from importing required modules, is calling module named AppModule
.
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { enableProdMode } from '@angular/core';
import { AppModule } from './app/app.module';
import 'rxjs/add/operator/toPromise';
import 'rxjs/add/operator/map';
if (process.env.ENV === 'production') {
enableProdMode();
}
document.addEventListener('DOMContentLoaded', _ => {
platformBrowserDynamic().bootstrapModule(AppModule)
});
In src folder, create polyfills.ts. Pollyfills make sure that all browsers provide the same level of support you need.
import 'core-js/es6';
import 'core-js/es7/reflect';
require('zone.js/dist/zone');
if (process.env.ENV === 'production') {
} else {
Error['stackTraceLimit'] = Infinity;
require('zone.js/dist/long-stack-trace-zone');
}
In the same src folder, create vendor.ts. This file imports everything you need from js resources. As you can see, we are using Angular framework, RxJS for observable pattern and ag-Grid
styles for grid data display.
WebPack will pickup on that we have CSS files linked and it'll bundle those into vendor.css so we can use those styles inside our Add-In without worrying if our SharePoint module has reference to those files, did we put that in Style
Library and all those obstacles you'll need to overcome in old school Sharepoint WebPart deployment. Lovely.
import '@angular/platform-browser';
import '@angular/platform-browser-dynamic';
import '@angular/core';
import '@angular/common';
import '@angular/http';
import '@angular/router';
import 'rxjs';
import '../node_modules/ag-grid/dist/styles/ag-grid.css'
import '../node_modules/ag-grid/dist/styles/ag-theme-fresh.css';
As you can see, main.ts bootstraps AppModule
module. Let's define that module - create app.module
in /src/app folder.
In AppModule
, we import our SpListModule
which holds SpListComponent
component where all the work is done.
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { CommonModule } from '@angular/common';
import { HttpModule } from '@angular/http'
import { FormsModule } from '@angular/forms';
import { APP_BASE_HREF } from '@angular/common';
import { SpListComponent } from './components/splist/splist.component';
import { SpListModule } from './components/splist.module';
import { AgGridModule } from "ag-grid-angular/main";
@NgModule({
imports: [
BrowserModule,
HttpModule,
CommonModule,
FormsModule,
SpListModule,
AgGridModule.withComponents([])
],
exports: [
],
declarations: [
],
providers: [{
provide: APP_BASE_HREF,
useValue: '/'
}],
bootstrap: [SpListComponent]
})
export class AppModule { }
Now, create components folder in /src/app folder. In that folder, create another folder named splist. Inside of it, we will create our Angular component. You can use Angular CLI to create components which will create four files that hold actual component code, component html, component styles and component tests. I'm doing that manually as you can see.
In /src/app/components/splist folder, create splist.component.html file. This markup creates agGrid component which will be doing all the heavy lifting for us.
<div style="width: 800px;">
<ag-grid-angular #agGrid style="width: 100%; height: 350px;"
class="ag-theme-fresh"
[gridOptions]="gridOptions"
[columnDefs]="columnDefs"
[rowData]="rowData">
</ag-grid-angular>
</div>
In the same folder, create splist.component.ts.
This code defines grid columns and loads data from MyListService
using MyListItem
definition. Everything happens in the constructor where data is loaded for initial display and then polled from service every minute. To enable agGrid
to differentiate records, set RowNodeId
value.
import { Component, OnInit } from '@angular/core';
import { Observable } from 'rxjs/Observable';
import { GridOptions } from "ag-grid/main";
import { MyListService } from '../../shared/services/MyListService'
import { MyListItem } from '../../shared/models/MyListItem';
@Component({
selector: 'my-app',
templateUrl: './splist.component.html',
providers: [MyListService]
})
export class SpListComponent {
gridOptions: GridOptions;
private rowData: MyListItem[];
initialRowData$;
rowDataUpdates$;
constructor(myService: MyListService) {
this.rowDataUpdates$ = myService.getItemsChanged();
this.initialRowData$ = myService.getItems();
this.gridOptions = <GridOptions>{
enableRangeSelection: true,
enableColResize: true,
columnDefs: this.createColumnDefs(),
getRowNodeId: function (data) {
return data.Id;
},
onGridReady: () => {
this.initialRowData$.subscribe((initial) => {
if (this.gridOptions.api) {
this.gridOptions.api.setRowData(initial);
}
});
this.rowDataUpdates$.subscribe((updates) => {
if (this.gridOptions.api) {
console.log("update " + JSON.stringify(updates));
this.gridOptions.api.updateRowData({ update: updates })
}
});
this.gridOptions.api.sizeColumnsToFit();
}
};
}
private createColumnDefs() {
return [
{ headerName: "ID", field: "Id", width: 70 },
{ headerName: "Title", field: "Title", width: 280 },
{
headerName: "Amount", field: "Amount", width: 100,
cellClass: 'cell-number',
valueFormatter: this.numberFormatter,
cellRenderer: 'animateShowChange'
},
{ headerName: "Urgent", field: "Urgent", width: 280 }
]
}
numberFormatter(params) {
if (typeof params.value === 'number') {
return params.value.toFixed(2);
} else {
return params.value;
}
}
}
Ok. That's representation part done. Now, let's create model and service.
Create shared folder in /src/app folder. In that folder, create models and services folders.
Inside /src/app/shared/models folder, create MyListItem.ts. This is where we define our model that corresponds to SPList
structure (we haven't still created that, I'll get to that later on). It's a simple class since our demo list is also pretty simple.
export class MyListItem {
public Id: number;
public Title: string;
public Amount: number;
public Urgent: string;
}
Now to the service part. Create MyListService.ts in /src/app/shared/services folder. This is where the actual data from Sharepoint is pulled. I'm using CSOM and decided not to complicate matters by including CAML queries even I've included camljs in project.json just in case I decide to provide paginated data for example.
As in regular CSOM, you've got to get your context by executing SP.ClientContext
. The only difference between getItems
and getItemsChanged
is in using RxJS Observables which sets polling time to 60s by using interval.
I'm just showing how monitoring of SP List can be done, but in reality polling SharePoint this often is not best practice. In realistic scenario, I would use more logic to prevent unnecessary load on SP farm. Probably the best bet is using SP.LastItemModifiedDate
.
import { Injectable } from '@angular/core';
import { Observable, Subject } from 'rxjs';
import { MyListItem } from '../models/MyListItem';
import { cloneDeep } from 'lodash'
export class MyListService {
private spHostUrl: string;
private clientContext: SP.ClientContext;
private appContext: SP.AppContextSite;
private listName = "Shopping List";
private listContext: SP.List;
constructor() {
SP.SOD.executeFunc('sp.js', 'SP.ClientContext', () => {
this.spHostUrl = GetUrlKeyValue('SPHostUrl');
this.clientContext = SP.ClientContext.get_current();
this.appContext = new SP.AppContextSite(this.clientContext, this.spHostUrl);
});
}
public getItems(): Observable<MyListItem[]> {
let self = this;
let caml = SP.CamlQuery.createAllItemsQuery();
let agItems: MyListItem[] = [];
this.listContext = this.appContext.get_web().get_lists().getByTitle(this.listName);
let listItems = self.listContext.getItems(caml);
self.clientContext.load(listItems);
return Observable.create((observer) => {
self.clientContext.executeQueryAsync(
() => {
for (let index = 0; index < listItems.get_count(); index++) {
let item = listItems.get_item(index);
let fieldValues = item.get_fieldValues();
agItems.push({
Id: fieldValues.ID,
Title: fieldValues.Title,
Amount: fieldValues.Amount,
Urgent: fieldValues.Urgent
});
}
},
(sender: any, args: SP.ClientRequestFailedEventArgs) => {
console.log("Service error: ", args);
}
);
});
}
public getItemsChanged(): Observable<MyListItem[]> {
let self = this;
let caml = SP.CamlQuery.createAllItemsQuery();
let agItems: MyListItem[] = [];
this.listContext = this.appContext.get_web().get_lists().getByTitle(this.listName);
let listItems = self.listContext.getItems(caml);
self.clientContext.load(listItems);
return Observable.create((observer) => {
const interval = setInterval(() => {
agItems = [];
let cnt = 0;
self.clientContext.executeQueryAsync(
() => {
for (let index = 0; index < listItems.get_count(); index++) {
let item = listItems.get_item(index);
let fieldValues = item.get_fieldValues();
agItems.push({
Id: fieldValues.ID,
Title: fieldValues.Title,
Amount: fieldValues.Amount,
Urgent: fieldValues.Urgent
});
}
},
(sender: any, args: SP.ClientRequestFailedEventArgs) => {
console.log("Service error: ", args);
}
);
observer.next(cloneDeep(agItems));
}, 20000);
return () => clearInterval(interval);
});
}
}
Rxjs provides an elegant way to handle async data retrieval even if using interval with observables seems clumsy. Sure, I could have used promises and async/await to get data from SharePoint and then in ngInit
, initialize interval for polling data.
ngOnInit() {
this.myInterval = setInterval(() => {
myService.getItems();
}, 60000);
}
ngOnDestroy() {
clearInterval(myInterval);
}
}
Of course, code to use promises should be slightly different. After creating promise, I'd use async/await to get data.
public async getItemsSync() {
let items: MyListItem[] = [];
try {
items = await this.getItemsPromised();
} catch (error) {
console.log("getItemSync error");
}
return items;
}
protected getItemsPromised(): Promise<MyListItem[]> {
let self = this;
let spItems: MyListItem[] = [];
let caml = SP.CamlQuery.createAllItemsQuery();
this.listContext = this.appContext.get_web().get_lists().getByTitle(this.listName);
let listItems = self.listContext.getItems(caml);
self.clientContext.load(listItems);
let promise = new Promise<MyListItem[]>((resolve, reject) => {
self.clientContext.executeQueryAsync(() => {
for (let index = 0; index < listItems.get_count(); index++) {
let item = listItems.get_item(index);
let fieldValues = item.get_fieldValues();
spItems.push({
Id: fieldValues.ID,
Title: fieldValues.Title,
Amount: fieldValues.Amount,
Urgent: fieldValues.Urgent
});
}
resolve(spItems);
},
(sender: any, args: SP.ClientRequestFailedEventArgs) => {
console.log("Service error: ", args);
reject();
}
);
});
return promise;
}
You should end up with a structure like this:
SharePoint List
Lastly, we need to create list that we'll be monitoring. That should be easy enough? List structure is in the picture below:
We need to make sure our Add-In has enough rights to access SPWeb resources. Edit AppManifest.xml and on Permissions tab, set Scope to Web and Permissions to Read. If you plan to edit SPList
data from grid, you'll need to elevate that privilege to Write
which equals to Contributor
in onprem SharePoint permissions.
WebPack to the Rescue
Ok. So we have everything in place. We've got Angular code, we have grid control, we have service to retrieve data. Cool.
The problem is that when ts code is transpiled and packaged, you need to have all necessary js and css files included inside SharePoint Add-In entry page. That should pose no big problem, but the issue is that by default, Angular uses underscore parser to perform injection in HTML. Of course, aspx pages have a lot of <% %>
ASP.NET tags and you need to configure underscore to use different tags. Or you can use different loader.
I've used handlebars (which I love ever since I've first found about them) and since WebPack is very flexible, I've loaded handlebars-loader and used it to parse aspx page and do the injection. Another problem is that standard injection template inserts <head>
tag if it's missing from file where loader is injecting links, so I had to make my own template and completely bypass WebPack out of the box. Luckily, everything is easily customizable (after you dig through lot of documentation to learn how to do it) and we've got brand new SharePoint Page template.
If you check WebPack that we created in the beginning, you'll notice this code in plugins section:
new HtmlWebpackPlugin({
filename: 'Default.aspx',
template: '!!handlebars-loader!src/Default.aspx',
inject: false
})
This is where WebPack uses handlebars-loader
and uses it to process src/Default.aspx file. You remember Default.aspx file? The one we copied from Pages module? Ok. Let's modify it so WebPack and handlebars-loader
can make sense of it.
Microsoft page template says where to insert CSS and where to insert js links. We need to use handlebars markup and WebPack data to insert it.
So, replace the original part of aspx page inside asp:Content tag PlaceHolderAdditionalPageHead
that looks like this:
<!--
<link rel="Stylesheet" type="text/css" href="../Content/App.css" />
<!--
<script type="text/javascript" src="../Scripts/App.js"></script>
Replace it with this markup. You'll notice that js tags have defer
property to ensure late loading and allowing SharePoint scripts to load. We could have used SP.SOD
calls, but I haven't had any issues this way.
<!--
{{#each htmlWebpackPlugin.files.css}}
<link href="{{this}}" rel="stylesheet">
{{/each}}
<!--
{{#each htmlWebpackPlugin.files.chunks}}
<script type="text/javascript" src="{{this.entry}}" defer></script>
{{/each}}
Also, remove the following line from top of content tag or you'll get warnings that you're missing jQuery library.
<script type="text/javascript" src="../Scripts/jquery-1.9.1.min.js"></script>
Final Packaging
At last, we need to have everything neatly packaged in SharePoint module. Select Show All Files in project and then include all of them in project (Right click, Include In Project).
That should be it. Right click on project and deploy it. Do not forget to check AppManifest.xml to ensure app/Default.aspx is your start page and make sure that icon is linked to .ico file we moved at the start of project.
SharePoint Online should ask you do you trust Add-In and after that, you'll get Angular4 AddIn with agGrid using Observables to display List data.
TODO
- Edit/Update list data
- Make list polling less load heavy
History
- 1.0 - Initial version, 1.3.2018