Using MIP SDK in SharePoint – II

This post is in continuation with how to use MIP SDK in C#. Please go through it first to cover some basic concepts like, setting up your system, nuget required for MIP and registering Azure AD app which will be used in the code below too.

My case scenario was to analyze a protected file present in SharePoint Document Library and set/remove protection on it depending on the contents of the document. In case you are not aware, we can use Azure Information Protection or Unified Labeling in SharePoint too. It’s pretty straight-forward and more details can be found here.

In comparison to the code I uploaded on git, there are few basic changes for using MIP SDK in Provider Hosted add-ins. Instead of relying on file path, we will use file stream from SharePoint and similarly, upload that stream back to SharePoint. I am assuming that you all are aware of provider hosted add-ins and have gone through the part one of this post. I am listing the changes below while rest of the stuff remains the same.

  1. Create a provider hosted add-in using Visual Studio. I have used SharePoint Online and MVC template for my work
  2. In AppManifest.xml give proper permission to access libraries of your target site collection
  3. Now coming to HomeController, InvokeMIP method, the first difference is how will we get tenant id of our host web. Instead of finding it in Claims, we will get it using TokenHelper’s method GetRealmFromTargetUrl
  4. Apart from this, we will create SharePoint context to get file stream of the uploaded file where we need to perform MIP operations
  5. Using this stream, MIP file handler will be created
  6. The output will be collected in memory stream
  7. Output Stream will be used to upload the changed document back to SharePoint (For more details on how to upload large files in provider hosted add-in, check this post here)

The complete code will look like below:

private void InvokeMIP()
{
//this client id is for Azure AD app and NOT of SharePoint app
private static readonly string clientId = ConfigurationManager.AppSettings["ida:ClientId"];
private static readonly string appName = ConfigurationManager.AppSettings["app:Name"];
private static readonly string appVersion = ConfigurationManager.AppSettings["app:Version"];
private static readonly string mipData = ConfigurationManager.AppSettings["MipData"];
private readonly string mipPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, mipData);
try
{
SharePointContext spContext = SharePointContextProvider.Current.GetSharePointContext(HttpContext);
Uri sharepointUrl = new Uri(spContext.SPHostUrl.AbsoluteUri.ToString());
//fetch tenant id to be used in getting access token
string tenantId = TokenHelper.GetRealmFromTargetUrl(sharepointUrl).ToString();
// Set path to bins folder.
var path = Path.Combine(
Directory.GetParent(Path.GetDirectoryName(new Uri(System.Reflection.Assembly.GetExecutingAssembly().CodeBase).LocalPath)).FullName,
Environment.Is64BitProcess ? "bin\\x64" : "bin\\x86");
MIP.Initialize(MipComponent.File, path);
ApplicationInfo appInfo = new ApplicationInfo()
{
ApplicationId = clientId,
ApplicationName = appName,
ApplicationVersion = appVersion
};
AuthDelegateImplementation authDelegate = new AuthDelegateImplementation(appInfo, tenantId);
var profileSettings = new FileProfileSettings(mipPath, false, authDelegate, new ConsentDelegateImplementation(), appInfo, LogLevel.Trace);
//create MIP File Profile
var fileProfile = Task.Run(async () => await MIP.LoadFileProfileAsync(profileSettings)).Result;
//create MIP Engine and add it to the file profile
var engineSettings = new FileEngineSettings("", "", "en-US");
engineSettings.Identity = new Identity("admin@tenant.com"); //important to paas a valid admin account here
var fileEngine = Task.Run(async () => await fileProfile.AddEngineAsync(engineSettings)).Result;
//client context to get file stream
using (var clientContext = spContext.CreateUserClientContextForSPHost())
{
if (clientContext != null)
{
var list = clientContext.Web.Lists.GetByTitle("Documents");
clientContext.Load(list, i => i.RootFolder);
clientContext.ExecuteQuery();
var listItem = list.GetItemById(1);
clientContext.Load(listItem, l => l.File, l => l.File.ServerRelativeUrl);
var fileData = listItem.File.OpenBinaryStream();
clientContext.ExecuteQuery();
if (fileData.Value != null)
{
var fileStream = fileData.Value;
//create file handler with the stream from SharePoint
var handler = Task.Run(async () => await fileEngine.CreateFileHandlerAsync(fileStream, listItem.File.ServerRelativeUrl, true)).Result;
//if needed, can check the existing labels and protection details using below two lines
var labelDetails = handler.Label;
var protectionDetails = handler.Protection;
LabelingOptions labelingOptions = new LabelingOptions()
{
AssignmentMethod = AssignmentMethod.Privileged, //because I am removing a high priority label
IsDowngradeJustified = true,
JustificationMessage = "Lowering label"
};
handler.DeleteLabel(labelingOptions);
//output stream where new file will be stored
Stream outputStream = new MemoryStream();
var result = Task.Run(async () => await handler.CommitAsync(outputStream)).Result;
//This is neccessary else you will get following error
//Specified argument was out of the range of valid values.\r\nParameter name: bytesToCopy
outputStream.Position = 0;
//You can create a new file handler using output stream here
//and use it to apply different label using newhandler.SetLabel() method
//and then upload it to SharePoint too
FileCreationInformation decryptedFile = new FileCreationInformation();
decryptedFile.ContentStream = outputStream;
decryptedFile.Url = "decrypted.docx"; //change this according to your need
decryptedFile.Overwrite = true;
var uploadedFile = list.RootFolder.Files.Add(decryptedFile);
clientContext.Load(uploadedFile);
clientContext.ExecuteQuery();
}
}
}
}
catch(Exception ex)
{
}
}

