Friday, September 25, 2009

Detecting Edit Mode view of an EPiServer page

Today I was given the slightly unusual requirement that my page must behave differently depending on whether it is being viewed normally, or through the IFrame in EPiServer's Edit Mode. It was a little tricky finding the answer so I thought I would explain it...

Obviously the only way that the Edit Mode view can be detected is if there is something in the IFrame source URL used to request the page which gives it away. Fortunately there is. The page is requested using the unfriendly URL. The ID in the query-string contains both the page ID and the work page ID. Usually, when the page is loading after a rewrite from the friendly url, the work page ID is not included. Also, there is another querystring parameter called "idkeep" which apparently is not present except in this IFrame source. These are what we can look for to determine whether we are in Edit Mode or not.

Having figured out the solution it wasn't long before I realised that the best way to code this, had already been written by Kalle Hoppe as a reply on this blog post by Jacob Khan (which is confusingly named and is actually about a different topic - detecting if a control is being used on an Admin mode plugin).

All of the comments on that post miss the point of that blog post entirely, but are all valid methods to solve my problem. Of all of them I would recommend Kalle's method, since it uses the EPiServer API to parse the ID, and it is a parameter which is less likely to be changed by EPiServer in the future. It also doesn't require changing the EPiServer.UI.WebContols.SystemIFrame control.

Here's the code:


public bool IsPageInEditMode
{
PageReference pageVersionReference = PageReference.Parse(Request.QueryString["id"]);

if (pageVersionReference.WorkID > 0)
return true;
return false;
}


Monday, August 24, 2009

EPiServer Composer layouts should not replace mark-up

I’ve been using EPiServer Composer (formerly X3) for over a year now. EPiServer Composer can allow you to develop a very nice drag and drop interface for your editors, essentially allowing them to layout areas of new pages as and when they’re created. The temptation here, is to develop a series of functions (droppable objects like controls) which are so generic in nature, that you could allow your editors to layout the entire page. This might seem like a good idea at first, I mean, why limit the control that your editors have? Surely it’ll mean less development effort in the future?!

The problem is that Composer is not template based like ASP.NET is. It works on a per-page basis. Function properties (which includes layout functions) are all stored on a page. This means that it is impossible to give the editors the ability to make changes to page layouts globally.

Composer does have a system for allowing editors to define templates, which are useful if they want to create a series of pages which all have the same layout, however, once a page has been created using a template, all ties to that template are lost. Editing the template will not cascade the layout changes to the pages created from it.

It also has a feature called Global functions, which are functions whose properties are managed centrally, allowing site-wide changes where they are used. The problem here is that global functions do not (for implementation reasons) support layout functions.

No, Composer is much better suited for use in designing smaller areas of the page (the page body for instance where the layout will be different on each page anyway), where the surrounding controls (header, footer, navigation menu etc.) are all hard coded within the ASPX. This gives the developer a measure of control over the more important layouts, as well as the ability to make site-wide changes through simple code changes. As a rule of thumb, use Composer content areas where you might normally have used an XHTML property and Dynamic Content. Essentially Composer does the same job as Dynamic Content, but it’s so much easier for non-developers to use to layout the page.

Another of the reasons using Composer is so attractive, is that allowing pages to be changed without new code, means cutting out complex release management processes in larger businesses. However, if deployment of new code is a time consuming issue, and is something you’re trying to avoid, then consider using an EPiServer unified virtual path provider to allow developers (maybe even editors) to upload new ASPX templates at runtime, rather than flooding your pages with Composer functions where they shouldn’t be used, and potentially losing the manageability of your site.

At the end of the day, if you’re really intent on using Composer to the extreme but need to maintain a certain control over site-wide layout, then there is one suggestion I can make. It can be done using an elaborate system of url providers to point to a single Composer-designed page (a kind of template page) together with functions which fetch data from other pages or an external system, based on the requested URL. The problems with this though, are numerous. The development effort is huge and the performance impacts (unless you implement an equally convoluted caching strategy) will be nothing short of disastrous. I only mention this so that you can consider all possible solutions that might work for your business, but in general I would only recommend using Composer where layouts really do change on a per-page basis.

Custom Properties and Modal Dialogs

Ever wanted to be able to easily throw together a pop-up window custom property simply and easily? This blog entry describes the a set of base classes I use when I want to create such a property, and shows an implementation of a simple key-value editor.

Why use modal dialogs?

I use this approach whenever I think my new custom property is likely to get a bit too complex, or if I’m adding to an already large list of properties on a page type. There’s nothing worse than hitting that Edit tab and having to wait ages for the page to load all the properties. Users of Composer, with its browser busting page editor property, will know what I mean. Using dialogs keeps it all a little more lightweight, until the point of need. Writing for a standard ASP.NET page is also a lot easier and more flexible than writing a custom property in the usual way.

The Dialog Loader

Let’s start with the actual custom property control. Its responsibility is to load a modal window, accept the returned XML, and save the data. It is also responsible for showing something meaningful to the user both before and after the dialog is loaded (i.e, what they’re about to edit before they load the dialog, and what they are about to save after the dialog returns). If any of you have used the multi-page selection custom property in EPiCode, you’ll recognise the idea. My control looks like this...




A single custom property

