Kicking Off A Sharepoint Workflow from a Button, and [Much] More!

<UPDATE date=”2011-02-14″>: As of jQuery 1.5, single quotes are *required* around z:row or any other similar node selector. This was actually “required” in previous versions of jQuery, but not enforced.

$(xData.responseXML).find("[nodeName='z:row']")

</UPDATE>

Cross-posted from EndUserSharePoint.com

A little while ago, I noticed that a fellow named Rob Doyle was asking some interesting questions about working with workflows in the SPServices Discussions. If you’re not familiar with SPServices, it’s my jQuery library which helps you to relatively easily work with the SharePoint Web Services. It also gives you what I call ‘value-added functions” which can really easily let you dress up your list and library forms to enhance the user experience and improve data quality. (There’s the sales pitch. Now go download that wonderful, free software!)

As we went back and forth on some issue he was having it became clear that he was building something pretty cool, using SPServices as one part of his toolkit. It sounded like it would be a good article to write up and share, so here it is. Rob was kind enough to do the writing, capture all of the screenshots, and clean everything up for general consumption. I just did a little editing, but the story is Rob’s. I hope you enjoy this inventive solution and great use of my SPServices library.

The Business Case

Recently, my team and I were asked to create an enterprise application to assist users in completing a convoluted and infrequently utilized – but absolutely required – business process: expense requests and purchase reports. The process itself can change as a result of the type of request being submitted, and many of the nuances are frequently overlooked. This leads to backtracking and lost productivity at both ends. Sound familiar?

Our goal was to create an electronic form which built in the dynamic business rules of the process, but which did not overwhelm the user. The solution ended up being a blend of InfoPath for the input form, SharePoint for the data storage and workflow processing, and jQuery (and specifically Marc Anderson’s SPServices library) for the UI layer.

clip_image002
Figure 1 – Entry Form for new requests

I designed the system to be a series of dashboards for the user, with only minimal interaction with their record requests. All actions that were allowable on a particular record would be shown in a table along with your standard identifiable details about the record itself.

An example:

clip_image004
Figure 2 – User Dashboard

Here, the individual records can either be simply Viewed or officially Logged to our final system of record. In other views, a record might be able to be deleted, copied, or have its status otherwise changed. All these actions were enabled or disabled according to the business process.

In general, when hitting one of the buttons the form itself appears in Edit or View mode, or a custom SharePoint Designer workflow is kicked off. The View page is a page with a single record data view and with a style sheet made to look similar to the InfoPath form using standard CSS techniques. Importantly, the site Master Page is not attached so that we can display the view page in a popup window.
clip_image006
Figure 3 – ‘View’ record appears in an AJAX iFrame

Kicking off the workflows: Dataview Setup

The workflows themselves are kicked off using the SPServices “StartWorkflow” method. I’ve modified the function a bit to include an argument I named “wfID” to indicate which button was running which workflow. So if wfID = “delT” then the Delete workflow is run; if wfID=”confT” then the Confirm workflow is run. This allowed me to use this same function for all of the workflows in the entire application which updated a record.

On the page, all buttons using this workflow had their onclick event set as follows:

<a href="#" class="button">
  <xsl:attribute name="onclick">
    <xsl:text>javascript:StartWorkflow(&apos;https://yoururlhere.com</xsl:text>
    <xsl:value-of select="@FileRef" disable-output-escaping="yes" />
    <xsl:text>&apos;,&apos;</xsl:text>
    <xsl:value-of select="@ID" disable-output-escaping="yes" />
    <xsl:text>&apos;, &apos;confT&apos;);</xsl:text>
  </xsl:attribute>
  <xsl:attribute name="id">
    <xsl:text>buttonID</xsl:text>
    <xsl:value-of select="@ID" />
  </xsl:attribute>
  <span>Confirm</span>
</a>

Looks like a lot, right? It is a lot of code to add in, but doing it in this manner allowed some huge flexibility with the solution: I was able to kick off any of the workflows on an item just by clicking that button and using literally the exact same JavaScript code. The workflow itself gets passed the ItemURL, its corresponding ID, and an argument which tells the JavaScript function which workflow to run.

The call to the function is in this form:

onclick="javascript:Startworkflow('http://yoururl/itemsite/itemURL.xml’, '1048’, 'workflow1’)"

Breaking it down:

