Sue Hernandez's SharePoint Blog

SharePoint and Related Stuff

Monthly Archives: October 2010

Long Running Client Side Scripting (i.e. Javascript)

In my Google Mapping Dashboard/Scorecard solution, I was calling out IN SCRIPT to several data sources, including SharePoint Lists (via web services) and xml files (provided by the CorasWorks Data Integration Toolset).  I originally had very long-running processes which would not only hang the browser, but it also just plain made it crash.

Using the reference below, I got the general idea of what they were trying to do, and I modified it to my own liking.  In this article, I will explain how to use a “timed loop” to control browser UI responsiveness.

References:

First off, we will call our web service to get our return data.  I’m using the JQuery library in this example, but it is not necessary to use JQuery to affect the same results.

$.ajax({
	url: YOUR_URL_HERE,
	dataType: "xml",
	complete: function(xData, status) {
		var xDoc = xData.responseXML;
		var nodes = xDoc.selectNodes("//Table1");

		var items = new Array();
		for (var i = 0; i < nodes.length; i++) {
			items.push(nodes[i]);
		}
		totalNumNodes = items.length;
		loopChunks(items, totalNumNodes);
	},
	contentType: "text/xml; charset=\"utf-8\""
});

Notice how we’re calling a function here after we receive our response from the AJAX call, a function called loopChunks.  We pass in an item array – as far as I can tell, it MUST be an actual Array object, thus looping through the items first and pushing them into an array.  We also pass in the total number of nodes, so we can do some percentage calculation (in case we want to update the browser with that percentage information).

Next, we call our loopChunks method:

function loopChunks(items, totalNumNodes) {
	if (items.length > 0) {
		var index = (totalNumNodes - items.length);
		var node = items[0];
		items.shift();  // This removes the first item in the array

		var pct = Math.round((index / totalNumNodes) * 100);

		updateInfoPanel(pct);

		if (pct % 10 == 0) {
			setTimeout(function() {
				processNode(node, items, totalNumNodes);
			}, 25);
		}
		else {
			processNode(node, items, totalNumNodes);
		}
	}
	else {
		items = null;
		try {
			CollectGarbage();
		}
		catch (ex) { }
		window.setTimeout(function() { completionFunction(); }, 25);
	}
}

First we’ll see in this function that we check to see if we have any items left to process.  If we do, we first find out which item we’re on (for our percentage done) and then specify the current node as the first node in the array.  We then use the shift() method of the Array object to remove the first item in the array.  Here, the documentation tells us that we could have just used the return value of the shift() function to get the first node, but for me this always returned a GUID and not the actual node (even when just looping through strings). 

Next we calculate the percentage done we are.  After that, you’ll notice that I do a check to see if our current percentage is a factor of 10.  **IMPORTANT**  The reason I did this was because when you loop through memory items, and you update the percentage on the UI EVERY TIME, you end up actually having TOO much overhead just in the setTimeout function that follows.  I found that if I’m doing lots of calculations with the node, I’ll update the UI every 10%.  If I’m doing faster operations, I’ll update the UI every 20% or 25%.

Next in our function, we’re either setting a timeout or just calling directly the processNode function.  The processNode function is what actually does all of our work.

function processNode(node, items, totalNumNodes) {
	var locationKey = "";
	if (node.selectSingleNode("Location_x0020_Key") != null) {
		locationKey = node.selectSingleNode("Location_x0020_Key").text;
	}
	var lat = "";
	if (node.selectSingleNode("Latitude") != null) {
		lat = node.selectSingleNode("Latitude").text;
	}
	var lng = "";
	if (node.selectSingleNode("Longitude") != null) {
		lng = node.selectSingleNode("Longitude").text;
	}

	var latLon = new google.maps.LatLng(lat, lng);
	var title = locationKey;

	// Set the Marker
	var marker = new google.maps.Marker({
		position: latLon,
		title: title
	});

	fluster.addMarker(marker);  // SourceForge - Fluster2Cluster

	loopChunks(items, totalNumNodes);
}

Here we are simply getting the Name of the Google Marker, the Latitude and Longitude of the Marker, and setting it into our global variable fluster which is an implementation of the Clustering script library Fluster2Cluster from SourceForge.

Notice at the end of the function, we call the original looping function, loopChunks, with the parameters we passed down into the function.

Here’s the rest of the code, which was referenced in the other functions: the updateInfoPanel method and the completionFunction method.

