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
Comments
Anonymous
August 19, 2012
Excellent demo for demonstrating the power of SP Apps... good work!Anonymous
August 22, 2012
Hi, great article! Your sample (KudosPart1.zip) works correctly as dev hosted app and as azure hosted app (RemoteWebApplication & AutoDeployedWebApplication principal). So I'm wondering what exactly makes it able to use crossdomain JS in developer hosted and azure hosted apps? Because all crossdomain JS code samples that I found so far use internal app principal and was sure that this is simply impossible.Anonymous
October 02, 2012
Very useful article and everything had worked fine till today, but today I've encountered the problem with webpart in Kudos sample. Looks like isn't working as it should in IE 10 currently. I believe this MSDN article explains why this happens msdn.microsoft.com/.../jj612823(v=office.15).aspx . But example that they provide also doesn't work. :) I would highly appreciate if you share any thoughts about how to handle this issue. Thanks in advance!Anonymous
October 07, 2012
Great Article. Looking forward to more on 2013. Is there a guide to various CSOM functions and there usage? Need to start working on an upgrade and I wanna show how good and easy apps can be.Anonymous
October 17, 2012
Great post!!! Looking forward to part2 :) Why does your App need the "Tenant Write" permission? Is that for writing to the Newsfeed? What else can you do with the "Tenant" permission?Anonymous
May 15, 2013
The comment has been removedAnonymous
June 24, 2013
I am new to the SharePoint and Office 365 development. However, I have difficulties running this sample in SharePoint.Anonymous
October 02, 2013
Could this work in an on-premise scenario?Anonymous
October 02, 2013
Autohosted apps are not currently supported on-prem. However, this could easily be delivered on-prem as a provider-hosted or possibly even SharePoint-hosted app. The primary effort would be changing the way the server-side clientContext is established to support S2S: msdn.microsoft.com/.../fp179901.aspxAnonymous
November 27, 2013
The comment has been removedAnonymous
April 15, 2014
The comment has been removedAnonymous
June 03, 2015
Does anyone have this as an installable solution (or WSP files) for sharepoint 2013 onsite. we dont use 365 or azure and all we need is a simple Kudos app like this ...Anonymous
June 03, 2015
Does anyone have this as an installable solution (or WSP files) for sharepoint 2013 onsite. we dont use 365 or azure and all we need is a simple Kudos app like this ...