Fabio's blog

marketing | technology

Using EPiServer dynamic content to create a widget manager

leave a comment »

Dynamic content, a feature introduced in EPiServer 5, is very useful as it allows CMS users to effectively inject your code in the WYSIWYG editor. Unfortunately, there is no way of using this feature in a more controlled manner… unless you fancy rolling out your custom property to do that.

What I wanted to achieve was to use dynamic content plug-ins like WordPress widgets or Umbraco macros; for example, when you have an area in your template where CMS users can add/edit/delete/sort dynamic content plug-ins, each one with its property setters. So after taking a look at the way the dynamic content dialogue works, I thought this would be easy to do with a simple custom property and some javascript / jquery.

The custom property

A simple override of the PropertyXhtmlString, using an .ascx control for the editing experience.

  [Serializable]
  [PageDefinitionTypePlugIn(DisplayName = "Features catalogue")]
  public class PropertyWidgetContainer : PropertyXhtmlString
  {
    public override IPropertyControl CreatePropertyControl()
    {
      return (IPropertyControl)BuildManager.CreateInstanceFromVirtualPath("~/templates/controls/features/widgetcontainer.ascx", typeof(WidgetContainer));
    }
  }

The editing control (code behind)

This is the default code which will render the property to the editor.

  public partial class WidgetContainer : UserControlBase, IPropertyControl
  {
    public WidgetContainer()
    {
      Enabled = true;
    }

    public void SetupControl()
    {
      if (RenderType == RenderType.Edit)
      {
        EpiContext = "{ \"id\": \"" + CurrentPage.PageLink.ID + "_" + CurrentPage.PageLink.WorkID + "\", \"parentId\": \"" + CurrentPage.ParentLink.ID + "\", \"pageFolderId\": \"" + CurrentPage.Property["PageFolderID"].Value + "\", \"epslanguage\": \"" + CurrentPage.LanguageBranch + "\" }";
        dataContainer.Text = (string)PropertyData.Value;
      }
    }

    public void ApplyChanges()
    {
      PropertyData.Value = dataContainer.Text;
    }

    public bool DisplayEditUI
    {
      get { return PropertyData.DisplayEditUI; }
    }

    public PropertyData PropertyData { get; set; }
    public PropertyDataCollection Properties { get; set; }
    public RenderType RenderType { get; set; }

    public TableRowLayout RowLayout
    {
      get { return TableRowLayout.Default; }
    }

    public string ValidationGroup { get; set; }
    public bool Enabled { get; set; }
    public string EpiContext { get; set; }
  }

The editing control

So far so good, a quick test of the basics is to have a textarea to input the source code of any dynamic content…

<asp:textbox id="dataContainer" textmode="MultiLine" runat="server" />

So I can run this, enter something like the html source generated by a dynamic content plug-in and save…

<span classid="b30218a7-77fc-43dd-a844-81935aa9b35e" dynamicclass="Simple text" state="PHA+SnVzdCBzb21lIHRleHQgb24gdGhlIHJpZ2h0PC9wPg==|" disabled="disabled" contentEditable="false">{DynamicContent:Simple text}</span>

Now, if I can find a way of translating what’s in this textarea to a friendly interface, we’re basically there. JQuery to the rescue…

<asp:textbox id="dataContainer" textmode="MultiLine" runat="server" />
<div class="epi-buttonDefault">
  <span class="epi-cmsButton"><input class="epi-cmsButton-text epi-cmsButton-tools epi-cmsButton-Add" id="addplugin" type="button" /></span>
</div>
<table class="epi-default" id="tableContainer">
	<thead>
		<tr class="header">
			<th>Module</th>
			<th>Actions</th>
		</tr>
	</thead>
	<tbody>	
	</tbody>