function updateInfoPanel(pct, stage) {
	try {
		var html = '<a class="whiteLink" onclick="closeInfoPanel();" href="#"><strong>&gt;&gt;</strong></a>

		// this is a progress bar of sorts
		html += "<div style='width:104px;height:25px;border:2px navy solid'><div style='width:" + pct + "px;height:21px;background-color:green'>&nbsp;</div></div>";

		$("#map_infoPanel").html(html);
	}
	catch (ex) {
		alert(ex.message);
	}
}

function completionFunction() {
	fluster.initialize();
}

That’s it!

Full Text Search – search.asmx – User Profile Title field??

Continuing with my Google Maps V3 dashboard using JQuery and AJAX, and a little bit of CorasWorks Data Integration Toolset (DIT).  My next challenge was that when you click on a location in the InfoWindow, it brings up a pane to the left of the map with details in it.

Well, in those details, I wanted to show all of the people that were related to that Location by a custom profile property in their SharePoint User Profile – i.e. all the employees of that location.

So I chose to use the search.asmx web service to perform a full text query on the People scope.  I searched around on Google and I found the following example:

function initializeUserProfileInfo(location)
{
	var userSearchQueryString =
              '<QueryPacket xmlns="urn:Microsoft.Search.Query">
                   <Query>
                         <SupportedFormats>
                               <Format>urn:Microsoft.Search.Response.Document:Document</Format>
                         </SupportedFormats>
                         <Context>
                               <QueryText type="MSSQLFT" language="en-us">
                                      select preferredname, firstname, lastname, workemail, workphone,
                                      title, department, pictureurl, description from scope() where
                                      "scope" = \'People\' order by preferredname
                               </QueryText>
                         </Context>
                         <Range>
                               <StartAt>1</StartAt>
                               <Count>10000</Count>
                         </Range>
                         <EnableStemming>true</EnableStemming>
                         <TrimDuplicates>true</TrimDuplicates>
                         <IgnoreAllNoiseQuery>true</IgnoreAllNoiseQuery>
                         <ImplicitAndBehavior>true</ImplicitAndBehavior>
                         <IncludeRelevanceResults>true</IncludeRelevanceResults>
                         <IncludeSpecialTermResults>true</IncludeSpecialTermResults>
                         <IncludeHighConfidenceResults>true</IncludeHighConfidenceResults>
                   </Query>
              </QueryPacket>';

         // VERY IMPORTANT - make sure the string above is actually escaped

         var soapRequest = '<?xml version="1.0" encoding="utf-8"?>
                   <soap:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
                   xmlns:xsd="http://www.w3.org/2001/XMLSchema"
                   xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
                         <soap:Body>
                               <QueryEx
                               xmlns="http://microsoft.com/webservices/OfficeServer/QueryService">
                                     <queryXml>' + userSearchQueryString + '</queryXml>
                               </QueryEx>
                         </soap:Body>
                   </soap:Envelope>';

	$.ajax({
		url: siteURL + "/_vti_bin/search.asmx",
		type: "POST",
		dataType: "xml",
		data: soapRequest,
		contentType: "text/xml; charset=\"utf-8\"",
		error: function(XMLHttpRequest, textStatus, errorThrown) {
			ajaxError(XMLHttpRequest, textStatus, errorThrown);
		},
		success: function(xml) { processAjaxResultUserSearch(xml); }
	});
}

My problem was was that with the processAjaxResultUserSearch(xml) function, I wanted to get the person’s Title and group by that.  So I happily asked for the TITLE field (they come back as all caps for some reason), but I kept getting the preferred name!  I couldn’t figure out how to get the title from the title field.  I checked in the User Profile store, and yes, the Title field was getting populated correctly.

Well it turns out that if you go to your SSP –> Search Settings –> Metadata Property Mappings, there is a list here of a bunch of mappings such that when you search for the value on the left, you get the value on the right.  Well sure enough, next to Title, it was mapped to (among other things) People:PreferredName(Text).

So I searched around in the mappings, and I found that “JobTitle” was mapped to People:Title(Text).  I used this one, and lo and behold that’s the right one. 

IMPORTANT:  Also, make sure your Query Packet is ESCAPED, so for example

userSearchQueryString =
          '&lt;QueryPacket xmlns="urn:Microsoft.Search.Query"&gt;
                   &lt;Query&gt;
                         &lt;SupportedFormats&gt;

... etc etc

Just thought I’d pass that along.

Passing Parameters to a Complete function – JQuery and AJAX

So I’m trying to create a dashboard with all kinds of great stuff.  You may have noticed my post on getting a Google Map using version 3 where you get the data from the Data Integration Toolkit (DIT).

What I was trying to accomplish now was a sort of INNER JOIN if you will, on data from 2 different locations.  So the way I chose to do it was, as I was iterating through the results of the first ajax call where I got the data from, I first created a Google Marker, and then I wanted to look up the related information based on the marker’s title, from another list again using AJAX.