view raw
HomeController.cs
hosted with ❤ by GitHub

Make sure you have registered Azure AD app with proper permissions and your SharePoint App has correct permissions too.

P.S. Please note that MIP SDK does not support content marking (watermark, header, footer). You can apply a label with watermark but it wont be reflected until you open the document and save it in office again. More details here

Happy Coding!

Update November 9, 2019:

Now you can enable sensitivity labels for file present in OneDrive and SharePoint. The feature comes with some new capabilities and some limitations and is currently in public preview. Click here to learn more about this.

Knockout.js with Sharepoint 2013 App – Part III

In my previous post, we saw how to add items in a SP list using KO. This post will focus on removing items from a list. The code snippet at the end combines all the three posts. Using it; you can display, add and remove items from list. Besides that we send only updated (i.e. added or removed) data to server side. Going on the same lines, we can implement the “edit” functionality also. Right now, I am skipping it.

For reference, the other two posts are:

  1. Part I – Displaying Data from SP list. 
  2. Part II – Adding Data to SP list.
  3. Part III – Removing Data from SP list. (this one)

To add remove functionality, a “Remove this” link is added after every row in the table. So, the html looks like:

KO-Remove-1

$root.RemoveCountries will update the view model. Here $root is the main view model in the topmost/parent context. It will update the whole table by removing the current row.

“Id” column of sharepoint list is also included in the “CountryList” class to delete items by id. For newly created rows the id is assigned to ‘0’. After every update to the server, the “changedCountries” array is emptied.

The final result will look like,

KO-Remove-2

Now, the final collaborated code is:

/* Place custom styles below */
.tableStyle {
border-collapse:collapse;
border: 1px solid black;
text-align:center;
width:50%;
padding:15px;
}
.messages {
display:none;
font-weight:bold;
}
#success {
color:green;
}
#failure {
color:red;
}

view raw
App.css
hosted with ❤ by GitHub

