Second Life of a Hungarian SharePoint Geek

October 19, 2015

Displaying Notifications and Status Messages from a SharePoint-based AngularJS Application Including a FormController

Assume the following requirements: We should create a Single Page Application (SPA) (no SharePoint App!) that reads data from SharePoint using the JavaScript client object model, allows the user to edit the values, displays if a field value was changed (if it is “dirty” vs. “pristine”), performs data validations (if it is “valid” vs. “invalid”) and lets the user to save the changes. AngularJS was selected as the presentation framework for the SPA. On data save, we should give the users feedback on the progress (like “Saving changes…”, “Save completed” or “Error during the save operation”) via standard SharePoint notifications and status messages.

Challenge 1:  The FormController of the AngularJS framework is based on the form HTML element. That means, if we would like to use the features of the FormController, like dirty / pristine, validation, etc., we should include a form element in our HTML application. However, our SharePoint page is an ASP.NET page, that already contains a form element, and it does not like to include multiple ones.

Solution 1: Although there are tricks to bypass this limitation (like this or this one), I chose another way to go. I’ve included a Page Viewer Web Part that displays a “pure” HTML page that is stored in a document library in SharePoint, as well as any other non-standard artifacts of the application (.js and .css files, etc.). This HTML page – displayed in an IFRAME by the Page Viewer Web Part – contains the form element, that does not interfere with the form element on the ASP.NET page.

You can display a notification by calling the SP.UI.Notify.addNotification method, similarly a status message is displayed via the SP.UI.Status.addStatus method. Both of these Notify and Status classes are defined in the SP.UI namespace in the sp.js (and its debug version in sp.debug.js). This JavaScript file is typically referenced in the standard SharePoint pages, however you should add a reference to it in your custom pages, like in the case of our HTML page. If you forget to add the reference, you will get an error like this one:

TypeError: Unable to get property ‘addNotification’ of undefined or null reference

Challenge 2:  There is no notification / status message displayed, even if you add the reference to the sp.js. The reason of the problem is, that the HTML elements required by these methods are defined in the master page of the standard SharePoint sites. Obviously, these elements are not found in our custom page in the IFRAME, so the messages are not displayed.

Solution 2: I’ve found two similar blog posts (this one and this one) describing a similar issue with IFRAME and notification messages in the case of Client App Parts. The first of this two posts states that the problem is the IFRAME itself, that prohibits the communication between the parent page and the IFRAME. Of course, that is wrong. The real reason is the different domain names in the URL of the app part (IFRAME) and the host page, as correctly stated in the second post. If we have the same domain name (and we do have in this case), we do not need the rather complex approach described by the posts(that is still valid for the Client App Parts).  Displaying a notification / status message from the script included in the HTML page in the IFRAME in our case is so simple as to prepend the text ‘parent.’ before the method invocation, for example:

var notifyId = parent.SP.UI.Notify.addNotification("Saving…", true);

Of course, in this case you are using the JavaScript and HTML objects on the parent page, so you don’t need to reference the sp.js in your HTML page.

July 16, 2015

How to Read Project Properties that are not Available in the Client Object Model?

Recently I had a development task that at the first sight seemed to be trivial, but it turned out quickly to be rather a challenge. I had to display some basic project information on a page in our Project Web Site (PWS), like project start and finish date, remaining work and percent complete. The web page was built using client-side technologies, like the Client-side object model (CSOM) for Project 2013 and using the AngularJS library, and we did not plan to change the architecture to server side code.

If you check the properties of the PublishedProject (either on the client side in namespace / assembly Microsoft.ProjectServer.Client or on the server side in Microsoft.ProjectServer), you see that it has properties like StartDate and FinishDate, and it inherits its PercentComplete property from the Project base class, however there is no property for RemainingWork or PercentWorkComplete defined, although both of these values are available as fields if you manage a Project Server view (see screenshot below). This information is not available via REST / OData either.

image

You should know, that in the case of  Project Server, the server side OM is simply a wrapper around the PSI, for example, the PercentComplete property in the Project class is defined:

public int PercentComplete
{
  get
  {
    ProjectDataSet.TaskRow summaryTaskRow = this.SummaryTaskRow;
    if (summaryTaskRow != null && !summaryTaskRow.IsTASK_PCT_COMPNull())
      return summaryTaskRow.TASK_PCT_COMP;
    else
      return 0;
  }
}

Client side OMs (either managed or ECMAScript) and REST calls invoke the server side OM, so at the background the good old PSI is still in action.

It seems that the developers of Project Server remained simply not enough time to map all of the fields available via PSI to the object models on the server side and the client side.

You should know either, that the project properties we need are stored as task properties for the project summary task of the current project. In the Project Server database the tasks of the published projects (so the project summary tasks as well) are stored in the [pub].[MSP_TASKS] table. If you run the following query (where ProjectWebApp is the name of the database and the Guid in the [PROJ_UID] filter is the ID of your project), you find some specific field values that may help to identify the summary task record of a project:

SELECT [TASK_UID]    
      ,[TASK_PARENT_UID]
      ,[TASK_ID]
      ,[TASK_OUTLINE_NUM]
      ,[TASK_OUTLINE_LEVEL]
      ,[TASK_NAME]
      ,[TASK_START_DATE]
      ,[TASK_FINISH_DATE]
      ,[TASK_PCT_COMP]
      ,[TASK_PCT_WORK_COMP]
      ,[TASK_REM_WORK]
  FROM [ProjectWebApp].[pub].[MSP_TASKS]
  WHERE [PROJ_UID] = ‘d0ae5086-be7a-e411-9568-005056b45654’

The project summary task record – at least, based on my experimental results – , matches the following conditions:

[TASK_ID] = 0

[TASK_OUTLINE_NUM] = 0

[TASK_OUTLINE_LEVEL] = 0

[TASK_UID] = [TASK_PARENT_UID]

But as said, we need a solution on the client side, and obviously one that does not tamper with the Project Server database. What options are there to achieve the missing information?