The button itself is actually an anchor tag (<A>) with a class attached to it. This allows the same CSS code to be assigned to all the buttons, so I could copy and paste the entire block of button code, and change two items and be done.

<a href="#" class="button">

Then we use the power of XSL in the data view. Using the <xsl:attribute> tag allowed me to add additional attributes to the A tag and construct the code dynamically. The <xsl:text> tag allows us to add literal text to the line as well:

<xsl:attribute name="onclick">
  <xsl:text>javascript:StartWorkflow(&apos;https://yoururlhere.com</xsl:text>

This results in the following line being added to the dataview:

onclick="javascript:StartWorkflow(“https://yoururlhere.com

Then we add the file’s location, ID, and our workflow argument (along with the requisite commas and apostrophes), so that the workflow knows which item we are processing:

  <xsl:value-of select="@FileRef" disable-output-escaping="yes" />
  <xsl:text>&apos;,&apos;</xsl:text>
  <xsl:value-of select="@ID" disable-output-escaping="yes" />
  <xsl:text>&apos;, &apos;confT&apos;);</xsl:text>
</xsl:attribute>

Note the “confT” argument above – the function will be looking for this so that it knows which workflow to execute. …and that’s it for the JavaScript function call setup.

Then we close out the A tag with a span that includes the text to be shown on the button, and allows us to use this same exact line with every single button. The CSS formatting is beyond the scope of this article, but you can look up the term “sliding doors” and find any number of tutorials.

  <span>Confirm</span>
</a>

Now, when the dataview is displayed on the page, everything is updated with the correct ItemURL and ItemID as needed. So why do all this? Well, when a new button is required in another spot on the page, or on another page, only two items need to be updated: the workflow argument (“confT” above) and the text on the button itself. That’s it! The entire set of code as above can be pasted inside a <td> in the dataview and updated as needed.

Maintainability

Now that we’ve got the setup out of the way, let’s take a look at the JavaScript itself. In a less complex situation, we might hardcode the workflow values on every page, or have a distinct function that runs a specific workflow. There are two problems with this approach, however:

  • Maintainability – it is monstrously difficult to update every single dataview in your site with the required values, and
  • SharePoint is finicky when it comes to using workflows in this manner. The Web Service requires that a specific and current “TemplateID” be passed to the request – sort of the GUID for the workflow itself – and this TemplateID changes every time the workflow is updated.

There was no way I was going to update every dataview in our entire application (there are 12) every time the workflow changed. Our approach to minimize this was to use a lesser-known aspect of the Content Editor Web Part (and later this was incorporated directly into the master page).

We added the CEWP to the bottom of every page which included customized workflow calls and all our reference files, including the jQuery and SPServices libraries. Then the CEWP was pointed at a txt file which resided in a document library (and later in the _layouts folder). This meant that since all the distinct pages required the same supporting code, they could share this one file, and the file itself could be maintained in one location. Any time the workflow TemplateID changed – no problem – a single update and we were done! Hundreds of buttons were automatically updated the next time the page loaded.

clip_image008
Figure 4 – CEWP with codefile.txt reference

The JavaScript call itself

Here’s a sample of the JavaScript call which kicks off the majority of the workflows:

function StartWorkflow(ItemURL, ItemID, wfID) {
  if (wfID == "wrT") {
    tempID = "{7acb9ced-bd58-4ea4-993e-787a0773f1db}"; wfname = "withdraw?"; alert("you are running workflow" +tempID)
  } else if (wfID == "wrE") {
    tempID = "{a6b9e3d5-fc89-428b-b861-0af09b56a4cc}";  wfname = "withdraw this expense report?";
  } else if (wfID == "logT") {
    tempID = "{6d28a7fb-cffb-489b-966a-27efd6fc1082}";  wfname = "mark this request as logged?";
  } else if (wfID == "logE") {
    tempID = "{cf69a2bc-e80b-4ee0-a3bd-b3577156996d}"; wfname = "mark this expense report as logged?";
  } else if (wfID == "delT") {
    tempID = "{8a914bcb-b54b-4ef3-803a-47a7e1aef30f}";  wfname = "delete this request?";
  } else if (wfID == "delE") {
    tempID = "{022276b6-3032-4354-b52f-6afdd2898e66}";  wfname = "delete this expense report?";
  } else if (wfID == "confT") {
    tempID = "{02449161-b2f6-4f90-acad-174dd42c4617}";  wfname = "confirm this request as being withdrawn?";
  } else if (wfID == "confE") {
    tempID = "{dfc7ed43-0c7e-4c50-a4d8-18df749743d5}";  wfname = "confirm this expense report as being withdrawn?";
  } else {
    alert("Sorry, you've enountered an error! Please contact the helpdesk immediately");
  }

  document.body.style.cursor = "wait";
  var r = confirm("Are you sure you wish to " + wfname);

  if (r==true) {
  $().SPServices({
    operation: "StartWorkflow",
    item: ItemURL,
    debug: true,
    templateId: tempID,
    workflowParameters: "<root />",
    completefunc: function(xData, Status) {
      var out =  $().SPServices.SPDebugXMLHttpResult({node:  xData.responseXML});
    }
  });
  $("#rowID" + ItemID).find("td").fadeOut(1000, function(){
    $(this).parent().remove();});
    document.body.style.cursor = "default";
  } else {
    alert("Cancelled!");
    document.body.style.cursor = "default";
  }
}