'use strict';
//global variables
var hostWebUrl;
var appWebUrl;
var listItems;
var list;
var context;
var completeCountryList;
//this array is to keep tab of those countries that are added or removed
var changedCountries = [];
$(document).ready(function () {
//get the url of app web and host web
hostWebUrl = QS("SPHostUrl");
appWebUrl = QS("SPAppWebUrl");
LoadData();
});
//class for saving the countries and their states
function CountryList(countryName, stateName, id, isUpdated) {
var self = this;
self.CountryName = countryName;
self.StateName = stateName;
//Sharepoint List Item ID
self.Id = id;
//IsUpdated is just to keep tab of rows that are added/removed from the table. This is not a SP column
self.IsUpdated = isUpdated;
}
//View Model to combine data from list into the format which view expects
function CountryListViewModel() {
var self = this;
self.Countries = ko.observableArray([]);
self.AddCountries = function (countryName, stateName, id, isUpdated) {
self.Countries.push(new CountryList(countryName, stateName, id, isUpdated));
//hide success message
$("#success").hide();
}
self.RemoveCountries = function (country) {
//to keep tab of countries that are removed
changedCountries.push(country);
self.Countries.remove(country);
//hide success message
$("#success").hide();
}
//Update SP list with the changes in view model
self.UpdateSPList = function () {
/* this variable checks if empty string is passed in either of the state or country name
in this case the value is not updated in SP */
var emptyValues = false;
//create array of only those rows that are updated
$.each(self.Countries(), function () {
if (this.IsUpdated == true) {
//if either of the country or state name is blank skip the entry
if (this.CountryName.length > 0 && this.StateName.length > 0) {
$("#tdError").hide();
changedCountries.push(this);
this.IsUpdated = false;
}
else {
emptyValues = true;
}
}
});
//show error message if even one of the entry is blank
if (emptyValues) {
$("#failure").text("Empty values are not updated in sharepoint");
$("#failure").show();
}
else {
$("#failure").hide();
}
//if there are no updates
if (changedCountries.length == 0) {
alert("No Items to update");
}
else {
SubmitDataToSP(changedCountries);
}
}
}
//function to update corresponding sharepoint list
function SubmitDataToSP(changedCountries) {
$.each(changedCountries, function () {
//for newly added entries id will be 0
if (this.Id == 0) {
var itemCreateInfo = new SP.ListItemCreationInformation();
var newListItem = list.addItem(itemCreateInfo);
newListItem.set_item("CountryName", this.CountryName);
newListItem.set_item("StateName", this.StateName);
newListItem.update();
context.load(newListItem);
}
else {
//if id is not 0 that means this item should be deleted
var deletedItem = list.getItemById(this.Id);
deletedItem.deleteObject();
}
context.executeQueryAsync(ItemAdded, Failed);
});
//empty the changed countries array for further processing
changedCountries.length = 0;
}
function ItemAdded() {
//give a success message
$("#success").text("Item are successfull updated");
$("#success").show();
}
//function which apply KO bindings and make a call to SP using CSOM
function LoadData() {
completeCountryList = new CountryListViewModel();
//get items from SP list
GetList();
ko.applyBindings(completeCountryList);
}
function GetList() {
context = new SP.ClientContext(appWebUrl);
//No need to use SP.RequestExecutor.js for cross domain calls to host web in SP Hosted web
/* var factory = new SP.ProxyWebRequestExecutorFactory(appWebUrl);
context.set_webRequestExecutorFactory(factory); */
var hostContext = new SP.AppContextSite(context, hostWebUrl);
list = hostContext.get_web().get_lists().getByTitle("KnockoutList");
var camlQuery = new SP.CamlQuery();
camlQuery.set_viewXml("<View><RowLimit>20</RowLimit></View>");
listItems = list.getItems(camlQuery);
context.load(listItems, "Include(Id, CountryName, StateName)");
context.executeQueryAsync(ListItemsLoaded, Failed);
}
function ListItemsLoaded(sender, args) {
var enumerator = listItems.getEnumerator();
while (enumerator.moveNext()) {
var currentItem = enumerator.get_current();
completeCountryList.AddCountries(currentItem.get_item("CountryName"), currentItem.get_item("StateName"), currentItem.get_id(), false);
}
}
function Failed(sender, args) {
alert(args.get_message());
}
function QS(name) {
name = name.replace(/[\[]/, "\\\[").replace(/[\]]/, "\\\]");
var regex = new RegExp("[\\?&]" + name + "=([^&#]*)"),
results = regex.exec(location.search);
return results == null ? "" : decodeURIComponent(results[1].replace(/\+/g, " "));
}

view raw
App.js
hosted with ❤ by GitHub

<div id="divCountryList">
<h2>Country List</h2>
<br />
<table id="tblCountryList" border="1" class="tableStyle">
<thead>
<tr>
<th>Country</th>
<th>State</th>
<th></th>
</tr>
</thead>
<!– Iterating through every list item using foreach of KO –>
<tbody data-bind="foreach: Countries">
<tr>
<td><input data-bind="value: CountryName" /></td>
<td><input data-bind="value: StateName" /></td>
<td><a href="#" data-bind="click: $root.RemoveCountries">Remove this</a></td>
</tr>
</tbody>
</table>
<br />
<button data-bind="click: AddCountries.bind($data, '', '', 0, true)">Add State</button>
<button data-bind="click: UpdateSPList">Submit to SharePoint</button>
<br />
<div id="success" class="messages"></div>
<br />
<div id="failure" class="messages"></div>
</div>