</table>
<script type="text/javascript" src="/js/lib.min.js"></script>
<script type="text/javascript">
  var tpl = '<tr class="pluginRow">' +
                    '<td class="plugin">%PLUGIN%</td>' +
                    '<td><span class="epi-cmsButton"><input class="epi-cmsButton-tools epi-cmsButton-Delete" type="button"></span>' +
                    '<span class="epi-cmsButton"><input class="epi-cmsButton-tools epi-cmsButton-Edit" type="button"></span></td>' +
                '</tr>';

  var context = <%= EpiContext %>, data, table;
  jQuery(function () {
    var widgetMgr = new widgets.manager($('#<%= dataContainer.ClientID %>'), $('#<%= tableContainer.ClientID %>'), tpl);
    if (widgetMgr.data.val() != '') {
      var el = $('<div />').append(widgetMgr.data.val());
      $('.dynamiccontent', el).each(function() {
        widgetMgr.addItem({ plugin: $('<div />').append($(this)).html() });
      });
    }
    $('#<%= addplugin.ClientID %>').click(function (evt) {
      var args = { plugin: '', index: -1, instance: widgetMgr };
      EPi.CreateDialog(EPi.ResolveUrlFromUI('Editor/Dialogs/DynamicContent.aspx') + '?' +  $.param(context), widgetMgr.onDialogClosed, args, null, { width: 470, height: 380 });
      evt.preventDefault();
    });
  });
</script>

The widgetManager javascript class takes care of doing most of the hard work…