One of the main aims of this work was to create a single reusable custom property that I could just register straight away, and not have to develop a different version of it each time I wanted this sort of property. It would work in such a generic way that each time I use it, I could configure it point it to any page (dialog) and it would load up that page when needed. Herein lied the first difficulty. There is no way to add fields to the custom property configuration screen. It’s number 1 on Henrik Nyström’s wish list for the EPiServer property framework.

To get round the problem I designed the custom property control so that it looks at the name of the instance of this property on the page type and locates .NET app settings in web.config which begin with that name.

In the example below, the configurable items are; the url of the dialog to load and the width and height of the dialog. This means my web.config app settings look like this...




<appSettings>
<add key="KeyValueListDialogURL" value="/Admin/Dialogs/KeyValueListDialog.aspx"/>
<add key="KeyValueListDialogWidth" value="450"/>
<add key="KeyValueListDialogHeight" value="150"/>
</appSettings>


Loading the Dialog

In order to keep things consistent, and to follow the EPiServer road-map as closely as possible, I have elected to use the EPi javascript library to open my dialogs (in essentially the same way as the page selection or file selection dialogs do). When the user clicks on the elipses button the property control loads that dialog and passes the previously saved value (if available) and the display text. It then waits for the dialog to close and receives the changed display text and property value. The property can then be saved. Here is the complete code for the custom property:




using System;
using System.Configuration;
using System.Web.UI.HtmlControls;
using System.Web.UI.WebControls;
using System.Xml.Linq;
using System.Xml.Serialization;
using EPiServer.Core;
using EPiServer.PlugIn;
using EPiServer.Web.PropertyControls;

