This is part of a series:
- Apps, Apps and Apps… A Story (Step 1)
- Apps, Apps and Apps… A Story (Step 2) — Commenting App — this post
In the previous post we set up the composite SharePoint App + Office App scaffold. Now we’re building a real-world example: a document commenting app that lets reviewers add comments to a Word document, stored in a SharePoint list, and visible directly in the Word task pane.
Note: I’m not a JavaScript specialist and this was built in a few days for demo purposes. Some patterns here won’t win code review awards, but they illustrate the concepts clearly.
Adding the Comments List
Add a Generic List called Comments to the solution. In schema.xml, add a content type with the fields just above <ContentTypeRef ID='0x01'>:
<ContentType ID='0x01003A0615B3B7374E358B9C70938B67229B' Name='CommentsCT'>
<FieldRefs>
<FieldRef ID='{fa564e0f-0c70-4ab9-b863-0177e6ddd247}' Name='Title' />
<FieldRef ID='{B63E2472-AEAB-497C-B2D8-89540E6C2080}' Name='DocumentComment' />
<FieldRef ID='{84FAA816-776B-45A6-913F-31FF54366C1E}' Name='DocumentLookupColumn' />
<FieldRef ID='{F2849C6A-61EE-46A3-BD48-50EE3CAB2FC1}' Name='DocumentFileName' />
<FieldRef ID='{8F910850-62E3-47E9-AF31-4F44DC96F783}' Name='Reviewer' />
</FieldRefs>
</ContentType>
Add the fields in the <Fields> tag:
<Fields>
<Field ID='{fa564e0f-0c70-4ab9-b863-0177e6ddd247}' Type='Text' Name='Title' DisplayName='$Resources:core,Title;' Required='TRUE' MaxLength='255' />
<Field ID='{B63E2472-AEAB-497C-B2D8-89540E6C2080}' Type='Text' Name='DocumentComment' DisplayName='DocumentComment' Required='False' />
<Field ID='{8F910850-62E3-47E9-AF31-4F44DC96F783}' Type='Text' Name='Reviewer' DisplayName='Reviewer' Required='False' />
<Field ID='{F2849C6A-61EE-46A3-BD48-50EE3CAB2FC1}' Type='Text' Name='DocumentFileName' DisplayName='DocumentFileName' Required='False' />
<Field ID='{84FAA816-776B-45A6-913F-31FF54366C1E}' Type='Lookup' Name='DocumentLookupColumn' DisplayName='Document Lookup Column' Required='FALSE' ShowField='Title' List='Lists/ConnectionsDocLib' />
</Fields>
The lookup field is included for future use if you want to switch from filename-based lookup to a proper lookup column.
The Task Pane UI (home.html)
Replace the body content with:
<div id='content-header'>
<div class='padding'><h1>Welcome</h1></div>
</div>
<div id='content-main'>
<div class='padding'>
Select Reviewer:
<select class='select' id='select-reviewer' name='D1'></select>
</div>
<div id='AddComments' class='padding'>
<textarea cols='40' rows='10' id='addCommentText'></textarea>
<br />
<button id='add-comment-to-list'>Add Item to List</button>
</div>
<div id='comments-message'></div>
</div>
CSS for the comment display area in App.css:
#comments-message {
background-color: #818285;
color: #fff;
position: absolute;
width: 100%;
min-height: 80px;
max-height: 300px;
overflow-y: scroll;
right: 0;
z-index: 100;
bottom: 0;
display: none;
}
The JavaScript Object (Revealing Module Pattern)
I’m using the Revealing Module Pattern for the comments list operations.
var CommentsApp = window.CommentsApp || {};
CommentsApp.CommentsList = function () {
var digest;
var appURL;
createItem = function (title, reviewer, comments, filename) {
sp_context = new SP.ClientContext(appURL);
var list = sp_context.get_web().get_lists().getByTitle('Comments');
var comment = list.addItem(new SP.ListItemCreationInformation());
comment.set_item('Title', title);
comment.set_item('DocumentComment', comments);
comment.set_item('Reviewer', reviewer);
comment.set_item('DocumentFileName', filename);
comment.update();
sp_context.executeQueryAsync(
function() { getAllByDocument(filename); },
function(sender, args) { console.error(args.get_message()); }
);
},
getAllByDocument = function (documentName) {
var url = appURL + "/_api/web/lists/getbytitle('Comments')/Items" +
"?$select=Title,ID,DocumentComment,DocumentFileName" +
"&$filter=DocumentFileName eq '" + documentName + "'";
$.ajax({
url: url,
type: 'GET',
headers: { 'accept': 'application/json;odata=verbose' },
success: function (data) {
if (data.d.results) {
$('#comments-message').empty();
data.d.results.forEach(function(item, i) {
$('#comments-message').append(
'<div id="box"><div class="close_box"></div>' +
'<div class="padding">' +
'<div id="comments-message-header">Comment #' + i + '</div>' +
'<div id="comments-message-body">' + item.DocumentComment + '</div>' +
'<div id="commentID" style="visibility:hidden;">' + item.ID + '</div>' +
'</div></div>'
);
});
$('#comments-message').slideDown('fast');
}
}
});
},
removeItem = function (id) {
sp_context = new SP.ClientContext(appURL);
var comment = sp_context.get_web().get_lists()
.getByTitle('Comments').getItemById(id);
comment.deleteObject();
sp_context.executeQueryAsync();
},
setAppUrl = function (appweburl) { appURL = appweburl; }
return {
createComment: createItem,
deleteComment: removeItem,
setAppWebUrl: setAppUrl,
getAllByDocumentName: getAllByDocument
};
}();
Office.initialize + Initialization Flow
Office.initialize = function (reason) {
$(document).ready(function () {
// Close box handler — deletes comment from list
$(document).on('click', '.close_box', function () {
var id = $(this).parent().find('#commentID').text();
$(this).parent().remove();
CommentsApp.CommentsList.deleteComment(id);
});
if (Office.context.document.url === '') {
// New unsaved document — can't comment without a filename
$('#comments-message').text('Save the document first to enable commenting.').show();
} else {
var fileName = Office.context.document.url
.toString()
.substr(Office.context.document.url.toString().lastIndexOf('/') + 1);
$('#add-comment-to-list').click(function() {
CommentsApp.CommentsList.createComment(
'Comment',
$('#select-reviewer option:selected').text(),
$('#addCommentText').val(),
fileName
);
});
// Deferred load of SP JS APIs
var scriptbase = '/_layouts/15/';
$.getScript(scriptbase + 'SP.Runtime.js', function () {
$.getScript(scriptbase + 'SP.js', function () {
// Get App web URL, then load users and comments
var context = SP.ClientContext.get_current();
var web = context.get_web();
context.load(web);
context.executeQueryAsync(function() {
var appWebURL = web.get_url();
CommentsApp.CommentsList.setAppWebUrl(appWebURL);
// Load reviewers into dropdown
$.ajax({
url: appWebURL + '/../_api/web/siteUsers',
type: 'GET',
headers: { 'ACCEPT': 'application/json;odata=verbose' },
success: function(data) {
data.d.results.forEach(function(user) {
$('#select-reviewer').append(
'<option value="' + user.Id + '">' + user.Title + '</option>'
);
});
}
});
// Load existing comments
CommentsApp.CommentsList.getAllByDocumentName(fileName);
}, function(sender, args) {
console.error('Failed: ' + args.get_message());
});
});
});
}
});
};
Key Notes
Why filename and not Title? When you save a Word document, the Title field is never auto-filled — only the file Name field is. So I’m matching on filename throughout.
REST + Lookup fields: To query a lookup field via REST you need $expand:
?$select=Title,DocumentLookupColumn/Title&$expand=DocumentLookupColumn/Title&$filter=DocumentLookupColumn/Title eq 'filename'
I couldn’t get this working with the /Name sub-field specifically — if anyone knows why, drop a comment!
DOM manipulation from inside the object: Not ideal. The next post will refactor this using AngularJS for proper separation.