The code itself is very simple. The only modifications made have been to allow the additional “wfID” argument to be passed, and the corresponding templateID is inserted depending on the button pushed. That’s it! All the records are updated, emails are sent, and the record itself is removed from the screen without a time-consuming page refresh.

This line finds the table row which has been assigned in the code using the ItemID from the list, fades it out slowly, and then removes it completely from the table.

$("#rowID" + ItemID).find("td").fadeOut(1000, function(){
  $(this).parent().remove();
});

Because of the way the system is designed, that row will not appear again in this page, because its status has now changed as a result of the workflow successfully completing.

The Copy Function

One of the core requirements of the system was that users be able to copy previous requests in order to get a head start on filling everything out. This presented a number of challenges in our design, not the least of which is the common “I don’t know the ID of an item until after it’s been created” issue.

One of my design prerequisites was that the user never be interrupted from their personal process flow. This meant that on all dashboards, the system would present a ‘View’ or ‘Edit’ mode on a record on top of the dashboard itself. This allowed the user to finish reviewing or filling out their form, and go right back to the dashboard without interrupting their main flow, and without causing a single page refresh.

The copy function is kicked off using the exact same principles and xsl code in the dataview as all the other workflows, although the function name is different. Our call is instead:

onclick="javascript:CopyWorkflow('ItemURL’, 'ItemID’);"

The function itself does the following things:

  1. Alerts the user with a message box that this process takes a moment or two
  2. Places a jQuery overlay with a “processing…” icon over the screen so that the user can’t click on anything else during this process
  3. Runs a workflow which copies a blank version of the InfoPath form from a system library
  4. The workflow then updates that new destination form with all the values from the copied source
  5. Runs a modified version of Marc Anderson’s invaluable “GetLastItemID” function that pulls the last ID of the InfoPath file which matches the current user to a custom field called “DocOwner”
  6. Opens the new form in a new window on the same page
  7. Closes the overlay

Seems sort of a roundabout way of doing things, but is what you need to do in order to copy record values from an InfoPath form. Simply copying the document unfortunately doesn’t work. In the script, the code is pushed the same two arguments as the standard StartWorkflow function, and does not require the wfID argument, since we are always running the Copy workflow function.

<script type="text/javascript">
function CopyWorkflow(ItemURL, ItemID)
{
  var Itempath = "https://yoursystemURL.com" + ItemURL;
  alert("Please wait - this function may take a few moments");
  var overlay2 = jQuery('<div id="overlay2"><img src="loader.gif" /><p>Your new form will appear in a new window momentarily</p></div>');
  overlay2.appendTo(document.body);
  $().SPServices({
    operation: "StartWorkflow",
    item: Itempath,
    debug: true,
    templateId: "{4c9edb2c-b6f4-4c91-97c6-641b3219ec1e}",
    workflowParameters: "<root />",
    completefunc: function(xData, Status) {
      var lastdoc = $().SPServices.SPGetLastDocId({ listName: "PreTravel" });
      var out =  $().SPServices.SPDebugXMLHttpResult({node: xData.responseXML});
      alert("Copy Complete");
      alert("lastID is: " + lastdoc);
      var newurl = "Yoursite.com/sitepath/_layouts/FormServer.aspx?XmlLocation=/sitecoll/library/REQ" + lastdoc + ".xml&DefaultItemOpen=1&OpenIn=Browser";
      newwindow = window.open(newurl, 'Edit', 'width=900, height=600, status=no, directories=no, location=no, menubar=no, toolbar=no, scrollbars=1');
      if(window.focus) {newwindow.focus()}
      //$("#overlay2").remove();
      $("#fancy_overlay").fadeOut();
    }
  });
}

