共用方式為


Connecting a Project Task Pane App to PWA

Introduction

Apps for Office present a great new opportunity to bring data from a variety of sources into Office applications. The new Project specifically offers a very powerful way to surface both SharePoint and Project Web App data in a task pane app in Project Professional. To connect with SharePoint or PWA from the client, we use on-the-fly OAuth flow as presented in the SharePoint 2013 SDK. The App for Project described in this article connects with the same PWA site collection that Project Professional is currently connected to, and displays data about the currently selected task that is not otherwise available from within Project Professional. This will work with all versions of Project Professional (C2R, MSI, On Demand) and with all PWA environments (Project Online, Project Server).

image

Setup

Prerequisites for this project are:

  • Visual Studio 2012
  • IIS or IIS Express configured to allow applications to run on your server (at localhost)
  • Office 2013 and SharePoint 2013 tools for Visual Studio, available here
  • Project Professional 2013
  • Project Online tenant or Project Server

The first step is to launch Visual Studio 2012. Create a new App for Office 2013 project as shown below. Let’s call it “TaskLastModifiedApp”.

image

In the next dialog, make this a Task Pane App for Project.

We need to add references, as this app will use a number of APIs across Office and SharePoint. These DLLs may be in a different location on your system. Most of these references are automatically added if you use the App for SharePoint template, so if you can’t find these on your system, create a quick App for SharePoint solution and note the reference paths to help you out. You should add:

  • C:\Program Files\Reference Assemblies\Microsoft\Windows Identity Foundation\v3.5\Microsoft.IdentityModel.dll
  • C:\Windows\Microsoft.NET\assembly\GAC_MSIL\Microsoft.IdentityModel.Extensions\v4.0_2.0.0.0__69c3241e6f0468ca\Microsoft.IdentityModel.Extensions.dll
  • C:\Program Files\Reference Assemblies\Microsoft\Windows Identity Foundation\v3.5\Microsoft.IdentityModel.WindowsTokenService.dll
  • C:\Program Files\Common Files\Microsoft Shared\Web Server Extensions\15\ISAPI\Microsoft.ProjectServer.Client.dll
  • C:\Program Files\Common Files\Microsoft Shared\Web Server Extensions\15\ISAPI\Microsoft.SharePoint.Client.dll
  • C:\Program Files\Common Files\Microsoft Shared\Web Server Extensions\15\ISAPI\Microsoft.SharePoint.Client.Runtime.dll
  • C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.0\System.IdentityModel.dll
  • C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.0\System.IdentityModel.Selectors.dll
  • C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.0\System.ServiceModel.dll

Additionally, you will need to add a copy of TokenHelper.cs, which is generated when creating an autohosted or provider-hosted App for SharePoint project.

Task Pane App Manifest

The actual task pane app is just an XML manifest. Open up TaskLastModifiedApp.xml from the TaskLastModifiedApp project in your Solution Explorer. Replace its contents with the following:

  1: <?xml version="1.0" encoding="UTF-8"?>
  2: <OfficeApp xmlns="https://schemas.microsoft.com/office/appforoffice/1.0" xmlns:xsi="https://www.w3.org/2001/XMLSchema-instance" xsi:type="TaskPaneApp">
  3:   <Id>[leave this line alone]</Id>
  4:   <Version>1.0</Version>
  5:   <ProviderName>Microsoft</ProviderName>
  6:   <DefaultLocale>en-US</DefaultLocale>
  7:   <DisplayName DefaultValue="TaskLastModifiedApp" />
  8:   <Description DefaultValue="This app will show you when the selected task was last modified"/>
  9:   <AppDomains>
  10:     <AppDomain>https://localhost:44301/</AppDomain>
  11:   </AppDomains>
  12:   <Capabilities>
  13:     <Capability Name="Project" />
  14:   </Capabilities>
  15:   <DefaultSettings>
  16:     <SourceLocation DefaultValue="https://localhost:44301/pages/URLConstructor.aspx" />
  17:   </DefaultSettings>
  18:   <Permissions>ReadWriteDocument</Permissions>
  19: </OfficeApp>

