Wednesday, 11 May 2016

Sitecore PXM and ODG project (part 5 – PXM InDesign custom task)

  1. Introduction
  2. Non-editable fields
  3. Architecture
  4. Customising ODG email
  5. PXM InDesign custom task

For the final post on this project I will run through how we can add some custom functionality to InDesign, as we have previously created a custom (non-editable) snippet which can be used in InDesign, however there is no way to create one except through Sitecore.

The plan is to create a custom PXM task that can be executed in InDesign, so that when a Page item is selected (in the project panel) and we execute our task, a Non-editable Snippet item will be created under that Page.

There are only a couple of posts that I've seen on how to create a custom task, however it's more than enough to go off for our scenario which is not terribly complex.  Those posts cover how to create a custom task, so follow along and set up your custom task.  As is mentioned in the posts, our task is passed a dictionary of various values from InDesign; in our case the only relevant one is the ID of the item selected in the project panel, which is ci_projectPanel.  Once we know that we can get the relevant item, check that it is a Page, and create a new item beneath it.  Simple :)

I've also created  a gist for the full class, in a more legible format.  This code is untested but should work as described.
public string ExecuteTask(Dictionary<string, object> dictionary)
{
  Item page = Sitecore.Context.Database.GetItem(new ID(dictionary["ci_projectPanel"].ToString()));
  if (page.TemplateID != new ID("{6BFA47BA-F73C-48DB-9170-C0CC94179EC7}"))
  {
    return "Please select a page under which to add the non-editable snippet";
  }

  page.Add("Non-Editable", new TemplateID(new ID("{C0FD5401-A2A5-4205-831D-DF06120B389E}")));
  return $"Created custom snippet under page {page.Name}.";
}

Wednesday, 4 May 2016

Sitecore Custom Editor - Jump to Datasource

I recently worked with a client who was very new to Sitecore and CMSs in general, and was struggling to get their head around the concept of a component (with a datasource) being shown on the page, so that the content was not coming from the page item itself, but from the datasource content. To help them out I decided to look into whether I could add a custom tab which would show a preview of the page (yes, there is already a preview editor) but with the components with datasources highlighted (in a coloured border) and a clickable link to the datasource itself.

The new editor


First things first, we'll need a custom editor! There are a few blog entries already on how to do this, and it's very straightforward.  Now let's have a look at what we need to put in our new editor's aspx file.

As I mentioned, there's already a preview editor, but can we reuse or extend that?  Having a look at the item (in the core db) /sitecore/content/Applications/Content Editor/Editors/Layouts/Preview we can see the relevant page that is being shown is /sitecore/shell/~/xaml/Sitecore.Shell.Applications.ContentEditor.Editors.Preview.aspx which actually corresponds to the file sitecore\shell\Applications\Content Manager\Editors\Preview\Preview.xaml.xml which references the class Sitecore.Shell.Applications.ContentEditor.Editors.Preview.PreviewPage in the Sitecore.Client assembly.

Now I know how to read XML and XSLT, but I know I certainly prefer to write .NET, so the first thing I did was copy the necessary code over to C#.  I've created a gist for the CustomEditor.aspx and CustomEditor.aspx.cs files, which are based on the original PreviewPage, but not identical - I only included the code I deemed necessary, and on line 109 of the .cs file I have added a custom URL parameter so that we can identify that the page is being viewed in our custom editor: urlString["custom_editor"] = "true";

At this stage we can add our custom "editor" to the page and see a preview.

Highlighting the datasources


My plan to highlight the datasources and turn them into a link was to wrap the relevant renderings in a
<div class="rendering" data-datasource-id="{the-datasource-id}"> </div>

and then use javascript/CSS to highlight and make the region clickable (opening the datasource item in the content editor).

Fortunately wrapping a rendering is quite simple.  A quick Google search sent me to a page on ctor.io where I found exactly what was needed, except that the Title field in that example was changed to ID so that it can be used in the data- attribute of the HTML above.  We also only want to wrap the renderings in this way when we're in our custom editor, so we need to check the URL and ensure it contains our "custom_editor" parameter (above).
public class RendererWrapper : GetRendererProcessor
{
  public override void Process(GetRendererArgs args)
  {
    // Make sure the page is being viewed in the correct place
    if (Context.GetSiteName() == "shell") return;
    if (!Context.RawUrl.Contains("custom_editor")) return;
    if (!(args.Result is ViewRenderer)) return;

    if (args.Rendering == null || !ID.IsID(args.Rendering.DataSource)) return;

    Item dataSourceItem = args.PageContext.Database.GetItem(args.Rendering.DataSource);
    if (dataSourceItem == null) return;

    // Add wrapper rendering
    WrapperModel model = new WrapperModel
    {
      Renderer = (ViewRenderer)args.Result,
      Id = dataSourceItem.ID
    };

    args.Result = new ViewRenderer
    {
      Model = model,
      Rendering = args.Rendering,
      ViewPath = "/Views/Common/RenderingWrapper.cshtml"
    };
  }
}
public class WrapperModel
{
  public ViewRenderer Renderer { get; set; }
  public ID Id { get; set; }
}

