cd ../blog

Apps, Apps and Apps… A Story (Step 2) – Commenting App

Building a real document commenting app inside a SharePoint + Office App composite solution — CSOM list operations, REST queries, jQuery, and the Revealing Module Pattern.

</>

This is part of a series:

  1. Apps, Apps and Apps… A Story (Step 1)
  2. 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.

Download the code