/**************************************************
* Widget manager for EPiServer, requires JQuery and the TableDnD plug-in for JQuery (for sorting)
***************************************************/
	widgets = {};
	widgets.manager = function (d, t, k) {
	  this.init(d, t, k);
	};
	widgets.manager.prototype = {
	  constructor: widgets.manager,
	  init: function (d, t, k) {
	    this.data = d.hide();
	    this.table = t.hide();
	    this.tpl = k;
	  },
	  data: null,
	  table: null,
	  tpl: '',
	  setItems: function () {
	    this.data.empty();
	    var el = $('<div />');
	    $('.plugin', this.table).each(function () {
	      el.append($(this).html());
	    });
	    this.data.val(el.html());
	    if (this.data.val() === '') {
	      this.table.hide();
	    } else {
	      this.table.show();
	      this.bindTableEvents();
	    }
	  },
	  addItem: function (arguments) {
	    $('tbody', this.table).append($(this.tpl.replace('%PLUGIN%', arguments.plugin)));
	    this.setItems();
	  },
	  editItem: function (arguments) {
	    var tblrows = $('.pluginRow', this.table);
	    $(tblrows[arguments.index]).html($(this.tpl.replace('%PLUGIN%', arguments.plugin)).html());
	    this.setItems();
	  },
	  bindTableEvents: function () {
	    var instance = this;
	    var tblrows = $('.pluginRow', instance.table);
	    $('.epi-cmsButton-Delete', tblrows).unbind('click');
	    $('.epi-cmsButton-Delete', tblrows).click(function (evt) {
	      if (confirm("Are you sure?")) {
	        var tr = $(this).parents("tr:first");
	        tr.remove();
	        instance.setItems();
	      }
	      evt.preventDefault();
	    });
	    $('.epi-cmsButton-Edit', tblrows).unbind('click');
	    $('.epi-cmsButton-Edit', tblrows).click(function (evt) {
	      var tr = $(this).parents("tr:first");
	      var selectedNode = $('.dynamiccontent', tr);
	      var formString = '<html xmlns="http://www.w3.org/1999/xhtml"><head><title></title></head><body onload="document.forms[0].submit()">' +
                    '<form action="' + EPi.ResolveUrlFromUI('Editor/Dialogs/DynamicContent.aspx') + '?' + $.param(context) + '" method="post">' +
                    '<input name="state" type="hidden" value="' + selectedNode.attr("state").replace(/"/g, '&quot;') + '" />' +
                    '<input name="hash" type="hidden" value="' + selectedNode.attr("hash").replace(/"/g, '&quot;') + '" />' +
                    '<input name="dynamicclass" type="hidden" value="' + selectedNode.attr("dynamicclass").replace(/"/g, '&quot;') + '" />' +
                    '</form></body></html>';
	      var args = { plugin: $('td:first', tr).html(), index: tblrows.index(tr), instance: instance };
	      var dialog = EPi.CreateDialog(EPi.ResolveUrlFromUI('Editor/Dialogs/DynamicContent.aspx') + '?' + $.param(context), instance.onDialogClosed, args, null, { width: 470, height: 380 });
	      dialog._dialog.document.write(formString);
	      dialog._dialog.document.close();
	      evt.preventDefault();
	    });
	    instance.table.tableDnD({
	      onDrop: function (table, row) {
	        instance.setItems();
	      }
	    });
	  },
	  onDialogClosed: function (returnObject, onCompleteArguments) {
	    if (!returnObject) {
	      return;
	    }
	    if (onCompleteArguments.index === -1) {
	      onCompleteArguments.plugin = returnObject;
	      onCompleteArguments.instance.addItem(onCompleteArguments);
	    } else {
	      onCompleteArguments.plugin = returnObject;
	      onCompleteArguments.instance.editItem(onCompleteArguments);
	    }
	  }
	};

Excellent, now I can add widgets…

… sort them with drag’n’drop, delete them, change their settings…

… and so on.

When I view the saved version, the dynamic content plugins render as I expect them to do.

Limitations

The dynamic content factory in the current EPiServer version does not allow you to provide an alternative implementation – one, for instance, where you can organise your dynamic content by user role or area, so limiting which plug-ins can be used by who or where.

Other stuff

I’ve been using the labs DCPlugin to make creation of plugins easier (http://labs.episerver.com/en/Blogs/Allan/Dates/2009/2/Turn-your-User-Controls-into-Dynamic-Content/) – I’ve recompiled it to address a Unicode character bug.

Written by Fabio

December 17, 2010 at 3:50 pm

Posted in .NET, EPiServer

EPiServer and MVC

with 10 comments

Thanks to the great work of Joel Abrahamsson (here his first stab at the solution) and a little more prototyping, it looks like EPiServer and MVC can play together very well! Time to say goodbye to asp.net webforms templates on EPiServer?

At Syzygy, we got this working with EPiServer 5.2.375.236 (the latest production version), MVC 1.0 and recompiled versions of the PageTypeBuilder and Castle DynamicProxy2 assemblies (the reason for this was to solve trust exceptions when the code was running from IIS 7.5).

The trick to get the EPiServer Friendly URL rewriter to play nicely with MVC is to use custom MvcHandler and IControllerFactory implementations: a wild-card style route can be passed to the custom handler which then will be able to:

  • find out from EPiServer the ID of the content item (from the friendly URL)
  • load the page data
  • read custom properties of the page data such as “ControllerName” and “ActionName”
  • forward the page data to the correct controller / action through our controller factory

So, in our prototype, we implemented the custom MVC handler with the following code:

		protected override void ProcessRequest(HttpContextBase httpContext)
		{
			var internalUrlBuilder = GetInternalUrl(httpContext.Request.RawUrl);
			var mvcPage = CurrentPageResolver.Instance.GetCurrentPage(internalUrlBuilder.QueryCollection["id"] ?? string.Empty);
			var controllerName = mvcPage.ControllerName;
			var actionName = mvcPage.ActionName;

			RequestContext.RouteData.Values.Add("action", actionName);
			RequestContext.RouteData.Values.Add("controller", controllerName);

			RequestContext.RouteData.Values["data"] = mvcPage;

			var controller = ControllerBuilder.Current.GetControllerFactory().CreateController(RequestContext, controllerName);
			controller.Execute(RequestContext);
		}

This code will intercept the request, convert the raw URL (as generated by the EPiServer Friendly URL rewriter) into the internal EPiServer URL, which will give us the ID of the content item (or, when in edit mode, the ID followed by an underscore and the version ID – i.e. “the slug”).

At this point, we can use any custom class to call the EPiServer DataFactory and get the PageData object. In our custom CurrentPageResolver class, the page data is cast to the base class for all our page types (MvcPageData), which contains information about the controller and the action to use for rendering.

using System;
using System.Collections.Generic;
using EPiServer;
using EPiServer.Core;
using MvcCms.Bases;

namespace MvcCms.Utilities
{
    public class CurrentPageResolver
    {
        private static readonly CurrentPageResolver instance = new CurrentPageResolver();

        private CurrentPageResolver()
        {
        }

        public static CurrentPageResolver Instance
        {
            get { return instance; }
        }

        public MvcPageData GetCurrentPage(string id)
        {
            if (String.IsNullOrEmpty(id))
            {
                return GetHomePage();
            }

            return IsWorkPage(id) ?
                           GetWorkPage(id) : GetPage(id);
        }

        private static bool IsWorkPage(string id)
        {
            return id.Contains("_");
        }

        private static MvcPageData GetWorkPage(string slug)
        {
            PageData page = null;

            string[] splitSlug = slug.Split(new[] {'_'}, StringSplitOptions.RemoveEmptyEntries);

            int pageId;
            if (splitSlug.Length > 1 && int.TryParse(splitSlug[0], out pageId))
            {
                int workPageId;
                if (int.TryParse(splitSlug[1], out workPageId))
                {
                    var workPageReference = new PageReference(pageId, workPageId);
                    page = DataFactory.Instance.GetPage(workPageReference);
                }
            }

            return page as MvcPageData;
        }

        protected MvcPageData GetPage(string id)
        {
            int pageId;
            if (int.TryParse(id, out pageId))
            {
                var pageReference = new PageReference(pageId);
                return DataFactory.Instance.GetPage(pageReference) as MvcPageData;
            }

            return null;
        }

        public static MvcPageData GetHomePage()
        {
            return DataFactory.Instance.GetPage(PageReference.StartPage) as MvcPageData;
        }

        public List<MvcPageData> GetChildren()
        {
            return GetChildren(GetHomePage().PageLink);
        }

        public List<MvcPageData> GetChildren(PageReference parent)
        {
            PageDataCollection pages = DataFactory.Instance.GetChildren(parent);
            var list = new List<MvcPageData>(pages.Count);
            foreach (PageData page in pages)
            {
                list.Add(page as MvcPageData);
            }
            return list;
        }
    }
}

Our page type base (MvcPageData) class looks like this:

using System.Collections.Generic;
using MvcCms.Utilities;
using PageTypeBuilder;

namespace MvcCms.Bases
{
    public abstract class MvcPageData : TypedPageData
    {
        public abstract string ControllerName { get; }
        public virtual string ActionName
        {
            get
            {
                return "Index";
            }
        }

        public string PageUrlSegment
        {
            get { return this.GetPropertyValue(page => page.PageUrlSegment); }
        }

        public virtual List<MvcPageData> Children
        {
            get
            {
                if (PageLink == StartPage.PageLink)
                {
                    return new List<MvcPageData>(0);
                }

                return CurrentPageResolver.Instance.GetChildren(PageLink);
            }
        }

        public virtual List<MvcPageData> SiteLinks
        {
            get { return CurrentPageResolver.Instance.GetChildren(StartPage.PageLink); }
        }

        public MvcPageData StartPage
        {
            get { return CurrentPageResolver.GetHomePage(); }
        }
    }
}

The custom controller factory will use the information that is contained in the route data to create the correct controller from the available ones. Once the controller is created, this will again be able to use the MvcPageData available in the route data (through a custom model binder), and pass it to a view.

The last step would be to wire up all the work in the global.asax, ignoring the requests to edit, admin, util and app_themes URLs.

		protected void Application_Start(Object sender, EventArgs e)
		{
			ControllerBuilder.Current.SetControllerFactory(new MvcControllerFactory());
			ModelBinders.Binders.Add(typeof (MvcPageData), new PageDataModelBinder());

			RegisterRoutes(RouteTable.Routes);
		}

		public static void RegisterRoutes(RouteCollection routes)
		{
			routes.IgnoreRoute("{resource}.axd/{*pathInfo}");
			routes.IgnoreRoute("cms/{*pathInfo}");
			routes.IgnoreRoute("util/{*pathInfo}");
			routes.IgnoreRoute("app_themes/{*pathInfo}");

			routes.Add(new Route("{*data}", new WildCardRouteHandler()));
		}

In order to get the solution working, you’ll need to follow the instructions in the readme.txt file. Any problems, contact me.

Enjoy!

Fabio

Written by Fabio

October 10, 2009 at 1:21 pm

Posted in .NET, EPiServer, MVC

Follow

Get every new post delivered to your Inbox.