view raw
default.html
hosted with ❤ by GitHub

 

Note: In sharepoint hosted app, we do not need SP.RequestExecutor.js to communicate with host web that means cross-domain calls to host web is allowed without this javascript file. The code is updated for this.

Summary: KO is an amazing library that makes life easier by segregating the display logic with the business logic. Using it in SP, makes the js code neat and simple to understand. The only flip side is, if there are changes at the server side, then UI is not automatically updated until a refresh is performed. Regardless of this, KO is a library we all should learn.

P.S.: This example is by no means perfect or bug free. There are few loopholes like:

  1. User can edit the existing items but this is not handled in the code.
  2. After adding a new item to SP list the view model is not updated unless user do a refresh.
  3. Only 20 items are fetched and no pagination is implemented.

But the code is for demo purpose only. You can extend it in any way you want. Hope it helps!!

Another good blogs on KO (also referenced here):

  1. Knockout.js Simplified
  2. Using Knockout.js in a sharepoint context

Knockout.js with Sharepoint 2013 App – Part II

In my previous post, we saw how to display items from a sharepoint list using KO. In this post, we will see how to add items in the list. This demo is in continuation with the previous one.

For your reference, the posts in this series are:

  1. Part I – Displaying Data from SP list. 
  2. Part II – Adding Data to SP list. (this one)
  3. Part III – Removing Data from SP list.

First, lets go through the changes made in the previous code for incorporating this functionality.

HTML Changes:

  1. A button “Add State” is added. This button updates the view model and add a new country-state in the existing table. It does not update the SP list.
  2. Another button “Submit to Sharepoint” is added. This button actually updates the data in the sharepoint list.
  3. Now the country and states are displayed in text box so that user can add new ones. Edit functionality is not included in this post.
  4. Two divs are added for displaying success and failure messages.

CSS Changes:

  1. styling is added for success and failure messages. Not much changes are done here.

Javascript Changes:

  1. Another property “IsUpdated” is added in  the “CountryList” class. This property is used to keep tab of newly created items in the view model. Using this, we will send only changed items to the server and not the complete list of data.
  2. In the view model, “UpdateSPList” function is added. This function will be called on click of “Submit to SP” button.
  3. “changedCountries” array holds all the newly added items. “emptyValues” is a boolean variable which maintains if any of the country name or state name is blank. If yes, then that entry is not sent to SP and a message is displayed to the user.
  4. “SubmitDataToSP” is a function which actually uses sharepoint CSOM and adds new items in the list.
  5. On click of “Add State” button a function “AddCountries” of view model is called. The parameters passed are $data (which is the current item inside a foreach loop), country name, state name and true as the value of “IsUpdated” property. For more details on ‘click’ event, check this link.
  6. Similarly on click of “Submit to SP” we call, “UpdateSPList” method of the view model.

So, now the complete code is as follows:

.tableStyle {
border-collapse:collapse;
border: 1px solid black;
text-align:center;
width:50%;
padding:15px;
}
.messages {
display:none;
font-weight:bold;
}
#success {
color:green;
}
#failure {
color:red;
}

view raw
App.css
hosted with ❤ by GitHub