Replace the port after localhost (in both instances) with whatever port you have configured IIS to use for SSL. Make sure to toggle the “SSL Enabled” property on the TaskLastModifiedAppWeb project to true. Let whatever ID was originally set in the manifest remain.

Architecture

Next, delete the TaskLastModifiedApp.html page – we will need .aspx pages in this project. The basic architecture of the task pane app is as follows:

  • When the task pane app is launched, it loads “URLConstructor.aspx”, which pulls the PWA URL from the client and constructs a call to OAuthAuthorize with the proper tokens to request permissions for the app to access PWA data. This page loads “URLConstructor.js” to interact with the client.
  • OAuthAuthorize is launched in a new window, since we cannot predict the customer’s Project domain. After the user trusts the app, that new window is redirected to “PostOAuth.aspx”, which surfaces the auth code back to URLConstructor.aspx.
  • Once URLConstructor.aspx has the auth code, the task pane app is redirected with this as a token to “Default.aspx”, which has the functional code for the app. This page uses Project CSOM code in its code-behind page to read data from PWA, as well as “TaskLastModifiedApp.js” to interact with the client.

Constructing the OAuthAuthorize URL

The complete code for URLConstructor.aspx is as follows:

  1: <%@ Page Language="C#" AutoEventWireup="true" CodeBehind="URLConstructor.aspx.cs" Inherits="TaskLastModifiedAppWeb.Pages.URLConstructor" %>
  2:  
  3: <!DOCTYPE html>
  4:  
  5: <html xmlns="https://www.w3.org/1999/xhtml">
  6:  
  7: <head runat="server">
  8:     <title>TaskLastModifiedApp</title>
  9:     <meta http-equiv="X-UA-Compatible" content="IE=9" />
  10:     <script type="text/javascript" src="..\Scripts\Office\1.0\MicrosoftAjax.js"></script>
  11:     <script type="text/javascript" src="..\Scripts\Office\1.0\Office.js"></script>
  12:     <script type="text/javascript" src="..\Scripts\URLConstructor.js"></script>
  13:     <script type="text/javascript">
  14:         function getClientId() {
  15:             var clientId = '<%=ConfigurationManager.AppSettings["ClientId"].ToString() %>'; //read the clientID from web.config
  16:             getPwaUrl(clientId); //return to client code
  17:         }</script>
  18: </head>
  19:  
  20: <body>
  21:     Redirecting...
  22: </body>
  23:  
  24: </html>

This page needs to be an .aspx page in order to read from web.config, but does not need anything in its code-behind. The clientId read from web.config is needed for the authorization flow. getPwaUrl() is a function within URLConstructor.js.

The complete code for URLConstructor.js is as follows:

  1: var _projDoc;
  2: var pwaUrl;
  3: var oAuthUrl;
  4:  
  5: Office.initialize = function (reason) {
  6:     _projDoc = Office.context.document;
  7:     getClientId(); //on document load, reads the ClientId from web.config first since it is server-side
  8: }
  9:  
  10: function getPwaUrl(clientId) { //uses Office App API to read PWA URL
  11:     _projDoc.getProjectFieldAsync(Office.ProjectProjectFields.ProjectServerUrl,
  12:         function (asyncResult) {
  13:             if (asyncResult.status == Office.AsyncResultStatus.Succeeded) {
  14:                 pwaUrl = asyncResult.value.fieldValue;
  15:                 generateUrl(clientId); //creates the OAuthAuthorize URL with necessary parameters
  16:             }
  17:             else {
  18:                 logMethodError("getProjectFieldAsync", asyncResult.error.name, asyncResult.error.message);
  19:             }
  20:         }
  21:     )
  22: };
  23:  
  24: function generateUrl(clientId) {
  25:     oAuthUrl = pwaUrl + "/_layouts/15/OAuthAuthorize.aspx?IsDlg=1&client_id=" + clientId + "&scope=Projects.Read&response_type=code&redirect_uri=https://localhost:44301/pages/PostOAuth.aspx";
  26:     authWindow = window.open(oAuthUrl);
  27:     codeListener(); //start listening for the auth code
  28: }
  29:  
  30: function codeListener() {
  31:     setTimeout(function () { readCode(); }, 1000); //check for the auth code every one second
  32: }
  33:  
  34: function readCode() {
  35:     try { //if we can actually reach the authCode field on PostOAuth.aspx
  36:         authCode = authWindow.document.getElementById("authCode").value;  //pull the authCode value
  37:         if (authCode != "NA") { //if it is not the default "NA"
  38:             authWindow.close(); //close the new window
  39:             document.location.href = "/Pages/Default.aspx?code=" + authCode; //redirect task pane to the app code with the authCode token
  40:         }
  41:     }
  42:     catch (e) {
  43:         codeListener(); //if we couldn't reach PostOAuth.aspx, wait another second and try again
  44:     }
  45: }