GetLastDocID

Here’s a final item, where I’ve modified the GetLastItemID function to pull a particular document ID. The only real modification is that we are matching on the “DocOwner” field, not the “Created” or “Modified By” field. If I could go back to this one, I would have modified it to accept the field name as a parameter.

$.fn.SPServices.SPGetLastDocId = function(options) {

    var opt = $.extend({}, {
      webURL: "",        // URL of the target Web.  If not specified, the current Web is used.
      listName: "",
      userAccount: "",
      CAMLQuery: ""
    }, options);

    var userId;
    var lastId = "none.xml";
    $().SPServices({
      operation: "GetUserInfo",
      async: false,
      userLoginName: (opt.userAccount != "") ? opt.userAccount : $().SPServices.SPGetCurrentUser(),
      completefunc: function (xData, Status) {
        $(xData.responseXML).find("User").each(function() {
          userId = $(this).attr("ID");
        });
      }
    });

    // Get the list items for the user, sorted by Created, descending. If the CAMLQuery option has been specified, And it with
    // the existing Where clause
    var camlQuery = "<Query><Where>";
    if(opt.CAMLQuery.length > 0) camlQuery += "<And>";
    camlQuery += "<Eq><FieldRef Name='DocOwner' LookupId='TRUE'/><Value Type='Integer'>" + userId + "</Value></Eq>";
    if(opt.CAMLQuery.length > 0) camlQuery += opt.CAMLQuery + "</And>";
    camlQuery += "</Where><OrderBy><FieldRef Name='Last_x0020_Modified' Ascending='FALSE'/></OrderBy></Query>";

    $().SPServices({
      operation: "GetListItems",
      async: false,
      webURL: opt.webURL,
      listName: opt.listName,
      CAMLQuery: camlQuery,
      CAMLViewFields: "<ViewFields><FieldRef Name='ID'/></ViewFields>",
      CAMLRowLimit: 1,
      CAMLQueryOptions: "<QueryOptions><ViewAttributes Scope='Recursive' /></QueryOptions>",
      completefunc: function(xData, Status) {
        $(xData.responseXML).find("[nodeName=z:row]").each(function() {
          lastId = $(this).attr("ows_ID");
        });
      }
    });
    return lastId;
  };

</script>

So that’s it – a focus on maintainability and running workflows using SPServices. But here’s the best thing: this entire application is not only easy to maintain, it is now entirely portable to be deployed to other sites in our environment. We are already working on a modified version to automate another process.

If you have questions post them here, or Rob Doyle can be reached through his LinkedIn profile.

Similar Posts

53 Comments

  1. So I know this is a little crazier but the process above did not work for me so I will tell you a crazy easy way to do this.

    Basically, I created the workflow. I then navigated to the workflow where the start and cancel button are. I copied the URL and opened that URL in SP designer. I then copied the entire table and pasted it into my lists’ dispform and wala! I deleted everything I didnt need like the cancel button and what not. no custom coding needed.

  2. So just my two cents on this topic. I tried this method and it did not work for me either. Here is what I did to get it done.

    1: create a workflow that copies a list item from list A to List B
    2: Set that workflow to be started manually (this creates the code for the button needed later).
    3: navigate to List A and create a record.
    4: Click on the new record and then click workflows
    5: Click on the workflow you created, there is a button that says “Start”
    6: open that entire URL in SP Designer
    7: Highlght the button and copy the entire webpart code
    8: Paste that code in a new cell in the DispForm.aspx of list A.
    Wala

  3. Hi Marc, I greatly appreciate the useful solutions you post and the detail you put into each of them. I have benefited from the knowledge you have shared and wanted to say “thanks!”

  4. Hi Marc, is there a blog post for this article which includes more detailed steps about how to implement a solution like this?

  5. I’ve read elsewhere that this SPServices StartWorkflow thing doesn’t work for SharePoint Foundation 2010 – is there a workaround for me?

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.