'use strict';
//global variables
var hostWebUrl;
var appWebUrl;
var listItems;
var list;
var context;
var completeCountryList;
$(document).ready(function () {
//get the url of app web and host web
hostWebUrl = QS("SPHostUrl");
appWebUrl = QS("SPAppWebUrl");
LoadData();
});
//class for saving the countries and their states
function CountryList(countryName, stateName, isUpdated) {
var self = this;
self.CountryName = countryName;
self.StateName = stateName;
//IsUpdated is just to keep tab of rows that are added/removed from the table. This is not a SP column
self.IsUpdated = isUpdated;
}
//View Model to combine data from list into the format which view expects
function CountryListViewModel() {
var self = this;
self.Countries = ko.observableArray([]);
self.AddCountries = function (countryName, stateName, isUpdated) {
self.Countries.push(new CountryList(countryName, stateName, isUpdated));
//hide success message
$("#success").hide();
}
self.RemoveCountries = function (country) {
self.Countries.remove(country);
//hide success message
$("#success").hide();
}
//Update SP list with the changes in view model
self.UpdateSPList = function () {
var changedCountries = [];
var emptyValues = false;
//create array of only those rows that are updated
$.each(self.Countries(), function () {
if (this.IsUpdated == true) {
//if either of the country or state name is blank skip the entry
if (this.CountryName.length > 0 && this.StateName.length > 0) {
$("#tdError").hide();
changedCountries.push(this);
this.IsUpdated = false;
}
else {
emptyValues = true;
}
}
});
//show error message if even one of the entry is blank
if (emptyValues) {
$("#failure").text("Empty values are not updated in sharepoint");
$("#failure").show();
}
else {
$("#failure").hide();
}
//if there are no updates
if (changedCountries.length == 0) {
alert("No Items to update");
}
else {
SubmitDataToSP(changedCountries);
}
}
}
//function to update corresponding sharepoint list
function SubmitDataToSP(changedCountries) {
$.each(changedCountries, function () {
var itemCreateInfo = new SP.ListItemCreationInformation();
var newListItem = list.addItem(itemCreateInfo);
newListItem.set_item("CountryName", this.CountryName);
newListItem.set_item("StateName", this.StateName);
newListItem.update();
context.load(newListItem);
context.executeQueryAsync(ItemAdded, Failed);
});
}
function ItemAdded() {
//give a success message
$("#success").text("Item Added");
$("#success").show();
}
//function which apply KO bindings and make a call to SP using CSOM
function LoadData() {
completeCountryList = new CountryListViewModel();
GetList();
ko.applyBindings(completeCountryList);
}
function GetList() {
context = new SP.ClientContext(appWebUrl);
//No need to use SP.RequestExecutor.js for cross domain calls to host web in SP Hosted web
/* var factory = new SP.ProxyWebRequestExecutorFactory(appWebUrl);
context.set_webRequestExecutorFactory(factory); */
var hostContext = new SP.AppContextSite(context, hostWebUrl);
list = hostContext.get_web().get_lists().getByTitle("KnockoutList");
var camlQuery = new SP.CamlQuery();
camlQuery.set_viewXml("<View><RowLimit>20</RowLimit></View>");
listItems = list.getItems(camlQuery);
context.load(listItems, "Include(Id, CountryName, StateName)");
context.executeQueryAsync(ListItemsLoaded, Failed);
}
function ListItemsLoaded(sender, args) {
var enumerator = listItems.getEnumerator();
while (enumerator.moveNext()) {
var currentItem = enumerator.get_current();
completeCountryList.AddCountries(currentItem.get_item("CountryName"), currentItem.get_item("StateName"), false);
}
}
function Failed(sender, args) {
alert(args.get_message());
}
function QS(name) {
name = name.replace(/[\[]/, "\\\[").replace(/[\]]/, "\\\]");
var regex = new RegExp("[\\?&]" + name + "=([^&#]*)"),
results = regex.exec(location.search);
return results == null ? "" : decodeURIComponent(results[1].replace(/\+/g, " "));
}

view raw
App.js
hosted with ❤ by GitHub

<div id="divCountryList">
<h2>Country List</h2>
<br />
<table id="tblCountryList" border="1" class="tableStyle">
<thead>
<tr>
<th>Country</th>
<th>State</th>
</tr>
</thead>
<!– Iterating through every list item using foreach of KO –>
<tbody data-bind="foreach: Countries">
<tr>
<td><input data-bind="value: CountryName" /></td>
<td><input data-bind="value: StateName" /></td>
</tr>
</tbody>
</table>
<br />
<button data-bind="click: AddCountries.bind($data, '', '', true)">Add State</button>
<button data-bind="click: UpdateSPList">Submit to SharePoint</button>
<br />
<div id="success" class="messages"></div>
<br />
<div id="failure" class="messages"></div>
</div>

view raw
default.html
hosted with ❤ by GitHub

The result page will look like:

result

Note:

Please note that this might not be the completely optimized way to achieve this functionality. The goal here is to demo use of KO with sharepoint. In the next post, we will modify this code further to achieve delete functionality.

Hope this helps!!

Knockout.js with Sharepoint 2013 App – Part I

Knockout.js is an amazing javascript library for binding data and generating html at run time. It uses MVVM pattern that is Model-View-ViewModel pattern. This post assumes that you have a basic understanding of knockout and sharepoint client side object model. If not, then go through the amazing set of tutorials present in knockout (KO) site.

