Saturday, May 28, 2011

Publishing pages auto save mechanism in Sharepoint when user leaves edit mode. Part 1

In this series of posts I would like to describe the internal mechanisms used by Sharepoint for auto saving publishing pages. Publishing pages are part of publishing infrastructure feature of Sharepoint (basic WCM feature) and content producers may add content on these pages on behalf of business needs. In order to add content on a page user should switch page to Edit mode (Site Actions > Edit Page). When all necessary changes are done user should save his changes by choosing ribbon Page > Check In, Save & Close or Publish.

If user forgot to save changes Sharepoint has useful feature which prevents loosing of content: publishing pages auto save. Most probably you saw javascript confirmation dialog when tried to leave page edit mode without saving, like this:

image

or this

image

If you familiar with onbeforeunload event handling in IE, you most probably know that this is standard IE javascript confirmation dialog which prevents user to leave the page. Header “Are you sure you want to navigate away from this page” and footer “Press OK to continue, or Cancel to stay on the current page” are standard texts shown by IE. But middle part text “The page could not be saved because your changes conflict with recent changes made by another user. If you continue, your changes will be lost” is custom message which can be set in your javascript handler. This functionality is not Sharepoint-specific, it is standard IE behavior. As we will see below Sharepoint utilizes this feature for auto save feature. Let’s look under the hood of its implementation.

Key element in auto save feature is Publishing Console control. In most cases it is added with feature into master page of the publishing site:

   1: <asp:ContentPlaceHolder ID="SPNavigation" runat="server">
   2:     <SharePoint:DelegateControl runat="server" ControlId="PublishingConsole" Id="PublishingConsoleDelegate"/>
   3: </asp:ContentPlaceHolder>

Control itself is located in "14\Template\ControlTemplates\PublishingConsole.ascx". Let’s look inside:

   1: <%@ Control Language="C#"   %>
   2: ...
   3: <SharePoint:UIVersionedContent id="publishingConsoleV4" UIVersion="4" runat="server">
   4:     <ContentTemplate>
   5:         <PublishingInt:PublishingRibbon id="publishingRibbon" runat="server" />
   6:     </ContentTemplate>
   7: </SharePoint:UIVersionedContent>
   8: <SharePoint:UIVersionedContent id="publishingConsoleV3" UIVersion="3" runat="server">
   9:     <ContentTemplate>
  10:         ...
  11:     </ContentTemplate>
  12: </SharePoint:UIVersionedContent>

As you can see it contains 2 different consoles for different UI versions:

  • UI version = 4 – this is for new UI with ribbons which is used by default in Sharepoint 2010;
  • UI version = 3 – this is for old style UI which used in Sharepoint 2007.

In Sharepoint 2010 you can use old style for UI – see my previous blog post: Use Sharepoint 2007 sites look and feel in Sharepoint 2010. In context of the current article it is important that there are 2 different versions of the Publishing Console. And auto save feature is implemented differently for them. For UI version = 4 it uses ribbon javascript API (SP.Ribbon.PageState.PageStateHandler), and for UI version = 3 SaveBeforeNavigationControl is used. In this part I will describe how auto save is implemented in UI version = 3 (Sharepoint 2007). In the next part I will show mechanism used for UI version = 4.

Publishing Console in Sharepoint 2007 (UI version = 3) looks like this:

image

As I told above basic element of our investigation for UI version = 3 is SaveBeforeNavigationControl class (there is also SaveBeforeNavigateHandler, but it is used for non-publishing pages, like Wiki pages. Probably I will also describe how it works in one of the next articles). This control implements ICallbackEventHandler interface. In order to fully understand the logic of the control I will briefly describe how ASP.Net uses this interface. It allows to use control as a target handler for the javascript calls. Common usage is the following:

  1. Place control on the page
  2. Call ClientScriptManager.GetCallbackEventReference method and pass reference to the control as first argument. It will return javascript method which can be called from client side without postbacks. Request will be sent to the server and control will handle it
  3. When javascript method (from step 2) is called on the client side ICallbackEventHandler.RaiseCallbackEvent method of the control is triggered. It is important to understand that this method is called on server side, although it was initiated from client side via javascript
  4. Then ICallbackEventHandler.GetCallbackResult method is triggered as part of the same methods calls chain on the server. You can return string with the status of callback processing. State of the control between RaiseCallbackEvent and GetCallbackResult method calls is preserved, i.e. they are called on the same object instance. So you can use e.g. class member variables in order to save state between the calls:

       1: public class MyControl : WebControl, ICallbackEventHandler
       2: {
       3:     private int i = 0;
       4:  
       5:     public void RaiseCallbackEvent(string eventArgument)
       6:     {
       7:         i = 1;
       8:     }
       9:  
      10:     public string GetCallbackResult()
      11:     {
      12:         return i.ToString();
      13:     }
      14: }

  5. Result of GetCallbackResult function is passed to the javascript handler which you can specify during the call to GetCallbackEventReference (see step 1)

       1: <script type="text/javascript">
       2:     function CallBackHandler(result) {
       3:         alert(result);
       4:     }
       5: </script>