namespace EPiDave.CustomProperties
{

/// <summary>
/// Custom property to store data that has come from a dialog box
/// </summary>
[PageDefinitionTypePlugIn]
public class PropertyDialogData : PropertyLongString
{

#region Overrides

/// <summary>
/// Creates a new PropertyDialogData Control.
/// This is used by the properties window.
/// </summary>
/// <returns>PropertyDialogData Control</returns>
public override IPropertyControl CreatePropertyControl()
{
return new PropertyDialogDataControl();
}

#endregion

#region Properties

/// <summary>
/// Gets the description from the raw xml of this property.
/// </summary>
public string DataDescription
{
get
{
XElement dialogData = null;
try { dialogData = XElement.Parse(this.ToString()); }
catch { }

XElement dataNode = GetDataFromDialogData(dialogData, "Description");

return (dataNode != null) ? dataNode.Value : String.Empty;
}
}

/// <summary>
/// Gets the data from the raw xml of this property
/// </summary>
public XElement Data
{
get
{
XElement dialogData = null;
try { dialogData = XElement.Parse(this.ToString()); }
catch { }

return GetDataFromDialogData(dialogData, "Data");
}
}

#endregion

#region Static Methods

/// <summary>
/// Helper to the Descendent nodes of the DialogData by name
/// </summary>
/// <param name="dialogData">The dialog data</param>
/// <param name="elementName">The descendent node name</param>
/// <returns></returns>
public static XElement GetDataFromDialogData(XElement dialogData, string elementName)
{
try
{
if (dialogData != null)
{
XElement dataElement = dialogData.Element(elementName);
if (dataElement != null)
return dataElement;
}
}
catch { }
return null;
}

/// <summary>
/// Helper to create dialog data from xml data and description
/// </summary>
/// <param name="description">The description of the dialog data</param>
/// <param name="data">The dialog data</param>
/// <returns></returns>
public static XElement CreateDialogData(string description, XElement data)
{
XElement newData = new XElement("Data");

if (data != null)
newData.Add(data);

XElement dialogData = new XElement("DialogData",
new XElement("Description", description),
newData);

return dialogData;
}

#endregion

}

/// <summary>
/// Server control used for the DialogData custom property
/// </summary>
public class PropertyDialogDataControl : PropertyLongStringControl
{

#region Constants

private const string DIALOG_JS_KEY = "PropertDialogDataJavaScript";

#endregion

#region Server Controls

private HiddenField _dataContainer;
private TextBox _statusText;

#endregion

#region Private Properties

private string EditorURL
{
get { return ConfigurationManager.AppSettings[Name + "DialogURL"]; }
}

private int DialogWidth
{
get { return Int32.Parse(ConfigurationManager.AppSettings[Name + "DialogWidth"]); }
}

private int DialogHeight
{
get { return Int32.Parse(ConfigurationManager.AppSettings[Name + "DialogHeight"]); }
}

#endregion

#region Overrides

public override void CreateEditControls()
{

// Hidden field
_dataContainer = new HiddenField();

if (DialogData.Value != null)
_dataContainer.Value = DialogData.Value.ToString();

Controls.Add(_dataContainer);

// Text Box
_statusText = new TextBox();
_statusText.EnableViewState = false;
_statusText.ReadOnly = true;
_statusText.Enabled = false;
_statusText.Text = DialogData.DataDescription;
Controls.Add(_statusText);

// Button
HtmlInputButton button = new HtmlInputButton();
button.Value = "...";
button.Attributes.Add("class", "epismallbutton");

// Add an onclick event to the button that will
// launch a popup window for the selected dialog url.
button.Attributes.Add("onclick", string.Format("LaunchDialog('{0}','{1}');", _dataContainer.ClientID, _statusText.ClientID));
Controls.Add(button);

// Create the dialog launch javascript.
string scriptString = @"
<script type='text/javascript'>
<!--
function LaunchDialog(hiddenXmlInputId, displayTextId)
{
var hiddenXmlInputCtrl = document.getElementById(hiddenXmlInputId);

var url = '"
+ EditorURL + @"';
var features = {width: "
+ DialogWidth.ToString() + @", height: " + DialogHeight.ToString() + @"};

var callbackArguments = new Object();
callbackArguments.hiddenXmlInputId = hiddenXmlInputId;
callbackArguments.displayTextId = displayTextId;

EPi.CreateDialog(
url,
WhenDialogClosed,
callbackArguments,
hiddenXmlInputCtrl.value,
features);

}

function WhenDialogClosed(returnValue, callbackArguments)
{
if (returnValue != null)
{
var statusTextboxCtrl = document.getElementById(callbackArguments.displayTextId);
var hiddenXmlInputCtrl = document.getElementById(callbackArguments.hiddenXmlInputId);

if (returnValue.isOk == true)
{
hiddenXmlInputCtrl.value = returnValue.xml;
statusTextboxCtrl.value = returnValue.displayText;
}
}
}
//-->
</script>
"
;

// register the javascript on the page.
if (!Page.ClientScript.IsStartupScriptRegistered(this.GetType(), DIALOG_JS_KEY))
Page.ClientScript.RegisterStartupScript(this.GetType(), DIALOG_JS_KEY, scriptString);

}

/// <summary>
/// Updates the data
/// </summary>
public override void ApplyChanges()
{
ApplyEditChanges();
}

/// <summary>
/// Saves new data
/// </summary>
public override void ApplyEditChanges()
{
// Encode current hidden value, and text field to save
string xmlString = _dataContainer.Value;

if (String.IsNullOrEmpty(xmlString))
{
// Check required property before clearing
if (IsRequired)
{
AddErrorValidator(string.Format("{0} must be specified.", Name));
return;
}
}

// Store the xml in the database
SetValue(xmlString);
}

#endregion

#region Public Properties

/// <summary>
/// Gets the data that the user has received from the dialog
/// </summary>
public PropertyDialogData DialogData
{
get
{
return PropertyData as PropertyDialogData;
}
}

/// <summary>
/// Returns that this control cannot be used for on page editing
/// </summary>
public override bool SupportsOnPageEdit
{
get
{
return false;
}
}

/// <summary>
/// Override the table layout so that the entry field
/// is placed to the right of the prompt rather then on
/// a new row.
/// </summary>
[XmlIgnore]
public override TableRowLayout RowLayout
{
get
{
return TableRowLayout.Default;
}
}

#endregion

}

}


The Dialog Page

When the dialog is loaded it receives the previously saved data from the parent window on the client side. We therefore perform a post-back, so that this data can be used by server side code. This functionality, as well as a method called ‘SaveAndClose’ (which sends the data back to the parent window) are standard requirements, so I have created a base class from which all Dialog pages derive. Imagine if you had to copy this code every time. Yuck. So here’s the code for the base class:




using System;
using System.Web.UI;
using System.Xml.Linq;
using EPiDave.CustomProperties;

namespace EPiDave.Admin
{

public class Dialog : System.Web.UI.Page
{

private XElement _dialogData = null;

protected System.Web.UI.WebControls.HiddenField ReturnDisplayText;
protected System.Web.UI.WebControls.HiddenField ReturnValueXml;
protected System.Web.UI.WebControls.HiddenField InitialXml;

protected override void OnInit(EventArgs e)
{

// Add required controls to the page's form
ReturnDisplayText = new System.Web.UI.WebControls.HiddenField();
ReturnValueXml = new System.Web.UI.WebControls.HiddenField();
InitialXml = new System.Web.UI.WebControls.HiddenField();
InitialXml.ID = "InitialXml";
InitialXml.EnableViewState = false;
Form.Controls.Add(ReturnDisplayText);
Form.Controls.Add(ReturnValueXml);
Form.Controls.Add(InitialXml);

base.OnInit(e);

}

protected override void OnLoad(EventArgs e)
{

base.OnLoad(e);

// Little work-around for adding the client side __doPostBack() function
// to a page that ASP.NET doesn't think requires it.
ClientScript.GetPostBackClientHyperlink(new Control(), String.Empty);

// Add dialog javascript
string javaScript = @"
// Global variables
var isPostBack = false;

// Safe way to have many onload functions. EPiServer might add
// it's own, we don't want to overwrite those
function addLoadEvent(func) {
var oldonload = window.onload;
if (typeof window.onload != 'function') {
window.onload = func;
} else {
window.onload = function() {
if (oldonload) {
oldonload();
}
func();
}
}
}

// Called when the page is loaded, we
// only want to do this the first time,
// not on subsequent post back.
// During the first load (not a postback)
// we'll set the global javascript var
// isPostBack to false, and true for all
// postbacks after that
function LoadedPage()
{

debugger

if (!isPostBack)
{
var InitialXmlCtrl = document.getElementById('InitialXml');
if (EPi.GetDialog().dialogArguments)
InitialXmlCtrl.value = EPi.GetDialog().dialogArguments;
else
InitialXmlCtrl.value = '';
__doPostBack('', '');
}

}

// User hit Save button
function OnCloseAndSave(xmlInputCtrlId, displayTextCtrlId)
{
var xmlInputCtrl = document.getElementById(xmlInputCtrlId);
var displayTextCtrl = document.getElementById(displayTextCtrlId);
var returnValues = new Object();

returnValues.isOk = true;

returnValues.displayText = displayTextCtrl.value;
returnValues.xml = xmlInputCtrl.value;

EPi.GetDialog().Close(returnValues);
}

function OnCloseAndCancel()
{
var returnValues = new Object();

returnValues.isOk = false;

returnValues.displayText = '';
returnValues.xml = '';

EPi.GetDialog().Close(returnValues);
}

// Global Calls

// Add load event handler
addLoadEvent(LoadedPage);"
;

// Register Javascript
ClientScript.RegisterClientScriptInclude(this.GetType(), "EPiJS1", EPiServer.UriSupport.ResolveUrlFromUtilBySettings("javascript/episerverscriptmanager.js"));
ClientScript.RegisterClientScriptInclude(this.GetType(), "EPiJS2", EPiServer.UriSupport.ResolveUrlFromUIBySettings("javascript/system.js"));
ClientScript.RegisterClientScriptInclude(this.GetType(), "EPiJS3", EPiServer.UriSupport.ResolveUrlFromUIBySettings("javascript/system.aspx"));
ClientScript.RegisterStartupScript(this.GetType(), "DialogJS1", javaScript, true);

// Tell Javascript that this is a postback. We need
// this value to stop the client script forcing another postback.
if (IsPostBack)
ClientScript.RegisterStartupScript(this.GetType(), "IsPostBackScriptKey", "isPostBack = true;", true);

// The first time we get here, we need to get the xml string from the
// client parameter and to the server. We do this by assigning the xml to
// the hidden InitialXml input field, and forcing a postback.
// The InitialXml will only have a value the first time the
// page is posted back.
if (IsPostBack)
{

// See if we have a value for the InitialXml field
// This should only happen once
if (!String.IsNullOrEmpty(InitialXml.Value))
{

if (_dialogData == null)
{
// Load control with saved data
_dialogData = XElement.Parse(InitialXml.Value);
}

// Remove the saved data from the hidden field.
InitialXml.Value = "";

}

}

}

/// <summary>
/// Closes the dialog box
/// </summary>
protected void CloseAndCancel()
{

string scriptCaller = "OnCloseAndCancel();";

Page.ClientScript.RegisterStartupScript(
this.GetType(),
"CloseAndCancelScriptKey",
scriptCaller,
true);
}

/// <summary>
/// Closes and saves the dialog box by assigning the xml
/// to a hidden field, runs javascript to return it to the
/// caller, and then closes the window.
/// </summary>
protected void CloseAndSave(string displayText, XElement xmlData)
{

// Store values in form
ReturnValueXml.Value = PropertyDialogData.CreateDialogData(displayText, xmlData).ToString();
ReturnDisplayText.Value = displayText;

// Call script to return values to caller
string scriptCaller = "OnCloseAndSave('{0}', '{1}');";

Page.ClientScript.RegisterStartupScript(
this.GetType(),
"OnCloseAndSaveScriptKey",
string.Format(scriptCaller, ReturnValueXml.ClientID, ReturnDisplayText.ClientID),
true);

}

/// <summary>
/// Gets the previously saved data for this dialog
/// </summary>
protected XElement DialogData
{
get
{
return PropertyDialogData.GetDataFromDialogData(_dialogData, "Data");
}
}

}

}


Sample Dialog Page

Finally here is an example dialog page which derives from the base, and which implements a VERY simple editable key-value list.




... and here’s the code:


KeyValueListDialog.aspx




<%@ Page Language="C#" EnableViewState="true" AutoEventWireup="true" CodeBehind="KeyValueListDialog.aspx.cs" Inherits="EPiDave.TestBed.Admin.Dialogs.KeyValueListDialog" %>

<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN" >
<html>
<head runat="server">
<base target="_self">

<title></title>

<link rel="stylesheet" type="text/css" href="<%=ResolveUrl("~/App_Themes/Default/styles/system.css") %>">
<link rel="stylesheet" type="text/css" href="<%=ResolveUrl("~/App_Themes/Default/styles/ToolButton.css") %>">

</head>
<body>
<form id="Form1" enctype="multipart/form-data" method="post" runat="server">
<div>

Key 1: <asp:TextBox ID="Key1" runat="server" /> Value 1: <asp:TextBox ID="Value1" runat="server" /><br />
Key 2: <asp:TextBox ID="Key2" runat="server" /> Value 2: <asp:TextBox ID="Value2" runat="server" /><br />
Key 3: <asp:TextBox ID="Key3" runat="server" /> Value 3: <asp:TextBox ID="Value3" runat="server" /><br /><br />

<span class="epitoolbutton"><img src="/App_Themes/Default/Images/Tools/Save.gif" alt="Save" /><asp:Button ID="InsertButton" Text="Save and Close" OnClick="InsertButton_Click" runat="server" /></span>
<span class="epitoolbutton"><img src="/App_Themes/Default/Images/Tools/Cancel.gif" alt="Cancel" /><asp:Button ID="CancelButton" Text="Cancel" OnClick="CancelButton_Click" runat="server" /></span>

</div>
</form>
</body>
</html>


KeyValueListDialog.aspx.cs




using System;
using System.Xml.Linq;
using EPiDave.Admin;

namespace EPiDave.TestBed.Admin.Dialogs
{
public partial class KeyValueListDialog : Dialog
{

protected override void OnLoad(EventArgs e)
{

base.OnLoad(e);

if (DialogData != null)
{

// Get the element we inserted into the dialog data
XElement DataContainer = DialogData.Element("Data");

if (DataContainer != null)
{

// The Dialog data has been loaded, so display the values.
XElement element;

// Key 1
element = DataContainer.Element("Key1");
if (element != null)
{
Key1.Text = element.Attribute("Key").Value;
Value1.Text = element.Attribute("Value").Value;
}

// Key 2
element = DataContainer.Element("Key2");
if (element != null)
{
Key2.Text = element.Attribute("Key").Value;
Value2.Text = element.Attribute("Value").Value;
}

// Key 3
element = DataContainer.Element("Key3");
if (element != null)
{
Key3.Text = element.Attribute("Key").Value;
Value3.Text = element.Attribute("Value").Value;
}

}

}

}

/// <summary>
/// Handles the cancel button click event closing the
/// dialog box
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
protected void CancelButton_Click(object sender, EventArgs e)
{
CloseAndCancel();
}

/// <summary>
/// Handles the save button click event saving the selected
/// pages and closing the dialog box.
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
protected void InsertButton_Click(object sender, EventArgs e)
{

// Get the data and serialize it.
XElement data = new XElement("Data");

int numSaved = 0;

// Key 1
if ((!String.IsNullOrEmpty(Key1.Text)) && (!String.IsNullOrEmpty(Value1.Text)))
{
numSaved++;
data.Add(new XElement("Key1", new XAttribute("Key", Key1.Text), new XAttribute("Value", Value1.Text)));
}

// Key 2
if ((!String.IsNullOrEmpty(Key2.Text)) && (!String.IsNullOrEmpty(Value2.Text)))
{
numSaved++;
data.Add(new XElement("Key2", new XAttribute("Key", Key2.Text), new XAttribute("Value", Value2.Text)));
}

// Key 1
if ((!String.IsNullOrEmpty(Key3.Text)) && (!String.IsNullOrEmpty(Value3.Text)))
{
numSaved++;
data.Add(new XElement("Key3", new XAttribute("Key", Key3.Text), new XAttribute("Value", Value3.Text)));
}

string displayText = String.Format("{0} keys saved.", numSaved.ToString());

CloseAndSave(displayText, data);
}

}
}


Summary


To recap, if you use this framework, all you have to do to create a modal custom property control is the following:

Add the standard custom property to your page type.
Add the required app settings to web.config,
Create a dialog which inherits from the Dialog page base class and calls the ‘SaveAndClose’ method on the appropriate event.

Easy!

Monday, August 3, 2009

Storing business objects in EPiServer

I recently had a discussion with someone about how best to store collections in EPiServer, such as RSS feeds, advertisements, frequently asked questions, customer details etc. Here are my thoughts...

Using custom properties

If you wanted to display a collection of objects on a page, then usually you might create a custom property with a suitable interface so that editors can add to, and maintain the list. You could then register this property for a particular page type - so that each page of this page type can have its own list, or as a dynamic property - so that it is available as a shared list, and available across multiple page types.

This probably makes the most sense. However, the problem with this is that there is often a lot of development that needs to be done, e.g.:

  • Developing a suitable editor for the collection (which might need to allow sorting, grouping, searching, validation etc.)
  • Developing the web control that will render the list from the serialised data.
  • Adding the more advanced features that your business might need, such as workflows for approvals, scheduling the inclusion and deletion of items from the list, security and so on.

Even if you are sure that you know exactly what your business requires, there is still the chance for scope creep, and a future of upgrades and maintenance.

Using pages

One of the ways to make your life easier is to create your collections as EPiServer pages. This instantly gives you a huge amount of the functionality you might require to manage the items. You will have the ability to:
  • Easily define the properties of the items through their page type
  • Add workflows such as approvals
  • Set up access for different users and groups with different privileges
  • Use the many EPiServer page list controls that are available to render the list

And your editors will be able to:

  • Perform the usual C.R.U.D. operations
  • Organise items in a list or hierarchy, with grouping and sorting.
  • Search for items etc. etc.

In short, EPiServer is designed to manage a large collection, and this is what it does well! Of course, an item (in these sorts of collections) is a kind of content that does not directly correlate to a page on a website, so it might seem a bit foreign to re-use this functionality. However, it is a simple matter make sure that no-one can browse to an item (which now has its own URL) by adding some redirection or error trapping logic to the load event of the item’s page type ASPX, or through an event handler on the BeginRequest event. Actually it might be good to have the ability to display a page per item, if you wanted to give editors a preview how the item might look when displayed in a list! :)