The problem was, is that as soon as the Complete function was called for the second, related information list, I wanted to pass in the marker and send it into Fluster2Cluster for processing (http://sourceforge.net/projects/fluster/).

Unfortunately, the complete function only passes in 2 pieces of information into the function – the response data, and the status.  So I could not make a call such as the following:

$.ajax({
	async: true,
	url: siteURL + "/_vti_bin/Lists.asmx",
	type: "POST",
	dataType: "xml",
	data: newSoapEnvelope,
	complete: ajaxCompleteFunction

which is how I normally had been making my ajax calls.  From what I could find, there was no way to “pass in” extra parameters.

So the resolution, however, was pretty simple.  Instead of putting the name of the function, you can actually define a function right there in the code.  Turns out that you can pass in variables right into the guts of that function.  Take the example below:

function getTicketCountFromList(marker, markup, last)
{
	var listGUID = "GUID_TO_LIST";
	var title = marker.getTitle();

	// Everything
	var newSoapEnvelope = 'YOUR_SOAP_ENVELOPE_HERE_WITH_QUERY';

	$.ajax({
		async: true,
		url: siteURL + "/_vti_bin/Lists.asmx",
		type: "POST",
		dataType: "xml",
		data: newSoapEnvelope,
		complete: function(xData, status) {
			// Geocode and set marker
			var xDoc = xData.responseXML;

			var nodes = xDoc.selectNodes("//z:row");
			var count = 0;

			if (nodes.length > 0) {
				count = nodes(0).getAttribute("ows_TicketCount");
			}

			count = format_number(count, 0);

			fluster.addMarker(marker, markup, count);

			if (last)
			{
				fluster.initialize();
			}
		},
		contentType: "text/xml; charset=\"utf-8\""
	});
}

This way, I used the paramters “marker”, “markup” (for the InfoWindow), and “last” (specifying that it was the last marker to plot, so that we can initialize the Fluster2Cluster).

SharePoint, JQuery, Google Maps, and the CorasWorks DIT

I am going to show you how to use the CorasWorks Data Integration Toolset to retrieve geo-coded location information from a database and show it in a Content Editor Web Part in SharePoint using JQuery and the Google Maps api V3.

First of all, I recommend that you create 4 document libraries: 1 for holding Data Connections, 1  for holding Data Providers, 1 document library that holds Web Part Pages that you can use to display your data (unless you want the data on your default.aspx page), and finally 1 library to hold your jquery script, and any other supporting things you need like css or images.

For the data connection library, you will need to create a connection to your data source that will retrieve the geo-coded locations.  Here is a sample data connection file that is in the format that the DIT needs in order to load its data:

<?xml version="1.0" encoding="utf-8"?>
<CorasWorks>
	<Data>
		<Name>geodata</Name>
		<Default>true</Default>
		<ConnectionType>ADO</ConnectionType>
		<ConnectionString>Provider=sqloledb;Data Source=SERVER_NAME;Initial Catalog=DB_NAME;User Id=USER_ID;Password=PASSWORD</ConnectionString>
		<Query>
			SELECT     LocationId, Title, Address, City, State, Zip, Latitude, Longitude
			FROM       GeoLocations
		</Query>
		<Values>
		</Values>
	</Data>
</CorasWorks>

Load that up into your Data Connections library, and copy the URL to that file.

Next, create a web part page and place it in the Data Providers library.  Place an External Data Provider web part from CorasWorks on to the page and configure it with the URL to your data connection (put the URL in the “Source XML File Location” under the “Source XML” heading).  Make sure you turn on “Output XML” in the “Output Properties” heading.  You should now be presented with xml output similar to the following:

<NewDataSet>
     <Data>
          <LocationId>1</LocationId>
          <Title>Sues House</Title>
          <Address>123 Main Street</Address>
          <City>Manassas</City>
          <State>VA</State>
          <Zip>20110</Zip>
     </Data>
</NewDataSet>

Next, upload the latest version of jquery to your Document Library you created for this.  Copy the link to the file so you can use that in your map as a script reference.

Now we’re going to create a web part page to hold our map using a Content Editor Web Part.  Drop a CEWP on there and start off by putting  a script reference to both your jquery instance as well as the Google Maps API.

<script type="text/javascript" language="javascript" src="https://YOUR_SHAREPOINT_SERVER/PATH_TO_SITE/Scripts/jquery-1.4.2.min.js"></script>
<script type="text/javascript" src="http://maps.google.com/maps/api/js?sensor=false"></script>

Now we’ll add in the CEWP a div or a span (doesn’t matter which) to hold your map.

<div id="map_canvas" style="height:500px;width:100%">
     Loading...<br/>
     If the map does not load in a few seconds, then you may have security set to not allow unsecured content.  Please refresh the page and allow non-secure content to render on the page.
</div>

Now we’ll add a script tag, and put in some variables and an initialize function to initialize the Google Map.

<script type="text/javascript" language="javascript">
     var geocoder;
     var map;

     function initialize() {
     }
</script>

Now we’ll fill in the initialize function.  First we’re going to set up the Google Map, and then we’re going to make an AJAX call to the URL of the Data Provider (that XML output) that we created with the CorasWorks External Data Provider.

     function initialize() {
          geocoder = new google.maps.Geocoder();

          //  Roughly the middle of the US - make this whatever lat lon you want
          var myLatlng = new google.maps.LatLng(39.011902,-98.4842465);

          var myOptions = {
              zoom: 4,
              center: myLatlng,
              mapTypeId: google.maps.MapTypeId.ROADMAP
          }

          // Get the canvas
          var canvas = document.getElementById("map_canvas");

          // Get the Map and set it into the canvas
          map = new google.maps.Map(canvas, myOptions);

          $.ajax({
              url: "https://YOUR_SHAREPOINT_SERVER/YOUR_PATH_TO_SITE/Data%20Providers/GeoList%20Data.aspx",
              dataType: "xml",
              complete: processAjaxResult,
              contentType: "text/xml; charset=\"utf-8\""
          });
     }

Now we need to fill in the function “processAjaxResult”, which is a function called asynchronously after the AJAX call is made.  The function will get the nodes of your data and set up markers for each one.  It will either use the Latitude and Longitude if it was provided, or it will use the Google Geocoding service if it doesn’t have an address.

     function processAjaxResult(xData, status) {
          // Geocode and set marker

          var xDoc = xData.responseXML;
          var nodes = xDoc.selectNodes("//Data");

          for(var i = 0; i < nodes.length; i++)
          {
              var lat = nodes(i).selectSingleNode("Latitude");
              if(!lat) { lat = ""; }
              else { lat = nodes(i).selectSingleNode("Latitude").text; }

              var lon = nodes(i).selectSingleNode("Longitude");
              if(!lon) { lon = ""; }
              else { lon = nodes(i).selectSingleNode("Longitude").text; }

              var title = nodes(i).selectSingleNode("Title");
              if(!title) { title = "Your Location"; }
              else { title= nodes(i).selectSingleNode("Title").text; }

              if(lat != "" && lon != "")
              {
                  var latLon = new google.maps.LatLng(lat, lon);
                  setMarker(latLon, title);
              }
              else
              {
                  // No Latitude and/or Longitude.  Need to Geo Code it.
                  var streetAddress = nodes(i).selectSingleNode("Address");
                  if(!streetAddress) { streetAddress = ""; }
                  else { streetAddress = nodes(i).selectSingleNode("Address").text; }

                  var city = nodes(i).selectSingleNode("City");
                  if(!city) { city = ""; }
                  else { city = nodes(i).selectSingleNode("City").text; }

                  var state = nodes(i).selectSingleNode("State");
                  if(!state) { state = ""; }
                  else { state = nodes(i).selectSingleNode("State").text; }

                  var zip = nodes(i).selectSingleNode("Zip");
                  if(!zip) { zip = ""; }
                  else { zip = nodes(i).selectSingleNode("Zip").text; }

                  var fullAddress = streetAddress + " " + city + " " + state + " " + zip;
                  codeAddress(fullAddress, title);
              }
          }
     }

There were 2 functions referenced in this code snippet: setMarker and codeAddress. Here are those functions – they are very simple.

     function codeAddress(address, title) {
          geocoder.geocode( { 'address': address}, function(results, status) {
              if (status == google.maps.GeocoderStatus.OK) {
                  setMarker(results[0].geometry.location, title);
              } else {
                  alert("Geocode was not successful for the following reason: " + status + "   Location: " + address);
              }
          });
     }

     function setMarker(latLon, title) {
          var marker = new google.maps.Marker({
              position: latLon,
              title: title
          }); 

          marker.setMap(map);
     }

Finally, we need to call the initialize function when the body has finished loading.  So let’s use jquery to add the initialize function to the body onload.

     $(document).ready(function() {
          initialize();
     });

That’s all there is to it!

If you have tons of points on your map, check out Fluster2Cluster from SourceForge http://sourceforge.net/projects/fluster/

I have used this with great success, and even modified it to accept the HTML that will show up in an InfoWindow (one of those Google Pop-ups).