Now when we saw how mechanism of callback handlers works in ASP.Net lets see how it is used inside SaveBeforeNavigationControl.RaiseCallbackEvent method is empty and all work is done in GetCallbackResult (for simplifying I removed all logging methods):

   1: string ICallbackEventHandler.GetCallbackResult()
   2: {
   3:     try
   4:     {
   5:         if ((ConsoleUtilities.FormContextMode == SPControlMode.Edit) &&
   6:             (WebPartManager.GetCurrentWebPartManager(this.Page).Personalization.Scope == PersonalizationScope.Shared))
   7:         {
   8:             this.Page.Validate();
   9:             if (!this.Page.IsValid)
  10:             {
  11:                 return SPHttpUtility.NoEncode(Resources.GetString("SaveBeforeNavigateValidationErrorEncounteredWarning"));
  12:             }
  13:             SPListItem item = SPContext.GetContext(HttpContext.Current).Item as SPListItem;
  14:             if (item != null)
  15:             {
  16:                 if (ConsoleContext.AuthoringItemVersion != ConsoleContext.CurrentItemVersion)
  17:                 {
  18:                     return SPHttpUtility.NoEncode(Resources.GetString("ErrorFileVersionConflict"));
  19:                 }
  20:                 string currentItemVersion = ConsoleContext.CurrentItemVersion;
  21:                 if (!string.IsNullOrEmpty(currentItemVersion))
  22:                 {
  23:                     try
  24:                     {
  25:                         currentItemVersion = 
  26:                             (int.Parse(currentItemVersion, CultureInfo.InvariantCulture) + 1).ToString(CultureInfo.InvariantCulture);
  27:                     }
  28:                     catch (FormatException)
  29:                     {
  30:                         currentItemVersion = string.Empty;
  31:                     }
  32:                     catch (OverflowException)
  33:                     {
  34:                         currentItemVersion = string.Empty;
  35:                     }
  36:                 }
  37:                 item.Properties["SBN_SaveSucceededField"] = currentItemVersion;
  38:                 item.Properties["SBN_SaveSucceededRequestDigest"] = HttpContext.Current.Request.Form.Get("__REQUESTDIGEST");
  39:                 item.Update();
  40:                 ConsoleContext.AuthoringItemVersion = ConsoleContext.CurrentItemVersion;
  41:             }
  42:             else
  43:             {
  44:                 // log
  45:             }
  46:         }
  47:         else
  48:         {
  49:             return "save succeeded";
  50:         }
  51:     }
  52:     catch (SPException exception)
  53:     {
  54:         return SPHttpUtility.NoEncode(Resources.GetFormattedString("ConsoleSaveErrorMessageWithException",
  55:             new object[] { exception.Message }));
  56:     }
  57:     return "save succeeded";
  58: }

It validates the page (e.g. checks that all required fields are specified) and checks that current version is still the same as was on the moment when page was switched to the edit mode. It is done in order to prevent overriding of the other users changes (optimistic lock). Here the list of exact error message (from Microsoft.SharePoint.Publishing.Intl.dll assembly) which may come from SaveBeforeNavigationControl (not only from GetCallbackResult method, but also from OnPreRender – see below):

Resource Key Value
SaveBeforeNavigateErrorEncounteredWarning The following error was encountered while attempting to save the page:
ErrorFileVersionConflict The page you are attempting to save has been modified by another user since you began editing. Choose one of the following options:
SaveBeforeNavigateNotCheckedOutWarning           To save your changes before continuing, click "OK". To continue without saving changes, click "Cancel".
SaveBeforeNavigateErrorEncounteredWarning The following error was encountered while attempting to save the page:
SaveBeforeNavigateUnknownErrorEncounteredWarning The page took too long to save. You can click "Cancel", and then try to save the page again. If you click "OK", you might lose unsaved data.
SaveBeforeNavigateCurrentlySavingStatus Saving Page Content...
ConsoleSaveErrorMessage This page contains content or formatting that is not valid. You can find more information in the affected sections.