and our RenderingWrapper.cshtml:
@model Sitecore.Common.Website.Enhancements.WrapperModel

<div class="rendering" data-datasource-id="@Model.Id">
    @Html.Partial(Model.Renderer.ViewPath, Model.Renderer.Model)
</div>

and of course we need to include the processor in the pipeline:
<pipelines>
  <mvc.getRenderer>
    <processor 
      patch:after="processor[@type='Sitecore.Mvc.Pipelines.Response.GetRenderer.GetViewRenderer, Sitecore.Mvc']"
      type="Sitecore.Common.Website.Enhancements.RendererWrapper, Sitecore.Common.Website"/>
  </mvc.getRenderer>
</pipelines>

at this point when we preview the page in our custom editor, our renderings which have datasources should be wrapped in our new div tag.  A little CSS for divs with the class "rendering" can quickly add a coloured border and turn the cursor in to a pointer.

Linking it together


The final step is to make the divs clickable - ie. opening the selected datasource in the Content Editor.  Digging around I found that the way, in Content Editor, to open an item using javascript is scForm.postEvent("", "", "item:load(id={item-id},language=en)"), where item-id is the ID of the item to open.  The main issue is that our page is being displayed in an IFrame, which is displayed in an IFrame (the custom editor tab) which is in the Content Editor frame; so the event needs to propagate from the page up 2 levels to the Content Editor where it should call the aforementioned postEvent() method.  Not the biggest issue in the world, we can use the javascript parent variable to access the parent frame.

So on our page we have the click handler:
$(function () {
  $('.rendering').click(function (e) {
    if (parent != null) {
      e.preventDefault();
      parent.renderingClicked($(this).data('datasourceId'));
    }
  });
});

which calls the handler in our custom editor .aspx (in the gist above)
function renderingClicked(datasourceId) {
  parent.scForm.postEvent("", "", "item:load(id=" + datasourceId + ",language=en)");
}

which tells the Content Editor to display our item!

Monday, 4 April 2016

Sitecore PXM and ODG project (part 4 – customising email)

  1. Introduction
  2. Non-editable fields
  3. Architecture
  4. Customising ODG email
  5. PXM InDesign custom task

In the next couple of posts I'd like to focus on some of the customisations that can be made to improve or build upon the functionality provided by PXM and ODG out of the box.

Let's start with something simple: renaming the PDF file that's output from ODG.  Fortunately for us, Sitecore has made life a little easier by checking whether the name has already been set before setting its own, which means all we have to do is insert our "naming" processor before the others. If we look at Sitecore.PrintStudio.config, we can see in the <printPreview>, <printToDashboard> and <printToInDesign> pipelines it's the Sitecore.PrintStudio.PublishingEngine.Pipelines.RenderProjectXmlargs.PrintOptions.ResultFileName, so let's insert our processor before this one. In this case I only care about the <printToDashboard> pipeline, but the others will be the same.
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/">
  <sitecore>
    <pipelines>
      <printToDashboard>
        <processor
          patch:before="processor[@type='Sitecore.PrintStudio.PublishingEngine.Pipelines.RenderProjectXml, Sitecore.PrintStudio.PublishingEngine']"
          type="Custom.Pipelines.NameOutputFiles, Custom"/>
      </printToDashboard>
    </pipelines>
  </sitecore>
</configuration>

And the processor to set the name
public class NameOutputFiles : Sitecore.PrintStudio.PublishingEngine.Pipelines.IPrintProcessor
{
  public void Process(PrintPipelineArgs args)
  {
    args.PrintOptions.ResultFileName = Utils.GenerateFilename(Context.User, args.ProcessorItem.InnerItem.ID.Guid) + args.PrintOptions.ResultExtension;
  }

  public static string GenerateFilename(User user, Guid itemId)
  {
    return string.Concat(user.LocalName, "_", itemId, "_", DateTime.UtcNow.ToString("yyyy-MM-dd-HH-mm-ss-fff"));
  }
}

In this case I've separated the code generating the name into a separate method in case we need to re-use the same naming method elsewhere in the code (for example to generate a link to the document).

