Currently I'm working on a really fun and challenging project at work that involves EPiServer 7 and Backbone(with RequireJs and Handlebars).
Our client is a very large company, they have an API containing information about their clients. We are using their API to display their data in a...nice way. Their API is kind of slow so we will need to do all the API request with AJAX.
The design looks like this:
The idea is that all blocks should load data independently and not block the page rendering, so each block is doing an Ajax call to the API.
One big challenge I saw at the beginning of the project was; how should I pass all EPiServer properties down to the Handlebars view? Normally, I would just use a standard MVC Controller and pass the model to the Razor View and use the Model.PropertyName syntax, but it will not work in this case because we let Backbone and Handlebars do all our frontend work.
Luckily for us, it turns out that Backbones initialize function takes an options parameter like this:
initialize: function(options) {
this.options = options;
},
The "algorithm" we came up with is pretty simple:
- Contentarea containing all of our blocks
- Put an attribute on all properties that we want to pass to Backbone
- Write out a script tag on the page containing our "serialized" blocks
- Loop through the script tag and pass the options to the initialize function
So, by looking at the image of the design above, you see the Heading and Subheading on the Addons block? Those are the two properties we will set in the Episerver CMS and pass along all the way down to the Handlebars template, let's go!
EPiServer Part
Note, I've renamed a few things(names, classes etc) to not give away to much information about our client so there might be some syntax erros etc.
MyBasePage.cs
Every page that we create inherits from MyBasePage
The base page contains a Contentarea with a MyBlockContainer attribute, the attribute is used when we are fetching the contentarea using reflection in the MyTestViewModel file. The attribute is only needed if we ever want to have more than one Contentarea per page...it's not likely but it's better to be safe than sorry :)
[ContentType(
GUID = "9F682987-3E79-4265-BD02-F5DB6BEEF6B3",
DisplayName = "My page",
Description = "Standardpage with a contentarea",
GroupName = Constants.ContentTypeGroupNames.MyBasePage
)]
[SiteImageUrl]
public class MyBasePage : SitePageData
{
[Display(
GroupName = SystemTabNames.Content,
Order = 1010)]
[CultureSpecific]
[MyBlockContainerAttribute]
public virtual ContentArea MainContentArea { get; set; }
}
MyAddonsPage.cs
Standard page containing just a PageReference property
[ContentType(
DisplayName = "Addons",
GUID = "a2d1b517-b5af-4a94-84c0-3fc4b7adb632",
Description = "Page displaying the installed and available addons",
GroupName = Constants.ContentTypeGroupNames.MyAddons)]
public class MyAddonsPage : MyBasePage
{
[Display(
Name = "Go back link",
Description = "Page the user gets redirected to when clicking go back",
Order = 100)]
[MyConfig(JsonKey = "goBackLink")]
public virtual PageReference GoBackLink { get; set; }
}
MyAddonsBlock.cs
Our block, the important things to notice here are:
- The MyModuleAttribute on the class. Here we are setting the Path(used by Backbone). It's also used in the MyPageViewModel for identifying the blocks.
- The MyConfigAttribute on the properties. Used to get only the properties we want when using reflection in the MyPageViewModel. The JsonKey is the name/key the object will get when it gets serialized.
- The inheritance from MyWidgetBaseBlock. It's also used in the MyPageViewModel when using reflection. It's an empty class just inheriting from BlockData.
[ContentType(
DisplayName = "Installed addons list", GUID = "c844a285-7d2c-48e9-b5c3-45cc68a4ba88",
Description = "Block showing installed addons for the subscription",
GroupName = Constants.ContentTypeGroupNames.MyAddons)]
[MyModule(Path = "Addons")]
public class MyAddonsInstalledBlock: MyWidgetBaseBlock
{
[Display(GroupName = Constants.PropertyGroupNames.Content]
[CultureSpecific]
[MyConfig(JsonKey = "heading")]
public virtual string Heading {get;set;}
[Display(GroupName = Constants.PropertyGroupNames.Content]
[CultureSpecific]
[MyConfig(JsonKey = "subheading")]
public virtual string SubHeading {get;set;}
}
MyAddonsPageController.cs
Our pagecontroller, creates the ViewModel and sets some data...
public class MyAddonsPageController : PageControllerBase<MyAddonsPage>
{
public ActionResult Index(MyAddonsPage currentPage, string subscriptionId = "")
{
TempData["subscriptionId"] = "1337";
TempData["name"] = "JOSEF";
var model = MyPageViewModel.Create(currentPage);
return View("~/Views/Pages/MyAddonsPage/MyAddonsPage.cshtml", model);
}
}
ModuleViewData
Used in the MyPageViewModel
public class ModuleViewData
{
public string Path{get; set;}
public Dictionary<string, object> Options {get;set;}
}
MyPageViewModel.cs
This is where all the magic is happening :)
Basic ViewModel containing a CurrentPage property and a reference to a ConfigurationPage.
The GetPageProperties uses Reflection to get all Properties on the page that has the MyConfigAttribute.
GetModules searches through the contentarea and selects all blocks that has the MyModuleAttribute. It then gets the Path and selects all properties with the MyConfigAttribute.
public class MyPageViewModel<T> : IPageViewModel<T> where T : MyBasePage
{
public T CurrentPage { get; private set; }
public SiteConfigurationPage SiteConfiguration { get; private set; }
public MyPageViewModel(T currentPage)
{
CurrentPage = currentPage;
CustomerType = CustomerContext.GetCustomerType(currentPage);
}
public ModuleViewData GetPageProperties
{
get
{
var page = CurrentPage;
var properties = page.GetType()
.GetProperties()
.Where(isMyConfigProperty);
var pageOptions = properties.ToDictionary(
property => GetJsonKey(property),
property => GetJsonValue(property, page));
var urlResolver = ServiceLocator.Current
.GetInstance<UrlResolver>();
var pageUrl = urlResolver.GetUrl(page.ContentLink);
var viewData = new ModuleViewData
{
Path = pageUrl,
Options = pageOptions
};
return viewData;
}
}
/// <summary>
/// Searches through the contentarea, selects all blocks with the MyModuleAttribute
/// Gets the Path from the block attribute
/// Selects all properties that has the MyConfigAttribute and adds them to options.
/// </summary>
public List<ModuleViewData> GetModules
{
get
{
var page = CurrentPage;
if (page == null) return new List<ModuleViewData>();
var contentAreas = page.GetType()
.GetProperties()
.Where(p => p.PropertyType == typeof(ContentArea));
ContentArea contentArea = GetContentArea(contentAreas, page);
//Sooo...Episerver returns null if the contentarea is empty...
if (contentArea == null || contentArea.Items == null)
{
return new List<ModuleViewData>();
}
var contentAreaItems = contentArea.Items;
var modulesList = GetBlocks(contentAreaItems);
return modulesList;
}
}
private static List<ModuleViewData> GetBlocks(IEnumerable<ContentAreaItem> contentAreaItems)
{
var contentLoader = ServiceLocator.Current
.GetInstance<IContentLoader>();
var modulesList = new List<ModuleViewData>();
foreach (var item in contentAreaItems)
{
BlockData block;
try
{
block = contentLoader.Get<BlockData>(item.ContentLink);
}
catch (Exception)
{
continue;
}
var moduleAttribute = (MyModuleAttribute)
Attribute.GetCustomAttribute(
block.GetType(),typeof(MyModuleAttribute));
if (moduleAttribute == null) continue;
var myConfigProperties = block.GetType()
.GetProperties()
.Where(isMyConfigProperty);
var configProperties = myConfigProperties as IList<PropertyInfo> ?? myConfigProperties.ToList();
var blockOptions = configProperties.ToDictionary(property => GetJsonKey(property),
property => GetJsonValue(property, block));
var viewData = new ModuleViewData
{
Path = moduleAttribute.Path,
Options = blockOptions
};
modulesList.Add(viewData);
}
return modulesList;
}
private static ContentArea GetContentArea(IEnumerable<PropertyInfo> contentAreas, T page)
{
foreach (var ca in contentAreas)
{
var hasAttribute = Attribute.IsDefined(ca, typeof(MyBlockContainerAttribute));
if (!hasAttribute) continue;
return ca.GetValue(page) as ContentArea;
}
return null;
}
/// <summary>
/// Checks if a Property has the MyConfigAttribute
/// </summary>
/// <param name="property"></param>
/// <returns></returns>
private static bool isMyConfigProperty(PropertyInfo property)
{
var isMyConfigProperty = Attribute.IsDefined(property, typeof(MyConfigAttribute));
return property.PropertyType.Namespace != null && isMyConfigProperty;
}
private static string GetJsonKey(PropertyInfo property)
{
var jsonKey = (MyConfigAttribute)
Attribute.GetCustomAttribute(property, typeof(MyConfigAttribute));
return jsonKey.JsonKey ?? property.Name.ToLower();
}
private static object GetJsonValue(PropertyInfo property, IReadOnly type)
{
var attribute = (MyConfigAttribute)
Attribute.GetCustomAttribute(property, typeof(MyConfigAttribute));
var propertyValue = property.GetValue(type, null);
if (attribute.NeedsConvertedValue)
{
return GetConvertedValue(propertyValue.ToString());
}
return propertyValue;
}
/// <summary>
/// Gets a converted value that will be used in the frontend
/// Example, if the editor chooses Full layout in Episerver, the frontend will render
/// the block with a section tag, so we need to get the correct value from the dict.
/// </summary>
/// <param name="key"></param>
/// <returns></returns>
/// TODO: MOVE THIS TO PROPER LOCATION
private static string GetConvertedValue(string key)
{
key = key.ToLower();
var dict = new Dictionary<string, string>
{
{"half", "aside"},
{"full", "section"},
{"0", "subscription"},
{"1", "customer"}
};
return dict.ContainsKey(key) ? dict[key] : key;
}
#endregion
}
public static class MyPageViewModel
{
/// <summary>
/// Returns a PageViewModel of type <typeparam name="T"/>.
/// </summary>
/// <remarks>
/// Convenience method for creating PageViewModels without having to specify the type as methods can use type inference while constructors cannot.
/// </remarks>
public static MyPageViewModel<T> Create<T>(T page) where T : MyPage
{
return new MyPageViewModel<T>(page);
}
}
MyAddonsPage.cshtml
Here we are just rendering our page's heading, Contentarea and the MyConfigPartial...note that we are passing in the GetModules and GetPageProperties to the partial view.
@model MyPageViewModel<MyAddonsPage>
<h1>@Model.CurrentPage.Heading: <span>@TempData["name"]</span></h1>
<a class="back" href="@Url.PageUrl(Model.CurrentPage.GoBackLink)[email-protected]["subscriptionId"]"><i class="icon-triangle-left"></i>Go back</a>
@Html.PropertyFor(x => x.CurrentPage.MainContentArea)
@Html.Partial("PagePartials/MyConfigPartial", new MyConfigViewModel{GetModules = Model.GetModules, GetPageProperties = Model.GetPageProperties})
MyConfigViewModel.cs
Just a DTO for our MyConfigPartial...
public class MyConfigViewModel
{
public ModuleViewData GetPageProperties { get; set; }
public List<ModuleViewData> GetModules { get; set; }
}
MyConfigPartial.cshtml
Creates our scripttag containing serialized data(our blocks etc...)
@model Common.MyConfigViewModel
@{
bool inEditMode = PageEditing.PageIsInEditMode;
}
@if (!inEditMode)
{
<script type="text/javascript">
window.Config = {
currentPageId: "@TempData["currentPageId"]",
subscriptionId: "@TempData["subscriptionId"]",
name: "@TempData["name"]",
page: @Html.Raw(JsonConvert.SerializeObject(Model.GetPageProperties, Formatting.Indented)),
blocks: @Html.Raw(JsonConvert.SerializeObject(Model.GetModules, Formatting.Indented))
};
</script>
}
Results in...
Our lovely scripttag! This is used by our bootstrap.js file belowe.
window.Config={
currentPageId: "17",
subscriptionId: "1337",
name: "JOSEF",
page: {
"path": "/startpage/addons/",
"options": {
}
},
blocks: [
{
"path": "Addons",
"options": {
"heading": "My Addons heading from EPI",
"subheading": "My subheading from EPI",
"tagName": "aside",
"settings": {
}
}
},
{
"path": "AnotherBlock",
"options": {
"anotherProperty": "Text from EPI",
"tagName": "aside",
"settings": {
}
}
}
]
};
Backbone Part
Bootstrap.js
Gets all the block data from our scripttag and loads all of our modules.
// Load blocks
var _cfg = window["Config"];
// Make sure that the config is present
if (!_.isUndefined(_cfg)) {
// Make sure the cfg has the property blocks specified and that its a proper JS-object
if (_cfg.hasOwnProperty("blocks")) {
// take all the 'paths' from the array of "blocks" and require those
require(_.pluck(_cfg["blocks"], "path"), function() {
// save all the arguments as an array-like object (these are the loaded "blocks")
var requiredBlocks = arguments;
// loop through all of the blocks from the config
_.each(_cfg["blocks"], function (block, index) {
// the module we're initializing is matched by index
var module = new requiredBlocks[index](block.options);
if (module.postFormStatus) {
initializePostFormStatusFor(module);
}
if( _cfg["blocks"].length-1 === index) {
Backbone.history.start();
}
$("#main-region").append(module.$el);
}, this)
});
}
}
Config.js
Contains all of our requirements, you can see our Addons module here as well.
require.config({
baseUrl: '/static/javascripts',
paths: {
'text': 'vendor/requirejs-text/text',
'jquery': 'vendor/jquery/dist/jquery',
'underscore': 'vendor/underscore/underscore',
'backbone': 'vendor/backbone/backbone',
'scrollto': 'vendor/jquery.ScrollTo/jquery.ScrollTo',
'handlebars': 'vendor/handlebars/handlebars',
"parsley": "vendor/parsleyjs/dist/parsley",
"chartjs": "vendor/Chart.js/Chart",
'Addons': 'modules/blocks/addons/widget/addonsView',
shim: {
'underscore': {
exports: '_'
},
'backbone': {
deps: ['jquery', 'underscore'],
exports: 'Backbone'
},
'handlebars': {
exports: 'Handlebars'
},
'scrollto': {
deps: ['jquery']
},
},
config: {
text: {
useXhr: function (url, protocol, hostname, port) {
return true;
}
}
}});
addonsView.js
Here we are getting the options from the scripttag, it's passed in as an argument to the initialize function. We are getting the values from the options object and setting it to a "lang" object that we are passing to our template.
define([
'jquery',
'underscore',
'backbone',
'handlebars',
'helpers/helpers',
'modules/blocks/addons/addonsCollection',
'text!/static/html/templates/blocks/addons/addonsIndex.cshtml',
],
function ($, _, Backbone, Handlebars, helpers, addonsCollection, template) {
var addonsView = Backbone.View.extend({
tagName: 'section',
className: "block addons-page extras-shop st-loading-data",
tmpl: Handlebars.compile(template),
initialize: function (options) {
this.lang = this.getTextContent(options);
this.collection = new addonsCollection();
this.loadCollection();
this.render();
},
loadCollection: function (e) {
helpers.setLoadingDataState(this.$el, true);
this.collection.fetch({ reset: true });
},
render: function () {
this.$el.html(this.tmpl({
lang : this.lang
}));
},
getTextContent: function (options) {
var lang = {};
lang.heading = options.heading || "Addons Heading";
lang.subheading = options.subheading = "Addons subheading"
return lang;
}
});
return addonsView;
}
);
addonsIndex.cshtml
Our addonsIndex view, it's a Handlerbars template. Here we are displaying the heading and subheading that we got from EPi...it's been a long run...FINALLY ;). You may notice that the tbody is empty, we are using an ItemView for that but I've removed all code just to keep it simple here.
<header class="mono arrow-up">
<h2>{{lang.heading}}<span>{{lang.subheading}}</span></h2>
<div class="icon-area"></div>
</header>
<table class="generic-table with-border">
<thead>
<tr>
<th class="name" colspan="3">Name</th>
<th class="description">Description</th>
<th class="amount">Amount</th>
<th class="text" colspan="3">Text</th>
</tr>
</thead>
<tbody class="js-addon-item">
</tbody>
<tfoot>
<tr>
<td colspan="8"></td>
</tr>
</tfoot>
</table>
Conclusions
It seems like alot of code just to get the value of two properties and display them in a template? I agree. Another approach would maybe skip the scripttag containing all of the blocks and instead render out the basic markup of each blocks with Razor and then render out a scripttag with basic info about the block inside the razor view. That would let us have access to the Model and we could've used the standard Model.Property syntax. THEN we could let Backbone do its work and start loading data from the API.
But that would give us some other problems, like updating html markup on multiple places, both in the razor views and also in the project containing our Handlebars templates...
One more thing...
Another thing worth noting is that I came up with a very elegant(in my opinion) way to add "Settings" to a block.
I will create another blog post describing this(I've removed a lot of code in the MyPageViewModel responsible for the internal blocks) but basically it worked like this:
The idea was that everything that were more "advanced" than a simple String property should be a setting.
A setting could be a PageReference together with a string containing the Link text, or it could be a bool that hides certain things on the block, or...
I did not want to polute the option tag with settings properties directly beneath it so I needed a way to encapsulate them.
I used an internal block and then serialized the data giving me the following object
blocks: [
{
"path": "Addons",
"options": {
"heading": "My Addons heading from EPI",
"subheading": "My subheading from EPI"
"tagName": "aside",
"settings": {
"pageReferenceUrl" : "http://link-to-a-page-com",
"linkText" : "Test link text"
}
}
}
]
The addons block would've looked like this when containing a settingsblock:
[ContentType(
DisplayName = "Installed addons list", GUID = "c844a285-7d2c-48e9-b5c3-45cc68a4ba88",
Description = "Block showing installed addons for the subscription",
GroupName = Constants.ContentTypeGroupNames.MyAddons)]
[MyModule(Path = "Addons")]
public class MyAddonsInstalledBlock: MyWidgetBaseBlock
{
[Display(GroupName = Constants.PropertyGroupNames.Content]
[CultureSpecific]
[MyConfig(JsonKey = "heading")]
public virtual string Heading {get;set;}
[Display(GroupName = Constants.PropertyGroupNames.Content]
[CultureSpecific]
[MyConfig(JsonKey = "subheading")]
public virtual string SubHeading {get;set;}
[Display(GroupName = "Blocksettings")]
[MySettingsProperty]
public virtual MyAddonsSettingsBlock {get; set;}
}
And the MyAddonsSettingsBlock would've looked like this
[ContentType(
DisplayName = "Addons settings block"
GroupName = Constants.ContentTypeGroupNames.Settings)]
[MySettingsBlock(JsonKey = "Settings")]
public class MyAddonsSettingsBlock: MySettingsBaseBlock
{
[MySettingsProperty(JsonKey = "pageReferenceUrl")]
public virtual PageReference PageReferenceUrl {get;set;}
[MySettingsProperty(JsonKey = "linkText")]
public virtual string LinkText {get;set;}
}