There are some down sides though. EPiServer makes some assumptions about your items. It thinks they are pages of a site! This means adding a huge amount of overhead to them. The database stores a lot of information about pages in EPiServer that you might not want, and this, combined with the fact that the relational model isn’t going to be entirely optimised for your needs, will make data access slower than it needs to be. The API used to get page data back from the database is optimised in certain ways (such as retrieving the properties of the page), but this won’t do you any good if you have related objects in your list. You will often need several database reads on each page request. Caching may only get you so far as well.

Nowhere can potential performance issues been seen better, than in the page tree in the EPiServer edit mode. Imagine if you had thousands, or even millions of items in your collection. This interface is not designed to support lists of this scale. Even when in optimised mode, if your items do not lend themselves nicely to being stored in a hierarchy, and end up all under one parent node, then the interface will eventually become unusable, and you’ll need to write your own.

With a large collection of pages, you may also face performance issues on individual page requests, since the default EPiServer URL provider will have to search through all of your pages to find the page ID which corresponds to the requested URL.

If performance and scale are issues for you then the last resort is to start...

Using a custom data access layer

In the end, creating your own tables and stored procedures etc, may be the way to go, since you have so much extra control over the functionality, but generally I wouldn’t recommend it unless you really need to. EPiServer has so much functionality that can be re-used, it would seem silly to me not to try.