Now let's look at how we can add additional recipients to the email that ODG sends out once the document has been exported.  By default it sends an email to notify the user (who generated the document) that their document is ready, with a link to the document.  What if we want this to be CCd or BCCd to for example their boss, or a support email?

For our example, let's add a support email as a BCC to all emails sent.

The code which handles sending the email is in the Dashboard Service, however the code which tells the Dashboard Service which email(s) to use is in the printToDashboard pipeline, in the SendToDashboard class, so let's extend that and switch it out in a patch:
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/">
  <sitecore>
    <pipelines>
      <printToDashboard>
        <processor 
          patch:instead="processor[@type='Sitecore.PrintStudio.PublishingEngine.Pipelines.Dashboard.SendToDashboard, Sitecore.PrintStudio.PublishingEngine']"
          type="Custom.Pipelines.SendToDashboard, Custom"/>
      </printToDashboard>
    </pipelines>
  </sitecore>
</configuration>

So taking a look at SendToDashboard in the Send method, we can see the message is being built by SoapHelper.SendSoapMessage() call, and taking a look in SoapHelper we find that the CreateGroupNode method takes a BCC field, to which SendSoapMessage is just passing a blank string.

Unfortunately here's where things get a bit messy. We need to get BCC into SendSoapMessage, so ideally we would extend SoapHelper and add another override of SendSoapMessage which takes BCC as a parameter (with pretty much exactly the same code), however a lot of the methods that are used in SoapHelper are marked private (not protected) so we can't re-use the existing code!  At the end of the day I found it easiest to just copy the SendSoapMessage code directly into the new SendToDashboard class and use reflection to call CreateGroupNode() and CreateJobNode().  It would be great if Sitecore made these protected in the future, so we can extend the SoapHelper class (or expose the CC and BCC fields).

So now in our SendToDashboard class we have our new Send() method which calls the new SendSoapMessage() method with our BCC field.  Let's take things a little further and say we want to pass new info into this email that's being sent.  What if we want to pass the name of the user's document into the email so that they know which one it was (sounds pretty standard, right)?