When the task pane app loads, it first reads web.config from the aspx page, since this is server-side code. Once it has the clientId, we read the PWA URL. We then create the full OAuthAuthorize URL with the parameters specified above. scope=Projects.Read requests read permission to projects on the current PWA site. Make sure to match the SSL port here as well, as before.

On running the app, a new window will open up outside of Project that prompts the user to login to PWA (if they have not checked “Keep me signed in” previously). They will then be presented with a “Do you trust…” page, the same as if they were installing an App for SharePoint. This is the OAuthAuthorize.aspx page. Once trusted, that new window navigates to PostOAuth.aspx, presented below:

  1: <%@ Page Language="C#" AutoEventWireup="true" CodeBehind="PostOAuth.aspx.cs" Inherits="TaskLastModifiedAppWeb.Pages.PostOAuth" %>
  2:  
  3: <!DOCTYPE html>
  4:  
  5: <html xmlns="https://www.w3.org/1999/xhtml">
  6: <head runat="server">
  7:     <title></title>
  8: </head>
  9: <body>
  10:     <form id="form1" runat="server">
  11:     <div>
  12:     <asp:HiddenField ID="authCode" runat="server" value="NA"/>
  13:         Closing...
  14:     </div>
  15:     </form>
  16: </body>
  17: </html>

And PostOAuth.aspx.cs:

  1: using System;
  2: using System.Collections.Generic;
  3: using System.Linq;
  4: using System.Web;
  5: using System.Web.UI;
  6: using System.Web.UI.WebControls;
  7:  
  8: namespace TaskLastModifiedAppWeb.Pages
  9: {
  10:     public partial class PostOAuth : System.Web.UI.Page
  11:     {
  12:         protected void Page_Load(object sender, EventArgs e)
  13:         {
  14:             var code = Request.QueryString["code"];
  15:             authCode.Value = code;
  16:         }
  17:     }
  18: }

This page saves the auth code token in a hidden field. The task pane app, still on URLConstructor.aspx, waits for this value and then closes the new window. The app then continues on to default.aspx with the proper code token needed to finish the OAuth flow.

Reading the Last Modified Date of the Selected Task

The remainder of this article is an example of what you might do in your task pane app now that you have read access to PWA data. This example will show you the Last Modified date and time of the task you have selected. On launch, it shows you data for the selected task, and as you change tasks, the data is updated using an event handler.

The complete code for Default.aspx is as follows:

  1: <%@ Page Language="C#" AutoEventWireup="true" CodeBehind="Default.aspx.cs" Inherits="TaskLastModifiedAppWeb.Pages.Default" %>
  2:  
  3: <!DOCTYPE html>
  4:  
  5: <html xmlns="https://www.w3.org/1999/xhtml">
  6: <head runat="server">
  7:     <title>TaskLastModifiedApp</title>
  8:     <meta http-equiv="X-UA-Compatible" content="IE=9" />
  9:     <script type="text/javascript" src="..\Scripts\Office\1.0\MicrosoftAjax.js"></script>
  10:     <script type="text/javascript" src="..\Scripts\Office\1.0\Office.js"></script>
  11:     <script type="text/javascript" src="..\Scripts\TaskLastModifiedApp.js"></script>
  12:     
  13: </head>
  14:  
  15: <body>
  16:     <form id="form1" runat="server">
  17:         
  18:         <asp:HiddenField ID="projGuidHF" runat="server" />
  19:         <asp:HiddenField ID="taskGuidHF" runat="server" />
  20:         <asp:HiddenField ID="pwaUrlHF" runat="server" />
  21:  
  22:         <asp:Label ID="TaskLastModLabel" runat="server" Text="Loading..." />
  23:  
  24:         <div style="display: none;">
  25:             <asp:Button ID="hiddenTaskChangedButton" runat="server" OnClick="OnTaskChanged" />
  26:         </div>
  27:  
  28:     </form>
  29:  
  30: </body>
  31: </html>