Summary

To summarize, using pages for your business objects is often a very good solution which is quick to implement and easily adopted by your editors. But what you need to bear in mind is that EPiServer is not a content management system in the literal sense. ‘Page management system’ would be more accurate, and if you’re thinking of using pages to manage your collections, you need to be sure that performance not be a problem.

It would be nice to see EPiServer change (or expand) their architecture in the future for enterprise applications in need of managing more than just pages. Of course, for specific markets, products and plugins are already available (EPiServer Community for instance).

Friday, July 31, 2009

EPiServer Enterprise Virtual Path Provider

This article demonstrates how to create a virtual path provider which can be used to serve site specific files, while still using the same application directory for your sites.

Introduction to EPiServer Enterprise

For those of you who have been able to play with a copy of EPiServer running an enterprise license, you can skip the next couple of paragraphs. For those who haven’t, here’s how it works:

Each site defined in IIS has the same home directory. When a page of one of the sites requested, EPiServer looks at the host header to see which set of pages (site) should be served. Multiple sites can be defined in the EPiServer section of web.config, and the host header(s) and start page for each site are configured.

When pages are created, EPiServer handles the fact that you might want to use the same URLs for pages under different sites. There are also many site settings which allow you to keep control over the way each site works, despite the fact they share the same code. Users, roles, access, workflow, categories, custom properties, etc, can all be administered from within a single interface, but you still have the ability to specify how these work over different sites. One of the main benefits of this architecture is that you can share resources, such as images, CSS, web controls, and page types over all your sites.

The Problem

The problem is, that there is no built in ability in EPiServer, for creating site specific resources for a given URL, or virtual path. Say for instance that you would like to use a common URL for two sites;

‘site1.com/logo.gif’, and
‘site2.com/logo.gif’.

Because these sites share the same application directory, this is impossible, without both sites having the same logo. You could separate all your site specific files into different sub directories, but this would give you URLs like these;

‘site1.com/site1-files/logo.gif’ and
‘site2.com/site2-files/logo.gif’.

This works fine but it’s not very elegant to look at. More importantly than how it looks though, are the implications for using these URLs in your application, and for virtual paths used on the server. Any ASP.NET page or control which wants to use a particular image, would have to be implemented once for each site, or, it would have to get the logo location from somewhere that can change per site, such as page properties, or some other custom site-aware system.

Now consider that you might want something like a page type to be common across multiple sites (because they work in exactly the same way and use the same controls) but you want the layout and the style of the page type to be different on each site. You should be able to specify different master pages for each site, and maybe different css files, without having to create more than one ASPX, and define more than one page type in EPiServer. Unfortunately, if you’re using a common ASPX for your page type, it will have to use the same resources. This is also true of things like web controls.

The Solution

One solution is to create a virtual path provider (VPP) which serves different files depending on which site is being requested. The logic is simple. When a request for a file is received, we check to see if there is a site-specific version of that file available. If there is, then we serve that file. If there isn’t, then we don’t serve anything from the VPP, instead allowing the file to be served by ASP.NET from the main application directory (the shared resources).

Here's the code for the virtual path provider and related classes:


using System;
using System.Collections;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.IO;
using System.Web;
using System.Web.Caching;
using System.Web.Hosting;