Unfortunately this is just as messy.  In this case it's the CreateSoapMessageDetails() method we need to override to take the document name and pass it to SoapMessageDetails(), which should pass it into the string.Format() which builds the "Success" or "Failed" nodes.  Again none of these are protected, so we need to pretty  duplicate the code all the way through with our new parameter.  Don't forget to update the email template to make use of this new parameter!
public class SendToDashboard : Sitecore.PrintStudio.PublishingEngine.Pipelines.IPrintProcessor
{
  public void Process(PrintPipelineArgs args)
  {
    if (!string.IsNullOrEmpty(args.XmlResultFile))
    {
      Item processingJobItem = args.ProcessorItem.Database.GetItem(args.PrintJobId);
      this.Send(args.XmlResultFile, Path.Combine(args.PrintOptions.ResultFolder, args.PrintOptions.ResultFileName), args.PrintOptions.ResultFolder, processingJobItem, args.ProcessorItem.InnerItem.Name);
    }
    else
    {
      Logger.Info("Empty args.XmlResultFile");
      args.AddMessage("Empty args.XmlResultFile", PipelineMessageType.Error);
      args.AbortPipeline();
    }
  }
  private void Send(string resultXmlFileName, string pdfFileName, string absoluteFilePath, Item processingJobItem, string docName)
  {
    if (string.IsNullOrEmpty(resultXmlFileName))
    {
      return;
    }
    string dbServerIpAddress = WebConfigHandler.PrintStudioEngineSettings.DbServerIpAddress;
    string dbServerPort = WebConfigHandler.PrintStudioEngineSettings.DbServerPort;
    Assert.IsNotNullOrEmpty(dbServerIpAddress, "Missing PrintStudio.DBServer.IPAddress configuration");
    Assert.IsNotNullOrEmpty(dbServerPort, "Missing PrintStudio.DBServer.Port configuration");
    Language userLanguage = SitecoreHelper.GetUserLanguage(Context.User, processingJobItem.Database);
    XmlDocument resultDocument = new XmlDocument();
    Logger.Info("Sending to Dashboard: " + Context.User.Name + " (" + Context.User.Profile.Email + ") file " + resultXmlFileName);
    SendSoapMessage(Context.User.Name,
      Context.User.Name,
      Context.User.Profile.Email,
      resultXmlFileName,
      WebConfigHandler.PrintStudioEngineSettings.ResponseType,
      WebConfigHandler.PrintStudioEngineSettings.DashboardQueueName,
      WebConfigHandler.PrintStudioEngineSettings.DashboardServiceMethod,
      SitecoreHelper.GetMessage(processingJobItem, userLanguage, "Mail Subject") + " " + TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, TimeZoneInfo.FindSystemTimeZoneById(Constants.General.Timezone)).ToString("g"),
 string.Empty,
     CreateSoapMessageDetails(processingJobItem, resultDocument, userLanguage, docName).ToString(),
     SitecoreHelper.GetPrintFilePath(processingJobItem, "relative filename path"),
     absoluteFilePath,
     pdfFileName,
     $"{dbServerIpAddress}:{dbServerPort}",
     processingJobItem.Database.GetItem(new ID(Constants.Items.SiteSettings))?.Fields[Constants.Fields.SupportEmail]?.Value);
  }
  private static string SendSoapMessage(string userName, string loginName, string email, string jobXml, string responseType, string serviceType, string serviceMethod, string subject, string body, string messageBrands, string relativePath, string absolutePath, string fileName, string serviceUrl, string bcc)
  {
     string startTime = DateTime.UtcNow.ToString("s", DateTimeFormatInfo.InvariantInfo);
     XmlDataDocument xmlDataDocument = new XmlDataDocument();
     XmlNode groupNode = (XmlNode)typeof(SoapHelper).GetMethod("CreateGroupNode", BindingFlags.Static | BindingFlags.NonPublic).Invoke(null, new object[] { xmlDataDocument, userName, userName, loginName, startTime, responseType, email, body, subject, string.Empty, bcc, string.Empty, "HTML" });
     XmlNode jobNode = (XmlNode)typeof(SoapHelper).GetMethod("CreateJobNode", BindingFlags.Static | BindingFlags.NonPublic).Invoke(null, new object[] { xmlDataDocument, userName, "1", serviceType, serviceMethod, string.Empty, fileName, relativePath, absolutePath, string.Empty, string.Empty, jobXml, string.Empty, serviceUrl });
     typeof(SoapHelper).GetMethod("AddJobNode", BindingFlags.Static | BindingFlags.NonPublic).Invoke(null, new object[] { xmlDataDocument, groupNode, jobNode });
     if (!string.IsNullOrEmpty(messageBrands))
     {
       XmlNode xmlNode = groupNode.SelectSingleNode("//Jobs/Job");
       if (xmlNode != null && groupNode.OwnerDocument != null)
       {
         XmlElement element = groupNode.OwnerDocument.CreateElement("ResultMail");
         element.InnerXml = messageBrands;
         xmlNode.AppendChild(element);
       }
    }
    return SoapHelper.DashBoardWebService.Send("<?xml version=\"1.0\" encoding=\"utf-16\" ?>" + groupNode.OuterXml); // returns "Success"
  }
  private static StringBuilder CreateSoapMessageDetails(Item processingJobItem, XmlDocument resultDocument, Language language, string jobName)
  {
    string message1 = SitecoreHelper.GetMessage(processingJobItem, language, "Mail Header");
    string message2 = SitecoreHelper.GetMessage(processingJobItem, language, "Mail Footer");
    string message3 = SitecoreHelper.GetMessage(processingJobItem, language, "Ready Message");
    string message4 = SitecoreHelper.GetMessage(processingJobItem, language, "Rejected Message");
    return SoapMessageDetails(resultDocument, message3, message4, message1, message2, jobName);
  }
  private static StringBuilder SoapMessageDetails(XmlDocument resultDocument, string jobSuccess, string jobFailure, string mailHeader, string mailFooter, string jobName)
  {
    StringBuilder stringBuilder = new StringBuilder();
    XmlNode xmlNode1 = resultDocument.CreateElement("Header");
    xmlNode1.AppendChild(resultDocument.CreateCDataSection(mailHeader));
    stringBuilder.Append(xmlNode1.OuterXml);
    XmlNode xmlNode2 = resultDocument.CreateElement("Footer");
    xmlNode2.AppendChild(resultDocument.CreateCDataSection(mailFooter));
    stringBuilder.Append(xmlNode2.OuterXml);
    XmlNode xmlNode3 = resultDocument.CreateElement("Success");
    xmlNode3.AppendChild(resultDocument.CreateCDataSection(string.Format(jobSuccess, Context.User.Profile.FullName, Context.User.LocalName, jobName)));
    stringBuilder.Append(xmlNode3.OuterXml);
    XmlNode xmlNode4 = resultDocument.CreateElement("Failed");
    xmlNode4.AppendChild(resultDocument.CreateCDataSection(string.Format(jobFailure, Context.User.Profile.FullName, Context.User.LocalName, jobName)));
    stringBuilder.Append(xmlNode4.OuterXml);
    return stringBuilder;
  }
}

This was a bit of a messy one. Hopefully with newer releases of PXM and ODG Sitecore makes things a little more extensible!