Monday, August 24, 2009

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!

1 comment:

axtelius said...

Work's great, just two minor things.
1: There is a "debugger" left in the JS in the Dialog class which messes things up.

2: In the class PropertyDialogData, the callback JS, inside if (returnValue.isOk == true) I added a EPi.PageLeaveCheck.SetPageChanged(true) so you can't navigate away from the page without being notified.

/Fredrik

Post a Comment