namespace EPiDave.Web.Hosting
{

/// <summary>
/// Virtual Path Provider designed to expose a file system for a
/// particular directory which contains files specific to a
/// particular website.
/// </summary>
/// <remarks>
/// This class cannot be used with the EPiServer file manager UI.
/// </remarks>

public class SiteSpecificPathProvider : VirtualPathProvider
{

private string _baseVirtualPath;

/// <summary>
/// Constructor - Loads the virtualPath property from supplied config settings
/// as the base virtual path to be used for the lifetime of the class instance.
/// </summary>
/// <param name="str">Name of the VPP (unused but required for construction by EPiServer)</param>
/// <param name="nvc">Collection of config properties for the VPP (from web.config)</param>

public SiteSpecificPathProvider(string name, NameValueCollection configProperties)
: base()
{

string virtualPath = configProperties["VirtualPath"];

if (String.IsNullOrEmpty(virtualPath))
_baseVirtualPath = "/";
else
_baseVirtualPath = VirtualPathUtility.AppendTrailingSlash(VirtualPathUtility.ToAbsolute(virtualPath));

}

/// <summary>
/// IsVirtual tests to see if the virtual path provided is one this VPP can serve
/// based on the loaded base virtual path.
/// </summary>
/// <param name="virtualPath">The virtual path requested</param>
/// <returns>True or false</returns>
/// <example>
/// Should return true if supplied path is under
/// "/base/" or is "/base" directory itself.
/// </example>

private bool IsVirtual(string virtualPath)
{

if (virtualPath.StartsWith(_baseVirtualPath, StringComparison.InvariantCultureIgnoreCase) ||
virtualPath.Equals(VirtualPathUtility.RemoveTrailingSlash(_baseVirtualPath), StringComparison.InvariantCultureIgnoreCase))
return true;
else
return false;

}

/// <summary>
/// ConvertVirtualToReal converts a virtual path to a real file system path
/// </summary>
/// <param name="virtualPath">The virtual path</param>
/// <param name="SiteResourceMapping">The mapped directory</param>
/// <returns>Server file path</returns>
/// <example>
/// Converting "~/Resources/logo.gif" with a directory mapping of "~/SiteResources/Site1/"
/// will result in "C:\ApplicationPath\SiteResources\Site1\Resources\logo.gif"
/// </example>

internal string ConvertVirtualToReal(string virtualPath)
{

string hostName = HttpContext.Current.Request.ServerVariables["HTTP_HOST"];

string path = "SiteSpecific/" + hostName + VirtualPathUtility.ToAbsolute(virtualPath);
path = HttpContext.Current.Server.MapPath(path);

return path;

}

internal bool FileAvailable(string virtualPath)
{
if (IsVirtual(virtualPath))
{
if (File.Exists(ConvertVirtualToReal(virtualPath)))
return true;
}
return false;
}

internal bool DirectoryAvailable(string virtualPath)
{
if (IsVirtual(virtualPath))
{
if (Directory.Exists(ConvertVirtualToReal(virtualPath)))
return true;
}
return false;
}

/// <summary>
/// FileExists tests to see whether or not a file exists at
/// a particular virtual path.
/// </summary>
/// <param name="virtualPath">The virtual path of the file</param>
/// <returns>True / false</returns>

public override bool FileExists(string virtualPath)
{

// If the file exists return true,
// else pass to the next provider in the list.
if (FileAvailable(virtualPath))
return true;

return Previous.FileExists(virtualPath);

}

/// <summary>
/// DirectoryExists tests to see whether or not a directory exists at
/// a particular virtual path.
/// </summary>
/// <param name="virtualDir">The virtual path of the directory</param>
/// <returns>True / false</returns>

public override bool DirectoryExists(string virtualDir)
{

// If the directory exists return true,
// else pass to the next provider in the list.
if (DirectoryAvailable(virtualDir))
return true;

return Previous.DirectoryExists(virtualDir);

}

/// <summary>
/// GetFile gets the specified file as SiteResourceFile object.
/// </summary>
/// <param name="virtualPath">The virtual path of the file</param>
/// <returns>SiteResourceFile</returns>

public override VirtualFile GetFile(string virtualPath)
{

// If the file exists return it,
// else pass to the next provider in the list.
if (FileAvailable(virtualPath))
return new SiteResourceFile(virtualPath, this);

return Previous.GetFile(virtualPath);

}

/// <summary>
/// GetDirectory gets the specified directory as VirtualDirectory object.
/// </summary>
/// <param name="virtualDir">The virtual path of the directory</param>
/// <returns>SiteResourceDirectory</returns>

public override VirtualDirectory GetDirectory(string virtualDir)
{

// If the directory exists return it,
// else pass to the next provider in the list.
if (DirectoryAvailable(virtualDir))
return new SiteResourceDirectory(virtualDir, this);

return Previous.GetDirectory(virtualDir);

}

/// <summary>
/// GetFileHash provides a hash (including version) of the path that should be used now.
/// If this hash is different to that in memory, the cache will be invalidated.
/// </summary>
/// <remarks>
/// The reason we do not use a cache dependency is:
/// a) There is no clear event that can be used to fire the notification of invalidation.
/// b) The site specific directory needs to be checked despite a core cache that may still be valid.
/// </remarks>
/// <param name="virtualPath">The virtual path of the file or directory</param>
/// <param name="virtualPathDependencies">The virtual path dependencies already known</param>
/// <returns>File or directory hash</returns>

public override string GetFileHash(string virtualPath, IEnumerable virtualPathDependencies)
{
if (IsVirtual(virtualPath))
{
string timestamp;
string realpath;

// Try to find a file by that name
if (FileExists(virtualPath))
{
// If we find a file, return the hash of the latest version.
realpath = ConvertVirtualToReal(virtualPath);
timestamp = File.GetLastWriteTimeUtc(realpath).ToString();
return realpath + timestamp;
}

// Finding file has failed. Try to find a directory by that name
if (DirectoryExists(virtualPath))
{
// If we find a directory, return the hash of the latest version.
realpath = ConvertVirtualToReal(virtualPath);
timestamp = Directory.GetLastWriteTimeUtc(realpath).ToString();
return realpath + timestamp;
}

// No file or directory exists, so return null.
return null;
}
return Previous.GetFileHash(virtualPath, virtualPathDependencies);
}

/// <summary>
/// GetCacheDependency returns null for paths within our virtual path,
/// since we're using the GetFileHash function for invalidating our cache.
/// </summary>
/// <param name="virtualPath">The virtual path</param>
/// <param name="virtualPathDependencies">The virtual path dependencies already known</param>
/// <param name="utcStart">The date and time</param>
/// <returns>null</returns>

public override CacheDependency GetCacheDependency(string virtualPath, IEnumerable virtualPathDependencies, DateTime utcStart)
{
if (IsVirtual(virtualPath))
return null;

return Previous.GetCacheDependency(virtualPath, virtualPathDependencies, utcStart);
}

}

/// <summary>
/// SiteResourceDirectory - Class representing a virtual directory
/// </summary>

public class SiteResourceDirectory : VirtualDirectory
{

string _virtualPath;
SiteSpecificPathProvider _provider;

/// <summary>
/// Constructor
/// </summary>
/// <param name="virtualPath">The virtual path requested</param>
/// <param name="provider">The SiteSpecificPathProvider</param>

public SiteResourceDirectory(string virtualPath, SiteSpecificPathProvider provider)
: base(virtualPath)
{
_virtualPath = virtualPath;
_provider = provider;
}

/// <summary>
/// Children property - Returns the complete list
/// of files and sub directories within this directory
/// </summary>

public override IEnumerable Children
{
get
{
List<object> children = new List<object>();
foreach (object dir in Directories)
{
children.Add(dir);
}
foreach (object file in Files)
{
children.Add(file);
}
return children;
}
}

/// <summary>
/// Directories property - Returns a list of sub
/// directories within this directory
/// </summary>

public override IEnumerable Directories
{
get
{
// Create a new list of directories
IList<SiteResourceDirectory> directories = new List<SiteResourceDirectory>();

// Get REAL path from virtual path
string realDir = _provider.ConvertVirtualToReal(_virtualPath);

// If current directory exists, get sub directories
if (realDir != null)
{

// Find directories in this directory
string[] subDirectoryEntries = Directory.GetDirectories(realDir);
foreach (string subDirectory in subDirectoryEntries)
{
string s = subDirectory.Replace(HttpRuntime.AppDomainAppPath, "~/").Replace(Path.DirectorySeparatorChar, '/');
directories.Add(new SiteResourceDirectory(VirtualPathUtility.AppendTrailingSlash(VirtualPathUtility.Combine(_virtualPath, s)), _provider));
}

}

return directories;
}
}

/// <summary>
/// Files property - Returns a list of files
/// within this directory
/// </summary>

public override IEnumerable Files
{
get
{
// Create a new list of files
IList<SiteResourceFile> files = new List<SiteResourceFile>();

// Get REAL path from virtual path
string realDir = _provider.ConvertVirtualToReal(_virtualPath);

// If current directory exists, get files
if (Directory.Exists(realDir))
{

// Find files in this directory
string[] fileEntries = Directory.GetFiles(realDir);
foreach (string file in fileEntries)
{
string f = file.Replace(HttpRuntime.AppDomainAppPath, "~/").Replace(Path.DirectorySeparatorChar, '/');
files.Add(new SiteResourceFile(VirtualPathUtility.Combine(VirtualPathUtility.AppendTrailingSlash(_virtualPath), f), _provider));
}

}

return files;
}
}

}

/// <summary>
/// SiteResourceFile - Class representing a virtual file
/// </summary>

public class SiteResourceFile : VirtualFile
{

string _virtualPath;
SiteSpecificPathProvider _provider;

/// <summary>
/// Constructor
/// </summary>
/// <param name="virtualPath">The virtual path of the file</param>
/// <param name="provider">The SiteSpecificPathProvider</param>

public SiteResourceFile(string virtualPath, SiteSpecificPathProvider provider)
: base(virtualPath)
{
_virtualPath = virtualPath;
_provider = provider;
}

/// <summary>
/// Exposes a stream to the file in our virtual file system
/// </summary>
/// <returns>Stream</returns>

public override Stream Open()
{

string realPath = _provider.ConvertVirtualToReal(_virtualPath);

// If the file exists put the content on the stream.
if (File.Exists(realPath))
{
return new FileStream(realPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
}
return null;

}

}

}
EPiServer kindly gives us the ability to register this virtual path provider in the web.config.




<add name="SiteSpecificFiles" virtualpath="~/Resources/" type="EPiDave.Web.Hosting.SiteSpecificPathProvider, EPiDave" />


Of course, there are other solutions to this problem, but I find this one is very easy to work with. With images and other binary files, it means you don't need any special http handlers. With ASPXs and ASCXs etc, it means that you can have clean markup. You don't have to implement some site specific logic behind each part of your page or control. All you need to do, is create a different resource, and put it in the correct directory on the server.