This page contains three hidden fields used to pass data back and forth between the client-side code and the server-side code. It also leverages a label to surface the results to the user, and a hidden button that the client-side code uses to call a server-side function, as you will see below.

The complete code for TaskLastModifiedApp.js is as follows:

  1: var _projDoc;
  2: var taskGuid;
  3: var projGuid;
  4: var pwaUrl;
  5:  
  6: // This runs after every postback
  7: Office.initialize = function (reason) {
  8:     _projDoc = Office.context.document;
  9:     if (document.getElementById("pwaUrlHF").value == "NA") { //if this is the first run
  10:         firstRun();
  11:     }
  12:     manageTaskEventHandler('addHandlerAsync'); //need to re-register event handler after each postback
  13: }
  14:  
  15: // Only need these on the first page load, not on postbacks
  16: function firstRun() {
  17:     getProjGuid();
  18:     _projDoc.getProjectFieldAsync(Office.ProjectProjectFields.ProjectServerUrl,
  19:         function (asyncResult) {
  20:             pwaUrl = asyncResult.value.fieldValue;
  21:             document.getElementById("pwaUrlHF").value = pwaUrl;
  22:         }
  23:     )
  24:     getTaskGuid();
  25: }
  26:  
  27: // Get the GUID of the selected task, comes from SDK
  28: function getTaskGuid() {
  29:     var TaskLastModLabel = document.getElementById("TaskLastModLabel");
  30:     TaskLastModLabel.innerHTML = "Loading...";
  31:     _projDoc.getSelectedTaskAsync(function (asyncResult) {
  32:         taskGuid = asyncResult.value;
  33:         document.getElementById("taskGuidHF").value = taskGuid; //saves the task GUID to a hidden field to pass to the code-behind
  34:         document.getElementById("hiddenTaskChangedButton").click(); //runs the CSOM calls in the aspx.cs file
  35:     });
  36: }
  37:  
  38:  // Get the GUID of the current project.
  39: function getProjGuid() {
  40:     _projDoc.getProjectFieldAsync(Office.ProjectProjectFields.GUID,
  41:         function (asyncResult) {
  42:             projGuid = asyncResult.value.fieldValue;
  43:             document.getElementById("projGuidHF").value = projGuid; //saves the project GUID to a hidden field to pass to the code-behind
  44:         }
  45:     )
  46: }
  47:  
  48: // Task selection changed event handler.
  49: function onTaskSelectionChanged(eventArgs) {
  50:     getTaskGuid();
  51: }
  52:  
  53: // Add or remove a task selection changed event handler.
  54: function manageTaskEventHandler(docMethod) {
  55:     manageEventHandlerAsync(
  56:         Office.EventType.TaskSelectionChanged,      // The task selection changed event.
  57:         onTaskSelectionChanged,                     // The event handler.
  58:         docMethod                // The Office.Document method to add or remove an event handler.
  59:     );
  60: }
  61:  
  62: // Add or remove the specified event handler.
  63: function manageEventHandlerAsync(eventType, handler, operation, onComplete) {
  64:     _projDoc[operation]   //The operation is addHandlerAsync or removeHandlerAsync.
  65:     (
  66:         eventType,
  67:         handler,
  68:         function (asyncResult) {
  69:             // code here runs after event has been registered (or failed)
  70:         }
  71:     );
  72: }

The first time this code runs, it pulls the PWA URL (just like we did in URLConstructor.js) and saves it to one of our hidden fields, registers a client-side event handler to capture when a new task is selected, and starts the process of connecting to PWA and pulling the data we need, which is mostly done in the code-behind file. After each postback, we do not need to recapture the PWA URL, as our ProjectContext is maintained as a static variable.

We need to make one quick tweak to TokenHelper.cs first – change GetRealmFromTargetUrl from a private method to a public method.

