Real-world Apps for SharePoint 2013 - Kudos (Part 1)

In this two part series, I will explore developing real-world solutions via apps for SharePoint 2013.  Part 1 will focus on the basic app delivery without complex tenancy considerations (the solution will store all information in a list on the tenant).  In part 2, we will explore tenancy considerations in the Office Marketplace for solutions leveraging cloud storage in SQL Azure and other advanced scenarios.

The Solution

Microsoft uses a popular employee recognition application called Kudos.  I can send a kudos to anyone in the organization to recognize them for exceptional work.  When a kudos is submitted, an email is sent to the recipient, the submitter, and both of their managers (if applicable).  Additionally, the kudos is added to social newsfeeds for a wider audience to see.  Statistics are available to see historic kudos activity for employees.  I felt like this was a perfect candidate for a SharePoint 2013 app, given the rich integration with social, reporting, and workflow.  The video below outlines the final solution of Part 1, which is detailed throughout this post (including the provided code):

[View:https://www.youtube.com/watch?v=LLXhIjLUs6A]

Getting Started

The solutions in this series will be developed as Autohosted solutions, which are ultimately deployed to Azure for hosting.  I should note that the solution in Part 1 could have been developed as a SharePoint hosted solution, but I chose Autohosted given the direction we will take in Part 2.  Since Austohosted solutions provide the flexibility of server-side code, Part 1 has a really nice mix of CSOM and SSOM.  It also does a good job of illustrating the differences in the three web locations…the host web (consumes the app), and app web (hosts SharePoint-specific components of the solution), and the remote app web (hosts the app logic).  An app will always have a host web but not always a remote web (SharePoint-hosted apps) or app web (optional in Autohosted and Provider hosted apps).  The Part 1 solution leverages all three as outlined in the diagram below:

Building the Solution

I started by creating an App for SharePoint 2013 project in Visual Studio leveraging my Office 365 Preview tenant for debugging and configured as Autohosted.  The app will read profiles, write to lists/newsfeed, and use SharePoint utilities for sending email, all of which require specific app permissions.  App permissions are set in the AppManifest.xml.  Here are the permissions I set for the Kudos app:

With app permissions in place, I turned my attention to storage of kudos.  For Part 1, I decided to use a SharePoint list for storage.  In the future I will modify the solution to use SQL Azure for storage, which will be better for capacity/growth and much more efficient for advanced analytics and reporting (not to mention cleaner to work with compared to CAML).  Lists in SharePoint apps get provisioned in the app web, which is on a separate domain from the consuming site.  Below is the simple list structure I came up with for storing kudos.  Notice the manager fields are optional, since not all employees have a manager or have it set on their profile:

Next, I turned my attention to the markup and script that users would interact with to submit kudos.  Apps for SharePoint are required to provide a full-screen user experience, but I also wanted the app to be surfaced in existing SharePoint pages.  To do this, I added a Client Web Part to the SharePoint project and will deliver the same kudos page in both full-screen and in the app part.  For details on designing/styling for both experiences, see my previous post on Optimizing User Experience of Apps for SharePoint 2013.  The Kudos app has two views.  The first view has contains an employee look-up form and displays kudos statistics to the user such as the number of kudos sent/received over time and the users balance (we will limit the user to 4 submissions/week).  The second view will display details of the kudos recipient (Name, Profile Picture, Title, etc) and provide the form for the kudos text.  Below are pictures of these two views that we will dissect later in this post:

Kudos Stats and Lookup Form Kudos Entry Form

When the Kudos app is first loaded, it will display kudos statistics for the user (ex: number of kudos sent and received over time).  This is achieved through SSOM CAML queries on page load (note: this could also be done client-side):

protected void Page_Load(object sender, EventArgs e){    if (!this.IsPostBack)    {        //get context token for         var contextToken = TokenHelper.GetContextTokenFromRequest(Page.Request);        hdnContextToken.Value = contextToken;        string hostweburl = Request["SPHostUrl"];        string appweburl = Request["SPAppWebUrl"];        using (var clientContext = TokenHelper.GetClientContextWithContextToken(appweburl, contextToken, Request.Url.Authority))        {            //load the current user            Web web = clientContext.Web;            User currentUser = web.CurrentUser;            clientContext.Load(currentUser);            clientContext.ExecuteQuery();            //get the current users kudos activity            ListCollection lists = web.Lists;            List kudosList = clientContext.Web.Lists.GetByTitle("KudosList");            CamlQuery receivedQuery = new CamlQuery()            {                ViewXml = "<View><Query><Where><Eq><FieldRef Name='Recipient' LookupId='TRUE' /><Value Type='User'>" + currentUser.Id + "</Value></Eq></Where></Query><ViewFields><FieldRef Name='Title' /><FieldRef Name='Submitter' /><FieldRef Name='SubmitterManager' /><FieldRef Name='Recipient' /><FieldRef Name='RecipientManager' /></ViewFields></View>"            };            var receivedItems = kudosList.GetItems(receivedQuery);            CamlQuery sentQuery = new CamlQuery()            {                ViewXml = "<View><Query><Where><Eq><FieldRef Name='Submitter' LookupId='TRUE' /><Value Type='User'>" + currentUser.Id + "</Value></Eq></Where></Query><ViewFields><FieldRef Name='Title' /><FieldRef Name='Submitter' /><FieldRef Name='SubmitterManager' /><FieldRef Name='Recipient' /><FieldRef Name='RecipientManager' /></ViewFields></View>"            };            var sentItems = kudosList.GetItems(sentQuery);            clientContext.Load(receivedItems, items => items.IncludeWithDefaultProperties(item => item.DisplayName));                clientContext.Load(sentItems, items => items.IncludeWithDefaultProperties(item => item.DisplayName));            clientContext.ExecuteQuery();            //convert to generics collection            List<Kudo> receivedKudos = receivedItems.ToKudosList();            List<Kudo> sentKudos = sentItems.ToKudosList();            //set statistics            int availableKudos = 4 - sentKudos.Count(i => i.CreatedDate > DateTime.Now.Subtract(TimeSpan.FromDays(7)));            hdnSentQuota.Value = availableKudos.ToString();            lblReceivedThisMonth.Text = String.Format("{0} Kudos received this month", receivedKudos.Count(i => i.CreatedDate > DateTime.Now.Subtract(TimeSpan.FromDays(30))).ToString());            lblReceivedAllTime.Text = String.Format("{0} received all time!", receivedKudos.Count.ToString());            lblSentThisMonth.Text = String.Format("{0} Kudos sent this month", sentKudos.Count(i => i.CreatedDate > DateTime.Now.Subtract(TimeSpan.FromDays(30))).ToString());            lblRemainingThisWeek.Text = String.Format("You can send {0} more this week", availableKudos.ToString());            lblAfterThisKudos.Text = String.Format("After this kudos, you can send {0} this week", (availableKudos - 1).ToString());            }    }}

 

Most of the heavy lifting prior to submitting a kudos will be handled client-side (employee lookups, profile queries, etc).  This requires referencing several script file from the host site including SP.js, SP.Runtime.js, init.js, SP.UserProfiles.js, and SP.RequestExecutor.js.  The last two of these are probably the least familiar but particularly important.  SP.UserProfiles.js provides client-side access to profiles and SP.RequestExecutor.js wraps all of our client-side requests with the appropriate OAuth details.  Here is how I referenced them dynamically from the host site:

//Load the required SharePoint libraries and wire events.$(document).ready(function () {    //Get the URI decoded URLs.    hostweburl = decodeURIComponent(getQueryStringParameter('SPHostUrl'));    appweburl = decodeURIComponent(getQueryStringParameter('SPAppWebUrl'));    var scriptbase = hostweburl + '/_layouts/15/';    //load all appropriate scripts for the page to function    $.getScript(scriptbase + 'SP.Runtime.js', function () {        $.getScript(scriptbase + 'SP.js', function () {            $.getScript(scriptbase + 'SP.RequestExecutor.js', registerContextAndProxy);            $.getScript(scriptbase + 'init.js', function () {                $.getScript(scriptbase + 'SP.UserProfiles.js', function () { });            });             });    });

 

The People Pickers in SharePoint 2013 have a great auto-fill capability I wanted to replicate in my employee lookup.  After some hunting and script debugging, I found a fantastic and largely undocumented client-side function for doing users lookups.  SP.UI.ApplicationPages.ClientPeoplePickerWebServiceInterface.clientPeoplePickerSearchUser takes the client context and a ClientPeoplePickerQueryParameters object to find partial matches on a query.  I wired this into the keyup event of my lookup textbox as follows:

//loadwire keyup on textbox to do user lookups$('#txtKudosRecipient').keyup(function (event) {    var txt = $('#txtKudosRecipient').val();    if ($('#txtKudosRecipient').hasClass('txtLookupSelected'))        $('#txtKudosRecipient').removeClass('txtLookupSelected');    if (txt.length > 0) {        var query = new SP.UI.ApplicationPages.ClientPeoplePickerQueryParameters();        query.set_allowMultipleEntities(false); query.set_maximumEntitySuggestions(50); query.set_principalType(1);        query.set_principalSource(15); query.set_queryString(txt);        var searchResult = SP.UI.ApplicationPages.ClientPeoplePickerWebServiceInterface.clientPeoplePickerSearchUser(context, query);        context.executeQueryAsync(function () {            var results = context.parseObjectFromJsonString(searchResult.get_value());            var txtResults = '';            if (results) {                if (results.length > 0) {                    for (var i = 0; i < results.length; i++) {                        var item = results[i];                        var loginName = item['Key'];                        var displayName = item['DisplayText'];                        var title = item['EntityData']['Title']; ...

 

Once a user is selected, script will query for detailed profile information on submitter and recipient and toggle to the kudos entry view.  No be surprises here, but a decent amount of CSOM using the new UserProfile scripts:

//function that is fired when a recipient is selected from the suggestions dialog or btnSearchfunction recipientSelected(recipientKey, recipientText) {    $('#txtKudosRecipient').val(recipientText);    $('#txtKudosRecipient').addClass('txtLookupSelected');    $('#divUserSearch').css('display', 'none');    //look up user    var peopleMgr = new SP.UserProfiles.PeopleManager(context);    var submitterProfile = peopleMgr.getMyProperties();    var recipientProfile = peopleMgr.getPropertiesFor(recipientKey);    context.load(submitterProfile, 'AccountName', 'PictureUrl', 'ExtendedManagers', 'Title', 'Email', 'DisplayName');    context.load(recipientProfile, 'AccountName', 'PictureUrl', 'ExtendedManagers', 'Title', 'Email', 'DisplayName');    context.executeQueryAsync(function () {         var url = recipientProfile.get_pictureUrl();        var title = recipientProfile.get_title();        var email = recipientProfile.get_email();        //set profile image source        $('#imgRecipient').attr('src', url);        $('#imgRecipient').attr('alt', recipientText);        //set label text        $('#lblRecipient').html(recipientText);        $('#lblRecipientTitle').html(title);        $('#lblRecipientEmail').html(email);        //set hidden fields        $('#hdnSubmitter').val(submitterProfile.get_accountName());        $('#hdnSubmitterName').val(submitterProfile.get_displayName());        $('#hdnRecipient').val(recipientProfile.get_accountName());        $('#hdnRecipientName').val(submitterProfile.get_displayName());        var sMgrs = submitterProfile.get_extendedManagers();        if (sMgrs.length > 0)             $('#hdnSubmitterManager').val(sMgrs[sMgrs.length - 1]);        else            $('#hdnSubmitterManager').val('');        var rMgrs = recipientProfile.get_extendedManagers();        if (rMgrs.length > 0)            $('#hdnRecipientManager').val(rMgrs[rMgrs.length - 1]);        else            $('#hdnRecipientManager').val('');    }, function () {         alert('Failed to load user profile details'); });}

 

When the user submits a kudos, the kudos form will execute it's one and only postback.  In reality, everything the postback does with SSOM could be achieved client-side with CSOM.  However, I'm looking to do some advanced things in Part 2 that I think will be easier server-side (ex: impersonate the social post as a "Kudos" service account).  The postback does three basic things…adds a kudos record to the kudos list on the app web, creates a post on the social feed with a recipient mention, and emails the kudos to the submitter, recipient, and their managers (if applicable).  Here is the postback code:

protected void btnSend_Click(object sender, ImageClickEventArgs e){    //get all managers    Kudo newKudo = new Kudo();    newKudo.KudosText = txtMessage.Text;    //get context token for     string contextToken = hdnContextToken.Value;    string hostweburl = Request["SPHostUrl"];    string appweburl = Request["SPAppWebUrl"];    using (var clientContext = TokenHelper.GetClientContextWithContextToken(appweburl, contextToken, Request.Url.Authority))    {        //get the context        Web web = clientContext.Web;        ListCollection lists = web.Lists;        List kudosList = clientContext.Web.Lists.GetByTitle("KudosList");        //ensure submitter        newKudo.Submitter = web.EnsureUser(hdnSubmitter.Value);        clientContext.Load(newKudo.Submitter);        //ensure recipient        newKudo.Recipient = web.EnsureUser(hdnRecipient.Value);        clientContext.Load(newKudo.Recipient);        //ensure submitter manager (if applicable)        if (!String.IsNullOrEmpty(hdnSubmitterManager.Value))        {            newKudo.SubmitterManager = web.EnsureUser(hdnSubmitterManager.Value);            clientContext.Load(newKudo.SubmitterManager);        }        //ensure recipient manager (if applicable)        if (!String.IsNullOrEmpty(hdnRecipientManager.Value))        {            newKudo.RecipientManager = web.EnsureUser(hdnRecipientManager.Value);            clientContext.Load(newKudo.RecipientManager);        }        clientContext.ExecuteQuery();        //add the listitem and execute changes to SharePoint        clientContext.Load(kudosList, list => list.Fields);        Microsoft.SharePoint.Client.ListItem kudosListItem = newKudo.Add(kudosList);        clientContext.Load(kudosListItem);        clientContext.ExecuteQuery();        //write to social feed        SocialFeedManager socialMgr = new SocialFeedManager(clientContext);        var post = new SocialPostCreationData();        post.ContentText = "Sent @{0} a Kudos for:\n'" + txtMessage.Text + "'";        post.ContentItems = new[]        {            new SocialDataItem            {                ItemType = SocialDataItemType.User,                AccountName = newKudo.Recipient.LoginName            }        };        ClientResult<SocialThread> resultThread = socialMgr.CreatePost("", post);        clientContext.ExecuteQuery();        //send email to appropriate parties        EmailProperties email = new EmailProperties();        email.To = new List<String>() { newKudo.Recipient.Email };        email.CC = new List<String>() { newKudo.Submitter.Email };        if (!String.IsNullOrEmpty(hdnSubmitterManager.Value))            ((List<String>)email.CC).Add(newKudo.SubmitterManager.Email);        if (!String.IsNullOrEmpty(hdnRecipientManager.Value))            ((List<String>)email.CC).Add(newKudo.RecipientManager.Email);        email.Subject = String.Format("You have received public Kudos from {0}", hdnSubmitterName.Value);        email.Body = String.Format("<html><body><p>You have received the following public Kudos from {0}:</p><p style=\"font-style: italic\">\"{1}\"</p><p>To recognize this achievement, this Kudos has been shared with both of your managers and will be visible to anyone in your Newsfeed.</p></body></html>", hdnSubmitterName.Value, txtMessage.Text);        Utility.SendEmail(clientContext, email);        clientContext.ExecuteQuery();        //update stats on the landing page        int availableKudos = Convert.ToInt32(hdnSentQuota.Value) - 1;        hdnSentQuota.Value = availableKudos.ToString();        int sentThisMonth = Convert.ToInt32(lblSentThisMonth.Text.Substring(0, lblSentThisMonth.Text.IndexOf(' '))) + 1;        lblSentThisMonth.Text = String.Format("{0} Kudos sent this month", sentThisMonth.ToString());        lblRemainingThisWeek.Text = String.Format("You can send {0} more this week", availableKudos.ToString());        lblAfterThisKudos.Text = String.Format("After this kudos, you can send {0} this week", (availableKudos - 1).ToString());        txtMessage.Text = "";    }}

 

That's about it!  Here are some screenshots of the Kudos app in action:

Kudos App in Existing WikiPage (during lookup)

Kudos Form with Selected Recipient

Newsfeed with Kudos Activity

Kudos Email with Managers CC'ed

Final Thoughts

Kudos was a really fun app to develop.  It's an app that almost any organization could leverage to further capitalize on the power of social in SharePoint 2013.  Best of all, it runs in Office 365!  Look for Part 2 in the next few weeks, where I will expand upon the solution to leverage SQL Azure storage, workflow, and address multi-tenancy for the marketplace.  I hope this was helpful and get you excited about building fantastic apps for SharePoint 2013!

Kudos Part 1 Code: KudosPart1.zip