I was recently introduced to KO and found it amazing. But I didn’t find much on how to implement it in sharepoint context. So, I decided to look into it and  share with you all my learnings of KO with sharepoint. This post is dedicated to just displaying data from a list in a page using KO. Further posts will focus on adding and removing data too. At the end, we will see if KO is actually a good option for sharepoint or not. The post in the series will be:

  1. Part I – Displaying Data from SP list. (this one)
  2. Part II – Adding Data to SP list.
  3. Part III – Removing Data from SP list.

To start I am using sharepoint 2013 app model. A sharepoint hosted app is created for displaying list’s data in html page present in app context. The structure of the list is:

List for knockout Demo

This is a simple list which contains names of country and their states. Using KO we will display this in a table structure. KO makes the client side code simple and easier to use. HTML, CSS and JS all files are separate and manageable.

So the code for displaying this data is :

.tableStyle {
border-collapse:collapse;
border: 1px solid black;
text-align:center;
width:50%;
padding:15px;
}

view raw
App.css
hosted with ❤ by GitHub

'use strict';
var hostWebUrl;
var appWebUrl;
var listItems
var completeCountryList;
$(document).ready(function () {
//get the url of app web and host web
hostWebUrl = QS("SPHostUrl");
appWebUrl = QS("SPAppWebUrl");
LoadData();
});
//class for saving the countries and their states
function CountryList(countryName, stateName) {
var self = this;
self.CountryName = countryName;
self.StateName = stateName;
}
//View Model to combine data from list into the format which view expects
function CountryListViewModel() {
var self = this;
self.Countries = ko.observableArray([]);
self.AddCountries = function (countryName, stateName) {
self.Countries.push(new CountryList(countryName, stateName));
}
}
//function which apply KO bindings and make a call to SP using CSOM
function LoadData() {
completeCountryList = new CountryListViewModel();
GetList();
ko.applyBindings(completeCountryList);
}
function GetList() {
var context = new SP.ClientContext(appWebUrl);
//No need to use SP.RequestExecutor.js for cross domain calls to host web in SP Hosted web
/* var factory = new SP.ProxyWebRequestExecutorFactory(appWebUrl);
context.set_webRequestExecutorFactory(factory); */
var hostContext = new SP.AppContextSite(context, hostWebUrl);
var list = hostContext.get_web().get_lists().getByTitle("KnockoutList");
var camlQuery = new SP.CamlQuery();
camlQuery.set_viewXml("<View><RowLimit>10</RowLimit></View>");
listItems = list.getItems(camlQuery);
context.load(listItems, "Include(Id, CountryName, StateName)");
context.executeQueryAsync(ListItemsLoaded, ListItemsFailed);
}
function ListItemsLoaded(sender, args) {
var enumerator = listItems.getEnumerator();
while (enumerator.moveNext()) {
var currentItem = enumerator.get_current();
completeCountryList.AddCountries(currentItem.get_item("CountryName"), currentItem.get_item("StateName"));
}
}
function ListItemsFailed(sender, args) {
alert(args.get_message());
}
function QS(name) {
name = name.replace(/[\[]/, "\\\[").replace(/[\]]/, "\\\]");
var regex = new RegExp("[\\?&]" + name + "=([^&#]*)"),
results = regex.exec(location.search);
return results == null ? "" : decodeURIComponent(results[1].replace(/\+/g, " "));
}

view raw
App.js
hosted with ❤ by GitHub

<asp:Content ContentPlaceHolderID="PlaceHolderMain" runat="server">
<div id="divCountryList">
<h2>Country List</h2>
<br />
<table id="tblCountryList" border="1" class="tableStyle">
<thead>
<tr>
<th>Country</th>
<th>State</th>
</tr>
</thead>
<!– Iterating through every list item using foreach of KO –>
<tbody data-bind="foreach: Countries">
<tr>
<td data-bind="text: CountryName"></td>
<td data-bind="text: StateName"></td>
</tr>
</tbody>
</table>
</div>
</asp:Content>

view raw
default.aspx
hosted with ❤ by GitHub

The output page will look like:

KO output 1

This is how we use KO in sharepoint. Notice the object oriented structure of “App.js”.  The code is relatively very clean and easy to understand. In next post we will add items in a list using KO. Hope this helps.