The complete code for Default.aspx.cs is as follows:

  1: using System;
  2: using System.Collections.Generic;
  3: using System.Linq;
  4: using System.Web;
  5: using System.Web.UI;
  6: using System.Web.UI.WebControls;
  7: using Microsoft.ProjectServer.Client;
  8: using Microsoft.SharePoint.Client;
  9:  
  10: namespace TaskLastModifiedAppWeb.Pages
  11: {
  12:     public partial class Default : System.Web.UI.Page
  13:     {
  14:         public static ProjectContext projContext;
  15:         public static PublishedProject thisProj;
  16:         public Guid projGuid;
  17:  
  18:         protected void Page_Load(object sender, EventArgs e)
  19:         {
  20:             if (!IsPostBack) //set values of hidden fields if this is the first page load
  21:             {
  22:                 projGuidHF.Value = "NA";
  23:                 taskGuidHF.Value = "NA";
  24:                 pwaUrlHF.Value = "NA";
  25:             }
  26:         }
  27:  
  28:  
  29:         protected void GetContexts()
  30:         {
  31:             var code = Request.QueryString["code"]; //pulls the code token from the request
  32:  
  33:             string targetPwa = pwaUrlHF.Value; //pulls the PWA URL from where the Office app API stored it
  34:  
  35:             Uri targetPwaUri = new Uri(targetPwa);
  36:  
  37:             var tRealm = TokenHelper.GetRealmFromTargetUrl(targetPwaUri);
  38:  
  39:             Uri rUri = new Uri("https://localhost:44301/pages/PostOAuth.aspx"); //hardcoded link to redirect_uri
  40:  
  41:             var clientContext = TokenHelper.GetClientContextWithAuthorizationCode(targetPwa, "00000003-0000-0ff1-ce00-000000000000", code, tRealm, rUri);
  42:  
  43:             projContext = GetProjectContextWithAuthorizationCode(targetPwa, "00000003-0000-0ff1-ce00-000000000000", code, tRealm, rUri);
  44:  
  45:             projGuid = new Guid("{" + projGuidHF.Value + "}"); //loads the current project through CSOM
  46:  
  47:             var projects = projContext.LoadQuery(projContext.Projects.Where(proj => proj.Id == projGuid));
  48:             projContext.ExecuteQuery();
  49:             thisProj = projects.First();
  50:         }
  51:  
  52:         protected void OnTaskChanged(object sender, EventArgs e) //determine the selected task's last modified date
  53:         {
  54:             if (thisProj == null)
  55:             {
  56:                 GetContexts();
  57:             }
  58:             
  59:             var taskGuid = new Guid(taskGuidHF.Value);
  60:  
  61:             var tasks = projContext.LoadQuery(thisProj.Tasks.Where(task => task.Id == taskGuid)); //load the selected task off of the project
  62:             projContext.ExecuteQuery();
  63:             PublishedTask thisTask = tasks.First();
  64:             string dateMod = thisTask.Modified.ToString("D"); //pull out the Modified field on the task
  65:             string timeMod = thisTask.Modified.ToString("t");
  66:             TaskLastModLabel.Text = "The selected task was last modified on " + dateMod + " at " + timeMod + ".";
  67:         }
  68:  
  69:         public static ProjectContext GetProjectContextWithAuthorizationCode(string targetUrl,string targetPrincipalName,string authorizationCode,string targetRealm,Uri redirectUri)
  70:         {
  71:             Uri targetUri = new Uri(targetUrl);
  72:  
  73:             string accessToken =
  74:                 TokenHelper.GetAccessToken(authorizationCode, targetPrincipalName, targetUri.Authority, targetRealm, redirectUri).AccessToken;
  75:  
  76:             return GetProjectContextWithAccessToken(targetUrl, accessToken);
  77:         }
  78:  
  79:         public static ProjectContext GetProjectContextWithAccessToken(string targetUrl, string accessToken)
  80:         {
  81:             Uri targetUri = new Uri(targetUrl);
  82:  
  83:             ProjectContext projContext = new ProjectContext(targetUrl);
  84:  
  85:             projContext.AuthenticationMode = ClientAuthenticationMode.Anonymous;
  86:             projContext.FormDigestHandlingEnabled = false;
  87:             projContext.ExecutingWebRequest +=
  88:                 delegate(object oSender, WebRequestEventArgs webRequestEventArgs)
  89:                 {
  90:                     webRequestEventArgs.WebRequestExecutor.RequestHeaders["Authorization"] =
  91:                         "Bearer " + accessToken;
  92:                 };
  93:  
  94:             return projContext;
  95:         }
  96:     }
  97: }