The Project class has a property called SummaryTaskId, but if you have this value already, and would like to query the project tasks via REST (for example: http://YourProjServer/PWA/_api/ProjectServer/Projects(‘d0ae5086-be7a-e411-9568-005056b45654’)/Tasks(‘FFAE5086-BE7A-E411-9568-005056B45654’)) or via the client object model, the result is empty. The description of the SummaryTaskId property says: “Gets the GUID for the hidden project summary task”. Yes, it is so hidden, that it simply not included in the Tasks collection of the Project class! The Tasks property of the PublishedProject class is of type PublishedTaskCollection, and on the server side the record for the project summary task is simply filtered out, when initializing the internal Dictionary used for the storage of the Task records. If you don’t believe me, or need more details on that, see the constructor method of Microsoft.ProjectServer.PublishedTaskCollection class below:

internal PublishedTaskCollection()
{
    Func<Dictionary<Guid, PublishedTask>> valueFactory = null;
    if (valueFactory == null)
    {
        valueFactory = () => base.ProjectData.Task.OfType<ProjectDataSet.TaskRow>().Where<ProjectDataSet.TaskRow>(delegate (ProjectDataSet.TaskRow r) {
            if (!r.IsTASK_PARENT_UIDNull())
            {
                return (r.TASK_PARENT_UID != r.TASK_UID);
            }
            return true;
        }).ToDictionary<ProjectDataSet.TaskRow, Guid, PublishedTask>(r => r.TASK_UID, r => this.CreateTask(r));
    }
    this._tasks = new Lazy<Dictionary<Guid, PublishedTask>>(valueFactory);
}

Of course, we get the same, empty result if we would like to filter the tasks for one the special conditions we found in the database (like [TASK_OUTLINE_LEVEL] = 0):
http://YourProjServer/PWA/_api/ProjectServer/Projects(‘d0ae5086-be7a-e411-9568-005056b45654&#8217;)/Tasks?$filter=OutlineLevel eq 0 

The project reporting data contains the project summary tasks as well, so we could invoke the ProjectData OData endpoint from the client side to query the required information. The problem with this approach is that it would require extra permissions on the reporting data and one cannot limit this permission to the summary tasks of a specific project, to summary tasks, or just to tasks at all. If you grant your users the Access Project Server Reporting Service global permission, they can query all of the reporting data. It is sure not our goal, but you can test it if you wish.

Once you have the ID of the project summary task (for example via the SummaryTaskId property), the task is available via a query like this one:

http://YourProjServer/PWA/_api/ProjectData/Tasks(ProjektID=guid’d0ae5086-be7a-e411-9568-005056b45654&#8242;,TaskID=guid’FFAE5086-BE7A-E411-9568-005056B45654′)

When using PSI, we can access the required information via the TASK_REM_WORK and TASK_PCT_WORK_COMP fields in ProjectDataSet.TaskRow, that means, rows in the Task property (type of  ProjectDataSet.TaskDataTable) of the ProjectDataSet. The first row in the record set contains the information about the project summary task.

We could create our own extensions for the client object model (wrapping around just this piece of  PSI), as I illustrated for the managed, and for the ECMAScript object model as well, but it would require a lot of work, so I ignored this option for now. Instead of this, I’ve created a simple .NET console application utilizing the PSI (see the most important part of the code below). Unfortunately, I have not found a method that returns only a specific task of a specific project, so I had to call the ReadProjectEntities method to read all of the tasks of the project.

  1. _projectClient = new SvcProject.ProjectClient(ENDPOINT_PROJECT, pwaUrl + "/_vti_bin/PSI/ProjectServer.svc");
  2. _projectClient.ClientCredentials.Windows.AllowedImpersonationLevel = System.Security.Principal.TokenImpersonationLevel.Impersonation;
  3.  
  4. Guid projId = Guid.Parse("d0ae5086-be7a-e411-9568-005056b45654");
  5. int taskEntityId = 2;
  6.  
  7. var projEntitiesDS = _projectClient.ReadProjectEntities(projId, taskEntityId, SvcProject.DataStoreEnum.PublishedStore);
  8. var tasksTable = projEntitiesDS.Task;
  9.  
  10. foreach (SvcProject.ProjectDataSet.TaskRow task in tasksTable.Rows)
  11. {
  12.     Console.WriteLine(string.Format("TASK_OUTLINE_NUM: {0}; TASK_PCT_WORK_COMP: {1}; TASK_REM_WORK: {2}", task.TASK_OUTLINE_NUM, task.TASK_PCT_WORK_COMP, task.TASK_REM_WORK));
  13. }

I’ve captured the request and the response using Fiddler:

image

Then extended my JavaScript code with methods that assemble the request in the same format, submit it to the server, then parse the required fields out of the response.

First, I needed a helper method to format strings:

  1. String.format = (function () {
  2.     // The string containing the format items (e.g. "{0}")
  3.     // will and always has to be the first argument.
  4.     var result = arguments[0];
  5.  
  6.     // start with the second argument (i = 1)
  7.     for (var i = 1; i < arguments.length; i++) {
  8.         // "gm" = RegEx options for Global search (more than one instance)
  9.         // and for Multiline search
  10.         var regEx = new RegExp("\\{" + (i – 1) + "\\}", "gm");
  11.         result = result.replace(regEx, arguments[i]);
  12.     }
  13.  
  14.     return result;
  15. });

In my Angular controller I defined this function to format dates:

  1. $scope.formatDate = function (date) {
  2.     var formattedDate = '';
  3.     if ((typeof date != "undefined") && (date.year() > 1)) {
  4.         formattedDate = String.format("{0}.{1}.{2}", date.year(), date.month() + 1, date.date());
  5.     }
  6.  
  7.     return formattedDate;
  8. }

Next, in the controller we get the ID of the project for the current PWS, and we read project properties that are available via the client object model, and finally the ones, that are available only via PSI:

  1. var promiseWebProps = ProjService.getWebProps($scope);
  2. promiseWebProps.then(function (props) {
  3.     $scope.projectId = props.projectId;
  4.  
  5.     // read the project properties that are available via the client object model
  6.     var promiseProjProp = ProjService.getProjectProps($scope);
  7.     promiseProjProp.then(function (props) {
  8.         $scope.projStartDate = moment(props.projStartDate);
  9.         $scope.projFinishDate = moment(props.projFinishDate);
  10.         $scope.percentComp = props.percentComp;
  11.     }, function (errorMsg) {
  12.         console.log("Error: " + errorMsg);
  13.     });
  14.  
  15.     // read the project properties that are available only via PSI
  16.     var promiseProjPropEx = ProjService.getProjectPropsEx($scope);
  17.     promiseProjPropEx.then(function (propsEx) {
  18.         $scope.remainingWork = Math.round(propsEx.remainingWork / 600) / 100;
  19.         $scope.percentWorkComp = propsEx.percentWorkComp;
  20.     }, function (errorMsg) {
  21.         console.log("Error: " + errorMsg);
  22.     });
  23.  
  24. }, function (errorMsg) {
  25.     console.log("Error: " + errorMsg);
  26. });

As you can see, the value we receive in the remainingWork property should be divided by 600 and 100 to get the value in hours.

In our custom ProjService service I’ve implemented the corresponding methods.

The project ID is stored in the property bag of the PWS in a property called MSPWAPROJUID (see this post about how to read property bags from the client object model):

  1. this.getWebProps = function ($scope) {
  2.     var deferred = $q.defer();
  3.  
  4.     var ctx = SP.ClientContext.get_current();
  5.  
  6.     var web = ctx.get_web();
  7.     var props = web.get_allProperties();
  8.     ctx.load(props);
  9.  
  10.  
  11.     ctx.executeQueryAsync(
  12.         function () {
  13.             var allProps = props.get_fieldValues();
  14.  
  15.             deferred.resolve(
  16.                 {
  17.                     projectId: allProps.MSPWAPROJUID
  18.                 });
  19.         },
  20.         function (sender, args) {
  21.             deferred.reject('Request failed. ' + args.get_message() + '\n' + args.get_stackTrace());
  22.         }
  23.     );
  24.  
  25.     return deferred.promise;
  26. };

Having the project ID, reading project properties via the client object model should be straightforward as well:

  1. this.getProjectProps = function ($scope) {
  2.     var deferred = $q.defer();
  3.  
  4.     var ctx = SP.ClientContext.get_current();
  5.  
  6.     var projContext = PS.ProjectContext.get_current();
  7.  
  8.     projContext.set_isPageUrl(ctx.get_isPageUrl);
  9.     var proj = projContext.get_projects().getById($scope.projectId);
  10.     projContext.load(proj, "StartDate", "FinishDate", "PercentComplete");
  11.  
  12.     projContext.executeQueryAsync(
  13.         function () {
  14.             deferred.resolve({
  15.                 projStartDate: proj.get_startDate(),
  16.                 projFinishDate: proj.get_finishDate(),
  17.                 percentComp: proj.get_percentComplete()
  18.             });
  19.         },
  20.         function (sender, args) {
  21.             deferred.reject('Request failed. ' + args.get_message() + '\n' + args.get_stackTrace());
  22.         }
  23.     );
  24.  
  25.     return deferred.promise;
  26. };

Reading the ‘extra’ properties via PSI is a bit more complex. First, we assemble the request XML as we captured it with Fiddler when used the console application mentioned above, and post it to the server. Next, we process the response (see the code of the helper method buildXMLFromString farther below), and parse out the necessary properties from the project summary task (that is the Task node having rowOrder = 0) using XPath queries.

  1. this.getProjectPropsEx = function () {
  2.     var deferred = $q.defer();
  3.    
  4.     // assuming your PWA is located at /PWA
  5.     var psiUrl = String.format("{0}//{1}/PWA/_vti_bin/PSI/ProjectServer.svc", window.location.protocol, window.location.host);
  6.    
  7.     $http({
  8.         method: 'POST',
  9.         url: psiUrl,
  10.         data: String.format('<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"><s:Body xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance&quot; xmlns:xsd="http://www.w3.org/2001/XMLSchema"><ReadProjectEntities xmlns="http://schemas.microsoft.com/office/project/server/webservices/Project/"><projectUid&gt;{0}</projectUid><ProjectEntityType>2</ProjectEntityType><dataStore>PublishedStore</dataStore></ReadProjectEntities></s:Body></s:Envelope>', $scope.projectId),
  11.         headers: {
  12.             "Content-Type": 'text/xml; charset=utf-8',
  13.             "SOAPAction": "http://schemas.microsoft.com/office/project/server/webservices/Project/ReadProjectEntities&quot;
  14.         }
  15.     }).success(function (data) {
  16.         var dataAsXml = buildXMLFromString(data);
  17.         dataAsXml.setProperty('SelectionLanguage', 'XPath');
  18.         dataAsXml.setProperty('SelectionNamespaces', 'xmlns:pds="http://schemas.microsoft.com/office/project/server/webservices/ProjectDataSet/&quot; xmlns:msdata="urn:schemas-microsoft-com:xml-msdata"');
  19.         var projSumTaskNode = dataAsXml.selectSingleNode("//pds:Task[@msdata:rowOrder=0]");
  20.         var remainingWork = projSumTaskNode.selectSingleNode("pds:TASK_REM_WORK").nodeTypedValue;
  21.         var percentWorkComp = projSumTaskNode.selectSingleNode("pds:TASK_PCT_WORK_COMP").nodeTypedValue;
  22.         deferred.resolve(
  23.             {
  24.                 remainingWork: remainingWork,
  25.                 percentWorkComp: percentWorkComp
  26.             });
  27.     })
  28.     .error(function (data, status) {
  29.         deferred.reject('Request failed. ' + data);
  30.     });
  31.     
  32.     return deferred.promise;
  33. }

These are the helper methods I used for processing the response text as XML:

  1. function createMSXMLDocumentObject() {
  2.     if (typeof (ActiveXObject) != "undefined") {
  3.         // http://blogs.msdn.com/b/xmlteam/archive/2006/10/23/using-the-right-version-of-msxml-in-internet-explorer.aspx
  4.         var progIDs = [
  5.                         "Msxml2.DOMDocument.6.0",
  6.                         "Msxml2.DOMDocument.3.0",
  7.                         "MSXML.DOMDocument"
  8.         ];
  9.         for (var i = 0; i < progIDs.length; i++) {
  10.             try {
  11.                 return new ActiveXObject(progIDs[i]);
  12.             } catch (e) { };
  13.         }
  14.     }
  15.  
  16.     return null;
  17. }
  18.  
  19. function buildXMLFromString(text) {
  20.     var xmlDoc;
  21.  
  22.     xmlDoc = createMSXMLDocumentObject();
  23.     if (!xmlDoc) {
  24.         alert("Cannot create XMLDocument object");
  25.         return null;
  26.     }
  27.  
  28.     xmlDoc.loadXML(text);
  29.  
  30.     var errorMsg = null;
  31.     if (xmlDoc.parseError && xmlDoc.parseError.errorCode != 0) {
  32.         errorMsg = "XML Parsing Error: " + xmlDoc.parseError.reason
  33.                     + " at line " + xmlDoc.parseError.line
  34.                     + " at position " + xmlDoc.parseError.linepos;
  35.     }
  36.     else {
  37.         if (xmlDoc.documentElement) {
  38.             if (xmlDoc.documentElement.nodeName == "parsererror") {
  39.                 errorMsg = xmlDoc.documentElement.childNodes[0].nodeValue;
  40.             }
  41.         }
  42.         else {
  43.             errorMsg = "XML Parsing Error!";
  44.         }
  45.     }
  46.  
  47.     if (errorMsg) {
  48.         alert(errorMsg);
  49.         return null;
  50.     }
  51.  
  52.     return xmlDoc;
  53. }

Having an HTML template like this one:

  1. <div><span>% complete:</span><span>{{percentComp}}%</span></div>
  2. <div><span>% work complete:</span><span>{{percentWorkComp}}%</span></div>
  3. <div><span>Remaining work:</span><span>{{remainingWork}} Hours</span></div>
  4. <div><span>Project start:</span><span>{{formatDate(projStartDate)}}</span></div>
  5. <div><span>Project finish:</span><span>{{formatDate(projFinishDate)}}</span></div>

the result should be displayed similar to this one:

image

A drawback of this approach (not to mention the fact that it is pretty hacky) is, that due the ReadProjectEntities method, all of the fields of all of the project tasks should be downloaded to the client, although we need only a few fields of a single task, the project summary task. So it would make sense to implement some kind of  caching on the client side, but it is out of the scope of this post. But as long as Microsoft does not provide all the project fields in the client object model, I have not found any better solution that would require a relative small effort.

July 6, 2015

Accessing and Manipulating Property Bags via the ECMAScript Client Object Model

Recently I work a lot with web applications implemented on the client side with JavaScript, mostly using the AngularJS library. Rather often should I build the application logic on the values stored in the property bags of the web objects, so I decided to sum up the experience I made in this field.

Note: A similar blog entry discussing the same topic can be found here. It might be useful to read that one as well, but I include additional info in my entry as well.

Note 2: The code samples in my post are borrowed from our custom AngularJS service, but the bulk of them should be reusable for any kind of JavaScript solution.

The first example shows how to retrieve the property values:

  1. this.readSettings = function ($scope) {
  2.     var deferred = $q.defer();
  3.  
  4.     var ctx = SP.ClientContext.get_current();
  5.  
  6.     var web = ctx.get_web();
  7.     var props = web.get_allProperties();
  8.     ctx.load(props);
  9.  
  10.     ctx.executeQueryAsync(
  11.         function () {
  12.             // you receive an error if the property is not defined for the web:
  13.             // The property or field has not been initialized. It has not been requested or the request has not been executed. It may need to be explicitly requested.
  14.             var yourProperty = props.get_item("YourProperty");
  15.             // or alternativelyyou can use
  16.             //var allProps = props.get_fieldValues();
  17.             //var yourProperty = allProps.YourProperty;
  18.             // in this second case you can  check, if the property is defined …
  19.             //if (typeof (yourProperty) == "undefined") {
  20.             //    console.log("The property is undefined");
  21.             //}
  22.             //…or iterate through all the properties
  23.             //for (var property in allProps) {
  24.             //    if (allProps.hasOwnProperty(property)) {
  25.             //        console.log(String.format("{0}: {1}", property, allProps[property]));
  26.             //    }
  27.             //}
  28.  
  29.             deferred.resolve(
  30.                 {
  31.                     projectId: projectId
  32.                 });
  33.         },
  34.         function (sender, args) {
  35.             deferred.reject('Request failed. ' + args.get_message() + '\n' + args.get_stackTrace());
  36.         }
  37.     );
  38.  
  39.     return deferred.promise;
  40. };

If you trace the network traffic with Fiddler, you will capture the following (or similar) request-response:

image

As you can see, all of the properties from the bag are returned by the server, they are in the form of simple property name – property value pairs (see response for Query with Id=6).

When you already have the response from the server, there are two different methods to get the value of a specific property. There is a very important difference between the two versions (get_item method vs. get_fieldValues method). If the property is undefined in the property bag, you receive an error, if you would like to read the value via props.get_item("YourProperty"):

The property or field has not been initialized. It has not been requested or the request has not been executed. It may need to be explicitly requested.

However, if you access the property via the get_fieldValues method, you don’t get the exception, even if there is no such property in the property bag. In this case the property remain simply undefined, so you can check its existence via

var allProps = props.get_fieldValues();
if (typeof (allProps.YourProperty) == "undefined") {
  console.log("The property is undefined");
}

In this case the properties from the bag are available as simple JavaScript properties from code:

image

You can dump (or process) all properties defined in the bag via this code (taken from this thread):

var allProps = props.get_fieldValues();
for (var property in allProps) {
  if (allProps.hasOwnProperty(property)) {
    console.log(String.format("{0}: {1}", property, allProps[property]));
  }
}

A sample output of the script in the debugging console:

image

Unfortunately, I have not found any way to limit the scope of properties returned by the query to specific properties. All of the properties in the property bag are returned by the request. The GetProperty method of the SPWeb class, that on the server side makes it possible to access the value of a single property, is not implemented in the client object model.

If you try this one:

var props = web.get_allProperties();
ctx.load(props, "Include(YourProperty)");

you will receive an exception complaining about the invalid request, as you call the executeQueryAsync method.

Saving the value back to the web properties is a straightforward operation:

  1. this.saveSettings = function ($scope) {
  2.     var deferred = $q.defer();
  3.  
  4.     var ctx = new SP.ClientContext();
  5.  
  6.     var web = ctx.get_web();
  7.     var props = web.get_allProperties();        
  8.     props.set_item("YourProperty", $scope.propValue);
  9.     web.update();
  10.  
  11.     ctx.executeQueryAsync(
  12.         function () {
  13.             deferred.resolve();
  14.         },
  15.         function (sender, args) {
  16.             deferred.reject('Request failed. ' + args.get_message() + '\n' + args.get_stackTrace());
  17.         }
  18.     );
  19.  
  20.     return deferred.promise;
  21. }

June 11, 2014

How to filter the results of JSCOM requests on the server side

Filed under: ECMAScript Client Object Model, SP 2013 — Tags: , — Peter Holpar @ 22:32

In the past I already discussed the internals of the Managed Client Object Model and the ECMAScript Object Model (a.k.a. JSCOM) several times. In this post I illustrate an unofficial way of extending the JSCOM.

WARNING: As I wrote above, the method I describe here is totally unofficial and unsupported. It’s only an experiment that depends on unpublished features of the JSCOM, like internal functions that can be changed or even removed without prior notice. Please, take my words seriously, and do not apply this method in a productive environment.

Assuming you are working with the Managed Client Object Model, you can submit queries, that are evaluated on the server side, and return only results that fulfill the conditions. For example, the request below returns only the filterable, non-hidden fields of a list (sample taken from MSDN):

clientContext.Load(oList,
    list => list.Fields.Where(
    field => field.Hidden == false
        && field.Filterable == true));
clientContext.ExecuteQuery();

The most important benefit of this syntax is that you can reduce the network traffic caused by returning irrelevant data (in this case fields) that we would have to filter on the client side without this feature.

Unfortunately, this feature seems to be missing in the ECMAScript Object Model, the Include keyword supports only limiting the members of the object to be returned (the query below returns only the Title and Id properties of each lists), but does not help to filter the results at all (e.g. you can not filter lists based on their properties).

clientContext.load(collList, ‘Include(Title, Id)’);

Since I’m working recently quite a lot with JSCOM (mainly in context of Project Server 2013), I was curious what happens when we use this feature from the Managed Client Object Model, and how we could inject the functionality into JSCOM.

Note: Yes, I know that REST makes it possible (via the $filter query option) to filter the items returned, however it has its own limitation, like the lack of the batch requests – e.g. aggregating requests on the client side and sending them in batches via executeQueryAsync – that is (at least, IMHO) one of the best features of JSCOM.

So I’ve created a simple .NET console application that submits the following query (first without, next with filtering) to the server:

ClientContext ctx = new ClientContext(“http://sp2013”);
// without filtering
// var listQuery = ctx.Web.Lists;
// with filtering
var listQuery = ctx.Web.Lists.Where(l => l.Title == "Images");
var list = ctx.LoadQuery(listQuery);
ctx.ExecuteQuery();

and captured the network traffic using Fiddler in both cases. The screenshot below highlights the difference between the two queries:

image

The equivalent version of the no-filter query in JavaScript:

  1. var context = SP.ClientContext.get_current();
  2. var lists = context.get_web().get_lists();
  3.  
  4. $(document).ready(function () {
  5.     getList();
  6. });
  7.  
  8. function getList() {
  9.     context.load(lists);
  10.     context.executeQueryAsync(onGetListSuccess, onGetListFail);
  11. }

As in the case of the .NET version, this query submits the request without the QueryableExpression. If we want to enable filtering in case of JSCOM, we should first find where the Query part of the request is assembled. Using a simple search in the .js files shows that this task is performed in the SP.ClientQueryInternal.prototype.$2s_1 method in the SP.Runtime.js.

The default implementation of this method is illustrated below (code taken from SP.Runtime.debug.js):

  1. $2s_1: function SP_ClientQueryInternal$$2s_1($p0, $p1) {
  2.     $p0.writeStartElement('Query');
  3.     this.$2r_1($p0, $p1);
  4.     $p0.writeEndElement();
  5.     if (this.$n_1) {
  6.         $p0.writeStartElement('ChildItemQuery');
  7.         this.$n_1.$2r_1($p0, $p1);
  8.         $p0.writeEndElement();
  9.     }
  10. }

We should create our custom override of the $2s_1 method that injects the QueryableExpression part of the query into the ChildItemQuery block (see a similar implementation in the $2w_0 method of the SP.ClientObjectPropertyConditionalScope.prototype in SP.Runtime.debug.js). For example (to filter child items with Title Images”):

  1. SP.ClientQueryInternal.prototype.$2s_1 = function SP_ClientQueryInternal$$2s_1($p0, $p1) {
  2.     $p0.writeStartElement('Query');
  3.     this.$2r_1($p0, $p1);
  4.     $p0.writeEndElement();
  5.     if (this.$n_1) {
  6.         $p0.writeStartElement('ChildItemQuery');
  7.         this.$n_1.$2r_1($p0, $p1);
  8.  
  9.         // custom code
  10.         
  11.         $p0.writeStartElement(SP.ClientConstants.QueryableExpression);
  12.         $p0.writeStartElement(SP.ClientConstants.where);
  13.         $p0.writeStartElement(SP.ClientConstants.Test);
  14.         $p0.writeStartElement(SP.ClientConstants.Parameters);
  15.         $p0.writeStartElement(SP.ClientConstants.Parameter);
  16.         $p0.writeAttributeString(SP.ClientConstants.Name, "l");
  17.         $p0.writeEndElement();  // Parameters
  18.         $p0.writeEndElement();  // Parameter
  19.         $p0.writeStartElement(SP.ClientConstants.Body);
  20.         $p0.writeStartElement(SP.ClientConstants.equal);
  21.         $p0.writeStartElement(SP.ClientConstants.expressionProperty);
  22.         $p0.writeAttributeString(SP.ClientConstants.Name, "Title");
  23.         $p0.writeStartElement(SP.ClientConstants.expressionParameter);
  24.         $p0.writeAttributeString(SP.ClientConstants.Name, "l");
  25.         $p0.writeEndElement();  // ExpressionParameter
  26.         $p0.writeEndElement();  // ExpressionProperty
  27.         $p0.writeStartElement(SP.ClientConstants.expressionConstant);
  28.         SP.DataConvert.writeValueToXmlElement($p0, "Images");
  29.         $p0.writeEndElement();  // EQ
  30.         $p0.writeEndElement();  // Body
  31.         $p0.writeEndElement();  // ExpressionConstant
  32.         $p0.writeEndElement();  // Test
  33.         $p0.writeStartElement(SP.ClientConstants.Object);
  34.         $p0.writeStartElement(SP.ClientConstants.queryableObject);
  35.         $p0.writeEndElement();  // QueryableObject
  36.         $p0.writeEndElement();  // Object
  37.         $p0.writeEndElement();  // Where
  38.         $p0.writeEndElement();  // QueryableExpression
  39.  
  40.         // end custom code
  41.  
  42.  
  43.         $p0.writeEndElement();
  44.     }
  45. }

If we define this method in our page as illustrated above, all request sent to the server will filter the child items using the condition defined in the override (Title EQ Images”). That is probably not what we would like to achieve. It would be better to limit the filtering for example to the context the request was sent from.

I’ve found, that the calling SP.ClientContext object is available as this.$0_1.

First, I‘ve created a filterBase function that writes the filtering part of the request stream based on the condition, filter name and filter value parameters:

  1. function filterBase($p0, condition, filterName, filterValue) {
  2.     $p0.writeStartElement(SP.ClientConstants.QueryableExpression);
  3.     $p0.writeStartElement(SP.ClientConstants.where);
  4.     $p0.writeStartElement(SP.ClientConstants.Test);
  5.     $p0.writeStartElement(SP.ClientConstants.Parameters);
  6.     $p0.writeStartElement(SP.ClientConstants.Parameter);
  7.     $p0.writeAttributeString(SP.ClientConstants.Name, "l");
  8.     $p0.writeEndElement();  // Parameters
  9.     $p0.writeEndElement();  // Parameter
  10.     $p0.writeStartElement(SP.ClientConstants.Body);
  11.     $p0.writeStartElement(condition);
  12.     $p0.writeStartElement(SP.ClientConstants.expressionProperty);
  13.     $p0.writeAttributeString(SP.ClientConstants.Name, filterName);
  14.     $p0.writeStartElement(SP.ClientConstants.expressionParameter);
  15.     $p0.writeAttributeString(SP.ClientConstants.Name, "l");
  16.     $p0.writeEndElement();  // ExpressionParameter
  17.     $p0.writeEndElement();  // ExpressionProperty
  18.     $p0.writeStartElement(SP.ClientConstants.expressionConstant);
  19.     SP.DataConvert.writeValueToXmlElement($p0, filterValue);
  20.     $p0.writeEndElement();  // EQ
  21.     $p0.writeEndElement();  // Body
  22.     $p0.writeEndElement();  // ExpressionConstant
  23.     $p0.writeEndElement();  // Test
  24.     $p0.writeStartElement(SP.ClientConstants.Object);
  25.     $p0.writeStartElement(SP.ClientConstants.queryableObject);
  26.     $p0.writeEndElement();  // QueryableObject
  27.     $p0.writeEndElement();  // Object
  28.     $p0.writeEndElement();  // Where
  29.     $p0.writeEndElement();  // QueryableExpression
  30. }

To keep our former example condition, we can call filterBase from a new function filterLists:

  1. function filterLists($p0) {
  2.     filterBase($p0, SP.ClientConstants.equal, "Title", "Images");
  3. }

We assign the filtering function in our getLists method to the context:

  1. function getList() {
  2.     context.load(lists);
  3.     context.filter = filterLists;
  4.     context.executeQueryAsync(onGetListSuccess, onGetListFail);
  5. }

In the new version of the of the $2s_1 method override we check if the current context has a filtering method assigned to, and if one is found, it is called:

  1. SP.ClientQueryInternal.prototype.$2s_1 = function SP_ClientQueryInternal$$2s_1($p0, $p1) {
  2.     $p0.writeStartElement('Query');
  3.     this.$2r_1($p0, $p1);
  4.     $p0.writeEndElement();
  5.     if (this.$n_1) {
  6.         $p0.writeStartElement('ChildItemQuery');
  7.         this.$n_1.$2r_1($p0, $p1);
  8.  
  9.         // custom code
  10.  
  11.         var context = this.$0_1;
  12.         if (context.filter != undefined) {
  13.             context.filter($p0);
  14.         }
  15.  
  16.         // end custom code
  17.  
  18.  
  19.         $p0.writeEndElement();
  20.     }
  21. }

OK, we are one step further now, but wouldn’t it be nice, if we could query more objects in the same context (and the same batch) and have the option to set different filters for each one?

I’ve found that the object path property of the client object to be queried is available in the $2s_1 method via this.$G_0.

In this case we should override the $2s_1 method like this:

  1. SP.ClientQueryInternal.prototype.$2s_1 = function SP_ClientQueryInternal$$2s_1($p0, $p1) {
  2.     $p0.writeStartElement('Query');
  3.     this.$2r_1($p0, $p1);
  4.     $p0.writeEndElement();
  5.     if (this.$n_1) {
  6.         $p0.writeStartElement('ChildItemQuery');
  7.         this.$n_1.$2r_1($p0, $p1);
  8.  
  9.         // custom code
  10.  
  11.         var objectPathProp = this.$G_0;
  12.         if (objectPathProp.filter != undefined) {
  13.             objectPathProp.filter($p0);
  14.         }
  15.  
  16.         // end custom code
  17.  
  18.  
  19.         $p0.writeEndElement();
  20.     }
  21. }

The object path property is available as myObject.$5_0.$e_0 property of the client object (for example, lists.$5_0.$e_0), so we can rewrite our methods as illustrated below. In this case we extend our example with a further object (groups) that should be filtered by the filterGroups method (OwnerTitle property of the group EQ "DevSite Owners"). The getList function was also renamed to getObjects.

  1. var context = SP.ClientContext.get_current();
  2. var lists = context.get_web().get_lists();
  3. var groups = context.get_web().get_siteGroups();
  4.  
  5. $(document).ready(function () {
  6.     getObjects();
  7. });
  8.  
  9. function filterLists($p0) {
  10.     filterBase($p0, SP.ClientConstants.equal, "Title", "Images");
  11. }
  12.  
  13. function filterGroups($p0) {
  14.     filterBase($p0, SP.ClientConstants.equal, "OwnerTitle", "DevSite Owners");
  15. }
  16.  
  17. function getObjects() {
  18.     context.load(lists);
  19.     context.load(groups);
  20.  
  21.     lists.$5_0.$e_0.filter = filterLists;
  22.     groups.$5_0.$e_0.filter = filterGroups;
  23.     context.executeQueryAsync(onGetListSuccess, onGetListFail);
  24. }

The following Fiddler screenshot shows the resulting query:

image

This kind of overrides provides already a quite flexible “framework” for filtering objects on the server side using the JSCOM, making this important feature available for JavaScript developers as well. It would be nice if Microsoft would provide a similar, but official solution to this issue in a forthcoming service pack.

We can see from this experiment as well, that there is much more power available in the XML / JSON communication protocol behind the client object models, than it is made public by this APIs, so there is yet place for improvements. You can find valuable information related to the SharePoint Client Query Protocol here.

April 8, 2014

Mysterious “File Not Found” Error When Working With the ECMAScript Client Object Model

Recently I was debugging a very simple JavaScript in Internet Explorer 9 with the F12 Developer Tools, when received a “File Not Found” error. In the script I tried to open a site via

webToCheck = context.get_site().openWeb(‘/Subweb’);

and it seemed as the site did not exist. However, the same site could be opened from the Managed Client Object Model.

I launched Fiddler to check what happens in the background and was surprised to see another web site (let’s call it SubwebWrong) in the request. This web site really did not exist, so the response of the server was reasonable.

image

But where did that site name came from? I found a former entry in the Watch window of the Developer Tools, that referred to this site:

webToCheck = context.get_site().openWeb(‘/SubwebWrong’);

So it seems that this command was executed automatically by IE, that caused the script to malfunction. Pretty strange behavior, indeed.

During debugging I received an erratic “Access denied. You do not have permission to perform this action or access this resource.” error as well. It happened typically after working for a longer time on the same page. I assume that the reason is that the form digest on the page was timed out.

July 29, 2013

ECMAScript Client Object Model Internals – Creating custom client OM extensions

Filed under: ECMAScript Client Object Model, SP 2010 — Tags: , — Peter Holpar @ 18:26

About two years ago I published a sample about the extensibility of the Managed Client Object Model of SharePoint 2010. To understand the theory behind the code in the current post you should read that article and my other posts around the client OM first.

Since that I’ve got the question several times, if a similar extension would be available for the JavaScript / ECMAScript object model. Due to the increasing popularity of the JavaScript based solutions around SharePoint, this topic became more interesting in the past months.

The answer for the previous question is yes, it is definitely possible to extend the out-of-the-box client JS libraries, and it is not even so complicate as one might think. To tell the truth, I had more troubles with loading the .js files in the correct order (see the issue and the solution later) as with implementing the library extensions themself. In this post I show you an extension built on the previous sample. As a prerequisite, you should deploy and configure (in web.config) the server-side components from that post.

Next, I’ve created a new .js file called CustomScripts.js (see code below) and deployed it to 14\TEMPLATE\LAYOUTS\Custom.

  1. // registering our custom namespace
  2. Type.registerNamespace('SS');
  3.  
  4. // ———————————————-
  5. // SS.CustomRequestContext class
  6. // ———————————————-
  7. SS.CustomClientContext = function (serverRelativeUrl) {
  8.     SS.CustomClientContext.initializeBase(this, [(SP.ScriptUtility.isNullOrUndefined(serverRelativeUrl)) ? SP.PageContextInfo.get_webServerRelativeUrl() : serverRelativeUrl]);
  9. }
  10. SS.CustomClientContext.get_current = function () {
  11.     if (!SS.CustomClientContext.$1S_1) {
  12.         SS.CustomClientContext.$1S_1 = new SS.CustomClientContext(SP.PageContextInfo.get_webServerRelativeUrl());
  13.     }
  14.     return SS.CustomClientContext.$1S_1;
  15. }
  16. SS.CustomClientContext.prototype = {
  17.     get_customClientObject: function () {
  18.         var $v_0 = SS.CustomRequestContext.getCurrent(this).get_customClientObject();
  19.         return $v_0;
  20.     }
  21. }
  22.  
  23. // ———————————————-
  24. // SS.CustomRequestContext class
  25. // ———————————————-
  26. SS.CustomRequestContext = function (Context, ObjectPath) {
  27.     SS.CustomRequestContext.initializeBase(this, [Context, ObjectPath]);
  28. }
  29. SS.CustomRequestContext.getCurrent = function ($p0) {
  30.     var $v_0 = $p0.get_staticObjects()['ClientExtension$Server$SSCustomContext$Current'];
  31.     if ((!$v_0)) {
  32.         $v_0 = new SS.CustomRequestContext($p0, new SP.ObjectPathStaticProperty($p0, '{DF694817-22BA-4952-A1E9-84C6E69709A8}', 'Current'));
  33.         $p0.get_staticObjects()['ClientExtension$Server$SSCustomContext$Current'] = $v_0;
  34.     }
  35.     return ($v_0);
  36. }
  37. SS.CustomRequestContext.prototype = {
  38.     get_customClientObject: function () {
  39.         var $v_0 = ((this.get_objectData().get_clientObjectProperties()['CustomClientObject']));
  40.         if (SP.ScriptUtility.isUndefined($v_0)) {
  41.             $v_0 = new SS.CustomClientObject(this.get_context(), new SP.ObjectPathProperty(this.get_context(), this.get_path(), 'CustomClientObject'));
  42.             this.get_objectData().get_clientObjectProperties()['CustomClientObject'] = $v_0;
  43.         }
  44.         return $v_0;
  45.     }
  46. }
  47.  
  48. // ———————————————-
  49. // SS.CustomClientObject class
  50. // ———————————————-
  51. SS.CustomClientObject = function (Context, ObjectPath) {
  52.     SS.CustomClientObject.initializeBase(this, [Context, ObjectPath]);
  53. }
  54. SS.CustomClientObject.prototype = {
  55.     getMessage: function (name) {
  56.  
  57.         var $v_0;
  58.         var $v_1 = new SP.ClientActionInvokeMethod(this, 'GetMessage', [name]);
  59.         this.get_context().addQuery($v_1);
  60.         $v_0 = new SP.StringResult();
  61.         this.get_context().addQueryIdAndResultObject($v_1.get_id(), $v_0);
  62.         this.removeFromParentCollection();
  63.        
  64.         return $v_0;
  65.     }
  66. }
  67.  
  68. // registering our custom classes
  69. SS.CustomClientContext.registerClass('SS.CustomClientContext', SP.ClientContext);
  70. SS.CustomRequestContext.registerClass('SS.CustomRequestContext', SP.ClientObject);
  71. SS.CustomClientObject.registerClass('SS.CustomClientObject', SP.ClientObject);
  72.  
  73. // notify waiting jobs
  74. NotifyScriptLoadedAndExecuteWaitingJobs("CustomScripts.js");

If you understand the internals of the managed client OM from my former posts and had already a look at the source code in the standard SharePoint client OM .js files, the code above should be pretty straightforward. It’s nothing more than inheriting our custom JS classes from the standard SharePoint JS classes, and implementing properties and methods as it is done in the standard libraries (almost copy/paste).

For testing the new library, I’ve created a new web part page, and added a Content Editor Web Part (CEWP) with the code below:

  1. <script type="text/javascript">
  2.  
  3. // wait the standard client OM scripts to be loaded first
  4. ExecuteOrDelayUntilScriptLoaded(loadScript, 'sp.js');
  5.  
  6. // load our custom script next
  7. function loadScript() {
  8.     var script = document.createElement("script");
  9.     script.src = "/_layouts/Custom/CustomScripts.js";
  10.     script.type = "text/javascript";
  11.     document.getElementsByTagName("head")[0].appendChild(script);
  12. }
  13.  
  14. // when the script is loaded, execute the custom OM methods
  15. ExecuteOrDelayUntilScriptLoaded(startScript, 'CustomScripts.js');
  16.  
  17. var result;
  18.  
  19. function startScript() {
  20.   try {
  21.     var context = SS.CustomClientContext.get_current();
  22.     var obj = context.get_customClientObject();
  23.     result = obj.getMessage('Joe');
  24.     context.executeQueryAsync(Function.createDelegate(this, this.success), Function.createDelegate(this, this.failed));
  25.   }
  26.   catch (e) {
  27.     alert("Error: " + e);
  28.   }
  29. }
  30.  
  31. function failed(sender, args) {
  32.     alert("Operation failed: " + args.get_message());
  33. }
  34.  
  35. function success(sender, args) {
  36.     alert(result.get_value());
  37. }
  38.  
  39. </script>

The standard SharePoint client OM .js files (like SP.Runtime.js first and SP.js next) are loaded by the web part page automatically. These files must be loaded first. Our extension script (CustomScripts.js) is built upon objects in these standard files, so it must be loaded after loading the standard libraries is finished. Loading the files in the wrong order (the standard OM files must be completely loaded before loading our extension!) causes mysterious errors. I solved this requirement through the combination of ExecuteOrDelayUntilScriptLoaded methods and NotifyScriptLoadedAndExecuteWaitingJobs method (see at the end of CustomScripts.js above) and the custom loadScript method.

After this preparation, the client OM extension objects and methods can be used as their standard OM counterparts. Of course, in this case we must start the script with our custom context (SS.CustomClientContext), and not the standard SharePoint client object model context (SP.ClientContext).

The image below illustrates the expected output of the script:

image

January 9, 2013

Using IE as a local Host for SharePoint ECMAScript Client Object Model, the Office 365 version

In my recent post I wrote about how you can use the JavaScript Client Object Model (JSCOM) against a remote SharePoint 2010 server from a local HTML page. In the current post my goal is to demonstrate a similar technique, but in this case against the Office 365 Developer Preview, that is the cloud-based version of SharePoint 2013.

NOTE: Again, be aware that the methods described here don’t use the public, documented interfaces, so it is probably not supported by MS, and suggested to be used only as an experiment. It is what can be done, and not a best practice at all. There is no guarantee that it will work for you, especially after MS updates the version of Office 365. I publish this results as I believe one can learn a few tricks around the internals of JSCOM and communication between client and server.

In the previous post we authenticated our requests using the standard Windows-integrated authentication method. In the case of Office 365 that is rather different, as it requires claim-based authentication (see these articles on  CodeProject for an introduction on the theme, or this guide from Microsoft for a deep dive). You can find excellent examples for such authentication against Office 365 when using the Managed Client Object Model, including this code sample on MSDN. In the code from Sundararajan Narasiman and Wictor Wilén the Windows Identity Foundation (WIF) classes have been used. Doug Ware, another fellow MVP, published a similar solution, but without involving WIF into the game. You can even find an example for PHP, but for JavaScript I found no solution on the web.

The detailed process of the authentication is very well described on MSDN, so I don’t rehash all the steps here, just provide a quick overview to enable better understanding of the JavaScript code sample below.

1. We get the token from the security token service (STS) of MS Online.

2. "Login" to the actual O365 site using the token provided by STS in the former step. As a result of this step, we have the required cookies (FedAuth and rtFA) to be used automatically in the next steps. These cookies are set by Set-Cookie headers of the response and cached and reused by the browser for later requests targeting the same site.

3. Get the digest from the Sites web service and refresh the one stored in the local page.

4. Execute the JSCOM request (after setting the full URL)

As you can see, the last two steps are identical to the steps we performed in the case of on-premise SharePoint in the last post.

And here is the actual code to demonstrate the theory in practice. Don’t forget to set the URLs for JavaScript file references to match your site name, as well as the values of the JavaScript variables, like usr, pwd and siteFullUrl. BTW, it seems that one can access the SharePoint JavaScript files in the LAYOUTS folder without authentication, at least, I get no authentication prompt when I try to download one.

Code Snippet
  1. <script type="text/ecmascript" src="http://code.jquery.com/jquery-1.8.3.min.js"></script>
  2. <script type="text/ecmascript" src="https://yourdomain-my.sharepoint.com/_layouts/1033/init.js"></script>
  3. <script type="text/ecmascript" src="https://yourdomain-my.sharepoint.com/_layouts/MicrosoftAjax.js"></script>
  4. <script type="text/ecmascript" src="https://yourdomain-my.sharepoint.com/_layouts/CUI.js"></script>
  5. <script type="text/ecmascript" src="https://yourdomain-my.sharepoint.com/_layouts/1033/Core.js"></script>
  6. <script type="text/ecmascript" src="https://yourdomain-my.sharepoint.com/_layouts/SP.Core.js"></script>
  7. <script type="text/ecmascript" src="https://yourdomain-my.sharepoint.com/_layouts/SP.Runtime.js"></script>
  8. <script type="text/ecmascript" src="https://yourdomain-my.sharepoint.com/_layouts/SP.js"></script>
  9.  
  10. <input type="hidden" name="__REQUESTDIGEST" id="__REQUESTDIGEST" value="0x753C251974CACCF3B030F7FF1358D0E0229B6DE0B0D363A0272EDBF69FBE4225A2107BE0998E236C248D2116D0A47B0D1849248B558F420AB09BDE06CFCFDB56,07 Jan 2013 13:30:54 -0000" />
  11.  
  12. <script language="ecmascript" type="text/ecmascript">
  13.     var tokenReq = '<?xml version="1.0" encoding="utf-8"?>';
  14.     tokenReq += '<soap:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance&quot; xmlns:xsd="http://www.w3.org/2001/XMLSchema&quot; xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">&#039;;
  15.     tokenReq += '  <soap:Body>';
  16.     tokenReq += '    <GetUpdatedFormDigestInformation xmlns="http://schemas.microsoft.com/sharepoint/soap/&quot; />';
  17.     tokenReq += '  </soap:Body>';
  18.     tokenReq += '</soap:Envelope>';
  19.  
  20.     // you should set these values according your actual request
  21.     var usr =  'username@yourdomain.onmicrosoft.com';
  22.     var pwd = 'password';
  23.     var siteFullUrl = "https://yourdomain-my.sharepoint.com&quot;;
  24.  
  25.     var loginUrl = siteFullUrl + "/_forms/default.aspx?wa=wsignin1.0";
  26.     var authReq =   '<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope&quot; xmlns:a="http://www.w3.org/2005/08/addressing&quot; xmlns:u="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd">&#039;
  27.     authReq +=      '  <s:Header>'
  28.     authReq +=      '    <a:Action s:mustUnderstand="1">http://schemas.xmlsoap.org/ws/2005/02/trust/RST/Issue</a:Action>&#039;
  29.     authReq +=      '    <a:ReplyTo>'
  30.     authReq +=      '      <a:Address>http://www.w3.org/2005/08/addressing/anonymous</a:Address>&#039;
  31.     authReq +=      '    </a:ReplyTo>'
  32.     authReq +=      '    <a:To s:mustUnderstand="1">https://login.microsoftonline.com/extSTS.srf</a:To>&#039;
  33.     authReq +=      '    <o:Security s:mustUnderstand="1" xmlns:o="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd">&#039;
  34.     authReq +=      '      <o:UsernameToken>'
  35.     authReq +=      '        <o:Username>' + usr + '</o:Username>'
  36.     authReq +=      '        <o:Password>' + pwd + '</o:Password>'
  37.     authReq +=      '      </o:UsernameToken>'
  38.     authReq +=      '    </o:Security>'
  39.     authReq +=      '  </s:Header>'
  40.     authReq +=      '  <s:Body>'
  41.     authReq +=      '    <t:RequestSecurityToken xmlns:t="http://schemas.xmlsoap.org/ws/2005/02/trust"><wsp:AppliesTo xmlns:wsp="http://schemas.xmlsoap.org/ws/2004/09/policy">&#039;
  42.     authReq +=      '      <a:EndpointReference>'
  43.     authReq +=      '        <a:Address>' + loginUrl + '</a:Address>'
  44.     authReq +=      '      </a:EndpointReference>'
  45.     authReq +=      '      </wsp:AppliesTo>'
  46.     authReq +=      '      <t:KeyType>http://schemas.xmlsoap.org/ws/2005/05/identity/NoProofKey</t:KeyType>&#039;
  47.     authReq +=      '      <t:RequestType>http://schemas.xmlsoap.org/ws/2005/02/trust/Issue</t:RequestType>&#039;
  48.     authReq +=      '      <t:TokenType>urn:oasis:names:tc:SAML:1.0:assertion</t:TokenType>'
  49.     authReq +=      '    </t:RequestSecurityToken>'
  50.     authReq +=      '  </s:Body>'
  51.     authReq +=      '</s:Envelope>';
  52.     
  53.     var lists;
  54.  
  55.     function startScript() {
  56.       getToken();
  57.     }
  58.  
  59.     // Step 1: we get the token from the STS
  60.     function getToken()
  61.     {
  62.         $.support.cors = true; // enable cross-domain query
  63.         $.ajax({
  64.             type: 'POST',
  65.             data: authReq,
  66.             crossDomain: true, // had no effect, see support.cors above
  67.             contentType: 'application/soap+xml; charset=utf-8',
  68.             url: 'https://login.microsoftonline.com/extSTS.srf&#039;,         
  69.             dataType: 'xml',
  70.             complete: function (result) {
  71.                 // extract the token from the response data
  72.                 // var token = $(result.responseXML).find("wsse\\:BinarySecurityToken").text(); // responseXML is undefined, we should work with responseText, because Content-Type: application/soap+xml; charset=utf-8
  73.                 var token = $(result.responseText).find("BinarySecurityToken").text();
  74.                 getFedAuthCookies(token);
  75.             },
  76.             error: function(XMLHttpRequest, textStatus, errorThrown) {
  77.                 alert(errorThrown);
  78.                 }
  79.         });
  80.     }
  81.  
  82.     // Step 2: "login" using the token provided by STS in step 1
  83.     function getFedAuthCookies(token)
  84.     {
  85.         $.support.cors = true; // enable cross-domain query
  86.         $.ajax({
  87.             type: 'POST',
  88.             data: token,
  89.             crossDomain: true, // had no effect, see support.cors above
  90.             contentType: 'application/x-www-form-urlencoded',
  91.             url: loginUrl,         
  92.          // dataType: 'html', // default is OK: Intelligent Guess (xml, json, script, or html)
  93.             complete: function (result) {  
  94.                 refreshDigest();
  95.             },
  96.             error: function(XMLHttpRequest, textStatus, errorThrown) {
  97.                 alert(errorThrown);
  98.             }
  99.         });
  100.     }
  101.  
  102.     // Step 3: get the digest from the Sites web service and refresh the one stored locally
  103.     function refreshDigest()
  104.     {
  105.         $.support.cors = true; // enable cross-domain query
  106.         $.ajax({
  107.                 type: 'POST',
  108.                 data: tokenReq,
  109.                 crossDomain: true, // had no effect, see support.cors above
  110.                 contentType: 'text/xml; charset="utf-8"',
  111.                 url: siteFullUrl + '/_vti_bin/sites.asmx',
  112.                 headers: {
  113.                     'SOAPAction': 'http://schemas.microsoft.com/sharepoint/soap/GetUpdatedFormDigestInformation&#039;,
  114.                     'X-RequestForceAuthentication': 'true'
  115.                 },
  116.                 dataType: 'xml',
  117.                 complete: function (result) {  
  118.                     $('#__REQUESTDIGEST').val($(result.responseXML).find("DigestValue").text());
  119.                     sendJSCOMReq();
  120.                 },
  121.                 error: function(XMLHttpRequest, textStatus, errorThrown) {
  122.                     alert(errorThrown);
  123.                 }
  124.         });
  125.     }
  126.  
  127.     // Step 4: execute the JSCOM request (after setting the full URL)
  128.     function sendJSCOMReq() {
  129.         try {
  130.  
  131.             var spPageContextInfo = {webServerRelativeUrl: "\u002fTestappforSharePoint", webAbsoluteUrl: "https:\u002f\u002fyour.sharepoint.com\u002fTestappforSharePoint", siteAbsoluteUrl: "https:\u002f\u002fyourdomain-f7079688f25f20.sharepoint.com", serverRequestPath: "\u002fTestappforSharePoint\u002fPages\u002fDefault.aspx", layoutsUrl: "_layouts\u002f15", webTitle: "Test app for SharePoint", webTemplate: "17", tenantAppVersion: "0", webLogoUrl: "\u002f_layouts\u002f15\u002fimages\u002fsiteIcon.png?rev=23", webLanguage: 1033, currentLanguage: 1033, currentUICultureName: "en-US", currentCultureName: "en-US", clientServerTimeDelta: new Date("2013-01-07T13:57:14.0337474Z") – new Date(), siteClientTag: "0$$15.0.4433.1011", crossDomainPhotosEnabled:true, webUIVersion:15, webPermMasks:{High:2147483647,Low:4294967295}, pagePersonalizationScope:1,userId:11, systemUserKey:"i:0h.f|membership|1003bffd844f8d57@live.com", alertsEnabled:true, siteServerRelativeUrl: "\u002f", allowSilverlightPrompt:'True'};
  132.  
  133.  
  134.             var siteRelativeUrl = "/";
  135.             var context = new SP.ClientContext(siteRelativeUrl);
  136.             context.$1P_0 = siteFullUrl;
  137.  
  138.             var web = context.get_web();
  139.             lists = web.get_lists();
  140.  
  141.             context.load(lists);
  142.             context.executeQueryAsync(Function.createDelegate(this, this.onQuerySucceeded), Function.createDelegate(this, this.onQueryFailed));  
  143.         } catch (err) {
  144.             var msg = "There was an error on this page.\n";
  145.             msg += "Error description: " + err.message + "\n";
  146.             alert(msg);
  147.         }
  148.     }
  149.  
  150.     // Step 5 (success): process response
  151.     function onQuerySucceeded(sender, args) {
  152.         var count = lists.get_count();
  153.         var listTitles = "Number of lists: " + count + ":\n";
  154.         for(var i=0;i<count; i++)
  155.         {
  156.             var list = lists.get_item(i);
  157.             listTitles += "  " + list.get_title() + "\n";
  158.         }
  159.         alert(listTitles);
  160.     }
  161.  
  162.     // Step 5 (failure): display error
  163.     function onQueryFailed(sender, args) {
  164.       alert("Request failed: "+ args.get_message());
  165.     }
  166.  
  167.     // start the custom script execution after the scripts and page are loaded
  168.     SP.SOD.executeOrDelayUntilScriptLoaded(function () {
  169.         $(document).ready(startScript);
  170.     }, "sp.js");
  171.  
  172. </script>

You might be wondering, how it is possible to come up with a solution like that. As a first step, I downloaded a few of the managed client object model / WIF samples mentioned above (thank you guys for sharing, you all made my life easier!), and created a simple test console application using them. I analyzed the network traffic (even it is HTTPS!) using Fiddler. Then I tried to understand (based on my knowledge about the authentication process), what happened and why (request data and headers, response data, cookies, etc.). Last (and probably the longest) step was an iteration of trial and error, when I was to reproduce the same network traffic using JavaScript / jQuery objects, step-by-step analyzing the results, comparing them to the original measurements captured for the test console application. So it took some time, but at the end I was quite happy with the results.

January 8, 2013

Using IE as a local Host for SharePoint ECMAScript Client Object Model

In the recent weeks I got an idea of a specific client-side HTML application (HTML file located on the local hard drive) that communicate with the SharePoint server. More on that idea later, as I hopefully achieve some results, but for now I would like to share a few interesting byproducts of my research.

This time I start with a few questions: what can we do, if we need a client application that manipulates SharePoint objects on a computer that doesn’t have (or can’t have) the managed client object model installed on it? Let’s say, you don’t have even the necessary .NET runtime version, or you don’t have Visual Studio, you have no former C# / PowerShell experience (or at least, you would not like to bother with them as you need a quickly editable application), so assume all you know is HTML and the good old JavaScript. You can’t deploy files to the SharePoint site, you don’t have even page edit permissions, so the standard way of injecting JavaScript through the Content Editor Web Part (CEWP) is not available to you. Using the REST API from your HTML pages through web requests could be an option, but it might be simply not powerful enough (especially in SharePoint 2010 version) to achieve your goals.

Using the ECMAScript Client Object Model (aka JavaScript Client Object Model or JSCOM) would be ideal to this task, but it has a significant out-of-the-box limitation: it was designed to work in the SharePoint site context it interacts with, that means, you can use only relative URLs when instantiating the SP.ClientContext object, or get the current context from the SharePoint page the script is used on, none of these is possible when working with a local HTML file.

In the last years I already delved quite deep into the client object model (both the managed and the JavaScript versions), and learned that even the out-of-the-box asynchronous calls can be altered to synchronous, so we should never give up.

In this post I show you a possible workaround for the problem in the case of an on-premise SharePoint 2010 that authenticates users through standard windows-integrated authentication. In the next post I plan to illustrate an even more complex solution for the case of the Office 365 Preview (that is a cloud-based SharePoint 2013).

As always, Fiddler proved again to be an invaluable tool analyzing the network traffic caused by the managed OM and trying to simulate the same for ECMAScript.

NOTE: As in the case of the former hacks, be aware that the methods described here don’t use the public, documented interfaces, so it is probably not supported by MS, and suggested to be used only as an experiment. There is no guarantee that it will work for you, especially if our environments are at different SP / cumulative update level.

In the progress of my work, I faced two major (and several minor) issues:

1. How to enforce using of absolute site URLs in JSCOM instead of the relative ones? It was the easier problem to solve as you see it soon.

2. How to achieve the right request digest token to get authorization to access the server side object from the JavaScript code.

Prerequisites to start the work with JSCOM in the local HTML files:

  • References to the SharePoint JavaScript libraries (and jQuery if you would like to work with that). These ones are included “automatically” when working with standard SharePoint pages, but in this case we should reference them (in the correct order!) as well. We could start our custom scripts only after our page and these libraries are already loaded.
  • A defined JavaScript variable (see more about that below) called _spPageContextInfo. That is in our case only a dummy placeholder (can be copied from the source of a SharePoint page) that the scripts depend on.
  • A HTML input field (by default it is a hidden one) called __REQUESTDIGEST that contains the authentication digest for the page. It can be copied from the source of a SharePoint page as well, but it has an expiration time. It is no problem in the case of a web page, as it is refreshed on the server site on page reloads, but it is not the case in the local file, so we should get always an actual one (see problem 2 above).

Regarding Issue 1: After a short investigation I found that the URL the client requests are sent to is determined by the $1P_0 property of the SP.ClientContext object. We should set our custom value right after initializing the context.

var siteRelativeUrl = "/";
var siteFullUrl = "http://intranet.contoso.com&quot;;
var context = new SP.ClientContext(siteRelativeUrl); // we set a dummy relative path that always exists
context.$1P_0 = siteFullUrl;

Setting the absolute URL is not enough for the successful request. Next step was to solve the issue of the request digest as without that our request would be rejected. It took me a while, but at the end it turned to be the same token as the one returned by the GetUpdatedFormDigestInformation method of the Sites web service. So all we have to do is to call this method with the properly formatted request, and replace the value of the __REQUESTDIGEST field before calling the actual JSCOM code.

On of the minor issues was, how to send POST requests to another domain (issue with cross-domain scripting), in this case to access the Sites web service. For GET requests there is JSONP (you can set crossDomain: true for your AJAX request in jQuery), but for POST, it is not available, crossDomain had simply no effect during my tests. In this situation I received an “Invalid argument.” error thrown in MicrosoftAjax.js when setting headers for the web requests.

Fortunately I found this jQuery option that solved this problem for me:

$.support.cors = true;

NOTE: When you open your HTML page not from the file system, but rather through another web server, you might be faced with the following prompt:

image

Choosing the default value (that is “No”) results in an “Access is denied.” error in MicrosoftAjax.js.

NOTE: For the sake of simplicity, I’ve uploaded a jQuery version (v1.8.2 to be exact, as the version number may be important) to my SharePoint server. In the real life one can reference for example http://code.jquery.com/jquery-latest.min.js instead of this.

Finally, here is the full source code of a “minimized” page that displays the number and the name of the lists on a remote SharePoint site. It is assumed that you are online (access to SP) and are (or can be) authenticated by the SharePoint server, having minimum read permissions on the site:

Code Snippet
  1. <script type="text/ecmascript" src="http://intranet.contoso.com/_layouts/jquery/jquery.min.js"></script>
  2. <script type="text/ecmascript" src="http://intranet.contoso.com/_layouts/1033/init.js"></script>
  3. <script type="text/ecmascript" src="http://intranet.contoso.com/_layouts/MicrosoftAjax.js"></script>
  4. <script type="text/ecmascript" src="http://intranet.contoso.com/_layouts/CUI.js"></script>
  5. <script type="text/ecmascript" src="http://intranet.contoso.com/_layouts/1033/Core.js"></script>
  6. <script type="text/ecmascript" src="http://intranet.contoso.com/_layouts/SP.Core.js"></script>
  7. <script type="text/ecmascript" src="http://intranet.contoso.com/_layouts/SP.Runtime.js"></script>
  8. <script type="text/ecmascript" src="http://intranet.contoso.com/_layouts/SP.js"></script>
  9.  
  10. <input type="hidden" name="__REQUESTDIGEST" id="__REQUESTDIGEST" value="0x0B5280FBC219C9A7E41746D9EA6AC24E65CB936560A6856AA4C38997F114401AED3254D7DDFACBE03C2028F689D0E67F55239E8FA679CE15F3EBC7A0C6280A34,05 Jan 2013 21:34:32 -0000" />
  11.  
  12. <script language="ecmascript" type="text/ecmascript">
  13.  
  14.     // define token request XML
  15.     var tokenReq = '<?xml version="1.0" encoding="utf-8"?>';
  16.     tokenReq += '<soap:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance&quot; xmlns:xsd="http://www.w3.org/2001/XMLSchema&quot; xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">&#039;;
  17.     tokenReq += '  <soap:Body>';
  18.     tokenReq += '    <GetUpdatedFormDigestInformation xmlns="http://schemas.microsoft.com/sharepoint/soap/&quot; />';
  19.     tokenReq += '  </soap:Body>';
  20.     tokenReq += '</soap:Envelope>';
  21.  
  22.     var siteFullUrl = "http://intranet.contoso.com&quot;;
  23.     var lists;
  24.  
  25.     function startScript() {
  26.       refreshDigest();
  27.     }
  28.  
  29.     function refreshDigest() {
  30.         $.support.cors = true; // enable cross-domain query
  31.         $.ajax({
  32.             type: 'POST',
  33.             data: tokenReq,
  34.             crossDomain: true,  // had no effect, see support.cors above
  35.             contentType: 'text/xml; charset="utf-8"',
  36.             url: siteFullUrl + '/_vti_bin/sites.asmx',
  37.             headers: {
  38.                 'SOAPAction': 'http://schemas.microsoft.com/sharepoint/soap/GetUpdatedFormDigestInformation&#039;,
  39.                 'X-RequestForceAuthentication': 'true'
  40.             },   
  41.             dataType: 'xml',
  42.             complete: function (result) {  
  43.                 $('#__REQUESTDIGEST').val($(result.responseXML).find("DigestValue").text());
  44.                 sendJSCOMReq();
  45.             }
  46.         });
  47.     }
  48.  
  49.     function sendJSCOMReq()
  50.     {
  51.         var _spPageContextInfo = {webServerRelativeUrl: "\u002f", webLanguage: 1033, currentLanguage: 1033, webUIVersion:4,pageListId:"{7bbd4c55-f832-40e2-8e2a-243455c3b2ba}",pageItemId:1,userId:1073741823, alertsEnabled:true, siteServerRelativeUrl: "\u002f", allowSilverlightPrompt:'True'};
  52.  
  53.         var siteRelativeUrl = "/";
  54.         var context = new SP.ClientContext(siteRelativeUrl); // we set a dummy relative path that always exists
  55.         context.$1P_0 = siteFullUrl;
  56.  
  57.         var web = context.get_web();
  58.         lists = web.get_lists();
  59.  
  60.         context.load(lists);
  61.         context.executeQueryAsync(Function.createDelegate(this, this.onQuerySucceeded), Function.createDelegate(this, this.onQueryFailed));        
  62.     }
  63.  
  64.     function onQuerySucceeded(sender, args) {
  65.         var count = lists.get_count();
  66.         var listTitles = "Number of lists: " + count + ":\n";
  67.         for(var i=0;i<count; i++)
  68.         {
  69.             var list = lists.get_item(i);
  70.             listTitles += "  " + list.get_title() + "\n";
  71.         }
  72.         alert(listTitles);
  73.     }
  74.  
  75.     function onQueryFailed(sender, args) {
  76.       alert("Request failed: "+ args.get_message());
  77.     }
  78.  
  79.     // start the custom script execution after the scripts and page are loaded
  80.     SP.SOD.executeOrDelayUntilScriptLoaded(function () {
  81.         $(document).ready(startScript);
  82.     }, "sp.js");
  83.  
  84. </script>

After solving these problems I had a bad feeling. What happens if I authenticate myself as a standard user with low privileges, then stole the digest of a site owner included in a SharePoint page using a network traffic analyzer tool and try to send my request with the token of the other user? My experiments show that this issue is handled by SharePoint, as I received an error stating the token was not valid and I had to get a new one from the server.

In the next post I take another step forward to show you how to achieve the same using the Office 365 Preview version.

October 16, 2011

Creating more advanced conditions for your ECB menus through jQuery and synchronous Client Object Model / WCF Data Services calls

In my last post I showed you how to alter the Edit Control Block (ECB) menu of SharePoint 2010 based on simple conditions. As I described, if you need to use more advanced conditions, for example based on list item field values or other information not available on the client side, you should apply a few tricks or even hacks.

For these advanced solutions it is useful to provide the context to our allowDeletion method through the ctx parameter, so we add this one to the parameter list of the method.

It means that our original condition in method AddListMenuItems is changed from

if (allowDeletion(currentItemID))

to

if (allowDeletion(ctx, currentItemID))

In the simplest case, the information is already there at the page on client side, you simply have to find the way to get it. Checking the HTML source of the page or using the Developer Tools in the Tools menu of Internet Explorer (you can press F12 as a shortcut key) usually helps you to find the right track.

For example, if you need the title of the item, that is included in the LinkTitle field (as we have an ECB menu linked to the title) for each items. When looking up the right item, we should use the ID of the item (passed as the itemID parameter) and the ID of our list view (available in ctx.ctxId).

Note: Although it should be evident, your page must load the jQuery library before you can use jQuery methods. Similarly, the ECMAScript Client Object Model library must be loaded before you reference its objects in methods later in the posts.

So if we would like to allow deletion only for items having title beginning with ‘V’ then we need to apply a method like this:

  1. function allowDeletion(ctx, itemID) {
  2.  
  3.     var title = jQuery('div[Field="LinkTitle"][CTXName="ctx' + ctx.ctxId + '"][id="' + itemID + '"]').text();
  4.  
  5.     return (title.toUpperCase().startsWith("V"));
  6.  
  7. }

Next step is to see how to get the information if it is not available on the client side, but included in the list items on the server side. My first idea was to use the ECMAScript Client Object Model, however it turned out quickly that is not the best choice, as the AddListMenuItems method does not wait for the asynchronous reply supported by the client OM to be returned. Although I created a dirty workaround for that, I will show you that later.

Another alternative is to use REST protocol through the WCF Data Services.

As mentioned, our call must be synchronous, so we have to use jQuery.ajax specifying async: false. The next code snippet shows an example using the same condition applied above, that is title of the item must be started with ‘V’ to enable deletion.

  1. function allowDeletion(ctx, itemID) {
  2.  
  3.     itemUrl = ctx.HttpRoot + "/_vti_bin/listdata.svc/" + ctx.ListTitle +"(" + itemID + ")?$select=Title"    
  4.  
  5.     var title = "";
  6.  
  7.     jQuery.ajax(
  8.     {
  9.         type: 'GET',
  10.         url: itemUrl,
  11.         dataType: 'json',
  12.         success: function (result) {
  13.             if (result.isOk != false) {
  14.                 title = result.d.Title;
  15.             }
  16.         },
  17.         data: {},
  18.         async: false
  19.     });
  20.  
  21.     return (title.toUpperCase().startsWith("V"));
  22.  
  23. }

Note: When working with jQuery and WCF Data Services, it is useful to know about the parsererror issue with field values containing apostrophe (single quote) and how to fix it.

It is important to note that you are not restricted to the actual list only. With a bit of additional complexity you can query related lists as well using $expand and the lookup field / user field IDs in the current list. If you are unsure how to compose the URL for you request, I suggest you to try to create the filter first in C# using the LINQ syntax, then use Fiddler to capture the request or apply this simple trick from Sahil Malik.

If network bandwidth and speed are limited, then waiting for the synchronous result will cause issues via blocking the UI thread of the browser. If that is the case you should consider “pre-caching” the data required for checks on page load through a single request. If the item count of the list is limited, then you can cache all of the data (in our case, the IDs and the related titles of the items is needed), in the case of a larger list, you should get only the items displayed in the current page of the view. To get the IDs of the items on the page you should run a jQuery select similar to the one we used to get the title in the first example, and submit the REST request using a complex condition.

Last, I would like to show you my workaround for calling ECMAScript Client Object Model synchronously. As you might now, it is officially / theoretically not possible. I found that it can be done technologically, although I had to spend a few hours Fiddlering and digging into the internals of the ECMAScript Client OM (SP.Runtime.debug.js), comparing the JavaScript methods to the ones in the managed Client OM classes using Reflector. If you demand more information about it I can give you more details, but now I publish it “as is”.

To build up the request I use the “traditional” ECMAScript Client Object Model (that, of course, should be already loaded before our script), but before sending it I get the built-up request in XML format from the internal methods of the OM, and send the request through jQuery.ajax in synchronous mode, just as like the case of WCF Data Services. The JSON result is “loaded” into JavaScript objects.

Be aware that the method described here don’t use the public, documented interfaces, so it is probably not supported by MS, and suggested to be used only as an experiment. There is no guarantee that it will work for you, especially if our environments are at different SP / cumulative update level.

  1. function allowDeletion(ctx, itemID) {
  2.  
  3.     var title = "";
  4.  
  5.     try {
  6.         var context = SP.ClientContext.get_current();
  7.         var web = context.get_web();
  8.         var selectedListId = SP.ListOperation.Selection.getSelectedList();
  9.         var selectedListItem = web.get_lists().getById(selectedListId).getItemById(itemID);
  10.         context.load(selectedListItem, "Title");
  11.  
  12.         // start hacking
  13.         var pendingRequest = context.get_pendingRequest();
  14.         var webRequest = pendingRequest.get_webRequest();
  15.  
  16.         // get the request XML
  17.         var body = pendingRequest.$24_0().toString();
  18.  
  19.         // get the URL of client.svc
  20.         var url = webRequest.get_url();
  21.  
  22.         // "initialize" request
  23.         SP.ClientRequest.$1T(webRequest);
  24.  
  25.         // we should add digest later to the request as an HTTP header (see below)
  26.         var digest = webRequest.get_headers()['X-RequestDigest'];
  27.  
  28.         jQuery.ajax(
  29.         {
  30.             type: "POST",
  31.             data: body,
  32.             url: ctx.HttpRoot + url,
  33.             success: function (result) {
  34.                 if (result.isOk != false) {
  35.                     title = result[result.length – 1].Title;
  36.                 }
  37.             },
  38.             headers: {
  39.                 "x-requestdigest": digest
  40.             },
  41.             contentType: "application/x-www-form-urlencoded",
  42.             async: false
  43.         });
  44.  
  45.     }
  46.     catch (e) {
  47.         alert("Error: " + e);
  48.     }
  49.  
  50.     return (title.toUpperCase().startsWith("V"));
  51.  
  52. }

BTW, the example above achieves the same, it allows deletion only for items having title beginning with ‘V’. Similarly to the former WCF DS example, the synchronous request through the network blocks the UI thread, and might require pre-caching in case of a slow network.

September 21, 2011

Creating ECB menu items based on specific list properties using the client object model

In my past posts I’ve already described how to hide and modify ECB menu items through JavaScript and the SharePoint client object model.

Now I show how to create new menu items on demand based on the properties of the list.

Since it is not so different from the former examples, I do not provide the full solution for the current sample, only include the most important codes.

Our first Elements file contains the ScriptLink custom actions. The Sequence attribute of the CustomAction elements is important, as the later scripts depend on the former ones.

First we add a reference to jQuery, next we add our custom menus.js file that contain the logic for adding the new menu. Last we add a script block that (after the page is loaded) wait for the ECMAScript client OM script (sp.js) to be loaded, then calls the createEcbMenus method (included in menus.js).

  1. <?xml version="1.0" encoding="utf-8"?>
  2. <Elements xmlns="http://schemas.microsoft.com/sharepoint/">
  3.   <CustomAction
  4.      ScriptSrc="scripts/jQuery/jquery-1.6.1.min.js"
  5.      Location="ScriptLink"
  6.      Sequence="1000" />
  7.   <CustomAction
  8.       Location="ScriptLink"
  9.       ScriptSrc="scripts/menus.js"
  10.       Sequence="1100" />
  11.   <CustomAction
  12.     Location="ScriptLink"
  13.     ScriptBlock="$.noConflict(); jQuery(document).ready(function() { ExecuteOrDelayUntilScriptLoaded(createEcbMenus, 'sp.js'); });"
  14.     Sequence="1200" />
  15. </Elements>

Following code is the content of the menus.js that we deploy to the Layouts\scripts folder.

  1. var inProgress = false;
  2. var list;
  3. var selectedListId;
  4.  
  5. function createEcbMenus() {
  6.     ExecuteOrDelayUntilScriptLoaded(createEcbMenusEx, 'Core.js');
  7. };
  8.  
  9.  
  10. function createEcbMenusEx() {
  11.     if (!inProgress) {
  12.         try {
  13.             inProgress = true;
  14.             var selection = SP.ListOperation.Selection;
  15.             if (selection != null) {
  16.                 this.selectedListId = selection.getSelectedList();
  17.                 if (selectedListId != null) {
  18.                     var context = SP.ClientContext.get_current();
  19.                     web = context.get_web();
  20.                     this.list = web.get_lists().getById(this.selectedListId);
  21.                     // we will use the Title property of the list later on
  22.                     // so add it to the query
  23.                     context.load(this.list, "Title");
  24.                     context.executeQueryAsync(Function.createDelegate(this, this.gotList), Function.createDelegate(this, this.failed));
  25.                 }
  26.             }
  27.         }
  28.         catch (e) {
  29.             alert("Error: " + e);
  30.             inProgress = false;
  31.         }
  32.     }
  33. }
  34.  
  35. function failed(sender, args) {
  36.     alert("Operation failed: " + args.get_message());
  37.     inProgress = false;
  38. }
  39.  
  40. function gotList() {
  41.     if (this.list.get_title() == 'My List') {
  42.         // just to be sure jQuery is loaded
  43.         if (jQuery) {
  44.             var id = "ECBItems_" + this.selectedListId.toLowerCase();
  45.  
  46.             jQuery('div[id*="ECBItems"]').each(
  47.                 function () {
  48.                     if (jQuery(this).attr('id') == id) {
  49.                         jQuery('div[id*="ECBItems"]').each(
  50.                         function () {
  51.                             if (jQuery(this).attr('id') == id) {
  52.                                 jQuery(this).append('<div><div>Custom menu item</div><div></div><div>javascript:doSomethingWithListItem({ItemId})</div><div>0x0</div><div>0x0</div><div>List</div><div>100</div><div>1000</div></div>')
  53.                             }
  54.                         });
  55.                     }
  56.                 });
  57.         }
  58.  
  59.     }
  60.     inProgress = false;
  61. }
  62.  
  63.  
  64. function doSomethingWithListItem(listItemId) {
  65.     alert("List item ID: " + listItemId);
  66. }

The createEcbMenus method we call from the last ScriptLink custom action (see above) simply waits for the loading of the Core.js file, then it calls the createEcbMenusEx method.

The createEcbMenusEx method submits a query using the client OM to get the Title property of the current list, that we use in this sample to decide if we should add the new menu item or not.

The most important part is the gotList callback method that is called when the submitted query succeeded. If the list is named “My List” then we check for the ECBItems DIV, and add the DIV structure for the new menu item to it. See former posts mentioned above for details of the format. In this case the menu title will be Custom menu item, and it calls the doSomethingWithListItem method when clicked.

Theoretically this code should work, but in practice, sometimes it simply does not. It has a very straightforward reason, namely it does not find the ECBItems DIV where it should to add the new item to. It happens when no custom action deployed for the EditControlBlock.

Note: If you need more info about it, check the former posts referred to above for the RenderECBItemsAsHtml method. BTW, that method is called by XsltListViewWebPart.OutputECB method that is called by the BaseXsltListWebPart.RenderWebPart method during the list view rendering. If you would like to know more about the custom action internals, I suggest you to read this post.

As a workaround we should add a dummy custom action element file that is never displayed. In this case I used the RegistrationId=”12345” and RegistrationType="List", so if you don’t have a list based on a template with ID 12345, you should be OK.

  1. <?xml version="1.0" encoding="utf-8"?>
  2. <Elements xmlns="http://schemas.microsoft.com/sharepoint/">
  3.   <CustomAction Description="Dummy Custom Action that should be never displayed"
  4.               Id="MyCompany.DummyAction"
  5.               Location="EditControlBlock"
  6.               ImageUrl="/_layouts/images/dummy.png"
  7.               Sequence="1000"
  8.               Title="Dummy"
  9.               RegistrationType="List"
  10.               RegistrationId="12345" >
  11.     <UrlAction Url="javascript:alert('It is a dummy CA')"/>
  12.   </CustomAction>
  13. </Elements>

Deploying the dummy custom action forces the ECBItems DIV to be rendered, so our script will able to add the new item to the menu.

Older Posts »

Create a free website or blog at WordPress.com.