Then it stores increased version number into item properties collection. But where the actual saving of the page content occurs? In order to answer this question we need to investigate second important part – SaveBeforeNavigateHandler.OnPreRender method. I don’t want to explain all details of these method – not all of them are important. Briefly it adds necessary javascript on the page. Exactly this method registers handler for the window.onbeforeunload event. Lets check most important part of javascript registered in the OnPreRender() method:

   1: window.onbeforeunload = cms_handleOnBeforeUnload;
   2:  
   3: function cms_handleOnBeforeUnload() {
   4:     if (g_bWarnBeforeLeave && browseris.ie6up) {
   5:         if (useSyncCallback) { MakeCallbacksSynchronous(); }
   6:         g_recentCallBackResult = "";
   7:         ShowContentSavingBusyMessage(true);
   8:         __theFormPostData = "";
   9:         _spSuppressFormOnSubmitWrapper = true;
  10:         try {
  11:             WebForm_OnSubmit();
  12:             event.returnValue = undefined;
  13:         }
  14:         finally {
  15:             _spSuppressFormOnSubmitWrapper = false;
  16:         }
  17:         WebForm_InitCallback();
  18:         WebForm_DoCallback('ctl00$SPNavigation$ctl01$publishingConsoleV3$sbn1', '', SBN_CallBackHandler, null, null, false);
  19:         if (!useSyncCallback) {
  20:             var count = 0;
  21:             while (g_recentCallBackResult == "" && count < 150) {
  22:                 count++;
  23:                 WaitForCallback(100);
  24:             }
  25:         } else {
  26:             ResetCallbackMode();
  27:         }
  28:         ShowContentSavingBusyMessage(false);
  29:         if (g_recentCallBackResult != "save succeeded") {
  30:             g_bWarnBeforeLeave = true;
  31:             if (g_recentCallBackResult != "") {
  32:                 var validationString = 'The following error was encountered while attempting to save the page:' + '  ' + g_recentCallBackResult;
  33:                 return validationString;
  34:             }
  35:             else {
  36:                 return 'The page took too long to save. You can click \u0022Cancel\u0022, and then try to save the page again. If you click \u0022OK\u0022, you might lose unsaved data.';
  37:             }
  38:         }
  39:         var saveComplete = document.forms['aspnetForm'].MSO_PageAlreadySaved;
  40:         if (saveComplete != null) {
  41:             saveComplete.value = "1";
  42:         }
  43:     }
  44:     g_bWarnBeforeLeave = false;
  45: }
  46:  
  47: var g_recentCallBackResult = "";
  48: function SBN_CallBackHandler(callBackResult) {
  49:     g_recentCallBackResult = callBackResult;
  50: }

As I already said it registers handler for the window.onbeforeunload event - cms_handleOnBeforeUnload. This method is executed when page is unloaded – e.g. when user leaves the edit mode without saving. There is a lot of code, but most of it – just handling of different browsers support level for XMLHttpRequest object (IE supports callback timeouts, so variable useSyncCallback = true in IE, but e.g. in FF it is false and instead of one call to the XMLHttpRequest with specified timeout in FF it will use loop with periodical calls to http://example.com/_vti_bin/PublishingService.asmx Wait() method – see WaitForCallback(100) method call above).

For us only 2 lines of code are important:

   1: WebForm_OnSubmit();
   2: ...
   3: WebForm_DoCallback('ctl00$SPNavigation$ctl01$publishingConsoleV3$sbn1', '', SBN_CallBackHandler, null, null, false);

First line (WebForm_OnSubmit()) is exactly the line which causes saving of the page content. You can try to override the cms_handleOnBeforeUnload with your realization and comment the first line – you will see that changes now are not saved when user leaves the edit mode. And second line – is call to SaveBeforeNavigationControl.GetCallbackResult() method. If you remember this method returns "save succeeded" string. So cms_handleOnBeforeUnload handler checks that if returned string is not "save succeeded" – method returns string with validation errors. And this message will be shown in the middle of the IE javascript window as we already saw above (http://msdn.microsoft.com/en-us/library/ms536907(v=vs.85).aspx).

That’s how auto saving works in Sharepoint 2007 and in Sharepoint 2010 with UI version = 3. In the next part I will describe how it works in Sharepoint 2010 with UI version = 4.

6 comments:

  1. Hi,

    This post is very interresting, thank you for sharint, do you plan to publish the second part soon ?

    Best regard

    ReplyDelete
  2. hi STEMax,
    glad that you like it. It is hard to say currently when 2nd part will be published. Not very soon, have lack of time. Will shift it up in priority chain

    ReplyDelete
  3. Hi,

    Yeah, time is the most valuable resource out there :) Anyway your blog is very interesting, I hope you'll have enought time to keep on.

    Cheers !

    ReplyDelete
  4. Hi Alex,

    Good write up. I am the two prompt messages on the same page from one of my Sharepoint workflows. I recently migrated from SP2007 to Sp2010 farm, suddently the approval workflow started to bring these two message consecutively after use click the approve button. I have tried place the "meta equiv='X-UA-Compatible'... that others have suggested but no luck.

    Any recommendations on how I can troubleshoot this?

    Tjanks

    ReplyDelete
  5. Gene, it is hard to say without any additional info. After migration to SP2010, do you still use UIVersion 3 or 4. If you still use UIVersion 3, try to replace cms_handleOnBeforeUnload with your own implementation - copy original function and add alerts in it. Doing like this you will be able to track the execution path which ends with warnings and probably will understand what was changed after migration.
    The quicker way to replace standard implementation of cms_handleOnBeforeUnload, is to use Content editor web part and add javascript code in it which overrides window.onbeforeunload in it (this code should be executed after Sharepoint code which assigns handler to window.onbeforeunload).
    Another possible direction of investigation - is to check under what account workflow is running. Was it changed somehow after migration?

    ReplyDelete
  6. Hi Alex,

    This happen in bother UIVersion 3 and 4. I got the prompt before and after UI upgrade. The workflow was changed after the migration but as far as I know, it was minor change.

    ReplyDelete