PageLoad()

The first time the page loads, we need to initialize the hidden field values. This enables us to not set them directly in Default.aspx (and thus lose their values after a postback) and lets the client-side code distinguish between a first run load and a postback.

GetContexts()

This code also only runs once, assuming the current project remains loaded. This handles the last part of the OAuth flow – we use the code token from OAuthAuthorize to generate a client context and a project context using methods in TokenHelper.cs, as well as slightly modified methods GetProjectContextWithAuthorizationCode() and GetProjectContextWithAccessToken().

OnTaskChanged()

This first checks to make sure we have a project loaded from which to pull data. We then read the selected task guid from the hidden field, which was updated client-side before this method was called. We use Project CSOM to load the selected task from PWA and read its Last Modified field, which is then presented to the user in a readable format using the label on Default.aspx.

Register the App ID and App Secret

Since we are not submitting this app to the Office Store, we need to register it on our test tenant. On your tenant, navigate to https://[your PWA site]/_layouts/15/appregnew.aspx. Generate a new App Id and App Secret, set the Title to “TaskLastModifiedApp”, set the App Domain to localhost:44301 (or wherever the app code is running), and set the Redirect URI to match the redirect_uri token value in the oAuth URL created in URLConstructor.js.

image

Hit Create, and then add the App Id and App Secret to web.config in your Visual Studio solution. It should look like the following when you are done, with your values for ClientId and ClientSecret:

  1: <?xml version="1.0"?>
  2:  
  3: <configuration>
  4:     <system.web>
  5:       <compilation debug="true" targetFramework="4.0" />
  6:     </system.web>
  7:   <appSettings>
  8:     <add key="ClientId" value="a9ce3d5a-bb14-4aad-9c27-41a05c473b4d" />
  9:     <add key="ClientSecret" value="hL0C8wt2PPaBYNYRMZzcUcu3C/Vv0fbm48djGzyIXOw=" />
  10:   </appSettings>
  11: </configuration>

Time to Test!

To run the app, just hit F5 in Visual Studio, which will launch Project Professional. Make sure to connect to a PWA profile, then load a published project (or create and publish a new one). From a task view, select the Project tab in the ribbon, hit the dropdown for Apps for Office, and click TaskLastModifiedApp.

image

The app will launch in a task pane on the right side of the screen. It’ll prompt you to trust the app, quickly load, and then display the last modified date and time of the selected task.

image

Select a different task, and the data will update almost instantly.

image

Wrap-Up

In this blog post, you have learned how to create a task pane app in Project Professional that can display data from PWA that would not normally be visible in the client. The app uses SharePoint’s OAuthAuthorize page to request permissions from PWA and handle the authentication handshake between the app code and your online data. For more on working with OAuth, make sure to check out the relevant node in the SharePoint 2013 SDK. For more information on PWA programmability, check out the Project 2013 SDK. To learn more about writing task pane apps for the new Project, see this node in the Office 2013 SDK.

TaskLastModifiedApp.zip

Comments

  • Anonymous
    June 13, 2013
    Hello, Thank you for your tutorial. When I try to test this app online, the URLConstructor.aspx fails instantly and in Fiddler I get the error "No Proxy-Authorization Header is present. No Authorization Header is present." . I don't know if this is the problem or not. I use an Office 365 Project Online and the app is on my own server (provider hosted). Is there a special configuration to make your app work in real situation ? Thank you in advance, James.

  • Anonymous
    June 16, 2013
    Hi, Thanks for your post! I'm facing the same problem as James. When i tried this app on an "on premise solution" i have the following error : "The Azure Access Control service is unavailable" And, on "an online solution" : App error, this app could not be started Fiddler show me an error 500 on the URL URLConstructor.aspx Is it possible to view errors logs ? Thanks for your reply, Bonito